Archipelago — open-source initial import

This commit is contained in:
Archipelago
2026-08-12 10:55:50 +00:00
commit 07c37b83e8
1578 changed files with 333070 additions and 0 deletions
+28
View File
@@ -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
+74
View File
@@ -0,0 +1,74 @@
name: Demo images
# Builds and pushes the public-demo images on every change to the UI / mock
# backend, so the separated `archy-demo` Portainer stack auto-tracks the real
# code (see demo-deploy/ and docs/demo-deployment-design.md).
#
# Required repo configuration:
# vars.DEMO_REGISTRY e.g. 146.59.87.168:3000/lfg2025
# vars.DEMO_REGISTRY_HOST registry host for docker login (no org suffix)
# secrets.DEMO_REGISTRY_USER
# secrets.DEMO_REGISTRY_TOKEN
# Optional:
# secrets.PORTAINER_WEBHOOK redeploy hook called after a successful push
on:
push:
branches: [main]
paths:
- 'neode-ui/**'
- 'docker-compose.demo.yml'
- '.github/workflows/demo-images.yml'
workflow_dispatch:
jobs:
build:
name: Build & push demo images
runs-on: ubuntu-latest
# Skip cleanly on forks / before registry config is set.
if: ${{ vars.DEMO_REGISTRY != '' }}
steps:
- name: Checkout
uses: actions/checkout@v4
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@v3
with:
# The demo registry is plain HTTP — teach buildkit to push without TLS
# (the host docker daemon needs it in insecure-registries for login too).
buildkitd-config-inline: |
[registry."${{ vars.DEMO_REGISTRY_HOST || vars.DEMO_REGISTRY }}"]
http = true
- name: Log in to registry
uses: docker/login-action@v3
with:
registry: ${{ vars.DEMO_REGISTRY_HOST || vars.DEMO_REGISTRY }}
username: ${{ secrets.DEMO_REGISTRY_USER }}
password: ${{ secrets.DEMO_REGISTRY_TOKEN }}
- name: Build & push backend
uses: docker/build-push-action@v6
with:
context: .
file: neode-ui/Dockerfile.backend
push: true
tags: |
${{ vars.DEMO_REGISTRY }}/archy-demo-backend:demo
${{ vars.DEMO_REGISTRY }}/archy-demo-backend:${{ github.sha }}
- name: Build & push web
uses: docker/build-push-action@v6
with:
context: .
file: neode-ui/Dockerfile.web
push: true
build-args: |
VITE_DEMO=1
tags: |
${{ vars.DEMO_REGISTRY }}/archy-demo-web:demo
${{ vars.DEMO_REGISTRY }}/archy-demo-web:${{ github.sha }}
- name: Trigger Portainer redeploy
if: ${{ success() && secrets.PORTAINER_WEBHOOK != '' }}
run: curl -fsS -X POST "${{ secrets.PORTAINER_WEBHOOK }}"
+72
View File
@@ -0,0 +1,72 @@
name: Post-Install Tests
on:
workflow_dispatch:
inputs:
target:
description: 'Target node IP (e.g. 192.168.1.198)'
required: true
default: '192.168.1.198'
password:
description: 'Node password (or "auto" for fresh install)'
required: false
default: 'auto'
jobs:
post-install-tests:
runs-on: ubuntu-latest
timeout-minutes: 15
steps:
- name: Checkout
uses: actions/checkout@v4
with:
fetch-depth: 1
- name: Run post-install tests on target
run: |
TARGET="${{ github.event.inputs.target }}"
PASSWORD="${{ github.event.inputs.password }}"
if [ "$PASSWORD" = "auto" ]; then
PASSWORD="testpass123!"
fi
echo "══════════════════════════════════════════"
echo "Running post-install tests on $TARGET"
echo "══════════════════════════════════════════"
# Copy test script to target and run
sshpass -p 'archipelago' scp -o StrictHostKeyChecking=no \
scripts/run-post-install-tests.sh \
archipelago@${TARGET}:/tmp/run-post-install-tests.sh 2>/dev/null || \
scp -o StrictHostKeyChecking=no \
scripts/run-post-install-tests.sh \
archipelago@${TARGET}:/tmp/run-post-install-tests.sh
# Run tests (with sudo for service checks)
sshpass -p 'archipelago' ssh -o StrictHostKeyChecking=no \
archipelago@${TARGET} \
"sudo bash /tmp/run-post-install-tests.sh '$PASSWORD'" 2>/dev/null || \
ssh -o StrictHostKeyChecking=no \
archipelago@${TARGET} \
"sudo bash /tmp/run-post-install-tests.sh '$PASSWORD'"
frontend-tests:
runs-on: ubuntu-latest
timeout-minutes: 10
steps:
- name: Checkout
uses: actions/checkout@v4
with:
fetch-depth: 1
- name: Install dependencies
run: cd neode-ui && npm ci
- name: Type check
run: cd neode-ui && npx vue-tsc -b --noEmit
- name: Run tests
run: cd neode-ui && npx vitest run
- name: Audit dependencies
run: cd neode-ui && npm audit --omit=dev
+51
View File
@@ -0,0 +1,51 @@
#!/usr/bin/env bash
# Keep the served companion APK in sync with main on every push.
#
# When a push to main includes Android changes, rebuild the APK, refresh
# neode-ui/public/packages/archipelago-companion.apk, commit it, and ask
# you to push again (so the refreshed APK rides along in the same push).
#
# Enable once per clone: git config core.hooksPath .githooks
set -euo pipefail
ROOT="$(git rev-parse --show-toplevel)"
cd "$ROOT"
# ship-companion.sh already (re)published the APK for this push — don't redo it.
[ -n "${SHIP_COMPANION:-}" ] && exit 0
PUSH_MAIN=0; RANGE_OLD=""; RANGE_NEW=""
while read -r _local_ref local_sha remote_ref remote_sha; do
if [ "${remote_ref##*/}" = "main" ]; then
PUSH_MAIN=1; RANGE_OLD="$remote_sha"; RANGE_NEW="$local_sha"
fi
done
[ "$PUSH_MAIN" = "1" ] || exit 0
# Loop-break: if the tip is already the auto APK commit, let the push proceed.
case "$(git log -1 --pretty=%s)" in
*"companion APK"*) exit 0 ;;
esac
# Only rebuild when this push actually touches the Android app.
ZEROS="0000000000000000000000000000000000000000"
if [ -z "$RANGE_OLD" ] || [ "$RANGE_OLD" = "$ZEROS" ]; then
ANDROID_CHANGED=1
elif git diff --quiet "$RANGE_OLD" "$RANGE_NEW" -- Android/ 2>/dev/null; then
ANDROID_CHANGED=0
else
ANDROID_CHANGED=1
fi
[ "$ANDROID_CHANGED" = "1" ] || exit 0
bash scripts/publish-companion-apk.sh || exit 0
DEST="neode-ui/public/packages/archipelago-companion.apk"
if git diff --cached --quiet -- "$DEST"; then
exit 0 # APK unchanged — nothing to do
fi
git commit -q -m "chore(android): update companion APK download [skip ci]"
echo "" >&2
echo "▶ Companion APK rebuilt and committed. Run your push again to include it." >&2
exit 1
+78
View File
@@ -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)?
+81
View File
@@ -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.
+5
View File
@@ -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.
@@ -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
+16
View File
@@ -0,0 +1,16 @@
## Summary
<!-- Brief description of what this PR does -->
## Changes
-
## Checklist
- [ ] TypeScript type-check passes (`npm run type-check`)
- [ ] Frontend builds (`npm run build`)
- [ ] Tests pass (`npm test`)
- [ ] Rust clippy clean (if backend changes)
- [ ] No new compiler warnings
- [ ] Tested on live server
+219
View File
@@ -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
+65
View File
@@ -0,0 +1,65 @@
name: CI
on:
push:
branches: [main]
pull_request:
branches: [main]
env:
RUST_VERSION: stable
NODE_VERSION: 18
jobs:
rust:
name: Rust (fmt + clippy + test)
runs-on: ubuntu-latest
defaults:
run:
working-directory: core
steps:
- name: Checkout
uses: actions/checkout@v4
- name: Setup Rust
uses: actions-rust-lang/setup-rust-toolchain@v1
with:
toolchain: ${{ env.RUST_VERSION }}
components: rustfmt, clippy
- name: Check formatting
run: cargo fmt --all -- --check
- name: Clippy
run: cargo clippy --all-targets --all-features -- -D warnings
- name: Tests
run: cargo test --all-features
frontend:
name: Frontend (type-check + lint)
runs-on: ubuntu-latest
defaults:
run:
working-directory: neode-ui
steps:
- name: Checkout
uses: actions/checkout@v4
- name: Setup Node.js
uses: actions/setup-node@v4
with:
node-version: ${{ env.NODE_VERSION }}
cache: 'npm'
cache-dependency-path: neode-ui/package-lock.json
- name: Install dependencies
run: npm ci
- name: Type check
run: npm run type-check
- name: Build
run: npm run build
+74
View File
@@ -0,0 +1,74 @@
name: Demo images
# Builds and pushes the public-demo images on every change to the UI / mock
# backend, so the separated `archy-demo` Portainer stack auto-tracks the real
# code (see demo-deploy/ and docs/demo-deployment-design.md).
#
# Required repo configuration:
# vars.DEMO_REGISTRY e.g. 146.59.87.168:3000/lfg2025
# vars.DEMO_REGISTRY_HOST registry host for docker login (no org suffix)
# secrets.DEMO_REGISTRY_USER
# secrets.DEMO_REGISTRY_TOKEN
# Optional:
# secrets.PORTAINER_WEBHOOK redeploy hook called after a successful push
on:
push:
branches: [main]
paths:
- 'neode-ui/**'
- 'docker-compose.demo.yml'
- '.github/workflows/demo-images.yml'
workflow_dispatch:
jobs:
build:
name: Build & push demo images
runs-on: ubuntu-latest
# Skip cleanly on forks / before registry config is set.
if: ${{ vars.DEMO_REGISTRY != '' }}
steps:
- name: Checkout
uses: actions/checkout@v4
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@v3
with:
# The demo registry is plain HTTP — teach buildkit to push without TLS
# (the host docker daemon needs it in insecure-registries for login too).
buildkitd-config-inline: |
[registry."${{ vars.DEMO_REGISTRY_HOST || vars.DEMO_REGISTRY }}"]
http = true
- name: Log in to registry
uses: docker/login-action@v3
with:
registry: ${{ vars.DEMO_REGISTRY_HOST || vars.DEMO_REGISTRY }}
username: ${{ secrets.DEMO_REGISTRY_USER }}
password: ${{ secrets.DEMO_REGISTRY_TOKEN }}
- name: Build & push backend
uses: docker/build-push-action@v6
with:
context: .
file: neode-ui/Dockerfile.backend
push: true
tags: |
${{ vars.DEMO_REGISTRY }}/archy-demo-backend:demo
${{ vars.DEMO_REGISTRY }}/archy-demo-backend:${{ github.sha }}
- name: Build & push web
uses: docker/build-push-action@v6
with:
context: .
file: neode-ui/Dockerfile.web
push: true
build-args: |
VITE_DEMO=1
tags: |
${{ vars.DEMO_REGISTRY }}/archy-demo-web:demo
${{ vars.DEMO_REGISTRY }}/archy-demo-web:${{ github.sha }}
- name: Trigger Portainer redeploy
if: ${{ success() && secrets.PORTAINER_WEBHOOK != '' }}
run: curl -fsS -X POST "${{ secrets.PORTAINER_WEBHOOK }}"
+98
View File
@@ -0,0 +1,98 @@
# SSH keys (sandbox copies)
.ssh/
# Rust build output
target/
**/target/
Cargo.lock
# Node.js
node_modules/
**/node_modules/
npm-debug.log*
yarn-debug.log*
yarn-error.log*
package-lock.json
pnpm-debug.log*
# Build outputs
dist/
dist-ssr/
build/
*.local
# IDE / editor
.idea/
.vscode/
*.swp
*.swo
*~
.DS_Store
# Environment and local overrides
.env
.env.local
.env.*.local
scripts/deploy-config.sh
# Logs
logs/
*.log
# OS
.DS_Store
Thumbs.db
# Testing
coverage/
.nyc_output/
# Temporary files
*.tmp
*.temp
# Build artifacts
*.iso
*.img
*.dmg
*.app
# Release artifacts live in Gitea Release attachments, not Git history.
releases/**
!releases/
!releases/manifest.json
# macOS build output
build/macos/
# Image recipe output
image-recipe/output/
image-recipe/*.iso
image-recipe/*.img
# Loop tool artifacts (created in every subdirectory)
*/loop/
loop/loop/
loop/loop.log.bak
# Separate repos nested in tree
web/
._*
# Resilience harness reports (generated, contains session cookies)
scripts/resilience/reports/
# Codex / pnpm / python caches / editor backups
.codex
.codex-target-*/
.codex-tmp/
.pnpm-store/
**/__pycache__/
*.bak
.claude/scheduled_tasks.lock
# Local evidence screenshots; intentional UI screenshots should live under an
# app/docs asset path with a descriptive filename.
Screenshot *.png
uploads/
+3
View File
@@ -0,0 +1,3 @@
[submodule "indeedhub"]
path = indeedhub
url = http://146.59.87.168:3000/lfg2025/indeehub.git
+21
View File
@@ -0,0 +1,21 @@
*.iml
.gradle
/local.properties
/.idea
.DS_Store
/build
/captures
.externalNativeBuild
.cxx
local.properties
/app/build
/app/release
*.apk
*.aab
*.jks
*.keystore
# Exception: the repo-dedicated *debug* keystore is committed on purpose so every
# machine (and the published companion download) signs debug builds identically —
# updates then install over the top without an uninstall. Debug keys are not
# secret (well-known password "android"); never commit a real release keystore.
!/app/debug.keystore
+94
View File
@@ -0,0 +1,94 @@
# Companion App — Build, Ship & "App Not Installed" Runbook
Canonical procedure for releasing the Archipelago Companion Android app and for
debugging install failures. Read this before touching the companion release flow.
Hard lessons from 2026-06-26 are baked in below — don't relearn them.
## Ship the companion (the only sanctioned way)
```bash
./Android/ship-companion.sh
```
This calls `scripts/publish-companion-apk.sh` (the single source of truth, also
used by the `.githooks/pre-push` hook), which:
1. **Removes/rejects resource dirs whose names contain spaces.** Empty stray
`mipmap-* NNN` dirs (left by icon-export tools) break a *clean* build with
`Invalid resource directory name`. Incremental builds hide them — clean builds
don't.
2. **Always does a CLEAN build** (`:app:clean :app:assembleDebug`).
3. **Forces v1 + v2 + v3 signing** via `zipalign` + `apksigner`.
4. **Verifies all three schemes** (`apksigner verify --min-sdk-version 21`) and
**aborts** if any is missing.
5. Stages the signed APK at `neode-ui/public/packages/archipelago-companion.apk`,
commits, and pushes with `SHIP_COMPANION=1` (the sanctioned pre-push bypass).
**Never** hand-roll `gradlew assembleDebug` + `cp` to the served path. That path
skips the clean build and the signature enforcement and is exactly how a broken
APK shipped.
### Bump the version first
Edit `Android/app/build.gradle.kts``versionCode` (must strictly increase) and
`versionName`. The committed value can drift AHEAD of what's actually built into
the served APK, so verify the served APK's real version after shipping:
`aapt2 dump badging neode-ui/public/packages/archipelago-companion.apk | grep version`.
## Signing facts (important)
- Debug builds are signed with the **committed** `Android/app/debug.keystore`
(store/key pass `android`, alias `androiddebugkey`) so every machine and the
served download share ONE signing key. Cert SHA-256: `D6:22:E0:7E:…:66:4D`.
- **AGP silently ignores `enableV1Signing = true` for `minSdk ≥ 24`**, so a plain
gradle build produces a **v2-only** APK. The `apksigner` step in the publish
script is what actually guarantees v1+v2+v3 — do not remove it.
- **Changing the signing key forces every existing install to be uninstalled
once.** Android blocks in-place upgrades across different signatures. Treat the
keystore as permanent; never regenerate it casually.
## Debugging "App Not Installed" — DIAGNOSE FIRST
Do **not** theorize about signing schemes / OEM quirks. Get the real reason:
```bash
adb install ~/Desktop/archipelago-companion-<ver>.apk
# -> Failure [INSTALL_FAILED_<REASON>: ...]
```
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 <pkg>` under user 0
> can show nothing while the conflict persists. `adb uninstall <pkg>` clears it
> across all users.
## Phone / adb safety (non-negotiable)
When acting on the user's physical phone, be surgical — the user once had all
home-screen app layouts wiped by an over-broad action.
- Default to **read-only** adb (`devices`, `getprop`, `pm path/list`, `dumpsys`).
- Mutations (`adb install`, `adb uninstall com.archipelago.app.debug`) only with
explicit go-ahead and **scoped to our exact package** — echo it first.
- **Never** run launcher/system resets: no `pm clear` on launchers, no
`reset-permissions`, no factory wipe, no uninstalling apps you didn't build.
## Verify the published download after shipping
The download served to nodes is Gitea raw-on-main. Confirm the live bytes match
what you built and signed:
```bash
SERVED=neode-ui/public/packages/archipelago-companion.apk
URL=http://146.59.87.168:3000/lfg2025/archy/raw/branch/main/$SERVED
curl -sS -o /tmp/live.apk "$URL"
shasum -a 256 "$SERVED" /tmp/live.apk # must match
apksigner verify -v --min-sdk-version 21 /tmp/live.apk | grep -i "scheme" # v1/v2/v3 = true
```
+116
View File
@@ -0,0 +1,116 @@
plugins {
id("com.android.application")
id("org.jetbrains.kotlin.android")
}
android {
namespace = "com.archipelago.app"
compileSdk = 35
defaultConfig {
applicationId = "com.archipelago.app"
minSdk = 26
targetSdk = 35
versionCode = 16
versionName = "0.4.12"
vectorDrawables {
useSupportLibrary = true
}
}
signingConfigs {
// Repo-dedicated debug keystore (committed at app/debug.keystore) so every
// machine — and the published companion download — signs debug builds with
// the SAME key. Without this, Gradle falls back to each machine's
// ~/.android/debug.keystore, so a build from a different machine has a
// different signature and the phone rejects the update ("App not installed").
getByName("debug") {
storeFile = file("debug.keystore")
storePassword = "android"
keyAlias = "androiddebugkey"
keyPassword = "android"
// Force both legacy JAR (v1) and APK Signature Scheme v2. AGP drops v1
// for minSdk>=24, but some OEM package installers (e.g. Samsung) reject
// a v2-only sideload with "App not installed" — keep v1 for max compat.
enableV1Signing = true
enableV2Signing = true
}
}
buildTypes {
debug {
// Separate app ID so a debug/test build installs alongside the
// release app instead of colliding on signature.
applicationIdSuffix = ".debug"
versionNameSuffix = "-debug"
signingConfig = signingConfigs.getByName("debug")
}
release {
isMinifyEnabled = true
isShrinkResources = true
proguardFiles(
getDefaultProguardFile("proguard-android-optimize.txt"),
"proguard-rules.pro"
)
}
}
compileOptions {
sourceCompatibility = JavaVersion.VERSION_17
targetCompatibility = JavaVersion.VERSION_17
}
kotlinOptions {
jvmTarget = "17"
}
buildFeatures {
compose = true
}
composeOptions {
kotlinCompilerExtensionVersion = "1.5.14"
}
packaging {
resources {
excludes += "/META-INF/{AL2.0,LGPL2.1}"
}
}
}
dependencies {
val composeBom = platform("androidx.compose:compose-bom:2024.05.00")
implementation(composeBom)
implementation("androidx.core:core-ktx:1.13.1")
implementation("androidx.lifecycle:lifecycle-runtime-ktx:2.8.2")
implementation("androidx.activity:activity-compose:1.9.0")
// Compose
implementation("androidx.compose.ui:ui")
implementation("androidx.compose.ui:ui-graphics")
implementation("androidx.compose.ui:ui-tooling-preview")
implementation("androidx.compose.material3:material3")
implementation("androidx.compose.material:material-icons-extended")
implementation("androidx.compose.animation:animation")
// Navigation
implementation("androidx.navigation:navigation-compose:2.7.7")
// DataStore for preferences
implementation("androidx.datastore:datastore-preferences:1.1.1")
// WebView
implementation("androidx.webkit:webkit:1.11.0")
// Splash screen
implementation("androidx.core:core-splashscreen:1.0.1")
// OkHttp for WebSocket (remote input)
implementation("com.squareup.okhttp3:okhttp:4.12.0")
debugImplementation("androidx.compose.ui:ui-tooling")
debugImplementation("androidx.compose.ui:ui-test-manifest")
}
Binary file not shown.
+7
View File
@@ -0,0 +1,7 @@
# Keep WebView JavaScript interface
-keepclassmembers class com.archipelago.app.ui.screens.WebViewScreen$* {
public *;
}
# Keep Compose
-dontwarn androidx.compose.**
+32
View File
@@ -0,0 +1,32 @@
<?xml version="1.0" encoding="utf-8"?>
<manifest xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:tools="http://schemas.android.com/tools">
<uses-permission android:name="android.permission.INTERNET" />
<uses-permission android:name="android.permission.ACCESS_NETWORK_STATE" />
<application
android:name=".ArchipelagoApp"
android:allowBackup="true"
android:icon="@mipmap/ic_launcher"
android:roundIcon="@mipmap/ic_launcher_round"
android:label="@string/app_name"
android:supportsRtl="true"
android:theme="@style/Theme.Archipelago"
android:usesCleartextTraffic="true"
tools:targetApi="35">
<activity
android:name=".MainActivity"
android:exported="true"
android:theme="@style/Theme.Archipelago.Splash"
android:windowSoftInputMode="adjustResize"
android:configChanges="orientation|screenSize|screenLayout|keyboardHidden">
<intent-filter>
<action android:name="android.intent.action.MAIN" />
<category android:name="android.intent.category.LAUNCHER" />
</intent-filter>
</activity>
</application>
</manifest>
+492
View File
@@ -0,0 +1,492 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0, maximum-scale=1.0, user-scalable=no, viewport-fit=cover">
<title>Archipelago</title>
<style>
@import url('https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700&display=swap');
* { margin: 0; padding: 0; box-sizing: border-box; }
body {
font-family: 'Inter', -apple-system, BlinkMacSystemFont, sans-serif;
background: #000;
color: #f5f5f5;
min-height: 100vh;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
padding: 24px;
padding-top: calc(24px + env(safe-area-inset-top, 0px));
overflow: hidden;
-webkit-tap-highlight-color: transparent;
}
/* --- Intro Screen --- */
#intro {
display: flex;
flex-direction: column;
align-items: center;
text-align: center;
gap: 20px;
animation: fadeIn 0.6s ease;
}
#intro.hidden, #connect.hidden, #connecting.hidden { display: none; }
.logo-container {
width: 88px;
height: 88px;
border-radius: 20px;
overflow: hidden;
border: 2px solid rgba(255,255,255,0.12);
background: #030202;
}
.logo-container svg { width: 100%; height: 100%; }
.logo-square {
opacity: 0;
animation: squareIn 3s ease-out infinite;
}
@keyframes squareIn {
0% { opacity: 0; }
15% { opacity: 1; }
100% { opacity: 1; }
}
.brand-name {
font-size: 12px;
font-weight: 600;
letter-spacing: 6px;
color: #F7931A;
text-transform: uppercase;
}
h1 {
font-size: 28px;
font-weight: 600;
line-height: 1.3;
color: #f5f5f5;
margin-top: 16px;
}
.subtitle {
font-size: 16px;
color: #666;
line-height: 1.6;
max-width: 320px;
}
/* --- Glass Button --- */
.glass-button {
width: 100%;
max-width: 340px;
padding: 16px 24px;
border-radius: 12px;
font-size: 16px;
font-weight: 600;
cursor: pointer;
border: none;
transition: all 0.15s ease;
-webkit-tap-highlight-color: transparent;
}
.glass-button:active { transform: scale(0.97); }
.glass-button-primary {
background: #F7931A;
color: #000;
}
.glass-button-primary:disabled {
background: rgba(247,147,26,0.3);
color: rgba(0,0,0,0.5);
}
.glass-button-outline {
background: rgba(255,255,255,0.06);
color: #f5f5f5;
border: 1px solid rgba(255,255,255,0.12);
backdrop-filter: blur(12px);
-webkit-backdrop-filter: blur(12px);
}
/* --- Connect Screen --- */
#connect {
width: 100%;
max-width: 400px;
display: flex;
flex-direction: column;
align-items: center;
gap: 20px;
animation: fadeIn 0.4s ease;
}
.glass-card {
width: 100%;
padding: 20px;
border-radius: 16px;
background: rgba(255,255,255,0.04);
border: 1px solid rgba(255,255,255,0.08);
backdrop-filter: blur(16px);
-webkit-backdrop-filter: blur(16px);
}
.form-group { margin-bottom: 16px; }
.form-group:last-child { margin-bottom: 0; }
label {
display: block;
font-size: 13px;
font-weight: 500;
color: rgba(255,255,255,0.5);
margin-bottom: 8px;
}
input[type="text"] {
width: 100%;
padding: 14px 16px;
border-radius: 12px;
border: 1px solid rgba(255,255,255,0.12);
background: transparent;
color: #f5f5f5;
font-size: 16px;
font-family: inherit;
outline: none;
transition: border-color 0.2s;
}
input[type="text"]:focus {
border-color: #F7931A;
}
input[type="text"]::placeholder {
color: rgba(255,255,255,0.25);
}
.port-row {
display: flex;
gap: 12px;
align-items: flex-end;
}
.port-input { width: 120px; }
.toggle-row {
display: flex;
align-items: center;
justify-content: space-between;
padding: 4px 0;
}
.toggle-label {
display: flex;
align-items: center;
gap: 8px;
font-size: 14px;
color: rgba(255,255,255,0.7);
}
.toggle-label svg { width: 18px; height: 18px; opacity: 0.5; }
/* Toggle switch */
.toggle {
position: relative;
width: 48px;
height: 28px;
-webkit-appearance: none;
appearance: none;
background: rgba(255,255,255,0.12);
border-radius: 14px;
border: none;
cursor: pointer;
transition: background 0.2s;
}
.toggle:checked { background: #F7931A; }
.toggle::before {
content: '';
position: absolute;
top: 3px;
left: 3px;
width: 22px;
height: 22px;
border-radius: 50%;
background: #fff;
transition: transform 0.2s;
}
.toggle:checked::before { transform: translateX(20px); }
/* Error */
.error-msg {
width: 100%;
padding: 12px 16px;
border-radius: 12px;
background: rgba(239,68,68,0.12);
border: 1px solid rgba(239,68,68,0.25);
color: #ef4444;
font-size: 14px;
display: none;
}
.error-msg.visible { display: block; }
/* Saved servers */
.saved-title {
font-size: 11px;
font-weight: 600;
letter-spacing: 1px;
color: rgba(255,255,255,0.3);
text-transform: uppercase;
}
.saved-item {
width: 100%;
display: flex;
align-items: center;
justify-content: space-between;
padding: 14px 16px;
border-radius: 12px;
background: rgba(255,255,255,0.04);
border: 1px solid rgba(255,255,255,0.08);
cursor: pointer;
transition: background 0.15s;
}
.saved-item:active { background: rgba(255,255,255,0.08); }
.saved-addr {
font-size: 14px;
color: #f5f5f5;
}
.saved-remove {
background: none;
border: none;
color: rgba(255,255,255,0.3);
font-size: 18px;
cursor: pointer;
padding: 4px 8px;
}
/* Connecting overlay */
#connecting {
display: flex;
flex-direction: column;
align-items: center;
gap: 16px;
animation: fadeIn 0.3s ease;
}
.spinner {
width: 32px;
height: 32px;
border: 3px solid rgba(247,147,26,0.2);
border-top-color: #F7931A;
border-radius: 50%;
animation: spin 0.8s linear infinite;
}
@keyframes spin { to { transform: rotate(360deg); } }
@keyframes fadeIn { from { opacity: 0; transform: translateY(8px); } to { opacity: 1; transform: none; } }
/* Hide scrollbar */
::-webkit-scrollbar { display: none; }
</style>
</head>
<body>
<!-- Intro -->
<div id="intro">
<div class="logo-container">
<svg viewBox="0 0 1024 1024" fill="none">
<rect width="1024" height="1024" fill="#030202"/>
<rect x="357.614" y="318" width="71.007" height="70.936" fill="white" class="logo-square" style="animation-delay:0ms"/>
<rect x="436.152" y="318" width="72.082" height="70.936" fill="white" class="logo-square" style="animation-delay:100ms"/>
<rect x="515.766" y="318" width="72.082" height="70.936" fill="white" class="logo-square" style="animation-delay:200ms"/>
<rect x="595.379" y="318" width="71.007" height="70.936" fill="white" class="logo-square" style="animation-delay:300ms"/>
<rect x="595.379" y="396.46" width="71.007" height="72.011" fill="white" class="logo-square" style="animation-delay:400ms"/>
<rect x="673.917" y="396.46" width="72.083" height="72.011" fill="white" class="logo-square" style="animation-delay:500ms"/>
<rect x="278" y="475.994" width="72.083" height="72.012" fill="white" class="logo-square" style="animation-delay:600ms"/>
<rect x="357.614" y="475.994" width="71.007" height="72.012" fill="white" class="logo-square" style="animation-delay:700ms"/>
<rect x="436.152" y="475.994" width="72.082" height="72.012" fill="white" class="logo-square" style="animation-delay:800ms"/>
<rect x="515.766" y="475.994" width="72.082" height="72.012" fill="white" class="logo-square" style="animation-delay:900ms"/>
<rect x="595.379" y="475.994" width="71.007" height="72.012" fill="white" class="logo-square" style="animation-delay:1000ms"/>
<rect x="673.917" y="475.994" width="72.083" height="72.012" fill="white" class="logo-square" style="animation-delay:1100ms"/>
<rect x="278" y="555.529" width="72.083" height="70.936" fill="white" class="logo-square" style="animation-delay:1200ms"/>
<rect x="357.614" y="555.529" width="71.007" height="70.936" fill="white" class="logo-square" style="animation-delay:1300ms"/>
<rect x="595.379" y="555.529" width="71.007" height="70.936" fill="white" class="logo-square" style="animation-delay:1400ms"/>
<rect x="673.917" y="555.529" width="72.083" height="70.936" fill="white" class="logo-square" style="animation-delay:1500ms"/>
<rect x="357.614" y="633.989" width="71.007" height="72.011" fill="white" class="logo-square" style="animation-delay:1600ms"/>
<rect x="436.152" y="633.989" width="72.082" height="72.011" fill="white" class="logo-square" style="animation-delay:1700ms"/>
<rect x="515.766" y="633.989" width="72.082" height="72.011" fill="white" class="logo-square" style="animation-delay:1800ms"/>
<rect x="595.379" y="633.989" width="71.007" height="72.011" fill="white" class="logo-square" style="animation-delay:1900ms"/>
</svg>
</div>
<span class="brand-name">Archipelago</span>
<h1>Your Sovereign<br>Personal Server</h1>
<p class="subtitle">Bitcoin node, app platform, and private cloud — all in one box you control.</p>
<button class="glass-button glass-button-primary" onclick="showConnect()" style="margin-top:16px">Get Started</button>
</div>
<!-- Connect -->
<div id="connect" class="hidden">
<div class="logo-container" style="width:56px;height:56px;border-radius:14px">
<svg viewBox="0 0 1024 1024" fill="none">
<rect width="1024" height="1024" fill="#030202"/>
<rect x="357.614" y="318" width="71.007" height="70.936" fill="white"/>
<rect x="436.152" y="318" width="72.082" height="70.936" fill="white"/>
<rect x="515.766" y="318" width="72.082" height="70.936" fill="white"/>
<rect x="595.379" y="318" width="71.007" height="70.936" fill="white"/>
<rect x="595.379" y="396.46" width="71.007" height="72.011" fill="white"/>
<rect x="673.917" y="396.46" width="72.083" height="72.011" fill="white"/>
<rect x="278" y="475.994" width="72.083" height="72.012" fill="white"/>
<rect x="357.614" y="475.994" width="71.007" height="72.012" fill="white"/>
<rect x="436.152" y="475.994" width="72.082" height="72.012" fill="white"/>
<rect x="515.766" y="475.994" width="72.082" height="72.012" fill="white"/>
<rect x="595.379" y="475.994" width="71.007" height="72.012" fill="white"/>
<rect x="673.917" y="475.994" width="72.083" height="72.012" fill="white"/>
<rect x="278" y="555.529" width="72.083" height="70.936" fill="white"/>
<rect x="357.614" y="555.529" width="71.007" height="70.936" fill="white"/>
<rect x="595.379" y="555.529" width="71.007" height="70.936" fill="white"/>
<rect x="673.917" y="555.529" width="72.083" height="70.936" fill="white"/>
<rect x="357.614" y="633.989" width="71.007" height="72.011" fill="white"/>
<rect x="436.152" y="633.989" width="72.082" height="72.011" fill="white"/>
<rect x="515.766" y="633.989" width="72.082" height="72.011" fill="white"/>
<rect x="595.379" y="633.989" width="71.007" height="72.011" fill="white"/>
</svg>
</div>
<h1 style="font-size:22px">Connect to Server</h1>
<p class="subtitle" style="font-size:14px">Enter your Archipelago server IP or hostname</p>
<div class="glass-card">
<div class="form-group">
<label>Server Address</label>
<input type="text" id="address" placeholder="192.168.1.100" autocomplete="off" autocorrect="off" autocapitalize="off" spellcheck="false">
</div>
<div class="form-group">
<div class="port-row">
<div class="port-input">
<label>Port (optional)</label>
<input type="text" id="port" placeholder="80" inputmode="numeric" pattern="[0-9]*">
</div>
</div>
</div>
<div class="form-group">
<div class="toggle-row">
<span class="toggle-label">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><rect x="3" y="11" width="18" height="11" rx="2"/><path d="M7 11V7a5 5 0 0110 0v4"/></svg>
Use HTTPS
</span>
<input type="checkbox" id="https" class="toggle">
</div>
</div>
</div>
<div id="error" class="error-msg"></div>
<button class="glass-button glass-button-primary" id="connectBtn" onclick="doConnect()" disabled>Connect</button>
<div id="savedServers"></div>
</div>
<!-- Connecting -->
<div id="connecting" class="hidden">
<div class="spinner"></div>
<p style="color:rgba(255,255,255,0.6);font-size:14px">Connecting…</p>
</div>
<script>
var STORAGE_KEY = 'archipelago_servers';
var ACTIVE_KEY = 'archipelago_active';
function showConnect() {
document.getElementById('intro').classList.add('hidden');
document.getElementById('connect').classList.remove('hidden');
document.getElementById('address').focus();
renderSaved();
}
// Enable button when address has content
document.getElementById('address').addEventListener('input', function() {
document.getElementById('connectBtn').disabled = !this.value.trim();
});
// Enter to connect
document.getElementById('address').addEventListener('keydown', function(e) {
if (e.key === 'Enter' && this.value.trim()) doConnect();
});
document.getElementById('port').addEventListener('keydown', function(e) {
if (e.key === 'Enter') doConnect();
});
function buildUrl() {
var addr = document.getElementById('address').value.trim();
var port = document.getElementById('port').value.trim();
var https = document.getElementById('https').checked;
var scheme = https ? 'https' : 'http';
var portSuffix = port ? ':' + port : '';
return scheme + '://' + addr + portSuffix;
}
function doConnect() {
var addr = document.getElementById('address').value.trim();
if (!addr) return;
var url = buildUrl();
document.getElementById('connect').classList.add('hidden');
document.getElementById('connecting').classList.remove('hidden');
document.getElementById('error').classList.remove('visible');
// Save and navigate directly — no XHR test needed,
// the WebView error handler catches failures
saveServer(url);
localStorage.setItem(ACTIVE_KEY, url);
AndroidBridge.onConnected(url);
}
function saveServer(url) {
var saved = JSON.parse(localStorage.getItem(STORAGE_KEY) || '[]');
if (saved.indexOf(url) === -1) saved.push(url);
localStorage.setItem(STORAGE_KEY, JSON.stringify(saved));
}
function removeServer(url) {
var saved = JSON.parse(localStorage.getItem(STORAGE_KEY) || '[]');
saved = saved.filter(function(s) { return s !== url; });
localStorage.setItem(STORAGE_KEY, JSON.stringify(saved));
renderSaved();
}
function connectSaved(url) {
document.getElementById('intro').classList.add('hidden');
document.getElementById('connect').classList.add('hidden');
document.getElementById('connecting').classList.remove('hidden');
localStorage.setItem(ACTIVE_KEY, url);
AndroidBridge.onConnected(url);
}
function renderSaved() {
var saved = JSON.parse(localStorage.getItem(STORAGE_KEY) || '[]');
var container = document.getElementById('savedServers');
if (!saved.length) { container.innerHTML = ''; return; }
var html = '<p class="saved-title" style="margin-top:8px;margin-bottom:8px">Saved Servers</p>';
saved.forEach(function(url) {
html += '<div class="saved-item" onclick="connectSaved(\'' + url + '\')">' +
'<span class="saved-addr">' + url.replace(/^https?:\/\//, '') + '</span>' +
'<button class="saved-remove" onclick="event.stopPropagation();removeServer(\'' + url + '\')">&times;</button>' +
'</div>';
});
container.innerHTML = html;
}
// On load: check if already connected
(function() {
var active = localStorage.getItem(ACTIVE_KEY);
if (active) {
connectSaved(active);
}
})();
</script>
</body>
</html>
@@ -0,0 +1,5 @@
package com.archipelago.app
import android.app.Application
class ArchipelagoApp : Application()
@@ -0,0 +1,22 @@
package com.archipelago.app
import android.os.Bundle
import androidx.activity.ComponentActivity
import androidx.activity.compose.setContent
import androidx.activity.enableEdgeToEdge
import androidx.core.splashscreen.SplashScreen.Companion.installSplashScreen
import com.archipelago.app.ui.navigation.AppNavHost
import com.archipelago.app.ui.theme.ArchipelagoTheme
class MainActivity : ComponentActivity() {
override fun onCreate(savedInstanceState: Bundle?) {
installSplashScreen()
enableEdgeToEdge()
super.onCreate(savedInstanceState)
setContent {
ArchipelagoTheme {
AppNavHost()
}
}
}
}
@@ -0,0 +1,167 @@
package com.archipelago.app.data
import android.content.Context
import androidx.datastore.core.DataStore
import androidx.datastore.preferences.core.Preferences
import androidx.datastore.preferences.core.booleanPreferencesKey
import androidx.datastore.preferences.core.edit
import androidx.datastore.preferences.core.stringPreferencesKey
import androidx.datastore.preferences.core.stringSetPreferencesKey
import androidx.datastore.preferences.preferencesDataStore
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.map
private val Context.dataStore: DataStore<Preferences> by preferencesDataStore(name = "server_prefs")
data class ServerEntry(
val address: String,
val useHttps: Boolean,
val port: String = "",
val password: String = "",
val name: String = "",
) {
/** Label to show in lists — the user-given name, or the address if unnamed. */
fun displayName(): String = name.ifBlank { address }
fun toUrl(): String {
val scheme = if (useHttps) "https" else "http"
val portSuffix = if (port.isNotBlank()) ":$port" else ""
return "$scheme://$address$portSuffix"
}
fun toWsUrl(): String {
val scheme = if (useHttps) "wss" else "ws"
val portSuffix = if (port.isNotBlank()) ":$port" else ""
return "$scheme://$address$portSuffix"
}
// name is the trailing field so entries saved before naming existed
// (4 fields) still deserialize, with name defaulting to "".
fun serialize(): String = "$address|$useHttps|$port|$password|$name"
companion object {
fun deserialize(raw: String): ServerEntry? {
val parts = raw.split("|")
if (parts.size < 2) return null
return ServerEntry(
address = parts[0],
useHttps = parts[1].toBooleanStrictOrNull() ?: false,
port = parts.getOrElse(2) { "" },
password = parts.getOrElse(3) { "" },
name = parts.getOrElse(4) { "" },
)
}
}
}
class ServerPreferences(private val context: Context) {
private val activeAddressKey = stringPreferencesKey("active_address")
private val activeHttpsKey = booleanPreferencesKey("active_https")
private val activePortKey = stringPreferencesKey("active_port")
private val activePasswordKey = stringPreferencesKey("active_password")
private val activeNameKey = stringPreferencesKey("active_name")
private val savedServersKey = stringSetPreferencesKey("saved_servers")
private val introSeenKey = booleanPreferencesKey("intro_seen")
val activeServer: Flow<ServerEntry?> = context.dataStore.data.map { prefs ->
val address = prefs[activeAddressKey] ?: return@map null
ServerEntry(
address = address,
useHttps = prefs[activeHttpsKey] ?: false,
port = prefs[activePortKey] ?: "",
password = prefs[activePasswordKey] ?: "",
name = prefs[activeNameKey] ?: "",
)
}
val savedServers: Flow<List<ServerEntry>> = context.dataStore.data.map { prefs ->
val raw = prefs[savedServersKey] ?: emptySet()
raw.mapNotNull { ServerEntry.deserialize(it) }
}
val introSeen: Flow<Boolean> = context.dataStore.data.map { prefs ->
prefs[introSeenKey] ?: false
}
suspend fun setActiveServer(server: ServerEntry) {
context.dataStore.edit { prefs ->
prefs[activeAddressKey] = server.address
prefs[activeHttpsKey] = server.useHttps
prefs[activePortKey] = server.port
prefs[activePasswordKey] = server.password
prefs[activeNameKey] = server.name
}
addSavedServer(server)
}
suspend fun clearActiveServer() {
context.dataStore.edit { prefs ->
prefs.remove(activeAddressKey)
prefs.remove(activeHttpsKey)
prefs.remove(activePortKey)
prefs.remove(activePasswordKey)
prefs.remove(activeNameKey)
}
}
suspend fun addSavedServer(server: ServerEntry) {
context.dataStore.edit { prefs ->
val current = prefs[savedServersKey] ?: emptySet()
prefs[savedServersKey] = current + server.serialize()
}
}
/**
* Replace a saved server in place. Matches the existing entry by connection
* identity (address/port/scheme) so edits that change the name or password —
* or that touch a legacy 4-field entry — still update the right record. If the
* edited server is also the active one, the active record is kept in sync.
*/
suspend fun updateSavedServer(original: ServerEntry, updated: ServerEntry) {
context.dataStore.edit { prefs ->
val current = prefs[savedServersKey] ?: emptySet()
val filtered = current.filterNot { raw ->
val e = ServerEntry.deserialize(raw)
e != null &&
e.address == original.address &&
e.port == original.port &&
e.useHttps == original.useHttps
}.toSet()
prefs[savedServersKey] = filtered + updated.serialize()
val isActive = prefs[activeAddressKey] == original.address &&
(prefs[activePortKey] ?: "") == original.port &&
(prefs[activeHttpsKey] ?: false) == original.useHttps
if (isActive) {
prefs[activeAddressKey] = updated.address
prefs[activeHttpsKey] = updated.useHttps
prefs[activePortKey] = updated.port
prefs[activePasswordKey] = updated.password
prefs[activeNameKey] = updated.name
}
}
}
suspend fun removeSavedServer(server: ServerEntry) {
context.dataStore.edit { prefs ->
val current = prefs[savedServersKey] ?: emptySet()
// Match by connection identity (address/port/scheme) rather than the
// exact serialized string, so a rename — or the legacy 4-field format
// saved before names existed — still removes the right entry.
prefs[savedServersKey] = current.filterNot { raw ->
val e = ServerEntry.deserialize(raw)
e != null &&
e.address == server.address &&
e.port == server.port &&
e.useHttps == server.useHttps
}.toSet()
}
}
suspend fun markIntroSeen() {
context.dataStore.edit { prefs ->
prefs[introSeenKey] = true
}
}
}
@@ -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<ConnectionState> = _state
private val trustManager = object : X509TrustManager {
override fun checkClientTrusted(chain: Array<X509Certificate>?, authType: String?) {}
override fun checkServerTrusted(chain: Array<X509Certificate>?, authType: String?) {}
override fun getAcceptedIssuers(): Array<X509Certificate> = 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}""")
}
}
@@ -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)
}
}
@@ -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<Job?>(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))
}
}
@@ -0,0 +1,134 @@
package com.archipelago.app.ui.components
import androidx.compose.foundation.background
import androidx.compose.foundation.gestures.awaitEachGesture
import androidx.compose.foundation.gestures.awaitFirstDown
import androidx.compose.foundation.gestures.detectTapGestures
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.width
import androidx.compose.foundation.shape.CircleShape
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.input.pointer.changedToUp
import androidx.compose.ui.input.pointer.pointerInput
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.unit.Dp
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import com.archipelago.app.ui.theme.BitcoinOrange
import com.archipelago.app.ui.theme.Neo
import com.archipelago.app.ui.theme.neoInset
import com.archipelago.app.ui.theme.neoRaised
@Composable
fun GamepadLayout(
onKey: (String) -> Unit,
onTwoFingerHold: () -> Unit,
modifier: Modifier = Modifier,
) {
val surface = Neo.surface()
Box(
modifier = modifier
.fillMaxSize()
.background(surface)
.pointerInput(Unit) {
awaitEachGesture {
awaitFirstDown(requireUnconsumed = false)
var t = 0L; var fired = false
do {
val ev = awaitPointerEvent()
val a = ev.changes.filter { !it.changedToUp() }
if (a.size >= 2 && t == 0L) t = System.currentTimeMillis()
if (a.size >= 2 && !fired && t > 0 && System.currentTimeMillis() - t > 500) { fired = true; onTwoFingerHold() }
if (a.size < 2) t = 0L
} while (ev.changes.any { it.pressed })
}
}
.padding(horizontal = 24.dp, vertical = 16.dp),
) {
// D-pad — centered left
DPad(
onDirection = onKey,
modifier = Modifier.align(Alignment.CenterStart).size(200.dp),
)
// Face buttons — centered right (diamond)
Column(
modifier = Modifier.align(Alignment.CenterEnd),
horizontalAlignment = Alignment.CenterHorizontally,
verticalArrangement = Arrangement.spacedBy(6.dp),
) {
FaceBtn("esc", 64.dp) { onKey("Escape") }
Row(horizontalArrangement = Arrangement.spacedBy(28.dp)) {
FaceBtn("tab", 64.dp) { onKey("Tab") }
FaceBtn("enter", 64.dp, accent = true) { onKey("Return") }
}
FaceBtn("bksp", 64.dp) { onKey("BackSpace") }
}
// Bottom: L, SELECT, START, R
Row(
modifier = Modifier.align(Alignment.BottomCenter),
horizontalArrangement = Arrangement.spacedBy(16.dp),
verticalAlignment = Alignment.CenterVertically,
) {
PillBtn("L", 56.dp) { onKey("Prior") }
PillBtn("SELECT", 80.dp) { onKey("Escape") }
PillBtn("START", 80.dp) { onKey("Return") }
PillBtn("R", 56.dp) { onKey("Next") }
}
}
}
@Composable
private fun FaceBtn(label: String, size: Dp, accent: Boolean = false, onClick: () -> Unit) {
var p by remember { mutableStateOf(false) }
val l = Neo.shadowLight(); val d = Neo.shadowDark()
val tc = if (accent) BitcoinOrange.copy(alpha = 0.7f) else Neo.textSecondary()
Box(
modifier = Modifier
.size(size)
.then(if (p) Modifier.neoInset(l, d, size / 2, 1.dp, 3.dp) else Modifier.neoRaised(l, d, size / 2, 2.dp, 4.dp))
.clip(CircleShape)
.background(Neo.surfaceRaised())
.pointerInput(Unit) { detectTapGestures(onPress = { p = true; onClick(); tryAwaitRelease(); p = false }) },
contentAlignment = Alignment.Center,
) {
Text(label, color = if (p) tc.copy(alpha = 1f) else tc, fontSize = 12.sp, fontWeight = FontWeight.SemiBold, letterSpacing = 0.5.sp)
}
}
@Composable
private fun PillBtn(label: String, w: Dp, onClick: () -> Unit) {
var p by remember { mutableStateOf(false) }
val l = Neo.shadowLight(); val d = Neo.shadowDark()
Box(
modifier = Modifier
.width(w).height(34.dp)
.then(if (p) Modifier.neoInset(l, d, 8.dp, 1.dp, 2.dp) else Modifier.neoRaised(l, d, 8.dp, 2.dp, 4.dp))
.clip(RoundedCornerShape(8.dp))
.background(Neo.surfaceRaised())
.pointerInput(Unit) { detectTapGestures(onPress = { p = true; onClick(); tryAwaitRelease(); p = false }) },
contentAlignment = Alignment.Center,
) {
Text(label, color = Neo.textMuted(), fontSize = 9.sp, fontWeight = FontWeight.Medium, letterSpacing = 1.sp)
}
}
@@ -0,0 +1,468 @@
package com.archipelago.app.ui.components
import androidx.compose.foundation.Canvas
import androidx.compose.foundation.Image
import androidx.compose.foundation.background
import androidx.compose.foundation.border
import androidx.compose.foundation.gestures.awaitEachGesture
import androidx.compose.foundation.gestures.awaitFirstDown
import androidx.compose.foundation.gestures.detectTapGestures
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.aspectRatio
import androidx.compose.foundation.layout.fillMaxHeight
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.width
import androidx.compose.foundation.shape.CircleShape
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.Settings
import androidx.compose.material3.Icon
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.DisposableEffect
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.draw.shadow
import androidx.compose.ui.geometry.CornerRadius
import androidx.compose.ui.geometry.Offset
import androidx.compose.ui.geometry.Size
import androidx.compose.ui.graphics.Brush
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.ColorFilter
import androidx.compose.ui.graphics.Path
import androidx.compose.ui.graphics.drawscope.DrawScope
import androidx.compose.ui.graphics.drawscope.Fill
import androidx.compose.ui.input.pointer.changedToUp
import androidx.compose.ui.input.pointer.pointerInput
import androidx.compose.ui.res.painterResource
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.unit.Dp
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import com.archipelago.app.R
import com.archipelago.app.ui.theme.ControllerStyle
import com.archipelago.app.ui.theme.NES
import kotlinx.coroutines.Job
import kotlinx.coroutines.delay
import kotlinx.coroutines.launch
import kotlin.math.abs
// ═══════════════════════════════════════════════════════════
// Palettes
// ═══════════════════════════════════════════════════════════
data class NESPalette(
val body: Color, val face: Color, val ridge: Color,
val label: Color, val labelMuted: Color,
val dpad: Color, val dpadHi: Color,
val btn: Color, val btnPress: Color,
val capsule: Color, val capsulePress: Color,
val inlayBg: Color, val inlayBorder: Color,
)
val ClassicPalette = NESPalette(
body = NES.ClassicBody, face = NES.ClassicFace, ridge = NES.ClassicRidge,
label = NES.ClassicLabel, labelMuted = NES.ClassicLabelMuted,
dpad = Color(0xFF0C0C0C), dpadHi = Color(0xFF1A1A1A),
btn = NES.ClassicButtonRed, btnPress = NES.ClassicButtonRedPress,
capsule = Color(0xFF1C1C1C), capsulePress = Color(0xFF0E0E0E),
inlayBg = Color(0xFF080808), inlayBorder = Color(0xFF999999),
)
// Glassmorphism-black (OS design): translucent dark surfaces so the backdrop
// shows through the controller, subtle white-alpha borders, translucent-white
// buttons. Accents come from each button's ring.
val DarkPalette = NESPalette(
body = Color(0xA6121216), face = Color(0x8C0E0E12), ridge = Color(0x14FFFFFF),
label = Color(0xFF9A9A9A), labelMuted = Color(0xFF777777),
dpad = Color(0xFF202024), dpadHi = Color(0xFF33333A),
btn = Color(0x14FFFFFF), btnPress = Color(0x0AFFFFFF),
capsule = Color(0x12FFFFFF), capsulePress = Color(0x08FFFFFF),
inlayBg = Color(0x990A0A0A), inlayBorder = Color(0x1FFFFFFF),
)
fun paletteFor(style: ControllerStyle) = if (style == ControllerStyle.CLASSIC) ClassicPalette else DarkPalette
// ═══════════════════════════════════════════════════════════
// Landscape NES Controller
// ═══════════════════════════════════════════════════════════
@Composable
fun NESController(
style: ControllerStyle = ControllerStyle.CLASSIC,
playerId: Int = 0,
onKey: (String) -> Unit,
onMenu: () -> Unit,
onPlayerToggle: () -> Unit = {},
modifier: Modifier = Modifier,
) {
val c = paletteFor(style)
val isClassic = style == ControllerStyle.CLASSIC
Box(
modifier = modifier
.fillMaxSize()
.twoFingerHold(onMenu)
.padding(horizontal = 40.dp, vertical = 24.dp),
contentAlignment = Alignment.Center,
) {
// Controller body
Box(
Modifier
.fillMaxWidth(0.86f)
.aspectRatio(2.3f)
.shadow(32.dp, RoundedCornerShape(16.dp), ambientColor = Color(0xFF000000), spotColor = Color(0xFF000000))
.clip(RoundedCornerShape(16.dp))
.background(
Brush.verticalGradient(listOf(c.body, c.body))
)
.border(1.dp, Color.White.copy(alpha = if (isClassic) 0.08f else 0.04f), RoundedCornerShape(16.dp)),
) {
// Top highlight edge
Box(
Modifier.fillMaxWidth().height(1.dp).align(Alignment.TopCenter)
.background(Color.White.copy(alpha = if (isClassic) 0.12f else 0.05f))
)
// Face plate
Box(
Modifier
.fillMaxSize()
.padding(14.dp)
.clip(RoundedCornerShape(10.dp))
.background(c.face)
.border(0.5.dp, Color.White.copy(alpha = 0.03f), RoundedCornerShape(10.dp)),
) {
// Ridges
Ridges(c.ridge, Modifier.align(Alignment.CenterStart).width(7.dp).fillMaxHeight().padding(vertical = 12.dp))
Ridges(c.ridge, Modifier.align(Alignment.CenterEnd).width(7.dp).fillMaxHeight().padding(vertical = 12.dp))
// D-Pad in inlay (more left margin)
Inlay(c, Modifier.align(Alignment.CenterStart).padding(start = 48.dp).size(140.dp)) {
OnePointDPad(c, 120.dp, onKey)
}
// Center: Logo + START/SELECT
Column(
Modifier.align(Alignment.Center),
horizontalAlignment = Alignment.CenterHorizontally,
) {
Image(
painter = painterResource(id = R.drawable.ic_logo_wide),
contentDescription = "Archipelago",
modifier = Modifier.width(180.dp),
colorFilter = ColorFilter.tint(if (isClassic) NES.ClassicLabel else c.label),
)
Spacer(Modifier.height(10.dp))
Inlay(c, Modifier.padding(horizontal = 4.dp)) {
Row(
Modifier.padding(horizontal = 12.dp, vertical = 8.dp),
horizontalArrangement = Arrangement.spacedBy(16.dp),
) {
CapsuleBtn("SELECT", c, 64.dp, 28.dp) { onKey("Escape") }
CapsuleBtn("START", c, 64.dp, 28.dp) { onKey("Return") }
}
}
}
// A/B/C Buttons in inlay — triangle: C top, B+A bottom
Inlay(c, Modifier.align(Alignment.CenterEnd).padding(end = 48.dp).size(140.dp)) {
Column(
Modifier.fillMaxSize(),
horizontalAlignment = Alignment.CenterHorizontally,
verticalArrangement = Arrangement.Center,
) {
// C on top
GlassFaceBtn("C", Color(0xFFBBBBBB), 44.dp) { onKey("c") }
Spacer(Modifier.height(6.dp))
// B + A on bottom row
Row(horizontalArrangement = Arrangement.spacedBy(12.dp)) {
GlassFaceBtn("B", Color(0xFF60A5FA), 44.dp) { onKey("b") }
GlassFaceBtn("A", Color(0xFFF7931A), 44.dp) { onKey("a") }
}
}
}
// Player toggle + settings (bottom center)
Row(
Modifier.align(Alignment.BottomCenter).padding(bottom = 4.dp),
horizontalArrangement = Arrangement.spacedBy(10.dp),
verticalAlignment = Alignment.CenterVertically,
) {
PlayerPill(c, playerId, onPlayerToggle)
SettingsBtn(c, Modifier, onMenu)
}
}
}
}
}
// ═══════════════════════════════════════════════════════════
// Shared sub-components
// ═══════════════════════════════════════════════════════════
/** Inlay well — dark recessed area with border */
@Composable
fun Inlay(c: NESPalette, modifier: Modifier = Modifier, content: @Composable () -> Unit) {
Box(
modifier = modifier
.clip(RoundedCornerShape(10.dp))
.background(c.inlayBg)
.border(3.dp, c.inlayBorder, RoundedCornerShape(10.dp))
.padding(4.dp),
contentAlignment = Alignment.Center,
) { content() }
}
/** One-piece D-pad — single cross shape, touch detects direction */
@Composable
fun OnePointDPad(c: NESPalette, size: Dp, onDir: (String) -> Unit) {
val scope = rememberCoroutineScope()
var job by remember { mutableStateOf<Job?>(null) }
var activeDir by remember { mutableStateOf<String?>(null) }
DisposableEffect(Unit) { onDispose { job?.cancel() } }
Canvas(
modifier = Modifier
.size(size)
.pointerInput(Unit) {
detectTapGestures(
onPress = { offset ->
val cx = this@pointerInput.size.width / 2f
val cy = this@pointerInput.size.height / 2f
val dx = offset.x - cx
val dy = offset.y - cy
val dead = cx * 0.24f
if (abs(dx) < dead && abs(dy) < dead) {
tryAwaitRelease(); return@detectTapGestures
}
val dir = if (abs(dx) > abs(dy)) {
if (dx > 0) "Right" else "Left"
} else {
if (dy > 0) "Down" else "Up"
}
activeDir = dir; onDir(dir)
job?.cancel()
// 500ms initial delay so a normal tap sends one key, not
// two (a touch tap often exceeds 300ms → doubled nav sound).
job = scope.launch { delay(500); while (true) { onDir(dir); delay(90) } }
tryAwaitRelease()
job?.cancel(); activeDir = null
},
)
},
) {
val w = size.toPx()
val arm = w * 0.33f // arm width = 1/3 of total
val offset = (w - arm) / 2f
// Cross shape
val crossColor = c.dpad
// Vertical bar
drawRoundRect(
color = crossColor,
topLeft = Offset(offset, 0f),
size = Size(arm, w),
cornerRadius = CornerRadius(4.dp.toPx()),
)
// Horizontal bar
drawRoundRect(
color = crossColor,
topLeft = Offset(0f, offset),
size = Size(w, arm),
cornerRadius = CornerRadius(4.dp.toPx()),
)
// Top-edge lighting
drawRoundRect(
brush = Brush.verticalGradient(listOf(Color.White.copy(alpha = 0.06f), Color.Transparent)),
topLeft = Offset(offset, 0f),
size = Size(arm, w * 0.15f),
cornerRadius = CornerRadius(4.dp.toPx()),
)
drawRoundRect(
brush = Brush.verticalGradient(listOf(Color.White.copy(alpha = 0.06f), Color.Transparent)),
topLeft = Offset(0f, offset),
size = Size(w, arm * 0.3f),
cornerRadius = CornerRadius(4.dp.toPx()),
)
// Active direction highlight
activeDir?.let { dir ->
val hi = c.dpadHi
when (dir) {
"Up" -> drawRoundRect(hi, Offset(offset, 0f), Size(arm, arm), CornerRadius(4.dp.toPx()))
"Down" -> drawRoundRect(hi, Offset(offset, w - arm), Size(arm, arm), CornerRadius(4.dp.toPx()))
"Left" -> drawRoundRect(hi, Offset(0f, offset), Size(arm, arm), CornerRadius(4.dp.toPx()))
"Right" -> drawRoundRect(hi, Offset(w - arm, offset), Size(arm, arm), CornerRadius(4.dp.toPx()))
}
}
// Center circle
drawCircle(c.dpadHi, radius = w * 0.06f, center = Offset(w / 2f, w / 2f))
}
}
@Composable
fun Ridges(color: Color, modifier: Modifier) {
Canvas(modifier = modifier) {
val h = 1.5.dp.toPx(); val gap = 3.dp.toPx(); var y = 0f
while (y < size.height) { drawRect(color, Offset(0f, y), Size(size.width, h)); y += h + gap }
}
}
/** A/B round button with lighting */
@Composable
fun RoundBtn(c: NESPalette, sz: Dp = 52.dp, onClick: () -> Unit) {
var p by remember { mutableStateOf(false) }
Box(
Modifier
.size(sz)
.shadow(if (p) 1.dp else 4.dp, CircleShape)
.clip(CircleShape)
.background(Brush.verticalGradient(
if (p) listOf(c.btnPress, c.btn.copy(alpha = 0.85f))
else listOf(c.btn, c.btn.copy(alpha = 0.8f))
))
.pointerInput(Unit) { detectTapGestures(onPress = { p = true; onClick(); tryAwaitRelease(); p = false }) },
contentAlignment = Alignment.Center,
) {
if (!p) Box(Modifier.fillMaxSize().clip(CircleShape).background(
Brush.verticalGradient(listOf(Color.White.copy(alpha = 0.18f), Color.Transparent))
))
}
}
/** Colored round button — custom color instead of palette */
@Composable
fun ColorBtn(color: Color, pressColor: Color, sz: Dp = 48.dp, onClick: () -> Unit) {
var p by remember { mutableStateOf(false) }
Box(
Modifier
.size(sz)
.shadow(if (p) 1.dp else 4.dp, CircleShape)
.clip(CircleShape)
.background(Brush.verticalGradient(
if (p) listOf(pressColor, color.copy(alpha = 0.85f))
else listOf(color, color.copy(alpha = 0.8f))
))
.pointerInput(Unit) { detectTapGestures(onPress = { p = true; onClick(); tryAwaitRelease(); p = false }) },
contentAlignment = Alignment.Center,
) {
if (!p) Box(Modifier.fillMaxSize().clip(CircleShape).background(
Brush.verticalGradient(listOf(Color.White.copy(alpha = 0.18f), Color.Transparent))
))
}
}
/** Glass face button — dark translucent fill, colored ring + letter (OS style) */
@Composable
fun GlassFaceBtn(label: String, accent: Color, sz: Dp = 44.dp, onClick: () -> Unit) {
var p by remember { mutableStateOf(false) }
Box(
Modifier
.size(sz)
.clip(CircleShape)
.background(
Brush.verticalGradient(
if (p) listOf(Color.White.copy(alpha = 0.05f), Color.White.copy(alpha = 0.02f))
else listOf(Color.White.copy(alpha = 0.10f), Color.White.copy(alpha = 0.03f))
)
)
.border(1.5.dp, accent.copy(alpha = if (p) 0.95f else 0.55f), CircleShape)
.pointerInput(Unit) { detectTapGestures(onPress = { p = true; onClick(); tryAwaitRelease(); p = false }) },
contentAlignment = Alignment.Center,
) {
Text(label, color = accent.copy(alpha = if (p) 1f else 0.85f), fontSize = 16.sp, fontWeight = FontWeight.Bold)
}
}
/** START/SELECT capsule */
@Composable
fun CapsuleBtn(label: String, c: NESPalette, w: Dp = 64.dp, h: Dp = 28.dp, onClick: () -> Unit) {
var p by remember { mutableStateOf(false) }
Box(
Modifier
.width(w).height(h)
.shadow(if (p) 0.dp else 2.dp, RoundedCornerShape(4.dp))
.clip(RoundedCornerShape(4.dp))
.background(Brush.verticalGradient(
if (p) listOf(c.capsulePress, c.capsule)
else listOf(c.capsule, c.capsule.copy(alpha = 0.85f))
))
.pointerInput(Unit) { detectTapGestures(onPress = { p = true; onClick(); tryAwaitRelease(); p = false }) },
contentAlignment = Alignment.Center,
) {
if (!p) Box(Modifier.fillMaxSize().clip(RoundedCornerShape(4.dp)).background(
Brush.verticalGradient(listOf(Color.White.copy(alpha = 0.05f), Color.Transparent))
))
Text(label, color = c.labelMuted, fontSize = 8.sp, fontWeight = FontWeight.Bold, letterSpacing = 1.sp)
}
}
/** Settings gear button (48dp — large enough for easy tap on TV) */
@Composable
fun SettingsBtn(c: NESPalette, modifier: Modifier = Modifier, onClick: () -> Unit) {
var p by remember { mutableStateOf(false) }
Box(
modifier = modifier
.size(48.dp)
.clip(CircleShape)
.background(if (p) c.capsulePress else c.capsule)
.pointerInput(Unit) { detectTapGestures(onPress = { p = true; onClick(); tryAwaitRelease(); p = false }) },
contentAlignment = Alignment.Center,
) {
Icon(Icons.Default.Settings, "Settings", Modifier.size(28.dp), tint = c.labelMuted)
}
}
/** Player ID toggle pill (P1/P2/ALL) */
@Composable
fun PlayerPill(c: NESPalette, playerId: Int, onToggle: () -> Unit) {
val label = when (playerId) { 1 -> "P1"; 2 -> "P2"; else -> "ALL" }
val accent = when (playerId) { 1 -> Color(0xFF00F0FF); 2 -> Color(0xFFFF0080); else -> c.labelMuted }
var p by remember { mutableStateOf(false) }
Box(
modifier = Modifier
.height(28.dp)
.width(44.dp)
.clip(RoundedCornerShape(6.dp))
.background(if (p) c.capsulePress else c.capsule)
.border(1.dp, accent.copy(alpha = 0.5f), RoundedCornerShape(6.dp))
.pointerInput(Unit) { detectTapGestures(onPress = { p = true; onToggle(); tryAwaitRelease(); p = false }) },
contentAlignment = Alignment.Center,
) {
Text(label, color = accent, fontSize = 10.sp, fontWeight = FontWeight.Bold, letterSpacing = 1.sp)
}
}
/** Two-finger hold gesture modifier */
fun Modifier.twoFingerHold(onHold: () -> Unit) = this.pointerInput(Unit) {
awaitEachGesture {
awaitFirstDown(requireUnconsumed = false)
var t = 0L; var fired = false
do {
val ev = awaitPointerEvent()
val a = ev.changes.filter { !it.changedToUp() }
if (a.size >= 2 && t == 0L) t = System.currentTimeMillis()
if (a.size >= 2 && !fired && t > 0 && System.currentTimeMillis() - t > 500) { fired = true; onHold() }
if (a.size < 2) t = 0L
} while (ev.changes.any { it.pressed })
}
}
@@ -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<String>, 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<Job?>(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)
}
}
@@ -0,0 +1,343 @@
package com.archipelago.app.ui.components
import androidx.compose.animation.AnimatedVisibility
import androidx.compose.animation.fadeIn
import androidx.compose.animation.fadeOut
import androidx.compose.animation.scaleIn
import androidx.compose.animation.scaleOut
import androidx.compose.foundation.background
import androidx.compose.foundation.border
import androidx.compose.foundation.clickable
import androidx.compose.foundation.interaction.MutableInteractionSource
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.width
import androidx.compose.foundation.layout.widthIn
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.foundation.text.KeyboardActions
import androidx.compose.foundation.text.KeyboardOptions
import androidx.compose.material3.OutlinedTextField
import androidx.compose.material3.OutlinedTextFieldDefaults
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.text.TextStyle
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.input.ImeAction
import androidx.compose.ui.text.input.KeyboardType
import androidx.compose.ui.text.input.PasswordVisualTransformation
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import com.archipelago.app.data.ServerEntry
import com.archipelago.app.ui.theme.BitcoinOrange
import com.archipelago.app.ui.theme.ControllerStyle
import com.archipelago.app.ui.theme.SurfaceDark
import com.archipelago.app.ui.theme.TextMuted
import com.archipelago.app.ui.theme.TextPrimary
// Glassmorphism palette (OS design): near-black surfaces, subtle white borders,
// Bitcoin-orange accent.
private val PanelBg = SurfaceDark // #0A0A0A
private val PanelBorder = Color.White.copy(alpha = 0.12f)
private val RowBg = Color.White.copy(alpha = 0.05f)
private val RowBorder = Color.White.copy(alpha = 0.08f)
private val FieldBg = Color.White.copy(alpha = 0.04f)
private val PANEL_R = 20.dp
private val ROW_R = 14.dp
private val ROW_H = 54.dp
private val FIELD_H = 58.dp
/** Glassmorphism modal menu — #0A0A0A surface, subtle white borders. */
@Composable
fun NESMenu(
visible: Boolean,
servers: List<ServerEntry>,
activeServer: ServerEntry?,
isGamepadMode: Boolean,
controllerStyle: ControllerStyle,
onDismiss: () -> Unit,
onSelectServer: (ServerEntry) -> Unit,
onAddServer: (ServerEntry) -> Unit,
onEditServer: (ServerEntry, ServerEntry) -> Unit,
onRemoveServer: (ServerEntry) -> Unit,
onToggleMode: () -> Unit,
onToggleStyle: () -> Unit,
onBackToWebView: (() -> Unit)? = null,
) {
AnimatedVisibility(visible = visible, enter = fadeIn(), exit = fadeOut()) {
Box(
Modifier.fillMaxSize().background(Color.Black.copy(alpha = 0.7f))
.clickable(indication = null, interactionSource = remember { MutableInteractionSource() }) { onDismiss() },
contentAlignment = Alignment.Center,
) {
AnimatedVisibility(visible = visible, enter = fadeIn() + scaleIn(initialScale = 0.95f), exit = fadeOut() + scaleOut(targetScale = 0.95f)) {
MenuPanel(servers, activeServer, isGamepadMode, controllerStyle, onDismiss, onSelectServer, onAddServer, onEditServer, onRemoveServer, onToggleMode, onToggleStyle, onBackToWebView)
}
}
}
}
@Composable
private fun MenuPanel(
servers: List<ServerEntry>,
activeServer: ServerEntry?,
isGamepadMode: Boolean,
controllerStyle: ControllerStyle,
onDismiss: () -> Unit,
onSelectServer: (ServerEntry) -> Unit,
onAddServer: (ServerEntry) -> Unit,
onEditServer: (ServerEntry, ServerEntry) -> Unit,
onRemoveServer: (ServerEntry) -> Unit,
onToggleMode: () -> Unit,
onToggleStyle: () -> Unit,
onBackToWebView: (() -> Unit)?,
) {
var showAdd by remember { mutableStateOf(false) }
// The saved server being edited, or null when adding a new one.
var editing by remember { mutableStateOf<ServerEntry?>(null) }
var nm by remember { mutableStateOf("") }
var addr by remember { mutableStateOf("") }
var pwd by remember { mutableStateOf("") }
fun resetForm() {
nm = ""; addr = ""; pwd = ""; showAdd = false; editing = null
}
fun startEdit(server: ServerEntry) {
editing = server
nm = server.name; addr = server.address; pwd = server.password
showAdd = false
}
fun submit() {
if (addr.isBlank()) return
val orig = editing
if (orig != null) {
// Preserve fields the compact form doesn't expose (scheme, port).
onEditServer(orig, orig.copy(address = addr, password = pwd, name = nm))
} else {
onAddServer(ServerEntry(addr, false, password = pwd, name = nm))
}
resetForm()
}
Column(
modifier = Modifier
.widthIn(max = 420.dp)
.padding(horizontal = 20.dp)
.clip(RoundedCornerShape(PANEL_R))
.background(PanelBg)
.border(1.dp, PanelBorder, RoundedCornerShape(PANEL_R))
.clickable(indication = null, interactionSource = remember { MutableInteractionSource() }) {}
.padding(22.dp),
verticalArrangement = Arrangement.spacedBy(10.dp),
) {
// Title
Text(
"Menu",
color = TextPrimary,
fontSize = 18.sp,
fontWeight = FontWeight.SemiBold,
letterSpacing = 2.sp,
modifier = Modifier.fillMaxWidth(),
textAlign = TextAlign.Center,
)
Spacer(Modifier.height(2.dp))
// Servers
servers.forEach { server ->
val active = server.serialize() == activeServer?.serialize()
MenuItem(
label = server.displayName(),
selected = active,
onClick = { onSelectServer(server) },
onEdit = { startEdit(server) },
onRemove = { onRemoveServer(server) },
)
}
if (servers.isEmpty()) {
Text("No servers", color = TextMuted, fontSize = 14.sp, modifier = Modifier.padding(vertical = 4.dp))
}
// Add / edit server
if (showAdd || editing != null) {
Column(
Modifier
.fillMaxWidth()
.clip(RoundedCornerShape(ROW_R))
.background(FieldBg)
.border(1.dp, RowBorder, RoundedCornerShape(ROW_R))
.padding(12.dp),
verticalArrangement = Arrangement.spacedBy(8.dp),
) {
Row(
Modifier.fillMaxWidth(),
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.SpaceBetween,
) {
Text(
if (editing != null) "Edit Server" else "Add Server",
color = TextMuted,
fontSize = 13.sp,
letterSpacing = 1.sp,
fontWeight = FontWeight.Medium,
)
Text(
"Cancel",
color = TextMuted,
fontSize = 13.sp,
modifier = Modifier.clickable { resetForm() }.padding(start = 8.dp),
)
}
GlassField(
value = nm, onValueChange = { nm = it },
placeholder = "Name (optional)",
keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Text, imeAction = ImeAction.Next),
)
GlassField(
value = addr, onValueChange = { addr = it.trim() },
placeholder = "192.168.1.100",
keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Uri, imeAction = ImeAction.Next),
)
Row(horizontalArrangement = Arrangement.spacedBy(8.dp), verticalAlignment = Alignment.CenterVertically) {
GlassField(
value = pwd, onValueChange = { pwd = it },
placeholder = "Password",
modifier = Modifier.weight(1f),
visualTransformation = PasswordVisualTransformation(),
keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Password, imeAction = ImeAction.Go),
keyboardActions = KeyboardActions(onGo = { submit() }),
)
Box(
Modifier.size(FIELD_H).clip(RoundedCornerShape(12.dp)).background(BitcoinOrange.copy(alpha = 0.15f))
.border(1.dp, BitcoinOrange.copy(alpha = 0.4f), RoundedCornerShape(12.dp))
.clickable { submit() },
contentAlignment = Alignment.Center,
) { Text("OK", color = BitcoinOrange, fontSize = 14.sp, fontWeight = FontWeight.Bold) }
}
}
} else {
MenuItem(label = "Add Server", labelColor = BitcoinOrange, onClick = { showAdd = true })
}
Spacer(Modifier.height(2.dp))
Box(Modifier.fillMaxWidth().height(1.dp).background(PanelBorder))
Spacer(Modifier.height(2.dp))
// Mode toggle
MenuItem(
label = if (isGamepadMode) "Switch to Keyboard" else "Switch to Gamepad",
onClick = onToggleMode,
)
// Style toggle
MenuItem(
label = if (controllerStyle == ControllerStyle.CLASSIC) "Style: Classic" else "Style: Dark",
onClick = onToggleStyle,
)
// Back to dashboard
if (onBackToWebView != null) {
MenuItem(label = "Back to Dashboard", onClick = onBackToWebView)
}
}
}
@Composable
private fun MenuItem(
label: String,
selected: Boolean = false,
labelColor: Color = TextPrimary,
onClick: () -> Unit,
onEdit: (() -> Unit)? = null,
onRemove: (() -> Unit)? = null,
) {
Row(
Modifier
.fillMaxWidth()
.height(ROW_H)
.clip(RoundedCornerShape(ROW_R))
.background(if (selected) BitcoinOrange.copy(alpha = 0.12f) else RowBg)
.border(1.dp, if (selected) BitcoinOrange.copy(alpha = 0.4f) else RowBorder, RoundedCornerShape(ROW_R))
.clickable { onClick() }
.padding(horizontal = 16.dp),
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.SpaceBetween,
) {
Text(
label,
color = if (selected) BitcoinOrange else labelColor,
fontSize = 16.sp,
fontWeight = FontWeight.Medium,
modifier = Modifier.weight(1f),
)
if (onEdit != null) {
Text(
"",
color = TextMuted,
fontSize = 16.sp,
modifier = Modifier.clickable { onEdit() }.padding(horizontal = 8.dp),
)
}
if (onRemove != null) {
Text(
"",
color = TextMuted,
fontSize = 16.sp,
modifier = Modifier.clickable { onRemove() }.padding(horizontal = 8.dp),
)
}
}
}
/** Glass text field with centered input text. */
@Composable
private fun GlassField(
value: String,
onValueChange: (String) -> Unit,
placeholder: String,
modifier: Modifier = Modifier,
visualTransformation: androidx.compose.ui.text.input.VisualTransformation = androidx.compose.ui.text.input.VisualTransformation.None,
keyboardOptions: KeyboardOptions = KeyboardOptions.Default,
keyboardActions: KeyboardActions = KeyboardActions.Default,
) {
OutlinedTextField(
value = value,
onValueChange = onValueChange,
placeholder = {
Text(placeholder, color = TextMuted, fontSize = 15.sp, modifier = Modifier.fillMaxWidth(), textAlign = TextAlign.Center)
},
modifier = modifier.fillMaxWidth().height(FIELD_H),
singleLine = true,
visualTransformation = visualTransformation,
keyboardOptions = keyboardOptions,
keyboardActions = keyboardActions,
textStyle = TextStyle(color = TextPrimary, fontSize = 16.sp, textAlign = TextAlign.Center),
colors = OutlinedTextFieldDefaults.colors(
focusedBorderColor = Color.White.copy(alpha = 0.3f),
unfocusedBorderColor = Color.White.copy(alpha = 0.12f),
cursorColor = BitcoinOrange,
focusedTextColor = TextPrimary,
unfocusedTextColor = TextPrimary,
),
shape = RoundedCornerShape(12.dp),
)
}
@@ -0,0 +1,158 @@
package com.archipelago.app.ui.components
import androidx.compose.foundation.Image
import androidx.compose.foundation.background
import androidx.compose.foundation.border
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.width
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.draw.shadow
import androidx.compose.ui.graphics.Brush
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.ColorFilter
import androidx.compose.ui.res.painterResource
import androidx.compose.ui.unit.dp
import com.archipelago.app.R
import com.archipelago.app.ui.theme.ControllerStyle
import com.archipelago.app.ui.theme.NES
/**
* Portrait gamepad — vertical remote shape like Apple TV but NES-styled.
* Large trackpad top, D-pad middle, A/B + START/SELECT bottom.
*/
@Composable
fun NESPortraitController(
style: ControllerStyle = ControllerStyle.CLASSIC,
playerId: Int = 0,
onKey: (String) -> Unit,
onMouseMove: (Int, Int) -> Unit = { _, _ -> },
onMouseClick: (Int) -> Unit = { _ -> },
onMouseScroll: (Int) -> Unit = { _ -> },
onMenu: () -> Unit,
onPlayerToggle: () -> Unit = {},
) {
val c = paletteFor(style)
val isClassic = style == ControllerStyle.CLASSIC
Box(
Modifier
.fillMaxSize()
.twoFingerHold(onMenu)
.padding(horizontal = 40.dp, vertical = 24.dp),
contentAlignment = Alignment.Center,
) {
// Remote body — tall vertical shape
Box(
Modifier
.fillMaxWidth(0.75f)
.fillMaxSize()
.shadow(28.dp, RoundedCornerShape(20.dp), ambientColor = Color.Black, spotColor = Color.Black)
.clip(RoundedCornerShape(20.dp))
.background(Brush.verticalGradient(listOf(c.body, c.body)))
.border(1.dp, Color.White.copy(alpha = if (isClassic) 0.08f else 0.04f), RoundedCornerShape(20.dp)),
) {
// Top highlight
Box(
Modifier.fillMaxWidth().height(1.dp).align(Alignment.TopCenter)
.background(Color.White.copy(alpha = if (isClassic) 0.12f else 0.05f))
)
// Face plate
Column(
Modifier
.fillMaxSize()
.padding(14.dp)
.clip(RoundedCornerShape(14.dp))
.background(c.face)
.border(0.5.dp, Color.White.copy(alpha = 0.03f), RoundedCornerShape(14.dp))
.padding(16.dp),
horizontalAlignment = Alignment.CenterHorizontally,
verticalArrangement = Arrangement.SpaceBetween,
) {
// Trackpad area (touch surface for mouse)
Trackpad(
onMove = { dx, dy -> onMouseMove(dx, dy) },
onClick = { onMouseClick(it) },
onScroll = { dy -> onMouseScroll(dy) },
onTwoFingerHold = onMenu,
modifier = Modifier
.fillMaxWidth()
.weight(1f),
)
Spacer(Modifier.height(12.dp))
// D-Pad
Inlay(c, Modifier.size(150.dp)) {
OnePointDPad(c, 130.dp, onKey)
}
Spacer(Modifier.height(12.dp))
// Logo
Image(
painter = painterResource(id = R.drawable.ic_logo_wide),
contentDescription = "Archipelago",
modifier = Modifier.width(140.dp),
colorFilter = ColorFilter.tint(if (isClassic) NES.ClassicLabel else c.label),
)
Spacer(Modifier.height(12.dp))
// A/B/C Buttons — triangle: C top, B+A bottom
Inlay(c, Modifier.fillMaxWidth()) {
Column(
Modifier.fillMaxWidth().padding(horizontal = 12.dp, vertical = 8.dp),
horizontalAlignment = Alignment.CenterHorizontally,
) {
GlassFaceBtn("C", Color(0xFFBBBBBB), 46.dp) { onKey("c") }
Spacer(Modifier.height(6.dp))
Row(horizontalArrangement = Arrangement.spacedBy(14.dp)) {
GlassFaceBtn("B", Color(0xFF60A5FA), 46.dp) { onKey("b") }
GlassFaceBtn("A", Color(0xFFF7931A), 46.dp) { onKey("a") }
}
}
}
Spacer(Modifier.height(10.dp))
// START / SELECT
Inlay(c, Modifier) {
Row(
Modifier.padding(horizontal = 10.dp, vertical = 6.dp),
horizontalArrangement = Arrangement.spacedBy(14.dp),
) {
CapsuleBtn("SELECT", c, 64.dp, 28.dp) { onKey("Escape") }
CapsuleBtn("START", c, 64.dp, 28.dp) { onKey("Return") }
}
}
Spacer(Modifier.height(6.dp))
// Player toggle + Settings
Row(
Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.Center,
verticalAlignment = Alignment.CenterVertically,
) {
PlayerPill(c, playerId, onPlayerToggle)
Spacer(Modifier.width(10.dp))
SettingsBtn(c, Modifier, onMenu)
}
}
}
}
}
@@ -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<ServerEntry>,
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<ServerEntry>,
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(),
)
@@ -0,0 +1,107 @@
package com.archipelago.app.ui.components
import androidx.compose.foundation.background
import androidx.compose.foundation.gestures.awaitEachGesture
import androidx.compose.foundation.gestures.awaitFirstDown
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableIntStateOf
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.geometry.Offset
import androidx.compose.ui.input.pointer.changedToUp
import androidx.compose.ui.input.pointer.pointerInput
import androidx.compose.ui.input.pointer.positionChange
import androidx.compose.ui.unit.dp
import com.archipelago.app.ui.theme.Neo
import com.archipelago.app.ui.theme.neoInset
private const val TAP_THRESHOLD = 12f
private const val TAP_TIMEOUT = 250L
@Composable
fun Trackpad(
onMove: (dx: Int, dy: Int) -> Unit,
onClick: (button: Int) -> Unit,
onScroll: (dy: Int) -> Unit,
onTwoFingerHold: () -> Unit,
modifier: Modifier = Modifier,
) {
var fingers by remember { mutableIntStateOf(0) }
val surface = Neo.surface()
val light = Neo.shadowLight()
val dark = Neo.shadowDark()
val muted = Neo.textMuted()
Box(
modifier = modifier
.neoInset(light, dark, 20.dp, 3.dp, 6.dp)
.clip(RoundedCornerShape(20.dp))
.background(surface)
.pointerInput(Unit) {
awaitEachGesture {
val first = awaitFirstDown(requireUnconsumed = false)
var total = Offset.Zero
val t0 = System.currentTimeMillis()
var maxPtrs = 1
var holdFired = false
var twoStart = 0L
var scrollAcc = 0f
fingers = 1
do {
val ev = awaitPointerEvent()
val active = ev.changes.filter { !it.changedToUp() }
maxPtrs = maxOf(maxPtrs, active.size)
fingers = active.size
when {
active.size >= 2 -> {
if (twoStart == 0L) twoStart = System.currentTimeMillis()
if (!holdFired && System.currentTimeMillis() - twoStart > 500) {
holdFired = true
onTwoFingerHold()
}
if (!holdFired) {
val dy = active.map { it.positionChange().y }.average().toFloat()
scrollAcc += dy
if (kotlin.math.abs(scrollAcc) > 12f) {
onScroll(if (scrollAcc > 0) 1 else -1)
scrollAcc = 0f
}
}
ev.changes.forEach { it.consume() }
}
active.size == 1 && maxPtrs == 1 -> {
val d = active.first().positionChange()
total += d
if (d != Offset.Zero) onMove(d.x.toInt(), d.y.toInt())
active.first().consume()
}
}
} while (ev.changes.any { it.pressed })
fingers = 0
val elapsed = System.currentTimeMillis() - t0
if (maxPtrs == 1 && elapsed < TAP_TIMEOUT && total.getDistance() < TAP_THRESHOLD) {
onClick(1)
}
}
},
contentAlignment = Alignment.Center,
) {
Text(
text = if (fingers >= 2) "hold for menu" else "",
style = MaterialTheme.typography.labelSmall,
color = muted.copy(alpha = 0.4f),
)
}
}
@@ -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<String>, 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<String>, 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<Job?>(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) }
}
@@ -0,0 +1,107 @@
package com.archipelago.app.ui.navigation
import androidx.compose.runtime.Composable
import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue
import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.ui.platform.LocalContext
import androidx.navigation.compose.NavHost
import androidx.navigation.compose.composable
import androidx.navigation.compose.rememberNavController
import com.archipelago.app.data.ServerPreferences
import com.archipelago.app.ui.screens.IntroScreen
import com.archipelago.app.ui.screens.RemoteInputScreen
import com.archipelago.app.ui.screens.ServerConnectScreen
import com.archipelago.app.ui.screens.WebViewScreen
import kotlinx.coroutines.launch
object Routes {
const val INTRO = "intro"
const val SERVER_CONNECT = "server_connect"
const val WEB_VIEW = "web_view"
const val REMOTE_INPUT = "remote_input"
}
@Composable
fun AppNavHost() {
val context = LocalContext.current
val prefs = remember { ServerPreferences(context) }
val navController = rememberNavController()
val scope = rememberCoroutineScope()
val introSeen by prefs.introSeen.collectAsState(initial = null)
val activeServer by prefs.activeServer.collectAsState(initial = null)
if (introSeen == null) return
val startDestination = when {
introSeen == false -> Routes.INTRO
activeServer != null -> Routes.WEB_VIEW
else -> Routes.SERVER_CONNECT
}
NavHost(
navController = navController,
startDestination = startDestination,
) {
composable(Routes.INTRO) {
IntroScreen(
onContinue = {
scope.launch {
prefs.markIntroSeen()
navController.navigate(Routes.SERVER_CONNECT) {
popUpTo(Routes.INTRO) { inclusive = true }
}
}
},
)
}
composable(Routes.SERVER_CONNECT) {
ServerConnectScreen(
onConnected = { _ ->
navController.navigate(Routes.WEB_VIEW) {
popUpTo(Routes.SERVER_CONNECT) { inclusive = true }
}
},
)
}
composable(Routes.WEB_VIEW) {
val server = activeServer
if (server == null) {
ServerConnectScreen(
onConnected = { _ ->
navController.navigate(Routes.WEB_VIEW) {
popUpTo(0) { inclusive = true }
}
},
)
} else {
WebViewScreen(
serverUrl = server.toUrl(),
onDisconnect = {
scope.launch {
prefs.clearActiveServer()
navController.navigate(Routes.SERVER_CONNECT) {
popUpTo(0) { inclusive = true }
}
}
},
onRemoteInput = {
navController.navigate(Routes.REMOTE_INPUT)
},
)
}
}
composable(Routes.REMOTE_INPUT) {
RemoteInputScreen(
onBack = {
navController.popBackStack()
},
)
}
}
}
@@ -0,0 +1,243 @@
package com.archipelago.app.ui.screens
import androidx.compose.animation.AnimatedVisibility
import androidx.compose.animation.core.Animatable
import androidx.compose.animation.core.animateFloatAsState
import androidx.compose.animation.core.tween
import androidx.compose.animation.fadeIn
import androidx.compose.animation.slideInVertically
import androidx.compose.foundation.Canvas
import androidx.compose.foundation.Image
import androidx.compose.foundation.background
import androidx.compose.foundation.border
import androidx.compose.foundation.clickable
import androidx.compose.foundation.interaction.MutableInteractionSource
import androidx.compose.foundation.interaction.collectIsPressedAsState
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.WindowInsets
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.safeDrawing
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.windowInsetsPadding
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.alpha
import androidx.compose.ui.draw.clip
import androidx.compose.ui.geometry.Offset
import androidx.compose.ui.geometry.Size
import androidx.compose.ui.graphics.Brush
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.layout.ContentScale
import androidx.compose.ui.res.painterResource
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import com.archipelago.app.R
import com.archipelago.app.ui.theme.SurfaceBlack
import com.archipelago.app.ui.theme.TextMuted
import com.archipelago.app.ui.theme.TextPrimary
import kotlinx.coroutines.delay
@Composable
fun IntroScreen(onContinue: () -> Unit) {
val logoAlpha = remember { Animatable(0f) }
var showContent by remember { mutableStateOf(false) }
LaunchedEffect(Unit) {
logoAlpha.animateTo(1f, animationSpec = tween(800))
delay(300)
showContent = true
}
Box(
modifier = Modifier
.fillMaxSize()
.background(SurfaceBlack),
) {
// Reddish synthwave backdrop
Image(
painter = painterResource(id = R.drawable.bg_synthwave),
contentDescription = null,
modifier = Modifier.fillMaxSize(),
contentScale = ContentScale.Crop,
)
// Dark scrim so the title/buttons stay legible over the art
Box(
modifier = Modifier
.fillMaxSize()
.background(
Brush.verticalGradient(
colors = listOf(
Color.Black.copy(alpha = 0.55f),
Color.Black.copy(alpha = 0.35f),
Color.Black.copy(alpha = 0.75f),
),
)
),
)
Column(
modifier = Modifier
.align(Alignment.Center)
.fillMaxWidth()
.windowInsetsPadding(WindowInsets.safeDrawing)
.padding(horizontal = 32.dp),
horizontalAlignment = Alignment.CenterHorizontally,
verticalArrangement = Arrangement.Center,
) {
// Circular badge logo
Image(
painter = painterResource(id = R.drawable.ic_logo),
contentDescription = "Archipelago",
modifier = Modifier
.size(160.dp)
.alpha(logoAlpha.value),
)
Spacer(modifier = Modifier.height(48.dp))
AnimatedVisibility(
visible = showContent,
enter = fadeIn(tween(600)) + slideInVertically(
initialOffsetY = { it / 4 },
animationSpec = tween(600),
),
) {
Column(horizontalAlignment = Alignment.CenterHorizontally) {
Text(
text = stringResource(R.string.welcome_title),
style = MaterialTheme.typography.headlineLarge,
color = Color(0xFFFAFAFA),
textAlign = TextAlign.Center,
)
Spacer(modifier = Modifier.height(16.dp))
Text(
text = stringResource(R.string.welcome_subtitle),
style = MaterialTheme.typography.bodyLarge,
color = Color(0xFFFAFAFA),
textAlign = TextAlign.Center,
lineHeight = 26.sp,
)
Spacer(modifier = Modifier.height(48.dp))
GlassButton(
text = stringResource(R.string.get_started),
onClick = onContinue,
modifier = Modifier.fillMaxWidth().height(56.dp),
)
}
}
}
}
}
/** The pixel-art "A" from AnimatedLogo.vue — 20 white squares */
@Composable
fun PixelArtLogo(modifier: Modifier = Modifier) {
Canvas(modifier = modifier) {
val s = size.width / 1024f
val rects = listOf(
floatArrayOf(357.614f, 318f, 71.007f, 70.936f),
floatArrayOf(436.152f, 318f, 72.082f, 70.936f),
floatArrayOf(515.766f, 318f, 72.082f, 70.936f),
floatArrayOf(595.379f, 318f, 71.007f, 70.936f),
floatArrayOf(595.379f, 396.46f, 71.007f, 72.011f),
floatArrayOf(673.917f, 396.46f, 72.083f, 72.011f),
floatArrayOf(278f, 475.994f, 72.083f, 72.012f),
floatArrayOf(357.614f, 475.994f, 71.007f, 72.012f),
floatArrayOf(436.152f, 475.994f, 72.082f, 72.012f),
floatArrayOf(515.766f, 475.994f, 72.082f, 72.012f),
floatArrayOf(595.379f, 475.994f, 71.007f, 72.012f),
floatArrayOf(673.917f, 475.994f, 72.083f, 72.012f),
floatArrayOf(278f, 555.529f, 72.083f, 70.936f),
floatArrayOf(357.614f, 555.529f, 71.007f, 70.936f),
floatArrayOf(595.379f, 555.529f, 71.007f, 70.936f),
floatArrayOf(673.917f, 555.529f, 72.083f, 70.936f),
floatArrayOf(357.614f, 633.989f, 71.007f, 72.011f),
floatArrayOf(436.152f, 633.989f, 72.082f, 72.011f),
floatArrayOf(515.766f, 633.989f, 72.082f, 72.011f),
floatArrayOf(595.379f, 633.989f, 71.007f, 72.011f),
)
for (r in rects) {
drawRect(
color = Color.White,
topLeft = Offset(r[0] * s, r[1] * s),
size = Size(r[2] * s, r[3] * s),
)
}
}
}
/**
* Glass-style button matching Archipelago's .glass-button.
* Custom press state (subtle brighten) instead of Material ripple.
*/
@Composable
fun GlassButton(
text: String,
onClick: () -> Unit,
modifier: Modifier = Modifier,
) {
val interactionSource = remember { MutableInteractionSource() }
val isPressed by interactionSource.collectIsPressedAsState()
val pressAlpha by animateFloatAsState(
targetValue = if (isPressed) 1f else 0f,
animationSpec = tween(if (isPressed) 0 else 150),
label = "press",
)
// Lerp between rest and pressed states
val bgTop = 0.12f + pressAlpha * 0.08f // 0.12 → 0.20
val bgBottom = 0.04f + pressAlpha * 0.06f // 0.04 → 0.10
val borderA = 0.15f + pressAlpha * 0.10f // 0.15 → 0.25
val textAlpha = 1f - pressAlpha * 0.2f // 1.0 → 0.8
Box(
modifier = modifier
.clip(RoundedCornerShape(12.dp))
.background(
Brush.verticalGradient(
colors = listOf(
Color.White.copy(alpha = bgTop),
Color.White.copy(alpha = bgBottom),
),
)
)
.border(
width = 1.dp,
color = Color.White.copy(alpha = borderA),
shape = RoundedCornerShape(12.dp),
)
.clickable(
interactionSource = interactionSource,
indication = null,
onClick = onClick,
),
contentAlignment = Alignment.Center,
) {
Text(
text = text,
color = Color.White.copy(alpha = textAlpha),
style = MaterialTheme.typography.labelLarge,
fontSize = 16.sp,
)
}
}
@@ -0,0 +1,251 @@
package com.archipelago.app.ui.screens
import android.content.res.Configuration
import androidx.activity.compose.BackHandler
import androidx.compose.foundation.Image
import androidx.compose.foundation.background
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.WindowInsets
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.safeDrawing
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.windowInsetsPadding
import androidx.compose.foundation.shape.CircleShape
import androidx.compose.runtime.Composable
import androidx.compose.runtime.DisposableEffect
import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.graphics.Brush
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.layout.ContentScale
import androidx.compose.ui.platform.LocalConfiguration
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.platform.LocalLifecycleOwner
import androidx.lifecycle.Lifecycle
import androidx.lifecycle.LifecycleEventObserver
import androidx.compose.ui.res.painterResource
import androidx.compose.ui.unit.dp
import com.archipelago.app.R
import com.archipelago.app.data.ServerPreferences
import com.archipelago.app.network.ConnectionState
import com.archipelago.app.network.InputWebSocket
import com.archipelago.app.ui.components.NESController
import com.archipelago.app.ui.components.NESKeyboard
import com.archipelago.app.ui.components.NESMenu
import com.archipelago.app.ui.components.NESPortraitController
import com.archipelago.app.ui.components.Trackpad
import com.archipelago.app.ui.theme.BitcoinOrange
import com.archipelago.app.ui.theme.ControllerStyle
import com.archipelago.app.ui.theme.ErrorRed
import com.archipelago.app.ui.theme.SuccessGreen
import com.archipelago.app.ui.theme.TextMuted
import kotlinx.coroutines.launch
@Composable
fun RemoteInputScreen(onBack: () -> Unit) {
val context = LocalContext.current
val prefs = remember { ServerPreferences(context) }
val scope = rememberCoroutineScope()
val isLandscape = LocalConfiguration.current.orientation == Configuration.ORIENTATION_LANDSCAPE
val savedServers by prefs.savedServers.collectAsState(initial = emptyList())
val activeServer by prefs.activeServer.collectAsState(initial = null)
var isGamepadMode by remember { mutableStateOf(true) }
var showModal by remember { mutableStateOf(false) }
var controllerStyle by remember { mutableStateOf(ControllerStyle.DARK) }
var playerId by remember { mutableStateOf(0) } // 0 = broadcast, 1 = P1, 2 = P2
val ws = remember { InputWebSocket(scope) }
// When the kiosk forwards an "open in external browser" app, launch it in
// the phone's default browser.
DisposableEffect(ws) {
ws.onExternalOpen = { url ->
try {
val intent = android.content.Intent(
android.content.Intent.ACTION_VIEW,
android.net.Uri.parse(url),
).apply { addFlags(android.content.Intent.FLAG_ACTIVITY_NEW_TASK) }
context.startActivity(intent)
} catch (_: Exception) {}
}
onDispose { ws.onExternalOpen = null }
}
fun togglePlayer() {
playerId = when (playerId) { 0 -> 1; 1 -> 2; else -> 0 }
ws.playerId = playerId
}
val connectionState by ws.state.collectAsState()
val lifecycleOwner = LocalLifecycleOwner.current
BackHandler { onBack() }
// Connect on server change + reconnect when app resumes from background
DisposableEffect(lifecycleOwner, activeServer) {
val server = activeServer
if (server != null) {
ws.connect(server.toUrl(), server.password)
}
val observer = LifecycleEventObserver { _, event ->
if (event == Lifecycle.Event.ON_RESUME && server != null) {
val state = ws.state.value
if (state != ConnectionState.CONNECTED && state != ConnectionState.CONNECTING) {
ws.connect(server.toUrl(), server.password)
}
}
}
lifecycleOwner.lifecycle.addObserver(observer)
onDispose {
lifecycleOwner.lifecycle.removeObserver(observer)
ws.disconnect()
}
}
Box(
Modifier
.fillMaxSize()
.background(Color(0xFF0C0C0C)),
) {
// Reddish synthwave backdrop behind the controller
Image(
painter = painterResource(id = R.drawable.bg_synthwave),
contentDescription = null,
modifier = Modifier.fillMaxSize(),
contentScale = ContentScale.Crop,
)
// Light scrim — the controller body provides its own contrast, so keep
// this subtle and let the backdrop show through around it.
Box(
modifier = Modifier
.fillMaxSize()
.background(
Brush.verticalGradient(
colors = listOf(
Color.Black.copy(alpha = 0.4f),
Color.Black.copy(alpha = 0.25f),
Color.Black.copy(alpha = 0.45f),
),
)
),
)
Box(Modifier.fillMaxSize().windowInsetsPadding(WindowInsets.safeDrawing)) {
when {
isGamepadMode && isLandscape -> NESController(
style = controllerStyle,
playerId = playerId,
onKey = { ws.sendKey(it) },
onMenu = { showModal = true },
onPlayerToggle = ::togglePlayer,
)
isGamepadMode && !isLandscape -> NESPortraitController(
style = controllerStyle,
playerId = playerId,
onKey = { ws.sendKey(it) },
onMouseMove = { dx, dy -> ws.sendMouseMove(dx, dy) },
onMouseClick = { ws.sendClick(it) },
onMouseScroll = { ws.sendScroll(it) },
onMenu = { showModal = true },
onPlayerToggle = ::togglePlayer,
)
else -> {
// Keyboard mode: trackpad fills top, keyboard pinned bottom
Box(Modifier.fillMaxSize()) {
Column(Modifier.fillMaxSize()) {
Trackpad(
onMove = { dx, dy -> ws.sendMouseMove(dx, dy) },
onClick = { ws.sendClick(it) },
onScroll = { ws.sendScroll(it) },
onTwoFingerHold = { showModal = true },
modifier = Modifier.fillMaxWidth().weight(1f)
.padding(horizontal = 16.dp, vertical = 8.dp),
)
NESKeyboard(
style = controllerStyle,
onKey = { ws.sendKey(it) },
modifier = Modifier.fillMaxWidth(),
)
}
// Settings icon top-right in keyboard mode
com.archipelago.app.ui.components.SettingsBtn(
c = com.archipelago.app.ui.components.paletteFor(controllerStyle),
modifier = Modifier.align(Alignment.TopEnd).padding(8.dp),
onClick = { showModal = true },
)
}
}
}
// Connection dot
Box(
Modifier.align(Alignment.TopStart).padding(6.dp).size(8.dp)
.clip(CircleShape).background(
when (connectionState) {
ConnectionState.CONNECTED -> SuccessGreen
ConnectionState.CONNECTING -> BitcoinOrange
ConnectionState.ERROR, ConnectionState.AUTH_FAILED -> ErrorRed
ConnectionState.DISCONNECTED -> TextMuted
}
),
)
}
NESMenu(
visible = showModal,
servers = savedServers,
activeServer = activeServer,
isGamepadMode = isGamepadMode,
controllerStyle = controllerStyle,
onDismiss = { showModal = false },
onSelectServer = { server ->
scope.launch { ws.disconnect(); prefs.setActiveServer(server) }; showModal = false
},
onAddServer = { server ->
scope.launch { prefs.addSavedServer(server); if (activeServer == null) prefs.setActiveServer(server) }
},
onEditServer = { original, updated ->
scope.launch {
prefs.updateSavedServer(original, updated)
// If the edited server is the live one, reconnect with the new
// address/credentials so the change takes effect immediately.
if (original.serialize() == activeServer?.serialize()) {
ws.disconnect()
prefs.setActiveServer(updated)
}
}
},
onRemoveServer = { server ->
scope.launch {
prefs.removeSavedServer(server)
// Deleting the last server leaves nothing to control — drop the
// active server and return to the Connect screen.
val remaining = savedServers.count { it.serialize() != server.serialize() }
if (remaining == 0) {
ws.disconnect()
prefs.clearActiveServer()
showModal = false
onBack()
}
}
},
onToggleMode = { isGamepadMode = !isGamepadMode; showModal = false },
onToggleStyle = {
controllerStyle = if (controllerStyle == ControllerStyle.CLASSIC) ControllerStyle.DARK else ControllerStyle.CLASSIC
},
onBackToWebView = { showModal = false; onBack() },
)
}
}
@@ -0,0 +1,603 @@
package com.archipelago.app.ui.screens
import androidx.compose.animation.AnimatedVisibility
import androidx.compose.animation.fadeIn
import androidx.compose.animation.fadeOut
import androidx.compose.foundation.Image
import androidx.compose.foundation.background
import androidx.compose.foundation.border
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.WindowInsets
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.safeDrawing
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.width
import androidx.compose.foundation.layout.windowInsetsPadding
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.foundation.text.KeyboardActions
import androidx.compose.foundation.text.KeyboardOptions
import androidx.compose.material.icons.filled.Visibility
import androidx.compose.material.icons.filled.VisibilityOff
import androidx.compose.foundation.verticalScroll
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.Close
import androidx.compose.material.icons.filled.Edit
import androidx.compose.material.icons.filled.Lock
import androidx.compose.material.icons.filled.LockOpen
import androidx.compose.material3.CircularProgressIndicator
import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.OutlinedTextField
import androidx.compose.material3.OutlinedTextFieldDefaults
import androidx.compose.material3.Switch
import androidx.compose.material3.SwitchDefaults
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.draw.drawWithContent
import androidx.compose.ui.graphics.Brush
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.ColorFilter
import androidx.compose.ui.layout.ContentScale
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.platform.LocalSoftwareKeyboardController
import androidx.compose.ui.res.painterResource
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.input.ImeAction
import androidx.compose.ui.text.input.KeyboardType
import androidx.compose.ui.text.input.PasswordVisualTransformation
import androidx.compose.ui.text.input.VisualTransformation
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import com.archipelago.app.R
import com.archipelago.app.data.ServerEntry
import com.archipelago.app.data.ServerPreferences
import com.archipelago.app.ui.theme.BitcoinOrange
import com.archipelago.app.ui.theme.ErrorRed
import com.archipelago.app.ui.theme.SurfaceBlack
import com.archipelago.app.ui.theme.SurfaceCard
import com.archipelago.app.ui.theme.SuccessGreen
import com.archipelago.app.ui.theme.TextMuted
import com.archipelago.app.ui.theme.TextPrimary
import com.archipelago.app.ui.theme.TextSecondary
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
import java.net.HttpURLConnection
import java.net.URL
import javax.net.ssl.HttpsURLConnection
import javax.net.ssl.SSLContext
import javax.net.ssl.X509TrustManager
@Composable
fun ServerConnectScreen(
onConnected: (String) -> Unit,
onRemoteInput: () -> Unit = {},
) {
val context = LocalContext.current
val prefs = remember { ServerPreferences(context) }
val scope = rememberCoroutineScope()
val keyboard = LocalSoftwareKeyboardController.current
var name by remember { mutableStateOf("") }
var address by remember { mutableStateOf("") }
var port by remember { mutableStateOf("") }
var password by remember { mutableStateOf("") }
var passwordVisible by remember { mutableStateOf(false) }
var useHttps by remember { mutableStateOf(false) }
var isConnecting by remember { mutableStateOf(false) }
var errorMessage by remember { mutableStateOf<String?>(null) }
// The saved server currently being edited, or null when adding/connecting.
var editingServer by remember { mutableStateOf<ServerEntry?>(null) }
val savedServers by prefs.savedServers.collectAsState(initial = emptyList())
fun clearForm() {
name = ""
address = ""
port = ""
password = ""
useHttps = false
passwordVisible = false
errorMessage = null
}
fun startEdit(server: ServerEntry) {
editingServer = server
name = server.name
address = server.address
port = server.port
password = server.password
useHttps = server.useHttps
passwordVisible = false
errorMessage = null
}
fun cancelEdit() {
editingServer = null
clearForm()
}
fun saveEdit() {
val original = editingServer ?: return
if (address.isBlank()) {
errorMessage = "Enter a server address"
return
}
val updated = ServerEntry(address, useHttps, port, password, name)
scope.launch {
prefs.updateSavedServer(original, updated)
cancelEdit()
}
}
fun connect(server: ServerEntry) {
if (isConnecting) return
if (server.address.isBlank()) {
errorMessage = "Enter a server address"
return
}
isConnecting = true
errorMessage = null
scope.launch {
val result = testConnection(server)
isConnecting = false
if (result) {
prefs.setActiveServer(server)
onConnected(server.toUrl())
} else {
errorMessage = context.getString(R.string.connection_failed)
}
}
}
Box(
modifier = Modifier
.fillMaxSize()
.background(SurfaceBlack),
) {
// Reddish synthwave backdrop
Image(
painter = painterResource(id = R.drawable.bg_synthwave),
contentDescription = null,
modifier = Modifier.fillMaxSize(),
contentScale = ContentScale.Crop,
)
// Dark scrim so the form stays legible over the art
Box(
modifier = Modifier
.fillMaxSize()
.background(
Brush.verticalGradient(
colors = listOf(
Color.Black.copy(alpha = 0.6f),
Color.Black.copy(alpha = 0.45f),
Color.Black.copy(alpha = 0.8f),
),
)
),
)
Column(
modifier = Modifier
.fillMaxSize()
.windowInsetsPadding(WindowInsets.safeDrawing)
.verticalScroll(state = rememberScrollState())
.drawWithContent { drawContent() }
.padding(horizontal = 24.dp)
.padding(top = 48.dp, bottom = 32.dp),
horizontalAlignment = Alignment.CenterHorizontally,
verticalArrangement = Arrangement.spacedBy(16.dp),
) {
// Circular badge logo
Image(
painter = painterResource(id = R.drawable.ic_logo),
contentDescription = "Archipelago",
modifier = Modifier.size(96.dp),
)
Spacer(modifier = Modifier.height(4.dp))
Text(
text = if (editingServer != null) stringResource(R.string.edit_server_title) else "Connect to Server",
style = MaterialTheme.typography.headlineMedium,
color = TextPrimary,
textAlign = TextAlign.Center,
)
Text(
text = stringResource(R.string.server_address_hint),
style = MaterialTheme.typography.bodyMedium,
color = TextMuted,
textAlign = TextAlign.Center,
)
Spacer(modifier = Modifier.height(4.dp))
// Glass card with form
Box(
modifier = Modifier
.fillMaxWidth()
.clip(RoundedCornerShape(16.dp))
.background(Color.Black.copy(alpha = 0.6f))
.background(
Brush.verticalGradient(
colors = listOf(
Color.White.copy(alpha = 0.06f),
Color.White.copy(alpha = 0.02f),
),
)
)
.border(1.dp, Color.White.copy(alpha = 0.1f), RoundedCornerShape(16.dp))
.padding(20.dp),
) {
Column {
OutlinedTextField(
value = name,
onValueChange = {
name = it
errorMessage = null
},
label = { Text(stringResource(R.string.server_name_label)) },
placeholder = { Text(stringResource(R.string.server_name_placeholder)) },
modifier = Modifier.fillMaxWidth(),
singleLine = true,
keyboardOptions = KeyboardOptions(
keyboardType = KeyboardType.Text,
imeAction = ImeAction.Next,
),
colors = OutlinedTextFieldDefaults.colors(
focusedBorderColor = Color.White.copy(alpha = 0.3f),
unfocusedBorderColor = Color.White.copy(alpha = 0.12f),
cursorColor = Color.White,
focusedLabelColor = Color.White.copy(alpha = 0.7f),
unfocusedLabelColor = TextMuted,
focusedTextColor = TextPrimary,
unfocusedTextColor = TextPrimary,
),
shape = RoundedCornerShape(12.dp),
)
Spacer(modifier = Modifier.height(12.dp))
OutlinedTextField(
value = address,
onValueChange = {
address = sanitizeAddress(it)
errorMessage = null
},
label = { Text(stringResource(R.string.server_address_label)) },
placeholder = { Text(stringResource(R.string.server_address_placeholder)) },
modifier = Modifier.fillMaxWidth(),
singleLine = true,
keyboardOptions = KeyboardOptions(
keyboardType = KeyboardType.Uri,
imeAction = ImeAction.Next,
),
colors = OutlinedTextFieldDefaults.colors(
focusedBorderColor = Color.White.copy(alpha = 0.3f),
unfocusedBorderColor = Color.White.copy(alpha = 0.12f),
cursorColor = Color.White,
focusedLabelColor = Color.White.copy(alpha = 0.7f),
unfocusedLabelColor = TextMuted,
focusedTextColor = TextPrimary,
unfocusedTextColor = TextPrimary,
),
shape = RoundedCornerShape(12.dp),
)
Spacer(modifier = Modifier.height(12.dp))
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.spacedBy(12.dp),
) {
OutlinedTextField(
value = port,
onValueChange = {
port = it.filter { c -> c.isDigit() }.take(5)
errorMessage = null
},
label = { Text(stringResource(R.string.port_label)) },
placeholder = { Text("80") },
modifier = Modifier.weight(1f),
singleLine = true,
keyboardOptions = KeyboardOptions(
keyboardType = KeyboardType.Number,
imeAction = ImeAction.Next,
),
colors = OutlinedTextFieldDefaults.colors(
focusedBorderColor = Color.White.copy(alpha = 0.3f),
unfocusedBorderColor = Color.White.copy(alpha = 0.12f),
cursorColor = Color.White,
focusedLabelColor = Color.White.copy(alpha = 0.7f),
unfocusedLabelColor = TextMuted,
focusedTextColor = TextPrimary,
unfocusedTextColor = TextPrimary,
),
shape = RoundedCornerShape(12.dp),
)
OutlinedTextField(
value = password,
onValueChange = {
password = it
errorMessage = null
},
label = { Text("Password") },
modifier = Modifier.weight(2f),
singleLine = true,
visualTransformation = if (passwordVisible) VisualTransformation.None else PasswordVisualTransformation(),
trailingIcon = {
IconButton(onClick = { passwordVisible = !passwordVisible }) {
Icon(
imageVector = if (passwordVisible) Icons.Default.VisibilityOff else Icons.Default.Visibility,
contentDescription = if (passwordVisible) "Hide password" else "Show password",
tint = TextMuted,
modifier = Modifier.size(20.dp),
)
}
},
keyboardOptions = KeyboardOptions(
keyboardType = KeyboardType.Password,
imeAction = ImeAction.Go,
),
keyboardActions = KeyboardActions(
onGo = {
keyboard?.hide()
if (editingServer != null) {
saveEdit()
} else {
connect(ServerEntry(address, useHttps, port, password, name))
}
},
),
colors = OutlinedTextFieldDefaults.colors(
focusedBorderColor = Color.White.copy(alpha = 0.3f),
unfocusedBorderColor = Color.White.copy(alpha = 0.12f),
cursorColor = Color.White,
focusedLabelColor = Color.White.copy(alpha = 0.7f),
unfocusedLabelColor = TextMuted,
focusedTextColor = TextPrimary,
unfocusedTextColor = TextPrimary,
),
shape = RoundedCornerShape(12.dp),
)
}
Spacer(modifier = Modifier.height(16.dp))
Row(
modifier = Modifier.fillMaxWidth(),
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.SpaceBetween,
) {
Row(verticalAlignment = Alignment.CenterVertically) {
Icon(
imageVector = if (useHttps) Icons.Default.Lock else Icons.Default.LockOpen,
contentDescription = null,
modifier = Modifier.size(20.dp),
tint = if (useHttps) SuccessGreen else TextMuted,
)
Spacer(modifier = Modifier.width(8.dp))
Text(
text = stringResource(R.string.use_https),
style = MaterialTheme.typography.bodyMedium,
color = TextSecondary,
)
}
Switch(
checked = useHttps,
onCheckedChange = { useHttps = it },
colors = SwitchDefaults.colors(
checkedThumbColor = SurfaceBlack,
checkedTrackColor = BitcoinOrange,
uncheckedThumbColor = TextMuted,
uncheckedTrackColor = SurfaceCard,
),
)
}
}
}
// Error
AnimatedVisibility(visible = errorMessage != null, enter = fadeIn(), exit = fadeOut()) {
Box(
modifier = Modifier
.fillMaxWidth()
.clip(RoundedCornerShape(12.dp))
.background(ErrorRed.copy(alpha = 0.12f))
.border(1.dp, ErrorRed.copy(alpha = 0.25f), RoundedCornerShape(12.dp))
.padding(12.dp),
) {
Text(text = errorMessage ?: "", color = ErrorRed, style = MaterialTheme.typography.bodyMedium)
}
}
if (editingServer != null) {
// Save / Cancel while editing an existing saved server
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.spacedBy(12.dp),
) {
GlassButton(
text = stringResource(R.string.cancel),
onClick = {
keyboard?.hide()
cancelEdit()
},
modifier = Modifier.weight(1f).height(56.dp),
)
GlassButton(
text = stringResource(R.string.save_changes),
onClick = {
keyboard?.hide()
saveEdit()
},
modifier = Modifier.weight(1f).height(56.dp),
)
}
} else {
// Connect button — glass style
GlassButton(
text = if (isConnecting) stringResource(R.string.connecting) else stringResource(R.string.connect),
onClick = {
keyboard?.hide()
connect(ServerEntry(address, useHttps, port, password, name))
},
modifier = Modifier.fillMaxWidth().height(56.dp),
)
}
if (isConnecting) {
CircularProgressIndicator(
modifier = Modifier.size(24.dp),
color = Color.White.copy(alpha = 0.6f),
strokeWidth = 2.dp,
)
}
// Saved servers (hidden while editing one to keep focus on the form)
if (editingServer == null && savedServers.isNotEmpty()) {
Spacer(modifier = Modifier.height(8.dp))
Text(
text = stringResource(R.string.saved_servers),
style = MaterialTheme.typography.labelMedium,
color = TextMuted,
letterSpacing = 1.sp,
modifier = Modifier.fillMaxWidth(),
)
savedServers.forEach { server ->
SavedServerItem(
server = server,
onConnect = { connect(it) },
onEdit = { startEdit(it) },
onRemove = { scope.launch { prefs.removeSavedServer(it) } },
)
}
}
}
}
}
@Composable
private fun SavedServerItem(
server: ServerEntry,
onConnect: (ServerEntry) -> Unit,
onEdit: (ServerEntry) -> Unit,
onRemove: (ServerEntry) -> Unit,
) {
Row(
modifier = Modifier
.fillMaxWidth()
.clip(RoundedCornerShape(12.dp))
.background(Color.Black.copy(alpha = 0.6f))
.background(
Brush.verticalGradient(
colors = listOf(
Color.White.copy(alpha = 0.06f),
Color.White.copy(alpha = 0.02f),
),
)
)
.border(1.dp, Color.White.copy(alpha = 0.1f), RoundedCornerShape(12.dp))
.clickable { onConnect(server) }
.padding(horizontal = 16.dp, vertical = 14.dp),
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.SpaceBetween,
) {
Row(verticalAlignment = Alignment.CenterVertically, modifier = Modifier.weight(1f)) {
Icon(
imageVector = if (server.useHttps) Icons.Default.Lock else Icons.Default.LockOpen,
contentDescription = null,
modifier = Modifier.size(20.dp),
tint = if (server.useHttps) SuccessGreen else BitcoinOrange,
)
Spacer(modifier = Modifier.width(12.dp))
Column {
Text(text = server.displayName(), style = MaterialTheme.typography.bodyMedium, color = TextPrimary, maxLines = 1, overflow = TextOverflow.Ellipsis)
val secondary = buildString {
if (server.name.isNotBlank()) append(server.address)
if (server.port.isNotBlank()) {
if (isNotEmpty()) append(":${server.port}") else append("Port ${server.port}")
}
}
if (secondary.isNotBlank()) {
Text(text = secondary, style = MaterialTheme.typography.labelMedium, color = TextMuted, maxLines = 1, overflow = TextOverflow.Ellipsis)
}
}
}
IconButton(onClick = { onEdit(server) }) {
Icon(imageVector = Icons.Default.Edit, contentDescription = stringResource(R.string.edit_server), modifier = Modifier.size(18.dp), tint = TextMuted)
}
IconButton(onClick = { onRemove(server) }) {
Icon(imageVector = Icons.Default.Close, contentDescription = stringResource(R.string.remove_server), modifier = Modifier.size(18.dp), tint = TextMuted)
}
}
}
/** Strip protocol prefixes and trailing slashes from address input. */
private fun sanitizeAddress(input: String): String {
return input.trim()
.removePrefix("https://")
.removePrefix("http://")
.trimEnd('/')
}
/** Test RPC connectivity. Accepts self-signed certs for local LAN servers. */
private suspend fun testConnection(server: ServerEntry): Boolean {
return withContext(Dispatchers.IO) {
try {
val url = URL("${server.toUrl()}/rpc/v1")
val connection = url.openConnection() as HttpURLConnection
// Trust self-signed certs for local HTTPS (Archipelago nodes rarely have CA certs)
if (connection is HttpsURLConnection) {
val trustAll = arrayOf<javax.net.ssl.TrustManager>(object : X509TrustManager {
override fun checkClientTrusted(chain: Array<java.security.cert.X509Certificate>?, authType: String?) {}
override fun checkServerTrusted(chain: Array<java.security.cert.X509Certificate>?, authType: String?) {}
override fun getAcceptedIssuers(): Array<java.security.cert.X509Certificate> = arrayOf()
})
val sc = SSLContext.getInstance("TLS")
sc.init(null, trustAll, java.security.SecureRandom())
connection.sslSocketFactory = sc.socketFactory
connection.hostnameVerifier = javax.net.ssl.HostnameVerifier { _, _ -> true }
}
connection.requestMethod = "POST"
connection.connectTimeout = 5000
connection.readTimeout = 5000
connection.setRequestProperty("Content-Type", "application/json")
connection.doOutput = true
val body = """{"method":"server.echo","params":{"message":"ping"}}"""
connection.outputStream.use { it.write(body.toByteArray()) }
val code = connection.responseCode
connection.disconnect()
code in 200..499
} catch (_: Exception) {
false
}
}
}
@@ -0,0 +1,702 @@
package com.archipelago.app.ui.screens
import android.annotation.SuppressLint
import android.graphics.Bitmap
import android.graphics.BitmapFactory
import android.view.ViewGroup
import android.webkit.CookieManager
import android.webkit.WebChromeClient
import android.webkit.WebResourceError
import android.webkit.WebResourceRequest
import android.webkit.WebSettings
import android.webkit.WebView
import android.webkit.WebViewClient
import androidx.activity.compose.BackHandler
import androidx.compose.animation.AnimatedVisibility
import androidx.compose.animation.fadeIn
import androidx.compose.animation.fadeOut
import androidx.compose.foundation.Image
import androidx.compose.foundation.background
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.WindowInsets
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.safeDrawing
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.width
import androidx.compose.foundation.layout.windowInsetsPadding
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.automirrored.filled.ArrowBack
import androidx.compose.material.icons.automirrored.filled.ArrowForward
import androidx.compose.material.icons.filled.Close
import androidx.compose.material.icons.filled.CloudOff
import androidx.compose.material.icons.filled.OpenInBrowser
import androidx.compose.material.icons.filled.Refresh
import androidx.compose.material3.CircularProgressIndicator
import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton
import androidx.compose.material3.LinearProgressIndicator
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableIntStateOf
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.graphics.asImageBitmap
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.dp
import androidx.compose.ui.viewinterop.AndroidView
import com.archipelago.app.R
import com.archipelago.app.ui.theme.BitcoinOrange
import com.archipelago.app.ui.theme.SurfaceBlack
import com.archipelago.app.ui.theme.TextMuted
import com.archipelago.app.ui.theme.TextPrimary
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.withContext
/** Open a URL in the phone's default browser (genuinely external links). */
private fun openExternalUrl(context: android.content.Context, url: String) {
try {
val intent = android.content.Intent(
android.content.Intent.ACTION_VIEW,
android.net.Uri.parse(url),
).apply {
// Required when launching from a non-Activity/binder thread
// (the JS bridge below can run off the UI thread).
addFlags(android.content.Intent.FLAG_ACTIVITY_NEW_TASK)
}
context.startActivity(intent)
} catch (_: Exception) {}
}
/** True when [url] points at the same host as the connected Archipelago node
* (ignoring port). Such URLs are node apps — e.g. one that can't be iframed —
* and should stay inside the app rather than bouncing out to the browser. */
private fun isSameHost(url: String, base: String): Boolean {
return try {
val a = android.net.Uri.parse(url).host ?: return false
val b = android.net.Uri.parse(base).host ?: return false
a.equals(b, ignoreCase = true)
} catch (_: Exception) {
false
}
}
/** Apply the WebView settings shared by the kiosk view and the in-app browser.
* These are tuned for SPA performance and parity with the mobile browser;
* none of them alter how a page renders visually. */
@SuppressLint("SetJavaScriptEnabled")
private fun WebView.applyArchipelagoSettings() {
// Pre-rasterize just outside the viewport so flinging the kiosk/app doesn't
// show blank checkerboarding — the single biggest scroll-smoothness win and
// a major part of the "feels slower than the browser" gap. (API 23+)
settings.setOffscreenPreRaster(true)
settings.apply {
javaScriptEnabled = true
domStorageEnabled = true
databaseEnabled = true
mediaPlaybackRequiresUserGesture = false
mixedContentMode = WebSettings.MIXED_CONTENT_COMPATIBILITY_MODE
useWideViewPort = true
loadWithOverviewMode = true
setSupportZoom(false)
builtInZoomControls = false
cacheMode = WebSettings.LOAD_DEFAULT
allowContentAccess = true
allowFileAccess = false
}
// chrome://inspect profiling on debuggable builds only — lets us measure the
// real in-page bottleneck rather than guess. No effect on release builds.
val debuggable = 0 != (context.applicationInfo.flags and
android.content.pm.ApplicationInfo.FLAG_DEBUGGABLE)
if (debuggable) WebView.setWebContentsDebuggingEnabled(true)
}
@SuppressLint("SetJavaScriptEnabled", "ClickableViewAccessibility")
@Composable
fun WebViewScreen(
serverUrl: String,
onDisconnect: () -> Unit,
onRemoteInput: () -> Unit = {},
) {
var isLoading by remember { mutableStateOf(true) }
var loadProgress by remember { mutableIntStateOf(0) }
var hasError by remember { mutableStateOf(false) }
var webView by remember { mutableStateOf<WebView?>(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<String?>(null) }
BackHandler(enabled = inAppUrl == null && webView?.canGoBack() == true) {
webView?.goBack()
}
Box(
modifier = Modifier
.fillMaxSize()
.background(SurfaceBlack),
) {
if (hasError) {
Column(
modifier = Modifier
.fillMaxSize()
.windowInsetsPadding(WindowInsets.safeDrawing)
.padding(32.dp),
horizontalAlignment = Alignment.CenterHorizontally,
verticalArrangement = Arrangement.Center,
) {
Icon(
imageVector = Icons.Default.CloudOff,
contentDescription = null,
modifier = Modifier.size(64.dp),
tint = TextMuted,
)
Spacer(modifier = Modifier.height(24.dp))
Text(
text = stringResource(R.string.server_unreachable),
style = MaterialTheme.typography.headlineMedium,
color = TextPrimary,
textAlign = TextAlign.Center,
)
Spacer(modifier = Modifier.height(12.dp))
Text(
text = stringResource(R.string.connection_failed),
style = MaterialTheme.typography.bodyMedium,
color = TextMuted,
textAlign = TextAlign.Center,
)
Spacer(modifier = Modifier.height(32.dp))
GlassButton(
text = stringResource(R.string.retry),
onClick = {
hasError = false
isLoading = true
webView?.loadUrl(serverUrl)
},
modifier = Modifier.fillMaxWidth().height(56.dp),
)
Spacer(modifier = Modifier.height(12.dp))
GlassButton(
text = stringResource(R.string.disconnect),
onClick = onDisconnect,
modifier = Modifier.fillMaxWidth().height(48.dp),
)
}
} else {
// Edge-to-edge WebView — background bleeds behind status bar.
// Safe area values injected as CSS env() polyfill on each page load.
AndroidView(
modifier = Modifier.fillMaxSize(),
factory = { context ->
WebView(context).apply {
layoutParams = ViewGroup.LayoutParams(
ViewGroup.LayoutParams.MATCH_PARENT,
ViewGroup.LayoutParams.MATCH_PARENT,
)
isVerticalScrollBarEnabled = false
isHorizontalScrollBarEnabled = false
val cookieManager = CookieManager.getInstance()
cookieManager.setAcceptCookie(true)
cookieManager.setAcceptThirdPartyCookies(this, true)
applyArchipelagoSettings()
settings.apply {
setSupportMultipleWindows(true) // enables onCreateWindow for window.open
// Let JS open windows without a synchronous user-gesture
// chain; without this, window.open() from a Vue click
// handler silently no-ops and "Open in new tab" dies.
javaScriptCanOpenWindowsAutomatically = true
}
val webViewRef = this
// Decide where an outbound URL goes:
// - same host as the node → in-app WebView overlay
// (this is the "open in browser" target for apps the
// kiosk couldn't iframe — keep the user inside the app)
// - different host → the phone's real browser
fun routeOutbound(url: String) {
if (isSameHost(url, serverUrl)) {
inAppUrl = url
} else {
openExternalUrl(context, url)
}
}
// JS bridge. The web UI calls:
// window.ArchipelagoNative.openExternal(url) — host-routed
// window.ArchipelagoNative.openInApp(url) — force in-app
// Falls back to window.open in a plain mobile browser.
addJavascriptInterface(
object {
@android.webkit.JavascriptInterface
fun openExternal(url: String) {
webViewRef.post { routeOutbound(url) }
}
@android.webkit.JavascriptInterface
fun openInApp(url: String) {
webViewRef.post { inAppUrl = url }
}
},
"ArchipelagoNative",
)
webViewClient = object : WebViewClient() {
override fun onPageStarted(view: WebView?, url: String?, favicon: Bitmap?) {
isLoading = true
hasError = false
}
override fun onPageFinished(view: WebView?, url: String?) {
isLoading = false
if (view == null) return
// Convert physical pixels → CSS pixels
val density = view.resources.displayMetrics.density
val satPx = view.rootWindowInsets
?.getInsets(android.view.WindowInsets.Type.statusBars())
?.top ?: 0
val sabPx = view.rootWindowInsets
?.getInsets(android.view.WindowInsets.Type.navigationBars())
?.bottom ?: 0
val sat = (satPx / density).toInt()
val sab = (sabPx / density).toInt()
// Android WebView doesn't populate env(safe-area-inset-*).
// Set CSS custom properties the web UI can use as fallback:
// var(--safe-area-top, env(safe-area-inset-top, 0px))
view.evaluateJavascript(
"""
(function() {
var style = document.getElementById('archipelago-android-insets');
if (!style) {
style = document.createElement('style');
style.id = 'archipelago-android-insets';
document.head.appendChild(style);
}
style.textContent = ':root { --safe-area-top: ${sat}px; --safe-area-bottom: ${sab}px; }';
})();
""".trimIndent(),
null,
)
}
override fun onReceivedError(
view: WebView?,
request: WebResourceRequest?,
error: WebResourceError?,
) {
if (request?.isForMainFrame == true) {
hasError = true
isLoading = false
}
}
// Node apps (e.g. NetBird) terminate TLS with a
// self-signed cert — the dashboard needs a secure
// context for OIDC/window.crypto.subtle (#15). The
// WebView default is to CANCEL untrusted certs, so
// those apps render blank. The user explicitly trusts
// their own node, so proceed for same-host certs only;
// reject anything else (don't blanket-trust the web).
override fun onReceivedSslError(
view: WebView?,
handler: android.webkit.SslErrorHandler?,
error: android.net.http.SslError?,
) {
val u = error?.url
if (u != null && isSameHost(u, serverUrl)) {
handler?.proceed()
} else {
handler?.cancel()
}
}
override fun shouldOverrideUrlLoading(
view: WebView?,
request: WebResourceRequest?,
): Boolean {
val url = request?.url?.toString() ?: return false
// Keep kiosk navigation (same origin incl. port) in place
if (url.startsWith(serverUrl)) return false
// Same node (other port) → in-app; external → browser
routeOutbound(url)
return true
}
}
webChromeClient = object : WebChromeClient() {
override fun onProgressChanged(view: WebView?, newProgress: Int) {
loadProgress = newProgress
}
// window.open() — e.g. the kiosk's "Open in new tab"
// for an app that can't be iframed. Capture the target
// URL via a throwaway WebView and route it ourselves.
override fun onCreateWindow(
view: WebView?,
isDialog: Boolean,
isUserGesture: Boolean,
resultMsg: android.os.Message?,
): Boolean {
val transport = resultMsg?.obj as? WebView.WebViewTransport
?: return false
val popup = WebView(context).apply {
settings.javaScriptEnabled = true
webViewClient = object : WebViewClient() {
override fun shouldOverrideUrlLoading(
view: WebView?,
request: WebResourceRequest?,
): Boolean {
val url = request?.url?.toString() ?: return true
routeOutbound(url)
return true
}
override fun onPageStarted(view: WebView?, url: String?, favicon: Bitmap?) {
if (url != null) routeOutbound(url)
view?.stopLoading()
}
}
}
transport.webView = popup
resultMsg.sendToTarget()
return true
}
}
// Two-finger hold (500ms) → navigate to remote input
var twoFingerStart = 0L
var twoFingerFired = false
setOnTouchListener { _, event ->
val pointerCount = event.pointerCount
when (event.actionMasked) {
android.view.MotionEvent.ACTION_POINTER_DOWN -> {
if (pointerCount >= 2) {
twoFingerStart = System.currentTimeMillis()
twoFingerFired = false
}
}
android.view.MotionEvent.ACTION_MOVE -> {
if (pointerCount >= 2 && !twoFingerFired && twoFingerStart > 0) {
if (System.currentTimeMillis() - twoFingerStart > 500) {
twoFingerFired = true
onRemoteInput()
}
}
}
android.view.MotionEvent.ACTION_UP,
android.view.MotionEvent.ACTION_POINTER_UP,
android.view.MotionEvent.ACTION_CANCEL -> {
if (event.pointerCount <= 2) {
twoFingerStart = 0L
}
}
}
false // don't consume — let WebView handle normally
}
webView = this
loadUrl(serverUrl)
}
},
)
// Loading bar at top edge
AnimatedVisibility(
visible = isLoading,
enter = fadeIn(),
exit = fadeOut(),
) {
LinearProgressIndicator(
progress = { loadProgress / 100f },
modifier = Modifier.fillMaxWidth(),
color = BitcoinOrange,
trackColor = SurfaceBlack,
)
}
// In-app browser overlay for non-iframeable node apps. Rendered last
// so it sits above the kiosk WebView, which stays alive underneath.
inAppUrl?.let { target ->
InAppBrowser(
url = target,
serverUrl = serverUrl,
onClose = { inAppUrl = null },
)
}
}
}
}
/** Best-effort fetch of the origin's /favicon.ico, so the launched app's icon
* can be shown on the loading screen before the WebView reports onReceivedIcon
* (which only fires once the page's <head> has parsed). Blocking — call on IO. */
private fun fetchFavicon(pageUrl: String): Bitmap? {
return try {
val u = android.net.Uri.parse(pageUrl)
val scheme = u.scheme ?: return null
val host = u.host ?: return null
val portPart = if (u.port > 0) ":${u.port}" else ""
val conn = (java.net.URL("$scheme://$host$portPart/favicon.ico").openConnection()
as java.net.HttpURLConnection).apply {
connectTimeout = 4000
readTimeout = 4000
instanceFollowRedirects = true
}
conn.inputStream.use { BitmapFactory.decodeStream(it) }
} catch (_: Exception) {
null
}
}
/**
* Lightweight in-app browser used when the kiosk hands off an app that can't be
* shown in an iframe. Loads the app in a local WebView with a centered loading
* screen (app favicon + progress bar) and a BOTTOM control bar mirroring the
* web mobile-iframe footer (back / forward / reload / open-in-browser / close).
* Same-host navigation stays here; any genuinely external link escapes to the
* phone's browser.
*/
@SuppressLint("SetJavaScriptEnabled")
@Composable
private fun InAppBrowser(
url: String,
serverUrl: String,
onClose: () -> Unit,
) {
val context = LocalContext.current
var browser by remember { mutableStateOf<WebView?>(null) }
var title by remember { mutableStateOf(android.net.Uri.parse(url).host ?: url) }
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) }
// 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)
.windowInsetsPadding(WindowInsets.safeDrawing),
) {
// WebView + loading overlay fill the area above the bottom control bar.
Box(modifier = Modifier.weight(1f).fillMaxWidth()) {
AndroidView(
modifier = Modifier.fillMaxSize(),
factory = { ctx ->
WebView(ctx).apply {
layoutParams = ViewGroup.LayoutParams(
ViewGroup.LayoutParams.MATCH_PARENT,
ViewGroup.LayoutParams.MATCH_PARENT,
)
isVerticalScrollBarEnabled = false
isHorizontalScrollBarEnabled = false
CookieManager.getInstance().setAcceptThirdPartyCookies(this, true)
applyArchipelagoSettings()
webChromeClient = object : WebChromeClient() {
override fun onProgressChanged(view: WebView?, newProgress: Int) {
progress = newProgress
}
override fun onReceivedTitle(view: WebView?, t: String?) {
if (!t.isNullOrBlank()) title = t
}
override fun onReceivedIcon(view: WebView?, icon: Bitmap?) {
if (icon != null) favicon = icon
}
}
webViewClient = object : WebViewClient() {
override fun onPageStarted(view: WebView?, u: String?, favicon: Bitmap?) {
loading = true
}
override fun onPageFinished(view: WebView?, u: String?) {
loading = false
canGoBack = view?.canGoBack() == true
canGoForward = view?.canGoForward() == true
}
override fun doUpdateVisitedHistory(view: WebView?, u: String?, isReload: Boolean) {
canGoBack = view?.canGoBack() == true
canGoForward = view?.canGoForward() == true
}
// Self-signed TLS on the node's apps (e.g. NetBird on
// :8087) would otherwise be cancelled by the WebView
// and render blank. Proceed for the user's own node
// (same host); reject any other untrusted cert.
override fun onReceivedSslError(
view: WebView?,
handler: android.webkit.SslErrorHandler?,
error: android.net.http.SslError?,
) {
val u = error?.url
if (u != null && isSameHost(u, serverUrl)) {
handler?.proceed()
} else {
handler?.cancel()
}
}
override fun shouldOverrideUrlLoading(
view: WebView?,
request: WebResourceRequest?,
): Boolean {
val u = request?.url?.toString() ?: return false
// Stay in the overlay for same-node navigation;
// hand genuinely external links to the real browser.
if (isSameHost(u, serverUrl)) return false
openExternalUrl(ctx, u)
return true
}
}
browser = this
loadUrl(url)
}
},
)
// Centered loading screen — app favicon (or spinner) + title + bar.
if (loading) {
Column(
modifier = Modifier
.fillMaxSize()
.background(SurfaceBlack),
horizontalAlignment = Alignment.CenterHorizontally,
verticalArrangement = Arrangement.Center,
) {
Box(
modifier = Modifier.size(84.dp).clip(RoundedCornerShape(20.dp)),
contentAlignment = Alignment.Center,
) {
val fav = favicon
if (fav != null) {
Image(
bitmap = fav.asImageBitmap(),
contentDescription = title,
modifier = Modifier.fillMaxSize(),
)
} else {
CircularProgressIndicator(color = BitcoinOrange)
}
}
Spacer(modifier = Modifier.height(18.dp))
Text(
text = title,
style = MaterialTheme.typography.bodyLarge,
color = TextPrimary,
maxLines = 1,
overflow = TextOverflow.Ellipsis,
)
Spacer(modifier = Modifier.height(16.dp))
LinearProgressIndicator(
progress = { progress / 100f },
modifier = Modifier.width(220.dp),
color = BitcoinOrange,
trackColor = TextMuted.copy(alpha = 0.2f),
)
}
}
}
// Bottom control bar — mirrors the web mobile-iframe footer.
Row(
modifier = Modifier
.fillMaxWidth()
.height(56.dp)
.background(SurfaceBlack)
.padding(horizontal = 8.dp),
horizontalArrangement = Arrangement.SpaceAround,
verticalAlignment = Alignment.CenterVertically,
) {
IconButton(onClick = { browser?.goBack() }, enabled = canGoBack) {
Icon(
imageVector = Icons.AutoMirrored.Filled.ArrowBack,
contentDescription = "Back",
tint = if (canGoBack) TextPrimary else TextMuted.copy(alpha = 0.4f),
)
}
IconButton(onClick = { browser?.goForward() }, enabled = canGoForward) {
Icon(
imageVector = Icons.AutoMirrored.Filled.ArrowForward,
contentDescription = "Forward",
tint = if (canGoForward) TextPrimary else TextMuted.copy(alpha = 0.4f),
)
}
IconButton(onClick = { browser?.reload() }) {
Icon(
imageVector = Icons.Default.Refresh,
contentDescription = "Reload",
tint = TextPrimary,
)
}
IconButton(onClick = { openExternalUrl(context, browser?.url ?: url) }) {
Icon(
imageVector = Icons.Default.OpenInBrowser,
contentDescription = stringResource(R.string.open_in_browser),
tint = TextPrimary,
)
}
IconButton(onClick = onClose) {
Icon(
imageVector = Icons.Default.Close,
contentDescription = stringResource(R.string.close),
tint = TextPrimary,
)
}
}
}
}
@@ -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)
@@ -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 }
@@ -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()) }
})
}
}
@@ -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,
)
}
@@ -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,
),
)
Binary file not shown.

After

Width:  |  Height:  |  Size: 869 KiB

@@ -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>
@@ -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>
@@ -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>
@@ -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>
@@ -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>
@@ -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>
@@ -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>
@@ -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>
@@ -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>
@@ -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>
@@ -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>
@@ -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>
@@ -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>
@@ -0,0 +1,35 @@
<?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="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="edit_server_title">Edit Server</string>
<string name="save_changes">Save Changes</string>
<string name="cancel">Cancel</string>
</resources>
@@ -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>
@@ -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>
Binary file not shown.
+4
View File
@@ -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
}
+5
View File
@@ -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
Binary file not shown.
+7
View File
@@ -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
Vendored Executable
+249
View File
@@ -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" "$@"
+92
View File
@@ -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
+10
View File
@@ -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>

After

Width:  |  Height:  |  Size: 1.4 KiB

+18
View File
@@ -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")
+41
View File
@@ -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."
+1019
View File
File diff suppressed because it is too large Load Diff
+84
View File
@@ -0,0 +1,84 @@
# Archipelago — agent guide
## ✅ Single-node production gate is GREEN (2026-06-23)
`tests/lifecycle/run-gate.sh` is **5/5 on .228, 0 failures** — the single-node exit
criterion is met and the priority banner is demoted. Next exit-criteria: the
**multinode pass** (`docs/multinode-testing-plan.md`) and workstreams B/C/D.
**For day-to-day work, use `docs/UNIFIED-TASK-TRACKER.md`** — the consolidated,
priority-ordered "what's left" list across the 1.8.0 OTA and master-plan docs
(fastest/simplest tasks first). It supersedes hunting through the two source docs
below for open items; those remain the narrative/history.
**Read `docs/PRODUCTION-MASTER-PLAN.md` first** — it is still the authoritative plan
for the north star: a world-class, **developer-ready app platform** where every app
is manifest-driven, manifests ship via the **signed registry** (not OTA disk files),
and **third-party developers publish apps via an external/decentralized registry**
all rootless, secure, robust, and 100%-uptime-capable. It no longer overrides all
ad-hoc direction now that the gate is green, but it remains the source of truth for
sequencing the remaining workstreams.
Detailed sub-plans (all linked from the master):
- App platform / packaging phases + security model → `docs/APP-PACKAGING-MIGRATION-PLAN.md`
- Registry-distributed manifests (in progress) → `docs/registry-manifest-design.md`
- External/decentralized marketplace for devs → `docs/marketplace-protocol.md`
- Current per-app state → `docs/archive/app-registry-status-2026-06-21.md`
- Production test gate (exit criterion) → `tests/lifecycle/TESTING.md`
## Commit & push every unit of work (never violate)
**The #1 process rule: work is not "done" until it is committed AND pushed.** This
exists because finished work has been lost/clobbered by sitting uncommitted in the
shared tree across agents and sessions. To prevent that:
- **Commit each feature/fix the moment it works** — one focused, self-contained
commit per logical change (it compiles and its targeted tests pass). Do not let
unrelated changes accumulate uncommitted.
- **Push immediately after committing** so nothing lives only on one machine. `main`
is protected → push via `git push gitea-ai main` (account `ai`, see the memory
note); feature branches push to their own remote.
- **Never leave a stack of finished work uncommitted** overnight or when handing off
between agents — if you must pause mid-change, commit a clearly-labelled WIP
checkpoint rather than leaving it dirty.
- **Stage explicitly by path** (`git add <paths>`) when another agent's uncommitted
work shares the tree — never `git add -A` / `git commit -a`, which clobbers or
entangles their changes.
- **Never commit or push secrets** (mnemonics, private keys, API tokens). Signing is
done offline; artifacts (catalog/manifest) are signed, not the keys.
- Commit messages end with the `Co-Authored-By: Claude …` trailer.
## Invariants (never violate)
- **Rootless Podman only.** No rootful, no Docker-socket mounts, no privileged
containers unless explicitly approved.
- **No per-app Rust installers / no OS-level reliance.** Apps are declarative;
the orchestrator owns the lifecycle. `install_immich_stack` (hardcoded
`podman run` + `sudo chown`) is the anti-pattern being deleted, not a template.
- **Secrets are manifest-declared** (`generated_secrets`, materialised by
`container::secrets`, 0600/rootless) — never hardcoded, per-app, or logged.
- **Migrations never destroy data** — preserve `/var/lib/archipelago/<app>`,
secrets, credentials, ports, and adoption container names; keep a rollback path.
- **Verify on the real node .228 before any tag.** (Fleet-wide multinode
verification is a separate plan: `docs/multinode-testing-plan.md`.)
## Build / verify
- Rust workspace root is `core/` (no Cargo.toml at repo root). `cargo` from `core/`.
- If a `cargo test`/build hits `rust-lld: undefined hidden symbol`, it's
incremental-cache corruption — rebuild with `CARGO_INCREMENTAL=0`.
- Frontend: `neode-ui/``npm run build` outputs to `web/dist/neode-ui/`.
Grep the built bundle for new strings before shipping (build can silently no-op).
- App manifests load from disk on nodes at `/opt/archipelago/apps/*/manifest.yml`
(today); the goal is to distribute them via the signed catalog instead.
## Production test gate (definition of done)
`tests/lifecycle/run-gate.sh` green across install / UI / stop / start / restart /
reinstall / reboot-survive / archipelago-restart-survive / uninstall — **5× on
.228** (`ARCHY_ITERATIONS=5`). **Run the gate ON the node** (it uses local podman/systemctl/bitcoin
probes), not via RPC from another host. **✅ GREEN 2026-06-23 (5/5, 0 not-ok)** — keep it
green (re-run after orchestrator/lifecycle changes); regressions are top priority again.
**Multinode testing (.198 + the rest of the fleet) is a SEPARATE plan**
`docs/multinode-testing-plan.md` — not part of this single-node gate criterion, and is
the next exit criterion now that single-node is green.
+161
View File
@@ -0,0 +1,161 @@
# Contributing to Archipelago
Thank you for your interest in contributing to Archipelago! This document covers the process for contributing code, reporting bugs, and submitting apps.
## Code of Conduct
Be respectful. We follow the [Contributor Covenant](https://www.contributor-covenant.org/version/2/1/code_of_conduct/).
## Getting Started
1. Fork the repository on the project's Gitea instance
2. Clone your fork: `git clone <your-fork-url>/archy.git`
3. Set up the dev environment (see `docs/developer-guide.md`)
4. Create a feature branch: `git checkout -b feature/your-feature`
## Development Setup
### Frontend (Vue.js)
```bash
cd neode-ui
npm install
npm start # Dev server on :8100
npm run type-check # TypeScript validation
npm run build # Production build
npm test # Run tests
```
### Backend (Rust)
Build on a Linux server (Debian 13), **not** macOS:
```bash
cargo clippy --all-targets --all-features
cargo fmt --all
cargo test --all-features
```
### Deploy to dev server
```bash
./scripts/deploy-to-target.sh --live
```
## Code Style
### Frontend (TypeScript + Vue)
- `<script setup lang="ts">` — always Composition API
- TypeScript strict mode — no `any`, use `unknown` or proper types
- Global CSS classes in `src/style.css` — never inline Tailwind in components
- Pinia for state management — focused single-purpose stores
- Use `@/api/rpc-client.ts` for RPC calls
### Backend (Rust)
- No `unwrap()` or `expect()` in production code — use `?` operator
- `thiserror` for library errors, `anyhow` for application errors
- `tracing` for structured logging — never `println!`
- Run `cargo clippy` and `cargo fmt` before commits
### General
- Functions under 50 lines, single responsibility
- Comment WHY not WHAT
- Remove dead code — never comment it out
- No `TODO`/`FIXME` in commits
## Commit Format
```
type: description
```
**Types**: `feat:`, `fix:`, `docs:`, `refactor:`, `test:`, `chore:`, `perf:`
Examples:
- `feat: add backup scheduling to settings page`
- `fix: handle WiFi connection timeout gracefully`
- `test: add unit tests for RPC client retry logic`
## Pull Request Process
1. Ensure your branch is up to date with `main`
2. All checks must pass: TypeScript, build, tests, clippy
3. Include a clear description of what changed and why
4. Link any related issues
5. Request review from a maintainer
### PR Checklist
- [ ] TypeScript type-check passes (`npm run type-check`)
- [ ] Frontend builds (`npm run build`)
- [ ] Tests pass (`npm test`)
- [ ] Rust clippy clean (`cargo clippy --all-targets --all-features`)
- [ ] No new compiler warnings
- [ ] Follows code style guidelines above
## Testing Requirements
- New features need tests
- Bug fixes need a regression test
- Frontend: Vitest + Vue Test Utils
- Backend: `#[test]` and `#[tokio::test]`
- Target: maintain or improve existing coverage
## Reporting Bugs
Use the **Bug Report** issue template. Include:
1. Steps to reproduce
2. Expected behavior
3. Actual behavior
4. System info (hardware, OS version, Archipelago version)
5. Screenshots if applicable
6. Relevant logs (`journalctl -u archipelago`)
## Feature Requests
Use the **Feature Request** issue template. Include:
1. Problem description
2. Proposed solution
3. Alternatives considered
4. Impact on existing users
## App Submissions
To submit an app for the Archipelago marketplace:
1. Create a manifest following `docs/app-manifest-spec.md`
2. Ensure the container image is published to a public registry
3. Test on Archipelago hardware (x86_64 and ARM64 if possible)
4. Open a PR adding the app to the curated list
5. Include: app description, icon, resource requirements, dependencies
### App Requirements
- Container must run as non-root (UID > 1000)
- `readonly_root: true` unless explicitly justified
- Drop all capabilities except those required
- `no-new-privileges: true`
- Pin specific image versions (no `latest` tag)
- No hardcoded secrets
## Security Disclosure
**Do NOT open public issues for security vulnerabilities.**
Email security concerns to the maintainers directly. Include:
1. Description of the vulnerability
2. Steps to reproduce
3. Potential impact
4. Suggested fix (if any)
We will acknowledge receipt within 48 hours and provide a timeline for a fix.
## License
By contributing, you agree that your contributions will be licensed under the same license as the project.
Executable
+166
View File
@@ -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 ""
+202
View File
@@ -0,0 +1,202 @@
# Archipelago
> Self-Sovereign Bitcoin Node OS
**Archipelago** is a bootable personal server OS. Flash it to a USB drive, install on any x86_64 or ARM64 machine, and manage Bitcoin infrastructure, self-hosted apps, mesh communication, and decentralized identity through a glassmorphism web UI.
[![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)]()
## Philosophy
Archipelago is being built as a **developer-ready app platform**, not a fixed appliance:
- **Manifest-driven apps.** Every app is declared in a single `manifest.yml` — image, ports, volumes, secrets, health checks, security policy. The orchestrator owns the entire lifecycle; there is no per-app installer code and no host-level provisioning.
- **Signed distribution.** App manifests ship inside an Ed25519-signed catalog verified against a pinned release-root key, not as loose files on disk. OTA release manifests are signed the same way.
- **Decentralized marketplace.** Third-party developers publish apps via Nostr-based discovery (NIP-78) with DID-signed manifests and federation-weighted trust scoring — no gatekept central store.
- **Rootless and secure by default.** Rootless Podman only. Read-only root, no-new-privileges, capability allow-list, secrets materialised 0600 and never logged. Never rootful, never a Docker socket mount.
- **100%-uptime-capable.** Every container is a systemd Quadlet unit under `user.slice` that survives backend restarts; a level-triggered reconciler self-heals drift every 30 seconds; migrations never destroy data.
## Features
### Bitcoin Infrastructure
- **Bitcoin Core and Bitcoin Knots** full nodes with per-app version pinning and bulletproof version switching, automatic prune/full mode based on disk size
- **LND** and **Core Lightning** with channel management
- **ElectrumX** Electrum server for wallet connectivity
- **BTCPay Server** for accepting Bitcoin payments
- **Mempool** block explorer and fee estimator
- **Fedimint** federation guardian, gateway, and client — plus Cashu ecash wallet support
### Self-Hosted Apps (50+)
Storage (FileBrowser, Immich, Nextcloud), Productivity (Vaultwarden), Media (Jellyfin, PhotoPrism, IndeeHub), Search (SearXNG), Network (NetBird, Tailscale), Home (Home Assistant), Nostr (nostr-rs-relay, strfry), Dev/Ops (Gitea, Grafana, Portainer, Uptime Kuma), and more — 27 curated in the store UI, 50+ packaged as manifests.
### Mesh Networking (tri-protocol)
- **Meshtastic**, **MeshCore**, and **Reticulum (RNS/LXMF)** LoRa transports behind one mesh chat UI
- End-to-end encryption with X3DH key agreement + double-ratchet
- RNode radio support with an OS-level `archy-rnodeconf` tool; interop verified against Sideband
- Image/voice attachments, mesh AI assistant (`!ai`), Bitcoin balance relay over mesh
### Decentralized Identity
- Ed25519 node identity with DID Documents (did:key)
- Multi-identity management (Personal/Business/Anonymous)
- W3C Verifiable Credentials issuance and verification
- Nostr integration: NIP-33 node discovery, NIP-44/NIP-04 encryption, NIP-07 signer bridge for iframe apps, relay hosting
- Decentralized Web Node (DWN) record sync between federated nodes over Tor
### Multi-Node Federation
- Invite-based node joining over Tor hidden services
- Trust levels (Trusted/Verified/Untrusted) with DID-based auth
- State sync and app deployment across federated nodes
- File sharing with access controls (free/peers-only/paid via Lightning, on-chain, or ecash)
### System Updates
- OTA updates from a self-hosted Gitea release server, Ed25519-signature-verified against a pinned release-root key
- Resumable downloads, automatic pre-update backup, rollback with a post-update self-verify window
- Manual, scheduled-check, and auto-apply modes (auto-apply refuses unsigned manifests)
### Security
- Argon2id password hashing (transparent upgrade from legacy hashes), ChaCha20-Poly1305 encrypted secrets at rest
- Rootless Podman: read-only root, cap-drop ALL with a reviewed allow-list, no-new-privileges
- Signed release manifests and signed app catalog (Ed25519, pinned trust anchor)
- TOTP two-factor authentication, per-endpoint rate limiting, CSRF protection
- AppArmor profiles for container confinement; Tor hidden services for inter-node traffic
- Independent security audit of an early version archived in [`docs/archive/`](docs/archive/security-code-audit-2026-03.md); top findings since remediated
## Roadmap
**Done**
- Single-node production gate **green** — install / stop / start / restart / reinstall / reboot-survive / uninstall, 5 consecutive full runs with zero failures on real hardware
- Quadlet migration validated (all backends as `user.slice` services on the canary node)
- Release signing ceremony completed — release-root key pinned, catalog and OTA manifests signed
- Reticulum third mesh transport (real-RF LoRa gates passed), Bitcoin Core/Knots multi-version switching, decentralized marketplace backend, public demo
**In progress**
- Multinode pass: the same production gate across the whole test fleet ([`docs/multinode-testing-plan.md`](docs/multinode-testing-plan.md))
- Quadlet default flip fleet-wide + container-flapping elimination
- 1.8.0 release hardening tail ([`docs/1.8.0-RELEASE-HARDENING-PLAN.md`](docs/1.8.0-RELEASE-HARDENING-PLAN.md)): OTA upgrade soak on real hardware, ISO/image hardening (per-device keys, no default creds, signed ISO)
**Planned**
- Developer CLI (`archy app validate/render/install/test`) to open third-party app publishing
- External marketplace trust UX + publishing tooling ([`docs/marketplace-protocol.md`](docs/marketplace-protocol.md))
- DHT/P2P distribution of releases and app images ([`docs/dht-distribution-design.md`](docs/dht-distribution-design.md))
- P2P encrypted voice/video over Tor, dual-ecash (Fedimint + Cashu) phases, paid streaming, hardware signer support
The live, priority-ordered task list is [`docs/UNIFIED-TASK-TRACKER.md`](docs/UNIFIED-TASK-TRACKER.md); the full narrative plan is [`docs/PRODUCTION-MASTER-PLAN.md`](docs/PRODUCTION-MASTER-PLAN.md).
## Quick Start
### Install from ISO
1. Build or download the ISO for your architecture (x86_64 or ARM64) — see [`image-recipe/`](image-recipe/)
2. Flash to USB drive with Balena Etcher or `dd`
3. Boot from USB on target hardware and follow the automated installer
4. Access the web UI at `http://<device-ip>`
5. Set your password and complete the onboarding wizard (seed backup, DID identity)
### Supported Hardware
| Platform | Examples | Minimum |
|----------|----------|---------|
| **x86_64** | Intel NUC, mini PCs, any 64-bit PC | 4GB RAM, 32GB storage |
| **ARM64** | Raspberry Pi 5, ARM64 SBCs | 4GB RAM, 32GB storage |
**Recommended**: 8GB+ RAM, 1TB+ NVMe SSD (for a full Bitcoin node). Optional: an RNode-compatible LoRa radio for mesh networking.
## Development
### Prerequisites
- macOS or Linux for frontend development
- Linux dev server (Debian 13) for backend builds — **never build Rust on macOS for Linux**
- Node.js 20+, Rust stable toolchain
### Frontend Development
```bash
cd neode-ui
npm install
npm start # Dev server on http://localhost:8100 (mock backend on :5959)
npm run type-check # TypeScript validation
npm run build # Production build → web/dist/neode-ui/
```
### Backend Development
```bash
cd core # Rust workspace root (no Cargo.toml at repo root)
cargo build
cargo test
```
### Deploy to a Test Node
```bash
./scripts/deploy-to-target.sh --live # Deploy to primary dev server
./scripts/deploy-to-target.sh --both # Deploy to both LAN servers
```
### Release (tarball-only)
Releases ship as a backend binary and a frontend tarball referenced by
`releases/manifest.json`, published to the self-hosted Gitea release server.
```bash
./scripts/create-release.sh 1.2.3
git push origin main --tags
```
## Architecture
```
Debian 13 (Trixie)
├── Rootless Podman — every app a systemd Quadlet unit under user.slice
├── Nginx (reverse proxy, security headers, rate limiting)
├── Rust Backend (JSON-RPC API on 127.0.0.1:5678, ~380 RPC methods)
│ ├── core/archipelago/ — API, orchestrator + reconciler, mesh, identity,
│ │ federation, wallet, updates, marketplace
│ ├── core/container/ — Podman client, manifest schema, Quadlet compiler,
│ │ health monitor, signed app catalog
│ ├── core/security/ — AppArmor/seccomp policy, secrets manager
│ ├── core/openwrt/ — TollGate gateway provisioning (SSH/UCI)
│ └── core/performance/ — resource limits
├── Vue 3 Frontend (Composition API + TypeScript strict + Pinia + Tailwind, PWA)
│ └── Three UI modes (Pro/Easy/Chat) + gamepad navigation + i18n
├── Reticulum daemon (supervised Python/PyInstaller, one per LoRa radio)
└── System Tor (hidden services, SOCKS5 proxy)
```
~117,000 lines of Rust | ~69,000 lines of TypeScript/Vue | 51 packaged apps | Android companion app
## Documentation
| Doc | Purpose |
|-----|---------|
| [Architecture](docs/architecture.md) | System design, crate map, data paths |
| [Developer Guide](docs/developer-guide.md) | Dev setup, workflow, code conventions |
| [API Reference](docs/api-reference.md) | RPC endpoint reference |
| [App Developer Guide](docs/app-developer-guide.md) | Building and publishing apps |
| [App Manifest Spec](docs/app-manifest-spec.md) | The `manifest.yml` schema |
| [User Walkthrough](docs/user-walkthrough.md) | End-user installation and usage guide |
| [Troubleshooting](docs/troubleshooting.md) | Diagnostic scenarios and solutions |
| [Operations Runbook](docs/operations-runbook.md) | Ops commands and emergency recovery |
| [Production Master Plan](docs/PRODUCTION-MASTER-PLAN.md) | North star and workstream narrative |
| [Unified Task Tracker](docs/UNIFIED-TASK-TRACKER.md) | Live, priority-ordered open items |
| [Test Gate](tests/lifecycle/TESTING.md) | Production lifecycle test gate (definition of done) |
| [Archive](docs/archive/) | Historical audits, session logs, shipped designs |
## Contributing
1. Fork the repository
2. Create a feature branch (`feature/description`)
3. Follow the coding standards in [CONTRIBUTING.md](CONTRIBUTING.md) and [CLAUDE.md](CLAUDE.md)
4. Submit a pull request
## License
[MIT License](LICENSE)
## Acknowledgments
Built with: [Rust](https://www.rust-lang.org/), [Vue.js](https://vuejs.org/), [Podman](https://podman.io/), [Bitcoin Core](https://bitcoin.org/), [LND](https://lightning.engineering/), [Reticulum](https://reticulum.network/), [Debian](https://www.debian.org/)
+112
View File
@@ -0,0 +1,112 @@
# Archipelago v1.0.0 Release Notes
**Release Date**: March 2026
**Target Platform**: Debian 13 (Trixie) — x86_64 and ARM64
## What is Archipelago?
Archipelago is a self-sovereign Bitcoin Node OS. Flash it to a USB drive, install on any x86_64 or ARM64 machine, and manage your personal server through a modern web interface. Run Bitcoin infrastructure, self-hosted apps, and Web5 identity — all from hardware you control.
## Key Features
### Bitcoin Infrastructure
- **Bitcoin Knots** full node with pruning support
- **LND** Lightning Network daemon with channel management UI
- **Electrs** Electrum server for wallet connectivity
- **BTCPay Server** for accepting Bitcoin payments
- **Mempool** block explorer and fee estimator
- **Fedimint** federation guardian and gateway
### Self-Hosted Apps (20+)
- **Storage**: File Browser, Immich, PhotoPrism, Nextcloud
- **Productivity**: Penpot, OnlyOffice, Vaultwarden
- **Media**: Jellyfin
- **Search**: SearXNG (private search)
- **AI**: Ollama (local LLMs with Claude, GPT, and open models)
- **Network**: Tailscale VPN, Nginx Proxy Manager, Uptime Kuma
- **Home**: Home Assistant
- **Platform**: IndeedHub, Grafana monitoring
### Web5 Identity
- DID-based digital identity (Ed25519 + secp256k1 dual key)
- Verifiable Credentials issuance and verification
- Decentralized Web Node (DWN) for data sync
- Nostr relay integration for node discovery
### Federation
- DID-authenticated peer-to-peer federation
- Remote node monitoring and management
- Bilateral trust with single-use invite codes
- Tor hidden services for private communication
### Security
- AES-256-GCM encrypted secrets at rest
- Container isolation: read-only root, capability dropping, non-root user
- TOTP two-factor authentication with backup codes
- Session management: HttpOnly cookies, SameSite=Strict, CSRF tokens
- Rate limiting on sensitive endpoints
- AppArmor profiles for container confinement
- Per-endpoint input validation
### System
- Rust backend with JSON-RPC API (<1ms response time)
- Vue 3 frontend with glassmorphism design
- WebSocket real-time updates
- Automated OTA updates with rollback
- Tor hidden services for all apps
- Goal-based onboarding wizard
- Kiosk mode for dedicated hardware
## Supported Hardware
- **x86_64**: Any 64-bit PC, Intel NUC, mini PCs
- **ARM64**: Raspberry Pi 5, other ARM64 SBCs
- **Minimum**: 4GB RAM, 32GB storage (500GB+ recommended for Bitcoin)
- **Recommended**: 8GB+ RAM, 1TB+ NVMe SSD
## Installation
1. Download the ISO for your architecture
2. Flash to USB drive (use Balena Etcher or `dd`)
3. Boot from USB on target hardware
4. Follow the automated installer
5. Access the web UI at `http://<device-ip>`
6. Set your password and start the onboarding wizard
## Known Limitations
- Bitcoin initial block download takes 3-7 days depending on hardware
- Some apps (BTCPay Server, Home Assistant) open in new tab due to X-Frame-Options
- ARM64 builds may have slower container pulls due to less cached registry content
- Tor hidden service generation takes 1-2 minutes on first boot
## Upgrade from Beta
If upgrading from v0.5.0-beta:
1. Back up your data via Settings > Backup
2. The OTA update system will handle the upgrade automatically
3. If OTA fails, reflash with the v1.0.0 ISO (app data is preserved on separate partition)
## Security Model
Archipelago follows defense-in-depth:
- **Network**: Nginx reverse proxy, Tor hidden services, VPN support
- **Application**: Container isolation with Podman (rootless)
- **Data**: AES-256-GCM encryption for secrets, 0600 file permissions
- **Auth**: Argon2 password hashing, TOTP 2FA, session rotation
- **Updates**: SHA-256 verified downloads with rollback capability
See `docs/adr/` for architectural decision records on security choices.
## Contributing
Archipelago is open source. To contribute:
1. Fork the repository
2. Create a feature branch (`feature/description`)
3. Follow the coding standards in `CLAUDE.md`
4. Submit a pull request with tests
## License
MIT License. See `LICENSE` for details.
# 2026-04-18 ISO build trigger
+39
View File
@@ -0,0 +1,39 @@
# Archipelago App Catalog
Dynamic app catalog for the Archipelago marketplace. Nodes fetch this catalog to discover available apps.
## How it works
1. The Archipelago frontend fetches `catalog.json` from this repo
2. Apps listed here appear in every node's app store automatically
3. When a user installs an app, the backend pulls the Docker image and creates the container
## Adding a new app
Add an entry to `catalog.json`:
```json
{
"id": "my-app",
"title": "My App",
"version": "1.0.0",
"description": "What it does",
"icon": "/assets/img/app-icons/my-app.svg",
"author": "Author",
"category": "data",
"dockerImage": "146.59.87.168:3000/lfg2025/my-app:1.0.0",
"repoUrl": "https://github.com/...",
"containerConfig": {
"ports": ["8080:8080"],
"volumes": ["/var/lib/archipelago/my-app:/data"],
"env": ["NODE_ENV=production"]
}
}
```
For apps with hardcoded backend configs (Bitcoin, LND, etc.), `containerConfig` is optional.
For new apps, include `containerConfig` so the backend knows how to create the container.
## Categories
money, commerce, data, home, nostr, networking, community, development, l484
+541
View File
@@ -0,0 +1,541 @@
{
"version": 2,
"updated": "2026-04-22T00:00:00Z",
"registry": "146.59.87.168:3000/lfg2025",
"featured": {
"id": "indeedhub",
"banner": "/assets/img/featured/indeedhub-banner.jpg",
"headline": "Stream Sovereignty",
"description": "Bitcoin documentaries with Nostr identity.",
"tag": "NOSTR IDENTITY // YOUR NODE"
},
"apps": [
{
"id": "bitcoin-knots",
"title": "Bitcoin Knots",
"version": "28.1.0",
"description": "Full Bitcoin Knots node with dynamic prune/full-mode startup based on host disk.",
"icon": "/assets/img/app-icons/bitcoin-knots.webp",
"author": "Bitcoin Knots",
"category": "money",
"tier": "core",
"dockerImage": "146.59.87.168:3000/lfg2025/bitcoin-knots:latest",
"repoUrl": "https://github.com/bitcoinknots/bitcoin"
},
{
"id": "bitcoin-core",
"title": "Bitcoin Core",
"version": "28.4.0",
"description": "Reference Bitcoin Core node with dynamic prune/full-mode startup based on host disk.",
"icon": "/assets/img/app-icons/bitcoin-core.svg",
"author": "Bitcoin Core contributors",
"category": "money",
"tier": "optional",
"dockerImage": "146.59.87.168:3000/lfg2025/bitcoin:28.4",
"repoUrl": "https://github.com/bitcoin/bitcoin"
},
{
"id": "lnd",
"title": "LND",
"version": "0.18.4",
"description": "Lightning Network implementation by Lightning Labs. Enables instant, low-cost Bitcoin payments.",
"icon": "/assets/img/app-icons/lnd.png",
"author": "Lightning Labs",
"category": "money",
"tier": "core",
"dockerImage": "146.59.87.168:3000/lfg2025/lnd:v0.18.4-beta",
"repoUrl": "https://github.com/lightningnetwork/lnd",
"requires": [
"bitcoin-knots"
]
},
{
"id": "btcpay-server",
"title": "BTCPay Server",
"version": "2.3.9",
"description": "Self-hosted Bitcoin payment processor. Accept Bitcoin payments without intermediaries.",
"icon": "/assets/img/app-icons/btcpay-server.png",
"author": "BTCPay Server Foundation",
"category": "commerce",
"tier": "core",
"dockerImage": "docker.io/btcpayserver/btcpayserver:2.3.9",
"repoUrl": "https://github.com/btcpayserver/btcpayserver",
"requires": [
"bitcoin-knots"
]
},
{
"id": "mempool",
"title": "Mempool Explorer",
"version": "3.0.0",
"description": "Bitcoin mempool and blockchain explorer. Real-time transaction and block visualization.",
"icon": "/assets/img/app-icons/mempool.webp",
"author": "Mempool",
"category": "money",
"tier": "core",
"dockerImage": "146.59.87.168:3000/lfg2025/mempool-frontend:v3.0.1",
"repoUrl": "https://github.com/mempool/mempool",
"requires": [
"bitcoin-knots",
"electrumx"
]
},
{
"id": "electrumx",
"title": "ElectrumX",
"version": "1.18.0",
"description": "Electrum server indexing Bitcoin chain data for lightweight wallet queries.",
"icon": "/assets/img/app-icons/electrumx.png",
"author": "Luke Childs",
"category": "money",
"tier": "core",
"dockerImage": "146.59.87.168:3000/lfg2025/electrumx:v1.18.0",
"repoUrl": "https://github.com/spesmilo/electrumx",
"requires": [
"bitcoin-knots"
]
},
{
"id": "indeedhub",
"title": "IndeeHub",
"version": "1.0.0",
"description": "Bitcoin documentary streaming platform featuring God Bless Bitcoin and other educational content about Bitcoin, sovereignty, and decentralized technology. Sign in with your Nostr identity.",
"icon": "/assets/img/app-icons/indeedhub.png",
"author": "IndeeHub",
"category": "community",
"dockerImage": "146.59.87.168:3000/lfg2025/indeedhub:1.0.0",
"repoUrl": "https://github.com/indeedhub/indeedhub"
},
{
"id": "botfights",
"title": "BotFights",
"version": "1.1.0",
"description": "Bot competition arena with 2-player arcade fighting mode. AI bots battle in trivia challenges while humans duke it out with controllers. Built for Bitcoiners.",
"icon": "/assets/img/app-icons/botfights.svg",
"author": "BotFights",
"category": "community",
"dockerImage": "146.59.87.168:3000/lfg2025/botfights:1.1.0",
"repoUrl": "https://botfights.net",
"containerConfig": {
"ports": [
"9100:9100"
],
"volumes": [
"/var/lib/archipelago/botfights:/app/server/data"
],
"env": [
"NODE_ENV=production",
"PORT=9100",
"FIGHT_LOOP_ENABLED=true",
"ARCHY_EMBEDDED=1"
]
}
},
{
"id": "gitea",
"title": "Gitea",
"version": "1.23",
"description": "Self-hosted Git service with built-in container registry, CI/CD, and package hosting.",
"icon": "/assets/img/app-icons/gitea.svg",
"author": "Gitea",
"category": "development",
"dockerImage": "docker.io/gitea/gitea:1.23",
"repoUrl": "https://gitea.com",
"containerConfig": {
"ports": [
"3001:3000",
"2222:22"
],
"volumes": [
"/var/lib/archipelago/gitea/data:/data",
"/var/lib/archipelago/gitea/config:/etc/gitea"
],
"env": [
"GITEA__database__DB_TYPE=sqlite3",
"GITEA__server__SSH_PORT=2222",
"GITEA__server__SSH_LISTEN_PORT=22",
"GITEA__server__LFS_START_SERVER=true",
"GITEA__packages__ENABLED=true",
"GITEA__repository__ENABLE_PUSH_CREATE_USER=true",
"GITEA__repository__ENABLE_PUSH_CREATE_ORG=true",
"GITEA__security__X_FRAME_OPTIONS="
]
},
"tier": "optional"
},
{
"id": "filebrowser",
"title": "File Browser",
"version": "2.27.0",
"description": "Baseline Archipelago file manager service.",
"icon": "/assets/img/app-icons/file-browser.webp",
"author": "File Browser",
"category": "data",
"tier": "core",
"dockerImage": "146.59.87.168:3000/lfg2025/filebrowser:v2.27.0",
"repoUrl": "https://github.com/filebrowser/filebrowser",
"containerConfig": {
"ports": [
"8083:80"
],
"volumes": [
"/var/lib/archipelago/filebrowser:/srv",
"/var/lib/archipelago/filebrowser-data:/data"
],
"args": [
"--database=/data/database.db",
"--root=/srv",
"--address=0.0.0.0",
"--port=80"
]
}
},
{
"id": "nostr-rs-relay",
"title": "Nostr Relay (Rust)",
"version": "0.8.0",
"description": "High-performance Nostr relay written in Rust. Host your own decentralized social media relay and earn networking profits.",
"icon": "/assets/img/app-icons/nostrudel.svg",
"author": "Nostr RS Relay",
"category": "community",
"tier": "recommended",
"dockerImage": "scsibug/nostr-rs-relay:0.8.9",
"repoUrl": "https://github.com/scsibug/nostr-rs-relay",
"containerConfig": {
"ports": [
"8081:8080"
],
"volumes": [
"/var/lib/archipelago/nostr-relay:/usr/src/app/db"
],
"env": [
"RELAY_NAME=Archipelago Nostr Relay",
"RELAY_DESCRIPTION=Self-hosted Nostr relay on Archipelago"
]
}
},
{
"id": "vaultwarden",
"title": "Vaultwarden",
"version": "1.30.0",
"description": "Self-hosted password vault with zero-knowledge encryption.",
"icon": "/assets/img/app-icons/vaultwarden.webp",
"author": "Vaultwarden",
"category": "data",
"tier": "recommended",
"dockerImage": "146.59.87.168:3000/lfg2025/vaultwarden:1.30.0-alpine",
"repoUrl": "https://github.com/dani-garcia/vaultwarden",
"containerConfig": {
"ports": [
"8082:80"
],
"volumes": [
"/var/lib/archipelago/vaultwarden:/data"
]
}
},
{
"id": "searxng",
"title": "SearXNG",
"version": "1.0.0",
"description": "Privacy-respecting metasearch engine. Search the web without tracking.",
"icon": "/assets/img/app-icons/searxng.png",
"author": "SearXNG",
"category": "data",
"tier": "recommended",
"dockerImage": "146.59.87.168:3000/lfg2025/searxng:latest",
"repoUrl": "https://github.com/searxng/searxng",
"containerConfig": {
"ports": [
"8888:8080"
],
"volumes": [
"/var/lib/archipelago/searxng:/etc/searxng"
]
}
},
{
"id": "fedimint",
"title": "Fedimint Guardian",
"version": "0.10.0",
"description": "Federated Bitcoin minting service with built-in Guardian UI. Privacy-preserving Bitcoin custody.",
"icon": "/assets/img/app-icons/fedimint.png",
"author": "Fedimint",
"category": "money",
"dockerImage": "146.59.87.168:3000/lfg2025/fedimintd:v0.10.0",
"repoUrl": "https://github.com/fedimint/fedimint"
},
{
"id": "fedimint-clientd",
"title": "Fedimint Client",
"version": "0.8.0",
"description": "Fedimint ecash client daemon (fmcd). Lets the node hold Fedimint ecash and join federations; the wallet talks to it over a local REST API.",
"icon": "/assets/img/app-icons/fedimint.png",
"author": "Fedimint",
"category": "money",
"tier": "core",
"dockerImage": "146.59.87.168:3000/lfg2025/fmcd:0.8.1",
"repoUrl": "https://github.com/minmoto/fmcd"
},
{
"id": "fedimint-gateway",
"title": "Fedimint Gateway",
"version": "0.10.0",
"description": "Fedimint gateway service with automatic LND-or-LDK backend selection.",
"icon": "/assets/img/app-icons/fedimint.png",
"author": "Fedimint",
"category": "money",
"dockerImage": "146.59.87.168:3000/lfg2025/gatewayd:v0.10.0",
"repoUrl": "https://github.com/fedimint/fedimint",
"containerConfig": {
"ports": [
"8176:8176",
"9737:9737"
],
"volumes": [
"/var/lib/archipelago/fedimint-gateway:/data",
"/var/lib/archipelago/lnd:/lnd:ro"
]
}
},
{
"id": "barkd",
"title": "Ark Wallet",
"version": "0.3.0",
"description": "Ark protocol wallet daemon (barkd). Lets the node hold self-custodial off-chain bitcoin via an Ark server; the wallet talks to it over a local REST API. Signet by default while Ark matures.",
"icon": "/assets/img/app-icons/bark.png",
"author": "Second",
"category": "money",
"dockerImage": "146.59.87.168:3000/lfg2025/barkd:0.3.0",
"repoUrl": "https://gitlab.com/ark-bitcoin/bark",
"containerConfig": {
"ports": [
"3535:3535"
],
"volumes": [
"/var/lib/archipelago/barkd:/data"
]
}
},
{
"id": "jellyfin",
"title": "Jellyfin",
"version": "10.8.13",
"description": "Free media server. Stream movies, music, and photos.",
"icon": "/assets/img/app-icons/jellyfin.webp",
"author": "Jellyfin",
"category": "data",
"dockerImage": "146.59.87.168:3000/lfg2025/jellyfin:10.8.13",
"repoUrl": "https://github.com/jellyfin/jellyfin",
"containerConfig": {
"ports": [
"8096:8096"
],
"volumes": [
"/var/lib/archipelago/jellyfin/config:/config",
"/var/lib/archipelago/jellyfin/cache:/cache"
]
}
},
{
"id": "immich",
"title": "Immich",
"version": "2.7.4",
"description": "Self-hosted photo and video backup with mobile apps and search.",
"icon": "/assets/img/app-icons/immich.png",
"author": "Immich",
"category": "data",
"dockerImage": "146.59.87.168:3000/lfg2025/immich-server:release",
"repoUrl": "https://github.com/immich-app/immich"
},
{
"id": "homeassistant",
"title": "Home Assistant",
"version": "2024.1.0",
"description": "Open source home automation platform. Control and monitor your smart home devices.",
"icon": "/assets/img/app-icons/homeassistant.png",
"author": "Home Assistant",
"category": "home",
"dockerImage": "146.59.87.168:3000/lfg2025/home-assistant:2024.1",
"repoUrl": "https://github.com/home-assistant/core",
"containerConfig": {
"ports": [
"8123:8123"
],
"volumes": [
"/var/lib/archipelago/home-assistant:/config"
],
"env": [
"TZ=UTC"
]
}
},
{
"id": "grafana",
"title": "Grafana",
"version": "10.2.0",
"description": "Analytics and monitoring platform. Visualize metrics and create dashboards.",
"icon": "/assets/img/app-icons/grafana.png",
"author": "Grafana Labs",
"category": "data",
"tier": "recommended",
"dockerImage": "grafana/grafana:10.2.0",
"repoUrl": "https://github.com/grafana/grafana",
"containerConfig": {
"ports": [
"3000:3000"
],
"volumes": [
"/var/lib/archipelago/grafana:/var/lib/grafana"
],
"env": [
"GF_PATHS_DATA=/var/lib/grafana",
"GF_USERS_ALLOW_SIGN_UP=false"
]
}
},
{
"id": "tailscale",
"title": "Tailscale",
"version": "1.78.0",
"description": "Zero-config VPN with WireGuard mesh networking.",
"icon": "/assets/img/app-icons/tailscale.webp",
"author": "Tailscale",
"category": "networking",
"tier": "recommended",
"dockerImage": "146.59.87.168:3000/lfg2025/tailscale:stable",
"repoUrl": "https://github.com/tailscale/tailscale",
"containerConfig": {
"ports": [
"8240:8240"
],
"volumes": [
"/var/lib/archipelago/tailscale:/var/lib/tailscale"
],
"env": [
"TS_STATE_DIR=/var/lib/tailscale"
],
"args": [
"sh",
"-c",
"tailscaled --tun=userspace-networking & for i in $(seq 1 30); do [ -S /var/run/tailscale/tailscaled.sock ] && break; sleep 1; done; tailscale web --listen 0.0.0.0:8240 & wait"
]
}
},
{
"id": "portainer",
"title": "Portainer",
"version": "2.19.4",
"description": "Container management web UI for the local Podman socket.",
"icon": "/assets/img/app-icons/portainer.webp",
"author": "Portainer",
"category": "development",
"tier": "optional",
"dockerImage": "146.59.87.168:3000/lfg2025/portainer:2.19.4",
"repoUrl": "https://github.com/portainer/portainer",
"containerConfig": {
"ports": [
"9000:9000"
],
"volumes": [
"/var/lib/archipelago/portainer:/data",
"/run/user/1000/podman/podman.sock:/var/run/docker.sock"
],
"notes": "Uses the manifest-owned Podman socket bind mount preparation path."
}
},
{
"id": "netbird",
"title": "NetBird",
"version": "2.38.0",
"description": "Self-hosted WireGuard mesh VPN control plane with dashboard, embedded identity provider, management API, signal, relay, and STUN. The user-facing entry point — a TLS proxy in front of the dashboard + server.",
"icon": "/assets/img/app-icons/netbird.svg",
"author": "NetBird",
"category": "networking",
"tier": "recommended",
"dockerImage": "docker.io/library/nginx:1.27-alpine",
"repoUrl": "https://github.com/netbirdio/netbird",
"containerConfig": {
"ports": [
"8087:80",
"8086:80",
"3478:3478/udp"
],
"volumes": [
"/var/lib/archipelago/netbird:/var/lib/netbird"
],
"notes": "Installed as a two-container stack: netbird dashboard on 8087 and netbird-server control plane on 8086 plus UDP 3478. For production clients, publish a DNS name over HTTPS with gRPC/WebSocket routing."
}
},
{
"id": "uptime-kuma",
"title": "Uptime Kuma",
"version": "1.23.0",
"description": "Self-hosted uptime monitoring.",
"icon": "/assets/img/app-icons/uptime-kuma.webp",
"author": "Uptime Kuma",
"category": "data",
"tier": "recommended",
"dockerImage": "146.59.87.168:3000/lfg2025/uptime-kuma:1",
"repoUrl": "https://github.com/louislam/uptime-kuma",
"containerConfig": {
"ports": [
"3002:3001"
],
"volumes": [
"/var/lib/archipelago/uptime-kuma:/app/data"
],
"env": [
"TZ=UTC"
],
"args": [
"--",
"node",
"server/server.js"
]
}
},
{
"id": "photoprism",
"title": "PhotoPrism",
"version": "240915",
"description": "AI-powered photo management with facial recognition.",
"icon": "/assets/img/app-icons/photoprism.svg",
"author": "PhotoPrism",
"category": "data",
"dockerImage": "146.59.87.168:3000/lfg2025/photoprism:240915",
"repoUrl": "https://github.com/photoprism/photoprism",
"containerConfig": {
"ports": [
"2342:2342"
],
"volumes": [
"/var/lib/archipelago/photoprism:/photoprism/storage"
],
"env": [
"PHOTOPRISM_ADMIN_PASSWORD=archipelago",
"PHOTOPRISM_DEFAULT_LOCALE=en"
]
}
},
{
"id": "nextcloud",
"title": "Nextcloud",
"version": "29",
"description": "Your own private cloud. File sync, calendars, contacts.",
"icon": "/assets/img/app-icons/nextcloud.webp",
"author": "Nextcloud",
"category": "data",
"dockerImage": "146.59.87.168:3000/lfg2025/nextcloud:29",
"repoUrl": "https://github.com/nextcloud/server",
"containerConfig": {
"ports": [
"8085:80"
],
"volumes": [
"/var/lib/archipelago/nextcloud:/var/www/html"
]
}
}
]
}
+83
View File
@@ -0,0 +1,83 @@
# Archipelago Apps — Development Guide
## App Overview
### Bitcoin & Lightning
| App | Ports | Version |
|-----|-------|---------|
| bitcoin-knots | 8332 (RPC), 8333 (P2P) | v28.1 |
| lnd | 9735 (P2P), 10009 (gRPC), 8080 (REST) | v0.17.4-beta |
| btcpay-server | 23000 (HTTP) | v1.13.5 |
| mempool | 4080 (HTTP) | v2.5.0 |
| electrumx | 50001 (TCP), 50002 (SSL) | latest |
| fedimint | 8173 (API), 8174 (Web) | v0.10.0 |
### Nostr
| App | Ports | Version |
|-----|-------|---------|
| nostr-rs-relay | 8081 (WebSocket) | v0.9.0 |
| nostrudel | 8082 (HTTP) | v0.40.0 |
### Self-Hosted
| App | Port | Version |
|-----|------|---------|
| nextcloud | 8084 | v28 |
| jellyfin | 8096 | v10.8.13 |
| immich | 2283 | release |
| photoprism | 2342 | v240915 |
| vaultwarden | 8222 | v1.30.0-alpine |
| homeassistant | 8123 | v2024.1 |
| filebrowser | 8083 | v2.27.0 |
| searxng | 8888 | 2024.11.17 |
| ollama | 11434 | v0.5.4 |
| grafana | 3001 | v10.2.0 |
| portainer | 9000 | v2.19.4 |
| penpot | 8089 | v2.4 |
## Building Apps
```bash
cd apps
./build.sh # Build all custom apps
./build.sh <app-id> # Build specific app
```
Custom apps with local source: `router`, `did-wallet`. All other apps use official container images.
## App Structure
Each app directory contains:
- `manifest.yml` — Container configuration
- `Dockerfile` — Image definition (custom apps only)
- `README.md` — App-specific docs (custom apps only)
- `src/` — Source code (custom apps only)
## Running in Development
The Archipelago backend manages containers via rootless Podman. Install and start apps through the web UI Marketplace or via RPC:
```bash
curl -X POST http://localhost:5959/rpc/v1 \
-H "Content-Type: application/json" \
-d '{"method": "container-install", "params": {"manifest_path": "apps/router/manifest.yml"}}'
```
### Manual Testing (Podman)
```bash
# Build
./build.sh router
# Run directly with Podman
podman run -p 18084:8080 \
-v /tmp/archipelago-dev/router:/app/data \
localhost/archipelago/router:latest
```
## Integration Checklist
Adding a new app requires updates in multiple places. See the full checklist in [CLAUDE.md](../CLAUDE.md) under "App Integration Checklist".
## Port Assignments
See [PORTS.md](./PORTS.md) for complete mapping. Dev ports are offset by +10000.
+84
View File
@@ -0,0 +1,84 @@
# Port Assignments Reference
This document lists all port assignments for Archipelago apps.
## Production Ports
| App | Port(s) | Protocol | Service | Dev Port(s) |
|-----|---------|----------|---------|-------------|
| bitcoin-core | 8332, 8333 | TCP | RPC, P2P | 18332, 18333 |
| btcpay-server | 80, 443 | TCP | HTTP, HTTPS | 10080, 10443 |
| home-assistant | 8123 | TCP | Web UI | 18123 |
| grafana | 3001 | TCP | Web UI | 13001 |
| endurain | 8085 | TCP | Web UI | 18085 |
| fedimint | 8173, 8174 | TCP | API, Web UI | 18173, 18174 |
| morphos-server | 8086 | TCP | Web UI | 18086 |
| lightning-stack | 9737, 10010, 8087 | TCP | P2P, gRPC, REST | 19737, 20010, 18087 |
| mempool | 4080 | TCP | Web UI | 14080 |
| ollama | 11434 | TCP | API | 21434 |
| searxng | 8888 | TCP | Web UI | 18888 |
| penpot | 8089 | TCP | Web UI | 18089 |
| lnd | 9735, 10009, 18080 | TCP | P2P, gRPC, REST | 19735, 20009, 28080 |
| core-lightning | 9736, 9835 | TCP | P2P, gRPC | 19736, 19835 |
| nostr-rs-relay | 8081 | TCP | HTTP/WebSocket | 18081 |
| strfry | 8082 | TCP | HTTP/WebSocket | 18082 |
| did-wallet | 8083 | TCP | Web UI | 18083 |
| router | 8084, 5353, 1900 | TCP/UDP | Web UI, mDNS, SSDP | 18084, 15353, 11900 |
| meshtastic | 4403, 1883 | TCP | HTTP API, MQTT | 14403, 11883 |
## Development Ports (Offset: +10000)
In development mode, all ports are offset by 10000 to avoid conflicts with production services.
### Quick Access URLs (Development)
| App | Dev URL |
|-----|----------|
| Bitcoin Core RPC | http://localhost:18332 |
| BTCPay Server | http://localhost:10080 |
| Home Assistant | http://localhost:18123 |
| Grafana | http://localhost:13001 |
| Endurain | http://localhost:18085 |
| Fedimint | http://localhost:18174 |
| MorphOS Server | http://localhost:18086 |
| Lightning Stack | http://localhost:18087 |
| Mempool | http://localhost:14080 |
| Ollama | http://localhost:21434 |
| SearXNG | http://localhost:18888 |
| Penpot | http://localhost:18089 |
| LND REST | http://localhost:18080 |
| Core Lightning | http://localhost:19835 |
| Nostr RS Relay | http://localhost:18081 |
| Strfry | http://localhost:18082 |
| DID Wallet | http://localhost:18083 |
| Router | http://localhost:18084 |
| Meshtastic | http://localhost:14403 |
## Port Conflict Resolution
All apps use unique base ports to prevent conflicts. The port offset system ensures:
- No conflicts in production (each app has unique ports)
- No conflicts in development (offset applied automatically)
- Easy port management via PortManager
## Changing Port Offset
The port offset is configurable via environment variable:
```bash
ARCHIPELAGO_PORT_OFFSET=10000
```
Or in the Archipelago config:
```toml
[dev]
port_offset = 10000
```
## Port Ranges
- **Bitcoin/Lightning**: 8000-10000 range
- **Web Services**: 3000-9000 range
- **System Services**: 10000+ range
- **Custom Apps**: 8000-9000 range
+107
View File
@@ -0,0 +1,107 @@
# Quick Start Guide - Archipelago Apps
This guide will help you get all prepackaged apps running in your development environment.
## Prerequisites
1. **Container Runtime**: Podman or Docker
```bash
# Check if available
podman --version # or docker --version
```
2. **Node.js** (for custom apps): v18+
```bash
node --version
```
3. **Archipelago Backend**: Running in dev mode
```bash
cd core
ARCHIPELAGO_DEV_MODE=true cargo run --bin archipelago
```
## Building Apps
### Build All Apps
```bash
cd apps
./build.sh
```
This will build all apps that have Dockerfiles. Standard apps (bitcoin-core, lnd, etc.) will use their official images, while custom apps (router, did-wallet) will be built from source.
### Build Specific App
```bash
./build.sh router
./build.sh did-wallet
```
## Running Apps via Archipelago
Once the backend is running, you can install and start apps via:
1. **UI**: Navigate to http://localhost:8100 and use the Apps/Marketplace interface
2. **RPC**: Use the container-install RPC method
```bash
curl -X POST http://localhost:5959/rpc/v1 \
-H "Content-Type: application/json" \
-d '{
"method": "container-install",
"params": {
"manifest_path": "apps/router/manifest.yml"
}
}'
```
## Port Access
In development mode, apps are accessible on offset ports:
- **Router**: http://localhost:18084
- **DID Wallet**: http://localhost:18083
- **Nostr RS Relay**: http://localhost:18081
- **Strfry**: http://localhost:18082
See [PORTS.md](./PORTS.md) for complete port mapping.
## Development Workflow
### For Custom Apps (router, did-wallet)
1. **Make changes** to source code in `apps/<app-id>/src/`
2. **Rebuild** the container:
```bash
./build.sh <app-id>
```
3. **Restart** the container via Archipelago UI or RPC
### For Standard Apps
Standard apps use official images. To customize:
1. Create a custom Dockerfile that extends the official image
2. Add your customizations
3. Update the manifest to use your custom image
## Testing Locally
You can test apps directly without Archipelago:
```bash
# Build
./build.sh router
# Run
docker run -p 18084:8080 \
-v /tmp/archipelago-dev/router:/app/data \
archipelago/router:latest
```
## Next Steps
- Read [DEVELOPMENT.md](./DEVELOPMENT.md) for detailed development information
- Check [PORTS.md](./PORTS.md) for port assignments
- Review individual app READMEs for app-specific details
+44
View File
@@ -0,0 +1,44 @@
# Archipelago App Manifests
Containerized applications for the Archipelago Bitcoin Node OS. All apps run in rootless Podman with security hardening (cap-drop ALL, readonly root, non-root user, memory limits).
## App Categories
### Bitcoin & Lightning
- **bitcoin-knots** — Full Bitcoin node (v28.1)
- **lnd** — Lightning Network Daemon (v0.17.4-beta)
- **btcpay-server** — Payment processor (v1.13.5)
- **mempool** — Block explorer and fee estimator (v2.5.0)
- **electrumx** — Electrum server
- **fedimint** — Federated Bitcoin minting (v0.10.0)
### Nostr
- **nostr-rs-relay** — High-performance Rust relay (v0.9.0)
- **nostrudel** — Nostr web client (v0.40.0)
### Web5 & Identity
- **did-wallet** — Web5 DID Wallet
### Self-Hosted Services
- **nextcloud** (v28), **jellyfin** (v10.8.13), **immich** (release), **photoprism** (v240915)
- **vaultwarden** (v1.30.0-alpine), **penpot** (v2.4)
- **homeassistant** (v2024.1), **filebrowser** (v2.27.0), **searxng** (2024.11.17)
- **ollama** (v0.5.4), **grafana** (v10.2.0), **portainer** (v2.19.4)
### Networking
- **tailscale** (stable), **nginx-proxy-manager** (v2.12.1)
### Custom & External
- **indeedhub** — Bitcoin documentary streaming (custom build)
- **router** — Mesh routing and network management
- **botfights** — External web app
## Manifest Format
Each app has a `manifest.yml` defining container image, resources, dependencies, security policies, health checks, and network config. See [`docs/app-manifest-spec.md`](../docs/app-manifest-spec.md) for the spec.
## Quick Reference
- [PORTS.md](./PORTS.md) — Complete port mapping
- [QUICKSTART.md](./QUICKSTART.md) — Build and run apps
- [DEVELOPMENT.md](./DEVELOPMENT.md) — Development workflow
+38
View File
@@ -0,0 +1,38 @@
app:
id: aiui
name: AI Assistant
version: 0.1.0
description: Conversational AI interface for Archipelago. Quarantined — communicates only via context broker.
internal: true # System-managed, not shown in App Store
container:
image: localhost/archipelago-aiui:latest
pull_policy: always
resources:
cpu_limit: 1
memory_limit: 512Mi
disk_limit: 1Gi
security:
capabilities: []
readonly_root: true
no_new_privileges: true
user: 1000
seccomp_profile: default
network_policy: isolated # No outbound network — all data comes via context broker
apparmor_profile: aiui
ports:
- host: 5180
container: 80
protocol: tcp
bind: 127.0.0.1 # Only accessible via nginx proxy, not externally
health_check:
type: http
endpoint: http://localhost:80
path: /
interval: 60s
timeout: 5s
retries: 3
+49
View File
@@ -0,0 +1,49 @@
app:
id: archy-btcpay-db
name: BTCPay Postgres
version: "15.17"
description: Postgres backend for BTCPay and NBXplorer.
container:
image: 146.59.87.168:3000/lfg2025/postgres:15.17
pull_policy: if-not-present
network: archy-net
data_uid: "100998:100998"
secret_env:
- key: POSTGRES_PASSWORD
secret_file: btcpay-db-password
dependencies:
- storage: 20Gi
resources:
memory_limit: 1Gi
disk_limit: 20Gi
security:
capabilities: [CHOWN, FOWNER, SETUID, SETGID, DAC_OVERRIDE]
readonly_root: false
network_policy: isolated
ports: []
volumes:
- type: bind
source: /var/lib/archipelago/postgres-btcpay
target: /var/lib/postgresql/data
options: [rw]
environment:
- POSTGRES_DB=btcpay
- POSTGRES_USER=btcpay
health_check:
type: tcp
endpoint: localhost:5432
interval: 30s
timeout: 5s
retries: 3
bitcoin_integration:
rpc_access: none
sync_required: false
+51
View File
@@ -0,0 +1,51 @@
app:
id: archy-mempool-db
name: Mempool MariaDB
version: 11.4.10
description: MariaDB backend for the mempool explorer stack.
container:
image: 146.59.87.168:3000/lfg2025/mariadb:11.4.10
pull_policy: if-not-present
network: archy-net
data_uid: "100998:100998"
secret_env:
- key: MYSQL_PASSWORD
secret_file: mempool-db-password
- key: MYSQL_ROOT_PASSWORD
secret_file: mysql-root-db-password
dependencies:
- storage: 20Gi
resources:
memory_limit: 512Mi
disk_limit: 20Gi
security:
capabilities: [CHOWN, FOWNER, SETUID, SETGID, DAC_OVERRIDE]
readonly_root: false
network_policy: isolated
ports: []
volumes:
- type: bind
source: /var/lib/archipelago/mysql-mempool
target: /var/lib/mysql
options: [rw]
environment:
- MYSQL_DATABASE=mempool
- MYSQL_USER=mempool
health_check:
type: tcp
endpoint: localhost:3306
interval: 30s
timeout: 5s
retries: 3
bitcoin_integration:
rpc_access: none
sync_required: false
+47
View File
@@ -0,0 +1,47 @@
app:
id: archy-mempool-web
name: Mempool Web
version: 3.0.1
description: Frontend web UI for mempool explorer.
container_name: mempool
container:
image: 146.59.87.168:3000/lfg2025/mempool-frontend:v3.0.1
pull_policy: if-not-present
network: archy-net
dependencies:
- app_id: mempool-api
version: ">=3.0.0"
resources:
memory_limit: 512Mi
security:
capabilities: []
readonly_root: false
network_policy: isolated
ports:
- host: 4080
container: 8080
protocol: tcp
environment:
- FRONTEND_HTTP_PORT=8080
- BACKEND_MAINNET_HTTP_HOST=mempool-api
health_check:
type: http
# 127.0.0.1 not localhost: the image's wget resolves localhost to ::1 (IPv6)
# first, but nginx binds 0.0.0.0:8080 (IPv4) only -> localhost probe gets
# "connection refused" -> perpetual unhealthy -> health_monitor restart loop.
endpoint: http://127.0.0.1:8080
path: /
interval: 30s
timeout: 5s
retries: 3
bitcoin_integration:
rpc_access: none
sync_required: false
+64
View File
@@ -0,0 +1,64 @@
app:
id: archy-nbxplorer
name: NBXplorer
version: 2.6.0
description: BTCPay blockchain indexer service.
container:
image: 146.59.87.168:3000/lfg2025/nbxplorer:2.6.0
pull_policy: if-not-present
network: archy-net
secret_env:
- key: NBXPLORER_BTCRPCPASSWORD
secret_file: bitcoin-rpc-password
- key: BTCPAY_DB_PASS
secret_file: btcpay-db-password
dependencies:
- app_id: bitcoin-core
version: ">=26.0"
- app_id: archy-btcpay-db
version: ">=15.17"
resources:
memory_limit: 2Gi
disk_limit: 20Gi
security:
capabilities: []
readonly_root: false
network_policy: isolated
ports:
- host: 32838
container: 32838
protocol: tcp
volumes:
- type: bind
source: /var/lib/archipelago/nbxplorer
target: /data
options: [rw]
environment:
- NBXPLORER_DATADIR=/data
- NBXPLORER_NETWORK=mainnet
- NBXPLORER_CHAINS=btc
- NBXPLORER_BIND=0.0.0.0:32838
- NBXPLORER_BTCRPCURL=http://bitcoin-knots:8332
- NBXPLORER_BTCRPCUSER=archipelago
- NBXPLORER_BTCNODEENDPOINT=bitcoin-knots:8333
- NBXPLORER_NOAUTH=1
- NBXPLORER_POSTGRES=Username=btcpay;Password=${BTCPAY_DB_PASS};Host=archy-btcpay-db;Port=5432;Database=nbxplorer
health_check:
type: http
endpoint: http://localhost:32838
path: /
interval: 30s
timeout: 30s
retries: 5
bitcoin_integration:
rpc_access: read-only
sync_required: true
+32
View File
@@ -0,0 +1,32 @@
# barkd — Ark protocol wallet daemon (https://gitlab.com/ark-bitcoin/bark).
# No official upstream image exists (their GitLab registry is empty), so we
# package the pinned, checksum-verified release binary ourselves and push to
# the node registry — same approach as fmcd. Keep the version in lockstep with
# the REST shapes coded in core/archipelago/src/wallet/ark_client.rs (0.3.0).
FROM debian:bookworm-slim
ARG BARKD_VERSION=0.3.0
ARG BARKD_SHA256=8562fa27386bae666ed62fa95c92d40f7bdb20d22525f75799adfc16adaaedb3
RUN apt-get update && \
apt-get install -y --no-install-recommends ca-certificates curl && \
curl -fsSL "https://gitlab.com/api/v4/projects/ark-bitcoin%2Fbark/packages/generic/release-assets/bark-${BARKD_VERSION}/barkd-${BARKD_VERSION}-linux-x86_64" \
-o /usr/local/bin/barkd && \
echo "${BARKD_SHA256} /usr/local/bin/barkd" | sha256sum -c - && \
chmod a+x /usr/local/bin/barkd && \
apt-get purge -y curl && apt-get autoremove -y && \
apt-get clean && rm -rf /var/lib/apt/lists/*
COPY entrypoint.sh /entrypoint.sh
RUN chmod a+x /entrypoint.sh
# The wallet itself is created over REST by the node's Ark bridge
# (wallet.ark-* RPCs) — the container just runs the daemon.
ENV BARKD_DATADIR=/data \
BARKD_BIND_HOST=0.0.0.0 \
BARKD_BIND_PORT=3535
EXPOSE 3535
VOLUME /data
ENTRYPOINT ["/entrypoint.sh"]
+15
View File
@@ -0,0 +1,15 @@
#!/bin/sh
# Install the node-provided auth secret (64-char hex from the manifest's
# generated barkd-secret) so the wallet bridge can derive the matching Bearer
# token, then start the daemon. Without BARKD_SECRET, barkd generates its own
# random token in the datadir and the bridge won't authenticate — so treat a
# failed refresh as fatal rather than starting an unreachable daemon.
set -eu
if [ -n "${BARKD_SECRET:-}" ]; then
# `secret refresh` prints the Bearer token on stdout — never log it.
barkd secret refresh --secret "$BARKD_SECRET" >/dev/null
unset BARKD_SECRET
fi
exec barkd
+76
View File
@@ -0,0 +1,76 @@
app:
id: barkd
name: Ark Wallet
version: 0.3.0
description: Ark protocol wallet daemon (barkd). Lets the node hold self-custodial off-chain bitcoin via an Ark server; the wallet talks to it over a local REST API. Signet by default while Ark matures.
container:
# barkd packaged from the pinned upstream release binary (no usable
# upstream image exists — their registry is empty). Built from
# apps/barkd/Dockerfile and pushed to the node registry. Pin the tag to
# match the REST shapes coded in core/archipelago/src/wallet/ark_client.rs
# (validated against barkd 0.3.0 on signet, 2026-07-14).
image: 146.59.87.168:3000/lfg2025/barkd:0.3.0
pull_policy: if-not-present
network: archy-net
# The entrypoint installs the shared secret below via `barkd secret
# refresh` (so the wallet bridge can derive the matching Bearer token) and
# execs the daemon. The Ark wallet itself is created over REST by the
# bridge on first use (wallet.ark-* RPCs) with the node's ark_config
# (default: Second's public signet server) — no host provisioning needed.
generated_secrets:
- name: barkd-secret
kind: hex32
secret_env:
- key: BARKD_SECRET
secret_file: barkd-secret
data_uid: "1000:1000"
dependencies:
- storage: 1Gi
resources:
# barkd is a single wallet daemon (SQLite + a gRPC conn to the Ark server
# + esplora polling); steady state is tiny. Cap it so a stuck sync can't
# starve the node.
cpu_limit: 1
memory_limit: 512Mi
disk_limit: 1Gi
security:
readonly_root: true
# Needs outbound HTTPS to the Ark server (ark.signet.2nd.dev) and the
# esplora chain source, plus the published REST port for the wallet
# bridge. No inbound requirements beyond that.
network_policy: bridge
ports:
# barkd REST bound to 3535 in-container (BARKD_BIND_PORT); 3535 is free on
# the host (see port_allocator.rs). The Rust bridge targets
# http://127.0.0.1:3535.
- host: 3535
container: 3535
protocol: tcp
volumes:
# Holds the wallet DB, mnemonic and auth token. ARK funds are recoverable
# on-chain from this datadir (unilateral exit) — include it in backups.
- type: bind
source: /var/lib/archipelago/barkd
target: /data
options: [rw]
environment:
- BARKD_DATADIR=/data
- BARKD_BIND_HOST=0.0.0.0
- BARKD_BIND_PORT=3535
# All /api/v1/* routes require the Bearer token, so an HTTP probe would 401
# forever — use a TCP probe like fmcd (the host-side lifecycle layer
# verifies reachability).
health_check:
type: tcp
endpoint: localhost:3535
interval: 30s
timeout: 5s
retries: 3
+34
View File
@@ -0,0 +1,34 @@
# Bitcoin Core — minimal rootless image built from the OFFICIAL upstream release.
#
# The CANONICAL, verified build path is scripts/build-bitcoin-image.sh, which
# downloads the upstream tarball, verifies SHA-256 + the OpenPGP signature
# (fail-closed), and tags/pushes <registry>/bitcoin:<version>. This Dockerfile
# mirrors that image for a manual/local build and replaces the old stale
# community base (`FROM bitcoin/bitcoin:24.0`).
#
# Build (binaries must be pre-fetched + verified into ./bin — see the script):
# scripts/build-bitcoin-image.sh core 31.0
FROM debian:bookworm-slim
ARG BITCOIN_VERSION=31.0
RUN set -eux; \
apt-get update; \
apt-get install -y --no-install-recommends ca-certificates; \
rm -rf /var/lib/apt/lists/*; \
useradd -m -u 1000 -s /bin/bash bitcoin; \
mkdir -p /home/bitcoin/.bitcoin; \
chown -R bitcoin:bitcoin /home/bitcoin
# bin/ holds the SHA-256 + GPG-verified bitcoind / bitcoin-cli (Guix-built,
# x86_64-linux-gnu) extracted from the official release tarball.
COPY bin/bitcoind /usr/local/bin/bitcoind
COPY bin/bitcoin-cli /usr/local/bin/bitcoin-cli
RUN chmod 0755 /usr/local/bin/bitcoind /usr/local/bin/bitcoin-cli
# Run as (container) root, like the legacy hand-built :latest image. Rootless
# Podman maps container-root to the unprivileged host service user; the manifest
# grants CAP_DAC_OVERRIDE so bitcoind can read its data dir, which the
# orchestrator chowns to the data_uid (host 100101 / container uid 102), not to
# this image's `bitcoin` user. A non-root USER can't read existing chain data and
# bitcoind crash-loops with "Error initializing block database".
WORKDIR /home/bitcoin
VOLUME ["/home/bitcoin/.bitcoin"]
EXPOSE 8332 8333
ENTRYPOINT ["bitcoind"]
+109
View File
@@ -0,0 +1,109 @@
app:
id: bitcoin-core
name: Bitcoin Core
version: 28.4.0
description: Reference Bitcoin Core node with dynamic prune/full-mode startup based on host disk.
container_name: bitcoin-core
container:
image: 146.59.87.168:3000/lfg2025/bitcoin:28.4
pull_policy: if-not-present
network: archy-net
entrypoint: ["sh", "-lc"]
custom_args:
# Sync-speed flags: -par=0 uses every core (was capped at 2 by
# --cpus=2, now removed for bitcoin/electrumx). -dbcache sized to
# the IBD sweet spot - 4GB on full nodes, 1GB on pruned. Container
# --memory=8g (config.rs::get_memory_limit) leaves headroom for
# mempool + connections.
#
# -printtoconsole=0: foreground bitcoind defaults console logging ON,
# which pushed every IBD "UpdateTip" line through conmon into journald
# (>1 GB/day on a fresh node). bitcoind still writes debug.log in the
# datadir (/var/lib/archipelago/bitcoin/debug.log, self-shrunk on
# restart) — use that for deep debugging; podman logs only carries
# entrypoint/startup errors.
- >-
BITCOIND="$(command -v bitcoind || true)";
if [ -z "$BITCOIND" ]; then
BITCOIND="$(find /opt -path '*/bin/bitcoind' -type f 2>/dev/null | sort | tail -n 1)";
fi;
if [ -z "$BITCOIND" ]; then
echo "bitcoind not found in image" >&2;
exit 127;
fi;
RPC_USER="$(printenv BITCOIN_RPC_USER)";
RPC_PASS="$(printenv BITCOIN_RPC_PASS)";
RPC_TXRELAY_AUTH="$(printenv BITCOIN_RPC_TXRELAY_RPCAUTH || true)";
DISK_GB_VALUE="$(printenv DISK_GB || true)";
RPC_HEADROOM="-rpcthreads=16 -rpcworkqueue=256";
RPC_TXRELAY_FLAGS="-rpcwhitelistdefault=0";
if [ -n "$RPC_TXRELAY_AUTH" ]; then
RPC_TXRELAY_FLAGS="$RPC_TXRELAY_FLAGS -rpcauth=$RPC_TXRELAY_AUTH -rpcwhitelist=txrelay:sendrawtransaction,submitpackage,testmempoolaccept,getmempoolinfo,getrawmempool,getmempoolentry,getnetworkinfo,getblockchaininfo,getblockcount,getblockhash,getblock,getblockheader,getrawtransaction,gettxout,gettxspendingprevout,decoderawtransaction,decodescript,estimatesmartfee,uptime,ping,getconnectioncount,getpeerinfo,getindexinfo,getdeploymentinfo,getchaintips";
fi;
if [ "${DISK_GB_VALUE:-0}" -lt 1000 ]; then
exec "$BITCOIND" -datadir=/home/bitcoin/.bitcoin -noconf -printtoconsole=0 -server=1 -prune=550 -rpcallowip=0.0.0.0/0 -rpcbind=0.0.0.0:8332 -listen=1 -bind=0.0.0.0:8333 -dbcache=1024 -par=0 -maxconnections=125 $RPC_HEADROOM $RPC_TXRELAY_FLAGS -rpcuser="$RPC_USER" -rpcpassword="$RPC_PASS";
else
exec "$BITCOIND" -datadir=/home/bitcoin/.bitcoin -noconf -printtoconsole=0 -server=1 -txindex=1 -rpcallowip=0.0.0.0/0 -rpcbind=0.0.0.0:8332 -listen=1 -bind=0.0.0.0:8333 -dbcache=4096 -par=0 -maxconnections=125 $RPC_HEADROOM $RPC_TXRELAY_FLAGS -rpcuser="$RPC_USER" -rpcpassword="$RPC_PASS";
fi
derived_env:
- key: DISK_GB
template: "{{DISK_GB}}"
secret_env:
- key: BITCOIN_RPC_PASS
secret_file: bitcoin-rpc-password
- key: BITCOIN_RPC_TXRELAY_RPCAUTH
secret_file: bitcoin-rpc-txrelay-rpcauth
data_uid: "100101:100101"
dependencies:
- storage: 500Gi
resources:
cpu_limit: 0
memory_limit: 4Gi
disk_limit: 500Gi
security:
capabilities: [CHOWN, FOWNER, SETUID, SETGID, DAC_OVERRIDE]
readonly_root: false
network_policy: isolated
ports:
# RPC is auth-only: publish host-local ONLY - the LAN cannot reach
# nodeIP:8332. In-node consumers (lnd, fedimint, btcpay, mempool-api)
# dial the container's archy-net alias directly (bitcoin-core:8332),
# which needs no publish at all. Do NOT bind the archy-net gateway
# (10.89.0.1): rootlessport binds in the HOST netns where that address
# does not exist, and the whole unit crash-loops (2026-07-09, .228).
# P2P 8333 stays public.
- host: 8332
container: 8332
protocol: tcp
bind: 127.0.0.1
- host: 8333
container: 8333
protocol: tcp
volumes:
- type: bind
source: /var/lib/archipelago/bitcoin
target: /home/bitcoin/.bitcoin
options: [rw]
environment:
- BITCOIN_RPC_USER=archipelago
health_check:
type: tcp
endpoint: localhost:8332
interval: 30s
timeout: 5s
retries: 3
bitcoin_integration:
rpc_access: admin
sync_required: true
testnet_support: false
pruning_support: true
+35
View File
@@ -0,0 +1,35 @@
# Bitcoin Knots — minimal rootless image built from the OFFICIAL upstream release.
#
# Knots previously had NO Dockerfile (the :latest tag was built/pushed by hand).
# The CANONICAL, verified build path is scripts/build-bitcoin-image.sh, which
# downloads the upstream tarball, verifies SHA-256 + the OpenPGP signature
# (fail-closed, Luke-Jr release key), and tags/pushes
# <registry>/bitcoin-knots:<version>. Knots version strings embed a build date,
# e.g. 29.3.knots20260508 — the full string is the tag.
#
# Build (binaries must be pre-fetched + verified into ./bin — see the script):
# scripts/build-bitcoin-image.sh knots 29.3.knots20260508
FROM debian:bookworm-slim
ARG KNOTS_VERSION=29.3.knots20260508
RUN set -eux; \
apt-get update; \
apt-get install -y --no-install-recommends ca-certificates; \
rm -rf /var/lib/apt/lists/*; \
useradd -m -u 1000 -s /bin/bash bitcoin; \
mkdir -p /home/bitcoin/.bitcoin; \
chown -R bitcoin:bitcoin /home/bitcoin
# bin/ holds the SHA-256 + GPG-verified bitcoind / bitcoin-cli (Knots, Guix-built,
# x86_64-linux-gnu) extracted from the official release tarball.
COPY bin/bitcoind /usr/local/bin/bitcoind
COPY bin/bitcoin-cli /usr/local/bin/bitcoin-cli
RUN chmod 0755 /usr/local/bin/bitcoind /usr/local/bin/bitcoin-cli
# Run as (container) root, like the legacy hand-built :latest image. Rootless
# Podman maps container-root to the unprivileged host service user; the manifest
# grants CAP_DAC_OVERRIDE so bitcoind can read its data dir, which the
# orchestrator chowns to the data_uid (host 100101 / container uid 102), not to
# this image's `bitcoin` user. A non-root USER can't read existing chain data and
# bitcoind crash-loops with "Error initializing block database".
WORKDIR /home/bitcoin
VOLUME ["/home/bitcoin/.bitcoin"]
EXPOSE 8332 8333
ENTRYPOINT ["bitcoind"]
+109
View File
@@ -0,0 +1,109 @@
app:
id: bitcoin-knots
name: Bitcoin Knots
version: 28.1.0
description: Full Bitcoin Knots node with dynamic prune/full-mode startup based on host disk.
container_name: bitcoin-knots
container:
image: 146.59.87.168:3000/lfg2025/bitcoin-knots:latest
pull_policy: if-not-present
network: archy-net
entrypoint: ["sh", "-lc"]
custom_args:
# Sync-speed flags: -par=0 uses every core (was capped at 2 by
# --cpus=2, now removed for bitcoin/electrumx). -dbcache sized to
# the IBD sweet spot - 4GB on full nodes, 1GB on pruned. Container
# --memory=8g (config.rs::get_memory_limit) leaves headroom for
# mempool + connections.
#
# -printtoconsole=0: foreground bitcoind defaults console logging ON,
# which pushed every IBD "UpdateTip" line through conmon into journald
# (>1 GB/day on a fresh node). bitcoind still writes debug.log in the
# datadir (/var/lib/archipelago/bitcoin/debug.log, self-shrunk on
# restart) — use that for deep debugging; podman logs only carries
# entrypoint/startup errors.
- >-
BITCOIND="$(command -v bitcoind || true)";
if [ -z "$BITCOIND" ]; then
BITCOIND="$(find /opt -path '*/bin/bitcoind' -type f 2>/dev/null | sort | tail -n 1)";
fi;
if [ -z "$BITCOIND" ]; then
echo "bitcoind not found in image" >&2;
exit 127;
fi;
RPC_USER="$(printenv BITCOIN_RPC_USER)";
RPC_PASS="$(printenv BITCOIN_RPC_PASS)";
RPC_TXRELAY_AUTH="$(printenv BITCOIN_RPC_TXRELAY_RPCAUTH || true)";
DISK_GB_VALUE="$(printenv DISK_GB || true)";
RPC_HEADROOM="-rpcthreads=16 -rpcworkqueue=256";
RPC_TXRELAY_FLAGS="-rpcwhitelistdefault=0";
if [ -n "$RPC_TXRELAY_AUTH" ]; then
RPC_TXRELAY_FLAGS="$RPC_TXRELAY_FLAGS -rpcauth=$RPC_TXRELAY_AUTH -rpcwhitelist=txrelay:sendrawtransaction,submitpackage,testmempoolaccept,getmempoolinfo,getrawmempool,getmempoolentry,getnetworkinfo,getblockchaininfo,getblockcount,getblockhash,getblock,getblockheader,getrawtransaction,gettxout,gettxspendingprevout,decoderawtransaction,decodescript,estimatesmartfee,uptime,ping,getconnectioncount,getpeerinfo,getindexinfo,getdeploymentinfo,getchaintips";
fi;
if [ "${DISK_GB_VALUE:-0}" -lt 1000 ]; then
exec "$BITCOIND" -datadir=/home/bitcoin/.bitcoin -noconf -printtoconsole=0 -server=1 -prune=550 -rpcallowip=0.0.0.0/0 -rpcbind=0.0.0.0:8332 -listen=1 -bind=0.0.0.0:8333 -dbcache=2048 -par=0 -maxconnections=125 $RPC_HEADROOM $RPC_TXRELAY_FLAGS -rpcuser="$RPC_USER" -rpcpassword="$RPC_PASS";
else
exec "$BITCOIND" -datadir=/home/bitcoin/.bitcoin -noconf -printtoconsole=0 -server=1 -txindex=1 -rpcallowip=0.0.0.0/0 -rpcbind=0.0.0.0:8332 -listen=1 -bind=0.0.0.0:8333 -dbcache=4096 -par=0 -maxconnections=125 $RPC_HEADROOM $RPC_TXRELAY_FLAGS -rpcuser="$RPC_USER" -rpcpassword="$RPC_PASS";
fi
derived_env:
- key: DISK_GB
template: "{{DISK_GB}}"
secret_env:
- key: BITCOIN_RPC_PASS
secret_file: bitcoin-rpc-password
- key: BITCOIN_RPC_TXRELAY_RPCAUTH
secret_file: bitcoin-rpc-txrelay-rpcauth
data_uid: "100101:100101"
dependencies:
- storage: 500Gi
resources:
cpu_limit: 0
memory_limit: 8Gi
disk_limit: 500Gi
security:
capabilities: [CHOWN, FOWNER, SETUID, SETGID, DAC_OVERRIDE]
readonly_root: false
network_policy: isolated
ports:
# RPC is auth-only: publish host-local ONLY - the LAN cannot reach
# nodeIP:8332. In-node consumers (lnd, fedimint, btcpay, mempool-api)
# dial the container's archy-net alias directly (bitcoin-knots:8332),
# which needs no publish at all. Do NOT bind the archy-net gateway
# (10.89.0.1): rootlessport binds in the HOST netns where that address
# does not exist, and the whole unit crash-loops (2026-07-09, .228).
# P2P 8333 stays public.
- host: 8332
container: 8332
protocol: tcp
bind: 127.0.0.1
- host: 8333
container: 8333
protocol: tcp
volumes:
- type: bind
source: /var/lib/archipelago/bitcoin
target: /home/bitcoin/.bitcoin
options: [rw]
environment:
- BITCOIN_RPC_USER=archipelago
health_check:
type: tcp
endpoint: localhost:8332
interval: 30s
timeout: 5s
retries: 3
bitcoin_integration:
rpc_access: admin
sync_required: true
testnet_support: false
pruning_support: true
+56
View File
@@ -0,0 +1,56 @@
app:
id: bitcoin-ui
name: Bitcoin UI
version: 1.0.0
description: |
Archipelago-native HTTP proxy + static site for interacting with the
Bitcoin Core / Bitcoin Knots JSON-RPC. Runs nginx inside a container
and reverse-proxies /bitcoin-rpc/ to 127.0.0.1:8332 on the host. The
upstream Authorization header is substituted from
/var/lib/archipelago/secrets/bitcoin-rpc-password by the prod
orchestrator's pre-start hook, rendered into an nginx.conf that is
bind-mounted read-only at container start.
container:
build:
context: /opt/archipelago/docker/bitcoin-ui
dockerfile: Dockerfile
tag: localhost/bitcoin-ui:local
dependencies:
- app_id: bitcoin-core
resources:
memory_limit: 128Mi
security:
readonly_root: false
network_policy: host
# Host networking: nginx listens on 8334 directly on the host IP, and
# proxies to 127.0.0.1:8332 which is where the bitcoin backend binds
# its RPC. `ports:` is intentionally empty because host networking
# bypasses port mapping.
ports: []
volumes:
# Bind-mount the rendered nginx.conf read-only. The prod orchestrator
# renders /var/lib/archipelago/bitcoin-ui/nginx.conf on every install
# and every reconcile pass, substituting the base64 RPC auth from
# the plaintext password secret. If the rendered bytes change (the
# password rotated, or the template was updated by OTA), the
# reconciler restarts this container so nginx re-reads the config.
- type: bind
source: /var/lib/archipelago/bitcoin-ui/nginx.conf
target: /etc/nginx/conf.d/default.conf
options: [ro]
environment: []
health_check:
type: http
endpoint: http://127.0.0.1:8334
path: /
interval: 30s
timeout: 5s
retries: 3
+75
View File
@@ -0,0 +1,75 @@
app:
id: botfights
name: BotFights
version: 1.1.0
description: Bot competition arena with 2-player arcade fighting mode. AI bots battle in trivia challenges while humans duke it out with controllers. Built for Bitcoiners.
category: community
container:
image: 146.59.87.168:3000/lfg2025/botfights:1.1.0
pull_policy: always
dependencies:
- storage: 500Mi
resources:
cpu_limit: 2
memory_limit: 512Mi
disk_limit: 500Mi
security:
capabilities: []
readonly_root: true
no_new_privileges: true
user: 1001
seccomp_profile: default
network_policy: bridge
apparmor_profile: default
ports:
- host: 9100
container: 9100
protocol: tcp # Web UI + API
volumes:
- type: bind
source: botfights-data
target: /app/server/data
- type: tmpfs
target: /tmp
options: [rw,noexec,nosuid,size=64m]
environment:
- NODE_ENV=production
health_check:
type: http
endpoint: http://localhost:9100
path: /api/health
interval: 30s
timeout: 10s
retries: 3
start_period: 30s
interfaces:
main:
name: Web UI
description: Bot arena and arcade fighter with controller support
type: ui
port: 9100
protocol: http
path: /
metadata:
author: Dorian
repo: https://botfights.net
icon: /assets/img/app-icons/botfights.svg
license: MIT
tags:
- bitcoin
- gaming
- arcade
- fighter
- bots
- competition
- controller
+5
View File
@@ -0,0 +1,5 @@
# BTCPay Server - uses official image
FROM btcpayserver/btcpayserver:1.12.0
# Default configuration is in the image
# No additional setup needed
+95
View File
@@ -0,0 +1,95 @@
app:
id: btcpay-server
name: BTCPay Server
version: 2.3.9
description: Self-hosted Bitcoin payment processor. Accept Bitcoin payments without intermediaries.
container:
image: docker.io/btcpayserver/btcpayserver:2.3.9
pull_policy: if-not-present
network: archy-net
secret_env:
- key: BTCPAY_BTCRPCPASSWORD
secret_file: bitcoin-rpc-password
- key: BTCPAY_DB_PASS
secret_file: btcpay-db-password
# Internal LND node. Generated by the daemon (lnd macaroon as hex +
# tls.cert thumbprint) — see container::lnd::ensure_btcpay_lnd_connection_secret.
# Optional: nodes without LND run btcpay without an internal node.
- key: BTCPAY_BTCLIGHTNING
secret_file: btcpay-lnd-connection
optional: true
derived_env:
- key: BTCPAY_HOST
template: "{{HOST_IP}}:23000"
dependencies:
- app_id: bitcoin-core
version: ">=26.0"
- app_id: archy-btcpay-db
version: ">=15.17"
- app_id: archy-nbxplorer
version: ">=2.6.0"
resources:
cpu_limit: 2
memory_limit: 2Gi
disk_limit: 20Gi
security:
capabilities: []
readonly_root: false
network_policy: isolated
ports:
- host: 23000
container: 49392
protocol: tcp
volumes:
- type: bind
source: /var/lib/archipelago/btcpay
target: /datadir
options: [rw]
environment:
- ASPNETCORE_URLS=http://0.0.0.0:49392
- BTCPAY_PROTOCOL=http
- BTCPAY_CHAINS=btc
# Plugins must live on the persistent volume: the image default
# (/root/.btcpayserver/Plugins) is container-local, so every recreate
# silently wiped installed plugins.
- BTCPAY_PLUGINDIR=/datadir/Plugins
- BTCPAY_BTCEXPLORERURL=http://archy-nbxplorer:32838
- BTCPAY_BTCRPCURL=http://bitcoin-knots:8332
- BTCPAY_BTCRPCUSER=archipelago
- BTCPAY_POSTGRES=Username=btcpay;Password=${BTCPAY_DB_PASS};Host=archy-btcpay-db;Port=5432;Database=btcpay
health_check:
type: http
endpoint: http://localhost:49392
path: /
interval: 30s
timeout: 30s
retries: 5
bitcoin_integration:
rpc_access: read-only
sync_required: true
lightning_integration:
payment_processing: false
invoice_management: true
interfaces:
main:
name: Web UI
description: BTCPay Server dashboard
type: ui
port: 23000
protocol: http
path: /
metadata:
launch:
open_in_new_tab: true

Some files were not shown because too many files have changed in this diff Show More