commit e3bcd9fca9e1989b66656ebd35c47c2ddd308659 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/build-iso.yml b/.gitea/workflows/build-iso.yml new file mode 100644 index 00000000..d1abdc54 --- /dev/null +++ b/.gitea/workflows/build-iso.yml @@ -0,0 +1,62 @@ +name: Build Archipelago release ISO (gated) + +# Resurrected from image-recipe/_archived/.gitea-workflows/build-iso-dev.yml. +# Dispatch-only on purpose: the ISO is cut per release, not per push, and +# the iso-builder runner is a live node — builds are deliberate events. +on: + workflow_dispatch: + +jobs: + build-iso: + runs-on: iso-builder + timeout-minutes: 180 + steps: + - name: Sync source to workspace + run: | + # Direct fetch + sync (actions/checkout token is broken on this Gitea) + REPO_DIR="$HOME/Projects/archy" + [ -d "$REPO_DIR" ] || REPO_DIR="$HOME/archy" + cd "$REPO_DIR" && git fetch origin main && git reset --hard origin/main + echo "=== Source at commit: $(git log --oneline -1) ===" + + - name: Install ISO build dependencies + run: | + if dpkg -s debootstrap squashfs-tools xorriso isolinux syslinux-common mtools \ + grub-efi-amd64-bin grub-pc-bin grub-common >/dev/null 2>&1; then + echo "ISO build deps already installed, skipping apt" + else + sudo apt-get update -qq + sudo apt-get install -y -qq \ + debootstrap squashfs-tools xorriso \ + isolinux syslinux-common mtools \ + grub-efi-amd64-bin grub-pc-bin grub-common + fi + + - name: Build backend + frontend if stale + run: | + REPO_DIR="$HOME/Projects/archy" + [ -d "$REPO_DIR" ] || REPO_DIR="$HOME/archy" + cd "$REPO_DIR" + . "$HOME/.cargo/env" 2>/dev/null || true + VERSION=$(grep -m1 '^version' core/archipelago/Cargo.toml | sed 's/.*"\(.*\)".*/\1/') + if ! strings core/target/release/archipelago 2>/dev/null | grep -qF "$VERSION"; then + cargo build --release --manifest-path core/Cargo.toml -p archipelago + fi + if ! grep -rqoF "$VERSION" web/dist/neode-ui/assets/*.js 2>/dev/null; then + (cd neode-ui && npm ci && npm run build) + fi + + - name: Gated ISO build (gates + build + smoke + qemu) + run: | + REPO_DIR="$HOME/Projects/archy" + [ -d "$REPO_DIR" ] || REPO_DIR="$HOME/archy" + cd "$REPO_DIR" + . "$HOME/.cargo/env" 2>/dev/null || true + bash scripts/build-iso-release.sh + + - name: Report artifacts + if: always() + run: | + REPO_DIR="$HOME/Projects/archy" + [ -d "$REPO_DIR" ] || REPO_DIR="$HOME/archy" + ls -lh "$REPO_DIR"/image-recipe/results/*.iso 2>/dev/null | tail -3 || echo "no ISO produced" diff --git a/.gitea/workflows/demo-images.yml b/.gitea/workflows/demo-images.yml new file mode 100644 index 00000000..ba739508 --- /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. source.archipelago-foundation.org/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' + - '.gitea/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..91c53ffc --- /dev/null +++ b/.gitea/workflows/post-install-tests.yml @@ -0,0 +1,83 @@ +name: Post-Install Tests + +on: + workflow_dispatch: + inputs: + target: + description: 'Target node IP or hostname' + required: true + password: + description: 'Node UI password (leave blank to use the NODE_UI_PASSWORD secret)' + required: false + +jobs: + post-install-tests: + runs-on: ubuntu-latest + timeout-minutes: 15 + steps: + - name: Checkout + uses: actions/checkout@v4 + with: + fetch-depth: 1 + + - name: Install SSH key + env: + SSH_KEY: ${{ secrets.NODE_SSH_KEY }} + run: | + if [ -z "$SSH_KEY" ]; then + echo "ERROR: repository secret NODE_SSH_KEY is not configured." + echo "Post-install tests authenticate by key; password auth is not supported." + exit 1 + fi + mkdir -p ~/.ssh && chmod 700 ~/.ssh + printf '%s\n' "$SSH_KEY" > ~/.ssh/id_ed25519 + chmod 600 ~/.ssh/id_ed25519 + + - name: Run post-install tests on target + env: + TARGET: ${{ github.event.inputs.target }} + NODE_PASSWORD: ${{ github.event.inputs.password }} + NODE_UI_PASSWORD: ${{ secrets.NODE_UI_PASSWORD }} + SSH_USER: ${{ vars.NODE_SSH_USER }} + run: | + PASSWORD="${NODE_PASSWORD:-$NODE_UI_PASSWORD}" + if [ -z "$PASSWORD" ]; then + echo "ERROR: no node password supplied (input or NODE_UI_PASSWORD secret)." + exit 1 + fi + USER_NAME="${SSH_USER:-archipelago}" + + echo "══════════════════════════════════════════" + echo "Running post-install tests on $TARGET" + echo "══════════════════════════════════════════" + + scp -o StrictHostKeyChecking=accept-new \ + scripts/run-post-install-tests.sh \ + "${USER_NAME}@${TARGET}:/tmp/run-post-install-tests.sh" + + # Password is passed over stdin, never as an argv the node's process + # list (or this job's log) would expose. + printf '%s' "$PASSWORD" | ssh -o StrictHostKeyChecking=accept-new \ + "${USER_NAME}@${TARGET}" \ + "sudo bash /tmp/run-post-install-tests.sh --password-stdin" + + 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/.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..ef60d323 --- /dev/null +++ b/.github/pull_request_template.md @@ -0,0 +1,16 @@ +## Summary + + + +## Verification + + + +## Checklist + +- [ ] Rust formatting/clippy/tests pass when backend code changed. +- [ ] Frontend type-check/build/tests pass when frontend code changed. +- [ ] App manifests validate when app packaging changed. +- [ ] Generated catalogs are updated when manifest-owned catalog fields changed. +- [ ] Docs are updated for user-facing or developer-facing behavior changes. +- [ ] No secrets, generated build outputs, local screenshots, or private host details are included. 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..ab3b5ada --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,123 @@ +name: CI + +on: + push: + branches: [main] + pull_request: + branches: [main] + +env: + RUST_VERSION: stable + NODE_VERSION: 20 + +jobs: + rust: + name: Rust + 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: Format + run: cargo fmt --all -- --check + + # KEY-05 layer (b) is enforced HERE, with no step of its own: core/clippy.toml + # bans the defaulted RNG entry points, and `-D warnings` already turns a + # `disallowed_methods` hit into a build failure. `--all-targets` covers tests + # too, deliberately. See docs/security/KEY-05-ENTROPY-ENFORCEMENT.md + - name: Clippy + run: cargo clippy --all-targets --all-features -- -D warnings + + # KEY-05 layer (c) — see core/deny.toml for the policy and its rationale. + # + # The version is pinned deliberately. EmbarkStudios/cargo-deny-action exposes + # no input to pin the cargo-deny version, and an unpinned supply-chain checker + # is a contradiction in terms, so the tool is installed from crates.io — the + # source actually vetted at the 10-06 Task 5 legitimacy checkpoint — rather + # than by adding another unvetted action to this workflow. + # + # `check bans` ONLY: the advisories gate is not enabled (bans-only policy). + - name: Supply chain (cargo-deny) + run: | + cargo install --locked cargo-deny --version 0.20.2 + cargo deny check bans + + - name: Test + run: cargo test --all-features + + frontend: + name: Frontend + 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 + run: npm ci + + - name: Type check + run: npm run type-check + + - name: Test + run: npm test + + - name: Build + run: npm run build + + manifests: + name: App Manifests + runs-on: ubuntu-latest + + steps: + - name: Checkout + uses: actions/checkout@v4 + + - name: Install YAML parser + run: python3 -m pip install --quiet pyyaml + + - name: Validate manifests + run: | + for manifest in apps/*/manifest.yml; do + ./scripts/validate-app-manifest.sh --repo-audit "$manifest" + done + + # The signed catalog overrides on-disk manifests on every node, so a + # catalog naming a registry host the deployed fleet does not trust breaks + # every install fleet-wide. Blocking, and cheap. + - name: Catalog registry trust floor + run: python3 scripts/check-catalog-registry-trust.py + + # A stale image literal on the fallback install path deploys an old + # image after the manifest has moved on — how a withdrawn, vulnerable + # release gets installed post-fix. Blocking. + - name: Installer image pins + run: python3 scripts/check-installer-image-pins.py + + # Advisory: shows where the release catalog has fallen behind the + # manifests in this repo. Not blocking, because the catalog can only be + # updated through the signing ceremony, so drift is expected between a + # manifest landing and the next signed release. + - name: Catalog drift (advisory) + continue-on-error: true + run: python3 scripts/check-app-catalog-drift.py --catalog releases/app-catalog.json --release diff --git a/.github/workflows/demo-images.yml b/.github/workflows/demo-images.yml new file mode 100644 index 00000000..9e0f1733 --- /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. source.archipelago-foundation.org/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..02574536 --- /dev/null +++ b/.gitignore @@ -0,0 +1,164 @@ +# SSH keys and sandbox copies +.ssh/ + +# Rust build output +target/ +**/target/ + +# Node.js +node_modules/ +**/node_modules/ +npm-debug.log* +yarn-debug.log* +yarn-error.log* +pnpm-debug.log* + +# Build outputs +dist/ +dist-ssr/ +build/ +*.local + +# Vite build cache +neode-ui/.vite/ + +# IDE / editor +.idea/ +.vscode/ +*.swp +*.swo +*~ +.DS_Store +._* +Thumbs.db + +# Environment and local overrides +.env +.env.local +.env.*.local +.env.production +core/.env.production +scripts/deploy-config.sh + +# Logs +logs/ +*.log + +# Testing +coverage/ +.nyc_output/ + +# Image / release artifacts +*.iso +*.img +*.dmg +*.app +*.apk +*.keystore +*.s9pk +*.tar.gz + +# Release artifacts live in release attachments, not Git history. +releases/** +!releases/ +!releases/manifest.json +# The signed app catalog and the registry trust floor are source, not build +# output: nodes fetch the catalog from this path on main, and the floor is what +# scripts/check-catalog-registry-trust.py checks it against. Both were being +# swallowed by the rule above — app-catalog.json only stayed tracked because it +# predates it. +!releases/app-catalog.json +!releases/registry-trust-floor.json + +# Image recipe output +image-recipe/output/ +image-recipe/*.iso +image-recipe/*.img + +# Loop tool artifacts +*/loop/ +loop/loop/ +loop/loop.log.bak + +# Separate repos nested in tree +web/ + +# Resilience harness reports contain session cookies. +scripts/resilience/reports/ + +# Codex / pnpm / python caches / editor backups +.codex +.codex-target-*/ +.codex-tmp/ +.claude/ +.pnpm-store/ + +# Key material and local databases — belt-and-braces so a stray key or a +# copied node database can never be committed. Open-source readiness plan, +# Phase 1 item 5: `.claude/settings.local.json` was previously only caught by +# a machine-global ignore rule, which protects one machine and no contributor. +*.key +*.pem +id_rsa* +*.sqlite +*.sqlite3 +*.db + +# ...except the throwaway TLS fixtures the appgate tests compile in via +# include_bytes!. They are documented non-identity material (see that +# directory's README) and are already tracked; the negation stops the rule +# above from silently dropping them if they are ever regenerated. +!core/archipelago/src/appgate/testdata/*.key +**/__pycache__/ +*.bak + +# Local evidence screenshots; intentional UI screenshots should live under an +# app/docs asset path with a descriptive filename. +Screenshot *.png +uploads/ + +# ── Local-only material ───────────────────────────────────────────────────── +# Present on disk, never tracked: everything describing Archipelago's own +# infrastructure or internal development process. The repo is source code and +# guidelines only. Inventory: .local-only/manifest.txt — wipe: .local-only/wipe.sh +/.local-only/ +/.planning/ +/loop/ +/docs/operations-runbook.md +/docs/hotfix-process.md +/docs/PRODUCTION-MASTER-PLAN.md +/docs/UNIFIED-TASK-TRACKER.md +/docs/FIPS-UPTIME-AND-UI-STATE-PLAN.md +/docs/HANDOFF-2026-07-20-fips-peer-files.md +/docs/HANDOFF-2026-07-23-companion-apk-deploy.md +/docs/qr-scanner-snappiness-handover.md +/docs/RETICULUM-TRANSPORT-PROGRESS.md +/docs/combined-test-plan-2026-07-22.md +/docs/pine-voice-release-test-plan.md +/docs/OPEN-SOURCE-READINESS-PLAN.md +/docs/archive/HANDOVER-2026-07-02-iso-feedback.md +/docs/archive/SESSION-1.8.0-OTA-PROGRESS.md +/docs/security/KEY-02-FLEET-ROTATION.md +/docs/security/KEY-03-SIGNING-POSTURE.md +/tests/production-quality/TRACKER.md +/scripts/deploy-config-defaults.sh +/scripts/deploy-tailscale.sh +/scripts/deploy-to-target.sh +/scripts/setup-target-dev.sh +/scripts/setup-aiui-server.sh +/scripts/setup-https-dev.sh +/scripts/debug-frontend.sh +/scripts/node-profile.sh +/scripts/fleet-fips-pair.sh +/scripts/fleet-fips-unpair.sh +/image-recipe/sync-from-live.sh +/docs/security/PHASE-10-VERIFICATION-GUIDE.md +/docs/security/KEY-01-ON-NODE-VERIFICATION.md +/docs/security/KEY-02-ROOTFS-EVIDENCE.md +/docs/security/ENTROPY-SEED-AUDIT-2026-07-31.md +/image-recipe/INTEGRATION-GUIDE.md +/docs/multinode-testing-plan.md +/docs/bitcoin-version-bulletproof-rollout.md + +# Generated PWA dev output (vite-plugin-pwa) — never a source artifact +neode-ui/dev-dist/ diff --git a/Android/.gitignore b/Android/.gitignore new file mode 100644 index 00000000..e3f57e61 --- /dev/null +++ b/Android/.gitignore @@ -0,0 +1,25 @@ +*.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 + +# Rust build outputs (archy-fips-core → jniLibs via buildRustArm64) +/rust/archy-fips-core/target +/app/src/main/jniLibs diff --git a/Android/COMPANION_RELEASE.md b/Android/COMPANION_RELEASE.md new file mode 100644 index 00000000..b5e7c5c4 --- /dev/null +++ b/Android/COMPANION_RELEASE.md @@ -0,0 +1,101 @@ +# 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). +6. The first-launch companion modal and Android "Share this app" QR point at + `http://146.59.87.168:2100/packages/archipelago-companion.apk`. After the + repo artifact is built, mirror that exact APK to the VPS2-served path before + calling the release done. + +**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 checked-in artifact is Gitea raw-on-main. The QR/App Store download served +to users is the VPS2 `:2100` URL. Confirm both live byte streams match what you +built and signed: + +```bash +SERVED=neode-ui/public/packages/archipelago-companion.apk +GITEA_URL=https://source.archipelago-foundation.org/lfg2025/archy/raw/branch/main/$SERVED +QR_URL=http://146.59.87.168:2100/packages/archipelago-companion.apk +curl -sS -o /tmp/live-gitea.apk "$GITEA_URL" +curl -sS -o /tmp/live-qr.apk "$QR_URL" +shasum -a 256 "$SERVED" /tmp/live-gitea.apk /tmp/live-qr.apk # all must match +apksigner verify -v --min-sdk-version 21 /tmp/live-qr.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..0fb58dd7 --- /dev/null +++ b/Android/app/build.gradle.kts @@ -0,0 +1,165 @@ +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 = 45 + versionName = "0.5.25" + + vectorDrawables { + useSupportLibrary = true + } + + // The embedded FIPS mesh (libarchy_fips_core.so) is built arm64-only, + // matching real handsets. FipsNative.available gates every call, so + // the app still runs as a plain companion elsewhere (e.g. x86 emu). + ndk { abiFilters += "arm64-v8a" } + } + + 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}" + } + } +} + +// --------------------------------------------------------------------------- +// Embedded FIPS mesh: cross-compile Android/rust/archy-fips-core via cargo-ndk +// into jniLibs before the native-libs merge, so a plain `gradlew assembleDebug` +// builds the Rust too. Requires rustup target aarch64-linux-android, cargo-ndk, +// and an NDK (ANDROID_NDK_HOME or the SDK's ndk/ dir). +// --------------------------------------------------------------------------- +val rustCrateDir = layout.projectDirectory.dir("../rust/archy-fips-core") +val jniLibsDir = layout.projectDirectory.dir("src/main/jniLibs") + +tasks.register("buildRustArm64") { + workingDir = rustCrateDir.asFile + inputs.dir(rustCrateDir.dir("src")) + inputs.file(rustCrateDir.file("Cargo.toml")) + outputs.dir(jniLibsDir) + // cargo/cargo-ndk live in ~/.cargo/bin, which Gradle's env may not have. + val home = System.getProperty("user.home") + environment("PATH", "$home/.cargo/bin:${System.getenv("PATH")}") + if (System.getenv("ANDROID_NDK_HOME") == null) { + val sdkNdk = file("$home/Library/Android/sdk/ndk") + .listFiles()?.maxByOrNull { it.name } + if (sdkNdk != null) environment("ANDROID_NDK_HOME", sdkNdk.absolutePath) + } + commandLine( + "cargo", "ndk", + "-t", "arm64-v8a", + "--platform", "26", + "-o", jniLibsDir.asFile.absolutePath, + "build", "--release", + ) +} + +tasks.matching { + it.name in listOf( + "mergeDebugNativeLibs", "mergeReleaseNativeLibs", + "mergeDebugJniLibFolders", "mergeReleaseJniLibFolders", + ) +}.configureEach { dependsOn("buildRustArm64") } + +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") + + // CameraX + ZXing (Apache-2.0, on-device, no telemetry) for pairing-QR scanning + implementation("androidx.camera:camera-camera2:1.3.4") + implementation("androidx.camera:camera-lifecycle:1.3.4") + implementation("androidx.camera:camera-view:1.3.4") + implementation("com.google.zxing:core:3.5.3") + + debugImplementation("androidx.compose.ui:ui-tooling") + debugImplementation("androidx.compose.ui:ui-test-manifest") +} 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..1ed5e9a3 --- /dev/null +++ b/Android/app/src/main/AndroidManifest.xml @@ -0,0 +1,75 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + 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..3e066864 --- /dev/null +++ b/Android/app/src/main/java/com/archipelago/app/MainActivity.kt @@ -0,0 +1,41 @@ +package com.archipelago.app + +import android.content.Intent +import android.os.Bundle +import androidx.activity.ComponentActivity +import androidx.activity.compose.setContent +import androidx.activity.enableEdgeToEdge +import androidx.compose.runtime.collectAsState +import androidx.compose.runtime.getValue +import androidx.core.splashscreen.SplashScreen.Companion.installSplashScreen +import com.archipelago.app.ui.navigation.AppNavHost +import com.archipelago.app.ui.theme.ArchipelagoTheme +import kotlinx.coroutines.flow.MutableStateFlow + +class MainActivity : ComponentActivity() { + + // Pairing deep link (archipelago://pair?...) from the launch intent or a + // later one (launchMode=singleTask). Consumed by AppNavHost. + private val pendingPairUri = MutableStateFlow(null) + + override fun onCreate(savedInstanceState: Bundle?) { + installSplashScreen() + enableEdgeToEdge() + super.onCreate(savedInstanceState) + pendingPairUri.value = intent?.dataString + setContent { + ArchipelagoTheme { + val pairUri by pendingPairUri.collectAsState() + AppNavHost( + pairUri = pairUri, + onPairUriConsumed = { pendingPairUri.value = null }, + ) + } + } + } + + override fun onNewIntent(intent: Intent) { + super.onNewIntent(intent) + pendingPairUri.value = intent.dataString + } +} 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..6d0458aa --- /dev/null +++ b/Android/app/src/main/java/com/archipelago/app/data/ServerPreferences.kt @@ -0,0 +1,241 @@ +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 = "", + /** Node's FIPS mesh ULA (IPv6) — reachable from anywhere once meshed. */ + val meshIp: String = "", + /** Node's FIPS npub — the durable identity. When present it, not the + * address, is what identifies the entry: FIPS peers on npubs, IPs are + * only dial hints (docs/companion-pairing-qr.md, npub-first contract). */ + val npub: String = "", +) { + /** Label to show in lists — the user-given name, or the address if unnamed. */ + fun displayName(): String = name.ifBlank { address } + + /** Bracket bare IPv6 literals (the mesh ULA) so they form valid URLs. */ + private fun urlHost(host: String): String = + if (host.contains(":") && !host.startsWith("[")) "[$host]" else host + + fun toUrl(): String { + val scheme = if (useHttps) "https" else "http" + val portSuffix = if (port.isNotBlank()) ":$port" else "" + return "$scheme://${urlHost(address)}$portSuffix" + } + + fun toWsUrl(): String { + val scheme = if (useHttps) "wss" else "ws" + val portSuffix = if (port.isNotBlank()) ":$port" else "" + return "$scheme://${urlHost(address)}$portSuffix" + } + + /** Mesh-address UI URL, or null when the node never advertised one. */ + fun toMeshUrl(): String? = + meshIp.takeIf { it.isNotBlank() }?.let { "http://${urlHost(it)}" } + + // name/meshIp/npub are trailing fields so entries saved before they + // existed (4/5/6 fields) still deserialize, defaulting to "". + fun serialize(): String = "$address|$useHttps|$port|$password|$name|$meshIp|$npub" + + /** Same node as [other]? npub identity wins; address/port/scheme is the + * fallback for LAN-only entries that never advertised FIPS. */ + fun sameNode(other: ServerEntry): Boolean = + (npub.isNotBlank() && npub == other.npub) || + (address == other.address && port == other.port && useHttps == other.useHttps) + + 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) { "" }, + meshIp = parts.getOrElse(5) { "" }, + npub = parts.getOrElse(6) { "" }, + ) + } + } +} + +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 activeMeshIpKey = stringPreferencesKey("active_mesh_ip") + private val activeNpubKey = stringPreferencesKey("active_npub") + private val savedServersKey = stringSetPreferencesKey("saved_servers") + private val introSeenKey = booleanPreferencesKey("intro_seen") + private val gestureHintSeenKey = booleanPreferencesKey("gesture_hint_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] ?: "", + meshIp = prefs[activeMeshIpKey] ?: "", + npub = prefs[activeNpubKey] ?: "", + ) + } + + 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 + } + + /** One-shot flag for the three-finger-hold teaching overlay. */ + val gestureHintSeen: Flow = context.dataStore.data.map { prefs -> + prefs[gestureHintSeenKey] ?: 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 + prefs[activeMeshIpKey] = server.meshIp + prefs[activeNpubKey] = server.npub + } + addSavedServer(server) + } + + suspend fun clearActiveServer() { + context.dataStore.edit { prefs -> + prefs.remove(activeAddressKey) + prefs.remove(activeHttpsKey) + prefs.remove(activePortKey) + prefs.remove(activePasswordKey) + prefs.remove(activeNameKey) + prefs.remove(activeMeshIpKey) + prefs.remove(activeNpubKey) + } + } + + 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 node + * identity — npub first, address/port/scheme as the LAN-only fallback + * (ServerEntry.sameNode) — so edits that change the name, password or even + * every address still update the right record. An edit form that doesn't + * carry the npub keeps the stored one. If the edited server is also the + * active one, the active record is kept in sync. + */ + suspend fun updateSavedServer(original: ServerEntry, updated: ServerEntry) { + val toStore = updated.copy(npub = updated.npub.ifBlank { original.npub }) + context.dataStore.edit { prefs -> + val current = prefs[savedServersKey] ?: emptySet() + val filtered = current.filterNot { raw -> + ServerEntry.deserialize(raw)?.sameNode(original) == true + }.toSet() + prefs[savedServersKey] = filtered + toStore.serialize() + + val activeNpub = prefs[activeNpubKey] ?: "" + val isActive = (activeNpub.isNotBlank() && activeNpub == original.npub) || + ( + prefs[activeAddressKey] == original.address && + (prefs[activePortKey] ?: "") == original.port && + (prefs[activeHttpsKey] ?: false) == original.useHttps + ) + if (isActive) { + prefs[activeAddressKey] = toStore.address + prefs[activeHttpsKey] = toStore.useHttps + prefs[activePortKey] = toStore.port + prefs[activePasswordKey] = toStore.password + prefs[activeNameKey] = toStore.name + prefs[activeMeshIpKey] = toStore.meshIp + prefs[activeNpubKey] = toStore.npub + } + } + } + + /** + * Add a server, or update the entry for the same node — npub first, + * address/port/scheme as the LAN-only fallback (ServerEntry.sameNode) — + * used by QR pairing so re-scanning a node never duplicates it, even after + * the LAN renumbered and every address changed (npub-first contract in + * docs/companion-pairing-qr.md). A blank incoming password/name keeps the + * stored value (a real node's QR never carries the password). Returns the + * merged entry. + */ + suspend fun upsertServer(server: ServerEntry): ServerEntry { + var merged = server + context.dataStore.edit { prefs -> + val current = prefs[savedServersKey] ?: emptySet() + val existing = current.mapNotNull { ServerEntry.deserialize(it) } + .firstOrNull { it.sameNode(server) } + if (existing != null) { + merged = server.copy( + password = server.password.ifBlank { existing.password }, + name = server.name.ifBlank { existing.name }, + meshIp = server.meshIp.ifBlank { existing.meshIp }, + npub = server.npub.ifBlank { existing.npub }, + ) + } + val filtered = current.filterNot { raw -> + ServerEntry.deserialize(raw)?.sameNode(merged) == true + }.toSet() + prefs[savedServersKey] = filtered + merged.serialize() + } + return merged + } + + suspend fun removeSavedServer(server: ServerEntry) { + context.dataStore.edit { prefs -> + val current = prefs[savedServersKey] ?: emptySet() + // Match by node identity (npub, else address/port/scheme) rather + // than the exact serialized string, so a rename — or a legacy + // short-format entry — still removes the right record. + prefs[savedServersKey] = current.filterNot { raw -> + ServerEntry.deserialize(raw)?.sameNode(server) == true + }.toSet() + } + } + + suspend fun markIntroSeen() { + context.dataStore.edit { prefs -> + prefs[introSeenKey] = true + } + } + + suspend fun markGestureHintSeen() { + context.dataStore.edit { prefs -> + prefs[gestureHintSeenKey] = true + } + } +} diff --git a/Android/app/src/main/java/com/archipelago/app/data/ServerQrParser.kt b/Android/app/src/main/java/com/archipelago/app/data/ServerQrParser.kt new file mode 100644 index 00000000..465d8838 --- /dev/null +++ b/Android/app/src/main/java/com/archipelago/app/data/ServerQrParser.kt @@ -0,0 +1,128 @@ +package com.archipelago.app.data + +import android.net.Uri +import com.archipelago.app.fips.AnchorPeer +import com.archipelago.app.fips.FipsPairInfo + +/** + * Result of parsing a pairing QR / deep link. + * + * UnsupportedVersion means the payload is structurally a pairing URI but its + * major version is newer than this app understands — the UI should tell the + * user to update the app rather than call the code invalid. + */ +sealed class PairResult { + data class Success( + val server: ServerEntry, + /** Mesh info when the node advertises FIPS (fnpub present). */ + val fips: FipsPairInfo? = null, + ) : PairResult() + object UnsupportedVersion : PairResult() + object Invalid : PairResult() +} + +/** + * Parser for the companion pairing QR / OS deep link. Contract: + * docs/companion-pairing-qr.md (repo root). + * + * archipelago://pair?v=1&url=&name=…[&tok=…][&pw=…][&fnpub=…&fip=…&fhost=…&fudp=…&ftcp=…] + * + * - `url` is a full origin including scheme (http for LAN/mDNS nodes, https + * for the public demo); trailing slashes are normalized away. + * - `tok` is a device token minted by the node; it goes through the password + * field on purpose — the whole password auto-login path (WebSocket auth + + * WebView form injection) then works unchanged, and the backend accepts + * device tokens wherever it accepts the password. `pw` (demo only) wins if + * both are ever present. + * - `fnpub`/`fip`/`fhost`/`fudp`/`ftcp` describe the node's FIPS mesh; their + * absence just means LAN-only pairing (older node, or FIPS not provisioned). + * - Unknown extra query params are tolerated (forward compat under v=1). + */ +object ServerQrParser { + private const val SUPPORTED_MAJOR = 1 + + fun parse(raw: String): PairResult { + val uri = try { + Uri.parse(raw.trim()) + } catch (_: Exception) { + return PairResult.Invalid + } + if (!"archipelago".equals(uri.scheme, ignoreCase = true)) return PairResult.Invalid + if (uri.isOpaque || !"pair".equals(uri.host, ignoreCase = true)) return PairResult.Invalid + + val major = uri.getQueryParameter("v") + ?.trim() + ?.takeWhile { it.isDigit() } + ?.toIntOrNull() + ?: return PairResult.Invalid + if (major != SUPPORTED_MAJOR) return PairResult.UnsupportedVersion + + val serverUrl = uri.getQueryParameter("url")?.trim()?.trimEnd('/') + if (serverUrl.isNullOrBlank()) return PairResult.Invalid + val server = Uri.parse(serverUrl) + val scheme = server.scheme?.lowercase() + if (scheme != "http" && scheme != "https") return PairResult.Invalid + val host = server.host + if (host.isNullOrBlank()) return PairResult.Invalid + + val fips = parseFips(uri) + val credential = uri.getQueryParameter("pw")?.takeIf { it.isNotBlank() } + ?: uri.getQueryParameter("tok") + ?: "" + + return PairResult.Success( + server = ServerEntry( + address = host, + useHttps = scheme == "https", + port = if (server.port != -1) server.port.toString() else "", + password = credential, + name = uri.getQueryParameter("name") ?: "", + meshIp = fips?.ula ?: "", + // npub is the durable identity — saved-server upserts match on + // it, so re-scanning after a LAN renumber updates in place. + npub = fips?.npub ?: "", + ), + fips = fips, + ) + } + + private fun parseFips(uri: Uri): FipsPairInfo? { + val npub = uri.getQueryParameter("fnpub")?.trim() + var host = uri.getQueryParameter("fhost")?.trim() + if (npub.isNullOrBlank() || host.isNullOrBlank()) return null + // A .fips fhost is a dead dial hint: Android's system DNS can't + // resolve .fips, so every handshake send fails and first connect + // falls back to slow anchor discovery. If the QR's `url` host is a + // real address, dial that instead (QRs minted while the node UI was + // browsed over the mesh carry npub….fips here). + if (host.endsWith(".fips")) { + val urlHost = uri.getQueryParameter("url") + ?.let { runCatching { Uri.parse(it).host }.getOrNull() } + if (!urlHost.isNullOrBlank() && !urlHost.endsWith(".fips")) host = urlHost + } + return FipsPairInfo( + npub = npub, + ula = uri.getQueryParameter("fip")?.trim().orEmpty(), + host = host, + udpPort = uri.getQueryParameter("fudp")?.toIntOrNull() ?: 2121, + tcpPort = uri.getQueryParameter("ftcp")?.toIntOrNull() ?: 8443, + anchors = parseAnchors(uri.getQueryParameter("fanchors")), + ) + } + + /** `fanchors` = comma-joined `npub@host:port/transport`; bad items skipped. */ + private fun parseAnchors(raw: String?): List { + if (raw.isNullOrBlank()) return emptyList() + return raw.split(",").mapNotNull { item -> + val at = item.indexOf('@') + val slash = item.lastIndexOf('/') + if (at <= 0 || slash <= at) return@mapNotNull null + val npub = item.substring(0, at).trim() + val addr = item.substring(at + 1, slash).trim() + val transport = item.substring(slash + 1).trim().lowercase() + if (npub.isBlank() || !addr.contains(":")) return@mapNotNull null + if (transport != "udp" && transport != "tcp") return@mapNotNull null + AnchorPeer(npub = npub, addr = addr, transport = transport) + } + } +} diff --git a/Android/app/src/main/java/com/archipelago/app/fips/ArchyVpnService.kt b/Android/app/src/main/java/com/archipelago/app/fips/ArchyVpnService.kt new file mode 100644 index 00000000..3c10f07a --- /dev/null +++ b/Android/app/src/main/java/com/archipelago/app/fips/ArchyVpnService.kt @@ -0,0 +1,324 @@ +package com.archipelago.app.fips + +import android.app.Notification +import android.app.NotificationChannel +import android.app.NotificationManager +import android.app.PendingIntent +import android.content.Intent +import android.net.ConnectivityManager +import android.net.Network +import android.net.NetworkCapabilities +import android.net.NetworkRequest +import android.net.VpnService +import android.os.Build +import android.util.Log +import com.archipelago.app.MainActivity +import com.archipelago.app.R +import com.archipelago.app.data.ServerPreferences +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.Job +import kotlinx.coroutines.SupervisorJob +import kotlinx.coroutines.cancel +import kotlinx.coroutines.delay +import kotlinx.coroutines.flow.first +import kotlinx.coroutines.isActive +import kotlinx.coroutines.launch + +/** + * VpnService hosting the embedded FIPS mesh node. + * + * Split tunnel: only fd00::/8 (the FIPS ULA space) routes into the TUN, so + * normal phone traffic is untouched — this is mesh reachability, not a + * default-route VPN. The established fd is detached and handed to the Rust + * node (Node::start_with_tun_fd); the node owns it until stop. + */ +class ArchyVpnService : VpnService() { + + private val scope = CoroutineScope(SupervisorJob() + Dispatchers.IO) + private var warmerJob: Job? = null + + // Seamless transport handoff (Wi-Fi ⇄ 5G ⇄ future BLE). Without this the + // tunnel's underlying network stays pinned to the interface that was + // default when the VPN came up; when the phone leaves Wi-Fi for 5G the + // mesh sockets ride a dead network and sessions never recover until the + // app is restarted (user-reported 2026-07-27). The callback (a) re-pins + // the tunnel to the new default network via setUnderlyingNetworks and + // (b) forces an immediate mesh re-home so discovery + sessions rebuild + // on the new path within seconds instead of waiting out dead-link + // timeouts. + private var connectivityManager: ConnectivityManager? = null + private var networkCallback: ConnectivityManager.NetworkCallback? = null + @Volatile + private var currentUnderlying: Network? = null + + override fun onStartCommand(intent: Intent?, flags: Int, startId: Int): Int { + if (intent?.action == ACTION_STOP) { + shutdown() + return START_NOT_STICKY + } + startForeground(NOTIFICATION_ID, buildNotification()) + scope.launch { startMesh() } + return START_STICKY + } + + private suspend fun startMesh() { + if (!FipsNative.available) { + shutdown() + return + } + val prefs = FipsPreferences(this) + val identity = FipsManager.ensureIdentity(prefs) + val peersJson = prefs.combinedPeersJson() + if (identity == null || peersJson == "[]") { + Log.w(TAG, "mesh not configured — stopping") + shutdown() + return + } + // Party mode: fixed inbound UDP bind so a nearby phone can dial us + // directly over a shared LAN/hotspot (no internet required). + val listenPort = if (prefs.partyListen()) PartyQr.PARTY_UDP_PORT else 0 + + // A fresh app open re-triggers the service. Tearing a HEALTHY mesh + // down to rebuild it costs ~8s of anchor+session bring-up on every + // launch (observed live: stop 00:34:38 → session back 00:34:49) and + // is what made "freshly loading the app" slow. Keep a running node; + // restart only when it's dead or a pairing changed the peer set. + if (FipsNative.isRunning() && !FipsManager.peersDirty) { + Log.i(TAG, "mesh already running — keeping warm sessions") + startSessionWarmer() + return + } + FipsManager.peersDirty = false + // Re-establishing while running would strand the old fd; restart clean. + if (FipsNative.isRunning()) FipsNative.stop() + + val pfd = try { + Builder() + .setSession("Archipelago Mesh") + .setMtu(1280) + .addAddress(identity.address, 128) + .addRoute("fd00::", 8) + // The TUN is IPv6-only. Android blocks every address family + // the VPN has no address for — without this, bringing the + // mesh up cut ALL of the phone's IPv4 internet. + .allowFamily(android.system.OsConstants.AF_INET) + // And let apps that bind their own network skip the TUN + // entirely — this is mesh reachability, not a privacy VPN. + .allowBypass() + .apply { + // Android 10+ treats VPN networks as METERED by default, + // which flips the whole phone into data-saver behaviour + // (background sync off, "metered" warnings) while the + // mesh is up. It inherits the underlying network's real + // metered state instead. + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) setMetered(false) + } + .establish() + } catch (e: Exception) { + Log.e(TAG, "VPN establish failed", e) + null + } + if (pfd == null) { + shutdown() + return + } + + val fd = pfd.detachFd() + val result = FipsNative.start(identity.secret, peersJson, fd, listenPort) + Log.i(TAG, "mesh start: $result (listen=$listenPort)") + if (result.contains("\"error\"")) { + shutdown() + } else { + startSessionWarmer() + registerNetworkHandoff() + // Phone-to-phone chat/beam + the phone's own mesh-served page. + FlareServer.start(this, identity.address, identity.npub, prefs.partyName()) + // Mutual pairing: when a phone that scanned OUR QR announces + // itself, store it as a party peer and re-run the mesh config so + // this side gets the peer + chat entry without scanning back. + FlareServer.onHello = { peer -> + scope.launch { + prefs.upsertPartyPeer(peer) + FipsManager.requestMeshRestart(this@ArchyVpnService) + } + } + } + } + + /** + * Pre-warm + keep-warm mesh sessions to every known node ULA. + * + * Discovery + first session through the public tree can take 15s+ + * (from node diagnosis) — paying that cost here, the + * moment the tunnel is up, means the connect probe and WebView hit an + * established session instead of timing out on a cold one. The periodic + * touch afterwards keeps the session from idling out. Failed connects + * are expected and cheap; the attempt itself is what drives discovery. + */ + private fun startSessionWarmer() { + warmerJob?.cancel() + warmerJob = scope.launch { + val prefs = ServerPreferences(this@ArchyVpnService) + val fipsPrefs = FipsPreferences(this@ArchyVpnService) + var round = 0 + while (isActive && FipsNative.isRunning()) { + val targets = try { + prefs.savedServers.first() + .mapNotNull { it.meshIp.ifBlank { null } } + .map { it to 80 } + + // Party phones answer on the flare port, not :80. + fipsPrefs.partyPeers().map { it.ula to PartyQr.FLARE_PORT } + } catch (_: Exception) { + emptyList() + }.distinct() + if (round == 0) Log.i(TAG, "session warmer: ${targets.map { it.first }}") + // Probe all targets CONCURRENTLY with a short timeout — the + // old sequential 20s-per-target loop let one cold node starve + // every other target for the whole aggressive window. + targets.map { (ula, port) -> + launch { + try { + java.net.Socket().use { s -> + s.connect( + java.net.InetSocketAddress( + java.net.InetAddress.getByName(ula), + port, + ), + 5_000, + ) + } + } catch (_: Exception) { + // Cold path / node away — the attempt still drove + // session establishment; try again next round. + } + } + }.forEach { it.join() } + round++ + // Aggressive for the first ~minute (session bring-up), then a + // slow keep-warm tick that costs nearly nothing. + delay(if (round < 12) 5_000 else 60_000) + } + } + } + + /** + * Track the phone's default network and hand the mesh over to it as the + * phone roams (Wi-Fi ⇄ 5G, and later BLE). Two actions per change: + * 1. setUnderlyingNetworks(new) — the tunnel's packets follow the live + * network instead of dying on the one it launched with. + * 2. re-home the mesh — kick the session warmer so discovery + sessions + * rebuild on the new path immediately; the node's own fast-reconnect + * (1s) redials peers over the new route. + * onAvailable also fires for the FIRST network, which is how the initial + * underlying network gets set. + */ + private fun registerNetworkHandoff() { + if (networkCallback != null) return + val cm = getSystemService(ConnectivityManager::class.java) ?: return + connectivityManager = cm + val request = NetworkRequest.Builder() + .addCapability(NetworkCapabilities.NET_CAPABILITY_INTERNET) + .build() + val cb = object : ConnectivityManager.NetworkCallback() { + override fun onAvailable(network: Network) { + handoffTo(network) + } + + override fun onLost(network: Network) { + // The lost network was our underlying one — clear the pin so + // the system falls back to whatever default remains; the next + // onAvailable re-pins explicitly. + if (network == currentUnderlying) { + currentUnderlying = null + runCatching { setUnderlyingNetworks(null) } + } + } + } + networkCallback = cb + // requestNetwork tracks the BEST network of the request; when the + // phone moves Wi-Fi→5G the callback re-fires onAvailable with the new + // one. (registerDefaultNetworkCallback would also work; requestNetwork + // lets us extend to BLE-capable transports later.) + runCatching { cm.requestNetwork(request, cb) } + } + + private fun handoffTo(network: Network) { + val changed = network != currentUnderlying + currentUnderlying = network + // Always re-assert; cheap and covers capability changes on the same + // Network object. + runCatching { setUnderlyingNetworks(arrayOf(network)) } + if (changed && FipsNative.isRunning()) { + Log.i(TAG, "network handoff → re-homing mesh on new default network") + // Fresh warmer pass drives immediate rediscovery/session rebuild + // on the new path instead of waiting out dead-link timeouts. + startSessionWarmer() + } + } + + private fun unregisterNetworkHandoff() { + val cm = connectivityManager + val cb = networkCallback + if (cm != null && cb != null) { + runCatching { cm.unregisterNetworkCallback(cb) } + } + networkCallback = null + connectivityManager = null + currentUnderlying = null + } + + private fun shutdown() { + warmerJob?.cancel() + unregisterNetworkHandoff() + FlareServer.stop() + FipsNative.stop() + stopForeground(STOP_FOREGROUND_REMOVE) + stopSelf() + } + + override fun onDestroy() { + unregisterNetworkHandoff() + FipsNative.stop() + scope.cancel() + super.onDestroy() + } + + override fun onRevoke() { + // User pulled VPN permission from system settings. + shutdown() + } + + private fun buildNotification(): Notification { + val manager = getSystemService(NotificationManager::class.java) + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) { + manager.createNotificationChannel( + NotificationChannel( + CHANNEL_ID, + "Mesh connection", + NotificationManager.IMPORTANCE_MIN, + ).apply { description = "Keeps the node reachable from anywhere" } + ) + } + val tapIntent = PendingIntent.getActivity( + this, + 0, + Intent(this, MainActivity::class.java), + PendingIntent.FLAG_IMMUTABLE, + ) + return Notification.Builder(this, CHANNEL_ID) + .setContentTitle("Connected to your Archipelago") + .setContentText("Secure mesh link active") + .setSmallIcon(R.mipmap.ic_launcher) + .setContentIntent(tapIntent) + .setOngoing(true) + .build() + } + + companion object { + const val ACTION_STOP = "com.archipelago.app.fips.STOP" + private const val CHANNEL_ID = "archy_mesh" + private const val NOTIFICATION_ID = 4841 + private const val TAG = "ArchyVpnService" + } +} diff --git a/Android/app/src/main/java/com/archipelago/app/fips/FipsManager.kt b/Android/app/src/main/java/com/archipelago/app/fips/FipsManager.kt new file mode 100644 index 00000000..398356ec --- /dev/null +++ b/Android/app/src/main/java/com/archipelago/app/fips/FipsManager.kt @@ -0,0 +1,103 @@ +package com.archipelago.app.fips + +import android.content.Context +import android.content.Intent +import android.net.VpnService +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow + +/** + * Glue between pairing and the mesh: persists the node peer from a scanned + * QR and asks the UI to bring the tunnel up. There is deliberately no + * settings surface — scanning a node's QR is the entire configuration. + * + * The one unavoidable interaction is Android's VPN consent dialog + * (VpnService.prepare), which only an Activity can launch; [consentNeeded] + * signals AppNavHost to run it, once, on first pairing. + */ +object FipsManager { + + /** Set when a pairing registered mesh info and the VPN needs starting. */ + private val _consentNeeded = MutableStateFlow(false) + val consentNeeded: StateFlow = _consentNeeded + + /** True after a pairing changed the peer set while the node was running — + * tells the service a restart is genuinely needed (the ONLY case; a + * routine app open must keep the warm mesh, not rebuild it). */ + @Volatile + var peersDirty: Boolean = false + + fun consentHandled() { + _consentNeeded.value = false + } + + /** + * Persist mesh info from a pairing scan and request tunnel start. + * No-op on devices without the native lib (non-arm64). + */ + suspend fun registerNode(context: Context, info: FipsPairInfo?, alias: String) { + if (info == null || !FipsNative.available) return + val prefs = FipsPreferences(context) + ensureIdentity(prefs) + prefs.upsertNodePeer(info, alias) + peersDirty = true + // Restart the mesh with the new peer RIGHT NOW when consent already + // exists — relying on the consentNeeded collector left a running + // mesh on the OLD peer list whenever the collector wasn't active + // (fresh pairings looked dead until a full app restart). + if (VpnService.prepare(context) == null) { + startService(context) + } else { + _consentNeeded.value = true + } + } + + /** Generate-once mesh identity. Returns null only if the RNG/native fails. */ + suspend fun ensureIdentity(prefs: FipsPreferences): FipsNative.Identity? { + prefs.identity()?.let { return it } + val generated = FipsNative.parseIdentity(FipsNative.generateIdentity()) ?: return null + prefs.saveIdentity(generated) + return generated + } + + /** + * Start the mesh service if this device is paired and the user has already + * consented to the VPN (prepare() == null). Called on app start so the + * tunnel comes back without any interaction; first-time consent goes + * through AppNavHost instead. + */ + suspend fun autoStartIfReady(context: Context) { + if (!FipsNative.available) return + val prefs = FipsPreferences(context) + if (prefs.identity() == null || !prefs.hasPeers()) return + if (VpnService.prepare(context) != null) return // consent missing — don't prompt here + startService(context) + } + + fun startService(context: Context) { + val intent = Intent(context, ArchyVpnService::class.java) + context.startForegroundService(intent) + } + + /** + * Re-run the mesh with current prefs (party listen toggled, peer added). + * Marks the peer set dirty so startMesh genuinely restarts the node — + * otherwise the keep-warm fast path would skip the new config. + * First-timers go through the consent flow. + */ + fun requestMeshRestart(context: Context) { + if (!FipsNative.available) return + peersDirty = true + if (VpnService.prepare(context) == null) { + startService(context) + } else { + _consentNeeded.value = true + } + } + + fun stopService(context: Context) { + val intent = Intent(context, ArchyVpnService::class.java) + .setAction(ArchyVpnService.ACTION_STOP) + context.startService(intent) + } +} diff --git a/Android/app/src/main/java/com/archipelago/app/fips/FipsNative.kt b/Android/app/src/main/java/com/archipelago/app/fips/FipsNative.kt new file mode 100644 index 00000000..cd52a13a --- /dev/null +++ b/Android/app/src/main/java/com/archipelago/app/fips/FipsNative.kt @@ -0,0 +1,49 @@ +package com.archipelago.app.fips + +import org.json.JSONObject + +/** + * JNI binding to the embedded FIPS mesh node (Android/rust/archy-fips-core, + * built into libarchy_fips_core.so by the buildRustArm64 gradle task). + * + * All calls return JSON strings; failures come back as {"error": "…"} rather + * than exceptions. [available] is false on ABIs the .so isn't built for + * (anything but arm64) — every caller must gate on it so the app still runs + * as a plain companion there. + */ +object FipsNative { + val available: Boolean = try { + System.loadLibrary("archy_fips_core") + true + } catch (_: Throwable) { + false + } + + external fun generateIdentity(): String + external fun deriveIdentity(secret: String): String + + /** + * [listenPort] 0 = outbound-only (default posture). Non-zero binds UDP on + * that port so a nearby phone can dial us directly (party mode); the node + * stays leaf-only either way. + */ + external fun start(secret: String, peersJson: String, tunFd: Int, listenPort: Int): String + external fun stop() + external fun isRunning(): Boolean + external fun statusJson(): String + + data class Identity(val secret: String, val npub: String, val address: String) + + /** Parse an identity JSON reply; null on {"error": …} or malformed. */ + fun parseIdentity(json: String): Identity? = try { + val obj = JSONObject(json) + if (obj.has("error")) null + else Identity( + secret = obj.getString("secret"), + npub = obj.getString("npub"), + address = obj.getString("address"), + ) + } catch (_: Exception) { + null + } +} diff --git a/Android/app/src/main/java/com/archipelago/app/fips/FipsPairInfo.kt b/Android/app/src/main/java/com/archipelago/app/fips/FipsPairInfo.kt new file mode 100644 index 00000000..34d403cc --- /dev/null +++ b/Android/app/src/main/java/com/archipelago/app/fips/FipsPairInfo.kt @@ -0,0 +1,29 @@ +package com.archipelago.app.fips + +/** + * Node mesh parameters carried by the pairing QR (fnpub/fip/fhost/fudp/ftcp — + * docs/companion-pairing-qr.md). The phone's embedded FIPS node dials + * host:udpPort / host:tcpPort claiming nothing; the mesh accepts inbound peers + * without registration, so possession of these params is all pairing takes. + */ +data class FipsPairInfo( + val npub: String, + /** Node's fips0 ULA — where its UI stays reachable once meshed. May be empty. */ + val ula: String, + val host: String, + val udpPort: Int, + val tcpPort: Int, + /** + * Public rendezvous anchors (the node's seed-anchor list). The phone + * peers with these too, so it can route to the node via the mesh when + * the node's LAN endpoint isn't directly dialable. + */ + val anchors: List = emptyList(), +) + +/** One rendezvous anchor from the QR's `fanchors` param (npub@addr/transport). */ +data class AnchorPeer( + val npub: String, + val addr: String, + val transport: String, +) diff --git a/Android/app/src/main/java/com/archipelago/app/fips/FipsPreferences.kt b/Android/app/src/main/java/com/archipelago/app/fips/FipsPreferences.kt new file mode 100644 index 00000000..42be68c8 --- /dev/null +++ b/Android/app/src/main/java/com/archipelago/app/fips/FipsPreferences.kt @@ -0,0 +1,341 @@ +package com.archipelago.app.fips + +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 kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.first +import kotlinx.coroutines.flow.map +import org.json.JSONArray +import org.json.JSONObject +import androidx.datastore.preferences.preferencesDataStore + +private val Context.fipsDataStore: DataStore by preferencesDataStore(name = "fips_prefs") + +// Archipelago-operated public anchor (vps2). Baked in so EVERY pairing yields +// both paths — direct LAN p2p to the node AND a public rendezvous for +// away-from-home — even when the scanned node is old enough that its QR +// carries no fanchors. Keep in lockstep with +// core/archipelago/src/fips/anchors.rs (ARCHY_ANCHOR_*). +internal const val ARCHY_ANCHOR_NPUB = + "npub1dptaktwxv0mm245g2lqjykwm5ll0jpc6m3r4242ydfa9z7qe6urs3jvrak" +internal const val ARCHY_ANCHOR_ADDR = "146.59.87.168:8444" +internal const val ARCHY_ANCHOR_TRANSPORT = "tcp" + +/** + * Public FIPS network anchors (join.fips.network — the dual-transport TCP + * pair; keep in lockstep with core/archipelago/src/fips/anchors.rs + * fips_network_anchors()). Baked into every pairing at trailing priority so + * a degraded/unreachable vps2 anchor can never strand the phone: the mesh + * still joins the public tree and routes to the node through it. + */ +internal val PUBLIC_FIPS_ANCHORS = listOf( + Triple( + "npub10yffd020a4ag8zcy75f9pruq3rnghvvhd5hphl9s62zgp35s560qrksp9u", + "23.182.128.74:443", + "tcp", + ), + Triple( + "npub1qmc3cvfz0yu2hx96nq3gp55zdan2qclealn7xshgr448d3nh6lks7zel98", + "217.77.8.91:443", + "tcp", + ), +) + +/** + * Mesh identity + known node peers. Follows the same plaintext-DataStore + * storage model as ServerPreferences (the server password lives there the + * same way); the mesh secret only grants mesh membership, not node login. + */ +class FipsPreferences(private val context: Context) { + + private val secretKey = stringPreferencesKey("fips_secret") + private val npubKey = stringPreferencesKey("fips_npub") + private val addressKey = stringPreferencesKey("fips_address") + /** JSON array of node peers in fips PeerConfig shape (see NodePeer). */ + private val peersKey = stringPreferencesKey("fips_node_peers") + /** JSON array of phone party peers (PartyPeer shape, NOT PeerConfig). */ + private val partyPeersKey = stringPreferencesKey("fips_party_peers") + /** Party mode: accept a direct inbound mesh link (UDP 2121). */ + private val partyListenKey = booleanPreferencesKey("fips_party_listen") + /** Name shown in this phone's party QR and outgoing flares. */ + private val partyNameKey = stringPreferencesKey("fips_party_name") + + suspend fun identity(): FipsNative.Identity? { + val prefs = context.fipsDataStore.data.first() + val secret = prefs[secretKey] ?: return null + return FipsNative.Identity( + secret = secret, + npub = prefs[npubKey] ?: "", + address = prefs[addressKey] ?: "", + ) + } + + suspend fun saveIdentity(identity: FipsNative.Identity) { + context.fipsDataStore.edit { prefs -> + prefs[secretKey] = identity.secret + prefs[npubKey] = identity.npub + prefs[addressKey] = identity.address + } + } + + suspend fun peersJson(): String { + val prefs = context.fipsDataStore.data.first() + return prefs[peersKey] ?: "[]" + } + + suspend fun hasPeers(): Boolean = JSONArray(peersJson()).length() > 0 + + // ── Mesh Party (phone↔phone) ──────────────────────────────────────────── + + suspend fun partyListen(): Boolean = + context.fipsDataStore.data.first()[partyListenKey] ?: false + + val partyListenFlow: Flow + get() = context.fipsDataStore.data.map { it[partyListenKey] ?: false } + + suspend fun setPartyListen(enabled: Boolean) { + context.fipsDataStore.edit { it[partyListenKey] = enabled } + } + + suspend fun partyName(): String = + context.fipsDataStore.data.first()[partyNameKey] + ?: android.os.Build.MODEL.orEmpty().ifBlank { "Phone" } + + suspend fun setPartyName(name: String) { + context.fipsDataStore.edit { it[partyNameKey] = name.trim() } + } + + val partyPeersFlow: Flow> + get() = context.fipsDataStore.data.map { parsePartyPeers(it[partyPeersKey] ?: "[]") } + + suspend fun partyPeers(): List = + parsePartyPeers(context.fipsDataStore.data.first()[partyPeersKey] ?: "[]") + + /** Matched by npub, so re-scanning updates the direct-dial address in place. */ + suspend fun upsertPartyPeer(peer: PartyPeer) { + context.fipsDataStore.edit { prefs -> + val kept = parsePartyPeers(prefs[partyPeersKey] ?: "[]").filter { it.npub != peer.npub } + prefs[partyPeersKey] = toJson(kept + peer) + } + } + + suspend fun removePartyPeer(npub: String) { + context.fipsDataStore.edit { prefs -> + val kept = parsePartyPeers(prefs[partyPeersKey] ?: "[]").filter { it.npub != npub } + prefs[partyPeersKey] = toJson(kept) + } + } + + /** + * Node peers + direct-dial party peers, in the fips PeerConfig JSON the + * Rust node deserializes. Party peers get the best priority: on a shared + * LAN/hotspot the direct link beats every anchor path, and while off-LAN + * the failed dial is cheap (auto-reconnect keeps retrying, which is + * exactly what makes the link snap up the moment both phones share WiFi). + * Party peers without an underlay address are mesh-routed and need no + * entry here at all. + */ + suspend fun combinedPeersJson(): String { + val merged = JSONArray(peersJson()) + val party = partyPeers() + for (peer in party) { + if (peer.ip.isBlank() || peer.port <= 0) continue + merged.put(JSONObject().apply { + put("npub", peer.npub) + put("alias", hostSafeAlias(peer.name.ifBlank { "party-phone" })) + put("addresses", JSONArray().put(JSONObject().apply { + put("transport", "udp") + put("addr", "${peer.ip}:${peer.port}") + put("priority", 5) + })) + }) + } + // A party-only phone (never paired with a node) still needs a public + // rendezvous to reach its peers ACROSS the internet — without it, two + // bare phones would be hotspot/LAN-only. Node pairing normally bakes + // this anchor in; do the same when there are party peers. + if (party.isNotEmpty() && + (0 until merged.length()).none { + merged.optJSONObject(it)?.optString("npub") == ARCHY_ANCHOR_NPUB + } + ) { + merged.put(JSONObject().apply { + put("npub", ARCHY_ANCHOR_NPUB) + put("alias", "archipelago-anchor") + put("addresses", JSONArray().put(JSONObject().apply { + put("transport", ARCHY_ANCHOR_TRANSPORT) + put("addr", ARCHY_ANCHOR_ADDR) + put("priority", 40) + })) + }) + } + // Backfill anchor peers for entries paired before newer QR/app + // releases added them. `upsertNodePeer` persists these on re-scan, but + // startup must also self-heal old DataStore state so updating the APK is + // enough to get off-LAN redundancy. + if (merged.length() > 0) { + addAnchorIfMissing(merged, ARCHY_ANCHOR_NPUB, "archipelago-anchor", ARCHY_ANCHOR_ADDR, ARCHY_ANCHOR_TRANSPORT, 40) + for ((i, anchor) in PUBLIC_FIPS_ANCHORS.withIndex()) { + val (npub, addr, transport) = anchor + addAnchorIfMissing(merged, npub, "fips-network-anchor-${i + 1}", addr, transport, 50 + i * 10) + } + } + return merged.toString() + } + + private fun addAnchorIfMissing( + peers: JSONArray, + npub: String, + alias: String, + addr: String, + transport: String, + priority: Int, + ) { + if ((0 until peers.length()).any { peers.optJSONObject(it)?.optString("npub") == npub }) return + peers.put(JSONObject().apply { + put("npub", npub) + put("alias", alias) + put("addresses", JSONArray().put(JSONObject().apply { + put("transport", transport) + put("addr", addr) + put("priority", priority) + })) + }) + } + + private fun parsePartyPeers(json: String): List = try { + val arr = JSONArray(json) + (0 until arr.length()).mapNotNull { i -> + val o = arr.optJSONObject(i) ?: return@mapNotNull null + val npub = o.optString("npub") + val ula = o.optString("ula") + if (npub.isBlank() || ula.isBlank()) return@mapNotNull null + PartyPeer( + npub = npub, + ula = ula, + name = o.optString("name").ifBlank { "Phone" }, + ip = o.optString("ip"), + port = o.optInt("port"), + ) + } + } catch (_: Exception) { + emptyList() + } + + private fun toJson(peers: List): String { + val arr = JSONArray() + for (p in peers) { + arr.put(JSONObject().apply { + put("npub", p.npub) + put("ula", p.ula) + put("name", p.name) + put("ip", p.ip) + put("port", p.port) + }) + } + return arr.toString() + } + + /** + * Add or update the node peer plus its rendezvous anchors (each matched + * by npub, so re-pairing updates addresses instead of duplicating). + * Stored directly in the fips PeerConfig JSON shape the Rust side + * deserializes. The node's direct addresses get the best priorities; + * anchors trail so the mesh prefers the direct path when it works. + */ + suspend fun upsertNodePeer(info: FipsPairInfo, alias: String) { + val incoming = mutableListOf() + incoming += JSONObject().apply { + put("npub", info.npub) + put("alias", hostSafeAlias(alias.ifBlank { "Archipelago" })) + val addresses = JSONArray() + // .fips hosts are unresolvable on Android (no system .fips DNS): + // storing one gives the mesh a dial target that fails every + // handshake and stalls first connect on anchor discovery. + if (info.udpPort > 0 && !info.host.endsWith(".fips")) { + addresses.put(JSONObject().apply { + put("transport", "udp") + put("addr", "${info.host}:${info.udpPort}") + put("priority", 10) + }) + } + if (info.tcpPort > 0 && !info.host.endsWith(".fips")) { + addresses.put(JSONObject().apply { + put("transport", "tcp") + put("addr", "${info.host}:${info.tcpPort}") + put("priority", 20) + }) + } + put("addresses", addresses) + } + for (anchor in info.anchors) { + if (anchor.npub == info.npub) continue + if (anchor.addr.substringBeforeLast(":").endsWith(".fips")) continue + incoming += JSONObject().apply { + put("npub", anchor.npub) + put("alias", "mesh-anchor") + put("addresses", JSONArray().put(JSONObject().apply { + put("transport", anchor.transport) + put("addr", anchor.addr) + put("priority", 30) + })) + } + } + // Guarantee the public anchor: without it, a QR from an older node + // leaves the phone LAN-only and pairing/connecting dies off-LAN. + if (info.npub != ARCHY_ANCHOR_NPUB && + incoming.none { it.optString("npub") == ARCHY_ANCHOR_NPUB } + ) { + incoming += JSONObject().apply { + put("npub", ARCHY_ANCHOR_NPUB) + put("alias", "archipelago-anchor") + put("addresses", JSONArray().put(JSONObject().apply { + put("transport", ARCHY_ANCHOR_TRANSPORT) + put("addr", ARCHY_ANCHOR_ADDR) + put("priority", 40) + })) + } + } + // And the public FIPS network anchors at trailing priority, so one + // degraded rendezvous (vps2, 2026-07-24) can never strand the phone. + for ((i, anchor) in PUBLIC_FIPS_ANCHORS.withIndex()) { + val (npub, addr, transport) = anchor + if (info.npub == npub || incoming.any { it.optString("npub") == npub }) continue + incoming += JSONObject().apply { + put("npub", npub) + put("alias", "fips-network-anchor-${i + 1}") + put("addresses", JSONArray().put(JSONObject().apply { + put("transport", transport) + put("addr", addr) + put("priority", 50 + i * 10) + })) + } + } + val incomingNpubs = incoming.map { it.optString("npub") }.toSet() + context.fipsDataStore.edit { prefs -> + val current = JSONArray(prefs[peersKey] ?: "[]") + val merged = JSONArray() + for (i in 0 until current.length()) { + val existing = current.optJSONObject(i) ?: continue + if (existing.optString("npub") !in incomingNpubs) merged.put(existing) + } + incoming.forEach { merged.put(it) } + prefs[peersKey] = merged.toString() + } + } +} + +/** + * Peer aliases feed the fips host map as `.fips` hostnames; anything + * that isn't a valid DNS label ("Test Node" — the space) gets rejected and + * silently drops the peer from name resolution. Slug it instead of losing it. + */ +internal fun hostSafeAlias(alias: String): String = + alias.lowercase() + .replace(Regex("[^a-z0-9.-]+"), "-") + .trim('-', '.') + .ifBlank { "archipelago" } diff --git a/Android/app/src/main/java/com/archipelago/app/fips/FlareServer.kt b/Android/app/src/main/java/com/archipelago/app/fips/FlareServer.kt new file mode 100644 index 00000000..44f1fe5b --- /dev/null +++ b/Android/app/src/main/java/com/archipelago/app/fips/FlareServer.kt @@ -0,0 +1,398 @@ +package com.archipelago.app.fips + +import android.content.Context +import android.util.Log +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import okhttp3.MediaType.Companion.toMediaType +import okhttp3.OkHttpClient +import okhttp3.Request +import okhttp3.RequestBody.Companion.toRequestBody +import org.json.JSONObject +import java.io.BufferedInputStream +import java.io.File +import java.io.InputStream +import java.net.InetAddress +import java.net.InetSocketAddress +import java.net.ServerSocket +import java.net.Socket +import java.util.UUID +import java.util.concurrent.ExecutorService +import java.util.concurrent.Executors +import java.util.concurrent.TimeUnit + +/** One chat/photo message in a party conversation, keyed by the peer's npub. */ +data class FlareMessage( + val id: String, + val peerNpub: String, + val fromMe: Boolean, + val name: String, + val text: String = "", + val photoPath: String = "", + val ts: Long, + val status: Status = Status.RECEIVED, +) { + enum class Status { SENDING, SENT, FAILED, RECEIVED } +} + +/** In-memory conversation store (demo scope — nothing persists across restarts). */ +object FlareStore { + private val _messages = MutableStateFlow>(emptyList()) + val messages: StateFlow> = _messages + + fun add(message: FlareMessage) { + _messages.value = _messages.value + message + } + + fun setStatus(id: String, status: FlareMessage.Status) { + _messages.value = _messages.value.map { if (it.id == id) it.copy(status = status) else it } + } +} + +/** + * Minimal HTTP listener bound ONLY on this phone's mesh ULA — plain HTTP is + * fine there because FIPS is the encryption + peer-identity layer (same + * stance as the node's ULA-only peer listener). This is what makes the phone + * a *server* on the mesh: another phone (or `curl -6` from any mesh node) + * reaches it by npub-derived address with no port forwarding, DNS, or CA. + * + * FIPS authenticates the node, not the request (project doctrine), so inputs + * are still validated at this boundary: size caps, JSON shape, no + * client-controlled paths. + */ +object FlareServer { + private const val TAG = "FlareServer" + private const val MAX_PHOTO_BYTES = 8 * 1024 * 1024 + private const val MAX_TEXT_CHARS = 4_000 + private const val MAX_HEADER_BYTES = 16 * 1024 + + private var socket: ServerSocket? = null + private var pool: ExecutorService? = null + @Volatile private var identityName = "Phone" + @Volatile private var identityNpub = "" + @Volatile private var photoDir: File? = null + + /** Invoked when a peer announces itself (POST /hello) — pairing used to + * be one-way: only the SCANNING phone learned the other side, so the + * scanned phone had no peer, no chat entry, no way in. The VPN service + * wires this to upsert the peer + restart the mesh config. */ + @Volatile var onHello: ((PartyPeer) -> Unit)? = null + + @Synchronized + fun start(context: Context, ula: String, myNpub: String, myName: String) { + stop() + identityNpub = myNpub + identityName = myName + photoDir = File(context.cacheDir, "flare").apply { mkdirs() } + val pool = Executors.newCachedThreadPool().also { this.pool = it } + pool.execute { + try { + val server = ServerSocket().apply { + reuseAddress = true + bind(InetSocketAddress(InetAddress.getByName(ula), PartyQr.FLARE_PORT)) + } + socket = server + Log.i(TAG, "flare listening on [$ula]:${PartyQr.FLARE_PORT}") + while (!server.isClosed) { + val client = try { + server.accept() + } catch (_: Exception) { + break + } + pool.execute { handle(client) } + } + } catch (e: Exception) { + Log.e(TAG, "flare server died: $e") + } + } + } + + @Synchronized + fun stop() { + try { + socket?.close() + } catch (_: Exception) { + } + socket = null + pool?.shutdownNow() + pool = null + } + + private fun handle(client: Socket) { + client.use { sock -> + sock.soTimeout = 30_000 + try { + val input = BufferedInputStream(sock.getInputStream()) + val requestLine = readLine(input) ?: return + val parts = requestLine.trim().split(" ") + if (parts.size < 2) return respond(sock, 400, json("bad_request")) + val (method, path) = parts[0] to parts[1] + + var contentLength = 0 + var from = "" + var fromName = "" + var headerBytes = requestLine.length + while (true) { + val line = readLine(input) ?: return + if (line.isEmpty()) break + headerBytes += line.length + if (headerBytes > MAX_HEADER_BYTES) return respond(sock, 431, json("headers_too_large")) + val idx = line.indexOf(':') + if (idx <= 0) continue + val key = line.substring(0, idx).trim().lowercase() + val value = line.substring(idx + 1).trim() + when (key) { + "content-length" -> contentLength = value.toIntOrNull() ?: 0 + "x-from" -> from = value.take(80) + "x-name" -> fromName = value.take(80) + } + } + + when { + method == "GET" && (path == "/" || path.startsWith("/?")) -> + respondHtml(sock, profilePage()) + method == "POST" && path == "/hello" -> { + if (contentLength !in 1..MAX_HEADER_BYTES) return respond(sock, 413, json("too_large")) + val body = readExactly(input, contentLength) ?: return + receiveHello(String(body, Charsets.UTF_8)) + respond(sock, 200, """{"ok":true}""") + } + method == "POST" && path == "/flare" -> { + if (contentLength !in 1..MAX_HEADER_BYTES) return respond(sock, 413, json("too_large")) + val body = readExactly(input, contentLength) ?: return + receiveFlare(String(body, Charsets.UTF_8)) + respond(sock, 200, """{"ok":true}""") + } + method == "POST" && path == "/photo" -> { + if (contentLength !in 1..MAX_PHOTO_BYTES) return respond(sock, 413, json("too_large")) + if (!from.startsWith("npub1")) return respond(sock, 400, json("bad_request")) + val body = readExactly(input, contentLength) ?: return + receivePhoto(from, fromName, body) + respond(sock, 200, """{"ok":true}""") + } + else -> respond(sock, 404, json("not_found")) + } + } catch (e: Exception) { + Log.w(TAG, "request failed: $e") + } + } + } + + /** A peer that scanned OUR QR announces itself — mutual pairing. */ + private fun receiveHello(body: String) { + val o = try { + JSONObject(body) + } catch (_: Exception) { + return + } + val from = o.optString("from") + val ula = o.optString("ula") + if (!from.startsWith("npub1") || !ula.startsWith("fd")) return + if (from == identityNpub) return + val peer = PartyPeer( + npub = from.take(80), + ula = ula.take(64), + name = o.optString("name").take(24).ifBlank { "Phone" }, + ip = o.optString("ip").take(40), + port = o.optInt("port", 0), + ) + onHello?.invoke(peer) + // Seed the conversation so the chat has a visible entry on this side. + FlareStore.add( + FlareMessage( + id = UUID.randomUUID().toString(), + peerNpub = peer.npub, + fromMe = false, + name = peer.name, + text = "👋 ${peer.name} joined the party", + ts = System.currentTimeMillis(), + ) + ) + } + + private fun receiveFlare(body: String) { + val o = try { + JSONObject(body) + } catch (_: Exception) { + return + } + val from = o.optString("from") + if (!from.startsWith("npub1")) return + val text = o.optString("text").take(MAX_TEXT_CHARS) + if (text.isBlank()) return + FlareStore.add( + FlareMessage( + id = UUID.randomUUID().toString(), + peerNpub = from, + fromMe = false, + name = o.optString("name").take(80).ifBlank { "Phone" }, + text = text, + ts = System.currentTimeMillis(), + ) + ) + } + + private fun receivePhoto(from: String, fromName: String, bytes: ByteArray) { + // Server-generated filename — the sender never controls the path. + val dir = photoDir ?: return + val file = File(dir, "${UUID.randomUUID()}.jpg") + file.writeBytes(bytes) + FlareStore.add( + FlareMessage( + id = UUID.randomUUID().toString(), + peerNpub = from, + fromMe = false, + name = fromName.ifBlank { "Phone" }, + photoPath = file.absolutePath, + ts = System.currentTimeMillis(), + ) + ) + } + + private fun profilePage(): String { + val npub = identityNpub + val name = identityName + return """ + + + $name — on the mesh +
+

⚡ $name

+
$npub
+

This page is being served by a phone, addressed by its + cryptographic identity over the FIPS mesh.

+

No port forwarding. No DNS. No certificate authority. No cloud. + The key is the address — and the transport underneath can be + 5G, WiFi, or a hotspot with no internet at all.

+
+ """.trimIndent() + } + + // ── tiny HTTP plumbing ────────────────────────────────────────────────── + + /** Read one CRLF-terminated header line as ISO-8859-1; null on EOF. */ + private fun readLine(input: InputStream): String? { + val sb = StringBuilder() + while (true) { + val b = input.read() + if (b == -1) return if (sb.isEmpty()) null else sb.toString() + if (b == '\n'.code) return sb.toString().trimEnd('\r') + sb.append(b.toChar()) + if (sb.length > MAX_HEADER_BYTES) return null + } + } + + private fun readExactly(input: InputStream, length: Int): ByteArray? { + val buf = ByteArray(length) + var off = 0 + while (off < length) { + val n = input.read(buf, off, length - off) + if (n == -1) return null + off += n + } + return buf + } + + private fun json(code: String) = """{"error":{"code":"$code","message":"request rejected"}}""" + + private fun respond(sock: Socket, status: Int, body: String) = + writeResponse(sock, status, "application/json", body.toByteArray(Charsets.UTF_8)) + + private fun respondHtml(sock: Socket, body: String) = + writeResponse(sock, 200, "text/html; charset=utf-8", body.toByteArray(Charsets.UTF_8)) + + private fun writeResponse(sock: Socket, status: Int, contentType: String, body: ByteArray) { + val reason = when (status) { + 200 -> "OK"; 400 -> "Bad Request"; 404 -> "Not Found" + 413 -> "Payload Too Large"; 431 -> "Headers Too Large" + else -> "Error" + } + val head = "HTTP/1.1 $status $reason\r\n" + + "Content-Type: $contentType\r\n" + + "Content-Length: ${body.size}\r\n" + + "Connection: close\r\n\r\n" + sock.getOutputStream().apply { + write(head.toByteArray(Charsets.ISO_8859_1)) + write(body) + flush() + } + } +} + +/** Outbound flares: plain HTTP to the peer's ULA — FIPS encrypts underneath. */ +object FlareClient { + // Connect timeout must outlive cold mesh-session establishment (~15s via + // the public tree); the attempt itself drives + // session setup, same trick as the VPN service's session warmer. + private val http = OkHttpClient.Builder() + .connectTimeout(25, TimeUnit.SECONDS) + .readTimeout(15, TimeUnit.SECONDS) + .writeTimeout(60, TimeUnit.SECONDS) + .build() + + private fun base(peer: PartyPeer) = "http://[${peer.ula}]:${PartyQr.FLARE_PORT}" + + /** Announce myself to a freshly scanned peer so pairing becomes MUTUAL — + * their phone gets me as a peer + a chat entry without scanning back. + * Blocking — call from Dispatchers.IO. */ + fun sendHello( + peer: PartyPeer, + myNpub: String, + myName: String, + myUla: String, + myIp: String?, + myPort: Int, + ): Boolean = try { + val body = JSONObject() + .put("from", myNpub) + .put("name", myName) + .put("ula", myUla) + .put("ip", myIp ?: "") + .put("port", if (myIp != null) myPort else 0) + .toString() + .toRequestBody("application/json".toMediaType()) + http.newCall( + Request.Builder().url("${base(peer)}/hello").post(body).build() + ).execute().use { it.isSuccessful } + } catch (_: Exception) { + false + } + + /** Blocking — call from Dispatchers.IO. */ + fun sendText(peer: PartyPeer, myNpub: String, myName: String, text: String): Boolean = try { + val body = JSONObject() + .put("from", myNpub) + .put("name", myName) + .put("text", text) + .put("ts", System.currentTimeMillis()) + .toString() + .toRequestBody("application/json".toMediaType()) + http.newCall( + Request.Builder().url("${base(peer)}/flare").post(body).build() + ).execute().use { it.isSuccessful } + } catch (_: Exception) { + false + } + + /** Blocking — call from Dispatchers.IO. */ + fun sendPhoto(peer: PartyPeer, myNpub: String, myName: String, jpeg: ByteArray): Boolean = try { + http.newCall( + Request.Builder() + .url("${base(peer)}/photo") + .header("X-From", myNpub) + .header("X-Name", myName) + .post(jpeg.toRequestBody("image/jpeg".toMediaType())) + .build() + ).execute().use { it.isSuccessful } + } catch (_: Exception) { + false + } +} diff --git a/Android/app/src/main/java/com/archipelago/app/fips/PartyQr.kt b/Android/app/src/main/java/com/archipelago/app/fips/PartyQr.kt new file mode 100644 index 00000000..b79e148e --- /dev/null +++ b/Android/app/src/main/java/com/archipelago/app/fips/PartyQr.kt @@ -0,0 +1,102 @@ +package com.archipelago.app.fips + +import android.net.Uri +import java.net.Inet4Address +import java.net.NetworkInterface + +/** + * Phone↔phone mesh pairing ("Mesh Party") QR contract: + * + * archipelago://party?v=1&npub=&ula=&name=[&ip=&port=] + * + * npub + ula alone are enough to chat *through* the mesh (anchors route by + * node address, no underlay info needed). ip/port are present only while the + * showing phone has its inbound UDP listener up (party mode) — the scanner + * then also gets a direct-dial link that works on a shared LAN/hotspot with + * no internet at all. Same versioning stance as the node pairing QR + * (docs/companion-pairing-qr.md): unknown params tolerated under v=1. + */ +data class PartyPeer( + val npub: String, + val ula: String, + val name: String, + /** Direct-dial underlay endpoint; empty when the peer wasn't listening. */ + val ip: String = "", + val port: Int = 0, +) + +object PartyQr { + const val SCHEME_HOST = "party" + private const val SUPPORTED_MAJOR = 1 + + /** UDP port a party-mode phone listens on (matches the node's mesh port). */ + const val PARTY_UDP_PORT = 2121 + + /** Application-layer chat/beam port, bound only on the mesh ULA. */ + const val FLARE_PORT = 5680 + + fun build(npub: String, ula: String, name: String, ip: String?, port: Int): String { + val b = Uri.Builder() + .scheme("archipelago") + .authority(SCHEME_HOST) + .appendQueryParameter("v", "1") + .appendQueryParameter("npub", npub) + .appendQueryParameter("ula", ula) + .appendQueryParameter("name", name) + if (!ip.isNullOrBlank() && port > 0) { + b.appendQueryParameter("ip", ip) + b.appendQueryParameter("port", port.toString()) + } + return b.build().toString() + } + + /** Null when [raw] is not a valid party QR (foreign codes just keep scanning). */ + fun parse(raw: String): PartyPeer? { + val uri = try { + Uri.parse(raw.trim()) + } catch (_: Exception) { + return null + } + if (!"archipelago".equals(uri.scheme, ignoreCase = true)) return null + if (uri.isOpaque || !SCHEME_HOST.equals(uri.host, ignoreCase = true)) return null + val major = uri.getQueryParameter("v")?.takeWhile { it.isDigit() }?.toIntOrNull() ?: return null + if (major != SUPPORTED_MAJOR) return null + + val npub = uri.getQueryParameter("npub")?.trim().orEmpty() + val ula = uri.getQueryParameter("ula")?.trim().orEmpty() + if (!npub.startsWith("npub1") || !ula.startsWith("fd")) return null + return PartyPeer( + npub = npub, + ula = ula, + name = uri.getQueryParameter("name")?.trim().orEmpty().ifBlank { "Phone" }, + ip = uri.getQueryParameter("ip")?.trim().orEmpty(), + port = uri.getQueryParameter("port")?.toIntOrNull() ?: 0, + ) + } + + /** + * This phone's private IPv4 on WiFi or its own hotspot, for the QR's + * direct-dial hint. Hotspot interfaces (ap/swlan/softap) win over wlan so + * the hotspot-host phone advertises the address its guests can reach. + */ + fun localWifiIpv4(): String? { + val candidates = mutableListOf>() // ifname → addr + try { + for (nif in NetworkInterface.getNetworkInterfaces()) { + if (!nif.isUp || nif.isLoopback) continue + for (addr in nif.inetAddresses) { + if (addr is Inet4Address && addr.isSiteLocalAddress) { + candidates += nif.name to addr.hostAddress.orEmpty() + } + } + } + } catch (_: Exception) { + return null + } + val hotspot = candidates.firstOrNull { + it.first.startsWith("ap") || it.first.startsWith("swlan") || it.first.startsWith("softap") + } + return (hotspot ?: candidates.firstOrNull { it.first.startsWith("wlan") } ?: candidates.firstOrNull()) + ?.second + } +} 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..c93991d6 --- /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, + onThreeFingerHold: () -> 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 >= 3 && t == 0L) t = System.currentTimeMillis() + if (a.size >= 3 && !fired && t > 0 && System.currentTimeMillis() - t > 500) { fired = true; onThreeFingerHold() } + if (a.size < 3) 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/GestureHintOverlay.kt b/Android/app/src/main/java/com/archipelago/app/ui/components/GestureHintOverlay.kt new file mode 100644 index 00000000..62390b1e --- /dev/null +++ b/Android/app/src/main/java/com/archipelago/app/ui/components/GestureHintOverlay.kt @@ -0,0 +1,147 @@ +package com.archipelago.app.ui.components + +import androidx.compose.animation.core.LinearEasing +import androidx.compose.animation.core.RepeatMode +import androidx.compose.animation.core.animateFloat +import androidx.compose.animation.core.infiniteRepeatable +import androidx.compose.animation.core.rememberInfiniteTransition +import androidx.compose.animation.core.tween +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.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.offset +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.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.remember +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.draw.scale +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.text.style.TextAlign +import androidx.compose.ui.unit.dp +import com.archipelago.app.R +import com.archipelago.app.ui.theme.BitcoinOrange +import kotlinx.coroutines.delay + +/** + * First-launch teaching overlay for the three-finger hold gesture. Three + * fingertip dots pulse in a "press" rhythm with an expanding ring while a + * short caption explains what the gesture opens. Dismissed by tapping + * anywhere (or automatically after a few seconds) — shown once, ever. + */ +@Composable +fun GestureHintOverlay(onDismiss: () -> Unit) { + // Auto-dismiss so a user who taps nothing is never stuck behind the scrim. + LaunchedEffect(Unit) { + delay(6500) + onDismiss() + } + + val transition = rememberInfiniteTransition(label = "gesture-hint") + // Fingertips press down together… + val press by transition.animateFloat( + initialValue = 1f, + targetValue = 0.86f, + animationSpec = infiniteRepeatable(tween(650), RepeatMode.Reverse), + label = "press", + ) + // …while a ring ripples outward on each press cycle. + val ripple by transition.animateFloat( + initialValue = 0f, + targetValue = 1f, + animationSpec = infiniteRepeatable(tween(1300, easing = LinearEasing)), + label = "ripple", + ) + + Box( + modifier = Modifier + .fillMaxSize() + .background(Color.Black.copy(alpha = 0.72f)) + .clickable( + interactionSource = remember { MutableInteractionSource() }, + indication = null, + onClick = onDismiss, + ), + contentAlignment = Alignment.Center, + ) { + Column(horizontalAlignment = Alignment.CenterHorizontally) { + // Hand: three fingertip dots in a natural arc + ripple ring. + Box(Modifier.size(160.dp), contentAlignment = Alignment.Center) { + Box( + Modifier + .size(150.dp) + .scale(0.4f + ripple * 0.6f) + .border( + 2.dp, + BitcoinOrange.copy(alpha = (1f - ripple) * 0.8f), + CircleShape, + ), + ) + FingerDot(x = (-44).dp, y = 14.dp, scale = press) + FingerDot(x = 0.dp, y = (-12).dp, scale = press) + FingerDot(x = 44.dp, y = 8.dp, scale = press) + } + + Spacer(Modifier.height(28.dp)) + + Text( + text = stringResource(R.string.gesture_hint_title), + style = MaterialTheme.typography.headlineSmall, + fontWeight = FontWeight.Bold, + color = Color.White, + textAlign = TextAlign.Center, + ) + Spacer(Modifier.height(10.dp)) + Text( + text = stringResource(R.string.gesture_hint_body), + style = MaterialTheme.typography.bodyMedium, + color = Color.White.copy(alpha = 0.7f), + textAlign = TextAlign.Center, + modifier = Modifier.padding(horizontal = 48.dp), + ) + + Spacer(Modifier.height(32.dp)) + + Box( + modifier = Modifier + .clip(RoundedCornerShape(12.dp)) + .background(Color.White.copy(alpha = 0.12f)) + .clickable(onClick = onDismiss) + .padding(horizontal = 28.dp, vertical = 12.dp), + ) { + Text( + text = stringResource(R.string.gesture_hint_got_it), + style = MaterialTheme.typography.labelLarge, + color = Color.White, + ) + } + } + } +} + +@Composable +private fun FingerDot(x: androidx.compose.ui.unit.Dp, y: androidx.compose.ui.unit.Dp, scale: Float) { + Box( + Modifier + .offset(x = x, y = y) + .size(26.dp) + .scale(scale) + .background(Color.White.copy(alpha = 0.92f), CircleShape), + ) +} diff --git a/Android/app/src/main/java/com/archipelago/app/ui/components/MeshLoadingScreen.kt b/Android/app/src/main/java/com/archipelago/app/ui/components/MeshLoadingScreen.kt new file mode 100644 index 00000000..aac1270a --- /dev/null +++ b/Android/app/src/main/java/com/archipelago/app/ui/components/MeshLoadingScreen.kt @@ -0,0 +1,77 @@ +package com.archipelago.app.ui.components + +import androidx.compose.foundation.background +import androidx.compose.foundation.border +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.size +import androidx.compose.material3.CircularProgressIndicator +import androidx.compose.material3.Text +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.graphics.Color +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.sp +import com.archipelago.app.ui.screens.PixelArtLogo +import com.archipelago.app.ui.theme.BitcoinOrange +import com.archipelago.app.ui.theme.SurfaceBlack +import com.archipelago.app.ui.theme.TextMuted + +/** + * The branded "F*CK IPs" full-screen loader — shown whenever the app is + * dialing the node over the mesh (relaunch race, post-scan first connect), + * instead of an anonymous spinner. The point of the brand: what's loading + * is a connection to a cryptographic identity, not an IP. + */ +@Composable +fun MeshLoadingScreen(message: String = "Dialing your node by its key — no IPs harmed") { + Box( + Modifier + .fillMaxSize() + .background(SurfaceBlack), + contentAlignment = Alignment.Center, + ) { + Column(horizontalAlignment = Alignment.CenterHorizontally) { + // The brand's circle-container logo (as on the connect screen / + // web login): pixel-art "a" centered in a black disc. + Box( + Modifier + .size(120.dp) + .clip(androidx.compose.foundation.shape.CircleShape) + .background(Color.Black) + .border( + 1.dp, + Color.White.copy(alpha = 0.14f), + androidx.compose.foundation.shape.CircleShape, + ), + contentAlignment = Alignment.Center, + ) { + PixelArtLogo(Modifier.size(64.dp)) + } + Spacer(Modifier.height(20.dp)) + Text( + text = "F*CK IPs MESH", + color = BitcoinOrange, + fontSize = 18.sp, + fontWeight = FontWeight.Bold, + letterSpacing = 4.sp, + ) + Spacer(Modifier.height(8.dp)) + Text( + text = message, + color = TextMuted, + fontSize = 13.sp, + textAlign = TextAlign.Center, + ) + Spacer(Modifier.height(24.dp)) + CircularProgressIndicator(color = BitcoinOrange) + } + } +} 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..00931af1 --- /dev/null +++ b/Android/app/src/main/java/com/archipelago/app/ui/components/NESController.kt @@ -0,0 +1,488 @@ +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.Palette +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 = {}, + onToggleStyle: (() -> Unit)? = null, + modifier: Modifier = Modifier, +) { + val c = paletteFor(style) + val isClassic = style == ControllerStyle.CLASSIC + + Box( + modifier = modifier + .fillMaxSize() + .threeFingerHold(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) + onToggleStyle?.let { StyleBtn(c, Modifier, it) } + } + } + } + } +} + +// ═══════════════════════════════════════════════════════════ +// 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) + } +} + +/** Dark/Classic style toggle — lives next to the settings gear (the menu hub + * no longer carries it). */ +@Composable +fun StyleBtn(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.Palette, "Controller style", Modifier.size(26.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) + } +} + +/** Three-finger hold gesture modifier (two fingers stay free for scrolling) */ +fun Modifier.threeFingerHold(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 >= 3 && t == 0L) t = System.currentTimeMillis() + if (a.size >= 3 && !fired && t > 0 && System.currentTimeMillis() - t > 500) { fired = true; onHold() } + if (a.size < 3) 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..3e7ef978 --- /dev/null +++ b/Android/app/src/main/java/com/archipelago/app/ui/components/NESMenu.kt @@ -0,0 +1,620 @@ +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.layout.statusBarsPadding +import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.verticalScroll +import androidx.compose.material.icons.automirrored.filled.ArrowBack +import androidx.compose.material.icons.filled.Bolt +import androidx.compose.material.icons.filled.Dashboard +import androidx.compose.material.icons.filled.Dns +import androidx.compose.material.icons.filled.Groups +import androidx.compose.material.icons.filled.Keyboard +import androidx.compose.material.icons.filled.SportsEsports +import androidx.compose.foundation.layout.heightIn +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.Close +import androidx.compose.material.icons.filled.QrCodeScanner +import androidx.compose.material3.Icon +import androidx.compose.material3.OutlinedTextField +import androidx.compose.material3.OutlinedTextFieldDefaults +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.platform.LocalClipboardManager +import androidx.compose.ui.platform.LocalConfiguration +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.text.AnnotatedString +import com.archipelago.app.fips.FipsManager +import com.archipelago.app.fips.FipsNative +import com.archipelago.app.fips.FipsPreferences +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.res.stringResource +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.R +import com.archipelago.app.data.ServerEntry +import com.archipelago.app.ui.theme.BitcoinOrange +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?, + onDismiss: () -> Unit, + onSelectServer: (ServerEntry) -> Unit, + onAddServer: (ServerEntry) -> Unit, + onScanQr: (() -> Unit)? = null, + onEditServer: (ServerEntry, ServerEntry) -> Unit, + onRemoveServer: (ServerEntry) -> Unit, + onRemote: () -> Unit, + onKeyboard: () -> Unit, + onBackToWebView: (() -> Unit)? = null, + onMeshParty: (() -> Unit)? = null, +) { + AnimatedVisibility(visible = visible, enter = fadeIn(), exit = fadeOut()) { + // Contained hub overlay: a centred glass panel (not full-screen) that + // holds the card page and its sub-pages (Nodes, FIPS) and scrolls + // inside its own bounds when content is tall. Tapping the dimmed + // backdrop dismisses. + 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, onDismiss, onSelectServer, onAddServer, onScanQr, onEditServer, onRemoveServer, onRemote, onKeyboard, onBackToWebView, onMeshParty) + } + } + } +} + +@Composable +private fun MenuPanel( + servers: List, + activeServer: ServerEntry?, + onDismiss: () -> Unit, + onSelectServer: (ServerEntry) -> Unit, + onAddServer: (ServerEntry) -> Unit, + onScanQr: (() -> Unit)?, + onEditServer: (ServerEntry, ServerEntry) -> Unit, + onRemoveServer: (ServerEntry) -> Unit, + onRemote: () -> Unit, + onKeyboard: () -> Unit, + onBackToWebView: (() -> Unit)?, + onMeshParty: (() -> 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("") } + var https by remember { mutableStateOf(false) } + + fun resetForm() { + nm = ""; addr = ""; pwd = ""; https = false; showAdd = false; editing = null + } + + fun startEdit(server: ServerEntry) { + editing = server + nm = server.name; addr = server.address; pwd = server.password; https = server.useHttps + showAdd = false + } + + fun submit() { + if (addr.isBlank()) return + val orig = editing + if (orig != null) { + // Preserve port (compact form doesn't expose it); scheme is now editable. + onEditServer(orig, orig.copy(address = addr, useHttps = https, password = pwd, name = nm)) + } else { + onAddServer(ServerEntry(addr, https, password = pwd, name = nm)) + } + resetForm() + } + + var page by remember { mutableStateOf(HubPage.HUB) } + + Column( + modifier = Modifier + .widthIn(max = 420.dp) + .fillMaxWidth() + .padding(horizontal = 20.dp) + // Cap height just short of the full screen; the panel wraps short + // content and only scrolls in the rare case it outgrows this. + .heightIn(max = (LocalConfiguration.current.screenHeightDp * 0.92f).dp) + .clip(RoundedCornerShape(PANEL_R)) + .background(PanelBg.copy(alpha = 0.86f)) + .border(1.dp, PanelBorder, RoundedCornerShape(PANEL_R)) + .clickable(indication = null, interactionSource = remember { MutableInteractionSource() }) {} + .verticalScroll(rememberScrollState()) + .padding(20.dp), + verticalArrangement = Arrangement.spacedBy(12.dp), + ) { + // Header: back (on sub-pages) or title, and a close on the hub. + Row( + Modifier.fillMaxWidth(), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.SpaceBetween, + ) { + if (page == HubPage.HUB) { + Text("Menu", color = TextPrimary, fontSize = 20.sp, fontWeight = FontWeight.SemiBold, letterSpacing = 2.sp) + IconRound(Icons.Default.Close, "Close") { onDismiss() } + } else { + Row(verticalAlignment = Alignment.CenterVertically) { + IconRound(Icons.AutoMirrored.Filled.ArrowBack, "Back") { resetForm(); page = HubPage.HUB } + Spacer(Modifier.width(12.dp)) + Text( + if (page == HubPage.NODES) "Nodes" else "FIPS Mesh", + color = TextPrimary, fontSize = 20.sp, fontWeight = FontWeight.SemiBold, letterSpacing = 1.sp, + ) + } + IconRound(Icons.Default.Close, "Close") { onDismiss() } + } + } + Spacer(Modifier.height(4.dp)) + + when (page) { + HubPage.HUB -> { + // Card page — one card per destination. Dashboard first: it's a + // peer of the others so three-finger → hub → Dashboard returns + // to the node UI, same shape as every other option. + if (onBackToWebView != null) { + HubCard(Icons.Default.Dashboard, "Dashboard", "The node's web interface") { onBackToWebView() } + } + HubCard(Icons.Default.SportsEsports, "Remote", "Game controller for the node") { onRemote() } + HubCard(Icons.Default.Keyboard, "Keyboard", "Type into the node") { onKeyboard() } + HubCard(Icons.Default.Dns, "Nodes", activeServer?.displayName() ?: "Add or switch servers") { + page = HubPage.NODES + } + if (FipsNative.available) { + HubCard(Icons.Default.Bolt, "FIPS Mesh", "Mesh identity & status") { page = HubPage.FIPS } + } + if (onMeshParty != null) { + HubCard(Icons.Default.Groups, "Mesh Party", "Phone-to-phone chat & beam") { onMeshParty() } + } + // Dark/Classic style lives on the remote/keyboard screen next to + // the settings button — not here. + } + + HubPage.NODES -> { + 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)) + } + + 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) } + } + // HTTPS scheme toggle (available on both add and edit). + Row( + Modifier + .fillMaxWidth() + .clip(RoundedCornerShape(ROW_R)) + .clickable { https = !https } + .padding(vertical = 2.dp), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.SpaceBetween, + ) { + Text("Use HTTPS", color = TextMuted, fontSize = 13.sp) + Box( + Modifier + .width(46.dp).height(26.dp) + .clip(RoundedCornerShape(13.dp)) + .background(if (https) BitcoinOrange.copy(alpha = 0.9f) else RowBg) + .border(1.dp, if (https) BitcoinOrange else RowBorder, RoundedCornerShape(13.dp)), + contentAlignment = if (https) Alignment.CenterEnd else Alignment.CenterStart, + ) { + Box( + Modifier + .padding(horizontal = 3.dp) + .size(20.dp) + .clip(RoundedCornerShape(10.dp)) + .background(if (https) Color.White else TextMuted), + ) + } + } + } + } else { + Row(Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.spacedBy(10.dp)) { + Box(Modifier.weight(1f)) { + MenuItem(label = "Add Server", labelColor = BitcoinOrange, onClick = { showAdd = true }) + } + if (onScanQr != null) { + Box( + Modifier + .size(ROW_H) + .clip(RoundedCornerShape(ROW_R)) + .background(RowBg) + .border(1.dp, RowBorder, RoundedCornerShape(ROW_R)) + .clickable { onScanQr() }, + contentAlignment = Alignment.Center, + ) { + Icon( + Icons.Default.QrCodeScanner, + contentDescription = stringResource(R.string.add_server_qr), + tint = BitcoinOrange, + modifier = Modifier.size(24.dp), + ) + } + } + } + } + } + + HubPage.FIPS -> { + FipsSection(embedded = true) + } + } + } +} + +private enum class HubPage { HUB, NODES, FIPS } + +/** Big tappable destination card for the hub page: icon + title + subtitle. */ +@Composable +private fun HubCard( + icon: androidx.compose.ui.graphics.vector.ImageVector, + title: String, + subtitle: String, + onClick: () -> Unit, +) { + Row( + Modifier + .fillMaxWidth() + .clip(RoundedCornerShape(ROW_R)) + .background(RowBg) + .border(1.dp, RowBorder, RoundedCornerShape(ROW_R)) + .clickable { onClick() } + .padding(16.dp), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(14.dp), + ) { + Box( + Modifier.size(40.dp).clip(RoundedCornerShape(12.dp)).background(BitcoinOrange.copy(alpha = 0.14f)), + contentAlignment = Alignment.Center, + ) { + Icon(icon, contentDescription = title, tint = BitcoinOrange, modifier = Modifier.size(22.dp)) + } + Column(Modifier.weight(1f)) { + Text(title, color = TextPrimary, fontSize = 16.sp, fontWeight = FontWeight.SemiBold) + Text(subtitle, color = TextMuted, fontSize = 12.sp, maxLines = 1) + } + } +} + +/** Small circular icon button used in the hub header. */ +@Composable +private fun IconRound( + icon: androidx.compose.ui.graphics.vector.ImageVector, + desc: String, + onClick: () -> Unit, +) { + Box( + Modifier + .size(40.dp) + .clip(RoundedCornerShape(20.dp)) + .background(RowBg) + .border(1.dp, RowBorder, RoundedCornerShape(20.dp)) + .clickable { onClick() }, + contentAlignment = Alignment.Center, + ) { + Icon(icon, contentDescription = desc, tint = TextPrimary, modifier = Modifier.size(20.dp)) + } +} + +/** Snapshot of the phone's mesh identity + state for the FIPS menu section. */ +private data class FipsInfo( + val available: Boolean, + val running: Boolean, + val npub: String, + val meshAddress: String, + val peerCount: Int, +) + +/** + * FIPS mesh oversight: shows what the phone's embedded mesh node is doing — + * running state, its mesh identity (npub), its mesh address (fd…ULA), how + * many peers/anchors it's configured with — and a one-tap Reconnect that + * re-homes the mesh (also the manual fix if a network handoff ever misses). + * Collapsed by default so the menu stays compact. + */ +@Composable +private fun FipsSection(embedded: Boolean = false) { + if (!FipsNative.available) return + val context = LocalContext.current + val clipboard = LocalClipboardManager.current + var expanded by remember { mutableStateOf(embedded) } + var info by remember { mutableStateOf(null) } + + // Load identity/state when the section opens (cheap DataStore + JSON read). + LaunchedEffect(expanded) { + if (expanded && info == null) { + val prefs = FipsPreferences(context) + val id = prefs.identity() + val peers = runCatching { + org.json.JSONArray(prefs.peersJson()).length() + }.getOrDefault(0) + info = FipsInfo( + available = true, + running = FipsNative.isRunning(), + npub = id?.npub.orEmpty(), + meshAddress = id?.address.orEmpty(), + peerCount = peers, + ) + } + } + + Column(Modifier.fillMaxWidth()) { + // Embedded in the hub's FIPS sub-page the header row would duplicate + // the page title, so only the standalone (collapsible) form shows it. + if (!embedded) MenuItem( + label = "FIPS Mesh", + labelColor = BitcoinOrange, + onClick = { expanded = !expanded }, + ) + if (expanded) { + val i = info + Column( + Modifier + .fillMaxWidth() + .padding(top = 6.dp) + .clip(RoundedCornerShape(ROW_R)) + .background(FieldBg) + .border(1.dp, RowBorder, RoundedCornerShape(ROW_R)) + .padding(14.dp), + verticalArrangement = Arrangement.spacedBy(10.dp), + ) { + if (i == null) { + Text("Loading…", color = TextMuted, fontSize = 13.sp) + } else { + FipsRow("Status", if (i.running) "Connected" else "Stopped", + valueColor = if (i.running) BitcoinOrange else TextMuted) + if (i.meshAddress.isNotBlank()) { + FipsRow("Mesh address", i.meshAddress, mono = true, + onCopy = { clipboard.setText(AnnotatedString(i.meshAddress)) }) + } + if (i.npub.isNotBlank()) { + FipsRow("Identity (npub)", i.npub, mono = true, + onCopy = { clipboard.setText(AnnotatedString(i.npub)) }) + } + FipsRow("Peers & anchors", i.peerCount.toString()) + Text( + "Your node reaches this phone over the mesh by its npub — no ports opened to the internet.", + color = TextMuted, fontSize = 11.sp, + ) + MenuItem( + label = "Reconnect mesh", + labelColor = BitcoinOrange, + onClick = { + FipsManager.requestMeshRestart(context) + info = null + if (!embedded) expanded = false + }, + ) + } + } + } + } +} + +@Composable +private fun FipsRow( + label: String, + value: String, + valueColor: Color = TextPrimary, + mono: Boolean = false, + onCopy: (() -> Unit)? = null, +) { + Row( + Modifier + .fillMaxWidth() + .then(if (onCopy != null) Modifier.clickable { onCopy() } else Modifier), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.SpaceBetween, + ) { + Text(label, color = TextMuted, fontSize = 12.sp, modifier = Modifier.width(120.dp)) + Text( + value, + color = valueColor, + fontSize = if (mono) 11.sp else 13.sp, + fontWeight = FontWeight.Medium, + modifier = Modifier.weight(1f), + textAlign = TextAlign.End, + ) + if (onCopy != null) { + Text("⧉", color = TextMuted, fontSize = 13.sp, modifier = Modifier.padding(start = 8.dp)) + } + } +} + +@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..eaf3afc0 --- /dev/null +++ b/Android/app/src/main/java/com/archipelago/app/ui/components/NESPortraitController.kt @@ -0,0 +1,163 @@ +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 = {}, + onToggleStyle: (() -> Unit)? = null, +) { + val c = paletteFor(style) + val isClassic = style == ControllerStyle.CLASSIC + + Box( + Modifier + .fillMaxSize() + .threeFingerHold(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) }, + onThreeFingerHold = 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) + onToggleStyle?.let { + Spacer(Modifier.width(10.dp)) + StyleBtn(c, Modifier, it) + } + } + } + } + } +} diff --git a/Android/app/src/main/java/com/archipelago/app/ui/components/QrScannerOverlay.kt b/Android/app/src/main/java/com/archipelago/app/ui/components/QrScannerOverlay.kt new file mode 100644 index 00000000..61a7940a --- /dev/null +++ b/Android/app/src/main/java/com/archipelago/app/ui/components/QrScannerOverlay.kt @@ -0,0 +1,352 @@ +package com.archipelago.app.ui.components + +import android.Manifest +import android.content.pm.PackageManager +import androidx.activity.compose.BackHandler +import androidx.activity.compose.rememberLauncherForActivityResult +import androidx.activity.result.contract.ActivityResultContracts +import androidx.camera.core.CameraSelector +import androidx.camera.core.ImageAnalysis +import androidx.camera.core.ImageProxy +import androidx.camera.core.Preview +import androidx.camera.lifecycle.ProcessCameraProvider +import androidx.camera.view.PreviewView +import androidx.compose.animation.AnimatedVisibility +import androidx.compose.animation.fadeIn +import androidx.compose.animation.fadeOut +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.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.material.icons.Icons +import androidx.compose.material.icons.filled.Close +import androidx.compose.material3.Icon +import androidx.compose.material3.IconButton +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.DisposableEffect +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.rememberUpdatedState +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.platform.LocalLifecycleOwner +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.style.TextAlign +import androidx.compose.ui.unit.dp +import androidx.compose.ui.viewinterop.AndroidView +import androidx.core.content.ContextCompat +import com.archipelago.app.R +import com.archipelago.app.data.PairResult +import com.archipelago.app.data.ServerQrParser +import com.archipelago.app.ui.screens.GlassButton +import com.archipelago.app.ui.theme.BitcoinOrange +import com.archipelago.app.ui.theme.TextMuted +import com.archipelago.app.ui.theme.TextPrimary +import com.google.zxing.BarcodeFormat +import com.google.zxing.BinaryBitmap +import com.google.zxing.DecodeHintType +import com.google.zxing.MultiFormatReader +import com.google.zxing.NotFoundException +import com.google.zxing.PlanarYUVLuminanceSource +import com.google.zxing.common.HybridBinarizer +import kotlinx.coroutines.delay +import java.util.concurrent.Executors + +/** + * Full-screen camera overlay that scans the node pairing QR + * (docs/companion-pairing-qr.md) and reports the decoded server entry. + * Handles the camera permission itself; foreign/invalid codes show a hint + * and scanning continues. + */ +@Composable +fun QrScannerOverlay( + visible: Boolean, + onDismiss: () -> Unit, + onServerScanned: (PairResult.Success) -> Unit, +) { + val context = LocalContext.current + var hasPermission by remember { + mutableStateOf( + ContextCompat.checkSelfPermission(context, Manifest.permission.CAMERA) == + PackageManager.PERMISSION_GRANTED + ) + } + var hintRes by remember { mutableStateOf(null) } + var handled by remember { mutableStateOf(false) } + + val permissionLauncher = rememberLauncherForActivityResult( + ActivityResultContracts.RequestPermission() + ) { granted -> hasPermission = granted } + + LaunchedEffect(visible) { + if (visible) { + handled = false + hintRes = null + val granted = ContextCompat.checkSelfPermission(context, Manifest.permission.CAMERA) == + PackageManager.PERMISSION_GRANTED + hasPermission = granted + if (!granted) permissionLauncher.launch(Manifest.permission.CAMERA) + } + } + + // Foreign-code hint fades after a moment so scanning feels live again. + LaunchedEffect(hintRes) { + if (hintRes != null) { + delay(2500) + hintRes = null + } + } + + AnimatedVisibility(visible = visible, enter = fadeIn(), exit = fadeOut()) { + BackHandler { onDismiss() } + Box( + Modifier + .fillMaxSize() + .background(Color.Black), + ) { + if (hasPermission) { + CameraQrPreview( + onDecoded = { text -> + if (!handled) { + when (val result = ServerQrParser.parse(text)) { + is PairResult.Success -> { + handled = true + onServerScanned(result) + } + is PairResult.UnsupportedVersion -> hintRes = R.string.update_app_for_qr + is PairResult.Invalid -> hintRes = R.string.invalid_pairing_qr + } + } + }, + ) + // Aim frame + Box( + Modifier + .align(Alignment.Center) + .size(260.dp) + .border(2.dp, BitcoinOrange.copy(alpha = 0.85f), RoundedCornerShape(20.dp)), + ) + } else { + Column( + Modifier + .align(Alignment.Center) + .padding(horizontal = 32.dp), + horizontalAlignment = Alignment.CenterHorizontally, + verticalArrangement = Arrangement.spacedBy(16.dp), + ) { + Text( + text = stringResource(R.string.camera_permission_needed), + color = TextPrimary, + style = MaterialTheme.typography.bodyLarge, + textAlign = TextAlign.Center, + ) + GlassButton( + text = stringResource(R.string.grant_camera_access), + onClick = { permissionLauncher.launch(Manifest.permission.CAMERA) }, + modifier = Modifier.fillMaxWidth().height(56.dp), + ) + } + } + + // Top bar: title + close + Row( + Modifier + .fillMaxWidth() + .windowInsetsPadding(WindowInsets.safeDrawing) + .padding(horizontal = 8.dp, vertical = 4.dp), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.SpaceBetween, + ) { + Text( + text = stringResource(R.string.scan_node_qr), + color = TextPrimary, + style = MaterialTheme.typography.titleMedium, + modifier = Modifier.padding(start = 12.dp), + ) + IconButton(onClick = onDismiss) { + Icon(Icons.Default.Close, stringResource(R.string.close), tint = TextPrimary) + } + } + + // Bottom hints + Column( + Modifier + .align(Alignment.BottomCenter) + .windowInsetsPadding(WindowInsets.safeDrawing) + .padding(horizontal = 32.dp, vertical = 24.dp), + horizontalAlignment = Alignment.CenterHorizontally, + ) { + hintRes?.let { res -> + Text( + text = stringResource(res), + color = BitcoinOrange, + style = MaterialTheme.typography.bodyMedium, + textAlign = TextAlign.Center, + ) + Spacer(Modifier.height(8.dp)) + } + if (hasPermission) { + Text( + text = stringResource(R.string.scan_qr_hint), + color = TextMuted, + style = MaterialTheme.typography.bodyMedium, + textAlign = TextAlign.Center, + ) + } + } + } + } +} + +/** Shared by the pairing scanner and the wallet scan modal. */ +@Composable +internal fun CameraQrPreview(onDecoded: (String) -> Unit) { + val context = LocalContext.current + val lifecycleOwner = LocalLifecycleOwner.current + val currentOnDecoded by rememberUpdatedState(onDecoded) + val previewView = remember { + PreviewView(context).apply { + scaleType = PreviewView.ScaleType.FILL_CENTER + // TextureView, not the SurfaceView default: SurfaceView punches a + // hole in the window, which black-flashes inside Compose fades and + // ignores rounded-corner clipping (wallet modal). + implementationMode = PreviewView.ImplementationMode.COMPATIBLE + } + } + + DisposableEffect(Unit) { + val analysisExecutor = Executors.newSingleThreadExecutor() + val mainExecutor = ContextCompat.getMainExecutor(context) + val providerFuture = ProcessCameraProvider.getInstance(context) + var provider: ProcessCameraProvider? = null + val focusScheduler = Executors.newSingleThreadScheduledExecutor() + + providerFuture.addListener({ + val p = providerFuture.get() + provider = p + val preview = Preview.Builder().build().also { + it.setSurfaceProvider(previewView.surfaceProvider) + } + // Dense Lightning-invoice QRs need BOTH enough pixels per module and + // sharp focus. 1280x720 + a far-focused camera (e.g. Pixel 9a's main + // lens, which won't focus close) left dense invoices undecodable + // while sparse address QRs still read — the "scanner doesn't pick up + // invoices" report. 1920x1080 roughly doubles module resolution so a + // QR held at the camera's actual focus distance still resolves. + @Suppress("DEPRECATION") + val analysis = ImageAnalysis.Builder() + .setTargetResolution(android.util.Size(1920, 1080)) + .setBackpressureStrategy(ImageAnalysis.STRATEGY_KEEP_ONLY_LATEST) + .build() + .also { + it.setAnalyzer( + analysisExecutor, + QrCodeAnalyzer { text -> mainExecutor.execute { currentOnDecoded(text) } }, + ) + } + try { + p.unbindAll() + val cam = p.bindToLifecycle(lifecycleOwner, CameraSelector.DEFAULT_BACK_CAMERA, preview, analysis) + // Force a centre autofocus on a repeating tick. A hand-held QR is + // a static scene, so continuous-AF often never retriggers and the + // lens sits at its resting (far) focus — fatal for dense codes. + // A normalized centre point works before the view is measured. + val point = androidx.camera.core.SurfaceOrientedMeteringPointFactory(1f, 1f) + .createPoint(0.5f, 0.5f) + val focusAction = androidx.camera.core.FocusMeteringAction.Builder( + point, + androidx.camera.core.FocusMeteringAction.FLAG_AF, + ).disableAutoCancel().build() + focusScheduler.scheduleWithFixedDelay({ + runCatching { cam.cameraControl.startFocusAndMetering(focusAction) } + }, 0, 2, java.util.concurrent.TimeUnit.SECONDS) + } catch (_: Exception) { + // Camera unavailable — the user can dismiss and enter details manually. + } + }, mainExecutor) + + onDispose { + focusScheduler.shutdownNow() + provider?.unbindAll() + analysisExecutor.shutdown() + } + } + + AndroidView(factory = { previewView }, modifier = Modifier.fillMaxSize()) +} + +/** ZXing-based QR decoder over the camera's Y (luminance) plane. */ +private class QrCodeAnalyzer(private val onDecoded: (String) -> Unit) : ImageAnalysis.Analyzer { + private val reader = MultiFormatReader().apply { + setHints( + mapOf( + DecodeHintType.POSSIBLE_FORMATS to listOf(BarcodeFormat.QR_CODE), + // Screen-displayed QRs come with moiré, glare, and soft focus at + // close range — the exhaustive search is worth the milliseconds. + DecodeHintType.TRY_HARDER to true, + ) + ) + } + + private var lastAttempt = 0L + + override fun analyze(image: ImageProxy) { + // Decode ~7x/s, not on every frame: TRY_HARDER (plus the inverted + // retry) pegs a core when run at camera rate, and that CPU contention + // is what made the preview itself stutter. KEEP_ONLY_LATEST means the + // frames skipped here are simply dropped, so decodes stay current. + val now = System.currentTimeMillis() + if (now - lastAttempt < 140) { + image.close() + return + } + lastAttempt = now + try { + val plane = image.planes[0] + val buffer = plane.buffer + // Copy into a rowStride-wide array; the last row of the plane buffer + // may be short of the full stride, so the tail stays zero-padded. + val data = ByteArray(plane.rowStride * image.height) + buffer.get(data, 0, minOf(buffer.remaining(), data.size)) + val source = PlanarYUVLuminanceSource( + data, plane.rowStride, image.height, + 0, 0, image.width, image.height, + false, + ) + val result = try { + reader.decodeWithState(BinaryBitmap(HybridBinarizer(source))) + } catch (_: NotFoundException) { + // Dark-themed pages can render light-on-dark QRs — retry inverted. + reader.reset() + reader.decodeWithState(BinaryBitmap(HybridBinarizer(source.invert()))) + } + onDecoded(result.text) + } catch (_: NotFoundException) { + // No QR in this frame — keep scanning. + } catch (_: Exception) { + // Malformed frame; skip it. + } finally { + reader.reset() + image.close() + } + } +} 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..bb02faa7 --- /dev/null +++ b/Android/app/src/main/java/com/archipelago/app/ui/components/Trackpad.kt @@ -0,0 +1,116 @@ +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, + onThreeFingerHold: () -> 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 threeStart = 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 { + // Three fingers = hold for menu; two = scroll. Kept + // on separate counts so a long two-finger scroll can + // never fire the menu mid-gesture. + active.size >= 3 -> { + if (threeStart == 0L) threeStart = System.currentTimeMillis() + if (!holdFired && System.currentTimeMillis() - threeStart > 500) { + holdFired = true + onThreeFingerHold() + } + ev.changes.forEach { it.consume() } + } + active.size == 2 -> { + threeStart = 0L + 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 = when { + fingers >= 3 -> "hold for menu" + fingers == 2 -> "scroll" + 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/components/WalletQrScannerModal.kt b/Android/app/src/main/java/com/archipelago/app/ui/components/WalletQrScannerModal.kt new file mode 100644 index 00000000..98c77c65 --- /dev/null +++ b/Android/app/src/main/java/com/archipelago/app/ui/components/WalletQrScannerModal.kt @@ -0,0 +1,294 @@ +package com.archipelago.app.ui.components + +import android.Manifest +import android.content.Context +import android.content.pm.PackageManager +import android.graphics.BitmapFactory +import android.net.Uri +import androidx.activity.compose.BackHandler +import androidx.activity.compose.rememberLauncherForActivityResult +import androidx.activity.result.contract.ActivityResultContracts +import androidx.compose.animation.AnimatedVisibility +import androidx.compose.animation.fadeIn +import androidx.compose.animation.fadeOut +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.aspectRatio +import androidx.compose.foundation.layout.defaultMinSize +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.widthIn +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.Close +import androidx.compose.material3.Icon +import androidx.compose.material3.IconButton +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.clip +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.text.style.TextAlign +import androidx.compose.ui.unit.dp +import androidx.core.content.ContextCompat +import com.archipelago.app.R +import com.archipelago.app.ui.screens.GlassButton +import com.archipelago.app.ui.theme.BitcoinOrange +import com.google.zxing.BarcodeFormat +import com.google.zxing.BinaryBitmap +import com.google.zxing.DecodeHintType +import com.google.zxing.MultiFormatReader +import com.google.zxing.NotFoundException +import com.google.zxing.RGBLuminanceSource +import com.google.zxing.common.HybridBinarizer + +/** + * Native replacement for the web wallet's scan pane — same visual design as + * neode-ui's WalletScanModal (dark glass card, square preview, orange + * viewfinder, status strip) but the camera and decoding run natively, so the + * preview doesn't lag the way getUserMedia does inside a WebView. + * + * Decoded text is handed back to the page ([onDecoded]) which does all the + * detection/spend logic; the page in turn streams status lines (animated-QR + * progress, "not recognised" errors) back in via [status] and closes the + * modal through the JS bridge once it accepts a code. + */ +@Composable +fun WalletQrScannerModal( + visible: Boolean, + status: Pair?, // message from the web page + isError + onDecoded: (String) -> Unit, + onDismiss: () -> Unit, +) { + val context = LocalContext.current + var hasPermission by remember { + mutableStateOf( + ContextCompat.checkSelfPermission(context, Manifest.permission.CAMERA) == + PackageManager.PERMISSION_GRANTED + ) + } + val permissionLauncher = rememberLauncherForActivityResult( + ActivityResultContracts.RequestPermission() + ) { granted -> hasPermission = granted } + + // Local error from a failed image upload; a fresh web status replaces it. + var uploadError by remember { mutableStateOf(null) } + val noQrMessage = stringResource(R.string.no_qr_in_image) + val imagePicker = rememberLauncherForActivityResult( + ActivityResultContracts.GetContent() + ) { uri -> + if (uri != null) { + val decoded = decodeQrFromUri(context, uri) + if (decoded != null) { + uploadError = null + onDecoded(decoded) + } else { + uploadError = noQrMessage + } + } + } + + LaunchedEffect(visible) { + if (visible) { + uploadError = null + val granted = ContextCompat.checkSelfPermission(context, Manifest.permission.CAMERA) == + PackageManager.PERMISSION_GRANTED + hasPermission = granted + if (!granted) permissionLauncher.launch(Manifest.permission.CAMERA) + } + } + LaunchedEffect(status) { if (status != null) uploadError = null } + + AnimatedVisibility(visible = visible, enter = fadeIn(), exit = fadeOut()) { + BackHandler { onDismiss() } + Box( + Modifier + .fillMaxSize() + .background(Color.Black.copy(alpha = 0.6f)) + .clickable( + interactionSource = remember { MutableInteractionSource() }, + indication = null, + onClick = onDismiss, + ), + contentAlignment = Alignment.Center, + ) { + Column( + Modifier + .padding(16.dp) + .widthIn(max = 420.dp) + .fillMaxWidth() + .clip(RoundedCornerShape(24.dp)) + .background(Color(0xF212151C)) + .border(1.dp, Color.White.copy(alpha = 0.10f), RoundedCornerShape(24.dp)) + .clickable( + interactionSource = remember { MutableInteractionSource() }, + indication = null, + onClick = {}, // swallow — only the scrim dismisses + ) + .padding(24.dp), + ) { + // Header — mirrors the web modal's title row + Row( + Modifier.fillMaxWidth(), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.SpaceBetween, + ) { + Text( + text = stringResource(R.string.scan_to_send), + style = MaterialTheme.typography.titleLarge, + fontWeight = FontWeight.SemiBold, + color = Color.White, + ) + IconButton(onClick = onDismiss) { + Icon( + Icons.Default.Close, + stringResource(R.string.close), + tint = Color.White.copy(alpha = 0.7f), + ) + } + } + + Spacer(Modifier.height(8.dp)) + + // Square camera preview with the orange viewfinder + Box( + Modifier + .fillMaxWidth() + .aspectRatio(1f) + .clip(RoundedCornerShape(12.dp)) + .background(Color.Black.copy(alpha = 0.4f)) + .border(1.dp, Color.White.copy(alpha = 0.10f), RoundedCornerShape(12.dp)), + contentAlignment = Alignment.Center, + ) { + if (hasPermission) { + // Throttle repeat frames: a static QR decodes ~20x/s but + // the page only needs one; animated QRs still stream + // because each frame's text differs. + var lastText by remember { mutableStateOf("") } + var lastSentAt by remember { mutableStateOf(0L) } + CameraQrPreview(onDecoded = { text -> + val now = System.currentTimeMillis() + if (text != lastText || now - lastSentAt > 250) { + lastText = text + lastSentAt = now + onDecoded(text) + } + }) + Box( + Modifier + .fillMaxSize(0.62f) + .border( + 2.dp, + BitcoinOrange.copy(alpha = 0.85f), + RoundedCornerShape(16.dp), + ), + ) + } else { + Column( + Modifier.padding(horizontal = 24.dp), + horizontalAlignment = Alignment.CenterHorizontally, + verticalArrangement = Arrangement.spacedBy(12.dp), + ) { + Text( + text = stringResource(R.string.camera_permission_needed), + color = Color.White.copy(alpha = 0.7f), + style = MaterialTheme.typography.bodyMedium, + textAlign = TextAlign.Center, + ) + GlassButton( + text = stringResource(R.string.grant_camera_access), + onClick = { permissionLauncher.launch(Manifest.permission.CAMERA) }, + modifier = Modifier.fillMaxWidth().height(48.dp), + ) + } + } + } + + Spacer(Modifier.height(16.dp)) + + // Status strip — same slot the web modal uses for hints/errors + val message = uploadError ?: status?.first + val isError = uploadError != null || status?.second == true + Box( + Modifier + .fillMaxWidth() + .clip(RoundedCornerShape(8.dp)) + .background(Color.White.copy(alpha = 0.05f)) + .padding(12.dp) + .defaultMinSize(minHeight = 24.dp), + contentAlignment = Alignment.Center, + ) { + Text( + text = message?.takeIf { it.isNotBlank() } + ?: stringResource(R.string.scan_wallet_hint), + style = MaterialTheme.typography.bodySmall, + color = if (isError) Color(0xFFF87171) else Color.White.copy(alpha = 0.6f), + textAlign = TextAlign.Center, + ) + } + + Spacer(Modifier.height(16.dp)) + + GlassButton( + text = stringResource(R.string.upload_qr_image), + onClick = { imagePicker.launch("image/*") }, + modifier = Modifier.fillMaxWidth().height(48.dp), + ) + } + } + } +} + +/** Decode a QR from a picked image, downsampled so huge photos stay cheap. */ +private fun decodeQrFromUri(context: Context, uri: Uri): String? { + return try { + val resolver = context.contentResolver + val bounds = BitmapFactory.Options().apply { inJustDecodeBounds = true } + resolver.openInputStream(uri)?.use { BitmapFactory.decodeStream(it, null, bounds) } + var sample = 1 + val maxDim = maxOf(bounds.outWidth, bounds.outHeight) + while (maxDim / (sample * 2) >= 1600) sample *= 2 + val opts = BitmapFactory.Options().apply { inSampleSize = sample } + val bmp = resolver.openInputStream(uri)?.use { BitmapFactory.decodeStream(it, null, opts) } + ?: return null + val pixels = IntArray(bmp.width * bmp.height) + bmp.getPixels(pixels, 0, bmp.width, 0, 0, bmp.width, bmp.height) + val source = RGBLuminanceSource(bmp.width, bmp.height, pixels) + val reader = MultiFormatReader().apply { + setHints( + mapOf( + DecodeHintType.POSSIBLE_FORMATS to listOf(BarcodeFormat.QR_CODE), + DecodeHintType.TRY_HARDER to true, + ) + ) + } + try { + reader.decodeWithState(BinaryBitmap(HybridBinarizer(source))).text + } catch (_: NotFoundException) { + // Light-on-dark QRs (dark-themed wallets) decode inverted. + reader.reset() + reader.decodeWithState(BinaryBitmap(HybridBinarizer(source.invert()))).text + } + } catch (_: Exception) { + null + } +} 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..138c817b --- /dev/null +++ b/Android/app/src/main/java/com/archipelago/app/ui/navigation/NavGraph.kt @@ -0,0 +1,225 @@ +package com.archipelago.app.ui.navigation + +import android.net.VpnService +import androidx.activity.compose.rememberLauncherForActivityResult +import androidx.activity.result.contract.ActivityResultContracts +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +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.platform.LocalContext +import androidx.navigation.NavType +import androidx.navigation.compose.NavHost +import androidx.navigation.compose.composable +import androidx.navigation.compose.rememberNavController +import androidx.navigation.navArgument +import com.archipelago.app.data.PairResult +import com.archipelago.app.data.ServerEntry +import com.archipelago.app.data.ServerPreferences +import com.archipelago.app.data.ServerQrParser +import com.archipelago.app.fips.FipsManager +import com.archipelago.app.ui.screens.FlareScreen +import com.archipelago.app.ui.screens.IntroScreen +import com.archipelago.app.ui.screens.PartyScreen +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" + const val MESH_PARTY = "mesh_party" + const val FLARE = "flare" +} + +@Composable +fun AppNavHost( + pairUri: String? = null, + onPairUriConsumed: () -> Unit = {}, +) { + 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) + + // Pairing entry from a deep link that carried no password — prefills the + // connect form so the user lands on the password prompt for that server. + var pairPrefill by remember { mutableStateOf(null) } + + // Mesh tunnel: Android's VPN consent dialog is the single unavoidable + // interaction — it can only be launched from an Activity, so pairing + // paths raise FipsManager.consentNeeded and it is handled here, once. + val consentNeeded by FipsManager.consentNeeded.collectAsState() + val vpnConsentLauncher = rememberLauncherForActivityResult( + ActivityResultContracts.StartActivityForResult() + ) { result -> + FipsManager.consentHandled() + if (result.resultCode == android.app.Activity.RESULT_OK) { + FipsManager.startService(context) + } + } + LaunchedEffect(consentNeeded) { + if (!consentNeeded) return@LaunchedEffect + val consentIntent = VpnService.prepare(context) + if (consentIntent == null) { + FipsManager.consentHandled() + FipsManager.startService(context) + } else { + vpnConsentLauncher.launch(consentIntent) + } + } + + // Paired + previously consented → the mesh comes back silently on launch. + LaunchedEffect(Unit) { + FipsManager.autoStartIfReady(context) + } + + if (introSeen == null) return + + // Declared after the introSeen gate so it can't fire before the NavHost + // below has set the nav graph; pairUri stays pending until consumed here. + LaunchedEffect(pairUri) { + val raw = pairUri ?: return@LaunchedEffect + onPairUriConsumed() + when (val result = ServerQrParser.parse(raw)) { + is PairResult.Success -> { + // Pairing implies the app is installed and in use — skip the intro. + prefs.markIntroSeen() + val merged = prefs.upsertServer(result.server) + FipsManager.registerNode(context, result.fips, merged.displayName()) + if (merged.password.isNotBlank()) { + // Demo flow: password came with the link — connect in one step. + prefs.setActiveServer(merged) + navController.navigate(Routes.WEB_VIEW) { + popUpTo(0) { inclusive = true } + } + } else { + pairPrefill = merged + navController.navigate(Routes.SERVER_CONNECT) { + popUpTo(0) { inclusive = true } + } + } + } + else -> { + // Invalid or too-new pairing link — ignore; normal startup continues. + } + } + } + + 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( + onMeshParty = { + navController.navigate(Routes.MESH_PARTY) + }, + 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 } + } + }, + initialServer = pairPrefill, + ) + } + + 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(), + serverPassword = server.password, + meshFallbackUrl = server.toMeshUrl(), + onDisconnect = { + scope.launch { + prefs.clearActiveServer() + navController.navigate(Routes.SERVER_CONNECT) { + popUpTo(0) { inclusive = true } + } + } + }, + onRemoteInput = { + navController.navigate(Routes.REMOTE_INPUT) + }, + onRemoteKeyboard = { + navController.navigate("${Routes.REMOTE_INPUT}?keyboard=true") + }, + onMeshParty = { + navController.navigate(Routes.MESH_PARTY) + }, + ) + } + } + + composable( + "${Routes.REMOTE_INPUT}?keyboard={keyboard}", + arguments = listOf( + navArgument("keyboard") { + type = NavType.BoolType + defaultValue = false + }, + ), + ) { entry -> + RemoteInputScreen( + onBack = { + navController.popBackStack() + }, + onMeshParty = { + navController.navigate(Routes.MESH_PARTY) + }, + startInKeyboard = entry.arguments?.getBoolean("keyboard") == true, + ) + } + + composable(Routes.MESH_PARTY) { + PartyScreen( + onBack = { navController.popBackStack() }, + onOpenChat = { navController.navigate(Routes.FLARE) }, + ) + } + + composable(Routes.FLARE) { + FlareScreen( + onBack = { navController.popBackStack() }, + ) + } + } +} diff --git a/Android/app/src/main/java/com/archipelago/app/ui/screens/FlareScreen.kt b/Android/app/src/main/java/com/archipelago/app/ui/screens/FlareScreen.kt new file mode 100644 index 00000000..f9f24a30 --- /dev/null +++ b/Android/app/src/main/java/com/archipelago/app/ui/screens/FlareScreen.kt @@ -0,0 +1,359 @@ +package com.archipelago.app.ui.screens + +import android.graphics.Bitmap +import android.graphics.BitmapFactory +import android.net.Uri +import androidx.activity.compose.BackHandler +import androidx.activity.compose.rememberLauncherForActivityResult +import androidx.activity.result.contract.ActivityResultContracts +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.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.imePadding +import androidx.compose.foundation.layout.navigationBarsPadding +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.layout.statusBarsPadding +import androidx.compose.foundation.layout.widthIn +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.lazy.items +import androidx.compose.foundation.lazy.rememberLazyListState +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.Image +import androidx.compose.material3.Icon +import androidx.compose.material3.OutlinedTextField +import androidx.compose.material3.OutlinedTextFieldDefaults +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +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.Color +import androidx.compose.ui.graphics.asImageBitmap +import androidx.compose.ui.layout.ContentScale +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.text.TextStyle +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.text.input.ImeAction +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp +import com.archipelago.app.fips.FipsManager +import com.archipelago.app.fips.FipsNative +import com.archipelago.app.fips.FipsPreferences +import com.archipelago.app.fips.FlareClient +import com.archipelago.app.fips.FlareMessage +import com.archipelago.app.fips.FlareStore +import com.archipelago.app.fips.PartyPeer +import com.archipelago.app.ui.theme.BitcoinOrange +import com.archipelago.app.ui.theme.SurfaceDark +import com.archipelago.app.ui.theme.TextMuted +import com.archipelago.app.ui.theme.TextPrimary +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.launch +import kotlinx.coroutines.withContext +import java.io.ByteArrayOutputStream +import java.io.File +import java.util.UUID + +private val BubbleTheirs = Color.White.copy(alpha = 0.07f) +private val BubbleBorder = Color.White.copy(alpha = 0.08f) + +/** + * Flare — phone↔phone chat and photo beam over the FIPS mesh. Every byte is + * E2E encrypted by the mesh layer and addressed by npub; whether it travels + * via a public anchor (5G) or a direct hotspot link is invisible up here — + * which is the entire point. + */ +@Composable +fun FlareScreen(onBack: () -> Unit) { + val context = LocalContext.current + val prefs = remember { FipsPreferences(context) } + val scope = rememberCoroutineScope() + + var identity by remember { mutableStateOf(null) } + var myName by remember { mutableStateOf("Phone") } + val peers by prefs.partyPeersFlow.collectAsState(initial = emptyList()) + var selectedNpub by remember { mutableStateOf(null) } + val allMessages by FlareStore.messages.collectAsState() + var draft by remember { mutableStateOf("") } + + LaunchedEffect(Unit) { + identity = FipsManager.ensureIdentity(prefs) + myName = prefs.partyName() + } + LaunchedEffect(peers) { + if (selectedNpub == null || peers.none { it.npub == selectedNpub }) { + selectedNpub = peers.firstOrNull()?.npub + } + } + + val peer = peers.firstOrNull { it.npub == selectedNpub } + val messages = allMessages.filter { it.peerNpub == selectedNpub } + val listState = rememberLazyListState() + LaunchedEffect(messages.size) { + if (messages.isNotEmpty()) listState.animateScrollToItem(messages.size - 1) + } + + fun sendText() { + val target = peer ?: return + val me = identity ?: return + val text = draft.trim() + if (text.isEmpty()) return + draft = "" + val msg = FlareMessage( + id = UUID.randomUUID().toString(), + peerNpub = target.npub, + fromMe = true, + name = myName, + text = text, + ts = System.currentTimeMillis(), + status = FlareMessage.Status.SENDING, + ) + FlareStore.add(msg) + scope.launch(Dispatchers.IO) { + val ok = FlareClient.sendText(target, me.npub, myName, text) + FlareStore.setStatus(msg.id, if (ok) FlareMessage.Status.SENT else FlareMessage.Status.FAILED) + } + } + + fun sendPhoto(uri: Uri) { + val target = peer ?: return + val me = identity ?: return + scope.launch(Dispatchers.IO) { + val jpeg = compressPhoto(context, uri) ?: return@launch + // Local copy so our own bubble renders the sent photo. + val dir = File(context.cacheDir, "flare").apply { mkdirs() } + val local = File(dir, "${UUID.randomUUID()}.jpg").apply { writeBytes(jpeg) } + val msg = FlareMessage( + id = UUID.randomUUID().toString(), + peerNpub = target.npub, + fromMe = true, + name = myName, + photoPath = local.absolutePath, + ts = System.currentTimeMillis(), + status = FlareMessage.Status.SENDING, + ) + FlareStore.add(msg) + val ok = FlareClient.sendPhoto(target, me.npub, myName, jpeg) + FlareStore.setStatus(msg.id, if (ok) FlareMessage.Status.SENT else FlareMessage.Status.FAILED) + } + } + + val photoPicker = rememberLauncherForActivityResult( + ActivityResultContracts.GetContent() + ) { uri -> uri?.let { sendPhoto(it) } } + + BackHandler { onBack() } + + Column( + Modifier + .fillMaxSize() + .background(SurfaceDark) + .statusBarsPadding() + .navigationBarsPadding() + .imePadding(), + ) { + // Header + Row( + Modifier.fillMaxWidth().padding(horizontal = 16.dp, vertical = 10.dp), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.SpaceBetween, + ) { + Text("‹ Back", color = TextMuted, fontSize = 15.sp, modifier = Modifier.clickable { onBack() }.padding(6.dp)) + Column(horizontalAlignment = Alignment.CenterHorizontally) { + Text("FLARE", color = TextPrimary, fontSize = 16.sp, fontWeight = FontWeight.SemiBold, letterSpacing = 3.sp) + peer?.let { + Text( + it.name + if (it.ip.isNotBlank()) " · direct+mesh" else " · mesh", + color = BitcoinOrange, + fontSize = 11.sp, + ) + } + } + Spacer(Modifier.size(48.dp)) + } + + // Peer tabs when chatting with more than one phone + if (peers.size > 1) { + Row( + Modifier.fillMaxWidth().padding(horizontal = 16.dp), + horizontalArrangement = Arrangement.spacedBy(8.dp), + ) { + peers.forEach { p -> + val active = p.npub == selectedNpub + Text( + p.name, + color = if (active) BitcoinOrange else TextMuted, + fontSize = 13.sp, + modifier = Modifier + .clip(RoundedCornerShape(10.dp)) + .background(if (active) BitcoinOrange.copy(alpha = 0.12f) else Color.Transparent) + .border( + 1.dp, + if (active) BitcoinOrange.copy(alpha = 0.4f) else BubbleBorder, + RoundedCornerShape(10.dp), + ) + .clickable { selectedNpub = p.npub } + .padding(horizontal = 12.dp, vertical = 6.dp), + ) + } + } + } + + if (peer == null) { + Box(Modifier.weight(1f).fillMaxWidth(), contentAlignment = Alignment.Center) { + Text("No party peers yet — scan a phone first", color = TextMuted, fontSize = 14.sp) + } + } else { + LazyColumn( + state = listState, + modifier = Modifier.weight(1f).fillMaxWidth(), + contentPadding = androidx.compose.foundation.layout.PaddingValues(16.dp), + verticalArrangement = Arrangement.spacedBy(8.dp), + ) { + items(messages, key = { it.id }) { msg -> + MessageBubble(msg) + } + } + + // Composer + Row( + Modifier.fillMaxWidth().padding(horizontal = 12.dp, vertical = 8.dp), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(8.dp), + ) { + Box( + Modifier + .size(48.dp) + .clip(RoundedCornerShape(12.dp)) + .background(BubbleTheirs) + .border(1.dp, BubbleBorder, RoundedCornerShape(12.dp)) + .clickable { photoPicker.launch("image/*") }, + contentAlignment = Alignment.Center, + ) { + Icon(Icons.Default.Image, "Beam a photo", tint = BitcoinOrange, modifier = Modifier.size(22.dp)) + } + OutlinedTextField( + value = draft, + onValueChange = { draft = it }, + placeholder = { Text("Send a flare…", color = TextMuted, fontSize = 14.sp) }, + modifier = Modifier.weight(1f), + singleLine = true, + keyboardOptions = KeyboardOptions(imeAction = ImeAction.Send), + keyboardActions = KeyboardActions(onSend = { sendText() }), + textStyle = TextStyle(color = TextPrimary, fontSize = 15.sp), + 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), + ) + Box( + Modifier + .size(48.dp) + .clip(RoundedCornerShape(12.dp)) + .background(BitcoinOrange.copy(alpha = 0.15f)) + .border(1.dp, BitcoinOrange.copy(alpha = 0.4f), RoundedCornerShape(12.dp)) + .clickable { sendText() }, + contentAlignment = Alignment.Center, + ) { + Text("➤", color = BitcoinOrange, fontSize = 18.sp) + } + } + } + } +} + +@Composable +private fun MessageBubble(msg: FlareMessage) { + Row( + Modifier.fillMaxWidth(), + horizontalArrangement = if (msg.fromMe) Arrangement.End else Arrangement.Start, + ) { + Column( + Modifier + .widthIn(max = 300.dp) + .clip(RoundedCornerShape(16.dp)) + .background(if (msg.fromMe) BitcoinOrange.copy(alpha = 0.14f) else BubbleTheirs) + .border( + 1.dp, + if (msg.fromMe) BitcoinOrange.copy(alpha = 0.35f) else BubbleBorder, + RoundedCornerShape(16.dp), + ) + .padding(horizontal = 12.dp, vertical = 8.dp), + ) { + if (msg.photoPath.isNotBlank()) { + val bmp = remember(msg.photoPath) { BitmapFactory.decodeFile(msg.photoPath) } + bmp?.let { + Image( + bitmap = it.asImageBitmap(), + contentDescription = "Beamed photo", + modifier = Modifier + .fillMaxWidth() + .clip(RoundedCornerShape(10.dp)), + contentScale = ContentScale.FillWidth, + ) + } + } + if (msg.text.isNotBlank()) { + Text(msg.text, color = TextPrimary, fontSize = 15.sp) + } + Text( + when (msg.status) { + FlareMessage.Status.SENDING -> "sending…" + FlareMessage.Status.SENT -> "sent · E2E via mesh" + FlareMessage.Status.FAILED -> "failed — tap to retry later" + FlareMessage.Status.RECEIVED -> msg.name + }, + color = if (msg.status == FlareMessage.Status.FAILED) BitcoinOrange else TextMuted, + fontSize = 10.sp, + modifier = Modifier.padding(top = 2.dp), + ) + } + } +} + +/** Decode, downscale (≤1600px) and JPEG-compress a picked photo off-main. */ +private suspend fun compressPhoto(context: android.content.Context, uri: Uri): ByteArray? = + withContext(Dispatchers.IO) { + try { + val bounds = BitmapFactory.Options().apply { inJustDecodeBounds = true } + context.contentResolver.openInputStream(uri)?.use { + BitmapFactory.decodeStream(it, null, bounds) + } + var sample = 1 + while (maxOf(bounds.outWidth, bounds.outHeight) / sample > 1600) sample *= 2 + val opts = BitmapFactory.Options().apply { inSampleSize = sample } + val bitmap = context.contentResolver.openInputStream(uri)?.use { + BitmapFactory.decodeStream(it, null, opts) + } ?: return@withContext null + val out = ByteArrayOutputStream() + bitmap.compress(Bitmap.CompressFormat.JPEG, 80, out) + bitmap.recycle() + out.toByteArray() + } catch (_: Exception) { + null + } + } 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..db42dff9 --- /dev/null +++ b/Android/app/src/main/java/com/archipelago/app/ui/screens/IntroScreen.kt @@ -0,0 +1,256 @@ +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, + // Mesh Party works with no node at all (phone↔phone) — offered right on + // the first screen so a friend who just got the app can join a party. + onMeshParty: () -> 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), + ) + + Spacer(modifier = Modifier.height(12.dp)) + + GlassButton( + text = stringResource(R.string.mesh_party), + onClick = onMeshParty, + modifier = Modifier.fillMaxWidth().height(48.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/PartyScreen.kt b/Android/app/src/main/java/com/archipelago/app/ui/screens/PartyScreen.kt new file mode 100644 index 00000000..31494993 --- /dev/null +++ b/Android/app/src/main/java/com/archipelago/app/ui/screens/PartyScreen.kt @@ -0,0 +1,519 @@ +package com.archipelago.app.ui.screens + +import android.graphics.Bitmap +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.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.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.statusBarsPadding +import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.foundation.text.KeyboardOptions +import androidx.compose.foundation.verticalScroll +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.LaunchedEffect +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.Color +import androidx.compose.ui.graphics.asImageBitmap +import androidx.compose.ui.platform.LocalContext +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.style.TextAlign +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp +import com.archipelago.app.fips.FipsManager +import com.archipelago.app.fips.FipsNative +import com.archipelago.app.fips.FipsPreferences +import com.archipelago.app.fips.FlareClient +import com.archipelago.app.fips.PartyPeer +import com.archipelago.app.fips.PartyQr +import com.archipelago.app.ui.components.CameraQrPreview +import com.archipelago.app.ui.theme.BitcoinOrange +import com.archipelago.app.ui.theme.SurfaceDark +import com.archipelago.app.ui.theme.TextMuted +import com.archipelago.app.ui.theme.TextPrimary +import com.google.zxing.BarcodeFormat +import com.google.zxing.EncodeHintType +import com.google.zxing.qrcode.QRCodeWriter +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.delay +import kotlinx.coroutines.launch +import kotlinx.coroutines.withContext + +private val CardBg = Color.White.copy(alpha = 0.05f) +private val CardBorder = Color.White.copy(alpha = 0.08f) + +/** Public companion download (vps2 demo host — serves the same APK as the + * nodes' QR link). Rendered as the "Share this app" QR. */ +private const val APP_DOWNLOAD_URL = + "http://146.59.87.168:2100/packages/archipelago-companion.apk" + +/** + * Mesh Party — phone↔phone FIPS pairing. Show your QR, scan theirs, and the + * two embedded mesh nodes link up: through anchors when there's internet, + * directly over any shared WiFi/hotspot when there isn't. + */ +@Composable +fun PartyScreen( + onBack: () -> Unit, + onOpenChat: () -> Unit, +) { + val context = LocalContext.current + val prefs = remember { FipsPreferences(context) } + val scope = rememberCoroutineScope() + + var identity by remember { mutableStateOf(null) } + var name by remember { mutableStateOf("") } + var localIp by remember { mutableStateOf(null) } + var showScanner by remember { mutableStateOf(false) } + var showShareQr by remember { mutableStateOf(false) } + var scanHint by remember { mutableStateOf(null) } + + // Camera permission — fresh installs (and reinstalls: uninstall wipes + // grants) land here with no CAMERA grant, and the raw preview just showed + // black. Ask the moment the scanner opens. + var hasCamera by remember { + mutableStateOf( + androidx.core.content.ContextCompat.checkSelfPermission( + context, android.Manifest.permission.CAMERA, + ) == android.content.pm.PackageManager.PERMISSION_GRANTED + ) + } + val cameraPermLauncher = androidx.activity.compose.rememberLauncherForActivityResult( + androidx.activity.result.contract.ActivityResultContracts.RequestPermission() + ) { hasCamera = it } + LaunchedEffect(showScanner) { + if (showScanner && !hasCamera) { + cameraPermLauncher.launch(android.Manifest.permission.CAMERA) + } + } + val listenOn by prefs.partyListenFlow.collectAsState(initial = false) + val peers by prefs.partyPeersFlow.collectAsState(initial = emptyList()) + + LaunchedEffect(Unit) { + identity = FipsManager.ensureIdentity(prefs) + name = prefs.partyName() + // The hotspot/WiFi address can change while this screen is open + // (e.g. the user flips the hotspot on mid-demo) — keep it fresh. + while (true) { + localIp = withContext(Dispatchers.IO) { PartyQr.localWifiIpv4() } + delay(3_000) + } + } + + val qrPayload = identity?.let { id -> + PartyQr.build( + npub = id.npub, + ula = id.address, + name = name.ifBlank { "Phone" }, + ip = if (listenOn) localIp else null, + port = PartyQr.PARTY_UDP_PORT, + ) + } + val qrBitmap = remember(qrPayload) { qrPayload?.let { renderQr(it) } } + + BackHandler { + when { + showScanner -> showScanner = false + showShareQr -> showShareQr = false + else -> onBack() + } + } + + Box(Modifier.fillMaxSize().background(SurfaceDark)) { + Column( + Modifier + .fillMaxSize() + .statusBarsPadding() + .verticalScroll(rememberScrollState()) + .padding(horizontal = 24.dp, vertical = 16.dp), + verticalArrangement = Arrangement.spacedBy(14.dp), + ) { + Row( + Modifier.fillMaxWidth(), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.SpaceBetween, + ) { + Text("‹ Back", color = TextMuted, fontSize = 15.sp, modifier = Modifier.clickable { onBack() }.padding(8.dp)) + Text("MESH PARTY", color = TextPrimary, fontSize = 17.sp, fontWeight = FontWeight.SemiBold, letterSpacing = 3.sp) + Spacer(Modifier.size(56.dp)) + } + + // My QR card + Column( + Modifier + .fillMaxWidth() + .clip(RoundedCornerShape(20.dp)) + .background(CardBg) + .border(1.dp, CardBorder, RoundedCornerShape(20.dp)) + .padding(18.dp), + horizontalAlignment = Alignment.CenterHorizontally, + verticalArrangement = Arrangement.spacedBy(12.dp), + ) { + qrBitmap?.let { bmp -> + Box( + Modifier + .clip(RoundedCornerShape(16.dp)) + .background(Color.White) + .padding(12.dp), + ) { + Image( + bitmap = bmp.asImageBitmap(), + contentDescription = "My mesh party QR", + modifier = Modifier.size(220.dp), + ) + } + } ?: Text("Mesh identity unavailable on this device", color = TextMuted, fontSize = 14.sp) + + identity?.let { + Text( + it.npub.take(16) + "…" + it.npub.takeLast(6), + color = TextMuted, + fontSize = 12.sp, + textAlign = TextAlign.Center, + ) + } + + OutlinedTextField( + value = name, + onValueChange = { + name = it.take(24) + scope.launch { prefs.setPartyName(name) } + }, + placeholder = { Text("Your name", color = TextMuted, textAlign = TextAlign.Center, modifier = Modifier.fillMaxWidth()) }, + modifier = Modifier.fillMaxWidth().height(56.dp), + singleLine = true, + keyboardOptions = KeyboardOptions(imeAction = ImeAction.Done), + textStyle = TextStyle(color = TextPrimary, fontSize = 15.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), + ) + + // Direct-link toggle (hotspot mode) + Row( + Modifier.fillMaxWidth(), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.SpaceBetween, + ) { + Column(Modifier.padding(end = 12.dp)) { + Text("Accept direct links", color = TextPrimary, fontSize = 15.sp, fontWeight = FontWeight.Medium) + Text( + when { + listenOn && localIp != null -> "Dialable at $localIp:${PartyQr.PARTY_UDP_PORT} — no internet needed" + listenOn -> "Waiting for a WiFi/hotspot address…" + else -> "Off — mesh routes via anchors only" + }, + color = if (listenOn) BitcoinOrange else TextMuted, + fontSize = 12.sp, + ) + } + Switch( + checked = listenOn, + onCheckedChange = { on -> + scope.launch { + prefs.setPartyListen(on) + FipsManager.requestMeshRestart(context) + } + }, + colors = SwitchDefaults.colors( + checkedTrackColor = BitcoinOrange, + checkedThumbColor = Color.White, + ), + ) + } + } + + GlassButton( + text = "Scan a Phone", + onClick = { showScanner = true }, + modifier = Modifier.fillMaxWidth().height(56.dp), + ) + + if (peers.isNotEmpty()) { + Text("PARTY PEERS", color = TextMuted, fontSize = 12.sp, letterSpacing = 2.sp) + peers.forEach { peer -> + Row( + Modifier + .fillMaxWidth() + .clip(RoundedCornerShape(14.dp)) + .background(CardBg) + .border(1.dp, CardBorder, RoundedCornerShape(14.dp)) + .clickable { onOpenChat() } + .padding(horizontal = 16.dp, vertical = 12.dp), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.SpaceBetween, + ) { + Column(Modifier.padding(end = 8.dp)) { + Text(peer.name, color = TextPrimary, fontSize = 15.sp, fontWeight = FontWeight.Medium) + Text( + peer.npub.take(14) + "…" + if (peer.ip.isNotBlank()) " · direct ${peer.ip}" else " · via mesh", + color = TextMuted, + fontSize = 11.sp, + ) + } + Text( + "✕", + color = TextMuted, + fontSize = 16.sp, + modifier = Modifier.clickable { + scope.launch { + prefs.removePartyPeer(peer.npub) + FipsManager.requestMeshRestart(context) + } + }.padding(8.dp), + ) + } + } + GlassButton( + text = "Open Flare Chat", + onClick = onOpenChat, + modifier = Modifier.fillMaxWidth().height(56.dp), + ) + } else { + Text( + "Scan another phone's party QR (or let them scan yours) to link your mesh nodes — works over 5G via anchors, or over any shared WiFi/hotspot with zero internet.", + color = TextMuted, + fontSize = 13.sp, + textAlign = TextAlign.Center, + modifier = Modifier.fillMaxWidth().padding(horizontal = 8.dp), + ) + } + Spacer(Modifier.height(12.dp)) + // Hand the app itself to a friend: shares this install's own APK + // through the system sheet (Quick Share/Bluetooth), so a nearby + // phone gets the companion with zero internet — the whole party + // premise. + GlassButton( + text = "Share this app", + onClick = { showShareQr = true }, + modifier = Modifier.fillMaxWidth().height(48.dp), + ) + Spacer(Modifier.height(12.dp)) + } + + // "Share this app" — a QR of the public download link, so the other + // phone scans it with its normal camera and installs over any + // internet. (The vps2 demo host serves the same APK the nodes do.) + AnimatedVisibility(visible = showShareQr, enter = fadeIn(), exit = fadeOut()) { + Box( + Modifier + .fillMaxSize() + .background(Color.Black.copy(alpha = 0.94f)) + .clickable { showShareQr = false }, + contentAlignment = Alignment.Center, + ) { + Column(horizontalAlignment = Alignment.CenterHorizontally) { + val dlQr = remember { renderQr(APP_DOWNLOAD_URL) } + dlQr?.let { bmp -> + Box( + Modifier + .clip(RoundedCornerShape(16.dp)) + .background(Color.White) + .padding(14.dp), + ) { + Image( + bitmap = bmp.asImageBitmap(), + contentDescription = "Companion download QR", + modifier = Modifier.size(240.dp), + ) + } + } + Spacer(Modifier.height(18.dp)) + Text( + "Scan with any camera to download\nthe Archipelago Companion", + color = TextPrimary, + fontSize = 15.sp, + textAlign = TextAlign.Center, + ) + Spacer(Modifier.height(10.dp)) + Text( + "…or send the APK file directly", + color = BitcoinOrange, + fontSize = 13.sp, + modifier = Modifier.clickable { shareCompanionApk(context) }.padding(8.dp), + ) + Spacer(Modifier.height(6.dp)) + Text("Close", color = TextMuted, fontSize = 14.sp, modifier = Modifier.clickable { showShareQr = false }.padding(8.dp)) + } + } + } + + // Party QR scanner overlay + AnimatedVisibility(visible = showScanner, enter = fadeIn(), exit = fadeOut()) { + Box(Modifier.fillMaxSize().background(Color.Black)) { + if (!hasCamera) { + Column( + Modifier.align(Alignment.Center).padding(horizontal = 32.dp), + horizontalAlignment = Alignment.CenterHorizontally, + verticalArrangement = Arrangement.spacedBy(14.dp), + ) { + Text( + "Camera access is needed to scan a party QR.", + color = TextPrimary, + fontSize = 15.sp, + textAlign = TextAlign.Center, + ) + GlassButton( + text = "Grant camera access", + onClick = { cameraPermLauncher.launch(android.Manifest.permission.CAMERA) }, + modifier = Modifier.fillMaxWidth().height(48.dp), + ) + } + } else CameraQrPreview(onDecoded = { text -> + val peer = PartyQr.parse(text) + when { + peer == null -> scanHint = "Not a mesh party QR" + peer.npub == identity?.npub -> scanHint = "That's your own QR" + else -> { + showScanner = false + scanHint = null + scope.launch { + prefs.upsertPartyPeer(peer) + // Pick up the direct-dial PeerConfig (and the + // listener, if ours is on) immediately. + FipsManager.requestMeshRestart(context) + // Pairing must be MUTUAL: announce ourselves so + // the scanned phone gets us as a peer + a chat + // entry without scanning back. Retried while + // the fresh link/session comes up. + val me = identity + if (me != null) { + launch(Dispatchers.IO) { + for (attempt in 0 until 6) { + val ok = FlareClient.sendHello( + peer = peer, + myNpub = me.npub, + myName = name.ifBlank { "Phone" }, + myUla = me.address, + myIp = if (listenOn) localIp else null, + myPort = PartyQr.PARTY_UDP_PORT, + ) + if (ok) break + delay(3_000) + } + } + } + onOpenChat() + } + } + } + }) + Box( + Modifier + .align(Alignment.Center) + .size(260.dp) + .border(2.dp, BitcoinOrange.copy(alpha = 0.85f), RoundedCornerShape(20.dp)), + ) + Text( + "Close", + color = TextPrimary, + fontSize = 16.sp, + modifier = Modifier + .align(Alignment.TopEnd) + .statusBarsPadding() + .clickable { showScanner = false } + .padding(20.dp), + ) + scanHint?.let { + Text( + it, + color = BitcoinOrange, + fontSize = 14.sp, + textAlign = TextAlign.Center, + modifier = Modifier + .align(Alignment.BottomCenter) + .padding(bottom = 48.dp) + .fillMaxWidth(), + ) + } + } + } + } + + // Scan hints fade so the camera feels live again. + LaunchedEffect(scanHint) { + if (scanHint != null) { + delay(2500) + scanHint = null + } + } +} + +/** Render a QR payload as a bitmap (dark modules on white). */ +private fun renderQr(payload: String, size: Int = 640): Bitmap? = try { + val matrix = QRCodeWriter().encode( + payload, + BarcodeFormat.QR_CODE, + size, + size, + mapOf(EncodeHintType.MARGIN to 1), + ) + val pixels = IntArray(size * size) + for (y in 0 until size) { + for (x in 0 until size) { + pixels[y * size + x] = if (matrix[x, y]) 0xFF0A0A0A.toInt() else 0xFFFFFFFF.toInt() + } + } + Bitmap.createBitmap(pixels, size, size, Bitmap.Config.ARGB_8888) +} catch (_: Exception) { + null +} + +/** Share this install's own APK via the system share sheet — a nearby friend + * gets the companion with no internet at all (Quick Share / Bluetooth). */ +private fun shareCompanionApk(context: android.content.Context) { + try { + val src = java.io.File(context.applicationInfo.sourceDir) + val dir = java.io.File(context.cacheDir, "share").apply { mkdirs() } + val out = java.io.File(dir, "archipelago-companion.apk") + src.copyTo(out, overwrite = true) + val uri = androidx.core.content.FileProvider.getUriForFile( + context, "${context.packageName}.fileprovider", out, + ) + val send = android.content.Intent(android.content.Intent.ACTION_SEND).apply { + type = "application/vnd.android.package-archive" + putExtra(android.content.Intent.EXTRA_STREAM, uri) + addFlags(android.content.Intent.FLAG_GRANT_READ_URI_PERMISSION) + } + context.startActivity( + android.content.Intent.createChooser(send, "Share Archipelago Companion") + .addFlags(android.content.Intent.FLAG_ACTIVITY_NEW_TASK), + ) + } catch (_: Exception) { + // No share targets / copy failed — nothing sensible to do here. + } +} 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..9af0ba08 --- /dev/null +++ b/Android/app/src/main/java/com/archipelago/app/ui/screens/RemoteInputScreen.kt @@ -0,0 +1,287 @@ +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.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +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.fips.FipsManager +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.QrScannerOverlay +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, + onMeshParty: (() -> Unit)? = null, + // Land on the keyboard instead of the gamepad (hub menu's Keyboard card). + startInKeyboard: Boolean = false, +) { + 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(!startInKeyboard) } + var showModal by remember { mutableStateOf(false) } + var showQrScanner 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 + } + fun toggleStyle() { + controllerStyle = if (controllerStyle == ControllerStyle.CLASSIC) ControllerStyle.DARK else ControllerStyle.CLASSIC + } + 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, + onToggleStyle = ::toggleStyle, + ) + 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, + onToggleStyle = ::toggleStyle, + ) + 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) }, + onThreeFingerHold = { 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 + style icons top-right in keyboard mode + Row( + Modifier.align(Alignment.TopEnd).padding(8.dp), + horizontalArrangement = Arrangement.spacedBy(8.dp), + ) { + com.archipelago.app.ui.components.SettingsBtn( + c = com.archipelago.app.ui.components.paletteFor(controllerStyle), + onClick = { showModal = true }, + ) + com.archipelago.app.ui.components.StyleBtn( + c = com.archipelago.app.ui.components.paletteFor(controllerStyle), + onClick = ::toggleStyle, + ) + } + } + } + } + + // 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, + 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) } + }, + onScanQr = { showQrScanner = true }, + 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() + } + } + }, + onRemote = { isGamepadMode = true; showModal = false }, + onKeyboard = { isGamepadMode = false; showModal = false }, + onBackToWebView = { showModal = false; onBack() }, + onMeshParty = onMeshParty?.let { open -> { showModal = false; open() } }, + ) + + // Pairing-QR scan launched from the menu's Add Server row. The menu stays + // open behind the scanner so the new entry appears as soon as it closes. + QrScannerOverlay( + visible = showQrScanner, + onDismiss = { showQrScanner = false }, + onServerScanned = { scan -> + showQrScanner = false + scope.launch { + val merged = prefs.upsertServer(scan.server) + FipsManager.registerNode(context, scan.fips, merged.displayName()) + if (activeServer == null) prefs.setActiveServer(merged) + } + }, + ) + } +} 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..1c22e67c --- /dev/null +++ b/Android/app/src/main/java/com/archipelago/app/ui/screens/ServerConnectScreen.kt @@ -0,0 +1,725 @@ +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.LaunchedEffect +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.PairResult +import com.archipelago.app.data.ServerEntry +import com.archipelago.app.data.ServerPreferences +import com.archipelago.app.fips.FipsManager +import com.archipelago.app.ui.components.MeshLoadingScreen +import com.archipelago.app.ui.components.QrScannerOverlay +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.delay +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 = {}, + // Prefill from a pairing deep link (archipelago://pair) that carried no + // password — opens the manual form on the password prompt for that server. + initialServer: ServerEntry? = null, +) { + 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) } + // Landing shows Scan/Manual choice; the form appears in manual mode or while editing. + var manualMode by remember { mutableStateOf(false) } + var showScanner by remember { mutableStateOf(false) } + + 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 { + var reachable = testConnection(server) + + // LAN address didn't answer — phone off-LAN (5G) or DHCP moved the + // node. The scanned IP was only ever a dial hint; the node's real + // identity is its npub and its ULA is reachable from anywhere over + // the mesh. Bring the tunnel up and probe the ULA before failing. + if (!reachable && server.meshIp.isNotBlank()) { + FipsManager.autoStartIfReady(context) + val meshServer = server.copy( + address = server.meshIp, + useHttps = false, + port = "", + ) + // Mesh discovery + first session can take 15s+ through the + // public tree (per node diagnosis), and on a + // first-ever pairing the VPN consent dialog is on screen at + // the same time — so probe patiently inside a 60s budget with + // per-attempt timeouts wide enough to ride out TCP + // retransmit backoff. The VPN service pre-warms the session + // in parallel (ArchyVpnService.startSessionWarmer). + val deadline = System.currentTimeMillis() + 60_000 + while (!reachable && System.currentTimeMillis() < deadline) { + reachable = testConnection(meshServer, timeoutMs = 15_000) + if (!reachable) delay(3000) + } + } + isConnecting = false + + if (reachable) { + prefs.setActiveServer(server) + onConnected(server.toUrl()) + } else { + errorMessage = context.getString(R.string.connection_failed) + } + } + } + + fun prefill(server: ServerEntry) { + name = server.name + address = server.address + port = server.port + password = server.password + useHttps = server.useHttps + } + + // Pairing QR scanned: dedupe against saved servers, then either auto-connect + // (payload carried a credential — demo password or a real node's device + // token) or land on the password prompt with everything else filled in. + // Mesh info (when present) is registered so the FIPS tunnel comes up too. + fun onQrScanned(scan: PairResult.Success) { + showScanner = false + scope.launch { + val merged = prefs.upsertServer(scan.server) + FipsManager.registerNode(context, scan.fips, merged.displayName()) + prefill(merged) + if (merged.password.isNotBlank()) { + connect(merged) + } else { + manualMode = true + } + } + } + + LaunchedEffect(initialServer) { + if (initialServer != null) { + prefill(prefs.upsertServer(initialServer)) + manualMode = 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 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, + // Center the content vertically — the landing (logo + two buttons) is + // short and looks stranded at the top otherwise. Taller content (the + // manual form, saved servers) still scrolls from the top as normal. + verticalArrangement = Arrangement.spacedBy(16.dp, Alignment.CenterVertically), + ) { + // 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, + ) + + val showForm = manualMode || editingServer != null + + Text( + text = if (showForm) stringResource(R.string.server_address_hint) else stringResource(R.string.connect_landing_hint), + style = MaterialTheme.typography.bodyMedium, + color = TextMuted, + textAlign = TextAlign.Center, + ) + + Spacer(modifier = Modifier.height(4.dp)) + + if (!showForm) { + // Landing: scan the pairing QR, or fall back to manual entry + GlassButton( + text = stringResource(R.string.scan_node_qr), + onClick = { showScanner = true }, + modifier = Modifier.fillMaxWidth().height(56.dp), + ) + GlassButton( + text = stringResource(R.string.enter_manually), + onClick = { + errorMessage = null + manualMode = true + }, + modifier = Modifier.fillMaxWidth().height(56.dp), + ) + } + + // Glass card with form + if (showForm) 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 if (manualMode) { + // Back to the Scan/Manual landing + Connect + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.spacedBy(12.dp), + ) { + GlassButton( + text = stringResource(R.string.back), + onClick = { + keyboard?.hide() + manualMode = false + clearForm() + }, + modifier = Modifier.weight(1f).height(56.dp), + ) + 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.weight(2f).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) } }, + ) + } + } + } + + QrScannerOverlay( + visible = showScanner, + onDismiss = { showScanner = false }, + onServerScanned = { onQrScanned(it) }, + ) + + // Full-screen branded loader while the first connect runs — most + // visibly right after a pairing-QR scan, when the mesh may still be + // establishing (LAN probe → tunnel up → ULA probe can take a while). + // The small inline spinner stays for context; this owns the screen. + if (isConnecting) { + MeshLoadingScreen() + } + } +} + +@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. + * [timeoutMs] is per-phase (connect / read) — mesh probes need far more + * patience than LAN ones (first session through the tree can take 15s+). */ +private suspend fun testConnection(server: ServerEntry, timeoutMs: Int = 5000): 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 = timeoutMs + connection.readTimeout = timeoutMs + 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..dbf90741 --- /dev/null +++ b/Android/app/src/main/java/com/archipelago/app/ui/screens/WebViewScreen.kt @@ -0,0 +1,1369 @@ +package com.archipelago.app.ui.screens + +import android.Manifest +import android.annotation.SuppressLint +import android.content.pm.PackageManager +import android.graphics.Bitmap +import android.graphics.BitmapFactory +import android.view.ViewGroup +import android.webkit.CookieManager +import android.webkit.PermissionRequest +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.activity.compose.rememberLauncherForActivityResult +import androidx.activity.result.contract.ActivityResultContracts +import androidx.core.content.ContextCompat +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.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.WindowInsets +import androidx.compose.foundation.layout.WindowInsetsSides +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.navigationBars +import androidx.compose.foundation.layout.only +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.safeDrawing +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.layout.statusBars +import androidx.compose.foundation.layout.width +import androidx.compose.foundation.layout.windowInsetsBottomHeight +import androidx.compose.foundation.layout.windowInsetsPadding +import androidx.compose.foundation.layout.windowInsetsTopHeight +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.collectAsState +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableIntStateOf +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.rememberCoroutineScope +import androidx.compose.runtime.setValue +import androidx.compose.runtime.snapshotFlow +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.asImageBitmap +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.SpanStyle +import androidx.compose.ui.text.buildAnnotatedString +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.text.style.TextAlign +import androidx.compose.ui.text.style.TextOverflow +import androidx.compose.ui.text.withStyle +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp +import androidx.compose.ui.viewinterop.AndroidView +import android.webkit.ValueCallback +import com.archipelago.app.R +import com.archipelago.app.data.ServerPreferences +import com.archipelago.app.fips.FipsManager +import com.archipelago.app.ui.components.GestureHintOverlay +import com.archipelago.app.ui.components.MeshLoadingScreen +import com.archipelago.app.ui.components.NESMenu +import com.archipelago.app.ui.components.QrScannerOverlay +import com.archipelago.app.ui.components.WalletQrScannerModal +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.TextMuted +import com.archipelago.app.ui.theme.TextPrimary +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.delay +import kotlinx.coroutines.flow.first +import kotlinx.coroutines.launch +import kotlinx.coroutines.withContext +import org.json.JSONObject + +/** 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 + } +} + +/** Kiosk WebView retained across navigation (remote ⇄ dashboard) so leaving + * the kiosk and coming back reattaches the LIVE page — no reload, no + * re-login, no reconnect. Dropped on retry/disconnect/server change. */ +private object KioskWebView { + var instance: WebView? = null + var url: String? = null + + // Live-composition delegates for the JS bridges (see the factory) — the + // registered interface objects call through these, so reattaching the + // retained view re-points them instead of leaving stale closures. + var onRouteOutbound: (String) -> Unit = {} + var onOpenInApp: (String) -> Unit = {} + var onQrOpen: () -> Unit = {} + var onQrStatus: (String, Boolean) -> Unit = { _, _ -> } + var onQrClose: () -> Unit = {} + + fun drop() { + instance?.let { + (it.parent as? ViewGroup)?.removeView(it) + it.destroy() + } + instance = null + url = null + } +} + +/** Inject the safe-area CSS vars from the CURRENT window insets. Android + * WebView doesn't populate env(safe-area-inset-*); worse, on a cold start + * onPageFinished can run before the view is attached — rootWindowInsets is + * null then, and injecting 0px collapsed the UI's top/bottom margins (and + * put the tab bar inside the gesture zone, killing its taps). Called from + * onPageFinished, from the window-insets listener (fires when real insets + * arrive), and on reattach. */ +private fun injectSafeAreaVars(view: WebView) { + val insets = view.rootWindowInsets ?: return // listener re-fires when real + val density = view.resources.displayMetrics.density + val sat = (insets.getInsets(android.view.WindowInsets.Type.statusBars()).top / density).toInt() + val sab = (insets.getInsets(android.view.WindowInsets.Type.navigationBars()).bottom / density).toInt() + 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; }'; + // Vue components sample the var into reactive state; tell them it + // changed (an authenticated session can mount before we run). + window.dispatchEvent(new CustomEvent('archy-insets', { detail: { top: ${sat}, bottom: ${sab} } })); + })(); + """.trimIndent(), + null, + ) +} + +/** In-app browser pages (node apps + same-node links) don't consume the + * neode-ui `--safe-area-top` var, so with the WebView drawing edge-to-edge + * their content ran up under the status bar. Pad the document body down by + * the status-bar height: the padded strip shows the page's OWN background + * (padding is inside the element), so the bar keeps the page colour while + * content starts below it — the pre-edge-to-edge look, without the black bar. + * + * Body padding only moves normal-flow content. fixed/sticky elements anchored + * at the viewport top (IndeeHub's floating header) stayed glued under the + * status bar, so we also push each of those down by the inset — once, marked + * via data attribute — and keep a throttled MutationObserver running so + * headers an SPA mounts after load get the same treatment. + * Idempotent; runs on start (early) and finish (after the app rewrites head). */ +private fun injectTopInset(view: WebView) { + val insets = view.rootWindowInsets ?: return + val density = view.resources.displayMetrics.density + val sat = (insets.getInsets(android.view.WindowInsets.Type.statusBars()).top / density).toInt() + if (sat <= 0) return + view.evaluateJavascript( + """ + (function() { + var SAT = $sat; + var s = document.getElementById('archy-top-inset'); + if (!s) { + s = document.createElement('style'); + s.id = 'archy-top-inset'; + (document.head || document.documentElement).appendChild(s); + } + s.textContent = + 'body{padding-top:' + SAT + 'px!important;box-sizing:border-box!important;}'; + function push(el) { + if (el.dataset.archyInset) return; + var cs = getComputedStyle(el); + if (cs.position !== 'fixed' && cs.position !== 'sticky') return; + var top = parseFloat(cs.top); // 'auto' -> NaN skips bottom bars + if (isNaN(top) || top >= SAT) return; + el.style.setProperty('top', (top + SAT) + 'px', 'important'); + el.dataset.archyInset = '1'; + } + function sweep() { + if (!document.body) return; + // Fixed/sticky bars live shallow in the tree (portals mount on + // body); depth cap keeps the computed-style pass off big lists. + var els = document.body.querySelectorAll( + 'body > *, body > * > *, body > * > * > *, body > * > * > * > *'); + for (var i = 0; i < els.length; i++) push(els[i]); + } + sweep(); + if (!window.__archyInsetObserver) { + var queued = false, last = 0; + window.__archyInsetObserver = new MutationObserver(function() { + if (queued) return; + queued = true; + var wait = Math.max(0, 250 - (Date.now() - last)); + setTimeout(function() { + queued = false; + last = Date.now(); + sweep(); + }, wait); + }); + window.__archyInsetObserver.observe(document.documentElement, + { childList: true, subtree: true }); + } + })(); + """.trimIndent(), + null, + ) +} + +/** True when a TCP listener answers at [base]'s host:port within [timeoutMs]. */ +private fun tcpAnswers(base: String, timeoutMs: Int): Boolean = try { + val u = android.net.Uri.parse(base) + val port = if (u.port != -1) u.port else if (u.scheme == "https") 443 else 80 + java.net.Socket().use { + it.connect(java.net.InetSocketAddress(u.host, port), timeoutMs) + true + } +} catch (_: Exception) { + false +} + +/** Fastest answering origin: LAN inside a short window, else the mesh ULA + * (patient — a cold session may still be establishing). If NEITHER answers, + * fall back to the mesh URL when we have one — off-LAN the LAN IP is + * unreachable, and loading it just produced a confusing "can't reach + * 192.168.x.x" error page (user-reported 2026-07-27). Targeting the mesh URL + * instead means the load retries against the path that's actually coming up, + * and any error shows the mesh address rather than a dead LAN IP. */ +private suspend fun pickStartUrl(lanUrl: String, meshUrl: String?): String = + withContext(Dispatchers.IO) { + if (tcpAnswers(lanUrl, 2500)) return@withContext lanUrl + if (meshUrl != null && tcpAnswers(meshUrl, 12_000)) return@withContext meshUrl + meshUrl ?: lanUrl + } + +/** 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 = {}, + // Like onRemoteInput but landing on the keyboard (the hub menu's Keyboard card). + onRemoteKeyboard: () -> Unit = {}, + // Opens the phone-to-phone Mesh Party screen; null hides its hub card. + onMeshParty: (() -> Unit)? = null, + // Stored password for this server (from QR pairing or manual entry). When + // non-blank, the login page is auto-filled and submitted — the one-step + // demo flow from docs/companion-pairing-qr.md. + serverPassword: String = "", + // Node's FIPS mesh URL (http://[fd…]). When the primary address fails on + // a main-frame load — typically the phone left the LAN — retry there + // before surfacing the error page: the mesh tunnel works from anywhere. + meshFallbackUrl: String? = null, +) { + var isLoading by remember { mutableStateOf(true) } + // First kiosk load (often over the FIPS mesh) gets the full branded + // loader; later navigations keep just the slim top progress bar. + var firstLoadDone by remember { mutableStateOf(false) } + LaunchedEffect(Unit) { + snapshotFlow { isLoading }.first { !it } + firstLoadDone = true + } + var loadProgress by remember { mutableIntStateOf(0) } + var triedMeshFallback by remember { mutableStateOf(false) } + var hasError by remember { mutableStateOf(false) } + var webView by remember { mutableStateOf(null) } + + // Race LAN vs mesh BEFORE the WebView exists: Chromium pointed at an + // unreachable LAN IP burns a minute+ in connect retries before + // onReceivedError fires the mesh fallback — the "stuck connecting" + // stall. A raw TCP probe answers in milliseconds at home and fails in + // ~2.5s off-LAN, so startup lands on the right origin in seconds. + var startUrl by remember(serverUrl) { mutableStateOf(null) } + var raceNonce by remember { mutableIntStateOf(0) } + LaunchedEffect(serverUrl, meshFallbackUrl, raceNonce) { + // A retained live session exists — reattach instantly: no race, no + // reload, no re-login (remote ⇄ dashboard round trip). + if (KioskWebView.instance != null && KioskWebView.url == serverUrl) { + isLoading = false + startUrl = serverUrl + return@LaunchedEffect + } + val picked = pickStartUrl(serverUrl, meshFallbackUrl) + // Starting on the mesh: don't bounce back to it on error (it IS it). + if (picked != serverUrl) triedMeshFallback = true + startUrl = picked + } + + // Web-page camera access (wallet QR scanner). The WebView's default + // WebChromeClient silently denies getUserMedia, so grant video capture — + // asking for the app-level CAMERA permission first when needed. + val webViewContext = LocalContext.current + var pendingWebPermission by remember { mutableStateOf(null) } + val webCameraPermissionLauncher = rememberLauncherForActivityResult( + ActivityResultContracts.RequestPermission(), + ) { granted -> + pendingWebPermission?.let { req -> + if (granted) req.grant(arrayOf(PermissionRequest.RESOURCE_VIDEO_CAPTURE)) else req.deny() + } + pendingWebPermission = 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) } + + // Same node = EITHER of its addresses. Over the mesh the kiosk's host is + // the ULA while app links may carry the LAN IP (and vice versa) — + // comparing against one host bounced same-node apps (Pine, Home + // Assistant, BTCPay) out to the phone's external browser. + fun isSameNode(url: String): Boolean = + isSameHost(url, serverUrl) || + (meshFallbackUrl != null && isSameHost(url, meshFallbackUrl)) + + // Native wallet QR scanner, opened by the web UI via the ArchipelagoQr + // bridge; status lines stream back from the page while it's up. + var walletScannerVisible by remember { mutableStateOf(false) } + var walletScannerStatus by remember { mutableStateOf?>(null) } + + // One-time three-finger-hold teaching overlay (initial=true: never flash + // it while DataStore is still loading). + val prefs = remember { ServerPreferences(webViewContext) } + + // Hub menu overlay state — the three-finger hold opens the menu right here + // over the dashboard (it used to jump to the remote screen). + val savedServers by prefs.savedServers.collectAsState(initial = emptyList()) + val activeServer by prefs.activeServer.collectAsState(initial = null) + var showHubMenu by remember { mutableStateOf(false) } + var showPairScanner by remember { mutableStateOf(false) } + + val gestureHintSeen by prefs.gestureHintSeen.collectAsState(initial = true) + var gestureHintDismissed by remember { mutableStateOf(false) } + // Don't teach the gesture on top of the login/splash — arm the overlay + // ~2 minutes after the kiosk first finishes loading, once the user has + // settled in. + var gestureHintReady by remember { mutableStateOf(false) } + LaunchedEffect(Unit) { + snapshotFlow { isLoading }.first { !it } + delay(120_000) + gestureHintReady = true + } + val scope = rememberCoroutineScope() + + // support — without a chooser implementation the + // WebView silently ignores file inputs (broke the wallet's upload path). + var pendingFileChooser by remember { mutableStateOf>?>(null) } + val fileChooserLauncher = rememberLauncherForActivityResult( + ActivityResultContracts.StartActivityForResult(), + ) { result -> + pendingFileChooser?.onReceiveValue( + WebChromeClient.FileChooserParams.parseResult(result.resultCode, result.data), + ) + pendingFileChooser = 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 = { + // Re-race LAN vs mesh — the network we're on may have + // changed since the last pick. Drop the retained view: + // an errored session must genuinely reload. + KioskWebView.drop() + webView = null + hasError = false + isLoading = true + triedMeshFallback = false + startUrl = null + raceNonce++ + }, + modifier = Modifier.fillMaxWidth().height(56.dp), + ) + + Spacer(modifier = Modifier.height(12.dp)) + + GlassButton( + text = stringResource(R.string.disconnect), + onClick = { + KioskWebView.drop() + onDisconnect() + }, + modifier = Modifier.fillMaxWidth().height(48.dp), + ) + } + } else if (startUrl == null) { + // Racing LAN vs mesh (≤2.5s at home, a few seconds off-LAN) — + // far cheaper than letting Chromium retry a dead LAN IP. + MeshLoadingScreen() + } else { + // Edge-to-edge WebView — background bleeds behind status bar. + // Safe area values injected as CSS env() polyfill on each page load. + val initialUrl = startUrl ?: serverUrl + AndroidView( + modifier = Modifier.fillMaxSize(), + factory = { context -> + // Reattach the retained kiosk WebView (remote ⇄ dashboard + // must not reload the node UI). Everything configured + // below is idempotent, and re-running it rebinds clients, + // bridges and listeners to THIS composition's state — + // stale closures from the previous visit are replaced. + if (KioskWebView.url != serverUrl) KioskWebView.drop() + val reused = KioskWebView.instance + (reused ?: WebView(context)).apply { + (parent as? ViewGroup)?.removeView(this) + 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 + + // Re-inject the safe-area vars whenever REAL insets + // arrive — on cold start onPageFinished often beats + // window attachment and would otherwise bake in 0px. + setOnApplyWindowInsetsListener { v, insets -> + (v as? WebView)?.let { injectSafeAreaVars(it) } + v.onApplyWindowInsets(insets) + } + + // 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 (isSameNode(url)) { + inAppUrl = url + } else { + openExternalUrl(context, url) + } + } + + // Bridge callbacks are DELEGATED through the holder: + // the interface objects registered on a retained + // WebView survive recomposition, and re-registering + // them doesn't reliably swap the JS-visible object + // without a reload — with direct closures, a + // remote ⇄ dashboard round trip left the bridges + // writing to a dead composition's state ("apps don't + // launch"). These assignments re-point the live + // interface objects at THIS composition every attach. + KioskWebView.onRouteOutbound = { url -> routeOutbound(url) } + KioskWebView.onOpenInApp = { url -> inAppUrl = url } + KioskWebView.onQrOpen = { + walletScannerStatus = null + walletScannerVisible = true + } + KioskWebView.onQrStatus = { msg, err -> walletScannerStatus = msg to err } + KioskWebView.onQrClose = { walletScannerVisible = false } + + // 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. + if (reused == null) addJavascriptInterface( + object { + @android.webkit.JavascriptInterface + fun openExternal(url: String) { + webViewRef.post { KioskWebView.onRouteOutbound(url) } + } + + @android.webkit.JavascriptInterface + fun openInApp(url: String) { + webViewRef.post { KioskWebView.onOpenInApp(url) } + } + }, + "ArchipelagoNative", + ) + + // Wallet QR bridge. The web scan modal calls: + // window.ArchipelagoQr.open() — show the native scanner + // window.ArchipelagoQr.setStatus(msg, e) — mirror status/progress lines + // window.ArchipelagoQr.close() — code accepted, tear down + // Decodes flow back through window.__archyQrResult(text); + // a user cancel calls window.__archyQrCancelled(). + if (reused == null) addJavascriptInterface( + object { + @android.webkit.JavascriptInterface + fun open() { + webViewRef.post { KioskWebView.onQrOpen() } + } + + @android.webkit.JavascriptInterface + fun setStatus(message: String, isError: Boolean) { + webViewRef.post { KioskWebView.onQrStatus(message, isError) } + } + + @android.webkit.JavascriptInterface + fun close() { + webViewRef.post { KioskWebView.onQrClose() } + } + }, + "ArchipelagoQr", + ) + + 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 + + injectSafeAreaVars(view) + + // Auto-login with the stored password (QR pairing / + // saved server) — only on our own server's pages + // (LAN origin or the mesh address). + val onOwnServer = url != null && ( + url.startsWith(serverUrl) || + (meshFallbackUrl != null && url.startsWith(meshFallbackUrl)) + ) + if (serverPassword.isNotBlank() && onOwnServer) { + view.evaluateJavascript(buildAutoLoginScript(serverPassword), null) + } + } + + override fun onReceivedError( + view: WebView?, + request: WebResourceRequest?, + error: WebResourceError?, + ) { + if (request?.isForMainFrame == true) { + val mesh = meshFallbackUrl + if (mesh != null && !triedMeshFallback && + request.url?.toString()?.startsWith(mesh) != true + ) { + triedMeshFallback = true + isLoading = true + view?.loadUrl(mesh) + return + } + 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 && isSameNode(u)) { + 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 + // Mesh address is the same server too + if (meshFallbackUrl != null && url.startsWith(meshFallbackUrl)) 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 + } + + override fun onShowFileChooser( + view: WebView?, + filePathCallback: ValueCallback>?, + fileChooserParams: FileChooserParams?, + ): Boolean { + pendingFileChooser?.onReceiveValue(null) + pendingFileChooser = filePathCallback + val intent = fileChooserParams?.createIntent() + if (intent == null) { + pendingFileChooser = null + return false + } + return try { + fileChooserLauncher.launch(intent) + true + } catch (_: Exception) { + pendingFileChooser = null + false + } + } + + // Wallet QR scanner: grant the page camera access. + // Only video capture is granted — anything else the + // page asks for is denied as before. + override fun onPermissionRequest(request: PermissionRequest) { + if (PermissionRequest.RESOURCE_VIDEO_CAPTURE !in request.resources) { + request.deny() + return + } + val hasCamera = ContextCompat.checkSelfPermission( + webViewContext, Manifest.permission.CAMERA, + ) == PackageManager.PERMISSION_GRANTED + if (hasCamera) { + request.grant(arrayOf(PermissionRequest.RESOURCE_VIDEO_CAPTURE)) + } else { + pendingWebPermission = request + webCameraPermissionLauncher.launch(Manifest.permission.CAMERA) + } + } + + // 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 + } + } + + // Three-finger hold (500ms) → open the hub menu overlay + // in place (Remote/Keyboard cards do the navigating). + // Three fingers, not two: two-finger scroll/pinch on the + // page collided with the old two-finger hold. + var threeFingerStart = 0L + var threeFingerFired = false + setOnTouchListener { _, event -> + val pointerCount = event.pointerCount + when (event.actionMasked) { + android.view.MotionEvent.ACTION_POINTER_DOWN -> { + if (pointerCount >= 3) { + threeFingerStart = System.currentTimeMillis() + threeFingerFired = false + } + } + android.view.MotionEvent.ACTION_MOVE -> { + if (pointerCount >= 3 && !threeFingerFired && threeFingerStart > 0) { + if (System.currentTimeMillis() - threeFingerStart > 500) { + threeFingerFired = true + showHubMenu = true + } + } + } + android.view.MotionEvent.ACTION_UP, + android.view.MotionEvent.ACTION_POINTER_UP, + android.view.MotionEvent.ACTION_CANCEL -> { + if (event.pointerCount <= 3) { + threeFingerStart = 0L + } + } + } + false // don't consume — let WebView handle normally + } + + webView = this + if (reused == null) { + KioskWebView.instance = this + KioskWebView.url = serverUrl + loadUrl(initialUrl) + } else { + // Reattached views keep stale measurements until an + // input event — that was the top/bottom UI being + // wrong until a tap. Force a fresh pass, and re-sync + // the page's safe-area vars while we're at it. + post { + requestLayout() + invalidate() + injectSafeAreaVars(this) + } + } + } + }, + ) + + // Loading bar at top edge + AnimatedVisibility( + visible = isLoading, + enter = fadeIn(), + exit = fadeOut(), + ) { + LinearProgressIndicator( + progress = { loadProgress / 100f }, + modifier = Modifier.fillMaxWidth(), + color = BitcoinOrange, + trackColor = SurfaceBlack, + ) + } + + // Branded first-load screen while the mesh session comes up. + AnimatedVisibility( + visible = isLoading && !firstLoadDone, + enter = fadeIn(), + exit = fadeOut(), + ) { + Column( + Modifier.fillMaxSize().background(SurfaceBlack), + horizontalAlignment = Alignment.CenterHorizontally, + verticalArrangement = Arrangement.Center, + ) { + Text( + buildAnnotatedString { + withStyle(SpanStyle(color = ErrorRed)) { append("F*CK") } + withStyle(SpanStyle(color = TextPrimary)) { append(" IPS") } + }, + fontSize = 40.sp, + fontWeight = FontWeight.Black, + letterSpacing = 4.sp, + textAlign = TextAlign.Center, + ) + Spacer(Modifier.height(10.dp)) + Text( + "connecting to your archipelago", + color = TextMuted, + fontSize = 13.sp, + letterSpacing = 1.sp, + ) + Spacer(Modifier.height(28.dp)) + CircularProgressIndicator(color = BitcoinOrange) + } + } + + // 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, + meshUrl = meshFallbackUrl, + onClose = { inAppUrl = null }, + ) + } + + // Native wallet QR scanner, opened by the page via ArchipelagoQr. + WalletQrScannerModal( + visible = walletScannerVisible, + status = walletScannerStatus, + onDecoded = { text -> + webView?.evaluateJavascript( + "window.__archyQrResult && window.__archyQrResult(${JSONObject.quote(text)})", + null, + ) + }, + onDismiss = { + walletScannerVisible = false + webView?.evaluateJavascript( + "window.__archyQrCancelled && window.__archyQrCancelled()", + null, + ) + }, + ) + + // First-launch teaching overlay for the three-finger hold — armed + // ~2 minutes after login so it never fights the splash/first look. + if (gestureHintReady && !gestureHintSeen && !gestureHintDismissed && + !isLoading && inAppUrl == null + ) { + GestureHintOverlay( + onDismiss = { + gestureHintDismissed = true + scope.launch { prefs.markGestureHintSeen() } + }, + ) + } + } + + // Hub menu overlay — opened by the three-finger hold, drawn above + // everything (also reachable from the error screen, where switching + // servers is exactly what's needed). + NESMenu( + visible = showHubMenu, + servers = savedServers, + activeServer = activeServer, + onDismiss = { showHubMenu = false }, + onSelectServer = { server -> + showHubMenu = false + scope.launch { prefs.setActiveServer(server) } + }, + onAddServer = { server -> + scope.launch { + prefs.addSavedServer(server) + if (activeServer == null) prefs.setActiveServer(server) + } + }, + onScanQr = { showPairScanner = true }, + onEditServer = { original, updated -> + scope.launch { + prefs.updateSavedServer(original, updated) + // Editing the live server reloads the kiosk with the new + // address/credentials via the activeServer recomposition. + if (original.serialize() == activeServer?.serialize()) { + prefs.setActiveServer(updated) + } + } + }, + onRemoveServer = { server -> + scope.launch { + prefs.removeSavedServer(server) + // Nothing left to show — back to the Connect screen. + val remaining = savedServers.count { it.serialize() != server.serialize() } + if (remaining == 0) { + prefs.clearActiveServer() + showHubMenu = false + onDisconnect() + } + } + }, + onRemote = { showHubMenu = false; onRemoteInput() }, + onKeyboard = { showHubMenu = false; onRemoteKeyboard() }, + onBackToWebView = { showHubMenu = false }, + onMeshParty = onMeshParty?.let { open -> { showHubMenu = false; open() } }, + ) + + // Pairing-QR scan launched from the menu's Nodes page; the menu stays + // open behind it so the new entry appears as soon as it closes. + QrScannerOverlay( + visible = showPairScanner, + onDismiss = { showPairScanner = false }, + onServerScanned = { scan -> + showPairScanner = false + scope.launch { + val merged = prefs.upsertServer(scan.server) + FipsManager.registerNode(webViewContext, scan.fips, merged.displayName()) + if (activeServer == null) prefs.setActiveServer(merged) + } + }, + ) + } +} + +/** 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, + meshUrl: String? = null, + onClose: () -> Unit, +) { + val context = LocalContext.current + // Same-node check across BOTH node addresses (LAN + mesh ULA) — see the + // kiosk's isSameNode; a mismatch here bounced app links to the browser. + fun isSameNode(u: String): Boolean = + isSameHost(u, serverUrl) || (meshUrl != null && isSameHost(u, meshUrl)) + var browser by remember { mutableStateOf(null) } + // Loader title: never show a raw IP host — a mesh ULA like + // [fd79:1aa:…] is technically the host but reads as garbage on the + // loading screen. Show a neutral name until the page reports its + // real (onReceivedTitle upgrades it). + var title by remember { + mutableStateOf( + android.net.Uri.parse(url).host + ?.takeUnless { it.contains(':') || it.matches(Regex("^\\d+(\\.\\d+){3}$")) } + ?: "Archipelago", + ) + } + var favicon by remember { mutableStateOf<Bitmap?>(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) } + + // Same camera bridge as the main WebView — node apps opened in the overlay + // (e.g. anything with a QR scanner) get getUserMedia too. + var pendingWebPermission by remember { mutableStateOf<PermissionRequest?>(null) } + val webCameraPermissionLauncher = rememberLauncherForActivityResult( + ActivityResultContracts.RequestPermission(), + ) { granted -> + pendingWebPermission?.let { req -> + if (granted) req.grant(arrayOf(PermissionRequest.RESOURCE_VIDEO_CAPTURE)) else req.deny() + } + pendingWebPermission = null + } + + // 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 <head> (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) + // Whole-overlay touch shield: every touch not handled by a child + // (control-bar gaps, inset strips) dies here instead of falling + // through to the kiosk's tab bar behind (a near-miss on Close + // was opening the AIUI tab underneath). + .clickable( + interactionSource = remember { MutableInteractionSource() }, + indication = null, + onClick = {}, + ) + // Bottom inset handled by the touch-shield strip below the bar. + // No TOP inset padding: the WebView draws edge-to-edge behind the + // status bar so the app's own background fills it — the padded + // version painted an opaque black bar there (user-rejected look). + .windowInsetsPadding( + WindowInsets.safeDrawing.only(WindowInsetsSides.Horizontal) + ), + ) { + // 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 + } + + override fun onPermissionRequest(request: PermissionRequest) { + if (PermissionRequest.RESOURCE_VIDEO_CAPTURE !in request.resources) { + request.deny() + return + } + val hasCamera = ContextCompat.checkSelfPermission( + context, Manifest.permission.CAMERA, + ) == PackageManager.PERMISSION_GRANTED + if (hasCamera) { + request.grant(arrayOf(PermissionRequest.RESOURCE_VIDEO_CAPTURE)) + } else { + pendingWebPermission = request + webCameraPermissionLauncher.launch(Manifest.permission.CAMERA) + } + } + } + + webViewClient = object : WebViewClient() { + override fun onPageStarted(view: WebView?, u: String?, favicon: Bitmap?) { + loading = true + view?.let { injectTopInset(it) } + } + + override fun onPageFinished(view: WebView?, u: String?) { + loading = false + canGoBack = view?.canGoBack() == true + canGoForward = view?.canGoForward() == true + view?.let { injectTopInset(it) } + } + + 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 && isSameNode(u)) { + 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 (isSameNode(u)) 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, + ) + } + } + + // Touch-shield over the gesture-nav strip: solid black AND consumes + // taps — stray touches below the control bar landed on the kiosk's + // tab bar behind this overlay (opening the AIUI chat by accident). + Box( + Modifier + .fillMaxWidth() + .windowInsetsBottomHeight(WindowInsets.navigationBars) + .background(Color.Black) + .clickable( + interactionSource = remember { MutableInteractionSource() }, + indication = null, + onClick = {}, + ), + ) + } +} + +/** + * JS that fills the web UI login form (Login.vue's #login-password) with the + * stored password and submits it once the form is interactive — the one-step + * pairing flow. No-ops when the login step never appears (already + * authenticated, first-boot setup, TOTP). At most two attempts per page load, + * then it stops for good so a wrong stored password can't spam the node. + */ +private fun buildAutoLoginScript(password: String): String { + val quoted = org.json.JSONObject.quote(password) + return """ + (function () { + if (window.__archyAutoLogin) return; + window.__archyAutoLogin = true; + var pw = $quoted; + var attempts = 0; + var started = Date.now(); + var timer = setInterval(function () { + if (Date.now() - started > 45000) { clearInterval(timer); return; } + var el = document.getElementById('login-password'); + if (!el) { + // Field gone after we submitted = success or step change - stop. + if (attempts > 0) clearInterval(timer); + return; + } + if (el.disabled) return; // form waits on serverReady + if (attempts >= 2) { clearInterval(timer); return; } + attempts++; + var setter = Object.getOwnPropertyDescriptor(window.HTMLInputElement.prototype, 'value').set; + setter.call(el, pw); + el.dispatchEvent(new Event('input', { bubbles: true })); + // Let Vue re-render before submitting: a synchronous Enter arrives + // while the login button is still disabled, and the web UI's + // controller-nav "Enter in input clicks the next enabled button" + // pattern then hits Replay Intro instead — restarting the intro + // cinematic on every connect (two frames = value flush + render). + requestAnimationFrame(function () { + requestAnimationFrame(function () { + el.dispatchEvent(new KeyboardEvent('keydown', { key: 'Enter', bubbles: true })); + }); + }); + }, 1500); + })(); + """.trimIndent() +} 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 @@ +<?xml version="1.0" encoding="utf-8"?> +<!-- Whole badge lives here (background renders to the mask edge with no + safe-zone cropping, unlike the foreground): dark fill + metallic ring pulled + inward to ~0.88 so the mask can't clip it + grid at ~0.58. Matches the + locally-rendered preview. Foreground is transparent. --> +<vector xmlns:android="http://schemas.android.com/apk/res/android" + xmlns:aapt="http://schemas.android.com/aapt" + android:width="108dp" + android:height="108dp" + android:viewportWidth="752" + android:viewportHeight="752"> + + <path + android:fillColor="#0A0A0A" + android:pathData="M0,0h752v752H0z" /> + + <!-- Ring matching logo.svg's gradient (#000->#666). Scale 0.65 places it at + the home-screen's visible edge (calibrated from a device home screenshot; + launcher3 crops less than the Settings App-info view). --> + <group + android:pivotX="376" + android:pivotY="376" + android:scaleX="0.65" + android:scaleY="0.65"> + <path + android:fillColor="#00000000" + android:strokeWidth="22.8834" + android:pathData="M11.441,375.669a364.227,364.227 0 1,0 728.454,0a364.227,364.227 0 1,0 -728.454,0z"> + <aapt:attr name="android:strokeColor"> + <gradient + android:type="linear" + android:startX="751.337" + android:startY="751.338" + android:endX="0" + android:endY="0.000976562"> + <item android:offset="0" android:color="#FF000000" /> + <item android:offset="1" android:color="#FF666666" /> + </gradient> + </aapt:attr> + </path> + </group> + + <!-- White Archipelago grid --> + <group + android:pivotX="376" + android:pivotY="376" + android:scaleX="0.55" + android:scaleY="0.55"> + <path + android:fillColor="#FFFFFF" + android:pathData="M253.805,278.37V222.28H309.853V278.37H253.805ZM315.797,278.37V222.28H372.694V278.37H315.797ZM378.639,278.37V222.28H435.536V278.37H378.639ZM441.481,278.37V222.28H497.529V278.37H441.481ZM441.481,341.259V284.319H497.529V341.259H441.481ZM503.473,341.259V284.319H560.37V341.259H503.473ZM190.963,404.148V347.208H247.86V404.148H190.963ZM253.805,404.148V347.208H309.853V404.148H253.805ZM315.797,404.148V347.208H372.694V404.148H315.797ZM378.639,404.148V347.208H435.536V404.148H378.639ZM441.481,404.148V347.208H497.529V404.148H441.481ZM503.473,404.148V347.208H560.37V404.148H503.473ZM190.963,466.187V410.097H247.86V466.187H190.963ZM253.805,466.187V410.097H309.853V466.187H253.805ZM441.481,466.187V410.097H497.529V466.187H441.481ZM503.473,466.187V410.097H560.37V466.187H503.473ZM253.805,529.076V472.136H309.853V529.076H253.805ZM315.797,529.076V472.136H372.694V529.076H315.797ZM378.639,529.076V472.136H435.536V529.076H378.639ZM441.481,529.076V472.136H497.529V529.076H441.481Z" /> + </group> +</vector> 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 @@ +<?xml version="1.0" encoding="utf-8"?> +<!-- Transparent — the whole badge (ring + grid) is in the background layer so it + renders to the mask edge without safe-zone cropping. --> +<vector xmlns:android="http://schemas.android.com/apk/res/android" + android:width="108dp" + android:height="108dp" + android:viewportWidth="108" + android:viewportHeight="108"> + <path + android:fillColor="#00000000" + android:pathData="M0,0h108v108H0z" /> +</vector> 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 @@ +<?xml version="1.0" encoding="utf-8"?> +<!-- Archipelago circular badge logo (from logo.svg): + dark circle with a black→grey gradient ring + white pixel-grid mark. --> +<vector xmlns:android="http://schemas.android.com/apk/res/android" + xmlns:aapt="http://schemas.android.com/aapt" + android:width="120dp" + android:height="120dp" + android:viewportWidth="752" + android:viewportHeight="752"> + + <!-- Ringed circle (circle converted to a path; stroke carries the gradient) --> + <path + android:fillColor="#0A0A0A" + android:strokeWidth="22.8834" + android:pathData="M11.441,375.669a364.227,364.227 0 1,0 728.454,0a364.227,364.227 0 1,0 -728.454,0z"> + <aapt:attr name="android:strokeColor"> + <gradient + android:type="linear" + android:startX="751.337" + android:startY="751.338" + android:endX="0" + android:endY="0"> + <item android:offset="0" android:color="#FF000000" /> + <item android:offset="1" android:color="#FF666666" /> + </gradient> + </aapt:attr> + </path> + + <!-- White Archipelago pixel grid --> + <path + android:fillColor="#FFFFFF" + android:pathData="M253.805,278.37V222.28H309.853V278.37H253.805ZM315.797,278.37V222.28H372.694V278.37H315.797ZM378.639,278.37V222.28H435.536V278.37H378.639ZM441.481,278.37V222.28H497.529V278.37H441.481ZM441.481,341.259V284.319H497.529V341.259H441.481ZM503.473,341.259V284.319H560.37V341.259H503.473ZM190.963,404.148V347.208H247.86V404.148H190.963ZM253.805,404.148V347.208H309.853V404.148H253.805ZM315.797,404.148V347.208H372.694V404.148H315.797ZM378.639,404.148V347.208H435.536V404.148H378.639ZM441.481,404.148V347.208H497.529V404.148H441.481ZM503.473,404.148V347.208H560.37V404.148H503.473ZM190.963,466.187V410.097H247.86V466.187H190.963ZM253.805,466.187V410.097H309.853V466.187H253.805ZM441.481,466.187V410.097H497.529V466.187H441.481ZM503.473,466.187V410.097H560.37V466.187H503.473ZM253.805,529.076V472.136H309.853V529.076H253.805ZM315.797,529.076V472.136H372.694V529.076H315.797ZM378.639,529.076V472.136H435.536V529.076H378.639ZM441.481,529.076V472.136H497.529V529.076H441.481Z" /> +</vector> 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 @@ +<vector xmlns:android="http://schemas.android.com/apk/res/android" + android:width="240dp" + android:height="30dp" + android:viewportWidth="2079" + android:viewportHeight="263"> + + <!-- A --> + <path android:fillColor="#FFFFFF" + android:pathData="M29.6,85.6V59.2H56V85.6H29.6ZM58.8,85.6V59.2H85.6V85.6H58.8ZM88.4,85.6V59.2H115.2V85.6H88.4ZM118,85.6V59.2H144.4V85.6H118ZM118,115.2V88.4H144.4V115.2H118ZM147.2,115.2V88.4H174V115.2H147.2ZM0,144.8V118H26.8V144.8H0ZM29.6,144.8V118H56V144.8H29.6ZM58.8,144.8V118H85.6V144.8H58.8ZM88.4,144.8V118H115.2V144.8H88.4ZM118,144.8V118H144.4V144.8H118ZM147.2,144.8V118H174V144.8H147.2ZM0,174V147.6H26.8V174H0ZM29.6,174V147.6H56V174H29.6ZM118,174V147.6H144.4V174H118ZM147.2,174V147.6H174V174H147.2ZM29.6,203.6V176.8H56V203.6H29.6ZM58.8,203.6V176.8H85.6V203.6H58.8ZM88.4,203.6V176.8H115.2V203.6H88.4ZM118,203.6V176.8H144.4V203.6H118Z" /> + + <!-- R --> + <path android:fillColor="#FFFFFF" + android:pathData="M243.663,85.6V59.2H270.062V85.6H243.663ZM272.863,85.6V59.2H299.663V85.6H272.863ZM302.463,85.6V59.2H329.263V85.6H302.463ZM332.062,85.6V59.2H358.462V85.6H332.062ZM332.062,115.2V88.4H358.462V115.2H332.062ZM361.263,115.2V88.4H388.062V115.2H361.263ZM214.062,115.2V88.4H240.863V115.2H214.062ZM243.663,115.2V88.4H270.062V115.2H243.663ZM214.062,144.8V118H240.863V144.8H214.062ZM243.663,144.8V118H270.062V144.8H243.663ZM214.062,174V147.6H240.863V174H214.062ZM243.663,174V147.6H270.062V174H243.663ZM243.663,203.6V176.8H270.062V203.6H243.663Z" /> + + <!-- C --> + <path android:fillColor="#FFFFFF" + android:pathData="M457.725,85.6V59.2H484.125V85.6H457.725ZM486.925,85.6V59.2H513.725V85.6H486.925ZM516.525,85.6V59.2H543.325V85.6H516.525ZM546.125,85.6V59.2H572.525V85.6H546.125ZM428.125,115.2V88.4H454.925V115.2H428.125ZM457.725,115.2V88.4H484.125V115.2H457.725ZM546.125,115.2V88.4H572.525V115.2H546.125ZM575.325,115.2V88.4H602.125V115.2H575.325ZM428.125,144.8V118H454.925V144.8H428.125ZM457.725,144.8V118H484.125V144.8H457.725ZM428.125,174V147.6H454.925V174H428.125ZM457.725,174V147.6H484.125V174H457.725ZM546.125,174V147.6H572.525V174H546.125ZM575.325,174V147.6H602.125V174H575.325ZM457.725,203.6V176.8H484.125V203.6H457.725ZM486.925,203.6V176.8H513.725V203.6H486.925ZM516.525,203.6V176.8H543.325V203.6H516.525ZM546.125,203.6V176.8H572.525V203.6H546.125Z" /> + + <!-- H --> + <path android:fillColor="#FFFFFF" + android:pathData="M671.787,26.8V0H698.188V26.8H671.787ZM642.188,56.4V29.6H668.987V56.4H642.188ZM671.787,56.4V29.6H698.188V56.4H671.787ZM642.188,85.6V59.2H668.987V85.6H642.188ZM671.787,85.6V59.2H698.188V85.6H671.787ZM700.987,85.6V59.2H727.787V85.6H700.987ZM730.588,85.6V59.2H757.388V85.6H730.588ZM760.188,85.6V59.2H786.588V85.6H760.188ZM642.188,115.2V88.4H668.987V115.2H642.188ZM671.787,115.2V88.4H698.188V115.2H671.787ZM760.188,115.2V88.4H786.588V115.2H760.188ZM789.388,115.2V88.4H816.188V115.2H789.388ZM642.188,144.8V118H668.987V144.8H642.188ZM671.787,144.8V118H698.188V144.8H671.787ZM760.188,144.8V118H786.588V144.8H760.188ZM789.388,144.8V118H816.188V144.8H789.388ZM642.188,174V147.6H668.987V174H642.188ZM671.787,174V147.6H698.188V174H671.787ZM760.188,174V147.6H786.588V174H760.188ZM789.388,174V147.6H816.188V174H789.388ZM671.787,203.6V176.8H698.188V203.6H671.787ZM760.188,203.6V176.8H786.588V203.6H760.188Z" /> + + <!-- I --> + <path android:fillColor="#FFFFFF" + android:pathData="M856.25,26.8V0H883.05V26.8H856.25ZM885.85,26.8V0H912.25V26.8H885.85ZM856.25,85.6V59.2H883.05V85.6H856.25ZM856.25,115.2V88.4H883.05V115.2H856.25ZM885.85,115.2V88.4H912.25V115.2H885.85ZM856.25,144.8V118H883.05V144.8H856.25ZM885.85,144.8V118H912.25V144.8H885.85ZM856.25,174V147.6H883.05V174H856.25ZM885.85,174V147.6H912.25V174H885.85ZM885.85,203.6V176.8H912.25V203.6H885.85Z" /> + + <!-- P --> + <path android:fillColor="#FFFFFF" + android:pathData="M981.944,85.6V59.2H1008.34V85.6H981.944ZM1011.14,85.6V59.2H1037.94V85.6H1011.14ZM1040.74,85.6V59.2H1067.54V85.6H1040.74ZM1070.34,85.6V59.2H1096.74V85.6H1070.34ZM952.344,115.2V88.4H979.144V115.2H952.344ZM981.944,115.2V88.4H1008.34V115.2H981.944ZM1070.34,115.2V88.4H1096.74V115.2H1070.34ZM1099.54,115.2V88.4H1126.34V115.2H1099.54ZM952.344,144.8V118H979.144V144.8H952.344ZM981.944,144.8V118H1008.34V144.8H981.944ZM1070.34,144.8V118H1096.74V144.8H1070.34ZM1099.54,144.8V118H1126.34V144.8H1099.54ZM952.344,174V147.6H979.144V174H952.344ZM981.944,174V147.6H1008.34V174H981.944ZM1070.34,174V147.6H1096.74V174H1070.34ZM1099.54,174V147.6H1126.34V174H1099.54ZM952.344,203.6V176.8H979.144V203.6H952.344ZM981.944,203.6V176.8H1008.34V203.6H981.944ZM1011.14,203.6V176.8H1037.94V203.6H1011.14ZM1040.74,203.6V176.8H1067.54V203.6H1040.74ZM1070.34,203.6V176.8H1096.74V203.6H1070.34ZM952.344,233.2V206.4H979.144V233.2H952.344ZM981.944,233.2V206.4H1008.34V233.2H981.944ZM981.944,262.4V236H1008.34V262.4H981.944Z" /> + + <!-- E --> + <path android:fillColor="#FFFFFF" + android:pathData="M1196.01,85.6V59.2H1222.41V85.6H1196.01ZM1225.21,85.6V59.2H1252.01V85.6H1225.21ZM1254.81,85.6V59.2H1281.61V85.6H1254.81ZM1284.41,85.6V59.2H1310.81V85.6H1284.41ZM1166.41,115.2V88.4H1193.21V115.2H1166.41ZM1196.01,115.2V88.4H1222.41V115.2H1196.01ZM1284.41,115.2V88.4H1310.81V115.2H1284.41ZM1313.61,115.2V88.4H1340.41V115.2H1313.61ZM1166.41,144.8V118H1193.21V144.8H1166.41ZM1196.01,144.8V118H1222.41V144.8H1196.01ZM1225.21,144.8V118H1252.01V144.8H1225.21ZM1254.81,144.8V118H1281.61V144.8H1254.81ZM1284.41,144.8V118H1310.81V144.8H1284.41ZM1313.61,144.8V118H1340.41V144.8H1313.61ZM1166.41,174V147.6H1193.21V174H1166.41ZM1196.01,174V147.6H1222.41V174H1196.01ZM1196.01,203.6V176.8H1222.41V203.6H1196.01ZM1225.21,203.6V176.8H1252.01V203.6H1225.21ZM1254.81,203.6V176.8H1281.61V203.6H1254.81ZM1284.41,203.6V176.8H1310.81V203.6H1284.41Z" /> + + <!-- L --> + <path android:fillColor="#FFFFFF" + android:pathData="M1380.47,26.8V0H1407.27V26.8H1380.47ZM1380.47,56.4V29.6H1407.27V56.4H1380.47ZM1410.07,56.4V29.6H1436.47V56.4H1410.07ZM1380.47,85.6V59.2H1407.27V85.6H1380.47ZM1410.07,85.6V59.2H1436.47V85.6H1410.07ZM1380.47,115.2V88.4H1407.27V115.2H1380.47ZM1410.07,115.2V88.4H1436.47V115.2H1410.07ZM1380.47,144.8V118H1407.27V144.8H1380.47ZM1410.07,144.8V118H1436.47V144.8H1410.07ZM1380.47,174V147.6H1407.27V174H1380.47ZM1410.07,174V147.6H1436.47V174H1410.07ZM1410.07,203.6V176.8H1436.47V203.6H1410.07Z" /> + + <!-- A (second) --> + <path android:fillColor="#FFFFFF" + android:pathData="M1506.16,85.6V59.2H1532.56V85.6H1506.16ZM1535.36,85.6V59.2H1562.16V85.6H1535.36ZM1564.96,85.6V59.2H1591.76V85.6H1564.96ZM1594.56,85.6V59.2H1620.96V85.6H1594.56ZM1594.56,115.2V88.4H1620.96V115.2H1594.56ZM1623.76,115.2V88.4H1650.56V115.2H1623.76ZM1476.56,144.8V118H1503.36V144.8H1476.56ZM1506.16,144.8V118H1532.56V144.8H1506.16ZM1535.36,144.8V118H1562.16V144.8H1535.36ZM1564.96,144.8V118H1591.76V144.8H1564.96ZM1594.56,144.8V118H1620.96V144.8H1594.56ZM1623.76,144.8V118H1650.56V144.8H1623.76ZM1476.56,174V147.6H1503.36V174H1476.56ZM1506.16,174V147.6H1532.56V174H1506.16ZM1594.56,174V147.6H1620.96V174H1594.56ZM1623.76,174V147.6H1650.56V174H1623.76ZM1506.16,203.6V176.8H1532.56V203.6H1506.16ZM1535.36,203.6V176.8H1562.16V203.6H1535.36ZM1564.96,203.6V176.8H1591.76V203.6H1564.96ZM1594.56,203.6V176.8H1620.96V203.6H1594.56Z" /> + + <!-- G --> + <path android:fillColor="#FFFFFF" + android:pathData="M1720.22,85.6V59.2H1746.62V85.6H1720.22ZM1749.43,85.6V59.2H1776.22V85.6H1749.43ZM1779.03,85.6V59.2H1805.82V85.6H1779.03ZM1808.62,85.6V59.2H1835.03V85.6H1808.62ZM1690.62,115.2V88.4H1717.43V115.2H1690.62ZM1720.22,115.2V88.4H1746.62V115.2H1720.22ZM1808.62,115.2V88.4H1835.03V115.2H1808.62ZM1837.82,115.2V88.4H1864.62V115.2H1837.82ZM1690.62,144.8V118H1717.43V144.8H1690.62ZM1720.22,144.8V118H1746.62V144.8H1720.22ZM1808.62,144.8V118H1835.03V144.8H1808.62ZM1837.82,144.8V118H1864.62V144.8H1837.82ZM1690.62,174V147.6H1717.43V174H1690.62ZM1720.22,174V147.6H1746.62V174H1720.22ZM1808.62,174V147.6H1835.03V174H1808.62ZM1837.82,174V147.6H1864.62V174H1837.82ZM1720.22,203.6V176.8H1746.62V203.6H1720.22ZM1749.43,203.6V176.8H1776.22V203.6H1749.43ZM1779.03,203.6V176.8H1805.82V203.6H1779.03ZM1808.62,203.6V176.8H1835.03V203.6H1808.62ZM1837.82,203.6V176.8H1864.62V203.6H1837.82ZM1808.62,233.2V206.4H1835.03V233.2H1808.62ZM1837.82,233.2V206.4H1864.62V233.2H1837.82ZM1720.22,262.4V236H1746.62V262.4H1720.22ZM1749.43,262.4V236H1776.22V262.4H1749.43ZM1779.03,262.4V236H1805.82V262.4H1779.03ZM1808.62,262.4V236H1835.03V262.4H1808.62Z" /> + + <!-- O --> + <path android:fillColor="#FFFFFF" + android:pathData="M1934.29,85.6V59.2H1960.69V85.6H1934.29ZM1963.49,85.6V59.2H1990.29V85.6H1963.49ZM1993.09,85.6V59.2H2019.89V85.6H1993.09ZM2022.69,85.6V59.2H2049.09V85.6H2022.69ZM1904.69,115.2V88.4H1931.49V115.2H1904.69ZM1934.29,115.2V88.4H1960.69V115.2H1934.29ZM2022.69,115.2V88.4H2049.09V115.2H2022.69ZM2051.89,115.2V88.4H2078.69V115.2H2051.89ZM1904.69,144.8V118H1931.49V144.8H1904.69ZM1934.29,144.8V118H1960.69V144.8H1934.29ZM2022.69,144.8V118H2049.09V144.8H2022.69ZM2051.89,144.8V118H2078.69V144.8H2051.89ZM1904.69,174V147.6H1931.49V174H1904.69ZM1934.29,174V147.6H1960.69V174H1934.29ZM2022.69,174V147.6H2049.09V174H2022.69ZM2051.89,174V147.6H2078.69V174H2051.89ZM1963.49,203.6V176.8H1990.29V203.6H1963.49ZM1993.09,203.6V176.8H2019.89V203.6H1993.09ZM1934.29,203.6V176.8H1960.69V203.6H1934.29ZM2022.69,203.6V176.8H2049.09V203.6H2022.69Z" /> + +</vector> 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 @@ +<vector xmlns:android="http://schemas.android.com/apk/res/android" + android:width="24dp" + android:height="24dp" + android:viewportWidth="24" + android:viewportHeight="24"> + <path + android:pathData="M15,19l-7,-7 7,-7" + android:strokeColor="#FFFFFF" + android:strokeWidth="2" + android:strokeLineCap="round" + android:strokeLineJoin="round" /> +</vector> 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 @@ +<vector xmlns:android="http://schemas.android.com/apk/res/android" + android:width="24dp" + android:height="24dp" + android:viewportWidth="24" + android:viewportHeight="24"> + <path + android:pathData="M6,18L18,6M6,6l12,12" + android:strokeColor="#FFFFFF" + android:strokeWidth="2" + android:strokeLineCap="round" + android:strokeLineJoin="round" /> +</vector> 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 @@ +<vector xmlns:android="http://schemas.android.com/apk/res/android" + android:width="24dp" + android:height="24dp" + android:viewportWidth="24" + android:viewportHeight="24"> + <path + android:pathData="M9,5l7,7 -7,7" + android:strokeColor="#FFFFFF" + android:strokeWidth="2" + android:strokeLineCap="round" + android:strokeLineJoin="round" /> +</vector> 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 @@ +<vector xmlns:android="http://schemas.android.com/apk/res/android" + android:width="24dp" + android:height="24dp" + android:viewportWidth="24" + android:viewportHeight="24"> + <path + android:pathData="M10,6H6a2,2 0,0 0,-2 2v10a2,2 0,0 0,2 2h10a2,2 0,0 0,2 -2v-4M14,4h6m0,0v6m0,-6L10,14" + android:strokeColor="#FFFFFF" + android:strokeWidth="2" + android:strokeLineCap="round" + android:strokeLineJoin="round" /> +</vector> 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 @@ +<vector xmlns:android="http://schemas.android.com/apk/res/android" + android:width="24dp" + android:height="24dp" + android:viewportWidth="24" + android:viewportHeight="24"> + <path + android:pathData="M4,4v6h6M20,20v-6h-6M5.64,15.36A8,8 0,0 0,18.36 18M18.36,8.64A8,8 0,0 0,5.64 6" + android:strokeColor="#FFFFFF" + android:strokeWidth="2" + android:strokeLineCap="round" + android:strokeLineJoin="round" /> +</vector> 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 @@ +<?xml version="1.0" encoding="utf-8"?> +<!-- Archipelago pixel-art "A" for splash screen --> +<vector xmlns:android="http://schemas.android.com/apk/res/android" + android:width="108dp" + android:height="108dp" + android:viewportWidth="1024" + android:viewportHeight="1024"> + + <group + android:pivotX="512" + android:pivotY="512" + android:scaleX="0.55" + android:scaleY="0.55"> + + <path android:fillColor="#FFFFFF" android:pathData="M357.614,318h71.007v70.936h-71.007z" /> + <path android:fillColor="#FFFFFF" android:pathData="M436.152,318h72.082v70.936h-72.082z" /> + <path android:fillColor="#FFFFFF" android:pathData="M515.766,318h72.082v70.936h-72.082z" /> + <path android:fillColor="#FFFFFF" android:pathData="M595.379,318h71.007v70.936h-71.007z" /> + <path android:fillColor="#FFFFFF" android:pathData="M595.379,396.46h71.007v72.011h-71.007z" /> + <path android:fillColor="#FFFFFF" android:pathData="M673.917,396.46h72.083v72.011h-72.083z" /> + <path android:fillColor="#FFFFFF" android:pathData="M278,475.994h72.083v72.012h-72.083z" /> + <path android:fillColor="#FFFFFF" android:pathData="M357.614,475.994h71.007v72.012h-71.007z" /> + <path android:fillColor="#FFFFFF" android:pathData="M436.152,475.994h72.082v72.012h-72.082z" /> + <path android:fillColor="#FFFFFF" android:pathData="M515.766,475.994h72.082v72.012h-72.082z" /> + <path android:fillColor="#FFFFFF" android:pathData="M595.379,475.994h71.007v72.012h-71.007z" /> + <path android:fillColor="#FFFFFF" android:pathData="M673.917,475.994h72.083v72.012h-72.083z" /> + <path android:fillColor="#FFFFFF" android:pathData="M278,555.529h72.083v70.936h-72.083z" /> + <path android:fillColor="#FFFFFF" android:pathData="M357.614,555.529h71.007v70.936h-71.007z" /> + <path android:fillColor="#FFFFFF" android:pathData="M595.379,555.529h71.007v70.936h-71.007z" /> + <path android:fillColor="#FFFFFF" android:pathData="M673.917,555.529h72.083v70.936h-72.083z" /> + <path android:fillColor="#FFFFFF" android:pathData="M357.614,633.989h71.007v72.011h-71.007z" /> + <path android:fillColor="#FFFFFF" android:pathData="M436.152,633.989h72.082v72.011h-72.082z" /> + <path android:fillColor="#FFFFFF" android:pathData="M515.766,633.989h72.082v72.011h-72.082z" /> + <path android:fillColor="#FFFFFF" android:pathData="M595.379,633.989h71.007v72.011h-71.007z" /> + </group> +</vector> 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 @@ +<?xml version="1.0" encoding="utf-8"?> +<adaptive-icon xmlns:android="http://schemas.android.com/apk/res/android"> + <background android:drawable="@drawable/ic_launcher_background" /> + <foreground android:drawable="@drawable/ic_launcher_foreground" /> +</adaptive-icon> 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 @@ +<?xml version="1.0" encoding="utf-8"?> +<adaptive-icon xmlns:android="http://schemas.android.com/apk/res/android"> + <background android:drawable="@drawable/ic_launcher_background" /> + <foreground android:drawable="@drawable/ic_launcher_foreground" /> +</adaptive-icon> 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 @@ +<?xml version="1.0" encoding="utf-8"?> +<resources> + <color name="black">#FF000000</color> + <color name="white">#FFFFFFFF</color> + <color name="bitcoin_orange">#FFF7931A</color> + <color name="surface_dark">#FF0A0A0A</color> + <color name="surface_card">#FF1A1A1A</color> + <color name="splash_background">#FF000000</color> +</resources> 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..6f92ff2c --- /dev/null +++ b/Android/app/src/main/res/values/strings.xml @@ -0,0 +1,52 @@ +<?xml version="1.0" encoding="utf-8"?> +<resources> + <string name="app_name">Archipelago</string> + <string name="server_address_label">Server Address</string> + <string name="server_address_placeholder">192.168.1.100</string> + <string name="server_address_hint">Enter your Archipelago server IP or hostname</string> + <string name="connect">Connect</string> + <string name="connecting">Connecting…</string> + <string name="connection_failed">Could not reach server. Check the address and try again.</string> + <string name="connection_timeout">Connection timed out. Is the server running?</string> + <string name="welcome_title">Your Sovereign\nPersonal Server</string> + <string name="welcome_subtitle">Bitcoin node, app platform, and private cloud — all in one box you control.</string> + <string name="get_started">Get Started</string> + <string name="mesh_party">Mesh Party</string> + <string name="use_https">Use HTTPS</string> + <string name="port_label">Port (optional)</string> + <string name="saved_servers">Saved Servers</string> + <string name="no_saved_servers">No saved servers yet</string> + <string name="remove_server">Remove</string> + <string name="disconnect">Disconnect</string> + <string name="server_unreachable">Server unreachable</string> + <string name="retry">Retry</string> + <string name="remote_input">Remote Control</string> + <string name="remote_input_hint">Use your phone as a keyboard and mouse for the kiosk</string> + <string name="close">Close</string> + <string name="open_in_browser">Open in browser</string> + <string name="back">Back</string> + <string name="forward">Forward</string> + <string name="refresh">Refresh</string> + <string name="server_name_label">Server Name (optional)</string> + <string name="server_name_placeholder">My Archipelago</string> + <string name="edit_server">Edit</string> + <string name="scan_node_qr">Scan Node\'s QR</string> + <string name="enter_manually">Enter Manually</string> + <string name="connect_landing_hint">Scan the pairing QR from your node\'s Companion popup, or enter the address manually</string> + <string name="scan_qr_hint">Point the camera at the pairing QR shown in the Companion popup</string> + <string name="camera_permission_needed">Camera access is needed to scan the pairing QR. You can also enter the server details manually.</string> + <string name="grant_camera_access">Grant Camera Access</string> + <string name="invalid_pairing_qr">Not an Archipelago pairing code</string> + <string name="update_app_for_qr">This pairing code needs a newer app version — please update the companion app</string> + <string name="add_server_qr">Add server by QR</string> + <string name="edit_server_title">Edit Server</string> + <string name="save_changes">Save Changes</string> + <string name="cancel">Cancel</string> + <string name="gesture_hint_title">Hold with three fingers</string> + <string name="gesture_hint_body">Anywhere in the app — opens the remote control and menu</string> + <string name="gesture_hint_got_it">Got it</string> + <string name="scan_to_send">Scan to send</string> + <string name="scan_wallet_hint">Point the camera at a Lightning invoice, Bitcoin address, Cashu or Fedimint code</string> + <string name="upload_qr_image">Upload image</string> + <string name="no_qr_in_image">No QR code found in that image — try another, closer and well-lit</string> +</resources> 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 @@ +<?xml version="1.0" encoding="utf-8"?> +<resources> + <style name="Theme.Archipelago" parent="android:Theme.Material.NoActionBar"> + <item name="android:statusBarColor">@android:color/transparent</item> + <item name="android:navigationBarColor">@android:color/transparent</item> + <item name="android:windowBackground">@color/black</item> + </style> + + <style name="Theme.Archipelago.Splash" parent="Theme.SplashScreen"> + <item name="windowSplashScreenBackground">@color/splash_background</item> + <item name="windowSplashScreenAnimatedIcon">@drawable/ic_splash_logo</item> + <item name="postSplashScreenTheme">@style/Theme.Archipelago</item> + </style> +</resources> diff --git a/Android/app/src/main/res/xml/file_paths.xml b/Android/app/src/main/res/xml/file_paths.xml new file mode 100644 index 00000000..40e6b2d8 --- /dev/null +++ b/Android/app/src/main/res/xml/file_paths.xml @@ -0,0 +1,5 @@ +<?xml version="1.0" encoding="utf-8"?> +<!-- FileProvider scope for the party-screen "Share this app" APK handoff. --> +<paths> + <cache-path name="share" path="share/" /> +</paths> 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 @@ +<?xml version="1.0" encoding="utf-8"?> +<network-security-config> + <!-- Allow cleartext for local network Archipelago servers --> + <base-config cleartextTrafficPermitted="true"> + <trust-anchors> + <certificates src="system" /> + </trust-anchors> + </base-config> +</network-security-config> 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 @@ +<svg width="752" height="752" viewBox="0 0 752 752" fill="none" xmlns="http://www.w3.org/2000/svg"> +<circle cx="375.668" cy="375.669" r="364.227" fill="#0A0A0A" stroke="url(#paint0_linear_877_1990)" stroke-width="22.8834"/> +<path d="M253.805 278.37V222.28H309.853V278.37H253.805ZM315.797 278.37V222.28H372.694V278.37H315.797ZM378.639 278.37V222.28H435.536V278.37H378.639ZM441.481 278.37V222.28H497.529V278.37H441.481ZM441.481 341.259V284.319H497.529V341.259H441.481ZM503.473 341.259V284.319H560.37V341.259H503.473ZM190.963 404.148V347.208H247.86V404.148H190.963ZM253.805 404.148V347.208H309.853V404.148H253.805ZM315.797 404.148V347.208H372.694V404.148H315.797ZM378.639 404.148V347.208H435.536V404.148H378.639ZM441.481 404.148V347.208H497.529V404.148H441.481ZM503.473 404.148V347.208H560.37V404.148H503.473ZM190.963 466.187V410.097H247.86V466.187H190.963ZM253.805 466.187V410.097H309.853V466.187H253.805ZM441.481 466.187V410.097H497.529V466.187H441.481ZM503.473 466.187V410.097H560.37V466.187H503.473ZM253.805 529.076V472.136H309.853V529.076H253.805ZM315.797 529.076V472.136H372.694V529.076H315.797ZM378.639 529.076V472.136H435.536V529.076H378.639ZM441.481 529.076V472.136H497.529V529.076H441.481Z" fill="white"/> +<defs> +<linearGradient id="paint0_linear_877_1990" x1="751.337" y1="751.338" x2="0" y2="0.000976562" gradientUnits="userSpaceOnUse"> +<stop/> +<stop offset="1" stop-color="#666666"/> +</linearGradient> +</defs> +</svg> diff --git a/Android/rust/archy-fips-core/Cargo.lock b/Android/rust/archy-fips-core/Cargo.lock new file mode 100644 index 00000000..b99f5ba2 --- /dev/null +++ b/Android/rust/archy-fips-core/Cargo.lock @@ -0,0 +1,1690 @@ +# This file is automatically @generated by Cargo. +# It is not intended for manual editing. +version = 4 + +[[package]] +name = "aead" +version = "0.5.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d122413f284cf2d62fb1b7db97e02edb8cda96d769b16e443a4f6195e35662b0" +dependencies = [ + "crypto-common", + "generic-array", +] + +[[package]] +name = "aho-corasick" +version = "1.1.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ddd31a130427c27518df266943a5308ed92d4b226cc639f5a8f1002816174301" +dependencies = [ + "memchr", +] + +[[package]] +name = "anstream" +version = "1.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "824a212faf96e9acacdbd09febd34438f8f711fb84e09a8916013cd7815ca28d" +dependencies = [ + "anstyle", + "anstyle-parse", + "anstyle-query", + "anstyle-wincon", + "colorchoice", + "is_terminal_polyfill", + "utf8parse", +] + +[[package]] +name = "anstyle" +version = "1.0.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "940b3a0ca603d1eade50a4846a2afffd5ef57a9feac2c0e2ec2e14f9ead76000" + +[[package]] +name = "anstyle-parse" +version = "1.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "52ce7f38b242319f7cabaa6813055467063ecdc9d355bbb4ce0c68908cd8130e" +dependencies = [ + "utf8parse", +] + +[[package]] +name = "anstyle-query" +version = "1.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "40c48f72fd53cd289104fc64099abca73db4166ad86ea0b4341abe65af83dadc" +dependencies = [ + "windows-sys 0.61.2", +] + +[[package]] +name = "anstyle-wincon" +version = "3.0.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "291e6a250ff86cd4a820112fb8898808a366d8f9f58ce16d1f538353ad55747d" +dependencies = [ + "anstyle", + "once_cell_polyfill", + "windows-sys 0.61.2", +] + +[[package]] +name = "anyhow" +version = "1.0.104" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "330a5ed07fa54e4702c9d6c4174f74427fc0ef6e214bbd677ae50a5099946470" + +[[package]] +name = "archy-fips-core" +version = "0.1.0" +dependencies = [ + "anyhow", + "fips", + "getrandom 0.2.17", + "hex", + "jni", + "libc", + "paranoid-android", + "serde_json", + "tokio", + "tracing", + "tracing-subscriber", +] + +[[package]] +name = "arrayvec" +version = "0.7.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d3fb67a6e08acf24fdeccbac2cb6ac4305825bd1f117462e0e6f2f193345ad56" + +[[package]] +name = "async-channel" +version = "2.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "924ed96dd52d1b75e9c1a3e6275715fd320f5f9439fb5a4a11fa51f4221158d2" +dependencies = [ + "concurrent-queue", + "event-listener-strategy", + "futures-core", + "pin-project-lite", +] + +[[package]] +name = "async-task" +version = "4.7.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8b75356056920673b02621b35afd0f7dda9306d03c79a30f5c56c44cf256e3de" + +[[package]] +name = "atomic-waker" +version = "1.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1505bd5d3d116872e7271a6d4e16d81d0c8570876c8de68093a09ac269d8aac0" + +[[package]] +name = "bech32" +version = "0.11.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "32637268377fc7b10a8c6d51de3e7fba1ce5dd371a96e342b34e6078db558e7f" + +[[package]] +name = "bitcoin-consensus-encoding" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "207311705279250ba465076a1bac4b1ac982855fff73fc5f67e22158ac58cdc9" +dependencies = [ + "bitcoin-internals", + "hex-conservative 1.2.0", + "serde", +] + +[[package]] +name = "bitcoin-internals" +version = "0.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d573f4cf32996a8dce612e4348cece65a241f1882ed594047c9ba348e8869fa5" + +[[package]] +name = "bitcoin-io" +version = "0.1.101" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bb5de036369d1ac59d3c1819ebc4d850f89466f5401c571a285b6ed564a4cb78" +dependencies = [ + "bitcoin-consensus-encoding", +] + +[[package]] +name = "bitcoin_hashes" +version = "0.14.101" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bca4c7abb40c8817d77403c880988cfd484f23ab2365726afb2f798363e2c4a2" +dependencies = [ + "bitcoin-io", + "hex-conservative 0.2.2", +] + +[[package]] +name = "bitflags" +version = "2.13.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b588b76d00fde79687d7646a9b5bdf3cc0f655e0bbd080335a95d7e96f3587da" + +[[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 = "blocking" +version = "1.6.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e83f8d02be6967315521be875afa792a316e28d57b5a2d401897e2a7921b7f21" +dependencies = [ + "async-channel", + "async-task", + "futures-io", + "futures-lite", + "piper", +] + +[[package]] +name = "bytes" +version = "1.12.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fc652a48c352aef3ea3aed32080501cf3ef6ed5da78602a020c991775b0aff04" + +[[package]] +name = "cc" +version = "1.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c89588d05638b5b4594a3348a2d6c20277e43a7f5c5202b05cc56888475a47b8" +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.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f079e83a288787bcd14a6aea84cee5c87a67c5a3e660c30f557a3d24761b3527" + +[[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.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d524456ba66e72eb8b115ff89e01e497f8e6d11d78b70b1aa13c0fbd97540a81" +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 = "cipher" +version = "0.4.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "773f3b9af64447d2ce9850330c473515014aa235e6a783b02db81ff39e4a3dad" +dependencies = [ + "crypto-common", + "inout", + "zeroize", +] + +[[package]] +name = "clap" +version = "4.6.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d91e0c145792ef73a6ad36d27c75ac09f1832222a3c209689d90f534685ee5b7" +dependencies = [ + "clap_builder", + "clap_derive", +] + +[[package]] +name = "clap_builder" +version = "4.6.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f09628afdcc538b57f3c6341e9c8e9970f18e4a481690a64974d7023bd33548b" +dependencies = [ + "anstream", + "anstyle", + "clap_lex", + "strsim", +] + +[[package]] +name = "clap_derive" +version = "4.6.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d012d2b9d65aca7f18f4d9878a045bc17899bba951561ba5ec3c2ba1eed9a061" +dependencies = [ + "heck", + "proc-macro2", + "quote", + "syn 3.0.3", +] + +[[package]] +name = "clap_lex" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c8d4a3bb8b1e0c1050499d1815f5ab16d04f0959b233085fb31653fbfc9d98f9" + +[[package]] +name = "colorchoice" +version = "1.0.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1d07550c9036bf2ae0c684c4297d503f838287c83c53686d05370d0e139ae570" + +[[package]] +name = "combine" +version = "4.6.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ba5a308b75df32fe02788e748662718f03fde005016435c444eea572398219fd" +dependencies = [ + "bytes", + "memchr", +] + +[[package]] +name = "concurrent-queue" +version = "2.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4ca0197aee26d1ae37445ee532fefce43251d24cc7c166799f4d46817f1d3973" +dependencies = [ + "crossbeam-utils", +] + +[[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 = "crossbeam-utils" +version = "0.8.22" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "61803da095bee82a81bb1a452ecc25d3b2f1416d1897eb86430c6159ef717c17" + +[[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 = "digest" +version = "0.10.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9ed9a281f7bc9b7576e61468ba615a66a5c8cfdff42420a70aa82701a3b1e292" +dependencies = [ + "block-buffer", + "crypto-common", + "subtle", +] + +[[package]] +name = "dirs" +version = "6.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c3e8aa94d75141228480295a7d0e7feb620b1a5ad9f12bc40be62411e38cce4e" +dependencies = [ + "dirs-sys", +] + +[[package]] +name = "dirs-sys" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e01a3366d27ee9890022452ee61b2b63a67e6f13f58900b651ff5665f0bb1fab" +dependencies = [ + "libc", + "option-ext", + "redox_users", + "windows-sys 0.61.2", +] + +[[package]] +name = "either" +version = "1.16.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "91622ff5e7162018101f2fea40d6ebf4a78bbe5a49736a2020649edf9693679e" + +[[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 = "event-listener" +version = "5.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e13b66accf52311f30a0db42147dadea9850cb48cd070028831ae5f5d4b856ab" +dependencies = [ + "concurrent-queue", + "parking", + "pin-project-lite", +] + +[[package]] +name = "event-listener-strategy" +version = "0.5.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8be9f3dfaaffdae2972880079a491a1a8bb7cbed0b8dd7a347f668b4150a3b93" +dependencies = [ + "event-listener", + "pin-project-lite", +] + +[[package]] +name = "fastrand" +version = "2.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "da7c62ceae207dd37ea5b845da6a0696c799f85e97da1ab5b7910be3c1c80223" + +[[package]] +name = "find-msvc-tools" +version = "0.1.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5baebc0774151f905a1a2cc41989300b1e6fbb29aff0ceffa1064fdd3088d582" + +[[package]] +name = "fips" +version = "0.3.0-dev" +source = "git+https://github.com/Zazawowow/fips-native?rev=07d21d4482be56b14295d2525e41f8386d1bfe6f#07d21d4482be56b14295d2525e41f8386d1bfe6f" +dependencies = [ + "bech32", + "chacha20poly1305", + "clap", + "dirs", + "futures", + "hex", + "hkdf", + "libc", + "rand 0.10.2", + "rtnetlink", + "secp256k1", + "serde", + "serde_json", + "serde_yaml", + "sha2", + "simple-dns", + "socket2", + "thiserror 2.0.19", + "tokio", + "tokio-socks", + "tracing", + "tracing-subscriber", + "tun", +] + +[[package]] +name = "futures" +version = "0.3.33" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a88cf1f829d945f548cf8fec32c61b1f202b6d93b45848602fc02af4b12ad218" +dependencies = [ + "futures-channel", + "futures-core", + "futures-executor", + "futures-io", + "futures-sink", + "futures-task", + "futures-util", +] + +[[package]] +name = "futures-channel" +version = "0.3.33" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "262590f4fe6afeb0bc83be1daa64e52657fe185690a958af7f3ad0e92085c5ae" +dependencies = [ + "futures-core", + "futures-sink", +] + +[[package]] +name = "futures-core" +version = "0.3.33" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2cd50c473c80f6d7c3670a752354b8e569b1a7cbfdc0419ec88e5edad85e0dc7" + +[[package]] +name = "futures-executor" +version = "0.3.33" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6754879cc9f2c66f88c6e5c35344bb0bdb0708b0352b1201815667c7eabc7458" +dependencies = [ + "futures-core", + "futures-task", + "futures-util", +] + +[[package]] +name = "futures-io" +version = "0.3.33" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4577ecaa3c4f96589d473f679a71b596316f6641bc350038b962a5daf0085d7a" + +[[package]] +name = "futures-lite" +version = "2.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f78e10609fe0e0b3f4157ffab1876319b5b0db102a2c60dc4626306dc46b44ad" +dependencies = [ + "futures-core", + "pin-project-lite", +] + +[[package]] +name = "futures-macro" +version = "0.3.33" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2d6d3cde68c518367be28956066ddfef33813991b77a55005a69dae04bf3b10b" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "futures-sink" +version = "0.3.33" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e34418ac499d6305c2fb5ad0ed2f6ac998c5f8ca209b4510f7f94242c647e307" + +[[package]] +name = "futures-task" +version = "0.3.33" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b231ed28831efb4a61a08580c4bc233ec56bc009f4cd8f52da2c3cb97df0c109" + +[[package]] +name = "futures-util" +version = "0.3.33" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a77a90a256fce34da66415271e30f94ee91c57b04b8a2c042d9cf3220179deaa" +dependencies = [ + "futures-channel", + "futures-core", + "futures-io", + "futures-macro", + "futures-sink", + "futures-task", + "memchr", + "pin-project-lite", + "slab", +] + +[[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", + "libc", + "wasi", +] + +[[package]] +name = "getrandom" +version = "0.4.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "300e883d756b2e4ec94e02791f39b04b522276138852cfc41d9fb7e904106099" +dependencies = [ + "cfg-if", + "libc", + "r-efi", + "rand_core 0.10.1", +] + +[[package]] +name = "hashbrown" +version = "0.17.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ed5909b6e89a2db4456e54cd5f673791d7eca6732202bbf2a9cc504fe2f9b84a" + +[[package]] +name = "heck" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2304e00983f87ffb38b55b444b5e3b60a884b5d30c0fca7d82fe33449bbe55ea" + +[[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.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fda06d18ac606267c40c04e41b9947729bf8b9efe74bd4e82b61a5f26a510b9f" +dependencies = [ + "arrayvec", +] + +[[package]] +name = "hex-conservative" +version = "1.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "35431185f361ccf3ffc58254628af5f1f5d5f28531da2e02e5d6c82bbc282a10" +dependencies = [ + "arrayvec", +] + +[[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", +] + +[[package]] +name = "indexmap" +version = "2.14.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d466e9454f08e4a911e14806c24e16fba1b4c121d1ea474396f396069cf949d9" +dependencies = [ + "equivalent", + "hashbrown", +] + +[[package]] +name = "inout" +version = "0.1.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "879f10e63c20629ecabbb64a8010319738c66a5cd0c29b02d63d272b03751d01" +dependencies = [ + "generic-array", +] + +[[package]] +name = "ipnet" +version = "2.12.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d98f6fed1fde3f8c21bc40a1abb88dd75e67924f9cffc3ef95607bad8017f8e2" + +[[package]] +name = "is_terminal_polyfill" +version = "1.70.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a6cb138bb79a146c1bd460005623e142ef0181e3d0219cb493e02f7d08a35695" + +[[package]] +name = "itoa" +version = "1.0.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8f42a60cbdf9a97f5d2305f08a87dc4e09308d1276d28c869c684d7777685682" + +[[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-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.119", +] + +[[package]] +name = "lazy_static" +version = "1.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bbd2bcb4c963f2ddae06a2efc7e9f3591312473c50c6685e1f298068316e66fe" + +[[package]] +name = "libc" +version = "0.2.189" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3eaf3ede3fee6db1a4c2ee091bf8a8b4dccdc6d17f656fb07896ee72867612f2" + +[[package]] +name = "libloading" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "754ca22de805bb5744484a5b151a9e1a8e837d5dc232c2d7d8c2e3492edc8b60" +dependencies = [ + "cfg-if", + "windows-link", +] + +[[package]] +name = "libredox" +version = "0.1.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c943259e342f1e06ff2da7a83eabdfe7f92ce10262688dbf1895ff0b3e6e4652" +dependencies = [ + "libc", +] + +[[package]] +name = "log" +version = "0.4.33" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0ceec5bc11778974d1bcb055b18002eba7f4b3518b6a0081b3af5f21666da9ad" + +[[package]] +name = "matchers" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d1525a2a28c7f4fa0fc98bb91ae755d1e2d1505079e05539e35bc876b5d65ae9" +dependencies = [ + "regex-automata", +] + +[[package]] +name = "memchr" +version = "2.8.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cf8baf1c55e62ffcace7a9f06f4bd9cd3f0c4beb022d3b367256b91b87513d98" + +[[package]] +name = "mio" +version = "1.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "30d65c71f1ce40ab09135ce117d742b9f8a19ff91a41a8b57ed50bc2de59c427" +dependencies = [ + "libc", + "wasi", + "windows-sys 0.61.2", +] + +[[package]] +name = "ndk-sys" +version = "0.5.0+25.2.9519653" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8c196769dd60fd4f363e11d948139556a344e79d451aeb2fa2fd040738ef7691" +dependencies = [ + "jni-sys 0.3.1", +] + +[[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.28.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4ce3636fa715e988114552619582b530481fd5ef176a1e5c1bf024077c2c9445" +dependencies = [ + "bitflags", + "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.19", +] + +[[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 = "nix" +version = "0.30.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "74523f3a35e05aba87a1d978330aef40f67b0304ac79c1c00b294c9830543db6" +dependencies = [ + "bitflags", + "cfg-if", + "cfg_aliases", + "libc", +] + +[[package]] +name = "nix" +version = "0.31.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cf20d2fde8ff38632c426f1165ed7436270b44f199fc55284c38276f9db47c3d" +dependencies = [ + "bitflags", + "cfg-if", + "cfg_aliases", + "libc", +] + +[[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 = "once_cell" +version = "1.21.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9f7c3e4beb33f85d45ae3e3a1792185706c8e16d043238c593331cc7cd313b50" + +[[package]] +name = "once_cell_polyfill" +version = "1.70.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "384b8ab6d37215f3c5301a95a4accb5d64aa607f1fcb26a11b5303878451b4fe" + +[[package]] +name = "opaque-debug" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c08d65885ee38876c4f86fa503fb49d7b507c2b62552df7c70b2fce627e06381" + +[[package]] +name = "option-ext" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "04744f49eae99ab78e0d5c0b603ab218f515ea8cfe5a456d7629ad883a3b6e7d" + +[[package]] +name = "paranoid-android" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "101795d63d371b43e38d6e7254677657be82f17022f7f7893c268f33ac0caadc" +dependencies = [ + "lazy_static", + "ndk-sys", + "sharded-slab", + "smallvec", + "tracing-core", + "tracing-subscriber", +] + +[[package]] +name = "parking" +version = "2.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f38d5652c16fde515bb1ecef450ab0f6a219d619a7274976324d5e377f7dceba" + +[[package]] +name = "paste" +version = "1.0.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "57c0d7b74b563b49d38dae00a0c37d4d6de9b432382b2892f0574ddcae73fd0a" + +[[package]] +name = "pin-project-lite" +version = "0.2.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a89322df9ebe1c1578d689c92318e070967d1042b512afbe49518723f4e6d5cd" + +[[package]] +name = "piper" +version = "0.2.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c835479a4443ded371d6c535cbfd8d31ad92c5d23ae9770a61bc155e4992a3c1" +dependencies = [ + "atomic-waker", + "fastrand", + "futures-io", +] + +[[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 = "ppv-lite86" +version = "0.2.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "85eae3c4ed2f50dcfe72643da4befc30deadb458a9b590d720cde2f2b1e97da9" +dependencies = [ + "zerocopy", +] + +[[package]] +name = "proc-macro2" +version = "1.0.107" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "985e7ec9bb745e6ce6535b544d84d6cd6f7ad8bd711c398938ae983b91a766d9" +dependencies = [ + "unicode-ident", +] + +[[package]] +name = "quote" +version = "1.0.47" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1fbf4db142a473a8d80c26bbf18454ed458bf8d26c8219c331daecfdbd079001" +dependencies = [ + "proc-macro2", +] + +[[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.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "22f6172bdec972074665ed81ed53b71da00bfc44b65a753cfde883ec4c702a1a" +dependencies = [ + "libc", + "rand_chacha", + "rand_core 0.6.4", +] + +[[package]] +name = "rand" +version = "0.10.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c7f5fa3a058cd35567ef9bfa5e75732bee0f9e4c55fa90477bef2dfcdbc4be80" +dependencies = [ + "chacha20 0.10.1", + "getrandom 0.4.3", + "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_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.10.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "63b8176103e19a2643978565ca18b50549f6101881c443590420e4dc998a3c69" + +[[package]] +name = "redox_users" +version = "0.5.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a4e608c6638b9c18977b00b475ac1f28d14e84b27d8d42f70e0bf1e3dec127ac" +dependencies = [ + "getrandom 0.2.17", + "libredox", + "thiserror 2.0.19", +] + +[[package]] +name = "regex-automata" +version = "0.4.16" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8fcfdb36bda0c880c5931cdc7a2bcdc8ba4556847b9d912bca70bc94708711ad" +dependencies = [ + "aho-corasick", + "memchr", + "regex-syntax", +] + +[[package]] +name = "regex-syntax" +version = "0.8.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d6f6ff9a378485b298a5286656da665ba74413d36db0979633275d2e708145d4" + +[[package]] +name = "rtnetlink" +version = "0.20.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4b960d5d873a75b5be9761b1e73b146f52dddcd27bac75263f40fba686d4d7b5" +dependencies = [ + "futures-channel", + "futures-util", + "log", + "netlink-packet-core", + "netlink-packet-route", + "netlink-proto", + "netlink-sys", + "nix 0.30.1", + "thiserror 1.0.69", + "tokio", +] + +[[package]] +name = "ryu" +version = "1.0.23" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9774ba4a74de5f7b1c1451ed6cd5285a32eddb5cccb8cc655a4e50009e06477f" + +[[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 = "secp256k1" +version = "0.30.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b50c5943d326858130af85e049f2661ba3c78b26589b8ab98e65e80ae44a1252" +dependencies = [ + "bitcoin_hashes", + "rand 0.8.7", + "secp256k1-sys", +] + +[[package]] +name = "secp256k1-sys" +version = "0.10.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d4387882333d3aa8cb20530a17c69a3752e97837832f34f6dccc760e715001d9" +dependencies = [ + "cc", +] + +[[package]] +name = "serde" +version = "1.0.229" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4148590afebada386688f18773da617792bf2ef03ffc1e4cbd2b1d45b023e0ba" +dependencies = [ + "serde_core", + "serde_derive", +] + +[[package]] +name = "serde_core" +version = "1.0.229" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "67dca2c9c51e58a4791a4b1ed58308b39c64224d349a935ab5039aa360942a48" +dependencies = [ + "serde_derive", +] + +[[package]] +name = "serde_derive" +version = "1.0.229" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e7a5d71263a5a7d47b41f6b3f06ba276f10cc18b0931f1799f710578e2309348" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.3", +] + +[[package]] +name = "serde_json" +version = "1.0.151" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c841b55ecdae098c80dcae9cf767f6f8a0c2cdb3416bbef72181df4d0fe73f14" +dependencies = [ + "itoa", + "memchr", + "serde", + "serde_core", + "zmij", +] + +[[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 = "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", +] + +[[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 = "2.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f8fadd59c855ef2080decdef8ff161eb6661b86933c9d82e5ba29dc602a55aba" + +[[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 = "simple-dns" +version = "0.11.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7a75cbde1bf934313596a004973e462f9a82caa814dcf1a5f507bdf51597eeb4" +dependencies = [ + "bitflags", +] + +[[package]] +name = "slab" +version = "0.4.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0c790de23124f9ab44544d7ac05d60440adc586479ce501c1d6d7da3cd8c9cf5" + +[[package]] +name = "smallvec" +version = "1.15.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8ed6a63f02c8539c91a8685a86f4099661ba3da017932f6ebbea6de3f0fa7c90" + +[[package]] +name = "socket2" +version = "0.6.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c3d1e2c7f27f8d4cb10542a02c49005dbd6e93095799d6f3be745fae9f8fedd4" +dependencies = [ + "libc", + "windows-sys 0.61.2", +] + +[[package]] +name = "strsim" +version = "0.11.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7da8b5736845d9f2fcb837ea5d9e2628564b3b043a70948a3f0b778838c5fb4f" + +[[package]] +name = "subtle" +version = "2.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "13c2bddecc57b384dee18652358fb23172facb8a2c51ccc10d74c157bdea3292" + +[[package]] +name = "syn" +version = "2.0.119" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "872831b642d1a07999a962a351ed35b955ea2cfc8f3862091e2a240a84f17297" +dependencies = [ + "proc-macro2", + "quote", + "unicode-ident", +] + +[[package]] +name = "syn" +version = "3.0.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "53e9bae58849f64dfa4f5d5ae372c8341f7305f82a3868709269343628b659a3" +dependencies = [ + "proc-macro2", + "quote", + "unicode-ident", +] + +[[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.19" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "09a43598840e33d5b0331f38c5e30d13bb11c11210a4b58f0d9b18a5a5eefcd9" +dependencies = [ + "thiserror-impl 2.0.19", +] + +[[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.119", +] + +[[package]] +name = "thiserror-impl" +version = "2.0.19" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "43cbfe0cf76104d42a574802844187e84a305e531ed54455f11fbde0f10541cd" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.3", +] + +[[package]] +name = "thread_local" +version = "1.1.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1ad99c4c6d32803332c548b1af0540b357b3f5fc0be8f6c6bfe8b2e6ae784070" +dependencies = [ + "cfg-if", +] + +[[package]] +name = "tokio" +version = "1.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "202caea871b69668250d242070849eb495be178ed697a3e98aebce5bc81a0bed" +dependencies = [ + "bytes", + "libc", + "mio", + "pin-project-lite", + "signal-hook-registry", + "socket2", + "tokio-macros", + "windows-sys 0.61.2", +] + +[[package]] +name = "tokio-macros" +version = "2.7.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6328af13490e73a9b4694030fafd93f8c8c6a9dede33e821c3fc63eddf8042ba" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "tokio-socks" +version = "0.5.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a7e2948f60dbe26b35f2c7fb74ac2854c1fddded0fe9d7548fcc674a246f7615" +dependencies = [ + "either", + "futures-util", + "thiserror 1.0.69", + "tokio", +] + +[[package]] +name = "tokio-util" +version = "0.7.19" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "494815d09bf52b5548659851081238f0ca39ff638363907596da739561c62c52" +dependencies = [ + "bytes", + "futures-core", + "futures-sink", + "libc", + "pin-project-lite", + "tokio", +] + +[[package]] +name = "tracing" +version = "0.1.44" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "63e71662fa4b2a2c3a26f570f037eb95bb1f85397f3cd8076caed2f026a6d100" +dependencies = [ + "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.119", +] + +[[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.23" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cb7f578e5945fb242538965c2d0b04418d38ec25c79d160cd279bf0731c8d319" +dependencies = [ + "matchers", + "nu-ansi-term", + "once_cell", + "regex-automata", + "sharded-slab", + "smallvec", + "thread_local", + "tracing", + "tracing-core", + "tracing-log", +] + +[[package]] +name = "tun" +version = "0.8.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8bb55e468c585e02ef89ba7a1a9848871ac942dfc64547ad4d1166b0f61a98be" +dependencies = [ + "bytes", + "cfg-if", + "futures", + "futures-core", + "ipnet", + "libc", + "log", + "nix 0.31.3", + "serde_json", + "thiserror 2.0.19", + "tokio", + "tokio-util", + "windows-sys 0.61.2", + "wintun-bindings", +] + +[[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.24" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e6e4313cd5fcd3dad5cafa179702e2b244f760991f45397d14d4ebf38247da75" + +[[package]] +name = "universal-hash" +version = "0.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fc1de2c688dc15305988b563c3854064043356019f97a4b46276fe734c4f07ea" +dependencies = [ + "crypto-common", + "subtle", +] + +[[package]] +name = "unsafe-libyaml" +version = "0.2.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "673aac59facbab8a9007c7f6108d11f63b603f7cabff99fabf650fea5c32b861" + +[[package]] +name = "utf8parse" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "06abde3611657adf66d383f00b093d7faecc7fa57071cce2578660c9f1010821" + +[[package]] +name = "valuable" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ba73ea9cf16a25df0c8caa16c51acb937d5712a8429db78a3ee29d5dcacd3a65" + +[[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 = "wasi" +version = "0.11.1+wasi-snapshot-preview1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ccf3ec651a847eb01de73ccad15eb7d99f80485de043efb2f370cd654f4ea44b" + +[[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 = "windows-link" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f0805222e57f7521d6a62e36fa9163bc891acd422f971defe97d64e70d0a4fe5" + +[[package]] +name = "windows-sys" +version = "0.45.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "75283be5efb2831d37ea142365f009c02ec203cd29a3ebecbc093d52315b66d0" +dependencies = [ + "windows-targets", +] + +[[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", + "windows_aarch64_msvc", + "windows_i686_gnu", + "windows_i686_msvc", + "windows_x86_64_gnu", + "windows_x86_64_gnullvm", + "windows_x86_64_msvc", +] + +[[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_msvc" +version = "0.42.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e08e8864a60f06ef0d0ff4ba04124db8b0fb3be5776a5cd47641e942e58c4d43" + +[[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_msvc" +version = "0.42.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "44d840b6ec649f480a41c8d80f9c65108b92d89345dd94027bfe06ac444d1060" + +[[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_gnullvm" +version = "0.42.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "26d41b46a36d453748aedef1486d5c7a85db22e56aff34643984ea85514e94a3" + +[[package]] +name = "windows_x86_64_msvc" +version = "0.42.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9aec5da331524158c6d1a4ac0ab1541149c0b9505fde06423b02f5ef0106b9f0" + +[[package]] +name = "wintun-bindings" +version = "0.7.39" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fc4494d02357537af05cf526be7b817a51752b688a78926af57379abd840d911" +dependencies = [ + "blocking", + "futures", + "libloading", + "log", + "thiserror 2.0.19", + "windows-sys 0.61.2", +] + +[[package]] +name = "zerocopy" +version = "0.8.55" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b5a105cd7b140f6eeec8acff2ea38135d3cab283ada58540f629fe51e46696eb" +dependencies = [ + "zerocopy-derive", +] + +[[package]] +name = "zerocopy-derive" +version = "0.8.55" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0fe976fb70c78cd64cccfe3a6fc142244e8a77b70959b30faf9d0ac37ee228eb" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "zeroize" +version = "1.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e13c156562582aa81c60cb29407084cdb54c4164760106ab78e6c5b0858cf64e" + +[[package]] +name = "zmij" +version = "1.0.23" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "29666d0abbfad1e3dc4dcf6144730dd3a3ab225bbbdac83319345b1b44ccfc1b" diff --git a/Android/rust/archy-fips-core/Cargo.toml b/Android/rust/archy-fips-core/Cargo.toml new file mode 100644 index 00000000..e6cc8cf4 --- /dev/null +++ b/Android/rust/archy-fips-core/Cargo.toml @@ -0,0 +1,48 @@ +# Embedded FIPS mesh node for the Archipelago companion app. +# +# Built for Android via cargo-ndk (see Android/app/build.gradle.kts, task +# buildRustArm64) into app/src/main/jniLibs/arm64-v8a/libarchy_fips_core.so. +# Also builds on the host so `cargo test` covers the non-JNI logic. +# +# `fips` is pinned to the fips-native fork rev that this integration was +# developed against — the fork carries Android support upstream lacks +# (Tun::from_fd for a VpnService-owned fd, cfg(target_os = "android") paths). +# Override with a local checkout when hacking on fips itself: +# CARGO_NET_OFFLINE=false cargo ndk ... --config 'patch."https://github.com/9qeklajc/fips-native".fips.path="/path/to/fips-native/fips"' +[package] +name = "archy-fips-core" +version = "0.1.0" +edition = "2021" +license = "MIT" +publish = false + +[lib] +name = "archy_fips_core" +# rlib for host tests; cdylib for the Android shared library. +crate-type = ["lib", "cdylib"] + +[dependencies] +# default-features drops the ratatui TUI; tun-support enables Node::start_with_tun_fd. +# Zazawowow/fips-native `fast-join-pinned` = upstream pinned rev 46494a74 + +# discovery re-fire on topology change (fresh 5G join: first route no longer +# waits out doomed pre-join lookups). Upstream 9qeklajc denies pushes. +fips = { git = "https://github.com/Zazawowow/fips-native", rev = "07d21d4482be56b14295d2525e41f8386d1bfe6f", default-features = false, features = ["tun-support"] } +anyhow = "1.0" +serde_json = "1.0" +hex = "0.4" +# OS CSPRNG for identity generation (crypto rule: no thread-local RNG for keys). +getrandom = "0.2" +tokio = { version = "1", features = ["rt-multi-thread", "sync", "time", "macros"] } +tracing = "0.1" +# fcntl: force the VpnService TUN fd into blocking mode (see mesh::start). +libc = "0.2" + +# The JNI surface only exists on Android; host builds skip it and drive the +# mesh module directly (tests). +[target.'cfg(target_os = "android")'.dependencies] +jni = "0.21" +# Bridge `tracing` (ours + fips) to logcat: `adb logcat -s archy-fips`. +tracing-subscriber = { version = "0.3", features = ["env-filter"] } +paranoid-android = "0.2" + +[workspace] diff --git a/Android/rust/archy-fips-core/src/jni_glue.rs b/Android/rust/archy-fips-core/src/jni_glue.rs new file mode 100644 index 00000000..5f891135 --- /dev/null +++ b/Android/rust/archy-fips-core/src/jni_glue.rs @@ -0,0 +1,129 @@ +//! JNI surface for `com.archipelago.app.fips.FipsNative` — JSON over strings, +//! no codegen (the myco / nostr-vpn embedding pattern). Errors come back as +//! `{"error": "…"}` so Kotlin never sees a raw exception from native code. + +use std::sync::Once; + +use jni::objects::{JClass, JString}; +use jni::sys::{jboolean, jint, jstring}; +use jni::JNIEnv; + +use crate::mesh; + +static LOG_INIT: Once = Once::new(); + +fn init_logging() { + LOG_INIT.call_once(|| { + use tracing_subscriber::layer::SubscriberExt; + use tracing_subscriber::util::SubscriberInitExt; + let _ = tracing_subscriber::registry() + .with(tracing_subscriber::EnvFilter::new("info")) + .with(paranoid_android::layer("archy-fips")) + .try_init(); + }); +} + +fn jstr(env: &mut JNIEnv, s: &JString) -> String { + env.get_string(s).map(|s| s.into()).unwrap_or_default() +} + +fn out(env: &JNIEnv, s: String) -> jstring { + env.new_string(s) + .map(|s| s.into_raw()) + .unwrap_or(std::ptr::null_mut()) +} + +fn err_json(e: impl std::fmt::Display) -> String { + serde_json::json!({ "error": e.to_string() }).to_string() +} + +fn identity_json(info: &mesh::IdentityInfo) -> String { + serde_json::json!({ + "secret": info.secret_hex, + "npub": info.npub, + "address": info.address, + }) + .to_string() +} + +/// Kotlin: `external fun generateIdentity(): String` +#[no_mangle] +pub extern "system" fn Java_com_archipelago_app_fips_FipsNative_generateIdentity( + env: JNIEnv, + _class: JClass, +) -> jstring { + init_logging(); + let json = match mesh::generate_identity() { + Ok(info) => identity_json(&info), + Err(e) => err_json(e), + }; + out(&env, json) +} + +/// Kotlin: `external fun deriveIdentity(secret: String): String` +#[no_mangle] +pub extern "system" fn Java_com_archipelago_app_fips_FipsNative_deriveIdentity( + mut env: JNIEnv, + _class: JClass, + secret: JString, +) -> jstring { + init_logging(); + let secret = jstr(&mut env, &secret); + let json = match mesh::derive_identity(&secret) { + Ok(info) => identity_json(&info), + Err(e) => err_json(e), + }; + out(&env, json) +} + +/// Kotlin: `external fun start(secret: String, peersJson: String, tunFd: Int, listenPort: Int): String` +/// Returns `{"npub": "...", "address": "..."}` or `{"error": "..."}`. +/// `listenPort` 0 = outbound-only; non-zero = fixed UDP bind (party mode). +#[no_mangle] +pub extern "system" fn Java_com_archipelago_app_fips_FipsNative_start( + mut env: JNIEnv, + _class: JClass, + secret: JString, + peers_json: JString, + tun_fd: jint, + listen_port: jint, +) -> jstring { + init_logging(); + let secret = jstr(&mut env, &secret); + let peers = jstr(&mut env, &peers_json); + let listen_port = u16::try_from(listen_port).unwrap_or(0); + let json = match mesh::start(&secret, &peers, tun_fd, listen_port) { + Ok((npub, address)) => { + serde_json::json!({ "npub": npub, "address": address }).to_string() + } + Err(e) => err_json(e), + }; + out(&env, json) +} + +/// Kotlin: `external fun stop()` +#[no_mangle] +pub extern "system" fn Java_com_archipelago_app_fips_FipsNative_stop( + _env: JNIEnv, + _class: JClass, +) { + mesh::stop(); +} + +/// Kotlin: `external fun isRunning(): Boolean` +#[no_mangle] +pub extern "system" fn Java_com_archipelago_app_fips_FipsNative_isRunning( + _env: JNIEnv, + _class: JClass, +) -> jboolean { + mesh::is_running() as jboolean +} + +/// Kotlin: `external fun statusJson(): String` +#[no_mangle] +pub extern "system" fn Java_com_archipelago_app_fips_FipsNative_statusJson( + env: JNIEnv, + _class: JClass, +) -> jstring { + out(&env, mesh::status_json()) +} diff --git a/Android/rust/archy-fips-core/src/lib.rs b/Android/rust/archy-fips-core/src/lib.rs new file mode 100644 index 00000000..98a22d7e --- /dev/null +++ b/Android/rust/archy-fips-core/src/lib.rs @@ -0,0 +1,17 @@ +//! Embedded FIPS mesh node for the Archipelago companion app. +//! +//! The phone runs a real, leaf-only FIPS node in-process: Android's +//! `VpnService` owns the TUN fd (routing only `fd00::/8`, so normal traffic +//! never touches the tunnel) and hands it to [`fips::Node::start_with_tun_fd`]. +//! Peering is outbound-only — the pairing QR carries the node's npub and +//! transport endpoints, and FIPS nodes accept inbound peers without prior +//! registration, so no server-side enrollment step exists. +//! +//! The JNI surface (`jni_glue`, Android-only) is deliberately tiny and +//! JSON-over-strings, mirroring the myco / nostr-vpn embedding pattern: +//! `generateIdentity`, `deriveIdentity`, `start`, `stop`, `isRunning`. + +pub mod mesh; + +#[cfg(target_os = "android")] +mod jni_glue; diff --git a/Android/rust/archy-fips-core/src/mesh.rs b/Android/rust/archy-fips-core/src/mesh.rs new file mode 100644 index 00000000..6f1a9e37 --- /dev/null +++ b/Android/rust/archy-fips-core/src/mesh.rs @@ -0,0 +1,295 @@ +//! Mesh lifecycle: identity, config assembly, and the node task. +//! +//! Host-buildable (no JNI) so the config/identity logic is unit-testable; +//! only [`start`] needs a real TUN fd and therefore only runs on-device. + +use std::sync::atomic::{AtomicBool, Ordering}; +use std::sync::{Arc, Mutex}; +use std::time::Duration; + +use anyhow::{anyhow, Context, Result}; +use fips::config::{PeerConfig, TransportInstances, UdpConfig}; +use fips::{Config, Identity, Node}; +use tokio::sync::Notify; + +/// How long `start` waits for the node to come up (TUN attach + transports). +const START_TIMEOUT: Duration = Duration::from_secs(15); + +/// How long `stop` waits for the node task to drain. +const STOP_TIMEOUT: Duration = Duration::from_secs(5); + +struct MeshHandle { + runtime: tokio::runtime::Runtime, + task: Option<tokio::task::JoinHandle<()>>, + shutdown: Arc<Notify>, + running: Arc<AtomicBool>, + npub: String, + address: String, +} + +static MESH: Mutex<Option<MeshHandle>> = Mutex::new(None); + +#[derive(Debug, Clone)] +pub struct IdentityInfo { + pub secret_hex: String, + pub npub: String, + /// The phone's own ULA on the mesh (fd::/8), for VpnService.addAddress. + pub address: String, +} + +/// Generate a fresh mesh identity from the OS CSPRNG. +pub fn generate_identity() -> Result<IdentityInfo> { + // ~1 in 2^128 chance a candidate is off the curve; loop regardless. + loop { + let mut bytes = [0u8; 32]; + getrandom::getrandom(&mut bytes).context("OS RNG")?; + if let Ok(id) = Identity::from_secret_bytes(&bytes) { + return Ok(IdentityInfo { + secret_hex: hex::encode(bytes), + npub: id.npub(), + address: id.address().to_ipv6().to_string(), + }); + } + } +} + +/// Re-derive npub + ULA from a stored secret. +pub fn derive_identity(secret: &str) -> Result<IdentityInfo> { + let id = Identity::from_secret_str(secret).map_err(|e| anyhow!("bad secret: {e}"))?; + Ok(IdentityInfo { + secret_hex: secret.to_string(), + npub: id.npub(), + address: id.address().to_ipv6().to_string(), + }) +} + +/// Build the phone-side node config: leaf-only (never routes third-party +/// traffic — battery), no DNS responder, TUN enabled but attached to the +/// VpnService fd rather than created. +/// +/// `listen_port` 0 = ephemeral UDP (outbound-only, the default posture). +/// Non-zero = fixed UDP bind so a nearby phone can dial us directly over a +/// local link (party mode); leaf_only still guarantees we never carry +/// third-party transit even while accepting an inbound link. +pub fn build_config(secret: &str, peers: Vec<PeerConfig>, listen_port: u16) -> Config { + let mut cfg = Config::default(); + cfg.node.identity.nsec = Some(secret.to_string()); + cfg.node.identity.persistent = false; + cfg.node.leaf_only = true; + cfg.tun.enabled = true; + cfg.tun.mtu = Some(1280); + cfg.dns.enabled = false; + cfg.transports.udp = TransportInstances::Single(UdpConfig { + bind_addr: Some(format!("0.0.0.0:{listen_port}")), + ..Default::default() + }); + // TCP with no bind_addr = outbound-only (fallback when UDP is blocked). + cfg.transports.tcp = TransportInstances::Single(Default::default()); + // Fast-connect profile — a phone opens the app and expects the node NOW. + // Stock pacing is tuned for always-on routers: a failed discovery backs + // off 30s, session resends gap out to 8-16s, dead links redial at + // 5s→300s. Over 5G that stacked into a ~40s first connect (observed + // 2026-07-24: anchor +10s, session +40s). Retries only fire while a + // link/session is down, so steady-state traffic is unchanged. + cfg.node.retry.base_interval_secs = 1; // dead-link redial 1s,2s,4s… + cfg.node.retry.max_backoff_secs = 30; // …capped at 30s, not 5 min + cfg.node.retry.max_retries = 30; + cfg.node.rate_limit.handshake_resend_interval_ms = 400; + cfg.node.rate_limit.handshake_resend_backoff = 1.5; + cfg.node.rate_limit.handshake_max_resends = 10; + cfg.node.discovery.backoff_base_secs = 1; // failed lookup retries fast + cfg.node.discovery.backoff_max_secs = 30; + cfg.node.discovery.retry_interval_secs = 2; + cfg.node.discovery.max_attempts = 3; + // Lookups launched before the tree position settles are doomed; a 10s + // completion timeout made each one cost 10s before the 1s retry could + // fire (observed: 19s to a route on a fresh join). Fail fast instead — + // the resend-within-window above still gives each attempt two shots. + cfg.node.discovery.timeout_secs = 5; + cfg.peers = peers; + cfg +} + +/// Parse the peers JSON handed over from Kotlin. Shape = fips `PeerConfig`: +/// `[{"npub":"…","alias":"…","addresses":[{"transport":"udp","addr":"host:2121","priority":10},…]}]` +pub fn parse_peers(peers_json: &str) -> Result<Vec<PeerConfig>> { + serde_json::from_str(peers_json).context("peers JSON") +} + +/// Start the mesh node on the given TUN fd (from `VpnService.establish()`, +/// detached — the node owns it from here). Returns (npub, ula) on success. +/// Any previously running node is stopped first. +pub fn start(secret: &str, peers_json: &str, tun_fd: i32, listen_port: u16) -> Result<(String, String)> { + stop(); + + // Android hands the VpnService TUN fd over in non-blocking mode on some + // OS builds. The fips TUN reader is a dedicated blocking-read thread that + // treats EAGAIN as fatal — the loop died at startup ("TUN read error … + // Try again (os error 11)" on-device), so sessions came up but no packet + // ever entered the mesh. Force the fd into the blocking mode the reader + // is designed for. + unsafe { + let flags = libc::fcntl(tun_fd, libc::F_GETFL); + if flags >= 0 && (flags & libc::O_NONBLOCK) != 0 { + libc::fcntl(tun_fd, libc::F_SETFL, flags & !libc::O_NONBLOCK); + } + } + + let peers = parse_peers(peers_json)?; + let config = build_config(secret, peers, listen_port); + let mut node = Node::new(config).map_err(|e| anyhow!("node init: {e}"))?; + let npub = node.npub(); + let address = node.identity().address().to_ipv6().to_string(); + + let runtime = tokio::runtime::Builder::new_multi_thread() + .worker_threads(2) + .enable_all() + .thread_name("archy-fips") + .build() + .context("tokio runtime")?; + + let shutdown = Arc::new(Notify::new()); + let running = Arc::new(AtomicBool::new(false)); + let (started_tx, started_rx) = tokio::sync::oneshot::channel::<Result<()>>(); + + let task = { + let shutdown = shutdown.clone(); + let running = running.clone(); + runtime.spawn(async move { + match node.start_with_tun_fd(tun_fd).await { + Ok(()) => { + running.store(true, Ordering::SeqCst); + let _ = started_tx.send(Ok(())); + } + Err(e) => { + let _ = started_tx.send(Err(anyhow!("node start: {e}"))); + return; + } + } + tokio::select! { + result = node.run_rx_loop() => { + if let Err(e) = result { + tracing::error!("mesh rx loop error: {e}"); + } + } + _ = shutdown.notified() => {} + } + if let Err(e) = node.stop().await { + tracing::warn!("mesh stop: {e}"); + } + running.store(false, Ordering::SeqCst); + }) + }; + + let started = runtime + .block_on(async { tokio::time::timeout(START_TIMEOUT, started_rx).await }) + .map_err(|_| anyhow!("node start timed out"))? + .map_err(|_| anyhow!("node task died during start"))?; + if let Err(e) = started { + runtime.shutdown_background(); + return Err(e); + } + + *MESH.lock().unwrap() = Some(MeshHandle { + runtime, + task: Some(task), + shutdown, + running, + npub: npub.clone(), + address: address.clone(), + }); + Ok((npub, address)) +} + +/// Stop the mesh node if running. Idempotent. +pub fn stop() { + let Some(mut handle) = MESH.lock().unwrap().take() else { + return; + }; + handle.shutdown.notify_waiters(); + if let Some(task) = handle.task.take() { + let _ = handle + .runtime + .block_on(async { tokio::time::timeout(STOP_TIMEOUT, task).await }); + } + handle.runtime.shutdown_background(); +} + +pub fn is_running() -> bool { + MESH.lock() + .unwrap() + .as_ref() + .map(|h| h.running.load(Ordering::SeqCst)) + .unwrap_or(false) +} + +/// `{running, npub, address}` for the Kotlin status surface. +pub fn status_json() -> String { + let guard = MESH.lock().unwrap(); + match guard.as_ref() { + Some(h) => serde_json::json!({ + "running": h.running.load(Ordering::SeqCst), + "npub": h.npub, + "address": h.address, + }) + .to_string(), + None => serde_json::json!({ "running": false }).to_string(), + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn identity_roundtrip() { + let a = generate_identity().unwrap(); + let b = derive_identity(&a.secret_hex).unwrap(); + assert_eq!(a.npub, b.npub); + assert_eq!(a.address, b.address); + // ULA in fd::/8 + assert!(a.address.starts_with("fd")); + } + + #[test] + fn peers_json_parses_into_peer_config() { + let peers = parse_peers( + r#"[{ + "npub": "npub1abc", + "alias": "My Archipelago", + "addresses": [ + {"transport": "udp", "addr": "192.0.2.10:2121", "priority": 10}, + {"transport": "tcp", "addr": "192.0.2.10:8443", "priority": 20} + ] + }]"#, + ) + .unwrap(); + assert_eq!(peers.len(), 1); + assert_eq!(peers[0].addresses.len(), 2); + assert!(peers[0].is_auto_connect()); + } + + #[test] + fn config_is_leaf_only_with_tun() { + let id = generate_identity().unwrap(); + let cfg = build_config(&id.secret_hex, vec![], 0); + assert!(cfg.node.leaf_only); + assert!(cfg.tun.enabled); + assert_eq!(cfg.tun.mtu(), 1280); + assert!(!cfg.dns.enabled); + assert!(!cfg.transports.udp.is_empty()); + assert!(!cfg.transports.tcp.is_empty()); + } + + #[test] + fn listen_port_sets_fixed_udp_bind() { + let id = generate_identity().unwrap(); + let cfg = build_config(&id.secret_hex, vec![], 2121); + // Party mode keeps leaf_only — accepting a link is not routing transit. + assert!(cfg.node.leaf_only); + let TransportInstances::Single(udp) = &cfg.transports.udp else { + panic!("expected single UDP transport"); + }; + assert_eq!(udp.bind_addr.as_deref(), Some("0.0.0.0:2121")); + } +} 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..08b6e9ca --- /dev/null +++ b/CHANGELOG.md @@ -0,0 +1,1296 @@ +# Changelog + +## v1.7.127-alpha (2026-08-09) + +- **Your node now has its own assistant.** This is the first release to ship AIUI: a conversational screen that can answer from your node's own content — your films, music and files come first, the open web second — and can act on the node itself: install or remove an app, check what's running, or queue up your media, all through a fixed list of vetted actions rather than free rein. It is off-limits to your data until you say otherwise: every data category starts closed, grants are made in Settings → AI Data Access and live on the node itself, and anything that changes the node asks you to confirm in the dashboard's own chrome first — a declined action stays declined. What leaves the node is screened: your API key is stored encrypted and never written in plain text, credential-shaped strings are scrubbed from app logs before the model sees them, your public address and Wi-Fi name are stripped from network answers, web search is gated behind your login session, and cloud-bound text passes a secret scan on the way out. Three model backends are supported — Anthropic's API, a local Ollama, and pay-per-use Routstr with a hard prepaid budget ceiling — and mesh peers can reach the same loop with `!ai`. +- **Tor now tells you the truth, heals itself, and the Restart button really restarts it.** Three nodes ran for days with Tor completely dead while the dashboard said "Connected" — the indicator was reading a leftover address file, not the daemon, and the restart button reported success without checking. The cause was a configuration line Tor can never bind on our systems; a node could re-break itself from a single settings change. The node now refuses to write that line, checks Tor with a real connection instead of a leftover file, repairs its own Tor configuration at every start, and the Restart button only claims success once Tor is actually answering. Onion addresses that had silently never been published (BTCPay's included) come back with it. +- **Inviting another node as Trusted works again — on every node.** Generating a Trusted invite, or promoting a peer from the dropdown, silently failed everywhere: the security prompt that asks for your node password could never appear, because the message requesting it was being scrubbed out of the reply on its way to your browser. The prompt now opens, and if a trust change fails, the error appears inside the window you are looking at instead of hidden behind it. +- **The mempool explorer actually connects now.** The page loaded but sat empty forever. Three separate causes stacked up: the block index had spent days rebuilding without anything saying so, and then two different layers of the node's plumbing were dropping the live-data connection the page depends on — so everything reported healthy while your screen showed nothing. All three are fixed, and the node's own health checks now test the real connection a browser makes, so this cannot pass unnoticed again. +- **Apps no longer vanish after stopping cleanly.** A stopped app's container is deleted by design, but the restart policy meant an app that exited cleanly was never brought back — it simply disappeared until reinstalled. Backends now restart in every case, the node remembers what you have installed so a missing app is recreated rather than forgotten, and this release repairs the incorrect policy on apps installed by earlier versions. +- **Your Bitcoin node will not silently change software versions anymore.** "Latest" previously meant different things in different places — one path installed a newer build that deliberately halts until you make a network-rules decision, which froze one node's sync at a fixed block while it reported itself fully synced. Bitcoin Knots is now pinned to an explicit, known-good version; changing it is a decision you make, never a side effect of an update. +- **Smaller fixes:** the AI data-access settings now say plainly which categories the assistant can see but not act on; the transactions window's tab bar is transparent glass instead of a black block; BTCPay logins no longer fail with a server error when the node is under heavy load right at that moment. + +- **You can now replace your Lightning connection keys from Settings, without touching a terminal.** The tokens wallet apps like Zeus use to reach your node are bearer keys: anything that has ever seen one can spend from your node until they are replaced, and there is no way to cancel one individually. Replacing them was previously a script you had to SSH in and run, which in practice meant it never happened. Settings → Lightning credentials now shows when yours were issued, which node they belong to and how many channels must survive, then does the whole job behind your node password — with a step-by-step progress list, and a refusal to call it a success unless it has confirmed your node identity and every channel came back. Your coins and channels are not touched: nothing is closed, and the wallet is never re-created. Afterwards you re-pair Zeus by scanning the Lightning app's QR code again. +- **Replacing those keys no longer silently breaks BTCPay Server.** BTCPay holds its own copy of the key, and that copy cannot repair itself — so a node that replaced its keys ended up with BTCPay running, healthy, and unable to take a single Lightning payment, with nothing anywhere saying why. The dashboard now updates BTCPay's copy as part of the run and restarts it around its existing data, and the Settings screen warns you if it finds a node already stuck in that state. The command-line script fixes the same gap. +- **Lightning stops getting stuck locked on a busy node.** Lightning opens its databases before it will accept the password that unlocks the wallet, and on a loaded node that took nearly three minutes — longer than the node was willing to wait. Giving up restarted Lightning, which started the slow open again, so the wallet stayed locked forever and everything depending on it stayed broken. The node now waits as long as it takes. A genuinely wrong password still fails immediately. +- Known gaps, disclosed rather than buried: three voice-assistant ports remain open without authentication. Non-browser clients — phone apps for Vaultwarden, Home Assistant or Jellyfin, and git over the web — meet the login page and need an access token. The full 5x real-node lifecycle gate was not run for this release; reboot survival was verified directly on a live node — all installed apps returned after a cold reboot, uninstalled apps stayed gone, and restart policy was confirmed on every managed unit. + +## v1.7.126-alpha (2026-08-07) + +- **The most important fix in this release: the update button could take you backwards onto a version withdrawn for a security hole.** BTCPay Server published 2.4.2 to close a flaw that was being actively exploited — a way past two-factor authentication. Nodes that had already moved to 2.4.2 were then shown an "Update" button offering 2.3.9, the very release being withdrawn, and taking it would have rolled the node back onto the vulnerable version. The cause was that the node only asked whether the two version numbers differed, never which was newer, so any stale record anywhere could present a rollback as an upgrade. It now refuses to offer a lower version as an update, so a stale record fails safe instead of becoming a trap. BTCPay itself is on 2.4.2, and every place that still named the old version — including the fallback installer, which would have installed it outright — has been corrected. +- **An app now reports its own version, not a helper's.** Where an app is made of several parts, the node could read the version of the wrong part: BTCPay showed as "15.17", which is the version of its database, while offering an update to 2.4.2. That is the number update decisions are made from, so a nonsensical pair was being presented as a legitimate upgrade. When the node cannot identify an app's own container it now says so rather than guessing at a neighbour. +- **Your node issues its own certificate, so apps stop being flagged as insecure.** Each node now has its own certificate authority, with a one-step install from Settings, and app screens are served over the same secure connection as the dashboard rather than dropping back to an unprotected one. Apps answer on both the secure and plain address on the same port, so nothing that worked before stops working. +- **An app that is still starting says "starting".** It previously reported "App not reachable", which reads as a failure when the app is simply warming up. +- **Updates and app downloads now come from a proper domain name.** They previously used a bare numeric address over an unprotected connection. Downloads are now encrypted in transit, and the old address is kept as an automatic fallback for nodes whose clock or name lookup is off — the signature, not the address, is what makes either source safe. +- Also in this release: the tool app developers run to check their app description no longer rejects every valid file (it needed a program most machines do not have, and reported the missing program as a broken file); and the node's own security audit, which had been reporting all-clear, now actually inspects the files where credentials had been sitting. +- Housekeeping, disclosed rather than buried: this release removes Archipelago's own infrastructure details from the published source — machine names, addresses and internal working notes — ahead of the code being opened to the public. No behaviour changes for your node. +- Known gaps, unchanged from the last release: three voice-assistant ports remain open without authentication. Non-browser clients — phone apps for Vaultwarden, Home Assistant or Jellyfin, and git over the web — meet the login page and need an access token. The 5x real-node lifecycle gate was not run for this release. + +## v1.7.125-alpha (2026-08-06) + +- **The Lightning, Bitcoin, Electrum and mesh screens work again behind the login gate.** Since the gate went up, those screens loaded their frame and then showed every number as unreachable. The gate was deliberately hiding your login from the apps it protects — right for third-party apps, wrong for the node's own screens, which need that login to fetch your data. The gate now removes only its own credential, and the node's own screens explicitly receive yours. The same mistake was also quietly signing you out of apps with their own logins — Vaultwarden, Nextcloud, Gitea — on every single request; that stops too. +- **IndeeHub heals itself.** Three separate faults fixed: its database helper was recreated with permissions too tight to read its own files (it had crashed and restarted roughly ten thousand times on one node); on another node two of its seven parts could never be recreated at all because of how the node asked for their storage — it would remove the old part and then fail to build its replacement, leaving the app half-missing forever; and a regenerated password could lock the app out of a database that keeps the original. The storage fault fixes the same trap for every future multi-part app. +- **A missing piece of a running app now gets put back automatically.** If one container of a multi-part app disappears while its siblings are still running, the node treats that as a hole to repair rather than a choice to respect, and rebuilds the missing piece. An app you actually uninstalled stays uninstalled. +- **Send and Receive open clean every time.** Whatever you typed last — an address, an amount, and above all an armed "send all funds" toggle — no longer quietly carries over into the next payment. Choosing "send all funds" also shows the amount being swept instead of a confusing 0. +- **A sweep that cannot happen now says why.** Trying to sweep a balance that is below Bitcoin's dust minimum (about 546 sats) or not yet confirmed used to fail with "check server logs"; it now explains that no transaction can be built from those coins. +- **The camera scanner option no longer vanishes on desktop.** Browsers only allow the live camera on secure (HTTPS) pages, and the scan window silently hid the camera choice on plain connections — which read as "the scanner is gone". The option now stays visible and explains itself, and the photo and paste routes always work. The companion app's built-in scanner is untouched. +- **App data folders can no longer be "repaired" into a state the app cannot use.** When the node fixed a folder's ownership through its fallback path, it wrote the container's raw user number instead of the translated one, so the fix reported success while the app still could not open its own files — one node's BotFights restarted every ten seconds over exactly this. The translation is now applied. +- Also: the app login page uses the Archipelago mark and stays centred on phones with the keyboard open, app icons in the install window are no longer cropped, and when the node fails to build a container it now records the actual reason instead of a one-line stub that hid the cause of the IndeeHub fault for days. +- Known gaps, disclosed rather than buried: three voice-assistant ports remain open without authentication. Non-browser clients — phone apps for Vaultwarden, Home Assistant or Jellyfin, and git over the web — meet the login page and need an access token. The 5x real-node lifecycle gate was not run for this release. + +## v1.7.124-alpha (2026-08-05) + +- **The most important fix in this release: some nodes were left switched off by their own update, and could not switch themselves back on.** The node replaces its program and then exits, expecting the system to start it again — but nodes installed from older images carried a setting that only restarts the program if it *crashes*. A clean, deliberate exit looked like success, so nothing restarted it, and the node sat dead showing "server starting" with nothing able to start it. One of ours was down for over two hours this way, and three of four checked had the same setting waiting to bite. Your node now repairs that setting itself the first time it starts, so it survives every future update. +- **Portainer opens again.** Its screen reported the app as not responding because the app was quietly refusing to start: nodes have been running Portainer 2.39.1, their stored data was written by that version, and the app list pinned a version from two years earlier — so when the container was rebuilt it landed on the old one, which will not read newer data. The correct version is now pinned, older installs upgrade cleanly, and no data was touched. +- **Bitcoin starts reliably again.** A leftover settings file in the Bitcoin folder — one the node itself kept rewriting and Bitcoin no longer reads — is treated as fatal by Bitcoin, so affected nodes restarted every few seconds forever. The node no longer writes that file, removes stale copies, and treats any that remain as harmless. +- **Every app screen opens from My Apps again.** The login gate refused to be displayed inside another page at all, which is exactly how My Apps opens an app, so protected apps appeared broken. It now allows only your own node to display it, and refuses everyone else — a distinction the old setting could not express. +- **The app login screen now looks like the node's own.** Same rotating artwork, the same panel, the Archipelago mark, and the app's real icon shown as a tile the way My Apps shows it, instead of a plain box with a letter. +- **The Mesh screen uses wide displays properly.** On very large screens it stacked all five panels on top of each other, clipping three of the headings to a sliver and squeezing the map into a letterbox — more screen producing a worse view. It now shows one panel at a time, filling the space, with the map running edge to edge. +- **You can choose how long you stay signed in.** Settings → Account now offers an inactivity timeout and a hard limit, plus an option to re-enter your password before sending funds. TV and kiosk screens are never signed out for sitting idle, because there is nobody there to sign them back in. +- Updates now come from `source.archipelago-foundation.org` rather than a bare address, with the old one kept as an automatic fallback for nodes whose clock or name lookup is off. Also included: clearer wallet errors from ecash mints, and mesh peers reconnecting via their last known address before falling back to the wider network. +- Known gaps, disclosed rather than buried: three voice-assistant ports remain open without authentication; the correct fix puts them on a private network with the assistant. Non-browser clients — phone apps for Vaultwarden, Home Assistant or Jellyfin, and git over the web — meet the login page and need an access token. The 5x real-node lifecycle gate was not run for this release. + +## v1.7.123-alpha (2026-08-05) + +- **Five more screens on your node were readable by anyone who could reach it, and the previous release's own check said they were fine.** The Bitcoin, Lightning, Electrum, FIPS mesh and Fedimint Guardian screens each answered on their port with no login. They were missed because they work differently from ordinary apps: they run directly on the node's network rather than behind its container plumbing, so there was no address to pin and their descriptions listed no port at all — and the node builds its list of what to protect from exactly those descriptions. It therefore neither protected them nor listed them as unprotected. A check that reports success while five screens are open is worse than no check, and this was found by scanning the node from another machine rather than asking the node about itself. +- **What was actually readable was the page, not your money.** Every request on those ports that could have returned a credential — the Lightning connection details, the wallet passthrough, container logs, and every node command — already required a login and still refused without one. The Lightning macaroon fix from v1.7.120 was verified directly rather than assumed. What leaked was the screen itself: layout and code, no wallet data, no keys. +- All five now serve only to the node itself, with the login gate in front of them, exactly like the twenty app screens closed in the previous release. +- **Every port on the node now has a stated policy — there are no undecided ones left.** Eleven ports previously had no instruction either way and stayed open by default. The BotFights arena, the router screen and the Pine voice screen now require the node password. The ones that genuinely cannot take a login page stay open with a written reason: Fedimint's guardian and gateway connections (federation members authenticate to the federation), NetBird's management and dashboard ports (your VPN devices carry their own credentials and cannot hold a browser session, and its dashboard needs its own certificate), Pine's secure listener, and the Lightning REST port, which wallets reach with a macaroon exactly as before. +- Fresh installs are covered too, not just existing nodes. The five screens are delivered as prebuilt images, so a newly flashed node would have come up open even after this fix. All five were rebuilt, published, and then pulled back and inspected to confirm the fix is really inside them. +- Two delivery faults fixed alongside, either of which would have silently undone the above: two of the five screens were reaching nodes through no update path at all, so edits to them never arrived; and a fourth copy of the Bitcoin screen's configuration was being rewritten on every health check, which would have re-opened that port after everything else was corrected. +- Known gaps, disclosed rather than buried: non-browser clients — phone apps for Vaultwarden, Home Assistant or Jellyfin, and git over the web — meet the login page and need an access token. Three voice-assistant ports remain open without authentication; the correct fix puts them on a private network with the assistant. The 5x real-node lifecycle gate was not run for this release. + +## v1.7.122-alpha (2026-08-04) + +- **Your apps now ask for your node password before they open — over your home network, Tailscale, the mesh and Tor alike.** Until now anyone who could reach your node could open Immich, Nextcloud, Vaultwarden, Jellyfin, Grafana and the rest simply by typing the address and port, with no login at all. Twenty app screens now sit behind the same login you use for the node, showing you which app you are opening, and honouring two-factor if you have it switched on. Logging in at an app address logs you into the dashboard too, so it is one password, not one per app. This completes the groundwork disclosed in v1.7.121. +- **The things that must stay open stayed open.** Zeus and other remote wallets still reach your Lightning node directly, Electrum wallets still connect, and Bitcoin still talks to its peers — those connections carry their own proof of identity and a login page would simply break them. Every one of these seventeen exceptions now has to state in writing why it is safe to leave open, so the list is something you can read rather than something you have to discover. +- **A private address on your node was answering the mesh without a password.** One app's port was marked as being for this machine only, and the part of the node that carries mesh traffic did not know that — it forwarded requests from the whole mesh straight to it. Found while verifying the work above on a real node, not in testing. That path now refuses anything marked machine-only, and the app is reachable only from the node itself, as intended. +- **Tor addresses no longer skip the login.** An app published as a .onion address was handed straight to the app, because a Tor visitor carries no session cookie to check. The login gate now takes those addresses first, closing the last of the four routes that went around it. +- Nodes fix themselves after this update. Apps installed before this system used its current container setup kept their old wide-open address even after the signed list told them to move, and each would otherwise have needed hand-holding on every node. Your node now notices the difference and rebuilds those apps itself, keeping their data, within about half a minute of starting. Verified by putting a node back into the old state deliberately and watching it repair. +- The node had been reading two different sets of instructions about its own apps — the signed list it downloads, and older copies on disk — which is how a port meant to stay private was briefly opened on a test node. Both now come from the signed list, and a port withdrawn from the login gate is released without needing a restart. +- **The key that signs these updates has been replaced.** The previous signing key was exposed where it should not have been, so it is treated as compromised and this release installs its replacement. This update is the last one signed with the old key, by necessity — it is the one that teaches your node the new one. +- Known gaps, disclosed rather than buried: eleven app ports still have no stated policy — BotFights, the Fedimint gateway, NetBird, the voice assistant's own screens and the router screen — and remain reachable without a login until each is decided deliberately; the node reports them rather than guessing, because guessing at an unstated setting caused both incidents behind this work. Three voice-assistant ports are still open without authentication; the correct fix puts them on a private network with the assistant. Non-browser clients — phone apps for Vaultwarden, Home Assistant or Jellyfin, and git over the web — will now meet the login page and need an access token. The 5x real-node lifecycle gate was not run for this release. + +## v1.7.121-alpha (2026-08-04) + +- **Making another node "Trusted" now asks for your node password.** Trust was being handed out by machines rather than by you: any node able to reach yours could join and mark itself Trusted, because the check proved only that the caller owned the key it had just presented — never that you had approved it. Trust also spread on its own, since every peer a Trusted node advertised was added as Trusted too, so one grant quietly propagated across the whole federation. Uninvited joins are now capped at Observer, advertised peers arrive as Observers, and raising anyone to Trusted — whether by generating an invite or by changing the dropdown on a node — requires your password. Lowering trust deliberately does not, because the safe action must never be the inconvenient one. Existing peers are left exactly as they are rather than silently demoted, and each one now records how its trust was granted so you can review them. +- **Nodes you have peered with can be messaged straight away.** Peering was not enough: you also had to be within LoRa radio range of the other node once before chat would work. The node picked how to send a message based on which radio was plugged in, and only one of those paths knew how to reach a peer over the mesh's internet transports — so on a node with a different radio, or no radio at all, messaging a peer you had just federated with simply failed until a radio contact happened to appear. Peered nodes are reachable without radio by definition, so that choice no longer depends on the hardware. Radio is still preferred when the other node is actually in range and the message fits. +- The dashboard no longer flickers a vertical line across its cards. A rendering seam appeared at random while moving the mouse, because the two large cards used a background-blur effect that this system already disables everywhere else on the dashboard — that browser mis-draws it inside the dashboard's animated container, and these two cards had been missed when the workaround was written. Diagnosed from a single screenshot rather than by trying to reproduce it. +- The Lightning screen will actually update from now on. Its image was set to "latest", and the container system will not re-fetch a label it already holds, so nodes kept the same Lightning screen forever no matter how many updates shipped. A separate copy of the same setting used only by brand-new installs also described the screen incorrectly, so fresh installs got a screen that never answered. +- Apps that provide their own screens stop rebuilding themselves in a loop. On this system's own node one of them rebuilt every thirty-five seconds indefinitely, burning processor time and restarting the app each round. The node decided a rebuild was needed by comparing file dates against the image's creation date, but a rebuild that changes nothing reuses the existing image and leaves that date untouched — so the condition that triggered the rebuild was still true afterwards, forever. Nodes taking this update repair themselves the first time they check. +- Groundwork you can see but that does not change access yet: the node can now tell you which of its app ports answer without a login, and every port that is deliberately open — Bitcoin's peer connections for syncing the chain, Lightning's wallet connections, the Electrum wallet protocol — now has to state in writing why it is safe, so the list of exceptions is something you can read rather than something you have to discover. The login gate that will sit in front of the rest is built and proven working end to end on a real node, but it is not yet closing any ports; that arrives with the signed app catalog that tells each app to hand its address over. +- Releases can no longer ship an unsigned update file. Signing was skippable, and when it was skipped the release was still committed and tagged — producing an update that every node correctly refuses to install. It had been caught by hand every cycle; now the release simply stops. +- Known gaps, disclosed rather than buried: the 5x real-node lifecycle gate was not run for this release. App ports other than the deliberate exceptions above are still reachable without a login — the gate reports them, and closing them needs the next signed catalog. Three voice-assistant ports are open without authentication and should not be; the correct fix puts them on a private network with the assistant instead, which needs testing on a node that runs both. Two nodes on the fleet still share SSH host keys (detection shipped, rotation remains a deliberate operator decision). + +## v1.7.120-alpha (2026-08-02) + +- **Security, and the reason to take this update: two ports on your node handed anyone who could reach them complete control of your money, with no password.** The Lightning app's port answered a plain web request with the LND admin macaroon, the TLS certificate and the node's onion address — everything needed to drain the wallet remotely, and the onion meant an attacker kept that ability even after losing access to your network. The Bitcoin app's port reached Bitcoin Core's control interface using credentials the node itself supplied on the caller's behalf, with a wallet loaded. Anything on your home network, your Tailscale network or the mesh could use either one. Both now require you to be logged in. If your node has been reachable by anyone you do not fully trust, treat the Lightning macaroon and the Bitcoin RPC password as known to them. +- The Bitcoin and Lightning app screens can no longer be published as public Tor addresses automatically. They were one app-id away from being handed a worldwide, permanent address as a silent side effect of being installed — which would have re-opened the hole above to the entire internet. Turning Tor on for them deliberately still works; it just never happens on its own. +- **Fixes shipped inside the program now actually reach apps that your system keeps running.** A container the node had been told to uninstall, but that the system service manager kept alive anyway, was quietly skipped by the part of the node that applies configuration — so it never received updates that shipped with the program. This was found the hard way: the Bitcoin control-interface fix above appeared to be installed and silently was not, while the Lightning half applied correctly, which is the most misleading way for a security fix to fail. Both halves are now proven to land on a real node. +- The Lightning and Bitcoin node screens have been rebuilt to match what umbrelOS offers. Lightning gains Overview, Channels, Activity, Insights, Connect and Settings tabs with a sats/BTC switch; Bitcoin gains Insights, Peers, Connect and Sharing. Along the way: every copy button on those screens silently did nothing (the browser blocks clipboard access inside an embedded page) and now works; the channels link led to a dead page; and Node ID showed a bare key instead of the full address someone can actually connect to. +- Updates to the Bitcoin screen show up without a hard refresh. The page was being cached by the browser, so a freshly updated screen kept rendering the previous one. +- The AI sidebar loads again. It was asking for its program files at an address that pointed at the main app's files, where they do not exist, so it silently loaded nothing. +- The navigation above the bottom bar no longer follows you between screens. Back buttons and the mesh tab bar stayed pinned over every other page once you had visited the screen that owns them. Keeping tabs loaded in the background — the change that made switching between them instant — means leaving a screen hides it rather than destroying it, and this floating navigation sits outside the screen it belongs to, so it was never being hidden with it. It is now tied to whether its own screen is on display. The speed is unchanged: the screens are still kept loaded, so returning to one is still instant. +- Wallet: Lightning actions are now offered based on whether you actually have a usable channel rather than just a running node, sending is gated the same way, and an invoice you cannot yet receive offers to install a Lightning node instead of simply failing. +- Onboarding and viewing fixes: the "I have written down my recovery words" tickbox is findable on short screens, paid pictures and videos open in the app's own viewer with a visible loading state instead of a blank browser tab, picture-in-picture survives changing tabs, and the FIPS/Tor labels on peer cards stay put instead of wrapping into the card below. +- Key-material hardening across the node: a node that is already set up refuses to have its identity replaced by an unauthenticated request; first-boot secret generation now fails loudly instead of silently continuing with shared keys; the node proves its TLS certificate and key are actually a matching pair; the Bitcoin Core wallet path that kept a second copy of your spending key outside the encrypted store has been removed; and every place the node generates a key, token or nonce now names its source of randomness explicitly, enforced at build time. +- Federation and mesh: a rotated gateway credential now reaches the already-running container instead of leaving the old one in place, sync failures are surfaced to you instead of being swallowed, and nodes can share their Lightning connection details with a chosen peer over the mesh — the groundwork for opening channels with nodes you already talk to. +- Known gaps, disclosed rather than buried: the 5x real-node lifecycle gate was not run for this release. Two nodes on the fleet still share SSH host keys with each other (detection shipped, rotation is a deliberate operator decision and has not been performed). Bitcoin Core can now reach Tor from its container, but is not yet routed through it — the network mode is becoming a setting you choose, and until then Core's peers remain on the clear internet. + +## v1.7.119-alpha (2026-07-31) + +- Wallet payments now work on nodes whose channels are private/unannounced. Every invoice-creation call site — the wallet's own Receive flow, and the seller-side paid-content/peer-files flow — only ever sent LND the amount and memo, so LND defaulted private to false and returned invoices with no route hints. Any node whose only usable channel is private or unannounced (the common shape for a channel someone opened to you) was silently unpayable through the wallet, and unpayable through paid file/content sales too. Both call sites now set LND's private flag correctly; this was broken in the field and is the main reason for this release. +- Tor and the mesh's Tor fallback are reliable again. The node's background "doctor" health-checker was fighting Tor over the permission bits on its own hidden-service directory: it compared the directory's mode against the literal string "700", but Tor's own setgid hidden-service mode is 2700 — a value the doctor's check never recognized as correct. Every ~5 minutes it "corrected" the mode back to 700 and restarted Tor to apply it, and Tor immediately reasserted 2700 — a permanent restart loop that meant Tor could never hold onto its consensus/HSDir cache long enough to be useful, breaking the mesh's Tor fallback path entirely. The check now compares only the owner/group/other bits that actually matter (both 700 and 2700 pass; genuinely wrong modes like 750 or 2755 are still corrected and restart Tor), plus a 30-minute restart backoff so no future condition can reproduce the storm. +- Wallet balances and your node's own FIPS identity key (npub) are no longer written to the browser's sessionStorage — caught by an audit of the page-caching work below. Every cache call site in the app now makes an explicit, reviewed decision about whether its data is allowed to persist across a reload, and a one-time migration purges any legacy, unaudited snapshot left behind by an older build. +- Server, Home, Mesh, Chat/AI chat, and the secondary screens (app details, marketplace, cloud, federation, monitoring, router/OpenWrt) now load instantly from cache when you revisit them and refresh quietly in the background, instead of blanking and re-fetching everything on every tab switch — this closes out the page-performance work started back in v1.7.116/117. +- App updates (including this one) now apply automatically in the background instead of waiting on a tap-to-update prompt, matching how kiosk/TV installs already behaved — the reload still waits for any in-progress splash/dashboard animation to finish first, so it won't land mid-motion. This was a direct, explicit decision made with the mid-payment-reload risk spelled out in advance; reverting to a confirmation prompt for beta is a one-line change if wanted later. +- Known gap, disclosed rather than buried: the project's 5x production lifecycle gate (install/UI/stop/start/restart/reinstall/reboot-survive/archipelago-restart-survive/uninstall, run on a real node — CLAUDE.md's own definition of done before a release tag) was NOT run for this release, because its target node was unreachable and running it here would have required rebooting a shared, live build machine out from under other active work. This release's own automated gates (release-gate harness, strict catalog-drift check, the full cargo test suite, a mount-level ISO smoke test, and a headless QEMU boot test) all still ran and passed — this is specifically about the separate 5x real-node lifecycle gate, which is still outstanding and should be run as soon as the node is reachable again. + +## v1.7.118-alpha (2026-07-29) + +- Fixes mesh radios dropping off on nodes that took the v1.7.117 update. Updates only ever replaced the main program, never the packaged radio helpers — so updated nodes were left running an older radio daemon that didn't understand a new option and quietly gave up, showing "device not connected" with a Connect button that did nothing. The node now checks what its radio daemon supports before using new options, and updates finally carry the radio helpers themselves, so every node gets current radio support with the update instead of only from a fresh install. +- The in-app "Flash LoRa" flow works on updated nodes. The RNode flashing tool was only ever included on freshly installed nodes; everywhere else flashing failed with a cryptic "No such file or directory". The tool now ships with updates and is included on new install images, and if it's somehow still missing the error says exactly what to do instead. +- Message notification badges finally remember what you've read. Unread counts were only kept in memory, so every visit re-counted old messages as new — including a phantom badge for chats with nothing new in them. Read-state is now saved on the device, opening a chat marks all its linked conversations read, and history no longer re-badges after a reload. +- Every mesh message now has a visible "⋯" button that opens the route view: watch the path your message took animate — sender and receiver appear, a pulse travels the link, and each relay hop lights up in order — with signal quality for radio links and delivery status. (Tapping the transport pill still works too.) + +## v1.7.117-alpha (2026-07-29) + +- Flash your LoRa radio from inside the app. The Mesh page now has a "Flash LoRa" button that opens a guided flow: pick the firmware family (MeshCore, Meshtastic, or Reticulum RNode) and your board, and the node downloads the latest release and flashes it with live progress — no external flasher website, no cables to a computer. The same flow appears when a freshly plugged-in radio is detected, and a long list of flashing pitfalls was fixed along the way: radios no longer boot-loop after a flash, failures show the real error instead of silently bouncing back, wedged flash jobs can't get stuck forever, and board auto-detection no longer misidentifies Heltec boards. +- Every Archipelago node now acts as a Reticulum relay. Nodes forward mesh traffic and re-broadcast peer announcements, so two radios that can't hear each other directly can still discover and message each other through any Archipelago node in between — your nodes become infrastructure for the whole neighbourhood mesh, including non-Archipelago apps like Sideband. +- Reticulum (RNode) radios are now first-class mesh citizens. Radios are reliably detected on node startup (a boot-timing race used to leave them unclaimed), settings changes apply live without a restart, your node's name propagates over the Reticulum network so other apps like Sideband see it properly, and a crashed Reticulum daemon is detected and restarted automatically. Photo and file attachments sent over Reticulum now actually arrive — four separate delivery bugs were found and fixed, verified end-to-end over real radio hardware. +- Messages to contacts that exist on both the internet mesh and a LoRa radio now prefer the radio when it's live, and attachments follow the same path — so co-located nodes talk over the air even when the internet path exists. +- Mesh chat polish: each message in the image viewer shows which transport carried it, a new hop-route view shows the path a message took, reactions moved into a tidy dropdown, and read-tracking now reflects what you've actually seen. The Refresh and Broadcast buttons give real feedback, and the radio-setup modal shows honest probe progress instead of freezing. +- The wallet transactions list works properly on phones now: it scrolls (it silently couldn't on touch screens before), and the All / On-chain / Lightning / Ecash filter tabs stay pinned at the top with a subtle blur while the list scrolls underneath. +- Backend services no longer masquerade as launchable apps. Anything without a real web interface — databases, APIs, background workers, including stacks you deploy by hand for testing — now files under Services with no Launch button. Apps declare their interface in their manifest; for everything else the node checks the port itself to see whether a browser page actually lives there. +- Lightning payments that take a while (slow multi-hop routes) are no longer reported as failed while they're still in flight. The wallet now waits properly, shows an honest "pending" state, and reports the true final outcome. +- Server pages feel instant: Server, Federation, Lightning channels, Monitoring, wallet, Cloud, and Credentials screens now render immediately from a shared cache and refresh live in the background (including push updates over the node's websocket), instead of blanking while every panel refetches. +- The node stays responsive under heavy load: the connection handler now sheds excess load instead of stalling everything behind it, and companion-app probes no longer trigger container builds during routine checks. +- FIPS mesh uptime hardening continues: the node's peer port is opened explicitly everywhere, LAN anchors use the right port, direct peering between co-located nodes works again, dials fail fast instead of hanging, and a connectivity watcher re-applies anchors immediately when the network comes back. +- FIPS startup is more reliable on nodes that have the packaged `fips.service` instead of Archipelago's `archipelago-fips.service`. Startup self-heal, onboarding, dashboard Start, and reconnect now use the systemd unit the node actually has, so FIPS no longer looks like it needs to be installed when it only needs to be started. +- App screens over the FIPS mesh now bind their relay only to the node's FIPS address instead of reserving the same host ports Podman needs. This keeps apps such as FileBrowser and Botfights from restart-looping because the backend was already holding their published ports. +- Companion app 0.5.25: a redesigned settings hub (three-finger tap opens it over the dashboard), seamless transport handoff with FIPS mesh settings, the wallet scanner reads dense invoice QR codes, app webviews clear the phone status bar with an HTTPS toggle on add/edit, and off-LAN loads fall back to the mesh URL instead of a dead LAN address. +- Public-source preparation now includes a Nostr Git hosting plan using `ngit`, NIP-34, and GRASP: anyone can clone, fork, review, and propose changes from their Archipelago node, while canonical merge authority stays with a small signed maintainer set in the style of Bitcoin Core. + +## v1.7.116-alpha (2026-07-27) + +- Nodes no longer get stuck on "server starting up" after an update or reboot. On a node running many apps, the backend used to spend minutes recovering containers before it told the system it was ready, and anything that touched it during that window could leave it down for good. It now reports ready immediately and recovers in the background, and it always restarts itself if it ever does go down. +- Installing apps no longer crashes the node. A change that made app screens reachable over the mesh was accidentally holding onto every app's network port in advance — so installing an app like Grafana, Photoprism, Uptime Kuma, or Jellyfin collided with it and the port-cleanup step took the whole backend down, rolling the install back. Installs are now clean and the backend can never be caught by that cleanup. +- Rolls up everything from v1.7.115: app screens and the dashboard load over the mesh out of the box (firewall openings shipped automatically, IPv6 support end to end), and nodes rejoin the mesh in seconds after their rendezvous point restarts. + +## v1.7.115-alpha (2026-07-26) + +- The companion app can reach your node's screen from anywhere again. The recent security hardening locked down the node's mesh interface so tightly that the dashboard itself was blocked — the phone would pair and connect, then sit on a blank screen. The node now explicitly opens its own web interface (and only that) through the mesh firewall on every install and upgrade, so the phone's view of your node works out of the box, on any network, and can't silently break in a future update. +- The node's web interface also answers on IPv6 everywhere it answers on IPv4 — the mesh runs entirely on IPv6, and one v4-only listener was enough to make a working connection show nothing. +- Nodes now come back onto the mesh in seconds instead of minutes after their rendezvous anchor restarts: the fast-reconnect tuning proven on the phone this week is now baked into every node's mesh configuration, and it survives upgrades. + +## v1.7.114-alpha (2026-07-26) + +- Plugging in a mesh radio no longer traps it in an endless reboot loop. The device detector itself was causing it: every scan pulsed the radio's reset line, the same board was probed twice under two names, and retries came so fast the radio never finished booting before the next reset hit. Detection now gives the board real time to boot, probes it once, backs off properly between attempts, and no longer fights the "device detected" popup for the port. Radios that could never connect now come up within a minute of being plugged in. +- The Lightning channels screen now has All / Active / Pending / Closed tabs. Pending gathers everything in motion (opening, closing, force-closing — each with its own status dot and a link to the closing transaction), and Closed is a real history: how each channel ended, what settled back to you, and the closing transaction for each. +- Sending bitcoin on-chain now puts you in charge of the network fee: pick Fast, Standard, or Slow (Standard is the default), or set your own target blocks or sats-per-vByte. The confirmation step shows the estimated fee for your chosen speed before any money moves. +- Type on-chain amounts in whichever unit you think in — a sats/BTC switch on the amount field converts as you type. +- Back up your seed by scanning it. Every recovery-phrase screen (onboarding, Settings, and the Lightning wallet seed) now has Words and QR code tabs — words always shown first. The QR for your node's recovery phrase uses the SeedQR standard, so hardware wallets like Passport Prime, SeedSigner, and Keystone can import it with a single scan (a plain-text option remains for wallets that read the phrase as text). The Lightning seed's QR is plain text with an honest note: it's an LND-format seed that restores into Lightning wallets like Zeus or Blixt, not into hardware wallets. + +## v1.7.113-alpha (2026-07-25) + +- Fixed a money bug in Cashu ecash sends: the token you handed a recipient could carry your own change proofs along with it, letting the same sats be credited twice. Change now stays in your wallet — only the amount you meant to send leaves it. +- Closing a Lightning channel is no longer a leap of faith. The close used to hang (or time out with an error) even though it had actually gone through; it now comes back within seconds with the closing transaction ID. Channels mid-close appear in the channel list as Closing or Force-closing with their transaction attached, and a new closed-channels history keeps past closes visible instead of letting them vanish from the list. +- The wallet card now leads with your total bitcoin across everything, and the on-chain balance gets its own chain icon so the rows read at a glance. +- The companion phone app (0.5.15) connects dramatically faster away from home: a cold connect over 5G dropped from 40+ seconds to about 5. First connects no longer stall on unreachable mesh dial hints, fresh joins fail fast and retry instead of waiting out long timeouts, and the phone re-announces itself the moment the network around it changes. The node side's mesh-join handling was hardened to match. + +## v1.7.112-alpha (2026-07-23) + +- Sound works on TVs out of the box. Fresh installs were missing the audio system entirely, and even when present a boot-time race left HDMI silent until the cable was unplugged and replugged. Both are fixed: installer images now ship the full audio stack, and a small background helper detects the silent-HDMI state and heals it automatically. +- Plug in a game controller and drive the whole TV interface with it — navigation, menus, and media playback all respond to the gamepad, and dialogs that pop up are controller-navigable too. +- The companion phone app took a huge leap (0.5.9). Your node and its apps now work from anywhere — on 5G or any internet connection, the phone reaches the node over the encrypted mesh with zero port forwarding or VPN setup. Startup away from home is instant, apps on your node open inside the app, the phone's native camera handles QR scanning, and a branded full-screen loader shows while the mesh connects. +- Mesh Party: two phones scan each other's QR and instantly get a direct encrypted chat and app sharing between them — plus a "Share this app" QR that anyone can scan with a normal camera to install the companion app. +- Pairing a second phone no longer silently logs out the first. Every device now keeps its own named access credential, ending the mystery reconnects when a household paired more than one phone. +- The companion pairing QR is scannable again (it had grown too dense for phone cameras) and now identifies your node by its identity key, so the app recognizes your node even after it moves or gets a new address. +- Every app your node serves on your home network is now also reachable over the mesh — remote access covers the apps themselves, not just the dashboard. +- Selling files: you now choose which payment methods you accept (Lightning, ecash, …) and buyers are only offered those — enforced by the node itself, not just the buttons. Paying twice for the same file is impossible now, purchases file themselves into a new Paid Files tab, purchased music always plays in the bottom-bar player, and videos get picture-in-picture. +- Sending Lightning is invoice-first: paste or scan an invoice and the amount fills in and locks by itself. An expired invoice now tells you plainly to ask for a fresh one instead of failing cryptically, and payment errors always reach your screen. +- Sending to a pasted address gets a confirmation step showing exactly what will happen before any money moves, and buying ecash is an explicit two-step — no more accidental purchases. +- Apps keep running when you change how they're displayed. Switching an app between windowed and fullscreen used to reload it from scratch (stopping any playing media); the app now stays live through the switch, and each app remembers its own preferred display mode. +- A watchdog notices when the Lightning (LND) node wedges and revives it before you do; Fedi ecash gets its own send option with scannable token QR codes. +- Fedimint's Lightning gateway and guardian now follow whichever bitcoin version is actually running instead of pointing at a stale address — switching bitcoin versions no longer strands them. +- If your router starts handing out different addresses, the Pine voice speaker re-links itself automatically instead of staying silent until someone re-configures it. +- Polish: mesh radios never show garbled device names anymore, the TV kiosk uses slim overlay scrollbars instead of fat grey bars, and the AI chat's background artwork shows through again. +- Your mesh messages now survive restarts. Chat history — channels and DMs alike — used to live only in memory, so a reboot or update wiped every conversation; worse, other nodes silently discarded the first messages you sent after a reboot. Everything is now saved on the node and restored on startup, and post-reboot messages deliver reliably. +- Plug in any LoRa radio and the node walks you through it. A setup window appears every time a radio is connected, shows what firmware is already on it (MeshCore, Meshtastic, or Reticulum RNode — with its current name, region, and channels where available), and offers two honest choices: "Set Up with Archipelago Settings" (a preview screen shows exactly what will be written before anything touches the radio) or "Keep As Is" (the radio is used untouched, and you can hot-swap radios freely). Swapping sticks mid-session now just works — including Reticulum RNodes, which fresh installer images now support out of the box. +- Incoming bitcoin appears in your wallet within seconds of being sent — balance and the yellow "unconfirmed" entry update live, no refresh, no waiting for the next poll. +- The speaker now announces the very first mesh message a node ever receives, and DMs announce just like channel messages (a safety guard against announcement storms was quietly swallowing them). Announcements also react about twice as fast. +- Opening a Lightning channel right after the node starts no longer fails with a scary red error. The node quietly retries while Lightning finishes waking up, and if it's still not ready you get a calm "still finishing its startup — try again shortly" notice instead. +- Viewing a transaction works on every node now, including small ones. Nodes with pruned bitcoin storage can't run the Mempool explorer app; transaction links now open your choice of external explorer instead (tx1138.com by default) — after a clear one-time warning that a third-party server will see which transaction you looked up. Set your preferred explorer in Wallet Settings → the new On-chain tab. +- Voice commands respond noticeably faster: speech recognition now transcribes in roughly half the time, with identical accuracy on short commands. +- Scanning a Lightning invoice with your phone's camera is far more reliable — dense invoice QR codes that the photo scanner missed are now read by the phone's native barcode engine. +- The companion app's pairing QR always contains an address your phone can actually reach. If you manage your node over a VPN (Tailscale), the QR used to embed the VPN address, and pairing silently failed; it now advertises the node's home-network address. +- Peer requests sent from Nostr discovery now actually arrive: your node checks for incoming requests every five minutes by itself (previously they sat unseen until someone manually pressed "Poll"), requests publish to all your configured relays instead of two hardcoded ones, and a failed send tells you instead of pretending it worked. +- The Connected Nodes list refreshes instantly. It previously froze for up to 30 seconds per offline peer while checking who's reachable, one peer at a time; the checks now run all at once in the background while the list shows immediately. +- Apps opened from inside a window (like a transaction from the wallet) now animate smoothly on top instead of loading invisibly underneath. +- Settings-style windows keep their tabs pinned at the top and their buttons pinned at the bottom; only the middle scrolls. The wallet's tabs are now Channels / Cashu / Fedi / Ark / On-chain so all five fit. +- On the TV screen, menus no longer flash open and instantly close. And the interface never follows your computer's light/dark preference anymore — dropdowns and other native controls stay dark on every device. +- Error messages tell you what's actually wrong: "Insufficient balance: need 80 sats, have 0 sats" now reaches your screen instead of "Operation failed. Check server logs." +- Installing Mempool no longer refuses to start while ElectrumX is mid-resync (it connects by itself once ElectrumX is ready), and installs no longer fail just because the system was momentarily busy. +- Much quieter logs: the node no longer tries to start containers that are already running (hundreds of harmless-but-alarming errors per day), and a node that's offline stops hammering unreachable servers every 30 seconds with rebuild attempts. +- Phones pairing with the companion app connect over the node's embedded mesh for remote access, with instant QR pairing and per-device access tokens (contributed alongside this release). + +## v1.7.111-alpha (2026-07-22) + +- Ask your node anything, out loud. Install Pine (the voice assistant app) alongside Home Assistant and everything wires itself automatically: speech recognition, the speaking voice, and a Claude-powered brain. Questions about your node — "what's the block height?", "how many peers am I connected to?", "is bitcoin synced?", "what's my Lightning balance?" — are answered instantly from the node itself without costing anything; anything else goes to Claude for a real conversation. New mesh radio messages are read out on your speaker as they arrive. +- Pine now ships a wake-word listener, so a paired speaker can sit on standby and activate when it hears its wake word instead of needing a button press. (A custom "Yo Archy" wake word is in the works.) +- Pine's launcher page shows your node's live status at a glance: software version, uptime, bitcoin sync progress, and mesh peers. +- Fixed: installing Pine could send Home Assistant into a crash loop on startup (a record the installer wrote was missing a timestamp field Home Assistant requires). Two noisy warnings that repeated in Home Assistant's log every half minute are silenced too. +- The companion phone app opens every app in its fast built-in browser view again, with native back/forward/reload controls, instead of embedding some apps inside the page where they scroll and render worse. This had quietly regressed. +- Turning on federation discovery now shows you exactly what you're about to sign: a panel explains the announcement before your key signs it, you can review the signing details any time from the discoverability strip, and the panel fits and scrolls properly on small phones. +- Fixed a bug on nodes using the newer app-management engine where Bitcoin's access credentials were written out incorrectly (a placeholder leaked through as the literal text "/bin/bash"), which broke the node's Bitcoin status display, Lightning's connection to the chain, and any app that reads Bitcoin data. +- Bitcoin's access credentials also moved out of the process command line into a protected file, so they're no longer visible to other software on the node. +- Desktop app windows have one-click buttons to switch between side panel, overlay, and fullscreen viewing. +- On the phone home screen, the wallet card moved up to sit right under My Apps. +- Home Assistant updated to 2026.7.3, which keeps voice satellites (like Pine's speaker) connected reliably. + +## v1.7.110-alpha (2026-07-21) + +- Pay by pointing your camera: the wallet has a new Scan button (on the wallet card and inside both the Send and Receive windows) that reads any payment QR code — Lightning invoices, Bitcoin addresses, Cashu tokens, and Fedimint invites — and takes you straight to the right send or redeem screen with everything filled in. It also understands the animated, multi-part QR codes some wallets show for long payloads. If your browser can't open a live camera preview (common when reaching the node over plain http), a "Take photo of QR" button snaps a picture with your phone's camera and reads the code from the photo instead. +- The TV screen got a complete overhaul. A deep bug made the display freeze on the intro artwork on 4K TVs — that's fixed, and along the way: the interface now picks a comfortable, sharp size for big screens (a 4K TV gets a full desktop layout at double sharpness), the artwork behind every page shows again instead of a black void, switching between tabs animates smoothly, the built-in AI assistant stays in its dark theme, and the Cashu and Ark wallet icons no longer render as empty squares. +- You can now choose how big the interface renders on your node's attached screen: Settings → Display offers Auto (recommended), Large UI, Balanced, and Native — changing it applies immediately. +- The companion phone app can steer the TV again. Remote input from the phone was being silently ignored on kiosk displays; the remote-control relay now runs there like everywhere else. +- The companion app is also ready to grant its built-in browser camera access, so the wallet scanner can work inside the app (ships with the next companion app build). +- Zero-amount Lightning invoices can now be paid: the wallet asks you for the amount and sends it along, instead of failing on invoices that leave the amount up to the payer. +- The Lightning setup guidance now reads the same everywhere: "Open a channel with Zeus Olympus node and start sending and receiving Lightning payments. Minimum 150,000 · maximum 1,500,000 on-chain sats required." +- Installer images now bundle a color-emoji font, so emoji anywhere in the interface render properly on the TV screen. + +## v1.7.109-alpha (2026-07-21) + +- Meet Pine, your node's voice assistant: a new app in the App Store that gives your node ears and a voice — speech-to-text and text-to-speech engines that run entirely on your own hardware, ready to wire into Home Assistant for private, offline voice control. Install it like any other app; nothing you say leaves your node. +- Your node can now program its MeshCore radio's RF settings — frequency, bandwidth, spreading factor, and coding rate — from Mesh → Device settings. Radios that were flashed with mismatched settings could hear that other radios exist but never decode their messages, and until now the only fix was a separate phone app. Set the values once and the node programs the radio automatically (it restarts once to apply); every radio on your mesh must use the same values to talk to each other. +- The Device settings panel is tidier: values you can edit (name, region, channel) are no longer also shown as separate read-only rows. + +## v1.7.108-alpha (2026-07-20) + +- Your node connects to the private mesh far more reliably. Nodes rely on a public rendezvous point to find each other, and the only one available was unreachable from many home and office networks — leaving some nodes unable to join the mesh at all. There is now a second, always-reachable rendezvous point, and your node tries every one it knows, so it joins the mesh in seconds instead of being stranded. +- Wi-Fi setup now heals itself on older nodes. Some nodes set up before a mid-year fix couldn't connect to a Wi-Fi network from the screen — it failed with a permissions error — because the piece that lets the node manage networking on your behalf was missing. Nodes now put that piece in place automatically on startup, so "scan, pick a network, type the password, connect" works without reinstalling. +- Your node rejoins the mesh within seconds after an update. Applying an update briefly restarts the mesh service, and previously a node could sit disconnected from other nodes for up to five minutes before it retried. +- The TV screen now fits your television. On a large or 4K TV the interface rendered tiny with no way to zoom on a keyboard-less screen; it now sizes itself to a comfortable, readable scale automatically (and small laptop panels are left unchanged). +- More TV-screen polish: the built-in assistant shows its dark theme instead of bright white panels, the on-screen hint for switching between the kiosk and a terminal now points at the right keys, the welcome logo no longer occasionally renders as garbled characters, and an accidental tap of the power button no longer shuts the node down — hold it to power off on purpose. +- Behind the scenes: fixed the installer image build so it no longer stops on a component that was removed from the product, and so it correctly includes the private relay it was meant to bundle. + +## v1.7.106-alpha (2026-07-20) + +- Nodes on the same network now find each other directly. Your node announces itself on your local network and connects straight to other Archipelago nodes nearby, instead of every connection having to be introduced by a public rendezvous server out on the internet. Peers in the same home or office stay connected to each other even when that server is unreachable, and they reach each other faster. +- On a phone, the peer files screen tells you how you're connected again. The badge showing whether a peer's files are arriving over the fast mesh or over Tor was only visible on desktop — on narrow screens it disappeared entirely. It now appears next to the peer name on mobile too. +- Your node's mesh settings can no longer be written in a way that breaks the mesh. The configuration file used to be assembled as free-form text, where one wrong setting would stop the mesh service from starting and quietly drop your node off the network. It's now generated from a checked description of the file, with tests that verify the exact output. +- When your node has trouble reaching another node, the logs now record the real reason instead of a generic summary. A failure to open a peer's files previously logged only "Failed to connect to peer" and threw away the actual cause, which made these problems very hard to diagnose. Nothing changes on screen, and no internal detail is exposed. +- Behind the scenes: the installer image now builds its mesh component at a fixed, known version instead of whatever upstream had published that day, so two images built from the same source are identical. + +## v1.7.105-alpha (2026-07-20) + +- Fixed a failure loop where a node that lost power or was moved could get stuck on a blank "can't reach your node" screen forever: startup recovery no longer spends minutes retrying containers that no longer exist, and a genuinely large recovery is no longer cut off half-way and forced to start over. The node now reaches its login screen even after the messiest shutdown. +- Phone tunnel setup (WireGuard) is now dependable: the QR screen automatically retries while a fresh install is still settling instead of dead-ending at "failed to fetch", and if your node has moved to a different network the QR and downloadable config now carry the node's current address instead of the old one. +- Fixed the white screen some laptop displays showed right after the intro on v1.7.104. +- The companion phone app no longer suggests installing the companion app from inside itself. +- The Tor page now lists onion addresses only for apps you actually have installed — fresh installs no longer come with six pre-made addresses for apps that were never set up. +- Running `archipelago --version` or `--help` on the command line now prints and exits instead of silently starting a second copy of the node, which could briefly disrupt running apps. +- Behind the scenes: installer image builds now stop loudly if VPN components are missing instead of producing a broken image, and a background file-permission sweep runs far less often, reducing disk churn on busy nodes. + +## v1.7.104-alpha (2026-07-19) + +- Software updates are now much safer to receive: the node will never install an update that isn't completely downloaded and verified byte-for-byte, closing a rare bug where an interrupted or cancelled download could leave a node unable to start. +- If a freshly installed update does fail to start, the node now notices and automatically restores the previous working version by itself — no manual rescue needed. +- The Electrum server now works with whichever Bitcoin you run: it finds Bitcoin Knots or Bitcoin Core automatically instead of assuming Knots. +- While the Electrum server is first building its index, its waiting screen now shows the ElectrumX app icon and live progress. + +## v1.7.103-alpha (2026-07-18) + +- Connecting from the phone app no longer replays the intro cinematic on a loop: signing in after scanning the pairing QR could accidentally trigger "Replay Intro" instead of logging you in. The companion app now lands you straight on your dashboard, and the Android app waits for the login screen to be ready before it types your password. +- Pressing Enter in any password box now does what you expect — it signs you in or moves to the next field, and can no longer "click" a nearby button by mistake when using a controller or the companion app. +- The public demo no longer interrupts you with an "Update Available" popup that reset the site back to the intro — demo visitors simply get the newest version on their next visit. + +## v1.7.102-alpha (2026-07-17) + +- The password you choose during setup is now truly your node's password: it also becomes the system login for console and SSH access, instead of leaving the factory default in place. If you ever renamed your node and the TV screen went black on the next boot, that's fixed too — renaming no longer breaks the kiosk display. +- Setting up Lightning is now a guided journey: a fund-your-wallet step that shows a live countdown while Bitcoin syncs, suggested channels you can open straight into the Zeus mobile wallet with one tap, and a "finish setup" prompt that walks you to the end — goals now complete when you've actually done the steps, not just when apps happen to be running. +- Pair your phone by pointing it at the screen: the companion app now connects by scanning a QR code — scan, and it fills in your node's address and logs you in. The App Store has a banner to grab the Android app, and the pairing flow can now also set up secure remote access so your phone reaches home from anywhere. +- First installs are far more dependable: app downloads that stall now retry instead of hanging forever (the old "first install fails, the second works" pattern), big multi-part apps show their real download progress instead of sitting at "Preparing", Lightning no longer fails its first install over temporary hiccups, and a brand-new node now comes up with its core apps — file cloud and ecash wallet — even with no internet connection. +- The installer image is about 160MB smaller and gets to a working screen faster, because the apps bundled for offline setup are now compressed. +- The first-run experience keeps its magic: the typing intro is back on fresh installs and can no longer be cut short by a mid-play refresh — updates now politely wait for the cinematic to finish — and dark backgrounds stay dark instead of flashing black or white. +- Your backups now include your secrets — including the key that protects your Lightning wallet's recovery seed — and there's a Download button to take a copy off the node; the seed-backup reminder now actually opens the backup flow when you tap it. +- Networking Profits grew into a full dashboard, network cards keep their action buttons in reach on every screen size, "Connect to Mesh" goes to the right page instead of a dead end, and the identity pages got a round of mobile polish. +- Behind the scenes: apps that report their own health are no longer second-guessed by a port probe (fewer false "restarting" states), and pressing arrow keys or a gamepad is once again the only thing that shows the controller focus ring. + +## v1.7.101-alpha (2026-07-15) + +- The wallet speaks Ark: a new Ark tab shows your Ark balance and history, you can send and receive over the Ark protocol, pay Lightning invoices from your Ark balance, and Ark payments appear in the transactions view with their own filter chip. +- Every app you install now automatically gets its own private .onion address — your apps are reachable over Tor a few seconds after install, with no manual "Add Service" step. +- "Add Service" in the Tor panel now works for every app, not just a fixed list — the node reads the app's actual web port, so apps like Gitea, Jellyfin, Nextcloud, and Uptime Kuma no longer fail with "see server logs". +- Renaming your node now genuinely renames it everywhere: the machine's hostname, its .local network name (re-announced immediately), the local hosts file, and the HTTPS certificate all follow — so http and https links using your node's name keep working right after a rename. +- The node no longer mistakes a VPN tunnel for its own address. On fresh installs with NetBird, apps could launch on an internal 10.x address instead of your LAN IP; the node now reads its address from the actual network route, fixing app launch links, generated app configs, and VPN setup. +- Your cloud got a real layout: Apps-style tabs with categories for Folders, My Files, and Peer Files, readable file rows, a search that also finds files shared by your federated peer nodes — and music now opens in the bottom-bar player instead of a broken preview window. +- The first-login experience flows again: the dashboard entrance animation is back — and you can actually hear it now (its sound was silently swallowed before, including on replays) — "Replay Intro" in Settings actually replays it, opening a direct link to an inner page no longer detours through the splash screen, the login screen keeps the intro video until your first login (switching to rotating backgrounds after), and the intro video streams three times lighter so it starts instantly. +- Changing DNS settings no longer blanks the page, and the DNS and WiFi dialogs now cover the whole app instead of only the right panel. +- The public demo is richer and truer: the intro plays on every fresh visit, Ark wallet flows, working DNS and Tor service management, and a library of peer content with previews that never break. +- Assorted fixes: failed installs clean up after themselves properly, and the transactions view fits mobile screens (capped at 60% of the visible viewport). + +## 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 <your question>" 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 the staging node 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 the staging node 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 the staging node 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 a fleet node 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 a fleet node updated the existing stack from LAN origins to its tailnet address 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 a fleet node 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 a fleet node 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 a fleet node 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 ``the BTCPay host`/`www` redirect to BTCPay login over HTTPS and `the L484 host`/`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 a fleet node 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 a fleet node 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 a fleet node 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 a fleet node 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 a fleet node 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 a fleet node 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 the registry mirrors. 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 the release 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..55b74c00 --- /dev/null +++ b/CLAUDE.md @@ -0,0 +1,72 @@ +# Archipelago — contributor guide + +This file orients anyone (human or AI) working in this repository: the +invariants that must hold, how to build and verify, and where the deeper +design docs live. The authoritative behaviour is always the code in `core/`. + +**Read [`docs/ROADMAP.md`](docs/ROADMAP.md) for where the project is going** and +[`docs/README.md`](docs/README.md) for the full documentation index. + +The north star: a world-class, **developer-ready app platform** — every app +manifest-driven, rootless, secure, and 100%-uptime-capable, with third-party +developers publishing via an external/decentralized registry. + +Detailed sub-plans: +- App platform / packaging phases + security model → [`docs/APP-PACKAGING-MIGRATION-PLAN.md`](docs/APP-PACKAGING-MIGRATION-PLAN.md) +- Registry-distributed manifests → [`docs/registry-manifest-design.md`](docs/registry-manifest-design.md) +- External/decentralized marketplace for devs → [`docs/marketplace-protocol.md`](docs/marketplace-protocol.md) +- App manifest schema → [`docs/app-manifest-spec.md`](docs/app-manifest-spec.md) +- Production test gate → [`tests/lifecycle/TESTING.md`](tests/lifecycle/TESTING.md) + +## Commit & push every unit of work + +Work is not "done" until it is committed **and** pushed. Finished work has been +lost by sitting uncommitted in a shared tree across 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). Don't let + unrelated changes accumulate uncommitted. +- **Push immediately after committing** so nothing lives only on one machine. +- **Never leave a stack of finished work uncommitted** overnight or when handing + off — if you must pause mid-change, commit a clearly-labelled WIP checkpoint + rather than leaving the tree dirty. +- **Stage explicitly by path** (`git add <paths>`) when another contributor's + uncommitted work shares the tree — never `git add -A` / `git commit -a`, which + clobbers or entangles their changes. +- **Never commit secrets** (mnemonics, private keys, API tokens). Signing is done + offline; artifacts (catalog/manifest) are signed, not the keys. + +## 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. A hardcoded `podman run` + `sudo chown` + installer 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/<app>`, + secrets, credentials, ports, and adoption container names; keep a rollback path. +- **Verify on a real node before any release tag.** + +## Build / verify + +- Rust workspace root is `core/` (no Cargo.toml at repo root). Run `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 (the build can silently + no-op). +- App manifests are delivered inside the **signed catalog** (`releases/app-catalog.json`), + whose entry overrides the on-disk `/opt/archipelago/apps/*/manifest.yml` + (origin-wins; disk is the fallback). Editing a disk manifest alone does **not** + change a catalog-covered app — regenerate and re-sign the catalog. + +## Production test gate (definition of done) + +`tests/lifecycle/run-gate.sh` must be green across install / UI / stop / start / +restart / reinstall / reboot-survive / archipelago-restart-survive / uninstall. +**Run the gate on the node** (it uses local podman/systemctl/bitcoin probes), not +via RPC from another host, and re-run it after any orchestrator/lifecycle change. +Multinode / fleet testing is a separate pass. See +[`tests/lifecycle/TESTING.md`](tests/lifecycle/TESTING.md). diff --git a/CODE_OF_CONDUCT.md b/CODE_OF_CONDUCT.md new file mode 100644 index 00000000..90378f2b --- /dev/null +++ b/CODE_OF_CONDUCT.md @@ -0,0 +1,19 @@ +# Code of Conduct + +## Our standard + +Be direct, respectful, and focused on the work. Healthy disagreement is welcome; +harassment, personal attacks, and discriminatory language are not. + +## Scope + +This code of conduct applies to project repositories, issue trackers, pull +requests, documentation, chat, and community spaces connected to Archipelago. + +## Enforcement + +Maintainers may edit, hide, or remove comments and may restrict participation +for behavior that makes collaboration unsafe or unproductive. + +Report conduct concerns privately through the repository owner account or the +private contact channel listed on the project homepage. diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md new file mode 100644 index 00000000..ab6cdb0b --- /dev/null +++ b/CONTRIBUTING.md @@ -0,0 +1,100 @@ +# Contributing to Archipelago + +This project is preparing for public developer contribution. The highest-value +contributions are focused fixes, tests, app manifests, documentation +improvements, and clear bug reports with reproducible evidence. + +## Development setup + +### Frontend + +```bash +cd neode-ui +npm install +npm start +npm run type-check +npm test +``` + +### Backend + +```bash +cd core +cargo fmt --all -- --check +cargo clippy --all-targets --all-features -- -D warnings +cargo test --all-features +``` + +Linux is required for host integration work involving Podman, systemd, +networking, or image builds. Frontend development works locally with the mock +backend. + +## App manifests + +App packages live under `apps/<app-id>/manifest.yml` and use the schema +documented in [docs/app-manifest-spec.md](docs/app-manifest-spec.md). Validate +before submitting: + +```bash +./scripts/validate-app-manifest.sh apps/<app-id>/manifest.yml +python3 scripts/generate-app-catalog.py +python3 scripts/check-app-catalog-drift.py --release --strict +``` + +App submissions must: + +- pin container image versions; +- avoid hardcoded secrets; +- use `security.no_new_privileges: true`; +- use `security.readonly_root: true` unless the manifest explains why writable + root is required; +- request only necessary Linux capabilities; +- store durable data under `/var/lib/archipelago/<app-id>/`; +- define truthful health checks and launch interfaces for user-facing UIs. + +## Code style + +- Rust: prefer `?` over `unwrap()`/`expect()` in production paths. +- Rust: use `tracing` for structured logs. +- TypeScript: avoid `any`; use explicit types or `unknown`. +- Vue: prefer `<script setup lang="ts">`. +- Keep changes scoped; do not mix drive-by refactors with behavioral changes. +- Remove dead code rather than commenting it out. +- Add tests for new behavior and regression tests for bug fixes. + +## Pull requests + +1. Open one focused PR per behavior or documentation change. +2. Explain what changed, why it changed, and how it was verified. +3. Include screenshots for UI changes. +4. Link relevant issues or docs. +5. Keep generated catalog changes in sync with manifest changes. + +Suggested commit format: + +```text +feat: add backup scheduling +fix: reject unsafe manifest volume +docs: clarify app deployment flow +test: cover catalog drift check +``` + +## Reporting bugs + +Include: + +- exact version or commit; +- host platform and architecture; +- steps to reproduce; +- expected and actual behavior; +- logs from the relevant component; +- screenshots for UI issues. + +## Security + +Do not report vulnerabilities in public issues. Follow [SECURITY.md](SECURITY.md). + +## License + +By contributing, you agree that your contribution is licensed under the +project's MIT License. diff --git a/INSTALL.sh b/INSTALL.sh new file mode 100755 index 00000000..33feaae8 --- /dev/null +++ b/INSTALL.sh @@ -0,0 +1,166 @@ +#!/bin/bash +# Archipelago Installation Script +# This script installs all dependencies needed to run the Archipelago project + +set -e + +echo "============================================" +echo "Archipelago Dependencies Installation" +echo "============================================" +echo "" + +# Colors for output +RED='\033[0;31m' +GREEN='\033[0;32m' +YELLOW='\033[1;33m' +NC='\033[0m' # No Color + +# Function to check if command exists +command_exists() { + command -v "$1" >/dev/null 2>&1 +} + +# Function to print status +print_status() { + if [ $1 -eq 0 ]; then + echo -e "${GREEN}✓${NC} $2" + else + echo -e "${RED}✗${NC} $2" + fi +} + +# Check and install Homebrew (macOS package manager) +echo "Checking Homebrew..." +if command_exists brew; then + print_status 0 "Homebrew already installed" +else + echo -e "${YELLOW}Installing Homebrew...${NC}" + /bin/bash -c "$(curl -fsSL https://raw.githubusercontent.com/Homebrew/install/HEAD/install.sh)" + + # Add Homebrew to PATH for Apple Silicon Macs + if [[ $(uname -m) == 'arm64' ]]; then + echo 'eval "$(/opt/homebrew/bin/brew shellenv)"' >> ~/.zprofile + eval "$(/opt/homebrew/bin/brew shellenv)" + fi + print_status 0 "Homebrew installed" +fi + +echo "" + +# Install Rust +echo "Checking Rust..." +if command_exists rustc && command_exists cargo; then + print_status 0 "Rust already installed ($(rustc --version))" +else + echo -e "${YELLOW}Installing Rust...${NC}" + curl --proto '=https' --tlsv1.2 -sSf https://sh.rustup.rs | sh -s -- -y + source "$HOME/.cargo/env" + print_status 0 "Rust installed ($(rustc --version))" +fi + +echo "" + +# Install Node.js +echo "Checking Node.js..." +if command_exists node && command_exists npm; then + NODE_VERSION=$(node --version) + print_status 0 "Node.js already installed ($NODE_VERSION)" +else + echo -e "${YELLOW}Installing Node.js...${NC}" + brew install node + print_status 0 "Node.js installed ($(node --version))" +fi + +echo "" + +# Install Podman +echo "Checking Podman..." +if command_exists podman; then + print_status 0 "Podman already installed ($(podman --version))" +else + echo -e "${YELLOW}Installing Podman...${NC}" + brew install podman + print_status 0 "Podman installed" + + echo -e "${YELLOW}Initializing Podman machine...${NC}" + podman machine init + podman machine start + print_status 0 "Podman machine initialized and started" +fi + +echo "" + +# Install PostgreSQL +echo "Checking PostgreSQL..." +if command_exists psql; then + print_status 0 "PostgreSQL already installed" +else + echo -e "${YELLOW}Installing PostgreSQL...${NC}" + brew install postgresql@15 + print_status 0 "PostgreSQL installed" + + echo -e "${YELLOW}Starting PostgreSQL service...${NC}" + brew services start postgresql@15 + print_status 0 "PostgreSQL service started" +fi + +echo "" +echo "============================================" +echo "Installing Project Dependencies" +echo "============================================" +echo "" + +# Install frontend dependencies +echo "Installing frontend dependencies (neode-ui)..." +cd neode-ui +npm install +print_status 0 "Frontend dependencies installed" +cd .. + +echo "" + +# Install custom app dependencies +echo "Installing custom app dependencies..." + +for app in did-wallet morphos-server router; do + if [ -d "apps/$app" ]; then + echo " - Installing $app dependencies..." + cd "apps/$app" + npm install + cd ../.. + print_status 0 "$app dependencies installed" + fi +done + +echo "" + +# Build Rust backend +echo "Building Rust backend..." +cd core +cargo build +print_status 0 "Backend built successfully" +cd .. + +echo "" +echo "============================================" +echo "Installation Complete!" +echo "============================================" +echo "" +echo "Next steps:" +echo "" +echo "1. Start the backend:" +echo " cd core" +echo " cargo run --bin archipelago" +echo "" +echo "2. In another terminal, start the frontend:" +echo " cd neode-ui" +echo " npm run dev" +echo "" +echo "3. Open your browser to:" +echo " http://localhost:8100" +echo "" +echo "For more information, see:" +echo " - README.md" +echo " - docs/developer-guide.md" +echo " - apps/QUICKSTART.md" +echo "" diff --git a/LICENSE b/LICENSE new file mode 100644 index 00000000..85278aa1 --- /dev/null +++ b/LICENSE @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2026 Dorian and the Archipelago Project contributors + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/NOTICE b/NOTICE new file mode 100644 index 00000000..b2dcc409 --- /dev/null +++ b/NOTICE @@ -0,0 +1,73 @@ +# Archipelago — Third-Party Notices + +Archipelago is licensed under the MIT License (see LICENSE). +This file lists third-party components included in this repository and its +release artifacts, with their licenses and required attributions. + +## Embedded / vendored components + +- **FIPS mesh networking** — https://github.com/jmcorgan/fips + Copyright (c) 2026 Johnathan Corgan. MIT License. + Used as the embedded mesh VPN in the OS (`fips` daemon, pinned v0.4.1) and + compiled into the Android companion app (`Android/rust/archy-fips-core`). + +- **QR Code Generator for JavaScript** — http://www.d-project.com/ + Copyright (c) 2009 Kazuhiko Arase. MIT License. + Vendored at `docker/lnd-ui/qrcode.js` and `docker/electrs-ui/qrcode.js` + (original headers preserved). + +- **nostr-rs-relay** — https://github.com/scsibug/nostr-rs-relay — MIT License. + Binary extracted into the OS image at `/opt/archipelago/bin/`. + +- **Reticulum (RNS) and LXMF** — https://github.com/markqvist/Reticulum + Copyright Mark Qvist. Distributed under the Reticulum License (an MIT-style + license with field-of-use restrictions: no use in systems designed to harm + human beings, and no use in AI/ML training datasets). The optional + `archy-reticulum-daemon` binary bundles RNS 1.3.5 and LXMF 1.0.1. The + Reticulum License is NOT an OSI-approved open-source license; it applies + only to that optional component, not to Archipelago itself. + +## Fonts + +- **Montserrat** — SIL Open Font License 1.1 + (`neode-ui/public/assets/fonts/Montserrat/OFL.txt`). +- **Open Sans** — Apache License 2.0 + (`neode-ui/public/assets/fonts/Open_Sans/LICENSE.txt`). + +## Artwork and icons + +- **Mesh device artwork** (`neode-ui/public/assets/img/mesh-devices/`): + device illustrations from the Meshtastic project — https://meshtastic.org + © Meshtastic contributors, GPL-3.0. Meshtastic® is a registered trademark + of Meshtastic LLC. See the ATTRIBUTION.md in that directory. +- Some UI icons are derived from **game-icons.net** (CC BY 3.0 — see + ATTRIBUTION.md in `neode-ui/public/assets/icon/`) and **pixelarticons** + (MIT, https://github.com/halfmage/pixelarticons). +- Third-party application logos under `neode-ui/public/assets/img/app-icons/` + and `service-icons/` are trademarks of their respective owners, used solely + to identify the corresponding applications. No endorsement is implied. + +## Original media + +All demo content (music, photos, posters in `demo/`), UI sound effects, +background images, and intro video in `neode-ui/public/assets/` are original +works created and owned by the Archipelago project author, released with the +project. The welcome voice line (`welcome-noderunner.mp3`) was generated with +ElevenLabs TTS under a commercial-use plan. + +## Redistributed software (ISO and container registry) + +The Archipelago OS image is based on Debian and redistributes Debian packages +(including the Linux kernel, GRUB, and non-free firmware/microcode blobs +required for hardware support); per-package license texts are preserved at +`/usr/share/doc/*/copyright` in the installed system, and corresponding source +is available via Debian (https://snapshot.debian.org) as referenced in each +release's notes. Container images offered through the app catalog and mirror +registry remain under their upstream licenses (including GPL/AGPL software +such as mempool, Nextcloud, Vaultwarden, SearXNG, PhotoPrism, Immich, +Jellyfin, MariaDB, AdGuard Home, and strfry); source links are provided in +the app catalog. The modified mempool-frontend image is built from +`docker/mempool-frontend/` in this repository (AGPL-3.0 corresponding source). + +Full per-crate and per-package license inventories for release binaries are +generated at build time (see THIRD-PARTY-LICENSES files in release artifacts). diff --git a/README.md b/README.md new file mode 100644 index 00000000..dd5a11be --- /dev/null +++ b/README.md @@ -0,0 +1,112 @@ +# Archipelago + +> Self-sovereign Bitcoin node OS and manifest-driven app platform. + +Archipelago is a bootable personal server OS for Bitcoin infrastructure, +self-hosted apps, mesh communication, decentralized identity, and federation. +Apps are packaged as declarative `manifest.yml` files and run as rootless +Podman containers managed by the Rust backend. + +[![Debian 13](https://img.shields.io/badge/Debian-13%20Trixie-a80030)](https://www.debian.org/) +[![License](https://img.shields.io/badge/license-MIT-green)](LICENSE) +[![Rust](https://img.shields.io/badge/rust-stable-orange)](https://www.rust-lang.org/) +[![Vue.js](https://img.shields.io/badge/vue.js-3.5-brightgreen)](https://vuejs.org/) +[![Version](https://img.shields.io/badge/version-1.8.0--alpha-blue)]() + +## What is here + +- `core/` - Rust workspace: backend API, container runtime, security, OpenWrt + helpers, and performance/resource management. +- `neode-ui/` - Vue 3 + TypeScript frontend. +- `apps/` - app manifests and custom app container sources. +- `docker/` - supporting container build contexts for UI companion surfaces. +- `image-recipe/` - bootable image/ISO build inputs. +- `Android/` - Android companion app. +- `scripts/` - development, release, deployment, and validation tooling. +- `docs/` - architecture, app packaging, operations, API, and roadmap docs. + +## Platform model + +Archipelago is built as a developer-ready app platform, not a fixed appliance: + +- Apps are declared in `apps/<app-id>/manifest.yml`. +- The Rust parser in `core/container/src/manifest.rs` is the canonical schema. +- The orchestrator compiles manifests to rootless Podman/Quadlet runtime state. +- App data lives under `/var/lib/archipelago/<app-id>/`. +- Secrets are generated or read from `/var/lib/archipelago/secrets/` and + injected through Podman secrets rather than static environment values. +- Release and app catalogs are signed and verified against a pinned trust + anchor. + +Start with: + +- [Architecture](docs/architecture.md) +- [Developer Guide](docs/developer-guide.md) +- [App Developer Guide](docs/app-developer-guide.md) +- [App Manifest Spec](docs/app-manifest-spec.md) +- [Nostr Git Source Hosting Plan](docs/nostr-git-source-hosting.md) +- [Troubleshooting](docs/troubleshooting.md) + +## Quick start + +### Frontend + +```bash +cd neode-ui +npm install +npm start +``` + +The dev UI runs at `http://localhost:8100` with a mock backend on `:5959`. + +### Backend + +```bash +cd core +cargo build +cargo test --all-features +``` + +Linux is the supported backend runtime and release-build target. macOS is fine +for frontend work and many Rust compile/test loops, but host integration tests +that touch Podman, systemd, networking, or image build paths require Linux. + +### App manifests + +```bash +./scripts/validate-app-manifest.sh apps/filebrowser/manifest.yml +python3 scripts/generate-app-catalog.py +python3 scripts/check-app-catalog-drift.py --release --strict +``` + +`scripts/generate-app-catalog.py` requires Python with PyYAML installed. + +## Documentation map + +The full, grouped index lives at **[docs/README.md](docs/README.md)**. The most +common entry points: + +| Doc | Purpose | +|-----|---------| +| [Architecture](docs/architecture.md) | System layers, crates, data paths, security model | +| [Developer Guide](docs/developer-guide.md) | Local setup, code workflow, testing | +| [API Reference](docs/api-reference.md) | JSON-RPC API overview | +| [App Developer Guide](docs/app-developer-guide.md) | How to package and test apps | +| [App Manifest Spec](docs/app-manifest-spec.md) | Manifest schema and validation rules | +| [Nostr Git Source Hosting Plan](docs/nostr-git-source-hosting.md) | ngit/NIP-34 contribution workflow and maintainer model | +| [Apps README](apps/README.md) | Packaged app catalog overview | +| [Image Recipe](image-recipe/README.md) | Bootable image build flow | +| [Roadmap](docs/ROADMAP.md) | Shipped, in-progress, and planned work | +| [Archive](docs/archive/) | Historical plans, audits, and handoffs | + +## Contributing + +Read [CONTRIBUTING.md](CONTRIBUTING.md) before opening a pull request. For +security issues, follow [SECURITY.md](SECURITY.md) and do not open a public +issue. + +## License + +Archipelago is licensed under the [MIT License](LICENSE). Third-party notices +are listed in [NOTICE](NOTICE) and generated license inventories in component +release artifacts. diff --git a/SECURITY.md b/SECURITY.md new file mode 100644 index 00000000..57ddeb97 --- /dev/null +++ b/SECURITY.md @@ -0,0 +1,38 @@ +# Security Policy + +## Reporting vulnerabilities + +Please do not open a public issue for a security vulnerability. + +Until a dedicated security intake address is published, report privately to the +project maintainer through the repository owner account or the private contact +channel listed on the project homepage. + +Include: + +- affected commit, version, or release; +- affected component; +- reproduction steps; +- expected impact; +- logs, proof of concept, or packet captures when relevant; +- whether the issue is already public. + +We aim to acknowledge credible reports within 48 hours and coordinate fixes +before public disclosure. + +## Scope + +Security-sensitive areas include: + +- authentication, session handling, CSRF, and rate limiting; +- release and app-catalog signature verification; +- container manifest validation and runtime compilation; +- Podman/Quadlet isolation, capabilities, volumes, and secret injection; +- backup encryption and key derivation; +- federation, Tor, Nostr, mesh, DID, and credential flows; +- Android companion pairing and device-token handling. + +## Supported versions + +Archipelago is currently pre-1.0 alpha software. Security fixes target the +current `main` branch and the latest published alpha release. diff --git a/aiui/.claude/hooks/block-risky-bash.sh b/aiui/.claude/hooks/block-risky-bash.sh new file mode 100755 index 00000000..b27f9f0e --- /dev/null +++ b/aiui/.claude/hooks/block-risky-bash.sh @@ -0,0 +1,71 @@ +#!/usr/bin/env bash +# PreToolUse Bash guard: block dangerous shell commands. +# Denies: rm -rf, git reset --hard, git push -f, git clean -fd, chmod -R 777, +# fork bombs, block device overwrites, mkfs, paths escaping project root. +# Uses python3 instead of jq for JSON (guaranteed on macOS). +set -euo pipefail + +INPUT=$(cat) +CMD=$(python3 -c " +import json, sys +try: + data = json.loads(sys.stdin.read()) + print(data.get('tool_input', {}).get('command', '')) +except: pass +" <<< "$INPUT") +BASE="${CLAUDE_PROJECT_DIR:-}" +[[ -z "$BASE" ]] && BASE=$(python3 -c " +import json, sys +try: + data = json.loads(sys.stdin.read()) + print(data.get('cwd', '')) +except: pass +" <<< "$INPUT") +[[ -z "$BASE" ]] && BASE="$(pwd)" + +# Normalize: collapse whitespace, strip leading/trailing +CMD_NORM=$(echo "$CMD" | tr -s '[:space:]' ' ' | sed 's/^[[:space:]]*//;s/[[:space:]]*$//') + +deny() { + local reason="$1" + python3 -c " +import json +print(json.dumps({ + 'hookSpecificOutput': { + 'hookEventName': 'PreToolUse', + 'permissionDecision': 'deny', + 'permissionDecisionReason': '$reason' + } +})) +" + exit 0 +} + +# Dangerous patterns (case-insensitive where sensible) +case "$CMD_NORM" in + *"rm -rf"*|*"rm -fr"*|*"rm -f -r"*|*"rm -r -f"*) deny "Destructive rm -rf blocked by security hook" ;; + *"git reset --hard"*) deny "git reset --hard would lose uncommitted work" ;; + *"git push --force"*|*"git push -f"*|*"git push -f "*) deny "git push --force would rewrite history" ;; + *"git clean -fd"*|*"git clean -f -d"*) deny "git clean -fd deletes untracked files" ;; + *"chmod -R 777"*|*"chmod -R 0777"*) deny "chmod -R 777 is a security risk" ;; + *":(){ :"*"};:"*) deny "Fork bomb pattern blocked" ;; + *"> /dev/sd"*|*">/dev/sd"*) deny "Block device overwrite blocked" ;; + *"mkfs "*|*"mkfs."*) deny "Disk format command blocked" ;; +esac + +# Check for path traversal escaping project root (../ outside project) +# Only if we have a sensible base +if [[ -n "$BASE" ]] && [[ -d "$BASE" ]]; then + # Simple heuristic: command contains .. and would resolve outside project + if echo "$CMD_NORM" | grep -qE '\.\./|/\.\.'; then + # Extract plausible paths and check - allow ../ within project + if echo "$CMD_NORM" | grep -qE '(rm|mv|cp|cat|chmod|chown)\s+.*\.\.'; then + # Could be risky; be conservative for rm/mv/cp + if echo "$CMD_NORM" | grep -qE '\brm\b.*\.\.'; then + deny "Path traversal with rm blocked" + fi + fi + fi +fi + +exit 0 diff --git a/aiui/.claude/hooks/post-push-progress.sh b/aiui/.claude/hooks/post-push-progress.sh new file mode 100755 index 00000000..fa909356 --- /dev/null +++ b/aiui/.claude/hooks/post-push-progress.sh @@ -0,0 +1,75 @@ +#!/usr/bin/env bash +# PostToolUse Bash hook: detect git push/commit and prompt Claude to update PROGRESS.md. +# Returns structured feedback with recent commits so Claude can write a session log entry. +# Uses python3 instead of jq for JSON (guaranteed on macOS). +set -euo pipefail + +INPUT=$(cat) + +# Extract command from JSON using python3 +CMD=$(python3 -c " +import json, sys +try: + data = json.loads(sys.stdin.read()) + print(data.get('tool_input', {}).get('command', '')) +except: pass +" <<< "$INPUT") + +# Only trigger on git push or git commit commands +if ! echo "$CMD" | grep -qE '\bgit\s+(push|commit)\b'; then + exit 0 +fi + +# Gather context for the progress update +BASE="${CLAUDE_PROJECT_DIR:-$(pwd)}" +BRANCH=$(git -C "$BASE" branch --show-current 2>/dev/null || echo "unknown") +PROGRESS_FILE="$BASE/PROGRESS.md" +TIMESTAMP=$(date '+%Y-%m-%d %H:%M') + +# Get recent commits (branch vs main, or last 10) +if git -C "$BASE" rev-parse --verify main &>/dev/null; then + COMMITS=$(git -C "$BASE" log --oneline main..HEAD 2>/dev/null | head -15) + if [ -z "$COMMITS" ]; then + COMMITS=$(git -C "$BASE" log --oneline -10 2>/dev/null) + fi +else + COMMITS=$(git -C "$BASE" log --oneline -10 2>/dev/null) +fi + +# Get changed files in recent commits +CHANGED_FILES=$(git -C "$BASE" diff --name-only main..HEAD 2>/dev/null | head -20 || \ + git -C "$BASE" diff --name-only HEAD~5..HEAD 2>/dev/null | head -20 || \ + echo "unknown") + +# Build the feedback message and output as JSON using python3 +python3 -c " +import json, sys + +message = '''Progress Update Needed + +A git push/commit was detected on branch \`$BRANCH\` at $TIMESTAMP. + +Recent commits: +\`\`\` +$COMMITS +\`\`\` + +Changed files: +\`\`\` +$CHANGED_FILES +\`\`\` + +Please update PROGRESS.md: +1. Add a session log entry under '## Session Log' with format: ### $TIMESTAMP — $BRANCH +2. Summarize what was accomplished (2-4 bullet points based on the commits above) +3. Update any roadmap checkboxes if tasks were completed +4. Commit the PROGRESS.md update''' + +output = { + 'hookSpecificOutput': { + 'hookEventName': 'PostToolUse', + 'progressUpdate': message + } +} +print(json.dumps(output)) +" diff --git a/aiui/.claude/hooks/protect-files.sh b/aiui/.claude/hooks/protect-files.sh new file mode 100755 index 00000000..a0670545 --- /dev/null +++ b/aiui/.claude/hooks/protect-files.sh @@ -0,0 +1,85 @@ +#!/usr/bin/env bash +# PreToolUse Edit|Write guard: block edits outside project and to protected paths. +# Denies: paths outside project, .git/, .env*, lockfiles, node_modules/ +# Uses python3 instead of jq for JSON (guaranteed on macOS). +set -euo pipefail + +INPUT=$(cat) +FILE_PATH=$(python3 -c " +import json, sys +try: + data = json.loads(sys.stdin.read()) + print(data.get('tool_input', {}).get('file_path', '')) +except: pass +" <<< "$INPUT") +BASE="${CLAUDE_PROJECT_DIR:-}" +[[ -z "$BASE" ]] && BASE=$(python3 -c " +import json, sys +try: + data = json.loads(sys.stdin.read()) + print(data.get('cwd', '')) +except: pass +" <<< "$INPUT") +[[ -z "$BASE" ]] && BASE="$(pwd)" + +# Resolve to absolute path +if [[ -z "$FILE_PATH" ]]; then + exit 0 +fi +ABS_BASE=$(cd "$BASE" 2>/dev/null && pwd) || true +[[ -z "$ABS_BASE" ]] && ABS_BASE=$(python3 -c "import os,sys; print(os.path.abspath(os.path.normpath(sys.argv[1])))" "$BASE" 2>/dev/null) || true +[[ -z "$ABS_BASE" ]] && ABS_BASE="$BASE" +# Ensure base has trailing slash for prefix check +[[ "$ABS_BASE" != */ ]] && ABS_BASE="${ABS_BASE}/" +if [[ "$FILE_PATH" != /* ]]; then + ABS_PATH="$ABS_BASE${FILE_PATH#./}" +else + ABS_PATH="$FILE_PATH" +fi +# Normalize path (collapse .. and ., no symlink resolution needed) +ABS_PATH=$(python3 -c "import os,sys; print(os.path.abspath(os.path.normpath(sys.argv[1])))" "$ABS_PATH" 2>/dev/null) || true +[[ -z "$ABS_PATH" ]] && ABS_PATH="$ABS_BASE${FILE_PATH#./}" + +deny() { + local reason="$1" + echo "Blocked: $ABS_PATH — $reason" >&2 + python3 -c " +import json +print(json.dumps({ + 'hookSpecificOutput': { + 'hookEventName': 'PreToolUse', + 'permissionDecision': 'deny', + 'permissionDecisionReason': '$reason' + } +})) +" + exit 0 +} + +# Protected patterns (path contains or equals) +PROTECTED_PATTERNS=( + ".git/" + ".env" + ".env.local" + "node_modules/" + "package-lock.json" + "pnpm-lock.yaml" +) + +for pattern in "${PROTECTED_PATTERNS[@]}"; do + if [[ "$ABS_PATH" == *"$pattern"* ]] || [[ "$ABS_PATH" == *"/$pattern" ]]; then + deny "Edit blocked: path matches protected pattern ($pattern)" + fi +done + +# .env.*.local +if [[ "$ABS_PATH" =~ \.env\..*\.local$ ]]; then + deny "Edit blocked: .env.*.local files contain secrets" +fi + +# Ensure path is under project root (ABS_BASE has trailing /) +if [[ "$ABS_PATH" != "$ABS_BASE"* ]] && [[ "$ABS_PATH" != "$BASE"* ]]; then + deny "Edit blocked: path is outside project directory" +fi + +exit 0 diff --git a/aiui/.claude/launch.json b/aiui/.claude/launch.json new file mode 100644 index 00000000..8d6343b5 --- /dev/null +++ b/aiui/.claude/launch.json @@ -0,0 +1,12 @@ +{ + "version": "0.0.1", + "configurations": [ + { + "name": "app", + "runtimeExecutable": "bash", + "runtimeArgs": ["packages/app/scripts/dev.sh"], + "port": 5173, + "autoPort": true + } + ] +} diff --git a/aiui/.claude/memory/MEMORY.md b/aiui/.claude/memory/MEMORY.md new file mode 100644 index 00000000..cb1a743c --- /dev/null +++ b/aiui/.claude/memory/MEMORY.md @@ -0,0 +1,61 @@ +# AIUI Project Memory + +## Session Startup +1. Run `preview_start` with name `"app"` immediately — runs both Vite (:5173) + Claude proxy (:3141) via `packages/app/scripts/dev.sh` +2. Always commit work before ending a session +3. Work on `development` branch, merge to `main` only when production ready + +## User Preferences +- NO worktrees, NO temporary branches — just `development` and `main` +- Always use combined dev script (proxy + frontend), never bare `vite` +- Commit frequently to avoid losing work + +## Current State (2026-03-04) +- Branch: `overnight/2026-03-03`, all committed and pushed to remote (git.tx1138.com) +- Typecheck passes clean + +## What's Been Built +- Chat: AI streaming with stop generation, web search, article integration, paste & extract +- Content panel tabs: Films, Music, Magazine, News, Books, TV Series, Images, Places, Code, Design System, Nostr, **Apps** +- Detail views for each content type (side-by-side desktop, overlay mobile) +- **Apps tab**: curated DB of ~30 Nostr/Bitcoin apps, AppsGrid + AppDetail with search/category filtering/how-to +- Design system viewer (grid + detail) for tokens, colors, typography, components +- Nostr feed scaffold with note/article/zap filtering +- Content extraction: contentExtraction.ts + contentFiltering.ts (overhauled classifiers) +- **Bare domain extraction** from AI text (e.g. "check out damus.io") +- Banner fallback composable (primary → API → gradient) +- Image fallbacks: Wikipedia + Google Books sources +- Loading skeletons per content type variant +- Project grid with breadcrumb nav and inline creation +- Filesystem Vite plugin for local project browsing +- PWA with star icon, TMDB proxy, Jamendo for music +- **Slash command palette**: /code, /nostr, /design, /search show in palette with auto-send +- **Chat action buttons**: wrapped in glass container (backdrop blur, border, shadow) +- **Settings modal**: Memory + Advanced Settings via gear icon +- **Chat history**: dedicated clock icon button +- **Web search**: Brave API primary, SearXNG rotation fallback, DuckDuckGo fallback +- iOS HIG mobile UX rules in `.cursor/rules/15-mobile-ux.mdc` + +## Key Files +- Dev script: `packages/app/scripts/dev.sh` +- Launch config: `.claude/launch.json` (name: "app") +- Main page: `packages/app/src/pages/ChatPage.vue` +- Content panel: `packages/app/src/components/content/ContentPanel.vue` +- Content grids: `packages/app/src/components/content/*Grid.vue` +- Detail views: `packages/app/src/components/content/*Detail.vue` +- **Apps**: `packages/app/src/data/apps.ts` (curated DB), `AppsGrid.vue`, `AppDetail.vue` +- AI composable: `packages/app/src/composables/useAI.ts` +- Content extraction: `packages/app/src/composables/contentExtraction.ts` +- Content filtering: `packages/app/src/composables/contentFiltering.ts` +- Content panel logic: `packages/app/src/composables/useContentPanel.ts` +- Image fallbacks: `packages/app/src/composables/useImageFallback.ts` +- Banner fallback: `packages/app/src/composables/useBannerFallback.ts` +- Chat input: `packages/app/src/components/chat/ChatInput.vue` +- Prompt palette: `packages/app/src/components/chat/PromptPalette.vue` +- Chat message: `packages/app/src/components/chat/ChatMessage.vue` +- Settings modal: `packages/app/src/components/chat/SettingsModal.vue` +- Web search plugin: `packages/app/vite-web-search.ts` +- Prompt templates store: `packages/app/src/stores/promptTemplates.ts` + +## Recent Session Work (2026-03-04) +See `session-2026-03-04.md` for details. diff --git a/aiui/.claude/memory/code-mode-ui.md b/aiui/.claude/memory/code-mode-ui.md new file mode 100644 index 00000000..9f69526b --- /dev/null +++ b/aiui/.claude/memory/code-mode-ui.md @@ -0,0 +1,18 @@ +# Code Mode UI — Future Work + +## After content surfacing is complete, implement: + +### 1. Code Mode Visual Treatment +- Colour the message container in orange (`#F7931A`) styling when in code mode +- Change header text from "Message AIUI" to "Code" +- Visual signal so user knows they're in coding context + +### 2. Design System Context Selection +- All design system items should be selectable with a cursor/pointer icon on hover +- Selecting a design system item provides that UI context to the code generation +- Think of it as "code with this component/token in mind" + +### 3. File Browser / Open File Context +- File browser or open file in the content panel +- Selected files provide context for coding +- Pairs with the design system selection — user picks UI + files as coding context diff --git a/aiui/.claude/memory/session-2026-03-04.md b/aiui/.claude/memory/session-2026-03-04.md new file mode 100644 index 00000000..c16258df --- /dev/null +++ b/aiui/.claude/memory/session-2026-03-04.md @@ -0,0 +1,66 @@ +# Session 2026-03-04 + +## Completed This Session + +### 1. Chat UX Changes +- **History button**: Changed from title-click dropdown to dedicated clock icon in ChatHeader +- **Settings modal**: Created `SettingsModal.vue` — Memory + Advanced Settings behind gear icon, glass-card with backdrop blur +- **PromptIndex fix**: Reverted to original behavior (current conversation only), fixed broken v-if/v-else chain where StreamingDots broke the template chain +- **Chat action buttons**: Wrapped hover icons in proper glass container (`bg-black/60 backdrop-blur-md border border-white/10`) with divider between actions and thumbs + +### 2. iOS HIG Integration +- Created `.cursor/rules/15-mobile-ux.mdc` with comprehensive iOS HIG values +- Updated CLAUDE.md Mobile UX section + +### 3. Web Search Fix +- All SearXNG instances were returning 429, DuckDuckGo rate-limiting +- Added Brave Search API as primary backend (`BRAVE_SEARCH_API_KEY` env var) +- Expanded SearXNG pool to 8 instances with rotation +- Added HTML response guard for captcha pages + +### 4. Content Detection Overhaul (MAJOR) +- **Expanded all classifiers** in `contentFiltering.ts`: isNewsQuery, isMusicQuery, isBookQuery, isTVQuery, isPlaceQuery, isWebsitesQuery + response variants +- **Added Nostr detection**: `isNostrQuery()`, `isNostrLikeResponse()` +- **Added App detection**: `isAppQuery()`, `isAppLikeResponse()` +- **Updated `filterTabsByContext()`**: new `hasNostr` + `hasApps` params, nostr/app query priority +- **Updated `preferredFirstTab()`**: nostr + app checks + +### 5. Bare Domain Extraction +- `extractBareDomainLinks(text)` in contentExtraction.ts +- Detects plain domains like "damus.io" not inside markdown/bold/URL patterns +- Known TLDs whitelist, file extension blacklist + +### 6. Apps Tab (NEW FEATURE) +- **Database**: `packages/app/src/data/apps.ts` — AppEntry interface, ~30 curated apps + - Nostr clients: Damus, Primal, Snort, Amethyst, Coracle, Iris, noStrudel + - Lightning wallets: Phoenix, Breez, Zeus, Alby, Mutiny, WoS + - Bitcoin wallets: Sparrow, BlueWallet, Nunchuk, Coldcard + - Privacy: SimpleX Chat, Signal, Mullvad VPN + - Node software: Start9, Umbrel, RaspiBlitz, myNode + - Dev tools: NDK, nostr-tools, Nak +- **Extraction**: `extractApps(text, userQuery)` — keyword matching against DB, surfaces with 1+ match for app/nostr/known-app queries, 2+ for general +- **UI**: `AppsGrid.vue` (list with search/category filter), `AppDetail.vue` (gradient header, how-to steps, related apps, external link) +- **Wired in**: useContentPanel.ts (panelApps ref, selectedApp, open/close), ContentPanel.vue (registered), PromptIndex badges + +### 7. Slash Command Palette +- `/code`, `/nostr`, `/design`, `/search` appear as commands in PromptPalette +- Commands section above Templates section with `/slash` prefix styling +- Auto-send on select (except `/search` which sets text for query input) +- 8px side margins (`left-2 right-2`), no max-height scroll limit +- `ChatInput.vue`: simplified `isPaletteMode` — no longer excludes command names + +### 8. App Detection Fix +- Queries mentioning known app names (e.g. "start9") now match via `queryMatchesApp` check +- Previously required explicit app/nostr query patterns like "what app" or "best wallet" + +## Known Issues / TODO for Next Session +- User reported "start9" search shows Brief but Apps tab was empty — FIXED in last commit +- The `/design` command was added to palette and ChatWindow handleSend +- Consider adding more apps to the curated database over time +- The plan file is at `.claude/plans/content-detection-overhaul.md` (all steps complete) + +## Git State +- Branch: `overnight/2026-03-03` +- Latest commit: `f346992` — feat(chat): slash command palette, action button containers, app detection fix +- Previous commit: `84ccdc7` — feat(app): content detection overhaul, apps tab, chat UX, web search +- All pushed to origin diff --git a/aiui/.claude/plans/content-detection-overhaul.md b/aiui/.claude/plans/content-detection-overhaul.md new file mode 100644 index 00000000..61927689 --- /dev/null +++ b/aiui/.claude/plans/content-detection-overhaul.md @@ -0,0 +1,160 @@ +# Plan: Overhaul Content Detection + Add Apps Tab + +## Context + +The content surfacing system misses many common AI response patterns. Example: AI responds about Nostr (mentioning damus.io, primal.net, snort.social) but the Nostr tab never surfaces. Query/response classifiers use narrow regexes that miss natural language variations. There's no "topic detection" layer, no app detection, and bare domains in AI text aren't extracted as websites. + +**Goals:** +1. Fix content detection to handle how AIs actually respond +2. Add Nostr tab surfacing (currently only via `/nostr` command) +3. Add Apps tab with curated Nostr + Bitcoin ecosystem apps (local DB + AI extraction fallback) +4. Extract bare domains from AI text (e.g. "check out damus.io") + +--- + +## Part 1: Expand Query & Response Classifiers + +**File:** `packages/app/src/composables/contentFiltering.ts` + +### 1A. Add Nostr classifiers (new functions) + +- `isNostrQuery(q)` — matches: nostr, npub, nip-\d, damus, primal, snort, amethyst, coracle, zap, relay, note1, nevent, nprofile, fiatjaf, nostrich, "decentralized social" +- `isNostrLikeResponse(text)` — requires literal "nostr" OR 2+ Nostr-specific signals (npub, nip-, client names, relay+wss, zap+lightning) + +### 1B. Add App classifiers (new functions) + +- `isAppQuery(q)` — matches: app, client, wallet, tool, software, download, install, "what app", "best app for", "recommend.*app" +- `isAppLikeResponse(text)` — matches: "you can use", "popular clients include", "I'd recommend", "available on", "download from" + +### 1C. Expand existing classifiers with broader patterns + +| Classifier | Add these patterns | +|---|---| +| `isNewsQuery` | "what happened today", "any updates on", "trending", "catch me up", "brief me", "current events" | +| `isMusicQuery` | "genre", "spotify", "bandcamp", "grammys", "billboard", "mixtape", "discography", "banger", "favorite jam" | +| `isBookQuery` | "what should I read", "favorite reads", "reading list", "book club", "memoir", "audiobook", "goodreads", "worth reading" | +| `isTVQuery` | "what's good on netflix", "anything to binge", "hbo", "disney+", "apple tv", "amazon prime", "docuseries", "limited series" | +| `isPlaceQuery` | "hungry", "food near me", "best brunch spot", "happy hour", "speakeasy", "rooftop bar", "food truck" | +| `isWebsitesQuery` | "point me to", "link me", "any good sites", "tools for", "platforms for" | +| `isWebsitesLikeResponse` | "here are some resources", "I'd recommend checking", "you can visit", "useful resources" | +| `isNewsLikeResponse` | "I can't access the web but", "having trouble reaching", "unable to browse but" | + +### 1D. Update `preferredFirstTab()` — add nostr + app checks + +### 1E. Update `filterTabsByContext()` — add `hasNostr` and `hasApps` params, integrate into tab ordering + +--- + +## Part 2: Bare Domain Extraction + +**File:** `packages/app/src/composables/contentExtraction.ts` + +Add `extractBareDomainLinks(text)`: +- Detect plain-text domains like "damus.io", "primal.net" not inside markdown links or bold patterns +- Skip positions covered by existing extractors (markdown links, bold-domain, full URLs) +- Require known TLDs (.com, .org, .io, .net, .social, .app, etc.) +- Block file extensions (.js, .ts, .vue, .json, .css) +- Use existing `normUrl()` for dedup + +--- + +## Part 3: Apps Tab — Curated Database + AI Extraction + +### 3A. Create app database + +**New file:** `packages/app/src/data/apps.ts` + +```ts +interface AppEntry { + id: string + name: string + description: string // One-liner + longDescription: string // Why use this, how it works + category: 'nostr-client' | 'lightning-wallet' | 'bitcoin-wallet' | 'privacy' | 'node' | 'dev-tool' | 'relay' + platforms: ('ios' | 'android' | 'web' | 'desktop' | 'cli' | 'nodeos')[] + url: string + icon?: string + keywords: string[] // For matching AI responses + howTo?: string[] // Getting started steps + relatedApps?: string[] // IDs of related apps +} +``` + +**Initial curated apps (~25-30):** +- Nostr clients: Damus, Primal, Snort, Amethyst, Coracle, Iris, Nostrudel, nos.social +- Lightning wallets: Phoenix, Mutiny, Breez, Zeus, Alby, Wallet of Satoshi +- Bitcoin wallets: Sparrow, Blue Wallet, Nunchuk, Coldcard, Green +- Privacy tools: Tor, SimpleX Chat, Signal, Mullvad VPN +- Node software: Start9, Umbrel, RaspiBlitz, myNode +- Dev tools: NDK, nostr-tools, Nak + +### 3B. Add app extraction + +**File:** `packages/app/src/composables/contentExtraction.ts` + +Add `extractApps(text, userQuery)`: +1. Match AI text against known app names/keywords from database +2. If app query detected OR 2+ known apps mentioned → return matched apps +3. For unknown apps, create basic entries from context (name + URL if bare domain found) + +### 3C. Create UI components + +**New files:** +- `packages/app/src/components/content/AppsGrid.vue` — Grid of app cards (icon, name, category badge, one-liner) +- `packages/app/src/components/content/AppDetail.vue` — Detail: icon, name, platforms, long description, how-to steps, link, related apps + +Follow existing grid/detail patterns (e.g. `BookGrid.vue`/`BookDetail.vue`). + +### 3D. Register in ContentPanel.vue + +Add rendering for `activeTab === 'app'`, add `'app'` to `ContentTab` type. + +--- + +## Part 4: Wire Everything Together + +**File:** `packages/app/src/composables/useContentPanel.ts` + +In `updatePanelFromText()`: +- Call `extractBareDomainLinks(text)`, merge with website sources +- Call `extractApps(text, userQuery)` +- Compute `hasNostr = isNostrQuery(userQuery) || isNostrLikeResponse(text)` +- Compute `hasApps = apps.length > 0` +- Pass `hasNostr` and `hasApps` to `filterTabsByContext()` +- Add `panelApps` ref, title logic for apps/nostr tabs + +Same changes in `getContextualInlineContent()`. + +Broaden magazine detection: add tech/protocol keywords, surface magazine for 3+ sections with no other structured content. + +--- + +## Part 5: PromptIndex badges + +**File:** `packages/app/src/components/chat/PromptIndex.vue` + +Add 'Nostr' and 'Apps' badge detection. + +--- + +## Implementation Order + +1. `contentFiltering.ts` — classifiers + filterTabsByContext signature +2. `contentExtraction.ts` — `extractBareDomainLinks()` + `extractApps()` +3. `data/apps.ts` — curated app database +4. `useContentPanel.ts` — wire everything +5. `AppsGrid.vue` + `AppDetail.vue` — UI components +6. `ContentPanel.vue` — register tab + components +7. `PromptIndex.vue` — badges +8. Typecheck + manual test + +## Verification + +1. `pnpm typecheck` passes +2. "tell me about Nostr" → Nostr + magazine tabs surface +3. "best Nostr clients?" → Apps tab with Damus, Primal, Snort +4. "recommend a bitcoin wallet" → Apps tab with Phoenix, Sparrow +5. "what happened with BIP 110?" → Magazine tab (regression) +6. "best movies of 2024" → Films tab (regression) +7. Bare domains in AI text extracted as websites +8. PromptIndex badges show Nostr/Apps diff --git a/aiui/.claude/plans/fluffy-bouncing-whisper.md b/aiui/.claude/plans/fluffy-bouncing-whisper.md new file mode 100644 index 00000000..200d13ca --- /dev/null +++ b/aiui/.claude/plans/fluffy-bouncing-whisper.md @@ -0,0 +1,74 @@ +# Plan: Code Mode UI — Orange Input, Design System Context, File Browser Context + +## Context +The user wants three connected features that enhance the coding experience in AIUI: +1. Visual indication when in code mode (orange input container, "Code" label) +2. Ability to select design system items as coding context +3. Ability to select files from file browser as coding context + +After this, the user wants to circle back and create a flawless version of content extraction/tab surfacing. + +## Changes + +### 1. Orange Code Mode Input Container +**Files**: `ChatWindow.vue`, `ChatInput.vue` + +**ChatWindow.vue** (line 106-115): +- Pass `activeTab` to ChatInput as a prop: `:active-tab="activeTab"` +- Change placeholder logic: `activeTab === 'code' ? 'Code...' : isStreaming ? 'Waiting for response...' : 'Message AIUI...'` + +**ChatInput.vue**: +- Add `activeTab` prop (optional string, default `''`) +- Conditionally style the container div (line 79-81): + - When `activeTab === 'code'`: use `bg-accent/15 border border-accent/25 backdrop-blur-xl` instead of `path-glass-bubble` + - Keep the `rounded-2xl px-4 py-3 flex items-end gap-2 transition-all duration-300` classes +- Conditionally style the send button orange when in code mode + +### 2. Design System Item Selection for Coding Context +**Files**: `useCodeContext.ts`, `DesignSystemGrid.vue` + +**useCodeContext.ts**: +- Add `selectedDesignTokens: ref<string[]>([])` to module state (stores item IDs) +- Add `toggleDesignToken(id)` — adds/removes from selection array +- Add `clearDesignTokens()` — clears selection +- Add `isDesignTokenSelected(id)` — checks if item is in selection +- Clear on `exitCodeMode()` +- Export all new state/actions + +**DesignSystemGrid.vue**: +- Import `useCodeContext` +- When `codeMode` is true, show a selection indicator (accent ring + checkmark) on items +- `selectItem` should call `toggleDesignToken(item.id)` when in code mode (instead of `openDesignSystemItem`) +- When NOT in code mode, keep existing behavior (open detail view) +- Selected items get `ring-2 ring-accent/50 bg-accent/10` styling + +### 3. File Browser Selection for Coding Context +**Files**: `useCodeContext.ts`, `ProjectGrid.vue` + +**useCodeContext.ts**: +- Add `selectedFiles: ref<string[]>([])` — paths of files selected for context +- Add `toggleFileSelection(path)` — adds/removes from selection +- Add `clearFileSelection()` — clears all +- Add `isFileSelected(path)` — checks if file in selection +- Clear on `exitCodeMode()` +- Export new state/actions + +**ProjectGrid.vue**: +- Import `useCodeContext` +- When `codeMode` is true, file clicks toggle selection instead of (or in addition to) opening +- Show visual selection state (accent highlight/checkmark) on selected files in FileTreeNode + +## Files to Modify +1. `packages/app/src/components/chat/ChatWindow.vue` — pass activeTab prop +2. `packages/app/src/components/chat/ChatInput.vue` — conditional orange styling + "Code" placeholder +3. `packages/app/src/composables/useCodeContext.ts` — add design token + file selection state +4. `packages/app/src/components/content/DesignSystemGrid.vue` — toggle selection in code mode +5. `packages/app/src/components/content/ProjectGrid.vue` — toggle file selection in code mode + +## Verification +1. `pnpm typecheck` — no type errors +2. `pnpm lint` — no new lint errors +3. Manual: `/code` command → input turns orange with "Code..." placeholder +4. Manual: In code mode, design system tab → clicking items toggles selection (accent ring) +5. Manual: In code mode, file browser → clicking files toggles selection +6. Manual: Exiting code mode clears all selections diff --git a/aiui/.claude/settings.json b/aiui/.claude/settings.json new file mode 100644 index 00000000..0454473d --- /dev/null +++ b/aiui/.claude/settings.json @@ -0,0 +1,35 @@ +{ + "hooks": { + "PreToolUse": [ + { + "matcher": "Bash", + "hooks": [ + { + "type": "command", + "command": "\"$CLAUDE_PROJECT_DIR\"/.claude/hooks/block-risky-bash.sh" + } + ] + }, + { + "matcher": "Edit|Write", + "hooks": [ + { + "type": "command", + "command": "\"$CLAUDE_PROJECT_DIR\"/.claude/hooks/protect-files.sh" + } + ] + } + ], + "PostToolUse": [ + { + "matcher": "Bash", + "hooks": [ + { + "type": "command", + "command": "\"$CLAUDE_PROJECT_DIR\"/.claude/hooks/post-push-progress.sh" + } + ] + } + ] + } +} diff --git a/aiui/.claude/skills/add-content-type/SKILL.md b/aiui/.claude/skills/add-content-type/SKILL.md new file mode 100644 index 00000000..4963e586 --- /dev/null +++ b/aiui/.claude/skills/add-content-type/SKILL.md @@ -0,0 +1,43 @@ +--- +name: add-content-type +description: Scaffold a complete new content type (tag, extraction, grid, detail, prompt) +allowed-tools: Bash(*), Read, Edit, Write, Glob, Grep, Agent +--- + +Add a complete new content type to AIUI. The user will provide the content type name (e.g., "event", "product", "video"). + +Follow ALL steps — this is the full pipeline for a content type: + +1. **Tag format**: Add a new `[[{type}_ext:Field1|Field2|...]]` regex to `packages/app/src/composables/contentExtraction.ts` alongside the existing ones (FILM_EXT_RE, SONG_EXT_RE, etc.) + +2. **Type definition**: Add the TypeScript interface to `packages/core/src/types/content.ts` if it doesn't exist + +3. **Extraction function**: Add `extractAll{Type}s(text, userQuery)` to `contentExtraction.ts` following the pattern of `extractAllFilms` or `extractAllBooks` + +4. **Strip tags function**: Add `strip{Type}Tags()` and include it in `stripContentTags()` + +5. **Query classifier**: Add `is{Type}Query()` and optionally `is{Type}LikeResponse()` to `contentFiltering.ts` + +6. **ContentTab type**: Add the new tab name to the `ContentTab` union in `contentFiltering.ts` + +7. **Tab filtering**: Update `filterTabsByContext()` and `preferredFirstTab()` in `contentFiltering.ts` + +8. **Grid component**: Create `packages/app/src/components/content/{Type}Grid.vue` following the glass-morphism pattern of existing grids (BookGrid.vue is a good template) + +9. **Detail component**: Create `packages/app/src/components/content/{Type}Detail.vue` following the pattern of BookDetail.vue + +10. **Wire into ContentPanel.vue**: Add import, grid render block, detail render block, and panel state refs + +11. **Wire into ContentGridView.vue**: Add import, props, and grid render block + +12. **Wire into ChatPage.vue**: Pass the new panel data as props to ContentGridView + +13. **Wire into useContentPanel.ts**: Add panel ref, selected ref, open/close functions, extraction call in `updatePanelFromText()` + +14. **System prompt**: Add tag format instructions to the `SYSTEM_PROMPT` in `useAI.ts` + +15. **Tab label**: Add to `TAB_LABELS` in `ContentPanel.vue` + +16. **Verify**: Run `pnpm typecheck` and fix any errors + +Report what was created and the tag format to use. diff --git a/aiui/.claude/skills/add-tool/SKILL.md b/aiui/.claude/skills/add-tool/SKILL.md new file mode 100644 index 00000000..7e97db99 --- /dev/null +++ b/aiui/.claude/skills/add-tool/SKILL.md @@ -0,0 +1,32 @@ +--- +name: add-tool +description: Add a new AI tool (function call) to the Claude proxy for the AI to use +allowed-tools: Bash(*), Read, Edit, Write, Glob, Grep +--- + +Add a new tool that the AI model can call via Claude's tool_use API. The user will describe what the tool should do (e.g., "search local files", "get app status", "browse media"). + +## Steps + +1. **Read the proxy**: Read `packages/app/server/claude-proxy.ts` to understand the existing tool_use loop and `SEARCH_WEB_TOOL` definition. + +2. **Define the tool**: Add a new tool definition following the Claude tool_use format: + ```ts + const NEW_TOOL = { + name: 'tool_name', + description: 'What this tool does...', + input_schema: { + type: 'object', + properties: { ... }, + required: [...] + } + } + ``` + +3. **Add handler**: In the tool_use loop (where `search_web` calls are handled), add a handler for the new tool name. + +4. **Implement backend**: If the tool needs a new API endpoint (e.g., `/api/media/scan`), create a Vite plugin or add a route to the proxy. + +5. **Update system prompt**: Add instructions in `useAI.ts` SYSTEM_PROMPT telling the AI when and how to use the new tool. + +6. **Verify**: Run `pnpm typecheck` and test the proxy starts without errors. diff --git a/aiui/.claude/skills/audit-prompts/SKILL.md b/aiui/.claude/skills/audit-prompts/SKILL.md new file mode 100644 index 00000000..d33b93b8 --- /dev/null +++ b/aiui/.claude/skills/audit-prompts/SKILL.md @@ -0,0 +1,37 @@ +--- +name: audit-prompts +description: Deep audit of AI system prompts — find gaps, test extraction coverage, verify tag formats +allowed-tools: Bash(*), Read, Glob, Grep, Agent +--- + +Perform a comprehensive audit of AIUI's AI prompt system. Do NOT make changes — report findings only. + +## Steps + +1. **Read the full system prompt**: Read `packages/app/src/composables/useAI.ts` and reconstruct the complete system prompt including all dynamic sections (persona, Wavlake, memory, web search, Archy context, code context). + +2. **Catalog all tag formats**: List every `[[type:...]]` and `[[type_ext:...]]` format defined in the prompt. Cross-reference with regexes in `contentExtraction.ts`. + +3. **Check for gaps**: For each content type in `ContentTab` (contentFiltering.ts), verify: + - Is there a tag format in the system prompt? + - Is there a matching extraction regex? + - Is there a query classifier? + - Is there a grid + detail component? + - Is the tab wired in ContentPanel.vue and ContentGridView.vue? + +4. **Test extraction coverage**: Read the seed prompts in `src/__tests__/fixtures/seedPrompts.ts`. For each seed, verify: + - Does the extraction function find the expected number of items? + - Are there edge cases that would break extraction? + +5. **Analyze prompt quality**: Check for: + - Conflicting instructions + - Missing edge case handling (e.g., "what if the AI can't find a match?") + - Overly vague instructions + - Missing content types that should have tag formats + +6. **Check proxy tools**: Read `server/claude-proxy.ts` and verify tool definitions match what the prompt claims. + +7. **Report**: Create a structured summary with: + - Content type coverage matrix (tag/extraction/grid/detail/prompt) + - Identified gaps and inconsistencies + - Priority recommendations diff --git a/aiui/.claude/skills/check/SKILL.md b/aiui/.claude/skills/check/SKILL.md new file mode 100644 index 00000000..2df6a024 --- /dev/null +++ b/aiui/.claude/skills/check/SKILL.md @@ -0,0 +1,17 @@ +--- +name: check +description: Run all quality checks (typecheck, lint, test) and auto-fix errors +allowed-tools: Bash(*), Read, Edit, Glob, Grep, Agent +--- + +Run all quality checks for the AIUI project and fix any issues found. Execute in order: + +1. **TypeScript**: Run `pnpm typecheck`. If errors found, read the failing files and fix the type errors. +2. **Lint**: Run `pnpm lint`. If fixable errors, run `pnpm lint --fix` first, then fix remaining manually. +3. **Tests**: Run `pnpm --filter @aiui/app test -- --run`. For each failure: + - Read the test file and the source file it tests + - Determine if the test is wrong (outdated assertion) or the source has a bug + - Fix whichever is incorrect +4. Report a summary: pass/fail counts, what was fixed. + +Important: Do NOT change test expectations just to make them pass — understand WHY they fail first. diff --git a/aiui/.claude/skills/deploy/SKILL.md b/aiui/.claude/skills/deploy/SKILL.md new file mode 100644 index 00000000..39e61cb7 --- /dev/null +++ b/aiui/.claude/skills/deploy/SKILL.md @@ -0,0 +1,32 @@ +--- +name: deploy +description: Build and prepare AIUI for deployment to Archy node +allowed-tools: Bash(*), Read, Edit, Glob, Grep +--- + +Build AIUI for production deployment. Steps: + +1. **Pre-flight checks**: + - `pnpm typecheck` — must pass + - `pnpm lint` — must pass + - `pnpm --filter @aiui/app test -- --run` — report failures but continue + +2. **Build**: + - `pnpm build` + - Verify `packages/app/dist/` exists and contains `index.html` + +3. **Bundle analysis**: + - Report total dist size and gzip estimate + - List the 5 largest chunks + - Check against 250KB gzipped budget (warn if over) + +4. **Verify nginx config**: + - Read `packages/app/server/nginx-archy.conf` + - Verify SPA routing (`try_files $uri $uri/ /aiui/index.html`) + - Verify proxy paths for Claude API + +5. **Container build** (if Dockerfile exists): + - `podman build -t aiui:latest packages/app/` + - Report image size + +6. **Report**: Build status, bundle size, any warnings. diff --git a/aiui/.claude/skills/fix-tab/SKILL.md b/aiui/.claude/skills/fix-tab/SKILL.md new file mode 100644 index 00000000..c1e4e5cd --- /dev/null +++ b/aiui/.claude/skills/fix-tab/SKILL.md @@ -0,0 +1,33 @@ +--- +name: fix-tab +description: Diagnose and fix a broken content panel tab (extraction, routing, rendering) +allowed-tools: Bash(*), Read, Edit, Glob, Grep, Agent +--- + +Diagnose and fix a broken content panel tab. The user will specify which tab (e.g., "app", "code", "image", "nostr"). + +## Diagnostic pipeline — check each layer: + +1. **System prompt**: Does `useAI.ts` SYSTEM_PROMPT tell the AI to use the tag format for this content type? If not, add instructions. + +2. **Tag regex**: Does `contentExtraction.ts` have a regex for this content type's tags? If not, add one. + +3. **Extraction function**: Does `extractAll{Type}s()` or `extract{Type}s()` exist and work correctly? Test with sample input. + +4. **Query classifier**: Do `is{Type}Query()` and/or `is{Type}LikeResponse()` exist in `contentFiltering.ts`? + +5. **Tab filtering**: Is the tab included in `filterTabsByContext()` and `preferredFirstTab()`? Check the boolean flag is wired. + +6. **useContentPanel.ts**: Is the extraction called in `updatePanelFromText()`? Is the panel ref populated? Is the tab added to `availableTabs`? + +7. **ContentPanel.vue**: Is there a grid render block for `activeTab === '{tab}'`? Are imports present? Is the tab in TAB_LABELS? + +8. **ContentGridView.vue**: Same checks for the wide desktop view. + +9. **ChatPage.vue**: Are the panel data props passed to ContentGridView? + +10. **Grid component**: Does the grid component exist and render correctly? + +11. **Detail component**: Does the detail component exist? + +Fix each broken layer. Run `pnpm typecheck` after all fixes. diff --git a/aiui/.claude/skills/mock-archy/SKILL.md b/aiui/.claude/skills/mock-archy/SKILL.md new file mode 100644 index 00000000..878ac1ad --- /dev/null +++ b/aiui/.claude/skills/mock-archy/SKILL.md @@ -0,0 +1,32 @@ +--- +name: mock-archy +description: Enable/configure mock Archy data for standalone dev testing +allowed-tools: Bash(*), Read, Edit, Glob, Grep +--- + +Set up or modify mock Archy data for testing the Archipelago integration without a real Archy host. + +## How it works + +Mock data is in `packages/app/src/mocks/archy.ts`. When enabled, `useArchy.ts` loads this data instead of waiting for the Archy bridge. + +## Enable mock mode + +Two ways: +1. Add `VITE_MOCK_ARCHY=true` to `.env.local` +2. Add `?mockArchy` to the URL: `http://localhost:5173/?mockArchy` + +## Customization + +The user may ask to: +- Add/remove mock apps from the installed list +- Change wallet balance or channel count +- Add/modify files in the mock file list +- Change system info or network status +- Test specific scenarios (e.g., "node is syncing", "wallet offline", "no files") + +Edit `packages/app/src/mocks/archy.ts` accordingly. + +## Verify + +After changes, check that `buildArchyContext()` in `useArchy.ts` produces the expected system prompt section by reading the function and tracing the mock data through it. diff --git a/aiui/.claude/skills/new-detail/SKILL.md b/aiui/.claude/skills/new-detail/SKILL.md new file mode 100644 index 00000000..9209e265 --- /dev/null +++ b/aiui/.claude/skills/new-detail/SKILL.md @@ -0,0 +1,27 @@ +--- +name: new-detail +description: Generate a detail view component following AIUI glass-morphism patterns +allowed-tools: Read, Write, Edit, Glob, Grep +--- + +Create a new detail view component at `packages/app/src/components/content/{Name}Detail.vue`. + +## Requirements + +1. **Read a reference**: Read `BookDetail.vue` or `PlaceDetail.vue` as a template. + +2. **Follow conventions**: + - `<script setup lang="ts">` with single item prop + - Back button at top (emits 'back' event) + - Hero image/banner area with gradient overlay and fallback + - Title, subtitle, and metadata section + - Description/long text body with proper typography + - Action buttons (external links, share, etc.) with glass-button styling + - Dark/light mode via `useTheme()` + - Smooth scroll, overflow-y-auto + +3. **Props**: Accept single item of the content type +4. **Emits**: `back` event for navigation +5. **Responsive**: Full height, works in sidebar and mobile overlay + +The user will specify the content type and which fields to display. diff --git a/aiui/.claude/skills/new-grid/SKILL.md b/aiui/.claude/skills/new-grid/SKILL.md new file mode 100644 index 00000000..117dae8c --- /dev/null +++ b/aiui/.claude/skills/new-grid/SKILL.md @@ -0,0 +1,27 @@ +--- +name: new-grid +description: Generate a content grid component following AIUI glass-morphism patterns +allowed-tools: Read, Write, Edit, Glob, Grep +--- + +Create a new content grid component at `packages/app/src/components/content/{Name}Grid.vue`. + +## Requirements + +1. **Read a reference**: Read `BookGrid.vue` or `PlaceGrid.vue` as a template — they show the standard pattern. + +2. **Follow conventions**: + - `<script setup lang="ts">` with props and emits + - Glass morphism styling (bg-white/5, rounded-xl, hover:bg-white/10) + - Dark/light mode support via `useTheme()` + - Search input at top (if the content type has enough items) + - Grid of cards with image fallback, title, subtitle, metadata + - Touch targets min 44x44px + - Empty state message when no items match + - Custom scrollbar class + +3. **Props**: Accept array of items + title string +4. **Emits**: `select-{type}` event when a card is clicked +5. **Responsive**: Works on mobile (full width) and desktop (sidebar width) + +The user will specify the content type and its fields. diff --git a/aiui/.claude/skills/overnight/SKILL.md b/aiui/.claude/skills/overnight/SKILL.md new file mode 100644 index 00000000..128e9384 --- /dev/null +++ b/aiui/.claude/skills/overnight/SKILL.md @@ -0,0 +1,19 @@ +--- +name: overnight +description: Commit, branch, and start the overnight automation loop +disable-model-invocation: true +allowed-tools: Bash(*), Read, Write, Edit, Glob, Grep +--- + +Prepare and launch the overnight automation loop. Do ALL steps in order, stopping on any failure: + +1. Stage and commit all uncommitted changes: `git add -A && git commit -m "chore: pre-overnight snapshot"` (skip if working tree is clean) +2. Push current branch to origin +3. Get today's date as YYYY-MM-DD. Check if `overnight/$DATE` branch exists: + - If yes: `git checkout overnight/$DATE` + - If no: run `./loop/prepare.sh` +4. Verify `loop/plan.md` has unchecked tasks (`grep -c '^\- \[ \]' loop/plan.md`) +5. Commit plan files if modified: `git add loop/plan.md loop/prompt.md && git commit -m "chore: overnight plan $DATE"` (skip if clean) +6. Push: `git push -u origin overnight/$DATE` +7. Start the loop: run `caffeinate -i ./loop/loop.sh` with `run_in_background: true` +8. Report: branch name, number of tasks, and confirm the loop is running in background diff --git a/aiui/.claude/skills/pwa-icon-cache-fix/SKILL.md b/aiui/.claude/skills/pwa-icon-cache-fix/SKILL.md new file mode 100644 index 00000000..dbc65f81 --- /dev/null +++ b/aiui/.claude/skills/pwa-icon-cache-fix/SKILL.md @@ -0,0 +1,102 @@ +--- +name: pwa-icon-cache-fix +description: Use when the user reports a PWA icon not updating, stale PWA icon, wrong icon after install, or any PWA caching issue. Also applies when changing PWA icons in a Vite + vite-plugin-pwa project. +version: 2.0.0 +--- + +# PWA Icon Cache Fix + +## Problem + +PWA icons are cached at FOUR independent layers: +1. **Service worker cache** (Workbox precache) +2. **Browser HTTP cache** +3. **Browser manifest resources** (Chromium stores resized icons in its profile data, keyed by a permanent extension ID tied to the origin — NEVER re-fetched even after uninstall/reinstall) +4. **macOS .app bundle** (`.icns` file baked into the `.app` in `~/Applications/`) + +Query string cache busting (`?v=2`) and uninstall/reinstall do NOT fix this. Chromium reuses the same extension ID for the same origin, so it keeps the old cached icons. + +## Fix Steps + +### 1. Verify icon files on disk and server are correct + +```bash +# Visual check +Read packages/app/public/pwa-192x192.png +Read packages/app/public/pwa-512x512.png + +# Hash match check +curl -s http://localhost:5173/pwa-192x192.png | md5 +md5 -q packages/app/public/pwa-192x192.png +``` + +### 2. Find the PWA's Chromium extension ID + +Read the installed `.app` bundle's `Info.plist` to get the `CrAppModeShortcutID`: + +```bash +plutil -p "~/Applications/Brave Browser Apps.localized/AIUI.app/Contents/Info.plist" | grep CrAppModeShortcutID +``` + +This returns an ID like `idemibpphagihbobmgmaojhjfidlfpdl`. + +### 3. Overwrite the cached icons in browser profile + +Chromium stores resized icons at: +`~/Library/Application Support/BraveSoftware/Brave-Browser/Default/Web Applications/Manifest Resources/{ID}/Icons/` + +Overwrite every size using `sips`: + +```bash +ICON_DIR="~/Library/Application Support/BraveSoftware/Brave-Browser/Default/Web Applications/Manifest Resources/{ID}/Icons" +SRC="packages/app/public/pwa-512x512.png" +for size in 32 48 64 96 128 192 256 512; do + sips -z $size $size "$SRC" --out "${ICON_DIR}/${size}.png" +done +``` + +### 4. Rebuild the macOS .icns in the .app bundle + +```bash +ICONSET="/tmp/aiui.iconset" +mkdir -p "$ICONSET" +SRC="packages/app/public/pwa-512x512.png" +sips -z 16 16 "$SRC" --out "$ICONSET/icon_16x16.png" +sips -z 32 32 "$SRC" --out "$ICONSET/icon_16x16@2x.png" +sips -z 32 32 "$SRC" --out "$ICONSET/icon_32x32.png" +sips -z 64 64 "$SRC" --out "$ICONSET/icon_32x32@2x.png" +sips -z 128 128 "$SRC" --out "$ICONSET/icon_128x128.png" +sips -z 256 256 "$SRC" --out "$ICONSET/icon_128x128@2x.png" +sips -z 256 256 "$SRC" --out "$ICONSET/icon_256x256.png" +sips -z 512 512 "$SRC" --out "$ICONSET/icon_256x256@2x.png" +sips -z 512 512 "$SRC" --out "$ICONSET/icon_512x512.png" +cp "$SRC" "$ICONSET/icon_512x512@2x.png" +iconutil -c icns "$ICONSET" -o "~/Applications/Brave Browser Apps.localized/AIUI.app/Contents/Resources/app.icns" +``` + +### 5. Flush macOS icon cache + +```bash +touch "~/Applications/Brave Browser Apps.localized/AIUI.app" +killall Finder +killall Dock +``` + +### 6. Bump PWA_CACHE_VERSION in main.ts + +Increment the `PWA_CACHE_VERSION` constant — this nukes all SW caches on next page load for web-layer caching. + +### 7. Delete stale build artifacts + +Remove old `dist/` and `dev-dist/` SW/manifest files. + +## Browser-Specific Paths + +- **Brave**: `~/Library/Application Support/BraveSoftware/Brave-Browser/Default/Web Applications/` +- **Chrome**: `~/Library/Application Support/Google/Chrome/Default/Web Applications/` +- **PWA apps (Brave)**: `~/Applications/Brave Browser Apps.localized/` +- **PWA apps (Chrome)**: `~/Applications/Chrome Apps.localized/` + +## Key Insight + +Chromium assigns a permanent extension ID per origin (e.g., `localhost:5173`). This ID persists across uninstall/reinstall. The icon cache in `Manifest Resources/{ID}/Icons/` is populated ONCE and never refreshed from the manifest. The only fix is to overwrite the files directly on disk. diff --git a/aiui/.claude/skills/test-prompts/SKILL.md b/aiui/.claude/skills/test-prompts/SKILL.md new file mode 100644 index 00000000..ecaf8eb9 --- /dev/null +++ b/aiui/.claude/skills/test-prompts/SKILL.md @@ -0,0 +1,26 @@ +--- +name: test-prompts +description: Test AI prompt quality by simulating queries and checking extraction results +allowed-tools: Bash(*), Read, Edit, Glob, Grep, Agent +--- + +Test the AIUI AI prompt and content extraction pipeline end-to-end. This skill does NOT call the actual AI — it uses the seed prompts and extraction functions directly. + +## Steps + +1. **Read seed prompts**: Read `packages/app/src/__tests__/fixtures/seedPrompts.ts` to get all test cases. + +2. **Run extraction tests**: For each seed prompt, run the test via `pnpm --filter @aiui/app test -- --run -t "seed"` and report results. + +3. **Test edge cases**: Create and test these additional scenarios by calling extraction functions in a test: + - Mixed content response (films + songs + books in one response) + - App recommendation response (should trigger app tab) + - News query with web search results + - Place/restaurant recommendations + - Code response with 3+ code blocks + - Nostr-related query + - Empty/minimal response + +4. **Verify tab routing**: For each scenario, check that `filterTabsByContext()` returns the expected tabs in the expected order. + +5. **Report**: Summary of what works, what's broken, and what's missing. Include specific test cases that fail. diff --git a/aiui/.claude/skills/trace/SKILL.md b/aiui/.claude/skills/trace/SKILL.md new file mode 100644 index 00000000..15f42e92 --- /dev/null +++ b/aiui/.claude/skills/trace/SKILL.md @@ -0,0 +1,28 @@ +--- +name: trace +description: End-to-end trace of a query through prompt, extraction, tabs, and rendering +allowed-tools: Bash(*), Read, Glob, Grep, Agent +--- + +Trace how a specific user query flows through the entire AIUI pipeline. The user will provide a sample query (e.g., "best nostr apps", "recommend some films", "bitcoin news"). + +## Trace each stage: + +1. **Query classifiers**: Run the query through each classifier in `contentFiltering.ts`: + - `isNewsQuery()`, `isMusicQuery()`, `isBookQuery()`, `isTVQuery()`, `isImageQuery()`, `isPlaceQuery()`, `isRecipeQuery()`, `isCodeQuery()`, `isNostrQuery()`, `isAppQuery()`, `isWebsitesQuery()` + - Report which ones return true + +2. **Preferred tab**: What does `preferredFirstTab()` return for this query? + +3. **System prompt**: What would `buildSystemPrompt()` include? Read `useAI.ts` and trace all dynamic sections. + +4. **Expected AI response**: Based on the system prompt instructions, what tags would the AI likely use? Construct a realistic sample response. + +5. **Extraction**: Run the sample response through each extraction function and report what gets found: + - `extractAllFilms()`, `extractAllSongs()`, `extractAllPodcasts()`, `extractAllBooks()`, `extractAllTVSeries()`, `extractAllImages()`, `extractAllPlaces()`, `extractApps()`, `extractCodeBlocks()`, `extractRecipes()` + +6. **Tab filtering**: What tabs would `filterTabsByContext()` return? In what order? + +7. **Rendering**: Which grid component would render? Trace through ContentPanel.vue and/or ContentGridView.vue. + +8. **Report**: Complete flow diagram showing: Query -> Classifiers -> Prompt -> Expected Response -> Extraction -> Tabs -> Grid diff --git a/aiui/.claude/worktrees/agitated-hofstadter b/aiui/.claude/worktrees/agitated-hofstadter new file mode 160000 index 00000000..10e12a32 --- /dev/null +++ b/aiui/.claude/worktrees/agitated-hofstadter @@ -0,0 +1 @@ +Subproject commit 10e12a329f0652a2270d2b988eae42567b792cef diff --git a/aiui/.claude/worktrees/funny-hofstadter b/aiui/.claude/worktrees/funny-hofstadter new file mode 160000 index 00000000..1c5185a1 --- /dev/null +++ b/aiui/.claude/worktrees/funny-hofstadter @@ -0,0 +1 @@ +Subproject commit 1c5185a15c7c79a61e967107442e31b4ee8561f4 diff --git a/aiui/.claude/worktrees/happy-colden b/aiui/.claude/worktrees/happy-colden new file mode 160000 index 00000000..666e1232 --- /dev/null +++ b/aiui/.claude/worktrees/happy-colden @@ -0,0 +1 @@ +Subproject commit 666e1232f4d4af887ef9405455b682670e52afc1 diff --git a/aiui/.claude/worktrees/hardcore-beaver b/aiui/.claude/worktrees/hardcore-beaver new file mode 160000 index 00000000..a817fa19 --- /dev/null +++ b/aiui/.claude/worktrees/hardcore-beaver @@ -0,0 +1 @@ +Subproject commit a817fa199fb57499cedbba153f286effce87c1e6 diff --git a/aiui/.claude/worktrees/heuristic-raman b/aiui/.claude/worktrees/heuristic-raman new file mode 160000 index 00000000..e8e002de --- /dev/null +++ b/aiui/.claude/worktrees/heuristic-raman @@ -0,0 +1 @@ +Subproject commit e8e002debc829052f139ede0824a3b8230210684 diff --git a/aiui/.claude/worktrees/priceless-colden b/aiui/.claude/worktrees/priceless-colden new file mode 160000 index 00000000..aaaef7d7 --- /dev/null +++ b/aiui/.claude/worktrees/priceless-colden @@ -0,0 +1 @@ +Subproject commit aaaef7d710e05ec7dd3f9909fd86de38d4dc592b diff --git a/aiui/.cursor/rules/00-master-philosophy.mdc b/aiui/.cursor/rules/00-master-philosophy.mdc new file mode 100644 index 00000000..d28a46c3 --- /dev/null +++ b/aiui/.cursor/rules/00-master-philosophy.mdc @@ -0,0 +1,65 @@ +--- +description: Core development philosophy for AIUI - the foundational rules that govern all code and design decisions +globs: "**/*" +alwaysApply: true +--- + +# Master Philosophy + +## Mission +Build the next-generation AI content surface UI — a paradigm where AI responses are rendered as rich, interactive content, not plain text. Delivered as a reusable component library (@aiui/core) and a reference application (AIUI App). + +## Philosophical Pillars + +### 1. Open Source Only +Every dependency must be OSS (MIT, Apache-2.0, GPL-compatible). No proprietary SDKs, no vendor-locked services. Before adding any dependency, verify its license. + +### 2. Decentralized-First +No hard dependency on any centralized service. AI backends, messaging protocols, storage, search — all connect through pluggable adapter interfaces. Users choose their own providers. + +### 3. Bitcoin Only +Bitcoin is the only monetary unit. On-chain, Lightning, ecash (Cashu, Fedimint/Fedi). No fiat payment rails, no altcoins, no stablecoins — anywhere in the UI or codebase. AIUI is never a wallet and never handles funds directly. See `10-bitcoin-only.mdc` for full rules. + +### 4. Cryptography for Everything Sensitive +E2E encryption for messages, encrypted local storage, proper key management. Privacy is not a feature — it is a requirement. + +### 5. Mobile-First, Everywhere-Perfect +Every component works flawlessly on mobile, tablet, and desktop. Mobile is the foundation, not an afterthought. Touch targets, viewport management, and safe areas are first-class citizens. + +### 6. Consistency is Sacred +Mobile and desktop versions show identical content and functionality unless explicitly designed otherwise. Design tokens ensure visual consistency across all breakpoints. + +### 7. Theme-First Architecture +Theming is a core architectural decision from day one. Themes are CSS-based with reactive state management. Dark mode and light mode are equals. + +### 8. Utility-First, Component-Second +Tailwind CSS utilities in templates for maximum flexibility. Component classes only for truly reusable patterns. Extract components when you repeat, not before. + +### 9. Performance as a Feature +Initial load < 250KB gzipped. Lazy load everything that isn't immediately visible. CSS transforms for GPU acceleration. SVG over raster images. Code splitting by default. + +### 10. Plugin-Everything +Every external integration connects through a typed plugin interface. AI providers, media sources, messaging protocols, wallets, social embeds — all pluggable. + +### 11. Accessibility is Not Optional +WCAG AA compliance minimum. Keyboard navigation everywhere. Screen reader friendly. Color contrast tested and validated. + +### 12. MCP-Native +First-class Model Context Protocol support for AI tool interoperability. + +## Anti-Patterns to Avoid + +- Desktop-first thinking +- Hardcoded values (use design tokens) +- Premature abstraction (build three times before abstracting) +- Magic numbers without comments +- Invisible state (user should always know what's happening) +- Handling funds or private keys +- Loading third-party tracking scripts +- Proprietary dependencies + +## The Ultimate Goal + +When someone uses AIUI, they should think: "This feels incredibly polished", "Everything just works", "My data is safe", "I control my own setup." + +When a developer reads the code: "This is well organized", "I understand exactly what's happening", "Adding a new renderer is straightforward." diff --git a/aiui/.cursor/rules/01-vue-conventions.mdc b/aiui/.cursor/rules/01-vue-conventions.mdc new file mode 100644 index 00000000..7806b7da --- /dev/null +++ b/aiui/.cursor/rules/01-vue-conventions.mdc @@ -0,0 +1,84 @@ +--- +description: Vue 3 Composition API conventions and best practices for AIUI +globs: "**/*.vue,**/*.ts" +alwaysApply: false +--- + +# Vue 3 Conventions + +## Composition API with `<script setup>` +Always use `<script setup lang="ts">`. Never use Options API. + +## Component Organization Order +1. Imports — external, then internal +2. Props — with TypeScript-style validation +3. Emits — explicitly defined +4. State (refs and reactive) +5. Computed — derived values, always pure +6. Watchers — side effects only +7. Methods — business logic +8. Lifecycle hooks — ordered by execution +9. Expose — public API (if needed) + +## File Organization +``` +src/ + components/ + ui/ # Primitives (Button, Card, Badge, Input) + chat/ # Chat window, message list, input + content-panel/ # Side panel for surfaced content + renderers/ # Content type renderers + layout/ # Shell, split-pane, responsive containers + composables/ # Shared composition functions (useTheme, useMedia, useCrypto) + stores/ # Pinia stores + plugins/ # Plugin system + types/ # Shared TypeScript types + styles/ # Global CSS, themes, design tokens + utils/ # Pure utility functions +``` + +## Naming Conventions +- Components: PascalCase (`ProjectCard.vue`) +- Composables: camelCase, prefixed with "use" (`useTheme.ts`) +- Props: camelCase in JS, kebab-case in templates +- Boolean props: prefix with `is`, `has`, `can`, `should` +- Handler props: prefix with `on` (`onClick`, `onClose`) +- Emits: explicit, kebab-case in templates (`project:updated`) + +## Props — Always Validate +```typescript +defineProps({ + title: { type: String, required: true }, + count: { type: Number, default: 0 }, + status: { + type: String as PropType<'pending' | 'active' | 'complete'>, + default: 'pending' + } +}) +``` + +Never use array-style props: `defineProps(['title', 'count'])` + +## Reactive State +- `ref` for primitives and single values +- `reactive` for objects with multiple properties +- `computed` for derived state (never side effects in computed) +- `shallowRef` for large objects that change at top level only + +## Templates — Keep Clean +Move complex logic to computed properties or methods. No inline logic in templates. Use `v-if` for infrequent toggles, `v-show` for frequent ones. + +## Composables +- One responsibility per composable +- Return only what's needed +- Handle cleanup in `onUnmounted` +- Make composables testable + +## Performance +- Lazy load heavy components: `defineAsyncComponent(() => import(...))` +- Use `shallowRef` for large lists +- Use `:key` with unique identifiers, never index +- Avoid reactive objects in templates (create in script) + +## Error Handling +Use `onErrorCaptured` for component-level error boundaries. Always handle async errors with try/catch/finally pattern (loading, error, data states). diff --git a/aiui/.cursor/rules/02-tailwind-styling.mdc b/aiui/.cursor/rules/02-tailwind-styling.mdc new file mode 100644 index 00000000..49f0c285 --- /dev/null +++ b/aiui/.cursor/rules/02-tailwind-styling.mdc @@ -0,0 +1,111 @@ +--- +description: Tailwind CSS utility-first styling conventions for AIUI, ported from Archy +globs: "**/*.vue,**/*.css,**/*.ts" +alwaysApply: false +--- + +# Tailwind CSS Styling + +## Source of Truth +All glass morphism, container, and button patterns originate from the Archy project (`/Projects/Archy/neode-ui/src/style.css`). When in doubt, match Archy exactly. + +## Utility-First +Use Tailwind utilities directly in templates. Extract to component classes only when a pattern repeats 3+ times. + +## 4px Spacing Grid +``` +1 = 4px, 2 = 8px, 3 = 12px, 4 = 16px, 5 = 20px, 6 = 24px, 7 = 28px, 8 = 32px +``` + +## Typography Scale +``` +text-xs = 12px (metadata, timestamps) +text-sm = 14px (body text, buttons) +text-base = 16px (default body, inputs) +text-lg = 18px (subtitles) +text-xl = 20px (card titles) +text-2xl = 24px (section headings) +text-3xl = 30px (page headings) +text-4xl = 36px (hero headings) +``` + +Font weights: `font-normal` (body), `font-medium` (emphasis), `font-semibold` (headings/buttons), `font-bold` (strong emphasis). + +## Glass Morphism (from Archy) + +### Containers (exact Archy values) +- `.glass` — base: `bg: rgba(0,0,0,0.35)`, `blur(18px)`, `border: 1px solid rgba(255,255,255,0.18)`, `shadow: 0 8px 24px rgba(0,0,0,0.45)` +- `.glass-strong` — stronger blur: same bg but `blur(24px)` +- `.glass-card` — primary card: `bg: rgba(0,0,0,0.65)`, `blur(18px)`, `border-radius: 1rem`, same border/shadow +- `.gradient-card` — gradient: `linear-gradient(135deg, rgba(255,255,255,0.1) 0%, rgba(0,0,0,0.8) 100%)` +- `.gradient-card-dark` — dark gradient: `linear-gradient(180deg, rgba(0,0,0,0.4) 0%, rgba(0,0,0,0.9) 100%)` +- `.gradient-border-container` — gradient border with inner glass, `border-radius: 1.5rem` +- `.toast-glass` — `border-radius: 0.75rem`, same glass as `.glass-card` + +### Buttons (exact Archy values) +- `.glass-button` — 48px height, `bg: rgba(0,0,0,0.6)`, `blur(18px)`, border `rgba(255,255,255,0.18)`, `color: rgba(255,255,255,0.9)` +- `.glass-button-sm` — compact variant (auto height, `py-1.5 px-3`) + +### Icon / Ghost buttons (Archy pattern) +```html +<button class="p-2 rounded-lg text-white/70 hover:text-white hover:bg-white/10 transition-colors"> +``` +Touch target: minimum 44x44px via padding. + +### Active Navigation +`.nav-tab-active` — `bg: rgba(0,0,0,0.35)`, inset highlight, gradient border via CSS mask `::before` + +### Usage Rules +- ✅ Cards, panels, modals, sidebars +- ✅ Navigation bars, headers (fixed positioning) +- ✅ Hover states, buttons +- ❌ Body text containers (readability) +- ❌ Form input fields (confusing UX) + +## Inset Highlight +The signature Archy inset glow: +```css +box-shadow: inset 0 1px 0 rgba(255, 255, 255, 0.22); +``` +Apply to headers, selected cards, active nav items. + +## Border — No Separators Between Sections +Per Archy theme rules: no borders between sidebar and content, or header and content. Only subtle `rgba(255,255,255,0.06-0.08)` borders for internal dividers. + +## Gradient Text +```html +<h1 class="gradient-text">Title</h1> +``` +`linear-gradient(to right, #ffffff, #9ca3af)` with `background-clip: text`. + +## Focus States — Gamepad/Keyboard Glow +All focusable elements get a blue glow (no outline): +```css +*:focus-visible { + outline: none; + box-shadow: 0 0 16px rgba(120, 180, 255, 0.2), 0 0 32px rgba(100, 160, 255, 0.1); +} +``` + +## Scrollbar +- `.custom-scrollbar` — gradient thumb (`rgba(255,255,255,0.3)` to `0.1`), dark track +- `.scrollbar-hide` — hidden scrollbar, keeps scroll functionality + +## Responsive — Mobile First +Base styles for mobile, enhance with breakpoints: +```html +<div class="text-base md:text-lg lg:text-xl p-4 md:p-6 lg:p-8"> +``` +Breakpoints: `sm` (640px), `md` (768px), `lg` (1024px), `xl` (1280px), `2xl` (1536px). + +## Hover States (from Archy) +```html +<div class="transition-all duration-300 hover:bg-white/10 hover:text-white"> +``` +Interactive card lift: `hover:translateY(-2px)` with intensified shadow. + +## Animations (Archy timings) +- `animate-fade-up` — 900ms `cubic-bezier(0.22, 1, 0.36, 1)` with 120ms delay +- `animate-fade-up-fast` — 400ms, no delay (for chat messages) +- `animate-fade-in` — 500ms ease +- `animate-scale-in` — 250ms for modals/popups diff --git a/aiui/.cursor/rules/03-design-system.mdc b/aiui/.cursor/rules/03-design-system.mdc new file mode 100644 index 00000000..3a760b66 --- /dev/null +++ b/aiui/.cursor/rules/03-design-system.mdc @@ -0,0 +1,118 @@ +--- +description: Design system foundations - glassmorphism from Archy, colors, typography, spacing +globs: "**/*.vue,**/*.css,**/*.ts" +alwaysApply: false +--- + +# Design System + +All glass morphism, container, and button patterns are ported from the Archy project and must match exactly. + +## Glass Morphism Hierarchy (from Archy) + +### Glass Intensity Levels +| Class | Background | Blur | Use Case | +|-------|-----------|------|----------| +| `.glass` | `rgba(0,0,0,0.35)` | 18px | Sidebar, panels, inputs | +| `.glass-strong` | `rgba(0,0,0,0.35)` | 24px | Headers, message bubbles (user) | +| `.glass-card` | `rgba(0,0,0,0.65)` | 18px | Primary cards, modals, main containers | +| `.gradient-card` | gradient white→black | 18px | Feature cards | +| `.gradient-card-dark` | gradient black→black | 18px | Dark feature cards | + +All share: `border: 1px solid rgba(255,255,255,0.18)`, `box-shadow: 0 8px 24px rgba(0,0,0,0.45)`. + +### Button Hierarchy (from Archy) +| Class | Purpose | Details | +|-------|---------|---------| +| `.glass-button` | Default | 48px height, `rgba(0,0,0,0.6)`, blur 18px | +| `.glass-button-sm` | Compact | Auto height, smaller padding | +| Ghost | Icon/text actions | `p-2 rounded-lg text-white/70 hover:text-white hover:bg-white/10` | + +### Inset Highlight +Signature Archy top-edge glow on focused/active elements: +```css +box-shadow: inset 0 1px 0 rgba(255, 255, 255, 0.22); +``` + +### Gradient Border (CSS mask technique) +For premium-feel borders on selected cards and active nav: +```css +::before { + content: ''; + position: absolute; + inset: 0; + border-radius: inherit; + padding: 2px; + background: linear-gradient(135deg, rgba(255, 255, 255, 0.3), transparent); + -webkit-mask: linear-gradient(#fff 0 0) content-box, linear-gradient(#fff 0 0); + -webkit-mask-composite: xor; + mask-composite: exclude; + pointer-events: none; +} +``` + +## Design Tokens + +### Color Palette +Semantic color tokens defined by purpose: +- `primary` — main brand actions (#606060) +- `accent` — highlight, Bitcoin orange (#F7931A) +- `success` — positive states (#10B981) +- `error` — negative states (#EF4444) +- `warning` — caution states (#F59E0B) +- `info` — informational (#3B82F6) + +### Glass Tokens (from Archy Tailwind config) +- `glass-dark`: `rgba(0, 0, 0, 0.35)` +- `glass-darker`: `rgba(0, 0, 0, 0.6)` +- `glass-border`: `rgba(255, 255, 255, 0.18)` +- `glass-highlight`: `rgba(255, 255, 255, 0.22)` + +### Shadows +- `shadow-glass`: `0 8px 24px rgba(0, 0, 0, 0.45)` +- `shadow-glass-sm`: `0 6px 18px rgba(0, 0, 0, 0.35)` +- `shadow-glass-inset`: `inset 0 1px 0 rgba(255, 255, 255, 0.22)` + +### Typography +- Body font: Inter, system-ui (AIUI default) +- Mono font: Menlo, Monaco, Courier New +- Text opacity scale: `text-white/25` (placeholders), `text-white/40` (muted), `text-white/60` (secondary), `text-white/70` (interactive default), `text-white/80` (body), `text-white/90` (emphasis), `text-white/96` (headings), `text-white` (active/selected) + +### Spacing +4px grid: `4, 8, 12, 16, 20, 24, 28, 32` px. + +### Border Radius +- `rounded-lg` (8px) — buttons, nav items, inputs +- `rounded-xl` (12px) — toasts, small cards +- `rounded-2xl` (16px) — main cards, modals +- `rounded-3xl` (24px) — bottom sheets +- `rounded-full` — pills, avatars, FABs +- `1rem` (16px) — `.glass-card` default + +## Component Patterns + +### Cards +Use `.glass-card` with additional padding: +```html +<div class="glass-card p-6">Content</div> +``` + +### Modals +```html +<div class="fixed inset-0 z-50 flex items-center justify-center p-4 bg-black/60 backdrop-blur-sm"> + <div class="glass-card p-6 max-w-md w-full">...</div> +</div> +``` + +### Icons +- SVG, using `currentColor` +- Sizes: 16px, 20px, 24px, 32px +- Icon-only buttons: `p-2 rounded-lg` (reaches 44px touch target with 24px icon) +- Must have `aria-label` + +## Theme Architecture +- Base background: `#0a0a0a` (near-black) +- No separator borders between sidebar/header/content +- Header, sidebar, root share same visual weight +- CSS-based themes with reactive Vue state +- `localStorage` persistence diff --git a/aiui/.cursor/rules/04-component-architecture.mdc b/aiui/.cursor/rules/04-component-architecture.mdc new file mode 100644 index 00000000..4e7019db --- /dev/null +++ b/aiui/.cursor/rules/04-component-architecture.mdc @@ -0,0 +1,91 @@ +--- +description: Component architecture principles - composition, patterns, and structure +globs: "**/*.vue,**/*.ts" +alwaysApply: false +--- + +# Component Architecture + +## Core Philosophy: Composition Over Configuration +Build complex UIs from simple, focused components that compose well together. + +- Single Responsibility: each component does one thing well +- Use slots instead of complex prop APIs +- Provide sensible defaults +- Clear TypeScript interfaces for props +- Keep component state local and minimal + +## Anti-Patterns +- God components that do everything +- Prop drilling through many layers (use provide/inject or Pinia) +- Hard-coded values instead of props +- Component logic mixed with layout +- Tight coupling between components + +## Compound Component Pattern +Components that work together as a cohesive unit: +```vue +<Card> + <Card.Header>Title</Card.Header> + <Card.Body>Content</Card.Body> + <Card.Footer>Actions</Card.Footer> +</Card> +``` + +## Container/Presenter Pattern +Separate logic from presentation: +- Container: handles data fetching, state, side effects +- Presenter: pure rendering, receives data via props, emits events + +## Slot Pattern (Vue) +Use named slots for flexible content injection: +```vue +<template> + <div class="section"> + <slot name="title" /> + <slot name="content" /> + <slot name="actions" /> + </div> +</template> +``` + +## Prop Interface Design +```typescript +interface BaseComponentProps { + class?: string + testId?: string +} + +interface ButtonProps extends BaseComponentProps { + variant?: 'primary' | 'secondary' | 'ghost' + size?: 'sm' | 'md' | 'lg' + disabled?: boolean + loading?: boolean +} +``` + +## Component File Template +``` +1. Imports (external, then internal) +2. Types/Interfaces +3. Constants +4. Main component (props, emits, state, computed, methods, lifecycle) +5. Sub-components (if any) +``` + +## Error Boundaries +Every major section should have error boundary handling via `onErrorCaptured`. Show fallback UI, never a blank screen. + +## Responsive Components +Use CSS-based responsive (`hidden md:block`) over JS-based (`useMediaQuery`) when possible. JS-based only when behavior changes (not just visibility). + +## Component Checklist +Before shipping any component: +- [ ] TypeScript interface defined +- [ ] Sensible default props +- [ ] Loading and error states handled +- [ ] ARIA attributes added +- [ ] Keyboard navigation works +- [ ] Responsive behavior tested +- [ ] Dark mode styling works +- [ ] Touch interactions verified on mobile diff --git a/aiui/.cursor/rules/05-content-surfaces.mdc b/aiui/.cursor/rules/05-content-surfaces.mdc new file mode 100644 index 00000000..55a51d1c --- /dev/null +++ b/aiui/.cursor/rules/05-content-surfaces.mdc @@ -0,0 +1,91 @@ +--- +description: The five content surfaces that define how content is rendered in AIUI +globs: "**/renderers/**,**/chat/**,**/content-panel/**" +alwaysApply: false +--- + +# Content Surfaces + +AIUI has five distinct surfaces where content can appear. Every renderer must define how it behaves in each applicable surface. + +## Surface 1: Chat Preview +- Location: inline in chat message bubble +- Max height: ~120px +- Purpose: identify content at a glance (thumbnail, title, brief metadata) +- Always tappable/clickable to expand to Panel Preview or Panel Play +- Lightweight rendering only — no heavy libraries loaded +- Examples: film poster thumbnail strip, file icon with name, code snippet (first 5 lines), image thumbnail + +## Surface 2: Chat Play +- Location: inline in chat message bubble +- Max height: ~200px +- Purpose: inline playback without leaving the chat +- Must not disrupt chat scrolling +- Has an "expand" button to open in Panel Play +- Examples: voice note waveform with play button, short video player, audio player, small interactive widget + +## Surface 3: Panel Preview +- Location: content panel (beside chat on desktop, overlay on mobile) +- No height limit (scrollable within panel) +- Purpose: full browsing/exploration experience +- Supports: filtering, sorting, searching, pagination +- Click items to go to Panel Play or Panel Edit +- Examples: film grid (tiled, filterable), image gallery, search results list, document preview, file tree + +## Surface 4: Panel Play +- Location: content panel +- Purpose: full immersive media playback +- Examples: full video player with controls, audio with spectrum visualization, slideshow, trailer playback + +## Surface 5: Panel Edit/Interactive +- Location: content panel +- Purpose: full interaction and editing +- Changes can be sent back to chat as new messages +- Examples: code editor (CodeMirror), form filling, approval workflow, spreadsheet editing, diagram creation + +## Surface Transitions +``` +Chat Preview --tap--> Panel Preview --tap item--> Panel Play + --tap item--> Panel Edit +Chat Play --expand--> Panel Play +Panel Edit --submit--> Chat (new message with result) +``` + +## Renderer Interface +Every renderer must export: +```typescript +interface RendererDefinition { + id: string + name: string + contentType: string // MIME-like type identifier + surfaces: SurfaceType[] // which surfaces this renderer supports + chatPreview?: Component // Surface 1 + chatPlay?: Component // Surface 2 + panelPreview?: Component // Surface 3 + panelPlay?: Component // Surface 4 + panelEdit?: Component // Surface 5 + lazyDependencies?: () => Promise<any> // heavy libs loaded on demand +} +``` + +## Mobile Behavior +- On mobile, there is no side-by-side layout +- Panel surfaces open as a full-screen overlay or bottom sheet +- Chat Preview and Chat Play remain inline +- Transition: tap Chat Preview → full-screen Panel Preview (slide up) +- Back gesture or button returns to chat + +## Performance Rules +- Chat Preview and Chat Play must render with zero lazy-loaded dependencies +- Panel surfaces may lazy-load heavy libraries (CodeMirror, pdf.js, etc.) +- Never block the chat scroll with renderer loading +- Use skeleton/placeholder while panel content loads + +## Content Type Expert Rules +For extraction, parsing, and surfacing logic, see: +- `20-content-films.mdc` — Films +- `21-content-songs.mdc` — Songs (includes looksLikeSong blocklist) +- `22-content-podcasts.mdc` — Podcasts (includes looksLikePodcast) +- `23-content-news.mdc` — News + RSS, ArticleDetail security +- `24-content-websites.mdc` — Websites vs News, overlay +- `25-content-magazine.mdc` — Magazine/Brief parsing, hero, meme diff --git a/aiui/.cursor/rules/06-plugin-system.mdc b/aiui/.cursor/rules/06-plugin-system.mdc new file mode 100644 index 00000000..16833ac0 --- /dev/null +++ b/aiui/.cursor/rules/06-plugin-system.mdc @@ -0,0 +1,98 @@ +--- +description: Plugin architecture rules - interfaces, registration, lifecycle, sandboxing +globs: "**/plugins/**,**/*.plugin.ts" +alwaysApply: false +--- + +# Plugin System + +## Philosophy +Every external integration connects through a typed plugin interface. No direct coupling to any service, provider, or protocol. + +## Plugin Types +```typescript +type PluginType = + | 'ai-provider' // LLM backends (OpenRouter, Ollama, Claude, etc.) + | 'media-source' // Content sources (Plex, YouTube, Nextcloud, Archive.org) + | 'messaging' // Chat protocols (Nostr, Matrix, local) + | 'storage' // File storage (local FS, IPFS, Nextcloud) + | 'renderer' // Custom content renderers + | 'file-handler' // File open/preview handlers + | 'crypto' // Encryption providers + | 'search' // Search backends (SearXNG, local) + | 'auth' // Authentication (Nostr keys, DID, passkeys) + | 'wallet' // Bitcoin wallet deep-linking (Phoenix, Zeus, Alby, etc.) + | 'social-embed' // Social post fetching (X, Nostr, Mastodon) + | 'mcp' // Model Context Protocol servers + | 'media' // Media processing (ffmpeg.wasm, whisper, TTS) +``` + +## Base Plugin Interface +```typescript +interface AIUIPlugin { + id: string + name: string + version: string + type: PluginType + description?: string + icon?: string + init(context: PluginContext): Promise<void> + destroy(): Promise<void> + isAvailable(): Promise<boolean> +} +``` + +## Plugin Context +Plugins receive a context object with access to: +- Settings store (read/write plugin-specific settings) +- Event bus (emit/listen for app events) +- Logger (structured logging) +- Crypto utilities (for encrypting plugin data at rest) + +Plugins do NOT receive: +- Direct DOM access (community plugins) +- File system access (without explicit capability grant) +- Network access to arbitrary hosts (without declaration) + +## Sandboxing Tiers + +### Tier 1: Trusted (built-in, official) +Run in main thread with full API access. AI adapters, core renderers, crypto providers. + +### Tier 2: Community +Run in sandboxed iframes with `postMessage` API. Custom renderers, themes, visual extensions. Cannot access host DOM, file system, or network directly. + +### Tier 3: External Processes +MCP servers, local AI runners. Run as separate processes (Tauri IPC) or connect via HTTP. Isolated by OS process boundary. + +## Plugin Lifecycle +1. `register()` — declare plugin to registry +2. `init()` — plugin sets up, connects to services +3. Active — plugin responds to requests +4. `destroy()` — cleanup on disable/uninstall + +## Registration +```typescript +import { registerPlugin } from '@aiui/core' + +registerPlugin({ + id: 'ai-openrouter', + name: 'OpenRouter', + type: 'ai-provider', + version: '1.0.0', + async init(ctx) { /* setup */ }, + async destroy() { /* cleanup */ }, + // ... adapter methods +}) +``` + +## Plugin Settings +Each plugin can declare settings schema. Settings are stored encrypted and exposed through a standard settings UI. + +## Rules +- Every plugin must declare its type +- Every plugin must implement `init()` and `destroy()` +- Every plugin must implement `isAvailable()` to report its status +- Plugins must handle errors gracefully — never crash the host +- Community plugins must not load external scripts +- All network requests must go through the plugin context (for privacy/proxy control) diff --git a/aiui/.cursor/rules/07-ai-integration.mdc b/aiui/.cursor/rules/07-ai-integration.mdc new file mode 100644 index 00000000..5e3ad3f0 --- /dev/null +++ b/aiui/.cursor/rules/07-ai-integration.mdc @@ -0,0 +1,83 @@ +--- +description: AI adapter patterns, streaming, tool calling, context injection +globs: "**/ai/**,**/plugins/ai-*/**" +alwaysApply: false +--- + +# AI Integration + +## Universal AI Adapter +All AI providers connect through the `AIProviderAdapter` interface: + +```typescript +interface AIProviderAdapter extends AIUIPlugin { + type: 'ai-provider' + chat(messages: Message[], options: ChatOptions): AsyncIterable<ChatChunk> + models(): Promise<Model[]> + supportsStreaming: boolean + supportsVision: boolean + supportsTools: boolean + supportsMultimodal: boolean +} +``` + +## Provider Hierarchy +1. **OpenAI-Compatible Adapter** — covers OpenRouter, Ollama, vLLM, llama.cpp, LocalAI, Mistral, DeepSeek, xAI, Qwen. Just change `baseURL` + API key. +2. **Anthropic Adapter** — Claude. Different tool_use format (content blocks vs tool_calls). +3. **Gemini Adapter** — Google. Different multimodal format. +4. **MCP Client** — connects to any MCP server for tools, resources, prompts. + +## Streaming +- All AI responses use Server-Sent Events (SSE) over HTTP +- Pattern: `data: {"token": "Hello"}\n\n` with `data: [DONE]\n\n` termination +- Client: parse SSE stream, feed tokens to `StreamingTextRenderer` +- Always show a typing indicator while waiting for first token +- Handle connection drops gracefully (show error, offer retry) + +## Tool Calling +AI can invoke tools. The adapter normalizes tool call formats: +```typescript +interface ToolCall { + id: string + name: string + arguments: Record<string, unknown> +} + +interface ToolResult { + toolCallId: string + content: string | StructuredContent + isError: boolean +} +``` + +Normalize across providers: +- OpenAI: `tool_calls` in assistant message → `role: "tool"` result +- Claude: `type: "tool_use"` content block → `tool_result` in user message +- Map both to AIUI's unified `ToolCall` / `ToolResult` types + +## Context Injection +The system prompt includes context about the user's environment: +- Connected media sources and their capabilities +- Available tools and plugins +- User preferences (language, theme, preferred wallet) +- In dev mode: mock data summaries + +Never include sensitive data (API keys, passwords) in system prompts. + +## Model Selection +Users can switch models within a conversation. The UI shows: +- Available models from all connected providers +- Model capabilities (vision, tools, streaming) +- Cost per token in sats (if applicable) + +## Dev Mode +- `VITE_OPENROUTER_API_KEY` in `.env.local` +- Free models available (Llama, Mistral via OpenRouter) +- Mock tool responses available via dev fixtures +- Debug panel shows: raw messages, token count, latency + +## Error Handling +- Rate limits: show user-friendly message, auto-retry with backoff +- Auth errors: prompt to check API key in settings +- Network errors: show offline indicator, queue message for retry +- Model errors: show error in chat, suggest alternative model diff --git a/aiui/.cursor/rules/08-renderer-development.mdc b/aiui/.cursor/rules/08-renderer-development.mdc new file mode 100644 index 00000000..724c16e1 --- /dev/null +++ b/aiui/.cursor/rules/08-renderer-development.mdc @@ -0,0 +1,80 @@ +--- +description: How to build content renderers - interfaces, lazy loading, accessibility +globs: "**/renderers/**" +alwaysApply: false +--- + +# Renderer Development + +## What is a Renderer? +A renderer is a set of Vue components that know how to display a specific content type across the five content surfaces (chat-preview, chat-play, panel-preview, panel-play, panel-edit). + +## Renderer Registration +```typescript +import { registerRenderer } from '@aiui/core' + +registerRenderer({ + id: 'film', + name: 'Film', + contentType: 'application/x-aiui-film', + surfaces: ['chat-preview', 'panel-preview', 'panel-play'], + chatPreview: () => import('./FilmChatPreview.vue'), + panelPreview: () => import('./FilmGrid.vue'), + panelPlay: () => import('./FilmDetail.vue'), +}) +``` + +## Content Type Detection +Renderers are matched to content by `contentType` field in the message data: +```typescript +interface ContentBlock { + contentType: string // e.g., 'application/x-aiui-film' + data: Record<string, unknown> // renderer-specific data + title?: string // human-readable title for panel tab +} +``` + +## Performance Rules +1. Chat surfaces (preview, play) must render with ZERO lazy-loaded heavy dependencies +2. Panel surfaces may lazy-load libraries (CodeMirror, pdf.js, etc.) +3. Use `defineAsyncComponent` for panel components +4. Show skeleton/placeholder while loading +5. Never block the main thread — use Web Workers for heavy parsing + +## Data Contracts +Each renderer defines its expected data shape as a TypeScript interface: +```typescript +interface FilmRendererData { + films: Film[] + query?: string + filters?: FilmFilters +} +``` +Document the interface. Validate incoming data. Show graceful error if data is malformed. + +## Accessibility Requirements +- All renderers must be keyboard navigable +- Images need alt text +- Interactive elements need ARIA labels +- Media players need captions/transcripts when available +- Focus management when transitioning between surfaces + +## Mobile Behavior +- Chat Preview: constrained to message bubble width +- Chat Play: full message width, max 200px height +- Panel surfaces on mobile: full-screen overlay with back gesture +- Touch targets: minimum 44x44px +- Swipe gestures where appropriate (image gallery, film cards) + +## Renderer Checklist +- [ ] TypeScript data interface defined and exported +- [ ] All applicable surfaces implemented +- [ ] Lazy loading for heavy dependencies +- [ ] Skeleton/placeholder states +- [ ] Error state (malformed data) +- [ ] Empty state (no data) +- [ ] Keyboard navigation +- [ ] ARIA labels on interactive elements +- [ ] Mobile responsive +- [ ] Dark mode compatible +- [ ] Transition animations (per motion design rules) diff --git a/aiui/.cursor/rules/09-security-crypto.mdc b/aiui/.cursor/rules/09-security-crypto.mdc new file mode 100644 index 00000000..a86b5b92 --- /dev/null +++ b/aiui/.cursor/rules/09-security-crypto.mdc @@ -0,0 +1,60 @@ +--- +description: Cryptography and security rules - E2E encryption, key management, storage +globs: "**/crypto/**,**/*.ts" +alwaysApply: false +--- + +# Security & Cryptography + +## Principles +- Privacy is a requirement, not a feature +- Zero telemetry, zero analytics unless user explicitly opts in +- Never transmit unencrypted sensitive data +- Never store plaintext credentials +- Minimal data collection — store only what's needed + +## Encryption Stack + +### E2E Message Encryption +- Library: **tweetnacl.js** (6KB, audited by Cure53) +- Algorithm: XSalsa20-Poly1305 via NaCl `box` (public-key authenticated encryption) +- Each conversation has a shared secret derived from key exchange + +### Local Storage Encryption +- Library: **Web Crypto API** (native, zero bundle cost) +- Algorithm: AES-256-GCM for encrypting IndexedDB values +- Key derived from user's master password via PBKDF2 (100K+ iterations) + +### Key Management +- **Desktop (Tauri)**: OS keychain (macOS Keychain, Windows Credential Manager, Linux Secret Service) +- **Web**: Encrypted IndexedDB with user-derived key +- **Nostr compatibility**: secp256k1 keys via @noble/curves, NIP-07 browser extension support +- **Passkeys/WebAuthn**: For passwordless authentication + +### Credential Storage +- API keys encrypted at rest using AES-256-GCM +- Never stored in localStorage (use encrypted IndexedDB or OS keychain) +- Never included in logs, error reports, or system prompts +- Display as masked values in settings UI (show last 4 chars only) + +## Dev Mode Bypass +When `VITE_DISABLE_CRYPTO=true` (dev only): +- Skip E2E encryption (messages stored in plain text) +- Skip storage encryption (IndexedDB unencrypted) +- API keys stored in `.env.local` (gitignored) +- This flag must NEVER exist in production builds + +## Security Rules for Code +- Never log sensitive data (keys, tokens, passwords, message content) +- Never include secrets in error messages +- Sanitize all user input before rendering (XSS prevention) +- Use Content Security Policy headers +- Validate all data from plugins before rendering +- Community plugins run in sandboxed iframes (no direct DOM access) +- Never eval() or innerHTML with untrusted content + +## Network Security +- All external requests over HTTPS only +- Certificate pinning for known services (Tauri) +- Proxy social media fetches to avoid leaking user IP +- No third-party tracking scripts, analytics, or telemetry SDKs diff --git a/aiui/.cursor/rules/10-bitcoin-only.mdc b/aiui/.cursor/rules/10-bitcoin-only.mdc new file mode 100644 index 00000000..fdf33c5d --- /dev/null +++ b/aiui/.cursor/rules/10-bitcoin-only.mdc @@ -0,0 +1,72 @@ +--- +description: Bitcoin-only payment and monetary policy - on-chain, Lightning, ecash +globs: "**/*" +alwaysApply: true +--- + +# Bitcoin Only + +## Core Rule +Bitcoin is the only monetary unit in AIUI. This applies everywhere — UI labels, data models, API responses, documentation, and conversation context. + +## Supported Payment Protocols +- **On-chain Bitcoin**: BIP21 URI scheme (`bitcoin:bc1q...?amount=0.001`) +- **Lightning Network**: BOLT11 invoices, LNURL-pay, LNURL-withdraw, keysend +- **Cashu ecash**: Cashu tokens, mint interactions (`cashu:` URI, `web+cashu:`) +- **Fedimint/Fedi**: Federation ecash (`fedi:` URI) +- **Nostr Zaps**: NIP-57 Lightning zaps (social tipping) + +## AIUI is NEVER a Wallet + +### Never Do +- Store private keys or seed phrases +- Sign Bitcoin transactions +- Build or broadcast transactions +- Track wallet balances +- Display transaction history +- Create send/receive screens +- Implement payment processing logic +- Hold funds in custody + +### Always Do +- Construct deep-link URIs and hand off to external wallet apps +- Detect installed wallet apps (via URI scheme probing or Tauri app detection) +- Let users configure preferred wallets in settings +- Display payment requests as QR codes with "Open in Wallet" buttons +- Show invoice/address details (amount, memo, expiry) as read-only information + +## Wallet Deep-Linking +```typescript +// Construct URI, open external wallet — that's it +const uri = `lightning:${bolt11Invoice}` +window.open(uri) // or Tauri shell.open(uri) +``` + +Supported wallet URI schemes: +- `bitcoin:` — BIP21 (any on-chain wallet) +- `lightning:` — BOLT11 (any Lightning wallet) +- `cashu:` — Cashu tokens +- `fedi:` — Fedimint +- Wallet-specific: `phoenix://`, `zeus://`, `mutiny://`, `alby://` + +## Denomination +- Primary unit: **sats** (1 BTC = 100,000,000 sats) +- Display: `1,234 sats` or `₿0.00001234` +- User preference: sats or BTC (configurable in settings) +- AI cost tracking: show token costs in sats + +## Prohibited +- No fiat currencies (USD, EUR, etc.) — not in UI, not in code, not in variable names +- No altcoins or tokens +- No stablecoins (USDT, USDC, etc.) +- No fiat-denominated pricing +- No payment processor integrations (Stripe, PayPal, etc.) +- No KYC/AML flows + +## Renderer Components +- `LightningInvoiceRenderer` — BOLT11 QR + amount + memo + "Open in Wallet" +- `BitcoinAddressRenderer` — BIP21 QR + "Open in Wallet" +- `CashuTokenRenderer` — ecash token + mint info + "Redeem in Wallet" +- `FedimintRenderer` — federation ecash + "Open in Fedi" +- `PaymentRequestRenderer` — unified card with payment method options +- `ZapRenderer` — Nostr zap display (NIP-57) diff --git a/aiui/.cursor/rules/11-dev-prod-modes.mdc b/aiui/.cursor/rules/11-dev-prod-modes.mdc new file mode 100644 index 00000000..0a8bc6db --- /dev/null +++ b/aiui/.cursor/rules/11-dev-prod-modes.mdc @@ -0,0 +1,101 @@ +--- +description: Development vs production configuration, feature flags, mock data patterns +globs: "**/*" +alwaysApply: false +--- + +# Dev & Prod Modes + +## Development Mode + +### Environment +```env +# .env.local (gitignored) +VITE_OPENROUTER_API_KEY=sk-or-... +VITE_TMDB_API_KEY=... +VITE_DEV_MODE=true +VITE_MOCK_MEDIA_SOURCES=true +VITE_DISABLE_CRYPTO=true +``` + +### What's Enabled +- Hot reload via Vite HMR +- Debug panel overlay (AI context, plugin status, renderer registry, message data) +- Mock media source plugins (Plex, YouTube, Nextcloud from JSON fixtures) +- OpenRouter AI connection (real API, free models available) +- Component playground (Storybook/Histoire) +- Verbose logging +- TypeScript strict mode +- All renderers available without lazy loading (for dev speed) + +### What's Disabled +- E2E encryption (plain text messages for debugging) +- Storage encryption (plain IndexedDB) +- Tauri features (dev runs as pure web app) +- Production optimizations (tree-shaking, minification) +- Service worker / offline mode + +### Mock Data +- Film fixtures: 50-100 films with real TMDB poster URLs +- Media source mocks: JSON files returning fake Plex/YouTube/Nextcloud responses +- Located in: `packages/app/src/mocks/` +- Auto-loaded when `VITE_MOCK_MEDIA_SOURCES=true` +- Mock data must match production data interfaces exactly + +### Dev Scripts +``` +pnpm dev # Web dev server +pnpm dev:desktop # Tauri dev (when needed) +pnpm storybook # Component playground +pnpm test # Vitest +pnpm lint # ESLint + Prettier +pnpm typecheck # TypeScript +pnpm build # Production build +pnpm turbo build # Turborepo cached build +``` + +## Production Mode + +### What's Enabled +- E2E encryption for all messages +- Encrypted local storage +- Key management via OS keychain (Tauri) or encrypted IndexedDB (web) +- User-configured AI providers (settings page) +- Real media source connections (Plex API, YouTube, etc.) +- Optimized builds (tree-shaken, code-split, minified) +- Lazy loading for all heavy renderers +- Service worker for offline support +- Auto-update (Tauri) + +### What's Disabled +- Debug panels +- Mock data +- Dev logging +- Source maps (in distributed builds) +- `VITE_DISABLE_CRYPTO` flag (must not exist) + +### Build Targets +- Web: Static SPA bundle (< 250KB initial gzipped) +- Desktop: Tauri app (macOS .dmg, Windows .msi, Linux .AppImage) +- Mobile: Tauri mobile (iOS .ipa, Android .apk) + +## Feature Flags +Use composable `useFeatureFlags()`: +```typescript +const { isDev, isTauri, isMobile, isCryptoEnabled, isMockData } = useFeatureFlags() +``` + +Gate platform-specific features: +```typescript +if (isTauri()) { + // Native file system access +} else { + // File System Access API or file picker +} +``` + +## Environment Variable Rules +- All env vars prefixed with `VITE_` (Vite requirement) +- Secrets only in `.env.local` (gitignored) +- `.env.example` committed with placeholder values +- Never read `process.env` directly — use typed config module diff --git a/aiui/.cursor/rules/12-accessibility.mdc b/aiui/.cursor/rules/12-accessibility.mdc new file mode 100644 index 00000000..726b73b8 --- /dev/null +++ b/aiui/.cursor/rules/12-accessibility.mdc @@ -0,0 +1,69 @@ +--- +description: Accessibility standards - WCAG AA, keyboard navigation, screen readers +globs: "**/*.vue" +alwaysApply: false +--- + +# Accessibility + +## Standard +WCAG AA compliance minimum. Target AAA where feasible. + +## Color Contrast +- Normal text: 4.5:1 minimum ratio +- Large text (18px+ or 14px+ bold): 3:1 minimum +- Interactive elements: 3:1 against adjacent colors +- Test with browser DevTools accessibility panel + +## Keyboard Navigation +- All interactive elements focusable via Tab +- Visible focus indicators on every focusable element (`focus:ring-2`) +- Escape closes modals, drawers, dropdowns +- Arrow keys navigate within lists, grids, tabs +- Enter/Space activates buttons and controls +- Focus trap inside modals (Tab cycles within modal) + +## Semantic HTML +```html +<header>, <nav>, <main>, <article>, <aside>, <footer> +``` +Never `<div class="header">`. Use semantic elements. + +## ARIA +- Icon-only buttons: `aria-label="Close modal"` +- Dynamic content: `aria-live="polite"` for updates +- Screen reader only text: `class="sr-only"` +- Expandable sections: `aria-expanded="true/false"` +- Form fields: `aria-describedby` for help text, `aria-invalid` for errors + +## Images +- All `<img>` tags need `alt` text +- Decorative images: `alt=""` +- Complex images: `aria-describedby` pointing to description + +## Media +- Audio/video players: keyboard-accessible controls +- Provide transcripts/captions when available +- Respect `prefers-reduced-motion` for animations + +## Touch Targets +- Minimum: 44x44px (Apple HIG) +- Recommended: 48x48px (Material Design) +- Minimum 8px gap between adjacent targets + +## Reduced Motion +```css +@media (prefers-reduced-motion: reduce) { + * { + animation-duration: 0.01ms !important; + transition-duration: 0.01ms !important; + } +} +``` +Check in JS: `window.matchMedia('(prefers-reduced-motion: reduce)').matches` + +## Testing +- VoiceOver (macOS), TalkBack (Android), NVDA (Windows) +- Keyboard-only navigation test +- axe DevTools or Lighthouse accessibility audit +- High contrast mode test diff --git a/aiui/.cursor/rules/13-performance.mdc b/aiui/.cursor/rules/13-performance.mdc new file mode 100644 index 00000000..639780ac --- /dev/null +++ b/aiui/.cursor/rules/13-performance.mdc @@ -0,0 +1,60 @@ +--- +description: Performance optimization - bundle budget, lazy loading, virtual scrolling +globs: "**/*" +alwaysApply: false +--- + +# Performance + +## Bundle Budget +- Initial load: **< 250KB gzipped** +- Core (Vue + Tailwind + Pinia + Router + chat UI): ~150KB +- First renderer batch (markdown, streaming text): ~50KB +- Everything else: lazy-loaded on demand + +## Lazy Loading Strategy +- Route-based code splitting via Vue Router `() => import(...)` +- Renderer components via `defineAsyncComponent` +- Heavy libraries loaded only when their renderer is activated: + - CodeMirror 6: ~300KB (on code edit) + - Monaco: ~5MB (on IDE panel open) + - pdf.js: ~400KB (on PDF view) + - KaTeX: ~300KB (on math render) + - Mermaid: ~200KB (on diagram render) + - Leaflet: ~40KB (on map render) + - Whisper WASM: ~50MB (on STT activation, cached) + - Piper TTS: ~100MB (on TTS activation, cached) + +## Virtual Scrolling +- Chat message list uses TanStack Virtual +- Dynamic row heights (messages vary in size) +- Inverted scroll (newest at bottom, load older on scroll up) +- Buffer: render 5 items above and below viewport +- Recycle DOM nodes for off-screen messages + +## GPU Acceleration +Only animate `transform` and `opacity` — never `width`, `height`, `top`, `left`. +Use `will-change` sparingly and remove after animation. + +## Image Optimization +- Use `loading="lazy"` on all non-critical images +- Provide `srcset` with multiple sizes +- Use WebP/AVIF where supported +- Skeleton placeholders while loading + +## Network +- Preconnect to known API hosts +- Preload critical resources +- Debounce scroll and resize handlers (100ms) +- Batch API requests where possible + +## Memory +- Clean up event listeners in `onUnmounted` +- Use `shallowRef` for large data sets +- Dispose heavy library instances when panel closes +- Monitor memory with browser DevTools + +## Core Web Vitals Targets +- LCP (Largest Contentful Paint): < 2.5s +- FID (First Input Delay): < 100ms +- CLS (Cumulative Layout Shift): < 0.1 diff --git a/aiui/.cursor/rules/14-animation-motion.mdc b/aiui/.cursor/rules/14-animation-motion.mdc new file mode 100644 index 00000000..793d0067 --- /dev/null +++ b/aiui/.cursor/rules/14-animation-motion.mdc @@ -0,0 +1,79 @@ +--- +description: Animation principles - timing, easing, stagger, reduced motion +globs: "**/*.vue,**/*.css" +alwaysApply: false +--- + +# Animation & Motion Design + +## Philosophy +Every animation serves a purpose: guide attention, provide feedback, show relationships, enhance perceived performance, or add delight. Never animate for decoration alone. + +## Duration Scale +``` +100ms - Instant: micro-feedback (hover states, button press) +200ms - Fast: small elements (tooltips, dropdowns) +300ms - Moderate: standard UI transitions (modals, cards) +500ms - Normal: page sections, complex components +600ms - Slow: hero animations, page transitions (max for UI) +``` +Never exceed 600ms for UI element animations. + +## Easing Functions +- **ease-out** (90% of animations): elements entering viewport +- **ease-in**: elements exiting viewport +- **ease-in-out**: elements moving within viewport +- **spring**: playful interactions (button press, drag-and-drop) +- **linear**: progress bars, loading spinners only + +Custom smooth deceleration: `cubic-bezier(0.16, 1, 0.3, 1)` + +## Common Patterns + +### Fade & Slide Up (entrance) +```css +@keyframes fadeSlideUp { + from { opacity: 0; transform: translateY(20px); } + to { opacity: 1; transform: translateY(0); } +} +``` + +### Scale & Fade (emphasis) +```css +@keyframes scaleIn { + from { opacity: 0; transform: scale(0.8); } + to { opacity: 1; transform: scale(1); } +} +``` + +### Hover feedback +```css +.interactive { + transition: transform 0.1s ease, opacity 0.1s ease; +} +.interactive:active { + transform: scale(0.95); + opacity: 0.8; +} +``` + +## Staggered Animations +When animating multiple elements, stagger by 50-150ms per item: +```css +.card { animation-delay: calc(var(--index) * 0.1s); } +``` +Max items in a stagger cascade: 6-8. Total cascade: under 1 second. + +## Reduced Motion +Always respect `prefers-reduced-motion`. Provide instant transitions as fallback. + +## Performance +- Only animate `transform` and `opacity` (GPU-composited) +- Use `will-change` sparingly, remove after animation +- Limit simultaneous animations +- Use `requestAnimationFrame` for JS animations + +## Loading States +- Skeleton shimmer: 2s infinite, `linear-gradient` sweep +- Pulse: 2s infinite, opacity 1 → 0.5 → 1 +- Spinner: 1s infinite linear rotation diff --git a/aiui/.cursor/rules/15-mobile-ux.mdc b/aiui/.cursor/rules/15-mobile-ux.mdc new file mode 100644 index 00000000..9cb736a6 --- /dev/null +++ b/aiui/.cursor/rules/15-mobile-ux.mdc @@ -0,0 +1,173 @@ +--- +description: Mobile UX patterns informed by Apple iOS HIG — touch targets, typography, spacing, navigation, animations +globs: "**/*.vue,**/*.css" +alwaysApply: false +--- + +# Mobile UX (iOS HIG-Informed) + +## Philosophy +Design for mobile first, enhance for desktop. Follow Apple iOS Human Interface Guidelines for sizing, spacing, and interaction patterns. Adapt native iOS conventions to our glass morphism dark theme. + +## Typography (iOS Dynamic Type Mapped to CSS) + +| iOS Text Style | Default Size | CSS Equivalent | AIUI Usage | +|---|---|---|---| +| Large Title | 34pt | `text-[34px]` / `text-3xl` | Page titles (rare) | +| Title 1 | 28pt | `text-[28px]` / `text-2xl` | Section headers | +| Title 2 | 22pt | `text-[22px]` / `text-xl` | Sub-section headers | +| Title 3 | 20pt | `text-[20px]` / `text-lg` | Card titles | +| Headline | 17pt semibold | `text-[17px] font-semibold` | Emphasis labels | +| Body | 17pt | `text-[17px]` / `text-base` | Primary content | +| Callout | 16pt | `text-[16px]` | Secondary content | +| Subheadline | 15pt | `text-[15px]` | Metadata | +| Footnote | 13pt | `text-[13px]` / `text-xs` | Timestamps, captions | +| Caption 1 | 12pt | `text-[12px]` | Badges, small labels | +| Caption 2 | 11pt | `text-[11px]` | Smallest text (tab labels) | + +### Key rules +- **Minimum text size**: 11px (Caption 2) — never go smaller +- **Body text on mobile**: 17px (not 14px/16px) for comfortable reading +- Use `text-sm` (14px) sparingly — only for dense UI, not primary reading content +- Chat messages should use at least 15-16px on mobile +- Metadata/timestamps: 11-13px is acceptable + +## Touch Targets + +| Rule | Value | Tailwind | +|---|---|---| +| Minimum tap target | **44 × 44px** | `min-w-[44px] min-h-[44px]` | +| Minimum gap between targets | **8px** | `gap-2` | +| Comfortable button height | 44-50px | `h-11` to `h-[50px]` | +| iOS nav bar button | 44px | `h-11` | + +### Key rules +- The 44px minimum applies to the **tappable area**, not the visual size +- A 24px icon can have a 44px tap target via padding: `p-2.5` on a 24px icon +- Our `w-9 h-9` (36px) header buttons are below 44px — compensate with generous spacing or padding hit areas +- Text buttons must extend touch target beyond text bounds + +## Spacing & Layout + +| Element | iOS Value | CSS | +|---|---|---| +| Side margins (iPhone) | 16px | `px-4` | +| Nav bar height | 44px | `h-11` | +| Tab bar height | 49px (+34px safe area) | `h-[49px]` + `pb-[env(safe-area-inset-bottom)]` | +| Bottom safe area (notch) | 34px | `env(safe-area-inset-bottom)` | +| Search bar | 36px field + 8px padding | `h-9` + `py-1` | +| Standard content inset | 16px horizontal | `px-4` | + +### Safe area insets +```css +/* Always use for full-screen layouts */ +padding-top: env(safe-area-inset-top); +padding-bottom: env(safe-area-inset-bottom); +padding-left: env(safe-area-inset-left); +padding-right: env(safe-area-inset-right); +height: 100dvh; /* Dynamic viewport height — avoids iOS Safari toolbar */ +``` + +## Navigation Patterns + +### iOS-native patterns to follow +- **Primary navigation**: Bottom tab bar (persists across screens) +- **Secondary navigation**: Top nav bar with back button (left) and actions (right) +- **Modals**: Sheet sliding up from bottom (half-screen or full) +- **Context menus**: Long-press or action sheets from bottom + +### Primary action placement +``` +Top 20%: Navigation, info, secondary actions +Middle 60%: Main content (scrollable) +Bottom 20%: Primary actions (thumb zone) — send, approve, play +``` + +### Sheets & modals on mobile +- Use bottom sheets with three detents: small (~25%), medium (~50%), large (full) +- Always provide a close button — don't rely solely on swipe-to-dismiss +- Content panels: full-screen overlay or bottom sheet, never side-by-side + +## Form Inputs + +| Rule | Value | Why | +|---|---|---| +| **Minimum input font** | **16px** | Prevents iOS Safari auto-zoom on focus | +| Minimum field height | 44px | Matches tap target | +| Use `inputmode` | `numeric`, `email`, `tel`, `url`, `search` | Shows appropriate keyboard | +| Use `autocomplete` | Standard attributes | Enables autofill | +| Submit button placement | Bottom of form, thumb zone | Easy to reach | + +## Animations & Motion (iOS Spring Model) + +### Duration guidelines +| Type | Duration | Tailwind | +|---|---|---| +| Micro-interaction (tap, toggle) | 100-200ms | `duration-150` | +| Standard transition (push/pop) | 250-350ms | `duration-300` | +| Modal presentation (sheet) | 300-400ms | `duration-300` | +| Complex transitions | 400-500ms | `duration-500` | + +### iOS-style easing +```css +/* Standard iOS-like transition (ease out / decelerate) */ +transition: transform 0.35s cubic-bezier(0.2, 0.9, 0.3, 1.0); + +/* Bouncy spring-like (for playful entrances) */ +transition: transform 0.5s cubic-bezier(0.175, 0.885, 0.32, 1.275); + +/* Quick snap (micro-interactions) */ +transition: transform 0.25s cubic-bezier(0.0, 0.0, 0.2, 1.0); +``` + +### Motion rules +- Entrances: ease-out (decelerate) +- Exits: ease-in (accelerate) +- Only animate `transform` and `opacity` +- **Always** respect `prefers-reduced-motion`: +```css +@media (prefers-reduced-motion: reduce) { + *, *::before, *::after { + animation-duration: 0.01ms !important; + transition-duration: 0.01ms !important; + } +} +``` + +## Gestures +- Swipe left/right: gallery nav, dismiss +- Swipe down: close overlay/bottom sheet, pull-to-refresh +- Long press: context menu, selection +- Pinch: zoom on images +- Minimum swipe distance: 50px before triggering + +## Scroll Behavior +- Lock body scroll when modal/drawer is open +- `overscroll-behavior: contain` on modal content +- `touch-action: manipulation` to prevent zoom on double-tap +- `-webkit-overflow-scrolling: touch` for smooth iOS scroll + +## Iconography +| Context | Size | Style | +|---|---|---| +| Tab bar | 25px | Filled/solid | +| Nav bar / toolbar | 22px | Outlined, 1.5px stroke | +| Inline with text | Match font size | Outlined | +| Standalone | 28-33px | Filled or outlined | + +## AIUI Custom Overrides (Keep These) +These deviate from stock iOS but are intentional for our design language: +- **Dark-only theme**: No light mode. Background `#0a0a0a`, not iOS system colors +- **Glass morphism**: Translucent surfaces with backdrop-blur instead of iOS solid materials +- **Accent color**: Bitcoin orange `#F7931A` instead of iOS systemBlue +- **Text opacity scale**: Our `/25` → `/96` scale instead of iOS label hierarchy +- **No separator borders**: We use spacing and glass layering instead +- **Custom animations**: `animate-fade-up`, `animate-scale-in` per our design system +- **Header buttons**: Currently 36px (`w-9 h-9`), acceptable with generous spacing + +## Performance on Mobile +- Test on real devices, not just emulators +- Test on 3G/4G connections +- Debounce scroll handlers +- Lazy load images with `loading="lazy"` +- Critical CSS inlined, rest loaded async diff --git a/aiui/.cursor/rules/16-git-workflow.mdc b/aiui/.cursor/rules/16-git-workflow.mdc new file mode 100644 index 00000000..67ffa263 --- /dev/null +++ b/aiui/.cursor/rules/16-git-workflow.mdc @@ -0,0 +1,52 @@ +--- +description: Git workflow - commit conventions, branching, PR process +globs: "**/*" +alwaysApply: false +--- + +# Git Workflow + +## Commit Messages +Format: `type(scope): description` + +Types: +- `feat`: new feature +- `fix`: bug fix +- `refactor`: code restructuring (no behavior change) +- `style`: formatting, whitespace (no code change) +- `docs`: documentation +- `test`: adding/updating tests +- `chore`: build, dependencies, tooling +- `perf`: performance improvement + +Scope: the package or area (`core`, `app`, `plugin-x`, `renderer-film`, etc.) + +Examples: +``` +feat(core): add renderer registry with lazy loading +fix(chat): prevent scroll jump on new message +refactor(plugin-system): simplify adapter interface +chore(deps): update Vue to 3.6 +``` + +## Branching +- `main`: production-ready, always deployable +- `dev`: integration branch for features +- `feat/description`: feature branches (from dev) +- `fix/description`: bug fix branches +- `release/x.y.z`: release preparation + +## Pull Requests +- One feature per PR +- Description: what changed, why, how to test +- All tests pass +- TypeScript strict mode passes +- No linter errors +- Reviewed before merge + +## Rules +- Never force push to `main` or `dev` +- Never commit `.env.local` or any secrets +- Never commit `node_modules` +- Squash merge feature branches to keep history clean +- Tag releases with semver: `v1.0.0` diff --git a/aiui/.cursor/rules/20-content-films.mdc b/aiui/.cursor/rules/20-content-films.mdc new file mode 100644 index 00000000..f63ef8cf --- /dev/null +++ b/aiui/.cursor/rules/20-content-films.mdc @@ -0,0 +1,30 @@ +--- +description: Expert rules for Film content extraction, display, and surfacing +globs: "**/useContentPanel.ts,**/FilmCard.vue,**/FilmGrid.vue,**/FilmDetail.vue,**/mocks/films*" +alwaysApply: false +--- + +# Films Content Surface + +## Extraction Patterns + +- **Tagged**: `[[film:f123]]` or `[[film:123]]` → resolved from mock library +- **External**: `[[film_ext:Title|YYYY|Director]]` → create external film with fallback poster + +## Edge Cases + +- `normalizeFilmId`: `f123` and `123` both become `f123` +- Duplicate prevention: key by `title|year` for externals +- Empty/malformed: skip if title < 2 chars, year invalid +- Poster: use `generatePosterFallback(title, year)` for externals + +## Strip Rules + +- `stripFilmTags` removes `[[film:...]]` and `[[film_ext:...]]` before displaying text +- Preserve `\n{3,}` → `\n\n` to avoid excessive whitespace + +## Display + +- FilmCard: poster, title, year, director +- FilmDetail: full metadata, sources, cast +- Panel: grid of FilmCards, click opens FilmDetail in panel diff --git a/aiui/.cursor/rules/21-content-songs.mdc b/aiui/.cursor/rules/21-content-songs.mdc new file mode 100644 index 00000000..2c27179a --- /dev/null +++ b/aiui/.cursor/rules/21-content-songs.mdc @@ -0,0 +1,31 @@ +--- +description: Expert rules for Song content extraction, display, and surfacing +globs: "**/useContentPanel.ts,**/SongCard.vue,**/SongGrid.vue,**/SongDetail.vue,**/mocks/songs*" +alwaysApply: false +--- + +# Songs Content Surface + +## Extraction Priority + +1. Tagged: `[[song:s123]]` or `[[song_ext:Title|Artist|YYYY]]` +2. Library match: title + artist within 120 chars +3. Patterns: `"Title" by Artist`, `Title – Artist`, `**Title** by Artist` + +## looksLikeSong Rejection + +Reject when title/artist contains: news phrases, "BIP", "protocol", "web search", "mailing list", "training cutoff", etc. See `looksLikeSong()` blocklist. + +- Max length: title 55 chars, artist 40 chars + +## Edge Cases + +- If `extractFilmIds` or `extractPodcastIds` found → return [] (don't mix film/podcast with song patterns) +- If `isNewsLikeResponse` → return [] (news bullets often look like "X – Y") +- Skip if title/artist is 4-digit year +- Skip if contains `[[film` or `[[song` tags +- Dedupe by `title|artist` lowercase + +## Strip Rules + +- `stripSongTags` removes song tags before displaying text diff --git a/aiui/.cursor/rules/22-content-podcasts.mdc b/aiui/.cursor/rules/22-content-podcasts.mdc new file mode 100644 index 00000000..309d6a59 --- /dev/null +++ b/aiui/.cursor/rules/22-content-podcasts.mdc @@ -0,0 +1,26 @@ +--- +description: Expert rules for Podcast content extraction, display, and surfacing +globs: "**/useContentPanel.ts,**/PodcastCard.vue,**/PodcastGrid.vue,**/PodcastDetail.vue,**/mocks/podcasts*" +alwaysApply: false +--- + +# Podcasts Content Surface + +## Extraction Patterns + +- **Tagged**: `[[podcast:p123]]` or `[[podcast_ext:Title|Host|YYYY]]` +- No pattern fallback (unlike songs) — only tags + +## Edge Cases + +- Duplicate prevention: key by `title|host` lowercase +- Empty: skip if title or host < 2 chars +- Year optional in external format + +## looksLikePodcast (when added) + +Reject when title/host looks like: news source names, documentation sites, "Bitcoin Mailing List", etc. — same philosophy as `looksLikeSong`. + +## Strip Rules + +- `stripPodcastTags` removes podcast tags before displaying text diff --git a/aiui/.cursor/rules/23-content-news.mdc b/aiui/.cursor/rules/23-content-news.mdc new file mode 100644 index 00000000..7effc7e9 --- /dev/null +++ b/aiui/.cursor/rules/23-content-news.mdc @@ -0,0 +1,47 @@ +--- +description: Expert rules for News content extraction, merge, and surfacing +globs: "**/useContentPanel.ts,**/useRssFetch.ts,**/NewsGrid.vue,**/ArticleDetail.vue,**/vite-rss*" +alwaysApply: false +--- + +# News Content Surface + +## Sources + +1. **Web search**: `message.webResults` from AI (with imgSrc, content) +2. **RSS**: Fetched from website URLs only when `newsContext` is true + +## newsContext + +- `isNewsQuery(userQuery)` — "news", "latest", "what's happening", "what are people saying", etc. +- `isNewsLikeResponse(text)` — "for instant news", "check these sources", "access to web search", etc. + +## Merge Rules + +- `mergeNewsResults(web, rss)` — dedupe by URL (normalized: lowercase, no trailing slash) +- Web results take precedence when URL collision + +## RSS Fetch Guard + +- **Only fetch RSS when `newsContext` is true and `mergedWebsites.length > 0`** — avoid surfacing irrelevant RSS from docs/resource links when user asked "websites" +- Max 8 URLs, 15 articles total, 5 sites tried +- Timeout: 15s client, 5s per feed server-side + +## Display + +- NewsGrid (variant=news): articles open in **ArticleDetail** (in-panel) +- Relevance sort when `query` provided +- Search filter by title, content, url +- imgSrc: validate with `isSafeImgUrl` (https only) + +## Known Limitations + +- **RSS language**: Feeds return whatever the site publishes; no query/language filtering — may surface non-English articles +- **RSS relevance**: No semantic filtering; articles are shown as published + +## ArticleDetail Security + +- `sanitizeHtml`: allow only safe tags (p, br, a, strong, em, ul, ol, li, blockquote, h1-h4) +- Strip script, style, iframe, object, embed +- Links: `href` must be `https?://`, reject `javascript:` +- Images: `src` must be `https?://` diff --git a/aiui/.cursor/rules/24-content-websites.mdc b/aiui/.cursor/rules/24-content-websites.mdc new file mode 100644 index 00000000..b9df3f1b --- /dev/null +++ b/aiui/.cursor/rules/24-content-websites.mdc @@ -0,0 +1,32 @@ +--- +description: Expert rules for Websites content extraction and surfacing +globs: "**/useContentPanel.ts,**/NewsGrid.vue,**/articleOverlay*" +alwaysApply: false +--- + +# Websites Content Surface + +## Extraction + +1. **Markdown links**: `[Title](https://...)` — extract all with `extractMarkdownLinks` +2. **Bold domains**: `**Name** (domain.tld)` — extract with `extractBoldDomainLinks` +3. Merge with `mergeNewsResults` (dedupe by URL) + +## URLs Validation + +- Scheme: `https?://` only +- `new URL(raw)` must not throw +- Min length: title 2, url 10 chars +- Normalize for dedupe: lowercase, no trailing slash + +## Display + +- NewsGrid (variant=websites): card with favicon/globe icon +- Click → **overlay iframe** (not ArticleDetail) +- Use `articleOverlayStore.open(url, title, undefined, imgSrc)` + +## Distinction from News + +- News = articles (web search + RSS) → ArticleDetail in panel +- Websites = plain links from response → overlay iframe +- Same NewsGrid component, different `variant` and click handler diff --git a/aiui/.cursor/rules/25-content-magazine.mdc b/aiui/.cursor/rules/25-content-magazine.mdc new file mode 100644 index 00000000..31fb5b9d --- /dev/null +++ b/aiui/.cursor/rules/25-content-magazine.mdc @@ -0,0 +1,42 @@ +--- +description: Expert rules for Magazine/Brief content extraction and surfacing +globs: "**/useContentPanel.ts,**/MagazineGrid.vue" +alwaysApply: false +--- + +# Magazine Content Surface + +## Detection + +- `hasMagazine` = sections ≥ 1 AND (newsQuery OR newsLikeResponse OR context keywords) +- Context keywords: sentiment, bearish, bull case, macro, %, BTC, bitcoin, BIP, protocol, debate, what's happening + +## Section Extraction Order + +1. `## Heading` blocks — content until next ## or **Section** +2. `**Pro/Anti camp**` blocks with emoji +3. Bullets: `- **Title**: Content` or `- **Title** — Content` (em/en dash) +4. Attributed: `- **Name** (Role) description` +5. Intro paragraph (before first ##) +6. "Key takeaway" / "This is being called..." +7. "For deeper analysis" / further reading + +## Section Rules + +- Min: title 2 chars, content 15 chars +- Max content: 2000 chars per section +- Dedupe by title prefix (first 50 chars) +- Skip bullets already inside ## blocks (`blockContents`) +- `addSection` extracts: url, author, imageUrl from content + +## Hero Image + +1. First markdown image in text +2. First `.jpg|.png|.gif|.webp` URL +3. `webResults[0]?.imgSrc` +4. Picsum fallback seeded by query + +## Format & Security + +- `formatContent`: escape `&<>`, preserve `**bold**` as `<strong>`, `\n\n` → `</p><p>` +- Meme: imgflip URLs, contextual by topic (bearish, bull, Bitcoin, macro) diff --git a/aiui/.env.example b/aiui/.env.example new file mode 100644 index 00000000..9ebfb0c0 --- /dev/null +++ b/aiui/.env.example @@ -0,0 +1,33 @@ +# AIUI Development Environment +# Copy this file to .env.local and fill in your values + +# AI Provider (OpenRouter - gives access to many models including free ones) +# Get your key at: https://openrouter.ai/keys +VITE_OPENROUTER_API_KEY=sk-or-your-key-here + +# Anthropic Claude — for live web search (Claude invokes search mid-response): +# Option 1: OAuth token from Max subscription (run: claude setup-token, save output) +# → No extra cost; uses your existing Max subscription. +ANTHROPIC_TOKEN=sk-ant-oat01-your-token-here +# Option 2: API key from https://console.anthropic.com/settings/keys +ANTHROPIC_API_KEY=sk-ant-your-key-here +# +# Without either: proxy uses CLI with built-in WebSearch + pre-fetched context. + +# TMDB API (free, fetches posters on-demand when images fail) +# Get your key at: https://www.themoviedb.org/settings/api +TMDB_API_KEY=your-tmdb-key-here + +# Jamendo API (optional, extends music search - free 35k req/mo) +# Get your client_id at: https://devportal.jamendo.com/ +JAMENDO_CLIENT_ID=your-jamendo-client-id + +# SearXNG instance for web search (optional) +# Uses public instances by default; falls back to DuckDuckGo when they fail. +# For reliable dev: host your own (https://docs.searxng.org/) or rely on DDG fallback. +# SEARXNG_URL=https://your-searxng.instance + +# Development flags +VITE_DEV_MODE=true +VITE_MOCK_MEDIA_SOURCES=true +VITE_DISABLE_CRYPTO=true diff --git a/aiui/.github/workflows/ci.yml b/aiui/.github/workflows/ci.yml new file mode 100644 index 00000000..46722366 --- /dev/null +++ b/aiui/.github/workflows/ci.yml @@ -0,0 +1,131 @@ +name: CI + +on: + push: + branches: [main, development] + pull_request: + branches: [main, development] + +jobs: + lint-typecheck: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - uses: pnpm/action-setup@v4 + with: + version: 10 + - uses: actions/setup-node@v4 + with: + node-version: 20 + cache: pnpm + - run: pnpm install --frozen-lockfile + - run: pnpm typecheck + - run: pnpm lint + + test: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - uses: pnpm/action-setup@v4 + with: + version: 10 + - uses: actions/setup-node@v4 + with: + node-version: 20 + cache: pnpm + - run: pnpm install --frozen-lockfile + - run: pnpm test + + bundle-size: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - uses: pnpm/action-setup@v4 + with: + version: 10 + - uses: actions/setup-node@v4 + with: + node-version: 20 + cache: pnpm + - run: pnpm install --frozen-lockfile + - run: pnpm build + - name: Check bundle size + run: | + BUNDLE_SIZE=$(find packages/app/dist/assets -name '*.js' -o -name '*.css' | xargs gzip -c | wc -c) + BUNDLE_KB=$((BUNDLE_SIZE / 1024)) + echo "Bundle size: ${BUNDLE_KB}KB gzipped" + if [ "$BUNDLE_KB" -gt 250 ]; then + echo "::error::Bundle size ${BUNDLE_KB}KB exceeds 250KB budget" + exit 1 + fi + echo "Bundle size ${BUNDLE_KB}KB is within 250KB budget" + + e2e: + runs-on: ubuntu-latest + strategy: + matrix: + browser: [chromium, firefox, webkit] + steps: + - uses: actions/checkout@v4 + - uses: pnpm/action-setup@v4 + with: + version: 10 + - uses: actions/setup-node@v4 + with: + node-version: 20 + cache: pnpm + - run: pnpm install --frozen-lockfile + - run: cd packages/app && npx playwright install --with-deps ${{ matrix.browser }} + - run: cd packages/app && pnpm test:e2e --project=${{ matrix.browser }} + + e2e-mobile: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - uses: pnpm/action-setup@v4 + with: + version: 10 + - uses: actions/setup-node@v4 + with: + node-version: 20 + cache: pnpm + - run: pnpm install --frozen-lockfile + - run: cd packages/app && npx playwright install --with-deps chromium webkit + - run: cd packages/app && pnpm test:e2e --project=iphone14 --project=galaxy-s21 + + lighthouse: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - uses: pnpm/action-setup@v4 + with: + version: 10 + - uses: actions/setup-node@v4 + with: + node-version: 20 + cache: pnpm + - run: pnpm install --frozen-lockfile + - run: pnpm build + - name: Run Lighthouse + uses: treosh/lighthouse-ci-action@v12 + with: + configPath: packages/app/lighthouserc.json + uploadArtifacts: true + + dependency-audit: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - uses: pnpm/action-setup@v4 + with: + version: 10 + - uses: actions/setup-node@v4 + with: + node-version: 20 + cache: pnpm + - run: pnpm install --frozen-lockfile + - name: Audit dependencies + run: pnpm audit --audit-level=critical || true + - name: Check licenses + run: | + npx license-checker --production --onlyAllow 'MIT;Apache-2.0;ISC;BSD-2-Clause;BSD-3-Clause;0BSD;CC0-1.0;Unlicense;CC-BY-4.0;Python-2.0;BlueOak-1.0.0' --excludePrivatePackages || echo "::warning::Non-approved licenses found" diff --git a/aiui/.github/workflows/dependency-audit.yml b/aiui/.github/workflows/dependency-audit.yml new file mode 100644 index 00000000..b6cbcc56 --- /dev/null +++ b/aiui/.github/workflows/dependency-audit.yml @@ -0,0 +1,57 @@ +name: Weekly Dependency Audit + +on: + schedule: + - cron: '0 9 * * 1' # Every Monday at 9am UTC + workflow_dispatch: + +jobs: + audit: + runs-on: ubuntu-latest + permissions: + issues: write + steps: + - uses: actions/checkout@v4 + - uses: pnpm/action-setup@v4 + with: + version: 10 + - uses: actions/setup-node@v4 + with: + node-version: 20 + cache: pnpm + - run: pnpm install --frozen-lockfile + + - name: Audit for vulnerabilities + id: audit + run: | + AUDIT_RESULT=$(pnpm audit --audit-level=moderate 2>&1) || true + echo "$AUDIT_RESULT" + if echo "$AUDIT_RESULT" | grep -q "critical"; then + echo "has_critical=true" >> $GITHUB_OUTPUT + else + echo "has_critical=false" >> $GITHUB_OUTPUT + fi + + - name: Check licenses + id: licenses + run: | + LICENSE_RESULT=$(npx license-checker --production --onlyAllow 'MIT;Apache-2.0;ISC;BSD-2-Clause;BSD-3-Clause;0BSD;CC0-1.0;Unlicense;CC-BY-4.0;Python-2.0;BlueOak-1.0.0' --excludePrivatePackages 2>&1) || true + echo "$LICENSE_RESULT" + if echo "$LICENSE_RESULT" | grep -q "FAIL"; then + echo "has_violations=true" >> $GITHUB_OUTPUT + else + echo "has_violations=false" >> $GITHUB_OUTPUT + fi + + - name: Create issue if violations found + if: steps.audit.outputs.has_critical == 'true' || steps.licenses.outputs.has_violations == 'true' + uses: actions/github-script@v7 + with: + script: | + await github.rest.issues.create({ + owner: context.repo.owner, + repo: context.repo.repo, + title: '⚠️ Dependency audit: violations found', + body: `The weekly dependency audit found issues:\n\n- Critical vulnerabilities: ${{ steps.audit.outputs.has_critical }}\n- License violations: ${{ steps.licenses.outputs.has_violations }}\n\nRun \`pnpm audit\` and \`npx license-checker\` locally for details.`, + labels: ['security', 'dependencies'], + }) diff --git a/aiui/.gitignore b/aiui/.gitignore new file mode 100644 index 00000000..5e4c1302 --- /dev/null +++ b/aiui/.gitignore @@ -0,0 +1,54 @@ +# Dependencies +node_modules/ +.pnpm-store/ + +# Build output +dist/ +*.tsbuildinfo + +# scripts/build-aiui.sh's staleness-detection cache (13-09) — a local, +# best-effort marker so the script can tell "source changed but the +# emitted asset filenames didn't" from a plain rebuild with no changes. +.build-aiui-last-src-hash +.build-aiui-last-assets + +# Turborepo +.turbo/ + +# Environment (secrets) +.env.local +.env.*.local + +# Tauri +packages/app/src-tauri/target/ + +# IDE +.idea/ +*.swp +*.swo +*~ + +# OS +.DS_Store +Thumbs.db + +# Dev chat history +.dev/ + +# Debug +npm-debug.log* +pnpm-debug.log* + +# Test coverage +coverage/ + +# Overnight loop logs +loop/loop.log + +# Playwright +test-results/ +playwright-report/ +playwright/.cache/ + +# Storybook +storybook-static/ diff --git a/aiui/CLAUDE.md b/aiui/CLAUDE.md new file mode 100644 index 00000000..39014609 --- /dev/null +++ b/aiui/CLAUDE.md @@ -0,0 +1,345 @@ +# CLAUDE.md — AIUI Project Guide + +## Project Overview + +AIUI is a next-generation AI content surface UI. It's a **pnpm monorepo** with two packages: + +- `@aiui/app` — Reference application (Vite + Vue 3 + Tailwind CSS) +- `@aiui/core` — Reusable component library + +**Stack**: Vue 3 (Composition API), TypeScript ~5.8 (strict), Vite, Tailwind CSS, Pinia, Vue Router, Turborepo +**Node**: >=20.0.0 | **pnpm**: >=10.0.0 + +## Quick Reference + +```bash +pnpm dev # Run app dev server + Claude proxy +pnpm dev:core # Watch-build core library +pnpm build # Build all packages (turbo) +pnpm test # Run tests (vitest) +pnpm lint # Lint all packages (eslint) +pnpm typecheck # Type-check all packages (vue-tsc) +pnpm clean # Remove dist/ directories +``` + +Dev server: `http://localhost:5173` | Claude proxy: `http://localhost:3141` + +## Core Philosophy + +- **Open source only** — MIT/Apache-2.0 licensed dependencies only +- **Decentralized-first** — Pluggable adapters, no vendor lock-in +- **Bitcoin only** — sats/Lightning/Cashu/Fedimint. Never fiat, never altcoins. AIUI is never a wallet — always deep-link to external wallets +- **Privacy-first** — E2E encryption (tweetnacl.js), encrypted local storage (AES-256-GCM), no tracking/telemetry +- **Mobile-first, everywhere-perfect** — Desktop is an enhancement of the mobile experience +- **Plugin-everything** — All integrations go through typed plugin interfaces + +## Vue 3 Conventions + +**Always use `<script setup lang="ts">`** — never Options API. + +### Script section ordering + +Imports → Props (`defineProps`) → Emits (`defineEmits`) → Reactive state → Computed → Watchers → Methods → Lifecycle hooks → `defineExpose` + +### Naming + +| Thing | Convention | Example | +|-------|-----------|---------| +| Components | PascalCase | `ProjectCard.vue` | +| Composables | camelCase, `use` prefix | `useTheme.ts` | +| Props (JS) | camelCase | `projectName` | +| Props (template) | kebab-case | `project-name` | +| Boolean props | `is`/`has`/`can`/`should` prefix | `isVisible`, `canEdit` | +| Emits (template) | kebab-case with colon namespacing | `project:updated` | +| Stores | camelCase, `use` prefix, `Store` suffix | `useSettingsStore` | + +### Reactive state rules + +- `ref()` for primitives, `reactive()` for objects +- `computed()` for derived values — no side effects in computed +- `shallowRef()` for large collections/objects not requiring deep reactivity +- Always use unique IDs for `:key` — never array index + +### Props + +Always use object-style with type annotations, never array-style: + +```ts +// Correct +defineProps<{ title: string; count?: number }>() + +// Wrong +defineProps(['title', 'count']) +``` + +### Performance + +- Lazy load with `defineAsyncComponent` for non-critical components +- Use `onErrorCaptured` for error boundaries +- Always handle loading/error/data states in async operations + +## File Structure + +``` +packages/app/src/ +├── components/ +│ ├── ui/ # Generic UI components +│ ├── chat/ # Chat interface components +│ ├── content-panel/ # Content panel components +│ ├── renderers/ # Content type renderers +│ └── layout/ # Layout components +├── composables/ # Shared composition functions +├── stores/ # Pinia stores +├── pages/ # Route-level components +├── styles/ # Global CSS, themes, tokens +├── utils/ # Pure utility functions +├── types/ # TypeScript type definitions +├── plugins/ # Plugin system +└── mocks/ # Dev fixtures & mock data + +packages/core/src/ +├── plugins/ # Plugin system interfaces +└── types/ # Shared TypeScript types +``` + +## Tailwind & Design System + +### Glass Morphism (Archy-derived) + +This project uses a glass morphism design language. Key utility classes: + +| Class | Purpose | +|-------|---------| +| `.glass` | Standard glass: `rgba(0,0,0,0.35)`, `blur(18px)`, white border 0.18 opacity | +| `.glass-strong` | Stronger blur: `blur(24px)` | +| `.glass-card` | Card variant: `rgba(0,0,0,0.65)`, `border-radius: 1rem` | +| `.glass-button` | Button: 48px height, `rgba(0,0,0,0.6)`, `blur(18px)` | +| `.glass-button-sm` | Compact button variant | +| `.gradient-card` | Gradient background card | + +### Spacing + +4px grid system: `1`=4px, `2`=8px, `3`=12px, `4`=16px, etc. + +### Colors + +- Background: `#0a0a0a` (near-black) +- Accent / Bitcoin orange: `#F7931A` +- Primary: `#606060` +- Text opacity scale: `/25` (placeholder) → `/40` (muted) → `/60` (secondary) → `/70` (interactive) → `/80` (body) → `/90` (emphasis) → `/96` (headings) → `text-white` (active) +- No separator borders between major sections + +### Typography + +`Inter`/`system-ui` for body, `Menlo`/`Monaco` for monospace. + +### Responsive breakpoints (mobile-first) + +`sm` 640px → `md` 768px → `lg` 1024px → `xl` 1280px → `2xl` 1536px + +### Animations + +- `animate-fade-up` (900ms), `animate-fade-up-fast` (400ms), `animate-fade-in` (500ms), `animate-scale-in` (250ms) +- Duration: 100ms micro, 200ms fast, 300ms moderate, 500ms normal, 600ms max +- Easing: `ease-out` for entrances (90% of animations), `ease-in` for exits +- Only animate `transform` and `opacity` — avoid animating layout properties +- Always respect `prefers-reduced-motion` + +## Content Surfaces Architecture + +Every content renderer supports up to five surfaces: + +1. **Chat Preview** (~120px max) — inline bubble, identify content at a glance +2. **Chat Play** (~200px max) — inline playback with expand button +3. **Panel Preview** (unlimited) — full browsing, filtering, sorting +4. **Panel Play** — full immersive playback +5. **Panel Edit** — full interaction, sends changes back to chat + +On mobile, Panel surfaces open as full-screen overlays, not side-by-side. + +```ts +interface RendererDefinition { + id: string + name: string + contentType: string + surfaces: SurfaceType[] + chatPreview?: Component + chatPlay?: Component + panelPreview?: Component + panelPlay?: Component + panelEdit?: Component + lazyDependencies?: () => Promise<any> +} +``` + +Chat surfaces must have zero lazy dependencies. Panel surfaces may lazy-load heavy libraries. + +## Plugin System + +All integrations are plugins. Plugin types: `ai-provider`, `media-source`, `messaging`, `storage`, `renderer`, `file-handler`, `crypto`, `search`, `auth`, `wallet`, `social-embed`, `mcp`, `media`. + +```ts +interface AIUIPlugin { + id: string + name: string + version: string + type: PluginType + description?: string + init(context: PluginContext): Promise<void> + destroy(): Promise<void> + isAvailable(): Promise<boolean> +} +``` + +Sandboxing: Tier 1 (trusted built-in), Tier 2 (community — sandboxed iframes), Tier 3 (external processes). Community plugins get no direct DOM access. + +## AI Provider Integration + +```ts +interface AIProviderAdapter extends AIUIPlugin { + type: 'ai-provider' + chat(messages: Message[], options: ChatOptions): AsyncIterable<ChatChunk> + models(): Promise<Model[]> + supportsStreaming: boolean + supportsVision: boolean + supportsTools: boolean +} +``` + +Normalize tool calling across providers (OpenAI `tool_calls` vs Claude `tool_use`). Never include API keys in context injection. + +## Security & Crypto + +- E2E encryption: tweetnacl.js XSalsa20-Poly1305 +- Local storage: Web Crypto API AES-256-GCM + PBKDF2 (100K+ iterations) +- API keys: encrypted at rest, never in localStorage, never logged, masked in UI (last 4 chars) +- No `eval()` or `innerHTML` with untrusted content +- Sanitize all user input against XSS +- HTTPS only, CSP headers in production +- Dev bypass: `VITE_DISABLE_CRYPTO=true` (never in production) + +## Accessibility + +WCAG AA minimum compliance: + +- Color contrast: 4.5:1 normal text, 3:1 large/interactive +- Keyboard: all elements focusable via Tab, visible focus indicators, Escape closes modals +- Semantic HTML: use `<header>`, `<nav>`, `<main>`, `<article>`, `<aside>`, `<footer>` — not div soup +- ARIA: `aria-label` for icon buttons, `aria-live="polite"` for dynamic updates, `sr-only` for screen reader text +- Touch targets: min 44x44px with 8px gaps +- All images need `alt` attributes (decorative: `alt=""`) +- Respect `prefers-reduced-motion` + +## Performance Budget + +- **Initial load**: < 250KB gzipped +- Core bundle: Vue + Tailwind + Pinia + Router + chat UI (~150KB) + markdown + streaming (~50KB) +- Everything else: lazy-loaded on demand +- Virtual scrolling (TanStack Virtual) for chat lists +- Clean up listeners in `onUnmounted`, use `shallowRef` for large data +- Core Web Vitals: LCP < 2.5s, FID < 100ms, CLS < 0.1 +- Preconnect to API hosts, debounce inputs (100ms) + +## Mobile UX (iOS HIG-Informed) + +Follows Apple iOS Human Interface Guidelines. See `.cursor/rules/15-mobile-ux.mdc` for full reference. + +- **Typography**: Body 17px, Footnote 13px, Caption 11px minimum — never smaller than 11px +- **Touch targets**: min 44×44px tappable area, 8px gap between targets +- **Side margins**: 16px (`px-4`) +- **Primary actions**: Bottom thumb zone +- **Viewport**: `height: 100dvh` with `env(safe-area-inset-*)` for notched devices +- **Form inputs**: min 16px font (prevents iOS zoom), appropriate `inputmode` +- **Content panels**: Full-screen overlay or bottom sheet on mobile, never side-by-side +- **Transitions**: 150ms micro, 300ms standard, 400ms modal — ease-out entrances, ease-in exits +- **Sheets**: Bottom sheets with close button, don't rely solely on swipe-to-dismiss +- Support both portrait and landscape + +## Environment & Dev Mode + +Env vars must be prefixed `VITE_`. Secrets go in `.env.local` (gitignored). See `.env.example` for template. + +Feature flags via `useFeatureFlags()`: `isDev`, `isTauri`, `isMobile`, `isCryptoEnabled`, `isMockData` + +Dev mode enables: mock data, debug panel, verbose logging, disabled encryption, all renderers without lazy loading. + +## Git Conventions + +### Commit format + +``` +type(scope): description +``` + +**Types**: `feat`, `fix`, `refactor`, `style`, `docs`, `test`, `chore`, `perf` +**Scope**: package or area — `core`, `app`, `chat`, `renderer-film`, `plugin-x` + +### Branches + +`main` (production), `dev` (integration), `feat/description`, `fix/description` + +### Rules + +- One feature per PR +- All tests pass, TypeScript strict passes, no lint errors +- No force push to main/dev +- Never commit `.env.local`, secrets, or `node_modules` +- Squash merge features, tag releases `v1.0.0` + +## Archipelago (Archy) Integration + +AIUI runs inside an iframe in Archipelago's Chat mode. All communication with the host happens via `window.postMessage()` through a strict protocol. + +### Architecture + +``` +AIUI (iframe) ←→ postMessage ←→ Archy ContextBroker ←→ Node data +``` + +AIUI is **quarantined** — it never directly accesses Archy's APIs, stores, or node data. The Archy ContextBroker fetches and sanitizes data before passing it to AIUI. + +### Protocol + +Use `archyBridge.ts` (`src/services/archyBridge.ts`) for all Archy communication: + +```ts +import { archyBridge } from '@/services/archyBridge' + +// Request context (respects user permissions) +const apps = await archyBridge.requestContext('apps') +if (!apps.permitted) { + // Show: "Enable 'Installed Apps' access in Archy Settings" +} + +// Request an action +await archyBridge.requestAction('open-app', { appId: 'btcpay-server' }) + +// Listen for theme/permission updates +archyBridge.onPermissionsUpdate((categories) => { ... }) +archyBridge.onThemeUpdate((theme) => { ... }) +``` + +**Context categories** (user toggles each on/off in Archy Settings): +- `apps` — App names, status, health (no credentials) +- `system` — CPU, RAM, disk (no paths or IPs) +- `network` — Connection status, peer count (no IPs) +- `wallet` — Balance, channel count (no keys or seeds) +- `files` — File/folder names (no contents) + +### Critical Rules + +1. **NEVER** fetch Archy APIs directly — always use `archyBridge` +2. **NEVER** store or log raw user data from context responses +3. **NEVER** make HTTP requests to the host machine +4. Handle `permitted: false` gracefully — tell users what to enable +5. Send `ready` message on mount so Archy knows the iframe loaded +6. Build must output a static SPA servable from any base path +7. All AI provider keys are user-provided and stored locally in AIUI only + +### Build & Deploy + +AIUI deploys as a Podman container on the Archy node: +- Build: `pnpm build` → `packages/app/dist/` +- Container: nginx:alpine serving the dist +- Proxied at `/aiui/` via Archy's nginx +- Updates independently of Archy — new container image = new version diff --git a/aiui/For Others.zip b/aiui/For Others.zip new file mode 100644 index 00000000..5a869523 Binary files /dev/null and b/aiui/For Others.zip differ diff --git a/aiui/For Others/README.md b/aiui/For Others/README.md new file mode 100644 index 00000000..2c394326 --- /dev/null +++ b/aiui/For Others/README.md @@ -0,0 +1,267 @@ +# Claude Code Overnight Automation + +Run Claude Code headlessly overnight to execute a full task checklist — with rate-limit resilience, macOS sleep prevention, and a stop hook that prevents Claude from quitting until every task is done. + +## How It Works + +``` +loop.sh (orchestrator) + | + +--> Reads plan.md for unchecked [ ] tasks + +--> Pipes prompt.md into `claude -p` (headless mode) + | | + | +--> Claude reads your plan, specs, and project rules + | +--> Implements tasks one by one + | +--> Runs typecheck/lint/test after each + | +--> Commits, marks [x], moves to next + | | + | +--> Claude tries to stop + | | + | +--> Stop Hook intercepts + | +--> Checks plan.md for remaining [ ] tasks + | +--> If incomplete: BLOCKS the stop (Claude continues) + | +--> If all done: allows stop + | + +--> Detects rate limits in output + | +--> Sleeps 1 hour, retries (up to 5x) + | +--> After 5 retries: schedules macOS launchd job to resume later + | + +--> Loops N iterations (default 10) + +--> Exits when all tasks checked or iterations exhausted +``` + +### The "Ralph Wiggum" Stop Hook + +The secret sauce. Claude Code supports a `Stop` hook — a shell script that runs every time Claude tries to end its session. By returning `{"decision":"block"}`, the hook **prevents Claude from stopping**. Combined with `--dangerously-skip-permissions`, Claude becomes a fully autonomous task executor that won't quit until the job is done. + +### Sleep Prevention + +On macOS, `caffeinate -i` prevents idle sleep during long runs. A hook starts it when Claude begins and kills it when Claude finishes. + +### Rate Limit Resilience + +If Claude hits API rate limits: +1. **Inline retry**: Sleep 1 hour, then retry the same iteration +2. **Scheduled retry**: After 5 failed retries, create a macOS `launchd` plist that auto-runs the loop later +3. The plist self-destructs after executing + +## Prerequisites + +- **Claude Code CLI** (`claude` command available in PATH) + - Install: https://docs.anthropic.com/en/docs/claude-code + - Must be logged in: run `claude login` first +- **macOS** (for `caffeinate` and `launchd` — see Linux notes below) +- **Git** (the script commits after each task) +- A project with `package.json` or similar build tooling + +## Quick Start + +```bash +# 1. Clone or copy this folder into your project +cp -r "For Others/templates" ~/my-project/loop + +# 2. Run the setup script (creates hooks, updates settings) +cd ~/my-project +bash "path/to/For Others/setup.sh" + +# 3. Edit your task list +vim loop/plan.md + +# 4. Edit your prompt (project-specific rules) +vim loop/prompt.md + +# 5. Start the overnight run +./loop/loop.sh +``` + +Or just run the setup script — it walks you through everything: + +```bash +bash "For Others/setup.sh" +``` + +## File Structure + +After setup, your project will have: + +``` +your-project/ + loop/ + loop.sh # Main orchestrator (run this) + prompt.md # Instructions piped to Claude each iteration + plan.md # Task checklist ([ ] = todo, [x] = done) + loop.log # Full output log (auto-created) + +~/.claude/ + hooks/ + prevent-sleep.sh # Starts caffeinate on session start + stop-hook-autonomous.sh # Blocks stop until tasks complete + allow-sleep.sh # Kills caffeinate on session end + settings.json # Hook registrations (auto-updated by setup) +``` + +## Configuration + +All config is via environment variables (set before running `loop.sh` or export in your shell): + +| Variable | Default | Description | +|----------|---------|-------------| +| `CLAUDE_AUTONOMOUS` | `1` | Set to `0` to disable the stop hook (Claude can quit freely) | +| `ITERATION_COUNT` | `10` | Max loop iterations | +| `ITERATION_DELAY` | `30` | Seconds to pause between iterations | +| `RATE_LIMIT_WAIT` | `3600` | Seconds to sleep when rate limited (1 hour) | +| `MAX_RATE_LIMIT_RETRIES` | `5` | Retries before scheduling launchd | +| `CLAUDE_BIN` | `claude` | Path to Claude CLI binary | +| `PROMPT_FILE` | `loop/prompt.md` | Path to prompt file | +| `LOG_FILE` | `loop/loop.log` | Path to log file | + +### Examples + +```bash +# Quick test run (2 iterations, 10s delay, no stop hook) +CLAUDE_AUTONOMOUS=0 ITERATION_COUNT=2 ITERATION_DELAY=10 ./loop/loop.sh + +# Full overnight run (20 iterations, 1 min between) +ITERATION_COUNT=20 ITERATION_DELAY=60 ./loop/loop.sh + +# Use a custom prompt +PROMPT_FILE=my-prompt.md ./loop/loop.sh +``` + +## Writing Your Plan + +`loop/plan.md` is a markdown checklist. Each line starting with `- [ ]` is a pending task: + +```markdown +## Phase 1: Core Features +- [ ] **1.1** — Add user authentication (JWT + refresh tokens) +- [ ] **1.2** — Create user profile page with avatar upload +- [ ] **1.3** — Add settings page with theme toggle + +## Phase 2: API +- [ ] **2.1** — REST endpoints for CRUD operations +- [ ] **2.2** — WebSocket support for real-time updates + +## Final +- [ ] **FINAL** — Run full test suite, fix any failures, tag release +``` + +Claude will: +1. Find the first `- [ ]` line +2. Read the spec from your prompt or a separate spec file +3. Implement it +4. Mark it `- [x]` +5. Move to the next + +### Tips for good plans + +- **Be specific**: "Add JWT auth with refresh tokens, store in httpOnly cookies" > "Add auth" +- **Order matters**: Put foundational tasks first (types, utils, config) before features that depend on them +- **Include testing gates**: "Run `pnpm test` and fix failures" as part of each task +- **Keep tasks small**: 30-60 minutes of work each. Large tasks lead to context window exhaustion +- **Add a FINAL task**: A catchall that runs the full test suite + +## Writing Your Prompt + +`loop/prompt.md` is what Claude reads at the start of every iteration. Include: + +1. **What files to read** (your plan, specs, project conventions) +2. **Project-specific rules** (coding style, frameworks, constraints) +3. **Per-task workflow** (implement → test → commit → mark done) +4. **Hard rules** (what to never do, minimum effort before skipping) + +See `templates/prompt.md` for a starting template. + +## Operating the Loop + +### Starting + +```bash +# Foreground (see output live) +./loop/loop.sh + +# Background with logging +nohup ./loop/loop.sh > /dev/null 2>&1 & + +# With caffeinate (prevents sleep even if hooks fail) +caffeinate -i ./loop/loop.sh +``` + +### Monitoring + +```bash +# Watch the log live +tail -f loop/loop.log + +# Check progress +grep -c '\- \[x\]' loop/plan.md # completed +grep -c '\- \[ \]' loop/plan.md # remaining + +# Check git commits +git log --oneline -20 +``` + +### Stopping + +- **Let it finish**: The loop stops automatically when all tasks are checked +- **Kill it**: `Ctrl+C` or `kill %1` — Claude's current task will be interrupted but committed work is preserved +- **Disable stop hook**: Set `CLAUDE_AUTONOMOUS=0` in the environment before the next iteration + +### Resuming + +Just run `./loop/loop.sh` again. It reads `plan.md` fresh each iteration, so it picks up where it left off (skipping `[x]` tasks). + +## Customizing the Prompt + +The prompt template has `{{PLACEHOLDER}}` markers. Replace them with your project's specifics: + +| Placeholder | What to put | +|-------------|-------------| +| `{{SPEC_FILE}}` | Path to your detailed spec (e.g., `SPEC.md`, `docs/plan.md`) | +| `{{PROJECT_RULES_FILE}}` | Path to your coding conventions file | +| `{{PROJECT_RULES}}` | Inline coding rules (style, frameworks, constraints) | + +## Troubleshooting + +### Claude exits immediately +- Make sure `claude login` has been run +- Check that `claude -p "hello"` works in your terminal +- Verify `~/.claude/hooks/stop-hook-autonomous.sh` exists and is executable + +### Rate limit loop +- Default wait is 1 hour. Increase `RATE_LIMIT_WAIT` if your limits are longer +- Check `loop.log` for the specific rate limit message +- Claude Max subscriptions have higher limits than API keys + +### Mac goes to sleep +- Run `caffeinate -i ./loop/loop.sh` as a belt-and-suspenders approach +- Check that `~/.claude/hooks/prevent-sleep.sh` is executable: `chmod +x ~/.claude/hooks/prevent-sleep.sh` + +### Tasks not getting marked complete +- Ensure your plan uses exact format: `- [ ]` (dash, space, brackets, space) +- The stop hook matches `^\s*[-*]?\s*\[\s*\]` — standard markdown checkboxes + +### Stop hook not working +- Verify `CLAUDE_AUTONOMOUS=1` is set: `echo $CLAUDE_AUTONOMOUS` +- Check hook is registered in `~/.claude/settings.json` +- Test the hook manually: `echo '{}' | bash ~/.claude/hooks/stop-hook-autonomous.sh` + +## Linux Notes + +The system is macOS-focused but works on Linux with minor changes: + +- **Sleep prevention**: Replace `caffeinate` with `systemd-inhibit --what=idle --who=claude-loop --why="Overnight automation" sleep infinity &` or simply disable sleep via `systemctl mask sleep.target` +- **Scheduled retry**: Replace the launchd plist section in `loop.sh` with a `systemd-run --on-calendar` or `at` command +- **Hooks work identically** — they're plain bash scripts + +## Security Notes + +- `--dangerously-skip-permissions` gives Claude **full system access** within the project. Only run on trusted codebases. +- The loop runs as your user — Claude can read/write anything you can +- API keys in `.env.local` are accessible to Claude during the session +- Review commits after an overnight run before pushing to production +- Consider running in a VM or container for additional isolation + +## License + +MIT. Use it however you want. diff --git a/aiui/For Others/setup.sh b/aiui/For Others/setup.sh new file mode 100755 index 00000000..7aef609d --- /dev/null +++ b/aiui/For Others/setup.sh @@ -0,0 +1,496 @@ +#!/usr/bin/env bash +# ============================================================================ +# Claude Code Overnight Automation — One-File Setup +# ============================================================================ +# Run from your project root: +# bash setup.sh +# +# This single script creates everything: +# loop/loop.sh — main orchestrator +# loop/prompt.md — template prompt for Claude +# loop/plan.md — your task checklist +# ~/.claude/hooks/ — sleep prevention + autonomous stop hook +# ~/.claude/settings.json — hook registrations +# ============================================================================ +set -euo pipefail + +BOLD='\033[1m' +DIM='\033[2m' +GREEN='\033[0;32m' +YELLOW='\033[0;33m' +CYAN='\033[0;36m' +RED='\033[0;31m' +NC='\033[0m' + +ok() { echo -e " ${GREEN}+${NC} $1"; } +warn() { echo -e " ${YELLOW}!${NC} $1"; } +err() { echo -e " ${RED}x${NC} $1"; } +info() { echo -e " ${DIM}$1${NC}"; } + +echo "" +echo -e "${BOLD} Claude Code Overnight Automation${NC}" +echo -e " ${DIM}────────────────────────────────────${NC}" +echo "" + +# ── Prerequisites ──────────────────────────────────────────────────────────── +echo -e " ${BOLD}Checking prerequisites...${NC}" +echo "" + +MISSING=0 + +if command -v claude &>/dev/null; then + ok "Claude CLI: $(which claude)" +else + err "Claude CLI not found. Install: https://docs.anthropic.com/en/docs/claude-code" + MISSING=1 +fi + +if command -v git &>/dev/null; then + ok "Git: $(which git)" +else + err "Git not found." + MISSING=1 +fi + +if [[ "$(uname)" == "Darwin" ]]; then + ok "macOS (caffeinate + launchd available)" +else + warn "Not macOS — sleep hooks need Linux equivalents (see README)" +fi + +if git rev-parse --is-inside-work-tree &>/dev/null; then + PROJECT_DIR="$(git rev-parse --show-toplevel)" + ok "Project: $PROJECT_DIR" +else + PROJECT_DIR="$(pwd)" + warn "Not a git repo — using: $PROJECT_DIR" +fi + +[[ "$MISSING" -eq 1 ]] && { echo ""; err "Fix the above and re-run."; exit 1; } + +echo "" + +# ── Create loop/ directory ─────────────────────────────────────────────────── +echo -e " ${BOLD}Creating loop files...${NC}" +echo "" + +LOOP_DIR="$PROJECT_DIR/loop" +mkdir -p "$LOOP_DIR" + +# ── loop.sh (embedded) ────────────────────────────────────────────────────── +if [[ -f "$LOOP_DIR/loop.sh" ]]; then + warn "loop/loop.sh exists — skipping" +else + cat > "$LOOP_DIR/loop.sh" << 'LOOPEOF' +#!/usr/bin/env sh +# Claude Code Overnight Automation — Loop Script +# Usage: ./loop/loop.sh +# Config via env vars: ITERATION_COUNT, ITERATION_DELAY, CLAUDE_AUTONOMOUS, etc. +set -u + +PROJECT_DIR="${CLAUDE_PROJECT_DIR:-$(cd "$(dirname "$0")/.." && pwd)}" +PROMPT_FILE="${PROMPT_FILE:-$PROJECT_DIR/loop/prompt.md}" +LOG_FILE="${LOG_FILE:-$PROJECT_DIR/loop/loop.log}" +ITERATION_COUNT="${ITERATION_COUNT:-10}" +ITERATION_DELAY="${ITERATION_DELAY:-30}" +CLAUDE_BIN="${CLAUDE_BIN:-claude}" +RATE_LIMIT_WAIT="${RATE_LIMIT_WAIT:-3600}" +MAX_RATE_LIMIT_RETRIES="${MAX_RATE_LIMIT_RETRIES:-5}" +CLAUDE_EXIT=0 + +cd "$PROJECT_DIR" + +log() { echo "$1" | tee -a "$LOG_FILE"; } +banner() { + log ""; log "════════════════════════════════════════════════════════════════" + log " $1"; log " $(date '+%Y-%m-%d %H:%M:%S')" + log "════════════════════════════════════════════════════════════════"; log "" +} +section() { log ""; log "────────────────────────────────────────"; log " $1"; log "────────────────────────────────────────"; log ""; } + +plan_has_tasks() { grep -q '^\- \[ \]' "$PROJECT_DIR/loop/plan.md" 2>/dev/null; } +remaining_tasks() { grep -c '^\- \[ \]' "$PROJECT_DIR/loop/plan.md" 2>/dev/null || echo "0"; } +next_task() { grep -m1 '^\- \[ \]' "$PROJECT_DIR/loop/plan.md" 2>/dev/null | sed 's/^- \[ \] //' || echo "(none)"; } + +check_rate_limit() { + [ "${CLAUDE_EXIT:-0}" -eq 0 ] && return 1 + tail -50 "$LOG_FILE" 2>/dev/null | grep -v "^Rate limit" | grep -v "^Sleeping" | grep -v "^═" | grep -v "^─" \ + | grep -qi -e "rate.limit" -e "too.many.requests" -e "429" -e "quota.exceeded" -e "usage.limit" -e "limit.reached" 2>/dev/null +} + +banner "OVERNIGHT AUTOMATION STARTED" +log " Project: $PROJECT_DIR" +log " Prompt: $PROMPT_FILE" +log " Autonomous: ${CLAUDE_AUTONOMOUS:-0}" +log " Iterations: $ITERATION_COUNT (${ITERATION_DELAY}s delay)" +log " Rate limit: wait ${RATE_LIMIT_WAIT}s, retry ${MAX_RATE_LIMIT_RETRIES}x" +log " Tasks left: $(remaining_tasks)" +log " Next task: $(next_task)" +log "" + +i=1; rate_limit_retries=0 +while [ "$i" -le "$ITERATION_COUNT" ]; do + if ! plan_has_tasks; then + banner "ALL TASKS COMPLETE"; log " No remaining [ ] tasks. Stopping."; break + fi + + section "ITERATION $i/$ITERATION_COUNT" + log " Remaining: $(remaining_tasks)"; log " Next: $(next_task)"; log "" + + export CLAUDE_PROJECT_DIR="$PROJECT_DIR" + export CLAUDE_AUTONOMOUS="${CLAUDE_AUTONOMOUS:-1}" + + if [ -f "$PROMPT_FILE" ]; then + log " Starting Claude..."; log "" + "$CLAUDE_BIN" -p --dangerously-skip-permissions < "$PROMPT_FILE" 2>&1 | tee -a "$LOG_FILE" + CLAUDE_EXIT=$?; log ""; log " Exit code: $CLAUDE_EXIT" + else + log " ERROR: $PROMPT_FILE not found"; exit 1 + fi + + if check_rate_limit; then + rate_limit_retries=$((rate_limit_retries + 1)) + if [ "$rate_limit_retries" -ge "$MAX_RATE_LIMIT_RETRIES" ]; then + section "RATE LIMITED — SCHEDULING RETRY" + PLIST_LABEL="com.claude-loop.overnight-retry" + PLIST_PATH="$HOME/Library/LaunchAgents/${PLIST_LABEL}.plist" + RETRY_TIME=$(date -v+${RATE_LIMIT_WAIT}S '+%H:%M' 2>/dev/null || date -d "+${RATE_LIMIT_WAIT} seconds" '+%H:%M') + RETRY_HOUR=$(echo "$RETRY_TIME" | cut -d: -f1); RETRY_MIN=$(echo "$RETRY_TIME" | cut -d: -f2) + cat > "$PLIST_PATH" <<PLIST +<?xml version="1.0" encoding="UTF-8"?> +<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd"> +<plist version="1.0"><dict> + <key>Label</key><string>${PLIST_LABEL}</string> + <key>ProgramArguments</key><array> + <string>/bin/sh</string><string>-c</string> + <string>cd ${PROJECT_DIR} && caffeinate -i ./loop/loop.sh >> ${LOG_FILE} 2>&1; launchctl unload ${PLIST_PATH}; rm -f ${PLIST_PATH}</string> + </array> + <key>StartCalendarInterval</key><dict><key>Hour</key><integer>${RETRY_HOUR}</integer><key>Minute</key><integer>${RETRY_MIN}</integer></dict> + <key>EnvironmentVariables</key><dict> + <key>CLAUDE_AUTONOMOUS</key><string>1</string> + <key>CLAUDE_PROJECT_DIR</key><string>${PROJECT_DIR}</string> + <key>PATH</key><string>/usr/local/bin:/usr/bin:/bin:$HOME/.local/bin</string> + </dict> + <key>StandardOutPath</key><string>${LOG_FILE}</string> + <key>StandardErrorPath</key><string>${LOG_FILE}</string> +</dict></plist> +PLIST + launchctl load "$PLIST_PATH" 2>/dev/null || true + log " Scheduled retry at ~${RETRY_TIME}"; exit 0 + fi + section "RATE LIMITED — WAITING" + log " Attempt $rate_limit_retries/$MAX_RATE_LIMIT_RETRIES"; log " Sleeping ${RATE_LIMIT_WAIT}s..." + sleep "$RATE_LIMIT_WAIT" + if ! plan_has_tasks; then banner "ALL TASKS COMPLETE"; break; fi + log " Retrying..."; continue + fi + + rate_limit_retries=0 + section "ITERATION $i COMPLETE" + log " Remaining: $(remaining_tasks)"; log " Next: $(next_task)" + i=$((i + 1)) + if [ "$i" -le "$ITERATION_COUNT" ] && [ "$ITERATION_DELAY" -gt 0 ]; then + log " Pausing ${ITERATION_DELAY}s..."; sleep "$ITERATION_DELAY" + fi +done + +banner "LOOP FINISHED" +log " Completed $((i - 1)) iterations"; log " Remaining: $(remaining_tasks)"; log "" +LOOPEOF + chmod +x "$LOOP_DIR/loop.sh" + ok "Created loop/loop.sh" +fi + +# ── prompt.md and plan.md are created later after interactive input ──────── + +echo "" + +# ── Install hooks ──────────────────────────────────────────────────────────── +echo -e " ${BOLD}Installing hooks...${NC}" +echo "" + +HOOKS_DIR="$HOME/.claude/hooks" +mkdir -p "$HOOKS_DIR" + +# prevent-sleep.sh +if [[ -f "$HOOKS_DIR/prevent-sleep.sh" ]]; then + warn "prevent-sleep.sh exists — skipping" +else + cat > "$HOOKS_DIR/prevent-sleep.sh" << 'HOOKEOF' +#!/usr/bin/env bash +set -euo pipefail +PID_FILE="${CLAUDE_CAFFEINATE_PID:-$HOME/.claude/caffeinate.pid}" +if [[ -f "$PID_FILE" ]]; then + old_pid=$(cat "$PID_FILE") + kill -0 "$old_pid" 2>/dev/null && kill "$old_pid" 2>/dev/null || true + rm -f "$PID_FILE" +fi +caffeinate -i & +echo $! > "$PID_FILE" +exit 0 +HOOKEOF + chmod +x "$HOOKS_DIR/prevent-sleep.sh" + ok "Installed ~/.claude/hooks/prevent-sleep.sh" +fi + +# stop-hook-autonomous.sh +if [[ -f "$HOOKS_DIR/stop-hook-autonomous.sh" ]]; then + warn "stop-hook-autonomous.sh exists — skipping" +else + cat > "$HOOKS_DIR/stop-hook-autonomous.sh" << 'HOOKEOF' +#!/usr/bin/env bash +# "Ralph Wiggum" — blocks Claude from stopping until all plan tasks are done. +# Requires CLAUDE_AUTONOMOUS=1 to activate. +set -euo pipefail +BASE="${CLAUDE_PROJECT_DIR:-}" +if [[ -z "$BASE" ]] && command -v jq &>/dev/null; then + BASE=$(jq -r '.cwd // empty' 2>/dev/null || true) +fi +[[ -z "$BASE" ]] && BASE="$(pwd)" +PLAN_FILE="${CLAUDE_PLAN_FILE:-plan.md}" +ALT_FILES="loop/plan.md todo.md loop/todo.md" +AUTO_SLEEP_HOOK="$HOME/.claude/hooks/allow-sleep.sh" +plan="" +for f in "$PLAN_FILE" $ALT_FILES; do + [[ -z "$f" ]] && continue + if [[ "$f" == /* ]]; then path="$f"; else path="$BASE/$f"; fi + if [[ -f "$path" ]]; then plan="$path"; break; fi +done +if [[ -z "${CLAUDE_AUTONOMOUS:-}" ]] || [[ "$CLAUDE_AUTONOMOUS" == "0" ]]; then + [[ -x "$AUTO_SLEEP_HOOK" ]] && "$AUTO_SLEEP_HOOK" || true; exit 0 +fi +if [[ -z "$plan" ]] || [[ ! -f "$plan" ]]; then + [[ -x "$AUTO_SLEEP_HOOK" ]] && "$AUTO_SLEEP_HOOK" || true; exit 0 +fi +incomplete=$(grep -c -E '^\s*[-*]?\s*\[\s*\]' "$plan" 2>/dev/null || echo 0) +if [[ "${incomplete:-0}" -gt 0 ]]; then + echo '{"decision":"block","reason":"Plan has '"$incomplete"' incomplete task(s). Continue with the next item."}' + exit 0 +fi +[[ -x "$AUTO_SLEEP_HOOK" ]] && "$AUTO_SLEEP_HOOK" || true +exit 0 +HOOKEOF + chmod +x "$HOOKS_DIR/stop-hook-autonomous.sh" + ok "Installed ~/.claude/hooks/stop-hook-autonomous.sh" +fi + +# allow-sleep.sh +if [[ -f "$HOOKS_DIR/allow-sleep.sh" ]]; then + warn "allow-sleep.sh exists — skipping" +else + cat > "$HOOKS_DIR/allow-sleep.sh" << 'HOOKEOF' +#!/usr/bin/env bash +set -euo pipefail +PID_FILE="${CLAUDE_CAFFEINATE_PID:-$HOME/.claude/caffeinate.pid}" +if [[ -f "$PID_FILE" ]]; then + pid=$(cat "$PID_FILE") + kill -0 "$pid" 2>/dev/null && kill "$pid" 2>/dev/null || true + rm -f "$PID_FILE" +fi +exit 0 +HOOKEOF + chmod +x "$HOOKS_DIR/allow-sleep.sh" + ok "Installed ~/.claude/hooks/allow-sleep.sh" +fi + +echo "" + +# ── Register hooks in settings.json ───────────────────────────────────────── +echo -e " ${BOLD}Configuring Claude settings...${NC}" +echo "" + +SETTINGS_FILE="$HOME/.claude/settings.json" + +if [[ -f "$SETTINGS_FILE" ]]; then + cp "$SETTINGS_FILE" "${SETTINGS_FILE}.backup.$(date +%s)" + info "Backed up settings.json" +fi + +if [[ -f "$SETTINGS_FILE" ]] && grep -q "stop-hook-autonomous" "$SETTINGS_FILE" 2>/dev/null; then + ok "Hooks already registered" +else + if command -v python3 &>/dev/null; then + python3 << 'PYEOF' +import json, os +p = os.path.expanduser("~/.claude/settings.json") +h = os.path.expanduser("~/.claude/hooks") +s = json.load(open(p)) if os.path.exists(p) else {} +if "hooks" not in s: s["hooks"] = {} +for event, script in [("UserPromptSubmit","prevent-sleep.sh"),("Stop","stop-hook-autonomous.sh"),("SessionEnd","allow-sleep.sh")]: + if event not in s["hooks"]: s["hooks"][event] = [] + if not any(script in json.dumps(x) for x in s["hooks"][event]): + s["hooks"][event].append({"matcher":"","hooks":[{"type":"command","command":f"{h}/{script}"}]}) +with open(p,"w") as f: json.dump(s,f,indent=2); f.write("\n") +PYEOF + ok "Registered hooks in settings.json" + else + warn "python3 not found — add hooks to ~/.claude/settings.json manually" + fi +fi + +echo "" + +# ══════════════════════════════════════════════════════════════════════════════ +# INTERACTIVE SETUP — collect tasks and project context +# ══════════════════════════════════════════════════════════════════════════════ + +echo -e " ${DIM}════════════════════════════════════════════════════════════${NC}" +echo "" +echo -e " ${BOLD}Now let's set up your tasks and project context.${NC}" +echo "" + +# ── Collect tasks ──────────────────────────────────────────────────────────── +if [[ -f "$LOOP_DIR/plan.md" ]]; then + echo -e " ${YELLOW}loop/plan.md already exists.${NC}" + echo -ne " Overwrite with new tasks? [y/N] " + read -r OVERWRITE_PLAN + [[ "$OVERWRITE_PLAN" =~ ^[Yy] ]] || SKIP_PLAN=1 +fi + +if [[ "${SKIP_PLAN:-0}" != "1" ]]; then + echo -e " ${BOLD}Enter your tasks${NC} — one per line." + echo -e " ${DIM}Be specific. Claude will execute these literally.${NC}" + echo -e " ${DIM}Example: \"Add JWT authentication with refresh tokens\"${NC}" + echo -e " ${DIM}Press Enter on an empty line when done.${NC}" + echo "" + + TASKS=() + TASK_NUM=1 + while true; do + echo -ne " ${CYAN}Task $TASK_NUM:${NC} " + read -r TASK_LINE + [[ -z "$TASK_LINE" ]] && break + TASKS+=("$TASK_LINE") + TASK_NUM=$((TASK_NUM + 1)) + done + + if [[ ${#TASKS[@]} -eq 0 ]]; then + warn "No tasks entered — writing example plan" + cat > "$LOOP_DIR/plan.md" << 'PLANEOF' +# Task Plan + +## Phase 1 +- [ ] **1.1** — First task description +- [ ] **1.2** — Second task description + +## Final +- [ ] **FINAL** — Run full test suite, fix failures, tag release +PLANEOF + else + echo "# Task Plan" > "$LOOP_DIR/plan.md" + echo "" >> "$LOOP_DIR/plan.md" + i=1 + for task in "${TASKS[@]}"; do + echo "- [ ] **$i** — $task" >> "$LOOP_DIR/plan.md" + i=$((i + 1)) + done + echo "" >> "$LOOP_DIR/plan.md" + echo "- [ ] **FINAL** — Run full test suite, fix any failures" >> "$LOOP_DIR/plan.md" + ok "Wrote ${#TASKS[@]} tasks to loop/plan.md" + fi + echo "" +fi + +# ── Collect project context ────────────────────────────────────────────────── +if [[ -f "$LOOP_DIR/prompt.md" ]] && [[ "${SKIP_PLAN:-0}" == "1" ]]; then + SKIP_PROMPT=1 +fi + +if [[ "${SKIP_PROMPT:-0}" != "1" ]]; then + echo -e " ${BOLD}Project context${NC} — tell Claude about your project." + echo -e " ${DIM}Stack, test commands, coding style, anything important.${NC}" + echo -e " ${DIM}Example: \"TypeScript + React, run 'npm test', use Prettier formatting\"${NC}" + echo -e " ${DIM}Press Enter on an empty line when done (or just Enter to skip).${NC}" + echo "" + + RULES=() + while true; do + echo -ne " ${CYAN}>${NC} " + read -r RULE_LINE + [[ -z "$RULE_LINE" ]] && break + RULES+=("$RULE_LINE") + done + + # Build prompt.md + cat > "$LOOP_DIR/prompt.md" << 'PROMPTEOF' +You are executing a project roadmap autonomously. Read these files first: + +1. `loop/plan.md` — Your task checklist (mark items `- [x]` as you complete them) +2. Read any project documentation (README, CLAUDE.md, etc.) for conventions + +PROMPTEOF + + if [[ ${#RULES[@]} -gt 0 ]]; then + echo "## Project Rules" >> "$LOOP_DIR/prompt.md" + echo "" >> "$LOOP_DIR/prompt.md" + for rule in "${RULES[@]}"; do + echo "- $rule" >> "$LOOP_DIR/prompt.md" + done + echo "" >> "$LOOP_DIR/prompt.md" + ok "Added ${#RULES[@]} project rules to prompt" + fi + + cat >> "$LOOP_DIR/prompt.md" << 'PROMPTEOF' +## For each task in loop/plan.md: + +1. Find the first unchecked `- [ ]` item +2. Understand what needs to be done +3. Implement it following the project's existing patterns and conventions +4. Run the project's type checker / linter / tests — fix all errors +5. Commit with a conventional message: `type(scope): description` +6. Mark the task `- [x]` in `loop/plan.md` +7. Move to the next unchecked task immediately + +## Rules + +- If tests fail, fix them before moving on +- If a task is difficult, make at least 30 genuine attempts before skipping +- Always run linter + type checker after code changes +- Do not stop until all tasks are checked or you are rate limited +PROMPTEOF + ok "Created loop/prompt.md" + echo "" +fi + +# ── Summary & launch ───────────────────────────────────────────────────────── +TASK_COUNT=$(grep -c '^\- \[ \]' "$LOOP_DIR/plan.md" 2>/dev/null || echo "0") + +echo -e " ${DIM}════════════════════════════════════════════════════════════${NC}" +echo "" +echo -e " ${BOLD}${GREEN}Ready to go!${NC}" +echo "" +echo -e " ${BOLD}Tasks:${NC} $TASK_COUNT in loop/plan.md" +echo -e " ${BOLD}Prompt:${NC} loop/prompt.md" +echo -e " ${BOLD}Log:${NC} loop/loop.log (created on first run)" +echo "" +echo -e " ${DIM}────────────────────────────────────────────────────────────${NC}" +echo "" +echo -e " ${BOLD}To start:${NC}" +echo -e " ${GREEN}./loop/loop.sh${NC}" +echo "" +echo -e " ${BOLD}To start with sleep prevention (macOS):${NC}" +echo -e " ${GREEN}caffeinate -i ./loop/loop.sh${NC}" +echo "" +echo -e " ${BOLD}Monitor:${NC}" +echo -e " ${DIM}tail -f loop/loop.log${NC}" +echo "" +echo -e " ${BOLD}Config:${NC}" +echo -e " ${DIM}CLAUDE_AUTONOMOUS=0${NC} — let Claude stop freely (testing)" +echo -e " ${DIM}ITERATION_COUNT=20${NC} — more iterations" +echo -e " ${DIM}ITERATION_DELAY=60${NC} — longer pause between rounds" +echo "" +echo -e " ${DIM}────────────────────────────────────────────────────────────${NC}" +echo "" +echo -ne " ${BOLD}Start the loop now?${NC} [y/N] " +read -r START_NOW + +if [[ "$START_NOW" =~ ^[Yy] ]]; then + echo "" + echo -e " ${GREEN}Launching...${NC}" + echo "" + exec ./loop/loop.sh +fi + +echo "" +echo -e " ${DIM}Run ./loop/loop.sh whenever you're ready.${NC}" +echo "" diff --git a/aiui/PLAN.md b/aiui/PLAN.md new file mode 100644 index 00000000..ee8fdc99 --- /dev/null +++ b/aiui/PLAN.md @@ -0,0 +1,1051 @@ +# AIUI Project Plan: Roadmap, Progress Tracking & Automated Coding Tasks + +## Context + +AIUI is at ~65% architectural completeness. The chat + content surface experience is mature (11 content types, streaming, responsive layout, glass morphism design system). However, critical infrastructure is missing: no persistent storage beyond dev mode, zero error boundaries, no unit tests, plugin system spec'd but unused, no encryption. + +The user has Claude Code automation set up for late-night unattended runs. This plan provides: +1. **Progress tracking automation** — PROGRESS.md updated on every commit/push +2. **Detailed task specs** — granular enough for automated Claude sessions to execute autonomously + +There are **6 active worktrees** — all automation must work across branches. + +--- + +## Part 1: Progress Tracking Automation + +### Task 1.1: Create PROGRESS.md + +**File**: `PROGRESS.md` (repo root) + +Create a structured progress document with: +- Project status summary (current milestone, % complete) +- Roadmap checklist (M0–M7, matching Part 2 below) +- Session log section (auto-populated by hook) + +Format: +```markdown +# AIUI Progress + +## Current Status +**Active Milestone**: M1 — Stability & Polish +**Overall**: ~65% architectural completeness + +## Roadmap +### M0: Foundation ✅ +- [x] Chat interface with streaming +- [x] 11 content type renderers +- [x] Responsive layout (mobile + desktop) +- [x] Glass morphism design system +- [x] Claude proxy + web search +- [x] PWA support + +### M1: Stability & Polish ✅ +- [x] Persistent storage (IndexedDB) +- [x] Error boundaries +- [x] Unit tests for composables +- [x] E2E test coverage +- [x] CI pipeline + +(... remaining milestones from Part 2 ...) + +## Session Log +<!-- Auto-populated by post-push hook --> +``` + +### Task 1.2: Create post-push progress hook + +**File**: `.claude/hooks/post-push-progress.sh` + +A `PostToolUse` hook for Bash that: +1. Reads stdin JSON, extracts `tool_input.command` +2. Checks if command contains `git push` or `git commit` +3. If match: runs `git log --oneline` for commits on current branch not on `main` +4. Reads current PROGRESS.md roadmap section to identify active milestone +5. Outputs JSON with `hookSpecificOutput` containing a message asking Claude to update the Session Log in PROGRESS.md + +The hook script should: +- Extract branch name via `git branch --show-current` +- Get commit list via `git log --oneline main..HEAD` (or last 5 commits if no divergence) +- Format the output as structured feedback + +### Task 1.3: Register the hook + +**File**: `.claude/settings.json` + +Add a PostToolUse entry: +```json +{ + "hooks": { + "PreToolUse": [ ... existing ... ], + "PostToolUse": [ + { + "matcher": "Bash", + "hooks": [ + { + "type": "command", + "command": "\"$CLAUDE_PROJECT_DIR\"/.claude/hooks/post-push-progress.sh" + } + ] + } + ] + } +} +``` + +--- + +## Part 2: Long-Term Delivery Roadmap + +### M0: Foundation ✅ (Complete) +All done — chat, renderers, layout, design system, proxy, PWA. + +--- + +### M1: Stability & Polish (Infrastructure) + +#### Task M1.1: IndexedDB Persistent Storage + +**Why**: Conversations are lost on page refresh in production. Currently only persisted via dev-mode `/api/dev-chats` Vite middleware. + +**Files to modify**: +- `packages/app/src/stores/chat.ts` (198 lines) — add IndexedDB adapter +- Create `packages/app/src/utils/idb-storage.ts` — IndexedDB wrapper + +**Implementation**: +1. Create `idb-storage.ts` with these functions: + ```ts + export async function openDB(): Promise<IDBDatabase> + export async function saveConversation(conv: Conversation): Promise<void> + export async function loadAllConversations(): Promise<Map<string, Conversation>> + export async function deleteConversation(id: string): Promise<void> + ``` + - DB name: `aiui-store`, version 1 + - Object store: `conversations`, keyPath: `id` + - Index on `updatedAt` for sorted retrieval + +2. Modify `chat.ts`: + - Replace `saveServerChats()` (line 51-62) with `saveConversation()` calls + - Replace `loadServerChats()` (line 64-84) with `loadAllConversations()` + - Keep dev-chats middleware as fallback when `indexedDB` unavailable + - Debounced save stays at 800ms + +**Acceptance criteria**: +- Conversations survive page refresh +- New conversations appear after reload +- Delete removes from IndexedDB +- Falls back to dev-chats middleware if IDB unavailable +- `pnpm typecheck` passes + +#### Task M1.2: Error Boundaries + +**Why**: Zero `onErrorCaptured` usage. Any component error crashes the whole app. + +**Files to modify**: +- Create `packages/app/src/components/ui/ErrorBoundary.vue` +- `packages/app/src/pages/ChatPage.vue` (597 lines) — wrap major sections +- `packages/app/src/components/chat/ChatWindow.vue` (248 lines) — wrap message list +- `packages/app/src/components/content/ContentPanel.vue` — wrap detail views + +**Implementation**: +1. Create `ErrorBoundary.vue`: + ```vue + <script setup lang="ts"> + import { ref, onErrorCaptured } from 'vue' + defineProps<{ fallbackMessage?: string }>() + const error = ref<Error | null>(null) + const hasError = ref(false) + onErrorCaptured((err) => { + error.value = err instanceof Error ? err : new Error(String(err)) + hasError.value = true + return false // prevent propagation + }) + function retry() { hasError.value = false; error.value = null } + </script> + <template> + <slot v-if="!hasError" /> + <div v-else class="glass-card p-4 text-center"> + <p class="text-white/60">{{ fallbackMessage || 'Something went wrong' }}</p> + <button class="glass-button-sm mt-2" @click="retry">Retry</button> + </div> + </template> + ``` + +2. Wrap in ChatPage.vue: + - Wrap chat column (around `<ChatWindow>`) + - Wrap content panel column (around `<ContentPanel>`) + - Wrap detail view column (around `<DetailView>`) + +3. Wrap in ChatWindow.vue: + - Wrap the message loop (v-for of ChatMessage) + +4. Wrap in ContentPanel.vue: + - Wrap each grid component render + - Wrap detail component render + +**Acceptance criteria**: +- A failing renderer shows error card with retry button +- Chat continues working if content panel errors +- Content panel continues if one card errors +- `pnpm typecheck` passes + +#### Task M1.3: Unit Tests for contentExtraction + +**Why**: `contentExtraction.ts` is 967 lines of regex parsing with zero tests. It's the most critical composable. + +**File to create**: `packages/app/src/__tests__/contentExtraction.test.ts` + +**Tests to write** (use vitest): +```ts +describe('contentExtraction', () => { + describe('extractAllFilms', () => { + it('extracts film_ext tags with title, year, director') + it('extracts film:id tags and looks up from library') + it('returns empty array for text with no film tags') + it('handles multiple films in one message') + it('handles malformed tags gracefully') + }) + + describe('extractAllSongs', () => { + it('extracts song_ext tags with title, artist, year') + it('extracts song:id tags from library') + it('extracts songs from markdown bold patterns') + it('deduplicates songs by title+artist') + }) + + describe('extractAllPodcasts', () => { + it('extracts podcast_ext tags') + it('extracts podcast:id from library') + }) + + describe('extractAllBooks', () => { + it('extracts book_ext tags with title, author, year') + it('handles optional fields') + }) + + describe('extractAllTVSeries', () => { + it('extracts tv_ext tags') + it('parses creator and network fields') + }) + + describe('extractAllPlaces', () => { + it('extracts place_ext tags with all fields') + it('handles missing optional fields (rating, price)') + }) + + describe('extractMagazineSections', () => { + it('extracts sections from markdown headings') + it('captures content between headings') + it('extracts hero images') + }) + + describe('stripContentTags', () => { + it('removes all tag types from text') + it('preserves non-tag content') + it('handles nested/adjacent tags') + }) + + describe('extractBoldDomainLinks', () => { + it('extracts **domain.com** patterns with URLs') + it('extracts markdown links') + }) +}) +``` + +**How to run**: `pnpm test` (vitest via turbo) + +**Acceptance criteria**: +- All tests pass +- Covers the 10 main extraction functions +- Tests edge cases (empty input, malformed tags, duplicates) +- `pnpm test` exits 0 + +#### Task M1.4: Unit Tests for useAI + +**File to create**: `packages/app/src/__tests__/useAI.test.ts` + +**Tests to write**: +```ts +describe('useAI', () => { + describe('provider selection', () => { + it('defaults to first available provider') + it('switches provider via setActiveProvider') + it('lists available models for active provider') + }) + + describe('context injection', () => { + it('includes film library in system prompt') + it('includes song library in system prompt') + it('includes content tag format instructions') + }) + + describe('sendMessage', () => { + it('adds user message to store') + it('creates assistant message placeholder') + it('sets isStreaming to true during stream') + it('sets isStreaming to false after completion') + it('handles stream errors gracefully') + }) + + describe('stopGeneration', () => { + it('aborts active stream') + it('sets isStreaming to false') + }) +}) +``` + +**Note**: Will need to mock `fetch` for streaming tests. Use vitest's `vi.fn()`. + +**Acceptance criteria**: +- All tests pass with mocked fetch/SSE +- `pnpm test` exits 0 + +#### Task M1.5: E2E Test Expansion + +**File to modify**: `packages/app/e2e/content-surfaces.spec.ts` + +**Tests to add**: +```ts +test('sends a message and receives streaming response') +test('content panel shows film cards when AI mentions films') +test('clicking a film card opens detail view') +test('mobile viewport shows full-screen overlay for content') +test('stop button halts generation') +test('web search toggle works') +test('new conversation clears messages') +test('panel side toggle switches layout') +``` + +**Acceptance criteria**: +- `pnpm test:e2e` passes (needs dev server running) + +--- + +### M2: Content Experience (UX) + +#### Task M2.1: Markdown Rendering in Chat + +**Why**: Chat messages display plain text. Markdown (bold, italic, links, code blocks, lists) should render properly. + +**Files to modify**: +- `packages/app/src/components/chat/ChatMessage.vue` (333 lines) +- Add `markdown-it` as dependency + +**Implementation**: +1. `pnpm add markdown-it` + `pnpm add -D @types/markdown-it` in `packages/app` +2. In ChatMessage.vue: + - Import and configure markdown-it with safe defaults (no HTML) + - After `stripContentTags()`, render remaining text through markdown-it + - Use `v-html` with the sanitized markdown output + - Add CSS for rendered markdown (code blocks, lists, links) in main.css + - Ensure content tags are extracted BEFORE markdown rendering + +**Security**: markdown-it with `html: false` prevents XSS. No raw HTML passthrough. + +**Acceptance criteria**: +- Bold, italic, links, code blocks, lists render in chat +- Content tags still extract correctly (films, songs, etc.) +- No XSS possible +- `pnpm typecheck` passes + +#### Task M2.2: Virtual Scrolling for Chat + +**Why**: Long conversations with many messages cause scroll jank. + +**Files to modify**: +- `packages/app/src/components/chat/ChatWindow.vue` +- Add `@tanstack/vue-virtual` dependency + +**Implementation**: +1. `pnpm add @tanstack/vue-virtual` in `packages/app` +2. Replace the message `v-for` loop with `useVirtualizer`: + - Estimate row heights (user messages ~60px, assistant ~200px) + - Use dynamic measurement for actual heights + - Maintain scroll-to-bottom behavior during streaming + - Keep overscan at 5 items + +**Acceptance criteria**: +- Scrolling is smooth with 100+ messages +- Auto-scroll to bottom during streaming still works +- `pnpm typecheck` passes + +#### Task M2.3: Music Source Resolution + +**Why**: PlayerBar exists but music source resolution is incomplete. Iframe embedding untested. + +**Files to modify**: +- `packages/app/src/composables/usePlayer.ts` (185 lines) +- `packages/app/src/components/player/PlayerBar.vue` (165 lines) + +**Implementation**: +1. In usePlayer.ts: + - Add queue management: `queue: ShallowRef<Song[]>`, `currentIndex: Ref<number>` + - Add `playNext()`, `playPrevious()`, `addToQueue(song)` methods + - Fix iframe playback (lines 72-83): create Plyr instance for iframes too + - Add retry logic for failed music searches (try next source) + +2. In PlayerBar.vue: + - Add next/previous buttons + - Show queue count + - Add queue panel (slide-up from player) + +**Acceptance criteria**: +- Can play songs from search results +- Next/previous navigation works +- Queue persists across song changes +- `pnpm typecheck` passes + +#### Task M2.4: Nostr Feed Integration + +**Why**: NostrGrid.vue exists but is non-functional. No relay connection. + +**Files to modify**: +- `packages/app/src/components/content/NostrGrid.vue` +- Create `packages/app/src/composables/useNostr.ts` + +**Implementation**: +1. Create `useNostr.ts`: + - Connect to public relays (wss://relay.damus.io, wss://nos.lol, wss://relay.snort.social) + - Use raw WebSocket (no nostr-tools dependency to keep bundle small) + - Subscribe to kind:1 (text notes) with limit 50 + - Parse NIP-01 event format manually + - Export `useNostr()` returning `{ events, isConnected, connect, disconnect }` + +2. Update NostrGrid.vue: + - Use `useNostr()` composable + - Display events as cards with author npub (truncated), content, timestamp + - Lazy-load on tab activation only + +**Acceptance criteria**: +- Nostr tab shows real posts from public relays +- Connection/disconnection is clean (no leaked WebSockets) +- Handles relay errors gracefully +- `pnpm typecheck` passes + +--- + +### M3: Plugin System (Infrastructure) + +#### Task M3.1: Activate Plugin Registry at Runtime + +**Why**: `packages/core/src/plugins/registry.ts` exists with `registerPlugin()` but nothing calls it. + +**Files to modify**: +- `packages/app/src/main.ts` (26 lines) — add plugin initialization +- Create `packages/app/src/plugins/index.ts` — plugin bootstrap +- Create `packages/app/src/plugins/claude-provider.ts` — first AI provider plugin + +**Implementation**: +1. Create `plugins/index.ts`: + ```ts + export async function initializePlugins() { + // Register built-in plugins + const { claudeProvider } = await import('./claude-provider') + registerPlugin(claudeProvider) + } + ``` + +2. Create `plugins/claude-provider.ts`: + - Implement `AIProviderAdapter` interface from `@aiui/core` + - Wrap existing `useAI.ts` streaming logic as a plugin + - Export as a Tier 1 (trusted) plugin + +3. In `main.ts`: + - Call `initializePlugins()` before app mount + - Make it async with error handling + +**Acceptance criteria**: +- Plugin registry has at least 1 registered plugin at runtime +- Chat still works through the plugin adapter +- `getPluginsByType('ai-provider')` returns the Claude provider +- `pnpm typecheck` passes + +#### Task M3.2: Renderer Plugin Registration + +**Why**: Content renderers are hardcoded. Making them pluggable enables community extensions. + +**Files to modify**: +- Create `packages/app/src/plugins/renderers/film-renderer.ts` +- Create `packages/app/src/plugins/renderers/song-renderer.ts` +- Modify `packages/app/src/plugins/index.ts` — register renderers +- Modify `packages/app/src/components/content/ContentPanel.vue` — use registry lookups + +**Implementation**: +1. Create renderer plugins for film and song (as examples): + ```ts + const filmRenderer: RendererDefinition = { + id: 'film', + name: 'Film Renderer', + contentType: 'film', + surfaces: ['chat-preview', 'panel-preview', 'panel-play'], + chatPreview: FilmCard, + panelPreview: FilmGrid, + panelPlay: FilmDetail, + } + ``` + +2. Register in `plugins/index.ts` via `registerRenderer()` + +3. In ContentPanel.vue, look up renderers via `getRendererForContentType()` instead of hardcoded imports (gradual migration — start with film/song, keep others hardcoded) + +**Acceptance criteria**: +- Film and song renderers registered via plugin system +- `getAllRenderers()` returns registered renderers +- Content panel still renders correctly +- `pnpm typecheck` passes + +--- + +### M4: Social & Discovery (UX) + +#### Task M4.1: Social Embeds + +**Why**: Nostr notes referenced in chat should render as rich embeds, not raw text. + +**Files to create/modify**: +- Create `packages/app/src/components/chat/NostrEmbed.vue` +- Modify `packages/app/src/components/chat/ChatMessage.vue` — detect and render nostr: URIs + +**Implementation**: +1. Create `NostrEmbed.vue`: + - Accept `noteId` or `npub` prop + - Fetch note from relays (reuse `useNostr` composable from M2.4) + - Display: author npub (truncated), content, timestamp, relay source + - Glass card styling matching existing design system + - Loading skeleton while fetching + - Error state if note not found + +2. In ChatMessage.vue: + - Regex detect `nostr:note1...`, `nostr:npub1...`, `nostr:nevent1...` patterns + - Replace with `<NostrEmbed>` component inline + - Handle bech32 decoding (NIP-19) for note/npub/nevent + +**Acceptance criteria**: +- `nostr:note1...` in chat renders as embedded card +- `nostr:npub1...` renders as profile card +- Graceful fallback if relay unreachable +- `pnpm typecheck` passes + +#### Task M4.2: Federated Search + +**Why**: Search currently only queries web. Should search across all content types simultaneously. + +**Files to create/modify**: +- Create `packages/app/src/composables/useFederatedSearch.ts` +- Modify `packages/app/src/components/chat/ChatInput.vue` — add search mode +- Create `packages/app/src/components/ui/SearchResults.vue` + +**Implementation**: +1. Create `useFederatedSearch.ts`: + ```ts + interface SearchResult { + type: 'film' | 'song' | 'podcast' | 'book' | 'article' | 'place' | 'web' + title: string + subtitle: string + thumbnail?: string + data: unknown // type-specific payload + } + export function useFederatedSearch() { + // Search across: film library, song library, podcast library, web (DDG/SearXNG) + // Return unified results sorted by relevance + // Debounce input (150ms) + // Cancel previous searches on new input + } + ``` + +2. In ChatInput.vue: + - Add `/search` command prefix detection + - When typing after `/search`, show SearchResults overlay above input + - Selecting a result inserts it as a content reference in the message + +3. Create SearchResults.vue: + - Grouped by content type with type icons + - Keyboard navigation (arrow keys + enter) + - Glass morphism dropdown styling + +**Acceptance criteria**: +- `/search matrix` returns films, songs, articles matching "matrix" +- Results grouped by type +- Selecting a result works +- `pnpm typecheck` passes + +#### Task M4.3: Bookmarks/Favorites + +**Why**: Users can't save interesting content items for later. + +**Files to create/modify**: +- Create `packages/app/src/stores/favorites.ts` — Pinia store +- Create `packages/app/src/components/ui/FavoriteButton.vue` +- Create `packages/app/src/components/content/FavoritesGrid.vue` +- Modify content card components (FilmCard, SongCard, etc.) — add favorite button +- Modify `packages/app/src/components/content/ContentPanel.vue` — add Favorites tab + +**Implementation**: +1. Create `favorites.ts` Pinia store: + ```ts + interface FavoriteItem { + id: string + type: 'film' | 'song' | 'podcast' | 'book' | 'tv' | 'place' | 'article' + title: string + data: unknown + savedAt: number + } + // Persist to IndexedDB (reuse idb-storage from M1.1) + // Methods: addFavorite, removeFavorite, isFavorited, getFavoritesByType + ``` + +2. Create `FavoriteButton.vue`: + - Heart icon toggle (outline = not saved, filled = saved) + - Animate on toggle (scale bounce) + - Bitcoin orange when favorited + +3. Create `FavoritesGrid.vue`: + - Tab in ContentPanel showing all saved items + - Filter by content type + - Sort by date saved + - Remove from favorites via swipe or button + +4. Add FavoriteButton to existing cards: FilmCard, SongCard, BookCard, etc. + +**Acceptance criteria**: +- Can favorite/unfavorite any content item +- Favorites persist across page refresh (IndexedDB) +- Favorites tab shows all saved items +- Filter by type works +- `pnpm typecheck` passes + +--- + +### M5: Security & Privacy (Infrastructure) + +#### Task M5.1: E2E Encryption + +**Why**: Conversations stored in IndexedDB are plaintext. Need encryption at rest. + +**Files to create/modify**: +- Create `packages/app/src/utils/crypto.ts` +- Modify `packages/app/src/utils/idb-storage.ts` — encrypt before write, decrypt on read + +**Implementation**: +1. Create `crypto.ts`: + ```ts + // Use Web Crypto API (no external dependencies) + export async function deriveKey(password: string, salt: Uint8Array): Promise<CryptoKey> + // PBKDF2, 100K iterations, SHA-256 + + export async function encrypt(data: string, key: CryptoKey): Promise<{ ciphertext: ArrayBuffer; iv: Uint8Array }> + // AES-256-GCM, random 12-byte IV + + export async function decrypt(ciphertext: ArrayBuffer, iv: Uint8Array, key: CryptoKey): Promise<string> + // AES-256-GCM decrypt + + export async function generateSalt(): Promise<Uint8Array> + // 16 random bytes + ``` + +2. Modify `idb-storage.ts`: + - Add optional encryption parameter to save/load functions + - When `VITE_DISABLE_CRYPTO=true` (dev mode), skip encryption + - Store salt alongside encrypted data + - Key derived from user passphrase (prompted on first use) + +**Acceptance criteria**: +- Conversations encrypted in IndexedDB when crypto enabled +- Dev mode (`VITE_DISABLE_CRYPTO=true`) bypasses encryption +- Decryption with wrong passphrase fails gracefully +- `pnpm typecheck` passes +- Unit tests for encrypt/decrypt round-trip + +#### Task M5.2: Encrypted Storage Layer + +**Why**: All IndexedDB data (conversations, favorites, settings) should use the encryption layer. + +**Files to modify**: +- Modify `packages/app/src/stores/favorites.ts` — use encrypted storage +- Create `packages/app/src/components/ui/PassphraseDialog.vue` +- Modify `packages/app/src/main.ts` — prompt for passphrase on startup + +**Implementation**: +1. Create `PassphraseDialog.vue`: + - Modal dialog with passphrase input + - "Remember for this session" checkbox (holds key in memory) + - Create new / enter existing passphrase flow + - Glass card styling, min 16px font (no iOS zoom) + +2. Wire encryption into all storage operations: + - Conversations (chat.ts store) + - Favorites (favorites.ts store) + - Future: settings, API keys + +**Acceptance criteria**: +- First launch prompts for passphrase creation +- Subsequent launches prompt for passphrase entry +- Wrong passphrase shows error, does not corrupt data +- Session key held in memory (not persisted) +- `pnpm typecheck` passes + +#### Task M5.3: API Key Vault + +**Why**: API keys (Claude, OpenRouter) are currently stored in plaintext localStorage. + +**Files to create/modify**: +- Create `packages/app/src/utils/key-vault.ts` +- Create `packages/app/src/components/settings/ApiKeyManager.vue` +- Modify `packages/app/src/composables/useAI.ts` — read keys from vault + +**Implementation**: +1. Create `key-vault.ts`: + ```ts + // Encrypted storage for API keys using crypto.ts + export async function storeApiKey(provider: string, key: string): Promise<void> + export async function getApiKey(provider: string): Promise<string | null> + export async function deleteApiKey(provider: string): Promise<void> + export async function listProviders(): Promise<string[]> + // Keys encrypted with session-derived key from passphrase + // Stored in dedicated IndexedDB object store: 'api-keys' + ``` + +2. Create `ApiKeyManager.vue`: + - List configured providers + - Add/remove API keys + - Keys masked in UI (show last 4 chars) + - Test connection button per provider + +3. In `useAI.ts`: + - Replace direct env var / localStorage reads with vault lookups + - Fallback to env vars for dev mode + +**Acceptance criteria**: +- API keys encrypted at rest +- Keys never appear in console/logs +- UI shows masked keys +- Test connection verifies key works +- `pnpm typecheck` passes + +--- + +### M6: Payments & Identity (UX + Infrastructure) + +#### Task M6.1: Lightning Wallet Deep-links + +**Why**: AIUI is Bitcoin-only. Need to deep-link to external Lightning wallets for payments. + +**Files to create/modify**: +- Create `packages/app/src/utils/lightning.ts` +- Create `packages/app/src/components/ui/PaymentButton.vue` +- Create `packages/app/src/components/ui/LightningInvoice.vue` + +**Implementation**: +1. Create `lightning.ts`: + ```ts + // Generate LNURL-pay links, BIP21 URIs, Lightning: URIs + export function createLightningUri(invoice: string): string + export function createBip21Uri(address: string, amount?: number, label?: string): string + export function detectWallet(): 'strike' | 'muun' | 'phoenix' | 'zeus' | 'generic' + // Deep-link formats: lightning:BOLT11, bitcoin:?lightning=BOLT11 + ``` + +2. Create `PaymentButton.vue`: + - Bitcoin orange gradient button + - Shows sat amount + - On click: generates deep-link URI, opens wallet + - Fallback: show QR code with invoice string + - Copy invoice to clipboard button + +3. Create `LightningInvoice.vue`: + - Display BOLT11 invoice as QR code (use `qrcode` lib or canvas) + - Show amount in sats + - Expiry countdown + - Copy button + +**Acceptance criteria**: +- Payment button generates valid Lightning URIs +- Deep-link opens system wallet picker on mobile +- QR fallback for desktop +- `pnpm typecheck` passes + +#### Task M6.2: Cashu Token Support + +**Why**: Cashu ecash tokens enable offline micropayments. Display and copy Cashu tokens in chat. + +**Files to create/modify**: +- Create `packages/app/src/utils/cashu.ts` +- Create `packages/app/src/components/chat/CashuToken.vue` +- Modify `packages/app/src/components/chat/ChatMessage.vue` — detect cashu tokens + +**Implementation**: +1. Create `cashu.ts`: + ```ts + // Parse Cashu token format (cashuA...) + export function parseCashuToken(token: string): { mint: string; amount: number; unit: string } | null + export function isCashuToken(text: string): boolean + // No wallet functionality — AIUI is never a wallet + // Just parse, display, and deep-link to external wallet + ``` + +2. Create `CashuToken.vue`: + - Detect `cashuA...` strings in chat messages + - Display as card: amount, mint URL (truncated), copy button + - "Open in wallet" deep-link button + - Glass card styling with Bitcoin orange accent + +3. In ChatMessage.vue: + - Regex detect Cashu tokens + - Replace inline with `<CashuToken>` component + +**Acceptance criteria**: +- Cashu tokens in chat render as rich cards +- Copy token to clipboard works +- Deep-link to wallet works +- Invalid tokens show graceful fallback +- `pnpm typecheck` passes + +#### Task M6.3: Nostr Identity (NIP-07) + +**Why**: Enable login via Nostr browser extension (nos2x, Alby, etc.) for identity. + +**Files to create/modify**: +- Create `packages/app/src/composables/useNostrIdentity.ts` +- Create `packages/app/src/components/settings/NostrLogin.vue` +- Modify `packages/app/src/stores/` — add user identity store + +**Implementation**: +1. Create `useNostrIdentity.ts`: + ```ts + // NIP-07: window.nostr API + export function useNostrIdentity() { + const isAvailable: Ref<boolean> // window.nostr exists + const pubkey: Ref<string | null> + const npub: Ref<string | null> // bech32 encoded + async function login(): Promise<void> // calls window.nostr.getPublicKey() + async function sign(event: NostrEvent): Promise<NostrEvent> // calls window.nostr.signEvent() + function logout(): void + } + ``` + +2. Create `NostrLogin.vue`: + - "Login with Nostr" button (purple/Nostr brand color) + - Shows npub when logged in (truncated with copy) + - Logout button + - Detects if NIP-07 extension is installed + +**Acceptance criteria**: +- Login with nos2x/Alby extension works +- Public key displayed as npub +- Sign events for Nostr posting +- Graceful message if no extension installed +- `pnpm typecheck` passes + +--- + +### M7: Platform (Infrastructure) + +#### Task M7.1: MCP Server Integration + +**Why**: Model Context Protocol enables rich tool use. AIUI should expose content surfaces as MCP tools. + +**Files to create/modify**: +- Create `packages/app/src/plugins/mcp-server.ts` +- Modify `packages/app/src/composables/useAI.ts` — add MCP tool handling + +**Implementation**: +1. Create `mcp-server.ts`: + ```ts + // Expose AIUI capabilities as MCP tools + const tools = [ + { name: 'search_films', description: 'Search film library', inputSchema: {...} }, + { name: 'search_songs', description: 'Search song library', inputSchema: {...} }, + { name: 'search_web', description: 'Search the web', inputSchema: {...} }, + { name: 'get_nostr_feed', description: 'Fetch Nostr notes', inputSchema: {...} }, + ] + // Handle tool_use responses from AI and route to appropriate composable + ``` + +2. In useAI.ts: + - Parse tool_use blocks from Claude responses + - Route to appropriate handler (film search, web search, etc.) + - Return tool results back in conversation + +**Acceptance criteria**: +- Claude can call tools via MCP format +- Tool results display as content in panel +- `pnpm typecheck` passes + +#### Task M7.2: Multi-provider AI Normalization + +**Why**: Different AI providers (Claude, OpenRouter, Ollama) have different APIs. Normalize them. + +**Files to create/modify**: +- Create `packages/app/src/adapters/claude-adapter.ts` +- Create `packages/app/src/adapters/openrouter-adapter.ts` +- Create `packages/app/src/adapters/ollama-adapter.ts` +- Create `packages/app/src/adapters/types.ts` — unified interface +- Modify `packages/app/src/composables/useAI.ts` — use adapter pattern + +**Implementation**: +1. Create `types.ts`: + ```ts + interface AIAdapter { + id: string + name: string + chat(messages: Message[], options: ChatOptions): AsyncIterable<string> + models(): Promise<Model[]> + supportsStreaming: boolean + supportsVision: boolean + supportsTools: boolean + } + ``` + +2. Create adapters for Claude (existing logic), OpenRouter (OpenAI-compatible), Ollama (local). + +3. Refactor useAI.ts to select adapter by provider setting. + +**Acceptance criteria**: +- Can switch between Claude/OpenRouter/Ollama +- Streaming works with all providers +- Content extraction works regardless of provider +- `pnpm typecheck` passes + +#### Task M7.3: Tauri Desktop Build + +**Why**: Desktop app via Tauri for native experience with system tray, global shortcuts. + +**Files to create/modify**: +- Create `src-tauri/` directory with Tauri config +- Create `src-tauri/tauri.conf.json` +- Create `src-tauri/src/main.rs` +- Modify `packages/app/package.json` — add tauri scripts + +**Implementation**: +1. Initialize Tauri in the app package: + - `pnpm add -D @tauri-apps/cli @tauri-apps/api` in packages/app + - Configure window: frameless with custom titlebar, transparent background + - System tray with quick-access menu + - Global shortcut (Cmd+Shift+A) to show/hide window + +2. Tauri config: + - Window size: 1200x800, min 800x600 + - Transparent background (for glass morphism) + - Auto-updater enabled + - File system scope: app data directory only + +**Acceptance criteria**: +- `pnpm tauri dev` launches desktop app +- Glass morphism renders correctly with transparent window +- System tray works +- `pnpm tauri build` produces .dmg/.app +- `pnpm typecheck` passes + +#### Task M7.4: Offline Mode + +**Why**: AIUI should work without internet for browsing saved content. + +**Files to create/modify**: +- Modify `packages/app/src/sw.ts` or PWA config — cache strategies +- Create `packages/app/src/composables/useOffline.ts` +- Modify UI components — offline indicators + +**Implementation**: +1. Create `useOffline.ts`: + ```ts + export function useOffline() { + const isOnline: Ref<boolean> // navigator.onLine + event listeners + const pendingSync: Ref<number> // count of items waiting to sync + function queueForSync(action: SyncAction): void + function processSyncQueue(): Promise<void> + } + ``` + +2. PWA cache strategies: + - App shell: cache-first (HTML, JS, CSS, fonts) + - API responses: network-first with cache fallback + - Images: cache-first with stale-while-revalidate + - IndexedDB data: always available offline + +3. UI indicators: + - Subtle banner when offline ("Offline — browsing saved content") + - Disable AI chat input when offline (grey out with tooltip) + - Show cached content (favorites, saved conversations) + +**Acceptance criteria**: +- App loads without internet +- Saved conversations and favorites accessible offline +- Chat disabled with clear offline indicator +- Reconnection triggers sync +- `pnpm typecheck` passes + +--- + +## Part 3: Automated Session Execution Order + +For automated late-night Claude sessions, execute tasks in this order: + +### Priority Queue (each session picks next incomplete task): + +**M1: Stability & Polish** +1. **Task M1.2** — Error boundaries *(verify existing ErrorBoundary.vue, wrap remaining components)* +2. **Task M1.3** — Unit tests for contentExtraction *(create __tests__/contentExtraction.test.ts)* +3. **Task M1.1** — IndexedDB persistent storage *(create idb-storage.ts, modify chat.ts)* +4. **Task M1.4** — Unit tests for useAI *(create __tests__/useAI.test.ts with mocked fetch)* +5. **Task M1.5** — E2E test expansion *(8 new tests in content-surfaces.spec.ts)* + +**M2: Content Experience** +6. **Task M2.1** — Markdown rendering in chat *(add markdown-it, modify ChatMessage.vue)* +7. **Task M2.3** — Music source resolution + queue *(fix usePlayer.ts, update PlayerBar.vue)* +8. **Task M2.2** — Virtual scrolling for chat *(add @tanstack/vue-virtual to ChatWindow.vue)* +9. **Task M2.4** — Nostr feed integration *(create useNostr.ts, update NostrGrid.vue)* + +**M3: Plugin System** +10. **Task M3.1** — Activate plugin registry *(create plugins/index.ts, claude-provider.ts)* +11. **Task M3.2** — Renderer plugin registration *(film/song renderer plugins)* + +**M4: Social & Discovery** +12. **Task M4.1** — Social embeds *(NostrEmbed.vue, nostr: URI detection in chat)* +13. **Task M4.2** — Federated search *(useFederatedSearch.ts, /search command)* +14. **Task M4.3** — Bookmarks/favorites *(favorites.ts store, FavoriteButton, FavoritesGrid)* + +**M5: Security & Privacy** +15. **Task M5.1** — E2E encryption *(crypto.ts with Web Crypto API AES-256-GCM)* +16. **Task M5.2** — Encrypted storage layer *(PassphraseDialog, wire encryption to all stores)* +17. **Task M5.3** — API key vault *(key-vault.ts, ApiKeyManager.vue)* + +**M6: Payments & Identity** +18. **Task M6.1** — Lightning wallet deep-links *(lightning.ts, PaymentButton, LightningInvoice)* +19. **Task M6.2** — Cashu token support *(cashu.ts, CashuToken.vue inline in chat)* +20. **Task M6.3** — Nostr identity NIP-07 *(useNostrIdentity.ts, NostrLogin.vue)* + +**M7: Platform** +21. **Task M7.1** — MCP server integration *(mcp-server.ts, tool routing in useAI)* +22. **Task M7.2** — Multi-provider AI normalization *(adapter pattern for Claude/OpenRouter/Ollama)* +23. **Task M7.3** — Tauri desktop build *(src-tauri config, transparent window, system tray)* +24. **Task M7.4** — Offline mode *(useOffline.ts, cache strategies, offline UI indicators)* + +### Session Protocol + +Each automated session should: +1. Read `PROGRESS.md` to find the next incomplete task +2. Read this plan file for the task's detailed spec +3. Execute the task following the spec exactly +4. Run `pnpm typecheck` after changes +5. Run `pnpm lint` after changes +6. Run `pnpm test` if unit tests exist +7. Commit with conventional format: `type(scope): description` +8. Push to current branch +9. Update PROGRESS.md session log (triggered by hook, or manually) + +--- + +## Verification + +After implementing Part 1 (progress automation): +1. Run `pnpm typecheck && pnpm lint` — should pass +2. Commit a change and push — hook should fire +3. Verify PROGRESS.md gets a session log entry +4. Test from a different worktree — should work identically + +After each M1–M3 task: +1. `pnpm typecheck` passes +2. `pnpm lint` passes +3. `pnpm test` passes (if tests exist) +4. Dev server runs without errors (`pnpm dev`) +5. Manual smoke test: send a message, see content render diff --git a/aiui/PLAN2.md b/aiui/PLAN2.md new file mode 100644 index 00000000..76c3b583 --- /dev/null +++ b/aiui/PLAN2.md @@ -0,0 +1,443 @@ +# AIUI Plan 2 — Extended Roadmap + +## Context & Philosophy + +This plan continues from M0–M7 (all complete). Every item below must honour the core philosophy: +- **Glass morphism only** — `glass`, `glass-card`, `glass-button`. No light mode, no gray-900 hacks. +- **Open source / MIT/Apache-2.0** — no proprietary dependencies +- **Decentralised-first** — no vendor lock-in, pluggable everything +- **Bitcoin only** — sats, Lightning, Cashu, Fedimint. AIUI is never a wallet — always deep-link +- **Privacy-first** — no telemetry, no tracking, E2E encryption +- **Mobile-first, everywhere-perfect** — desktop enhances mobile, never replaces it +- **Plugin-everything** — all integrations go through typed plugin interfaces +- **< 250 KB gzipped initial load** — everything else lazy-loaded + +--- + +## M8: Chat UX Polish + +### M8.1 — Message Editing & Regeneration +Edit any sent message in place; all messages after it are cleared and AI regenerates from that point. Pencil icon appears on hover. Textarea replaces bubble on click. `Escape` cancels, `Enter` submits. + +### M8.2 — Conversation Branching +Fork from any assistant message. Branch indicator in chat header (e.g. "Branch 2 of 3"). Branch switcher as a compact glass pill above the forked message. Each branch stored as a separate conversation in IDB. + +### M8.3 — Reply-to Threading +Click any message → "Reply" option. Reply shows a quoted excerpt of the target message above the input. Thread line connects quoted block to source. Visual only — does not send separate context to AI, just prepends `> quote` to the user message. + +### M8.4 — Conversation Search +`Cmd+F` / search icon opens a slide-down glass panel above chat. Real-time filtering highlights matching messages. Up/down arrows jump between matches. `Escape` closes. + +### M8.5 — Auto-Title Generation +After the first AI response in a new conversation, send a background request: `"Give a 4-word title for this conversation: {first user message}"`. Replace "New Chat" silently. No loading state — title updates smoothly. + +### M8.6 — Context Window Visualiser +Slim progress bar at top of chat column. Estimates token count from message lengths (1 token ≈ 4 chars). Shows percentage of model's context window used. Bitcoin-orange fill → red when > 80%. Tooltip: "~12,400 / 200,000 tokens used". + +### M8.7 — Conversation Export +Three-dot menu on each conversation → Export. Options: Markdown (download .md), JSON (full data), Plain text. Uses File System Access API when available, falls back to `<a download>`. No server involved. + +### M8.8 — Import Conversations +Settings → Import → drag-and-drop or file picker for AIUI JSON export or Claude.ai export JSON. Merges into existing conversations without overwriting. Shows import summary (N conversations added). + +### M8.9 — Long-press / Right-click Context Menus +Messages: Copy, Edit, Delete, Reply, Branch from here. Content cards: Favourite, Share, Open detail, Copy title. Uses a reusable `ContextMenu.vue` glass-card component positioned at cursor. Closes on outside click or `Escape`. + +### M8.10 — Scroll Position Memory +When switching between conversations, restore the previous scroll position. Store position per conversation ID in a `Map<string, number>` (not persisted — session only). Virtual scroller should seek to the stored offset on mount. + +--- + +## M9: AI Experience + +### M9.1 — Multi-Model Comparison Mode +Split-screen: same prompt sent to two models simultaneously. Side-by-side layout on desktop, swipeable tabs on mobile. Model selector per pane. Shows streaming output in both. Useful for comparing Claude vs OpenRouter models. + +### M9.2 — System Prompt Editor +Settings → Personas. Create named personas (e.g. "Film Critic", "Bitcoin Analyst"). Each has a system prompt, model preference, and accent colour. Select persona per conversation via a pill menu above the input. Default persona applies to all new conversations. + +### M9.3 — Prompt Template Library +`/` in chat input opens a command palette (glass dropdown). Templates listed with title + preview. Variables in templates use `{{variable}}` syntax — on selection, a mini form appears to fill them. Templates stored in IDB, importable/exportable as JSON. + +### M9.4 — Vision Input +Drag-and-drop or paste image into chat input. Image preview appears as a thumbnail above the input. On send, image encoded as base64 and included in the message content array (Claude vision format). Only enabled when active model supports vision. Max 4 images per message. + +### M9.5 — Response Feedback +Thumbs up / thumbs down on each AI message (appears on hover). Stored locally in IDB per message ID. Shown in conversation export. Future: aggregate across sessions for personal preference tracking. Never sent anywhere. + +### M9.6 — Token & Cost Estimator +Settings toggle to show token counts. Each message shows estimated token count in a tiny badge (bottom-right of bubble). Running total shown in context window bar. Cost estimate based on current model's pricing (hardcoded table, updated with model releases). + +### M9.7 — AI Memory Panel +Settings → Memory. A list of "always remember" facts injected into every system prompt. e.g. "I live in London", "I prefer sats over fiat". Edit/delete/add. Max 20 items. Stored encrypted in IDB. Shown as a collapsed "Memory" section in the system prompt. + +### M9.8 — Model Capabilities Badge +Model selector shows capability badges: Vision 👁, Tools 🔧, Long context 📄. Tooltip explains each. Greys out vision input button when selected model doesn't support it. Updates dynamically when switching providers. + +### M9.9 — Temperature & Params Slider +Advanced settings section (collapsed by default) beneath the model selector. Sliders for: Temperature (0–1), Max tokens (256–8192), Top-P. Values persisted per conversation in IDB. Reset to defaults button. + +### M9.10 — Stop Sequence Configuration +Advanced settings: configurable stop sequences (comma-separated). Applied to all requests for that conversation. Useful for structured output tasks. Shown as a small tag list below the slider panel. + +--- + +## M10: Advanced Content Renderers + +### M10.1 — Full Article Renderer +When AI returns a long-form article (> 800 words with headings), render it in the panel as a paginated article view. Features: auto-generated table of contents (sticky left sidebar on desktop), estimated reading time, font-size control, print mode. Uses existing markdown-it instance. + +### M10.2 — PDF Viewer +Content type `pdf` renders via `pdfjs-dist` (lazy loaded, ~400 KB). Page navigation, zoom, text selection, search within PDF. Chat preview: thumbnail of page 1. Panel play: full viewer. Files loaded from URL (no local file upload in v1). + +### M10.3 — Map Renderer +Content type `place` upgrades from static card to interactive Leaflet map (lazy loaded). OpenStreetMap tiles (no API key needed). Pins for all places mentioned in conversation. Cluster pins when > 10 places. Panel play: fullscreen map with place list sidebar. + +### M10.4 — Recipe Renderer +New content type `recipe`. Tag: `<recipe_ext title="..." servings="..." time="...">`. Structured display: ingredients checklist (tap to strike through), numbered steps, metadata chips (time, servings, calories). "Scale recipe" slider (0.5×–4×) recalculates quantities. + +### M10.5 — Event Renderer +New content type `event`. Tag: `<event_ext title="..." date="..." location="..." url="...">`. Shows: date chip, location, countdown. Add to calendar buttons: ICS download, Google Calendar URL, Apple Calendar. Glass card in chat, full detail in panel. + +### M10.6 — Math Renderer +Detect `$...$` (inline) and `$$...$$` (block) LaTeX in chat messages. Render using KaTeX (lazy loaded, ~70 KB). Fallback: display raw LaTeX in a code block. No re-renders during streaming — batch render on stream end. + +### M10.7 — Mermaid Diagram Renderer +Detect ` ```mermaid ` fenced code blocks. Render using Mermaid.js (lazy loaded, ~500 KB). Support: flowchart, sequence, gantt, entity-relationship. Dark theme matching glass design. Copy SVG button. Pan/zoom on mobile. + +### M10.8 — Audio Waveform Player +Upgrade PlayerBar for locally-loaded audio. Use WaveSurfer.js (lazy loaded) to show waveform visualization. Waveform rendered in Bitcoin orange on dark background. Click to seek. Existing queue/next/prev preserved. + +### M10.9 — Table Renderer +Markdown tables rendered as interactive tables: column sort (click header), row filter (search input above table), CSV export button. Uses existing markdown-it but overrides the table token renderer. Max 500 rows before virtualisation kicks in. + +### M10.10 — Timeline Renderer +New content type `timeline`. AI returns a series of `<event_ext>` tags. Panel renders them as a vertical timeline: date on left, event card on right, connecting line. Animate entries in as they appear during streaming. + +### M10.11 — Code Runner +Fenced code blocks with a "Run" button for HTML/CSS/JS. Opens a sandboxed `<iframe srcdoc="...">` in the panel. Output console below. `sandbox="allow-scripts"` only — no network access, no storage. Python: future (Pyodide). + +### M10.12 — Video Renderer +New content type `video`. Native `<video>` element with custom glass controls. HLS.js for adaptive streams (lazy loaded). YouTube URL detection → nocookie embed fallback. Panel play: fullscreen. Chat preview: thumbnail + play button. + +--- + +## M11: Nostr Ecosystem + +### M11.1 — Publish Nostr Notes +Compose panel in the Nostr tab. Write a note → sign via NIP-07 → broadcast to configured relays. Shows send status per relay. Can attach content card references (film, song, etc.) as URL mentions. Character counter (280 soft limit, no hard cap). + +### M11.2 — Nostr DMs (NIP-17) +Encrypted direct messages using NIP-17 sealed gifts. DM inbox tab in Nostr section. Contact list from follows. Message threads per contact. Messages encrypted client-side, stored in IDB. No plaintext ever sent to relay. + +### M11.3 — Relay Management UI +Settings → Nostr Relays. Add/remove relay URLs. Health column: latency (ms), status (connected/disconnected/error). Test connection button. Read/write toggle per relay. Import relay list from NIP-65 event. + +### M11.4 — Nostr Profile Editor +Settings → Nostr Identity (extends M6.3). Edit: display name, bio, avatar URL, banner URL, website, NIP-05 address, Lightning address. Preview renders as a profile card. Publish as kind:0 event via NIP-07. + +### M11.5 — Zaps (NIP-57) +On any Nostr note or profile, show a Zap ⚡ button. Opens a zap dialog: amount input (in sats), optional message. Fetches LNURL-pay from profile's Lightning address. Shows QR + deep-link. Confirms via Lightning payment. Never holds funds. + +### M11.6 — NIP-05 Verification Badge +Nostr profiles with NIP-05 show a ✓ badge. Verified by fetching `/.well-known/nostr.json?name=...` from the NIP-05 domain. Cached in IDB for 24 hours. Badge tooltip shows the full NIP-05 identifier. + +### M11.7 — Nostr Search (NIP-50) +Search input in Nostr tab. Sends `REQ` with `search` field to NIP-50 supporting relays (nostr.wine, relay.nostr.band). Results show as note cards with author, content, timestamp. Filter by content type. + +### M11.8 — Thread View +Clicking a Nostr note opens a thread view in the panel. Fetches root event and all replies (kind:1, `#e` tag). Renders as a threaded tree (indent by depth, max 5 levels). Loads lazily from relays. Reply button opens compose with reply reference. + +### M11.9 — Nostr Lists (NIP-51) +View and manage: follow list (kind:3), mute list (kind:10000), pin list (kind:10001), bookmark list (kind:10003). Each as a panel tab in the Nostr section. Add/remove items. Publish via NIP-07. + +### M11.10 — Long-Form Content (NIP-23) +Nostr long-form articles (kind:30023) rendered in the article renderer (M10.1). Discovery tab in Nostr section shows recent articles from follows. Clicking opens the full article in panel play. Share as Nostr note button. + +--- + +## M12: Bitcoin Ecosystem + +### M12.1 — On-Chain Address Display +Detect Bitcoin addresses in chat (bech32 segwit, legacy). Render as a glass card: address (truncated), QR code, "View on mempool.space" link, copy button. Balance lookup via mempool.space API (lazy, opt-in). Never sends private keys. + +### M12.2 — Fedimint Ecash +Detect Fedimint ecash tokens in chat (e-cash token format). Display: federation name, amount in sats, "Receive in Fedi" deep-link button. QR of the token string. Copy button. Same approach as Cashu — AIUI is never a wallet. + +### M12.3 — BOLT12 Offers +Detect `lno1...` BOLT12 offer strings. Render as glass card: decoded amount (if fixed), description, "Pay with wallet" deep-link. QR of the offer. BOLT12 is static (reusable), unlike BOLT11 invoices. + +### M12.4 — Nostr Wallet Connect (NWC) +Settings → Connect Wallet. Paste NWC connection string (`nostr+walletconnect://...`). AIUI can then: check balance, pay invoices (with user confirmation). Uses NIP-47. All operations require explicit user tap. Stored encrypted in IDB. + +### M12.5 — LNURL-auth Login +Settings → LNURL-auth. Generates a LNURL-auth QR code. Scanning with a Lightning wallet proves ownership of the Lightning node. Sets a persistent identity (pubkey) used for local preference sync. No password needed. + +### M12.6 — Live Sat/Fiat Price +Settings toggle: show amounts in sats or fiat equivalent. Price fetched from mempool.space `/api/v1/prices` every 60 seconds. Used across: Cashu cards, Lightning invoices, cost estimator, zap dialog. Stored in a `useBitcoinPrice` composable. + +### M12.7 — Mempool.space Tx Viewer +Detect txid hashes (64 hex chars) and block heights in chat. Render as a glass card with: confirmations, fee rate, amount, link to mempool.space. Block height renders block summary. Updates live via mempool.space WebSocket. + +### M12.8 — BOLT11 Decoder Card +Full BOLT11 invoice decode before paying: show amount, description, expiry countdown, destination node alias (if known). Expiry shown as a red countdown when < 5 minutes. "Pay" button triggers deep-link or NWC payment (M12.4). + +--- + +## M13: Content Discovery + +### M13.1 — "For You" Feed +A new "For You" tab in the content panel. Surfaces content types you've interacted with most (from favorites + conversation history). Uses a simple frequency map (no ML). Refreshes on each app open. Fully local, no server. + +### M13.2 — Content Tagging +On any content card: "Add tag" (plus icon). Tags are user-defined strings stored in IDB alongside the item. Filter any content grid by tag. Tag cloud view in favorites panel. Export tags with content JSON. + +### M13.3 — Smart Playlists +Music tab → Smart Playlists. Auto-generated from: recently played, most played, by genre tag, by decade. Each playlist is a computed view over the song IDB store. Play button queues the whole playlist. No manual curation needed. + +### M13.4 — Similar Content +Below any open content detail: "More like this" section. Populated by sending a background AI request: `"List 3 films similar to {title} as film_ext tags"`. Results appear after 2–3 seconds. Cached in IDB per item for 7 days. + +### M13.5 — Recently Viewed +A "Recent" tab in the content panel. Ordered list of the last 50 content items you opened (any type). Each entry: thumbnail, title, type, time ago. Tap to re-open. Stored in IDB, cleared on data wipe. + +### M13.6 — Content Collections +User-created collections (like playlists but for any content type). Create collection → name it → add any content card to it via long-press menu. Collections shown as a grid of 4-thumbnail mosaics. Shareable as a Nostr list (NIP-51). + +### M13.7 — Trending in Conversations +A "Trending" section: content items referenced most frequently across all your conversations in the last 30 days. Computed on load from IDB. Shows a small "referenced N times" badge. Pure local analytics. + +### M13.8 — Content Sharing via Nostr +Any content card: Share → "Post to Nostr". Generates a note with the content title, year, a short AI-generated description, and the content tag as a URL. Signs and broadcasts via NIP-07. Opens compose preview before posting. + +--- + +## M14: Plugin Marketplace + +### M14.1 — Plugin Discovery UI +Settings → Plugins → Discover. Fetches a static community registry JSON (hosted on GitHub Pages or IPFS). Lists plugins with: name, description, type, author, version, rating. Install button triggers M14.7 (import by URL). + +### M14.2 — Plugin Settings Panel +Each installed plugin has a gear icon → settings panel. Plugin declares its settings schema (JSON Schema). AIUI renders the settings form automatically using a `PluginSettingsForm.vue` component. Settings stored encrypted in IDB under plugin ID. + +### M14.3 — Plugin Permissions UI +On install: permissions dialog lists requested capabilities (e.g. "Access chat messages", "Make network requests", "Read favorites"). User grants/denies each. Permissions stored per plugin. Plugin can check granted permissions at runtime via `context.hasPermission()`. + +### M14.4 — Plugin Dev Mode +`VITE_PLUGIN_DEV=true` enables: hot-reload of plugins from `src/plugins/dev/`, error inspector panel (shows plugin errors without crashing app), plugin performance profiler (time per `init()` call). + +### M14.5 — Built-in Plugin: Wikipedia +Plugin type `search`. `/wiki {query}` in chat input fetches Wikipedia summary via the Wikipedia REST API. Returns a `article` content card inline. No API key needed. Rendered via the article renderer (M10.1). + +### M14.6 — Built-in Plugin: OpenLibrary +Plugin type `search`. Searches Open Library (openlibrary.org) for books. Returns `book_ext` tagged results. Cover images from Open Library covers API. Free, no API key. + +### M14.7 — Plugin Import by URL +Settings → Plugins → Install from URL. Paste a GitHub raw URL or IPFS CID. AIUI fetches the plugin manifest (`aiui-plugin.json`), validates schema, shows permissions dialog (M14.3), then installs. Plugins are community Tier 2 (sandboxed iframe). + +### M14.8 — Plugin Versioning & Auto-Update +Installed plugins store their version. On app start, check registry for newer versions (background fetch). Badge on Plugins settings icon when updates available. Update all button. Changelog shown before updating. + +--- + +## M15: Settings & Personalisation + +### M15.1 — Accent Colour Picker +Settings → Appearance. Colour wheel or preset swatches to change the accent colour (default Bitcoin orange #F7931A). Updates `--color-accent` CSS variable in real time. Persisted in IDB. Affects all gradient buttons, badges, active states. + +### M15.2 — Glass Intensity Slider +Settings → Appearance. Three presets: Subtle / Default / Strong. Maps to blur(12px)/blur(18px)/blur(28px) and background opacity 0.25/0.35/0.50. Updates glass CSS variables. Live preview as you drag. + +### M15.3 — Font Size Settings +Settings → Appearance. Three sizes: Compact (13px base), Default (15px), Large (17px). Sets `--font-size-base` CSS variable. Scales all rem-based text. Persisted in IDB. + +### M15.4 — Content Type Visibility +Settings → Content. Toggle visibility of each of the 11 content type tabs in the panel. Hidden types still extract from AI messages but don't show in the panel. Useful for users who only care about music + films. + +### M15.5 — Keyboard Shortcut Map +Settings → Shortcuts. Lists all keyboard shortcuts. Each row shows action + current binding. Click to rebind (record next key combo). Conflicts highlighted in red. Stored in IDB. Uses the existing keybindings system. + +### M15.6 — Browser Push Notifications +Settings → Notifications. Opt-in for: "Generation complete" (when a long response finishes while tab is backgrounded). Uses the Web Notifications API + Service Worker `showNotification()`. Notification click focuses the tab and scrolls to the response. + +### M15.7 — Auto-Archive Old Conversations +Settings → Storage. Slider: archive conversations older than N days (7/30/90/never). Archived conversations move to an "Archive" folder, not deleted. Unarchive individually. Archive stored in a separate IDB object store. + +### M15.8 — Full Data Export +Settings → Data → Export All. Creates a JSON archive: all conversations, favorites, settings, tags, collections. Optionally encrypted with the current passphrase. Single file download. Compliant with GDPR right to portability. + +### M15.9 — Data Wipe +Settings → Data → Wipe Everything. Two-step confirmation. Clears: all IDB stores, service worker cache, localStorage. Does not clear the API key vault unless explicitly checked. Shows what will be deleted before confirming. + +### M15.10 — Default Conversation Settings +Settings → Chat. Set global defaults: default model, default persona, web search on/off, show token counts. These apply to all new conversations. Per-conversation overrides still possible. + +--- + +## M16: Mobile UX Polish + +### M16.1 — Bottom Sheet Component +Reusable `BottomSheet.vue`. Gesture-driven: drag down to dismiss, swipe up to expand. Snap points: 40% / 80% / 100% height. Backdrop tap to close. Used by: context menus, share sheets, relay management, plugin settings. Replaces modals on mobile. + +### M16.2 — Swipe to Navigate Conversations +On mobile, swipe left/right on the chat area to move between conversations. Animated slide transition. Visual edge indicator (thin line at sides) to hint swipeability. Threshold: 80px swipe distance, 0.3 velocity. + +### M16.3 — Pull-to-Refresh on Content Panels +Each content grid supports pull-to-refresh. Custom glass spinner animation. Triggers: re-fetch from AI context, reload Nostr feed, clear image cache for that type. Haptic feedback on release. + +### M16.4 — Haptic Feedback +Use `navigator.vibrate()` for: message send (10ms), favourite toggle (15ms), error (pattern: 50ms–50ms–50ms), pull-to-refresh trigger (20ms). Wrapped in `useHaptics()` composable that checks support before calling. Settings toggle to disable. + +### M16.5 — Web Share API +All content cards and conversations: Share button triggers native `navigator.share()` where available. Falls back to a glass share sheet (copy link, copy text, Nostr share). Adapts to iOS (files not supported) vs Android (files supported). + +### M16.6 — Pinch-to-Zoom on Images & Maps +Images in the panel support pinch-to-zoom via touch events. Min scale 1×, max 4×. Double-tap resets to 1×. Map renderer uses Leaflet's built-in touch zoom. Implemented with a `usePinchZoom()` composable (no library needed). + +### M16.7 — iOS PWA Polish +Meta tags: `apple-mobile-web-app-capable`, `apple-mobile-web-app-status-bar-style: black-translucent`. Safe area insets via `env(safe-area-inset-*)` on all fixed elements (chat input, player bar, nav). Splash screens for common iPhone sizes. + +### M16.8 — Long-press Context Menus on Mobile +On mobile, long-press (500ms) on messages or content cards opens the context menu (M8.9) as a bottom sheet (M16.1). Haptic on trigger (20ms). Prevents default browser long-press menu via `@contextmenu.prevent`. + +### M16.9 — Scroll Position Memory +Restore scroll position when switching tabs, conversations, or navigating back. Store position per route + conversation ID in a `Map` (session only). Content grids also remember their scroll offset. + +### M16.10 — Landscape Mode Optimisation +Detect landscape on mobile. Rearrange layout: chat takes 50% width, content panel 50% (instead of overlay). Player bar becomes minimal (just controls, no waveform). Smooth transition on rotate via CSS transitions on layout classes. + +--- + +## M17: Accessibility & Internationalisation + +### M17.1 — Keyboard Navigation Audit +Full Tab order review across all pages. All interactive elements reachable. Focus trap in modals and bottom sheets. `Escape` closes any overlay. Roving tabindex in content card grids. Arrow keys navigate card grids. + +### M17.2 — ARIA Audit +All icon buttons: `aria-label`. All dynamic content: `aria-live="polite"`. Dialogs: `role="dialog"`, `aria-modal`, `aria-labelledby`. Content card grids: `role="list"` + `role="listitem"`. Loading states: `aria-busy`. + +### M17.3 — High Contrast Mode +`@media (prefers-contrast: more)` stylesheet. Increases border opacity from 0.18 → 0.5. Text opacity: all `/90` → `100%`. Removes backdrop blur (performance + clarity). Accent remains orange. Toggle also available in Settings. + +### M17.4 — Automated Accessibility Tests +Axe-core integrated into Playwright E2E tests. Run `pnpm test:a11y` which opens each page and asserts zero critical axe violations. CI fails on new violations. Reports saved as HTML artefacts. + +### M17.5 — i18n Foundation +Add `vue-i18n`. Extract all hardcoded strings into `src/i18n/en.json`. Add `es.json` (Spanish) and `fr.json` (French) with machine-translated initial values (marked as needing review). Language auto-detected from `navigator.language`, overridable in Settings. + +### M17.6 — RTL Layout Support +`dir="rtl"` on `<html>` for Arabic/Hebrew locales. Use logical CSS properties (`padding-inline-start` not `padding-left`). Flex row reversal handled by `rtl:flex-row-reverse` Tailwind variant. Test with Arabic locale. + +### M17.7 — Dyslexia-Friendly Font Option +Settings → Appearance → Font. Option: "OpenDyslexic". Loaded via self-hosted WOFF2 (MIT licensed). Sets `--font-sans` CSS variable. Letter spacing +0.05em, line height 1.6. + +### M17.8 — Skip Navigation Link +Hidden "Skip to main content" link as the first focusable element. Visible on Tab focus. Jumps to `<main>` landmark. Standard accessibility pattern — costs nothing, helps screen reader users significantly. + +--- + +## M18: Performance + +### M18.1 — Bundle Analysis & Splitting +Run `vite-bundle-visualizer` in CI. Identify any component loaded eagerly that should be lazy. Target: core bundle stays < 150 KB gzipped. Create per-route chunk boundaries in Vue Router. + +### M18.2 — Image Lazy Loading with Blur-up +All content card images: `loading="lazy"` + `decoding="async"`. Low-quality placeholder (16×16 px, base64 inline) shown until full image loads. CSS transition from blurred placeholder to sharp image. `IntersectionObserver`-based (via `useIntersectionObserver`). + +### M18.3 — Request Deduplication +`useFetch()` composable wraps all API calls. Identical in-flight requests share a single Promise (keyed by URL + body hash). Cancel via `AbortController` on component unmount. Prevents duplicate AI requests on fast re-renders. + +### M18.4 — Web Worker for Heavy Tasks +Move `contentExtraction` parsing and AES-256-GCM encryption/decryption into a Web Worker (`src/workers/heavy.worker.ts`). Main thread posts messages, worker responds. Use `comlink` (MIT, ~1 KB) for typed RPC. Keeps UI thread free. + +### M18.5 — Prefetch on Hover +Content cards: on `mouseenter` (desktop) or 100ms touch hold (mobile), prefetch the detail data. E.g. fetch TMDB details for a film card before the user clicks. Store in a short-lived cache (5 min). Makes panel open feel instant. + +### M18.6 — Memory Leak Audit +Systematically add `onUnmounted` cleanup to all composables that use: `setInterval`, `setTimeout`, `addEventListener`, WebSocket connections, `IntersectionObserver`, `ResizeObserver`. Add a dev-mode leak detector that logs active listeners on route change. + +### M18.7 — Background Sync Queue +If an IDB save fails (e.g. storage quota exceeded), queue the operation in a `SyncQueue`. On next app focus (`visibilitychange`), retry the queue. Show a subtle warning badge in settings if queue is non-empty. + +### M18.8 — OPFS Storage Backend (Optional) +Implement an alternative storage backend using Origin Private File System (OPFS) via SQLite WASM (`@sqlite.org/sqlite-wasm`, Apache 2.0). Feature-flagged: `VITE_STORAGE=opfs`. Faster for large datasets (1000+ conversations). Falls back to IDB if OPFS unavailable. + +--- + +## M19: Developer Experience & Quality + +### M19.1 — Storybook +Add Storybook 8 to `packages/app`. Stories for all `ui/` components. Glass morphism theme applied to Storybook canvas (`background: #0a0a0a`). Run with `pnpm storybook`. Stories used as visual regression baseline. + +### M19.2 — Visual Regression Tests +Playwright screenshot tests for: ChatPage, ContentPanel, each renderer card, PassphraseDialog, BottomSheet. Compare against baseline snapshots on every PR. Fail if pixel diff > 0.5%. Update baseline with `pnpm test:update-snapshots`. + +### M19.3 — Bundle Size CI Gate +Add a GitHub Actions step: build → measure gzipped bundle → fail if > 250 KB. Use `bundlesize` (MIT). Track history: post bundle size as a PR comment showing diff from base branch. + +### M19.4 — Comprehensive Mock Data +Expand `src/mocks/` with realistic data for all 11 content types (20+ items each). Add a mock Nostr relay (in-process WebSocket server) for E2E tests. Add mock TMDB responses for all test films. + +### M19.5 — E2E Cross-Browser Matrix +Playwright config: run tests on Chromium + Firefox + WebKit. CI matrix: macOS (WebKit) + Linux (Chromium + Firefox). Mobile viewports: iPhone 14 (390×844) + Galaxy S21 (360×800). + +### M19.6 — Proxy Integration Tests +Test `claude-proxy.ts` with a mock Anthropic API (intercepted by `nock` or `msw`). Assert: SSE streaming format, tool_use round-trips, error handling (401, 429, 500), client disconnect kills child process. + +### M19.7 — Performance Benchmarks (Lighthouse CI) +Run Lighthouse in CI on each PR against a built + served app. Track: LCP, FID, CLS, TTI. Fail if LCP > 3s or CLS > 0.15. Post scores as PR comment. Store history in a JSON file committed to `reports/` branch. + +### M19.8 — Dependency Audit +Weekly GitHub Actions job: `pnpm audit` for vulnerabilities, `license-checker` to flag non-MIT/Apache dependencies. Auto-create an issue if violations found. Block releases on critical vulnerabilities. + +--- + +## M20: Collaboration & Sharing + +### M20.1 — Share Conversation via Nostr +Export a conversation as a Nostr long-form article (kind:30023). Title = conversation title. Content = formatted Markdown. Sign via NIP-07. Optionally encrypt for a specific npub (NIP-44). Shareable via `nostr:naddr1...` link. + +### M20.2 — Read-Only Conversation Viewer +A `/view/:nostrAddr` route that renders a shared Nostr conversation (from M20.1) in read-only mode. No auth needed for public conversations. Shows content cards inline. Works as a landing page for shared links. + +### M20.3 — Collaborative Playlist (Nostr NIP-51) +Create a shared content list (NIP-51 kind:30004). Invite others by npub to contribute. Each contributor signs their additions. AIUI merges all list events from the relay into a unified view. Useful for collaborative music or film curation. + +### M20.4 — Conversation Templates +Pre-built conversation starters: "Bitcoin deep dive", "Film analysis", "Nostr onboarding", "Music discovery". Each is a system prompt + first user message. Shown on the new conversation screen as glass cards. Import/export as JSON. Share via Nostr. + +### M20.5 — Export as Audio Podcast +Experimental (M20.5): Text-to-speech for a conversation using Web Speech API (`speechSynthesis`). Reads AI responses only. Controls: voice selector, speed, skip. Export as WAV (Web Audio API). Background music track from the player queue mixed in (opt-in). Pure client-side. + +### M20.6 — Community Content Packs +Import a curated set of content (films, songs, books) from a community-maintained JSON file. Hosted on GitHub or IPFS. Registry listed in the plugin marketplace (M14.1). Examples: "2024 Best Films", "Bitcoin Music Playlist", "Essential Nostr Reads". + +--- + +## Automated Session Execution Order + +Each session should: +1. Read `PROGRESS.md` — find the next `[ ]` item +2. Read the task spec above +3. Implement the task +4. Run `pnpm typecheck && pnpm lint && pnpm test` +5. Commit: `type(scope): description` +6. Update `PROGRESS.md` + +### Priority Queue + +**M8 (Chat Polish):** M8.1 → M8.4 → M8.5 → M8.6 → M8.2 → M8.3 → M8.7 → M8.8 → M8.9 → M8.10 +**M9 (AI Experience):** M9.1 → M9.4 → M9.2 → M9.3 → M9.7 → M9.5 → M9.6 → M9.8 → M9.9 → M9.10 +**M10 (Renderers):** M10.6 → M10.7 → M10.3 → M10.1 → M10.9 → M10.4 → M10.5 → M10.11 → M10.2 → M10.8 → M10.10 → M10.12 +**M11 (Nostr):** M11.3 → M11.1 → M11.5 → M11.6 → M11.7 → M11.8 → M11.4 → M11.2 → M11.9 → M11.10 +**M12 (Bitcoin):** M12.1 → M12.6 → M12.8 → M12.7 → M12.3 → M12.2 → M12.5 → M12.4 +**M13 (Discovery):** M13.5 → M13.1 → M13.2 → M13.3 → M13.4 → M13.6 → M13.7 → M13.8 +**M14 (Plugins):** M14.5 → M14.6 → M14.7 → M14.1 → M14.2 → M14.3 → M14.4 → M14.8 +**M15 (Settings):** M15.1 → M15.2 → M15.3 → M15.4 → M15.5 → M15.6 → M15.7 → M15.8 → M15.9 → M15.10 +**M16 (Mobile):** M16.1 → M16.7 → M16.4 → M16.5 → M16.2 → M16.8 → M16.3 → M16.6 → M16.9 → M16.10 +**M17 (a11y/i18n):** M17.8 → M17.1 → M17.2 → M17.3 → M17.4 → M17.5 → M17.6 → M17.7 +**M18 (Perf):** M18.2 → M18.6 → M18.3 → M18.5 → M18.1 → M18.4 → M18.7 → M18.8 +**M19 (DX):** M19.4 → M19.6 → M19.5 → M19.3 → M19.1 → M19.2 → M19.7 → M19.8 +**M20 (Collab):** M20.4 → M20.1 → M20.2 → M20.6 → M20.3 → M20.5 + +**Total: 116 tasks across 13 milestones** diff --git a/aiui/PROGRESS.md b/aiui/PROGRESS.md new file mode 100644 index 00000000..c9c880de --- /dev/null +++ b/aiui/PROGRESS.md @@ -0,0 +1,157 @@ +# AIUI Progress + +## Current Status +**Active Milestone**: COMPLETE +**Overall**: M0–M20 all complete. 116 tasks implemented. All tests, typecheck, lint, and build passing. + +## Roadmap + +### M0: Foundation ✅ +- [x] Chat interface with streaming (Claude/OpenRouter/Mock) +- [x] 11 content type renderers (film, song, podcast, book, TV, image, place, article, magazine, nostr, code) +- [x] Responsive layout (mobile three-column + desktop overlays) +- [x] Glass morphism design system (Tailwind CSS) +- [x] Claude proxy + web search (SearXNG/DDG) +- [x] PWA support (auto-update, installable) +- [x] Music player (Plyr-based PlayerBar) +- [x] Dev chat persistence (Vite middleware) +- [x] ESLint flat config (packages/app + packages/core) +- [x] CI baseline (pnpm test, lint, typecheck passing) +- [x] Progress tracking automation + +### M1: Stability & Polish ✅ +- [x] ErrorBoundary.vue component created +- [x] Error boundaries wrapping all major sections +- [x] Unit tests — contentExtraction composable (10 extraction functions, 49 tests) +- [x] IndexedDB persistent storage (conversations survive refresh) +- [x] Unit tests — useAI composable (16 tests, mocked fetch/SSE) +- [x] E2E test expansion (8 new tests: streaming, content cards, mobile, etc.) + +### M2: Content Experience ✅ +- [x] Markdown rendering in chat (markdown-it, XSS safe) +- [x] Music source resolution + queue management (next/prev, queue panel) +- [x] Virtual scrolling for chat (@tanstack/vue-virtual) +- [x] Nostr feed integration (relay WebSocket, kind:1 notes) + +### M3: Plugin System ✅ +- [x] Activate plugin registry at runtime (claude-provider adapter) +- [x] Renderer plugin registration (film/song as plugins) + +### M4: Social & Discovery ✅ +- [x] Social embeds (Nostr notes inline via nostr: URI) +- [x] Federated search across content types (/search command) +- [x] Bookmarks/favorites (Pinia + IndexedDB, heart toggle) + +### M5: Security & Privacy ✅ +- [x] E2E encryption (Web Crypto API, AES-256-GCM, PBKDF2) +- [x] Encrypted storage layer (PassphraseDialog, all stores encrypted) +- [x] API key vault (encrypted at rest, masked UI) + +### M6: Payments & Identity ✅ +- [x] Lightning wallet deep-links (LNURL-pay, BIP21, QR code) +- [x] Cashu token support (parse + display inline, never a wallet) +- [x] Nostr identity NIP-07 (browser extension login, event signing) + +### M7: Platform ✅ +- [x] MCP server integration (content surfaces as MCP tools) +- [x] Multi-provider AI normalization (Claude/OpenRouter/Ollama adapters) +- [x] Tauri desktop build (transparent window, system tray, global shortcut) +- [x] Offline mode (cache strategies, offline banner, cached content browsing) + +## Session Log +<!-- Entries below are auto-populated by the post-push Claude Code hook --> +<!-- Format: ### YYYY-MM-DD HH:MM — branch-name --> + +### 2026-03-03 — overnight/2026-03-03 +**Completed M3–M7 (15 tasks)**: +- M3.1: Plugin registry + Claude provider adapter +- M3.2: Film/song renderer plugins with lazy loading +- M4.1: Nostr social embeds (bech32 NIP-19 decoder, NostrEmbed.vue) +- M4.2: Federated search (/search command, SearchResults overlay) +- M4.3: Bookmarks/favorites (Pinia + IDB, FavoriteButton, FavoritesGrid) +- M5.1: E2E encryption (AES-256-GCM, PBKDF2 100K iterations) +- M5.2: Encrypted storage layer (PassphraseDialog, transparent encrypt/decrypt) +- M5.3: API key vault (encrypted IDB, ApiKeyManager.vue, vault integration in useAI) +- M6.1: Lightning wallet deep-links (BOLT11 parser, PaymentButton, LightningInvoice) +- M6.2: Cashu token support (cashu.ts parser, CashuToken.vue inline in chat) +- M6.3: Nostr identity NIP-07 (useNostrIdentity.ts, NostrLogin.vue, bech32 encode) +- M7.1: MCP server integration (tool definitions + handlers for library search) +- M7.2: Multi-provider AI normalization (adapter pattern: Claude/OpenRouter/Ollama) +- M7.3: Tauri desktop build scaffold (frameless window, tray, global shortcut) +- M7.4: Offline mode (useOffline.ts, enhanced PWA image/API caching) + +### 2026-03-03 (cont.) — overnight/2026-03-03 +**Completed M8 Chat UX Polish (10 tasks)**: +- M8.1: Message editing & regeneration (pencil icon, re-send clears subsequent) +- M8.2: Conversation branching (BranchSwitcher.vue, fork from any assistant msg) +- M8.3: Reply-to threading (quoted excerpt in input, `> quote` prepend) +- M8.4: Conversation search (Cmd+F glass panel, match nav, jump to message) +- M8.5: Auto-title generation (background Haiku call after first exchange) +- M8.6: Context window visualiser (ContextBar.vue, token estimate, orange→red) +- M8.7: Conversation export (Markdown/JSON/text, File System Access API) +- M8.8: Import conversations (AIUI JSON + Claude.ai format parser) +- M8.9: Context menus (ContextMenu.vue + ContextMenuItem.vue, right-click) +- M8.10: Scroll position memory (Map per conversation, restore on switch) +- TEST:M8: All 74 tests pass, typecheck + lint clean + +### 2026-03-03 (cont.) — overnight/2026-03-03 +**Completed M9–M20 (all remaining milestones)**: + +**M9: AI Experience (10 tasks)** +- Multi-model comparison, system prompt editor/personas, prompt templates +- Vision input, response feedback, token/cost estimator +- AI memory panel, model capabilities badges, temperature sliders, stop sequences + +**M10: Advanced Content Renderers (12 tasks)** +- Full article renderer, PDF viewer (pdfjs-dist), map renderer (Leaflet) +- Recipe, event, math (KaTeX), Mermaid diagram renderers +- Audio waveform (WaveSurfer.js), table, timeline, code runner, video (HLS.js) + +**M11: Nostr Ecosystem (10 tasks)** +- Publish notes, DMs NIP-17, relay management, profile editor +- Zaps NIP-57, NIP-05 verification, NIP-50 search, thread view +- NIP-51 lists, long-form content NIP-23 + +**M12: Bitcoin Ecosystem (8 tasks)** +- On-chain address display, Fedimint ecash, BOLT12 offers +- NWC (NIP-47), LNURL-auth, live sat/fiat price, mempool viewer, BOLT11 decoder + +**M13: Content Discovery (8 tasks)** +- "For You" feed, content tagging, smart playlists, similar content +- Recently viewed history, content collections, trending, share to Nostr + +**M14: Plugin Marketplace (8 tasks)** +- Plugin discovery UI, settings panel, permissions, dev mode +- Wikipedia + OpenLibrary built-in plugins, import by URL, versioning + +**M15: Settings & Personalisation (10 tasks)** +- Accent colour picker, glass intensity slider, font size settings +- Content visibility toggles, keyboard shortcut map, push notifications +- Auto-archive, full data export, data wipe, default conversation settings + +**M16: Mobile UX Polish (10 tasks)** +- Bottom sheet component, swipe navigation, pull-to-refresh, haptic feedback +- Web Share API, pinch-to-zoom, iOS PWA polish, long-press context menus +- Scroll position memory per route, landscape optimisation + +**M17: Accessibility & Internationalisation (8 tasks)** +- Keyboard nav audit, ARIA audit, high contrast mode, axe-core tests +- i18n foundation (vue-i18n, en/es/fr), RTL support, dyslexia-friendly font, skip nav + +**M18: Performance (8 tasks)** +- Bundle analysis/splitting, image lazy loading, request deduplication +- Web Worker for heavy tasks, prefetch on hover, memory leak audit +- Background sync queue, OPFS storage backend (SQLite WASM) + +**M19: Developer Experience & Quality (8 tasks)** +- Storybook 8, visual regression tests, bundle size CI gate +- Comprehensive mock data (20+ items per type), E2E cross-browser matrix +- Proxy integration tests, Lighthouse CI, dependency audit + +**M20: Collaboration & Sharing (6 tasks)** +- Share conversation via Nostr (kind:30023, NIP-44 encryption) +- Read-only conversation viewer (/view/:nostrAddr) +- Collaborative playlists (NIP-51), conversation templates +- Audio podcast export (Web Speech API), community content packs + +**FINAL**: All gates passed — 101 tests, 0 typecheck errors, 0 lint errors, build succeeds diff --git a/aiui/docs/research/ios-app.md b/aiui/docs/research/ios-app.md new file mode 100644 index 00000000..dcc3032c --- /dev/null +++ b/aiui/docs/research/ios-app.md @@ -0,0 +1,113 @@ +# iOS App Research — AIUI + +## Overview + +Three approaches for shipping AIUI (Vue 3 + Vite SPA) as an iOS app. + +## Approach 1: Capacitor (Recommended) + +Capacitor wraps the Vite build output (`dist/`) in a native iOS Xcode project. The web app runs inside WKWebView with a JavaScript bridge to native device APIs. + +```bash +pnpm add @capacitor/core @capacitor/cli @capacitor/ios +npx cap init && npx cap add ios +pnpm build && npx cap sync +npx cap open ios # opens Xcode +``` + +**Pros:** +- Near-zero code changes to existing Vue 3 app — one codebase for web + iOS + Android +- Large, mature plugin ecosystem (camera, biometrics, push, geolocation, haptics) +- Hot reload during dev via `npx cap run ios --livereload` +- OTA live updates possible via Capgo, bypassing App Store review for JS changes +- `@capacitor/push-notifications` wraps APNs natively + +**Cons:** +- Service workers do NOT work in WKWebView on iOS (capacitor:// protocol breaks SW registration) +- Performance ceiling is WebKit JS engine (not V8) +- Each iOS SDK bump requires Capacitor + plugin updates + +**Push Notifications:** Full support via `@capacitor/push-notifications` (APNs). Production-grade. + +**Offline:** Entire app bundle ships inside .ipa — available offline. Dynamic data must use `@capacitor/preferences` or local SQLite. Workbox/SW caching does not work. + +**Performance:** Modern WKWebView uses Nitro JS engine (same as Safari). For a chat UI like AIUI, indistinguishable from Safari. GPU-accelerated CSS transforms work well. + +## Approach 2: Custom WKWebView Swift Wrapper + +Write a native Swift/SwiftUI app embedding WKWebView. Use `WKScriptMessageHandler` for JS↔Swift communication. + +**Pros:** +- Maximum native control — own the shell, native navigation, gestures +- Can implement App Clips, Share Extensions, Widgets alongside web content +- Full access to all iOS APIs at the native layer + +**Cons:** +- Requires Swift knowledge — adds second language + build system +- JS↔Swift bridge must be hand-written for every integration +- No structured plugin community; each integration is bespoke +- More setup friction vs Capacitor + +**Push/Offline/Performance:** Same as Capacitor (all use WKWebView). More manual setup. + +## Approach 3: React Native WebView + +Create a React Native app with `react-native-webview` rendering the Vite build output. + +**Pros:** +- RN has deep native API access and large ecosystem +- Surrounding shell can be fully native + +**Cons:** +- Two separate tech stacks (Vue + RN) — highest maintenance burden +- No code sharing between Vue app and RN shell +- Performance often worse (full RN runtime + WebView engine) +- RN's own breaking changes cadence adds risk + +**Verdict:** Only justified if an existing RN app is already in production. + +## App Store Risk: Guideline 4.2 + +Apple's Guideline 4.2 (Minimum Functionality) is the primary risk for all webview-based apps. Apps that pass share these traits: +- Native tab bar or navigation (not web-based menus) +- At least one native API integration (push, biometrics, camera, Apple Pay) +- Offline functionality beyond what a browser bookmark offers +- UI formatted for iOS, not a desktop website in a phone frame + +For AIUI: the chat interface, push notifications, and offline message history constitute sufficient native functionality. + +## Service Workers in WKWebView + +**SWs do not run inside WKWebView** — this is a fundamental WebKit limitation, not framework-specific. The correct offline strategy for all three approaches: ship assets in app bundle + implement dynamic caching via native storage APIs. + +## Deep Linking + +All three support iOS Universal Links via AASA file + Associated Domains capability: +- **Capacitor:** `@capacitor/app` `appUrlOpen` event → Vue Router +- **Custom WKWebView:** `AppDelegate.application(_:continue:...)` → JS evaluation +- **RN:** React Navigation linking config → WebView `postMessage` + +## Comparison + +| Dimension | Capacitor | Custom WKWebView | RN WebView | +|---|---|---|---| +| Vue code reuse | 100% | 100% | 100% | +| Native shell effort | Low | High | Very high | +| Push notifications | First-class | Manual APNs | Via RN layer | +| App Store risk | Moderate* | Moderate* | Moderate* | +| Performance | Good | Good | Adequate | +| Maintenance burden | Low-moderate | High | Very high | +| Team fit (web-first) | Best | Poor | Poor | + +*All face identical Guideline 4.2 scrutiny — framework choice is irrelevant to reviewers. + +## Concrete Next Steps + +1. Add `@capacitor/core`, `@capacitor/cli`, `@capacitor/ios` to `packages/app` +2. Set Vite `base: './'` for the Capacitor build config +3. Disable PWA service worker for native builds (partially done already) +4. Add `@capacitor/push-notifications` for APNs +5. Implement native splash screen and app icon +6. Test on iOS Simulator via `npx cap run ios` +7. Set up Apple Developer account + code signing +8. Submit TestFlight build for internal testing diff --git a/aiui/docs/research/mac-desktop.md b/aiui/docs/research/mac-desktop.md new file mode 100644 index 00000000..19e70b09 --- /dev/null +++ b/aiui/docs/research/mac-desktop.md @@ -0,0 +1,119 @@ +# Mac Desktop App Research — AIUI + +## Overview + +Two approaches for shipping AIUI as a Mac desktop app: Tauri v2 (Rust-based, system WebView) vs Electron (Chromium-based). + +## Tauri v2 (Recommended) + +Released stable October 2024. Uses OS-native WebView (WKWebView on macOS). The Vue 3 + Vite frontend runs inside the WebView unchanged. JS calls into Rust via typed IPC bridge. + +**Binary Size:** 2–8 MB installer (no bundled runtime) +**Memory Usage:** ~30–40 MB idle +**Startup Time:** < 500ms + +### Menu Bar App Pattern (Raycast-style) + +Fully supported via `tauri-plugin-positioner` + tray + window APIs. Frameless popover window anchored to tray icon with `decorations: false`, `skip_taskbar: true`. Community examples exist (`ahkohd/tauri-macos-menubar-app-example` v2-popover branch). + +### Global Hotkey + +Built-in via `@tauri-apps/plugin-global-shortcut`. Register accelerators (e.g., `CmdOrCtrl+Space`) that fire even when background/minimized. First-class plugin. + +### System Tray + +First-class support. `AppHandle::tray()` with native menus and click event handling from Rust or frontend. + +### Auto-Update + +`@tauri-apps/plugin-updater` — signed updates required (Ed25519 keypair). Host a static JSON endpoint with version metadata and signed artifact URLs. + +### macOS Code Signing / Notarization + +Automated via Tauri CLI environment variables (`APPLE_CERTIFICATE`, `APPLE_SIGNING_IDENTITY`, `APPLE_ID`, `APPLE_TEAM_ID`). Notarization adds ~2–5 min per build. + +### Build Pipeline + +- Prerequisites: Rust toolchain + Xcode CLI tools +- First build: 5–15 min (Cargo compiles Rust deps) +- Incremental builds: Fast with caching +- Config: `tauri.conf.json` + `Cargo.toml` +- Complexity: Medium-High (Rust requirement is the barrier) + +### Mobile Support + +Tauri v2 has **first-class iOS/Android support** in the same codebase (WKWebView on iOS, Android System WebView on Android). HMR extends to physical devices. This is a genuine differentiator — Electron is desktop-only. + +## Electron + +Mature since 2013. Bundles full Chromium + Node.js runtime. Used by VS Code, Slack, Discord, Obsidian. + +**Binary Size:** 80–150 MB installer +**Memory Usage:** 200–350 MB idle +**Startup Time:** 1–2s + +### Menu Bar App + +Well-established via `menubar` npm package. Creates BrowserWindow positioned below tray icon, manages show/hide on tray click. Very mature. + +### Global Hotkey + +`globalShortcut` module in Electron core. System-wide even when hidden. + +### System Tray + +`Tray` class in Electron core with context menus and click events. + +### Auto-Update + +`electron-updater` (S3/GitHub Releases) or `update.electronjs.org` (free for open-source). + +### macOS Code Signing / Notarization + +Via `@electron/osx-sign` + `@electron/notarize`, integrated into `electron-builder` / Electron Forge. + +### Build Pipeline + +- Prerequisites: Node.js only — no additional runtimes +- Build tools: `electron-vite` for Vue 3 + Vite integration +- Build times: 2–5 min (no Rust compilation) + 2–5 min notarization +- Complexity: Medium (main/renderer process split requires understanding) + +## Comparison + +| Dimension | Tauri v2 | Electron | +|---|---|---| +| Installer size | 2–8 MB | 80–150 MB | +| Idle RAM | 30–40 MB | 200–350 MB | +| Startup time | < 500ms | 1–2s | +| Menu bar app | Supported | Supported | +| Global hotkey | Built-in plugin | Built-in API | +| System tray | Built-in | Built-in | +| Auto-update | Built-in (signed) | electron-updater | +| New language | Rust | None (JS/TS) | +| iOS/Android | Yes (same codebase) | No | +| WebView | WKWebView (varies by OS) | Chromium (pinned, consistent) | +| Ecosystem maturity | Growing fast | Very mature | +| Security model | Capability-based, opt-in | Opt-out, manual discipline | +| Debug tools | Safari Web Inspector | Chrome DevTools | + +## Recommendation + +**Tauri v2 is the stronger choice for AIUI:** + +1. **Memory advantage is decisive.** Users running local LLMs or managing API streaming need resources for the AI workload, not the shell. 30 MB vs 300 MB matters. +2. **Menu bar pattern fits naturally** for a chat/AI assistant (Raycast-style quick invoke). +3. **iOS/Android support** from the same codebase aligns with AIUI's multi-surface vision. +4. **Capability-based security** is appropriate for handling API keys and sensitive chat data. +5. **Binary size matters** — 5 MB download vs 120 MB affects distribution trust. + +## Concrete Next Steps + +1. Scaffold Tauri v2 project: `npm create tauri-app@latest` with Vite template +2. Point dev server to existing `packages/app` Vite config +3. Implement tray icon + menu bar popover window +4. Register global hotkey (e.g., `Cmd+Shift+Space`) to invoke chat +5. Write Rust commands for: file I/O, tray management, updater config +6. Set up macOS code signing + notarization pipeline +7. Distribute via Homebrew cask or direct download +8. Evaluate Tauri mobile targets for iOS/Android convergence diff --git a/aiui/docs/research/plugin-security.md b/aiui/docs/research/plugin-security.md new file mode 100644 index 00000000..6a1545b1 --- /dev/null +++ b/aiui/docs/research/plugin-security.md @@ -0,0 +1,209 @@ +# Plugin System Hardening Research — AIUI + +## Current State + +The plugin system has these existing components: +- `packages/core/src/types/plugin.ts` — `AIUIPlugin` interface, `PluginContext`, `PluginType` +- `packages/core/src/plugins/registry.ts` — in-memory Vue ref-based registry +- `packages/app/src/stores/pluginMarketplace.ts` — `InstalledPlugin`, `PluginPermission`, `installPlugin`, `hasPermission` +- `packages/app/src/components/settings/PluginMarketplace.vue` — permissions dialog +- `packages/app/src/components/renderers/CodeRunner.vue` — existing `<iframe sandbox="allow-scripts">` + postMessage pattern + +**Gaps:** No cryptographic signature verification, no runtime permission enforcement in sandbox, no CSP on plugin iframes. + +## 1. Signature Validation for Community Plugins + +### Problem + +`importFromUrl` fetches arbitrary `aiui-plugin.json` from any URL with no integrity check. A compromised URL grants arbitrary code execution. + +### Recommended: Ed25519 Detached Signatures + +Aligns with existing crypto posture (tweetnacl.js for E2E, Web Crypto for storage). + +**Manifest format:** +```json +{ + "id": "my-plugin", + "name": "My Plugin", + "version": "1.2.0", + "signature": { + "algorithm": "ed25519", + "publicKey": "base64-public-key", + "value": "base64-signature-over-canonical-manifest" + } +} +``` + +**Verification:** Canonical payload = manifest JSON minus `signature` field, sorted keys. Verify via `crypto.subtle.verify('Ed25519', ...)` (Chrome 113+, Firefox 130+, Safari 17+) with `tweetnacl.js` fallback. + +**Integration point:** Gate `installPlugin()` on signature verification for Tier 2+ plugins. + +### Secondary: SRI Hash + +`bundleUrl` + `integrity` field enables browser-native subresource integrity enforcement at load time. + +### Trust Model + +| Tier | Who | Signing | Sandbox | Verification | +|---|---|---|---|---| +| 1 — Built-in | AIUI maintainers | Bundled in app | None (trusted) | None needed | +| 2 — Verified | Reviewed by AIUI team | Ed25519 by author | Full iframe sandbox | Signature + hash | +| 3 — Sideloaded | User-imported URL | Optional | Full iframe sandbox, stricter CSP | Warn prominently | + +## 2. Sandboxed iframe Execution + +### Architecture + +Each community plugin runs in an isolated iframe. AIUI manages a `PluginBridge` service: + +``` +AIUI App (parent) + │ + │ postMessage (structured protocol) + │ + └── Plugin Sandbox iframe (origin: null, sandbox="allow-scripts") + └── Plugin code (no DOM, no storage, no cross-origin network) +``` + +### Sandbox Configuration + +```html +<!-- Tier 2 plugin (headless, no UI) --> +<iframe sandbox="allow-scripts" srcdoc="..." style="display: none" /> + +<!-- Tier 2 plugin with UI panel --> +<iframe sandbox="allow-scripts allow-popups-to-escape-sandbox" srcdoc="..." /> +``` + +**Never grant:** `allow-same-origin` (breaks isolation), `allow-forms`, `allow-top-navigation`, `allow-modals` unless explicitly user-approved. + +### postMessage Protocol + +Typed message envelopes following the archyBridge pattern: + +```typescript +// Plugin → Host +interface PluginRequest { + type: 'plugin:request' + id: string // correlation ID + pluginId: string + capability: string // e.g. 'storage:get', 'network:fetch' + payload: unknown +} + +// Host → Plugin +interface PluginResponse { + type: 'plugin:response' + id: string + success: boolean + data?: unknown + error?: string +} +``` + +### Host-side Validation + +1. `event.origin` must be `null` (sandboxed srcdoc iframes) +2. `pluginId` must match the iframe→plugin mapping +3. Requested capability must be in `grantedPermissions` +4. Rate-limit: reject if > N requests/second (DoS prevention) + +### Network Restriction + +With `sandbox="allow-scripts"` alone, iframes can still `fetch()`. Block direct network via CSP in srcdoc: + +```html +<meta http-equiv="Content-Security-Policy" + content="default-src 'none'; script-src 'unsafe-inline'; connect-src 'none'"> +``` + +Plugins requiring network use the `network:fetch` capability — host proxies the request after validating the URL. + +### Storage Isolation + +Sandboxed iframes without `allow-same-origin` cannot access parent's localStorage/IndexedDB. Plugin storage goes through the `storage` capability, namespaced under `plugin::{id}::`. + +## 3. Permission System Per Plugin + +### Expanded Permissions + +```typescript +export type PluginPermission = + | 'chat-read' // read chat history + | 'chat-inject' // inject messages (high risk) + | 'chat-messages' // read + inject (legacy, maps to both) + | 'network' // outbound HTTPS via host proxy + | 'favorites' // read/write favorites + | 'storage' // namespaced plugin storage + | 'nostr' // access Nostr identity (high risk) + | 'wallet' // deep-link to wallet (high risk) + | 'clipboard' // read/write clipboard + | 'notifications' // send notifications + | 'media-playback' // control media player + | 'renderer' // register content renderer + | 'settings-read' // read AIUI settings +``` + +### Risk Classification + +```typescript +const permissionRisk: Record<PluginPermission, 'low' | 'medium' | 'high'> = { + 'network': 'low', + 'storage': 'low', + 'chat-read': 'low', + 'favorites': 'low', + 'notifications': 'medium', + 'clipboard': 'medium', + 'media-playback': 'medium', + 'renderer': 'medium', + 'settings-read': 'medium', + 'chat-messages': 'high', + 'chat-inject': 'high', + 'nostr': 'high', + 'wallet': 'high', +} +``` + +### User Consent Flow + +1. High-risk permissions default to unchecked in consent dialog +2. Show risk badges (low/medium/high) next to each permission +3. Unverified plugins (Tier 3) show prominent warning before permissions dialog +4. Progressive disclosure: summary before detailed checkboxes + +### Permission Revocation + +1. Terminate plugin iframe immediately (`iframe.remove()`) +2. Host handler rechecks `hasPermission()` on every capability call +3. Emit `plugin:permission-revoked` event so plugin can react gracefully + +### Least Privilege + +- Plugins declare minimum permissions in manifest +- All capabilities gated on `hasPermission()` — no admin override +- Storage namespaced under `plugin::{id}::` +- Optional `allowedOrigins[]` in manifest restricts network targets +- Audit log: capability invocations logged with plugin ID + +## Integration Points + +### Files to modify: + +**`packages/core/src/types/plugin.ts`** — Add `tier`, `signature`, `sandbox` fields to manifest type. + +**`packages/app/src/stores/pluginMarketplace.ts`** — Add `verifyManifestSignature()` in `installPlugin()`. Add `revokePermission()`. Move `grantedPermissions` to encrypted storage. + +**`packages/core/src/plugins/registry.ts`** — Evolve into `PluginHost` service that manages sandbox iframe lifecycle and routes postMessage capability requests. + +**`packages/app/src/plugins/index.ts`** — Tier 1 plugins use `registerPlugin()` directly. Tier 2+ load through `PluginHost.loadSandboxed(manifest)`. + +## Concrete Next Steps + +1. Define `PluginSignature` type and `verifyManifestSignature()` using tweetnacl.js +2. Create `PluginSandbox` service to manage iframe lifecycle + postMessage routing +3. Add CSP meta tag to plugin srcdoc template +4. Implement capability handlers (storage, network, chat) with permission checks +5. Update consent dialog with risk classification badges +6. Add `revokePermission()` with live iframe termination +7. Create CLI tool for plugin authors to sign manifests diff --git a/aiui/loop/README.md b/aiui/loop/README.md new file mode 100644 index 00000000..a85d0337 --- /dev/null +++ b/aiui/loop/README.md @@ -0,0 +1,150 @@ +# Overnight Claude Automation + +Run Claude Code autonomously while you're away. Combines sleep prevention, task-based execution, the Ralph Wiggum Technique (Stop hook blocks until plan is complete), and security hooks that restrict AI to project files and block destructive commands. + +## Prerequisites + +- **Claude Code CLI** ([claude.ai/code](https://claude.ai/code)) — installed at `~/.local/bin/claude` or in PATH +- **Hooks** — user-level hooks in `~/.claude/` (sleep, Ralph Wiggum) +- **jq** — for security hook scripts (`brew install jq`) + +## Flow + +### Pre-run (before 5–6pm) + +1. **Commit and push** — Snap current work and back up to remote. +2. **Run prepare script** — Creates date-stamped branch and verifies clean state: + + ```bash + ./loop/prepare.sh + ``` + +3. **Edit plan** — Update `loop/plan.md` with evening scope and tasks (see template below). +4. **Commit plan** — Version the plan so you can revert if needed: + + ```bash + git add loop/plan.md && git commit -m "chore: overnight plan $(date +%Y-%m-%d)" + ``` + +5. **Push** (optional but recommended): `git push -u origin overnight/YYYY-MM-DD` + +### Overnight + +```bash +tmux new -s overnight +caffeinate -i ./loop/loop.sh +# Detach: Ctrl+B, then D +``` + +### Post-run (next morning) + +1. `git status` and `git diff` to review changes. +2. Run `pnpm test && pnpm lint && pnpm typecheck`. +3. Merge branch or revert if needed. + +## Quick Start + +1. **Edit your plan** — Add tasks to `loop/plan.md` using the evening run format: + + ```markdown + # Evening Run — YYYY-MM-DD + + ## Scope + Add tests to chat components. + + ## Tasks + - [ ] Add unit tests for useAI composable + - [ ] Fix linter errors in packages/app + ``` + +2. **Run overnight** — From project root: + + ```bash + caffeinate -i ./loop/loop.sh + ``` + +## How It Works + +| Component | Purpose | +|-----------|---------| +| **UserPromptSubmit hook** | Starts `caffeinate` to prevent Mac sleep when you submit a prompt | +| **Stop hook** | Checks `plan.md` for unchecked tasks; blocks Claude from stopping until all are done (Ralph Wiggum) | +| **SessionEnd hook** | Kills `caffeinate` so Mac can sleep again | +| **PreToolUse (Bash)** | Blocks dangerous commands (rm -rf, git reset --hard, etc.) | +| **PreToolUse (Edit\|Write)** | Blocks edits outside project and to protected paths | +| **loop.sh** | Runs Claude with `--dangerously-skip-permissions` and feeds the prompt from `loop/prompt.md` | + +## Security Model + +Project-scoped hooks in `.claude/hooks/` restrict the AI during overnight runs: + +### Bash guard (`block-risky-bash.sh`) + +Blocks: `rm -rf`, `git reset --hard`, `git push --force`, `git clean -fd`, `chmod -R 777`, fork bombs, block device overwrites, `mkfs`, and path traversal with destructive commands. + +### File edit guard (`protect-files.sh`) + +Blocks Edit/Write when: + +- Path is **outside project directory** +- Path contains **`.git/`** +- Path is **`.env`**, **`.env.local`**, **`.env.*.local`** +- Path is **`package-lock.json`** or **`pnpm-lock.yaml`** +- Path contains **`node_modules/`** + +Read, Glob, and Grep remain unrestricted. + +## Environment Variables + +| Variable | Default | Description | +|----------|---------|-------------| +| `CLAUDE_AUTONOMOUS` | `1` | Set to `1` to enable Ralph Wiggum (Stop hook checks plan). `0` disables. | +| `CLAUDE_PLAN_FILE` | `plan.md` | Plan file path (relative to project). | +| `ITERATION_COUNT` | `1` | Number of loop iterations (use >1 for multi-run without Ralph Wiggum). | +| `ITERATION_DELAY` | `600` | Seconds between iterations when `ITERATION_COUNT` > 1. | +| `PROMPT_FILE` | `loop/prompt.md` | Prompt content for Claude. | +| `LOG_FILE` | `loop/loop.log` | Log output (gitignored). | +| `RATE_LIMIT_WAIT` | `3600` | Seconds to wait when rate limited (default 1 hour). | +| `MAX_RATE_LIMIT_RETRIES` | `5` | Max rate limit retries before scheduling launchd job. | + +## Rate Limit Handling + +The loop script automatically detects rate limits (429, quota exceeded, etc.) and handles them: + +1. **Inline retry** — On first rate limit hit, sleeps for `RATE_LIMIT_WAIT` seconds (default 1 hour) and retries. +2. **Escalating retries** — Retries up to `MAX_RATE_LIMIT_RETRIES` times with the same wait. +3. **launchd fallback** — After max retries, creates a self-cleaning launchd plist at `~/Library/LaunchAgents/com.aiui.overnight-retry.plist` that restarts the loop at the estimated reset time. The plist auto-removes after running. + +This means you can walk away knowing the automation will survive rate limits overnight. + +## Scheduling (Optional) + +Install [claude-code-schedule](https://github.com/macalinao/claude-code-schedule) for time-based runs: + +```bash +cargo install claude-code-schedule +ccschedule --time 05:30 --message "Review plan.md and complete next task" +``` + +## continuous-claude (Optional) + +For full PR-based workflow (branches, PRs, CI): + +```bash +# Install from https://github.com/AnandChowdhary/continuous-claude +continuous-claude -p "Work through loop/plan.md" -m 10 --max-duration 8h +``` + +## Remote Monitoring + +- **Tmux + SSH**: Attach from another machine: `ssh host 'tmux attach -t overnight'` +- **Tailscale**: Use Tailscale for easy remote SSH when away from home network +- **Log tail**: `tail -f loop/loop.log` to watch progress + +## Safety + +- **Start small** — Test with 1–2 tasks before overnight runs +- **prepare.sh** — Run before starting; fails if working tree is dirty or branch exists +- **Git** — Loop does not auto-commit; you review and merge in the morning +- **`--dangerously-skip-permissions`** — Security hooks still run and block dangerous actions +- **Project-scoped hooks** — Only apply when Claude runs in AIUI; other projects unaffected diff --git a/aiui/loop/hardening-plan.md b/aiui/loop/hardening-plan.md new file mode 100644 index 00000000..12c74dc3 --- /dev/null +++ b/aiui/loop/hardening-plan.md @@ -0,0 +1,286 @@ +# AIUI Debug, Fix & Hardening Plan + +## Execution Rules + +- Run on `development` branch +- Each `- [ ]` task is one agent iteration — complete fully before moving on +- After each phase: `pnpm typecheck && pnpm lint && pnpm test -- --run` +- If checks fail, fix before proceeding +- Commit at end of each phase with `type(scope): description` format +- Do NOT push — human will review and push + +--- + +## PHASE 0: Baseline Verification + +- [ ] **P0.1 — Baseline check** + - Run: `pnpm install && pnpm typecheck && pnpm lint && pnpm test -- --run` + - Record output. Note existing failures. Fix any blockers before proceeding. + - Commit: `chore(app): verify baseline before hardening` + +--- + +## PHASE 1: Critical Security Fixes + +- [ ] **P1.1 — Fix CORS wildcard in claude-proxy.ts** + - File: `packages/app/server/claude-proxy.ts` + - Replace all `'Access-Control-Allow-Origin': '*'` (lines ~415, 473, 531) with the correct localhost origin or import `ALLOWED_ORIGIN` from `dev-auth.ts`. Match the pattern already used on lines 294/303. + - Verify: `grep -n "Allow-Origin.*\*" packages/app/server/claude-proxy.ts` returns zero matches. + +- [ ] **P1.2 — Fix dev auth token bypass** + - File: `packages/app/server/dev-auth.ts` + - Line 11: `if (!token) return true` skips auth entirely when token empty. + - Fix: Only skip in non-production. `if (!token) { if (process.env.NODE_ENV === 'production') { res.writeHead(401); res.end('Unauthorized'); return false; } return true; }` + - Add console.warn when auth disabled. + +- [ ] **P1.3 — Symlink traversal protection in vite-fs.ts** + - File: `packages/app/vite-fs.ts` + - In `walk` function: after constructing `fullPath`, add `if (lstatSync(fullPath).isSymbolicLink()) continue;` + - In `handleRead`: before `statSync`, check `lstatSync(filePath).isSymbolicLink()` → return 403. + - Import `lstatSync` from `fs`. + +- [ ] **P1.4 — Body size limit on vite-fs.ts mkdir** + - File: `packages/app/vite-fs.ts` + - `handleMkdir` reads body with no size cap (lines 185-209). + - Add `const MAX_BODY_SIZE = 1024`. Track `let size = 0` on `data` events. Return 413 if exceeded. + +- [ ] **P1.5 — JSON schema validation in vite-dev-chats.ts** + - File: `packages/app/vite-dev-chats.ts` + - After `JSON.parse(body)` on line 66, validate shape: must be object with optional `conversations` (object) and `activeConversationId` (string|null). Reject with 400 if invalid. + +- [ ] **P1.6 — CSP headers in nginx-archy.conf** + - File: `packages/app/server/nginx-archy.conf` + - Add inside `/aiui/` location block: + ``` + add_header Content-Security-Policy "default-src 'self'; script-src 'self'; style-src 'self' 'unsafe-inline'; img-src 'self' https: data:; connect-src 'self' https://api.anthropic.com https://openrouter.ai https://wavlake.com https://itunes.apple.com https://openlibrary.org https://covers.openlibrary.org https://en.wikipedia.org https://www.googleapis.com https://image.tmdb.org; frame-ancestors 'self'; base-uri 'self'; form-action 'self';" always; + add_header X-Content-Type-Options "nosniff" always; + add_header X-Frame-Options "SAMEORIGIN" always; + add_header Referrer-Policy "strict-origin-when-cross-origin" always; + ``` + +- [ ] **P1.7 — OpenRouter validation + streaming timeout** + - File: `packages/app/server/claude-proxy.ts` + - OpenRouter handler: parse `reqBody`, validate has `model` (string), `messages` (array), `stream` (boolean). Return 400 if invalid. + - Both streaming reader loops: add idle timeout (120s). `let idleTimer = setTimeout(() => reader.cancel(), 120000)`. Reset on each chunk. Clear on completion. + - Verify: `pnpm typecheck` + - **Commit**: `fix(app): critical security — CORS, auth, symlink, CSP, body validation` + +--- + +## PHASE 2: Proxy & Server Hardening + +- [ ] **P2.1 — Fix tool use loop in claude-proxy.ts** + - File: `packages/app/server/claude-proxy.ts` (lines 164-222) + - Unknown tool names: push error tool_result `{ type: 'tool_result', tool_use_id: tu.id, content: 'Error: unknown tool', is_error: true }`. + - Add turnMessages size guard: `if (JSON.stringify(turnMessages).length > 500_000)` break loop. + - Add `AbortSignal.timeout(30000)` on each API call within loop. + +- [ ] **P2.2 — SSRF mitigation in vite-rss.ts** + - File: `packages/app/vite-rss.ts` + - After `tryParseFeed` returns, validate returned article URLs with `isPrivateUrl`. Filter out any private URLs. + - Add comment documenting residual TOCTOU risk (dev-only middleware). + +- [ ] **P2.3 — Sanitize web search context in useAI.ts** + - File: `packages/app/src/composables/useAI.ts` (lines 399-408) + - Create helper: `function sanitizeSearchText(s: string): string { return s.replace(/[[\]()#*_~` >]/g, '\\$&').replace(/\n/g, ' ').slice(0, 500) }` + - Apply to `r.title` and `r.content` in `formatWebSearchContext`. + - **Commit**: `fix(app): proxy hardening — tool loop, SSRF, prompt injection` + +--- + +## PHASE 3: State & Logic Bugs + +- [ ] **P3.1 — Fix contentType assignment in useContentPanel.ts** + - File: `packages/app/src/composables/useContentPanel.ts` (lines 219-225) + - The `contentType` ref type is `'film' | 'song' | 'podcast'`. Expand to include all content types or use `ContentTab`. Fix assignments: books→'film' is acceptable if type can't expand, but news/TV should map correctly. If the type is only used for player logic, keep narrow type but fix assignments to be semantically correct. + +- [ ] **P3.2 — Fix apiFetching race in useBannerFallback.ts** + - File: `packages/app/src/composables/useBannerFallback.ts` + - Change `let apiFetching = false` to `const apiFetching = ref(false)`. Update all references to `.value`. + +- [ ] **P3.3 — Fix event listener cleanup in chat.ts** + - File: `packages/app/src/stores/chat.ts` (lines 111-118) + - Extract handlers to named functions. Add `import.meta.hot?.dispose()` cleanup for HMR. + +- [ ] **P3.4 — Fix news RSS race condition in useContentPanel.ts** + - File: `packages/app/src/composables/useContentPanel.ts` (lines 134-154) + - Capture `const tabAtStart = activeTab.value` before RSS fetch. In `.then()`, only set `activeTab.value = 'news'` if `activeTab.value === tabAtStart` (user hasn't manually switched). + +- [ ] **P3.5 — Add error logging to silent catch blocks** + - Files: `useImageFallback.ts`, `useAI.ts`, `stores/chat.ts` + - Replace all `catch { }` and `catch { /* ignore */ }` with `catch(e) { console.debug('[module] op failed:', e) }`. + - Keep `console.debug` (not `error`) for expected failures. + - **Commit**: `fix(app): state bugs — contentType, race conditions, error logging` + +--- + +## PHASE 4: Content Extraction & Filtering + +- [ ] **P4.1 — Add recipe instructions to system prompt** + - File: `packages/app/src/composables/useAI.ts` (SYSTEM_PROMPT) + - Add after Apps section: + ``` + **Recipes:** When sharing recipes, use the <recipe_ext> XML tag: + <recipe_ext title="Name" servings="4" time="30 min" calories="450"> + - ingredient 1 + 1. Step one + </recipe_ext> + ``` + +- [ ] **P4.2 — Fix app category validation** + - File: `packages/app/src/composables/contentExtraction.ts` (line ~1330) + - Add `const VALID_CATEGORIES = new Set(['nostr-client','lightning-wallet','bitcoin-wallet','privacy','node','dev-tool','relay'])`. Validate before cast, fallback to `'dev-tool'`. + +- [ ] **P4.3 — Fix song deduplication** + - File: `packages/app/src/composables/contentExtraction.ts` (lines 606-638) + - After combining library + external songs, deduplicate by normalized `title|artist` key. Library songs (with real IDs) take priority. + +- [ ] **P4.4 — Fix film/TV tag ambiguity** + - File: `packages/app/src/composables/contentExtraction.ts` (lines 914-916) + - Only convert `film_ext` to TV when: query is TV-like AND no explicit `tv_ext` tags present AND response reads TV-like. If AI used both `film_ext` and `tv_ext`, keep both as-is. + +- [ ] **P4.5 — Fix isBookLikeResponse false positives** + - File: `packages/app/src/composables/contentFiltering.ts` (lines 60-62) + - Increase `by [Author]` threshold from `>= 1` to `>= 2`. + +- [ ] **P4.6 — Fix isRecipeLikeResponse for prose recipes** + - File: `packages/app/src/composables/contentFiltering.ts` (lines 92-94) + - Add prose detection: recipe keywords + list structure pattern. + +- [ ] **P4.7 — Fix looksLikeSong false positives** + - File: `packages/app/src/composables/contentExtraction.ts` (lines 488-506) + - Add financial terms to blocklist: `'market cap','etf','price','trading','volume','earnings','valuation','stock','portfolio','investment','yield','inflation','interest rate'`. + - **Commit**: `fix(app): extraction — dedup, tag ambiguity, false positives, recipes` + +--- + +## PHASE 5: Memory & Cache Hardening + +- [ ] **P5.1 — Bound caches in useImageFallback.ts** + - File: `packages/app/src/composables/useImageFallback.ts` + - Create helper: `function boundedSet<K,V>(map: Map<K,V>, key: K, val: V, max=500) { if (map.size >= max) { const first = map.keys().next().value; if (first !== undefined) map.delete(first); } map.set(key, val); }` + - Replace all `.set()` calls on memory caches with `boundedSet()`. + - Also bound `failedUrls` Set to 1000 entries. + +- [ ] **P5.2 — Bound caches in usePlayer.ts + cleanup** + - File: `packages/app/src/composables/usePlayer.ts` + - Bound `resultCache` and `nullCacheTimestamps` to 200 entries. + - Add `destroy()` method that calls `destroyPlayer()`, resets DOM refs, cancels active search controller. + - **Commit**: `fix(app): bound caches, player cleanup` + +--- + +## PHASE 6: Accessibility + +- [ ] **P6.1 — Fix touch targets and aria attributes** + - Audit all `*Grid.vue` and `*Detail.vue` for: interactive elements < 44px, images without alt, SVG fallbacks without aria-label. + - ImageGrid.vue: add `role="img"` `aria-label="Image unavailable"` to fallback SVG wrapper. + - Ensure all interactive genre/tag pills in grids have adequate touch targets (min 44x44 including padding). + - Non-interactive info pills are exempt. + - **Commit**: `fix(app): accessibility — touch targets, alt text, aria` + +--- + +## PHASE 7: Security Tests + +- [ ] **P7.1 — Write dev-auth tests** + - New file: `packages/app/src/__tests__/dev-auth.test.ts` + - Tests: auth bypass when no token (dev), auth required when token set, rate limiting 429, CORS headers correct. + +- [ ] **P7.2 — Extend proxy tests** + - File: `packages/app/src/__tests__/proxy.test.ts` + - Tests: unknown tool error result, max rounds termination, turnMessages size guard, streaming timeout. + +- [ ] **P7.3 — Write vite-fs security tests** + - New file: `packages/app/src/__tests__/vite-fs.test.ts` + - Tests: path traversal rejected, symlink blocked, sensitive files blocked, body size limit enforced. + +- [ ] **P7.4 — Write SSRF tests for vite-rss** + - New file: `packages/app/src/__tests__/vite-rss.test.ts` + - Tests: private IP detection, localhost blocked, non-http blocked, private URLs filtered from results. + - **Commit**: `test(app): security tests — auth, proxy, fs, SSRF` + +--- + +## PHASE 8: Content Extraction Tests + +- [ ] **P8.1 — Write content filtering tests** + - New file: `packages/app/src/composables/__tests__/contentFiltering.test.ts` + - Tests: isBookLikeResponse false positive fix, isRecipeLikeResponse prose detection, isMusicQuery rejects financial terms, filterTabsByContext ordering. + +- [ ] **P8.2 — Extend content extraction tests** + - File: `packages/app/src/__tests__/contentExtraction.test.ts` + - Tests: song dedup, looksLikeSong rejects financial terms, app category fallback, recipe parsing. + +- [ ] **P8.3 — Write TV/film resolution tests** + - Same file as P8.2. + - Tests: film_ext→TV conversion rules, book_ext in news context, explicit tags respected. + - **Commit**: `test(app): content extraction — filtering, dedup, tag resolution` + +--- + +## PHASE 9: State & Composable Tests + +- [ ] **P9.1 — Write useContentPanel tests** + - File: `packages/app/src/composables/__tests__/useContentPanel.test.ts` + - Tests: contentType assignment per content type, RSS tab override prevention, closePanel resets, empty text → no tabs. + +- [ ] **P9.2 — Write useBannerFallback tests** + - New file: `packages/app/src/composables/__tests__/useBannerFallback.test.ts` + - Tests: primary URL fallthrough, API fetch on exhaustion, apiFetching guard, gradient fallback. + +- [ ] **P9.3 — Write chat store tests** + - New file: `packages/app/src/__tests__/chat-store.test.ts` + - Tests: createConversation, addMessage, deleteConversation, branchFromMessage, debouncedIDBSave. + - **Commit**: `test(app): state tests — contentPanel, bannerFallback, chat store` + +--- + +## PHASE 10: AI Integration Tests + +- [ ] **P10.1 — Extend useAI tests** + - File: `packages/app/src/__tests__/useAI.test.ts` + - Tests: system prompt includes recipe instructions, web search results sanitized, editAndResend truncates, sanitizeHistory merges consecutive roles. + +- [ ] **P10.2 — Write useImageFallback cache tests** + - New file: `packages/app/src/composables/__tests__/useImageFallback.test.ts` + - Tests: boundedSet evicts at max, generatePosterFallback valid SVG, escapeXml handles specials. + - **Commit**: `test(app): AI integration, image fallback cache tests` + +--- + +## PHASE 11: Final Verification + +- [ ] **P11.1 — Full integration check** + - Run: `pnpm typecheck && pnpm lint && pnpm test -- --run` + - Run: `pnpm build` — verify production build succeeds + - Fix any regressions. + +- [ ] **P11.2 — Final commit** + - Run coverage report if configured: `pnpm test -- --run --coverage` + - Ensure all changes committed on `development`. + - **Commit**: `chore(app): hardening complete — all checks pass` + +--- + +## Phase Dependencies + +``` +P0 → P1 → P2 → P3 → P4 → P5 ─┐ + ├→ P7 (tests P1,P2) + P6 ──┤ + ├→ P8 (tests P4) + ├→ P9 (tests P3,P5) + ├→ P10 (tests P2.3,P4.1) + └→ P11 (final) +``` + +P5 and P6 can run in parallel. P7-P10 can run in any order after their fix phases. + +## Summary + +- **12 phases**, **42 tasks** +- Phases 0-6: fixes (security → proxy → state → extraction → caches → a11y) +- Phases 7-10: tests (security → extraction → state → AI) +- Phase 11: final verification +- Target: all checks pass, 40%+ test coverage on critical paths, secure for Archy deployment diff --git a/aiui/loop/loop.sh b/aiui/loop/loop.sh new file mode 100755 index 00000000..7498dd3b --- /dev/null +++ b/aiui/loop/loop.sh @@ -0,0 +1,200 @@ +#!/usr/bin/env sh +# Headless loop script for overnight Claude Code automation. +# Set CLAUDE_AUTONOMOUS=1 for Ralph Wiggum (Stop hook blocks until plan is complete). +# Rate-limit aware: detects limits, sleeps until reset, and retries automatically. +set -u + +PROJECT_DIR="${CLAUDE_PROJECT_DIR:-$(cd "$(dirname "$0")/.." && pwd)}" +PROMPT_FILE="${PROMPT_FILE:-$PROJECT_DIR/loop/prompt.md}" +LOG_FILE="${LOG_FILE:-$PROJECT_DIR/loop/loop.log}" +ITERATION_COUNT="${ITERATION_COUNT:-10}" +ITERATION_DELAY="${ITERATION_DELAY:-30}" +CLAUDE_BIN="${CLAUDE_BIN:-claude}" +RATE_LIMIT_WAIT="${RATE_LIMIT_WAIT:-3600}" # Default: wait 1 hour on rate limit +MAX_RATE_LIMIT_RETRIES="${MAX_RATE_LIMIT_RETRIES:-5}" # Max retries before giving up +CLAUDE_EXIT=0 + +cd "$PROJECT_DIR" + +# Human-readable log with visual separators +log() { + echo "$1" | tee -a "$LOG_FILE" +} + +banner() { + log "" + log "════════════════════════════════════════════════════════════════" + log " $1" + log " $(date '+%Y-%m-%d %H:%M:%S')" + log "════════════════════════════════════════════════════════════════" + log "" +} + +section() { + log "" + log "────────────────────────────────────────" + log " $1" + log "────────────────────────────────────────" + log "" +} + +# Check if plan has remaining tasks +plan_has_tasks() { + grep -q '^\- \[ \]' "$PROJECT_DIR/loop/plan.md" 2>/dev/null +} + +# Show remaining task count +remaining_tasks() { + grep -c '^\- \[ \]' "$PROJECT_DIR/loop/plan.md" 2>/dev/null || echo "0" +} + +# Show next task +next_task() { + grep -m1 '^\- \[ \]' "$PROJECT_DIR/loop/plan.md" 2>/dev/null | sed 's/^- \[ \] //' || echo "(none)" +} + +# Detect rate limit from Claude output (only when Claude exited non-zero) +check_rate_limit() { + [ "${CLAUDE_EXIT:-0}" -eq 0 ] && return 1 + # Check last 50 lines for rate limit indicators, excluding our own log lines + tail -50 "$LOG_FILE" 2>/dev/null | grep -v "^Rate limit detected" | grep -v "^Sleeping" | grep -v "^═" | grep -v "^─" | grep -qi \ + -e "rate.limit" \ + -e "too.many.requests" \ + -e "429" \ + -e "quota.exceeded" \ + -e "usage.limit" \ + -e "limit.reached" 2>/dev/null +} + +banner "OVERNIGHT AUTOMATION STARTED" +log " Project: $PROJECT_DIR" +log " Prompt: $PROMPT_FILE" +log " Autonomous: ${CLAUDE_AUTONOMOUS:-0}" +log " Iterations: $ITERATION_COUNT (${ITERATION_DELAY}s between each)" +log " Rate limit: wait ${RATE_LIMIT_WAIT}s, retry up to ${MAX_RATE_LIMIT_RETRIES}x" +log " Tasks left: $(remaining_tasks)" +log " Next task: $(next_task)" +log "" + +i=1 +rate_limit_retries=0 +while [ "$i" -le "$ITERATION_COUNT" ]; do + + # Check if there are tasks remaining before starting + if ! plan_has_tasks; then + banner "ALL TASKS COMPLETE" + log " No remaining tasks in plan.md. Stopping." + break + fi + + section "ITERATION $i/$ITERATION_COUNT" + log " Tasks remaining: $(remaining_tasks)" + log " Next task: $(next_task)" + log "" + + export CLAUDE_PROJECT_DIR="$PROJECT_DIR" + export CLAUDE_AUTONOMOUS="${CLAUDE_AUTONOMOUS:-1}" + + # Run Claude with autonomous permissions; prompt from file + if [ -f "$PROMPT_FILE" ]; then + log " Starting Claude session..." + log "" + "$CLAUDE_BIN" -p --dangerously-skip-permissions \ + < "$PROMPT_FILE" 2>&1 | tee -a "$LOG_FILE" + CLAUDE_EXIT=$? + log "" + log " Claude exited with code: $CLAUDE_EXIT" + else + log " ERROR: $PROMPT_FILE not found" + exit 1 + fi + + # Check for rate limit after Claude exits + if check_rate_limit; then + rate_limit_retries=$((rate_limit_retries + 1)) + if [ "$rate_limit_retries" -ge "$MAX_RATE_LIMIT_RETRIES" ]; then + section "RATE LIMITED — SCHEDULING LAUNCHD RETRY" + log " Hit rate limit $rate_limit_retries times. Creating launchd job to retry later." + + # Schedule a retry using launchd for after rate limit resets + PLIST_LABEL="com.aiui.overnight-retry" + PLIST_PATH="$HOME/Library/LaunchAgents/${PLIST_LABEL}.plist" + RETRY_TIME=$(date -v+${RATE_LIMIT_WAIT}S '+%H:%M' 2>/dev/null || date -d "+${RATE_LIMIT_WAIT} seconds" '+%H:%M') + RETRY_HOUR=$(echo "$RETRY_TIME" | cut -d: -f1) + RETRY_MIN=$(echo "$RETRY_TIME" | cut -d: -f2) + + cat > "$PLIST_PATH" <<PLIST +<?xml version="1.0" encoding="UTF-8"?> +<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd"> +<plist version="1.0"> +<dict> + <key>Label</key> + <string>${PLIST_LABEL}</string> + <key>ProgramArguments</key> + <array> + <string>/bin/sh</string> + <string>-c</string> + <string>cd ${PROJECT_DIR} && caffeinate -i ./loop/loop.sh >> ${LOG_FILE} 2>&1; launchctl unload ${PLIST_PATH}; rm -f ${PLIST_PATH}</string> + </array> + <key>StartCalendarInterval</key> + <dict> + <key>Hour</key> + <integer>${RETRY_HOUR}</integer> + <key>Minute</key> + <integer>${RETRY_MIN}</integer> + </dict> + <key>EnvironmentVariables</key> + <dict> + <key>CLAUDE_AUTONOMOUS</key> + <string>1</string> + <key>CLAUDE_PROJECT_DIR</key> + <string>${PROJECT_DIR}</string> + <key>PATH</key> + <string>/usr/local/bin:/usr/bin:/bin:$HOME/.local/bin</string> + </dict> + <key>StandardOutPath</key> + <string>${LOG_FILE}</string> + <key>StandardErrorPath</key> + <string>${LOG_FILE}</string> +</dict> +</plist> +PLIST + + launchctl load "$PLIST_PATH" 2>/dev/null || true + log " Scheduled retry at ~${RETRY_TIME}" + log " Plist: $PLIST_PATH (auto-removes after running)" + exit 0 + fi + + section "RATE LIMITED — WAITING" + log " Attempt $rate_limit_retries/$MAX_RATE_LIMIT_RETRIES" + log " Sleeping ${RATE_LIMIT_WAIT}s until $(date -v+${RATE_LIMIT_WAIT}S '+%H:%M:%S' 2>/dev/null || date -d "+${RATE_LIMIT_WAIT} seconds" '+%H:%M:%S')..." + sleep "$RATE_LIMIT_WAIT" + + # Check if plan still has tasks before retrying + if ! plan_has_tasks; then + banner "ALL TASKS COMPLETE (during rate limit wait)" + break + fi + log " Retrying..." + continue # Retry same iteration + fi + + # Reset rate limit counter on successful run + rate_limit_retries=0 + + section "ITERATION $i COMPLETE" + log " Tasks remaining: $(remaining_tasks)" + log " Next task: $(next_task)" + + i=$((i + 1)) + if [ "$i" -le "$ITERATION_COUNT" ] && [ "$ITERATION_DELAY" -gt 0 ]; then + log " Pausing ${ITERATION_DELAY}s before next iteration..." + sleep "$ITERATION_DELAY" + fi +done + +banner "LOOP FINISHED" +log " Completed $((i - 1)) iterations" +log " Tasks remaining: $(remaining_tasks)" +log "" diff --git a/aiui/loop/plan.md b/aiui/loop/plan.md new file mode 100644 index 00000000..0d0e1cd9 --- /dev/null +++ b/aiui/loop/plan.md @@ -0,0 +1,59 @@ +# Overnight Plan — 2026-03-04 + +## Phase 1: Critical Fixes + +- [x] P1-1: Brighten SVG fallbacks — increase background lightness from 18% to 28% across all 8 generators in `useImageFallback.ts` (generateSongCoverFallback, generatePodcastCoverFallback, generateNewsFallback, generateImageFallback, generatePosterFallback, generateTVSeriesFallback, generateBookCoverFallback, generatePlaceFallback). Proportionally increase all inner element lightness by +10%. TEST: run `pnpm typecheck` and visually confirm SVGs generate valid data URIs. +- [x] P1-2: Add `.catch(() => {})` to all cover fetch promise chains in grid components — SongGrid.vue (line 157), FilmGrid.vue (line 143), TVSeriesGrid.vue (line 160), BookGrid.vue (line 141), PodcastGrid.vue (line 130). Prevents unhandled rejection if fetch throws unexpectedly. TEST: `pnpm typecheck && pnpm lint`. +- [x] P1-3: Refine mobile keyboard handling — In `useVisualViewport.ts`, add debounce to viewport change handler (50ms) to prevent jittery resizing. In `App.vue`, ensure the `rootStyle` computed applies `overflow: hidden` when keyboard is open. TEST: `pnpm typecheck`. +- [x] P1-4: Verify service worker cleanup — Confirm `dev-dist/sw.js` contains the self-destructing SW and `vite.config.ts` has `devOptions.enabled: false`. If not, fix. TEST: read both files and verify. + +## Phase 2: Error Handling Hardening + +- [x] P2-1: Wrap JSON.parse calls in try/catch — `useContentDiscovery.ts` sessionStorage parse, all sessionStorage/localStorage reads in composables. Search for `JSON.parse` across all `.ts` and `.vue` files, wrap any unprotected calls. TEST: `pnpm typecheck && pnpm lint`. +- [x] P2-2: Add `.ok` checks before `.json()` on fetch calls — `useBitcoinPrice.ts` (Mempool API), `MempoolTxCard.vue` (tip height), `useNip05Verification.ts` (NIP-05 lookup), `ZapDialog.vue` (Lightning address). Search for `fetch(` → `.json()` patterns without `.ok` check. TEST: `pnpm typecheck && pnpm lint`. +- [x] P2-3: Harden SSE streaming — In `useAI.ts` `readSSE()`, wrap `reader.read()` in try/catch, close reader on error. In `openrouter-adapter.ts`, add same pattern. TEST: `pnpm typecheck`. +- [x] P2-4: Add error handling to async watchers — `PdfViewer.vue` watch calling `renderPage()`, `VideoPlayer.vue` `initHls()` in onMounted. Wrap in try/catch with user-friendly error state. TEST: `pnpm typecheck`. + +## Phase 3: Security Hardening + +- [x] P3-1: postMessage origin validation — In `archyBridge.ts`, replace `'*'` targetOrigin with configurable origin. Add origin check on incoming message handler. TEST: `pnpm typecheck`. +- [x] P3-2: URL validation — In `contentExtraction.ts`, add URL length limit (2048 chars) to `extractUrlFromText()`. Validate URLs before fetch. TEST: `pnpm typecheck && pnpm lint`. +- [x] P3-3: Content sanitization — Review `html.ts` for innerHTML usage, ensure SVG injection is covered. Replace `innerHTML = ''` with `textContent = ''` in `usePlayer.ts`. TEST: `pnpm typecheck`. +- [x] P3-4: Add CSP meta tag — Add `<meta http-equiv="Content-Security-Policy" ...>` to `index.html` with appropriate directives for the app (allow self, API hosts, image CDNs). TEST: `pnpm typecheck`. + +## Phase 4: Test Suite + +- [x] P4-1: Unit tests for usePlayer — Create `packages/app/src/composables/__tests__/usePlayer.test.ts`. Test playback state, queue management, play/pause/next/prev. Minimum 8 test cases. TEST: `pnpm test`. +- [x] P4-2: Unit tests for useContentPanel — Create `packages/app/src/composables/__tests__/useContentPanel.test.ts`. Test tab switching, detail opening, panel state management. Minimum 6 test cases. TEST: `pnpm test`. +- [x] P4-3: Unit tests for useVisualViewport — Create `packages/app/src/composables/__tests__/useVisualViewport.test.ts`. Mock visualViewport API, test keyboard detection, viewport height calculation. Minimum 5 test cases. TEST: `pnpm test`. +- [x] P4-4: Content extraction edge case tests — Create `packages/app/src/composables/__tests__/contentExtraction.test.ts`. Test interleaved tags, malformed tags, unicode content, missing fields. Minimum 10 test cases. TEST: `pnpm test`. +- [x] P4-5: Seeded prompt regression tests — Create `packages/app/src/__tests__/seed-conversations.test.ts`. Import all seed conversations from mocks, run content extraction on each, verify expected content types are produced. Minimum 1 test per seed. TEST: `pnpm test`. + +## Phase 5: Feature Work + +- [x] P5-1: File browser page — Create `packages/app/src/pages/BrowsePage.vue` with file tree navigation. Add route `/browse` to router. Use the existing `vite-fs.ts` plugin for file reading. Show files/folders with icons, breadcrumb nav. TEST: `pnpm typecheck && pnpm lint`. +- [x] P5-2: File tree component — Create `packages/app/src/components/browse/FileTree.vue`. Recursive tree with expand/collapse, file type icons (folder, code, image, document). Use glass morphism styling. TEST: `pnpm typecheck`. +- [x] P5-3: File preview component — Create `packages/app/src/components/browse/FilePreview.vue`. Preview text files with syntax highlighting (reuse code viewer), images inline, show file metadata. TEST: `pnpm typecheck`. +- [x] P5-4: Allow .claude folder in code viewer — Update `vite-fs.ts` to allow `.claude/` path. Update any path validation that blocks dotfiles. Show CLAUDE.md, settings, hooks, memory files. TEST: `pnpm typecheck`. +- [x] P5-5: Archy local search guide — Create `packages/app/src/docs/archy-local-search.md` documenting how file types map to content surfaces (images→ImageGrid, music→SongGrid, etc.), how ContextBroker filtering works. Also add a help section component that can display this in-app. TEST: file exists and is valid markdown. + +## Phase 6: Accessibility + +- [x] P6-1: Add aria-labels to icon buttons — Audit all icon-only buttons across chat components (ChatHeader.vue, ChatMessage.vue, ChatInput.vue, ChatSearch.vue). Add descriptive `aria-label` to each. TEST: `pnpm lint`. +- [x] P6-2: Add aria-labels to content grids — All Grid components (SongGrid, FilmGrid, TVSeriesGrid, PlaceGrid, BookGrid, PodcastGrid, NewsGrid, ImageGrid). Each card button needs `aria-label` with content title. TEST: `pnpm lint`. +- [x] P6-3: Focus management for dialogs — `ZapDialog.vue`: add focus trap, auto-focus close button, `aria-modal="true"`, `role="dialog"`. Same for `SettingsModal.vue`. Ensure Escape key closes. TEST: `pnpm typecheck`. +- [x] P6-4: Color contrast audit — Check `text-white/40` against dark backgrounds for WCAG AA (4.5:1). Verify `#F7931A` accent contrast. Fix any failing ratios by increasing opacity. Document findings in comments. TEST: `pnpm lint`. +- [x] P6-5: Alt text improvements — `ImageGrid.vue`: use `img.title || img.alt` instead of generic. All content grids: ensure img alt includes meaningful content (title + artist/director/author). TEST: `pnpm lint`. + +## Phase 7: Performance & Compatibility + +- [x] P7-1: Lazy load heavy renderers — Use `defineAsyncComponent` for PdfViewer, VideoPlayer, MapView. Add loading skeleton components for each. TEST: `pnpm typecheck`. +- [x] P7-2: Add in-memory caching — `useNip05Verification.ts`: cache results with 5-min TTL. `useBitcoinPrice.ts`: cache price with 30s TTL. TEST: `pnpm typecheck`. +- [x] P7-3: Error boundaries for grid items — Create `packages/app/src/components/ui/ErrorBoundary.vue` using `onErrorCaptured`. Wrap each grid item renderer to prevent cascade failures. Show fallback UI on component crash. TEST: `pnpm typecheck`. +- [x] P7-4: Code file size limits — In `useCodeContext.ts` `openFile()`, add file size check before reading (reject > 1MB). Add loading indicator for large files. TEST: `pnpm typecheck`. + +## Phase 8: Research & Documentation + +- [x] P8-1: iOS app research — Research Capacitor vs WKWebView wrapper vs React Native WebView for shipping AIUI as iOS app. Document in `docs/research/ios-app.md`: pros/cons, App Store requirements, push notification integration, offline capability. Include concrete next steps. +- [x] P8-2: Mac desktop app research — Research Tauri v2 vs Electron for Mac desktop app. Document in `docs/research/mac-desktop.md`: binary size, memory usage, menu bar app pattern (like Raycast), global hotkey/command invocation, tray API. Include concrete next steps. +- [x] P8-3: Plugin system hardening research — Document in `docs/research/plugin-security.md`: signature validation for community plugins, sandboxed iframe execution, permission system per plugin. Reference existing plugin interfaces in `packages/core/src/plugins/`. diff --git a/aiui/loop/prepare.sh b/aiui/loop/prepare.sh new file mode 100755 index 00000000..d0066f17 --- /dev/null +++ b/aiui/loop/prepare.sh @@ -0,0 +1,41 @@ +#!/usr/bin/env sh +# Pre-run script: verify repo state and create overnight branch. +# Run before 5-6pm to ensure you can safely start the overnight loop. +set -eu + +PROJECT_DIR="${CLAUDE_PROJECT_DIR:-$(cd "$(dirname "$0")/.." && pwd)}" +cd "$PROJECT_DIR" + +DATE=$(date '+%Y-%m-%d') +BRANCH="overnight/${DATE}" + +echo "=== Overnight pre-run check @ $(date '+%Y-%m-%dT%H:%M:%S') ===" + +# 1. Check git status is clean +if ! git diff --quiet || ! git diff --cached --quiet; then + echo "Error: Working tree not clean. Commit or stash changes first." >&2 + echo " git status" >&2 + git status --short >&2 + exit 1 +fi + +# 2. Check we're not already on an overnight branch +current=$(git branch --show-current 2>/dev/null || true) +if [ -n "$current" ] && [ "$current" = "$BRANCH" ]; then + echo "Already on $BRANCH. Ready to run." >&2 + exit 0 +fi + +# 3. Create date-stamped branch +if git rev-parse --verify "$BRANCH" >/dev/null 2>&1; then + echo "Branch $BRANCH already exists. Checkout or use a different date." >&2 + exit 1 +fi +git checkout -b "$BRANCH" +echo "Created branch $BRANCH" + +# 4. Remind to push +echo "" +echo "Reminder: Push before starting overnight run: git push -u origin $BRANCH" +echo "Then run: caffeinate -i ./loop/loop.sh" +echo "=== Ready ===" diff --git a/aiui/loop/prompt.md b/aiui/loop/prompt.md new file mode 100644 index 00000000..f9a85052 --- /dev/null +++ b/aiui/loop/prompt.md @@ -0,0 +1,73 @@ +You are working through an overnight automation plan for the AIUI app. Read these files first: + +1. `loop/plan.md` — Your task checklist (mark items `- [x]` as you complete them) +2. `CLAUDE.md` — Project conventions, design system rules, and coding standards + +## Project Context + +AIUI is an AI content surface UI — a Vue 3 + TypeScript + Tailwind CSS app with chat, content panels (films, music, books, TV, places, news, images, podcasts), and a plugin system. It runs as a PWA and inside Archy (an iframe host). + +Key directories: +- `packages/app/src/` — Main application source +- `packages/app/src/composables/` — Shared composition functions +- `packages/app/src/components/content/` — Content grid and detail components +- `packages/app/src/components/chat/` — Chat interface components +- `packages/app/src/styles/main.css` — Glass morphism design system +- `packages/core/src/` — Core library and types + +## Working Process + +For each task in `loop/plan.md`: + +1. Find the first unchecked `- [ ]` item +2. Read the task description carefully — it tells you what to change and where +3. Read the relevant source files before making changes +4. Make the change following CLAUDE.md conventions +5. Run the TEST command specified in the task +6. Fix any errors from the test command before proceeding +7. Commit with conventional commit format: `type(scope): description` +8. Mark the task done: change `- [ ]` to `- [x]` in `loop/plan.md` +9. Move to the next unchecked task immediately + +## Testing Gates + +Every task specifies a TEST command. You MUST run it and pass before committing: +- `pnpm typecheck` — TypeScript strict mode compilation +- `pnpm lint` — ESLint checks +- `pnpm test` — Vitest unit tests +- Multiple commands joined with `&&` must ALL pass + +If a test fails, fix the issue and re-run. Do not skip tests. Do not mark a task as done if tests fail. + +## Coding Rules + +- **Vue 3 Composition API only** — `<script setup lang="ts">`, never Options API +- **Glass morphism design** — use `.glass`, `.glass-card`, `.glass-button` from `main.css` +- **Dark theme** — `bg-white/5`, `text-white/80`, never `bg-gray-*` or plain `bg-white` +- **Text opacity scale** — `text-white/25` → `/40` → `/60` → `/70` → `/80` → `/90` → `/96` +- **Accent** — `text-accent` (`#F7931A`) +- **Touch targets** — minimum 44x44px for all interactive elements +- **Font minimums** — never smaller than 11px +- **No over-engineering** — only change what the task asks for +- **Keep existing patterns** — match the style of surrounding code + +## Commit Format + +``` +type(scope): description + +Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> +``` + +Types: `feat`, `fix`, `refactor`, `test`, `docs`, `chore`, `perf` +Scope: `app`, `core`, or specific area like `chat`, `content`, `player` + +## Rules + +- Never skip a testing gate — if tests fail, fix them before moving on +- If a task is proving difficult, make at least 10 genuine attempts before moving on +- Always read source files before editing them +- Do not stop until all tasks are checked or you are rate limited +- Commit after each completed task +- For research tasks (Phase 8), create the docs directory if needed: `mkdir -p docs/research` +- For test tasks (Phase 4), create the test directory if needed: `mkdir -p packages/app/src/composables/__tests__` diff --git a/aiui/package.json b/aiui/package.json new file mode 100644 index 00000000..1c09db1e --- /dev/null +++ b/aiui/package.json @@ -0,0 +1,33 @@ +{ + "name": "aiui", + "version": "0.1.0", + "private": true, + "description": "The next-generation AI content surface UI", + "license": "MIT", + "scripts": { + "dev": "pnpm --filter @aiui/app dev", + "dev:core": "pnpm --filter @aiui/core dev", + "build": "turbo build", + "test": "turbo test", + "lint": "turbo lint", + "typecheck": "turbo typecheck", + "clean": "turbo clean" + }, + "devDependencies": { + "turbo": "^2.8.12", + "typescript": "~5.8.0" + }, + "packageManager": "pnpm@10.30.3", + "engines": { + "node": ">=20.0.0", + "pnpm": ">=10.0.0" + }, + "pnpm": { + "onlyBuiltDependencies": [ + "esbuild" + ] + }, + "dependencies": { + "pdfjs-dist": "^5.5.207" + } +} diff --git a/aiui/packages/app/.storybook/main.ts b/aiui/packages/app/.storybook/main.ts new file mode 100644 index 00000000..ac31dc31 --- /dev/null +++ b/aiui/packages/app/.storybook/main.ts @@ -0,0 +1,21 @@ +import type { StorybookConfig } from '@storybook/vue3-vite' + +const config: StorybookConfig = { + stories: ['../src/**/__stories__/*.stories.ts'], + framework: { + name: '@storybook/vue3-vite', + options: {}, + }, + addons: ['@storybook/addon-essentials'], + viteFinal(config) { + config.resolve ??= {} + config.resolve.alias ??= {} + // Match app aliases + const alias = config.resolve.alias as Record<string, string> + alias['@'] = new URL('../src', import.meta.url).pathname + alias['@aiui/core'] = new URL('../../core/src', import.meta.url).pathname + return config + }, +} + +export default config diff --git a/aiui/packages/app/.storybook/preview.ts b/aiui/packages/app/.storybook/preview.ts new file mode 100644 index 00000000..a31267c1 --- /dev/null +++ b/aiui/packages/app/.storybook/preview.ts @@ -0,0 +1,16 @@ +import type { Preview } from '@storybook/vue3' +import '../src/styles/main.css' + +const preview: Preview = { + parameters: { + backgrounds: { + default: 'dark', + values: [ + { name: 'dark', value: '#0a0a0a' }, + ], + }, + layout: 'centered', + }, +} + +export default preview diff --git a/aiui/packages/app/dev-dist/registerSW.js b/aiui/packages/app/dev-dist/registerSW.js new file mode 100644 index 00000000..1d5625f4 --- /dev/null +++ b/aiui/packages/app/dev-dist/registerSW.js @@ -0,0 +1 @@ +if('serviceWorker' in navigator) navigator.serviceWorker.register('/dev-sw.js?dev-sw', { scope: '/', type: 'classic' }) \ No newline at end of file diff --git a/aiui/packages/app/dev-dist/sw.js b/aiui/packages/app/dev-dist/sw.js new file mode 100644 index 00000000..1558dfd4 --- /dev/null +++ b/aiui/packages/app/dev-dist/sw.js @@ -0,0 +1,124 @@ +/** + * Copyright 2018 Google Inc. All Rights Reserved. + * 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 + * http://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. + */ + +// If the loader is already loaded, just stop. +if (!self.define) { + let registry = {}; + + // Used for `eval` and `importScripts` where we can't get script URL by other means. + // In both cases, it's safe to use a global var because those functions are synchronous. + let nextDefineUri; + + const singleRequire = (uri, parentUri) => { + uri = new URL(uri + ".js", parentUri).href; + return registry[uri] || ( + + new Promise(resolve => { + if ("document" in self) { + const script = document.createElement("script"); + script.src = uri; + script.onload = resolve; + document.head.appendChild(script); + } else { + nextDefineUri = uri; + importScripts(uri); + resolve(); + } + }) + + .then(() => { + let promise = registry[uri]; + if (!promise) { + throw new Error(`Module ${uri} didn’t register its module`); + } + return promise; + }) + ); + }; + + self.define = (depsNames, factory) => { + const uri = nextDefineUri || ("document" in self ? document.currentScript.src : "") || location.href; + if (registry[uri]) { + // Module is already loading or loaded. + return; + } + let exports = {}; + const require = depUri => singleRequire(depUri, uri); + const specialDeps = { + module: { uri }, + exports, + require + }; + registry[uri] = Promise.all(depsNames.map( + depName => specialDeps[depName] || require(depName) + )).then(deps => { + factory(...deps); + return exports; + }); + }; +} +define(['./workbox-f97094b3'], (function (workbox) { 'use strict'; + + self.skipWaiting(); + workbox.clientsClaim(); + + /** + * The precacheAndRoute() method efficiently caches and responds to + * requests for URLs in the manifest. + * See https://goo.gl/S9QRab + */ + workbox.precacheAndRoute([{ + "url": "registerSW.js", + "revision": "3ca0b8505b4bec776b69afdba2768812" + }, { + "url": "index.html", + "revision": "0.rat89nkoims" + }], {}); + workbox.cleanupOutdatedCaches(); + workbox.registerRoute(new workbox.NavigationRoute(workbox.createHandlerBoundToURL("index.html"), { + allowlist: [/^\/$/] + })); + workbox.registerRoute(/^https:\/\/api\.anthropic\.com\/.*/i, new workbox.NetworkOnly(), 'GET'); + workbox.registerRoute(/^https:\/\/openrouter\.ai\/.*/i, new workbox.NetworkOnly(), 'GET'); + workbox.registerRoute(/\/api\/web-search\?.*/i, new workbox.NetworkOnly(), 'GET'); + workbox.registerRoute(/\/api\/rss-articles\?.*/i, new workbox.NetworkOnly(), 'GET'); + workbox.registerRoute(/\/api\/tmdb\/.*/i, new workbox.StaleWhileRevalidate({ + "cacheName": "tmdb-cache", + plugins: [new workbox.ExpirationPlugin({ + maxEntries: 200, + maxAgeSeconds: 86400 + })] + }), 'GET'); + workbox.registerRoute(/^https:\/\/image\.tmdb\.org\/.*/i, new workbox.CacheFirst({ + "cacheName": "tmdb-images", + plugins: [new workbox.ExpirationPlugin({ + maxEntries: 500, + maxAgeSeconds: 604800 + })] + }), 'GET'); + workbox.registerRoute(/^https:\/\/upload\.wikimedia\.org\/.*/i, new workbox.CacheFirst({ + "cacheName": "wiki-images", + plugins: [new workbox.ExpirationPlugin({ + maxEntries: 200, + maxAgeSeconds: 604800 + })] + }), 'GET'); + workbox.registerRoute(/^https:\/\/d12wklypp119aj\.cloudfront\.net\/image\/.*/i, new workbox.CacheFirst({ + "cacheName": "wavlake-images", + plugins: [new workbox.ExpirationPlugin({ + maxEntries: 300, + maxAgeSeconds: 604800 + })] + }), 'GET'); + +})); diff --git a/aiui/packages/app/dev-dist/workbox-cf23aef7.js b/aiui/packages/app/dev-dist/workbox-cf23aef7.js new file mode 100644 index 00000000..0e7fbaee --- /dev/null +++ b/aiui/packages/app/dev-dist/workbox-cf23aef7.js @@ -0,0 +1,3503 @@ +define(['exports'], (function (exports) { 'use strict'; + + // @ts-ignore + try { + self['workbox:core:7.3.0'] && _(); + } catch (e) {} + + /* + Copyright 2019 Google LLC + Use of this source code is governed by an MIT-style + license that can be found in the LICENSE file or at + https://opensource.org/licenses/MIT. + */ + const logger = (() => { + // Don't overwrite this value if it's already set. + // See https://github.com/GoogleChrome/workbox/pull/2284#issuecomment-560470923 + if (!('__WB_DISABLE_DEV_LOGS' in globalThis)) { + self.__WB_DISABLE_DEV_LOGS = false; + } + let inGroup = false; + const methodToColorMap = { + debug: `#7f8c8d`, + log: `#2ecc71`, + warn: `#f39c12`, + error: `#c0392b`, + groupCollapsed: `#3498db`, + groupEnd: null // No colored prefix on groupEnd + }; + const print = function (method, args) { + if (self.__WB_DISABLE_DEV_LOGS) { + return; + } + if (method === 'groupCollapsed') { + // Safari doesn't print all console.groupCollapsed() arguments: + // https://bugs.webkit.org/show_bug.cgi?id=182754 + if (/^((?!chrome|android).)*safari/i.test(navigator.userAgent)) { + console[method](...args); + return; + } + } + const styles = [`background: ${methodToColorMap[method]}`, `border-radius: 0.5em`, `color: white`, `font-weight: bold`, `padding: 2px 0.5em`]; + // When in a group, the workbox prefix is not displayed. + const logPrefix = inGroup ? [] : ['%cworkbox', styles.join(';')]; + console[method](...logPrefix, ...args); + if (method === 'groupCollapsed') { + inGroup = true; + } + if (method === 'groupEnd') { + inGroup = false; + } + }; + // eslint-disable-next-line @typescript-eslint/ban-types + const api = {}; + const loggerMethods = Object.keys(methodToColorMap); + for (const key of loggerMethods) { + const method = key; + api[method] = (...args) => { + print(method, args); + }; + } + return api; + })(); + + /* + Copyright 2018 Google LLC + + Use of this source code is governed by an MIT-style + license that can be found in the LICENSE file or at + https://opensource.org/licenses/MIT. + */ + const messages$1 = { + 'invalid-value': ({ + paramName, + validValueDescription, + value + }) => { + if (!paramName || !validValueDescription) { + throw new Error(`Unexpected input to 'invalid-value' error.`); + } + return `The '${paramName}' parameter was given a value with an ` + `unexpected value. ${validValueDescription} Received a value of ` + `${JSON.stringify(value)}.`; + }, + 'not-an-array': ({ + moduleName, + className, + funcName, + paramName + }) => { + if (!moduleName || !className || !funcName || !paramName) { + throw new Error(`Unexpected input to 'not-an-array' error.`); + } + return `The parameter '${paramName}' passed into ` + `'${moduleName}.${className}.${funcName}()' must be an array.`; + }, + 'incorrect-type': ({ + expectedType, + paramName, + moduleName, + className, + funcName + }) => { + if (!expectedType || !paramName || !moduleName || !funcName) { + throw new Error(`Unexpected input to 'incorrect-type' error.`); + } + const classNameStr = className ? `${className}.` : ''; + return `The parameter '${paramName}' passed into ` + `'${moduleName}.${classNameStr}` + `${funcName}()' must be of type ${expectedType}.`; + }, + 'incorrect-class': ({ + expectedClassName, + paramName, + moduleName, + className, + funcName, + isReturnValueProblem + }) => { + if (!expectedClassName || !moduleName || !funcName) { + throw new Error(`Unexpected input to 'incorrect-class' error.`); + } + const classNameStr = className ? `${className}.` : ''; + if (isReturnValueProblem) { + return `The return value from ` + `'${moduleName}.${classNameStr}${funcName}()' ` + `must be an instance of class ${expectedClassName}.`; + } + return `The parameter '${paramName}' passed into ` + `'${moduleName}.${classNameStr}${funcName}()' ` + `must be an instance of class ${expectedClassName}.`; + }, + 'missing-a-method': ({ + expectedMethod, + paramName, + moduleName, + className, + funcName + }) => { + if (!expectedMethod || !paramName || !moduleName || !className || !funcName) { + throw new Error(`Unexpected input to 'missing-a-method' error.`); + } + return `${moduleName}.${className}.${funcName}() expected the ` + `'${paramName}' parameter to expose a '${expectedMethod}' method.`; + }, + 'add-to-cache-list-unexpected-type': ({ + entry + }) => { + return `An unexpected entry was passed to ` + `'workbox-precaching.PrecacheController.addToCacheList()' The entry ` + `'${JSON.stringify(entry)}' isn't supported. You must supply an array of ` + `strings with one or more characters, objects with a url property or ` + `Request objects.`; + }, + 'add-to-cache-list-conflicting-entries': ({ + firstEntry, + secondEntry + }) => { + if (!firstEntry || !secondEntry) { + throw new Error(`Unexpected input to ` + `'add-to-cache-list-duplicate-entries' error.`); + } + return `Two of the entries passed to ` + `'workbox-precaching.PrecacheController.addToCacheList()' had the URL ` + `${firstEntry} but different revision details. Workbox is ` + `unable to cache and version the asset correctly. Please remove one ` + `of the entries.`; + }, + 'plugin-error-request-will-fetch': ({ + thrownErrorMessage + }) => { + if (!thrownErrorMessage) { + throw new Error(`Unexpected input to ` + `'plugin-error-request-will-fetch', error.`); + } + return `An error was thrown by a plugins 'requestWillFetch()' method. ` + `The thrown error message was: '${thrownErrorMessage}'.`; + }, + 'invalid-cache-name': ({ + cacheNameId, + value + }) => { + if (!cacheNameId) { + throw new Error(`Expected a 'cacheNameId' for error 'invalid-cache-name'`); + } + return `You must provide a name containing at least one character for ` + `setCacheDetails({${cacheNameId}: '...'}). Received a value of ` + `'${JSON.stringify(value)}'`; + }, + 'unregister-route-but-not-found-with-method': ({ + method + }) => { + if (!method) { + throw new Error(`Unexpected input to ` + `'unregister-route-but-not-found-with-method' error.`); + } + return `The route you're trying to unregister was not previously ` + `registered for the method type '${method}'.`; + }, + 'unregister-route-route-not-registered': () => { + return `The route you're trying to unregister was not previously ` + `registered.`; + }, + 'queue-replay-failed': ({ + name + }) => { + return `Replaying the background sync queue '${name}' failed.`; + }, + 'duplicate-queue-name': ({ + name + }) => { + return `The Queue name '${name}' is already being used. ` + `All instances of backgroundSync.Queue must be given unique names.`; + }, + 'expired-test-without-max-age': ({ + methodName, + paramName + }) => { + return `The '${methodName}()' method can only be used when the ` + `'${paramName}' is used in the constructor.`; + }, + 'unsupported-route-type': ({ + moduleName, + className, + funcName, + paramName + }) => { + return `The supplied '${paramName}' parameter was an unsupported type. ` + `Please check the docs for ${moduleName}.${className}.${funcName} for ` + `valid input types.`; + }, + 'not-array-of-class': ({ + value, + expectedClass, + moduleName, + className, + funcName, + paramName + }) => { + return `The supplied '${paramName}' parameter must be an array of ` + `'${expectedClass}' objects. Received '${JSON.stringify(value)},'. ` + `Please check the call to ${moduleName}.${className}.${funcName}() ` + `to fix the issue.`; + }, + 'max-entries-or-age-required': ({ + moduleName, + className, + funcName + }) => { + return `You must define either config.maxEntries or config.maxAgeSeconds` + `in ${moduleName}.${className}.${funcName}`; + }, + 'statuses-or-headers-required': ({ + moduleName, + className, + funcName + }) => { + return `You must define either config.statuses or config.headers` + `in ${moduleName}.${className}.${funcName}`; + }, + 'invalid-string': ({ + moduleName, + funcName, + paramName + }) => { + if (!paramName || !moduleName || !funcName) { + throw new Error(`Unexpected input to 'invalid-string' error.`); + } + return `When using strings, the '${paramName}' parameter must start with ` + `'http' (for cross-origin matches) or '/' (for same-origin matches). ` + `Please see the docs for ${moduleName}.${funcName}() for ` + `more info.`; + }, + 'channel-name-required': () => { + return `You must provide a channelName to construct a ` + `BroadcastCacheUpdate instance.`; + }, + 'invalid-responses-are-same-args': () => { + return `The arguments passed into responsesAreSame() appear to be ` + `invalid. Please ensure valid Responses are used.`; + }, + 'expire-custom-caches-only': () => { + return `You must provide a 'cacheName' property when using the ` + `expiration plugin with a runtime caching strategy.`; + }, + 'unit-must-be-bytes': ({ + normalizedRangeHeader + }) => { + if (!normalizedRangeHeader) { + throw new Error(`Unexpected input to 'unit-must-be-bytes' error.`); + } + return `The 'unit' portion of the Range header must be set to 'bytes'. ` + `The Range header provided was "${normalizedRangeHeader}"`; + }, + 'single-range-only': ({ + normalizedRangeHeader + }) => { + if (!normalizedRangeHeader) { + throw new Error(`Unexpected input to 'single-range-only' error.`); + } + return `Multiple ranges are not supported. Please use a single start ` + `value, and optional end value. The Range header provided was ` + `"${normalizedRangeHeader}"`; + }, + 'invalid-range-values': ({ + normalizedRangeHeader + }) => { + if (!normalizedRangeHeader) { + throw new Error(`Unexpected input to 'invalid-range-values' error.`); + } + return `The Range header is missing both start and end values. At least ` + `one of those values is needed. The Range header provided was ` + `"${normalizedRangeHeader}"`; + }, + 'no-range-header': () => { + return `No Range header was found in the Request provided.`; + }, + 'range-not-satisfiable': ({ + size, + start, + end + }) => { + return `The start (${start}) and end (${end}) values in the Range are ` + `not satisfiable by the cached response, which is ${size} bytes.`; + }, + 'attempt-to-cache-non-get-request': ({ + url, + method + }) => { + return `Unable to cache '${url}' because it is a '${method}' request and ` + `only 'GET' requests can be cached.`; + }, + 'cache-put-with-no-response': ({ + url + }) => { + return `There was an attempt to cache '${url}' but the response was not ` + `defined.`; + }, + 'no-response': ({ + url, + error + }) => { + let message = `The strategy could not generate a response for '${url}'.`; + if (error) { + message += ` The underlying error is ${error}.`; + } + return message; + }, + 'bad-precaching-response': ({ + url, + status + }) => { + return `The precaching request for '${url}' failed` + (status ? ` with an HTTP status of ${status}.` : `.`); + }, + 'non-precached-url': ({ + url + }) => { + return `createHandlerBoundToURL('${url}') was called, but that URL is ` + `not precached. Please pass in a URL that is precached instead.`; + }, + 'add-to-cache-list-conflicting-integrities': ({ + url + }) => { + return `Two of the entries passed to ` + `'workbox-precaching.PrecacheController.addToCacheList()' had the URL ` + `${url} with different integrity values. Please remove one of them.`; + }, + 'missing-precache-entry': ({ + cacheName, + url + }) => { + return `Unable to find a precached response in ${cacheName} for ${url}.`; + }, + 'cross-origin-copy-response': ({ + origin + }) => { + return `workbox-core.copyResponse() can only be used with same-origin ` + `responses. It was passed a response with origin ${origin}.`; + }, + 'opaque-streams-source': ({ + type + }) => { + const message = `One of the workbox-streams sources resulted in an ` + `'${type}' response.`; + if (type === 'opaqueredirect') { + return `${message} Please do not use a navigation request that results ` + `in a redirect as a source.`; + } + return `${message} Please ensure your sources are CORS-enabled.`; + } + }; + + /* + Copyright 2018 Google LLC + + Use of this source code is governed by an MIT-style + license that can be found in the LICENSE file or at + https://opensource.org/licenses/MIT. + */ + const generatorFunction = (code, details = {}) => { + const message = messages$1[code]; + if (!message) { + throw new Error(`Unable to find message for code '${code}'.`); + } + return message(details); + }; + const messageGenerator = generatorFunction; + + /* + Copyright 2018 Google LLC + + Use of this source code is governed by an MIT-style + license that can be found in the LICENSE file or at + https://opensource.org/licenses/MIT. + */ + /** + * Workbox errors should be thrown with this class. + * This allows use to ensure the type easily in tests, + * helps developers identify errors from workbox + * easily and allows use to optimise error + * messages correctly. + * + * @private + */ + class WorkboxError extends Error { + /** + * + * @param {string} errorCode The error code that + * identifies this particular error. + * @param {Object=} details Any relevant arguments + * that will help developers identify issues should + * be added as a key on the context object. + */ + constructor(errorCode, details) { + const message = messageGenerator(errorCode, details); + super(message); + this.name = errorCode; + this.details = details; + } + } + + /* + Copyright 2018 Google LLC + + Use of this source code is governed by an MIT-style + license that can be found in the LICENSE file or at + https://opensource.org/licenses/MIT. + */ + /* + * This method throws if the supplied value is not an array. + * The destructed values are required to produce a meaningful error for users. + * The destructed and restructured object is so it's clear what is + * needed. + */ + const isArray = (value, details) => { + if (!Array.isArray(value)) { + throw new WorkboxError('not-an-array', details); + } + }; + const hasMethod = (object, expectedMethod, details) => { + const type = typeof object[expectedMethod]; + if (type !== 'function') { + details['expectedMethod'] = expectedMethod; + throw new WorkboxError('missing-a-method', details); + } + }; + const isType = (object, expectedType, details) => { + if (typeof object !== expectedType) { + details['expectedType'] = expectedType; + throw new WorkboxError('incorrect-type', details); + } + }; + const isInstance = (object, + // Need the general type to do the check later. + // eslint-disable-next-line @typescript-eslint/ban-types + expectedClass, details) => { + if (!(object instanceof expectedClass)) { + details['expectedClassName'] = expectedClass.name; + throw new WorkboxError('incorrect-class', details); + } + }; + const isOneOf = (value, validValues, details) => { + if (!validValues.includes(value)) { + details['validValueDescription'] = `Valid values are ${JSON.stringify(validValues)}.`; + throw new WorkboxError('invalid-value', details); + } + }; + const isArrayOfClass = (value, + // Need general type to do check later. + expectedClass, + // eslint-disable-line + details) => { + const error = new WorkboxError('not-array-of-class', details); + if (!Array.isArray(value)) { + throw error; + } + for (const item of value) { + if (!(item instanceof expectedClass)) { + throw error; + } + } + }; + const finalAssertExports = { + hasMethod, + isArray, + isInstance, + isOneOf, + isType, + isArrayOfClass + }; + + // @ts-ignore + try { + self['workbox:routing:7.3.0'] && _(); + } catch (e) {} + + /* + Copyright 2018 Google LLC + + Use of this source code is governed by an MIT-style + license that can be found in the LICENSE file or at + https://opensource.org/licenses/MIT. + */ + /** + * The default HTTP method, 'GET', used when there's no specific method + * configured for a route. + * + * @type {string} + * + * @private + */ + const defaultMethod = 'GET'; + /** + * The list of valid HTTP methods associated with requests that could be routed. + * + * @type {Array<string>} + * + * @private + */ + const validMethods = ['DELETE', 'GET', 'HEAD', 'PATCH', 'POST', 'PUT']; + + /* + Copyright 2018 Google LLC + + Use of this source code is governed by an MIT-style + license that can be found in the LICENSE file or at + https://opensource.org/licenses/MIT. + */ + /** + * @param {function()|Object} handler Either a function, or an object with a + * 'handle' method. + * @return {Object} An object with a handle method. + * + * @private + */ + const normalizeHandler = handler => { + if (handler && typeof handler === 'object') { + { + finalAssertExports.hasMethod(handler, 'handle', { + moduleName: 'workbox-routing', + className: 'Route', + funcName: 'constructor', + paramName: 'handler' + }); + } + return handler; + } else { + { + finalAssertExports.isType(handler, 'function', { + moduleName: 'workbox-routing', + className: 'Route', + funcName: 'constructor', + paramName: 'handler' + }); + } + return { + handle: handler + }; + } + }; + + /* + Copyright 2018 Google LLC + + Use of this source code is governed by an MIT-style + license that can be found in the LICENSE file or at + https://opensource.org/licenses/MIT. + */ + /** + * A `Route` consists of a pair of callback functions, "match" and "handler". + * The "match" callback determine if a route should be used to "handle" a + * request by returning a non-falsy value if it can. The "handler" callback + * is called when there is a match and should return a Promise that resolves + * to a `Response`. + * + * @memberof workbox-routing + */ + class Route { + /** + * Constructor for Route class. + * + * @param {workbox-routing~matchCallback} match + * A callback function that determines whether the route matches a given + * `fetch` event by returning a non-falsy value. + * @param {workbox-routing~handlerCallback} handler A callback + * function that returns a Promise resolving to a Response. + * @param {string} [method='GET'] The HTTP method to match the Route + * against. + */ + constructor(match, handler, method = defaultMethod) { + { + finalAssertExports.isType(match, 'function', { + moduleName: 'workbox-routing', + className: 'Route', + funcName: 'constructor', + paramName: 'match' + }); + if (method) { + finalAssertExports.isOneOf(method, validMethods, { + paramName: 'method' + }); + } + } + // These values are referenced directly by Router so cannot be + // altered by minificaton. + this.handler = normalizeHandler(handler); + this.match = match; + this.method = method; + } + /** + * + * @param {workbox-routing-handlerCallback} handler A callback + * function that returns a Promise resolving to a Response + */ + setCatchHandler(handler) { + this.catchHandler = normalizeHandler(handler); + } + } + + /* + Copyright 2018 Google LLC + + Use of this source code is governed by an MIT-style + license that can be found in the LICENSE file or at + https://opensource.org/licenses/MIT. + */ + /** + * RegExpRoute makes it easy to create a regular expression based + * {@link workbox-routing.Route}. + * + * For same-origin requests the RegExp only needs to match part of the URL. For + * requests against third-party servers, you must define a RegExp that matches + * the start of the URL. + * + * @memberof workbox-routing + * @extends workbox-routing.Route + */ + class RegExpRoute extends Route { + /** + * If the regular expression contains + * [capture groups]{@link https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/RegExp#grouping-back-references}, + * the captured values will be passed to the + * {@link workbox-routing~handlerCallback} `params` + * argument. + * + * @param {RegExp} regExp The regular expression to match against URLs. + * @param {workbox-routing~handlerCallback} handler A callback + * function that returns a Promise resulting in a Response. + * @param {string} [method='GET'] The HTTP method to match the Route + * against. + */ + constructor(regExp, handler, method) { + { + finalAssertExports.isInstance(regExp, RegExp, { + moduleName: 'workbox-routing', + className: 'RegExpRoute', + funcName: 'constructor', + paramName: 'pattern' + }); + } + const match = ({ + url + }) => { + const result = regExp.exec(url.href); + // Return immediately if there's no match. + if (!result) { + return; + } + // Require that the match start at the first character in the URL string + // if it's a cross-origin request. + // See https://github.com/GoogleChrome/workbox/issues/281 for the context + // behind this behavior. + if (url.origin !== location.origin && result.index !== 0) { + { + logger.debug(`The regular expression '${regExp.toString()}' only partially matched ` + `against the cross-origin URL '${url.toString()}'. RegExpRoute's will only ` + `handle cross-origin requests if they match the entire URL.`); + } + return; + } + // If the route matches, but there aren't any capture groups defined, then + // this will return [], which is truthy and therefore sufficient to + // indicate a match. + // If there are capture groups, then it will return their values. + return result.slice(1); + }; + super(match, handler, method); + } + } + + /* + Copyright 2018 Google LLC + + Use of this source code is governed by an MIT-style + license that can be found in the LICENSE file or at + https://opensource.org/licenses/MIT. + */ + const getFriendlyURL = url => { + const urlObj = new URL(String(url), location.href); + // See https://github.com/GoogleChrome/workbox/issues/2323 + // We want to include everything, except for the origin if it's same-origin. + return urlObj.href.replace(new RegExp(`^${location.origin}`), ''); + }; + + /* + Copyright 2018 Google LLC + + Use of this source code is governed by an MIT-style + license that can be found in the LICENSE file or at + https://opensource.org/licenses/MIT. + */ + /** + * The Router can be used to process a `FetchEvent` using one or more + * {@link workbox-routing.Route}, responding with a `Response` if + * a matching route exists. + * + * If no route matches a given a request, the Router will use a "default" + * handler if one is defined. + * + * Should the matching Route throw an error, the Router will use a "catch" + * handler if one is defined to gracefully deal with issues and respond with a + * Request. + * + * If a request matches multiple routes, the **earliest** registered route will + * be used to respond to the request. + * + * @memberof workbox-routing + */ + class Router { + /** + * Initializes a new Router. + */ + constructor() { + this._routes = new Map(); + this._defaultHandlerMap = new Map(); + } + /** + * @return {Map<string, Array<workbox-routing.Route>>} routes A `Map` of HTTP + * method name ('GET', etc.) to an array of all the corresponding `Route` + * instances that are registered. + */ + get routes() { + return this._routes; + } + /** + * Adds a fetch event listener to respond to events when a route matches + * the event's request. + */ + addFetchListener() { + // See https://github.com/Microsoft/TypeScript/issues/28357#issuecomment-436484705 + self.addEventListener('fetch', event => { + const { + request + } = event; + const responsePromise = this.handleRequest({ + request, + event + }); + if (responsePromise) { + event.respondWith(responsePromise); + } + }); + } + /** + * Adds a message event listener for URLs to cache from the window. + * This is useful to cache resources loaded on the page prior to when the + * service worker started controlling it. + * + * The format of the message data sent from the window should be as follows. + * Where the `urlsToCache` array may consist of URL strings or an array of + * URL string + `requestInit` object (the same as you'd pass to `fetch()`). + * + * ``` + * { + * type: 'CACHE_URLS', + * payload: { + * urlsToCache: [ + * './script1.js', + * './script2.js', + * ['./script3.js', {mode: 'no-cors'}], + * ], + * }, + * } + * ``` + */ + addCacheListener() { + // See https://github.com/Microsoft/TypeScript/issues/28357#issuecomment-436484705 + self.addEventListener('message', event => { + // event.data is type 'any' + // eslint-disable-next-line @typescript-eslint/no-unsafe-member-access + if (event.data && event.data.type === 'CACHE_URLS') { + // eslint-disable-next-line @typescript-eslint/no-unsafe-assignment + const { + payload + } = event.data; + { + logger.debug(`Caching URLs from the window`, payload.urlsToCache); + } + const requestPromises = Promise.all(payload.urlsToCache.map(entry => { + if (typeof entry === 'string') { + entry = [entry]; + } + const request = new Request(...entry); + return this.handleRequest({ + request, + event + }); + // TODO(philipwalton): TypeScript errors without this typecast for + // some reason (probably a bug). The real type here should work but + // doesn't: `Array<Promise<Response> | undefined>`. + })); // TypeScript + event.waitUntil(requestPromises); + // If a MessageChannel was used, reply to the message on success. + if (event.ports && event.ports[0]) { + void requestPromises.then(() => event.ports[0].postMessage(true)); + } + } + }); + } + /** + * Apply the routing rules to a FetchEvent object to get a Response from an + * appropriate Route's handler. + * + * @param {Object} options + * @param {Request} options.request The request to handle. + * @param {ExtendableEvent} options.event The event that triggered the + * request. + * @return {Promise<Response>|undefined} A promise is returned if a + * registered route can handle the request. If there is no matching + * route and there's no `defaultHandler`, `undefined` is returned. + */ + handleRequest({ + request, + event + }) { + { + finalAssertExports.isInstance(request, Request, { + moduleName: 'workbox-routing', + className: 'Router', + funcName: 'handleRequest', + paramName: 'options.request' + }); + } + const url = new URL(request.url, location.href); + if (!url.protocol.startsWith('http')) { + { + logger.debug(`Workbox Router only supports URLs that start with 'http'.`); + } + return; + } + const sameOrigin = url.origin === location.origin; + const { + params, + route + } = this.findMatchingRoute({ + event, + request, + sameOrigin, + url + }); + let handler = route && route.handler; + const debugMessages = []; + { + if (handler) { + debugMessages.push([`Found a route to handle this request:`, route]); + if (params) { + debugMessages.push([`Passing the following params to the route's handler:`, params]); + } + } + } + // If we don't have a handler because there was no matching route, then + // fall back to defaultHandler if that's defined. + const method = request.method; + if (!handler && this._defaultHandlerMap.has(method)) { + { + debugMessages.push(`Failed to find a matching route. Falling ` + `back to the default handler for ${method}.`); + } + handler = this._defaultHandlerMap.get(method); + } + if (!handler) { + { + // No handler so Workbox will do nothing. If logs is set of debug + // i.e. verbose, we should print out this information. + logger.debug(`No route found for: ${getFriendlyURL(url)}`); + } + return; + } + { + // We have a handler, meaning Workbox is going to handle the route. + // print the routing details to the console. + logger.groupCollapsed(`Router is responding to: ${getFriendlyURL(url)}`); + debugMessages.forEach(msg => { + if (Array.isArray(msg)) { + logger.log(...msg); + } else { + logger.log(msg); + } + }); + logger.groupEnd(); + } + // Wrap in try and catch in case the handle method throws a synchronous + // error. It should still callback to the catch handler. + let responsePromise; + try { + responsePromise = handler.handle({ + url, + request, + event, + params + }); + } catch (err) { + responsePromise = Promise.reject(err); + } + // Get route's catch handler, if it exists + const catchHandler = route && route.catchHandler; + if (responsePromise instanceof Promise && (this._catchHandler || catchHandler)) { + responsePromise = responsePromise.catch(async err => { + // If there's a route catch handler, process that first + if (catchHandler) { + { + // Still include URL here as it will be async from the console group + // and may not make sense without the URL + logger.groupCollapsed(`Error thrown when responding to: ` + ` ${getFriendlyURL(url)}. Falling back to route's Catch Handler.`); + logger.error(`Error thrown by:`, route); + logger.error(err); + logger.groupEnd(); + } + try { + return await catchHandler.handle({ + url, + request, + event, + params + }); + } catch (catchErr) { + if (catchErr instanceof Error) { + err = catchErr; + } + } + } + if (this._catchHandler) { + { + // Still include URL here as it will be async from the console group + // and may not make sense without the URL + logger.groupCollapsed(`Error thrown when responding to: ` + ` ${getFriendlyURL(url)}. Falling back to global Catch Handler.`); + logger.error(`Error thrown by:`, route); + logger.error(err); + logger.groupEnd(); + } + return this._catchHandler.handle({ + url, + request, + event + }); + } + throw err; + }); + } + return responsePromise; + } + /** + * Checks a request and URL (and optionally an event) against the list of + * registered routes, and if there's a match, returns the corresponding + * route along with any params generated by the match. + * + * @param {Object} options + * @param {URL} options.url + * @param {boolean} options.sameOrigin The result of comparing `url.origin` + * against the current origin. + * @param {Request} options.request The request to match. + * @param {Event} options.event The corresponding event. + * @return {Object} An object with `route` and `params` properties. + * They are populated if a matching route was found or `undefined` + * otherwise. + */ + findMatchingRoute({ + url, + sameOrigin, + request, + event + }) { + const routes = this._routes.get(request.method) || []; + for (const route of routes) { + let params; + // route.match returns type any, not possible to change right now. + // eslint-disable-next-line @typescript-eslint/no-unsafe-assignment + const matchResult = route.match({ + url, + sameOrigin, + request, + event + }); + if (matchResult) { + { + // Warn developers that using an async matchCallback is almost always + // not the right thing to do. + if (matchResult instanceof Promise) { + logger.warn(`While routing ${getFriendlyURL(url)}, an async ` + `matchCallback function was used. Please convert the ` + `following route to use a synchronous matchCallback function:`, route); + } + } + // See https://github.com/GoogleChrome/workbox/issues/2079 + // eslint-disable-next-line @typescript-eslint/no-unsafe-assignment + params = matchResult; + if (Array.isArray(params) && params.length === 0) { + // Instead of passing an empty array in as params, use undefined. + params = undefined; + } else if (matchResult.constructor === Object && + // eslint-disable-line + Object.keys(matchResult).length === 0) { + // Instead of passing an empty object in as params, use undefined. + params = undefined; + } else if (typeof matchResult === 'boolean') { + // For the boolean value true (rather than just something truth-y), + // don't set params. + // See https://github.com/GoogleChrome/workbox/pull/2134#issuecomment-513924353 + params = undefined; + } + // Return early if have a match. + return { + route, + params + }; + } + } + // If no match was found above, return and empty object. + return {}; + } + /** + * Define a default `handler` that's called when no routes explicitly + * match the incoming request. + * + * Each HTTP method ('GET', 'POST', etc.) gets its own default handler. + * + * Without a default handler, unmatched requests will go against the + * network as if there were no service worker present. + * + * @param {workbox-routing~handlerCallback} handler A callback + * function that returns a Promise resulting in a Response. + * @param {string} [method='GET'] The HTTP method to associate with this + * default handler. Each method has its own default. + */ + setDefaultHandler(handler, method = defaultMethod) { + this._defaultHandlerMap.set(method, normalizeHandler(handler)); + } + /** + * If a Route throws an error while handling a request, this `handler` + * will be called and given a chance to provide a response. + * + * @param {workbox-routing~handlerCallback} handler A callback + * function that returns a Promise resulting in a Response. + */ + setCatchHandler(handler) { + this._catchHandler = normalizeHandler(handler); + } + /** + * Registers a route with the router. + * + * @param {workbox-routing.Route} route The route to register. + */ + registerRoute(route) { + { + finalAssertExports.isType(route, 'object', { + moduleName: 'workbox-routing', + className: 'Router', + funcName: 'registerRoute', + paramName: 'route' + }); + finalAssertExports.hasMethod(route, 'match', { + moduleName: 'workbox-routing', + className: 'Router', + funcName: 'registerRoute', + paramName: 'route' + }); + finalAssertExports.isType(route.handler, 'object', { + moduleName: 'workbox-routing', + className: 'Router', + funcName: 'registerRoute', + paramName: 'route' + }); + finalAssertExports.hasMethod(route.handler, 'handle', { + moduleName: 'workbox-routing', + className: 'Router', + funcName: 'registerRoute', + paramName: 'route.handler' + }); + finalAssertExports.isType(route.method, 'string', { + moduleName: 'workbox-routing', + className: 'Router', + funcName: 'registerRoute', + paramName: 'route.method' + }); + } + if (!this._routes.has(route.method)) { + this._routes.set(route.method, []); + } + // Give precedence to all of the earlier routes by adding this additional + // route to the end of the array. + this._routes.get(route.method).push(route); + } + /** + * Unregisters a route with the router. + * + * @param {workbox-routing.Route} route The route to unregister. + */ + unregisterRoute(route) { + if (!this._routes.has(route.method)) { + throw new WorkboxError('unregister-route-but-not-found-with-method', { + method: route.method + }); + } + const routeIndex = this._routes.get(route.method).indexOf(route); + if (routeIndex > -1) { + this._routes.get(route.method).splice(routeIndex, 1); + } else { + throw new WorkboxError('unregister-route-route-not-registered'); + } + } + } + + /* + Copyright 2019 Google LLC + + Use of this source code is governed by an MIT-style + license that can be found in the LICENSE file or at + https://opensource.org/licenses/MIT. + */ + let defaultRouter; + /** + * Creates a new, singleton Router instance if one does not exist. If one + * does already exist, that instance is returned. + * + * @private + * @return {Router} + */ + const getOrCreateDefaultRouter = () => { + if (!defaultRouter) { + defaultRouter = new Router(); + // The helpers that use the default Router assume these listeners exist. + defaultRouter.addFetchListener(); + defaultRouter.addCacheListener(); + } + return defaultRouter; + }; + + /* + Copyright 2019 Google LLC + + Use of this source code is governed by an MIT-style + license that can be found in the LICENSE file or at + https://opensource.org/licenses/MIT. + */ + /** + * Easily register a RegExp, string, or function with a caching + * strategy to a singleton Router instance. + * + * This method will generate a Route for you if needed and + * call {@link workbox-routing.Router#registerRoute}. + * + * @param {RegExp|string|workbox-routing.Route~matchCallback|workbox-routing.Route} capture + * If the capture param is a `Route`, all other arguments will be ignored. + * @param {workbox-routing~handlerCallback} [handler] A callback + * function that returns a Promise resulting in a Response. This parameter + * is required if `capture` is not a `Route` object. + * @param {string} [method='GET'] The HTTP method to match the Route + * against. + * @return {workbox-routing.Route} The generated `Route`. + * + * @memberof workbox-routing + */ + function registerRoute(capture, handler, method) { + let route; + if (typeof capture === 'string') { + const captureUrl = new URL(capture, location.href); + { + if (!(capture.startsWith('/') || capture.startsWith('http'))) { + throw new WorkboxError('invalid-string', { + moduleName: 'workbox-routing', + funcName: 'registerRoute', + paramName: 'capture' + }); + } + // We want to check if Express-style wildcards are in the pathname only. + // TODO: Remove this log message in v4. + const valueToCheck = capture.startsWith('http') ? captureUrl.pathname : capture; + // See https://github.com/pillarjs/path-to-regexp#parameters + const wildcards = '[*:?+]'; + if (new RegExp(`${wildcards}`).exec(valueToCheck)) { + logger.debug(`The '$capture' parameter contains an Express-style wildcard ` + `character (${wildcards}). Strings are now always interpreted as ` + `exact matches; use a RegExp for partial or wildcard matches.`); + } + } + const matchCallback = ({ + url + }) => { + { + if (url.pathname === captureUrl.pathname && url.origin !== captureUrl.origin) { + logger.debug(`${capture} only partially matches the cross-origin URL ` + `${url.toString()}. This route will only handle cross-origin requests ` + `if they match the entire URL.`); + } + } + return url.href === captureUrl.href; + }; + // If `capture` is a string then `handler` and `method` must be present. + route = new Route(matchCallback, handler, method); + } else if (capture instanceof RegExp) { + // If `capture` is a `RegExp` then `handler` and `method` must be present. + route = new RegExpRoute(capture, handler, method); + } else if (typeof capture === 'function') { + // If `capture` is a function then `handler` and `method` must be present. + route = new Route(capture, handler, method); + } else if (capture instanceof Route) { + route = capture; + } else { + throw new WorkboxError('unsupported-route-type', { + moduleName: 'workbox-routing', + funcName: 'registerRoute', + paramName: 'capture' + }); + } + const defaultRouter = getOrCreateDefaultRouter(); + defaultRouter.registerRoute(route); + return route; + } + + /* + Copyright 2019 Google LLC + Use of this source code is governed by an MIT-style + license that can be found in the LICENSE file or at + https://opensource.org/licenses/MIT. + */ + /** + * Returns a promise that resolves and the passed number of milliseconds. + * This utility is an async/await-friendly version of `setTimeout`. + * + * @param {number} ms + * @return {Promise} + * @private + */ + function timeout(ms) { + return new Promise(resolve => setTimeout(resolve, ms)); + } + + /* + Copyright 2018 Google LLC + + Use of this source code is governed by an MIT-style + license that can be found in the LICENSE file or at + https://opensource.org/licenses/MIT. + */ + const _cacheNameDetails = { + googleAnalytics: 'googleAnalytics', + precache: 'precache-v2', + prefix: 'workbox', + runtime: 'runtime', + suffix: typeof registration !== 'undefined' ? registration.scope : '' + }; + const _createCacheName = cacheName => { + return [_cacheNameDetails.prefix, cacheName, _cacheNameDetails.suffix].filter(value => value && value.length > 0).join('-'); + }; + const eachCacheNameDetail = fn => { + for (const key of Object.keys(_cacheNameDetails)) { + fn(key); + } + }; + const cacheNames = { + updateDetails: details => { + eachCacheNameDetail(key => { + if (typeof details[key] === 'string') { + _cacheNameDetails[key] = details[key]; + } + }); + }, + getGoogleAnalyticsName: userCacheName => { + return userCacheName || _createCacheName(_cacheNameDetails.googleAnalytics); + }, + getPrecacheName: userCacheName => { + return userCacheName || _createCacheName(_cacheNameDetails.precache); + }, + getPrefix: () => { + return _cacheNameDetails.prefix; + }, + getRuntimeName: userCacheName => { + return userCacheName || _createCacheName(_cacheNameDetails.runtime); + }, + getSuffix: () => { + return _cacheNameDetails.suffix; + } + }; + + /* + Copyright 2020 Google LLC + Use of this source code is governed by an MIT-style + license that can be found in the LICENSE file or at + https://opensource.org/licenses/MIT. + */ + function stripParams(fullURL, ignoreParams) { + const strippedURL = new URL(fullURL); + for (const param of ignoreParams) { + strippedURL.searchParams.delete(param); + } + return strippedURL.href; + } + /** + * Matches an item in the cache, ignoring specific URL params. This is similar + * to the `ignoreSearch` option, but it allows you to ignore just specific + * params (while continuing to match on the others). + * + * @private + * @param {Cache} cache + * @param {Request} request + * @param {Object} matchOptions + * @param {Array<string>} ignoreParams + * @return {Promise<Response|undefined>} + */ + async function cacheMatchIgnoreParams(cache, request, ignoreParams, matchOptions) { + const strippedRequestURL = stripParams(request.url, ignoreParams); + // If the request doesn't include any ignored params, match as normal. + if (request.url === strippedRequestURL) { + return cache.match(request, matchOptions); + } + // Otherwise, match by comparing keys + const keysOptions = Object.assign(Object.assign({}, matchOptions), { + ignoreSearch: true + }); + const cacheKeys = await cache.keys(request, keysOptions); + for (const cacheKey of cacheKeys) { + const strippedCacheKeyURL = stripParams(cacheKey.url, ignoreParams); + if (strippedRequestURL === strippedCacheKeyURL) { + return cache.match(cacheKey, matchOptions); + } + } + return; + } + + /* + Copyright 2018 Google LLC + + Use of this source code is governed by an MIT-style + license that can be found in the LICENSE file or at + https://opensource.org/licenses/MIT. + */ + /** + * The Deferred class composes Promises in a way that allows for them to be + * resolved or rejected from outside the constructor. In most cases promises + * should be used directly, but Deferreds can be necessary when the logic to + * resolve a promise must be separate. + * + * @private + */ + class Deferred { + /** + * Creates a promise and exposes its resolve and reject functions as methods. + */ + constructor() { + this.promise = new Promise((resolve, reject) => { + this.resolve = resolve; + this.reject = reject; + }); + } + } + + /* + Copyright 2018 Google LLC + + Use of this source code is governed by an MIT-style + license that can be found in the LICENSE file or at + https://opensource.org/licenses/MIT. + */ + // Callbacks to be executed whenever there's a quota error. + // Can't change Function type right now. + // eslint-disable-next-line @typescript-eslint/ban-types + const quotaErrorCallbacks = new Set(); + + /* + Copyright 2018 Google LLC + + Use of this source code is governed by an MIT-style + license that can be found in the LICENSE file or at + https://opensource.org/licenses/MIT. + */ + /** + * Runs all of the callback functions, one at a time sequentially, in the order + * in which they were registered. + * + * @memberof workbox-core + * @private + */ + async function executeQuotaErrorCallbacks() { + { + logger.log(`About to run ${quotaErrorCallbacks.size} ` + `callbacks to clean up caches.`); + } + for (const callback of quotaErrorCallbacks) { + await callback(); + { + logger.log(callback, 'is complete.'); + } + } + { + logger.log('Finished running callbacks.'); + } + } + + // @ts-ignore + try { + self['workbox:strategies:7.3.0'] && _(); + } catch (e) {} + + /* + Copyright 2020 Google LLC + + Use of this source code is governed by an MIT-style + license that can be found in the LICENSE file or at + https://opensource.org/licenses/MIT. + */ + function toRequest(input) { + return typeof input === 'string' ? new Request(input) : input; + } + /** + * A class created every time a Strategy instance calls + * {@link workbox-strategies.Strategy~handle} or + * {@link workbox-strategies.Strategy~handleAll} that wraps all fetch and + * cache actions around plugin callbacks and keeps track of when the strategy + * is "done" (i.e. all added `event.waitUntil()` promises have resolved). + * + * @memberof workbox-strategies + */ + class StrategyHandler { + /** + * Creates a new instance associated with the passed strategy and event + * that's handling the request. + * + * The constructor also initializes the state that will be passed to each of + * the plugins handling this request. + * + * @param {workbox-strategies.Strategy} strategy + * @param {Object} options + * @param {Request|string} options.request A request to run this strategy for. + * @param {ExtendableEvent} options.event The event associated with the + * request. + * @param {URL} [options.url] + * @param {*} [options.params] The return value from the + * {@link workbox-routing~matchCallback} (if applicable). + */ + constructor(strategy, options) { + this._cacheKeys = {}; + /** + * The request the strategy is performing (passed to the strategy's + * `handle()` or `handleAll()` method). + * @name request + * @instance + * @type {Request} + * @memberof workbox-strategies.StrategyHandler + */ + /** + * The event associated with this request. + * @name event + * @instance + * @type {ExtendableEvent} + * @memberof workbox-strategies.StrategyHandler + */ + /** + * A `URL` instance of `request.url` (if passed to the strategy's + * `handle()` or `handleAll()` method). + * Note: the `url` param will be present if the strategy was invoked + * from a workbox `Route` object. + * @name url + * @instance + * @type {URL|undefined} + * @memberof workbox-strategies.StrategyHandler + */ + /** + * A `param` value (if passed to the strategy's + * `handle()` or `handleAll()` method). + * Note: the `param` param will be present if the strategy was invoked + * from a workbox `Route` object and the + * {@link workbox-routing~matchCallback} returned + * a truthy value (it will be that value). + * @name params + * @instance + * @type {*|undefined} + * @memberof workbox-strategies.StrategyHandler + */ + { + finalAssertExports.isInstance(options.event, ExtendableEvent, { + moduleName: 'workbox-strategies', + className: 'StrategyHandler', + funcName: 'constructor', + paramName: 'options.event' + }); + } + Object.assign(this, options); + this.event = options.event; + this._strategy = strategy; + this._handlerDeferred = new Deferred(); + this._extendLifetimePromises = []; + // Copy the plugins list (since it's mutable on the strategy), + // so any mutations don't affect this handler instance. + this._plugins = [...strategy.plugins]; + this._pluginStateMap = new Map(); + for (const plugin of this._plugins) { + this._pluginStateMap.set(plugin, {}); + } + this.event.waitUntil(this._handlerDeferred.promise); + } + /** + * Fetches a given request (and invokes any applicable plugin callback + * methods) using the `fetchOptions` (for non-navigation requests) and + * `plugins` defined on the `Strategy` object. + * + * The following plugin lifecycle methods are invoked when using this method: + * - `requestWillFetch()` + * - `fetchDidSucceed()` + * - `fetchDidFail()` + * + * @param {Request|string} input The URL or request to fetch. + * @return {Promise<Response>} + */ + async fetch(input) { + const { + event + } = this; + let request = toRequest(input); + if (request.mode === 'navigate' && event instanceof FetchEvent && event.preloadResponse) { + const possiblePreloadResponse = await event.preloadResponse; + if (possiblePreloadResponse) { + { + logger.log(`Using a preloaded navigation response for ` + `'${getFriendlyURL(request.url)}'`); + } + return possiblePreloadResponse; + } + } + // If there is a fetchDidFail plugin, we need to save a clone of the + // original request before it's either modified by a requestWillFetch + // plugin or before the original request's body is consumed via fetch(). + const originalRequest = this.hasCallback('fetchDidFail') ? request.clone() : null; + try { + for (const cb of this.iterateCallbacks('requestWillFetch')) { + request = await cb({ + request: request.clone(), + event + }); + } + } catch (err) { + if (err instanceof Error) { + throw new WorkboxError('plugin-error-request-will-fetch', { + thrownErrorMessage: err.message + }); + } + } + // The request can be altered by plugins with `requestWillFetch` making + // the original request (most likely from a `fetch` event) different + // from the Request we make. Pass both to `fetchDidFail` to aid debugging. + const pluginFilteredRequest = request.clone(); + try { + let fetchResponse; + // See https://github.com/GoogleChrome/workbox/issues/1796 + fetchResponse = await fetch(request, request.mode === 'navigate' ? undefined : this._strategy.fetchOptions); + if ("development" !== 'production') { + logger.debug(`Network request for ` + `'${getFriendlyURL(request.url)}' returned a response with ` + `status '${fetchResponse.status}'.`); + } + for (const callback of this.iterateCallbacks('fetchDidSucceed')) { + fetchResponse = await callback({ + event, + request: pluginFilteredRequest, + response: fetchResponse + }); + } + return fetchResponse; + } catch (error) { + { + logger.log(`Network request for ` + `'${getFriendlyURL(request.url)}' threw an error.`, error); + } + // `originalRequest` will only exist if a `fetchDidFail` callback + // is being used (see above). + if (originalRequest) { + await this.runCallbacks('fetchDidFail', { + error: error, + event, + originalRequest: originalRequest.clone(), + request: pluginFilteredRequest.clone() + }); + } + throw error; + } + } + /** + * Calls `this.fetch()` and (in the background) runs `this.cachePut()` on + * the response generated by `this.fetch()`. + * + * The call to `this.cachePut()` automatically invokes `this.waitUntil()`, + * so you do not have to manually call `waitUntil()` on the event. + * + * @param {Request|string} input The request or URL to fetch and cache. + * @return {Promise<Response>} + */ + async fetchAndCachePut(input) { + const response = await this.fetch(input); + const responseClone = response.clone(); + void this.waitUntil(this.cachePut(input, responseClone)); + return response; + } + /** + * Matches a request from the cache (and invokes any applicable plugin + * callback methods) using the `cacheName`, `matchOptions`, and `plugins` + * defined on the strategy object. + * + * The following plugin lifecycle methods are invoked when using this method: + * - cacheKeyWillBeUsed() + * - cachedResponseWillBeUsed() + * + * @param {Request|string} key The Request or URL to use as the cache key. + * @return {Promise<Response|undefined>} A matching response, if found. + */ + async cacheMatch(key) { + const request = toRequest(key); + let cachedResponse; + const { + cacheName, + matchOptions + } = this._strategy; + const effectiveRequest = await this.getCacheKey(request, 'read'); + const multiMatchOptions = Object.assign(Object.assign({}, matchOptions), { + cacheName + }); + cachedResponse = await caches.match(effectiveRequest, multiMatchOptions); + { + if (cachedResponse) { + logger.debug(`Found a cached response in '${cacheName}'.`); + } else { + logger.debug(`No cached response found in '${cacheName}'.`); + } + } + for (const callback of this.iterateCallbacks('cachedResponseWillBeUsed')) { + cachedResponse = (await callback({ + cacheName, + matchOptions, + cachedResponse, + request: effectiveRequest, + event: this.event + })) || undefined; + } + return cachedResponse; + } + /** + * Puts a request/response pair in the cache (and invokes any applicable + * plugin callback methods) using the `cacheName` and `plugins` defined on + * the strategy object. + * + * The following plugin lifecycle methods are invoked when using this method: + * - cacheKeyWillBeUsed() + * - cacheWillUpdate() + * - cacheDidUpdate() + * + * @param {Request|string} key The request or URL to use as the cache key. + * @param {Response} response The response to cache. + * @return {Promise<boolean>} `false` if a cacheWillUpdate caused the response + * not be cached, and `true` otherwise. + */ + async cachePut(key, response) { + const request = toRequest(key); + // Run in the next task to avoid blocking other cache reads. + // https://github.com/w3c/ServiceWorker/issues/1397 + await timeout(0); + const effectiveRequest = await this.getCacheKey(request, 'write'); + { + if (effectiveRequest.method && effectiveRequest.method !== 'GET') { + throw new WorkboxError('attempt-to-cache-non-get-request', { + url: getFriendlyURL(effectiveRequest.url), + method: effectiveRequest.method + }); + } + // See https://github.com/GoogleChrome/workbox/issues/2818 + const vary = response.headers.get('Vary'); + if (vary) { + logger.debug(`The response for ${getFriendlyURL(effectiveRequest.url)} ` + `has a 'Vary: ${vary}' header. ` + `Consider setting the {ignoreVary: true} option on your strategy ` + `to ensure cache matching and deletion works as expected.`); + } + } + if (!response) { + { + logger.error(`Cannot cache non-existent response for ` + `'${getFriendlyURL(effectiveRequest.url)}'.`); + } + throw new WorkboxError('cache-put-with-no-response', { + url: getFriendlyURL(effectiveRequest.url) + }); + } + const responseToCache = await this._ensureResponseSafeToCache(response); + if (!responseToCache) { + { + logger.debug(`Response '${getFriendlyURL(effectiveRequest.url)}' ` + `will not be cached.`, responseToCache); + } + return false; + } + const { + cacheName, + matchOptions + } = this._strategy; + const cache = await self.caches.open(cacheName); + const hasCacheUpdateCallback = this.hasCallback('cacheDidUpdate'); + const oldResponse = hasCacheUpdateCallback ? await cacheMatchIgnoreParams( + // TODO(philipwalton): the `__WB_REVISION__` param is a precaching + // feature. Consider into ways to only add this behavior if using + // precaching. + cache, effectiveRequest.clone(), ['__WB_REVISION__'], matchOptions) : null; + { + logger.debug(`Updating the '${cacheName}' cache with a new Response ` + `for ${getFriendlyURL(effectiveRequest.url)}.`); + } + try { + await cache.put(effectiveRequest, hasCacheUpdateCallback ? responseToCache.clone() : responseToCache); + } catch (error) { + if (error instanceof Error) { + // See https://developer.mozilla.org/en-US/docs/Web/API/DOMException#exception-QuotaExceededError + if (error.name === 'QuotaExceededError') { + await executeQuotaErrorCallbacks(); + } + throw error; + } + } + for (const callback of this.iterateCallbacks('cacheDidUpdate')) { + await callback({ + cacheName, + oldResponse, + newResponse: responseToCache.clone(), + request: effectiveRequest, + event: this.event + }); + } + return true; + } + /** + * Checks the list of plugins for the `cacheKeyWillBeUsed` callback, and + * executes any of those callbacks found in sequence. The final `Request` + * object returned by the last plugin is treated as the cache key for cache + * reads and/or writes. If no `cacheKeyWillBeUsed` plugin callbacks have + * been registered, the passed request is returned unmodified + * + * @param {Request} request + * @param {string} mode + * @return {Promise<Request>} + */ + async getCacheKey(request, mode) { + const key = `${request.url} | ${mode}`; + if (!this._cacheKeys[key]) { + let effectiveRequest = request; + for (const callback of this.iterateCallbacks('cacheKeyWillBeUsed')) { + effectiveRequest = toRequest(await callback({ + mode, + request: effectiveRequest, + event: this.event, + // params has a type any can't change right now. + params: this.params // eslint-disable-line + })); + } + this._cacheKeys[key] = effectiveRequest; + } + return this._cacheKeys[key]; + } + /** + * Returns true if the strategy has at least one plugin with the given + * callback. + * + * @param {string} name The name of the callback to check for. + * @return {boolean} + */ + hasCallback(name) { + for (const plugin of this._strategy.plugins) { + if (name in plugin) { + return true; + } + } + return false; + } + /** + * Runs all plugin callbacks matching the given name, in order, passing the + * given param object (merged ith the current plugin state) as the only + * argument. + * + * Note: since this method runs all plugins, it's not suitable for cases + * where the return value of a callback needs to be applied prior to calling + * the next callback. See + * {@link workbox-strategies.StrategyHandler#iterateCallbacks} + * below for how to handle that case. + * + * @param {string} name The name of the callback to run within each plugin. + * @param {Object} param The object to pass as the first (and only) param + * when executing each callback. This object will be merged with the + * current plugin state prior to callback execution. + */ + async runCallbacks(name, param) { + for (const callback of this.iterateCallbacks(name)) { + // TODO(philipwalton): not sure why `any` is needed. It seems like + // this should work with `as WorkboxPluginCallbackParam[C]`. + await callback(param); + } + } + /** + * Accepts a callback and returns an iterable of matching plugin callbacks, + * where each callback is wrapped with the current handler state (i.e. when + * you call each callback, whatever object parameter you pass it will + * be merged with the plugin's current state). + * + * @param {string} name The name fo the callback to run + * @return {Array<Function>} + */ + *iterateCallbacks(name) { + for (const plugin of this._strategy.plugins) { + if (typeof plugin[name] === 'function') { + const state = this._pluginStateMap.get(plugin); + const statefulCallback = param => { + const statefulParam = Object.assign(Object.assign({}, param), { + state + }); + // TODO(philipwalton): not sure why `any` is needed. It seems like + // this should work with `as WorkboxPluginCallbackParam[C]`. + return plugin[name](statefulParam); + }; + yield statefulCallback; + } + } + } + /** + * Adds a promise to the + * [extend lifetime promises]{@link https://w3c.github.io/ServiceWorker/#extendableevent-extend-lifetime-promises} + * of the event associated with the request being handled (usually a + * `FetchEvent`). + * + * Note: you can await + * {@link workbox-strategies.StrategyHandler~doneWaiting} + * to know when all added promises have settled. + * + * @param {Promise} promise A promise to add to the extend lifetime promises + * of the event that triggered the request. + */ + waitUntil(promise) { + this._extendLifetimePromises.push(promise); + return promise; + } + /** + * Returns a promise that resolves once all promises passed to + * {@link workbox-strategies.StrategyHandler~waitUntil} + * have settled. + * + * Note: any work done after `doneWaiting()` settles should be manually + * passed to an event's `waitUntil()` method (not this handler's + * `waitUntil()` method), otherwise the service worker thread may be killed + * prior to your work completing. + */ + async doneWaiting() { + while (this._extendLifetimePromises.length) { + const promises = this._extendLifetimePromises.splice(0); + const result = await Promise.allSettled(promises); + const firstRejection = result.find(i => i.status === 'rejected'); + if (firstRejection) { + throw firstRejection.reason; + } + } + } + /** + * Stops running the strategy and immediately resolves any pending + * `waitUntil()` promises. + */ + destroy() { + this._handlerDeferred.resolve(null); + } + /** + * This method will call cacheWillUpdate on the available plugins (or use + * status === 200) to determine if the Response is safe and valid to cache. + * + * @param {Request} options.request + * @param {Response} options.response + * @return {Promise<Response|undefined>} + * + * @private + */ + async _ensureResponseSafeToCache(response) { + let responseToCache = response; + let pluginsUsed = false; + for (const callback of this.iterateCallbacks('cacheWillUpdate')) { + responseToCache = (await callback({ + request: this.request, + response: responseToCache, + event: this.event + })) || undefined; + pluginsUsed = true; + if (!responseToCache) { + break; + } + } + if (!pluginsUsed) { + if (responseToCache && responseToCache.status !== 200) { + responseToCache = undefined; + } + { + if (responseToCache) { + if (responseToCache.status !== 200) { + if (responseToCache.status === 0) { + logger.warn(`The response for '${this.request.url}' ` + `is an opaque response. The caching strategy that you're ` + `using will not cache opaque responses by default.`); + } else { + logger.debug(`The response for '${this.request.url}' ` + `returned a status code of '${response.status}' and won't ` + `be cached as a result.`); + } + } + } + } + } + return responseToCache; + } + } + + /* + Copyright 2020 Google LLC + + Use of this source code is governed by an MIT-style + license that can be found in the LICENSE file or at + https://opensource.org/licenses/MIT. + */ + /** + * An abstract base class that all other strategy classes must extend from: + * + * @memberof workbox-strategies + */ + class Strategy { + /** + * Creates a new instance of the strategy and sets all documented option + * properties as public instance properties. + * + * Note: if a custom strategy class extends the base Strategy class and does + * not need more than these properties, it does not need to define its own + * constructor. + * + * @param {Object} [options] + * @param {string} [options.cacheName] Cache name to store and retrieve + * requests. Defaults to the cache names provided by + * {@link workbox-core.cacheNames}. + * @param {Array<Object>} [options.plugins] [Plugins]{@link https://developers.google.com/web/tools/workbox/guides/using-plugins} + * to use in conjunction with this caching strategy. + * @param {Object} [options.fetchOptions] Values passed along to the + * [`init`](https://developer.mozilla.org/en-US/docs/Web/API/WindowOrWorkerGlobalScope/fetch#Parameters) + * of [non-navigation](https://github.com/GoogleChrome/workbox/issues/1796) + * `fetch()` requests made by this strategy. + * @param {Object} [options.matchOptions] The + * [`CacheQueryOptions`]{@link https://w3c.github.io/ServiceWorker/#dictdef-cachequeryoptions} + * for any `cache.match()` or `cache.put()` calls made by this strategy. + */ + constructor(options = {}) { + /** + * Cache name to store and retrieve + * requests. Defaults to the cache names provided by + * {@link workbox-core.cacheNames}. + * + * @type {string} + */ + this.cacheName = cacheNames.getRuntimeName(options.cacheName); + /** + * The list + * [Plugins]{@link https://developers.google.com/web/tools/workbox/guides/using-plugins} + * used by this strategy. + * + * @type {Array<Object>} + */ + this.plugins = options.plugins || []; + /** + * Values passed along to the + * [`init`]{@link https://developer.mozilla.org/en-US/docs/Web/API/WindowOrWorkerGlobalScope/fetch#Parameters} + * of all fetch() requests made by this strategy. + * + * @type {Object} + */ + this.fetchOptions = options.fetchOptions; + /** + * The + * [`CacheQueryOptions`]{@link https://w3c.github.io/ServiceWorker/#dictdef-cachequeryoptions} + * for any `cache.match()` or `cache.put()` calls made by this strategy. + * + * @type {Object} + */ + this.matchOptions = options.matchOptions; + } + /** + * Perform a request strategy and returns a `Promise` that will resolve with + * a `Response`, invoking all relevant plugin callbacks. + * + * When a strategy instance is registered with a Workbox + * {@link workbox-routing.Route}, this method is automatically + * called when the route matches. + * + * Alternatively, this method can be used in a standalone `FetchEvent` + * listener by passing it to `event.respondWith()`. + * + * @param {FetchEvent|Object} options A `FetchEvent` or an object with the + * properties listed below. + * @param {Request|string} options.request A request to run this strategy for. + * @param {ExtendableEvent} options.event The event associated with the + * request. + * @param {URL} [options.url] + * @param {*} [options.params] + */ + handle(options) { + const [responseDone] = this.handleAll(options); + return responseDone; + } + /** + * Similar to {@link workbox-strategies.Strategy~handle}, but + * instead of just returning a `Promise` that resolves to a `Response` it + * it will return an tuple of `[response, done]` promises, where the former + * (`response`) is equivalent to what `handle()` returns, and the latter is a + * Promise that will resolve once any promises that were added to + * `event.waitUntil()` as part of performing the strategy have completed. + * + * You can await the `done` promise to ensure any extra work performed by + * the strategy (usually caching responses) completes successfully. + * + * @param {FetchEvent|Object} options A `FetchEvent` or an object with the + * properties listed below. + * @param {Request|string} options.request A request to run this strategy for. + * @param {ExtendableEvent} options.event The event associated with the + * request. + * @param {URL} [options.url] + * @param {*} [options.params] + * @return {Array<Promise>} A tuple of [response, done] + * promises that can be used to determine when the response resolves as + * well as when the handler has completed all its work. + */ + handleAll(options) { + // Allow for flexible options to be passed. + if (options instanceof FetchEvent) { + options = { + event: options, + request: options.request + }; + } + const event = options.event; + const request = typeof options.request === 'string' ? new Request(options.request) : options.request; + const params = 'params' in options ? options.params : undefined; + const handler = new StrategyHandler(this, { + event, + request, + params + }); + const responseDone = this._getResponse(handler, request, event); + const handlerDone = this._awaitComplete(responseDone, handler, request, event); + // Return an array of promises, suitable for use with Promise.all(). + return [responseDone, handlerDone]; + } + async _getResponse(handler, request, event) { + await handler.runCallbacks('handlerWillStart', { + event, + request + }); + let response = undefined; + try { + response = await this._handle(request, handler); + // The "official" Strategy subclasses all throw this error automatically, + // but in case a third-party Strategy doesn't, ensure that we have a + // consistent failure when there's no response or an error response. + if (!response || response.type === 'error') { + throw new WorkboxError('no-response', { + url: request.url + }); + } + } catch (error) { + if (error instanceof Error) { + for (const callback of handler.iterateCallbacks('handlerDidError')) { + response = await callback({ + error, + event, + request + }); + if (response) { + break; + } + } + } + if (!response) { + throw error; + } else { + logger.log(`While responding to '${getFriendlyURL(request.url)}', ` + `an ${error instanceof Error ? error.toString() : ''} error occurred. Using a fallback response provided by ` + `a handlerDidError plugin.`); + } + } + for (const callback of handler.iterateCallbacks('handlerWillRespond')) { + response = await callback({ + event, + request, + response + }); + } + return response; + } + async _awaitComplete(responseDone, handler, request, event) { + let response; + let error; + try { + response = await responseDone; + } catch (error) { + // Ignore errors, as response errors should be caught via the `response` + // promise above. The `done` promise will only throw for errors in + // promises passed to `handler.waitUntil()`. + } + try { + await handler.runCallbacks('handlerDidRespond', { + event, + request, + response + }); + await handler.doneWaiting(); + } catch (waitUntilError) { + if (waitUntilError instanceof Error) { + error = waitUntilError; + } + } + await handler.runCallbacks('handlerDidComplete', { + event, + request, + response, + error: error + }); + handler.destroy(); + if (error) { + throw error; + } + } + } + /** + * Classes extending the `Strategy` based class should implement this method, + * and leverage the {@link workbox-strategies.StrategyHandler} + * arg to perform all fetching and cache logic, which will ensure all relevant + * cache, cache options, fetch options and plugins are used (per the current + * strategy instance). + * + * @name _handle + * @instance + * @abstract + * @function + * @param {Request} request + * @param {workbox-strategies.StrategyHandler} handler + * @return {Promise<Response>} + * + * @memberof workbox-strategies.Strategy + */ + + /* + Copyright 2018 Google LLC + + Use of this source code is governed by an MIT-style + license that can be found in the LICENSE file or at + https://opensource.org/licenses/MIT. + */ + const messages = { + strategyStart: (strategyName, request) => `Using ${strategyName} to respond to '${getFriendlyURL(request.url)}'`, + printFinalResponse: response => { + if (response) { + logger.groupCollapsed(`View the final response here.`); + logger.log(response || '[No response returned]'); + logger.groupEnd(); + } + } + }; + + /* + Copyright 2018 Google LLC + + Use of this source code is governed by an MIT-style + license that can be found in the LICENSE file or at + https://opensource.org/licenses/MIT. + */ + /** + * An implementation of a + * [network-only](https://developer.chrome.com/docs/workbox/caching-strategies-overview/#network-only) + * request strategy. + * + * This class is useful if you want to take advantage of any + * [Workbox plugins](https://developer.chrome.com/docs/workbox/using-plugins/). + * + * If the network request fails, this will throw a `WorkboxError` exception. + * + * @extends workbox-strategies.Strategy + * @memberof workbox-strategies + */ + class NetworkOnly extends Strategy { + /** + * @param {Object} [options] + * @param {Array<Object>} [options.plugins] [Plugins]{@link https://developers.google.com/web/tools/workbox/guides/using-plugins} + * to use in conjunction with this caching strategy. + * @param {Object} [options.fetchOptions] Values passed along to the + * [`init`](https://developer.mozilla.org/en-US/docs/Web/API/WindowOrWorkerGlobalScope/fetch#Parameters) + * of [non-navigation](https://github.com/GoogleChrome/workbox/issues/1796) + * `fetch()` requests made by this strategy. + * @param {number} [options.networkTimeoutSeconds] If set, any network requests + * that fail to respond within the timeout will result in a network error. + */ + constructor(options = {}) { + super(options); + this._networkTimeoutSeconds = options.networkTimeoutSeconds || 0; + } + /** + * @private + * @param {Request|string} request A request to run this strategy for. + * @param {workbox-strategies.StrategyHandler} handler The event that + * triggered the request. + * @return {Promise<Response>} + */ + async _handle(request, handler) { + { + finalAssertExports.isInstance(request, Request, { + moduleName: 'workbox-strategies', + className: this.constructor.name, + funcName: '_handle', + paramName: 'request' + }); + } + let error = undefined; + let response; + try { + const promises = [handler.fetch(request)]; + if (this._networkTimeoutSeconds) { + const timeoutPromise = timeout(this._networkTimeoutSeconds * 1000); + promises.push(timeoutPromise); + } + response = await Promise.race(promises); + if (!response) { + throw new Error(`Timed out the network response after ` + `${this._networkTimeoutSeconds} seconds.`); + } + } catch (err) { + if (err instanceof Error) { + error = err; + } + } + { + logger.groupCollapsed(messages.strategyStart(this.constructor.name, request)); + if (response) { + logger.log(`Got response from network.`); + } else { + logger.log(`Unable to get a response from the network.`); + } + messages.printFinalResponse(response); + logger.groupEnd(); + } + if (!response) { + throw new WorkboxError('no-response', { + url: request.url, + error + }); + } + return response; + } + } + + /* + Copyright 2019 Google LLC + + Use of this source code is governed by an MIT-style + license that can be found in the LICENSE file or at + https://opensource.org/licenses/MIT. + */ + /** + * Claim any currently available clients once the service worker + * becomes active. This is normally used in conjunction with `skipWaiting()`. + * + * @memberof workbox-core + */ + function clientsClaim() { + self.addEventListener('activate', () => self.clients.claim()); + } + + /* + Copyright 2020 Google LLC + Use of this source code is governed by an MIT-style + license that can be found in the LICENSE file or at + https://opensource.org/licenses/MIT. + */ + /** + * A utility method that makes it easier to use `event.waitUntil` with + * async functions and return the result. + * + * @param {ExtendableEvent} event + * @param {Function} asyncFn + * @return {Function} + * @private + */ + function waitUntil(event, asyncFn) { + const returnPromise = asyncFn(); + event.waitUntil(returnPromise); + return returnPromise; + } + + // @ts-ignore + try { + self['workbox:precaching:7.3.0'] && _(); + } catch (e) {} + + /* + Copyright 2018 Google LLC + + Use of this source code is governed by an MIT-style + license that can be found in the LICENSE file or at + https://opensource.org/licenses/MIT. + */ + // Name of the search parameter used to store revision info. + const REVISION_SEARCH_PARAM = '__WB_REVISION__'; + /** + * Converts a manifest entry into a versioned URL suitable for precaching. + * + * @param {Object|string} entry + * @return {string} A URL with versioning info. + * + * @private + * @memberof workbox-precaching + */ + function createCacheKey(entry) { + if (!entry) { + throw new WorkboxError('add-to-cache-list-unexpected-type', { + entry + }); + } + // If a precache manifest entry is a string, it's assumed to be a versioned + // URL, like '/app.abcd1234.js'. Return as-is. + if (typeof entry === 'string') { + const urlObject = new URL(entry, location.href); + return { + cacheKey: urlObject.href, + url: urlObject.href + }; + } + const { + revision, + url + } = entry; + if (!url) { + throw new WorkboxError('add-to-cache-list-unexpected-type', { + entry + }); + } + // If there's just a URL and no revision, then it's also assumed to be a + // versioned URL. + if (!revision) { + const urlObject = new URL(url, location.href); + return { + cacheKey: urlObject.href, + url: urlObject.href + }; + } + // Otherwise, construct a properly versioned URL using the custom Workbox + // search parameter along with the revision info. + const cacheKeyURL = new URL(url, location.href); + const originalURL = new URL(url, location.href); + cacheKeyURL.searchParams.set(REVISION_SEARCH_PARAM, revision); + return { + cacheKey: cacheKeyURL.href, + url: originalURL.href + }; + } + + /* + Copyright 2020 Google LLC + + Use of this source code is governed by an MIT-style + license that can be found in the LICENSE file or at + https://opensource.org/licenses/MIT. + */ + /** + * A plugin, designed to be used with PrecacheController, to determine the + * of assets that were updated (or not updated) during the install event. + * + * @private + */ + class PrecacheInstallReportPlugin { + constructor() { + this.updatedURLs = []; + this.notUpdatedURLs = []; + this.handlerWillStart = async ({ + request, + state + }) => { + // TODO: `state` should never be undefined... + if (state) { + state.originalRequest = request; + } + }; + this.cachedResponseWillBeUsed = async ({ + event, + state, + cachedResponse + }) => { + if (event.type === 'install') { + if (state && state.originalRequest && state.originalRequest instanceof Request) { + // TODO: `state` should never be undefined... + const url = state.originalRequest.url; + if (cachedResponse) { + this.notUpdatedURLs.push(url); + } else { + this.updatedURLs.push(url); + } + } + } + return cachedResponse; + }; + } + } + + /* + Copyright 2020 Google LLC + + Use of this source code is governed by an MIT-style + license that can be found in the LICENSE file or at + https://opensource.org/licenses/MIT. + */ + /** + * A plugin, designed to be used with PrecacheController, to translate URLs into + * the corresponding cache key, based on the current revision info. + * + * @private + */ + class PrecacheCacheKeyPlugin { + constructor({ + precacheController + }) { + this.cacheKeyWillBeUsed = async ({ + request, + params + }) => { + // Params is type any, can't change right now. + /* eslint-disable */ + const cacheKey = (params === null || params === void 0 ? void 0 : params.cacheKey) || this._precacheController.getCacheKeyForURL(request.url); + /* eslint-enable */ + return cacheKey ? new Request(cacheKey, { + headers: request.headers + }) : request; + }; + this._precacheController = precacheController; + } + } + + /* + Copyright 2018 Google LLC + + Use of this source code is governed by an MIT-style + license that can be found in the LICENSE file or at + https://opensource.org/licenses/MIT. + */ + /** + * @param {string} groupTitle + * @param {Array<string>} deletedURLs + * + * @private + */ + const logGroup = (groupTitle, deletedURLs) => { + logger.groupCollapsed(groupTitle); + for (const url of deletedURLs) { + logger.log(url); + } + logger.groupEnd(); + }; + /** + * @param {Array<string>} deletedURLs + * + * @private + * @memberof workbox-precaching + */ + function printCleanupDetails(deletedURLs) { + const deletionCount = deletedURLs.length; + if (deletionCount > 0) { + logger.groupCollapsed(`During precaching cleanup, ` + `${deletionCount} cached ` + `request${deletionCount === 1 ? ' was' : 's were'} deleted.`); + logGroup('Deleted Cache Requests', deletedURLs); + logger.groupEnd(); + } + } + + /* + Copyright 2018 Google LLC + + Use of this source code is governed by an MIT-style + license that can be found in the LICENSE file or at + https://opensource.org/licenses/MIT. + */ + /** + * @param {string} groupTitle + * @param {Array<string>} urls + * + * @private + */ + function _nestedGroup(groupTitle, urls) { + if (urls.length === 0) { + return; + } + logger.groupCollapsed(groupTitle); + for (const url of urls) { + logger.log(url); + } + logger.groupEnd(); + } + /** + * @param {Array<string>} urlsToPrecache + * @param {Array<string>} urlsAlreadyPrecached + * + * @private + * @memberof workbox-precaching + */ + function printInstallDetails(urlsToPrecache, urlsAlreadyPrecached) { + const precachedCount = urlsToPrecache.length; + const alreadyPrecachedCount = urlsAlreadyPrecached.length; + if (precachedCount || alreadyPrecachedCount) { + let message = `Precaching ${precachedCount} file${precachedCount === 1 ? '' : 's'}.`; + if (alreadyPrecachedCount > 0) { + message += ` ${alreadyPrecachedCount} ` + `file${alreadyPrecachedCount === 1 ? ' is' : 's are'} already cached.`; + } + logger.groupCollapsed(message); + _nestedGroup(`View newly precached URLs.`, urlsToPrecache); + _nestedGroup(`View previously precached URLs.`, urlsAlreadyPrecached); + logger.groupEnd(); + } + } + + /* + Copyright 2019 Google LLC + + Use of this source code is governed by an MIT-style + license that can be found in the LICENSE file or at + https://opensource.org/licenses/MIT. + */ + let supportStatus; + /** + * A utility function that determines whether the current browser supports + * constructing a new `Response` from a `response.body` stream. + * + * @return {boolean} `true`, if the current browser can successfully + * construct a `Response` from a `response.body` stream, `false` otherwise. + * + * @private + */ + function canConstructResponseFromBodyStream() { + if (supportStatus === undefined) { + const testResponse = new Response(''); + if ('body' in testResponse) { + try { + new Response(testResponse.body); + supportStatus = true; + } catch (error) { + supportStatus = false; + } + } + supportStatus = false; + } + return supportStatus; + } + + /* + Copyright 2019 Google LLC + + Use of this source code is governed by an MIT-style + license that can be found in the LICENSE file or at + https://opensource.org/licenses/MIT. + */ + /** + * Allows developers to copy a response and modify its `headers`, `status`, + * or `statusText` values (the values settable via a + * [`ResponseInit`]{@link https://developer.mozilla.org/en-US/docs/Web/API/Response/Response#Syntax} + * object in the constructor). + * To modify these values, pass a function as the second argument. That + * function will be invoked with a single object with the response properties + * `{headers, status, statusText}`. The return value of this function will + * be used as the `ResponseInit` for the new `Response`. To change the values + * either modify the passed parameter(s) and return it, or return a totally + * new object. + * + * This method is intentionally limited to same-origin responses, regardless of + * whether CORS was used or not. + * + * @param {Response} response + * @param {Function} modifier + * @memberof workbox-core + */ + async function copyResponse(response, modifier) { + let origin = null; + // If response.url isn't set, assume it's cross-origin and keep origin null. + if (response.url) { + const responseURL = new URL(response.url); + origin = responseURL.origin; + } + if (origin !== self.location.origin) { + throw new WorkboxError('cross-origin-copy-response', { + origin + }); + } + const clonedResponse = response.clone(); + // Create a fresh `ResponseInit` object by cloning the headers. + const responseInit = { + headers: new Headers(clonedResponse.headers), + status: clonedResponse.status, + statusText: clonedResponse.statusText + }; + // Apply any user modifications. + const modifiedResponseInit = modifier ? modifier(responseInit) : responseInit; + // Create the new response from the body stream and `ResponseInit` + // modifications. Note: not all browsers support the Response.body stream, + // so fall back to reading the entire body into memory as a blob. + const body = canConstructResponseFromBodyStream() ? clonedResponse.body : await clonedResponse.blob(); + return new Response(body, modifiedResponseInit); + } + + /* + Copyright 2020 Google LLC + + Use of this source code is governed by an MIT-style + license that can be found in the LICENSE file or at + https://opensource.org/licenses/MIT. + */ + /** + * A {@link workbox-strategies.Strategy} implementation + * specifically designed to work with + * {@link workbox-precaching.PrecacheController} + * to both cache and fetch precached assets. + * + * Note: an instance of this class is created automatically when creating a + * `PrecacheController`; it's generally not necessary to create this yourself. + * + * @extends workbox-strategies.Strategy + * @memberof workbox-precaching + */ + class PrecacheStrategy extends Strategy { + /** + * + * @param {Object} [options] + * @param {string} [options.cacheName] Cache name to store and retrieve + * requests. Defaults to the cache names provided by + * {@link workbox-core.cacheNames}. + * @param {Array<Object>} [options.plugins] {@link https://developers.google.com/web/tools/workbox/guides/using-plugins|Plugins} + * to use in conjunction with this caching strategy. + * @param {Object} [options.fetchOptions] Values passed along to the + * {@link https://developer.mozilla.org/en-US/docs/Web/API/WindowOrWorkerGlobalScope/fetch#Parameters|init} + * of all fetch() requests made by this strategy. + * @param {Object} [options.matchOptions] The + * {@link https://w3c.github.io/ServiceWorker/#dictdef-cachequeryoptions|CacheQueryOptions} + * for any `cache.match()` or `cache.put()` calls made by this strategy. + * @param {boolean} [options.fallbackToNetwork=true] Whether to attempt to + * get the response from the network if there's a precache miss. + */ + constructor(options = {}) { + options.cacheName = cacheNames.getPrecacheName(options.cacheName); + super(options); + this._fallbackToNetwork = options.fallbackToNetwork === false ? false : true; + // Redirected responses cannot be used to satisfy a navigation request, so + // any redirected response must be "copied" rather than cloned, so the new + // response doesn't contain the `redirected` flag. See: + // https://bugs.chromium.org/p/chromium/issues/detail?id=669363&desc=2#c1 + this.plugins.push(PrecacheStrategy.copyRedirectedCacheableResponsesPlugin); + } + /** + * @private + * @param {Request|string} request A request to run this strategy for. + * @param {workbox-strategies.StrategyHandler} handler The event that + * triggered the request. + * @return {Promise<Response>} + */ + async _handle(request, handler) { + const response = await handler.cacheMatch(request); + if (response) { + return response; + } + // If this is an `install` event for an entry that isn't already cached, + // then populate the cache. + if (handler.event && handler.event.type === 'install') { + return await this._handleInstall(request, handler); + } + // Getting here means something went wrong. An entry that should have been + // precached wasn't found in the cache. + return await this._handleFetch(request, handler); + } + async _handleFetch(request, handler) { + let response; + const params = handler.params || {}; + // Fall back to the network if we're configured to do so. + if (this._fallbackToNetwork) { + { + logger.warn(`The precached response for ` + `${getFriendlyURL(request.url)} in ${this.cacheName} was not ` + `found. Falling back to the network.`); + } + const integrityInManifest = params.integrity; + const integrityInRequest = request.integrity; + const noIntegrityConflict = !integrityInRequest || integrityInRequest === integrityInManifest; + // Do not add integrity if the original request is no-cors + // See https://github.com/GoogleChrome/workbox/issues/3096 + response = await handler.fetch(new Request(request, { + integrity: request.mode !== 'no-cors' ? integrityInRequest || integrityInManifest : undefined + })); + // It's only "safe" to repair the cache if we're using SRI to guarantee + // that the response matches the precache manifest's expectations, + // and there's either a) no integrity property in the incoming request + // or b) there is an integrity, and it matches the precache manifest. + // See https://github.com/GoogleChrome/workbox/issues/2858 + // Also if the original request users no-cors we don't use integrity. + // See https://github.com/GoogleChrome/workbox/issues/3096 + if (integrityInManifest && noIntegrityConflict && request.mode !== 'no-cors') { + this._useDefaultCacheabilityPluginIfNeeded(); + const wasCached = await handler.cachePut(request, response.clone()); + { + if (wasCached) { + logger.log(`A response for ${getFriendlyURL(request.url)} ` + `was used to "repair" the precache.`); + } + } + } + } else { + // This shouldn't normally happen, but there are edge cases: + // https://github.com/GoogleChrome/workbox/issues/1441 + throw new WorkboxError('missing-precache-entry', { + cacheName: this.cacheName, + url: request.url + }); + } + { + const cacheKey = params.cacheKey || (await handler.getCacheKey(request, 'read')); + // Workbox is going to handle the route. + // print the routing details to the console. + logger.groupCollapsed(`Precaching is responding to: ` + getFriendlyURL(request.url)); + logger.log(`Serving the precached url: ${getFriendlyURL(cacheKey instanceof Request ? cacheKey.url : cacheKey)}`); + logger.groupCollapsed(`View request details here.`); + logger.log(request); + logger.groupEnd(); + logger.groupCollapsed(`View response details here.`); + logger.log(response); + logger.groupEnd(); + logger.groupEnd(); + } + return response; + } + async _handleInstall(request, handler) { + this._useDefaultCacheabilityPluginIfNeeded(); + const response = await handler.fetch(request); + // Make sure we defer cachePut() until after we know the response + // should be cached; see https://github.com/GoogleChrome/workbox/issues/2737 + const wasCached = await handler.cachePut(request, response.clone()); + if (!wasCached) { + // Throwing here will lead to the `install` handler failing, which + // we want to do if *any* of the responses aren't safe to cache. + throw new WorkboxError('bad-precaching-response', { + url: request.url, + status: response.status + }); + } + return response; + } + /** + * This method is complex, as there a number of things to account for: + * + * The `plugins` array can be set at construction, and/or it might be added to + * to at any time before the strategy is used. + * + * At the time the strategy is used (i.e. during an `install` event), there + * needs to be at least one plugin that implements `cacheWillUpdate` in the + * array, other than `copyRedirectedCacheableResponsesPlugin`. + * + * - If this method is called and there are no suitable `cacheWillUpdate` + * plugins, we need to add `defaultPrecacheCacheabilityPlugin`. + * + * - If this method is called and there is exactly one `cacheWillUpdate`, then + * we don't have to do anything (this might be a previously added + * `defaultPrecacheCacheabilityPlugin`, or it might be a custom plugin). + * + * - If this method is called and there is more than one `cacheWillUpdate`, + * then we need to check if one is `defaultPrecacheCacheabilityPlugin`. If so, + * we need to remove it. (This situation is unlikely, but it could happen if + * the strategy is used multiple times, the first without a `cacheWillUpdate`, + * and then later on after manually adding a custom `cacheWillUpdate`.) + * + * See https://github.com/GoogleChrome/workbox/issues/2737 for more context. + * + * @private + */ + _useDefaultCacheabilityPluginIfNeeded() { + let defaultPluginIndex = null; + let cacheWillUpdatePluginCount = 0; + for (const [index, plugin] of this.plugins.entries()) { + // Ignore the copy redirected plugin when determining what to do. + if (plugin === PrecacheStrategy.copyRedirectedCacheableResponsesPlugin) { + continue; + } + // Save the default plugin's index, in case it needs to be removed. + if (plugin === PrecacheStrategy.defaultPrecacheCacheabilityPlugin) { + defaultPluginIndex = index; + } + if (plugin.cacheWillUpdate) { + cacheWillUpdatePluginCount++; + } + } + if (cacheWillUpdatePluginCount === 0) { + this.plugins.push(PrecacheStrategy.defaultPrecacheCacheabilityPlugin); + } else if (cacheWillUpdatePluginCount > 1 && defaultPluginIndex !== null) { + // Only remove the default plugin; multiple custom plugins are allowed. + this.plugins.splice(defaultPluginIndex, 1); + } + // Nothing needs to be done if cacheWillUpdatePluginCount is 1 + } + } + PrecacheStrategy.defaultPrecacheCacheabilityPlugin = { + async cacheWillUpdate({ + response + }) { + if (!response || response.status >= 400) { + return null; + } + return response; + } + }; + PrecacheStrategy.copyRedirectedCacheableResponsesPlugin = { + async cacheWillUpdate({ + response + }) { + return response.redirected ? await copyResponse(response) : response; + } + }; + + /* + Copyright 2019 Google LLC + + Use of this source code is governed by an MIT-style + license that can be found in the LICENSE file or at + https://opensource.org/licenses/MIT. + */ + /** + * Performs efficient precaching of assets. + * + * @memberof workbox-precaching + */ + class PrecacheController { + /** + * Create a new PrecacheController. + * + * @param {Object} [options] + * @param {string} [options.cacheName] The cache to use for precaching. + * @param {string} [options.plugins] Plugins to use when precaching as well + * as responding to fetch events for precached assets. + * @param {boolean} [options.fallbackToNetwork=true] Whether to attempt to + * get the response from the network if there's a precache miss. + */ + constructor({ + cacheName, + plugins = [], + fallbackToNetwork = true + } = {}) { + this._urlsToCacheKeys = new Map(); + this._urlsToCacheModes = new Map(); + this._cacheKeysToIntegrities = new Map(); + this._strategy = new PrecacheStrategy({ + cacheName: cacheNames.getPrecacheName(cacheName), + plugins: [...plugins, new PrecacheCacheKeyPlugin({ + precacheController: this + })], + fallbackToNetwork + }); + // Bind the install and activate methods to the instance. + this.install = this.install.bind(this); + this.activate = this.activate.bind(this); + } + /** + * @type {workbox-precaching.PrecacheStrategy} The strategy created by this controller and + * used to cache assets and respond to fetch events. + */ + get strategy() { + return this._strategy; + } + /** + * Adds items to the precache list, removing any duplicates and + * stores the files in the + * {@link workbox-core.cacheNames|"precache cache"} when the service + * worker installs. + * + * This method can be called multiple times. + * + * @param {Array<Object|string>} [entries=[]] Array of entries to precache. + */ + precache(entries) { + this.addToCacheList(entries); + if (!this._installAndActiveListenersAdded) { + self.addEventListener('install', this.install); + self.addEventListener('activate', this.activate); + this._installAndActiveListenersAdded = true; + } + } + /** + * This method will add items to the precache list, removing duplicates + * and ensuring the information is valid. + * + * @param {Array<workbox-precaching.PrecacheController.PrecacheEntry|string>} entries + * Array of entries to precache. + */ + addToCacheList(entries) { + { + finalAssertExports.isArray(entries, { + moduleName: 'workbox-precaching', + className: 'PrecacheController', + funcName: 'addToCacheList', + paramName: 'entries' + }); + } + const urlsToWarnAbout = []; + for (const entry of entries) { + // See https://github.com/GoogleChrome/workbox/issues/2259 + if (typeof entry === 'string') { + urlsToWarnAbout.push(entry); + } else if (entry && entry.revision === undefined) { + urlsToWarnAbout.push(entry.url); + } + const { + cacheKey, + url + } = createCacheKey(entry); + const cacheMode = typeof entry !== 'string' && entry.revision ? 'reload' : 'default'; + if (this._urlsToCacheKeys.has(url) && this._urlsToCacheKeys.get(url) !== cacheKey) { + throw new WorkboxError('add-to-cache-list-conflicting-entries', { + firstEntry: this._urlsToCacheKeys.get(url), + secondEntry: cacheKey + }); + } + if (typeof entry !== 'string' && entry.integrity) { + if (this._cacheKeysToIntegrities.has(cacheKey) && this._cacheKeysToIntegrities.get(cacheKey) !== entry.integrity) { + throw new WorkboxError('add-to-cache-list-conflicting-integrities', { + url + }); + } + this._cacheKeysToIntegrities.set(cacheKey, entry.integrity); + } + this._urlsToCacheKeys.set(url, cacheKey); + this._urlsToCacheModes.set(url, cacheMode); + if (urlsToWarnAbout.length > 0) { + const warningMessage = `Workbox is precaching URLs without revision ` + `info: ${urlsToWarnAbout.join(', ')}\nThis is generally NOT safe. ` + `Learn more at https://bit.ly/wb-precache`; + { + logger.warn(warningMessage); + } + } + } + } + /** + * Precaches new and updated assets. Call this method from the service worker + * install event. + * + * Note: this method calls `event.waitUntil()` for you, so you do not need + * to call it yourself in your event handlers. + * + * @param {ExtendableEvent} event + * @return {Promise<workbox-precaching.InstallResult>} + */ + install(event) { + // waitUntil returns Promise<any> + // eslint-disable-next-line @typescript-eslint/no-unsafe-return + return waitUntil(event, async () => { + const installReportPlugin = new PrecacheInstallReportPlugin(); + this.strategy.plugins.push(installReportPlugin); + // Cache entries one at a time. + // See https://github.com/GoogleChrome/workbox/issues/2528 + for (const [url, cacheKey] of this._urlsToCacheKeys) { + const integrity = this._cacheKeysToIntegrities.get(cacheKey); + const cacheMode = this._urlsToCacheModes.get(url); + const request = new Request(url, { + integrity, + cache: cacheMode, + credentials: 'same-origin' + }); + await Promise.all(this.strategy.handleAll({ + params: { + cacheKey + }, + request, + event + })); + } + const { + updatedURLs, + notUpdatedURLs + } = installReportPlugin; + { + printInstallDetails(updatedURLs, notUpdatedURLs); + } + return { + updatedURLs, + notUpdatedURLs + }; + }); + } + /** + * Deletes assets that are no longer present in the current precache manifest. + * Call this method from the service worker activate event. + * + * Note: this method calls `event.waitUntil()` for you, so you do not need + * to call it yourself in your event handlers. + * + * @param {ExtendableEvent} event + * @return {Promise<workbox-precaching.CleanupResult>} + */ + activate(event) { + // waitUntil returns Promise<any> + // eslint-disable-next-line @typescript-eslint/no-unsafe-return + return waitUntil(event, async () => { + const cache = await self.caches.open(this.strategy.cacheName); + const currentlyCachedRequests = await cache.keys(); + const expectedCacheKeys = new Set(this._urlsToCacheKeys.values()); + const deletedURLs = []; + for (const request of currentlyCachedRequests) { + if (!expectedCacheKeys.has(request.url)) { + await cache.delete(request); + deletedURLs.push(request.url); + } + } + { + printCleanupDetails(deletedURLs); + } + return { + deletedURLs + }; + }); + } + /** + * Returns a mapping of a precached URL to the corresponding cache key, taking + * into account the revision information for the URL. + * + * @return {Map<string, string>} A URL to cache key mapping. + */ + getURLsToCacheKeys() { + return this._urlsToCacheKeys; + } + /** + * Returns a list of all the URLs that have been precached by the current + * service worker. + * + * @return {Array<string>} The precached URLs. + */ + getCachedURLs() { + return [...this._urlsToCacheKeys.keys()]; + } + /** + * Returns the cache key used for storing a given URL. If that URL is + * unversioned, like `/index.html', then the cache key will be the original + * URL with a search parameter appended to it. + * + * @param {string} url A URL whose cache key you want to look up. + * @return {string} The versioned URL that corresponds to a cache key + * for the original URL, or undefined if that URL isn't precached. + */ + getCacheKeyForURL(url) { + const urlObject = new URL(url, location.href); + return this._urlsToCacheKeys.get(urlObject.href); + } + /** + * @param {string} url A cache key whose SRI you want to look up. + * @return {string} The subresource integrity associated with the cache key, + * or undefined if it's not set. + */ + getIntegrityForCacheKey(cacheKey) { + return this._cacheKeysToIntegrities.get(cacheKey); + } + /** + * This acts as a drop-in replacement for + * [`cache.match()`](https://developer.mozilla.org/en-US/docs/Web/API/Cache/match) + * with the following differences: + * + * - It knows what the name of the precache is, and only checks in that cache. + * - It allows you to pass in an "original" URL without versioning parameters, + * and it will automatically look up the correct cache key for the currently + * active revision of that URL. + * + * E.g., `matchPrecache('index.html')` will find the correct precached + * response for the currently active service worker, even if the actual cache + * key is `'/index.html?__WB_REVISION__=1234abcd'`. + * + * @param {string|Request} request The key (without revisioning parameters) + * to look up in the precache. + * @return {Promise<Response|undefined>} + */ + async matchPrecache(request) { + const url = request instanceof Request ? request.url : request; + const cacheKey = this.getCacheKeyForURL(url); + if (cacheKey) { + const cache = await self.caches.open(this.strategy.cacheName); + return cache.match(cacheKey); + } + return undefined; + } + /** + * Returns a function that looks up `url` in the precache (taking into + * account revision information), and returns the corresponding `Response`. + * + * @param {string} url The precached URL which will be used to lookup the + * `Response`. + * @return {workbox-routing~handlerCallback} + */ + createHandlerBoundToURL(url) { + const cacheKey = this.getCacheKeyForURL(url); + if (!cacheKey) { + throw new WorkboxError('non-precached-url', { + url + }); + } + return options => { + options.request = new Request(url); + options.params = Object.assign({ + cacheKey + }, options.params); + return this.strategy.handle(options); + }; + } + } + + /* + Copyright 2019 Google LLC + + Use of this source code is governed by an MIT-style + license that can be found in the LICENSE file or at + https://opensource.org/licenses/MIT. + */ + let precacheController; + /** + * @return {PrecacheController} + * @private + */ + const getOrCreatePrecacheController = () => { + if (!precacheController) { + precacheController = new PrecacheController(); + } + return precacheController; + }; + + /* + Copyright 2018 Google LLC + + Use of this source code is governed by an MIT-style + license that can be found in the LICENSE file or at + https://opensource.org/licenses/MIT. + */ + /** + * Removes any URL search parameters that should be ignored. + * + * @param {URL} urlObject The original URL. + * @param {Array<RegExp>} ignoreURLParametersMatching RegExps to test against + * each search parameter name. Matches mean that the search parameter should be + * ignored. + * @return {URL} The URL with any ignored search parameters removed. + * + * @private + * @memberof workbox-precaching + */ + function removeIgnoredSearchParams(urlObject, ignoreURLParametersMatching = []) { + // Convert the iterable into an array at the start of the loop to make sure + // deletion doesn't mess up iteration. + for (const paramName of [...urlObject.searchParams.keys()]) { + if (ignoreURLParametersMatching.some(regExp => regExp.test(paramName))) { + urlObject.searchParams.delete(paramName); + } + } + return urlObject; + } + + /* + Copyright 2019 Google LLC + + Use of this source code is governed by an MIT-style + license that can be found in the LICENSE file or at + https://opensource.org/licenses/MIT. + */ + /** + * Generator function that yields possible variations on the original URL to + * check, one at a time. + * + * @param {string} url + * @param {Object} options + * + * @private + * @memberof workbox-precaching + */ + function* generateURLVariations(url, { + ignoreURLParametersMatching = [/^utm_/, /^fbclid$/], + directoryIndex = 'index.html', + cleanURLs = true, + urlManipulation + } = {}) { + const urlObject = new URL(url, location.href); + urlObject.hash = ''; + yield urlObject.href; + const urlWithoutIgnoredParams = removeIgnoredSearchParams(urlObject, ignoreURLParametersMatching); + yield urlWithoutIgnoredParams.href; + if (directoryIndex && urlWithoutIgnoredParams.pathname.endsWith('/')) { + const directoryURL = new URL(urlWithoutIgnoredParams.href); + directoryURL.pathname += directoryIndex; + yield directoryURL.href; + } + if (cleanURLs) { + const cleanURL = new URL(urlWithoutIgnoredParams.href); + cleanURL.pathname += '.html'; + yield cleanURL.href; + } + if (urlManipulation) { + const additionalURLs = urlManipulation({ + url: urlObject + }); + for (const urlToAttempt of additionalURLs) { + yield urlToAttempt.href; + } + } + } + + /* + Copyright 2020 Google LLC + + Use of this source code is governed by an MIT-style + license that can be found in the LICENSE file or at + https://opensource.org/licenses/MIT. + */ + /** + * A subclass of {@link workbox-routing.Route} that takes a + * {@link workbox-precaching.PrecacheController} + * instance and uses it to match incoming requests and handle fetching + * responses from the precache. + * + * @memberof workbox-precaching + * @extends workbox-routing.Route + */ + class PrecacheRoute extends Route { + /** + * @param {PrecacheController} precacheController A `PrecacheController` + * instance used to both match requests and respond to fetch events. + * @param {Object} [options] Options to control how requests are matched + * against the list of precached URLs. + * @param {string} [options.directoryIndex=index.html] The `directoryIndex` will + * check cache entries for a URLs ending with '/' to see if there is a hit when + * appending the `directoryIndex` value. + * @param {Array<RegExp>} [options.ignoreURLParametersMatching=[/^utm_/, /^fbclid$/]] An + * array of regex's to remove search params when looking for a cache match. + * @param {boolean} [options.cleanURLs=true] The `cleanURLs` option will + * check the cache for the URL with a `.html` added to the end of the end. + * @param {workbox-precaching~urlManipulation} [options.urlManipulation] + * This is a function that should take a URL and return an array of + * alternative URLs that should be checked for precache matches. + */ + constructor(precacheController, options) { + const match = ({ + request + }) => { + const urlsToCacheKeys = precacheController.getURLsToCacheKeys(); + for (const possibleURL of generateURLVariations(request.url, options)) { + const cacheKey = urlsToCacheKeys.get(possibleURL); + if (cacheKey) { + const integrity = precacheController.getIntegrityForCacheKey(cacheKey); + return { + cacheKey, + integrity + }; + } + } + { + logger.debug(`Precaching did not find a match for ` + getFriendlyURL(request.url)); + } + return; + }; + super(match, precacheController.strategy); + } + } + + /* + Copyright 2019 Google LLC + Use of this source code is governed by an MIT-style + license that can be found in the LICENSE file or at + https://opensource.org/licenses/MIT. + */ + /** + * Add a `fetch` listener to the service worker that will + * respond to + * [network requests]{@link https://developer.mozilla.org/en-US/docs/Web/API/Service_Worker_API/Using_Service_Workers#Custom_responses_to_requests} + * with precached assets. + * + * Requests for assets that aren't precached, the `FetchEvent` will not be + * responded to, allowing the event to fall through to other `fetch` event + * listeners. + * + * @param {Object} [options] See the {@link workbox-precaching.PrecacheRoute} + * options. + * + * @memberof workbox-precaching + */ + function addRoute(options) { + const precacheController = getOrCreatePrecacheController(); + const precacheRoute = new PrecacheRoute(precacheController, options); + registerRoute(precacheRoute); + } + + /* + Copyright 2019 Google LLC + + Use of this source code is governed by an MIT-style + license that can be found in the LICENSE file or at + https://opensource.org/licenses/MIT. + */ + /** + * Adds items to the precache list, removing any duplicates and + * stores the files in the + * {@link workbox-core.cacheNames|"precache cache"} when the service + * worker installs. + * + * This method can be called multiple times. + * + * Please note: This method **will not** serve any of the cached files for you. + * It only precaches files. To respond to a network request you call + * {@link workbox-precaching.addRoute}. + * + * If you have a single array of files to precache, you can just call + * {@link workbox-precaching.precacheAndRoute}. + * + * @param {Array<Object|string>} [entries=[]] Array of entries to precache. + * + * @memberof workbox-precaching + */ + function precache(entries) { + const precacheController = getOrCreatePrecacheController(); + precacheController.precache(entries); + } + + /* + Copyright 2019 Google LLC + + Use of this source code is governed by an MIT-style + license that can be found in the LICENSE file or at + https://opensource.org/licenses/MIT. + */ + /** + * This method will add entries to the precache list and add a route to + * respond to fetch events. + * + * This is a convenience method that will call + * {@link workbox-precaching.precache} and + * {@link workbox-precaching.addRoute} in a single call. + * + * @param {Array<Object|string>} entries Array of entries to precache. + * @param {Object} [options] See the + * {@link workbox-precaching.PrecacheRoute} options. + * + * @memberof workbox-precaching + */ + function precacheAndRoute(entries, options) { + precache(entries); + addRoute(options); + } + + /* + Copyright 2018 Google LLC + + Use of this source code is governed by an MIT-style + license that can be found in the LICENSE file or at + https://opensource.org/licenses/MIT. + */ + const SUBSTRING_TO_FIND = '-precache-'; + /** + * Cleans up incompatible precaches that were created by older versions of + * Workbox, by a service worker registered under the current scope. + * + * This is meant to be called as part of the `activate` event. + * + * This should be safe to use as long as you don't include `substringToFind` + * (defaulting to `-precache-`) in your non-precache cache names. + * + * @param {string} currentPrecacheName The cache name currently in use for + * precaching. This cache won't be deleted. + * @param {string} [substringToFind='-precache-'] Cache names which include this + * substring will be deleted (excluding `currentPrecacheName`). + * @return {Array<string>} A list of all the cache names that were deleted. + * + * @private + * @memberof workbox-precaching + */ + const deleteOutdatedCaches = async (currentPrecacheName, substringToFind = SUBSTRING_TO_FIND) => { + const cacheNames = await self.caches.keys(); + const cacheNamesToDelete = cacheNames.filter(cacheName => { + return cacheName.includes(substringToFind) && cacheName.includes(self.registration.scope) && cacheName !== currentPrecacheName; + }); + await Promise.all(cacheNamesToDelete.map(cacheName => self.caches.delete(cacheName))); + return cacheNamesToDelete; + }; + + /* + Copyright 2019 Google LLC + + Use of this source code is governed by an MIT-style + license that can be found in the LICENSE file or at + https://opensource.org/licenses/MIT. + */ + /** + * Adds an `activate` event listener which will clean up incompatible + * precaches that were created by older versions of Workbox. + * + * @memberof workbox-precaching + */ + function cleanupOutdatedCaches() { + // See https://github.com/Microsoft/TypeScript/issues/28357#issuecomment-436484705 + self.addEventListener('activate', event => { + const cacheName = cacheNames.getPrecacheName(); + event.waitUntil(deleteOutdatedCaches(cacheName).then(cachesDeleted => { + { + if (cachesDeleted.length > 0) { + logger.log(`The following out-of-date precaches were cleaned up ` + `automatically:`, cachesDeleted); + } + } + })); + }); + } + + /* + Copyright 2018 Google LLC + + Use of this source code is governed by an MIT-style + license that can be found in the LICENSE file or at + https://opensource.org/licenses/MIT. + */ + /** + * NavigationRoute makes it easy to create a + * {@link workbox-routing.Route} that matches for browser + * [navigation requests]{@link https://developers.google.com/web/fundamentals/primers/service-workers/high-performance-loading#first_what_are_navigation_requests}. + * + * It will only match incoming Requests whose + * {@link https://fetch.spec.whatwg.org/#concept-request-mode|mode} + * is set to `navigate`. + * + * You can optionally only apply this route to a subset of navigation requests + * by using one or both of the `denylist` and `allowlist` parameters. + * + * @memberof workbox-routing + * @extends workbox-routing.Route + */ + class NavigationRoute extends Route { + /** + * If both `denylist` and `allowlist` are provided, the `denylist` will + * take precedence and the request will not match this route. + * + * The regular expressions in `allowlist` and `denylist` + * are matched against the concatenated + * [`pathname`]{@link https://developer.mozilla.org/en-US/docs/Web/API/HTMLHyperlinkElementUtils/pathname} + * and [`search`]{@link https://developer.mozilla.org/en-US/docs/Web/API/HTMLHyperlinkElementUtils/search} + * portions of the requested URL. + * + * *Note*: These RegExps may be evaluated against every destination URL during + * a navigation. Avoid using + * [complex RegExps](https://github.com/GoogleChrome/workbox/issues/3077), + * or else your users may see delays when navigating your site. + * + * @param {workbox-routing~handlerCallback} handler A callback + * function that returns a Promise resulting in a Response. + * @param {Object} options + * @param {Array<RegExp>} [options.denylist] If any of these patterns match, + * the route will not handle the request (even if a allowlist RegExp matches). + * @param {Array<RegExp>} [options.allowlist=[/./]] If any of these patterns + * match the URL's pathname and search parameter, the route will handle the + * request (assuming the denylist doesn't match). + */ + constructor(handler, { + allowlist = [/./], + denylist = [] + } = {}) { + { + finalAssertExports.isArrayOfClass(allowlist, RegExp, { + moduleName: 'workbox-routing', + className: 'NavigationRoute', + funcName: 'constructor', + paramName: 'options.allowlist' + }); + finalAssertExports.isArrayOfClass(denylist, RegExp, { + moduleName: 'workbox-routing', + className: 'NavigationRoute', + funcName: 'constructor', + paramName: 'options.denylist' + }); + } + super(options => this._match(options), handler); + this._allowlist = allowlist; + this._denylist = denylist; + } + /** + * Routes match handler. + * + * @param {Object} options + * @param {URL} options.url + * @param {Request} options.request + * @return {boolean} + * + * @private + */ + _match({ + url, + request + }) { + if (request && request.mode !== 'navigate') { + return false; + } + const pathnameAndSearch = url.pathname + url.search; + for (const regExp of this._denylist) { + if (regExp.test(pathnameAndSearch)) { + { + logger.log(`The navigation route ${pathnameAndSearch} is not ` + `being used, since the URL matches this denylist pattern: ` + `${regExp.toString()}`); + } + return false; + } + } + if (this._allowlist.some(regExp => regExp.test(pathnameAndSearch))) { + { + logger.debug(`The navigation route ${pathnameAndSearch} ` + `is being used.`); + } + return true; + } + { + logger.log(`The navigation route ${pathnameAndSearch} is not ` + `being used, since the URL being navigated to doesn't ` + `match the allowlist.`); + } + return false; + } + } + + /* + Copyright 2019 Google LLC + + Use of this source code is governed by an MIT-style + license that can be found in the LICENSE file or at + https://opensource.org/licenses/MIT. + */ + /** + * Helper function that calls + * {@link PrecacheController#createHandlerBoundToURL} on the default + * {@link PrecacheController} instance. + * + * If you are creating your own {@link PrecacheController}, then call the + * {@link PrecacheController#createHandlerBoundToURL} on that instance, + * instead of using this function. + * + * @param {string} url The precached URL which will be used to lookup the + * `Response`. + * @param {boolean} [fallbackToNetwork=true] Whether to attempt to get the + * response from the network if there's a precache miss. + * @return {workbox-routing~handlerCallback} + * + * @memberof workbox-precaching + */ + function createHandlerBoundToURL(url) { + const precacheController = getOrCreatePrecacheController(); + return precacheController.createHandlerBoundToURL(url); + } + + exports.NavigationRoute = NavigationRoute; + exports.NetworkOnly = NetworkOnly; + exports.cleanupOutdatedCaches = cleanupOutdatedCaches; + exports.clientsClaim = clientsClaim; + exports.createHandlerBoundToURL = createHandlerBoundToURL; + exports.precacheAndRoute = precacheAndRoute; + exports.registerRoute = registerRoute; + +})); diff --git a/aiui/packages/app/dev-dist/workbox-f97094b3.js b/aiui/packages/app/dev-dist/workbox-f97094b3.js new file mode 100644 index 00000000..2fa6029e --- /dev/null +++ b/aiui/packages/app/dev-dist/workbox-f97094b3.js @@ -0,0 +1,4619 @@ +define(['exports'], (function (exports) { 'use strict'; + + // @ts-ignore + try { + self['workbox:core:7.3.0'] && _(); + } catch (e) {} + + /* + Copyright 2019 Google LLC + Use of this source code is governed by an MIT-style + license that can be found in the LICENSE file or at + https://opensource.org/licenses/MIT. + */ + const logger = (() => { + // Don't overwrite this value if it's already set. + // See https://github.com/GoogleChrome/workbox/pull/2284#issuecomment-560470923 + if (!('__WB_DISABLE_DEV_LOGS' in globalThis)) { + self.__WB_DISABLE_DEV_LOGS = false; + } + let inGroup = false; + const methodToColorMap = { + debug: `#7f8c8d`, + log: `#2ecc71`, + warn: `#f39c12`, + error: `#c0392b`, + groupCollapsed: `#3498db`, + groupEnd: null // No colored prefix on groupEnd + }; + const print = function (method, args) { + if (self.__WB_DISABLE_DEV_LOGS) { + return; + } + if (method === 'groupCollapsed') { + // Safari doesn't print all console.groupCollapsed() arguments: + // https://bugs.webkit.org/show_bug.cgi?id=182754 + if (/^((?!chrome|android).)*safari/i.test(navigator.userAgent)) { + console[method](...args); + return; + } + } + const styles = [`background: ${methodToColorMap[method]}`, `border-radius: 0.5em`, `color: white`, `font-weight: bold`, `padding: 2px 0.5em`]; + // When in a group, the workbox prefix is not displayed. + const logPrefix = inGroup ? [] : ['%cworkbox', styles.join(';')]; + console[method](...logPrefix, ...args); + if (method === 'groupCollapsed') { + inGroup = true; + } + if (method === 'groupEnd') { + inGroup = false; + } + }; + // eslint-disable-next-line @typescript-eslint/ban-types + const api = {}; + const loggerMethods = Object.keys(methodToColorMap); + for (const key of loggerMethods) { + const method = key; + api[method] = (...args) => { + print(method, args); + }; + } + return api; + })(); + + /* + Copyright 2018 Google LLC + + Use of this source code is governed by an MIT-style + license that can be found in the LICENSE file or at + https://opensource.org/licenses/MIT. + */ + const messages$1 = { + 'invalid-value': ({ + paramName, + validValueDescription, + value + }) => { + if (!paramName || !validValueDescription) { + throw new Error(`Unexpected input to 'invalid-value' error.`); + } + return `The '${paramName}' parameter was given a value with an ` + `unexpected value. ${validValueDescription} Received a value of ` + `${JSON.stringify(value)}.`; + }, + 'not-an-array': ({ + moduleName, + className, + funcName, + paramName + }) => { + if (!moduleName || !className || !funcName || !paramName) { + throw new Error(`Unexpected input to 'not-an-array' error.`); + } + return `The parameter '${paramName}' passed into ` + `'${moduleName}.${className}.${funcName}()' must be an array.`; + }, + 'incorrect-type': ({ + expectedType, + paramName, + moduleName, + className, + funcName + }) => { + if (!expectedType || !paramName || !moduleName || !funcName) { + throw new Error(`Unexpected input to 'incorrect-type' error.`); + } + const classNameStr = className ? `${className}.` : ''; + return `The parameter '${paramName}' passed into ` + `'${moduleName}.${classNameStr}` + `${funcName}()' must be of type ${expectedType}.`; + }, + 'incorrect-class': ({ + expectedClassName, + paramName, + moduleName, + className, + funcName, + isReturnValueProblem + }) => { + if (!expectedClassName || !moduleName || !funcName) { + throw new Error(`Unexpected input to 'incorrect-class' error.`); + } + const classNameStr = className ? `${className}.` : ''; + if (isReturnValueProblem) { + return `The return value from ` + `'${moduleName}.${classNameStr}${funcName}()' ` + `must be an instance of class ${expectedClassName}.`; + } + return `The parameter '${paramName}' passed into ` + `'${moduleName}.${classNameStr}${funcName}()' ` + `must be an instance of class ${expectedClassName}.`; + }, + 'missing-a-method': ({ + expectedMethod, + paramName, + moduleName, + className, + funcName + }) => { + if (!expectedMethod || !paramName || !moduleName || !className || !funcName) { + throw new Error(`Unexpected input to 'missing-a-method' error.`); + } + return `${moduleName}.${className}.${funcName}() expected the ` + `'${paramName}' parameter to expose a '${expectedMethod}' method.`; + }, + 'add-to-cache-list-unexpected-type': ({ + entry + }) => { + return `An unexpected entry was passed to ` + `'workbox-precaching.PrecacheController.addToCacheList()' The entry ` + `'${JSON.stringify(entry)}' isn't supported. You must supply an array of ` + `strings with one or more characters, objects with a url property or ` + `Request objects.`; + }, + 'add-to-cache-list-conflicting-entries': ({ + firstEntry, + secondEntry + }) => { + if (!firstEntry || !secondEntry) { + throw new Error(`Unexpected input to ` + `'add-to-cache-list-duplicate-entries' error.`); + } + return `Two of the entries passed to ` + `'workbox-precaching.PrecacheController.addToCacheList()' had the URL ` + `${firstEntry} but different revision details. Workbox is ` + `unable to cache and version the asset correctly. Please remove one ` + `of the entries.`; + }, + 'plugin-error-request-will-fetch': ({ + thrownErrorMessage + }) => { + if (!thrownErrorMessage) { + throw new Error(`Unexpected input to ` + `'plugin-error-request-will-fetch', error.`); + } + return `An error was thrown by a plugins 'requestWillFetch()' method. ` + `The thrown error message was: '${thrownErrorMessage}'.`; + }, + 'invalid-cache-name': ({ + cacheNameId, + value + }) => { + if (!cacheNameId) { + throw new Error(`Expected a 'cacheNameId' for error 'invalid-cache-name'`); + } + return `You must provide a name containing at least one character for ` + `setCacheDetails({${cacheNameId}: '...'}). Received a value of ` + `'${JSON.stringify(value)}'`; + }, + 'unregister-route-but-not-found-with-method': ({ + method + }) => { + if (!method) { + throw new Error(`Unexpected input to ` + `'unregister-route-but-not-found-with-method' error.`); + } + return `The route you're trying to unregister was not previously ` + `registered for the method type '${method}'.`; + }, + 'unregister-route-route-not-registered': () => { + return `The route you're trying to unregister was not previously ` + `registered.`; + }, + 'queue-replay-failed': ({ + name + }) => { + return `Replaying the background sync queue '${name}' failed.`; + }, + 'duplicate-queue-name': ({ + name + }) => { + return `The Queue name '${name}' is already being used. ` + `All instances of backgroundSync.Queue must be given unique names.`; + }, + 'expired-test-without-max-age': ({ + methodName, + paramName + }) => { + return `The '${methodName}()' method can only be used when the ` + `'${paramName}' is used in the constructor.`; + }, + 'unsupported-route-type': ({ + moduleName, + className, + funcName, + paramName + }) => { + return `The supplied '${paramName}' parameter was an unsupported type. ` + `Please check the docs for ${moduleName}.${className}.${funcName} for ` + `valid input types.`; + }, + 'not-array-of-class': ({ + value, + expectedClass, + moduleName, + className, + funcName, + paramName + }) => { + return `The supplied '${paramName}' parameter must be an array of ` + `'${expectedClass}' objects. Received '${JSON.stringify(value)},'. ` + `Please check the call to ${moduleName}.${className}.${funcName}() ` + `to fix the issue.`; + }, + 'max-entries-or-age-required': ({ + moduleName, + className, + funcName + }) => { + return `You must define either config.maxEntries or config.maxAgeSeconds` + `in ${moduleName}.${className}.${funcName}`; + }, + 'statuses-or-headers-required': ({ + moduleName, + className, + funcName + }) => { + return `You must define either config.statuses or config.headers` + `in ${moduleName}.${className}.${funcName}`; + }, + 'invalid-string': ({ + moduleName, + funcName, + paramName + }) => { + if (!paramName || !moduleName || !funcName) { + throw new Error(`Unexpected input to 'invalid-string' error.`); + } + return `When using strings, the '${paramName}' parameter must start with ` + `'http' (for cross-origin matches) or '/' (for same-origin matches). ` + `Please see the docs for ${moduleName}.${funcName}() for ` + `more info.`; + }, + 'channel-name-required': () => { + return `You must provide a channelName to construct a ` + `BroadcastCacheUpdate instance.`; + }, + 'invalid-responses-are-same-args': () => { + return `The arguments passed into responsesAreSame() appear to be ` + `invalid. Please ensure valid Responses are used.`; + }, + 'expire-custom-caches-only': () => { + return `You must provide a 'cacheName' property when using the ` + `expiration plugin with a runtime caching strategy.`; + }, + 'unit-must-be-bytes': ({ + normalizedRangeHeader + }) => { + if (!normalizedRangeHeader) { + throw new Error(`Unexpected input to 'unit-must-be-bytes' error.`); + } + return `The 'unit' portion of the Range header must be set to 'bytes'. ` + `The Range header provided was "${normalizedRangeHeader}"`; + }, + 'single-range-only': ({ + normalizedRangeHeader + }) => { + if (!normalizedRangeHeader) { + throw new Error(`Unexpected input to 'single-range-only' error.`); + } + return `Multiple ranges are not supported. Please use a single start ` + `value, and optional end value. The Range header provided was ` + `"${normalizedRangeHeader}"`; + }, + 'invalid-range-values': ({ + normalizedRangeHeader + }) => { + if (!normalizedRangeHeader) { + throw new Error(`Unexpected input to 'invalid-range-values' error.`); + } + return `The Range header is missing both start and end values. At least ` + `one of those values is needed. The Range header provided was ` + `"${normalizedRangeHeader}"`; + }, + 'no-range-header': () => { + return `No Range header was found in the Request provided.`; + }, + 'range-not-satisfiable': ({ + size, + start, + end + }) => { + return `The start (${start}) and end (${end}) values in the Range are ` + `not satisfiable by the cached response, which is ${size} bytes.`; + }, + 'attempt-to-cache-non-get-request': ({ + url, + method + }) => { + return `Unable to cache '${url}' because it is a '${method}' request and ` + `only 'GET' requests can be cached.`; + }, + 'cache-put-with-no-response': ({ + url + }) => { + return `There was an attempt to cache '${url}' but the response was not ` + `defined.`; + }, + 'no-response': ({ + url, + error + }) => { + let message = `The strategy could not generate a response for '${url}'.`; + if (error) { + message += ` The underlying error is ${error}.`; + } + return message; + }, + 'bad-precaching-response': ({ + url, + status + }) => { + return `The precaching request for '${url}' failed` + (status ? ` with an HTTP status of ${status}.` : `.`); + }, + 'non-precached-url': ({ + url + }) => { + return `createHandlerBoundToURL('${url}') was called, but that URL is ` + `not precached. Please pass in a URL that is precached instead.`; + }, + 'add-to-cache-list-conflicting-integrities': ({ + url + }) => { + return `Two of the entries passed to ` + `'workbox-precaching.PrecacheController.addToCacheList()' had the URL ` + `${url} with different integrity values. Please remove one of them.`; + }, + 'missing-precache-entry': ({ + cacheName, + url + }) => { + return `Unable to find a precached response in ${cacheName} for ${url}.`; + }, + 'cross-origin-copy-response': ({ + origin + }) => { + return `workbox-core.copyResponse() can only be used with same-origin ` + `responses. It was passed a response with origin ${origin}.`; + }, + 'opaque-streams-source': ({ + type + }) => { + const message = `One of the workbox-streams sources resulted in an ` + `'${type}' response.`; + if (type === 'opaqueredirect') { + return `${message} Please do not use a navigation request that results ` + `in a redirect as a source.`; + } + return `${message} Please ensure your sources are CORS-enabled.`; + } + }; + + /* + Copyright 2018 Google LLC + + Use of this source code is governed by an MIT-style + license that can be found in the LICENSE file or at + https://opensource.org/licenses/MIT. + */ + const generatorFunction = (code, details = {}) => { + const message = messages$1[code]; + if (!message) { + throw new Error(`Unable to find message for code '${code}'.`); + } + return message(details); + }; + const messageGenerator = generatorFunction; + + /* + Copyright 2018 Google LLC + + Use of this source code is governed by an MIT-style + license that can be found in the LICENSE file or at + https://opensource.org/licenses/MIT. + */ + /** + * Workbox errors should be thrown with this class. + * This allows use to ensure the type easily in tests, + * helps developers identify errors from workbox + * easily and allows use to optimise error + * messages correctly. + * + * @private + */ + class WorkboxError extends Error { + /** + * + * @param {string} errorCode The error code that + * identifies this particular error. + * @param {Object=} details Any relevant arguments + * that will help developers identify issues should + * be added as a key on the context object. + */ + constructor(errorCode, details) { + const message = messageGenerator(errorCode, details); + super(message); + this.name = errorCode; + this.details = details; + } + } + + /* + Copyright 2018 Google LLC + + Use of this source code is governed by an MIT-style + license that can be found in the LICENSE file or at + https://opensource.org/licenses/MIT. + */ + /* + * This method throws if the supplied value is not an array. + * The destructed values are required to produce a meaningful error for users. + * The destructed and restructured object is so it's clear what is + * needed. + */ + const isArray = (value, details) => { + if (!Array.isArray(value)) { + throw new WorkboxError('not-an-array', details); + } + }; + const hasMethod = (object, expectedMethod, details) => { + const type = typeof object[expectedMethod]; + if (type !== 'function') { + details['expectedMethod'] = expectedMethod; + throw new WorkboxError('missing-a-method', details); + } + }; + const isType = (object, expectedType, details) => { + if (typeof object !== expectedType) { + details['expectedType'] = expectedType; + throw new WorkboxError('incorrect-type', details); + } + }; + const isInstance = (object, + // Need the general type to do the check later. + // eslint-disable-next-line @typescript-eslint/ban-types + expectedClass, details) => { + if (!(object instanceof expectedClass)) { + details['expectedClassName'] = expectedClass.name; + throw new WorkboxError('incorrect-class', details); + } + }; + const isOneOf = (value, validValues, details) => { + if (!validValues.includes(value)) { + details['validValueDescription'] = `Valid values are ${JSON.stringify(validValues)}.`; + throw new WorkboxError('invalid-value', details); + } + }; + const isArrayOfClass = (value, + // Need general type to do check later. + expectedClass, + // eslint-disable-line + details) => { + const error = new WorkboxError('not-array-of-class', details); + if (!Array.isArray(value)) { + throw error; + } + for (const item of value) { + if (!(item instanceof expectedClass)) { + throw error; + } + } + }; + const finalAssertExports = { + hasMethod, + isArray, + isInstance, + isOneOf, + isType, + isArrayOfClass + }; + + // @ts-ignore + try { + self['workbox:routing:7.3.0'] && _(); + } catch (e) {} + + /* + Copyright 2018 Google LLC + + Use of this source code is governed by an MIT-style + license that can be found in the LICENSE file or at + https://opensource.org/licenses/MIT. + */ + /** + * The default HTTP method, 'GET', used when there's no specific method + * configured for a route. + * + * @type {string} + * + * @private + */ + const defaultMethod = 'GET'; + /** + * The list of valid HTTP methods associated with requests that could be routed. + * + * @type {Array<string>} + * + * @private + */ + const validMethods = ['DELETE', 'GET', 'HEAD', 'PATCH', 'POST', 'PUT']; + + /* + Copyright 2018 Google LLC + + Use of this source code is governed by an MIT-style + license that can be found in the LICENSE file or at + https://opensource.org/licenses/MIT. + */ + /** + * @param {function()|Object} handler Either a function, or an object with a + * 'handle' method. + * @return {Object} An object with a handle method. + * + * @private + */ + const normalizeHandler = handler => { + if (handler && typeof handler === 'object') { + { + finalAssertExports.hasMethod(handler, 'handle', { + moduleName: 'workbox-routing', + className: 'Route', + funcName: 'constructor', + paramName: 'handler' + }); + } + return handler; + } else { + { + finalAssertExports.isType(handler, 'function', { + moduleName: 'workbox-routing', + className: 'Route', + funcName: 'constructor', + paramName: 'handler' + }); + } + return { + handle: handler + }; + } + }; + + /* + Copyright 2018 Google LLC + + Use of this source code is governed by an MIT-style + license that can be found in the LICENSE file or at + https://opensource.org/licenses/MIT. + */ + /** + * A `Route` consists of a pair of callback functions, "match" and "handler". + * The "match" callback determine if a route should be used to "handle" a + * request by returning a non-falsy value if it can. The "handler" callback + * is called when there is a match and should return a Promise that resolves + * to a `Response`. + * + * @memberof workbox-routing + */ + class Route { + /** + * Constructor for Route class. + * + * @param {workbox-routing~matchCallback} match + * A callback function that determines whether the route matches a given + * `fetch` event by returning a non-falsy value. + * @param {workbox-routing~handlerCallback} handler A callback + * function that returns a Promise resolving to a Response. + * @param {string} [method='GET'] The HTTP method to match the Route + * against. + */ + constructor(match, handler, method = defaultMethod) { + { + finalAssertExports.isType(match, 'function', { + moduleName: 'workbox-routing', + className: 'Route', + funcName: 'constructor', + paramName: 'match' + }); + if (method) { + finalAssertExports.isOneOf(method, validMethods, { + paramName: 'method' + }); + } + } + // These values are referenced directly by Router so cannot be + // altered by minificaton. + this.handler = normalizeHandler(handler); + this.match = match; + this.method = method; + } + /** + * + * @param {workbox-routing-handlerCallback} handler A callback + * function that returns a Promise resolving to a Response + */ + setCatchHandler(handler) { + this.catchHandler = normalizeHandler(handler); + } + } + + /* + Copyright 2018 Google LLC + + Use of this source code is governed by an MIT-style + license that can be found in the LICENSE file or at + https://opensource.org/licenses/MIT. + */ + /** + * RegExpRoute makes it easy to create a regular expression based + * {@link workbox-routing.Route}. + * + * For same-origin requests the RegExp only needs to match part of the URL. For + * requests against third-party servers, you must define a RegExp that matches + * the start of the URL. + * + * @memberof workbox-routing + * @extends workbox-routing.Route + */ + class RegExpRoute extends Route { + /** + * If the regular expression contains + * [capture groups]{@link https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/RegExp#grouping-back-references}, + * the captured values will be passed to the + * {@link workbox-routing~handlerCallback} `params` + * argument. + * + * @param {RegExp} regExp The regular expression to match against URLs. + * @param {workbox-routing~handlerCallback} handler A callback + * function that returns a Promise resulting in a Response. + * @param {string} [method='GET'] The HTTP method to match the Route + * against. + */ + constructor(regExp, handler, method) { + { + finalAssertExports.isInstance(regExp, RegExp, { + moduleName: 'workbox-routing', + className: 'RegExpRoute', + funcName: 'constructor', + paramName: 'pattern' + }); + } + const match = ({ + url + }) => { + const result = regExp.exec(url.href); + // Return immediately if there's no match. + if (!result) { + return; + } + // Require that the match start at the first character in the URL string + // if it's a cross-origin request. + // See https://github.com/GoogleChrome/workbox/issues/281 for the context + // behind this behavior. + if (url.origin !== location.origin && result.index !== 0) { + { + logger.debug(`The regular expression '${regExp.toString()}' only partially matched ` + `against the cross-origin URL '${url.toString()}'. RegExpRoute's will only ` + `handle cross-origin requests if they match the entire URL.`); + } + return; + } + // If the route matches, but there aren't any capture groups defined, then + // this will return [], which is truthy and therefore sufficient to + // indicate a match. + // If there are capture groups, then it will return their values. + return result.slice(1); + }; + super(match, handler, method); + } + } + + /* + Copyright 2018 Google LLC + + Use of this source code is governed by an MIT-style + license that can be found in the LICENSE file or at + https://opensource.org/licenses/MIT. + */ + const getFriendlyURL = url => { + const urlObj = new URL(String(url), location.href); + // See https://github.com/GoogleChrome/workbox/issues/2323 + // We want to include everything, except for the origin if it's same-origin. + return urlObj.href.replace(new RegExp(`^${location.origin}`), ''); + }; + + /* + Copyright 2018 Google LLC + + Use of this source code is governed by an MIT-style + license that can be found in the LICENSE file or at + https://opensource.org/licenses/MIT. + */ + /** + * The Router can be used to process a `FetchEvent` using one or more + * {@link workbox-routing.Route}, responding with a `Response` if + * a matching route exists. + * + * If no route matches a given a request, the Router will use a "default" + * handler if one is defined. + * + * Should the matching Route throw an error, the Router will use a "catch" + * handler if one is defined to gracefully deal with issues and respond with a + * Request. + * + * If a request matches multiple routes, the **earliest** registered route will + * be used to respond to the request. + * + * @memberof workbox-routing + */ + class Router { + /** + * Initializes a new Router. + */ + constructor() { + this._routes = new Map(); + this._defaultHandlerMap = new Map(); + } + /** + * @return {Map<string, Array<workbox-routing.Route>>} routes A `Map` of HTTP + * method name ('GET', etc.) to an array of all the corresponding `Route` + * instances that are registered. + */ + get routes() { + return this._routes; + } + /** + * Adds a fetch event listener to respond to events when a route matches + * the event's request. + */ + addFetchListener() { + // See https://github.com/Microsoft/TypeScript/issues/28357#issuecomment-436484705 + self.addEventListener('fetch', event => { + const { + request + } = event; + const responsePromise = this.handleRequest({ + request, + event + }); + if (responsePromise) { + event.respondWith(responsePromise); + } + }); + } + /** + * Adds a message event listener for URLs to cache from the window. + * This is useful to cache resources loaded on the page prior to when the + * service worker started controlling it. + * + * The format of the message data sent from the window should be as follows. + * Where the `urlsToCache` array may consist of URL strings or an array of + * URL string + `requestInit` object (the same as you'd pass to `fetch()`). + * + * ``` + * { + * type: 'CACHE_URLS', + * payload: { + * urlsToCache: [ + * './script1.js', + * './script2.js', + * ['./script3.js', {mode: 'no-cors'}], + * ], + * }, + * } + * ``` + */ + addCacheListener() { + // See https://github.com/Microsoft/TypeScript/issues/28357#issuecomment-436484705 + self.addEventListener('message', event => { + // event.data is type 'any' + // eslint-disable-next-line @typescript-eslint/no-unsafe-member-access + if (event.data && event.data.type === 'CACHE_URLS') { + // eslint-disable-next-line @typescript-eslint/no-unsafe-assignment + const { + payload + } = event.data; + { + logger.debug(`Caching URLs from the window`, payload.urlsToCache); + } + const requestPromises = Promise.all(payload.urlsToCache.map(entry => { + if (typeof entry === 'string') { + entry = [entry]; + } + const request = new Request(...entry); + return this.handleRequest({ + request, + event + }); + // TODO(philipwalton): TypeScript errors without this typecast for + // some reason (probably a bug). The real type here should work but + // doesn't: `Array<Promise<Response> | undefined>`. + })); // TypeScript + event.waitUntil(requestPromises); + // If a MessageChannel was used, reply to the message on success. + if (event.ports && event.ports[0]) { + void requestPromises.then(() => event.ports[0].postMessage(true)); + } + } + }); + } + /** + * Apply the routing rules to a FetchEvent object to get a Response from an + * appropriate Route's handler. + * + * @param {Object} options + * @param {Request} options.request The request to handle. + * @param {ExtendableEvent} options.event The event that triggered the + * request. + * @return {Promise<Response>|undefined} A promise is returned if a + * registered route can handle the request. If there is no matching + * route and there's no `defaultHandler`, `undefined` is returned. + */ + handleRequest({ + request, + event + }) { + { + finalAssertExports.isInstance(request, Request, { + moduleName: 'workbox-routing', + className: 'Router', + funcName: 'handleRequest', + paramName: 'options.request' + }); + } + const url = new URL(request.url, location.href); + if (!url.protocol.startsWith('http')) { + { + logger.debug(`Workbox Router only supports URLs that start with 'http'.`); + } + return; + } + const sameOrigin = url.origin === location.origin; + const { + params, + route + } = this.findMatchingRoute({ + event, + request, + sameOrigin, + url + }); + let handler = route && route.handler; + const debugMessages = []; + { + if (handler) { + debugMessages.push([`Found a route to handle this request:`, route]); + if (params) { + debugMessages.push([`Passing the following params to the route's handler:`, params]); + } + } + } + // If we don't have a handler because there was no matching route, then + // fall back to defaultHandler if that's defined. + const method = request.method; + if (!handler && this._defaultHandlerMap.has(method)) { + { + debugMessages.push(`Failed to find a matching route. Falling ` + `back to the default handler for ${method}.`); + } + handler = this._defaultHandlerMap.get(method); + } + if (!handler) { + { + // No handler so Workbox will do nothing. If logs is set of debug + // i.e. verbose, we should print out this information. + logger.debug(`No route found for: ${getFriendlyURL(url)}`); + } + return; + } + { + // We have a handler, meaning Workbox is going to handle the route. + // print the routing details to the console. + logger.groupCollapsed(`Router is responding to: ${getFriendlyURL(url)}`); + debugMessages.forEach(msg => { + if (Array.isArray(msg)) { + logger.log(...msg); + } else { + logger.log(msg); + } + }); + logger.groupEnd(); + } + // Wrap in try and catch in case the handle method throws a synchronous + // error. It should still callback to the catch handler. + let responsePromise; + try { + responsePromise = handler.handle({ + url, + request, + event, + params + }); + } catch (err) { + responsePromise = Promise.reject(err); + } + // Get route's catch handler, if it exists + const catchHandler = route && route.catchHandler; + if (responsePromise instanceof Promise && (this._catchHandler || catchHandler)) { + responsePromise = responsePromise.catch(async err => { + // If there's a route catch handler, process that first + if (catchHandler) { + { + // Still include URL here as it will be async from the console group + // and may not make sense without the URL + logger.groupCollapsed(`Error thrown when responding to: ` + ` ${getFriendlyURL(url)}. Falling back to route's Catch Handler.`); + logger.error(`Error thrown by:`, route); + logger.error(err); + logger.groupEnd(); + } + try { + return await catchHandler.handle({ + url, + request, + event, + params + }); + } catch (catchErr) { + if (catchErr instanceof Error) { + err = catchErr; + } + } + } + if (this._catchHandler) { + { + // Still include URL here as it will be async from the console group + // and may not make sense without the URL + logger.groupCollapsed(`Error thrown when responding to: ` + ` ${getFriendlyURL(url)}. Falling back to global Catch Handler.`); + logger.error(`Error thrown by:`, route); + logger.error(err); + logger.groupEnd(); + } + return this._catchHandler.handle({ + url, + request, + event + }); + } + throw err; + }); + } + return responsePromise; + } + /** + * Checks a request and URL (and optionally an event) against the list of + * registered routes, and if there's a match, returns the corresponding + * route along with any params generated by the match. + * + * @param {Object} options + * @param {URL} options.url + * @param {boolean} options.sameOrigin The result of comparing `url.origin` + * against the current origin. + * @param {Request} options.request The request to match. + * @param {Event} options.event The corresponding event. + * @return {Object} An object with `route` and `params` properties. + * They are populated if a matching route was found or `undefined` + * otherwise. + */ + findMatchingRoute({ + url, + sameOrigin, + request, + event + }) { + const routes = this._routes.get(request.method) || []; + for (const route of routes) { + let params; + // route.match returns type any, not possible to change right now. + // eslint-disable-next-line @typescript-eslint/no-unsafe-assignment + const matchResult = route.match({ + url, + sameOrigin, + request, + event + }); + if (matchResult) { + { + // Warn developers that using an async matchCallback is almost always + // not the right thing to do. + if (matchResult instanceof Promise) { + logger.warn(`While routing ${getFriendlyURL(url)}, an async ` + `matchCallback function was used. Please convert the ` + `following route to use a synchronous matchCallback function:`, route); + } + } + // See https://github.com/GoogleChrome/workbox/issues/2079 + // eslint-disable-next-line @typescript-eslint/no-unsafe-assignment + params = matchResult; + if (Array.isArray(params) && params.length === 0) { + // Instead of passing an empty array in as params, use undefined. + params = undefined; + } else if (matchResult.constructor === Object && + // eslint-disable-line + Object.keys(matchResult).length === 0) { + // Instead of passing an empty object in as params, use undefined. + params = undefined; + } else if (typeof matchResult === 'boolean') { + // For the boolean value true (rather than just something truth-y), + // don't set params. + // See https://github.com/GoogleChrome/workbox/pull/2134#issuecomment-513924353 + params = undefined; + } + // Return early if have a match. + return { + route, + params + }; + } + } + // If no match was found above, return and empty object. + return {}; + } + /** + * Define a default `handler` that's called when no routes explicitly + * match the incoming request. + * + * Each HTTP method ('GET', 'POST', etc.) gets its own default handler. + * + * Without a default handler, unmatched requests will go against the + * network as if there were no service worker present. + * + * @param {workbox-routing~handlerCallback} handler A callback + * function that returns a Promise resulting in a Response. + * @param {string} [method='GET'] The HTTP method to associate with this + * default handler. Each method has its own default. + */ + setDefaultHandler(handler, method = defaultMethod) { + this._defaultHandlerMap.set(method, normalizeHandler(handler)); + } + /** + * If a Route throws an error while handling a request, this `handler` + * will be called and given a chance to provide a response. + * + * @param {workbox-routing~handlerCallback} handler A callback + * function that returns a Promise resulting in a Response. + */ + setCatchHandler(handler) { + this._catchHandler = normalizeHandler(handler); + } + /** + * Registers a route with the router. + * + * @param {workbox-routing.Route} route The route to register. + */ + registerRoute(route) { + { + finalAssertExports.isType(route, 'object', { + moduleName: 'workbox-routing', + className: 'Router', + funcName: 'registerRoute', + paramName: 'route' + }); + finalAssertExports.hasMethod(route, 'match', { + moduleName: 'workbox-routing', + className: 'Router', + funcName: 'registerRoute', + paramName: 'route' + }); + finalAssertExports.isType(route.handler, 'object', { + moduleName: 'workbox-routing', + className: 'Router', + funcName: 'registerRoute', + paramName: 'route' + }); + finalAssertExports.hasMethod(route.handler, 'handle', { + moduleName: 'workbox-routing', + className: 'Router', + funcName: 'registerRoute', + paramName: 'route.handler' + }); + finalAssertExports.isType(route.method, 'string', { + moduleName: 'workbox-routing', + className: 'Router', + funcName: 'registerRoute', + paramName: 'route.method' + }); + } + if (!this._routes.has(route.method)) { + this._routes.set(route.method, []); + } + // Give precedence to all of the earlier routes by adding this additional + // route to the end of the array. + this._routes.get(route.method).push(route); + } + /** + * Unregisters a route with the router. + * + * @param {workbox-routing.Route} route The route to unregister. + */ + unregisterRoute(route) { + if (!this._routes.has(route.method)) { + throw new WorkboxError('unregister-route-but-not-found-with-method', { + method: route.method + }); + } + const routeIndex = this._routes.get(route.method).indexOf(route); + if (routeIndex > -1) { + this._routes.get(route.method).splice(routeIndex, 1); + } else { + throw new WorkboxError('unregister-route-route-not-registered'); + } + } + } + + /* + Copyright 2019 Google LLC + + Use of this source code is governed by an MIT-style + license that can be found in the LICENSE file or at + https://opensource.org/licenses/MIT. + */ + let defaultRouter; + /** + * Creates a new, singleton Router instance if one does not exist. If one + * does already exist, that instance is returned. + * + * @private + * @return {Router} + */ + const getOrCreateDefaultRouter = () => { + if (!defaultRouter) { + defaultRouter = new Router(); + // The helpers that use the default Router assume these listeners exist. + defaultRouter.addFetchListener(); + defaultRouter.addCacheListener(); + } + return defaultRouter; + }; + + /* + Copyright 2019 Google LLC + + Use of this source code is governed by an MIT-style + license that can be found in the LICENSE file or at + https://opensource.org/licenses/MIT. + */ + /** + * Easily register a RegExp, string, or function with a caching + * strategy to a singleton Router instance. + * + * This method will generate a Route for you if needed and + * call {@link workbox-routing.Router#registerRoute}. + * + * @param {RegExp|string|workbox-routing.Route~matchCallback|workbox-routing.Route} capture + * If the capture param is a `Route`, all other arguments will be ignored. + * @param {workbox-routing~handlerCallback} [handler] A callback + * function that returns a Promise resulting in a Response. This parameter + * is required if `capture` is not a `Route` object. + * @param {string} [method='GET'] The HTTP method to match the Route + * against. + * @return {workbox-routing.Route} The generated `Route`. + * + * @memberof workbox-routing + */ + function registerRoute(capture, handler, method) { + let route; + if (typeof capture === 'string') { + const captureUrl = new URL(capture, location.href); + { + if (!(capture.startsWith('/') || capture.startsWith('http'))) { + throw new WorkboxError('invalid-string', { + moduleName: 'workbox-routing', + funcName: 'registerRoute', + paramName: 'capture' + }); + } + // We want to check if Express-style wildcards are in the pathname only. + // TODO: Remove this log message in v4. + const valueToCheck = capture.startsWith('http') ? captureUrl.pathname : capture; + // See https://github.com/pillarjs/path-to-regexp#parameters + const wildcards = '[*:?+]'; + if (new RegExp(`${wildcards}`).exec(valueToCheck)) { + logger.debug(`The '$capture' parameter contains an Express-style wildcard ` + `character (${wildcards}). Strings are now always interpreted as ` + `exact matches; use a RegExp for partial or wildcard matches.`); + } + } + const matchCallback = ({ + url + }) => { + { + if (url.pathname === captureUrl.pathname && url.origin !== captureUrl.origin) { + logger.debug(`${capture} only partially matches the cross-origin URL ` + `${url.toString()}. This route will only handle cross-origin requests ` + `if they match the entire URL.`); + } + } + return url.href === captureUrl.href; + }; + // If `capture` is a string then `handler` and `method` must be present. + route = new Route(matchCallback, handler, method); + } else if (capture instanceof RegExp) { + // If `capture` is a `RegExp` then `handler` and `method` must be present. + route = new RegExpRoute(capture, handler, method); + } else if (typeof capture === 'function') { + // If `capture` is a function then `handler` and `method` must be present. + route = new Route(capture, handler, method); + } else if (capture instanceof Route) { + route = capture; + } else { + throw new WorkboxError('unsupported-route-type', { + moduleName: 'workbox-routing', + funcName: 'registerRoute', + paramName: 'capture' + }); + } + const defaultRouter = getOrCreateDefaultRouter(); + defaultRouter.registerRoute(route); + return route; + } + + /* + Copyright 2019 Google LLC + Use of this source code is governed by an MIT-style + license that can be found in the LICENSE file or at + https://opensource.org/licenses/MIT. + */ + /** + * Returns a promise that resolves and the passed number of milliseconds. + * This utility is an async/await-friendly version of `setTimeout`. + * + * @param {number} ms + * @return {Promise} + * @private + */ + function timeout(ms) { + return new Promise(resolve => setTimeout(resolve, ms)); + } + + /* + Copyright 2018 Google LLC + + Use of this source code is governed by an MIT-style + license that can be found in the LICENSE file or at + https://opensource.org/licenses/MIT. + */ + const _cacheNameDetails = { + googleAnalytics: 'googleAnalytics', + precache: 'precache-v2', + prefix: 'workbox', + runtime: 'runtime', + suffix: typeof registration !== 'undefined' ? registration.scope : '' + }; + const _createCacheName = cacheName => { + return [_cacheNameDetails.prefix, cacheName, _cacheNameDetails.suffix].filter(value => value && value.length > 0).join('-'); + }; + const eachCacheNameDetail = fn => { + for (const key of Object.keys(_cacheNameDetails)) { + fn(key); + } + }; + const cacheNames = { + updateDetails: details => { + eachCacheNameDetail(key => { + if (typeof details[key] === 'string') { + _cacheNameDetails[key] = details[key]; + } + }); + }, + getGoogleAnalyticsName: userCacheName => { + return userCacheName || _createCacheName(_cacheNameDetails.googleAnalytics); + }, + getPrecacheName: userCacheName => { + return userCacheName || _createCacheName(_cacheNameDetails.precache); + }, + getPrefix: () => { + return _cacheNameDetails.prefix; + }, + getRuntimeName: userCacheName => { + return userCacheName || _createCacheName(_cacheNameDetails.runtime); + }, + getSuffix: () => { + return _cacheNameDetails.suffix; + } + }; + + /* + Copyright 2020 Google LLC + Use of this source code is governed by an MIT-style + license that can be found in the LICENSE file or at + https://opensource.org/licenses/MIT. + */ + function stripParams(fullURL, ignoreParams) { + const strippedURL = new URL(fullURL); + for (const param of ignoreParams) { + strippedURL.searchParams.delete(param); + } + return strippedURL.href; + } + /** + * Matches an item in the cache, ignoring specific URL params. This is similar + * to the `ignoreSearch` option, but it allows you to ignore just specific + * params (while continuing to match on the others). + * + * @private + * @param {Cache} cache + * @param {Request} request + * @param {Object} matchOptions + * @param {Array<string>} ignoreParams + * @return {Promise<Response|undefined>} + */ + async function cacheMatchIgnoreParams(cache, request, ignoreParams, matchOptions) { + const strippedRequestURL = stripParams(request.url, ignoreParams); + // If the request doesn't include any ignored params, match as normal. + if (request.url === strippedRequestURL) { + return cache.match(request, matchOptions); + } + // Otherwise, match by comparing keys + const keysOptions = Object.assign(Object.assign({}, matchOptions), { + ignoreSearch: true + }); + const cacheKeys = await cache.keys(request, keysOptions); + for (const cacheKey of cacheKeys) { + const strippedCacheKeyURL = stripParams(cacheKey.url, ignoreParams); + if (strippedRequestURL === strippedCacheKeyURL) { + return cache.match(cacheKey, matchOptions); + } + } + return; + } + + /* + Copyright 2018 Google LLC + + Use of this source code is governed by an MIT-style + license that can be found in the LICENSE file or at + https://opensource.org/licenses/MIT. + */ + /** + * The Deferred class composes Promises in a way that allows for them to be + * resolved or rejected from outside the constructor. In most cases promises + * should be used directly, but Deferreds can be necessary when the logic to + * resolve a promise must be separate. + * + * @private + */ + class Deferred { + /** + * Creates a promise and exposes its resolve and reject functions as methods. + */ + constructor() { + this.promise = new Promise((resolve, reject) => { + this.resolve = resolve; + this.reject = reject; + }); + } + } + + /* + Copyright 2018 Google LLC + + Use of this source code is governed by an MIT-style + license that can be found in the LICENSE file or at + https://opensource.org/licenses/MIT. + */ + // Callbacks to be executed whenever there's a quota error. + // Can't change Function type right now. + // eslint-disable-next-line @typescript-eslint/ban-types + const quotaErrorCallbacks = new Set(); + + /* + Copyright 2018 Google LLC + + Use of this source code is governed by an MIT-style + license that can be found in the LICENSE file or at + https://opensource.org/licenses/MIT. + */ + /** + * Runs all of the callback functions, one at a time sequentially, in the order + * in which they were registered. + * + * @memberof workbox-core + * @private + */ + async function executeQuotaErrorCallbacks() { + { + logger.log(`About to run ${quotaErrorCallbacks.size} ` + `callbacks to clean up caches.`); + } + for (const callback of quotaErrorCallbacks) { + await callback(); + { + logger.log(callback, 'is complete.'); + } + } + { + logger.log('Finished running callbacks.'); + } + } + + // @ts-ignore + try { + self['workbox:strategies:7.3.0'] && _(); + } catch (e) {} + + /* + Copyright 2020 Google LLC + + Use of this source code is governed by an MIT-style + license that can be found in the LICENSE file or at + https://opensource.org/licenses/MIT. + */ + function toRequest(input) { + return typeof input === 'string' ? new Request(input) : input; + } + /** + * A class created every time a Strategy instance calls + * {@link workbox-strategies.Strategy~handle} or + * {@link workbox-strategies.Strategy~handleAll} that wraps all fetch and + * cache actions around plugin callbacks and keeps track of when the strategy + * is "done" (i.e. all added `event.waitUntil()` promises have resolved). + * + * @memberof workbox-strategies + */ + class StrategyHandler { + /** + * Creates a new instance associated with the passed strategy and event + * that's handling the request. + * + * The constructor also initializes the state that will be passed to each of + * the plugins handling this request. + * + * @param {workbox-strategies.Strategy} strategy + * @param {Object} options + * @param {Request|string} options.request A request to run this strategy for. + * @param {ExtendableEvent} options.event The event associated with the + * request. + * @param {URL} [options.url] + * @param {*} [options.params] The return value from the + * {@link workbox-routing~matchCallback} (if applicable). + */ + constructor(strategy, options) { + this._cacheKeys = {}; + /** + * The request the strategy is performing (passed to the strategy's + * `handle()` or `handleAll()` method). + * @name request + * @instance + * @type {Request} + * @memberof workbox-strategies.StrategyHandler + */ + /** + * The event associated with this request. + * @name event + * @instance + * @type {ExtendableEvent} + * @memberof workbox-strategies.StrategyHandler + */ + /** + * A `URL` instance of `request.url` (if passed to the strategy's + * `handle()` or `handleAll()` method). + * Note: the `url` param will be present if the strategy was invoked + * from a workbox `Route` object. + * @name url + * @instance + * @type {URL|undefined} + * @memberof workbox-strategies.StrategyHandler + */ + /** + * A `param` value (if passed to the strategy's + * `handle()` or `handleAll()` method). + * Note: the `param` param will be present if the strategy was invoked + * from a workbox `Route` object and the + * {@link workbox-routing~matchCallback} returned + * a truthy value (it will be that value). + * @name params + * @instance + * @type {*|undefined} + * @memberof workbox-strategies.StrategyHandler + */ + { + finalAssertExports.isInstance(options.event, ExtendableEvent, { + moduleName: 'workbox-strategies', + className: 'StrategyHandler', + funcName: 'constructor', + paramName: 'options.event' + }); + } + Object.assign(this, options); + this.event = options.event; + this._strategy = strategy; + this._handlerDeferred = new Deferred(); + this._extendLifetimePromises = []; + // Copy the plugins list (since it's mutable on the strategy), + // so any mutations don't affect this handler instance. + this._plugins = [...strategy.plugins]; + this._pluginStateMap = new Map(); + for (const plugin of this._plugins) { + this._pluginStateMap.set(plugin, {}); + } + this.event.waitUntil(this._handlerDeferred.promise); + } + /** + * Fetches a given request (and invokes any applicable plugin callback + * methods) using the `fetchOptions` (for non-navigation requests) and + * `plugins` defined on the `Strategy` object. + * + * The following plugin lifecycle methods are invoked when using this method: + * - `requestWillFetch()` + * - `fetchDidSucceed()` + * - `fetchDidFail()` + * + * @param {Request|string} input The URL or request to fetch. + * @return {Promise<Response>} + */ + async fetch(input) { + const { + event + } = this; + let request = toRequest(input); + if (request.mode === 'navigate' && event instanceof FetchEvent && event.preloadResponse) { + const possiblePreloadResponse = await event.preloadResponse; + if (possiblePreloadResponse) { + { + logger.log(`Using a preloaded navigation response for ` + `'${getFriendlyURL(request.url)}'`); + } + return possiblePreloadResponse; + } + } + // If there is a fetchDidFail plugin, we need to save a clone of the + // original request before it's either modified by a requestWillFetch + // plugin or before the original request's body is consumed via fetch(). + const originalRequest = this.hasCallback('fetchDidFail') ? request.clone() : null; + try { + for (const cb of this.iterateCallbacks('requestWillFetch')) { + request = await cb({ + request: request.clone(), + event + }); + } + } catch (err) { + if (err instanceof Error) { + throw new WorkboxError('plugin-error-request-will-fetch', { + thrownErrorMessage: err.message + }); + } + } + // The request can be altered by plugins with `requestWillFetch` making + // the original request (most likely from a `fetch` event) different + // from the Request we make. Pass both to `fetchDidFail` to aid debugging. + const pluginFilteredRequest = request.clone(); + try { + let fetchResponse; + // See https://github.com/GoogleChrome/workbox/issues/1796 + fetchResponse = await fetch(request, request.mode === 'navigate' ? undefined : this._strategy.fetchOptions); + if ("development" !== 'production') { + logger.debug(`Network request for ` + `'${getFriendlyURL(request.url)}' returned a response with ` + `status '${fetchResponse.status}'.`); + } + for (const callback of this.iterateCallbacks('fetchDidSucceed')) { + fetchResponse = await callback({ + event, + request: pluginFilteredRequest, + response: fetchResponse + }); + } + return fetchResponse; + } catch (error) { + { + logger.log(`Network request for ` + `'${getFriendlyURL(request.url)}' threw an error.`, error); + } + // `originalRequest` will only exist if a `fetchDidFail` callback + // is being used (see above). + if (originalRequest) { + await this.runCallbacks('fetchDidFail', { + error: error, + event, + originalRequest: originalRequest.clone(), + request: pluginFilteredRequest.clone() + }); + } + throw error; + } + } + /** + * Calls `this.fetch()` and (in the background) runs `this.cachePut()` on + * the response generated by `this.fetch()`. + * + * The call to `this.cachePut()` automatically invokes `this.waitUntil()`, + * so you do not have to manually call `waitUntil()` on the event. + * + * @param {Request|string} input The request or URL to fetch and cache. + * @return {Promise<Response>} + */ + async fetchAndCachePut(input) { + const response = await this.fetch(input); + const responseClone = response.clone(); + void this.waitUntil(this.cachePut(input, responseClone)); + return response; + } + /** + * Matches a request from the cache (and invokes any applicable plugin + * callback methods) using the `cacheName`, `matchOptions`, and `plugins` + * defined on the strategy object. + * + * The following plugin lifecycle methods are invoked when using this method: + * - cacheKeyWillBeUsed() + * - cachedResponseWillBeUsed() + * + * @param {Request|string} key The Request or URL to use as the cache key. + * @return {Promise<Response|undefined>} A matching response, if found. + */ + async cacheMatch(key) { + const request = toRequest(key); + let cachedResponse; + const { + cacheName, + matchOptions + } = this._strategy; + const effectiveRequest = await this.getCacheKey(request, 'read'); + const multiMatchOptions = Object.assign(Object.assign({}, matchOptions), { + cacheName + }); + cachedResponse = await caches.match(effectiveRequest, multiMatchOptions); + { + if (cachedResponse) { + logger.debug(`Found a cached response in '${cacheName}'.`); + } else { + logger.debug(`No cached response found in '${cacheName}'.`); + } + } + for (const callback of this.iterateCallbacks('cachedResponseWillBeUsed')) { + cachedResponse = (await callback({ + cacheName, + matchOptions, + cachedResponse, + request: effectiveRequest, + event: this.event + })) || undefined; + } + return cachedResponse; + } + /** + * Puts a request/response pair in the cache (and invokes any applicable + * plugin callback methods) using the `cacheName` and `plugins` defined on + * the strategy object. + * + * The following plugin lifecycle methods are invoked when using this method: + * - cacheKeyWillBeUsed() + * - cacheWillUpdate() + * - cacheDidUpdate() + * + * @param {Request|string} key The request or URL to use as the cache key. + * @param {Response} response The response to cache. + * @return {Promise<boolean>} `false` if a cacheWillUpdate caused the response + * not be cached, and `true` otherwise. + */ + async cachePut(key, response) { + const request = toRequest(key); + // Run in the next task to avoid blocking other cache reads. + // https://github.com/w3c/ServiceWorker/issues/1397 + await timeout(0); + const effectiveRequest = await this.getCacheKey(request, 'write'); + { + if (effectiveRequest.method && effectiveRequest.method !== 'GET') { + throw new WorkboxError('attempt-to-cache-non-get-request', { + url: getFriendlyURL(effectiveRequest.url), + method: effectiveRequest.method + }); + } + // See https://github.com/GoogleChrome/workbox/issues/2818 + const vary = response.headers.get('Vary'); + if (vary) { + logger.debug(`The response for ${getFriendlyURL(effectiveRequest.url)} ` + `has a 'Vary: ${vary}' header. ` + `Consider setting the {ignoreVary: true} option on your strategy ` + `to ensure cache matching and deletion works as expected.`); + } + } + if (!response) { + { + logger.error(`Cannot cache non-existent response for ` + `'${getFriendlyURL(effectiveRequest.url)}'.`); + } + throw new WorkboxError('cache-put-with-no-response', { + url: getFriendlyURL(effectiveRequest.url) + }); + } + const responseToCache = await this._ensureResponseSafeToCache(response); + if (!responseToCache) { + { + logger.debug(`Response '${getFriendlyURL(effectiveRequest.url)}' ` + `will not be cached.`, responseToCache); + } + return false; + } + const { + cacheName, + matchOptions + } = this._strategy; + const cache = await self.caches.open(cacheName); + const hasCacheUpdateCallback = this.hasCallback('cacheDidUpdate'); + const oldResponse = hasCacheUpdateCallback ? await cacheMatchIgnoreParams( + // TODO(philipwalton): the `__WB_REVISION__` param is a precaching + // feature. Consider into ways to only add this behavior if using + // precaching. + cache, effectiveRequest.clone(), ['__WB_REVISION__'], matchOptions) : null; + { + logger.debug(`Updating the '${cacheName}' cache with a new Response ` + `for ${getFriendlyURL(effectiveRequest.url)}.`); + } + try { + await cache.put(effectiveRequest, hasCacheUpdateCallback ? responseToCache.clone() : responseToCache); + } catch (error) { + if (error instanceof Error) { + // See https://developer.mozilla.org/en-US/docs/Web/API/DOMException#exception-QuotaExceededError + if (error.name === 'QuotaExceededError') { + await executeQuotaErrorCallbacks(); + } + throw error; + } + } + for (const callback of this.iterateCallbacks('cacheDidUpdate')) { + await callback({ + cacheName, + oldResponse, + newResponse: responseToCache.clone(), + request: effectiveRequest, + event: this.event + }); + } + return true; + } + /** + * Checks the list of plugins for the `cacheKeyWillBeUsed` callback, and + * executes any of those callbacks found in sequence. The final `Request` + * object returned by the last plugin is treated as the cache key for cache + * reads and/or writes. If no `cacheKeyWillBeUsed` plugin callbacks have + * been registered, the passed request is returned unmodified + * + * @param {Request} request + * @param {string} mode + * @return {Promise<Request>} + */ + async getCacheKey(request, mode) { + const key = `${request.url} | ${mode}`; + if (!this._cacheKeys[key]) { + let effectiveRequest = request; + for (const callback of this.iterateCallbacks('cacheKeyWillBeUsed')) { + effectiveRequest = toRequest(await callback({ + mode, + request: effectiveRequest, + event: this.event, + // params has a type any can't change right now. + params: this.params // eslint-disable-line + })); + } + this._cacheKeys[key] = effectiveRequest; + } + return this._cacheKeys[key]; + } + /** + * Returns true if the strategy has at least one plugin with the given + * callback. + * + * @param {string} name The name of the callback to check for. + * @return {boolean} + */ + hasCallback(name) { + for (const plugin of this._strategy.plugins) { + if (name in plugin) { + return true; + } + } + return false; + } + /** + * Runs all plugin callbacks matching the given name, in order, passing the + * given param object (merged ith the current plugin state) as the only + * argument. + * + * Note: since this method runs all plugins, it's not suitable for cases + * where the return value of a callback needs to be applied prior to calling + * the next callback. See + * {@link workbox-strategies.StrategyHandler#iterateCallbacks} + * below for how to handle that case. + * + * @param {string} name The name of the callback to run within each plugin. + * @param {Object} param The object to pass as the first (and only) param + * when executing each callback. This object will be merged with the + * current plugin state prior to callback execution. + */ + async runCallbacks(name, param) { + for (const callback of this.iterateCallbacks(name)) { + // TODO(philipwalton): not sure why `any` is needed. It seems like + // this should work with `as WorkboxPluginCallbackParam[C]`. + await callback(param); + } + } + /** + * Accepts a callback and returns an iterable of matching plugin callbacks, + * where each callback is wrapped with the current handler state (i.e. when + * you call each callback, whatever object parameter you pass it will + * be merged with the plugin's current state). + * + * @param {string} name The name fo the callback to run + * @return {Array<Function>} + */ + *iterateCallbacks(name) { + for (const plugin of this._strategy.plugins) { + if (typeof plugin[name] === 'function') { + const state = this._pluginStateMap.get(plugin); + const statefulCallback = param => { + const statefulParam = Object.assign(Object.assign({}, param), { + state + }); + // TODO(philipwalton): not sure why `any` is needed. It seems like + // this should work with `as WorkboxPluginCallbackParam[C]`. + return plugin[name](statefulParam); + }; + yield statefulCallback; + } + } + } + /** + * Adds a promise to the + * [extend lifetime promises]{@link https://w3c.github.io/ServiceWorker/#extendableevent-extend-lifetime-promises} + * of the event associated with the request being handled (usually a + * `FetchEvent`). + * + * Note: you can await + * {@link workbox-strategies.StrategyHandler~doneWaiting} + * to know when all added promises have settled. + * + * @param {Promise} promise A promise to add to the extend lifetime promises + * of the event that triggered the request. + */ + waitUntil(promise) { + this._extendLifetimePromises.push(promise); + return promise; + } + /** + * Returns a promise that resolves once all promises passed to + * {@link workbox-strategies.StrategyHandler~waitUntil} + * have settled. + * + * Note: any work done after `doneWaiting()` settles should be manually + * passed to an event's `waitUntil()` method (not this handler's + * `waitUntil()` method), otherwise the service worker thread may be killed + * prior to your work completing. + */ + async doneWaiting() { + while (this._extendLifetimePromises.length) { + const promises = this._extendLifetimePromises.splice(0); + const result = await Promise.allSettled(promises); + const firstRejection = result.find(i => i.status === 'rejected'); + if (firstRejection) { + throw firstRejection.reason; + } + } + } + /** + * Stops running the strategy and immediately resolves any pending + * `waitUntil()` promises. + */ + destroy() { + this._handlerDeferred.resolve(null); + } + /** + * This method will call cacheWillUpdate on the available plugins (or use + * status === 200) to determine if the Response is safe and valid to cache. + * + * @param {Request} options.request + * @param {Response} options.response + * @return {Promise<Response|undefined>} + * + * @private + */ + async _ensureResponseSafeToCache(response) { + let responseToCache = response; + let pluginsUsed = false; + for (const callback of this.iterateCallbacks('cacheWillUpdate')) { + responseToCache = (await callback({ + request: this.request, + response: responseToCache, + event: this.event + })) || undefined; + pluginsUsed = true; + if (!responseToCache) { + break; + } + } + if (!pluginsUsed) { + if (responseToCache && responseToCache.status !== 200) { + responseToCache = undefined; + } + { + if (responseToCache) { + if (responseToCache.status !== 200) { + if (responseToCache.status === 0) { + logger.warn(`The response for '${this.request.url}' ` + `is an opaque response. The caching strategy that you're ` + `using will not cache opaque responses by default.`); + } else { + logger.debug(`The response for '${this.request.url}' ` + `returned a status code of '${response.status}' and won't ` + `be cached as a result.`); + } + } + } + } + } + return responseToCache; + } + } + + /* + Copyright 2020 Google LLC + + Use of this source code is governed by an MIT-style + license that can be found in the LICENSE file or at + https://opensource.org/licenses/MIT. + */ + /** + * An abstract base class that all other strategy classes must extend from: + * + * @memberof workbox-strategies + */ + class Strategy { + /** + * Creates a new instance of the strategy and sets all documented option + * properties as public instance properties. + * + * Note: if a custom strategy class extends the base Strategy class and does + * not need more than these properties, it does not need to define its own + * constructor. + * + * @param {Object} [options] + * @param {string} [options.cacheName] Cache name to store and retrieve + * requests. Defaults to the cache names provided by + * {@link workbox-core.cacheNames}. + * @param {Array<Object>} [options.plugins] [Plugins]{@link https://developers.google.com/web/tools/workbox/guides/using-plugins} + * to use in conjunction with this caching strategy. + * @param {Object} [options.fetchOptions] Values passed along to the + * [`init`](https://developer.mozilla.org/en-US/docs/Web/API/WindowOrWorkerGlobalScope/fetch#Parameters) + * of [non-navigation](https://github.com/GoogleChrome/workbox/issues/1796) + * `fetch()` requests made by this strategy. + * @param {Object} [options.matchOptions] The + * [`CacheQueryOptions`]{@link https://w3c.github.io/ServiceWorker/#dictdef-cachequeryoptions} + * for any `cache.match()` or `cache.put()` calls made by this strategy. + */ + constructor(options = {}) { + /** + * Cache name to store and retrieve + * requests. Defaults to the cache names provided by + * {@link workbox-core.cacheNames}. + * + * @type {string} + */ + this.cacheName = cacheNames.getRuntimeName(options.cacheName); + /** + * The list + * [Plugins]{@link https://developers.google.com/web/tools/workbox/guides/using-plugins} + * used by this strategy. + * + * @type {Array<Object>} + */ + this.plugins = options.plugins || []; + /** + * Values passed along to the + * [`init`]{@link https://developer.mozilla.org/en-US/docs/Web/API/WindowOrWorkerGlobalScope/fetch#Parameters} + * of all fetch() requests made by this strategy. + * + * @type {Object} + */ + this.fetchOptions = options.fetchOptions; + /** + * The + * [`CacheQueryOptions`]{@link https://w3c.github.io/ServiceWorker/#dictdef-cachequeryoptions} + * for any `cache.match()` or `cache.put()` calls made by this strategy. + * + * @type {Object} + */ + this.matchOptions = options.matchOptions; + } + /** + * Perform a request strategy and returns a `Promise` that will resolve with + * a `Response`, invoking all relevant plugin callbacks. + * + * When a strategy instance is registered with a Workbox + * {@link workbox-routing.Route}, this method is automatically + * called when the route matches. + * + * Alternatively, this method can be used in a standalone `FetchEvent` + * listener by passing it to `event.respondWith()`. + * + * @param {FetchEvent|Object} options A `FetchEvent` or an object with the + * properties listed below. + * @param {Request|string} options.request A request to run this strategy for. + * @param {ExtendableEvent} options.event The event associated with the + * request. + * @param {URL} [options.url] + * @param {*} [options.params] + */ + handle(options) { + const [responseDone] = this.handleAll(options); + return responseDone; + } + /** + * Similar to {@link workbox-strategies.Strategy~handle}, but + * instead of just returning a `Promise` that resolves to a `Response` it + * it will return an tuple of `[response, done]` promises, where the former + * (`response`) is equivalent to what `handle()` returns, and the latter is a + * Promise that will resolve once any promises that were added to + * `event.waitUntil()` as part of performing the strategy have completed. + * + * You can await the `done` promise to ensure any extra work performed by + * the strategy (usually caching responses) completes successfully. + * + * @param {FetchEvent|Object} options A `FetchEvent` or an object with the + * properties listed below. + * @param {Request|string} options.request A request to run this strategy for. + * @param {ExtendableEvent} options.event The event associated with the + * request. + * @param {URL} [options.url] + * @param {*} [options.params] + * @return {Array<Promise>} A tuple of [response, done] + * promises that can be used to determine when the response resolves as + * well as when the handler has completed all its work. + */ + handleAll(options) { + // Allow for flexible options to be passed. + if (options instanceof FetchEvent) { + options = { + event: options, + request: options.request + }; + } + const event = options.event; + const request = typeof options.request === 'string' ? new Request(options.request) : options.request; + const params = 'params' in options ? options.params : undefined; + const handler = new StrategyHandler(this, { + event, + request, + params + }); + const responseDone = this._getResponse(handler, request, event); + const handlerDone = this._awaitComplete(responseDone, handler, request, event); + // Return an array of promises, suitable for use with Promise.all(). + return [responseDone, handlerDone]; + } + async _getResponse(handler, request, event) { + await handler.runCallbacks('handlerWillStart', { + event, + request + }); + let response = undefined; + try { + response = await this._handle(request, handler); + // The "official" Strategy subclasses all throw this error automatically, + // but in case a third-party Strategy doesn't, ensure that we have a + // consistent failure when there's no response or an error response. + if (!response || response.type === 'error') { + throw new WorkboxError('no-response', { + url: request.url + }); + } + } catch (error) { + if (error instanceof Error) { + for (const callback of handler.iterateCallbacks('handlerDidError')) { + response = await callback({ + error, + event, + request + }); + if (response) { + break; + } + } + } + if (!response) { + throw error; + } else { + logger.log(`While responding to '${getFriendlyURL(request.url)}', ` + `an ${error instanceof Error ? error.toString() : ''} error occurred. Using a fallback response provided by ` + `a handlerDidError plugin.`); + } + } + for (const callback of handler.iterateCallbacks('handlerWillRespond')) { + response = await callback({ + event, + request, + response + }); + } + return response; + } + async _awaitComplete(responseDone, handler, request, event) { + let response; + let error; + try { + response = await responseDone; + } catch (error) { + // Ignore errors, as response errors should be caught via the `response` + // promise above. The `done` promise will only throw for errors in + // promises passed to `handler.waitUntil()`. + } + try { + await handler.runCallbacks('handlerDidRespond', { + event, + request, + response + }); + await handler.doneWaiting(); + } catch (waitUntilError) { + if (waitUntilError instanceof Error) { + error = waitUntilError; + } + } + await handler.runCallbacks('handlerDidComplete', { + event, + request, + response, + error: error + }); + handler.destroy(); + if (error) { + throw error; + } + } + } + /** + * Classes extending the `Strategy` based class should implement this method, + * and leverage the {@link workbox-strategies.StrategyHandler} + * arg to perform all fetching and cache logic, which will ensure all relevant + * cache, cache options, fetch options and plugins are used (per the current + * strategy instance). + * + * @name _handle + * @instance + * @abstract + * @function + * @param {Request} request + * @param {workbox-strategies.StrategyHandler} handler + * @return {Promise<Response>} + * + * @memberof workbox-strategies.Strategy + */ + + /* + Copyright 2018 Google LLC + + Use of this source code is governed by an MIT-style + license that can be found in the LICENSE file or at + https://opensource.org/licenses/MIT. + */ + const messages = { + strategyStart: (strategyName, request) => `Using ${strategyName} to respond to '${getFriendlyURL(request.url)}'`, + printFinalResponse: response => { + if (response) { + logger.groupCollapsed(`View the final response here.`); + logger.log(response || '[No response returned]'); + logger.groupEnd(); + } + } + }; + + /* + Copyright 2018 Google LLC + + Use of this source code is governed by an MIT-style + license that can be found in the LICENSE file or at + https://opensource.org/licenses/MIT. + */ + /** + * An implementation of a + * [network-only](https://developer.chrome.com/docs/workbox/caching-strategies-overview/#network-only) + * request strategy. + * + * This class is useful if you want to take advantage of any + * [Workbox plugins](https://developer.chrome.com/docs/workbox/using-plugins/). + * + * If the network request fails, this will throw a `WorkboxError` exception. + * + * @extends workbox-strategies.Strategy + * @memberof workbox-strategies + */ + class NetworkOnly extends Strategy { + /** + * @param {Object} [options] + * @param {Array<Object>} [options.plugins] [Plugins]{@link https://developers.google.com/web/tools/workbox/guides/using-plugins} + * to use in conjunction with this caching strategy. + * @param {Object} [options.fetchOptions] Values passed along to the + * [`init`](https://developer.mozilla.org/en-US/docs/Web/API/WindowOrWorkerGlobalScope/fetch#Parameters) + * of [non-navigation](https://github.com/GoogleChrome/workbox/issues/1796) + * `fetch()` requests made by this strategy. + * @param {number} [options.networkTimeoutSeconds] If set, any network requests + * that fail to respond within the timeout will result in a network error. + */ + constructor(options = {}) { + super(options); + this._networkTimeoutSeconds = options.networkTimeoutSeconds || 0; + } + /** + * @private + * @param {Request|string} request A request to run this strategy for. + * @param {workbox-strategies.StrategyHandler} handler The event that + * triggered the request. + * @return {Promise<Response>} + */ + async _handle(request, handler) { + { + finalAssertExports.isInstance(request, Request, { + moduleName: 'workbox-strategies', + className: this.constructor.name, + funcName: '_handle', + paramName: 'request' + }); + } + let error = undefined; + let response; + try { + const promises = [handler.fetch(request)]; + if (this._networkTimeoutSeconds) { + const timeoutPromise = timeout(this._networkTimeoutSeconds * 1000); + promises.push(timeoutPromise); + } + response = await Promise.race(promises); + if (!response) { + throw new Error(`Timed out the network response after ` + `${this._networkTimeoutSeconds} seconds.`); + } + } catch (err) { + if (err instanceof Error) { + error = err; + } + } + { + logger.groupCollapsed(messages.strategyStart(this.constructor.name, request)); + if (response) { + logger.log(`Got response from network.`); + } else { + logger.log(`Unable to get a response from the network.`); + } + messages.printFinalResponse(response); + logger.groupEnd(); + } + if (!response) { + throw new WorkboxError('no-response', { + url: request.url, + error + }); + } + return response; + } + } + + /* + Copyright 2019 Google LLC + Use of this source code is governed by an MIT-style + license that can be found in the LICENSE file or at + https://opensource.org/licenses/MIT. + */ + /** + * A helper function that prevents a promise from being flagged as unused. + * + * @private + **/ + function dontWaitFor(promise) { + // Effective no-op. + void promise.then(() => {}); + } + + /* + Copyright 2019 Google LLC + + Use of this source code is governed by an MIT-style + license that can be found in the LICENSE file or at + https://opensource.org/licenses/MIT. + */ + /** + * Adds a function to the set of quotaErrorCallbacks that will be executed if + * there's a quota error. + * + * @param {Function} callback + * @memberof workbox-core + */ + // Can't change Function type + // eslint-disable-next-line @typescript-eslint/ban-types + function registerQuotaErrorCallback(callback) { + { + finalAssertExports.isType(callback, 'function', { + moduleName: 'workbox-core', + funcName: 'register', + paramName: 'callback' + }); + } + quotaErrorCallbacks.add(callback); + { + logger.log('Registered a callback to respond to quota errors.', callback); + } + } + + function _extends() { + return _extends = Object.assign ? Object.assign.bind() : function (n) { + for (var e = 1; e < arguments.length; e++) { + var t = arguments[e]; + for (var r in t) ({}).hasOwnProperty.call(t, r) && (n[r] = t[r]); + } + return n; + }, _extends.apply(null, arguments); + } + + const instanceOfAny = (object, constructors) => constructors.some(c => object instanceof c); + let idbProxyableTypes; + let cursorAdvanceMethods; + // This is a function to prevent it throwing up in node environments. + function getIdbProxyableTypes() { + return idbProxyableTypes || (idbProxyableTypes = [IDBDatabase, IDBObjectStore, IDBIndex, IDBCursor, IDBTransaction]); + } + // This is a function to prevent it throwing up in node environments. + function getCursorAdvanceMethods() { + return cursorAdvanceMethods || (cursorAdvanceMethods = [IDBCursor.prototype.advance, IDBCursor.prototype.continue, IDBCursor.prototype.continuePrimaryKey]); + } + const cursorRequestMap = new WeakMap(); + const transactionDoneMap = new WeakMap(); + const transactionStoreNamesMap = new WeakMap(); + const transformCache = new WeakMap(); + const reverseTransformCache = new WeakMap(); + function promisifyRequest(request) { + const promise = new Promise((resolve, reject) => { + const unlisten = () => { + request.removeEventListener('success', success); + request.removeEventListener('error', error); + }; + const success = () => { + resolve(wrap(request.result)); + unlisten(); + }; + const error = () => { + reject(request.error); + unlisten(); + }; + request.addEventListener('success', success); + request.addEventListener('error', error); + }); + promise.then(value => { + // Since cursoring reuses the IDBRequest (*sigh*), we cache it for later retrieval + // (see wrapFunction). + if (value instanceof IDBCursor) { + cursorRequestMap.set(value, request); + } + // Catching to avoid "Uncaught Promise exceptions" + }).catch(() => {}); + // This mapping exists in reverseTransformCache but doesn't doesn't exist in transformCache. This + // is because we create many promises from a single IDBRequest. + reverseTransformCache.set(promise, request); + return promise; + } + function cacheDonePromiseForTransaction(tx) { + // Early bail if we've already created a done promise for this transaction. + if (transactionDoneMap.has(tx)) return; + const done = new Promise((resolve, reject) => { + const unlisten = () => { + tx.removeEventListener('complete', complete); + tx.removeEventListener('error', error); + tx.removeEventListener('abort', error); + }; + const complete = () => { + resolve(); + unlisten(); + }; + const error = () => { + reject(tx.error || new DOMException('AbortError', 'AbortError')); + unlisten(); + }; + tx.addEventListener('complete', complete); + tx.addEventListener('error', error); + tx.addEventListener('abort', error); + }); + // Cache it for later retrieval. + transactionDoneMap.set(tx, done); + } + let idbProxyTraps = { + get(target, prop, receiver) { + if (target instanceof IDBTransaction) { + // Special handling for transaction.done. + if (prop === 'done') return transactionDoneMap.get(target); + // Polyfill for objectStoreNames because of Edge. + if (prop === 'objectStoreNames') { + return target.objectStoreNames || transactionStoreNamesMap.get(target); + } + // Make tx.store return the only store in the transaction, or undefined if there are many. + if (prop === 'store') { + return receiver.objectStoreNames[1] ? undefined : receiver.objectStore(receiver.objectStoreNames[0]); + } + } + // Else transform whatever we get back. + return wrap(target[prop]); + }, + set(target, prop, value) { + target[prop] = value; + return true; + }, + has(target, prop) { + if (target instanceof IDBTransaction && (prop === 'done' || prop === 'store')) { + return true; + } + return prop in target; + } + }; + function replaceTraps(callback) { + idbProxyTraps = callback(idbProxyTraps); + } + function wrapFunction(func) { + // Due to expected object equality (which is enforced by the caching in `wrap`), we + // only create one new func per func. + // Edge doesn't support objectStoreNames (booo), so we polyfill it here. + if (func === IDBDatabase.prototype.transaction && !('objectStoreNames' in IDBTransaction.prototype)) { + return function (storeNames, ...args) { + const tx = func.call(unwrap(this), storeNames, ...args); + transactionStoreNamesMap.set(tx, storeNames.sort ? storeNames.sort() : [storeNames]); + return wrap(tx); + }; + } + // Cursor methods are special, as the behaviour is a little more different to standard IDB. In + // IDB, you advance the cursor and wait for a new 'success' on the IDBRequest that gave you the + // cursor. It's kinda like a promise that can resolve with many values. That doesn't make sense + // with real promises, so each advance methods returns a new promise for the cursor object, or + // undefined if the end of the cursor has been reached. + if (getCursorAdvanceMethods().includes(func)) { + return function (...args) { + // Calling the original function with the proxy as 'this' causes ILLEGAL INVOCATION, so we use + // the original object. + func.apply(unwrap(this), args); + return wrap(cursorRequestMap.get(this)); + }; + } + return function (...args) { + // Calling the original function with the proxy as 'this' causes ILLEGAL INVOCATION, so we use + // the original object. + return wrap(func.apply(unwrap(this), args)); + }; + } + function transformCachableValue(value) { + if (typeof value === 'function') return wrapFunction(value); + // This doesn't return, it just creates a 'done' promise for the transaction, + // which is later returned for transaction.done (see idbObjectHandler). + if (value instanceof IDBTransaction) cacheDonePromiseForTransaction(value); + if (instanceOfAny(value, getIdbProxyableTypes())) return new Proxy(value, idbProxyTraps); + // Return the same value back if we're not going to transform it. + return value; + } + function wrap(value) { + // We sometimes generate multiple promises from a single IDBRequest (eg when cursoring), because + // IDB is weird and a single IDBRequest can yield many responses, so these can't be cached. + if (value instanceof IDBRequest) return promisifyRequest(value); + // If we've already transformed this value before, reuse the transformed value. + // This is faster, but it also provides object equality. + if (transformCache.has(value)) return transformCache.get(value); + const newValue = transformCachableValue(value); + // Not all types are transformed. + // These may be primitive types, so they can't be WeakMap keys. + if (newValue !== value) { + transformCache.set(value, newValue); + reverseTransformCache.set(newValue, value); + } + return newValue; + } + const unwrap = value => reverseTransformCache.get(value); + + /** + * Open a database. + * + * @param name Name of the database. + * @param version Schema version. + * @param callbacks Additional callbacks. + */ + function openDB(name, version, { + blocked, + upgrade, + blocking, + terminated + } = {}) { + const request = indexedDB.open(name, version); + const openPromise = wrap(request); + if (upgrade) { + request.addEventListener('upgradeneeded', event => { + upgrade(wrap(request.result), event.oldVersion, event.newVersion, wrap(request.transaction), event); + }); + } + if (blocked) { + request.addEventListener('blocked', event => blocked( + // Casting due to https://github.com/microsoft/TypeScript-DOM-lib-generator/pull/1405 + event.oldVersion, event.newVersion, event)); + } + openPromise.then(db => { + if (terminated) db.addEventListener('close', () => terminated()); + if (blocking) { + db.addEventListener('versionchange', event => blocking(event.oldVersion, event.newVersion, event)); + } + }).catch(() => {}); + return openPromise; + } + /** + * Delete a database. + * + * @param name Name of the database. + */ + function deleteDB(name, { + blocked + } = {}) { + const request = indexedDB.deleteDatabase(name); + if (blocked) { + request.addEventListener('blocked', event => blocked( + // Casting due to https://github.com/microsoft/TypeScript-DOM-lib-generator/pull/1405 + event.oldVersion, event)); + } + return wrap(request).then(() => undefined); + } + const readMethods = ['get', 'getKey', 'getAll', 'getAllKeys', 'count']; + const writeMethods = ['put', 'add', 'delete', 'clear']; + const cachedMethods = new Map(); + function getMethod(target, prop) { + if (!(target instanceof IDBDatabase && !(prop in target) && typeof prop === 'string')) { + return; + } + if (cachedMethods.get(prop)) return cachedMethods.get(prop); + const targetFuncName = prop.replace(/FromIndex$/, ''); + const useIndex = prop !== targetFuncName; + const isWrite = writeMethods.includes(targetFuncName); + if ( + // Bail if the target doesn't exist on the target. Eg, getAll isn't in Edge. + !(targetFuncName in (useIndex ? IDBIndex : IDBObjectStore).prototype) || !(isWrite || readMethods.includes(targetFuncName))) { + return; + } + const method = async function (storeName, ...args) { + // isWrite ? 'readwrite' : undefined gzipps better, but fails in Edge :( + const tx = this.transaction(storeName, isWrite ? 'readwrite' : 'readonly'); + let target = tx.store; + if (useIndex) target = target.index(args.shift()); + // Must reject if op rejects. + // If it's a write operation, must reject if tx.done rejects. + // Must reject with op rejection first. + // Must resolve with op value. + // Must handle both promises (no unhandled rejections) + return (await Promise.all([target[targetFuncName](...args), isWrite && tx.done]))[0]; + }; + cachedMethods.set(prop, method); + return method; + } + replaceTraps(oldTraps => _extends({}, oldTraps, { + get: (target, prop, receiver) => getMethod(target, prop) || oldTraps.get(target, prop, receiver), + has: (target, prop) => !!getMethod(target, prop) || oldTraps.has(target, prop) + })); + + // @ts-ignore + try { + self['workbox:expiration:7.3.0'] && _(); + } catch (e) {} + + /* + Copyright 2018 Google LLC + + Use of this source code is governed by an MIT-style + license that can be found in the LICENSE file or at + https://opensource.org/licenses/MIT. + */ + const DB_NAME = 'workbox-expiration'; + const CACHE_OBJECT_STORE = 'cache-entries'; + const normalizeURL = unNormalizedUrl => { + const url = new URL(unNormalizedUrl, location.href); + url.hash = ''; + return url.href; + }; + /** + * Returns the timestamp model. + * + * @private + */ + class CacheTimestampsModel { + /** + * + * @param {string} cacheName + * + * @private + */ + constructor(cacheName) { + this._db = null; + this._cacheName = cacheName; + } + /** + * Performs an upgrade of indexedDB. + * + * @param {IDBPDatabase<CacheDbSchema>} db + * + * @private + */ + _upgradeDb(db) { + // TODO(philipwalton): EdgeHTML doesn't support arrays as a keyPath, so we + // have to use the `id` keyPath here and create our own values (a + // concatenation of `url + cacheName`) instead of simply using + // `keyPath: ['url', 'cacheName']`, which is supported in other browsers. + const objStore = db.createObjectStore(CACHE_OBJECT_STORE, { + keyPath: 'id' + }); + // TODO(philipwalton): once we don't have to support EdgeHTML, we can + // create a single index with the keyPath `['cacheName', 'timestamp']` + // instead of doing both these indexes. + objStore.createIndex('cacheName', 'cacheName', { + unique: false + }); + objStore.createIndex('timestamp', 'timestamp', { + unique: false + }); + } + /** + * Performs an upgrade of indexedDB and deletes deprecated DBs. + * + * @param {IDBPDatabase<CacheDbSchema>} db + * + * @private + */ + _upgradeDbAndDeleteOldDbs(db) { + this._upgradeDb(db); + if (this._cacheName) { + void deleteDB(this._cacheName); + } + } + /** + * @param {string} url + * @param {number} timestamp + * + * @private + */ + async setTimestamp(url, timestamp) { + url = normalizeURL(url); + const entry = { + url, + timestamp, + cacheName: this._cacheName, + // Creating an ID from the URL and cache name won't be necessary once + // Edge switches to Chromium and all browsers we support work with + // array keyPaths. + id: this._getId(url) + }; + const db = await this.getDb(); + const tx = db.transaction(CACHE_OBJECT_STORE, 'readwrite', { + durability: 'relaxed' + }); + await tx.store.put(entry); + await tx.done; + } + /** + * Returns the timestamp stored for a given URL. + * + * @param {string} url + * @return {number | undefined} + * + * @private + */ + async getTimestamp(url) { + const db = await this.getDb(); + const entry = await db.get(CACHE_OBJECT_STORE, this._getId(url)); + return entry === null || entry === void 0 ? void 0 : entry.timestamp; + } + /** + * Iterates through all the entries in the object store (from newest to + * oldest) and removes entries once either `maxCount` is reached or the + * entry's timestamp is less than `minTimestamp`. + * + * @param {number} minTimestamp + * @param {number} maxCount + * @return {Array<string>} + * + * @private + */ + async expireEntries(minTimestamp, maxCount) { + const db = await this.getDb(); + let cursor = await db.transaction(CACHE_OBJECT_STORE).store.index('timestamp').openCursor(null, 'prev'); + const entriesToDelete = []; + let entriesNotDeletedCount = 0; + while (cursor) { + const result = cursor.value; + // TODO(philipwalton): once we can use a multi-key index, we + // won't have to check `cacheName` here. + if (result.cacheName === this._cacheName) { + // Delete an entry if it's older than the max age or + // if we already have the max number allowed. + if (minTimestamp && result.timestamp < minTimestamp || maxCount && entriesNotDeletedCount >= maxCount) { + // TODO(philipwalton): we should be able to delete the + // entry right here, but doing so causes an iteration + // bug in Safari stable (fixed in TP). Instead we can + // store the keys of the entries to delete, and then + // delete the separate transactions. + // https://github.com/GoogleChrome/workbox/issues/1978 + // cursor.delete(); + // We only need to return the URL, not the whole entry. + entriesToDelete.push(cursor.value); + } else { + entriesNotDeletedCount++; + } + } + cursor = await cursor.continue(); + } + // TODO(philipwalton): once the Safari bug in the following issue is fixed, + // we should be able to remove this loop and do the entry deletion in the + // cursor loop above: + // https://github.com/GoogleChrome/workbox/issues/1978 + const urlsDeleted = []; + for (const entry of entriesToDelete) { + await db.delete(CACHE_OBJECT_STORE, entry.id); + urlsDeleted.push(entry.url); + } + return urlsDeleted; + } + /** + * Takes a URL and returns an ID that will be unique in the object store. + * + * @param {string} url + * @return {string} + * + * @private + */ + _getId(url) { + // Creating an ID from the URL and cache name won't be necessary once + // Edge switches to Chromium and all browsers we support work with + // array keyPaths. + return this._cacheName + '|' + normalizeURL(url); + } + /** + * Returns an open connection to the database. + * + * @private + */ + async getDb() { + if (!this._db) { + this._db = await openDB(DB_NAME, 1, { + upgrade: this._upgradeDbAndDeleteOldDbs.bind(this) + }); + } + return this._db; + } + } + + /* + Copyright 2018 Google LLC + + Use of this source code is governed by an MIT-style + license that can be found in the LICENSE file or at + https://opensource.org/licenses/MIT. + */ + /** + * The `CacheExpiration` class allows you define an expiration and / or + * limit on the number of responses stored in a + * [`Cache`](https://developer.mozilla.org/en-US/docs/Web/API/Cache). + * + * @memberof workbox-expiration + */ + class CacheExpiration { + /** + * To construct a new CacheExpiration instance you must provide at least + * one of the `config` properties. + * + * @param {string} cacheName Name of the cache to apply restrictions to. + * @param {Object} config + * @param {number} [config.maxEntries] The maximum number of entries to cache. + * Entries used the least will be removed as the maximum is reached. + * @param {number} [config.maxAgeSeconds] The maximum age of an entry before + * it's treated as stale and removed. + * @param {Object} [config.matchOptions] The [`CacheQueryOptions`](https://developer.mozilla.org/en-US/docs/Web/API/Cache/delete#Parameters) + * that will be used when calling `delete()` on the cache. + */ + constructor(cacheName, config = {}) { + this._isRunning = false; + this._rerunRequested = false; + { + finalAssertExports.isType(cacheName, 'string', { + moduleName: 'workbox-expiration', + className: 'CacheExpiration', + funcName: 'constructor', + paramName: 'cacheName' + }); + if (!(config.maxEntries || config.maxAgeSeconds)) { + throw new WorkboxError('max-entries-or-age-required', { + moduleName: 'workbox-expiration', + className: 'CacheExpiration', + funcName: 'constructor' + }); + } + if (config.maxEntries) { + finalAssertExports.isType(config.maxEntries, 'number', { + moduleName: 'workbox-expiration', + className: 'CacheExpiration', + funcName: 'constructor', + paramName: 'config.maxEntries' + }); + } + if (config.maxAgeSeconds) { + finalAssertExports.isType(config.maxAgeSeconds, 'number', { + moduleName: 'workbox-expiration', + className: 'CacheExpiration', + funcName: 'constructor', + paramName: 'config.maxAgeSeconds' + }); + } + } + this._maxEntries = config.maxEntries; + this._maxAgeSeconds = config.maxAgeSeconds; + this._matchOptions = config.matchOptions; + this._cacheName = cacheName; + this._timestampModel = new CacheTimestampsModel(cacheName); + } + /** + * Expires entries for the given cache and given criteria. + */ + async expireEntries() { + if (this._isRunning) { + this._rerunRequested = true; + return; + } + this._isRunning = true; + const minTimestamp = this._maxAgeSeconds ? Date.now() - this._maxAgeSeconds * 1000 : 0; + const urlsExpired = await this._timestampModel.expireEntries(minTimestamp, this._maxEntries); + // Delete URLs from the cache + const cache = await self.caches.open(this._cacheName); + for (const url of urlsExpired) { + await cache.delete(url, this._matchOptions); + } + { + if (urlsExpired.length > 0) { + logger.groupCollapsed(`Expired ${urlsExpired.length} ` + `${urlsExpired.length === 1 ? 'entry' : 'entries'} and removed ` + `${urlsExpired.length === 1 ? 'it' : 'them'} from the ` + `'${this._cacheName}' cache.`); + logger.log(`Expired the following ${urlsExpired.length === 1 ? 'URL' : 'URLs'}:`); + urlsExpired.forEach(url => logger.log(` ${url}`)); + logger.groupEnd(); + } else { + logger.debug(`Cache expiration ran and found no entries to remove.`); + } + } + this._isRunning = false; + if (this._rerunRequested) { + this._rerunRequested = false; + dontWaitFor(this.expireEntries()); + } + } + /** + * Update the timestamp for the given URL. This ensures the when + * removing entries based on maximum entries, most recently used + * is accurate or when expiring, the timestamp is up-to-date. + * + * @param {string} url + */ + async updateTimestamp(url) { + { + finalAssertExports.isType(url, 'string', { + moduleName: 'workbox-expiration', + className: 'CacheExpiration', + funcName: 'updateTimestamp', + paramName: 'url' + }); + } + await this._timestampModel.setTimestamp(url, Date.now()); + } + /** + * Can be used to check if a URL has expired or not before it's used. + * + * This requires a look up from IndexedDB, so can be slow. + * + * Note: This method will not remove the cached entry, call + * `expireEntries()` to remove indexedDB and Cache entries. + * + * @param {string} url + * @return {boolean} + */ + async isURLExpired(url) { + if (!this._maxAgeSeconds) { + { + throw new WorkboxError(`expired-test-without-max-age`, { + methodName: 'isURLExpired', + paramName: 'maxAgeSeconds' + }); + } + } else { + const timestamp = await this._timestampModel.getTimestamp(url); + const expireOlderThan = Date.now() - this._maxAgeSeconds * 1000; + return timestamp !== undefined ? timestamp < expireOlderThan : true; + } + } + /** + * Removes the IndexedDB object store used to keep track of cache expiration + * metadata. + */ + async delete() { + // Make sure we don't attempt another rerun if we're called in the middle of + // a cache expiration. + this._rerunRequested = false; + await this._timestampModel.expireEntries(Infinity); // Expires all. + } + } + + /* + Copyright 2018 Google LLC + + Use of this source code is governed by an MIT-style + license that can be found in the LICENSE file or at + https://opensource.org/licenses/MIT. + */ + /** + * This plugin can be used in a `workbox-strategy` to regularly enforce a + * limit on the age and / or the number of cached requests. + * + * It can only be used with `workbox-strategy` instances that have a + * [custom `cacheName` property set](/web/tools/workbox/guides/configure-workbox#custom_cache_names_in_strategies). + * In other words, it can't be used to expire entries in strategy that uses the + * default runtime cache name. + * + * Whenever a cached response is used or updated, this plugin will look + * at the associated cache and remove any old or extra responses. + * + * When using `maxAgeSeconds`, responses may be used *once* after expiring + * because the expiration clean up will not have occurred until *after* the + * cached response has been used. If the response has a "Date" header, then + * a light weight expiration check is performed and the response will not be + * used immediately. + * + * When using `maxEntries`, the entry least-recently requested will be removed + * from the cache first. + * + * @memberof workbox-expiration + */ + class ExpirationPlugin { + /** + * @param {ExpirationPluginOptions} config + * @param {number} [config.maxEntries] The maximum number of entries to cache. + * Entries used the least will be removed as the maximum is reached. + * @param {number} [config.maxAgeSeconds] The maximum age of an entry before + * it's treated as stale and removed. + * @param {Object} [config.matchOptions] The [`CacheQueryOptions`](https://developer.mozilla.org/en-US/docs/Web/API/Cache/delete#Parameters) + * that will be used when calling `delete()` on the cache. + * @param {boolean} [config.purgeOnQuotaError] Whether to opt this cache in to + * automatic deletion if the available storage quota has been exceeded. + */ + constructor(config = {}) { + /** + * A "lifecycle" callback that will be triggered automatically by the + * `workbox-strategies` handlers when a `Response` is about to be returned + * from a [Cache](https://developer.mozilla.org/en-US/docs/Web/API/Cache) to + * the handler. It allows the `Response` to be inspected for freshness and + * prevents it from being used if the `Response`'s `Date` header value is + * older than the configured `maxAgeSeconds`. + * + * @param {Object} options + * @param {string} options.cacheName Name of the cache the response is in. + * @param {Response} options.cachedResponse The `Response` object that's been + * read from a cache and whose freshness should be checked. + * @return {Response} Either the `cachedResponse`, if it's + * fresh, or `null` if the `Response` is older than `maxAgeSeconds`. + * + * @private + */ + this.cachedResponseWillBeUsed = async ({ + event, + request, + cacheName, + cachedResponse + }) => { + if (!cachedResponse) { + return null; + } + const isFresh = this._isResponseDateFresh(cachedResponse); + // Expire entries to ensure that even if the expiration date has + // expired, it'll only be used once. + const cacheExpiration = this._getCacheExpiration(cacheName); + dontWaitFor(cacheExpiration.expireEntries()); + // Update the metadata for the request URL to the current timestamp, + // but don't `await` it as we don't want to block the response. + const updateTimestampDone = cacheExpiration.updateTimestamp(request.url); + if (event) { + try { + event.waitUntil(updateTimestampDone); + } catch (error) { + { + // The event may not be a fetch event; only log the URL if it is. + if ('request' in event) { + logger.warn(`Unable to ensure service worker stays alive when ` + `updating cache entry for ` + `'${getFriendlyURL(event.request.url)}'.`); + } + } + } + } + return isFresh ? cachedResponse : null; + }; + /** + * A "lifecycle" callback that will be triggered automatically by the + * `workbox-strategies` handlers when an entry is added to a cache. + * + * @param {Object} options + * @param {string} options.cacheName Name of the cache that was updated. + * @param {string} options.request The Request for the cached entry. + * + * @private + */ + this.cacheDidUpdate = async ({ + cacheName, + request + }) => { + { + finalAssertExports.isType(cacheName, 'string', { + moduleName: 'workbox-expiration', + className: 'Plugin', + funcName: 'cacheDidUpdate', + paramName: 'cacheName' + }); + finalAssertExports.isInstance(request, Request, { + moduleName: 'workbox-expiration', + className: 'Plugin', + funcName: 'cacheDidUpdate', + paramName: 'request' + }); + } + const cacheExpiration = this._getCacheExpiration(cacheName); + await cacheExpiration.updateTimestamp(request.url); + await cacheExpiration.expireEntries(); + }; + { + if (!(config.maxEntries || config.maxAgeSeconds)) { + throw new WorkboxError('max-entries-or-age-required', { + moduleName: 'workbox-expiration', + className: 'Plugin', + funcName: 'constructor' + }); + } + if (config.maxEntries) { + finalAssertExports.isType(config.maxEntries, 'number', { + moduleName: 'workbox-expiration', + className: 'Plugin', + funcName: 'constructor', + paramName: 'config.maxEntries' + }); + } + if (config.maxAgeSeconds) { + finalAssertExports.isType(config.maxAgeSeconds, 'number', { + moduleName: 'workbox-expiration', + className: 'Plugin', + funcName: 'constructor', + paramName: 'config.maxAgeSeconds' + }); + } + } + this._config = config; + this._maxAgeSeconds = config.maxAgeSeconds; + this._cacheExpirations = new Map(); + if (config.purgeOnQuotaError) { + registerQuotaErrorCallback(() => this.deleteCacheAndMetadata()); + } + } + /** + * A simple helper method to return a CacheExpiration instance for a given + * cache name. + * + * @param {string} cacheName + * @return {CacheExpiration} + * + * @private + */ + _getCacheExpiration(cacheName) { + if (cacheName === cacheNames.getRuntimeName()) { + throw new WorkboxError('expire-custom-caches-only'); + } + let cacheExpiration = this._cacheExpirations.get(cacheName); + if (!cacheExpiration) { + cacheExpiration = new CacheExpiration(cacheName, this._config); + this._cacheExpirations.set(cacheName, cacheExpiration); + } + return cacheExpiration; + } + /** + * @param {Response} cachedResponse + * @return {boolean} + * + * @private + */ + _isResponseDateFresh(cachedResponse) { + if (!this._maxAgeSeconds) { + // We aren't expiring by age, so return true, it's fresh + return true; + } + // Check if the 'date' header will suffice a quick expiration check. + // See https://github.com/GoogleChromeLabs/sw-toolbox/issues/164 for + // discussion. + const dateHeaderTimestamp = this._getDateHeaderTimestamp(cachedResponse); + if (dateHeaderTimestamp === null) { + // Unable to parse date, so assume it's fresh. + return true; + } + // If we have a valid headerTime, then our response is fresh iff the + // headerTime plus maxAgeSeconds is greater than the current time. + const now = Date.now(); + return dateHeaderTimestamp >= now - this._maxAgeSeconds * 1000; + } + /** + * This method will extract the data header and parse it into a useful + * value. + * + * @param {Response} cachedResponse + * @return {number|null} + * + * @private + */ + _getDateHeaderTimestamp(cachedResponse) { + if (!cachedResponse.headers.has('date')) { + return null; + } + const dateHeader = cachedResponse.headers.get('date'); + const parsedDate = new Date(dateHeader); + const headerTime = parsedDate.getTime(); + // If the Date header was invalid for some reason, parsedDate.getTime() + // will return NaN. + if (isNaN(headerTime)) { + return null; + } + return headerTime; + } + /** + * This is a helper method that performs two operations: + * + * - Deletes *all* the underlying Cache instances associated with this plugin + * instance, by calling caches.delete() on your behalf. + * - Deletes the metadata from IndexedDB used to keep track of expiration + * details for each Cache instance. + * + * When using cache expiration, calling this method is preferable to calling + * `caches.delete()` directly, since this will ensure that the IndexedDB + * metadata is also cleanly removed and open IndexedDB instances are deleted. + * + * Note that if you're *not* using cache expiration for a given cache, calling + * `caches.delete()` and passing in the cache's name should be sufficient. + * There is no Workbox-specific method needed for cleanup in that case. + */ + async deleteCacheAndMetadata() { + // Do this one at a time instead of all at once via `Promise.all()` to + // reduce the chance of inconsistency if a promise rejects. + for (const [cacheName, cacheExpiration] of this._cacheExpirations) { + await self.caches.delete(cacheName); + await cacheExpiration.delete(); + } + // Reset this._cacheExpirations to its initial state. + this._cacheExpirations = new Map(); + } + } + + /* + Copyright 2018 Google LLC + + Use of this source code is governed by an MIT-style + license that can be found in the LICENSE file or at + https://opensource.org/licenses/MIT. + */ + const cacheOkAndOpaquePlugin = { + /** + * Returns a valid response (to allow caching) if the status is 200 (OK) or + * 0 (opaque). + * + * @param {Object} options + * @param {Response} options.response + * @return {Response|null} + * + * @private + */ + cacheWillUpdate: async ({ + response + }) => { + if (response.status === 200 || response.status === 0) { + return response; + } + return null; + } + }; + + /* + Copyright 2018 Google LLC + + Use of this source code is governed by an MIT-style + license that can be found in the LICENSE file or at + https://opensource.org/licenses/MIT. + */ + /** + * An implementation of a + * [stale-while-revalidate](https://developer.chrome.com/docs/workbox/caching-strategies-overview/#stale-while-revalidate) + * request strategy. + * + * Resources are requested from both the cache and the network in parallel. + * The strategy will respond with the cached version if available, otherwise + * wait for the network response. The cache is updated with the network response + * with each successful request. + * + * By default, this strategy will cache responses with a 200 status code as + * well as [opaque responses](https://developer.chrome.com/docs/workbox/caching-resources-during-runtime/#opaque-responses). + * Opaque responses are cross-origin requests where the response doesn't + * support [CORS](https://enable-cors.org/). + * + * If the network request fails, and there is no cache match, this will throw + * a `WorkboxError` exception. + * + * @extends workbox-strategies.Strategy + * @memberof workbox-strategies + */ + class StaleWhileRevalidate extends Strategy { + /** + * @param {Object} [options] + * @param {string} [options.cacheName] Cache name to store and retrieve + * requests. Defaults to cache names provided by + * {@link workbox-core.cacheNames}. + * @param {Array<Object>} [options.plugins] [Plugins]{@link https://developers.google.com/web/tools/workbox/guides/using-plugins} + * to use in conjunction with this caching strategy. + * @param {Object} [options.fetchOptions] Values passed along to the + * [`init`](https://developer.mozilla.org/en-US/docs/Web/API/WindowOrWorkerGlobalScope/fetch#Parameters) + * of [non-navigation](https://github.com/GoogleChrome/workbox/issues/1796) + * `fetch()` requests made by this strategy. + * @param {Object} [options.matchOptions] [`CacheQueryOptions`](https://w3c.github.io/ServiceWorker/#dictdef-cachequeryoptions) + */ + constructor(options = {}) { + super(options); + // If this instance contains no plugins with a 'cacheWillUpdate' callback, + // prepend the `cacheOkAndOpaquePlugin` plugin to the plugins list. + if (!this.plugins.some(p => 'cacheWillUpdate' in p)) { + this.plugins.unshift(cacheOkAndOpaquePlugin); + } + } + /** + * @private + * @param {Request|string} request A request to run this strategy for. + * @param {workbox-strategies.StrategyHandler} handler The event that + * triggered the request. + * @return {Promise<Response>} + */ + async _handle(request, handler) { + const logs = []; + { + finalAssertExports.isInstance(request, Request, { + moduleName: 'workbox-strategies', + className: this.constructor.name, + funcName: 'handle', + paramName: 'request' + }); + } + const fetchAndCachePromise = handler.fetchAndCachePut(request).catch(() => { + // Swallow this error because a 'no-response' error will be thrown in + // main handler return flow. This will be in the `waitUntil()` flow. + }); + void handler.waitUntil(fetchAndCachePromise); + let response = await handler.cacheMatch(request); + let error; + if (response) { + { + logs.push(`Found a cached response in the '${this.cacheName}'` + ` cache. Will update with the network response in the background.`); + } + } else { + { + logs.push(`No response found in the '${this.cacheName}' cache. ` + `Will wait for the network response.`); + } + try { + // NOTE(philipwalton): Really annoying that we have to type cast here. + // https://github.com/microsoft/TypeScript/issues/20006 + response = await fetchAndCachePromise; + } catch (err) { + if (err instanceof Error) { + error = err; + } + } + } + { + logger.groupCollapsed(messages.strategyStart(this.constructor.name, request)); + for (const log of logs) { + logger.log(log); + } + messages.printFinalResponse(response); + logger.groupEnd(); + } + if (!response) { + throw new WorkboxError('no-response', { + url: request.url, + error + }); + } + return response; + } + } + + /* + Copyright 2018 Google LLC + + Use of this source code is governed by an MIT-style + license that can be found in the LICENSE file or at + https://opensource.org/licenses/MIT. + */ + /** + * An implementation of a [cache-first](https://developer.chrome.com/docs/workbox/caching-strategies-overview/#cache-first-falling-back-to-network) + * request strategy. + * + * A cache first strategy is useful for assets that have been revisioned, + * such as URLs like `/styles/example.a8f5f1.css`, since they + * can be cached for long periods of time. + * + * If the network request fails, and there is no cache match, this will throw + * a `WorkboxError` exception. + * + * @extends workbox-strategies.Strategy + * @memberof workbox-strategies + */ + class CacheFirst extends Strategy { + /** + * @private + * @param {Request|string} request A request to run this strategy for. + * @param {workbox-strategies.StrategyHandler} handler The event that + * triggered the request. + * @return {Promise<Response>} + */ + async _handle(request, handler) { + const logs = []; + { + finalAssertExports.isInstance(request, Request, { + moduleName: 'workbox-strategies', + className: this.constructor.name, + funcName: 'makeRequest', + paramName: 'request' + }); + } + let response = await handler.cacheMatch(request); + let error = undefined; + if (!response) { + { + logs.push(`No response found in the '${this.cacheName}' cache. ` + `Will respond with a network request.`); + } + try { + response = await handler.fetchAndCachePut(request); + } catch (err) { + if (err instanceof Error) { + error = err; + } + } + { + if (response) { + logs.push(`Got response from network.`); + } else { + logs.push(`Unable to get a response from the network.`); + } + } + } else { + { + logs.push(`Found a cached response in the '${this.cacheName}' cache.`); + } + } + { + logger.groupCollapsed(messages.strategyStart(this.constructor.name, request)); + for (const log of logs) { + logger.log(log); + } + messages.printFinalResponse(response); + logger.groupEnd(); + } + if (!response) { + throw new WorkboxError('no-response', { + url: request.url, + error + }); + } + return response; + } + } + + /* + Copyright 2019 Google LLC + + Use of this source code is governed by an MIT-style + license that can be found in the LICENSE file or at + https://opensource.org/licenses/MIT. + */ + /** + * Claim any currently available clients once the service worker + * becomes active. This is normally used in conjunction with `skipWaiting()`. + * + * @memberof workbox-core + */ + function clientsClaim() { + self.addEventListener('activate', () => self.clients.claim()); + } + + /* + Copyright 2020 Google LLC + Use of this source code is governed by an MIT-style + license that can be found in the LICENSE file or at + https://opensource.org/licenses/MIT. + */ + /** + * A utility method that makes it easier to use `event.waitUntil` with + * async functions and return the result. + * + * @param {ExtendableEvent} event + * @param {Function} asyncFn + * @return {Function} + * @private + */ + function waitUntil(event, asyncFn) { + const returnPromise = asyncFn(); + event.waitUntil(returnPromise); + return returnPromise; + } + + // @ts-ignore + try { + self['workbox:precaching:7.3.0'] && _(); + } catch (e) {} + + /* + Copyright 2018 Google LLC + + Use of this source code is governed by an MIT-style + license that can be found in the LICENSE file or at + https://opensource.org/licenses/MIT. + */ + // Name of the search parameter used to store revision info. + const REVISION_SEARCH_PARAM = '__WB_REVISION__'; + /** + * Converts a manifest entry into a versioned URL suitable for precaching. + * + * @param {Object|string} entry + * @return {string} A URL with versioning info. + * + * @private + * @memberof workbox-precaching + */ + function createCacheKey(entry) { + if (!entry) { + throw new WorkboxError('add-to-cache-list-unexpected-type', { + entry + }); + } + // If a precache manifest entry is a string, it's assumed to be a versioned + // URL, like '/app.abcd1234.js'. Return as-is. + if (typeof entry === 'string') { + const urlObject = new URL(entry, location.href); + return { + cacheKey: urlObject.href, + url: urlObject.href + }; + } + const { + revision, + url + } = entry; + if (!url) { + throw new WorkboxError('add-to-cache-list-unexpected-type', { + entry + }); + } + // If there's just a URL and no revision, then it's also assumed to be a + // versioned URL. + if (!revision) { + const urlObject = new URL(url, location.href); + return { + cacheKey: urlObject.href, + url: urlObject.href + }; + } + // Otherwise, construct a properly versioned URL using the custom Workbox + // search parameter along with the revision info. + const cacheKeyURL = new URL(url, location.href); + const originalURL = new URL(url, location.href); + cacheKeyURL.searchParams.set(REVISION_SEARCH_PARAM, revision); + return { + cacheKey: cacheKeyURL.href, + url: originalURL.href + }; + } + + /* + Copyright 2020 Google LLC + + Use of this source code is governed by an MIT-style + license that can be found in the LICENSE file or at + https://opensource.org/licenses/MIT. + */ + /** + * A plugin, designed to be used with PrecacheController, to determine the + * of assets that were updated (or not updated) during the install event. + * + * @private + */ + class PrecacheInstallReportPlugin { + constructor() { + this.updatedURLs = []; + this.notUpdatedURLs = []; + this.handlerWillStart = async ({ + request, + state + }) => { + // TODO: `state` should never be undefined... + if (state) { + state.originalRequest = request; + } + }; + this.cachedResponseWillBeUsed = async ({ + event, + state, + cachedResponse + }) => { + if (event.type === 'install') { + if (state && state.originalRequest && state.originalRequest instanceof Request) { + // TODO: `state` should never be undefined... + const url = state.originalRequest.url; + if (cachedResponse) { + this.notUpdatedURLs.push(url); + } else { + this.updatedURLs.push(url); + } + } + } + return cachedResponse; + }; + } + } + + /* + Copyright 2020 Google LLC + + Use of this source code is governed by an MIT-style + license that can be found in the LICENSE file or at + https://opensource.org/licenses/MIT. + */ + /** + * A plugin, designed to be used with PrecacheController, to translate URLs into + * the corresponding cache key, based on the current revision info. + * + * @private + */ + class PrecacheCacheKeyPlugin { + constructor({ + precacheController + }) { + this.cacheKeyWillBeUsed = async ({ + request, + params + }) => { + // Params is type any, can't change right now. + /* eslint-disable */ + const cacheKey = (params === null || params === void 0 ? void 0 : params.cacheKey) || this._precacheController.getCacheKeyForURL(request.url); + /* eslint-enable */ + return cacheKey ? new Request(cacheKey, { + headers: request.headers + }) : request; + }; + this._precacheController = precacheController; + } + } + + /* + Copyright 2018 Google LLC + + Use of this source code is governed by an MIT-style + license that can be found in the LICENSE file or at + https://opensource.org/licenses/MIT. + */ + /** + * @param {string} groupTitle + * @param {Array<string>} deletedURLs + * + * @private + */ + const logGroup = (groupTitle, deletedURLs) => { + logger.groupCollapsed(groupTitle); + for (const url of deletedURLs) { + logger.log(url); + } + logger.groupEnd(); + }; + /** + * @param {Array<string>} deletedURLs + * + * @private + * @memberof workbox-precaching + */ + function printCleanupDetails(deletedURLs) { + const deletionCount = deletedURLs.length; + if (deletionCount > 0) { + logger.groupCollapsed(`During precaching cleanup, ` + `${deletionCount} cached ` + `request${deletionCount === 1 ? ' was' : 's were'} deleted.`); + logGroup('Deleted Cache Requests', deletedURLs); + logger.groupEnd(); + } + } + + /* + Copyright 2018 Google LLC + + Use of this source code is governed by an MIT-style + license that can be found in the LICENSE file or at + https://opensource.org/licenses/MIT. + */ + /** + * @param {string} groupTitle + * @param {Array<string>} urls + * + * @private + */ + function _nestedGroup(groupTitle, urls) { + if (urls.length === 0) { + return; + } + logger.groupCollapsed(groupTitle); + for (const url of urls) { + logger.log(url); + } + logger.groupEnd(); + } + /** + * @param {Array<string>} urlsToPrecache + * @param {Array<string>} urlsAlreadyPrecached + * + * @private + * @memberof workbox-precaching + */ + function printInstallDetails(urlsToPrecache, urlsAlreadyPrecached) { + const precachedCount = urlsToPrecache.length; + const alreadyPrecachedCount = urlsAlreadyPrecached.length; + if (precachedCount || alreadyPrecachedCount) { + let message = `Precaching ${precachedCount} file${precachedCount === 1 ? '' : 's'}.`; + if (alreadyPrecachedCount > 0) { + message += ` ${alreadyPrecachedCount} ` + `file${alreadyPrecachedCount === 1 ? ' is' : 's are'} already cached.`; + } + logger.groupCollapsed(message); + _nestedGroup(`View newly precached URLs.`, urlsToPrecache); + _nestedGroup(`View previously precached URLs.`, urlsAlreadyPrecached); + logger.groupEnd(); + } + } + + /* + Copyright 2019 Google LLC + + Use of this source code is governed by an MIT-style + license that can be found in the LICENSE file or at + https://opensource.org/licenses/MIT. + */ + let supportStatus; + /** + * A utility function that determines whether the current browser supports + * constructing a new `Response` from a `response.body` stream. + * + * @return {boolean} `true`, if the current browser can successfully + * construct a `Response` from a `response.body` stream, `false` otherwise. + * + * @private + */ + function canConstructResponseFromBodyStream() { + if (supportStatus === undefined) { + const testResponse = new Response(''); + if ('body' in testResponse) { + try { + new Response(testResponse.body); + supportStatus = true; + } catch (error) { + supportStatus = false; + } + } + supportStatus = false; + } + return supportStatus; + } + + /* + Copyright 2019 Google LLC + + Use of this source code is governed by an MIT-style + license that can be found in the LICENSE file or at + https://opensource.org/licenses/MIT. + */ + /** + * Allows developers to copy a response and modify its `headers`, `status`, + * or `statusText` values (the values settable via a + * [`ResponseInit`]{@link https://developer.mozilla.org/en-US/docs/Web/API/Response/Response#Syntax} + * object in the constructor). + * To modify these values, pass a function as the second argument. That + * function will be invoked with a single object with the response properties + * `{headers, status, statusText}`. The return value of this function will + * be used as the `ResponseInit` for the new `Response`. To change the values + * either modify the passed parameter(s) and return it, or return a totally + * new object. + * + * This method is intentionally limited to same-origin responses, regardless of + * whether CORS was used or not. + * + * @param {Response} response + * @param {Function} modifier + * @memberof workbox-core + */ + async function copyResponse(response, modifier) { + let origin = null; + // If response.url isn't set, assume it's cross-origin and keep origin null. + if (response.url) { + const responseURL = new URL(response.url); + origin = responseURL.origin; + } + if (origin !== self.location.origin) { + throw new WorkboxError('cross-origin-copy-response', { + origin + }); + } + const clonedResponse = response.clone(); + // Create a fresh `ResponseInit` object by cloning the headers. + const responseInit = { + headers: new Headers(clonedResponse.headers), + status: clonedResponse.status, + statusText: clonedResponse.statusText + }; + // Apply any user modifications. + const modifiedResponseInit = modifier ? modifier(responseInit) : responseInit; + // Create the new response from the body stream and `ResponseInit` + // modifications. Note: not all browsers support the Response.body stream, + // so fall back to reading the entire body into memory as a blob. + const body = canConstructResponseFromBodyStream() ? clonedResponse.body : await clonedResponse.blob(); + return new Response(body, modifiedResponseInit); + } + + /* + Copyright 2020 Google LLC + + Use of this source code is governed by an MIT-style + license that can be found in the LICENSE file or at + https://opensource.org/licenses/MIT. + */ + /** + * A {@link workbox-strategies.Strategy} implementation + * specifically designed to work with + * {@link workbox-precaching.PrecacheController} + * to both cache and fetch precached assets. + * + * Note: an instance of this class is created automatically when creating a + * `PrecacheController`; it's generally not necessary to create this yourself. + * + * @extends workbox-strategies.Strategy + * @memberof workbox-precaching + */ + class PrecacheStrategy extends Strategy { + /** + * + * @param {Object} [options] + * @param {string} [options.cacheName] Cache name to store and retrieve + * requests. Defaults to the cache names provided by + * {@link workbox-core.cacheNames}. + * @param {Array<Object>} [options.plugins] {@link https://developers.google.com/web/tools/workbox/guides/using-plugins|Plugins} + * to use in conjunction with this caching strategy. + * @param {Object} [options.fetchOptions] Values passed along to the + * {@link https://developer.mozilla.org/en-US/docs/Web/API/WindowOrWorkerGlobalScope/fetch#Parameters|init} + * of all fetch() requests made by this strategy. + * @param {Object} [options.matchOptions] The + * {@link https://w3c.github.io/ServiceWorker/#dictdef-cachequeryoptions|CacheQueryOptions} + * for any `cache.match()` or `cache.put()` calls made by this strategy. + * @param {boolean} [options.fallbackToNetwork=true] Whether to attempt to + * get the response from the network if there's a precache miss. + */ + constructor(options = {}) { + options.cacheName = cacheNames.getPrecacheName(options.cacheName); + super(options); + this._fallbackToNetwork = options.fallbackToNetwork === false ? false : true; + // Redirected responses cannot be used to satisfy a navigation request, so + // any redirected response must be "copied" rather than cloned, so the new + // response doesn't contain the `redirected` flag. See: + // https://bugs.chromium.org/p/chromium/issues/detail?id=669363&desc=2#c1 + this.plugins.push(PrecacheStrategy.copyRedirectedCacheableResponsesPlugin); + } + /** + * @private + * @param {Request|string} request A request to run this strategy for. + * @param {workbox-strategies.StrategyHandler} handler The event that + * triggered the request. + * @return {Promise<Response>} + */ + async _handle(request, handler) { + const response = await handler.cacheMatch(request); + if (response) { + return response; + } + // If this is an `install` event for an entry that isn't already cached, + // then populate the cache. + if (handler.event && handler.event.type === 'install') { + return await this._handleInstall(request, handler); + } + // Getting here means something went wrong. An entry that should have been + // precached wasn't found in the cache. + return await this._handleFetch(request, handler); + } + async _handleFetch(request, handler) { + let response; + const params = handler.params || {}; + // Fall back to the network if we're configured to do so. + if (this._fallbackToNetwork) { + { + logger.warn(`The precached response for ` + `${getFriendlyURL(request.url)} in ${this.cacheName} was not ` + `found. Falling back to the network.`); + } + const integrityInManifest = params.integrity; + const integrityInRequest = request.integrity; + const noIntegrityConflict = !integrityInRequest || integrityInRequest === integrityInManifest; + // Do not add integrity if the original request is no-cors + // See https://github.com/GoogleChrome/workbox/issues/3096 + response = await handler.fetch(new Request(request, { + integrity: request.mode !== 'no-cors' ? integrityInRequest || integrityInManifest : undefined + })); + // It's only "safe" to repair the cache if we're using SRI to guarantee + // that the response matches the precache manifest's expectations, + // and there's either a) no integrity property in the incoming request + // or b) there is an integrity, and it matches the precache manifest. + // See https://github.com/GoogleChrome/workbox/issues/2858 + // Also if the original request users no-cors we don't use integrity. + // See https://github.com/GoogleChrome/workbox/issues/3096 + if (integrityInManifest && noIntegrityConflict && request.mode !== 'no-cors') { + this._useDefaultCacheabilityPluginIfNeeded(); + const wasCached = await handler.cachePut(request, response.clone()); + { + if (wasCached) { + logger.log(`A response for ${getFriendlyURL(request.url)} ` + `was used to "repair" the precache.`); + } + } + } + } else { + // This shouldn't normally happen, but there are edge cases: + // https://github.com/GoogleChrome/workbox/issues/1441 + throw new WorkboxError('missing-precache-entry', { + cacheName: this.cacheName, + url: request.url + }); + } + { + const cacheKey = params.cacheKey || (await handler.getCacheKey(request, 'read')); + // Workbox is going to handle the route. + // print the routing details to the console. + logger.groupCollapsed(`Precaching is responding to: ` + getFriendlyURL(request.url)); + logger.log(`Serving the precached url: ${getFriendlyURL(cacheKey instanceof Request ? cacheKey.url : cacheKey)}`); + logger.groupCollapsed(`View request details here.`); + logger.log(request); + logger.groupEnd(); + logger.groupCollapsed(`View response details here.`); + logger.log(response); + logger.groupEnd(); + logger.groupEnd(); + } + return response; + } + async _handleInstall(request, handler) { + this._useDefaultCacheabilityPluginIfNeeded(); + const response = await handler.fetch(request); + // Make sure we defer cachePut() until after we know the response + // should be cached; see https://github.com/GoogleChrome/workbox/issues/2737 + const wasCached = await handler.cachePut(request, response.clone()); + if (!wasCached) { + // Throwing here will lead to the `install` handler failing, which + // we want to do if *any* of the responses aren't safe to cache. + throw new WorkboxError('bad-precaching-response', { + url: request.url, + status: response.status + }); + } + return response; + } + /** + * This method is complex, as there a number of things to account for: + * + * The `plugins` array can be set at construction, and/or it might be added to + * to at any time before the strategy is used. + * + * At the time the strategy is used (i.e. during an `install` event), there + * needs to be at least one plugin that implements `cacheWillUpdate` in the + * array, other than `copyRedirectedCacheableResponsesPlugin`. + * + * - If this method is called and there are no suitable `cacheWillUpdate` + * plugins, we need to add `defaultPrecacheCacheabilityPlugin`. + * + * - If this method is called and there is exactly one `cacheWillUpdate`, then + * we don't have to do anything (this might be a previously added + * `defaultPrecacheCacheabilityPlugin`, or it might be a custom plugin). + * + * - If this method is called and there is more than one `cacheWillUpdate`, + * then we need to check if one is `defaultPrecacheCacheabilityPlugin`. If so, + * we need to remove it. (This situation is unlikely, but it could happen if + * the strategy is used multiple times, the first without a `cacheWillUpdate`, + * and then later on after manually adding a custom `cacheWillUpdate`.) + * + * See https://github.com/GoogleChrome/workbox/issues/2737 for more context. + * + * @private + */ + _useDefaultCacheabilityPluginIfNeeded() { + let defaultPluginIndex = null; + let cacheWillUpdatePluginCount = 0; + for (const [index, plugin] of this.plugins.entries()) { + // Ignore the copy redirected plugin when determining what to do. + if (plugin === PrecacheStrategy.copyRedirectedCacheableResponsesPlugin) { + continue; + } + // Save the default plugin's index, in case it needs to be removed. + if (plugin === PrecacheStrategy.defaultPrecacheCacheabilityPlugin) { + defaultPluginIndex = index; + } + if (plugin.cacheWillUpdate) { + cacheWillUpdatePluginCount++; + } + } + if (cacheWillUpdatePluginCount === 0) { + this.plugins.push(PrecacheStrategy.defaultPrecacheCacheabilityPlugin); + } else if (cacheWillUpdatePluginCount > 1 && defaultPluginIndex !== null) { + // Only remove the default plugin; multiple custom plugins are allowed. + this.plugins.splice(defaultPluginIndex, 1); + } + // Nothing needs to be done if cacheWillUpdatePluginCount is 1 + } + } + PrecacheStrategy.defaultPrecacheCacheabilityPlugin = { + async cacheWillUpdate({ + response + }) { + if (!response || response.status >= 400) { + return null; + } + return response; + } + }; + PrecacheStrategy.copyRedirectedCacheableResponsesPlugin = { + async cacheWillUpdate({ + response + }) { + return response.redirected ? await copyResponse(response) : response; + } + }; + + /* + Copyright 2019 Google LLC + + Use of this source code is governed by an MIT-style + license that can be found in the LICENSE file or at + https://opensource.org/licenses/MIT. + */ + /** + * Performs efficient precaching of assets. + * + * @memberof workbox-precaching + */ + class PrecacheController { + /** + * Create a new PrecacheController. + * + * @param {Object} [options] + * @param {string} [options.cacheName] The cache to use for precaching. + * @param {string} [options.plugins] Plugins to use when precaching as well + * as responding to fetch events for precached assets. + * @param {boolean} [options.fallbackToNetwork=true] Whether to attempt to + * get the response from the network if there's a precache miss. + */ + constructor({ + cacheName, + plugins = [], + fallbackToNetwork = true + } = {}) { + this._urlsToCacheKeys = new Map(); + this._urlsToCacheModes = new Map(); + this._cacheKeysToIntegrities = new Map(); + this._strategy = new PrecacheStrategy({ + cacheName: cacheNames.getPrecacheName(cacheName), + plugins: [...plugins, new PrecacheCacheKeyPlugin({ + precacheController: this + })], + fallbackToNetwork + }); + // Bind the install and activate methods to the instance. + this.install = this.install.bind(this); + this.activate = this.activate.bind(this); + } + /** + * @type {workbox-precaching.PrecacheStrategy} The strategy created by this controller and + * used to cache assets and respond to fetch events. + */ + get strategy() { + return this._strategy; + } + /** + * Adds items to the precache list, removing any duplicates and + * stores the files in the + * {@link workbox-core.cacheNames|"precache cache"} when the service + * worker installs. + * + * This method can be called multiple times. + * + * @param {Array<Object|string>} [entries=[]] Array of entries to precache. + */ + precache(entries) { + this.addToCacheList(entries); + if (!this._installAndActiveListenersAdded) { + self.addEventListener('install', this.install); + self.addEventListener('activate', this.activate); + this._installAndActiveListenersAdded = true; + } + } + /** + * This method will add items to the precache list, removing duplicates + * and ensuring the information is valid. + * + * @param {Array<workbox-precaching.PrecacheController.PrecacheEntry|string>} entries + * Array of entries to precache. + */ + addToCacheList(entries) { + { + finalAssertExports.isArray(entries, { + moduleName: 'workbox-precaching', + className: 'PrecacheController', + funcName: 'addToCacheList', + paramName: 'entries' + }); + } + const urlsToWarnAbout = []; + for (const entry of entries) { + // See https://github.com/GoogleChrome/workbox/issues/2259 + if (typeof entry === 'string') { + urlsToWarnAbout.push(entry); + } else if (entry && entry.revision === undefined) { + urlsToWarnAbout.push(entry.url); + } + const { + cacheKey, + url + } = createCacheKey(entry); + const cacheMode = typeof entry !== 'string' && entry.revision ? 'reload' : 'default'; + if (this._urlsToCacheKeys.has(url) && this._urlsToCacheKeys.get(url) !== cacheKey) { + throw new WorkboxError('add-to-cache-list-conflicting-entries', { + firstEntry: this._urlsToCacheKeys.get(url), + secondEntry: cacheKey + }); + } + if (typeof entry !== 'string' && entry.integrity) { + if (this._cacheKeysToIntegrities.has(cacheKey) && this._cacheKeysToIntegrities.get(cacheKey) !== entry.integrity) { + throw new WorkboxError('add-to-cache-list-conflicting-integrities', { + url + }); + } + this._cacheKeysToIntegrities.set(cacheKey, entry.integrity); + } + this._urlsToCacheKeys.set(url, cacheKey); + this._urlsToCacheModes.set(url, cacheMode); + if (urlsToWarnAbout.length > 0) { + const warningMessage = `Workbox is precaching URLs without revision ` + `info: ${urlsToWarnAbout.join(', ')}\nThis is generally NOT safe. ` + `Learn more at https://bit.ly/wb-precache`; + { + logger.warn(warningMessage); + } + } + } + } + /** + * Precaches new and updated assets. Call this method from the service worker + * install event. + * + * Note: this method calls `event.waitUntil()` for you, so you do not need + * to call it yourself in your event handlers. + * + * @param {ExtendableEvent} event + * @return {Promise<workbox-precaching.InstallResult>} + */ + install(event) { + // waitUntil returns Promise<any> + // eslint-disable-next-line @typescript-eslint/no-unsafe-return + return waitUntil(event, async () => { + const installReportPlugin = new PrecacheInstallReportPlugin(); + this.strategy.plugins.push(installReportPlugin); + // Cache entries one at a time. + // See https://github.com/GoogleChrome/workbox/issues/2528 + for (const [url, cacheKey] of this._urlsToCacheKeys) { + const integrity = this._cacheKeysToIntegrities.get(cacheKey); + const cacheMode = this._urlsToCacheModes.get(url); + const request = new Request(url, { + integrity, + cache: cacheMode, + credentials: 'same-origin' + }); + await Promise.all(this.strategy.handleAll({ + params: { + cacheKey + }, + request, + event + })); + } + const { + updatedURLs, + notUpdatedURLs + } = installReportPlugin; + { + printInstallDetails(updatedURLs, notUpdatedURLs); + } + return { + updatedURLs, + notUpdatedURLs + }; + }); + } + /** + * Deletes assets that are no longer present in the current precache manifest. + * Call this method from the service worker activate event. + * + * Note: this method calls `event.waitUntil()` for you, so you do not need + * to call it yourself in your event handlers. + * + * @param {ExtendableEvent} event + * @return {Promise<workbox-precaching.CleanupResult>} + */ + activate(event) { + // waitUntil returns Promise<any> + // eslint-disable-next-line @typescript-eslint/no-unsafe-return + return waitUntil(event, async () => { + const cache = await self.caches.open(this.strategy.cacheName); + const currentlyCachedRequests = await cache.keys(); + const expectedCacheKeys = new Set(this._urlsToCacheKeys.values()); + const deletedURLs = []; + for (const request of currentlyCachedRequests) { + if (!expectedCacheKeys.has(request.url)) { + await cache.delete(request); + deletedURLs.push(request.url); + } + } + { + printCleanupDetails(deletedURLs); + } + return { + deletedURLs + }; + }); + } + /** + * Returns a mapping of a precached URL to the corresponding cache key, taking + * into account the revision information for the URL. + * + * @return {Map<string, string>} A URL to cache key mapping. + */ + getURLsToCacheKeys() { + return this._urlsToCacheKeys; + } + /** + * Returns a list of all the URLs that have been precached by the current + * service worker. + * + * @return {Array<string>} The precached URLs. + */ + getCachedURLs() { + return [...this._urlsToCacheKeys.keys()]; + } + /** + * Returns the cache key used for storing a given URL. If that URL is + * unversioned, like `/index.html', then the cache key will be the original + * URL with a search parameter appended to it. + * + * @param {string} url A URL whose cache key you want to look up. + * @return {string} The versioned URL that corresponds to a cache key + * for the original URL, or undefined if that URL isn't precached. + */ + getCacheKeyForURL(url) { + const urlObject = new URL(url, location.href); + return this._urlsToCacheKeys.get(urlObject.href); + } + /** + * @param {string} url A cache key whose SRI you want to look up. + * @return {string} The subresource integrity associated with the cache key, + * or undefined if it's not set. + */ + getIntegrityForCacheKey(cacheKey) { + return this._cacheKeysToIntegrities.get(cacheKey); + } + /** + * This acts as a drop-in replacement for + * [`cache.match()`](https://developer.mozilla.org/en-US/docs/Web/API/Cache/match) + * with the following differences: + * + * - It knows what the name of the precache is, and only checks in that cache. + * - It allows you to pass in an "original" URL without versioning parameters, + * and it will automatically look up the correct cache key for the currently + * active revision of that URL. + * + * E.g., `matchPrecache('index.html')` will find the correct precached + * response for the currently active service worker, even if the actual cache + * key is `'/index.html?__WB_REVISION__=1234abcd'`. + * + * @param {string|Request} request The key (without revisioning parameters) + * to look up in the precache. + * @return {Promise<Response|undefined>} + */ + async matchPrecache(request) { + const url = request instanceof Request ? request.url : request; + const cacheKey = this.getCacheKeyForURL(url); + if (cacheKey) { + const cache = await self.caches.open(this.strategy.cacheName); + return cache.match(cacheKey); + } + return undefined; + } + /** + * Returns a function that looks up `url` in the precache (taking into + * account revision information), and returns the corresponding `Response`. + * + * @param {string} url The precached URL which will be used to lookup the + * `Response`. + * @return {workbox-routing~handlerCallback} + */ + createHandlerBoundToURL(url) { + const cacheKey = this.getCacheKeyForURL(url); + if (!cacheKey) { + throw new WorkboxError('non-precached-url', { + url + }); + } + return options => { + options.request = new Request(url); + options.params = Object.assign({ + cacheKey + }, options.params); + return this.strategy.handle(options); + }; + } + } + + /* + Copyright 2019 Google LLC + + Use of this source code is governed by an MIT-style + license that can be found in the LICENSE file or at + https://opensource.org/licenses/MIT. + */ + let precacheController; + /** + * @return {PrecacheController} + * @private + */ + const getOrCreatePrecacheController = () => { + if (!precacheController) { + precacheController = new PrecacheController(); + } + return precacheController; + }; + + /* + Copyright 2018 Google LLC + + Use of this source code is governed by an MIT-style + license that can be found in the LICENSE file or at + https://opensource.org/licenses/MIT. + */ + /** + * Removes any URL search parameters that should be ignored. + * + * @param {URL} urlObject The original URL. + * @param {Array<RegExp>} ignoreURLParametersMatching RegExps to test against + * each search parameter name. Matches mean that the search parameter should be + * ignored. + * @return {URL} The URL with any ignored search parameters removed. + * + * @private + * @memberof workbox-precaching + */ + function removeIgnoredSearchParams(urlObject, ignoreURLParametersMatching = []) { + // Convert the iterable into an array at the start of the loop to make sure + // deletion doesn't mess up iteration. + for (const paramName of [...urlObject.searchParams.keys()]) { + if (ignoreURLParametersMatching.some(regExp => regExp.test(paramName))) { + urlObject.searchParams.delete(paramName); + } + } + return urlObject; + } + + /* + Copyright 2019 Google LLC + + Use of this source code is governed by an MIT-style + license that can be found in the LICENSE file or at + https://opensource.org/licenses/MIT. + */ + /** + * Generator function that yields possible variations on the original URL to + * check, one at a time. + * + * @param {string} url + * @param {Object} options + * + * @private + * @memberof workbox-precaching + */ + function* generateURLVariations(url, { + ignoreURLParametersMatching = [/^utm_/, /^fbclid$/], + directoryIndex = 'index.html', + cleanURLs = true, + urlManipulation + } = {}) { + const urlObject = new URL(url, location.href); + urlObject.hash = ''; + yield urlObject.href; + const urlWithoutIgnoredParams = removeIgnoredSearchParams(urlObject, ignoreURLParametersMatching); + yield urlWithoutIgnoredParams.href; + if (directoryIndex && urlWithoutIgnoredParams.pathname.endsWith('/')) { + const directoryURL = new URL(urlWithoutIgnoredParams.href); + directoryURL.pathname += directoryIndex; + yield directoryURL.href; + } + if (cleanURLs) { + const cleanURL = new URL(urlWithoutIgnoredParams.href); + cleanURL.pathname += '.html'; + yield cleanURL.href; + } + if (urlManipulation) { + const additionalURLs = urlManipulation({ + url: urlObject + }); + for (const urlToAttempt of additionalURLs) { + yield urlToAttempt.href; + } + } + } + + /* + Copyright 2020 Google LLC + + Use of this source code is governed by an MIT-style + license that can be found in the LICENSE file or at + https://opensource.org/licenses/MIT. + */ + /** + * A subclass of {@link workbox-routing.Route} that takes a + * {@link workbox-precaching.PrecacheController} + * instance and uses it to match incoming requests and handle fetching + * responses from the precache. + * + * @memberof workbox-precaching + * @extends workbox-routing.Route + */ + class PrecacheRoute extends Route { + /** + * @param {PrecacheController} precacheController A `PrecacheController` + * instance used to both match requests and respond to fetch events. + * @param {Object} [options] Options to control how requests are matched + * against the list of precached URLs. + * @param {string} [options.directoryIndex=index.html] The `directoryIndex` will + * check cache entries for a URLs ending with '/' to see if there is a hit when + * appending the `directoryIndex` value. + * @param {Array<RegExp>} [options.ignoreURLParametersMatching=[/^utm_/, /^fbclid$/]] An + * array of regex's to remove search params when looking for a cache match. + * @param {boolean} [options.cleanURLs=true] The `cleanURLs` option will + * check the cache for the URL with a `.html` added to the end of the end. + * @param {workbox-precaching~urlManipulation} [options.urlManipulation] + * This is a function that should take a URL and return an array of + * alternative URLs that should be checked for precache matches. + */ + constructor(precacheController, options) { + const match = ({ + request + }) => { + const urlsToCacheKeys = precacheController.getURLsToCacheKeys(); + for (const possibleURL of generateURLVariations(request.url, options)) { + const cacheKey = urlsToCacheKeys.get(possibleURL); + if (cacheKey) { + const integrity = precacheController.getIntegrityForCacheKey(cacheKey); + return { + cacheKey, + integrity + }; + } + } + { + logger.debug(`Precaching did not find a match for ` + getFriendlyURL(request.url)); + } + return; + }; + super(match, precacheController.strategy); + } + } + + /* + Copyright 2019 Google LLC + Use of this source code is governed by an MIT-style + license that can be found in the LICENSE file or at + https://opensource.org/licenses/MIT. + */ + /** + * Add a `fetch` listener to the service worker that will + * respond to + * [network requests]{@link https://developer.mozilla.org/en-US/docs/Web/API/Service_Worker_API/Using_Service_Workers#Custom_responses_to_requests} + * with precached assets. + * + * Requests for assets that aren't precached, the `FetchEvent` will not be + * responded to, allowing the event to fall through to other `fetch` event + * listeners. + * + * @param {Object} [options] See the {@link workbox-precaching.PrecacheRoute} + * options. + * + * @memberof workbox-precaching + */ + function addRoute(options) { + const precacheController = getOrCreatePrecacheController(); + const precacheRoute = new PrecacheRoute(precacheController, options); + registerRoute(precacheRoute); + } + + /* + Copyright 2019 Google LLC + + Use of this source code is governed by an MIT-style + license that can be found in the LICENSE file or at + https://opensource.org/licenses/MIT. + */ + /** + * Adds items to the precache list, removing any duplicates and + * stores the files in the + * {@link workbox-core.cacheNames|"precache cache"} when the service + * worker installs. + * + * This method can be called multiple times. + * + * Please note: This method **will not** serve any of the cached files for you. + * It only precaches files. To respond to a network request you call + * {@link workbox-precaching.addRoute}. + * + * If you have a single array of files to precache, you can just call + * {@link workbox-precaching.precacheAndRoute}. + * + * @param {Array<Object|string>} [entries=[]] Array of entries to precache. + * + * @memberof workbox-precaching + */ + function precache(entries) { + const precacheController = getOrCreatePrecacheController(); + precacheController.precache(entries); + } + + /* + Copyright 2019 Google LLC + + Use of this source code is governed by an MIT-style + license that can be found in the LICENSE file or at + https://opensource.org/licenses/MIT. + */ + /** + * This method will add entries to the precache list and add a route to + * respond to fetch events. + * + * This is a convenience method that will call + * {@link workbox-precaching.precache} and + * {@link workbox-precaching.addRoute} in a single call. + * + * @param {Array<Object|string>} entries Array of entries to precache. + * @param {Object} [options] See the + * {@link workbox-precaching.PrecacheRoute} options. + * + * @memberof workbox-precaching + */ + function precacheAndRoute(entries, options) { + precache(entries); + addRoute(options); + } + + /* + Copyright 2018 Google LLC + + Use of this source code is governed by an MIT-style + license that can be found in the LICENSE file or at + https://opensource.org/licenses/MIT. + */ + const SUBSTRING_TO_FIND = '-precache-'; + /** + * Cleans up incompatible precaches that were created by older versions of + * Workbox, by a service worker registered under the current scope. + * + * This is meant to be called as part of the `activate` event. + * + * This should be safe to use as long as you don't include `substringToFind` + * (defaulting to `-precache-`) in your non-precache cache names. + * + * @param {string} currentPrecacheName The cache name currently in use for + * precaching. This cache won't be deleted. + * @param {string} [substringToFind='-precache-'] Cache names which include this + * substring will be deleted (excluding `currentPrecacheName`). + * @return {Array<string>} A list of all the cache names that were deleted. + * + * @private + * @memberof workbox-precaching + */ + const deleteOutdatedCaches = async (currentPrecacheName, substringToFind = SUBSTRING_TO_FIND) => { + const cacheNames = await self.caches.keys(); + const cacheNamesToDelete = cacheNames.filter(cacheName => { + return cacheName.includes(substringToFind) && cacheName.includes(self.registration.scope) && cacheName !== currentPrecacheName; + }); + await Promise.all(cacheNamesToDelete.map(cacheName => self.caches.delete(cacheName))); + return cacheNamesToDelete; + }; + + /* + Copyright 2019 Google LLC + + Use of this source code is governed by an MIT-style + license that can be found in the LICENSE file or at + https://opensource.org/licenses/MIT. + */ + /** + * Adds an `activate` event listener which will clean up incompatible + * precaches that were created by older versions of Workbox. + * + * @memberof workbox-precaching + */ + function cleanupOutdatedCaches() { + // See https://github.com/Microsoft/TypeScript/issues/28357#issuecomment-436484705 + self.addEventListener('activate', event => { + const cacheName = cacheNames.getPrecacheName(); + event.waitUntil(deleteOutdatedCaches(cacheName).then(cachesDeleted => { + { + if (cachesDeleted.length > 0) { + logger.log(`The following out-of-date precaches were cleaned up ` + `automatically:`, cachesDeleted); + } + } + })); + }); + } + + /* + Copyright 2018 Google LLC + + Use of this source code is governed by an MIT-style + license that can be found in the LICENSE file or at + https://opensource.org/licenses/MIT. + */ + /** + * NavigationRoute makes it easy to create a + * {@link workbox-routing.Route} that matches for browser + * [navigation requests]{@link https://developers.google.com/web/fundamentals/primers/service-workers/high-performance-loading#first_what_are_navigation_requests}. + * + * It will only match incoming Requests whose + * {@link https://fetch.spec.whatwg.org/#concept-request-mode|mode} + * is set to `navigate`. + * + * You can optionally only apply this route to a subset of navigation requests + * by using one or both of the `denylist` and `allowlist` parameters. + * + * @memberof workbox-routing + * @extends workbox-routing.Route + */ + class NavigationRoute extends Route { + /** + * If both `denylist` and `allowlist` are provided, the `denylist` will + * take precedence and the request will not match this route. + * + * The regular expressions in `allowlist` and `denylist` + * are matched against the concatenated + * [`pathname`]{@link https://developer.mozilla.org/en-US/docs/Web/API/HTMLHyperlinkElementUtils/pathname} + * and [`search`]{@link https://developer.mozilla.org/en-US/docs/Web/API/HTMLHyperlinkElementUtils/search} + * portions of the requested URL. + * + * *Note*: These RegExps may be evaluated against every destination URL during + * a navigation. Avoid using + * [complex RegExps](https://github.com/GoogleChrome/workbox/issues/3077), + * or else your users may see delays when navigating your site. + * + * @param {workbox-routing~handlerCallback} handler A callback + * function that returns a Promise resulting in a Response. + * @param {Object} options + * @param {Array<RegExp>} [options.denylist] If any of these patterns match, + * the route will not handle the request (even if a allowlist RegExp matches). + * @param {Array<RegExp>} [options.allowlist=[/./]] If any of these patterns + * match the URL's pathname and search parameter, the route will handle the + * request (assuming the denylist doesn't match). + */ + constructor(handler, { + allowlist = [/./], + denylist = [] + } = {}) { + { + finalAssertExports.isArrayOfClass(allowlist, RegExp, { + moduleName: 'workbox-routing', + className: 'NavigationRoute', + funcName: 'constructor', + paramName: 'options.allowlist' + }); + finalAssertExports.isArrayOfClass(denylist, RegExp, { + moduleName: 'workbox-routing', + className: 'NavigationRoute', + funcName: 'constructor', + paramName: 'options.denylist' + }); + } + super(options => this._match(options), handler); + this._allowlist = allowlist; + this._denylist = denylist; + } + /** + * Routes match handler. + * + * @param {Object} options + * @param {URL} options.url + * @param {Request} options.request + * @return {boolean} + * + * @private + */ + _match({ + url, + request + }) { + if (request && request.mode !== 'navigate') { + return false; + } + const pathnameAndSearch = url.pathname + url.search; + for (const regExp of this._denylist) { + if (regExp.test(pathnameAndSearch)) { + { + logger.log(`The navigation route ${pathnameAndSearch} is not ` + `being used, since the URL matches this denylist pattern: ` + `${regExp.toString()}`); + } + return false; + } + } + if (this._allowlist.some(regExp => regExp.test(pathnameAndSearch))) { + { + logger.debug(`The navigation route ${pathnameAndSearch} ` + `is being used.`); + } + return true; + } + { + logger.log(`The navigation route ${pathnameAndSearch} is not ` + `being used, since the URL being navigated to doesn't ` + `match the allowlist.`); + } + return false; + } + } + + /* + Copyright 2019 Google LLC + + Use of this source code is governed by an MIT-style + license that can be found in the LICENSE file or at + https://opensource.org/licenses/MIT. + */ + /** + * Helper function that calls + * {@link PrecacheController#createHandlerBoundToURL} on the default + * {@link PrecacheController} instance. + * + * If you are creating your own {@link PrecacheController}, then call the + * {@link PrecacheController#createHandlerBoundToURL} on that instance, + * instead of using this function. + * + * @param {string} url The precached URL which will be used to lookup the + * `Response`. + * @param {boolean} [fallbackToNetwork=true] Whether to attempt to get the + * response from the network if there's a precache miss. + * @return {workbox-routing~handlerCallback} + * + * @memberof workbox-precaching + */ + function createHandlerBoundToURL(url) { + const precacheController = getOrCreatePrecacheController(); + return precacheController.createHandlerBoundToURL(url); + } + + exports.CacheFirst = CacheFirst; + exports.ExpirationPlugin = ExpirationPlugin; + exports.NavigationRoute = NavigationRoute; + exports.NetworkOnly = NetworkOnly; + exports.StaleWhileRevalidate = StaleWhileRevalidate; + exports.cleanupOutdatedCaches = cleanupOutdatedCaches; + exports.clientsClaim = clientsClaim; + exports.createHandlerBoundToURL = createHandlerBoundToURL; + exports.precacheAndRoute = precacheAndRoute; + exports.registerRoute = registerRoute; + +})); diff --git a/aiui/packages/app/e2e/content-surfaces.spec.ts b/aiui/packages/app/e2e/content-surfaces.spec.ts new file mode 100644 index 00000000..e245a12c --- /dev/null +++ b/aiui/packages/app/e2e/content-surfaces.spec.ts @@ -0,0 +1,208 @@ +import { test, expect } from '@playwright/test' + +test.describe('Content surfaces', () => { + test('empty state shows when no conversation selected', async ({ page }) => { + await page.goto('/') + const main = page.locator('main.path-glass-card') + await expect(main).toBeVisible() + }) + + test('chat can receive input', async ({ page }) => { + await page.goto('/') + await page.waitForLoadState('networkidle') + const input = page.getByPlaceholder(/Message AIUI/) + await input.click() + await input.pressSequentially('Recommend some films') + await expect(input).toHaveValue('Recommend some films', { timeout: 3000 }) + }) + + test('films surface: films conversation loads and shows film cards', async ({ page }) => { + await Promise.all([ + page.waitForResponse((res) => res.url().includes('dev-chats') && res.status() === 200, { timeout: 15000 }), + page.goto('/'), + ]) + await expect(page.getByText('Recommend some sci-fi films')).toBeVisible({ timeout: 10000 }) + await page.getByRole('button', { name: /View all \d+ films/i }).click() + await expect(page.locator('main').getByText('Blade Runner 2049').first()).toBeVisible({ timeout: 5000 }) + }) + + test('films surface: clicking assistant bubble opens panel', async ({ page }) => { + await Promise.all([ + page.waitForResponse((res) => res.url().includes('dev-chats') && res.status() === 200, { timeout: 15000 }), + page.goto('/'), + ]) + await expect(page.getByText('Recommend some sci-fi films')).toBeVisible({ timeout: 10000 }) + await page.locator('.path-glass-bubble').filter({ hasText: /Blade Runner|Arrival|Dune/ }).first().click() + await expect(page.locator('main').getByText('Blade Runner 2049').first()).toBeVisible({ timeout: 5000 }) + }) + + test('magazine surface: BIP brief shows sections', async ({ page }) => { + await Promise.all([ + page.waitForResponse((res) => res.url().includes('dev-chats') && res.status() === 200, { timeout: 15000 }), + page.goto('/'), + ]) + await page.locator('aside button').filter({ has: page.locator('h2') }).first().click() + await page.getByRole('button', { name: 'BIP 110 brief' }).click() + await expect(page.getByText(/BIP 110|Pro camp|Summary/i).first()).toBeVisible({ timeout: 8000 }) + await page.getByRole('button', { name: 'View brief' }).click() + await expect(page.getByText(/AI Brief|Summary|Pro camp/i).first()).toBeVisible({ timeout: 5000 }) + }) + + test('songs surface: songs conversation shows song cards', async ({ page }) => { + await Promise.all([ + page.waitForResponse((res) => res.url().includes('dev-chats') && res.status() === 200, { timeout: 15000 }), + page.goto('/'), + ]) + await page.locator('aside button').filter({ has: page.locator('h2') }).first().click() + await page.getByRole('button', { name: 'Music recommendations' }).click() + await expect(page.getByText('Never Meant').first()).toBeVisible({ timeout: 8000 }) + await page.getByRole('button', { name: /View all \d+ songs/i }).click() + await expect(page.locator('main').getByText('Never Meant').first()).toBeVisible({ timeout: 5000 }) + }) + + test('podcasts surface: podcasts conversation shows podcast cards', async ({ page }) => { + await Promise.all([ + page.waitForResponse((res) => res.url().includes('dev-chats') && res.status() === 200, { timeout: 15000 }), + page.goto('/'), + ]) + await page.locator('aside button').filter({ has: page.locator('h2') }).first().click() + await page.getByRole('button', { name: 'Bitcoin podcasts' }).click() + await expect(page.getByText('What Bitcoin Did').first()).toBeVisible({ timeout: 8000 }) + await page.getByRole('button', { name: /View all \d+ podcasts/i }).click() + await expect(page.locator('main').getByText('What Bitcoin Did').first()).toBeVisible({ timeout: 5000 }) + }) + + test('websites surface: websites tab shows link cards', async ({ page }) => { + await Promise.all([ + page.waitForResponse((res) => res.url().includes('dev-chats') && res.status() === 200, { timeout: 15000 }), + page.goto('/'), + ]) + await page.locator('aside button').filter({ has: page.locator('h2') }).first().click() + await page.getByRole('button', { name: 'Bitcoin resources' }).click() + await expect(page.getByText('Bitcoin Magazine').first()).toBeVisible({ timeout: 8000 }) + await page.getByRole('button', { name: /View all \d+ websites/i }).click() + await expect(page.locator('main').getByText('Bitcoin Magazine').first()).toBeVisible({ timeout: 5000 }) + }) + + test('news surface: news conversation shows articles', async ({ page }) => { + await Promise.all([ + page.waitForResponse((res) => res.url().includes('dev-chats') && res.status() === 200, { timeout: 15000 }), + page.goto('/'), + ]) + await page.locator('aside button').filter({ has: page.locator('h2') }).first().click() + await page.getByRole('button', { name: 'Latest Bitcoin news' }).click() + await expect(page.getByText(/Bitcoin hits|ETF inflows/i).first()).toBeVisible({ timeout: 8000 }) + await page.getByRole('button', { name: /View all \d+ articles/i }).click() + await expect(page.locator('main').getByText(/Bitcoin hits|ETF inflows/i).first()).toBeVisible({ timeout: 5000 }) + }) +}) + +test.describe('Chat interactions', () => { + test('sends a message and receives streaming response', async ({ page }) => { + await Promise.all([ + page.waitForResponse((res) => res.url().includes('dev-chats') && res.status() === 200, { timeout: 15000 }), + page.goto('/'), + ]) + await page.waitForLoadState('networkidle') + const input = page.getByPlaceholder(/Message AIUI/) + await input.click() + await input.fill('Hello') + await input.press('Enter') + // User message should appear in chat + await expect(page.getByText('Hello').first()).toBeVisible({ timeout: 5000 }) + }) + + test('content panel shows film cards when AI mentions films', async ({ page }) => { + await Promise.all([ + page.waitForResponse((res) => res.url().includes('dev-chats') && res.status() === 200, { timeout: 15000 }), + page.goto('/'), + ]) + await expect(page.getByText('Recommend some sci-fi films')).toBeVisible({ timeout: 10000 }) + // Film cards should be visible inline in assistant message + await expect(page.getByText('Blade Runner 2049').first()).toBeVisible({ timeout: 5000 }) + // Open panel via "View all" button + await page.getByRole('button', { name: /View all \d+ films/i }).click() + await expect(page.locator('main').getByText('Blade Runner 2049').first()).toBeVisible({ timeout: 5000 }) + }) + + test('clicking a film card opens detail view', async ({ page }) => { + await Promise.all([ + page.waitForResponse((res) => res.url().includes('dev-chats') && res.status() === 200, { timeout: 15000 }), + page.goto('/'), + ]) + await expect(page.getByText('Recommend some sci-fi films')).toBeVisible({ timeout: 10000 }) + // Open panel + await page.locator('.path-glass-bubble').filter({ hasText: /Blade Runner|Arrival|Dune/ }).first().click() + const filmCard = page.locator('main').getByText('Blade Runner 2049').first() + await expect(filmCard).toBeVisible({ timeout: 5000 }) + // Click film card to open detail + await filmCard.click() + // Detail view should show film metadata + await expect(page.getByText(/Denis Villeneuve|2017|Sci-Fi/i).first()).toBeVisible({ timeout: 5000 }) + }) + + test('mobile viewport shows full-screen overlay for content', async ({ page }) => { + await page.setViewportSize({ width: 375, height: 812 }) + await Promise.all([ + page.waitForResponse((res) => res.url().includes('dev-chats') && res.status() === 200, { timeout: 15000 }), + page.goto('/'), + ]) + await expect(page.getByText('Recommend some sci-fi films')).toBeVisible({ timeout: 10000 }) + // Open panel on mobile + await page.locator('.path-glass-bubble').filter({ hasText: /Blade Runner|Arrival|Dune/ }).first().click() + // Content should be visible as overlay on mobile + await expect(page.locator('main').getByText('Blade Runner 2049').first()).toBeVisible({ timeout: 5000 }) + }) + + test('stop button halts generation', async ({ page }) => { + await page.goto('/') + await page.waitForLoadState('networkidle') + // When not streaming, stop button should not be visible + const stopButton = page.getByRole('button', { name: 'Stop generation' }) + await expect(stopButton).toBeHidden({ timeout: 3000 }) + // Chat input should be available instead + const input = page.getByPlaceholder(/Message AIUI/) + await expect(input).toBeVisible({ timeout: 3000 }) + }) + + test('web search toggle works', async ({ page }) => { + await page.goto('/') + await page.waitForLoadState('networkidle') + const toggle = page.getByRole('button', { name: 'Toggle web search' }) + await expect(toggle).toBeVisible({ timeout: 5000 }) + // Click to toggle web search on + await toggle.click() + // The button styling should change (it gains accent color when active) + await expect(toggle).toBeVisible() + // Click again to toggle off + await toggle.click() + await expect(toggle).toBeVisible() + }) + + test('new conversation clears messages', async ({ page }) => { + await Promise.all([ + page.waitForResponse((res) => res.url().includes('dev-chats') && res.status() === 200, { timeout: 15000 }), + page.goto('/'), + ]) + await expect(page.getByText('Recommend some sci-fi films')).toBeVisible({ timeout: 10000 }) + // Click "New conversation" button + await page.getByRole('button', { name: 'New conversation' }).click() + // Previous messages should be cleared + await expect(page.getByText('Recommend some sci-fi films')).toBeHidden({ timeout: 5000 }) + }) + + test('panel side toggle switches layout', async ({ page }) => { + await page.setViewportSize({ width: 1280, height: 800 }) + await Promise.all([ + page.waitForResponse((res) => res.url().includes('dev-chats') && res.status() === 200, { timeout: 15000 }), + page.goto('/'), + ]) + await expect(page.getByText('Recommend some sci-fi films')).toBeVisible({ timeout: 10000 }) + // Open the panel + await page.locator('.path-glass-bubble').filter({ hasText: /Blade Runner|Arrival|Dune/ }).first().click() + await expect(page.locator('main').getByText('Blade Runner 2049').first()).toBeVisible({ timeout: 5000 }) + // Both chat and panel sections should be visible on desktop + const chatSection = page.locator('section').first() + await expect(chatSection).toBeVisible() + }) +}) diff --git a/aiui/packages/app/e2e/fixtures/test-chats.ts b/aiui/packages/app/e2e/fixtures/test-chats.ts new file mode 100644 index 00000000..89958dba --- /dev/null +++ b/aiui/packages/app/e2e/fixtures/test-chats.ts @@ -0,0 +1,148 @@ +import type { Conversation } from '@aiui/core/types/message' + +const now = Date.now() + +/** Films: user asks for films, assistant responds with [[film:f1]] etc */ +export const filmsConversation: Conversation = { + id: 'e2e-films', + title: 'Film recommendations', + messages: [ + { + id: 'm1', + role: 'user', + content: 'Recommend some sci-fi films', + timestamp: now - 60000, + }, + { + id: 'm2', + role: 'assistant', + content: `Here are some great sci-fi films:\n\n- [[film:f1]] - Blade Runner 2049\n- [[film:f2]] - Arrival\n- [[film:f3]] - Dune\n\nAll from Denis Villeneuve.`, + timestamp: now - 30000, + }, + ], + createdAt: now - 120000, + updatedAt: now, +} + +/** Magazine: news-like query + bullet sections (BIP/debate context) */ +export const magazineConversation: Conversation = { + id: 'e2e-magazine', + title: 'BIP 110 brief', + messages: [ + { + id: 'm1', + role: 'user', + content: "What's the latest on BIP 110? What are people saying?", + timestamp: now - 60000, + }, + { + id: 'm2', + role: 'assistant', + content: `## Summary\n\nBIP 110 is being debated. Macro sentiment is bearish. BTC holding.\n\n- **Pro camp** — Technical improvement, faster.\n- **Anti camp** — Too risky, prefer status quo.\n\n**Henrik Zeberg** (analyst) says this could be bullish long-term.\n\nFor deeper analysis: check **Bitcoin Mailing List** (gnusha.org).`, + timestamp: now - 30000, + }, + ], + createdAt: now - 120000, + updatedAt: now, +} + +/** Websites: user asks for resources, assistant gives markdown links */ +export const websitesConversation: Conversation = { + id: 'e2e-websites', + title: 'Bitcoin resources', + messages: [ + { + id: 'm1', + role: 'user', + content: 'Best websites to check for Bitcoin news?', + timestamp: now - 60000, + }, + { + id: 'm2', + role: 'assistant', + content: `Here are the best places to check:\n\n- [Bitcoin Magazine](https://bitcoinmagazine.com)\n- [Bitcoin.org](https://bitcoin.org)\n- [Mempool.space](https://mempool.space)`, + timestamp: now - 30000, + }, + ], + createdAt: now - 120000, + updatedAt: now, +} + +/** News: web search results + news-like response */ +export const newsConversation: Conversation = { + id: 'e2e-news', + title: 'Latest Bitcoin news', + messages: [ + { + id: 'm1', + role: 'user', + content: "What's the latest Bitcoin news?", + timestamp: now - 60000, + }, + { + id: 'm2', + role: 'assistant', + content: `Here's what's happening. For the latest news check these sources:\n\n- [Bitcoin hits new high](https://example.com/btc-high)\n- [ETF inflows surge](https://example.com/etf-inflows)`, + timestamp: now - 30000, + webResults: [ + { title: 'Bitcoin hits new high', url: 'https://example.com/btc-high', content: 'BTC reached...' }, + { title: 'ETF inflows surge', url: 'https://example.com/etf-inflows', content: 'Spot ETF...' }, + ], + }, + ], + createdAt: now - 120000, + updatedAt: now, +} + +/** Songs: user asks for music, assistant responds with [[song:s1]] */ +export const songsConversation: Conversation = { + id: 'e2e-songs', + title: 'Music recommendations', + messages: [ + { + id: 'm1', + role: 'user', + content: 'Recommend some math rock', + timestamp: now - 60000, + }, + { + id: 'm2', + role: 'assistant', + content: `Here are great math rock tracks:\n\n- [[song:s1]] Never Meant by American Football\n- [[song:s2]] The Kill by Toe`, + timestamp: now - 30000, + }, + ], + createdAt: now - 120000, + updatedAt: now, +} + +/** Podcasts */ +export const podcastsConversation: Conversation = { + id: 'e2e-podcasts', + title: 'Bitcoin podcasts', + messages: [ + { + id: 'm1', + role: 'user', + content: 'Best Bitcoin podcasts?', + timestamp: now - 60000, + }, + { + id: 'm2', + role: 'assistant', + content: `Check these:\n\n- [[podcast:p1]] What Bitcoin Did\n- [[podcast:p2]] The Audacity to Podcast`, + timestamp: now - 30000, + }, + ], + createdAt: now - 120000, + updatedAt: now, +} + +export const allTestConversations = { + [filmsConversation.id]: filmsConversation, + [magazineConversation.id]: magazineConversation, + [websitesConversation.id]: websitesConversation, + [newsConversation.id]: newsConversation, + [songsConversation.id]: songsConversation, + [podcastsConversation.id]: podcastsConversation, +} diff --git a/aiui/packages/app/e2e/global-setup.ts b/aiui/packages/app/e2e/global-setup.ts new file mode 100644 index 00000000..f7018273 --- /dev/null +++ b/aiui/packages/app/e2e/global-setup.ts @@ -0,0 +1,27 @@ +import { mkdirSync, writeFileSync, existsSync, readFileSync } from 'fs' +import { resolve } from 'path' +import { allTestConversations } from './fixtures/test-chats' + +const CHATS_PATH = resolve(process.cwd(), '.dev', 'chats.json') + +export default async function globalSetup() { + const dir = resolve(process.cwd(), '.dev') + if (!existsSync(dir)) mkdirSync(dir, { recursive: true }) + + // Backup existing chats if present (for local dev) + let backup: string | null = null + if (existsSync(CHATS_PATH)) { + backup = readFileSync(CHATS_PATH, 'utf-8') + } + + const payload = { + conversations: allTestConversations, + activeConversationId: 'e2e-films', + } + writeFileSync(CHATS_PATH, JSON.stringify(payload, null, 2), 'utf-8') + + // Store backup path for teardown (we pass via env since globalSetup/Teardown don't share scope easily) + if (backup) { + process.env.AIUI_E2E_CHATS_BACKUP = backup + } +} diff --git a/aiui/packages/app/e2e/global-teardown.ts b/aiui/packages/app/e2e/global-teardown.ts new file mode 100644 index 00000000..d7ffea98 --- /dev/null +++ b/aiui/packages/app/e2e/global-teardown.ts @@ -0,0 +1,11 @@ +import { writeFileSync } from 'fs' +import { resolve } from 'path' + +const CHATS_PATH = resolve(process.cwd(), '.dev', 'chats.json') + +export default async function globalTeardown() { + const backup = process.env.AIUI_E2E_CHATS_BACKUP + if (backup) { + writeFileSync(CHATS_PATH, backup, 'utf-8') + } +} diff --git a/aiui/packages/app/e2e/smoke.spec.ts b/aiui/packages/app/e2e/smoke.spec.ts new file mode 100644 index 00000000..39409221 --- /dev/null +++ b/aiui/packages/app/e2e/smoke.spec.ts @@ -0,0 +1,20 @@ +import { test, expect } from '@playwright/test' + +test.describe('AIUI smoke tests', () => { + test('app loads and shows chat interface', async ({ page }) => { + await page.goto('/') + await expect(page).toHaveTitle(/AIUI/) + }) + + test('chat input is visible and focusable', async ({ page }) => { + await page.goto('/') + const input = page.getByPlaceholder(/Message AIUI|Waiting for/) + await expect(input).toBeVisible() + }) + + test('content panel area exists', async ({ page }) => { + await page.goto('/') + const main = page.locator('main.path-glass-card') + await expect(main).toBeVisible() + }) +}) diff --git a/aiui/packages/app/e2e/visual-regression.spec.ts b/aiui/packages/app/e2e/visual-regression.spec.ts new file mode 100644 index 00000000..93e1bfa7 --- /dev/null +++ b/aiui/packages/app/e2e/visual-regression.spec.ts @@ -0,0 +1,48 @@ +import { test, expect } from '@playwright/test' + +test.describe('Visual Regression', () => { + test('ChatPage renders correctly', async ({ page }) => { + await page.goto('/') + await page.waitForLoadState('networkidle') + await expect(page).toHaveScreenshot('chat-page.png', { + maxDiffPixelRatio: 0.005, + fullPage: true, + }) + }) + + test('ContentPanel renders correctly', async ({ page }) => { + await page.goto('/') + await page.waitForLoadState('networkidle') + // Open content panel by clicking a content tab + const filmTab = page.getByRole('button', { name: /film/i }).first() + if (await filmTab.isVisible()) { + await filmTab.click() + await page.waitForTimeout(500) + await expect(page).toHaveScreenshot('content-panel-films.png', { + maxDiffPixelRatio: 0.005, + }) + } + }) + + test('PassphraseDialog renders correctly', async ({ page }) => { + await page.goto('/') + await page.waitForLoadState('networkidle') + // PassphraseDialog shows on first load if crypto is enabled + const dialog = page.locator('.glass-card').filter({ hasText: 'Unlock AIUI' }) + if (await dialog.isVisible()) { + await expect(dialog).toHaveScreenshot('passphrase-dialog.png', { + maxDiffPixelRatio: 0.005, + }) + } + }) + + test('BottomSheet renders correctly on mobile', async ({ page }) => { + await page.setViewportSize({ width: 390, height: 844 }) + await page.goto('/') + await page.waitForLoadState('networkidle') + await expect(page).toHaveScreenshot('mobile-view.png', { + maxDiffPixelRatio: 0.005, + fullPage: true, + }) + }) +}) diff --git a/aiui/packages/app/eslint.config.js b/aiui/packages/app/eslint.config.js new file mode 100644 index 00000000..0a8ad982 --- /dev/null +++ b/aiui/packages/app/eslint.config.js @@ -0,0 +1,46 @@ +import js from '@eslint/js' +import tseslint from 'typescript-eslint' +import pluginVue from 'eslint-plugin-vue' +import vueParser from 'vue-eslint-parser' +import globals from 'globals' + +export default tseslint.config( + js.configs.recommended, + ...tseslint.configs.recommended, + ...pluginVue.configs['flat/recommended'], + { + files: ['src/**/*.vue'], + languageOptions: { + parser: vueParser, + parserOptions: { + parser: tseslint.parser, + extraFileExtensions: ['.vue'], + sourceType: 'module', + }, + }, + }, + { + files: ['src/**/*.{ts,vue}'], + languageOptions: { + globals: { + ...globals.browser, + }, + }, + rules: { + // TypeScript + '@typescript-eslint/no-unused-vars': ['warn', { argsIgnorePattern: '^_', varsIgnorePattern: '^_' }], + '@typescript-eslint/no-explicit-any': 'warn', + + // Vue + 'vue/multi-word-component-names': 'off', + 'vue/require-default-prop': 'off', + 'vue/no-v-html': 'warn', + + // General + 'no-console': ['warn', { allow: ['warn', 'error'] }], + }, + }, + { + ignores: ['dist/', 'node_modules/', 'e2e/', 'server/'], + }, +) diff --git a/aiui/packages/app/index.html b/aiui/packages/app/index.html new file mode 100644 index 00000000..b8f4ee77 --- /dev/null +++ b/aiui/packages/app/index.html @@ -0,0 +1,30 @@ +<!DOCTYPE html> +<html lang="en" class="h-full overflow-hidden"> + <head> + <meta charset="UTF-8" /> + <!-- interactive-widget=resizes-content: when the soft keyboard opens, + Chrome (108+) resizes the LAYOUT viewport instead of only the visual + one, so window.innerHeight shrinks and full-height layouts scale to + the space above the keyboard rather than scrolling under it. + neode-ui's index.html already carries this; this adds parity for + AIUI's standalone mode. Ignored by iOS Safari and by Android WebView — + the companion app controls keyboard resize itself via + windowSoftInputMode/IME insets (see docs/companion-keyboard-viewport.md). --> + <meta name="viewport" content="width=device-width, initial-scale=1.0, minimum-scale=1.0, maximum-scale=1.0, user-scalable=no, viewport-fit=cover, interactive-widget=resizes-content" /> + <meta name="theme-color" content="#0a0a0a" media="(prefers-color-scheme: dark)" /> + <meta name="theme-color" content="#faf9f6" media="(prefers-color-scheme: light)" /> + <meta name="mobile-web-app-capable" content="yes" /> + <meta name="apple-mobile-web-app-capable" content="yes" /> + <meta name="apple-mobile-web-app-status-bar-style" content="black-translucent" /> + <meta name="apple-mobile-web-app-title" content="AIUI" /> + <meta name="description" content="AI chat interface with rich content surfaces" /> + <!-- CSP set via HTTP headers in production nginx — not in HTML meta to avoid breaking Vite HMR --> + <link rel="icon" href="/favicon.svg" type="image/svg+xml" /> + <link rel="apple-touch-icon" href="/apple-touch-icon-180x180.png" /> + <title>AIUI + + +
+ + + diff --git a/aiui/packages/app/lighthouserc.json b/aiui/packages/app/lighthouserc.json new file mode 100644 index 00000000..3adea37d --- /dev/null +++ b/aiui/packages/app/lighthouserc.json @@ -0,0 +1,23 @@ +{ + "ci": { + "collect": { + "staticDistDir": "./dist", + "numberOfRuns": 3, + "settings": { + "preset": "desktop" + } + }, + "assert": { + "assertions": { + "categories:performance": ["error", { "minScore": 0.7 }], + "largest-contentful-paint": ["error", { "maxNumericValue": 3000 }], + "cumulative-layout-shift": ["error", { "maxNumericValue": 0.15 }], + "categories:accessibility": ["warn", { "minScore": 0.8 }], + "categories:best-practices": ["warn", { "minScore": 0.8 }] + } + }, + "upload": { + "target": "temporary-public-storage" + } + } +} diff --git a/aiui/packages/app/package.json b/aiui/packages/app/package.json new file mode 100644 index 00000000..27066845 --- /dev/null +++ b/aiui/packages/app/package.json @@ -0,0 +1,67 @@ +{ + "name": "@aiui/app", + "version": "0.1.0", + "private": true, + "description": "AIUI reference application", + "license": "MIT", + "type": "module", + "scripts": { + "dev": "bash scripts/dev.sh", + "dev:vite": "vite", + "dev:proxy": "tsx server/claude-proxy.ts", + "build": "vue-tsc --noEmit && vite build", + "preview": "vite preview", + "test": "vitest run", + "test:e2e": "playwright test", + "test:e2e:ui": "playwright test --ui", + "lint": "eslint src/", + "typecheck": "vue-tsc --noEmit", + "clean": "rm -rf dist", + "storybook": "storybook dev -p 6006", + "build-storybook": "storybook build" + }, + "dependencies": { + "@aiui/core": "workspace:*", + "@tanstack/vue-virtual": "^3.13.19", + "hls.js": "^1.6.15", + "katex": "^0.16.33", + "leaflet": "^1.9.4", + "markdown-it": "^14.1.1", + "mermaid": "^11.12.3", + "pdfjs-dist": "^5.5.207", + "pinia": "^3.0.4", + "plyr": "^3.8.4", + "vue": "^3.5.29", + "vue-router": "^5.0.3", + "wavesurfer.js": "^7.12.1", + "dompurify": "^3.2.6" + }, + "devDependencies": { + "@eslint/js": "^10.0.1", + "@playwright/test": "^1.49.0", + "@storybook/addon-essentials": "^8.6.0", + "@storybook/vue3": "^8.6.0", + "@storybook/vue3-vite": "^8.6.0", + "@tailwindcss/vite": "^4.2.1", + "@types/leaflet": "^1.9.21", + "@types/markdown-it": "^14.1.2", + "@vitejs/plugin-basic-ssl": "^2.1.4", + "@vitejs/plugin-vue": "^6.0.4", + "duck-duck-scrape": "^2.2.7", + "eslint": "^10.0.2", + "eslint-plugin-vue": "^10.8.0", + "globals": "^17.4.0", + "happy-dom": "^20.8.3", + "rss-parser": "^3.13.0", + "storybook": "^8.6.0", + "tailwindcss": "^4.2.1", + "tsx": "^4.21.0", + "typescript": "~5.8.0", + "typescript-eslint": "^8.56.1", + "vite": "^7.3.1", + "vite-plugin-pwa": "^1.2.0", + "vitest": "^4.0.18", + "vue-eslint-parser": "^10.4.0", + "vue-tsc": "^3.2.5" + } +} diff --git a/aiui/packages/app/playwright.config.ts b/aiui/packages/app/playwright.config.ts new file mode 100644 index 00000000..41f5beee --- /dev/null +++ b/aiui/packages/app/playwright.config.ts @@ -0,0 +1,49 @@ +import { defineConfig, devices } from '@playwright/test' + +export default defineConfig({ + testDir: './e2e', + fullyParallel: false, + forbidOnly: !!process.env.CI, + retries: process.env.CI ? 2 : 0, + workers: 1, + globalSetup: './e2e/global-setup.ts', + globalTeardown: './e2e/global-teardown.ts', + reporter: 'html', + snapshotPathTemplate: '{testDir}/__screenshots__/{projectName}/{testFilePath}/{arg}{ext}', + expect: { + toHaveScreenshot: { maxDiffPixelRatio: 0.005 }, + }, + use: { + baseURL: 'http://localhost:5173', + trace: 'on-first-retry', + }, + projects: [ + // Desktop browsers + { name: 'chromium', use: { ...devices['Desktop Chrome'] } }, + { name: 'firefox', use: { ...devices['Desktop Firefox'] } }, + { name: 'webkit', use: { ...devices['Desktop Safari'] } }, + // Mobile viewports + { + name: 'iphone14', + use: { + ...devices['iPhone 14'], + viewport: { width: 390, height: 844 }, + }, + }, + { + name: 'galaxy-s21', + use: { + userAgent: 'Mozilla/5.0 (Linux; Android 12; SM-G991B) AppleWebKit/537.36', + viewport: { width: 360, height: 800 }, + deviceScaleFactor: 3, + isMobile: true, + hasTouch: true, + }, + }, + ], + webServer: { + command: 'pnpm run dev:vite', + url: 'http://localhost:5173', + reuseExistingServer: true, + }, +}) diff --git a/aiui/packages/app/public/apple-touch-icon-180x180.png b/aiui/packages/app/public/apple-touch-icon-180x180.png new file mode 100644 index 00000000..898d0583 Binary files /dev/null and b/aiui/packages/app/public/apple-touch-icon-180x180.png differ diff --git a/aiui/packages/app/public/assets/icons/microphone.svg b/aiui/packages/app/public/assets/icons/microphone.svg new file mode 100644 index 00000000..2221b5cd --- /dev/null +++ b/aiui/packages/app/public/assets/icons/microphone.svg @@ -0,0 +1,3 @@ + + + diff --git a/aiui/packages/app/public/assets/img/bg-intro-3.webp b/aiui/packages/app/public/assets/img/bg-intro-3.webp new file mode 100644 index 00000000..95738b54 Binary files /dev/null and b/aiui/packages/app/public/assets/img/bg-intro-3.webp differ diff --git a/aiui/packages/app/public/favicon.svg b/aiui/packages/app/public/favicon.svg new file mode 100644 index 00000000..2c3e7fc0 --- /dev/null +++ b/aiui/packages/app/public/favicon.svg @@ -0,0 +1,4 @@ + + + + diff --git a/aiui/packages/app/public/icon.svg b/aiui/packages/app/public/icon.svg new file mode 100644 index 00000000..b37e385b --- /dev/null +++ b/aiui/packages/app/public/icon.svg @@ -0,0 +1,16 @@ + + + + + + + + + + + + + + + + diff --git a/aiui/packages/app/public/images/loading-poster.svg b/aiui/packages/app/public/images/loading-poster.svg new file mode 100644 index 00000000..654bc3fa --- /dev/null +++ b/aiui/packages/app/public/images/loading-poster.svg @@ -0,0 +1,24 @@ + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/aiui/packages/app/public/pwa-192x192.png b/aiui/packages/app/public/pwa-192x192.png new file mode 100644 index 00000000..c951f2d3 Binary files /dev/null and b/aiui/packages/app/public/pwa-192x192.png differ diff --git a/aiui/packages/app/public/pwa-512x512.png b/aiui/packages/app/public/pwa-512x512.png new file mode 100644 index 00000000..8e52eb0c Binary files /dev/null and b/aiui/packages/app/public/pwa-512x512.png differ diff --git a/aiui/packages/app/scripts/dev.sh b/aiui/packages/app/scripts/dev.sh new file mode 100755 index 00000000..f4b26539 --- /dev/null +++ b/aiui/packages/app/scripts/dev.sh @@ -0,0 +1,39 @@ +#!/usr/bin/env bash +# Start Claude proxy and Vite dev server together. +# Both are killed when either exits or when this script receives SIGINT/SIGTERM. + +set -euo pipefail + +SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)" +APP_DIR="$(cd "$SCRIPT_DIR/.." && pwd)" + +cd "$APP_DIR" + +# Generate a dev API token if not already set +if [ -z "${VITE_DEV_API_TOKEN:-}" ]; then + export VITE_DEV_API_TOKEN=$(openssl rand -hex 16) +fi + +cleanup() { + kill 0 2>/dev/null || true + wait 2>/dev/null || true +} +trap cleanup EXIT INT TERM + +# Use local node_modules binaries +BIN="$APP_DIR/node_modules/.bin" + +# Kill stale proxy from previous session (it has a different auth token) +if lsof -ti:3141 > /dev/null 2>&1; then + echo " Killing stale Claude proxy on :3141" + kill $(lsof -ti:3141) 2>/dev/null || true + sleep 0.3 +fi +"$BIN/tsx" server/claude-proxy.ts & +sleep 0.3 + +# Start Vite dev server in background (host binding controlled by vite.config.ts / VITE_HOST) +"$BIN/vite" & + +# Wait for all background jobs (compatible with bash 3.2 on macOS) +wait diff --git a/aiui/packages/app/scripts/generate-seed-chats.ts b/aiui/packages/app/scripts/generate-seed-chats.ts new file mode 100644 index 00000000..1b4d5e55 --- /dev/null +++ b/aiui/packages/app/scripts/generate-seed-chats.ts @@ -0,0 +1,20 @@ +/** + * Generate .dev/chats.json from the seed prompt index. + * Run: pnpm -C packages/app exec tsx scripts/generate-seed-chats.ts + */ +import { seedPromptsToConversation } from '../src/__tests__/fixtures/seedPrompts' +import { writeFileSync, mkdirSync, existsSync } from 'fs' +import { resolve, dirname } from 'path' + +const outPath = resolve(dirname(new URL(import.meta.url).pathname), '../.dev/chats.json') +const dir = dirname(outPath) +if (!existsSync(dir)) mkdirSync(dir, { recursive: true }) + +const conv = seedPromptsToConversation() +const data = { + conversations: { [conv.id]: conv }, + activeConversationId: conv.id, +} + +writeFileSync(outPath, JSON.stringify(data, null, 2), 'utf-8') +console.log(`Wrote seed conversation (${conv.messages.length / 2} prompts) to ${outPath}`) diff --git a/aiui/packages/app/server/claude-proxy.ts b/aiui/packages/app/server/claude-proxy.ts new file mode 100644 index 00000000..f101d6a2 --- /dev/null +++ b/aiui/packages/app/server/claude-proxy.ts @@ -0,0 +1,579 @@ +import { spawn, execSync } from 'child_process' +import { createServer } from 'http' +import { readFileSync, existsSync } from 'fs' +import { resolve, dirname } from 'path' +import { fileURLToPath } from 'url' +import { validateDevAuth, handleCorsOptions, checkRateLimit } from './dev-auth.js' + +const __dirname = dirname(fileURLToPath(import.meta.url)) + +// Load .env.local from workspace root (monorepo) or cwd +function loadEnv() { + for (const base of [resolve(__dirname, '../../..'), process.cwd()]) { + const path = resolve(base, '.env.local') + if (existsSync(path)) { + try { + const buf = readFileSync(path, 'utf8') + for (const line of buf.split('\n')) { + const m = line.match(/^([^#=]+)=(.*)$/) + if (m) { + const key = m[1].trim() + const val = m[2].trim().replace(/^["']|["']$/g, '') + if (!process.env[key]) process.env[key] = val + } + } + break + } catch { + /* ignore */ + } + } + } +} +loadEnv() + +const PORT = 3141 + +/** + * Resolve the `claude` CLI binary. The old hardcoded `~/.local/bin/claude` + * broke on any machine/install where the CLI lives elsewhere (e.g. an nvm + * Node install's own bin dir) — ENOENT on spawn with no obvious fix short of + * a manual symlink. Prefer a real PATH lookup, matching how a user would + * actually run `claude` themselves; fall back to the historical path for + * anyone relying on it, then to the bare command name so spawn() still gets + * a chance to resolve it via PATH at process-start time even if neither + * check above found it (e.g. PATH changes after this proxy boots). + */ +function resolveClaudeBin(): string { + if (process.env.CLAUDE_BIN && existsSync(process.env.CLAUDE_BIN)) { + return process.env.CLAUDE_BIN + } + try { + const found = execSync('command -v claude', { encoding: 'utf8' }).trim() + if (found) return found + } catch { + /* not resolvable via PATH right now — fall through */ + } + const legacy = resolve(process.env.HOME ?? '', '.local/bin/claude') + if (existsSync(legacy)) return legacy + return 'claude' +} + +const CLAUDE_BIN = resolveClaudeBin() +const APP_URL = process.env.APP_URL ?? 'http://localhost:5173' + +/** API key (sk-ant-api03-...) or OAuth token (sk-ant-oat...) from Max subscription */ +function getAnthropicCredential(): string | undefined { + const fromEnv = process.env.ANTHROPIC_API_KEY + ?? process.env.VITE_ANTHROPIC_API_KEY + ?? process.env.ANTHROPIC_TOKEN + ?? process.env.VITE_ANTHROPIC_TOKEN + if (fromEnv) return fromEnv + const home = process.env.HOME ?? '' + const settingsPath = resolve(home, '.claude/settings.json') + if (home && existsSync(settingsPath)) { + try { + const json = JSON.parse(readFileSync(settingsPath, 'utf8')) + const env = json?.env + if (env && typeof env === 'object') { + const t = env.ANTHROPIC_TOKEN ?? env.VITE_ANTHROPIC_TOKEN ?? env.ANTHROPIC_API_KEY ?? env.VITE_ANTHROPIC_API_KEY + if (typeof t === 'string') return t + } + } catch { /* ignore */ } + } + // macOS keychain: Claude Code stores OAuth credentials here + if (process.platform === 'darwin') { + try { + const raw = execSync( + 'security find-generic-password -s "Claude Code-credentials" -w 2>/dev/null', + { encoding: 'utf8', timeout: 3000 }, + ).trim() + const creds = JSON.parse(raw) + const oauthToken = creds?.claudeAiOauth?.accessToken + if (typeof oauthToken === 'string' && oauthToken.startsWith('sk-ant-')) { + console.log('[proxy] Found Claude OAuth token in macOS keychain') + return oauthToken + } + } catch { /* keychain not available or no entry */ } + } + return undefined +} + +const ANTHROPIC_CREDENTIAL = getAnthropicCredential() +const OPENROUTER_API_KEY = process.env.OPENROUTER_API_KEY ?? process.env.VITE_OPENROUTER_API_KEY ?? '' +const isOAuthToken = (s: string) => /^sk-ant-oat/.test(s) + +const SEARCH_WEB_TOOL = { + name: 'search_web', + description: 'Search the web for current information. Use this when the user asks for news, recent events, facts you are unsure about, or any information that may have changed. Perform one search per distinct topic. Returns titles, URLs, and snippets.', + input_schema: { + type: 'object', + properties: { + query: { + type: 'string', + description: 'Search query (e.g. "Bitcoin price March 2025", "latest news AI regulation")', + }, + }, + required: ['query'], + }, +} + +function mapModelToApi(model: string): string { + if (model?.includes('opus')) return 'claude-opus-4-20250514' + if (model?.includes('haiku')) return 'claude-haiku-4-5-20251001' + return 'claude-sonnet-4-20250514' +} + +async function runSearchWeb(query: string): Promise { + const url = `${APP_URL.replace(/\/$/, '')}/api/web-search?${new URLSearchParams({ q: query })}` + try { + const res = await fetch(url, { + headers: { Accept: 'application/json' }, + signal: AbortSignal.timeout(10000), + }) + if (!res.ok) return `Search failed: ${res.status}` + const data = (await res.json()) as { results?: { title?: string; url?: string; content?: string }[] } + const results = data.results ?? [] + if (results.length === 0) return 'No results found.' + return results + .map((r, i) => `${i + 1}. [${r.title ?? 'Unknown'}](${r.url ?? ''})${r.content ? ` — ${r.content.slice(0, 150)}${r.content.length > 150 ? '…' : ''}` : ''}`) + .join('\n') + } catch (err) { + return `Search error: ${err instanceof Error ? err.message : String(err)}` + } +} + +async function streamViaAnthropicApi( + model: string, + system: string | undefined, + messages: { role: string; content: unknown }[], + res: import('http').ServerResponse, + useTools: boolean, + credential: string, + maxTokens?: number, +): Promise { + const apiModel = mapModelToApi(model) + const apiMessages = messages.map((m) => ({ + role: m.role === 'assistant' ? 'assistant' : 'user', + content: typeof m.content === 'string' ? m.content : m.content, + })) + + let clientDisconnected = false + res.on('close', () => { clientDisconnected = true }) + + const sendDelta = (text: string) => { + if (!clientDisconnected) { + res.write(`data: ${JSON.stringify({ type: 'content_block_delta', delta: { type: 'text_delta', text } })}\n\n`) + } + } + + const sendError = (msg: string) => { + if (!clientDisconnected) { + res.write(`data: ${JSON.stringify({ type: 'error', error: { message: msg } })}\n\n`) + } + } + + const buildHeaders = (): Record => { + const headers: Record = { + 'Content-Type': 'application/json', + 'anthropic-version': '2023-06-01', + } + if (isOAuthToken(credential)) { + headers['Authorization'] = `Bearer ${credential}` + headers['anthropic-beta'] = 'oauth-2025-04-20' + } else { + headers['x-api-key'] = credential + } + return headers + } + + // Tool use loop (non-streaming — needs to collect tool calls) + if (useTools) { + let turnMessages = [...apiMessages] + const maxToolRounds = 5 + let rounds = 0 + + while (rounds < maxToolRounds) { + rounds++ + const body: Record = { + model: apiModel, + max_tokens: maxTokens ?? 4096, + system, + messages: turnMessages, + tools: [SEARCH_WEB_TOOL], + } + + const apiRes = await fetch('https://api.anthropic.com/v1/messages', { + method: 'POST', + headers: buildHeaders(), + body: JSON.stringify(body), + signal: AbortSignal.timeout(120000), + }) + + if (!apiRes.ok) { + const errBody = await apiRes.text() + sendError(`Anthropic API ${apiRes.status}: ${errBody.slice(0, 200)}`) + break + } + + const data = (await apiRes.json()) as { + content?: { type: string; text?: string; id?: string; name?: string; input?: { query?: string } }[] + stop_reason?: string + } + + const content = data.content ?? [] + const toolUses = content.filter((b) => b.type === 'tool_use') + const textBlocks = content.filter((b) => b.type === 'text') + + if (data.stop_reason === 'tool_use' && toolUses.length > 0) { + const toolResults: { type: string; tool_use_id: string; content: string }[] = [] + for (const tu of toolUses) { + if (tu.name === 'search_web' && tu.id && tu.input?.query) { + console.log('[proxy] tool search_web:', tu.input.query) + const result = await runSearchWeb(tu.input.query) + toolResults.push({ type: 'tool_result', tool_use_id: tu.id, content: result }) + } + } + turnMessages = [ + ...turnMessages, + { role: 'assistant' as const, content }, + { role: 'user' as const, content: toolResults }, + ] + continue + } + + for (const block of textBlocks) { + if (block.text) sendDelta(block.text) + } + break + } + + if (!clientDisconnected) { + res.write('data: [DONE]\n\n') + res.end() + } + return + } + + // Streaming path (no tools) + const body: Record = { + model: apiModel, + max_tokens: maxTokens ?? 4096, + stream: true, + messages: apiMessages, + } + if (system) body.system = system + + const apiRes = await fetch('https://api.anthropic.com/v1/messages', { + method: 'POST', + headers: buildHeaders(), + body: JSON.stringify(body), + signal: AbortSignal.timeout(120000), + }) + + if (!apiRes.ok) { + const errBody = await apiRes.text() + sendError(`Anthropic API ${apiRes.status}: ${errBody.slice(0, 200)}`) + if (!clientDisconnected) { + res.write('data: [DONE]\n\n') + res.end() + } + return + } + + // Pipe SSE stream from Anthropic to client + const reader = apiRes.body?.getReader() + if (!reader) { + sendError('No response body from API') + if (!clientDisconnected) { + res.write('data: [DONE]\n\n') + res.end() + } + return + } + + const decoder = new TextDecoder() + try { + while (true) { + if (clientDisconnected) break + const { done, value } = await reader.read() + if (done) break + const chunk = decoder.decode(value, { stream: true }) + res.write(chunk) + } + } catch (err) { + if (!clientDisconnected) { + sendError(`Stream error: ${err instanceof Error ? err.message : String(err)}`) + } + } finally { + reader.cancel().catch(() => {}) + if (!clientDisconnected) { + res.end() + } + } +} + +async function streamOpenRouterProxy( + reqBody: string, + res: import('http').ServerResponse, +): Promise { + if (!OPENROUTER_API_KEY) { + res.writeHead(500, { 'Content-Type': 'application/json', 'Access-Control-Allow-Origin': 'http://localhost:5173' }) + res.end(JSON.stringify({ error: 'OPENROUTER_API_KEY not configured on server' })) + return + } + + res.writeHead(200, { + 'Content-Type': 'text/event-stream', + 'Cache-Control': 'no-cache', + 'Connection': 'keep-alive', + 'Access-Control-Allow-Origin': 'http://localhost:5173', + 'X-Accel-Buffering': 'no', + }) + + let clientDisconnected = false + res.on('close', () => { clientDisconnected = true }) + + try { + const apiRes = await fetch('https://openrouter.ai/api/v1/chat/completions', { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + 'Authorization': `Bearer ${OPENROUTER_API_KEY}`, + 'HTTP-Referer': APP_URL, + 'X-Title': 'AIUI', + }, + body: reqBody, + signal: AbortSignal.timeout(120000), + }) + + if (!apiRes.ok) { + const errBody = await apiRes.text() + if (!clientDisconnected) { + res.write(`data: ${JSON.stringify({ error: `OpenRouter API ${apiRes.status}: ${errBody.slice(0, 200)}` })}\n\n`) + res.write('data: [DONE]\n\n') + res.end() + } + return + } + + const reader = apiRes.body?.getReader() + if (!reader) { + if (!clientDisconnected) { + res.write('data: [DONE]\n\n') + res.end() + } + return + } + + const decoder = new TextDecoder() + while (true) { + const { done, value } = await reader.read() + if (done || clientDisconnected) break + const chunk = decoder.decode(value, { stream: true }) + res.write(chunk) + } + + if (!clientDisconnected) { + res.end() + } + } catch (err) { + console.error('[proxy] OpenRouter error:', err) + if (!clientDisconnected) { + res.write(`data: ${JSON.stringify({ error: `OpenRouter proxy error: ${err instanceof Error ? err.message : String(err)}` })}\n\n`) + res.write('data: [DONE]\n\n') + res.end() + } + } +} + +const server = createServer((req, res) => { + if (req.method === 'OPTIONS') { + handleCorsOptions(res) + return + } + + if (req.method !== 'POST' || (req.url !== '/v1/messages' && req.url !== '/v1/openrouter')) { + res.writeHead(404, { 'Content-Type': 'application/json' }) + res.end(JSON.stringify({ error: 'Not found' })) + return + } + + if (!validateDevAuth(req, res)) return + if (!checkRateLimit(req, res, true)) return + + const MAX_BODY_SIZE = 1 * 1024 * 1024 // 1 MB + let body = '' + let aborted = false + req.on('data', (chunk) => { + body += chunk + if (body.length > MAX_BODY_SIZE) { + aborted = true + res.writeHead(413, { 'Content-Type': 'application/json' }) + res.end(JSON.stringify({ error: 'Request body too large (max 1MB)' })) + req.destroy() + } + }) + req.on('end', () => { + if (aborted) return + if (req.url === '/v1/openrouter') { + console.log('[proxy] → OpenRouter proxy') + streamOpenRouterProxy(body, res) + return + } + try { + const payload = JSON.parse(body) + const { model, messages, system, webSearch, max_tokens } = payload + + // Use client-provided API key if present, otherwise fall back to server credential + const clientKey = req.headers['x-api-key'] as string | undefined + const credential = clientKey || ANTHROPIC_CREDENTIAL + + if (credential) { + // Direct API — fast streaming + const useTools = webSearch === true + const apiModel = mapModelToApi(model) + console.log(`[proxy] → Anthropic API ${apiModel}${useTools ? ' [tools]' : ' [stream]'}`) + + res.writeHead(200, { + 'Content-Type': 'text/event-stream', + 'Cache-Control': 'no-cache', + 'Connection': 'keep-alive', + 'Access-Control-Allow-Origin': '*', + 'X-Accel-Buffering': 'no', + }) + streamViaAnthropicApi(model, system, messages ?? [], res, useTools, credential, max_tokens) + return + } + + // CLI fallback — no API credential available + const modelFlag = model?.includes('opus') ? 'opus' + : model?.includes('haiku') ? 'haiku' + : 'sonnet' + + const history = (messages ?? []) as { role: string; content: string }[] + const userMessages = history.filter((m) => m.role === 'user') + const lastUserMsg = userMessages[userMessages.length - 1]?.content ?? '' + + const contextParts: string[] = [] + if (system) contextParts.push(system) + const prior = history.slice(0, -1) + if (prior.length > 0) { + contextParts.push( + 'Conversation so far:\n' + + prior.map((m) => `${m.role}: ${m.content}`).join('\n') + ) + } + + const systemPrompt = contextParts.length > 0 ? contextParts.join('\n\n') : undefined + + const args = ['-p', '--model', modelFlag] + if (systemPrompt) args.push('--system-prompt', systemPrompt) + if (webSearch === true) { + args.push('--allowed-tools', 'WebSearch', 'WebFetch') + args.push('--permission-mode', 'dontAsk') + } + args.push('--', lastUserMsg) + + console.log(`[proxy] → claude CLI --model ${modelFlag}${webSearch ? ' [WebSearch]' : ''} "${lastUserMsg.slice(0, 60)}..."`) + + const procEnv = { ...process.env, NO_COLOR: '1', TERM: 'dumb' } + delete procEnv.CLAUDECODE + delete procEnv.CLAUDE_CODE + delete procEnv.ANTHROPIC_CLAUDE_CODE + delete procEnv.CLAUDE_CODE_ENTRYPOINT + if (webSearch === true) { + delete procEnv.DISALLOWED_TOOLS + } + const proc = spawn(CLAUDE_BIN, args, { + stdio: ['pipe', 'pipe', 'pipe'], + env: procEnv, + detached: false, + }) + + proc.stdin.end('') + + res.writeHead(200, { + 'Content-Type': 'text/event-stream', + 'Cache-Control': 'no-cache', + 'Connection': 'keep-alive', + 'Access-Control-Allow-Origin': '*', + 'X-Accel-Buffering': 'no', + }) + + let fullOutput = '' + let clientDisconnected = false + + proc.stdout.on('data', (chunk: Buffer) => { + const text = chunk.toString() + fullOutput += text + + if (!clientDisconnected) { + const sseData = { + type: 'content_block_delta', + delta: { type: 'text_delta', text }, + } + res.write(`data: ${JSON.stringify(sseData)}\n\n`) + } + }) + + proc.stderr.on('data', (chunk: Buffer) => { + const msg = chunk.toString().trim() + if (msg) console.error('[proxy] stderr:', msg) + }) + + proc.on('error', (err: NodeJS.ErrnoException) => { + console.error('[proxy] spawn error:', err) + if (!clientDisconnected) { + const message = err.code === 'ENOENT' + ? `Claude CLI not found (tried "${CLAUDE_BIN}"). Install it (npm i -g @anthropic-ai/claude-code), ` + + `make sure it's on PATH, or set CLAUDE_BIN to its full path in .env.local. ` + + `Alternatively, configure ANTHROPIC_API_KEY or ANTHROPIC_TOKEN in .env.local to skip the CLI entirely.` + : `Spawn error: ${err.message}` + const errData = { + type: 'error', + error: { message }, + } + res.write(`data: ${JSON.stringify(errData)}\n\n`) + res.write('data: [DONE]\n\n') + res.end() + } + }) + + proc.on('close', (code, signal) => { + console.log(`[proxy] ← exit code=${code} signal=${signal} output=${fullOutput.length}b`) + if (!clientDisconnected) { + res.write('data: [DONE]\n\n') + res.end() + } + }) + + res.on('close', () => { + clientDisconnected = true + if (proc.exitCode === null && !proc.killed) { + console.log('[proxy] Client disconnected, killing process') + proc.kill('SIGTERM') + } + }) + + } catch (err) { + console.error('[proxy] Parse error:', err) + res.writeHead(400, { + 'Content-Type': 'application/json', + 'Access-Control-Allow-Origin': 'http://localhost:5173', + }) + res.end(JSON.stringify({ error: String(err) })) + } + }) +}) + +server.listen(PORT, () => { + console.log(`\n Claude proxy → http://localhost:${PORT}`) + console.log(` Binary: ${CLAUDE_BIN}`) + if (ANTHROPIC_CREDENTIAL) { + const mode = isOAuthToken(ANTHROPIC_CREDENTIAL) ? 'OAuth (Max)' : 'API key' + console.log(` Tool use (search_web): enabled (${mode})`) + } else { + console.log(` Tool use: add ANTHROPIC_TOKEN (Max) or ANTHROPIC_API_KEY to .env.local`) + } + console.log(` OpenRouter proxy: ${OPENROUTER_API_KEY ? 'enabled' : 'add OPENROUTER_API_KEY to .env.local'}\n`) +}) diff --git a/aiui/packages/app/server/dev-auth.ts b/aiui/packages/app/server/dev-auth.ts new file mode 100644 index 00000000..f51699df --- /dev/null +++ b/aiui/packages/app/server/dev-auth.ts @@ -0,0 +1,84 @@ +/** + * Shared dev server authentication and rate limiting middleware. + * Validates Bearer token on all /api/* requests. + * Token is auto-generated in scripts/dev.sh and injected via VITE_DEV_API_TOKEN. + */ +import type { IncomingMessage, ServerResponse } from 'http' + +/** Validate Authorization header. Returns true if authorized, false if rejected (response already sent). */ +export function validateDevAuth(req: IncomingMessage, res: ServerResponse): boolean { + const token = process.env.VITE_DEV_API_TOKEN ?? '' + if (!token) return true // No token configured, skip auth + const auth = req.headers.authorization + if (auth === `Bearer ${token}`) return true + res.writeHead(401, { 'Content-Type': 'application/json' }) + res.end(JSON.stringify({ error: 'Unauthorized' })) + return false +} + +const ALLOWED_ORIGIN = 'http://localhost:5173' + +/** Set CORS headers with explicit localhost origin instead of wildcard. */ +export function setCorsHeaders(res: ServerResponse): void { + res.setHeader('Access-Control-Allow-Origin', ALLOWED_ORIGIN) +} + +/** Write CORS preflight response. */ +export function handleCorsOptions(res: ServerResponse): void { + res.writeHead(204, { + 'Access-Control-Allow-Origin': ALLOWED_ORIGIN, + 'Access-Control-Allow-Methods': 'GET, POST, PUT, OPTIONS', + 'Access-Control-Allow-Headers': 'Content-Type, Authorization', + }) + res.end() +} + +// ─── Rate Limiting ────────────────────────────────────────── + +const WINDOW_MS = 60_000 // 1 minute window +const READ_LIMIT = 60 // 60 requests per minute for reads +const WRITE_LIMIT = 10 // 10 requests per minute for writes + +interface RateBucket { + count: number + resetAt: number +} + +const rateBuckets = new Map() + +// Clean up stale buckets every 5 minutes +setInterval(() => { + const now = Date.now() + for (const [key, bucket] of rateBuckets) { + if (now > bucket.resetAt) rateBuckets.delete(key) + } +}, 5 * 60_000) + +function getClientIp(req: IncomingMessage): string { + return req.socket.remoteAddress ?? 'unknown' +} + +/** + * Check rate limit for a request. Returns true if allowed, false if rejected (response already sent). + * @param isWrite - Set to true for write operations (POST/PUT/DELETE) which have a lower limit. + */ +export function checkRateLimit(req: IncomingMessage, res: ServerResponse, isWrite = false): boolean { + const ip = getClientIp(req) + const limit = isWrite ? WRITE_LIMIT : READ_LIMIT + const key = `${ip}:${isWrite ? 'w' : 'r'}` + const now = Date.now() + + let bucket = rateBuckets.get(key) + if (!bucket || now > bucket.resetAt) { + bucket = { count: 0, resetAt: now + WINDOW_MS } + rateBuckets.set(key, bucket) + } + + bucket.count++ + if (bucket.count > limit) { + res.writeHead(429, { 'Content-Type': 'application/json', 'Retry-After': '60' }) + res.end(JSON.stringify({ error: 'Too many requests' })) + return false + } + return true +} diff --git a/aiui/packages/app/server/nginx-archy.conf b/aiui/packages/app/server/nginx-archy.conf new file mode 100644 index 00000000..fdf539c9 --- /dev/null +++ b/aiui/packages/app/server/nginx-archy.conf @@ -0,0 +1,61 @@ +# AIUI nginx config for Archipelago deployment +# Include this in your Archy nginx server block. +# +# Prerequisites: +# - AIUI built and placed in /opt/archipelago/web-ui/aiui/ +# - Set $anthropic_api_key in nginx or via env (see below) +# +# Usage in nginx.conf: +# include /opt/archipelago/web-ui/aiui/nginx-archy.conf; + +# Serve AIUI SPA +location /aiui/ { + alias /opt/archipelago/web-ui/aiui/; + try_files $uri $uri/ /aiui/index.html; + + # Cache static assets aggressively + location ~* /aiui/assets/ { + expires 1y; + add_header Cache-Control "public, immutable"; + } +} + +# Proxy Claude API requests from AIUI iframe +# AIUI fetches /api/claude/v1/messages → proxied to Anthropic API +location /aiui/api/claude/ { + # Rewrite: strip /aiui/api/claude prefix, forward to Anthropic + rewrite ^/aiui/api/claude/(.*)$ /$1 break; + + proxy_pass https://api.anthropic.com; + proxy_ssl_server_name on; + proxy_set_header Host api.anthropic.com; + proxy_set_header x-api-key $anthropic_api_key; + proxy_set_header anthropic-version "2023-06-01"; + proxy_set_header Content-Type "application/json"; + + # SSE streaming support + proxy_buffering off; + proxy_cache off; + proxy_read_timeout 300s; + proxy_http_version 1.1; + proxy_set_header Connection ""; + + # Security: only allow from same origin (AIUI iframe) + # The iframe has sandbox="allow-same-origin" so requests come from Archy's origin +} + +# Proxy OpenRouter API requests (optional, for multi-model support) +location /aiui/api/openrouter/ { + rewrite ^/aiui/api/openrouter/(.*)$ /api/v1/chat/completions break; + + proxy_pass https://openrouter.ai; + proxy_ssl_server_name on; + proxy_set_header Host openrouter.ai; + proxy_set_header Content-Type "application/json"; + + proxy_buffering off; + proxy_cache off; + proxy_read_timeout 300s; + proxy_http_version 1.1; + proxy_set_header Connection ""; +} diff --git a/aiui/packages/app/src-tauri/Cargo.toml b/aiui/packages/app/src-tauri/Cargo.toml new file mode 100644 index 00000000..b6558158 --- /dev/null +++ b/aiui/packages/app/src-tauri/Cargo.toml @@ -0,0 +1,21 @@ +[package] +name = "aiui" +version = "0.1.0" +description = "AIUI Desktop Application" +authors = ["AIUI"] +edition = "2021" + +[build-dependencies] +tauri-build = { version = "2", features = [] } + +[dependencies] +tauri = { version = "2", features = [ + "tray-icon", + "global-shortcut", +] } +tauri-plugin-updater = "2" +serde = { version = "1", features = ["derive"] } +serde_json = "1" + +[features] +custom-protocol = ["tauri/custom-protocol"] diff --git a/aiui/packages/app/src-tauri/build.rs b/aiui/packages/app/src-tauri/build.rs new file mode 100644 index 00000000..d860e1e6 --- /dev/null +++ b/aiui/packages/app/src-tauri/build.rs @@ -0,0 +1,3 @@ +fn main() { + tauri_build::build() +} diff --git a/aiui/packages/app/src-tauri/src/main.rs b/aiui/packages/app/src-tauri/src/main.rs new file mode 100644 index 00000000..2bf269cc --- /dev/null +++ b/aiui/packages/app/src-tauri/src/main.rs @@ -0,0 +1,50 @@ +// Prevents additional console window on Windows in release. +#![cfg_attr(not(debug_assertions), windows_subsystem = "windows")] + +use tauri::{ + tray::TrayIconBuilder, + Manager, +}; + +fn main() { + tauri::Builder::default() + .setup(|app| { + // Create system tray + let _tray = TrayIconBuilder::new() + .tooltip("AIUI") + .on_tray_icon_event(|tray, event| { + use tauri::tray::TrayIconEvent; + if let TrayIconEvent::Click { .. } = event { + let app = tray.app_handle(); + if let Some(window) = app.get_webview_window("main") { + let _ = window.show(); + let _ = window.set_focus(); + } + } + }) + .build(app)?; + + // Register global shortcut (Cmd+Shift+A / Ctrl+Shift+A) + #[cfg(target_os = "macos")] + let shortcut = "CommandOrControl+Shift+A"; + #[cfg(not(target_os = "macos"))] + let shortcut = "Ctrl+Shift+A"; + + let app_handle = app.handle().clone(); + app.global_shortcut().on_shortcut(shortcut, move |_app, _shortcut, _event| { + if let Some(window) = app_handle.get_webview_window("main") { + if window.is_visible().unwrap_or(false) { + let _ = window.hide(); + } else { + let _ = window.show(); + let _ = window.set_focus(); + } + } + })?; + + Ok(()) + }) + .plugin(tauri_plugin_updater::Builder::new().build()) + .run(tauri::generate_context!()) + .expect("error while running tauri application"); +} diff --git a/aiui/packages/app/src-tauri/tauri.conf.json b/aiui/packages/app/src-tauri/tauri.conf.json new file mode 100644 index 00000000..7deea1c1 --- /dev/null +++ b/aiui/packages/app/src-tauri/tauri.conf.json @@ -0,0 +1,56 @@ +{ + "$schema": "https://raw.githubusercontent.com/nicolgit/tauri-docs/v2/tooling/cli/schema.json", + "productName": "AIUI", + "version": "0.1.0", + "identifier": "com.aiui.app", + "build": { + "frontendDist": "../dist", + "devUrl": "http://localhost:5173", + "beforeDevCommand": "pnpm dev:app", + "beforeBuildCommand": "pnpm build:app" + }, + "app": { + "windows": [ + { + "title": "AIUI", + "width": 1200, + "height": 800, + "minWidth": 800, + "minHeight": 600, + "decorations": false, + "transparent": true, + "resizable": true, + "center": true + } + ], + "security": { + "csp": "default-src 'self'; script-src 'self'; style-src 'self' 'unsafe-inline'; img-src 'self' data: https:; connect-src 'self' https: wss:" + }, + "trayIcon": { + "iconPath": "icons/icon.png", + "iconAsTemplate": true + } + }, + "bundle": { + "active": true, + "targets": "all", + "icon": [ + "icons/32x32.png", + "icons/128x128.png", + "icons/128x128@2x.png", + "icons/icon.icns", + "icons/icon.ico" + ], + "macOS": { + "minimumSystemVersion": "10.15" + } + }, + "plugins": { + "updater": { + "active": true, + "endpoints": [], + "pubkey": "" + }, + "global-shortcut": {} + } +} diff --git a/aiui/packages/app/src/App.vue b/aiui/packages/app/src/App.vue new file mode 100644 index 00000000..b7fd6d51 --- /dev/null +++ b/aiui/packages/app/src/App.vue @@ -0,0 +1,118 @@ + + + diff --git a/aiui/packages/app/src/__tests__/archyIntegration.test.ts b/aiui/packages/app/src/__tests__/archyIntegration.test.ts new file mode 100644 index 00000000..4f57a3c7 --- /dev/null +++ b/aiui/packages/app/src/__tests__/archyIntegration.test.ts @@ -0,0 +1,285 @@ +/** + * Archy Integration Tests + * + * Tests archyBridge message handling, useArchy composable, + * and ArchyAppsGrid component behavior. + */ +import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest' + +// ═══════════════════════════════════════════════════════════════════ +// archyBridge — postMessage handling +// ═══════════════════════════════════════════════════════════════════ + +describe('archyBridge: postMessage protocol', () => { + let originalParent: Window + let messageHandler: ((event: MessageEvent) => void) | null = null + + beforeEach(() => { + originalParent = window.parent + // Mock window.parent to simulate being in an iframe + Object.defineProperty(window, 'parent', { + value: { + postMessage: vi.fn(), + }, + writable: true, + configurable: true, + }) + // Capture addEventListener calls to grab the message handler + const originalAddEventListener = window.addEventListener.bind(window) + vi.spyOn(window, 'addEventListener').mockImplementation((type: string, handler: any) => { + if (type === 'message') { + messageHandler = handler + } + return originalAddEventListener(type, handler) + }) + }) + + afterEach(() => { + Object.defineProperty(window, 'parent', { + value: originalParent, + writable: true, + configurable: true, + }) + vi.restoreAllMocks() + messageHandler = null + }) + + it('isInArchy returns true when window.parent !== window', async () => { + // Dynamic import to get fresh module state + const { archyBridge } = await import('@/services/archyBridge') + expect(archyBridge.isInArchy()).toBe(true) + }) + + it('init sends ready message to parent', async () => { + const { archyBridge } = await import('@/services/archyBridge') + archyBridge.init() + expect(window.parent.postMessage).toHaveBeenCalledWith( + { type: 'ready' }, + window.location.origin, + ) + archyBridge.destroy() + }) + + it('requestContext sends context:request message', async () => { + const { archyBridge } = await import('@/services/archyBridge') + archyBridge.init() + + // Fire and forget — just verify the message shape + archyBridge.requestContext('apps').catch(() => {}) + + expect(window.parent.postMessage).toHaveBeenCalledWith( + expect.objectContaining({ + type: 'context:request', + category: 'apps', + }), + window.location.origin, + ) + + archyBridge.destroy() + }) + + it('requestAction sends action:request message', async () => { + const { archyBridge } = await import('@/services/archyBridge') + archyBridge.init() + + // Fire and forget + archyBridge.requestAction('open-app', { appId: 'mempool' }).catch(() => {}) + + expect(window.parent.postMessage).toHaveBeenCalledWith( + expect.objectContaining({ + type: 'action:request', + action: 'open-app', + params: { appId: 'mempool' }, + }), + window.location.origin, + ) + + archyBridge.destroy() + }) + + it('requestTheme sends theme:request message', async () => { + const { archyBridge } = await import('@/services/archyBridge') + archyBridge.init() + archyBridge.requestTheme() + + expect(window.parent.postMessage).toHaveBeenCalledWith( + { type: 'theme:request' }, + window.location.origin, + ) + archyBridge.destroy() + }) + + it('onPermissionsUpdate registers and fires callback', async () => { + const { archyBridge } = await import('@/services/archyBridge') + const callback = vi.fn() + const unsubscribe = archyBridge.onPermissionsUpdate(callback) + + // Unsubscribe should be a function + expect(typeof unsubscribe).toBe('function') + unsubscribe() + }) + + it('onThemeUpdate registers and fires callback', async () => { + const { archyBridge } = await import('@/services/archyBridge') + const callback = vi.fn() + const unsubscribe = archyBridge.onThemeUpdate(callback) + + expect(typeof unsubscribe).toBe('function') + unsubscribe() + }) + + it('getPermissions returns empty array initially', async () => { + const { archyBridge } = await import('@/services/archyBridge') + const perms = archyBridge.getPermissions() + expect(Array.isArray(perms)).toBe(true) + }) + + it('getTheme returns null initially', async () => { + const { archyBridge } = await import('@/services/archyBridge') + const theme = archyBridge.getTheme() + // May be null or have been set by a previous test + expect(theme === null || typeof theme === 'object').toBe(true) + }) +}) + +// ═══════════════════════════════════════════════════════════════════ +// useArchy — composable behavior +// ═══════════════════════════════════════════════════════════════════ + +describe('useArchy: composable', () => { + it('exports expected API shape', async () => { + const { useArchy } = await import('@/composables/useArchy') + const archy = useArchy() + + expect(archy).toHaveProperty('isEmbedded') + expect(archy).toHaveProperty('isInitialized') + expect(archy).toHaveProperty('permissions') + expect(archy).toHaveProperty('accentColor') + expect(archy).toHaveProperty('installedApps') + expect(archy).toHaveProperty('systemInfo') + expect(archy).toHaveProperty('networkInfo') + expect(archy).toHaveProperty('walletInfo') + expect(archy).toHaveProperty('fileList') + expect(archy).toHaveProperty('init') + expect(archy).toHaveProperty('destroy') + expect(archy).toHaveProperty('refreshContext') + expect(archy).toHaveProperty('requestAction') + expect(archy).toHaveProperty('buildArchyContext') + }) + + it('buildArchyContext returns empty string when not initialized', async () => { + const { useArchy } = await import('@/composables/useArchy') + const archy = useArchy() + // Not initialized, should return empty + const ctx = archy.buildArchyContext() + expect(typeof ctx).toBe('string') + }) + + it('requestAction returns failure when not initialized', async () => { + const { useArchy } = await import('@/composables/useArchy') + const archy = useArchy() + const result = await archy.requestAction('open-app', { appId: 'test' }) + expect(result).toEqual({ success: false, error: 'Not initialized' }) + }) + + it('isEmbedded and isInitialized are readonly refs', async () => { + const { useArchy } = await import('@/composables/useArchy') + const archy = useArchy() + // Should be refs (have .value) + expect(typeof archy.isEmbedded.value).toBe('boolean') + expect(typeof archy.isInitialized.value).toBe('boolean') + }) +}) + +// ═══════════════════════════════════════════════════════════════════ +// useArchy: buildArchyContext output format +// ═══════════════════════════════════════════════════════════════════ + +describe('useArchy: buildArchyContext format', () => { + it('context string includes wallet info when available', async () => { + // We can't easily set internal state without init, so we test the format expectations + const { useArchy } = await import('@/composables/useArchy') + const archy = useArchy() + // Context should be a string + const ctx = archy.buildArchyContext() + expect(typeof ctx).toBe('string') + }) +}) + +// ═══════════════════════════════════════════════════════════════════ +// archy-apps.ts — data file +// ═══════════════════════════════════════════════════════════════════ + +describe('archy-apps: data integrity', () => { + it('exports ARCHY_APPS array with all major services', async () => { + const { ARCHY_APPS } = await import('@/data/archy-apps') + expect(Array.isArray(ARCHY_APPS)).toBe(true) + expect(ARCHY_APPS.length).toBeGreaterThanOrEqual(15) + + // Verify all required services are present + const ids = ARCHY_APPS.map((a) => a.id) + expect(ids).toContain('bitcoin-core') + expect(ids).toContain('lnd') + expect(ids).toContain('btcpay-server') + expect(ids).toContain('mempool') + expect(ids).toContain('nextcloud') + expect(ids).toContain('immich') + expect(ids).toContain('nostr-rs-relay') + expect(ids).toContain('home-assistant') + expect(ids).toContain('grafana') + expect(ids).toContain('searxng') + expect(ids).toContain('ollama') + expect(ids).toContain('penpot') + expect(ids).toContain('onlyoffice') + expect(ids).toContain('fedimint') + expect(ids).toContain('meshtastic') + }) + + it('each app has required fields', async () => { + const { ARCHY_APPS } = await import('@/data/archy-apps') + for (const app of ARCHY_APPS) { + expect(app.id).toBeTruthy() + expect(app.name).toBeTruthy() + expect(app.description).toBeTruthy() + expect(app.icon).toBeTruthy() + expect(app.category).toBeTruthy() + expect(app.deepLink).toBeTruthy() + expect(app.deepLink.startsWith('/app/')).toBe(true) + } + }) + + it('getArchyApp looks up apps by ID', async () => { + const { getArchyApp } = await import('@/data/archy-apps') + const btc = getArchyApp('bitcoin-core') + expect(btc).toBeDefined() + expect(btc!.name).toBe('Bitcoin Core') + + const missing = getArchyApp('nonexistent-app') + expect(missing).toBeUndefined() + }) + + it('app IDs are unique', async () => { + const { ARCHY_APPS } = await import('@/data/archy-apps') + const ids = ARCHY_APPS.map((a) => a.id) + expect(new Set(ids).size).toBe(ids.length) + }) + + it('deep links follow /app/{id} pattern', async () => { + const { ARCHY_APPS } = await import('@/data/archy-apps') + for (const app of ARCHY_APPS) { + expect(app.deepLink).toBe(`/app/${app.id}`) + } + }) +}) + +// ═══════════════════════════════════════════════════════════════════ +// Base-aware API paths +// ═══════════════════════════════════════════════════════════════════ + +describe('Base-aware API paths', () => { + it('import.meta.env.BASE_URL is defined', () => { + // In Vite test environment, BASE_URL defaults to '/' + expect(typeof import.meta.env.BASE_URL).toBe('string') + expect(import.meta.env.BASE_URL).toBeTruthy() + }) +}) diff --git a/aiui/packages/app/src/__tests__/contentExtraction.test.ts b/aiui/packages/app/src/__tests__/contentExtraction.test.ts new file mode 100644 index 00000000..3b26e87b --- /dev/null +++ b/aiui/packages/app/src/__tests__/contentExtraction.test.ts @@ -0,0 +1,435 @@ +import { describe, it, expect } from 'vitest' +import { + extractAllFilms, + extractAllSongs, + extractAllPodcasts, + extractAllBooks, + extractAllTVSeries, + extractAllPlaces, + extractAllImages, + extractMagazineSections, + extractMagazineHeroImage, + extractBoldDomainLinks, + extractMarkdownLinks, + mergeNewsResults, + stripContentTags, + stripFilmTags, + stripSongTags, + stripPodcastTags, + stripBookTags, + stripTVTags, + stripPlaceTags, + extractFilmIds, + extractSongIds, + extractPodcastIds, +} from '@/composables/contentExtraction' + +// ─── Films ──────────────────────────────────────────────────────── + +describe('extractAllFilms', () => { + it('extracts film_ext tags with title, year, director', () => { + const text = 'Check out [[film_ext:Inception|2010|Christopher Nolan]]' + const films = extractAllFilms(text) + expect(films).toHaveLength(1) + expect(films[0].title).toBe('Inception') + expect(films[0].year).toBe(2010) + expect(films[0].director).toBe('Christopher Nolan') + }) + + it('extracts film:id tags and looks up from library', () => { + const text = 'You should watch [[film:f1]]' + const films = extractAllFilms(text) + expect(films).toHaveLength(1) + expect(films[0].id).toBe('f1') + }) + + it('returns empty array for text with no film tags', () => { + const text = 'Just some text about movies without any tags' + const films = extractAllFilms(text) + expect(films).toHaveLength(0) + }) + + it('handles multiple films in one message', () => { + const text = '[[film_ext:Inception|2010|Christopher Nolan]] and [[film_ext:Interstellar|2014|Christopher Nolan]]' + const films = extractAllFilms(text) + expect(films).toHaveLength(2) + expect(films[0].title).toBe('Inception') + expect(films[1].title).toBe('Interstellar') + }) + + it('handles malformed tags gracefully', () => { + const text = '[[film_ext:]] [[film_ext:Incomplete]] [[film:]]' + const films = extractAllFilms(text) + expect(films).toHaveLength(0) + }) + + it('deduplicates films by title and year', () => { + const text = '[[film_ext:Inception|2010|Nolan]] and again [[film_ext:Inception|2010|Nolan]]' + const films = extractAllFilms(text) + expect(films).toHaveLength(1) + }) + + it('normalizes film IDs with or without f prefix', () => { + const ids1 = extractFilmIds('[[film:1]] [[film:f2]]') + expect(ids1).toEqual(['f1', 'f2']) + }) +}) + +// ─── Songs ──────────────────────────────────────────────────────── + +describe('extractAllSongs', () => { + it('extracts song_ext tags with title, artist, year', () => { + const text = '[[song_ext:Bohemian Rhapsody|Queen|1975]]' + const songs = extractAllSongs(text) + expect(songs).toHaveLength(1) + expect(songs[0].title).toBe('Bohemian Rhapsody') + expect(songs[0].artist).toBe('Queen') + expect(songs[0].year).toBe(1975) + }) + + it('extracts song_ext without year', () => { + const text = '[[song_ext:Paranoid Android|Radiohead]]' + const songs = extractAllSongs(text) + expect(songs).toHaveLength(1) + expect(songs[0].title).toBe('Paranoid Android') + expect(songs[0].artist).toBe('Radiohead') + expect(songs[0].year).toBeUndefined() + }) + + it('extracts song:id tags from library', () => { + const text = 'Listen to [[song:s1]]' + const songs = extractAllSongs(text) + expect(songs).toHaveLength(1) + expect(songs[0].id).toBe('s1') + }) + + it('deduplicates songs by title+artist', () => { + const text = '[[song_ext:Creep|Radiohead]] again [[song_ext:Creep|Radiohead]]' + const songs = extractAllSongs(text) + expect(songs).toHaveLength(1) + }) + + it('returns empty for text with film tags (not music)', () => { + const text = '[[film_ext:Inception|2010|Nolan]] great movie' + const songs = extractAllSongs(text) + expect(songs).toHaveLength(0) + }) + + it('filters out non-song content using looksLikeSong', () => { + const text = '[[song_ext:Latest News|Web Search]]' + const songs = extractAllSongs(text) + expect(songs).toHaveLength(0) + }) + + it('normalizes song IDs with or without s prefix', () => { + const ids = extractSongIds('[[song:1]] [[song:s2]]') + expect(ids).toEqual(['s1', 's2']) + }) +}) + +// ─── Podcasts ───────────────────────────────────────────────────── + +describe('extractAllPodcasts', () => { + it('extracts podcast_ext tags', () => { + const text = '[[podcast_ext:Bitcoin Audible|Guy Swann|2018]]' + const podcasts = extractAllPodcasts(text) + expect(podcasts).toHaveLength(1) + expect(podcasts[0].title).toBe('Bitcoin Audible') + expect(podcasts[0].host).toBe('Guy Swann') + expect(podcasts[0].year).toBe(2018) + }) + + it('extracts podcast:id from library', () => { + const text = '[[podcast:p1]]' + const podcasts = extractAllPodcasts(text) + expect(podcasts).toHaveLength(1) + expect(podcasts[0].id).toBe('p1') + }) + + it('returns empty for no podcast tags', () => { + const text = 'No podcasts here' + const podcasts = extractAllPodcasts(text) + expect(podcasts).toHaveLength(0) + }) + + it('filters out non-podcast titles', () => { + const text = '[[podcast_ext:Bitcoin Mailing List|GitHub]]' + const podcasts = extractAllPodcasts(text) + expect(podcasts).toHaveLength(0) + }) + + it('normalizes podcast IDs', () => { + const ids = extractPodcastIds('[[podcast:1]] [[podcast:p2]]') + expect(ids).toEqual(['p1', 'p2']) + }) +}) + +// ─── Books ──────────────────────────────────────────────────────── + +describe('extractAllBooks', () => { + it('extracts book_ext tags with title, author, year', () => { + const text = '[[book_ext:The Bitcoin Standard|Saifedean Ammous|2018]]' + const books = extractAllBooks(text, 'best bitcoin books') + expect(books).toHaveLength(1) + expect(books[0].title).toBe('The Bitcoin Standard') + expect(books[0].author).toBe('Saifedean Ammous') + expect(books[0].year).toBe(2018) + }) + + it('handles optional year field', () => { + const text = '[[book_ext:Mastering Bitcoin|Andreas Antonopoulos]]' + const books = extractAllBooks(text, 'bitcoin books') + expect(books).toHaveLength(1) + expect(books[0].year).toBeUndefined() + }) + + it('returns empty when no book tags and not book query', () => { + const text = 'Just talking about tech' + const books = extractAllBooks(text, 'what is javascript') + expect(books).toHaveLength(0) + }) + + it('extracts multiple books', () => { + const text = '[[book_ext:Book One|Author A|2020]] and [[book_ext:Book Two|Author B|2021]]' + const books = extractAllBooks(text, 'books') + expect(books).toHaveLength(2) + }) +}) + +// ─── TV Series ──────────────────────────────────────────────────── + +describe('extractAllTVSeries', () => { + it('extracts tv_ext tags', () => { + const text = '[[tv_ext:Breaking Bad|Vince Gilligan|2008]]' + const series = extractAllTVSeries(text, 'best tv shows') + expect(series).toHaveLength(1) + expect(series[0].title).toBe('Breaking Bad') + expect(series[0].creator).toBe('Vince Gilligan') + expect(series[0].year).toBe(2008) + }) + + it('parses creator field', () => { + const text = '[[tv_ext:The Wire|David Simon|2002]]' + const series = extractAllTVSeries(text, 'tv shows') + expect(series).toHaveLength(1) + expect(series[0].creator).toBe('David Simon') + }) + + it('returns empty when no TV tags and not TV query', () => { + const text = 'Some random text' + const series = extractAllTVSeries(text, 'cooking recipe') + expect(series).toHaveLength(0) + }) + + it('handles tv_ext without year', () => { + const text = '[[tv_ext:The Sopranos|David Chase]]' + const series = extractAllTVSeries(text, 'tv') + expect(series).toHaveLength(1) + expect(series[0].year).toBeUndefined() + }) +}) + +// ─── Places ─────────────────────────────────────────────────────── + +describe('extractAllPlaces', () => { + it('extracts place_ext tags with all fields', () => { + const text = '[[place_ext:Joe\'s Pizza|Pizza|New York|4.5|2|123 Main St]]' + const places = extractAllPlaces(text, 'pizza near me') + expect(places).toHaveLength(1) + expect(places[0].name).toBe("Joe's Pizza") + expect(places[0].cuisine).toBe('Pizza') + expect(places[0].city).toBe('New York') + expect(places[0].rating).toBe(4.5) + expect(places[0].priceLevel).toBe(2) + expect(places[0].address).toBe('123 Main St') + }) + + it('handles missing optional fields (rating, price)', () => { + const text = '[[place_ext:The Diner|American]]' + const places = extractAllPlaces(text, 'restaurants') + expect(places).toHaveLength(1) + expect(places[0].name).toBe('The Diner') + expect(places[0].cuisine).toBe('American') + expect(places[0].rating).toBeUndefined() + expect(places[0].priceLevel).toBeUndefined() + }) + + it('returns empty for non-place queries without tags', () => { + const text = 'Nothing about restaurants here' + const places = extractAllPlaces(text, 'what is bitcoin') + expect(places).toHaveLength(0) + }) +}) + +// ─── Images ─────────────────────────────────────────────────────── + +describe('extractAllImages', () => { + it('extracts markdown image syntax', () => { + const text = '![alt text](https://example.com/image.jpg)' + const images = extractAllImages(text, 'show me images') + expect(images).toHaveLength(1) + expect(images[0].url).toBe('https://example.com/image.jpg') + expect(images[0].alt).toBe('alt text') + }) + + it('extracts bare image URLs', () => { + const text = 'Check this: https://example.com/photo.png and https://example.com/pic.webp' + const images = extractAllImages(text, 'images') + expect(images).toHaveLength(2) + }) + + it('returns empty when not an image query and only one image', () => { + const text = 'https://example.com/photo.jpg' + const images = extractAllImages(text, 'what is bitcoin') + expect(images).toHaveLength(0) + }) +}) + +// ─── Magazine Sections ──────────────────────────────────────────── + +describe('extractMagazineSections', () => { + it('extracts sections from markdown headings', () => { + const text = `## First Section +This is the content of the first section with enough text to pass the minimum length filter. + +## Second Section +This is the content of the second section, also with enough detail to be meaningful.` + + const sections = extractMagazineSections(text) + expect(sections.length).toBeGreaterThanOrEqual(2) + const titles = sections.map(s => s.title) + expect(titles).toContain('First Section') + expect(titles).toContain('Second Section') + }) + + it('captures content between headings', () => { + const text = `## Market Update +Bitcoin surged to new highs today as institutional demand increased significantly and retail sentiment improved. + +## Analysis +Analysts believe the trend will continue through the end of the quarter as macro conditions stabilize.` + + const sections = extractMagazineSections(text) + const marketSection = sections.find(s => s.title === 'Market Update') + expect(marketSection).toBeDefined() + expect(marketSection!.content).toContain('Bitcoin surged') + }) + + it('extracts hero images', () => { + const text = 'Some text with ![hero](https://example.com/hero.jpg) embedded' + const hero = extractMagazineHeroImage(text) + expect(hero).toBe('https://example.com/hero.jpg') + }) + + it('returns undefined for no images', () => { + const hero = extractMagazineHeroImage('No images here') + expect(hero).toBeUndefined() + }) + + it('extracts sections from numbered lists with bold titles', () => { + const text = `Here are the key developments: + +1. **Strong price recovery** — Bitcoin climbed back above $60,000 as market confidence returned. +2. **Institutional adoption grows** — Major banks announced new crypto custody services for their clients. +3. **Regulatory clarity emerges** — New framework provides guidelines for digital asset companies.` + + const sections = extractMagazineSections(text) + expect(sections.length).toBeGreaterThanOrEqual(3) + const titles = sections.map(s => s.title) + expect(titles).toContain('Strong price recovery') + }) + + it('handles empty or short text', () => { + const sections = extractMagazineSections('') + expect(sections).toHaveLength(0) + }) +}) + +// ─── Tag Stripping ──────────────────────────────────────────────── + +describe('stripContentTags', () => { + it('removes all tag types from text', () => { + const text = 'Watch [[film:f1]] and listen to [[song:s1]] and read [[book_ext:Title|Author|2020]]' + const stripped = stripContentTags(text) + expect(stripped).not.toContain('[[film:') + expect(stripped).not.toContain('[[song:') + expect(stripped).not.toContain('[[book_ext:') + }) + + it('preserves non-tag content', () => { + const text = 'Watch this great movie [[film:f1]] and enjoy' + const stripped = stripContentTags(text) + expect(stripped).toContain('Watch this great movie') + expect(stripped).toContain('and enjoy') + }) + + it('handles adjacent tags', () => { + const text = '[[film:f1]][[song:s1]][[podcast:p1]]' + const stripped = stripContentTags(text) + expect(stripped).toBe('') + }) + + it('strips all specific tag types individually', () => { + expect(stripFilmTags('[[film:f1]] [[film_ext:Title|2020|Dir]]')).toBe('') + expect(stripSongTags('[[song:s1]] [[song_ext:Title|Artist]]')).toBe('') + expect(stripPodcastTags('[[podcast:p1]] [[podcast_ext:Title|Host]]')).toBe('') + expect(stripBookTags('[[book_ext:Title|Author|2020]]')).toBe('') + expect(stripTVTags('[[tv_ext:Title|Creator|2020]]')).toBe('') + expect(stripPlaceTags('[[place_ext:Name|Cuisine]]')).toBe('') + }) +}) + +// ─── Links Extraction ───────────────────────────────────────────── + +describe('extractBoldDomainLinks', () => { + it('extracts **domain.com** patterns with URLs', () => { + const text = '**CoinDesk** (coindesk.com) — the best source' + const links = extractBoldDomainLinks(text) + expect(links).toHaveLength(1) + expect(links[0].title).toBe('CoinDesk') + expect(links[0].url).toBe('https://coindesk.com') + }) + + it('deduplicates URLs', () => { + const text = '**CoinDesk** (coindesk.com) and **CoinDesk News** (coindesk.com)' + const links = extractBoldDomainLinks(text) + expect(links).toHaveLength(1) + }) +}) + +describe('extractMarkdownLinks', () => { + it('extracts markdown links', () => { + const text = 'Check out [Bitcoin](https://bitcoin.org) for more' + const links = extractMarkdownLinks(text) + expect(links).toHaveLength(1) + expect(links[0].title).toBe('Bitcoin') + expect(links[0].url).toBe('https://bitcoin.org') + }) + + it('handles multiple links', () => { + const text = '[Link 1](https://example.com) and [Link 2](https://example.org/page)' + const links = extractMarkdownLinks(text) + expect(links).toHaveLength(2) + }) + + it('skips invalid URLs', () => { + const text = '[Bad Link](not-a-url)' + const links = extractMarkdownLinks(text) + expect(links).toHaveLength(0) + }) +}) + +describe('mergeNewsResults', () => { + it('merges web results with text-extracted results', () => { + const web = [{ title: 'Web A', url: 'https://a.com', content: 'content a' }] + const fromText = [ + { title: 'Text B', url: 'https://b.com', content: undefined }, + { title: 'Text A Dup', url: 'https://a.com', content: undefined }, + ] + const merged = mergeNewsResults(web, fromText) + expect(merged).toHaveLength(2) + // Web result takes priority for same URL + const aResult = merged.find(r => r.url.includes('a.com')) + expect(aResult!.title).toBe('Web A') + }) +}) diff --git a/aiui/packages/app/src/__tests__/extractionQuality.test.ts b/aiui/packages/app/src/__tests__/extractionQuality.test.ts new file mode 100644 index 00000000..4b2177ee --- /dev/null +++ b/aiui/packages/app/src/__tests__/extractionQuality.test.ts @@ -0,0 +1,1137 @@ +/** + * Extraction Quality Tests + * + * Tests real-world AI response patterns to verify content surfacing. + * Each test simulates a user query + AI response and checks that + * the right content types are extracted with correct data. + */ +import { describe, it, expect } from 'vitest' +import { + extractAllFilms, + extractAllSongs, + extractAllPodcasts, + extractAllBooks, + extractAllTVSeries, + extractAllPlaces, + extractAllImages, + extractCodeBlocks, + extractApps, +} from '@/composables/contentExtraction' +import { + extractMagazineSections, + extractMarkdownLinks, + extractBoldDomainLinks, + extractBareDomainLinks, +} from '@/composables/contentExtraction' +import { + isBookQuery, isBookLikeResponse, + isTVQuery, isPlaceQuery, isPlaceLikeResponse, + isMusicQuery, isCodeQuery, isCodeLikeResponse, + isNewsQuery, isNewsLikeResponse, isWebsitesQuery, isAppQuery, + isNostrQuery, isNostrLikeResponse, + isAppLikeResponse, isImageQuery, + filterTabsByContext, preferredFirstTab, +} from '@/composables/contentFiltering' + +// ─── Helper: simulate full pipeline ───────────────────────────── + +function extractAll(text: string, userQuery: string) { + const films = extractAllFilms(text) + const songs = extractAllSongs(text, userQuery) + const podcasts = extractAllPodcasts(text) + const books = extractAllBooks(text, userQuery) + const tvSeries = extractAllTVSeries(text, userQuery) + const images = extractAllImages(text, userQuery) + const places = extractAllPlaces(text, userQuery) + const codeBlocks = extractCodeBlocks(text) + const apps = extractApps(text, userQuery) + return { films, songs, podcasts, books, tvSeries, images, places, codeBlocks, apps } +} + +// ═══════════════════════════════════════════════════════════════════ +// BOOKS — pattern-based extraction +// ═══════════════════════════════════════════════════════════════════ + +describe('Books: pattern extraction from AI responses', () => { + it('extracts books from "Title by Author" format', () => { + const text = `Here are some essential Bitcoin books: + +The Bitcoin Standard by Saifedean Ammous is a great starting point. It covers the history of money and why Bitcoin matters. + +You might also enjoy Mastering Bitcoin by Andreas Antonopoulos for the technical side.` + const books = extractAllBooks(text, 'recommend bitcoin books') + expect(books.length).toBeGreaterThanOrEqual(2) + expect(books.some(b => b.title.includes('Bitcoin Standard'))).toBe(true) + expect(books.some(b => b.title.includes('Mastering Bitcoin'))).toBe(true) + }) + + it('extracts books from numbered list with bold and "by"', () => { + const text = `Top books on money: + +1. **The Bitcoin Standard** by Saifedean Ammous +2. **The Fiat Standard** by Saifedean Ammous +3. **Broken Money** by Lyn Alden +4. **The Price of Tomorrow** by Jeff Booth` + const books = extractAllBooks(text, 'books about money') + expect(books.length).toBeGreaterThanOrEqual(4) + }) + + it('extracts books from em-dash format', () => { + const text = `Essential reads: + +- The Sovereign Individual — James Dale Davidson (1997) +- The Bitcoin Standard — Saifedean Ammous (2018) +- Broken Money — Lyn Alden (2023)` + const books = extractAllBooks(text, 'book recommendations') + expect(books.length).toBeGreaterThanOrEqual(2) + }) + + it('single "by Author" triggers isBookLikeResponse', () => { + const text = 'I highly recommend The Bitcoin Standard by Saifedean Ammous. It is an excellent book on monetary theory.' + expect(isBookLikeResponse(text)).toBe(true) + }) + + it('isBookQuery matches common book queries', () => { + expect(isBookQuery('best books about bitcoin')).toBe(true) + expect(isBookQuery('what should I read')).toBe(true) + expect(isBookQuery('recommend me a novel')).toBe(true) + expect(isBookQuery('any good nonfiction books')).toBe(true) + }) + + it('does not extract books from film responses', () => { + const text = '[[film_ext:Inception|2010|Christopher Nolan]] is great. Directed by Christopher Nolan.' + const books = extractAllBooks(text, 'what is inception') + expect(books).toHaveLength(0) + }) +}) + +// ═══════════════════════════════════════════════════════════════════ +// TV SERIES — pattern-based extraction +// ═══════════════════════════════════════════════════════════════════ + +describe('TV Series: pattern extraction from AI responses', () => { + it('extracts TV shows from numbered list with years', () => { + const text = `Best TV dramas of all time: + +1. **Breaking Bad** (2008–2013) +2. **The Wire** (2002–2008) +3. **The Sopranos** (1999–2007) +4. **Better Call Saul** (2015–2022)` + const series = extractAllTVSeries(text, 'best tv shows') + expect(series.length).toBeGreaterThanOrEqual(4) + expect(series.some(s => s.title.includes('Breaking Bad'))).toBe(true) + }) + + it('extracts shows with "N seasons" format', () => { + const text = `Some great binge watches: + +**Breaking Bad** — 5 seasons of incredible storytelling +**Better Call Saul** — 6 seasons, a worthy prequel` + const series = extractAllTVSeries(text, 'what should I binge') + expect(series.length).toBeGreaterThanOrEqual(2) + }) + + it('isTVLikeResponse triggers with single season mention', () => { + const text = 'Breaking Bad ran for 5 seasons on AMC and is widely considered one of the best TV dramas ever made.' + expect(isTVQuery('best tv shows')).toBe(true) + // Should pass with just 1 "season" mention now + const hasKeyword = /\b(season|episodes?|showrunner|streaming|renewed|cancelled|premiere|network|HBO|Netflix|AMC)\b/i.test(text) + expect(hasKeyword).toBe(true) + }) + + it('does not extract TV from non-TV contexts', () => { + const text = 'Bitcoin has seen a new season of adoption. The network grows stronger.' + const series = extractAllTVSeries(text, 'what is bitcoin') + expect(series).toHaveLength(0) + }) +}) + +// ═══════════════════════════════════════════════════════════════════ +// PLACES — pattern-based extraction +// ═══════════════════════════════════════════════════════════════════ + +describe('Places: pattern extraction from AI responses', () => { + it('extracts places with explicit type words', () => { + const text = `Best pizza in New York: + +1. **Joe's Pizza** — classic New York pizza joint since 1975 +2. **Di Fara Pizza** — legendary Brooklyn pizzeria` + const places = extractAllPlaces(text, 'best pizza in new york') + expect(places.length).toBeGreaterThanOrEqual(1) + }) + + it('extracts places from bold + description without type word (place query)', () => { + const text = `Great dinner spots in Austin: + +1. **Franklin Barbecue** — World-famous brisket, expect a long line but worth every minute +2. **Uchi** — Innovative Japanese cuisine with a Texas twist +3. **Launderette** — New American comfort food in a converted laundromat` + const places = extractAllPlaces(text, 'where to eat in austin') + expect(places.length).toBeGreaterThanOrEqual(3) + }) + + it('isPlaceQuery matches food/restaurant queries', () => { + expect(isPlaceQuery('best restaurants in london')).toBe(true) + expect(isPlaceQuery('where to eat in tokyo')).toBe(true) + expect(isPlaceQuery('good brunch spots')).toBe(true) + expect(isPlaceQuery('I am hungry')).toBe(true) + }) + + it('does not extract places from non-place contexts', () => { + const text = '**Bitcoin** — A peer-to-peer electronic cash system' + const places = extractAllPlaces(text, 'what is bitcoin') + expect(places).toHaveLength(0) + }) +}) + +// ═══════════════════════════════════════════════════════════════════ +// SONGS — coexistence with other content types +// ═══════════════════════════════════════════════════════════════════ + +describe('Songs: coexistence with other content', () => { + it('returns explicit song tags even when film tags present', () => { + const text = `Great movie with an amazing soundtrack: +[[film_ext:Guardians of the Galaxy|2014|James Gunn]] + +Featured songs: +[[song_ext:Hooked on a Feeling|Blue Swede|1974]] +[[song_ext:Come and Get Your Love|Redbone|1974]]` + const songs = extractAllSongs(text, 'guardians of the galaxy soundtrack') + expect(songs.length).toBeGreaterThanOrEqual(2) + }) + + it('returns explicit song tags even when book tags present', () => { + const text = `[[book_ext:Norwegian Wood|Haruki Murakami|1987]] + +The title references the Beatles song: +[[song_ext:Norwegian Wood|The Beatles|1965]]` + const songs = extractAllSongs(text, 'tell me about norwegian wood') + expect(songs.length).toBeGreaterThanOrEqual(1) + expect(songs[0].title).toBe('Norwegian Wood') + }) + + it('skips pattern-based songs when film tags present (no explicit song tags)', () => { + const text = `[[film_ext:Inception|2010|Christopher Nolan]] + +Great film. The music by Hans Zimmer is amazing — "Time" is iconic.` + const songs = extractAllSongs(text, 'tell me about inception') + expect(songs).toHaveLength(0) + }) + + it('extracts songs for music queries', () => { + const text = `Here are some great rock songs: + +"Bohemian Rhapsody" by Queen is a masterpiece. +"Stairway to Heaven" by Led Zeppelin is another classic.` + const songs = extractAllSongs(text, 'best rock songs') + expect(songs.length).toBeGreaterThanOrEqual(1) + }) +}) + +// ═══════════════════════════════════════════════════════════════════ +// CODE BLOCKS — extraction and classification +// ═══════════════════════════════════════════════════════════════════ + +describe('Code blocks: extraction and classification', () => { + it('extracts fenced code blocks with language', () => { + const text = `Here's a simple function: + +\`\`\`typescript +function greet(name: string): string { + return \`Hello, \${name}!\` +} +\`\`\` + +And here's the Python version: + +\`\`\`python +def greet(name: str) -> str: + return f"Hello, {name}!" +\`\`\` +` + const blocks = extractCodeBlocks(text) + expect(blocks).toHaveLength(2) + expect(blocks[0].language).toBe('typescript') + expect(blocks[1].language).toBe('python') + expect(blocks[0].code).toContain('function greet') + }) + + it('extracts code blocks without language specifier', () => { + const text = `Run this command: + +\`\`\` +npm install +\`\`\` +` + const blocks = extractCodeBlocks(text) + expect(blocks).toHaveLength(1) + expect(blocks[0].language).toBe('text') + }) + + it('extracts label from preceding heading', () => { + const text = `**Setup Script** +\`\`\`bash +#!/bin/bash +echo "Setting up..." +\`\`\` +` + const blocks = extractCodeBlocks(text) + expect(blocks).toHaveLength(1) + expect(blocks[0].label).toBe('Setup Script') + }) + + it('isCodeQuery matches programming queries', () => { + expect(isCodeQuery('write me a function to sort an array')).toBe(true) + expect(isCodeQuery('how to implement binary search in python')).toBe(true) + expect(isCodeQuery('debug this javascript error')).toBe(true) + expect(isCodeQuery('best typescript libraries')).toBe(true) + }) + + it('isCodeQuery does not match non-code queries', () => { + expect(isCodeQuery('best movies of 2024')).toBe(false) + expect(isCodeQuery('recommend restaurants in paris')).toBe(false) + }) + + it('isCodeLikeResponse triggers with 3+ code blocks', () => { + const text = '```js\na\n```\n```py\nb\n```\n```go\nc\n```' + expect(isCodeLikeResponse(text)).toBe(true) + }) + + it('isCodeLikeResponse does not trigger with 1-2 blocks', () => { + const text = '```js\na\n```\nSome text\n```py\nb\n```' + expect(isCodeLikeResponse(text)).toBe(false) + }) +}) + +// ═══════════════════════════════════════════════════════════════════ +// IMAGES — threshold behavior +// ═══════════════════════════════════════════════════════════════════ + +describe('Images: threshold and alt text', () => { + it('returns single image for image queries', () => { + const text = '![A sunset](https://example.com/sunset.jpg)' + const images = extractAllImages(text, 'show me a sunset image') + expect(images).toHaveLength(1) + }) + + it('returns single image with meaningful alt text', () => { + const text = '![Bitcoin price chart for 2024](https://example.com/chart.png)' + const images = extractAllImages(text, 'bitcoin price') + expect(images).toHaveLength(1) + }) + + it('skips single image without alt text for non-image query', () => { + const text = 'https://example.com/random.jpg' + const images = extractAllImages(text, 'what is bitcoin') + expect(images).toHaveLength(0) + }) + + it('returns 2+ images for any query', () => { + const text = 'https://example.com/a.jpg https://example.com/b.png' + const images = extractAllImages(text, 'what is bitcoin') + expect(images).toHaveLength(2) + }) +}) + +// ═══════════════════════════════════════════════════════════════════ +// TAB FILTERING — comprehensive routing +// ═══════════════════════════════════════════════════════════════════ + +describe('Tab filtering: correct tabs surfaced', () => { + it('surfaces book tab for book query with books', () => { + const tabs = filterTabsByContext('best bitcoin books', false, false, false, true, false, false, false, false, false, false, false, false, false) + expect(tabs).toContain('book') + }) + + it('surfaces code tab for code query with code', () => { + const tabs = filterTabsByContext('write a typescript function', false, false, false, false, false, false, false, false, false, false, false, false, true) + expect(tabs).toContain('code') + expect(tabs[0]).toBe('code') // should be first + }) + + it('surfaces multiple tabs when multiple content types present', () => { + const tabs = filterTabsByContext('movies and music', true, true, false, false, false, false, false, false, false, false, false, false, false) + expect(tabs).toContain('film') + expect(tabs).toContain('song') + }) + + it('surfaces place tab for restaurant query', () => { + const tabs = filterTabsByContext('best restaurants in london', false, false, false, false, false, false, true, false, false, false, false, false, false) + expect(tabs).toContain('place') + expect(tabs[0]).toBe('place') + }) + + it('surfaces TV tab for TV query', () => { + const tabs = filterTabsByContext('best tv shows to binge', false, false, false, false, true, false, false, false, false, false, false, false, false) + expect(tabs).toContain('tvshow') + }) +}) + +// ═══════════════════════════════════════════════════════════════════ +// FULL PIPELINE — realistic AI responses +// ═══════════════════════════════════════════════════════════════════ + +describe('Full pipeline: realistic AI responses', () => { + it('book recommendation response', () => { + const query = 'recommend some books about bitcoin and economics' + const response = `Here are my top recommendations: + +1. **The Bitcoin Standard** by Saifedean Ammous — The definitive work on why Bitcoin matters for sound money. +2. **Broken Money** by Lyn Alden — A thorough analysis of the monetary system and how Bitcoin fits in. +3. **The Fiat Standard** by Saifedean Ammous — The sequel exploring the fiat monetary system. +4. **The Price of Tomorrow** by Jeff Booth — How deflation will shape the future economy. +5. **Mastering Bitcoin** by Andreas Antonopoulos — Technical deep-dive into how Bitcoin works. + +Each of these books offers a unique perspective on money, technology, and economics.` + + const result = extractAll(response, query) + expect(result.books.length).toBeGreaterThanOrEqual(4) + expect(result.films).toHaveLength(0) + expect(result.songs).toHaveLength(0) + }) + + it('restaurant recommendation response', () => { + const query = 'best sushi in tokyo' + const response = `Here are Tokyo's finest sushi restaurants: + +1. **Sukiyabashi Jiro** — The legendary 3-Michelin-star omakase experience in Ginza +2. **Sushi Saito** — Another 3-star establishment known for exquisite nigiri +3. **Sushi Yoshitake** — Intimate counter seating with seasonal specialties +4. **Kyubey** — Classic Ginza sushi since 1935, welcoming to tourists` + + const result = extractAll(response, query) + expect(result.places.length).toBeGreaterThanOrEqual(3) + expect(result.books).toHaveLength(0) + }) + + it('coding response with multiple code blocks', () => { + const query = 'implement binary search in typescript' + const response = `Here's a binary search implementation: + +**Iterative approach** +\`\`\`typescript +function binarySearch(arr: number[], target: number): number { + let left = 0 + let right = arr.length - 1 + while (left <= right) { + const mid = Math.floor((left + right) / 2) + if (arr[mid] === target) return mid + if (arr[mid] < target) left = mid + 1 + else right = mid - 1 + } + return -1 +} +\`\`\` + +**Recursive approach** +\`\`\`typescript +function binarySearchRecursive(arr: number[], target: number, left = 0, right = arr.length - 1): number { + if (left > right) return -1 + const mid = Math.floor((left + right) / 2) + if (arr[mid] === target) return mid + if (arr[mid] < target) return binarySearchRecursive(arr, target, mid + 1, right) + return binarySearchRecursive(arr, target, left, mid - 1) +} +\`\`\` + +**Usage** +\`\`\`typescript +const arr = [1, 3, 5, 7, 9, 11] +console.log(binarySearch(arr, 7)) // 3 +console.log(binarySearchRecursive(arr, 7)) // 3 +\`\`\` +` + const result = extractAll(response, query) + expect(result.codeBlocks.length).toBeGreaterThanOrEqual(3) + expect(result.codeBlocks[0].language).toBe('typescript') + expect(result.codeBlocks[0].label).toBe('Iterative approach') + expect(isCodeQuery(query)).toBe(true) + expect(isCodeLikeResponse(response)).toBe(true) + }) + + it('TV show recommendation response', () => { + const query = 'best tv series of all time' + const response = `Here are the greatest TV series ever made: + +1. **Breaking Bad** (2008–2013) — A chemistry teacher turned drug lord. 5 seasons of perfect television. +2. **The Wire** (2002–2008) — A sprawling look at Baltimore's institutions. 5 seasons. +3. **The Sopranos** (1999–2007) — The show that started the golden age of TV. 6 seasons. +4. **Mad Men** (2007–2015) — 1960s advertising world, beautifully crafted. 7 seasons. +5. **Chernobyl** (2019) — A devastating miniseries about the nuclear disaster.` + + const result = extractAll(response, query) + expect(result.tvSeries.length).toBeGreaterThanOrEqual(4) + expect(result.films).toHaveLength(0) + }) + + it('mixed film and song tags coexist', () => { + const query = 'tell me about the guardians of the galaxy soundtrack' + const response = `Guardians of the Galaxy has one of the best movie soundtracks: + +[[film_ext:Guardians of the Galaxy|2014|James Gunn]] + +The "Awesome Mix Vol. 1" features: +[[song_ext:Hooked on a Feeling|Blue Swede|1974]] +[[song_ext:Come and Get Your Love|Redbone|1974]] +[[song_ext:Spirit in the Sky|Norman Greenbaum|1969]] +[[song_ext:Escape (The Piña Colada Song)|Rupert Holmes|1979]]` + + const result = extractAll(response, query) + expect(result.films.length).toBeGreaterThanOrEqual(1) + expect(result.songs.length).toBeGreaterThanOrEqual(4) + }) +}) + +// ═══════════════════════════════════════════════════════════════════ +// EDGE CASES — tricky patterns that commonly fail +// ═══════════════════════════════════════════════════════════════════ + +describe('Edge cases: commonly failing patterns', () => { + it('does not false-positive books from link markdown', () => { + const text = 'Check out [The Bitcoin Standard](https://example.com) by visiting the website.' + const books = extractAllBooks(text, 'bitcoin resources') + // Should not extract "The Bitcoin Standard" as a book from a markdown link + expect(books.every(b => !b.title.includes('Bitcoin Standard'))).toBe(true) + }) + + it('does not extract place names from non-place bold text', () => { + const text = `Key Bitcoin concepts: + +1. **Proof of Work** — The consensus mechanism that secures Bitcoin +2. **Hash Rate** — The computational power of the network` + const places = extractAllPlaces(text, 'explain bitcoin') + expect(places).toHaveLength(0) + }) + + it('handles quoted titles in book extraction', () => { + const text = '"The Bitcoin Standard" by Saifedean Ammous is essential reading for understanding sound money.' + const books = extractAllBooks(text, 'what books should I read about bitcoin') + expect(books.length).toBeGreaterThanOrEqual(1) + }) + + it('does not extract TV series from Bitcoin season references', () => { + const text = 'This is a new season for Bitcoin adoption. The network hashrate reached new highs.' + const series = extractAllTVSeries(text, 'bitcoin news') + expect(series).toHaveLength(0) + }) + + it('extracts books from smart/curly quotes', () => { + const text = '\u201CThe Bitcoin Standard\u201D by Saifedean Ammous is a must-read.' + const books = extractAllBooks(text, 'bitcoin books') + expect(books.length).toBeGreaterThanOrEqual(1) + expect(books[0].title).toContain('Bitcoin Standard') + }) + + it('extracts places when query mentions pizza', () => { + expect(isPlaceQuery('best pizza near me')).toBe(true) + expect(isPlaceQuery('pizza recommendations')).toBe(true) + }) + + it('does not false-positive songs from film director "by" pattern', () => { + const text = '[[film_ext:Inception|2010|Christopher Nolan]]\n\nInception, directed by Christopher Nolan, is a mind-bending thriller.' + const songs = extractAllSongs(text, 'tell me about inception') + expect(songs).toHaveLength(0) + }) + + it('handles books with single-word titles in bold', () => { + const text = '1. **Sapiens** by Yuval Noah Harari — A brief history of humankind' + const books = extractAllBooks(text, 'best nonfiction books') + expect(books.length).toBeGreaterThanOrEqual(1) + }) + + it('extracts multiple places from varied formatting', () => { + const query = 'where to eat in paris' + const text = `Top restaurants in Paris: + +1. **Le Comptoir du Panth\u00e9on** \u2014 Classic French bistro with incredible steak frites +2. **Chez Janou** \u2014 Famous for its chocolate mousse, cozy Proven\u00e7al atmosphere +3. **L'As du Fallafel** \u2014 Best falafel in the Marais, always a queue` + const places = extractAllPlaces(text, query) + expect(places.length).toBeGreaterThanOrEqual(3) + }) + + it('extracts TV shows from "created by" format', () => { + const text = `1. **Breaking Bad** (2008\u20132013) \u2014 Created by Vince Gilligan. A chemistry teacher becomes a drug lord. +2. **The Wire** (2002\u20132008) \u2014 Created by David Simon. A deep look at Baltimore institutions.` + const series = extractAllTVSeries(text, 'best tv dramas') + expect(series.length).toBeGreaterThanOrEqual(2) + }) + + it('does not extract books when response is about films', () => { + const text = `Great sci-fi films: + +1. **Blade Runner** (1982) \u2014 Directed by Ridley Scott +2. **2001: A Space Odyssey** (1968) \u2014 Directed by Stanley Kubrick` + const books = extractAllBooks(text, 'best sci-fi movies') + expect(books).toHaveLength(0) + }) + + it('extracts code blocks with varied languages', () => { + const text = `**HTML** +\`\`\`html +
Hello
+\`\`\` + +**CSS** +\`\`\`css +.container { color: red; } +\`\`\` + +**JavaScript** +\`\`\`javascript +document.querySelector('.container') +\`\`\` +` + const blocks = extractCodeBlocks(text) + expect(blocks).toHaveLength(3) + expect(blocks[0].language).toBe('html') + expect(blocks[1].language).toBe('css') + expect(blocks[2].language).toBe('javascript') + }) + + it('surfaces correct tabs for multi-content response', () => { + // Books + code in same response + const tabs = filterTabsByContext( + 'how to learn programming', + false, false, false, true, false, false, false, false, false, false, false, false, true + ) + expect(tabs).toContain('book') + expect(tabs).toContain('code') + }) + + it('prefers place tab first for food queries', () => { + const tabs = filterTabsByContext( + 'best pizza in new york', + false, false, false, false, false, false, true, false, false, false, false, false, false + ) + expect(tabs[0]).toBe('place') + }) + + it('extracts books from prose with "also enjoy" mid-sentence', () => { + const text = `I'd recommend starting with The Bitcoin Standard by Saifedean Ammous. You might also enjoy Mastering Bitcoin by Andreas Antonopoulos for the technical deep dive.` + const books = extractAllBooks(text, 'bitcoin book recommendations') + expect(books.length).toBeGreaterThanOrEqual(2) + }) + + it('extracts places with cuisine type in description', () => { + const text = `Best spots in Brooklyn: + +1. **Lucali** — Beloved BYOB pizzeria with incredible thin-crust pies +2. **Peter Luger** — Iconic steakhouse since 1887, cash only +3. **Olmsted** — Innovative New American restaurant with a backyard garden` + const places = extractAllPlaces(text, 'where to eat in brooklyn') + expect(places.length).toBeGreaterThanOrEqual(3) + }) + + it('does not false-positive places from tech concepts', () => { + const text = `**React** — A JavaScript library for building user interfaces +**Vue** — The progressive JavaScript framework +**Angular** — A platform for building mobile and desktop web apps` + const places = extractAllPlaces(text, 'best javascript frameworks') + expect(places).toHaveLength(0) + }) + + it('extracts songs from "Artist - Title" format', () => { + const text = `Classic rock essentials: + +[[song_ext:Stairway to Heaven|Led Zeppelin|1971]] +[[song_ext:Hotel California|Eagles|1977]] +[[song_ext:Comfortably Numb|Pink Floyd|1979]]` + const songs = extractAllSongs(text, 'best classic rock songs') + expect(songs.length).toBeGreaterThanOrEqual(3) + }) + + it('handles TV query "what should I watch"', () => { + expect(isTVQuery('what should I watch tonight')).toBe(true) + expect(isTVQuery('anything good to binge')).toBe(true) + expect(isTVQuery('best tv comedies')).toBe(true) + }) + + it('handles place query with cuisine names', () => { + expect(isPlaceQuery('best sushi in tokyo')).toBe(true) + expect(isPlaceQuery('good ramen spots')).toBe(true) + expect(isPlaceQuery('where to get tacos')).toBe(true) + }) + + it('full pipeline: mixed books and songs response', () => { + const query = 'tell me about norwegian wood' + const text = `"Norwegian Wood" can refer to both a novel and a song: + +**The Novel**: "Norwegian Wood" by Haruki Murakami (1987) is a nostalgic story of love and loss set in 1960s Tokyo. It's one of Murakami's most accessible works. + +**The Song**: [[song_ext:Norwegian Wood (This Bird Has Flown)|The Beatles|1965]] + +The Beatles' track from Rubber Soul inspired Murakami's title. The song features George Harrison's sitar playing.` + const result = extractAll(text, query) + expect(result.books.length).toBeGreaterThanOrEqual(1) + expect(result.songs.length).toBeGreaterThanOrEqual(1) + }) + + it('full pipeline: place response without explicit type words', () => { + const query = 'best brunch in london' + const text = `Here are London's best brunch spots: + +1. **Dishoom** — Bombay-inspired breakfast, try the bacon naan roll and chai +2. **The Wolseley** — Grand European cafe on Piccadilly, impeccable service +3. **Padella** — Fresh handmade pasta, worth the queue at Borough Market +4. **Bao** — Taiwanese steamed buns and small plates, Soho or Fitzrovia` + const result = extractAll(text, query) + expect(result.places.length).toBeGreaterThanOrEqual(3) + }) + + it('extracts TV from [[tv_ext:]] tags with Title|Year|Creator format', () => { + const text = `Similar series:\n\n[[tv_ext:Ghost in the Shell: Stand Alone Complex|2002|Kenji Kamiyama]] — Gold standard for tech-noir sci-fi.\n\n[[tv_ext:Cyberpunk: Edgerunners|2022|Hiroyuki Imaishi]] — Stunning animation.\n\n[[tv_ext:Altered Carbon|2018|Laeta Kalogridis]] — Cyberpunk noir.` + const result = extractAll(text, 'pantheon news') + expect(result.tvSeries.length).toBeGreaterThanOrEqual(3) + expect(result.tvSeries[0].title).toBe('Ghost in the Shell: Stand Alone Complex') + expect(result.tvSeries[0].year).toBe(2002) + expect(result.tvSeries[0].creator).toBe('Kenji Kamiyama') + }) + + it('news query with TV content surfaces both news and TV tabs', () => { + // Simulates the Pantheon response: news query + TV ext tags + const tabs = filterTabsByContext( + 'any news on pantheon season 3', + false, false, false, false, true, false, false, false, true, false, false, false, false + ) + expect(tabs).toContain('tvshow') + expect(tabs).toContain('websites') + }) + + it('full pipeline: news query does not surface books or places', () => { + const text = `Here are the latest developments: + +Bitcoin has surged past $100,000 for the first time. The rally was driven by institutional adoption and ETF inflows. Major exchanges like Coinbase and Kraken reported record trading volumes.` + const result = extractAll(text, 'latest bitcoin news') + expect(result.books).toHaveLength(0) + expect(result.places).toHaveLength(0) + expect(result.tvSeries).toHaveLength(0) + }) +}) + +// ═══════════════════════════════════════════════════════════════════ +// PODCASTS — extraction patterns +// ═══════════════════════════════════════════════════════════════════ + +describe('Podcasts: extraction from AI responses', () => { + it('extracts podcasts from [[podcast_ext:]] tags', () => { + const text = `Great Bitcoin podcasts: + +[[podcast_ext:What Bitcoin Did|Peter McCormack|2018]] +[[podcast_ext:Bitcoin Audible|Guy Swann|2018]]` + const podcasts = extractAllPodcasts(text) + expect(podcasts.length).toBeGreaterThanOrEqual(2) + expect(podcasts[0].title).toBe('What Bitcoin Did') + }) + + it('extracts podcasts from [[podcast:p1]] library tags', () => { + const text = 'Check out [[podcast:p1]] and [[podcast:p2]] for great content.' + const podcasts = extractAllPodcasts(text) + // These reference library items — may or may not match depending on mock data + expect(podcasts.length).toBeGreaterThanOrEqual(0) + }) + + it('extracts podcasts from ext tags with optional year', () => { + const text = `[[podcast_ext:The Bitcoin Standard Podcast|Saifedean Ammous]]` + const podcasts = extractAllPodcasts(text) + expect(podcasts.length).toBeGreaterThanOrEqual(1) + expect(podcasts[0].title).toBe('The Bitcoin Standard Podcast') + }) +}) + +// ═══════════════════════════════════════════════════════════════════ +// APPS — extraction from AI responses +// ═══════════════════════════════════════════════════════════════════ + +describe('Apps: extraction from AI responses', () => { + it('extracts apps from app-related query', () => { + const text = `Best Bitcoin wallets: + +1. **Sparrow Wallet** — Desktop wallet with full coin control +2. **Blue Wallet** — Mobile Lightning wallet +3. **Electrum** — Lightweight Bitcoin wallet` + const apps = extractApps(text, 'best bitcoin wallet apps') + expect(apps.length).toBeGreaterThanOrEqual(1) + }) + + it('isAppQuery matches app-related queries', () => { + expect(isAppQuery('best nostr apps')).toBe(true) + expect(isAppQuery('what wallet should I use')).toBe(true) + expect(isAppQuery('recommend a bitcoin wallet')).toBe(true) + }) + + it('isAppQuery does not match non-app queries', () => { + expect(isAppQuery('history of money')).toBe(false) + expect(isAppQuery('best pizza in nyc')).toBe(false) + }) +}) + +// ═══════════════════════════════════════════════════════════════════ +// FILMS — library and ext tag extraction +// ═══════════════════════════════════════════════════════════════════ + +describe('Films: tag-based extraction', () => { + it('extracts films from [[film_ext:]] tags', () => { + const text = `Classic sci-fi films: + +[[film_ext:Blade Runner|1982|Ridley Scott]] +[[film_ext:2001: A Space Odyssey|1968|Stanley Kubrick]] +[[film_ext:The Matrix|1999|The Wachowskis]]` + const films = extractAllFilms(text) + expect(films.length).toBeGreaterThanOrEqual(3) + expect(films[0].title).toBe('Blade Runner') + expect(films[0].year).toBe(1982) + expect(films[0].director).toBe('Ridley Scott') + }) + + it('extracts films from [[film:f1]] library tags', () => { + const text = 'You should watch [[film:f1]] — it is a classic.' + const films = extractAllFilms(text) + expect(films.length).toBeGreaterThanOrEqual(0) // depends on mock data + }) + + it('extracts films from multiple [[film_ext:]] tags', () => { + const text = `[[film_ext:Inception|2010|Christopher Nolan]] +[[film_ext:Interstellar|2014|Christopher Nolan]]` + const films = extractAllFilms(text) + expect(films.length).toBeGreaterThanOrEqual(2) + expect(films[0].director).toBe('Christopher Nolan') + }) +}) + +// ═══════════════════════════════════════════════════════════════════ +// MAGAZINE SECTIONS — bullet-style extraction +// ═══════════════════════════════════════════════════════════════════ + +describe('Magazine sections: extraction', () => { + it('extracts bullet-format magazine sections with colon separator', () => { + const text = `Here's your Bitcoin brief: + +- **Bitcoin Surges Past $100K**: The price of Bitcoin has reached a new all-time high driven by ETF inflows. Institutional investors continue to pour capital into spot Bitcoin ETFs. + +- **Lightning Network Growth**: Channel count has doubled in 2025, with total capacity exceeding 5,000 BTC. New routing solutions improve payment reliability. + +- **Mining Difficulty Adjustment**: A 4.2% difficulty increase signals growing network hashrate. Miners are deploying next-gen ASIC hardware at scale.` + const sections = extractMagazineSections(text) + expect(sections.length).toBeGreaterThanOrEqual(2) + }) + + it('extracts numbered magazine sections', () => { + const text = `Top Bitcoin developments: + +1. **ETF Inflows Hit Record**: BlackRock's iShares Bitcoin Trust saw $500M in a single day. +2. **El Salvador Doubles Down**: The country adds another 100 BTC to reserves.` + const sections = extractMagazineSections(text) + expect(sections.length).toBeGreaterThanOrEqual(2) + }) +}) + +// ═══════════════════════════════════════════════════════════════════ +// WEBSITES/NEWS — markdown and domain extraction +// ═══════════════════════════════════════════════════════════════════ + +describe('Websites: markdown link and domain extraction', () => { + it('extracts markdown links', () => { + const text = `Useful resources: + +- [Bitcoin Whitepaper](https://bitcoin.org/bitcoin.pdf) +- [Mempool Explorer](https://mempool.space) +- [Lightning Network Docs](https://docs.lightning.engineering)` + const links = extractMarkdownLinks(text) + expect(links.length).toBeGreaterThanOrEqual(3) + expect(links[0].title).toBe('Bitcoin Whitepaper') + }) + + it('extracts bold domain links with parenthesized domain', () => { + const text = `Check out **Bitcoin.org**(bitcoin.org) and **Mempool Explorer**(mempool.space) for more information.` + const domains = extractBoldDomainLinks(text) + expect(domains.length).toBeGreaterThanOrEqual(2) + }) + + it('extracts bare domain names from text', () => { + const text = `For Bitcoin info, check bitcoin.org and blockstream.com for real-time data.` + const domains = extractBareDomainLinks(text) + expect(domains.length).toBeGreaterThanOrEqual(2) + }) + + it('isWebsitesQuery matches resource queries', () => { + expect(isWebsitesQuery('bitcoin resources')).toBe(true) + expect(isWebsitesQuery('useful websites for learning')).toBe(true) + }) +}) + +// ═══════════════════════════════════════════════════════════════════ +// EDGE CASES — extended +// ═══════════════════════════════════════════════════════════════════ + +describe('Edge cases: extended', () => { + it('handles empty string input gracefully', () => { + const result = extractAll('', '') + expect(result.films).toHaveLength(0) + expect(result.books).toHaveLength(0) + expect(result.songs).toHaveLength(0) + expect(result.tvSeries).toHaveLength(0) + expect(result.places).toHaveLength(0) + expect(result.images).toHaveLength(0) + expect(result.codeBlocks).toHaveLength(0) + expect(result.apps).toHaveLength(0) + expect(result.podcasts).toHaveLength(0) + }) + + it('handles very long text without crashing', () => { + const longText = 'A '.repeat(10000) + '\n\n**The Bitcoin Standard** by Saifedean Ammous is great.' + const books = extractAllBooks(longText, 'bitcoin books') + expect(books.length).toBeGreaterThanOrEqual(1) + }) + + it('handles unicode characters in titles', () => { + const text = `1. **Café Müller** — Pina Bausch's choreographic masterpiece restaurant +2. **Ñoño's Tacos** — Authentic Mexican street food` + const places = extractAllPlaces(text, 'where to eat') + expect(places.length).toBeGreaterThanOrEqual(1) + }) + + it('handles special characters in code blocks', () => { + const text = '```python\nprint("Hello & \\"quotes\\"")\n```' + const blocks = extractCodeBlocks(text) + expect(blocks).toHaveLength(1) + expect(blocks[0].code).toContain('') + }) + + it('does not crash on malformed tags', () => { + const text = '[[film_ext:incomplete tag\n[[song_ext:\n[[podcast_ext:Title|' + const result = extractAll(text, 'test') + // Should not throw, just return empty + expect(result.films).toHaveLength(0) + }) + + it('handles mixed content with 3+ types in one response', () => { + const text = `Here's a diverse recommendation: + +**Books:** +1. **The Bitcoin Standard** by Saifedean Ammous — a must-read book + +**Films:** +[[film_ext:The Big Short|2015|Adam McKay]] + +**Music:** +[[song_ext:Money|Pink Floyd|1973]] + +**Code:** +\`\`\`python +import hashlib +print(hashlib.sha256(b"bitcoin").hexdigest()) +\`\`\` +\`\`\`javascript +const crypto = require('crypto') +console.log(crypto.createHash('sha256').update('bitcoin').digest('hex')) +\`\`\` +\`\`\`bash +echo -n "bitcoin" | sha256sum +\`\`\`` + const result = extractAll(text, 'recommend books and movies about bitcoin') + expect(result.books.length).toBeGreaterThanOrEqual(1) + expect(result.films.length).toBeGreaterThanOrEqual(1) + expect(result.songs.length).toBeGreaterThanOrEqual(1) + expect(result.codeBlocks.length).toBeGreaterThanOrEqual(3) + }) + + it('handles image markdown with special characters in alt text', () => { + const text = `![A café & bistro's outdoor patio (2024)](https://example.com/photo.jpg) +![Mountain sunrise — golden hour](https://example.com/sunrise.png)` + const images = extractAllImages(text, 'show me photos') + expect(images.length).toBeGreaterThanOrEqual(2) + }) + + it('extracts images from multiple markdown image patterns', () => { + const text = `Here are some cat photos: + +![Tabby cat](https://example.com/tabby.jpg) +![Black cat](https://example.com/black.jpg) +![Persian cat](https://example.com/persian.jpg)` + const images = extractAllImages(text, 'cat photos') + expect(images.length).toBeGreaterThanOrEqual(3) + }) + + it('isImageQuery matches image-related queries', () => { + expect(isImageQuery('show me pictures of cats')).toBe(true) + expect(isImageQuery('generate an image of a sunset')).toBe(true) + }) +}) + +// ═══════════════════════════════════════════════════════════════════ +// FILTER TABS BY CONTEXT — comprehensive routing (T11) +// ═══════════════════════════════════════════════════════════════════ + +describe('filterTabsByContext: comprehensive routing', () => { + it('news query with TV content surfaces both', () => { + const tabs = filterTabsByContext( + 'news about breaking bad season 6', + false, false, false, false, true, false, false, true, false, false, false, false, false, + ) + expect(tabs).toContain('tvshow') + expect(tabs).toContain('news') + }) + + it('nostr query surfaces nostr first', () => { + const tabs = filterTabsByContext( + 'what is nostr', + false, false, false, false, false, false, false, false, false, false, true, false, false, + ) + expect(tabs).toContain('nostr') + }) + + it('app query surfaces apps first', () => { + const tabs = filterTabsByContext( + 'best bitcoin wallet apps', + false, false, false, false, false, false, false, false, false, false, false, true, false, + ) + expect(tabs).toContain('app') + expect(tabs[0]).toBe('app') + }) + + it('nostr + apps query surfaces both', () => { + const tabs = filterTabsByContext( + 'nostr apps and clients', + false, false, false, false, false, false, false, false, false, false, true, true, false, + ) + expect(tabs).toContain('nostr') + expect(tabs).toContain('app') + }) + + it('code + apps surfaces both', () => { + const tabs = filterTabsByContext( + 'how to build a bitcoin app', + false, false, false, false, false, false, false, false, false, false, false, true, true, + ) + expect(tabs).toContain('app') + expect(tabs).toContain('code') + }) + + it('magazine shows for news-like query with magazine sections', () => { + const tabs = filterTabsByContext( + 'bitcoin market update', + false, false, false, false, false, false, false, false, false, true, false, false, false, + ) + expect(tabs).toContain('magazine') + }) + + it('does not silently drop any present content type', () => { + // All content types present + const tabs = filterTabsByContext( + 'tell me everything', + true, true, true, true, true, true, true, true, true, true, true, true, true, + ) + expect(tabs).toContain('film') + expect(tabs).toContain('song') + expect(tabs).toContain('podcast') + expect(tabs).toContain('book') + expect(tabs).toContain('tvshow') + expect(tabs).toContain('image') + expect(tabs).toContain('place') + }) + + it('preferredFirstTab returns place for restaurant queries', () => { + expect(preferredFirstTab('best restaurants nearby')).toBe('place') + }) + + it('preferredFirstTab returns book for book queries', () => { + expect(preferredFirstTab('recommend good books')).toBe('book') + }) + + it('preferredFirstTab returns code for coding queries', () => { + expect(preferredFirstTab('write a python script')).toBe('code') + }) + + it('preferredFirstTab returns tvshow for TV queries', () => { + expect(preferredFirstTab('best tv shows to watch')).toBe('tvshow') + }) + + it('preferredFirstTab returns song for music queries', () => { + expect(preferredFirstTab('recommend some music')).toBe('song') + }) + + it('preferredFirstTab returns null for generic queries', () => { + const result = preferredFirstTab('what is bitcoin') + // May return null or a default — just verify it doesn't crash + expect(result === null || typeof result === 'string').toBe(true) + }) + + it('news + TV query prioritizes correctly', () => { + const tabs = filterTabsByContext( + 'any news on stranger things', + false, false, false, false, true, false, false, true, true, false, false, false, false, + ) + expect(tabs).toContain('tvshow') + expect(tabs).toContain('news') + }) + + it('websites tab surfaces when websites are present', () => { + const tabs = filterTabsByContext( + 'bitcoin resources and links', + false, false, false, false, false, false, false, false, true, false, false, false, false, + ) + expect(tabs).toContain('websites') + }) + + it('image tab surfaces for image queries', () => { + const tabs = filterTabsByContext( + 'show me sunset images', + false, false, false, false, false, true, false, false, false, false, false, false, false, + ) + expect(tabs).toContain('image') + expect(tabs[0]).toBe('image') + }) + + it('podcast tab surfaces when podcasts are present', () => { + const tabs = filterTabsByContext( + 'best bitcoin podcasts', + false, false, true, false, false, false, false, false, false, false, false, false, false, + ) + expect(tabs).toContain('podcast') + }) +}) + +// ═══════════════════════════════════════════════════════════════════ +// NOSTR — query and response detection +// ═══════════════════════════════════════════════════════════════════ + +describe('Nostr: query and response detection', () => { + it('isNostrQuery matches nostr-related queries', () => { + expect(isNostrQuery('what is nostr')).toBe(true) + expect(isNostrQuery('show me my nostr feed')).toBe(true) + expect(isNostrQuery('nostr relay recommendations')).toBe(true) + }) + + it('isNostrQuery does not match unrelated queries', () => { + expect(isNostrQuery('best pizza in nyc')).toBe(false) + expect(isNostrQuery('tell me about bitcoin')).toBe(false) + }) + + it('isNostrLikeResponse detects nostr content', () => { + const text = 'Nostr is a decentralized protocol using relays and npub keys for identity. You can use NIP-05 for verification.' + expect(isNostrLikeResponse(text)).toBe(true) + }) +}) + +// ═══════════════════════════════════════════════════════════════════ +// NEWS — query and response detection +// ═══════════════════════════════════════════════════════════════════ + +describe('News: query and response detection', () => { + it('isNewsQuery matches news queries', () => { + expect(isNewsQuery('latest bitcoin news')).toBe(true) + expect(isNewsQuery("what's happening in crypto")).toBe(true) + expect(isNewsQuery('recent headlines')).toBe(true) + }) + + it('isNewsLikeResponse detects news source patterns', () => { + const text = 'For the latest Bitcoin news, check these sources for reliable information.' + expect(isNewsLikeResponse(text)).toBe(true) + }) + + it('isNewsQuery does not match non-news queries', () => { + expect(isNewsQuery('how to cook pasta')).toBe(false) + expect(isNewsQuery('explain quantum computing')).toBe(false) + }) +}) diff --git a/aiui/packages/app/src/__tests__/fixtures/guideConversation.ts b/aiui/packages/app/src/__tests__/fixtures/guideConversation.ts new file mode 100644 index 00000000..330f8bbc --- /dev/null +++ b/aiui/packages/app/src/__tests__/fixtures/guideConversation.ts @@ -0,0 +1,136 @@ +/** + * AIUI Guide — pre-loaded as a chat conversation so users can + * read it right in the chat window. + */ + +export function guideToConversation(): { + id: string + title: string + messages: { id: string; role: string; content: string; timestamp: number }[] + createdAt: number + updatedAt: number +} { + const baseTime = 1772496000000 + + const guideContent = `# AIUI Guide — Your Node Assistant + +AIUI is your AI assistant running directly on your Archipelago node. It can see your installed apps, read your files, check Bitcoin and Lightning status, and help you manage everything — all privately, with no data leaving your node. + +--- + +## Node Awareness + +AIUI automatically knows about your node setup. Just ask naturally: + +- *"What apps do I have installed?"* +- *"Is my node connected to the network?"* +- *"What version of Archipelago am I running?"* + +--- + +## File Browsing & Reading + +AIUI can browse and read text files stored in your Nextcloud. Supported formats: \`.txt\`, \`.md\`, \`.json\`, \`.csv\`, \`.log\`, \`.yaml\`, \`.conf\`, \`.toml\`, \`.xml\`, \`.html\`, \`.css\`, \`.js\`, \`.ts\`, \`.py\`, \`.sh\`, and more. + +- *"What files do I have?"* +- *"Read my config.yaml file"* +- *"Show me the contents of notes.md"* +- *"Summarize my todo.txt"* + +> Files are read up to 100KB. Larger files are truncated. Binary files (images, videos) cannot be read as text. + +--- + +## Bitcoin Node Status + +If you have Bitcoin Core running, AIUI can check sync status, block height, and mempool info in real-time. + +- *"How's my Bitcoin node doing?"* +- *"What block height am I on?"* +- *"Is my node fully synced?"* +- *"How many transactions are in the mempool?"* + +--- + +## Lightning Network (LND) + +AIUI can query your LND node for channels, peers, balances, and sync status. Private keys and macaroons are never exposed. + +- *"What's my Lightning balance?"* +- *"How many channels do I have open?"* +- *"How many peers is my node connected to?"* +- *"Is my Lightning node synced?"* + +--- + +## App Logs + +When an app isn't working right, AIUI can pull recent log output to help diagnose issues. + +- *"Why is Mempool not working?"* +- *"Show me the Bitcoin Core logs"* +- *"What errors is Nextcloud showing?"* +- *"Show me the last 100 lines of LND logs"* + +--- + +## App Management + +AIUI can help you navigate your node, open apps, and install new ones. + +- *"Open Mempool"* +- *"Install BTCPay Server"* +- *"Take me to the Settings page"* +- *"What apps are available to install?"* + +--- + +## Chat Features + +- **Conversation History** — All chats saved locally. Use the history panel to switch between them. +- **Edit Messages** — Click any sent message to edit and re-send. +- **Branch Conversations** — Fork at any point to explore a different direction. +- **Web Search** — When enabled, AIUI searches the web for current info. +- **Image Support** — Attach images for visual questions. + +--- + +## Privacy & Permissions + +AIUI only accesses what you allow. Node data categories (apps, files, wallet, bitcoin, network, system) are permission-gated through the Archy permissions panel. All processing goes through your node's Claude proxy — your conversations and data never touch third-party servers beyond the AI API. Private keys, seeds, and macaroons are never exposed. + +--- + +## Tips + +- Be specific — *"Read my bitcoin.conf"* works better than *"show me config files"* +- AIUI remembers context within a conversation, so ask follow-ups +- If something seems wrong with an app, ask AIUI to check the logs first +- You can ask AIUI to explain what a config file does after reading it +- Use the history panel to return to previous conversations at any time + +--- + +Try asking me something! For example: *"What apps do I have installed?"* or *"How's my Bitcoin node?"*` + + return { + id: 'aiui-guide', + title: 'AIUI Guide', + messages: [ + { + id: 'guide-q', + role: 'user', + content: 'How do I use AIUI?', + timestamp: baseTime, + }, + { + id: 'guide-a', + role: 'assistant', + content: guideContent, + timestamp: baseTime + 2000, + }, + ], + createdAt: baseTime, + updatedAt: baseTime + 2000, + } +} diff --git a/aiui/packages/app/src/__tests__/fixtures/nodeDemoPrompts.ts b/aiui/packages/app/src/__tests__/fixtures/nodeDemoPrompts.ts new file mode 100644 index 00000000..4a072de6 --- /dev/null +++ b/aiui/packages/app/src/__tests__/fixtures/nodeDemoPrompts.ts @@ -0,0 +1,269 @@ +/** + * Node Demo Prompts — pre-populated conversations demonstrating + * AIUI's local node search and management capabilities. + */ + +export interface NodeDemoPrompt { + id: string + userQuery: string + assistantResponse: string +} + +export const nodeDemoPrompts: NodeDemoPrompt[] = [ + { + id: 'node-apps', + userQuery: 'What apps do I have installed on my node?', + assistantResponse: `You have the following apps installed on your Archipelago node: + +- **Bitcoin Core** (running) — Your Bitcoin full node, fully synced to the blockchain +- **LND** (running) — Lightning Network daemon for fast, cheap payments +- **Mempool** (running) — Visual Bitcoin blockchain explorer and mempool monitor +- **Nextcloud** (running) — Your private cloud storage for files, photos, and documents +- **BTCPay Server** (running) — Self-hosted payment processor for accepting Bitcoin +- **Tor** (running) — Anonymous network routing for privacy + +All 6 apps are running normally. Would you like me to check the logs for any of them, or open one up?`, + }, + { + id: 'node-bitcoin', + userQuery: "How's my Bitcoin node doing?", + assistantResponse: `Your Bitcoin Core node is looking healthy: + +**Bitcoin Node Status** +- **Block Height:** 886,421 +- **Sync Progress:** 100.00% (fully synced) +- **Chain:** mainnet +- **Mempool:** 42,318 transactions (68.2 MB) + +Your node is fully caught up with the Bitcoin network. The mempool is moderately full — transaction fees are around 12-25 sat/vB for next-block confirmation. This is a typical level of activity. + +Would you like me to check your Lightning node status as well?`, + }, + { + id: 'node-lightning', + userQuery: "Yes, what's my Lightning balance?", + assistantResponse: `Here's your Lightning Network status: + +**Lightning (LND)** +- **Node Alias:** ArchyNode-228 +- **Channels:** 5 active channels +- **Peers:** 8 connected peers +- **Channel Balance:** 3,150,000 sats (in channels) +- **On-chain Balance:** 1,245,000 sats +- **Pending Open:** 0 sats +- **Sync:** Fully synced to chain + +Your total Lightning capacity is about 3.15M sats across 5 channels, plus 1.24M sats on-chain. Everything looks healthy — all channels are active and your node is fully synced. + +Your total Bitcoin holdings visible to the node: **4,395,000 sats** (~$4,395 at current rates).`, + }, + { + id: 'node-files', + userQuery: 'What files do I have stored on my node?', + assistantResponse: `You have **47 files** in your Nextcloud storage. Here are the most recent: + +- bitcoin.conf +- lnd.conf +- notes.md +- budget-2026.csv +- family-photos/ (folder) +- Documents/ (folder) +- backup-keys.txt +- todo.txt +- project-ideas.md +- recipes/ (folder) + +Would you like me to read any of these files? I can open text files like .txt, .md, .json, .csv, .conf, .yaml, and more.`, + }, + { + id: 'node-read-file', + userQuery: 'Read my todo.txt', + assistantResponse: `Here's the contents of **todo.txt** (342 bytes): + +\`\`\` +Shopping List +- Groceries for the week +- New HDMI cable for the node monitor +- Backup hard drive (4TB minimum) + +Node Tasks +- Open more Lightning channels +- Set up Ride The Lightning (RTL) +- Configure automatic channel backups +- Test BTCPay Server checkout flow + +Personal +- Call dentist for appointment +- Renew domain registration +- Plan weekend hike +\`\`\` + +Looks like you've got some node tasks on your list! I can help you with a few of those — I can open the app marketplace to install Ride The Lightning, or check your current channel backup configuration. Want me to help with any of these?`, + }, + { + id: 'node-read-config', + userQuery: 'Show me my bitcoin.conf', + assistantResponse: `Here's your **bitcoin.conf** (512 bytes): + +\`\`\`conf +# Bitcoin Core Configuration +server=1 +daemon=1 +txindex=1 + +# RPC Settings +rpcuser=archipelago +rpcpassword=EXAMPLE-ONLY-not-a-real-password +rpcallowip=127.0.0.1 +rpcbind=127.0.0.1 +rpcport=8332 + +# Network +listen=1 +maxconnections=40 +maxuploadtarget=5000 + +# Mempool +maxmempool=300 +mempoolexpiry=72 + +# Performance +dbcache=450 +par=2 + +# ZMQ (for LND) +zmqpubrawblock=tcp://127.0.0.1:28332 +zmqpubrawtx=tcp://127.0.0.1:28333 +\`\`\` + +Your config looks solid. A few observations: +- **txindex=1** is enabled, which is needed for Electrum Server and block explorers +- **dbcache=450** MB — reasonable for your hardware +- **ZMQ** is configured for LND to receive real-time block/tx notifications +- **maxuploadtarget=5000** MB/day limits upload bandwidth + +Everything is properly set up for running LND and Mempool alongside Bitcoin Core.`, + }, + { + id: 'node-app-logs', + userQuery: "Mempool seems slow, can you check what's going on?", + assistantResponse: `Let me check the Mempool logs for you. + +Here are the last 50 lines of **Mempool** logs: + +\`\`\` +[2026-03-05 14:23:01] Updating mempool stats... +[2026-03-05 14:23:02] Fetched 42318 transactions from Bitcoin Core +[2026-03-05 14:23:03] Block 886421 indexed successfully +[2026-03-05 14:23:05] WebSocket: 12 clients connected +[2026-03-05 14:23:10] Mining pool stats updated +[2026-03-05 14:23:15] Mempool size: 68.2 MB (42318 txs) +[2026-03-05 14:23:20] Fee estimation updated: 12-25 sat/vB +[2026-03-05 14:23:25] WARN: Electrs sync delay detected (2.3s) +[2026-03-05 14:23:30] Updating difficulty adjustment... +[2026-03-05 14:23:35] Hashrate calculation complete +\`\`\` + +I see one warning: **Electrs sync delay of 2.3 seconds**. This is the Electrum Server indexer that Mempool uses for address lookups. A small delay like this is usually temporary and happens when: + +1. A new block was just found (extra indexing work) +2. The mempool is particularly full (more data to process) + +The rest of the logs look normal. Mempool is serving 12 active WebSocket connections. If the slowness persists, you might want to restart the Mempool container. Want me to do that?`, + }, + { + id: 'node-open-app', + userQuery: 'Open Mempool so I can check it myself', + assistantResponse: `Opening **Mempool** for you now. The app should appear in a new view — you'll see the real-time mempool visualization, fee estimates, and recent blocks. + +If it's still feeling slow, try refreshing the page. The Electrs sync delay I noticed earlier should resolve itself within a few minutes.`, + }, + { + id: 'node-install', + userQuery: 'What other apps can I install?', + assistantResponse: `Here are some apps available in the Archipelago marketplace that you haven't installed yet: + +**Recommended for your setup:** +- **Ride The Lightning (RTL)** — Visual Lightning node manager with channel management, payments, and routing fee settings. Great companion to LND. +- **ThunderHub** — Another Lightning dashboard with a modern UI. More advanced routing and rebalancing tools. +- **BOS (Balance of Satoshis)** — CLI tool for advanced Lightning channel management and rebalancing. + +**Privacy & Communication:** +- **Nostr Relay** — Run your own Nostr relay for censorship-resistant social media +- **SimpleX Chat Server** — Private messaging server + +**Other:** +- **Home Assistant** — Smart home automation +- **Gitea** — Self-hosted Git repository (like a private GitHub) +- **Vaultwarden** — Self-hosted password manager (Bitwarden compatible) + +Since you mentioned wanting to set up RTL in your todo list, would you like me to install **Ride The Lightning** for you?`, + }, + { + id: 'node-summary', + userQuery: 'Give me a full status summary of my node', + assistantResponse: `Here's your complete **Archipelago Node Status**: + +--- + +**System** +- Archipelago v0.1.0 +- Network: Connected +- Uptime: 14 days, 6 hours + +**Bitcoin Core** — Running +- Block: 886,421 (100% synced) +- Mempool: 42,318 txs (68.2 MB) +- Connections: 38 peers + +**Lightning (LND)** — Running +- Alias: ArchyNode-228 +- Channels: 5 active, 0 pending +- Peers: 8 connected +- Channel balance: 3,150,000 sats +- On-chain balance: 1,245,000 sats +- Total: 4,395,000 sats + +**Apps** — 6/6 running +- Bitcoin Core, LND, Mempool, Nextcloud, BTCPay Server, Tor + +**Storage** +- Nextcloud: 47 files +- Blockchain: ~600 GB + +--- + +Everything is running smoothly. No errors or warnings in the last 24 hours. Your node is fully synced, all channels are active, and all apps are healthy.`, + }, +] + +/** + * Build a single conversation with all node demo prompts as sequential messages. + */ +export function nodeDemoToConversation(): { + id: string + title: string + messages: { id: string; role: string; content: string; timestamp: number }[] + createdAt: number + updatedAt: number +} { + const baseTime = 1772492400000 // Slightly after seed prompts + const messages: { id: string; role: string; content: string; timestamp: number }[] = [] + + for (let i = 0; i < nodeDemoPrompts.length; i++) { + const prompt = nodeDemoPrompts[i] + const ts = baseTime + i * 120000 // 2 min between each exchange + messages.push( + { id: `${prompt.id}-q`, role: 'user', content: prompt.userQuery, timestamp: ts }, + { id: `${prompt.id}-a`, role: 'assistant', content: prompt.assistantResponse, timestamp: ts + 5000 }, + ) + } + + return { + id: 'node-demo', + title: 'Exploring My Node', + messages, + createdAt: baseTime, + updatedAt: baseTime + nodeDemoPrompts.length * 120000, + } +} diff --git a/aiui/packages/app/src/__tests__/fixtures/seedPrompts.ts b/aiui/packages/app/src/__tests__/fixtures/seedPrompts.ts new file mode 100644 index 00000000..8e16261a --- /dev/null +++ b/aiui/packages/app/src/__tests__/fixtures/seedPrompts.ts @@ -0,0 +1,473 @@ +/** + * Seed Prompt Index — realistic AI prompt/response pairs covering every content type. + * Used by: .dev/chats.json (seeded conversations), extraction tests, e2e tests. + * + * Each entry represents a user query + AI response that exercises a specific + * content surface (films, songs, books, TV, places, podcasts, images, code, + * recipes, events, news/magazine, mixed content). + */ + +export interface SeedPrompt { + id: string + /** Content types this prompt exercises */ + types: string[] + userQuery: string + assistantResponse: string + /** Expected extraction counts for validation */ + expected: { + films?: number + songs?: number + books?: number + tvSeries?: number + places?: number + podcasts?: number + images?: number + codeBlocks?: number + recipes?: number + events?: number + magazineSections?: number + apps?: number + } +} + +export const seedPrompts: SeedPrompt[] = [ + // ─── Films ────────────────────────────────────────────────── + { + id: 'seed-films', + types: ['films'], + userQuery: 'What are the best Christopher Nolan films?', + assistantResponse: `Here are Christopher Nolan's finest works: + +[[film_ext:Inception|2010|Christopher Nolan]] — A mind-bending heist through layers of dreams. DiCaprio leads an ensemble cast through architecture-defying action sequences and emotionally resonant storytelling. + +[[film_ext:The Dark Knight|2008|Christopher Nolan]] — Heath Ledger's Joker elevates this beyond superhero fare into genuine crime thriller territory. The interrogation scene alone is worth the watch. + +[[film_ext:Interstellar|2014|Christopher Nolan]] — A father-daughter love story wrapped in hard science fiction. The docking scene and "years of messages" sequence are devastating. + +[[film_ext:Memento|2000|Christopher Nolan]] — The reverse-chronology thriller that put Nolan on the map. Guy Pearce hunts his wife's killer while unable to form new memories. + +[[film_ext:Oppenheimer|2023|Christopher Nolan]] — Cillian Murphy embodies the father of the atomic bomb in Nolan's most dialogue-heavy and politically charged film. + +All feature practical effects, IMAX cinematography, and Hans Zimmer scores (except Memento and Oppenheimer's Göransson score).`, + expected: { films: 5 }, + }, + + // ─── Songs / Music ────────────────────────────────────────── + { + id: 'seed-songs', + types: ['songs'], + userQuery: 'Recommend some ambient electronic music for deep work', + assistantResponse: `Here are excellent ambient electronic tracks for focused work: + +[[song_ext:Weightless|Marconi Union|2011]] — Scientifically designed to reduce anxiety. The 8-minute track uses sustained harmonies and a gradually slowing tempo (60 BPM down to 50). + +[[song_ext:An Ending (Ascent)|Brian Eno|1983]] — From the Apollo album. Pure synthesizer beauty — ascending chords that feel like floating above Earth. 4 minutes of transcendence. + +[[song_ext:Intro|The xx|2009]] — Minimalist guitar loops over electronic beats. Not strictly ambient, but the repetitive structure aids concentration. + +[[song_ext:Avril 14th|Aphex Twin|2001]] — A rare gentle piano piece from Richard D. James. Delicate and hypnotic — perfect for reading or writing. + +[[song_ext:Music for Airports 1/1|Brian Eno|1978]] — The track that coined "ambient music." Tape loops of piano and voices create an ever-evolving soundscape. + +[[song_ext:Treefingers|Radiohead|2000]] — From Kid A. Processed guitar creating ethereal textures. Works beautifully on loop.`, + // Six, not ten. The assistantResponse above lists exactly six + // `song_ext` entries and ends coherently on Treefingers — it was never + // truncated. The extractor was returning the right answer and this + // number was the wrong one, which is why "extracts 10 songs" had been + // failing. Counted from the fixture, not from intent. + expected: { songs: 6 }, + }, + + // ─── Books ────────────────────────────────────────────────── + { + id: 'seed-books', + types: ['books'], + userQuery: 'Best books on Bitcoin and monetary history', + assistantResponse: `Essential reading on Bitcoin and the history of money: + +[[book_ext:The Bitcoin Standard|Saifedean Ammous|2018]] — The foundational text connecting Austrian economics to Bitcoin. Traces money from primitive shells through gold to fiat, making the case for Bitcoin as the hardest money ever created. + +[[book_ext:The Fiat Standard|Saifedean Ammous|2021]] — The sequel examining how fiat currency actually works — debt creation, cantillon effects, and why the system incentivizes time preference distortion. + +[[book_ext:Mastering Bitcoin|Andreas Antonopoulos|2017]] — The technical reference. Covers cryptography, transactions, mining, and the protocol in depth. Essential for developers. + +[[book_ext:The Price of Tomorrow|Jeff Booth|2020]] — Argues that technology is naturally deflationary, but central banks fight this with money printing. Bitcoin aligns with technology's deflationary nature. + +[[book_ext:Broken Money|Lyn Alden|2023]] — A comprehensive monetary history from ancient to modern times, examining why monetary systems break and how Bitcoin fits into the broader evolution of money technology. + +**Reading order:** Start with *The Bitcoin Standard* for philosophy, then *Broken Money* for history, then *Mastering Bitcoin* for technical depth.`, + expected: { books: 5 }, + }, + + // ─── TV Series ────────────────────────────────────────────── + { + id: 'seed-tv', + types: ['tvSeries'], + userQuery: 'Best TV shows of the last decade', + assistantResponse: `Outstanding television from the 2010s and 2020s: + +[[tv_ext:Breaking Bad|Vince Gilligan|2008–2013]] — Walter White's transformation from chemistry teacher to drug lord. The gold standard of character-driven TV drama with a flawless final season. + +[[tv_ext:Severance|Dan Erickson|2022–present]] — A workplace thriller where employees surgically separate work and personal memories. Ben Stiller directs a Kafkaesque masterpiece. + +[[tv_ext:Chernobyl|Craig Mazin|2019]] — Five episodes covering the 1986 nuclear disaster. Haunting, meticulously researched, and terrifyingly relevant to institutional failure. + +[[tv_ext:The Bear|Christopher Storer|2022–present]] — A fine-dining chef returns to run his family's Chicago sandwich shop. Captures kitchen intensity with a season 2 that rivals prestige drama. + +[[tv_ext:Better Call Saul|Peter Gould|2015–2022]] — The Breaking Bad prequel that arguably surpasses it. Bob Odenkirk delivers a career-defining performance across 6 seasons. + +[[tv_ext:True Detective|Nic Pizzolatto|2014–present]] — Season 1 with McConaughey and Harrelson is peak television. Philosophical detective fiction in the Louisiana bayou. + +All available on major streaming platforms. Start with whichever genre appeals most — there's no wrong entry point.`, + expected: { tvSeries: 6 }, + }, + + // ─── Places / Restaurants ─────────────────────────────────── + { + id: 'seed-places', + types: ['places'], + userQuery: 'Best restaurants in Austin Texas', + assistantResponse: `Here are Austin's standout restaurants: + +[[place_ext:Franklin Barbecue|BBQ|Austin TX|4.8|2|1900 E 11th St]] — The most famous BBQ in Texas. Expect 3-4 hour lines for brisket that's genuinely worth the wait. Get there by 8am on weekends. + +[[place_ext:Uchi|Japanese|Austin TX|4.7|3|801 S Lamar Blvd]] — Tyson Cole's Japanese farmhouse dining. The tasting menu is exceptional — the maguro sashimi with goat cheese and cracked pepper is iconic Austin dining. + +[[place_ext:Ramen Tatsu-Ya|Ramen|Austin TX|4.6|2|8557 Research Blvd]] — Austin's best ramen. The Underbelly (pork broth, woodear mushroom, nori) has cult status. Cash only, expect a line. + +[[place_ext:Suerte|Mexican|Austin TX|4.5|2|1800 E 6th St]] — Modern Mexican from the Uchi team. Smoked beef suadero tacos and duck carnitas are standouts. Great mezcal program. + +[[place_ext:Odd Duck|American|Austin TX|4.4|2|1201 S Lamar Blvd]] — Farm-to-table small plates in a converted trailer park. The menu changes daily based on what's fresh. Excellent craft cocktails. + +**Pro tip:** Reservations are essential for Uchi and Suerte. Franklin is walk-in only. Visit Odd Duck for late-night bites after 9pm.`, + expected: { places: 5 }, + }, + + // ─── Podcasts ─────────────────────────────────────────────── + { + id: 'seed-podcasts', + types: ['podcasts'], + userQuery: 'Best Bitcoin and technology podcasts', + assistantResponse: `Top podcasts for Bitcoin and tech: + +[[podcast_ext:What Bitcoin Did|Peter McCormack|2018]] — Twice-weekly interviews covering Bitcoin from beginner to advanced. Peter's honest, accessible style makes complex topics approachable. Essential listening. + +[[podcast_ext:Lex Fridman Podcast|Lex Fridman|2018]] — Long-form conversations with scientists, engineers, and thinkers. The Bitcoin episodes with Saylor, Dorsey, and Antonopoulos are must-listens. + +[[podcast_ext:Bitcoin Audible|Guy Swann|2016]] — Guy reads and discusses the best Bitcoin articles and papers. Perfect for commutes — turns written content into audio education. + +[[podcast_ext:TFTC - Tales from the Crypt|Marty Bent|2018]] — No-nonsense Bitcoin podcast. Marty's "Rabbit Hole Recap" Friday episodes are a great weekly Bitcoin news summary. + +[[podcast_ext:Stephan Livera Podcast|Stephan Livera|2018]] — Technical and economic deep dives. Stephan interviews protocol developers, economists, and Lightning Network builders. + +Start with **What Bitcoin Did** for breadth, **Stephan Livera** for depth, and **Lex Fridman** for cross-disciplinary thinking.`, + expected: { podcasts: 5 }, + }, + + // ─── Code ─────────────────────────────────────────────────── + { + id: 'seed-code', + types: ['code'], + userQuery: 'Show me how to implement a debounce function in TypeScript', + assistantResponse: `Here's a proper TypeScript debounce with generic types and cancel support: + +\`\`\`typescript +function debounce any>( + fn: T, + delay: number, +): { (...args: Parameters): void; cancel: () => void } { + let timer: ReturnType | null = null + + const debounced = (...args: Parameters) => { + if (timer) clearTimeout(timer) + timer = setTimeout(() => { + fn(...args) + timer = null + }, delay) + } + + debounced.cancel = () => { + if (timer) { + clearTimeout(timer) + timer = null + } + } + + return debounced +} +\`\`\` + +Usage example: + +\`\`\`typescript +const debouncedSearch = debounce((query: string) => { + console.log('Searching for:', query) + fetch(\`/api/search?q=\${encodeURIComponent(query)}\`) +}, 300) + +// In an input handler: +input.addEventListener('input', (e) => { + debouncedSearch((e.target as HTMLInputElement).value) +}) + +// Cleanup: +debouncedSearch.cancel() +\`\`\` + +Key design decisions: +- **Generic types** preserve the original function's parameter types +- **Cancel method** prevents stale calls on component unmount +- **ReturnType** works in both Node and browser +- The return type explicitly shows the augmented function signature`, + expected: { codeBlocks: 2 }, + }, + + // ─── Images ───────────────────────────────────────────────── + { + id: 'seed-images', + types: ['images'], + userQuery: 'Show me some beautiful landscape photography', + assistantResponse: `Here are stunning landscape photographs: + +![Patagonia's Torres del Paine at sunrise](https://images.unsplash.com/photo-1531804055935-76f44d7c3621?w=1200) + +The granite towers catching first light create one of Earth's most dramatic mountain scenes. + +![Iceland's Kirkjufell mountain with aurora borealis](https://images.unsplash.com/photo-1504829857797-ddff29c27927?w=1200) + +The iconic arrowhead-shaped mountain with northern lights dancing above. + +![Japanese autumn — Kiyomizu-dera temple in Kyoto](https://images.unsplash.com/photo-1493976040374-85c8e12f0c0e?w=1200) + +Crimson maples frame the famous wooden stage during peak koyo season. + +These represent three different approaches to landscape photography: dramatic alpine, astrophotography, and cultural landscape. Each uses natural light and careful composition to tell a story about place.`, + expected: { images: 3 }, + }, + + // ─── Mixed: Films + Songs ─────────────────────────────────── + { + id: 'seed-mixed-films-songs', + types: ['films', 'songs'], + userQuery: 'What are the best movie soundtracks?', + assistantResponse: `Great question — here are films with legendary soundtracks: + +[[film_ext:Drive|2011|Nicolas Winding Refn]] — The synthwave soundtrack defined a genre. Cliff Martinez's score plus curated tracks create an unforgettable atmosphere. + +Key tracks: +[[song_ext:Nightcall|Kavinsky|2010]] — The opening drive through LA at night. Pure 80s-inspired synth perfection. +[[song_ext:A Real Hero|College & Electric Youth|2010]] — The emotional core of the film. Dreamy, melancholic electronica. + +[[film_ext:Blade Runner 2049|2017|Denis Villeneuve]] — Hans Zimmer and Benjamin Wallfisch created a soundscape that honors Vangelis while pushing into new territory. + +[[film_ext:Interstellar|2014|Christopher Nolan]] — Zimmer's organ-driven score is overwhelming in IMAX. "No Time for Caution" during the docking scene is peak cinema. + +[[song_ext:Time|Hans Zimmer|2010]] — From Inception. The building layers of strings and brass have become the template for modern trailer music. + +[[song_ext:Where Is My Mind?|Pixies|1988]] — The Fight Club ending. Sometimes the perfect needle drop is worth more than any original score. + +The best soundtracks don't just accompany the film — they become inseparable from the experience.`, + expected: { films: 3, songs: 2 }, + }, + + // ─── Mixed: Books + Podcasts ──────────────────────────────── + { + id: 'seed-mixed-books-podcasts', + types: ['books', 'podcasts'], + userQuery: 'I want to learn about Austrian economics, what should I read and listen to?', + assistantResponse: `Here's a complete Austrian economics learning path: + +## Essential Reading + +[[book_ext:Economics in One Lesson|Henry Hazlitt|1946]] — The best starting point. Hazlitt distills Bastiat's "seen and unseen" into a masterclass on economic thinking. Short, clear, devastating to interventionist arguments. + +[[book_ext:Human Action|Ludwig von Mises|1949]] — The magnum opus of Austrian economics. Dense but comprehensive — covers praxeology, money, banking, and the business cycle. Read after Hazlitt. + +[[book_ext:The Road to Serfdom|Friedrich Hayek|1944]] — Hayek's warning about central planning. Written during WWII, prophetic about the dangers of collectivism. Surprisingly readable. + +[[book_ext:Man, Economy, and State|Murray Rothbard|1962]] — Rothbard's systematic treatise building from first principles. More accessible than Mises, more rigorous than Hazlitt. + +## Podcasts for Ongoing Learning + +[[podcast_ext:The Tom Woods Show|Tom Woods|2013]] — Daily episodes on economics, liberty, and history. Woods makes Austrian concepts accessible with humor and clarity. + +[[podcast_ext:Human Action Podcast|Jeff Deist|2018]] — Chapter-by-chapter walkthrough of Mises' Human Action. Perfect companion to the book. + +[[podcast_ext:EconTalk|Russ Roberts|2006]] — Not strictly Austrian, but Roberts' classical liberal perspective and interview style make this essential economics listening. + +**Path:** Hazlitt → Tom Woods podcast → Road to Serfdom → Human Action book + podcast → Rothbard for depth.`, + expected: { books: 4, podcasts: 3 }, + }, + + // ─── Magazine / News ──────────────────────────────────────── + { + id: 'seed-magazine', + types: ['magazine'], + userQuery: "What's happening in the Bitcoin ecosystem this week?", + assistantResponse: `## Bitcoin Weekly Roundup + +### Lightning Network Milestone +The Lightning Network reached 6,000 BTC in public capacity this week, a new all-time high. Channel count grew 12% month-over-month, with major growth in developing markets. + +**amboss.space** — Track Lightning network statistics and node rankings. + +--- + +### Ordinals Debate Heats Up +Bitcoin Core developers are split on whether to filter Ordinals inscriptions at the mempool level. Luke Dashjr's patch was rejected, but the debate continues about block space allocation. + +**delvingbitcoin.org** — Developer discussion forum where the technical arguments play out. + +--- + +### Regulatory Update: EU MiCA +The EU's Markets in Crypto-Assets regulation takes effect next month. Self-custody remains legal, but exchanges face new reporting requirements. Most Bitcoin-only companies are prepared. + +--- + +### Mining Difficulty Adjustment +Difficulty increased 4.7% — the third consecutive positive adjustment. Hash rate continues climbing post-halving as miners optimize operations. + +**mempool.space** — Real-time mempool and mining statistics. + +--- + +*Sources: Bitcoin Magazine, Nostr, Lightning Labs blog*`, + expected: { magazineSections: 4 }, + }, + + // ─── Recipes ──────────────────────────────────────────────── + { + id: 'seed-recipes', + types: ['recipes'], + userQuery: 'How do I make a proper Italian carbonara?', + assistantResponse: `Here's an authentic Roman carbonara — no cream, no shortcuts: + + +- 400g spaghetti or rigatoni +- 200g guanciale (cured pork cheek), cut into strips +- 4 large egg yolks + 2 whole eggs +- 100g Pecorino Romano, finely grated +- Freshly ground black pepper +1. Bring a large pot of well-salted water to boil. Cook pasta 1 minute short of al dente. +2. Cut guanciale into 1cm strips. Cook in a cold dry pan over medium heat until fat renders and edges crisp (8-10 min). Remove from heat. +3. Whisk egg yolks, whole eggs, and most of the Pecorino together. Season generously with black pepper. +4. When pasta is ready, reserve 200ml pasta water. Drain and add pasta to the guanciale pan (off heat). +5. Wait 30 seconds for the pan to cool slightly, then pour the egg mixture over the pasta. Toss vigorously — the residual heat cooks the eggs into a creamy sauce. Add pasta water a splash at a time if needed. +6. Serve immediately with remaining Pecorino and more black pepper. + + +**Keys to success:** +- Guanciale, not pancetta or bacon — the flavor is completely different +- Pan OFF heat before adding eggs — scrambled eggs ≠ carbonara +- Pecorino Romano, not Parmesan — sharper, saltier, authentic +- Save that pasta water — it's liquid gold for emulsifying the sauce`, + expected: { recipes: 1 }, + }, + + // ─── Events ───────────────────────────────────────────────── + { + id: 'seed-events', + types: ['events'], + userQuery: 'What Bitcoin conferences are coming up?', + assistantResponse: `Here are the major upcoming Bitcoin events: + + +The largest annual Bitcoin conference. 35,000+ attendees, major announcements, and networking. Past speakers include Saylor, Dorsey, and Bukele. + + + +The OG Bitcoin-only conference. Technical focus, cypherpunk ethos, and intimate atmosphere. 1,000 attendees who actually build things. + + + +Focused on real-world Bitcoin adoption. Lightning Network workshops, merchant onboarding, and stories from the ground in El Salvador. + + + +Developer-focused unconference. Hands-on workshops for building on Bitcoin and Lightning. Small, technical, high signal. + + +**Tip:** Baltic Honeybadger sells out fast. TABConf is the best value for developers. Bitcoin 2026 is the big tent event.`, + expected: { events: 4 }, + }, + + // ─── TV + Places combined ─────────────────────────────────── + { + id: 'seed-mixed-tv-places', + types: ['tvSeries', 'places'], + userQuery: 'Best food shows and where to eat what they feature', + assistantResponse: `Food TV worth watching, with real-world destinations: + +[[tv_ext:The Bear|Christopher Storer|2022–present]] — Set in a Chicago Italian beef shop. The kitchen chaos is real — watch it for the cooking AND the anxiety. + +Eat the real thing at: +[[place_ext:Mr. Beef|Italian Beef|Chicago IL|4.2|1|666 N Orleans St]] — The actual shop that inspired The Bear. Cash only. Get the Italian beef dipped with hot giardiniera. + +[[tv_ext:Chef's Table|David Gelb|2015–present]] — Netflix's cinematic exploration of the world's best chefs. Each episode is a visual feast. + +Featured restaurant still taking reservations: +[[place_ext:Gaggan Anand|Indian|Bangkok|4.9|4|68/1 Soi Langsuan]] — The molecular Indian cuisine from Chef's Table Season 2. 25-course tasting menu. + +[[tv_ext:Anthony Bourdain: Parts Unknown|Zero Point Zero|2013–2018]] — Bourdain's masterpiece. Food, culture, and humanity. The Japan and Iran episodes are peak television. + +Bourdain's favorite: +[[place_ext:Bún Bò Huế Bà Phượng|Vietnamese|Huế Vietnam|4.5|1|Huế City]] — The noodle soup spot from the Vietnam episode. Simple, perfect, $2 a bowl.`, + expected: { tvSeries: 3, places: 3 }, + }, + + // ─── Tag stripping test — all tags in one response ────────── + { + id: 'seed-strip-tags', + types: ['films', 'songs', 'books', 'tvSeries', 'places', 'podcasts'], + userQuery: 'Give me your top pick in every category', + assistantResponse: `Here's one masterpiece per category: + +**Film:** [[film_ext:There Will Be Blood|2007|Paul Thomas Anderson]] — Daniel Day-Lewis as an oil prospector consumed by greed. The milkshake scene. The bowling alley. Perfect. + +**Song:** [[song_ext:Bohemian Rhapsody|Queen|1975]] — Six minutes that redefined what a pop single could be. Opera section? Guitar solo? Headbanging? Yes to all. + +**Book:** [[book_ext:Blood Meridian|Cormac McCarthy|1985]] — The darkest, most beautiful novel in American literature. The Judge is literature's greatest villain. + +**TV Show:** [[tv_ext:The Wire|David Simon|2002–2008]] — Every institution fails. Every character is compromised. Baltimore becomes a lens for all of America. + +**Restaurant:** [[place_ext:Jiro Sushi|Sushi|Tokyo|4.9|4|Ginza]] — 20 pieces of sushi. No menu. The greatest craftsman alive serves fish that transcends food. + +**Podcast:** [[podcast_ext:Hardcore History|Dan Carlin|2006]] — Multi-hour epics on history's most dramatic moments. "Blueprint for Armageddon" (WWI) is the greatest podcast ever made. + +One of each is all you need to start.`, + expected: { films: 1, songs: 1, books: 1, tvSeries: 1, places: 1, podcasts: 1 }, + }, +] + +/** + * Convert seed prompts to the .dev/chats.json conversation format. + */ +/** Build a single conversation with all seed prompts as sequential messages. */ +export function seedPromptsToConversation(): { + id: string + title: string + messages: { id: string; role: string; content: string; timestamp: number }[] + createdAt: number + updatedAt: number +} { + const baseTime = 1772488800000 + const messages: { id: string; role: string; content: string; timestamp: number }[] = [] + + for (let i = 0; i < seedPrompts.length; i++) { + const seed = seedPrompts[i] + const ts = baseTime + i * 60000 + messages.push( + { id: `${seed.id}-q`, role: 'user', content: seed.userQuery, timestamp: ts }, + { id: `${seed.id}-a`, role: 'assistant', content: seed.assistantResponse, timestamp: ts + 3000 }, + ) + } + + return { + id: 'seed-all', + title: 'Content Showcase', + messages, + createdAt: baseTime, + updatedAt: baseTime + seedPrompts.length * 60000, + } +} diff --git a/aiui/packages/app/src/__tests__/proxy.test.ts b/aiui/packages/app/src/__tests__/proxy.test.ts new file mode 100644 index 00000000..6145ac24 --- /dev/null +++ b/aiui/packages/app/src/__tests__/proxy.test.ts @@ -0,0 +1,188 @@ +import { describe, it, expect, vi, beforeEach } from 'vitest' + +// Mock proxy request/response logic without actually spawning processes +describe('Proxy Integration', () => { + describe('SSE streaming format', () => { + it('should produce valid SSE content_block_delta events', () => { + const text = 'Hello, world!' + const sseData = { + type: 'content_block_delta', + delta: { type: 'text_delta', text }, + } + const sseString = `data: ${JSON.stringify(sseData)}\n\n` + + expect(sseString).toMatch(/^data: /) + expect(sseString).toMatch(/\n\n$/) + + const parsed = JSON.parse(sseString.replace('data: ', '').trim()) + expect(parsed.type).toBe('content_block_delta') + expect(parsed.delta.type).toBe('text_delta') + expect(parsed.delta.text).toBe(text) + }) + + it('should produce valid DONE event', () => { + const done = 'data: [DONE]\n\n' + expect(done).toBe('data: [DONE]\n\n') + }) + + it('should produce valid error events', () => { + const errData = { + type: 'error', + error: { message: 'Anthropic API 401: Unauthorized' }, + } + const sseString = `data: ${JSON.stringify(errData)}\n\n` + const parsed = JSON.parse(sseString.replace('data: ', '').trim()) + expect(parsed.type).toBe('error') + expect(parsed.error.message).toContain('401') + }) + }) + + describe('Model mapping', () => { + function mapModelToApi(model: string): string { + if (model?.includes('opus')) return 'claude-opus-4-20250514' + if (model?.includes('haiku')) return 'claude-haiku-4-5-20251001' + return 'claude-sonnet-4-20250514' + } + + it('should map sonnet model correctly', () => { + expect(mapModelToApi('sonnet')).toBe('claude-sonnet-4-20250514') + expect(mapModelToApi('claude-sonnet')).toBe('claude-sonnet-4-20250514') + }) + + it('should map opus model correctly', () => { + expect(mapModelToApi('opus')).toBe('claude-opus-4-20250514') + expect(mapModelToApi('claude-opus')).toBe('claude-opus-4-20250514') + }) + + it('should map haiku model correctly', () => { + expect(mapModelToApi('haiku')).toBe('claude-haiku-4-5-20251001') + }) + + it('should default to sonnet for unknown models', () => { + expect(mapModelToApi('unknown')).toBe('claude-sonnet-4-20250514') + }) + }) + + describe('Request validation', () => { + it('should reject non-POST requests', () => { + const method = 'GET' as string + const isValid = method === 'POST' + expect(isValid).toBe(false) + }) + + it('should reject unknown paths', () => { + const validPaths = ['/v1/messages', '/v1/openrouter'] + expect(validPaths.includes('/v1/unknown')).toBe(false) + expect(validPaths.includes('/v1/messages')).toBe(true) + expect(validPaths.includes('/v1/openrouter')).toBe(true) + }) + + it('should parse request body correctly', () => { + const body = JSON.stringify({ + model: 'sonnet', + messages: [{ role: 'user', content: 'Hello' }], + system: 'You are helpful.', + webSearch: true, + }) + const parsed = JSON.parse(body) + expect(parsed.model).toBe('sonnet') + expect(parsed.messages).toHaveLength(1) + expect(parsed.system).toBe('You are helpful.') + expect(parsed.webSearch).toBe(true) + }) + + it('should handle malformed JSON', () => { + const badBody = 'not json' + expect(() => JSON.parse(badBody)).toThrow() + }) + }) + + describe('Tool use round-trips', () => { + it('should format search_web tool correctly', () => { + const tool = { + name: 'search_web', + description: 'Search the web for current information.', + input_schema: { + type: 'object', + properties: { + query: { type: 'string', description: 'Search query' }, + }, + required: ['query'], + }, + } + expect(tool.name).toBe('search_web') + expect(tool.input_schema.properties.query.type).toBe('string') + }) + + it('should construct tool_result messages correctly', () => { + const toolResult = { + type: 'tool_result', + tool_use_id: 'toolu_123', + content: '1. [Bitcoin price](https://example.com) — Current price is...', + } + expect(toolResult.type).toBe('tool_result') + expect(toolResult.tool_use_id).toBe('toolu_123') + expect(toolResult.content).toContain('Bitcoin') + }) + + it('should limit tool rounds to 5', () => { + const maxToolRounds = 5 + let rounds = 0 + while (rounds < maxToolRounds) { + rounds++ + } + expect(rounds).toBe(5) + }) + }) + + describe('Error handling', () => { + it('should handle 401 unauthorized', () => { + const status = 401 + const errMsg = `Anthropic API ${status}: Unauthorized` + expect(errMsg).toContain('401') + }) + + it('should handle 429 rate limit', () => { + const status = 429 + const errMsg = `Anthropic API ${status}: Rate limited` + expect(errMsg).toContain('429') + }) + + it('should handle 500 server error', () => { + const status = 500 + const errMsg = `Anthropic API ${status}: Internal Server Error` + expect(errMsg).toContain('500') + }) + }) + + describe('OAuth token detection', () => { + const isOAuthToken = (s: string) => /^sk-ant-oat/.test(s) + + it('should detect OAuth tokens', () => { + expect(isOAuthToken('sk-ant-oat-abc123')).toBe(true) + }) + + it('should not flag API keys as OAuth', () => { + expect(isOAuthToken('sk-ant-api03-abc123')).toBe(false) + }) + }) + + describe('Client disconnect handling', () => { + it('should track client disconnection', () => { + const state = { clientDisconnected: false } + // Simulate disconnect + state.clientDisconnected = true + expect(state.clientDisconnected).toBe(true) + }) + + it('should not write after disconnect', () => { + const clientDisconnected = true + const writes: string[] = [] + const write = (data: string) => { + if (!clientDisconnected) writes.push(data) + } + write('should not appear') + expect(writes).toHaveLength(0) + }) + }) +}) diff --git a/aiui/packages/app/src/__tests__/seed-conversations.test.ts b/aiui/packages/app/src/__tests__/seed-conversations.test.ts new file mode 100644 index 00000000..dfac64ea --- /dev/null +++ b/aiui/packages/app/src/__tests__/seed-conversations.test.ts @@ -0,0 +1,59 @@ +/** + * Seed Conversation Regression Tests + * + * Ensures each seed conversation produces at least the expected content types + * when run through the content extraction pipeline. This is a quick smoke test + * that verifies the extraction pipeline hasn't regressed. + */ +import { describe, it, expect } from 'vitest' +import { seedPrompts } from './fixtures/seedPrompts' +import { + extractAllFilms, + extractAllSongs, + extractAllPodcasts, + extractAllBooks, + extractAllTVSeries, + extractAllPlaces, + extractAllImages, + extractCodeBlocks, + extractRecipes, + extractEvents, + stripContentTags, +} from '@/composables/contentExtraction' + +describe('Seed conversation regression', () => { + for (const seed of seedPrompts) { + it(`[${seed.id}] produces expected content types for "${seed.userQuery.slice(0, 50)}"`, () => { + const text = seed.assistantResponse + const query = seed.userQuery + + // Run all extractors + const films = extractAllFilms(text) + const songs = extractAllSongs(text, query) + const podcasts = extractAllPodcasts(text) + const books = extractAllBooks(text, query) + const tvSeries = extractAllTVSeries(text, query) + const places = extractAllPlaces(text, query) + const images = extractAllImages(text, query) + const codeBlocks = extractCodeBlocks(text) + const recipes = extractRecipes(text) + const events = extractEvents(text) + + // Validate expected counts match + if (seed.expected.films !== undefined) expect(films.length).toBe(seed.expected.films) + if (seed.expected.songs !== undefined) expect(songs.length).toBe(seed.expected.songs) + if (seed.expected.podcasts !== undefined) expect(podcasts.length).toBe(seed.expected.podcasts) + if (seed.expected.books !== undefined) expect(books.length).toBe(seed.expected.books) + if (seed.expected.tvSeries !== undefined) expect(tvSeries.length).toBe(seed.expected.tvSeries) + if (seed.expected.places !== undefined) expect(places.length).toBe(seed.expected.places) + if (seed.expected.images !== undefined) expect(images.length).toBe(seed.expected.images) + if (seed.expected.codeBlocks !== undefined) expect(codeBlocks.length).toBe(seed.expected.codeBlocks) + if (seed.expected.recipes !== undefined) expect(recipes.length).toBe(seed.expected.recipes) + if (seed.expected.events !== undefined) expect(events.length).toBe(seed.expected.events) + + // Verify stripping leaves no tags + const stripped = stripContentTags(text) + expect(stripped).not.toMatch(/\[\[[^\]]+\]\]/) + }) + } +}) diff --git a/aiui/packages/app/src/__tests__/seedExtraction.test.ts b/aiui/packages/app/src/__tests__/seedExtraction.test.ts new file mode 100644 index 00000000..24baa863 --- /dev/null +++ b/aiui/packages/app/src/__tests__/seedExtraction.test.ts @@ -0,0 +1,239 @@ +/** + * Seed Extraction Tests + * + * Validates that every seed prompt in the prompt index extracts the expected + * content types and counts. These are the gold-standard test cases — if any + * fail, the content surfacing pipeline has regressed. + * + * Run overnight to harden extraction patterns against real-world AI responses. + */ +import { describe, it, expect } from 'vitest' +import { seedPrompts } from './fixtures/seedPrompts' +import { + extractAllFilms, + extractAllSongs, + extractAllPodcasts, + extractAllBooks, + extractAllTVSeries, + extractAllPlaces, + extractAllImages, + extractCodeBlocks, + extractRecipes, + extractEvents, + extractMagazineSections, + stripContentTags, + stripRecipeTags, + stripEventTags, +} from '@/composables/contentExtraction' + +// ─── Extraction count validation ───────────────────────────── + +describe('Seed prompt extraction', () => { + for (const seed of seedPrompts) { + describe(`[${seed.id}] "${seed.userQuery}"`, () => { + const text = seed.assistantResponse + const query = seed.userQuery + + if (seed.expected.films !== undefined) { + it(`extracts ${seed.expected.films} films`, () => { + const films = extractAllFilms(text) + expect(films.length).toBe(seed.expected.films) + for (const f of films) { + expect(f.title).toBeTruthy() + } + }) + } + + if (seed.expected.songs !== undefined) { + it(`extracts ${seed.expected.songs} songs`, () => { + const songs = extractAllSongs(text, query) + expect(songs.length).toBe(seed.expected.songs) + for (const s of songs) { + expect(s.title).toBeTruthy() + expect(s.artist).toBeTruthy() + } + }) + } + + if (seed.expected.books !== undefined) { + it(`extracts ${seed.expected.books} books`, () => { + const books = extractAllBooks(text, query) + expect(books.length).toBe(seed.expected.books) + for (const b of books) { + expect(b.title).toBeTruthy() + } + }) + } + + if (seed.expected.tvSeries !== undefined) { + it(`extracts ${seed.expected.tvSeries} TV series`, () => { + const tv = extractAllTVSeries(text, query) + expect(tv.length).toBe(seed.expected.tvSeries) + for (const t of tv) { + expect(t.title).toBeTruthy() + } + }) + } + + if (seed.expected.places !== undefined) { + it(`extracts ${seed.expected.places} places`, () => { + const places = extractAllPlaces(text, query) + expect(places.length).toBe(seed.expected.places) + for (const p of places) { + expect(p.name).toBeTruthy() + } + }) + } + + if (seed.expected.podcasts !== undefined) { + it(`extracts ${seed.expected.podcasts} podcasts`, () => { + const podcasts = extractAllPodcasts(text) + expect(podcasts.length).toBe(seed.expected.podcasts) + for (const p of podcasts) { + expect(p.title).toBeTruthy() + } + }) + } + + if (seed.expected.images !== undefined) { + it(`extracts ${seed.expected.images} images`, () => { + const images = extractAllImages(text, query) + expect(images.length).toBe(seed.expected.images) + }) + } + + if (seed.expected.codeBlocks !== undefined) { + it(`extracts ${seed.expected.codeBlocks} code blocks`, () => { + const code = extractCodeBlocks(text) + expect(code.length).toBe(seed.expected.codeBlocks) + for (const c of code) { + expect(c.code.trim()).toBeTruthy() + } + }) + } + + if (seed.expected.recipes !== undefined) { + it(`extracts ${seed.expected.recipes} recipes`, () => { + const recipes = extractRecipes(text) + expect(recipes.length).toBe(seed.expected.recipes) + for (const r of recipes) { + expect(r.title).toBeTruthy() + expect(r.ingredients.length).toBeGreaterThan(0) + expect(r.steps.length).toBeGreaterThan(0) + } + }) + } + + if (seed.expected.events !== undefined) { + it(`extracts ${seed.expected.events} events`, () => { + const events = extractEvents(text) + expect(events.length).toBe(seed.expected.events) + for (const e of events) { + expect(e.title).toBeTruthy() + } + }) + } + + if (seed.expected.magazineSections !== undefined) { + it(`extracts ${seed.expected.magazineSections} magazine sections`, () => { + const sections = extractMagazineSections(text) + expect(sections.length).toBeGreaterThanOrEqual(seed.expected.magazineSections!) + }) + } + }) + } +}) + +// ─── Tag stripping — no tags leak into displayed content ────── + +describe('Tag stripping completeness', () => { + for (const seed of seedPrompts) { + it(`[${seed.id}] stripContentTags removes all bracket tags`, () => { + const cleaned = stripContentTags(seed.assistantResponse) + // No [[...]] bracket tags should remain + const bracketMatches = cleaned.match(/\[\[[^\]]+\]\]/g) + expect(bracketMatches).toBeNull() + }) + + it(`[${seed.id}] strip functions remove all XML tags`, () => { + let cleaned = stripRecipeTags(stripEventTags(seed.assistantResponse)) + cleaned = stripContentTags(cleaned) + // No <..._ext> XML tags should remain + const xmlMatches = cleaned.match(/<\/?(?:recipe|event)_ext[^>]*>/g) + expect(xmlMatches).toBeNull() + }) + } +}) + +// ─── Data integrity — extracted content has required fields ─── + +describe('Extraction data integrity', () => { + const filmSeed = seedPrompts.find(s => s.id === 'seed-films')! + it('films have title, year, and director', () => { + const films = extractAllFilms(filmSeed.assistantResponse) + for (const f of films) { + expect(f.title).toBeTruthy() + expect(f.year).toBeGreaterThan(1900) + expect(f.director).toBeTruthy() + } + }) + + const songSeed = seedPrompts.find(s => s.id === 'seed-songs')! + it('songs have title, artist, and year', () => { + const songs = extractAllSongs(songSeed.assistantResponse, songSeed.userQuery) + for (const s of songs) { + expect(s.title).toBeTruthy() + expect(s.artist).toBeTruthy() + } + }) + + const bookSeed = seedPrompts.find(s => s.id === 'seed-books')! + it('books have title and author', () => { + const books = extractAllBooks(bookSeed.assistantResponse, bookSeed.userQuery) + for (const b of books) { + expect(b.title).toBeTruthy() + expect(b.author).toBeTruthy() + } + }) + + const tvSeed = seedPrompts.find(s => s.id === 'seed-tv')! + it('TV series have title and creator', () => { + const tv = extractAllTVSeries(tvSeed.assistantResponse, tvSeed.userQuery) + for (const t of tv) { + expect(t.title).toBeTruthy() + expect(t.creator).toBeTruthy() + } + }) + + const placeSeed = seedPrompts.find(s => s.id === 'seed-places')! + it('places have name, cuisine, and city', () => { + const places = extractAllPlaces(placeSeed.assistantResponse, placeSeed.userQuery) + for (const p of places) { + expect(p.name).toBeTruthy() + expect(p.cuisine).toBeTruthy() + expect(p.city).toBeTruthy() + } + }) + + const recipeSeed = seedPrompts.find(s => s.id === 'seed-recipes')! + it('recipes have complete data', () => { + const recipes = extractRecipes(recipeSeed.assistantResponse) + expect(recipes.length).toBe(1) + const r = recipes[0] + expect(r.title).toBe('Spaghetti alla Carbonara') + expect(r.servings).toBe('4') + expect(r.time).toBe('25 min') + expect(r.ingredients.length).toBeGreaterThanOrEqual(4) + expect(r.steps.length).toBeGreaterThanOrEqual(5) + }) + + const eventSeed = seedPrompts.find(s => s.id === 'seed-events')! + it('events have title, date, and location', () => { + const events = extractEvents(eventSeed.assistantResponse) + for (const e of events) { + expect(e.title).toBeTruthy() + expect(e.date).toBeTruthy() + expect(e.location).toBeTruthy() + } + }) +}) diff --git a/aiui/packages/app/src/__tests__/useAI.test.ts b/aiui/packages/app/src/__tests__/useAI.test.ts new file mode 100644 index 00000000..87d2dd1d --- /dev/null +++ b/aiui/packages/app/src/__tests__/useAI.test.ts @@ -0,0 +1,384 @@ +import { describe, it, expect, vi, beforeEach } from 'vitest' +import { setActivePinia, createPinia } from 'pinia' + +// Mock web search before importing useAI +vi.mock('@/composables/useWebSearch', () => ({ + searchWeb: vi.fn().mockResolvedValue([]), +})) + +// Mock mock data to avoid importing large fixture files +vi.mock('@/mocks/films', () => ({ + mockFilms: [ + { id: 'f1', title: 'Test Film', year: 2020, director: 'Test Dir', genres: ['Drama'], rating: 8, sources: [{ type: 'stream' }] }, + ], +})) +vi.mock('@/mocks/songs', () => ({ + mockSongs: [ + { id: 's1', title: 'Test Song', artist: 'Test Artist', album: 'Test Album', year: 2020, genres: ['Rock'], sources: [{ type: 'jamendo' }] }, + ], +})) +vi.mock('@/mocks/podcasts', () => ({ + mockPodcasts: [ + { id: 'p1', title: 'Test Podcast', host: 'Test Host', year: 2020, genres: ['Tech'], sources: [{ type: 'rss' }] }, + ], +})) + +// Mock idb-storage to avoid IndexedDB access in tests +vi.mock('@/utils/idb-storage', () => ({ + saveConversation: vi.fn().mockResolvedValue(undefined), + loadAllConversations: vi.fn().mockResolvedValue(new Map()), + deleteConversation: vi.fn().mockResolvedValue(undefined), + isIDBAvailable: vi.fn().mockReturnValue(false), +})) + +import { useAI } from '@/composables/useAI' +import { useChatStore } from '@/stores/chat' +import { searchWeb } from '@/composables/useWebSearch' + +const originalFetch = globalThis.fetch + +// Helper to create a mock SSE readable stream +function createSSEStream(events: string[]): ReadableStream { + const encoder = new TextEncoder() + let index = 0 + return new ReadableStream({ + pull(controller) { + if (index < events.length) { + controller.enqueue(encoder.encode(events[index])) + index++ + } else { + controller.close() + } + }, + }) +} + +function mockClaudeResponse(events: string[]) { + return { + ok: true, + body: createSSEStream(events), + text: () => Promise.resolve(''), + } +} + +describe('useAI', () => { + beforeEach(() => { + setActivePinia(createPinia()) + // Reset provider state (module-level ref) back to claude + const { setProvider } = useAI() + setProvider('claude') + // Re-mock searchWeb + vi.mocked(searchWeb).mockResolvedValue([]) + // Default fetch mock (catches loadServerChats and any stray calls) + globalThis.fetch = originalFetch + }) + + describe('provider selection', () => { + it('defaults to claude provider', () => { + const { activeProvider } = useAI() + expect(activeProvider.value).toBe('claude') + }) + + it('switches provider via setProvider', () => { + const { setProvider, activeProvider, activeModel } = useAI() + setProvider('openrouter') + expect(activeProvider.value).toBe('openrouter') + expect(activeModel.value).toBe('meta-llama/llama-4-maverick') + }) + + it('switches to mock provider', () => { + const { setProvider, activeProvider, activeModel } = useAI() + setProvider('mock') + expect(activeProvider.value).toBe('mock') + expect(activeModel.value).toBe('echo') + }) + + it('lists available providers with models', () => { + const { availableProviders } = useAI() + expect(availableProviders.value.length).toBe(3) + const ids = availableProviders.value.map(p => p.id) + expect(ids).toContain('claude') + expect(ids).toContain('openrouter') + expect(ids).toContain('mock') + }) + + it('sets model directly via setModel', () => { + const { setModel, activeModel } = useAI() + setModel('claude-sonnet-4') + expect(activeModel.value).toBe('claude-sonnet-4') + }) + }) + + describe('context injection', () => { + it('includes film library in system prompt', async () => { + const fetchSpy = vi.fn().mockResolvedValue( + mockClaudeResponse(['data: {"type":"content_block_delta","delta":{"text":"Hi"}}\n\n']) + ) + globalThis.fetch = fetchSpy + + const chatStore = useChatStore() + chatStore.webSearchEnabled = false + const { sendMessage } = useAI() + + await sendMessage('hello') + + const claudeCall = fetchSpy.mock.calls.find( + (c: unknown[]) => (c[0] as string)?.toString().includes('/claude/') + ) + expect(claudeCall).toBeDefined() + const body = JSON.parse(claudeCall![1].body as string) + expect(body.system).toContain('Test Film') + expect(body.system).toContain("user's film library") + }) + + it('includes song library in system prompt', async () => { + const fetchSpy = vi.fn().mockResolvedValue( + mockClaudeResponse(['data: {"type":"content_block_delta","delta":{"text":"Hi"}}\n\n']) + ) + globalThis.fetch = fetchSpy + + const chatStore = useChatStore() + chatStore.webSearchEnabled = false + const { sendMessage } = useAI() + + await sendMessage('hello') + + const claudeCall = fetchSpy.mock.calls.find( + (c: unknown[]) => (c[0] as string)?.toString().includes('/claude/') + ) + const body = JSON.parse(claudeCall![1].body as string) + expect(body.system).toContain('Test Song') + expect(body.system).toContain("user's song library") + }) + + it('includes content tag format instructions', async () => { + const fetchSpy = vi.fn().mockResolvedValue( + mockClaudeResponse(['data: {"type":"content_block_delta","delta":{"text":"Hi"}}\n\n']) + ) + globalThis.fetch = fetchSpy + + const chatStore = useChatStore() + chatStore.webSearchEnabled = false + const { sendMessage } = useAI() + + await sendMessage('hello') + + const claudeCall = fetchSpy.mock.calls.find( + (c: unknown[]) => (c[0] as string)?.toString().includes('/claude/') + ) + const body = JSON.parse(claudeCall![1].body as string) + expect(body.system).toContain('[[film_ext:') + expect(body.system).toContain('[[song_ext:') + expect(body.system).toContain('[[book_ext:') + }) + }) + + describe('sendMessage', () => { + it('adds user message to store', async () => { + globalThis.fetch = vi.fn().mockResolvedValue( + mockClaudeResponse(['data: {"type":"content_block_delta","delta":{"text":"ok"}}\n\n']) + ) + + const chatStore = useChatStore() + chatStore.webSearchEnabled = false + const { sendMessage } = useAI() + + await sendMessage('test message') + + const userMsg = chatStore.messages.find(m => m.role === 'user') + expect(userMsg).toBeDefined() + expect(userMsg!.content).toBe('test message') + }) + + it('creates assistant message placeholder', async () => { + globalThis.fetch = vi.fn().mockResolvedValue( + mockClaudeResponse(['data: {"type":"content_block_delta","delta":{"text":"hi"}}\n\n']) + ) + + const chatStore = useChatStore() + chatStore.webSearchEnabled = false + const { sendMessage } = useAI() + + await sendMessage('test') + + const assistantMsg = chatStore.messages.find(m => m.role === 'assistant') + expect(assistantMsg).toBeDefined() + expect(assistantMsg!.content).toContain('hi') + }) + + it('sets isStreaming to true during stream and false after', async () => { + const streamingStates: boolean[] = [] + + globalThis.fetch = vi.fn().mockImplementation(() => { + streamingStates.push(useChatStore().isStreaming) + return Promise.resolve( + mockClaudeResponse(['data: {"type":"content_block_delta","delta":{"text":"hello"}}\n\n']) + ) + }) + + const chatStore = useChatStore() + chatStore.webSearchEnabled = false + const { sendMessage } = useAI() + + expect(chatStore.isStreaming).toBe(false) + await sendMessage('hi') + expect(chatStore.isStreaming).toBe(false) + // During the streaming fetch call, isStreaming should have been true + // (earlier non-streaming fetches like refreshWavlakeCatalog may also be captured) + expect(streamingStates.some(s => s === true)).toBe(true) + }) + + it('handles stream errors gracefully', async () => { + globalThis.fetch = vi.fn().mockResolvedValue({ + ok: false, + status: 500, + text: () => Promise.resolve('Internal Server Error'), + }) + + const chatStore = useChatStore() + chatStore.webSearchEnabled = false + const { sendMessage, setProvider } = useAI() + setProvider('claude') // Ensure claude provider + + await sendMessage('test') + + const assistantMsg = chatStore.messages.find(m => m.role === 'assistant') + expect(assistantMsg).toBeDefined() + expect(assistantMsg!.content).toContain('⚠') + expect(chatStore.isStreaming).toBe(false) + }) + + it('handles connection errors gracefully', async () => { + globalThis.fetch = vi.fn().mockRejectedValue(new Error('Network failure')) + + const chatStore = useChatStore() + chatStore.webSearchEnabled = false + const { sendMessage, setProvider } = useAI() + setProvider('claude') // Ensure claude provider + + await sendMessage('test') + + const assistantMsg = chatStore.messages.find(m => m.role === 'assistant') + expect(assistantMsg).toBeDefined() + expect(assistantMsg!.content).toContain('Connection error') + expect(chatStore.isStreaming).toBe(false) + }) + + it('uses mock provider when set to mock', async () => { + const { sendMessage, setProvider } = useAI() + const chatStore = useChatStore() + chatStore.webSearchEnabled = false + + setProvider('mock') + await sendMessage('echo this') + + const assistantMsg = chatStore.messages.find(m => m.role === 'assistant') + expect(assistantMsg).toBeDefined() + expect(assistantMsg!.content).toContain('echo this') + expect(assistantMsg!.content).toContain('echo mode') + }) + }) + + describe('stopGeneration', () => { + it('aborts active stream and sets isStreaming to false', async () => { + // Create a fetch that returns a stream which rejects on abort + globalThis.fetch = vi.fn().mockImplementation((_url: string, init?: RequestInit) => { + const signal = init?.signal + return Promise.resolve({ + ok: true, + body: new ReadableStream({ + start(controller) { + // Send first chunk so readSSE enters its loop + const encoder = new TextEncoder() + controller.enqueue(encoder.encode('data: {"type":"content_block_delta","delta":{"text":"h"}}\n\n')) + // When aborted, close the stream + if (signal) { + signal.addEventListener('abort', () => { + try { controller.close() } catch { /* already closed */ } + }) + } + }, + }), + text: () => Promise.resolve(''), + }) + }) + + const chatStore = useChatStore() + chatStore.webSearchEnabled = false + const { sendMessage, stopGeneration, setProvider } = useAI() + setProvider('claude') + + const sendPromise = sendMessage('test') + + // Wait for fetch to start and first chunk to process + await new Promise(r => setTimeout(r, 50)) + + expect(chatStore.isStreaming).toBe(true) + + stopGeneration() + expect(chatStore.isStreaming).toBe(false) + + await sendPromise + }) + }) + + describe('web search integration', () => { + it('injects web results into system prompt when enabled', async () => { + const mockResults = [ + { title: 'Result 1', url: 'https://example.com', content: 'Some content' }, + ] + vi.mocked(searchWeb).mockResolvedValue(mockResults) + + const fetchSpy = vi.fn().mockResolvedValue( + mockClaudeResponse(['data: {"type":"content_block_delta","delta":{"text":"answer"}}\n\n']) + ) + globalThis.fetch = fetchSpy + + const chatStore = useChatStore() + chatStore.webSearchEnabled = true + const { sendMessage } = useAI() + + await sendMessage('latest bitcoin news') + + // Find the Claude API call (not any dev-chats call) + const claudeCall = fetchSpy.mock.calls.find( + (c: unknown[]) => (c[0] as string)?.toString().includes('/claude/') + ) + expect(claudeCall).toBeDefined() + const body = JSON.parse(claudeCall![1].body as string) + expect(body.system).toContain('Web search results') + expect(body.system).toContain('Result 1') + // FALSE on purpose: `proxyWebSearch = webSearchEnabled && !clientSearchSucceeded`. + // The client already searched and injected the results above, so asking + // the proxy to search again would be a second, redundant search per turn. + // This assertion read `true` and had been failing since that change — + // the test was stale, the behaviour is intentional. + expect(body.webSearch).toBe(false) + }) + + it('asks the proxy to search when the client-side search finds nothing', async () => { + // The other half of the same contract: no client results means nothing + // was injected, so the proxy must still do the search. + vi.mocked(searchWeb).mockResolvedValue([]) + + const fetchSpy = vi.fn().mockResolvedValue( + mockClaudeResponse(['data: {"type":"content_block_delta","delta":{"text":"answer"}}\n\n']) + ) + globalThis.fetch = fetchSpy + + const chatStore = useChatStore() + chatStore.webSearchEnabled = true + const { sendMessage } = useAI() + + await sendMessage('latest bitcoin news') + + const claudeCall = fetchSpy.mock.calls.find( + (c: unknown[]) => (c[0] as string)?.toString().includes('/claude/') + ) + expect(claudeCall).toBeDefined() + const body = JSON.parse(claudeCall![1].body as string) + expect(body.system).not.toContain('Web search results') + expect(body.webSearch).toBe(true) + }) + }) +}) diff --git a/aiui/packages/app/src/adapters/claude-adapter.ts b/aiui/packages/app/src/adapters/claude-adapter.ts new file mode 100644 index 00000000..a19d9725 --- /dev/null +++ b/aiui/packages/app/src/adapters/claude-adapter.ts @@ -0,0 +1,113 @@ +import type { AIAdapter, ChatMessage, ChatOptions } from './types' +import { getApiKey } from '@/utils/key-vault' +import { apiFetch } from '@/utils/api-fetch' + +const BASE = import.meta.env.BASE_URL || '/' +const CLAUDE_PATH = `${BASE}api/claude/v1/messages` + +export const claudeAdapter: AIAdapter = { + id: 'claude', + name: 'Claude (Max)', + supportsStreaming: true, + supportsVision: true, + supportsTools: true, + + models() { + return [ + { id: 'claude-haiku-4.5', name: 'Claude 4.5 Haiku' }, + { id: 'claude-sonnet-4', name: 'Claude Sonnet 4' }, + { id: 'claude-opus-4', name: 'Claude Opus 4' }, + ] + }, + + async chat(messages, options, onToken, onError) { + const headers: Record = { 'Content-Type': 'application/json' } + + const vaultKey = await getApiKey('claude') + if (vaultKey) { + headers['x-api-key'] = vaultKey + } + + const apiMessages = messages + .filter(m => m.role !== 'system') + .map(m => ({ role: m.role, content: m.content })) + + const res = await apiFetch(CLAUDE_PATH, { + method: 'POST', + headers, + body: JSON.stringify({ + model: options.model, + system: options.systemPrompt, + messages: apiMessages, + stream: true, + webSearch: options.webSearch ?? false, + }), + signal: options.signal, + }) + + if (!res.ok) { + const body = await res.text().catch(() => 'Could not read error body') + onError(`Claude proxy error ${res.status}: ${body}`) + return + } + + await readSSE(res, (data) => { + try { + const parsed = JSON.parse(data) + if (parsed.type === 'content_block_delta' && parsed.delta?.text) { + onToken(parsed.delta.text) + } else if (parsed.type === 'error') { + onError(parsed.error?.message ?? 'Claude stream error') + } + } catch { /* malformed SSE chunk */ } + }, onError, options.signal) + }, +} + +async function readSSE( + res: Response, + onData: (data: string) => void, + onError: (err: string) => void, + signal?: AbortSignal, +): Promise { + const reader = res.body?.getReader() + if (!reader) { + onError('No response body') + return + } + + const decoder = new TextDecoder() + let buffer = '' + + try { + while (true) { + if (signal?.aborted) { + reader.cancel() + return + } + const { done, value } = await reader.read() + if (done) break + + buffer += decoder.decode(value, { stream: true }) + const lines = buffer.split('\n') + buffer = lines.pop() ?? '' + + for (const line of lines) { + const trimmed = line.trim() + if (!trimmed || !trimmed.startsWith('data: ')) continue + const payload = trimmed.slice(6) + if (payload === '[DONE]') return + try { + onData(payload) + } catch { + // skip malformed chunks + } + } + } + } catch (err) { + if (signal?.aborted) return + onError(err instanceof Error ? err.message : 'Stream read error') + } finally { + reader.cancel().catch(() => {}) + } +} diff --git a/aiui/packages/app/src/adapters/ollama-adapter.ts b/aiui/packages/app/src/adapters/ollama-adapter.ts new file mode 100644 index 00000000..2aadc1d6 --- /dev/null +++ b/aiui/packages/app/src/adapters/ollama-adapter.ts @@ -0,0 +1,94 @@ +import type { AIAdapter, ChatOptions } from './types' + +const OLLAMA_BASE = 'http://localhost:11434' + +export const ollamaAdapter: AIAdapter = { + id: 'ollama', + name: 'Ollama (Local)', + supportsStreaming: true, + supportsVision: false, + supportsTools: false, + + models() { + return [ + { id: 'llama3.2', name: 'Llama 3.2' }, + { id: 'mistral', name: 'Mistral' }, + { id: 'gemma2', name: 'Gemma 2' }, + { id: 'qwen2.5', name: 'Qwen 2.5' }, + ] + }, + + async chat(messages, options, onToken, onError) { + const ollamaMessages = messages.map(m => ({ + role: m.role, + content: m.content, + })) + + if (options.systemPrompt) { + ollamaMessages.unshift({ role: 'system', content: options.systemPrompt }) + } + + let res: Response + try { + res = await fetch(`${OLLAMA_BASE}/api/chat`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + model: options.model, + messages: ollamaMessages, + stream: true, + }), + signal: options.signal, + }) + } catch { + onError('Cannot connect to Ollama. Is it running on localhost:11434?') + return + } + + if (!res.ok) { + const body = await res.text().catch(() => 'Could not read error body') + onError(`Ollama error ${res.status}: ${body}`) + return + } + + // Ollama uses newline-delimited JSON (not SSE) + const reader = res.body?.getReader() + if (!reader) { + onError('No response body') + return + } + + const decoder = new TextDecoder() + let buffer = '' + + try { + while (true) { + if (options.signal?.aborted) { + reader.cancel() + return + } + const { done, value } = await reader.read() + if (done) break + + buffer += decoder.decode(value, { stream: true }) + const lines = buffer.split('\n') + buffer = lines.pop() ?? '' + + for (const line of lines) { + if (!line.trim()) continue + try { + const parsed = JSON.parse(line) + if (parsed.message?.content) { + onToken(parsed.message.content) + } + if (parsed.done) return + } catch { + // skip malformed lines + } + } + } + } finally { + reader.cancel().catch(() => {}) + } + }, +} diff --git a/aiui/packages/app/src/adapters/openrouter-adapter.ts b/aiui/packages/app/src/adapters/openrouter-adapter.ts new file mode 100644 index 00000000..78e6bf46 --- /dev/null +++ b/aiui/packages/app/src/adapters/openrouter-adapter.ts @@ -0,0 +1,119 @@ +import type { AIAdapter, ChatOptions } from './types' +import { getApiKey } from '@/utils/key-vault' +import { apiFetch } from '@/utils/api-fetch' + +const OPENROUTER_PATH = '/api/openrouter' + +export const openrouterAdapter: AIAdapter = { + id: 'openrouter', + name: 'OpenRouter', + supportsStreaming: true, + supportsVision: false, + supportsTools: false, + + models() { + return [ + { id: 'meta-llama/llama-4-maverick', name: 'Llama 4 Maverick' }, + { id: 'qwen/qwen3-235b-a22b-thinking-2507', name: 'Qwen3 235B Thinking' }, + { id: 'mistralai/mistral-small-3.1-24b-instruct:free', name: 'Mistral Small 3.1 (free)' }, + { id: 'google/gemma-3-27b-it:free', name: 'Gemma 3 27B (free)' }, + ] + }, + + async chat(messages, options, onToken, onError) { + const orMessages = messages.map(m => ({ + role: m.role as 'user' | 'assistant' | 'system', + content: m.content, + })) + + // Prepend system prompt as system message + if (options.systemPrompt) { + orMessages.unshift({ role: 'system', content: options.systemPrompt }) + } + + const headers: Record = { + 'Content-Type': 'application/json', + 'HTTP-Referer': window.location.origin, + 'X-Title': 'AIUI', + } + + const vaultKey = await getApiKey('openrouter') + if (vaultKey) { + headers['Authorization'] = `Bearer ${vaultKey}` + } + + const res = await apiFetch(OPENROUTER_PATH, { + method: 'POST', + headers, + body: JSON.stringify({ + model: options.model, + messages: orMessages, + stream: true, + }), + signal: options.signal, + }) + + if (!res.ok) { + const body = await res.text().catch(() => 'Could not read error body') + onError(`OpenRouter error ${res.status}: ${body}`) + return + } + + await readSSE(res, (data) => { + if (data === '[DONE]') return + try { + const parsed = JSON.parse(data) + const delta = parsed.choices?.[0]?.delta?.content + if (delta) onToken(delta) + } catch { /* malformed SSE chunk */ } + }, onError, options.signal) + }, +} + +async function readSSE( + res: Response, + onData: (data: string) => void, + onError: (err: string) => void, + signal?: AbortSignal, +): Promise { + const reader = res.body?.getReader() + if (!reader) { + onError('No response body') + return + } + + const decoder = new TextDecoder() + let buffer = '' + + try { + while (true) { + if (signal?.aborted) { + reader.cancel() + return + } + const { done, value } = await reader.read() + if (done) break + + buffer += decoder.decode(value, { stream: true }) + const lines = buffer.split('\n') + buffer = lines.pop() ?? '' + + for (const line of lines) { + const trimmed = line.trim() + if (!trimmed || !trimmed.startsWith('data: ')) continue + const payload = trimmed.slice(6) + if (payload === '[DONE]') return + try { + onData(payload) + } catch { + // skip malformed chunks + } + } + } + } catch (err) { + if (signal?.aborted) return + onError(err instanceof Error ? err.message : 'Stream read error') + } finally { + reader.cancel().catch(() => {}) + } +} diff --git a/aiui/packages/app/src/adapters/types.ts b/aiui/packages/app/src/adapters/types.ts new file mode 100644 index 00000000..7f33a839 --- /dev/null +++ b/aiui/packages/app/src/adapters/types.ts @@ -0,0 +1,34 @@ +/** + * Unified AI adapter interface for normalizing different provider APIs. + */ + +export interface ChatMessage { + role: 'user' | 'assistant' | 'system' + content: string +} + +export interface ChatOptions { + model: string + systemPrompt?: string + webSearch?: boolean + signal?: AbortSignal +} + +export interface AIAdapter { + id: string + name: string + supportsStreaming: boolean + supportsVision: boolean + supportsTools: boolean + + /** List available models for this adapter */ + models(): { id: string; name: string }[] + + /** Stream chat completion, yielding text tokens */ + chat( + messages: ChatMessage[], + options: ChatOptions, + onToken: (text: string) => void, + onError: (err: string) => void, + ): Promise +} diff --git a/aiui/packages/app/src/components/browse/FilePreview.vue b/aiui/packages/app/src/components/browse/FilePreview.vue new file mode 100644 index 00000000..b3413fed --- /dev/null +++ b/aiui/packages/app/src/components/browse/FilePreview.vue @@ -0,0 +1,58 @@ + + + diff --git a/aiui/packages/app/src/components/browse/FileTree.vue b/aiui/packages/app/src/components/browse/FileTree.vue new file mode 100644 index 00000000..f7547634 --- /dev/null +++ b/aiui/packages/app/src/components/browse/FileTree.vue @@ -0,0 +1,124 @@ + + + diff --git a/aiui/packages/app/src/components/chat/AdvancedSettings.vue b/aiui/packages/app/src/components/chat/AdvancedSettings.vue new file mode 100644 index 00000000..1f12019d --- /dev/null +++ b/aiui/packages/app/src/components/chat/AdvancedSettings.vue @@ -0,0 +1,179 @@ + + + diff --git a/aiui/packages/app/src/components/chat/BranchSwitcher.vue b/aiui/packages/app/src/components/chat/BranchSwitcher.vue new file mode 100644 index 00000000..13b4aaaa --- /dev/null +++ b/aiui/packages/app/src/components/chat/BranchSwitcher.vue @@ -0,0 +1,55 @@ + + + diff --git a/aiui/packages/app/src/components/chat/CashuToken.vue b/aiui/packages/app/src/components/chat/CashuToken.vue new file mode 100644 index 00000000..10998859 --- /dev/null +++ b/aiui/packages/app/src/components/chat/CashuToken.vue @@ -0,0 +1,116 @@ + + + diff --git a/aiui/packages/app/src/components/chat/ChatHeader.vue b/aiui/packages/app/src/components/chat/ChatHeader.vue new file mode 100644 index 00000000..c03152aa --- /dev/null +++ b/aiui/packages/app/src/components/chat/ChatHeader.vue @@ -0,0 +1,431 @@ + + + + + diff --git a/aiui/packages/app/src/components/chat/ChatHistory.vue b/aiui/packages/app/src/components/chat/ChatHistory.vue new file mode 100644 index 00000000..3ef8a152 --- /dev/null +++ b/aiui/packages/app/src/components/chat/ChatHistory.vue @@ -0,0 +1,70 @@ + + + diff --git a/aiui/packages/app/src/components/chat/ChatInput.vue b/aiui/packages/app/src/components/chat/ChatInput.vue new file mode 100644 index 00000000..c534e076 --- /dev/null +++ b/aiui/packages/app/src/components/chat/ChatInput.vue @@ -0,0 +1,447 @@ +