commit 8c78a7d4c25f2ce684d9b340d32ef1e15c975906 Author: Archipelago Date: Wed Aug 12 10:55:49 2026 +0000 Archipelago — open-source initial import diff --git a/.dockerignore b/.dockerignore new file mode 100644 index 00000000..f323aac2 --- /dev/null +++ b/.dockerignore @@ -0,0 +1,28 @@ +# Ignore everything except what the demo Dockerfiles need +* + +# Allow neode-ui (frontend + mock backend + docker configs) +!neode-ui/ + +# Allow demo assets (AIUI pre-built dist) +!demo/ + +# Allow the Bitcoin UI + ElectrumX UI mock shells (served from /docker/*) +!docker/ +docker/* +!docker/bitcoin-ui/ +!docker/electrs-ui/ +!docker/lnd-ui/ +!docker/fedimint-ui/ + +# Allow backend source for ISO source builds +!core/ +!scripts/ +!image-recipe/ +image-recipe/build/ +image-recipe/results/ +image-recipe/output/ + +# Exclude nested node_modules (will npm install in container) +neode-ui/node_modules +neode-ui/dist diff --git a/.gitea/workflows/demo-images.yml b/.gitea/workflows/demo-images.yml new file mode 100644 index 00000000..0471538b --- /dev/null +++ b/.gitea/workflows/demo-images.yml @@ -0,0 +1,74 @@ +name: Demo images + +# Builds and pushes the public-demo images on every change to the UI / mock +# backend, so the separated `archy-demo` Portainer stack auto-tracks the real +# code (see demo-deploy/ and docs/demo-deployment-design.md). +# +# Required repo configuration: +# vars.DEMO_REGISTRY e.g. 146.59.87.168:3000/lfg2025 +# vars.DEMO_REGISTRY_HOST registry host for docker login (no org suffix) +# secrets.DEMO_REGISTRY_USER +# secrets.DEMO_REGISTRY_TOKEN +# Optional: +# secrets.PORTAINER_WEBHOOK redeploy hook called after a successful push + +on: + push: + branches: [main] + paths: + - 'neode-ui/**' + - 'docker-compose.demo.yml' + - '.github/workflows/demo-images.yml' + workflow_dispatch: + +jobs: + build: + name: Build & push demo images + runs-on: ubuntu-latest + # Skip cleanly on forks / before registry config is set. + if: ${{ vars.DEMO_REGISTRY != '' }} + steps: + - name: Checkout + uses: actions/checkout@v4 + + - name: Set up Docker Buildx + uses: docker/setup-buildx-action@v3 + with: + # The demo registry is plain HTTP — teach buildkit to push without TLS + # (the host docker daemon needs it in insecure-registries for login too). + buildkitd-config-inline: | + [registry."${{ vars.DEMO_REGISTRY_HOST || vars.DEMO_REGISTRY }}"] + http = true + + - name: Log in to registry + uses: docker/login-action@v3 + with: + registry: ${{ vars.DEMO_REGISTRY_HOST || vars.DEMO_REGISTRY }} + username: ${{ secrets.DEMO_REGISTRY_USER }} + password: ${{ secrets.DEMO_REGISTRY_TOKEN }} + + - name: Build & push backend + uses: docker/build-push-action@v6 + with: + context: . + file: neode-ui/Dockerfile.backend + push: true + tags: | + ${{ vars.DEMO_REGISTRY }}/archy-demo-backend:demo + ${{ vars.DEMO_REGISTRY }}/archy-demo-backend:${{ github.sha }} + + - name: Build & push web + uses: docker/build-push-action@v6 + with: + context: . + file: neode-ui/Dockerfile.web + push: true + build-args: | + VITE_DEMO=1 + tags: | + ${{ vars.DEMO_REGISTRY }}/archy-demo-web:demo + ${{ vars.DEMO_REGISTRY }}/archy-demo-web:${{ github.sha }} + + - name: Trigger Portainer redeploy + if: ${{ success() && secrets.PORTAINER_WEBHOOK != '' }} + run: curl -fsS -X POST "${{ secrets.PORTAINER_WEBHOOK }}" diff --git a/.gitea/workflows/post-install-tests.yml b/.gitea/workflows/post-install-tests.yml new file mode 100644 index 00000000..7c5c4c86 --- /dev/null +++ b/.gitea/workflows/post-install-tests.yml @@ -0,0 +1,72 @@ +name: Post-Install Tests + +on: + workflow_dispatch: + inputs: + target: + description: 'Target node IP (e.g. 192.168.1.198)' + required: true + default: '192.168.1.198' + password: + description: 'Node password (or "auto" for fresh install)' + required: false + default: 'auto' + +jobs: + post-install-tests: + runs-on: ubuntu-latest + timeout-minutes: 15 + steps: + - name: Checkout + uses: actions/checkout@v4 + with: + fetch-depth: 1 + + - name: Run post-install tests on target + run: | + TARGET="${{ github.event.inputs.target }}" + PASSWORD="${{ github.event.inputs.password }}" + if [ "$PASSWORD" = "auto" ]; then + PASSWORD="testpass123!" + fi + + echo "══════════════════════════════════════════" + echo "Running post-install tests on $TARGET" + echo "══════════════════════════════════════════" + + # Copy test script to target and run + sshpass -p 'archipelago' scp -o StrictHostKeyChecking=no \ + scripts/run-post-install-tests.sh \ + archipelago@${TARGET}:/tmp/run-post-install-tests.sh 2>/dev/null || \ + scp -o StrictHostKeyChecking=no \ + scripts/run-post-install-tests.sh \ + archipelago@${TARGET}:/tmp/run-post-install-tests.sh + + # Run tests (with sudo for service checks) + sshpass -p 'archipelago' ssh -o StrictHostKeyChecking=no \ + archipelago@${TARGET} \ + "sudo bash /tmp/run-post-install-tests.sh '$PASSWORD'" 2>/dev/null || \ + ssh -o StrictHostKeyChecking=no \ + archipelago@${TARGET} \ + "sudo bash /tmp/run-post-install-tests.sh '$PASSWORD'" + + frontend-tests: + runs-on: ubuntu-latest + timeout-minutes: 10 + steps: + - name: Checkout + uses: actions/checkout@v4 + with: + fetch-depth: 1 + + - name: Install dependencies + run: cd neode-ui && npm ci + + - name: Type check + run: cd neode-ui && npx vue-tsc -b --noEmit + + - name: Run tests + run: cd neode-ui && npx vitest run + + - name: Audit dependencies + run: cd neode-ui && npm audit --omit=dev diff --git a/.githooks/pre-push b/.githooks/pre-push new file mode 100755 index 00000000..c943bc06 --- /dev/null +++ b/.githooks/pre-push @@ -0,0 +1,51 @@ +#!/usr/bin/env bash +# Keep the served companion APK in sync with main on every push. +# +# When a push to main includes Android changes, rebuild the APK, refresh +# neode-ui/public/packages/archipelago-companion.apk, commit it, and ask +# you to push again (so the refreshed APK rides along in the same push). +# +# Enable once per clone: git config core.hooksPath .githooks +set -euo pipefail + +ROOT="$(git rev-parse --show-toplevel)" +cd "$ROOT" + +# ship-companion.sh already (re)published the APK for this push — don't redo it. +[ -n "${SHIP_COMPANION:-}" ] && exit 0 + +PUSH_MAIN=0; RANGE_OLD=""; RANGE_NEW="" +while read -r _local_ref local_sha remote_ref remote_sha; do + if [ "${remote_ref##*/}" = "main" ]; then + PUSH_MAIN=1; RANGE_OLD="$remote_sha"; RANGE_NEW="$local_sha" + fi +done +[ "$PUSH_MAIN" = "1" ] || exit 0 + +# Loop-break: if the tip is already the auto APK commit, let the push proceed. +case "$(git log -1 --pretty=%s)" in + *"companion APK"*) exit 0 ;; +esac + +# Only rebuild when this push actually touches the Android app. +ZEROS="0000000000000000000000000000000000000000" +if [ -z "$RANGE_OLD" ] || [ "$RANGE_OLD" = "$ZEROS" ]; then + ANDROID_CHANGED=1 +elif git diff --quiet "$RANGE_OLD" "$RANGE_NEW" -- Android/ 2>/dev/null; then + ANDROID_CHANGED=0 +else + ANDROID_CHANGED=1 +fi +[ "$ANDROID_CHANGED" = "1" ] || exit 0 + +bash scripts/publish-companion-apk.sh || exit 0 + +DEST="neode-ui/public/packages/archipelago-companion.apk" +if git diff --cached --quiet -- "$DEST"; then + exit 0 # APK unchanged — nothing to do +fi + +git commit -q -m "chore(android): update companion APK download [skip ci]" +echo "" >&2 +echo "▶ Companion APK rebuilt and committed. Run your push again to include it." >&2 +exit 1 diff --git a/.github/ISSUE_TEMPLATE/app_submission.yml b/.github/ISSUE_TEMPLATE/app_submission.yml new file mode 100644 index 00000000..caca79ec --- /dev/null +++ b/.github/ISSUE_TEMPLATE/app_submission.yml @@ -0,0 +1,78 @@ +name: App Submission +description: Submit an app for the Archipelago marketplace +title: "[App]: " +labels: ["app-submission"] +body: + - type: input + id: app_name + attributes: + label: App Name + placeholder: My Bitcoin App + validations: + required: true + + - type: input + id: docker_image + attributes: + label: Container Image + description: Full image reference with tag (no :latest) + placeholder: "ghcr.io/org/app:1.2.3" + validations: + required: true + + - type: textarea + id: description + attributes: + label: Description + description: What does this app do? + validations: + required: true + + - type: input + id: homepage + attributes: + label: Homepage / Repository + placeholder: "https://github.com/..." + + - type: dropdown + id: category + attributes: + label: Category + options: + - Bitcoin + - Lightning + - Privacy + - Storage + - Communication + - Development + - Other + validations: + required: true + + - type: checkboxes + id: requirements + attributes: + label: App Requirements Met + options: + - label: Runs as non-root user (UID > 1000) + required: true + - label: No `latest` tag — pinned version + required: true + - label: "Supports x86_64" + required: true + - label: "Supports ARM64" + - label: Tested on Archipelago hardware + required: true + + - type: textarea + id: ports + attributes: + label: Required Ports + description: List ports the app needs exposed + placeholder: "8080 (web UI), 9735 (Lightning)" + + - type: textarea + id: dependencies + attributes: + label: Dependencies + description: Does this app require other apps (e.g., Bitcoin, LND)? diff --git a/.github/ISSUE_TEMPLATE/bug_report.yml b/.github/ISSUE_TEMPLATE/bug_report.yml new file mode 100644 index 00000000..fddcceee --- /dev/null +++ b/.github/ISSUE_TEMPLATE/bug_report.yml @@ -0,0 +1,81 @@ +name: Bug Report +description: Report a bug in Archipelago +title: "[Bug]: " +labels: ["bug", "triage"] +body: + - type: markdown + attributes: + value: | + Thank you for reporting a bug. Please fill out the sections below. + + - type: textarea + id: description + attributes: + label: Description + description: A clear description of the bug. + placeholder: What happened? + validations: + required: true + + - type: textarea + id: steps + attributes: + label: Steps to Reproduce + description: Minimal steps to reproduce the issue. + placeholder: | + 1. Go to '...' + 2. Click on '...' + 3. See error + validations: + required: true + + - type: textarea + id: expected + attributes: + label: Expected Behavior + description: What should have happened? + validations: + required: true + + - type: textarea + id: actual + attributes: + label: Actual Behavior + description: What actually happened? + validations: + required: true + + - type: input + id: version + attributes: + label: Archipelago Version + description: Check Settings page or run `archipelago --version` + placeholder: "0.1.0" + validations: + required: true + + - type: dropdown + id: hardware + attributes: + label: Hardware + options: + - x86_64 (Intel/AMD) + - ARM64 (Raspberry Pi 5) + - ARM64 (Other) + - Other + validations: + required: true + + - type: textarea + id: logs + attributes: + label: Relevant Logs + description: | + Run `journalctl -u archipelago --since "1 hour ago"` and paste relevant output. + render: shell + + - type: textarea + id: screenshots + attributes: + label: Screenshots + description: If applicable, add screenshots. diff --git a/.github/ISSUE_TEMPLATE/config.yml b/.github/ISSUE_TEMPLATE/config.yml new file mode 100644 index 00000000..c2c2d00d --- /dev/null +++ b/.github/ISSUE_TEMPLATE/config.yml @@ -0,0 +1,5 @@ +blank_issues_enabled: true +contact_links: + - name: Security Vulnerability + url: mailto:security@archipelago-os.org + about: Do NOT open public issues for security vulnerabilities. Email us directly. diff --git a/.github/ISSUE_TEMPLATE/feature_request.yml b/.github/ISSUE_TEMPLATE/feature_request.yml new file mode 100644 index 00000000..2d4e125c --- /dev/null +++ b/.github/ISSUE_TEMPLATE/feature_request.yml @@ -0,0 +1,44 @@ +name: Feature Request +description: Suggest a new feature or improvement +title: "[Feature]: " +labels: ["enhancement"] +body: + - type: textarea + id: problem + attributes: + label: Problem + description: What problem does this solve? + placeholder: I'm always frustrated when... + validations: + required: true + + - type: textarea + id: solution + attributes: + label: Proposed Solution + description: How should this work? + validations: + required: true + + - type: textarea + id: alternatives + attributes: + label: Alternatives Considered + description: What other approaches did you consider? + + - type: dropdown + id: area + attributes: + label: Area + options: + - Web UI + - Backend / API + - App Management + - Networking + - Security + - Web5 / Identity + - ISO / Installation + - Documentation + - Other + validations: + required: true diff --git a/.github/pull_request_template.md b/.github/pull_request_template.md new file mode 100644 index 00000000..beacd6d0 --- /dev/null +++ b/.github/pull_request_template.md @@ -0,0 +1,16 @@ +## Summary + + + +## Changes + +- + +## Checklist + +- [ ] TypeScript type-check passes (`npm run type-check`) +- [ ] Frontend builds (`npm run build`) +- [ ] Tests pass (`npm test`) +- [ ] Rust clippy clean (if backend changes) +- [ ] No new compiler warnings +- [ ] Tested on live server diff --git a/.github/workflows/build-macos.yml b/.github/workflows/build-macos.yml new file mode 100644 index 00000000..3b3678bc --- /dev/null +++ b/.github/workflows/build-macos.yml @@ -0,0 +1,219 @@ +name: macOS Production Build + +on: + push: + tags: + - 'v*' + workflow_dispatch: + inputs: + version: + description: 'Version number (e.g., 0.1.0)' + required: true + default: '0.1.0' + +env: + RUST_VERSION: stable + NODE_VERSION: 18 + +jobs: + build-macos: + name: Build macOS App + runs-on: macos-latest + + steps: + - name: Checkout repository + uses: actions/checkout@v4 + with: + fetch-depth: 0 + + - name: Set version + id: version + run: | + if [ "${{ github.event_name }}" == "workflow_dispatch" ]; then + VERSION="${{ github.event.inputs.version }}" + else + VERSION="${GITHUB_REF#refs/tags/v}" + fi + echo "VERSION=$VERSION" >> $GITHUB_OUTPUT + echo "Building version: $VERSION" + + - name: Setup Rust + uses: actions-rust-lang/setup-rust-toolchain@v1 + with: + toolchain: ${{ env.RUST_VERSION }} + components: rustfmt, clippy + + - name: Setup Node.js + uses: actions/setup-node@v4 + with: + node-version: ${{ env.NODE_VERSION }} + cache: 'npm' + cache-dependency-path: neode-ui/package-lock.json + + - name: Install frontend dependencies + working-directory: neode-ui + run: npm ci + + - name: Build Rust backend (Release) + working-directory: core + run: | + cargo build --release --workspace + strip target/release/archipelago + ls -lh target/release/archipelago + + - name: Build Vue.js frontend (Production) + working-directory: neode-ui + run: | + npm run build:production + ls -lh dist/ + + - name: Run production build script + env: + ARCHIPELAGO_VERSION: ${{ steps.version.outputs.VERSION }} + run: | + chmod +x build-macos-production.sh + ./build-macos-production.sh + + - name: Verify build artifacts + run: | + ls -lh build/macos/ + if [ ! -d "build/macos/Archipelago.app" ]; then + echo "❌ App bundle not found!" + exit 1 + fi + if [ ! -f "build/macos/Archipelago-${{ steps.version.outputs.VERSION }}-macOS.dmg" ]; then + echo "⚠️ DMG not created (optional)" + fi + + - name: Code sign (if credentials available) + if: ${{ secrets.MACOS_CERTIFICATE != '' }} + env: + MACOS_CERTIFICATE: ${{ secrets.MACOS_CERTIFICATE }} + MACOS_CERTIFICATE_PWD: ${{ secrets.MACOS_CERTIFICATE_PWD }} + KEYCHAIN_PWD: ${{ secrets.KEYCHAIN_PWD }} + run: | + # Import certificate + echo "$MACOS_CERTIFICATE" | base64 --decode > certificate.p12 + security create-keychain -p "$KEYCHAIN_PWD" build.keychain + security default-keychain -s build.keychain + security unlock-keychain -p "$KEYCHAIN_PWD" build.keychain + security import certificate.p12 -k build.keychain -P "$MACOS_CERTIFICATE_PWD" -T /usr/bin/codesign + security set-key-partition-list -S apple-tool:,apple:,codesign: -s -k "$KEYCHAIN_PWD" build.keychain + + # Sign the app + codesign --deep --force --verify --verbose \ + --sign "Developer ID Application" \ + --options runtime \ + build/macos/Archipelago.app + + # Verify + codesign --verify --verbose build/macos/Archipelago.app + + - name: Notarize (if credentials available) + if: ${{ secrets.APPLE_ID != '' }} + env: + APPLE_ID: ${{ secrets.APPLE_ID }} + APPLE_TEAM_ID: ${{ secrets.APPLE_TEAM_ID }} + APPLE_APP_PASSWORD: ${{ secrets.APPLE_APP_PASSWORD }} + run: | + # Create zip for notarization + ditto -c -k --keepParent build/macos/Archipelago.app Archipelago.zip + + # Submit for notarization + xcrun notarytool submit Archipelago.zip \ + --apple-id "$APPLE_ID" \ + --team-id "$APPLE_TEAM_ID" \ + --password "$APPLE_APP_PASSWORD" \ + --wait + + # Staple + xcrun stapler staple build/macos/Archipelago.app + + # Recreate DMG with notarized app + rm -f build/macos/Archipelago-${{ steps.version.outputs.VERSION }}-macOS.dmg + hdiutil create -volname "Archipelago ${{ steps.version.outputs.VERSION }}" \ + -srcfolder build/macos/Archipelago.app \ + -ov -format UDZO \ + build/macos/Archipelago-${{ steps.version.outputs.VERSION }}-macOS.dmg + + xcrun stapler staple build/macos/Archipelago-${{ steps.version.outputs.VERSION }}-macOS.dmg + + - name: Create checksums + working-directory: build/macos + run: | + if [ -f "Archipelago-${{ steps.version.outputs.VERSION }}-macOS.dmg" ]; then + shasum -a 256 "Archipelago-${{ steps.version.outputs.VERSION }}-macOS.dmg" > checksums.txt + fi + cat checksums.txt || echo "No DMG to checksum" + + - name: Upload build artifacts + uses: actions/upload-artifact@v4 + with: + name: Archipelago-${{ steps.version.outputs.VERSION }}-macOS + path: | + build/macos/Archipelago.app + build/macos/*.dmg + build/macos/checksums.txt + retention-days: 30 + + - name: Create GitHub Release + if: startsWith(github.ref, 'refs/tags/') + uses: softprops/action-gh-release@v1 + with: + files: | + build/macos/Archipelago-${{ steps.version.outputs.VERSION }}-macOS.dmg + build/macos/checksums.txt + draft: true + generate_release_notes: true + body: | + ## Archipelago v${{ steps.version.outputs.VERSION }} + + ### 🎉 macOS Release + + **Download**: `Archipelago-${{ steps.version.outputs.VERSION }}-macOS.dmg` + + ### Installation + 1. Download the DMG file + 2. Open and drag Archipelago to Applications + 3. Install [Docker Desktop](https://www.docker.com/products/docker-desktop) + 4. Launch Archipelago + + ### What's New + See [CHANGELOG.md](https://github.com/${{ github.repository }}/blob/main/CHANGELOG.md) + + ### System Requirements + - macOS 10.15 (Catalina) or later + - 8GB RAM minimum (16GB recommended) + - Docker Desktop 23.0+ + + ### Checksums + See `checksums.txt` for SHA-256 verification + env: + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} + + test-build: + name: Test Build (No Artifacts) + runs-on: macos-latest + if: github.event_name == 'push' && !startsWith(github.ref, 'refs/tags/') + + steps: + - name: Checkout + uses: actions/checkout@v4 + + - name: Setup Rust + uses: actions-rust-lang/setup-rust-toolchain@v1 + + - name: Setup Node.js + uses: actions/setup-node@v4 + with: + node-version: ${{ env.NODE_VERSION }} + + - name: Test backend build + working-directory: core + run: cargo build --release + + - name: Test frontend build + working-directory: neode-ui + run: | + npm ci + npm run build:production diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 00000000..88697e19 --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,65 @@ +name: CI + +on: + push: + branches: [main] + pull_request: + branches: [main] + +env: + RUST_VERSION: stable + NODE_VERSION: 18 + +jobs: + rust: + name: Rust (fmt + clippy + test) + runs-on: ubuntu-latest + defaults: + run: + working-directory: core + + steps: + - name: Checkout + uses: actions/checkout@v4 + + - name: Setup Rust + uses: actions-rust-lang/setup-rust-toolchain@v1 + with: + toolchain: ${{ env.RUST_VERSION }} + components: rustfmt, clippy + + - name: Check formatting + run: cargo fmt --all -- --check + + - name: Clippy + run: cargo clippy --all-targets --all-features -- -D warnings + + - name: Tests + run: cargo test --all-features + + frontend: + name: Frontend (type-check + lint) + runs-on: ubuntu-latest + defaults: + run: + working-directory: neode-ui + + steps: + - name: Checkout + uses: actions/checkout@v4 + + - name: Setup Node.js + uses: actions/setup-node@v4 + with: + node-version: ${{ env.NODE_VERSION }} + cache: 'npm' + cache-dependency-path: neode-ui/package-lock.json + + - name: Install dependencies + run: npm ci + + - name: Type check + run: npm run type-check + + - name: Build + run: npm run build diff --git a/.github/workflows/demo-images.yml b/.github/workflows/demo-images.yml new file mode 100644 index 00000000..0471538b --- /dev/null +++ b/.github/workflows/demo-images.yml @@ -0,0 +1,74 @@ +name: Demo images + +# Builds and pushes the public-demo images on every change to the UI / mock +# backend, so the separated `archy-demo` Portainer stack auto-tracks the real +# code (see demo-deploy/ and docs/demo-deployment-design.md). +# +# Required repo configuration: +# vars.DEMO_REGISTRY e.g. 146.59.87.168:3000/lfg2025 +# vars.DEMO_REGISTRY_HOST registry host for docker login (no org suffix) +# secrets.DEMO_REGISTRY_USER +# secrets.DEMO_REGISTRY_TOKEN +# Optional: +# secrets.PORTAINER_WEBHOOK redeploy hook called after a successful push + +on: + push: + branches: [main] + paths: + - 'neode-ui/**' + - 'docker-compose.demo.yml' + - '.github/workflows/demo-images.yml' + workflow_dispatch: + +jobs: + build: + name: Build & push demo images + runs-on: ubuntu-latest + # Skip cleanly on forks / before registry config is set. + if: ${{ vars.DEMO_REGISTRY != '' }} + steps: + - name: Checkout + uses: actions/checkout@v4 + + - name: Set up Docker Buildx + uses: docker/setup-buildx-action@v3 + with: + # The demo registry is plain HTTP — teach buildkit to push without TLS + # (the host docker daemon needs it in insecure-registries for login too). + buildkitd-config-inline: | + [registry."${{ vars.DEMO_REGISTRY_HOST || vars.DEMO_REGISTRY }}"] + http = true + + - name: Log in to registry + uses: docker/login-action@v3 + with: + registry: ${{ vars.DEMO_REGISTRY_HOST || vars.DEMO_REGISTRY }} + username: ${{ secrets.DEMO_REGISTRY_USER }} + password: ${{ secrets.DEMO_REGISTRY_TOKEN }} + + - name: Build & push backend + uses: docker/build-push-action@v6 + with: + context: . + file: neode-ui/Dockerfile.backend + push: true + tags: | + ${{ vars.DEMO_REGISTRY }}/archy-demo-backend:demo + ${{ vars.DEMO_REGISTRY }}/archy-demo-backend:${{ github.sha }} + + - name: Build & push web + uses: docker/build-push-action@v6 + with: + context: . + file: neode-ui/Dockerfile.web + push: true + build-args: | + VITE_DEMO=1 + tags: | + ${{ vars.DEMO_REGISTRY }}/archy-demo-web:demo + ${{ vars.DEMO_REGISTRY }}/archy-demo-web:${{ github.sha }} + + - name: Trigger Portainer redeploy + if: ${{ success() && secrets.PORTAINER_WEBHOOK != '' }} + run: curl -fsS -X POST "${{ secrets.PORTAINER_WEBHOOK }}" diff --git a/.gitignore b/.gitignore new file mode 100644 index 00000000..53492bb0 --- /dev/null +++ b/.gitignore @@ -0,0 +1,98 @@ +# SSH keys (sandbox copies) +.ssh/ + +# Rust build output +target/ +**/target/ +Cargo.lock + +# Node.js +node_modules/ +**/node_modules/ +npm-debug.log* +yarn-debug.log* +yarn-error.log* +package-lock.json +pnpm-debug.log* + +# Build outputs +dist/ +dist-ssr/ +build/ +*.local + +# IDE / editor +.idea/ +.vscode/ +*.swp +*.swo +*~ +.DS_Store + +# Environment and local overrides +.env +.env.local +.env.*.local +scripts/deploy-config.sh + +# Logs +logs/ +*.log + +# OS +.DS_Store +Thumbs.db + +# Testing +coverage/ +.nyc_output/ + +# Temporary files +*.tmp +*.temp + +# Build artifacts +*.iso +*.img +*.dmg +*.app + +# Release artifacts live in Gitea Release attachments, not Git history. +releases/** +!releases/ +!releases/manifest.json + +# macOS build output +build/macos/ + +# Image recipe output +image-recipe/output/ +image-recipe/*.iso +image-recipe/*.img + +# Loop tool artifacts (created in every subdirectory) +*/loop/ +loop/loop/ +loop/loop.log.bak + +# Separate repos nested in tree +web/ + +._* + +# Resilience harness reports (generated, contains session cookies) +scripts/resilience/reports/ + +# Codex / pnpm / python caches / editor backups +.codex +.codex-target-*/ +.codex-tmp/ +.pnpm-store/ +**/__pycache__/ +*.bak +.claude/scheduled_tasks.lock + +# Local evidence screenshots; intentional UI screenshots should live under an +# app/docs asset path with a descriptive filename. +Screenshot *.png +uploads/ diff --git a/.gitmodules b/.gitmodules new file mode 100644 index 00000000..b79b5f6c --- /dev/null +++ b/.gitmodules @@ -0,0 +1,3 @@ +[submodule "indeedhub"] + path = indeedhub + url = http://146.59.87.168:3000/lfg2025/indeehub.git diff --git a/Android/.gitignore b/Android/.gitignore new file mode 100644 index 00000000..4cac341c --- /dev/null +++ b/Android/.gitignore @@ -0,0 +1,21 @@ +*.iml +.gradle +/local.properties +/.idea +.DS_Store +/build +/captures +.externalNativeBuild +.cxx +local.properties +/app/build +/app/release +*.apk +*.aab +*.jks +*.keystore +# Exception: the repo-dedicated *debug* keystore is committed on purpose so every +# machine (and the published companion download) signs debug builds identically — +# updates then install over the top without an uninstall. Debug keys are not +# secret (well-known password "android"); never commit a real release keystore. +!/app/debug.keystore diff --git a/Android/COMPANION_RELEASE.md b/Android/COMPANION_RELEASE.md new file mode 100644 index 00000000..1ef25999 --- /dev/null +++ b/Android/COMPANION_RELEASE.md @@ -0,0 +1,94 @@ +# Companion App — Build, Ship & "App Not Installed" Runbook + +Canonical procedure for releasing the Archipelago Companion Android app and for +debugging install failures. Read this before touching the companion release flow. +Hard lessons from 2026-06-26 are baked in below — don't relearn them. + +## Ship the companion (the only sanctioned way) + +```bash +./Android/ship-companion.sh +``` + +This calls `scripts/publish-companion-apk.sh` (the single source of truth, also +used by the `.githooks/pre-push` hook), which: + +1. **Removes/rejects resource dirs whose names contain spaces.** Empty stray + `mipmap-* NNN` dirs (left by icon-export tools) break a *clean* build with + `Invalid resource directory name`. Incremental builds hide them — clean builds + don't. +2. **Always does a CLEAN build** (`:app:clean :app:assembleDebug`). +3. **Forces v1 + v2 + v3 signing** via `zipalign` + `apksigner`. +4. **Verifies all three schemes** (`apksigner verify --min-sdk-version 21`) and + **aborts** if any is missing. +5. Stages the signed APK at `neode-ui/public/packages/archipelago-companion.apk`, + commits, and pushes with `SHIP_COMPANION=1` (the sanctioned pre-push bypass). + +**Never** hand-roll `gradlew assembleDebug` + `cp` to the served path. That path +skips the clean build and the signature enforcement and is exactly how a broken +APK shipped. + +### Bump the version first +Edit `Android/app/build.gradle.kts` — `versionCode` (must strictly increase) and +`versionName`. The committed value can drift AHEAD of what's actually built into +the served APK, so verify the served APK's real version after shipping: +`aapt2 dump badging neode-ui/public/packages/archipelago-companion.apk | grep version`. + +## Signing facts (important) + +- Debug builds are signed with the **committed** `Android/app/debug.keystore` + (store/key pass `android`, alias `androiddebugkey`) so every machine and the + served download share ONE signing key. Cert SHA-256: `D6:22:E0:7E:…:66:4D`. +- **AGP silently ignores `enableV1Signing = true` for `minSdk ≥ 24`**, so a plain + gradle build produces a **v2-only** APK. The `apksigner` step in the publish + script is what actually guarantees v1+v2+v3 — do not remove it. +- **Changing the signing key forces every existing install to be uninstalled + once.** Android blocks in-place upgrades across different signatures. Treat the + keystore as permanent; never regenerate it casually. + +## Debugging "App Not Installed" — DIAGNOSE FIRST + +Do **not** theorize about signing schemes / OEM quirks. Get the real reason: + +```bash +adb install ~/Desktop/archipelago-companion-.apk +# -> Failure [INSTALL_FAILED_: ...] +``` + +Map the reason: + +| `INSTALL_FAILED_*` | Cause | Fix | +|---|---|---| +| `UPDATE_INCOMPATIBLE … signatures do not match` | Old install signed with a **different key** (e.g. pre-shared-keystore per-machine key `58:31:12…`). | Uninstall the old package, then install. **One-time** per device after a key change. | +| `INVALID_APK` / parse error | Corrupt/incomplete download or bad signing. | Re-download; re-run the publish script. | +| `INSUFFICIENT_STORAGE` | Storage. | Free space. | +| `OLDER_SDK` | Device below `minSdk` (26 = Android 8.0). | Unsupported device. | + +> A manual uninstall on the phone may NOT clear `UPDATE_INCOMPATIBLE` if the +> package is registered under another user/profile — `pm path ` under user 0 +> can show nothing while the conflict persists. `adb uninstall ` clears it +> across all users. + +## Phone / adb safety (non-negotiable) + +When acting on the user's physical phone, be surgical — the user once had all +home-screen app layouts wiped by an over-broad action. + +- Default to **read-only** adb (`devices`, `getprop`, `pm path/list`, `dumpsys`). +- Mutations (`adb install`, `adb uninstall com.archipelago.app.debug`) only with + explicit go-ahead and **scoped to our exact package** — echo it first. +- **Never** run launcher/system resets: no `pm clear` on launchers, no + `reset-permissions`, no factory wipe, no uninstalling apps you didn't build. + +## Verify the published download after shipping + +The download served to nodes is Gitea raw-on-main. Confirm the live bytes match +what you built and signed: + +```bash +SERVED=neode-ui/public/packages/archipelago-companion.apk +URL=http://146.59.87.168:3000/lfg2025/archy/raw/branch/main/$SERVED +curl -sS -o /tmp/live.apk "$URL" +shasum -a 256 "$SERVED" /tmp/live.apk # must match +apksigner verify -v --min-sdk-version 21 /tmp/live.apk | grep -i "scheme" # v1/v2/v3 = true +``` diff --git a/Android/app/build.gradle.kts b/Android/app/build.gradle.kts new file mode 100644 index 00000000..2215d582 --- /dev/null +++ b/Android/app/build.gradle.kts @@ -0,0 +1,116 @@ +plugins { + id("com.android.application") + id("org.jetbrains.kotlin.android") +} + +android { + namespace = "com.archipelago.app" + compileSdk = 35 + + defaultConfig { + applicationId = "com.archipelago.app" + minSdk = 26 + targetSdk = 35 + versionCode = 16 + versionName = "0.4.12" + + vectorDrawables { + useSupportLibrary = true + } + } + + signingConfigs { + // Repo-dedicated debug keystore (committed at app/debug.keystore) so every + // machine — and the published companion download — signs debug builds with + // the SAME key. Without this, Gradle falls back to each machine's + // ~/.android/debug.keystore, so a build from a different machine has a + // different signature and the phone rejects the update ("App not installed"). + getByName("debug") { + storeFile = file("debug.keystore") + storePassword = "android" + keyAlias = "androiddebugkey" + keyPassword = "android" + // Force both legacy JAR (v1) and APK Signature Scheme v2. AGP drops v1 + // for minSdk>=24, but some OEM package installers (e.g. Samsung) reject + // a v2-only sideload with "App not installed" — keep v1 for max compat. + enableV1Signing = true + enableV2Signing = true + } + } + + buildTypes { + debug { + // Separate app ID so a debug/test build installs alongside the + // release app instead of colliding on signature. + applicationIdSuffix = ".debug" + versionNameSuffix = "-debug" + signingConfig = signingConfigs.getByName("debug") + } + release { + isMinifyEnabled = true + isShrinkResources = true + proguardFiles( + getDefaultProguardFile("proguard-android-optimize.txt"), + "proguard-rules.pro" + ) + } + } + + compileOptions { + sourceCompatibility = JavaVersion.VERSION_17 + targetCompatibility = JavaVersion.VERSION_17 + } + + kotlinOptions { + jvmTarget = "17" + } + + buildFeatures { + compose = true + } + + composeOptions { + kotlinCompilerExtensionVersion = "1.5.14" + } + + packaging { + resources { + excludes += "/META-INF/{AL2.0,LGPL2.1}" + } + } +} + +dependencies { + val composeBom = platform("androidx.compose:compose-bom:2024.05.00") + implementation(composeBom) + + implementation("androidx.core:core-ktx:1.13.1") + implementation("androidx.lifecycle:lifecycle-runtime-ktx:2.8.2") + implementation("androidx.activity:activity-compose:1.9.0") + + // Compose + implementation("androidx.compose.ui:ui") + implementation("androidx.compose.ui:ui-graphics") + implementation("androidx.compose.ui:ui-tooling-preview") + implementation("androidx.compose.material3:material3") + implementation("androidx.compose.material:material-icons-extended") + implementation("androidx.compose.animation:animation") + + // Navigation + implementation("androidx.navigation:navigation-compose:2.7.7") + + // DataStore for preferences + implementation("androidx.datastore:datastore-preferences:1.1.1") + + // WebView + implementation("androidx.webkit:webkit:1.11.0") + + // Splash screen + implementation("androidx.core:core-splashscreen:1.0.1") + + // OkHttp for WebSocket (remote input) + implementation("com.squareup.okhttp3:okhttp:4.12.0") + + debugImplementation("androidx.compose.ui:ui-tooling") + debugImplementation("androidx.compose.ui:ui-test-manifest") +} diff --git a/Android/app/debug.keystore b/Android/app/debug.keystore new file mode 100644 index 00000000..d99c47cf Binary files /dev/null and b/Android/app/debug.keystore differ diff --git a/Android/app/proguard-rules.pro b/Android/app/proguard-rules.pro new file mode 100644 index 00000000..158946a7 --- /dev/null +++ b/Android/app/proguard-rules.pro @@ -0,0 +1,7 @@ +# Keep WebView JavaScript interface +-keepclassmembers class com.archipelago.app.ui.screens.WebViewScreen$* { + public *; +} + +# Keep Compose +-dontwarn androidx.compose.** diff --git a/Android/app/src/main/AndroidManifest.xml b/Android/app/src/main/AndroidManifest.xml new file mode 100644 index 00000000..8759972a --- /dev/null +++ b/Android/app/src/main/AndroidManifest.xml @@ -0,0 +1,32 @@ + + + + + + + + + + + + + + + + + diff --git a/Android/app/src/main/assets/connect.html b/Android/app/src/main/assets/connect.html new file mode 100644 index 00000000..d4db1f8f --- /dev/null +++ b/Android/app/src/main/assets/connect.html @@ -0,0 +1,492 @@ + + + + + +Archipelago + + + + + +
+
+ + + + + + + + + + + + + + + + + + + + + + + +
+ Archipelago +

Your Sovereign
Personal Server

+

Bitcoin node, app platform, and private cloud — all in one box you control.

+ +
+ + + + + + + + + + diff --git a/Android/app/src/main/java/com/archipelago/app/ArchipelagoApp.kt b/Android/app/src/main/java/com/archipelago/app/ArchipelagoApp.kt new file mode 100644 index 00000000..ed001ab1 --- /dev/null +++ b/Android/app/src/main/java/com/archipelago/app/ArchipelagoApp.kt @@ -0,0 +1,5 @@ +package com.archipelago.app + +import android.app.Application + +class ArchipelagoApp : Application() diff --git a/Android/app/src/main/java/com/archipelago/app/MainActivity.kt b/Android/app/src/main/java/com/archipelago/app/MainActivity.kt new file mode 100644 index 00000000..e268d489 --- /dev/null +++ b/Android/app/src/main/java/com/archipelago/app/MainActivity.kt @@ -0,0 +1,22 @@ +package com.archipelago.app + +import android.os.Bundle +import androidx.activity.ComponentActivity +import androidx.activity.compose.setContent +import androidx.activity.enableEdgeToEdge +import androidx.core.splashscreen.SplashScreen.Companion.installSplashScreen +import com.archipelago.app.ui.navigation.AppNavHost +import com.archipelago.app.ui.theme.ArchipelagoTheme + +class MainActivity : ComponentActivity() { + override fun onCreate(savedInstanceState: Bundle?) { + installSplashScreen() + enableEdgeToEdge() + super.onCreate(savedInstanceState) + setContent { + ArchipelagoTheme { + AppNavHost() + } + } + } +} diff --git a/Android/app/src/main/java/com/archipelago/app/data/ServerPreferences.kt b/Android/app/src/main/java/com/archipelago/app/data/ServerPreferences.kt new file mode 100644 index 00000000..255f0b05 --- /dev/null +++ b/Android/app/src/main/java/com/archipelago/app/data/ServerPreferences.kt @@ -0,0 +1,167 @@ +package com.archipelago.app.data + +import android.content.Context +import androidx.datastore.core.DataStore +import androidx.datastore.preferences.core.Preferences +import androidx.datastore.preferences.core.booleanPreferencesKey +import androidx.datastore.preferences.core.edit +import androidx.datastore.preferences.core.stringPreferencesKey +import androidx.datastore.preferences.core.stringSetPreferencesKey +import androidx.datastore.preferences.preferencesDataStore +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.map + +private val Context.dataStore: DataStore by preferencesDataStore(name = "server_prefs") + +data class ServerEntry( + val address: String, + val useHttps: Boolean, + val port: String = "", + val password: String = "", + val name: String = "", +) { + /** Label to show in lists — the user-given name, or the address if unnamed. */ + fun displayName(): String = name.ifBlank { address } + + fun toUrl(): String { + val scheme = if (useHttps) "https" else "http" + val portSuffix = if (port.isNotBlank()) ":$port" else "" + return "$scheme://$address$portSuffix" + } + + fun toWsUrl(): String { + val scheme = if (useHttps) "wss" else "ws" + val portSuffix = if (port.isNotBlank()) ":$port" else "" + return "$scheme://$address$portSuffix" + } + + // name is the trailing field so entries saved before naming existed + // (4 fields) still deserialize, with name defaulting to "". + fun serialize(): String = "$address|$useHttps|$port|$password|$name" + + companion object { + fun deserialize(raw: String): ServerEntry? { + val parts = raw.split("|") + if (parts.size < 2) return null + return ServerEntry( + address = parts[0], + useHttps = parts[1].toBooleanStrictOrNull() ?: false, + port = parts.getOrElse(2) { "" }, + password = parts.getOrElse(3) { "" }, + name = parts.getOrElse(4) { "" }, + ) + } + } +} + +class ServerPreferences(private val context: Context) { + + private val activeAddressKey = stringPreferencesKey("active_address") + private val activeHttpsKey = booleanPreferencesKey("active_https") + private val activePortKey = stringPreferencesKey("active_port") + private val activePasswordKey = stringPreferencesKey("active_password") + private val activeNameKey = stringPreferencesKey("active_name") + private val savedServersKey = stringSetPreferencesKey("saved_servers") + private val introSeenKey = booleanPreferencesKey("intro_seen") + + val activeServer: Flow = context.dataStore.data.map { prefs -> + val address = prefs[activeAddressKey] ?: return@map null + ServerEntry( + address = address, + useHttps = prefs[activeHttpsKey] ?: false, + port = prefs[activePortKey] ?: "", + password = prefs[activePasswordKey] ?: "", + name = prefs[activeNameKey] ?: "", + ) + } + + val savedServers: Flow> = context.dataStore.data.map { prefs -> + val raw = prefs[savedServersKey] ?: emptySet() + raw.mapNotNull { ServerEntry.deserialize(it) } + } + + val introSeen: Flow = context.dataStore.data.map { prefs -> + prefs[introSeenKey] ?: false + } + + suspend fun setActiveServer(server: ServerEntry) { + context.dataStore.edit { prefs -> + prefs[activeAddressKey] = server.address + prefs[activeHttpsKey] = server.useHttps + prefs[activePortKey] = server.port + prefs[activePasswordKey] = server.password + prefs[activeNameKey] = server.name + } + addSavedServer(server) + } + + suspend fun clearActiveServer() { + context.dataStore.edit { prefs -> + prefs.remove(activeAddressKey) + prefs.remove(activeHttpsKey) + prefs.remove(activePortKey) + prefs.remove(activePasswordKey) + prefs.remove(activeNameKey) + } + } + + suspend fun addSavedServer(server: ServerEntry) { + context.dataStore.edit { prefs -> + val current = prefs[savedServersKey] ?: emptySet() + prefs[savedServersKey] = current + server.serialize() + } + } + + /** + * Replace a saved server in place. Matches the existing entry by connection + * identity (address/port/scheme) so edits that change the name or password — + * or that touch a legacy 4-field entry — still update the right record. If the + * edited server is also the active one, the active record is kept in sync. + */ + suspend fun updateSavedServer(original: ServerEntry, updated: ServerEntry) { + context.dataStore.edit { prefs -> + val current = prefs[savedServersKey] ?: emptySet() + val filtered = current.filterNot { raw -> + val e = ServerEntry.deserialize(raw) + e != null && + e.address == original.address && + e.port == original.port && + e.useHttps == original.useHttps + }.toSet() + prefs[savedServersKey] = filtered + updated.serialize() + + val isActive = prefs[activeAddressKey] == original.address && + (prefs[activePortKey] ?: "") == original.port && + (prefs[activeHttpsKey] ?: false) == original.useHttps + if (isActive) { + prefs[activeAddressKey] = updated.address + prefs[activeHttpsKey] = updated.useHttps + prefs[activePortKey] = updated.port + prefs[activePasswordKey] = updated.password + prefs[activeNameKey] = updated.name + } + } + } + + suspend fun removeSavedServer(server: ServerEntry) { + context.dataStore.edit { prefs -> + val current = prefs[savedServersKey] ?: emptySet() + // Match by connection identity (address/port/scheme) rather than the + // exact serialized string, so a rename — or the legacy 4-field format + // saved before names existed — still removes the right entry. + prefs[savedServersKey] = current.filterNot { raw -> + val e = ServerEntry.deserialize(raw) + e != null && + e.address == server.address && + e.port == server.port && + e.useHttps == server.useHttps + }.toSet() + } + } + + suspend fun markIntroSeen() { + context.dataStore.edit { prefs -> + prefs[introSeenKey] = true + } + } +} diff --git a/Android/app/src/main/java/com/archipelago/app/network/InputWebSocket.kt b/Android/app/src/main/java/com/archipelago/app/network/InputWebSocket.kt new file mode 100644 index 00000000..671a7af7 --- /dev/null +++ b/Android/app/src/main/java/com/archipelago/app/network/InputWebSocket.kt @@ -0,0 +1,203 @@ +package com.archipelago.app.network + +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.Job +import kotlinx.coroutines.delay +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.launch +import kotlinx.coroutines.withContext +import okhttp3.MediaType.Companion.toMediaType +import okhttp3.OkHttpClient +import okhttp3.Request +import okhttp3.RequestBody.Companion.toRequestBody +import okhttp3.Response +import okhttp3.WebSocket +import okhttp3.WebSocketListener +import java.security.cert.X509Certificate +import java.util.concurrent.TimeUnit +import javax.net.ssl.SSLContext +import javax.net.ssl.X509TrustManager + +enum class ConnectionState { DISCONNECTED, CONNECTING, CONNECTED, AUTH_FAILED, ERROR } + +class InputWebSocket( + private val scope: CoroutineScope, +) { + private var ws: WebSocket? = null + private var reconnectJob: Job? = null + private var reconnectAttempt = 0 + private var serverUrl: String = "" + private var password: String = "" + private var sessionCookie: String? = null + + /** Player ID for arcade mode (0 = broadcast, 1 = P1, 2 = P2) */ + var playerId: Int = 0 + + /** + * Invoked when the kiosk asks us to open a URL in the phone's default + * browser ({"t":"o","url":"…"}). "Open in external browser" apps can't be + * usefully opened on the kiosk, so the kiosk forwards them here. + */ + var onExternalOpen: ((String) -> Unit)? = null + + private val _state = MutableStateFlow(ConnectionState.DISCONNECTED) + val state: StateFlow = _state + + private val trustManager = object : X509TrustManager { + override fun checkClientTrusted(chain: Array?, authType: String?) {} + override fun checkServerTrusted(chain: Array?, authType: String?) {} + override fun getAcceptedIssuers(): Array = arrayOf() + } + + private val client: OkHttpClient by lazy { + val sc = SSLContext.getInstance("TLS") + sc.init(null, arrayOf(trustManager), java.security.SecureRandom()) + + OkHttpClient.Builder() + .sslSocketFactory(sc.socketFactory, trustManager) + .hostnameVerifier { _, _ -> true } + .pingInterval(30, TimeUnit.SECONDS) + .readTimeout(0, TimeUnit.MILLISECONDS) + .connectTimeout(10, TimeUnit.SECONDS) + .build() + } + + fun connect(httpUrl: String, pwd: String = "") { + disconnect() + serverUrl = httpUrl + password = pwd + sessionCookie = null + reconnectAttempt = 0 + scope.launch(Dispatchers.IO) { doAuth() } + } + + private suspend fun doAuth() { + _state.value = ConnectionState.CONNECTING + + if (password.isBlank()) { + doConnect() + return + } + + try { + val body = """{"method":"auth.login","params":{"password":"$password"}}""" + .toRequestBody("application/json".toMediaType()) + val req = Request.Builder() + .url("$serverUrl/rpc/v1") + .post(body) + .build() + + val response = withContext(Dispatchers.IO) { client.newCall(req).execute() } + + if (response.isSuccessful) { + sessionCookie = response.headers("Set-Cookie") + .mapNotNull { cookie -> + cookie.split(";") + .firstOrNull() + ?.trim() + ?.takeIf { it.startsWith("session=") } + ?.removePrefix("session=") + } + .firstOrNull() + response.close() + + if (sessionCookie != null) { + doConnect() + } else { + _state.value = ConnectionState.AUTH_FAILED + } + } else { + response.close() + _state.value = ConnectionState.AUTH_FAILED + } + } catch (_: Exception) { + _state.value = ConnectionState.ERROR + scheduleReconnect() + } + } + + private fun doConnect() { + val basePath = "/ws/remote-input" + if (playerId > 0) "?p=$playerId" else "" + val wsUrl = serverUrl + .replace("https://", "wss://") + .replace("http://", "ws://") + .trimEnd('/') + basePath + + val reqBuilder = Request.Builder().url(wsUrl) + sessionCookie?.let { reqBuilder.header("Cookie", "session=$it") } + + ws = client.newWebSocket(reqBuilder.build(), object : WebSocketListener() { + override fun onOpen(webSocket: WebSocket, response: Response) { + _state.value = ConnectionState.CONNECTED + reconnectAttempt = 0 + } + + override fun onMessage(webSocket: WebSocket, text: String) { + // The only inbound message we act on is an external-open request + // forwarded from the kiosk: {"t":"o","url":"https://…"}. + try { + val obj = org.json.JSONObject(text) + if (obj.optString("t") == "o") { + val url = obj.optString("url") + if (url.startsWith("http://") || url.startsWith("https://")) { + onExternalOpen?.invoke(url) + } + } + } catch (_: Exception) {} + } + + override fun onFailure(webSocket: WebSocket, t: Throwable, response: Response?) { + _state.value = ConnectionState.ERROR + scheduleReconnect() + } + + override fun onClosing(webSocket: WebSocket, code: Int, reason: String) { + webSocket.close(1000, null) + _state.value = ConnectionState.DISCONNECTED + if (code != 1000) scheduleReconnect() + } + + override fun onClosed(webSocket: WebSocket, code: Int, reason: String) { + _state.value = ConnectionState.DISCONNECTED + } + }) + } + + private fun scheduleReconnect() { + reconnectJob?.cancel() + reconnectJob = scope.launch(Dispatchers.IO) { + val delayMs = minOf(1000L * (1 shl minOf(reconnectAttempt, 5)), 30_000L) + reconnectAttempt++ + delay(delayMs) + doAuth() + } + } + + fun disconnect() { + reconnectJob?.cancel() + ws?.close(1000, "bye") + ws = null + _state.value = ConnectionState.DISCONNECTED + } + + // ─── Input senders ────────────────────────────────────────── + + fun sendKey(key: String) { + val pField = if (playerId > 0) ""","p":$playerId""" else "" + ws?.send("""{"t":"k","k":"$key"$pField}""") + } + + fun sendMouseMove(dx: Int, dy: Int) { + ws?.send("""{"t":"m","x":$dx,"y":$dy}""") + } + + fun sendClick(button: Int = 1) { + ws?.send("""{"t":"c","b":$button}""") + } + + fun sendScroll(dy: Int) { + ws?.send("""{"t":"s","y":$dy}""") + } +} diff --git a/Android/app/src/main/java/com/archipelago/app/ui/components/ActionButtons.kt b/Android/app/src/main/java/com/archipelago/app/ui/components/ActionButtons.kt new file mode 100644 index 00000000..75c7b797 --- /dev/null +++ b/Android/app/src/main/java/com/archipelago/app/ui/components/ActionButtons.kt @@ -0,0 +1,56 @@ +package com.archipelago.app.ui.components + +import androidx.compose.foundation.background +import androidx.compose.foundation.gestures.detectTapGestures +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.input.pointer.pointerInput +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp +import com.archipelago.app.ui.theme.BitcoinOrange +import com.archipelago.app.ui.theme.Neo +import com.archipelago.app.ui.theme.neoInset +import com.archipelago.app.ui.theme.neoRaised + +private val R = 14.dp + +@Composable +fun ActionButtons( + onEscape: () -> Unit, + onEnter: () -> Unit, + modifier: Modifier = Modifier, +) { + Column(modifier = modifier, verticalArrangement = Arrangement.spacedBy(10.dp), horizontalAlignment = Alignment.CenterHorizontally) { + NeoBtn("ESC", Neo.textSecondary(), Modifier.fillMaxWidth().weight(1f), onEscape) + NeoBtn("ENTER", BitcoinOrange.copy(alpha = 0.7f), Modifier.fillMaxWidth().weight(1f), onEnter) + } +} + +@Composable +private fun NeoBtn(label: String, color: androidx.compose.ui.graphics.Color, modifier: Modifier, onClick: () -> Unit) { + var p by remember { mutableStateOf(false) } + val l = Neo.shadowLight(); val d = Neo.shadowDark() + Box( + modifier = modifier + .then(if (p) Modifier.neoInset(l, d, R, 1.dp, 2.dp) else Modifier.neoRaised(l, d, R, 2.dp, 4.dp)) + .clip(RoundedCornerShape(R)) + .background(Neo.surfaceRaised()) + .pointerInput(Unit) { detectTapGestures(onPress = { p = true; onClick(); tryAwaitRelease(); p = false }) }, + contentAlignment = Alignment.Center, + ) { + Text(label, color = if (p) color else color.copy(alpha = 0.7f), fontSize = 12.sp, fontWeight = FontWeight.Bold, letterSpacing = 1.5.sp) + } +} diff --git a/Android/app/src/main/java/com/archipelago/app/ui/components/DPad.kt b/Android/app/src/main/java/com/archipelago/app/ui/components/DPad.kt new file mode 100644 index 00000000..ec40e657 --- /dev/null +++ b/Android/app/src/main/java/com/archipelago/app/ui/components/DPad.kt @@ -0,0 +1,121 @@ +package com.archipelago.app.ui.components + +import androidx.compose.foundation.background +import androidx.compose.foundation.gestures.detectTapGestures +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.shape.CircleShape +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.automirrored.filled.KeyboardArrowLeft +import androidx.compose.material.icons.automirrored.filled.KeyboardArrowRight +import androidx.compose.material.icons.filled.KeyboardArrowDown +import androidx.compose.material.icons.filled.KeyboardArrowUp +import androidx.compose.material3.Icon +import androidx.compose.runtime.Composable +import androidx.compose.runtime.DisposableEffect +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.rememberCoroutineScope +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.graphics.vector.ImageVector +import androidx.compose.ui.input.pointer.pointerInput +import androidx.compose.ui.unit.dp +import com.archipelago.app.ui.theme.BitcoinOrange +import com.archipelago.app.ui.theme.Neo +import com.archipelago.app.ui.theme.neoInset +import com.archipelago.app.ui.theme.neoRaised +import kotlinx.coroutines.Job +import kotlinx.coroutines.delay +import kotlinx.coroutines.launch + +private val BTN = 50.dp +private val BTN_R = 12.dp +private val GAP = 8.dp +private val NOB = 24.dp + +@Composable +fun DPad( + onDirection: (String) -> Unit, + modifier: Modifier = Modifier, +) { + val surface = Neo.surface() + val raised = Neo.surfaceRaised() + val l = Neo.shadowLight() + val d = Neo.shadowDark() + + // Recessed well + Box( + modifier = modifier + .neoInset(l, d, 20.dp, 2.dp, 4.dp) + .clip(RoundedCornerShape(20.dp)) + .background(surface) + .padding(14.dp), + contentAlignment = Alignment.Center, + ) { + // Cross layout with explicit spacing + Column(horizontalAlignment = Alignment.CenterHorizontally) { + Btn(Icons.Default.KeyboardArrowUp, "Up", onDirection) + Box(modifier = Modifier.size(height = GAP, width = BTN)) // spacer + Row(verticalAlignment = Alignment.CenterVertically) { + Btn(Icons.AutoMirrored.Filled.KeyboardArrowLeft, "Left", onDirection) + Box(modifier = Modifier.size(width = GAP, height = BTN)) // spacer + // Center nob + Box( + modifier = Modifier + .size(NOB) + .neoRaised(l, d, NOB / 2, 1.dp, 2.dp) + .clip(CircleShape) + .background(raised), + contentAlignment = Alignment.Center, + ) { + Box(Modifier.size(8.dp).clip(CircleShape).background(BitcoinOrange.copy(alpha = 0.15f))) + } + Box(modifier = Modifier.size(width = GAP, height = BTN)) // spacer + Btn(Icons.AutoMirrored.Filled.KeyboardArrowRight, "Right", onDirection) + } + Box(modifier = Modifier.size(height = GAP, width = BTN)) // spacer + Btn(Icons.Default.KeyboardArrowDown, "Down", onDirection) + } + } +} + +@Composable +private fun Btn(icon: ImageVector, key: String, onDir: (String) -> Unit) { + val scope = rememberCoroutineScope() + var job by remember { mutableStateOf(null) } + var p by remember { mutableStateOf(false) } + val bg = Neo.surfaceRaised() + val l = Neo.shadowLight() + val d = Neo.shadowDark() + val tint = Neo.textPrimary() + DisposableEffect(Unit) { onDispose { job?.cancel() } } + + Box( + modifier = Modifier + .size(BTN) + .then(if (p) Modifier.neoInset(l, d, BTN_R, 1.dp, 2.dp) else Modifier.neoRaised(l, d, BTN_R, 2.dp, 4.dp)) + .clip(RoundedCornerShape(BTN_R)) + .background(bg) + .pointerInput(key) { + detectTapGestures(onPress = { + p = true; onDir(key) + // 500ms initial delay so a normal tap sends one key, not two + // (a touch tap often exceeds 350ms → doubled nav sound). + job = scope.launch { delay(500); while (true) { onDir(key); delay(100) } } + tryAwaitRelease(); p = false; job?.cancel() + }) + }, + contentAlignment = Alignment.Center, + ) { + Icon(icon, key, Modifier.fillMaxSize(0.48f), tint = if (p) tint.copy(alpha = 0.9f) else tint.copy(alpha = 0.5f)) + } +} diff --git a/Android/app/src/main/java/com/archipelago/app/ui/components/GamepadLayout.kt b/Android/app/src/main/java/com/archipelago/app/ui/components/GamepadLayout.kt new file mode 100644 index 00000000..f931ff21 --- /dev/null +++ b/Android/app/src/main/java/com/archipelago/app/ui/components/GamepadLayout.kt @@ -0,0 +1,134 @@ +package com.archipelago.app.ui.components + +import androidx.compose.foundation.background +import androidx.compose.foundation.gestures.awaitEachGesture +import androidx.compose.foundation.gestures.awaitFirstDown +import androidx.compose.foundation.gestures.detectTapGestures +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.layout.width +import androidx.compose.foundation.shape.CircleShape +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.input.pointer.changedToUp +import androidx.compose.ui.input.pointer.pointerInput +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.unit.Dp +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp +import com.archipelago.app.ui.theme.BitcoinOrange +import com.archipelago.app.ui.theme.Neo +import com.archipelago.app.ui.theme.neoInset +import com.archipelago.app.ui.theme.neoRaised + +@Composable +fun GamepadLayout( + onKey: (String) -> Unit, + onTwoFingerHold: () -> Unit, + modifier: Modifier = Modifier, +) { + val surface = Neo.surface() + + Box( + modifier = modifier + .fillMaxSize() + .background(surface) + .pointerInput(Unit) { + awaitEachGesture { + awaitFirstDown(requireUnconsumed = false) + var t = 0L; var fired = false + do { + val ev = awaitPointerEvent() + val a = ev.changes.filter { !it.changedToUp() } + if (a.size >= 2 && t == 0L) t = System.currentTimeMillis() + if (a.size >= 2 && !fired && t > 0 && System.currentTimeMillis() - t > 500) { fired = true; onTwoFingerHold() } + if (a.size < 2) t = 0L + } while (ev.changes.any { it.pressed }) + } + } + .padding(horizontal = 24.dp, vertical = 16.dp), + ) { + // D-pad — centered left + DPad( + onDirection = onKey, + modifier = Modifier.align(Alignment.CenterStart).size(200.dp), + ) + + // Face buttons — centered right (diamond) + Column( + modifier = Modifier.align(Alignment.CenterEnd), + horizontalAlignment = Alignment.CenterHorizontally, + verticalArrangement = Arrangement.spacedBy(6.dp), + ) { + FaceBtn("esc", 64.dp) { onKey("Escape") } + Row(horizontalArrangement = Arrangement.spacedBy(28.dp)) { + FaceBtn("tab", 64.dp) { onKey("Tab") } + FaceBtn("enter", 64.dp, accent = true) { onKey("Return") } + } + FaceBtn("bksp", 64.dp) { onKey("BackSpace") } + } + + // Bottom: L, SELECT, START, R + Row( + modifier = Modifier.align(Alignment.BottomCenter), + horizontalArrangement = Arrangement.spacedBy(16.dp), + verticalAlignment = Alignment.CenterVertically, + ) { + PillBtn("L", 56.dp) { onKey("Prior") } + PillBtn("SELECT", 80.dp) { onKey("Escape") } + PillBtn("START", 80.dp) { onKey("Return") } + PillBtn("R", 56.dp) { onKey("Next") } + } + } +} + +@Composable +private fun FaceBtn(label: String, size: Dp, accent: Boolean = false, onClick: () -> Unit) { + var p by remember { mutableStateOf(false) } + val l = Neo.shadowLight(); val d = Neo.shadowDark() + val tc = if (accent) BitcoinOrange.copy(alpha = 0.7f) else Neo.textSecondary() + + Box( + modifier = Modifier + .size(size) + .then(if (p) Modifier.neoInset(l, d, size / 2, 1.dp, 3.dp) else Modifier.neoRaised(l, d, size / 2, 2.dp, 4.dp)) + .clip(CircleShape) + .background(Neo.surfaceRaised()) + .pointerInput(Unit) { detectTapGestures(onPress = { p = true; onClick(); tryAwaitRelease(); p = false }) }, + contentAlignment = Alignment.Center, + ) { + Text(label, color = if (p) tc.copy(alpha = 1f) else tc, fontSize = 12.sp, fontWeight = FontWeight.SemiBold, letterSpacing = 0.5.sp) + } +} + +@Composable +private fun PillBtn(label: String, w: Dp, onClick: () -> Unit) { + var p by remember { mutableStateOf(false) } + val l = Neo.shadowLight(); val d = Neo.shadowDark() + + Box( + modifier = Modifier + .width(w).height(34.dp) + .then(if (p) Modifier.neoInset(l, d, 8.dp, 1.dp, 2.dp) else Modifier.neoRaised(l, d, 8.dp, 2.dp, 4.dp)) + .clip(RoundedCornerShape(8.dp)) + .background(Neo.surfaceRaised()) + .pointerInput(Unit) { detectTapGestures(onPress = { p = true; onClick(); tryAwaitRelease(); p = false }) }, + contentAlignment = Alignment.Center, + ) { + Text(label, color = Neo.textMuted(), fontSize = 9.sp, fontWeight = FontWeight.Medium, letterSpacing = 1.sp) + } +} diff --git a/Android/app/src/main/java/com/archipelago/app/ui/components/NESController.kt b/Android/app/src/main/java/com/archipelago/app/ui/components/NESController.kt new file mode 100644 index 00000000..6d4cb2b3 --- /dev/null +++ b/Android/app/src/main/java/com/archipelago/app/ui/components/NESController.kt @@ -0,0 +1,468 @@ +package com.archipelago.app.ui.components + +import androidx.compose.foundation.Canvas +import androidx.compose.foundation.Image +import androidx.compose.foundation.background +import androidx.compose.foundation.border +import androidx.compose.foundation.gestures.awaitEachGesture +import androidx.compose.foundation.gestures.awaitFirstDown +import androidx.compose.foundation.gestures.detectTapGestures +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.aspectRatio +import androidx.compose.foundation.layout.fillMaxHeight +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.layout.width +import androidx.compose.foundation.shape.CircleShape +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.Settings +import androidx.compose.material3.Icon +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.DisposableEffect +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.rememberCoroutineScope +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.draw.shadow +import androidx.compose.ui.geometry.CornerRadius +import androidx.compose.ui.geometry.Offset +import androidx.compose.ui.geometry.Size +import androidx.compose.ui.graphics.Brush +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.ColorFilter +import androidx.compose.ui.graphics.Path +import androidx.compose.ui.graphics.drawscope.DrawScope +import androidx.compose.ui.graphics.drawscope.Fill +import androidx.compose.ui.input.pointer.changedToUp +import androidx.compose.ui.input.pointer.pointerInput +import androidx.compose.ui.res.painterResource +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.unit.Dp +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp +import com.archipelago.app.R +import com.archipelago.app.ui.theme.ControllerStyle +import com.archipelago.app.ui.theme.NES +import kotlinx.coroutines.Job +import kotlinx.coroutines.delay +import kotlinx.coroutines.launch +import kotlin.math.abs + +// ═══════════════════════════════════════════════════════════ +// Palettes +// ═══════════════════════════════════════════════════════════ + +data class NESPalette( + val body: Color, val face: Color, val ridge: Color, + val label: Color, val labelMuted: Color, + val dpad: Color, val dpadHi: Color, + val btn: Color, val btnPress: Color, + val capsule: Color, val capsulePress: Color, + val inlayBg: Color, val inlayBorder: Color, +) + +val ClassicPalette = NESPalette( + body = NES.ClassicBody, face = NES.ClassicFace, ridge = NES.ClassicRidge, + label = NES.ClassicLabel, labelMuted = NES.ClassicLabelMuted, + dpad = Color(0xFF0C0C0C), dpadHi = Color(0xFF1A1A1A), + btn = NES.ClassicButtonRed, btnPress = NES.ClassicButtonRedPress, + capsule = Color(0xFF1C1C1C), capsulePress = Color(0xFF0E0E0E), + inlayBg = Color(0xFF080808), inlayBorder = Color(0xFF999999), +) + +// Glassmorphism-black (OS design): translucent dark surfaces so the backdrop +// shows through the controller, subtle white-alpha borders, translucent-white +// buttons. Accents come from each button's ring. +val DarkPalette = NESPalette( + body = Color(0xA6121216), face = Color(0x8C0E0E12), ridge = Color(0x14FFFFFF), + label = Color(0xFF9A9A9A), labelMuted = Color(0xFF777777), + dpad = Color(0xFF202024), dpadHi = Color(0xFF33333A), + btn = Color(0x14FFFFFF), btnPress = Color(0x0AFFFFFF), + capsule = Color(0x12FFFFFF), capsulePress = Color(0x08FFFFFF), + inlayBg = Color(0x990A0A0A), inlayBorder = Color(0x1FFFFFFF), +) + +fun paletteFor(style: ControllerStyle) = if (style == ControllerStyle.CLASSIC) ClassicPalette else DarkPalette + +// ═══════════════════════════════════════════════════════════ +// Landscape NES Controller +// ═══════════════════════════════════════════════════════════ + +@Composable +fun NESController( + style: ControllerStyle = ControllerStyle.CLASSIC, + playerId: Int = 0, + onKey: (String) -> Unit, + onMenu: () -> Unit, + onPlayerToggle: () -> Unit = {}, + modifier: Modifier = Modifier, +) { + val c = paletteFor(style) + val isClassic = style == ControllerStyle.CLASSIC + + Box( + modifier = modifier + .fillMaxSize() + .twoFingerHold(onMenu) + .padding(horizontal = 40.dp, vertical = 24.dp), + contentAlignment = Alignment.Center, + ) { + // Controller body + Box( + Modifier + .fillMaxWidth(0.86f) + .aspectRatio(2.3f) + .shadow(32.dp, RoundedCornerShape(16.dp), ambientColor = Color(0xFF000000), spotColor = Color(0xFF000000)) + .clip(RoundedCornerShape(16.dp)) + .background( + Brush.verticalGradient(listOf(c.body, c.body)) + ) + .border(1.dp, Color.White.copy(alpha = if (isClassic) 0.08f else 0.04f), RoundedCornerShape(16.dp)), + ) { + // Top highlight edge + Box( + Modifier.fillMaxWidth().height(1.dp).align(Alignment.TopCenter) + .background(Color.White.copy(alpha = if (isClassic) 0.12f else 0.05f)) + ) + + // Face plate + Box( + Modifier + .fillMaxSize() + .padding(14.dp) + .clip(RoundedCornerShape(10.dp)) + .background(c.face) + .border(0.5.dp, Color.White.copy(alpha = 0.03f), RoundedCornerShape(10.dp)), + ) { + // Ridges + Ridges(c.ridge, Modifier.align(Alignment.CenterStart).width(7.dp).fillMaxHeight().padding(vertical = 12.dp)) + Ridges(c.ridge, Modifier.align(Alignment.CenterEnd).width(7.dp).fillMaxHeight().padding(vertical = 12.dp)) + + // D-Pad in inlay (more left margin) + Inlay(c, Modifier.align(Alignment.CenterStart).padding(start = 48.dp).size(140.dp)) { + OnePointDPad(c, 120.dp, onKey) + } + + // Center: Logo + START/SELECT + Column( + Modifier.align(Alignment.Center), + horizontalAlignment = Alignment.CenterHorizontally, + ) { + Image( + painter = painterResource(id = R.drawable.ic_logo_wide), + contentDescription = "Archipelago", + modifier = Modifier.width(180.dp), + colorFilter = ColorFilter.tint(if (isClassic) NES.ClassicLabel else c.label), + ) + Spacer(Modifier.height(10.dp)) + Inlay(c, Modifier.padding(horizontal = 4.dp)) { + Row( + Modifier.padding(horizontal = 12.dp, vertical = 8.dp), + horizontalArrangement = Arrangement.spacedBy(16.dp), + ) { + CapsuleBtn("SELECT", c, 64.dp, 28.dp) { onKey("Escape") } + CapsuleBtn("START", c, 64.dp, 28.dp) { onKey("Return") } + } + } + } + + // A/B/C Buttons in inlay — triangle: C top, B+A bottom + Inlay(c, Modifier.align(Alignment.CenterEnd).padding(end = 48.dp).size(140.dp)) { + Column( + Modifier.fillMaxSize(), + horizontalAlignment = Alignment.CenterHorizontally, + verticalArrangement = Arrangement.Center, + ) { + // C on top + GlassFaceBtn("C", Color(0xFFBBBBBB), 44.dp) { onKey("c") } + Spacer(Modifier.height(6.dp)) + // B + A on bottom row + Row(horizontalArrangement = Arrangement.spacedBy(12.dp)) { + GlassFaceBtn("B", Color(0xFF60A5FA), 44.dp) { onKey("b") } + GlassFaceBtn("A", Color(0xFFF7931A), 44.dp) { onKey("a") } + } + } + } + + // Player toggle + settings (bottom center) + Row( + Modifier.align(Alignment.BottomCenter).padding(bottom = 4.dp), + horizontalArrangement = Arrangement.spacedBy(10.dp), + verticalAlignment = Alignment.CenterVertically, + ) { + PlayerPill(c, playerId, onPlayerToggle) + SettingsBtn(c, Modifier, onMenu) + } + } + } + } +} + +// ═══════════════════════════════════════════════════════════ +// Shared sub-components +// ═══════════════════════════════════════════════════════════ + +/** Inlay well — dark recessed area with border */ +@Composable +fun Inlay(c: NESPalette, modifier: Modifier = Modifier, content: @Composable () -> Unit) { + Box( + modifier = modifier + .clip(RoundedCornerShape(10.dp)) + .background(c.inlayBg) + .border(3.dp, c.inlayBorder, RoundedCornerShape(10.dp)) + .padding(4.dp), + contentAlignment = Alignment.Center, + ) { content() } +} + +/** One-piece D-pad — single cross shape, touch detects direction */ +@Composable +fun OnePointDPad(c: NESPalette, size: Dp, onDir: (String) -> Unit) { + val scope = rememberCoroutineScope() + var job by remember { mutableStateOf(null) } + var activeDir by remember { mutableStateOf(null) } + DisposableEffect(Unit) { onDispose { job?.cancel() } } + + Canvas( + modifier = Modifier + .size(size) + .pointerInput(Unit) { + detectTapGestures( + onPress = { offset -> + val cx = this@pointerInput.size.width / 2f + val cy = this@pointerInput.size.height / 2f + val dx = offset.x - cx + val dy = offset.y - cy + val dead = cx * 0.24f + if (abs(dx) < dead && abs(dy) < dead) { + tryAwaitRelease(); return@detectTapGestures + } + val dir = if (abs(dx) > abs(dy)) { + if (dx > 0) "Right" else "Left" + } else { + if (dy > 0) "Down" else "Up" + } + activeDir = dir; onDir(dir) + job?.cancel() + // 500ms initial delay so a normal tap sends one key, not + // two (a touch tap often exceeds 300ms → doubled nav sound). + job = scope.launch { delay(500); while (true) { onDir(dir); delay(90) } } + tryAwaitRelease() + job?.cancel(); activeDir = null + }, + ) + }, + ) { + val w = size.toPx() + val arm = w * 0.33f // arm width = 1/3 of total + val offset = (w - arm) / 2f + + // Cross shape + val crossColor = c.dpad + + // Vertical bar + drawRoundRect( + color = crossColor, + topLeft = Offset(offset, 0f), + size = Size(arm, w), + cornerRadius = CornerRadius(4.dp.toPx()), + ) + // Horizontal bar + drawRoundRect( + color = crossColor, + topLeft = Offset(0f, offset), + size = Size(w, arm), + cornerRadius = CornerRadius(4.dp.toPx()), + ) + + // Top-edge lighting + drawRoundRect( + brush = Brush.verticalGradient(listOf(Color.White.copy(alpha = 0.06f), Color.Transparent)), + topLeft = Offset(offset, 0f), + size = Size(arm, w * 0.15f), + cornerRadius = CornerRadius(4.dp.toPx()), + ) + drawRoundRect( + brush = Brush.verticalGradient(listOf(Color.White.copy(alpha = 0.06f), Color.Transparent)), + topLeft = Offset(0f, offset), + size = Size(w, arm * 0.3f), + cornerRadius = CornerRadius(4.dp.toPx()), + ) + + // Active direction highlight + activeDir?.let { dir -> + val hi = c.dpadHi + when (dir) { + "Up" -> drawRoundRect(hi, Offset(offset, 0f), Size(arm, arm), CornerRadius(4.dp.toPx())) + "Down" -> drawRoundRect(hi, Offset(offset, w - arm), Size(arm, arm), CornerRadius(4.dp.toPx())) + "Left" -> drawRoundRect(hi, Offset(0f, offset), Size(arm, arm), CornerRadius(4.dp.toPx())) + "Right" -> drawRoundRect(hi, Offset(w - arm, offset), Size(arm, arm), CornerRadius(4.dp.toPx())) + } + } + + // Center circle + drawCircle(c.dpadHi, radius = w * 0.06f, center = Offset(w / 2f, w / 2f)) + } +} + +@Composable +fun Ridges(color: Color, modifier: Modifier) { + Canvas(modifier = modifier) { + val h = 1.5.dp.toPx(); val gap = 3.dp.toPx(); var y = 0f + while (y < size.height) { drawRect(color, Offset(0f, y), Size(size.width, h)); y += h + gap } + } +} + +/** A/B round button with lighting */ +@Composable +fun RoundBtn(c: NESPalette, sz: Dp = 52.dp, onClick: () -> Unit) { + var p by remember { mutableStateOf(false) } + Box( + Modifier + .size(sz) + .shadow(if (p) 1.dp else 4.dp, CircleShape) + .clip(CircleShape) + .background(Brush.verticalGradient( + if (p) listOf(c.btnPress, c.btn.copy(alpha = 0.85f)) + else listOf(c.btn, c.btn.copy(alpha = 0.8f)) + )) + .pointerInput(Unit) { detectTapGestures(onPress = { p = true; onClick(); tryAwaitRelease(); p = false }) }, + contentAlignment = Alignment.Center, + ) { + if (!p) Box(Modifier.fillMaxSize().clip(CircleShape).background( + Brush.verticalGradient(listOf(Color.White.copy(alpha = 0.18f), Color.Transparent)) + )) + } +} + +/** Colored round button — custom color instead of palette */ +@Composable +fun ColorBtn(color: Color, pressColor: Color, sz: Dp = 48.dp, onClick: () -> Unit) { + var p by remember { mutableStateOf(false) } + Box( + Modifier + .size(sz) + .shadow(if (p) 1.dp else 4.dp, CircleShape) + .clip(CircleShape) + .background(Brush.verticalGradient( + if (p) listOf(pressColor, color.copy(alpha = 0.85f)) + else listOf(color, color.copy(alpha = 0.8f)) + )) + .pointerInput(Unit) { detectTapGestures(onPress = { p = true; onClick(); tryAwaitRelease(); p = false }) }, + contentAlignment = Alignment.Center, + ) { + if (!p) Box(Modifier.fillMaxSize().clip(CircleShape).background( + Brush.verticalGradient(listOf(Color.White.copy(alpha = 0.18f), Color.Transparent)) + )) + } +} + +/** Glass face button — dark translucent fill, colored ring + letter (OS style) */ +@Composable +fun GlassFaceBtn(label: String, accent: Color, sz: Dp = 44.dp, onClick: () -> Unit) { + var p by remember { mutableStateOf(false) } + Box( + Modifier + .size(sz) + .clip(CircleShape) + .background( + Brush.verticalGradient( + if (p) listOf(Color.White.copy(alpha = 0.05f), Color.White.copy(alpha = 0.02f)) + else listOf(Color.White.copy(alpha = 0.10f), Color.White.copy(alpha = 0.03f)) + ) + ) + .border(1.5.dp, accent.copy(alpha = if (p) 0.95f else 0.55f), CircleShape) + .pointerInput(Unit) { detectTapGestures(onPress = { p = true; onClick(); tryAwaitRelease(); p = false }) }, + contentAlignment = Alignment.Center, + ) { + Text(label, color = accent.copy(alpha = if (p) 1f else 0.85f), fontSize = 16.sp, fontWeight = FontWeight.Bold) + } +} + +/** START/SELECT capsule */ +@Composable +fun CapsuleBtn(label: String, c: NESPalette, w: Dp = 64.dp, h: Dp = 28.dp, onClick: () -> Unit) { + var p by remember { mutableStateOf(false) } + Box( + Modifier + .width(w).height(h) + .shadow(if (p) 0.dp else 2.dp, RoundedCornerShape(4.dp)) + .clip(RoundedCornerShape(4.dp)) + .background(Brush.verticalGradient( + if (p) listOf(c.capsulePress, c.capsule) + else listOf(c.capsule, c.capsule.copy(alpha = 0.85f)) + )) + .pointerInput(Unit) { detectTapGestures(onPress = { p = true; onClick(); tryAwaitRelease(); p = false }) }, + contentAlignment = Alignment.Center, + ) { + if (!p) Box(Modifier.fillMaxSize().clip(RoundedCornerShape(4.dp)).background( + Brush.verticalGradient(listOf(Color.White.copy(alpha = 0.05f), Color.Transparent)) + )) + Text(label, color = c.labelMuted, fontSize = 8.sp, fontWeight = FontWeight.Bold, letterSpacing = 1.sp) + } +} + +/** Settings gear button (48dp — large enough for easy tap on TV) */ +@Composable +fun SettingsBtn(c: NESPalette, modifier: Modifier = Modifier, onClick: () -> Unit) { + var p by remember { mutableStateOf(false) } + Box( + modifier = modifier + .size(48.dp) + .clip(CircleShape) + .background(if (p) c.capsulePress else c.capsule) + .pointerInput(Unit) { detectTapGestures(onPress = { p = true; onClick(); tryAwaitRelease(); p = false }) }, + contentAlignment = Alignment.Center, + ) { + Icon(Icons.Default.Settings, "Settings", Modifier.size(28.dp), tint = c.labelMuted) + } +} + +/** Player ID toggle pill (P1/P2/ALL) */ +@Composable +fun PlayerPill(c: NESPalette, playerId: Int, onToggle: () -> Unit) { + val label = when (playerId) { 1 -> "P1"; 2 -> "P2"; else -> "ALL" } + val accent = when (playerId) { 1 -> Color(0xFF00F0FF); 2 -> Color(0xFFFF0080); else -> c.labelMuted } + var p by remember { mutableStateOf(false) } + Box( + modifier = Modifier + .height(28.dp) + .width(44.dp) + .clip(RoundedCornerShape(6.dp)) + .background(if (p) c.capsulePress else c.capsule) + .border(1.dp, accent.copy(alpha = 0.5f), RoundedCornerShape(6.dp)) + .pointerInput(Unit) { detectTapGestures(onPress = { p = true; onToggle(); tryAwaitRelease(); p = false }) }, + contentAlignment = Alignment.Center, + ) { + Text(label, color = accent, fontSize = 10.sp, fontWeight = FontWeight.Bold, letterSpacing = 1.sp) + } +} + +/** Two-finger hold gesture modifier */ +fun Modifier.twoFingerHold(onHold: () -> Unit) = this.pointerInput(Unit) { + awaitEachGesture { + awaitFirstDown(requireUnconsumed = false) + var t = 0L; var fired = false + do { + val ev = awaitPointerEvent() + val a = ev.changes.filter { !it.changedToUp() } + if (a.size >= 2 && t == 0L) t = System.currentTimeMillis() + if (a.size >= 2 && !fired && t > 0 && System.currentTimeMillis() - t > 500) { fired = true; onHold() } + if (a.size < 2) t = 0L + } while (ev.changes.any { it.pressed }) + } +} + diff --git a/Android/app/src/main/java/com/archipelago/app/ui/components/NESKeyboard.kt b/Android/app/src/main/java/com/archipelago/app/ui/components/NESKeyboard.kt new file mode 100644 index 00000000..f5b1c756 --- /dev/null +++ b/Android/app/src/main/java/com/archipelago/app/ui/components/NESKeyboard.kt @@ -0,0 +1,211 @@ +package com.archipelago.app.ui.components + +import androidx.compose.foundation.background +import androidx.compose.foundation.border +import androidx.compose.foundation.gestures.detectTapGestures +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.DisposableEffect +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.rememberCoroutineScope +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.graphics.Brush +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.input.pointer.pointerInput +import androidx.compose.ui.text.style.TextAlign +import androidx.compose.ui.unit.Dp +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp +import com.archipelago.app.ui.theme.ControllerStyle +import com.archipelago.app.ui.theme.NES +import kotlinx.coroutines.Job +import kotlinx.coroutines.delay +import kotlinx.coroutines.launch + +private enum class NKLayer { ALPHA, NUM, SYM } +private val KEY_H = 42.dp +private val GAP = 4.dp + +@Composable +fun NESKeyboard( + style: ControllerStyle = ControllerStyle.CLASSIC, + onKey: (String) -> Unit, + modifier: Modifier = Modifier, +) { + val c = paletteFor(style) + val isClassic = style == ControllerStyle.CLASSIC + val keyBg = c.dpad + val keyBgP = c.dpadHi + val keyTxt = c.labelMuted + val accent = if (isClassic) NES.ClassicLabel else c.labelMuted + + var layer by remember { mutableStateOf(NKLayer.ALPHA) } + var shifted by remember { mutableStateOf(false) } + var capsLock by remember { mutableStateOf(false) } + var ctrlHeld by remember { mutableStateOf(false) } + val up = shifted || capsLock + + fun emit(k: String) { + val key = if (ctrlHeld) "ctrl+$k" else k + onKey(key) + if (shifted && !capsLock) shifted = false + if (ctrlHeld) ctrlHeld = false + } + fun ch(cc: String) { emit(if (up && layer == NKLayer.ALPHA) "shift+$cc" else cc) } + + // NES body wrapping keyboard + Column( + modifier = modifier + .clip(RoundedCornerShape(14.dp)) + .background(c.body) + .padding(8.dp) + .clip(RoundedCornerShape(8.dp)) + .background(c.face) + .padding(horizontal = 8.dp, vertical = 6.dp), + verticalArrangement = Arrangement.spacedBy(GAP), + ) { + when (layer) { + NKLayer.ALPHA -> { + KeyRow("q w e r t y u i o p".split(" "), up, keyBg, keyBgP, keyTxt, ::ch) + KeyRow("a s d f g h j k l".split(" "), up, keyBg, keyBgP, keyTxt, ::ch, inset = 16.dp) + Row(Modifier.fillMaxWidth().height(KEY_H), Arrangement.spacedBy(GAP)) { + NKey(if (capsLock) "\u21EA" else "\u21E7", Modifier.weight(1.4f), keyBg, keyBgP, if (up) accent else keyTxt) { + if (capsLock) { capsLock = false; shifted = false } else if (shifted) capsLock = true else shifted = true + } + "z x c v b n m".split(" ").forEach { k -> + NKey(if (up) k.uppercase() else k, Modifier.weight(1f), keyBg, keyBgP, keyTxt, 17) { ch(k) } + } + NRepKey("\u232B", Modifier.weight(1.4f), keyBg, keyBgP, keyTxt) { emit("BackSpace") } + } + } + NKLayer.NUM -> { + KeyRow("1 2 3 4 5 6 7 8 9 0".split(" "), false, keyBg, keyBgP, keyTxt, ::emit) + KeyRow("- / : ; ( ) \$ & @ \"".split(" "), false, keyBg, keyBgP, keyTxt, ::emit) + Row(Modifier.fillMaxWidth().height(KEY_H), Arrangement.spacedBy(GAP)) { + NKey("#+=", Modifier.weight(1.4f), keyBg, keyBgP, keyTxt) { layer = NKLayer.SYM } + ". , ? ! '".split(" ").forEach { k -> + NKey(k, Modifier.weight(1f), keyBg, keyBgP, keyTxt) { emit(k) } + } + NRepKey("\u232B", Modifier.weight(1.4f), keyBg, keyBgP, keyTxt) { emit("BackSpace") } + } + } + NKLayer.SYM -> { + KeyRow("[ ] { } # % ^ * + =".split(" "), false, keyBg, keyBgP, keyTxt, ::emit) + KeyRow("_ \\ | ~ < > ` @ !".split(" "), false, keyBg, keyBgP, keyTxt, ::emit) + Row(Modifier.fillMaxWidth().height(KEY_H), Arrangement.spacedBy(GAP)) { + NKey("123", Modifier.weight(1.4f), keyBg, keyBgP, keyTxt) { layer = NKLayer.NUM } + ". , ? ! '".split(" ").forEach { k -> + NKey(k, Modifier.weight(1f), keyBg, keyBgP, keyTxt) { emit(k) } + } + NRepKey("\u232B", Modifier.weight(1.4f), keyBg, keyBgP, keyTxt) { emit("BackSpace") } + } + } + } + // Bottom row + Row(Modifier.fillMaxWidth().height(KEY_H), Arrangement.spacedBy(GAP)) { + NKey(if (layer == NKLayer.ALPHA) "123" else "ABC", Modifier.weight(1.4f), keyBg, keyBgP, keyTxt) { + layer = if (layer == NKLayer.ALPHA) NKLayer.NUM else NKLayer.ALPHA; shifted = false; capsLock = false + } + NKey("Ctrl", Modifier.weight(1.2f), keyBg, keyBgP, if (ctrlHeld) accent else keyTxt, 11) { + ctrlHeld = !ctrlHeld + } + NKey(",", Modifier.weight(0.8f), keyBg, keyBgP, keyTxt) { emit("comma") } + NKey("space", Modifier.weight(4f), keyBg, keyBgP, keyTxt, 12) { emit("space") } + NKey(".", Modifier.weight(0.8f), keyBg, keyBgP, keyTxt) { emit("period") } + NKey("\u23CE", Modifier.weight(1.4f), keyBg, keyBgP, accent, 15) { emit("Return") } + } + } +} + +/** Key row — each key gets equal weight */ +@Composable +private fun KeyRow( + keys: List, up: Boolean, + bg: Color, bgP: Color, txt: Color, + onKey: (String) -> Unit, inset: Dp = 0.dp, +) { + Row( + Modifier.fillMaxWidth().height(KEY_H).padding(horizontal = inset), + Arrangement.spacedBy(GAP), + ) { + keys.forEach { k -> + NKey( + label = if (up) k.uppercase() else k, + modifier = Modifier.weight(1f), + bg = bg, bgP = bgP, txt = txt, + fontSize = 17, + onTap = { onKey(k) }, + ) + } + } +} + +/** Single NES key — D-pad style flat dark button */ +@Composable +private fun NKey( + label: String, modifier: Modifier = Modifier, + bg: Color, bgP: Color, txt: Color, + fontSize: Int = 13, onTap: () -> Unit, +) { + var p by remember { mutableStateOf(false) } + Box( + modifier = modifier + .height(KEY_H) + .clip(RoundedCornerShape(4.dp)) + .background(Brush.verticalGradient(if (p) listOf(bgP, bg) else listOf(bg, bg.copy(alpha = 0.9f)))) + .then( + if (!p) Modifier.border(0.5.dp, + Brush.verticalGradient(listOf(Color.White.copy(alpha = 0.06f), Color.Transparent)), + RoundedCornerShape(4.dp)) + else Modifier + ) + .pointerInput(label) { + detectTapGestures(onPress = { p = true; onTap(); tryAwaitRelease(); p = false }) + }, + contentAlignment = Alignment.Center, + ) { + Text(label, color = txt, fontSize = fontSize.sp, textAlign = TextAlign.Center, maxLines = 1) + } +} + +/** Repeatable NES key (backspace) */ +@Composable +private fun NRepKey( + label: String, modifier: Modifier, + bg: Color, bgP: Color, txt: Color, onTap: () -> Unit, +) { + var p by remember { mutableStateOf(false) } + val scope = rememberCoroutineScope() + var job by remember { mutableStateOf(null) } + DisposableEffect(Unit) { onDispose { job?.cancel() } } + + Box( + modifier = modifier + .height(KEY_H) + .clip(RoundedCornerShape(4.dp)) + .background(Brush.verticalGradient(if (p) listOf(bgP, bg) else listOf(bg, bg.copy(alpha = 0.9f)))) + .pointerInput(Unit) { + detectTapGestures(onPress = { + p = true; onTap() + job = scope.launch { delay(400); while (true) { onTap(); delay(55) } } + tryAwaitRelease(); job?.cancel(); p = false + }) + }, + contentAlignment = Alignment.Center, + ) { + Text(label, color = txt, fontSize = 16.sp) + } +} diff --git a/Android/app/src/main/java/com/archipelago/app/ui/components/NESMenu.kt b/Android/app/src/main/java/com/archipelago/app/ui/components/NESMenu.kt new file mode 100644 index 00000000..2eb41d43 --- /dev/null +++ b/Android/app/src/main/java/com/archipelago/app/ui/components/NESMenu.kt @@ -0,0 +1,343 @@ +package com.archipelago.app.ui.components + +import androidx.compose.animation.AnimatedVisibility +import androidx.compose.animation.fadeIn +import androidx.compose.animation.fadeOut +import androidx.compose.animation.scaleIn +import androidx.compose.animation.scaleOut +import androidx.compose.foundation.background +import androidx.compose.foundation.border +import androidx.compose.foundation.clickable +import androidx.compose.foundation.interaction.MutableInteractionSource +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.layout.width +import androidx.compose.foundation.layout.widthIn +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.foundation.text.KeyboardActions +import androidx.compose.foundation.text.KeyboardOptions +import androidx.compose.material3.OutlinedTextField +import androidx.compose.material3.OutlinedTextFieldDefaults +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.text.TextStyle +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.text.input.ImeAction +import androidx.compose.ui.text.input.KeyboardType +import androidx.compose.ui.text.input.PasswordVisualTransformation +import androidx.compose.ui.text.style.TextAlign +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp +import com.archipelago.app.data.ServerEntry +import com.archipelago.app.ui.theme.BitcoinOrange +import com.archipelago.app.ui.theme.ControllerStyle +import com.archipelago.app.ui.theme.SurfaceDark +import com.archipelago.app.ui.theme.TextMuted +import com.archipelago.app.ui.theme.TextPrimary + +// Glassmorphism palette (OS design): near-black surfaces, subtle white borders, +// Bitcoin-orange accent. +private val PanelBg = SurfaceDark // #0A0A0A +private val PanelBorder = Color.White.copy(alpha = 0.12f) +private val RowBg = Color.White.copy(alpha = 0.05f) +private val RowBorder = Color.White.copy(alpha = 0.08f) +private val FieldBg = Color.White.copy(alpha = 0.04f) + +private val PANEL_R = 20.dp +private val ROW_R = 14.dp +private val ROW_H = 54.dp +private val FIELD_H = 58.dp + +/** Glassmorphism modal menu — #0A0A0A surface, subtle white borders. */ +@Composable +fun NESMenu( + visible: Boolean, + servers: List, + activeServer: ServerEntry?, + isGamepadMode: Boolean, + controllerStyle: ControllerStyle, + onDismiss: () -> Unit, + onSelectServer: (ServerEntry) -> Unit, + onAddServer: (ServerEntry) -> Unit, + onEditServer: (ServerEntry, ServerEntry) -> Unit, + onRemoveServer: (ServerEntry) -> Unit, + onToggleMode: () -> Unit, + onToggleStyle: () -> Unit, + onBackToWebView: (() -> Unit)? = null, +) { + AnimatedVisibility(visible = visible, enter = fadeIn(), exit = fadeOut()) { + Box( + Modifier.fillMaxSize().background(Color.Black.copy(alpha = 0.7f)) + .clickable(indication = null, interactionSource = remember { MutableInteractionSource() }) { onDismiss() }, + contentAlignment = Alignment.Center, + ) { + AnimatedVisibility(visible = visible, enter = fadeIn() + scaleIn(initialScale = 0.95f), exit = fadeOut() + scaleOut(targetScale = 0.95f)) { + MenuPanel(servers, activeServer, isGamepadMode, controllerStyle, onDismiss, onSelectServer, onAddServer, onEditServer, onRemoveServer, onToggleMode, onToggleStyle, onBackToWebView) + } + } + } +} + +@Composable +private fun MenuPanel( + servers: List, + activeServer: ServerEntry?, + isGamepadMode: Boolean, + controllerStyle: ControllerStyle, + onDismiss: () -> Unit, + onSelectServer: (ServerEntry) -> Unit, + onAddServer: (ServerEntry) -> Unit, + onEditServer: (ServerEntry, ServerEntry) -> Unit, + onRemoveServer: (ServerEntry) -> Unit, + onToggleMode: () -> Unit, + onToggleStyle: () -> Unit, + onBackToWebView: (() -> Unit)?, +) { + var showAdd by remember { mutableStateOf(false) } + // The saved server being edited, or null when adding a new one. + var editing by remember { mutableStateOf(null) } + var nm by remember { mutableStateOf("") } + var addr by remember { mutableStateOf("") } + var pwd by remember { mutableStateOf("") } + + fun resetForm() { + nm = ""; addr = ""; pwd = ""; showAdd = false; editing = null + } + + fun startEdit(server: ServerEntry) { + editing = server + nm = server.name; addr = server.address; pwd = server.password + showAdd = false + } + + fun submit() { + if (addr.isBlank()) return + val orig = editing + if (orig != null) { + // Preserve fields the compact form doesn't expose (scheme, port). + onEditServer(orig, orig.copy(address = addr, password = pwd, name = nm)) + } else { + onAddServer(ServerEntry(addr, false, password = pwd, name = nm)) + } + resetForm() + } + + Column( + modifier = Modifier + .widthIn(max = 420.dp) + .padding(horizontal = 20.dp) + .clip(RoundedCornerShape(PANEL_R)) + .background(PanelBg) + .border(1.dp, PanelBorder, RoundedCornerShape(PANEL_R)) + .clickable(indication = null, interactionSource = remember { MutableInteractionSource() }) {} + .padding(22.dp), + verticalArrangement = Arrangement.spacedBy(10.dp), + ) { + // Title + Text( + "Menu", + color = TextPrimary, + fontSize = 18.sp, + fontWeight = FontWeight.SemiBold, + letterSpacing = 2.sp, + modifier = Modifier.fillMaxWidth(), + textAlign = TextAlign.Center, + ) + Spacer(Modifier.height(2.dp)) + + // Servers + servers.forEach { server -> + val active = server.serialize() == activeServer?.serialize() + MenuItem( + label = server.displayName(), + selected = active, + onClick = { onSelectServer(server) }, + onEdit = { startEdit(server) }, + onRemove = { onRemoveServer(server) }, + ) + } + + if (servers.isEmpty()) { + Text("No servers", color = TextMuted, fontSize = 14.sp, modifier = Modifier.padding(vertical = 4.dp)) + } + + // Add / edit server + if (showAdd || editing != null) { + Column( + Modifier + .fillMaxWidth() + .clip(RoundedCornerShape(ROW_R)) + .background(FieldBg) + .border(1.dp, RowBorder, RoundedCornerShape(ROW_R)) + .padding(12.dp), + verticalArrangement = Arrangement.spacedBy(8.dp), + ) { + Row( + Modifier.fillMaxWidth(), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.SpaceBetween, + ) { + Text( + if (editing != null) "Edit Server" else "Add Server", + color = TextMuted, + fontSize = 13.sp, + letterSpacing = 1.sp, + fontWeight = FontWeight.Medium, + ) + Text( + "Cancel", + color = TextMuted, + fontSize = 13.sp, + modifier = Modifier.clickable { resetForm() }.padding(start = 8.dp), + ) + } + GlassField( + value = nm, onValueChange = { nm = it }, + placeholder = "Name (optional)", + keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Text, imeAction = ImeAction.Next), + ) + GlassField( + value = addr, onValueChange = { addr = it.trim() }, + placeholder = "192.168.1.100", + keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Uri, imeAction = ImeAction.Next), + ) + Row(horizontalArrangement = Arrangement.spacedBy(8.dp), verticalAlignment = Alignment.CenterVertically) { + GlassField( + value = pwd, onValueChange = { pwd = it }, + placeholder = "Password", + modifier = Modifier.weight(1f), + visualTransformation = PasswordVisualTransformation(), + keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Password, imeAction = ImeAction.Go), + keyboardActions = KeyboardActions(onGo = { submit() }), + ) + Box( + Modifier.size(FIELD_H).clip(RoundedCornerShape(12.dp)).background(BitcoinOrange.copy(alpha = 0.15f)) + .border(1.dp, BitcoinOrange.copy(alpha = 0.4f), RoundedCornerShape(12.dp)) + .clickable { submit() }, + contentAlignment = Alignment.Center, + ) { Text("OK", color = BitcoinOrange, fontSize = 14.sp, fontWeight = FontWeight.Bold) } + } + } + } else { + MenuItem(label = "Add Server", labelColor = BitcoinOrange, onClick = { showAdd = true }) + } + + Spacer(Modifier.height(2.dp)) + Box(Modifier.fillMaxWidth().height(1.dp).background(PanelBorder)) + Spacer(Modifier.height(2.dp)) + + // Mode toggle + MenuItem( + label = if (isGamepadMode) "Switch to Keyboard" else "Switch to Gamepad", + onClick = onToggleMode, + ) + + // Style toggle + MenuItem( + label = if (controllerStyle == ControllerStyle.CLASSIC) "Style: Classic" else "Style: Dark", + onClick = onToggleStyle, + ) + + // Back to dashboard + if (onBackToWebView != null) { + MenuItem(label = "Back to Dashboard", onClick = onBackToWebView) + } + } +} + +@Composable +private fun MenuItem( + label: String, + selected: Boolean = false, + labelColor: Color = TextPrimary, + onClick: () -> Unit, + onEdit: (() -> Unit)? = null, + onRemove: (() -> Unit)? = null, +) { + Row( + Modifier + .fillMaxWidth() + .height(ROW_H) + .clip(RoundedCornerShape(ROW_R)) + .background(if (selected) BitcoinOrange.copy(alpha = 0.12f) else RowBg) + .border(1.dp, if (selected) BitcoinOrange.copy(alpha = 0.4f) else RowBorder, RoundedCornerShape(ROW_R)) + .clickable { onClick() } + .padding(horizontal = 16.dp), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.SpaceBetween, + ) { + Text( + label, + color = if (selected) BitcoinOrange else labelColor, + fontSize = 16.sp, + fontWeight = FontWeight.Medium, + modifier = Modifier.weight(1f), + ) + if (onEdit != null) { + Text( + "✎", + color = TextMuted, + fontSize = 16.sp, + modifier = Modifier.clickable { onEdit() }.padding(horizontal = 8.dp), + ) + } + if (onRemove != null) { + Text( + "✕", + color = TextMuted, + fontSize = 16.sp, + modifier = Modifier.clickable { onRemove() }.padding(horizontal = 8.dp), + ) + } + } +} + +/** Glass text field with centered input text. */ +@Composable +private fun GlassField( + value: String, + onValueChange: (String) -> Unit, + placeholder: String, + modifier: Modifier = Modifier, + visualTransformation: androidx.compose.ui.text.input.VisualTransformation = androidx.compose.ui.text.input.VisualTransformation.None, + keyboardOptions: KeyboardOptions = KeyboardOptions.Default, + keyboardActions: KeyboardActions = KeyboardActions.Default, +) { + OutlinedTextField( + value = value, + onValueChange = onValueChange, + placeholder = { + Text(placeholder, color = TextMuted, fontSize = 15.sp, modifier = Modifier.fillMaxWidth(), textAlign = TextAlign.Center) + }, + modifier = modifier.fillMaxWidth().height(FIELD_H), + singleLine = true, + visualTransformation = visualTransformation, + keyboardOptions = keyboardOptions, + keyboardActions = keyboardActions, + textStyle = TextStyle(color = TextPrimary, fontSize = 16.sp, textAlign = TextAlign.Center), + colors = OutlinedTextFieldDefaults.colors( + focusedBorderColor = Color.White.copy(alpha = 0.3f), + unfocusedBorderColor = Color.White.copy(alpha = 0.12f), + cursorColor = BitcoinOrange, + focusedTextColor = TextPrimary, + unfocusedTextColor = TextPrimary, + ), + shape = RoundedCornerShape(12.dp), + ) +} diff --git a/Android/app/src/main/java/com/archipelago/app/ui/components/NESPortraitController.kt b/Android/app/src/main/java/com/archipelago/app/ui/components/NESPortraitController.kt new file mode 100644 index 00000000..59fac842 --- /dev/null +++ b/Android/app/src/main/java/com/archipelago/app/ui/components/NESPortraitController.kt @@ -0,0 +1,158 @@ +package com.archipelago.app.ui.components + +import androidx.compose.foundation.Image +import androidx.compose.foundation.background +import androidx.compose.foundation.border +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.layout.width +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.draw.shadow +import androidx.compose.ui.graphics.Brush +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.ColorFilter +import androidx.compose.ui.res.painterResource +import androidx.compose.ui.unit.dp +import com.archipelago.app.R +import com.archipelago.app.ui.theme.ControllerStyle +import com.archipelago.app.ui.theme.NES + +/** + * Portrait gamepad — vertical remote shape like Apple TV but NES-styled. + * Large trackpad top, D-pad middle, A/B + START/SELECT bottom. + */ +@Composable +fun NESPortraitController( + style: ControllerStyle = ControllerStyle.CLASSIC, + playerId: Int = 0, + onKey: (String) -> Unit, + onMouseMove: (Int, Int) -> Unit = { _, _ -> }, + onMouseClick: (Int) -> Unit = { _ -> }, + onMouseScroll: (Int) -> Unit = { _ -> }, + onMenu: () -> Unit, + onPlayerToggle: () -> Unit = {}, +) { + val c = paletteFor(style) + val isClassic = style == ControllerStyle.CLASSIC + + Box( + Modifier + .fillMaxSize() + .twoFingerHold(onMenu) + .padding(horizontal = 40.dp, vertical = 24.dp), + contentAlignment = Alignment.Center, + ) { + // Remote body — tall vertical shape + Box( + Modifier + .fillMaxWidth(0.75f) + .fillMaxSize() + .shadow(28.dp, RoundedCornerShape(20.dp), ambientColor = Color.Black, spotColor = Color.Black) + .clip(RoundedCornerShape(20.dp)) + .background(Brush.verticalGradient(listOf(c.body, c.body))) + .border(1.dp, Color.White.copy(alpha = if (isClassic) 0.08f else 0.04f), RoundedCornerShape(20.dp)), + ) { + // Top highlight + Box( + Modifier.fillMaxWidth().height(1.dp).align(Alignment.TopCenter) + .background(Color.White.copy(alpha = if (isClassic) 0.12f else 0.05f)) + ) + + // Face plate + Column( + Modifier + .fillMaxSize() + .padding(14.dp) + .clip(RoundedCornerShape(14.dp)) + .background(c.face) + .border(0.5.dp, Color.White.copy(alpha = 0.03f), RoundedCornerShape(14.dp)) + .padding(16.dp), + horizontalAlignment = Alignment.CenterHorizontally, + verticalArrangement = Arrangement.SpaceBetween, + ) { + // Trackpad area (touch surface for mouse) + Trackpad( + onMove = { dx, dy -> onMouseMove(dx, dy) }, + onClick = { onMouseClick(it) }, + onScroll = { dy -> onMouseScroll(dy) }, + onTwoFingerHold = onMenu, + modifier = Modifier + .fillMaxWidth() + .weight(1f), + ) + + Spacer(Modifier.height(12.dp)) + + // D-Pad + Inlay(c, Modifier.size(150.dp)) { + OnePointDPad(c, 130.dp, onKey) + } + + Spacer(Modifier.height(12.dp)) + + // Logo + Image( + painter = painterResource(id = R.drawable.ic_logo_wide), + contentDescription = "Archipelago", + modifier = Modifier.width(140.dp), + colorFilter = ColorFilter.tint(if (isClassic) NES.ClassicLabel else c.label), + ) + + Spacer(Modifier.height(12.dp)) + + // A/B/C Buttons — triangle: C top, B+A bottom + Inlay(c, Modifier.fillMaxWidth()) { + Column( + Modifier.fillMaxWidth().padding(horizontal = 12.dp, vertical = 8.dp), + horizontalAlignment = Alignment.CenterHorizontally, + ) { + GlassFaceBtn("C", Color(0xFFBBBBBB), 46.dp) { onKey("c") } + Spacer(Modifier.height(6.dp)) + Row(horizontalArrangement = Arrangement.spacedBy(14.dp)) { + GlassFaceBtn("B", Color(0xFF60A5FA), 46.dp) { onKey("b") } + GlassFaceBtn("A", Color(0xFFF7931A), 46.dp) { onKey("a") } + } + } + } + + Spacer(Modifier.height(10.dp)) + + // START / SELECT + Inlay(c, Modifier) { + Row( + Modifier.padding(horizontal = 10.dp, vertical = 6.dp), + horizontalArrangement = Arrangement.spacedBy(14.dp), + ) { + CapsuleBtn("SELECT", c, 64.dp, 28.dp) { onKey("Escape") } + CapsuleBtn("START", c, 64.dp, 28.dp) { onKey("Return") } + } + } + + Spacer(Modifier.height(6.dp)) + + // Player toggle + Settings + Row( + Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.Center, + verticalAlignment = Alignment.CenterVertically, + ) { + PlayerPill(c, playerId, onPlayerToggle) + Spacer(Modifier.width(10.dp)) + SettingsBtn(c, Modifier, onMenu) + } + } + } + } +} diff --git a/Android/app/src/main/java/com/archipelago/app/ui/components/ServerModal.kt b/Android/app/src/main/java/com/archipelago/app/ui/components/ServerModal.kt new file mode 100644 index 00000000..b326f82e --- /dev/null +++ b/Android/app/src/main/java/com/archipelago/app/ui/components/ServerModal.kt @@ -0,0 +1,263 @@ +package com.archipelago.app.ui.components + +import androidx.compose.animation.AnimatedVisibility +import androidx.compose.animation.fadeIn +import androidx.compose.animation.fadeOut +import androidx.compose.animation.scaleIn +import androidx.compose.animation.scaleOut +import androidx.compose.foundation.background +import androidx.compose.foundation.border +import androidx.compose.foundation.clickable +import androidx.compose.foundation.interaction.MutableInteractionSource +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.layout.width +import androidx.compose.foundation.layout.widthIn +import androidx.compose.foundation.shape.CircleShape +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.foundation.text.KeyboardActions +import androidx.compose.foundation.text.KeyboardOptions +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.Add +import androidx.compose.material.icons.filled.Close +import androidx.compose.material.icons.filled.Gamepad +import androidx.compose.material.icons.filled.Keyboard +import androidx.compose.material.icons.filled.RadioButtonChecked +import androidx.compose.material.icons.filled.RadioButtonUnchecked +import androidx.compose.material.icons.filled.Web +import androidx.compose.material3.HorizontalDivider +import androidx.compose.material3.Icon +import androidx.compose.material3.IconButton +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.OutlinedTextField +import androidx.compose.material3.OutlinedTextFieldDefaults +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.vector.ImageVector +import androidx.compose.ui.text.input.ImeAction +import androidx.compose.ui.text.input.KeyboardType +import androidx.compose.ui.text.input.PasswordVisualTransformation +import androidx.compose.ui.unit.dp +import com.archipelago.app.data.ServerEntry +import com.archipelago.app.ui.theme.BitcoinOrange +import com.archipelago.app.ui.theme.Neo +import com.archipelago.app.ui.theme.TextMuted +import com.archipelago.app.ui.theme.TextPrimary +import com.archipelago.app.ui.theme.neoRaised + +private val ROW_H = 48.dp +private val ROW_R = 12.dp + +@Composable +fun ServerModal( + visible: Boolean, + servers: List, + activeServer: ServerEntry?, + isGamepadMode: Boolean, + onDismiss: () -> Unit, + onSelectServer: (ServerEntry) -> Unit, + onAddServer: (ServerEntry) -> Unit, + onRemoveServer: (ServerEntry) -> Unit, + onToggleGamepadMode: () -> Unit, + onBackToWebView: (() -> Unit)? = null, +) { + AnimatedVisibility(visible = visible, enter = fadeIn(), exit = fadeOut()) { + Box( + modifier = Modifier + .fillMaxSize() + .background(Color.Black.copy(alpha = 0.55f)) + .clickable( + indication = null, + interactionSource = remember { MutableInteractionSource() }, + ) { onDismiss() }, + contentAlignment = Alignment.Center, + ) { + AnimatedVisibility(visible = visible, enter = fadeIn() + scaleIn(initialScale = 0.95f), exit = fadeOut() + scaleOut(targetScale = 0.95f)) { + ModalBody(servers, activeServer, isGamepadMode, onDismiss, onSelectServer, onAddServer, onRemoveServer, onToggleGamepadMode, onBackToWebView) + } + } + } +} + +@Composable +private fun ModalBody( + servers: List, + activeServer: ServerEntry?, + isGamepadMode: Boolean, + onDismiss: () -> Unit, + onSelectServer: (ServerEntry) -> Unit, + onAddServer: (ServerEntry) -> Unit, + onRemoveServer: (ServerEntry) -> Unit, + onToggleGamepadMode: () -> Unit, + onBackToWebView: (() -> Unit)?, +) { + val surface = Neo.surfaceRaised() + val light = Neo.shadowLight() + val dark = Neo.shadowDark() + var showAddForm by remember { mutableStateOf(false) } + var newAddress by remember { mutableStateOf("") } + var newPassword by remember { mutableStateOf("") } + + Column( + modifier = Modifier + .widthIn(max = 380.dp) + .neoRaised(light, dark, 24.dp, 6.dp, 12.dp) + .clip(RoundedCornerShape(24.dp)) + .background(surface) + .clickable(indication = null, interactionSource = remember { MutableInteractionSource() }) {} + .padding(20.dp), + verticalArrangement = Arrangement.spacedBy(8.dp), + ) { + // Header + Row(Modifier.fillMaxWidth(), Arrangement.SpaceBetween, Alignment.CenterVertically) { + Text("Servers", style = MaterialTheme.typography.titleMedium, color = Neo.textPrimary()) + IconButton(onClick = onDismiss, modifier = Modifier.size(32.dp)) { + Icon(Icons.Default.Close, "Close", Modifier.size(16.dp), tint = Neo.textMuted()) + } + } + + // Server rows + servers.forEach { server -> + val isActive = server.serialize() == activeServer?.serialize() + ModalRow( + icon = if (isActive) Icons.Default.RadioButtonChecked else Icons.Default.RadioButtonUnchecked, + iconTint = if (isActive) BitcoinOrange else Neo.textMuted(), + label = server.address + if (server.port.isNotBlank()) ":${server.port}" else "", + onClick = { onSelectServer(server) }, + trailing = { + IconButton(onClick = { onRemoveServer(server) }, modifier = Modifier.size(28.dp)) { + Icon(Icons.Default.Close, "Remove", Modifier.size(14.dp), tint = Neo.textMuted()) + } + }, + ) + } + + if (servers.isEmpty()) { + Text("No servers", style = MaterialTheme.typography.bodyMedium, color = Neo.textMuted(), modifier = Modifier.padding(vertical = 4.dp)) + } + + // Add server + if (showAddForm) { + Column( + modifier = Modifier + .fillMaxWidth() + .clip(RoundedCornerShape(ROW_R)) + .background(Neo.surface()) + .padding(12.dp), + verticalArrangement = Arrangement.spacedBy(8.dp), + ) { + OutlinedTextField( + value = newAddress, onValueChange = { newAddress = it.trim() }, + placeholder = { Text("192.168.1.100") }, + modifier = Modifier.fillMaxWidth(), singleLine = true, + keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Uri, imeAction = ImeAction.Next), + colors = neoFieldColors(), + shape = RoundedCornerShape(10.dp), + textStyle = MaterialTheme.typography.bodyMedium, + ) + Row(horizontalArrangement = Arrangement.spacedBy(8.dp), verticalAlignment = Alignment.CenterVertically) { + OutlinedTextField( + value = newPassword, onValueChange = { newPassword = it }, + placeholder = { Text("Password") }, + modifier = Modifier.weight(1f), singleLine = true, + visualTransformation = PasswordVisualTransformation(), + keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Password, imeAction = ImeAction.Go), + keyboardActions = KeyboardActions(onGo = { + if (newAddress.isNotBlank()) { + onAddServer(ServerEntry(newAddress, false, password = newPassword)) + newAddress = ""; newPassword = ""; showAddForm = false + } + }), + colors = neoFieldColors(), + shape = RoundedCornerShape(10.dp), + textStyle = MaterialTheme.typography.bodyMedium, + ) + Box( + modifier = Modifier.size(36.dp).clip(CircleShape).background(BitcoinOrange.copy(alpha = 0.15f)) + .clickable { + if (newAddress.isNotBlank()) { + onAddServer(ServerEntry(newAddress, false, password = newPassword)) + newAddress = ""; newPassword = ""; showAddForm = false + } + }, + contentAlignment = Alignment.Center, + ) { Icon(Icons.Default.Add, "Add", Modifier.size(16.dp), tint = BitcoinOrange) } + } + } + } else { + ModalRow(icon = Icons.Default.Add, iconTint = BitcoinOrange, label = "Add Server", labelColor = BitcoinOrange, onClick = { showAddForm = true }) + } + + HorizontalDivider(color = Neo.border(), modifier = Modifier.padding(vertical = 4.dp)) + + // Gamepad toggle — label says what you switch TO + ModalRow( + icon = if (isGamepadMode) Icons.Default.Keyboard else Icons.Default.Gamepad, + iconTint = Neo.textSecondary(), + label = if (isGamepadMode) "Switch to Keyboard" else "Switch to Gamepad", + onClick = onToggleGamepadMode, + ) + + // Back to dashboard + if (onBackToWebView != null) { + ModalRow(icon = Icons.Default.Web, iconTint = Neo.textSecondary(), label = "Back to Dashboard", onClick = onBackToWebView) + } + } +} + +/** Uniform-height row used for all modal actions */ +@Composable +private fun ModalRow( + icon: ImageVector, + iconTint: Color, + label: String, + onClick: () -> Unit, + labelColor: Color = Neo.textPrimary(), + trailing: (@Composable () -> Unit)? = null, +) { + val bg = Neo.surface() + val light = Neo.shadowLight() + val dark = Neo.shadowDark() + + Row( + modifier = Modifier + .fillMaxWidth() + .height(ROW_H) + .neoRaised(light, dark, ROW_R, 2.dp, 5.dp) + .clip(RoundedCornerShape(ROW_R)) + .background(bg) + .clickable { onClick() } + .padding(horizontal = 14.dp), + verticalAlignment = Alignment.CenterVertically, + ) { + Icon(icon, null, Modifier.size(18.dp), tint = iconTint) + Spacer(Modifier.width(12.dp)) + Text(label, style = MaterialTheme.typography.bodyMedium, color = labelColor, modifier = Modifier.weight(1f)) + if (trailing != null) trailing() + } +} + +@Composable +private fun neoFieldColors() = OutlinedTextFieldDefaults.colors( + focusedBorderColor = BitcoinOrange.copy(alpha = 0.4f), + unfocusedBorderColor = Neo.border(), + cursorColor = BitcoinOrange, + focusedTextColor = Neo.textPrimary(), + unfocusedTextColor = Neo.textPrimary(), +) diff --git a/Android/app/src/main/java/com/archipelago/app/ui/components/Trackpad.kt b/Android/app/src/main/java/com/archipelago/app/ui/components/Trackpad.kt new file mode 100644 index 00000000..27b27ec7 --- /dev/null +++ b/Android/app/src/main/java/com/archipelago/app/ui/components/Trackpad.kt @@ -0,0 +1,107 @@ +package com.archipelago.app.ui.components + +import androidx.compose.foundation.background +import androidx.compose.foundation.gestures.awaitEachGesture +import androidx.compose.foundation.gestures.awaitFirstDown +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableIntStateOf +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.geometry.Offset +import androidx.compose.ui.input.pointer.changedToUp +import androidx.compose.ui.input.pointer.pointerInput +import androidx.compose.ui.input.pointer.positionChange +import androidx.compose.ui.unit.dp +import com.archipelago.app.ui.theme.Neo +import com.archipelago.app.ui.theme.neoInset + +private const val TAP_THRESHOLD = 12f +private const val TAP_TIMEOUT = 250L + +@Composable +fun Trackpad( + onMove: (dx: Int, dy: Int) -> Unit, + onClick: (button: Int) -> Unit, + onScroll: (dy: Int) -> Unit, + onTwoFingerHold: () -> Unit, + modifier: Modifier = Modifier, +) { + var fingers by remember { mutableIntStateOf(0) } + val surface = Neo.surface() + val light = Neo.shadowLight() + val dark = Neo.shadowDark() + val muted = Neo.textMuted() + + Box( + modifier = modifier + .neoInset(light, dark, 20.dp, 3.dp, 6.dp) + .clip(RoundedCornerShape(20.dp)) + .background(surface) + .pointerInput(Unit) { + awaitEachGesture { + val first = awaitFirstDown(requireUnconsumed = false) + var total = Offset.Zero + val t0 = System.currentTimeMillis() + var maxPtrs = 1 + var holdFired = false + var twoStart = 0L + var scrollAcc = 0f + fingers = 1 + + do { + val ev = awaitPointerEvent() + val active = ev.changes.filter { !it.changedToUp() } + maxPtrs = maxOf(maxPtrs, active.size) + fingers = active.size + + when { + active.size >= 2 -> { + if (twoStart == 0L) twoStart = System.currentTimeMillis() + if (!holdFired && System.currentTimeMillis() - twoStart > 500) { + holdFired = true + onTwoFingerHold() + } + if (!holdFired) { + val dy = active.map { it.positionChange().y }.average().toFloat() + scrollAcc += dy + if (kotlin.math.abs(scrollAcc) > 12f) { + onScroll(if (scrollAcc > 0) 1 else -1) + scrollAcc = 0f + } + } + ev.changes.forEach { it.consume() } + } + active.size == 1 && maxPtrs == 1 -> { + val d = active.first().positionChange() + total += d + if (d != Offset.Zero) onMove(d.x.toInt(), d.y.toInt()) + active.first().consume() + } + } + } while (ev.changes.any { it.pressed }) + + fingers = 0 + val elapsed = System.currentTimeMillis() - t0 + if (maxPtrs == 1 && elapsed < TAP_TIMEOUT && total.getDistance() < TAP_THRESHOLD) { + onClick(1) + } + } + }, + contentAlignment = Alignment.Center, + ) { + Text( + text = if (fingers >= 2) "hold for menu" else "", + style = MaterialTheme.typography.labelSmall, + color = muted.copy(alpha = 0.4f), + ) + } +} diff --git a/Android/app/src/main/java/com/archipelago/app/ui/components/VirtualKeyboard.kt b/Android/app/src/main/java/com/archipelago/app/ui/components/VirtualKeyboard.kt new file mode 100644 index 00000000..d09632de --- /dev/null +++ b/Android/app/src/main/java/com/archipelago/app/ui/components/VirtualKeyboard.kt @@ -0,0 +1,173 @@ +package com.archipelago.app.ui.components + +import androidx.compose.foundation.background +import androidx.compose.foundation.gestures.detectTapGestures +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.DisposableEffect +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.rememberCoroutineScope +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.input.pointer.pointerInput +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.text.style.TextAlign +import androidx.compose.ui.unit.Dp +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp +import com.archipelago.app.ui.theme.BitcoinOrange +import com.archipelago.app.ui.theme.Neo +import com.archipelago.app.ui.theme.neoInset +import com.archipelago.app.ui.theme.neoRaised +import kotlinx.coroutines.Job +import kotlinx.coroutines.delay +import kotlinx.coroutines.launch + +private enum class Layer { ALPHA, NUM, SYM } +private val KEY_H = 46.dp +private val KEY_R = 10.dp +private val GAP = 5.dp + +@Composable +fun VirtualKeyboard(onKey: (String) -> Unit, modifier: Modifier = Modifier) { + var layer by remember { mutableStateOf(Layer.ALPHA) } + var shifted by remember { mutableStateOf(false) } + var capsLock by remember { mutableStateOf(false) } + val up = shifted || capsLock + + fun emit(k: String) { onKey(k); if (shifted && !capsLock) shifted = false } + fun ch(c: String) { emit(if (up && layer == Layer.ALPHA) "shift+$c" else c) } + + Column( + modifier = modifier.background(Neo.surface()).padding(horizontal = 6.dp, vertical = 6.dp), + verticalArrangement = Arrangement.spacedBy(GAP), + ) { + when (layer) { + Layer.ALPHA -> { + CRow("q w e r t y u i o p".split(" "), up, ::ch) + CRow("a s d f g h j k l".split(" "), up, ::ch, inset = 18.dp) + Row(Modifier.fillMaxWidth().height(KEY_H), Arrangement.spacedBy(GAP)) { + SKey(if (capsLock) "\u21EA" else "\u21E7", Modifier.weight(1.4f), active = up) { + if (capsLock) { capsLock = false; shifted = false } else if (shifted) capsLock = true else shifted = true + } + "z x c v b n m".split(" ").forEach { c -> CKey(if (up) c.uppercase() else c, Modifier.weight(1f)) { ch(c) } } + RKey("\u232B", Modifier.weight(1.4f)) { emit("BackSpace") } + } + } + Layer.NUM -> { + SRow("1 2 3 4 5 6 7 8 9 0".split(" "), ::emit) + SRow("- / : ; ( ) \$ & @ \"".split(" "), ::emit) + Row(Modifier.fillMaxWidth().height(KEY_H), Arrangement.spacedBy(GAP)) { + SKey("#+=", Modifier.weight(1.4f)) { layer = Layer.SYM } + ". , ? ! '".split(" ").forEach { c -> CKey(c, Modifier.weight(1f)) { emit(c) } } + RKey("\u232B", Modifier.weight(1.4f)) { emit("BackSpace") } + } + } + Layer.SYM -> { + SRow("[ ] { } # % ^ * + =".split(" "), ::emit) + SRow("_ \\ | ~ < > ` @ !".split(" "), ::emit) + Row(Modifier.fillMaxWidth().height(KEY_H), Arrangement.spacedBy(GAP)) { + SKey("123", Modifier.weight(1.4f)) { layer = Layer.NUM } + ". , ? ! '".split(" ").forEach { c -> CKey(c, Modifier.weight(1f)) { emit(c) } } + RKey("\u232B", Modifier.weight(1.4f)) { emit("BackSpace") } + } + } + } + Row(Modifier.fillMaxWidth().height(KEY_H), Arrangement.spacedBy(GAP)) { + SKey(if (layer == Layer.ALPHA) "123" else "ABC", Modifier.weight(1.4f)) { + layer = if (layer == Layer.ALPHA) Layer.NUM else Layer.ALPHA; shifted = false; capsLock = false + } + CKey(",", Modifier.weight(1f)) { emit("comma") } + CKey("space", Modifier.weight(5f), fontSize = 13) { emit("space") } + CKey(".", Modifier.weight(1f)) { emit("period") } + AKey("\u23CE", Modifier.weight(1.4f)) { emit("Return") } + } + } +} + +@Composable +private fun CRow(keys: List, up: Boolean, onKey: (String) -> Unit, inset: Dp = 0.dp) { + Row(Modifier.fillMaxWidth().height(KEY_H).padding(horizontal = inset), Arrangement.spacedBy(GAP)) { + keys.forEach { c -> CKey(if (up) c.uppercase() else c, Modifier.weight(1f)) { onKey(c) } } + } +} +@Composable +private fun SRow(keys: List, onKey: (String) -> Unit) { + Row(Modifier.fillMaxWidth().height(KEY_H), Arrangement.spacedBy(GAP)) { + keys.forEach { c -> CKey(c, Modifier.weight(1f)) { onKey(c) } } + } +} + +/** Character key */ +@Composable +private fun CKey(label: String, modifier: Modifier = Modifier, fontSize: Int = 19, onTap: () -> Unit) { + var p by remember { mutableStateOf(false) } + val bg = Neo.surfaceRaised(); val l = Neo.shadowLight(); val d = Neo.shadowDark(); val t = Neo.textPrimary() + Box( + modifier = modifier.height(KEY_H) + .then(if (p) Modifier.neoInset(l, d, KEY_R) else Modifier.neoRaised(l, d, KEY_R)) + .clip(RoundedCornerShape(KEY_R)).background(bg) + .pointerInput(label) { detectTapGestures(onPress = { p = true; onTap(); tryAwaitRelease(); p = false }) }, + contentAlignment = Alignment.Center, + ) { Text(label, color = t.copy(alpha = if (p) 0.9f else 0.7f), fontSize = fontSize.sp, textAlign = TextAlign.Center, maxLines = 1) } +} + +/** Special key */ +@Composable +private fun SKey(label: String, modifier: Modifier = Modifier, active: Boolean = false, onTap: () -> Unit) { + var p by remember { mutableStateOf(false) } + val bg = Neo.surfaceRaised(); val l = Neo.shadowLight(); val d = Neo.shadowDark() + val tc = if (active) BitcoinOrange.copy(alpha = 0.8f) else Neo.textSecondary() + Box( + modifier = modifier.height(KEY_H) + .then(if (p) Modifier.neoInset(l, d, KEY_R) else Modifier.neoRaised(l, d, KEY_R)) + .clip(RoundedCornerShape(KEY_R)).background(bg) + .pointerInput(label) { detectTapGestures(onPress = { p = true; onTap(); tryAwaitRelease(); p = false }) }, + contentAlignment = Alignment.Center, + ) { Text(label, color = tc, fontSize = 14.sp, fontWeight = FontWeight.Medium, textAlign = TextAlign.Center) } +} + +/** Accent key (return) */ +@Composable +private fun AKey(label: String, modifier: Modifier = Modifier, onTap: () -> Unit) { + var p by remember { mutableStateOf(false) } + val l = Neo.shadowLight(); val d = Neo.shadowDark() + Box( + modifier = modifier.height(KEY_H) + .then(if (p) Modifier.neoInset(l, d, KEY_R) else Modifier.neoRaised(l, d, KEY_R)) + .clip(RoundedCornerShape(KEY_R)).background(Neo.surfaceRaised()) + .pointerInput(Unit) { detectTapGestures(onPress = { p = true; onTap(); tryAwaitRelease(); p = false }) }, + contentAlignment = Alignment.Center, + ) { Text(label, color = BitcoinOrange.copy(alpha = 0.7f), fontSize = 17.sp, fontWeight = FontWeight.Bold) } +} + +/** Repeatable key (backspace) */ +@Composable +private fun RKey(label: String, modifier: Modifier = Modifier, onTap: () -> Unit) { + var p by remember { mutableStateOf(false) } + val scope = rememberCoroutineScope(); var job by remember { mutableStateOf(null) } + val l = Neo.shadowLight(); val d = Neo.shadowDark() + DisposableEffect(Unit) { onDispose { job?.cancel() } } + Box( + modifier = modifier.height(KEY_H) + .then(if (p) Modifier.neoInset(l, d, KEY_R) else Modifier.neoRaised(l, d, KEY_R)) + .clip(RoundedCornerShape(KEY_R)).background(Neo.surfaceRaised()) + .pointerInput(Unit) { detectTapGestures(onPress = { + p = true; onTap(); job = scope.launch { delay(400); while (true) { onTap(); delay(55) } } + tryAwaitRelease(); job?.cancel(); p = false + }) }, + contentAlignment = Alignment.Center, + ) { Text(label, color = Neo.textSecondary(), fontSize = 17.sp) } +} diff --git a/Android/app/src/main/java/com/archipelago/app/ui/navigation/NavGraph.kt b/Android/app/src/main/java/com/archipelago/app/ui/navigation/NavGraph.kt new file mode 100644 index 00000000..4a58bc4c --- /dev/null +++ b/Android/app/src/main/java/com/archipelago/app/ui/navigation/NavGraph.kt @@ -0,0 +1,107 @@ +package com.archipelago.app.ui.navigation + +import androidx.compose.runtime.Composable +import androidx.compose.runtime.collectAsState +import androidx.compose.runtime.getValue +import androidx.compose.runtime.remember +import androidx.compose.runtime.rememberCoroutineScope +import androidx.compose.ui.platform.LocalContext +import androidx.navigation.compose.NavHost +import androidx.navigation.compose.composable +import androidx.navigation.compose.rememberNavController +import com.archipelago.app.data.ServerPreferences +import com.archipelago.app.ui.screens.IntroScreen +import com.archipelago.app.ui.screens.RemoteInputScreen +import com.archipelago.app.ui.screens.ServerConnectScreen +import com.archipelago.app.ui.screens.WebViewScreen +import kotlinx.coroutines.launch + +object Routes { + const val INTRO = "intro" + const val SERVER_CONNECT = "server_connect" + const val WEB_VIEW = "web_view" + const val REMOTE_INPUT = "remote_input" +} + +@Composable +fun AppNavHost() { + val context = LocalContext.current + val prefs = remember { ServerPreferences(context) } + val navController = rememberNavController() + val scope = rememberCoroutineScope() + + val introSeen by prefs.introSeen.collectAsState(initial = null) + val activeServer by prefs.activeServer.collectAsState(initial = null) + + if (introSeen == null) return + + val startDestination = when { + introSeen == false -> Routes.INTRO + activeServer != null -> Routes.WEB_VIEW + else -> Routes.SERVER_CONNECT + } + + NavHost( + navController = navController, + startDestination = startDestination, + ) { + composable(Routes.INTRO) { + IntroScreen( + onContinue = { + scope.launch { + prefs.markIntroSeen() + navController.navigate(Routes.SERVER_CONNECT) { + popUpTo(Routes.INTRO) { inclusive = true } + } + } + }, + ) + } + + composable(Routes.SERVER_CONNECT) { + ServerConnectScreen( + onConnected = { _ -> + navController.navigate(Routes.WEB_VIEW) { + popUpTo(Routes.SERVER_CONNECT) { inclusive = true } + } + }, + ) + } + + composable(Routes.WEB_VIEW) { + val server = activeServer + if (server == null) { + ServerConnectScreen( + onConnected = { _ -> + navController.navigate(Routes.WEB_VIEW) { + popUpTo(0) { inclusive = true } + } + }, + ) + } else { + WebViewScreen( + serverUrl = server.toUrl(), + onDisconnect = { + scope.launch { + prefs.clearActiveServer() + navController.navigate(Routes.SERVER_CONNECT) { + popUpTo(0) { inclusive = true } + } + } + }, + onRemoteInput = { + navController.navigate(Routes.REMOTE_INPUT) + }, + ) + } + } + + composable(Routes.REMOTE_INPUT) { + RemoteInputScreen( + onBack = { + navController.popBackStack() + }, + ) + } + } +} diff --git a/Android/app/src/main/java/com/archipelago/app/ui/screens/IntroScreen.kt b/Android/app/src/main/java/com/archipelago/app/ui/screens/IntroScreen.kt new file mode 100644 index 00000000..5426a825 --- /dev/null +++ b/Android/app/src/main/java/com/archipelago/app/ui/screens/IntroScreen.kt @@ -0,0 +1,243 @@ +package com.archipelago.app.ui.screens + +import androidx.compose.animation.AnimatedVisibility +import androidx.compose.animation.core.Animatable +import androidx.compose.animation.core.animateFloatAsState +import androidx.compose.animation.core.tween +import androidx.compose.animation.fadeIn +import androidx.compose.animation.slideInVertically +import androidx.compose.foundation.Canvas +import androidx.compose.foundation.Image +import androidx.compose.foundation.background +import androidx.compose.foundation.border +import androidx.compose.foundation.clickable +import androidx.compose.foundation.interaction.MutableInteractionSource +import androidx.compose.foundation.interaction.collectIsPressedAsState +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.WindowInsets +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.safeDrawing +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.layout.windowInsetsPadding +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.alpha +import androidx.compose.ui.draw.clip +import androidx.compose.ui.geometry.Offset +import androidx.compose.ui.geometry.Size +import androidx.compose.ui.graphics.Brush +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.layout.ContentScale +import androidx.compose.ui.res.painterResource +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.style.TextAlign +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp +import com.archipelago.app.R +import com.archipelago.app.ui.theme.SurfaceBlack +import com.archipelago.app.ui.theme.TextMuted +import com.archipelago.app.ui.theme.TextPrimary +import kotlinx.coroutines.delay + +@Composable +fun IntroScreen(onContinue: () -> Unit) { + val logoAlpha = remember { Animatable(0f) } + var showContent by remember { mutableStateOf(false) } + + LaunchedEffect(Unit) { + logoAlpha.animateTo(1f, animationSpec = tween(800)) + delay(300) + showContent = true + } + + Box( + modifier = Modifier + .fillMaxSize() + .background(SurfaceBlack), + ) { + // Reddish synthwave backdrop + Image( + painter = painterResource(id = R.drawable.bg_synthwave), + contentDescription = null, + modifier = Modifier.fillMaxSize(), + contentScale = ContentScale.Crop, + ) + // Dark scrim so the title/buttons stay legible over the art + Box( + modifier = Modifier + .fillMaxSize() + .background( + Brush.verticalGradient( + colors = listOf( + Color.Black.copy(alpha = 0.55f), + Color.Black.copy(alpha = 0.35f), + Color.Black.copy(alpha = 0.75f), + ), + ) + ), + ) + Column( + modifier = Modifier + .align(Alignment.Center) + .fillMaxWidth() + .windowInsetsPadding(WindowInsets.safeDrawing) + .padding(horizontal = 32.dp), + horizontalAlignment = Alignment.CenterHorizontally, + verticalArrangement = Arrangement.Center, + ) { + // Circular badge logo + Image( + painter = painterResource(id = R.drawable.ic_logo), + contentDescription = "Archipelago", + modifier = Modifier + .size(160.dp) + .alpha(logoAlpha.value), + ) + + Spacer(modifier = Modifier.height(48.dp)) + + AnimatedVisibility( + visible = showContent, + enter = fadeIn(tween(600)) + slideInVertically( + initialOffsetY = { it / 4 }, + animationSpec = tween(600), + ), + ) { + Column(horizontalAlignment = Alignment.CenterHorizontally) { + Text( + text = stringResource(R.string.welcome_title), + style = MaterialTheme.typography.headlineLarge, + color = Color(0xFFFAFAFA), + textAlign = TextAlign.Center, + ) + + Spacer(modifier = Modifier.height(16.dp)) + + Text( + text = stringResource(R.string.welcome_subtitle), + style = MaterialTheme.typography.bodyLarge, + color = Color(0xFFFAFAFA), + textAlign = TextAlign.Center, + lineHeight = 26.sp, + ) + + Spacer(modifier = Modifier.height(48.dp)) + + GlassButton( + text = stringResource(R.string.get_started), + onClick = onContinue, + modifier = Modifier.fillMaxWidth().height(56.dp), + ) + } + } + } + } +} + +/** The pixel-art "A" from AnimatedLogo.vue — 20 white squares */ +@Composable +fun PixelArtLogo(modifier: Modifier = Modifier) { + Canvas(modifier = modifier) { + val s = size.width / 1024f + val rects = listOf( + floatArrayOf(357.614f, 318f, 71.007f, 70.936f), + floatArrayOf(436.152f, 318f, 72.082f, 70.936f), + floatArrayOf(515.766f, 318f, 72.082f, 70.936f), + floatArrayOf(595.379f, 318f, 71.007f, 70.936f), + floatArrayOf(595.379f, 396.46f, 71.007f, 72.011f), + floatArrayOf(673.917f, 396.46f, 72.083f, 72.011f), + floatArrayOf(278f, 475.994f, 72.083f, 72.012f), + floatArrayOf(357.614f, 475.994f, 71.007f, 72.012f), + floatArrayOf(436.152f, 475.994f, 72.082f, 72.012f), + floatArrayOf(515.766f, 475.994f, 72.082f, 72.012f), + floatArrayOf(595.379f, 475.994f, 71.007f, 72.012f), + floatArrayOf(673.917f, 475.994f, 72.083f, 72.012f), + floatArrayOf(278f, 555.529f, 72.083f, 70.936f), + floatArrayOf(357.614f, 555.529f, 71.007f, 70.936f), + floatArrayOf(595.379f, 555.529f, 71.007f, 70.936f), + floatArrayOf(673.917f, 555.529f, 72.083f, 70.936f), + floatArrayOf(357.614f, 633.989f, 71.007f, 72.011f), + floatArrayOf(436.152f, 633.989f, 72.082f, 72.011f), + floatArrayOf(515.766f, 633.989f, 72.082f, 72.011f), + floatArrayOf(595.379f, 633.989f, 71.007f, 72.011f), + ) + for (r in rects) { + drawRect( + color = Color.White, + topLeft = Offset(r[0] * s, r[1] * s), + size = Size(r[2] * s, r[3] * s), + ) + } + } +} + +/** + * Glass-style button matching Archipelago's .glass-button. + * Custom press state (subtle brighten) instead of Material ripple. + */ +@Composable +fun GlassButton( + text: String, + onClick: () -> Unit, + modifier: Modifier = Modifier, +) { + val interactionSource = remember { MutableInteractionSource() } + val isPressed by interactionSource.collectIsPressedAsState() + val pressAlpha by animateFloatAsState( + targetValue = if (isPressed) 1f else 0f, + animationSpec = tween(if (isPressed) 0 else 150), + label = "press", + ) + + // Lerp between rest and pressed states + val bgTop = 0.12f + pressAlpha * 0.08f // 0.12 → 0.20 + val bgBottom = 0.04f + pressAlpha * 0.06f // 0.04 → 0.10 + val borderA = 0.15f + pressAlpha * 0.10f // 0.15 → 0.25 + val textAlpha = 1f - pressAlpha * 0.2f // 1.0 → 0.8 + + Box( + modifier = modifier + .clip(RoundedCornerShape(12.dp)) + .background( + Brush.verticalGradient( + colors = listOf( + Color.White.copy(alpha = bgTop), + Color.White.copy(alpha = bgBottom), + ), + ) + ) + .border( + width = 1.dp, + color = Color.White.copy(alpha = borderA), + shape = RoundedCornerShape(12.dp), + ) + .clickable( + interactionSource = interactionSource, + indication = null, + onClick = onClick, + ), + contentAlignment = Alignment.Center, + ) { + Text( + text = text, + color = Color.White.copy(alpha = textAlpha), + style = MaterialTheme.typography.labelLarge, + fontSize = 16.sp, + ) + } +} diff --git a/Android/app/src/main/java/com/archipelago/app/ui/screens/RemoteInputScreen.kt b/Android/app/src/main/java/com/archipelago/app/ui/screens/RemoteInputScreen.kt new file mode 100644 index 00000000..ad35d89e --- /dev/null +++ b/Android/app/src/main/java/com/archipelago/app/ui/screens/RemoteInputScreen.kt @@ -0,0 +1,251 @@ +package com.archipelago.app.ui.screens + +import android.content.res.Configuration +import androidx.activity.compose.BackHandler +import androidx.compose.foundation.Image +import androidx.compose.foundation.background +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.WindowInsets +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.safeDrawing +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.layout.windowInsetsPadding +import androidx.compose.foundation.shape.CircleShape +import androidx.compose.runtime.Composable +import androidx.compose.runtime.DisposableEffect +import androidx.compose.runtime.collectAsState +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.rememberCoroutineScope +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.graphics.Brush +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.layout.ContentScale +import androidx.compose.ui.platform.LocalConfiguration +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.platform.LocalLifecycleOwner +import androidx.lifecycle.Lifecycle +import androidx.lifecycle.LifecycleEventObserver +import androidx.compose.ui.res.painterResource +import androidx.compose.ui.unit.dp +import com.archipelago.app.R +import com.archipelago.app.data.ServerPreferences +import com.archipelago.app.network.ConnectionState +import com.archipelago.app.network.InputWebSocket +import com.archipelago.app.ui.components.NESController +import com.archipelago.app.ui.components.NESKeyboard +import com.archipelago.app.ui.components.NESMenu +import com.archipelago.app.ui.components.NESPortraitController +import com.archipelago.app.ui.components.Trackpad +import com.archipelago.app.ui.theme.BitcoinOrange +import com.archipelago.app.ui.theme.ControllerStyle +import com.archipelago.app.ui.theme.ErrorRed +import com.archipelago.app.ui.theme.SuccessGreen +import com.archipelago.app.ui.theme.TextMuted +import kotlinx.coroutines.launch + +@Composable +fun RemoteInputScreen(onBack: () -> Unit) { + val context = LocalContext.current + val prefs = remember { ServerPreferences(context) } + val scope = rememberCoroutineScope() + val isLandscape = LocalConfiguration.current.orientation == Configuration.ORIENTATION_LANDSCAPE + + val savedServers by prefs.savedServers.collectAsState(initial = emptyList()) + val activeServer by prefs.activeServer.collectAsState(initial = null) + + var isGamepadMode by remember { mutableStateOf(true) } + var showModal by remember { mutableStateOf(false) } + var controllerStyle by remember { mutableStateOf(ControllerStyle.DARK) } + var playerId by remember { mutableStateOf(0) } // 0 = broadcast, 1 = P1, 2 = P2 + + val ws = remember { InputWebSocket(scope) } + + // When the kiosk forwards an "open in external browser" app, launch it in + // the phone's default browser. + DisposableEffect(ws) { + ws.onExternalOpen = { url -> + try { + val intent = android.content.Intent( + android.content.Intent.ACTION_VIEW, + android.net.Uri.parse(url), + ).apply { addFlags(android.content.Intent.FLAG_ACTIVITY_NEW_TASK) } + context.startActivity(intent) + } catch (_: Exception) {} + } + onDispose { ws.onExternalOpen = null } + } + + fun togglePlayer() { + playerId = when (playerId) { 0 -> 1; 1 -> 2; else -> 0 } + ws.playerId = playerId + } + val connectionState by ws.state.collectAsState() + val lifecycleOwner = LocalLifecycleOwner.current + + BackHandler { onBack() } + + // Connect on server change + reconnect when app resumes from background + DisposableEffect(lifecycleOwner, activeServer) { + val server = activeServer + if (server != null) { + ws.connect(server.toUrl(), server.password) + } + + val observer = LifecycleEventObserver { _, event -> + if (event == Lifecycle.Event.ON_RESUME && server != null) { + val state = ws.state.value + if (state != ConnectionState.CONNECTED && state != ConnectionState.CONNECTING) { + ws.connect(server.toUrl(), server.password) + } + } + } + lifecycleOwner.lifecycle.addObserver(observer) + + onDispose { + lifecycleOwner.lifecycle.removeObserver(observer) + ws.disconnect() + } + } + + Box( + Modifier + .fillMaxSize() + .background(Color(0xFF0C0C0C)), + ) { + // Reddish synthwave backdrop behind the controller + Image( + painter = painterResource(id = R.drawable.bg_synthwave), + contentDescription = null, + modifier = Modifier.fillMaxSize(), + contentScale = ContentScale.Crop, + ) + // Light scrim — the controller body provides its own contrast, so keep + // this subtle and let the backdrop show through around it. + Box( + modifier = Modifier + .fillMaxSize() + .background( + Brush.verticalGradient( + colors = listOf( + Color.Black.copy(alpha = 0.4f), + Color.Black.copy(alpha = 0.25f), + Color.Black.copy(alpha = 0.45f), + ), + ) + ), + ) + Box(Modifier.fillMaxSize().windowInsetsPadding(WindowInsets.safeDrawing)) { + when { + isGamepadMode && isLandscape -> NESController( + style = controllerStyle, + playerId = playerId, + onKey = { ws.sendKey(it) }, + onMenu = { showModal = true }, + onPlayerToggle = ::togglePlayer, + ) + isGamepadMode && !isLandscape -> NESPortraitController( + style = controllerStyle, + playerId = playerId, + onKey = { ws.sendKey(it) }, + onMouseMove = { dx, dy -> ws.sendMouseMove(dx, dy) }, + onMouseClick = { ws.sendClick(it) }, + onMouseScroll = { ws.sendScroll(it) }, + onMenu = { showModal = true }, + onPlayerToggle = ::togglePlayer, + ) + else -> { + // Keyboard mode: trackpad fills top, keyboard pinned bottom + Box(Modifier.fillMaxSize()) { + Column(Modifier.fillMaxSize()) { + Trackpad( + onMove = { dx, dy -> ws.sendMouseMove(dx, dy) }, + onClick = { ws.sendClick(it) }, + onScroll = { ws.sendScroll(it) }, + onTwoFingerHold = { showModal = true }, + modifier = Modifier.fillMaxWidth().weight(1f) + .padding(horizontal = 16.dp, vertical = 8.dp), + ) + NESKeyboard( + style = controllerStyle, + onKey = { ws.sendKey(it) }, + modifier = Modifier.fillMaxWidth(), + ) + } + // Settings icon top-right in keyboard mode + com.archipelago.app.ui.components.SettingsBtn( + c = com.archipelago.app.ui.components.paletteFor(controllerStyle), + modifier = Modifier.align(Alignment.TopEnd).padding(8.dp), + onClick = { showModal = true }, + ) + } + } + } + + // Connection dot + Box( + Modifier.align(Alignment.TopStart).padding(6.dp).size(8.dp) + .clip(CircleShape).background( + when (connectionState) { + ConnectionState.CONNECTED -> SuccessGreen + ConnectionState.CONNECTING -> BitcoinOrange + ConnectionState.ERROR, ConnectionState.AUTH_FAILED -> ErrorRed + ConnectionState.DISCONNECTED -> TextMuted + } + ), + ) + } + + NESMenu( + visible = showModal, + servers = savedServers, + activeServer = activeServer, + isGamepadMode = isGamepadMode, + controllerStyle = controllerStyle, + onDismiss = { showModal = false }, + onSelectServer = { server -> + scope.launch { ws.disconnect(); prefs.setActiveServer(server) }; showModal = false + }, + onAddServer = { server -> + scope.launch { prefs.addSavedServer(server); if (activeServer == null) prefs.setActiveServer(server) } + }, + onEditServer = { original, updated -> + scope.launch { + prefs.updateSavedServer(original, updated) + // If the edited server is the live one, reconnect with the new + // address/credentials so the change takes effect immediately. + if (original.serialize() == activeServer?.serialize()) { + ws.disconnect() + prefs.setActiveServer(updated) + } + } + }, + onRemoveServer = { server -> + scope.launch { + prefs.removeSavedServer(server) + // Deleting the last server leaves nothing to control — drop the + // active server and return to the Connect screen. + val remaining = savedServers.count { it.serialize() != server.serialize() } + if (remaining == 0) { + ws.disconnect() + prefs.clearActiveServer() + showModal = false + onBack() + } + } + }, + onToggleMode = { isGamepadMode = !isGamepadMode; showModal = false }, + onToggleStyle = { + controllerStyle = if (controllerStyle == ControllerStyle.CLASSIC) ControllerStyle.DARK else ControllerStyle.CLASSIC + }, + onBackToWebView = { showModal = false; onBack() }, + ) + } +} diff --git a/Android/app/src/main/java/com/archipelago/app/ui/screens/ServerConnectScreen.kt b/Android/app/src/main/java/com/archipelago/app/ui/screens/ServerConnectScreen.kt new file mode 100644 index 00000000..2edaafe5 --- /dev/null +++ b/Android/app/src/main/java/com/archipelago/app/ui/screens/ServerConnectScreen.kt @@ -0,0 +1,603 @@ +package com.archipelago.app.ui.screens + +import androidx.compose.animation.AnimatedVisibility +import androidx.compose.animation.fadeIn +import androidx.compose.animation.fadeOut +import androidx.compose.foundation.Image +import androidx.compose.foundation.background +import androidx.compose.foundation.border +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.WindowInsets +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.safeDrawing +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.layout.width +import androidx.compose.foundation.layout.windowInsetsPadding +import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.foundation.text.KeyboardActions +import androidx.compose.foundation.text.KeyboardOptions +import androidx.compose.material.icons.filled.Visibility +import androidx.compose.material.icons.filled.VisibilityOff +import androidx.compose.foundation.verticalScroll +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.Close +import androidx.compose.material.icons.filled.Edit +import androidx.compose.material.icons.filled.Lock +import androidx.compose.material.icons.filled.LockOpen +import androidx.compose.material3.CircularProgressIndicator +import androidx.compose.material3.Icon +import androidx.compose.material3.IconButton +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.OutlinedTextField +import androidx.compose.material3.OutlinedTextFieldDefaults +import androidx.compose.material3.Switch +import androidx.compose.material3.SwitchDefaults +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.collectAsState +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.rememberCoroutineScope +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.draw.drawWithContent +import androidx.compose.ui.graphics.Brush +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.ColorFilter +import androidx.compose.ui.layout.ContentScale +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.platform.LocalSoftwareKeyboardController +import androidx.compose.ui.res.painterResource +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.input.ImeAction +import androidx.compose.ui.text.input.KeyboardType +import androidx.compose.ui.text.input.PasswordVisualTransformation +import androidx.compose.ui.text.input.VisualTransformation +import androidx.compose.ui.text.style.TextAlign +import androidx.compose.ui.text.style.TextOverflow +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp +import com.archipelago.app.R +import com.archipelago.app.data.ServerEntry +import com.archipelago.app.data.ServerPreferences +import com.archipelago.app.ui.theme.BitcoinOrange +import com.archipelago.app.ui.theme.ErrorRed +import com.archipelago.app.ui.theme.SurfaceBlack +import com.archipelago.app.ui.theme.SurfaceCard +import com.archipelago.app.ui.theme.SuccessGreen +import com.archipelago.app.ui.theme.TextMuted +import com.archipelago.app.ui.theme.TextPrimary +import com.archipelago.app.ui.theme.TextSecondary +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.launch +import kotlinx.coroutines.withContext +import java.net.HttpURLConnection +import java.net.URL +import javax.net.ssl.HttpsURLConnection +import javax.net.ssl.SSLContext +import javax.net.ssl.X509TrustManager + +@Composable +fun ServerConnectScreen( + onConnected: (String) -> Unit, + onRemoteInput: () -> Unit = {}, +) { + val context = LocalContext.current + val prefs = remember { ServerPreferences(context) } + val scope = rememberCoroutineScope() + val keyboard = LocalSoftwareKeyboardController.current + + var name by remember { mutableStateOf("") } + var address by remember { mutableStateOf("") } + var port by remember { mutableStateOf("") } + var password by remember { mutableStateOf("") } + var passwordVisible by remember { mutableStateOf(false) } + var useHttps by remember { mutableStateOf(false) } + var isConnecting by remember { mutableStateOf(false) } + var errorMessage by remember { mutableStateOf(null) } + // The saved server currently being edited, or null when adding/connecting. + var editingServer by remember { mutableStateOf(null) } + + val savedServers by prefs.savedServers.collectAsState(initial = emptyList()) + + fun clearForm() { + name = "" + address = "" + port = "" + password = "" + useHttps = false + passwordVisible = false + errorMessage = null + } + + fun startEdit(server: ServerEntry) { + editingServer = server + name = server.name + address = server.address + port = server.port + password = server.password + useHttps = server.useHttps + passwordVisible = false + errorMessage = null + } + + fun cancelEdit() { + editingServer = null + clearForm() + } + + fun saveEdit() { + val original = editingServer ?: return + if (address.isBlank()) { + errorMessage = "Enter a server address" + return + } + val updated = ServerEntry(address, useHttps, port, password, name) + scope.launch { + prefs.updateSavedServer(original, updated) + cancelEdit() + } + } + + fun connect(server: ServerEntry) { + if (isConnecting) return + if (server.address.isBlank()) { + errorMessage = "Enter a server address" + return + } + isConnecting = true + errorMessage = null + + scope.launch { + val result = testConnection(server) + isConnecting = false + + if (result) { + prefs.setActiveServer(server) + onConnected(server.toUrl()) + } else { + errorMessage = context.getString(R.string.connection_failed) + } + } + } + + Box( + modifier = Modifier + .fillMaxSize() + .background(SurfaceBlack), + ) { + // Reddish synthwave backdrop + Image( + painter = painterResource(id = R.drawable.bg_synthwave), + contentDescription = null, + modifier = Modifier.fillMaxSize(), + contentScale = ContentScale.Crop, + ) + // Dark scrim so the form stays legible over the art + Box( + modifier = Modifier + .fillMaxSize() + .background( + Brush.verticalGradient( + colors = listOf( + Color.Black.copy(alpha = 0.6f), + Color.Black.copy(alpha = 0.45f), + Color.Black.copy(alpha = 0.8f), + ), + ) + ), + ) + Column( + modifier = Modifier + .fillMaxSize() + .windowInsetsPadding(WindowInsets.safeDrawing) + .verticalScroll(state = rememberScrollState()) + .drawWithContent { drawContent() } + .padding(horizontal = 24.dp) + .padding(top = 48.dp, bottom = 32.dp), + horizontalAlignment = Alignment.CenterHorizontally, + verticalArrangement = Arrangement.spacedBy(16.dp), + ) { + // Circular badge logo + Image( + painter = painterResource(id = R.drawable.ic_logo), + contentDescription = "Archipelago", + modifier = Modifier.size(96.dp), + ) + + Spacer(modifier = Modifier.height(4.dp)) + + Text( + text = if (editingServer != null) stringResource(R.string.edit_server_title) else "Connect to Server", + style = MaterialTheme.typography.headlineMedium, + color = TextPrimary, + textAlign = TextAlign.Center, + ) + + Text( + text = stringResource(R.string.server_address_hint), + style = MaterialTheme.typography.bodyMedium, + color = TextMuted, + textAlign = TextAlign.Center, + ) + + Spacer(modifier = Modifier.height(4.dp)) + + // Glass card with form + Box( + modifier = Modifier + .fillMaxWidth() + .clip(RoundedCornerShape(16.dp)) + .background(Color.Black.copy(alpha = 0.6f)) + .background( + Brush.verticalGradient( + colors = listOf( + Color.White.copy(alpha = 0.06f), + Color.White.copy(alpha = 0.02f), + ), + ) + ) + .border(1.dp, Color.White.copy(alpha = 0.1f), RoundedCornerShape(16.dp)) + .padding(20.dp), + ) { + Column { + OutlinedTextField( + value = name, + onValueChange = { + name = it + errorMessage = null + }, + label = { Text(stringResource(R.string.server_name_label)) }, + placeholder = { Text(stringResource(R.string.server_name_placeholder)) }, + modifier = Modifier.fillMaxWidth(), + singleLine = true, + keyboardOptions = KeyboardOptions( + keyboardType = KeyboardType.Text, + imeAction = ImeAction.Next, + ), + colors = OutlinedTextFieldDefaults.colors( + focusedBorderColor = Color.White.copy(alpha = 0.3f), + unfocusedBorderColor = Color.White.copy(alpha = 0.12f), + cursorColor = Color.White, + focusedLabelColor = Color.White.copy(alpha = 0.7f), + unfocusedLabelColor = TextMuted, + focusedTextColor = TextPrimary, + unfocusedTextColor = TextPrimary, + ), + shape = RoundedCornerShape(12.dp), + ) + + Spacer(modifier = Modifier.height(12.dp)) + + OutlinedTextField( + value = address, + onValueChange = { + address = sanitizeAddress(it) + errorMessage = null + }, + label = { Text(stringResource(R.string.server_address_label)) }, + placeholder = { Text(stringResource(R.string.server_address_placeholder)) }, + modifier = Modifier.fillMaxWidth(), + singleLine = true, + keyboardOptions = KeyboardOptions( + keyboardType = KeyboardType.Uri, + imeAction = ImeAction.Next, + ), + colors = OutlinedTextFieldDefaults.colors( + focusedBorderColor = Color.White.copy(alpha = 0.3f), + unfocusedBorderColor = Color.White.copy(alpha = 0.12f), + cursorColor = Color.White, + focusedLabelColor = Color.White.copy(alpha = 0.7f), + unfocusedLabelColor = TextMuted, + focusedTextColor = TextPrimary, + unfocusedTextColor = TextPrimary, + ), + shape = RoundedCornerShape(12.dp), + ) + + Spacer(modifier = Modifier.height(12.dp)) + + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.spacedBy(12.dp), + ) { + OutlinedTextField( + value = port, + onValueChange = { + port = it.filter { c -> c.isDigit() }.take(5) + errorMessage = null + }, + label = { Text(stringResource(R.string.port_label)) }, + placeholder = { Text("80") }, + modifier = Modifier.weight(1f), + singleLine = true, + keyboardOptions = KeyboardOptions( + keyboardType = KeyboardType.Number, + imeAction = ImeAction.Next, + ), + colors = OutlinedTextFieldDefaults.colors( + focusedBorderColor = Color.White.copy(alpha = 0.3f), + unfocusedBorderColor = Color.White.copy(alpha = 0.12f), + cursorColor = Color.White, + focusedLabelColor = Color.White.copy(alpha = 0.7f), + unfocusedLabelColor = TextMuted, + focusedTextColor = TextPrimary, + unfocusedTextColor = TextPrimary, + ), + shape = RoundedCornerShape(12.dp), + ) + + OutlinedTextField( + value = password, + onValueChange = { + password = it + errorMessage = null + }, + label = { Text("Password") }, + modifier = Modifier.weight(2f), + singleLine = true, + visualTransformation = if (passwordVisible) VisualTransformation.None else PasswordVisualTransformation(), + trailingIcon = { + IconButton(onClick = { passwordVisible = !passwordVisible }) { + Icon( + imageVector = if (passwordVisible) Icons.Default.VisibilityOff else Icons.Default.Visibility, + contentDescription = if (passwordVisible) "Hide password" else "Show password", + tint = TextMuted, + modifier = Modifier.size(20.dp), + ) + } + }, + keyboardOptions = KeyboardOptions( + keyboardType = KeyboardType.Password, + imeAction = ImeAction.Go, + ), + keyboardActions = KeyboardActions( + onGo = { + keyboard?.hide() + if (editingServer != null) { + saveEdit() + } else { + connect(ServerEntry(address, useHttps, port, password, name)) + } + }, + ), + colors = OutlinedTextFieldDefaults.colors( + focusedBorderColor = Color.White.copy(alpha = 0.3f), + unfocusedBorderColor = Color.White.copy(alpha = 0.12f), + cursorColor = Color.White, + focusedLabelColor = Color.White.copy(alpha = 0.7f), + unfocusedLabelColor = TextMuted, + focusedTextColor = TextPrimary, + unfocusedTextColor = TextPrimary, + ), + shape = RoundedCornerShape(12.dp), + ) + } + + Spacer(modifier = Modifier.height(16.dp)) + + Row( + modifier = Modifier.fillMaxWidth(), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.SpaceBetween, + ) { + Row(verticalAlignment = Alignment.CenterVertically) { + Icon( + imageVector = if (useHttps) Icons.Default.Lock else Icons.Default.LockOpen, + contentDescription = null, + modifier = Modifier.size(20.dp), + tint = if (useHttps) SuccessGreen else TextMuted, + ) + Spacer(modifier = Modifier.width(8.dp)) + Text( + text = stringResource(R.string.use_https), + style = MaterialTheme.typography.bodyMedium, + color = TextSecondary, + ) + } + Switch( + checked = useHttps, + onCheckedChange = { useHttps = it }, + colors = SwitchDefaults.colors( + checkedThumbColor = SurfaceBlack, + checkedTrackColor = BitcoinOrange, + uncheckedThumbColor = TextMuted, + uncheckedTrackColor = SurfaceCard, + ), + ) + } + } + } + + // Error + AnimatedVisibility(visible = errorMessage != null, enter = fadeIn(), exit = fadeOut()) { + Box( + modifier = Modifier + .fillMaxWidth() + .clip(RoundedCornerShape(12.dp)) + .background(ErrorRed.copy(alpha = 0.12f)) + .border(1.dp, ErrorRed.copy(alpha = 0.25f), RoundedCornerShape(12.dp)) + .padding(12.dp), + ) { + Text(text = errorMessage ?: "", color = ErrorRed, style = MaterialTheme.typography.bodyMedium) + } + } + + if (editingServer != null) { + // Save / Cancel while editing an existing saved server + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.spacedBy(12.dp), + ) { + GlassButton( + text = stringResource(R.string.cancel), + onClick = { + keyboard?.hide() + cancelEdit() + }, + modifier = Modifier.weight(1f).height(56.dp), + ) + GlassButton( + text = stringResource(R.string.save_changes), + onClick = { + keyboard?.hide() + saveEdit() + }, + modifier = Modifier.weight(1f).height(56.dp), + ) + } + } else { + // Connect button — glass style + GlassButton( + text = if (isConnecting) stringResource(R.string.connecting) else stringResource(R.string.connect), + onClick = { + keyboard?.hide() + connect(ServerEntry(address, useHttps, port, password, name)) + }, + modifier = Modifier.fillMaxWidth().height(56.dp), + ) + } + + if (isConnecting) { + CircularProgressIndicator( + modifier = Modifier.size(24.dp), + color = Color.White.copy(alpha = 0.6f), + strokeWidth = 2.dp, + ) + } + + // Saved servers (hidden while editing one to keep focus on the form) + if (editingServer == null && savedServers.isNotEmpty()) { + Spacer(modifier = Modifier.height(8.dp)) + Text( + text = stringResource(R.string.saved_servers), + style = MaterialTheme.typography.labelMedium, + color = TextMuted, + letterSpacing = 1.sp, + modifier = Modifier.fillMaxWidth(), + ) + + savedServers.forEach { server -> + SavedServerItem( + server = server, + onConnect = { connect(it) }, + onEdit = { startEdit(it) }, + onRemove = { scope.launch { prefs.removeSavedServer(it) } }, + ) + } + } + } + } +} + +@Composable +private fun SavedServerItem( + server: ServerEntry, + onConnect: (ServerEntry) -> Unit, + onEdit: (ServerEntry) -> Unit, + onRemove: (ServerEntry) -> Unit, +) { + Row( + modifier = Modifier + .fillMaxWidth() + .clip(RoundedCornerShape(12.dp)) + .background(Color.Black.copy(alpha = 0.6f)) + .background( + Brush.verticalGradient( + colors = listOf( + Color.White.copy(alpha = 0.06f), + Color.White.copy(alpha = 0.02f), + ), + ) + ) + .border(1.dp, Color.White.copy(alpha = 0.1f), RoundedCornerShape(12.dp)) + .clickable { onConnect(server) } + .padding(horizontal = 16.dp, vertical = 14.dp), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.SpaceBetween, + ) { + Row(verticalAlignment = Alignment.CenterVertically, modifier = Modifier.weight(1f)) { + Icon( + imageVector = if (server.useHttps) Icons.Default.Lock else Icons.Default.LockOpen, + contentDescription = null, + modifier = Modifier.size(20.dp), + tint = if (server.useHttps) SuccessGreen else BitcoinOrange, + ) + Spacer(modifier = Modifier.width(12.dp)) + Column { + Text(text = server.displayName(), style = MaterialTheme.typography.bodyMedium, color = TextPrimary, maxLines = 1, overflow = TextOverflow.Ellipsis) + val secondary = buildString { + if (server.name.isNotBlank()) append(server.address) + if (server.port.isNotBlank()) { + if (isNotEmpty()) append(":${server.port}") else append("Port ${server.port}") + } + } + if (secondary.isNotBlank()) { + Text(text = secondary, style = MaterialTheme.typography.labelMedium, color = TextMuted, maxLines = 1, overflow = TextOverflow.Ellipsis) + } + } + } + IconButton(onClick = { onEdit(server) }) { + Icon(imageVector = Icons.Default.Edit, contentDescription = stringResource(R.string.edit_server), modifier = Modifier.size(18.dp), tint = TextMuted) + } + IconButton(onClick = { onRemove(server) }) { + Icon(imageVector = Icons.Default.Close, contentDescription = stringResource(R.string.remove_server), modifier = Modifier.size(18.dp), tint = TextMuted) + } + } +} + +/** Strip protocol prefixes and trailing slashes from address input. */ +private fun sanitizeAddress(input: String): String { + return input.trim() + .removePrefix("https://") + .removePrefix("http://") + .trimEnd('/') +} + +/** Test RPC connectivity. Accepts self-signed certs for local LAN servers. */ +private suspend fun testConnection(server: ServerEntry): Boolean { + return withContext(Dispatchers.IO) { + try { + val url = URL("${server.toUrl()}/rpc/v1") + val connection = url.openConnection() as HttpURLConnection + + // Trust self-signed certs for local HTTPS (Archipelago nodes rarely have CA certs) + if (connection is HttpsURLConnection) { + val trustAll = arrayOf(object : X509TrustManager { + override fun checkClientTrusted(chain: Array?, authType: String?) {} + override fun checkServerTrusted(chain: Array?, authType: String?) {} + override fun getAcceptedIssuers(): Array = arrayOf() + }) + val sc = SSLContext.getInstance("TLS") + sc.init(null, trustAll, java.security.SecureRandom()) + connection.sslSocketFactory = sc.socketFactory + connection.hostnameVerifier = javax.net.ssl.HostnameVerifier { _, _ -> true } + } + + connection.requestMethod = "POST" + connection.connectTimeout = 5000 + connection.readTimeout = 5000 + connection.setRequestProperty("Content-Type", "application/json") + connection.doOutput = true + val body = """{"method":"server.echo","params":{"message":"ping"}}""" + connection.outputStream.use { it.write(body.toByteArray()) } + val code = connection.responseCode + connection.disconnect() + code in 200..499 + } catch (_: Exception) { + false + } + } +} diff --git a/Android/app/src/main/java/com/archipelago/app/ui/screens/WebViewScreen.kt b/Android/app/src/main/java/com/archipelago/app/ui/screens/WebViewScreen.kt new file mode 100644 index 00000000..23cbc5a3 --- /dev/null +++ b/Android/app/src/main/java/com/archipelago/app/ui/screens/WebViewScreen.kt @@ -0,0 +1,702 @@ +package com.archipelago.app.ui.screens + +import android.annotation.SuppressLint +import android.graphics.Bitmap +import android.graphics.BitmapFactory +import android.view.ViewGroup +import android.webkit.CookieManager +import android.webkit.WebChromeClient +import android.webkit.WebResourceError +import android.webkit.WebResourceRequest +import android.webkit.WebSettings +import android.webkit.WebView +import android.webkit.WebViewClient +import androidx.activity.compose.BackHandler +import androidx.compose.animation.AnimatedVisibility +import androidx.compose.animation.fadeIn +import androidx.compose.animation.fadeOut +import androidx.compose.foundation.Image +import androidx.compose.foundation.background +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.WindowInsets +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.safeDrawing +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.layout.width +import androidx.compose.foundation.layout.windowInsetsPadding +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.automirrored.filled.ArrowBack +import androidx.compose.material.icons.automirrored.filled.ArrowForward +import androidx.compose.material.icons.filled.Close +import androidx.compose.material.icons.filled.CloudOff +import androidx.compose.material.icons.filled.OpenInBrowser +import androidx.compose.material.icons.filled.Refresh +import androidx.compose.material3.CircularProgressIndicator +import androidx.compose.material3.Icon +import androidx.compose.material3.IconButton +import androidx.compose.material3.LinearProgressIndicator +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableIntStateOf +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.graphics.asImageBitmap +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.style.TextAlign +import androidx.compose.ui.text.style.TextOverflow +import androidx.compose.ui.unit.dp +import androidx.compose.ui.viewinterop.AndroidView +import com.archipelago.app.R +import com.archipelago.app.ui.theme.BitcoinOrange +import com.archipelago.app.ui.theme.SurfaceBlack +import com.archipelago.app.ui.theme.TextMuted +import com.archipelago.app.ui.theme.TextPrimary +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.withContext + +/** Open a URL in the phone's default browser (genuinely external links). */ +private fun openExternalUrl(context: android.content.Context, url: String) { + try { + val intent = android.content.Intent( + android.content.Intent.ACTION_VIEW, + android.net.Uri.parse(url), + ).apply { + // Required when launching from a non-Activity/binder thread + // (the JS bridge below can run off the UI thread). + addFlags(android.content.Intent.FLAG_ACTIVITY_NEW_TASK) + } + context.startActivity(intent) + } catch (_: Exception) {} +} + +/** True when [url] points at the same host as the connected Archipelago node + * (ignoring port). Such URLs are node apps — e.g. one that can't be iframed — + * and should stay inside the app rather than bouncing out to the browser. */ +private fun isSameHost(url: String, base: String): Boolean { + return try { + val a = android.net.Uri.parse(url).host ?: return false + val b = android.net.Uri.parse(base).host ?: return false + a.equals(b, ignoreCase = true) + } catch (_: Exception) { + false + } +} + +/** Apply the WebView settings shared by the kiosk view and the in-app browser. + * These are tuned for SPA performance and parity with the mobile browser; + * none of them alter how a page renders visually. */ +@SuppressLint("SetJavaScriptEnabled") +private fun WebView.applyArchipelagoSettings() { + // Pre-rasterize just outside the viewport so flinging the kiosk/app doesn't + // show blank checkerboarding — the single biggest scroll-smoothness win and + // a major part of the "feels slower than the browser" gap. (API 23+) + settings.setOffscreenPreRaster(true) + + settings.apply { + javaScriptEnabled = true + domStorageEnabled = true + databaseEnabled = true + mediaPlaybackRequiresUserGesture = false + mixedContentMode = WebSettings.MIXED_CONTENT_COMPATIBILITY_MODE + useWideViewPort = true + loadWithOverviewMode = true + setSupportZoom(false) + builtInZoomControls = false + cacheMode = WebSettings.LOAD_DEFAULT + allowContentAccess = true + allowFileAccess = false + } + + // chrome://inspect profiling on debuggable builds only — lets us measure the + // real in-page bottleneck rather than guess. No effect on release builds. + val debuggable = 0 != (context.applicationInfo.flags and + android.content.pm.ApplicationInfo.FLAG_DEBUGGABLE) + if (debuggable) WebView.setWebContentsDebuggingEnabled(true) +} + +@SuppressLint("SetJavaScriptEnabled", "ClickableViewAccessibility") +@Composable +fun WebViewScreen( + serverUrl: String, + onDisconnect: () -> Unit, + onRemoteInput: () -> Unit = {}, +) { + var isLoading by remember { mutableStateOf(true) } + var loadProgress by remember { mutableIntStateOf(0) } + var hasError by remember { mutableStateOf(false) } + var webView by remember { mutableStateOf(null) } + + // A node app that refused iframing, opened in a local WebView overlay. + // null = no overlay. The kiosk WebView underneath stays alive (and warm) + // while this is shown, so closing it returns instantly with no reload. + var inAppUrl by remember { mutableStateOf(null) } + + BackHandler(enabled = inAppUrl == null && webView?.canGoBack() == true) { + webView?.goBack() + } + + Box( + modifier = Modifier + .fillMaxSize() + .background(SurfaceBlack), + ) { + if (hasError) { + Column( + modifier = Modifier + .fillMaxSize() + .windowInsetsPadding(WindowInsets.safeDrawing) + .padding(32.dp), + horizontalAlignment = Alignment.CenterHorizontally, + verticalArrangement = Arrangement.Center, + ) { + Icon( + imageVector = Icons.Default.CloudOff, + contentDescription = null, + modifier = Modifier.size(64.dp), + tint = TextMuted, + ) + + Spacer(modifier = Modifier.height(24.dp)) + + Text( + text = stringResource(R.string.server_unreachable), + style = MaterialTheme.typography.headlineMedium, + color = TextPrimary, + textAlign = TextAlign.Center, + ) + + Spacer(modifier = Modifier.height(12.dp)) + + Text( + text = stringResource(R.string.connection_failed), + style = MaterialTheme.typography.bodyMedium, + color = TextMuted, + textAlign = TextAlign.Center, + ) + + Spacer(modifier = Modifier.height(32.dp)) + + GlassButton( + text = stringResource(R.string.retry), + onClick = { + hasError = false + isLoading = true + webView?.loadUrl(serverUrl) + }, + modifier = Modifier.fillMaxWidth().height(56.dp), + ) + + Spacer(modifier = Modifier.height(12.dp)) + + GlassButton( + text = stringResource(R.string.disconnect), + onClick = onDisconnect, + modifier = Modifier.fillMaxWidth().height(48.dp), + ) + } + } else { + // Edge-to-edge WebView — background bleeds behind status bar. + // Safe area values injected as CSS env() polyfill on each page load. + AndroidView( + modifier = Modifier.fillMaxSize(), + factory = { context -> + WebView(context).apply { + layoutParams = ViewGroup.LayoutParams( + ViewGroup.LayoutParams.MATCH_PARENT, + ViewGroup.LayoutParams.MATCH_PARENT, + ) + + isVerticalScrollBarEnabled = false + isHorizontalScrollBarEnabled = false + + val cookieManager = CookieManager.getInstance() + cookieManager.setAcceptCookie(true) + cookieManager.setAcceptThirdPartyCookies(this, true) + + applyArchipelagoSettings() + settings.apply { + setSupportMultipleWindows(true) // enables onCreateWindow for window.open + // Let JS open windows without a synchronous user-gesture + // chain; without this, window.open() from a Vue click + // handler silently no-ops and "Open in new tab" dies. + javaScriptCanOpenWindowsAutomatically = true + } + + val webViewRef = this + + // Decide where an outbound URL goes: + // - same host as the node → in-app WebView overlay + // (this is the "open in browser" target for apps the + // kiosk couldn't iframe — keep the user inside the app) + // - different host → the phone's real browser + fun routeOutbound(url: String) { + if (isSameHost(url, serverUrl)) { + inAppUrl = url + } else { + openExternalUrl(context, url) + } + } + + // JS bridge. The web UI calls: + // window.ArchipelagoNative.openExternal(url) — host-routed + // window.ArchipelagoNative.openInApp(url) — force in-app + // Falls back to window.open in a plain mobile browser. + addJavascriptInterface( + object { + @android.webkit.JavascriptInterface + fun openExternal(url: String) { + webViewRef.post { routeOutbound(url) } + } + + @android.webkit.JavascriptInterface + fun openInApp(url: String) { + webViewRef.post { inAppUrl = url } + } + }, + "ArchipelagoNative", + ) + + webViewClient = object : WebViewClient() { + override fun onPageStarted(view: WebView?, url: String?, favicon: Bitmap?) { + isLoading = true + hasError = false + } + + override fun onPageFinished(view: WebView?, url: String?) { + isLoading = false + if (view == null) return + + // Convert physical pixels → CSS pixels + val density = view.resources.displayMetrics.density + val satPx = view.rootWindowInsets + ?.getInsets(android.view.WindowInsets.Type.statusBars()) + ?.top ?: 0 + val sabPx = view.rootWindowInsets + ?.getInsets(android.view.WindowInsets.Type.navigationBars()) + ?.bottom ?: 0 + val sat = (satPx / density).toInt() + val sab = (sabPx / density).toInt() + + // Android WebView doesn't populate env(safe-area-inset-*). + // Set CSS custom properties the web UI can use as fallback: + // var(--safe-area-top, env(safe-area-inset-top, 0px)) + view.evaluateJavascript( + """ + (function() { + var style = document.getElementById('archipelago-android-insets'); + if (!style) { + style = document.createElement('style'); + style.id = 'archipelago-android-insets'; + document.head.appendChild(style); + } + style.textContent = ':root { --safe-area-top: ${sat}px; --safe-area-bottom: ${sab}px; }'; + })(); + """.trimIndent(), + null, + ) + } + + override fun onReceivedError( + view: WebView?, + request: WebResourceRequest?, + error: WebResourceError?, + ) { + if (request?.isForMainFrame == true) { + hasError = true + isLoading = false + } + } + + // Node apps (e.g. NetBird) terminate TLS with a + // self-signed cert — the dashboard needs a secure + // context for OIDC/window.crypto.subtle (#15). The + // WebView default is to CANCEL untrusted certs, so + // those apps render blank. The user explicitly trusts + // their own node, so proceed for same-host certs only; + // reject anything else (don't blanket-trust the web). + override fun onReceivedSslError( + view: WebView?, + handler: android.webkit.SslErrorHandler?, + error: android.net.http.SslError?, + ) { + val u = error?.url + if (u != null && isSameHost(u, serverUrl)) { + handler?.proceed() + } else { + handler?.cancel() + } + } + + override fun shouldOverrideUrlLoading( + view: WebView?, + request: WebResourceRequest?, + ): Boolean { + val url = request?.url?.toString() ?: return false + // Keep kiosk navigation (same origin incl. port) in place + if (url.startsWith(serverUrl)) return false + // Same node (other port) → in-app; external → browser + routeOutbound(url) + return true + } + } + + webChromeClient = object : WebChromeClient() { + override fun onProgressChanged(view: WebView?, newProgress: Int) { + loadProgress = newProgress + } + + // window.open() — e.g. the kiosk's "Open in new tab" + // for an app that can't be iframed. Capture the target + // URL via a throwaway WebView and route it ourselves. + override fun onCreateWindow( + view: WebView?, + isDialog: Boolean, + isUserGesture: Boolean, + resultMsg: android.os.Message?, + ): Boolean { + val transport = resultMsg?.obj as? WebView.WebViewTransport + ?: return false + + val popup = WebView(context).apply { + settings.javaScriptEnabled = true + webViewClient = object : WebViewClient() { + override fun shouldOverrideUrlLoading( + view: WebView?, + request: WebResourceRequest?, + ): Boolean { + val url = request?.url?.toString() ?: return true + routeOutbound(url) + return true + } + + override fun onPageStarted(view: WebView?, url: String?, favicon: Bitmap?) { + if (url != null) routeOutbound(url) + view?.stopLoading() + } + } + } + transport.webView = popup + resultMsg.sendToTarget() + return true + } + } + + // Two-finger hold (500ms) → navigate to remote input + var twoFingerStart = 0L + var twoFingerFired = false + setOnTouchListener { _, event -> + val pointerCount = event.pointerCount + when (event.actionMasked) { + android.view.MotionEvent.ACTION_POINTER_DOWN -> { + if (pointerCount >= 2) { + twoFingerStart = System.currentTimeMillis() + twoFingerFired = false + } + } + android.view.MotionEvent.ACTION_MOVE -> { + if (pointerCount >= 2 && !twoFingerFired && twoFingerStart > 0) { + if (System.currentTimeMillis() - twoFingerStart > 500) { + twoFingerFired = true + onRemoteInput() + } + } + } + android.view.MotionEvent.ACTION_UP, + android.view.MotionEvent.ACTION_POINTER_UP, + android.view.MotionEvent.ACTION_CANCEL -> { + if (event.pointerCount <= 2) { + twoFingerStart = 0L + } + } + } + false // don't consume — let WebView handle normally + } + + webView = this + loadUrl(serverUrl) + } + }, + ) + + // Loading bar at top edge + AnimatedVisibility( + visible = isLoading, + enter = fadeIn(), + exit = fadeOut(), + ) { + LinearProgressIndicator( + progress = { loadProgress / 100f }, + modifier = Modifier.fillMaxWidth(), + color = BitcoinOrange, + trackColor = SurfaceBlack, + ) + } + + // In-app browser overlay for non-iframeable node apps. Rendered last + // so it sits above the kiosk WebView, which stays alive underneath. + inAppUrl?.let { target -> + InAppBrowser( + url = target, + serverUrl = serverUrl, + onClose = { inAppUrl = null }, + ) + } + } + } +} + +/** Best-effort fetch of the origin's /favicon.ico, so the launched app's icon + * can be shown on the loading screen before the WebView reports onReceivedIcon + * (which only fires once the page's has parsed). Blocking — call on IO. */ +private fun fetchFavicon(pageUrl: String): Bitmap? { + return try { + val u = android.net.Uri.parse(pageUrl) + val scheme = u.scheme ?: return null + val host = u.host ?: return null + val portPart = if (u.port > 0) ":${u.port}" else "" + val conn = (java.net.URL("$scheme://$host$portPart/favicon.ico").openConnection() + as java.net.HttpURLConnection).apply { + connectTimeout = 4000 + readTimeout = 4000 + instanceFollowRedirects = true + } + conn.inputStream.use { BitmapFactory.decodeStream(it) } + } catch (_: Exception) { + null + } +} + +/** + * Lightweight in-app browser used when the kiosk hands off an app that can't be + * shown in an iframe. Loads the app in a local WebView with a centered loading + * screen (app favicon + progress bar) and a BOTTOM control bar mirroring the + * web mobile-iframe footer (back / forward / reload / open-in-browser / close). + * Same-host navigation stays here; any genuinely external link escapes to the + * phone's browser. + */ +@SuppressLint("SetJavaScriptEnabled") +@Composable +private fun InAppBrowser( + url: String, + serverUrl: String, + onClose: () -> Unit, +) { + val context = LocalContext.current + var browser by remember { mutableStateOf(null) } + var title by remember { mutableStateOf(android.net.Uri.parse(url).host ?: url) } + var favicon by remember { mutableStateOf(null) } + var progress by remember { mutableIntStateOf(0) } + var loading by remember { mutableStateOf(true) } + var canGoBack by remember { mutableStateOf(false) } + var canGoForward by remember { mutableStateOf(false) } + + // Seed the loading-screen icon immediately from a best-effort favicon + // pre-fetch (main's app-icon work), then onReceivedIcon upgrades it — so the + // loader shows an icon right away instead of staying blank until the page + // parses its (which is what made the loader look stuck). + LaunchedEffect(url) { + val fetched = withContext(Dispatchers.IO) { fetchFavicon(url) } + if (fetched != null && favicon == null) favicon = fetched + } + + // Back: walk the in-app history first, then close the overlay. + BackHandler { + val b = browser + if (b != null && b.canGoBack()) b.goBack() else onClose() + } + + Column( + modifier = Modifier + .fillMaxSize() + .background(SurfaceBlack) + .windowInsetsPadding(WindowInsets.safeDrawing), + ) { + // WebView + loading overlay fill the area above the bottom control bar. + Box(modifier = Modifier.weight(1f).fillMaxWidth()) { + AndroidView( + modifier = Modifier.fillMaxSize(), + factory = { ctx -> + WebView(ctx).apply { + layoutParams = ViewGroup.LayoutParams( + ViewGroup.LayoutParams.MATCH_PARENT, + ViewGroup.LayoutParams.MATCH_PARENT, + ) + isVerticalScrollBarEnabled = false + isHorizontalScrollBarEnabled = false + + CookieManager.getInstance().setAcceptThirdPartyCookies(this, true) + applyArchipelagoSettings() + + webChromeClient = object : WebChromeClient() { + override fun onProgressChanged(view: WebView?, newProgress: Int) { + progress = newProgress + } + + override fun onReceivedTitle(view: WebView?, t: String?) { + if (!t.isNullOrBlank()) title = t + } + + override fun onReceivedIcon(view: WebView?, icon: Bitmap?) { + if (icon != null) favicon = icon + } + } + + webViewClient = object : WebViewClient() { + override fun onPageStarted(view: WebView?, u: String?, favicon: Bitmap?) { + loading = true + } + + override fun onPageFinished(view: WebView?, u: String?) { + loading = false + canGoBack = view?.canGoBack() == true + canGoForward = view?.canGoForward() == true + } + + override fun doUpdateVisitedHistory(view: WebView?, u: String?, isReload: Boolean) { + canGoBack = view?.canGoBack() == true + canGoForward = view?.canGoForward() == true + } + + // Self-signed TLS on the node's apps (e.g. NetBird on + // :8087) would otherwise be cancelled by the WebView + // and render blank. Proceed for the user's own node + // (same host); reject any other untrusted cert. + override fun onReceivedSslError( + view: WebView?, + handler: android.webkit.SslErrorHandler?, + error: android.net.http.SslError?, + ) { + val u = error?.url + if (u != null && isSameHost(u, serverUrl)) { + handler?.proceed() + } else { + handler?.cancel() + } + } + + override fun shouldOverrideUrlLoading( + view: WebView?, + request: WebResourceRequest?, + ): Boolean { + val u = request?.url?.toString() ?: return false + // Stay in the overlay for same-node navigation; + // hand genuinely external links to the real browser. + if (isSameHost(u, serverUrl)) return false + openExternalUrl(ctx, u) + return true + } + } + + browser = this + loadUrl(url) + } + }, + ) + + // Centered loading screen — app favicon (or spinner) + title + bar. + if (loading) { + Column( + modifier = Modifier + .fillMaxSize() + .background(SurfaceBlack), + horizontalAlignment = Alignment.CenterHorizontally, + verticalArrangement = Arrangement.Center, + ) { + Box( + modifier = Modifier.size(84.dp).clip(RoundedCornerShape(20.dp)), + contentAlignment = Alignment.Center, + ) { + val fav = favicon + if (fav != null) { + Image( + bitmap = fav.asImageBitmap(), + contentDescription = title, + modifier = Modifier.fillMaxSize(), + ) + } else { + CircularProgressIndicator(color = BitcoinOrange) + } + } + Spacer(modifier = Modifier.height(18.dp)) + Text( + text = title, + style = MaterialTheme.typography.bodyLarge, + color = TextPrimary, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + ) + Spacer(modifier = Modifier.height(16.dp)) + LinearProgressIndicator( + progress = { progress / 100f }, + modifier = Modifier.width(220.dp), + color = BitcoinOrange, + trackColor = TextMuted.copy(alpha = 0.2f), + ) + } + } + } + + // Bottom control bar — mirrors the web mobile-iframe footer. + Row( + modifier = Modifier + .fillMaxWidth() + .height(56.dp) + .background(SurfaceBlack) + .padding(horizontal = 8.dp), + horizontalArrangement = Arrangement.SpaceAround, + verticalAlignment = Alignment.CenterVertically, + ) { + IconButton(onClick = { browser?.goBack() }, enabled = canGoBack) { + Icon( + imageVector = Icons.AutoMirrored.Filled.ArrowBack, + contentDescription = "Back", + tint = if (canGoBack) TextPrimary else TextMuted.copy(alpha = 0.4f), + ) + } + IconButton(onClick = { browser?.goForward() }, enabled = canGoForward) { + Icon( + imageVector = Icons.AutoMirrored.Filled.ArrowForward, + contentDescription = "Forward", + tint = if (canGoForward) TextPrimary else TextMuted.copy(alpha = 0.4f), + ) + } + IconButton(onClick = { browser?.reload() }) { + Icon( + imageVector = Icons.Default.Refresh, + contentDescription = "Reload", + tint = TextPrimary, + ) + } + IconButton(onClick = { openExternalUrl(context, browser?.url ?: url) }) { + Icon( + imageVector = Icons.Default.OpenInBrowser, + contentDescription = stringResource(R.string.open_in_browser), + tint = TextPrimary, + ) + } + IconButton(onClick = onClose) { + Icon( + imageVector = Icons.Default.Close, + contentDescription = stringResource(R.string.close), + tint = TextPrimary, + ) + } + } + } +} diff --git a/Android/app/src/main/java/com/archipelago/app/ui/theme/Color.kt b/Android/app/src/main/java/com/archipelago/app/ui/theme/Color.kt new file mode 100644 index 00000000..6e2ffef2 --- /dev/null +++ b/Android/app/src/main/java/com/archipelago/app/ui/theme/Color.kt @@ -0,0 +1,24 @@ +package com.archipelago.app.ui.theme + +import androidx.compose.ui.graphics.Color + +// Archipelago brand palette — Bitcoin orange on dark +val BitcoinOrange = Color(0xFFF7931A) +val BitcoinOrangeLight = Color(0xFFFFB74D) +val BitcoinOrangeDark = Color(0xFFE07C00) + +val SurfaceBlack = Color(0xFF000000) +val SurfaceDark = Color(0xFF0A0A0A) +val SurfaceCard = Color(0xFF1A1A1A) +val SurfaceCardHover = Color(0xFF222222) +val SurfaceElevated = Color(0xFF2A2A2A) + +val TextPrimary = Color(0xFFF5F5F5) +val TextSecondary = Color(0xFFB0B0B0) +val TextMuted = Color(0xFF666666) + +val BorderSubtle = Color(0xFF2A2A2A) +val BorderDefault = Color(0xFF3A3A3A) + +val ErrorRed = Color(0xFFEF4444) +val SuccessGreen = Color(0xFF22C55E) diff --git a/Android/app/src/main/java/com/archipelago/app/ui/theme/NES.kt b/Android/app/src/main/java/com/archipelago/app/ui/theme/NES.kt new file mode 100644 index 00000000..9ff432fb --- /dev/null +++ b/Android/app/src/main/java/com/archipelago/app/ui/theme/NES.kt @@ -0,0 +1,44 @@ +package com.archipelago.app.ui.theme + +import androidx.compose.ui.graphics.Color + +/** NES/8BitDo controller palettes */ +object NES { + // ── Classic (light body, red buttons) ────────────── + val ClassicBody = Color(0xFFD4D0C8) // warm light gray plastic + val ClassicFace = Color(0xFF1C1C1C) // dark face plate + val ClassicAccent = Color(0xFF8A8A8A) // mid gray trim + val ClassicRidge = Color(0xFFBBB8B0) // grip lines + val ClassicButtonRed = Color(0xFFC1121C) // A/B red + val ClassicButtonRedPress = Color(0xFF8A0D14) + val ClassicButtonGray = Color(0xFF5A5A5A) // turbo buttons + val ClassicButtonGrayPress = Color(0xFF3A3A3A) + val ClassicDPad = Color(0xFF1A1A1A) + val ClassicDPadPress = Color(0xFF2A2A2A) + val ClassicLabel = Color(0xFFC1121C) // red text labels + val ClassicLabelMuted = Color(0xFF6A6A6A) + val ClassicSelect = Color(0xFF2A2A2A) // START/SELECT + + // ── Transparent Dark ─────────────────────────────── + val DarkBody = Color(0xFF2A2A2E) // smoky translucent dark + val DarkFace = Color(0xFF151518) // darker face + val DarkAccent = Color(0xFF3A3A3E) // trim + val DarkRidge = Color(0xFF222226) // grip lines + val DarkButtonMain = Color(0xFF3A3A3E) // all buttons dark + val DarkButtonMainPress = Color(0xFF222226) + val DarkDPad = Color(0xFF0E0E10) + val DarkDPadPress = Color(0xFF1A1A1E) + val DarkLabel = Color(0xFF5A5A60) // muted labels + val DarkLabelMuted = Color(0xFF3A3A3E) + val DarkSelect = Color(0xFF1A1A1E) + + // ── Menu UI (NES-style) ──────────────────────────── + val MenuBg = Color(0xFF000000) + val MenuPanel = Color(0xFF0B1B4A) // dark navy + val MenuBorder = Color(0xFFFFFFFF) + val MenuText = Color(0xFFFFFFFF) + val MenuSelected = Color(0xFFC1121C) + val MenuMuted = Color(0xFF7A7A7A) +} + +enum class ControllerStyle { CLASSIC, DARK } diff --git a/Android/app/src/main/java/com/archipelago/app/ui/theme/Neo.kt b/Android/app/src/main/java/com/archipelago/app/ui/theme/Neo.kt new file mode 100644 index 00000000..57431684 --- /dev/null +++ b/Android/app/src/main/java/com/archipelago/app/ui/theme/Neo.kt @@ -0,0 +1,106 @@ +package com.archipelago.app.ui.theme + +import androidx.compose.foundation.isSystemInDarkTheme +import androidx.compose.runtime.Composable +import androidx.compose.runtime.ReadOnlyComposable +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.drawBehind +import androidx.compose.ui.geometry.CornerRadius +import androidx.compose.ui.geometry.RoundRect +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.Paint +import androidx.compose.ui.graphics.Path +import androidx.compose.ui.graphics.drawscope.drawIntoCanvas +import androidx.compose.ui.graphics.toArgb +import androidx.compose.ui.unit.Dp +import androidx.compose.ui.unit.dp + +object Neo { + // ── Dark ─────────────────────────────────────────── + val DarkSurface = Color(0xFF0A0A0A) + val DarkSurfaceRaised = Color(0xFF0F0F11) + val DarkShadowLight = Color(0xFF151517) + val DarkShadowDark = Color(0xFF000000) + val DarkBorder = Color(0x0AFFFFFF) + + // ── Light ────────────────────────────────────────── + val LightSurface = Color(0xFFE0E0E4) + val LightSurfaceRaised = Color(0xFFE6E6EA) + val LightShadowLight = Color(0xFFF2F2F6) + val LightShadowDark = Color(0xFFB4B4BA) + val LightBorder = Color(0x0A000000) + + val LightTextPrimary = Color(0xFF141414) + val LightTextSecondary = Color(0xFF5A5A5A) + val LightTextMuted = Color(0xFF9A9A9A) + + // ── Accessors ────────────────────────────────────── + + @Composable @ReadOnlyComposable + fun surface() = if (isSystemInDarkTheme()) DarkSurface else LightSurface + + @Composable @ReadOnlyComposable + fun surfaceRaised() = if (isSystemInDarkTheme()) DarkSurfaceRaised else LightSurfaceRaised + + @Composable @ReadOnlyComposable + fun shadowLight() = if (isSystemInDarkTheme()) DarkShadowLight else LightShadowLight + + @Composable @ReadOnlyComposable + fun shadowDark() = if (isSystemInDarkTheme()) DarkShadowDark else LightShadowDark + + @Composable @ReadOnlyComposable + fun border() = if (isSystemInDarkTheme()) DarkBorder else LightBorder + + @Composable @ReadOnlyComposable + fun textPrimary() = if (isSystemInDarkTheme()) Color(0xFFD0D0D0) else LightTextPrimary + + @Composable @ReadOnlyComposable + fun textSecondary() = if (isSystemInDarkTheme()) Color(0xFF666666) else LightTextSecondary + + @Composable @ReadOnlyComposable + fun textMuted() = if (isSystemInDarkTheme()) Color(0xFF333333) else LightTextMuted +} + +/** Subtle neomorphic raised shadow */ +fun Modifier.neoRaised( + lightShadow: Color, + darkShadow: Color, + radius: Dp = 14.dp, + shadowOffset: Dp = 2.dp, + shadowBlur: Dp = 4.dp, +) = this.drawBehind { + val r = radius.toPx() + val off = shadowOffset.toPx() + val blur = shadowBlur.toPx() + drawIntoCanvas { canvas -> + val path = Path().apply { addRoundRect(RoundRect(0f, 0f, size.width, size.height, CornerRadius(r))) } + canvas.drawPath(path, Paint().also { + it.asFrameworkPaint().apply { isAntiAlias = true; color = android.graphics.Color.TRANSPARENT; setShadowLayer(blur, off, off, darkShadow.toArgb()) } + }) + canvas.drawPath(path, Paint().also { + it.asFrameworkPaint().apply { isAntiAlias = true; color = android.graphics.Color.TRANSPARENT; setShadowLayer(blur, -off, -off, lightShadow.toArgb()) } + }) + } +} + +/** Subtle neomorphic inset shadow */ +fun Modifier.neoInset( + lightShadow: Color, + darkShadow: Color, + radius: Dp = 14.dp, + shadowOffset: Dp = 1.dp, + shadowBlur: Dp = 3.dp, +) = this.drawBehind { + val r = radius.toPx() + val off = shadowOffset.toPx() + val blur = shadowBlur.toPx() + drawIntoCanvas { canvas -> + val path = Path().apply { addRoundRect(RoundRect(0f, 0f, size.width, size.height, CornerRadius(r))) } + canvas.drawPath(path, Paint().also { + it.asFrameworkPaint().apply { isAntiAlias = true; color = android.graphics.Color.TRANSPARENT; setShadowLayer(blur, -off, -off, darkShadow.toArgb()) } + }) + canvas.drawPath(path, Paint().also { + it.asFrameworkPaint().apply { isAntiAlias = true; color = android.graphics.Color.TRANSPARENT; setShadowLayer(blur, off, off, lightShadow.toArgb()) } + }) + } +} diff --git a/Android/app/src/main/java/com/archipelago/app/ui/theme/Theme.kt b/Android/app/src/main/java/com/archipelago/app/ui/theme/Theme.kt new file mode 100644 index 00000000..9fa7d247 --- /dev/null +++ b/Android/app/src/main/java/com/archipelago/app/ui/theme/Theme.kt @@ -0,0 +1,56 @@ +package com.archipelago.app.ui.theme + +import androidx.compose.foundation.isSystemInDarkTheme +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.darkColorScheme +import androidx.compose.material3.lightColorScheme +import androidx.compose.runtime.Composable + +private val DarkColorScheme = darkColorScheme( + primary = BitcoinOrange, + onPrimary = SurfaceBlack, + primaryContainer = BitcoinOrangeDark, + onPrimaryContainer = TextPrimary, + secondary = BitcoinOrangeLight, + onSecondary = SurfaceBlack, + background = SurfaceBlack, + onBackground = TextPrimary, + surface = SurfaceDark, + onSurface = TextPrimary, + surfaceVariant = SurfaceCard, + onSurfaceVariant = TextSecondary, + outline = BorderDefault, + outlineVariant = BorderSubtle, + error = ErrorRed, + onError = TextPrimary, +) + +private val LightColorScheme = lightColorScheme( + primary = BitcoinOrange, + onPrimary = SurfaceBlack, + primaryContainer = BitcoinOrangeLight, + onPrimaryContainer = SurfaceBlack, + secondary = BitcoinOrangeDark, + onSecondary = TextPrimary, + background = Neo.LightSurface, + onBackground = Neo.LightTextPrimary, + surface = Neo.LightSurfaceRaised, + onSurface = Neo.LightTextPrimary, + surfaceVariant = Neo.LightSurface, + onSurfaceVariant = Neo.LightTextSecondary, + outline = Neo.LightBorder, + outlineVariant = Neo.LightBorder, + error = ErrorRed, + onError = TextPrimary, +) + +@Composable +fun ArchipelagoTheme(content: @Composable () -> Unit) { + val colorScheme = if (isSystemInDarkTheme()) DarkColorScheme else LightColorScheme + + MaterialTheme( + colorScheme = colorScheme, + typography = Typography, + content = content, + ) +} diff --git a/Android/app/src/main/java/com/archipelago/app/ui/theme/Type.kt b/Android/app/src/main/java/com/archipelago/app/ui/theme/Type.kt new file mode 100644 index 00000000..c9444834 --- /dev/null +++ b/Android/app/src/main/java/com/archipelago/app/ui/theme/Type.kt @@ -0,0 +1,60 @@ +package com.archipelago.app.ui.theme + +import androidx.compose.material3.Typography +import androidx.compose.ui.text.TextStyle +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.unit.sp + +val Typography = Typography( + displayLarge = TextStyle( + fontWeight = FontWeight.Bold, + fontSize = 32.sp, + lineHeight = 40.sp, + letterSpacing = (-0.5).sp, + ), + headlineLarge = TextStyle( + fontWeight = FontWeight.SemiBold, + fontSize = 28.sp, + lineHeight = 36.sp, + ), + headlineMedium = TextStyle( + fontWeight = FontWeight.SemiBold, + fontSize = 24.sp, + lineHeight = 32.sp, + ), + titleLarge = TextStyle( + fontWeight = FontWeight.Medium, + fontSize = 20.sp, + lineHeight = 28.sp, + ), + titleMedium = TextStyle( + fontWeight = FontWeight.Medium, + fontSize = 16.sp, + lineHeight = 24.sp, + letterSpacing = 0.15.sp, + ), + bodyLarge = TextStyle( + fontWeight = FontWeight.Normal, + fontSize = 16.sp, + lineHeight = 24.sp, + letterSpacing = 0.5.sp, + ), + bodyMedium = TextStyle( + fontWeight = FontWeight.Normal, + fontSize = 14.sp, + lineHeight = 20.sp, + letterSpacing = 0.25.sp, + ), + labelLarge = TextStyle( + fontWeight = FontWeight.SemiBold, + fontSize = 14.sp, + lineHeight = 20.sp, + letterSpacing = 0.1.sp, + ), + labelMedium = TextStyle( + fontWeight = FontWeight.Medium, + fontSize = 12.sp, + lineHeight = 16.sp, + letterSpacing = 0.5.sp, + ), +) diff --git a/Android/app/src/main/res/drawable/bg_synthwave.jpg b/Android/app/src/main/res/drawable/bg_synthwave.jpg new file mode 100644 index 00000000..2f3afb80 Binary files /dev/null and b/Android/app/src/main/res/drawable/bg_synthwave.jpg differ diff --git a/Android/app/src/main/res/drawable/ic_launcher_background.xml b/Android/app/src/main/res/drawable/ic_launcher_background.xml new file mode 100644 index 00000000..a952248d --- /dev/null +++ b/Android/app/src/main/res/drawable/ic_launcher_background.xml @@ -0,0 +1,53 @@ + + + + + + + + + + + + + + + + + + + + + + + diff --git a/Android/app/src/main/res/drawable/ic_launcher_foreground.xml b/Android/app/src/main/res/drawable/ic_launcher_foreground.xml new file mode 100644 index 00000000..4c640719 --- /dev/null +++ b/Android/app/src/main/res/drawable/ic_launcher_foreground.xml @@ -0,0 +1,12 @@ + + + + + diff --git a/Android/app/src/main/res/drawable/ic_logo.xml b/Android/app/src/main/res/drawable/ic_logo.xml new file mode 100644 index 00000000..275ad1d6 --- /dev/null +++ b/Android/app/src/main/res/drawable/ic_logo.xml @@ -0,0 +1,33 @@ + + + + + + + + + + + + + + + + + diff --git a/Android/app/src/main/res/drawable/ic_logo_wide.xml b/Android/app/src/main/res/drawable/ic_logo_wide.xml new file mode 100644 index 00000000..51122311 --- /dev/null +++ b/Android/app/src/main/res/drawable/ic_logo_wide.xml @@ -0,0 +1,51 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/Android/app/src/main/res/drawable/ic_nav_back.xml b/Android/app/src/main/res/drawable/ic_nav_back.xml new file mode 100644 index 00000000..fb5842af --- /dev/null +++ b/Android/app/src/main/res/drawable/ic_nav_back.xml @@ -0,0 +1,12 @@ + + + diff --git a/Android/app/src/main/res/drawable/ic_nav_close.xml b/Android/app/src/main/res/drawable/ic_nav_close.xml new file mode 100644 index 00000000..3620ff4c --- /dev/null +++ b/Android/app/src/main/res/drawable/ic_nav_close.xml @@ -0,0 +1,12 @@ + + + diff --git a/Android/app/src/main/res/drawable/ic_nav_forward.xml b/Android/app/src/main/res/drawable/ic_nav_forward.xml new file mode 100644 index 00000000..89757edb --- /dev/null +++ b/Android/app/src/main/res/drawable/ic_nav_forward.xml @@ -0,0 +1,12 @@ + + + diff --git a/Android/app/src/main/res/drawable/ic_nav_newtab.xml b/Android/app/src/main/res/drawable/ic_nav_newtab.xml new file mode 100644 index 00000000..e1c4eb2a --- /dev/null +++ b/Android/app/src/main/res/drawable/ic_nav_newtab.xml @@ -0,0 +1,12 @@ + + + diff --git a/Android/app/src/main/res/drawable/ic_nav_refresh.xml b/Android/app/src/main/res/drawable/ic_nav_refresh.xml new file mode 100644 index 00000000..27766e42 --- /dev/null +++ b/Android/app/src/main/res/drawable/ic_nav_refresh.xml @@ -0,0 +1,12 @@ + + + diff --git a/Android/app/src/main/res/drawable/ic_splash_logo.xml b/Android/app/src/main/res/drawable/ic_splash_logo.xml new file mode 100644 index 00000000..31eb8233 --- /dev/null +++ b/Android/app/src/main/res/drawable/ic_splash_logo.xml @@ -0,0 +1,36 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/Android/app/src/main/res/mipmap-anydpi-v26/ic_launcher.xml b/Android/app/src/main/res/mipmap-anydpi-v26/ic_launcher.xml new file mode 100644 index 00000000..6b78462d --- /dev/null +++ b/Android/app/src/main/res/mipmap-anydpi-v26/ic_launcher.xml @@ -0,0 +1,5 @@ + + + + + diff --git a/Android/app/src/main/res/mipmap-anydpi-v26/ic_launcher_round.xml b/Android/app/src/main/res/mipmap-anydpi-v26/ic_launcher_round.xml new file mode 100644 index 00000000..6b78462d --- /dev/null +++ b/Android/app/src/main/res/mipmap-anydpi-v26/ic_launcher_round.xml @@ -0,0 +1,5 @@ + + + + + diff --git a/Android/app/src/main/res/values/colors.xml b/Android/app/src/main/res/values/colors.xml new file mode 100644 index 00000000..f3f45238 --- /dev/null +++ b/Android/app/src/main/res/values/colors.xml @@ -0,0 +1,9 @@ + + + #FF000000 + #FFFFFFFF + #FFF7931A + #FF0A0A0A + #FF1A1A1A + #FF000000 + diff --git a/Android/app/src/main/res/values/strings.xml b/Android/app/src/main/res/values/strings.xml new file mode 100644 index 00000000..db51acea --- /dev/null +++ b/Android/app/src/main/res/values/strings.xml @@ -0,0 +1,35 @@ + + + Archipelago + Server Address + 192.168.1.100 + Enter your Archipelago server IP or hostname + Connect + Connecting… + Could not reach server. Check the address and try again. + Connection timed out. Is the server running? + Your Sovereign\nPersonal Server + Bitcoin node, app platform, and private cloud — all in one box you control. + Get Started + Use HTTPS + Port (optional) + Saved Servers + No saved servers yet + Remove + Disconnect + Server unreachable + Retry + Remote Control + Use your phone as a keyboard and mouse for the kiosk + Close + Open in browser + Back + Forward + Refresh + Server Name (optional) + My Archipelago + Edit + Edit Server + Save Changes + Cancel + diff --git a/Android/app/src/main/res/values/themes.xml b/Android/app/src/main/res/values/themes.xml new file mode 100644 index 00000000..3da59fcb --- /dev/null +++ b/Android/app/src/main/res/values/themes.xml @@ -0,0 +1,14 @@ + + + + + + diff --git a/Android/app/src/main/res/xml/network_security_config.xml b/Android/app/src/main/res/xml/network_security_config.xml new file mode 100644 index 00000000..cdf19ccc --- /dev/null +++ b/Android/app/src/main/res/xml/network_security_config.xml @@ -0,0 +1,9 @@ + + + + + + + + + diff --git a/Android/archipelago-0.3.0-debug.apk.zip b/Android/archipelago-0.3.0-debug.apk.zip new file mode 100644 index 00000000..be620f3c Binary files /dev/null and b/Android/archipelago-0.3.0-debug.apk.zip differ diff --git a/Android/build.gradle.kts b/Android/build.gradle.kts new file mode 100644 index 00000000..ac5880ab --- /dev/null +++ b/Android/build.gradle.kts @@ -0,0 +1,4 @@ +plugins { + id("com.android.application") version "8.4.0" apply false + id("org.jetbrains.kotlin.android") version "1.9.24" apply false +} diff --git a/Android/gradle.properties b/Android/gradle.properties new file mode 100644 index 00000000..8679d5b5 --- /dev/null +++ b/Android/gradle.properties @@ -0,0 +1,5 @@ +org.gradle.jvmargs=-Xmx2048m -Dfile.encoding=UTF-8 +android.useAndroidX=true +kotlin.code.style=official +android.nonTransitiveRClass=true +android.suppressUnsupportedCompileSdk=35 diff --git a/Android/gradle/wrapper/gradle-wrapper.jar b/Android/gradle/wrapper/gradle-wrapper.jar new file mode 100644 index 00000000..e6441136 Binary files /dev/null and b/Android/gradle/wrapper/gradle-wrapper.jar differ diff --git a/Android/gradle/wrapper/gradle-wrapper.properties b/Android/gradle/wrapper/gradle-wrapper.properties new file mode 100644 index 00000000..b82aa23a --- /dev/null +++ b/Android/gradle/wrapper/gradle-wrapper.properties @@ -0,0 +1,7 @@ +distributionBase=GRADLE_USER_HOME +distributionPath=wrapper/dists +distributionUrl=https\://services.gradle.org/distributions/gradle-8.7-bin.zip +networkTimeout=10000 +validateDistributionUrl=true +zipStoreBase=GRADLE_USER_HOME +zipStorePath=wrapper/dists diff --git a/Android/gradlew b/Android/gradlew new file mode 100755 index 00000000..1aa94a42 --- /dev/null +++ b/Android/gradlew @@ -0,0 +1,249 @@ +#!/bin/sh + +# +# Copyright © 2015-2021 the original authors. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# https://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +# + +############################################################################## +# +# Gradle start up script for POSIX generated by Gradle. +# +# Important for running: +# +# (1) You need a POSIX-compliant shell to run this script. If your /bin/sh is +# noncompliant, but you have some other compliant shell such as ksh or +# bash, then to run this script, type that shell name before the whole +# command line, like: +# +# ksh Gradle +# +# Busybox and similar reduced shells will NOT work, because this script +# requires all of these POSIX shell features: +# * functions; +# * expansions «$var», «${var}», «${var:-default}», «${var+SET}», +# «${var#prefix}», «${var%suffix}», and «$( cmd )»; +# * compound commands having a testable exit status, especially «case»; +# * various built-in commands including «command», «set», and «ulimit». +# +# Important for patching: +# +# (2) This script targets any POSIX shell, so it avoids extensions provided +# by Bash, Ksh, etc; in particular arrays are avoided. +# +# The "traditional" practice of packing multiple parameters into a +# space-separated string is a well documented source of bugs and security +# problems, so this is (mostly) avoided, by progressively accumulating +# options in "$@", and eventually passing that to Java. +# +# Where the inherited environment variables (DEFAULT_JVM_OPTS, JAVA_OPTS, +# and GRADLE_OPTS) rely on word-splitting, this is performed explicitly; +# see the in-line comments for details. +# +# There are tweaks for specific operating systems such as AIX, CygWin, +# Darwin, MinGW, and NonStop. +# +# (3) This script is generated from the Groovy template +# https://github.com/gradle/gradle/blob/HEAD/subprojects/plugins/src/main/resources/org/gradle/api/internal/plugins/unixStartScript.txt +# within the Gradle project. +# +# You can find Gradle at https://github.com/gradle/gradle/. +# +############################################################################## + +# Attempt to set APP_HOME + +# Resolve links: $0 may be a link +app_path=$0 + +# Need this for daisy-chained symlinks. +while + APP_HOME=${app_path%"${app_path##*/}"} # leaves a trailing /; empty if no leading path + [ -h "$app_path" ] +do + ls=$( ls -ld "$app_path" ) + link=${ls#*' -> '} + case $link in #( + /*) app_path=$link ;; #( + *) app_path=$APP_HOME$link ;; + esac +done + +# This is normally unused +# shellcheck disable=SC2034 +APP_BASE_NAME=${0##*/} +# Discard cd standard output in case $CDPATH is set (https://github.com/gradle/gradle/issues/25036) +APP_HOME=$( cd "${APP_HOME:-./}" > /dev/null && pwd -P ) || exit + +# Use the maximum available, or set MAX_FD != -1 to use that value. +MAX_FD=maximum + +warn () { + echo "$*" +} >&2 + +die () { + echo + echo "$*" + echo + exit 1 +} >&2 + +# OS specific support (must be 'true' or 'false'). +cygwin=false +msys=false +darwin=false +nonstop=false +case "$( uname )" in #( + CYGWIN* ) cygwin=true ;; #( + Darwin* ) darwin=true ;; #( + MSYS* | MINGW* ) msys=true ;; #( + NONSTOP* ) nonstop=true ;; +esac + +CLASSPATH=$APP_HOME/gradle/wrapper/gradle-wrapper.jar + + +# Determine the Java command to use to start the JVM. +if [ -n "$JAVA_HOME" ] ; then + if [ -x "$JAVA_HOME/jre/sh/java" ] ; then + # IBM's JDK on AIX uses strange locations for the executables + JAVACMD=$JAVA_HOME/jre/sh/java + else + JAVACMD=$JAVA_HOME/bin/java + fi + if [ ! -x "$JAVACMD" ] ; then + die "ERROR: JAVA_HOME is set to an invalid directory: $JAVA_HOME + +Please set the JAVA_HOME variable in your environment to match the +location of your Java installation." + fi +else + JAVACMD=java + if ! command -v java >/dev/null 2>&1 + then + die "ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH. + +Please set the JAVA_HOME variable in your environment to match the +location of your Java installation." + fi +fi + +# Increase the maximum file descriptors if we can. +if ! "$cygwin" && ! "$darwin" && ! "$nonstop" ; then + case $MAX_FD in #( + max*) + # In POSIX sh, ulimit -H is undefined. That's why the result is checked to see if it worked. + # shellcheck disable=SC2039,SC3045 + MAX_FD=$( ulimit -H -n ) || + warn "Could not query maximum file descriptor limit" + esac + case $MAX_FD in #( + '' | soft) :;; #( + *) + # In POSIX sh, ulimit -n is undefined. That's why the result is checked to see if it worked. + # shellcheck disable=SC2039,SC3045 + ulimit -n "$MAX_FD" || + warn "Could not set maximum file descriptor limit to $MAX_FD" + esac +fi + +# Collect all arguments for the java command, stacking in reverse order: +# * args from the command line +# * the main class name +# * -classpath +# * -D...appname settings +# * --module-path (only if needed) +# * DEFAULT_JVM_OPTS, JAVA_OPTS, and GRADLE_OPTS environment variables. + +# For Cygwin or MSYS, switch paths to Windows format before running java +if "$cygwin" || "$msys" ; then + APP_HOME=$( cygpath --path --mixed "$APP_HOME" ) + CLASSPATH=$( cygpath --path --mixed "$CLASSPATH" ) + + JAVACMD=$( cygpath --unix "$JAVACMD" ) + + # Now convert the arguments - kludge to limit ourselves to /bin/sh + for arg do + if + case $arg in #( + -*) false ;; # don't mess with options #( + /?*) t=${arg#/} t=/${t%%/*} # looks like a POSIX filepath + [ -e "$t" ] ;; #( + *) false ;; + esac + then + arg=$( cygpath --path --ignore --mixed "$arg" ) + fi + # Roll the args list around exactly as many times as the number of + # args, so each arg winds up back in the position where it started, but + # possibly modified. + # + # NB: a `for` loop captures its iteration list before it begins, so + # changing the positional parameters here affects neither the number of + # iterations, nor the values presented in `arg`. + shift # remove old arg + set -- "$@" "$arg" # push replacement arg + done +fi + + +# Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script. +DEFAULT_JVM_OPTS='"-Xmx64m" "-Xms64m"' + +# Collect all arguments for the java command: +# * DEFAULT_JVM_OPTS, JAVA_OPTS, JAVA_OPTS, and optsEnvironmentVar are not allowed to contain shell fragments, +# and any embedded shellness will be escaped. +# * For example: A user cannot expect ${Hostname} to be expanded, as it is an environment variable and will be +# treated as '${Hostname}' itself on the command line. + +set -- \ + "-Dorg.gradle.appname=$APP_BASE_NAME" \ + -classpath "$CLASSPATH" \ + org.gradle.wrapper.GradleWrapperMain \ + "$@" + +# Stop when "xargs" is not available. +if ! command -v xargs >/dev/null 2>&1 +then + die "xargs is not available" +fi + +# Use "xargs" to parse quoted args. +# +# With -n1 it outputs one arg per line, with the quotes and backslashes removed. +# +# In Bash we could simply go: +# +# readarray ARGS < <( xargs -n1 <<<"$var" ) && +# set -- "${ARGS[@]}" "$@" +# +# but POSIX shell has neither arrays nor command substitution, so instead we +# post-process each arg (as a line of input to sed) to backslash-escape any +# character that might be a shell metacharacter, then use eval to reverse +# that process (while maintaining the separation between arguments), and wrap +# the whole thing up as a single "set" statement. +# +# This will of course break if any of these variables contains a newline or +# an unmatched quote. +# + +eval "set -- $( + printf '%s\n' "$DEFAULT_JVM_OPTS $JAVA_OPTS $GRADLE_OPTS" | + xargs -n1 | + sed ' s~[^-[:alnum:]+,./:=@_]~\\&~g; ' | + tr '\n' ' ' + )" '"$@"' + +exec "$JAVACMD" "$@" diff --git a/Android/gradlew.bat b/Android/gradlew.bat new file mode 100644 index 00000000..7101f8e4 --- /dev/null +++ b/Android/gradlew.bat @@ -0,0 +1,92 @@ +@rem +@rem Copyright 2015 the original author or authors. +@rem +@rem Licensed under the Apache License, Version 2.0 (the "License"); +@rem you may not use this file except in compliance with the License. +@rem You may obtain a copy of the License at +@rem +@rem https://www.apache.org/licenses/LICENSE-2.0 +@rem +@rem Unless required by applicable law or agreed to in writing, software +@rem distributed under the License is distributed on an "AS IS" BASIS, +@rem WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +@rem See the License for the specific language governing permissions and +@rem limitations under the License. +@rem + +@if "%DEBUG%"=="" @echo off +@rem ########################################################################## +@rem +@rem Gradle startup script for Windows +@rem +@rem ########################################################################## + +@rem Set local scope for the variables with windows NT shell +if "%OS%"=="Windows_NT" setlocal + +set DIRNAME=%~dp0 +if "%DIRNAME%"=="" set DIRNAME=. +@rem This is normally unused +set APP_BASE_NAME=%~n0 +set APP_HOME=%DIRNAME% + +@rem Resolve any "." and ".." in APP_HOME to make it shorter. +for %%i in ("%APP_HOME%") do set APP_HOME=%%~fi + +@rem Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script. +set DEFAULT_JVM_OPTS="-Xmx64m" "-Xms64m" + +@rem Find java.exe +if defined JAVA_HOME goto findJavaFromJavaHome + +set JAVA_EXE=java.exe +%JAVA_EXE% -version >NUL 2>&1 +if %ERRORLEVEL% equ 0 goto execute + +echo. 1>&2 +echo ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH. 1>&2 +echo. 1>&2 +echo Please set the JAVA_HOME variable in your environment to match the 1>&2 +echo location of your Java installation. 1>&2 + +goto fail + +:findJavaFromJavaHome +set JAVA_HOME=%JAVA_HOME:"=% +set JAVA_EXE=%JAVA_HOME%/bin/java.exe + +if exist "%JAVA_EXE%" goto execute + +echo. 1>&2 +echo ERROR: JAVA_HOME is set to an invalid directory: %JAVA_HOME% 1>&2 +echo. 1>&2 +echo Please set the JAVA_HOME variable in your environment to match the 1>&2 +echo location of your Java installation. 1>&2 + +goto fail + +:execute +@rem Setup the command line + +set CLASSPATH=%APP_HOME%\gradle\wrapper\gradle-wrapper.jar + + +@rem Execute Gradle +"%JAVA_EXE%" %DEFAULT_JVM_OPTS% %JAVA_OPTS% %GRADLE_OPTS% "-Dorg.gradle.appname=%APP_BASE_NAME%" -classpath "%CLASSPATH%" org.gradle.wrapper.GradleWrapperMain %* + +:end +@rem End local scope for the variables with windows NT shell +if %ERRORLEVEL% equ 0 goto mainEnd + +:fail +rem Set variable GRADLE_EXIT_CONSOLE if you need the _script_ return code instead of +rem the _cmd.exe /c_ return code! +set EXIT_CODE=%ERRORLEVEL% +if %EXIT_CODE% equ 0 set EXIT_CODE=1 +if not ""=="%GRADLE_EXIT_CONSOLE%" exit %EXIT_CODE% +exit /b %EXIT_CODE% + +:mainEnd +if "%OS%"=="Windows_NT" endlocal + +:omega diff --git a/Android/logo.svg b/Android/logo.svg new file mode 100644 index 00000000..f218f5a4 --- /dev/null +++ b/Android/logo.svg @@ -0,0 +1,10 @@ + + + + + + + + + + diff --git a/Android/settings.gradle.kts b/Android/settings.gradle.kts new file mode 100644 index 00000000..06cab823 --- /dev/null +++ b/Android/settings.gradle.kts @@ -0,0 +1,18 @@ +pluginManagement { + repositories { + google() + mavenCentral() + gradlePluginPortal() + } +} + +dependencyResolutionManagement { + repositoriesMode.set(RepositoriesMode.FAIL_ON_PROJECT_REPOS) + repositories { + google() + mavenCentral() + } +} + +rootProject.name = "Archipelago" +include(":app") diff --git a/Android/ship-companion.sh b/Android/ship-companion.sh new file mode 100755 index 00000000..cd32646c --- /dev/null +++ b/Android/ship-companion.sh @@ -0,0 +1,41 @@ +#!/usr/bin/env bash +# +# Build the Android companion app and publish it as the served download +# (neode-ui/public/packages/archipelago-companion.apk — a plain APK a phone can +# install straight from the link), then commit + push. +# +# Use this INSTEAD of `git push` when shipping the companion app, so the +# downloadable APK on the node always matches what's on main. +# +# ./Android/ship-companion.sh +# +# The actual build/sign/verify/stage is done by scripts/publish-companion-apk.sh +# (single source of truth, shared with the pre-push hook). It does a CLEAN build, +# forces v1+v2+v3 signing, and ABORTS if any signature scheme is missing — so a +# broken or v2-only APK can never be shipped. +set -euo pipefail + +ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" +cd "$ROOT" + +export JAVA_HOME="${JAVA_HOME:-/opt/homebrew/opt/openjdk@17}" +export ANDROID_HOME="${ANDROID_HOME:-$HOME/Library/Android/sdk}" + +DEST="neode-ui/public/packages/archipelago-companion.apk" + +echo "==> Building + signing + verifying companion APK" +bash scripts/publish-companion-apk.sh + +[ -f "$DEST" ] || { echo "ERROR: served APK not found at $DEST" >&2; exit 1; } + +if git diff --cached --quiet -- "$DEST"; then + echo "==> Nothing to commit (APK unchanged)" +else + git commit -q -m "chore(android): update companion apk download" + echo "==> Committed" +fi + +echo "==> Pushing $(git branch --show-current)" +# SHIP_COMPANION lets the pre-push guard know the APK was just refreshed. +SHIP_COMPANION=1 git push origin "$(git branch --show-current)" +echo "==> Done — companion APK published and pushed." diff --git a/CHANGELOG.md b/CHANGELOG.md new file mode 100644 index 00000000..920ad877 --- /dev/null +++ b/CHANGELOG.md @@ -0,0 +1,1006 @@ +# Changelog + +## v1.7.100-alpha (2026-07-14) + +- Bitcoin now supports multiple versions of both Bitcoin Core and Bitcoin Knots: install the version you want, switch between them, pin a version, or let it auto-update — and switching is designed to be safe, with no surprise resyncs. +- Lightning grew up: your LND wallet's recovery seed is captured at setup and kept as an encrypted backup you can reveal from Settings, there's a new Channels tab with a fee control when opening channels, and on-chain and Lightning balances now show side by side. +- Installing Lightning (and other Bitcoin-dependent apps) on a fresh node no longer fails repeatedly — the node now waits until Bitcoin is genuinely ready to answer before starting them, and Bitcoin sizes its storage to your actual disk and its memory cache to your RAM, so small machines stop swapping and stalling. +- The wallet understands more money: Cashu v4 tokens are supported, you can pay for a peer's files from either your Cashu or Fedimint ecash, and the Transactions view now shows your Lightning, Cashu, and Fedimint activity together — with a payment confirmation screen and an automatic refund if a purchase fails. +- Mesh radios got a major upgrade: Meshtastic direct messages are now true end-to-end-encrypted radio messages that interoperate with off-the-shelf Meshtastic phone apps, your radio's region and a shared channel are provisioned automatically, and a new setup window appears when a radio is plugged in — with board pictures, full radio settings, and signal-strength indicators. +- Reticulum joins as a third mesh radio protocol with RNode LoRa hardware support, including sending images and voice messages over the radio — and every chat message now carries a small pill showing how it travelled (Mesh, FIPS, or Tor). +- Your node can manage an OpenWrt router: set up its internet uplink from the UI with a Wi-Fi network scan, turn it into a TollGate pay-for-Wi-Fi hotspot with a real captive portal, and sweep the router's earnings into your node's wallet. The gateway's status appears on the Home screen's Network tile. +- Peering is now trust-aware: "Invite a Peer" grants view-only Observer access while "Link Your Nodes" grants Trusted access, incoming requests ask for your confirmation with an optional message, Node Visibility is a single clear switch plus a list of discoverable nodes you can peer with, and the Fleet view shows your trusted nodes' health. +- Updates and apps are verified end-to-end: release updates are cryptographically signed and checked against a key baked into your node, app definitions arrive via the signed catalog, and container images are checked against trusted sources before anything installs or runs. +- Dozens of reliability fixes: failed installs no longer leave phantom app cards, uninstalling can't hang forever, apps you stopped stay stopped, crashed apps heal themselves (even "running" containers whose process actually died), the login page no longer refresh-loops, and the mobile layout fits real phone screens instead of hiding the last row behind the browser bar. + +### Also in this release + +- Ask your node things over the radio: send "!archy" for node status with no AI involved, or "!ai " in a direct message for an AI answer that comes back on the same path it arrived — with a model dropdown (Haiku, Sonnet, or Opus) and an "always allow" list in the Mesh AI Assistant panel. +- The off-grid mesh radio no longer posts cryptic identity codes ("ARCHY:") to the shared public channel every minute. +- Mesh contacts take care of themselves: new radios you hear are added automatically, "Clear All" really removes contacts (they return when in range), each contact shows a reachability dot, and the Peers list has a search box. +- You can message standard meshcore phone apps and they can message you — readable text both ways, private replies instead of public-channel broadcasts. +- Federated Archipelago nodes now appear on the Mesh Map. +- Apps open as an overlay on top of whatever page you're on, in every display mode, instead of yanking you to a different screen; the Services tab groups apps by category with proper icons. +- BTCPay Server keeps its plugins across restarts, connects to your node's own LND out of the box, and its invoices stay payable over private Lightning channels. +- Fedimint federations show up in Wallet Settings again (the client app's configuration error is fixed), and Wallet Settings has tabbed sections for Cashu and Fedimint. +- The phone companion app can upload and download files, edit saved server entries, opens non-embeddable apps in an in-app browser, and got a proper round launcher icon. +- Six placeholder "apps" that were just web bookmarks (484.kitchen, arch-presentation, call-the-operator, nwnn, syntropy-institute, t-zero) are gone from the store. +- The Bitcoin dashboard works fully offline (no more loading its styling from the internet), Gitea opens on the right port, and mempool, strfry, and Electrum stopped their restart/health-check loops. +- Kiosk displays: HDMI audio no longer stutters, and a bad display-clone state no longer sticks after reboot. +- Consistent dropdowns, toggles, tabs, and modal styling across Settings, Federation, and the rest of the UI; in Mesh chat, scrolling the conversation no longer also scrolls the contact list; "App Updates" and "App Registry" sit directly under Account in Settings. +- A fresh node no longer reinstalls apps just because their definition file exists on disk — only apps you actually installed come back. + +## v1.7.99-alpha (2026-06-17) + +- Your node can now hold Fedimint ecash as well as Cashu. Wallet Settings now has tabbed sections for each: keep your list of trusted Cashu mints, or paste a Fedimint invite code to join a federation, and the home wallet card shows both your Cashu and Fedimint balances side by side. A new "Fedimint Client" app in the catalog powers the federation side. +- You can now buy files shared by another node, right from their cloud. When you open a peer's paid file you get a simple "Buy this file" picker with several ways to pay — instantly from this node's ecash balance, from your node's own Lightning wallet, on-chain from your node, or by scanning a Lightning QR code with any outside wallet. Once payment settles, the file downloads automatically. +- Your node can now act as an AI assistant on the off-grid mesh radio network. If your node has a local AI model available (via Ollama), other people on the mesh can ask it a question by starting their message with "!ai" and get an answer back over the radio — handy where there's no internet. A new Mesh assistant panel lets you turn this on or off and shows whether a local AI model was detected. +- You can now view your node's 24-word recovery phrase whenever you need it. Settings has a new "Recovery phrase" option that, after you confirm your password (and 2FA code if you use one), reveals the words behind a tap-to-show blur with a copy button — so you can write them down and store them safely offline. +- Setting up a brand-new node is smoother and less alarming. If the node is still starting up while you generate or confirm your recovery phrase, it now quietly waits and retries instead of flashing a scary error, and offers a clear "Try again" button only when something genuinely goes wrong. The final setup screen also shows a gentle "securing your private connection…" status that turns to "ready" on its own, so you can tell the encrypted transport is coming up rather than stuck. +- The NetBird VPN app now actually logs in. It was failing to reach its sign-in screen because the dashboard needs a secure (HTTPS) connection that wasn't being provided; the node now serves it over HTTPS and opens it in a browser tab, so the login flow completes. +- When you use your phone to remote-control a node's attached screen, two-finger scrolling now works inside apps and panels, not just the main page. And tapping an app that's meant to open in an external browser now hands the link to your phone to open there, instead of trying to open it on the (often unattended) attached display. +- You can now choose whether your node shares Bitcoin block headers over the mesh. The Mesh Bitcoin panel has new switches to announce headers to peers and to accept headers from them, and your choices are remembered. +- Version numbers now display cleanly everywhere. In a few places the interface was showing a doubled "v" (like "vv1.7.98"); it now always shows a single, tidy version label. +- The "Back" buttons throughout the cloud and other detail screens now look and behave consistently on both desktop and mobile, including when browsing another node's files. +- For advanced testing, Settings now includes an optional "update & app source" choice between the usual trusted origin and an experimental peer-to-peer (DHT swarm) mode that pulls updates and app content from other nodes first, falling back to the origin automatically. The trusted origin remains the default. + +## v1.7.98-alpha (2026-06-16) + +- Apps that crash now recover on their own. Multi-part apps like Immich and IndeedHub could have one of their pieces stop and stay stopped until the whole node was rebooted; the node now checks every couple of minutes and restarts any crashed piece automatically (while still leaving apps you deliberately stopped alone). +- The on-screen kiosk display can no longer slow the whole node down. On machines without a graphics chip the kiosk browser could spin a CPU core at full tilt, starving everything else (including the wallet, which then timed out); it's now capped and uses lighter rendering on those machines. +- If an update download fails, you're taken back to the Download button to retry, instead of being stranded on an Install button for an update that didn't actually finish downloading. +- Your node's identity is clearer and always visible: Settings now shows your Node DID on every node (it previously only appeared if your browser had cached it) plus your node's npub, both with copy buttons. There's also a terminal tool to cryptographically prove all your node's keys come from your one seed phrase. +- The "all nodes over Tor" group chat sends quickly now — the "sending" spinner clears as soon as the reachable nodes have the message, instead of hanging on a slow or offline node. +- Message notifications now have a close button and open the relevant chat when tapped. +- The encrypted mesh transport (FIPS) turns itself on automatically after setup — no button to press — and connects to peers more reliably (it retries and keeps connections warm), so node-to-node features use the fast path more often instead of falling back to Tor. +- Your chat history with other nodes is saved reliably and now encrypted on disk, so it survives restarts and updates and can't be read from a stolen drive (only clearing chat removes it). +- Peer media shows a "connecting" loader before a video or audio file plays, and audio errors are accurate instead of blaming File Browser. +- The Fedimint app now displays with its proper styling, and the Connected Nodes screen stays compact — it shows a few nodes and scrolls, you can tap a node to jump to it in Federation, or tap Message to open its chat. +- App updates can now arrive on their own without waiting for a full system release, so individual apps can be improved and shipped faster. + +## v1.7.97-alpha (2026-06-16) + +- The Bitcoin sync status on the home screen no longer disappears for a moment when it refreshes. If the node was briefly busy, the panel used to vanish and pop back; it now stays put and simply shows "Updating…" until the next reading arrives, while a genuinely stopped node still correctly shows as not running. +- Bitcoin sync progress on the home screen now updates more promptly, so the percentage and block height keep pace with the node instead of lagging behind. +- The Lightning wallet "connect your wallet" screen loads its details and QR code again across all nodes, instead of failing to fetch them. +- Your list of trusted nodes is now clean: the same node no longer appears several times under different names, and removed nodes stay removed. In chat, a node that previously showed up as two separate contacts now appears just once. +- Browsing another node's cloud is smoother: music and video files from a peer now preview and play properly (including seeking partway through), and the connection now shows a small badge telling you whether it's using the fast encrypted mesh or the slower Tor network. +- Opening "My Folders" in the cloud now shows a clear, friendly message when the file app isn't running, instead of a confusing error. +- The Electrum server app opens on its own once it's ready, instead of sometimes leaving a loading spinner stuck on top of the screen. +- The Fedimint app now displays with its proper styling and icons, instead of appearing unstyled with a missing image. +- The Mempool app now connects to your Bitcoin node whether the node is Bitcoin Core or Bitcoin Knots, instead of only working with one of them. +- Nodes start up cleanly after a reboot. On some boots the node's main service was trying to start before its data drive had finished mounting, so it failed and retried about twenty times over roughly five minutes — showing a wall of "Failed to start" messages — before finally coming up. It now waits for the data drive to be ready first, so it starts on the first try. +- The background images throughout the interface now load faster — they've been made significantly smaller with no loss of quality. + +## v1.7.96-alpha (2026-06-15) + +- The screen attached to your node now shows the normal Archipelago interface and your dashboard after you sign in, instead of a separate, stripped-down grid of app icons that could appear in its place. That extra screen has been removed so the attached display matches what you see everywhere else. +- On a brand-new node, the attached screen now walks through the same welcome and setup steps you'd see on a phone or laptop, and shows the normal sign-in screen once the node is set up — so the on-device display always matches the rest of the interface. +- When adding a FIPS network anchor, you can now choose whether it connects over TCP (for a public anchor reached across the internet) or UDP (for one on your local network), instead of it always assuming the local-network option. +- Behind the scenes, a new automated two-node test now exercises real node-to-node features — browsing another node's shared files and handling a removed node — against live nodes before each release, so node-to-node problems are caught earlier. + +## v1.7.95-alpha (2026-06-15) + +- Browsing another node's shared files now works over the fast encrypted mesh. Opening a peer's cloud could fail with a generic "Operation failed" message because the request for their file list wasn't permitted over the mesh and came back as "not found" — and it never retried over Tor. The mesh now serves the file list directly, and if a peer can't answer over the mesh the node automatically falls back to Tor instead of giving up. +- Nodes you remove from your federation now stay removed. Previously a deleted node could quietly come back the next time you synced with another node that still listed it. Removed nodes are now remembered as removed and won't reappear on their own — only if you add them back yourself. +- The app credentials pop-up now appears as a normal centred box with a dimmed background over the whole screen, instead of stretching to fill the entire screen. + +## v1.7.94-alpha (2026-06-15) + +- Your node now joins the private encrypted mesh network on its own. A wrong built-in setting meant nodes were quietly never reaching the shared mesh meeting point, so everything between nodes fell back to the slower Tor network. Every node now connects to the mesh automatically on startup, so node-to-node features like file sharing use the faster encrypted mesh first and only fall back to Tor when a peer is genuinely offline. (Confirmed live: a node with its mesh setting wiped re-connected to the mesh by itself within a second of starting.) +- You can now bring the mesh networking software up to the latest stable version straight from the node, with one action — it fetches the new version, checks it's genuine before installing, and restarts the mesh on its own. (Confirmed live end to end: a node on an older build was upgraded to the current stable release and rejoined the mesh automatically.) +- The Lightning wallet screen connects again on nodes where it was showing a "failed to fetch" error instead of your balance and channels. The wallet app and the node now talk to each other correctly, and the connection quietly repairs itself if its details drift after a restart. + +## v1.7.93-alpha (2026-06-14) + +- Receiving Bitcoin and Lightning works again on nodes where the Lightning wallet was stuck locked. After some updates the wallet could come back locked with a password the node no longer had, so "generate a receive address" kept failing with a "wallet is locked" message that nothing could clear. The node now detects this and repairs itself automatically. +- Each node now secures its Lightning wallet with its own unique, randomly generated password instead of a shared built-in one, and remembers it safely so the wallet unlocks on its own after every restart or update — no more getting stuck locked. +- If a wallet is found locked with an unrecoverable password, the node rebuilds it cleanly so Bitcoin and Lightning start working again. (On these early-access nodes the wallet holds no funds, so nothing is lost — a wallet locked with an unknown password was already inaccessible.) +- The self-repair was validated end to end on live nodes: a stuck, locked wallet was detected, rebuilt, and came back unlocked on its own, and stayed unlocked across restarts. + +## v1.7.92-alpha (2026-06-14) + +- The Electrum server app no longer flashes a "can't connect, try again" error over its loading screen while it's still catching up. If ElectrumX is building its index or waiting on the Bitcoin node, you now just see the sync progress, and the app opens on its own once it's ready. +- Behind the scenes, the reboot-survival test now confirms the whole system is genuinely healthy after a restart — every app reachable, updates not stuck, core services answering — instead of only checking that containers came back, so update-related problems are caught before shipping. +- Settings → What's New now lists the notes for every recent release again. The screen had quietly fallen several versions behind, so the last eight releases of changes weren't showing up there — they're all back now, and a release check keeps it from drifting again. + +## v1.7.91-alpha (2026-06-14) + +- Apps you've installed now reliably show their "Open" button again. Some apps — including Jellyfin, BTCPay Server, Fedimint, Gitea and Portainer — were running fine but their launch link sometimes went missing, so there was no way to open them from the home screen. They now open correctly. +- Receiving Bitcoin is more dependable: if the wallet's internal connection details drift after a restart, it now repairs them on its own, and any error it does hit is reported clearly instead of as a generic failure or a misleading "wallet locked" message. +- Installing Bitcoin now sets itself up correctly without manual help — a security credential that could previously be missing and stop Bitcoin from starting is created automatically before it launches. +- The Electrum server app is back on the home screen and can be launched again. +- Behind the scenes, the release now runs an expanded automated test suite before shipping, so these kinds of issues are caught earlier. + +## v1.7.90-alpha (2026-06-13) + +- Generating a Bitcoin receive address works again — the wallet now requests the correct address type, fixing the "400 Bad Request" error when creating an address. +- In the companion app, the on-screen pointer can now click into apps and type — including the app store search box — instead of clicks and keystrokes not reaching app content. +- "Open in a new tab" from the companion app now opens the app in your phone's browser, instead of doing nothing. The normal mobile browser keeps working as before. +- The login/credentials pop-up on phones is once again a centered, properly sized window rather than stretching the full height of the screen. +- The Electrum server now recovers on its own if its index ever gets corrupted, and shows a clear progress screen (with percent complete and block height) while it builds its index, instead of a blank or broken page. +- Software updates are more reliable on slow internet connections — downloads are given much more time to finish before giving up. + +## v1.7.89-alpha (2026-06-12) + +- The AI assistant looks the way it always did again: no extra back button or close button on phones, and the desktop view fills the whole screen without a gap at the bottom. +- System updates are much more reliable: updates that previously got stuck partway or failed to install now complete cleanly, and a failed update can no longer block all future updates. +- After an update, the system now checks itself correctly on every node type, so working updates are no longer mistakenly undone. +- Generating a Bitcoin receive address works again on nodes where a network proxy previously got in the way. +- The Lightning wallet now recovers and unlocks itself properly after restarts. + +## v1.7.88-alpha (2026-06-12) + +- AIUI now loads immediately again instead of waiting on a production availability probe and cache-busted iframe URL, restoring the lighter launch behavior from before the regression. +- Bitcoin receive now uses LND's GET-based newaddress flow with the native SegWit address type, fixing the `501 Method Not Allowed` response from the previous POST attempt. +- Validation pending on the AIUI rollback; the rest of the release train remains unchanged. + +## v1.7.87-alpha (2026-06-12) + +- Bitcoin receive now calls LND's on-chain address endpoint with the correct REST method, and backend failures keep the specific address-generation error instead of collapsing into the generic operation-failed message. +- App launch credential interstitials now render as true full-screen overlays, and the launcher loading indicator uses the neutral brand palette instead of a blue spinner. +- Validation passed with `git diff --check`, `npm run type-check`, and the focused frontend tests for `bitcoinReceive` and `AppIconGrid`. + +## v1.7.86-alpha (2026-06-12) + +- Fleet now preserves the last known node list, alerts, and selection locally while telemetry refreshes in the background, so the dashboard no longer blanks on tab switches or update scans. +- Connected nodes and identities now reuse their last loaded data instead of reloading the visible list every time the user revisits the tab. +- The Fleet matrix and detail views now show actual node names and host information instead of raw node id prefixes. +- The network map only redraws when its graph data actually changes, which stops the D3 scene from visually resetting on every refresh tick. +- Mobile federation and system-update actions now stack full width, and the ElectrumX app health check allows a long startup window so slow sync nodes do not restart mid-index. +- Validation passed with `git diff --check`, focused frontend tests, and `npm run type-check`. + +## v1.7.85-alpha (2026-06-12) + +- ElectrumX now runs with less cache pressure and more memory headroom, reducing the restart loop seen during sync catch-up. +- Portainer is pinned to `2.19.4` instead of `latest`, avoiding schema-drift restarts from surprise image updates. +- LND receive-address creation now asks for a native SegWit address and returns clearer wallet/readiness failures when an address is not available. +- Fleet telemetry now carries server name, hostname, and server URL, and the Fleet dashboard shows those names instead of hashed node ids. +- Trusted federation peers are still auto-added transitively, but the local node no longer imports itself back into the fleet list. +- Validation passed locally for the touched frontend helpers, `git diff --check`, and Rust formatting. + +## v1.7.84-alpha (2026-06-11) + +- Bitcoin trusted-node relay approvals now generate restricted `txrelay` RPC credentials when needed and restart the active Bitcoin backend so bitcoind loads the new `rpcauth` whitelist. +- Kiosk mode now includes a browser safe-area path for HDMI displays that crop edges, and self-update refreshes kiosk launcher/systemd files so display fixes ship to existing nodes. The experimental X11 scaling safe-area is opt-in to avoid stretching TV output. +- Wi-Fi setup now reports scan errors instead of showing an empty network list, supports retrying scans from the modal, parses escaped `nmcli` SSIDs correctly, and can join open networks without forcing a WPA password. +- Bitcoin Core now matches Bitcoin Knots for restricted relay RPC support, including the txrelay secret injection and transaction broadcast whitelist. +- The restricted Bitcoin relay whitelist now includes `submitpackage` and `gettxout`, covering newer wallet/package-relay broadcast flows without opening wallet/admin RPC. +- The Bitcoin UI companion image is pinned to `1.7.84-alpha` across release metadata and the Quadlet fallback path, avoiding stale `latest` detection during OTA updates. +- Container scanning now uses an RAII in-flight guard so timeout and error paths cannot leave the scanner stuck in a permanently busy state. +- Validation passed with `cargo fmt`, `cargo check -p archipelago`, `git diff --check`, and focused source review of the relay message/approval path. + +## v1.7.83-alpha (2026-06-11) + +- App launch metadata now derives more consistently from app manifests, with typed launch interfaces and catalog generation updates that keep packaged apps aligned with their runtime ports and launch surfaces. +- Revoked or unsupported app surfaces were removed from the catalog and release path, including OnlyOffice and the unvalidated Saleor surface, so the Marketplace no longer exposes apps that cannot be safely supported in this release. +- The frontend production build now passes strict TypeScript checks after tightening app details, Web5, cloud refresh, and credential test typing. +- Mobile and desktop app surfaces received release polish: improved mobile app layout, safer mesh desktop/tablet scrolling, and the Home system card now routes directly to monitoring. +- Bitcoin UI status rendering now avoids false stale/reconnecting states when fresh block snapshots advance, and guards optional DOM updates so the standalone Bitcoin UI is more resilient. +- Deploy tooling now excludes local Codex scratch output, archived image-build artifacts, and upload screenshots from target syncs, and bounded optional IndeedHub fixups so a stuck Podman helper cannot hold the deploy. +- Validation passed with `npm run type-check`, production `npm run build`, backend `cargo build --release`, catalog/release manifest checks, focused frontend tests, and live `.198` deploy verification through the frontend/service restart phase. + +## v1.7.82-alpha (2026-05-22) + +- Saleor storefront proxying now forwards `X-Forwarded-Host`, fixing Next.js Server Actions requests that compared the browser origin with the internal `storefront-app:3000` upstream host. +- Saleor storefront media now routes `/thumbnail/` and `/media/` through the same `9011` proxy to the Saleor API, fixing product image optimizer failures caused by `localhost:8000` media URLs. +- The Saleor storefront container receives an explicit internal media origin so rewritten media URLs resolve inside the Podman network without exposing private API ports to browsers. +- Validation passed with `cargo fmt --all --check --manifest-path core/Cargo.toml`, `cargo check -p archipelago --manifest-path core/Cargo.toml`, and live checks on `100.114.134.21` for storefront HTML, static assets, GraphQL, media redirects, and optimized product images. + +## v1.7.81-alpha (2026-05-21) + +- Saleor storefront installs now use the prebuilt registry image instead of building the Next.js app on-device, avoiding Podman build failures during stack installation. +- Existing Saleor stacks are repaired on adoption by recreating missing storefront containers, forcing the storefront app to bind `0.0.0.0:3000`, and resolving nginx upstreams dynamically after container restarts. +- The shipped Saleor storefront image now includes public assets and omits Vercel-only Speed Insights injection, fixing broken static asset responses and the local `/_vercel/speed-insights/script.js` browser warning. +- Validation passed with `cargo fmt --all --check --manifest-path core/Cargo.toml`, `cargo check -p archipelago --manifest-path core/Cargo.toml`, and live checks on `100.114.134.21` for `9011` storefront, static assets, and proxied GraphQL. + +## v1.7.80-alpha (2026-05-21) + +- Saleor storefront proxying now falls back to the direct request scheme when no forwarded protocol header is present, fixing direct `http://node:9011` launches that could generate an invalid same-origin GraphQL URL. +- The Saleor storefront release path keeps public proxy support intact by still honoring forwarded HTTPS headers for Nginx Proxy Manager domains while repairing local/direct port launches. +- Validation passed with `cargo fmt --check` and `cargo check` for the Archipelago backend before release staging. + +## v1.7.79-alpha (2026-05-20) + +- Saleor now installs the official Saleor Storefront as part of the stack, built from the pinned `saleor/storefront` source and served as the customer-facing shop on port `9011`. +- Saleor app launches now open the storefront while the admin dashboard remains available on port `9010` with the generated `admin@example.com` credentials shown in Archipelago. +- Public Nginx Proxy Manager hosts forwarding to the Saleor storefront also expose same-origin `/graphql/`, so public storefront domains can talk to the local Saleor API without mixed-content or private-LAN reachability failures. +- Saleor stack metadata, marketplace descriptions, catalog ports, scanner exclusions, and app-session routing now describe the storefront/dashboard/API split explicitly. + +## v1.7.78-alpha (2026-05-20) + +- Public Nginx Proxy Manager hosts for Saleor now keep browser GraphQL calls same-origin at `/graphql/` and proxy them to the local API on `8000`, fixing `Failed to fetch` when a public domain such as `noderunner.shop` was loaded from devices that cannot reach the node's private LAN/tailnet API address. +- Saleor's validated stack changes are now release-ready: dashboard origins on port `9010` are explicitly allowed for dashboard/API calls, preserving the working test-node install path for production nodes. +- NetBird launches now stay pinned to the unified dashboard/proxy origin on port `8087` instead of following stale runtime-discovered server URLs on `8086`. +- NetBird's local nginx proxy now routes browser API, OAuth, relay, and WebSocket traffic through `host.containers.internal:8086` instead of a hard-coded rootless Podman gateway IP, and includes the upstream `management.ProxyService` gRPC path. +- The mobile credentials interstitial now keeps credential lists scrollable and action buttons reachable in both My Apps and the mobile app icon grid. +- Android WebView popup windows now hand external popup URLs to the system browser, covering app login/signup flows that open secondary windows. +- Validation passed with `git diff --check`, `cargo check -p archipelago`, and the focused `npm test -- src/views/appSession/__tests__/appSessionConfig.test.ts` suite. + +## v1.7.77-alpha (2026-05-20) + +- Saleor first-use now exposes generated credentials through Archipelago instead of leaving users at an unexplained dashboard login: App Details shows copyable `admin@example.com` credentials, and My Apps/mobile icon launches show a pre-launch credentials modal. +- Saleor installs now create or repair the `admin@example.com` staff account idempotently after sample data loads, use the correct dashboard mount path, and re-check stack containers after startup so stopped containers are caught. +- NetBird embedded login now uses the upstream-compatible IdP signing-key behavior and sends ID tokens from the dashboard to the management API, fixing the post-signup `Unauthenticated` state while preserving the unified local proxy/logout routes. +- Transient unnamed Podman helper containers created during app install tasks are hidden from My Apps, so generated names like `eager_keldysh` no longer appear as user applications. +- Validation passed with catalog/release JSON checks, `npm run type-check`, and `cargo fmt --all --check --manifest-path core/Cargo.toml`; live checks on `100.114.134.21` confirmed Saleor dashboard/API availability, generated Saleor admin login, NetBird OAuth availability, and NetBird logout redirects. + +## v1.7.76-alpha (2026-05-20) + +- Saleor installs now use dashboard port `9010`, avoiding the existing Portainer `9000` binding on the test node while keeping API `8000`, Mailpit `8025`, and Jaeger `16686` unchanged. +- Saleor's Valkey cache no longer bind-mounts `/var/lib/archipelago/saleor-cache`, and the dashboard container has the minimal rootless nginx capabilities it needs to chown cache files, bind port 80 inside the container, and drop workers to the nginx user. +- NetBird's browser proxy now sends API, OAuth, relay, WebSocket, and management traffic through the stable host-published server port at `169.254.1.2:8086`, avoiding stale rootless Podman DNS/IPs after `netbird-server` restarts. +- Mobile App Store category chips now stay visible above the tab bar, Discover is available on mobile, and category selection updates the page route/query so the selected category is actually shown. +- Apps that require a real browser tab now open directly from the app icon tap instead of first entering an in-shell app-session route, including BTCPay, Grafana, Home Assistant, Vaultwarden, Nextcloud, Portainer, OnlyOffice, Tailscale, Uptime Kuma, Gitea, and Nginx Proxy Manager. +- Validation passed with catalog JSON checks, `npm run type-check`, `cargo fmt --all --check --manifest-path core/Cargo.toml`, and `cargo check -p archipelago --manifest-path core/Cargo.toml`; live checks on `100.70.96.88` confirmed Saleor dashboard `9010`/API `8000` and NetBird API/OAuth routes survive `netbird-server` restart. + +## v1.7.75-alpha (2026-05-19) + +- Saleor is now published as a recommended commerce app with catalog metadata, icon, direct app-session launch on port `9000`, scanner metadata, image pins, and a full stack installer for dashboard, API, worker, PostgreSQL, Valkey, Mailpit, and Jaeger. +- Existing NetBird installs are repaired more aggressively by rewriting unified-origin config, recreating the dashboard/proxy containers, restarting the server, preserving data, and handling exact `/api` and `/oauth2` routes plus dashboard logout redirects through the local proxy. +- Desktop dashboard scrolling now hands focus back from the sidebar to the main content when the pointer or wheel moves over the main pane, preventing the sidebar scroll area from trapping wheel input on short screens. +- Validation passed with catalog JSON checks, `npm run type-check`, `cargo fmt --all --check --manifest-path core/Cargo.toml`, and `cargo check -p archipelago --manifest-path core/Cargo.toml` before release. + +## v1.7.74-alpha (2026-05-19) + +- App-session right panels now re-focus the iframe after load and when the frame area is activated, so wheel/touch scrolling works immediately after switching tabs or selecting an app on shorter screens. +- NetBird now launches through a unified local origin on port `8087` that proxies the dashboard plus `/oauth2`, `/api`, relay, WebSocket, and gRPC routes to `netbird-server`, fixing the embedded login flow that previously ended in `Unauthenticated` or `404 page not found` after logout. +- Existing NetBird installs are repaired on adopt/start by rewriting `config.yaml`, `dashboard.env`, and the local nginx proxy config, then creating the missing `netbird-dashboard` and `netbird` proxy containers when needed while preserving NetBird data. +- Saleor is still pending and is not included in this release; its registry/installer work remains local until it can be validated separately. +- Validation passed with catalog JSON checks, `npm run type-check`, `cargo fmt --all --check --manifest-path core/Cargo.toml`, and `cargo check -p archipelago --manifest-path core/Cargo.toml`. + +## v1.7.73-alpha (2026-05-19) + +- Mobile app launches for iframe-blocked apps now open the direct app URL in a new browser tab immediately instead of landing in a broken in-shell webview that requires a second tap. +- Mobile My Apps/Websites tabs now react to route query changes, App Store pages label the mobile view as Discover, mobile filters have safe bottom spacing, and App Store search ignores the current category so searches cover all available apps. +- My Apps search now surfaces matching App Store entries when the app is not installed, making it possible to jump directly from a failed My Apps search to the installable app details. +- NetBird self-host installs now prefer a `100.x` tailnet/CGNAT address for dashboard, management, relay, STUN, and auth redirect origins when one is present; live repair on `100.89.209.89` updated the existing stack from LAN origins to `100.89.209.89` and restored `netbird-server`. +- App-session iframe frames now focus automatically and wrap the iframe in a scroll host so wheel/touch scrolling works in the active right frame without requiring an initial click. + +## v1.7.72-alpha (2026-05-19) + +- Settings What's New now includes the missing release notes for `v1.7.68-alpha` through `v1.7.71-alpha`, so the modal reflects the current OTA history instead of stopping at `v1.7.67-alpha`. +- The follow-up release carries the NetBird install fix, Gitea icon polish, mobile app-session fallback updates, and rounder app icon masks from `v1.7.71-alpha` with the Settings modal notes included. +- The local Cargo lockfile version metadata is kept in sync with the release bump after the previous release build updated it. + +## v1.7.71-alpha (2026-05-19) + +- NetBird stack installs now pre-create `/var/lib/archipelago/netbird/data` before binding it into `netbird-server`, fixing the failed install/start path seen on `100.70.96.88` where Podman rejected the missing host directory. +- NetBird start/restart ordering now starts `netbird-server` before the dashboard container so lifecycle actions bring the control plane up before the UI. +- App-session invalid IDs and panel-mode fallbacks now return to `/dashboard/apps`, avoiding the stale `/apps` route that could render a 404. +- Mobile launches for apps that block iframes now stay inside the Archipelago app-session fallback instead of automatically opening an external browser tab. +- Installed Gitea containers now report the packaged Gitea icon, and app icon masks use a rounder radius on mobile grids, app cards, and detail headers. +- Validation passed with `npm run type-check`, focused Vitest app-session/app-grid tests, `cargo fmt --all --check --manifest-path core/Cargo.toml`, and `cargo check -p archipelago --manifest-path core/Cargo.toml`. + +## v1.7.70-alpha (2026-05-19) + +- NetBird is being corrected from the peer/client daemon image to the self-hosted NetBird control-plane stack with a launchable dashboard on port `8087`, a combined management/signal/relay server on `8086`, and STUN on UDP `3478`. +- App sessions now always launch local apps through direct host ports and carry an explicit dashboard return target, so closing an iframe returns to the launching dashboard screen instead of falling through to browser history or a 404. +- Mobile app launches ignore stale desktop panel state and route into the full app-session webview consistently. +- The desktop sidebar now pins the logo/version at the top and controller/online/mode controls at the bottom, with only the navigation section scrolling on shorter screens. +- Validation passed with catalog JSON checks, `scripts/image-versions.sh` syntax check, `npm run type-check`, `cargo fmt --all --check --manifest-path core/Cargo.toml`, and `cargo check -p archipelago --manifest-path core/Cargo.toml`. + +## v1.7.69-alpha (2026-05-19) + +- App installs now allow up to 10 minutes for the initial `package.install` RPC to return, matching slow container image pulls and preventing apps from disappearing from My Apps while the backend is still pulling or retrying mirrors. +- Live diagnostics on `100.70.96.88` confirmed the Gitea install did not fail; the primary registry pull timed out after 300 seconds, the fallback mirror succeeded, and Gitea came up healthy on `3001` while the frontend had already timed out at 15 seconds. +- Gitea and other Docker-image app installs now stay visible during slow registry pulls instead of being marked as failed by the browser before backend install progress can complete. +- Gitea is now categorized as a known Data app in My Apps, so a running Gitea container appears with installed apps instead of being filtered into the Websites/Services split. +- NetBird `0.71.2` is now available in the app catalog and fallback marketplace data as a recommended networking app using the official `docker.io/netbirdio/netbird:0.71.2` image. +- NetBird installs get persistent state under `/var/lib/archipelago/netbird`, `NET_ADMIN`/`NET_RAW`, `/dev/net/tun`, `slirp4netns`, image-version pinning, backend metadata, and health checks through `netbird status`. +- The Archipelago terminal now includes `nano` on new disk installs and ISO builds, and self-update installs it on existing nodes if it is missing. +- Validation passed with catalog JSON checks, shell syntax checks, `npm run type-check`, `cargo fmt --all --check --manifest-path core/Cargo.toml`, and `cargo check -p archipelago --manifest-path core/Cargo.toml`. + +## v1.7.68-alpha (2026-05-19) + +- BTCPay Server now ships on the official `docker.io/btcpayserver/btcpayserver:2.3.9` image, fixing the plugin catalog crash caused by newer plugin dependency version metadata while preserving existing datadirs and Postgres databases. +- BTCPay release and first-boot health checks no longer depend on `curl` inside the container; they use a bash TCP probe that works with the official image out of the box. +- Host nginx now serves Nginx Proxy Manager HTTP-01 challenge files before the Archipelago SPA fallback and is marked as the default HTTP/HTTPS virtual host, so public proxy hosts can issue certificates without hijacking local API traffic. +- Nginx Proxy Manager first-boot, runtime repair, and container-doctor paths now pre-create the ACME webroot, keep bind mounts owned by the rootless Archipelago user, and sync issued public proxy hosts into host nginx vhosts. +- The Nginx Proxy Manager host-nginx sync now skips proxy hosts with missing certificate files and rolls back the generated nginx include if validation fails, preventing a bad certificate path from poisoning later nginx reloads. +- App session close buttons now return to the previous dashboard screen when possible and otherwise fall back to My Apps, avoiding the 404 page after closing an app launched from an invalid or stale history entry. +- System Update confirmation and mirror modals now teleport to the document body with a full-screen overlay, so they cover the whole app instead of only the right-hand dashboard panel. +- Mobile app launches stay inside Archipelago's app-session webview and hide desktop-only new-tab launch affordances, including apps such as Home Assistant that previously looked like they would leave the mobile shell. +- Live recovery on `100.70.96.88` upgraded only the `btcpay-server` container to `docker.io/btcpayserver/btcpayserver:2.3.9`, preserved the existing datadir and Postgres database, and confirmed the container is healthy after a pre-upgrade backup. +- Public validation confirmed `spay.tx1138.com`/`www` redirect to BTCPay login over HTTPS and `sapien.tx1138.com`/`www` serve the L484 page over HTTPS using the issued Let's Encrypt certificates. + +## v1.7.67-alpha (2026-05-18) + +- Home dashboard status cards now keep the last known good system, VPN, Bitcoin, and FIPS values while route changes or transient RPC failures are in flight, avoiding false "not configured" or "not running" flashes. +- Home, Web5 Monitoring, and the Monitoring page headline cards now share the same live system-stat snapshot for CPU, memory, disk, uptime, and load so the visible numbers agree across the UI. +- Settings What's New is filled through `v1.7.67-alpha`, including the missing historical `v1.7.44-alpha` through `v1.7.66-alpha` entries. +- Bitcoin/Knots/Core shell lifecycle specs now match the Rust app config memory policy: 8 GiB on normal hosts, 4 GiB on low-memory hosts, and pruned Knots uses a larger dbcache on hosts with enough RAM to improve IBD throughput. +- ElectrumX/electrs shell lifecycle specs now match the 4 GiB memory policy used by the Rust app config, reducing drift between first boot, reconcile, and app lifecycle paths. +- Live assessment of `100.70.96.88` identified the current IBD bottlenecks as CPU/thermal/I/O pressure rather than RAM exhaustion, with follow-up work planned for existing-node swap repair, kiosk Chromium CPU reduction, and reconcile failure cleanup. + +## v1.7.66-alpha (2026-05-18) + +- Nginx Proxy Manager stale-port repair now detects stopped or `Created` Podman records by inspecting `podman ps -a` port metadata, covering records where `podman port nginx-proxy-manager` returns no mapping until start. +- Live recovery on `100.70.96.88` removed only the stale Nginx Proxy Manager container record and recreated it with `8081:81`, `8084:80`, and `8444:443`, preserving `/var/lib/archipelago/nginx-proxy-manager` data. +- Validation confirmed Nginx Proxy Manager recovered as healthy and responds through direct admin port `8081`, host compatibility port `81`, and `/app/nginx-proxy-manager/`. + +## v1.7.65-alpha (2026-05-18) + +- Orchestrator-backed app starts now run the same pre-start repairs as the legacy Podman path, so Nginx Proxy Manager stale `81:81` container metadata is removed and recreated before the orchestrator tries to start it. +- Live diagnostics on `100.70.96.88` confirmed host nginx is healthy while Nginx Proxy Manager has no listeners on `8081`, `8084`, or `8444`, causing host nginx `502` responses for NPM proxy paths. + +## v1.7.64-alpha (2026-05-18) + +- Update apply rate limiting is relaxed for authenticated admins from 2 attempts per 10 minutes to 10 attempts per minute, preventing the System Update page from getting stuck behind `429 Too Many Requests` during legitimate OTA retry/troubleshooting flows. +- The corrected backend artifact rebuild protection from `v1.7.63-alpha` remains in place, so this release is built from a fresh Rust backend binary before publishing. + +## v1.7.63-alpha (2026-05-18) + +- Release automation now rebuilds the Rust backend after bumping the version and before hashing release artifacts, preventing OTA manifests from pointing at a stale backend binary. +- This corrected release carries the Nginx Proxy Manager stale-port repair in an updated backend binary, so nodes running `1.7.61-alpha` can actually receive and execute the fix. +- Validation confirmed the previously published `v1.7.62-alpha` backend artifact still contained `1.7.61-alpha`, explaining why nodes did not advance after applying that update. + +## v1.7.62-alpha (2026-05-18) + +- Nginx Proxy Manager start and restart now repair stale Podman containers that still publish the admin UI on host port `81`, which conflicts with host nginx on updated nodes. +- The repair recreates only the stale Nginx Proxy Manager container metadata while preserving `/var/lib/archipelago/nginx-proxy-manager` data and using the current `8081:81`, `8084:80`, and `8444:443` mappings. +- Runtime stale-listener cleanup for Nginx Proxy Manager is shared across start and restart paths so rootless port helper leftovers are still cleared before lifecycle retries. +- Validation passed with `cargo fmt --all --check --manifest-path core/Cargo.toml` and `cargo check -p archipelago --manifest-path core/Cargo.toml`. + +## v1.7.61-alpha (2026-05-18) + +- Multi-container stack installs now keep their app card in the `Installing` state for up to 20 minutes while dependency containers are being pulled and prepared. +- BTCPay Server installs no longer appear to vanish or fail after two minutes while Postgres and NBXplorer are still being created before the primary `btcpay-server` container exists. +- The stale-transition escape hatch remains short for start, stop, restart, update, and removal operations, so genuinely wedged lifecycle actions still recover quickly. +- Live validation on `100.70.96.88` confirmed BTCPay Server completed installation and responds on port `23000` with the expected HTTP redirect. + +## v1.7.60-alpha (2026-05-18) + +- Meshtastic serial detection now rejects malformed or incomplete handshakes instead of accepting unrelated serial devices as a fallback Meshtastic radio. +- Mesh radio auto-detection now skips known non-mesh serial devices such as Sierra Wireless LTE modems and Zooz/Z-Wave sticks, avoiding interference with production peripherals. +- Meshtastic config sync now sends `want_config_id` with the correct protobuf wire type, fixing radio-side `ignore malformed toradio` errors and allowing node-info/contact ingestion. +- The stable `/dev/mesh-radio` udev rule no longer claims every `ttyACM*` device; it only matches known mesh USB serial adapters and known USB CDC ACM radio vendors. +- Live validation on `100.70.96.88` confirmed Archipelago selects `/dev/ttyUSB0`, identifies the Meshtastic node, and refreshes 103 mesh contacts. + +## v1.7.59-alpha (2026-05-17) + +- Mobile app launching now keeps known container apps inside Archipelago's app-session flow instead of forcing desktop-only new-tab behavior on phones. +- App sessions on mobile now respect the status-bar safe area so foreground iframe content starts below the device chrome while the fullscreen backdrop remains edge-to-edge. +- Prepackaged website launch buttons now resolve their curated website URLs before website-container fallback logic, restoring launches for the L484 sites and adding the Arch Presentation bookmark. +- Meshtastic contact discovery now drains the radio config stream through completion and retries config sync when the contact cache is empty, so nearby nodes already known by the radio are more likely to appear in Archipelago. +- The Apps page now includes a compact sideload button and modal for installing trusted Docker images with optional title, description, and port mapping metadata. +- Sideloaded app title and description metadata now persist through the backend app-config file so refreshed package scans do not collapse custom apps back to generic IDs. +- Validation passed with `npm test -- appLauncher`, `npm run build`, `cargo check -p archipelago`, and `cargo fmt --all --check`. + +## v1.7.58-alpha (2026-05-17) + +- Mesh networking now supports Meshtastic radios over the Meshtastic serial API in addition to existing MeshCore Companion USB radios. +- The mesh listener now probes preferred and auto-detected serial paths for both MeshCore and Meshtastic firmware, preserving the existing reconnect loop so unplug/replug and firmware hot-swap behavior stays consistent. +- Meshtastic text packets are translated into the existing Archipelago mesh frame pipeline, so current RPC handlers, transport routing, message storage, typed-message decoding, and UI state continue to work without a separate frontend path. +- Meshtastic node information is surfaced as normal mesh contacts using stable synthetic public keys derived from Meshtastic node numbers, allowing peer refresh and message attribution to reuse existing MeshCore contact handling. +- Outbound Archipelago mesh messages can now be sent through Meshtastic as channel text packets using the same command path used by MeshCore channel broadcasts. +- Device status now reports the detected firmware family as `meshcore` or `meshtastic` from the shared listener abstraction. +- Radio udev rules now include USB CDC ACM serial devices (`ttyACM*`) alongside CP2102, CH340, and FTDI adapters so Meshtastic boards are more likely to appear through the stable `/dev/mesh-radio` symlink. +- Host nginx now serves `/assets/*` hashed frontend chunks as immutable static files with a hard 404 on misses instead of falling back to `index.html`, preventing strict MIME errors when a browser has a stale pre-update HTML shell. +- The SPA HTML shell and service-worker files now revalidate on every load, reducing stale frontend references after OTA updates. +- OTA runtime promotion now installs the bundled `nginx-archipelago.conf` into `/etc/nginx/sites-available/archipelago` and reloads nginx after a successful config test, so frontend cache/fallback fixes reach existing nodes without a manual deploy. +- Local validation passed with `cargo check -p archipelago`; live SSH testing against `100.70.96.88` was not completed because temporary public-key authentication was rejected on the target. + +## v1.7.57-alpha (2026-05-17) + +- Nginx Proxy Manager now avoids privileged rootless Podman host port `81`, preferring `8081:81` while host nginx keeps a compatibility proxy on `:81` for stale cached launch buttons. +- App installs now allocate ports by checking live host bind availability, falling back to a free high port when preferred ports are already occupied. +- Portainer-created launchable containers are separated into a `Websites` tab and launch through their discovered published host port instead of hard-coded app URLs. +- Internal BuildKit helper containers such as `buildx_buildkit_default` are hidden from the Apps UI. +- Portainer works out of the box on Debian 13/Podman installs by including `catatonit` and by preserving the Podman socket mount as a socket rather than creating it as a directory. + +## v1.7.56-alpha (2026-05-15) + +- Health notifications now clear when an app is no longer unhealthy, including stale alerts for removed containers such as Portainer. +- Fresh installs now include the full Wi-Fi userspace stack (`wpasupplicant`, `wireless-regdb`, `iw`, `rfkill`, `polkitd`, `pciutils`, and `usbutils`) so NetworkManager can scan and connect with Intel Wi-Fi cards out of the box. +- The installed system now grants the `archipelago` service user explicit NetworkManager PolicyKit access for web-triggered Wi-Fi scans and connection changes. +- Wi-Fi connect now replaces stale/partial NetworkManager profiles and creates an explicit WPA-PSK profile with the supplied password, avoiding no-secret retry failures after a failed attempt. +- Settings password changes now update the Linux/SSH password through non-interactive sudo, so the web password and SSH password stay in sync when the checkbox is enabled. +- Quadlet environment values with spaces or shell metacharacters are quoted consistently, preventing env drift recreate loops for apps like nostr-rs-relay and Grafana. +- Boot/bootstrap reconcile avoids restarting running Bitcoin containers while repairing RPC config, preserving IBD progress on active nodes. +- Exit code 137 is labeled as SIGKILL instead of assuming OOM, avoiding false OOM alerts for orchestrator-managed recreates. +- Container reconcile force-recreates Podman records stuck in `Stopping`, preserving bind-mounted app data while recovering wedged containers automatically. +- Container health reporting is honest for running containers: Archipelago surfaces Podman's actual health state instead of marking every running container healthy. +- Quadlet reconciliation restarts services when stale health gates, port bindings, network aliases, exec commands, or healthchecks drift from the current manifest. +- Bitcoin Knots sync performance improves on fresh installs and updates with 8Gi container memory, a 4Gi dbcache, and full CPU parallelism. +- ElectrumX initial indexing gets more headroom: CPU caps are removed, memory is raised to 4Gi, cache is raised to 3Gi, and oversized sends are allowed for heavier wallet/indexing workloads. +- Mempool/ElectrumX lifecycle qualification respects pruned/non-archival Bitcoin nodes instead of installing a half-running stack with unhealthy dependencies. +- LND wallet/RPC helpers are more tolerant of container-owned files and updated REST port metadata, improving LND lifecycle and wallet-connect flows. +- Marketplace/catalog metadata carries richer container config so remote lifecycle tests install apps using the same settings users get from the UI. +- The app screensaver no longer activates during media-heavy app sessions such as IndeeHub, Jellyfin, Immich, PhotoPrism, and File Browser; apps can also pause/resume it with media playback messages. +- A fresh `1.7.56-alpha` unbundled installer ISO is built from the same primary VPS2 release line for easy download and USB flashing. + +## v1.7.55-alpha (2026-05-13) + +- Container reconcile now force-recreates Podman records stuck in `Stopping`, preserving bind-mounted app data while recovering wedged containers automatically. +- `.198` is green after the container-layer hardening pass: focused and broad non-destructive lifecycle audits pass, raw Podman health/state sweep is clean, and direct app probes return healthy responses. +- Release-candidate artifacts are staged separately from live update publishing while Gitea artifact hosting is repaired. + +## v1.7.54-alpha (2026-05-06) + +- Existing installs now self-repair nginx backend proxy locations for `/bitcoin-status` and `/api/app-catalog`, including hosts where `sites-enabled/archipelago` is a copied active file instead of a symlink. +- LND UI is consistently served on `18083` across first boot, Tor config, companion Quadlet reconciliation, OTA runtime payloads, and ISO scripts; stale companion units/images are rewritten instead of only checking service active state. +- OTA frontend tarballs now carry a clean runtime payload with updated scripts, docker UI sources, and canonical nginx config, preventing startup promotion from reintroducing stale host assets. +- Release ISO builds now support the primary HTTP app registry when bundling core images, so unbundled media includes File Browser/Cloud support instead of requiring a post-install Marketplace download. +- `.116` was live-updated with the new backend and runtime scripts; focused non-destructive lifecycle audit passes for Bitcoin Knots, LND, BTCPay, Mempool, and Grafana. + +## v1.7.53-alpha (2026-05-05) + +- Bitcoin Knots/Core config generation no longer duplicates RPC bind and port settings between `bitcoin.conf` and container command args, fixing `Unable to bind all endpoints for RPC server` startup failures. +- Legacy Bitcoin container healthchecks no longer depend on `bitcoin-cli`, which is absent from current Knots images and can wedge Podman healthcheck runners. +- Update checks now prefer manifest OTA releases over stale git remotes unless `ARCHIPELAGO_GIT_UPDATES` is explicitly enabled, so installed nodes can see published releases from the VPS mirror. + +## v1.7.52-alpha (2026-05-05) + +- Tailscale now launches the local installed web UI on port `8240` and starts `tailscaled` before `tailscale web`, fixing unreachable installs after container creation. +- Grafana install/start/restart now repairs missing rootless host listeners on port `3000`, matching the existing SearXNG, Uptime Kuma, and Gitea recovery path. +- Debian 13/Trixie ISO and disk-install paths now force security updates from `trixie-security` during image/install creation so rebuilt release media includes patched base packages. +- Broad `.198` lifecycle audit passes with the current qualified app set; known absent blockers remain `electrumx`, `photoprism`, `dwn`, and `ollama`. + +## v1.7.49-alpha (2026-04-30) + +- Bitcoin Knots/Core UI now reports connection, reconnecting, syncing, and error states from a backend status bridge instead of showing a stale "Unable to connect" message while the node is warming up. +- ElectrumX UI now exposes indexed height, local Bitcoin height, known headers, status, and progress source so indexing/waiting states are readable during long initial sync. +- Added container doctor timer and smoke/lifecycle test coverage for Bitcoin Knots/Core, ElectrumX, Mempool, BTCPay/NBXplorer, and UI surface availability. +- Bitcoin Core and Bitcoin Knots are mutually exclusive variants, with a real Bitcoin Core manifest and corrected install conflict handling. +- IndeeHub now launches only on direct web UI port `7778`; the broken `/app/indeedhub/` path proxy was removed, and port `7777` remains the Nostr relay. +- BTCPay/NBXplorer Postgres environment formatting fixed so installs do not carry malformed connection strings. + +## v1.7.48-alpha (2026-04-29) + +- archipelago.service no longer fails to start with "Failed to set up mount namespacing: /run/containers: No such file or directory" on nodes where /run/containers wasn't pre-created. ExecStartPre now creates it. Existing nodes need a one-time `systemctl edit archipelago` to add the mkdir; ISO installs from this version forward have the fix baked in. + +## v1.7.47-alpha (2026-04-29) + +- Bitcoin Knots/Core sync is now significantly faster. The container now uses every available core for script verification (was capped at 2) and has 8GB of memory instead of 4GB so its 4GB UTXO cache has headroom for the mempool and peer connections. Existing nodes pick up the new limits on next install/update; freshly-installed nodes start at full speed. +- ElectrumX initial indexing is faster too. Its CPU cap is removed, container memory is 4GB, and its internal cache is now 3GB (default was 1.2GB). + +## v1.7.46-alpha (2026-04-29) + +- Health monitor no longer pages "Auto-restart failed" for orphaned containers. After a variant switch (bitcoin-core ↔ bitcoin-knots) the previous variant's container could survive uninstall and the health monitor would try restarting it forever. Now skipped silently with a debug log. +- Apps no longer disappear from My Apps when an install fails. The card stays visible with state=Stopped so the user can retry or uninstall, with the failure reason surfaced via the new install_progress.message field. +- "Downloading…" progress now actually advances during multi-image stack pulls. Was sticking at 20% until all pulls finished; now interpolates 20%→70% based on which image of N has landed. +- Pulled four docker.io images (bitcoin, gitea, nextcloud, valkey) into the lfg2025 registries on OVH and tx1138. Removes a docker.io dependency from first-boot installs. +- Resilience harness improvements: install-fail entries no longer vanish, install/uninstall/probe cells are timing-tolerant (60s retry on ui_probe and auth_probe), dep snapshots no longer leak companion containers into the dependent app's "new containers" set. + +## v1.7.45-alpha (2026-04-29) + +- Bitcoin RPC auth is durable. The dashboard reliably connects across container restart, image update, and reboot. Was failing on registry-pulled images that shipped a stale baked-in password. +- Multi-container apps show real install progress. IndeedHub (7), BTCPay (4), Mempool (3), Immich (3) — bar advances through Preparing → Pulling → Creating → Done instead of sitting at 0% until the very end. +- Apps no longer disappear from the dashboard mid-install. The container scanner now respects in-flight installs and updates instead of evicting an entry while its containers are still being created. +- IndeedHub installs cleanly on a fresh node. Five missing environment variables fixed; Nostr sign-in works on first install. +- Tailscale install no longer fails with "executable not found". Container command was a malformed shell string; now a proper command array. +- Removed three catalog entries that hung installs for ten minutes (dwn, endurain, ollama — no source images in our registries). Restored Nextcloud, sourced from docker.io. +- Bitcoin Core update path uses the correct image name (was pulling from a non-existent path). +- New ISO installs now allocate swap (sized to RAM, capped at 8GB, on the encrypted data partition). Without swap, container image builds and memory spikes were hitting OOM under load. + +## v1.7.44-alpha (2026-04-28) + +43de3b73 feat(orchestrator): complete container migration and release hardening +ce39430b feat(self-update): sync and rebuild UI containers on OTA +72dec5aa fix(lnd-ui): align container port across all specs +83aacdf2 chore(release): archive ISO build recipes, tarball-only releases + + +All notable changes to Archipelago will be documented in this file. + +The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/), +and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). + +## [Unreleased] + +## [1.3.1] - 2026-03-25 + +### Security +- All crypto dependencies pinned to exact versions from Cargo.lock (supply chain hardening) + - ed25519-dalek 2.1 → 2.2.0, sha2 → 0.10.9, hmac → 0.12.1, argon2 → 0.5.3, chacha20poly1305 → 0.10.1, zeroize → 1.8.2, hkdf → 0.12.4, aes-gcm → 0.10.3 +- All container images pinned to exact patch versions (no more floating tags) + - postgres:15 → 15.17, redis:7 → 7.4.8, nginx:alpine → 1.29.6-alpine, uptime-kuma:1 → 1.23.17, nextcloud:29 → 29.0.16, valkey:8 → 8.1.6, mariadb:11.4 → 11.4.10, and 7 more + - DWN server pinned by SHA256 digest (only has `:main` branch tag) + +### Reliability +- Nostr relay connections now have 10s timeout — prevents indefinite hangs blocking RPC calls + - identity_manager.rs: publish_profile() + - nostr_discovery.rs: publish_node_revocation(), verify_revocation(), discover_archipelago_nodes() + - marketplace.rs: discover(), publish() + +### Infrastructure +- CI pipeline added (.github/workflows/ci.yml) — cargo fmt, clippy, tests + frontend type-check, build +- Update system now fetches from git.tx1138.com Gitea instance (configurable via ARCHIPELAGO_UPDATE_URL) +- Cleaned up stale git branches (app-store, overnight/2026-03-12, overnight/2026-03-13) + +## [1.3.0] - 2026-03-19 + +### Security + +#### Pentest Remediation (33 findings, all addressed) +- **Critical**: Backend now binds to 127.0.0.1 only — no more direct LAN access to port 5678 +- **Critical**: Fixed path traversal in Tor service management that could allow `sudo rm -rf` on arbitrary directories +- **Critical**: Fixed unauthenticated file read/delete via DWN recordId path traversal +- **High**: Federation peers now require cryptographic signature — unsigned peers rejected +- **High**: Login redirect XSS vulnerability fixed with proper URL validation +- **High**: Viewer role restricted to read-only node methods (was granting sign/export access) +- **High**: Backup restore/verify now validates IDs against path traversal +- **High**: Tar archive extraction validates every entry path (prevents tar slip attacks) +- **High**: S3 backup endpoints require HTTPS and reject private IP ranges +- **Medium**: Remember-me token secret now uses cryptographic random (not machine-id) +- **Medium**: Destructive operations (factory reset, onboarding reset) now require password re-verification +- **Medium**: Session token rotated after TOTP verification (prevents interception reuse) +- **Medium**: Webhook URL validation hardened against IPv6 bypass, DNS rebinding, redirect chains +- **Low**: CORS localhost:8100 only included in dev mode +- **Low**: CSP `unsafe-inline` removed from `script-src` +- **Low**: Content filenames validated against path separators and hidden file prefixes +- **Low**: Nostr relay URLs restricted to `wss://` with private IP rejection +- **Low**: Onion address validation enforces v3 format (56 base32 chars) +- **Low**: Router detection restricted to private IP ranges only + +#### Nginx Authentication +- Fixed session cookie name mismatch (`session_id` → `session`) across all nginx auth checks +- LND Connect info endpoint now properly authenticated + +### Container Reliability + +#### Memory Limits (prevents OOM crashes) +- All 37 containers in `first-boot-containers.sh` now have `--memory=` limits +- Automatic RAM tier detection — reduced limits on 8GB machines +- Prevents a single runaway container from crashing the entire system + +#### Smart Container States +- New `exited` state distinguishes crashed containers from intentionally stopped ones +- Crashed containers show red "crashed" badge with restart button +- Health-aware status: "healthy" (green), "starting up" (yellow spinner), "unhealthy" (orange pulse) +- Restart button added next to Stop on running containers + +#### Crash Recovery Improvements +- Boot recovery and health monitor now coordinate via shared flag (no more restart cascade) +- User-stopped containers tracked in `user-stopped.json` — survive reboots without auto-restart +- Boot recovery uses tiered ordering: databases → core → services → apps → UIs +- Health monitor waits for boot recovery to complete before starting checks + +### UI Improvements + +#### Home Dashboard +- Wallet card now matches Web5 wallet display +- New Transactions modal with full history (incoming/outgoing, amounts, confirmations) +- Transactions button in header — switches to "Incoming" badge when pending transactions exist +- Dev faucet button (dev mode only) with mutable wallet state +- Fixed system stats crash (`cpu_usage_percent` field name mismatch) + +#### Apps & App Details +- Container restart button (icon) next to Stop on all running apps +- Exited/crashed containers show "Restart" instead of "Start" with red styling +- Removed broken sticky header from Apps page +- Health-aware status badges throughout + +#### Mesh, Cloud, Settings & More +- Mesh view overhaul with improved layout +- Glass button styling updates across components +- New BaseModal and ToggleSwitch components +- Updated translations (English + Spanish) +- Spotlight search improvements + +### Infrastructure + +#### LND Connect +- Tor hidden service now exposes LND REST port (8080) for remote wallet connections +- Fixed in ISO build script, deploy script, and live servers + +#### Dev Environment +- Mock backend has mutable wallet state (faucet/send/receive actually change balances) +- Testnet stack option auto-starts Podman machine on macOS +- Boot mode simulation for testing startup screens + +## [1.2.0] - 2026-03-14 + +### Fixed + +#### Crash Loop Resolution +- Identified and fixed UFW blocking Podman subnet DNS resolution on .228 +- Fixed archy-nbxplorer, btcpay-server, mempool-web, immich crash loops (3500+ restarts) +- All 32 containers stable with zero crash loops after fix + +#### DWN Sync Performance +- Made `dwn.sync` endpoint non-blocking (background task with polling) +- Added 90-second overall sync timeout to prevent indefinite blocking +- Deduplicated peer onion addresses before syncing +- Batched message pushes (50/batch) instead of one-at-a-time over Tor +- Fixed HTTP handler to process all messages in batch (was only first) + +#### Backup Reliability +- Increased backup.create rate limit from 3/600 to 10/600 for testing +- Increased backup.restore rate limit from 2/600 to 5/600 + +#### Deploy Script +- Added `set -eo pipefail` for pipe error detection +- Fixed duplicate variable initialization +- Fail on missing binary in --both path (was silently ignored) +- Added post-deploy health check on .198 + +### Added + +#### Cross-Node Test Suite +- US-08: DWN sync tests — 50/50 pass (register, write, sync, query bidirectional) +- US-10: Backup/restore tests — 80/80 pass (create, list, verify, delete × 10 × 2 nodes) +- US-15: Boot recovery tests — .228 9/9 pass (32/32 containers survive 3 reboots) +- `trigger_sync_and_wait()` helper for polling async DWN sync + +#### did:dht Integration Planning +- Architecture document: `docs/did-dht-integration.md` +- BEP-44 mutable DHT items, DNS packet encoding, z-base-32 identifiers +- Publication/resolution flows, `mainline` crate selection, security notes + +#### DWN Protocol Definitions +- 4 Archipelago DWN protocols documented in `docs/dwn-protocols.md` +- Node Identity Announcements (public) +- File Sharing Catalog (public) +- Federation State (private) +- App Deployment Requests (private) +- Auto-registration of all 4 protocols on backend startup + +#### Deploy Script Improvements +- `--dry-run` flag shows what would be deployed without executing +- Works with all other flags (--live, --both, --frontend-only) + +#### ISO/First-Boot Improvements +- Auto-create swap file on first boot (50% RAM, min 2GB, max 8GB) +- Tiered container startup ordering in first-boot script +- Tier 1: Databases, Tier 2: Core Services (5s delay), Tier 3: Applications (5s delay) + +### Security + +#### Backend Hardening +- Rate limiting on federation endpoints (join 5/60s, invite 10/300s) +- DWN message data size limit (10MB max) +- Container security: cap-drop ALL, no-new-privileges, per-app memory limits +- Input validation: path traversal protection on identity/DID endpoints +- Error sanitization: internal paths stripped from error messages + +## [1.1.0] - 2026-03-13 + +### Added + +#### Nostr Identity in Onboarding +- Auto-generate secp256k1 Nostr keypair during identity creation +- Onboarding shows both DID (`did:key:z...`) and Nostr ID (`npub1...`) with copy buttons +- Real Ed25519 signature verification in onboarding verify step +- Real encrypted backup creation in onboarding backup step + +#### NIP-07 Iframe Signing +- `nostr-provider.js` injected into all proxied iframe apps via nginx `sub_filter` +- `window.nostr` interface: `getPublicKey()`, `signEvent()`, `getRelays()` +- Signing consent modal with "Remember for this app" option +- `node.nostr-sign` RPC endpoint — signs events with node-level Nostr key +- NIP-04 and NIP-44 encrypt/decrypt RPC endpoints for iframe apps +- noStrudel Nostr client added to marketplace as iframe app + +#### File Sharing Across Nodes +- Content catalog with add/remove/browse over Tor +- Three access modes: `free`, `peers_only` (DID-authenticated), `paid` (cashu tokens) +- Availability controls: `AllPeers`, `Nobody`, `Specific` (DID allowlist) +- Peer Files view in Cloud page for browsing federated peers' shared content +- Content download from peers via Tor SOCKS proxy + +#### DWN Multi-Node Sync +- Bidirectional DWN message replication over Tor between federated nodes +- Protocol and message sync via `/dwn` HTTP endpoint +- DWN sync status in Federation dashboard with "Sync Now" button +- DWN management section in Web5 page (protocols, messages, sync targets) + +#### Node Visualization Map +- D3.js force-directed network topology graph +- Nodes colored by trust level (green/amber/red), opacity by online status +- Self node centered, draggable peer nodes with tooltips +- List/Map tab switcher in Federation page with localStorage persistence + +#### Tor Address Rotation +- `tor.rotate-service` RPC: generates new .onion address with 24h transition +- Automatic propagation to Nostr relays and federation peers +- `tor.cleanup-rotated` for expired transition directories +- Per-app Tor toggle (`tor.toggle-app`) to enable/disable Tor per service +- Tor management UI in Settings with rotate button and per-app toggles + +#### Boot Container Recovery +- All stopped containers automatically started on backend boot +- Fixes clean reboot scenario where PID marker was removed by systemd + +#### Monitoring & Testing +- Federation health check script (cron every 5min, CSV + JSON output) +- Uptime monitor with authenticated RPC access +- `test-first-install.sh` — 8-check post-install verification +- `test-nip07.sh` — 11-check NIP-07 signing validation +- `test-tor-rotation.sh` — 10-check Tor rotation lifecycle +- `test-integration-full.sh` — 23-check full integration test +- `test-failure-recovery.sh` — 5-scenario failure injection + recovery + +### Fixed +- Health monitor webhook gate no longer blocks auto-restart and notifications +- Monitoring alerts now trigger webhook delivery (DiskWarning, ContainerCrash) +- Tor hostname reading with `tor-hostnames` readable cache (0700 system Tor dirs) +- Tor rotation clears hostname cache before reading new address +- Rotation restarts system Tor (not just archy-tor container) +- NIP-07 signing uses node-level key (matches `getPublicKey()`) +- DWN sync URL uses port 80 (nginx/Tor) instead of 5678 +- DWN `/dwn` POST endpoint allows unauthenticated peer sync +- DWN message handler supports both single and batch message formats + +## [0.8.0-rc1] - 2026-03-11 + +### Added + +#### W3C Identity & Credentials +- W3C DID Core v1.0 compliant DID Document generation (`did:key` method) +- DID Document verification and cross-node resolution over Tor +- JSON-LD Verifiable Credentials (VC Data Model 2.0, Ed25519Signature2020 proofs) +- Verifiable Presentation creation with selective disclosure +- Credentials management UI at `/dashboard/web5/credentials` + +#### Decentralized Web Node (DWN) +- DWN message store with CRUD, protocol registration, and query interface +- DWN HTTP API (`POST /dwn`, `GET /dwn/health`) +- Bidirectional peer sync over Tor via SOCKS proxy +- DWN management UI in Web5 page with protocol browser + +#### Multi-Node Federation +- Node federation protocol with invite codes (`fed1:` prefix), trust levels, state sync +- Federation dashboard at `/dashboard/server/federation` +- Federated app deployment to trusted peers over Tor +- Architecture documented in `docs/multi-node-architecture.md` + +#### Decentralized Marketplace +- NIP-78 Nostr-based app manifest discovery across relays +- Trust scoring (0-100) based on DID verification, relay consensus, federation trust +- App manifest publishing with Nostr secp256k1 signing +- Community marketplace tab in App Store with trust score badges + +#### Networking +- VPN integration (Tailscale + WireGuard) with keypair generation and status display +- Mesh networking via Meshtastic LoRa devices with node discovery +- DNS-over-HTTPS configuration (Cloudflare, Google, Quad9, Mullvad, Custom) +- WiFi/Ethernet configuration via `nmcli` with scan-and-connect modal +- Network interfaces display in Server page + +#### Hardware Wallet Support +- PSBT signing flow (create, QR display, finalize, broadcast) +- USB hardware wallet detection (ColdCard, Trezor, Ledger) +- Hardware wallet signing UI in LND views + +#### System Management +- System monitoring (CPU, RAM, disk gauges on Dashboard) +- Automatic update system with download, apply, rollback, and scheduling +- Disk space management with auto-cleanup at 90% usage +- Container health monitoring with auto-recovery (max 3 restart attempts) +- Crash recovery via PID-file detection and container snapshot restoration +- Graceful shutdown with in-flight request draining (5s timeout) + +#### Backup & Restore +- Full backup with tar.gz + ChaCha20-Poly1305 encryption +- Backup create, list, verify, restore, delete via RPC +- USB drive detection and backup-to-USB +- Backup UI in Settings page + +#### Kiosk Mode +- Chromium kiosk with auto-restart and watchdog service +- Recovery page at `/recovery` (no auth required) +- Kiosk keyboard shortcuts (Ctrl+Shift+R/H/Q) +- Systemd services for kiosk and watchdog + +#### ARM64 Support +- Cross-compilation for aarch64 with rustls-tls +- All 6 core apps verified with multi-arch images +- Parameterized ISO build script (`ARCH=arm64`) +- RPi 5 testing guide + +#### Testing +- 236 frontend tests across 17 test files (Vitest) +- 124+ backend tests (cargo test) +- Playwright visual regression suite (12 pages) +- Chaos testing (SIGKILL recovery, concurrent RPC, rapid restarts) +- App lifecycle testing and dependency chain verification +- 1-week continuous uptime monitoring + +#### Documentation +- Developer guide, API reference (100+ endpoints), app developer SDK guide +- 5 Architecture Decision Records (Podman, DID:key, Nostr, Tor, ChaCha20) +- Release process, canary deploy, quality baseline documentation + +### Changed +- Settings sections use `glass-card` instead of `path-option-card` +- Web3 card shows "Coming Soon" badges instead of fake data +- Network diagnostics moved from Settings to Server page +- Removed `core/startos/` (2MB of dead code, zero dependencies) + +### Fixed +- CSRF protection on all state-changing RPC calls +- CORS restricted to same-origin (removed `Access-Control-Allow-Origin: *`) +- Nginx security headers (X-Frame-Options, CSP, X-Content-Type-Options) +- All 24 silent catch blocks now log in dev mode +- Zero `console.log` outside dev gate, zero `any` types + +### Security +- CSRF token validation on all state-changing endpoints +- Same-origin CORS policy +- Nginx security headers (SAMEORIGIN, nosniff, CSP, Referrer-Policy) +- Container security hardened (readonly root, dropped caps, non-root, no-new-privileges) +- Secrets rotation with AES-256-GCM and automatic scheduling + +## [0.5.0-beta] - 2026-03-11 + +### Added + +#### Security Hardening +- Session inactivity expiry (24h), max 5 concurrent sessions with oldest eviction +- Session rotation on password change (invalidates all other sessions) +- Container security: `--cap-drop=ALL`, `--security-opt=no-new-privileges:true`, read-only root +- Secrets rotation with AES-256-GCM encryption and metadata tracking +- Path traversal prevention (nginx regex blocks + client-side sanitizePath) +- Cookie-based auth for File Browser (removed token from URLs) +- Login rate limiting (5 failures per 60s per IP) +- TOTP two-factor authentication with backup codes + +#### Performance +- Backend startup: ~100ms +- Frontend bundle: ~105 KB gzipped initial load +- WebSocket heartbeat (30s ping/pong) with exponential backoff reconnection +- Server-side 5-minute inactivity timeout for stale WebSocket connections +- Real-time install progress reporting via WebSocket during container pulls +- Connection state machine (connecting/connected/disconnecting/disconnected) + +#### Apps & Integrations +- Pinned all container images to specific versions (no `:latest` tags) +- Fedimint and Fedimint Gateway with auto-LND detection +- IndeedHub virtual app integration +- Expanded read-only root filesystem support (electrs, nostr-relay, ollama) +- Dependency chain validation (Bitcoin → Electrs → Mempool, Bitcoin → LND) + +#### Documentation +- Comprehensive user guide (docs/user-guide.md) +- Beta release checklist (docs/BETA-RELEASE-CHECKLIST.md) +- 72-hour stability test script + +### Fixed +- Penpot hardcoded secret key replaced with SHA256-derived key +- WebSocket reconnection reliability after network interruption + +## [0.1.0] - 2026-01-28 + +### 🎉 Initial Release + +The first production release of Archipelago - a next-generation Bitcoin Node OS for macOS. + +### Added + +#### Core Features +- **Native Rust Backend** - High-performance async server using Tokio and Hyper +- **Modern Vue.js Frontend** - Beautiful glassmorphism UI with Tailwind CSS +- **Docker Integration** - Seamless container orchestration via Docker Desktop +- **Real-time WebSocket** - Live updates for container status and system events +- **Authentication System** - Secure user login and session management + +#### Bitcoin & Lightning +- **Bitcoin Core** - Full node in regtest mode with custom UI +- **LND** - Lightning Network Daemon with dedicated interface +- **BTCPay Server** - Bitcoin payment processing +- **Mempool Explorer** - Blockchain visualization and analytics + +#### Applications +- **Penpot** - Open-source design and prototyping platform +- **Endurain** - Self-hosted fitness tracking +- **Morphos** - File conversion utility +- **Nextcloud** - Cloud storage and file management +- **Home Assistant** - Home automation hub +- **Grafana** - Metrics and monitoring dashboards +- **OnlyOffice** - Document editing suite +- **SearXNG** - Privacy-respecting search engine +- **Fedimint** - Federated e-cash system + +#### User Interface +- **Onboarding Flow** - Guided setup for new users +- **Dashboard** - Real-time system overview +- **My Apps** - Alphabetically sorted app management +- **Cloud Interface** - File management by type (Documents, Photos, Videos, Music) +- **Web5 Explorer** - Decentralized identity and data management +- **Settings** - System configuration and preferences +- **Custom Launch Pages** - Dedicated UIs for Bitcoin Core and LND + +#### Technical Features +- **Container Runtime Abstraction** - Support for Docker and Podman +- **Dynamic Package Discovery** - Automatic detection of running containers +- **Health Monitoring** - Container status and health checks +- **Data Persistence** - Docker volumes for app data +- **Network Isolation** - Secure container networking +- **Resource Management** - CPU and memory allocation + +### Architecture + +- **Backend**: Rust + Tokio + Hyper + WebSocket +- **Frontend**: Vue 3 + TypeScript + Vite + Pinia +- **Styling**: Tailwind CSS + Custom Glassmorphism +- **Containers**: Docker Compose + Dockerode API +- **Build System**: Cargo + npm + macOS App Bundle + +### Known Limitations + +- Requires Docker Desktop (23.0+) +- macOS only (Intel and Apple Silicon) +- Single-user mode +- No auto-updates (manual download required) +- Ollama excluded due to image size +- Manual Docker container management + +### System Requirements + +- macOS 10.15 (Catalina) or later +- 8GB RAM minimum (16GB recommended) +- 20GB free disk space (50GB+ for blockchain data) +- Docker Desktop 23.0 or later +- Internet connection for initial container downloads + +### Installation + +1. Download `Archipelago-0.1.0-macOS.dmg` +2. Open the DMG and drag Archipelago to Applications +3. Install Docker Desktop if not already installed +4. Launch Archipelago from Applications +5. Access the UI at http://localhost:8100 + +### Security + +- **Code Signed**: Yes (Developer ID) +- **Notarized**: Yes (Apple notarization) +- **Sandboxed**: No (requires full disk access for Docker) +- **Hardened Runtime**: Yes +- **Gatekeeper**: Compatible + +### Documentation + +- README.md - Project overview +- BUILD_MACOS.md - Build instructions +- DEPLOYMENT_CHECKLIST.md - Release process +- docs/ - Detailed documentation + +### Credits + +Built with: +- Rust (backend) +- Vue.js (frontend) +- Docker (containers) +- Alpine Linux (inspiration) +- Parmanode (Bitcoin scripts) +- And many open-source dependencies + +### License + +[Specify your license here] + +--- + +## Version History + +### 0.1.0 - 2026-01-28 +Initial public release + +--- + +## Future Roadmap + +See GitHub Issues for planned features: +- [ ] Auto-update system +- [ ] Multi-user support +- [ ] Native container runtime (no Docker Desktop) +- [ ] iOS companion app +- [ ] Hardware wallet integration +- [ ] Tor integration +- [ ] VPN/Tailscale support +- [ ] Backup/restore functionality +- [ ] Mac App Store distribution +- [ ] Windows and Linux builds + +## Contributing + +See CONTRIBUTING.md for development setup and guidelines. + +## Support + +- GitHub Issues: Report bugs and request features +- Documentation: See `/docs` directory +- Community: [Discord/Telegram/Forum link] diff --git a/CLAUDE.md b/CLAUDE.md new file mode 100644 index 00000000..4f3d91ed --- /dev/null +++ b/CLAUDE.md @@ -0,0 +1,84 @@ +# Archipelago — agent guide + +## ✅ Single-node production gate is GREEN (2026-06-23) + +`tests/lifecycle/run-gate.sh` is **5/5 on .228, 0 failures** — the single-node exit +criterion is met and the priority banner is demoted. Next exit-criteria: the +**multinode pass** (`docs/multinode-testing-plan.md`) and workstreams B/C/D. + +**For day-to-day work, use `docs/UNIFIED-TASK-TRACKER.md`** — the consolidated, +priority-ordered "what's left" list across the 1.8.0 OTA and master-plan docs +(fastest/simplest tasks first). It supersedes hunting through the two source docs +below for open items; those remain the narrative/history. + +**Read `docs/PRODUCTION-MASTER-PLAN.md` first** — it is still the authoritative plan +for the north star: a world-class, **developer-ready app platform** where every app +is manifest-driven, manifests ship via the **signed registry** (not OTA disk files), +and **third-party developers publish apps via an external/decentralized registry** — +all rootless, secure, robust, and 100%-uptime-capable. It no longer overrides all +ad-hoc direction now that the gate is green, but it remains the source of truth for +sequencing the remaining workstreams. + +Detailed sub-plans (all linked from the master): +- App platform / packaging phases + security model → `docs/APP-PACKAGING-MIGRATION-PLAN.md` +- Registry-distributed manifests (in progress) → `docs/registry-manifest-design.md` +- External/decentralized marketplace for devs → `docs/marketplace-protocol.md` +- Current per-app state → `docs/archive/app-registry-status-2026-06-21.md` +- Production test gate (exit criterion) → `tests/lifecycle/TESTING.md` + +## Commit & push every unit of work (never violate) + +**The #1 process rule: work is not "done" until it is committed AND pushed.** This +exists because finished work has been lost/clobbered by sitting uncommitted in the +shared tree across agents and sessions. To prevent that: + +- **Commit each feature/fix the moment it works** — one focused, self-contained + commit per logical change (it compiles and its targeted tests pass). Do not let + unrelated changes accumulate uncommitted. +- **Push immediately after committing** so nothing lives only on one machine. `main` + is protected → push via `git push gitea-ai main` (account `ai`, see the memory + note); feature branches push to their own remote. +- **Never leave a stack of finished work uncommitted** overnight or when handing off + between agents — if you must pause mid-change, commit a clearly-labelled WIP + checkpoint rather than leaving it dirty. +- **Stage explicitly by path** (`git add `) when another agent's uncommitted + work shares the tree — never `git add -A` / `git commit -a`, which clobbers or + entangles their changes. +- **Never commit or push secrets** (mnemonics, private keys, API tokens). Signing is + done offline; artifacts (catalog/manifest) are signed, not the keys. +- Commit messages end with the `Co-Authored-By: Claude …` trailer. + +## Invariants (never violate) + +- **Rootless Podman only.** No rootful, no Docker-socket mounts, no privileged + containers unless explicitly approved. +- **No per-app Rust installers / no OS-level reliance.** Apps are declarative; + the orchestrator owns the lifecycle. `install_immich_stack` (hardcoded + `podman run` + `sudo chown`) is the anti-pattern being deleted, not a template. +- **Secrets are manifest-declared** (`generated_secrets`, materialised by + `container::secrets`, 0600/rootless) — never hardcoded, per-app, or logged. +- **Migrations never destroy data** — preserve `/var/lib/archipelago/`, + secrets, credentials, ports, and adoption container names; keep a rollback path. +- **Verify on the real node .228 before any tag.** (Fleet-wide multinode + verification is a separate plan: `docs/multinode-testing-plan.md`.) + +## Build / verify + +- Rust workspace root is `core/` (no Cargo.toml at repo root). `cargo` from `core/`. +- If a `cargo test`/build hits `rust-lld: undefined hidden symbol`, it's + incremental-cache corruption — rebuild with `CARGO_INCREMENTAL=0`. +- Frontend: `neode-ui/` → `npm run build` outputs to `web/dist/neode-ui/`. + Grep the built bundle for new strings before shipping (build can silently no-op). +- App manifests load from disk on nodes at `/opt/archipelago/apps/*/manifest.yml` + (today); the goal is to distribute them via the signed catalog instead. + +## Production test gate (definition of done) + +`tests/lifecycle/run-gate.sh` green across install / UI / stop / start / restart / +reinstall / reboot-survive / archipelago-restart-survive / uninstall — **5× on +.228** (`ARCHY_ITERATIONS=5`). **Run the gate ON the node** (it uses local podman/systemctl/bitcoin +probes), not via RPC from another host. **✅ GREEN 2026-06-23 (5/5, 0 not-ok)** — keep it +green (re-run after orchestrator/lifecycle changes); regressions are top priority again. +**Multinode testing (.198 + the rest of the fleet) is a SEPARATE plan** — +`docs/multinode-testing-plan.md` — not part of this single-node gate criterion, and is +the next exit criterion now that single-node is green. diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md new file mode 100644 index 00000000..b255972c --- /dev/null +++ b/CONTRIBUTING.md @@ -0,0 +1,161 @@ +# Contributing to Archipelago + +Thank you for your interest in contributing to Archipelago! This document covers the process for contributing code, reporting bugs, and submitting apps. + +## Code of Conduct + +Be respectful. We follow the [Contributor Covenant](https://www.contributor-covenant.org/version/2/1/code_of_conduct/). + +## Getting Started + +1. Fork the repository on the project's Gitea instance +2. Clone your fork: `git clone /archy.git` +3. Set up the dev environment (see `docs/developer-guide.md`) +4. Create a feature branch: `git checkout -b feature/your-feature` + +## Development Setup + +### Frontend (Vue.js) + +```bash +cd neode-ui +npm install +npm start # Dev server on :8100 +npm run type-check # TypeScript validation +npm run build # Production build +npm test # Run tests +``` + +### Backend (Rust) + +Build on a Linux server (Debian 13), **not** macOS: + +```bash +cargo clippy --all-targets --all-features +cargo fmt --all +cargo test --all-features +``` + +### Deploy to dev server + +```bash +./scripts/deploy-to-target.sh --live +``` + +## Code Style + +### Frontend (TypeScript + Vue) + +- `\" />;" \ + " }" \ + "}" > src/app/page.tsx + +RUN npm run build + +# ── Stage 3: Runner ── +FROM node:20-alpine AS runner +WORKDIR /app + +ENV NODE_ENV=production +ENV NEXT_TELEMETRY_DISABLED=1 +ENV PORT=3000 +ENV HOSTNAME=0.0.0.0 + +RUN addgroup --system --gid 1001 nodejs && \ + adduser --system --uid 1001 nextjs + +# Copy standalone build output +COPY --from=builder /app/public ./public +COPY --from=builder --chown=nextjs:nodejs /app/.next/standalone ./ +COPY --from=builder --chown=nextjs:nodejs /app/.next/static ./.next/static + +USER nextjs +EXPOSE 3000 + +HEALTHCHECK --interval=30s --timeout=10s --retries=3 --start-period=40s \ + CMD wget --no-verbose --tries=1 --spider http://localhost:3000/ || exit 1 + +CMD ["node", "server.js"] diff --git a/apps/indeedhub/README.md b/apps/indeedhub/README.md new file mode 100644 index 00000000..62c8a2de --- /dev/null +++ b/apps/indeedhub/README.md @@ -0,0 +1,53 @@ +# Indeehub — Bitcoin Documentary Streaming + +Bitcoin documentary streaming platform featuring God Bless Bitcoin and other educational content about Bitcoin, sovereignty, and decentralized technology. + +Self-hosted Next.js app with Nostr identity sign-in via Archipelago's NIP-07 provider. + +## Building the Image + +The app image is built from the **indeehub-frontend** project at `~/Projects/indeehub-frontend`. + +### Option 1: Use the build script + +```bash +# From archy repo root +./apps/indeedhub/build-from-prototype.sh +``` + +### Option 2: Build from source directory + +```bash +cd ~/Projects/indeehub-frontend +podman build -t localhost/indeedhub:latest -f ~/Projects/archy/apps/indeedhub/Dockerfile . +``` + +## Installing from App Store + +1. **Build the image** using one of the options above (must exist before install) +2. Go to **Dashboard -> App Store** (Marketplace) +3. Find **Indeehub** and click **Install** +4. On first launch, pick a Nostr identity to sign in with +5. The app appears in **My Apps** once the container is running + +## Port + +- Web UI: 8190 (maps to container port 3000) + +## Container + +- Image: `localhost/indeedhub:latest` (built locally, not pulled from a registry) +- Runtime: Node.js 20 (Next.js standalone) +- Port: 8190 -> 3000 +- Read-only root filesystem with tmpfs for /tmp and .next/cache + +## Nostr Identity + +On first launch, Archipelago shows a cypherpunk identity picker modal. Select which of your identities to use for NIP-07 signing. The NIP-07 provider is injected automatically via nginx proxy. + +## Services + +The app connects to the following external services (configured at build time): +- Indeehub API (content, auth, streaming) +- AWS S3 (media storage via CloudFront CDN) +- Nostr relays (via NIP-07 provider from Archipelago) diff --git a/apps/indeedhub/build-from-prototype.sh b/apps/indeedhub/build-from-prototype.sh new file mode 100755 index 00000000..50f5bdb7 --- /dev/null +++ b/apps/indeedhub/build-from-prototype.sh @@ -0,0 +1,35 @@ +#!/bin/bash +# Build Indeehub container image from the indeehub-frontend project +# Usage: ./build-from-prototype.sh [path-to-indeehub-frontend] + +set -e + +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +DEFAULT_FRONTEND="$HOME/Projects/indeehub-frontend" +FRONTEND_DIR="${1:-$DEFAULT_FRONTEND}" +IMAGE_TAG="localhost/indeedhub:latest" + +if [ ! -d "$FRONTEND_DIR" ]; then + echo "Indeehub frontend not found at: $FRONTEND_DIR" + echo " Set path: $0 /path/to/indeehub-frontend" + exit 1 +fi + +if [ ! -f "$FRONTEND_DIR/package.json" ]; then + echo "No package.json found in $FRONTEND_DIR — is this the right directory?" + exit 1 +fi + +# Determine container runtime +RUNTIME="podman" +if ! command -v podman >/dev/null 2>&1; then + RUNTIME="docker" +fi + +echo "Building Indeehub from $FRONTEND_DIR using $SCRIPT_DIR/Dockerfile" +$RUNTIME build -t "$IMAGE_TAG" -f "$SCRIPT_DIR/Dockerfile" "$FRONTEND_DIR" + +echo "Built $IMAGE_TAG" +echo "" +echo "You can now install Indeehub from the App Store in Archipelago." +echo "Or run directly: $RUNTIME run -d --name indeedhub -p 8190:3000 $IMAGE_TAG" diff --git a/apps/indeedhub/manifest.yml b/apps/indeedhub/manifest.yml new file mode 100644 index 00000000..471678c4 --- /dev/null +++ b/apps/indeedhub/manifest.yml @@ -0,0 +1,104 @@ +app: + id: indeedhub + name: IndeeHub + version: "1.0.0" + description: Bitcoin documentary streaming platform featuring God Bless Bitcoin and other educational content about Bitcoin, sovereignty, and decentralized technology. Sign in with your Nostr identity. + category: community + + # The user-facing launcher (app_id "indeedhub"). Container is named "indeedhub" + # (matches the runtime's per-app references + the live container, so the + # orchestrator adopts it). Its nginx (listen 7777) proxies to the backends by + # their short aliases on indeedhub-net: api:4000, minio:9000, relay:8080. + container_name: indeedhub + + container: + image: 146.59.87.168:3000/lfg2025/indeedhub:1.0.0 + pull_policy: if-not-present + network: indeedhub-net + + dependencies: + - app_id: indeedhub-api + - storage: 1Gi + + resources: + memory_limit: 512Mi + disk_limit: 1Gi + + security: + # nginx master runs as root and drops workers to the nginx user (uid/gid + # 101) — needs SET{UID,GID}; CHOWN + DAC_OVERRIDE let it own + write the + # proxy cache under the tmpfs /var/cache/nginx. The orchestrator does + # --cap-drop=ALL, so (unlike the legacy `podman run` default caps) these + # must be declared or nginx workers die with "setgid(101) failed". + capabilities: [CHOWN, DAC_OVERRIDE, SETGID, SETUID] + readonly_root: false + network_policy: isolated + + ports: + - host: 7778 + container: 7777 + protocol: tcp # Web UI. Port 7777 on the host is reserved for the Nostr relay. + + # Writable scratch the baked nginx needs; matches the legacy installer's + # --tmpfs /run + /var/cache/nginx. + volumes: + - type: tmpfs + target: /run + options: [rw, nosuid, nodev, size=16m] + - type: tmpfs + target: /var/cache/nginx + options: [rw, nosuid, nodev, size=32m] + + environment: [] + + # Defensive + idempotent. The current indeedhub:1.0.0 image already bakes the + # iframe-friendly nginx (X-Frame-Options omitted, nostr-provider.js present + + # #' /etc/nginx/conf.d/default.conf"] + - exec: ["nginx", "-s", "reload"] + + # TCP liveness on the nginx port, NOT an http GET of /. nginx binds 7777 at + # startup (before workers), so this passes immediately and stays green under + # load. An http check of / runs the SPA + sub_filter and false-fails when the + # node is busy → the reconciler then treats the frontend as wedged and + # recreates it in a loop (observed churning the frontend on the loaded .198). + health_check: + type: tcp + endpoint: localhost:7777 + interval: 30s + timeout: 5s + retries: 5 + start_period: 30s + + interfaces: + main: + name: Web UI + description: Stream Bitcoin documentaries with Nostr identity + type: ui + port: 7778 + protocol: http + path: / + + metadata: + author: Indeehub Team + icon: /assets/img/app-icons/indeedhub.png + website: https://indeedhub.com + repo: https://github.com/indeedhub/indeedhub + license: MIT + tags: + - bitcoin + - documentary + - streaming + - media + - education + - nostr diff --git a/apps/indeedhub/push-to-registry.sh b/apps/indeedhub/push-to-registry.sh new file mode 100755 index 00000000..5818bc0f --- /dev/null +++ b/apps/indeedhub/push-to-registry.sh @@ -0,0 +1,70 @@ +#!/bin/bash +# Build and push Indeehub container image to a registry +# Usage: ./push-to-registry.sh [version] +# +# Environment variables: +# REGISTRY - Registry host (default: ghcr.io) +# NAMESPACE - Registry namespace (default: archipelago-os) +# RUNTIME - Container runtime (default: podman) + +set -e + +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +FRONTEND_DIR="${INDEEHUB_FRONTEND:-$HOME/Projects/indeehub-frontend}" +VERSION="${1:-latest}" +REGISTRY="${REGISTRY:-146.59.87.168:3000}" +NAMESPACE="${NAMESPACE:-lfg2025}" +IMAGE_NAME="indeedhub" +RUNTIME="${RUNTIME:-podman}" + +FULL_TAG="${REGISTRY}/${NAMESPACE}/${IMAGE_NAME}:${VERSION}" +LATEST_TAG="${REGISTRY}/${NAMESPACE}/${IMAGE_NAME}:latest" + +if [ ! -d "$FRONTEND_DIR" ]; then + echo "Indeehub frontend not found at: $FRONTEND_DIR" + echo "Set INDEEHUB_FRONTEND=/path/to/indeehub-frontend" + exit 1 +fi + +echo "=== Indeehub Container Registry Push ===" +echo "Source: $FRONTEND_DIR" +echo "Image: $FULL_TAG" +echo "Runtime: $RUNTIME" +echo "" + +# Step 1: Build for linux/amd64 (target architecture) +echo "[1/3] Building image..." +$RUNTIME build --platform linux/amd64 \ + -t "$FULL_TAG" \ + -t "$LATEST_TAG" \ + -t "localhost/${IMAGE_NAME}:latest" \ + -t "localhost/${IMAGE_NAME}:${VERSION}" \ + -f "$SCRIPT_DIR/Dockerfile" \ + "$FRONTEND_DIR" + +echo "[2/3] Pushing to registry..." +# Login check +if ! $RUNTIME login --get-login "$REGISTRY" >/dev/null 2>&1; then + echo "" + echo "Not logged in to $REGISTRY." + echo "Run: $RUNTIME login $REGISTRY" + exit 1 +fi + +$RUNTIME push "$FULL_TAG" +if [ "$VERSION" != "latest" ]; then + $RUNTIME push "$LATEST_TAG" +fi + +echo "" +echo "[3/3] Done!" +echo "" +echo "Image pushed: $FULL_TAG" +if [ "$VERSION" != "latest" ]; then + echo "Also tagged: $LATEST_TAG" +fi +echo "" +echo "Federated nodes can now install via:" +echo " podman pull $FULL_TAG" +echo "" +echo "Update marketplace dockerImage to: $FULL_TAG" diff --git a/apps/jellyfin/manifest.yml b/apps/jellyfin/manifest.yml new file mode 100644 index 00000000..7234c1c8 --- /dev/null +++ b/apps/jellyfin/manifest.yml @@ -0,0 +1,61 @@ +app: + id: jellyfin + name: Jellyfin + version: 10.8.13 + description: Free media server. Stream movies, music, and photos. + + container: + image: 146.59.87.168:3000/lfg2025/jellyfin:10.8.13 + pull_policy: if-not-present + network: pasta + + dependencies: + - storage: 10Gi + + resources: + memory_limit: 1Gi + disk_limit: 10Gi + + security: + capabilities: [CHOWN, FOWNER, SETUID, SETGID, DAC_OVERRIDE] + readonly_root: false + network_policy: isolated + + ports: + - host: 8096 + container: 8096 + protocol: tcp + + volumes: + - type: bind + source: /var/lib/archipelago/jellyfin/config + target: /config + options: [rw] + - type: bind + source: /var/lib/archipelago/jellyfin/cache + target: /cache + options: [rw] + + environment: [] + + health_check: + type: tcp + endpoint: localhost:8096 + interval: 30s + timeout: 5s + retries: 3 + + interfaces: + main: + name: Web UI + description: Jellyfin media dashboard + type: ui + port: 8096 + protocol: http + path: / + + metadata: + icon: /assets/img/app-icons/jellyfin.webp + category: data + author: Jellyfin + repo: https://github.com/jellyfin/jellyfin diff --git a/apps/lightning-stack/Dockerfile b/apps/lightning-stack/Dockerfile new file mode 100644 index 00000000..83c5d177 --- /dev/null +++ b/apps/lightning-stack/Dockerfile @@ -0,0 +1,5 @@ +# Lightning Stack - uses official image +FROM lightninglabs/lightning-stack:v0.12.0 + +# Default configuration is in the image +# No additional setup needed diff --git a/apps/lightning-stack/manifest.yml b/apps/lightning-stack/manifest.yml new file mode 100644 index 00000000..4c5e4431 --- /dev/null +++ b/apps/lightning-stack/manifest.yml @@ -0,0 +1,68 @@ +app: + id: lightning-stack + name: Lightning Stack + version: 0.12.0 + description: Complete Lightning Network implementation. Includes LND, CLN, and management tools. + + container: + image: lightninglabs/lightning-stack:v0.12.0 + image_signature: cosign://... + pull_policy: if-not-present + + dependencies: + - app_id: bitcoin-core + version: ">=24.0" + - storage: 50Gi + + resources: + cpu_limit: 4 + memory_limit: 4Gi + disk_limit: 50Gi + + security: + capabilities: [NET_BIND_SERVICE] + readonly_root: true + no_new_privileges: true + user: 1000 + seccomp_profile: default + network_policy: isolated + apparmor_profile: lightning-stack + + ports: + - host: 9738 + container: 9735 + protocol: tcp # P2P + - host: 10010 + container: 10009 + protocol: tcp # gRPC + - host: 8091 + container: 8080 + protocol: tcp # REST/Web UI + + volumes: + - type: bind + source: /var/lib/archipelago/lightning-stack + target: /root/.lightning + options: [rw] + + environment: + - BITCOIND_HOST=bitcoin-core + - BITCOIND_RPCUSER=${BITCOIN_RPC_USER} + - BITCOIND_RPCPASS=${BITCOIN_RPC_PASSWORD} + - NETWORK=mainnet + + health_check: + type: http + endpoint: http://127.0.0.1:8080 + path: /v1/getinfo + interval: 30s + timeout: 5s + retries: 3 + + bitcoin_integration: + rpc_access: admin + sync_required: true + + lightning_integration: + channel_management: true + payment_routing: true diff --git a/apps/lnd-ui/manifest.yml b/apps/lnd-ui/manifest.yml new file mode 100644 index 00000000..a33c1c80 --- /dev/null +++ b/apps/lnd-ui/manifest.yml @@ -0,0 +1,44 @@ +app: + id: lnd-ui + name: LND UI + version: 1.0.0 + description: | + Archipelago-native HTTP frontend for LND. Runs nginx inside a + container and serves static assets. LND connection info is fetched + via an absolute URL that the host nginx routes to the archipelago + backend on 127.0.0.1:5678, so no upstream auth is baked in. + + container: + build: + context: /opt/archipelago/docker/lnd-ui + dockerfile: Dockerfile + tag: localhost/lnd-ui:local + + dependencies: + - app_id: lnd + + resources: + memory_limit: 64Mi + + security: + readonly_root: false + network_policy: bridge + + # Bridge networking via archy-net. Container nginx listens on 80; + # host nginx proxies /app/lnd/ -> 127.0.0.1:18083 -> container:80. + ports: + - host: 18083 + container: 80 + protocol: tcp + + volumes: [] + + environment: [] + + health_check: + type: http + endpoint: http://127.0.0.1:18083 + path: / + interval: 30s + timeout: 5s + retries: 3 diff --git a/apps/lnd/Dockerfile b/apps/lnd/Dockerfile new file mode 100644 index 00000000..4f7c90a4 --- /dev/null +++ b/apps/lnd/Dockerfile @@ -0,0 +1,5 @@ +# LND - uses official image +FROM lightninglabs/lnd:v0.18.0 + +# Default configuration is in the image +# No additional setup needed diff --git a/apps/lnd/manifest.yml b/apps/lnd/manifest.yml new file mode 100644 index 00000000..446e34e8 --- /dev/null +++ b/apps/lnd/manifest.yml @@ -0,0 +1,71 @@ +app: + id: lnd + name: LND + version: 0.18.4 + description: Lightning Network implementation by Lightning Labs. Enables instant, low-cost Bitcoin payments. + + container: + image: 146.59.87.168:3000/lfg2025/lnd:v0.18.4-beta + pull_policy: if-not-present + network: archy-net + # BITCOIND_HOST must follow the node's actual Bitcoin container — Knots or + # Core — resolved at apply time from host facts. Hardcoding either breaks + # LND's chain backend connection on the other (lnd.conf is likewise + # resolved in lnd::ensure_config). + derived_env: + - key: BITCOIND_HOST + template: "{{BITCOIN_HOST}}" + secret_env: + - key: BITCOIND_RPCPASS + secret_file: bitcoin-rpc-password + data_uid: "100000:100000" + + dependencies: + - app_id: bitcoin-core + version: ">=26.0" + + resources: + cpu_limit: 2 + memory_limit: 1Gi + disk_limit: 10Gi + + security: + capabilities: [CHOWN, FOWNER, SETUID, SETGID, DAC_OVERRIDE, NET_RAW] + readonly_root: false + network_policy: isolated + + ports: + - host: 9735 + container: 9735 + protocol: tcp + - host: 10009 + container: 10009 + protocol: tcp + - host: 18080 + container: 8080 + protocol: tcp + + volumes: + - type: bind + source: /var/lib/archipelago/lnd + target: /root/.lnd + options: [rw] + + environment: + - BITCOIND_RPCUSER=archipelago + - NETWORK=mainnet + + health_check: + type: tcp + endpoint: localhost:10009 + interval: 30s + timeout: 5s + retries: 3 + + bitcoin_integration: + rpc_access: admin + sync_required: true + + lightning_integration: + channel_management: true + payment_routing: true diff --git a/apps/mempool-api/manifest.yml b/apps/mempool-api/manifest.yml new file mode 100644 index 00000000..24b628ce --- /dev/null +++ b/apps/mempool-api/manifest.yml @@ -0,0 +1,75 @@ +app: + id: mempool-api + name: Mempool API + version: 3.0.0 + description: Backend API for mempool explorer. + + container: + image: 146.59.87.168:3000/lfg2025/mempool-backend:v3.0.0 + pull_policy: if-not-present + network: archy-net + # CORE_RPC_HOST must follow the node's actual Bitcoin container — Knots or + # Core — resolved at apply time from host facts (B12). Hardcoding either + # breaks mempool's RPC connection on the other. + derived_env: + - key: CORE_RPC_HOST + template: "{{BITCOIN_HOST}}" + secret_env: + - key: CORE_RPC_PASSWORD + secret_file: bitcoin-rpc-password + - key: DATABASE_PASSWORD + secret_file: mempool-db-password + + dependencies: + - app_id: bitcoin-knots + version: ">=26.0" + - app_id: electrumx + version: ">=1.18.0" + - app_id: archy-mempool-db + version: ">=11.4.10" + - bitcoin:archival + + resources: + memory_limit: 2Gi + disk_limit: 20Gi + + security: + capabilities: [] + readonly_root: false + network_policy: isolated + + ports: + - host: 8999 + container: 8999 + protocol: tcp + + volumes: + - type: bind + source: /var/lib/archipelago/mempool + target: /data + options: [rw] + + environment: + - MEMPOOL_BACKEND=electrum + - ELECTRUM_HOST=electrumx + - ELECTRUM_PORT=50001 + - ELECTRUM_TLS_ENABLED=false + - CORE_RPC_PORT=8332 + - CORE_RPC_USERNAME=archipelago + - DATABASE_ENABLED=true + - DATABASE_HOST=archy-mempool-db + - DATABASE_DATABASE=mempool + - DATABASE_USERNAME=mempool + + health_check: + type: http + endpoint: http://localhost:8999 + path: /api/v1/backend-info + interval: 30s + timeout: 5s + retries: 3 + + bitcoin_integration: + rpc_access: read-only + sync_required: true + pruning_support: false diff --git a/apps/mempool/Dockerfile b/apps/mempool/Dockerfile new file mode 100644 index 00000000..7f2da46a --- /dev/null +++ b/apps/mempool/Dockerfile @@ -0,0 +1,5 @@ +# Mempool - uses official image +FROM mempool/mempool:v2.5.0 + +# Default configuration is in the image +# No additional setup needed diff --git a/apps/mempool/manifest.yml b/apps/mempool/manifest.yml new file mode 100644 index 00000000..fbaf7263 --- /dev/null +++ b/apps/mempool/manifest.yml @@ -0,0 +1,60 @@ +app: + id: mempool + name: Mempool Explorer + version: 3.0.0 + description: Bitcoin mempool and blockchain explorer. Real-time transaction and block visualization. + + container: + image: 146.59.87.168:3000/lfg2025/mempool-frontend:v3.0.1 + image_signature: cosign://... + pull_policy: if-not-present + + dependencies: + - app_id: bitcoin-core + version: ">=24.0" + - storage: 20Gi + - bitcoin:archival + + resources: + cpu_limit: 2 + memory_limit: 2Gi + disk_limit: 20Gi + + security: + capabilities: [] + readonly_root: true + no_new_privileges: true + user: 1000 + seccomp_profile: default + network_policy: isolated + apparmor_profile: mempool + + ports: + - host: 4080 + container: 8080 # mempool-frontend nginx listens on 8080 (FRONTEND_HTTP_PORT=8080) + protocol: tcp # Web UI + + volumes: + - type: bind + source: /var/lib/archipelago/mempool + target: /data + options: [rw] + + environment: + - MEMPOOL_BACKEND=electrum + - MEMPOOL_BITCOIN_HOST=bitcoin-core + - MEMPOOL_BITCOIN_PORT=8332 + - MEMPOOL_BITCOIN_USER=${BITCOIN_RPC_USER} + - MEMPOOL_BITCOIN_PASSWORD=${BITCOIN_RPC_PASSWORD} + + health_check: + type: http + endpoint: http://localhost:4080 + path: /api/health + interval: 30s + timeout: 5s + retries: 3 + + bitcoin_integration: + rpc_access: read-only + sync_required: true diff --git a/apps/morphos-server/.dockerignore b/apps/morphos-server/.dockerignore new file mode 100644 index 00000000..e052d6d1 --- /dev/null +++ b/apps/morphos-server/.dockerignore @@ -0,0 +1,6 @@ +node_modules +dist +*.log +.git +.gitignore +README.md diff --git a/apps/morphos-server/Dockerfile b/apps/morphos-server/Dockerfile new file mode 100644 index 00000000..59bd227e --- /dev/null +++ b/apps/morphos-server/Dockerfile @@ -0,0 +1,37 @@ +FROM node:20-alpine AS builder + +WORKDIR /app + +# Copy package files +COPY package*.json ./ +RUN npm ci --only=production + +# Copy source code +COPY . . + +# Build the application +RUN npm run build + +# Production stage +FROM node:20-alpine + +WORKDIR /app + +# Copy built application +COPY --from=builder /app/dist ./dist +COPY --from=builder /app/node_modules ./node_modules +COPY --from=builder /app/package.json ./ + +# Create non-root user +RUN addgroup -g 1000 appuser && \ + adduser -D -u 1000 -G appuser appuser && \ + mkdir -p /app/data && \ + chown -R appuser:appuser /app + +USER appuser + +EXPOSE 8080 + +ENV MORPHOS_DATA_DIR=/app/data + +CMD ["node", "dist/index.js"] diff --git a/apps/morphos-server/manifest.yml b/apps/morphos-server/manifest.yml new file mode 100644 index 00000000..dd032bf4 --- /dev/null +++ b/apps/morphos-server/manifest.yml @@ -0,0 +1,50 @@ +app: + id: morphos-server + name: MorphOS Server + version: 1.0.0 + description: MorphOS server platform. Decentralized application server. + + container: + image: archipelago/morphos-server:1.0.0 + image_signature: cosign://... + pull_policy: if-not-present + + dependencies: + - storage: 5Gi + + resources: + cpu_limit: 2 + memory_limit: 2Gi + disk_limit: 5Gi + + security: + capabilities: [] + readonly_root: true + no_new_privileges: true + user: 1000 + seccomp_profile: default + network_policy: isolated + apparmor_profile: morphos-server + + ports: + - host: 8089 + container: 8080 + protocol: tcp # Web UI + + volumes: + - type: bind + source: /var/lib/archipelago/morphos-server + target: /app/data + options: [rw] + + environment: + - MORPHOS_ENV=production + - MORPHOS_DATA_DIR=/app/data + + health_check: + type: http + endpoint: http://127.0.0.1:8080 + path: /health + interval: 30s + timeout: 5s + retries: 3 diff --git a/apps/morphos-server/package-lock.json b/apps/morphos-server/package-lock.json new file mode 100644 index 00000000..4d5cfb5d --- /dev/null +++ b/apps/morphos-server/package-lock.json @@ -0,0 +1,1161 @@ +{ + "name": "morphos-server", + "version": "1.0.0", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "morphos-server", + "version": "1.0.0", + "dependencies": { + "express": "^4.18.2" + }, + "devDependencies": { + "@types/express": "^4.17.21", + "@types/node": "^20.10.0", + "ts-node": "^10.9.2", + "typescript": "^5.3.3" + } + }, + "node_modules/@cspotcode/source-map-support": { + "version": "0.8.1", + "resolved": "https://registry.npmjs.org/@cspotcode/source-map-support/-/source-map-support-0.8.1.tgz", + "integrity": "sha512-IchNf6dN4tHoMFIn/7OE8LWZ19Y6q/67Bmf6vnGREv8RSbBVb9LPJxEcnwrcwX6ixSvaiGoomAUvu4YSxXrVgw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/trace-mapping": "0.3.9" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/@jridgewell/resolve-uri": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/@jridgewell/resolve-uri/-/resolve-uri-3.1.2.tgz", + "integrity": "sha512-bRISgCIjP20/tbWSPWMEi54QVPRZExkuD9lJL+UIxUKtwVJA8wW1Trb1jMs1RFXo1CBTNZ/5hpC9QvmKWdopKw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/@jridgewell/sourcemap-codec": { + "version": "1.5.5", + "resolved": "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.5.5.tgz", + "integrity": "sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og==", + "dev": true, + "license": "MIT" + }, + "node_modules/@jridgewell/trace-mapping": { + "version": "0.3.9", + "resolved": "https://registry.npmjs.org/@jridgewell/trace-mapping/-/trace-mapping-0.3.9.tgz", + "integrity": "sha512-3Belt6tdc8bPgAtbcmdtNJlirVoTmEb5e2gC94PnkwEW9jI6CAHUeoG85tjWP5WquqfavoMtMwiG4P926ZKKuQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/resolve-uri": "^3.0.3", + "@jridgewell/sourcemap-codec": "^1.4.10" + } + }, + "node_modules/@tsconfig/node10": { + "version": "1.0.12", + "resolved": "https://registry.npmjs.org/@tsconfig/node10/-/node10-1.0.12.tgz", + "integrity": "sha512-UCYBaeFvM11aU2y3YPZ//O5Rhj+xKyzy7mvcIoAjASbigy8mHMryP5cK7dgjlz2hWxh1g5pLw084E0a/wlUSFQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/@tsconfig/node12": { + "version": "1.0.11", + "resolved": "https://registry.npmjs.org/@tsconfig/node12/-/node12-1.0.11.tgz", + "integrity": "sha512-cqefuRsh12pWyGsIoBKJA9luFu3mRxCA+ORZvA4ktLSzIuCUtWVxGIuXigEwO5/ywWFMZ2QEGKWvkZG1zDMTag==", + "dev": true, + "license": "MIT" + }, + "node_modules/@tsconfig/node14": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/@tsconfig/node14/-/node14-1.0.3.tgz", + "integrity": "sha512-ysT8mhdixWK6Hw3i1V2AeRqZ5WfXg1G43mqoYlM2nc6388Fq5jcXyr5mRsqViLx/GJYdoL0bfXD8nmF+Zn/Iow==", + "dev": true, + "license": "MIT" + }, + "node_modules/@tsconfig/node16": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/@tsconfig/node16/-/node16-1.0.4.tgz", + "integrity": "sha512-vxhUy4J8lyeyinH7Azl1pdd43GJhZH/tP2weN8TntQblOY+A0XbT8DJk1/oCPuOOyg/Ja757rG0CgHcWC8OfMA==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/body-parser": { + "version": "1.19.6", + "resolved": "https://registry.npmjs.org/@types/body-parser/-/body-parser-1.19.6.tgz", + "integrity": "sha512-HLFeCYgz89uk22N5Qg3dvGvsv46B8GLvKKo1zKG4NybA8U2DiEO3w9lqGg29t/tfLRJpJ6iQxnVw4OnB7MoM9g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/connect": "*", + "@types/node": "*" + } + }, + "node_modules/@types/connect": { + "version": "3.4.38", + "resolved": "https://registry.npmjs.org/@types/connect/-/connect-3.4.38.tgz", + "integrity": "sha512-K6uROf1LD88uDQqJCktA4yzL1YYAK6NgfsI0v/mTgyPKWsX1CnJ0XPSDhViejru1GcRkLWb8RlzFYJRqGUbaug==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/node": "*" + } + }, + "node_modules/@types/express": { + "version": "4.17.25", + "resolved": "https://registry.npmjs.org/@types/express/-/express-4.17.25.tgz", + "integrity": "sha512-dVd04UKsfpINUnK0yBoYHDF3xu7xVH4BuDotC/xGuycx4CgbP48X/KF/586bcObxT0HENHXEU8Nqtu6NR+eKhw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/body-parser": "*", + "@types/express-serve-static-core": "^4.17.33", + "@types/qs": "*", + "@types/serve-static": "^1" + } + }, + "node_modules/@types/express-serve-static-core": { + "version": "4.19.8", + "resolved": "https://registry.npmjs.org/@types/express-serve-static-core/-/express-serve-static-core-4.19.8.tgz", + "integrity": "sha512-02S5fmqeoKzVZCHPZid4b8JH2eM5HzQLZWN2FohQEy/0eXTq8VXZfSN6Pcr3F6N9R/vNrj7cpgbhjie6m/1tCA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/node": "*", + "@types/qs": "*", + "@types/range-parser": "*", + "@types/send": "*" + } + }, + "node_modules/@types/http-errors": { + "version": "2.0.5", + "resolved": "https://registry.npmjs.org/@types/http-errors/-/http-errors-2.0.5.tgz", + "integrity": "sha512-r8Tayk8HJnX0FztbZN7oVqGccWgw98T/0neJphO91KkmOzug1KkofZURD4UaD5uH8AqcFLfdPErnBod0u71/qg==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/mime": { + "version": "1.3.5", + "resolved": "https://registry.npmjs.org/@types/mime/-/mime-1.3.5.tgz", + "integrity": "sha512-/pyBZWSLD2n0dcHE3hq8s8ZvcETHtEuF+3E7XVt0Ig2nvsVQXdghHVcEkIWjy9A0wKfTn97a/PSDYohKIlnP/w==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/node": { + "version": "20.19.30", + "resolved": "https://registry.npmjs.org/@types/node/-/node-20.19.30.tgz", + "integrity": "sha512-WJtwWJu7UdlvzEAUm484QNg5eAoq5QR08KDNx7g45Usrs2NtOPiX8ugDqmKdXkyL03rBqU5dYNYVQetEpBHq2g==", + "dev": true, + "license": "MIT", + "peer": true, + "dependencies": { + "undici-types": "~6.21.0" + } + }, + "node_modules/@types/qs": { + "version": "6.14.0", + "resolved": "https://registry.npmjs.org/@types/qs/-/qs-6.14.0.tgz", + "integrity": "sha512-eOunJqu0K1923aExK6y8p6fsihYEn/BYuQ4g0CxAAgFc4b/ZLN4CrsRZ55srTdqoiLzU2B2evC+apEIxprEzkQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/range-parser": { + "version": "1.2.7", + "resolved": "https://registry.npmjs.org/@types/range-parser/-/range-parser-1.2.7.tgz", + "integrity": "sha512-hKormJbkJqzQGhziax5PItDUTMAM9uE2XXQmM37dyd4hVM+5aVl7oVxMVUiVQn2oCQFN/LKCZdvSM0pFRqbSmQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/send": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/@types/send/-/send-1.2.1.tgz", + "integrity": "sha512-arsCikDvlU99zl1g69TcAB3mzZPpxgw0UQnaHeC1Nwb015xp8bknZv5rIfri9xTOcMuaVgvabfIRA7PSZVuZIQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/node": "*" + } + }, + "node_modules/@types/serve-static": { + "version": "1.15.10", + "resolved": "https://registry.npmjs.org/@types/serve-static/-/serve-static-1.15.10.tgz", + "integrity": "sha512-tRs1dB+g8Itk72rlSI2ZrW6vZg0YrLI81iQSTkMmOqnqCaNr/8Ek4VwWcN5vZgCYWbg/JJSGBlUaYGAOP73qBw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/http-errors": "*", + "@types/node": "*", + "@types/send": "<1" + } + }, + "node_modules/@types/serve-static/node_modules/@types/send": { + "version": "0.17.6", + "resolved": "https://registry.npmjs.org/@types/send/-/send-0.17.6.tgz", + "integrity": "sha512-Uqt8rPBE8SY0RK8JB1EzVOIZ32uqy8HwdxCnoCOsYrvnswqmFZ/k+9Ikidlk/ImhsdvBsloHbAlewb2IEBV/Og==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/mime": "^1", + "@types/node": "*" + } + }, + "node_modules/accepts": { + "version": "1.3.8", + "resolved": "https://registry.npmjs.org/accepts/-/accepts-1.3.8.tgz", + "integrity": "sha512-PYAthTa2m2VKxuvSD3DPC/Gy+U+sOA1LAuT8mkmRuvw+NACSaeXEQ+NHcVF7rONl6qcaxV3Uuemwawk+7+SJLw==", + "license": "MIT", + "dependencies": { + "mime-types": "~2.1.34", + "negotiator": "0.6.3" + }, + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/acorn": { + "version": "8.15.0", + "resolved": "https://registry.npmjs.org/acorn/-/acorn-8.15.0.tgz", + "integrity": "sha512-NZyJarBfL7nWwIq+FDL6Zp/yHEhePMNnnJ0y3qfieCrmNvYct8uvtiV41UvlSe6apAfk0fY1FbWx+NwfmpvtTg==", + "dev": true, + "license": "MIT", + "bin": { + "acorn": "bin/acorn" + }, + "engines": { + "node": ">=0.4.0" + } + }, + "node_modules/acorn-walk": { + "version": "8.3.4", + "resolved": "https://registry.npmjs.org/acorn-walk/-/acorn-walk-8.3.4.tgz", + "integrity": "sha512-ueEepnujpqee2o5aIYnvHU6C0A42MNdsIDeqy5BydrkuC5R1ZuUFnm27EeFJGoEHJQgn3uleRvmTXaJgfXbt4g==", + "dev": true, + "license": "MIT", + "dependencies": { + "acorn": "^8.11.0" + }, + "engines": { + "node": ">=0.4.0" + } + }, + "node_modules/arg": { + "version": "4.1.3", + "resolved": "https://registry.npmjs.org/arg/-/arg-4.1.3.tgz", + "integrity": "sha512-58S9QDqG0Xx27YwPSt9fJxivjYl432YCwfDMfZ+71RAqUrZef7LrKQZ3LHLOwCS4FLNBplP533Zx895SeOCHvA==", + "dev": true, + "license": "MIT" + }, + "node_modules/array-flatten": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/array-flatten/-/array-flatten-1.1.1.tgz", + "integrity": "sha512-PCVAQswWemu6UdxsDFFX/+gVeYqKAod3D3UVm91jHwynguOwAvYPhx8nNlM++NqRcK6CxxpUafjmhIdKiHibqg==", + "license": "MIT" + }, + "node_modules/body-parser": { + "version": "1.20.4", + "resolved": "https://registry.npmjs.org/body-parser/-/body-parser-1.20.4.tgz", + "integrity": "sha512-ZTgYYLMOXY9qKU/57FAo8F+HA2dGX7bqGc71txDRC1rS4frdFI5R7NhluHxH6M0YItAP0sHB4uqAOcYKxO6uGA==", + "license": "MIT", + "dependencies": { + "bytes": "~3.1.2", + "content-type": "~1.0.5", + "debug": "2.6.9", + "depd": "2.0.0", + "destroy": "~1.2.0", + "http-errors": "~2.0.1", + "iconv-lite": "~0.4.24", + "on-finished": "~2.4.1", + "qs": "~6.14.0", + "raw-body": "~2.5.3", + "type-is": "~1.6.18", + "unpipe": "~1.0.0" + }, + "engines": { + "node": ">= 0.8", + "npm": "1.2.8000 || >= 1.4.16" + } + }, + "node_modules/bytes": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/bytes/-/bytes-3.1.2.tgz", + "integrity": "sha512-/Nf7TyzTx6S3yRJObOAV7956r8cr2+Oj8AC5dt8wSP3BQAoeX58NoHyCU8P8zGkNXStjTSi6fzO6F0pBdcYbEg==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/call-bind-apply-helpers": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/call-bind-apply-helpers/-/call-bind-apply-helpers-1.0.2.tgz", + "integrity": "sha512-Sp1ablJ0ivDkSzjcaJdxEunN5/XvksFJ2sMBFfq6x0ryhQV/2b/KwFe21cMpmHtPOSij8K99/wSfoEuTObmuMQ==", + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "function-bind": "^1.1.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/call-bound": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/call-bound/-/call-bound-1.0.4.tgz", + "integrity": "sha512-+ys997U96po4Kx/ABpBCqhA9EuxJaQWDQg7295H4hBphv3IZg0boBKuwYpt4YXp6MZ5AmZQnU/tyMTlRpaSejg==", + "license": "MIT", + "dependencies": { + "call-bind-apply-helpers": "^1.0.2", + "get-intrinsic": "^1.3.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/content-disposition": { + "version": "0.5.4", + "resolved": "https://registry.npmjs.org/content-disposition/-/content-disposition-0.5.4.tgz", + "integrity": "sha512-FveZTNuGw04cxlAiWbzi6zTAL/lhehaWbTtgluJh4/E95DqMwTmha3KZN1aAWA8cFIhHzMZUvLevkw5Rqk+tSQ==", + "license": "MIT", + "dependencies": { + "safe-buffer": "5.2.1" + }, + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/content-type": { + "version": "1.0.5", + "resolved": "https://registry.npmjs.org/content-type/-/content-type-1.0.5.tgz", + "integrity": "sha512-nTjqfcBFEipKdXCv4YDQWCfmcLZKm81ldF0pAopTvyrFGVbcR6P/VAAd5G7N+0tTr8QqiU0tFadD6FK4NtJwOA==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/cookie": { + "version": "0.7.2", + "resolved": "https://registry.npmjs.org/cookie/-/cookie-0.7.2.tgz", + "integrity": "sha512-yki5XnKuf750l50uGTllt6kKILY4nQ1eNIQatoXEByZ5dWgnKqbnqmTrBE5B4N7lrMJKQ2ytWMiTO2o0v6Ew/w==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/cookie-signature": { + "version": "1.0.7", + "resolved": "https://registry.npmjs.org/cookie-signature/-/cookie-signature-1.0.7.tgz", + "integrity": "sha512-NXdYc3dLr47pBkpUCHtKSwIOQXLVn8dZEuywboCOJY/osA0wFSLlSawr3KN8qXJEyX66FcONTH8EIlVuK0yyFA==", + "license": "MIT" + }, + "node_modules/create-require": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/create-require/-/create-require-1.1.1.tgz", + "integrity": "sha512-dcKFX3jn0MpIaXjisoRvexIJVEKzaq7z2rZKxf+MSr9TkdmHmsU4m2lcLojrj/FHl8mk5VxMmYA+ftRkP/3oKQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/debug": { + "version": "2.6.9", + "resolved": "https://registry.npmjs.org/debug/-/debug-2.6.9.tgz", + "integrity": "sha512-bC7ElrdJaJnPbAP+1EotYvqZsb3ecl5wi6Bfi6BJTUcNowp6cvspg0jXznRTKDjm/E7AdgFBVeAPVMNcKGsHMA==", + "license": "MIT", + "dependencies": { + "ms": "2.0.0" + } + }, + "node_modules/depd": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/depd/-/depd-2.0.0.tgz", + "integrity": "sha512-g7nH6P6dyDioJogAAGprGpCtVImJhpPk/roCzdb3fIh61/s/nPsfR6onyMwkCAR/OlC3yBC0lESvUoQEAssIrw==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/destroy": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/destroy/-/destroy-1.2.0.tgz", + "integrity": "sha512-2sJGJTaXIIaR1w4iJSNoN0hnMY7Gpc/n8D4qSCJw8QqFWXf7cuAgnEHxBpweaVcPevC2l3KpjYCx3NypQQgaJg==", + "license": "MIT", + "engines": { + "node": ">= 0.8", + "npm": "1.2.8000 || >= 1.4.16" + } + }, + "node_modules/diff": { + "version": "4.0.4", + "resolved": "https://registry.npmjs.org/diff/-/diff-4.0.4.tgz", + "integrity": "sha512-X07nttJQkwkfKfvTPG/KSnE2OMdcUCao6+eXF3wmnIQRn2aPAHH3VxDbDOdegkd6JbPsXqShpvEOHfAT+nCNwQ==", + "dev": true, + "license": "BSD-3-Clause", + "engines": { + "node": ">=0.3.1" + } + }, + "node_modules/dunder-proto": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/dunder-proto/-/dunder-proto-1.0.1.tgz", + "integrity": "sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A==", + "license": "MIT", + "dependencies": { + "call-bind-apply-helpers": "^1.0.1", + "es-errors": "^1.3.0", + "gopd": "^1.2.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/ee-first": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/ee-first/-/ee-first-1.1.1.tgz", + "integrity": "sha512-WMwm9LhRUo+WUaRN+vRuETqG89IgZphVSNkdFgeb6sS/E4OrDIN7t48CAewSHXc6C8lefD8KKfr5vY61brQlow==", + "license": "MIT" + }, + "node_modules/encodeurl": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/encodeurl/-/encodeurl-2.0.0.tgz", + "integrity": "sha512-Q0n9HRi4m6JuGIV1eFlmvJB7ZEVxu93IrMyiMsGC0lrMJMWzRgx6WGquyfQgZVb31vhGgXnfmPNNXmxnOkRBrg==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/es-define-property": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/es-define-property/-/es-define-property-1.0.1.tgz", + "integrity": "sha512-e3nRfgfUZ4rNGL232gUgX06QNyyez04KdjFrF+LTRoOXmrOgFKDg4BCdsjW8EnT69eqdYGmRpJwiPVYNrCaW3g==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/es-errors": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/es-errors/-/es-errors-1.3.0.tgz", + "integrity": "sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/es-object-atoms": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/es-object-atoms/-/es-object-atoms-1.1.1.tgz", + "integrity": "sha512-FGgH2h8zKNim9ljj7dankFPcICIK9Cp5bm+c2gQSYePhpaG5+esrLODihIorn+Pe6FGJzWhXQotPv73jTaldXA==", + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/escape-html": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/escape-html/-/escape-html-1.0.3.tgz", + "integrity": "sha512-NiSupZ4OeuGwr68lGIeym/ksIZMJodUGOSCZ/FSnTxcrekbvqrgdUxlJOMpijaKZVjAJrWrGs/6Jy8OMuyj9ow==", + "license": "MIT" + }, + "node_modules/etag": { + "version": "1.8.1", + "resolved": "https://registry.npmjs.org/etag/-/etag-1.8.1.tgz", + "integrity": "sha512-aIL5Fx7mawVa300al2BnEE4iNvo1qETxLrPI/o05L7z6go7fCw1J6EQmbK4FmJ2AS7kgVF/KEZWufBfdClMcPg==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/express": { + "version": "4.22.1", + "resolved": "https://registry.npmjs.org/express/-/express-4.22.1.tgz", + "integrity": "sha512-F2X8g9P1X7uCPZMA3MVf9wcTqlyNp7IhH5qPCI0izhaOIYXaW9L535tGA3qmjRzpH+bZczqq7hVKxTR4NWnu+g==", + "license": "MIT", + "dependencies": { + "accepts": "~1.3.8", + "array-flatten": "1.1.1", + "body-parser": "~1.20.3", + "content-disposition": "~0.5.4", + "content-type": "~1.0.4", + "cookie": "~0.7.1", + "cookie-signature": "~1.0.6", + "debug": "2.6.9", + "depd": "2.0.0", + "encodeurl": "~2.0.0", + "escape-html": "~1.0.3", + "etag": "~1.8.1", + "finalhandler": "~1.3.1", + "fresh": "~0.5.2", + "http-errors": "~2.0.0", + "merge-descriptors": "1.0.3", + "methods": "~1.1.2", + "on-finished": "~2.4.1", + "parseurl": "~1.3.3", + "path-to-regexp": "~0.1.12", + "proxy-addr": "~2.0.7", + "qs": "~6.14.0", + "range-parser": "~1.2.1", + "safe-buffer": "5.2.1", + "send": "~0.19.0", + "serve-static": "~1.16.2", + "setprototypeof": "1.2.0", + "statuses": "~2.0.1", + "type-is": "~1.6.18", + "utils-merge": "1.0.1", + "vary": "~1.1.2" + }, + "engines": { + "node": ">= 0.10.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/finalhandler": { + "version": "1.3.2", + "resolved": "https://registry.npmjs.org/finalhandler/-/finalhandler-1.3.2.tgz", + "integrity": "sha512-aA4RyPcd3badbdABGDuTXCMTtOneUCAYH/gxoYRTZlIJdF0YPWuGqiAsIrhNnnqdXGswYk6dGujem4w80UJFhg==", + "license": "MIT", + "dependencies": { + "debug": "2.6.9", + "encodeurl": "~2.0.0", + "escape-html": "~1.0.3", + "on-finished": "~2.4.1", + "parseurl": "~1.3.3", + "statuses": "~2.0.2", + "unpipe": "~1.0.0" + }, + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/forwarded": { + "version": "0.2.0", + "resolved": "https://registry.npmjs.org/forwarded/-/forwarded-0.2.0.tgz", + "integrity": "sha512-buRG0fpBtRHSTCOASe6hD258tEubFoRLb4ZNA6NxMVHNw2gOcwHo9wyablzMzOA5z9xA9L1KNjk/Nt6MT9aYow==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/fresh": { + "version": "0.5.2", + "resolved": "https://registry.npmjs.org/fresh/-/fresh-0.5.2.tgz", + "integrity": "sha512-zJ2mQYM18rEFOudeV4GShTGIQ7RbzA7ozbU9I/XBpm7kqgMywgmylMwXHxZJmkVoYkna9d2pVXVXPdYTP9ej8Q==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/function-bind": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/function-bind/-/function-bind-1.1.2.tgz", + "integrity": "sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA==", + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/get-intrinsic": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/get-intrinsic/-/get-intrinsic-1.3.0.tgz", + "integrity": "sha512-9fSjSaos/fRIVIp+xSJlE6lfwhES7LNtKaCBIamHsjr2na1BiABJPo0mOjjz8GJDURarmCPGqaiVg5mfjb98CQ==", + "license": "MIT", + "dependencies": { + "call-bind-apply-helpers": "^1.0.2", + "es-define-property": "^1.0.1", + "es-errors": "^1.3.0", + "es-object-atoms": "^1.1.1", + "function-bind": "^1.1.2", + "get-proto": "^1.0.1", + "gopd": "^1.2.0", + "has-symbols": "^1.1.0", + "hasown": "^2.0.2", + "math-intrinsics": "^1.1.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/get-proto": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/get-proto/-/get-proto-1.0.1.tgz", + "integrity": "sha512-sTSfBjoXBp89JvIKIefqw7U2CCebsc74kiY6awiGogKtoSGbgjYE/G/+l9sF3MWFPNc9IcoOC4ODfKHfxFmp0g==", + "license": "MIT", + "dependencies": { + "dunder-proto": "^1.0.1", + "es-object-atoms": "^1.0.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/gopd": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/gopd/-/gopd-1.2.0.tgz", + "integrity": "sha512-ZUKRh6/kUFoAiTAtTYPZJ3hw9wNxx+BIBOijnlG9PnrJsCcSjs1wyyD6vJpaYtgnzDrKYRSqf3OO6Rfa93xsRg==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/has-symbols": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/has-symbols/-/has-symbols-1.1.0.tgz", + "integrity": "sha512-1cDNdwJ2Jaohmb3sg4OmKaMBwuC48sYni5HUw2DvsC8LjGTLK9h+eb1X6RyuOHe4hT0ULCW68iomhjUoKUqlPQ==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/hasown": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/hasown/-/hasown-2.0.2.tgz", + "integrity": "sha512-0hJU9SCPvmMzIBdZFqNPXWa6dqh7WdH0cII9y+CyS8rG3nL48Bclra9HmKhVVUHyPWNH5Y7xDwAB7bfgSjkUMQ==", + "license": "MIT", + "dependencies": { + "function-bind": "^1.1.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/http-errors": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/http-errors/-/http-errors-2.0.1.tgz", + "integrity": "sha512-4FbRdAX+bSdmo4AUFuS0WNiPz8NgFt+r8ThgNWmlrjQjt1Q7ZR9+zTlce2859x4KSXrwIsaeTqDoKQmtP8pLmQ==", + "license": "MIT", + "dependencies": { + "depd": "~2.0.0", + "inherits": "~2.0.4", + "setprototypeof": "~1.2.0", + "statuses": "~2.0.2", + "toidentifier": "~1.0.1" + }, + "engines": { + "node": ">= 0.8" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/iconv-lite": { + "version": "0.4.24", + "resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.4.24.tgz", + "integrity": "sha512-v3MXnZAcvnywkTUEZomIActle7RXXeedOR31wwl7VlyoXO4Qi9arvSenNQWne1TcRwhCL1HwLI21bEqdpj8/rA==", + "license": "MIT", + "dependencies": { + "safer-buffer": ">= 2.1.2 < 3" + }, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/inherits": { + "version": "2.0.4", + "resolved": "https://registry.npmjs.org/inherits/-/inherits-2.0.4.tgz", + "integrity": "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==", + "license": "ISC" + }, + "node_modules/ipaddr.js": { + "version": "1.9.1", + "resolved": "https://registry.npmjs.org/ipaddr.js/-/ipaddr.js-1.9.1.tgz", + "integrity": "sha512-0KI/607xoxSToH7GjN1FfSbLoU0+btTicjsQSWQlh/hZykN8KpmMf7uYwPW3R+akZ6R/w18ZlXSHBYXiYUPO3g==", + "license": "MIT", + "engines": { + "node": ">= 0.10" + } + }, + "node_modules/make-error": { + "version": "1.3.6", + "resolved": "https://registry.npmjs.org/make-error/-/make-error-1.3.6.tgz", + "integrity": "sha512-s8UhlNe7vPKomQhC1qFelMokr/Sc3AgNbso3n74mVPA5LTZwkB9NlXf4XPamLxJE8h0gh73rM94xvwRT2CVInw==", + "dev": true, + "license": "ISC" + }, + "node_modules/math-intrinsics": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/math-intrinsics/-/math-intrinsics-1.1.0.tgz", + "integrity": "sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/media-typer": { + "version": "0.3.0", + "resolved": "https://registry.npmjs.org/media-typer/-/media-typer-0.3.0.tgz", + "integrity": "sha512-dq+qelQ9akHpcOl/gUVRTxVIOkAJ1wR3QAvb4RsVjS8oVoFjDGTc679wJYmUmknUF5HwMLOgb5O+a3KxfWapPQ==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/merge-descriptors": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/merge-descriptors/-/merge-descriptors-1.0.3.tgz", + "integrity": "sha512-gaNvAS7TZ897/rVaZ0nMtAyxNyi/pdbjbAwUpFQpN70GqnVfOiXpeUUMKRBmzXaSQ8DdTX4/0ms62r2K+hE6mQ==", + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/methods": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/methods/-/methods-1.1.2.tgz", + "integrity": "sha512-iclAHeNqNm68zFtnZ0e+1L2yUIdvzNoauKU4WBA3VvH/vPFieF7qfRlwUZU+DA9P9bPXIS90ulxoUoCH23sV2w==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/mime": { + "version": "1.6.0", + "resolved": "https://registry.npmjs.org/mime/-/mime-1.6.0.tgz", + "integrity": "sha512-x0Vn8spI+wuJ1O6S7gnbaQg8Pxh4NNHb7KSINmEWKiPE4RKOplvijn+NkmYmmRgP68mc70j2EbeTFRsrswaQeg==", + "license": "MIT", + "bin": { + "mime": "cli.js" + }, + "engines": { + "node": ">=4" + } + }, + "node_modules/mime-db": { + "version": "1.52.0", + "resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.52.0.tgz", + "integrity": "sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/mime-types": { + "version": "2.1.35", + "resolved": "https://registry.npmjs.org/mime-types/-/mime-types-2.1.35.tgz", + "integrity": "sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw==", + "license": "MIT", + "dependencies": { + "mime-db": "1.52.0" + }, + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/ms": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.0.0.tgz", + "integrity": "sha512-Tpp60P6IUJDTuOq/5Z8cdskzJujfwqfOTkrwIwj7IRISpnkJnT6SyJ4PCPnGMoFjC9ddhal5KVIYtAt97ix05A==", + "license": "MIT" + }, + "node_modules/negotiator": { + "version": "0.6.3", + "resolved": "https://registry.npmjs.org/negotiator/-/negotiator-0.6.3.tgz", + "integrity": "sha512-+EUsqGPLsM+j/zdChZjsnX51g4XrHFOIXwfnCVPGlQk/k5giakcKsuxCObBRu6DSm9opw/O6slWbJdghQM4bBg==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/object-inspect": { + "version": "1.13.4", + "resolved": "https://registry.npmjs.org/object-inspect/-/object-inspect-1.13.4.tgz", + "integrity": "sha512-W67iLl4J2EXEGTbfeHCffrjDfitvLANg0UlX3wFUUSTx92KXRFegMHUVgSqE+wvhAbi4WqjGg9czysTV2Epbew==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/on-finished": { + "version": "2.4.1", + "resolved": "https://registry.npmjs.org/on-finished/-/on-finished-2.4.1.tgz", + "integrity": "sha512-oVlzkg3ENAhCk2zdv7IJwd/QUD4z2RxRwpkcGY8psCVcCYZNq4wYnVWALHM+brtuJjePWiYF/ClmuDr8Ch5+kg==", + "license": "MIT", + "dependencies": { + "ee-first": "1.1.1" + }, + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/parseurl": { + "version": "1.3.3", + "resolved": "https://registry.npmjs.org/parseurl/-/parseurl-1.3.3.tgz", + "integrity": "sha512-CiyeOxFT/JZyN5m0z9PfXw4SCBJ6Sygz1Dpl0wqjlhDEGGBP1GnsUVEL0p63hoG1fcj3fHynXi9NYO4nWOL+qQ==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/path-to-regexp": { + "version": "0.1.12", + "resolved": "https://registry.npmjs.org/path-to-regexp/-/path-to-regexp-0.1.12.tgz", + "integrity": "sha512-RA1GjUVMnvYFxuqovrEqZoxxW5NUZqbwKtYz/Tt7nXerk0LbLblQmrsgdeOxV5SFHf0UDggjS/bSeOZwt1pmEQ==", + "license": "MIT" + }, + "node_modules/proxy-addr": { + "version": "2.0.7", + "resolved": "https://registry.npmjs.org/proxy-addr/-/proxy-addr-2.0.7.tgz", + "integrity": "sha512-llQsMLSUDUPT44jdrU/O37qlnifitDP+ZwrmmZcoSKyLKvtZxpyV0n2/bD/N4tBAAZ/gJEdZU7KMraoK1+XYAg==", + "license": "MIT", + "dependencies": { + "forwarded": "0.2.0", + "ipaddr.js": "1.9.1" + }, + "engines": { + "node": ">= 0.10" + } + }, + "node_modules/qs": { + "version": "6.14.1", + "resolved": "https://registry.npmjs.org/qs/-/qs-6.14.1.tgz", + "integrity": "sha512-4EK3+xJl8Ts67nLYNwqw/dsFVnCf+qR7RgXSK9jEEm9unao3njwMDdmsdvoKBKHzxd7tCYz5e5M+SnMjdtXGQQ==", + "license": "BSD-3-Clause", + "dependencies": { + "side-channel": "^1.1.0" + }, + "engines": { + "node": ">=0.6" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/range-parser": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/range-parser/-/range-parser-1.2.1.tgz", + "integrity": "sha512-Hrgsx+orqoygnmhFbKaHE6c296J+HTAQXoxEF6gNupROmmGJRoyzfG3ccAveqCBrwr/2yxQ5BVd/GTl5agOwSg==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/raw-body": { + "version": "2.5.3", + "resolved": "https://registry.npmjs.org/raw-body/-/raw-body-2.5.3.tgz", + "integrity": "sha512-s4VSOf6yN0rvbRZGxs8Om5CWj6seneMwK3oDb4lWDH0UPhWcxwOWw5+qk24bxq87szX1ydrwylIOp2uG1ojUpA==", + "license": "MIT", + "dependencies": { + "bytes": "~3.1.2", + "http-errors": "~2.0.1", + "iconv-lite": "~0.4.24", + "unpipe": "~1.0.0" + }, + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/safe-buffer": { + "version": "5.2.1", + "resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.2.1.tgz", + "integrity": "sha512-rp3So07KcdmmKbGvgaNxQSJr7bGVSVk5S9Eq1F+ppbRo70+YeaDxkw5Dd8NPN+GD6bjnYm2VuPuCXmpuYvmCXQ==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "MIT" + }, + "node_modules/safer-buffer": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/safer-buffer/-/safer-buffer-2.1.2.tgz", + "integrity": "sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==", + "license": "MIT" + }, + "node_modules/send": { + "version": "0.19.2", + "resolved": "https://registry.npmjs.org/send/-/send-0.19.2.tgz", + "integrity": "sha512-VMbMxbDeehAxpOtWJXlcUS5E8iXh6QmN+BkRX1GARS3wRaXEEgzCcB10gTQazO42tpNIya8xIyNx8fll1OFPrg==", + "license": "MIT", + "dependencies": { + "debug": "2.6.9", + "depd": "2.0.0", + "destroy": "1.2.0", + "encodeurl": "~2.0.0", + "escape-html": "~1.0.3", + "etag": "~1.8.1", + "fresh": "~0.5.2", + "http-errors": "~2.0.1", + "mime": "1.6.0", + "ms": "2.1.3", + "on-finished": "~2.4.1", + "range-parser": "~1.2.1", + "statuses": "~2.0.2" + }, + "engines": { + "node": ">= 0.8.0" + } + }, + "node_modules/send/node_modules/ms": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", + "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", + "license": "MIT" + }, + "node_modules/serve-static": { + "version": "1.16.3", + "resolved": "https://registry.npmjs.org/serve-static/-/serve-static-1.16.3.tgz", + "integrity": "sha512-x0RTqQel6g5SY7Lg6ZreMmsOzncHFU7nhnRWkKgWuMTu5NN0DR5oruckMqRvacAN9d5w6ARnRBXl9xhDCgfMeA==", + "license": "MIT", + "dependencies": { + "encodeurl": "~2.0.0", + "escape-html": "~1.0.3", + "parseurl": "~1.3.3", + "send": "~0.19.1" + }, + "engines": { + "node": ">= 0.8.0" + } + }, + "node_modules/setprototypeof": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/setprototypeof/-/setprototypeof-1.2.0.tgz", + "integrity": "sha512-E5LDX7Wrp85Kil5bhZv46j8jOeboKq5JMmYM3gVGdGH8xFpPWXUMsNrlODCrkoxMEeNi/XZIwuRvY4XNwYMJpw==", + "license": "ISC" + }, + "node_modules/side-channel": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/side-channel/-/side-channel-1.1.0.tgz", + "integrity": "sha512-ZX99e6tRweoUXqR+VBrslhda51Nh5MTQwou5tnUDgbtyM0dBgmhEDtWGP/xbKn6hqfPRHujUNwz5fy/wbbhnpw==", + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "object-inspect": "^1.13.3", + "side-channel-list": "^1.0.0", + "side-channel-map": "^1.0.1", + "side-channel-weakmap": "^1.0.2" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/side-channel-list": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/side-channel-list/-/side-channel-list-1.0.0.tgz", + "integrity": "sha512-FCLHtRD/gnpCiCHEiJLOwdmFP+wzCmDEkc9y7NsYxeF4u7Btsn1ZuwgwJGxImImHicJArLP4R0yX4c2KCrMrTA==", + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "object-inspect": "^1.13.3" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/side-channel-map": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/side-channel-map/-/side-channel-map-1.0.1.tgz", + "integrity": "sha512-VCjCNfgMsby3tTdo02nbjtM/ewra6jPHmpThenkTYh8pG9ucZ/1P8So4u4FGBek/BjpOVsDCMoLA/iuBKIFXRA==", + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.2", + "es-errors": "^1.3.0", + "get-intrinsic": "^1.2.5", + "object-inspect": "^1.13.3" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/side-channel-weakmap": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/side-channel-weakmap/-/side-channel-weakmap-1.0.2.tgz", + "integrity": "sha512-WPS/HvHQTYnHisLo9McqBHOJk2FkHO/tlpvldyrnem4aeQp4hai3gythswg6p01oSoTl58rcpiFAjF2br2Ak2A==", + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.2", + "es-errors": "^1.3.0", + "get-intrinsic": "^1.2.5", + "object-inspect": "^1.13.3", + "side-channel-map": "^1.0.1" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/statuses": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/statuses/-/statuses-2.0.2.tgz", + "integrity": "sha512-DvEy55V3DB7uknRo+4iOGT5fP1slR8wQohVdknigZPMpMstaKJQWhwiYBACJE3Ul2pTnATihhBYnRhZQHGBiRw==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/toidentifier": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/toidentifier/-/toidentifier-1.0.1.tgz", + "integrity": "sha512-o5sSPKEkg/DIQNmH43V0/uerLrpzVedkUh8tGNvaeXpfpuwjKenlSox/2O/BTlZUtEe+JG7s5YhEz608PlAHRA==", + "license": "MIT", + "engines": { + "node": ">=0.6" + } + }, + "node_modules/ts-node": { + "version": "10.9.2", + "resolved": "https://registry.npmjs.org/ts-node/-/ts-node-10.9.2.tgz", + "integrity": "sha512-f0FFpIdcHgn8zcPSbf1dRevwt047YMnaiJM3u2w2RewrB+fob/zePZcrOyQoLMMO7aBIddLcQIEK5dYjkLnGrQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@cspotcode/source-map-support": "^0.8.0", + "@tsconfig/node10": "^1.0.7", + "@tsconfig/node12": "^1.0.7", + "@tsconfig/node14": "^1.0.0", + "@tsconfig/node16": "^1.0.2", + "acorn": "^8.4.1", + "acorn-walk": "^8.1.1", + "arg": "^4.1.0", + "create-require": "^1.1.0", + "diff": "^4.0.1", + "make-error": "^1.1.1", + "v8-compile-cache-lib": "^3.0.1", + "yn": "3.1.1" + }, + "bin": { + "ts-node": "dist/bin.js", + "ts-node-cwd": "dist/bin-cwd.js", + "ts-node-esm": "dist/bin-esm.js", + "ts-node-script": "dist/bin-script.js", + "ts-node-transpile-only": "dist/bin-transpile.js", + "ts-script": "dist/bin-script-deprecated.js" + }, + "peerDependencies": { + "@swc/core": ">=1.2.50", + "@swc/wasm": ">=1.2.50", + "@types/node": "*", + "typescript": ">=2.7" + }, + "peerDependenciesMeta": { + "@swc/core": { + "optional": true + }, + "@swc/wasm": { + "optional": true + } + } + }, + "node_modules/type-is": { + "version": "1.6.18", + "resolved": "https://registry.npmjs.org/type-is/-/type-is-1.6.18.tgz", + "integrity": "sha512-TkRKr9sUTxEH8MdfuCSP7VizJyzRNMjj2J2do2Jr3Kym598JVdEksuzPQCnlFPW4ky9Q+iA+ma9BGm06XQBy8g==", + "license": "MIT", + "dependencies": { + "media-typer": "0.3.0", + "mime-types": "~2.1.24" + }, + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/typescript": { + "version": "5.9.3", + "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz", + "integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==", + "dev": true, + "license": "Apache-2.0", + "peer": true, + "bin": { + "tsc": "bin/tsc", + "tsserver": "bin/tsserver" + }, + "engines": { + "node": ">=14.17" + } + }, + "node_modules/undici-types": { + "version": "6.21.0", + "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-6.21.0.tgz", + "integrity": "sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/unpipe": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/unpipe/-/unpipe-1.0.0.tgz", + "integrity": "sha512-pjy2bYhSsufwWlKwPc+l3cN7+wuJlK6uz0YdJEOlQDbl6jo/YlPi4mb8agUkVC8BF7V8NuzeyPNqRksA3hztKQ==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/utils-merge": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/utils-merge/-/utils-merge-1.0.1.tgz", + "integrity": "sha512-pMZTvIkT1d+TFGvDOqodOclx0QWkkgi6Tdoa8gC8ffGAAqz9pzPTZWAybbsHHoED/ztMtkv/VoYTYyShUn81hA==", + "license": "MIT", + "engines": { + "node": ">= 0.4.0" + } + }, + "node_modules/v8-compile-cache-lib": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/v8-compile-cache-lib/-/v8-compile-cache-lib-3.0.1.tgz", + "integrity": "sha512-wa7YjyUGfNZngI/vtK0UHAN+lgDCxBPCylVXGp0zu59Fz5aiGtNXaq3DhIov063MorB+VfufLh3JlF2KdTK3xg==", + "dev": true, + "license": "MIT" + }, + "node_modules/vary": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/vary/-/vary-1.1.2.tgz", + "integrity": "sha512-BNGbWLfd0eUPabhkXUVm0j8uuvREyTh5ovRa/dyow/BqAbZJyC+5fU+IzQOzmAKzYqYRAISoRhdQr3eIZ/PXqg==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/yn": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/yn/-/yn-3.1.1.tgz", + "integrity": "sha512-Ux4ygGWsu2c7isFWe8Yu1YluJmqVhxqK2cLXNQA5AcC3QfbGNpM7fu0Y8b/z16pXLnFxZYvWhd3fhBY9DLmC6Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + } + } +} diff --git a/apps/morphos-server/package.json b/apps/morphos-server/package.json new file mode 100644 index 00000000..17d3a90e --- /dev/null +++ b/apps/morphos-server/package.json @@ -0,0 +1,20 @@ +{ + "name": "morphos-server", + "version": "1.0.0", + "description": "MorphOS server platform", + "main": "dist/index.js", + "scripts": { + "build": "tsc", + "start": "node dist/index.js", + "dev": "ts-node src/index.ts" + }, + "dependencies": { + "express": "^4.18.2" + }, + "devDependencies": { + "@types/express": "^4.17.21", + "@types/node": "^20.10.0", + "typescript": "^5.3.3", + "ts-node": "^10.9.2" + } +} diff --git a/apps/morphos-server/src/index.ts b/apps/morphos-server/src/index.ts new file mode 100644 index 00000000..5fd95a24 --- /dev/null +++ b/apps/morphos-server/src/index.ts @@ -0,0 +1,27 @@ +import express from 'express'; + +const app = express(); +const port = 8080; + +// Middleware +app.use(express.json()); + +// Health check endpoint +app.get('/health', (req, res) => { + res.json({ status: 'ok', service: 'morphos-server', version: '1.0.0' }); +}); + +// API endpoints +app.get('/api/info', (req, res) => { + res.json({ + name: 'MorphOS Server', + version: '1.0.0', + status: 'running' + }); +}); + +// Start server +app.listen(port, '0.0.0.0', () => { + console.log(`MorphOS Server listening on port ${port}`); + console.log(`Data directory: ${process.env.MORPHOS_DATA_DIR || '/app/data'}`); +}); diff --git a/apps/morphos-server/tsconfig.json b/apps/morphos-server/tsconfig.json new file mode 100644 index 00000000..fa8ee324 --- /dev/null +++ b/apps/morphos-server/tsconfig.json @@ -0,0 +1,16 @@ +{ + "compilerOptions": { + "target": "ES2020", + "module": "commonjs", + "lib": ["ES2020"], + "outDir": "./dist", + "rootDir": "./src", + "strict": true, + "esModuleInterop": true, + "skipLibCheck": true, + "forceConsistentCasingInFileNames": true, + "resolveJsonModule": true + }, + "include": ["src/**/*"], + "exclude": ["node_modules", "dist"] +} diff --git a/apps/netbird-dashboard/manifest.yml b/apps/netbird-dashboard/manifest.yml new file mode 100644 index 00000000..dbbb2e67 --- /dev/null +++ b/apps/netbird-dashboard/manifest.yml @@ -0,0 +1,77 @@ +app: + id: netbird-dashboard + name: NetBird Dashboard + version: "2.38.0" + description: NetBird management dashboard (SPA). Internal stack member served through the netbird proxy. + category: networking + + # Hyphen name matches runtime references + the live container (adoption). + # Alias `netbird-dashboard` is the short hostname the proxy's nginx proxies to. + container_name: netbird-dashboard + + container: + image: docker.io/netbirdio/dashboard:v2.38.0 + pull_policy: if-not-present + network: netbird-net + network_aliases: [netbird-dashboard] + # The dashboard SPA bakes its API/OIDC base URL from these at container + # start. They must point at the proxy's public HTTPS origin (8087) so the + # browser uses a secure context (window.crypto.subtle / OIDC PKCE, #15). + # {{HOST_IP}} is the node's primary host IP, resolved at apply time. + derived_env: + - key: NETBIRD_MGMT_API_ENDPOINT + template: "https://{{HOST_IP}}:8087" + - key: NETBIRD_MGMT_GRPC_API_ENDPOINT + template: "https://{{HOST_IP}}:8087" + - key: AUTH_AUTHORITY + template: "https://{{HOST_IP}}:8087/oauth2" + + dependencies: + - app_id: netbird-server + + resources: + memory_limit: 256Mi + + security: + # cap-drop=ALL is applied by the orchestrator. The dashboard image runs + # nginx (master as root, drops workers) binding :80 — needs the worker-drop + # caps + NET_BIND_SERVICE for the privileged port. + capabilities: [CHOWN, DAC_OVERRIDE, SETGID, SETUID, NET_BIND_SERVICE] + readonly_root: false + network_policy: isolated + + # Internal only — reached container-to-container by the proxy via netbird-net. + ports: [] + + volumes: [] + + environment: + - AUTH_AUDIENCE=netbird-dashboard + - AUTH_CLIENT_ID=netbird-dashboard + - AUTH_CLIENT_SECRET= + - USE_AUTH0=false + - AUTH_SUPPORTED_SCOPES=openid profile email groups + - AUTH_REDIRECT_URI=/nb-auth + - AUTH_SILENT_REDIRECT_URI=/nb-silent-auth + - NETBIRD_TOKEN_SOURCE=idToken + - NGINX_SSL_PORT=443 + - LETSENCRYPT_DOMAIN=none + + health_check: + type: tcp + endpoint: localhost:80 + interval: 30s + timeout: 5s + retries: 5 + start_period: 20s + + metadata: + author: NetBird + icon: /assets/img/app-icons/netbird.svg + website: https://netbird.io + repo: https://github.com/netbirdio/dashboard + license: BSD-3-Clause + tags: + - networking + - vpn + - dashboard diff --git a/apps/netbird-server/manifest.yml b/apps/netbird-server/manifest.yml new file mode 100644 index 00000000..cda51af9 --- /dev/null +++ b/apps/netbird-server/manifest.yml @@ -0,0 +1,122 @@ +app: + id: netbird-server + name: NetBird Server + version: "0.71.2" + description: NetBird combined management / signal / relay server with an embedded identity provider and STUN. Backend for the self-hosted NetBird mesh VPN. + category: networking + + # Hyphen name matches the runtime references (crash_recovery / dependencies / + # config startup order) + the live container, so on an existing node the + # orchestrator ADOPTS the running server rather than recreating it (data + + # the sqlite store under /var/lib/netbird preserved). Alias `netbird-server` + # is the short hostname the proxy's nginx proxies/grpc-passes to. + container_name: netbird-server + + container: + image: docker.io/netbirdio/netbird-server:0.71.2 + pull_policy: if-not-present + network: netbird-net + network_aliases: [netbird-server] + # The relay authSecret and the sqlite store encryptionKey are base64 keys + # (the server base64-decodes them to recover raw bytes — hex would decode to + # the wrong value). Generated once and reused: ensure_generated_secrets + # no-ops when the file already exists, so a re-render of config.yaml on an + # adopted node keeps the same keys (regenerating would orphan the store). + generated_secrets: + - name: netbird-relay-auth-secret + kind: base64 + - name: netbird-store-encryption-key + kind: base64 + # Pass the rendered config explicitly, mirroring the legacy `--config` arg. + custom_args: ["--config", "/etc/netbird/config.yaml"] + + dependencies: + - storage: 1Gi + + resources: + memory_limit: 1Gi + + security: + # cap-drop=ALL is applied by the orchestrator. The server binds :80 + # (management/signal/relay HTTP + gRPC) inside the container — a privileged + # port — so it needs NET_BIND_SERVICE. STUN is 3478/udp (unprivileged). + capabilities: [NET_BIND_SERVICE] + readonly_root: false + network_policy: isolated + + ports: + - host: 8086 + container: 80 + protocol: tcp # management API + embedded OIDC issuer (/oauth2) + - host: 3478 + container: 3478 + protocol: udp # STUN — must be UDP; tcp here breaks relay discovery + + volumes: + - type: bind + source: /var/lib/archipelago/netbird/data + target: /var/lib/netbird + options: [rw] + # The rendered config.yaml, read-only. Re-rendered on every reconcile from + # host facts + the base64 secrets; idempotent (stable bytes → no restart). + - type: bind + source: /var/lib/archipelago/netbird/config.yaml + target: /etc/netbird/config.yaml + options: [ro] + + environment: [] + + # The server's config. {{HOST_IP}} is the node's primary host IP (the proxy's + # public origin is https on 8087 — the dashboard needs a secure context for + # OIDC PKCE, issue #15). {{secret:...}} are read 0600 from the secrets dir. + files: + - path: /var/lib/archipelago/netbird/config.yaml + overwrite: true + content: | + server: + listenAddress: ":80" + exposedAddress: "https://{{HOST_IP}}:8087" + stunPorts: + - 3478 + metricsPort: 9090 + healthcheckAddress: ":9000" + logLevel: "info" + logFile: "console" + authSecret: "{{secret:netbird-relay-auth-secret}}" + dataDir: "/var/lib/netbird" + auth: + issuer: "https://{{HOST_IP}}:8087/oauth2" + localAuthDisabled: false + signKeyRefreshEnabled: false + dashboardRedirectURIs: + - "https://{{HOST_IP}}:8087/nb-auth" + - "https://{{HOST_IP}}:8087/nb-silent-auth" + dashboardPostLogoutRedirectURIs: + - "https://{{HOST_IP}}:8087/" + cliRedirectURIs: + - "http://localhost:53000/" + store: + engine: "sqlite" + encryptionKey: "{{secret:netbird-store-encryption-key}}" + + # TCP liveness on the management port. Binds at startup, stays green; an http + # check of /oauth2 would false-fail while the issuer warms up. + health_check: + type: tcp + endpoint: localhost:80 + interval: 30s + timeout: 5s + retries: 10 + start_period: 30s + + metadata: + author: NetBird + icon: /assets/img/app-icons/netbird.svg + website: https://netbird.io + repo: https://github.com/netbirdio/netbird + license: BSD-3-Clause + tags: + - networking + - vpn + - wireguard + - mesh diff --git a/apps/netbird/manifest.yml b/apps/netbird/manifest.yml new file mode 100644 index 00000000..6464335a --- /dev/null +++ b/apps/netbird/manifest.yml @@ -0,0 +1,182 @@ +app: + id: netbird + name: NetBird + version: "2.38.0" + description: Self-hosted WireGuard mesh VPN control plane with dashboard, embedded identity provider, management API, signal, relay, and STUN. The user-facing entry point — a TLS proxy in front of the dashboard + server. + category: networking + + # The user-facing launcher (app_id + container both "netbird", matching the + # runtime references + the live container so the orchestrator adopts it). This + # is the nginx that terminates TLS on 8087 and fans out to the dashboard + + # server by their short aliases on netbird-net. + container_name: netbird + + container: + image: docker.io/library/nginx:1.27-alpine + pull_policy: if-not-present + network: netbird-net + # Self-signed TLS cert materialised before create — the dashboard needs a + # secure context (window.crypto.subtle / OIDC PKCE, issue #15), so the proxy + # serves HTTPS. Idempotent: kept as-is when crt+key already exist (a user + # accepts it once). SAN defaults to the host IP + 127.0.0.1 + localhost. + generated_certs: + - crt: /var/lib/archipelago/netbird/tls.crt + key: /var/lib/archipelago/netbird/tls.key + + dependencies: + - app_id: netbird-server + - app_id: netbird-dashboard + - storage: 1Gi + + resources: + memory_limit: 256Mi + + security: + # cap-drop=ALL is applied by the orchestrator. nginx (master as root, drops + # workers) binds :443 — needs the worker-drop caps + NET_BIND_SERVICE. + capabilities: [CHOWN, DAC_OVERRIDE, SETGID, SETUID, NET_BIND_SERVICE] + readonly_root: false + network_policy: isolated + + ports: + # 8087 publishes the TLS listener (container :443). HTTPS is required for the + # dashboard's secure context (issue #15). + - host: 8087 + container: 443 + protocol: tcp + + volumes: + - type: bind + source: /var/lib/archipelago/netbird/nginx.conf + target: /etc/nginx/conf.d/default.conf + options: [ro] + - type: bind + source: /var/lib/archipelago/netbird/tls.crt + target: /etc/nginx/tls.crt + options: [ro] + - type: bind + source: /var/lib/archipelago/netbird/tls.key + target: /etc/nginx/tls.key + options: [ro] + + environment: [] + + # The proxy config. {{NETWORK_GATEWAY}} is the netbird-net bridge gateway = + # Podman's aardvark DNS. nginx uses it as an explicit `resolver` with VARIABLE + # upstreams so it re-resolves container names per request — without it nginx + # pins a container IP at startup and 502s forever once that IP moves on a + # restart/reboot (issue #15, observed live on .198). Every #15 fix below + # (CORS $http_origin reflect, grpc pass, nb-auth/nb-silent-auth rewrite to + # index.html, /relay websocket) is preserved verbatim from the legacy config. + files: + - path: /var/lib/archipelago/netbird/nginx.conf + overwrite: true + content: | + server { + listen 443 ssl; + server_name _; + + # netbird's dashboard needs a secure context (window.crypto.subtle for + # OIDC PKCE), so the proxy terminates TLS with a self-signed cert (#15). + ssl_certificate /etc/nginx/tls.crt; + ssl_certificate_key /etc/nginx/tls.key; + + # Rootless Podman can hand a container a new IP across restarts/reboots. + # nginx resolves a literal upstream name ONCE at startup and caches it, + # so after the IP moves every request 502s with "host unreachable" + # (issue #15, observed live on .198: nginx pinned to a dead + # netbird-dashboard IP). Fix: point `resolver` at the netbird-net + # gateway (Podman's aardvark DNS) and use VARIABLE upstreams, which + # forces nginx to re-resolve the container names at request time. + resolver {{NETWORK_GATEWAY}} valid=10s ipv6=off; + + proxy_set_header Host $host; + proxy_set_header X-Real-IP $remote_addr; + proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; + proxy_set_header X-Forwarded-Proto $scheme; + proxy_http_version 1.1; + + location ~ ^/(relay|ws-proxy/) { + set $nb_server netbird-server; + proxy_pass http://$nb_server:80; + proxy_set_header Upgrade $http_upgrade; + proxy_set_header Connection "upgrade"; + proxy_read_timeout 1d; + } + + location ~ ^/(api|oauth2)(/|$) { + # The dashboard is a SPA whose API/OIDC base URL is baked at build + # time to one host:port. A single box is reached via several + # addresses, so those fetches are cross-origin and the browser + # blocks them with no Access-Control-Allow-Origin (#15, live on + # .198). Reflect the caller's Origin and answer the CORS preflight. + if ($request_method = OPTIONS) { + add_header Access-Control-Allow-Origin $http_origin always; + add_header Access-Control-Allow-Credentials true always; + add_header Access-Control-Allow-Methods "GET, POST, PUT, PATCH, DELETE, OPTIONS" always; + add_header Access-Control-Allow-Headers "Authorization, Content-Type, Accept" always; + add_header Access-Control-Max-Age 86400 always; + add_header Content-Length 0; + return 204; + } + add_header Access-Control-Allow-Origin $http_origin always; + add_header Access-Control-Allow-Credentials true always; + add_header Access-Control-Allow-Methods "GET, POST, PUT, PATCH, DELETE, OPTIONS" always; + add_header Access-Control-Allow-Headers "Authorization, Content-Type, Accept" always; + set $nb_server netbird-server; + proxy_pass http://$nb_server:80; + } + + location ~ ^/(signalexchange\.SignalExchange|management\.ManagementService|management\.ProxyService)/ { + set $nb_server netbird-server; + grpc_pass grpc://$nb_server:80; + grpc_read_timeout 1d; + grpc_send_timeout 1d; + } + + # OIDC callback routes are client-side SPA routes with NO prebuilt page + # in the dashboard bundle, so proxying them straight through 404s — + # which crashes the dashboard's auth init and shows "Unauthenticated" + # with dead buttons (#15, live on .198: /nb-auth + /nb-silent-auth + # returned 404). Serve index.html at these paths (URL unchanged) so + # react-oidc boots and completes the login / silent-SSO. + location ~ ^/(nb-auth|nb-silent-auth) { + set $nb_dashboard netbird-dashboard; + rewrite ^.*$ /index.html break; + proxy_pass http://$nb_dashboard:80; + } + + location / { + set $nb_dashboard netbird-dashboard; + proxy_pass http://$nb_dashboard:80; + } + } + + health_check: + type: tcp + endpoint: localhost:443 + interval: 30s + timeout: 5s + retries: 5 + start_period: 20s + + interfaces: + main: + name: Dashboard + description: Manage your self-hosted NetBird mesh VPN + type: ui + port: 8087 + protocol: https + path: / + + metadata: + author: NetBird + icon: /assets/img/app-icons/netbird.svg + website: https://netbird.io + repo: https://github.com/netbirdio/netbird + license: BSD-3-Clause + tags: + - networking + - vpn + - wireguard + - mesh diff --git a/apps/nextcloud/manifest.yml b/apps/nextcloud/manifest.yml new file mode 100644 index 00000000..a8165868 --- /dev/null +++ b/apps/nextcloud/manifest.yml @@ -0,0 +1,59 @@ +app: + id: nextcloud + name: Nextcloud + version: "29" + description: Your own private cloud. File sync, calendars, contacts. + + container: + image: 146.59.87.168:3000/lfg2025/nextcloud:29 + pull_policy: if-not-present + network: pasta + + dependencies: + - storage: 10Gi + + resources: + memory_limit: 1Gi + disk_limit: 10Gi + + security: + capabilities: [CHOWN, SETUID, SETGID, DAC_OVERRIDE, NET_BIND_SERVICE] + readonly_root: false + network_policy: isolated + + ports: + - host: 8085 + container: 80 + protocol: tcp + + volumes: + - type: bind + source: /var/lib/archipelago/nextcloud + target: /var/www/html + options: [rw] + + environment: [] + + health_check: + type: tcp + endpoint: localhost:80 + interval: 30s + timeout: 5s + retries: 3 + + interfaces: + main: + name: Web UI + description: Nextcloud file and collaboration dashboard + type: ui + port: 8085 + protocol: http + path: / + + metadata: + icon: /assets/img/app-icons/nextcloud.webp + category: data + author: Nextcloud + repo: https://github.com/nextcloud/server + launch: + open_in_new_tab: true diff --git a/apps/nostr-rs-relay/Dockerfile b/apps/nostr-rs-relay/Dockerfile new file mode 100644 index 00000000..14182fdd --- /dev/null +++ b/apps/nostr-rs-relay/Dockerfile @@ -0,0 +1,5 @@ +# Nostr RS Relay - uses official image +FROM scsibug/nostr-rs-relay:latest + +# Default configuration is in the image +# No additional setup needed diff --git a/apps/nostr-rs-relay/manifest.yml b/apps/nostr-rs-relay/manifest.yml new file mode 100644 index 00000000..975a07ae --- /dev/null +++ b/apps/nostr-rs-relay/manifest.yml @@ -0,0 +1,58 @@ +app: + id: nostr-rs-relay + name: Nostr Relay (Rust) + version: 0.8.0 + description: High-performance Nostr relay written in Rust. Host your own decentralized social media relay and earn networking profits. + + container: + image: scsibug/nostr-rs-relay:0.8.9 + image_signature: cosign://... + pull_policy: verify-signature + data_uid: "1000:1000" + + dependencies: + - storage: 10Gi # For event storage + + resources: + cpu_limit: 2 + memory_limit: 1Gi + disk_limit: 10Gi + + security: + capabilities: [] + readonly_root: true + no_new_privileges: true + user: 1000 + seccomp_profile: default + network_policy: isolated + apparmor_profile: nostr-relay + + ports: + - host: 18081 + container: 8080 + protocol: tcp # HTTP/WebSocket + + volumes: + - type: bind + source: /var/lib/archipelago/nostr-relay + target: /usr/src/app/db + options: [rw] + + environment: + - RELAY_NAME=Archipelago Nostr Relay + - RELAY_DESCRIPTION=Self-hosted Nostr relay on Archipelago + - MAX_EVENTS=1000000 + - MAX_SUBSCRIPTIONS=100 + + health_check: + type: http + endpoint: http://localhost:8080 + path: / + interval: 30s + timeout: 30s + retries: 5 + + nostr_integration: + relay_type: public + monetization_enabled: true # Earn networking profits + event_storage: sqlite diff --git a/apps/photoprism/manifest.yml b/apps/photoprism/manifest.yml new file mode 100644 index 00000000..485d5936 --- /dev/null +++ b/apps/photoprism/manifest.yml @@ -0,0 +1,60 @@ +app: + id: photoprism + name: PhotoPrism + version: "240915" + description: AI-powered photo management with facial recognition. + + container: + image: 146.59.87.168:3000/lfg2025/photoprism:240915 + pull_policy: if-not-present + + dependencies: + - storage: 10Gi + + resources: + memory_limit: 1Gi + disk_limit: 10Gi + + security: + capabilities: [CHOWN, SETUID, SETGID] + readonly_root: false + network_policy: isolated + + ports: + - host: 2342 + container: 2342 + protocol: tcp + + volumes: + - type: bind + source: /var/lib/archipelago/photoprism + target: /photoprism/storage + options: [rw] + + environment: + - PHOTOPRISM_ADMIN_PASSWORD=archipelago + - PHOTOPRISM_DEFAULT_LOCALE=en + + health_check: + type: tcp + endpoint: localhost:2342 + interval: 60s + timeout: 5s + retries: 3 + + interfaces: + main: + name: Web UI + description: PhotoPrism photo library + type: ui + port: 2342 + protocol: http + path: / + + metadata: + icon: /assets/img/app-icons/photoprism.svg + category: data + author: PhotoPrism + repo: https://github.com/photoprism/photoprism + launch: + open_in_new_tab: true diff --git a/apps/portainer/manifest.yml b/apps/portainer/manifest.yml new file mode 100644 index 00000000..2307b504 --- /dev/null +++ b/apps/portainer/manifest.yml @@ -0,0 +1,64 @@ +app: + id: portainer + name: Portainer + version: 2.19.4 + description: Container management web UI for the local Podman socket. + category: development + + container: + image: 146.59.87.168:3000/lfg2025/portainer:2.19.4 + pull_policy: if-not-present + data_uid: "1000:1000" + + dependencies: + - storage: 1Gi + + resources: + memory_limit: 256Mi + disk_limit: 1Gi + + security: + capabilities: [CHOWN, SETUID, SETGID, DAC_OVERRIDE] + readonly_root: false + no_new_privileges: true + network_policy: isolated + + ports: + - host: 9000 + container: 9000 + protocol: tcp + + volumes: + - type: bind + source: /var/lib/archipelago/portainer + target: /data + options: [rw] + - type: bind + source: /var/lib/archipelago/portainer/compose + target: /data/compose + options: [rw] + - type: bind + source: /run/user/1000/podman/podman.sock + target: /var/run/docker.sock + options: [rw] + + environment: [] + + interfaces: + main: + name: Web UI + description: Portainer web interface + type: ui + port: 9000 + protocol: http + path: / + + metadata: + icon: /assets/img/app-icons/portainer.webp + tier: optional + launch: + open_in_new_tab: true + features: + - Container management dashboard + - Local Podman socket access + - Compose stack storage diff --git a/apps/router/.dockerignore b/apps/router/.dockerignore new file mode 100644 index 00000000..e052d6d1 --- /dev/null +++ b/apps/router/.dockerignore @@ -0,0 +1,6 @@ +node_modules +dist +*.log +.git +.gitignore +README.md diff --git a/apps/router/Dockerfile b/apps/router/Dockerfile new file mode 100644 index 00000000..8c8d0b22 --- /dev/null +++ b/apps/router/Dockerfile @@ -0,0 +1,40 @@ +FROM node:20-alpine AS builder + +WORKDIR /app + +# Copy package files +COPY package*.json ./ +RUN npm ci --only=production + +# Copy source code +COPY . . + +# Build the application +RUN npm run build + +# Production stage +FROM node:20-alpine + +WORKDIR /app + +# Install runtime dependencies +RUN apk add --no-cache \ + dbus \ + avahi \ + avahi-tools + +# Copy built application +COPY --from=builder /app/dist ./dist +COPY --from=builder /app/node_modules ./node_modules +COPY --from=builder /app/package.json ./ + +# Create non-root user +RUN addgroup -g 1000 appuser && \ + adduser -D -u 1000 -G appuser appuser && \ + chown -R appuser:appuser /app + +USER appuser + +EXPOSE 8080 5353 1900 + +CMD ["node", "dist/index.js"] diff --git a/apps/router/README.md b/apps/router/README.md new file mode 100644 index 00000000..21c2972d --- /dev/null +++ b/apps/router/README.md @@ -0,0 +1,38 @@ +# Archipelago Router + +Mesh routing and local network management for Archipelago. + +## Building + +```bash +# From the apps directory +./build.sh router + +# Or manually +cd router +docker build -t archipelago/router:latest . +# or +podman build -t archipelago/router:latest . +``` + +## Development + +```bash +cd router +npm install +npm run dev +``` + +## Ports + +- **8084**: Web UI (dev: 18084) +- **5353**: mDNS/Bonjour (dev: 15353) +- **1900**: SSDP (dev: 11900) + +## Running Locally + +```bash +docker run -p 8084:8080 -p 5353:5353/udp -p 1900:1900/udp \ + -v /tmp/archipelago-dev/router:/app/data \ + archipelago/router:latest +``` diff --git a/apps/router/manifest.yml b/apps/router/manifest.yml new file mode 100644 index 00000000..5bf88a1a --- /dev/null +++ b/apps/router/manifest.yml @@ -0,0 +1,67 @@ +app: + id: router + name: Mesh Router + version: 1.0.0 + description: Mesh routing and local network management. Provides device discovery, routing, and network topology visualization. + + container: + image: archipelago/router:1.0.0 + image_signature: cosign://... + pull_policy: if-not-present + + dependencies: + - storage: 500Mi + + resources: + cpu_limit: 2 + memory_limit: 512Mi + disk_limit: 500Mi + + security: + capabilities: [NET_ADMIN, NET_RAW] # Required for network management + readonly_root: true + no_new_privileges: true + user: 1000 + seccomp_profile: default + network_policy: host # Requires host network for routing + apparmor_profile: router + + ports: + - host: 8084 + container: 8080 + protocol: tcp # Web UI + - host: 5353 + container: 5353 + protocol: udp # mDNS/Bonjour + - host: 1900 + container: 1900 + protocol: udp # SSDP + + volumes: + - type: bind + source: /var/lib/archipelago/router + target: /app/data + options: [rw] + - type: bind + source: /var/run/dbus + target: /var/run/dbus + options: [ro] + + environment: + - NETWORK_INTERFACE=eth0 + - MESH_ENABLED=true + - DEVICE_DISCOVERY=true + + health_check: + type: http + endpoint: http://localhost:8084 + path: /health + interval: 30s + timeout: 5s + retries: 3 + + networking: + mesh_enabled: true + local_network_access: true + device_discovery: true + routing_protocols: [olsr, babel] diff --git a/apps/router/package-lock.json b/apps/router/package-lock.json new file mode 100644 index 00000000..659156ad --- /dev/null +++ b/apps/router/package-lock.json @@ -0,0 +1,1486 @@ +{ + "name": "archipelago-router", + "version": "1.0.0", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "archipelago-router", + "version": "1.0.0", + "dependencies": { + "bonjour": "^3.5.0", + "express": "^4.18.2", + "network-interfaces": "^1.1.0" + }, + "devDependencies": { + "@types/express": "^4.17.21", + "@types/node": "^20.10.0", + "ts-node": "^10.9.2", + "typescript": "^5.3.3" + } + }, + "node_modules/@cspotcode/source-map-support": { + "version": "0.8.1", + "resolved": "https://registry.npmjs.org/@cspotcode/source-map-support/-/source-map-support-0.8.1.tgz", + "integrity": "sha512-IchNf6dN4tHoMFIn/7OE8LWZ19Y6q/67Bmf6vnGREv8RSbBVb9LPJxEcnwrcwX6ixSvaiGoomAUvu4YSxXrVgw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/trace-mapping": "0.3.9" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/@jridgewell/resolve-uri": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/@jridgewell/resolve-uri/-/resolve-uri-3.1.2.tgz", + "integrity": "sha512-bRISgCIjP20/tbWSPWMEi54QVPRZExkuD9lJL+UIxUKtwVJA8wW1Trb1jMs1RFXo1CBTNZ/5hpC9QvmKWdopKw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/@jridgewell/sourcemap-codec": { + "version": "1.5.5", + "resolved": "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.5.5.tgz", + "integrity": "sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og==", + "dev": true, + "license": "MIT" + }, + "node_modules/@jridgewell/trace-mapping": { + "version": "0.3.9", + "resolved": "https://registry.npmjs.org/@jridgewell/trace-mapping/-/trace-mapping-0.3.9.tgz", + "integrity": "sha512-3Belt6tdc8bPgAtbcmdtNJlirVoTmEb5e2gC94PnkwEW9jI6CAHUeoG85tjWP5WquqfavoMtMwiG4P926ZKKuQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/resolve-uri": "^3.0.3", + "@jridgewell/sourcemap-codec": "^1.4.10" + } + }, + "node_modules/@tsconfig/node10": { + "version": "1.0.12", + "resolved": "https://registry.npmjs.org/@tsconfig/node10/-/node10-1.0.12.tgz", + "integrity": "sha512-UCYBaeFvM11aU2y3YPZ//O5Rhj+xKyzy7mvcIoAjASbigy8mHMryP5cK7dgjlz2hWxh1g5pLw084E0a/wlUSFQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/@tsconfig/node12": { + "version": "1.0.11", + "resolved": "https://registry.npmjs.org/@tsconfig/node12/-/node12-1.0.11.tgz", + "integrity": "sha512-cqefuRsh12pWyGsIoBKJA9luFu3mRxCA+ORZvA4ktLSzIuCUtWVxGIuXigEwO5/ywWFMZ2QEGKWvkZG1zDMTag==", + "dev": true, + "license": "MIT" + }, + "node_modules/@tsconfig/node14": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/@tsconfig/node14/-/node14-1.0.3.tgz", + "integrity": "sha512-ysT8mhdixWK6Hw3i1V2AeRqZ5WfXg1G43mqoYlM2nc6388Fq5jcXyr5mRsqViLx/GJYdoL0bfXD8nmF+Zn/Iow==", + "dev": true, + "license": "MIT" + }, + "node_modules/@tsconfig/node16": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/@tsconfig/node16/-/node16-1.0.4.tgz", + "integrity": "sha512-vxhUy4J8lyeyinH7Azl1pdd43GJhZH/tP2weN8TntQblOY+A0XbT8DJk1/oCPuOOyg/Ja757rG0CgHcWC8OfMA==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/body-parser": { + "version": "1.19.6", + "resolved": "https://registry.npmjs.org/@types/body-parser/-/body-parser-1.19.6.tgz", + "integrity": "sha512-HLFeCYgz89uk22N5Qg3dvGvsv46B8GLvKKo1zKG4NybA8U2DiEO3w9lqGg29t/tfLRJpJ6iQxnVw4OnB7MoM9g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/connect": "*", + "@types/node": "*" + } + }, + "node_modules/@types/connect": { + "version": "3.4.38", + "resolved": "https://registry.npmjs.org/@types/connect/-/connect-3.4.38.tgz", + "integrity": "sha512-K6uROf1LD88uDQqJCktA4yzL1YYAK6NgfsI0v/mTgyPKWsX1CnJ0XPSDhViejru1GcRkLWb8RlzFYJRqGUbaug==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/node": "*" + } + }, + "node_modules/@types/express": { + "version": "4.17.25", + "resolved": "https://registry.npmjs.org/@types/express/-/express-4.17.25.tgz", + "integrity": "sha512-dVd04UKsfpINUnK0yBoYHDF3xu7xVH4BuDotC/xGuycx4CgbP48X/KF/586bcObxT0HENHXEU8Nqtu6NR+eKhw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/body-parser": "*", + "@types/express-serve-static-core": "^4.17.33", + "@types/qs": "*", + "@types/serve-static": "^1" + } + }, + "node_modules/@types/express-serve-static-core": { + "version": "4.19.8", + "resolved": "https://registry.npmjs.org/@types/express-serve-static-core/-/express-serve-static-core-4.19.8.tgz", + "integrity": "sha512-02S5fmqeoKzVZCHPZid4b8JH2eM5HzQLZWN2FohQEy/0eXTq8VXZfSN6Pcr3F6N9R/vNrj7cpgbhjie6m/1tCA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/node": "*", + "@types/qs": "*", + "@types/range-parser": "*", + "@types/send": "*" + } + }, + "node_modules/@types/http-errors": { + "version": "2.0.5", + "resolved": "https://registry.npmjs.org/@types/http-errors/-/http-errors-2.0.5.tgz", + "integrity": "sha512-r8Tayk8HJnX0FztbZN7oVqGccWgw98T/0neJphO91KkmOzug1KkofZURD4UaD5uH8AqcFLfdPErnBod0u71/qg==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/mime": { + "version": "1.3.5", + "resolved": "https://registry.npmjs.org/@types/mime/-/mime-1.3.5.tgz", + "integrity": "sha512-/pyBZWSLD2n0dcHE3hq8s8ZvcETHtEuF+3E7XVt0Ig2nvsVQXdghHVcEkIWjy9A0wKfTn97a/PSDYohKIlnP/w==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/node": { + "version": "20.19.30", + "resolved": "https://registry.npmjs.org/@types/node/-/node-20.19.30.tgz", + "integrity": "sha512-WJtwWJu7UdlvzEAUm484QNg5eAoq5QR08KDNx7g45Usrs2NtOPiX8ugDqmKdXkyL03rBqU5dYNYVQetEpBHq2g==", + "dev": true, + "license": "MIT", + "peer": true, + "dependencies": { + "undici-types": "~6.21.0" + } + }, + "node_modules/@types/qs": { + "version": "6.14.0", + "resolved": "https://registry.npmjs.org/@types/qs/-/qs-6.14.0.tgz", + "integrity": "sha512-eOunJqu0K1923aExK6y8p6fsihYEn/BYuQ4g0CxAAgFc4b/ZLN4CrsRZ55srTdqoiLzU2B2evC+apEIxprEzkQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/range-parser": { + "version": "1.2.7", + "resolved": "https://registry.npmjs.org/@types/range-parser/-/range-parser-1.2.7.tgz", + "integrity": "sha512-hKormJbkJqzQGhziax5PItDUTMAM9uE2XXQmM37dyd4hVM+5aVl7oVxMVUiVQn2oCQFN/LKCZdvSM0pFRqbSmQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/send": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/@types/send/-/send-1.2.1.tgz", + "integrity": "sha512-arsCikDvlU99zl1g69TcAB3mzZPpxgw0UQnaHeC1Nwb015xp8bknZv5rIfri9xTOcMuaVgvabfIRA7PSZVuZIQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/node": "*" + } + }, + "node_modules/@types/serve-static": { + "version": "1.15.10", + "resolved": "https://registry.npmjs.org/@types/serve-static/-/serve-static-1.15.10.tgz", + "integrity": "sha512-tRs1dB+g8Itk72rlSI2ZrW6vZg0YrLI81iQSTkMmOqnqCaNr/8Ek4VwWcN5vZgCYWbg/JJSGBlUaYGAOP73qBw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/http-errors": "*", + "@types/node": "*", + "@types/send": "<1" + } + }, + "node_modules/@types/serve-static/node_modules/@types/send": { + "version": "0.17.6", + "resolved": "https://registry.npmjs.org/@types/send/-/send-0.17.6.tgz", + "integrity": "sha512-Uqt8rPBE8SY0RK8JB1EzVOIZ32uqy8HwdxCnoCOsYrvnswqmFZ/k+9Ikidlk/ImhsdvBsloHbAlewb2IEBV/Og==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/mime": "^1", + "@types/node": "*" + } + }, + "node_modules/accepts": { + "version": "1.3.8", + "resolved": "https://registry.npmjs.org/accepts/-/accepts-1.3.8.tgz", + "integrity": "sha512-PYAthTa2m2VKxuvSD3DPC/Gy+U+sOA1LAuT8mkmRuvw+NACSaeXEQ+NHcVF7rONl6qcaxV3Uuemwawk+7+SJLw==", + "license": "MIT", + "dependencies": { + "mime-types": "~2.1.34", + "negotiator": "0.6.3" + }, + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/acorn": { + "version": "8.15.0", + "resolved": "https://registry.npmjs.org/acorn/-/acorn-8.15.0.tgz", + "integrity": "sha512-NZyJarBfL7nWwIq+FDL6Zp/yHEhePMNnnJ0y3qfieCrmNvYct8uvtiV41UvlSe6apAfk0fY1FbWx+NwfmpvtTg==", + "dev": true, + "license": "MIT", + "bin": { + "acorn": "bin/acorn" + }, + "engines": { + "node": ">=0.4.0" + } + }, + "node_modules/acorn-walk": { + "version": "8.3.4", + "resolved": "https://registry.npmjs.org/acorn-walk/-/acorn-walk-8.3.4.tgz", + "integrity": "sha512-ueEepnujpqee2o5aIYnvHU6C0A42MNdsIDeqy5BydrkuC5R1ZuUFnm27EeFJGoEHJQgn3uleRvmTXaJgfXbt4g==", + "dev": true, + "license": "MIT", + "dependencies": { + "acorn": "^8.11.0" + }, + "engines": { + "node": ">=0.4.0" + } + }, + "node_modules/arg": { + "version": "4.1.3", + "resolved": "https://registry.npmjs.org/arg/-/arg-4.1.3.tgz", + "integrity": "sha512-58S9QDqG0Xx27YwPSt9fJxivjYl432YCwfDMfZ+71RAqUrZef7LrKQZ3LHLOwCS4FLNBplP533Zx895SeOCHvA==", + "dev": true, + "license": "MIT" + }, + "node_modules/array-flatten": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/array-flatten/-/array-flatten-2.1.2.tgz", + "integrity": "sha512-hNfzcOV8W4NdualtqBFPyVO+54DSJuZGY9qT4pRroB6S9e3iiido2ISIC5h9R2sPJ8H3FHCIiEnsv1lPXO3KtQ==", + "license": "MIT" + }, + "node_modules/body-parser": { + "version": "1.20.4", + "resolved": "https://registry.npmjs.org/body-parser/-/body-parser-1.20.4.tgz", + "integrity": "sha512-ZTgYYLMOXY9qKU/57FAo8F+HA2dGX7bqGc71txDRC1rS4frdFI5R7NhluHxH6M0YItAP0sHB4uqAOcYKxO6uGA==", + "license": "MIT", + "dependencies": { + "bytes": "~3.1.2", + "content-type": "~1.0.5", + "debug": "2.6.9", + "depd": "2.0.0", + "destroy": "~1.2.0", + "http-errors": "~2.0.1", + "iconv-lite": "~0.4.24", + "on-finished": "~2.4.1", + "qs": "~6.14.0", + "raw-body": "~2.5.3", + "type-is": "~1.6.18", + "unpipe": "~1.0.0" + }, + "engines": { + "node": ">= 0.8", + "npm": "1.2.8000 || >= 1.4.16" + } + }, + "node_modules/bonjour": { + "version": "3.5.0", + "resolved": "https://registry.npmjs.org/bonjour/-/bonjour-3.5.0.tgz", + "integrity": "sha512-RaVTblr+OnEli0r/ud8InrU7D+G0y6aJhlxaLa6Pwty4+xoxboF1BsUI45tujvRpbj9dQVoglChqonGAsjEBYg==", + "license": "MIT", + "dependencies": { + "array-flatten": "^2.1.0", + "deep-equal": "^1.0.1", + "dns-equal": "^1.0.0", + "dns-txt": "^2.0.2", + "multicast-dns": "^6.0.1", + "multicast-dns-service-types": "^1.1.0" + } + }, + "node_modules/buffer-indexof": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/buffer-indexof/-/buffer-indexof-1.1.1.tgz", + "integrity": "sha512-4/rOEg86jivtPTeOUUT61jJO1Ya1TrR/OkqCSZDyq84WJh3LuuiphBYJN+fm5xufIk4XAFcEwte/8WzC8If/1g==", + "license": "MIT" + }, + "node_modules/bytes": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/bytes/-/bytes-3.1.2.tgz", + "integrity": "sha512-/Nf7TyzTx6S3yRJObOAV7956r8cr2+Oj8AC5dt8wSP3BQAoeX58NoHyCU8P8zGkNXStjTSi6fzO6F0pBdcYbEg==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/call-bind": { + "version": "1.0.8", + "resolved": "https://registry.npmjs.org/call-bind/-/call-bind-1.0.8.tgz", + "integrity": "sha512-oKlSFMcMwpUg2ednkhQ454wfWiU/ul3CkJe/PEHcTKuiX6RpbehUiFMXu13HalGZxfUwCQzZG747YXBn1im9ww==", + "license": "MIT", + "dependencies": { + "call-bind-apply-helpers": "^1.0.0", + "es-define-property": "^1.0.0", + "get-intrinsic": "^1.2.4", + "set-function-length": "^1.2.2" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/call-bind-apply-helpers": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/call-bind-apply-helpers/-/call-bind-apply-helpers-1.0.2.tgz", + "integrity": "sha512-Sp1ablJ0ivDkSzjcaJdxEunN5/XvksFJ2sMBFfq6x0ryhQV/2b/KwFe21cMpmHtPOSij8K99/wSfoEuTObmuMQ==", + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "function-bind": "^1.1.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/call-bound": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/call-bound/-/call-bound-1.0.4.tgz", + "integrity": "sha512-+ys997U96po4Kx/ABpBCqhA9EuxJaQWDQg7295H4hBphv3IZg0boBKuwYpt4YXp6MZ5AmZQnU/tyMTlRpaSejg==", + "license": "MIT", + "dependencies": { + "call-bind-apply-helpers": "^1.0.2", + "get-intrinsic": "^1.3.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/content-disposition": { + "version": "0.5.4", + "resolved": "https://registry.npmjs.org/content-disposition/-/content-disposition-0.5.4.tgz", + "integrity": "sha512-FveZTNuGw04cxlAiWbzi6zTAL/lhehaWbTtgluJh4/E95DqMwTmha3KZN1aAWA8cFIhHzMZUvLevkw5Rqk+tSQ==", + "license": "MIT", + "dependencies": { + "safe-buffer": "5.2.1" + }, + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/content-type": { + "version": "1.0.5", + "resolved": "https://registry.npmjs.org/content-type/-/content-type-1.0.5.tgz", + "integrity": "sha512-nTjqfcBFEipKdXCv4YDQWCfmcLZKm81ldF0pAopTvyrFGVbcR6P/VAAd5G7N+0tTr8QqiU0tFadD6FK4NtJwOA==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/cookie": { + "version": "0.7.2", + "resolved": "https://registry.npmjs.org/cookie/-/cookie-0.7.2.tgz", + "integrity": "sha512-yki5XnKuf750l50uGTllt6kKILY4nQ1eNIQatoXEByZ5dWgnKqbnqmTrBE5B4N7lrMJKQ2ytWMiTO2o0v6Ew/w==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/cookie-signature": { + "version": "1.0.7", + "resolved": "https://registry.npmjs.org/cookie-signature/-/cookie-signature-1.0.7.tgz", + "integrity": "sha512-NXdYc3dLr47pBkpUCHtKSwIOQXLVn8dZEuywboCOJY/osA0wFSLlSawr3KN8qXJEyX66FcONTH8EIlVuK0yyFA==", + "license": "MIT" + }, + "node_modules/create-require": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/create-require/-/create-require-1.1.1.tgz", + "integrity": "sha512-dcKFX3jn0MpIaXjisoRvexIJVEKzaq7z2rZKxf+MSr9TkdmHmsU4m2lcLojrj/FHl8mk5VxMmYA+ftRkP/3oKQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/debug": { + "version": "2.6.9", + "resolved": "https://registry.npmjs.org/debug/-/debug-2.6.9.tgz", + "integrity": "sha512-bC7ElrdJaJnPbAP+1EotYvqZsb3ecl5wi6Bfi6BJTUcNowp6cvspg0jXznRTKDjm/E7AdgFBVeAPVMNcKGsHMA==", + "license": "MIT", + "dependencies": { + "ms": "2.0.0" + } + }, + "node_modules/deep-equal": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/deep-equal/-/deep-equal-1.1.2.tgz", + "integrity": "sha512-5tdhKF6DbU7iIzrIOa1AOUt39ZRm13cmL1cGEh//aqR8x9+tNfbywRf0n5FD/18OKMdo7DNEtrX2t22ZAkI+eg==", + "license": "MIT", + "dependencies": { + "is-arguments": "^1.1.1", + "is-date-object": "^1.0.5", + "is-regex": "^1.1.4", + "object-is": "^1.1.5", + "object-keys": "^1.1.1", + "regexp.prototype.flags": "^1.5.1" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/define-data-property": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/define-data-property/-/define-data-property-1.1.4.tgz", + "integrity": "sha512-rBMvIzlpA8v6E+SJZoo++HAYqsLrkg7MSfIinMPFhmkorw7X+dOXVJQs+QT69zGkzMyfDnIMN2Wid1+NbL3T+A==", + "license": "MIT", + "dependencies": { + "es-define-property": "^1.0.0", + "es-errors": "^1.3.0", + "gopd": "^1.0.1" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/define-properties": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/define-properties/-/define-properties-1.2.1.tgz", + "integrity": "sha512-8QmQKqEASLd5nx0U1B1okLElbUuuttJ/AnYmRXbbbGDWh6uS208EjD4Xqq/I9wK7u0v6O08XhTWnt5XtEbR6Dg==", + "license": "MIT", + "dependencies": { + "define-data-property": "^1.0.1", + "has-property-descriptors": "^1.0.0", + "object-keys": "^1.1.1" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/depd": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/depd/-/depd-2.0.0.tgz", + "integrity": "sha512-g7nH6P6dyDioJogAAGprGpCtVImJhpPk/roCzdb3fIh61/s/nPsfR6onyMwkCAR/OlC3yBC0lESvUoQEAssIrw==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/destroy": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/destroy/-/destroy-1.2.0.tgz", + "integrity": "sha512-2sJGJTaXIIaR1w4iJSNoN0hnMY7Gpc/n8D4qSCJw8QqFWXf7cuAgnEHxBpweaVcPevC2l3KpjYCx3NypQQgaJg==", + "license": "MIT", + "engines": { + "node": ">= 0.8", + "npm": "1.2.8000 || >= 1.4.16" + } + }, + "node_modules/diff": { + "version": "4.0.4", + "resolved": "https://registry.npmjs.org/diff/-/diff-4.0.4.tgz", + "integrity": "sha512-X07nttJQkwkfKfvTPG/KSnE2OMdcUCao6+eXF3wmnIQRn2aPAHH3VxDbDOdegkd6JbPsXqShpvEOHfAT+nCNwQ==", + "dev": true, + "license": "BSD-3-Clause", + "engines": { + "node": ">=0.3.1" + } + }, + "node_modules/dns-equal": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/dns-equal/-/dns-equal-1.0.0.tgz", + "integrity": "sha512-z+paD6YUQsk+AbGCEM4PrOXSss5gd66QfcVBFTKR/HpFL9jCqikS94HYwKww6fQyO7IxrIIyUu+g0Ka9tUS2Cg==", + "license": "MIT" + }, + "node_modules/dns-packet": { + "version": "1.3.4", + "resolved": "https://registry.npmjs.org/dns-packet/-/dns-packet-1.3.4.tgz", + "integrity": "sha512-BQ6F4vycLXBvdrJZ6S3gZewt6rcrks9KBgM9vrhW+knGRqc8uEdT7fuCwloc7nny5xNoMJ17HGH0R/6fpo8ECA==", + "license": "MIT", + "dependencies": { + "ip": "^1.1.0", + "safe-buffer": "^5.0.1" + } + }, + "node_modules/dns-txt": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/dns-txt/-/dns-txt-2.0.2.tgz", + "integrity": "sha512-Ix5PrWjphuSoUXV/Zv5gaFHjnaJtb02F2+Si3Ht9dyJ87+Z/lMmy+dpNHtTGraNK958ndXq2i+GLkWsWHcKaBQ==", + "license": "MIT", + "dependencies": { + "buffer-indexof": "^1.0.0" + } + }, + "node_modules/dunder-proto": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/dunder-proto/-/dunder-proto-1.0.1.tgz", + "integrity": "sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A==", + "license": "MIT", + "dependencies": { + "call-bind-apply-helpers": "^1.0.1", + "es-errors": "^1.3.0", + "gopd": "^1.2.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/ee-first": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/ee-first/-/ee-first-1.1.1.tgz", + "integrity": "sha512-WMwm9LhRUo+WUaRN+vRuETqG89IgZphVSNkdFgeb6sS/E4OrDIN7t48CAewSHXc6C8lefD8KKfr5vY61brQlow==", + "license": "MIT" + }, + "node_modules/encodeurl": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/encodeurl/-/encodeurl-2.0.0.tgz", + "integrity": "sha512-Q0n9HRi4m6JuGIV1eFlmvJB7ZEVxu93IrMyiMsGC0lrMJMWzRgx6WGquyfQgZVb31vhGgXnfmPNNXmxnOkRBrg==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/es-define-property": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/es-define-property/-/es-define-property-1.0.1.tgz", + "integrity": "sha512-e3nRfgfUZ4rNGL232gUgX06QNyyez04KdjFrF+LTRoOXmrOgFKDg4BCdsjW8EnT69eqdYGmRpJwiPVYNrCaW3g==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/es-errors": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/es-errors/-/es-errors-1.3.0.tgz", + "integrity": "sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/es-object-atoms": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/es-object-atoms/-/es-object-atoms-1.1.1.tgz", + "integrity": "sha512-FGgH2h8zKNim9ljj7dankFPcICIK9Cp5bm+c2gQSYePhpaG5+esrLODihIorn+Pe6FGJzWhXQotPv73jTaldXA==", + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/escape-html": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/escape-html/-/escape-html-1.0.3.tgz", + "integrity": "sha512-NiSupZ4OeuGwr68lGIeym/ksIZMJodUGOSCZ/FSnTxcrekbvqrgdUxlJOMpijaKZVjAJrWrGs/6Jy8OMuyj9ow==", + "license": "MIT" + }, + "node_modules/etag": { + "version": "1.8.1", + "resolved": "https://registry.npmjs.org/etag/-/etag-1.8.1.tgz", + "integrity": "sha512-aIL5Fx7mawVa300al2BnEE4iNvo1qETxLrPI/o05L7z6go7fCw1J6EQmbK4FmJ2AS7kgVF/KEZWufBfdClMcPg==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/express": { + "version": "4.22.1", + "resolved": "https://registry.npmjs.org/express/-/express-4.22.1.tgz", + "integrity": "sha512-F2X8g9P1X7uCPZMA3MVf9wcTqlyNp7IhH5qPCI0izhaOIYXaW9L535tGA3qmjRzpH+bZczqq7hVKxTR4NWnu+g==", + "license": "MIT", + "dependencies": { + "accepts": "~1.3.8", + "array-flatten": "1.1.1", + "body-parser": "~1.20.3", + "content-disposition": "~0.5.4", + "content-type": "~1.0.4", + "cookie": "~0.7.1", + "cookie-signature": "~1.0.6", + "debug": "2.6.9", + "depd": "2.0.0", + "encodeurl": "~2.0.0", + "escape-html": "~1.0.3", + "etag": "~1.8.1", + "finalhandler": "~1.3.1", + "fresh": "~0.5.2", + "http-errors": "~2.0.0", + "merge-descriptors": "1.0.3", + "methods": "~1.1.2", + "on-finished": "~2.4.1", + "parseurl": "~1.3.3", + "path-to-regexp": "~0.1.12", + "proxy-addr": "~2.0.7", + "qs": "~6.14.0", + "range-parser": "~1.2.1", + "safe-buffer": "5.2.1", + "send": "~0.19.0", + "serve-static": "~1.16.2", + "setprototypeof": "1.2.0", + "statuses": "~2.0.1", + "type-is": "~1.6.18", + "utils-merge": "1.0.1", + "vary": "~1.1.2" + }, + "engines": { + "node": ">= 0.10.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/express/node_modules/array-flatten": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/array-flatten/-/array-flatten-1.1.1.tgz", + "integrity": "sha512-PCVAQswWemu6UdxsDFFX/+gVeYqKAod3D3UVm91jHwynguOwAvYPhx8nNlM++NqRcK6CxxpUafjmhIdKiHibqg==", + "license": "MIT" + }, + "node_modules/finalhandler": { + "version": "1.3.2", + "resolved": "https://registry.npmjs.org/finalhandler/-/finalhandler-1.3.2.tgz", + "integrity": "sha512-aA4RyPcd3badbdABGDuTXCMTtOneUCAYH/gxoYRTZlIJdF0YPWuGqiAsIrhNnnqdXGswYk6dGujem4w80UJFhg==", + "license": "MIT", + "dependencies": { + "debug": "2.6.9", + "encodeurl": "~2.0.0", + "escape-html": "~1.0.3", + "on-finished": "~2.4.1", + "parseurl": "~1.3.3", + "statuses": "~2.0.2", + "unpipe": "~1.0.0" + }, + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/forwarded": { + "version": "0.2.0", + "resolved": "https://registry.npmjs.org/forwarded/-/forwarded-0.2.0.tgz", + "integrity": "sha512-buRG0fpBtRHSTCOASe6hD258tEubFoRLb4ZNA6NxMVHNw2gOcwHo9wyablzMzOA5z9xA9L1KNjk/Nt6MT9aYow==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/fresh": { + "version": "0.5.2", + "resolved": "https://registry.npmjs.org/fresh/-/fresh-0.5.2.tgz", + "integrity": "sha512-zJ2mQYM18rEFOudeV4GShTGIQ7RbzA7ozbU9I/XBpm7kqgMywgmylMwXHxZJmkVoYkna9d2pVXVXPdYTP9ej8Q==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/function-bind": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/function-bind/-/function-bind-1.1.2.tgz", + "integrity": "sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA==", + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/functions-have-names": { + "version": "1.2.3", + "resolved": "https://registry.npmjs.org/functions-have-names/-/functions-have-names-1.2.3.tgz", + "integrity": "sha512-xckBUXyTIqT97tq2x2AMb+g163b5JFysYk0x4qxNFwbfQkmNZoiRHb6sPzI9/QV33WeuvVYBUIiD4NzNIyqaRQ==", + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/get-intrinsic": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/get-intrinsic/-/get-intrinsic-1.3.0.tgz", + "integrity": "sha512-9fSjSaos/fRIVIp+xSJlE6lfwhES7LNtKaCBIamHsjr2na1BiABJPo0mOjjz8GJDURarmCPGqaiVg5mfjb98CQ==", + "license": "MIT", + "dependencies": { + "call-bind-apply-helpers": "^1.0.2", + "es-define-property": "^1.0.1", + "es-errors": "^1.3.0", + "es-object-atoms": "^1.1.1", + "function-bind": "^1.1.2", + "get-proto": "^1.0.1", + "gopd": "^1.2.0", + "has-symbols": "^1.1.0", + "hasown": "^2.0.2", + "math-intrinsics": "^1.1.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/get-proto": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/get-proto/-/get-proto-1.0.1.tgz", + "integrity": "sha512-sTSfBjoXBp89JvIKIefqw7U2CCebsc74kiY6awiGogKtoSGbgjYE/G/+l9sF3MWFPNc9IcoOC4ODfKHfxFmp0g==", + "license": "MIT", + "dependencies": { + "dunder-proto": "^1.0.1", + "es-object-atoms": "^1.0.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/gopd": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/gopd/-/gopd-1.2.0.tgz", + "integrity": "sha512-ZUKRh6/kUFoAiTAtTYPZJ3hw9wNxx+BIBOijnlG9PnrJsCcSjs1wyyD6vJpaYtgnzDrKYRSqf3OO6Rfa93xsRg==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/has-property-descriptors": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/has-property-descriptors/-/has-property-descriptors-1.0.2.tgz", + "integrity": "sha512-55JNKuIW+vq4Ke1BjOTjM2YctQIvCT7GFzHwmfZPGo5wnrgkid0YQtnAleFSqumZm4az3n2BS+erby5ipJdgrg==", + "license": "MIT", + "dependencies": { + "es-define-property": "^1.0.0" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/has-symbols": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/has-symbols/-/has-symbols-1.1.0.tgz", + "integrity": "sha512-1cDNdwJ2Jaohmb3sg4OmKaMBwuC48sYni5HUw2DvsC8LjGTLK9h+eb1X6RyuOHe4hT0ULCW68iomhjUoKUqlPQ==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/has-tostringtag": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/has-tostringtag/-/has-tostringtag-1.0.2.tgz", + "integrity": "sha512-NqADB8VjPFLM2V0VvHUewwwsw0ZWBaIdgo+ieHtK3hasLz4qeCRjYcqfB6AQrBggRKppKF8L52/VqdVsO47Dlw==", + "license": "MIT", + "dependencies": { + "has-symbols": "^1.0.3" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/hasown": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/hasown/-/hasown-2.0.2.tgz", + "integrity": "sha512-0hJU9SCPvmMzIBdZFqNPXWa6dqh7WdH0cII9y+CyS8rG3nL48Bclra9HmKhVVUHyPWNH5Y7xDwAB7bfgSjkUMQ==", + "license": "MIT", + "dependencies": { + "function-bind": "^1.1.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/http-errors": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/http-errors/-/http-errors-2.0.1.tgz", + "integrity": "sha512-4FbRdAX+bSdmo4AUFuS0WNiPz8NgFt+r8ThgNWmlrjQjt1Q7ZR9+zTlce2859x4KSXrwIsaeTqDoKQmtP8pLmQ==", + "license": "MIT", + "dependencies": { + "depd": "~2.0.0", + "inherits": "~2.0.4", + "setprototypeof": "~1.2.0", + "statuses": "~2.0.2", + "toidentifier": "~1.0.1" + }, + "engines": { + "node": ">= 0.8" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/iconv-lite": { + "version": "0.4.24", + "resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.4.24.tgz", + "integrity": "sha512-v3MXnZAcvnywkTUEZomIActle7RXXeedOR31wwl7VlyoXO4Qi9arvSenNQWne1TcRwhCL1HwLI21bEqdpj8/rA==", + "license": "MIT", + "dependencies": { + "safer-buffer": ">= 2.1.2 < 3" + }, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/inherits": { + "version": "2.0.4", + "resolved": "https://registry.npmjs.org/inherits/-/inherits-2.0.4.tgz", + "integrity": "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==", + "license": "ISC" + }, + "node_modules/ip": { + "version": "1.1.9", + "resolved": "https://registry.npmjs.org/ip/-/ip-1.1.9.tgz", + "integrity": "sha512-cyRxvOEpNHNtchU3Ln9KC/auJgup87llfQpQ+t5ghoC/UhL16SWzbueiCsdTnWmqAWl7LadfuwhlqmtOaqMHdQ==", + "license": "MIT" + }, + "node_modules/ipaddr.js": { + "version": "1.9.1", + "resolved": "https://registry.npmjs.org/ipaddr.js/-/ipaddr.js-1.9.1.tgz", + "integrity": "sha512-0KI/607xoxSToH7GjN1FfSbLoU0+btTicjsQSWQlh/hZykN8KpmMf7uYwPW3R+akZ6R/w18ZlXSHBYXiYUPO3g==", + "license": "MIT", + "engines": { + "node": ">= 0.10" + } + }, + "node_modules/is-arguments": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/is-arguments/-/is-arguments-1.2.0.tgz", + "integrity": "sha512-7bVbi0huj/wrIAOzb8U1aszg9kdi3KN/CyU19CTI7tAoZYEZoL9yCDXpbXN+uPsuWnP02cyug1gleqq+TU+YCA==", + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.2", + "has-tostringtag": "^1.0.2" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-date-object": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/is-date-object/-/is-date-object-1.1.0.tgz", + "integrity": "sha512-PwwhEakHVKTdRNVOw+/Gyh0+MzlCl4R6qKvkhuvLtPMggI1WAHt9sOwZxQLSGpUaDnrdyDsomoRgNnCfKNSXXg==", + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.2", + "has-tostringtag": "^1.0.2" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-regex": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/is-regex/-/is-regex-1.2.1.tgz", + "integrity": "sha512-MjYsKHO5O7mCsmRGxWcLWheFqN9DJ/2TmngvjKXihe6efViPqc274+Fx/4fYj/r03+ESvBdTXK0V6tA3rgez1g==", + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.2", + "gopd": "^1.2.0", + "has-tostringtag": "^1.0.2", + "hasown": "^2.0.2" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/make-error": { + "version": "1.3.6", + "resolved": "https://registry.npmjs.org/make-error/-/make-error-1.3.6.tgz", + "integrity": "sha512-s8UhlNe7vPKomQhC1qFelMokr/Sc3AgNbso3n74mVPA5LTZwkB9NlXf4XPamLxJE8h0gh73rM94xvwRT2CVInw==", + "dev": true, + "license": "ISC" + }, + "node_modules/math-intrinsics": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/math-intrinsics/-/math-intrinsics-1.1.0.tgz", + "integrity": "sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/media-typer": { + "version": "0.3.0", + "resolved": "https://registry.npmjs.org/media-typer/-/media-typer-0.3.0.tgz", + "integrity": "sha512-dq+qelQ9akHpcOl/gUVRTxVIOkAJ1wR3QAvb4RsVjS8oVoFjDGTc679wJYmUmknUF5HwMLOgb5O+a3KxfWapPQ==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/merge-descriptors": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/merge-descriptors/-/merge-descriptors-1.0.3.tgz", + "integrity": "sha512-gaNvAS7TZ897/rVaZ0nMtAyxNyi/pdbjbAwUpFQpN70GqnVfOiXpeUUMKRBmzXaSQ8DdTX4/0ms62r2K+hE6mQ==", + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/methods": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/methods/-/methods-1.1.2.tgz", + "integrity": "sha512-iclAHeNqNm68zFtnZ0e+1L2yUIdvzNoauKU4WBA3VvH/vPFieF7qfRlwUZU+DA9P9bPXIS90ulxoUoCH23sV2w==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/mime": { + "version": "1.6.0", + "resolved": "https://registry.npmjs.org/mime/-/mime-1.6.0.tgz", + "integrity": "sha512-x0Vn8spI+wuJ1O6S7gnbaQg8Pxh4NNHb7KSINmEWKiPE4RKOplvijn+NkmYmmRgP68mc70j2EbeTFRsrswaQeg==", + "license": "MIT", + "bin": { + "mime": "cli.js" + }, + "engines": { + "node": ">=4" + } + }, + "node_modules/mime-db": { + "version": "1.52.0", + "resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.52.0.tgz", + "integrity": "sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/mime-types": { + "version": "2.1.35", + "resolved": "https://registry.npmjs.org/mime-types/-/mime-types-2.1.35.tgz", + "integrity": "sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw==", + "license": "MIT", + "dependencies": { + "mime-db": "1.52.0" + }, + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/ms": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.0.0.tgz", + "integrity": "sha512-Tpp60P6IUJDTuOq/5Z8cdskzJujfwqfOTkrwIwj7IRISpnkJnT6SyJ4PCPnGMoFjC9ddhal5KVIYtAt97ix05A==", + "license": "MIT" + }, + "node_modules/multicast-dns": { + "version": "6.2.3", + "resolved": "https://registry.npmjs.org/multicast-dns/-/multicast-dns-6.2.3.tgz", + "integrity": "sha512-ji6J5enbMyGRHIAkAOu3WdV8nggqviKCEKtXcOqfphZZtQrmHKycfynJ2V7eVPUA4NhJ6V7Wf4TmGbTwKE9B6g==", + "license": "MIT", + "dependencies": { + "dns-packet": "^1.3.1", + "thunky": "^1.0.2" + }, + "bin": { + "multicast-dns": "cli.js" + } + }, + "node_modules/multicast-dns-service-types": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/multicast-dns-service-types/-/multicast-dns-service-types-1.1.0.tgz", + "integrity": "sha512-cnAsSVxIDsYt0v7HmC0hWZFwwXSh+E6PgCrREDuN/EsjgLwA5XRmlMHhSiDPrt6HxY1gTivEa/Zh7GtODoLevQ==", + "license": "MIT" + }, + "node_modules/negotiator": { + "version": "0.6.3", + "resolved": "https://registry.npmjs.org/negotiator/-/negotiator-0.6.3.tgz", + "integrity": "sha512-+EUsqGPLsM+j/zdChZjsnX51g4XrHFOIXwfnCVPGlQk/k5giakcKsuxCObBRu6DSm9opw/O6slWbJdghQM4bBg==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/network-interfaces": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/network-interfaces/-/network-interfaces-1.1.0.tgz", + "integrity": "sha512-fBk/Cm/RminFKhyUYKolI5nWI2de1m0pHlikz1mnTDbbe/1d2+ti+x/pWlOYuK8o/9p9vyK912+66h2NXGNUwQ==", + "license": "MIT" + }, + "node_modules/object-inspect": { + "version": "1.13.4", + "resolved": "https://registry.npmjs.org/object-inspect/-/object-inspect-1.13.4.tgz", + "integrity": "sha512-W67iLl4J2EXEGTbfeHCffrjDfitvLANg0UlX3wFUUSTx92KXRFegMHUVgSqE+wvhAbi4WqjGg9czysTV2Epbew==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/object-is": { + "version": "1.1.6", + "resolved": "https://registry.npmjs.org/object-is/-/object-is-1.1.6.tgz", + "integrity": "sha512-F8cZ+KfGlSGi09lJT7/Nd6KJZ9ygtvYC0/UYYLI9nmQKLMnydpB9yvbv9K1uSkEu7FU9vYPmVwLg328tX+ot3Q==", + "license": "MIT", + "dependencies": { + "call-bind": "^1.0.7", + "define-properties": "^1.2.1" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/object-keys": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/object-keys/-/object-keys-1.1.1.tgz", + "integrity": "sha512-NuAESUOUMrlIXOfHKzD6bpPu3tYt3xvjNdRIQ+FeT0lNb4K8WR70CaDxhuNguS2XG+GjkyMwOzsN5ZktImfhLA==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/on-finished": { + "version": "2.4.1", + "resolved": "https://registry.npmjs.org/on-finished/-/on-finished-2.4.1.tgz", + "integrity": "sha512-oVlzkg3ENAhCk2zdv7IJwd/QUD4z2RxRwpkcGY8psCVcCYZNq4wYnVWALHM+brtuJjePWiYF/ClmuDr8Ch5+kg==", + "license": "MIT", + "dependencies": { + "ee-first": "1.1.1" + }, + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/parseurl": { + "version": "1.3.3", + "resolved": "https://registry.npmjs.org/parseurl/-/parseurl-1.3.3.tgz", + "integrity": "sha512-CiyeOxFT/JZyN5m0z9PfXw4SCBJ6Sygz1Dpl0wqjlhDEGGBP1GnsUVEL0p63hoG1fcj3fHynXi9NYO4nWOL+qQ==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/path-to-regexp": { + "version": "0.1.12", + "resolved": "https://registry.npmjs.org/path-to-regexp/-/path-to-regexp-0.1.12.tgz", + "integrity": "sha512-RA1GjUVMnvYFxuqovrEqZoxxW5NUZqbwKtYz/Tt7nXerk0LbLblQmrsgdeOxV5SFHf0UDggjS/bSeOZwt1pmEQ==", + "license": "MIT" + }, + "node_modules/proxy-addr": { + "version": "2.0.7", + "resolved": "https://registry.npmjs.org/proxy-addr/-/proxy-addr-2.0.7.tgz", + "integrity": "sha512-llQsMLSUDUPT44jdrU/O37qlnifitDP+ZwrmmZcoSKyLKvtZxpyV0n2/bD/N4tBAAZ/gJEdZU7KMraoK1+XYAg==", + "license": "MIT", + "dependencies": { + "forwarded": "0.2.0", + "ipaddr.js": "1.9.1" + }, + "engines": { + "node": ">= 0.10" + } + }, + "node_modules/qs": { + "version": "6.14.1", + "resolved": "https://registry.npmjs.org/qs/-/qs-6.14.1.tgz", + "integrity": "sha512-4EK3+xJl8Ts67nLYNwqw/dsFVnCf+qR7RgXSK9jEEm9unao3njwMDdmsdvoKBKHzxd7tCYz5e5M+SnMjdtXGQQ==", + "license": "BSD-3-Clause", + "dependencies": { + "side-channel": "^1.1.0" + }, + "engines": { + "node": ">=0.6" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/range-parser": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/range-parser/-/range-parser-1.2.1.tgz", + "integrity": "sha512-Hrgsx+orqoygnmhFbKaHE6c296J+HTAQXoxEF6gNupROmmGJRoyzfG3ccAveqCBrwr/2yxQ5BVd/GTl5agOwSg==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/raw-body": { + "version": "2.5.3", + "resolved": "https://registry.npmjs.org/raw-body/-/raw-body-2.5.3.tgz", + "integrity": "sha512-s4VSOf6yN0rvbRZGxs8Om5CWj6seneMwK3oDb4lWDH0UPhWcxwOWw5+qk24bxq87szX1ydrwylIOp2uG1ojUpA==", + "license": "MIT", + "dependencies": { + "bytes": "~3.1.2", + "http-errors": "~2.0.1", + "iconv-lite": "~0.4.24", + "unpipe": "~1.0.0" + }, + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/regexp.prototype.flags": { + "version": "1.5.4", + "resolved": "https://registry.npmjs.org/regexp.prototype.flags/-/regexp.prototype.flags-1.5.4.tgz", + "integrity": "sha512-dYqgNSZbDwkaJ2ceRd9ojCGjBq+mOm9LmtXnAnEGyHhN/5R7iDW2TRw3h+o/jCFxus3P2LfWIIiwowAjANm7IA==", + "license": "MIT", + "dependencies": { + "call-bind": "^1.0.8", + "define-properties": "^1.2.1", + "es-errors": "^1.3.0", + "get-proto": "^1.0.1", + "gopd": "^1.2.0", + "set-function-name": "^2.0.2" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/safe-buffer": { + "version": "5.2.1", + "resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.2.1.tgz", + "integrity": "sha512-rp3So07KcdmmKbGvgaNxQSJr7bGVSVk5S9Eq1F+ppbRo70+YeaDxkw5Dd8NPN+GD6bjnYm2VuPuCXmpuYvmCXQ==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "MIT" + }, + "node_modules/safer-buffer": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/safer-buffer/-/safer-buffer-2.1.2.tgz", + "integrity": "sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==", + "license": "MIT" + }, + "node_modules/send": { + "version": "0.19.2", + "resolved": "https://registry.npmjs.org/send/-/send-0.19.2.tgz", + "integrity": "sha512-VMbMxbDeehAxpOtWJXlcUS5E8iXh6QmN+BkRX1GARS3wRaXEEgzCcB10gTQazO42tpNIya8xIyNx8fll1OFPrg==", + "license": "MIT", + "dependencies": { + "debug": "2.6.9", + "depd": "2.0.0", + "destroy": "1.2.0", + "encodeurl": "~2.0.0", + "escape-html": "~1.0.3", + "etag": "~1.8.1", + "fresh": "~0.5.2", + "http-errors": "~2.0.1", + "mime": "1.6.0", + "ms": "2.1.3", + "on-finished": "~2.4.1", + "range-parser": "~1.2.1", + "statuses": "~2.0.2" + }, + "engines": { + "node": ">= 0.8.0" + } + }, + "node_modules/send/node_modules/ms": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", + "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", + "license": "MIT" + }, + "node_modules/serve-static": { + "version": "1.16.3", + "resolved": "https://registry.npmjs.org/serve-static/-/serve-static-1.16.3.tgz", + "integrity": "sha512-x0RTqQel6g5SY7Lg6ZreMmsOzncHFU7nhnRWkKgWuMTu5NN0DR5oruckMqRvacAN9d5w6ARnRBXl9xhDCgfMeA==", + "license": "MIT", + "dependencies": { + "encodeurl": "~2.0.0", + "escape-html": "~1.0.3", + "parseurl": "~1.3.3", + "send": "~0.19.1" + }, + "engines": { + "node": ">= 0.8.0" + } + }, + "node_modules/set-function-length": { + "version": "1.2.2", + "resolved": "https://registry.npmjs.org/set-function-length/-/set-function-length-1.2.2.tgz", + "integrity": "sha512-pgRc4hJ4/sNjWCSS9AmnS40x3bNMDTknHgL5UaMBTMyJnU90EgWh1Rz+MC9eFu4BuN/UwZjKQuY/1v3rM7HMfg==", + "license": "MIT", + "dependencies": { + "define-data-property": "^1.1.4", + "es-errors": "^1.3.0", + "function-bind": "^1.1.2", + "get-intrinsic": "^1.2.4", + "gopd": "^1.0.1", + "has-property-descriptors": "^1.0.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/set-function-name": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/set-function-name/-/set-function-name-2.0.2.tgz", + "integrity": "sha512-7PGFlmtwsEADb0WYyvCMa1t+yke6daIG4Wirafur5kcf+MhUnPms1UeR0CKQdTZD81yESwMHbtn+TR+dMviakQ==", + "license": "MIT", + "dependencies": { + "define-data-property": "^1.1.4", + "es-errors": "^1.3.0", + "functions-have-names": "^1.2.3", + "has-property-descriptors": "^1.0.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/setprototypeof": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/setprototypeof/-/setprototypeof-1.2.0.tgz", + "integrity": "sha512-E5LDX7Wrp85Kil5bhZv46j8jOeboKq5JMmYM3gVGdGH8xFpPWXUMsNrlODCrkoxMEeNi/XZIwuRvY4XNwYMJpw==", + "license": "ISC" + }, + "node_modules/side-channel": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/side-channel/-/side-channel-1.1.0.tgz", + "integrity": "sha512-ZX99e6tRweoUXqR+VBrslhda51Nh5MTQwou5tnUDgbtyM0dBgmhEDtWGP/xbKn6hqfPRHujUNwz5fy/wbbhnpw==", + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "object-inspect": "^1.13.3", + "side-channel-list": "^1.0.0", + "side-channel-map": "^1.0.1", + "side-channel-weakmap": "^1.0.2" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/side-channel-list": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/side-channel-list/-/side-channel-list-1.0.0.tgz", + "integrity": "sha512-FCLHtRD/gnpCiCHEiJLOwdmFP+wzCmDEkc9y7NsYxeF4u7Btsn1ZuwgwJGxImImHicJArLP4R0yX4c2KCrMrTA==", + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "object-inspect": "^1.13.3" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/side-channel-map": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/side-channel-map/-/side-channel-map-1.0.1.tgz", + "integrity": "sha512-VCjCNfgMsby3tTdo02nbjtM/ewra6jPHmpThenkTYh8pG9ucZ/1P8So4u4FGBek/BjpOVsDCMoLA/iuBKIFXRA==", + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.2", + "es-errors": "^1.3.0", + "get-intrinsic": "^1.2.5", + "object-inspect": "^1.13.3" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/side-channel-weakmap": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/side-channel-weakmap/-/side-channel-weakmap-1.0.2.tgz", + "integrity": "sha512-WPS/HvHQTYnHisLo9McqBHOJk2FkHO/tlpvldyrnem4aeQp4hai3gythswg6p01oSoTl58rcpiFAjF2br2Ak2A==", + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.2", + "es-errors": "^1.3.0", + "get-intrinsic": "^1.2.5", + "object-inspect": "^1.13.3", + "side-channel-map": "^1.0.1" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/statuses": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/statuses/-/statuses-2.0.2.tgz", + "integrity": "sha512-DvEy55V3DB7uknRo+4iOGT5fP1slR8wQohVdknigZPMpMstaKJQWhwiYBACJE3Ul2pTnATihhBYnRhZQHGBiRw==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/thunky": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/thunky/-/thunky-1.1.0.tgz", + "integrity": "sha512-eHY7nBftgThBqOyHGVN+l8gF0BucP09fMo0oO/Lb0w1OF80dJv+lDVpXG60WMQvkcxAkNybKsrEIE3ZtKGmPrA==", + "license": "MIT" + }, + "node_modules/toidentifier": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/toidentifier/-/toidentifier-1.0.1.tgz", + "integrity": "sha512-o5sSPKEkg/DIQNmH43V0/uerLrpzVedkUh8tGNvaeXpfpuwjKenlSox/2O/BTlZUtEe+JG7s5YhEz608PlAHRA==", + "license": "MIT", + "engines": { + "node": ">=0.6" + } + }, + "node_modules/ts-node": { + "version": "10.9.2", + "resolved": "https://registry.npmjs.org/ts-node/-/ts-node-10.9.2.tgz", + "integrity": "sha512-f0FFpIdcHgn8zcPSbf1dRevwt047YMnaiJM3u2w2RewrB+fob/zePZcrOyQoLMMO7aBIddLcQIEK5dYjkLnGrQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@cspotcode/source-map-support": "^0.8.0", + "@tsconfig/node10": "^1.0.7", + "@tsconfig/node12": "^1.0.7", + "@tsconfig/node14": "^1.0.0", + "@tsconfig/node16": "^1.0.2", + "acorn": "^8.4.1", + "acorn-walk": "^8.1.1", + "arg": "^4.1.0", + "create-require": "^1.1.0", + "diff": "^4.0.1", + "make-error": "^1.1.1", + "v8-compile-cache-lib": "^3.0.1", + "yn": "3.1.1" + }, + "bin": { + "ts-node": "dist/bin.js", + "ts-node-cwd": "dist/bin-cwd.js", + "ts-node-esm": "dist/bin-esm.js", + "ts-node-script": "dist/bin-script.js", + "ts-node-transpile-only": "dist/bin-transpile.js", + "ts-script": "dist/bin-script-deprecated.js" + }, + "peerDependencies": { + "@swc/core": ">=1.2.50", + "@swc/wasm": ">=1.2.50", + "@types/node": "*", + "typescript": ">=2.7" + }, + "peerDependenciesMeta": { + "@swc/core": { + "optional": true + }, + "@swc/wasm": { + "optional": true + } + } + }, + "node_modules/type-is": { + "version": "1.6.18", + "resolved": "https://registry.npmjs.org/type-is/-/type-is-1.6.18.tgz", + "integrity": "sha512-TkRKr9sUTxEH8MdfuCSP7VizJyzRNMjj2J2do2Jr3Kym598JVdEksuzPQCnlFPW4ky9Q+iA+ma9BGm06XQBy8g==", + "license": "MIT", + "dependencies": { + "media-typer": "0.3.0", + "mime-types": "~2.1.24" + }, + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/typescript": { + "version": "5.9.3", + "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz", + "integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==", + "dev": true, + "license": "Apache-2.0", + "peer": true, + "bin": { + "tsc": "bin/tsc", + "tsserver": "bin/tsserver" + }, + "engines": { + "node": ">=14.17" + } + }, + "node_modules/undici-types": { + "version": "6.21.0", + "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-6.21.0.tgz", + "integrity": "sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/unpipe": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/unpipe/-/unpipe-1.0.0.tgz", + "integrity": "sha512-pjy2bYhSsufwWlKwPc+l3cN7+wuJlK6uz0YdJEOlQDbl6jo/YlPi4mb8agUkVC8BF7V8NuzeyPNqRksA3hztKQ==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/utils-merge": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/utils-merge/-/utils-merge-1.0.1.tgz", + "integrity": "sha512-pMZTvIkT1d+TFGvDOqodOclx0QWkkgi6Tdoa8gC8ffGAAqz9pzPTZWAybbsHHoED/ztMtkv/VoYTYyShUn81hA==", + "license": "MIT", + "engines": { + "node": ">= 0.4.0" + } + }, + "node_modules/v8-compile-cache-lib": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/v8-compile-cache-lib/-/v8-compile-cache-lib-3.0.1.tgz", + "integrity": "sha512-wa7YjyUGfNZngI/vtK0UHAN+lgDCxBPCylVXGp0zu59Fz5aiGtNXaq3DhIov063MorB+VfufLh3JlF2KdTK3xg==", + "dev": true, + "license": "MIT" + }, + "node_modules/vary": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/vary/-/vary-1.1.2.tgz", + "integrity": "sha512-BNGbWLfd0eUPabhkXUVm0j8uuvREyTh5ovRa/dyow/BqAbZJyC+5fU+IzQOzmAKzYqYRAISoRhdQr3eIZ/PXqg==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/yn": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/yn/-/yn-3.1.1.tgz", + "integrity": "sha512-Ux4ygGWsu2c7isFWe8Yu1YluJmqVhxqK2cLXNQA5AcC3QfbGNpM7fu0Y8b/z16pXLnFxZYvWhd3fhBY9DLmC6Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + } + } +} diff --git a/apps/router/package.json b/apps/router/package.json new file mode 100644 index 00000000..3189a7fe --- /dev/null +++ b/apps/router/package.json @@ -0,0 +1,22 @@ +{ + "name": "archipelago-router", + "version": "1.0.0", + "description": "Mesh routing and local network management for Archipelago", + "main": "dist/index.js", + "scripts": { + "build": "tsc", + "start": "node dist/index.js", + "dev": "ts-node src/index.ts" + }, + "dependencies": { + "express": "^4.18.2", + "bonjour": "^3.5.0", + "network-interfaces": "^1.1.0" + }, + "devDependencies": { + "@types/express": "^4.17.21", + "@types/node": "^20.10.0", + "typescript": "^5.3.3", + "ts-node": "^10.9.2" + } +} diff --git a/apps/router/src/index.ts b/apps/router/src/index.ts new file mode 100644 index 00000000..cb9601c8 --- /dev/null +++ b/apps/router/src/index.ts @@ -0,0 +1,59 @@ +import express from 'express'; +import bonjour from 'bonjour'; + +const app = express(); +const port = 8080; + +// Initialize Bonjour for mDNS +const bonjourInstance = bonjour(); + +// Publish Archipelago Router service +bonjourInstance.publish({ + name: 'Archipelago Router', + type: 'http', + port: port, + txt: { + version: '1.0.0', + mesh: 'enabled', + discovery: 'enabled' + } +}); + +// Middleware +app.use(express.json()); + +// Health check endpoint +app.get('/health', (req, res) => { + res.json({ status: 'ok', service: 'archipelago-router' }); +}); + +// Network topology endpoint +app.get('/api/topology', (req, res) => { + res.json({ + nodes: [], + links: [], + timestamp: Date.now() + }); +}); + +// Device discovery endpoint +app.get('/api/devices', (req, res) => { + res.json({ + devices: [], + count: 0 + }); +}); + +// Start server +app.listen(port, '0.0.0.0', () => { + console.log(`Archipelago Router listening on port ${port}`); + console.log('mDNS service published'); +}); + +// Graceful shutdown +process.on('SIGTERM', () => { + console.log('Shutting down...'); + bonjourInstance.unpublishAll(() => { + process.exit(0); + }); +}); diff --git a/apps/router/tsconfig.json b/apps/router/tsconfig.json new file mode 100644 index 00000000..fa8ee324 --- /dev/null +++ b/apps/router/tsconfig.json @@ -0,0 +1,16 @@ +{ + "compilerOptions": { + "target": "ES2020", + "module": "commonjs", + "lib": ["ES2020"], + "outDir": "./dist", + "rootDir": "./src", + "strict": true, + "esModuleInterop": true, + "skipLibCheck": true, + "forceConsistentCasingInFileNames": true, + "resolveJsonModule": true + }, + "include": ["src/**/*"], + "exclude": ["node_modules", "dist"] +} diff --git a/apps/searxng/Dockerfile b/apps/searxng/Dockerfile new file mode 100644 index 00000000..b7bf715e --- /dev/null +++ b/apps/searxng/Dockerfile @@ -0,0 +1,5 @@ +# SearXNG - uses official image +FROM searxng/searxng:2024.1.0 + +# Default configuration is in the image +# No additional setup needed diff --git a/apps/searxng/manifest.yml b/apps/searxng/manifest.yml new file mode 100644 index 00000000..1eeb727d --- /dev/null +++ b/apps/searxng/manifest.yml @@ -0,0 +1,49 @@ +app: + id: searxng + name: SearXNG + version: 1.0.0 + description: Privacy-respecting metasearch engine. Search the web without tracking. + + container: + image: 146.59.87.168:3000/lfg2025/searxng:latest + pull_policy: if-not-present + + dependencies: + - storage: 2Gi + + resources: + cpu_limit: 2 + memory_limit: 1Gi + disk_limit: 2Gi + + security: + capabilities: [] + readonly_root: true + no_new_privileges: true + user: 1000 + seccomp_profile: default + network_policy: isolated + apparmor_profile: searxng + + ports: + - host: 8888 + container: 8080 + protocol: tcp # Web UI + + volumes: + - type: bind + source: /var/lib/archipelago/searxng + target: /etc/searxng + options: [rw] + + environment: + - SEARXNG_HOSTNAME=localhost + - SEARXNG_BIND_ADDRESS=0.0.0.0:8080 + + health_check: + type: http + endpoint: http://localhost:8080 + path: / + interval: 30s + timeout: 30s + retries: 5 diff --git a/apps/strfry/Dockerfile b/apps/strfry/Dockerfile new file mode 100644 index 00000000..67d7f1de --- /dev/null +++ b/apps/strfry/Dockerfile @@ -0,0 +1,5 @@ +# Strfry - uses official image +FROM strfry/strfry:latest + +# Default configuration is in the image +# No additional setup needed diff --git a/apps/strfry/manifest.yml b/apps/strfry/manifest.yml new file mode 100644 index 00000000..ef74ed72 --- /dev/null +++ b/apps/strfry/manifest.yml @@ -0,0 +1,213 @@ +app: + id: strfry + name: Strfry Nostr Relay + version: 0.9.0 + description: Lightweight Nostr relay written in C++. Alternative to nostr-rs-relay with lower resource usage. + + container: + image: dockurr/strfry:1.0.4 + image_signature: cosign://... + pull_policy: verify-signature + + dependencies: + - storage: 5Gi + + resources: + cpu_limit: 1 + memory_limit: 512Mi + disk_limit: 5Gi + + security: + capabilities: [] + readonly_root: true + no_new_privileges: true + seccomp_profile: default + network_policy: isolated + apparmor_profile: nostr-relay + + ports: + - host: 8090 + container: 7777 + protocol: tcp # HTTP/WebSocket (strfry listens on 7777) + + volumes: + - type: bind + source: /var/lib/archipelago/strfry + target: /app/strfry-db + options: [rw] + # Image default config demands a 1M NOFILES rlimit, above the rootless + # user-manager hard cap (524288) — ship the config with nofiles = 0. + # Mounting it also skips the entrypoint's copy into /etc, which a + # readonly_root container cannot do. + - type: bind + source: /var/lib/archipelago/strfry-config/strfry.conf + target: /etc/strfry.conf + options: [ro] + + files: + - path: /var/lib/archipelago/strfry-config/strfry.conf + overwrite: true + content: | + ## + ## Default strfry config + ## + + # Directory that contains the strfry LMDB database (restart required) + db = "./strfry-db/" + + dbParams { + # Maximum number of threads/processes that can simultaneously have LMDB transactions open (restart required) + maxreaders = 256 + + # Size of mmap() to use when loading LMDB (default is 10TB, does *not* correspond to disk-space used) (restart required) + mapsize = 10995116277760 + + # Disables read-ahead when accessing the LMDB mapping. Reduces IO activity when DB size is larger than RAM. (restart required) + noReadAhead = false + } + + events { + # Maximum size of normalised JSON, in bytes + maxEventSize = 65536 + + # Events newer than this will be rejected + rejectEventsNewerThanSeconds = 900 + + # Events older than this will be rejected + rejectEventsOlderThanSeconds = 94608000 + + # Ephemeral events older than this will be rejected + rejectEphemeralEventsOlderThanSeconds = 60 + + # Ephemeral events will be deleted from the DB when older than this + ephemeralEventsLifetimeSeconds = 300 + + # Maximum number of tags allowed + maxNumTags = 2000 + + # Maximum size for tag values, in bytes + maxTagValSize = 1024 + } + + relay { + # Interface to listen on. Use 0.0.0.0 to listen on all interfaces (restart required) + bind = "0.0.0.0" + + # Port to open for the nostr websocket protocol (restart required) + port = 7777 + + # Set OS-limit on maximum number of open files/sockets (if 0, don't attempt to set) (restart required) + nofiles = 0 + + # HTTP header that contains the client's real IP, before reverse proxying (ie x-real-ip) (MUST be all lower-case) + realIpHeader = "" + + info { + # NIP-11: Name of this server. Short/descriptive (< 30 characters) + name = "Archipelago Strfry Relay" + + # NIP-11: Detailed information about relay, free-form + description = "Self-hosted strfry Nostr relay on Archipelago." + + # NIP-11: Administrative nostr pubkey, for contact purposes + pubkey = "" + + # NIP-11: Alternative administrative contact (email, website, etc) + contact = "" + + # NIP-11: URL pointing to an image to be used as an icon for the relay + icon = "" + + # List of supported lists as JSON array, or empty string to use default. Example: "[1,2]" + nips = "" + } + + # Maximum accepted incoming websocket frame size (should be larger than max event) (restart required) + maxWebsocketPayloadSize = 131072 + + # Maximum number of filters allowed in a REQ + maxReqFilterSize = 200 + + # Websocket-level PING message frequency (should be less than any reverse proxy idle timeouts) (restart required) + autoPingSeconds = 55 + + # If TCP keep-alive should be enabled (detect dropped connections to upstream reverse proxy) + enableTcpKeepalive = false + + # How much uninterrupted CPU time a REQ query should get during its DB scan + queryTimesliceBudgetMicroseconds = 10000 + + # Maximum records that can be returned per filter + maxFilterLimit = 500 + + # Maximum number of subscriptions (concurrent REQs) a connection can have open at any time + maxSubsPerConnection = 20 + + writePolicy { + # If non-empty, path to an executable script that implements the writePolicy plugin logic + plugin = "/app/write-policy.py" + } + + compression { + # Use permessage-deflate compression if supported by client. Reduces bandwidth, but slight increase in CPU (restart required) + enabled = true + + # Maintain a sliding window buffer for each connection. Improves compression, but uses more memory (restart required) + slidingWindow = true + } + + logging { + # Dump all incoming messages + dumpInAll = false + + # Dump all incoming EVENT messages + dumpInEvents = false + + # Dump all incoming REQ/CLOSE messages + dumpInReqs = false + + # Log performance metrics for initial REQ database scans + dbScanPerf = false + + # Log reason for invalid event rejection? Can be disabled to silence excessive logging + invalidEvents = true + } + + numThreads { + # Ingester threads: route incoming requests, validate events/sigs (restart required) + ingester = 3 + + # reqWorker threads: Handle initial DB scan for events (restart required) + reqWorker = 3 + + # reqMonitor threads: Handle filtering of new events (restart required) + reqMonitor = 3 + + # negentropy threads: Handle negentropy protocol messages (restart required) + negentropy = 2 + } + + negentropy { + # Support negentropy protocol messages + enabled = true + + # Maximum records that sync will process before returning an error + maxSyncEvents = 1000000 + } + } + + health_check: + type: http + # In-container probe: must target the CONTAINER port (7777), not the host + # mapping (8090), and 127.0.0.1 explicitly — `localhost` resolves to ::1 + # inside the image while strfry binds IPv4 0.0.0.0 only (verified on .228: + # localhost:7777 refused, 127.0.0.1:7777/health = 200). + endpoint: http://127.0.0.1:7777 + path: /health + interval: 30s + timeout: 5s + retries: 3 + + nostr_integration: + relay_type: public + monetization_enabled: true diff --git a/apps/uptime-kuma/manifest.yml b/apps/uptime-kuma/manifest.yml new file mode 100644 index 00000000..e58f0bbe --- /dev/null +++ b/apps/uptime-kuma/manifest.yml @@ -0,0 +1,54 @@ +app: + id: uptime-kuma + name: Uptime Kuma + version: 1.23.0 + description: Self-hosted uptime monitoring. + + container: + image: 146.59.87.168:3000/lfg2025/uptime-kuma:1 + pull_policy: if-not-present + network: pasta + custom_args: ["--", "node", "server/server.js"] + + dependencies: + - storage: 1Gi + + resources: + memory_limit: 256Mi + disk_limit: 1Gi + + security: + capabilities: [CHOWN, FOWNER, SETUID, SETGID] + readonly_root: false + network_policy: isolated + + ports: + - host: 3002 + container: 3001 + protocol: tcp + + volumes: + - type: bind + source: /var/lib/archipelago/uptime-kuma + target: /app/data + options: [rw] + + environment: + - TZ=UTC + + health_check: + type: http + endpoint: localhost:3001 + path: / + interval: 30s + timeout: 5s + retries: 3 + + metadata: + icon: /assets/img/app-icons/uptime-kuma.webp + category: data + tier: recommended + author: Uptime Kuma + repo: https://github.com/louislam/uptime-kuma + launch: + open_in_new_tab: true diff --git a/apps/vaultwarden/manifest.yml b/apps/vaultwarden/manifest.yml new file mode 100644 index 00000000..2f3d49d3 --- /dev/null +++ b/apps/vaultwarden/manifest.yml @@ -0,0 +1,60 @@ +app: + id: vaultwarden + name: Vaultwarden + version: 1.30.0 + description: Self-hosted password vault with zero-knowledge encryption. + + container: + image: 146.59.87.168:3000/lfg2025/vaultwarden:1.30.0-alpine + pull_policy: if-not-present + network: pasta + + dependencies: + - storage: 1Gi + + resources: + memory_limit: 256Mi + disk_limit: 1Gi + + security: + capabilities: [CHOWN, SETUID, SETGID, NET_BIND_SERVICE] + readonly_root: false + network_policy: isolated + + ports: + - host: 8082 + container: 80 + protocol: tcp + + volumes: + - type: bind + source: /var/lib/archipelago/vaultwarden + target: /data + options: [rw] + + environment: [] + + health_check: + type: tcp + endpoint: localhost:80 + interval: 30s + timeout: 5s + retries: 3 + + interfaces: + main: + name: Web UI + description: Vaultwarden web vault + type: ui + port: 8082 + protocol: http + path: / + + metadata: + icon: /assets/img/app-icons/vaultwarden.webp + category: data + tier: recommended + author: Vaultwarden + repo: https://github.com/dani-garcia/vaultwarden + launch: + open_in_new_tab: true diff --git a/core/.cargo/config.toml b/core/.cargo/config.toml new file mode 100644 index 00000000..adbdfd7c --- /dev/null +++ b/core/.cargo/config.toml @@ -0,0 +1,22 @@ +# Cargo configuration for Archipelago cross-compilation +# +# Native builds (x86_64 on x86_64) work automatically. +# ARM64 cross-compilation requires the aarch64-unknown-linux-gnu toolchain. +# +# Install the target: +# rustup target add aarch64-unknown-linux-gnu +# +# Install the cross-linker (Debian/Ubuntu): +# sudo apt install gcc-aarch64-linux-gnu +# +# Build for ARM64: +# cargo build --release --target aarch64-unknown-linux-gnu + +[target.aarch64-unknown-linux-gnu] +linker = "aarch64-linux-gnu-gcc" + +# OpenSSL cross-compilation environment (set before building) +# These are automatically set by the build scripts but documented here: +# OPENSSL_DIR=/usr/aarch64-linux-gnu +# PKG_CONFIG_PATH=/usr/lib/aarch64-linux-gnu/pkgconfig +# PKG_CONFIG_ALLOW_CROSS=1 diff --git a/core/.env.example b/core/.env.example new file mode 100644 index 00000000..c7ae929b --- /dev/null +++ b/core/.env.example @@ -0,0 +1,20 @@ +# Backend Configuration +# Copy this to .env and adjust as needed + +# Data directory for development +DATADIR=/tmp/archipelago-dev + +# RPC server binding address +RPC_BIND=127.0.0.1:5959 + +# Logging level (trace, debug, info, warn, error) +LOG_LEVEL=debug + +# Database URL (PostgreSQL) +DATABASE_URL=postgresql://localhost/archipelago_dev + +# Optional: Development mode +ARCHIPELAGO_DEV_MODE=true + +# Optional: Port offset for apps in dev mode +ARCHIPELAGO_DEV_PORT_OFFSET=10000 diff --git a/core/.env.production b/core/.env.production new file mode 100644 index 00000000..06ae27ee --- /dev/null +++ b/core/.env.production @@ -0,0 +1,33 @@ +# Archipelago Production Configuration +# This file is bundled with the macOS app + +# Server Configuration +ARCHIPELAGO_HOST=127.0.0.1 +ARCHIPELAGO_PORT=8100 +ARCHIPELAGO_BACKEND_PORT=3030 + +# Data Directories (relative to ~/Library/Application Support/Archipelago) +ARCHIPELAGO_DATA_DIR=data +ARCHIPELAGO_LOG_DIR=logs + +# Frontend Configuration +ARCHIPELAGO_FRONTEND_DIR=frontend + +# Docker UI Configuration +ARCHIPELAGO_DOCKER_UI_DIR=docker-ui + +# Security +ARCHIPELAGO_SESSION_SECRET=CHANGE_ME_ON_FIRST_RUN + +# Logging +RUST_LOG=info + +# Production Mode +NODE_ENV=production +ARCHIPELAGO_MODE=production + +# Docker Configuration +DOCKER_HOST=unix:///var/run/docker.sock + +# Disable External API Calls in Production +ARCHIPELAGO_DISABLE_EXTERNAL_APIS=true diff --git a/core/Cargo.lock b/core/Cargo.lock new file mode 100644 index 00000000..7fc9f8fe --- /dev/null +++ b/core/Cargo.lock @@ -0,0 +1,6901 @@ +# This file is automatically @generated by Cargo. +# It is not intended for manual editing. +version = 4 + +[[package]] +name = "adler2" +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 = [ + "crypto-common 0.1.7", + "generic-array", +] + +[[package]] +name = "aes" +version = "0.8.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b169f7a6d4742236a0a00c541b845991d0ac43e546831af1249753ab4c3aa3a0" +dependencies = [ + "cfg-if", + "cipher", + "cpufeatures 0.2.17", +] + +[[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 = "ahash" +version = "0.7.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "891477e0c6a8957309ee5c45a6368af3ae14bb510732d2684ffa19af310920f9" +dependencies = [ + "getrandom 0.2.17", + "once_cell", + "version_check", +] + +[[package]] +name = "aho-corasick" +version = "1.1.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ddd31a130427c27518df266943a5308ed92d4b226cc639f5a8f1002816174301" +dependencies = [ + "memchr", +] + +[[package]] +name = "allocator-api2" +version = "0.2.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "683d7910e743518b0e34f1186f92494becacb047c7b6bf616c96772180fef923" + +[[package]] +name = "android_system_properties" +version = "0.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "819e7219dbd41043ac279b19830f2efc897156490d7fd6ea916720117ee66311" +dependencies = [ + "libc", +] + +[[package]] +name = "anyhow" +version = "1.0.100" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a23eb6b1614318a8071c9b2521f36b424b2c83db5eb3a0fead4a6c0809af6e61" + +[[package]] +name = "arc-swap" +version = "1.9.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6a3a1fd6f75306b68087b831f025c712524bcb19aad54e557b1129cfa0a2b207" +dependencies = [ + "rustversion", +] + +[[package]] +name = "archipelago" +version = "1.7.100-alpha" +dependencies = [ + "anyhow", + "archipelago-container", + "archipelago-openwrt", + "archipelago-performance", + "archipelago-security", + "argon2", + "async-trait", + "base64 0.21.7", + "bcrypt", + "bip39", + "bitcoin", + "blake3", + "bs58", + "bytes", + "chacha20poly1305", + "chrono", + "ciborium", + "curve25519-dalek 4.1.3", + "data-encoding", + "ed25519-dalek 2.2.0", + "flate2", + "futures-util", + "hex", + "hkdf", + "hmac", + "http-body 1.0.1", + "http-body-util", + "hyper 0.14.32", + "hyper-util", + "hyper-ws-listener", + "iroh", + "iroh-blobs", + "libc", + "mainline", + "mdns-sd", + "nostr-sdk", + "qrcode", + "rand 0.8.5", + "reed-solomon-erasure", + "regex", + "reqwest 0.11.27", + "sd-notify", + "serde", + "serde_bytes", + "serde_json", + "serde_yaml", + "serial2-tokio", + "sha2 0.10.9", + "tar", + "tempfile", + "thiserror 1.0.69", + "tokio", + "tokio-test", + "tokio-tungstenite 0.20.1", + "toml", + "totp-rs", + "tower", + "tower-http", + "tracing", + "tracing-subscriber", + "uuid", + "zbase32", + "zeroize", +] + +[[package]] +name = "archipelago-container" +version = "0.1.0" +dependencies = [ + "anyhow", + "async-trait", + "chrono", + "futures", + "hex", + "hyper 0.14.32", + "indexmap", + "log", + "reqwest 0.11.27", + "serde", + "serde_json", + "serde_yaml", + "sha2 0.10.9", + "thiserror 1.0.69", + "tokio", + "tracing", + "uuid", +] + +[[package]] +name = "archipelago-openwrt" +version = "0.1.0" +dependencies = [ + "anyhow", + "async-trait", + "reqwest 0.11.27", + "serde", + "serde_json", + "ssh2", + "thiserror 1.0.69", + "tokio", + "tokio-test", + "tracing", +] + +[[package]] +name = "archipelago-performance" +version = "0.1.0" +dependencies = [ + "anyhow", + "log", + "serde", + "thiserror 1.0.69", + "tokio", + "tracing", +] + +[[package]] +name = "archipelago-security" +version = "0.1.0" +dependencies = [ + "aes-gcm", + "anyhow", + "chrono", + "hex", + "log", + "rand 0.8.5", + "serde", + "serde_json", + "tempfile", + "thiserror 1.0.69", + "tokio", + "tracing", + "uuid", + "zeroize", +] + +[[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", +] + +[[package]] +name = "arrayref" +version = "0.3.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "76a2e8124351fda1ef8aaaa3bbd7ebbcb486bbcd4225aca0aa0d84bb2db8fecb" + +[[package]] +name = "arrayvec" +version = "0.7.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7c02d123df017efcdfbd739ef81735b36c5ba83ec3c59c80a9d7ecc718f92e50" + +[[package]] +name = "asn1-rs" +version = "0.7.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b7f43a50ac4fdca5df8e885c21b835997f0a1cdee65494a6847694a98652d9d8" +dependencies = [ + "asn1-rs-derive", + "asn1-rs-impl", + "displaydoc", + "nom", + "num-traits", + "rusticata-macros", + "thiserror 2.0.18", + "time", +] + +[[package]] +name = "asn1-rs-derive" +version = "0.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3109e49b1e4909e9db6515a30c633684d68cdeaa252f215214cb4fa1a5bfee2c" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.114", + "synstructure", +] + +[[package]] +name = "asn1-rs-impl" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7b18050c2cd6fe86c3a76584ef5e0baf286d038cda203eb6223df2cc413565f7" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.114", +] + +[[package]] +name = "async-trait" +version = "0.1.89" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9035ad2d096bed7955a320ee7e2230574d28fd3c3a0f186cbea1ff3c7eed5dbb" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.114", +] + +[[package]] +name = "async-utility" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a34a3b57207a7a1007832416c3e4862378c8451b4e8e093e436f48c2d3d2c151" +dependencies = [ + "futures-util", + "gloo-timers", + "tokio", + "wasm-bindgen-futures", +] + +[[package]] +name = "async-wsocket" +version = "0.13.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9a7d8c7d34a225ba919dd9ba44d4b9106d20142da545e086be8ae21d1897e043" +dependencies = [ + "async-utility", + "futures", + "futures-util", + "js-sys", + "tokio", + "tokio-rustls 0.26.4", + "tokio-socks", + "tokio-tungstenite 0.26.2", + "url", + "wasm-bindgen", + "web-sys", +] + +[[package]] +name = "async_io_stream" +version = "0.3.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6d7b9decdf35d8908a7e3ef02f64c5e9b1695e230154c0e8de3969142d9b94c" +dependencies = [ + "futures", + "pharos", + "rustc_version", +] + +[[package]] +name = "atomic-destructor" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ef49f5882e4b6afaac09ad239a4f8c70a24b8f2b0897edb1f706008efd109cf4" + +[[package]] +name = "atomic-polyfill" +version = "1.0.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8cf2bce30dfe09ef0bfaef228b9d414faaf7e563035494d7fe092dba54b300f4" +dependencies = [ + "critical-section", +] + +[[package]] +name = "atomic-waker" +version = "1.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1505bd5d3d116872e7271a6d4e16d81d0c8570876c8de68093a09ac269d8aac0" + +[[package]] +name = "attohttpc" +version = "0.30.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "16e2cdb6d5ed835199484bb92bb8b3edd526effe995c61732580439c1a67e2e9" +dependencies = [ + "base64 0.22.1", + "http 1.4.0", + "log", + "url", +] + +[[package]] +name = "autocfg" +version = "1.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c08606f8c3cbf4ce6ec8e28fb0014a2c086708fe954eaa885384a6165172e7e8" + +[[package]] +name = "backon" +version = "1.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cffb0e931875b666fc4fcb20fee52e9bbd1ef836fd9e9e04ec21555f9f85f7ef" +dependencies = [ + "fastrand", + "gloo-timers", + "tokio", +] + +[[package]] +name = "bao-tree" +version = "0.16.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "06384416b1825e6e04fde63262fda2dc408f5b64c02d04e0d8b70ae72c17a52b" +dependencies = [ + "blake3", + "bytes", + "futures-lite", + "genawaiter", + "iroh-io", + "positioned-io", + "range-collections", + "self_cell", + "serde", + "smallvec", + "tokio", +] + +[[package]] +name = "base16ct" +version = "1.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fd307490d624467aa6f74b0eabb77633d1f758a7b25f12bceb0b22e08d9726f6" + +[[package]] +name = "base32" +version = "0.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "022dfe9eb35f19ebbcb51e0b40a5ab759f46ad60cadf7297e0bd085afb50e076" + +[[package]] +name = "base58ck" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2c8d66485a3a2ea485c1913c4572ce0256067a5377ac8c75c4960e1cda98605f" +dependencies = [ + "bitcoin-internals 0.3.0", + "bitcoin_hashes 0.14.1", +] + +[[package]] +name = "base64" +version = "0.21.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9d297deb1925b89f2ccc13d7635fa0714f12c87adce1c75356b39ca9b7178567" + +[[package]] +name = "base64" +version = "0.22.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "72b3254f16251a8381aa12e40e3c4d2f0199f8c6508fbecb9d91f575e0fbb8c6" + +[[package]] +name = "base64ct" +version = "1.8.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2af50177e190e07a26ab74f8b1efbfe2ef87da2116221318cb1c2e82baf7de06" + +[[package]] +name = "bcrypt" +version = "0.15.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e65938ed058ef47d92cf8b346cc76ef48984572ade631927e9937b5ffc7662c7" +dependencies = [ + "base64 0.22.1", + "blowfish", + "getrandom 0.2.17", + "subtle", + "zeroize", +] + +[[package]] +name = "bech32" +version = "0.11.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "32637268377fc7b10a8c6d51de3e7fba1ce5dd371a96e342b34e6078db558e7f" + +[[package]] +name = "binary-merge" +version = "0.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "597bb81c80a54b6a4381b23faba8d7774b144c94cbd1d6fe3f1329bd776554ab" + +[[package]] +name = "bip39" +version = "2.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "33415e24172c1b7d6066f6d999545375ab8e1d95421d6784bdfff9496f292387" +dependencies = [ + "bitcoin_hashes 0.13.0", + "rand 0.8.5", + "rand_core 0.6.4", + "serde", + "unicode-normalization", +] + +[[package]] +name = "bit-vec" +version = "0.9.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b71798fca2c1fe1086445a7258a4bc81e6e49dcd24c8d0dd9a1e57395b603f51" +dependencies = [ + "serde", +] + +[[package]] +name = "bitcoin" +version = "0.32.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ce6bc65742dea50536e35ad42492b234c27904a27f0abdcbce605015cb4ea026" +dependencies = [ + "base58ck", + "bech32", + "bitcoin-internals 0.3.0", + "bitcoin-io", + "bitcoin-units", + "bitcoin_hashes 0.14.1", + "hex-conservative 0.2.2", + "hex_lit", + "secp256k1", +] + +[[package]] +name = "bitcoin-internals" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9425c3bf7089c983facbae04de54513cce73b41c7f9ff8c845b54e7bc64ebbfb" + +[[package]] +name = "bitcoin-internals" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "30bdbe14aa07b06e6cfeffc529a1f099e5fbe249524f8125358604df99a4bed2" + +[[package]] +name = "bitcoin-io" +version = "0.1.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2dee39a0ee5b4095224a0cfc6bf4cc1baf0f9624b96b367e53b66d974e51d953" + +[[package]] +name = "bitcoin-units" +version = "0.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5285c8bcaa25876d07f37e3d30c303f2609179716e11d688f51e8f1fe70063e2" +dependencies = [ + "bitcoin-internals 0.3.0", +] + +[[package]] +name = "bitcoin_hashes" +version = "0.13.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1930a4dabfebb8d7d9992db18ebe3ae2876f0a305fab206fd168df931ede293b" +dependencies = [ + "bitcoin-internals 0.2.0", + "hex-conservative 0.1.2", +] + +[[package]] +name = "bitcoin_hashes" +version = "0.14.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "26ec84b80c482df901772e931a9a681e26a1b9ee2302edeff23cb30328745c8b" +dependencies = [ + "bitcoin-io", + "hex-conservative 0.2.2", + "serde", +] + +[[package]] +name = "bitflags" +version = "1.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bef38d45163c2f1dde094a7dfd33ccf595c92905c8f8f4fdc18d06fb1037718a" + +[[package]] +name = "bitflags" +version = "2.13.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b4388bee8683e3d04af747c73422af53102d2bd24d9eadb6cbc100baef4b43f8" + +[[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 = "blake3" +version = "1.8.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0aa83c34e62843d924f905e0f5c866eb1dd6545fc4d719e803d9ba6030371fce" +dependencies = [ + "arrayref", + "arrayvec", + "cc", + "cfg-if", + "constant_time_eq 0.4.2", + "cpufeatures 0.3.0", +] + +[[package]] +name = "block-buffer" +version = "0.10.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3078c7629b62d3f0439517fa394996acacc5cbc91c5a20d8c658e77abd503a71" +dependencies = [ + "generic-array", +] + +[[package]] +name = "block-buffer" +version = "0.12.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d2f6c7dbe95a6ed67ad9f18e57daf93a2f034c524b99fd2b76d18fdfeb6660aa" +dependencies = [ + "hybrid-array", +] + +[[package]] +name = "block-padding" +version = "0.3.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a8894febbff9f758034a5b8e12d87918f56dfc64a8e1fe757d65e29041538d93" +dependencies = [ + "generic-array", +] + +[[package]] +name = "block2" +version = "0.6.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cdeb9d870516001442e364c5220d3574d2da8dc765554b4a617230d33fa58ef5" +dependencies = [ + "objc2", +] + +[[package]] +name = "blowfish" +version = "0.9.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e412e2cd0f2b2d93e02543ceae7917b3c70331573df19ee046bcbc35e45e87d7" +dependencies = [ + "byteorder", + "cipher", +] + +[[package]] +name = "bs58" +version = "0.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bf88ba1141d185c399bee5288d850d63b8369520c1eafc32a0430b5b6c287bf4" +dependencies = [ + "tinyvec", +] + +[[package]] +name = "bumpalo" +version = "3.19.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5dd9dc738b7a8311c7ade152424974d8115f2cdad61e8dab8dac9f2362298510" + +[[package]] +name = "bytemuck" +version = "1.25.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c8efb64bd706a16a1bdde310ae86b351e4d21550d98d056f22f8a7f7a2183fec" + +[[package]] +name = "byteorder" +version = "1.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1fd0f2584146f6f2ef48085050886acf353beff7305ebd1ae69500e27c67f64b" + +[[package]] +name = "byteorder-lite" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8f1fe948ff07f4bd06c30984e69f5b4899c516a3ef74f34df92a2df2ab535495" + +[[package]] +name = "bytes" +version = "1.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b35204fbdc0b3f4446b89fc1ac2cf84a8a68971995d0bf2e925ec7cd960f9cb3" +dependencies = [ + "serde", +] + +[[package]] +name = "cbc" +version = "0.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "26b52a9543ae338f279b96b0b9fed9c8093744685043739079ce85cd58f289a6" +dependencies = [ + "cipher", +] + +[[package]] +name = "cc" +version = "1.2.54" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6354c81bbfd62d9cfa9cb3c773c2b7b2a3a482d569de977fd0e961f6e7c00583" +dependencies = [ + "find-msvc-tools", + "shlex", +] + +[[package]] +name = "cesu8" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6d43a04d8753f35258c91f8ec639f792891f748a1edbd759cf1dcea3382ad83c" + +[[package]] +name = "cfg-if" +version = "1.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9330f8b2ff13f34540b44e946ef35111825727b38d33286ef986142615121801" + +[[package]] +name = "cfg_aliases" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "613afe47fcd5fac7ccf1db93babcb082c5994d996f20b8b159f2ad1658eb5724" + +[[package]] +name = "chacha20" +version = "0.9.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c3613f74bd2eac03dad61bd53dbe620703d4371614fe0bc3b9f04dd36fe4e818" +dependencies = [ + "cfg-if", + "cipher", + "cpufeatures 0.2.17", +] + +[[package]] +name = "chacha20" +version = "0.10.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6f8d983286843e49675a4b7a2d174efe136dc93a18d69130dd18198a6c167601" +dependencies = [ + "cfg-if", + "cpufeatures 0.3.0", + "rand_core 0.10.1", +] + +[[package]] +name = "chacha20poly1305" +version = "0.10.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "10cd79432192d1c0f4e1a0fef9527696cc039165d729fb41b3f4f4f354c2dc35" +dependencies = [ + "aead", + "chacha20 0.9.1", + "cipher", + "poly1305", + "zeroize", +] + +[[package]] +name = "chrono" +version = "0.4.43" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fac4744fb15ae8337dc853fee7fb3f4e48c0fbaa23d0afe49c447b4fab126118" +dependencies = [ + "iana-time-zone", + "js-sys", + "num-traits", + "serde", + "wasm-bindgen", + "windows-link", +] + +[[package]] +name = "ciborium" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "42e69ffd6f0917f5c029256a24d0161db17cea3997d185db0d35926308770f0e" +dependencies = [ + "ciborium-io", + "ciborium-ll", + "serde", +] + +[[package]] +name = "ciborium-io" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "05afea1e0a06c9be33d539b876f1ce3692f4afea2cb41f740e7743225ed1c757" + +[[package]] +name = "ciborium-ll" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "57663b653d948a338bfb3eeba9bb2fd5fcfaecb9e199e87e1eda4d9e8b240fd9" +dependencies = [ + "ciborium-io", + "half", +] + +[[package]] +name = "cipher" +version = "0.4.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "773f3b9af64447d2ce9850330c473515014aa235e6a783b02db81ff39e4a3dad" +dependencies = [ + "crypto-common 0.1.7", + "inout", + "zeroize", +] + +[[package]] +name = "cmov" +version = "0.5.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0c9ea0ac24bc397ab3c98583a3c9ba74fa56b09a4449bbe172b9b1ddb016027a" + +[[package]] +name = "cobs" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0fa961b519f0b462e3a3b4a34b64d119eeaca1d59af726fe450bbba07a9fc0a1" +dependencies = [ + "thiserror 2.0.18", +] + +[[package]] +name = "combine" +version = "4.6.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ba5a308b75df32fe02788e748662718f03fde005016435c444eea572398219fd" +dependencies = [ + "bytes", + "memchr", +] + +[[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" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a6ef517f0926dd24a1582492c791b6a4818a4d94e789a334894aa15b0d12f55c" + +[[package]] +name = "constant_time_eq" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7c74b8349d32d297c9134b8c88677813a227df8f779daa29bfc29c183fe3dca6" + +[[package]] +name = "constant_time_eq" +version = "0.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3d52eff69cd5e647efe296129160853a42795992097e8af39800e1060caeea9b" + +[[package]] +name = "convert_case" +version = "0.10.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "633458d4ef8c78b72454de2d54fd6ab2e60f9e02be22f3c6104cdc8a4e0fceb9" +dependencies = [ + "unicode-segmentation", +] + +[[package]] +name = "cordyceps" +version = "0.3.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "688d7fbb8092b8de775ef2536f36c8c31f2bc4006ece2e8d8ad2d17d00ce0a2a" +dependencies = [ + "loom", + "tracing", +] + +[[package]] +name = "core-foundation" +version = "0.9.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "91e195e091a93c46f7102ec7818a2aa394e1e1771c3ab4825963fa03e45afb8f" +dependencies = [ + "core-foundation-sys", + "libc", +] + +[[package]] +name = "core-foundation" +version = "0.10.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b2a6cd9ae233e7f62ba4e9353e81a88df7fc8a5987b8d445b4d90c879bd156f6" +dependencies = [ + "core-foundation-sys", + "libc", +] + +[[package]] +name = "core-foundation-sys" +version = "0.8.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "773648b94d0e5d620f64f280777445740e61fe701025087ec8b57f45c791888b" + +[[package]] +name = "cpufeatures" +version = "0.2.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "59ed5838eebb26a2bb2e58f6d5b5316989ae9d08bab10e0e6d103e656d1b0280" +dependencies = [ + "libc", +] + +[[package]] +name = "cpufeatures" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8b2a41393f66f16b0823bb79094d54ac5fbd34ab292ddafb9a0456ac9f87d201" +dependencies = [ + "libc", +] + +[[package]] +name = "crc" +version = "3.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5eb8a2a1cd12ab0d987a5d5e825195d372001a4094a0376319d5a0ad71c1ba0d" +dependencies = [ + "crc-catalog", +] + +[[package]] +name = "crc-catalog" +version = "2.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "19d374276b40fb8bbdee95aef7c7fa6b5316ec764510eb64b8dd0e2ed0d7e7f5" + +[[package]] +name = "crc32fast" +version = "1.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9481c1c90cbf2ac953f07c8d4a58aa3945c425b7185c9154d67a65e4230da511" +dependencies = [ + "cfg-if", +] + +[[package]] +name = "critical-section" +version = "1.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "790eea4361631c5e7d22598ecd5723ff611904e3344ce8720784c93e3d83d40b" + +[[package]] +name = "crossbeam-channel" +version = "0.5.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "82b8f8f868b36967f9606790d1903570de9ceaf870a7bf9fbbd3016d636a2cb2" +dependencies = [ + "crossbeam-utils", +] + +[[package]] +name = "crossbeam-epoch" +version = "0.9.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5b82ac4a3c2ca9c3460964f020e1402edd5753411d7737aa39c3714ad1b5420e" +dependencies = [ + "crossbeam-utils", +] + +[[package]] +name = "crossbeam-utils" +version = "0.8.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d0a5c400df2834b80a4c3327b3aad3a4c4cd4de0629063962b03235697506a28" + +[[package]] +name = "crunchy" +version = "0.2.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "460fbee9c2c2f33933d720630a6a0bac33ba7053db5344fac858d4b8952d77d5" + +[[package]] +name = "crypto-common" +version = "0.1.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "78c8292055d1c1df0cce5d180393dc8cce0abec0a7102adb6c7b1eef6016d60a" +dependencies = [ + "generic-array", + "rand_core 0.6.4", + "typenum", +] + +[[package]] +name = "crypto-common" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ce6e4c961d6cd6c9a86db418387425e8bdeaf05b3c8bc1411e6dca4c252f1453" +dependencies = [ + "hybrid-array", +] + +[[package]] +name = "ctr" +version = "0.9.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0369ee1ad671834580515889b80f2ea915f23b8be8d0daa4bbaf2ac5c7590835" +dependencies = [ + "cipher", +] + +[[package]] +name = "ctutils" +version = "0.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7d5515a3834141de9eafb9717ad39eea8247b5674e6066c404e8c4b365d2a29e" +dependencies = [ + "cmov", +] + +[[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 0.2.9", + "rustc_version", + "subtle", + "zeroize", +] + +[[package]] +name = "curve25519-dalek" +version = "5.0.0-rc.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4f359e08ca85e7bd759e1fd933ff2bccd81864c60a8fba0e259c7f822b0924bf" +dependencies = [ + "cfg-if", + "cpufeatures 0.3.0", + "curve25519-dalek-derive", + "digest 0.11.3", + "fiat-crypto 0.3.0", + "rand_core 0.10.1", + "rustc_version", + "serde", + "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.114", +] + +[[package]] +name = "darling" +version = "0.20.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fc7f46116c46ff9ab3eb1597a45688b6715c6e628b5c133e288e709a29bcb4ee" +dependencies = [ + "darling_core", + "darling_macro", +] + +[[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.114", +] + +[[package]] +name = "darling_macro" +version = "0.20.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fc34b93ccb385b40dc71c6fceac4b2ad23662c7eeb248cf10d529b7e055b6ead" +dependencies = [ + "darling_core", + "quote", + "syn 2.0.114", +] + +[[package]] +name = "data-encoding" +version = "2.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a4ae5f15dda3c708c0ade84bfee31ccab44a3da4f88015ed22f63732abe300c8" + +[[package]] +name = "data-encoding-macro" +version = "0.1.20" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3259c913752a86488b501ed8680446a5ed2d5aeac6e596cb23ba3800768ea32c" +dependencies = [ + "data-encoding", + "data-encoding-macro-internal", +] + +[[package]] +name = "data-encoding-macro-internal" +version = "0.1.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ccc2776f0c61eca1ca32528f85548abd1a4be8fb53d1b21c013e4f18da1e7090" +dependencies = [ + "data-encoding", + "syn 2.0.114", +] + +[[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", + "zeroize", +] + +[[package]] +name = "der" +version = "0.8.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "71fd89660b2dc699704064e59e9dba0147b903e85319429e131620d022be411b" +dependencies = [ + "const-oid 0.10.2", + "pem-rfc7468", + "zeroize", +] + +[[package]] +name = "der-parser" +version = "10.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "07da5016415d5a3c4dd39b11ed26f915f52fc4e0dc197d87908bc916e51bc1a6" +dependencies = [ + "asn1-rs", + "displaydoc", + "nom", + "num-bigint", + "num-traits", + "rusticata-macros", +] + +[[package]] +name = "deranged" +version = "0.5.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7cd812cc2bc1d69d4764bd80df88b4317eaef9e773c75226407d9bc0876b211c" + +[[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", + "proc-macro2", + "quote", + "syn 2.0.114", +] + +[[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.114", +] + +[[package]] +name = "derive_more" +version = "2.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d751e9e49156b02b44f9c1815bcb94b984cdcc4396ecc32521c739452808b134" +dependencies = [ + "derive_more-impl", +] + +[[package]] +name = "derive_more-impl" +version = "2.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "799a97264921d8623a957f6c3b9011f3b5492f557bbb7a5a19b7fa6d06ba8dcb" +dependencies = [ + "convert_case", + "proc-macro2", + "quote", + "rustc_version", + "syn 2.0.114", + "unicode-xid", +] + +[[package]] +name = "diatomic-waker" +version = "0.2.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ab03c107fafeb3ee9f5925686dbb7a73bc76e3932abb0d2b365cb64b169cf04c" + +[[package]] +name = "digest" +version = "0.10.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9ed9a281f7bc9b7576e61468ba615a66a5c8cfdff42420a70aa82701a3b1e292" +dependencies = [ + "block-buffer 0.10.4", + "crypto-common 0.1.7", + "subtle", +] + +[[package]] +name = "digest" +version = "0.11.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f1dd6dbb5841937940781866fa1281a1ff7bd3bf827091440879f9994983d5c2" +dependencies = [ + "block-buffer 0.12.1", + "crypto-common 0.2.2", +] + +[[package]] +name = "dispatch2" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1e0e367e4e7da84520dedcac1901e4da967309406d1e51017ae1abfb97adbd38" +dependencies = [ + "bitflags 2.13.0", + "block2", + "libc", + "objc2", +] + +[[package]] +name = "displaydoc" +version = "0.2.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "97369cbbc041bc366949bc74d34658d6cda5621039731c6310521892a3a20ae0" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.114", +] + +[[package]] +name = "dlopen2" +version = "0.8.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5e2c5bd4158e66d1e215c49b837e11d62f3267b30c92f1d171c4d3105e3dc4d4" +dependencies = [ + "libc", + "once_cell", + "winapi", +] + +[[package]] +name = "ed25519" +version = "2.2.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "115531babc129696a58c64a4fef0a8bf9e9698629fb97e9e40767d235cfbcd53" +dependencies = [ + "pkcs8 0.10.2", + "signature 2.2.0", +] + +[[package]] +name = "ed25519" +version = "3.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "29fcf32e6c73d1079f83ab4d782de2d81620346a5f38c6237a86a22f8368980a" +dependencies = [ + "pkcs8 0.11.0", + "serdect", + "signature 3.0.0", +] + +[[package]] +name = "ed25519-dalek" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "70e796c081cee67dc755e1a36a0a172b897fab85fc3f6bc48307991f64e4eca9" +dependencies = [ + "curve25519-dalek 4.1.3", + "ed25519 2.2.3", + "rand_core 0.6.4", + "serde", + "sha2 0.10.9", + "subtle", + "zeroize", +] + +[[package]] +name = "ed25519-dalek" +version = "3.0.0-rc.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b011170fe4f04665565b4110afef66774fe9ffff278f3eb5b81cc73d26e27d60" +dependencies = [ + "curve25519-dalek 5.0.0-rc.0", + "ed25519 3.0.0", + "rand_core 0.10.1", + "serde", + "sha2 0.11.0", + "signature 3.0.0", + "subtle", + "zeroize", +] + +[[package]] +name = "either" +version = "1.15.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "48c757948c5ede0e46177b7add2e67155f70e33c07fea8284df6576da70b3719" + +[[package]] +name = "embedded-io" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ef1a6892d9eef45c8fa6b9e0086428a2cca8491aca8f787c534a3d6d0bcb3ced" + +[[package]] +name = "embedded-io" +version = "0.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "edd0f118536f44f5ccd48bcb8b111bdc3de888b58c74639dfb034a357d0f206d" + +[[package]] +name = "encoding_rs" +version = "0.8.35" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "75030f3c4f45dafd7586dd6780965a8c7e8e285a5ecb86713e63a79c5b2766f3" +dependencies = [ + "cfg-if", +] + +[[package]] +name = "enum-assoc" +version = "1.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3ed8956bd5c1f0415200516e78ff07ec9e16415ade83c056c230d7b7ea0d55b7" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.114", +] + +[[package]] +name = "env_logger" +version = "0.10.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4cd405aab171cb85d6735e5c8d9db038c17d3ca007a4d2c25f337935c3d90580" +dependencies = [ + "humantime", + "is-terminal", + "log", + "regex", + "termcolor", +] + +[[package]] +name = "equivalent" +version = "1.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "877a4ace8713b0bcf2a4e7eec82529c029f1d0619886d18145fea96c3ffe5c0f" + +[[package]] +name = "errno" +version = "0.3.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "39cab71617ae0d63f51a36d69f866391735b51691dbda63cf6f96d042b63efeb" +dependencies = [ + "libc", + "windows-sys 0.61.2", +] + +[[package]] +name = "fastbloom" +version = "0.17.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ef975e30683b2d965054bb0a836f8973857c4ebf6acf274fe46617cd285060d8" +dependencies = [ + "foldhash 0.2.0", + "libm", + "portable-atomic", + "siphasher", +] + +[[package]] +name = "fastrand" +version = "2.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "37909eebbb50d72f9059c3b6d82c0463f2ff062c9e95845c43a6c9c0355411be" + +[[package]] +name = "fiat-crypto" +version = "0.2.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "28dea519a9695b9977216879a3ebfddf92f1c08c05d984f8996aecd6ecdc811d" + +[[package]] +name = "fiat-crypto" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "64cd1e32ddd350061ae6edb1b082d7c54915b5c672c389143b9a63403a109f24" + +[[package]] +name = "filetime" +version = "0.2.27" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f98844151eee8917efc50bd9e8318cb963ae8b297431495d3f758616ea5c57db" +dependencies = [ + "cfg-if", + "libc", + "libredox", +] + +[[package]] +name = "find-msvc-tools" +version = "0.1.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8591b0bcc8a98a64310a2fae1bb3e9b8564dd10e381e6e28010fde8e8e8568db" + +[[package]] +name = "flate2" +version = "1.1.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "843fba2746e448b37e26a819579957415c8cef339bf08564fe8b7ddbd959573c" +dependencies = [ + "crc32fast", + "miniz_oxide", +] + +[[package]] +name = "flume" +version = "0.11.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "da0e4dd2a88388a1f4ccc7c9ce104604dab68d9f408dc34cd45823d5a9069095" +dependencies = [ + "futures-core", + "futures-sink", + "spin 0.9.8", +] + +[[package]] +name = "fnv" +version = "1.0.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3f9eec918d3f24069decb9af1554cad7c880e2da24a9afd88aca000531ab82c1" + +[[package]] +name = "foldhash" +version = "0.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d9c4f5dac5e15c24eb999c26181a6ca40b39fe946cbe4c263c7209467bc83af2" + +[[package]] +name = "foldhash" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "77ce24cb58228fbb8aa041425bb1050850ac19177686ea6e0f41a70416f56fdb" + +[[package]] +name = "form_urlencoded" +version = "1.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cb4cb245038516f5f85277875cdaa4f7d2c9a0fa0468de06ed190163b1581fcf" +dependencies = [ + "percent-encoding", +] + +[[package]] +name = "futures" +version = "0.3.31" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "65bc07b1a8bc7c85c5f2e110c476c7389b4554ba72af57d8445ea63a576b0876" +dependencies = [ + "futures-channel", + "futures-core", + "futures-executor", + "futures-io", + "futures-sink", + "futures-task", + "futures-util", +] + +[[package]] +name = "futures-buffered" +version = "0.2.13" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4421cb78ee172b6b06080093479d3c50f058e7c81b7d577bbb8d118d551d4cd5" +dependencies = [ + "cordyceps", + "diatomic-waker", + "futures-core", + "pin-project-lite", + "spin 0.10.0", +] + +[[package]] +name = "futures-channel" +version = "0.3.31" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2dff15bf788c671c1934e366d07e30c1814a8ef514e1af724a602e8a2fbe1b10" +dependencies = [ + "futures-core", + "futures-sink", +] + +[[package]] +name = "futures-core" +version = "0.3.31" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "05f29059c0c2090612e8d742178b0580d2dc940c837851ad723096f87af6663e" + +[[package]] +name = "futures-executor" +version = "0.3.31" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1e28d1d997f585e54aebc3f97d39e72338912123a67330d723fdbb564d646c9f" +dependencies = [ + "futures-core", + "futures-task", + "futures-util", +] + +[[package]] +name = "futures-io" +version = "0.3.31" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9e5c1b78ca4aae1ac06c48a526a655760685149f0d465d21f37abfe57ce075c6" + +[[package]] +name = "futures-lite" +version = "2.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f78e10609fe0e0b3f4157ffab1876319b5b0db102a2c60dc4626306dc46b44ad" +dependencies = [ + "fastrand", + "futures-core", + "futures-io", + "parking", + "pin-project-lite", +] + +[[package]] +name = "futures-macro" +version = "0.3.31" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "162ee34ebcb7c64a8abebc059ce0fee27c2262618d7b60ed8faf72fef13c3650" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.114", +] + +[[package]] +name = "futures-sink" +version = "0.3.31" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e575fab7d1e0dcb8d0c7bcf9a63ee213816ab51902e6d244a95819acacf1d4f7" + +[[package]] +name = "futures-task" +version = "0.3.31" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f90f7dce0722e95104fcb095585910c0977252f286e354b5e3bd38902cd99988" + +[[package]] +name = "futures-util" +version = "0.3.31" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9fa08315bb612088cc391249efdc3bc77536f16c91f6cf495e6fbe85b20a4a81" +dependencies = [ + "futures-channel", + "futures-core", + "futures-io", + "futures-macro", + "futures-sink", + "futures-task", + "memchr", + "pin-project-lite", + "pin-utils", + "slab", +] + +[[package]] +name = "genawaiter" +version = "0.99.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c86bd0361bcbde39b13475e6e36cb24c329964aa2611be285289d1e4b751c1a0" +dependencies = [ + "futures-core", + "genawaiter-macro", + "genawaiter-proc-macro", + "proc-macro-hack", +] + +[[package]] +name = "genawaiter-macro" +version = "0.99.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0b32dfe1fdfc0bbde1f22a5da25355514b5e450c33a6af6770884c8750aedfbc" + +[[package]] +name = "genawaiter-proc-macro" +version = "0.99.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "784f84eebc366e15251c4a8c3acee82a6a6f427949776ecb88377362a9621738" +dependencies = [ + "proc-macro-error", + "proc-macro-hack", + "proc-macro2", + "quote", + "syn 1.0.109", +] + +[[package]] +name = "generator" +version = "0.8.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b3b854b0e584ead1a33f18b2fcad7cf7be18b3875c78816b753639aa501513ae" +dependencies = [ + "cc", + "cfg-if", + "libc", + "log", + "rustversion", + "windows-link", + "windows-result", +] + +[[package]] +name = "generic-array" +version = "0.14.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "85649ca51fd72272d7821adaf274ad91c288277713d9c18820d8499a7ff69e9a" +dependencies = [ + "typenum", + "version_check", +] + +[[package]] +name = "getrandom" +version = "0.2.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ff2abc00be7fca6ebc474524697ae276ad847ad0a6b3faa4bcb027e9a4614ad0" +dependencies = [ + "cfg-if", + "js-sys", + "libc", + "wasi", + "wasm-bindgen", +] + +[[package]] +name = "getrandom" +version = "0.3.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "899def5c37c4fd7b2664648c28120ecec138e4d395b459e5ca34f9cce2dd77fd" +dependencies = [ + "cfg-if", + "libc", + "r-efi 5.3.0", + "wasip2", +] + +[[package]] +name = "getrandom" +version = "0.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0de51e6874e94e7bf76d726fc5d13ba782deca734ff60d5bb2fb2607c7406555" +dependencies = [ + "cfg-if", + "js-sys", + "libc", + "r-efi 6.0.0", + "rand_core 0.10.1", + "wasip2", + "wasip3", + "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 = "gloo-timers" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bbb143cf96099802033e0d4f4963b19fd2e0b728bcf076cd9cf7f6634f092994" +dependencies = [ + "futures-channel", + "futures-core", + "js-sys", + "wasm-bindgen", +] + +[[package]] +name = "h2" +version = "0.3.27" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0beca50380b1fc32983fc1cb4587bfa4bb9e78fc259aad4a0032d2080309222d" +dependencies = [ + "bytes", + "fnv", + "futures-core", + "futures-sink", + "futures-util", + "http 0.2.12", + "indexmap", + "slab", + "tokio", + "tokio-util", + "tracing", +] + +[[package]] +name = "h2" +version = "0.4.13" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2f44da3a8150a6703ed5d34e164b875fd14c2cdab9af1252a9a1020bde2bdc54" +dependencies = [ + "atomic-waker", + "bytes", + "fnv", + "futures-core", + "futures-sink", + "http 1.4.0", + "indexmap", + "slab", + "tokio", + "tokio-util", + "tracing", +] + +[[package]] +name = "half" +version = "2.7.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6ea2d84b969582b4b1864a92dc5d27cd2b77b622a8d79306834f1be5ba20d84b" +dependencies = [ + "cfg-if", + "crunchy", + "zerocopy", +] + +[[package]] +name = "hash32" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b0c35f58762feb77d74ebe43bdbc3210f09be9fe6742234d573bacc26ed92b67" +dependencies = [ + "byteorder", +] + +[[package]] +name = "hashbrown" +version = "0.12.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8a9ee70c43aaf417c914396645a0fa852624801b24ebb7ae78fe8272889ac888" +dependencies = [ + "ahash", +] + +[[package]] +name = "hashbrown" +version = "0.15.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9229cfe53dfd69f0609a49f65461bd93001ea1ef889cd5529dd176593f5338a1" +dependencies = [ + "foldhash 0.1.5", +] + +[[package]] +name = "hashbrown" +version = "0.16.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "841d1cc9bed7f9236f321df977030373f4a4163ae1a7dbfe1a51a2c1a51d9100" + +[[package]] +name = "hashbrown" +version = "0.17.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ed5909b6e89a2db4456e54cd5f673791d7eca6732202bbf2a9cc504fe2f9b84a" +dependencies = [ + "allocator-api2", + "equivalent", + "foldhash 0.2.0", +] + +[[package]] +name = "heapless" +version = "0.7.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cdc6457c0eb62c71aac4bc17216026d8410337c4126773b9c5daba343f17964f" +dependencies = [ + "atomic-polyfill", + "hash32", + "rustc_version", + "serde", + "spin 0.9.8", + "stable_deref_trait", +] + +[[package]] +name = "heck" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2304e00983f87ffb38b55b444b5e3b60a884b5d30c0fca7d82fe33449bbe55ea" + +[[package]] +name = "hermit-abi" +version = "0.5.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fc0fef456e4baa96da950455cd02c081ca953b141298e41db3fc7e36b1da849c" + +[[package]] +name = "hex" +version = "0.4.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7f24254aa9a54b5c858eaee2f5bccdb46aaf0e486a595ed5fd8f86ba55232a70" + +[[package]] +name = "hex-conservative" +version = "0.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "212ab92002354b4819390025006c897e8140934349e8635c9b077f47b4dcbd20" + +[[package]] +name = "hex-conservative" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fda06d18ac606267c40c04e41b9947729bf8b9efe74bd4e82b61a5f26a510b9f" +dependencies = [ + "arrayvec", +] + +[[package]] +name = "hex_lit" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3011d1213f159867b13cfd6ac92d2cd5f1345762c63be3554e84092d85a50bbd" + +[[package]] +name = "hickory-net" +version = "0.26.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e2295ed2f9c31e471e1428a8f88a3f0e1f4b27c15049592138d1eebe9c35b183" +dependencies = [ + "async-trait", + "bytes", + "cfg-if", + "data-encoding", + "futures-channel", + "futures-io", + "futures-util", + "h2 0.4.13", + "hickory-proto", + "http 1.4.0", + "idna", + "ipnet", + "jni 0.22.4", + "rand 0.10.1", + "rustls 0.23.36", + "thiserror 2.0.18", + "tinyvec", + "tokio", + "tokio-rustls 0.26.4", + "tracing", + "url", +] + +[[package]] +name = "hickory-proto" +version = "0.26.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0bab31817bfb44672a252e97fe81cd0c18d1b2cf892108922f6818820df8c643" +dependencies = [ + "data-encoding", + "idna", + "ipnet", + "jni 0.22.4", + "once_cell", + "prefix-trie", + "rand 0.10.1", + "ring", + "thiserror 2.0.18", + "tinyvec", + "tracing", + "url", +] + +[[package]] +name = "hickory-resolver" +version = "0.26.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f0d58d28879ceecde6607729660c2667a081ccdc082e082675042793960f178c" +dependencies = [ + "cfg-if", + "futures-util", + "hickory-net", + "hickory-proto", + "ipconfig", + "ipnet", + "jni 0.22.4", + "moka", + "ndk-context", + "once_cell", + "parking_lot 0.12.5", + "rand 0.10.1", + "resolv-conf", + "rustls 0.23.36", + "smallvec", + "system-configuration 0.7.0", + "thiserror 2.0.18", + "tokio", + "tokio-rustls 0.26.4", + "tracing", +] + +[[package]] +name = "hkdf" +version = "0.12.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7b5f8eb2ad728638ea2c7d47a21db23b7b58a72ed6a38256b8a1849f15fbbdf7" +dependencies = [ + "hmac", +] + +[[package]] +name = "hmac" +version = "0.12.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6c49c37c09c17a53d937dfbb742eb3a961d65a994e6bcdcf37e7399d0cc8ab5e" +dependencies = [ + "digest 0.10.7", +] + +[[package]] +name = "http" +version = "0.2.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "601cbb57e577e2f5ef5be8e7b83f0f63994f25aa94d673e54a92d5c516d101f1" +dependencies = [ + "bytes", + "fnv", + "itoa", +] + +[[package]] +name = "http" +version = "1.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e3ba2a386d7f85a81f119ad7498ebe444d2e22c2af0b86b069416ace48b3311a" +dependencies = [ + "bytes", + "itoa", +] + +[[package]] +name = "http-body" +version = "0.4.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7ceab25649e9960c0311ea418d17bee82c0dcec1bd053b5f9a66e265a693bed2" +dependencies = [ + "bytes", + "http 0.2.12", + "pin-project-lite", +] + +[[package]] +name = "http-body" +version = "1.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1efedce1fb8e6913f23e0c92de8e62cd5b772a67e7b3946df930a62566c93184" +dependencies = [ + "bytes", + "http 1.4.0", +] + +[[package]] +name = "http-body-util" +version = "0.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b021d93e26becf5dc7e1b75b1bed1fd93124b374ceb73f43d4d4eafec896a64a" +dependencies = [ + "bytes", + "futures-core", + "http 1.4.0", + "http-body 1.0.1", + "pin-project-lite", +] + +[[package]] +name = "httparse" +version = "1.10.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6dbf3de79e51f3d586ab4cb9d5c3e2c14aa28ed23d180cf89b4df0454a69cc87" + +[[package]] +name = "httpdate" +version = "1.0.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "df3b46402a9d5adb4c86a0cf463f42e19994e3ee891101b1841f30a545cb49a9" + +[[package]] +name = "humantime" +version = "2.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "135b12329e5e3ce057a9f972339ea52bc954fe1e9358ef27f95e89716fbc5424" + +[[package]] +name = "hybrid-array" +version = "0.4.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9155a582abd142abc056962c29e3ce5ff2ad5469f4246b537ed42c5deba857da" +dependencies = [ + "typenum", +] + +[[package]] +name = "hyper" +version = "0.14.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "41dfc780fdec9373c01bae43289ea34c972e40ee3c9f6b3c8801a35f35586ce7" +dependencies = [ + "bytes", + "futures-channel", + "futures-core", + "futures-util", + "h2 0.3.27", + "http 0.2.12", + "http-body 0.4.6", + "httparse", + "httpdate", + "itoa", + "pin-project-lite", + "socket2 0.5.10", + "tokio", + "tower-service", + "tracing", + "want", +] + +[[package]] +name = "hyper" +version = "1.8.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2ab2d4f250c3d7b1c9fcdff1cece94ea4e2dfbec68614f7b87cb205f24ca9d11" +dependencies = [ + "atomic-waker", + "bytes", + "futures-channel", + "futures-core", + "h2 0.4.13", + "http 1.4.0", + "http-body 1.0.1", + "httparse", + "httpdate", + "itoa", + "pin-project-lite", + "pin-utils", + "smallvec", + "tokio", + "want", +] + +[[package]] +name = "hyper-rustls" +version = "0.24.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ec3efd23720e2049821a693cbc7e65ea87c72f1c58ff2f9522ff332b1491e590" +dependencies = [ + "futures-util", + "http 0.2.12", + "hyper 0.14.32", + "rustls 0.21.12", + "tokio", + "tokio-rustls 0.24.1", +] + +[[package]] +name = "hyper-rustls" +version = "0.27.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "33ca68d021ef39cf6463ab54c1d0f5daf03377b70561305bb89a8f83aab66e0f" +dependencies = [ + "http 1.4.0", + "hyper 1.8.1", + "hyper-util", + "rustls 0.23.36", + "tokio", + "tokio-rustls 0.26.4", + "tower-service", +] + +[[package]] +name = "hyper-util" +version = "0.1.19" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "727805d60e7938b76b826a6ef209eb70eaa1812794f9424d4a4e2d740662df5f" +dependencies = [ + "base64 0.22.1", + "bytes", + "futures-channel", + "futures-core", + "futures-util", + "http 1.4.0", + "http-body 1.0.1", + "hyper 1.8.1", + "ipnet", + "libc", + "percent-encoding", + "pin-project-lite", + "socket2 0.6.2", + "system-configuration 0.6.1", + "tokio", + "tower-layer", + "tower-service", + "tracing", + "windows-registry", +] + +[[package]] +name = "hyper-ws-listener" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dcbfe4981e45b0a7403a55d4af12f8d30e173e722409658c3857243990e72180" +dependencies = [ + "anyhow", + "base64 0.21.7", + "env_logger", + "futures", + "hyper 0.14.32", + "log", + "sha-1", + "tokio", + "tokio-tungstenite 0.20.1", +] + +[[package]] +name = "iana-time-zone" +version = "0.1.64" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "33e57f83510bb73707521ebaffa789ec8caf86f9657cad665b092b581d40e9fb" +dependencies = [ + "android_system_properties", + "core-foundation-sys", + "iana-time-zone-haiku", + "js-sys", + "log", + "wasm-bindgen", + "windows-core", +] + +[[package]] +name = "iana-time-zone-haiku" +version = "0.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f31827a206f56af32e590ba56d5d2d085f558508192593743f16b2306495269f" +dependencies = [ + "cc", +] + +[[package]] +name = "icu_collections" +version = "2.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4c6b649701667bbe825c3b7e6388cb521c23d88644678e83c0c4d0a621a34b43" +dependencies = [ + "displaydoc", + "potential_utf", + "yoke", + "zerofrom", + "zerovec", +] + +[[package]] +name = "icu_locale_core" +version = "2.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "edba7861004dd3714265b4db54a3c390e880ab658fec5f7db895fae2046b5bb6" +dependencies = [ + "displaydoc", + "litemap", + "tinystr", + "writeable", + "zerovec", +] + +[[package]] +name = "icu_normalizer" +version = "2.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5f6c8828b67bf8908d82127b2054ea1b4427ff0230ee9141c54251934ab1b599" +dependencies = [ + "icu_collections", + "icu_normalizer_data", + "icu_properties", + "icu_provider", + "smallvec", + "zerovec", +] + +[[package]] +name = "icu_normalizer_data" +version = "2.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7aedcccd01fc5fe81e6b489c15b247b8b0690feb23304303a9e560f37efc560a" + +[[package]] +name = "icu_properties" +version = "2.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "020bfc02fe870ec3a66d93e677ccca0562506e5872c650f893269e08615d74ec" +dependencies = [ + "icu_collections", + "icu_locale_core", + "icu_properties_data", + "icu_provider", + "zerotrie", + "zerovec", +] + +[[package]] +name = "icu_properties_data" +version = "2.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "616c294cf8d725c6afcd8f55abc17c56464ef6211f9ed59cccffe534129c77af" + +[[package]] +name = "icu_provider" +version = "2.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "85962cf0ce02e1e0a629cc34e7ca3e373ce20dda4c4d7294bbd0bf1fdb59e614" +dependencies = [ + "displaydoc", + "icu_locale_core", + "writeable", + "yoke", + "zerofrom", + "zerotrie", + "zerovec", +] + +[[package]] +name = "id-arena" +version = "2.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3d3067d79b975e8844ca9eb072e16b31c3c1c36928edf9c6789548c524d0d954" + +[[package]] +name = "ident_case" +version = "1.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b9e0384b61958566e926dc50660321d12159025e767c18e043daf26b70104c39" + +[[package]] +name = "identity-hash" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dfdd7caa900436d8f13b2346fe10257e0c05c1f1f9e351f4f5d57c03bd5f45da" + +[[package]] +name = "idna" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3b0875f23caa03898994f6ddc501886a45c7d3d62d04d2d90788d47be1b1e4de" +dependencies = [ + "idna_adapter", + "smallvec", + "utf8_iter", +] + +[[package]] +name = "idna_adapter" +version = "1.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3acae9609540aa318d1bc588455225fb2085b9ed0c4f6bd0d9d5bcd86f1a0344" +dependencies = [ + "icu_normalizer", + "icu_properties", +] + +[[package]] +name = "if-addrs" +version = "0.15.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c0a05c691e1fae256cf7013d99dad472dc52d5543322761f83ec8d47eab40d2b" +dependencies = [ + "libc", + "windows-sys 0.61.2", +] + +[[package]] +name = "igd-next" +version = "0.17.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "de7238d487a9aff61f81b5ab41c0a841532a115a398b5fa92a2fadd0885e2581" +dependencies = [ + "attohttpc", + "bytes", + "futures", + "http 1.4.0", + "http-body-util", + "hyper 1.8.1", + "hyper-util", + "log", + "rand 0.10.1", + "tokio", + "url", + "xmltree", +] + +[[package]] +name = "image" +version = "0.25.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e6506c6c10786659413faa717ceebcb8f70731c0a60cbae39795fdf114519c1a" +dependencies = [ + "bytemuck", + "byteorder-lite", + "moxcms", + "num-traits", +] + +[[package]] +name = "indexmap" +version = "2.13.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7714e70437a7dc3ac8eb7e6f8df75fd8eb422675fc7678aff7364301092b1017" +dependencies = [ + "equivalent", + "hashbrown 0.16.1", + "serde", + "serde_core", +] + +[[package]] +name = "inout" +version = "0.1.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "879f10e63c20629ecabbb64a8010319738c66a5cd0c29b02d63d272b03751d01" +dependencies = [ + "block-padding", + "generic-array", +] + +[[package]] +name = "inplace-vec-builder" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cf64c2edc8226891a71f127587a2861b132d2b942310843814d5001d99a1d307" +dependencies = [ + "smallvec", +] + +[[package]] +name = "instant" +version = "0.1.13" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e0242819d153cba4b4b05a5a8f2a7e9bbf97b6055b2a002b395c96b5ff3c0222" +dependencies = [ + "cfg-if", + "js-sys", + "wasm-bindgen", + "web-sys", +] + +[[package]] +name = "ipconfig" +version = "0.3.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4d40460c0ce33d6ce4b0630ad68ff63d6661961c48b6dba35e5a4d81cfb48222" +dependencies = [ + "socket2 0.6.2", + "widestring", + "windows-registry", + "windows-result", + "windows-sys 0.61.2", +] + +[[package]] +name = "ipnet" +version = "2.12.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d98f6fed1fde3f8c21bc40a1abb88dd75e67924f9cffc3ef95607bad8017f8e2" +dependencies = [ + "serde", +] + +[[package]] +name = "iri-string" +version = "0.7.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "25e659a4bb38e810ebc252e53b5814ff908a8c58c2a9ce2fae1bbec24cbf4e20" +dependencies = [ + "memchr", + "serde", +] + +[[package]] +name = "iroh" +version = "1.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6435544bb3a5c4e6ff7affaa0c0aa0d1bca45bd700226329d5059d3eb54f9dff" +dependencies = [ + "backon", + "blake3", + "bytes", + "cfg_aliases", + "ctutils", + "data-encoding", + "derive_more", + "ed25519-dalek 3.0.0-rc.0", + "futures-util", + "getrandom 0.4.2", + "hickory-resolver", + "http 1.4.0", + "ipnet", + "iroh-base", + "iroh-dns", + "iroh-metrics", + "iroh-relay", + "n0-error", + "n0-future", + "n0-watcher", + "netwatch", + "noq", + "noq-proto", + "noq-udp", + "papaya", + "pin-project", + "portable-atomic", + "portmapper", + "rand 0.10.1", + "reqwest 0.13.4", + "rustc-hash", + "rustls 0.23.36", + "rustls-pki-types", + "serde", + "smallvec", + "strum", + "time", + "tokio", + "tokio-stream", + "tokio-util", + "tracing", + "url", + "wasm-bindgen-futures", +] + +[[package]] +name = "iroh-base" +version = "1.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c9c95e4459d9bb828a77084277abd308aa2b58a096652b079bddfd6ef2361f53" +dependencies = [ + "curve25519-dalek 5.0.0-rc.0", + "data-encoding", + "data-encoding-macro", + "derive_more", + "ed25519-dalek 3.0.0-rc.0", + "getrandom 0.4.2", + "n0-error", + "rand 0.10.1", + "serde", + "url", + "zeroize", +] + +[[package]] +name = "iroh-blobs" +version = "0.103.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5be50b0e2d0a9ba65cee4e0dfb708b3704e02ad12bd4c14c6307e94245943126" +dependencies = [ + "arrayvec", + "bao-tree", + "bytes", + "cfg_aliases", + "chrono", + "constant_time_eq 0.4.2", + "data-encoding", + "derive_more", + "genawaiter", + "getrandom 0.4.2", + "hex", + "iroh", + "iroh-base", + "iroh-io", + "iroh-metrics", + "iroh-tickets", + "iroh-util", + "irpc", + "n0-error", + "n0-future", + "nested_enum_utils", + "noq", + "postcard", + "rand 0.10.1", + "range-collections", + "redb", + "ref-cast", + "reflink-copy", + "self_cell", + "serde", + "smallvec", + "tokio", + "tracing", +] + +[[package]] +name = "iroh-dns" +version = "1.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "754f7e0c1f67938e1d671007264ffef158f14a9f795a7cc219ea68ea09a9d4c9" +dependencies = [ + "arc-swap", + "cfg_aliases", + "derive_more", + "hickory-resolver", + "iroh-base", + "n0-error", + "n0-future", + "ndk-context", + "portable-atomic", + "rand 0.10.1", + "rustls 0.23.36", + "simple-dns", + "strum", + "tokio", + "tracing", + "url", +] + +[[package]] +name = "iroh-io" +version = "0.6.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e0a5feb781017b983ff1b155cd1faf8174da2acafd807aa482876da2d7e6577a" +dependencies = [ + "bytes", + "futures-lite", + "pin-project", + "smallvec", + "tokio", +] + +[[package]] +name = "iroh-metrics" +version = "1.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "291065721ad7c477b972e581bbc528df031dc8eb5e39fe1ff3300ae5dfb157ef" +dependencies = [ + "iroh-metrics-derive", + "itoa", + "n0-error", + "portable-atomic", + "ryu", + "serde", + "tracing", +] + +[[package]] +name = "iroh-metrics-derive" +version = "1.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1ae5f0c4405d1fbc9fb16ff422ca40620e93dc36c30ecaba0c2aee3992b7bd48" +dependencies = [ + "heck", + "proc-macro2", + "quote", + "syn 2.0.114", +] + +[[package]] +name = "iroh-relay" +version = "1.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1c12e48fef252fd04f8e6b6a8802b377baf72548d62ae4838816624cd0e06b79" +dependencies = [ + "blake3", + "bytes", + "cfg_aliases", + "data-encoding", + "derive_more", + "getrandom 0.4.2", + "hickory-resolver", + "http 1.4.0", + "http-body-util", + "hyper 1.8.1", + "hyper-util", + "iroh-base", + "iroh-dns", + "iroh-metrics", + "lru 0.18.0", + "n0-error", + "n0-future", + "noq", + "noq-proto", + "num_enum", + "pin-project", + "postcard", + "rand 0.10.1", + "reqwest 0.13.4", + "rustls 0.23.36", + "rustls-pki-types", + "serde", + "serde_bytes", + "strum", + "tokio", + "tokio-rustls 0.26.4", + "tokio-util", + "tokio-websockets", + "tracing", + "url", + "vergen-gitcl", + "webpki-roots 1.0.6", + "ws_stream_wasm", +] + +[[package]] +name = "iroh-tickets" +version = "1.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "da53233419ca36bf521ed45683b7748366f9b233032891eefc2d70567a84ac54" +dependencies = [ + "data-encoding", + "derive_more", + "iroh-base", + "n0-error", + "postcard", + "serde", +] + +[[package]] +name = "iroh-util" +version = "0.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "20e41eb982f15230c55f0a70a74a514360e1f565b07861924fd0e8db172b3d00" +dependencies = [ + "derive_more", + "iroh", + "n0-error", + "n0-future", + "tokio", + "tracing", +] + +[[package]] +name = "irpc" +version = "0.17.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3623d6ff582b415904b29bbe6ebcb4a4f9a262ccdee05a45fdd003ef0950c386" +dependencies = [ + "futures-buffered", + "futures-util", + "irpc-derive", + "n0-error", + "n0-future", + "noq", + "postcard", + "rcgen", + "rustls 0.23.36", + "serde", + "smallvec", + "tokio", + "tokio-util", + "tracing", +] + +[[package]] +name = "irpc-derive" +version = "0.17.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "35c254013736de16472140d26904e6ac98e8f3887284dcf4af40f88c77411b56" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.114", +] + +[[package]] +name = "is-terminal" +version = "0.4.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3640c1c38b8e4e43584d8df18be5fc6b0aa314ce6ebf51b53313d4306cca8e46" +dependencies = [ + "hermit-abi", + "libc", + "windows-sys 0.61.2", +] + +[[package]] +name = "itoa" +version = "1.0.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "92ecc6618181def0457392ccd0ee51198e065e016d1d527a7ac1b6dc7c1f09d2" + +[[package]] +name = "jni" +version = "0.21.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1a87aa2bb7d2af34197c04845522473242e1aa17c12f4935d5856491a7fb8c97" +dependencies = [ + "cesu8", + "cfg-if", + "combine", + "jni-sys 0.3.1", + "log", + "thiserror 1.0.69", + "walkdir", + "windows-sys 0.45.0", +] + +[[package]] +name = "jni" +version = "0.22.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5efd9a482cf3a427f00d6b35f14332adc7902ce91efb778580e180ff90fa3498" +dependencies = [ + "cfg-if", + "combine", + "jni-macros", + "jni-sys 0.4.1", + "log", + "simd_cesu8", + "thiserror 2.0.18", + "walkdir", + "windows-link", +] + +[[package]] +name = "jni-macros" +version = "0.22.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a00109accc170f0bdb141fed3e393c565b6f5e072365c3bd58f5b062591560a3" +dependencies = [ + "proc-macro2", + "quote", + "rustc_version", + "simd_cesu8", + "syn 2.0.114", +] + +[[package]] +name = "jni-sys" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "41a652e1f9b6e0275df1f15b32661cf0d4b78d4d87ddec5e0c3c20f097433258" +dependencies = [ + "jni-sys 0.4.1", +] + +[[package]] +name = "jni-sys" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c6377a88cb3910bee9b0fa88d4f42e1d2da8e79915598f65fb0c7ee14c878af2" +dependencies = [ + "jni-sys-macros", +] + +[[package]] +name = "jni-sys-macros" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "38c0b942f458fe50cdac086d2f946512305e5631e720728f2a61aabcd47a6264" +dependencies = [ + "quote", + "syn 2.0.114", +] + +[[package]] +name = "js-sys" +version = "0.3.85" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8c942ebf8e95485ca0d52d97da7c5a2c387d0e7f0ba4c35e93bfcaee045955b3" +dependencies = [ + "once_cell", + "wasm-bindgen", +] + +[[package]] +name = "lazy_static" +version = "1.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bbd2bcb4c963f2ddae06a2efc7e9f3591312473c50c6685e1f298068316e66fe" + +[[package]] +name = "leb128fmt" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "09edd9e8b54e49e587e4f6295a7d29c3ea94d469cb40ab8ca70b288248a81db2" + +[[package]] +name = "libc" +version = "0.2.180" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bcc35a38544a891a5f7c865aca548a982ccb3b8650a5b06d0fd33a10283c56fc" + +[[package]] +name = "libm" +version = "0.2.16" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6d2cec3eae94f9f509c767b45932f1ada8350c4bdb85af2fcab4a3c14807981" + +[[package]] +name = "libredox" +version = "0.1.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1744e39d1d6a9948f4f388969627434e31128196de472883b39f148769bfe30a" +dependencies = [ + "bitflags 2.13.0", + "libc", + "plain", + "redox_syscall 0.7.3", +] + +[[package]] +name = "libssh2-sys" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "220e4f05ad4a218192533b300327f5150e809b54c4ec83b5a1d91833601811b9" +dependencies = [ + "cc", + "libc", + "libz-sys", + "openssl-sys", + "pkg-config", + "vcpkg", +] + +[[package]] +name = "libz-sys" +version = "1.1.29" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "85bc9657773828b90eeb625adff10eeac83cc21bbfd8e23a03eaa8a33c9e28d9" +dependencies = [ + "cc", + "libc", + "pkg-config", + "vcpkg", +] + +[[package]] +name = "linux-raw-sys" +version = "0.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "df1d3c3b53da64cf5760482273a98e575c651a67eec7f77df96b5b642de8f039" + +[[package]] +name = "litemap" +version = "0.8.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6373607a59f0be73a39b6fe456b8192fcc3585f602af20751600e974dd455e77" + +[[package]] +name = "lock_api" +version = "0.4.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "224399e74b87b5f3557511d98dff8b14089b3dadafcab6bb93eab67d3aace965" +dependencies = [ + "scopeguard", +] + +[[package]] +name = "log" +version = "0.4.29" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5e5032e24019045c762d3c0f28f5b6b8bbf38563a65908389bf7978758920897" + +[[package]] +name = "loom" +version = "0.7.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "419e0dc8046cb947daa77eb95ae174acfbddb7673b4151f56d1eed8e93fbfaca" +dependencies = [ + "cfg-if", + "generator", + "scoped-tls", + "tracing", + "tracing-subscriber", +] + +[[package]] +name = "lru" +version = "0.7.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e999beba7b6e8345721bd280141ed958096a2e4abdf74f67ff4ce49b4b54e47a" +dependencies = [ + "hashbrown 0.12.3", +] + +[[package]] +name = "lru" +version = "0.12.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "234cf4f4a04dc1f57e24b96cc0cd600cf2af460d4161ac5ecdd0af8e1f3b2a38" + +[[package]] +name = "lru" +version = "0.16.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a1dc47f592c06f33f8e3aea9591776ec7c9f9e4124778ff8a3c3b87159f7e593" + +[[package]] +name = "lru" +version = "0.18.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8a860605968fce16869fd239cf4237a82f3ac470723415db603b0e8b6c8d4fb9" +dependencies = [ + "hashbrown 0.17.1", +] + +[[package]] +name = "lru-slab" +version = "0.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "112b39cec0b298b6c1999fee3e31427f74f676e4cb9879ed1a121b43661a4154" + +[[package]] +name = "mac-addr" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d3d25b0e0b648a86960ac23b7ad4abb9717601dec6f66c165f5b037f3f03065f" + +[[package]] +name = "mainline" +version = "2.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2b751ffb57303217bcae8f490eee6044a5b40eadf6ca05ff476cad37e7b7970d" +dependencies = [ + "bytes", + "crc", + "ed25519-dalek 2.2.0", + "flume", + "lru 0.12.5", + "rand 0.8.5", + "serde", + "serde_bencode", + "serde_bytes", + "sha1_smol", + "thiserror 1.0.69", + "tracing", +] + +[[package]] +name = "matchers" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d1525a2a28c7f4fa0fc98bb91ae755d1e2d1505079e05539e35bc876b5d65ae9" +dependencies = [ + "regex-automata", +] + +[[package]] +name = "mdns-sd" +version = "0.18.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d797ab3274a16f4940f9650a29838e940223aeff31773df5c2827ad82150182f" +dependencies = [ + "fastrand", + "flume", + "if-addrs", + "log", + "mio", + "socket-pktinfo", + "socket2 0.6.2", +] + +[[package]] +name = "memchr" +version = "2.7.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f52b00d39961fc5b2736ea853c9cc86238e165017a493d1d5c8eac6bdc4cc273" + +[[package]] +name = "mime" +version = "0.3.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6877bb514081ee2a7ff5ef9de3281f14a4dd4bceac4c09388074a6b5df8a139a" + +[[package]] +name = "minimal-lexical" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "68354c5c6bd36d73ff3feceb05efa59b6acb7626617f4962be322a825e61f79a" + +[[package]] +name = "miniz_oxide" +version = "0.8.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1fa76a2c86f704bdb222d66965fb3d63269ce38518b83cb0575fca855ebb6316" +dependencies = [ + "adler2", + "simd-adler32", +] + +[[package]] +name = "mio" +version = "1.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a69bcab0ad47271a0234d9422b131806bf3968021e5dc9328caf2d4cd58557fc" +dependencies = [ + "libc", + "log", + "wasi", + "windows-sys 0.61.2", +] + +[[package]] +name = "moka" +version = "0.12.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "957228ad12042ee839f93c8f257b62b4c0ab5eaae1d4fa60de53b27c9d7c5046" +dependencies = [ + "crossbeam-channel", + "crossbeam-epoch", + "crossbeam-utils", + "equivalent", + "parking_lot 0.12.5", + "portable-atomic", + "smallvec", + "tagptr", + "uuid", +] + +[[package]] +name = "moxcms" +version = "0.7.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ac9557c559cd6fc9867e122e20d2cbefc9ca29d80d027a8e39310920ed2f0a97" +dependencies = [ + "num-traits", + "pxfm", +] + +[[package]] +name = "n0-error" +version = "1.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c37e81176a83a77d2514528b91bdafc70ef88aab428f0e1b91aebb8d99888895" +dependencies = [ + "n0-error-macros", + "spez", +] + +[[package]] +name = "n0-error-macros" +version = "1.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e2acd8b070213b0299282f884b4beba4e7b52d624fdcd504a3ad3665390c11e1" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.114", +] + +[[package]] +name = "n0-future" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e2ab99dfb861450e68853d34ae665243a88b8c493d01ba957321a1e9b2312bbe" +dependencies = [ + "cfg_aliases", + "derive_more", + "futures-buffered", + "futures-lite", + "futures-util", + "js-sys", + "pin-project", + "send_wrapper", + "tokio", + "tokio-util", + "wasm-bindgen", + "wasm-bindgen-futures", + "web-time", +] + +[[package]] +name = "n0-watcher" +version = "1.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bbc618745ad0b7414b149d0517ad8b5573b2fb4d4e2717add3d2446ce1fdd826" +dependencies = [ + "derive_more", + "n0-error", + "n0-future", +] + +[[package]] +name = "ndk-context" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "27b02d87554356db9e9a873add8782d4ea6e3e58ea071a9adb9a2e8ddb884a8b" + +[[package]] +name = "negentropy" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f0efe882e02d206d8d279c20eb40e03baf7cb5136a1476dc084a324fbc3ec42d" + +[[package]] +name = "nested_enum_utils" +version = "0.2.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b1d5475271bdd36a4a2769eac1ef88df0f99428ea43e52dfd8b0ee5cb674695f" +dependencies = [ + "proc-macro-crate", + "proc-macro2", + "quote", + "syn 2.0.114", +] + +[[package]] +name = "netdev" +version = "0.44.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9d31e7286c21ceaf0ddb1d881964011214555ea0b317cc2eb1a1d68d861386fc" +dependencies = [ + "block2", + "dispatch2", + "dlopen2", + "ipnet", + "jni 0.21.1", + "libc", + "mac-addr", + "ndk-context", + "netlink-packet-core", + "netlink-packet-route 0.29.0", + "netlink-sys", + "objc2", + "objc2-core-foundation", + "objc2-core-wlan", + "objc2-foundation", + "objc2-system-configuration", + "once_cell", + "plist", + "windows-sys 0.61.2", +] + +[[package]] +name = "netlink-packet-core" +version = "0.8.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3463cbb78394cb0141e2c926b93fc2197e473394b761986eca3b9da2c63ae0f4" +dependencies = [ + "paste", +] + +[[package]] +name = "netlink-packet-route" +version = "0.29.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "df9854ea6ad14e3f4698a7f03b65bce0833dd2d81d594a0e4a984170537146b6" +dependencies = [ + "bitflags 2.13.0", + "libc", + "log", + "netlink-packet-core", +] + +[[package]] +name = "netlink-packet-route" +version = "0.31.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e2288fcb784eb3defd5fb16f4c4160d5f477de192eac730f43e1d11c24d9a007" +dependencies = [ + "bitflags 2.13.0", + "libc", + "log", + "netlink-packet-core", +] + +[[package]] +name = "netlink-proto" +version = "0.12.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b65d130ee111430e47eed7896ea43ca693c387f097dd97376bffafbf25812128" +dependencies = [ + "bytes", + "futures", + "log", + "netlink-packet-core", + "netlink-sys", + "thiserror 2.0.18", +] + +[[package]] +name = "netlink-sys" +version = "0.8.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cd6c30ed10fa69cc491d491b85cc971f6bdeb8e7367b7cde2ee6cc878d583fae" +dependencies = [ + "bytes", + "futures-util", + "libc", + "log", + "tokio", +] + +[[package]] +name = "netwatch" +version = "0.19.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8487d26d691cd98d5c17b2adb4b1fd4b31cccc820da1eac827d483295d7bb94a" +dependencies = [ + "atomic-waker", + "bytes", + "cfg_aliases", + "derive_more", + "ipnet", + "js-sys", + "libc", + "n0-error", + "n0-future", + "n0-watcher", + "netdev", + "netlink-packet-core", + "netlink-packet-route 0.31.0", + "netlink-proto", + "netlink-sys", + "noq-udp", + "objc2-core-foundation", + "objc2-system-configuration", + "pin-project-lite", + "serde", + "socket2 0.6.2", + "time", + "tokio", + "tokio-util", + "tracing", + "web-sys", + "windows", + "windows-result", + "wmi", +] + +[[package]] +name = "nom" +version = "7.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d273983c5a657a70a3e8f2a01329822f3b8c8172b73826411a55751e404a0a4a" +dependencies = [ + "memchr", + "minimal-lexical", +] + +[[package]] +name = "noq" +version = "1.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "11f0c73794bfde94db01379c46990b9a773993fca2b61a66184ce148b7c7a187" +dependencies = [ + "bytes", + "cfg_aliases", + "derive_more", + "noq-proto", + "noq-udp", + "pin-project-lite", + "rustc-hash", + "rustls 0.23.36", + "socket2 0.6.2", + "thiserror 2.0.18", + "tokio", + "tokio-stream", + "tracing", + "web-time", +] + +[[package]] +name = "noq-proto" +version = "1.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "775be06b8d66c2c64db60140bf54dee8410f67b73c81cc1e1e32f11dfdaae501" +dependencies = [ + "aes-gcm", + "bytes", + "derive_more", + "enum-assoc", + "fastbloom", + "getrandom 0.4.2", + "identity-hash", + "lru-slab", + "rand 0.10.1", + "rand_pcg", + "ring", + "rustc-hash", + "rustls 0.23.36", + "rustls-pki-types", + "rustls-platform-verifier", + "slab", + "sorted-index-buffer", + "thiserror 2.0.18", + "tinyvec", + "tracing", + "web-time", +] + +[[package]] +name = "noq-udp" +version = "1.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cd5a37756f168cf350d68a97c4f0158bdf3c76f10175123941569b09ab51f011" +dependencies = [ + "cfg_aliases", + "libc", + "socket2 0.6.2", + "tracing", + "windows-sys 0.61.2", +] + +[[package]] +name = "nostr" +version = "0.44.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3aa5e3b6a278ed061835fe1ee293b71641e6bf8b401cfe4e1834bbf4ef0a34e1" +dependencies = [ + "aes", + "base64 0.22.1", + "bech32", + "bip39", + "bitcoin_hashes 0.14.1", + "cbc", + "chacha20 0.9.1", + "chacha20poly1305", + "getrandom 0.2.17", + "hex", + "instant", + "scrypt", + "secp256k1", + "serde", + "serde_json", + "unicode-normalization", + "url", +] + +[[package]] +name = "nostr-database" +version = "0.44.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7462c9d8ae5ef6a28d66a192d399ad2530f1f2130b13186296dbb11bdef5b3d1" +dependencies = [ + "lru 0.16.3", + "nostr", + "tokio", +] + +[[package]] +name = "nostr-gossip" +version = "0.44.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ade30de16869618919c6b5efc8258f47b654a98b51541eb77f85e8ec5e3c83a6" +dependencies = [ + "nostr", +] + +[[package]] +name = "nostr-relay-pool" +version = "0.44.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4b1073ccfbaea5549fb914a9d52c68dab2aecda61535e5143dd73e95445a804b" +dependencies = [ + "async-utility", + "async-wsocket", + "atomic-destructor", + "hex", + "lru 0.16.3", + "negentropy", + "nostr", + "nostr-database", + "tokio", + "tracing", +] + +[[package]] +name = "nostr-sdk" +version = "0.44.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "471732576710e779b64f04c55e3f8b5292f865fea228436daf19694f0bf70393" +dependencies = [ + "async-utility", + "nostr", + "nostr-database", + "nostr-gossip", + "nostr-relay-pool", + "tokio", + "tracing", +] + +[[package]] +name = "nu-ansi-term" +version = "0.50.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7957b9740744892f114936ab4a57b3f487491bbeafaf8083688b16841a4240e5" +dependencies = [ + "windows-sys 0.61.2", +] + +[[package]] +name = "num-bigint" +version = "0.4.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a5e44f723f1133c9deac646763579fdb3ac745e418f2a7af9cd0c431da1f20b9" +dependencies = [ + "num-integer", + "num-traits", +] + +[[package]] +name = "num-conv" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "521739c6d2bac4aa25192232afe6841231376b2b26d4d9fae5ecf8ca5772e441" + +[[package]] +name = "num-integer" +version = "0.1.46" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7969661fd2958a5cb096e56c8e1ad0444ac2bbcd0061bd28660485a44879858f" +dependencies = [ + "num-traits", +] + +[[package]] +name = "num-traits" +version = "0.2.19" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "071dfc062690e90b734c0b2273ce72ad0ffa95f0c74596bc250dcfd960262841" +dependencies = [ + "autocfg", +] + +[[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-macro-crate", + "proc-macro2", + "quote", + "syn 2.0.114", +] + +[[package]] +name = "num_threads" +version = "0.1.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5c7398b9c8b70908f6371f47ed36737907c87c52af34c268fed0bf0ceb92ead9" +dependencies = [ + "libc", +] + +[[package]] +name = "objc2" +version = "0.6.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3a12a8ed07aefc768292f076dc3ac8c48f3781c8f2d5851dd3d98950e8c5a89f" +dependencies = [ + "objc2-encode", +] + +[[package]] +name = "objc2-core-foundation" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2a180dd8642fa45cdb7dd721cd4c11b1cadd4929ce112ebd8b9f5803cc79d536" +dependencies = [ + "bitflags 2.13.0", + "block2", + "dispatch2", + "libc", + "objc2", +] + +[[package]] +name = "objc2-core-wlan" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c71e34919aba0d701380d911702455038a8a3587467fe0141d6a71501e7ffe48" +dependencies = [ + "bitflags 2.13.0", + "objc2", + "objc2-core-foundation", + "objc2-foundation", + "objc2-security", + "objc2-security-foundation", +] + +[[package]] +name = "objc2-encode" +version = "4.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ef25abbcd74fb2609453eb695bd2f860d389e457f67dc17cafc8b8cbc89d0c33" + +[[package]] +name = "objc2-foundation" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e3e0adef53c21f888deb4fa59fc59f7eb17404926ee8a6f59f5df0fd7f9f3272" +dependencies = [ + "bitflags 2.13.0", + "block2", + "libc", + "objc2", + "objc2-core-foundation", +] + +[[package]] +name = "objc2-security" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "709fe137109bd1e8b5a99390f77a7d8b2961dafc1a1c5db8f2e60329ad6d895a" +dependencies = [ + "bitflags 2.13.0", + "objc2", + "objc2-core-foundation", +] + +[[package]] +name = "objc2-security-foundation" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ef76382e9cedd18123099f17638715cc3d81dba3637d4c0d39ab69df2ef345a5" +dependencies = [ + "objc2", + "objc2-foundation", +] + +[[package]] +name = "objc2-system-configuration" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7216bd11cbda54ccabcab84d523dc93b858ec75ecfb3a7d89513fa22464da396" +dependencies = [ + "bitflags 2.13.0", + "dispatch2", + "libc", + "objc2", + "objc2-core-foundation", + "objc2-security", +] + +[[package]] +name = "oid-registry" +version = "0.8.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "12f40cff3dde1b6087cc5d5f5d4d65712f34016a03ed60e9c08dcc392736b5b7" +dependencies = [ + "asn1-rs", +] + +[[package]] +name = "once_cell" +version = "1.21.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "42f5e15c9953c5e4ccceeb2e7382a716482c34515315f7b03532b8b4e8393d2d" +dependencies = [ + "critical-section", + "portable-atomic", +] + +[[package]] +name = "opaque-debug" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c08d65885ee38876c4f86fa503fb49d7b507c2b62552df7c70b2fce627e06381" + +[[package]] +name = "openssl-probe" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7c87def4c32ab89d880effc9e097653c8da5d6ef28e6b539d313baaacfbafcbe" + +[[package]] +name = "openssl-sys" +version = "0.9.117" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b47e7e6bb2c38cd930d25a23b40fa52e068c10e85f3e03a7f5ba5aaca5713695" +dependencies = [ + "cc", + "libc", + "pkg-config", + "vcpkg", +] + +[[package]] +name = "papaya" +version = "0.2.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "997ee03cd38c01469a7046643714f0ad28880bcb9e6679ff0666e24817ca19b7" +dependencies = [ + "equivalent", + "seize", +] + +[[package]] +name = "parking" +version = "2.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f38d5652c16fde515bb1ecef450ab0f6a219d619a7274976324d5e377f7dceba" + +[[package]] +name = "parking_lot" +version = "0.11.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7d17b78036a60663b797adeaee46f5c9dfebb86948d1255007a1d6be0271ff99" +dependencies = [ + "instant", + "lock_api", + "parking_lot_core 0.8.6", +] + +[[package]] +name = "parking_lot" +version = "0.12.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "93857453250e3077bd71ff98b6a65ea6621a19bb0f559a85248955ac12c45a1a" +dependencies = [ + "lock_api", + "parking_lot_core 0.9.12", +] + +[[package]] +name = "parking_lot_core" +version = "0.8.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "60a2cfe6f0ad2bfc16aefa463b497d5c7a5ecd44a23efa72aa342d90177356dc" +dependencies = [ + "cfg-if", + "instant", + "libc", + "redox_syscall 0.2.16", + "smallvec", + "winapi", +] + +[[package]] +name = "parking_lot_core" +version = "0.9.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2621685985a2ebf1c516881c026032ac7deafcda1a2c9b7850dc81e3dfcb64c1" +dependencies = [ + "cfg-if", + "libc", + "redox_syscall 0.5.18", + "smallvec", + "windows-link", +] + +[[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" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "57c0d7b74b563b49d38dae00a0c37d4d6de9b432382b2892f0574ddcae73fd0a" + +[[package]] +name = "pbkdf2" +version = "0.12.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f8ed6a7761f76e3b9f92dfb0a60a6a6477c61024b775147ff0973a02653abaf2" +dependencies = [ + "digest 0.10.7", + "hmac", +] + +[[package]] +name = "pem" +version = "3.0.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1d30c53c26bc5b31a98cd02d20f25a7c8567146caf63ed593a9d87b2775291be" +dependencies = [ + "base64 0.22.1", + "serde_core", +] + +[[package]] +name = "pem-rfc7468" +version = "1.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a6305423e0e7738146434843d1694d621cce767262b2a86910beab705e4493d9" +dependencies = [ + "base64ct", +] + +[[package]] +name = "percent-encoding" +version = "2.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9b4f627cb1b25917193a259e49bdad08f671f8d9708acfd5fe0a8c1455d87220" + +[[package]] +name = "pharos" +version = "0.5.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e9567389417feee6ce15dd6527a8a1ecac205ef62c2932bcf3d9f6fc5b78b414" +dependencies = [ + "futures", + "rustc_version", +] + +[[package]] +name = "pin-project" +version = "1.1.13" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2466b2336ed02bcdca6b294417127b90ec92038d1d5c4fbeac971a922e0e0924" +dependencies = [ + "pin-project-internal", +] + +[[package]] +name = "pin-project-internal" +version = "1.1.13" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c96395f0a926bc13b1c17622aaddda1ecb55d49c8f1bf9777e4d877800a43f8b" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.114", +] + +[[package]] +name = "pin-project-lite" +version = "0.2.16" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3b3cff922bd51709b605d9ead9aa71031d81447142d828eb4a6eba76fe619f9b" + +[[package]] +name = "pin-utils" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8b870d8c151b6f2fb93e84a13146138f05d02ed11c7e7c54f8826aaaf7c9f184" + +[[package]] +name = "pkcs8" +version = "0.10.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f950b2377845cebe5cf8b5165cb3cc1a5e0fa5cfa3e1f7f55707d8fd82e0a7b7" +dependencies = [ + "der 0.7.10", + "spki 0.7.3", +] + +[[package]] +name = "pkcs8" +version = "0.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "451913da69c775a56034ea8d9003d27ee8948e12443eae7c038ba100a4f21cb7" +dependencies = [ + "der 0.8.0", + "spki 0.8.0", +] + +[[package]] +name = "pkg-config" +version = "0.3.33" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "19f132c84eca552bf34cab8ec81f1c1dcc229b811638f9d283dceabe58c5569e" + +[[package]] +name = "plain" +version = "0.2.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b4596b6d070b27117e987119b4dac604f3c58cfb0b191112e24771b2faeac1a6" + +[[package]] +name = "plist" +version = "1.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "092791278e026273c1b65bbdcfbba3a300f2994c896bd01ab01da613c29c46f1" +dependencies = [ + "base64 0.22.1", + "indexmap", + "quick-xml", + "serde", + "time", +] + +[[package]] +name = "poly1305" +version = "0.8.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8159bd90725d2df49889a078b54f4f79e87f1f8a8444194cdca81d38f5393abf" +dependencies = [ + "cpufeatures 0.2.17", + "opaque-debug", + "universal-hash", +] + +[[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.13.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c33a9471896f1c69cecef8d20cbe2f7accd12527ce60845ff44c153bb2a21b49" +dependencies = [ + "serde", +] + +[[package]] +name = "portmapper" +version = "0.19.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ccc716c56a0a50f7e4e25f41446419599d47c6197cc5c9858174220e97c272e6" +dependencies = [ + "base64 0.22.1", + "bytes", + "derive_more", + "hyper-util", + "igd-next", + "iroh-metrics", + "libc", + "n0-error", + "n0-future", + "netwatch", + "num_enum", + "rand 0.10.1", + "serde", + "smallvec", + "socket2 0.6.2", + "time", + "tokio", + "tokio-util", + "tower-layer", + "tracing", + "url", +] + +[[package]] +name = "positioned-io" +version = "0.3.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d4ec4b80060f033312b99b6874025d9503d2af87aef2dd4c516e253fbfcdada7" +dependencies = [ + "libc", + "winapi", +] + +[[package]] +name = "postcard" +version = "1.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6764c3b5dd454e283a30e6dfe78e9b31096d9e32036b5d1eaac7a6119ccb9a24" +dependencies = [ + "cobs", + "embedded-io 0.4.0", + "embedded-io 0.6.1", + "heapless", + "postcard-derive", + "serde", +] + +[[package]] +name = "postcard-derive" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e0232bd009a197ceec9cc881ba46f727fcd8060a2d8d6a9dde7a69030a6fe2bb" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.114", +] + +[[package]] +name = "potential_utf" +version = "0.1.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b73949432f5e2a09657003c25bca5e19a0e9c84f8058ca374f49e0ebe605af77" +dependencies = [ + "zerovec", +] + +[[package]] +name = "powerfmt" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "439ee305def115ba05938db6eb1644ff94165c5ab5e9420d1c1bcedbba909391" + +[[package]] +name = "ppv-lite86" +version = "0.2.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "85eae3c4ed2f50dcfe72643da4befc30deadb458a9b590d720cde2f2b1e97da9" +dependencies = [ + "zerocopy", +] + +[[package]] +name = "prefix-trie" +version = "0.8.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4cf6e3177f0684016a5c209b00882e15f8bdd3f3bb48f0491df10cd102d0c6e7" +dependencies = [ + "either", + "ipnet", + "num-traits", +] + +[[package]] +name = "prettyplease" +version = "0.2.37" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "479ca8adacdd7ce8f1fb39ce9ecccbfe93a3f1344b3d0d97f20bc0196208f62b" +dependencies = [ + "proc-macro2", + "syn 2.0.114", +] + +[[package]] +name = "proc-macro-crate" +version = "3.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e67ba7e9b2b56446f1d419b1d807906278ffa1a658a8a5d8a39dcb1f5a78614f" +dependencies = [ + "toml_edit 0.25.12+spec-1.1.0", +] + +[[package]] +name = "proc-macro-error" +version = "0.4.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "18f33027081eba0a6d8aba6d1b1c3a3be58cbb12106341c2d5759fcd9b5277e7" +dependencies = [ + "proc-macro-error-attr", + "proc-macro2", + "quote", + "syn 1.0.109", + "version_check", +] + +[[package]] +name = "proc-macro-error-attr" +version = "0.4.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8a5b4b77fdb63c1eca72173d68d24501c54ab1269409f6b672c85deb18af69de" +dependencies = [ + "proc-macro2", + "quote", + "syn 1.0.109", + "syn-mid", + "version_check", +] + +[[package]] +name = "proc-macro-hack" +version = "0.5.20+deprecated" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dc375e1527247fe1a97d8b7156678dfe7c1af2fc075c9a4db3690ecd2a148068" + +[[package]] +name = "proc-macro2" +version = "1.0.106" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8fd00f0bb2e90d81d1044c2b32617f68fcb9fa3bb7640c23e9c748e53fb30934" +dependencies = [ + "unicode-ident", +] + +[[package]] +name = "pxfm" +version = "0.1.28" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b5a041e753da8b807c9255f28de81879c78c876392ff2469cde94799b2896b9d" + +[[package]] +name = "qrcode" +version = "0.14.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d68782463e408eb1e668cf6152704bd856c78c5b6417adaee3203d8f4c1fc9ec" +dependencies = [ + "image", +] + +[[package]] +name = "quick-xml" +version = "0.39.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cdcc8dd4e2f670d309a5f0e83fe36dfdc05af317008fea29144da1a2ac858e5e" +dependencies = [ + "memchr", +] + +[[package]] +name = "quote" +version = "1.0.44" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "21b2ebcf727b7760c461f091f9f0f539b77b8e87f2fd88131e7f1b433b3cece4" +dependencies = [ + "proc-macro2", +] + +[[package]] +name = "r-efi" +version = "5.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "69cdb34c158ceb288df11e18b4bd39de994f6657d83847bdffdbd7f346754b0f" + +[[package]] +name = "r-efi" +version = "6.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f8dcc9c7d52a811697d2151c701e0d08956f92b0e24136cf4cf27b57a6a0d9bf" + +[[package]] +name = "rand" +version = "0.8.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "34af8d1a0e25924bc5b7c43c079c942339d8f0a8b57c39049bef581b46327404" +dependencies = [ + "libc", + "rand_chacha 0.3.1", + "rand_core 0.6.4", +] + +[[package]] +name = "rand" +version = "0.9.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6db2770f06117d490610c7488547d543617b21bfa07796d7a12f6f1bd53850d1" +dependencies = [ + "rand_chacha 0.9.0", + "rand_core 0.9.5", +] + +[[package]] +name = "rand" +version = "0.10.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d2e8e8bcc7961af1fdac401278c6a831614941f6164ee3bf4ce61b7edb162207" +dependencies = [ + "chacha20 0.10.0", + "getrandom 0.4.2", + "rand_core 0.10.1", +] + +[[package]] +name = "rand_chacha" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e6c10a63a0fa32252be49d21e7709d4d4baf8d231c2dbce1eaa8141b9b127d88" +dependencies = [ + "ppv-lite86", + "rand_core 0.6.4", +] + +[[package]] +name = "rand_chacha" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d3022b5f1df60f26e1ffddd6c66e8aa15de382ae63b3a0c1bfc0e4d3e3f325cb" +dependencies = [ + "ppv-lite86", + "rand_core 0.9.5", +] + +[[package]] +name = "rand_core" +version = "0.6.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ec0be4795e2f6a28069bec0b5ff3e2ac9bafc99e6a9a7dc3547996c5c816922c" +dependencies = [ + "getrandom 0.2.17", +] + +[[package]] +name = "rand_core" +version = "0.9.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "76afc826de14238e6e8c374ddcc1fa19e374fd8dd986b0d2af0d02377261d83c" +dependencies = [ + "getrandom 0.3.4", +] + +[[package]] +name = "rand_core" +version = "0.10.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "63b8176103e19a2643978565ca18b50549f6101881c443590420e4dc998a3c69" + +[[package]] +name = "rand_pcg" +version = "0.10.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "caa0f4137e1c0a72f4c651489402276c8e8e1cf081f3b0ba156d2cbeef09e86a" +dependencies = [ + "rand_core 0.10.1", +] + +[[package]] +name = "range-collections" +version = "0.4.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "861706ea9c4aded7584c5cd1d241cec2ea7f5f50999f236c22b65409a1f1a0d0" +dependencies = [ + "binary-merge", + "inplace-vec-builder", + "ref-cast", + "serde", + "smallvec", +] + +[[package]] +name = "rcgen" +version = "0.14.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "57f6d249aad744e274e682777a50283a225a32705394ee6d5fcc01efa25e4055" +dependencies = [ + "pem", + "ring", + "rustls-pki-types", + "time", + "x509-parser", + "yasna", +] + +[[package]] +name = "redb" +version = "4.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8e925444704b5f17d32bf42f5b6e2df050bceebc3dcd6e71cc73dafe8092e839" +dependencies = [ + "libc", +] + +[[package]] +name = "redox_syscall" +version = "0.2.16" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fb5a58c1855b4b6819d59012155603f0b22ad30cad752600aadfcb695265519a" +dependencies = [ + "bitflags 1.3.2", +] + +[[package]] +name = "redox_syscall" +version = "0.5.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ed2bf2547551a7053d6fdfafda3f938979645c44812fbfcda098faae3f1a362d" +dependencies = [ + "bitflags 2.13.0", +] + +[[package]] +name = "redox_syscall" +version = "0.7.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6ce70a74e890531977d37e532c34d45e9055d2409ed08ddba14529471ed0be16" +dependencies = [ + "bitflags 2.13.0", +] + +[[package]] +name = "reed-solomon-erasure" +version = "6.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7263373d500d4d4f505d43a2a662d475a894aa94503a1ee28e9188b5f3960d4f" +dependencies = [ + "libm", + "lru 0.7.8", + "parking_lot 0.11.2", + "smallvec", + "spin 0.9.8", +] + +[[package]] +name = "ref-cast" +version = "1.0.25" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f354300ae66f76f1c85c5f84693f0ce81d747e2c3f21a45fef496d89c960bf7d" +dependencies = [ + "ref-cast-impl", +] + +[[package]] +name = "ref-cast-impl" +version = "1.0.25" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b7186006dcb21920990093f30e3dea63b7d6e977bf1256be20c3563a5db070da" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.114", +] + +[[package]] +name = "reflink-copy" +version = "0.1.29" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "13362233b147e57674c37b802d216b7c5e3dcccbed8967c84f0d8d223868ae27" +dependencies = [ + "cfg-if", + "libc", + "rustix", + "windows", +] + +[[package]] +name = "regex" +version = "1.12.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "843bc0191f75f3e22651ae5f1e72939ab2f72a4bc30fa80a066bd66edefc24d4" +dependencies = [ + "aho-corasick", + "memchr", + "regex-automata", + "regex-syntax", +] + +[[package]] +name = "regex-automata" +version = "0.4.13" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5276caf25ac86c8d810222b3dbb938e512c55c6831a10f3e6ed1c93b84041f1c" +dependencies = [ + "aho-corasick", + "memchr", + "regex-syntax", +] + +[[package]] +name = "regex-syntax" +version = "0.8.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7a2d987857b319362043e95f5353c0535c1f58eec5336fdfcf626430af7def58" + +[[package]] +name = "reqwest" +version = "0.11.27" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dd67538700a17451e7cba03ac727fb961abb7607553461627b97de0b89cf4a62" +dependencies = [ + "base64 0.21.7", + "bytes", + "encoding_rs", + "futures-core", + "futures-util", + "h2 0.3.27", + "http 0.2.12", + "http-body 0.4.6", + "hyper 0.14.32", + "hyper-rustls 0.24.2", + "ipnet", + "js-sys", + "log", + "mime", + "once_cell", + "percent-encoding", + "pin-project-lite", + "rustls 0.21.12", + "rustls-pemfile", + "serde", + "serde_json", + "serde_urlencoded", + "sync_wrapper 0.1.2", + "system-configuration 0.5.1", + "tokio", + "tokio-rustls 0.24.1", + "tokio-socks", + "tokio-util", + "tower-service", + "url", + "wasm-bindgen", + "wasm-bindgen-futures", + "wasm-streams 0.4.2", + "web-sys", + "webpki-roots 0.25.4", + "winreg", +] + +[[package]] +name = "reqwest" +version = "0.13.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "219c5811de6525e5416c7d5d53bb656d3afdbc6c5af816e0802bcfa42dbdc1c3" +dependencies = [ + "base64 0.22.1", + "bytes", + "futures-core", + "futures-util", + "http 1.4.0", + "http-body 1.0.1", + "http-body-util", + "hyper 1.8.1", + "hyper-rustls 0.27.9", + "hyper-util", + "js-sys", + "log", + "percent-encoding", + "pin-project-lite", + "rustls 0.23.36", + "rustls-pki-types", + "rustls-platform-verifier", + "sync_wrapper 1.0.2", + "tokio", + "tokio-rustls 0.26.4", + "tokio-util", + "tower", + "tower-http", + "tower-service", + "url", + "wasm-bindgen", + "wasm-bindgen-futures", + "wasm-streams 0.5.0", + "web-sys", +] + +[[package]] +name = "resolv-conf" +version = "0.7.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1e061d1b48cb8d38042de4ae0a7a6401009d6143dc80d2e2d6f31f0bdd6470c7" + +[[package]] +name = "ring" +version = "0.17.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a4689e6c2294d81e88dc6261c768b63bc4fcdb852be6d1352498b114f61383b7" +dependencies = [ + "cc", + "cfg-if", + "getrandom 0.2.17", + "libc", + "untrusted", + "windows-sys 0.52.0", +] + +[[package]] +name = "rustc-hash" +version = "2.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "94300abf3f1ae2e2b8ffb7b58043de3d399c73fa6f4b73826402a5c457614dbe" + +[[package]] +name = "rustc_version" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cfcb3a22ef46e85b45de6ee7e79d063319ebb6594faafcf1c225ea92ab6e9b92" +dependencies = [ + "semver", +] + +[[package]] +name = "rusticata-macros" +version = "4.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "faf0c4a6ece9950b9abdb62b1cfcf2a68b3b67a10ba445b3bb85be2a293d0632" +dependencies = [ + "nom", +] + +[[package]] +name = "rustix" +version = "1.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "146c9e247ccc180c1f61615433868c99f3de3ae256a30a43b49f67c2d9171f34" +dependencies = [ + "bitflags 2.13.0", + "errno", + "libc", + "linux-raw-sys", + "windows-sys 0.61.2", +] + +[[package]] +name = "rustls" +version = "0.21.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3f56a14d1f48b391359b22f731fd4bd7e43c97f3c50eee276f3aa09c94784d3e" +dependencies = [ + "log", + "ring", + "rustls-webpki 0.101.7", + "sct", +] + +[[package]] +name = "rustls" +version = "0.23.36" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c665f33d38cea657d9614f766881e4d510e0eda4239891eea56b4cadcf01801b" +dependencies = [ + "log", + "once_cell", + "ring", + "rustls-pki-types", + "rustls-webpki 0.103.9", + "subtle", + "zeroize", +] + +[[package]] +name = "rustls-native-certs" +version = "0.8.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dab5152771c58876a2146916e53e35057e1a4dfa2b9df0f0305b07f611fdea4d" +dependencies = [ + "openssl-probe", + "rustls-pki-types", + "schannel", + "security-framework", +] + +[[package]] +name = "rustls-pemfile" +version = "1.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1c74cae0a4cf6ccbbf5f359f08efdf8ee7e1dc532573bf0db71968cb56b1448c" +dependencies = [ + "base64 0.21.7", +] + +[[package]] +name = "rustls-pki-types" +version = "1.14.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "be040f8b0a225e40375822a563fa9524378b9d63112f53e19ffff34df5d33fdd" +dependencies = [ + "web-time", + "zeroize", +] + +[[package]] +name = "rustls-platform-verifier" +version = "0.7.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "26d1e2536ce4f35f4846aa13bff16bd0ff40157cdb14cc056c7b14ba41233ba0" +dependencies = [ + "core-foundation 0.10.1", + "core-foundation-sys", + "jni 0.22.4", + "log", + "once_cell", + "rustls 0.23.36", + "rustls-native-certs", + "rustls-platform-verifier-android", + "rustls-webpki 0.103.9", + "security-framework", + "security-framework-sys", + "webpki-root-certs", + "windows-sys 0.61.2", +] + +[[package]] +name = "rustls-platform-verifier-android" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f87165f0995f63a9fbeea62b64d10b4d9d8e78ec6d7d51fb2125fda7bb36788f" + +[[package]] +name = "rustls-webpki" +version = "0.101.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8b6275d1ee7a1cd780b64aca7726599a1dbc893b1e64144529e55c3c2f745765" +dependencies = [ + "ring", + "untrusted", +] + +[[package]] +name = "rustls-webpki" +version = "0.103.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d7df23109aa6c1567d1c575b9952556388da57401e4ace1d15f79eedad0d8f53" +dependencies = [ + "ring", + "rustls-pki-types", + "untrusted", +] + +[[package]] +name = "rustversion" +version = "1.0.22" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b39cdef0fa800fc44525c84ccb54a029961a8215f9619753635a9c0d2538d46d" + +[[package]] +name = "ryu" +version = "1.0.22" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a50f4cf475b65d88e057964e0e9bb1f0aa9bbb2036dc65c64596b42932536984" + +[[package]] +name = "salsa20" +version = "0.10.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "97a22f5af31f73a954c10289c93e8a50cc23d971e80ee446f1f6f7137a088213" +dependencies = [ + "cipher", +] + +[[package]] +name = "same-file" +version = "1.0.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "93fc1dc3aaa9bfed95e02e6eadabb4baf7e3078b0bd1b4d7b6b0b68378900502" +dependencies = [ + "winapi-util", +] + +[[package]] +name = "schannel" +version = "0.1.29" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "91c1b7e4904c873ef0710c1f407dde2e6287de2bebc1bbbf7d430bb7cbffd939" +dependencies = [ + "windows-sys 0.61.2", +] + +[[package]] +name = "scoped-tls" +version = "1.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e1cf6437eb19a8f4a6cc0f7dca544973b0b78843adbfeb3683d1a94a0024a294" + +[[package]] +name = "scopeguard" +version = "1.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "94143f37725109f92c262ed2cf5e59bce7498c01bcc1502d7b9afe439a4e9f49" + +[[package]] +name = "scrypt" +version = "0.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0516a385866c09368f0b5bcd1caff3366aace790fcd46e2bb032697bb172fd1f" +dependencies = [ + "password-hash", + "pbkdf2", + "salsa20", + "sha2 0.10.9", +] + +[[package]] +name = "sct" +version = "0.7.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "da046153aa2352493d6cb7da4b6e5c0c057d8a1d0a9aa8560baffdd945acd414" +dependencies = [ + "ring", + "untrusted", +] + +[[package]] +name = "sd-notify" +version = "0.4.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b943eadf71d8b69e661330cb0e2656e31040acf21ee7708e2c238a0ec6af2bf4" +dependencies = [ + "libc", +] + +[[package]] +name = "secp256k1" +version = "0.29.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9465315bc9d4566e1724f0fffcbcc446268cb522e60f9a27bcded6b19c108113" +dependencies = [ + "bitcoin_hashes 0.14.1", + "rand 0.8.5", + "secp256k1-sys", + "serde", +] + +[[package]] +name = "secp256k1-sys" +version = "0.10.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d4387882333d3aa8cb20530a17c69a3752e97837832f34f6dccc760e715001d9" +dependencies = [ + "cc", +] + +[[package]] +name = "security-framework" +version = "3.7.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b7f4bc775c73d9a02cde8bf7b2ec4c9d12743edf609006c7facc23998404cd1d" +dependencies = [ + "bitflags 2.13.0", + "core-foundation 0.10.1", + "core-foundation-sys", + "libc", + "security-framework-sys", +] + +[[package]] +name = "security-framework-sys" +version = "2.17.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6ce2691df843ecc5d231c0b14ece2acc3efb62c0a398c7e1d875f3983ce020e3" +dependencies = [ + "core-foundation-sys", + "libc", +] + +[[package]] +name = "seize" +version = "0.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5b55fb86dfd3a2f5f76ea78310a88f96c4ea21a3031f8d212443d56123fd0521" +dependencies = [ + "libc", + "windows-sys 0.61.2", +] + +[[package]] +name = "self_cell" +version = "1.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b12e76d157a900eb52e81bc6e9f3069344290341720e9178cde2407113ac8d89" + +[[package]] +name = "semver" +version = "1.0.27" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d767eb0aabc880b29956c35734170f26ed551a859dbd361d140cdbeca61ab1e2" + +[[package]] +name = "send_wrapper" +version = "0.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cd0b0ec5f1c1ca621c432a25813d8d60c88abe6d3e08a3eb9cf37d97a0fe3d73" + +[[package]] +name = "serde" +version = "1.0.228" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9a8e94ea7f378bd32cbbd37198a4a91436180c5bb472411e48b5ec2e2124ae9e" +dependencies = [ + "serde_core", + "serde_derive", +] + +[[package]] +name = "serde_bencode" +version = "0.2.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a70dfc7b7438b99896e7f8992363ab8e2c4ba26aa5ec675d32d1c3c2c33d413e" +dependencies = [ + "serde", + "serde_bytes", +] + +[[package]] +name = "serde_bytes" +version = "0.11.19" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a5d440709e79d88e51ac01c4b72fc6cb7314017bb7da9eeff678aa94c10e3ea8" +dependencies = [ + "serde", + "serde_core", +] + +[[package]] +name = "serde_core" +version = "1.0.228" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "41d385c7d4ca58e59fc732af25c3983b67ac852c1a25000afe1175de458b67ad" +dependencies = [ + "serde_derive", +] + +[[package]] +name = "serde_derive" +version = "1.0.228" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d540f220d3187173da220f885ab66608367b6574e925011a9353e4badda91d79" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.114", +] + +[[package]] +name = "serde_json" +version = "1.0.149" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "83fc039473c5595ace860d8c4fafa220ff474b3fc6bfdb4293327f1a37e94d86" +dependencies = [ + "itoa", + "memchr", + "serde", + "serde_core", + "zmij", +] + +[[package]] +name = "serde_spanned" +version = "0.6.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bf41e0cfaf7226dca15e8197172c295a782857fcb97fad1808a166870dee75a3" +dependencies = [ + "serde", +] + +[[package]] +name = "serde_urlencoded" +version = "0.7.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d3491c14715ca2294c4d6a88f15e84739788c1d030eed8c110436aafdaa2f3fd" +dependencies = [ + "form_urlencoded", + "itoa", + "ryu", + "serde", +] + +[[package]] +name = "serde_yaml" +version = "0.9.34+deprecated" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6a8b1a1a2ebf674015cc02edccce75287f1a0130d394307b36743c2f5d504b47" +dependencies = [ + "indexmap", + "itoa", + "ryu", + "serde", + "unsafe-libyaml", +] + +[[package]] +name = "serdect" +version = "0.4.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "66cf8fedced2fcf12406bcb34223dffb92eaf34908ede12fed414c82b7f00b3e" +dependencies = [ + "base16ct", + "serde", +] + +[[package]] +name = "serial2" +version = "0.2.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9e1401f562d358cdfdbdf8946e51a7871ede1db68bd0fd99bedc79e400241550" +dependencies = [ + "cfg-if", + "libc", + "winapi", +] + +[[package]] +name = "serial2-tokio" +version = "0.1.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b253fd088ff95a617a48e4f01e5543be9072e48663cc4e5a9544f0b258de1e36" +dependencies = [ + "libc", + "serial2", + "tokio", + "winapi", +] + +[[package]] +name = "sha-1" +version = "0.10.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f5058ada175748e33390e40e872bd0fe59a19f265d0158daa551c5a88a76009c" +dependencies = [ + "cfg-if", + "cpufeatures 0.2.17", + "digest 0.10.7", +] + +[[package]] +name = "sha1" +version = "0.10.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e3bf829a2d51ab4a5ddf1352d8470c140cadc8301b2ae1789db023f01cedd6ba" +dependencies = [ + "cfg-if", + "cpufeatures 0.2.17", + "digest 0.10.7", +] + +[[package]] +name = "sha1_smol" +version = "1.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bbfa15b3dddfee50a0fff136974b3e1bde555604ba463834a7eb7deb6417705d" + +[[package]] +name = "sha2" +version = "0.10.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a7507d819769d01a365ab707794a4084392c824f54a7a6a7862f8c3d0892b283" +dependencies = [ + "cfg-if", + "cpufeatures 0.2.17", + "digest 0.10.7", +] + +[[package]] +name = "sha2" +version = "0.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "446ba717509524cb3f22f17ecc096f10f4822d76ab5c0b9822c5f9c284e825f4" +dependencies = [ + "cfg-if", + "cpufeatures 0.3.0", + "digest 0.11.3", +] + +[[package]] +name = "sharded-slab" +version = "0.1.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f40ca3c46823713e0d4209592e8d6e826aa57e928f09752619fc696c499637f6" +dependencies = [ + "lazy_static", +] + +[[package]] +name = "shlex" +version = "1.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0fda2ff0d084019ba4d7c6f371c95d8fd75ce3524c3cb8fb653a3023f6323e64" + +[[package]] +name = "signal-hook-registry" +version = "1.4.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c4db69cba1110affc0e9f7bcd48bbf87b3f4fc7c61fc9155afd4c469eb3d6c1b" +dependencies = [ + "errno", + "libc", +] + +[[package]] +name = "signature" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "77549399552de45a898a580c1b41d445bf730df867cc44e6c0233bbc4b8329de" +dependencies = [ + "rand_core 0.6.4", +] + +[[package]] +name = "signature" +version = "3.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "28d567dcbaf0049cb8ac2608a76cd95ff9e4412e1899d389ee400918ca7537f5" + +[[package]] +name = "simd-adler32" +version = "0.3.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e320a6c5ad31d271ad523dcf3ad13e2767ad8b1cb8f047f75a8aeaf8da139da2" + +[[package]] +name = "simd_cesu8" +version = "1.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "94f90157bb87cddf702797c5dadfa0be7d266cdf49e22da2fcaa32eff75b2c33" +dependencies = [ + "rustc_version", + "simdutf8", +] + +[[package]] +name = "simdutf8" +version = "0.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e3a9fe34e3e7a50316060351f37187a3f546bce95496156754b601a5fa71b76e" + +[[package]] +name = "simple-dns" +version = "0.11.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7a75cbde1bf934313596a004973e462f9a82caa814dcf1a5f507bdf51597eeb4" +dependencies = [ + "bitflags 2.13.0", +] + +[[package]] +name = "siphasher" +version = "1.0.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8ee5873ec9cce0195efcb7a4e9507a04cd49aec9c83d0389df45b1ef7ba2e649" + +[[package]] +name = "slab" +version = "0.4.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7a2ae44ef20feb57a68b23d846850f861394c2e02dc425a50098ae8c90267589" + +[[package]] +name = "smallvec" +version = "1.15.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "67b1b7a3b5fe4f1376887184045fcf45c69e92af734b7aaddc05fb777b6fbd03" +dependencies = [ + "serde", +] + +[[package]] +name = "socket-pktinfo" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "927136cc2ae6a1b0e66ac6b1210902b75c3f726db004a73bc18686dcd0dcd22f" +dependencies = [ + "libc", + "socket2 0.6.2", + "windows-sys 0.60.2", +] + +[[package]] +name = "socket2" +version = "0.5.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e22376abed350d73dd1cd119b57ffccad95b4e585a7cda43e286245ce23c0678" +dependencies = [ + "libc", + "windows-sys 0.52.0", +] + +[[package]] +name = "socket2" +version = "0.6.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "86f4aa3ad99f2088c990dfa82d367e19cb29268ed67c574d10d0a4bfe71f07e0" +dependencies = [ + "libc", + "windows-sys 0.60.2", +] + +[[package]] +name = "sorted-index-buffer" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ea06cc588e43c632923a55450401b8f25e628131571d4e1baea1bdfdb2b5ed06" + +[[package]] +name = "spez" +version = "0.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c87e960f4dca2788eeb86bbdde8dd246be8948790b7618d656e68f9b720a86e8" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.114", +] + +[[package]] +name = "spin" +version = "0.9.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6980e8d7511241f8acf4aebddbb1ff938df5eebe98691418c4468d0b72a96a67" +dependencies = [ + "lock_api", +] + +[[package]] +name = "spin" +version = "0.10.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d5fe4ccb98d9c292d56fec89a5e07da7fc4cf0dc11e156b41793132775d3e591" + +[[package]] +name = "spki" +version = "0.7.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d91ed6c858b01f942cd56b37a94b3e0a1798290327d1236e4d9cf4eaca44d29d" +dependencies = [ + "base64ct", + "der 0.7.10", +] + +[[package]] +name = "spki" +version = "0.8.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1d9efca8738c78ee9484207732f728b1ef517bbb1833d6fc0879ca898a522f6f" +dependencies = [ + "base64ct", + "der 0.8.0", +] + +[[package]] +name = "ssh2" +version = "0.9.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2f84d13b3b8a0d4e91a2629911e951db1bb8671512f5c09d7d4ba34500ba68c8" +dependencies = [ + "bitflags 2.13.0", + "libc", + "libssh2-sys", + "parking_lot 0.12.5", +] + +[[package]] +name = "stable_deref_trait" +version = "1.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6ce2be8dc25455e1f91df71bfa12ad37d7af1092ae736f3a6cd0e37bc7810596" + +[[package]] +name = "strsim" +version = "0.11.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7da8b5736845d9f2fcb837ea5d9e2628564b3b043a70948a3f0b778838c5fb4f" + +[[package]] +name = "strum" +version = "0.28.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9628de9b8791db39ceda2b119bbe13134770b56c138ec1d3af810d045c04f9bd" +dependencies = [ + "strum_macros", +] + +[[package]] +name = "strum_macros" +version = "0.28.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ab85eea0270ee17587ed4156089e10b9e6880ee688791d45a905f5b1ca36f664" +dependencies = [ + "heck", + "proc-macro2", + "quote", + "syn 2.0.114", +] + +[[package]] +name = "subtle" +version = "2.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "13c2bddecc57b384dee18652358fb23172facb8a2c51ccc10d74c157bdea3292" + +[[package]] +name = "syn" +version = "1.0.109" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "72b64191b275b66ffe2469e8af2c1cfe3bafa67b529ead792a6d0160888b4237" +dependencies = [ + "proc-macro2", + "quote", + "unicode-ident", +] + +[[package]] +name = "syn" +version = "2.0.114" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d4d107df263a3013ef9b1879b0df87d706ff80f65a86ea879bd9c31f9b307c2a" +dependencies = [ + "proc-macro2", + "quote", + "unicode-ident", +] + +[[package]] +name = "syn-mid" +version = "0.5.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fea305d57546cc8cd04feb14b62ec84bf17f50e3f7b12560d7bfa9265f39d9ed" +dependencies = [ + "proc-macro2", + "quote", + "syn 1.0.109", +] + +[[package]] +name = "sync_wrapper" +version = "0.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2047c6ded9c721764247e62cd3b03c09ffc529b2ba5b10ec482ae507a4a70160" + +[[package]] +name = "sync_wrapper" +version = "1.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0bf256ce5efdfa370213c1dabab5935a12e49f2c58d15e9eac2870d3b4f27263" +dependencies = [ + "futures-core", +] + +[[package]] +name = "synstructure" +version = "0.13.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "728a70f3dbaf5bab7f0c4b1ac8d7ae5ea60a4b5549c8a5914361c99147a709d2" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.114", +] + +[[package]] +name = "system-configuration" +version = "0.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ba3a3adc5c275d719af8cb4272ea1c4a6d668a777f37e115f6d11ddbc1c8e0e7" +dependencies = [ + "bitflags 1.3.2", + "core-foundation 0.9.4", + "system-configuration-sys 0.5.0", +] + +[[package]] +name = "system-configuration" +version = "0.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3c879d448e9d986b661742763247d3693ed13609438cf3d006f51f5368a5ba6b" +dependencies = [ + "bitflags 2.13.0", + "core-foundation 0.9.4", + "system-configuration-sys 0.6.0", +] + +[[package]] +name = "system-configuration" +version = "0.7.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a13f3d0daba03132c0aa9767f98351b3488edc2c100cda2d2ec2b04f3d8d3c8b" +dependencies = [ + "bitflags 2.13.0", + "core-foundation 0.9.4", + "system-configuration-sys 0.6.0", +] + +[[package]] +name = "system-configuration-sys" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a75fb188eb626b924683e3b95e3a48e63551fcfb51949de2f06a9d91dbee93c9" +dependencies = [ + "core-foundation-sys", + "libc", +] + +[[package]] +name = "system-configuration-sys" +version = "0.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8e1d1b10ced5ca923a1fcb8d03e96b8d3268065d724548c0211415ff6ac6bac4" +dependencies = [ + "core-foundation-sys", + "libc", +] + +[[package]] +name = "tagptr" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7b2093cf4c8eb1e67749a6762251bc9cd836b6fc171623bd0a9d324d37af2417" + +[[package]] +name = "tar" +version = "0.4.44" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1d863878d212c87a19c1a610eb53bb01fe12951c0501cf5a0d65f724914a667a" +dependencies = [ + "filetime", + "libc", + "xattr", +] + +[[package]] +name = "tempfile" +version = "3.24.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "655da9c7eb6305c55742045d5a8d2037996d61d8de95806335c7c86ce0f82e9c" +dependencies = [ + "fastrand", + "getrandom 0.3.4", + "once_cell", + "rustix", + "windows-sys 0.61.2", +] + +[[package]] +name = "termcolor" +version = "1.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "06794f8f6c5c898b3275aebefa6b8a1cb24cd2c6c79397ab15774837a0bc5755" +dependencies = [ + "winapi-util", +] + +[[package]] +name = "thiserror" +version = "1.0.69" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6aaf5339b578ea85b50e080feb250a3e8ae8cfcdff9a461c9ec2904bc923f52" +dependencies = [ + "thiserror-impl 1.0.69", +] + +[[package]] +name = "thiserror" +version = "2.0.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4288b5bcbc7920c07a1149a35cf9590a2aa808e0bc1eafaade0b80947865fbc4" +dependencies = [ + "thiserror-impl 2.0.18", +] + +[[package]] +name = "thiserror-impl" +version = "1.0.69" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4fee6c4efc90059e10f81e6d42c60a18f76588c3d74cb83a0b242a2b6c7504c1" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.114", +] + +[[package]] +name = "thiserror-impl" +version = "2.0.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ebc4ee7f67670e9b64d05fa4253e753e016c6c95ff35b89b7941d6b856dec1d5" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.114", +] + +[[package]] +name = "thread_local" +version = "1.1.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f60246a4944f24f6e018aa17cdeffb7818b76356965d03b07d6a9886e8962185" +dependencies = [ + "cfg-if", +] + +[[package]] +name = "time" +version = "0.3.49" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "711a53c2d47bbd818258c498c8dbfe186a2526c631495cfe7e078567f86b8469" +dependencies = [ + "deranged", + "js-sys", + "libc", + "num-conv", + "num_threads", + "powerfmt", + "serde_core", + "time-core", + "time-macros", +] + +[[package]] +name = "time-core" +version = "0.1.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9e1c906769ad99c88eaa54e728060edef082f8e358ff32030cb7c7d315e81109" + +[[package]] +name = "time-macros" +version = "0.2.29" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "71c652a3727a9cbb9a02f707f530b618ce00d0ccd762009c8c23bd191df3c17d" +dependencies = [ + "num-conv", + "time-core", +] + +[[package]] +name = "tinystr" +version = "0.8.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "42d3e9c45c09de15d06dd8acf5f4e0e399e85927b7f00711024eb7ae10fa4869" +dependencies = [ + "displaydoc", + "zerovec", +] + +[[package]] +name = "tinyvec" +version = "1.10.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bfa5fdc3bce6191a1dbc8c02d5c8bffcf557bafa17c124c5264a458f1b0613fa" +dependencies = [ + "tinyvec_macros", +] + +[[package]] +name = "tinyvec_macros" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1f3ccbac311fea05f86f61904b462b55fb3df8837a366dfc601a0161d0532f20" + +[[package]] +name = "tokio" +version = "1.49.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "72a2903cd7736441aac9df9d7688bd0ce48edccaadf181c3b90be801e81d3d86" +dependencies = [ + "bytes", + "libc", + "mio", + "parking_lot 0.12.5", + "pin-project-lite", + "signal-hook-registry", + "socket2 0.6.2", + "tokio-macros", + "windows-sys 0.61.2", +] + +[[package]] +name = "tokio-macros" +version = "2.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "af407857209536a95c8e56f8231ef2c2e2aff839b22e07a1ffcbc617e9db9fa5" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.114", +] + +[[package]] +name = "tokio-rustls" +version = "0.24.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c28327cf380ac148141087fbfb9de9d7bd4e84ab5d2c28fbc911d753de8a7081" +dependencies = [ + "rustls 0.21.12", + "tokio", +] + +[[package]] +name = "tokio-rustls" +version = "0.26.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1729aa945f29d91ba541258c8df89027d5792d85a8841fb65e8bf0f4ede4ef61" +dependencies = [ + "rustls 0.23.36", + "tokio", +] + +[[package]] +name = "tokio-socks" +version = "0.5.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0d4770b8024672c1101b3f6733eab95b18007dbe0847a8afe341fcf79e06043f" +dependencies = [ + "either", + "futures-util", + "thiserror 1.0.69", + "tokio", +] + +[[package]] +name = "tokio-stream" +version = "0.1.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "32da49809aab5c3bc678af03902d4ccddea2a87d028d86392a4b1560c6906c70" +dependencies = [ + "futures-core", + "pin-project-lite", + "tokio", + "tokio-util", +] + +[[package]] +name = "tokio-test" +version = "0.4.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3f6d24790a10a7af737693a3e8f1d03faef7e6ca0cc99aae5066f533766de545" +dependencies = [ + "futures-core", + "tokio", + "tokio-stream", +] + +[[package]] +name = "tokio-tungstenite" +version = "0.20.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "212d5dcb2a1ce06d81107c3d0ffa3121fe974b73f068c8282cb1c32328113b6c" +dependencies = [ + "futures-util", + "log", + "tokio", + "tungstenite 0.20.1", +] + +[[package]] +name = "tokio-tungstenite" +version = "0.26.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7a9daff607c6d2bf6c16fd681ccb7eecc83e4e2cdc1ca067ffaadfca5de7f084" +dependencies = [ + "futures-util", + "log", + "rustls 0.23.36", + "rustls-pki-types", + "tokio", + "tokio-rustls 0.26.4", + "tungstenite 0.26.2", + "webpki-roots 0.26.11", +] + +[[package]] +name = "tokio-util" +version = "0.7.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9ae9cec805b01e8fc3fd2fe289f89149a9b66dd16786abd8b19cfa7b48cb0098" +dependencies = [ + "bytes", + "futures-core", + "futures-sink", + "futures-util", + "pin-project-lite", + "tokio", +] + +[[package]] +name = "tokio-websockets" +version = "0.13.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dad543404f98bfc969aeb71994105c592acfc6c43323fddcd016bb208d1c65cb" +dependencies = [ + "base64 0.22.1", + "bytes", + "futures-core", + "futures-sink", + "getrandom 0.4.2", + "http 1.4.0", + "httparse", + "rand 0.10.1", + "ring", + "rustls-pki-types", + "sha1_smol", + "simdutf8", + "tokio", + "tokio-rustls 0.26.4", + "tokio-util", +] + +[[package]] +name = "toml" +version = "0.8.23" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dc1beb996b9d83529a9e75c17a1686767d148d70663143c7854d8b4a09ced362" +dependencies = [ + "serde", + "serde_spanned", + "toml_datetime 0.6.11", + "toml_edit 0.22.27", +] + +[[package]] +name = "toml_datetime" +version = "0.6.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "22cddaf88f4fbc13c51aebbf5f8eceb5c7c5a9da2ac40a13519eb5b0a0e8f11c" +dependencies = [ + "serde", +] + +[[package]] +name = "toml_datetime" +version = "1.1.1+spec-1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3165f65f62e28e0115a00b2ebdd37eb6f3b641855f9d636d3cd4103767159ad7" +dependencies = [ + "serde_core", +] + +[[package]] +name = "toml_edit" +version = "0.22.27" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "41fe8c660ae4257887cf66394862d21dbca4a6ddd26f04a3560410406a2f819a" +dependencies = [ + "indexmap", + "serde", + "serde_spanned", + "toml_datetime 0.6.11", + "toml_write", + "winnow 0.7.14", +] + +[[package]] +name = "toml_edit" +version = "0.25.12+spec-1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d2153edc6955a6c354fad8f5efd38b6a8769bdccf9fe50f8e1329f81b0baa5d7" +dependencies = [ + "indexmap", + "toml_datetime 1.1.1+spec-1.1.0", + "toml_parser", + "winnow 1.0.3", +] + +[[package]] +name = "toml_parser" +version = "1.1.2+spec-1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a2abe9b86193656635d2411dc43050282ca48aa31c2451210f4202550afb7526" +dependencies = [ + "winnow 1.0.3", +] + +[[package]] +name = "toml_write" +version = "0.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5d99f8c9a7727884afe522e9bd5edbfc91a3312b36a77b5fb8926e4c31a41801" + +[[package]] +name = "totp-rs" +version = "5.7.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f124352108f58ef88299e909f6e9470f1cdc8d2a1397963901b4a6366206bf72" +dependencies = [ + "base32", + "constant_time_eq 0.3.1", + "hmac", + "rand 0.9.2", + "sha1", + "sha2 0.10.9", + "url", + "urlencoding", +] + +[[package]] +name = "tower" +version = "0.5.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ebe5ef63511595f1344e2d5cfa636d973292adc0eec1f0ad45fae9f0851ab1d4" +dependencies = [ + "futures-core", + "futures-util", + "pin-project-lite", + "sync_wrapper 1.0.2", + "tokio", + "tower-layer", + "tower-service", +] + +[[package]] +name = "tower-http" +version = "0.6.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d4e6559d53cc268e5031cd8429d05415bc4cb4aefc4aa5d6cc35fbf5b924a1f8" +dependencies = [ + "bitflags 2.13.0", + "bytes", + "futures-util", + "http 1.4.0", + "http-body 1.0.1", + "iri-string", + "pin-project-lite", + "tower", + "tower-layer", + "tower-service", + "tracing", +] + +[[package]] +name = "tower-layer" +version = "0.3.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "121c2a6cda46980bb0fcd1647ffaf6cd3fc79a013de288782836f6df9c48780e" + +[[package]] +name = "tower-service" +version = "0.3.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8df9b6e13f2d32c91b9bd719c00d1958837bc7dec474d94952798cc8e69eeec3" + +[[package]] +name = "tracing" +version = "0.1.44" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "63e71662fa4b2a2c3a26f570f037eb95bb1f85397f3cd8076caed2f026a6d100" +dependencies = [ + "log", + "pin-project-lite", + "tracing-attributes", + "tracing-core", +] + +[[package]] +name = "tracing-attributes" +version = "0.1.31" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7490cfa5ec963746568740651ac6781f701c9c5ea257c58e057f3ba8cf69e8da" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.114", +] + +[[package]] +name = "tracing-core" +version = "0.1.36" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "db97caf9d906fbde555dd62fa95ddba9eecfd14cb388e4f491a66d74cd5fb79a" +dependencies = [ + "once_cell", + "valuable", +] + +[[package]] +name = "tracing-log" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ee855f1f400bd0e5c02d150ae5de3840039a3f54b025156404e34c23c03f47c3" +dependencies = [ + "log", + "once_cell", + "tracing-core", +] + +[[package]] +name = "tracing-subscriber" +version = "0.3.22" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2f30143827ddab0d256fd843b7a66d164e9f271cfa0dde49142c5ca0ca291f1e" +dependencies = [ + "matchers", + "nu-ansi-term", + "once_cell", + "regex-automata", + "sharded-slab", + "smallvec", + "thread_local", + "tracing", + "tracing-core", + "tracing-log", +] + +[[package]] +name = "try-lock" +version = "0.2.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e421abadd41a4225275504ea4d6566923418b7f05506fbc9c0fe86ba7396114b" + +[[package]] +name = "tungstenite" +version = "0.20.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9e3dac10fd62eaf6617d3a904ae222845979aec67c615d1c842b4002c7666fb9" +dependencies = [ + "byteorder", + "bytes", + "data-encoding", + "http 0.2.12", + "httparse", + "log", + "rand 0.8.5", + "sha1", + "thiserror 1.0.69", + "url", + "utf-8", +] + +[[package]] +name = "tungstenite" +version = "0.26.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4793cb5e56680ecbb1d843515b23b6de9a75eb04b66643e256a396d43be33c13" +dependencies = [ + "bytes", + "data-encoding", + "http 1.4.0", + "httparse", + "log", + "rand 0.9.2", + "rustls 0.23.36", + "rustls-pki-types", + "sha1", + "thiserror 2.0.18", + "utf-8", +] + +[[package]] +name = "typenum" +version = "1.20.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6f5e870be6c3b371b77fe0ee0bafb859fa4964b4404c27de1d380043c4dda20" + +[[package]] +name = "unicode-ident" +version = "1.0.22" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9312f7c4f6ff9069b165498234ce8be658059c6728633667c526e27dc2cf1df5" + +[[package]] +name = "unicode-normalization" +version = "0.1.22" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5c5713f0fc4b5db668a2ac63cdb7bb4469d8c9fed047b1d0292cc7b0ce2ba921" +dependencies = [ + "tinyvec", +] + +[[package]] +name = "unicode-segmentation" +version = "1.13.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c6f5d3c3b1bf09027a88a6bc961fc00497d651009560b5463668dc81b0fa87a8" + +[[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" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "673aac59facbab8a9007c7f6108d11f63b603f7cabff99fabf650fea5c32b861" + +[[package]] +name = "untrusted" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8ecb6da28b8a351d773b68d5825ac39017e680750f980f3a1a85cd8dd28a47c1" + +[[package]] +name = "url" +version = "2.5.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ff67a8a4397373c3ef660812acab3268222035010ab8680ec4215f38ba3d0eed" +dependencies = [ + "form_urlencoded", + "idna", + "percent-encoding", + "serde", + "serde_derive", +] + +[[package]] +name = "urlencoding" +version = "2.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "daf8dba3b7eb870caf1ddeed7bc9d2a049f3cfdfae7cb521b087cc33ae4c49da" + +[[package]] +name = "utf-8" +version = "0.7.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "09cc8ee72d2a9becf2f2febe0205bbed8fc6615b7cb429ad062dc7b7ddd036a9" + +[[package]] +name = "utf8_iter" +version = "1.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6c140620e7ffbb22c2dee59cafe6084a59b5ffc27a8859a5f0d494b5d52b6be" + +[[package]] +name = "uuid" +version = "1.19.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e2e054861b4bd027cd373e18e8d8d8e6548085000e41290d95ce0c373a654b4a" +dependencies = [ + "getrandom 0.3.4", + "js-sys", + "wasm-bindgen", +] + +[[package]] +name = "valuable" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ba73ea9cf16a25df0c8caa16c51acb937d5712a8429db78a3ee29d5dcacd3a65" + +[[package]] +name = "vcpkg" +version = "0.2.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "accd4ea62f7bb7a82fe23066fb0957d48ef677f6eeb8215f372f52e48bb32426" + +[[package]] +name = "vergen" +version = "9.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b849a1f6d8639e8de261e81ee0fc881e3e3620db1af9f2e0da015d4382ceaf75" +dependencies = [ + "anyhow", + "derive_builder", + "rustversion", + "vergen-lib", +] + +[[package]] +name = "vergen-gitcl" +version = "9.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "77ff3b5300a085d6bcd8fc96a507f706a28ae3814693236c9b409db71a1d15b9" +dependencies = [ + "anyhow", + "derive_builder", + "rustversion", + "time", + "vergen", + "vergen-lib", +] + +[[package]] +name = "vergen-lib" +version = "9.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b34a29ba7e9c59e62f229ae1932fb1b8fb8a6fdcc99215a641913f5f5a59a569" +dependencies = [ + "anyhow", + "derive_builder", + "rustversion", +] + +[[package]] +name = "version_check" +version = "0.9.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0b928f33d975fc6ad9f86c8f283853ad26bdd5b10b7f1542aa2fa15e2289105a" + +[[package]] +name = "walkdir" +version = "2.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "29790946404f91d9c5d06f9874efddea1dc06c5efe94541a7d6863108e3a5e4b" +dependencies = [ + "same-file", + "winapi-util", +] + +[[package]] +name = "want" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bfa7760aed19e106de2c7c0b581b509f2f25d3dacaf737cb82ac61bc6d760b0e" +dependencies = [ + "try-lock", +] + +[[package]] +name = "wasi" +version = "0.11.1+wasi-snapshot-preview1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ccf3ec651a847eb01de73ccad15eb7d99f80485de043efb2f370cd654f4ea44b" + +[[package]] +name = "wasip2" +version = "1.0.2+wasi-0.2.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9517f9239f02c069db75e65f174b3da828fe5f5b945c4dd26bd25d89c03ebcf5" +dependencies = [ + "wit-bindgen", +] + +[[package]] +name = "wasip3" +version = "0.4.0+wasi-0.3.0-rc-2026-01-06" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5428f8bf88ea5ddc08faddef2ac4a67e390b88186c703ce6dbd955e1c145aca5" +dependencies = [ + "wit-bindgen", +] + +[[package]] +name = "wasm-bindgen" +version = "0.2.108" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "64024a30ec1e37399cf85a7ffefebdb72205ca1c972291c51512360d90bd8566" +dependencies = [ + "cfg-if", + "once_cell", + "rustversion", + "wasm-bindgen-macro", + "wasm-bindgen-shared", +] + +[[package]] +name = "wasm-bindgen-futures" +version = "0.4.58" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "70a6e77fd0ae8029c9ea0063f87c46fde723e7d887703d74ad2616d792e51e6f" +dependencies = [ + "cfg-if", + "futures-util", + "js-sys", + "once_cell", + "wasm-bindgen", + "web-sys", +] + +[[package]] +name = "wasm-bindgen-macro" +version = "0.2.108" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "008b239d9c740232e71bd39e8ef6429d27097518b6b30bdf9086833bd5b6d608" +dependencies = [ + "quote", + "wasm-bindgen-macro-support", +] + +[[package]] +name = "wasm-bindgen-macro-support" +version = "0.2.108" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5256bae2d58f54820e6490f9839c49780dff84c65aeab9e772f15d5f0e913a55" +dependencies = [ + "bumpalo", + "proc-macro2", + "quote", + "syn 2.0.114", + "wasm-bindgen-shared", +] + +[[package]] +name = "wasm-bindgen-shared" +version = "0.2.108" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1f01b580c9ac74c8d8f0c0e4afb04eeef2acf145458e52c03845ee9cd23e3d12" +dependencies = [ + "unicode-ident", +] + +[[package]] +name = "wasm-encoder" +version = "0.244.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "990065f2fe63003fe337b932cfb5e3b80e0b4d0f5ff650e6985b1048f62c8319" +dependencies = [ + "leb128fmt", + "wasmparser", +] + +[[package]] +name = "wasm-metadata" +version = "0.244.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bb0e353e6a2fbdc176932bbaab493762eb1255a7900fe0fea1a2f96c296cc909" +dependencies = [ + "anyhow", + "indexmap", + "wasm-encoder", + "wasmparser", +] + +[[package]] +name = "wasm-streams" +version = "0.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "15053d8d85c7eccdbefef60f06769760a563c7f0a9d6902a13d35c7800b0ad65" +dependencies = [ + "futures-util", + "js-sys", + "wasm-bindgen", + "wasm-bindgen-futures", + "web-sys", +] + +[[package]] +name = "wasm-streams" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9d1ec4f6517c9e11ae630e200b2b65d193279042e28edd4a2cda233e46670bbb" +dependencies = [ + "futures-util", + "js-sys", + "wasm-bindgen", + "wasm-bindgen-futures", + "web-sys", +] + +[[package]] +name = "wasmparser" +version = "0.244.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "47b807c72e1bac69382b3a6fb3dbe8ea4c0ed87ff5629b8685ae6b9a611028fe" +dependencies = [ + "bitflags 2.13.0", + "hashbrown 0.15.5", + "indexmap", + "semver", +] + +[[package]] +name = "web-sys" +version = "0.3.85" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "312e32e551d92129218ea9a2452120f4aabc03529ef03e4d0d82fb2780608598" +dependencies = [ + "js-sys", + "wasm-bindgen", +] + +[[package]] +name = "web-time" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5a6580f308b1fad9207618087a65c04e7a10bc77e02c8e84e9b00dd4b12fa0bb" +dependencies = [ + "js-sys", + "wasm-bindgen", +] + +[[package]] +name = "webpki-root-certs" +version = "1.0.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f31141ce3fc3e300ae89b78c0dd67f9708061d1d2eda54b8209346fd6be9a92c" +dependencies = [ + "rustls-pki-types", +] + +[[package]] +name = "webpki-roots" +version = "0.25.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5f20c57d8d7db6d3b86154206ae5d8fba62dd39573114de97c2cb0578251f8e1" + +[[package]] +name = "webpki-roots" +version = "0.26.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "521bc38abb08001b01866da9f51eb7c5d647a19260e00054a8c7fd5f9e57f7a9" +dependencies = [ + "webpki-roots 1.0.6", +] + +[[package]] +name = "webpki-roots" +version = "1.0.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "22cfaf3c063993ff62e73cb4311efde4db1efb31ab78a3e5c457939ad5cc0bed" +dependencies = [ + "rustls-pki-types", +] + +[[package]] +name = "widestring" +version = "1.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "72069c3113ab32ab29e5584db3c6ec55d416895e60715417b5b883a357c3e471" + +[[package]] +name = "winapi" +version = "0.3.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5c839a674fcd7a98952e593242ea400abe93992746761e38641405d28b00f419" +dependencies = [ + "winapi-i686-pc-windows-gnu", + "winapi-x86_64-pc-windows-gnu", +] + +[[package]] +name = "winapi-i686-pc-windows-gnu" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ac3b87c63620426dd9b991e5ce0329eff545bccbbb34f3be09ff6fb6ab51b7b6" + +[[package]] +name = "winapi-util" +version = "0.1.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c2a7b1c03c876122aa43f3020e6c3c3ee5c05081c9a00739faf7503aeba10d22" +dependencies = [ + "windows-sys 0.61.2", +] + +[[package]] +name = "winapi-x86_64-pc-windows-gnu" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "712e227841d057c1ee1cd2fb22fa7e5a5461ae8e48fa2ca79ec42cfc1931183f" + +[[package]] +name = "windows" +version = "0.62.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "527fadee13e0c05939a6a05d5bd6eec6cd2e3dbd648b9f8e447c6518133d8580" +dependencies = [ + "windows-collections", + "windows-core", + "windows-future", + "windows-numerics", +] + +[[package]] +name = "windows-collections" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "23b2d95af1a8a14a3c7367e1ed4fc9c20e0a26e79551b1454d72583c97cc6610" +dependencies = [ + "windows-core", +] + +[[package]] +name = "windows-core" +version = "0.62.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b8e83a14d34d0623b51dce9581199302a221863196a1dde71a7663a4c2be9deb" +dependencies = [ + "windows-implement", + "windows-interface", + "windows-link", + "windows-result", + "windows-strings", +] + +[[package]] +name = "windows-future" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e1d6f90251fe18a279739e78025bd6ddc52a7e22f921070ccdc67dde84c605cb" +dependencies = [ + "windows-core", + "windows-link", + "windows-threading", +] + +[[package]] +name = "windows-implement" +version = "0.60.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "053e2e040ab57b9dc951b72c264860db7eb3b0200ba345b4e4c3b14f67855ddf" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.114", +] + +[[package]] +name = "windows-interface" +version = "0.59.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3f316c4a2570ba26bbec722032c4099d8c8bc095efccdc15688708623367e358" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.114", +] + +[[package]] +name = "windows-link" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f0805222e57f7521d6a62e36fa9163bc891acd422f971defe97d64e70d0a4fe5" + +[[package]] +name = "windows-numerics" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6e2e40844ac143cdb44aead537bbf727de9b044e107a0f1220392177d15b0f26" +dependencies = [ + "windows-core", + "windows-link", +] + +[[package]] +name = "windows-registry" +version = "0.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "02752bf7fbdcce7f2a27a742f798510f3e5ad88dbe84871e5168e2120c3d5720" +dependencies = [ + "windows-link", + "windows-result", + "windows-strings", +] + +[[package]] +name = "windows-result" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7781fa89eaf60850ac3d2da7af8e5242a5ea78d1a11c49bf2910bb5a73853eb5" +dependencies = [ + "windows-link", +] + +[[package]] +name = "windows-strings" +version = "0.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7837d08f69c77cf6b07689544538e017c1bfcf57e34b4c0ff58e6c2cd3b37091" +dependencies = [ + "windows-link", +] + +[[package]] +name = "windows-sys" +version = "0.45.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "75283be5efb2831d37ea142365f009c02ec203cd29a3ebecbc093d52315b66d0" +dependencies = [ + "windows-targets 0.42.2", +] + +[[package]] +name = "windows-sys" +version = "0.48.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "677d2418bec65e3338edb076e806bc1ec15693c5d0104683f2efe857f61056a9" +dependencies = [ + "windows-targets 0.48.5", +] + +[[package]] +name = "windows-sys" +version = "0.52.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "282be5f36a8ce781fad8c8ae18fa3f9beff57ec1b52cb3de0789201425d9a33d" +dependencies = [ + "windows-targets 0.52.6", +] + +[[package]] +name = "windows-sys" +version = "0.60.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f2f500e4d28234f72040990ec9d39e3a6b950f9f22d3dba18416c35882612bcb" +dependencies = [ + "windows-targets 0.53.5", +] + +[[package]] +name = "windows-sys" +version = "0.61.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ae137229bcbd6cdf0f7b80a31df61766145077ddf49416a728b02cb3921ff3fc" +dependencies = [ + "windows-link", +] + +[[package]] +name = "windows-targets" +version = "0.42.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8e5180c00cd44c9b1c88adb3693291f1cd93605ded80c250a75d472756b4d071" +dependencies = [ + "windows_aarch64_gnullvm 0.42.2", + "windows_aarch64_msvc 0.42.2", + "windows_i686_gnu 0.42.2", + "windows_i686_msvc 0.42.2", + "windows_x86_64_gnu 0.42.2", + "windows_x86_64_gnullvm 0.42.2", + "windows_x86_64_msvc 0.42.2", +] + +[[package]] +name = "windows-targets" +version = "0.48.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9a2fa6e2155d7247be68c096456083145c183cbbbc2764150dda45a87197940c" +dependencies = [ + "windows_aarch64_gnullvm 0.48.5", + "windows_aarch64_msvc 0.48.5", + "windows_i686_gnu 0.48.5", + "windows_i686_msvc 0.48.5", + "windows_x86_64_gnu 0.48.5", + "windows_x86_64_gnullvm 0.48.5", + "windows_x86_64_msvc 0.48.5", +] + +[[package]] +name = "windows-targets" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9b724f72796e036ab90c1021d4780d4d3d648aca59e491e6b98e725b84e99973" +dependencies = [ + "windows_aarch64_gnullvm 0.52.6", + "windows_aarch64_msvc 0.52.6", + "windows_i686_gnu 0.52.6", + "windows_i686_gnullvm 0.52.6", + "windows_i686_msvc 0.52.6", + "windows_x86_64_gnu 0.52.6", + "windows_x86_64_gnullvm 0.52.6", + "windows_x86_64_msvc 0.52.6", +] + +[[package]] +name = "windows-targets" +version = "0.53.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4945f9f551b88e0d65f3db0bc25c33b8acea4d9e41163edf90dcd0b19f9069f3" +dependencies = [ + "windows-link", + "windows_aarch64_gnullvm 0.53.1", + "windows_aarch64_msvc 0.53.1", + "windows_i686_gnu 0.53.1", + "windows_i686_gnullvm 0.53.1", + "windows_i686_msvc 0.53.1", + "windows_x86_64_gnu 0.53.1", + "windows_x86_64_gnullvm 0.53.1", + "windows_x86_64_msvc 0.53.1", +] + +[[package]] +name = "windows-threading" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3949bd5b99cafdf1c7ca86b43ca564028dfe27d66958f2470940f73d86d75b37" +dependencies = [ + "windows-link", +] + +[[package]] +name = "windows_aarch64_gnullvm" +version = "0.42.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "597a5118570b68bc08d8d59125332c54f1ba9d9adeedeef5b99b02ba2b0698f8" + +[[package]] +name = "windows_aarch64_gnullvm" +version = "0.48.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2b38e32f0abccf9987a4e3079dfb67dcd799fb61361e53e2882c3cbaf0d905d8" + +[[package]] +name = "windows_aarch64_gnullvm" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "32a4622180e7a0ec044bb555404c800bc9fd9ec262ec147edd5989ccd0c02cd3" + +[[package]] +name = "windows_aarch64_gnullvm" +version = "0.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a9d8416fa8b42f5c947f8482c43e7d89e73a173cead56d044f6a56104a6d1b53" + +[[package]] +name = "windows_aarch64_msvc" +version = "0.42.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e08e8864a60f06ef0d0ff4ba04124db8b0fb3be5776a5cd47641e942e58c4d43" + +[[package]] +name = "windows_aarch64_msvc" +version = "0.48.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dc35310971f3b2dbbf3f0690a219f40e2d9afcf64f9ab7cc1be722937c26b4bc" + +[[package]] +name = "windows_aarch64_msvc" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "09ec2a7bb152e2252b53fa7803150007879548bc709c039df7627cabbd05d469" + +[[package]] +name = "windows_aarch64_msvc" +version = "0.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b9d782e804c2f632e395708e99a94275910eb9100b2114651e04744e9b125006" + +[[package]] +name = "windows_i686_gnu" +version = "0.42.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c61d927d8da41da96a81f029489353e68739737d3beca43145c8afec9a31a84f" + +[[package]] +name = "windows_i686_gnu" +version = "0.48.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a75915e7def60c94dcef72200b9a8e58e5091744960da64ec734a6c6e9b3743e" + +[[package]] +name = "windows_i686_gnu" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8e9b5ad5ab802e97eb8e295ac6720e509ee4c243f69d781394014ebfe8bbfa0b" + +[[package]] +name = "windows_i686_gnu" +version = "0.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "960e6da069d81e09becb0ca57a65220ddff016ff2d6af6a223cf372a506593a3" + +[[package]] +name = "windows_i686_gnullvm" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0eee52d38c090b3caa76c563b86c3a4bd71ef1a819287c19d586d7334ae8ed66" + +[[package]] +name = "windows_i686_gnullvm" +version = "0.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fa7359d10048f68ab8b09fa71c3daccfb0e9b559aed648a8f95469c27057180c" + +[[package]] +name = "windows_i686_msvc" +version = "0.42.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "44d840b6ec649f480a41c8d80f9c65108b92d89345dd94027bfe06ac444d1060" + +[[package]] +name = "windows_i686_msvc" +version = "0.48.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8f55c233f70c4b27f66c523580f78f1004e8b5a8b659e05a4eb49d4166cca406" + +[[package]] +name = "windows_i686_msvc" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "240948bc05c5e7c6dabba28bf89d89ffce3e303022809e73deaefe4f6ec56c66" + +[[package]] +name = "windows_i686_msvc" +version = "0.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1e7ac75179f18232fe9c285163565a57ef8d3c89254a30685b57d83a38d326c2" + +[[package]] +name = "windows_x86_64_gnu" +version = "0.42.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8de912b8b8feb55c064867cf047dda097f92d51efad5b491dfb98f6bbb70cb36" + +[[package]] +name = "windows_x86_64_gnu" +version = "0.48.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "53d40abd2583d23e4718fddf1ebec84dbff8381c07cae67ff7768bbf19c6718e" + +[[package]] +name = "windows_x86_64_gnu" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "147a5c80aabfbf0c7d901cb5895d1de30ef2907eb21fbbab29ca94c5b08b1a78" + +[[package]] +name = "windows_x86_64_gnu" +version = "0.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9c3842cdd74a865a8066ab39c8a7a473c0778a3f29370b5fd6b4b9aa7df4a499" + +[[package]] +name = "windows_x86_64_gnullvm" +version = "0.42.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "26d41b46a36d453748aedef1486d5c7a85db22e56aff34643984ea85514e94a3" + +[[package]] +name = "windows_x86_64_gnullvm" +version = "0.48.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0b7b52767868a23d5bab768e390dc5f5c55825b6d30b86c844ff2dc7414044cc" + +[[package]] +name = "windows_x86_64_gnullvm" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "24d5b23dc417412679681396f2b49f3de8c1473deb516bd34410872eff51ed0d" + +[[package]] +name = "windows_x86_64_gnullvm" +version = "0.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0ffa179e2d07eee8ad8f57493436566c7cc30ac536a3379fdf008f47f6bb7ae1" + +[[package]] +name = "windows_x86_64_msvc" +version = "0.42.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9aec5da331524158c6d1a4ac0ab1541149c0b9505fde06423b02f5ef0106b9f0" + +[[package]] +name = "windows_x86_64_msvc" +version = "0.48.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ed94fce61571a4006852b7389a063ab983c02eb1bb37b47f8272ce92d06d9538" + +[[package]] +name = "windows_x86_64_msvc" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "589f6da84c646204747d1270a2a5661ea66ed1cced2631d546fdfb155959f9ec" + +[[package]] +name = "windows_x86_64_msvc" +version = "0.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d6bbff5f0aada427a1e5a6da5f1f98158182f26556f345ac9e04d36d0ebed650" + +[[package]] +name = "winnow" +version = "0.7.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5a5364e9d77fcdeeaa6062ced926ee3381faa2ee02d3eb83a5c27a8825540829" +dependencies = [ + "memchr", +] + +[[package]] +name = "winnow" +version = "1.0.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0592e1c9d151f854e6fd382574c3a0855250e1d9b2f99d9281c6e6391af352f1" +dependencies = [ + "memchr", +] + +[[package]] +name = "winreg" +version = "0.50.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "524e57b2c537c0f9b1e69f1965311ec12182b4122e45035b1508cd24d2adadb1" +dependencies = [ + "cfg-if", + "windows-sys 0.48.0", +] + +[[package]] +name = "wit-bindgen" +version = "0.51.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d7249219f66ced02969388cf2bb044a09756a083d0fab1e566056b04d9fbcaa5" +dependencies = [ + "wit-bindgen-rust-macro", +] + +[[package]] +name = "wit-bindgen-core" +version = "0.51.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ea61de684c3ea68cb082b7a88508a8b27fcc8b797d738bfc99a82facf1d752dc" +dependencies = [ + "anyhow", + "heck", + "wit-parser", +] + +[[package]] +name = "wit-bindgen-rust" +version = "0.51.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b7c566e0f4b284dd6561c786d9cb0142da491f46a9fbed79ea69cdad5db17f21" +dependencies = [ + "anyhow", + "heck", + "indexmap", + "prettyplease", + "syn 2.0.114", + "wasm-metadata", + "wit-bindgen-core", + "wit-component", +] + +[[package]] +name = "wit-bindgen-rust-macro" +version = "0.51.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0c0f9bfd77e6a48eccf51359e3ae77140a7f50b1e2ebfe62422d8afdaffab17a" +dependencies = [ + "anyhow", + "prettyplease", + "proc-macro2", + "quote", + "syn 2.0.114", + "wit-bindgen-core", + "wit-bindgen-rust", +] + +[[package]] +name = "wit-component" +version = "0.244.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9d66ea20e9553b30172b5e831994e35fbde2d165325bec84fc43dbf6f4eb9cb2" +dependencies = [ + "anyhow", + "bitflags 2.13.0", + "indexmap", + "log", + "serde", + "serde_derive", + "serde_json", + "wasm-encoder", + "wasm-metadata", + "wasmparser", + "wit-parser", +] + +[[package]] +name = "wit-parser" +version = "0.244.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ecc8ac4bc1dc3381b7f59c34f00b67e18f910c2c0f50015669dde7def656a736" +dependencies = [ + "anyhow", + "id-arena", + "indexmap", + "log", + "semver", + "serde", + "serde_derive", + "serde_json", + "unicode-xid", + "wasmparser", +] + +[[package]] +name = "wmi" +version = "0.18.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7c81b85c57a57500e56669586496bf2abd5cf082b9d32995251185d105208b64" +dependencies = [ + "chrono", + "futures", + "log", + "serde", + "thiserror 2.0.18", + "windows", + "windows-core", +] + +[[package]] +name = "writeable" +version = "0.6.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9edde0db4769d2dc68579893f2306b26c6ecfbe0ef499b013d731b7b9247e0b9" + +[[package]] +name = "ws_stream_wasm" +version = "0.7.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6c173014acad22e83f16403ee360115b38846fe754e735c5d9d3803fe70c6abc" +dependencies = [ + "async_io_stream", + "futures", + "js-sys", + "log", + "pharos", + "rustc_version", + "send_wrapper", + "thiserror 2.0.18", + "wasm-bindgen", + "wasm-bindgen-futures", + "web-sys", +] + +[[package]] +name = "x509-parser" +version = "0.18.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d43b0f71ce057da06bc0851b23ee24f3f86190b07203dd8f567d0b706a185202" +dependencies = [ + "asn1-rs", + "data-encoding", + "der-parser", + "lazy_static", + "nom", + "oid-registry", + "ring", + "rusticata-macros", + "thiserror 2.0.18", + "time", +] + +[[package]] +name = "xattr" +version = "1.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "32e45ad4206f6d2479085147f02bc2ef834ac85886624a23575ae137c8aa8156" +dependencies = [ + "libc", + "rustix", +] + +[[package]] +name = "xml-rs" +version = "0.8.28" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3ae8337f8a065cfc972643663ea4279e04e7256de865aa66fe25cec5fb912d3f" + +[[package]] +name = "xmltree" +version = "0.10.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d7d8a75eaf6557bb84a65ace8609883db44a29951042ada9b393151532e41fcb" +dependencies = [ + "xml-rs", +] + +[[package]] +name = "yasna" +version = "0.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b5f6765e852b9b4dc8e2a76843e4d64d1cea8e79bcde0b6901aea8e7c7f08282" +dependencies = [ + "bit-vec", + "time", +] + +[[package]] +name = "yoke" +version = "0.8.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "72d6e5c6afb84d73944e5cedb052c4680d5657337201555f9f2a16b7406d4954" +dependencies = [ + "stable_deref_trait", + "yoke-derive", + "zerofrom", +] + +[[package]] +name = "yoke-derive" +version = "0.8.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b659052874eb698efe5b9e8cf382204678a0086ebf46982b79d6ca3182927e5d" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.114", + "synstructure", +] + +[[package]] +name = "zbase32" +version = "0.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0f9079049688da5871a7558ddacb7f04958862c703e68258594cb7a862b5e33f" + +[[package]] +name = "zerocopy" +version = "0.8.33" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "668f5168d10b9ee831de31933dc111a459c97ec93225beb307aed970d1372dfd" +dependencies = [ + "zerocopy-derive", +] + +[[package]] +name = "zerocopy-derive" +version = "0.8.33" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2c7962b26b0a8685668b671ee4b54d007a67d4eaf05fda79ac0ecf41e32270f1" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.114", +] + +[[package]] +name = "zerofrom" +version = "0.1.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "50cc42e0333e05660c3587f3bf9d0478688e15d870fab3346451ce7f8c9fbea5" +dependencies = [ + "zerofrom-derive", +] + +[[package]] +name = "zerofrom-derive" +version = "0.1.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d71e5d6e06ab090c67b5e44993ec16b72dcbaabc526db883a360057678b48502" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.114", + "synstructure", +] + +[[package]] +name = "zeroize" +version = "1.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e13c156562582aa81c60cb29407084cdb54c4164760106ab78e6c5b0858cf64e" +dependencies = [ + "zeroize_derive", +] + +[[package]] +name = "zeroize_derive" +version = "1.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3c50655cbb0fe3fc43170059e702f1ce5e19b84cec58dc87b037a09935c2f328" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.114", +] + +[[package]] +name = "zerotrie" +version = "0.2.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2a59c17a5562d507e4b54960e8569ebee33bee890c70aa3fe7b97e85a9fd7851" +dependencies = [ + "displaydoc", + "yoke", + "zerofrom", +] + +[[package]] +name = "zerovec" +version = "0.11.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6c28719294829477f525be0186d13efa9a3c602f7ec202ca9e353d310fb9a002" +dependencies = [ + "yoke", + "zerofrom", + "zerovec-derive", +] + +[[package]] +name = "zerovec-derive" +version = "0.11.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "eadce39539ca5cb3985590102671f2567e659fca9666581ad3411d59207951f3" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.114", +] + +[[package]] +name = "zmij" +version = "1.0.16" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dfcd145825aace48cff44a8844de64bf75feec3080e0aa5cdbde72961ae51a65" diff --git a/core/Cargo.toml b/core/Cargo.toml new file mode 100644 index 00000000..24823e5c --- /dev/null +++ b/core/Cargo.toml @@ -0,0 +1,23 @@ +[workspace] +resolver = "2" + +members = [ + "archipelago", + "container", + "openwrt", + "performance", + "security", +] + +# Profiles at workspace root (members' [profile] are ignored in virtual workspaces) +[profile.release] +opt-level = 3 + +[profile.dev] +opt-level = 0 + +[profile.test] +opt-level = 3 + +# Archipelago workspace - no StartOS dependencies +# All patches removed - we use standard crates.io dependencies diff --git a/core/archipelago/Cargo.toml b/core/archipelago/Cargo.toml new file mode 100644 index 00000000..bee6d4cd --- /dev/null +++ b/core/archipelago/Cargo.toml @@ -0,0 +1,131 @@ +[package] +name = "archipelago" +version = "1.7.100-alpha" +edition = "2021" +description = "Archipelago Bitcoin Node OS - Native backend" +authors = ["Archipelago Team"] + +[[bin]] +name = "archipelago" +path = "src/main.rs" + +[features] +default = [] +# DHT Phase 2: iroh-blobs peer swarm engine. OFF by default — it pulls a heavy +# QUIC dependency tree, so it ships behind a flag for PoC/measurement on a +# scratch node before any fleet rollout. With the flag off, swarm::providers() +# is empty and every fetch goes straight to the origin HTTP path (today's +# behaviour). Attach the optional iroh / iroh-blobs deps to this feature when +# wiring the IrohProvider. +iroh-swarm = ["dep:iroh", "dep:iroh-blobs"] + +[dependencies] +# Core dependencies +tokio = { version = "1", features = ["full"] } +libc = "0.2" # process-group signalling for the supervised reticulum daemon +serde = { version = "1.0", features = ["derive"] } +serde_json = "1.0" +anyhow = "1.0" +thiserror = "1.0" +tracing = "0.1" +tracing-subscriber = { version = "0.3", features = ["env-filter"] } + +# HTTP and WebSocket +hyper = { version = "0.14", features = ["full", "http1"] } +hyper-util = { version = "0.1", features = ["full", "http1"] } +http-body-util = "0.1" +http-body = "1.0" +tower = "0.5" +tower-http = { version = "0.6", features = ["cors", "trace"] } +hyper-ws-listener = "0.3.0" +tokio-tungstenite = "0.20" +futures-util = "0.3" + +# Our modules +archipelago-container = { path = "../container" } +archipelago-openwrt = { path = "../openwrt" } +archipelago-security = { path = "../security" } +archipelago-performance = { path = "../performance" } + + +# Database (optional for now - can use SQLite or skip) +# sqlx = { version = "0.7", features = ["sqlite", "runtime-tokio-rustls"] } + +# Authentication +bcrypt = "0.15" +sha2 = "0.10.9" +blake3 = "1" +hmac = "0.12.1" +uuid = { version = "1.0", features = ["v4"] } +regex = "1.10" + +# Node identity (Ed25519 + X25519 key agreement) +ed25519-dalek = { version = "2.2.0", features = ["rand_core"] } +curve25519-dalek = "4.1.3" +rand = "0.8.5" +hex = "0.4" +bs58 = "0.5" +chrono = "0.4" + +# BIP-39 mnemonic seed generation + BIP-32 HD key derivation +bip39 = { version = "=2.1.0", features = ["rand"] } +bitcoin = { version = "=0.32.5", features = ["rand-std"] } + +# Configuration +toml = "0.8" +serde_yaml = "0.9" + +# HTTP client (for LND REST proxy, Tor SOCKS for peer messaging) +# Uses rustls-tls for cross-compilation (no OpenSSL dependency) +reqwest = { version = "0.11", default-features = false, features = ["json", "socks", "rustls-tls", "stream"] } + +# Nostr (node discovery + NIP-44 encrypted peer handshake) +nostr-sdk = { version = "0.44", features = ["nip04", "nip44"] } + +# Backup encryption (DID identity export) + TOTP 2FA encryption +argon2 = "0.5.3" +chacha20poly1305 = "0.10.1" +base64 = "0.21" + +# Full system backup (tar archive + gzip compression) +tar = "0.4" +flate2 = "1.0" + +# TOTP 2FA +totp-rs = { version = "5.7", features = ["otpauth", "gen_secret"] } +qrcode = "0.14" +data-encoding = "2.6" +zeroize = { version = "1.8.2", features = ["derive"] } + +# Mainline DHT (did:dht — BitTorrent DHT for decentralized identity) +mainline = "2" +zbase32 = "0.1" +bytes = "1" + +# Mesh networking (Meshcore serial protocol over USB LoRa radios) +serial2-tokio = "0.1" + +# Double Ratchet key derivation (Phase 3: encrypted mesh messaging) +hkdf = "0.12.4" + +# Transport abstraction (Phase 2: mesh as federation transport) +ciborium = "0.2.2" +serde_bytes = "0.11" +reed-solomon-erasure = "6.0" +mdns-sd = "0.18" + +# Systemd watchdog notification +sd-notify = "0.4" + +# Trait objects for async methods (container orchestrator trait, Step 4) +async-trait = "0.1" + +# DHT Phase 2: iroh-blobs peer swarm engine. OPTIONAL — only pulled in by the +# `iroh-swarm` feature (off by default). Heavy QUIC dep tree; kept behind the +# flag so the default fleet build is unaffected until the PoC is measured. +iroh = { version = "1", optional = true } +iroh-blobs = { version = "0.103", optional = true } + +[dev-dependencies] +tokio-test = "0.4" +tempfile = "3.10" diff --git a/core/archipelago/src/api/handler/blob.rs b/core/archipelago/src/api/handler/blob.rs new file mode 100644 index 00000000..23b72677 --- /dev/null +++ b/core/archipelago/src/api/handler/blob.rs @@ -0,0 +1,234 @@ +//! HTTP handlers for the content-addressed blob store. +//! +//! - `POST /api/blob` — session-authenticated. Raw body is the blob; +//! headers set mime/filename. Returns `{cid, size, mime}`. +//! - `GET /blob/?cap=&exp=&peer=` — peer-facing. +//! Capability verified against the stored HMAC key; bytes streamed back. + +use super::{build_response, ApiHandler}; +use crate::blobs::BlobStore; +use anyhow::Result; +use hyper::{Body, HeaderMap, Response, StatusCode}; +use std::path::Path; +use std::sync::Arc; + +/// Read the archipelago .onion address if Tor has published one, so uploads +/// that need to be publicly reachable (profile pictures, banners) can return +/// a URL a peer outside the LAN can actually fetch. Returns `None` before +/// onboarding or when Tor isn't running — callers fall back to the local +/// self-test URL. +async fn read_self_onion(data_dir: &Path) -> Option { + let hostnames = data_dir.join("tor-hostnames").join("archipelago"); + let legacy = Path::new("/var/lib/archipelago/tor-hostnames/archipelago"); + for p in [hostnames.as_path(), legacy] { + if let Ok(s) = tokio::fs::read_to_string(p).await { + let trimmed = s.trim(); + if !trimmed.is_empty() { + return Some(trimmed.to_string()); + } + } + } + None +} + +impl ApiHandler { + pub(super) async fn handle_blob_upload( + store: &Arc, + self_pubkey_hex: &str, + data_dir: &Path, + headers: &HeaderMap, + body: hyper::body::Bytes, + ) -> Result> { + let mime = headers + .get("x-blob-mime") + .and_then(|v| v.to_str().ok()) + .unwrap_or("application/octet-stream") + .to_string(); + let filename = headers + .get("x-blob-filename") + .and_then(|v| v.to_str().ok()) + .map(|s| s.to_string()); + // Optional caller-supplied thumbnail (small, base64) — e.g. the mesh + // chat's image-quality picker generates a tiny client-side preview so + // a ContentRef receiver can render something before fetching the full + // blob. Best-effort: a malformed header is just ignored, not fatal. + let thumb_bytes = headers + .get("x-blob-thumb") + .and_then(|v| v.to_str().ok()) + .and_then(|b64| { + use base64::{engine::general_purpose::STANDARD, Engine as _}; + STANDARD.decode(b64).ok() + }); + + let bytes = body.to_vec(); + // Uploads through /api/blob come from the node owner's session and + // are almost always intended for external consumption (profile + // pictures, banners). Store them public so `/blob/` serves + // without a capability check — external Nostr clients fetching a + // kind-0 `picture` URL have no cap and can't get one. + match store.put(&bytes, &mime, filename, thumb_bytes, true).await { + Ok(meta) => { + let exp = + (chrono::Utc::now().timestamp() as u64) + crate::blobs::DEFAULT_CAP_TTL_SECS; + let cap = store.issue_capability(&meta.cid, self_pubkey_hex, exp); + let self_test_url = format!( + "/blob/{}?cap={}&exp={}&peer={}", + meta.cid, cap, exp, self_pubkey_hex + ); + let public_url = match read_self_onion(data_dir).await { + Some(onion) => format!("http://{}/blob/{}", onion, meta.cid), + // Pre-onboarding / Tor-not-up: surface the local path so + // the UI doesn't break; publishing to Nostr should wait + // until Tor is live anyway. + None => format!("/blob/{}", meta.cid), + }; + let resp = serde_json::json!({ + "cid": meta.cid, + "size": meta.size, + "mime": meta.mime, + "filename": meta.filename, + "public_url": public_url, + "self_test_url": self_test_url, + }); + Ok(build_response( + StatusCode::OK, + "application/json", + Body::from(serde_json::to_vec(&resp).unwrap_or_default()), + )) + } + Err(e) => Ok(build_response( + StatusCode::BAD_REQUEST, + "text/plain", + Body::from(format!("blob upload failed: {}", e)), + )), + } + } + + /// Share-to-mesh iframe intent. Mirrors `handle_blob_upload` but adds + /// CORS headers for the requesting app origin and returns a small JSON + /// payload the app forwards to its parent via postMessage: + /// `{ type: "share-to-mesh", cid, size, mime, filename }`. + pub(super) async fn handle_share_to_mesh( + store: &Arc, + self_pubkey_hex: &str, + headers: &HeaderMap, + body: hyper::body::Bytes, + origin: &str, + ) -> Result> { + let mime = headers + .get("x-blob-mime") + .and_then(|v| v.to_str().ok()) + .unwrap_or("application/octet-stream") + .to_string(); + let filename = headers + .get("x-blob-filename") + .and_then(|v| v.to_str().ok()) + .map(|s| s.to_string()); + + let bytes = body.to_vec(); + let meta = match store.put(&bytes, &mime, filename, None, false).await { + Ok(m) => m, + Err(e) => { + return Ok(build_response( + StatusCode::BAD_REQUEST, + "text/plain", + Body::from(format!("share-to-mesh failed: {}", e)), + )); + } + }; + // Self-signed capability so the app can preview/download its own + // upload before the user has picked a peer. + let exp = (chrono::Utc::now().timestamp() as u64) + crate::blobs::DEFAULT_CAP_TTL_SECS; + let cap = store.issue_capability(&meta.cid, self_pubkey_hex, exp); + let self_url = format!( + "/blob/{}?cap={}&exp={}&peer={}", + meta.cid, cap, exp, self_pubkey_hex + ); + let resp = serde_json::json!({ + "type": "share-to-mesh", + "cid": meta.cid, + "size": meta.size, + "mime": meta.mime, + "filename": meta.filename, + "self_url": self_url, + }); + let body_vec = serde_json::to_vec(&resp).unwrap_or_default(); + Ok(Response::builder() + .status(StatusCode::OK) + .header("Content-Type", "application/json") + .header("Access-Control-Allow-Origin", origin) + .header("Access-Control-Allow-Credentials", "true") + .header("Vary", "Origin") + .body(Body::from(body_vec)) + .unwrap_or_else(|_| Response::new(Body::from("internal error")))) + } + + pub(super) async fn handle_blob_download( + store: &Arc, + path: &str, + query: &str, + ) -> Result> { + let cid = path.strip_prefix("/blob/").unwrap_or(""); + if cid.is_empty() || !cid.chars().all(|c| c.is_ascii_hexdigit()) || cid.len() != 64 { + return Ok(build_response( + StatusCode::BAD_REQUEST, + "text/plain", + Body::from("invalid cid"), + )); + } + + // Public blobs (profile pictures, banners) bypass the capability + // check — their CID is published on Nostr relays where any reader + // can see it, and external readers have no way to obtain a cap. + // Only blobs explicitly marked public at upload time qualify. + let is_public = store.meta(cid).await.map(|m| m.public).unwrap_or(false); + + if !is_public { + let mut cap = None; + let mut exp: Option = None; + let mut peer = None; + for pair in query.split('&') { + let mut it = pair.splitn(2, '='); + match (it.next(), it.next()) { + (Some("cap"), Some(v)) => cap = Some(v.to_string()), + (Some("exp"), Some(v)) => exp = v.parse().ok(), + (Some("peer"), Some(v)) => peer = Some(v.to_string()), + _ => {} + } + } + let (Some(cap), Some(exp), Some(peer)) = (cap, exp, peer) else { + return Ok(build_response( + StatusCode::UNAUTHORIZED, + "text/plain", + Body::from("missing cap/exp/peer"), + )); + }; + + if let Err(e) = store.verify_capability(cid, &peer, exp, &cap) { + tracing::warn!("blob cap rejected: cid={} peer={} reason={}", cid, peer, e); + return Ok(build_response( + StatusCode::FORBIDDEN, + "text/plain", + Body::from(format!("capability rejected: {}", e)), + )); + } + } + + let bytes = match store.get(cid).await { + Ok(b) => b, + Err(_) => { + return Ok(build_response( + StatusCode::NOT_FOUND, + "text/plain", + Body::from("blob not found"), + )) + } + }; + let mime = store + .meta(cid) + .await + .map(|m| m.mime) + .unwrap_or_else(|_| "application/octet-stream".to_string()); + Ok(build_response(StatusCode::OK, &mime, Body::from(bytes))) + } +} diff --git a/core/archipelago/src/api/handler/content.rs b/core/archipelago/src/api/handler/content.rs new file mode 100644 index 00000000..0b06b1db --- /dev/null +++ b/core/archipelago/src/api/handler/content.rs @@ -0,0 +1,479 @@ +use super::build_response; +use crate::config::Config; +use crate::content_server; +use anyhow::Result; +use hyper::{Response, StatusCode}; + +use super::{is_valid_app_id, ApiHandler}; + +impl ApiHandler { + pub(super) async fn handle_content_catalog(config: &Config) -> Result> { + match content_server::load_catalog(&config.data_dir).await { + Ok(catalog) => { + // Only expose public metadata for available items + let items: Vec = catalog + .items + .iter() + .filter(|i| !matches!(i.availability, content_server::Availability::Nobody)) + .map(|i| { + serde_json::json!({ + "id": i.id, + "filename": i.filename, + "mime_type": i.mime_type, + "size_bytes": i.size_bytes, + "description": i.description, + "access": i.access, + }) + }) + .collect(); + let body = + serde_json::to_vec(&serde_json::json!({ "items": items })).unwrap_or_default(); + Ok(build_response( + StatusCode::OK, + "application/json", + hyper::Body::from(body), + )) + } + Err(e) => { + let body = serde_json::json!({ "error": e.to_string() }); + let body_bytes = serde_json::to_vec(&body).unwrap_or_default(); + Ok(build_response( + StatusCode::INTERNAL_SERVER_ERROR, + "application/json", + hyper::Body::from(body_bytes), + )) + } + } + } + + pub(super) async fn handle_content_request( + path: &str, + headers: &hyper::HeaderMap, + config: &Config, + ) -> Result> { + let content_id = path.strip_prefix("/content/").unwrap_or(""); + if content_id.is_empty() || !is_valid_app_id(content_id) { + return Ok(build_response( + StatusCode::BAD_REQUEST, + "text/plain", + hyper::Body::from("Invalid content ID"), + )); + } + + // Extract payment token from X-Payment-Token header + let payment_token = headers + .get("x-payment-token") + .and_then(|v| v.to_str().ok()) + .map(|s| s.to_string()); + + // Extract a paid-entitlement gate token from X-Invoice-Hash (Lightning) + // or X-Onchain-Address (on-chain) — both authorize the download if this + // node issued+settled them, and both resolve against the same shared + // entitlement store keyed by the token string (#46). + let invoice_hash = headers + .get("x-invoice-hash") + .and_then(|v| v.to_str().ok()) + .map(|s| s.to_string()) + .or_else(|| { + headers + .get("x-onchain-address") + .and_then(|v| v.to_str().ok()) + .map(|s| s.to_string()) + }); + + // Extract federation peer DID from X-Federation-DID header + let peer_did = headers + .get("x-federation-did") + .and_then(|v| v.to_str().ok()) + .map(|s| s.to_string()); + + // Parse Range header for streaming support + let range = headers + .get("range") + .and_then(|v| v.to_str().ok()) + .and_then(content_server::parse_range_header); + + match content_server::serve_content( + &config.data_dir, + content_id, + payment_token.as_deref(), + invoice_hash.as_deref(), + peer_did.as_deref(), + range, + ) + .await + { + Ok(content_server::ServeResult::Ok(bytes, mime_type)) => { + let len = bytes.len(); + Ok(Response::builder() + .status(StatusCode::OK) + .header("Content-Type", mime_type) + .header("Content-Length", len.to_string()) + .header("Accept-Ranges", "bytes") + .body(hyper::Body::from(bytes)) + .unwrap()) + } + Ok(content_server::ServeResult::Partial { + bytes, + mime_type, + start, + end, + total, + }) => Ok(Response::builder() + .status(StatusCode::PARTIAL_CONTENT) + .header("Content-Type", mime_type) + .header("Content-Length", bytes.len().to_string()) + .header( + "Content-Range", + format!("bytes {}-{}/{}", start, end, total), + ) + .header("Accept-Ranges", "bytes") + .body(hyper::Body::from(bytes)) + .unwrap()), + Ok(content_server::ServeResult::PaymentRequired(price_sats)) => { + let body = serde_json::json!({ + "error": "Payment required", + "price_sats": price_sats, + "payment_header": "X-Payment-Token", + }); + let body_bytes = serde_json::to_vec(&body).unwrap_or_default(); + Ok(build_response( + StatusCode::PAYMENT_REQUIRED, + "application/json", + hyper::Body::from(body_bytes), + )) + } + Ok(content_server::ServeResult::Forbidden) => Ok(build_response( + StatusCode::FORBIDDEN, + "application/json", + hyper::Body::from( + r#"{"error":"This file is shared with the host's federation peers only. Federate with that node (exchange invites) so it recognizes you, then try again."}"#, + ), + )), + Ok(content_server::ServeResult::NotFound) | Err(_) => Ok(build_response( + StatusCode::NOT_FOUND, + "text/plain", + hyper::Body::from("Content not found"), + )), + } + } + + /// Seller side (#46): mint a Lightning invoice for a paid catalog item so a + /// buyer can pay from any external wallet. Path: GET /content/{id}/invoice. + /// Records a pending entitlement keyed by the invoice's payment hash. + pub(super) async fn handle_content_invoice(&self, path: &str) -> Result> { + let content_id = path + .strip_prefix("/content/") + .and_then(|s| s.strip_suffix("/invoice")) + .unwrap_or(""); + if content_id.is_empty() || !is_valid_app_id(content_id) { + return Ok(build_response( + StatusCode::BAD_REQUEST, + "text/plain", + hyper::Body::from("Invalid content ID"), + )); + } + + let catalog = content_server::load_catalog(&self.config.data_dir) + .await + .unwrap_or_default(); + let item = match catalog.items.iter().find(|i| i.id == content_id) { + Some(i) => i, + None => { + return Ok(build_response( + StatusCode::NOT_FOUND, + "text/plain", + hyper::Body::from("Content not found"), + )) + } + }; + let price_sats = match &item.access { + content_server::AccessControl::Paid { price_sats } => *price_sats, + _ => { + // Not a paid item — no invoice to issue. + return Ok(build_response( + StatusCode::BAD_REQUEST, + "application/json", + hyper::Body::from(r#"{"error":"Item is not paid"}"#), + )); + } + }; + + let memo = format!("Archipelago peer file {content_id}"); + match self + .rpc_handler + .create_invoice(price_sats as i64, &memo) + .await + { + Ok((bolt11, payment_hash)) if !payment_hash.is_empty() => { + crate::content_invoice::record_pending(&payment_hash, content_id, price_sats).await; + let body = serde_json::json!({ + "bolt11": bolt11, + "payment_hash": payment_hash, + "price_sats": price_sats, + }); + Ok(build_response( + StatusCode::OK, + "application/json", + hyper::Body::from(serde_json::to_vec(&body).unwrap_or_default()), + )) + } + Ok(_) => Ok(build_response( + StatusCode::INTERNAL_SERVER_ERROR, + "application/json", + hyper::Body::from(r#"{"error":"Invoice missing payment hash"}"#), + )), + Err(e) => { + // Surface the FULL error chain ({:#}) — the generic top-level + // message hid the real cause (e.g. the LND REST connection + // failing), which made this 503 undiagnosable. + tracing::warn!("content invoice creation failed: {e:#}"); + let body = serde_json::json!({ + "error": format!("Could not create invoice: {e:#}") + }); + Ok(build_response( + StatusCode::SERVICE_UNAVAILABLE, + "application/json", + hyper::Body::from(serde_json::to_vec(&body).unwrap_or_default()), + )) + } + } + } + + /// Seller side (#46): report whether a previously-issued invoice has settled. + /// Path: GET /content/{id}/invoice-status/{payment_hash}. On settlement the + /// entitlement is marked paid so the buyer can then download the file. + pub(super) async fn handle_content_invoice_status( + &self, + path: &str, + ) -> Result> { + let rest = path.strip_prefix("/content/").unwrap_or(""); + let (content_id, payment_hash) = match rest.split_once("/invoice-status/") { + Some((id, hash)) => (id, hash), + None => { + return Ok(build_response( + StatusCode::BAD_REQUEST, + "text/plain", + hyper::Body::from("Invalid request"), + )) + } + }; + if content_id.is_empty() || !is_valid_app_id(content_id) || payment_hash.is_empty() { + return Ok(build_response( + StatusCode::BAD_REQUEST, + "text/plain", + hyper::Body::from("Invalid request"), + )); + } + + // The hash must be one we issued for exactly this content item. + match crate::content_invoice::lookup(payment_hash).await { + Some((cid, _)) if cid == content_id => {} + _ => { + return Ok(build_response( + StatusCode::NOT_FOUND, + "application/json", + hyper::Body::from(r#"{"error":"Unknown invoice"}"#), + )) + } + } + + // Already paid? Otherwise ask our LND and persist the result. + let mut paid = crate::content_invoice::is_paid_for(payment_hash, content_id).await; + if !paid { + if let Ok(true) = self.rpc_handler.invoice_is_settled(payment_hash).await { + crate::content_invoice::mark_paid(payment_hash).await; + paid = true; + } + } + + let body = serde_json::json!({ "paid": paid }); + Ok(build_response( + StatusCode::OK, + "application/json", + hyper::Body::from(serde_json::to_vec(&body).unwrap_or_default()), + )) + } + + /// Seller side (#46): issue a fresh on-chain address for a paid catalog item + /// so a buyer can pay on-chain. Path: GET /content/{id}/onchain. Records a + /// pending entitlement keyed by the address; price doubles as expected amount. + pub(super) async fn handle_content_onchain(&self, path: &str) -> Result> { + let content_id = path + .strip_prefix("/content/") + .and_then(|s| s.strip_suffix("/onchain")) + .unwrap_or(""); + if content_id.is_empty() || !is_valid_app_id(content_id) { + return Ok(build_response( + StatusCode::BAD_REQUEST, + "text/plain", + hyper::Body::from("Invalid content ID"), + )); + } + let catalog = content_server::load_catalog(&self.config.data_dir) + .await + .unwrap_or_default(); + let price_sats = match catalog.items.iter().find(|i| i.id == content_id) { + Some(i) => match &i.access { + content_server::AccessControl::Paid { price_sats } => *price_sats, + _ => { + return Ok(build_response( + StatusCode::BAD_REQUEST, + "application/json", + hyper::Body::from(r#"{"error":"Item is not paid"}"#), + )) + } + }, + None => { + return Ok(build_response( + StatusCode::NOT_FOUND, + "text/plain", + hyper::Body::from("Content not found"), + )) + } + }; + + match self.rpc_handler.new_onchain_address().await { + Ok(address) if !address.is_empty() => { + crate::content_invoice::record_pending(&address, content_id, price_sats).await; + let body = serde_json::json!({ + "address": address, + "amount_sats": price_sats, + }); + Ok(build_response( + StatusCode::OK, + "application/json", + hyper::Body::from(serde_json::to_vec(&body).unwrap_or_default()), + )) + } + _ => { + let body = serde_json::json!({ + "error": "Could not generate an on-chain address (is the wallet ready?)" + }); + Ok(build_response( + StatusCode::SERVICE_UNAVAILABLE, + "application/json", + hyper::Body::from(serde_json::to_vec(&body).unwrap_or_default()), + )) + } + } + } + + /// Seller side (#46): report whether an on-chain payment to a previously- + /// issued address has arrived (>= price, >= 1 conf). Path: + /// GET /content/{id}/onchain-status/{address}. Marks the entitlement paid. + pub(super) async fn handle_content_onchain_status( + &self, + path: &str, + ) -> Result> { + let rest = path.strip_prefix("/content/").unwrap_or(""); + let (content_id, address) = match rest.split_once("/onchain-status/") { + Some((id, addr)) => (id, addr), + None => { + return Ok(build_response( + StatusCode::BAD_REQUEST, + "text/plain", + hyper::Body::from("Invalid request"), + )) + } + }; + if content_id.is_empty() || !is_valid_app_id(content_id) || address.is_empty() { + return Ok(build_response( + StatusCode::BAD_REQUEST, + "text/plain", + hyper::Body::from("Invalid request"), + )); + } + // The address must be one we issued for exactly this content item. + let price = match crate::content_invoice::lookup(address).await { + Some((cid, price)) if cid == content_id => price, + _ => { + return Ok(build_response( + StatusCode::NOT_FOUND, + "application/json", + hyper::Body::from(r#"{"error":"Unknown address"}"#), + )) + } + }; + + let mut paid = crate::content_invoice::is_paid_for(address, content_id).await; + if !paid { + if let Ok(true) = self.rpc_handler.onchain_received(address, price).await { + crate::content_invoice::mark_paid(address).await; + paid = true; + } + } + let body = serde_json::json!({ "paid": paid }); + Ok(build_response( + StatusCode::OK, + "application/json", + hyper::Body::from(serde_json::to_vec(&body).unwrap_or_default()), + )) + } + + /// Serve a degraded preview of paid content (blurred image or first 2% of video). + pub(super) async fn handle_content_preview( + path: &str, + config: &Config, + ) -> Result> { + // Path format: /content/{id}/preview + let content_id = path + .strip_prefix("/content/") + .and_then(|s| s.strip_suffix("/preview")) + .unwrap_or(""); + + if content_id.is_empty() || !is_valid_app_id(content_id) { + return Ok(build_response( + StatusCode::BAD_REQUEST, + "text/plain", + hyper::Body::from("Invalid content ID"), + )); + } + + match content_server::serve_content_preview(&config.data_dir, content_id).await { + Ok(content_server::PreviewResult::FullContent(bytes, mime_type)) => { + let len = bytes.len(); + Ok(Response::builder() + .status(StatusCode::OK) + .header("Content-Type", mime_type) + .header("Content-Length", len.to_string()) + .body(hyper::Body::from(bytes)) + .unwrap()) + } + Ok(content_server::PreviewResult::BlurPreview(bytes, mime_type)) => { + let len = bytes.len(); + Ok(Response::builder() + .status(StatusCode::OK) + .header("Content-Type", mime_type) + .header("Content-Length", len.to_string()) + .header("X-Content-Preview", "blur") + .body(hyper::Body::from(bytes)) + .unwrap()) + } + Ok(content_server::PreviewResult::TruncatedPreview(bytes, mime_type, total_size)) => { + let len = bytes.len(); + Ok(Response::builder() + .status(StatusCode::OK) + .header("Content-Type", mime_type) + .header("Content-Length", len.to_string()) + .header("X-Content-Preview", "truncated") + .header("X-Content-Total-Size", total_size.to_string()) + .body(hyper::Body::from(bytes)) + .unwrap()) + } + Ok(content_server::PreviewResult::PreviewUnavailable) => Ok(Response::builder() + .status(StatusCode::UNSUPPORTED_MEDIA_TYPE) + .header("Content-Type", "text/plain") + .header("X-Content-Preview", "unavailable") + .body(hyper::Body::from( + "Preview unavailable for this media (needs re-encoding)", + )) + .unwrap()), + Ok(content_server::PreviewResult::NotFound) | Err(_) => Ok(build_response( + StatusCode::NOT_FOUND, + "text/plain", + hyper::Body::from("Preview not available"), + )), + } + } +} diff --git a/core/archipelago/src/api/handler/dwn.rs b/core/archipelago/src/api/handler/dwn.rs new file mode 100644 index 00000000..9bf4778b --- /dev/null +++ b/core/archipelago/src/api/handler/dwn.rs @@ -0,0 +1,201 @@ +use super::build_response; +use crate::config::Config; +use crate::network::dwn_store::DwnStore; +use anyhow::Result; +use hyper::{Response, StatusCode}; + +use super::ApiHandler; + +impl ApiHandler { + /// DWN health endpoint — returns store stats. + pub(super) async fn handle_dwn_health(config: &Config) -> Result> { + match DwnStore::new(&config.data_dir).await { + Ok(store) => { + let stats = store + .stats() + .await + .unwrap_or(crate::network::dwn_store::StoreStats { + message_count: 0, + protocol_count: 0, + total_bytes: 0, + }); + let body = serde_json::json!({ + "status": "ok", + "message_count": stats.message_count, + "protocol_count": stats.protocol_count, + "total_bytes": stats.total_bytes, + }); + Ok(Response::builder() + .status(StatusCode::OK) + .header("Content-Type", "application/json") + .body(hyper::Body::from(body.to_string())) + .unwrap()) + } + Err(_) => Ok(build_response( + StatusCode::SERVICE_UNAVAILABLE, + "application/json", + hyper::Body::from(r#"{"status":"unavailable"}"#), + )), + } + } + + /// DWN message processing endpoint — handles RecordsWrite, RecordsQuery, RecordsRead, RecordsDelete. + /// Supports batch processing: all messages in the array are processed. + pub(super) async fn handle_dwn_message( + body: hyper::body::Bytes, + config: &Config, + ) -> Result> { + let request: serde_json::Value = match serde_json::from_slice(&body) { + Ok(v) => v, + Err(e) => { + let err = serde_json::json!({"error": format!("Invalid JSON: {}", e)}); + return Ok(Response::builder() + .status(StatusCode::BAD_REQUEST) + .header("Content-Type", "application/json") + .body(hyper::Body::from(err.to_string())) + .unwrap()); + } + }; + + // Collect all messages to process + let messages: Vec = if request.get("message").is_some() { + vec![request["message"].clone()] + } else if let Some(msgs) = request["messages"].as_array() { + msgs.clone() + } else { + vec![serde_json::Value::Null] + }; + + let store = DwnStore::new(&config.data_dir).await?; + let mut results = Vec::new(); + + for message in &messages { + let interface = message["descriptor"]["interface"].as_str().unwrap_or(""); + let method = message["descriptor"]["method"].as_str().unwrap_or(""); + + let result = match (interface, method) { + ("Records", "Write") => { + let author = message["author"].as_str().unwrap_or("unknown"); + let protocol = message["descriptor"]["protocol"].as_str(); + let schema = message["descriptor"]["schema"].as_str(); + let data_format = message["descriptor"]["dataFormat"].as_str(); + let data = message.get("data").cloned(); + // Deduplicate: check if recordId already exists + if let Some(record_id) = message["recordId"].as_str() { + if store.read_message(record_id).await.ok().flatten().is_some() { + serde_json::json!({"status": {"code": 200, "detail": "Already exists"}}) + } else { + match store + .write_message(author, protocol, schema, data_format, data) + .await + { + Ok(msg) => { + serde_json::json!({"status": {"code": 202}, "entry": msg}) + } + Err(e) => { + serde_json::json!({"status": {"code": 500, "detail": e.to_string()}}) + } + } + } + } else { + match store + .write_message(author, protocol, schema, data_format, data) + .await + { + Ok(msg) => serde_json::json!({"status": {"code": 202}, "entry": msg}), + Err(e) => { + serde_json::json!({"status": {"code": 500, "detail": e.to_string()}}) + } + } + } + } + ("Records", "Query") => { + let query = crate::network::dwn_store::MessageQuery { + protocol: message["descriptor"]["filter"]["protocol"] + .as_str() + .map(|s| s.to_string()), + schema: message["descriptor"]["filter"]["schema"] + .as_str() + .map(|s| s.to_string()), + author: message["descriptor"]["filter"]["author"] + .as_str() + .map(|s| s.to_string()), + date_from: message["descriptor"]["filter"]["dateFrom"] + .as_str() + .map(|s| s.to_string()), + date_to: message["descriptor"]["filter"]["dateTo"] + .as_str() + .map(|s| s.to_string()), + limit: message["descriptor"]["filter"]["limit"] + .as_u64() + .map(|n| n as usize), + }; + match store.query_messages(&query).await { + Ok(messages) => { + serde_json::json!({"status": {"code": 200}, "entries": messages}) + } + Err(e) => { + serde_json::json!({"status": {"code": 500, "detail": e.to_string()}}) + } + } + } + ("Records", "Read") => { + let record_id = message["descriptor"]["recordId"].as_str().unwrap_or(""); + match store.read_message(record_id).await { + Ok(Some(msg)) => { + serde_json::json!({"status": {"code": 200}, "entry": msg}) + } + Ok(None) => { + serde_json::json!({"status": {"code": 404, "detail": "Record not found"}}) + } + Err(e) => { + serde_json::json!({"status": {"code": 500, "detail": e.to_string()}}) + } + } + } + ("Records", "Delete") => { + let record_id = message["descriptor"]["recordId"].as_str().unwrap_or(""); + match store.delete_message(record_id).await { + Ok(true) => serde_json::json!({"status": {"code": 200}}), + Ok(false) => { + serde_json::json!({"status": {"code": 404, "detail": "Record not found"}}) + } + Err(e) => { + serde_json::json!({"status": {"code": 500, "detail": e.to_string()}}) + } + } + } + _ => { + serde_json::json!({"status": {"code": 400, "detail": format!("Unknown method: {}.{}", interface, method)}}) + } + }; + + results.push(result); + } + + // Return single result for single message, array for batch + let (response_body, http_status) = if results.len() == 1 { + let result = &results[0]; + let status_code = result["status"]["code"].as_u64().unwrap_or(200); + let http_status = match status_code { + 202 => StatusCode::ACCEPTED, + 400 => StatusCode::BAD_REQUEST, + 404 => StatusCode::NOT_FOUND, + 500 => StatusCode::INTERNAL_SERVER_ERROR, + _ => StatusCode::OK, + }; + (result.to_string(), http_status) + } else { + ( + serde_json::json!({"replies": results}).to_string(), + StatusCode::OK, + ) + }; + + Ok(build_response( + http_status, + "application/json", + hyper::Body::from(response_body), + )) + } +} diff --git a/core/archipelago/src/api/handler/mod.rs b/core/archipelago/src/api/handler/mod.rs new file mode 100644 index 00000000..20d8207f --- /dev/null +++ b/core/archipelago/src/api/handler/mod.rs @@ -0,0 +1,619 @@ +mod blob; +mod content; +mod dwn; +mod node_message; +mod proxy; +mod remote_input; +mod remote_relay; +mod websocket; + +use crate::api::rpc::RpcHandler; +use crate::blobs::BlobStore; +use crate::config::Config; +use crate::container::{ContainerOrchestrator, DevContainerOrchestrator}; +use crate::monitoring::MetricsStore; +use crate::session::{self, SessionStore}; +use crate::state::StateManager; +use anyhow::Result; +use hyper::{Method, Request, Response, StatusCode}; +use sha2::{Digest, Sha256}; +use std::sync::Arc; +use tokio::sync::broadcast; +use tracing::debug; + +/// Build an HTTP response without unwrap. Falls back to a plain 500 if builder fails. +// Used by handler submodules after unwrap elimination +#[allow(dead_code)] +pub(super) fn build_response( + status: StatusCode, + content_type: &str, + body: hyper::Body, +) -> Response { + Response::builder() + .status(status) + .header("Content-Type", content_type) + .body(body) + .unwrap_or_else(|_| Response::new(hyper::Body::from("Internal error"))) +} + +pub struct ApiHandler { + config: Config, + rpc_handler: Arc, + state_manager: Arc, + metrics_store: Arc, + session_store: SessionStore, + /// Broadcast channel for relaying companion app input to remote browsers. + input_relay_tx: broadcast::Sender, + /// Reverse broadcast channel: the kiosk browser publishes "open this URL + /// externally" requests here, and the companion (phone) socket forwards them + /// to the phone's default browser. Lets "open in external browser" apps — + /// which the kiosk can't usefully open itself — launch on the controller. + external_open_tx: broadcast::Sender, + /// Content-addressed blob store for attachments shared over mesh/federation. + blob_store: Arc, + /// Our own node pubkey (hex) — used to self-sign debug/test capabilities. + self_pubkey_hex: String, +} + +impl ApiHandler { + pub async fn new( + config: Config, + state_manager: Arc, + metrics_store: Arc, + orchestrator: Option>, + dev_orchestrator: Option>, + ) -> Result { + let session_store = SessionStore::new().await; + let rpc_handler = Arc::new( + RpcHandler::new( + config.clone(), + state_manager.clone(), + metrics_store.clone(), + session_store.clone(), + orchestrator, + dev_orchestrator, + ) + .await?, + ); + let (input_relay_tx, _) = broadcast::channel(64); + let (external_open_tx, _) = broadcast::channel(16); + + // Derive a blob-store capability key from the node's Ed25519 signing + // key. SHA-256 domain-separated so rotating the identity rotates + // every outstanding capability token (intentional — prevents a + // replaced node from honouring old caps). + let identity_dir = config.data_dir.join("identity"); + let identity = crate::identity::NodeIdentity::load_or_create(&identity_dir).await?; + let mut hasher = Sha256::new(); + hasher.update(identity.signing_key().to_bytes()); + hasher.update(b"|archipelago-blob-cap-v1"); + let mut cap_key = [0u8; 32]; + cap_key.copy_from_slice(&hasher.finalize()); + let blob_store = Arc::new(BlobStore::open(&config.data_dir, cap_key).await?); + let self_pubkey_hex = hex::encode(identity.signing_key().verifying_key().as_bytes()); + + // Share blob store with the RPC layer so mesh.send-content / + // mesh.fetch-content can reach the same instance (single cap_key, + // single on-disk root) without re-opening it. + rpc_handler + .set_blob_store(blob_store.clone(), self_pubkey_hex.clone()) + .await; + + Ok(Self { + config, + rpc_handler, + state_manager, + metrics_store, + session_store, + input_relay_tx, + external_open_tx, + blob_store, + self_pubkey_hex, + }) + } + + /// Access the RPC handler (for service initialization after construction). + pub fn rpc_handler(&self) -> &Arc { + &self.rpc_handler + } + + /// Check if the request has a valid session cookie. + async fn is_authenticated(&self, headers: &hyper::HeaderMap) -> bool { + match session::extract_session_cookie(headers) { + Some(token) => self.session_store.validate(&token).await, + None => false, + } + } + + /// Server-side fetch of the upstream app catalog so the browser can + /// load it without fighting CORS (upstream Gitea emits no ACAO) or + /// CSP (the fallback IP-port URL isn't in `connect-src`). The upstream + /// list is derived from the operator's configured container registries + /// so switching mirrors in Settings changes the App Store source too — + /// each active registry contributes one Gitea `raw/branch/main/catalog.json` + /// URL (http or https per `tls_verify`), tried in priority order. + /// If registry config can't be loaded, falls back to the hardcoded OVH + /// URL so the App Store still renders on nodes that haven't persisted + /// a registry config yet. 15s total timeout. + async fn handle_app_catalog_proxy(&self) -> Result> { + let mut upstreams: Vec = Vec::new(); + if let Ok(config) = crate::container::registry::load_registries(&self.config.data_dir).await + { + for reg in config.active_registries() { + let scheme = if reg.tls_verify { "https" } else { "http" }; + // Gitea raw URL: :////app-catalog/raw/branch/main/catalog.json. + // reg.url already includes the namespace (e.g. "host/lfg2025"), + // so we just tack on the repo + raw path. + upstreams.push(format!( + "{}://{}/app-catalog/raw/branch/main/catalog.json", + scheme, reg.url + )); + } + } + if upstreams.is_empty() { + upstreams.push( + "http://146.59.87.168:3000/lfg2025/app-catalog/raw/branch/main/catalog.json" + .to_string(), + ); + } + + let client = match reqwest::Client::builder() + .timeout(std::time::Duration::from_secs(15)) + .build() + { + Ok(c) => c, + Err(e) => { + return Ok(build_response( + hyper::StatusCode::INTERNAL_SERVER_ERROR, + "text/plain", + hyper::Body::from(format!("client build failed: {}", e)), + )); + } + }; + for url in &upstreams { + match client.get(url).send().await { + Ok(resp) if resp.status().is_success() => { + if let Ok(bytes) = resp.bytes().await { + return Ok(Response::builder() + .status(hyper::StatusCode::OK) + .header("Content-Type", "application/json") + .header("Cache-Control", "public, max-age=3600") + .body(hyper::Body::from(bytes)) + .unwrap_or_else(|_| { + Response::new(hyper::Body::from("proxy response build failed")) + })); + } + } + _ => continue, + } + } + Ok(build_response( + hyper::StatusCode::BAD_GATEWAY, + "text/plain", + hyper::Body::from("all upstream catalog URLs failed"), + )) + } + + /// Build a 401 Unauthorized JSON response. + fn unauthorized() -> Response { + let body = serde_json::json!({ "error": "Unauthorized" }); + let body_bytes = serde_json::to_vec(&body).unwrap_or_default(); + Response::builder() + .status(StatusCode::UNAUTHORIZED) + .header("Content-Type", "application/json") + .body(hyper::Body::from(body_bytes)) + .unwrap() + } + + /// A 401 that still carries CORS headers, for endpoints fetched + /// cross-origin by same-node app UIs (e.g. the LND wallet UI on its own + /// port). Without the ACAO header the browser surfaces an opaque CORS + /// error instead of the 401, so the app can't tell it just needs auth. + /// `origin` is the already-validated reflect value from `app_cors_origin` + /// (empty string when the origin isn't allowed → no CORS header added). + fn unauthorized_cors(origin: &str) -> Response { + let body = serde_json::json!({ "error": "Unauthorized" }); + let body_bytes = serde_json::to_vec(&body).unwrap_or_default(); + let mut builder = Response::builder() + .status(StatusCode::UNAUTHORIZED) + .header("Content-Type", "application/json") + .header("Vary", "Origin"); + if !origin.is_empty() { + builder = builder + .header("Access-Control-Allow-Origin", origin) + .header("Access-Control-Allow-Credentials", "true"); + } + builder.body(hyper::Body::from(body_bytes)).unwrap() + } + + /// Allowed CORS origins derived from the config host IP. + fn allowed_origins(&self) -> Vec { + let mut origins = vec![ + format!("http://{}", self.config.host_ip), + format!("https://{}", self.config.host_ip), + ]; + if self.config.dev_mode { + origins.push("http://localhost:8100".to_string()); // Vite dev server + } + origins + } + + /// Validate the Origin header against allowed origins. + /// Returns the matched origin if valid, None if cross-origin is not allowed. + fn validate_origin(&self, headers: &hyper::HeaderMap) -> Option { + let origin = headers.get("origin").and_then(|v| v.to_str().ok())?; + let allowed = self.allowed_origins(); + if allowed.iter().any(|a| a == origin) { + Some(origin.to_string()) + } else { + None + } + } + + /// Permissive origin check for the share-to-mesh iframe intent: any scheme + /// http(s):// followed by the configured host_ip, optionally `:port`. Apps + /// proxied under other ports (APP_PORTS) call this from within the same + /// node, so they share host_ip but not port. The session cookie still has + /// to be valid — this is a sanity check, not the primary auth. + fn validate_app_origin(&self, headers: &hyper::HeaderMap) -> Option { + let origin = headers.get("origin").and_then(|v| v.to_str().ok())?; + // Allow localhost dev server too so the Vite frontend can exercise it. + if self.config.dev_mode && origin == "http://localhost:8100" { + return Some(origin.to_string()); + } + let host_ip = &self.config.host_ip; + let matches = |scheme: &str| -> bool { + let prefix = format!("{}{}", scheme, host_ip); + if origin == prefix { + return true; + } + let with_port = format!("{}:", prefix); + origin.starts_with(&with_port) + && origin[with_port.len()..] + .bytes() + .all(|b| b.is_ascii_digit()) + }; + if matches("http://") || matches("https://") { + Some(origin.to_string()) + } else { + None + } + } + + /// CORS origin to echo for same-node app → backend calls (e.g. the LND + /// wallet UI, served on its own APP_PORTS port). Such apps share the node's + /// host but use a different port, so the strict allowlist (`host_ip`, no + /// port) rejects them and the browser gets no `Access-Control-Allow-Origin` + /// header ("blocked by CORS policy"). Reflect the Origin when its host + /// matches the request's own `Host` header — i.e. the app lives on the same + /// address the node is being reached by, which transparently covers the LAN + /// IP, the Tailscale IP, localhost, and the `.onion` address without needing + /// to enumerate them. Auth is still enforced by the session cookie; this + /// only authorizes the browser to *read* the reply. Returns "" (no echoed + /// origin) when there is no match. + fn app_cors_origin(&self, headers: &hyper::HeaderMap) -> String { + if let Some(origin) = self.validate_origin(headers) { + return origin; + } + let Some(origin) = headers.get("origin").and_then(|v| v.to_str().ok()) else { + return String::new(); + }; + // host portion (no scheme, no port) of an `scheme://host[:port]` value + let host_of = |s: &str| -> Option { + let after_scheme = s.split_once("://").map(|(_, r)| r).unwrap_or(s); + let host_port = after_scheme.split('/').next().unwrap_or(after_scheme); + let host = host_port + .rsplit_once(':') + .map(|(h, _)| h) + .unwrap_or(host_port); + (!host.is_empty()).then(|| host.to_string()) + }; + let origin_host = host_of(origin); + let req_host = headers + .get(hyper::header::HOST) + .and_then(|v| v.to_str().ok()) + .and_then(host_of); + match (origin_host, req_host) { + (Some(o), Some(r)) if o == r => origin.to_string(), + _ => String::new(), + } + } + + pub async fn handle_request(&self, req: Request) -> Result> { + let path = req.uri().path().to_string(); + let method = req.method().clone(); + + // Handle CORS preflight for all routes + if method == Method::OPTIONS { + let mut builder = Response::builder() + .status(StatusCode::NO_CONTENT) + .header("Vary", "Origin"); + let preflight_origin = self.app_cors_origin(req.headers()); + if !preflight_origin.is_empty() { + builder = builder + .header("Access-Control-Allow-Origin", &preflight_origin) + .header("Access-Control-Allow-Methods", "GET, POST, OPTIONS") + .header("Access-Control-Allow-Headers", "Content-Type, X-CSRF-Token") + .header("Access-Control-Allow-Credentials", "true"); + } + return Ok(builder.body(hyper::Body::empty()).unwrap()); + } + + // WebSocket upgrade — validate session before upgrading + if method == Method::GET && path == "/ws/db" { + if !self.is_authenticated(req.headers()).await { + tracing::warn!("401 WebSocket /ws/db — session invalid or missing"); + return Ok(Self::unauthorized()); + } + return Self::handle_websocket( + req, + self.state_manager.clone(), + self.metrics_store.clone(), + ) + .await; + } + + // Remote input WebSocket — companion app sends keyboard/mouse events + if method == Method::GET && path == "/ws/remote-input" { + if !self.is_authenticated(req.headers()).await { + tracing::warn!("401 WebSocket /ws/remote-input — session invalid or missing"); + return Ok(Self::unauthorized()); + } + return Self::handle_remote_input( + req, + self.input_relay_tx.clone(), + self.external_open_tx.subscribe(), + ) + .await; + } + + // Remote relay WebSocket — browser receives companion input events + if method == Method::GET && path == "/ws/remote-relay" { + if !self.is_authenticated(req.headers()).await { + tracing::warn!("401 WebSocket /ws/remote-relay — session invalid or missing"); + return Ok(Self::unauthorized()); + } + return Self::handle_remote_relay( + req, + self.input_relay_tx.subscribe(), + self.external_open_tx.clone(), + ) + .await; + } + + // Convert body to bytes for non-WS routes + let headers = req.headers().clone(); + let query_string = req.uri().query().map(|s| s.to_string()).unwrap_or_default(); + let (parts, body) = req.into_parts(); + let body_bytes = hyper::body::to_bytes(body) + .await + .map_err(|e| anyhow::anyhow!("Failed to read body: {}", e))?; + let req_with_bytes = Request::from_parts(parts, hyper::Body::from(body_bytes.clone())); + + debug!("{} {}", method, path); + + match (method, path.as_str()) { + // RPC — auth is handled inside rpc handler per-method + (Method::POST, "/rpc/v1") => self.rpc_handler.clone().handle(req_with_bytes).await, + + // Health — unauthenticated, returns JSON with service status + (Method::GET, "/health") => { + let recovery_complete = crate::crash_recovery::is_recovery_complete(); + let uptime = crate::crash_recovery::uptime_seconds(); + let health_status = if recovery_complete { "ok" } else { "degraded" }; + let status = serde_json::json!({ + "status": health_status, + "crash_recovery_complete": recovery_complete, + "uptime_seconds": uptime, + "version": env!("CARGO_PKG_VERSION"), + "services": { + "rpc": true, + "sessions": true, + } + }); + Ok(Response::builder() + .status(StatusCode::OK) + .header("Content-Type", "application/json") + .body(hyper::Body::from( + serde_json::to_vec(&status).unwrap_or_default(), + )) + .unwrap()) + } + + // Node message — P2P endpoint (authenticated by source validation, not cookie) + (Method::POST, "/archipelago/node-message") => { + Self::handle_node_message(body_bytes).await + } + + // Mesh typed envelope relay over federation — peers POST + // pre-encoded TypedEnvelope wire bytes here when the envelope is + // too large for a single LoRa frame (primarily ContentRef). No + // session auth: the body carries a pubkey + ed25519 signature + // over the wire bytes which we verify before dispatching. + (Method::POST, "/archipelago/mesh-typed") => { + Self::handle_mesh_typed_relay(self.rpc_handler.clone(), body_bytes).await + } + + // Blob upload — local/session use only. Session-authenticated so + // only the node owner can push attachments into the blob store. + (Method::POST, "/api/blob") => { + if !self.is_authenticated(&headers).await { + return Ok(Self::unauthorized()); + } + Self::handle_blob_upload( + &self.blob_store, + &self.self_pubkey_hex, + &self.config.data_dir, + &headers, + body_bytes, + ) + .await + } + + // Share-to-mesh intent — marketplace app iframes POST a file here + // to stage it as a mesh attachment. Same body format as /api/blob + // (raw bytes + X-Blob-Mime/X-Blob-Filename headers). The app is + // expected to postMessage `{type:'share-to-mesh', cid, ...}` to + // its parent window afterwards so the Mesh view can pick it up. + // Authenticated by session cookie + a relaxed Origin check (any + // port on the archipelago host is allowed, so proxied apps on + // their own ports can reach it with credentials:'include'). + (Method::POST, "/api/share-to-mesh") => { + if !self.is_authenticated(&headers).await { + return Ok(Self::unauthorized()); + } + let origin = match self.validate_app_origin(&headers) { + Some(o) => o, + None => { + return Ok(build_response( + StatusCode::FORBIDDEN, + "text/plain", + hyper::Body::from("origin not allowed"), + )) + } + }; + Self::handle_share_to_mesh( + &self.blob_store, + &self.self_pubkey_hex, + &headers, + body_bytes, + &origin, + ) + .await + } + + // Blob download — peer-facing. No session required; authenticated + // by HMAC capability token signed when the blob ref was shared. + (Method::GET, p) if p.starts_with("/blob/") => { + Self::handle_blob_download(&self.blob_store, p, &query_string).await + } + + // Content preview — degraded previews for paid content (no auth, no payment) + (Method::GET, p) if p.starts_with("/content/") && p.ends_with("/preview") => { + Self::handle_content_preview(p, &self.config).await + } + + // Lightning-invoice peer-file sale (#46): mint invoice / poll settlement + (Method::GET, p) if p.starts_with("/content/") && p.ends_with("/invoice") => { + self.handle_content_invoice(p).await + } + (Method::GET, p) if p.starts_with("/content/") && p.contains("/invoice-status/") => { + self.handle_content_invoice_status(p).await + } + + // On-chain peer-file sale (#46): issue address / poll for payment + (Method::GET, p) if p.starts_with("/content/") && p.contains("/onchain-status/") => { + self.handle_content_onchain_status(p).await + } + (Method::GET, p) if p.starts_with("/content/") && p.ends_with("/onchain") => { + self.handle_content_onchain(p).await + } + + // Content serving — peers access shared content over Tor (no session auth) + (Method::GET, p) if p.starts_with("/content/") => { + Self::handle_content_request(p, &headers, &self.config).await + } + + // Content catalog — list available content (no session auth, for peers) + (Method::GET, "/content") => Self::handle_content_catalog(&self.config).await, + + // Electrs status — unauthenticated (read-only sync status) + (Method::GET, "/electrs-status") => Self::handle_electrs_status().await, + (Method::GET, "/bitcoin-status") => Self::handle_bitcoin_status().await, + + // App-catalog proxy — fetches catalog.json from the configured + // upstream URLs server-side so the browser doesn't hit CORS + // (upstream Gitea has no ACAO header) or CSP (IP-port upstream + // falls outside `connect-src`). Session-authenticated so only + // the logged-in node owner can spin up fetches. + (Method::GET, "/api/app-catalog") => { + if !self.is_authenticated(&headers).await { + return Ok(Self::unauthorized()); + } + self.handle_app_catalog_proxy().await + } + + // LND connect info — nginx validates session cookie (presence check), + // backend is bound to 127.0.0.1 so only nginx can reach it. + // No backend auth check here because the LND UI iframe fetches this + // endpoint and the session cookie flow is validated at the nginx layer. + (Method::GET, "/lnd-connect-info") => { + let origin = self.app_cors_origin(&headers); + Self::handle_lnd_connect_info(self.rpc_handler.clone(), &origin).await + } + + // Container logs — requires session + (Method::GET, path) if path.starts_with("/api/container/logs") => { + if !self.is_authenticated(&headers).await { + return Ok(Self::unauthorized()); + } + let origin = self.validate_origin(&headers).unwrap_or_default(); + Self::handle_container_logs_http(self.rpc_handler.clone(), path, &origin).await + } + + // Peer content streaming proxy — Range-streams a peer's media file + // so