Compare commits
217
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
fb1f4bf0e3 | ||
|
|
79c3cc5947 | ||
|
|
c4f24f3efa | ||
|
|
0acfba40db | ||
|
|
3f76b4960a | ||
|
|
a8c4694c36 | ||
|
|
537c52d11c | ||
|
|
2971623145 | ||
|
|
43130f33f0 | ||
|
|
73228114b9 | ||
|
|
8907cc47d9 | ||
|
|
8fd72b947a | ||
|
|
ea254f63af | ||
|
|
1a306c7450 | ||
|
|
a969f892ea | ||
|
|
43e50e669e | ||
|
|
529c7fe25d | ||
|
|
d605d0d544 | ||
|
|
a3f07d5ac6 | ||
|
|
c83bade022 | ||
|
|
67454974b2 | ||
|
|
e24e0a6473 | ||
|
|
eb2fc0f37b | ||
|
|
94b5374f66 | ||
|
|
9f65f1e7ae | ||
|
|
c598bb8796 | ||
|
|
70996203f9 | ||
|
|
709922c293 | ||
|
|
0aee010f9c | ||
|
|
c5eeb4392f | ||
|
|
5457b98c66 | ||
|
|
1593c55894 | ||
|
|
1cc18e5b72 | ||
|
|
24582128c5 | ||
|
|
0b038434b1 | ||
|
|
d576f77435 | ||
|
|
7e8d3314d0 | ||
|
|
07772b563f | ||
|
|
d500214766 | ||
|
|
9cd507269c | ||
|
|
aa272bfcf4 | ||
|
|
3b6a32b2f8 | ||
|
|
c66ef048f4 | ||
|
|
32962db6a9 | ||
|
|
4edc50ae47 | ||
|
|
245fb1a815 | ||
|
|
6dffd8e2e8 | ||
|
|
365f3d7d18 | ||
|
|
70bcbc4acc | ||
|
|
56e4c30261 | ||
|
|
f7208e1769 | ||
|
|
77ee39a3df | ||
|
|
02917cc22c | ||
|
|
325b9ea9c9 | ||
|
|
9739bbb3db | ||
|
|
4db1b85fc5 | ||
|
|
a78aa02890 | ||
|
|
0365cc0f9d | ||
|
|
0880824fb3 | ||
|
|
c0d34bd836 | ||
|
|
c8d0dda656 | ||
|
|
57c6a4d512 | ||
|
|
a32e40da31 | ||
|
|
449ed7c1f7 | ||
|
|
729cee573a | ||
|
|
5b7ebd85b6 | ||
|
|
c6f11f8ddb | ||
|
|
a26090e561 | ||
|
|
2609f60e6c | ||
|
|
c4c558954b | ||
|
|
d4ea4ed636 | ||
|
|
0fadbb1d0f | ||
|
|
0e5cd24e18 | ||
|
|
1d8e57d564 | ||
|
|
00b7e1798f | ||
|
|
bca1698682 | ||
|
|
0641ee85ef | ||
|
|
901cf5713a | ||
|
|
875da6c630 | ||
|
|
4589e88442 | ||
|
|
5862689949 | ||
|
|
3995ab5cf5 | ||
|
|
9d83ee3770 | ||
|
|
7e3d01f633 | ||
|
|
385c950f00 | ||
|
|
98e15b3af0 | ||
|
|
df88d6d00a | ||
|
|
0dfc3a7cfb | ||
|
|
b163d30a0e | ||
|
|
85b25dd803 | ||
|
|
e3db5558e4 | ||
|
|
91c51c4d97 | ||
|
|
4343949005 | ||
|
|
5771f318bd | ||
|
|
408c605001 | ||
|
|
0cd2164d24 | ||
|
|
5227406341 | ||
|
|
d924c59c6c | ||
|
|
0fa2a866f5 | ||
|
|
9fcb68816b | ||
|
|
9a66f22138 | ||
|
|
504944fd08 | ||
|
|
8af2ca4ac2 | ||
|
|
f2b6028ca2 | ||
|
|
e679b4e886 | ||
|
|
d045f0b499 | ||
|
|
1e20b79c3c | ||
|
|
1bcf6ccadb | ||
|
|
9f6294d169 | ||
|
|
a0f688b522 | ||
|
|
f0926ece94 | ||
|
|
dcb0618012 | ||
|
|
8403f2233e | ||
|
|
b28e5f2ef6 | ||
|
|
3037e9808e | ||
|
|
881b9ee0bf | ||
|
|
5ba3c161c4 | ||
|
|
2ad57c63f1 | ||
|
|
6c48de20e3 | ||
|
|
e17de63016 | ||
|
|
6ba4540c53 | ||
|
|
5454bed038 | ||
|
|
128b78d083 | ||
|
|
75ecd1d3ea | ||
|
|
82fcccd113 | ||
|
|
1e89362e71 | ||
|
|
908e6185c8 | ||
|
|
0064cfccac | ||
|
|
53037b2b71 | ||
|
|
078f3b3619 | ||
|
|
4222a8507c | ||
|
|
db2cafc657 | ||
|
|
e49af6ff40 | ||
|
|
a865306488 | ||
|
|
979113c598 | ||
|
|
420f756947 | ||
|
|
35849c88c6 | ||
|
|
08927b88a8 | ||
|
|
0b7a1b84e4 | ||
|
|
bb4166954a | ||
|
|
ed2c14c88a | ||
|
|
42e98d6a86 | ||
|
|
3e5feebd49 | ||
|
|
4199c43cc9 | ||
|
|
54ddd043bd | ||
|
|
402183c0ae | ||
|
|
80875a2ed4 | ||
|
|
f72d4b92ac | ||
|
|
5d2c28da7b | ||
|
|
fbed32f95d | ||
|
|
d5fc3d01a4 | ||
|
|
87c324e36c | ||
|
|
15954aec14 | ||
|
|
1df075f7b1 | ||
|
|
ff4013bd3b | ||
|
|
37c8992d21 | ||
|
|
1ecb5c9aa3 | ||
|
|
5bae8273a2 | ||
|
|
265196bc2c | ||
|
|
9f1daa0f8c | ||
|
|
960e41e164 | ||
|
|
6502f13e29 | ||
|
|
c58f84c6e9 | ||
|
|
e59ea1da69 | ||
|
|
be6f06a573 | ||
|
|
8a450bb8f8 | ||
|
|
852ffc5b18 | ||
|
|
5799c37111 | ||
|
|
b14af20d1a | ||
|
|
7547d03166 | ||
|
|
d6019e47a5 | ||
|
|
0ca8f25b1b | ||
|
|
76d14c3bf9 | ||
|
|
db6003aef6 | ||
|
|
b991c18e73 | ||
|
|
a1cb83dfb2 | ||
|
|
ff532465cf | ||
|
|
39e88529b3 | ||
|
|
992bf636e0 | ||
|
|
beff5dd577 | ||
|
|
fb72e3e159 | ||
|
|
f339358109 | ||
|
|
df9b9905b6 | ||
|
|
e68fe071a7 | ||
|
|
7d31ca5d65 | ||
|
|
04876f3bef | ||
|
|
05fc49820c | ||
|
|
cbb108a636 | ||
|
|
bfd8bc5b9a | ||
|
|
6347d16a2f | ||
|
|
b193e64ffb | ||
|
|
6a3b46b5a9 | ||
|
|
770e3386f4 | ||
|
|
8f4c32b93f | ||
|
|
30a4343b83 | ||
|
|
cad68eb941 | ||
|
|
197e351880 | ||
|
|
8213b0aee3 | ||
|
|
5f01ec31ed | ||
|
|
d86043193b | ||
|
|
b705ed7715 | ||
|
|
581dbd6337 | ||
|
|
b3796546aa | ||
|
|
c0aef2f03e | ||
|
|
5e7e928650 | ||
|
|
088b3e255a | ||
|
|
97464779d4 | ||
|
|
72c1bdd57d | ||
|
|
1a30f984d5 | ||
|
|
4b91765cc7 | ||
|
|
b88609e0ff | ||
|
|
8b744d377c | ||
|
|
2f26cb2bc4 | ||
|
|
27331d66e4 | ||
|
|
a9cd164301 | ||
|
|
6171168927 | ||
|
|
7e7b6bd474 |
@@ -0,0 +1,62 @@
|
||||
name: Build Archipelago release ISO (gated)
|
||||
|
||||
# Resurrected from image-recipe/_archived/.gitea-workflows/build-iso-dev.yml.
|
||||
# Dispatch-only on purpose: the ISO is cut per release, not per push, and
|
||||
# the iso-builder runner is a live node — builds are deliberate events.
|
||||
on:
|
||||
workflow_dispatch:
|
||||
|
||||
jobs:
|
||||
build-iso:
|
||||
runs-on: iso-builder
|
||||
timeout-minutes: 180
|
||||
steps:
|
||||
- name: Sync source to workspace
|
||||
run: |
|
||||
# Direct fetch + sync (actions/checkout token is broken on this Gitea)
|
||||
REPO_DIR="$HOME/Projects/archy"
|
||||
[ -d "$REPO_DIR" ] || REPO_DIR="$HOME/archy"
|
||||
cd "$REPO_DIR" && git fetch origin main && git reset --hard origin/main
|
||||
echo "=== Source at commit: $(git log --oneline -1) ==="
|
||||
|
||||
- name: Install ISO build dependencies
|
||||
run: |
|
||||
if dpkg -s debootstrap squashfs-tools xorriso isolinux syslinux-common mtools \
|
||||
grub-efi-amd64-bin grub-pc-bin grub-common >/dev/null 2>&1; then
|
||||
echo "ISO build deps already installed, skipping apt"
|
||||
else
|
||||
sudo apt-get update -qq
|
||||
sudo apt-get install -y -qq \
|
||||
debootstrap squashfs-tools xorriso \
|
||||
isolinux syslinux-common mtools \
|
||||
grub-efi-amd64-bin grub-pc-bin grub-common
|
||||
fi
|
||||
|
||||
- name: Build backend + frontend if stale
|
||||
run: |
|
||||
REPO_DIR="$HOME/Projects/archy"
|
||||
[ -d "$REPO_DIR" ] || REPO_DIR="$HOME/archy"
|
||||
cd "$REPO_DIR"
|
||||
. "$HOME/.cargo/env" 2>/dev/null || true
|
||||
VERSION=$(grep -m1 '^version' core/archipelago/Cargo.toml | sed 's/.*"\(.*\)".*/\1/')
|
||||
if ! strings core/target/release/archipelago 2>/dev/null | grep -qF "$VERSION"; then
|
||||
cargo build --release --manifest-path core/Cargo.toml -p archipelago
|
||||
fi
|
||||
if ! grep -rqoF "$VERSION" web/dist/neode-ui/assets/*.js 2>/dev/null; then
|
||||
(cd neode-ui && npm ci && npm run build)
|
||||
fi
|
||||
|
||||
- name: Gated ISO build (gates + build + smoke + qemu)
|
||||
run: |
|
||||
REPO_DIR="$HOME/Projects/archy"
|
||||
[ -d "$REPO_DIR" ] || REPO_DIR="$HOME/archy"
|
||||
cd "$REPO_DIR"
|
||||
. "$HOME/.cargo/env" 2>/dev/null || true
|
||||
bash scripts/build-iso-release.sh
|
||||
|
||||
- name: Report artifacts
|
||||
if: always()
|
||||
run: |
|
||||
REPO_DIR="$HOME/Projects/archy"
|
||||
[ -d "$REPO_DIR" ] || REPO_DIR="$HOME/archy"
|
||||
ls -lh "$REPO_DIR"/image-recipe/results/*.iso 2>/dev/null | tail -3 || echo "no ISO produced"
|
||||
@@ -18,7 +18,7 @@ on:
|
||||
paths:
|
||||
- 'neode-ui/**'
|
||||
- 'docker-compose.demo.yml'
|
||||
- '.github/workflows/demo-images.yml'
|
||||
- '.gitea/workflows/demo-images.yml'
|
||||
workflow_dispatch:
|
||||
|
||||
jobs:
|
||||
|
||||
@@ -1,16 +1,16 @@
|
||||
## Summary
|
||||
|
||||
<!-- Brief description of what this PR does -->
|
||||
<!-- What changed and why? -->
|
||||
|
||||
## Changes
|
||||
## Verification
|
||||
|
||||
-
|
||||
<!-- Commands run, devices tested, screenshots, or reason testing was not run. -->
|
||||
|
||||
## 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
|
||||
- [ ] Rust formatting/clippy/tests pass when backend code changed.
|
||||
- [ ] Frontend type-check/build/tests pass when frontend code changed.
|
||||
- [ ] App manifests validate when app packaging changed.
|
||||
- [ ] Generated catalogs are updated when manifest-owned catalog fields changed.
|
||||
- [ ] Docs are updated for user-facing or developer-facing behavior changes.
|
||||
- [ ] No secrets, generated build outputs, local screenshots, or private host details are included.
|
||||
|
||||
@@ -8,11 +8,11 @@ on:
|
||||
|
||||
env:
|
||||
RUST_VERSION: stable
|
||||
NODE_VERSION: 18
|
||||
NODE_VERSION: 20
|
||||
|
||||
jobs:
|
||||
rust:
|
||||
name: Rust (fmt + clippy + test)
|
||||
name: Rust
|
||||
runs-on: ubuntu-latest
|
||||
defaults:
|
||||
run:
|
||||
@@ -28,17 +28,17 @@ jobs:
|
||||
toolchain: ${{ env.RUST_VERSION }}
|
||||
components: rustfmt, clippy
|
||||
|
||||
- name: Check formatting
|
||||
- name: Format
|
||||
run: cargo fmt --all -- --check
|
||||
|
||||
- name: Clippy
|
||||
run: cargo clippy --all-targets --all-features -- -D warnings
|
||||
|
||||
- name: Tests
|
||||
- name: Test
|
||||
run: cargo test --all-features
|
||||
|
||||
frontend:
|
||||
name: Frontend (type-check + lint)
|
||||
name: Frontend
|
||||
runs-on: ubuntu-latest
|
||||
defaults:
|
||||
run:
|
||||
@@ -52,14 +52,31 @@ jobs:
|
||||
uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: ${{ env.NODE_VERSION }}
|
||||
cache: 'npm'
|
||||
cache: npm
|
||||
cache-dependency-path: neode-ui/package-lock.json
|
||||
|
||||
- name: Install dependencies
|
||||
- name: Install
|
||||
run: npm ci
|
||||
|
||||
- name: Type check
|
||||
run: npm run type-check
|
||||
|
||||
- name: Test
|
||||
run: npm test
|
||||
|
||||
- name: Build
|
||||
run: npm run build
|
||||
|
||||
manifests:
|
||||
name: App Manifests
|
||||
runs-on: ubuntu-latest
|
||||
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: Validate manifests
|
||||
run: |
|
||||
for manifest in apps/*/manifest.yml; do
|
||||
./scripts/validate-app-manifest.sh --repo-audit "$manifest"
|
||||
done
|
||||
|
||||
+14
-21
@@ -1,10 +1,9 @@
|
||||
# SSH keys (sandbox copies)
|
||||
# SSH keys and sandbox copies
|
||||
.ssh/
|
||||
|
||||
# Rust build output
|
||||
target/
|
||||
**/target/
|
||||
Cargo.lock
|
||||
|
||||
# Node.js
|
||||
node_modules/
|
||||
@@ -12,7 +11,6 @@ node_modules/
|
||||
npm-debug.log*
|
||||
yarn-debug.log*
|
||||
yarn-error.log*
|
||||
package-lock.json
|
||||
pnpm-debug.log*
|
||||
|
||||
# Build outputs
|
||||
@@ -28,49 +26,46 @@ build/
|
||||
*.swo
|
||||
*~
|
||||
.DS_Store
|
||||
._*
|
||||
Thumbs.db
|
||||
|
||||
# Environment and local overrides
|
||||
.env
|
||||
.env.local
|
||||
.env.*.local
|
||||
.env.production
|
||||
core/.env.production
|
||||
scripts/deploy-config.sh
|
||||
|
||||
# Logs
|
||||
logs/
|
||||
*.log
|
||||
|
||||
# OS
|
||||
.DS_Store
|
||||
Thumbs.db
|
||||
|
||||
# Testing
|
||||
coverage/
|
||||
.nyc_output/
|
||||
|
||||
# Temporary files
|
||||
*.tmp
|
||||
*.temp
|
||||
|
||||
# Build artifacts
|
||||
# Image / release artifacts
|
||||
*.iso
|
||||
*.img
|
||||
*.dmg
|
||||
*.app
|
||||
*.apk
|
||||
*.keystore
|
||||
*.s9pk
|
||||
*.tar.gz
|
||||
|
||||
# Release artifacts live in Gitea Release attachments, not Git history.
|
||||
# Release artifacts live in 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 tool artifacts
|
||||
*/loop/
|
||||
loop/loop/
|
||||
loop/loop.log.bak
|
||||
@@ -78,19 +73,17 @@ loop/loop.log.bak
|
||||
# Separate repos nested in tree
|
||||
web/
|
||||
|
||||
._*
|
||||
|
||||
# Resilience harness reports (generated, contains session cookies)
|
||||
# Resilience harness reports contain session cookies.
|
||||
scripts/resilience/reports/
|
||||
|
||||
# Codex / pnpm / python caches / editor backups
|
||||
.codex
|
||||
.codex-target-*/
|
||||
.codex-tmp/
|
||||
.claude/
|
||||
.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.
|
||||
|
||||
@@ -19,3 +19,7 @@ local.properties
|
||||
# updates then install over the top without an uninstall. Debug keys are not
|
||||
# secret (well-known password "android"); never commit a real release keystore.
|
||||
!/app/debug.keystore
|
||||
|
||||
# Rust build outputs (archy-fips-core → jniLibs via buildRustArm64)
|
||||
/rust/archy-fips-core/target
|
||||
/app/src/main/jniLibs
|
||||
|
||||
@@ -23,6 +23,10 @@ used by the `.githooks/pre-push` hook), which:
|
||||
**aborts** if any is missing.
|
||||
5. Stages the signed APK at `neode-ui/public/packages/archipelago-companion.apk`,
|
||||
commits, and pushes with `SHIP_COMPANION=1` (the sanctioned pre-push bypass).
|
||||
6. The first-launch companion modal and Android "Share this app" QR point at
|
||||
`http://146.59.87.168:2100/packages/archipelago-companion.apk`. After the
|
||||
repo artifact is built, mirror that exact APK to the VPS2-served path before
|
||||
calling the release done.
|
||||
|
||||
**Never** hand-roll `gradlew assembleDebug` + `cp` to the served path. That path
|
||||
skips the clean build and the signature enforcement and is exactly how a broken
|
||||
@@ -82,13 +86,16 @@ home-screen app layouts wiped by an over-broad action.
|
||||
|
||||
## 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:
|
||||
The checked-in artifact is Gitea raw-on-main. The QR/App Store download served
|
||||
to users is the VPS2 `:2100` URL. Confirm both live byte streams match what you
|
||||
built and signed:
|
||||
|
||||
```bash
|
||||
SERVED=neode-ui/public/packages/archipelago-companion.apk
|
||||
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
|
||||
GITEA_URL=http://146.59.87.168:3000/lfg2025/archy/raw/branch/main/$SERVED
|
||||
QR_URL=http://146.59.87.168:2100/packages/archipelago-companion.apk
|
||||
curl -sS -o /tmp/live-gitea.apk "$GITEA_URL"
|
||||
curl -sS -o /tmp/live-qr.apk "$QR_URL"
|
||||
shasum -a 256 "$SERVED" /tmp/live-gitea.apk /tmp/live-qr.apk # all must match
|
||||
apksigner verify -v --min-sdk-version 21 /tmp/live-qr.apk | grep -i "scheme" # v1/v2/v3 = true
|
||||
```
|
||||
|
||||
@@ -11,12 +11,17 @@ android {
|
||||
applicationId = "com.archipelago.app"
|
||||
minSdk = 26
|
||||
targetSdk = 35
|
||||
versionCode = 19
|
||||
versionName = "0.4.15"
|
||||
versionCode = 45
|
||||
versionName = "0.5.25"
|
||||
|
||||
vectorDrawables {
|
||||
useSupportLibrary = true
|
||||
}
|
||||
|
||||
// The embedded FIPS mesh (libarchy_fips_core.so) is built arm64-only,
|
||||
// matching real handsets. FipsNative.available gates every call, so
|
||||
// the app still runs as a plain companion elsewhere (e.g. x86 emu).
|
||||
ndk { abiFilters += "arm64-v8a" }
|
||||
}
|
||||
|
||||
signingConfigs {
|
||||
@@ -80,6 +85,44 @@ android {
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Embedded FIPS mesh: cross-compile Android/rust/archy-fips-core via cargo-ndk
|
||||
// into jniLibs before the native-libs merge, so a plain `gradlew assembleDebug`
|
||||
// builds the Rust too. Requires rustup target aarch64-linux-android, cargo-ndk,
|
||||
// and an NDK (ANDROID_NDK_HOME or the SDK's ndk/ dir).
|
||||
// ---------------------------------------------------------------------------
|
||||
val rustCrateDir = layout.projectDirectory.dir("../rust/archy-fips-core")
|
||||
val jniLibsDir = layout.projectDirectory.dir("src/main/jniLibs")
|
||||
|
||||
tasks.register<Exec>("buildRustArm64") {
|
||||
workingDir = rustCrateDir.asFile
|
||||
inputs.dir(rustCrateDir.dir("src"))
|
||||
inputs.file(rustCrateDir.file("Cargo.toml"))
|
||||
outputs.dir(jniLibsDir)
|
||||
// cargo/cargo-ndk live in ~/.cargo/bin, which Gradle's env may not have.
|
||||
val home = System.getProperty("user.home")
|
||||
environment("PATH", "$home/.cargo/bin:${System.getenv("PATH")}")
|
||||
if (System.getenv("ANDROID_NDK_HOME") == null) {
|
||||
val sdkNdk = file("$home/Library/Android/sdk/ndk")
|
||||
.listFiles()?.maxByOrNull { it.name }
|
||||
if (sdkNdk != null) environment("ANDROID_NDK_HOME", sdkNdk.absolutePath)
|
||||
}
|
||||
commandLine(
|
||||
"cargo", "ndk",
|
||||
"-t", "arm64-v8a",
|
||||
"--platform", "26",
|
||||
"-o", jniLibsDir.asFile.absolutePath,
|
||||
"build", "--release",
|
||||
)
|
||||
}
|
||||
|
||||
tasks.matching {
|
||||
it.name in listOf(
|
||||
"mergeDebugNativeLibs", "mergeReleaseNativeLibs",
|
||||
"mergeDebugJniLibFolders", "mergeReleaseJniLibFolders",
|
||||
)
|
||||
}.configureEach { dependsOn("buildRustArm64") }
|
||||
|
||||
dependencies {
|
||||
val composeBom = platform("androidx.compose:compose-bom:2024.05.00")
|
||||
implementation(composeBom)
|
||||
|
||||
Binary file not shown.
@@ -7,6 +7,10 @@
|
||||
<!-- Pairing-QR scanner. Camera is optional: manual entry still works without one. -->
|
||||
<uses-permission android:name="android.permission.CAMERA" />
|
||||
<uses-feature android:name="android.hardware.camera.any" android:required="false" />
|
||||
<!-- Embedded FIPS mesh tunnel (ArchyVpnService) runs as a foreground service. -->
|
||||
<uses-permission android:name="android.permission.FOREGROUND_SERVICE" />
|
||||
<uses-permission android:name="android.permission.FOREGROUND_SERVICE_SPECIAL_USE" />
|
||||
<uses-permission android:name="android.permission.POST_NOTIFICATIONS" />
|
||||
|
||||
<application
|
||||
android:name=".ArchipelagoApp"
|
||||
@@ -19,6 +23,18 @@
|
||||
android:usesCleartextTraffic="true"
|
||||
tools:targetApi="35">
|
||||
|
||||
<!-- Party-screen "Share this app": exposes the copied APK from
|
||||
cache/share/ to the system share sheet, nothing else. -->
|
||||
<provider
|
||||
android:name="androidx.core.content.FileProvider"
|
||||
android:authorities="${applicationId}.fileprovider"
|
||||
android:exported="false"
|
||||
android:grantUriPermissions="true">
|
||||
<meta-data
|
||||
android:name="android.support.FILE_PROVIDER_PATHS"
|
||||
android:resource="@xml/file_paths" />
|
||||
</provider>
|
||||
|
||||
<activity
|
||||
android:name=".MainActivity"
|
||||
android:exported="true"
|
||||
@@ -39,6 +55,21 @@
|
||||
<data android:scheme="archipelago" android:host="pair" />
|
||||
</intent-filter>
|
||||
</activity>
|
||||
|
||||
<!-- Embedded FIPS mesh node: split-tunnel VpnService (fd00::/8 only),
|
||||
configured entirely by scanning the node's pairing QR. -->
|
||||
<service
|
||||
android:name=".fips.ArchyVpnService"
|
||||
android:exported="false"
|
||||
android:foregroundServiceType="specialUse"
|
||||
android:permission="android.permission.BIND_VPN_SERVICE">
|
||||
<property
|
||||
android:name="android.app.PROPERTY_SPECIAL_USE_FGS_SUBTYPE"
|
||||
android:value="mesh-vpn-tunnel" />
|
||||
<intent-filter>
|
||||
<action android:name="android.net.VpnService" />
|
||||
</intent-filter>
|
||||
</service>
|
||||
</application>
|
||||
|
||||
</manifest>
|
||||
|
||||
@@ -19,25 +19,45 @@ data class ServerEntry(
|
||||
val port: String = "",
|
||||
val password: String = "",
|
||||
val name: String = "",
|
||||
/** Node's FIPS mesh ULA (IPv6) — reachable from anywhere once meshed. */
|
||||
val meshIp: String = "",
|
||||
/** Node's FIPS npub — the durable identity. When present it, not the
|
||||
* address, is what identifies the entry: FIPS peers on npubs, IPs are
|
||||
* only dial hints (docs/companion-pairing-qr.md, npub-first contract). */
|
||||
val npub: String = "",
|
||||
) {
|
||||
/** Label to show in lists — the user-given name, or the address if unnamed. */
|
||||
fun displayName(): String = name.ifBlank { address }
|
||||
|
||||
/** Bracket bare IPv6 literals (the mesh ULA) so they form valid URLs. */
|
||||
private fun urlHost(host: String): String =
|
||||
if (host.contains(":") && !host.startsWith("[")) "[$host]" else host
|
||||
|
||||
fun toUrl(): String {
|
||||
val scheme = if (useHttps) "https" else "http"
|
||||
val portSuffix = if (port.isNotBlank()) ":$port" else ""
|
||||
return "$scheme://$address$portSuffix"
|
||||
return "$scheme://${urlHost(address)}$portSuffix"
|
||||
}
|
||||
|
||||
fun toWsUrl(): String {
|
||||
val scheme = if (useHttps) "wss" else "ws"
|
||||
val portSuffix = if (port.isNotBlank()) ":$port" else ""
|
||||
return "$scheme://$address$portSuffix"
|
||||
return "$scheme://${urlHost(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"
|
||||
/** Mesh-address UI URL, or null when the node never advertised one. */
|
||||
fun toMeshUrl(): String? =
|
||||
meshIp.takeIf { it.isNotBlank() }?.let { "http://${urlHost(it)}" }
|
||||
|
||||
// name/meshIp/npub are trailing fields so entries saved before they
|
||||
// existed (4/5/6 fields) still deserialize, defaulting to "".
|
||||
fun serialize(): String = "$address|$useHttps|$port|$password|$name|$meshIp|$npub"
|
||||
|
||||
/** Same node as [other]? npub identity wins; address/port/scheme is the
|
||||
* fallback for LAN-only entries that never advertised FIPS. */
|
||||
fun sameNode(other: ServerEntry): Boolean =
|
||||
(npub.isNotBlank() && npub == other.npub) ||
|
||||
(address == other.address && port == other.port && useHttps == other.useHttps)
|
||||
|
||||
companion object {
|
||||
fun deserialize(raw: String): ServerEntry? {
|
||||
@@ -49,6 +69,8 @@ data class ServerEntry(
|
||||
port = parts.getOrElse(2) { "" },
|
||||
password = parts.getOrElse(3) { "" },
|
||||
name = parts.getOrElse(4) { "" },
|
||||
meshIp = parts.getOrElse(5) { "" },
|
||||
npub = parts.getOrElse(6) { "" },
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -61,8 +83,11 @@ class ServerPreferences(private val context: Context) {
|
||||
private val activePortKey = stringPreferencesKey("active_port")
|
||||
private val activePasswordKey = stringPreferencesKey("active_password")
|
||||
private val activeNameKey = stringPreferencesKey("active_name")
|
||||
private val activeMeshIpKey = stringPreferencesKey("active_mesh_ip")
|
||||
private val activeNpubKey = stringPreferencesKey("active_npub")
|
||||
private val savedServersKey = stringSetPreferencesKey("saved_servers")
|
||||
private val introSeenKey = booleanPreferencesKey("intro_seen")
|
||||
private val gestureHintSeenKey = booleanPreferencesKey("gesture_hint_seen")
|
||||
|
||||
val activeServer: Flow<ServerEntry?> = context.dataStore.data.map { prefs ->
|
||||
val address = prefs[activeAddressKey] ?: return@map null
|
||||
@@ -72,6 +97,8 @@ class ServerPreferences(private val context: Context) {
|
||||
port = prefs[activePortKey] ?: "",
|
||||
password = prefs[activePasswordKey] ?: "",
|
||||
name = prefs[activeNameKey] ?: "",
|
||||
meshIp = prefs[activeMeshIpKey] ?: "",
|
||||
npub = prefs[activeNpubKey] ?: "",
|
||||
)
|
||||
}
|
||||
|
||||
@@ -84,6 +111,11 @@ class ServerPreferences(private val context: Context) {
|
||||
prefs[introSeenKey] ?: false
|
||||
}
|
||||
|
||||
/** One-shot flag for the three-finger-hold teaching overlay. */
|
||||
val gestureHintSeen: Flow<Boolean> = context.dataStore.data.map { prefs ->
|
||||
prefs[gestureHintSeenKey] ?: false
|
||||
}
|
||||
|
||||
suspend fun setActiveServer(server: ServerEntry) {
|
||||
context.dataStore.edit { prefs ->
|
||||
prefs[activeAddressKey] = server.address
|
||||
@@ -91,6 +123,8 @@ class ServerPreferences(private val context: Context) {
|
||||
prefs[activePortKey] = server.port
|
||||
prefs[activePasswordKey] = server.password
|
||||
prefs[activeNameKey] = server.name
|
||||
prefs[activeMeshIpKey] = server.meshIp
|
||||
prefs[activeNpubKey] = server.npub
|
||||
}
|
||||
addSavedServer(server)
|
||||
}
|
||||
@@ -102,6 +136,8 @@ class ServerPreferences(private val context: Context) {
|
||||
prefs.remove(activePortKey)
|
||||
prefs.remove(activePasswordKey)
|
||||
prefs.remove(activeNameKey)
|
||||
prefs.remove(activeMeshIpKey)
|
||||
prefs.remove(activeNpubKey)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -113,63 +149,66 @@ class ServerPreferences(private val context: Context) {
|
||||
}
|
||||
|
||||
/**
|
||||
* 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.
|
||||
* Replace a saved server in place. Matches the existing entry by node
|
||||
* identity — npub first, address/port/scheme as the LAN-only fallback
|
||||
* (ServerEntry.sameNode) — so edits that change the name, password or even
|
||||
* every address still update the right record. An edit form that doesn't
|
||||
* carry the npub keeps the stored one. If the edited server is also the
|
||||
* active one, the active record is kept in sync.
|
||||
*/
|
||||
suspend fun updateSavedServer(original: ServerEntry, updated: ServerEntry) {
|
||||
val toStore = updated.copy(npub = updated.npub.ifBlank { original.npub })
|
||||
context.dataStore.edit { prefs ->
|
||||
val current = prefs[savedServersKey] ?: emptySet()
|
||||
val filtered = current.filterNot { raw ->
|
||||
val e = ServerEntry.deserialize(raw)
|
||||
e != null &&
|
||||
e.address == original.address &&
|
||||
e.port == original.port &&
|
||||
e.useHttps == original.useHttps
|
||||
ServerEntry.deserialize(raw)?.sameNode(original) == true
|
||||
}.toSet()
|
||||
prefs[savedServersKey] = filtered + updated.serialize()
|
||||
prefs[savedServersKey] = filtered + toStore.serialize()
|
||||
|
||||
val isActive = prefs[activeAddressKey] == original.address &&
|
||||
(prefs[activePortKey] ?: "") == original.port &&
|
||||
(prefs[activeHttpsKey] ?: false) == original.useHttps
|
||||
val activeNpub = prefs[activeNpubKey] ?: ""
|
||||
val isActive = (activeNpub.isNotBlank() && activeNpub == original.npub) ||
|
||||
(
|
||||
prefs[activeAddressKey] == original.address &&
|
||||
(prefs[activePortKey] ?: "") == original.port &&
|
||||
(prefs[activeHttpsKey] ?: false) == original.useHttps
|
||||
)
|
||||
if (isActive) {
|
||||
prefs[activeAddressKey] = updated.address
|
||||
prefs[activeHttpsKey] = updated.useHttps
|
||||
prefs[activePortKey] = updated.port
|
||||
prefs[activePasswordKey] = updated.password
|
||||
prefs[activeNameKey] = updated.name
|
||||
prefs[activeAddressKey] = toStore.address
|
||||
prefs[activeHttpsKey] = toStore.useHttps
|
||||
prefs[activePortKey] = toStore.port
|
||||
prefs[activePasswordKey] = toStore.password
|
||||
prefs[activeNameKey] = toStore.name
|
||||
prefs[activeMeshIpKey] = toStore.meshIp
|
||||
prefs[activeNpubKey] = toStore.npub
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Add a server, or update the entry with the same connection identity
|
||||
* (address/port/scheme) — used by QR pairing so re-scanning a node never
|
||||
* duplicates it. A blank incoming password/name keeps the stored value
|
||||
* (a real node's QR never carries the password). Returns the merged entry.
|
||||
* Add a server, or update the entry for the same node — npub first,
|
||||
* address/port/scheme as the LAN-only fallback (ServerEntry.sameNode) —
|
||||
* used by QR pairing so re-scanning a node never duplicates it, even after
|
||||
* the LAN renumbered and every address changed (npub-first contract in
|
||||
* docs/companion-pairing-qr.md). A blank incoming password/name keeps the
|
||||
* stored value (a real node's QR never carries the password). Returns the
|
||||
* merged entry.
|
||||
*/
|
||||
suspend fun upsertServer(server: ServerEntry): ServerEntry {
|
||||
var merged = server
|
||||
context.dataStore.edit { prefs ->
|
||||
val current = prefs[savedServersKey] ?: emptySet()
|
||||
val existing = current.mapNotNull { ServerEntry.deserialize(it) }.firstOrNull {
|
||||
it.address == server.address &&
|
||||
it.port == server.port &&
|
||||
it.useHttps == server.useHttps
|
||||
}
|
||||
val existing = current.mapNotNull { ServerEntry.deserialize(it) }
|
||||
.firstOrNull { it.sameNode(server) }
|
||||
if (existing != null) {
|
||||
merged = server.copy(
|
||||
password = server.password.ifBlank { existing.password },
|
||||
name = server.name.ifBlank { existing.name },
|
||||
meshIp = server.meshIp.ifBlank { existing.meshIp },
|
||||
npub = server.npub.ifBlank { existing.npub },
|
||||
)
|
||||
}
|
||||
val filtered = current.filterNot { raw ->
|
||||
val e = ServerEntry.deserialize(raw)
|
||||
e != null &&
|
||||
e.address == server.address &&
|
||||
e.port == server.port &&
|
||||
e.useHttps == server.useHttps
|
||||
ServerEntry.deserialize(raw)?.sameNode(merged) == true
|
||||
}.toSet()
|
||||
prefs[savedServersKey] = filtered + merged.serialize()
|
||||
}
|
||||
@@ -179,15 +218,11 @@ class ServerPreferences(private val context: Context) {
|
||||
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.
|
||||
// Match by node identity (npub, else address/port/scheme) rather
|
||||
// than the exact serialized string, so a rename — or a legacy
|
||||
// short-format entry — still removes the right record.
|
||||
prefs[savedServersKey] = current.filterNot { raw ->
|
||||
val e = ServerEntry.deserialize(raw)
|
||||
e != null &&
|
||||
e.address == server.address &&
|
||||
e.port == server.port &&
|
||||
e.useHttps == server.useHttps
|
||||
ServerEntry.deserialize(raw)?.sameNode(server) == true
|
||||
}.toSet()
|
||||
}
|
||||
}
|
||||
@@ -197,4 +232,10 @@ class ServerPreferences(private val context: Context) {
|
||||
prefs[introSeenKey] = true
|
||||
}
|
||||
}
|
||||
|
||||
suspend fun markGestureHintSeen() {
|
||||
context.dataStore.edit { prefs ->
|
||||
prefs[gestureHintSeenKey] = true
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
package com.archipelago.app.data
|
||||
|
||||
import android.net.Uri
|
||||
import com.archipelago.app.fips.AnchorPeer
|
||||
import com.archipelago.app.fips.FipsPairInfo
|
||||
|
||||
/**
|
||||
* Result of parsing a pairing QR / deep link.
|
||||
@@ -10,7 +12,11 @@ import android.net.Uri
|
||||
* user to update the app rather than call the code invalid.
|
||||
*/
|
||||
sealed class PairResult {
|
||||
data class Success(val server: ServerEntry) : PairResult()
|
||||
data class Success(
|
||||
val server: ServerEntry,
|
||||
/** Mesh info when the node advertises FIPS (fnpub present). */
|
||||
val fips: FipsPairInfo? = null,
|
||||
) : PairResult()
|
||||
object UnsupportedVersion : PairResult()
|
||||
object Invalid : PairResult()
|
||||
}
|
||||
@@ -19,13 +25,18 @@ sealed class PairResult {
|
||||
* Parser for the companion pairing QR / OS deep link. Contract:
|
||||
* docs/companion-pairing-qr.md (repo root).
|
||||
*
|
||||
* archipelago://pair?v=1&url=<percent-encoded origin>[&pw=<password>]
|
||||
* archipelago://pair?v=1&url=<origin>&name=…[&tok=…][&pw=…][&fnpub=…&fip=…&fhost=…&fudp=…&ftcp=…]
|
||||
*
|
||||
* - `url` is a full origin including scheme (http for LAN/mDNS nodes, https
|
||||
* for the public demo); trailing slashes are normalized away.
|
||||
* - `pw` is only ever present for the public demo.
|
||||
* - Unknown extra query params are tolerated (forward compat under v=1 —
|
||||
* `name` is already honored if present).
|
||||
* - `tok` is a device token minted by the node; it goes through the password
|
||||
* field on purpose — the whole password auto-login path (WebSocket auth +
|
||||
* WebView form injection) then works unchanged, and the backend accepts
|
||||
* device tokens wherever it accepts the password. `pw` (demo only) wins if
|
||||
* both are ever present.
|
||||
* - `fnpub`/`fip`/`fhost`/`fudp`/`ftcp` describe the node's FIPS mesh; their
|
||||
* absence just means LAN-only pairing (older node, or FIPS not provisioned).
|
||||
* - Unknown extra query params are tolerated (forward compat under v=1).
|
||||
*/
|
||||
object ServerQrParser {
|
||||
private const val SUPPORTED_MAJOR = 1
|
||||
@@ -54,14 +65,64 @@ object ServerQrParser {
|
||||
val host = server.host
|
||||
if (host.isNullOrBlank()) return PairResult.Invalid
|
||||
|
||||
val fips = parseFips(uri)
|
||||
val credential = uri.getQueryParameter("pw")?.takeIf { it.isNotBlank() }
|
||||
?: uri.getQueryParameter("tok")
|
||||
?: ""
|
||||
|
||||
return PairResult.Success(
|
||||
ServerEntry(
|
||||
server = ServerEntry(
|
||||
address = host,
|
||||
useHttps = scheme == "https",
|
||||
port = if (server.port != -1) server.port.toString() else "",
|
||||
password = uri.getQueryParameter("pw") ?: "",
|
||||
password = credential,
|
||||
name = uri.getQueryParameter("name") ?: "",
|
||||
)
|
||||
meshIp = fips?.ula ?: "",
|
||||
// npub is the durable identity — saved-server upserts match on
|
||||
// it, so re-scanning after a LAN renumber updates in place.
|
||||
npub = fips?.npub ?: "",
|
||||
),
|
||||
fips = fips,
|
||||
)
|
||||
}
|
||||
|
||||
private fun parseFips(uri: Uri): FipsPairInfo? {
|
||||
val npub = uri.getQueryParameter("fnpub")?.trim()
|
||||
var host = uri.getQueryParameter("fhost")?.trim()
|
||||
if (npub.isNullOrBlank() || host.isNullOrBlank()) return null
|
||||
// A .fips fhost is a dead dial hint: Android's system DNS can't
|
||||
// resolve .fips, so every handshake send fails and first connect
|
||||
// falls back to slow anchor discovery. If the QR's `url` host is a
|
||||
// real address, dial that instead (QRs minted while the node UI was
|
||||
// browsed over the mesh carry npub….fips here).
|
||||
if (host.endsWith(".fips")) {
|
||||
val urlHost = uri.getQueryParameter("url")
|
||||
?.let { runCatching { Uri.parse(it).host }.getOrNull() }
|
||||
if (!urlHost.isNullOrBlank() && !urlHost.endsWith(".fips")) host = urlHost
|
||||
}
|
||||
return FipsPairInfo(
|
||||
npub = npub,
|
||||
ula = uri.getQueryParameter("fip")?.trim().orEmpty(),
|
||||
host = host,
|
||||
udpPort = uri.getQueryParameter("fudp")?.toIntOrNull() ?: 2121,
|
||||
tcpPort = uri.getQueryParameter("ftcp")?.toIntOrNull() ?: 8443,
|
||||
anchors = parseAnchors(uri.getQueryParameter("fanchors")),
|
||||
)
|
||||
}
|
||||
|
||||
/** `fanchors` = comma-joined `npub@host:port/transport`; bad items skipped. */
|
||||
private fun parseAnchors(raw: String?): List<AnchorPeer> {
|
||||
if (raw.isNullOrBlank()) return emptyList()
|
||||
return raw.split(",").mapNotNull { item ->
|
||||
val at = item.indexOf('@')
|
||||
val slash = item.lastIndexOf('/')
|
||||
if (at <= 0 || slash <= at) return@mapNotNull null
|
||||
val npub = item.substring(0, at).trim()
|
||||
val addr = item.substring(at + 1, slash).trim()
|
||||
val transport = item.substring(slash + 1).trim().lowercase()
|
||||
if (npub.isBlank() || !addr.contains(":")) return@mapNotNull null
|
||||
if (transport != "udp" && transport != "tcp") return@mapNotNull null
|
||||
AnchorPeer(npub = npub, addr = addr, transport = transport)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,324 @@
|
||||
package com.archipelago.app.fips
|
||||
|
||||
import android.app.Notification
|
||||
import android.app.NotificationChannel
|
||||
import android.app.NotificationManager
|
||||
import android.app.PendingIntent
|
||||
import android.content.Intent
|
||||
import android.net.ConnectivityManager
|
||||
import android.net.Network
|
||||
import android.net.NetworkCapabilities
|
||||
import android.net.NetworkRequest
|
||||
import android.net.VpnService
|
||||
import android.os.Build
|
||||
import android.util.Log
|
||||
import com.archipelago.app.MainActivity
|
||||
import com.archipelago.app.R
|
||||
import com.archipelago.app.data.ServerPreferences
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.Job
|
||||
import kotlinx.coroutines.SupervisorJob
|
||||
import kotlinx.coroutines.cancel
|
||||
import kotlinx.coroutines.delay
|
||||
import kotlinx.coroutines.flow.first
|
||||
import kotlinx.coroutines.isActive
|
||||
import kotlinx.coroutines.launch
|
||||
|
||||
/**
|
||||
* VpnService hosting the embedded FIPS mesh node.
|
||||
*
|
||||
* Split tunnel: only fd00::/8 (the FIPS ULA space) routes into the TUN, so
|
||||
* normal phone traffic is untouched — this is mesh reachability, not a
|
||||
* default-route VPN. The established fd is detached and handed to the Rust
|
||||
* node (Node::start_with_tun_fd); the node owns it until stop.
|
||||
*/
|
||||
class ArchyVpnService : VpnService() {
|
||||
|
||||
private val scope = CoroutineScope(SupervisorJob() + Dispatchers.IO)
|
||||
private var warmerJob: Job? = null
|
||||
|
||||
// Seamless transport handoff (Wi-Fi ⇄ 5G ⇄ future BLE). Without this the
|
||||
// tunnel's underlying network stays pinned to the interface that was
|
||||
// default when the VPN came up; when the phone leaves Wi-Fi for 5G the
|
||||
// mesh sockets ride a dead network and sessions never recover until the
|
||||
// app is restarted (user-reported 2026-07-27). The callback (a) re-pins
|
||||
// the tunnel to the new default network via setUnderlyingNetworks and
|
||||
// (b) forces an immediate mesh re-home so discovery + sessions rebuild
|
||||
// on the new path within seconds instead of waiting out dead-link
|
||||
// timeouts.
|
||||
private var connectivityManager: ConnectivityManager? = null
|
||||
private var networkCallback: ConnectivityManager.NetworkCallback? = null
|
||||
@Volatile
|
||||
private var currentUnderlying: Network? = null
|
||||
|
||||
override fun onStartCommand(intent: Intent?, flags: Int, startId: Int): Int {
|
||||
if (intent?.action == ACTION_STOP) {
|
||||
shutdown()
|
||||
return START_NOT_STICKY
|
||||
}
|
||||
startForeground(NOTIFICATION_ID, buildNotification())
|
||||
scope.launch { startMesh() }
|
||||
return START_STICKY
|
||||
}
|
||||
|
||||
private suspend fun startMesh() {
|
||||
if (!FipsNative.available) {
|
||||
shutdown()
|
||||
return
|
||||
}
|
||||
val prefs = FipsPreferences(this)
|
||||
val identity = FipsManager.ensureIdentity(prefs)
|
||||
val peersJson = prefs.combinedPeersJson()
|
||||
if (identity == null || peersJson == "[]") {
|
||||
Log.w(TAG, "mesh not configured — stopping")
|
||||
shutdown()
|
||||
return
|
||||
}
|
||||
// Party mode: fixed inbound UDP bind so a nearby phone can dial us
|
||||
// directly over a shared LAN/hotspot (no internet required).
|
||||
val listenPort = if (prefs.partyListen()) PartyQr.PARTY_UDP_PORT else 0
|
||||
|
||||
// A fresh app open re-triggers the service. Tearing a HEALTHY mesh
|
||||
// down to rebuild it costs ~8s of anchor+session bring-up on every
|
||||
// launch (observed live: stop 00:34:38 → session back 00:34:49) and
|
||||
// is what made "freshly loading the app" slow. Keep a running node;
|
||||
// restart only when it's dead or a pairing changed the peer set.
|
||||
if (FipsNative.isRunning() && !FipsManager.peersDirty) {
|
||||
Log.i(TAG, "mesh already running — keeping warm sessions")
|
||||
startSessionWarmer()
|
||||
return
|
||||
}
|
||||
FipsManager.peersDirty = false
|
||||
// Re-establishing while running would strand the old fd; restart clean.
|
||||
if (FipsNative.isRunning()) FipsNative.stop()
|
||||
|
||||
val pfd = try {
|
||||
Builder()
|
||||
.setSession("Archipelago Mesh")
|
||||
.setMtu(1280)
|
||||
.addAddress(identity.address, 128)
|
||||
.addRoute("fd00::", 8)
|
||||
// The TUN is IPv6-only. Android blocks every address family
|
||||
// the VPN has no address for — without this, bringing the
|
||||
// mesh up cut ALL of the phone's IPv4 internet.
|
||||
.allowFamily(android.system.OsConstants.AF_INET)
|
||||
// And let apps that bind their own network skip the TUN
|
||||
// entirely — this is mesh reachability, not a privacy VPN.
|
||||
.allowBypass()
|
||||
.apply {
|
||||
// Android 10+ treats VPN networks as METERED by default,
|
||||
// which flips the whole phone into data-saver behaviour
|
||||
// (background sync off, "metered" warnings) while the
|
||||
// mesh is up. It inherits the underlying network's real
|
||||
// metered state instead.
|
||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) setMetered(false)
|
||||
}
|
||||
.establish()
|
||||
} catch (e: Exception) {
|
||||
Log.e(TAG, "VPN establish failed", e)
|
||||
null
|
||||
}
|
||||
if (pfd == null) {
|
||||
shutdown()
|
||||
return
|
||||
}
|
||||
|
||||
val fd = pfd.detachFd()
|
||||
val result = FipsNative.start(identity.secret, peersJson, fd, listenPort)
|
||||
Log.i(TAG, "mesh start: $result (listen=$listenPort)")
|
||||
if (result.contains("\"error\"")) {
|
||||
shutdown()
|
||||
} else {
|
||||
startSessionWarmer()
|
||||
registerNetworkHandoff()
|
||||
// Phone-to-phone chat/beam + the phone's own mesh-served page.
|
||||
FlareServer.start(this, identity.address, identity.npub, prefs.partyName())
|
||||
// Mutual pairing: when a phone that scanned OUR QR announces
|
||||
// itself, store it as a party peer and re-run the mesh config so
|
||||
// this side gets the peer + chat entry without scanning back.
|
||||
FlareServer.onHello = { peer ->
|
||||
scope.launch {
|
||||
prefs.upsertPartyPeer(peer)
|
||||
FipsManager.requestMeshRestart(this@ArchyVpnService)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Pre-warm + keep-warm mesh sessions to every known node ULA.
|
||||
*
|
||||
* Discovery + first session through the public tree can take 15s+
|
||||
* (HANDOFF-2026-07-23 node diagnosis) — paying that cost here, the
|
||||
* moment the tunnel is up, means the connect probe and WebView hit an
|
||||
* established session instead of timing out on a cold one. The periodic
|
||||
* touch afterwards keeps the session from idling out. Failed connects
|
||||
* are expected and cheap; the attempt itself is what drives discovery.
|
||||
*/
|
||||
private fun startSessionWarmer() {
|
||||
warmerJob?.cancel()
|
||||
warmerJob = scope.launch {
|
||||
val prefs = ServerPreferences(this@ArchyVpnService)
|
||||
val fipsPrefs = FipsPreferences(this@ArchyVpnService)
|
||||
var round = 0
|
||||
while (isActive && FipsNative.isRunning()) {
|
||||
val targets = try {
|
||||
prefs.savedServers.first()
|
||||
.mapNotNull { it.meshIp.ifBlank { null } }
|
||||
.map { it to 80 } +
|
||||
// Party phones answer on the flare port, not :80.
|
||||
fipsPrefs.partyPeers().map { it.ula to PartyQr.FLARE_PORT }
|
||||
} catch (_: Exception) {
|
||||
emptyList()
|
||||
}.distinct()
|
||||
if (round == 0) Log.i(TAG, "session warmer: ${targets.map { it.first }}")
|
||||
// Probe all targets CONCURRENTLY with a short timeout — the
|
||||
// old sequential 20s-per-target loop let one cold node starve
|
||||
// every other target for the whole aggressive window.
|
||||
targets.map { (ula, port) ->
|
||||
launch {
|
||||
try {
|
||||
java.net.Socket().use { s ->
|
||||
s.connect(
|
||||
java.net.InetSocketAddress(
|
||||
java.net.InetAddress.getByName(ula),
|
||||
port,
|
||||
),
|
||||
5_000,
|
||||
)
|
||||
}
|
||||
} catch (_: Exception) {
|
||||
// Cold path / node away — the attempt still drove
|
||||
// session establishment; try again next round.
|
||||
}
|
||||
}
|
||||
}.forEach { it.join() }
|
||||
round++
|
||||
// Aggressive for the first ~minute (session bring-up), then a
|
||||
// slow keep-warm tick that costs nearly nothing.
|
||||
delay(if (round < 12) 5_000 else 60_000)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Track the phone's default network and hand the mesh over to it as the
|
||||
* phone roams (Wi-Fi ⇄ 5G, and later BLE). Two actions per change:
|
||||
* 1. setUnderlyingNetworks(new) — the tunnel's packets follow the live
|
||||
* network instead of dying on the one it launched with.
|
||||
* 2. re-home the mesh — kick the session warmer so discovery + sessions
|
||||
* rebuild on the new path immediately; the node's own fast-reconnect
|
||||
* (1s) redials peers over the new route.
|
||||
* onAvailable also fires for the FIRST network, which is how the initial
|
||||
* underlying network gets set.
|
||||
*/
|
||||
private fun registerNetworkHandoff() {
|
||||
if (networkCallback != null) return
|
||||
val cm = getSystemService(ConnectivityManager::class.java) ?: return
|
||||
connectivityManager = cm
|
||||
val request = NetworkRequest.Builder()
|
||||
.addCapability(NetworkCapabilities.NET_CAPABILITY_INTERNET)
|
||||
.build()
|
||||
val cb = object : ConnectivityManager.NetworkCallback() {
|
||||
override fun onAvailable(network: Network) {
|
||||
handoffTo(network)
|
||||
}
|
||||
|
||||
override fun onLost(network: Network) {
|
||||
// The lost network was our underlying one — clear the pin so
|
||||
// the system falls back to whatever default remains; the next
|
||||
// onAvailable re-pins explicitly.
|
||||
if (network == currentUnderlying) {
|
||||
currentUnderlying = null
|
||||
runCatching { setUnderlyingNetworks(null) }
|
||||
}
|
||||
}
|
||||
}
|
||||
networkCallback = cb
|
||||
// requestNetwork tracks the BEST network of the request; when the
|
||||
// phone moves Wi-Fi→5G the callback re-fires onAvailable with the new
|
||||
// one. (registerDefaultNetworkCallback would also work; requestNetwork
|
||||
// lets us extend to BLE-capable transports later.)
|
||||
runCatching { cm.requestNetwork(request, cb) }
|
||||
}
|
||||
|
||||
private fun handoffTo(network: Network) {
|
||||
val changed = network != currentUnderlying
|
||||
currentUnderlying = network
|
||||
// Always re-assert; cheap and covers capability changes on the same
|
||||
// Network object.
|
||||
runCatching { setUnderlyingNetworks(arrayOf(network)) }
|
||||
if (changed && FipsNative.isRunning()) {
|
||||
Log.i(TAG, "network handoff → re-homing mesh on new default network")
|
||||
// Fresh warmer pass drives immediate rediscovery/session rebuild
|
||||
// on the new path instead of waiting out dead-link timeouts.
|
||||
startSessionWarmer()
|
||||
}
|
||||
}
|
||||
|
||||
private fun unregisterNetworkHandoff() {
|
||||
val cm = connectivityManager
|
||||
val cb = networkCallback
|
||||
if (cm != null && cb != null) {
|
||||
runCatching { cm.unregisterNetworkCallback(cb) }
|
||||
}
|
||||
networkCallback = null
|
||||
connectivityManager = null
|
||||
currentUnderlying = null
|
||||
}
|
||||
|
||||
private fun shutdown() {
|
||||
warmerJob?.cancel()
|
||||
unregisterNetworkHandoff()
|
||||
FlareServer.stop()
|
||||
FipsNative.stop()
|
||||
stopForeground(STOP_FOREGROUND_REMOVE)
|
||||
stopSelf()
|
||||
}
|
||||
|
||||
override fun onDestroy() {
|
||||
unregisterNetworkHandoff()
|
||||
FipsNative.stop()
|
||||
scope.cancel()
|
||||
super.onDestroy()
|
||||
}
|
||||
|
||||
override fun onRevoke() {
|
||||
// User pulled VPN permission from system settings.
|
||||
shutdown()
|
||||
}
|
||||
|
||||
private fun buildNotification(): Notification {
|
||||
val manager = getSystemService(NotificationManager::class.java)
|
||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
|
||||
manager.createNotificationChannel(
|
||||
NotificationChannel(
|
||||
CHANNEL_ID,
|
||||
"Mesh connection",
|
||||
NotificationManager.IMPORTANCE_MIN,
|
||||
).apply { description = "Keeps the node reachable from anywhere" }
|
||||
)
|
||||
}
|
||||
val tapIntent = PendingIntent.getActivity(
|
||||
this,
|
||||
0,
|
||||
Intent(this, MainActivity::class.java),
|
||||
PendingIntent.FLAG_IMMUTABLE,
|
||||
)
|
||||
return Notification.Builder(this, CHANNEL_ID)
|
||||
.setContentTitle("Connected to your Archipelago")
|
||||
.setContentText("Secure mesh link active")
|
||||
.setSmallIcon(R.mipmap.ic_launcher)
|
||||
.setContentIntent(tapIntent)
|
||||
.setOngoing(true)
|
||||
.build()
|
||||
}
|
||||
|
||||
companion object {
|
||||
const val ACTION_STOP = "com.archipelago.app.fips.STOP"
|
||||
private const val CHANNEL_ID = "archy_mesh"
|
||||
private const val NOTIFICATION_ID = 4841
|
||||
private const val TAG = "ArchyVpnService"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,103 @@
|
||||
package com.archipelago.app.fips
|
||||
|
||||
import android.content.Context
|
||||
import android.content.Intent
|
||||
import android.net.VpnService
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.flow.StateFlow
|
||||
|
||||
/**
|
||||
* Glue between pairing and the mesh: persists the node peer from a scanned
|
||||
* QR and asks the UI to bring the tunnel up. There is deliberately no
|
||||
* settings surface — scanning a node's QR is the entire configuration.
|
||||
*
|
||||
* The one unavoidable interaction is Android's VPN consent dialog
|
||||
* (VpnService.prepare), which only an Activity can launch; [consentNeeded]
|
||||
* signals AppNavHost to run it, once, on first pairing.
|
||||
*/
|
||||
object FipsManager {
|
||||
|
||||
/** Set when a pairing registered mesh info and the VPN needs starting. */
|
||||
private val _consentNeeded = MutableStateFlow(false)
|
||||
val consentNeeded: StateFlow<Boolean> = _consentNeeded
|
||||
|
||||
/** True after a pairing changed the peer set while the node was running —
|
||||
* tells the service a restart is genuinely needed (the ONLY case; a
|
||||
* routine app open must keep the warm mesh, not rebuild it). */
|
||||
@Volatile
|
||||
var peersDirty: Boolean = false
|
||||
|
||||
fun consentHandled() {
|
||||
_consentNeeded.value = false
|
||||
}
|
||||
|
||||
/**
|
||||
* Persist mesh info from a pairing scan and request tunnel start.
|
||||
* No-op on devices without the native lib (non-arm64).
|
||||
*/
|
||||
suspend fun registerNode(context: Context, info: FipsPairInfo?, alias: String) {
|
||||
if (info == null || !FipsNative.available) return
|
||||
val prefs = FipsPreferences(context)
|
||||
ensureIdentity(prefs)
|
||||
prefs.upsertNodePeer(info, alias)
|
||||
peersDirty = true
|
||||
// Restart the mesh with the new peer RIGHT NOW when consent already
|
||||
// exists — relying on the consentNeeded collector left a running
|
||||
// mesh on the OLD peer list whenever the collector wasn't active
|
||||
// (fresh pairings looked dead until a full app restart).
|
||||
if (VpnService.prepare(context) == null) {
|
||||
startService(context)
|
||||
} else {
|
||||
_consentNeeded.value = true
|
||||
}
|
||||
}
|
||||
|
||||
/** Generate-once mesh identity. Returns null only if the RNG/native fails. */
|
||||
suspend fun ensureIdentity(prefs: FipsPreferences): FipsNative.Identity? {
|
||||
prefs.identity()?.let { return it }
|
||||
val generated = FipsNative.parseIdentity(FipsNative.generateIdentity()) ?: return null
|
||||
prefs.saveIdentity(generated)
|
||||
return generated
|
||||
}
|
||||
|
||||
/**
|
||||
* Start the mesh service if this device is paired and the user has already
|
||||
* consented to the VPN (prepare() == null). Called on app start so the
|
||||
* tunnel comes back without any interaction; first-time consent goes
|
||||
* through AppNavHost instead.
|
||||
*/
|
||||
suspend fun autoStartIfReady(context: Context) {
|
||||
if (!FipsNative.available) return
|
||||
val prefs = FipsPreferences(context)
|
||||
if (prefs.identity() == null || !prefs.hasPeers()) return
|
||||
if (VpnService.prepare(context) != null) return // consent missing — don't prompt here
|
||||
startService(context)
|
||||
}
|
||||
|
||||
fun startService(context: Context) {
|
||||
val intent = Intent(context, ArchyVpnService::class.java)
|
||||
context.startForegroundService(intent)
|
||||
}
|
||||
|
||||
/**
|
||||
* Re-run the mesh with current prefs (party listen toggled, peer added).
|
||||
* Marks the peer set dirty so startMesh genuinely restarts the node —
|
||||
* otherwise the keep-warm fast path would skip the new config.
|
||||
* First-timers go through the consent flow.
|
||||
*/
|
||||
fun requestMeshRestart(context: Context) {
|
||||
if (!FipsNative.available) return
|
||||
peersDirty = true
|
||||
if (VpnService.prepare(context) == null) {
|
||||
startService(context)
|
||||
} else {
|
||||
_consentNeeded.value = true
|
||||
}
|
||||
}
|
||||
|
||||
fun stopService(context: Context) {
|
||||
val intent = Intent(context, ArchyVpnService::class.java)
|
||||
.setAction(ArchyVpnService.ACTION_STOP)
|
||||
context.startService(intent)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,49 @@
|
||||
package com.archipelago.app.fips
|
||||
|
||||
import org.json.JSONObject
|
||||
|
||||
/**
|
||||
* JNI binding to the embedded FIPS mesh node (Android/rust/archy-fips-core,
|
||||
* built into libarchy_fips_core.so by the buildRustArm64 gradle task).
|
||||
*
|
||||
* All calls return JSON strings; failures come back as {"error": "…"} rather
|
||||
* than exceptions. [available] is false on ABIs the .so isn't built for
|
||||
* (anything but arm64) — every caller must gate on it so the app still runs
|
||||
* as a plain companion there.
|
||||
*/
|
||||
object FipsNative {
|
||||
val available: Boolean = try {
|
||||
System.loadLibrary("archy_fips_core")
|
||||
true
|
||||
} catch (_: Throwable) {
|
||||
false
|
||||
}
|
||||
|
||||
external fun generateIdentity(): String
|
||||
external fun deriveIdentity(secret: String): String
|
||||
|
||||
/**
|
||||
* [listenPort] 0 = outbound-only (default posture). Non-zero binds UDP on
|
||||
* that port so a nearby phone can dial us directly (party mode); the node
|
||||
* stays leaf-only either way.
|
||||
*/
|
||||
external fun start(secret: String, peersJson: String, tunFd: Int, listenPort: Int): String
|
||||
external fun stop()
|
||||
external fun isRunning(): Boolean
|
||||
external fun statusJson(): String
|
||||
|
||||
data class Identity(val secret: String, val npub: String, val address: String)
|
||||
|
||||
/** Parse an identity JSON reply; null on {"error": …} or malformed. */
|
||||
fun parseIdentity(json: String): Identity? = try {
|
||||
val obj = JSONObject(json)
|
||||
if (obj.has("error")) null
|
||||
else Identity(
|
||||
secret = obj.getString("secret"),
|
||||
npub = obj.getString("npub"),
|
||||
address = obj.getString("address"),
|
||||
)
|
||||
} catch (_: Exception) {
|
||||
null
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,29 @@
|
||||
package com.archipelago.app.fips
|
||||
|
||||
/**
|
||||
* Node mesh parameters carried by the pairing QR (fnpub/fip/fhost/fudp/ftcp —
|
||||
* docs/companion-pairing-qr.md). The phone's embedded FIPS node dials
|
||||
* host:udpPort / host:tcpPort claiming nothing; the mesh accepts inbound peers
|
||||
* without registration, so possession of these params is all pairing takes.
|
||||
*/
|
||||
data class FipsPairInfo(
|
||||
val npub: String,
|
||||
/** Node's fips0 ULA — where its UI stays reachable once meshed. May be empty. */
|
||||
val ula: String,
|
||||
val host: String,
|
||||
val udpPort: Int,
|
||||
val tcpPort: Int,
|
||||
/**
|
||||
* Public rendezvous anchors (the node's seed-anchor list). The phone
|
||||
* peers with these too, so it can route to the node via the mesh when
|
||||
* the node's LAN endpoint isn't directly dialable.
|
||||
*/
|
||||
val anchors: List<AnchorPeer> = emptyList(),
|
||||
)
|
||||
|
||||
/** One rendezvous anchor from the QR's `fanchors` param (npub@addr/transport). */
|
||||
data class AnchorPeer(
|
||||
val npub: String,
|
||||
val addr: String,
|
||||
val transport: String,
|
||||
)
|
||||
@@ -0,0 +1,341 @@
|
||||
package com.archipelago.app.fips
|
||||
|
||||
import android.content.Context
|
||||
import androidx.datastore.core.DataStore
|
||||
import androidx.datastore.preferences.core.Preferences
|
||||
import androidx.datastore.preferences.core.booleanPreferencesKey
|
||||
import androidx.datastore.preferences.core.edit
|
||||
import androidx.datastore.preferences.core.stringPreferencesKey
|
||||
import kotlinx.coroutines.flow.Flow
|
||||
import kotlinx.coroutines.flow.first
|
||||
import kotlinx.coroutines.flow.map
|
||||
import org.json.JSONArray
|
||||
import org.json.JSONObject
|
||||
import androidx.datastore.preferences.preferencesDataStore
|
||||
|
||||
private val Context.fipsDataStore: DataStore<Preferences> by preferencesDataStore(name = "fips_prefs")
|
||||
|
||||
// Archipelago-operated public anchor (vps2). Baked in so EVERY pairing yields
|
||||
// both paths — direct LAN p2p to the node AND a public rendezvous for
|
||||
// away-from-home — even when the scanned node is old enough that its QR
|
||||
// carries no fanchors. Keep in lockstep with
|
||||
// core/archipelago/src/fips/anchors.rs (ARCHY_ANCHOR_*).
|
||||
internal const val ARCHY_ANCHOR_NPUB =
|
||||
"npub1dptaktwxv0mm245g2lqjykwm5ll0jpc6m3r4242ydfa9z7qe6urs3jvrak"
|
||||
internal const val ARCHY_ANCHOR_ADDR = "146.59.87.168:8444"
|
||||
internal const val ARCHY_ANCHOR_TRANSPORT = "tcp"
|
||||
|
||||
/**
|
||||
* Public FIPS network anchors (join.fips.network — the dual-transport TCP
|
||||
* pair; keep in lockstep with core/archipelago/src/fips/anchors.rs
|
||||
* fips_network_anchors()). Baked into every pairing at trailing priority so
|
||||
* a degraded/unreachable vps2 anchor can never strand the phone: the mesh
|
||||
* still joins the public tree and routes to the node through it.
|
||||
*/
|
||||
internal val PUBLIC_FIPS_ANCHORS = listOf(
|
||||
Triple(
|
||||
"npub10yffd020a4ag8zcy75f9pruq3rnghvvhd5hphl9s62zgp35s560qrksp9u",
|
||||
"23.182.128.74:443",
|
||||
"tcp",
|
||||
),
|
||||
Triple(
|
||||
"npub1qmc3cvfz0yu2hx96nq3gp55zdan2qclealn7xshgr448d3nh6lks7zel98",
|
||||
"217.77.8.91:443",
|
||||
"tcp",
|
||||
),
|
||||
)
|
||||
|
||||
/**
|
||||
* Mesh identity + known node peers. Follows the same plaintext-DataStore
|
||||
* storage model as ServerPreferences (the server password lives there the
|
||||
* same way); the mesh secret only grants mesh membership, not node login.
|
||||
*/
|
||||
class FipsPreferences(private val context: Context) {
|
||||
|
||||
private val secretKey = stringPreferencesKey("fips_secret")
|
||||
private val npubKey = stringPreferencesKey("fips_npub")
|
||||
private val addressKey = stringPreferencesKey("fips_address")
|
||||
/** JSON array of node peers in fips PeerConfig shape (see NodePeer). */
|
||||
private val peersKey = stringPreferencesKey("fips_node_peers")
|
||||
/** JSON array of phone party peers (PartyPeer shape, NOT PeerConfig). */
|
||||
private val partyPeersKey = stringPreferencesKey("fips_party_peers")
|
||||
/** Party mode: accept a direct inbound mesh link (UDP 2121). */
|
||||
private val partyListenKey = booleanPreferencesKey("fips_party_listen")
|
||||
/** Name shown in this phone's party QR and outgoing flares. */
|
||||
private val partyNameKey = stringPreferencesKey("fips_party_name")
|
||||
|
||||
suspend fun identity(): FipsNative.Identity? {
|
||||
val prefs = context.fipsDataStore.data.first()
|
||||
val secret = prefs[secretKey] ?: return null
|
||||
return FipsNative.Identity(
|
||||
secret = secret,
|
||||
npub = prefs[npubKey] ?: "",
|
||||
address = prefs[addressKey] ?: "",
|
||||
)
|
||||
}
|
||||
|
||||
suspend fun saveIdentity(identity: FipsNative.Identity) {
|
||||
context.fipsDataStore.edit { prefs ->
|
||||
prefs[secretKey] = identity.secret
|
||||
prefs[npubKey] = identity.npub
|
||||
prefs[addressKey] = identity.address
|
||||
}
|
||||
}
|
||||
|
||||
suspend fun peersJson(): String {
|
||||
val prefs = context.fipsDataStore.data.first()
|
||||
return prefs[peersKey] ?: "[]"
|
||||
}
|
||||
|
||||
suspend fun hasPeers(): Boolean = JSONArray(peersJson()).length() > 0
|
||||
|
||||
// ── Mesh Party (phone↔phone) ────────────────────────────────────────────
|
||||
|
||||
suspend fun partyListen(): Boolean =
|
||||
context.fipsDataStore.data.first()[partyListenKey] ?: false
|
||||
|
||||
val partyListenFlow: Flow<Boolean>
|
||||
get() = context.fipsDataStore.data.map { it[partyListenKey] ?: false }
|
||||
|
||||
suspend fun setPartyListen(enabled: Boolean) {
|
||||
context.fipsDataStore.edit { it[partyListenKey] = enabled }
|
||||
}
|
||||
|
||||
suspend fun partyName(): String =
|
||||
context.fipsDataStore.data.first()[partyNameKey]
|
||||
?: android.os.Build.MODEL.orEmpty().ifBlank { "Phone" }
|
||||
|
||||
suspend fun setPartyName(name: String) {
|
||||
context.fipsDataStore.edit { it[partyNameKey] = name.trim() }
|
||||
}
|
||||
|
||||
val partyPeersFlow: Flow<List<PartyPeer>>
|
||||
get() = context.fipsDataStore.data.map { parsePartyPeers(it[partyPeersKey] ?: "[]") }
|
||||
|
||||
suspend fun partyPeers(): List<PartyPeer> =
|
||||
parsePartyPeers(context.fipsDataStore.data.first()[partyPeersKey] ?: "[]")
|
||||
|
||||
/** Matched by npub, so re-scanning updates the direct-dial address in place. */
|
||||
suspend fun upsertPartyPeer(peer: PartyPeer) {
|
||||
context.fipsDataStore.edit { prefs ->
|
||||
val kept = parsePartyPeers(prefs[partyPeersKey] ?: "[]").filter { it.npub != peer.npub }
|
||||
prefs[partyPeersKey] = toJson(kept + peer)
|
||||
}
|
||||
}
|
||||
|
||||
suspend fun removePartyPeer(npub: String) {
|
||||
context.fipsDataStore.edit { prefs ->
|
||||
val kept = parsePartyPeers(prefs[partyPeersKey] ?: "[]").filter { it.npub != npub }
|
||||
prefs[partyPeersKey] = toJson(kept)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Node peers + direct-dial party peers, in the fips PeerConfig JSON the
|
||||
* Rust node deserializes. Party peers get the best priority: on a shared
|
||||
* LAN/hotspot the direct link beats every anchor path, and while off-LAN
|
||||
* the failed dial is cheap (auto-reconnect keeps retrying, which is
|
||||
* exactly what makes the link snap up the moment both phones share WiFi).
|
||||
* Party peers without an underlay address are mesh-routed and need no
|
||||
* entry here at all.
|
||||
*/
|
||||
suspend fun combinedPeersJson(): String {
|
||||
val merged = JSONArray(peersJson())
|
||||
val party = partyPeers()
|
||||
for (peer in party) {
|
||||
if (peer.ip.isBlank() || peer.port <= 0) continue
|
||||
merged.put(JSONObject().apply {
|
||||
put("npub", peer.npub)
|
||||
put("alias", hostSafeAlias(peer.name.ifBlank { "party-phone" }))
|
||||
put("addresses", JSONArray().put(JSONObject().apply {
|
||||
put("transport", "udp")
|
||||
put("addr", "${peer.ip}:${peer.port}")
|
||||
put("priority", 5)
|
||||
}))
|
||||
})
|
||||
}
|
||||
// A party-only phone (never paired with a node) still needs a public
|
||||
// rendezvous to reach its peers ACROSS the internet — without it, two
|
||||
// bare phones would be hotspot/LAN-only. Node pairing normally bakes
|
||||
// this anchor in; do the same when there are party peers.
|
||||
if (party.isNotEmpty() &&
|
||||
(0 until merged.length()).none {
|
||||
merged.optJSONObject(it)?.optString("npub") == ARCHY_ANCHOR_NPUB
|
||||
}
|
||||
) {
|
||||
merged.put(JSONObject().apply {
|
||||
put("npub", ARCHY_ANCHOR_NPUB)
|
||||
put("alias", "archipelago-anchor")
|
||||
put("addresses", JSONArray().put(JSONObject().apply {
|
||||
put("transport", ARCHY_ANCHOR_TRANSPORT)
|
||||
put("addr", ARCHY_ANCHOR_ADDR)
|
||||
put("priority", 40)
|
||||
}))
|
||||
})
|
||||
}
|
||||
// Backfill anchor peers for entries paired before newer QR/app
|
||||
// releases added them. `upsertNodePeer` persists these on re-scan, but
|
||||
// startup must also self-heal old DataStore state so updating the APK is
|
||||
// enough to get off-LAN redundancy.
|
||||
if (merged.length() > 0) {
|
||||
addAnchorIfMissing(merged, ARCHY_ANCHOR_NPUB, "archipelago-anchor", ARCHY_ANCHOR_ADDR, ARCHY_ANCHOR_TRANSPORT, 40)
|
||||
for ((i, anchor) in PUBLIC_FIPS_ANCHORS.withIndex()) {
|
||||
val (npub, addr, transport) = anchor
|
||||
addAnchorIfMissing(merged, npub, "fips-network-anchor-${i + 1}", addr, transport, 50 + i * 10)
|
||||
}
|
||||
}
|
||||
return merged.toString()
|
||||
}
|
||||
|
||||
private fun addAnchorIfMissing(
|
||||
peers: JSONArray,
|
||||
npub: String,
|
||||
alias: String,
|
||||
addr: String,
|
||||
transport: String,
|
||||
priority: Int,
|
||||
) {
|
||||
if ((0 until peers.length()).any { peers.optJSONObject(it)?.optString("npub") == npub }) return
|
||||
peers.put(JSONObject().apply {
|
||||
put("npub", npub)
|
||||
put("alias", alias)
|
||||
put("addresses", JSONArray().put(JSONObject().apply {
|
||||
put("transport", transport)
|
||||
put("addr", addr)
|
||||
put("priority", priority)
|
||||
}))
|
||||
})
|
||||
}
|
||||
|
||||
private fun parsePartyPeers(json: String): List<PartyPeer> = try {
|
||||
val arr = JSONArray(json)
|
||||
(0 until arr.length()).mapNotNull { i ->
|
||||
val o = arr.optJSONObject(i) ?: return@mapNotNull null
|
||||
val npub = o.optString("npub")
|
||||
val ula = o.optString("ula")
|
||||
if (npub.isBlank() || ula.isBlank()) return@mapNotNull null
|
||||
PartyPeer(
|
||||
npub = npub,
|
||||
ula = ula,
|
||||
name = o.optString("name").ifBlank { "Phone" },
|
||||
ip = o.optString("ip"),
|
||||
port = o.optInt("port"),
|
||||
)
|
||||
}
|
||||
} catch (_: Exception) {
|
||||
emptyList()
|
||||
}
|
||||
|
||||
private fun toJson(peers: List<PartyPeer>): String {
|
||||
val arr = JSONArray()
|
||||
for (p in peers) {
|
||||
arr.put(JSONObject().apply {
|
||||
put("npub", p.npub)
|
||||
put("ula", p.ula)
|
||||
put("name", p.name)
|
||||
put("ip", p.ip)
|
||||
put("port", p.port)
|
||||
})
|
||||
}
|
||||
return arr.toString()
|
||||
}
|
||||
|
||||
/**
|
||||
* Add or update the node peer plus its rendezvous anchors (each matched
|
||||
* by npub, so re-pairing updates addresses instead of duplicating).
|
||||
* Stored directly in the fips PeerConfig JSON shape the Rust side
|
||||
* deserializes. The node's direct addresses get the best priorities;
|
||||
* anchors trail so the mesh prefers the direct path when it works.
|
||||
*/
|
||||
suspend fun upsertNodePeer(info: FipsPairInfo, alias: String) {
|
||||
val incoming = mutableListOf<JSONObject>()
|
||||
incoming += JSONObject().apply {
|
||||
put("npub", info.npub)
|
||||
put("alias", hostSafeAlias(alias.ifBlank { "Archipelago" }))
|
||||
val addresses = JSONArray()
|
||||
// .fips hosts are unresolvable on Android (no system .fips DNS):
|
||||
// storing one gives the mesh a dial target that fails every
|
||||
// handshake and stalls first connect on anchor discovery.
|
||||
if (info.udpPort > 0 && !info.host.endsWith(".fips")) {
|
||||
addresses.put(JSONObject().apply {
|
||||
put("transport", "udp")
|
||||
put("addr", "${info.host}:${info.udpPort}")
|
||||
put("priority", 10)
|
||||
})
|
||||
}
|
||||
if (info.tcpPort > 0 && !info.host.endsWith(".fips")) {
|
||||
addresses.put(JSONObject().apply {
|
||||
put("transport", "tcp")
|
||||
put("addr", "${info.host}:${info.tcpPort}")
|
||||
put("priority", 20)
|
||||
})
|
||||
}
|
||||
put("addresses", addresses)
|
||||
}
|
||||
for (anchor in info.anchors) {
|
||||
if (anchor.npub == info.npub) continue
|
||||
if (anchor.addr.substringBeforeLast(":").endsWith(".fips")) continue
|
||||
incoming += JSONObject().apply {
|
||||
put("npub", anchor.npub)
|
||||
put("alias", "mesh-anchor")
|
||||
put("addresses", JSONArray().put(JSONObject().apply {
|
||||
put("transport", anchor.transport)
|
||||
put("addr", anchor.addr)
|
||||
put("priority", 30)
|
||||
}))
|
||||
}
|
||||
}
|
||||
// Guarantee the public anchor: without it, a QR from an older node
|
||||
// leaves the phone LAN-only and pairing/connecting dies off-LAN.
|
||||
if (info.npub != ARCHY_ANCHOR_NPUB &&
|
||||
incoming.none { it.optString("npub") == ARCHY_ANCHOR_NPUB }
|
||||
) {
|
||||
incoming += JSONObject().apply {
|
||||
put("npub", ARCHY_ANCHOR_NPUB)
|
||||
put("alias", "archipelago-anchor")
|
||||
put("addresses", JSONArray().put(JSONObject().apply {
|
||||
put("transport", ARCHY_ANCHOR_TRANSPORT)
|
||||
put("addr", ARCHY_ANCHOR_ADDR)
|
||||
put("priority", 40)
|
||||
}))
|
||||
}
|
||||
}
|
||||
// And the public FIPS network anchors at trailing priority, so one
|
||||
// degraded rendezvous (vps2, 2026-07-24) can never strand the phone.
|
||||
for ((i, anchor) in PUBLIC_FIPS_ANCHORS.withIndex()) {
|
||||
val (npub, addr, transport) = anchor
|
||||
if (info.npub == npub || incoming.any { it.optString("npub") == npub }) continue
|
||||
incoming += JSONObject().apply {
|
||||
put("npub", npub)
|
||||
put("alias", "fips-network-anchor-${i + 1}")
|
||||
put("addresses", JSONArray().put(JSONObject().apply {
|
||||
put("transport", transport)
|
||||
put("addr", addr)
|
||||
put("priority", 50 + i * 10)
|
||||
}))
|
||||
}
|
||||
}
|
||||
val incomingNpubs = incoming.map { it.optString("npub") }.toSet()
|
||||
context.fipsDataStore.edit { prefs ->
|
||||
val current = JSONArray(prefs[peersKey] ?: "[]")
|
||||
val merged = JSONArray()
|
||||
for (i in 0 until current.length()) {
|
||||
val existing = current.optJSONObject(i) ?: continue
|
||||
if (existing.optString("npub") !in incomingNpubs) merged.put(existing)
|
||||
}
|
||||
incoming.forEach { merged.put(it) }
|
||||
prefs[peersKey] = merged.toString()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Peer aliases feed the fips host map as `<alias>.fips` hostnames; anything
|
||||
* that isn't a valid DNS label ("Framework PT" — the space) gets rejected and
|
||||
* silently drops the peer from name resolution. Slug it instead of losing it.
|
||||
*/
|
||||
internal fun hostSafeAlias(alias: String): String =
|
||||
alias.lowercase()
|
||||
.replace(Regex("[^a-z0-9.-]+"), "-")
|
||||
.trim('-', '.')
|
||||
.ifBlank { "archipelago" }
|
||||
@@ -0,0 +1,398 @@
|
||||
package com.archipelago.app.fips
|
||||
|
||||
import android.content.Context
|
||||
import android.util.Log
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.flow.StateFlow
|
||||
import okhttp3.MediaType.Companion.toMediaType
|
||||
import okhttp3.OkHttpClient
|
||||
import okhttp3.Request
|
||||
import okhttp3.RequestBody.Companion.toRequestBody
|
||||
import org.json.JSONObject
|
||||
import java.io.BufferedInputStream
|
||||
import java.io.File
|
||||
import java.io.InputStream
|
||||
import java.net.InetAddress
|
||||
import java.net.InetSocketAddress
|
||||
import java.net.ServerSocket
|
||||
import java.net.Socket
|
||||
import java.util.UUID
|
||||
import java.util.concurrent.ExecutorService
|
||||
import java.util.concurrent.Executors
|
||||
import java.util.concurrent.TimeUnit
|
||||
|
||||
/** One chat/photo message in a party conversation, keyed by the peer's npub. */
|
||||
data class FlareMessage(
|
||||
val id: String,
|
||||
val peerNpub: String,
|
||||
val fromMe: Boolean,
|
||||
val name: String,
|
||||
val text: String = "",
|
||||
val photoPath: String = "",
|
||||
val ts: Long,
|
||||
val status: Status = Status.RECEIVED,
|
||||
) {
|
||||
enum class Status { SENDING, SENT, FAILED, RECEIVED }
|
||||
}
|
||||
|
||||
/** In-memory conversation store (demo scope — nothing persists across restarts). */
|
||||
object FlareStore {
|
||||
private val _messages = MutableStateFlow<List<FlareMessage>>(emptyList())
|
||||
val messages: StateFlow<List<FlareMessage>> = _messages
|
||||
|
||||
fun add(message: FlareMessage) {
|
||||
_messages.value = _messages.value + message
|
||||
}
|
||||
|
||||
fun setStatus(id: String, status: FlareMessage.Status) {
|
||||
_messages.value = _messages.value.map { if (it.id == id) it.copy(status = status) else it }
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Minimal HTTP listener bound ONLY on this phone's mesh ULA — plain HTTP is
|
||||
* fine there because FIPS is the encryption + peer-identity layer (same
|
||||
* stance as the node's ULA-only peer listener). This is what makes the phone
|
||||
* a *server* on the mesh: another phone (or `curl -6` from any mesh node)
|
||||
* reaches it by npub-derived address with no port forwarding, DNS, or CA.
|
||||
*
|
||||
* FIPS authenticates the node, not the request (project doctrine), so inputs
|
||||
* are still validated at this boundary: size caps, JSON shape, no
|
||||
* client-controlled paths.
|
||||
*/
|
||||
object FlareServer {
|
||||
private const val TAG = "FlareServer"
|
||||
private const val MAX_PHOTO_BYTES = 8 * 1024 * 1024
|
||||
private const val MAX_TEXT_CHARS = 4_000
|
||||
private const val MAX_HEADER_BYTES = 16 * 1024
|
||||
|
||||
private var socket: ServerSocket? = null
|
||||
private var pool: ExecutorService? = null
|
||||
@Volatile private var identityName = "Phone"
|
||||
@Volatile private var identityNpub = ""
|
||||
@Volatile private var photoDir: File? = null
|
||||
|
||||
/** Invoked when a peer announces itself (POST /hello) — pairing used to
|
||||
* be one-way: only the SCANNING phone learned the other side, so the
|
||||
* scanned phone had no peer, no chat entry, no way in. The VPN service
|
||||
* wires this to upsert the peer + restart the mesh config. */
|
||||
@Volatile var onHello: ((PartyPeer) -> Unit)? = null
|
||||
|
||||
@Synchronized
|
||||
fun start(context: Context, ula: String, myNpub: String, myName: String) {
|
||||
stop()
|
||||
identityNpub = myNpub
|
||||
identityName = myName
|
||||
photoDir = File(context.cacheDir, "flare").apply { mkdirs() }
|
||||
val pool = Executors.newCachedThreadPool().also { this.pool = it }
|
||||
pool.execute {
|
||||
try {
|
||||
val server = ServerSocket().apply {
|
||||
reuseAddress = true
|
||||
bind(InetSocketAddress(InetAddress.getByName(ula), PartyQr.FLARE_PORT))
|
||||
}
|
||||
socket = server
|
||||
Log.i(TAG, "flare listening on [$ula]:${PartyQr.FLARE_PORT}")
|
||||
while (!server.isClosed) {
|
||||
val client = try {
|
||||
server.accept()
|
||||
} catch (_: Exception) {
|
||||
break
|
||||
}
|
||||
pool.execute { handle(client) }
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
Log.e(TAG, "flare server died: $e")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Synchronized
|
||||
fun stop() {
|
||||
try {
|
||||
socket?.close()
|
||||
} catch (_: Exception) {
|
||||
}
|
||||
socket = null
|
||||
pool?.shutdownNow()
|
||||
pool = null
|
||||
}
|
||||
|
||||
private fun handle(client: Socket) {
|
||||
client.use { sock ->
|
||||
sock.soTimeout = 30_000
|
||||
try {
|
||||
val input = BufferedInputStream(sock.getInputStream())
|
||||
val requestLine = readLine(input) ?: return
|
||||
val parts = requestLine.trim().split(" ")
|
||||
if (parts.size < 2) return respond(sock, 400, json("bad_request"))
|
||||
val (method, path) = parts[0] to parts[1]
|
||||
|
||||
var contentLength = 0
|
||||
var from = ""
|
||||
var fromName = ""
|
||||
var headerBytes = requestLine.length
|
||||
while (true) {
|
||||
val line = readLine(input) ?: return
|
||||
if (line.isEmpty()) break
|
||||
headerBytes += line.length
|
||||
if (headerBytes > MAX_HEADER_BYTES) return respond(sock, 431, json("headers_too_large"))
|
||||
val idx = line.indexOf(':')
|
||||
if (idx <= 0) continue
|
||||
val key = line.substring(0, idx).trim().lowercase()
|
||||
val value = line.substring(idx + 1).trim()
|
||||
when (key) {
|
||||
"content-length" -> contentLength = value.toIntOrNull() ?: 0
|
||||
"x-from" -> from = value.take(80)
|
||||
"x-name" -> fromName = value.take(80)
|
||||
}
|
||||
}
|
||||
|
||||
when {
|
||||
method == "GET" && (path == "/" || path.startsWith("/?")) ->
|
||||
respondHtml(sock, profilePage())
|
||||
method == "POST" && path == "/hello" -> {
|
||||
if (contentLength !in 1..MAX_HEADER_BYTES) return respond(sock, 413, json("too_large"))
|
||||
val body = readExactly(input, contentLength) ?: return
|
||||
receiveHello(String(body, Charsets.UTF_8))
|
||||
respond(sock, 200, """{"ok":true}""")
|
||||
}
|
||||
method == "POST" && path == "/flare" -> {
|
||||
if (contentLength !in 1..MAX_HEADER_BYTES) return respond(sock, 413, json("too_large"))
|
||||
val body = readExactly(input, contentLength) ?: return
|
||||
receiveFlare(String(body, Charsets.UTF_8))
|
||||
respond(sock, 200, """{"ok":true}""")
|
||||
}
|
||||
method == "POST" && path == "/photo" -> {
|
||||
if (contentLength !in 1..MAX_PHOTO_BYTES) return respond(sock, 413, json("too_large"))
|
||||
if (!from.startsWith("npub1")) return respond(sock, 400, json("bad_request"))
|
||||
val body = readExactly(input, contentLength) ?: return
|
||||
receivePhoto(from, fromName, body)
|
||||
respond(sock, 200, """{"ok":true}""")
|
||||
}
|
||||
else -> respond(sock, 404, json("not_found"))
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
Log.w(TAG, "request failed: $e")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** A peer that scanned OUR QR announces itself — mutual pairing. */
|
||||
private fun receiveHello(body: String) {
|
||||
val o = try {
|
||||
JSONObject(body)
|
||||
} catch (_: Exception) {
|
||||
return
|
||||
}
|
||||
val from = o.optString("from")
|
||||
val ula = o.optString("ula")
|
||||
if (!from.startsWith("npub1") || !ula.startsWith("fd")) return
|
||||
if (from == identityNpub) return
|
||||
val peer = PartyPeer(
|
||||
npub = from.take(80),
|
||||
ula = ula.take(64),
|
||||
name = o.optString("name").take(24).ifBlank { "Phone" },
|
||||
ip = o.optString("ip").take(40),
|
||||
port = o.optInt("port", 0),
|
||||
)
|
||||
onHello?.invoke(peer)
|
||||
// Seed the conversation so the chat has a visible entry on this side.
|
||||
FlareStore.add(
|
||||
FlareMessage(
|
||||
id = UUID.randomUUID().toString(),
|
||||
peerNpub = peer.npub,
|
||||
fromMe = false,
|
||||
name = peer.name,
|
||||
text = "👋 ${peer.name} joined the party",
|
||||
ts = System.currentTimeMillis(),
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
private fun receiveFlare(body: String) {
|
||||
val o = try {
|
||||
JSONObject(body)
|
||||
} catch (_: Exception) {
|
||||
return
|
||||
}
|
||||
val from = o.optString("from")
|
||||
if (!from.startsWith("npub1")) return
|
||||
val text = o.optString("text").take(MAX_TEXT_CHARS)
|
||||
if (text.isBlank()) return
|
||||
FlareStore.add(
|
||||
FlareMessage(
|
||||
id = UUID.randomUUID().toString(),
|
||||
peerNpub = from,
|
||||
fromMe = false,
|
||||
name = o.optString("name").take(80).ifBlank { "Phone" },
|
||||
text = text,
|
||||
ts = System.currentTimeMillis(),
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
private fun receivePhoto(from: String, fromName: String, bytes: ByteArray) {
|
||||
// Server-generated filename — the sender never controls the path.
|
||||
val dir = photoDir ?: return
|
||||
val file = File(dir, "${UUID.randomUUID()}.jpg")
|
||||
file.writeBytes(bytes)
|
||||
FlareStore.add(
|
||||
FlareMessage(
|
||||
id = UUID.randomUUID().toString(),
|
||||
peerNpub = from,
|
||||
fromMe = false,
|
||||
name = fromName.ifBlank { "Phone" },
|
||||
photoPath = file.absolutePath,
|
||||
ts = System.currentTimeMillis(),
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
private fun profilePage(): String {
|
||||
val npub = identityNpub
|
||||
val name = identityName
|
||||
return """
|
||||
<!doctype html><html><head><meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width,initial-scale=1">
|
||||
<title>$name — on the mesh</title>
|
||||
<style>
|
||||
body{background:#0a0a0a;color:#eee;font-family:monospace;
|
||||
display:flex;min-height:100vh;align-items:center;justify-content:center;margin:0}
|
||||
.card{border:1px solid rgba(255,255,255,.12);border-radius:20px;padding:32px;
|
||||
max-width:560px;background:rgba(255,255,255,.04)}
|
||||
h1{color:#f7931a;margin:0 0 8px;font-size:22px}
|
||||
.npub{word-break:break-all;color:#888;font-size:12px;margin:12px 0}
|
||||
p{line-height:1.5}
|
||||
</style></head><body><div class="card">
|
||||
<h1>⚡ $name</h1>
|
||||
<div class="npub">$npub</div>
|
||||
<p>This page is being served <b>by a phone</b>, addressed by its
|
||||
cryptographic identity over the FIPS mesh.</p>
|
||||
<p>No port forwarding. No DNS. No certificate authority. No cloud.
|
||||
The key <i>is</i> the address — and the transport underneath can be
|
||||
5G, WiFi, or a hotspot with no internet at all.</p>
|
||||
</div></body></html>
|
||||
""".trimIndent()
|
||||
}
|
||||
|
||||
// ── tiny HTTP plumbing ──────────────────────────────────────────────────
|
||||
|
||||
/** Read one CRLF-terminated header line as ISO-8859-1; null on EOF. */
|
||||
private fun readLine(input: InputStream): String? {
|
||||
val sb = StringBuilder()
|
||||
while (true) {
|
||||
val b = input.read()
|
||||
if (b == -1) return if (sb.isEmpty()) null else sb.toString()
|
||||
if (b == '\n'.code) return sb.toString().trimEnd('\r')
|
||||
sb.append(b.toChar())
|
||||
if (sb.length > MAX_HEADER_BYTES) return null
|
||||
}
|
||||
}
|
||||
|
||||
private fun readExactly(input: InputStream, length: Int): ByteArray? {
|
||||
val buf = ByteArray(length)
|
||||
var off = 0
|
||||
while (off < length) {
|
||||
val n = input.read(buf, off, length - off)
|
||||
if (n == -1) return null
|
||||
off += n
|
||||
}
|
||||
return buf
|
||||
}
|
||||
|
||||
private fun json(code: String) = """{"error":{"code":"$code","message":"request rejected"}}"""
|
||||
|
||||
private fun respond(sock: Socket, status: Int, body: String) =
|
||||
writeResponse(sock, status, "application/json", body.toByteArray(Charsets.UTF_8))
|
||||
|
||||
private fun respondHtml(sock: Socket, body: String) =
|
||||
writeResponse(sock, 200, "text/html; charset=utf-8", body.toByteArray(Charsets.UTF_8))
|
||||
|
||||
private fun writeResponse(sock: Socket, status: Int, contentType: String, body: ByteArray) {
|
||||
val reason = when (status) {
|
||||
200 -> "OK"; 400 -> "Bad Request"; 404 -> "Not Found"
|
||||
413 -> "Payload Too Large"; 431 -> "Headers Too Large"
|
||||
else -> "Error"
|
||||
}
|
||||
val head = "HTTP/1.1 $status $reason\r\n" +
|
||||
"Content-Type: $contentType\r\n" +
|
||||
"Content-Length: ${body.size}\r\n" +
|
||||
"Connection: close\r\n\r\n"
|
||||
sock.getOutputStream().apply {
|
||||
write(head.toByteArray(Charsets.ISO_8859_1))
|
||||
write(body)
|
||||
flush()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** Outbound flares: plain HTTP to the peer's ULA — FIPS encrypts underneath. */
|
||||
object FlareClient {
|
||||
// Connect timeout must outlive cold mesh-session establishment (~15s via
|
||||
// the public tree per HANDOFF-2026-07-23); the attempt itself drives
|
||||
// session setup, same trick as the VPN service's session warmer.
|
||||
private val http = OkHttpClient.Builder()
|
||||
.connectTimeout(25, TimeUnit.SECONDS)
|
||||
.readTimeout(15, TimeUnit.SECONDS)
|
||||
.writeTimeout(60, TimeUnit.SECONDS)
|
||||
.build()
|
||||
|
||||
private fun base(peer: PartyPeer) = "http://[${peer.ula}]:${PartyQr.FLARE_PORT}"
|
||||
|
||||
/** Announce myself to a freshly scanned peer so pairing becomes MUTUAL —
|
||||
* their phone gets me as a peer + a chat entry without scanning back.
|
||||
* Blocking — call from Dispatchers.IO. */
|
||||
fun sendHello(
|
||||
peer: PartyPeer,
|
||||
myNpub: String,
|
||||
myName: String,
|
||||
myUla: String,
|
||||
myIp: String?,
|
||||
myPort: Int,
|
||||
): Boolean = try {
|
||||
val body = JSONObject()
|
||||
.put("from", myNpub)
|
||||
.put("name", myName)
|
||||
.put("ula", myUla)
|
||||
.put("ip", myIp ?: "")
|
||||
.put("port", if (myIp != null) myPort else 0)
|
||||
.toString()
|
||||
.toRequestBody("application/json".toMediaType())
|
||||
http.newCall(
|
||||
Request.Builder().url("${base(peer)}/hello").post(body).build()
|
||||
).execute().use { it.isSuccessful }
|
||||
} catch (_: Exception) {
|
||||
false
|
||||
}
|
||||
|
||||
/** Blocking — call from Dispatchers.IO. */
|
||||
fun sendText(peer: PartyPeer, myNpub: String, myName: String, text: String): Boolean = try {
|
||||
val body = JSONObject()
|
||||
.put("from", myNpub)
|
||||
.put("name", myName)
|
||||
.put("text", text)
|
||||
.put("ts", System.currentTimeMillis())
|
||||
.toString()
|
||||
.toRequestBody("application/json".toMediaType())
|
||||
http.newCall(
|
||||
Request.Builder().url("${base(peer)}/flare").post(body).build()
|
||||
).execute().use { it.isSuccessful }
|
||||
} catch (_: Exception) {
|
||||
false
|
||||
}
|
||||
|
||||
/** Blocking — call from Dispatchers.IO. */
|
||||
fun sendPhoto(peer: PartyPeer, myNpub: String, myName: String, jpeg: ByteArray): Boolean = try {
|
||||
http.newCall(
|
||||
Request.Builder()
|
||||
.url("${base(peer)}/photo")
|
||||
.header("X-From", myNpub)
|
||||
.header("X-Name", myName)
|
||||
.post(jpeg.toRequestBody("image/jpeg".toMediaType()))
|
||||
.build()
|
||||
).execute().use { it.isSuccessful }
|
||||
} catch (_: Exception) {
|
||||
false
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,102 @@
|
||||
package com.archipelago.app.fips
|
||||
|
||||
import android.net.Uri
|
||||
import java.net.Inet4Address
|
||||
import java.net.NetworkInterface
|
||||
|
||||
/**
|
||||
* Phone↔phone mesh pairing ("Mesh Party") QR contract:
|
||||
*
|
||||
* archipelago://party?v=1&npub=<npub>&ula=<fd..>&name=<name>[&ip=<v4>&port=<udp>]
|
||||
*
|
||||
* npub + ula alone are enough to chat *through* the mesh (anchors route by
|
||||
* node address, no underlay info needed). ip/port are present only while the
|
||||
* showing phone has its inbound UDP listener up (party mode) — the scanner
|
||||
* then also gets a direct-dial link that works on a shared LAN/hotspot with
|
||||
* no internet at all. Same versioning stance as the node pairing QR
|
||||
* (docs/companion-pairing-qr.md): unknown params tolerated under v=1.
|
||||
*/
|
||||
data class PartyPeer(
|
||||
val npub: String,
|
||||
val ula: String,
|
||||
val name: String,
|
||||
/** Direct-dial underlay endpoint; empty when the peer wasn't listening. */
|
||||
val ip: String = "",
|
||||
val port: Int = 0,
|
||||
)
|
||||
|
||||
object PartyQr {
|
||||
const val SCHEME_HOST = "party"
|
||||
private const val SUPPORTED_MAJOR = 1
|
||||
|
||||
/** UDP port a party-mode phone listens on (matches the node's mesh port). */
|
||||
const val PARTY_UDP_PORT = 2121
|
||||
|
||||
/** Application-layer chat/beam port, bound only on the mesh ULA. */
|
||||
const val FLARE_PORT = 5680
|
||||
|
||||
fun build(npub: String, ula: String, name: String, ip: String?, port: Int): String {
|
||||
val b = Uri.Builder()
|
||||
.scheme("archipelago")
|
||||
.authority(SCHEME_HOST)
|
||||
.appendQueryParameter("v", "1")
|
||||
.appendQueryParameter("npub", npub)
|
||||
.appendQueryParameter("ula", ula)
|
||||
.appendQueryParameter("name", name)
|
||||
if (!ip.isNullOrBlank() && port > 0) {
|
||||
b.appendQueryParameter("ip", ip)
|
||||
b.appendQueryParameter("port", port.toString())
|
||||
}
|
||||
return b.build().toString()
|
||||
}
|
||||
|
||||
/** Null when [raw] is not a valid party QR (foreign codes just keep scanning). */
|
||||
fun parse(raw: String): PartyPeer? {
|
||||
val uri = try {
|
||||
Uri.parse(raw.trim())
|
||||
} catch (_: Exception) {
|
||||
return null
|
||||
}
|
||||
if (!"archipelago".equals(uri.scheme, ignoreCase = true)) return null
|
||||
if (uri.isOpaque || !SCHEME_HOST.equals(uri.host, ignoreCase = true)) return null
|
||||
val major = uri.getQueryParameter("v")?.takeWhile { it.isDigit() }?.toIntOrNull() ?: return null
|
||||
if (major != SUPPORTED_MAJOR) return null
|
||||
|
||||
val npub = uri.getQueryParameter("npub")?.trim().orEmpty()
|
||||
val ula = uri.getQueryParameter("ula")?.trim().orEmpty()
|
||||
if (!npub.startsWith("npub1") || !ula.startsWith("fd")) return null
|
||||
return PartyPeer(
|
||||
npub = npub,
|
||||
ula = ula,
|
||||
name = uri.getQueryParameter("name")?.trim().orEmpty().ifBlank { "Phone" },
|
||||
ip = uri.getQueryParameter("ip")?.trim().orEmpty(),
|
||||
port = uri.getQueryParameter("port")?.toIntOrNull() ?: 0,
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* This phone's private IPv4 on WiFi or its own hotspot, for the QR's
|
||||
* direct-dial hint. Hotspot interfaces (ap/swlan/softap) win over wlan so
|
||||
* the hotspot-host phone advertises the address its guests can reach.
|
||||
*/
|
||||
fun localWifiIpv4(): String? {
|
||||
val candidates = mutableListOf<Pair<String, String>>() // ifname → addr
|
||||
try {
|
||||
for (nif in NetworkInterface.getNetworkInterfaces()) {
|
||||
if (!nif.isUp || nif.isLoopback) continue
|
||||
for (addr in nif.inetAddresses) {
|
||||
if (addr is Inet4Address && addr.isSiteLocalAddress) {
|
||||
candidates += nif.name to addr.hostAddress.orEmpty()
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch (_: Exception) {
|
||||
return null
|
||||
}
|
||||
val hotspot = candidates.firstOrNull {
|
||||
it.first.startsWith("ap") || it.first.startsWith("swlan") || it.first.startsWith("softap")
|
||||
}
|
||||
return (hotspot ?: candidates.firstOrNull { it.first.startsWith("wlan") } ?: candidates.firstOrNull())
|
||||
?.second
|
||||
}
|
||||
}
|
||||
@@ -38,7 +38,7 @@ import com.archipelago.app.ui.theme.neoRaised
|
||||
@Composable
|
||||
fun GamepadLayout(
|
||||
onKey: (String) -> Unit,
|
||||
onTwoFingerHold: () -> Unit,
|
||||
onThreeFingerHold: () -> Unit,
|
||||
modifier: Modifier = Modifier,
|
||||
) {
|
||||
val surface = Neo.surface()
|
||||
@@ -54,9 +54,9 @@ fun GamepadLayout(
|
||||
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
|
||||
if (a.size >= 3 && t == 0L) t = System.currentTimeMillis()
|
||||
if (a.size >= 3 && !fired && t > 0 && System.currentTimeMillis() - t > 500) { fired = true; onThreeFingerHold() }
|
||||
if (a.size < 3) t = 0L
|
||||
} while (ev.changes.any { it.pressed })
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,147 @@
|
||||
package com.archipelago.app.ui.components
|
||||
|
||||
import androidx.compose.animation.core.LinearEasing
|
||||
import androidx.compose.animation.core.RepeatMode
|
||||
import androidx.compose.animation.core.animateFloat
|
||||
import androidx.compose.animation.core.infiniteRepeatable
|
||||
import androidx.compose.animation.core.rememberInfiniteTransition
|
||||
import androidx.compose.animation.core.tween
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.border
|
||||
import androidx.compose.foundation.clickable
|
||||
import androidx.compose.foundation.interaction.MutableInteractionSource
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.Spacer
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.foundation.layout.height
|
||||
import androidx.compose.foundation.layout.offset
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.layout.size
|
||||
import androidx.compose.foundation.shape.CircleShape
|
||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.LaunchedEffect
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.draw.clip
|
||||
import androidx.compose.ui.draw.scale
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import androidx.compose.ui.text.font.FontWeight
|
||||
import androidx.compose.ui.text.style.TextAlign
|
||||
import androidx.compose.ui.unit.dp
|
||||
import com.archipelago.app.R
|
||||
import com.archipelago.app.ui.theme.BitcoinOrange
|
||||
import kotlinx.coroutines.delay
|
||||
|
||||
/**
|
||||
* First-launch teaching overlay for the three-finger hold gesture. Three
|
||||
* fingertip dots pulse in a "press" rhythm with an expanding ring while a
|
||||
* short caption explains what the gesture opens. Dismissed by tapping
|
||||
* anywhere (or automatically after a few seconds) — shown once, ever.
|
||||
*/
|
||||
@Composable
|
||||
fun GestureHintOverlay(onDismiss: () -> Unit) {
|
||||
// Auto-dismiss so a user who taps nothing is never stuck behind the scrim.
|
||||
LaunchedEffect(Unit) {
|
||||
delay(6500)
|
||||
onDismiss()
|
||||
}
|
||||
|
||||
val transition = rememberInfiniteTransition(label = "gesture-hint")
|
||||
// Fingertips press down together…
|
||||
val press by transition.animateFloat(
|
||||
initialValue = 1f,
|
||||
targetValue = 0.86f,
|
||||
animationSpec = infiniteRepeatable(tween(650), RepeatMode.Reverse),
|
||||
label = "press",
|
||||
)
|
||||
// …while a ring ripples outward on each press cycle.
|
||||
val ripple by transition.animateFloat(
|
||||
initialValue = 0f,
|
||||
targetValue = 1f,
|
||||
animationSpec = infiniteRepeatable(tween(1300, easing = LinearEasing)),
|
||||
label = "ripple",
|
||||
)
|
||||
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.fillMaxSize()
|
||||
.background(Color.Black.copy(alpha = 0.72f))
|
||||
.clickable(
|
||||
interactionSource = remember { MutableInteractionSource() },
|
||||
indication = null,
|
||||
onClick = onDismiss,
|
||||
),
|
||||
contentAlignment = Alignment.Center,
|
||||
) {
|
||||
Column(horizontalAlignment = Alignment.CenterHorizontally) {
|
||||
// Hand: three fingertip dots in a natural arc + ripple ring.
|
||||
Box(Modifier.size(160.dp), contentAlignment = Alignment.Center) {
|
||||
Box(
|
||||
Modifier
|
||||
.size(150.dp)
|
||||
.scale(0.4f + ripple * 0.6f)
|
||||
.border(
|
||||
2.dp,
|
||||
BitcoinOrange.copy(alpha = (1f - ripple) * 0.8f),
|
||||
CircleShape,
|
||||
),
|
||||
)
|
||||
FingerDot(x = (-44).dp, y = 14.dp, scale = press)
|
||||
FingerDot(x = 0.dp, y = (-12).dp, scale = press)
|
||||
FingerDot(x = 44.dp, y = 8.dp, scale = press)
|
||||
}
|
||||
|
||||
Spacer(Modifier.height(28.dp))
|
||||
|
||||
Text(
|
||||
text = stringResource(R.string.gesture_hint_title),
|
||||
style = MaterialTheme.typography.headlineSmall,
|
||||
fontWeight = FontWeight.Bold,
|
||||
color = Color.White,
|
||||
textAlign = TextAlign.Center,
|
||||
)
|
||||
Spacer(Modifier.height(10.dp))
|
||||
Text(
|
||||
text = stringResource(R.string.gesture_hint_body),
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
color = Color.White.copy(alpha = 0.7f),
|
||||
textAlign = TextAlign.Center,
|
||||
modifier = Modifier.padding(horizontal = 48.dp),
|
||||
)
|
||||
|
||||
Spacer(Modifier.height(32.dp))
|
||||
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.clip(RoundedCornerShape(12.dp))
|
||||
.background(Color.White.copy(alpha = 0.12f))
|
||||
.clickable(onClick = onDismiss)
|
||||
.padding(horizontal = 28.dp, vertical = 12.dp),
|
||||
) {
|
||||
Text(
|
||||
text = stringResource(R.string.gesture_hint_got_it),
|
||||
style = MaterialTheme.typography.labelLarge,
|
||||
color = Color.White,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun FingerDot(x: androidx.compose.ui.unit.Dp, y: androidx.compose.ui.unit.Dp, scale: Float) {
|
||||
Box(
|
||||
Modifier
|
||||
.offset(x = x, y = y)
|
||||
.size(26.dp)
|
||||
.scale(scale)
|
||||
.background(Color.White.copy(alpha = 0.92f), CircleShape),
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,77 @@
|
||||
package com.archipelago.app.ui.components
|
||||
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.border
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.Spacer
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.foundation.layout.height
|
||||
import androidx.compose.foundation.layout.size
|
||||
import androidx.compose.material3.CircularProgressIndicator
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.draw.clip
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.text.font.FontWeight
|
||||
import androidx.compose.ui.text.style.TextAlign
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.compose.ui.unit.sp
|
||||
import com.archipelago.app.ui.screens.PixelArtLogo
|
||||
import com.archipelago.app.ui.theme.BitcoinOrange
|
||||
import com.archipelago.app.ui.theme.SurfaceBlack
|
||||
import com.archipelago.app.ui.theme.TextMuted
|
||||
|
||||
/**
|
||||
* The branded "F*CK IPs" full-screen loader — shown whenever the app is
|
||||
* dialing the node over the mesh (relaunch race, post-scan first connect),
|
||||
* instead of an anonymous spinner. The point of the brand: what's loading
|
||||
* is a connection to a cryptographic identity, not an IP.
|
||||
*/
|
||||
@Composable
|
||||
fun MeshLoadingScreen(message: String = "Dialing your node by its key — no IPs harmed") {
|
||||
Box(
|
||||
Modifier
|
||||
.fillMaxSize()
|
||||
.background(SurfaceBlack),
|
||||
contentAlignment = Alignment.Center,
|
||||
) {
|
||||
Column(horizontalAlignment = Alignment.CenterHorizontally) {
|
||||
// The brand's circle-container logo (as on the connect screen /
|
||||
// web login): pixel-art "a" centered in a black disc.
|
||||
Box(
|
||||
Modifier
|
||||
.size(120.dp)
|
||||
.clip(androidx.compose.foundation.shape.CircleShape)
|
||||
.background(Color.Black)
|
||||
.border(
|
||||
1.dp,
|
||||
Color.White.copy(alpha = 0.14f),
|
||||
androidx.compose.foundation.shape.CircleShape,
|
||||
),
|
||||
contentAlignment = Alignment.Center,
|
||||
) {
|
||||
PixelArtLogo(Modifier.size(64.dp))
|
||||
}
|
||||
Spacer(Modifier.height(20.dp))
|
||||
Text(
|
||||
text = "F*CK IPs MESH",
|
||||
color = BitcoinOrange,
|
||||
fontSize = 18.sp,
|
||||
fontWeight = FontWeight.Bold,
|
||||
letterSpacing = 4.sp,
|
||||
)
|
||||
Spacer(Modifier.height(8.dp))
|
||||
Text(
|
||||
text = message,
|
||||
color = TextMuted,
|
||||
fontSize = 13.sp,
|
||||
textAlign = TextAlign.Center,
|
||||
)
|
||||
Spacer(Modifier.height(24.dp))
|
||||
CircularProgressIndicator(color = BitcoinOrange)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -23,6 +23,7 @@ import androidx.compose.foundation.layout.width
|
||||
import androidx.compose.foundation.shape.CircleShape
|
||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.filled.Palette
|
||||
import androidx.compose.material.icons.filled.Settings
|
||||
import androidx.compose.material3.Icon
|
||||
import androidx.compose.material3.Text
|
||||
@@ -108,6 +109,7 @@ fun NESController(
|
||||
onKey: (String) -> Unit,
|
||||
onMenu: () -> Unit,
|
||||
onPlayerToggle: () -> Unit = {},
|
||||
onToggleStyle: (() -> Unit)? = null,
|
||||
modifier: Modifier = Modifier,
|
||||
) {
|
||||
val c = paletteFor(style)
|
||||
@@ -116,7 +118,7 @@ fun NESController(
|
||||
Box(
|
||||
modifier = modifier
|
||||
.fillMaxSize()
|
||||
.twoFingerHold(onMenu)
|
||||
.threeFingerHold(onMenu)
|
||||
.padding(horizontal = 40.dp, vertical = 24.dp),
|
||||
contentAlignment = Alignment.Center,
|
||||
) {
|
||||
@@ -205,6 +207,7 @@ fun NESController(
|
||||
) {
|
||||
PlayerPill(c, playerId, onPlayerToggle)
|
||||
SettingsBtn(c, Modifier, onMenu)
|
||||
onToggleStyle?.let { StyleBtn(c, Modifier, it) }
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -431,6 +434,23 @@ fun SettingsBtn(c: NESPalette, modifier: Modifier = Modifier, onClick: () -> Uni
|
||||
}
|
||||
}
|
||||
|
||||
/** Dark/Classic style toggle — lives next to the settings gear (the menu hub
|
||||
* no longer carries it). */
|
||||
@Composable
|
||||
fun StyleBtn(c: NESPalette, modifier: Modifier = Modifier, onClick: () -> Unit) {
|
||||
var p by remember { mutableStateOf(false) }
|
||||
Box(
|
||||
modifier = modifier
|
||||
.size(48.dp)
|
||||
.clip(CircleShape)
|
||||
.background(if (p) c.capsulePress else c.capsule)
|
||||
.pointerInput(Unit) { detectTapGestures(onPress = { p = true; onClick(); tryAwaitRelease(); p = false }) },
|
||||
contentAlignment = Alignment.Center,
|
||||
) {
|
||||
Icon(Icons.Default.Palette, "Controller style", Modifier.size(26.dp), tint = c.labelMuted)
|
||||
}
|
||||
}
|
||||
|
||||
/** Player ID toggle pill (P1/P2/ALL) */
|
||||
@Composable
|
||||
fun PlayerPill(c: NESPalette, playerId: Int, onToggle: () -> Unit) {
|
||||
@@ -451,17 +471,17 @@ fun PlayerPill(c: NESPalette, playerId: Int, onToggle: () -> Unit) {
|
||||
}
|
||||
}
|
||||
|
||||
/** Two-finger hold gesture modifier */
|
||||
fun Modifier.twoFingerHold(onHold: () -> Unit) = this.pointerInput(Unit) {
|
||||
/** Three-finger hold gesture modifier (two fingers stay free for scrolling) */
|
||||
fun Modifier.threeFingerHold(onHold: () -> Unit) = this.pointerInput(Unit) {
|
||||
awaitEachGesture {
|
||||
awaitFirstDown(requireUnconsumed = false)
|
||||
var t = 0L; var fired = false
|
||||
do {
|
||||
val ev = awaitPointerEvent()
|
||||
val a = ev.changes.filter { !it.changedToUp() }
|
||||
if (a.size >= 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
|
||||
if (a.size >= 3 && t == 0L) t = System.currentTimeMillis()
|
||||
if (a.size >= 3 && !fired && t > 0 && System.currentTimeMillis() - t > 500) { fired = true; onHold() }
|
||||
if (a.size < 3) t = 0L
|
||||
} while (ev.changes.any { it.pressed })
|
||||
}
|
||||
}
|
||||
|
||||
@@ -21,20 +21,40 @@ import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.layout.size
|
||||
import androidx.compose.foundation.layout.width
|
||||
import androidx.compose.foundation.layout.widthIn
|
||||
import androidx.compose.foundation.layout.statusBarsPadding
|
||||
import androidx.compose.foundation.rememberScrollState
|
||||
import androidx.compose.foundation.verticalScroll
|
||||
import androidx.compose.material.icons.automirrored.filled.ArrowBack
|
||||
import androidx.compose.material.icons.filled.Bolt
|
||||
import androidx.compose.material.icons.filled.Dashboard
|
||||
import androidx.compose.material.icons.filled.Dns
|
||||
import androidx.compose.material.icons.filled.Groups
|
||||
import androidx.compose.material.icons.filled.Keyboard
|
||||
import androidx.compose.material.icons.filled.SportsEsports
|
||||
import androidx.compose.foundation.layout.heightIn
|
||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
import androidx.compose.foundation.text.KeyboardActions
|
||||
import androidx.compose.foundation.text.KeyboardOptions
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.filled.Close
|
||||
import androidx.compose.material.icons.filled.QrCodeScanner
|
||||
import androidx.compose.material3.Icon
|
||||
import androidx.compose.material3.OutlinedTextField
|
||||
import androidx.compose.material3.OutlinedTextFieldDefaults
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.LaunchedEffect
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.ui.platform.LocalClipboardManager
|
||||
import androidx.compose.ui.platform.LocalConfiguration
|
||||
import androidx.compose.ui.platform.LocalContext
|
||||
import androidx.compose.ui.text.AnnotatedString
|
||||
import com.archipelago.app.fips.FipsManager
|
||||
import com.archipelago.app.fips.FipsNative
|
||||
import com.archipelago.app.fips.FipsPreferences
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.draw.clip
|
||||
@@ -51,7 +71,6 @@ import androidx.compose.ui.unit.sp
|
||||
import com.archipelago.app.R
|
||||
import com.archipelago.app.data.ServerEntry
|
||||
import com.archipelago.app.ui.theme.BitcoinOrange
|
||||
import com.archipelago.app.ui.theme.ControllerStyle
|
||||
import com.archipelago.app.ui.theme.SurfaceDark
|
||||
import com.archipelago.app.ui.theme.TextMuted
|
||||
import com.archipelago.app.ui.theme.TextPrimary
|
||||
@@ -75,26 +94,29 @@ fun NESMenu(
|
||||
visible: Boolean,
|
||||
servers: List<ServerEntry>,
|
||||
activeServer: ServerEntry?,
|
||||
isGamepadMode: Boolean,
|
||||
controllerStyle: ControllerStyle,
|
||||
onDismiss: () -> Unit,
|
||||
onSelectServer: (ServerEntry) -> Unit,
|
||||
onAddServer: (ServerEntry) -> Unit,
|
||||
onScanQr: (() -> Unit)? = null,
|
||||
onEditServer: (ServerEntry, ServerEntry) -> Unit,
|
||||
onRemoveServer: (ServerEntry) -> Unit,
|
||||
onToggleMode: () -> Unit,
|
||||
onToggleStyle: () -> Unit,
|
||||
onRemote: () -> Unit,
|
||||
onKeyboard: () -> Unit,
|
||||
onBackToWebView: (() -> Unit)? = null,
|
||||
onMeshParty: (() -> Unit)? = null,
|
||||
) {
|
||||
AnimatedVisibility(visible = visible, enter = fadeIn(), exit = fadeOut()) {
|
||||
// Contained hub overlay: a centred glass panel (not full-screen) that
|
||||
// holds the card page and its sub-pages (Nodes, FIPS) and scrolls
|
||||
// inside its own bounds when content is tall. Tapping the dimmed
|
||||
// backdrop dismisses.
|
||||
Box(
|
||||
Modifier.fillMaxSize().background(Color.Black.copy(alpha = 0.7f))
|
||||
.clickable(indication = null, interactionSource = remember { MutableInteractionSource() }) { onDismiss() },
|
||||
contentAlignment = Alignment.Center,
|
||||
) {
|
||||
AnimatedVisibility(visible = visible, enter = fadeIn() + scaleIn(initialScale = 0.95f), exit = fadeOut() + scaleOut(targetScale = 0.95f)) {
|
||||
MenuPanel(servers, activeServer, isGamepadMode, controllerStyle, onDismiss, onSelectServer, onAddServer, onScanQr, onEditServer, onRemoveServer, onToggleMode, onToggleStyle, onBackToWebView)
|
||||
MenuPanel(servers, activeServer, onDismiss, onSelectServer, onAddServer, onScanQr, onEditServer, onRemoveServer, onRemote, onKeyboard, onBackToWebView, onMeshParty)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -104,17 +126,16 @@ fun NESMenu(
|
||||
private fun MenuPanel(
|
||||
servers: List<ServerEntry>,
|
||||
activeServer: ServerEntry?,
|
||||
isGamepadMode: Boolean,
|
||||
controllerStyle: ControllerStyle,
|
||||
onDismiss: () -> Unit,
|
||||
onSelectServer: (ServerEntry) -> Unit,
|
||||
onAddServer: (ServerEntry) -> Unit,
|
||||
onScanQr: (() -> Unit)?,
|
||||
onEditServer: (ServerEntry, ServerEntry) -> Unit,
|
||||
onRemoveServer: (ServerEntry) -> Unit,
|
||||
onToggleMode: () -> Unit,
|
||||
onToggleStyle: () -> Unit,
|
||||
onRemote: () -> Unit,
|
||||
onKeyboard: () -> Unit,
|
||||
onBackToWebView: (() -> Unit)?,
|
||||
onMeshParty: (() -> Unit)?,
|
||||
) {
|
||||
var showAdd by remember { mutableStateOf(false) }
|
||||
// The saved server being edited, or null when adding a new one.
|
||||
@@ -122,14 +143,15 @@ private fun MenuPanel(
|
||||
var nm by remember { mutableStateOf("") }
|
||||
var addr by remember { mutableStateOf("") }
|
||||
var pwd by remember { mutableStateOf("") }
|
||||
var https by remember { mutableStateOf(false) }
|
||||
|
||||
fun resetForm() {
|
||||
nm = ""; addr = ""; pwd = ""; showAdd = false; editing = null
|
||||
nm = ""; addr = ""; pwd = ""; https = false; showAdd = false; editing = null
|
||||
}
|
||||
|
||||
fun startEdit(server: ServerEntry) {
|
||||
editing = server
|
||||
nm = server.name; addr = server.address; pwd = server.password
|
||||
nm = server.name; addr = server.address; pwd = server.password; https = server.useHttps
|
||||
showAdd = false
|
||||
}
|
||||
|
||||
@@ -137,156 +159,381 @@ private fun MenuPanel(
|
||||
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))
|
||||
// Preserve port (compact form doesn't expose it); scheme is now editable.
|
||||
onEditServer(orig, orig.copy(address = addr, useHttps = https, password = pwd, name = nm))
|
||||
} else {
|
||||
onAddServer(ServerEntry(addr, false, password = pwd, name = nm))
|
||||
onAddServer(ServerEntry(addr, https, password = pwd, name = nm))
|
||||
}
|
||||
resetForm()
|
||||
}
|
||||
|
||||
var page by remember { mutableStateOf(HubPage.HUB) }
|
||||
|
||||
Column(
|
||||
modifier = Modifier
|
||||
.widthIn(max = 420.dp)
|
||||
.fillMaxWidth()
|
||||
.padding(horizontal = 20.dp)
|
||||
// Cap height just short of the full screen; the panel wraps short
|
||||
// content and only scrolls in the rare case it outgrows this.
|
||||
.heightIn(max = (LocalConfiguration.current.screenHeightDp * 0.92f).dp)
|
||||
.clip(RoundedCornerShape(PANEL_R))
|
||||
.background(PanelBg)
|
||||
.background(PanelBg.copy(alpha = 0.86f))
|
||||
.border(1.dp, PanelBorder, RoundedCornerShape(PANEL_R))
|
||||
.clickable(indication = null, interactionSource = remember { MutableInteractionSource() }) {}
|
||||
.padding(22.dp),
|
||||
verticalArrangement = Arrangement.spacedBy(10.dp),
|
||||
.verticalScroll(rememberScrollState())
|
||||
.padding(20.dp),
|
||||
verticalArrangement = Arrangement.spacedBy(12.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,
|
||||
) {
|
||||
// Header: back (on sub-pages) or title, and a close on the hub.
|
||||
Row(
|
||||
Modifier.fillMaxWidth(),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
horizontalArrangement = Arrangement.SpaceBetween,
|
||||
) {
|
||||
if (page == HubPage.HUB) {
|
||||
Text("Menu", color = TextPrimary, fontSize = 20.sp, fontWeight = FontWeight.SemiBold, letterSpacing = 2.sp)
|
||||
IconRound(Icons.Default.Close, "Close") { onDismiss() }
|
||||
} else {
|
||||
Row(verticalAlignment = Alignment.CenterVertically) {
|
||||
IconRound(Icons.AutoMirrored.Filled.ArrowBack, "Back") { resetForm(); page = HubPage.HUB }
|
||||
Spacer(Modifier.width(12.dp))
|
||||
Text(
|
||||
if (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),
|
||||
if (page == HubPage.NODES) "Nodes" else "FIPS Mesh",
|
||||
color = TextPrimary, fontSize = 20.sp, fontWeight = FontWeight.SemiBold, letterSpacing = 1.sp,
|
||||
)
|
||||
}
|
||||
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) }
|
||||
}
|
||||
IconRound(Icons.Default.Close, "Close") { onDismiss() }
|
||||
}
|
||||
} else {
|
||||
Row(Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.spacedBy(10.dp)) {
|
||||
Box(Modifier.weight(1f)) {
|
||||
MenuItem(label = "Add Server", labelColor = BitcoinOrange, onClick = { showAdd = true })
|
||||
}
|
||||
Spacer(Modifier.height(4.dp))
|
||||
|
||||
when (page) {
|
||||
HubPage.HUB -> {
|
||||
// Card page — one card per destination. Dashboard first: it's a
|
||||
// peer of the others so three-finger → hub → Dashboard returns
|
||||
// to the node UI, same shape as every other option.
|
||||
if (onBackToWebView != null) {
|
||||
HubCard(Icons.Default.Dashboard, "Dashboard", "The node's web interface") { onBackToWebView() }
|
||||
}
|
||||
if (onScanQr != null) {
|
||||
// Add server by scanning the node's pairing QR
|
||||
Box(
|
||||
HubCard(Icons.Default.SportsEsports, "Remote", "Game controller for the node") { onRemote() }
|
||||
HubCard(Icons.Default.Keyboard, "Keyboard", "Type into the node") { onKeyboard() }
|
||||
HubCard(Icons.Default.Dns, "Nodes", activeServer?.displayName() ?: "Add or switch servers") {
|
||||
page = HubPage.NODES
|
||||
}
|
||||
if (FipsNative.available) {
|
||||
HubCard(Icons.Default.Bolt, "FIPS Mesh", "Mesh identity & status") { page = HubPage.FIPS }
|
||||
}
|
||||
if (onMeshParty != null) {
|
||||
HubCard(Icons.Default.Groups, "Mesh Party", "Phone-to-phone chat & beam") { onMeshParty() }
|
||||
}
|
||||
// Dark/Classic style lives on the remote/keyboard screen next to
|
||||
// the settings button — not here.
|
||||
}
|
||||
|
||||
HubPage.NODES -> {
|
||||
servers.forEach { server ->
|
||||
val active = server.serialize() == activeServer?.serialize()
|
||||
MenuItem(
|
||||
label = server.displayName(),
|
||||
selected = active,
|
||||
onClick = { onSelectServer(server) },
|
||||
onEdit = { startEdit(server) },
|
||||
onRemove = { onRemoveServer(server) },
|
||||
)
|
||||
}
|
||||
if (servers.isEmpty()) {
|
||||
Text("No servers", color = TextMuted, fontSize = 14.sp, modifier = Modifier.padding(vertical = 4.dp))
|
||||
}
|
||||
|
||||
if (showAdd || editing != null) {
|
||||
Column(
|
||||
Modifier
|
||||
.size(ROW_H)
|
||||
.fillMaxWidth()
|
||||
.clip(RoundedCornerShape(ROW_R))
|
||||
.background(RowBg)
|
||||
.background(FieldBg)
|
||||
.border(1.dp, RowBorder, RoundedCornerShape(ROW_R))
|
||||
.clickable { onScanQr() },
|
||||
contentAlignment = Alignment.Center,
|
||||
.padding(12.dp),
|
||||
verticalArrangement = Arrangement.spacedBy(8.dp),
|
||||
) {
|
||||
Icon(
|
||||
Icons.Default.QrCodeScanner,
|
||||
contentDescription = stringResource(R.string.add_server_qr),
|
||||
tint = BitcoinOrange,
|
||||
modifier = Modifier.size(24.dp),
|
||||
Row(
|
||||
Modifier.fillMaxWidth(),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
horizontalArrangement = Arrangement.SpaceBetween,
|
||||
) {
|
||||
Text(
|
||||
if (editing != null) "Edit Server" else "Add Server",
|
||||
color = TextMuted, fontSize = 13.sp, letterSpacing = 1.sp, fontWeight = FontWeight.Medium,
|
||||
)
|
||||
Text(
|
||||
"Cancel", color = TextMuted, fontSize = 13.sp,
|
||||
modifier = Modifier.clickable { resetForm() }.padding(start = 8.dp),
|
||||
)
|
||||
}
|
||||
GlassField(
|
||||
value = nm, onValueChange = { nm = it },
|
||||
placeholder = "Name (optional)",
|
||||
keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Text, imeAction = ImeAction.Next),
|
||||
)
|
||||
GlassField(
|
||||
value = addr, onValueChange = { addr = it.trim() },
|
||||
placeholder = "192.168.1.100",
|
||||
keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Uri, imeAction = ImeAction.Next),
|
||||
)
|
||||
Row(horizontalArrangement = Arrangement.spacedBy(8.dp), verticalAlignment = Alignment.CenterVertically) {
|
||||
GlassField(
|
||||
value = pwd, onValueChange = { pwd = it },
|
||||
placeholder = "Password",
|
||||
modifier = Modifier.weight(1f),
|
||||
visualTransformation = PasswordVisualTransformation(),
|
||||
keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Password, imeAction = ImeAction.Go),
|
||||
keyboardActions = KeyboardActions(onGo = { submit() }),
|
||||
)
|
||||
Box(
|
||||
Modifier.size(FIELD_H).clip(RoundedCornerShape(12.dp)).background(BitcoinOrange.copy(alpha = 0.15f))
|
||||
.border(1.dp, BitcoinOrange.copy(alpha = 0.4f), RoundedCornerShape(12.dp))
|
||||
.clickable { submit() },
|
||||
contentAlignment = Alignment.Center,
|
||||
) { Text("OK", color = BitcoinOrange, fontSize = 14.sp, fontWeight = FontWeight.Bold) }
|
||||
}
|
||||
// HTTPS scheme toggle (available on both add and edit).
|
||||
Row(
|
||||
Modifier
|
||||
.fillMaxWidth()
|
||||
.clip(RoundedCornerShape(ROW_R))
|
||||
.clickable { https = !https }
|
||||
.padding(vertical = 2.dp),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
horizontalArrangement = Arrangement.SpaceBetween,
|
||||
) {
|
||||
Text("Use HTTPS", color = TextMuted, fontSize = 13.sp)
|
||||
Box(
|
||||
Modifier
|
||||
.width(46.dp).height(26.dp)
|
||||
.clip(RoundedCornerShape(13.dp))
|
||||
.background(if (https) BitcoinOrange.copy(alpha = 0.9f) else RowBg)
|
||||
.border(1.dp, if (https) BitcoinOrange else RowBorder, RoundedCornerShape(13.dp)),
|
||||
contentAlignment = if (https) Alignment.CenterEnd else Alignment.CenterStart,
|
||||
) {
|
||||
Box(
|
||||
Modifier
|
||||
.padding(horizontal = 3.dp)
|
||||
.size(20.dp)
|
||||
.clip(RoundedCornerShape(10.dp))
|
||||
.background(if (https) Color.White else TextMuted),
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
} else {
|
||||
Row(Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.spacedBy(10.dp)) {
|
||||
Box(Modifier.weight(1f)) {
|
||||
MenuItem(label = "Add Server", labelColor = BitcoinOrange, onClick = { showAdd = true })
|
||||
}
|
||||
if (onScanQr != null) {
|
||||
Box(
|
||||
Modifier
|
||||
.size(ROW_H)
|
||||
.clip(RoundedCornerShape(ROW_R))
|
||||
.background(RowBg)
|
||||
.border(1.dp, RowBorder, RoundedCornerShape(ROW_R))
|
||||
.clickable { onScanQr() },
|
||||
contentAlignment = Alignment.Center,
|
||||
) {
|
||||
Icon(
|
||||
Icons.Default.QrCodeScanner,
|
||||
contentDescription = stringResource(R.string.add_server_qr),
|
||||
tint = BitcoinOrange,
|
||||
modifier = Modifier.size(24.dp),
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
HubPage.FIPS -> {
|
||||
FipsSection(embedded = true)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Spacer(Modifier.height(2.dp))
|
||||
Box(Modifier.fillMaxWidth().height(1.dp).background(PanelBorder))
|
||||
Spacer(Modifier.height(2.dp))
|
||||
private enum class HubPage { HUB, NODES, FIPS }
|
||||
|
||||
// Mode toggle
|
||||
MenuItem(
|
||||
label = if (isGamepadMode) "Switch to Keyboard" else "Switch to Gamepad",
|
||||
onClick = onToggleMode,
|
||||
/** Big tappable destination card for the hub page: icon + title + subtitle. */
|
||||
@Composable
|
||||
private fun HubCard(
|
||||
icon: androidx.compose.ui.graphics.vector.ImageVector,
|
||||
title: String,
|
||||
subtitle: String,
|
||||
onClick: () -> Unit,
|
||||
) {
|
||||
Row(
|
||||
Modifier
|
||||
.fillMaxWidth()
|
||||
.clip(RoundedCornerShape(ROW_R))
|
||||
.background(RowBg)
|
||||
.border(1.dp, RowBorder, RoundedCornerShape(ROW_R))
|
||||
.clickable { onClick() }
|
||||
.padding(16.dp),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
horizontalArrangement = Arrangement.spacedBy(14.dp),
|
||||
) {
|
||||
Box(
|
||||
Modifier.size(40.dp).clip(RoundedCornerShape(12.dp)).background(BitcoinOrange.copy(alpha = 0.14f)),
|
||||
contentAlignment = Alignment.Center,
|
||||
) {
|
||||
Icon(icon, contentDescription = title, tint = BitcoinOrange, modifier = Modifier.size(22.dp))
|
||||
}
|
||||
Column(Modifier.weight(1f)) {
|
||||
Text(title, color = TextPrimary, fontSize = 16.sp, fontWeight = FontWeight.SemiBold)
|
||||
Text(subtitle, color = TextMuted, fontSize = 12.sp, maxLines = 1)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** Small circular icon button used in the hub header. */
|
||||
@Composable
|
||||
private fun IconRound(
|
||||
icon: androidx.compose.ui.graphics.vector.ImageVector,
|
||||
desc: String,
|
||||
onClick: () -> Unit,
|
||||
) {
|
||||
Box(
|
||||
Modifier
|
||||
.size(40.dp)
|
||||
.clip(RoundedCornerShape(20.dp))
|
||||
.background(RowBg)
|
||||
.border(1.dp, RowBorder, RoundedCornerShape(20.dp))
|
||||
.clickable { onClick() },
|
||||
contentAlignment = Alignment.Center,
|
||||
) {
|
||||
Icon(icon, contentDescription = desc, tint = TextPrimary, modifier = Modifier.size(20.dp))
|
||||
}
|
||||
}
|
||||
|
||||
/** Snapshot of the phone's mesh identity + state for the FIPS menu section. */
|
||||
private data class FipsInfo(
|
||||
val available: Boolean,
|
||||
val running: Boolean,
|
||||
val npub: String,
|
||||
val meshAddress: String,
|
||||
val peerCount: Int,
|
||||
)
|
||||
|
||||
/**
|
||||
* FIPS mesh oversight: shows what the phone's embedded mesh node is doing —
|
||||
* running state, its mesh identity (npub), its mesh address (fd…ULA), how
|
||||
* many peers/anchors it's configured with — and a one-tap Reconnect that
|
||||
* re-homes the mesh (also the manual fix if a network handoff ever misses).
|
||||
* Collapsed by default so the menu stays compact.
|
||||
*/
|
||||
@Composable
|
||||
private fun FipsSection(embedded: Boolean = false) {
|
||||
if (!FipsNative.available) return
|
||||
val context = LocalContext.current
|
||||
val clipboard = LocalClipboardManager.current
|
||||
var expanded by remember { mutableStateOf(embedded) }
|
||||
var info by remember { mutableStateOf<FipsInfo?>(null) }
|
||||
|
||||
// Load identity/state when the section opens (cheap DataStore + JSON read).
|
||||
LaunchedEffect(expanded) {
|
||||
if (expanded && info == null) {
|
||||
val prefs = FipsPreferences(context)
|
||||
val id = prefs.identity()
|
||||
val peers = runCatching {
|
||||
org.json.JSONArray(prefs.peersJson()).length()
|
||||
}.getOrDefault(0)
|
||||
info = FipsInfo(
|
||||
available = true,
|
||||
running = FipsNative.isRunning(),
|
||||
npub = id?.npub.orEmpty(),
|
||||
meshAddress = id?.address.orEmpty(),
|
||||
peerCount = peers,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
Column(Modifier.fillMaxWidth()) {
|
||||
// Embedded in the hub's FIPS sub-page the header row would duplicate
|
||||
// the page title, so only the standalone (collapsible) form shows it.
|
||||
if (!embedded) MenuItem(
|
||||
label = "FIPS Mesh",
|
||||
labelColor = BitcoinOrange,
|
||||
onClick = { expanded = !expanded },
|
||||
)
|
||||
if (expanded) {
|
||||
val i = info
|
||||
Column(
|
||||
Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(top = 6.dp)
|
||||
.clip(RoundedCornerShape(ROW_R))
|
||||
.background(FieldBg)
|
||||
.border(1.dp, RowBorder, RoundedCornerShape(ROW_R))
|
||||
.padding(14.dp),
|
||||
verticalArrangement = Arrangement.spacedBy(10.dp),
|
||||
) {
|
||||
if (i == null) {
|
||||
Text("Loading…", color = TextMuted, fontSize = 13.sp)
|
||||
} else {
|
||||
FipsRow("Status", if (i.running) "Connected" else "Stopped",
|
||||
valueColor = if (i.running) BitcoinOrange else TextMuted)
|
||||
if (i.meshAddress.isNotBlank()) {
|
||||
FipsRow("Mesh address", i.meshAddress, mono = true,
|
||||
onCopy = { clipboard.setText(AnnotatedString(i.meshAddress)) })
|
||||
}
|
||||
if (i.npub.isNotBlank()) {
|
||||
FipsRow("Identity (npub)", i.npub, mono = true,
|
||||
onCopy = { clipboard.setText(AnnotatedString(i.npub)) })
|
||||
}
|
||||
FipsRow("Peers & anchors", i.peerCount.toString())
|
||||
Text(
|
||||
"Your node reaches this phone over the mesh by its npub — no ports opened to the internet.",
|
||||
color = TextMuted, fontSize = 11.sp,
|
||||
)
|
||||
MenuItem(
|
||||
label = "Reconnect mesh",
|
||||
labelColor = BitcoinOrange,
|
||||
onClick = {
|
||||
FipsManager.requestMeshRestart(context)
|
||||
info = null
|
||||
if (!embedded) expanded = false
|
||||
},
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Style toggle
|
||||
MenuItem(
|
||||
label = if (controllerStyle == ControllerStyle.CLASSIC) "Style: Classic" else "Style: Dark",
|
||||
onClick = onToggleStyle,
|
||||
@Composable
|
||||
private fun FipsRow(
|
||||
label: String,
|
||||
value: String,
|
||||
valueColor: Color = TextPrimary,
|
||||
mono: Boolean = false,
|
||||
onCopy: (() -> Unit)? = null,
|
||||
) {
|
||||
Row(
|
||||
Modifier
|
||||
.fillMaxWidth()
|
||||
.then(if (onCopy != null) Modifier.clickable { onCopy() } else Modifier),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
horizontalArrangement = Arrangement.SpaceBetween,
|
||||
) {
|
||||
Text(label, color = TextMuted, fontSize = 12.sp, modifier = Modifier.width(120.dp))
|
||||
Text(
|
||||
value,
|
||||
color = valueColor,
|
||||
fontSize = if (mono) 11.sp else 13.sp,
|
||||
fontWeight = FontWeight.Medium,
|
||||
modifier = Modifier.weight(1f),
|
||||
textAlign = TextAlign.End,
|
||||
)
|
||||
|
||||
// Back to dashboard
|
||||
if (onBackToWebView != null) {
|
||||
MenuItem(label = "Back to Dashboard", onClick = onBackToWebView)
|
||||
if (onCopy != null) {
|
||||
Text("⧉", color = TextMuted, fontSize = 13.sp, modifier = Modifier.padding(start = 8.dp))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
+7
-2
@@ -43,6 +43,7 @@ fun NESPortraitController(
|
||||
onMouseScroll: (Int) -> Unit = { _ -> },
|
||||
onMenu: () -> Unit,
|
||||
onPlayerToggle: () -> Unit = {},
|
||||
onToggleStyle: (() -> Unit)? = null,
|
||||
) {
|
||||
val c = paletteFor(style)
|
||||
val isClassic = style == ControllerStyle.CLASSIC
|
||||
@@ -50,7 +51,7 @@ fun NESPortraitController(
|
||||
Box(
|
||||
Modifier
|
||||
.fillMaxSize()
|
||||
.twoFingerHold(onMenu)
|
||||
.threeFingerHold(onMenu)
|
||||
.padding(horizontal = 40.dp, vertical = 24.dp),
|
||||
contentAlignment = Alignment.Center,
|
||||
) {
|
||||
@@ -87,7 +88,7 @@ fun NESPortraitController(
|
||||
onMove = { dx, dy -> onMouseMove(dx, dy) },
|
||||
onClick = { onMouseClick(it) },
|
||||
onScroll = { dy -> onMouseScroll(dy) },
|
||||
onTwoFingerHold = onMenu,
|
||||
onThreeFingerHold = onMenu,
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.weight(1f),
|
||||
@@ -151,6 +152,10 @@ fun NESPortraitController(
|
||||
PlayerPill(c, playerId, onPlayerToggle)
|
||||
Spacer(Modifier.width(10.dp))
|
||||
SettingsBtn(c, Modifier, onMenu)
|
||||
onToggleStyle?.let {
|
||||
Spacer(Modifier.width(10.dp))
|
||||
StyleBtn(c, Modifier, it)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -56,7 +56,6 @@ import androidx.compose.ui.viewinterop.AndroidView
|
||||
import androidx.core.content.ContextCompat
|
||||
import com.archipelago.app.R
|
||||
import com.archipelago.app.data.PairResult
|
||||
import com.archipelago.app.data.ServerEntry
|
||||
import com.archipelago.app.data.ServerQrParser
|
||||
import com.archipelago.app.ui.screens.GlassButton
|
||||
import com.archipelago.app.ui.theme.BitcoinOrange
|
||||
@@ -82,7 +81,7 @@ import java.util.concurrent.Executors
|
||||
fun QrScannerOverlay(
|
||||
visible: Boolean,
|
||||
onDismiss: () -> Unit,
|
||||
onServerScanned: (ServerEntry) -> Unit,
|
||||
onServerScanned: (PairResult.Success) -> Unit,
|
||||
) {
|
||||
val context = LocalContext.current
|
||||
var hasPermission by remember {
|
||||
@@ -131,7 +130,7 @@ fun QrScannerOverlay(
|
||||
when (val result = ServerQrParser.parse(text)) {
|
||||
is PairResult.Success -> {
|
||||
handled = true
|
||||
onServerScanned(result.server)
|
||||
onServerScanned(result)
|
||||
}
|
||||
is PairResult.UnsupportedVersion -> hintRes = R.string.update_app_for_qr
|
||||
is PairResult.Invalid -> hintRes = R.string.invalid_pairing_qr
|
||||
@@ -218,13 +217,20 @@ fun QrScannerOverlay(
|
||||
}
|
||||
}
|
||||
|
||||
/** Shared by the pairing scanner and the wallet scan modal. */
|
||||
@Composable
|
||||
private fun CameraQrPreview(onDecoded: (String) -> Unit) {
|
||||
internal fun CameraQrPreview(onDecoded: (String) -> Unit) {
|
||||
val context = LocalContext.current
|
||||
val lifecycleOwner = LocalLifecycleOwner.current
|
||||
val currentOnDecoded by rememberUpdatedState(onDecoded)
|
||||
val previewView = remember {
|
||||
PreviewView(context).apply { scaleType = PreviewView.ScaleType.FILL_CENTER }
|
||||
PreviewView(context).apply {
|
||||
scaleType = PreviewView.ScaleType.FILL_CENTER
|
||||
// TextureView, not the SurfaceView default: SurfaceView punches a
|
||||
// hole in the window, which black-flashes inside Compose fades and
|
||||
// ignores rounded-corner clipping (wallet modal).
|
||||
implementationMode = PreviewView.ImplementationMode.COMPATIBLE
|
||||
}
|
||||
}
|
||||
|
||||
DisposableEffect(Unit) {
|
||||
@@ -232,6 +238,7 @@ private fun CameraQrPreview(onDecoded: (String) -> Unit) {
|
||||
val mainExecutor = ContextCompat.getMainExecutor(context)
|
||||
val providerFuture = ProcessCameraProvider.getInstance(context)
|
||||
var provider: ProcessCameraProvider? = null
|
||||
val focusScheduler = Executors.newSingleThreadScheduledExecutor()
|
||||
|
||||
providerFuture.addListener({
|
||||
val p = providerFuture.get()
|
||||
@@ -239,12 +246,15 @@ private fun CameraQrPreview(onDecoded: (String) -> Unit) {
|
||||
val preview = Preview.Builder().build().also {
|
||||
it.setSurfaceProvider(previewView.surfaceProvider)
|
||||
}
|
||||
// CameraX's analysis default is 640x480 — too few pixels per module
|
||||
// to decode a modal-sized QR at arm's length. 1280x720 more than
|
||||
// doubles the pixel density at negligible analysis cost.
|
||||
// Dense Lightning-invoice QRs need BOTH enough pixels per module and
|
||||
// sharp focus. 1280x720 + a far-focused camera (e.g. Pixel 9a's main
|
||||
// lens, which won't focus close) left dense invoices undecodable
|
||||
// while sparse address QRs still read — the "scanner doesn't pick up
|
||||
// invoices" report. 1920x1080 roughly doubles module resolution so a
|
||||
// QR held at the camera's actual focus distance still resolves.
|
||||
@Suppress("DEPRECATION")
|
||||
val analysis = ImageAnalysis.Builder()
|
||||
.setTargetResolution(android.util.Size(1280, 720))
|
||||
.setTargetResolution(android.util.Size(1920, 1080))
|
||||
.setBackpressureStrategy(ImageAnalysis.STRATEGY_KEEP_ONLY_LATEST)
|
||||
.build()
|
||||
.also {
|
||||
@@ -255,13 +265,27 @@ private fun CameraQrPreview(onDecoded: (String) -> Unit) {
|
||||
}
|
||||
try {
|
||||
p.unbindAll()
|
||||
p.bindToLifecycle(lifecycleOwner, CameraSelector.DEFAULT_BACK_CAMERA, preview, analysis)
|
||||
val cam = p.bindToLifecycle(lifecycleOwner, CameraSelector.DEFAULT_BACK_CAMERA, preview, analysis)
|
||||
// Force a centre autofocus on a repeating tick. A hand-held QR is
|
||||
// a static scene, so continuous-AF often never retriggers and the
|
||||
// lens sits at its resting (far) focus — fatal for dense codes.
|
||||
// A normalized centre point works before the view is measured.
|
||||
val point = androidx.camera.core.SurfaceOrientedMeteringPointFactory(1f, 1f)
|
||||
.createPoint(0.5f, 0.5f)
|
||||
val focusAction = androidx.camera.core.FocusMeteringAction.Builder(
|
||||
point,
|
||||
androidx.camera.core.FocusMeteringAction.FLAG_AF,
|
||||
).disableAutoCancel().build()
|
||||
focusScheduler.scheduleWithFixedDelay({
|
||||
runCatching { cam.cameraControl.startFocusAndMetering(focusAction) }
|
||||
}, 0, 2, java.util.concurrent.TimeUnit.SECONDS)
|
||||
} catch (_: Exception) {
|
||||
// Camera unavailable — the user can dismiss and enter details manually.
|
||||
}
|
||||
}, mainExecutor)
|
||||
|
||||
onDispose {
|
||||
focusScheduler.shutdownNow()
|
||||
provider?.unbindAll()
|
||||
analysisExecutor.shutdown()
|
||||
}
|
||||
@@ -283,7 +307,19 @@ private class QrCodeAnalyzer(private val onDecoded: (String) -> Unit) : ImageAna
|
||||
)
|
||||
}
|
||||
|
||||
private var lastAttempt = 0L
|
||||
|
||||
override fun analyze(image: ImageProxy) {
|
||||
// Decode ~7x/s, not on every frame: TRY_HARDER (plus the inverted
|
||||
// retry) pegs a core when run at camera rate, and that CPU contention
|
||||
// is what made the preview itself stutter. KEEP_ONLY_LATEST means the
|
||||
// frames skipped here are simply dropped, so decodes stay current.
|
||||
val now = System.currentTimeMillis()
|
||||
if (now - lastAttempt < 140) {
|
||||
image.close()
|
||||
return
|
||||
}
|
||||
lastAttempt = now
|
||||
try {
|
||||
val plane = image.planes[0]
|
||||
val buffer = plane.buffer
|
||||
|
||||
@@ -32,7 +32,7 @@ fun Trackpad(
|
||||
onMove: (dx: Int, dy: Int) -> Unit,
|
||||
onClick: (button: Int) -> Unit,
|
||||
onScroll: (dy: Int) -> Unit,
|
||||
onTwoFingerHold: () -> Unit,
|
||||
onThreeFingerHold: () -> Unit,
|
||||
modifier: Modifier = Modifier,
|
||||
) {
|
||||
var fingers by remember { mutableIntStateOf(0) }
|
||||
@@ -53,7 +53,7 @@ fun Trackpad(
|
||||
val t0 = System.currentTimeMillis()
|
||||
var maxPtrs = 1
|
||||
var holdFired = false
|
||||
var twoStart = 0L
|
||||
var threeStart = 0L
|
||||
var scrollAcc = 0f
|
||||
fingers = 1
|
||||
|
||||
@@ -64,19 +64,24 @@ fun Trackpad(
|
||||
fingers = active.size
|
||||
|
||||
when {
|
||||
active.size >= 2 -> {
|
||||
if (twoStart == 0L) twoStart = System.currentTimeMillis()
|
||||
if (!holdFired && System.currentTimeMillis() - twoStart > 500) {
|
||||
// Three fingers = hold for menu; two = scroll. Kept
|
||||
// on separate counts so a long two-finger scroll can
|
||||
// never fire the menu mid-gesture.
|
||||
active.size >= 3 -> {
|
||||
if (threeStart == 0L) threeStart = System.currentTimeMillis()
|
||||
if (!holdFired && System.currentTimeMillis() - threeStart > 500) {
|
||||
holdFired = true
|
||||
onTwoFingerHold()
|
||||
onThreeFingerHold()
|
||||
}
|
||||
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 == 2 -> {
|
||||
threeStart = 0L
|
||||
val dy = active.map { it.positionChange().y }.average().toFloat()
|
||||
scrollAcc += dy
|
||||
if (kotlin.math.abs(scrollAcc) > 12f) {
|
||||
onScroll(if (scrollAcc > 0) 1 else -1)
|
||||
scrollAcc = 0f
|
||||
}
|
||||
ev.changes.forEach { it.consume() }
|
||||
}
|
||||
@@ -99,7 +104,11 @@ fun Trackpad(
|
||||
contentAlignment = Alignment.Center,
|
||||
) {
|
||||
Text(
|
||||
text = if (fingers >= 2) "hold for menu" else "",
|
||||
text = when {
|
||||
fingers >= 3 -> "hold for menu"
|
||||
fingers == 2 -> "scroll"
|
||||
else -> ""
|
||||
},
|
||||
style = MaterialTheme.typography.labelSmall,
|
||||
color = muted.copy(alpha = 0.4f),
|
||||
)
|
||||
|
||||
@@ -0,0 +1,294 @@
|
||||
package com.archipelago.app.ui.components
|
||||
|
||||
import android.Manifest
|
||||
import android.content.Context
|
||||
import android.content.pm.PackageManager
|
||||
import android.graphics.BitmapFactory
|
||||
import android.net.Uri
|
||||
import androidx.activity.compose.BackHandler
|
||||
import androidx.activity.compose.rememberLauncherForActivityResult
|
||||
import androidx.activity.result.contract.ActivityResultContracts
|
||||
import androidx.compose.animation.AnimatedVisibility
|
||||
import androidx.compose.animation.fadeIn
|
||||
import androidx.compose.animation.fadeOut
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.border
|
||||
import androidx.compose.foundation.clickable
|
||||
import androidx.compose.foundation.interaction.MutableInteractionSource
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.Spacer
|
||||
import androidx.compose.foundation.layout.aspectRatio
|
||||
import androidx.compose.foundation.layout.defaultMinSize
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.height
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.layout.widthIn
|
||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.filled.Close
|
||||
import androidx.compose.material3.Icon
|
||||
import androidx.compose.material3.IconButton
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.LaunchedEffect
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.draw.clip
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.platform.LocalContext
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import androidx.compose.ui.text.font.FontWeight
|
||||
import androidx.compose.ui.text.style.TextAlign
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.core.content.ContextCompat
|
||||
import com.archipelago.app.R
|
||||
import com.archipelago.app.ui.screens.GlassButton
|
||||
import com.archipelago.app.ui.theme.BitcoinOrange
|
||||
import com.google.zxing.BarcodeFormat
|
||||
import com.google.zxing.BinaryBitmap
|
||||
import com.google.zxing.DecodeHintType
|
||||
import com.google.zxing.MultiFormatReader
|
||||
import com.google.zxing.NotFoundException
|
||||
import com.google.zxing.RGBLuminanceSource
|
||||
import com.google.zxing.common.HybridBinarizer
|
||||
|
||||
/**
|
||||
* Native replacement for the web wallet's scan pane — same visual design as
|
||||
* neode-ui's WalletScanModal (dark glass card, square preview, orange
|
||||
* viewfinder, status strip) but the camera and decoding run natively, so the
|
||||
* preview doesn't lag the way getUserMedia does inside a WebView.
|
||||
*
|
||||
* Decoded text is handed back to the page ([onDecoded]) which does all the
|
||||
* detection/spend logic; the page in turn streams status lines (animated-QR
|
||||
* progress, "not recognised" errors) back in via [status] and closes the
|
||||
* modal through the JS bridge once it accepts a code.
|
||||
*/
|
||||
@Composable
|
||||
fun WalletQrScannerModal(
|
||||
visible: Boolean,
|
||||
status: Pair<String, Boolean>?, // message from the web page + isError
|
||||
onDecoded: (String) -> Unit,
|
||||
onDismiss: () -> Unit,
|
||||
) {
|
||||
val context = LocalContext.current
|
||||
var hasPermission by remember {
|
||||
mutableStateOf(
|
||||
ContextCompat.checkSelfPermission(context, Manifest.permission.CAMERA) ==
|
||||
PackageManager.PERMISSION_GRANTED
|
||||
)
|
||||
}
|
||||
val permissionLauncher = rememberLauncherForActivityResult(
|
||||
ActivityResultContracts.RequestPermission()
|
||||
) { granted -> hasPermission = granted }
|
||||
|
||||
// Local error from a failed image upload; a fresh web status replaces it.
|
||||
var uploadError by remember { mutableStateOf<String?>(null) }
|
||||
val noQrMessage = stringResource(R.string.no_qr_in_image)
|
||||
val imagePicker = rememberLauncherForActivityResult(
|
||||
ActivityResultContracts.GetContent()
|
||||
) { uri ->
|
||||
if (uri != null) {
|
||||
val decoded = decodeQrFromUri(context, uri)
|
||||
if (decoded != null) {
|
||||
uploadError = null
|
||||
onDecoded(decoded)
|
||||
} else {
|
||||
uploadError = noQrMessage
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
LaunchedEffect(visible) {
|
||||
if (visible) {
|
||||
uploadError = null
|
||||
val granted = ContextCompat.checkSelfPermission(context, Manifest.permission.CAMERA) ==
|
||||
PackageManager.PERMISSION_GRANTED
|
||||
hasPermission = granted
|
||||
if (!granted) permissionLauncher.launch(Manifest.permission.CAMERA)
|
||||
}
|
||||
}
|
||||
LaunchedEffect(status) { if (status != null) uploadError = null }
|
||||
|
||||
AnimatedVisibility(visible = visible, enter = fadeIn(), exit = fadeOut()) {
|
||||
BackHandler { onDismiss() }
|
||||
Box(
|
||||
Modifier
|
||||
.fillMaxSize()
|
||||
.background(Color.Black.copy(alpha = 0.6f))
|
||||
.clickable(
|
||||
interactionSource = remember { MutableInteractionSource() },
|
||||
indication = null,
|
||||
onClick = onDismiss,
|
||||
),
|
||||
contentAlignment = Alignment.Center,
|
||||
) {
|
||||
Column(
|
||||
Modifier
|
||||
.padding(16.dp)
|
||||
.widthIn(max = 420.dp)
|
||||
.fillMaxWidth()
|
||||
.clip(RoundedCornerShape(24.dp))
|
||||
.background(Color(0xF212151C))
|
||||
.border(1.dp, Color.White.copy(alpha = 0.10f), RoundedCornerShape(24.dp))
|
||||
.clickable(
|
||||
interactionSource = remember { MutableInteractionSource() },
|
||||
indication = null,
|
||||
onClick = {}, // swallow — only the scrim dismisses
|
||||
)
|
||||
.padding(24.dp),
|
||||
) {
|
||||
// Header — mirrors the web modal's title row
|
||||
Row(
|
||||
Modifier.fillMaxWidth(),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
horizontalArrangement = Arrangement.SpaceBetween,
|
||||
) {
|
||||
Text(
|
||||
text = stringResource(R.string.scan_to_send),
|
||||
style = MaterialTheme.typography.titleLarge,
|
||||
fontWeight = FontWeight.SemiBold,
|
||||
color = Color.White,
|
||||
)
|
||||
IconButton(onClick = onDismiss) {
|
||||
Icon(
|
||||
Icons.Default.Close,
|
||||
stringResource(R.string.close),
|
||||
tint = Color.White.copy(alpha = 0.7f),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
Spacer(Modifier.height(8.dp))
|
||||
|
||||
// Square camera preview with the orange viewfinder
|
||||
Box(
|
||||
Modifier
|
||||
.fillMaxWidth()
|
||||
.aspectRatio(1f)
|
||||
.clip(RoundedCornerShape(12.dp))
|
||||
.background(Color.Black.copy(alpha = 0.4f))
|
||||
.border(1.dp, Color.White.copy(alpha = 0.10f), RoundedCornerShape(12.dp)),
|
||||
contentAlignment = Alignment.Center,
|
||||
) {
|
||||
if (hasPermission) {
|
||||
// Throttle repeat frames: a static QR decodes ~20x/s but
|
||||
// the page only needs one; animated QRs still stream
|
||||
// because each frame's text differs.
|
||||
var lastText by remember { mutableStateOf("") }
|
||||
var lastSentAt by remember { mutableStateOf(0L) }
|
||||
CameraQrPreview(onDecoded = { text ->
|
||||
val now = System.currentTimeMillis()
|
||||
if (text != lastText || now - lastSentAt > 250) {
|
||||
lastText = text
|
||||
lastSentAt = now
|
||||
onDecoded(text)
|
||||
}
|
||||
})
|
||||
Box(
|
||||
Modifier
|
||||
.fillMaxSize(0.62f)
|
||||
.border(
|
||||
2.dp,
|
||||
BitcoinOrange.copy(alpha = 0.85f),
|
||||
RoundedCornerShape(16.dp),
|
||||
),
|
||||
)
|
||||
} else {
|
||||
Column(
|
||||
Modifier.padding(horizontal = 24.dp),
|
||||
horizontalAlignment = Alignment.CenterHorizontally,
|
||||
verticalArrangement = Arrangement.spacedBy(12.dp),
|
||||
) {
|
||||
Text(
|
||||
text = stringResource(R.string.camera_permission_needed),
|
||||
color = Color.White.copy(alpha = 0.7f),
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
textAlign = TextAlign.Center,
|
||||
)
|
||||
GlassButton(
|
||||
text = stringResource(R.string.grant_camera_access),
|
||||
onClick = { permissionLauncher.launch(Manifest.permission.CAMERA) },
|
||||
modifier = Modifier.fillMaxWidth().height(48.dp),
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Spacer(Modifier.height(16.dp))
|
||||
|
||||
// Status strip — same slot the web modal uses for hints/errors
|
||||
val message = uploadError ?: status?.first
|
||||
val isError = uploadError != null || status?.second == true
|
||||
Box(
|
||||
Modifier
|
||||
.fillMaxWidth()
|
||||
.clip(RoundedCornerShape(8.dp))
|
||||
.background(Color.White.copy(alpha = 0.05f))
|
||||
.padding(12.dp)
|
||||
.defaultMinSize(minHeight = 24.dp),
|
||||
contentAlignment = Alignment.Center,
|
||||
) {
|
||||
Text(
|
||||
text = message?.takeIf { it.isNotBlank() }
|
||||
?: stringResource(R.string.scan_wallet_hint),
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = if (isError) Color(0xFFF87171) else Color.White.copy(alpha = 0.6f),
|
||||
textAlign = TextAlign.Center,
|
||||
)
|
||||
}
|
||||
|
||||
Spacer(Modifier.height(16.dp))
|
||||
|
||||
GlassButton(
|
||||
text = stringResource(R.string.upload_qr_image),
|
||||
onClick = { imagePicker.launch("image/*") },
|
||||
modifier = Modifier.fillMaxWidth().height(48.dp),
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** Decode a QR from a picked image, downsampled so huge photos stay cheap. */
|
||||
private fun decodeQrFromUri(context: Context, uri: Uri): String? {
|
||||
return try {
|
||||
val resolver = context.contentResolver
|
||||
val bounds = BitmapFactory.Options().apply { inJustDecodeBounds = true }
|
||||
resolver.openInputStream(uri)?.use { BitmapFactory.decodeStream(it, null, bounds) }
|
||||
var sample = 1
|
||||
val maxDim = maxOf(bounds.outWidth, bounds.outHeight)
|
||||
while (maxDim / (sample * 2) >= 1600) sample *= 2
|
||||
val opts = BitmapFactory.Options().apply { inSampleSize = sample }
|
||||
val bmp = resolver.openInputStream(uri)?.use { BitmapFactory.decodeStream(it, null, opts) }
|
||||
?: return null
|
||||
val pixels = IntArray(bmp.width * bmp.height)
|
||||
bmp.getPixels(pixels, 0, bmp.width, 0, 0, bmp.width, bmp.height)
|
||||
val source = RGBLuminanceSource(bmp.width, bmp.height, pixels)
|
||||
val reader = MultiFormatReader().apply {
|
||||
setHints(
|
||||
mapOf(
|
||||
DecodeHintType.POSSIBLE_FORMATS to listOf(BarcodeFormat.QR_CODE),
|
||||
DecodeHintType.TRY_HARDER to true,
|
||||
)
|
||||
)
|
||||
}
|
||||
try {
|
||||
reader.decodeWithState(BinaryBitmap(HybridBinarizer(source))).text
|
||||
} catch (_: NotFoundException) {
|
||||
// Light-on-dark QRs (dark-themed wallets) decode inverted.
|
||||
reader.reset()
|
||||
reader.decodeWithState(BinaryBitmap(HybridBinarizer(source.invert()))).text
|
||||
}
|
||||
} catch (_: Exception) {
|
||||
null
|
||||
}
|
||||
}
|
||||
@@ -1,5 +1,8 @@
|
||||
package com.archipelago.app.ui.navigation
|
||||
|
||||
import android.net.VpnService
|
||||
import androidx.activity.compose.rememberLauncherForActivityResult
|
||||
import androidx.activity.result.contract.ActivityResultContracts
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.LaunchedEffect
|
||||
import androidx.compose.runtime.collectAsState
|
||||
@@ -9,14 +12,19 @@ import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.rememberCoroutineScope
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.ui.platform.LocalContext
|
||||
import androidx.navigation.NavType
|
||||
import androidx.navigation.compose.NavHost
|
||||
import androidx.navigation.compose.composable
|
||||
import androidx.navigation.compose.rememberNavController
|
||||
import androidx.navigation.navArgument
|
||||
import com.archipelago.app.data.PairResult
|
||||
import com.archipelago.app.data.ServerEntry
|
||||
import com.archipelago.app.data.ServerPreferences
|
||||
import com.archipelago.app.data.ServerQrParser
|
||||
import com.archipelago.app.fips.FipsManager
|
||||
import com.archipelago.app.ui.screens.FlareScreen
|
||||
import com.archipelago.app.ui.screens.IntroScreen
|
||||
import com.archipelago.app.ui.screens.PartyScreen
|
||||
import com.archipelago.app.ui.screens.RemoteInputScreen
|
||||
import com.archipelago.app.ui.screens.ServerConnectScreen
|
||||
import com.archipelago.app.ui.screens.WebViewScreen
|
||||
@@ -27,6 +35,8 @@ object Routes {
|
||||
const val SERVER_CONNECT = "server_connect"
|
||||
const val WEB_VIEW = "web_view"
|
||||
const val REMOTE_INPUT = "remote_input"
|
||||
const val MESH_PARTY = "mesh_party"
|
||||
const val FLARE = "flare"
|
||||
}
|
||||
|
||||
@Composable
|
||||
@@ -46,6 +56,34 @@ fun AppNavHost(
|
||||
// connect form so the user lands on the password prompt for that server.
|
||||
var pairPrefill by remember { mutableStateOf<ServerEntry?>(null) }
|
||||
|
||||
// Mesh tunnel: Android's VPN consent dialog is the single unavoidable
|
||||
// interaction — it can only be launched from an Activity, so pairing
|
||||
// paths raise FipsManager.consentNeeded and it is handled here, once.
|
||||
val consentNeeded by FipsManager.consentNeeded.collectAsState()
|
||||
val vpnConsentLauncher = rememberLauncherForActivityResult(
|
||||
ActivityResultContracts.StartActivityForResult()
|
||||
) { result ->
|
||||
FipsManager.consentHandled()
|
||||
if (result.resultCode == android.app.Activity.RESULT_OK) {
|
||||
FipsManager.startService(context)
|
||||
}
|
||||
}
|
||||
LaunchedEffect(consentNeeded) {
|
||||
if (!consentNeeded) return@LaunchedEffect
|
||||
val consentIntent = VpnService.prepare(context)
|
||||
if (consentIntent == null) {
|
||||
FipsManager.consentHandled()
|
||||
FipsManager.startService(context)
|
||||
} else {
|
||||
vpnConsentLauncher.launch(consentIntent)
|
||||
}
|
||||
}
|
||||
|
||||
// Paired + previously consented → the mesh comes back silently on launch.
|
||||
LaunchedEffect(Unit) {
|
||||
FipsManager.autoStartIfReady(context)
|
||||
}
|
||||
|
||||
if (introSeen == null) return
|
||||
|
||||
// Declared after the introSeen gate so it can't fire before the NavHost
|
||||
@@ -58,6 +96,7 @@ fun AppNavHost(
|
||||
// Pairing implies the app is installed and in use — skip the intro.
|
||||
prefs.markIntroSeen()
|
||||
val merged = prefs.upsertServer(result.server)
|
||||
FipsManager.registerNode(context, result.fips, merged.displayName())
|
||||
if (merged.password.isNotBlank()) {
|
||||
// Demo flow: password came with the link — connect in one step.
|
||||
prefs.setActiveServer(merged)
|
||||
@@ -89,6 +128,9 @@ fun AppNavHost(
|
||||
) {
|
||||
composable(Routes.INTRO) {
|
||||
IntroScreen(
|
||||
onMeshParty = {
|
||||
navController.navigate(Routes.MESH_PARTY)
|
||||
},
|
||||
onContinue = {
|
||||
scope.launch {
|
||||
prefs.markIntroSeen()
|
||||
@@ -125,6 +167,7 @@ fun AppNavHost(
|
||||
WebViewScreen(
|
||||
serverUrl = server.toUrl(),
|
||||
serverPassword = server.password,
|
||||
meshFallbackUrl = server.toMeshUrl(),
|
||||
onDisconnect = {
|
||||
scope.launch {
|
||||
prefs.clearActiveServer()
|
||||
@@ -136,15 +179,46 @@ fun AppNavHost(
|
||||
onRemoteInput = {
|
||||
navController.navigate(Routes.REMOTE_INPUT)
|
||||
},
|
||||
onRemoteKeyboard = {
|
||||
navController.navigate("${Routes.REMOTE_INPUT}?keyboard=true")
|
||||
},
|
||||
onMeshParty = {
|
||||
navController.navigate(Routes.MESH_PARTY)
|
||||
},
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
composable(Routes.REMOTE_INPUT) {
|
||||
composable(
|
||||
"${Routes.REMOTE_INPUT}?keyboard={keyboard}",
|
||||
arguments = listOf(
|
||||
navArgument("keyboard") {
|
||||
type = NavType.BoolType
|
||||
defaultValue = false
|
||||
},
|
||||
),
|
||||
) { entry ->
|
||||
RemoteInputScreen(
|
||||
onBack = {
|
||||
navController.popBackStack()
|
||||
},
|
||||
onMeshParty = {
|
||||
navController.navigate(Routes.MESH_PARTY)
|
||||
},
|
||||
startInKeyboard = entry.arguments?.getBoolean("keyboard") == true,
|
||||
)
|
||||
}
|
||||
|
||||
composable(Routes.MESH_PARTY) {
|
||||
PartyScreen(
|
||||
onBack = { navController.popBackStack() },
|
||||
onOpenChat = { navController.navigate(Routes.FLARE) },
|
||||
)
|
||||
}
|
||||
|
||||
composable(Routes.FLARE) {
|
||||
FlareScreen(
|
||||
onBack = { navController.popBackStack() },
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,359 @@
|
||||
package com.archipelago.app.ui.screens
|
||||
|
||||
import android.graphics.Bitmap
|
||||
import android.graphics.BitmapFactory
|
||||
import android.net.Uri
|
||||
import androidx.activity.compose.BackHandler
|
||||
import androidx.activity.compose.rememberLauncherForActivityResult
|
||||
import androidx.activity.result.contract.ActivityResultContracts
|
||||
import androidx.compose.foundation.Image
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.border
|
||||
import androidx.compose.foundation.clickable
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.Spacer
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.height
|
||||
import androidx.compose.foundation.layout.imePadding
|
||||
import androidx.compose.foundation.layout.navigationBarsPadding
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.layout.size
|
||||
import androidx.compose.foundation.layout.statusBarsPadding
|
||||
import androidx.compose.foundation.layout.widthIn
|
||||
import androidx.compose.foundation.lazy.LazyColumn
|
||||
import androidx.compose.foundation.lazy.items
|
||||
import androidx.compose.foundation.lazy.rememberLazyListState
|
||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
import androidx.compose.foundation.text.KeyboardActions
|
||||
import androidx.compose.foundation.text.KeyboardOptions
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.filled.Image
|
||||
import androidx.compose.material3.Icon
|
||||
import androidx.compose.material3.OutlinedTextField
|
||||
import androidx.compose.material3.OutlinedTextFieldDefaults
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.LaunchedEffect
|
||||
import androidx.compose.runtime.collectAsState
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.rememberCoroutineScope
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.draw.clip
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.graphics.asImageBitmap
|
||||
import androidx.compose.ui.layout.ContentScale
|
||||
import androidx.compose.ui.platform.LocalContext
|
||||
import androidx.compose.ui.text.TextStyle
|
||||
import androidx.compose.ui.text.font.FontWeight
|
||||
import androidx.compose.ui.text.input.ImeAction
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.compose.ui.unit.sp
|
||||
import com.archipelago.app.fips.FipsManager
|
||||
import com.archipelago.app.fips.FipsNative
|
||||
import com.archipelago.app.fips.FipsPreferences
|
||||
import com.archipelago.app.fips.FlareClient
|
||||
import com.archipelago.app.fips.FlareMessage
|
||||
import com.archipelago.app.fips.FlareStore
|
||||
import com.archipelago.app.fips.PartyPeer
|
||||
import com.archipelago.app.ui.theme.BitcoinOrange
|
||||
import com.archipelago.app.ui.theme.SurfaceDark
|
||||
import com.archipelago.app.ui.theme.TextMuted
|
||||
import com.archipelago.app.ui.theme.TextPrimary
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.launch
|
||||
import kotlinx.coroutines.withContext
|
||||
import java.io.ByteArrayOutputStream
|
||||
import java.io.File
|
||||
import java.util.UUID
|
||||
|
||||
private val BubbleTheirs = Color.White.copy(alpha = 0.07f)
|
||||
private val BubbleBorder = Color.White.copy(alpha = 0.08f)
|
||||
|
||||
/**
|
||||
* Flare — phone↔phone chat and photo beam over the FIPS mesh. Every byte is
|
||||
* E2E encrypted by the mesh layer and addressed by npub; whether it travels
|
||||
* via a public anchor (5G) or a direct hotspot link is invisible up here —
|
||||
* which is the entire point.
|
||||
*/
|
||||
@Composable
|
||||
fun FlareScreen(onBack: () -> Unit) {
|
||||
val context = LocalContext.current
|
||||
val prefs = remember { FipsPreferences(context) }
|
||||
val scope = rememberCoroutineScope()
|
||||
|
||||
var identity by remember { mutableStateOf<FipsNative.Identity?>(null) }
|
||||
var myName by remember { mutableStateOf("Phone") }
|
||||
val peers by prefs.partyPeersFlow.collectAsState(initial = emptyList())
|
||||
var selectedNpub by remember { mutableStateOf<String?>(null) }
|
||||
val allMessages by FlareStore.messages.collectAsState()
|
||||
var draft by remember { mutableStateOf("") }
|
||||
|
||||
LaunchedEffect(Unit) {
|
||||
identity = FipsManager.ensureIdentity(prefs)
|
||||
myName = prefs.partyName()
|
||||
}
|
||||
LaunchedEffect(peers) {
|
||||
if (selectedNpub == null || peers.none { it.npub == selectedNpub }) {
|
||||
selectedNpub = peers.firstOrNull()?.npub
|
||||
}
|
||||
}
|
||||
|
||||
val peer = peers.firstOrNull { it.npub == selectedNpub }
|
||||
val messages = allMessages.filter { it.peerNpub == selectedNpub }
|
||||
val listState = rememberLazyListState()
|
||||
LaunchedEffect(messages.size) {
|
||||
if (messages.isNotEmpty()) listState.animateScrollToItem(messages.size - 1)
|
||||
}
|
||||
|
||||
fun sendText() {
|
||||
val target = peer ?: return
|
||||
val me = identity ?: return
|
||||
val text = draft.trim()
|
||||
if (text.isEmpty()) return
|
||||
draft = ""
|
||||
val msg = FlareMessage(
|
||||
id = UUID.randomUUID().toString(),
|
||||
peerNpub = target.npub,
|
||||
fromMe = true,
|
||||
name = myName,
|
||||
text = text,
|
||||
ts = System.currentTimeMillis(),
|
||||
status = FlareMessage.Status.SENDING,
|
||||
)
|
||||
FlareStore.add(msg)
|
||||
scope.launch(Dispatchers.IO) {
|
||||
val ok = FlareClient.sendText(target, me.npub, myName, text)
|
||||
FlareStore.setStatus(msg.id, if (ok) FlareMessage.Status.SENT else FlareMessage.Status.FAILED)
|
||||
}
|
||||
}
|
||||
|
||||
fun sendPhoto(uri: Uri) {
|
||||
val target = peer ?: return
|
||||
val me = identity ?: return
|
||||
scope.launch(Dispatchers.IO) {
|
||||
val jpeg = compressPhoto(context, uri) ?: return@launch
|
||||
// Local copy so our own bubble renders the sent photo.
|
||||
val dir = File(context.cacheDir, "flare").apply { mkdirs() }
|
||||
val local = File(dir, "${UUID.randomUUID()}.jpg").apply { writeBytes(jpeg) }
|
||||
val msg = FlareMessage(
|
||||
id = UUID.randomUUID().toString(),
|
||||
peerNpub = target.npub,
|
||||
fromMe = true,
|
||||
name = myName,
|
||||
photoPath = local.absolutePath,
|
||||
ts = System.currentTimeMillis(),
|
||||
status = FlareMessage.Status.SENDING,
|
||||
)
|
||||
FlareStore.add(msg)
|
||||
val ok = FlareClient.sendPhoto(target, me.npub, myName, jpeg)
|
||||
FlareStore.setStatus(msg.id, if (ok) FlareMessage.Status.SENT else FlareMessage.Status.FAILED)
|
||||
}
|
||||
}
|
||||
|
||||
val photoPicker = rememberLauncherForActivityResult(
|
||||
ActivityResultContracts.GetContent()
|
||||
) { uri -> uri?.let { sendPhoto(it) } }
|
||||
|
||||
BackHandler { onBack() }
|
||||
|
||||
Column(
|
||||
Modifier
|
||||
.fillMaxSize()
|
||||
.background(SurfaceDark)
|
||||
.statusBarsPadding()
|
||||
.navigationBarsPadding()
|
||||
.imePadding(),
|
||||
) {
|
||||
// Header
|
||||
Row(
|
||||
Modifier.fillMaxWidth().padding(horizontal = 16.dp, vertical = 10.dp),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
horizontalArrangement = Arrangement.SpaceBetween,
|
||||
) {
|
||||
Text("‹ Back", color = TextMuted, fontSize = 15.sp, modifier = Modifier.clickable { onBack() }.padding(6.dp))
|
||||
Column(horizontalAlignment = Alignment.CenterHorizontally) {
|
||||
Text("FLARE", color = TextPrimary, fontSize = 16.sp, fontWeight = FontWeight.SemiBold, letterSpacing = 3.sp)
|
||||
peer?.let {
|
||||
Text(
|
||||
it.name + if (it.ip.isNotBlank()) " · direct+mesh" else " · mesh",
|
||||
color = BitcoinOrange,
|
||||
fontSize = 11.sp,
|
||||
)
|
||||
}
|
||||
}
|
||||
Spacer(Modifier.size(48.dp))
|
||||
}
|
||||
|
||||
// Peer tabs when chatting with more than one phone
|
||||
if (peers.size > 1) {
|
||||
Row(
|
||||
Modifier.fillMaxWidth().padding(horizontal = 16.dp),
|
||||
horizontalArrangement = Arrangement.spacedBy(8.dp),
|
||||
) {
|
||||
peers.forEach { p ->
|
||||
val active = p.npub == selectedNpub
|
||||
Text(
|
||||
p.name,
|
||||
color = if (active) BitcoinOrange else TextMuted,
|
||||
fontSize = 13.sp,
|
||||
modifier = Modifier
|
||||
.clip(RoundedCornerShape(10.dp))
|
||||
.background(if (active) BitcoinOrange.copy(alpha = 0.12f) else Color.Transparent)
|
||||
.border(
|
||||
1.dp,
|
||||
if (active) BitcoinOrange.copy(alpha = 0.4f) else BubbleBorder,
|
||||
RoundedCornerShape(10.dp),
|
||||
)
|
||||
.clickable { selectedNpub = p.npub }
|
||||
.padding(horizontal = 12.dp, vertical = 6.dp),
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (peer == null) {
|
||||
Box(Modifier.weight(1f).fillMaxWidth(), contentAlignment = Alignment.Center) {
|
||||
Text("No party peers yet — scan a phone first", color = TextMuted, fontSize = 14.sp)
|
||||
}
|
||||
} else {
|
||||
LazyColumn(
|
||||
state = listState,
|
||||
modifier = Modifier.weight(1f).fillMaxWidth(),
|
||||
contentPadding = androidx.compose.foundation.layout.PaddingValues(16.dp),
|
||||
verticalArrangement = Arrangement.spacedBy(8.dp),
|
||||
) {
|
||||
items(messages, key = { it.id }) { msg ->
|
||||
MessageBubble(msg)
|
||||
}
|
||||
}
|
||||
|
||||
// Composer
|
||||
Row(
|
||||
Modifier.fillMaxWidth().padding(horizontal = 12.dp, vertical = 8.dp),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
horizontalArrangement = Arrangement.spacedBy(8.dp),
|
||||
) {
|
||||
Box(
|
||||
Modifier
|
||||
.size(48.dp)
|
||||
.clip(RoundedCornerShape(12.dp))
|
||||
.background(BubbleTheirs)
|
||||
.border(1.dp, BubbleBorder, RoundedCornerShape(12.dp))
|
||||
.clickable { photoPicker.launch("image/*") },
|
||||
contentAlignment = Alignment.Center,
|
||||
) {
|
||||
Icon(Icons.Default.Image, "Beam a photo", tint = BitcoinOrange, modifier = Modifier.size(22.dp))
|
||||
}
|
||||
OutlinedTextField(
|
||||
value = draft,
|
||||
onValueChange = { draft = it },
|
||||
placeholder = { Text("Send a flare…", color = TextMuted, fontSize = 14.sp) },
|
||||
modifier = Modifier.weight(1f),
|
||||
singleLine = true,
|
||||
keyboardOptions = KeyboardOptions(imeAction = ImeAction.Send),
|
||||
keyboardActions = KeyboardActions(onSend = { sendText() }),
|
||||
textStyle = TextStyle(color = TextPrimary, fontSize = 15.sp),
|
||||
colors = OutlinedTextFieldDefaults.colors(
|
||||
focusedBorderColor = Color.White.copy(alpha = 0.3f),
|
||||
unfocusedBorderColor = Color.White.copy(alpha = 0.12f),
|
||||
cursorColor = BitcoinOrange,
|
||||
focusedTextColor = TextPrimary,
|
||||
unfocusedTextColor = TextPrimary,
|
||||
),
|
||||
shape = RoundedCornerShape(12.dp),
|
||||
)
|
||||
Box(
|
||||
Modifier
|
||||
.size(48.dp)
|
||||
.clip(RoundedCornerShape(12.dp))
|
||||
.background(BitcoinOrange.copy(alpha = 0.15f))
|
||||
.border(1.dp, BitcoinOrange.copy(alpha = 0.4f), RoundedCornerShape(12.dp))
|
||||
.clickable { sendText() },
|
||||
contentAlignment = Alignment.Center,
|
||||
) {
|
||||
Text("➤", color = BitcoinOrange, fontSize = 18.sp)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun MessageBubble(msg: FlareMessage) {
|
||||
Row(
|
||||
Modifier.fillMaxWidth(),
|
||||
horizontalArrangement = if (msg.fromMe) Arrangement.End else Arrangement.Start,
|
||||
) {
|
||||
Column(
|
||||
Modifier
|
||||
.widthIn(max = 300.dp)
|
||||
.clip(RoundedCornerShape(16.dp))
|
||||
.background(if (msg.fromMe) BitcoinOrange.copy(alpha = 0.14f) else BubbleTheirs)
|
||||
.border(
|
||||
1.dp,
|
||||
if (msg.fromMe) BitcoinOrange.copy(alpha = 0.35f) else BubbleBorder,
|
||||
RoundedCornerShape(16.dp),
|
||||
)
|
||||
.padding(horizontal = 12.dp, vertical = 8.dp),
|
||||
) {
|
||||
if (msg.photoPath.isNotBlank()) {
|
||||
val bmp = remember(msg.photoPath) { BitmapFactory.decodeFile(msg.photoPath) }
|
||||
bmp?.let {
|
||||
Image(
|
||||
bitmap = it.asImageBitmap(),
|
||||
contentDescription = "Beamed photo",
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.clip(RoundedCornerShape(10.dp)),
|
||||
contentScale = ContentScale.FillWidth,
|
||||
)
|
||||
}
|
||||
}
|
||||
if (msg.text.isNotBlank()) {
|
||||
Text(msg.text, color = TextPrimary, fontSize = 15.sp)
|
||||
}
|
||||
Text(
|
||||
when (msg.status) {
|
||||
FlareMessage.Status.SENDING -> "sending…"
|
||||
FlareMessage.Status.SENT -> "sent · E2E via mesh"
|
||||
FlareMessage.Status.FAILED -> "failed — tap to retry later"
|
||||
FlareMessage.Status.RECEIVED -> msg.name
|
||||
},
|
||||
color = if (msg.status == FlareMessage.Status.FAILED) BitcoinOrange else TextMuted,
|
||||
fontSize = 10.sp,
|
||||
modifier = Modifier.padding(top = 2.dp),
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** Decode, downscale (≤1600px) and JPEG-compress a picked photo off-main. */
|
||||
private suspend fun compressPhoto(context: android.content.Context, uri: Uri): ByteArray? =
|
||||
withContext(Dispatchers.IO) {
|
||||
try {
|
||||
val bounds = BitmapFactory.Options().apply { inJustDecodeBounds = true }
|
||||
context.contentResolver.openInputStream(uri)?.use {
|
||||
BitmapFactory.decodeStream(it, null, bounds)
|
||||
}
|
||||
var sample = 1
|
||||
while (maxOf(bounds.outWidth, bounds.outHeight) / sample > 1600) sample *= 2
|
||||
val opts = BitmapFactory.Options().apply { inSampleSize = sample }
|
||||
val bitmap = context.contentResolver.openInputStream(uri)?.use {
|
||||
BitmapFactory.decodeStream(it, null, opts)
|
||||
} ?: return@withContext null
|
||||
val out = ByteArrayOutputStream()
|
||||
bitmap.compress(Bitmap.CompressFormat.JPEG, 80, out)
|
||||
bitmap.recycle()
|
||||
out.toByteArray()
|
||||
} catch (_: Exception) {
|
||||
null
|
||||
}
|
||||
}
|
||||
@@ -55,7 +55,12 @@ import com.archipelago.app.ui.theme.TextPrimary
|
||||
import kotlinx.coroutines.delay
|
||||
|
||||
@Composable
|
||||
fun IntroScreen(onContinue: () -> Unit) {
|
||||
fun IntroScreen(
|
||||
onContinue: () -> Unit,
|
||||
// Mesh Party works with no node at all (phone↔phone) — offered right on
|
||||
// the first screen so a friend who just got the app can join a party.
|
||||
onMeshParty: () -> Unit = {},
|
||||
) {
|
||||
val logoAlpha = remember { Animatable(0f) }
|
||||
var showContent by remember { mutableStateOf(false) }
|
||||
|
||||
@@ -143,6 +148,14 @@ fun IntroScreen(onContinue: () -> Unit) {
|
||||
onClick = onContinue,
|
||||
modifier = Modifier.fillMaxWidth().height(56.dp),
|
||||
)
|
||||
|
||||
Spacer(modifier = Modifier.height(12.dp))
|
||||
|
||||
GlassButton(
|
||||
text = stringResource(R.string.mesh_party),
|
||||
onClick = onMeshParty,
|
||||
modifier = Modifier.fillMaxWidth().height(48.dp),
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,519 @@
|
||||
package com.archipelago.app.ui.screens
|
||||
|
||||
import android.graphics.Bitmap
|
||||
import androidx.activity.compose.BackHandler
|
||||
import androidx.compose.animation.AnimatedVisibility
|
||||
import androidx.compose.animation.fadeIn
|
||||
import androidx.compose.animation.fadeOut
|
||||
import androidx.compose.foundation.Image
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.border
|
||||
import androidx.compose.foundation.clickable
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.Spacer
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.height
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.layout.size
|
||||
import androidx.compose.foundation.layout.statusBarsPadding
|
||||
import androidx.compose.foundation.rememberScrollState
|
||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
import androidx.compose.foundation.text.KeyboardOptions
|
||||
import androidx.compose.foundation.verticalScroll
|
||||
import androidx.compose.material3.OutlinedTextField
|
||||
import androidx.compose.material3.OutlinedTextFieldDefaults
|
||||
import androidx.compose.material3.Switch
|
||||
import androidx.compose.material3.SwitchDefaults
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.LaunchedEffect
|
||||
import androidx.compose.runtime.collectAsState
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.rememberCoroutineScope
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.draw.clip
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.graphics.asImageBitmap
|
||||
import androidx.compose.ui.platform.LocalContext
|
||||
import androidx.compose.ui.text.TextStyle
|
||||
import androidx.compose.ui.text.font.FontWeight
|
||||
import androidx.compose.ui.text.input.ImeAction
|
||||
import androidx.compose.ui.text.style.TextAlign
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.compose.ui.unit.sp
|
||||
import com.archipelago.app.fips.FipsManager
|
||||
import com.archipelago.app.fips.FipsNative
|
||||
import com.archipelago.app.fips.FipsPreferences
|
||||
import com.archipelago.app.fips.FlareClient
|
||||
import com.archipelago.app.fips.PartyPeer
|
||||
import com.archipelago.app.fips.PartyQr
|
||||
import com.archipelago.app.ui.components.CameraQrPreview
|
||||
import com.archipelago.app.ui.theme.BitcoinOrange
|
||||
import com.archipelago.app.ui.theme.SurfaceDark
|
||||
import com.archipelago.app.ui.theme.TextMuted
|
||||
import com.archipelago.app.ui.theme.TextPrimary
|
||||
import com.google.zxing.BarcodeFormat
|
||||
import com.google.zxing.EncodeHintType
|
||||
import com.google.zxing.qrcode.QRCodeWriter
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.delay
|
||||
import kotlinx.coroutines.launch
|
||||
import kotlinx.coroutines.withContext
|
||||
|
||||
private val CardBg = Color.White.copy(alpha = 0.05f)
|
||||
private val CardBorder = Color.White.copy(alpha = 0.08f)
|
||||
|
||||
/** Public companion download (vps2 demo host — serves the same APK as the
|
||||
* nodes' QR link). Rendered as the "Share this app" QR. */
|
||||
private const val APP_DOWNLOAD_URL =
|
||||
"http://146.59.87.168:2100/packages/archipelago-companion.apk"
|
||||
|
||||
/**
|
||||
* Mesh Party — phone↔phone FIPS pairing. Show your QR, scan theirs, and the
|
||||
* two embedded mesh nodes link up: through anchors when there's internet,
|
||||
* directly over any shared WiFi/hotspot when there isn't.
|
||||
*/
|
||||
@Composable
|
||||
fun PartyScreen(
|
||||
onBack: () -> Unit,
|
||||
onOpenChat: () -> Unit,
|
||||
) {
|
||||
val context = LocalContext.current
|
||||
val prefs = remember { FipsPreferences(context) }
|
||||
val scope = rememberCoroutineScope()
|
||||
|
||||
var identity by remember { mutableStateOf<FipsNative.Identity?>(null) }
|
||||
var name by remember { mutableStateOf("") }
|
||||
var localIp by remember { mutableStateOf<String?>(null) }
|
||||
var showScanner by remember { mutableStateOf(false) }
|
||||
var showShareQr by remember { mutableStateOf(false) }
|
||||
var scanHint by remember { mutableStateOf<String?>(null) }
|
||||
|
||||
// Camera permission — fresh installs (and reinstalls: uninstall wipes
|
||||
// grants) land here with no CAMERA grant, and the raw preview just showed
|
||||
// black. Ask the moment the scanner opens.
|
||||
var hasCamera by remember {
|
||||
mutableStateOf(
|
||||
androidx.core.content.ContextCompat.checkSelfPermission(
|
||||
context, android.Manifest.permission.CAMERA,
|
||||
) == android.content.pm.PackageManager.PERMISSION_GRANTED
|
||||
)
|
||||
}
|
||||
val cameraPermLauncher = androidx.activity.compose.rememberLauncherForActivityResult(
|
||||
androidx.activity.result.contract.ActivityResultContracts.RequestPermission()
|
||||
) { hasCamera = it }
|
||||
LaunchedEffect(showScanner) {
|
||||
if (showScanner && !hasCamera) {
|
||||
cameraPermLauncher.launch(android.Manifest.permission.CAMERA)
|
||||
}
|
||||
}
|
||||
val listenOn by prefs.partyListenFlow.collectAsState(initial = false)
|
||||
val peers by prefs.partyPeersFlow.collectAsState(initial = emptyList())
|
||||
|
||||
LaunchedEffect(Unit) {
|
||||
identity = FipsManager.ensureIdentity(prefs)
|
||||
name = prefs.partyName()
|
||||
// The hotspot/WiFi address can change while this screen is open
|
||||
// (e.g. the user flips the hotspot on mid-demo) — keep it fresh.
|
||||
while (true) {
|
||||
localIp = withContext(Dispatchers.IO) { PartyQr.localWifiIpv4() }
|
||||
delay(3_000)
|
||||
}
|
||||
}
|
||||
|
||||
val qrPayload = identity?.let { id ->
|
||||
PartyQr.build(
|
||||
npub = id.npub,
|
||||
ula = id.address,
|
||||
name = name.ifBlank { "Phone" },
|
||||
ip = if (listenOn) localIp else null,
|
||||
port = PartyQr.PARTY_UDP_PORT,
|
||||
)
|
||||
}
|
||||
val qrBitmap = remember(qrPayload) { qrPayload?.let { renderQr(it) } }
|
||||
|
||||
BackHandler {
|
||||
when {
|
||||
showScanner -> showScanner = false
|
||||
showShareQr -> showShareQr = false
|
||||
else -> onBack()
|
||||
}
|
||||
}
|
||||
|
||||
Box(Modifier.fillMaxSize().background(SurfaceDark)) {
|
||||
Column(
|
||||
Modifier
|
||||
.fillMaxSize()
|
||||
.statusBarsPadding()
|
||||
.verticalScroll(rememberScrollState())
|
||||
.padding(horizontal = 24.dp, vertical = 16.dp),
|
||||
verticalArrangement = Arrangement.spacedBy(14.dp),
|
||||
) {
|
||||
Row(
|
||||
Modifier.fillMaxWidth(),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
horizontalArrangement = Arrangement.SpaceBetween,
|
||||
) {
|
||||
Text("‹ Back", color = TextMuted, fontSize = 15.sp, modifier = Modifier.clickable { onBack() }.padding(8.dp))
|
||||
Text("MESH PARTY", color = TextPrimary, fontSize = 17.sp, fontWeight = FontWeight.SemiBold, letterSpacing = 3.sp)
|
||||
Spacer(Modifier.size(56.dp))
|
||||
}
|
||||
|
||||
// My QR card
|
||||
Column(
|
||||
Modifier
|
||||
.fillMaxWidth()
|
||||
.clip(RoundedCornerShape(20.dp))
|
||||
.background(CardBg)
|
||||
.border(1.dp, CardBorder, RoundedCornerShape(20.dp))
|
||||
.padding(18.dp),
|
||||
horizontalAlignment = Alignment.CenterHorizontally,
|
||||
verticalArrangement = Arrangement.spacedBy(12.dp),
|
||||
) {
|
||||
qrBitmap?.let { bmp ->
|
||||
Box(
|
||||
Modifier
|
||||
.clip(RoundedCornerShape(16.dp))
|
||||
.background(Color.White)
|
||||
.padding(12.dp),
|
||||
) {
|
||||
Image(
|
||||
bitmap = bmp.asImageBitmap(),
|
||||
contentDescription = "My mesh party QR",
|
||||
modifier = Modifier.size(220.dp),
|
||||
)
|
||||
}
|
||||
} ?: Text("Mesh identity unavailable on this device", color = TextMuted, fontSize = 14.sp)
|
||||
|
||||
identity?.let {
|
||||
Text(
|
||||
it.npub.take(16) + "…" + it.npub.takeLast(6),
|
||||
color = TextMuted,
|
||||
fontSize = 12.sp,
|
||||
textAlign = TextAlign.Center,
|
||||
)
|
||||
}
|
||||
|
||||
OutlinedTextField(
|
||||
value = name,
|
||||
onValueChange = {
|
||||
name = it.take(24)
|
||||
scope.launch { prefs.setPartyName(name) }
|
||||
},
|
||||
placeholder = { Text("Your name", color = TextMuted, textAlign = TextAlign.Center, modifier = Modifier.fillMaxWidth()) },
|
||||
modifier = Modifier.fillMaxWidth().height(56.dp),
|
||||
singleLine = true,
|
||||
keyboardOptions = KeyboardOptions(imeAction = ImeAction.Done),
|
||||
textStyle = TextStyle(color = TextPrimary, fontSize = 15.sp, textAlign = TextAlign.Center),
|
||||
colors = OutlinedTextFieldDefaults.colors(
|
||||
focusedBorderColor = Color.White.copy(alpha = 0.3f),
|
||||
unfocusedBorderColor = Color.White.copy(alpha = 0.12f),
|
||||
cursorColor = BitcoinOrange,
|
||||
focusedTextColor = TextPrimary,
|
||||
unfocusedTextColor = TextPrimary,
|
||||
),
|
||||
shape = RoundedCornerShape(12.dp),
|
||||
)
|
||||
|
||||
// Direct-link toggle (hotspot mode)
|
||||
Row(
|
||||
Modifier.fillMaxWidth(),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
horizontalArrangement = Arrangement.SpaceBetween,
|
||||
) {
|
||||
Column(Modifier.padding(end = 12.dp)) {
|
||||
Text("Accept direct links", color = TextPrimary, fontSize = 15.sp, fontWeight = FontWeight.Medium)
|
||||
Text(
|
||||
when {
|
||||
listenOn && localIp != null -> "Dialable at $localIp:${PartyQr.PARTY_UDP_PORT} — no internet needed"
|
||||
listenOn -> "Waiting for a WiFi/hotspot address…"
|
||||
else -> "Off — mesh routes via anchors only"
|
||||
},
|
||||
color = if (listenOn) BitcoinOrange else TextMuted,
|
||||
fontSize = 12.sp,
|
||||
)
|
||||
}
|
||||
Switch(
|
||||
checked = listenOn,
|
||||
onCheckedChange = { on ->
|
||||
scope.launch {
|
||||
prefs.setPartyListen(on)
|
||||
FipsManager.requestMeshRestart(context)
|
||||
}
|
||||
},
|
||||
colors = SwitchDefaults.colors(
|
||||
checkedTrackColor = BitcoinOrange,
|
||||
checkedThumbColor = Color.White,
|
||||
),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
GlassButton(
|
||||
text = "Scan a Phone",
|
||||
onClick = { showScanner = true },
|
||||
modifier = Modifier.fillMaxWidth().height(56.dp),
|
||||
)
|
||||
|
||||
if (peers.isNotEmpty()) {
|
||||
Text("PARTY PEERS", color = TextMuted, fontSize = 12.sp, letterSpacing = 2.sp)
|
||||
peers.forEach { peer ->
|
||||
Row(
|
||||
Modifier
|
||||
.fillMaxWidth()
|
||||
.clip(RoundedCornerShape(14.dp))
|
||||
.background(CardBg)
|
||||
.border(1.dp, CardBorder, RoundedCornerShape(14.dp))
|
||||
.clickable { onOpenChat() }
|
||||
.padding(horizontal = 16.dp, vertical = 12.dp),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
horizontalArrangement = Arrangement.SpaceBetween,
|
||||
) {
|
||||
Column(Modifier.padding(end = 8.dp)) {
|
||||
Text(peer.name, color = TextPrimary, fontSize = 15.sp, fontWeight = FontWeight.Medium)
|
||||
Text(
|
||||
peer.npub.take(14) + "…" + if (peer.ip.isNotBlank()) " · direct ${peer.ip}" else " · via mesh",
|
||||
color = TextMuted,
|
||||
fontSize = 11.sp,
|
||||
)
|
||||
}
|
||||
Text(
|
||||
"✕",
|
||||
color = TextMuted,
|
||||
fontSize = 16.sp,
|
||||
modifier = Modifier.clickable {
|
||||
scope.launch {
|
||||
prefs.removePartyPeer(peer.npub)
|
||||
FipsManager.requestMeshRestart(context)
|
||||
}
|
||||
}.padding(8.dp),
|
||||
)
|
||||
}
|
||||
}
|
||||
GlassButton(
|
||||
text = "Open Flare Chat",
|
||||
onClick = onOpenChat,
|
||||
modifier = Modifier.fillMaxWidth().height(56.dp),
|
||||
)
|
||||
} else {
|
||||
Text(
|
||||
"Scan another phone's party QR (or let them scan yours) to link your mesh nodes — works over 5G via anchors, or over any shared WiFi/hotspot with zero internet.",
|
||||
color = TextMuted,
|
||||
fontSize = 13.sp,
|
||||
textAlign = TextAlign.Center,
|
||||
modifier = Modifier.fillMaxWidth().padding(horizontal = 8.dp),
|
||||
)
|
||||
}
|
||||
Spacer(Modifier.height(12.dp))
|
||||
// Hand the app itself to a friend: shares this install's own APK
|
||||
// through the system sheet (Quick Share/Bluetooth), so a nearby
|
||||
// phone gets the companion with zero internet — the whole party
|
||||
// premise.
|
||||
GlassButton(
|
||||
text = "Share this app",
|
||||
onClick = { showShareQr = true },
|
||||
modifier = Modifier.fillMaxWidth().height(48.dp),
|
||||
)
|
||||
Spacer(Modifier.height(12.dp))
|
||||
}
|
||||
|
||||
// "Share this app" — a QR of the public download link, so the other
|
||||
// phone scans it with its normal camera and installs over any
|
||||
// internet. (The vps2 demo host serves the same APK the nodes do.)
|
||||
AnimatedVisibility(visible = showShareQr, enter = fadeIn(), exit = fadeOut()) {
|
||||
Box(
|
||||
Modifier
|
||||
.fillMaxSize()
|
||||
.background(Color.Black.copy(alpha = 0.94f))
|
||||
.clickable { showShareQr = false },
|
||||
contentAlignment = Alignment.Center,
|
||||
) {
|
||||
Column(horizontalAlignment = Alignment.CenterHorizontally) {
|
||||
val dlQr = remember { renderQr(APP_DOWNLOAD_URL) }
|
||||
dlQr?.let { bmp ->
|
||||
Box(
|
||||
Modifier
|
||||
.clip(RoundedCornerShape(16.dp))
|
||||
.background(Color.White)
|
||||
.padding(14.dp),
|
||||
) {
|
||||
Image(
|
||||
bitmap = bmp.asImageBitmap(),
|
||||
contentDescription = "Companion download QR",
|
||||
modifier = Modifier.size(240.dp),
|
||||
)
|
||||
}
|
||||
}
|
||||
Spacer(Modifier.height(18.dp))
|
||||
Text(
|
||||
"Scan with any camera to download\nthe Archipelago Companion",
|
||||
color = TextPrimary,
|
||||
fontSize = 15.sp,
|
||||
textAlign = TextAlign.Center,
|
||||
)
|
||||
Spacer(Modifier.height(10.dp))
|
||||
Text(
|
||||
"…or send the APK file directly",
|
||||
color = BitcoinOrange,
|
||||
fontSize = 13.sp,
|
||||
modifier = Modifier.clickable { shareCompanionApk(context) }.padding(8.dp),
|
||||
)
|
||||
Spacer(Modifier.height(6.dp))
|
||||
Text("Close", color = TextMuted, fontSize = 14.sp, modifier = Modifier.clickable { showShareQr = false }.padding(8.dp))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Party QR scanner overlay
|
||||
AnimatedVisibility(visible = showScanner, enter = fadeIn(), exit = fadeOut()) {
|
||||
Box(Modifier.fillMaxSize().background(Color.Black)) {
|
||||
if (!hasCamera) {
|
||||
Column(
|
||||
Modifier.align(Alignment.Center).padding(horizontal = 32.dp),
|
||||
horizontalAlignment = Alignment.CenterHorizontally,
|
||||
verticalArrangement = Arrangement.spacedBy(14.dp),
|
||||
) {
|
||||
Text(
|
||||
"Camera access is needed to scan a party QR.",
|
||||
color = TextPrimary,
|
||||
fontSize = 15.sp,
|
||||
textAlign = TextAlign.Center,
|
||||
)
|
||||
GlassButton(
|
||||
text = "Grant camera access",
|
||||
onClick = { cameraPermLauncher.launch(android.Manifest.permission.CAMERA) },
|
||||
modifier = Modifier.fillMaxWidth().height(48.dp),
|
||||
)
|
||||
}
|
||||
} else CameraQrPreview(onDecoded = { text ->
|
||||
val peer = PartyQr.parse(text)
|
||||
when {
|
||||
peer == null -> scanHint = "Not a mesh party QR"
|
||||
peer.npub == identity?.npub -> scanHint = "That's your own QR"
|
||||
else -> {
|
||||
showScanner = false
|
||||
scanHint = null
|
||||
scope.launch {
|
||||
prefs.upsertPartyPeer(peer)
|
||||
// Pick up the direct-dial PeerConfig (and the
|
||||
// listener, if ours is on) immediately.
|
||||
FipsManager.requestMeshRestart(context)
|
||||
// Pairing must be MUTUAL: announce ourselves so
|
||||
// the scanned phone gets us as a peer + a chat
|
||||
// entry without scanning back. Retried while
|
||||
// the fresh link/session comes up.
|
||||
val me = identity
|
||||
if (me != null) {
|
||||
launch(Dispatchers.IO) {
|
||||
for (attempt in 0 until 6) {
|
||||
val ok = FlareClient.sendHello(
|
||||
peer = peer,
|
||||
myNpub = me.npub,
|
||||
myName = name.ifBlank { "Phone" },
|
||||
myUla = me.address,
|
||||
myIp = if (listenOn) localIp else null,
|
||||
myPort = PartyQr.PARTY_UDP_PORT,
|
||||
)
|
||||
if (ok) break
|
||||
delay(3_000)
|
||||
}
|
||||
}
|
||||
}
|
||||
onOpenChat()
|
||||
}
|
||||
}
|
||||
}
|
||||
})
|
||||
Box(
|
||||
Modifier
|
||||
.align(Alignment.Center)
|
||||
.size(260.dp)
|
||||
.border(2.dp, BitcoinOrange.copy(alpha = 0.85f), RoundedCornerShape(20.dp)),
|
||||
)
|
||||
Text(
|
||||
"Close",
|
||||
color = TextPrimary,
|
||||
fontSize = 16.sp,
|
||||
modifier = Modifier
|
||||
.align(Alignment.TopEnd)
|
||||
.statusBarsPadding()
|
||||
.clickable { showScanner = false }
|
||||
.padding(20.dp),
|
||||
)
|
||||
scanHint?.let {
|
||||
Text(
|
||||
it,
|
||||
color = BitcoinOrange,
|
||||
fontSize = 14.sp,
|
||||
textAlign = TextAlign.Center,
|
||||
modifier = Modifier
|
||||
.align(Alignment.BottomCenter)
|
||||
.padding(bottom = 48.dp)
|
||||
.fillMaxWidth(),
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Scan hints fade so the camera feels live again.
|
||||
LaunchedEffect(scanHint) {
|
||||
if (scanHint != null) {
|
||||
delay(2500)
|
||||
scanHint = null
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** Render a QR payload as a bitmap (dark modules on white). */
|
||||
private fun renderQr(payload: String, size: Int = 640): Bitmap? = try {
|
||||
val matrix = QRCodeWriter().encode(
|
||||
payload,
|
||||
BarcodeFormat.QR_CODE,
|
||||
size,
|
||||
size,
|
||||
mapOf(EncodeHintType.MARGIN to 1),
|
||||
)
|
||||
val pixels = IntArray(size * size)
|
||||
for (y in 0 until size) {
|
||||
for (x in 0 until size) {
|
||||
pixels[y * size + x] = if (matrix[x, y]) 0xFF0A0A0A.toInt() else 0xFFFFFFFF.toInt()
|
||||
}
|
||||
}
|
||||
Bitmap.createBitmap(pixels, size, size, Bitmap.Config.ARGB_8888)
|
||||
} catch (_: Exception) {
|
||||
null
|
||||
}
|
||||
|
||||
/** Share this install's own APK via the system share sheet — a nearby friend
|
||||
* gets the companion with no internet at all (Quick Share / Bluetooth). */
|
||||
private fun shareCompanionApk(context: android.content.Context) {
|
||||
try {
|
||||
val src = java.io.File(context.applicationInfo.sourceDir)
|
||||
val dir = java.io.File(context.cacheDir, "share").apply { mkdirs() }
|
||||
val out = java.io.File(dir, "archipelago-companion.apk")
|
||||
src.copyTo(out, overwrite = true)
|
||||
val uri = androidx.core.content.FileProvider.getUriForFile(
|
||||
context, "${context.packageName}.fileprovider", out,
|
||||
)
|
||||
val send = android.content.Intent(android.content.Intent.ACTION_SEND).apply {
|
||||
type = "application/vnd.android.package-archive"
|
||||
putExtra(android.content.Intent.EXTRA_STREAM, uri)
|
||||
addFlags(android.content.Intent.FLAG_GRANT_READ_URI_PERMISSION)
|
||||
}
|
||||
context.startActivity(
|
||||
android.content.Intent.createChooser(send, "Share Archipelago Companion")
|
||||
.addFlags(android.content.Intent.FLAG_ACTIVITY_NEW_TASK),
|
||||
)
|
||||
} catch (_: Exception) {
|
||||
// No share targets / copy failed — nothing sensible to do here.
|
||||
}
|
||||
}
|
||||
@@ -4,8 +4,10 @@ import android.content.res.Configuration
|
||||
import androidx.activity.compose.BackHandler
|
||||
import androidx.compose.foundation.Image
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.WindowInsets
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
@@ -37,6 +39,7 @@ import androidx.compose.ui.res.painterResource
|
||||
import androidx.compose.ui.unit.dp
|
||||
import com.archipelago.app.R
|
||||
import com.archipelago.app.data.ServerPreferences
|
||||
import com.archipelago.app.fips.FipsManager
|
||||
import com.archipelago.app.network.ConnectionState
|
||||
import com.archipelago.app.network.InputWebSocket
|
||||
import com.archipelago.app.ui.components.NESController
|
||||
@@ -53,7 +56,12 @@ import com.archipelago.app.ui.theme.TextMuted
|
||||
import kotlinx.coroutines.launch
|
||||
|
||||
@Composable
|
||||
fun RemoteInputScreen(onBack: () -> Unit) {
|
||||
fun RemoteInputScreen(
|
||||
onBack: () -> Unit,
|
||||
onMeshParty: (() -> Unit)? = null,
|
||||
// Land on the keyboard instead of the gamepad (hub menu's Keyboard card).
|
||||
startInKeyboard: Boolean = false,
|
||||
) {
|
||||
val context = LocalContext.current
|
||||
val prefs = remember { ServerPreferences(context) }
|
||||
val scope = rememberCoroutineScope()
|
||||
@@ -62,7 +70,7 @@ fun RemoteInputScreen(onBack: () -> Unit) {
|
||||
val savedServers by prefs.savedServers.collectAsState(initial = emptyList())
|
||||
val activeServer by prefs.activeServer.collectAsState(initial = null)
|
||||
|
||||
var isGamepadMode by remember { mutableStateOf(true) }
|
||||
var isGamepadMode by remember { mutableStateOf(!startInKeyboard) }
|
||||
var showModal by remember { mutableStateOf(false) }
|
||||
var showQrScanner by remember { mutableStateOf(false) }
|
||||
var controllerStyle by remember { mutableStateOf(ControllerStyle.DARK) }
|
||||
@@ -89,6 +97,9 @@ fun RemoteInputScreen(onBack: () -> Unit) {
|
||||
playerId = when (playerId) { 0 -> 1; 1 -> 2; else -> 0 }
|
||||
ws.playerId = playerId
|
||||
}
|
||||
fun toggleStyle() {
|
||||
controllerStyle = if (controllerStyle == ControllerStyle.CLASSIC) ControllerStyle.DARK else ControllerStyle.CLASSIC
|
||||
}
|
||||
val connectionState by ws.state.collectAsState()
|
||||
val lifecycleOwner = LocalLifecycleOwner.current
|
||||
|
||||
@@ -152,6 +163,7 @@ fun RemoteInputScreen(onBack: () -> Unit) {
|
||||
onKey = { ws.sendKey(it) },
|
||||
onMenu = { showModal = true },
|
||||
onPlayerToggle = ::togglePlayer,
|
||||
onToggleStyle = ::toggleStyle,
|
||||
)
|
||||
isGamepadMode && !isLandscape -> NESPortraitController(
|
||||
style = controllerStyle,
|
||||
@@ -162,6 +174,7 @@ fun RemoteInputScreen(onBack: () -> Unit) {
|
||||
onMouseScroll = { ws.sendScroll(it) },
|
||||
onMenu = { showModal = true },
|
||||
onPlayerToggle = ::togglePlayer,
|
||||
onToggleStyle = ::toggleStyle,
|
||||
)
|
||||
else -> {
|
||||
// Keyboard mode: trackpad fills top, keyboard pinned bottom
|
||||
@@ -171,7 +184,7 @@ fun RemoteInputScreen(onBack: () -> Unit) {
|
||||
onMove = { dx, dy -> ws.sendMouseMove(dx, dy) },
|
||||
onClick = { ws.sendClick(it) },
|
||||
onScroll = { ws.sendScroll(it) },
|
||||
onTwoFingerHold = { showModal = true },
|
||||
onThreeFingerHold = { showModal = true },
|
||||
modifier = Modifier.fillMaxWidth().weight(1f)
|
||||
.padding(horizontal = 16.dp, vertical = 8.dp),
|
||||
)
|
||||
@@ -181,12 +194,20 @@ fun RemoteInputScreen(onBack: () -> Unit) {
|
||||
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 },
|
||||
)
|
||||
// Settings + style icons top-right in keyboard mode
|
||||
Row(
|
||||
Modifier.align(Alignment.TopEnd).padding(8.dp),
|
||||
horizontalArrangement = Arrangement.spacedBy(8.dp),
|
||||
) {
|
||||
com.archipelago.app.ui.components.SettingsBtn(
|
||||
c = com.archipelago.app.ui.components.paletteFor(controllerStyle),
|
||||
onClick = { showModal = true },
|
||||
)
|
||||
com.archipelago.app.ui.components.StyleBtn(
|
||||
c = com.archipelago.app.ui.components.paletteFor(controllerStyle),
|
||||
onClick = ::toggleStyle,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -209,8 +230,6 @@ fun RemoteInputScreen(onBack: () -> Unit) {
|
||||
visible = showModal,
|
||||
servers = savedServers,
|
||||
activeServer = activeServer,
|
||||
isGamepadMode = isGamepadMode,
|
||||
controllerStyle = controllerStyle,
|
||||
onDismiss = { showModal = false },
|
||||
onSelectServer = { server ->
|
||||
scope.launch { ws.disconnect(); prefs.setActiveServer(server) }; showModal = false
|
||||
@@ -244,11 +263,10 @@ fun RemoteInputScreen(onBack: () -> Unit) {
|
||||
}
|
||||
}
|
||||
},
|
||||
onToggleMode = { isGamepadMode = !isGamepadMode; showModal = false },
|
||||
onToggleStyle = {
|
||||
controllerStyle = if (controllerStyle == ControllerStyle.CLASSIC) ControllerStyle.DARK else ControllerStyle.CLASSIC
|
||||
},
|
||||
onRemote = { isGamepadMode = true; showModal = false },
|
||||
onKeyboard = { isGamepadMode = false; showModal = false },
|
||||
onBackToWebView = { showModal = false; onBack() },
|
||||
onMeshParty = onMeshParty?.let { open -> { showModal = false; open() } },
|
||||
)
|
||||
|
||||
// Pairing-QR scan launched from the menu's Add Server row. The menu stays
|
||||
@@ -256,10 +274,11 @@ fun RemoteInputScreen(onBack: () -> Unit) {
|
||||
QrScannerOverlay(
|
||||
visible = showQrScanner,
|
||||
onDismiss = { showQrScanner = false },
|
||||
onServerScanned = { server ->
|
||||
onServerScanned = { scan ->
|
||||
showQrScanner = false
|
||||
scope.launch {
|
||||
val merged = prefs.upsertServer(server)
|
||||
val merged = prefs.upsertServer(scan.server)
|
||||
FipsManager.registerNode(context, scan.fips, merged.displayName())
|
||||
if (activeServer == null) prefs.setActiveServer(merged)
|
||||
}
|
||||
},
|
||||
|
||||
@@ -71,8 +71,11 @@ import androidx.compose.ui.text.style.TextOverflow
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.compose.ui.unit.sp
|
||||
import com.archipelago.app.R
|
||||
import com.archipelago.app.data.PairResult
|
||||
import com.archipelago.app.data.ServerEntry
|
||||
import com.archipelago.app.data.ServerPreferences
|
||||
import com.archipelago.app.fips.FipsManager
|
||||
import com.archipelago.app.ui.components.MeshLoadingScreen
|
||||
import com.archipelago.app.ui.components.QrScannerOverlay
|
||||
import com.archipelago.app.ui.theme.BitcoinOrange
|
||||
import com.archipelago.app.ui.theme.ErrorRed
|
||||
@@ -83,6 +86,7 @@ import com.archipelago.app.ui.theme.TextMuted
|
||||
import com.archipelago.app.ui.theme.TextPrimary
|
||||
import com.archipelago.app.ui.theme.TextSecondary
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.delay
|
||||
import kotlinx.coroutines.launch
|
||||
import kotlinx.coroutines.withContext
|
||||
import java.net.HttpURLConnection
|
||||
@@ -169,10 +173,35 @@ fun ServerConnectScreen(
|
||||
errorMessage = null
|
||||
|
||||
scope.launch {
|
||||
val result = testConnection(server)
|
||||
var reachable = testConnection(server)
|
||||
|
||||
// LAN address didn't answer — phone off-LAN (5G) or DHCP moved the
|
||||
// node. The scanned IP was only ever a dial hint; the node's real
|
||||
// identity is its npub and its ULA is reachable from anywhere over
|
||||
// the mesh. Bring the tunnel up and probe the ULA before failing.
|
||||
if (!reachable && server.meshIp.isNotBlank()) {
|
||||
FipsManager.autoStartIfReady(context)
|
||||
val meshServer = server.copy(
|
||||
address = server.meshIp,
|
||||
useHttps = false,
|
||||
port = "",
|
||||
)
|
||||
// Mesh discovery + first session can take 15s+ through the
|
||||
// public tree (HANDOFF-2026-07-23 node diagnosis), and on a
|
||||
// first-ever pairing the VPN consent dialog is on screen at
|
||||
// the same time — so probe patiently inside a 60s budget with
|
||||
// per-attempt timeouts wide enough to ride out TCP
|
||||
// retransmit backoff. The VPN service pre-warms the session
|
||||
// in parallel (ArchyVpnService.startSessionWarmer).
|
||||
val deadline = System.currentTimeMillis() + 60_000
|
||||
while (!reachable && System.currentTimeMillis() < deadline) {
|
||||
reachable = testConnection(meshServer, timeoutMs = 15_000)
|
||||
if (!reachable) delay(3000)
|
||||
}
|
||||
}
|
||||
isConnecting = false
|
||||
|
||||
if (result) {
|
||||
if (reachable) {
|
||||
prefs.setActiveServer(server)
|
||||
onConnected(server.toUrl())
|
||||
} else {
|
||||
@@ -190,12 +219,14 @@ fun ServerConnectScreen(
|
||||
}
|
||||
|
||||
// Pairing QR scanned: dedupe against saved servers, then either auto-connect
|
||||
// (payload carried a password — the demo flow) or land on the password
|
||||
// prompt with everything else filled in (real nodes never embed one).
|
||||
fun onQrScanned(scanned: ServerEntry) {
|
||||
// (payload carried a credential — demo password or a real node's device
|
||||
// token) or land on the password prompt with everything else filled in.
|
||||
// Mesh info (when present) is registered so the FIPS tunnel comes up too.
|
||||
fun onQrScanned(scan: PairResult.Success) {
|
||||
showScanner = false
|
||||
scope.launch {
|
||||
val merged = prefs.upsertServer(scanned)
|
||||
val merged = prefs.upsertServer(scan.server)
|
||||
FipsManager.registerNode(context, scan.fips, merged.displayName())
|
||||
prefill(merged)
|
||||
if (merged.password.isNotBlank()) {
|
||||
connect(merged)
|
||||
@@ -580,6 +611,14 @@ fun ServerConnectScreen(
|
||||
onDismiss = { showScanner = false },
|
||||
onServerScanned = { onQrScanned(it) },
|
||||
)
|
||||
|
||||
// Full-screen branded loader while the first connect runs — most
|
||||
// visibly right after a pairing-QR scan, when the mesh may still be
|
||||
// establishing (LAN probe → tunnel up → ULA probe can take a while).
|
||||
// The small inline spinner stays for context; this owns the screen.
|
||||
if (isConnecting) {
|
||||
MeshLoadingScreen()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -647,8 +686,10 @@ private fun sanitizeAddress(input: String): String {
|
||||
.trimEnd('/')
|
||||
}
|
||||
|
||||
/** Test RPC connectivity. Accepts self-signed certs for local LAN servers. */
|
||||
private suspend fun testConnection(server: ServerEntry): Boolean {
|
||||
/** Test RPC connectivity. Accepts self-signed certs for local LAN servers.
|
||||
* [timeoutMs] is per-phase (connect / read) — mesh probes need far more
|
||||
* patience than LAN ones (first session through the tree can take 15s+). */
|
||||
private suspend fun testConnection(server: ServerEntry, timeoutMs: Int = 5000): Boolean {
|
||||
return withContext(Dispatchers.IO) {
|
||||
try {
|
||||
val url = URL("${server.toUrl()}/rpc/v1")
|
||||
@@ -668,8 +709,8 @@ private suspend fun testConnection(server: ServerEntry): Boolean {
|
||||
}
|
||||
|
||||
connection.requestMethod = "POST"
|
||||
connection.connectTimeout = 5000
|
||||
connection.readTimeout = 5000
|
||||
connection.connectTimeout = timeoutMs
|
||||
connection.readTimeout = timeoutMs
|
||||
connection.setRequestProperty("Content-Type", "application/json")
|
||||
connection.doOutput = true
|
||||
val body = """{"method":"server.echo","params":{"message":"ping"}}"""
|
||||
|
||||
@@ -23,20 +23,28 @@ import androidx.compose.animation.fadeIn
|
||||
import androidx.compose.animation.fadeOut
|
||||
import androidx.compose.foundation.Image
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.clickable
|
||||
import androidx.compose.foundation.interaction.MutableInteractionSource
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.Spacer
|
||||
import androidx.compose.foundation.layout.WindowInsets
|
||||
import androidx.compose.foundation.layout.WindowInsetsSides
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.height
|
||||
import androidx.compose.foundation.layout.navigationBars
|
||||
import androidx.compose.foundation.layout.only
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.layout.safeDrawing
|
||||
import androidx.compose.foundation.layout.size
|
||||
import androidx.compose.foundation.layout.statusBars
|
||||
import androidx.compose.foundation.layout.width
|
||||
import androidx.compose.foundation.layout.windowInsetsBottomHeight
|
||||
import androidx.compose.foundation.layout.windowInsetsPadding
|
||||
import androidx.compose.foundation.layout.windowInsetsTopHeight
|
||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.automirrored.filled.ArrowBack
|
||||
@@ -53,28 +61,50 @@ import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.LaunchedEffect
|
||||
import androidx.compose.runtime.collectAsState
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableIntStateOf
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.rememberCoroutineScope
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.runtime.snapshotFlow
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.draw.clip
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.graphics.asImageBitmap
|
||||
import androidx.compose.ui.platform.LocalContext
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import androidx.compose.ui.text.SpanStyle
|
||||
import androidx.compose.ui.text.buildAnnotatedString
|
||||
import androidx.compose.ui.text.font.FontWeight
|
||||
import androidx.compose.ui.text.style.TextAlign
|
||||
import androidx.compose.ui.text.style.TextOverflow
|
||||
import androidx.compose.ui.text.withStyle
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.compose.ui.unit.sp
|
||||
import androidx.compose.ui.viewinterop.AndroidView
|
||||
import android.webkit.ValueCallback
|
||||
import com.archipelago.app.R
|
||||
import com.archipelago.app.data.ServerPreferences
|
||||
import com.archipelago.app.fips.FipsManager
|
||||
import com.archipelago.app.ui.components.GestureHintOverlay
|
||||
import com.archipelago.app.ui.components.MeshLoadingScreen
|
||||
import com.archipelago.app.ui.components.NESMenu
|
||||
import com.archipelago.app.ui.components.QrScannerOverlay
|
||||
import com.archipelago.app.ui.components.WalletQrScannerModal
|
||||
import com.archipelago.app.ui.theme.BitcoinOrange
|
||||
import com.archipelago.app.ui.theme.ErrorRed
|
||||
import com.archipelago.app.ui.theme.SurfaceBlack
|
||||
import com.archipelago.app.ui.theme.TextMuted
|
||||
import com.archipelago.app.ui.theme.TextPrimary
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.delay
|
||||
import kotlinx.coroutines.flow.first
|
||||
import kotlinx.coroutines.launch
|
||||
import kotlinx.coroutines.withContext
|
||||
import org.json.JSONObject
|
||||
|
||||
/** Open a URL in the phone's default browser (genuinely external links). */
|
||||
private fun openExternalUrl(context: android.content.Context, url: String) {
|
||||
@@ -104,6 +134,158 @@ private fun isSameHost(url: String, base: String): Boolean {
|
||||
}
|
||||
}
|
||||
|
||||
/** Kiosk WebView retained across navigation (remote ⇄ dashboard) so leaving
|
||||
* the kiosk and coming back reattaches the LIVE page — no reload, no
|
||||
* re-login, no reconnect. Dropped on retry/disconnect/server change. */
|
||||
private object KioskWebView {
|
||||
var instance: WebView? = null
|
||||
var url: String? = null
|
||||
|
||||
// Live-composition delegates for the JS bridges (see the factory) — the
|
||||
// registered interface objects call through these, so reattaching the
|
||||
// retained view re-points them instead of leaving stale closures.
|
||||
var onRouteOutbound: (String) -> Unit = {}
|
||||
var onOpenInApp: (String) -> Unit = {}
|
||||
var onQrOpen: () -> Unit = {}
|
||||
var onQrStatus: (String, Boolean) -> Unit = { _, _ -> }
|
||||
var onQrClose: () -> Unit = {}
|
||||
|
||||
fun drop() {
|
||||
instance?.let {
|
||||
(it.parent as? ViewGroup)?.removeView(it)
|
||||
it.destroy()
|
||||
}
|
||||
instance = null
|
||||
url = null
|
||||
}
|
||||
}
|
||||
|
||||
/** Inject the safe-area CSS vars from the CURRENT window insets. Android
|
||||
* WebView doesn't populate env(safe-area-inset-*); worse, on a cold start
|
||||
* onPageFinished can run before the view is attached — rootWindowInsets is
|
||||
* null then, and injecting 0px collapsed the UI's top/bottom margins (and
|
||||
* put the tab bar inside the gesture zone, killing its taps). Called from
|
||||
* onPageFinished, from the window-insets listener (fires when real insets
|
||||
* arrive), and on reattach. */
|
||||
private fun injectSafeAreaVars(view: WebView) {
|
||||
val insets = view.rootWindowInsets ?: return // listener re-fires when real
|
||||
val density = view.resources.displayMetrics.density
|
||||
val sat = (insets.getInsets(android.view.WindowInsets.Type.statusBars()).top / density).toInt()
|
||||
val sab = (insets.getInsets(android.view.WindowInsets.Type.navigationBars()).bottom / density).toInt()
|
||||
view.evaluateJavascript(
|
||||
"""
|
||||
(function() {
|
||||
var style = document.getElementById('archipelago-android-insets');
|
||||
if (!style) {
|
||||
style = document.createElement('style');
|
||||
style.id = 'archipelago-android-insets';
|
||||
document.head.appendChild(style);
|
||||
}
|
||||
style.textContent = ':root { --safe-area-top: ${sat}px; --safe-area-bottom: ${sab}px; }';
|
||||
// Vue components sample the var into reactive state; tell them it
|
||||
// changed (an authenticated session can mount before we run).
|
||||
window.dispatchEvent(new CustomEvent('archy-insets', { detail: { top: ${sat}, bottom: ${sab} } }));
|
||||
})();
|
||||
""".trimIndent(),
|
||||
null,
|
||||
)
|
||||
}
|
||||
|
||||
/** In-app browser pages (node apps + same-node links) don't consume the
|
||||
* neode-ui `--safe-area-top` var, so with the WebView drawing edge-to-edge
|
||||
* their content ran up under the status bar. Pad the document body down by
|
||||
* the status-bar height: the padded strip shows the page's OWN background
|
||||
* (padding is inside the element), so the bar keeps the page colour while
|
||||
* content starts below it — the pre-edge-to-edge look, without the black bar.
|
||||
*
|
||||
* Body padding only moves normal-flow content. fixed/sticky elements anchored
|
||||
* at the viewport top (IndeeHub's floating header) stayed glued under the
|
||||
* status bar, so we also push each of those down by the inset — once, marked
|
||||
* via data attribute — and keep a throttled MutationObserver running so
|
||||
* headers an SPA mounts after load get the same treatment.
|
||||
* Idempotent; runs on start (early) and finish (after the app rewrites head). */
|
||||
private fun injectTopInset(view: WebView) {
|
||||
val insets = view.rootWindowInsets ?: return
|
||||
val density = view.resources.displayMetrics.density
|
||||
val sat = (insets.getInsets(android.view.WindowInsets.Type.statusBars()).top / density).toInt()
|
||||
if (sat <= 0) return
|
||||
view.evaluateJavascript(
|
||||
"""
|
||||
(function() {
|
||||
var SAT = $sat;
|
||||
var s = document.getElementById('archy-top-inset');
|
||||
if (!s) {
|
||||
s = document.createElement('style');
|
||||
s.id = 'archy-top-inset';
|
||||
(document.head || document.documentElement).appendChild(s);
|
||||
}
|
||||
s.textContent =
|
||||
'body{padding-top:' + SAT + 'px!important;box-sizing:border-box!important;}';
|
||||
function push(el) {
|
||||
if (el.dataset.archyInset) return;
|
||||
var cs = getComputedStyle(el);
|
||||
if (cs.position !== 'fixed' && cs.position !== 'sticky') return;
|
||||
var top = parseFloat(cs.top); // 'auto' -> NaN skips bottom bars
|
||||
if (isNaN(top) || top >= SAT) return;
|
||||
el.style.setProperty('top', (top + SAT) + 'px', 'important');
|
||||
el.dataset.archyInset = '1';
|
||||
}
|
||||
function sweep() {
|
||||
if (!document.body) return;
|
||||
// Fixed/sticky bars live shallow in the tree (portals mount on
|
||||
// body); depth cap keeps the computed-style pass off big lists.
|
||||
var els = document.body.querySelectorAll(
|
||||
'body > *, body > * > *, body > * > * > *, body > * > * > * > *');
|
||||
for (var i = 0; i < els.length; i++) push(els[i]);
|
||||
}
|
||||
sweep();
|
||||
if (!window.__archyInsetObserver) {
|
||||
var queued = false, last = 0;
|
||||
window.__archyInsetObserver = new MutationObserver(function() {
|
||||
if (queued) return;
|
||||
queued = true;
|
||||
var wait = Math.max(0, 250 - (Date.now() - last));
|
||||
setTimeout(function() {
|
||||
queued = false;
|
||||
last = Date.now();
|
||||
sweep();
|
||||
}, wait);
|
||||
});
|
||||
window.__archyInsetObserver.observe(document.documentElement,
|
||||
{ childList: true, subtree: true });
|
||||
}
|
||||
})();
|
||||
""".trimIndent(),
|
||||
null,
|
||||
)
|
||||
}
|
||||
|
||||
/** True when a TCP listener answers at [base]'s host:port within [timeoutMs]. */
|
||||
private fun tcpAnswers(base: String, timeoutMs: Int): Boolean = try {
|
||||
val u = android.net.Uri.parse(base)
|
||||
val port = if (u.port != -1) u.port else if (u.scheme == "https") 443 else 80
|
||||
java.net.Socket().use {
|
||||
it.connect(java.net.InetSocketAddress(u.host, port), timeoutMs)
|
||||
true
|
||||
}
|
||||
} catch (_: Exception) {
|
||||
false
|
||||
}
|
||||
|
||||
/** Fastest answering origin: LAN inside a short window, else the mesh ULA
|
||||
* (patient — a cold session may still be establishing). If NEITHER answers,
|
||||
* fall back to the mesh URL when we have one — off-LAN the LAN IP is
|
||||
* unreachable, and loading it just produced a confusing "can't reach
|
||||
* 192.168.x.x" error page (user-reported 2026-07-27). Targeting the mesh URL
|
||||
* instead means the load retries against the path that's actually coming up,
|
||||
* and any error shows the mesh address rather than a dead LAN IP. */
|
||||
private suspend fun pickStartUrl(lanUrl: String, meshUrl: String?): String =
|
||||
withContext(Dispatchers.IO) {
|
||||
if (tcpAnswers(lanUrl, 2500)) return@withContext lanUrl
|
||||
if (meshUrl != null && tcpAnswers(meshUrl, 12_000)) return@withContext meshUrl
|
||||
meshUrl ?: lanUrl
|
||||
}
|
||||
|
||||
/** Apply the WebView settings shared by the kiosk view and the in-app browser.
|
||||
* These are tuned for SPA performance and parity with the mobile browser;
|
||||
* none of them alter how a page renders visually. */
|
||||
@@ -142,16 +324,53 @@ fun WebViewScreen(
|
||||
serverUrl: String,
|
||||
onDisconnect: () -> Unit,
|
||||
onRemoteInput: () -> Unit = {},
|
||||
// Like onRemoteInput but landing on the keyboard (the hub menu's Keyboard card).
|
||||
onRemoteKeyboard: () -> Unit = {},
|
||||
// Opens the phone-to-phone Mesh Party screen; null hides its hub card.
|
||||
onMeshParty: (() -> Unit)? = null,
|
||||
// Stored password for this server (from QR pairing or manual entry). When
|
||||
// non-blank, the login page is auto-filled and submitted — the one-step
|
||||
// demo flow from docs/companion-pairing-qr.md.
|
||||
serverPassword: String = "",
|
||||
// Node's FIPS mesh URL (http://[fd…]). When the primary address fails on
|
||||
// a main-frame load — typically the phone left the LAN — retry there
|
||||
// before surfacing the error page: the mesh tunnel works from anywhere.
|
||||
meshFallbackUrl: String? = null,
|
||||
) {
|
||||
var isLoading by remember { mutableStateOf(true) }
|
||||
// First kiosk load (often over the FIPS mesh) gets the full branded
|
||||
// loader; later navigations keep just the slim top progress bar.
|
||||
var firstLoadDone by remember { mutableStateOf(false) }
|
||||
LaunchedEffect(Unit) {
|
||||
snapshotFlow { isLoading }.first { !it }
|
||||
firstLoadDone = true
|
||||
}
|
||||
var loadProgress by remember { mutableIntStateOf(0) }
|
||||
var triedMeshFallback by remember { mutableStateOf(false) }
|
||||
var hasError by remember { mutableStateOf(false) }
|
||||
var webView by remember { mutableStateOf<WebView?>(null) }
|
||||
|
||||
// Race LAN vs mesh BEFORE the WebView exists: Chromium pointed at an
|
||||
// unreachable LAN IP burns a minute+ in connect retries before
|
||||
// onReceivedError fires the mesh fallback — the "stuck connecting"
|
||||
// stall. A raw TCP probe answers in milliseconds at home and fails in
|
||||
// ~2.5s off-LAN, so startup lands on the right origin in seconds.
|
||||
var startUrl by remember(serverUrl) { mutableStateOf<String?>(null) }
|
||||
var raceNonce by remember { mutableIntStateOf(0) }
|
||||
LaunchedEffect(serverUrl, meshFallbackUrl, raceNonce) {
|
||||
// A retained live session exists — reattach instantly: no race, no
|
||||
// reload, no re-login (remote ⇄ dashboard round trip).
|
||||
if (KioskWebView.instance != null && KioskWebView.url == serverUrl) {
|
||||
isLoading = false
|
||||
startUrl = serverUrl
|
||||
return@LaunchedEffect
|
||||
}
|
||||
val picked = pickStartUrl(serverUrl, meshFallbackUrl)
|
||||
// Starting on the mesh: don't bounce back to it on error (it IS it).
|
||||
if (picked != serverUrl) triedMeshFallback = true
|
||||
startUrl = picked
|
||||
}
|
||||
|
||||
// Web-page camera access (wallet QR scanner). The WebView's default
|
||||
// WebChromeClient silently denies getUserMedia, so grant video capture —
|
||||
// asking for the app-level CAMERA permission first when needed.
|
||||
@@ -171,6 +390,55 @@ fun WebViewScreen(
|
||||
// while this is shown, so closing it returns instantly with no reload.
|
||||
var inAppUrl by remember { mutableStateOf<String?>(null) }
|
||||
|
||||
// Same node = EITHER of its addresses. Over the mesh the kiosk's host is
|
||||
// the ULA while app links may carry the LAN IP (and vice versa) —
|
||||
// comparing against one host bounced same-node apps (Pine, Home
|
||||
// Assistant, BTCPay) out to the phone's external browser.
|
||||
fun isSameNode(url: String): Boolean =
|
||||
isSameHost(url, serverUrl) ||
|
||||
(meshFallbackUrl != null && isSameHost(url, meshFallbackUrl))
|
||||
|
||||
// Native wallet QR scanner, opened by the web UI via the ArchipelagoQr
|
||||
// bridge; status lines stream back from the page while it's up.
|
||||
var walletScannerVisible by remember { mutableStateOf(false) }
|
||||
var walletScannerStatus by remember { mutableStateOf<Pair<String, Boolean>?>(null) }
|
||||
|
||||
// One-time three-finger-hold teaching overlay (initial=true: never flash
|
||||
// it while DataStore is still loading).
|
||||
val prefs = remember { ServerPreferences(webViewContext) }
|
||||
|
||||
// Hub menu overlay state — the three-finger hold opens the menu right here
|
||||
// over the dashboard (it used to jump to the remote screen).
|
||||
val savedServers by prefs.savedServers.collectAsState(initial = emptyList())
|
||||
val activeServer by prefs.activeServer.collectAsState(initial = null)
|
||||
var showHubMenu by remember { mutableStateOf(false) }
|
||||
var showPairScanner by remember { mutableStateOf(false) }
|
||||
|
||||
val gestureHintSeen by prefs.gestureHintSeen.collectAsState(initial = true)
|
||||
var gestureHintDismissed by remember { mutableStateOf(false) }
|
||||
// Don't teach the gesture on top of the login/splash — arm the overlay
|
||||
// ~2 minutes after the kiosk first finishes loading, once the user has
|
||||
// settled in.
|
||||
var gestureHintReady by remember { mutableStateOf(false) }
|
||||
LaunchedEffect(Unit) {
|
||||
snapshotFlow { isLoading }.first { !it }
|
||||
delay(120_000)
|
||||
gestureHintReady = true
|
||||
}
|
||||
val scope = rememberCoroutineScope()
|
||||
|
||||
// <input type="file"> support — without a chooser implementation the
|
||||
// WebView silently ignores file inputs (broke the wallet's upload path).
|
||||
var pendingFileChooser by remember { mutableStateOf<ValueCallback<Array<android.net.Uri>>?>(null) }
|
||||
val fileChooserLauncher = rememberLauncherForActivityResult(
|
||||
ActivityResultContracts.StartActivityForResult(),
|
||||
) { result ->
|
||||
pendingFileChooser?.onReceiveValue(
|
||||
WebChromeClient.FileChooserParams.parseResult(result.resultCode, result.data),
|
||||
)
|
||||
pendingFileChooser = null
|
||||
}
|
||||
|
||||
BackHandler(enabled = inAppUrl == null && webView?.canGoBack() == true) {
|
||||
webView?.goBack()
|
||||
}
|
||||
@@ -219,9 +487,16 @@ fun WebViewScreen(
|
||||
GlassButton(
|
||||
text = stringResource(R.string.retry),
|
||||
onClick = {
|
||||
// Re-race LAN vs mesh — the network we're on may have
|
||||
// changed since the last pick. Drop the retained view:
|
||||
// an errored session must genuinely reload.
|
||||
KioskWebView.drop()
|
||||
webView = null
|
||||
hasError = false
|
||||
isLoading = true
|
||||
webView?.loadUrl(serverUrl)
|
||||
triedMeshFallback = false
|
||||
startUrl = null
|
||||
raceNonce++
|
||||
},
|
||||
modifier = Modifier.fillMaxWidth().height(56.dp),
|
||||
)
|
||||
@@ -230,17 +505,33 @@ fun WebViewScreen(
|
||||
|
||||
GlassButton(
|
||||
text = stringResource(R.string.disconnect),
|
||||
onClick = onDisconnect,
|
||||
onClick = {
|
||||
KioskWebView.drop()
|
||||
onDisconnect()
|
||||
},
|
||||
modifier = Modifier.fillMaxWidth().height(48.dp),
|
||||
)
|
||||
}
|
||||
} else if (startUrl == null) {
|
||||
// Racing LAN vs mesh (≤2.5s at home, a few seconds off-LAN) —
|
||||
// far cheaper than letting Chromium retry a dead LAN IP.
|
||||
MeshLoadingScreen()
|
||||
} else {
|
||||
// Edge-to-edge WebView — background bleeds behind status bar.
|
||||
// Safe area values injected as CSS env() polyfill on each page load.
|
||||
val initialUrl = startUrl ?: serverUrl
|
||||
AndroidView(
|
||||
modifier = Modifier.fillMaxSize(),
|
||||
factory = { context ->
|
||||
WebView(context).apply {
|
||||
// Reattach the retained kiosk WebView (remote ⇄ dashboard
|
||||
// must not reload the node UI). Everything configured
|
||||
// below is idempotent, and re-running it rebinds clients,
|
||||
// bridges and listeners to THIS composition's state —
|
||||
// stale closures from the previous visit are replaced.
|
||||
if (KioskWebView.url != serverUrl) KioskWebView.drop()
|
||||
val reused = KioskWebView.instance
|
||||
(reused ?: WebView(context)).apply {
|
||||
(parent as? ViewGroup)?.removeView(this)
|
||||
layoutParams = ViewGroup.LayoutParams(
|
||||
ViewGroup.LayoutParams.MATCH_PARENT,
|
||||
ViewGroup.LayoutParams.MATCH_PARENT,
|
||||
@@ -264,38 +555,90 @@ fun WebViewScreen(
|
||||
|
||||
val webViewRef = this
|
||||
|
||||
// Re-inject the safe-area vars whenever REAL insets
|
||||
// arrive — on cold start onPageFinished often beats
|
||||
// window attachment and would otherwise bake in 0px.
|
||||
setOnApplyWindowInsetsListener { v, insets ->
|
||||
(v as? WebView)?.let { injectSafeAreaVars(it) }
|
||||
v.onApplyWindowInsets(insets)
|
||||
}
|
||||
|
||||
// Decide where an outbound URL goes:
|
||||
// - same host as the node → in-app WebView overlay
|
||||
// (this is the "open in browser" target for apps the
|
||||
// kiosk couldn't iframe — keep the user inside the app)
|
||||
// - different host → the phone's real browser
|
||||
fun routeOutbound(url: String) {
|
||||
if (isSameHost(url, serverUrl)) {
|
||||
if (isSameNode(url)) {
|
||||
inAppUrl = url
|
||||
} else {
|
||||
openExternalUrl(context, url)
|
||||
}
|
||||
}
|
||||
|
||||
// Bridge callbacks are DELEGATED through the holder:
|
||||
// the interface objects registered on a retained
|
||||
// WebView survive recomposition, and re-registering
|
||||
// them doesn't reliably swap the JS-visible object
|
||||
// without a reload — with direct closures, a
|
||||
// remote ⇄ dashboard round trip left the bridges
|
||||
// writing to a dead composition's state ("apps don't
|
||||
// launch"). These assignments re-point the live
|
||||
// interface objects at THIS composition every attach.
|
||||
KioskWebView.onRouteOutbound = { url -> routeOutbound(url) }
|
||||
KioskWebView.onOpenInApp = { url -> inAppUrl = url }
|
||||
KioskWebView.onQrOpen = {
|
||||
walletScannerStatus = null
|
||||
walletScannerVisible = true
|
||||
}
|
||||
KioskWebView.onQrStatus = { msg, err -> walletScannerStatus = msg to err }
|
||||
KioskWebView.onQrClose = { walletScannerVisible = false }
|
||||
|
||||
// JS bridge. The web UI calls:
|
||||
// window.ArchipelagoNative.openExternal(url) — host-routed
|
||||
// window.ArchipelagoNative.openInApp(url) — force in-app
|
||||
// Falls back to window.open in a plain mobile browser.
|
||||
addJavascriptInterface(
|
||||
if (reused == null) addJavascriptInterface(
|
||||
object {
|
||||
@android.webkit.JavascriptInterface
|
||||
fun openExternal(url: String) {
|
||||
webViewRef.post { routeOutbound(url) }
|
||||
webViewRef.post { KioskWebView.onRouteOutbound(url) }
|
||||
}
|
||||
|
||||
@android.webkit.JavascriptInterface
|
||||
fun openInApp(url: String) {
|
||||
webViewRef.post { inAppUrl = url }
|
||||
webViewRef.post { KioskWebView.onOpenInApp(url) }
|
||||
}
|
||||
},
|
||||
"ArchipelagoNative",
|
||||
)
|
||||
|
||||
// Wallet QR bridge. The web scan modal calls:
|
||||
// window.ArchipelagoQr.open() — show the native scanner
|
||||
// window.ArchipelagoQr.setStatus(msg, e) — mirror status/progress lines
|
||||
// window.ArchipelagoQr.close() — code accepted, tear down
|
||||
// Decodes flow back through window.__archyQrResult(text);
|
||||
// a user cancel calls window.__archyQrCancelled().
|
||||
if (reused == null) addJavascriptInterface(
|
||||
object {
|
||||
@android.webkit.JavascriptInterface
|
||||
fun open() {
|
||||
webViewRef.post { KioskWebView.onQrOpen() }
|
||||
}
|
||||
|
||||
@android.webkit.JavascriptInterface
|
||||
fun setStatus(message: String, isError: Boolean) {
|
||||
webViewRef.post { KioskWebView.onQrStatus(message, isError) }
|
||||
}
|
||||
|
||||
@android.webkit.JavascriptInterface
|
||||
fun close() {
|
||||
webViewRef.post { KioskWebView.onQrClose() }
|
||||
}
|
||||
},
|
||||
"ArchipelagoQr",
|
||||
)
|
||||
|
||||
webViewClient = object : WebViewClient() {
|
||||
override fun onPageStarted(view: WebView?, url: String?, favicon: Bitmap?) {
|
||||
isLoading = true
|
||||
@@ -306,38 +649,16 @@ fun WebViewScreen(
|
||||
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,
|
||||
)
|
||||
injectSafeAreaVars(view)
|
||||
|
||||
// Auto-login with the stored password (QR pairing /
|
||||
// saved server) — only on our own server's pages.
|
||||
if (serverPassword.isNotBlank() && url != null && url.startsWith(serverUrl)) {
|
||||
// saved server) — only on our own server's pages
|
||||
// (LAN origin or the mesh address).
|
||||
val onOwnServer = url != null && (
|
||||
url.startsWith(serverUrl) ||
|
||||
(meshFallbackUrl != null && url.startsWith(meshFallbackUrl))
|
||||
)
|
||||
if (serverPassword.isNotBlank() && onOwnServer) {
|
||||
view.evaluateJavascript(buildAutoLoginScript(serverPassword), null)
|
||||
}
|
||||
}
|
||||
@@ -348,6 +669,15 @@ fun WebViewScreen(
|
||||
error: WebResourceError?,
|
||||
) {
|
||||
if (request?.isForMainFrame == true) {
|
||||
val mesh = meshFallbackUrl
|
||||
if (mesh != null && !triedMeshFallback &&
|
||||
request.url?.toString()?.startsWith(mesh) != true
|
||||
) {
|
||||
triedMeshFallback = true
|
||||
isLoading = true
|
||||
view?.loadUrl(mesh)
|
||||
return
|
||||
}
|
||||
hasError = true
|
||||
isLoading = false
|
||||
}
|
||||
@@ -366,7 +696,7 @@ fun WebViewScreen(
|
||||
error: android.net.http.SslError?,
|
||||
) {
|
||||
val u = error?.url
|
||||
if (u != null && isSameHost(u, serverUrl)) {
|
||||
if (u != null && isSameNode(u)) {
|
||||
handler?.proceed()
|
||||
} else {
|
||||
handler?.cancel()
|
||||
@@ -380,6 +710,8 @@ fun WebViewScreen(
|
||||
val url = request?.url?.toString() ?: return false
|
||||
// Keep kiosk navigation (same origin incl. port) in place
|
||||
if (url.startsWith(serverUrl)) return false
|
||||
// Mesh address is the same server too
|
||||
if (meshFallbackUrl != null && url.startsWith(meshFallbackUrl)) return false
|
||||
// Same node (other port) → in-app; external → browser
|
||||
routeOutbound(url)
|
||||
return true
|
||||
@@ -391,6 +723,27 @@ fun WebViewScreen(
|
||||
loadProgress = newProgress
|
||||
}
|
||||
|
||||
override fun onShowFileChooser(
|
||||
view: WebView?,
|
||||
filePathCallback: ValueCallback<Array<android.net.Uri>>?,
|
||||
fileChooserParams: FileChooserParams?,
|
||||
): Boolean {
|
||||
pendingFileChooser?.onReceiveValue(null)
|
||||
pendingFileChooser = filePathCallback
|
||||
val intent = fileChooserParams?.createIntent()
|
||||
if (intent == null) {
|
||||
pendingFileChooser = null
|
||||
return false
|
||||
}
|
||||
return try {
|
||||
fileChooserLauncher.launch(intent)
|
||||
true
|
||||
} catch (_: Exception) {
|
||||
pendingFileChooser = null
|
||||
false
|
||||
}
|
||||
}
|
||||
|
||||
// Wallet QR scanner: grant the page camera access.
|
||||
// Only video capture is granted — anything else the
|
||||
// page asks for is denied as before.
|
||||
@@ -446,31 +799,34 @@ fun WebViewScreen(
|
||||
}
|
||||
}
|
||||
|
||||
// Two-finger hold (500ms) → navigate to remote input
|
||||
var twoFingerStart = 0L
|
||||
var twoFingerFired = false
|
||||
// Three-finger hold (500ms) → open the hub menu overlay
|
||||
// in place (Remote/Keyboard cards do the navigating).
|
||||
// Three fingers, not two: two-finger scroll/pinch on the
|
||||
// page collided with the old two-finger hold.
|
||||
var threeFingerStart = 0L
|
||||
var threeFingerFired = false
|
||||
setOnTouchListener { _, event ->
|
||||
val pointerCount = event.pointerCount
|
||||
when (event.actionMasked) {
|
||||
android.view.MotionEvent.ACTION_POINTER_DOWN -> {
|
||||
if (pointerCount >= 2) {
|
||||
twoFingerStart = System.currentTimeMillis()
|
||||
twoFingerFired = false
|
||||
if (pointerCount >= 3) {
|
||||
threeFingerStart = System.currentTimeMillis()
|
||||
threeFingerFired = false
|
||||
}
|
||||
}
|
||||
android.view.MotionEvent.ACTION_MOVE -> {
|
||||
if (pointerCount >= 2 && !twoFingerFired && twoFingerStart > 0) {
|
||||
if (System.currentTimeMillis() - twoFingerStart > 500) {
|
||||
twoFingerFired = true
|
||||
onRemoteInput()
|
||||
if (pointerCount >= 3 && !threeFingerFired && threeFingerStart > 0) {
|
||||
if (System.currentTimeMillis() - threeFingerStart > 500) {
|
||||
threeFingerFired = true
|
||||
showHubMenu = true
|
||||
}
|
||||
}
|
||||
}
|
||||
android.view.MotionEvent.ACTION_UP,
|
||||
android.view.MotionEvent.ACTION_POINTER_UP,
|
||||
android.view.MotionEvent.ACTION_CANCEL -> {
|
||||
if (event.pointerCount <= 2) {
|
||||
twoFingerStart = 0L
|
||||
if (event.pointerCount <= 3) {
|
||||
threeFingerStart = 0L
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -478,7 +834,21 @@ fun WebViewScreen(
|
||||
}
|
||||
|
||||
webView = this
|
||||
loadUrl(serverUrl)
|
||||
if (reused == null) {
|
||||
KioskWebView.instance = this
|
||||
KioskWebView.url = serverUrl
|
||||
loadUrl(initialUrl)
|
||||
} else {
|
||||
// Reattached views keep stale measurements until an
|
||||
// input event — that was the top/bottom UI being
|
||||
// wrong until a tap. Force a fresh pass, and re-sync
|
||||
// the page's safe-area vars while we're at it.
|
||||
post {
|
||||
requestLayout()
|
||||
invalidate()
|
||||
injectSafeAreaVars(this)
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
)
|
||||
@@ -497,16 +867,144 @@ fun WebViewScreen(
|
||||
)
|
||||
}
|
||||
|
||||
// Branded first-load screen while the mesh session comes up.
|
||||
AnimatedVisibility(
|
||||
visible = isLoading && !firstLoadDone,
|
||||
enter = fadeIn(),
|
||||
exit = fadeOut(),
|
||||
) {
|
||||
Column(
|
||||
Modifier.fillMaxSize().background(SurfaceBlack),
|
||||
horizontalAlignment = Alignment.CenterHorizontally,
|
||||
verticalArrangement = Arrangement.Center,
|
||||
) {
|
||||
Text(
|
||||
buildAnnotatedString {
|
||||
withStyle(SpanStyle(color = ErrorRed)) { append("F*CK") }
|
||||
withStyle(SpanStyle(color = TextPrimary)) { append(" IPS") }
|
||||
},
|
||||
fontSize = 40.sp,
|
||||
fontWeight = FontWeight.Black,
|
||||
letterSpacing = 4.sp,
|
||||
textAlign = TextAlign.Center,
|
||||
)
|
||||
Spacer(Modifier.height(10.dp))
|
||||
Text(
|
||||
"connecting to your archipelago",
|
||||
color = TextMuted,
|
||||
fontSize = 13.sp,
|
||||
letterSpacing = 1.sp,
|
||||
)
|
||||
Spacer(Modifier.height(28.dp))
|
||||
CircularProgressIndicator(color = BitcoinOrange)
|
||||
}
|
||||
}
|
||||
|
||||
// In-app browser overlay for non-iframeable node apps. Rendered last
|
||||
// so it sits above the kiosk WebView, which stays alive underneath.
|
||||
inAppUrl?.let { target ->
|
||||
InAppBrowser(
|
||||
url = target,
|
||||
serverUrl = serverUrl,
|
||||
meshUrl = meshFallbackUrl,
|
||||
onClose = { inAppUrl = null },
|
||||
)
|
||||
}
|
||||
|
||||
// Native wallet QR scanner, opened by the page via ArchipelagoQr.
|
||||
WalletQrScannerModal(
|
||||
visible = walletScannerVisible,
|
||||
status = walletScannerStatus,
|
||||
onDecoded = { text ->
|
||||
webView?.evaluateJavascript(
|
||||
"window.__archyQrResult && window.__archyQrResult(${JSONObject.quote(text)})",
|
||||
null,
|
||||
)
|
||||
},
|
||||
onDismiss = {
|
||||
walletScannerVisible = false
|
||||
webView?.evaluateJavascript(
|
||||
"window.__archyQrCancelled && window.__archyQrCancelled()",
|
||||
null,
|
||||
)
|
||||
},
|
||||
)
|
||||
|
||||
// First-launch teaching overlay for the three-finger hold — armed
|
||||
// ~2 minutes after login so it never fights the splash/first look.
|
||||
if (gestureHintReady && !gestureHintSeen && !gestureHintDismissed &&
|
||||
!isLoading && inAppUrl == null
|
||||
) {
|
||||
GestureHintOverlay(
|
||||
onDismiss = {
|
||||
gestureHintDismissed = true
|
||||
scope.launch { prefs.markGestureHintSeen() }
|
||||
},
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
// Hub menu overlay — opened by the three-finger hold, drawn above
|
||||
// everything (also reachable from the error screen, where switching
|
||||
// servers is exactly what's needed).
|
||||
NESMenu(
|
||||
visible = showHubMenu,
|
||||
servers = savedServers,
|
||||
activeServer = activeServer,
|
||||
onDismiss = { showHubMenu = false },
|
||||
onSelectServer = { server ->
|
||||
showHubMenu = false
|
||||
scope.launch { prefs.setActiveServer(server) }
|
||||
},
|
||||
onAddServer = { server ->
|
||||
scope.launch {
|
||||
prefs.addSavedServer(server)
|
||||
if (activeServer == null) prefs.setActiveServer(server)
|
||||
}
|
||||
},
|
||||
onScanQr = { showPairScanner = true },
|
||||
onEditServer = { original, updated ->
|
||||
scope.launch {
|
||||
prefs.updateSavedServer(original, updated)
|
||||
// Editing the live server reloads the kiosk with the new
|
||||
// address/credentials via the activeServer recomposition.
|
||||
if (original.serialize() == activeServer?.serialize()) {
|
||||
prefs.setActiveServer(updated)
|
||||
}
|
||||
}
|
||||
},
|
||||
onRemoveServer = { server ->
|
||||
scope.launch {
|
||||
prefs.removeSavedServer(server)
|
||||
// Nothing left to show — back to the Connect screen.
|
||||
val remaining = savedServers.count { it.serialize() != server.serialize() }
|
||||
if (remaining == 0) {
|
||||
prefs.clearActiveServer()
|
||||
showHubMenu = false
|
||||
onDisconnect()
|
||||
}
|
||||
}
|
||||
},
|
||||
onRemote = { showHubMenu = false; onRemoteInput() },
|
||||
onKeyboard = { showHubMenu = false; onRemoteKeyboard() },
|
||||
onBackToWebView = { showHubMenu = false },
|
||||
onMeshParty = onMeshParty?.let { open -> { showHubMenu = false; open() } },
|
||||
)
|
||||
|
||||
// Pairing-QR scan launched from the menu's Nodes page; the menu stays
|
||||
// open behind it so the new entry appears as soon as it closes.
|
||||
QrScannerOverlay(
|
||||
visible = showPairScanner,
|
||||
onDismiss = { showPairScanner = false },
|
||||
onServerScanned = { scan ->
|
||||
showPairScanner = false
|
||||
scope.launch {
|
||||
val merged = prefs.upsertServer(scan.server)
|
||||
FipsManager.registerNode(webViewContext, scan.fips, merged.displayName())
|
||||
if (activeServer == null) prefs.setActiveServer(merged)
|
||||
}
|
||||
},
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -544,11 +1042,26 @@ private fun fetchFavicon(pageUrl: String): Bitmap? {
|
||||
private fun InAppBrowser(
|
||||
url: String,
|
||||
serverUrl: String,
|
||||
meshUrl: String? = null,
|
||||
onClose: () -> Unit,
|
||||
) {
|
||||
val context = LocalContext.current
|
||||
// Same-node check across BOTH node addresses (LAN + mesh ULA) — see the
|
||||
// kiosk's isSameNode; a mismatch here bounced app links to the browser.
|
||||
fun isSameNode(u: String): Boolean =
|
||||
isSameHost(u, serverUrl) || (meshUrl != null && isSameHost(u, meshUrl))
|
||||
var browser by remember { mutableStateOf<WebView?>(null) }
|
||||
var title by remember { mutableStateOf(android.net.Uri.parse(url).host ?: url) }
|
||||
// Loader title: never show a raw IP host — a mesh ULA like
|
||||
// [fd79:1aa:…] is technically the host but reads as garbage on the
|
||||
// loading screen. Show a neutral name until the page reports its
|
||||
// real <title> (onReceivedTitle upgrades it).
|
||||
var title by remember {
|
||||
mutableStateOf(
|
||||
android.net.Uri.parse(url).host
|
||||
?.takeUnless { it.contains(':') || it.matches(Regex("^\\d+(\\.\\d+){3}$")) }
|
||||
?: "Archipelago",
|
||||
)
|
||||
}
|
||||
var favicon by remember { mutableStateOf<Bitmap?>(null) }
|
||||
var progress by remember { mutableIntStateOf(0) }
|
||||
var loading by remember { mutableStateOf(true) }
|
||||
@@ -586,7 +1099,22 @@ private fun InAppBrowser(
|
||||
modifier = Modifier
|
||||
.fillMaxSize()
|
||||
.background(SurfaceBlack)
|
||||
.windowInsetsPadding(WindowInsets.safeDrawing),
|
||||
// Whole-overlay touch shield: every touch not handled by a child
|
||||
// (control-bar gaps, inset strips) dies here instead of falling
|
||||
// through to the kiosk's tab bar behind (a near-miss on Close
|
||||
// was opening the AIUI tab underneath).
|
||||
.clickable(
|
||||
interactionSource = remember { MutableInteractionSource() },
|
||||
indication = null,
|
||||
onClick = {},
|
||||
)
|
||||
// Bottom inset handled by the touch-shield strip below the bar.
|
||||
// No TOP inset padding: the WebView draws edge-to-edge behind the
|
||||
// status bar so the app's own background fills it — the padded
|
||||
// version painted an opaque black bar there (user-rejected look).
|
||||
.windowInsetsPadding(
|
||||
WindowInsets.safeDrawing.only(WindowInsetsSides.Horizontal)
|
||||
),
|
||||
) {
|
||||
// WebView + loading overlay fill the area above the bottom control bar.
|
||||
Box(modifier = Modifier.weight(1f).fillMaxWidth()) {
|
||||
@@ -637,12 +1165,14 @@ private fun InAppBrowser(
|
||||
webViewClient = object : WebViewClient() {
|
||||
override fun onPageStarted(view: WebView?, u: String?, favicon: Bitmap?) {
|
||||
loading = true
|
||||
view?.let { injectTopInset(it) }
|
||||
}
|
||||
|
||||
override fun onPageFinished(view: WebView?, u: String?) {
|
||||
loading = false
|
||||
canGoBack = view?.canGoBack() == true
|
||||
canGoForward = view?.canGoForward() == true
|
||||
view?.let { injectTopInset(it) }
|
||||
}
|
||||
|
||||
override fun doUpdateVisitedHistory(view: WebView?, u: String?, isReload: Boolean) {
|
||||
@@ -660,7 +1190,7 @@ private fun InAppBrowser(
|
||||
error: android.net.http.SslError?,
|
||||
) {
|
||||
val u = error?.url
|
||||
if (u != null && isSameHost(u, serverUrl)) {
|
||||
if (u != null && isSameNode(u)) {
|
||||
handler?.proceed()
|
||||
} else {
|
||||
handler?.cancel()
|
||||
@@ -674,7 +1204,7 @@ private fun InAppBrowser(
|
||||
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
|
||||
if (isSameNode(u)) return false
|
||||
openExternalUrl(ctx, u)
|
||||
return true
|
||||
}
|
||||
@@ -775,6 +1305,21 @@ private fun InAppBrowser(
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
// Touch-shield over the gesture-nav strip: solid black AND consumes
|
||||
// taps — stray touches below the control bar landed on the kiosk's
|
||||
// tab bar behind this overlay (opening the AIUI chat by accident).
|
||||
Box(
|
||||
Modifier
|
||||
.fillMaxWidth()
|
||||
.windowInsetsBottomHeight(WindowInsets.navigationBars)
|
||||
.background(Color.Black)
|
||||
.clickable(
|
||||
interactionSource = remember { MutableInteractionSource() },
|
||||
indication = null,
|
||||
onClick = {},
|
||||
),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -11,6 +11,7 @@
|
||||
<string name="welcome_title">Your Sovereign\nPersonal Server</string>
|
||||
<string name="welcome_subtitle">Bitcoin node, app platform, and private cloud — all in one box you control.</string>
|
||||
<string name="get_started">Get Started</string>
|
||||
<string name="mesh_party">Mesh Party</string>
|
||||
<string name="use_https">Use HTTPS</string>
|
||||
<string name="port_label">Port (optional)</string>
|
||||
<string name="saved_servers">Saved Servers</string>
|
||||
@@ -41,4 +42,11 @@
|
||||
<string name="edit_server_title">Edit Server</string>
|
||||
<string name="save_changes">Save Changes</string>
|
||||
<string name="cancel">Cancel</string>
|
||||
<string name="gesture_hint_title">Hold with three fingers</string>
|
||||
<string name="gesture_hint_body">Anywhere in the app — opens the remote control and menu</string>
|
||||
<string name="gesture_hint_got_it">Got it</string>
|
||||
<string name="scan_to_send">Scan to send</string>
|
||||
<string name="scan_wallet_hint">Point the camera at a Lightning invoice, Bitcoin address, Cashu or Fedimint code</string>
|
||||
<string name="upload_qr_image">Upload image</string>
|
||||
<string name="no_qr_in_image">No QR code found in that image — try another, closer and well-lit</string>
|
||||
</resources>
|
||||
|
||||
@@ -0,0 +1,5 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<!-- FileProvider scope for the party-screen "Share this app" APK handoff. -->
|
||||
<paths>
|
||||
<cache-path name="share" path="share/" />
|
||||
</paths>
|
||||
Generated
+1690
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,48 @@
|
||||
# Embedded FIPS mesh node for the Archipelago companion app.
|
||||
#
|
||||
# Built for Android via cargo-ndk (see Android/app/build.gradle.kts, task
|
||||
# buildRustArm64) into app/src/main/jniLibs/arm64-v8a/libarchy_fips_core.so.
|
||||
# Also builds on the host so `cargo test` covers the non-JNI logic.
|
||||
#
|
||||
# `fips` is pinned to the fips-native fork rev that this integration was
|
||||
# developed against — the fork carries Android support upstream lacks
|
||||
# (Tun::from_fd for a VpnService-owned fd, cfg(target_os = "android") paths).
|
||||
# Override with a local checkout when hacking on fips itself:
|
||||
# CARGO_NET_OFFLINE=false cargo ndk ... --config 'patch."https://github.com/9qeklajc/fips-native".fips.path="/path/to/fips-native/fips"'
|
||||
[package]
|
||||
name = "archy-fips-core"
|
||||
version = "0.1.0"
|
||||
edition = "2021"
|
||||
license = "MIT"
|
||||
publish = false
|
||||
|
||||
[lib]
|
||||
name = "archy_fips_core"
|
||||
# rlib for host tests; cdylib for the Android shared library.
|
||||
crate-type = ["lib", "cdylib"]
|
||||
|
||||
[dependencies]
|
||||
# default-features drops the ratatui TUI; tun-support enables Node::start_with_tun_fd.
|
||||
# Zazawowow/fips-native `fast-join-pinned` = upstream pinned rev 46494a74 +
|
||||
# discovery re-fire on topology change (fresh 5G join: first route no longer
|
||||
# waits out doomed pre-join lookups). Upstream 9qeklajc denies pushes.
|
||||
fips = { git = "https://github.com/Zazawowow/fips-native", rev = "07d21d4482be56b14295d2525e41f8386d1bfe6f", default-features = false, features = ["tun-support"] }
|
||||
anyhow = "1.0"
|
||||
serde_json = "1.0"
|
||||
hex = "0.4"
|
||||
# OS CSPRNG for identity generation (crypto rule: no thread-local RNG for keys).
|
||||
getrandom = "0.2"
|
||||
tokio = { version = "1", features = ["rt-multi-thread", "sync", "time", "macros"] }
|
||||
tracing = "0.1"
|
||||
# fcntl: force the VpnService TUN fd into blocking mode (see mesh::start).
|
||||
libc = "0.2"
|
||||
|
||||
# The JNI surface only exists on Android; host builds skip it and drive the
|
||||
# mesh module directly (tests).
|
||||
[target.'cfg(target_os = "android")'.dependencies]
|
||||
jni = "0.21"
|
||||
# Bridge `tracing` (ours + fips) to logcat: `adb logcat -s archy-fips`.
|
||||
tracing-subscriber = { version = "0.3", features = ["env-filter"] }
|
||||
paranoid-android = "0.2"
|
||||
|
||||
[workspace]
|
||||
@@ -0,0 +1,129 @@
|
||||
//! JNI surface for `com.archipelago.app.fips.FipsNative` — JSON over strings,
|
||||
//! no codegen (the myco / nostr-vpn embedding pattern). Errors come back as
|
||||
//! `{"error": "…"}` so Kotlin never sees a raw exception from native code.
|
||||
|
||||
use std::sync::Once;
|
||||
|
||||
use jni::objects::{JClass, JString};
|
||||
use jni::sys::{jboolean, jint, jstring};
|
||||
use jni::JNIEnv;
|
||||
|
||||
use crate::mesh;
|
||||
|
||||
static LOG_INIT: Once = Once::new();
|
||||
|
||||
fn init_logging() {
|
||||
LOG_INIT.call_once(|| {
|
||||
use tracing_subscriber::layer::SubscriberExt;
|
||||
use tracing_subscriber::util::SubscriberInitExt;
|
||||
let _ = tracing_subscriber::registry()
|
||||
.with(tracing_subscriber::EnvFilter::new("info"))
|
||||
.with(paranoid_android::layer("archy-fips"))
|
||||
.try_init();
|
||||
});
|
||||
}
|
||||
|
||||
fn jstr(env: &mut JNIEnv, s: &JString) -> String {
|
||||
env.get_string(s).map(|s| s.into()).unwrap_or_default()
|
||||
}
|
||||
|
||||
fn out(env: &JNIEnv, s: String) -> jstring {
|
||||
env.new_string(s)
|
||||
.map(|s| s.into_raw())
|
||||
.unwrap_or(std::ptr::null_mut())
|
||||
}
|
||||
|
||||
fn err_json(e: impl std::fmt::Display) -> String {
|
||||
serde_json::json!({ "error": e.to_string() }).to_string()
|
||||
}
|
||||
|
||||
fn identity_json(info: &mesh::IdentityInfo) -> String {
|
||||
serde_json::json!({
|
||||
"secret": info.secret_hex,
|
||||
"npub": info.npub,
|
||||
"address": info.address,
|
||||
})
|
||||
.to_string()
|
||||
}
|
||||
|
||||
/// Kotlin: `external fun generateIdentity(): String`
|
||||
#[no_mangle]
|
||||
pub extern "system" fn Java_com_archipelago_app_fips_FipsNative_generateIdentity(
|
||||
env: JNIEnv,
|
||||
_class: JClass,
|
||||
) -> jstring {
|
||||
init_logging();
|
||||
let json = match mesh::generate_identity() {
|
||||
Ok(info) => identity_json(&info),
|
||||
Err(e) => err_json(e),
|
||||
};
|
||||
out(&env, json)
|
||||
}
|
||||
|
||||
/// Kotlin: `external fun deriveIdentity(secret: String): String`
|
||||
#[no_mangle]
|
||||
pub extern "system" fn Java_com_archipelago_app_fips_FipsNative_deriveIdentity(
|
||||
mut env: JNIEnv,
|
||||
_class: JClass,
|
||||
secret: JString,
|
||||
) -> jstring {
|
||||
init_logging();
|
||||
let secret = jstr(&mut env, &secret);
|
||||
let json = match mesh::derive_identity(&secret) {
|
||||
Ok(info) => identity_json(&info),
|
||||
Err(e) => err_json(e),
|
||||
};
|
||||
out(&env, json)
|
||||
}
|
||||
|
||||
/// Kotlin: `external fun start(secret: String, peersJson: String, tunFd: Int, listenPort: Int): String`
|
||||
/// Returns `{"npub": "...", "address": "..."}` or `{"error": "..."}`.
|
||||
/// `listenPort` 0 = outbound-only; non-zero = fixed UDP bind (party mode).
|
||||
#[no_mangle]
|
||||
pub extern "system" fn Java_com_archipelago_app_fips_FipsNative_start(
|
||||
mut env: JNIEnv,
|
||||
_class: JClass,
|
||||
secret: JString,
|
||||
peers_json: JString,
|
||||
tun_fd: jint,
|
||||
listen_port: jint,
|
||||
) -> jstring {
|
||||
init_logging();
|
||||
let secret = jstr(&mut env, &secret);
|
||||
let peers = jstr(&mut env, &peers_json);
|
||||
let listen_port = u16::try_from(listen_port).unwrap_or(0);
|
||||
let json = match mesh::start(&secret, &peers, tun_fd, listen_port) {
|
||||
Ok((npub, address)) => {
|
||||
serde_json::json!({ "npub": npub, "address": address }).to_string()
|
||||
}
|
||||
Err(e) => err_json(e),
|
||||
};
|
||||
out(&env, json)
|
||||
}
|
||||
|
||||
/// Kotlin: `external fun stop()`
|
||||
#[no_mangle]
|
||||
pub extern "system" fn Java_com_archipelago_app_fips_FipsNative_stop(
|
||||
_env: JNIEnv,
|
||||
_class: JClass,
|
||||
) {
|
||||
mesh::stop();
|
||||
}
|
||||
|
||||
/// Kotlin: `external fun isRunning(): Boolean`
|
||||
#[no_mangle]
|
||||
pub extern "system" fn Java_com_archipelago_app_fips_FipsNative_isRunning(
|
||||
_env: JNIEnv,
|
||||
_class: JClass,
|
||||
) -> jboolean {
|
||||
mesh::is_running() as jboolean
|
||||
}
|
||||
|
||||
/// Kotlin: `external fun statusJson(): String`
|
||||
#[no_mangle]
|
||||
pub extern "system" fn Java_com_archipelago_app_fips_FipsNative_statusJson(
|
||||
env: JNIEnv,
|
||||
_class: JClass,
|
||||
) -> jstring {
|
||||
out(&env, mesh::status_json())
|
||||
}
|
||||
@@ -0,0 +1,17 @@
|
||||
//! Embedded FIPS mesh node for the Archipelago companion app.
|
||||
//!
|
||||
//! The phone runs a real, leaf-only FIPS node in-process: Android's
|
||||
//! `VpnService` owns the TUN fd (routing only `fd00::/8`, so normal traffic
|
||||
//! never touches the tunnel) and hands it to [`fips::Node::start_with_tun_fd`].
|
||||
//! Peering is outbound-only — the pairing QR carries the node's npub and
|
||||
//! transport endpoints, and FIPS nodes accept inbound peers without prior
|
||||
//! registration, so no server-side enrollment step exists.
|
||||
//!
|
||||
//! The JNI surface (`jni_glue`, Android-only) is deliberately tiny and
|
||||
//! JSON-over-strings, mirroring the myco / nostr-vpn embedding pattern:
|
||||
//! `generateIdentity`, `deriveIdentity`, `start`, `stop`, `isRunning`.
|
||||
|
||||
pub mod mesh;
|
||||
|
||||
#[cfg(target_os = "android")]
|
||||
mod jni_glue;
|
||||
@@ -0,0 +1,295 @@
|
||||
//! Mesh lifecycle: identity, config assembly, and the node task.
|
||||
//!
|
||||
//! Host-buildable (no JNI) so the config/identity logic is unit-testable;
|
||||
//! only [`start`] needs a real TUN fd and therefore only runs on-device.
|
||||
|
||||
use std::sync::atomic::{AtomicBool, Ordering};
|
||||
use std::sync::{Arc, Mutex};
|
||||
use std::time::Duration;
|
||||
|
||||
use anyhow::{anyhow, Context, Result};
|
||||
use fips::config::{PeerConfig, TransportInstances, UdpConfig};
|
||||
use fips::{Config, Identity, Node};
|
||||
use tokio::sync::Notify;
|
||||
|
||||
/// How long `start` waits for the node to come up (TUN attach + transports).
|
||||
const START_TIMEOUT: Duration = Duration::from_secs(15);
|
||||
|
||||
/// How long `stop` waits for the node task to drain.
|
||||
const STOP_TIMEOUT: Duration = Duration::from_secs(5);
|
||||
|
||||
struct MeshHandle {
|
||||
runtime: tokio::runtime::Runtime,
|
||||
task: Option<tokio::task::JoinHandle<()>>,
|
||||
shutdown: Arc<Notify>,
|
||||
running: Arc<AtomicBool>,
|
||||
npub: String,
|
||||
address: String,
|
||||
}
|
||||
|
||||
static MESH: Mutex<Option<MeshHandle>> = Mutex::new(None);
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct IdentityInfo {
|
||||
pub secret_hex: String,
|
||||
pub npub: String,
|
||||
/// The phone's own ULA on the mesh (fd::/8), for VpnService.addAddress.
|
||||
pub address: String,
|
||||
}
|
||||
|
||||
/// Generate a fresh mesh identity from the OS CSPRNG.
|
||||
pub fn generate_identity() -> Result<IdentityInfo> {
|
||||
// ~1 in 2^128 chance a candidate is off the curve; loop regardless.
|
||||
loop {
|
||||
let mut bytes = [0u8; 32];
|
||||
getrandom::getrandom(&mut bytes).context("OS RNG")?;
|
||||
if let Ok(id) = Identity::from_secret_bytes(&bytes) {
|
||||
return Ok(IdentityInfo {
|
||||
secret_hex: hex::encode(bytes),
|
||||
npub: id.npub(),
|
||||
address: id.address().to_ipv6().to_string(),
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Re-derive npub + ULA from a stored secret.
|
||||
pub fn derive_identity(secret: &str) -> Result<IdentityInfo> {
|
||||
let id = Identity::from_secret_str(secret).map_err(|e| anyhow!("bad secret: {e}"))?;
|
||||
Ok(IdentityInfo {
|
||||
secret_hex: secret.to_string(),
|
||||
npub: id.npub(),
|
||||
address: id.address().to_ipv6().to_string(),
|
||||
})
|
||||
}
|
||||
|
||||
/// Build the phone-side node config: leaf-only (never routes third-party
|
||||
/// traffic — battery), no DNS responder, TUN enabled but attached to the
|
||||
/// VpnService fd rather than created.
|
||||
///
|
||||
/// `listen_port` 0 = ephemeral UDP (outbound-only, the default posture).
|
||||
/// Non-zero = fixed UDP bind so a nearby phone can dial us directly over a
|
||||
/// local link (party mode); leaf_only still guarantees we never carry
|
||||
/// third-party transit even while accepting an inbound link.
|
||||
pub fn build_config(secret: &str, peers: Vec<PeerConfig>, listen_port: u16) -> Config {
|
||||
let mut cfg = Config::default();
|
||||
cfg.node.identity.nsec = Some(secret.to_string());
|
||||
cfg.node.identity.persistent = false;
|
||||
cfg.node.leaf_only = true;
|
||||
cfg.tun.enabled = true;
|
||||
cfg.tun.mtu = Some(1280);
|
||||
cfg.dns.enabled = false;
|
||||
cfg.transports.udp = TransportInstances::Single(UdpConfig {
|
||||
bind_addr: Some(format!("0.0.0.0:{listen_port}")),
|
||||
..Default::default()
|
||||
});
|
||||
// TCP with no bind_addr = outbound-only (fallback when UDP is blocked).
|
||||
cfg.transports.tcp = TransportInstances::Single(Default::default());
|
||||
// Fast-connect profile — a phone opens the app and expects the node NOW.
|
||||
// Stock pacing is tuned for always-on routers: a failed discovery backs
|
||||
// off 30s, session resends gap out to 8-16s, dead links redial at
|
||||
// 5s→300s. Over 5G that stacked into a ~40s first connect (observed
|
||||
// 2026-07-24: anchor +10s, session +40s). Retries only fire while a
|
||||
// link/session is down, so steady-state traffic is unchanged.
|
||||
cfg.node.retry.base_interval_secs = 1; // dead-link redial 1s,2s,4s…
|
||||
cfg.node.retry.max_backoff_secs = 30; // …capped at 30s, not 5 min
|
||||
cfg.node.retry.max_retries = 30;
|
||||
cfg.node.rate_limit.handshake_resend_interval_ms = 400;
|
||||
cfg.node.rate_limit.handshake_resend_backoff = 1.5;
|
||||
cfg.node.rate_limit.handshake_max_resends = 10;
|
||||
cfg.node.discovery.backoff_base_secs = 1; // failed lookup retries fast
|
||||
cfg.node.discovery.backoff_max_secs = 30;
|
||||
cfg.node.discovery.retry_interval_secs = 2;
|
||||
cfg.node.discovery.max_attempts = 3;
|
||||
// Lookups launched before the tree position settles are doomed; a 10s
|
||||
// completion timeout made each one cost 10s before the 1s retry could
|
||||
// fire (observed: 19s to a route on a fresh join). Fail fast instead —
|
||||
// the resend-within-window above still gives each attempt two shots.
|
||||
cfg.node.discovery.timeout_secs = 5;
|
||||
cfg.peers = peers;
|
||||
cfg
|
||||
}
|
||||
|
||||
/// Parse the peers JSON handed over from Kotlin. Shape = fips `PeerConfig`:
|
||||
/// `[{"npub":"…","alias":"…","addresses":[{"transport":"udp","addr":"host:2121","priority":10},…]}]`
|
||||
pub fn parse_peers(peers_json: &str) -> Result<Vec<PeerConfig>> {
|
||||
serde_json::from_str(peers_json).context("peers JSON")
|
||||
}
|
||||
|
||||
/// Start the mesh node on the given TUN fd (from `VpnService.establish()`,
|
||||
/// detached — the node owns it from here). Returns (npub, ula) on success.
|
||||
/// Any previously running node is stopped first.
|
||||
pub fn start(secret: &str, peers_json: &str, tun_fd: i32, listen_port: u16) -> Result<(String, String)> {
|
||||
stop();
|
||||
|
||||
// Android hands the VpnService TUN fd over in non-blocking mode on some
|
||||
// OS builds. The fips TUN reader is a dedicated blocking-read thread that
|
||||
// treats EAGAIN as fatal — the loop died at startup ("TUN read error …
|
||||
// Try again (os error 11)" on-device), so sessions came up but no packet
|
||||
// ever entered the mesh. Force the fd into the blocking mode the reader
|
||||
// is designed for.
|
||||
unsafe {
|
||||
let flags = libc::fcntl(tun_fd, libc::F_GETFL);
|
||||
if flags >= 0 && (flags & libc::O_NONBLOCK) != 0 {
|
||||
libc::fcntl(tun_fd, libc::F_SETFL, flags & !libc::O_NONBLOCK);
|
||||
}
|
||||
}
|
||||
|
||||
let peers = parse_peers(peers_json)?;
|
||||
let config = build_config(secret, peers, listen_port);
|
||||
let mut node = Node::new(config).map_err(|e| anyhow!("node init: {e}"))?;
|
||||
let npub = node.npub();
|
||||
let address = node.identity().address().to_ipv6().to_string();
|
||||
|
||||
let runtime = tokio::runtime::Builder::new_multi_thread()
|
||||
.worker_threads(2)
|
||||
.enable_all()
|
||||
.thread_name("archy-fips")
|
||||
.build()
|
||||
.context("tokio runtime")?;
|
||||
|
||||
let shutdown = Arc::new(Notify::new());
|
||||
let running = Arc::new(AtomicBool::new(false));
|
||||
let (started_tx, started_rx) = tokio::sync::oneshot::channel::<Result<()>>();
|
||||
|
||||
let task = {
|
||||
let shutdown = shutdown.clone();
|
||||
let running = running.clone();
|
||||
runtime.spawn(async move {
|
||||
match node.start_with_tun_fd(tun_fd).await {
|
||||
Ok(()) => {
|
||||
running.store(true, Ordering::SeqCst);
|
||||
let _ = started_tx.send(Ok(()));
|
||||
}
|
||||
Err(e) => {
|
||||
let _ = started_tx.send(Err(anyhow!("node start: {e}")));
|
||||
return;
|
||||
}
|
||||
}
|
||||
tokio::select! {
|
||||
result = node.run_rx_loop() => {
|
||||
if let Err(e) = result {
|
||||
tracing::error!("mesh rx loop error: {e}");
|
||||
}
|
||||
}
|
||||
_ = shutdown.notified() => {}
|
||||
}
|
||||
if let Err(e) = node.stop().await {
|
||||
tracing::warn!("mesh stop: {e}");
|
||||
}
|
||||
running.store(false, Ordering::SeqCst);
|
||||
})
|
||||
};
|
||||
|
||||
let started = runtime
|
||||
.block_on(async { tokio::time::timeout(START_TIMEOUT, started_rx).await })
|
||||
.map_err(|_| anyhow!("node start timed out"))?
|
||||
.map_err(|_| anyhow!("node task died during start"))?;
|
||||
if let Err(e) = started {
|
||||
runtime.shutdown_background();
|
||||
return Err(e);
|
||||
}
|
||||
|
||||
*MESH.lock().unwrap() = Some(MeshHandle {
|
||||
runtime,
|
||||
task: Some(task),
|
||||
shutdown,
|
||||
running,
|
||||
npub: npub.clone(),
|
||||
address: address.clone(),
|
||||
});
|
||||
Ok((npub, address))
|
||||
}
|
||||
|
||||
/// Stop the mesh node if running. Idempotent.
|
||||
pub fn stop() {
|
||||
let Some(mut handle) = MESH.lock().unwrap().take() else {
|
||||
return;
|
||||
};
|
||||
handle.shutdown.notify_waiters();
|
||||
if let Some(task) = handle.task.take() {
|
||||
let _ = handle
|
||||
.runtime
|
||||
.block_on(async { tokio::time::timeout(STOP_TIMEOUT, task).await });
|
||||
}
|
||||
handle.runtime.shutdown_background();
|
||||
}
|
||||
|
||||
pub fn is_running() -> bool {
|
||||
MESH.lock()
|
||||
.unwrap()
|
||||
.as_ref()
|
||||
.map(|h| h.running.load(Ordering::SeqCst))
|
||||
.unwrap_or(false)
|
||||
}
|
||||
|
||||
/// `{running, npub, address}` for the Kotlin status surface.
|
||||
pub fn status_json() -> String {
|
||||
let guard = MESH.lock().unwrap();
|
||||
match guard.as_ref() {
|
||||
Some(h) => serde_json::json!({
|
||||
"running": h.running.load(Ordering::SeqCst),
|
||||
"npub": h.npub,
|
||||
"address": h.address,
|
||||
})
|
||||
.to_string(),
|
||||
None => serde_json::json!({ "running": false }).to_string(),
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn identity_roundtrip() {
|
||||
let a = generate_identity().unwrap();
|
||||
let b = derive_identity(&a.secret_hex).unwrap();
|
||||
assert_eq!(a.npub, b.npub);
|
||||
assert_eq!(a.address, b.address);
|
||||
// ULA in fd::/8
|
||||
assert!(a.address.starts_with("fd"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn peers_json_parses_into_peer_config() {
|
||||
let peers = parse_peers(
|
||||
r#"[{
|
||||
"npub": "npub1abc",
|
||||
"alias": "My Archipelago",
|
||||
"addresses": [
|
||||
{"transport": "udp", "addr": "192.168.1.228:2121", "priority": 10},
|
||||
{"transport": "tcp", "addr": "192.168.1.228:8443", "priority": 20}
|
||||
]
|
||||
}]"#,
|
||||
)
|
||||
.unwrap();
|
||||
assert_eq!(peers.len(), 1);
|
||||
assert_eq!(peers[0].addresses.len(), 2);
|
||||
assert!(peers[0].is_auto_connect());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn config_is_leaf_only_with_tun() {
|
||||
let id = generate_identity().unwrap();
|
||||
let cfg = build_config(&id.secret_hex, vec![], 0);
|
||||
assert!(cfg.node.leaf_only);
|
||||
assert!(cfg.tun.enabled);
|
||||
assert_eq!(cfg.tun.mtu(), 1280);
|
||||
assert!(!cfg.dns.enabled);
|
||||
assert!(!cfg.transports.udp.is_empty());
|
||||
assert!(!cfg.transports.tcp.is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn listen_port_sets_fixed_udp_bind() {
|
||||
let id = generate_identity().unwrap();
|
||||
let cfg = build_config(&id.secret_hex, vec![], 2121);
|
||||
// Party mode keeps leaf_only — accepting a link is not routing transit.
|
||||
assert!(cfg.node.leaf_only);
|
||||
let TransportInstances::Single(udp) = &cfg.transports.udp else {
|
||||
panic!("expected single UDP transport");
|
||||
};
|
||||
assert_eq!(udp.bind_addr.as_deref(), Some("0.0.0.0:2121"));
|
||||
}
|
||||
}
|
||||
@@ -1,5 +1,76 @@
|
||||
# Changelog
|
||||
|
||||
## v1.7.117-alpha (2026-07-27)
|
||||
|
||||
- FIPS startup is more reliable on nodes that have the packaged `fips.service` instead of Archipelago's `archipelago-fips.service`. Startup self-heal, onboarding, dashboard Start, and reconnect now use the systemd unit the node actually has, so FIPS no longer looks like it needs to be installed when it only needs to be started.
|
||||
- App screens over the FIPS mesh now bind their relay only to the node's FIPS address instead of reserving the same host ports Podman needs. This keeps apps such as FileBrowser and Botfights from restart-looping because the backend was already holding their published ports.
|
||||
- Companion WebView safe-area handling now also moves fixed and sticky top bars below the phone status bar, including headers mounted after page load by single-page apps.
|
||||
- Public-source preparation now includes a Nostr Git hosting plan using `ngit`, NIP-34, and GRASP: anyone can clone, fork, review, and propose changes from their Archipelago node, while canonical merge authority stays with a small signed maintainer set in the style of Bitcoin Core.
|
||||
- Node OS release notes for v1.7.117 are still open; add any installer, service, kernel, firewall, or package changes here before cutting the release.
|
||||
|
||||
## v1.7.116-alpha (2026-07-27)
|
||||
|
||||
- Nodes no longer get stuck on "server starting up" after an update or reboot. On a node running many apps, the backend used to spend minutes recovering containers before it told the system it was ready, and anything that touched it during that window could leave it down for good. It now reports ready immediately and recovers in the background, and it always restarts itself if it ever does go down.
|
||||
- Installing apps no longer crashes the node. A change that made app screens reachable over the mesh was accidentally holding onto every app's network port in advance — so installing an app like Grafana, Photoprism, Uptime Kuma, or Jellyfin collided with it and the port-cleanup step took the whole backend down, rolling the install back. Installs are now clean and the backend can never be caught by that cleanup.
|
||||
- Rolls up everything from v1.7.115: app screens and the dashboard load over the mesh out of the box (firewall openings shipped automatically, IPv6 support end to end), and nodes rejoin the mesh in seconds after their rendezvous point restarts.
|
||||
|
||||
## v1.7.115-alpha (2026-07-26)
|
||||
|
||||
- The companion app can reach your node's screen from anywhere again. The recent security hardening locked down the node's mesh interface so tightly that the dashboard itself was blocked — the phone would pair and connect, then sit on a blank screen. The node now explicitly opens its own web interface (and only that) through the mesh firewall on every install and upgrade, so the phone's view of your node works out of the box, on any network, and can't silently break in a future update.
|
||||
- The node's web interface also answers on IPv6 everywhere it answers on IPv4 — the mesh runs entirely on IPv6, and one v4-only listener was enough to make a working connection show nothing.
|
||||
- Nodes now come back onto the mesh in seconds instead of minutes after their rendezvous anchor restarts: the fast-reconnect tuning proven on the phone this week is now baked into every node's mesh configuration, and it survives upgrades.
|
||||
|
||||
## v1.7.114-alpha (2026-07-26)
|
||||
|
||||
- Plugging in a mesh radio no longer traps it in an endless reboot loop. The device detector itself was causing it: every scan pulsed the radio's reset line, the same board was probed twice under two names, and retries came so fast the radio never finished booting before the next reset hit. Detection now gives the board real time to boot, probes it once, backs off properly between attempts, and no longer fights the "device detected" popup for the port. Radios that could never connect now come up within a minute of being plugged in.
|
||||
- The Lightning channels screen now has All / Active / Pending / Closed tabs. Pending gathers everything in motion (opening, closing, force-closing — each with its own status dot and a link to the closing transaction), and Closed is a real history: how each channel ended, what settled back to you, and the closing transaction for each.
|
||||
- Sending bitcoin on-chain now puts you in charge of the network fee: pick Fast, Standard, or Slow (Standard is the default), or set your own target blocks or sats-per-vByte. The confirmation step shows the estimated fee for your chosen speed before any money moves.
|
||||
- Type on-chain amounts in whichever unit you think in — a sats/BTC switch on the amount field converts as you type.
|
||||
- Back up your seed by scanning it. Every recovery-phrase screen (onboarding, Settings, and the Lightning wallet seed) now has Words and QR code tabs — words always shown first. The QR for your node's recovery phrase uses the SeedQR standard, so hardware wallets like Passport Prime, SeedSigner, and Keystone can import it with a single scan (a plain-text option remains for wallets that read the phrase as text). The Lightning seed's QR is plain text with an honest note: it's an LND-format seed that restores into Lightning wallets like Zeus or Blixt, not into hardware wallets.
|
||||
|
||||
## v1.7.113-alpha (2026-07-25)
|
||||
|
||||
- Fixed a money bug in Cashu ecash sends: the token you handed a recipient could carry your own change proofs along with it, letting the same sats be credited twice. Change now stays in your wallet — only the amount you meant to send leaves it.
|
||||
- Closing a Lightning channel is no longer a leap of faith. The close used to hang (or time out with an error) even though it had actually gone through; it now comes back within seconds with the closing transaction ID. Channels mid-close appear in the channel list as Closing or Force-closing with their transaction attached, and a new closed-channels history keeps past closes visible instead of letting them vanish from the list.
|
||||
- The wallet card now leads with your total bitcoin across everything, and the on-chain balance gets its own chain icon so the rows read at a glance.
|
||||
- The companion phone app (0.5.15) connects dramatically faster away from home: a cold connect over 5G dropped from 40+ seconds to about 5. First connects no longer stall on unreachable mesh dial hints, fresh joins fail fast and retry instead of waiting out long timeouts, and the phone re-announces itself the moment the network around it changes. The node side's mesh-join handling was hardened to match.
|
||||
|
||||
## v1.7.112-alpha (2026-07-23)
|
||||
|
||||
- Sound works on TVs out of the box. Fresh installs were missing the audio system entirely, and even when present a boot-time race left HDMI silent until the cable was unplugged and replugged. Both are fixed: installer images now ship the full audio stack, and a small background helper detects the silent-HDMI state and heals it automatically.
|
||||
- Plug in a game controller and drive the whole TV interface with it — navigation, menus, and media playback all respond to the gamepad, and dialogs that pop up are controller-navigable too.
|
||||
- The companion phone app took a huge leap (0.5.9). Your node and its apps now work from anywhere — on 5G or any internet connection, the phone reaches the node over the encrypted mesh with zero port forwarding or VPN setup. Startup away from home is instant, apps on your node open inside the app, the phone's native camera handles QR scanning, and a branded full-screen loader shows while the mesh connects.
|
||||
- Mesh Party: two phones scan each other's QR and instantly get a direct encrypted chat and app sharing between them — plus a "Share this app" QR that anyone can scan with a normal camera to install the companion app.
|
||||
- Pairing a second phone no longer silently logs out the first. Every device now keeps its own named access credential, ending the mystery reconnects when a household paired more than one phone.
|
||||
- The companion pairing QR is scannable again (it had grown too dense for phone cameras) and now identifies your node by its identity key, so the app recognizes your node even after it moves or gets a new address.
|
||||
- Every app your node serves on your home network is now also reachable over the mesh — remote access covers the apps themselves, not just the dashboard.
|
||||
- Selling files: you now choose which payment methods you accept (Lightning, ecash, …) and buyers are only offered those — enforced by the node itself, not just the buttons. Paying twice for the same file is impossible now, purchases file themselves into a new Paid Files tab, purchased music always plays in the bottom-bar player, and videos get picture-in-picture.
|
||||
- Sending Lightning is invoice-first: paste or scan an invoice and the amount fills in and locks by itself. An expired invoice now tells you plainly to ask for a fresh one instead of failing cryptically, and payment errors always reach your screen.
|
||||
- Sending to a pasted address gets a confirmation step showing exactly what will happen before any money moves, and buying ecash is an explicit two-step — no more accidental purchases.
|
||||
- Apps keep running when you change how they're displayed. Switching an app between windowed and fullscreen used to reload it from scratch (stopping any playing media); the app now stays live through the switch, and each app remembers its own preferred display mode.
|
||||
- A watchdog notices when the Lightning (LND) node wedges and revives it before you do; Fedi ecash gets its own send option with scannable token QR codes.
|
||||
- Fedimint's Lightning gateway and guardian now follow whichever bitcoin version is actually running instead of pointing at a stale address — switching bitcoin versions no longer strands them.
|
||||
- If your router starts handing out different addresses, the Pine voice speaker re-links itself automatically instead of staying silent until someone re-configures it.
|
||||
- Polish: mesh radios never show garbled device names anymore, the TV kiosk uses slim overlay scrollbars instead of fat grey bars, and the AI chat's background artwork shows through again.
|
||||
- Your mesh messages now survive restarts. Chat history — channels and DMs alike — used to live only in memory, so a reboot or update wiped every conversation; worse, other nodes silently discarded the first messages you sent after a reboot. Everything is now saved on the node and restored on startup, and post-reboot messages deliver reliably.
|
||||
- Plug in any LoRa radio and the node walks you through it. A setup window appears every time a radio is connected, shows what firmware is already on it (MeshCore, Meshtastic, or Reticulum RNode — with its current name, region, and channels where available), and offers two honest choices: "Set Up with Archipelago Settings" (a preview screen shows exactly what will be written before anything touches the radio) or "Keep As Is" (the radio is used untouched, and you can hot-swap radios freely). Swapping sticks mid-session now just works — including Reticulum RNodes, which fresh installer images now support out of the box.
|
||||
- Incoming bitcoin appears in your wallet within seconds of being sent — balance and the yellow "unconfirmed" entry update live, no refresh, no waiting for the next poll.
|
||||
- The speaker now announces the very first mesh message a node ever receives, and DMs announce just like channel messages (a safety guard against announcement storms was quietly swallowing them). Announcements also react about twice as fast.
|
||||
- Opening a Lightning channel right after the node starts no longer fails with a scary red error. The node quietly retries while Lightning finishes waking up, and if it's still not ready you get a calm "still finishing its startup — try again shortly" notice instead.
|
||||
- Viewing a transaction works on every node now, including small ones. Nodes with pruned bitcoin storage can't run the Mempool explorer app; transaction links now open your choice of external explorer instead (tx1138.com by default) — after a clear one-time warning that a third-party server will see which transaction you looked up. Set your preferred explorer in Wallet Settings → the new On-chain tab.
|
||||
- Voice commands respond noticeably faster: speech recognition now transcribes in roughly half the time, with identical accuracy on short commands.
|
||||
- Scanning a Lightning invoice with your phone's camera is far more reliable — dense invoice QR codes that the photo scanner missed are now read by the phone's native barcode engine.
|
||||
- The companion app's pairing QR always contains an address your phone can actually reach. If you manage your node over a VPN (Tailscale), the QR used to embed the VPN address, and pairing silently failed; it now advertises the node's home-network address.
|
||||
- Peer requests sent from Nostr discovery now actually arrive: your node checks for incoming requests every five minutes by itself (previously they sat unseen until someone manually pressed "Poll"), requests publish to all your configured relays instead of two hardcoded ones, and a failed send tells you instead of pretending it worked.
|
||||
- The Connected Nodes list refreshes instantly. It previously froze for up to 30 seconds per offline peer while checking who's reachable, one peer at a time; the checks now run all at once in the background while the list shows immediately.
|
||||
- Apps opened from inside a window (like a transaction from the wallet) now animate smoothly on top instead of loading invisibly underneath.
|
||||
- Settings-style windows keep their tabs pinned at the top and their buttons pinned at the bottom; only the middle scrolls. The wallet's tabs are now Channels / Cashu / Fedi / Ark / On-chain so all five fit.
|
||||
- On the TV screen, menus no longer flash open and instantly close. And the interface never follows your computer's light/dark preference anymore — dropdowns and other native controls stay dark on every device.
|
||||
- Error messages tell you what's actually wrong: "Insufficient balance: need 80 sats, have 0 sats" now reaches your screen instead of "Operation failed. Check server logs."
|
||||
- Installing Mempool no longer refuses to start while ElectrumX is mid-resync (it connects by itself once ElectrumX is ready), and installs no longer fail just because the system was momentarily busy.
|
||||
- Much quieter logs: the node no longer tries to start containers that are already running (hundreds of harmless-but-alarming errors per day), and a node that's offline stops hammering unreachable servers every 30 seconds with rebuild attempts.
|
||||
- Phones pairing with the companion app connect over the node's embedded mesh for remote access, with instant QR pairing and per-device access tokens (contributed alongside this release).
|
||||
|
||||
## v1.7.111-alpha (2026-07-22)
|
||||
|
||||
- Ask your node anything, out loud. Install Pine (the voice assistant app) alongside Home Assistant and everything wires itself automatically: speech recognition, the speaking voice, and a Claude-powered brain. Questions about your node — "what's the block height?", "how many peers am I connected to?", "is bitcoin synced?", "what's my Lightning balance?" — are answered instantly from the node itself without costing anything; anything else goes to Claude for a real conversation. New mesh radio messages are read out on your speaker as they arrive.
|
||||
|
||||
@@ -0,0 +1,19 @@
|
||||
# Code of Conduct
|
||||
|
||||
## Our standard
|
||||
|
||||
Be direct, respectful, and focused on the work. Healthy disagreement is welcome;
|
||||
harassment, personal attacks, and discriminatory language are not.
|
||||
|
||||
## Scope
|
||||
|
||||
This code of conduct applies to project repositories, issue trackers, pull
|
||||
requests, documentation, chat, and community spaces connected to Archipelago.
|
||||
|
||||
## Enforcement
|
||||
|
||||
Maintainers may edit, hide, or remove comments and may restrict participation
|
||||
for behavior that makes collaboration unsafe or unproductive.
|
||||
|
||||
Report conduct concerns privately through the repository owner account or the
|
||||
private contact channel listed on the project homepage.
|
||||
+65
-126
@@ -1,161 +1,100 @@
|
||||
# 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.
|
||||
This project is preparing for public developer contribution. The highest-value
|
||||
contributions are focused fixes, tests, app manifests, documentation
|
||||
improvements, and clear bug reports with reproducible evidence.
|
||||
|
||||
## Code of Conduct
|
||||
## Development setup
|
||||
|
||||
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)
|
||||
### Frontend
|
||||
|
||||
```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
|
||||
npm start
|
||||
npm run type-check
|
||||
npm test
|
||||
```
|
||||
|
||||
### Backend (Rust)
|
||||
|
||||
Build on a Linux server (Debian 13), **not** macOS:
|
||||
### Backend
|
||||
|
||||
```bash
|
||||
cargo clippy --all-targets --all-features
|
||||
cargo fmt --all
|
||||
cd core
|
||||
cargo fmt --all -- --check
|
||||
cargo clippy --all-targets --all-features -- -D warnings
|
||||
cargo test --all-features
|
||||
```
|
||||
|
||||
### Deploy to dev server
|
||||
Linux is required for host integration work involving Podman, systemd,
|
||||
networking, or image builds. Frontend development works locally with the mock
|
||||
backend.
|
||||
|
||||
## App manifests
|
||||
|
||||
App packages live under `apps/<app-id>/manifest.yml` and use the schema
|
||||
documented in [docs/app-manifest-spec.md](docs/app-manifest-spec.md). Validate
|
||||
before submitting:
|
||||
|
||||
```bash
|
||||
./scripts/deploy-to-target.sh --live
|
||||
./scripts/validate-app-manifest.sh apps/<app-id>/manifest.yml
|
||||
python3 scripts/generate-app-catalog.py
|
||||
python3 scripts/check-app-catalog-drift.py --release --strict
|
||||
```
|
||||
|
||||
## Code Style
|
||||
App submissions must:
|
||||
|
||||
### Frontend (TypeScript + Vue)
|
||||
- pin container image versions;
|
||||
- avoid hardcoded secrets;
|
||||
- use `security.no_new_privileges: true`;
|
||||
- use `security.readonly_root: true` unless the manifest explains why writable
|
||||
root is required;
|
||||
- request only necessary Linux capabilities;
|
||||
- store durable data under `/var/lib/archipelago/<app-id>/`;
|
||||
- define truthful health checks and launch interfaces for user-facing UIs.
|
||||
|
||||
- `<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
|
||||
## Code style
|
||||
|
||||
### Backend (Rust)
|
||||
- Rust: prefer `?` over `unwrap()`/`expect()` in production paths.
|
||||
- Rust: use `tracing` for structured logs.
|
||||
- TypeScript: avoid `any`; use explicit types or `unknown`.
|
||||
- Vue: prefer `<script setup lang="ts">`.
|
||||
- Keep changes scoped; do not mix drive-by refactors with behavioral changes.
|
||||
- Remove dead code rather than commenting it out.
|
||||
- Add tests for new behavior and regression tests for bug fixes.
|
||||
|
||||
- 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
|
||||
## Pull requests
|
||||
|
||||
### General
|
||||
1. Open one focused PR per behavior or documentation change.
|
||||
2. Explain what changed, why it changed, and how it was verified.
|
||||
3. Include screenshots for UI changes.
|
||||
4. Link relevant issues or docs.
|
||||
5. Keep generated catalog changes in sync with manifest changes.
|
||||
|
||||
- Functions under 50 lines, single responsibility
|
||||
- Comment WHY not WHAT
|
||||
- Remove dead code — never comment it out
|
||||
- No `TODO`/`FIXME` in commits
|
||||
Suggested commit format:
|
||||
|
||||
## Commit Format
|
||||
|
||||
```
|
||||
type: description
|
||||
```text
|
||||
feat: add backup scheduling
|
||||
fix: reject unsafe manifest volume
|
||||
docs: clarify app deployment flow
|
||||
test: cover catalog drift check
|
||||
```
|
||||
|
||||
**Types**: `feat:`, `fix:`, `docs:`, `refactor:`, `test:`, `chore:`, `perf:`
|
||||
## Reporting bugs
|
||||
|
||||
Examples:
|
||||
- `feat: add backup scheduling to settings page`
|
||||
- `fix: handle WiFi connection timeout gracefully`
|
||||
- `test: add unit tests for RPC client retry logic`
|
||||
Include:
|
||||
|
||||
## Pull Request Process
|
||||
- exact version or commit;
|
||||
- host platform and architecture;
|
||||
- steps to reproduce;
|
||||
- expected and actual behavior;
|
||||
- logs from the relevant component;
|
||||
- screenshots for UI issues.
|
||||
|
||||
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
|
||||
## Security
|
||||
|
||||
### 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.
|
||||
Do not report vulnerabilities in public issues. Follow [SECURITY.md](SECURITY.md).
|
||||
|
||||
## License
|
||||
|
||||
By contributing, you agree that your contributions will be licensed under the same license as the project.
|
||||
By contributing, you agree that your contribution is licensed under the
|
||||
project's MIT License.
|
||||
|
||||
@@ -0,0 +1,21 @@
|
||||
MIT License
|
||||
|
||||
Copyright (c) 2026 Dorian and the Archipelago Project contributors
|
||||
|
||||
Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||
of this software and associated documentation files (the "Software"), to deal
|
||||
in the Software without restriction, including without limitation the rights
|
||||
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
||||
copies of the Software, and to permit persons to whom the Software is
|
||||
furnished to do so, subject to the following conditions:
|
||||
|
||||
The above copyright notice and this permission notice shall be included in all
|
||||
copies or substantial portions of the Software.
|
||||
|
||||
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
||||
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
||||
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
||||
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
||||
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
||||
SOFTWARE.
|
||||
@@ -0,0 +1,73 @@
|
||||
# Archipelago — Third-Party Notices
|
||||
|
||||
Archipelago is licensed under the MIT License (see LICENSE).
|
||||
This file lists third-party components included in this repository and its
|
||||
release artifacts, with their licenses and required attributions.
|
||||
|
||||
## Embedded / vendored components
|
||||
|
||||
- **FIPS mesh networking** — https://github.com/jmcorgan/fips
|
||||
Copyright (c) 2026 Johnathan Corgan. MIT License.
|
||||
Used as the embedded mesh VPN in the OS (`fips` daemon, pinned v0.4.1) and
|
||||
compiled into the Android companion app (`Android/rust/archy-fips-core`).
|
||||
|
||||
- **QR Code Generator for JavaScript** — http://www.d-project.com/
|
||||
Copyright (c) 2009 Kazuhiko Arase. MIT License.
|
||||
Vendored at `docker/lnd-ui/qrcode.js` and `docker/electrs-ui/qrcode.js`
|
||||
(original headers preserved).
|
||||
|
||||
- **nostr-rs-relay** — https://github.com/scsibug/nostr-rs-relay — MIT License.
|
||||
Binary extracted into the OS image at `/opt/archipelago/bin/`.
|
||||
|
||||
- **Reticulum (RNS) and LXMF** — https://github.com/markqvist/Reticulum
|
||||
Copyright Mark Qvist. Distributed under the Reticulum License (an MIT-style
|
||||
license with field-of-use restrictions: no use in systems designed to harm
|
||||
human beings, and no use in AI/ML training datasets). The optional
|
||||
`archy-reticulum-daemon` binary bundles RNS 1.3.5 and LXMF 1.0.1. The
|
||||
Reticulum License is NOT an OSI-approved open-source license; it applies
|
||||
only to that optional component, not to Archipelago itself.
|
||||
|
||||
## Fonts
|
||||
|
||||
- **Montserrat** — SIL Open Font License 1.1
|
||||
(`neode-ui/public/assets/fonts/Montserrat/OFL.txt`).
|
||||
- **Open Sans** — Apache License 2.0
|
||||
(`neode-ui/public/assets/fonts/Open_Sans/LICENSE.txt`).
|
||||
|
||||
## Artwork and icons
|
||||
|
||||
- **Mesh device artwork** (`neode-ui/public/assets/img/mesh-devices/`):
|
||||
device illustrations from the Meshtastic project — https://meshtastic.org
|
||||
© Meshtastic contributors, GPL-3.0. Meshtastic® is a registered trademark
|
||||
of Meshtastic LLC. See the ATTRIBUTION.md in that directory.
|
||||
- Some UI icons are derived from **game-icons.net** (CC BY 3.0 — see
|
||||
ATTRIBUTION.md in `neode-ui/public/assets/icon/`) and **pixelarticons**
|
||||
(MIT, https://github.com/halfmage/pixelarticons).
|
||||
- Third-party application logos under `neode-ui/public/assets/img/app-icons/`
|
||||
and `service-icons/` are trademarks of their respective owners, used solely
|
||||
to identify the corresponding applications. No endorsement is implied.
|
||||
|
||||
## Original media
|
||||
|
||||
All demo content (music, photos, posters in `demo/`), UI sound effects,
|
||||
background images, and intro video in `neode-ui/public/assets/` are original
|
||||
works created and owned by the Archipelago project author, released with the
|
||||
project. The welcome voice line (`welcome-noderunner.mp3`) was generated with
|
||||
ElevenLabs TTS under a commercial-use plan.
|
||||
|
||||
## Redistributed software (ISO and container registry)
|
||||
|
||||
The Archipelago OS image is based on Debian and redistributes Debian packages
|
||||
(including the Linux kernel, GRUB, and non-free firmware/microcode blobs
|
||||
required for hardware support); per-package license texts are preserved at
|
||||
`/usr/share/doc/*/copyright` in the installed system, and corresponding source
|
||||
is available via Debian (https://snapshot.debian.org) as referenced in each
|
||||
release's notes. Container images offered through the app catalog and mirror
|
||||
registry remain under their upstream licenses (including GPL/AGPL software
|
||||
such as mempool, Nextcloud, Vaultwarden, SearXNG, PhotoPrism, Immich,
|
||||
Jellyfin, MariaDB, AdGuard Home, and strfry); source links are provided in
|
||||
the app catalog. The modified mempool-frontend image is built from
|
||||
`docker/mempool-frontend/` in this repository (AGPL-3.0 corresponding source).
|
||||
|
||||
Full per-crate and per-package license inventories for release binaries are
|
||||
generated at build time (see THIRD-PARTY-LICENSES files in release artifacts).
|
||||
@@ -1,8 +1,11 @@
|
||||
# Archipelago
|
||||
|
||||
> Self-Sovereign Bitcoin Node OS
|
||||
> Self-sovereign Bitcoin node OS and manifest-driven app platform.
|
||||
|
||||
**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.
|
||||
Archipelago is a bootable personal server OS for Bitcoin infrastructure,
|
||||
self-hosted apps, mesh communication, decentralized identity, and federation.
|
||||
Apps are packaged as declarative `manifest.yml` files and run as rootless
|
||||
Podman containers managed by the Rust backend.
|
||||
|
||||
[](https://www.debian.org/)
|
||||
[](LICENSE)
|
||||
@@ -10,193 +13,101 @@
|
||||
[](https://vuejs.org/)
|
||||
[]()
|
||||
|
||||
## Philosophy
|
||||
## What is here
|
||||
|
||||
Archipelago is being built as a **developer-ready app platform**, not a fixed appliance:
|
||||
- `core/` - Rust workspace: backend API, container runtime, security, OpenWrt
|
||||
helpers, and performance/resource management.
|
||||
- `neode-ui/` - Vue 3 + TypeScript frontend.
|
||||
- `apps/` - app manifests and custom app container sources.
|
||||
- `docker/` - supporting container build contexts for UI companion surfaces.
|
||||
- `image-recipe/` - bootable image/ISO build inputs.
|
||||
- `Android/` - Android companion app.
|
||||
- `scripts/` - development, release, deployment, and validation tooling.
|
||||
- `docs/` - architecture, app packaging, operations, API, and roadmap docs.
|
||||
|
||||
- **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.
|
||||
## Platform model
|
||||
|
||||
## Features
|
||||
Archipelago is built as a developer-ready app platform, not a fixed appliance:
|
||||
|
||||
### 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
|
||||
- Apps are declared in `apps/<app-id>/manifest.yml`.
|
||||
- The Rust parser in `core/container/src/manifest.rs` is the canonical schema.
|
||||
- The orchestrator compiles manifests to rootless Podman/Quadlet runtime state.
|
||||
- App data lives under `/var/lib/archipelago/<app-id>/`.
|
||||
- Secrets are generated or read from `/var/lib/archipelago/secrets/` and
|
||||
injected through Podman secrets rather than static environment values.
|
||||
- Release and app catalogs are signed and verified against a pinned trust
|
||||
anchor.
|
||||
|
||||
### 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.
|
||||
Start with:
|
||||
|
||||
### 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
|
||||
- [Architecture](docs/architecture.md)
|
||||
- [Developer Guide](docs/developer-guide.md)
|
||||
- [App Developer Guide](docs/app-developer-guide.md)
|
||||
- [App Manifest Spec](docs/app-manifest-spec.md)
|
||||
- [Nostr Git Source Hosting Plan](docs/nostr-git-source-hosting.md)
|
||||
- [Operations Runbook](docs/operations-runbook.md)
|
||||
- [Troubleshooting](docs/troubleshooting.md)
|
||||
|
||||
### 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
|
||||
## Quick start
|
||||
|
||||
### 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
|
||||
### Frontend
|
||||
|
||||
```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/
|
||||
npm start
|
||||
```
|
||||
|
||||
### Backend Development
|
||||
The dev UI runs at `http://localhost:8100` with a mock backend on `:5959`.
|
||||
|
||||
### Backend
|
||||
|
||||
```bash
|
||||
cd core # Rust workspace root (no Cargo.toml at repo root)
|
||||
cd core
|
||||
cargo build
|
||||
cargo test
|
||||
cargo test --all-features
|
||||
```
|
||||
|
||||
### Deploy to a Test Node
|
||||
Linux is the supported backend runtime and release-build target. macOS is fine
|
||||
for frontend work and many Rust compile/test loops, but host integration tests
|
||||
that touch Podman, systemd, networking, or image build paths require Linux.
|
||||
|
||||
### App manifests
|
||||
|
||||
```bash
|
||||
./scripts/deploy-to-target.sh --live # Deploy to primary dev server
|
||||
./scripts/deploy-to-target.sh --both # Deploy to both LAN servers
|
||||
./scripts/validate-app-manifest.sh apps/filebrowser/manifest.yml
|
||||
python3 scripts/generate-app-catalog.py
|
||||
python3 scripts/check-app-catalog-drift.py --release --strict
|
||||
```
|
||||
|
||||
### Release (tarball-only)
|
||||
`scripts/generate-app-catalog.py` requires Python with PyYAML installed.
|
||||
|
||||
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
|
||||
## Documentation map
|
||||
|
||||
| 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 |
|
||||
| [Architecture](docs/architecture.md) | System layers, crates, data paths, security model |
|
||||
| [Developer Guide](docs/developer-guide.md) | Local setup, code workflow, testing |
|
||||
| [API Reference](docs/api-reference.md) | JSON-RPC API overview |
|
||||
| [App Developer Guide](docs/app-developer-guide.md) | How to package and test apps |
|
||||
| [App Manifest Spec](docs/app-manifest-spec.md) | Manifest schema and validation rules |
|
||||
| [Nostr Git Source Hosting Plan](docs/nostr-git-source-hosting.md) | ngit/NIP-34 contribution workflow and maintainer model |
|
||||
| [Apps README](apps/README.md) | Packaged app catalog overview |
|
||||
| [Image Recipe](image-recipe/README.md) | Bootable image build flow |
|
||||
| [Operations Runbook](docs/operations-runbook.md) | Production operations and recovery |
|
||||
| [Open Source Readiness](docs/OPEN_SOURCE_READINESS.md) | Public-release cleanup checklist |
|
||||
| [Roadmap](docs/ROADMAP.md) | Shipped, in-progress, and planned work |
|
||||
| [Unified Task Tracker](docs/UNIFIED-TASK-TRACKER.md) | Launch hardening task list |
|
||||
| [Archive](docs/archive/) | Historical plans, audits, and handoffs |
|
||||
|
||||
## 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
|
||||
Read [CONTRIBUTING.md](CONTRIBUTING.md) before opening a pull request. For
|
||||
security issues, follow [SECURITY.md](SECURITY.md) and do not open a public
|
||||
issue.
|
||||
|
||||
## License
|
||||
|
||||
[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/)
|
||||
Archipelago is licensed under the [MIT License](LICENSE). Third-party notices
|
||||
are listed in [NOTICE](NOTICE) and generated license inventories in component
|
||||
release artifacts.
|
||||
|
||||
+38
@@ -0,0 +1,38 @@
|
||||
# Security Policy
|
||||
|
||||
## Reporting vulnerabilities
|
||||
|
||||
Please do not open a public issue for a security vulnerability.
|
||||
|
||||
Until a dedicated security intake address is published, report privately to the
|
||||
project maintainer through the repository owner account or the private contact
|
||||
channel listed on the project homepage.
|
||||
|
||||
Include:
|
||||
|
||||
- affected commit, version, or release;
|
||||
- affected component;
|
||||
- reproduction steps;
|
||||
- expected impact;
|
||||
- logs, proof of concept, or packet captures when relevant;
|
||||
- whether the issue is already public.
|
||||
|
||||
We aim to acknowledge credible reports within 48 hours and coordinate fixes
|
||||
before public disclosure.
|
||||
|
||||
## Scope
|
||||
|
||||
Security-sensitive areas include:
|
||||
|
||||
- authentication, session handling, CSRF, and rate limiting;
|
||||
- release and app-catalog signature verification;
|
||||
- container manifest validation and runtime compilation;
|
||||
- Podman/Quadlet isolation, capabilities, volumes, and secret injection;
|
||||
- backup encryption and key derivation;
|
||||
- federation, Tor, Nostr, mesh, DID, and credential flows;
|
||||
- Android companion pairing and device-token handling.
|
||||
|
||||
## Supported versions
|
||||
|
||||
Archipelago is currently pre-1.0 alpha software. Security fixes target the
|
||||
current `main` branch and the latest published alpha release.
|
||||
+11
-1
@@ -76,7 +76,17 @@ podman run -p 18084:8080 \
|
||||
|
||||
## Integration Checklist
|
||||
|
||||
Adding a new app requires updates in multiple places. See the full checklist in [CLAUDE.md](../CLAUDE.md) under "App Integration Checklist".
|
||||
Adding a new app requires updates in multiple places:
|
||||
|
||||
- add `apps/<app-id>/manifest.yml`;
|
||||
- add a Dockerfile and source directory only when the app is built locally;
|
||||
- choose non-conflicting ports from [PORTS.md](./PORTS.md);
|
||||
- declare `interfaces.main` for user-facing web UIs;
|
||||
- declare generated secrets instead of hardcoding credentials;
|
||||
- run `./scripts/validate-app-manifest.sh apps/<app-id>/manifest.yml`;
|
||||
- regenerate catalogs with `python3 scripts/generate-app-catalog.py`;
|
||||
- verify drift with `python3 scripts/check-app-catalog-drift.py --release --strict`;
|
||||
- test install, launch, stop, start, restart, uninstall, and reinstall.
|
||||
|
||||
## Port Assignments
|
||||
|
||||
|
||||
@@ -9,13 +9,22 @@ app:
|
||||
pull_policy: if-not-present
|
||||
network: archy-net
|
||||
entrypoint: ["sh", "-lc"]
|
||||
# The bitcoind host comes from $FM_BITCOIND_URL, filled by the
|
||||
# {{BITCOIN_HOST}} derived-env below — it resolves to whichever bitcoin
|
||||
# container is actually running (Knots, Core, or any future distro archy
|
||||
# ships), so the gateway is never pinned to one node's container name.
|
||||
# (Was hardcoded http://host.archipelago:8332 — the host gateway IP where
|
||||
# bitcoind does not listen — which crash-looped the gateway, 2026-07-22.)
|
||||
custom_args:
|
||||
- >-
|
||||
if [ -f /lnd/tls.cert ] && [ -f /lnd/data/chain/bitcoin/mainnet/admin.macaroon ]; then
|
||||
exec gatewayd --data-dir /data --listen 0.0.0.0:8176 --bcrypt-password-hash "$FEDI_HASH" --network bitcoin --bitcoind-url http://host.archipelago:8332 --bitcoind-username "$FM_BITCOIND_USERNAME" --bitcoind-password "$FM_BITCOIND_PASSWORD" lnd --lnd-rpc-host lnd:10009 --lnd-tls-cert /lnd/tls.cert --lnd-macaroon /lnd/data/chain/bitcoin/mainnet/admin.macaroon;
|
||||
exec gatewayd --data-dir /data --listen 0.0.0.0:8176 --bcrypt-password-hash "$FEDI_HASH" --network bitcoin --bitcoind-url "$FM_BITCOIND_URL" --bitcoind-username "$FM_BITCOIND_USERNAME" --bitcoind-password "$FM_BITCOIND_PASSWORD" lnd --lnd-rpc-host lnd:10009 --lnd-tls-cert /lnd/tls.cert --lnd-macaroon /lnd/data/chain/bitcoin/mainnet/admin.macaroon;
|
||||
else
|
||||
exec gatewayd --data-dir /data --listen 0.0.0.0:8176 --bcrypt-password-hash "$FEDI_HASH" --network bitcoin --bitcoind-url http://host.archipelago:8332 --bitcoind-username "$FM_BITCOIND_USERNAME" --bitcoind-password "$FM_BITCOIND_PASSWORD" ldk --ldk-lightning-port 9737 --ldk-alias archipelago-gateway;
|
||||
exec gatewayd --data-dir /data --listen 0.0.0.0:8176 --bcrypt-password-hash "$FEDI_HASH" --network bitcoin --bitcoind-url "$FM_BITCOIND_URL" --bitcoind-username "$FM_BITCOIND_USERNAME" --bitcoind-password "$FM_BITCOIND_PASSWORD" ldk --ldk-lightning-port 9737 --ldk-alias archipelago-gateway;
|
||||
fi
|
||||
derived_env:
|
||||
- key: FM_BITCOIND_URL
|
||||
template: "http://{{BITCOIN_HOST}}:8332"
|
||||
# The gateway's admin API is gated by a bcrypt password hash. Generate it on
|
||||
# first install (random password + its bcrypt hash, both 0600 rootless-owned)
|
||||
# so the app installs from its manifest alone — `fedimint-gateway-hash` holds
|
||||
|
||||
@@ -21,6 +21,11 @@ app:
|
||||
template: fedimint://{{HOST_MDNS}}:8173
|
||||
- key: FM_API_URL
|
||||
template: ws://{{HOST_MDNS}}:8174
|
||||
# Resolves to whichever bitcoin container is running (Knots/Core/future
|
||||
# distro) instead of a hardcoded name — the guardian works on any node
|
||||
# regardless of which Bitcoin software it runs.
|
||||
- key: FM_BITCOIND_URL
|
||||
template: "http://{{BITCOIN_HOST}}:8332"
|
||||
secret_env:
|
||||
- key: FM_BITCOIND_PASSWORD
|
||||
secret_file: bitcoin-rpc-password
|
||||
@@ -62,7 +67,8 @@ app:
|
||||
|
||||
environment:
|
||||
- FM_DATA_DIR=/data
|
||||
- FM_BITCOIND_URL=http://bitcoin-knots:8332
|
||||
# FM_BITCOIND_URL comes from derived_env ({{BITCOIN_HOST}}) above, not a
|
||||
# hardcoded name — do not re-add it here.
|
||||
- FM_BITCOIND_USERNAME=archipelago
|
||||
- FM_BITCOIN_NETWORK=bitcoin
|
||||
- FM_BIND_P2P=0.0.0.0:8173
|
||||
|
||||
@@ -1,7 +1,11 @@
|
||||
app:
|
||||
id: pine-whisper
|
||||
name: Pine Whisper (STT)
|
||||
version: "3.4.1"
|
||||
# App revision 3.4.2 = upstream wyoming-whisper 3.4.1 image + tuned args
|
||||
# (--beam-size 1). Bumped past the image version so catalog-driven nodes
|
||||
# pick up the args change; the pre-release form "3.4.1-1" would compare
|
||||
# LOWER than 3.4.1 under semver and never roll out.
|
||||
version: "3.4.2"
|
||||
description: Wyoming-protocol faster-whisper speech-to-text engine. Internal Pine voice-assistant stack member — turns speech captured by a PineVoice satellite into text for Home Assistant Assist.
|
||||
category: home
|
||||
|
||||
@@ -18,7 +22,11 @@ app:
|
||||
network_aliases: [pine-whisper]
|
||||
# The image entrypoint already binds tcp://0.0.0.0:10300; these args only
|
||||
# pick the model + language (mirrors the pine ha-stack.yml compose command).
|
||||
custom_args: ["--model", "base-int8", "--language", "en"]
|
||||
# --beam-size 1: the image default is 5 on x86 (1 on ARM). Benchmarked on
|
||||
# framework-pt (i5-1135G7, base-int8): beam 1 transcribes the same text
|
||||
# ~45% faster — the standard low-latency setting for short voice commands
|
||||
# (HA's own whisper add-on defaults to 1).
|
||||
custom_args: ["--model", "base-int8", "--language", "en", "--beam-size", "1"]
|
||||
|
||||
dependencies:
|
||||
- storage: 2Gi
|
||||
|
||||
@@ -1,33 +0,0 @@
|
||||
# Archipelago Production Configuration
|
||||
# This file is bundled with the macOS app
|
||||
|
||||
# Server Configuration
|
||||
ARCHIPELAGO_HOST=127.0.0.1
|
||||
ARCHIPELAGO_PORT=8100
|
||||
ARCHIPELAGO_BACKEND_PORT=3030
|
||||
|
||||
# Data Directories (relative to ~/Library/Application Support/Archipelago)
|
||||
ARCHIPELAGO_DATA_DIR=data
|
||||
ARCHIPELAGO_LOG_DIR=logs
|
||||
|
||||
# Frontend Configuration
|
||||
ARCHIPELAGO_FRONTEND_DIR=frontend
|
||||
|
||||
# Docker UI Configuration
|
||||
ARCHIPELAGO_DOCKER_UI_DIR=docker-ui
|
||||
|
||||
# Security
|
||||
ARCHIPELAGO_SESSION_SECRET=CHANGE_ME_ON_FIRST_RUN
|
||||
|
||||
# Logging
|
||||
RUST_LOG=info
|
||||
|
||||
# Production Mode
|
||||
NODE_ENV=production
|
||||
ARCHIPELAGO_MODE=production
|
||||
|
||||
# Docker Configuration
|
||||
DOCKER_HOST=unix:///var/run/docker.sock
|
||||
|
||||
# Disable External API Calls in Production
|
||||
ARCHIPELAGO_DISABLE_EXTERNAL_APIS=true
|
||||
Generated
+52
-1
@@ -84,6 +84,15 @@ version = "1.0.100"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "a23eb6b1614318a8071c9b2521f36b424b2c83db5eb3a0fead4a6c0809af6e61"
|
||||
|
||||
[[package]]
|
||||
name = "arbitrary"
|
||||
version = "1.4.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "c3d036a3c4ab069c7b410a2ce876bd74808d2d0888a82667669f8e783a898bf1"
|
||||
dependencies = [
|
||||
"derive_arbitrary",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "arc-swap"
|
||||
version = "1.9.1"
|
||||
@@ -95,7 +104,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "archipelago"
|
||||
version = "1.7.111-alpha"
|
||||
version = "1.7.116-alpha"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"archipelago-container",
|
||||
@@ -145,6 +154,7 @@ dependencies = [
|
||||
"serde_yaml",
|
||||
"serial2-tokio",
|
||||
"sha2 0.10.9",
|
||||
"socket2 0.5.10",
|
||||
"tar",
|
||||
"tempfile",
|
||||
"thiserror 1.0.69",
|
||||
@@ -160,6 +170,7 @@ dependencies = [
|
||||
"uuid",
|
||||
"zbase32",
|
||||
"zeroize",
|
||||
"zip",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -1174,6 +1185,17 @@ version = "0.5.8"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "7cd812cc2bc1d69d4764bd80df88b4317eaef9e773c75226407d9bc0876b211c"
|
||||
|
||||
[[package]]
|
||||
name = "derive_arbitrary"
|
||||
version = "1.4.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "1e567bd82dcff979e4b03460c307b3cdc9e96fde3d73bed1496d2bc75d9dd62a"
|
||||
dependencies = [
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
"syn 2.0.114",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "derive_builder"
|
||||
version = "0.20.2"
|
||||
@@ -6894,8 +6916,37 @@ dependencies = [
|
||||
"syn 2.0.114",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "zip"
|
||||
version = "2.4.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "fabe6324e908f85a1c52063ce7aa26b68dcb7eb6dbc83a2d148403c9bc3eba50"
|
||||
dependencies = [
|
||||
"arbitrary",
|
||||
"crc32fast",
|
||||
"crossbeam-utils",
|
||||
"displaydoc",
|
||||
"flate2",
|
||||
"indexmap",
|
||||
"memchr",
|
||||
"thiserror 2.0.18",
|
||||
"zopfli",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "zmij"
|
||||
version = "1.0.16"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "dfcd145825aace48cff44a8844de64bf75feec3080e0aa5cdbde72961ae51a65"
|
||||
|
||||
[[package]]
|
||||
name = "zopfli"
|
||||
version = "0.8.3"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "f05cd8797d63865425ff89b5c4a48804f35ba0ce8d125800027ad6017d2b5249"
|
||||
dependencies = [
|
||||
"bumpalo",
|
||||
"crc32fast",
|
||||
"log",
|
||||
"simd-adler32",
|
||||
]
|
||||
|
||||
@@ -0,0 +1,656 @@
|
||||
# Third-Party Rust Crate Licenses — Archipelago core
|
||||
|
||||
Generated from `cargo metadata` (all features) on 2026-07-23. 649 external crates.
|
||||
Full license texts ship with release artifacts (cargo-about; see docs/LICENSE-COMPLIANCE-AUDIT.md).
|
||||
|
||||
| Crate | Version | License | Source |
|
||||
|---|---|---|---|
|
||||
| adler2 | 2.0.1 | 0BSD OR MIT OR Apache-2.0 | https://github.com/oyvindln/adler2 |
|
||||
| aead | 0.5.2 | MIT OR Apache-2.0 | https://github.com/RustCrypto/traits |
|
||||
| aes | 0.8.4 | MIT OR Apache-2.0 | https://github.com/RustCrypto/block-ciphers |
|
||||
| aes-gcm | 0.10.3 | Apache-2.0 OR MIT | https://github.com/RustCrypto/AEADs |
|
||||
| ahash | 0.7.8 | MIT OR Apache-2.0 | https://github.com/tkaitchuck/ahash |
|
||||
| aho-corasick | 1.1.4 | Unlicense OR MIT | https://github.com/BurntSushi/aho-corasick |
|
||||
| allocator-api2 | 0.2.21 | MIT OR Apache-2.0 | https://github.com/zakarumych/allocator-api2 |
|
||||
| android_system_properties | 0.1.5 | MIT/Apache-2.0 | https://github.com/nical/android_system_properties |
|
||||
| anyhow | 1.0.100 | MIT OR Apache-2.0 | https://github.com/dtolnay/anyhow |
|
||||
| arc-swap | 1.9.1 | MIT OR Apache-2.0 | https://github.com/vorner/arc-swap |
|
||||
| argon2 | 0.5.3 | MIT OR Apache-2.0 | https://github.com/RustCrypto/password-hashes/tree/master/argon2 |
|
||||
| arrayref | 0.3.9 | BSD-2-Clause | https://github.com/droundy/arrayref |
|
||||
| arrayvec | 0.7.6 | MIT OR Apache-2.0 | https://github.com/bluss/arrayvec |
|
||||
| asn1-rs | 0.7.2 | MIT OR Apache-2.0 | https://github.com/rusticata/asn1-rs.git |
|
||||
| asn1-rs-derive | 0.6.0 | MIT OR Apache-2.0 | https://github.com/rusticata/asn1-rs.git |
|
||||
| asn1-rs-impl | 0.2.0 | MIT/Apache-2.0 | https://github.com/rusticata/asn1-rs.git |
|
||||
| async-trait | 0.1.89 | MIT OR Apache-2.0 | https://github.com/dtolnay/async-trait |
|
||||
| async-utility | 0.3.1 | MIT | https://github.com/yukibtc/async-utility.git |
|
||||
| async-wsocket | 0.13.1 | MIT | https://github.com/yukibtc/async-wsocket.git |
|
||||
| async_io_stream | 0.3.3 | Unlicense | https://github.com/najamelan/async_io_stream |
|
||||
| atomic-destructor | 0.3.0 | MIT | https://github.com/yukibtc/atomic-destructor.git |
|
||||
| atomic-polyfill | 1.0.3 | MIT OR Apache-2.0 | https://github.com/embassy-rs/atomic-polyfill |
|
||||
| atomic-waker | 1.1.2 | Apache-2.0 OR MIT | https://github.com/smol-rs/atomic-waker |
|
||||
| attohttpc | 0.30.1 | MPL-2.0 | https://github.com/sbstp/attohttpc |
|
||||
| autocfg | 1.5.0 | Apache-2.0 OR MIT | https://github.com/cuviper/autocfg |
|
||||
| backon | 1.6.0 | Apache-2.0 | https://github.com/Xuanwo/backon |
|
||||
| bao-tree | 0.16.0 | MIT OR Apache-2.0 | https://github.com/n0-computer/bao-tree |
|
||||
| base16ct | 1.0.0 | Apache-2.0 OR MIT | https://github.com/RustCrypto/formats |
|
||||
| base32 | 0.5.1 | MIT OR Apache-2.0 | https://github.com/andreasots/base32 |
|
||||
| base58ck | 0.1.0 | CC0-1.0 | https://github.com/rust-bitcoin/rust-bitcoin/ |
|
||||
| base64 | 0.21.7 | MIT OR Apache-2.0 | https://github.com/marshallpierce/rust-base64 |
|
||||
| base64 | 0.22.1 | MIT OR Apache-2.0 | https://github.com/marshallpierce/rust-base64 |
|
||||
| base64ct | 1.8.3 | Apache-2.0 OR MIT | https://github.com/RustCrypto/formats |
|
||||
| bcrypt | 0.15.1 | MIT | https://github.com/Keats/rust-bcrypt |
|
||||
| bech32 | 0.11.1 | MIT | https://github.com/rust-bitcoin/rust-bech32 |
|
||||
| binary-merge | 0.1.2 | MIT OR Apache-2.0 | https://github.com/rklaehn/binary-merge |
|
||||
| bip39 | 2.1.0 | CC0-1.0 | https://github.com/rust-bitcoin/rust-bip39/ |
|
||||
| bit-vec | 0.9.1 | Apache-2.0 OR MIT | https://github.com/contain-rs/bit-vec |
|
||||
| bitcoin | 0.32.5 | CC0-1.0 | https://github.com/rust-bitcoin/rust-bitcoin/ |
|
||||
| bitcoin-internals | 0.2.0 | CC0-1.0 | https://github.com/rust-bitcoin/rust-bitcoin/ |
|
||||
| bitcoin-internals | 0.3.0 | CC0-1.0 | https://github.com/rust-bitcoin/rust-bitcoin/ |
|
||||
| bitcoin-io | 0.1.4 | CC0-1.0 | https://github.com/rust-bitcoin/rust-bitcoin |
|
||||
| bitcoin-units | 0.1.2 | CC0-1.0 | https://github.com/rust-bitcoin/rust-bitcoin/ |
|
||||
| bitcoin_hashes | 0.13.0 | CC0-1.0 | https://github.com/rust-bitcoin/rust-bitcoin |
|
||||
| bitcoin_hashes | 0.14.1 | CC0-1.0 | https://github.com/rust-bitcoin/rust-bitcoin |
|
||||
| bitflags | 1.3.2 | MIT/Apache-2.0 | https://github.com/bitflags/bitflags |
|
||||
| bitflags | 2.13.0 | MIT OR Apache-2.0 | https://github.com/bitflags/bitflags |
|
||||
| blake2 | 0.10.6 | MIT OR Apache-2.0 | https://github.com/RustCrypto/hashes |
|
||||
| blake3 | 1.8.5 | CC0-1.0 OR Apache-2.0 OR Apache-2.0 WITH LLVM-exception | https://github.com/BLAKE3-team/BLAKE3 |
|
||||
| block-buffer | 0.10.4 | MIT OR Apache-2.0 | https://github.com/RustCrypto/utils |
|
||||
| block-buffer | 0.12.1 | MIT OR Apache-2.0 | https://github.com/RustCrypto/utils |
|
||||
| block-padding | 0.3.3 | MIT OR Apache-2.0 | https://github.com/RustCrypto/utils |
|
||||
| block2 | 0.6.2 | MIT | https://github.com/madsmtm/objc2 |
|
||||
| blowfish | 0.9.1 | MIT OR Apache-2.0 | https://github.com/RustCrypto/block-ciphers |
|
||||
| bs58 | 0.5.1 | MIT/Apache-2.0 | https://github.com/Nullus157/bs58-rs |
|
||||
| bumpalo | 3.19.1 | MIT OR Apache-2.0 | https://github.com/fitzgen/bumpalo |
|
||||
| bytemuck | 1.25.0 | Zlib OR Apache-2.0 OR MIT | https://github.com/Lokathor/bytemuck |
|
||||
| byteorder | 1.5.0 | Unlicense OR MIT | https://github.com/BurntSushi/byteorder |
|
||||
| byteorder-lite | 0.1.0 | Unlicense OR MIT | https://github.com/image-rs/byteorder-lite |
|
||||
| bytes | 1.11.0 | MIT | https://github.com/tokio-rs/bytes |
|
||||
| cbc | 0.1.2 | MIT OR Apache-2.0 | https://github.com/RustCrypto/block-modes |
|
||||
| cc | 1.2.54 | MIT OR Apache-2.0 | https://github.com/rust-lang/cc-rs |
|
||||
| cesu8 | 1.1.0 | Apache-2.0/MIT | https://github.com/emk/cesu8-rs |
|
||||
| cfg-if | 1.0.4 | MIT OR Apache-2.0 | https://github.com/rust-lang/cfg-if |
|
||||
| cfg_aliases | 0.2.1 | MIT | https://github.com/katharostech/cfg_aliases |
|
||||
| chacha20 | 0.10.0 | MIT OR Apache-2.0 | https://github.com/RustCrypto/stream-ciphers |
|
||||
| chacha20 | 0.9.1 | Apache-2.0 OR MIT | https://github.com/RustCrypto/stream-ciphers |
|
||||
| chacha20poly1305 | 0.10.1 | Apache-2.0 OR MIT | https://github.com/RustCrypto/AEADs/tree/master/chacha20poly1305 |
|
||||
| chrono | 0.4.43 | MIT OR Apache-2.0 | https://github.com/chronotope/chrono |
|
||||
| ciborium | 0.2.2 | Apache-2.0 | https://github.com/enarx/ciborium |
|
||||
| ciborium-io | 0.2.2 | Apache-2.0 | https://github.com/enarx/ciborium |
|
||||
| ciborium-ll | 0.2.2 | Apache-2.0 | https://github.com/enarx/ciborium |
|
||||
| cipher | 0.4.4 | MIT OR Apache-2.0 | https://github.com/RustCrypto/traits |
|
||||
| cmov | 0.5.4 | Apache-2.0 OR MIT | https://github.com/RustCrypto/utils |
|
||||
| cobs | 0.3.0 | MIT OR Apache-2.0 | https://github.com/jamesmunns/cobs.rs |
|
||||
| combine | 4.6.7 | MIT | https://github.com/Marwes/combine |
|
||||
| const-oid | 0.10.2 | Apache-2.0 OR MIT | https://github.com/RustCrypto/formats |
|
||||
| const-oid | 0.9.6 | Apache-2.0 OR MIT | https://github.com/RustCrypto/formats/tree/master/const-oid |
|
||||
| constant_time_eq | 0.3.1 | CC0-1.0 OR MIT-0 OR Apache-2.0 | https://github.com/cesarb/constant_time_eq |
|
||||
| constant_time_eq | 0.4.2 | CC0-1.0 OR MIT-0 OR Apache-2.0 | https://github.com/cesarb/constant_time_eq |
|
||||
| convert_case | 0.10.0 | MIT | https://github.com/rutrum/convert-case |
|
||||
| cordyceps | 0.3.4 | MIT | https://github.com/hawkw/mycelium |
|
||||
| core-foundation | 0.10.1 | MIT OR Apache-2.0 | https://github.com/servo/core-foundation-rs |
|
||||
| core-foundation | 0.9.4 | MIT OR Apache-2.0 | https://github.com/servo/core-foundation-rs |
|
||||
| core-foundation-sys | 0.8.7 | MIT OR Apache-2.0 | https://github.com/servo/core-foundation-rs |
|
||||
| cpufeatures | 0.2.17 | MIT OR Apache-2.0 | https://github.com/RustCrypto/utils |
|
||||
| cpufeatures | 0.3.0 | MIT OR Apache-2.0 | https://github.com/RustCrypto/utils |
|
||||
| crc | 3.4.0 | MIT OR Apache-2.0 | https://github.com/mrhooray/crc-rs.git |
|
||||
| crc-catalog | 2.4.0 | MIT OR Apache-2.0 | https://github.com/akhilles/crc-catalog.git |
|
||||
| crc32fast | 1.5.0 | MIT OR Apache-2.0 | https://github.com/srijs/rust-crc32fast |
|
||||
| critical-section | 1.2.0 | MIT OR Apache-2.0 | https://github.com/rust-embedded/critical-section |
|
||||
| crossbeam-channel | 0.5.15 | MIT OR Apache-2.0 | https://github.com/crossbeam-rs/crossbeam |
|
||||
| crossbeam-epoch | 0.9.18 | MIT OR Apache-2.0 | https://github.com/crossbeam-rs/crossbeam |
|
||||
| crossbeam-utils | 0.8.21 | MIT OR Apache-2.0 | https://github.com/crossbeam-rs/crossbeam |
|
||||
| crunchy | 0.2.4 | MIT | https://github.com/eira-fransham/crunchy |
|
||||
| crypto-common | 0.1.7 | MIT OR Apache-2.0 | https://github.com/RustCrypto/traits |
|
||||
| crypto-common | 0.2.2 | MIT OR Apache-2.0 | https://github.com/RustCrypto/traits |
|
||||
| ctr | 0.9.2 | MIT OR Apache-2.0 | https://github.com/RustCrypto/block-modes |
|
||||
| ctutils | 0.4.2 | Apache-2.0 OR MIT | https://github.com/RustCrypto/utils |
|
||||
| curve25519-dalek | 4.1.3 | BSD-3-Clause | https://github.com/dalek-cryptography/curve25519-dalek/tree/main/curve25519-dalek |
|
||||
| curve25519-dalek | 5.0.0-rc.0 | BSD-3-Clause | https://github.com/dalek-cryptography/curve25519-dalek/tree/main/curve25519-dalek |
|
||||
| curve25519-dalek-derive | 0.1.1 | MIT/Apache-2.0 | https://github.com/dalek-cryptography/curve25519-dalek |
|
||||
| darling | 0.20.11 | MIT | https://github.com/TedDriggs/darling |
|
||||
| darling_core | 0.20.11 | MIT | https://github.com/TedDriggs/darling |
|
||||
| darling_macro | 0.20.11 | MIT | https://github.com/TedDriggs/darling |
|
||||
| data-encoding | 2.11.0 | MIT | https://github.com/ia0/data-encoding |
|
||||
| data-encoding-macro | 0.1.20 | MIT | https://github.com/ia0/data-encoding |
|
||||
| data-encoding-macro-internal | 0.1.18 | MIT | https://github.com/ia0/data-encoding |
|
||||
| der | 0.7.10 | Apache-2.0 OR MIT | https://github.com/RustCrypto/formats/tree/master/der |
|
||||
| der | 0.8.0 | Apache-2.0 OR MIT | https://github.com/RustCrypto/formats |
|
||||
| der-parser | 10.0.0 | MIT OR Apache-2.0 | https://github.com/rusticata/der-parser.git |
|
||||
| deranged | 0.5.8 | MIT OR Apache-2.0 | https://github.com/jhpratt/deranged |
|
||||
| derive_builder | 0.20.2 | MIT OR Apache-2.0 | https://github.com/colin-kiegel/rust-derive-builder |
|
||||
| derive_builder_core | 0.20.2 | MIT OR Apache-2.0 | https://github.com/colin-kiegel/rust-derive-builder |
|
||||
| derive_builder_macro | 0.20.2 | MIT OR Apache-2.0 | https://github.com/colin-kiegel/rust-derive-builder |
|
||||
| derive_more | 2.1.1 | MIT | https://github.com/JelteF/derive_more |
|
||||
| derive_more-impl | 2.1.1 | MIT | https://github.com/JelteF/derive_more |
|
||||
| diatomic-waker | 0.2.3 | MIT OR Apache-2.0 | https://github.com/asynchronics/diatomic-waker |
|
||||
| digest | 0.10.7 | MIT OR Apache-2.0 | https://github.com/RustCrypto/traits |
|
||||
| digest | 0.11.3 | MIT OR Apache-2.0 | https://github.com/RustCrypto/traits |
|
||||
| dispatch2 | 0.3.1 | Zlib OR Apache-2.0 OR MIT | https://github.com/madsmtm/objc2 |
|
||||
| displaydoc | 0.2.5 | MIT OR Apache-2.0 | https://github.com/yaahc/displaydoc |
|
||||
| dlopen2 | 0.8.2 | MIT | https://github.com/OpenByteDev/dlopen2 |
|
||||
| ed25519 | 2.2.3 | Apache-2.0 OR MIT | https://github.com/RustCrypto/signatures/tree/master/ed25519 |
|
||||
| ed25519 | 3.0.0 | Apache-2.0 OR MIT | https://github.com/RustCrypto/signatures |
|
||||
| ed25519-dalek | 2.2.0 | BSD-3-Clause | https://github.com/dalek-cryptography/curve25519-dalek/tree/main/ed25519-dalek |
|
||||
| ed25519-dalek | 3.0.0-rc.0 | BSD-3-Clause | https://github.com/dalek-cryptography/curve25519-dalek/tree/main/ed25519-dalek |
|
||||
| either | 1.15.0 | MIT OR Apache-2.0 | https://github.com/rayon-rs/either |
|
||||
| embedded-io | 0.4.0 | MIT OR Apache-2.0 | https://github.com/embassy-rs/embedded-io |
|
||||
| embedded-io | 0.6.1 | MIT OR Apache-2.0 | https://github.com/rust-embedded/embedded-hal |
|
||||
| encoding_rs | 0.8.35 | (Apache-2.0 OR MIT) AND BSD-3-Clause | https://github.com/hsivonen/encoding_rs |
|
||||
| enum-assoc | 1.3.0 | MIT OR Apache-2.0 | https://github.com/Eolu/enum-assoc |
|
||||
| env_logger | 0.10.2 | MIT OR Apache-2.0 | https://github.com/rust-cli/env_logger |
|
||||
| equivalent | 1.0.2 | Apache-2.0 OR MIT | https://github.com/indexmap-rs/equivalent |
|
||||
| errno | 0.3.14 | MIT OR Apache-2.0 | https://github.com/lambda-fairy/rust-errno |
|
||||
| fastbloom | 0.17.0 | MIT OR Apache-2.0 | https://github.com/tomtomwombat/fastbloom/ |
|
||||
| fastrand | 2.3.0 | Apache-2.0 OR MIT | https://github.com/smol-rs/fastrand |
|
||||
| fiat-crypto | 0.2.9 | MIT OR Apache-2.0 OR BSD-1-Clause | https://github.com/mit-plv/fiat-crypto |
|
||||
| fiat-crypto | 0.3.0 | MIT OR Apache-2.0 OR BSD-1-Clause | https://github.com/mit-plv/fiat-crypto |
|
||||
| filetime | 0.2.27 | MIT/Apache-2.0 | https://github.com/alexcrichton/filetime |
|
||||
| find-msvc-tools | 0.1.8 | MIT OR Apache-2.0 | https://github.com/rust-lang/cc-rs |
|
||||
| flate2 | 1.1.9 | MIT OR Apache-2.0 | https://github.com/rust-lang/flate2-rs |
|
||||
| flume | 0.11.1 | Apache-2.0/MIT | https://github.com/zesterer/flume |
|
||||
| fnv | 1.0.7 | Apache-2.0 / MIT | https://github.com/servo/rust-fnv |
|
||||
| foldhash | 0.1.5 | Zlib | https://github.com/orlp/foldhash |
|
||||
| foldhash | 0.2.0 | Zlib | https://github.com/orlp/foldhash |
|
||||
| form_urlencoded | 1.2.2 | MIT OR Apache-2.0 | https://github.com/servo/rust-url |
|
||||
| futures | 0.3.31 | MIT OR Apache-2.0 | https://github.com/rust-lang/futures-rs |
|
||||
| futures-buffered | 0.2.13 | MIT | https://github.com/conradludgate/futures-buffered |
|
||||
| futures-channel | 0.3.31 | MIT OR Apache-2.0 | https://github.com/rust-lang/futures-rs |
|
||||
| futures-core | 0.3.31 | MIT OR Apache-2.0 | https://github.com/rust-lang/futures-rs |
|
||||
| futures-executor | 0.3.31 | MIT OR Apache-2.0 | https://github.com/rust-lang/futures-rs |
|
||||
| futures-io | 0.3.31 | MIT OR Apache-2.0 | https://github.com/rust-lang/futures-rs |
|
||||
| futures-lite | 2.6.1 | Apache-2.0 OR MIT | https://github.com/smol-rs/futures-lite |
|
||||
| futures-macro | 0.3.31 | MIT OR Apache-2.0 | https://github.com/rust-lang/futures-rs |
|
||||
| futures-sink | 0.3.31 | MIT OR Apache-2.0 | https://github.com/rust-lang/futures-rs |
|
||||
| futures-task | 0.3.31 | MIT OR Apache-2.0 | https://github.com/rust-lang/futures-rs |
|
||||
| futures-util | 0.3.31 | MIT OR Apache-2.0 | https://github.com/rust-lang/futures-rs |
|
||||
| genawaiter | 0.99.1 | MIT | https://github.com/whatisaphone/genawaiter |
|
||||
| genawaiter-macro | 0.99.1 | MIT/Apache-2.0 | https://github.com/whatisaphone/genawaiter |
|
||||
| genawaiter-proc-macro | 0.99.1 | MIT/Apache-2.0 | https://github.com/whatisaphone/genawaiter |
|
||||
| generator | 0.8.9 | MIT/Apache-2.0 | https://github.com/Xudong-Huang/generator-rs.git |
|
||||
| generic-array | 0.14.7 | MIT | https://github.com/fizyk20/generic-array.git |
|
||||
| getrandom | 0.2.17 | MIT OR Apache-2.0 | https://github.com/rust-random/getrandom |
|
||||
| getrandom | 0.3.4 | MIT OR Apache-2.0 | https://github.com/rust-random/getrandom |
|
||||
| getrandom | 0.4.2 | MIT OR Apache-2.0 | https://github.com/rust-random/getrandom |
|
||||
| ghash | 0.5.1 | Apache-2.0 OR MIT | https://github.com/RustCrypto/universal-hashes |
|
||||
| gloo-timers | 0.3.0 | MIT OR Apache-2.0 | https://github.com/rustwasm/gloo/tree/master/crates/timers |
|
||||
| h2 | 0.3.27 | MIT | https://github.com/hyperium/h2 |
|
||||
| h2 | 0.4.13 | MIT | https://github.com/hyperium/h2 |
|
||||
| half | 2.7.1 | MIT OR Apache-2.0 | https://github.com/VoidStarKat/half-rs |
|
||||
| hash32 | 0.2.1 | MIT OR Apache-2.0 | https://github.com/japaric/hash32 |
|
||||
| hashbrown | 0.12.3 | MIT OR Apache-2.0 | https://github.com/rust-lang/hashbrown |
|
||||
| hashbrown | 0.15.5 | MIT OR Apache-2.0 | https://github.com/rust-lang/hashbrown |
|
||||
| hashbrown | 0.16.1 | MIT OR Apache-2.0 | https://github.com/rust-lang/hashbrown |
|
||||
| hashbrown | 0.17.1 | MIT OR Apache-2.0 | https://github.com/rust-lang/hashbrown |
|
||||
| heapless | 0.7.17 | MIT OR Apache-2.0 | https://github.com/japaric/heapless |
|
||||
| heck | 0.5.0 | MIT OR Apache-2.0 | https://github.com/withoutboats/heck |
|
||||
| hermit-abi | 0.5.2 | MIT OR Apache-2.0 | https://github.com/hermit-os/hermit-rs |
|
||||
| hex | 0.4.3 | MIT OR Apache-2.0 | https://github.com/KokaKiwi/rust-hex |
|
||||
| hex-conservative | 0.1.2 | CC0-1.0 | https://github.com/rust-bitcoin/hex-conservative |
|
||||
| hex-conservative | 0.2.2 | CC0-1.0 | https://github.com/rust-bitcoin/hex-conservative |
|
||||
| hex_lit | 0.1.1 | MITNFA | https://github.com/Kixunil/hex_lit |
|
||||
| hickory-net | 0.26.1 | MIT OR Apache-2.0 | https://github.com/hickory-dns/hickory-dns |
|
||||
| hickory-proto | 0.26.1 | MIT OR Apache-2.0 | https://github.com/hickory-dns/hickory-dns |
|
||||
| hickory-resolver | 0.26.1 | MIT OR Apache-2.0 | https://github.com/hickory-dns/hickory-dns |
|
||||
| hkdf | 0.12.4 | MIT OR Apache-2.0 | https://github.com/RustCrypto/KDFs/ |
|
||||
| hmac | 0.12.1 | MIT OR Apache-2.0 | https://github.com/RustCrypto/MACs |
|
||||
| http | 0.2.12 | MIT OR Apache-2.0 | https://github.com/hyperium/http |
|
||||
| http | 1.4.0 | MIT OR Apache-2.0 | https://github.com/hyperium/http |
|
||||
| http-body | 0.4.6 | MIT | https://github.com/hyperium/http-body |
|
||||
| http-body | 1.0.1 | MIT | https://github.com/hyperium/http-body |
|
||||
| http-body-util | 0.1.3 | MIT | https://github.com/hyperium/http-body |
|
||||
| httparse | 1.10.1 | MIT OR Apache-2.0 | https://github.com/seanmonstar/httparse |
|
||||
| httpdate | 1.0.3 | MIT OR Apache-2.0 | https://github.com/pyfisch/httpdate |
|
||||
| humantime | 2.3.0 | MIT OR Apache-2.0 | https://github.com/chronotope/humantime |
|
||||
| hybrid-array | 0.4.12 | MIT OR Apache-2.0 | https://github.com/RustCrypto/hybrid-array |
|
||||
| hyper | 0.14.32 | MIT | https://github.com/hyperium/hyper |
|
||||
| hyper | 1.8.1 | MIT | https://github.com/hyperium/hyper |
|
||||
| hyper-rustls | 0.24.2 | Apache-2.0 OR ISC OR MIT | https://github.com/rustls/hyper-rustls |
|
||||
| hyper-rustls | 0.27.9 | Apache-2.0 OR ISC OR MIT | https://github.com/rustls/hyper-rustls |
|
||||
| hyper-util | 0.1.19 | MIT | https://github.com/hyperium/hyper-util |
|
||||
| hyper-ws-listener | 0.3.0 | MIT | |
|
||||
| iana-time-zone | 0.1.64 | MIT OR Apache-2.0 | https://github.com/strawlab/iana-time-zone |
|
||||
| iana-time-zone-haiku | 0.1.2 | MIT OR Apache-2.0 | https://github.com/strawlab/iana-time-zone |
|
||||
| icu_collections | 2.1.1 | Unicode-3.0 | https://github.com/unicode-org/icu4x |
|
||||
| icu_locale_core | 2.1.1 | Unicode-3.0 | https://github.com/unicode-org/icu4x |
|
||||
| icu_normalizer | 2.1.1 | Unicode-3.0 | https://github.com/unicode-org/icu4x |
|
||||
| icu_normalizer_data | 2.1.1 | Unicode-3.0 | https://github.com/unicode-org/icu4x |
|
||||
| icu_properties | 2.1.2 | Unicode-3.0 | https://github.com/unicode-org/icu4x |
|
||||
| icu_properties_data | 2.1.2 | Unicode-3.0 | https://github.com/unicode-org/icu4x |
|
||||
| icu_provider | 2.1.1 | Unicode-3.0 | https://github.com/unicode-org/icu4x |
|
||||
| id-arena | 2.3.0 | MIT/Apache-2.0 | https://github.com/fitzgen/id-arena |
|
||||
| ident_case | 1.0.1 | MIT/Apache-2.0 | https://github.com/TedDriggs/ident_case |
|
||||
| identity-hash | 0.1.0 | Apache-2.0 OR MIT | https://github.com/offsetting/identity-hash |
|
||||
| idna | 1.1.0 | MIT OR Apache-2.0 | https://github.com/servo/rust-url/ |
|
||||
| idna_adapter | 1.2.1 | Apache-2.0 OR MIT | https://github.com/hsivonen/idna_adapter |
|
||||
| if-addrs | 0.15.0 | MIT OR BSD-3-Clause | https://github.com/messense/if-addrs |
|
||||
| igd-next | 0.17.1 | MIT | https://github.com/dariusc93/rust-igd |
|
||||
| image | 0.25.9 | MIT OR Apache-2.0 | https://github.com/image-rs/image |
|
||||
| indexmap | 2.13.0 | Apache-2.0 OR MIT | https://github.com/indexmap-rs/indexmap |
|
||||
| inout | 0.1.4 | MIT OR Apache-2.0 | https://github.com/RustCrypto/utils |
|
||||
| inplace-vec-builder | 0.1.1 | MIT OR Apache-2.0 | https://github.com/rklaehn/inplace-vec-builder |
|
||||
| instant | 0.1.13 | BSD-3-Clause | https://github.com/sebcrozet/instant |
|
||||
| ipconfig | 0.3.4 | MIT/Apache-2.0 | https://github.com/liranringel/ipconfig |
|
||||
| ipnet | 2.12.0 | MIT OR Apache-2.0 | https://github.com/krisprice/ipnet |
|
||||
| iri-string | 0.7.12 | MIT OR Apache-2.0 | https://github.com/lo48576/iri-string |
|
||||
| iroh | 1.0.0 | MIT OR Apache-2.0 | https://github.com/n0-computer/iroh |
|
||||
| iroh-base | 1.0.0 | MIT OR Apache-2.0 | https://github.com/n0-computer/iroh |
|
||||
| iroh-blobs | 0.103.0 | MIT OR Apache-2.0 | https://github.com/n0-computer/iroh-blobs |
|
||||
| iroh-dns | 1.0.0 | MIT OR Apache-2.0 | https://github.com/n0-computer/iroh |
|
||||
| iroh-io | 0.6.2 | Apache-2.0 OR MIT | https://github.com/n0-computer/iroh |
|
||||
| iroh-metrics | 1.0.1 | MIT OR Apache-2.0 | https://github.com/n0-computer/iroh-metrics |
|
||||
| iroh-metrics-derive | 1.0.1 | MIT OR Apache-2.0 | https://github.com/n0-computer/iroh-metrics |
|
||||
| iroh-relay | 1.0.0 | MIT OR Apache-2.0 | https://github.com/n0-computer/iroh |
|
||||
| iroh-tickets | 1.0.0 | MIT OR Apache-2.0 | https://github.com/n0-computer/iroh-tickets |
|
||||
| iroh-util | 0.6.0 | MIT OR Apache-2.0 | https://github.com/n0-computer/iroh-util |
|
||||
| irpc | 0.17.0 | Apache-2.0/MIT | https://github.com/n0-computer/irpc |
|
||||
| irpc-derive | 0.17.0 | Apache-2.0/MIT | https://github.com/n0-computer/irpc |
|
||||
| is-terminal | 0.4.17 | MIT | https://github.com/sunfishcode/is-terminal |
|
||||
| itoa | 1.0.17 | MIT OR Apache-2.0 | https://github.com/dtolnay/itoa |
|
||||
| jni | 0.21.1 | MIT/Apache-2.0 | https://github.com/jni-rs/jni-rs |
|
||||
| jni | 0.22.4 | MIT OR Apache-2.0 | https://github.com/jni-rs/jni-rs |
|
||||
| jni-macros | 0.22.4 | MIT OR Apache-2.0 | https://github.com/jni-rs/jni-rs |
|
||||
| jni-sys | 0.3.1 | MIT OR Apache-2.0 | https://github.com/jni-rs/jni-sys |
|
||||
| jni-sys | 0.4.1 | MIT OR Apache-2.0 | https://github.com/jni-rs/jni-sys |
|
||||
| jni-sys-macros | 0.4.1 | MIT OR Apache-2.0 | https://github.com/jni-rs/jni-sys |
|
||||
| js-sys | 0.3.85 | MIT OR Apache-2.0 | https://github.com/wasm-bindgen/wasm-bindgen/tree/master/crates/js-sys |
|
||||
| lazy_static | 1.5.0 | MIT OR Apache-2.0 | https://github.com/rust-lang-nursery/lazy-static.rs |
|
||||
| leb128fmt | 0.1.0 | MIT OR Apache-2.0 | https://github.com/bluk/leb128fmt |
|
||||
| libc | 0.2.180 | MIT OR Apache-2.0 | https://github.com/rust-lang/libc |
|
||||
| libm | 0.2.16 | MIT | https://github.com/rust-lang/compiler-builtins |
|
||||
| libredox | 0.1.14 | MIT | https://gitlab.redox-os.org/redox-os/libredox.git |
|
||||
| libssh2-sys | 0.3.1 | MIT OR Apache-2.0 | https://github.com/alexcrichton/ssh2-rs |
|
||||
| libz-sys | 1.1.29 | MIT OR Apache-2.0 | https://github.com/rust-lang/libz-sys |
|
||||
| linux-raw-sys | 0.11.0 | Apache-2.0 WITH LLVM-exception OR Apache-2.0 OR MIT | https://github.com/sunfishcode/linux-raw-sys |
|
||||
| litemap | 0.8.1 | Unicode-3.0 | https://github.com/unicode-org/icu4x |
|
||||
| lock_api | 0.4.14 | MIT OR Apache-2.0 | https://github.com/Amanieu/parking_lot |
|
||||
| log | 0.4.29 | MIT OR Apache-2.0 | https://github.com/rust-lang/log |
|
||||
| loom | 0.7.2 | MIT | https://github.com/tokio-rs/loom |
|
||||
| lru | 0.12.5 | MIT | https://github.com/jeromefroe/lru-rs.git |
|
||||
| lru | 0.16.3 | MIT | https://github.com/jeromefroe/lru-rs.git |
|
||||
| lru | 0.18.0 | MIT | https://github.com/jeromefroe/lru-rs.git |
|
||||
| lru | 0.7.8 | MIT | https://github.com/jeromefroe/lru-rs.git |
|
||||
| lru-slab | 0.1.2 | MIT OR Apache-2.0 OR Zlib | https://github.com/Ralith/lru-slab |
|
||||
| mac-addr | 0.3.0 | MIT | https://github.com/shellrow/mac-addr |
|
||||
| mainline | 2.0.1 | MIT | https://github.com/nuhvi/mainline |
|
||||
| matchers | 0.2.0 | MIT | https://github.com/hawkw/matchers |
|
||||
| mdns-sd | 0.18.2 | Apache-2.0 OR MIT | https://github.com/keepsimple1/mdns-sd |
|
||||
| memchr | 2.7.6 | Unlicense OR MIT | https://github.com/BurntSushi/memchr |
|
||||
| mime | 0.3.17 | MIT OR Apache-2.0 | https://github.com/hyperium/mime |
|
||||
| minimal-lexical | 0.2.1 | MIT/Apache-2.0 | https://github.com/Alexhuszagh/minimal-lexical |
|
||||
| miniz_oxide | 0.8.9 | MIT OR Zlib OR Apache-2.0 | https://github.com/Frommi/miniz_oxide/tree/master/miniz_oxide |
|
||||
| mio | 1.1.1 | MIT | https://github.com/tokio-rs/mio |
|
||||
| moka | 0.12.15 | (MIT OR Apache-2.0) AND Apache-2.0 | https://github.com/moka-rs/moka |
|
||||
| moxcms | 0.7.11 | BSD-3-Clause OR Apache-2.0 | https://github.com/awxkee/moxcms.git |
|
||||
| n0-error | 1.0.0 | MIT OR Apache-2.0 | https://github.com/n0-computer/n0-error |
|
||||
| n0-error-macros | 1.0.0 | MIT OR Apache-2.0 | https://github.com/n0-computer/n0-error |
|
||||
| n0-future | 0.3.2 | MIT OR Apache-2.0 | https://github.com/n0-computer/n0-future |
|
||||
| n0-watcher | 1.0.0 | MIT OR Apache-2.0 | https://github.com/n0-computer/n0-watcher |
|
||||
| ndk-context | 0.1.1 | MIT OR Apache-2.0 | https://github.com/rust-windowing/android-ndk-rs |
|
||||
| negentropy | 0.5.0 | MIT | https://github.com/rust-nostr/negentropy.git |
|
||||
| nested_enum_utils | 0.2.3 | MIT OR Apache-2.0 | https://github.com/n0-computer/nested-enum-utils |
|
||||
| netdev | 0.44.0 | MIT | https://github.com/shellrow/netdev |
|
||||
| netlink-packet-core | 0.8.1 | MIT | https://github.com/rust-netlink/netlink-packet-core |
|
||||
| netlink-packet-route | 0.29.0 | MIT | https://github.com/rust-netlink/netlink-packet-route |
|
||||
| netlink-packet-route | 0.31.0 | MIT | https://github.com/rust-netlink/netlink-packet-route |
|
||||
| netlink-proto | 0.12.0 | MIT | https://github.com/rust-netlink/netlink-proto |
|
||||
| netlink-sys | 0.8.8 | MIT | https://github.com/rust-netlink/netlink-sys |
|
||||
| netwatch | 0.19.0 | MIT OR Apache-2.0 | https://github.com/n0-computer/net-tools |
|
||||
| nom | 7.1.3 | MIT | https://github.com/Geal/nom |
|
||||
| noq | 1.0.0 | MIT OR Apache-2.0 | https://github.com/n0-computer/noq |
|
||||
| noq-proto | 1.0.0 | MIT OR Apache-2.0 | https://github.com/n0-computer/noq |
|
||||
| noq-udp | 1.0.0 | MIT OR Apache-2.0 | https://github.com/n0-computer/noq |
|
||||
| nostr | 0.44.2 | MIT | https://github.com/rust-nostr/nostr.git |
|
||||
| nostr-database | 0.44.0 | MIT | https://github.com/rust-nostr/nostr.git |
|
||||
| nostr-gossip | 0.44.0 | MIT | https://github.com/rust-nostr/nostr.git |
|
||||
| nostr-relay-pool | 0.44.0 | MIT | https://github.com/rust-nostr/nostr.git |
|
||||
| nostr-sdk | 0.44.1 | MIT | https://github.com/rust-nostr/nostr.git |
|
||||
| nu-ansi-term | 0.50.3 | MIT | https://github.com/nushell/nu-ansi-term |
|
||||
| num-bigint | 0.4.6 | MIT OR Apache-2.0 | https://github.com/rust-num/num-bigint |
|
||||
| num-conv | 0.2.2 | MIT OR Apache-2.0 | https://github.com/jhpratt/num-conv |
|
||||
| num-integer | 0.1.46 | MIT OR Apache-2.0 | https://github.com/rust-num/num-integer |
|
||||
| num-traits | 0.2.19 | MIT OR Apache-2.0 | https://github.com/rust-num/num-traits |
|
||||
| num_enum | 0.7.6 | BSD-3-Clause OR MIT OR Apache-2.0 | https://github.com/illicitonion/num_enum |
|
||||
| num_enum_derive | 0.7.6 | BSD-3-Clause OR MIT OR Apache-2.0 | https://github.com/illicitonion/num_enum |
|
||||
| num_threads | 0.1.7 | MIT OR Apache-2.0 | https://github.com/jhpratt/num_threads |
|
||||
| objc2 | 0.6.4 | MIT | https://github.com/madsmtm/objc2 |
|
||||
| objc2-core-foundation | 0.3.2 | Zlib OR Apache-2.0 OR MIT | https://github.com/madsmtm/objc2 |
|
||||
| objc2-core-wlan | 0.3.2 | Zlib OR Apache-2.0 OR MIT | https://github.com/madsmtm/objc2 |
|
||||
| objc2-encode | 4.1.0 | MIT | https://github.com/madsmtm/objc2 |
|
||||
| objc2-foundation | 0.3.2 | MIT | https://github.com/madsmtm/objc2 |
|
||||
| objc2-security | 0.3.2 | Zlib OR Apache-2.0 OR MIT | https://github.com/madsmtm/objc2 |
|
||||
| objc2-security-foundation | 0.3.2 | Zlib OR Apache-2.0 OR MIT | https://github.com/madsmtm/objc2 |
|
||||
| objc2-system-configuration | 0.3.2 | Zlib OR Apache-2.0 OR MIT | https://github.com/madsmtm/objc2 |
|
||||
| oid-registry | 0.8.1 | MIT OR Apache-2.0 | https://github.com/rusticata/oid-registry.git |
|
||||
| once_cell | 1.21.3 | MIT OR Apache-2.0 | https://github.com/matklad/once_cell |
|
||||
| opaque-debug | 0.3.1 | MIT OR Apache-2.0 | https://github.com/RustCrypto/utils |
|
||||
| openssl-probe | 0.2.1 | MIT OR Apache-2.0 | https://github.com/rustls/openssl-probe |
|
||||
| openssl-sys | 0.9.117 | MIT | https://github.com/rust-openssl/rust-openssl |
|
||||
| papaya | 0.2.4 | MIT | https://github.com/ibraheemdev/papaya |
|
||||
| parking | 2.2.1 | Apache-2.0 OR MIT | https://github.com/smol-rs/parking |
|
||||
| parking_lot | 0.11.2 | Apache-2.0/MIT | https://github.com/Amanieu/parking_lot |
|
||||
| parking_lot | 0.12.5 | MIT OR Apache-2.0 | https://github.com/Amanieu/parking_lot |
|
||||
| parking_lot_core | 0.8.6 | Apache-2.0/MIT | https://github.com/Amanieu/parking_lot |
|
||||
| parking_lot_core | 0.9.12 | MIT OR Apache-2.0 | https://github.com/Amanieu/parking_lot |
|
||||
| password-hash | 0.5.0 | MIT OR Apache-2.0 | https://github.com/RustCrypto/traits/tree/master/password-hash |
|
||||
| paste | 1.0.15 | MIT OR Apache-2.0 | https://github.com/dtolnay/paste |
|
||||
| pbkdf2 | 0.12.2 | MIT OR Apache-2.0 | https://github.com/RustCrypto/password-hashes/tree/master/pbkdf2 |
|
||||
| pem | 3.0.6 | MIT | https://github.com/jcreekmore/pem-rs.git |
|
||||
| pem-rfc7468 | 1.0.0 | Apache-2.0 OR MIT | https://github.com/RustCrypto/formats |
|
||||
| percent-encoding | 2.3.2 | MIT OR Apache-2.0 | https://github.com/servo/rust-url/ |
|
||||
| pharos | 0.5.3 | Unlicense | https://github.com/najamelan/pharos |
|
||||
| pin-project | 1.1.13 | Apache-2.0 OR MIT | https://github.com/taiki-e/pin-project |
|
||||
| pin-project-internal | 1.1.13 | Apache-2.0 OR MIT | https://github.com/taiki-e/pin-project |
|
||||
| pin-project-lite | 0.2.16 | Apache-2.0 OR MIT | https://github.com/taiki-e/pin-project-lite |
|
||||
| pin-utils | 0.1.0 | MIT OR Apache-2.0 | https://github.com/rust-lang-nursery/pin-utils |
|
||||
| pkcs8 | 0.10.2 | Apache-2.0 OR MIT | https://github.com/RustCrypto/formats/tree/master/pkcs8 |
|
||||
| pkcs8 | 0.11.0 | Apache-2.0 OR MIT | https://github.com/RustCrypto/formats |
|
||||
| pkg-config | 0.3.33 | MIT OR Apache-2.0 | https://github.com/rust-lang/pkg-config-rs |
|
||||
| plain | 0.2.3 | MIT/Apache-2.0 | https://github.com/randomites/plain |
|
||||
| plist | 1.9.0 | MIT | https://github.com/ebarnard/rust-plist/ |
|
||||
| poly1305 | 0.8.0 | Apache-2.0 OR MIT | https://github.com/RustCrypto/universal-hashes |
|
||||
| polyval | 0.6.2 | Apache-2.0 OR MIT | https://github.com/RustCrypto/universal-hashes |
|
||||
| portable-atomic | 1.13.1 | Apache-2.0 OR MIT | https://github.com/taiki-e/portable-atomic |
|
||||
| portmapper | 0.19.0 | MIT OR Apache-2.0 | https://github.com/n0-computer/net-tools |
|
||||
| positioned-io | 0.3.5 | MIT | https://github.com/vasi/positioned-io |
|
||||
| postcard | 1.1.3 | MIT OR Apache-2.0 | https://github.com/jamesmunns/postcard |
|
||||
| postcard-derive | 0.2.2 | MIT OR Apache-2.0 | https://github.com/jamesmunns/postcard |
|
||||
| potential_utf | 0.1.4 | Unicode-3.0 | https://github.com/unicode-org/icu4x |
|
||||
| powerfmt | 0.2.0 | MIT OR Apache-2.0 | https://github.com/jhpratt/powerfmt |
|
||||
| ppv-lite86 | 0.2.21 | MIT OR Apache-2.0 | https://github.com/cryptocorrosion/cryptocorrosion |
|
||||
| prefix-trie | 0.8.4 | MIT OR Apache-2.0 | https://github.com/tiborschneider/prefix-trie |
|
||||
| prettyplease | 0.2.37 | MIT OR Apache-2.0 | https://github.com/dtolnay/prettyplease |
|
||||
| proc-macro-crate | 3.5.0 | MIT OR Apache-2.0 | https://github.com/bkchr/proc-macro-crate |
|
||||
| proc-macro-error | 0.4.12 | MIT OR Apache-2.0 | https://gitlab.com/CreepySkeleton/proc-macro-error |
|
||||
| proc-macro-error-attr | 0.4.12 | MIT OR Apache-2.0 | https://gitlab.com/CreepySkeleton/proc-macro-error |
|
||||
| proc-macro-hack | 0.5.20+deprecated | MIT OR Apache-2.0 | https://github.com/dtolnay/proc-macro-hack |
|
||||
| proc-macro2 | 1.0.106 | MIT OR Apache-2.0 | https://github.com/dtolnay/proc-macro2 |
|
||||
| pxfm | 0.1.28 | BSD-3-Clause OR Apache-2.0 | https://github.com/awxkee/pxfm |
|
||||
| qrcode | 0.14.1 | MIT OR Apache-2.0 | https://github.com/kennytm/qrcode-rust |
|
||||
| quick-xml | 0.39.4 | MIT | https://github.com/tafia/quick-xml |
|
||||
| quote | 1.0.44 | MIT OR Apache-2.0 | https://github.com/dtolnay/quote |
|
||||
| r-efi | 5.3.0 | MIT OR Apache-2.0 OR LGPL-2.1-or-later | https://github.com/r-efi/r-efi |
|
||||
| r-efi | 6.0.0 | MIT OR Apache-2.0 OR LGPL-2.1-or-later | https://github.com/r-efi/r-efi |
|
||||
| rand | 0.10.1 | MIT OR Apache-2.0 | https://github.com/rust-random/rand |
|
||||
| rand | 0.8.5 | MIT OR Apache-2.0 | https://github.com/rust-random/rand |
|
||||
| rand | 0.9.2 | MIT OR Apache-2.0 | https://github.com/rust-random/rand |
|
||||
| rand_chacha | 0.3.1 | MIT OR Apache-2.0 | https://github.com/rust-random/rand |
|
||||
| rand_chacha | 0.9.0 | MIT OR Apache-2.0 | https://github.com/rust-random/rand |
|
||||
| rand_core | 0.10.1 | MIT OR Apache-2.0 | https://github.com/rust-random/rand_core |
|
||||
| rand_core | 0.6.4 | MIT OR Apache-2.0 | https://github.com/rust-random/rand |
|
||||
| rand_core | 0.9.5 | MIT OR Apache-2.0 | https://github.com/rust-random/rand |
|
||||
| rand_pcg | 0.10.2 | MIT OR Apache-2.0 | https://github.com/rust-random/rngs |
|
||||
| range-collections | 0.4.6 | MIT OR Apache-2.0 | https://github.com/rklaehn/range-collections |
|
||||
| rcgen | 0.14.8 | MIT OR Apache-2.0 | https://github.com/rustls/rcgen |
|
||||
| redb | 4.1.0 | MIT OR Apache-2.0 | https://github.com/cberner/redb |
|
||||
| redox_syscall | 0.2.16 | MIT | https://gitlab.redox-os.org/redox-os/syscall |
|
||||
| redox_syscall | 0.5.18 | MIT | https://gitlab.redox-os.org/redox-os/syscall |
|
||||
| redox_syscall | 0.7.3 | MIT | https://gitlab.redox-os.org/redox-os/syscall |
|
||||
| reed-solomon-erasure | 6.0.0 | MIT | https://github.com/darrenldl/reed-solomon-erasure |
|
||||
| ref-cast | 1.0.25 | MIT OR Apache-2.0 | https://github.com/dtolnay/ref-cast |
|
||||
| ref-cast-impl | 1.0.25 | MIT OR Apache-2.0 | https://github.com/dtolnay/ref-cast |
|
||||
| reflink-copy | 0.1.29 | MIT/Apache-2.0 | https://github.com/cargo-bins/reflink-copy |
|
||||
| regex | 1.12.2 | MIT OR Apache-2.0 | https://github.com/rust-lang/regex |
|
||||
| regex-automata | 0.4.13 | MIT OR Apache-2.0 | https://github.com/rust-lang/regex |
|
||||
| regex-syntax | 0.8.8 | MIT OR Apache-2.0 | https://github.com/rust-lang/regex |
|
||||
| reqwest | 0.11.27 | MIT OR Apache-2.0 | https://github.com/seanmonstar/reqwest |
|
||||
| reqwest | 0.13.4 | MIT OR Apache-2.0 | https://github.com/seanmonstar/reqwest |
|
||||
| resolv-conf | 0.7.6 | MIT OR Apache-2.0 | https://github.com/hickory-dns/resolv-conf |
|
||||
| ring | 0.17.14 | Apache-2.0 AND ISC | https://github.com/briansmith/ring |
|
||||
| rustc-hash | 2.1.2 | Apache-2.0 OR MIT | https://github.com/rust-lang/rustc-hash |
|
||||
| rustc_version | 0.4.1 | MIT OR Apache-2.0 | https://github.com/djc/rustc-version-rs |
|
||||
| rusticata-macros | 4.1.0 | MIT/Apache-2.0 | https://github.com/rusticata/rusticata-macros.git |
|
||||
| rustix | 1.1.3 | Apache-2.0 WITH LLVM-exception OR Apache-2.0 OR MIT | https://github.com/bytecodealliance/rustix |
|
||||
| rustls | 0.21.12 | Apache-2.0 OR ISC OR MIT | https://github.com/rustls/rustls |
|
||||
| rustls | 0.23.36 | Apache-2.0 OR ISC OR MIT | https://github.com/rustls/rustls |
|
||||
| rustls-native-certs | 0.8.4 | Apache-2.0 OR ISC OR MIT | https://github.com/rustls/rustls-native-certs |
|
||||
| rustls-pemfile | 1.0.4 | Apache-2.0 OR ISC OR MIT | https://github.com/rustls/pemfile |
|
||||
| rustls-pki-types | 1.14.0 | MIT OR Apache-2.0 | https://github.com/rustls/pki-types |
|
||||
| rustls-platform-verifier | 0.7.0 | MIT OR Apache-2.0 | https://github.com/rustls/rustls-platform-verifier |
|
||||
| rustls-platform-verifier-android | 0.1.1 | MIT OR Apache-2.0 | https://github.com/rustls/rustls-platform-verifier |
|
||||
| rustls-webpki | 0.101.7 | ISC | https://github.com/rustls/webpki |
|
||||
| rustls-webpki | 0.103.9 | ISC | https://github.com/rustls/webpki |
|
||||
| rustversion | 1.0.22 | MIT OR Apache-2.0 | https://github.com/dtolnay/rustversion |
|
||||
| ryu | 1.0.22 | Apache-2.0 OR BSL-1.0 | https://github.com/dtolnay/ryu |
|
||||
| salsa20 | 0.10.2 | MIT OR Apache-2.0 | https://github.com/RustCrypto/stream-ciphers |
|
||||
| same-file | 1.0.6 | Unlicense/MIT | https://github.com/BurntSushi/same-file |
|
||||
| schannel | 0.1.29 | MIT | https://github.com/steffengy/schannel-rs |
|
||||
| scoped-tls | 1.0.1 | MIT/Apache-2.0 | https://github.com/alexcrichton/scoped-tls |
|
||||
| scopeguard | 1.2.0 | MIT OR Apache-2.0 | https://github.com/bluss/scopeguard |
|
||||
| scrypt | 0.11.0 | MIT OR Apache-2.0 | https://github.com/RustCrypto/password-hashes/tree/master/scrypt |
|
||||
| sct | 0.7.1 | Apache-2.0 OR ISC OR MIT | https://github.com/rustls/sct.rs |
|
||||
| sd-notify | 0.4.5 | MIT OR Apache-2.0 | https://github.com/lnicola/sd-notify |
|
||||
| secp256k1 | 0.29.1 | CC0-1.0 | https://github.com/rust-bitcoin/rust-secp256k1/ |
|
||||
| secp256k1-sys | 0.10.1 | CC0-1.0 | https://github.com/rust-bitcoin/rust-secp256k1/ |
|
||||
| security-framework | 3.7.0 | MIT OR Apache-2.0 | https://github.com/kornelski/rust-security-framework |
|
||||
| security-framework-sys | 2.17.0 | MIT OR Apache-2.0 | https://github.com/kornelski/rust-security-framework |
|
||||
| seize | 0.5.1 | MIT | https://github.com/ibraheemdev/seize |
|
||||
| self_cell | 1.2.2 | Apache-2.0 OR GPL-2.0-only | https://github.com/Voultapher/self_cell |
|
||||
| semver | 1.0.27 | MIT OR Apache-2.0 | https://github.com/dtolnay/semver |
|
||||
| send_wrapper | 0.6.0 | MIT/Apache-2.0 | https://github.com/thk1/send_wrapper |
|
||||
| serde | 1.0.228 | MIT OR Apache-2.0 | https://github.com/serde-rs/serde |
|
||||
| serde_bencode | 0.2.4 | MIT | https://github.com/toby/serde-bencode |
|
||||
| serde_bytes | 0.11.19 | MIT OR Apache-2.0 | https://github.com/serde-rs/bytes |
|
||||
| serde_core | 1.0.228 | MIT OR Apache-2.0 | https://github.com/serde-rs/serde |
|
||||
| serde_derive | 1.0.228 | MIT OR Apache-2.0 | https://github.com/serde-rs/serde |
|
||||
| serde_json | 1.0.149 | MIT OR Apache-2.0 | https://github.com/serde-rs/json |
|
||||
| serde_spanned | 0.6.9 | MIT OR Apache-2.0 | https://github.com/toml-rs/toml |
|
||||
| serde_urlencoded | 0.7.1 | MIT/Apache-2.0 | https://github.com/nox/serde_urlencoded |
|
||||
| serde_yaml | 0.9.34+deprecated | MIT OR Apache-2.0 | https://github.com/dtolnay/serde-yaml |
|
||||
| serdect | 0.4.3 | Apache-2.0 OR MIT | https://github.com/RustCrypto/formats |
|
||||
| serial2 | 0.2.34 | BSD-2-Clause OR Apache-2.0 | https://github.com/de-vri-es/serial2-rs |
|
||||
| serial2-tokio | 0.1.21 | BSD-2-Clause OR Apache-2.0 | https://github.com/de-vri-es/serial2-tokio-rs |
|
||||
| sha-1 | 0.10.1 | MIT OR Apache-2.0 | https://github.com/RustCrypto/hashes |
|
||||
| sha1 | 0.10.6 | MIT OR Apache-2.0 | https://github.com/RustCrypto/hashes |
|
||||
| sha1_smol | 1.0.1 | BSD-3-Clause | https://github.com/mitsuhiko/sha1-smol |
|
||||
| sha2 | 0.10.9 | MIT OR Apache-2.0 | https://github.com/RustCrypto/hashes |
|
||||
| sha2 | 0.11.0 | MIT OR Apache-2.0 | https://github.com/RustCrypto/hashes |
|
||||
| sharded-slab | 0.1.7 | MIT | https://github.com/hawkw/sharded-slab |
|
||||
| shlex | 1.3.0 | MIT OR Apache-2.0 | https://github.com/comex/rust-shlex |
|
||||
| signal-hook-registry | 1.4.8 | MIT OR Apache-2.0 | https://github.com/vorner/signal-hook |
|
||||
| signature | 2.2.0 | Apache-2.0 OR MIT | https://github.com/RustCrypto/traits/tree/master/signature |
|
||||
| signature | 3.0.0 | Apache-2.0 OR MIT | https://github.com/RustCrypto/traits |
|
||||
| simd-adler32 | 0.3.8 | MIT | https://github.com/mcountryman/simd-adler32 |
|
||||
| simd_cesu8 | 1.1.1 | Apache-2.0 OR MIT | https://github.com/seancroach/simd_cesu8 |
|
||||
| simdutf8 | 0.1.5 | MIT OR Apache-2.0 | https://github.com/rusticstuff/simdutf8 |
|
||||
| simple-dns | 0.11.3 | MIT | https://github.com/balliegojr/simple-dns |
|
||||
| siphasher | 1.0.3 | MIT/Apache-2.0 | https://github.com/jedisct1/rust-siphash |
|
||||
| slab | 0.4.11 | MIT | https://github.com/tokio-rs/slab |
|
||||
| smallvec | 1.15.1 | MIT OR Apache-2.0 | https://github.com/servo/rust-smallvec |
|
||||
| socket-pktinfo | 0.3.2 | MIT | https://github.com/pixsper/socket-pktinfo |
|
||||
| socket2 | 0.5.10 | MIT OR Apache-2.0 | https://github.com/rust-lang/socket2 |
|
||||
| socket2 | 0.6.2 | MIT OR Apache-2.0 | https://github.com/rust-lang/socket2 |
|
||||
| sorted-index-buffer | 0.2.1 | MIT OR Apache-2.0 | https://github.com/n0-computer/iroh |
|
||||
| spez | 0.1.2 | BSD-2-Clause | https://github.com/m-ou-se/spez |
|
||||
| spin | 0.10.0 | MIT | https://github.com/mvdnes/spin-rs.git |
|
||||
| spin | 0.9.8 | MIT | https://github.com/mvdnes/spin-rs.git |
|
||||
| spki | 0.7.3 | Apache-2.0 OR MIT | https://github.com/RustCrypto/formats/tree/master/spki |
|
||||
| spki | 0.8.0 | Apache-2.0 OR MIT | https://github.com/RustCrypto/formats |
|
||||
| ssh2 | 0.9.5 | MIT OR Apache-2.0 | https://github.com/alexcrichton/ssh2-rs |
|
||||
| stable_deref_trait | 1.2.1 | MIT OR Apache-2.0 | https://github.com/storyyeller/stable_deref_trait |
|
||||
| strsim | 0.11.1 | MIT | https://github.com/rapidfuzz/strsim-rs |
|
||||
| strum | 0.28.0 | MIT | https://github.com/Peternator7/strum |
|
||||
| strum_macros | 0.28.0 | MIT | https://github.com/Peternator7/strum |
|
||||
| subtle | 2.6.1 | BSD-3-Clause | https://github.com/dalek-cryptography/subtle |
|
||||
| syn | 1.0.109 | MIT OR Apache-2.0 | https://github.com/dtolnay/syn |
|
||||
| syn | 2.0.114 | MIT OR Apache-2.0 | https://github.com/dtolnay/syn |
|
||||
| syn-mid | 0.5.4 | Apache-2.0 OR MIT | https://github.com/taiki-e/syn-mid |
|
||||
| sync_wrapper | 0.1.2 | Apache-2.0 | https://github.com/Actyx/sync_wrapper |
|
||||
| sync_wrapper | 1.0.2 | Apache-2.0 | https://github.com/Actyx/sync_wrapper |
|
||||
| synstructure | 0.13.2 | MIT | https://github.com/mystor/synstructure |
|
||||
| system-configuration | 0.5.1 | MIT OR Apache-2.0 | https://github.com/mullvad/system-configuration-rs |
|
||||
| system-configuration | 0.6.1 | MIT OR Apache-2.0 | https://github.com/mullvad/system-configuration-rs |
|
||||
| system-configuration | 0.7.0 | MIT OR Apache-2.0 | https://github.com/mullvad/system-configuration-rs |
|
||||
| system-configuration-sys | 0.5.0 | MIT OR Apache-2.0 | https://github.com/mullvad/system-configuration-rs |
|
||||
| system-configuration-sys | 0.6.0 | MIT OR Apache-2.0 | https://github.com/mullvad/system-configuration-rs |
|
||||
| tagptr | 0.2.0 | MIT/Apache-2.0 | https://github.com/oliver-giersch/tagptr.git |
|
||||
| tar | 0.4.44 | MIT OR Apache-2.0 | https://github.com/alexcrichton/tar-rs |
|
||||
| tempfile | 3.24.0 | MIT OR Apache-2.0 | https://github.com/Stebalien/tempfile |
|
||||
| termcolor | 1.4.1 | Unlicense OR MIT | https://github.com/BurntSushi/termcolor |
|
||||
| thiserror | 1.0.69 | MIT OR Apache-2.0 | https://github.com/dtolnay/thiserror |
|
||||
| thiserror | 2.0.18 | MIT OR Apache-2.0 | https://github.com/dtolnay/thiserror |
|
||||
| thiserror-impl | 1.0.69 | MIT OR Apache-2.0 | https://github.com/dtolnay/thiserror |
|
||||
| thiserror-impl | 2.0.18 | MIT OR Apache-2.0 | https://github.com/dtolnay/thiserror |
|
||||
| thread_local | 1.1.9 | MIT OR Apache-2.0 | https://github.com/Amanieu/thread_local-rs |
|
||||
| time | 0.3.49 | MIT OR Apache-2.0 | https://github.com/time-rs/time |
|
||||
| time-core | 0.1.9 | MIT OR Apache-2.0 | https://github.com/time-rs/time |
|
||||
| time-macros | 0.2.29 | MIT OR Apache-2.0 | https://github.com/time-rs/time |
|
||||
| tinystr | 0.8.2 | Unicode-3.0 | https://github.com/unicode-org/icu4x |
|
||||
| tinyvec | 1.10.0 | Zlib OR Apache-2.0 OR MIT | https://github.com/Lokathor/tinyvec |
|
||||
| tinyvec_macros | 0.1.1 | MIT OR Apache-2.0 OR Zlib | https://github.com/Soveu/tinyvec_macros |
|
||||
| tokio | 1.49.0 | MIT | https://github.com/tokio-rs/tokio |
|
||||
| tokio-macros | 2.6.0 | MIT | https://github.com/tokio-rs/tokio |
|
||||
| tokio-rustls | 0.24.1 | MIT/Apache-2.0 | https://github.com/rustls/tokio-rustls |
|
||||
| tokio-rustls | 0.26.4 | MIT OR Apache-2.0 | https://github.com/rustls/tokio-rustls |
|
||||
| tokio-socks | 0.5.2 | MIT | https://github.com/sticnarf/tokio-socks |
|
||||
| tokio-stream | 0.1.18 | MIT | https://github.com/tokio-rs/tokio |
|
||||
| tokio-test | 0.4.5 | MIT | https://github.com/tokio-rs/tokio |
|
||||
| tokio-tungstenite | 0.20.1 | MIT | https://github.com/snapview/tokio-tungstenite |
|
||||
| tokio-tungstenite | 0.26.2 | MIT | https://github.com/snapview/tokio-tungstenite |
|
||||
| tokio-util | 0.7.18 | MIT | https://github.com/tokio-rs/tokio |
|
||||
| tokio-websockets | 0.13.2 | MIT | https://github.com/Gelbpunkt/tokio-websockets/ |
|
||||
| toml | 0.8.23 | MIT OR Apache-2.0 | https://github.com/toml-rs/toml |
|
||||
| toml_datetime | 0.6.11 | MIT OR Apache-2.0 | https://github.com/toml-rs/toml |
|
||||
| toml_datetime | 1.1.1+spec-1.1.0 | MIT OR Apache-2.0 | https://github.com/toml-rs/toml |
|
||||
| toml_edit | 0.22.27 | MIT OR Apache-2.0 | https://github.com/toml-rs/toml |
|
||||
| toml_edit | 0.25.12+spec-1.1.0 | MIT OR Apache-2.0 | https://github.com/toml-rs/toml |
|
||||
| toml_parser | 1.1.2+spec-1.1.0 | MIT OR Apache-2.0 | https://github.com/toml-rs/toml |
|
||||
| toml_write | 0.1.2 | MIT OR Apache-2.0 | https://github.com/toml-rs/toml |
|
||||
| totp-rs | 5.7.0 | MIT | https://github.com/constantoine/totp-rs |
|
||||
| tower | 0.5.3 | MIT | https://github.com/tower-rs/tower |
|
||||
| tower-http | 0.6.8 | MIT | https://github.com/tower-rs/tower-http |
|
||||
| tower-layer | 0.3.3 | MIT | https://github.com/tower-rs/tower |
|
||||
| tower-service | 0.3.3 | MIT | https://github.com/tower-rs/tower |
|
||||
| tracing | 0.1.44 | MIT | https://github.com/tokio-rs/tracing |
|
||||
| tracing-attributes | 0.1.31 | MIT | https://github.com/tokio-rs/tracing |
|
||||
| tracing-core | 0.1.36 | MIT | https://github.com/tokio-rs/tracing |
|
||||
| tracing-log | 0.2.0 | MIT | https://github.com/tokio-rs/tracing |
|
||||
| tracing-subscriber | 0.3.22 | MIT | https://github.com/tokio-rs/tracing |
|
||||
| try-lock | 0.2.5 | MIT | https://github.com/seanmonstar/try-lock |
|
||||
| tungstenite | 0.20.1 | MIT OR Apache-2.0 | https://github.com/snapview/tungstenite-rs |
|
||||
| tungstenite | 0.26.2 | MIT OR Apache-2.0 | https://github.com/snapview/tungstenite-rs |
|
||||
| typenum | 1.20.1 | MIT OR Apache-2.0 | https://github.com/paholg/typenum |
|
||||
| unicode-ident | 1.0.22 | (MIT OR Apache-2.0) AND Unicode-3.0 | https://github.com/dtolnay/unicode-ident |
|
||||
| unicode-normalization | 0.1.22 | MIT/Apache-2.0 | https://github.com/unicode-rs/unicode-normalization |
|
||||
| unicode-segmentation | 1.13.3 | MIT OR Apache-2.0 | https://github.com/unicode-rs/unicode-segmentation |
|
||||
| unicode-xid | 0.2.6 | MIT OR Apache-2.0 | https://github.com/unicode-rs/unicode-xid |
|
||||
| universal-hash | 0.5.1 | MIT OR Apache-2.0 | https://github.com/RustCrypto/traits |
|
||||
| unsafe-libyaml | 0.2.11 | MIT | https://github.com/dtolnay/unsafe-libyaml |
|
||||
| untrusted | 0.9.0 | ISC | https://github.com/briansmith/untrusted |
|
||||
| url | 2.5.8 | MIT OR Apache-2.0 | https://github.com/servo/rust-url |
|
||||
| urlencoding | 2.1.3 | MIT | https://github.com/kornelski/rust_urlencoding |
|
||||
| utf-8 | 0.7.6 | MIT OR Apache-2.0 | https://github.com/SimonSapin/rust-utf8 |
|
||||
| utf8_iter | 1.0.4 | Apache-2.0 OR MIT | https://github.com/hsivonen/utf8_iter |
|
||||
| uuid | 1.19.0 | Apache-2.0 OR MIT | https://github.com/uuid-rs/uuid |
|
||||
| valuable | 0.1.1 | MIT | https://github.com/tokio-rs/valuable |
|
||||
| vcpkg | 0.2.15 | MIT/Apache-2.0 | https://github.com/mcgoo/vcpkg-rs |
|
||||
| vergen | 9.1.0 | MIT OR Apache-2.0 | https://github.com/rustyhorde/vergen |
|
||||
| vergen-gitcl | 9.1.0 | MIT OR Apache-2.0 | https://github.com/rustyhorde/vergen |
|
||||
| vergen-lib | 9.1.0 | MIT OR Apache-2.0 | https://github.com/rustyhorde/vergen |
|
||||
| version_check | 0.9.5 | MIT/Apache-2.0 | https://github.com/SergioBenitez/version_check |
|
||||
| walkdir | 2.5.0 | Unlicense/MIT | https://github.com/BurntSushi/walkdir |
|
||||
| want | 0.3.1 | MIT | https://github.com/seanmonstar/want |
|
||||
| wasi | 0.11.1+wasi-snapshot-preview1 | Apache-2.0 WITH LLVM-exception OR Apache-2.0 OR MIT | https://github.com/bytecodealliance/wasi |
|
||||
| wasip2 | 1.0.2+wasi-0.2.9 | Apache-2.0 WITH LLVM-exception OR Apache-2.0 OR MIT | https://github.com/bytecodealliance/wasi-rs |
|
||||
| wasip3 | 0.4.0+wasi-0.3.0-rc-2026-01-06 | Apache-2.0 WITH LLVM-exception OR Apache-2.0 OR MIT | https://github.com/bytecodealliance/wasi-rs |
|
||||
| wasm-bindgen | 0.2.108 | MIT OR Apache-2.0 | https://github.com/wasm-bindgen/wasm-bindgen |
|
||||
| wasm-bindgen-futures | 0.4.58 | MIT OR Apache-2.0 | https://github.com/wasm-bindgen/wasm-bindgen/tree/master/crates/futures |
|
||||
| wasm-bindgen-macro | 0.2.108 | MIT OR Apache-2.0 | https://github.com/wasm-bindgen/wasm-bindgen/tree/master/crates/macro |
|
||||
| wasm-bindgen-macro-support | 0.2.108 | MIT OR Apache-2.0 | https://github.com/wasm-bindgen/wasm-bindgen/tree/master/crates/macro-support |
|
||||
| wasm-bindgen-shared | 0.2.108 | MIT OR Apache-2.0 | https://github.com/wasm-bindgen/wasm-bindgen/tree/master/crates/shared |
|
||||
| wasm-encoder | 0.244.0 | Apache-2.0 WITH LLVM-exception OR Apache-2.0 OR MIT | https://github.com/bytecodealliance/wasm-tools/tree/main/crates/wasm-encoder |
|
||||
| wasm-metadata | 0.244.0 | Apache-2.0 WITH LLVM-exception OR Apache-2.0 OR MIT | https://github.com/bytecodealliance/wasm-tools/tree/main/crates/wasm-metadata |
|
||||
| wasm-streams | 0.4.2 | MIT OR Apache-2.0 | https://github.com/MattiasBuelens/wasm-streams/ |
|
||||
| wasm-streams | 0.5.0 | MIT OR Apache-2.0 | https://github.com/MattiasBuelens/wasm-streams/ |
|
||||
| wasmparser | 0.244.0 | Apache-2.0 WITH LLVM-exception OR Apache-2.0 OR MIT | https://github.com/bytecodealliance/wasm-tools/tree/main/crates/wasmparser |
|
||||
| web-sys | 0.3.85 | MIT OR Apache-2.0 | https://github.com/wasm-bindgen/wasm-bindgen/tree/master/crates/web-sys |
|
||||
| web-time | 1.1.0 | MIT OR Apache-2.0 | https://github.com/daxpedda/web-time |
|
||||
| webpki-root-certs | 1.0.7 | CDLA-Permissive-2.0 | https://github.com/rustls/webpki-roots |
|
||||
| webpki-roots | 0.25.4 | MPL-2.0 | https://github.com/rustls/webpki-roots |
|
||||
| webpki-roots | 0.26.11 | CDLA-Permissive-2.0 | https://github.com/rustls/webpki-roots |
|
||||
| webpki-roots | 1.0.6 | CDLA-Permissive-2.0 | https://github.com/rustls/webpki-roots |
|
||||
| widestring | 1.2.1 | MIT OR Apache-2.0 | https://github.com/VoidStarKat/widestring-rs |
|
||||
| winapi | 0.3.9 | MIT/Apache-2.0 | https://github.com/retep998/winapi-rs |
|
||||
| winapi-i686-pc-windows-gnu | 0.4.0 | MIT/Apache-2.0 | https://github.com/retep998/winapi-rs |
|
||||
| winapi-util | 0.1.11 | Unlicense OR MIT | https://github.com/BurntSushi/winapi-util |
|
||||
| winapi-x86_64-pc-windows-gnu | 0.4.0 | MIT/Apache-2.0 | https://github.com/retep998/winapi-rs |
|
||||
| windows | 0.62.2 | MIT OR Apache-2.0 | https://github.com/microsoft/windows-rs |
|
||||
| windows-collections | 0.3.2 | MIT OR Apache-2.0 | https://github.com/microsoft/windows-rs |
|
||||
| windows-core | 0.62.2 | MIT OR Apache-2.0 | https://github.com/microsoft/windows-rs |
|
||||
| windows-future | 0.3.2 | MIT OR Apache-2.0 | https://github.com/microsoft/windows-rs |
|
||||
| windows-implement | 0.60.2 | MIT OR Apache-2.0 | https://github.com/microsoft/windows-rs |
|
||||
| windows-interface | 0.59.3 | MIT OR Apache-2.0 | https://github.com/microsoft/windows-rs |
|
||||
| windows-link | 0.2.1 | MIT OR Apache-2.0 | https://github.com/microsoft/windows-rs |
|
||||
| windows-numerics | 0.3.1 | MIT OR Apache-2.0 | https://github.com/microsoft/windows-rs |
|
||||
| windows-registry | 0.6.1 | MIT OR Apache-2.0 | https://github.com/microsoft/windows-rs |
|
||||
| windows-result | 0.4.1 | MIT OR Apache-2.0 | https://github.com/microsoft/windows-rs |
|
||||
| windows-strings | 0.5.1 | MIT OR Apache-2.0 | https://github.com/microsoft/windows-rs |
|
||||
| windows-sys | 0.45.0 | MIT OR Apache-2.0 | https://github.com/microsoft/windows-rs |
|
||||
| windows-sys | 0.48.0 | MIT OR Apache-2.0 | https://github.com/microsoft/windows-rs |
|
||||
| windows-sys | 0.52.0 | MIT OR Apache-2.0 | https://github.com/microsoft/windows-rs |
|
||||
| windows-sys | 0.60.2 | MIT OR Apache-2.0 | https://github.com/microsoft/windows-rs |
|
||||
| windows-sys | 0.61.2 | MIT OR Apache-2.0 | https://github.com/microsoft/windows-rs |
|
||||
| windows-targets | 0.42.2 | MIT OR Apache-2.0 | https://github.com/microsoft/windows-rs |
|
||||
| windows-targets | 0.48.5 | MIT OR Apache-2.0 | https://github.com/microsoft/windows-rs |
|
||||
| windows-targets | 0.52.6 | MIT OR Apache-2.0 | https://github.com/microsoft/windows-rs |
|
||||
| windows-targets | 0.53.5 | MIT OR Apache-2.0 | https://github.com/microsoft/windows-rs |
|
||||
| windows-threading | 0.2.1 | MIT OR Apache-2.0 | https://github.com/microsoft/windows-rs |
|
||||
| windows_aarch64_gnullvm | 0.42.2 | MIT OR Apache-2.0 | https://github.com/microsoft/windows-rs |
|
||||
| windows_aarch64_gnullvm | 0.48.5 | MIT OR Apache-2.0 | https://github.com/microsoft/windows-rs |
|
||||
| windows_aarch64_gnullvm | 0.52.6 | MIT OR Apache-2.0 | https://github.com/microsoft/windows-rs |
|
||||
| windows_aarch64_gnullvm | 0.53.1 | MIT OR Apache-2.0 | https://github.com/microsoft/windows-rs |
|
||||
| windows_aarch64_msvc | 0.42.2 | MIT OR Apache-2.0 | https://github.com/microsoft/windows-rs |
|
||||
| windows_aarch64_msvc | 0.48.5 | MIT OR Apache-2.0 | https://github.com/microsoft/windows-rs |
|
||||
| windows_aarch64_msvc | 0.52.6 | MIT OR Apache-2.0 | https://github.com/microsoft/windows-rs |
|
||||
| windows_aarch64_msvc | 0.53.1 | MIT OR Apache-2.0 | https://github.com/microsoft/windows-rs |
|
||||
| windows_i686_gnu | 0.42.2 | MIT OR Apache-2.0 | https://github.com/microsoft/windows-rs |
|
||||
| windows_i686_gnu | 0.48.5 | MIT OR Apache-2.0 | https://github.com/microsoft/windows-rs |
|
||||
| windows_i686_gnu | 0.52.6 | MIT OR Apache-2.0 | https://github.com/microsoft/windows-rs |
|
||||
| windows_i686_gnu | 0.53.1 | MIT OR Apache-2.0 | https://github.com/microsoft/windows-rs |
|
||||
| windows_i686_gnullvm | 0.52.6 | MIT OR Apache-2.0 | https://github.com/microsoft/windows-rs |
|
||||
| windows_i686_gnullvm | 0.53.1 | MIT OR Apache-2.0 | https://github.com/microsoft/windows-rs |
|
||||
| windows_i686_msvc | 0.42.2 | MIT OR Apache-2.0 | https://github.com/microsoft/windows-rs |
|
||||
| windows_i686_msvc | 0.48.5 | MIT OR Apache-2.0 | https://github.com/microsoft/windows-rs |
|
||||
| windows_i686_msvc | 0.52.6 | MIT OR Apache-2.0 | https://github.com/microsoft/windows-rs |
|
||||
| windows_i686_msvc | 0.53.1 | MIT OR Apache-2.0 | https://github.com/microsoft/windows-rs |
|
||||
| windows_x86_64_gnu | 0.42.2 | MIT OR Apache-2.0 | https://github.com/microsoft/windows-rs |
|
||||
| windows_x86_64_gnu | 0.48.5 | MIT OR Apache-2.0 | https://github.com/microsoft/windows-rs |
|
||||
| windows_x86_64_gnu | 0.52.6 | MIT OR Apache-2.0 | https://github.com/microsoft/windows-rs |
|
||||
| windows_x86_64_gnu | 0.53.1 | MIT OR Apache-2.0 | https://github.com/microsoft/windows-rs |
|
||||
| windows_x86_64_gnullvm | 0.42.2 | MIT OR Apache-2.0 | https://github.com/microsoft/windows-rs |
|
||||
| windows_x86_64_gnullvm | 0.48.5 | MIT OR Apache-2.0 | https://github.com/microsoft/windows-rs |
|
||||
| windows_x86_64_gnullvm | 0.52.6 | MIT OR Apache-2.0 | https://github.com/microsoft/windows-rs |
|
||||
| windows_x86_64_gnullvm | 0.53.1 | MIT OR Apache-2.0 | https://github.com/microsoft/windows-rs |
|
||||
| windows_x86_64_msvc | 0.42.2 | MIT OR Apache-2.0 | https://github.com/microsoft/windows-rs |
|
||||
| windows_x86_64_msvc | 0.48.5 | MIT OR Apache-2.0 | https://github.com/microsoft/windows-rs |
|
||||
| windows_x86_64_msvc | 0.52.6 | MIT OR Apache-2.0 | https://github.com/microsoft/windows-rs |
|
||||
| windows_x86_64_msvc | 0.53.1 | MIT OR Apache-2.0 | https://github.com/microsoft/windows-rs |
|
||||
| winnow | 0.7.14 | MIT | https://github.com/winnow-rs/winnow |
|
||||
| winnow | 1.0.3 | MIT | https://github.com/winnow-rs/winnow |
|
||||
| winreg | 0.50.0 | MIT | https://github.com/gentoo90/winreg-rs |
|
||||
| wit-bindgen | 0.51.0 | Apache-2.0 WITH LLVM-exception OR Apache-2.0 OR MIT | https://github.com/bytecodealliance/wit-bindgen |
|
||||
| wit-bindgen-core | 0.51.0 | Apache-2.0 WITH LLVM-exception OR Apache-2.0 OR MIT | https://github.com/bytecodealliance/wit-bindgen |
|
||||
| wit-bindgen-rust | 0.51.0 | Apache-2.0 WITH LLVM-exception OR Apache-2.0 OR MIT | https://github.com/bytecodealliance/wit-bindgen |
|
||||
| wit-bindgen-rust-macro | 0.51.0 | Apache-2.0 WITH LLVM-exception OR Apache-2.0 OR MIT | https://github.com/bytecodealliance/wit-bindgen |
|
||||
| wit-component | 0.244.0 | Apache-2.0 WITH LLVM-exception OR Apache-2.0 OR MIT | https://github.com/bytecodealliance/wasm-tools/tree/main/crates/wit-component |
|
||||
| wit-parser | 0.244.0 | Apache-2.0 WITH LLVM-exception OR Apache-2.0 OR MIT | https://github.com/bytecodealliance/wasm-tools/tree/main/crates/wit-parser |
|
||||
| wmi | 0.18.4 | MIT OR Apache-2.0 | https://github.com/ohadravid/wmi-rs |
|
||||
| writeable | 0.6.2 | Unicode-3.0 | https://github.com/unicode-org/icu4x |
|
||||
| ws_stream_wasm | 0.7.5 | Unlicense | https://github.com/najamelan/ws_stream_wasm |
|
||||
| x509-parser | 0.18.1 | MIT OR Apache-2.0 | https://github.com/rusticata/x509-parser.git |
|
||||
| xattr | 1.6.1 | MIT OR Apache-2.0 | https://github.com/Stebalien/xattr |
|
||||
| xml-rs | 0.8.28 | MIT | https://github.com/kornelski/xml-rs |
|
||||
| xmltree | 0.10.3 | MIT | https://github.com/eminence/xmltree-rs |
|
||||
| yasna | 0.6.0 | MIT OR Apache-2.0 | https://github.com/qnighy/yasna.rs |
|
||||
| yoke | 0.8.1 | Unicode-3.0 | https://github.com/unicode-org/icu4x |
|
||||
| yoke-derive | 0.8.1 | Unicode-3.0 | https://github.com/unicode-org/icu4x |
|
||||
| zbase32 | 0.1.2 | LGPL-3.0+ | https://gitlab.com/pgerber/zbase32-rust |
|
||||
| zerocopy | 0.8.33 | BSD-2-Clause OR Apache-2.0 OR MIT | https://github.com/google/zerocopy |
|
||||
| zerocopy-derive | 0.8.33 | BSD-2-Clause OR Apache-2.0 OR MIT | https://github.com/google/zerocopy |
|
||||
| zerofrom | 0.1.6 | Unicode-3.0 | https://github.com/unicode-org/icu4x |
|
||||
| zerofrom-derive | 0.1.6 | Unicode-3.0 | https://github.com/unicode-org/icu4x |
|
||||
| zeroize | 1.9.0 | Apache-2.0 OR MIT | https://github.com/RustCrypto/utils |
|
||||
| zeroize_derive | 1.5.0 | Apache-2.0 OR MIT | https://github.com/RustCrypto/utils |
|
||||
| zerotrie | 0.2.3 | Unicode-3.0 | https://github.com/unicode-org/icu4x |
|
||||
| zerovec | 0.11.5 | Unicode-3.0 | https://github.com/unicode-org/icu4x |
|
||||
| zerovec-derive | 0.11.2 | Unicode-3.0 | https://github.com/unicode-org/icu4x |
|
||||
| zmij | 1.0.16 | MIT | https://github.com/dtolnay/zmij |
|
||||
@@ -1,6 +1,6 @@
|
||||
[package]
|
||||
name = "archipelago"
|
||||
version = "1.7.111-alpha"
|
||||
version = "1.7.116-alpha"
|
||||
edition = "2021"
|
||||
description = "Archipelago Bitcoin Node OS - Native backend"
|
||||
authors = ["Archipelago Team"]
|
||||
@@ -22,6 +22,9 @@ iroh-swarm = ["dep:iroh", "dep:iroh-blobs"]
|
||||
[dependencies]
|
||||
# Core dependencies
|
||||
tokio = { version = "1", features = ["full"] }
|
||||
# Mesh port mirror: needs IPV6_V6ONLY on [::] listeners so they coexist with
|
||||
# the containers' own 0.0.0.0 binds (std/tokio don't expose the sockopt).
|
||||
socket2 = "0.5"
|
||||
libc = "0.2" # process-group signalling for the supervised reticulum daemon
|
||||
serde = { version = "1.0", features = ["derive"] }
|
||||
serde_json = "1.0"
|
||||
@@ -105,6 +108,10 @@ bytes = "1"
|
||||
# Mesh networking (Meshcore serial protocol over USB LoRa radios)
|
||||
serial2-tokio = "0.1"
|
||||
|
||||
# LoRa radio firmware flashing: Meshtastic ships per-board images inside a
|
||||
# per-platform release zip (see mesh/flash.rs).
|
||||
zip = { version = "2", default-features = false, features = ["deflate"] }
|
||||
|
||||
# Double Ratchet key derivation (Phase 3: encrypted mesh messaging)
|
||||
hkdf = "0.12.4"
|
||||
|
||||
|
||||
@@ -188,7 +188,7 @@ impl ApiHandler {
|
||||
}
|
||||
};
|
||||
let price_sats = match &item.access {
|
||||
content_server::AccessControl::Paid { price_sats } => *price_sats,
|
||||
content_server::AccessControl::Paid { price_sats, .. } => *price_sats,
|
||||
_ => {
|
||||
// Not a paid item — no invoice to issue.
|
||||
return Ok(build_response(
|
||||
@@ -198,6 +198,15 @@ impl ApiHandler {
|
||||
));
|
||||
}
|
||||
};
|
||||
if !content_server::method_accepted(&item.access, "lightning") {
|
||||
return Ok(build_response(
|
||||
StatusCode::BAD_REQUEST,
|
||||
"application/json",
|
||||
hyper::Body::from(
|
||||
r#"{"error":"The seller does not accept Lightning for this item"}"#,
|
||||
),
|
||||
));
|
||||
}
|
||||
|
||||
let memo = format!("Archipelago peer file {content_id}");
|
||||
match self
|
||||
@@ -315,7 +324,18 @@ impl ApiHandler {
|
||||
.unwrap_or_default();
|
||||
let price_sats = match catalog.items.iter().find(|i| i.id == content_id) {
|
||||
Some(i) => match &i.access {
|
||||
content_server::AccessControl::Paid { price_sats } => *price_sats,
|
||||
content_server::AccessControl::Paid { price_sats, .. } => {
|
||||
if !content_server::method_accepted(&i.access, "onchain") {
|
||||
return Ok(build_response(
|
||||
StatusCode::BAD_REQUEST,
|
||||
"application/json",
|
||||
hyper::Body::from(
|
||||
r#"{"error":"The seller does not accept on-chain payment for this item"}"#,
|
||||
),
|
||||
));
|
||||
}
|
||||
*price_sats
|
||||
}
|
||||
_ => {
|
||||
return Ok(build_response(
|
||||
StatusCode::BAD_REQUEST,
|
||||
|
||||
@@ -9,6 +9,23 @@ impl RpcHandler {
|
||||
params: Option<serde_json::Value>,
|
||||
) -> Result<serde_json::Value> {
|
||||
let params = params.ok_or_else(|| anyhow::anyhow!("Missing params"))?;
|
||||
|
||||
// Companion device-token login: minted via auth.createDeviceToken and
|
||||
// carried by the pairing QR. Verified here so it shares the login rate
|
||||
// limiter with password attempts.
|
||||
if let Some(token) = params.get("token").and_then(|v| v.as_str()) {
|
||||
return match crate::device_tokens::verify(&self.config.data_dir, token).await {
|
||||
Some(device) => {
|
||||
tracing::info!("[onboarding] device-token login ({device})");
|
||||
Ok(serde_json::Value::Null)
|
||||
}
|
||||
None => {
|
||||
tracing::warn!("[onboarding] device-token login failed");
|
||||
Err(anyhow::anyhow!("Invalid device token"))
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
let password = params
|
||||
.get("password")
|
||||
.and_then(|v| v.as_str())
|
||||
@@ -32,6 +49,15 @@ impl RpcHandler {
|
||||
|
||||
let valid = self.auth_manager.verify_password(password).await?;
|
||||
if !valid {
|
||||
// The companion app sends its device token through the password
|
||||
// field (it reuses the whole password auto-login path, including
|
||||
// the WebView form). Accept a valid token here so that path works.
|
||||
if let Some(device) =
|
||||
crate::device_tokens::verify(&self.config.data_dir, password).await
|
||||
{
|
||||
tracing::info!("[onboarding] device-token login via password field ({device})");
|
||||
return Ok(serde_json::Value::Null);
|
||||
}
|
||||
tracing::warn!("[onboarding] login failed — wrong password");
|
||||
return Err(anyhow::anyhow!("Password Incorrect"));
|
||||
}
|
||||
@@ -73,6 +99,56 @@ impl RpcHandler {
|
||||
Ok(serde_json::Value::Null)
|
||||
}
|
||||
|
||||
/// Mint a device token for the companion pairing QR. Session-gated by the
|
||||
/// dispatcher (not in UNAUTHENTICATED_METHODS), so only a logged-in web UI
|
||||
/// can mint one. The plaintext token is returned exactly once.
|
||||
pub(super) async fn handle_auth_create_device_token(
|
||||
&self,
|
||||
params: Option<serde_json::Value>,
|
||||
) -> Result<serde_json::Value> {
|
||||
let mut name = params
|
||||
.as_ref()
|
||||
.and_then(|p| p.get("name"))
|
||||
.and_then(|v| v.as_str())
|
||||
.unwrap_or("companion")
|
||||
.trim()
|
||||
.to_string();
|
||||
if name.is_empty() || name.len() > 64 {
|
||||
return Err(anyhow::anyhow!("Device name must be 1-64 characters"));
|
||||
}
|
||||
// The default name was a single shared slot: every pairing popup
|
||||
// replaced the previous phone's token, silently logging out the
|
||||
// first phone the moment a second one paired. Default-named mints
|
||||
// get a unique suffix so each device keeps its own credential;
|
||||
// explicitly named devices keep replace-in-place semantics.
|
||||
if name == "companion" {
|
||||
name = format!("companion-{}", hex::encode(rand::random::<[u8; 2]>()));
|
||||
}
|
||||
let token = crate::device_tokens::create(&self.config.data_dir, &name).await?;
|
||||
Ok(serde_json::json!({ "name": name, "token": token }))
|
||||
}
|
||||
|
||||
pub(super) async fn handle_auth_list_device_tokens(&self) -> Result<serde_json::Value> {
|
||||
let tokens = crate::device_tokens::list(&self.config.data_dir).await;
|
||||
Ok(serde_json::json!(tokens
|
||||
.iter()
|
||||
.map(|t| serde_json::json!({ "name": t.name, "created": t.created }))
|
||||
.collect::<Vec<_>>()))
|
||||
}
|
||||
|
||||
pub(super) async fn handle_auth_revoke_device_token(
|
||||
&self,
|
||||
params: Option<serde_json::Value>,
|
||||
) -> Result<serde_json::Value> {
|
||||
let name = params
|
||||
.as_ref()
|
||||
.and_then(|p| p.get("name"))
|
||||
.and_then(|v| v.as_str())
|
||||
.ok_or_else(|| anyhow::anyhow!("Missing name"))?;
|
||||
let removed = crate::device_tokens::remove(&self.config.data_dir, name).await?;
|
||||
Ok(serde_json::json!({ "removed": removed }))
|
||||
}
|
||||
|
||||
pub(super) async fn handle_auth_logout(&self) -> Result<serde_json::Value> {
|
||||
tracing::info!("[onboarding] logout");
|
||||
Ok(serde_json::Value::Null)
|
||||
|
||||
@@ -465,6 +465,7 @@ impl RpcHandler {
|
||||
signing_key.as_ref().map(|i| i.signing_key()),
|
||||
Some(&peer.pubkey),
|
||||
data.server_info.name.as_deref(),
|
||||
Some(&self.config.data_dir),
|
||||
)
|
||||
.await
|
||||
}
|
||||
|
||||
@@ -179,7 +179,24 @@ impl RpcHandler {
|
||||
if price == 0 {
|
||||
return Err(anyhow::anyhow!("Paid content requires price_sats > 0"));
|
||||
}
|
||||
AccessControl::Paid { price_sats: price }
|
||||
// Optional list of payment methods the sharer accepts.
|
||||
// Absent/empty = all methods (backward compatible).
|
||||
const KNOWN_METHODS: [&str; 4] = ["lightning", "onchain", "ecash", "fedimint"];
|
||||
let accepted: Vec<String> = params
|
||||
.get("accepted_methods")
|
||||
.and_then(|v| v.as_array())
|
||||
.map(|arr| {
|
||||
arr.iter()
|
||||
.filter_map(|m| m.as_str())
|
||||
.filter(|m| KNOWN_METHODS.contains(m))
|
||||
.map(str::to_string)
|
||||
.collect()
|
||||
})
|
||||
.unwrap_or_default();
|
||||
AccessControl::Paid {
|
||||
price_sats: price,
|
||||
accepted,
|
||||
}
|
||||
}
|
||||
_ => return Err(anyhow::anyhow!("Invalid access type: {}", access_type)),
|
||||
};
|
||||
@@ -262,6 +279,7 @@ impl RpcHandler {
|
||||
.service(crate::settings::transport::PeerService::PeerFiles)
|
||||
.header("X-Federation-DID", local_did)
|
||||
.timeout(std::time::Duration::from_secs(120))
|
||||
.fips_timeout(std::time::Duration::from_secs(8))
|
||||
.send_get()
|
||||
.await
|
||||
.context("Failed to connect to peer")?;
|
||||
@@ -347,6 +365,11 @@ impl RpcHandler {
|
||||
crate::fips::dial::PeerRequest::new(fips_npub.as_deref(), onion, "/content")
|
||||
.service(crate::settings::transport::PeerService::PeerFiles)
|
||||
.timeout(std::time::Duration::from_secs(30))
|
||||
// The Cloud page's hottest call: without a fast-fail cap a
|
||||
// cold FIPS path burned ~16.6s before Tor even started,
|
||||
// against the UI's 30s deadline — users saw errors, not
|
||||
// fallback.
|
||||
.fips_timeout(std::time::Duration::from_secs(6))
|
||||
.send_get()
|
||||
.await
|
||||
.context("Failed to connect to peer")?;
|
||||
@@ -412,6 +435,51 @@ impl RpcHandler {
|
||||
return Err(anyhow::anyhow!("Invalid v3 onion address"));
|
||||
}
|
||||
|
||||
// NEVER pay twice for content we already own (2026-07-22: a file
|
||||
// shared twice produced two catalog ids for the same bytes and the
|
||||
// buyer paid both). Guard BEFORE any ecash is minted, matching both
|
||||
// by exact (onion, content_id) and by (onion, filename) — the latter
|
||||
// catches duplicate ids pointing at the same file on the same
|
||||
// seller. The owned copy is served from the local cache instead.
|
||||
{
|
||||
let filename = params.get("filename").and_then(|v| v.as_str());
|
||||
let owned = crate::content_owned::list_owned(&self.config.data_dir).await;
|
||||
let already = owned.iter().find(|o| {
|
||||
o.onion == onion
|
||||
&& (o.content_id == content_id
|
||||
|| filename.is_some_and(|f| {
|
||||
!f.is_empty()
|
||||
&& o.filename.trim_start_matches('/') == f.trim_start_matches('/')
|
||||
}))
|
||||
});
|
||||
if let Some(o) = already {
|
||||
tracing::info!(
|
||||
onion,
|
||||
content_id,
|
||||
owned_as = %o.content_id,
|
||||
"paid download: already owned — serving cached copy, NOT paying again"
|
||||
);
|
||||
if let Some((mime, bytes)) =
|
||||
crate::content_owned::read_owned(&self.config.data_dir, &o.onion, &o.content_id)
|
||||
.await
|
||||
{
|
||||
use base64::Engine;
|
||||
return Ok(serde_json::json!({
|
||||
"owned": true,
|
||||
"already_owned": true,
|
||||
"filename": o.filename,
|
||||
"mime_type": mime,
|
||||
"size_bytes": bytes.len(),
|
||||
"paid_sats": 0,
|
||||
"data_base64":
|
||||
base64::engine::general_purpose::STANDARD.encode(&bytes),
|
||||
}));
|
||||
}
|
||||
// Cache record exists but bytes are gone — fall through and
|
||||
// repurchase rather than stranding the user.
|
||||
}
|
||||
}
|
||||
|
||||
// `method` pins the backend the user confirmed in the UI ("cashu" |
|
||||
// "fedimint"); absent = auto (Cashu first, then Fedimint). The seller's
|
||||
// verify_payment_token accepts either, so a node whose balance lives in
|
||||
@@ -590,6 +658,51 @@ impl RpcHandler {
|
||||
tracing::warn!("paid download: failed to cache purchased content (non-fatal): {e:#}");
|
||||
}
|
||||
|
||||
// Auto-file the purchase into the user's Files area (2026-07-22):
|
||||
// Photos for images/video, Music for audio, Documents otherwise —
|
||||
// same buckets the Cloud view uses. The in-app viewer still plays
|
||||
// from the purchase cache; this makes the file ALSO show up where
|
||||
// files live, on every device, without relying on a browser
|
||||
// download. Best-effort: never fail a paid download over it.
|
||||
{
|
||||
let folder = if mime_type.starts_with("image/") || mime_type.starts_with("video/") {
|
||||
"Photos"
|
||||
} else if mime_type.starts_with("audio/") {
|
||||
"Music"
|
||||
} else {
|
||||
"Documents"
|
||||
};
|
||||
let base = std::path::Path::new(&filename)
|
||||
.file_name()
|
||||
.and_then(|n| n.to_str())
|
||||
.unwrap_or("download")
|
||||
.to_string();
|
||||
let dir = self.config.data_dir.join("filebrowser").join(folder);
|
||||
if let Err(e) = tokio::fs::create_dir_all(&dir).await {
|
||||
tracing::warn!("paid download: cannot create {}: {e}", dir.display());
|
||||
} else {
|
||||
// Don't clobber an existing file of the same name: "x.jpg"
|
||||
// → "x (2).jpg" etc.
|
||||
let mut target = dir.join(&base);
|
||||
let (stem, ext) = match base.rsplit_once('.') {
|
||||
Some((s, e)) if !s.is_empty() => (s.to_string(), format!(".{e}")),
|
||||
_ => (base.clone(), String::new()),
|
||||
};
|
||||
let mut n = 2;
|
||||
while target.exists() {
|
||||
target = dir.join(format!("{stem} ({n}){ext}"));
|
||||
n += 1;
|
||||
}
|
||||
match tokio::fs::write(&target, &bytes).await {
|
||||
Ok(()) => tracing::info!("paid download: filed into {}", target.display()),
|
||||
Err(e) => tracing::warn!(
|
||||
"paid download: filing into {} failed (non-fatal): {e}",
|
||||
target.display()
|
||||
),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
use base64::Engine;
|
||||
let encoded = base64::engine::general_purpose::STANDARD.encode(&bytes);
|
||||
|
||||
@@ -1030,6 +1143,7 @@ impl RpcHandler {
|
||||
crate::fips::dial::PeerRequest::new(fips_npub.as_deref(), onion, &path)
|
||||
.service(crate::settings::transport::PeerService::PeerFiles)
|
||||
.timeout(std::time::Duration::from_secs(30))
|
||||
.fips_timeout(std::time::Duration::from_secs(6))
|
||||
.send_get()
|
||||
.await
|
||||
.context("Failed to connect to peer for preview")?;
|
||||
|
||||
@@ -26,6 +26,9 @@ impl RpcHandler {
|
||||
"auth.onboardingComplete" => self.handle_auth_onboarding_complete().await,
|
||||
"auth.isOnboardingComplete" => self.handle_auth_is_onboarding_complete().await,
|
||||
"auth.resetOnboarding" => self.handle_auth_reset_onboarding(params).await,
|
||||
"auth.createDeviceToken" => self.handle_auth_create_device_token(params).await,
|
||||
"auth.listDeviceTokens" => self.handle_auth_list_device_tokens().await,
|
||||
"auth.revokeDeviceToken" => self.handle_auth_revoke_device_token(params).await,
|
||||
|
||||
// Seed management (BIP-39 mnemonic)
|
||||
"seed.generate" => self.handle_seed_generate().await,
|
||||
@@ -121,12 +124,15 @@ impl RpcHandler {
|
||||
}
|
||||
"lnd.getinfo" => self.handle_lnd_getinfo().await,
|
||||
"lnd.listchannels" => self.handle_lnd_listchannels().await,
|
||||
"lnd.closedchannels" => self.handle_lnd_closedchannels().await,
|
||||
"lnd.openchannel" => self.handle_lnd_openchannel(params).await,
|
||||
"lnd.closechannel" => self.handle_lnd_closechannel(params).await,
|
||||
"lnd.newaddress" => self.handle_lnd_newaddress().await,
|
||||
"lnd.sendcoins" => self.handle_lnd_sendcoins(params).await,
|
||||
"lnd.estimatefee" => self.handle_lnd_estimatefee(params).await,
|
||||
"lnd.createinvoice" => self.handle_lnd_createinvoice(params).await,
|
||||
"lnd.payinvoice" => self.handle_lnd_payinvoice(params).await,
|
||||
"lnd.paymentstatus" => self.handle_lnd_paymentstatus(params).await,
|
||||
"lnd.create-psbt" => self.handle_lnd_create_psbt(params).await,
|
||||
"lnd.finalize-psbt" => self.handle_lnd_finalize_psbt(params).await,
|
||||
"lnd.create-raw-tx" => self.handle_lnd_create_raw_tx(params).await,
|
||||
@@ -257,6 +263,7 @@ impl RpcHandler {
|
||||
"wallet.fedimint-join" => self.handle_wallet_fedimint_join(params).await,
|
||||
"wallet.fedimint-leave" => self.handle_wallet_fedimint_leave(params).await,
|
||||
"wallet.fedimint-balance" => self.handle_wallet_fedimint_balance().await,
|
||||
"wallet.fedimint-send" => self.handle_wallet_fedimint_send(params).await,
|
||||
|
||||
// Ark protocol (via barkd sidecar)
|
||||
"wallet.ark-status" => self.handle_wallet_ark_status().await,
|
||||
@@ -383,7 +390,13 @@ impl RpcHandler {
|
||||
|
||||
// Mesh networking (Meshcore LoRa)
|
||||
"mesh.status" => self.handle_mesh_status().await,
|
||||
"mesh.probe-device" => self.handle_mesh_probe_device(params).await,
|
||||
"mesh.flash-list-firmware" => self.handle_mesh_flash_list_firmware(params).await,
|
||||
"mesh.flash-device" => self.handle_mesh_flash_device(params).await,
|
||||
"mesh.flash-status" => self.handle_mesh_flash_status().await,
|
||||
"mesh.flash-cancel" => self.handle_mesh_flash_cancel().await,
|
||||
"mesh.peers" => self.handle_mesh_peers().await,
|
||||
"mesh.refresh" => self.handle_mesh_refresh().await,
|
||||
"mesh.messages" => self.handle_mesh_messages(params).await,
|
||||
"mesh.debug-dump" => self.handle_mesh_debug_dump().await,
|
||||
"mesh.send" => self.handle_mesh_send(params).await,
|
||||
@@ -486,6 +499,7 @@ impl RpcHandler {
|
||||
|
||||
// FIPS mesh transport
|
||||
"fips.status" => self.handle_fips_status().await,
|
||||
"fips.pair-info" => self.handle_fips_pair_info().await,
|
||||
"fips.check-update" => self.handle_fips_check_update().await,
|
||||
"fips.apply-update" => self.handle_fips_apply_update().await,
|
||||
"fips.install" => self.handle_fips_install().await,
|
||||
|
||||
@@ -865,7 +865,9 @@ impl RpcHandler {
|
||||
"/rpc/v1",
|
||||
)
|
||||
.service(crate::settings::transport::PeerService::Peers)
|
||||
.timeout(std::time::Duration::from_secs(30));
|
||||
.timeout(std::time::Duration::from_secs(30))
|
||||
.fips_timeout(std::time::Duration::from_secs(6))
|
||||
.record_transport(&self.config.data_dir);
|
||||
|
||||
match req.send_json(&body).await {
|
||||
Ok((resp, transport)) if resp.status().is_success() => {
|
||||
|
||||
@@ -118,6 +118,31 @@ impl RpcHandler {
|
||||
Ok(serde_json::json!({ "removed": removed }))
|
||||
}
|
||||
|
||||
/// `wallet.fedimint-send` — spend ecash notes from any joined federation
|
||||
/// with sufficient balance. Returns the notes token for the recipient
|
||||
/// (rendered as text + QR by the send modal — the wallet's Fedi rail,
|
||||
/// split from Cashu 2026-07-22). The heavy lifting already existed in
|
||||
/// `fedimint_client::spend_from_any`; it was simply never exposed.
|
||||
pub(super) async fn handle_wallet_fedimint_send(
|
||||
&self,
|
||||
params: Option<serde_json::Value>,
|
||||
) -> Result<serde_json::Value> {
|
||||
let amount_sats = params
|
||||
.as_ref()
|
||||
.and_then(|p| p.get("amount_sats"))
|
||||
.and_then(|v| v.as_u64())
|
||||
.ok_or_else(|| anyhow::anyhow!("Missing amount_sats"))?;
|
||||
anyhow::ensure!(amount_sats > 0, "must be at least 1 sat");
|
||||
let (token, federation_id) =
|
||||
crate::wallet::fedimint_client::spend_from_any(&self.config.data_dir, amount_sats)
|
||||
.await?;
|
||||
Ok(serde_json::json!({
|
||||
"token": token,
|
||||
"federation_id": federation_id,
|
||||
"amount_sats": amount_sats,
|
||||
}))
|
||||
}
|
||||
|
||||
/// `wallet.fedimint-balance` — total sats across all joined federations.
|
||||
pub(super) async fn handle_wallet_fedimint_balance(&self) -> Result<serde_json::Value> {
|
||||
// Soft-fail to zero when clientd isn't installed/running, so the unified
|
||||
|
||||
@@ -13,7 +13,63 @@ use anyhow::Result;
|
||||
impl RpcHandler {
|
||||
pub(super) async fn handle_fips_status(&self) -> Result<serde_json::Value> {
|
||||
let status = fips::FipsStatus::query(&self.config.data_dir).await;
|
||||
Ok(serde_json::to_value(status)?)
|
||||
let mut v = serde_json::to_value(status)?;
|
||||
// Dial outcome counters (process-lifetime): how often peer dials
|
||||
// used FIPS vs fell back to Tor, broken down by reason. This is
|
||||
// the observability that makes "FIPS uptime" measurable.
|
||||
if let Some(obj) = v.as_object_mut() {
|
||||
obj.insert("dial_stats".to_string(), fips::telemetry::snapshot());
|
||||
}
|
||||
Ok(v)
|
||||
}
|
||||
|
||||
/// Everything the companion app needs to join this node's mesh, embedded
|
||||
/// in the pairing QR by the web UI: the daemon's npub (identity to dial),
|
||||
/// the fips0 ULA (where the UI is reachable once the phone is meshed),
|
||||
/// and the transport ports on this host. The QR builder supplies the
|
||||
/// host/IP itself — it knows which origin the browser reached the node on.
|
||||
pub(super) async fn handle_fips_pair_info(&self) -> Result<serde_json::Value> {
|
||||
let identity_dir = fips::identity_dir_from(&self.config.data_dir);
|
||||
let npub = crate::identity::fips_npub(&identity_dir)
|
||||
.await?
|
||||
.ok_or_else(|| {
|
||||
anyhow::anyhow!("FIPS identity not provisioned yet — complete onboarding first")
|
||||
})?;
|
||||
let ula = fips::iface::fips0_ula().map(|ip| ip.to_string());
|
||||
// The node's seed anchors ride along so the phone can rendezvous
|
||||
// through the same public mesh points when the node's LAN endpoint
|
||||
// isn't directly dialable (phone away from home, node behind NAT).
|
||||
let mut anchor_list = fips::anchors::load(&self.config.data_dir)
|
||||
.await
|
||||
.unwrap_or_default();
|
||||
// Pairing must always carry a public rendezvous point: without one the
|
||||
// phone is IP-bound to the LAN host it scanned and goes dark the
|
||||
// moment it leaves that network. This is a pairing hint only — the
|
||||
// node's own anchor file is not modified, so an operator's removal of
|
||||
// the default anchors still sticks for the node itself.
|
||||
if !anchor_list
|
||||
.iter()
|
||||
.any(|a| a.npub == fips::anchors::ARCHY_ANCHOR_NPUB)
|
||||
{
|
||||
anchor_list.push(fips::anchors::archy_anchor());
|
||||
}
|
||||
let anchors = anchor_list
|
||||
.into_iter()
|
||||
.map(|a| {
|
||||
serde_json::json!({
|
||||
"npub": a.npub,
|
||||
"addr": a.address,
|
||||
"transport": a.transport,
|
||||
})
|
||||
})
|
||||
.collect::<Vec<_>>();
|
||||
Ok(serde_json::json!({
|
||||
"npub": npub,
|
||||
"ula": ula,
|
||||
"udp_port": fips::PUBLISHED_UDP_PORT,
|
||||
"tcp_port": fips::DEFAULT_TCP_PORT,
|
||||
"anchors": anchors,
|
||||
}))
|
||||
}
|
||||
|
||||
pub(super) async fn handle_fips_check_update(&self) -> Result<serde_json::Value> {
|
||||
@@ -34,7 +90,8 @@ impl RpcHandler {
|
||||
pub(super) async fn handle_fips_install(&self) -> Result<serde_json::Value> {
|
||||
let identity_dir = fips::identity_dir_from(&self.config.data_dir);
|
||||
fips::config::install(&identity_dir).await?;
|
||||
fips::service::activate(fips::SERVICE_UNIT).await?;
|
||||
let unit = fips::service::activation_unit().await;
|
||||
fips::service::activate(unit).await?;
|
||||
let status = fips::FipsStatus::query(&self.config.data_dir).await;
|
||||
Ok(serde_json::to_value(status)?)
|
||||
}
|
||||
|
||||
@@ -86,7 +86,7 @@ impl RpcHandler {
|
||||
let did = crate::identity::did_key_from_pubkey_hex(&data.server_info.pubkey)
|
||||
.unwrap_or_default();
|
||||
let version = data.server_info.version.clone();
|
||||
let relays = self.config.nostr_relays.clone();
|
||||
let relays = self.handshake_relays().await;
|
||||
let tor_proxy = self.config.nostr_tor_proxy.clone();
|
||||
tokio::spawn(async move {
|
||||
if let Err(e) = nostr_handshake::publish_presence(
|
||||
@@ -106,6 +106,17 @@ impl RpcHandler {
|
||||
Ok(serde_json::json!({ "enabled": enabled }))
|
||||
}
|
||||
|
||||
/// The relay set every handshake operation uses: the user-managed relay
|
||||
/// list (Settings → Relays, `nostr_relays.json`) merged with the config
|
||||
/// defaults. Before 2026-07-22 handshake send/poll used ONLY the two
|
||||
/// hardcoded config relays (one of which is defunct) and ignored user
|
||||
/// relay edits entirely — so a sender publishing where the receiver
|
||||
/// never read was a routine, silent way for peer requests to vanish.
|
||||
pub(super) async fn handshake_relays(&self) -> Vec<String> {
|
||||
crate::nostr_relays::merged_relay_list(&self.config.data_dir, &self.config.nostr_relays)
|
||||
.await
|
||||
}
|
||||
|
||||
/// Discover discoverable nodes via Nostr presence events.
|
||||
/// Returns (nostr_pubkey, npub, DID, version) only — never an onion.
|
||||
pub(super) async fn handle_handshake_discover(&self) -> Result<serde_json::Value> {
|
||||
@@ -113,9 +124,10 @@ impl RpcHandler {
|
||||
// to query relays as long as the user is actively browsing — they're
|
||||
// an anonymous observer of presence events, not publishing anything.
|
||||
let identity_dir = self.config.data_dir.join("identity");
|
||||
let relays = self.handshake_relays().await;
|
||||
let nodes = nostr_handshake::discover_nodes(
|
||||
&identity_dir,
|
||||
&self.config.nostr_relays,
|
||||
&relays,
|
||||
self.config.nostr_tor_proxy.as_deref(),
|
||||
)
|
||||
.await?;
|
||||
@@ -161,7 +173,7 @@ impl RpcHandler {
|
||||
our_version,
|
||||
our_name,
|
||||
message,
|
||||
&self.config.nostr_relays,
|
||||
&self.handshake_relays().await,
|
||||
self.config.nostr_tor_proxy.as_deref(),
|
||||
)
|
||||
.await?;
|
||||
@@ -191,6 +203,40 @@ impl RpcHandler {
|
||||
/// - `PeerReject` → mark matching outbound row as `Rejected`
|
||||
///
|
||||
/// Never auto-adds peers, never auto-responds, never sends our onion.
|
||||
/// Background relay poll (2026-07-22): before this, `handshake.poll` ran
|
||||
/// ONLY when a user opened Federation and pressed the Poll button — a
|
||||
/// peer request sat on the relay until the target's operator happened to
|
||||
/// click, i.e. for most nodes forever ("requests never arrive"). Runs the
|
||||
/// same poll+dispatch as the RPC (the disabled gate inside still applies)
|
||||
/// and nudges the websocket revision when anything new lands so open UIs
|
||||
/// refresh immediately.
|
||||
pub async fn background_handshake_poll(self: &std::sync::Arc<Self>) {
|
||||
match self.handle_handshake_poll().await {
|
||||
Ok(res) => {
|
||||
let new = res
|
||||
.get("new_requests")
|
||||
.and_then(|v| v.as_array())
|
||||
.map(|a| a.len())
|
||||
.unwrap_or(0);
|
||||
let applied = res
|
||||
.get("applied_invites")
|
||||
.and_then(|v| v.as_array())
|
||||
.map(|a| a.len())
|
||||
.unwrap_or(0);
|
||||
if new > 0 || applied > 0 {
|
||||
tracing::info!(
|
||||
new_requests = new,
|
||||
applied_invites = applied,
|
||||
"handshake poll: inbound peer activity"
|
||||
);
|
||||
let (data, _) = self.state_manager.get_snapshot().await;
|
||||
self.state_manager.update_data(data).await;
|
||||
}
|
||||
}
|
||||
Err(e) => tracing::debug!("background handshake poll failed: {e:#}"),
|
||||
}
|
||||
}
|
||||
|
||||
pub(super) async fn handle_handshake_poll(&self) -> Result<serde_json::Value> {
|
||||
// Runtime gate: if the user hasn't enabled discoverability, don't
|
||||
// touch the relays. The poll endpoint is a hard no-op until they
|
||||
@@ -207,9 +253,10 @@ impl RpcHandler {
|
||||
}));
|
||||
}
|
||||
let identity_dir = self.config.data_dir.join("identity");
|
||||
let relays = self.handshake_relays().await;
|
||||
let handshakes = nostr_handshake::poll_handshakes(
|
||||
&identity_dir,
|
||||
&self.config.nostr_relays,
|
||||
&relays,
|
||||
self.config.nostr_tor_proxy.as_deref(),
|
||||
None,
|
||||
)
|
||||
|
||||
@@ -5,6 +5,21 @@ use tracing::info;
|
||||
|
||||
use super::LND_REST_BASE_URL;
|
||||
|
||||
/// LND rejects RPCs with "server is still in the process of starting" for a
|
||||
/// short window after wallet unlock (p2p/graph subsystems still loading).
|
||||
/// Transient by design — match it so callers retry and, if it persists,
|
||||
/// surface a calm notice instead of a scary failure. The exact phrase below
|
||||
/// is what the frontend keys its softer (non-red) styling on.
|
||||
fn lnd_still_starting(msg: &str) -> bool {
|
||||
let m = msg.to_ascii_lowercase();
|
||||
m.contains("in the process of starting") || m.contains("server is still starting")
|
||||
}
|
||||
|
||||
/// User-facing text for the still-starting state. Deliberately calm: this is
|
||||
/// a "wait a moment", not an error.
|
||||
const LND_STARTING_MSG: &str = "Your Lightning node is still finishing its startup — this \
|
||||
usually takes a minute or two after the node comes online. Please try again shortly.";
|
||||
|
||||
#[derive(Debug, Serialize)]
|
||||
struct ChannelInfo {
|
||||
chan_id: String,
|
||||
@@ -15,6 +30,8 @@ struct ChannelInfo {
|
||||
active: bool,
|
||||
status: String,
|
||||
channel_point: String,
|
||||
#[serde(skip_serializing_if = "String::is_empty")]
|
||||
closing_txid: String,
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize)]
|
||||
@@ -43,6 +60,10 @@ struct LndChannel {
|
||||
#[derive(Debug, Deserialize, Default)]
|
||||
struct LndPendingChannelsResponse {
|
||||
pending_open_channels: Option<Vec<LndPendingOpenChannel>>,
|
||||
// Cooperative closes waiting for their closing tx to confirm
|
||||
waiting_close_channels: Option<Vec<LndWaitingCloseChannel>>,
|
||||
// Force closes serving out their timelock
|
||||
pending_force_closing_channels: Option<Vec<LndForceClosingChannel>>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
@@ -50,6 +71,18 @@ struct LndPendingOpenChannel {
|
||||
channel: Option<LndPendingChannel>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
struct LndWaitingCloseChannel {
|
||||
channel: Option<LndPendingChannel>,
|
||||
closing_txid: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
struct LndForceClosingChannel {
|
||||
channel: Option<LndPendingChannel>,
|
||||
closing_txid: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
struct LndPendingChannel {
|
||||
remote_node_pub: Option<String>,
|
||||
@@ -59,6 +92,52 @@ struct LndPendingChannel {
|
||||
channel_point: Option<String>,
|
||||
}
|
||||
|
||||
impl LndPendingChannel {
|
||||
fn into_channel_info(self, status: &str, closing_txid: Option<String>) -> ChannelInfo {
|
||||
let parse = |s: &Option<String>| s.as_deref().and_then(|v| v.parse().ok()).unwrap_or(0);
|
||||
ChannelInfo {
|
||||
chan_id: String::new(),
|
||||
remote_pubkey: self.remote_node_pub.clone().unwrap_or_default(),
|
||||
capacity: parse(&self.capacity),
|
||||
local_balance: parse(&self.local_balance),
|
||||
remote_balance: parse(&self.remote_balance),
|
||||
active: false,
|
||||
status: status.into(),
|
||||
channel_point: self.channel_point.unwrap_or_default(),
|
||||
closing_txid: closing_txid.unwrap_or_default(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize, Default)]
|
||||
struct LndClosedChannelsResponse {
|
||||
channels: Option<Vec<LndClosedChannel>>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
struct LndClosedChannel {
|
||||
chan_id: Option<String>,
|
||||
remote_pubkey: Option<String>,
|
||||
capacity: Option<String>,
|
||||
settled_balance: Option<String>,
|
||||
close_type: Option<String>,
|
||||
closing_tx_hash: Option<String>,
|
||||
channel_point: Option<String>,
|
||||
close_height: Option<i64>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize)]
|
||||
struct ClosedChannelInfo {
|
||||
chan_id: String,
|
||||
remote_pubkey: String,
|
||||
capacity: i64,
|
||||
settled_balance: i64,
|
||||
close_type: String,
|
||||
closing_tx_hash: String,
|
||||
channel_point: String,
|
||||
close_height: i64,
|
||||
}
|
||||
|
||||
impl RpcHandler {
|
||||
pub(in crate::api::rpc) async fn handle_lnd_listchannels(&self) -> Result<serde_json::Value> {
|
||||
let (client, macaroon_hex) = self.lnd_client().await?;
|
||||
@@ -116,6 +195,7 @@ impl RpcHandler {
|
||||
"inactive".into()
|
||||
},
|
||||
channel_point: ch.channel_point.unwrap_or_default(),
|
||||
closing_txid: String::new(),
|
||||
}
|
||||
})
|
||||
.collect();
|
||||
@@ -123,31 +203,20 @@ impl RpcHandler {
|
||||
let mut pending_channels: Vec<ChannelInfo> = Vec::new();
|
||||
for pch in pending_resp.pending_open_channels.unwrap_or_default() {
|
||||
if let Some(ch) = pch.channel {
|
||||
let capacity: i64 = ch
|
||||
.capacity
|
||||
.as_deref()
|
||||
.and_then(|s| s.parse().ok())
|
||||
.unwrap_or(0);
|
||||
let local: i64 = ch
|
||||
.local_balance
|
||||
.as_deref()
|
||||
.and_then(|s| s.parse().ok())
|
||||
.unwrap_or(0);
|
||||
let remote: i64 = ch
|
||||
.remote_balance
|
||||
.as_deref()
|
||||
.and_then(|s| s.parse().ok())
|
||||
.unwrap_or(0);
|
||||
pending_channels.push(ChannelInfo {
|
||||
chan_id: String::new(),
|
||||
remote_pubkey: ch.remote_node_pub.unwrap_or_default(),
|
||||
capacity,
|
||||
local_balance: local,
|
||||
remote_balance: remote,
|
||||
active: false,
|
||||
status: "pending_open".into(),
|
||||
channel_point: ch.channel_point.unwrap_or_default(),
|
||||
});
|
||||
pending_channels.push(ch.into_channel_info("pending_open", None));
|
||||
}
|
||||
}
|
||||
for wch in pending_resp.waiting_close_channels.unwrap_or_default() {
|
||||
if let Some(ch) = wch.channel {
|
||||
pending_channels.push(ch.into_channel_info("closing", wch.closing_txid));
|
||||
}
|
||||
}
|
||||
for fch in pending_resp
|
||||
.pending_force_closing_channels
|
||||
.unwrap_or_default()
|
||||
{
|
||||
if let Some(ch) = fch.channel {
|
||||
pending_channels.push(ch.into_channel_info("force_closing", fch.closing_txid));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -251,25 +320,45 @@ impl RpcHandler {
|
||||
"perm": false,
|
||||
"timeout": "30"
|
||||
});
|
||||
let connect_resp = client
|
||||
.post(format!("{LND_REST_BASE_URL}/v1/peers"))
|
||||
.header("Grpc-Metadata-macaroon", &macaroon_hex)
|
||||
.json(&connect_body)
|
||||
.timeout(std::time::Duration::from_secs(35))
|
||||
.send()
|
||||
.await
|
||||
.context("Failed to connect to peer")?;
|
||||
// Right after wallet unlock, LND's RPC answers while its p2p
|
||||
// server is still spinning up, and every connect attempt gets
|
||||
// "server is still in the process of starting". That's a
|
||||
// transient state, not a failure — it clears in seconds — so
|
||||
// retry quietly for ~30s before surfacing a calm, non-scary
|
||||
// notice (the frontend styles LND_STARTING_MSG as info, not red).
|
||||
let mut attempt = 0u32;
|
||||
loop {
|
||||
let connect_resp = client
|
||||
.post(format!("{LND_REST_BASE_URL}/v1/peers"))
|
||||
.header("Grpc-Metadata-macaroon", &macaroon_hex)
|
||||
.json(&connect_body)
|
||||
.timeout(std::time::Duration::from_secs(35))
|
||||
.send()
|
||||
.await
|
||||
.context("Failed to connect to peer")?;
|
||||
|
||||
if !connect_resp.status().is_success() {
|
||||
if connect_resp.status().is_success() {
|
||||
break;
|
||||
}
|
||||
let body: serde_json::Value = connect_resp.json().await.unwrap_or_default();
|
||||
let msg = body
|
||||
.get("message")
|
||||
.and_then(|v| v.as_str())
|
||||
.unwrap_or("Unknown error");
|
||||
// LND returns an error if we already have this peer — that is fine
|
||||
if !msg.contains("already connected") {
|
||||
return Err(anyhow::anyhow!("Failed to connect to peer: {}", msg));
|
||||
if msg.contains("already connected") {
|
||||
break;
|
||||
}
|
||||
if lnd_still_starting(msg) && attempt < 5 {
|
||||
attempt += 1;
|
||||
info!(attempt, "LND still starting — retrying peer connect in 6s");
|
||||
tokio::time::sleep(std::time::Duration::from_secs(6)).await;
|
||||
continue;
|
||||
}
|
||||
if lnd_still_starting(msg) {
|
||||
return Err(anyhow::anyhow!("{LND_STARTING_MSG}"));
|
||||
}
|
||||
return Err(anyhow::anyhow!("Failed to connect to peer: {}", msg));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -305,12 +394,55 @@ impl RpcHandler {
|
||||
.get("message")
|
||||
.and_then(|v| v.as_str())
|
||||
.unwrap_or("Unknown error");
|
||||
if lnd_still_starting(msg) {
|
||||
return Err(anyhow::anyhow!("{LND_STARTING_MSG}"));
|
||||
}
|
||||
return Err(anyhow::anyhow!("Failed to open channel: {}", msg));
|
||||
}
|
||||
|
||||
Ok(body)
|
||||
}
|
||||
|
||||
pub(in crate::api::rpc) async fn handle_lnd_closedchannels(&self) -> Result<serde_json::Value> {
|
||||
let (client, macaroon_hex) = self.lnd_client().await?;
|
||||
|
||||
let resp: LndClosedChannelsResponse = client
|
||||
.get(format!("{LND_REST_BASE_URL}/v1/channels/closed"))
|
||||
.header("Grpc-Metadata-macaroon", &macaroon_hex)
|
||||
.send()
|
||||
.await
|
||||
.context("LND REST connection failed")?
|
||||
.json()
|
||||
.await
|
||||
.context("Failed to parse LND closed channels response")?;
|
||||
|
||||
let channels: Vec<ClosedChannelInfo> = resp
|
||||
.channels
|
||||
.unwrap_or_default()
|
||||
.into_iter()
|
||||
.map(|ch| ClosedChannelInfo {
|
||||
chan_id: ch.chan_id.unwrap_or_default(),
|
||||
remote_pubkey: ch.remote_pubkey.unwrap_or_default(),
|
||||
capacity: ch
|
||||
.capacity
|
||||
.as_deref()
|
||||
.and_then(|s| s.parse().ok())
|
||||
.unwrap_or(0),
|
||||
settled_balance: ch
|
||||
.settled_balance
|
||||
.as_deref()
|
||||
.and_then(|s| s.parse().ok())
|
||||
.unwrap_or(0),
|
||||
close_type: ch.close_type.unwrap_or_default(),
|
||||
closing_tx_hash: ch.closing_tx_hash.unwrap_or_default(),
|
||||
channel_point: ch.channel_point.unwrap_or_default(),
|
||||
close_height: ch.close_height.unwrap_or(0),
|
||||
})
|
||||
.collect();
|
||||
|
||||
Ok(serde_json::json!({ "channels": channels }))
|
||||
}
|
||||
|
||||
pub(in crate::api::rpc) async fn handle_lnd_closechannel(
|
||||
&self,
|
||||
params: Option<serde_json::Value>,
|
||||
@@ -351,27 +483,35 @@ impl RpcHandler {
|
||||
"Closing Lightning channel"
|
||||
);
|
||||
|
||||
let (client, macaroon_hex) = self.lnd_client().await?;
|
||||
let (_, macaroon_hex) = self.lnd_client().await?;
|
||||
|
||||
// The close endpoint is server-streaming: LND holds the connection
|
||||
// open and emits updates until the closing tx CONFIRMS on-chain
|
||||
// (potentially hours). Reading the whole body hangs the RPC even
|
||||
// though the close already went through, and the shared lnd_client's
|
||||
// 15s total timeout would abort the stream mid-read. Use a dedicated
|
||||
// client and return as soon as the first streamed update arrives.
|
||||
let client = reqwest::Client::builder()
|
||||
.no_proxy()
|
||||
.connect_timeout(std::time::Duration::from_secs(10))
|
||||
.danger_accept_invalid_certs(true)
|
||||
.build()
|
||||
.context("Failed to create streaming HTTP client")?;
|
||||
|
||||
let url = format!(
|
||||
"{LND_REST_BASE_URL}/v1/channels/{}/{}?force={}",
|
||||
parts[0], parts[1], force
|
||||
);
|
||||
|
||||
let resp = client
|
||||
let mut resp = client
|
||||
.delete(&url)
|
||||
.header("Grpc-Metadata-macaroon", &macaroon_hex)
|
||||
.send()
|
||||
.await
|
||||
.context("Failed to close channel")?;
|
||||
|
||||
let status = resp.status();
|
||||
let body: serde_json::Value = resp
|
||||
.json()
|
||||
.await
|
||||
.context("Failed to parse close channel response")?;
|
||||
|
||||
if !status.is_success() {
|
||||
if !resp.status().is_success() {
|
||||
let body: serde_json::Value = resp.json().await.unwrap_or_default();
|
||||
let msg = body
|
||||
.get("message")
|
||||
.and_then(|v| v.as_str())
|
||||
@@ -379,6 +519,56 @@ impl RpcHandler {
|
||||
return Err(anyhow::anyhow!("Failed to close channel: {}", msg));
|
||||
}
|
||||
|
||||
Ok(serde_json::json!({ "success": true }))
|
||||
// First streamed line is {"result":{"close_pending":…}} on success or
|
||||
// {"error":…} — the stream reports errors in-band after a 200.
|
||||
let mut buf: Vec<u8> = Vec::new();
|
||||
let first_update = tokio::time::timeout(std::time::Duration::from_secs(25), async {
|
||||
while let Some(chunk) = resp.chunk().await? {
|
||||
buf.extend_from_slice(&chunk);
|
||||
let line = match buf.iter().position(|&b| b == b'\n') {
|
||||
Some(pos) => &buf[..pos],
|
||||
None => &buf[..],
|
||||
};
|
||||
if let Ok(v) = serde_json::from_slice::<serde_json::Value>(line) {
|
||||
return Ok::<_, anyhow::Error>(Some(v));
|
||||
}
|
||||
}
|
||||
Ok(None)
|
||||
})
|
||||
.await;
|
||||
|
||||
match first_update {
|
||||
Ok(Ok(Some(update))) => {
|
||||
if let Some(err) = update.get("error") {
|
||||
let msg = err
|
||||
.get("message")
|
||||
.and_then(|v| v.as_str())
|
||||
.unwrap_or("Unknown error");
|
||||
return Err(anyhow::anyhow!("Failed to close channel: {}", msg));
|
||||
}
|
||||
// txid arrives base64-encoded in internal byte order; flip it
|
||||
// into the display order explorers use.
|
||||
use base64::Engine as _;
|
||||
let closing_txid = update
|
||||
.pointer("/result/close_pending/txid")
|
||||
.and_then(|v| v.as_str())
|
||||
.and_then(|b64| base64::engine::general_purpose::STANDARD.decode(b64).ok())
|
||||
.map(|mut bytes| {
|
||||
bytes.reverse();
|
||||
hex::encode(bytes)
|
||||
})
|
||||
.unwrap_or_default();
|
||||
info!(channel_point, closing_txid, "Channel close initiated");
|
||||
Ok(serde_json::json!({ "success": true, "closing_txid": closing_txid }))
|
||||
}
|
||||
Ok(Ok(None)) => Err(anyhow::anyhow!(
|
||||
"LND ended the close stream without an update — check the channel list"
|
||||
)),
|
||||
Ok(Err(e)) => Err(e).context("Failed reading close channel response"),
|
||||
// No update inside the window: the close is almost certainly still
|
||||
// negotiating with the peer — report initiated, the channel list
|
||||
// will show it under Closing.
|
||||
Err(_) => Ok(serde_json::json!({ "success": true, "closing_txid": "" })),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -56,6 +56,172 @@ pub(crate) async fn read_lnd_admin_macaroon() -> Result<Vec<u8>> {
|
||||
}
|
||||
}
|
||||
|
||||
/// Real-time wallet push (user req 2026-07-22): the UI must reflect an
|
||||
/// incoming on-chain transaction the moment the node sees it, not on the
|
||||
/// next poll. Streams LND's `/v1/transactions/subscribe` — it fires on
|
||||
/// 0-conf mempool arrival AND again on each confirmation — and nudges the
|
||||
/// shared data-model revision on every event; /ws/db pushes that to every
|
||||
/// connected client and the frontend refetches wallet balance/transactions.
|
||||
/// Reconnects forever with capped backoff: LND restarting, wallet locked, or
|
||||
/// LND not installed yet all just mean "try again shortly".
|
||||
pub(crate) fn spawn_lnd_tx_watcher(state_manager: std::sync::Arc<crate::state::StateManager>) {
|
||||
tokio::spawn(async move {
|
||||
let mut delay = std::time::Duration::from_secs(5);
|
||||
loop {
|
||||
match stream_lnd_transactions(&state_manager).await {
|
||||
// Stream ended cleanly (LND shutdown) — resume fast.
|
||||
Ok(()) => delay = std::time::Duration::from_secs(5),
|
||||
Err(e) => {
|
||||
tracing::debug!("lnd tx watcher: {e:#} — retrying in {delay:?}");
|
||||
}
|
||||
}
|
||||
tokio::time::sleep(delay).await;
|
||||
delay = (delay * 2).min(std::time::Duration::from_secs(120));
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
async fn stream_lnd_transactions(sm: &crate::state::StateManager) -> Result<()> {
|
||||
let macaroon_hex = hex::encode(read_lnd_admin_macaroon().await?);
|
||||
// Dedicated client: the shared lnd_client() carries a 15s total timeout,
|
||||
// which would kill this deliberately long-lived stream.
|
||||
let client = reqwest::Client::builder()
|
||||
.no_proxy()
|
||||
.connect_timeout(std::time::Duration::from_secs(10))
|
||||
.danger_accept_invalid_certs(true)
|
||||
.build()
|
||||
.context("Failed to create streaming HTTP client")?;
|
||||
let mut resp = client
|
||||
.get(format!("{LND_REST_BASE_URL}/v1/transactions/subscribe"))
|
||||
.header("Grpc-Metadata-macaroon", &macaroon_hex)
|
||||
.send()
|
||||
.await
|
||||
.context("subscribe request failed")?;
|
||||
anyhow::ensure!(
|
||||
resp.status().is_success(),
|
||||
"transactions/subscribe returned {}",
|
||||
resp.status()
|
||||
);
|
||||
tracing::info!("lnd tx watcher: streaming wallet transaction events");
|
||||
while let Some(chunk) = resp.chunk().await? {
|
||||
if chunk.is_empty() {
|
||||
continue;
|
||||
}
|
||||
// Any streamed event = wallet activity. Revision bump is the push
|
||||
// contract (same pattern as the mesh-peer bridge in server.rs) — the
|
||||
// clients refetch, so we don't need to parse the event body.
|
||||
let (data, _) = sm.get_snapshot().await;
|
||||
sm.update_data(data).await;
|
||||
tracing::debug!(
|
||||
bytes = chunk.len(),
|
||||
"lnd tx watcher: wallet tx event — nudged ws clients"
|
||||
);
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// LND wedge watchdog (2026-07-22, "100% uptime"): framework-pt's LND sat
|
||||
/// for 14 HOURS with its RPC answering but the server never finishing
|
||||
/// startup — synced_to_chain=false, zero peers, every channel inactive —
|
||||
/// and nothing noticed until a human tried to open a channel. The wedge
|
||||
/// signature is precise: RPC healthy while (!synced_to_chain, or zero peers
|
||||
/// with channels that need a peer) persists. A restart reliably clears it
|
||||
/// (the backend-churn wedge is a known lnd+rpcpolling failure mode), so
|
||||
/// after 15 consecutive bad minutes we bounce the container ourselves, with
|
||||
/// a 30-minute cooldown so a genuinely broken LND can't restart-loop.
|
||||
/// RPC-unreachable and locked-wallet states are deliberately NOT handled
|
||||
/// here — container-down is crash-recovery's job, and unlocking needs the
|
||||
/// operator.
|
||||
pub(crate) fn spawn_lnd_health_watchdog() {
|
||||
tokio::spawn(async move {
|
||||
let mut bad_minutes: u32 = 0;
|
||||
let mut last_restart: Option<tokio::time::Instant> = None;
|
||||
loop {
|
||||
tokio::time::sleep(std::time::Duration::from_secs(60)).await;
|
||||
let Ok(bytes) = read_lnd_admin_macaroon().await else {
|
||||
bad_minutes = 0; // no LND on this node (or not set up yet)
|
||||
continue;
|
||||
};
|
||||
let macaroon_hex = hex::encode(bytes);
|
||||
let Ok(client) = reqwest::Client::builder()
|
||||
.no_proxy()
|
||||
.timeout(std::time::Duration::from_secs(10))
|
||||
.danger_accept_invalid_certs(true)
|
||||
.build()
|
||||
else {
|
||||
continue;
|
||||
};
|
||||
let Ok(resp) = client
|
||||
.get(format!("{LND_REST_BASE_URL}/v1/getinfo"))
|
||||
.header("Grpc-Metadata-macaroon", &macaroon_hex)
|
||||
.send()
|
||||
.await
|
||||
else {
|
||||
bad_minutes = 0; // down/locked — not the wedge signature
|
||||
continue;
|
||||
};
|
||||
let Ok(info) = resp.json::<serde_json::Value>().await else {
|
||||
bad_minutes = 0;
|
||||
continue;
|
||||
};
|
||||
let synced = info
|
||||
.get("synced_to_chain")
|
||||
.and_then(|v| v.as_bool())
|
||||
.unwrap_or(true);
|
||||
let peers = info.get("num_peers").and_then(|v| v.as_u64()).unwrap_or(0);
|
||||
let channels = info
|
||||
.get("num_active_channels")
|
||||
.and_then(|v| v.as_u64())
|
||||
.unwrap_or(0)
|
||||
+ info
|
||||
.get("num_inactive_channels")
|
||||
.and_then(|v| v.as_u64())
|
||||
.unwrap_or(0)
|
||||
+ info
|
||||
.get("num_pending_channels")
|
||||
.and_then(|v| v.as_u64())
|
||||
.unwrap_or(0);
|
||||
let wedged = !synced || (channels > 0 && peers == 0);
|
||||
if !wedged {
|
||||
bad_minutes = 0;
|
||||
continue;
|
||||
}
|
||||
bad_minutes += 1;
|
||||
if bad_minutes < 15 {
|
||||
continue;
|
||||
}
|
||||
if last_restart
|
||||
.map(|t| t.elapsed() < std::time::Duration::from_secs(1800))
|
||||
.unwrap_or(false)
|
||||
{
|
||||
continue;
|
||||
}
|
||||
tracing::warn!(
|
||||
synced_to_chain = synced,
|
||||
num_peers = peers,
|
||||
channels,
|
||||
"LND wedged for {bad_minutes} minutes (RPC up, server never ready) — restarting the lnd container"
|
||||
);
|
||||
let out = tokio::process::Command::new("podman")
|
||||
.args(["restart", "lnd"])
|
||||
.output()
|
||||
.await;
|
||||
match out {
|
||||
Ok(o) if o.status.success() => {
|
||||
tracing::info!("LND watchdog restart complete");
|
||||
}
|
||||
Ok(o) => tracing::warn!(
|
||||
"LND watchdog restart failed: {}",
|
||||
String::from_utf8_lossy(&o.stderr).trim()
|
||||
),
|
||||
Err(e) => tracing::warn!("LND watchdog restart failed: {e}"),
|
||||
}
|
||||
last_restart = Some(tokio::time::Instant::now());
|
||||
bad_minutes = 0;
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
impl RpcHandler {
|
||||
/// Helper: create an authenticated LND REST client.
|
||||
/// Returns an HTTP client configured for LND's self-signed TLS and the
|
||||
|
||||
@@ -36,6 +36,33 @@ impl RpcHandler {
|
||||
|
||||
let (client, macaroon_hex) = self.lnd_client().await?;
|
||||
|
||||
// Decode the invoice up front (fast, local) so we know its payment
|
||||
// hash BEFORE handing it to LND. If the payment outlives our wait
|
||||
// below, the hash is what lets the UI keep tracking it instead of
|
||||
// declaring a false failure. Best-effort: a decode hiccup must not
|
||||
// block the payment itself.
|
||||
let (decoded_hash, decoded_amt) = match client
|
||||
.get(format!("{LND_REST_BASE_URL}/v1/payreq/{payment_request}"))
|
||||
.header("Grpc-Metadata-macaroon", &macaroon_hex)
|
||||
.send()
|
||||
.await
|
||||
{
|
||||
Ok(r) => match r.json::<serde_json::Value>().await {
|
||||
Ok(d) => (
|
||||
d.get("payment_hash")
|
||||
.and_then(|v| v.as_str())
|
||||
.unwrap_or("")
|
||||
.to_string(),
|
||||
d.get("num_satoshis")
|
||||
.and_then(|v| v.as_str())
|
||||
.and_then(|s| s.parse::<i64>().ok())
|
||||
.unwrap_or(0),
|
||||
),
|
||||
Err(_) => (String::new(), 0),
|
||||
},
|
||||
Err(_) => (String::new(), 0),
|
||||
};
|
||||
|
||||
let mut pay_body = serde_json::json!({
|
||||
"payment_request": payment_request,
|
||||
});
|
||||
@@ -43,13 +70,46 @@ impl RpcHandler {
|
||||
pay_body["amt"] = serde_json::json!(amt.to_string());
|
||||
}
|
||||
|
||||
let resp = client
|
||||
// `/v1/channels/transactions` is SYNCHRONOUS: it blocks until the
|
||||
// payment settles or definitively fails, and multi-hop routing with
|
||||
// retries routinely takes longer than the shared client's 15s budget.
|
||||
// That 15s abort used to surface as "Payment failed" while LND kept
|
||||
// paying in the background — the payment then succeeded and appeared
|
||||
// in history a minute later. Wait up to 120s on a dedicated client,
|
||||
// and treat a post-connect timeout as IN FLIGHT (status: pending),
|
||||
// never as failure — only LND may declare a payment failed.
|
||||
let pay_client = reqwest::Client::builder()
|
||||
.no_proxy()
|
||||
.connect_timeout(std::time::Duration::from_secs(10))
|
||||
.timeout(std::time::Duration::from_secs(120))
|
||||
.danger_accept_invalid_certs(true)
|
||||
.build()
|
||||
.context("Failed to create HTTP client")?;
|
||||
|
||||
let resp = match pay_client
|
||||
.post(format!("{LND_REST_BASE_URL}/v1/channels/transactions"))
|
||||
.header("Grpc-Metadata-macaroon", &macaroon_hex)
|
||||
.json(&pay_body)
|
||||
.send()
|
||||
.await
|
||||
.context("Failed to pay invoice")?;
|
||||
{
|
||||
Ok(r) => r,
|
||||
Err(e) if e.is_connect() => {
|
||||
// Never reached LND — nothing was sent; this IS a hard error.
|
||||
return Err(anyhow::anyhow!("Could not reach LND to pay: {e}"));
|
||||
}
|
||||
Err(_) => {
|
||||
// Timed out (or lost the connection) AFTER the payment was
|
||||
// handed to LND — it may well still succeed. Report pending
|
||||
// with the hash so the caller can poll lnd.paymentstatus.
|
||||
info!("payinvoice wait elapsed; payment still in flight");
|
||||
return Ok(serde_json::json!({
|
||||
"status": "pending",
|
||||
"payment_hash": decoded_hash,
|
||||
"amount_sats": decoded_amt,
|
||||
}));
|
||||
}
|
||||
};
|
||||
|
||||
let status = resp.status();
|
||||
let body: serde_json::Value = resp
|
||||
@@ -62,6 +122,14 @@ impl RpcHandler {
|
||||
.get("message")
|
||||
.and_then(|v| v.as_str())
|
||||
.unwrap_or("Unknown error");
|
||||
// Invoices are short-lived; retrying the same one can never
|
||||
// succeed, so tell the user the way out instead of just the fact.
|
||||
if msg.contains("invoice expired") {
|
||||
return Err(anyhow::anyhow!(
|
||||
"Payment failed: this invoice has expired ({}). Ask the recipient for a fresh invoice and try again.",
|
||||
msg.trim_start_matches("invoice expired. ")
|
||||
));
|
||||
}
|
||||
return Err(anyhow::anyhow!("Payment failed: {}", msg));
|
||||
}
|
||||
|
||||
@@ -78,20 +146,105 @@ impl RpcHandler {
|
||||
.and_then(|r| r.get("total_amt"))
|
||||
.and_then(|v| v.as_str())
|
||||
.and_then(|s| s.parse::<i64>().ok())
|
||||
.unwrap_or(0);
|
||||
.unwrap_or(decoded_amt);
|
||||
|
||||
let payment_hash = body
|
||||
.get("payment_hash")
|
||||
.and_then(|v| v.as_str())
|
||||
.unwrap_or("")
|
||||
.to_string();
|
||||
.filter(|s| !s.is_empty())
|
||||
.map(|s| s.to_string())
|
||||
.unwrap_or(decoded_hash);
|
||||
|
||||
Ok(serde_json::json!({
|
||||
"status": "succeeded",
|
||||
"payment_hash": payment_hash,
|
||||
"amount_sats": amount_sat,
|
||||
}))
|
||||
}
|
||||
|
||||
/// Status of an outgoing Lightning payment by hex payment hash. Lets the
|
||||
/// UI resolve a payinvoice that outlived its synchronous wait (`status:
|
||||
/// "pending"`) to a real terminal state instead of guessing.
|
||||
pub(in crate::api::rpc) async fn handle_lnd_paymentstatus(
|
||||
&self,
|
||||
params: Option<serde_json::Value>,
|
||||
) -> Result<serde_json::Value> {
|
||||
let params = params.unwrap_or_default();
|
||||
let payment_hash = params
|
||||
.get("payment_hash")
|
||||
.and_then(|v| v.as_str())
|
||||
.ok_or_else(|| anyhow::anyhow!("Missing 'payment_hash' parameter"))?;
|
||||
if payment_hash.len() != 64 || !payment_hash.chars().all(|c| c.is_ascii_hexdigit()) {
|
||||
return Err(anyhow::anyhow!("Invalid payment hash"));
|
||||
}
|
||||
|
||||
let (client, macaroon_hex) = self.lnd_client().await?;
|
||||
let resp = client
|
||||
.get(format!(
|
||||
"{LND_REST_BASE_URL}/v1/payments?include_incomplete=true&max_payments=100&reversed=true"
|
||||
))
|
||||
.header("Grpc-Metadata-macaroon", &macaroon_hex)
|
||||
.send()
|
||||
.await
|
||||
.context("LND REST connection failed")?;
|
||||
let body: serde_json::Value = resp
|
||||
.json()
|
||||
.await
|
||||
.context("Failed to parse payments response")?;
|
||||
|
||||
let hash_lower = payment_hash.to_lowercase();
|
||||
let found = body
|
||||
.get("payments")
|
||||
.and_then(|v| v.as_array())
|
||||
.and_then(|arr| {
|
||||
arr.iter().find(|p| {
|
||||
p.get("payment_hash").and_then(|v| v.as_str())
|
||||
== Some(hash_lower.as_str())
|
||||
})
|
||||
});
|
||||
|
||||
let Some(p) = found else {
|
||||
// Not in the latest window — either very old or LND never saw it.
|
||||
return Ok(serde_json::json!({ "status": "unknown" }));
|
||||
};
|
||||
|
||||
let lnd_status = p.get("status").and_then(|v| v.as_str()).unwrap_or("");
|
||||
let status = match lnd_status {
|
||||
"SUCCEEDED" => "succeeded",
|
||||
"FAILED" => "failed",
|
||||
_ => "in_flight",
|
||||
};
|
||||
let failure_reason = match p
|
||||
.get("failure_reason")
|
||||
.and_then(|v| v.as_str())
|
||||
.unwrap_or("")
|
||||
{
|
||||
"FAILURE_REASON_NO_ROUTE" => "No route to the recipient",
|
||||
"FAILURE_REASON_INSUFFICIENT_BALANCE" => "Insufficient channel balance",
|
||||
"FAILURE_REASON_TIMEOUT" => "Payment timed out in the network",
|
||||
"FAILURE_REASON_INCORRECT_PAYMENT_DETAILS" => {
|
||||
"Recipient rejected the payment (wrong details or expired invoice)"
|
||||
}
|
||||
"FAILURE_REASON_ERROR" => "Payment failed",
|
||||
_ => "",
|
||||
};
|
||||
|
||||
fn amt(p: &serde_json::Value, key: &str) -> i64 {
|
||||
p.get(key)
|
||||
.and_then(|f| f.as_str())
|
||||
.and_then(|s| s.parse().ok())
|
||||
.or_else(|| p.get(key).and_then(|f| f.as_i64()))
|
||||
.unwrap_or(0)
|
||||
}
|
||||
|
||||
Ok(serde_json::json!({
|
||||
"status": status,
|
||||
"failure_reason": failure_reason,
|
||||
"amount_sats": amt(p, "value_sat"),
|
||||
"fee_sats": amt(p, "fee_sat"),
|
||||
}))
|
||||
}
|
||||
|
||||
/// List on-chain transactions from LND.
|
||||
/// Returns all transactions, with incoming (amount > 0) flagged.
|
||||
pub(in crate::api::rpc) async fn handle_lnd_gettransactions(
|
||||
|
||||
@@ -124,16 +124,41 @@ impl RpcHandler {
|
||||
return Err(anyhow::anyhow!("Invalid Bitcoin address format"));
|
||||
}
|
||||
|
||||
// Fee control: either a confirmation target or an explicit fee rate
|
||||
let target_conf = params.get("target_conf").and_then(|v| v.as_i64());
|
||||
let sat_per_vbyte = params.get("sat_per_vbyte").and_then(|v| v.as_i64());
|
||||
if target_conf.is_some() && sat_per_vbyte.is_some() {
|
||||
return Err(anyhow::anyhow!(
|
||||
"Invalid fee parameters: specify either target_conf or sat_per_vbyte, not both"
|
||||
));
|
||||
}
|
||||
if let Some(tc) = target_conf {
|
||||
if !(1..=1008).contains(&tc) {
|
||||
return Err(anyhow::anyhow!(
|
||||
"Invalid target_conf: must be between 1 and 1008 blocks"
|
||||
));
|
||||
}
|
||||
}
|
||||
if let Some(rate) = sat_per_vbyte {
|
||||
if !(1..=5000).contains(&rate) {
|
||||
return Err(anyhow::anyhow!(
|
||||
"Invalid sat_per_vbyte: must be between 1 and 5000"
|
||||
));
|
||||
}
|
||||
}
|
||||
|
||||
info!(
|
||||
addr = addr,
|
||||
amount = amount,
|
||||
send_all = send_all,
|
||||
target_conf = target_conf,
|
||||
sat_per_vbyte = sat_per_vbyte,
|
||||
"Sending on-chain Bitcoin"
|
||||
);
|
||||
|
||||
let (client, macaroon_hex) = self.lnd_client().await?;
|
||||
|
||||
let send_body = match amount {
|
||||
let mut send_body = match amount {
|
||||
Some(amount) => serde_json::json!({
|
||||
"addr": addr,
|
||||
"amount": amount.to_string(),
|
||||
@@ -143,6 +168,13 @@ impl RpcHandler {
|
||||
"send_all": true,
|
||||
}),
|
||||
};
|
||||
if let Some(tc) = target_conf {
|
||||
send_body["target_conf"] = serde_json::json!(tc);
|
||||
}
|
||||
if let Some(rate) = sat_per_vbyte {
|
||||
// LND REST encodes uint64 as a JSON string
|
||||
send_body["sat_per_vbyte"] = serde_json::json!(rate.to_string());
|
||||
}
|
||||
|
||||
let resp = client
|
||||
.post(format!("{LND_REST_BASE_URL}/v1/transactions"))
|
||||
@@ -171,6 +203,82 @@ impl RpcHandler {
|
||||
Ok(serde_json::json!({ "txid": txid }))
|
||||
}
|
||||
|
||||
/// Estimate the on-chain fee for sending `amount` sats to `addr` at a
|
||||
/// confirmation target. Returns `{ fee_sat, sat_per_vbyte }` so the send
|
||||
/// UI can show the cost of each preset before the user confirms.
|
||||
pub(in crate::api::rpc) async fn handle_lnd_estimatefee(
|
||||
&self,
|
||||
params: Option<serde_json::Value>,
|
||||
) -> Result<serde_json::Value> {
|
||||
let params = params.unwrap_or_default();
|
||||
let addr = params
|
||||
.get("addr")
|
||||
.and_then(|v| v.as_str())
|
||||
.ok_or_else(|| anyhow::anyhow!("Missing 'addr' parameter"))?;
|
||||
if addr.len() < 14 || addr.len() > 90 || !addr.chars().all(|c| c.is_ascii_alphanumeric()) {
|
||||
return Err(anyhow::anyhow!("Invalid Bitcoin address format"));
|
||||
}
|
||||
let amount = params
|
||||
.get("amount")
|
||||
.and_then(|v| v.as_i64())
|
||||
.ok_or_else(|| anyhow::anyhow!("Missing 'amount' parameter (sats)"))?;
|
||||
if !(546..=21_000_000 * 100_000_000).contains(&amount) {
|
||||
return Err(anyhow::anyhow!("Invalid amount"));
|
||||
}
|
||||
let target_conf = params
|
||||
.get("target_conf")
|
||||
.and_then(|v| v.as_i64())
|
||||
.unwrap_or(6);
|
||||
if !(1..=1008).contains(&target_conf) {
|
||||
return Err(anyhow::anyhow!(
|
||||
"Invalid target_conf: must be between 1 and 1008 blocks"
|
||||
));
|
||||
}
|
||||
|
||||
let (client, macaroon_hex) = self.lnd_client().await?;
|
||||
|
||||
let resp = client
|
||||
.get(format!("{LND_REST_BASE_URL}/v1/transactions/fee"))
|
||||
.query(&[
|
||||
(format!("AddrToAmount[{addr}]"), amount.to_string()),
|
||||
("target_conf".to_string(), target_conf.to_string()),
|
||||
])
|
||||
.header("Grpc-Metadata-macaroon", &macaroon_hex)
|
||||
.send()
|
||||
.await
|
||||
.context("Failed to estimate fee")?;
|
||||
|
||||
let status = resp.status();
|
||||
let body: serde_json::Value = resp
|
||||
.json()
|
||||
.await
|
||||
.context("Failed to parse fee estimate response")?;
|
||||
|
||||
if !status.is_success() {
|
||||
let msg = body
|
||||
.get("message")
|
||||
.and_then(|v| v.as_str())
|
||||
.unwrap_or("Unknown error");
|
||||
return Err(anyhow::anyhow!("Failed to estimate fee: {}", msg));
|
||||
}
|
||||
|
||||
// LND REST encodes int64 as JSON strings
|
||||
let fee_sat = body
|
||||
.get("fee_sat")
|
||||
.and_then(|v| v.as_str())
|
||||
.and_then(|s| s.parse::<i64>().ok())
|
||||
.unwrap_or(0);
|
||||
let sat_per_vbyte = body
|
||||
.get("sat_per_vbyte")
|
||||
.and_then(|v| v.as_str())
|
||||
.and_then(|s| s.parse::<i64>().ok())
|
||||
.unwrap_or(0);
|
||||
Ok(serde_json::json!({
|
||||
"fee_sat": fee_sat,
|
||||
"sat_per_vbyte": sat_per_vbyte,
|
||||
}))
|
||||
}
|
||||
|
||||
/// Create a Lightning invoice.
|
||||
/// Create a Lightning invoice and return `(bolt11, payment_hash_hex)`.
|
||||
///
|
||||
|
||||
@@ -0,0 +1,132 @@
|
||||
use super::super::RpcHandler;
|
||||
use crate::mesh;
|
||||
use crate::mesh::flash::{self, FlashBoard, FlashJobStatus};
|
||||
use crate::mesh::types::DeviceType;
|
||||
use anyhow::Result;
|
||||
|
||||
fn parse_family(s: &str) -> Result<DeviceType> {
|
||||
match s.trim().to_lowercase().as_str() {
|
||||
"meshcore" => Ok(DeviceType::Meshcore),
|
||||
"meshtastic" => Ok(DeviceType::Meshtastic),
|
||||
"reticulum" | "rnode" => Ok(DeviceType::Reticulum),
|
||||
other => anyhow::bail!("Unknown firmware family: {other} (expected meshcore|meshtastic|reticulum)"),
|
||||
}
|
||||
}
|
||||
|
||||
fn parse_board(s: &str) -> Result<FlashBoard> {
|
||||
match s.trim().to_lowercase().as_str() {
|
||||
"heltec-v3" | "heltec_v3" | "heltecv3" => Ok(FlashBoard::HeltecV3),
|
||||
"heltec-v4" | "heltec_v4" | "heltecv4" => Ok(FlashBoard::HeltecV4),
|
||||
other => anyhow::bail!("Unknown board: {other} (expected heltec-v3|heltec-v4)"),
|
||||
}
|
||||
}
|
||||
|
||||
impl RpcHandler {
|
||||
/// mesh.flash-list-firmware — resolve the available firmware version(s)
|
||||
/// for a given family. v1 only ever surfaces "latest".
|
||||
pub(in crate::api::rpc) async fn handle_mesh_flash_list_firmware(
|
||||
&self,
|
||||
params: Option<serde_json::Value>,
|
||||
) -> Result<serde_json::Value> {
|
||||
let family = params
|
||||
.as_ref()
|
||||
.and_then(|p| p.get("family"))
|
||||
.and_then(|v| v.as_str())
|
||||
.ok_or_else(|| anyhow::anyhow!("Missing family"))?;
|
||||
let family = parse_family(family)?;
|
||||
let versions = flash::list_firmware(family).await?;
|
||||
Ok(serde_json::json!({ "versions": versions }))
|
||||
}
|
||||
|
||||
/// mesh.flash-device — erase and reflash a detected LoRa radio with the
|
||||
/// latest firmware for the given family, defaulting to a full chip
|
||||
/// erase before write. `board` is optional: if the port's USB vid:pid
|
||||
/// unambiguously resolves to a known board, that's used; otherwise the
|
||||
/// caller must supply it explicitly (see `flash::resolve_flash_board`'s
|
||||
/// doc comment on why we refuse to guess).
|
||||
pub(in crate::api::rpc) async fn handle_mesh_flash_device(
|
||||
&self,
|
||||
params: Option<serde_json::Value>,
|
||||
) -> Result<serde_json::Value> {
|
||||
let path = params
|
||||
.as_ref()
|
||||
.and_then(|p| p.get("path"))
|
||||
.and_then(|v| v.as_str())
|
||||
.ok_or_else(|| anyhow::anyhow!("Missing path"))?
|
||||
.to_string();
|
||||
let family = params
|
||||
.as_ref()
|
||||
.and_then(|p| p.get("family"))
|
||||
.and_then(|v| v.as_str())
|
||||
.ok_or_else(|| anyhow::anyhow!("Missing family"))?;
|
||||
let family = parse_family(family)?;
|
||||
|
||||
let detected = mesh::detect_devices().await;
|
||||
anyhow::ensure!(
|
||||
detected.iter().any(|d| d == &path),
|
||||
"{path} is not a detected mesh-radio candidate port"
|
||||
);
|
||||
|
||||
let board = match params
|
||||
.as_ref()
|
||||
.and_then(|p| p.get("board"))
|
||||
.and_then(|v| v.as_str())
|
||||
{
|
||||
Some(explicit) => parse_board(explicit)?,
|
||||
None => {
|
||||
let info = mesh::detect_devices_info()
|
||||
.await
|
||||
.into_iter()
|
||||
.find(|d| d.path == path);
|
||||
info.as_ref()
|
||||
.and_then(flash::resolve_flash_board)
|
||||
.ok_or_else(|| {
|
||||
anyhow::anyhow!(
|
||||
"Could not auto-detect the board on {path} — specify board explicitly"
|
||||
)
|
||||
})?
|
||||
}
|
||||
};
|
||||
|
||||
flash::start_flash_job(
|
||||
&self.flash_job,
|
||||
&self.mesh_service_arc(),
|
||||
self.config.data_dir.clone(),
|
||||
path,
|
||||
board,
|
||||
family,
|
||||
)
|
||||
.await?;
|
||||
|
||||
Ok(serde_json::json!({ "started": true }))
|
||||
}
|
||||
|
||||
/// mesh.flash-status — poll the current (or most recent) flash job.
|
||||
pub(in crate::api::rpc) async fn handle_mesh_flash_status(&self) -> Result<serde_json::Value> {
|
||||
let job = self.flash_job.read().await;
|
||||
match job.as_ref() {
|
||||
Some(j) => {
|
||||
let status: FlashJobStatus = j.snapshot().await;
|
||||
let mut value = serde_json::to_value(&status)?;
|
||||
if let Some(obj) = value.as_object_mut() {
|
||||
obj.insert("active".into(), (!status.done).into());
|
||||
}
|
||||
Ok(value)
|
||||
}
|
||||
None => Ok(serde_json::json!({ "active": false })),
|
||||
}
|
||||
}
|
||||
|
||||
/// mesh.flash-cancel — best-effort; only honored before erase/write has
|
||||
/// started (see `FlashJob::cancel`'s doc comment).
|
||||
pub(in crate::api::rpc) async fn handle_mesh_flash_cancel(&self) -> Result<serde_json::Value> {
|
||||
let job = self.flash_job.read().await;
|
||||
match job.as_ref() {
|
||||
Some(j) => {
|
||||
j.cancel().await?;
|
||||
Ok(serde_json::json!({ "cancelled": true }))
|
||||
}
|
||||
None => anyhow::bail!("No flash job in progress"),
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,6 +1,7 @@
|
||||
use super::super::RpcHandler;
|
||||
use crate::mesh;
|
||||
use anyhow::Result;
|
||||
use std::sync::Arc;
|
||||
use tracing::info;
|
||||
|
||||
impl RpcHandler {
|
||||
@@ -131,7 +132,14 @@ impl RpcHandler {
|
||||
config.broadcast_identity = broadcast;
|
||||
}
|
||||
if let Some(name) = params.get("advert_name").and_then(|v| v.as_str()) {
|
||||
config.advert_name = Some(name.to_string());
|
||||
// Empty clears the custom mesh name (falls back to the server
|
||||
// name) — without this, a name could be set but never unset.
|
||||
let trimmed = name.trim();
|
||||
config.advert_name = if trimmed.is_empty() {
|
||||
None
|
||||
} else {
|
||||
Some(trimmed.to_string())
|
||||
};
|
||||
}
|
||||
if let Some(announce) = params
|
||||
.get("announce_block_headers")
|
||||
@@ -181,6 +189,11 @@ impl RpcHandler {
|
||||
config.lora_radio_params = Some(parsed);
|
||||
}
|
||||
}
|
||||
// Hot-swap "keep as is": false = never write config to the radio
|
||||
// (region/channel/PHY/advert name) — use it exactly as flashed.
|
||||
if let Some(manage) = params.get("manage_radio").and_then(|v| v.as_bool()) {
|
||||
config.manage_radio = manage;
|
||||
}
|
||||
// Firmware pin: probe only the named firmware on the port ("auto"/""
|
||||
// clears the pin and restores strict-probe auto-detect).
|
||||
if let Some(kind) = params.get("device_kind").and_then(|v| v.as_str()) {
|
||||
@@ -197,10 +210,24 @@ impl RpcHandler {
|
||||
|
||||
mesh::save_config(&self.config.data_dir, &config).await?;
|
||||
|
||||
// If we have a running service, update its config
|
||||
let mut service = self.mesh_service.write().await;
|
||||
if let Some(svc) = service.as_mut() {
|
||||
svc.configure(config.clone()).await?;
|
||||
// Apply to the running service in the background: configure() may
|
||||
// stop+start the listener (config changes now restart the session so
|
||||
// they actually take effect), and that can take seconds when the old
|
||||
// session is mid-probe. Holding the service write-lock for that long
|
||||
// inside this handler stalled every concurrent mesh.status/mesh.peers
|
||||
// poll behind it — the UI froze and nginx surfaced 502s. The config is
|
||||
// already persisted above; the UI observes progress via mesh.status.
|
||||
{
|
||||
let service_arc = Arc::clone(&self.mesh_service);
|
||||
let config_for_apply = config.clone();
|
||||
tokio::spawn(async move {
|
||||
let mut service = service_arc.write().await;
|
||||
if let Some(svc) = service.as_mut() {
|
||||
if let Err(e) = svc.configure(config_for_apply).await {
|
||||
tracing::error!("Applying mesh config to running service failed: {e:#}");
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
info!("Mesh config updated");
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
mod assistant;
|
||||
mod bitcoin_ops;
|
||||
mod flash;
|
||||
mod messaging;
|
||||
mod safety;
|
||||
mod status;
|
||||
|
||||
@@ -52,6 +52,9 @@ impl RpcHandler {
|
||||
.map(|k| k.to_string().to_lowercase())
|
||||
.into(),
|
||||
);
|
||||
// Hot-swap "keep as is" state: false = archipelago never writes
|
||||
// config to the radio (see MeshConfig::manage_radio).
|
||||
obj.insert("manage_radio".into(), config.manage_radio.into());
|
||||
// USB identity per detected port so the setup modal can show the
|
||||
// actual board (product string on native-USB boards, vid:pid as
|
||||
// the fallback for bridge chips).
|
||||
@@ -78,6 +81,81 @@ impl RpcHandler {
|
||||
Ok(value)
|
||||
}
|
||||
|
||||
/// mesh.probe-device — Identify the firmware on a detected serial port and
|
||||
/// read its current configuration WITHOUT provisioning it. Powers the
|
||||
/// hot-swap "device detected" modal's current-details view. The path must
|
||||
/// be one of the currently detected candidate ports (no arbitrary device
|
||||
/// paths), and probing the live session's own port is refused.
|
||||
pub(in crate::api::rpc) async fn handle_mesh_probe_device(
|
||||
&self,
|
||||
params: Option<serde_json::Value>,
|
||||
) -> Result<serde_json::Value> {
|
||||
let path = params
|
||||
.as_ref()
|
||||
.and_then(|p| p.get("path"))
|
||||
.and_then(|v| v.as_str())
|
||||
.ok_or_else(|| anyhow::anyhow!("Missing path"))?
|
||||
.to_string();
|
||||
let detected = mesh::detect_devices().await;
|
||||
anyhow::ensure!(
|
||||
detected.iter().any(|d| d == &path),
|
||||
"{path} is not a detected mesh-radio candidate port"
|
||||
);
|
||||
// Refuse to probe while a firmware flash is in flight. Confirmed
|
||||
// live 2026-07-23: esptool ("multiple access on port?") and
|
||||
// rnodeconf (OSError Errno 71 Protocol error on an RTS ioctl) both
|
||||
// failed with symptoms consistent with a second process holding the
|
||||
// same serial fd — the flash subprocess runs for minutes outside
|
||||
// our own async runtime, so nothing previously stopped a concurrent
|
||||
// `mesh.probe-device` call (e.g. the hot-swap modal's own re-probe)
|
||||
// from opening the identical port at the same time and corrupting
|
||||
// both operations' handshakes.
|
||||
if let Some(job) = self.flash_job.read().await.as_ref() {
|
||||
anyhow::ensure!(
|
||||
job.snapshot().await.done,
|
||||
"A firmware flash is in progress — refusing to probe the serial port until it finishes"
|
||||
);
|
||||
}
|
||||
// Only hold the mesh_service lock long enough for the quick
|
||||
// active-path guard check — NEVER across the actual probe, which
|
||||
// can take 15-60s across its internal collision retries. Confirmed
|
||||
// live 2026-07-23: holding this read lock for the full probe starved
|
||||
// a concurrent firmware-flash job's MeshService::stop() (which needs
|
||||
// the write lock) well past its own bounded timeout, surfacing as
|
||||
// "Mesh listener did not release the serial port" even though
|
||||
// stop() itself was fast.
|
||||
{
|
||||
let service = self.mesh_service.read().await;
|
||||
if let Some(svc) = service.as_ref() {
|
||||
svc.ensure_probe_allowed(&path).await?;
|
||||
}
|
||||
}
|
||||
let probe = mesh::listener::probe_device(&path).await?;
|
||||
Ok(serde_json::to_value(probe)?)
|
||||
}
|
||||
|
||||
/// mesh.refresh — Actively refresh discovery state: re-query the radio's
|
||||
/// contact table and re-announce ourselves so quiet-but-alive neighbours
|
||||
/// answer. This is what the UI's Refresh button calls — before it existed
|
||||
/// the button only re-read server caches and never touched the radio.
|
||||
pub(in crate::api::rpc) async fn handle_mesh_refresh(&self) -> Result<serde_json::Value> {
|
||||
let service = self.mesh_service.read().await;
|
||||
let Some(svc) = service.as_ref() else {
|
||||
return Ok(serde_json::json!({ "refreshed": false, "device_connected": false }));
|
||||
};
|
||||
let status = svc.status().await;
|
||||
if status.device_connected {
|
||||
let state = svc.shared_state();
|
||||
let _ = state
|
||||
.send_cmd(crate::mesh::listener::MeshCommand::RefreshContacts)
|
||||
.await;
|
||||
}
|
||||
Ok(serde_json::json!({
|
||||
"refreshed": status.device_connected,
|
||||
"device_connected": status.device_connected,
|
||||
}))
|
||||
}
|
||||
|
||||
/// mesh.peers — List discovered mesh peers.
|
||||
pub(in crate::api::rpc) async fn handle_mesh_peers(&self) -> Result<serde_json::Value> {
|
||||
let service = self.mesh_service.read().await;
|
||||
|
||||
@@ -820,6 +820,8 @@ impl RpcHandler {
|
||||
crate::fips::dial::PeerRequest::new(fips_npub.as_deref(), &onion_bare, &path)
|
||||
.service(crate::settings::transport::PeerService::MeshFileSharing)
|
||||
.timeout(std::time::Duration::from_secs(120))
|
||||
.fips_timeout(std::time::Duration::from_secs(8))
|
||||
.record_transport(&self.config.data_dir)
|
||||
.send_get()
|
||||
.await
|
||||
.map_err(|e| anyhow::anyhow!("Fetch failed: {}", e))?;
|
||||
|
||||
@@ -73,6 +73,20 @@ pub(super) fn sanitize_error_message(msg: &str) -> String {
|
||||
"Mempool requires",
|
||||
"Container",
|
||||
"Image",
|
||||
// Wallet-actionable errors: masking "Insufficient balance: need 80
|
||||
// sats, have 0 sats" behind "Operation failed. Check server logs."
|
||||
// sent the operator to journalctl for a message that was written for
|
||||
// them in the first place (ecash send, 2026-07-22).
|
||||
"Insufficient balance",
|
||||
"Insufficient funds",
|
||||
// Lightning payment failures carry LND's reason ("invoice expired.
|
||||
// Valid until …", "no route", …) — the user can act on every one of
|
||||
// them, and masking sent the operator to journalctl (invoice-expired
|
||||
// send, 2026-07-23).
|
||||
"Payment failed",
|
||||
"Invalid payment request",
|
||||
"Missing 'payment_request'",
|
||||
"Your Lightning node is still finishing",
|
||||
"Bitcoin address",
|
||||
"No router",
|
||||
"No OpenWrt",
|
||||
@@ -151,6 +165,25 @@ mod sanitize_tests {
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn lightning_payment_errors_pass_through() {
|
||||
// LND's payment-failure reasons are written for the payer — masking
|
||||
// "invoice expired" as "Check server logs" left a user retrying a
|
||||
// dead invoice (framework-pt, 2026-07-23).
|
||||
for msg in [
|
||||
"Payment failed: this invoice has expired (Valid until 2026-07-23 07:41:42 +0000 UTC). Ask the recipient for a fresh invoice and try again.",
|
||||
"Payment failed: unable to find a path to destination",
|
||||
"Invalid payment request: must be a Lightning invoice (lnbc...)",
|
||||
"Missing 'payment_request' parameter",
|
||||
] {
|
||||
assert_ne!(
|
||||
sanitize_error_message(msg),
|
||||
"Operation failed. Check server logs for details.",
|
||||
"masked: {msg}"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn internal_errors_stay_generic() {
|
||||
assert_eq!(
|
||||
|
||||
@@ -15,7 +15,7 @@ mod fips;
|
||||
mod handshake;
|
||||
mod identity;
|
||||
mod interfaces;
|
||||
pub(in crate::api) mod lnd;
|
||||
pub(crate) mod lnd;
|
||||
mod marketplace;
|
||||
mod mesh;
|
||||
mod middleware;
|
||||
@@ -26,6 +26,7 @@ mod node;
|
||||
mod nostr;
|
||||
mod openwrt;
|
||||
mod package;
|
||||
pub(crate) use package::wyoming_satellite_keeper;
|
||||
mod peers;
|
||||
mod pine_status;
|
||||
mod response;
|
||||
@@ -88,6 +89,9 @@ pub struct RpcHandler {
|
||||
endpoint_rate_limiter: EndpointRateLimiter,
|
||||
response_cache: ResponseCache,
|
||||
mesh_service: Arc<tokio::sync::RwLock<Option<crate::mesh::MeshService>>>,
|
||||
/// LoRa radio firmware-flash job state, sibling to `mesh_service` — one
|
||||
/// job at a time, since flashing needs exclusive access to the port.
|
||||
flash_job: crate::mesh::flash::FlashJobHandle,
|
||||
transport_router: Arc<tokio::sync::RwLock<Option<Arc<crate::transport::TransportRouter>>>>,
|
||||
/// Shared content-addressed blob store. Set by ApiHandler after construction
|
||||
/// so mesh.send-content / mesh.fetch-content RPCs can reach it without a
|
||||
@@ -159,6 +163,7 @@ impl RpcHandler {
|
||||
endpoint_rate_limiter,
|
||||
response_cache: ResponseCache::new(5),
|
||||
mesh_service: Arc::new(tokio::sync::RwLock::new(None)),
|
||||
flash_job: crate::mesh::flash::new_job_handle(),
|
||||
transport_router: Arc::new(tokio::sync::RwLock::new(None)),
|
||||
blob_store: Arc::new(tokio::sync::RwLock::new(None)),
|
||||
self_pubkey_hex: Arc::new(tokio::sync::RwLock::new(None)),
|
||||
@@ -532,9 +537,31 @@ impl RpcHandler {
|
||||
self.login_rate_limiter.record_failure(client_ip).await;
|
||||
}
|
||||
|
||||
// On successful login, check if 2FA is required
|
||||
// On successful login, check if 2FA is required. Device-token logins
|
||||
// (companion pairing QR) skip the TOTP challenge like remember-me does:
|
||||
// the token was minted from an already-authenticated session, and there
|
||||
// is no password with which to decrypt the TOTP secret anyway.
|
||||
if method == "auth.login" && rpc_resp.error.is_none() {
|
||||
let totp_enabled = self.auth_manager.is_totp_enabled().await.unwrap_or(false);
|
||||
let password = login_params
|
||||
.as_ref()
|
||||
.and_then(|p| p.get("password"))
|
||||
.and_then(|v| v.as_str());
|
||||
let is_token_login = login_params
|
||||
.as_ref()
|
||||
.and_then(|p| p.get("token"))
|
||||
.and_then(|v| v.as_str())
|
||||
.is_some()
|
||||
|| match password {
|
||||
// Companion device tokens also arrive through the password
|
||||
// field (see handle_auth_login) — those logins get a full
|
||||
// session too; there's no password to decrypt TOTP with.
|
||||
Some(pw) => crate::device_tokens::verify(&self.config.data_dir, pw)
|
||||
.await
|
||||
.is_some(),
|
||||
None => false,
|
||||
};
|
||||
let totp_enabled =
|
||||
!is_token_login && self.auth_manager.is_totp_enabled().await.unwrap_or(false);
|
||||
if totp_enabled {
|
||||
let password = login_params
|
||||
.as_ref()
|
||||
|
||||
@@ -137,6 +137,7 @@ impl RpcHandler {
|
||||
None,
|
||||
None,
|
||||
None,
|
||||
Some(&self.config.data_dir),
|
||||
)
|
||||
.await?;
|
||||
|
||||
@@ -225,6 +226,7 @@ impl RpcHandler {
|
||||
signing_key.as_ref().map(|i| i.signing_key()),
|
||||
Some(&req.from_pubkey),
|
||||
data.server_info.name.as_deref(),
|
||||
Some(&self.config.data_dir),
|
||||
)
|
||||
.await
|
||||
{
|
||||
|
||||
@@ -206,19 +206,24 @@ pub(super) fn check_install_deps(package_id: &str, deps: &RunningDeps) -> Result
|
||||
"BTCPay Server requires a running Bitcoin node (Bitcoin Knots). \
|
||||
Please install and start Bitcoin Knots first."
|
||||
)),
|
||||
"mempool" | "mempool-web" if !deps.has_bitcoin || !deps.has_electrumx => {
|
||||
let mut missing = vec![];
|
||||
if !deps.has_bitcoin {
|
||||
missing.push("Bitcoin Knots");
|
||||
}
|
||||
if !deps.has_electrumx {
|
||||
missing.push("ElectrumX");
|
||||
}
|
||||
Err(anyhow::anyhow!(
|
||||
"Mempool requires {} to be running. Please install and start {} first.",
|
||||
missing.join(" and "),
|
||||
missing.join(" and ")
|
||||
))
|
||||
"mempool" | "mempool-web" if !deps.has_bitcoin => Err(anyhow::anyhow!(
|
||||
"Mempool requires Bitcoin Knots to be running. \
|
||||
Please install and start Bitcoin Knots first."
|
||||
)),
|
||||
// ElectrumX is deliberately NOT a hard gate for mempool: mempool-api
|
||||
// reconnects to electrum on its own (verified live — it retries
|
||||
// ECONNREFUSED in a loop and comes good when electrum starts
|
||||
// listening), and an ElectrumX mid-resync can be legitimately down
|
||||
// for DAYS. The hard gate here blocked repeated mempool installs on
|
||||
// .116 (2026-07-22) while electrumx was compacting. Block/fee views
|
||||
// work from bitcoind immediately; address lookups light up when
|
||||
// ElectrumX finishes.
|
||||
"mempool" | "mempool-web" if !deps.has_electrumx => {
|
||||
info!(
|
||||
"Mempool installing while ElectrumX is not running — \
|
||||
mempool-api reconnects automatically once ElectrumX is up"
|
||||
);
|
||||
Ok(())
|
||||
}
|
||||
"fedimint" if !deps.has_bitcoin => {
|
||||
info!("Fedimint installing without local Bitcoin node — configure remote Bitcoin RPC in Fedimint guardian setup");
|
||||
@@ -298,9 +303,10 @@ fn missing_install_deps(package_id: &str, deps: &RunningDeps) -> Vec<MissingDep>
|
||||
if !deps.has_bitcoin {
|
||||
missing.push(BITCOIN);
|
||||
}
|
||||
if !deps.has_electrumx {
|
||||
missing.push(ELECTRUM);
|
||||
}
|
||||
// ElectrumX intentionally not required — see check_install_deps:
|
||||
// mempool-api reconnects when electrum comes up, and a resyncing
|
||||
// ElectrumX can be down for days (the 3-minute bounded wait here
|
||||
// would still fail the install for no benefit).
|
||||
}
|
||||
// fedimint deliberately absent: check_install_deps allows it without
|
||||
// a local Bitcoin node (remote RPC configured in guardian setup).
|
||||
@@ -1111,7 +1117,11 @@ mod tests {
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn mempool_waits_on_both_bitcoin_and_electrumx() {
|
||||
async fn mempool_waits_on_bitcoin_only() {
|
||||
// ElectrumX is deliberately NOT gated (2026-07-22): a resyncing
|
||||
// ElectrumX can be down for days and mempool-api reconnects on
|
||||
// its own, so the wait only covers Bitcoin — even when electrumx
|
||||
// is installed and stopped.
|
||||
let calls = Arc::new(AtomicU32::new(0));
|
||||
let (labels, sink) = label_sink();
|
||||
let probe_calls = Arc::clone(&calls);
|
||||
@@ -1119,8 +1129,8 @@ mod tests {
|
||||
"mempool",
|
||||
move || {
|
||||
let n = probe_calls.fetch_add(1, Ordering::SeqCst);
|
||||
// Bitcoin comes up on probe 2, electrumx on probe 3.
|
||||
async move { Ok(probe(n >= 1, n >= 2, &["bitcoin-knots", "electrumx"])) }
|
||||
// Bitcoin comes up on probe 2; electrumx never does.
|
||||
async move { Ok(probe(n >= 1, false, &["bitcoin-knots", "electrumx"])) }
|
||||
},
|
||||
sink,
|
||||
36,
|
||||
@@ -1131,22 +1141,19 @@ mod tests {
|
||||
let labels = labels.lock().unwrap();
|
||||
assert_eq!(
|
||||
labels.as_slice(),
|
||||
&[
|
||||
"Waiting for Bitcoin and ElectrumX to start…".to_string(),
|
||||
"Waiting for ElectrumX to start…".to_string(),
|
||||
]
|
||||
&["Waiting for Bitcoin to start…".to_string()]
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn mempool_fails_fast_when_one_dep_is_not_installed() {
|
||||
// Bitcoin is installed (waiting could help) but ElectrumX is not
|
||||
// installed at all — waiting can never satisfy the gate, so fail
|
||||
// fast with the canonical message.
|
||||
async fn mempool_fails_fast_when_bitcoin_is_not_installed() {
|
||||
// Bitcoin isn't installed at all — waiting can never satisfy the
|
||||
// gate, so fail fast with the canonical message. (A missing
|
||||
// ElectrumX no longer blocks anything.)
|
||||
let (labels, sink) = label_sink();
|
||||
let err = wait_for_install_deps(
|
||||
"mempool",
|
||||
|| async { Ok(probe(false, false, &["bitcoin-knots"])) },
|
||||
|| async { Ok(probe(false, false, &["electrumx"])) },
|
||||
sink,
|
||||
36,
|
||||
Duration::ZERO,
|
||||
|
||||
@@ -2324,17 +2324,28 @@ async fn cleanup_start_conflict(package_id: &str, stderr: &str) -> bool {
|
||||
}
|
||||
|
||||
async fn cleanup_stale_pasta_port(port: &str) {
|
||||
// NEVER kill our own process. The daemon holds catalog app ports over
|
||||
// IPv6 (the mesh app-port relay), so a blunt `fuser -k <port>/tcp` would
|
||||
// terminate archipelago itself mid-install — installs failed and apps
|
||||
// vanished on framework-pt 2026-07-27. Kill every listener on the port
|
||||
// EXCEPT our PID (and our process group), leaving the relay/daemon alive.
|
||||
let self_pid = std::process::id();
|
||||
let kill_listener = format!(
|
||||
"ss -ltnp 'sport = :{}' 2>/dev/null | sed -n 's/.*pid=\\([0-9]*\\).*/\\1/p' | xargs -r kill 2>/dev/null || true",
|
||||
port
|
||||
"ss -ltnp 'sport = :{port}' 2>/dev/null | sed -n 's/.*pid=\\([0-9]*\\).*/\\1/p' | \
|
||||
while read p; do [ \"$p\" = \"{self_pid}\" ] || kill \"$p\" 2>/dev/null; done || true",
|
||||
);
|
||||
let _ = tokio::process::Command::new("sh")
|
||||
.args(["-c", &kill_listener])
|
||||
.output()
|
||||
.await;
|
||||
|
||||
let _ = tokio::process::Command::new("sudo")
|
||||
.args(["fuser", "-k", &format!("{}/tcp", port)])
|
||||
// sudo fuser -k, but exclude our own PID: fuser prints the PIDs holding
|
||||
// the port; kill each except self. (`fuser -k` has no exclusion flag.)
|
||||
let fuser_kill = format!(
|
||||
"for p in $(sudo fuser {port}/tcp 2>/dev/null); do [ \"$p\" = \"{self_pid}\" ] || sudo kill \"$p\" 2>/dev/null; done || true",
|
||||
);
|
||||
let _ = tokio::process::Command::new("sh")
|
||||
.args(["-c", &fuser_kill])
|
||||
.output()
|
||||
.await;
|
||||
|
||||
|
||||
@@ -4,6 +4,7 @@ mod dependencies;
|
||||
mod install;
|
||||
mod lifecycle;
|
||||
mod pine_ha;
|
||||
pub(crate) use pine_ha::wyoming_satellite_keeper;
|
||||
mod progress;
|
||||
mod runtime;
|
||||
mod set_config;
|
||||
|
||||
@@ -123,7 +123,11 @@ rest:
|
||||
- resource: http://host.containers.internal/api/pine/status
|
||||
headers:
|
||||
Authorization: "Bearer {token}"
|
||||
scan_interval: 30
|
||||
# 15s: the mesh-message sensor only carries the LATEST message id, so two
|
||||
# messages inside one poll window coalesce and only the newest announces
|
||||
# (observed on framework-pt 2026-07-22). Halving the window halves both
|
||||
# the announce lag and the coalescing odds; the endpoint is local + cheap.
|
||||
scan_interval: 15
|
||||
sensor:
|
||||
- name: "Archy Block Height"
|
||||
unique_id: archy_block_height
|
||||
@@ -337,7 +341,14 @@ const MESH_ANNOUNCE_AUTOMATION_ID: &str = "archy_mesh_announce";
|
||||
/// Announce new mesh messages on every Assist satellite. Only seeded into an
|
||||
/// empty/missing automations.yaml — merging into user-authored YAML risks
|
||||
/// mangling it, and anyone with existing automations can paste this one from
|
||||
/// the docs. Trigger conditions skip HA-restart transitions (unknown -> id).
|
||||
/// the docs. Trigger conditions skip HA-restart transitions (unknown -> id)
|
||||
/// but deliberately allow `None`/`unavailable` -> id: `None` is the sensor's
|
||||
/// state before the first-ever received message (and again whenever the mesh
|
||||
/// message store restarts empty), and `unavailable` follows any endpoint
|
||||
/// blip — blocking those swallowed the first DM a fresh node ever received
|
||||
/// (framework-pt, 2026-07-22). HA restarts still can't re-announce an old
|
||||
/// message: rest sensors don't restore state, so the post-restart transition
|
||||
/// is always from `unknown`.
|
||||
const MESH_ANNOUNCE_AUTOMATION: &str = r#"- id: archy_mesh_announce
|
||||
alias: Announce mesh messages on Pine
|
||||
description: Speak new mesh messages on the Pine speaker (seeded by Archipelago)
|
||||
@@ -348,7 +359,7 @@ const MESH_ANNOUNCE_AUTOMATION: &str = r#"- id: archy_mesh_announce
|
||||
- condition: template
|
||||
value_template: >-
|
||||
{{ trigger.from_state is not none
|
||||
and trigger.from_state.state not in ['unknown', 'unavailable', 'None']
|
||||
and trigger.from_state.state != 'unknown'
|
||||
and trigger.to_state.state not in ['unknown', 'unavailable', 'None']
|
||||
and trigger.from_state.state != trigger.to_state.state }}
|
||||
actions:
|
||||
@@ -371,6 +382,22 @@ async fn seed_mesh_announce_automation() -> bool {
|
||||
let path = std::path::Path::new(HA_CONFIG_DIR).join("automations.yaml");
|
||||
let existing = tokio::fs::read_to_string(&path).await.unwrap_or_default();
|
||||
if existing.contains(MESH_ANNOUNCE_AUTOMATION_ID) {
|
||||
// v1 of the seeded condition also blocked `None`/`unavailable` -> id
|
||||
// transitions, swallowing the first-ever received message. Upgrade by
|
||||
// replacing exactly that clause; anything else in the file (including
|
||||
// user-added automations) is left untouched.
|
||||
const V1_CLAUSE: &str =
|
||||
"trigger.from_state.state not in ['unknown', 'unavailable', 'None']";
|
||||
const V2_CLAUSE: &str = "trigger.from_state.state != 'unknown'";
|
||||
if existing.contains(V1_CLAUSE) {
|
||||
let upgraded = existing.replace(V1_CLAUSE, V2_CLAUSE);
|
||||
if let Err(e) = tokio::fs::write(&path, upgraded).await {
|
||||
warn!("pine/HA seed: upgrading automations.yaml failed: {e}");
|
||||
return false;
|
||||
}
|
||||
info!("pine/HA seed: mesh announce automation upgraded (first message announces)");
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
let trimmed = existing.trim();
|
||||
@@ -670,6 +697,193 @@ async fn seed_assist_pipeline(storage: &std::path::Path, claude_entity: Option<&
|
||||
false
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Wyoming satellite keeper
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/// Keep IP-pinned Wyoming satellite entries (voice speakers) reachable.
|
||||
///
|
||||
/// HA's zeroconf discovery stores a satellite as a fixed LAN IP. DHCP
|
||||
/// renumbering — or the whole node moving to a different network — strands
|
||||
/// the entry and the speaker silently drops (framework-pt 2026-07-23: entry
|
||||
/// pinned to 192.168.1.241 while the LAN had become 192.168.63.0/24). HA
|
||||
/// never re-resolves on its own. This keeper probes each satellite entry and,
|
||||
/// when one stops answering, sweeps the node's local /24s for the same
|
||||
/// Wyoming port and rewrites the entry to the address that answers.
|
||||
pub(crate) async fn wyoming_satellite_keeper() {
|
||||
loop {
|
||||
if home_assistant_installed().await {
|
||||
heal_wyoming_satellites().await;
|
||||
}
|
||||
tokio::time::sleep(std::time::Duration::from_secs(300)).await;
|
||||
}
|
||||
}
|
||||
|
||||
async fn tcp_alive(host: &str, port: u16, ms: u64) -> bool {
|
||||
matches!(
|
||||
tokio::time::timeout(
|
||||
std::time::Duration::from_millis(ms),
|
||||
tokio::net::TcpStream::connect((host, port)),
|
||||
)
|
||||
.await,
|
||||
Ok(Ok(_))
|
||||
)
|
||||
}
|
||||
|
||||
/// The node's own global IPv4 addresses. Loopback/CGNAT (tailscale) ranges are
|
||||
/// excluded — satellites live on real LANs.
|
||||
async fn local_ipv4_addresses() -> Vec<std::net::Ipv4Addr> {
|
||||
let Ok(out) = tokio::process::Command::new("ip")
|
||||
.args(["-4", "-o", "addr", "show", "scope", "global"])
|
||||
.output()
|
||||
.await
|
||||
else {
|
||||
return Vec::new();
|
||||
};
|
||||
String::from_utf8_lossy(&out.stdout)
|
||||
.lines()
|
||||
.filter_map(|l| {
|
||||
let cidr = l.split_whitespace().nth(3)?;
|
||||
let ip: std::net::Ipv4Addr = cidr.split('/').next()?.parse().ok()?;
|
||||
let o = ip.octets();
|
||||
// 100.64.0.0/10 — tailscale/CGNAT, never a speaker LAN.
|
||||
if o[0] == 100 && (64..128).contains(&o[1]) {
|
||||
return None;
|
||||
}
|
||||
Some(ip)
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// Sweep the /24 of every local interface for something answering `port`.
|
||||
/// First responder wins; the node's own addresses are skipped.
|
||||
async fn find_satellite(port: u16) -> Option<String> {
|
||||
let self_ips = local_ipv4_addresses().await;
|
||||
for base in self_ips.iter().map(|ip| ip.octets()) {
|
||||
let mut set = tokio::task::JoinSet::new();
|
||||
for i in 1..255u8 {
|
||||
let ip = std::net::Ipv4Addr::new(base[0], base[1], base[2], i);
|
||||
if self_ips.contains(&ip) {
|
||||
continue;
|
||||
}
|
||||
set.spawn(async move {
|
||||
tcp_alive(&ip.to_string(), port, 500)
|
||||
.await
|
||||
.then(|| ip.to_string())
|
||||
});
|
||||
}
|
||||
while let Some(res) = set.join_next().await {
|
||||
if let Ok(Some(ip)) = res {
|
||||
return Some(ip);
|
||||
}
|
||||
}
|
||||
}
|
||||
None
|
||||
}
|
||||
|
||||
/// One keeper pass: re-point dead IP-pinned wyoming entries at wherever their
|
||||
/// port now answers. Stops HA before editing the store (HA flushes its own
|
||||
/// in-memory copy on shutdown, which would clobber a live edit) and starts it
|
||||
/// again after.
|
||||
async fn heal_wyoming_satellites() {
|
||||
let path = std::path::Path::new(HA_STORAGE_DIR).join("core.config_entries");
|
||||
let Some(store) = read_store(&path).await else {
|
||||
return;
|
||||
};
|
||||
let Some(entries) = store
|
||||
.get("data")
|
||||
.and_then(|d| d.get("entries"))
|
||||
.and_then(|e| e.as_array())
|
||||
else {
|
||||
return;
|
||||
};
|
||||
|
||||
// (host, port, new_host) for every stranded satellite we can re-resolve.
|
||||
let mut moves: Vec<(String, u16, String)> = Vec::new();
|
||||
for e in entries {
|
||||
if e.get("domain").and_then(Value::as_str) != Some("wyoming") {
|
||||
continue;
|
||||
}
|
||||
let Some(host) = e
|
||||
.get("data")
|
||||
.and_then(|d| d.get("host"))
|
||||
.and_then(Value::as_str)
|
||||
else {
|
||||
continue;
|
||||
};
|
||||
// Engines use host.containers.internal — only IP-pinned entries drift.
|
||||
if host.parse::<std::net::Ipv4Addr>().is_err() {
|
||||
continue;
|
||||
}
|
||||
let port = e
|
||||
.get("data")
|
||||
.and_then(|d| d.get("port"))
|
||||
.and_then(Value::as_u64)
|
||||
.unwrap_or(0) as u16;
|
||||
if port == 0 || tcp_alive(host, port, 1500).await {
|
||||
continue;
|
||||
}
|
||||
let Some(new_host) = find_satellite(port).await else {
|
||||
info!("pine/HA keeper: satellite {host}:{port} unreachable and not found on any local /24 yet");
|
||||
continue;
|
||||
};
|
||||
if new_host != host {
|
||||
moves.push((host.to_string(), port, new_host));
|
||||
}
|
||||
}
|
||||
if moves.is_empty() {
|
||||
return;
|
||||
}
|
||||
|
||||
// Stop HA, re-read + rewrite the store, start HA.
|
||||
let _ = tokio::process::Command::new("podman")
|
||||
.args(["stop", "homeassistant"])
|
||||
.output()
|
||||
.await;
|
||||
if let Some(mut store) = read_store(&path).await {
|
||||
let mut changed = false;
|
||||
if let Some(entries) = store
|
||||
.get_mut("data")
|
||||
.and_then(|d| d.get_mut("entries"))
|
||||
.and_then(|e| e.as_array_mut())
|
||||
{
|
||||
for e in entries.iter_mut() {
|
||||
if e.get("domain").and_then(Value::as_str) != Some("wyoming") {
|
||||
continue;
|
||||
}
|
||||
let host = e
|
||||
.get("data")
|
||||
.and_then(|d| d.get("host"))
|
||||
.and_then(Value::as_str)
|
||||
.unwrap_or("")
|
||||
.to_string();
|
||||
let port = e
|
||||
.get("data")
|
||||
.and_then(|d| d.get("port"))
|
||||
.and_then(Value::as_u64)
|
||||
.unwrap_or(0) as u16;
|
||||
if let Some((_, _, new_host)) =
|
||||
moves.iter().find(|(h, p, _)| *h == host && *p == port)
|
||||
{
|
||||
if let Some(data) = e.get_mut("data") {
|
||||
data["host"] = json!(new_host);
|
||||
}
|
||||
e["modified_at"] = json!(ha_now());
|
||||
info!("pine/HA keeper: satellite moved {host}:{port} -> {new_host}:{port}");
|
||||
changed = true;
|
||||
}
|
||||
}
|
||||
}
|
||||
if changed {
|
||||
write_store(&path, &store).await;
|
||||
}
|
||||
}
|
||||
let _ = tokio::process::Command::new("podman")
|
||||
.args(["start", "homeassistant"])
|
||||
.output()
|
||||
.await;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Presence probes + HA restart
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
@@ -12,7 +12,11 @@ use tracing::info;
|
||||
|
||||
use super::install::{install_log, patch_indeedhub_nostr_provider};
|
||||
|
||||
const PODMAN_STACK_PROBE_TIMEOUT: Duration = Duration::from_secs(10);
|
||||
// 45s, not 10s: under heavy disk load (e.g. an ElectrumX resync/compaction
|
||||
// hammering the same SSD) a plain `podman ps` was observed taking >10s on
|
||||
// .116 (2026-07-22), failing a mempool install at the probe step for no real
|
||||
// reason. Podman being slow is not podman being dead.
|
||||
const PODMAN_STACK_PROBE_TIMEOUT: Duration = Duration::from_secs(45);
|
||||
const PODMAN_STACK_LOG_TIMEOUT: Duration = Duration::from_secs(15);
|
||||
const PODMAN_STACK_PULL_TIMEOUT: Duration = Duration::from_secs(600);
|
||||
const PODMAN_STACK_REMOVE_TIMEOUT: Duration = Duration::from_secs(90);
|
||||
|
||||
@@ -133,6 +133,7 @@ impl RpcHandler {
|
||||
Some(node_id.signing_key()),
|
||||
recipient_pubkey.as_deref(),
|
||||
node_name.as_deref(),
|
||||
Some(&self.config.data_dir),
|
||||
)
|
||||
.await?;
|
||||
Ok(serde_json::json!({ "ok": true, "sent_to": onion }))
|
||||
|
||||
@@ -56,12 +56,12 @@ pub(in crate::api::rpc) async fn save_pending_seed_encrypted(
|
||||
Ok(true)
|
||||
}
|
||||
|
||||
/// Best-effort: install fips.yaml + start archipelago-fips.service after the
|
||||
/// seed onboarding has written the fips_key to disk. Runs in a detached task
|
||||
/// so the user-facing RPC returns immediately — the systemctl calls can take
|
||||
/// a few seconds the first time on slow hardware. Any failure is logged but
|
||||
/// does not break onboarding; the user can still hit fips.install manually
|
||||
/// from the dashboard as an escape hatch.
|
||||
/// Best-effort: install fips.yaml + start the available FIPS systemd unit after
|
||||
/// seed onboarding has written the fips_key to disk. Runs in a detached task so
|
||||
/// the user-facing RPC returns immediately — the systemctl calls can take a few
|
||||
/// seconds the first time on slow hardware. Any failure is logged but does not
|
||||
/// break onboarding; the user can still hit fips.install manually from the
|
||||
/// dashboard as an escape hatch.
|
||||
fn spawn_post_onboarding_fips_activate(data_dir: std::path::PathBuf) {
|
||||
tokio::spawn(async move {
|
||||
let identity_dir = data_dir.join("identity");
|
||||
@@ -78,11 +78,12 @@ fn spawn_post_onboarding_fips_activate(data_dir: std::path::PathBuf) {
|
||||
tracing::warn!("post-onboarding fips config install failed: {}", e);
|
||||
return;
|
||||
}
|
||||
if let Err(e) = crate::fips::service::activate(crate::fips::SERVICE_UNIT).await {
|
||||
tracing::warn!("post-onboarding archipelago-fips activate failed: {}", e);
|
||||
let unit = crate::fips::service::activation_unit().await;
|
||||
if let Err(e) = crate::fips::service::activate(unit).await {
|
||||
tracing::warn!("post-onboarding FIPS activate failed via {}: {}", unit, e);
|
||||
return;
|
||||
}
|
||||
tracing::info!("archipelago-fips auto-activated post-onboarding");
|
||||
tracing::info!("FIPS auto-activated post-onboarding via {}", unit);
|
||||
});
|
||||
}
|
||||
|
||||
@@ -138,8 +139,8 @@ impl RpcHandler {
|
||||
// Initialize identity index at 0.
|
||||
crate::seed::save_identity_index(&self.config.data_dir, 0).await?;
|
||||
|
||||
// fips_key is now on disk — auto-activate archipelago-fips so the
|
||||
// user doesn't have to hit an "Activate" button. Detached task;
|
||||
// fips_key is now on disk — auto-activate FIPS so the user doesn't
|
||||
// have to hit a manual Start button. Detached task;
|
||||
// the onboarding RPC returns immediately.
|
||||
spawn_post_onboarding_fips_activate(self.config.data_dir.clone());
|
||||
|
||||
|
||||
@@ -61,6 +61,28 @@ impl RpcHandler {
|
||||
|
||||
info!("Server name updated to: {}", name);
|
||||
|
||||
// Propagate to the mesh: the listener advertises the server name (when
|
||||
// no explicit mesh advert_name overrides it), but it was only read at
|
||||
// process startup — a rename never reached the radio/RNS until the
|
||||
// next full restart. Push it into the service and bounce the listener
|
||||
// in the background (the restart re-probes the radio, which can take
|
||||
// seconds — don't block the rename response on it).
|
||||
{
|
||||
let mesh_arc = self.mesh_service_arc();
|
||||
let name_for_mesh = name.clone();
|
||||
tokio::spawn(async move {
|
||||
let mut guard = mesh_arc.write().await;
|
||||
if let Some(svc) = guard.as_mut() {
|
||||
svc.set_server_name(Some(name_for_mesh));
|
||||
if svc.config().advert_name.is_none() {
|
||||
if let Err(e) = svc.restart_listener_if_running().await {
|
||||
warn!("Mesh listener restart after rename failed: {}", e);
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// Push the new name to federation peers in background
|
||||
let data_dir = self.config.data_dir.clone();
|
||||
let state_manager = self.state_manager.clone();
|
||||
@@ -139,9 +161,17 @@ impl RpcHandler {
|
||||
.await
|
||||
.map(|s| s.trim().to_string())
|
||||
.unwrap_or_else(|_| "archipelago".to_string());
|
||||
// LAN IPv4 rides along for the companion pairing QR: when the
|
||||
// operator's browser reaches this node over Tailscale/VPN or
|
||||
// localhost, that origin is useless to a phone on the LAN — the QR
|
||||
// must advertise an address the phone can actually dial
|
||||
// (2026-07-22: a pairing QR carried a tailnet 100.x IP and the
|
||||
// companion could never connect).
|
||||
let lan_ip = crate::host_ip::primary_host_ipv4().await;
|
||||
Ok(serde_json::json!({
|
||||
"hostname": hostname,
|
||||
"mdns_hostname": format!("{hostname}.local"),
|
||||
"lan_ip": lan_ip,
|
||||
}))
|
||||
}
|
||||
|
||||
|
||||
@@ -498,7 +498,9 @@ pub(super) async fn notify_federation_peers_address_change(
|
||||
"/rpc/v1",
|
||||
)
|
||||
.service(crate::settings::transport::PeerService::Peers)
|
||||
.timeout(std::time::Duration::from_secs(30));
|
||||
.timeout(std::time::Duration::from_secs(30))
|
||||
.fips_timeout(std::time::Duration::from_secs(6))
|
||||
.record_transport(data_dir);
|
||||
match req.send_json(&payload).await {
|
||||
Ok((_, transport)) => {
|
||||
info!(peer_did = %peer.did, transport = %transport, "Notified peer of address change")
|
||||
|
||||
@@ -39,6 +39,28 @@ const KIOSK_LAUNCHER: &str =
|
||||
const KIOSK_SERVICE_PATH: &str = "/etc/systemd/system/archipelago-kiosk.service";
|
||||
const KIOSK_LAUNCHER_PATH: &str = "/usr/local/bin/archipelago-kiosk-launcher";
|
||||
|
||||
// HDMI audio (kiosk nodes): ISOs built before 2026-07-23 shipped no audio
|
||||
// stack at all even though the kiosk launcher expects PipeWire-Pulse, and the
|
||||
// kiosk's boot-time modeset can race the i915→HDA audio-component bind so the
|
||||
// HDMI ELD is lost and every HDMI profile stays unavailable (silent failure).
|
||||
// The router daemon handles routing + the ELD re-modeset nudge; this heal
|
||||
// installs the packages, group membership, script and unit on deployed nodes.
|
||||
const AUDIO_ROUTER: &str =
|
||||
include_str!("../../../image-recipe/configs/archipelago-audio-router.sh");
|
||||
const AUDIO_SERVICE: &str =
|
||||
include_str!("../../../image-recipe/configs/archipelago-audio-router.service");
|
||||
const AUDIO_ROUTER_PATH: &str = "/usr/local/bin/archipelago-audio-router";
|
||||
const AUDIO_SERVICE_PATH: &str = "/etc/systemd/system/archipelago-audio-router.service";
|
||||
|
||||
// Gamepad→keyboard bridge (TV input inside every app iframe) — same
|
||||
// splice-from-configs + self-heal pattern as the audio router.
|
||||
const GAMEPAD_KEYS: &str =
|
||||
include_str!("../../../image-recipe/configs/archipelago-gamepad-keys.py");
|
||||
const GAMEPAD_SERVICE: &str =
|
||||
include_str!("../../../image-recipe/configs/archipelago-gamepad-keys.service");
|
||||
const GAMEPAD_KEYS_PATH: &str = "/usr/local/bin/archipelago-gamepad-keys";
|
||||
const GAMEPAD_SERVICE_PATH: &str = "/etc/systemd/system/archipelago-gamepad-keys.service";
|
||||
|
||||
// Journald log-volume policy (size cap + per-service rate limit). Fresh ISOs
|
||||
// write the identical file at build time (image-recipe/_archived/
|
||||
// build-auto-installer-iso.sh); this heals already-deployed nodes via OTA.
|
||||
@@ -794,6 +816,137 @@ pub async fn ensure_kiosk_hardened() {
|
||||
}
|
||||
}
|
||||
|
||||
/// HDMI-audio self-heal for kiosk nodes: install the PipeWire stack (older
|
||||
/// ISOs shipped none), put the archipelago user in `audio` (PipeWire runs
|
||||
/// under the lingering user manager — no logind seat, so no udev ACLs on
|
||||
/// /dev/snd), and keep the audio-router daemon (HDMI routing + ELD boot-race
|
||||
/// nudge) installed and current. No-op on nodes without the kiosk — audio
|
||||
/// only matters where media plays on an attached display.
|
||||
pub async fn ensure_audio_stack() {
|
||||
if fs::metadata(KIOSK_SERVICE_PATH).await.is_err() {
|
||||
return; // no kiosk → no display audio to route
|
||||
}
|
||||
|
||||
// Package install runs via systemd-run (host_sudo), outside the service
|
||||
// sandbox — /usr and the dpkg database are read-only in our namespace.
|
||||
if fs::metadata("/usr/bin/pactl").await.is_err() {
|
||||
info!("audio: PipeWire stack missing — installing packages");
|
||||
let _ = host_sudo(&["apt-get", "update", "-qq"]).await;
|
||||
match host_sudo(&[
|
||||
"apt-get",
|
||||
"install",
|
||||
"-y",
|
||||
"-qq",
|
||||
"--no-install-recommends",
|
||||
"pipewire",
|
||||
"pipewire-pulse",
|
||||
"pipewire-alsa",
|
||||
"wireplumber",
|
||||
"alsa-utils",
|
||||
])
|
||||
.await
|
||||
{
|
||||
Ok(s) if s.success() => info!("audio: PipeWire stack installed"),
|
||||
Ok(s) => {
|
||||
warn!(
|
||||
"audio: package install exited with {} — will retry next start",
|
||||
s
|
||||
);
|
||||
return;
|
||||
}
|
||||
Err(e) => {
|
||||
warn!(
|
||||
"audio: package install failed: {:#} — will retry next start",
|
||||
e
|
||||
);
|
||||
return;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
let _ = host_sudo(&["usermod", "-aG", "audio", "archipelago"]).await;
|
||||
|
||||
let unit_was_missing = fs::metadata(AUDIO_SERVICE_PATH).await.is_err();
|
||||
let script_changed = write_root_if_needed(AUDIO_ROUTER_PATH, AUDIO_ROUTER)
|
||||
.await
|
||||
.unwrap_or(false);
|
||||
if script_changed {
|
||||
let _ = host_sudo(&["chmod", "+x", AUDIO_ROUTER_PATH]).await;
|
||||
}
|
||||
let unit_changed = write_root_if_needed(AUDIO_SERVICE_PATH, AUDIO_SERVICE)
|
||||
.await
|
||||
.unwrap_or(false);
|
||||
|
||||
if script_changed || unit_changed {
|
||||
if let Err(e) = host_sudo(&["systemctl", "daemon-reload"]).await {
|
||||
warn!("audio: daemon-reload failed: {:#}", e);
|
||||
}
|
||||
}
|
||||
if unit_was_missing {
|
||||
// First install on this node — bring it up now and on every boot.
|
||||
let _ = host_sudo(&[
|
||||
"systemctl",
|
||||
"enable",
|
||||
"--now",
|
||||
"archipelago-audio-router.service",
|
||||
])
|
||||
.await;
|
||||
info!("audio: router installed and enabled (HDMI routing + ELD heal)");
|
||||
} else if script_changed || unit_changed {
|
||||
// Content update: restart only if it's running — never re-enable a
|
||||
// unit an operator deliberately disabled.
|
||||
let _ = host_sudo(&[
|
||||
"systemctl",
|
||||
"try-restart",
|
||||
"archipelago-audio-router.service",
|
||||
])
|
||||
.await;
|
||||
info!("audio: router updated");
|
||||
}
|
||||
}
|
||||
|
||||
/// Gamepad→keyboard bridge self-heal for kiosk nodes: keeps the evdev→uinput
|
||||
/// daemon (controller works inside every app iframe on the TV) installed and
|
||||
/// current. Same gating as audio: no kiosk → no display input to bridge.
|
||||
pub async fn ensure_gamepad_keys() {
|
||||
if fs::metadata(KIOSK_SERVICE_PATH).await.is_err() {
|
||||
return;
|
||||
}
|
||||
let unit_was_missing = fs::metadata(GAMEPAD_SERVICE_PATH).await.is_err();
|
||||
let script_changed = write_root_if_needed(GAMEPAD_KEYS_PATH, GAMEPAD_KEYS)
|
||||
.await
|
||||
.unwrap_or(false);
|
||||
if script_changed {
|
||||
let _ = host_sudo(&["chmod", "+x", GAMEPAD_KEYS_PATH]).await;
|
||||
}
|
||||
let unit_changed = write_root_if_needed(GAMEPAD_SERVICE_PATH, GAMEPAD_SERVICE)
|
||||
.await
|
||||
.unwrap_or(false);
|
||||
if script_changed || unit_changed {
|
||||
if let Err(e) = host_sudo(&["systemctl", "daemon-reload"]).await {
|
||||
warn!("gamepad bridge: daemon-reload failed: {:#}", e);
|
||||
}
|
||||
}
|
||||
if unit_was_missing {
|
||||
let _ = host_sudo(&[
|
||||
"systemctl",
|
||||
"enable",
|
||||
"--now",
|
||||
"archipelago-gamepad-keys.service",
|
||||
])
|
||||
.await;
|
||||
info!("gamepad: bridge installed and enabled (TV controller input)");
|
||||
} else if script_changed || unit_changed {
|
||||
let _ = host_sudo(&[
|
||||
"systemctl",
|
||||
"try-restart",
|
||||
"archipelago-gamepad-keys.service",
|
||||
])
|
||||
.await;
|
||||
info!("gamepad: bridge updated");
|
||||
}
|
||||
}
|
||||
|
||||
/// Patch the nginx site config to add missing backend proxy blocks. Older ISO
|
||||
/// configs shipped individual per-endpoint `location` blocks, so missing
|
||||
/// endpoints silently fell through to the SPA `index.html` and the frontend
|
||||
@@ -866,6 +1019,13 @@ async fn patch_nginx_conf(path: &str) -> Result<bool> {
|
||||
// B13: fedimint block present but lacking the asset-rewrite sub_filters.
|
||||
let needs_fedimint_css = content.contains("location /app/fedimint/")
|
||||
&& !content.contains("'href=\"/' 'href=\"/app/fedimint/'");
|
||||
// Companion mesh access: phones reach this node over FIPS at its fips0
|
||||
// ULA (http://[fdxx:…]). Configs shipped before 2026-07-23 listened on
|
||||
// IPv4 only, so the ULA could never connect — nothing answered [::]:80.
|
||||
let missing_v6_http =
|
||||
content.contains("listen 80 default_server;") && !content.contains("listen [::]:80");
|
||||
let missing_v6_https =
|
||||
content.contains("listen 443 ssl default_server;") && !content.contains("listen [::]:443");
|
||||
if !missing_app_catalog
|
||||
&& !missing_bitcoin_status
|
||||
&& !missing_lnd_proxy
|
||||
@@ -873,12 +1033,27 @@ async fn patch_nginx_conf(path: &str) -> Result<bool> {
|
||||
&& !missing_pine_status
|
||||
&& !has_lnd_dup_cors
|
||||
&& !needs_fedimint_css
|
||||
&& !missing_v6_http
|
||||
&& !missing_v6_https
|
||||
{
|
||||
return Ok(false);
|
||||
}
|
||||
|
||||
let mut patched = content.clone();
|
||||
|
||||
if missing_v6_http {
|
||||
patched = patched.replace(
|
||||
"listen 80 default_server;",
|
||||
"listen 80 default_server;\n listen [::]:80 default_server;",
|
||||
);
|
||||
}
|
||||
if missing_v6_https {
|
||||
patched = patched.replace(
|
||||
"listen 443 ssl default_server;",
|
||||
"listen 443 ssl default_server;\n listen [::]:443 ssl default_server;",
|
||||
);
|
||||
}
|
||||
|
||||
if has_lnd_dup_cors {
|
||||
// Drop the redundant nginx-side CORS headers so the backend's single
|
||||
// validated Access-Control-Allow-Origin is the only one returned.
|
||||
|
||||
@@ -108,17 +108,32 @@ impl BootReconciler {
|
||||
let orchestrator = self.orchestrator.clone();
|
||||
let interval = self.interval;
|
||||
Some(tokio::spawn(async move {
|
||||
let mut failure_rounds: u32 = 0;
|
||||
loop {
|
||||
let installed = orchestrator.manifest_ids().await;
|
||||
for (companion, err) in crate::container::companion::reconcile(&installed).await
|
||||
{
|
||||
let failures = crate::container::companion::reconcile(&installed).await;
|
||||
for (companion, err) in &failures {
|
||||
tracing::warn!(
|
||||
companion = %companion,
|
||||
error = %err,
|
||||
"companion reconcile failed"
|
||||
);
|
||||
}
|
||||
time::sleep(interval).await;
|
||||
// A failed repair can involve registry pulls and full
|
||||
// image builds; retrying every 30s hammered unreachable
|
||||
// registries ~174×/image/day on an offline node
|
||||
// (archy-x250-dev log sweep, 2026-07-22). Back off
|
||||
// exponentially while rounds keep failing — 30s doubling
|
||||
// to a 1h cap — and reset the moment a round is clean.
|
||||
failure_rounds = if failures.is_empty() {
|
||||
0
|
||||
} else {
|
||||
failure_rounds.saturating_add(1)
|
||||
};
|
||||
let backoff = interval
|
||||
.saturating_mul(2u32.saturating_pow(failure_rounds.min(7)))
|
||||
.min(Duration::from_secs(3600));
|
||||
time::sleep(backoff).await;
|
||||
}
|
||||
}))
|
||||
} else {
|
||||
|
||||
@@ -22,8 +22,10 @@
|
||||
//! single declarative call.
|
||||
|
||||
use anyhow::{Context, Result};
|
||||
use std::collections::HashMap;
|
||||
use std::path::PathBuf;
|
||||
use std::time::Duration;
|
||||
use std::sync::{LazyLock, Mutex};
|
||||
use std::time::{Duration, Instant};
|
||||
use tokio::fs;
|
||||
use tokio::process::Command;
|
||||
use tracing::{info, warn};
|
||||
@@ -35,6 +37,15 @@ const COMPANION_REGISTRY: &str = "146.59.87.168:3000/lfg2025";
|
||||
const COMPANION_IMAGE_CHECK_TIMEOUT: Duration = Duration::from_secs(15);
|
||||
const COMPANION_BUILD_TIMEOUT: Duration = Duration::from_secs(900);
|
||||
const COMPANION_PULL_TIMEOUT: Duration = Duration::from_secs(300);
|
||||
/// After a failed repair (image build/pull included), leave the companion
|
||||
/// alone for this long. Without it, a node under IO pressure retried a 900s
|
||||
/// image build every 30s reconcile tick — each build pegging the disk that
|
||||
/// made the probes fail in the first place (live-diagnosed on zaza-optiplex
|
||||
/// 2026-07-28: load 50, podman scans starved, apps page stuck).
|
||||
const REPAIR_COOLDOWN: Duration = Duration::from_secs(600);
|
||||
|
||||
static REPAIR_FAILED_AT: LazyLock<Mutex<HashMap<&'static str, Instant>>> =
|
||||
LazyLock::new(|| Mutex::new(HashMap::new()));
|
||||
|
||||
/// Static description of one companion. The full list per backend
|
||||
/// app_id lives in `companions_for`.
|
||||
@@ -464,12 +475,28 @@ pub async fn reconcile(installed_apps: &[String]) -> Vec<(String, anyhow::Error)
|
||||
match needs_repair(spec).await {
|
||||
Ok(false) => {}
|
||||
Ok(true) => {
|
||||
if let Some(failed_at) =
|
||||
REPAIR_FAILED_AT.lock().unwrap().get(spec.name).copied()
|
||||
{
|
||||
if failed_at.elapsed() < REPAIR_COOLDOWN {
|
||||
continue;
|
||||
}
|
||||
}
|
||||
info!(
|
||||
companion = spec.name,
|
||||
"reconcile: companion not active, repairing"
|
||||
);
|
||||
if let Err(e) = install_one(spec).await {
|
||||
failures.push((spec.name.to_string(), e));
|
||||
match install_one(spec).await {
|
||||
Ok(()) => {
|
||||
REPAIR_FAILED_AT.lock().unwrap().remove(spec.name);
|
||||
}
|
||||
Err(e) => {
|
||||
REPAIR_FAILED_AT
|
||||
.lock()
|
||||
.unwrap()
|
||||
.insert(spec.name, Instant::now());
|
||||
failures.push((spec.name.to_string(), e));
|
||||
}
|
||||
}
|
||||
}
|
||||
Err(e) => {
|
||||
@@ -484,19 +511,58 @@ pub async fn reconcile(installed_apps: &[String]) -> Vec<(String, anyhow::Error)
|
||||
|
||||
/// Does this companion need install_one to be re-run? Returns true if
|
||||
/// the unit file is missing, stale, or the service is not active.
|
||||
///
|
||||
/// This probe runs every reconcile tick for every companion, so it must be
|
||||
/// PASSIVE: no image builds, no pulls. It used to call ensure_image_present
|
||||
/// to render the expected unit — under IO pressure the image-existence check
|
||||
/// inside timed out, read as "image missing", and a 900s `podman build` ran
|
||||
/// inside the probe even though the companion was up (the .198 load spiral).
|
||||
async fn needs_repair(spec: &CompanionSpec) -> Result<bool> {
|
||||
let dir = quadlet::unit_dir().await?;
|
||||
let unit_path = dir.join(format!("{}.container", spec.name));
|
||||
if !fs::try_exists(&unit_path).await.unwrap_or(false) {
|
||||
return Ok(true);
|
||||
}
|
||||
let expected_image = ensure_image_present(spec).await?;
|
||||
let expected_unit = build_unit(spec, &expected_image);
|
||||
if expected_unit.render() != fs::read_to_string(&unit_path).await.unwrap_or_default() {
|
||||
let svc = format!("{}.service", spec.name);
|
||||
// A hung `systemctl is-active` under IO pressure must not read as
|
||||
// "companion dead" — that's a repair (and possibly an image build) fired
|
||||
// off exactly when the node can least afford one.
|
||||
match tokio::time::timeout(Duration::from_secs(10), quadlet::is_active(&svc)).await {
|
||||
Ok(active) => {
|
||||
if !active {
|
||||
return Ok(true);
|
||||
}
|
||||
}
|
||||
Err(_) => {
|
||||
warn!(
|
||||
companion = spec.name,
|
||||
"is-active probe timed out; assuming active"
|
||||
);
|
||||
}
|
||||
}
|
||||
// Service is running. Flag it stale only on definitive, cheap signals:
|
||||
// the on-disk unit matching none of the image refs install_one could
|
||||
// have written, or a local build context newer than the built image.
|
||||
let on_disk = fs::read_to_string(&unit_path).await.unwrap_or_default();
|
||||
let local_image = format!("localhost/{}:latest", spec.image_base);
|
||||
let local_image_compat = format!("localhost/{}:local", spec.image_base);
|
||||
let registry_image = format!("{}/{}:latest", COMPANION_REGISTRY, spec.image_base);
|
||||
let matches_known_shape = [&local_image, &local_image_compat, ®istry_image]
|
||||
.iter()
|
||||
.any(|img| build_unit(spec, img).render() == on_disk);
|
||||
if !matches_known_shape {
|
||||
return Ok(true);
|
||||
}
|
||||
let svc = format!("{}.service", spec.name);
|
||||
Ok(!quadlet::is_active(&svc).await)
|
||||
if on_disk.contains(&local_image) && !on_disk.contains(&local_image_compat) {
|
||||
for dir in spec.build_dir_candidates {
|
||||
let dockerfile = PathBuf::from(dir).join("Dockerfile");
|
||||
if fs::try_exists(&dockerfile).await.unwrap_or(false) {
|
||||
// Conservative on any timeout/error inside: reuse the cache.
|
||||
return Ok(context_is_newer_than_image(dir, &local_image).await);
|
||||
}
|
||||
}
|
||||
}
|
||||
Ok(false)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
|
||||
@@ -3094,8 +3094,9 @@ impl ProdContainerOrchestrator {
|
||||
.unwrap_or_else(|| "bitcoin-knots".to_string());
|
||||
}
|
||||
#[allow(unreachable_code)]
|
||||
// Mirrors api::rpc::package::dependencies (the legacy install path);
|
||||
// both Bitcoin node variants are reachable on archy-net by name.
|
||||
// The known Bitcoin node containers, preferred in order. Any archy
|
||||
// Bitcoin distribution runs as a container named `bitcoin-<distro>`
|
||||
// (or bare `bitcoin`), all reachable on archy-net by name.
|
||||
const BITCOIN_NAMES: &[&str] = &["bitcoin-knots", "bitcoin-core", "bitcoin"];
|
||||
let names = tokio::process::Command::new("podman")
|
||||
.args(["ps", "--format", "{{.Names}}"])
|
||||
@@ -3105,12 +3106,23 @@ impl ProdContainerOrchestrator {
|
||||
.filter(|o| o.status.success())
|
||||
.map(|o| String::from_utf8_lossy(&o.stdout).into_owned())
|
||||
.unwrap_or_default();
|
||||
names
|
||||
.lines()
|
||||
.map(|l| l.trim())
|
||||
.find(|name| BITCOIN_NAMES.contains(name))
|
||||
.map(|name| name.to_string())
|
||||
.unwrap_or_else(|| "bitcoin-knots".to_string())
|
||||
let running: Vec<&str> = names.lines().map(|l| l.trim()).collect();
|
||||
// Prefer a known name in priority order…
|
||||
if let Some(hit) = BITCOIN_NAMES.iter().find(|n| running.contains(n)) {
|
||||
return hit.to_string();
|
||||
}
|
||||
// …else accept ANY running `bitcoin-*` / `bitcoin` container, so a
|
||||
// future Bitcoin distribution archy ships works without editing this
|
||||
// list (user req 2026-07-22). Excludes companions/sidecars like
|
||||
// `bitcoin-ui` and `archy-*`.
|
||||
if let Some(other) = running.iter().find(|n| {
|
||||
(**n == "bitcoin" || n.starts_with("bitcoin-"))
|
||||
&& !n.ends_with("-ui")
|
||||
&& !n.starts_with("archy-")
|
||||
}) {
|
||||
return other.to_string();
|
||||
}
|
||||
"bitcoin-knots".to_string()
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
@@ -3247,10 +3259,10 @@ impl ProdContainerOrchestrator {
|
||||
let mut env = manifest.app.environment.clone();
|
||||
env.extend(manifest.app.container.resolve_derived_env(&facts));
|
||||
|
||||
if manifest.app.id == "fedimint" || manifest.app.id == "fedimintd" {
|
||||
env.retain(|entry| !entry.starts_with("FM_BITCOIND_URL="));
|
||||
env.push("FM_BITCOIND_URL=http://bitcoin-knots:8332".to_string());
|
||||
}
|
||||
// FM_BITCOIND_URL now comes from the manifest's {{BITCOIN_HOST}}
|
||||
// derived_env (works on Knots/Core/any distro). The old hardcoded
|
||||
// `bitcoin-knots` override was removed 2026-07-22 — it defeated the
|
||||
// derived-env and broke Core nodes.
|
||||
|
||||
let provider = FileSecretsProvider {
|
||||
root: self.secrets_dir.clone(),
|
||||
|
||||
@@ -248,10 +248,17 @@ impl QuadletUnit {
|
||||
} else {
|
||||
proto.as_str()
|
||||
};
|
||||
// Keep the rendered directive byte-identical for unbound
|
||||
// ports so existing units don't read as drifted.
|
||||
// Unbound publishes are pinned to 0.0.0.0: rootlessport's
|
||||
// wildcard bind claims [::] too but BLACK-HOLES inbound v6
|
||||
// (accepts, forwards nothing — "empty response" from the
|
||||
// mesh, confirmed 2026-07-26). Pinning v4 frees the port's
|
||||
// v6 side for the daemon's mesh relay (app_port_v6_relay_loop),
|
||||
// which forwards to the v4 loopback listener that works.
|
||||
// NOTE: this changes the rendered directive for unbound
|
||||
// ports on purpose — the one-time drift/recreate at upgrade
|
||||
// is the deploy vehicle for the fix.
|
||||
if bind.is_empty() {
|
||||
let _ = writeln!(s, "PublishPort={host}:{container}/{p}");
|
||||
let _ = writeln!(s, "PublishPort=0.0.0.0:{host}:{container}/{p}");
|
||||
} else {
|
||||
let _ = writeln!(s, "PublishPort={bind}:{host}:{container}/{p}");
|
||||
}
|
||||
@@ -1018,7 +1025,7 @@ mod tests {
|
||||
u.network = NetworkMode::Bridge("archy-net".into());
|
||||
u.ports = vec![(3000, 3000, "tcp".into(), String::new())];
|
||||
let s = u.render();
|
||||
assert!(s.contains("PublishPort=3000:3000/tcp"));
|
||||
assert!(s.contains("PublishPort=0.0.0.0:3000:3000/tcp"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
@@ -1167,8 +1174,8 @@ mod tests {
|
||||
let s = u.render();
|
||||
assert!(s.contains("PublishPort=127.0.0.1:8332:8332/tcp"));
|
||||
assert!(s.contains("PublishPort=10.89.0.1:8332:8332/tcp"));
|
||||
assert!(s.contains("PublishPort=8333:8333/tcp"));
|
||||
assert!(!s.contains("PublishPort=8332:8332/tcp"));
|
||||
assert!(s.contains("PublishPort=0.0.0.0:8333:8333/tcp"));
|
||||
assert!(!s.contains("PublishPort=0.0.0.0:8332:8332/tcp"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
@@ -1200,8 +1207,8 @@ mod tests {
|
||||
..QuadletUnit::default()
|
||||
};
|
||||
let s = u.render();
|
||||
assert!(s.contains("PublishPort=8332:8332/tcp"));
|
||||
assert!(s.contains("PublishPort=8333:8333/tcp"));
|
||||
assert!(s.contains("PublishPort=0.0.0.0:8332:8332/tcp"));
|
||||
assert!(s.contains("PublishPort=0.0.0.0:8333:8333/tcp"));
|
||||
assert!(s.contains("Environment=BITCOIN_RPC_USER=archipelago"));
|
||||
assert!(s.contains("Environment=BITCOIN_RPC_PASS=secret"));
|
||||
assert!(s.contains("Environment=\"RELAY_NAME=Archipelago Nostr Relay\""));
|
||||
@@ -1317,7 +1324,7 @@ app:
|
||||
let m = AppManifest::parse(yaml).expect("manifest must parse");
|
||||
let s = QuadletUnit::from_manifest(&m, "searxng").render();
|
||||
|
||||
assert!(s.contains("PublishPort=8888:8080/tcp"));
|
||||
assert!(s.contains("PublishPort=0.0.0.0:8888:8080/tcp"));
|
||||
assert!(!s.contains("Network=host"));
|
||||
}
|
||||
|
||||
@@ -1746,7 +1753,7 @@ app:
|
||||
assert!(body.contains("Network=archy-net"));
|
||||
assert!(body.contains("NetworkAlias=lnd"));
|
||||
assert!(body.contains("PodmanArgs=--network-alias=lnd"));
|
||||
assert!(body.contains("PublishPort=10009:10009/tcp"));
|
||||
assert!(body.contains("PublishPort=0.0.0.0:10009:10009/tcp"));
|
||||
assert!(body.contains("Volume=/var/lib/archipelago/lnd:/root/.lnd:Z"));
|
||||
assert!(body.contains("Environment=LND_NETWORK=mainnet"));
|
||||
assert!(body.contains("PodmanArgs=--memory=1024m"));
|
||||
|
||||
@@ -51,9 +51,25 @@ pub enum AccessControl {
|
||||
PeersOnly,
|
||||
Paid {
|
||||
price_sats: u64,
|
||||
/// Payment methods the sharer accepts: "lightning", "onchain",
|
||||
/// "ecash", "fedimint". Empty = everything — which is also what
|
||||
/// catalogs written before this field deserialize to.
|
||||
#[serde(default, skip_serializing_if = "Vec::is_empty")]
|
||||
accepted: Vec<String>,
|
||||
},
|
||||
}
|
||||
|
||||
/// Does the sharer accept this payment method for the item? Empty list =
|
||||
/// all methods (pre-field catalogs and "no preference").
|
||||
pub fn method_accepted(access: &AccessControl, method: &str) -> bool {
|
||||
match access {
|
||||
AccessControl::Paid { accepted, .. } => {
|
||||
accepted.is_empty() || accepted.iter().any(|m| m == method)
|
||||
}
|
||||
_ => true,
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Default, Serialize, Deserialize)]
|
||||
pub struct ContentCatalog {
|
||||
pub items: Vec<ContentItem>,
|
||||
@@ -126,12 +142,29 @@ pub fn content_file_path(data_dir: &Path, item: &ContentItem) -> PathBuf {
|
||||
}
|
||||
|
||||
/// Add a content item to the catalog.
|
||||
///
|
||||
/// Idempotent per FILE, not just per id: `content.add` mints a fresh UUID on
|
||||
/// every call, so id-only dedup let the same file be shared twice as two
|
||||
/// separately-priced entries — and a buyer paid twice for one file
|
||||
/// (2026-07-22). Same filename → update the existing entry in place and
|
||||
/// keep its id, so existing buyers' owned records stay valid.
|
||||
pub async fn add_item(data_dir: &Path, item: ContentItem) -> Result<ContentCatalog> {
|
||||
let mut catalog = load_catalog(data_dir).await?;
|
||||
if catalog.items.iter().any(|i| i.id == item.id) {
|
||||
return Err(anyhow::anyhow!("Content item '{}' already exists", item.id));
|
||||
}
|
||||
catalog.items.push(item);
|
||||
let norm = |f: &str| f.trim_start_matches('/').to_string();
|
||||
if let Some(existing) = catalog
|
||||
.items
|
||||
.iter_mut()
|
||||
.find(|i| norm(&i.filename) == norm(&item.filename))
|
||||
{
|
||||
let keep_id = existing.id.clone();
|
||||
*existing = item;
|
||||
existing.id = keep_id;
|
||||
} else {
|
||||
catalog.items.push(item);
|
||||
}
|
||||
save_catalog(data_dir, &catalog).await?;
|
||||
Ok(catalog)
|
||||
}
|
||||
@@ -252,20 +285,26 @@ pub async fn serve_content(
|
||||
|
||||
// Check access control
|
||||
match &item.access {
|
||||
AccessControl::Paid { price_sats } => {
|
||||
AccessControl::Paid { price_sats, .. } => {
|
||||
// Two ways to satisfy payment:
|
||||
// (a) a valid ecash token (the local-wallet fast path), or
|
||||
// (b) a Lightning-invoice payment hash this node issued and has
|
||||
// since confirmed settled (the "pay from any wallet" path, #46).
|
||||
// Each path only counts when the sharer accepts that method.
|
||||
let mut authorized = false;
|
||||
if let Some(token) = payment_token {
|
||||
if verify_payment_token(data_dir, token, *price_sats).await {
|
||||
if (method_accepted(&item.access, "ecash")
|
||||
|| method_accepted(&item.access, "fedimint"))
|
||||
&& verify_payment_token(data_dir, token, *price_sats).await
|
||||
{
|
||||
authorized = true;
|
||||
}
|
||||
}
|
||||
if !authorized {
|
||||
if let Some(hash) = invoice_hash {
|
||||
if crate::content_invoice::is_paid_for(hash, id).await {
|
||||
if method_accepted(&item.access, "lightning")
|
||||
&& crate::content_invoice::is_paid_for(hash, id).await
|
||||
{
|
||||
authorized = true;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -403,6 +403,14 @@ pub async fn recover_containers(containers: &[RunningContainerRecord]) -> Recove
|
||||
pending_boot_starts_add(containers.iter().map(|r| r.name.clone()));
|
||||
|
||||
for (i, record) in containers.iter().enumerate() {
|
||||
// Skip containers that are already up — `podman start` on a running
|
||||
// container produces the noisy benign conmon "Failed to create
|
||||
// container" + cgroup Permission-denied journal pair (fleet log
|
||||
// sweep 2026-07-22).
|
||||
if container_state(&record.name).await.as_deref() == Some("running") {
|
||||
report.recovered += 1;
|
||||
continue;
|
||||
}
|
||||
info!(
|
||||
"Recovering container: {} (image: {})",
|
||||
record.name, record.image
|
||||
@@ -936,14 +944,21 @@ async fn container_state(container: &str) -> Option<String> {
|
||||
}
|
||||
|
||||
async fn start_existing_container(container: &str) -> bool {
|
||||
info!("Recovering stack container: {}", container);
|
||||
let timeout = match container {
|
||||
"immich_server" | "netbird-server" => Duration::from_secs(120),
|
||||
_ => Duration::from_secs(90),
|
||||
};
|
||||
if container_state(container).await.as_deref() == Some("initialized") {
|
||||
cleanup_container_runtime_state(container).await;
|
||||
match container_state(container).await.as_deref() {
|
||||
// Already up — `podman start` on a running container makes conmon
|
||||
// try to join the live cgroup and fail with a scary "Failed to
|
||||
// create container: exit status 1" + cgroup.procs Permission denied
|
||||
// pair in the journal. Fleet log sweep 2026-07-22 found hundreds of
|
||||
// these per day per node, all benign. Skip instead.
|
||||
Some("running") => return true,
|
||||
Some("initialized") => cleanup_container_runtime_state(container).await,
|
||||
_ => {}
|
||||
}
|
||||
info!("Recovering stack container: {}", container);
|
||||
match podman_output(&["start", container], timeout).await {
|
||||
Ok(output) if output.status.success() => {
|
||||
tokio::time::sleep(Duration::from_secs(3)).await;
|
||||
|
||||
@@ -0,0 +1,131 @@
|
||||
//! Companion device tokens — long-lived bearer credentials minted from an
|
||||
//! authenticated session, so the pairing QR can log a phone in without
|
||||
//! carrying the admin password (which the browser never has anyway).
|
||||
//!
|
||||
//! Only the SHA-256 of each token is persisted (`device-tokens.json` in the
|
||||
//! data dir); the plaintext is returned exactly once at mint time and rides
|
||||
//! the QR as the `tok` param. Verification goes through `auth.login`'s
|
||||
//! `token` param and is covered by the same login rate limiter as passwords.
|
||||
|
||||
use anyhow::{Context, Result};
|
||||
use serde::{Deserialize, Serialize};
|
||||
use sha2::{Digest, Sha256};
|
||||
use std::path::{Path, PathBuf};
|
||||
use tokio::fs;
|
||||
|
||||
const TOKENS_FILE: &str = "device-tokens.json";
|
||||
|
||||
/// Cap on stored tokens; re-pairing the same device name replaces its entry,
|
||||
/// so this only limits the number of *distinct* device names.
|
||||
const MAX_TOKENS: usize = 32;
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct DeviceToken {
|
||||
pub name: String,
|
||||
/// Hex SHA-256 of the plaintext token.
|
||||
pub hash: String,
|
||||
/// Unix seconds at mint time.
|
||||
pub created: u64,
|
||||
}
|
||||
|
||||
fn tokens_path(data_dir: &Path) -> PathBuf {
|
||||
data_dir.join(TOKENS_FILE)
|
||||
}
|
||||
|
||||
async fn load(data_dir: &Path) -> Vec<DeviceToken> {
|
||||
match fs::read(tokens_path(data_dir)).await {
|
||||
Ok(bytes) => serde_json::from_slice(&bytes).unwrap_or_default(),
|
||||
Err(_) => Vec::new(),
|
||||
}
|
||||
}
|
||||
|
||||
async fn save(data_dir: &Path, tokens: &[DeviceToken]) -> Result<()> {
|
||||
let bytes = serde_json::to_vec_pretty(tokens)?;
|
||||
fs::write(tokens_path(data_dir), bytes)
|
||||
.await
|
||||
.context("write device-tokens.json")
|
||||
}
|
||||
|
||||
fn hash_hex(token: &str) -> String {
|
||||
hex::encode(Sha256::digest(token.as_bytes()))
|
||||
}
|
||||
|
||||
fn ct_eq(a: &[u8], b: &[u8]) -> bool {
|
||||
if a.len() != b.len() {
|
||||
return false;
|
||||
}
|
||||
a.iter().zip(b).fold(0u8, |acc, (x, y)| acc | (x ^ y)) == 0
|
||||
}
|
||||
|
||||
/// Mint a new token for `name`. An existing token with the same name is
|
||||
/// replaced, so re-showing the pairing QR never piles up stale entries.
|
||||
/// Returns the plaintext token — the only time it ever exists outside the QR.
|
||||
pub async fn create(data_dir: &Path, name: &str) -> Result<String> {
|
||||
let token_bytes: [u8; 32] = rand::random();
|
||||
let token = hex::encode(token_bytes);
|
||||
|
||||
let mut tokens = load(data_dir).await;
|
||||
tokens.retain(|t| t.name != name);
|
||||
if tokens.len() >= MAX_TOKENS {
|
||||
tokens.remove(0);
|
||||
}
|
||||
tokens.push(DeviceToken {
|
||||
name: name.to_string(),
|
||||
hash: hash_hex(&token),
|
||||
created: std::time::SystemTime::now()
|
||||
.duration_since(std::time::UNIX_EPOCH)
|
||||
.map(|d| d.as_secs())
|
||||
.unwrap_or(0),
|
||||
});
|
||||
save(data_dir, &tokens).await?;
|
||||
Ok(token)
|
||||
}
|
||||
|
||||
/// Verify a candidate token. Returns the device name it was minted for.
|
||||
pub async fn verify(data_dir: &Path, candidate: &str) -> Option<String> {
|
||||
let candidate_hash = hash_hex(candidate);
|
||||
load(data_dir)
|
||||
.await
|
||||
.iter()
|
||||
.find(|t| ct_eq(t.hash.as_bytes(), candidate_hash.as_bytes()))
|
||||
.map(|t| t.name.clone())
|
||||
}
|
||||
|
||||
/// List stored tokens (hashes only — plaintexts are unrecoverable).
|
||||
pub async fn list(data_dir: &Path) -> Vec<DeviceToken> {
|
||||
load(data_dir).await
|
||||
}
|
||||
|
||||
/// Remove the token minted for `name`. Returns whether one existed.
|
||||
pub async fn remove(data_dir: &Path, name: &str) -> Result<bool> {
|
||||
let mut tokens = load(data_dir).await;
|
||||
let before = tokens.len();
|
||||
tokens.retain(|t| t.name != name);
|
||||
let removed = tokens.len() != before;
|
||||
if removed {
|
||||
save(data_dir, &tokens).await?;
|
||||
}
|
||||
Ok(removed)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[tokio::test]
|
||||
async fn mint_verify_replace_remove() {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
let token = create(dir.path(), "phone").await.unwrap();
|
||||
assert_eq!(verify(dir.path(), &token).await.as_deref(), Some("phone"));
|
||||
assert!(verify(dir.path(), "not-a-token").await.is_none());
|
||||
|
||||
// Re-minting the same name invalidates the old token.
|
||||
let token2 = create(dir.path(), "phone").await.unwrap();
|
||||
assert!(verify(dir.path(), &token).await.is_none());
|
||||
assert_eq!(verify(dir.path(), &token2).await.as_deref(), Some("phone"));
|
||||
assert_eq!(list(dir.path()).await.len(), 1);
|
||||
|
||||
assert!(remove(dir.path(), "phone").await.unwrap());
|
||||
assert!(verify(dir.path(), &token2).await.is_none());
|
||||
}
|
||||
}
|
||||
@@ -81,6 +81,29 @@ pub fn archy_anchor() -> SeedAnchor {
|
||||
}
|
||||
}
|
||||
|
||||
/// Public FIPS-network anchors from join.fips.network (adverts published as
|
||||
/// Nostr kind-37195 `fips-overlay-v1` events, refreshed hourly). Of the eight
|
||||
/// live test anchors (2026-07-22), these two are the dual-transport ones —
|
||||
/// they answer on TCP as well as UDP, and TCP is what traverses restrictive
|
||||
/// networks. The remaining six are UDP-only; add them per-node via
|
||||
/// `fips.add-seed-anchor` if more rendezvous diversity is wanted.
|
||||
pub fn fips_network_anchors() -> Vec<SeedAnchor> {
|
||||
vec![
|
||||
SeedAnchor {
|
||||
npub: "npub10yffd020a4ag8zcy75f9pruq3rnghvvhd5hphl9s62zgp35s560qrksp9u".to_string(),
|
||||
address: "23.182.128.74:443".to_string(),
|
||||
transport: "tcp".to_string(),
|
||||
label: "FIPS network anchor (join.fips.network)".to_string(),
|
||||
},
|
||||
SeedAnchor {
|
||||
npub: "npub1qmc3cvfz0yu2hx96nq3gp55zdan2qclealn7xshgr448d3nh6lks7zel98".to_string(),
|
||||
address: "217.77.8.91:443".to_string(),
|
||||
transport: "tcp".to_string(),
|
||||
label: "FIPS network anchor (join.fips.network)".to_string(),
|
||||
},
|
||||
]
|
||||
}
|
||||
|
||||
/// The default anchor set carried implicitly by `load()` on nodes that have
|
||||
/// never edited their anchor list, so every node dials them without operator
|
||||
/// action. Multiple anchors so one unreachable rendezvous host can't strand a
|
||||
@@ -88,7 +111,9 @@ pub fn archy_anchor() -> SeedAnchor {
|
||||
/// network can reach wins. The Archipelago-operated anchor is listed first
|
||||
/// because it is reachable from the widest set of networks.
|
||||
pub fn default_public_anchors() -> Vec<SeedAnchor> {
|
||||
vec![archy_anchor(), default_public_anchor()]
|
||||
let mut anchors = vec![archy_anchor(), default_public_anchor()];
|
||||
anchors.extend(fips_network_anchors());
|
||||
anchors
|
||||
}
|
||||
|
||||
/// One seed-anchor entry. `address` must be directly dialable (IP or
|
||||
@@ -131,7 +156,24 @@ pub async fn load(data_dir: &Path) -> Result<Vec<SeedAnchor>> {
|
||||
.with_context(|| format!("read {}", path.display()))?;
|
||||
let anchors: Vec<SeedAnchor> =
|
||||
serde_json::from_slice(&bytes).with_context(|| format!("parse {}", path.display()))?;
|
||||
Ok(anchors)
|
||||
Ok(repair_legacy_anchor_set(anchors))
|
||||
}
|
||||
|
||||
/// Add the newer redundant public TCP anchors to legacy files that only carry
|
||||
/// the Archipelago-operated vps2 anchor. A deliberately custom/private anchor
|
||||
/// list remains authoritative; this only repairs the exact stale shape shipped
|
||||
/// before the join.fips.network anchors became defaults.
|
||||
fn repair_legacy_anchor_set(mut anchors: Vec<SeedAnchor>) -> Vec<SeedAnchor> {
|
||||
let has_archy = anchors.iter().any(|a| a.npub == ARCHY_ANCHOR_NPUB);
|
||||
let has_fips_network = fips_network_anchors()
|
||||
.iter()
|
||||
.any(|default| anchors.iter().any(|a| a.npub == default.npub));
|
||||
if has_archy && !has_fips_network {
|
||||
for anchor in fips_network_anchors() {
|
||||
anchors.push(anchor);
|
||||
}
|
||||
}
|
||||
anchors
|
||||
}
|
||||
|
||||
/// Persist the list. Overwrites atomically via write-then-rename so a
|
||||
@@ -190,26 +232,32 @@ pub async fn remove(data_dir: &Path, npub: &str) -> Result<Vec<SeedAnchor>> {
|
||||
/// leaving `anchor_connected=false` and every peer dial falling back to
|
||||
/// a slow Tor timeout.
|
||||
pub async fn apply(anchors: &[SeedAnchor]) -> Vec<ApplyResult> {
|
||||
let mut results = Vec::with_capacity(anchors.len());
|
||||
for anchor in anchors {
|
||||
let out = Command::new("sudo")
|
||||
.args([
|
||||
"-n",
|
||||
"fipsctl",
|
||||
"connect",
|
||||
&anchor.npub,
|
||||
&anchor.address,
|
||||
&anchor.transport,
|
||||
])
|
||||
.output()
|
||||
.await;
|
||||
// Concurrent, each connect hard-capped: the old serial loop waited
|
||||
// unbounded on every `sudo fipsctl connect`, so one hung subprocess
|
||||
// stalled the whole apply — and the periodic anchor tick behind it,
|
||||
// which is exactly when a wedged daemon most needs the re-apply.
|
||||
let futs = anchors.iter().cloned().map(|anchor| async move {
|
||||
let out = tokio::time::timeout(
|
||||
std::time::Duration::from_secs(15),
|
||||
Command::new("sudo")
|
||||
.args([
|
||||
"-n",
|
||||
"fipsctl",
|
||||
"connect",
|
||||
&anchor.npub,
|
||||
&anchor.address,
|
||||
&anchor.transport,
|
||||
])
|
||||
.output(),
|
||||
)
|
||||
.await;
|
||||
let result = match out {
|
||||
Ok(o) if o.status.success() => ApplyResult {
|
||||
Ok(Ok(o)) if o.status.success() => ApplyResult {
|
||||
npub: anchor.npub.clone(),
|
||||
ok: true,
|
||||
message: String::from_utf8_lossy(&o.stdout).trim().to_string(),
|
||||
},
|
||||
Ok(o) => ApplyResult {
|
||||
Ok(Ok(o)) => ApplyResult {
|
||||
npub: anchor.npub.clone(),
|
||||
ok: false,
|
||||
message: format!(
|
||||
@@ -218,11 +266,16 @@ pub async fn apply(anchors: &[SeedAnchor]) -> Vec<ApplyResult> {
|
||||
String::from_utf8_lossy(&o.stderr).trim()
|
||||
),
|
||||
},
|
||||
Err(e) => ApplyResult {
|
||||
Ok(Err(e)) => ApplyResult {
|
||||
npub: anchor.npub.clone(),
|
||||
ok: false,
|
||||
message: format!("sudo fipsctl launch failed: {}", e),
|
||||
},
|
||||
Err(_) => ApplyResult {
|
||||
npub: anchor.npub.clone(),
|
||||
ok: false,
|
||||
message: "sudo fipsctl connect timed out after 15s".to_string(),
|
||||
},
|
||||
};
|
||||
if result.ok {
|
||||
tracing::debug!(npub = %result.npub, "Seed anchor applied");
|
||||
@@ -233,9 +286,9 @@ pub async fn apply(anchors: &[SeedAnchor]) -> Vec<ApplyResult> {
|
||||
"Seed anchor apply failed (non-fatal)"
|
||||
);
|
||||
}
|
||||
results.push(result);
|
||||
}
|
||||
results
|
||||
result
|
||||
});
|
||||
futures_util::future::join_all(futs).await
|
||||
}
|
||||
|
||||
/// Outcome of a single `fipsctl connect` call.
|
||||
@@ -248,12 +301,12 @@ pub struct ApplyResult {
|
||||
|
||||
/// FIPS UDP transport port (matches `transports.udp.bind_addr` in the generated
|
||||
/// `fips.yaml`). Direct peer links dial this, NOT the HTTP/LAN messaging port.
|
||||
const FIPS_UDP_PORT: u16 = 8668;
|
||||
const FIPS_UDP_PORT: u16 = crate::fips::PUBLISHED_UDP_PORT;
|
||||
|
||||
/// Build transient seed-anchor entries that dial LAN-discovered federation peers
|
||||
/// directly over their FIPS UDP transport. For each peer the registry knows both
|
||||
/// a LAN socket address AND a FIPS npub for, point a `udp` anchor at
|
||||
/// `<lan-ip>:8668`. This lets co-located federation nodes form a DIRECT FIPS link
|
||||
/// `<lan-ip>:<FIPS_UDP_PORT>`. This lets co-located federation nodes form a DIRECT FIPS link
|
||||
/// instead of depending on the global anchor's spanning tree to route between
|
||||
/// them (the cause of every dial falling back to Tor when the anchor link flaps).
|
||||
///
|
||||
@@ -313,6 +366,30 @@ mod tests {
|
||||
assert!(got.iter().all(|a| a.transport == "tcp"));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn load_repairs_legacy_archy_only_anchor_file() {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
save(dir.path(), &[archy_anchor()]).await.unwrap();
|
||||
|
||||
let got = load(dir.path()).await.unwrap();
|
||||
assert!(got.iter().any(|a| a.npub == ARCHY_ANCHOR_NPUB));
|
||||
for anchor in fips_network_anchors() {
|
||||
assert!(got.iter().any(|a| a.npub == anchor.npub));
|
||||
}
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn load_keeps_private_anchor_file_authoritative() {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
let private = mk("npub1private");
|
||||
save(dir.path(), std::slice::from_ref(&private))
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
let got = load(dir.path()).await.unwrap();
|
||||
assert_eq!(got, vec![private]);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn removing_one_default_persists_and_keeps_the_other() {
|
||||
// Editing the anchor list (here removing one default) makes the file
|
||||
@@ -331,10 +408,10 @@ mod tests {
|
||||
// Removing every default leaves an empty authoritative list that must
|
||||
// not be re-seeded on next load.
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
remove(dir.path(), ARCHY_ANCHOR_NPUB).await.unwrap();
|
||||
let list = remove(dir.path(), DEFAULT_PUBLIC_ANCHOR_NPUB)
|
||||
.await
|
||||
.unwrap();
|
||||
let mut list = Vec::new();
|
||||
for anchor in default_public_anchors() {
|
||||
list = remove(dir.path(), &anchor.npub).await.unwrap();
|
||||
}
|
||||
assert!(list.is_empty());
|
||||
let got = load(dir.path()).await.unwrap();
|
||||
assert!(got.is_empty(), "defaults must stay removed once edited");
|
||||
@@ -382,4 +459,39 @@ mod tests {
|
||||
assert_eq!(a.transport, "udp");
|
||||
assert_eq!(a.label, "");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn lan_fips_anchor_port_matches_daemon_bind() {
|
||||
// Drift guard: direct LAN anchors must dial the UDP port the
|
||||
// generated fips.yaml actually binds. These were out of sync for
|
||||
// months (anchors dialed 8668, the daemon bound 2121), making the
|
||||
// whole direct-peering feature dial a dead port.
|
||||
let yaml = crate::fips::config::render_config_yaml();
|
||||
assert!(
|
||||
yaml.contains(&format!("0.0.0.0:{FIPS_UDP_PORT}")),
|
||||
"lan_fips_anchors dials :{FIPS_UDP_PORT} but the daemon config binds elsewhere"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn lan_fips_anchors_builds_direct_entry() {
|
||||
let peer = crate::transport::PeerRecord {
|
||||
did: "did:key:zpeer".to_string(),
|
||||
lan_address: Some("192.168.63.198:5678".to_string()),
|
||||
fips_npub: Some("npub1peer".to_string()),
|
||||
..Default::default()
|
||||
};
|
||||
let out = lan_fips_anchors(&[peer]);
|
||||
assert_eq!(out.len(), 1);
|
||||
assert_eq!(out[0].address, format!("192.168.63.198:{FIPS_UDP_PORT}"));
|
||||
assert_eq!(out[0].transport, "udp");
|
||||
|
||||
// Peers missing either the LAN address or the npub produce nothing.
|
||||
let no_npub = crate::transport::PeerRecord {
|
||||
did: "did:key:zother".to_string(),
|
||||
lan_address: Some("192.168.63.199:5678".to_string()),
|
||||
..Default::default()
|
||||
};
|
||||
assert!(lan_fips_anchors(&[no_npub]).is_empty());
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,44 @@
|
||||
//! Generated by scripts/generate-app-catalog.py. Do not edit manually.
|
||||
//!
|
||||
//! Catalog app launch ports (the web UIs the companion opens by direct
|
||||
//! port). Used to write the fips0 firewall allowance drop-in so app UIs
|
||||
//! are reachable over the mesh; ports of apps that aren't installed have
|
||||
//! no listener, so allowing them is inert.
|
||||
|
||||
pub const APP_LAUNCH_PORTS: &[u16] = &[
|
||||
2283,
|
||||
2342,
|
||||
3000,
|
||||
3001,
|
||||
3002,
|
||||
4080,
|
||||
5180,
|
||||
7778,
|
||||
8080,
|
||||
8081,
|
||||
8082,
|
||||
8083,
|
||||
8084,
|
||||
8085,
|
||||
8087,
|
||||
8088,
|
||||
8089,
|
||||
8090,
|
||||
8096,
|
||||
8123,
|
||||
8175,
|
||||
8176,
|
||||
8240,
|
||||
8334,
|
||||
8888,
|
||||
8999,
|
||||
9000,
|
||||
9100,
|
||||
10380,
|
||||
11434,
|
||||
18081,
|
||||
18083,
|
||||
23000,
|
||||
32838,
|
||||
50002,
|
||||
];
|
||||
@@ -15,7 +15,7 @@ use std::path::Path;
|
||||
use tokio::process::Command;
|
||||
|
||||
use super::{
|
||||
DAEMON_CONFIG_PATH, DAEMON_KEY_PATH, DAEMON_PUB_PATH, DEFAULT_TCP_PORT, DEFAULT_UDP_PORT,
|
||||
DAEMON_CONFIG_PATH, DAEMON_KEY_PATH, DAEMON_PUB_PATH, DEFAULT_TCP_PORT, PUBLISHED_UDP_PORT,
|
||||
};
|
||||
|
||||
/// Header prepended to the generated YAML. serde doesn't emit comments, so
|
||||
@@ -48,6 +48,30 @@ pub struct FipsConfig {
|
||||
pub struct NodeSection {
|
||||
pub identity: IdentitySection,
|
||||
pub discovery: DiscoverySection,
|
||||
pub retry: RetrySection,
|
||||
pub rate_limit: RateLimitSection,
|
||||
}
|
||||
|
||||
/// Fast-reconnect profile (`node.retry.*`). Upstream defaults (5s base
|
||||
/// doubling to 300s) are tuned for stable always-on links; a node redialing
|
||||
/// a recycled anchor sat off-mesh for ~90s. Field names verified live on
|
||||
/// 2026-07-24: a daemon restarted with these keys in fips.yaml and peered.
|
||||
#[derive(Debug, Clone, PartialEq, Serialize)]
|
||||
pub struct RetrySection {
|
||||
pub base_interval_secs: u64,
|
||||
pub max_backoff_secs: u64,
|
||||
pub max_retries: u32,
|
||||
}
|
||||
|
||||
/// Session-handshake resend pacing (`node.rate_limit.*`). Stock 1s x2.0
|
||||
/// gaps out to 8-16s between resends exactly when a route has just
|
||||
/// appeared; 400ms x1.5 keeps continuous coverage through the connect
|
||||
/// window (phone measured session-after-route: 225ms).
|
||||
#[derive(Debug, Clone, PartialEq, Serialize)]
|
||||
pub struct RateLimitSection {
|
||||
pub handshake_resend_interval_ms: u64,
|
||||
pub handshake_resend_backoff: f64,
|
||||
pub handshake_max_resends: u32,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Serialize)]
|
||||
@@ -60,6 +84,14 @@ pub struct IdentitySection {
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Serialize)]
|
||||
pub struct DiscoverySection {
|
||||
/// Lookup completion timeout. Lookups fired while the tree position is
|
||||
/// still settling are doomed; failing them fast (5s, not 10s) lets the
|
||||
/// 1s-backoff retry find the route the moment it exists.
|
||||
pub timeout_secs: u64,
|
||||
pub backoff_base_secs: u64,
|
||||
pub backoff_max_secs: u64,
|
||||
pub retry_interval_secs: u64,
|
||||
pub max_attempts: u8,
|
||||
pub lan: LanDiscoverySection,
|
||||
}
|
||||
|
||||
@@ -123,8 +155,23 @@ impl Default for FipsConfig {
|
||||
node: NodeSection {
|
||||
identity: IdentitySection { persistent: true },
|
||||
discovery: DiscoverySection {
|
||||
timeout_secs: 5,
|
||||
backoff_base_secs: 1,
|
||||
backoff_max_secs: 30,
|
||||
retry_interval_secs: 2,
|
||||
max_attempts: 3,
|
||||
lan: LanDiscoverySection { enabled: true },
|
||||
},
|
||||
retry: RetrySection {
|
||||
base_interval_secs: 1,
|
||||
max_backoff_secs: 30,
|
||||
max_retries: 30,
|
||||
},
|
||||
rate_limit: RateLimitSection {
|
||||
handshake_resend_interval_ms: 400,
|
||||
handshake_resend_backoff: 1.5,
|
||||
handshake_max_resends: 10,
|
||||
},
|
||||
},
|
||||
tun: TunSection {
|
||||
enabled: true,
|
||||
@@ -137,7 +184,7 @@ impl Default for FipsConfig {
|
||||
},
|
||||
transports: TransportsSection {
|
||||
udp: TransportBind {
|
||||
bind_addr: format!("0.0.0.0:{DEFAULT_UDP_PORT}"),
|
||||
bind_addr: format!("0.0.0.0:{PUBLISHED_UDP_PORT}"),
|
||||
},
|
||||
tcp: TransportBind {
|
||||
bind_addr: format!("0.0.0.0:{DEFAULT_TCP_PORT}"),
|
||||
@@ -186,6 +233,75 @@ pub async fn install(identity_dir: &Path) -> Result<()> {
|
||||
let _ = tokio::fs::remove_file(&stage).await;
|
||||
install_result?;
|
||||
|
||||
// The release-hardening firewall (/etc/fips/fips.nft, provisioned
|
||||
// out-of-band) default-denies inbound on fips0 — without an explicit
|
||||
// allowance the node's web UI is unreachable over the mesh (phones got
|
||||
// RST on :80 with a healthy session; root-caused 2026-07-26 on
|
||||
// framework-pt). Ship the allowance as a fips.d drop-in on every
|
||||
// install/upgrade so no node ever regresses to a UI-less mesh.
|
||||
sudo_install_dir("/etc/fips/fips.d").await?;
|
||||
// PEER_PORT (5679) carries ALL federation sync, cloud browse/download,
|
||||
// mesh envelopes, DWN and invoices. It was missing from this allowlist
|
||||
// while the comment claimed "web UI + peer API" — so every hardened
|
||||
// node silently dropped peers' FIPS dials at the firewall and the whole
|
||||
// fleet fell back to Tor (root-caused live 2026-07-27: 28k drops on
|
||||
// .198's counter; :5679 answered in 0.35s once the rule was inserted).
|
||||
let dropin = format!(
|
||||
"# Written by archipelago on every daemon config install.\n\
|
||||
# Allows the web UI + peer API through the fips0\n\
|
||||
# default-deny inbound baseline (fips.nft).\n\
|
||||
tcp dport 80 accept\n\
|
||||
tcp dport 8443 accept\n\
|
||||
tcp dport {peer_port} accept\n",
|
||||
peer_port = crate::fips::dial::PEER_PORT
|
||||
);
|
||||
let nft_stage = std::env::temp_dir().join(format!("fips-webui-{}.nft", std::process::id()));
|
||||
tokio::fs::write(&nft_stage, dropin)
|
||||
.await
|
||||
.context("Failed to stage web-ui nft drop-in")?;
|
||||
let nft_install = sudo_install_file(&nft_stage, "/etc/fips/fips.d/80-web-ui.nft", "0644").await;
|
||||
let _ = tokio::fs::remove_file(&nft_stage).await;
|
||||
nft_install?;
|
||||
|
||||
// App launch ports: the companion opens catalog apps by direct port
|
||||
// over the mesh. Ports of apps that aren't installed have no listener,
|
||||
// so the allowance is inert until an app exists to answer.
|
||||
let port_list = super::app_ports::APP_LAUNCH_PORTS
|
||||
.iter()
|
||||
.map(|p| p.to_string())
|
||||
.collect::<Vec<_>>()
|
||||
.join(", ");
|
||||
let app_dropin = format!(
|
||||
"# Written by archipelago on every daemon config install.\n\
|
||||
# Catalog app launch ports (web UIs) allowed through the fips0\n\
|
||||
# default-deny inbound baseline. Service/RPC ports stay closed.\n\
|
||||
tcp dport {{ {port_list} }} accept\n"
|
||||
);
|
||||
let app_stage = std::env::temp_dir().join(format!("fips-appports-{}.nft", std::process::id()));
|
||||
tokio::fs::write(&app_stage, app_dropin)
|
||||
.await
|
||||
.context("Failed to stage app-ports nft drop-in")?;
|
||||
let app_install =
|
||||
sudo_install_file(&app_stage, "/etc/fips/fips.d/85-app-ports.nft", "0644").await;
|
||||
let _ = tokio::fs::remove_file(&app_stage).await;
|
||||
app_install?;
|
||||
// Make the allowance live immediately; a no-op error when the
|
||||
// hardening baseline isn't installed on this node yet.
|
||||
if tokio::fs::try_exists("/etc/fips/fips.nft").await.unwrap_or(false) {
|
||||
match Command::new("sudo")
|
||||
.args(["nft", "-f", "/etc/fips/fips.nft"])
|
||||
.output()
|
||||
.await
|
||||
{
|
||||
Ok(out) if !out.status.success() => tracing::warn!(
|
||||
"nft reload after web-ui drop-in failed: {}",
|
||||
String::from_utf8_lossy(&out.stderr).trim()
|
||||
),
|
||||
Err(e) => tracing::warn!("nft reload after web-ui drop-in failed: {e}"),
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
|
||||
sudo_install_file(&src_key, DAEMON_KEY_PATH, "0600").await?;
|
||||
// Heal a legacy fips_key.pub that was written as bech32 npub text
|
||||
// (pre-fix identity::write_fips_key_from_seed did this). Upstream
|
||||
@@ -293,7 +409,7 @@ mod tests {
|
||||
fn test_rendered_yaml_matches_upstream_schema() {
|
||||
let yaml = render_config_yaml();
|
||||
assert!(yaml.contains("persistent: true"));
|
||||
assert!(yaml.contains(&format!("0.0.0.0:{}", DEFAULT_UDP_PORT)));
|
||||
assert!(yaml.contains(&format!("0.0.0.0:{}", PUBLISHED_UDP_PORT)));
|
||||
assert!(yaml.contains(&format!("0.0.0.0:{}", DEFAULT_TCP_PORT)));
|
||||
assert!(yaml.contains("udp:"));
|
||||
assert!(yaml.contains("tcp:"));
|
||||
@@ -318,8 +434,21 @@ node:
|
||||
identity:
|
||||
persistent: true
|
||||
discovery:
|
||||
timeout_secs: 5
|
||||
backoff_base_secs: 1
|
||||
backoff_max_secs: 30
|
||||
retry_interval_secs: 2
|
||||
max_attempts: 3
|
||||
lan:
|
||||
enabled: true
|
||||
retry:
|
||||
base_interval_secs: 1
|
||||
max_backoff_secs: 30
|
||||
max_retries: 30
|
||||
rate_limit:
|
||||
handshake_resend_interval_ms: 400
|
||||
handshake_resend_backoff: 1.5
|
||||
handshake_max_resends: 10
|
||||
tun:
|
||||
enabled: true
|
||||
name: fips0
|
||||
@@ -329,7 +458,7 @@ dns:
|
||||
bind_addr: 127.0.0.1
|
||||
transports:
|
||||
udp:
|
||||
bind_addr: 0.0.0.0:8668
|
||||
bind_addr: 0.0.0.0:2121
|
||||
tcp:
|
||||
bind_addr: 0.0.0.0:8443
|
||||
peers: []
|
||||
|
||||
@@ -24,6 +24,7 @@
|
||||
//! ```
|
||||
#![allow(dead_code)]
|
||||
|
||||
use super::telemetry::{self, FallbackReason};
|
||||
use anyhow::{Context, Result};
|
||||
use std::net::{IpAddr, Ipv6Addr};
|
||||
use std::time::Duration;
|
||||
@@ -150,6 +151,12 @@ pub async fn warm_path(npub: &str) {
|
||||
if !is_service_active().await {
|
||||
return;
|
||||
}
|
||||
warm_path_unchecked(npub).await
|
||||
}
|
||||
|
||||
/// [`warm_path`] without the service-active check — for callers (the warm
|
||||
/// tick) that already verified the daemon once for the whole batch.
|
||||
pub async fn warm_path_unchecked(npub: &str) {
|
||||
let Ok(base) = peer_base_url(npub).await else {
|
||||
return;
|
||||
};
|
||||
@@ -276,21 +283,39 @@ pub fn as_ip_addr(v6: Ipv6Addr) -> IpAddr {
|
||||
|
||||
// ── High-level peer request helpers ────────────────────────────────────
|
||||
|
||||
/// TTL for the [`is_service_active`] cache. Every FIPS dial attempt and
|
||||
/// every warm-tick peer used to spawn up to two `systemctl` subprocesses;
|
||||
/// service state changes on human timescales, so 10s staleness is free.
|
||||
const SERVICE_ACTIVE_TTL_MS: u64 = 10_000;
|
||||
static SERVICE_ACTIVE: std::sync::atomic::AtomicBool = std::sync::atomic::AtomicBool::new(false);
|
||||
static SERVICE_PROBED_AT_MS: std::sync::atomic::AtomicU64 = std::sync::atomic::AtomicU64::new(0);
|
||||
|
||||
/// Quick poll: is the FIPS daemon (archipelago-supervised OR upstream)
|
||||
/// currently `systemctl is-active`? Async wrapper intended for the
|
||||
/// migration call sites; unlike `FipsTransport::is_available` this does
|
||||
/// not maintain a cache, so callers that poll frequently should cache
|
||||
/// themselves.
|
||||
/// currently `systemctl is-active`? Cached for [`SERVICE_ACTIVE_TTL_MS`];
|
||||
/// concurrent refreshes are harmless (idempotent probe, last write wins).
|
||||
pub async fn is_service_active() -> bool {
|
||||
use std::sync::atomic::Ordering;
|
||||
let now_ms = std::time::SystemTime::now()
|
||||
.duration_since(std::time::UNIX_EPOCH)
|
||||
.map(|d| d.as_millis() as u64)
|
||||
.unwrap_or(0);
|
||||
let probed_at = SERVICE_PROBED_AT_MS.load(Ordering::Relaxed);
|
||||
if probed_at != 0 && now_ms.saturating_sub(probed_at) < SERVICE_ACTIVE_TTL_MS {
|
||||
return SERVICE_ACTIVE.load(Ordering::Relaxed);
|
||||
}
|
||||
let mut active = false;
|
||||
for unit in [
|
||||
crate::fips::SERVICE_UNIT,
|
||||
crate::fips::UPSTREAM_SERVICE_UNIT,
|
||||
] {
|
||||
if crate::fips::service::unit_state(unit).await == "active" {
|
||||
return true;
|
||||
active = true;
|
||||
break;
|
||||
}
|
||||
}
|
||||
false
|
||||
SERVICE_ACTIVE.store(active, Ordering::Relaxed);
|
||||
SERVICE_PROBED_AT_MS.store(now_ms, Ordering::Relaxed);
|
||||
active
|
||||
}
|
||||
|
||||
/// Builder for a peer request that may be sent over FIPS (preferred) or
|
||||
@@ -317,6 +342,11 @@ pub struct PeerRequest<'a> {
|
||||
/// large content download needs so its long FIPS transfer isn't truncated.
|
||||
pub fips_timeout: Option<std::time::Duration>,
|
||||
pub service: Option<crate::settings::transport::PeerService>,
|
||||
/// When set, the transport that actually served this request is written
|
||||
/// to federation storage (`record_peer_transport`, matched by onion) so
|
||||
/// the per-peer FIPS/Tor badge reflects reality. Opt-in because not
|
||||
/// every caller has a data dir in scope.
|
||||
pub record_data_dir: Option<std::path::PathBuf>,
|
||||
}
|
||||
|
||||
impl<'a> PeerRequest<'a> {
|
||||
@@ -329,6 +359,31 @@ impl<'a> PeerRequest<'a> {
|
||||
timeout: std::time::Duration::from_secs(30),
|
||||
fips_timeout: None,
|
||||
service: None,
|
||||
record_data_dir: None,
|
||||
}
|
||||
}
|
||||
|
||||
/// Record the transport that serves this request into federation storage
|
||||
/// (matched by this request's onion host). Best-effort, off the hot path.
|
||||
pub fn record_transport(mut self, data_dir: impl Into<std::path::PathBuf>) -> Self {
|
||||
self.record_data_dir = Some(data_dir.into());
|
||||
self
|
||||
}
|
||||
|
||||
fn spawn_record(&self, kind: crate::transport::TransportKind) {
|
||||
if let Some(dir) = &self.record_data_dir {
|
||||
let dir = dir.clone();
|
||||
let onion = self.onion_host.to_string();
|
||||
let transport = kind.to_string();
|
||||
tokio::spawn(async move {
|
||||
let _ = crate::federation::record_peer_transport(
|
||||
&dir,
|
||||
None,
|
||||
Some(&onion),
|
||||
&transport,
|
||||
)
|
||||
.await;
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@@ -389,8 +444,22 @@ impl<'a> PeerRequest<'a> {
|
||||
// fix (404 path-not-served / 5xx) and we're allowed to
|
||||
// fall back. FIPS-only never falls back.
|
||||
if pref == TransportPref::Fips || !fips_should_fall_back(resp.status()) {
|
||||
telemetry::record_fips_ok();
|
||||
self.spawn_record(crate::transport::TransportKind::Fips);
|
||||
return Ok((resp, crate::transport::TransportKind::Fips));
|
||||
}
|
||||
let reason = if resp.status() == reqwest::StatusCode::NOT_FOUND {
|
||||
FallbackReason::Http404
|
||||
} else {
|
||||
FallbackReason::Http5xx
|
||||
};
|
||||
telemetry::record_fallback(reason);
|
||||
tracing::info!(
|
||||
reason = reason.key(),
|
||||
status = %resp.status(),
|
||||
"FIPS POST {} answered but status triggers Tor fallback",
|
||||
self.path
|
||||
);
|
||||
}
|
||||
None => {
|
||||
if pref == TransportPref::Fips {
|
||||
@@ -402,6 +471,7 @@ impl<'a> PeerRequest<'a> {
|
||||
}
|
||||
}
|
||||
let resp = self.send_tor_post_json(body).await?;
|
||||
self.spawn_record(crate::transport::TransportKind::Tor);
|
||||
Ok((resp, crate::transport::TransportKind::Tor))
|
||||
}
|
||||
|
||||
@@ -413,8 +483,22 @@ impl<'a> PeerRequest<'a> {
|
||||
match self.try_fips_get().await? {
|
||||
Some(resp) => {
|
||||
if pref == TransportPref::Fips || !fips_should_fall_back(resp.status()) {
|
||||
telemetry::record_fips_ok();
|
||||
self.spawn_record(crate::transport::TransportKind::Fips);
|
||||
return Ok((resp, crate::transport::TransportKind::Fips));
|
||||
}
|
||||
let reason = if resp.status() == reqwest::StatusCode::NOT_FOUND {
|
||||
FallbackReason::Http404
|
||||
} else {
|
||||
FallbackReason::Http5xx
|
||||
};
|
||||
telemetry::record_fallback(reason);
|
||||
tracing::info!(
|
||||
reason = reason.key(),
|
||||
status = %resp.status(),
|
||||
"FIPS GET {} answered but status triggers Tor fallback",
|
||||
self.path
|
||||
);
|
||||
}
|
||||
None => {
|
||||
if pref == TransportPref::Fips {
|
||||
@@ -426,6 +510,7 @@ impl<'a> PeerRequest<'a> {
|
||||
}
|
||||
}
|
||||
let resp = self.send_tor_get().await?;
|
||||
self.spawn_record(crate::transport::TransportKind::Tor);
|
||||
Ok((resp, crate::transport::TransportKind::Tor))
|
||||
}
|
||||
|
||||
@@ -434,67 +519,127 @@ impl<'a> PeerRequest<'a> {
|
||||
body: &B,
|
||||
) -> Result<Option<reqwest::Response>> {
|
||||
let Some(npub) = self.fips_npub else {
|
||||
telemetry::record_fallback(FallbackReason::NoNpub);
|
||||
return Ok(None);
|
||||
};
|
||||
if !is_service_active().await {
|
||||
telemetry::record_fallback(FallbackReason::ServiceInactive);
|
||||
return Ok(None);
|
||||
}
|
||||
let base = match peer_base_url(npub).await {
|
||||
Ok(b) => b,
|
||||
Err(e) => {
|
||||
tracing::debug!("FIPS resolve for {} failed: {}", npub, e);
|
||||
telemetry::record_fallback(FallbackReason::DnsFail);
|
||||
tracing::info!(
|
||||
reason = FallbackReason::DnsFail.key(),
|
||||
"FIPS resolve for {} failed: {}, falling back to Tor",
|
||||
npub,
|
||||
e
|
||||
);
|
||||
return Ok(None);
|
||||
}
|
||||
};
|
||||
let url = format!("{}{}", base, self.path);
|
||||
let c = client_with_timeout(self.fips_attempt_timeout());
|
||||
let budget = self.fips_attempt_timeout();
|
||||
// With an explicit fast-fail cap, halve the per-attempt client
|
||||
// timeout so the one-retry path in send_with_retry fits inside the
|
||||
// budget instead of silently doubling it ("fips_timeout(6s)" used
|
||||
// to really mean ~12.6s). Without one (long streaming downloads),
|
||||
// keep the full budget per attempt — the client timeout also
|
||||
// governs body streaming and must not truncate a real transfer.
|
||||
let per_attempt = if self.fips_timeout.is_some() {
|
||||
budget / 2
|
||||
} else {
|
||||
budget
|
||||
};
|
||||
let c = client_with_timeout(per_attempt);
|
||||
let mut rb = c.post(&url).json(body);
|
||||
for (k, v) in &self.headers {
|
||||
rb = rb.header(*k, v);
|
||||
}
|
||||
match send_with_retry(rb).await {
|
||||
Ok(r) => Ok(Some(r)),
|
||||
Err(e) => {
|
||||
tracing::debug!(
|
||||
match tokio::time::timeout(budget, send_with_retry(rb)).await {
|
||||
Ok(Ok(r)) => Ok(Some(r)),
|
||||
Ok(Err(e)) => {
|
||||
telemetry::record_fallback(FallbackReason::ConnectFail);
|
||||
tracing::info!(
|
||||
reason = FallbackReason::ConnectFail.key(),
|
||||
"FIPS POST {} failed after retry: {}, falling back to Tor",
|
||||
url,
|
||||
e
|
||||
);
|
||||
Ok(None)
|
||||
}
|
||||
Err(_) => {
|
||||
telemetry::record_fallback(FallbackReason::ConnectFail);
|
||||
tracing::info!(
|
||||
reason = FallbackReason::ConnectFail.key(),
|
||||
"FIPS POST {} exceeded attempt budget {:?}, falling back to Tor",
|
||||
url,
|
||||
budget
|
||||
);
|
||||
Ok(None)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async fn try_fips_get(&self) -> Result<Option<reqwest::Response>> {
|
||||
let Some(npub) = self.fips_npub else {
|
||||
telemetry::record_fallback(FallbackReason::NoNpub);
|
||||
return Ok(None);
|
||||
};
|
||||
if !is_service_active().await {
|
||||
telemetry::record_fallback(FallbackReason::ServiceInactive);
|
||||
return Ok(None);
|
||||
}
|
||||
let base = match peer_base_url(npub).await {
|
||||
Ok(b) => b,
|
||||
Err(e) => {
|
||||
tracing::debug!("FIPS resolve for {} failed: {}", npub, e);
|
||||
telemetry::record_fallback(FallbackReason::DnsFail);
|
||||
tracing::info!(
|
||||
reason = FallbackReason::DnsFail.key(),
|
||||
"FIPS resolve for {} failed: {}, falling back to Tor",
|
||||
npub,
|
||||
e
|
||||
);
|
||||
return Ok(None);
|
||||
}
|
||||
};
|
||||
let url = format!("{}{}", base, self.path);
|
||||
let c = client_with_timeout(self.fips_attempt_timeout());
|
||||
let budget = self.fips_attempt_timeout();
|
||||
// Same budget discipline as the POST path: halve per attempt only
|
||||
// under an explicit fast-fail cap; hard-cap the retry sequence.
|
||||
let per_attempt = if self.fips_timeout.is_some() {
|
||||
budget / 2
|
||||
} else {
|
||||
budget
|
||||
};
|
||||
let c = client_with_timeout(per_attempt);
|
||||
let mut rb = c.get(&url);
|
||||
for (k, v) in &self.headers {
|
||||
rb = rb.header(*k, v);
|
||||
}
|
||||
match send_with_retry(rb).await {
|
||||
Ok(r) => Ok(Some(r)),
|
||||
Err(e) => {
|
||||
tracing::debug!(
|
||||
match tokio::time::timeout(budget, send_with_retry(rb)).await {
|
||||
Ok(Ok(r)) => Ok(Some(r)),
|
||||
Ok(Err(e)) => {
|
||||
telemetry::record_fallback(FallbackReason::ConnectFail);
|
||||
tracing::info!(
|
||||
reason = FallbackReason::ConnectFail.key(),
|
||||
"FIPS GET {} failed after retry: {}, falling back to Tor",
|
||||
url,
|
||||
e
|
||||
);
|
||||
Ok(None)
|
||||
}
|
||||
Err(_) => {
|
||||
telemetry::record_fallback(FallbackReason::ConnectFail);
|
||||
tracing::info!(
|
||||
reason = FallbackReason::ConnectFail.key(),
|
||||
"FIPS GET {} exceeded attempt budget {:?}, falling back to Tor",
|
||||
url,
|
||||
budget
|
||||
);
|
||||
Ok(None)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -26,10 +26,12 @@
|
||||
#![allow(dead_code)]
|
||||
|
||||
pub mod anchors;
|
||||
pub mod app_ports;
|
||||
pub mod config;
|
||||
pub mod dial;
|
||||
pub mod iface;
|
||||
pub mod service;
|
||||
pub mod telemetry;
|
||||
pub mod update;
|
||||
|
||||
use serde::{Deserialize, Serialize};
|
||||
@@ -53,7 +55,8 @@ pub async fn ensure_activated(data_dir: &std::path::Path) {
|
||||
tracing::warn!("FIPS auto-activate: config install failed: {:#}", e);
|
||||
return;
|
||||
}
|
||||
if let Err(e) = service::activate(SERVICE_UNIT).await {
|
||||
let unit = service::activation_unit().await;
|
||||
if let Err(e) = service::activate(unit).await {
|
||||
tracing::warn!("FIPS auto-activate: service activate failed: {:#}", e);
|
||||
return;
|
||||
}
|
||||
@@ -77,25 +80,73 @@ pub async fn ensure_activated(data_dir: &std::path::Path) {
|
||||
pub fn spawn_fips_supervisor(data_dir: std::path::PathBuf) {
|
||||
tokio::spawn(async move {
|
||||
let mut tick = tokio::time::interval(std::time::Duration::from_secs(25));
|
||||
// Connectivity watcher state: re-apply seed anchors the moment the
|
||||
// anchor link drops (edge) or the data path degrades (dials keep
|
||||
// failing with zero successes), instead of waiting for the 300s
|
||||
// anchor tick. Bounded: at most one re-apply per RE_APPLY_BACKOFF.
|
||||
const RE_APPLY_BACKOFF: std::time::Duration = std::time::Duration::from_secs(60);
|
||||
let mut prev_connected: Option<bool> = None;
|
||||
let mut prev_totals = telemetry::totals();
|
||||
let mut last_apply: Option<std::time::Instant> = None;
|
||||
loop {
|
||||
tick.tick().await;
|
||||
// Bring FIPS up on its own once onboarding has materialised the key.
|
||||
ensure_activated(&data_dir).await;
|
||||
if !dial::is_service_active().await {
|
||||
prev_connected = None; // daemon restart = fresh edge detection
|
||||
continue;
|
||||
}
|
||||
|
||||
// ── Warm the union of federation peers + configured seed
|
||||
// anchors. Warming only federation npubs left the direct
|
||||
// anchors (vps2, LAN peers) to go cold between 300s ticks.
|
||||
let nodes = crate::federation::load_nodes(&data_dir)
|
||||
.await
|
||||
.unwrap_or_default();
|
||||
let seed = anchors::load(&data_dir).await.unwrap_or_default();
|
||||
let mut warm_npubs: std::collections::BTreeSet<String> = nodes
|
||||
.iter()
|
||||
.filter_map(|n| n.fips_npub.clone())
|
||||
.collect();
|
||||
warm_npubs.extend(seed.iter().map(|a| a.npub.clone()));
|
||||
let mut handles = Vec::new();
|
||||
for node in nodes {
|
||||
if let Some(npub) = node.fips_npub.clone() {
|
||||
handles.push(tokio::spawn(async move { dial::warm_path(&npub).await }));
|
||||
}
|
||||
for npub in warm_npubs {
|
||||
// Service-active was checked once above for the whole batch.
|
||||
handles.push(tokio::spawn(
|
||||
async move { dial::warm_path_unchecked(&npub).await },
|
||||
));
|
||||
}
|
||||
for h in handles {
|
||||
let _ = h.await;
|
||||
}
|
||||
|
||||
// ── Connectivity watcher: detect anchor-link loss AND silent
|
||||
// data-path death (daemon reports "connected" but every dial
|
||||
// connect-fails — observed live on .198, 2026-07-27, where the
|
||||
// 300s tick never healed it).
|
||||
let mut anchor_npubs = vec![service::PUBLIC_ANCHOR_NPUB.to_string()];
|
||||
anchor_npubs.extend(seed.iter().map(|a| a.npub.clone()));
|
||||
let (_, connected) = service::peer_connectivity_summary(&anchor_npubs).await;
|
||||
let totals = telemetry::totals();
|
||||
let link_dropped = prev_connected == Some(true) && !connected;
|
||||
let never_connected = prev_connected.is_none() && !connected;
|
||||
let data_path_dead =
|
||||
totals.1.saturating_sub(prev_totals.1) >= 5 && totals.0 == prev_totals.0;
|
||||
prev_connected = Some(connected);
|
||||
prev_totals = totals;
|
||||
|
||||
let backoff_ok = last_apply.is_none_or(|t| t.elapsed() >= RE_APPLY_BACKOFF);
|
||||
if (link_dropped || never_connected || data_path_dead) && backoff_ok && !seed.is_empty()
|
||||
{
|
||||
tracing::info!(
|
||||
link_dropped,
|
||||
never_connected,
|
||||
data_path_dead,
|
||||
"FIPS connectivity degraded — re-applying seed anchors now"
|
||||
);
|
||||
last_apply = Some(std::time::Instant::now());
|
||||
let _ = anchors::apply(&seed).await;
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
@@ -119,6 +170,15 @@ pub const UPSTREAM_REPO: &str = "jmcorgan/fips";
|
||||
/// Default UDP port the daemon listens on.
|
||||
pub const DEFAULT_UDP_PORT: u16 = 8668;
|
||||
|
||||
/// UDP port archipelago actually publishes/binds for the daemon. The
|
||||
/// container publishes `2121:2121/udp` (see `package/config.rs`) and every
|
||||
/// peer roster in the fleet dials `<host>:2121`, but the rendered daemon
|
||||
/// config used to bind upstream's 8668 — leaving inbound UDP dead on
|
||||
/// bridged-network installs and everything silently riding TCP 8443. The
|
||||
/// bind now uses this port so the published mapping, the fleet rosters,
|
||||
/// and the pairing QR all agree.
|
||||
pub const PUBLISHED_UDP_PORT: u16 = 2121;
|
||||
|
||||
/// Default TCP port the daemon listens on. Used as a fallback when a
|
||||
/// peer can't be reached over UDP — common on networks that block UDP
|
||||
/// (corporate/guest wifi) and the path the public fips.v0l.io anchor
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user