Compare commits
416 Commits
audio-bott
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
c31d99e49f | ||
|
|
c899ab3591 | ||
|
|
1618a3ac53 | ||
|
|
b938449cbc | ||
|
|
b4acb34624 | ||
|
|
c2ce71c680 | ||
|
|
8c2a55eb4c | ||
|
|
07b167d68e | ||
|
|
21de734385 | ||
|
|
8bea3707ca | ||
|
|
b7310ecfaf | ||
|
|
938dfb1453 | ||
|
|
49ec294dea | ||
|
|
00f1892bf8 | ||
|
|
9e77a4229c | ||
|
|
bea7f24a4f | ||
|
|
14feb1feb9 | ||
|
|
d2642856c1 | ||
|
|
338bfd43a7 | ||
|
|
3da1d0b0f7 | ||
|
|
500aebb3e2 | ||
|
|
04c056acdb | ||
|
|
d0463196a3 | ||
|
|
8e0939170c | ||
|
|
5c19effdd1 | ||
|
|
8e51164321 | ||
|
|
da14c135e4 | ||
|
|
d7c5d39747 | ||
|
|
6ba39041d0 | ||
|
|
c99f1c7b77 | ||
|
|
7326bb9262 | ||
|
|
3589c3a6b9 | ||
|
|
0aa3941c40 | ||
|
|
e62f911810 | ||
|
|
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 | ||
|
|
1931371058 | ||
|
|
693f4bb947 | ||
|
|
bb9ebb1138 | ||
|
|
cc2c06c6dc | ||
|
|
97fbaf8818 | ||
|
|
2116600e24 | ||
|
|
72f7c38701 | ||
|
|
745e180102 | ||
|
|
317a72aafe | ||
|
|
184257390d | ||
|
|
e074a117d2 | ||
|
|
b288be314c | ||
|
|
72fcf96016 | ||
|
|
50d5d4d132 | ||
|
|
73923d0ffd | ||
|
|
905e9cdb98 | ||
|
|
46cfc2ccd7 | ||
|
|
a597c1d946 | ||
|
|
84bc3d3138 | ||
|
|
856708e353 | ||
|
|
0a99de75f9 | ||
|
|
aaec25e53b | ||
|
|
172b1f3e9c | ||
|
|
c7fe688607 | ||
|
|
61d37c9798 | ||
|
|
d76f91a62f | ||
|
|
24b3d33ac3 | ||
|
|
b23f46c3d8 | ||
|
|
2a376ab275 | ||
|
|
5982fceb7c | ||
|
|
5951d31637 | ||
|
|
dc55ef8a54 | ||
|
|
97358d2314 | ||
|
|
1b78da1d7a | ||
|
|
0213da3fc5 | ||
|
|
8968d41ca9 | ||
|
|
be5122048b | ||
|
|
918b0275a9 | ||
|
|
a71a18bffd | ||
|
|
24cab326e2 | ||
|
|
811ac1ce50 | ||
|
|
69b1a45872 | ||
|
|
9823e76e8f | ||
|
|
8d4f6cb75e | ||
|
|
4bac839fb3 | ||
|
|
ea80815c98 | ||
|
|
4348581681 | ||
|
|
999a100531 | ||
|
|
a5810d4e51 | ||
|
|
4c3cd4b6ad | ||
|
|
c1dfc6672c | ||
|
|
8791ef60f5 | ||
|
|
1730d02003 | ||
|
|
146a3bc738 | ||
|
|
302f880d99 | ||
|
|
217eedc093 | ||
|
|
d79edb3a5c | ||
|
|
0f13545375 | ||
|
|
7eb4d6a7bf | ||
|
|
db008abdfd | ||
|
|
35e9c62441 | ||
|
|
cb301b1aef | ||
|
|
6a848c38cc | ||
|
|
830811372b | ||
|
|
38cb3dd252 | ||
|
|
50170b866e | ||
|
|
c91887a397 | ||
|
|
4fb200e938 | ||
|
|
5fd0d6c3c8 | ||
|
|
3ab7fb521a | ||
|
|
9e3ac9ba8f | ||
|
|
e2f83c0157 | ||
|
|
d5f709a3c3 | ||
|
|
710f576c77 | ||
|
|
c1d309f21f | ||
|
|
9c39243969 | ||
|
|
f25febf3bb | ||
|
|
0636d611e0 | ||
|
|
f13fdc6451 | ||
|
|
163bc3af01 | ||
|
|
ae12ff2517 | ||
|
|
cf4a8eef0e | ||
|
|
e5f8b5d789 | ||
|
|
aad6faa6d2 | ||
|
|
20edd31abb | ||
|
|
9a7331cead | ||
|
|
9eadec6936 | ||
|
|
6b0a84e710 | ||
|
|
fd361fb35e | ||
|
|
8bb61a51e2 | ||
|
|
17d225190a | ||
|
|
d08c0d29c7 | ||
|
|
a93bd70c5a | ||
|
|
25162ee846 | ||
|
|
837cfdfd1f | ||
|
|
573b469191 | ||
|
|
401f92a24f | ||
|
|
dc0adbef70 | ||
|
|
537c9fa70b | ||
|
|
b6468ebf3c | ||
|
|
70587210fb | ||
|
|
a27c7bafbf | ||
|
|
95b9d8f0fe | ||
|
|
918aba1de3 | ||
|
|
49b366fbe4 | ||
|
|
7eaf99873e | ||
|
|
a76a92cff8 | ||
|
|
a177ef3b38 | ||
|
|
ea0cd87b3b | ||
|
|
1ee1b56f70 | ||
|
|
73d181abea | ||
| 55d7f19545 | |||
|
|
46dd853614 | ||
|
|
977b8d06b8 | ||
|
|
f08f34ca10 | ||
|
|
aaa3477d5e | ||
|
|
9e264611e2 | ||
|
|
f32c4db7e2 | ||
|
|
8e13f981d0 | ||
|
|
3aebbcbbb8 | ||
| 90bedc2a25 | |||
|
|
a66e4bac6d | ||
|
|
af2dfd0bd6 | ||
|
|
68c25534d4 | ||
| 45c9def94a | |||
|
|
299f7d8f39 | ||
|
|
bb231e82b4 | ||
| e2309cc2ac | |||
|
|
a48499daeb | ||
|
|
6df9afe684 | ||
| 9d21dd5111 | |||
|
|
ddbfaf1d00 | ||
|
|
2e5f67a59a | ||
|
|
51d1c89137 | ||
|
|
785fb3d2be | ||
|
|
2b3a18f189 | ||
|
|
3028685e6b | ||
|
|
1a3170f1c3 | ||
|
|
547f674ac8 | ||
|
|
92d221bbf1 | ||
|
|
e9cfc234cd | ||
|
|
06186ab30c | ||
|
|
e5c7210663 | ||
|
|
500472944c | ||
|
|
a687df9bd9 | ||
|
|
0aa9463b36 | ||
|
|
5cb53ade66 | ||
|
|
47dea8cd55 | ||
|
|
72d7fa07ff | ||
|
|
6dcdada371 | ||
|
|
454c4bb25c | ||
|
|
66fd121748 | ||
|
|
fa91faa67c | ||
|
|
e9c3311eff | ||
|
|
338ae9a6de | ||
|
|
8f397b03f9 | ||
| 48d5fd0045 | |||
|
|
a44cbe478a | ||
|
|
519b4b209a | ||
| 2c82b498f0 | |||
|
|
efd1ae41de | ||
|
|
0c591a997e | ||
| 91e1059f56 | |||
|
|
3d986b81a0 | ||
| 2efed23afd |
62
.gitea/workflows/build-iso.yml
Normal file
62
.gitea/workflows/build-iso.yml
Normal file
@ -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:
|
paths:
|
||||||
- 'neode-ui/**'
|
- 'neode-ui/**'
|
||||||
- 'docker-compose.demo.yml'
|
- 'docker-compose.demo.yml'
|
||||||
- '.github/workflows/demo-images.yml'
|
- '.gitea/workflows/demo-images.yml'
|
||||||
workflow_dispatch:
|
workflow_dispatch:
|
||||||
|
|
||||||
jobs:
|
jobs:
|
||||||
|
|||||||
18
.github/pull_request_template.md
vendored
18
.github/pull_request_template.md
vendored
@ -1,16 +1,16 @@
|
|||||||
## Summary
|
## 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
|
## Checklist
|
||||||
|
|
||||||
- [ ] TypeScript type-check passes (`npm run type-check`)
|
- [ ] Rust formatting/clippy/tests pass when backend code changed.
|
||||||
- [ ] Frontend builds (`npm run build`)
|
- [ ] Frontend type-check/build/tests pass when frontend code changed.
|
||||||
- [ ] Tests pass (`npm test`)
|
- [ ] App manifests validate when app packaging changed.
|
||||||
- [ ] Rust clippy clean (if backend changes)
|
- [ ] Generated catalogs are updated when manifest-owned catalog fields changed.
|
||||||
- [ ] No new compiler warnings
|
- [ ] Docs are updated for user-facing or developer-facing behavior changes.
|
||||||
- [ ] Tested on live server
|
- [ ] No secrets, generated build outputs, local screenshots, or private host details are included.
|
||||||
|
|||||||
31
.github/workflows/ci.yml
vendored
31
.github/workflows/ci.yml
vendored
@ -8,11 +8,11 @@ on:
|
|||||||
|
|
||||||
env:
|
env:
|
||||||
RUST_VERSION: stable
|
RUST_VERSION: stable
|
||||||
NODE_VERSION: 18
|
NODE_VERSION: 20
|
||||||
|
|
||||||
jobs:
|
jobs:
|
||||||
rust:
|
rust:
|
||||||
name: Rust (fmt + clippy + test)
|
name: Rust
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
defaults:
|
defaults:
|
||||||
run:
|
run:
|
||||||
@ -28,17 +28,17 @@ jobs:
|
|||||||
toolchain: ${{ env.RUST_VERSION }}
|
toolchain: ${{ env.RUST_VERSION }}
|
||||||
components: rustfmt, clippy
|
components: rustfmt, clippy
|
||||||
|
|
||||||
- name: Check formatting
|
- name: Format
|
||||||
run: cargo fmt --all -- --check
|
run: cargo fmt --all -- --check
|
||||||
|
|
||||||
- name: Clippy
|
- name: Clippy
|
||||||
run: cargo clippy --all-targets --all-features -- -D warnings
|
run: cargo clippy --all-targets --all-features -- -D warnings
|
||||||
|
|
||||||
- name: Tests
|
- name: Test
|
||||||
run: cargo test --all-features
|
run: cargo test --all-features
|
||||||
|
|
||||||
frontend:
|
frontend:
|
||||||
name: Frontend (type-check + lint)
|
name: Frontend
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
defaults:
|
defaults:
|
||||||
run:
|
run:
|
||||||
@ -52,14 +52,31 @@ jobs:
|
|||||||
uses: actions/setup-node@v4
|
uses: actions/setup-node@v4
|
||||||
with:
|
with:
|
||||||
node-version: ${{ env.NODE_VERSION }}
|
node-version: ${{ env.NODE_VERSION }}
|
||||||
cache: 'npm'
|
cache: npm
|
||||||
cache-dependency-path: neode-ui/package-lock.json
|
cache-dependency-path: neode-ui/package-lock.json
|
||||||
|
|
||||||
- name: Install dependencies
|
- name: Install
|
||||||
run: npm ci
|
run: npm ci
|
||||||
|
|
||||||
- name: Type check
|
- name: Type check
|
||||||
run: npm run type-check
|
run: npm run type-check
|
||||||
|
|
||||||
|
- name: Test
|
||||||
|
run: npm test
|
||||||
|
|
||||||
- name: Build
|
- name: Build
|
||||||
run: npm run 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
|
||||||
|
|||||||
35
.gitignore
vendored
35
.gitignore
vendored
@ -1,10 +1,9 @@
|
|||||||
# SSH keys (sandbox copies)
|
# SSH keys and sandbox copies
|
||||||
.ssh/
|
.ssh/
|
||||||
|
|
||||||
# Rust build output
|
# Rust build output
|
||||||
target/
|
target/
|
||||||
**/target/
|
**/target/
|
||||||
Cargo.lock
|
|
||||||
|
|
||||||
# Node.js
|
# Node.js
|
||||||
node_modules/
|
node_modules/
|
||||||
@ -12,7 +11,6 @@ node_modules/
|
|||||||
npm-debug.log*
|
npm-debug.log*
|
||||||
yarn-debug.log*
|
yarn-debug.log*
|
||||||
yarn-error.log*
|
yarn-error.log*
|
||||||
package-lock.json
|
|
||||||
pnpm-debug.log*
|
pnpm-debug.log*
|
||||||
|
|
||||||
# Build outputs
|
# Build outputs
|
||||||
@ -28,49 +26,46 @@ build/
|
|||||||
*.swo
|
*.swo
|
||||||
*~
|
*~
|
||||||
.DS_Store
|
.DS_Store
|
||||||
|
._*
|
||||||
|
Thumbs.db
|
||||||
|
|
||||||
# Environment and local overrides
|
# Environment and local overrides
|
||||||
.env
|
.env
|
||||||
.env.local
|
.env.local
|
||||||
.env.*.local
|
.env.*.local
|
||||||
|
.env.production
|
||||||
|
core/.env.production
|
||||||
scripts/deploy-config.sh
|
scripts/deploy-config.sh
|
||||||
|
|
||||||
# Logs
|
# Logs
|
||||||
logs/
|
logs/
|
||||||
*.log
|
*.log
|
||||||
|
|
||||||
# OS
|
|
||||||
.DS_Store
|
|
||||||
Thumbs.db
|
|
||||||
|
|
||||||
# Testing
|
# Testing
|
||||||
coverage/
|
coverage/
|
||||||
.nyc_output/
|
.nyc_output/
|
||||||
|
|
||||||
# Temporary files
|
# Image / release artifacts
|
||||||
*.tmp
|
|
||||||
*.temp
|
|
||||||
|
|
||||||
# Build artifacts
|
|
||||||
*.iso
|
*.iso
|
||||||
*.img
|
*.img
|
||||||
*.dmg
|
*.dmg
|
||||||
*.app
|
*.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/
|
!releases/
|
||||||
!releases/manifest.json
|
!releases/manifest.json
|
||||||
|
|
||||||
# macOS build output
|
|
||||||
build/macos/
|
|
||||||
|
|
||||||
# Image recipe output
|
# Image recipe output
|
||||||
image-recipe/output/
|
image-recipe/output/
|
||||||
image-recipe/*.iso
|
image-recipe/*.iso
|
||||||
image-recipe/*.img
|
image-recipe/*.img
|
||||||
|
|
||||||
# Loop tool artifacts (created in every subdirectory)
|
# Loop tool artifacts
|
||||||
*/loop/
|
*/loop/
|
||||||
loop/loop/
|
loop/loop/
|
||||||
loop/loop.log.bak
|
loop/loop.log.bak
|
||||||
@ -78,19 +73,17 @@ loop/loop.log.bak
|
|||||||
# Separate repos nested in tree
|
# Separate repos nested in tree
|
||||||
web/
|
web/
|
||||||
|
|
||||||
._*
|
# Resilience harness reports contain session cookies.
|
||||||
|
|
||||||
# Resilience harness reports (generated, contains session cookies)
|
|
||||||
scripts/resilience/reports/
|
scripts/resilience/reports/
|
||||||
|
|
||||||
# Codex / pnpm / python caches / editor backups
|
# Codex / pnpm / python caches / editor backups
|
||||||
.codex
|
.codex
|
||||||
.codex-target-*/
|
.codex-target-*/
|
||||||
.codex-tmp/
|
.codex-tmp/
|
||||||
|
.claude/
|
||||||
.pnpm-store/
|
.pnpm-store/
|
||||||
**/__pycache__/
|
**/__pycache__/
|
||||||
*.bak
|
*.bak
|
||||||
.claude/scheduled_tasks.lock
|
|
||||||
|
|
||||||
# Local evidence screenshots; intentional UI screenshots should live under an
|
# Local evidence screenshots; intentional UI screenshots should live under an
|
||||||
# app/docs asset path with a descriptive filename.
|
# app/docs asset path with a descriptive filename.
|
||||||
|
|||||||
32
.planning/INGEST-CONFLICTS.md
Normal file
32
.planning/INGEST-CONFLICTS.md
Normal file
@ -0,0 +1,32 @@
|
|||||||
|
# Ingest Conflict Report
|
||||||
|
|
||||||
|
Mode: new (fresh bootstrap — no existing .planning/ context to check against)
|
||||||
|
Precedence: ADR > SPEC > PRD > DOC (no per-doc overrides present)
|
||||||
|
|
||||||
|
## Conflict Detection Report
|
||||||
|
|
||||||
|
### BLOCKERS (0)
|
||||||
|
|
||||||
|
(none)
|
||||||
|
|
||||||
|
### WARNINGS (0)
|
||||||
|
|
||||||
|
(none)
|
||||||
|
|
||||||
|
### INFO (4)
|
||||||
|
|
||||||
|
[INFO] Overlapping locked ADRs on Nostr marketplace discovery — consistent, not contradictory
|
||||||
|
Found: docs/adr/003-nostr-for-discovery.md and docs/adr/006-nostr-marketplace-discovery.md are both locked and both decide "Nostr relays (NIP-78, kind 30078) for app manifest discovery" over the same scope
|
||||||
|
Note: The decisions agree; ADR-006 refines ADR-003 with concrete trust tiers (Verified/Community/Unverified), curated built-in app list, and pre-install signature verification. Both preserved as separate entries in intel/decisions.md; no resolution needed. Consider marking one as superseding/refining the other in the docs for hygiene.
|
||||||
|
|
||||||
|
[INFO] SPEC security validation list narrower than ADR-009 mandatory defaults
|
||||||
|
Found: docs/adr/009-manifest-container-security.md (locked) mandates non-root UID (> 1000), pinned image tags (no `latest`), and a default seccomp profile as non-negotiable defaults; docs/app-manifest-spec.md's documented SecurityPolicy schema and AppManifest::validate() list do not mention these three (SecurityPolicy has apparmor_profile but no seccomp field)
|
||||||
|
Note: This is SPEC silence, not contradiction — no auto-resolution applied. ADR-009 governs by precedence (ADR > SPEC) and lock status. The SPEC itself declares `core/container/src/manifest.rs` canonical over the doc, so the gap may be documentation drift rather than implementation drift. Flagged for downstream verification, recorded as absent in intel/constraints.md.
|
||||||
|
|
||||||
|
[INFO] ADR numbering gap — ADR-010 absent from ingest set
|
||||||
|
Found: Classified ADRs run 001–009 and 011; no classification exists for an ADR-010
|
||||||
|
Note: Either ADR-010 does not exist, was withdrawn, or was not included in the ingest. No action required for synthesis; noted for completeness of the decision record.
|
||||||
|
|
||||||
|
[INFO] Cross-reference graph is acyclic
|
||||||
|
Found: cross_refs edges: ADR-007 → ADR-003; ADR-009 → docs/app-manifest-spec.md (+ code paths); SPEC → out-of-set docs and code only (app-developer-guide.md, manifest-hooks-design.md, marketplace-protocol.md, core/container/src/manifest.rs, api/rpc/package/stacks.rs)
|
||||||
|
Note: DFS cycle detection found no cycles; all 11 docs were synthesized. Several SPEC cross-refs point to documents not in the ingest set — they were not followed.
|
||||||
115
.planning/PROJECT.md
Normal file
115
.planning/PROJECT.md
Normal file
@ -0,0 +1,115 @@
|
|||||||
|
# Archipelago
|
||||||
|
|
||||||
|
## What This Is
|
||||||
|
|
||||||
|
Archipelago is a self-hosted personal-server platform: a Rust daemon (workspace at `core/`)
|
||||||
|
plus a Vue 3 frontend (`neode-ui/`, built to `web/dist/neode-ui/`) running on Debian nodes
|
||||||
|
with rootless Podman, managing ~40 declarative, manifest-driven apps (Bitcoin, Lightning,
|
||||||
|
mesh/LoRa, federation, media, and more). It ships as OTA-updated releases to a live fleet
|
||||||
|
and is actively shipping v1.7.x alpha releases. This milestone drives it to the
|
||||||
|
**developer-ready app platform** north star.
|
||||||
|
|
||||||
|
## Core Value
|
||||||
|
|
||||||
|
A third-party developer can publish an app via the signed/decentralized registry and a user
|
||||||
|
can install it on their node — every app manifest-driven, manifests shipped via the signed
|
||||||
|
registry (not OTA disk files), all rootless, secure, robust, and 100%-uptime-capable.
|
||||||
|
|
||||||
|
## Current State (brownfield baseline, 2026-07-29)
|
||||||
|
|
||||||
|
- Single-node production gate is **GREEN** (5/5 on .228, 2026-06-23) — that exit criterion is met.
|
||||||
|
- ~40 apps are manifest-based and Quadlet-migrated; all multi-container stacks use the
|
||||||
|
orchestrator stack pattern; the legacy per-app installer anti-pattern is deleted.
|
||||||
|
- Workstream B (registry-distributed manifests) phases 1+2 are code-complete; the signing
|
||||||
|
ceremony is done (release-root pinned in `anchor.rs`); the fleet flip is not yet authorized.
|
||||||
|
- Workstream C (marketplace) is design-only (`docs/marketplace-protocol.md`); no tooling or
|
||||||
|
trust UX built. Developer CLI suite (`archy app …`) does not exist yet.
|
||||||
|
- Phase-3 Quadlet default-flip is validated opt-in on .228/.198 but not default.
|
||||||
|
- Declared next exit criteria: the multinode pass (`docs/multinode-testing-plan.md`) and the
|
||||||
|
remaining workstreams.
|
||||||
|
|
||||||
|
## Requirements
|
||||||
|
|
||||||
|
### Validated
|
||||||
|
|
||||||
|
- ✓ Single-node lifecycle gate green 5× on .228 (install/UI/stop/start/restart/reinstall/
|
||||||
|
reboot-survive/daemon-restart-survive/uninstall) — 2026-06-23
|
||||||
|
- ✓ Manifest-driven app packaging for all ~40 apps incl. multi-container stacks (workstream A)
|
||||||
|
- ✓ Signed catalog + release-root signing ceremony (workstream B phases 1+2, code-complete)
|
||||||
|
|
||||||
|
### Active
|
||||||
|
|
||||||
|
See `.planning/REQUIREMENTS.md` — 20 v1 requirements across MNODE / LIFE / REG / SEC / DEV / MKT,
|
||||||
|
all mapped to phases in `.planning/ROADMAP.md`.
|
||||||
|
|
||||||
|
### Out of Scope
|
||||||
|
|
||||||
|
- Rootful containers, Docker, privileged containers — invariant (ADR-001/ADR-009)
|
||||||
|
- Per-app Rust installers / OS-level provisioning — the anti-pattern being deleted
|
||||||
|
- Centralized gatekept app store — decentralized Nostr marketplace instead (ADR-006)
|
||||||
|
- Web5 DWN spec compliance — deprioritized after TBD shutdown (ADR-011)
|
||||||
|
- Custom live voice-call protocol — deprioritized per user 2026-07-01; revisit later
|
||||||
|
- DHT/iroh distribution backbone (workstream D) — design-only, tracker-marked backlog; v2
|
||||||
|
|
||||||
|
## Context
|
||||||
|
|
||||||
|
- Repo: `core/` Rust workspace (no root Cargo.toml), `neode-ui/` Vue frontend, `apps/` manifests,
|
||||||
|
`tests/lifecycle/` + `tests/multinode/` gates, `docs/` authoritative plans.
|
||||||
|
- Authoritative narrative: `docs/PRODUCTION-MASTER-PLAN.md`; day-to-day open list:
|
||||||
|
`docs/UNIFIED-TASK-TRACKER.md`. Codebase map: `.planning/codebase/ARCHITECTURE.md` +
|
||||||
|
`.planning/codebase/CONCERNS.md`.
|
||||||
|
- Known debt informing this milestone (from CONCERNS.md): federation tombstone-write errors
|
||||||
|
swallowed; reconciler has no flap observability and no failed-unit self-healing; generated
|
||||||
|
AppArmor profiles are never applied; multinode test harness curl calls lack timeouts;
|
||||||
|
SPEC validation is narrower than ADR-009's mandates (non-root UID, pinned tags, seccomp).
|
||||||
|
- Fleet is live and OTA-updated; all destructive verification happens on designated test
|
||||||
|
nodes per the deploy roster — never uninvited on in-use nodes.
|
||||||
|
|
||||||
|
## Constraints
|
||||||
|
|
||||||
|
- **Security**: Rootless Podman only; manifest-declared secrets (0600, never logged);
|
||||||
|
mandatory container security defaults enforced at manifest level (ADR-009)
|
||||||
|
- **Data safety**: Migrations never destroy data — preserve `/var/lib/archipelago/<app>`,
|
||||||
|
secrets, credentials, ports, adoption container names; always a rollback path
|
||||||
|
- **Verification**: Real-node verification before any tag; lifecycle gate runs ON the node,
|
||||||
|
not via RPC; mesh changes need real-RF E2E tests; re-run the gate after orchestrator changes
|
||||||
|
- **Process**: Commit + push every unit of work (`git push gitea-ai main`); stage by explicit
|
||||||
|
path; deploy to the dev pair before any OTA; never commit secrets
|
||||||
|
- **Tech stack**: Rust (Tokio/Hyper, JSON-RPC 2.0) backend; Vue 3 + Pinia frontend;
|
||||||
|
Quadlet/systemd-user container units; Ed25519-signed release artifacts
|
||||||
|
|
||||||
|
## Key Decisions
|
||||||
|
|
||||||
|
<decisions>
|
||||||
|
|
||||||
|
All ten ADRs below are **locked** (Status: Accepted; ingest source `docs/adr/*.md`). They are
|
||||||
|
non-negotiable inputs to planning and cannot be overridden without a new ADR.
|
||||||
|
|
||||||
|
| ID | Decision | Scope |
|
||||||
|
|----|----------|-------|
|
||||||
|
| ADR-001 | Podman over Docker — rootless, daemonless, systemd-native; `archy-net` for inter-container DNS | Container runtime |
|
||||||
|
| ADR-002 | `did:key` (Ed25519) node identity — self-contained, offline-capable; gaps mitigated via federation trust lists | Identity |
|
||||||
|
| ADR-003 | Nostr relays (NIP-78, kind 30078) for node + app discovery — multi-relay query, 15-min cache, trust scoring, Tor-compatible | Discovery |
|
||||||
|
| ADR-004 | Tor hidden services for inter-node RPC/control plane — bulk data via registries, not Tor | Federation transport |
|
||||||
|
| ADR-005 | ChaCha20-Poly1305 + Argon2id (64MB, 3 iter) for backup encryption | Backups |
|
||||||
|
| ADR-006 | Nostr relays for marketplace discovery — DID-signed manifests, trust tiers (Verified/Community/Unverified), signature verification before install | Marketplace |
|
||||||
|
| ADR-007 | Bilateral DID federation trust via single-use invite codes; Trusted/Observer/Untrusted levels | Federation trust |
|
||||||
|
| ADR-008 | Dual keys from one master seed — Ed25519 canonical identity, secp256k1 for Nostr/Bitcoin/Lightning, linked via NIP-05 | Keys |
|
||||||
|
| ADR-009 | Manifest-level container security enforcement — readonly_root, no_new_privileges, non-root UID, drop-ALL caps, pinned tags, seccomp; overrides explicit + audited | Container security |
|
||||||
|
| ADR-011 | DWN deprioritized — keep custom `dwn_store.rs`, stop branding as Web5, invest in Nostr + Tor federation instead | Peer data sync |
|
||||||
|
|
||||||
|
(ADR-010 does not exist in the repo — numbering gap, noted in `.planning/INGEST-CONFLICTS.md`.)
|
||||||
|
|
||||||
|
</decisions>
|
||||||
|
|
||||||
|
Milestone-level decisions:
|
||||||
|
|
||||||
|
| Decision | Rationale | Outcome |
|
||||||
|
|----------|-----------|---------|
|
||||||
|
| Milestone version = 1.8.0-alpha | Decided 2026-07-08 per tracker | — Pending ship |
|
||||||
|
| Workstream D (DHT) deferred to v2 | Design-only, tracker-marked backlog; not needed for north-star metric | — Pending |
|
||||||
|
| App manifest canonical schema = `core/container/src/manifest.rs` | SPEC self-declares code wins over doc | ✓ Good |
|
||||||
|
| Phase-3 Quadlet flip gated on multinode gate reporting clean | Prior uncommitted-flip confusion; flip fresh as a 2-line change when gate is clean | — Pending |
|
||||||
|
|
||||||
|
---
|
||||||
|
*Last updated: 2026-07-29 after intel ingest (10 ADRs + 1 SPEC) + codebase mapping*
|
||||||
134
.planning/REQUIREMENTS.md
Normal file
134
.planning/REQUIREMENTS.md
Normal file
@ -0,0 +1,134 @@
|
|||||||
|
# Requirements: Archipelago (v1.8.0 — Developer-Ready App Platform)
|
||||||
|
|
||||||
|
**Defined:** 2026-07-29
|
||||||
|
**Core Value:** A third-party developer can publish an app via the signed/decentralized registry and a user can install it on their node — manifest-driven, rootless, secure, robust.
|
||||||
|
|
||||||
|
No PRDs existed in the ingest set; these requirements are derived from the master plan's
|
||||||
|
declared exit criteria (multinode pass + workstreams B/C/F), `.planning/codebase/CONCERNS.md`,
|
||||||
|
`docs/UNIFIED-TASK-TRACKER.md`, and the user-chosen success metric. Constraints from
|
||||||
|
`docs/app-manifest-spec.md` and the locked ADRs (see PROJECT.md) bound how each is built.
|
||||||
|
|
||||||
|
## v1 Requirements
|
||||||
|
|
||||||
|
### Federation & Mesh Hardening (FED)
|
||||||
|
|
||||||
|
- [ ] **FED-01**: Removing a federation node sticks — it disappears from every UI surface, tombstones propagate, it never reappears via later sync cycles, and a failed removal surfaces an error (never a silent no-op)
|
||||||
|
- [ ] **FED-02**: Federation sync converges and is observable — after sync settles, fleet nodes agree on the node list with fresh status; stale entries, duplicates, and silent sync failures are eliminated and sync errors are operator-visible
|
||||||
|
- [ ] **FED-03**: A structured code review of the federation/fleet area (`core/archipelago/src/federation`, node sync, FIPS/transport dial layer) and mesh area (`core/archipelago/src/mesh`, mesh RPC surface) is completed, with every finding fixed or explicitly deferred with a reason
|
||||||
|
- [ ] **FED-04**: Mesh messaging parity — attachment send (and the rest of the mesh chat surface) behaves identically on the demo and on real nodes: the demo backend implements the same RPC surface the UI calls, transport decisions mirror the real size-based tier logic, and no demo-only modals exist
|
||||||
|
- [ ] **FED-05**: Inter-node Lightning channel opening UX — the UI shows the node's shareable Lightning URI; lists trusted (federated) nodes by hostname for one-click channel opening; and lets the user browse/request channels with public nodes — using the existing design system and components, verified on the :8100 dev preview against archi-dev before deploy
|
||||||
|
- [ ] **FED-06**: On-brand payment success animation — the invoice "paid" tick's circle uses the screensaver-style ring with outer EQ-segment lines (reuse `ScreensaverRing.vue`'s compact size) in place of the current success burst, applied consistently everywhere the paid tick shows
|
||||||
|
|
||||||
|
### UI Performance (PERF)
|
||||||
|
|
||||||
|
- [ ] **PERF-01**: The slowest tab switches and secondary-screen opens are profiled with causes named (remount storms, serial RPC waterfalls, uncached fetches) — fixes are targeted, not guessed
|
||||||
|
- [ ] **PERF-02**: Main-tab switches render immediately from cached state with background refresh — no blank screens or long spinners on tabs already visited this session
|
||||||
|
- [ ] **PERF-03**: Secondary screens (screens reached from a tab's main page) open without a blocking full reload and are instant on repeat visits — verified on real node hardware, not just the dev box
|
||||||
|
|
||||||
|
### Multinode Verification (MNODE)
|
||||||
|
|
||||||
|
- [ ] **MNODE-01**: The 5× destructive lifecycle gate passes on a second fleet node (archy-x250-beta) with 0 failures, run on-node per gate policy
|
||||||
|
- [ ] **MNODE-02**: Cross-node federation/mesh/transport suites (`tests/multinode/smoke.sh`, `meshtastic.sh`) pass between fleet nodes, with all harness RPC calls time-bounded (no indefinite curl hangs)
|
||||||
|
- [ ] **MNODE-03**: Removing a federation peer sticks — tombstone-write failures are surfaced (not swallowed) and a removed peer never silently reappears after subsequent sync cycles
|
||||||
|
|
||||||
|
### Lifecycle Perfection (LIFE)
|
||||||
|
|
||||||
|
- [ ] **LIFE-01**: Quadlet backends are the default — restarting `archipelago.service` leaves every app container running (no SIGKILL-the-world, no multi-minute rebuild storm)
|
||||||
|
- [ ] **LIFE-02**: The reconciler self-heals failed Quadlet units — a `.service` in `failed` state (and not user-stopped) is reset-failed + started automatically, with backoff against busy-looping
|
||||||
|
- [ ] **LIFE-03**: Per-app restart/flap observability — restart counters, a threshold log line when an app restarts >N times in M minutes, and restart counts surfaced in health/status RPC output
|
||||||
|
- [ ] **LIFE-04**: Cascade uninstall→reinstall is gate-verified for multi-container stacks and installed apps — no ghost entries, no orphan containers, data preserved per policy, reinstall returns healthy
|
||||||
|
- [ ] **LIFE-05**: Install and uninstall report real, monotonic progress driven by backend progress events, always reaching a terminal success/failure state — asserted in the gate, never a fake or stuck bar
|
||||||
|
|
||||||
|
### Registry-Distributed Manifests (REG)
|
||||||
|
|
||||||
|
- [ ] **REG-01**: The published signed catalog embeds full app manifests; nodes install/update from signature-verified catalog manifests (disk manifests remain the fallback for build-source apps); tampered catalogs are rejected with safe fallback
|
||||||
|
- [ ] **REG-02**: The fleet is flipped to registry-distributed manifests — adding or bumping an image-only app requires only a re-signed catalog publish, no binary OTA or disk rsync
|
||||||
|
|
||||||
|
### Security Enforcement (SEC)
|
||||||
|
|
||||||
|
- [ ] **SEC-01**: `AppManifest::validate()` enforces the full ADR-009 mandate set — non-root UID, pinned image tags (no `latest`), capability allow-list, seccomp — with explicit, documented, auditable overrides
|
||||||
|
- [ ] **SEC-02**: Generated AppArmor/seccomp security profiles are actually applied at container creation (`--security-opt`) and verified effective on running apps
|
||||||
|
|
||||||
|
### Developer Tooling (DEV)
|
||||||
|
|
||||||
|
- [ ] **DEV-01**: `archy app validate` checks a manifest locally and returns the same pass/fail verdict the node enforces (schema + security rules)
|
||||||
|
- [ ] **DEV-02**: `archy app render` previews the exact Quadlet/podman configuration a manifest produces
|
||||||
|
- [ ] **DEV-03**: A developer can local-install and lifecycle-test an app against a dev node from the CLI (`archy app local-install` / `lifecycle-test`)
|
||||||
|
- [ ] **DEV-04**: The developer guide walks a new third-party developer from an empty directory to an installed, running app using only the CLI and docs
|
||||||
|
|
||||||
|
### Decentralized Marketplace (MKT)
|
||||||
|
|
||||||
|
- [ ] **MKT-01**: A third-party developer can publish a DID-signed app manifest to public Nostr relays (NIP-78, kind 30078) via the tooling
|
||||||
|
- [ ] **MKT-02**: A node discovers marketplace apps from multiple relays and displays each app's trust tier (Verified / Community / Unverified) per ADR-006 trust scoring
|
||||||
|
- [ ] **MKT-03**: Manifest signatures are verified before installation; tampered or invalid marketplace manifests cannot be installed
|
||||||
|
- [ ] **MKT-04**: End-to-end north star: a user installs a third-party marketplace-published app on their node and it runs healthy under the standard lifecycle guarantees
|
||||||
|
|
||||||
|
## v2 Requirements
|
||||||
|
|
||||||
|
Deferred to a future milestone. Tracked but not in the current roadmap.
|
||||||
|
|
||||||
|
### Distribution Backbone (DIST)
|
||||||
|
|
||||||
|
- **DIST-01**: BLAKE3 content-addressed catalog distribution via iroh swarm, origin-always-wins (workstream D — design-only today, tracker-marked backlog)
|
||||||
|
|
||||||
|
### Fleet & Hardening (FLEET)
|
||||||
|
|
||||||
|
- **FLEET-01**: Bitcoin multi-version fleet-wide OTA rollout (user-gated on timing per `docs/bitcoin-version-bulletproof-rollout.md`)
|
||||||
|
- **FLEET-02**: App-specific health assertions for the ~34 apps with only baseline lifecycle coverage
|
||||||
|
- **FLEET-03**: LUKS2 full-partition encryption for `/var/lib/archipelago/`
|
||||||
|
- **FLEET-04**: Dynamic per-app resource rebalancing (cgroup-stats feedback loop)
|
||||||
|
|
||||||
|
## Out of Scope
|
||||||
|
|
||||||
|
| Feature | Reason |
|
||||||
|
|---------|--------|
|
||||||
|
| Rootful/privileged containers, Docker | Invariant — ADR-001/ADR-009 |
|
||||||
|
| Per-app Rust installers / host provisioning | The anti-pattern workstream A deleted |
|
||||||
|
| Centralized gatekept app store | ADR-006 chose decentralized Nostr marketplace |
|
||||||
|
| Web5 DWN spec compliance | ADR-011 — deprioritized after TBD shutdown |
|
||||||
|
| Custom live voice-call protocol | Deprioritized 2026-07-01 per user; no scope decided |
|
||||||
|
|
||||||
|
## Traceability
|
||||||
|
|
||||||
|
Which phases cover which requirements. Updated during roadmap creation.
|
||||||
|
|
||||||
|
| Requirement | Phase | Status |
|
||||||
|
|-------------|-------|--------|
|
||||||
|
| FED-01 | Phase 1 | Pending |
|
||||||
|
| FED-02 | Phase 1 | Pending |
|
||||||
|
| FED-03 | Phase 1 | Pending |
|
||||||
|
| FED-04 | Phase 1 | Pending |
|
||||||
|
| FED-05 | Phase 1 | Pending |
|
||||||
|
| FED-06 | Phase 1 | Pending |
|
||||||
|
| PERF-01 | Phase 2 | Pending |
|
||||||
|
| PERF-02 | Phase 2 | Pending |
|
||||||
|
| PERF-03 | Phase 2 | Pending |
|
||||||
|
| MNODE-01 | Phase 3 | Pending |
|
||||||
|
| MNODE-02 | Phase 3 | Pending |
|
||||||
|
| MNODE-03 | Phase 3 | Pending |
|
||||||
|
| LIFE-01 | Phase 4 | Pending |
|
||||||
|
| LIFE-02 | Phase 4 | Pending |
|
||||||
|
| LIFE-03 | Phase 4 | Pending |
|
||||||
|
| LIFE-04 | Phase 4 | Pending |
|
||||||
|
| LIFE-05 | Phase 4 | Pending |
|
||||||
|
| REG-01 | Phase 5 | Pending |
|
||||||
|
| REG-02 | Phase 5 | Pending |
|
||||||
|
| SEC-01 | Phase 6 | Pending |
|
||||||
|
| SEC-02 | Phase 6 | Pending |
|
||||||
|
| DEV-01 | Phase 7 | Pending |
|
||||||
|
| DEV-02 | Phase 7 | Pending |
|
||||||
|
| DEV-03 | Phase 7 | Pending |
|
||||||
|
| DEV-04 | Phase 7 | Pending |
|
||||||
|
| MKT-01 | Phase 8 | Pending |
|
||||||
|
| MKT-02 | Phase 8 | Pending |
|
||||||
|
| MKT-03 | Phase 8 | Pending |
|
||||||
|
| MKT-04 | Phase 8 | Pending |
|
||||||
|
|
||||||
|
**Coverage:**
|
||||||
|
- v1 requirements: 29 total
|
||||||
|
- Mapped to phases: 29
|
||||||
|
- Unmapped: 0
|
||||||
|
|
||||||
|
---
|
||||||
|
*Requirements defined: 2026-07-29*
|
||||||
|
*Last updated: 2026-07-29 — added FED (federation/mesh hardening) and PERF (UI performance) requirement groups; phases renumbered after inserting them as Phases 1–2*
|
||||||
141
.planning/ROADMAP.md
Normal file
141
.planning/ROADMAP.md
Normal file
@ -0,0 +1,141 @@
|
|||||||
|
# Roadmap: Archipelago — v1.8.0 Developer-Ready App Platform
|
||||||
|
|
||||||
|
## Overview
|
||||||
|
|
||||||
|
Brownfield milestone starting from a green single-node production gate (5/5 on .228,
|
||||||
|
2026-06-23). The journey: make federation and mesh rock-solid (node removal, sync,
|
||||||
|
messaging parity), fix the UI slowness users feel on every tab switch, prove the platform
|
||||||
|
across the fleet (multinode pass), make the container lifecycle bulletproof (Quadlet
|
||||||
|
default, self-healing, honest progress, no ghosts), flip manifest distribution from OTA
|
||||||
|
disk files to the signed registry, harden manifest security enforcement to the full
|
||||||
|
ADR-009 bar, ship the `archy app` developer CLI, and land the decentralized Nostr
|
||||||
|
marketplace — ending at the north star: a third-party developer publishes an app via the
|
||||||
|
signed/decentralized registry and a user installs it on their node.
|
||||||
|
|
||||||
|
## Phases
|
||||||
|
|
||||||
|
**Phase Numbering:**
|
||||||
|
- Integer phases (1, 2, 3): Planned milestone work
|
||||||
|
- Decimal phases (2.1, 2.2): Urgent insertions (marked with INSERTED)
|
||||||
|
|
||||||
|
- [ ] **Phase 1: Federation & Mesh Hardening** - Deep review of federation/fleet + mesh code; node removal sticks, sync converges, mesh messaging behaves identically on demo and real nodes
|
||||||
|
- [ ] **Phase 2: UI Performance** - Tab switches and secondary screens render fast; worst transitions measured and fixed
|
||||||
|
- [ ] **Phase 3: Multinode Verification Pass** - Lifecycle gate green on a second node; cross-node federation/mesh/transport suites pass; federation removal sticks
|
||||||
|
- [ ] **Phase 4: Lifecycle Perfection & Quadlet Default** - Quadlet backends default, failed-unit self-healing, flap observability, cascade gate, truthful progress
|
||||||
|
- [ ] **Phase 5: Registry-Distributed Manifests** - Signed catalog carries full manifests; fleet flipped off OTA disk-file distribution
|
||||||
|
- [ ] **Phase 6: Manifest Security Enforcement** - Validation matches ADR-009 mandates; generated security profiles actually applied
|
||||||
|
- [ ] **Phase 7: Developer Tooling CLI** - `archy app validate/render/local-install/lifecycle-test` + developer guide
|
||||||
|
- [ ] **Phase 8: Decentralized Marketplace** - DID-signed publish to Nostr relays, trust-tier discovery, verified third-party install end-to-end
|
||||||
|
|
||||||
|
## Phase Details
|
||||||
|
|
||||||
|
### Phase 1: Federation & Mesh Hardening
|
||||||
|
**Goal**: Federation and mesh are tight — a structured review of the fleet/federation and mesh code feeds fixes so node removal sticks, sync converges, and mesh messaging (including attachments) behaves identically everywhere it runs
|
||||||
|
**Depends on**: Nothing (first phase)
|
||||||
|
**Requirements**: FED-01, FED-02, FED-03, FED-04, FED-05, FED-06
|
||||||
|
**Success Criteria** (what must be TRUE):
|
||||||
|
1. A structured code review of the federation/fleet area (`core/archipelago/src/federation`, node sync, FIPS/transport dial layer) and the mesh area (`core/archipelago/src/mesh`, mesh RPC surface) produces a findings list, and every finding is fixed or explicitly deferred with a reason
|
||||||
|
2. Removing a federation node removes it everywhere — it disappears from all UI surfaces, tombstones propagate, and it never reappears after later sync cycles; a failed removal surfaces an error instead of silently no-opping
|
||||||
|
3. Federation sync converges: after sync settles, fleet nodes agree on the node list and node status is fresh — stale entries, duplicates, and silent sync failures are gone, and sync errors are visible to the operator
|
||||||
|
4. Mesh attachment send works identically on the demo and on real nodes — same modals, same transport decisions, same success — with the demo backend implementing the same RPC surface the UI calls (no "Method not found", no demo-only chooser modal)
|
||||||
|
5. Channel-opening between nodes is first-class UI: a user can copy/share their node's Lightning URI; sees a list of trusted (federated) nodes by hostname to open a channel with in one flow; and can browse/request channels with public nodes — built with the existing design system (Teleport-to-body modals, house style), tested live on the :8100 dev preview against archi-dev, and fixed there before any deploy
|
||||||
|
6. The invoice/payment "paid" success animation is on-brand: the tick's circle is the screensaver-style ring with the outer EQ-segment lines (reuse `neode-ui/src/components/ScreensaverRing.vue`, which already ships a `compact` overlay size), replacing the current burst in the payment success pane (`neode-ui/src/components/SendBitcoinModal.vue`) and matching wherever else the paid tick appears
|
||||||
|
**Plans**: TBD
|
||||||
|
**UI hint**: yes
|
||||||
|
|
||||||
|
### Phase 2: UI Performance
|
||||||
|
**Goal**: The UI feels fast — switching tabs and opening secondary screens (screens reached from a tab's main page) renders promptly instead of stalling on refetches and remounts
|
||||||
|
**Depends on**: Nothing (frontend-focused; parallelizable with Phase 1)
|
||||||
|
**Requirements**: PERF-01, PERF-02, PERF-03
|
||||||
|
**Success Criteria** (what must be TRUE):
|
||||||
|
1. The slowest tab switches and secondary-screen opens are profiled and the causes named (remount storms, serial RPC waterfalls, uncached fetches) before fixes land
|
||||||
|
2. Switching between main tabs renders the target view immediately from cached state, refreshing data in the background — no blank screens or long spinners on tabs already visited this session
|
||||||
|
3. Secondary screens open without a blocking full reload; repeat visits are instant
|
||||||
|
4. The fixes are verified on real node hardware (not just the dev box) — the sluggishness the user reported is gone on-device
|
||||||
|
**Plans**: TBD
|
||||||
|
**UI hint**: yes
|
||||||
|
|
||||||
|
### Phase 3: Multinode Verification Pass
|
||||||
|
**Goal**: The platform's lifecycle and federation guarantees are proven across the fleet, not just on .228 — the declared next exit criterion
|
||||||
|
**Depends on**: Phase 1 (proves the federation/mesh fixes hold fleet-wide)
|
||||||
|
**Requirements**: MNODE-01, MNODE-02, MNODE-03
|
||||||
|
**Success Criteria** (what must be TRUE):
|
||||||
|
1. The 5× destructive lifecycle gate reports 0 failures on a second fleet node (archy-x250-beta), run on-node
|
||||||
|
2. The cross-node smoke suite (federation pairing both directions, FIPS anchors, peer content browse) passes between two fleet nodes with every harness RPC time-bounded — a slow node produces a test failure, never an indefinite hang
|
||||||
|
3. An operator who removes a federation peer never sees it reappear in the peer list after later sync cycles; a tombstone-write failure is surfaced as an error instead of silently swallowed
|
||||||
|
4. The on-air mesh suite passes between two radio-equipped nodes over real RF
|
||||||
|
**Plans**: TBD
|
||||||
|
|
||||||
|
### Phase 4: Lifecycle Perfection & Quadlet Default
|
||||||
|
**Goal**: An insanely-reliable container environment — every app installs, runs, restarts, uninstalls, and reinstalls cleanly with honest progress, no ghosts, and automatic recovery
|
||||||
|
**Depends on**: Phase 3 (Quadlet default-flip is gated on the second-node gate reporting clean)
|
||||||
|
**Requirements**: LIFE-01, LIFE-02, LIFE-03, LIFE-04, LIFE-05
|
||||||
|
**Success Criteria** (what must be TRUE):
|
||||||
|
1. Restarting `archipelago.service` on a fleet node leaves every app container running — no SIGKILL-the-world, no multi-minute reconciler rebuild
|
||||||
|
2. An app whose Quadlet unit enters `failed` state (and was not user-stopped) comes back automatically within a bounded window, with backoff on persistent failure — no operator intervention
|
||||||
|
3. An operator can see per-app restart counts in status output, and a flapping app (>N restarts in M minutes) is flagged in logs instead of being invisible
|
||||||
|
4. Uninstalling then reinstalling any gated app — including multi-container stacks like immich/btcpay — leaves no ghost My-Apps entries or orphan containers, preserves data per policy, and returns the app healthy, verified by the cascade gate tier
|
||||||
|
5. Install and uninstall progress bars move monotonically from real backend progress events and always land on a terminal success/failure state — asserted in the gate, and the single-node gate stays green after all orchestrator changes
|
||||||
|
**Plans**: TBD
|
||||||
|
**UI hint**: yes
|
||||||
|
|
||||||
|
### Phase 5: Registry-Distributed Manifests
|
||||||
|
**Goal**: Manifests ship via the signed registry, not OTA disk files — bumping or adding an app becomes a signed catalog change
|
||||||
|
**Depends on**: Phase 4 (fleet lifecycle stable under Quadlet default before changing the distribution channel)
|
||||||
|
**Requirements**: REG-01, REG-02
|
||||||
|
**Success Criteria** (what must be TRUE):
|
||||||
|
1. A fleet node installs and updates an image-only app from the full manifest embedded in the signed catalog, verified against the pinned release-root key, with no corresponding OTA disk file present (disk remains the fallback for build-source apps)
|
||||||
|
2. A tampered or unsigned catalog manifest is rejected and the node falls back safely — it never installs from an unverified manifest
|
||||||
|
3. Bumping an app version fleet-wide requires only regenerating, re-signing, and publishing the catalog — no binary OTA, no disk rsync — proven live on the fleet
|
||||||
|
**Plans**: TBD
|
||||||
|
|
||||||
|
### Phase 6: Manifest Security Enforcement
|
||||||
|
**Goal**: A third-party manifest cannot weaken node security — declared security policy is fully validated and actually enforced at runtime
|
||||||
|
**Depends on**: Phase 5 (enforcement guards the registry channel third-party manifests will arrive through)
|
||||||
|
**Requirements**: SEC-01, SEC-02
|
||||||
|
**Success Criteria** (what must be TRUE):
|
||||||
|
1. A manifest violating ADR-009 mandates (root user, unpinned `latest` tag, capability outside the allow-list, disabled seccomp) is rejected at validation with a clear error naming the violation
|
||||||
|
2. Security overrides (`readonly_root: false`, extra capabilities) work only when explicitly listed in the manifest and leave an audit trail
|
||||||
|
3. Generated AppArmor/seccomp profiles are applied to containers at creation and verifiably effective on a running app — not just generated and ignored
|
||||||
|
4. The single-node lifecycle gate stays green with enforcement on — existing catalog apps all pass the strengthened validation (or carry documented overrides)
|
||||||
|
**Plans**: TBD
|
||||||
|
|
||||||
|
### Phase 7: Developer Tooling CLI
|
||||||
|
**Goal**: A third-party developer can build, validate, and test an Archipelago app locally without reading platform internals
|
||||||
|
**Depends on**: Phase 6 (CLI validation must mirror the final enforced rule set)
|
||||||
|
**Requirements**: DEV-01, DEV-02, DEV-03, DEV-04
|
||||||
|
**Success Criteria** (what must be TRUE):
|
||||||
|
1. A developer runs `archy app validate` on a manifest directory and gets the same pass/fail verdict — including security rules — that a node would enforce at install
|
||||||
|
2. A developer runs `archy app render` and sees the exact Quadlet/podman configuration their manifest produces before ever touching a node
|
||||||
|
3. A developer can install their app onto a dev node and run its lifecycle test (install/UI/stop/start/restart/uninstall) from the CLI
|
||||||
|
4. A new developer following only the developer guide goes from an empty directory to a running app on a node — no tribal knowledge required
|
||||||
|
**Plans**: TBD
|
||||||
|
|
||||||
|
### Phase 8: Decentralized Marketplace
|
||||||
|
**Goal**: The north star — third-party developers publish apps via the decentralized registry and users install them on their nodes
|
||||||
|
**Depends on**: Phase 7 (publish rides the CLI; installs ride registry distribution from Phase 5 and enforcement from Phase 6)
|
||||||
|
**Requirements**: MKT-01, MKT-02, MKT-03, MKT-04
|
||||||
|
**Success Criteria** (what must be TRUE):
|
||||||
|
1. A third-party developer publishes a DID-signed app manifest to public Nostr relays (NIP-78, kind 30078) using the tooling
|
||||||
|
2. A node discovers the published app from multiple relays and the app store UI shows its trust tier (Verified / Community / Unverified) per ADR-006 scoring
|
||||||
|
3. The node verifies the manifest signature before installation; a tampered or invalid marketplace manifest cannot be installed
|
||||||
|
4. A user installs the third-party marketplace-published app on their node and it runs healthy under the standard lifecycle guarantees — the user-chosen success metric, demonstrated end-to-end
|
||||||
|
**Plans**: TBD
|
||||||
|
**UI hint**: yes
|
||||||
|
|
||||||
|
## Progress
|
||||||
|
|
||||||
|
**Execution Order:**
|
||||||
|
Phases execute in numeric order: 1 → 2 → 3 → 4 → 5 → 6 → 7 → 8
|
||||||
|
(Phases 1 and 2 are independent and may be worked in parallel.)
|
||||||
|
|
||||||
|
| Phase | Plans Complete | Status | Completed |
|
||||||
|
|-------|----------------|--------|-----------|
|
||||||
|
| 1. Federation & Mesh Hardening | 0/TBD | Not started | - |
|
||||||
|
| 2. UI Performance | 0/TBD | Not started | - |
|
||||||
|
| 3. Multinode Verification Pass | 0/TBD | Not started | - |
|
||||||
|
| 4. Lifecycle Perfection & Quadlet Default | 0/TBD | Not started | - |
|
||||||
|
| 5. Registry-Distributed Manifests | 0/TBD | Not started | - |
|
||||||
|
| 6. Manifest Security Enforcement | 0/TBD | Not started | - |
|
||||||
|
| 7. Developer Tooling CLI | 0/TBD | Not started | - |
|
||||||
|
| 8. Decentralized Marketplace | 0/TBD | Not started | - |
|
||||||
86
.planning/STATE.md
Normal file
86
.planning/STATE.md
Normal file
@ -0,0 +1,86 @@
|
|||||||
|
---
|
||||||
|
gsd_state_version: '1.0'
|
||||||
|
status: planning
|
||||||
|
progress:
|
||||||
|
total_phases: 8
|
||||||
|
completed_phases: 0
|
||||||
|
total_plans: 0
|
||||||
|
completed_plans: 0
|
||||||
|
percent: 0
|
||||||
|
---
|
||||||
|
|
||||||
|
# Project State
|
||||||
|
|
||||||
|
## Project Reference
|
||||||
|
|
||||||
|
See: .planning/PROJECT.md (updated 2026-07-29)
|
||||||
|
|
||||||
|
**Core value:** A third-party developer can publish an app via the signed/decentralized registry and a user can install it on their node — manifest-driven, rootless, secure, robust.
|
||||||
|
**Current focus:** Phase 1 — Federation & Mesh Hardening
|
||||||
|
|
||||||
|
## Current Position
|
||||||
|
|
||||||
|
Phase: 1 of 8 (Federation & Mesh Hardening)
|
||||||
|
Plan: 0 of TBD in current phase
|
||||||
|
Status: Ready to plan
|
||||||
|
Last activity: 2026-07-29 — Inserted Phase 1 (Federation & Mesh Hardening) and Phase 2 (UI Performance) per user priority; prior phases renumbered 3–8
|
||||||
|
|
||||||
|
Progress: [░░░░░░░░░░] 0%
|
||||||
|
|
||||||
|
## Performance Metrics
|
||||||
|
|
||||||
|
**Velocity:**
|
||||||
|
- Total plans completed: 0
|
||||||
|
- Average duration: —
|
||||||
|
- Total execution time: —
|
||||||
|
|
||||||
|
**By Phase:**
|
||||||
|
|
||||||
|
| Phase | Plans | Total | Avg/Plan |
|
||||||
|
|-------|-------|-------|----------|
|
||||||
|
| - | - | - | - |
|
||||||
|
|
||||||
|
## Accumulated Context
|
||||||
|
|
||||||
|
### Roadmap Evolution
|
||||||
|
|
||||||
|
- Phase 1 added (2026-07-29): Federation & Mesh Hardening — user-directed top priority (node removal/sync issues, mesh attachment parity incl. demo); prior phases shifted down
|
||||||
|
- Phase 2 added (2026-07-29): UI Performance — slow tab switches and secondary screens; prior phases shifted down
|
||||||
|
- FED-05 added to Phase 1 (2026-07-29): inter-node Lightning channel-opening UX (share node URI, pick trusted/federated nodes by hostname, request channels with public nodes); UI tested on :8100 dev preview against archi-dev before deploy
|
||||||
|
- FED-06 added to Phase 1 (2026-07-29): on-brand paid-tick animation — screensaver ring + EQ segments (reuse ScreensaverRing.vue compact) replacing the success burst in SendBitcoinModal.vue
|
||||||
|
|
||||||
|
### Decisions
|
||||||
|
|
||||||
|
Decisions are logged in PROJECT.md (10 locked ADRs in the `<decisions>` block + milestone decisions table). Recent decisions affecting current work:
|
||||||
|
|
||||||
|
- Milestone version = 1.8.0-alpha (decided 2026-07-08)
|
||||||
|
- Phase-3 Quadlet default-flip is gated on the second-node gate reporting clean (do fresh, never stage uncommitted)
|
||||||
|
- Workstream D (DHT distribution) deferred to v2 — design-only backlog
|
||||||
|
- Canonical manifest schema = `core/container/src/manifest.rs` (code wins over spec doc)
|
||||||
|
|
||||||
|
### Pending Todos
|
||||||
|
|
||||||
|
None yet.
|
||||||
|
|
||||||
|
### Blockers/Concerns
|
||||||
|
|
||||||
|
- [Phase 1] Federation tombstone fix touches trust code — fix carefully, re-verify with `tests/multinode/smoke.sh`, don't patch blind
|
||||||
|
- [Phase 3] Multinode gate on archy-x250-beta was launched 2026-07-01 (log on-node); verify outcome before re-running
|
||||||
|
- [Phase 5] Fleet registry flip awaits explicit user authorization + timing call
|
||||||
|
- [Phase 6] Strengthened ADR-009 validation may reject existing catalog apps — audit manifests before enforcement lands
|
||||||
|
- [Global] Live OTA fleet: deploy to the dev pair before any OTA; gate re-runs required after orchestrator changes; some verification is user/hardware-gated (radios, on-device tests)
|
||||||
|
|
||||||
|
## Deferred Items
|
||||||
|
|
||||||
|
| Category | Item | Status | Deferred At |
|
||||||
|
|----------|------|--------|-------------|
|
||||||
|
| Distribution | DIST-01 DHT/iroh backbone (workstream D) | v2 | 2026-07-29 |
|
||||||
|
| Fleet | FLEET-01 Bitcoin multi-version fleet OTA (user-gated) | v2 | 2026-07-29 |
|
||||||
|
| Fleet | FLEET-02 per-app deep health assertions (~34 apps) | v2 | 2026-07-29 |
|
||||||
|
| Fleet | FLEET-03 LUKS2 data-partition encryption | v2 | 2026-07-29 |
|
||||||
|
|
||||||
|
## Session Continuity
|
||||||
|
|
||||||
|
Last session: 2026-07-29
|
||||||
|
Stopped at: Roadmap + state initialized from ingest; next step is `/gsd-plan-phase 1`
|
||||||
|
Resume file: None
|
||||||
333
.planning/codebase/ARCHITECTURE.md
Normal file
333
.planning/codebase/ARCHITECTURE.md
Normal file
@ -0,0 +1,333 @@
|
|||||||
|
<!-- refreshed: 2026-07-29 -->
|
||||||
|
# Architecture
|
||||||
|
|
||||||
|
**Analysis Date:** 2026-07-29
|
||||||
|
|
||||||
|
## System Overview
|
||||||
|
|
||||||
|
```text
|
||||||
|
┌────────────────────────────────────────────────────────────────┐
|
||||||
|
│ Frontend Layer (Vue 3) │
|
||||||
|
│ `neode-ui/src` (TypeScript + SPA) │
|
||||||
|
│ Routes → Views → Components → Composables → RPC Client │
|
||||||
|
└────────────────┬─────────────────────────────────────────────┘
|
||||||
|
│ WebSocket + HTTP(S)
|
||||||
|
│ JSON-RPC 2.0 protocol
|
||||||
|
▼
|
||||||
|
┌────────────────────────────────────────────────────────────────┐
|
||||||
|
│ HTTP Server Layer (Hyper) │
|
||||||
|
│ `core/archipelago/src/server.rs` │
|
||||||
|
│ TCP Listener → Hyper → Router → ApiHandler/RpcHandler │
|
||||||
|
└────────────────┬─────────────────────────────────────────────┘
|
||||||
|
│
|
||||||
|
┌──────────┴──────────┬──────────────────┐
|
||||||
|
│ │ │
|
||||||
|
▼ ▼ ▼
|
||||||
|
┌─────────┐ ┌──────────┐ ┌────────────┐
|
||||||
|
│ WebSocket │ RPC │ │ Content │
|
||||||
|
│ Handler │ Handler │ │ Proxy │
|
||||||
|
│ (state sync) │ (methods)│ │ (app URIs) │
|
||||||
|
└─────────┘ └──────────┘ └────────────┘
|
||||||
|
│ │
|
||||||
|
└──────────┬───────┘
|
||||||
|
│
|
||||||
|
▼
|
||||||
|
┌─────────────────────────────────────┐
|
||||||
|
│ Service Layer (Async Tasks) │
|
||||||
|
│ `core/archipelago/src/api/rpc/*` │
|
||||||
|
│ │
|
||||||
|
│ • auth, identity, secrets │
|
||||||
|
│ • container orchestration │
|
||||||
|
│ • bitcoin, lightning, wallet │
|
||||||
|
│ • mesh, federation, FIPS │
|
||||||
|
│ • content, backup, settings │
|
||||||
|
└─────────────┬───────────────────────┘
|
||||||
|
│
|
||||||
|
┌───────────┼───────────┬──────────────┐
|
||||||
|
│ │ │ │
|
||||||
|
▼ ▼ ▼ ▼
|
||||||
|
┌─────────┐ ┌──────────┐ ┌────────┐ ┌──────────┐
|
||||||
|
│Container│ │ State │ │BlobStore
|
||||||
|
│Orch. │ │Manager │ │ │ │Identity │
|
||||||
|
│(Podman) │ │(Broadcast
|
||||||
|
│ │ │ channels)│ │ ContentClient Manager │
|
||||||
|
└─────────┘ └──────────┘ └────────┘ └──────────┘
|
||||||
|
│ │ │ │
|
||||||
|
└───────────┼───────────┼─────────┘
|
||||||
|
│
|
||||||
|
▼
|
||||||
|
┌─────────────────────────────────────┐
|
||||||
|
│ Persistent Storage Layer │
|
||||||
|
│ │
|
||||||
|
│ • Data directory files (YAML/JSON) │
|
||||||
|
│ • SQLite (session store) │
|
||||||
|
│ • Blob store (content-addressed) │
|
||||||
|
│ • Podman container state │
|
||||||
|
│ • Secret vaults (encrypted) │
|
||||||
|
└─────────────────────────────────────┘
|
||||||
|
```
|
||||||
|
|
||||||
|
## Component Responsibilities
|
||||||
|
|
||||||
|
| Component | Responsibility | File |
|
||||||
|
|-----------|----------------|------|
|
||||||
|
| **Server** | HTTP listener, connection multiplexing, TLS/encryption | `core/archipelago/src/server.rs` |
|
||||||
|
| **ApiHandler** | HTTP request routing, authentication, response formatting | `core/archipelago/src/api/handler/mod.rs` |
|
||||||
|
| **RpcHandler** | JSON-RPC 2.0 dispatch, method registration, rate limiting | `core/archipelago/src/api/rpc/mod.rs` |
|
||||||
|
| **ContainerOrchestrator** | Podman lifecycle, manifest reconciliation, adoption | `core/archipelago/src/container/prod_orchestrator.rs` |
|
||||||
|
| **StateManager** | Central state broadcast channel, revision tracking | `core/archipelago/src/state.rs` |
|
||||||
|
| **AuthManager** | User credentials, session validation, password hashing | `core/archipelago/src/auth.rs` |
|
||||||
|
| **Identity Manager** | Node Ed25519 keys, seed derivation, Tor address | `core/archipelago/src/identity_manager.rs` |
|
||||||
|
| **BootReconciler** | Periodic manifest sync loop, adoption, remediation | `core/archipelago/src/container/boot_reconciler.rs` |
|
||||||
|
| **Frontend Router** | Vue Router, page navigation, deep linking | `neode-ui/src/router/index.ts` |
|
||||||
|
| **Frontend Stores** | Pinia state (apps, settings, user, mesh) | `neode-ui/src/stores/` |
|
||||||
|
| **Frontend Components** | UI elements, modals, cards, layout primitives | `neode-ui/src/components/` |
|
||||||
|
|
||||||
|
## Pattern Overview
|
||||||
|
|
||||||
|
**Overall:** Multi-tier async architecture with centralized request dispatch and broadcast state synchronization.
|
||||||
|
|
||||||
|
**Key Characteristics:**
|
||||||
|
- **Async-first (Tokio)** - All I/O operations are non-blocking; task spawning for background work
|
||||||
|
- **RPC-driven API** - Frontend communicates via JSON-RPC 2.0 (not REST); single `/api/v0` WebSocket + HTTP endpoint
|
||||||
|
- **State as broadcast** - Global state changes flow through Tokio broadcast channels to all connected WebSocket clients
|
||||||
|
- **Manifest-driven containers** - App lifecycle controlled by declarative YAML manifests (Archipelago-specific extensions)
|
||||||
|
- **Plugin architecture** - Apps are isolated Podman containers with declarative interfaces (web UI, ports, secrets)
|
||||||
|
|
||||||
|
## Layers
|
||||||
|
|
||||||
|
**HTTP/Transport Layer:**
|
||||||
|
- Purpose: Accept inbound connections, handle TLS termination, demultiplex HTTP/WebSocket
|
||||||
|
- Location: `core/archipelago/src/server.rs`
|
||||||
|
- Contains: Hyper listener, TCP accept loop, connection state tracking
|
||||||
|
- Depends on: Tokio, Hyper, TLS/mTLS libraries (rustls/openssl)
|
||||||
|
- Used by: All external clients (web UI, companion app, API consumers)
|
||||||
|
|
||||||
|
**Request Routing & Auth Layer:**
|
||||||
|
- Purpose: Dispatch HTTP requests to handlers, validate sessions, enforce CSRF, rate-limit login
|
||||||
|
- Location: `core/archipelago/src/api/` (handler + rpc submodules)
|
||||||
|
- Contains: Route matching, middleware chain, cookie extraction, error formatting
|
||||||
|
- Depends on: Server, StateManager, SessionStore
|
||||||
|
- Used by: All request paths; gates API access
|
||||||
|
|
||||||
|
**RPC Dispatch Layer:**
|
||||||
|
- Purpose: Deserialize JSON-RPC 2.0 requests, call appropriate service method, serialize responses
|
||||||
|
- Location: `core/archipelago/src/api/rpc/mod.rs` + subdirectories (auth.rs, container.rs, bitcoin.rs, etc.)
|
||||||
|
- Contains: Method table, parameter validation, response formatting, rate limit checks
|
||||||
|
- Depends on: All service modules
|
||||||
|
- Used by: Frontend (WebSocket + HTTP POST to /api/v0), internal tools
|
||||||
|
|
||||||
|
**Service Layer:**
|
||||||
|
- Purpose: Implement business logic — container lifecycle, identity, auth, content sync, mesh discovery
|
||||||
|
- Location: `core/archipelago/src/api/rpc/*` (one RPC module per domain), plus `core/archipelago/src/` (background tasks)
|
||||||
|
- Contains: ~40 RPC method modules + 50+ core service modules (bootstrap.rs, health_monitor.rs, crash_recovery.rs, etc.)
|
||||||
|
- Depends on: StateManager, ContainerOrchestrator, config/secrets, external services (Bitcoin, Lightning, FIPS)
|
||||||
|
- Used by: RPC layer; other services for cross-cutting concerns (mesh, federation, webhooks)
|
||||||
|
|
||||||
|
**State Management Layer:**
|
||||||
|
- Purpose: Hold canonical application state, broadcast changes to all connected clients, persist snapshots
|
||||||
|
- Location: `core/archipelago/src/state.rs` (StateManager + data_model.rs)
|
||||||
|
- Contains: RwLock<DataModel>, broadcast channel, revision counter
|
||||||
|
- Depends on: DataModel (serde-serializable struct tree)
|
||||||
|
- Used by: All services that mutate state (container ops, auth, settings)
|
||||||
|
|
||||||
|
**Container Orchestration Layer:**
|
||||||
|
- Purpose: Podman lifecycle management, image verification, secret injection, crash recovery, adoption
|
||||||
|
- Location: `core/archipelago/src/container/prod_orchestrator.rs` (1M+ lines; split across boot_reconciler.rs, quadlet.rs, docker_packages.rs, etc.)
|
||||||
|
- Contains: Manifest parsing, image pull/verify, container create/start/stop, volume mounts, networking
|
||||||
|
- Depends on: Podman CLI + socket, config parser, image registries, local filesystem
|
||||||
|
- Used by: RPC container.* methods, BootReconciler loop, crash recovery
|
||||||
|
|
||||||
|
**Frontend Layer (Vue 3):**
|
||||||
|
- Purpose: Render UI, dispatch RPC calls, maintain local UI state, handle user input
|
||||||
|
- Location: `neode-ui/src/`
|
||||||
|
- Contains: Views (pages), Components (reusable UI), Composables (logic hooks), Stores (Pinia), Router
|
||||||
|
- Depends on: Vue 3, Vue Router, Pinia, RPC client library (custom), D3/Leaflet (charts/maps)
|
||||||
|
- Used by: Browser clients (desktop, mobile, companion app via WebView)
|
||||||
|
|
||||||
|
## Data Flow
|
||||||
|
|
||||||
|
### Primary Request Path (User Action → Backend → State Sync)
|
||||||
|
|
||||||
|
1. **Frontend user interaction** (click button, type input) → Vue component event handler
|
||||||
|
- Location: `neode-ui/src/views/*.vue` or `neode-ui/src/components/*.vue`
|
||||||
|
|
||||||
|
2. **Composable dispatches RPC** (e.g., `useContainerInstall()` calls `rpc.container.install()`)
|
||||||
|
- Location: `neode-ui/src/composables/` (custom or imported from `api/rpc-client.ts`)
|
||||||
|
|
||||||
|
3. **RPC client serializes → HTTP/WebSocket POST to /api/v0**
|
||||||
|
- Location: `neode-ui/src/api/rpc-client.ts`
|
||||||
|
- Payload: `{ jsonrpc: "2.0", method: "container.install", params: {...}, id: ... }`
|
||||||
|
|
||||||
|
4. **HTTP Server receives, routes to ApiHandler**
|
||||||
|
- Location: `core/archipelago/src/server.rs` (listener) → `core/archipelago/src/api/handler/mod.rs` (dispatch)
|
||||||
|
|
||||||
|
5. **ApiHandler checks auth**, extracts body, calls RpcHandler
|
||||||
|
- Location: `core/archipelago/src/api/handler/mod.rs:handle_request()`
|
||||||
|
|
||||||
|
6. **RpcHandler dispatches by method name** to specific RPC module
|
||||||
|
- Location: `core/archipelago/src/api/rpc/mod.rs:call()` → routing to `core/archipelago/src/api/rpc/container.rs:install()`
|
||||||
|
|
||||||
|
7. **Service method executes** (e.g., `container.rs:install()` calls orchestrator, updates state)
|
||||||
|
- Location: `core/archipelago/src/api/rpc/container.rs` (calls methods on ContainerOrchestrator)
|
||||||
|
|
||||||
|
8. **StateManager.update_data()** broadcasts the new state to all WebSocket subscribers
|
||||||
|
- Location: `core/archipelago/src/state.rs:update_data()` → broadcast channel
|
||||||
|
- All connected WebSocket clients receive `{ rev: N, data: {...} }` update
|
||||||
|
|
||||||
|
9. **Frontend receives state update**, updates Pinia stores, re-renders UI
|
||||||
|
- Location: `neode-ui/src/stores/` (Pinia stores mutate) → Vue reactivity chain → DOM update
|
||||||
|
|
||||||
|
**State Management:**
|
||||||
|
- All reads from `StateManager` go through `get_snapshot()` which acquires read-lock
|
||||||
|
- All writes go through `update_data()` which acquires write-lock + increments revision
|
||||||
|
- Broadcast channel has ~100-message buffer; slow subscribers may lose old updates (by design — UI only needs latest)
|
||||||
|
- WebSocket clients re-sync on reconnect via `get_snapshot()` call (full state transfer)
|
||||||
|
|
||||||
|
### Secondary Flow: Scheduled Reconciliation (Convergence Loop)
|
||||||
|
|
||||||
|
1. **BootReconciler spawned at startup** in `main.rs`
|
||||||
|
- Location: `core/archipelago/src/main.rs` (line ~338-348)
|
||||||
|
|
||||||
|
2. **Reconciler runs every `RECONCILER_DEFAULT_INTERVAL`** (~30s typical)
|
||||||
|
- Location: `core/archipelago/src/container/boot_reconciler.rs:run_forever()`
|
||||||
|
|
||||||
|
3. **Compares desired manifests (disk + registry catalog) vs actual Podman state**
|
||||||
|
- Looks for: containers missing, containers orphaned, image updates, secret changes
|
||||||
|
|
||||||
|
4. **Applies remediation** (create, delete, restart containers)
|
||||||
|
- Calls: orchestrator.reconcile_*() methods
|
||||||
|
|
||||||
|
5. **Logs changes, broadcasts state update if anything changed**
|
||||||
|
- Frontend receives update, shows user the reconciled app state
|
||||||
|
|
||||||
|
This ensures apps survive crashes, OTA updates, or manual Podman edits — the desired state always converges.
|
||||||
|
|
||||||
|
## Key Abstractions
|
||||||
|
|
||||||
|
**ContainerOrchestrator trait:**
|
||||||
|
- Purpose: Abstract container lifecycle behind a trait so Prod (Podman-based) and Dev (in-memory) modes can coexist
|
||||||
|
- Examples: `core/archipelago/src/container/prod_orchestrator.rs`, `core/archipelago/src/container/dev_orchestrator.rs`
|
||||||
|
- Pattern: Trait-based strategy; RpcHandler holds `Arc<dyn ContainerOrchestrator>`, switches at runtime
|
||||||
|
- Methods: create, start, stop, delete, adopt, list, reconcile, install, upgrade
|
||||||
|
|
||||||
|
**Manifest (YAML-based declarative app):**
|
||||||
|
- Purpose: Fully describe an app's container, dependencies, secrets, ports, UI in one file
|
||||||
|
- Examples: `/opt/archipelago/apps/*/manifest.yml` (on-disk) or registry-delivered catalogs
|
||||||
|
- Pattern: Custom extensions over OCI/Docker Compose (e.g., `interfaces.main.ui`, `generated_secrets`)
|
||||||
|
- Parsed into: `container::manifest::Manifest` struct, consumed by orchestrator
|
||||||
|
|
||||||
|
**RPC Method Modules:**
|
||||||
|
- Purpose: Group related JSON-RPC methods by domain (auth, container, bitcoin, mesh, etc.)
|
||||||
|
- Examples: `core/archipelago/src/api/rpc/auth.rs`, `core/archipelago/src/api/rpc/bitcoin.rs`
|
||||||
|
- Pattern: Each module exports `pub async fn method_name(handler, params) -> Result<Response>`
|
||||||
|
- Registration: Hardcoded dispatch in `RpcHandler::call()` (no reflection; methods are explicit)
|
||||||
|
|
||||||
|
**BlobStore (Content-Addressed):**
|
||||||
|
- Purpose: Store attachments/files by SHA-256 hash; issue time-limited capability tokens for access
|
||||||
|
- Examples: Used by mesh.send-content, federation attachments, backup archives
|
||||||
|
- Pattern: Capability-based access control (CBAC); tokens scoped to issuer pubkey + hash
|
||||||
|
- Located: `core/archipelago/src/blobs.rs` + `core/archipelago/src/content_server.rs`
|
||||||
|
|
||||||
|
**StateManager + DataModel:**
|
||||||
|
- Purpose: Single source of truth for UI state; broadcast updates to all clients
|
||||||
|
- Pattern: Read-write lock over a serde-serializable struct tree; broadcast channel for efficiency
|
||||||
|
- Persistence: Most state is ephemeral (app listings, UI settings); durable state persists to disk separately
|
||||||
|
- Clients: Frontend (WebSocket subscriber), internal services (read via get_snapshot), monitoring/debug
|
||||||
|
|
||||||
|
**Session Store:**
|
||||||
|
- Purpose: Track authenticated HTTP sessions (cookie → user identity mapping)
|
||||||
|
- Examples: SQLite-backed or in-memory store
|
||||||
|
- Pattern: Session token issued at login, validated on each request, expires after TTL
|
||||||
|
- Used by: ApiHandler auth check, rate limiter (per IP + per user)
|
||||||
|
|
||||||
|
## Entry Points
|
||||||
|
|
||||||
|
**Backend Daemon (Binary):**
|
||||||
|
- Location: `core/archipelago/src/main.rs`
|
||||||
|
- Triggers: `systemd start archipelago.service` or manual `./archipelago` on development node
|
||||||
|
- Responsibilities: Parse config, init tracing, load/reconcile containers, start HTTP server, spawn background tasks
|
||||||
|
- Key setup: Load identity → setup auth → spawn orchestrator → load manifests → start reconciler → start server
|
||||||
|
|
||||||
|
**Frontend SPA:**
|
||||||
|
- Location: `neode-ui/src/main.ts`
|
||||||
|
- Triggers: Browser loads `/index.html` (served by HTTP server from `/opt/archipelago/web-ui/`)
|
||||||
|
- Responsibilities: Boot Vue app, setup Router, setup Pinia stores, establish WebSocket to backend
|
||||||
|
- Key setup: Mount app → router ready → fetch initial state → subscribe to updates
|
||||||
|
|
||||||
|
**RPC Endpoints (HTTP + WebSocket):**
|
||||||
|
- Location: `core/archipelago/src/api/` (handler routes requests here)
|
||||||
|
- Endpoint: `/api/v0` (JSON-RPC 2.0 POST or WebSocket upgrade)
|
||||||
|
- Methods: ~200+ RPCs across domains (auth, container, bitcoin, mesh, federation, etc.)
|
||||||
|
- Example: `POST /api/v0` with body `{"jsonrpc": "2.0", "method": "auth.login", "params": {...}, "id": 1}`
|
||||||
|
|
||||||
|
**Background Tasks (Spawned at startup):**
|
||||||
|
- BootReconciler: Periodic manifest reconciliation loop
|
||||||
|
- Health Monitor: Periodic app health checks + restart
|
||||||
|
- Update Scheduler: Periodic app update checks
|
||||||
|
- Mesh Service: P2P mesh listener + sender (federation, LoRa)
|
||||||
|
- Webhook Relay: Listens for inbound webhooks, broadcasts to subscribers
|
||||||
|
- WebSocket Listener: Upgraded HTTP connections → broadcast state subscriber
|
||||||
|
- See: `core/archipelago/src/main.rs` (lines ~400-450 show the spawned tasks)
|
||||||
|
|
||||||
|
## Architectural Constraints
|
||||||
|
|
||||||
|
- **Single event loop** — All I/O-bound work runs on a single Tokio multi-threaded runtime; no worker threads by default (some container ops are blocking, run in tokio::task::spawn_blocking)
|
||||||
|
- **Global state via broadcast** — StateManager broadcasts to all WebSocket clients; no request-response for state changes (async by design)
|
||||||
|
- **Container state mutability** — Podman state can drift from manifest (manual edits, crashes); reconciler runs periodically to converge
|
||||||
|
- **No in-process data consistency** — Multiple services can mutate StateManager concurrently; last write wins (fine for UI; critical ops use locks)
|
||||||
|
- **Shared blob store** — All services that need to share content use the same BlobStore instance (single cap_key, single root directory)
|
||||||
|
- **Rate limiting per IP + method** — Prevents brute-force login, but shared IPs see shared limits (edge case: family users, proxies)
|
||||||
|
- **Session cookie same-site** — WebSocket + HTTP POST must be same-origin; CORS headers controlled by ApiHandler
|
||||||
|
|
||||||
|
## Anti-Patterns
|
||||||
|
|
||||||
|
### Circular RPC Dispatches
|
||||||
|
|
||||||
|
**What happens:** An RPC method calls back into another RPC method, forming a cycle (e.g., auth.login → container.list → auth.check_permission → auth.login)
|
||||||
|
**Why it's wrong:** Deadlocks on RwLocks, infinite loops on state broadcasts, unclear error messages, hard to debug
|
||||||
|
**Do this instead:** Pass check result as a side-effect from the outer method; compute permissions once at the start. Use composable patterns in frontend instead (e.g., `useCanInstall()` checks perms once per component mount).
|
||||||
|
|
||||||
|
### Synchronous blocking in RPC handlers
|
||||||
|
|
||||||
|
**What happens:** RPC method calls `.unwrap()` on Podman command result, blocking the entire event loop
|
||||||
|
**Why it's wrong:** One slow container op (e.g., large image pull) blocks all concurrent users
|
||||||
|
**Do this instead:** Use `tokio::task::spawn_blocking()` for I/O that may take >100ms. See `core/archipelago/src/container/docker_packages.rs` for examples.
|
||||||
|
|
||||||
|
### Hardcoding paths in app RPC modules
|
||||||
|
|
||||||
|
**What happens:** `bitcoin.rs` hardcodes `/opt/archipelago/data/bitcoin.conf` instead of using `config.data_dir`
|
||||||
|
**Why it's wrong:** Dev mode, tests, and alternate installs all fail with "not found"
|
||||||
|
**Do this instead:** Read from `Config` struct, which is passed to every RPC method. See `core/archipelago/src/api/rpc/bitcoin.rs:status()` for correct pattern.
|
||||||
|
|
||||||
|
### Frontend state outside Pinia stores
|
||||||
|
|
||||||
|
**What happens:** Components use component-local ref<> for app list, duplicate the StateManager's data
|
||||||
|
**Why it's wrong:** Stale data after OTA updates, inconsistent with other users on the same node, race conditions on install/uninstall
|
||||||
|
**Do this instead:** Always derive from Pinia stores (e.g., `useAppStore().apps`). Stores subscribe to WebSocket updates. See `neode-ui/src/stores/appStore.ts`.
|
||||||
|
|
||||||
|
### Not handling WebSocket reconnection
|
||||||
|
|
||||||
|
**What happens:** Frontend goes offline for 10s (network glitch), WebSocket closes, frontend doesn't re-sync state
|
||||||
|
**Why it's wrong:** UI shows stale data (app still "installing" when actually done), user clicks again, double-action happens
|
||||||
|
**Do this instead:** WebSocket reconnect handler should re-fetch full state (`node.status`, etc.), re-subscribe. See `neode-ui/src/api/rpc-client.ts` for the reconnect loop.
|
||||||
|
|
||||||
|
## Error Handling
|
||||||
|
|
||||||
|
**Strategy:** Defensive layering — errors are caught at each tier, logged, and converted to user-facing messages.
|
||||||
|
|
||||||
|
**Patterns:**
|
||||||
|
- HTTP layer: 4xx/5xx with JSON error (no 500s for logic errors; only for crashes)
|
||||||
|
- RPC layer: Serialize error as `{ error: { code: N, message: "...", data: {...} } }` per JSON-RPC spec
|
||||||
|
- Service layer: Use `anyhow::Result<T>` + `?` operator for early exit; convert to `RpcError` at handler boundary
|
||||||
|
- Frontend: Catch RPC errors, show toast/modal, log to console (never crash the app)
|
||||||
|
|
||||||
|
**Critical paths:**
|
||||||
|
- Auth failure: 401 Unauthorized + "Invalid password" (no "user not found" to leak usernames)
|
||||||
|
- Container ops: If reconciler sees drift, logs it but continues (never crashes the daemon)
|
||||||
|
- Image pull failure: Fallback to last-cached version if network timeout (user is never blocked on external registries)
|
||||||
|
- Podman socket unavailable: Return 503 Service Unavailable (user sees "Archipelago is starting")
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
*Architecture analysis: 2026-07-29*
|
||||||
195
.planning/codebase/CONCERNS.md
Normal file
195
.planning/codebase/CONCERNS.md
Normal file
@ -0,0 +1,195 @@
|
|||||||
|
# Codebase Concerns
|
||||||
|
|
||||||
|
**Analysis Date:** 2026-07-29
|
||||||
|
|
||||||
|
## Tech Debt
|
||||||
|
|
||||||
|
**Federation node removal tombstone gap:**
|
||||||
|
- Issue: `federation::remove_node()` (`core/archipelago/src/federation/storage.rs:180-197`) calls `tombstone_did()` at line 193 but explicitly drops the error with `let _ = …`. If tombstone write fails (disk I/O, permission, transient), the peer is removed from `nodes.json` but never actually recorded as removed, so the next background sync/notify-join silently re-adds it.
|
||||||
|
- Files: `core/archipelago/src/federation/storage.rs:180-197`, `core/archipelago/src/api/rpc/federation/handlers.rs:272-300`
|
||||||
|
- Impact: Federation peers marked for removal can reappear after the next sync cycle, confusing the operator and potentially re-establishing unwanted connections.
|
||||||
|
- Fix approach: Surface the tombstone-write failure instead of swallowing it; consider retry logic with backoff; add integration test via `tests/multinode/smoke.sh` to verify removal sticks across sync cycles.
|
||||||
|
|
||||||
|
**Container reconciler observability gap:**
|
||||||
|
- Issue: No metrics distinguish "settling after restart" from "flapping" — container thrashing is invisible until anecdotal reports. No per-app restart counter or log line when an app restarts >N times in M minutes.
|
||||||
|
- Files: `core/archipelago/src/container/prod_orchestrator.rs` (reconciler loop), `core/archipelago/src/health_monitor.rs`
|
||||||
|
- Impact: Silent restart storms go unnoticed; users see frequent service interruptions without diagnostics; operator can't distinguish normal convergence from a crash loop.
|
||||||
|
- Fix approach: Add per-app restart counter + log line when threshold exceeded; emit metric on each restart; wire restart count into health/status RPC output.
|
||||||
|
|
||||||
|
**Failed systemd unit self-healing gap:**
|
||||||
|
- Issue: When a Quadlet-backed app's `.service` unit enters `failed` state (e.g., exit 255), the reconciler does not automatically `reset-failed` + `start` it. The unit sits failed until the operator manually intervenes or the service restarts.
|
||||||
|
- Files: `core/archipelago/src/container/prod_orchestrator.rs` (reconcile loop)
|
||||||
|
- Impact: Apps with transient failures go down and stay down; no automatic recovery; operator must manually reset or restart the orchestrator.
|
||||||
|
- Fix approach: Add reconcile step: quadlet-backed app whose `.service` is `failed` and not user-stopped → call `systemctl --user reset-failed <unit>` + `start`; add backoff to avoid busy-loop on persistent failures.
|
||||||
|
|
||||||
|
**Bitcoin RPC credentials not retrieved from config/secrets:**
|
||||||
|
- Issue: `core/container/src/bitcoin_simulator.rs:158` has a TODO marking hardcoded (or missing) RPC credentials in the Bitcoin simulator real-mode path. Credentials should be fetched from the secret store.
|
||||||
|
- Files: `core/container/src/bitcoin_simulator.rs:155-165`
|
||||||
|
- Impact: Bitcoin simulator in real mode (Testnet/Mainnet) cannot authenticate to the node; RPC calls fail.
|
||||||
|
- Fix approach: Inject `SecretsProvider` into `BitcoinSimulator::new()` or pass credentials as constructor args; fetch via `config/secrets` at runtime; handle credential rotation.
|
||||||
|
|
||||||
|
**Container security policies not wired in:**
|
||||||
|
- Issue: `core/security/src/container_policies.rs` generates AppArmor/SELinux profiles but the `apply_profile()` function has a TODO at line 71: "Configure Podman to use the profile" — the profiles are generated but never applied to running containers.
|
||||||
|
- Files: `core/security/src/container_policies.rs:63-75`
|
||||||
|
- Impact: Security profiles exist but provide zero protection; containers run without the intended isolation constraints.
|
||||||
|
- Fix approach: Pass `--security-opt apparmor=<profile>` (or SELinux equivalent) to Podman at container creation; verify profile loads via `apparmor_status`; add CI check that profiles compile cleanly.
|
||||||
|
|
||||||
|
**Dynamic resource adjustment not implemented:**
|
||||||
|
- Issue: `core/performance/src/resource_manager.rs:86` has a TODO for dynamic resource adjustment based on usage. The allocator is static; no adaptive rebalancing when load patterns shift.
|
||||||
|
- Files: `core/performance/src/resource_manager.rs:86-88`
|
||||||
|
- Impact: Resource allocation is rigid; a node with skewed usage (e.g., one app consuming all memory) has no mechanism to rebalance dynamically.
|
||||||
|
- Fix approach: Monitor per-app resource usage via cgroup stats; implement feedback loop to adjust limits; gate on production deployment (likely Phase 3+).
|
||||||
|
|
||||||
|
## Known Bugs
|
||||||
|
|
||||||
|
**Multinode RPC robustness gap:**
|
||||||
|
- Symptoms: The `node_rpc()` function in `tests/multinode/lib/multinode.bash` lacks `--max-time` on curl calls — a slow server-side RPC can hang the test suite indefinitely with zero feedback.
|
||||||
|
- Files: `tests/multinode/lib/multinode.bash` (exact line TBD; see grep for `node_rpc`)
|
||||||
|
- Trigger: Run multinode federation/mesh test against a slow or overloaded node; curl will block forever.
|
||||||
|
- Workaround: Manually kill the test process and diagnose the hanging RPC manually; no automatic timeout recovery.
|
||||||
|
- Fix approach: Add `--max-time 30` to all curl calls in `node_rpc()`; re-run `tests/multinode/smoke.sh` to verify.
|
||||||
|
|
||||||
|
## Security Considerations
|
||||||
|
|
||||||
|
**Secrets environment variable exposure risk:**
|
||||||
|
- Risk: Bitcoin and other service credentials are materialized as env vars in `ARCHIPELAGO_*` (e.g., `BITCOIN_RPC_PASSWORD`). Env vars are visible via `/proc/<pid>/environ` and potentially logged.
|
||||||
|
- Files: `core/archipelago/src/container/prod_orchestrator.rs`, `core/container/src/manifest.rs`, `core/archipelago/src/api/rpc/package/config.rs`
|
||||||
|
- Current mitigation: Secrets are declared as `generated_secrets` in manifests and materialized 0600/rootless; the orchestrator avoids logging values.
|
||||||
|
- Recommendations: Audit all env-var passing to containers; consider switching high-sensitivity secrets (bitcoin RPC, LND macaroons) to file-based secrets mounted read-only; add audit logging for secret access.
|
||||||
|
|
||||||
|
**Federation DID validation incomplete:**
|
||||||
|
- Risk: Federation peer DIDs are added via the RPC without cryptographic verification of ownership. A compromised peer could advertise arbitrary DIDs.
|
||||||
|
- Files: `core/archipelago/src/api/rpc/federation/handlers.rs` (add-node path), `core/archipelago/src/federation/storage.rs`
|
||||||
|
- Current mitigation: DIDs are stored locally; transitive federation discovery uses the tombstone list to block removed peers.
|
||||||
|
- Recommendations: Add DID-ownership proof (e.g., signed proof-of-identity) before accepting a peer's advertised DID; document the trust model; consider user warnings when adding peers.
|
||||||
|
|
||||||
|
**AppArmor profiles overly permissive:**
|
||||||
|
- Risk: Generated AppArmor profiles use blanket `network,` instead of per-port/protocol rules. Readonly flag is checkbox only, not enforced per actual app needs.
|
||||||
|
- Files: `core/security/src/container_policies.rs:46-54`
|
||||||
|
- Current mitigation: None (profiles not applied).
|
||||||
|
- Recommendations: Refine per-app capabilities based on manifest's declared needs; add integration test verifying readonly mounts are enforced; apply profiles in development before prod.
|
||||||
|
|
||||||
|
## Performance Bottlenecks
|
||||||
|
|
||||||
|
**Container thrashing during reconcile:**
|
||||||
|
- Problem: Restarting `archipelago.service` SIGKILLs every container, forcing a full rebuild over several minutes. Uninstall + reinstall loops can cascade-trigger restarts.
|
||||||
|
- Files: `core/archipelago/src/container/prod_orchestrator.rs` (the reconciler's desired-state machine)
|
||||||
|
- Cause: Pre-Phase-3 architecture: containers run in systemd cgroup, not as independent Quadlet units.
|
||||||
|
- Improvement path: Phase-3 Quadlet default-flip (`config.rs:256`) — each app becomes an independent `.container` unit; restart only the affected app, not the entire cgroup.
|
||||||
|
|
||||||
|
**Reconciler churn on boot:**
|
||||||
|
- Problem: Boot reconciler makes multiple passes reconciling drift; during each pass, containers may be recreated. Post-OTA health checks deliberately skip per-app container assertions because of restart-storm unpredictability.
|
||||||
|
- Files: `core/archipelago/src/container/prod_orchestrator.rs`, `core/archipelago/src/bootstrap.rs`
|
||||||
|
- Cause: Multi-pass reconciliation + no incremental diff detection.
|
||||||
|
- Improvement path: Consolidate reconciler into single pass for boot; cache manifest/config diffs to avoid redundant comparisons; add boot-only fast-path.
|
||||||
|
|
||||||
|
**Bitcoin IBD on .198 stalled (disk I/O):**
|
||||||
|
- Problem: .198 bitcoin is mid-IBD with only 21% progress; disk is 448GB (below 1TB archival threshold); load is high (~3–5).
|
||||||
|
- Files: `tests/multinode-testing-plan.md` (documented issue)
|
||||||
|
- Cause: Undersized/slow disk; concurrent workload.
|
||||||
|
- Improvement path: User decision required: swap in a different node (already done for gate run, using .5 instead) or add storage + wait for sync. Not a code issue.
|
||||||
|
|
||||||
|
## Fragile Areas
|
||||||
|
|
||||||
|
**Uninstall + reinstall lifecycle:**
|
||||||
|
- Files: `core/archipelago/src/api/rpc/package/install.rs`, `core/archipelago/src/container/quadlet.rs:disable_remove()`, `neode-ui/src/components/AppCard.vue`
|
||||||
|
- Why fragile: Pre-2026-07-26, `quadlet::disable_remove()` called systemd + podman with no timeouts, causing hangs. Fixed by commit `71cc9ac4` (added `QUADLET_STOP_TIMEOUT`, SIGKILL escalation, reset-failed). AppCard was hardcoding uninstall bar to "stuck full-red" (fixed `9f17ba68`). Tests for reinstall/cascade are still opt-in.
|
||||||
|
- Safe modification: Any changes to the uninstall path must be tested via `cascade-uninstall.bats` (7/7 on .228); extend coverage to multi-container stacks (immich, btcpay). Verify on .228 before fleet roll.
|
||||||
|
- Test coverage: `tests/lifecycle/bats/cascade-uninstall.bats` exists but not in canonical gate; must opt-in with `ARCHY_GATE_CASCADE=1`.
|
||||||
|
|
||||||
|
**Production orchestrator state machine:**
|
||||||
|
- Files: `core/archipelago/src/container/prod_orchestrator.rs` (6291 lines)
|
||||||
|
- Why fragile: Largest file in the codebase; owns install/start/stop/restart/remove/upgrade for every app; per-app mutex + RwLock concurrency model; complex dependency resolution, adoption scan, Quadlet rendering, and host-port-wait logic interleaved.
|
||||||
|
- Safe modification: Understand the per-app mutex protocol before touching state mutation; test all changes via the lifecycle gate on .228; use the adoption scan + manifest merge logic for any new manifest evolution.
|
||||||
|
- Test coverage: 667 unit tests green (2026-07-01); lifecycle gate covers ~8 core apps; ~30 apps untested in gate.
|
||||||
|
|
||||||
|
**Mesh radio configuration + boot race:**
|
||||||
|
- Files: `core/archipelago/src/mesh/meshtastic.rs`, `core/archipelago/src/mesh/mod.rs`, tests at `tests/lifecycle/bats/meshtastic.bats`
|
||||||
|
- Why fragile: Radio boot-race fixed (2026-07-28, `a8c4694c`/`3f76b496`); on-air config apply must finish before device is used. Earlier versions had probe-boot-race + live config propagation issues. Must verify on real hardware.
|
||||||
|
- Safe modification: Any mesh changes require E2E test on real LoRa radios (dev-box ↔ x250-dev, or fleet broadcast); unit tests alone won't catch RF timing issues.
|
||||||
|
- Test coverage: 8-stage on-air smoke test in `tests/multinode/meshtastic.sh` (run manually; not in canonical gate).
|
||||||
|
|
||||||
|
**Lightning payment state machine:**
|
||||||
|
- Files: `core/archipelago/src/api/rpc/lnd/wallet.rs:payinvoice()`
|
||||||
|
- Why fragile: Slow multi-hop payments (>15s) previously surfaced as "failed" while settling in background; client-side 15s timeout was aborting the wait. Fixed by commit `614a0f5a` (120s wait, pending status, lnd.paymentstatus poll). Must verify on Framework PT with real multi-hop.
|
||||||
|
- Safe modification: Any lnd state changes must test full payment lifecycle: invoice creation, encoding, send, multi-hop wait, settlement confirmation. Verify on Framework PT before release.
|
||||||
|
- Test coverage: Local LND payinvoice smoke test; no multinode lightning routing test in gate.
|
||||||
|
|
||||||
|
## Scaling Limits
|
||||||
|
|
||||||
|
**Uninstall progress bar truthfulness:**
|
||||||
|
- Current capacity: Uninstall now has timeouts (fixed 2026-07-26) but progress-bar still reports fake stages (full-red full-opacity).
|
||||||
|
- Limit: Long uninstalls (>30s) show no real progress; bar claims "uninstalling" for the full duration.
|
||||||
|
- Scaling path: Backend must emit real progress events (% complete, stage name); UI must poll + display truthfully; integrate into all 5 gate iterations (not just 1 throw-away app).
|
||||||
|
|
||||||
|
**Federation node list deduplication on disk bloat:**
|
||||||
|
- Current capacity: `federation/storage.rs:dedup_nodes_by_onion()` reads entire nodes.json into memory each time a node is added/synced. At N federated peers, O(N) memory + O(N²) comparisons per operation.
|
||||||
|
- Limit: No hard limit measured; scales fine up to hundreds of peers. Beyond 1000+ peers, memory/time may become visible.
|
||||||
|
- Scaling path: Switch to a disk-backed database (e.g., rocksdb) for federation state if peer count grows; or implement incremental dedup on disk writes (preserve dedup state, only recompute on load).
|
||||||
|
|
||||||
|
**Lifecycle gate iteration count:**
|
||||||
|
- Current capacity: `ARCHY_ITERATIONS=5` runs 5 full cycles (stop/start/restart/survive per app). Entire run takes ~8–12 hours on .228.
|
||||||
|
- Limit: Cannot easily scale to 10+ iterations without timeout risks; per-app timeout tuning is manual.
|
||||||
|
- Scaling path: Add per-app timeout tuning (manifest field); parallelize per-app tests where safe (currently serial to avoid contention).
|
||||||
|
|
||||||
|
## Dependencies at Risk
|
||||||
|
|
||||||
|
**Reticulum transport daemon process group:**
|
||||||
|
- Risk: Pre-fix (before `be50c886`), process group wasn't cleaned up on drop. Fork-bombs or dangling processes possible under error conditions.
|
||||||
|
- Impact: Stale reticulum processes accumulating over time; resource leaks on node.
|
||||||
|
- Migration plan: Code fix already deployed (commit `7a7fec21`); no active risk. Monitor fleet for stale python processes post-deployment.
|
||||||
|
|
||||||
|
**Podman socket mount security model:**
|
||||||
|
- Risk: Apps mounting `/run/podman/podman.sock` get full container-management access. Not restricted by the security policy (AppArmor profiles not applied).
|
||||||
|
- Files: `core/archipelago/src/container/prod_orchestrator.rs:135-137` (detection), manifests for apps with podman mounts (e.g., portainer)
|
||||||
|
- Impact: A compromised app with podman socket access can start/stop/delete any container on the node.
|
||||||
|
- Recommendation: Restrict podman socket mounts to admin-only apps (portainer, docker-api tools); document risk; consider socket filtering layer (selinux context, etc.) once AppArmor is wired.
|
||||||
|
|
||||||
|
**Bitcoin version multi-version branch not fleet-wide:**
|
||||||
|
- Risk: Branch `bitcoin-version-bulletproof` (base `095a76cd`) carries multi-version support but hasn't been deployed fleet-wide yet. .228 carries it; others still run single version.
|
||||||
|
- Impact: Users on single-version nodes can't switch versions; version mismatch across fleet breaks federation.
|
||||||
|
- Migration plan: Coordinated OTA + catalog publish + `:latest` repoint sequencing per `docs/bitcoin-version-bulletproof-rollout.md`. Awaiting user decision on timing.
|
||||||
|
|
||||||
|
## Missing Critical Features
|
||||||
|
|
||||||
|
**Developer tooling CLI suite:**
|
||||||
|
- Problem: Third-party developers need `archy app validate/render/local-install/lifecycle-test` tooling before external registry launches.
|
||||||
|
- Blocks: External marketplace (workstream C); external developer onboarding.
|
||||||
|
- Status: Not yet built; documented in APP-PACKAGING-MIGRATION-PLAN.md step 5.
|
||||||
|
|
||||||
|
**Manifest-distributed registry flip:**
|
||||||
|
- Problem: Manifests still travel via OTA disk rsync. The signed catalog currently distributes only image overrides, not full manifests. Workstream B phases 1+2 done; not yet fleet-deployed.
|
||||||
|
- Blocks: Cannot confidently add/bump apps without re-signing the catalog.
|
||||||
|
- Status: Code ready; flip awaits authorization + timing call from user.
|
||||||
|
|
||||||
|
**Phase-3 Quadlet default-flip:**
|
||||||
|
- Problem: Orchestrator still uses legacy cgroup-based container management; Phase-3 `use_quadlet_backends` switch exists but is opt-in only.
|
||||||
|
- Blocks: Resolves container thrashing; unlocks independent app restarts; unblocks lifecycle perfection (workstream F).
|
||||||
|
- Status: Code validated on .228/.198 (commit pending); ready to flip when multinode gate passes.
|
||||||
|
|
||||||
|
## Test Coverage Gaps
|
||||||
|
|
||||||
|
**~30 apps with zero app-specific assertions:**
|
||||||
|
- What's not tested: Apps like grafana, jellyfin, vaultwarden, penpot, nextcloud, photoprism, uptime-kuma, homeassistant, etc. have no app-specific health checks beyond "container running."
|
||||||
|
- Files: `tests/lifecycle/bats/all-apps-matrix.bats`, `tests/lifecycle/bats/all-apps-lifecycle.bats` (generic baseline coverage)
|
||||||
|
- Risk: App-specific bugs (API down, data corruption, dependency failure) go unnoticed until user encounters them.
|
||||||
|
- Priority: Medium — baseline coverage is a real safety net; app-specific assertions are a "nice to harden" backlog item, not a gate blocker.
|
||||||
|
- Approach: Add per-app health RPC endpoints or HTTP probes; wire into the gate as opt-in per-app test suites.
|
||||||
|
|
||||||
|
**Progress UI assertions incomplete:**
|
||||||
|
- What's not tested: Install + uninstall must report monotonic, truthful progress. No stage/percentage assertions in the gate.
|
||||||
|
- Files: `neode-ui/src/components/AppCard.vue`, `core/archipelago/src/api/rpc/package/install.rs` (backend progress events)
|
||||||
|
- Risk: Silent hangs or fake progress bars are invisible to the gate.
|
||||||
|
- Priority: High — immich/grafana uninstall was stuck full-red (fixed); progress truthfulness is part of definition of done for workstream F.
|
||||||
|
- Approach: Backend must emit real progress events; UI must display & test them; integrate into canonical gate (currently opt-in).
|
||||||
|
|
||||||
|
**All-apps matrix in cascade gate:**
|
||||||
|
- What's not tested: `ARCHY_GATE_CASCADE=1` runs ONE throwaway app's uninstall/reinstall. Must extend to multi-container stacks (immich, btcpay, mempool) and all ~40 installed apps.
|
||||||
|
- Files: `tests/lifecycle/bats/cascade-uninstall.bats` (single-app variant)
|
||||||
|
- Risk: Multi-container app uninstall bugs (e.g., orphan postgres container) go undetected.
|
||||||
|
- Priority: High — part of workstream F definition of done.
|
||||||
|
- Approach: Parametrize cascade test over all manifest IDs; run 5 cascades total (not 5 per app to save time); gate-pass requires zero ghost containers post-uninstall.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
*Analysis based on codebase state 2026-07-29. Issues tracked in `docs/UNIFIED-TASK-TRACKER.md` (day-to-day) and `docs/PRODUCTION-MASTER-PLAN.md` (historical narrative).*
|
||||||
159
.planning/codebase/CONVENTIONS.md
Normal file
159
.planning/codebase/CONVENTIONS.md
Normal file
@ -0,0 +1,159 @@
|
|||||||
|
# Coding Conventions
|
||||||
|
|
||||||
|
**Analysis Date:** 2026-07-29
|
||||||
|
|
||||||
|
## Naming Patterns
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- TypeScript/Vue: PascalCase for components (e.g., `ToggleSwitch.vue`, `SendBitcoinModal.vue`), camelCase for composables and stores (e.g., `useFileType.ts`, `controller.ts`)
|
||||||
|
- Rust: snake_case for modules and files (e.g., `bitcoin_rpc.rs`, `storage_crypto.rs`)
|
||||||
|
- Test files: co-located with source in `__tests__/` subdirectories with `.test.ts` or `.spec.ts` suffix for Vitest, `.bats` for shell tests
|
||||||
|
- Constants in TypeScript use UPPER_SNAKE_CASE within modules (e.g., `IMAGE_EXTS`, `CATEGORY_COLORS` in `useFileType.ts`)
|
||||||
|
|
||||||
|
**Functions:**
|
||||||
|
- TypeScript/Vue: camelCase for all functions (e.g., `getFileCategory`, `formatSize`, `useFileType`)
|
||||||
|
- Composables: `use` prefix for Vue composables (e.g., `useFileType`, `useToast`, `useMessageToast`) — exported as named exports or default exports
|
||||||
|
- Store functions (Pinia): defined with snake_case action names, exported from `defineStore` factory
|
||||||
|
- Rust: snake_case for all functions and methods (e.g., `doesnt_reallocate`, following Rust conventions)
|
||||||
|
|
||||||
|
**Variables:**
|
||||||
|
- TypeScript: camelCase for local variables and reactive refs (e.g., `modelValue`, `isActive`, `gamepadCount`)
|
||||||
|
- Refs (Vue 3): prefix not required, but convention is lowercase start (e.g., `const ext = ref('jpg')`)
|
||||||
|
- Computed properties: camelCase, explicit `.value` suffix in templates when needed
|
||||||
|
- Parameters: camelCase, typed explicitly in TypeScript (e.g., `password: string`, `isDir: Ref<boolean>`)
|
||||||
|
|
||||||
|
**Types:**
|
||||||
|
- TypeScript: PascalCase for type aliases and interfaces (e.g., `RPCOptions`, `FileCategory`, `CatalogVersionInfo`)
|
||||||
|
- Union types: PascalCase (e.g., `PendingState = 'pending' | 'sent' | 'approved'`)
|
||||||
|
- Component props: typed with `defineProps<{ ... }>()` syntax in `<script setup>`
|
||||||
|
- Rust: PascalCase for structs and enums, snake_case for fields within them
|
||||||
|
|
||||||
|
## Code Style
|
||||||
|
|
||||||
|
**Formatting:**
|
||||||
|
- No global Prettier config; code style follows project patterns incrementally
|
||||||
|
- Vue components: single-file components (`.vue`) with `<template>`, `<script setup>`, optional `<style scoped>`
|
||||||
|
- TypeScript: indentation is 2 spaces (visible in `vitest.config.ts`, Vue components, test files)
|
||||||
|
- Line width: no strict enforcement observed; pragmatic wrapping around 80–100 characters
|
||||||
|
- Arrow functions preferred for short callbacks: `(x) => x * 2`
|
||||||
|
- Template strings for multi-line formatting
|
||||||
|
|
||||||
|
**Linting:**
|
||||||
|
- No `.eslintrc` detected at repo root or `neode-ui/` level
|
||||||
|
- Rust: Clippy allowances declared at crate level in `main.rs` (`#![allow(...)]`) to suppress stylistic lints and focus CI on correctness issues
|
||||||
|
- Examples of suppressed Clippy lints: `too_many_arguments`, `type_complexity`, `enum_variant_names`, `unused_io_amount`
|
||||||
|
|
||||||
|
## Import Organization
|
||||||
|
|
||||||
|
**Order:**
|
||||||
|
1. Vue framework imports (`import { computed, ref, type Ref } from 'vue'`)
|
||||||
|
2. Library imports (`import { defineStore } from 'pinia'`, `import { format } from 'date-fns'`)
|
||||||
|
3. Local module imports (`import { useFileType } from '../useFileType'`, `import { rpcClient } from '../api/rpc-client'`)
|
||||||
|
4. Types/interfaces (inline in import statements via `type` keyword when needed)
|
||||||
|
5. No blank lines required between groups in practice
|
||||||
|
|
||||||
|
**Path Aliases:**
|
||||||
|
- TypeScript: `@` alias maps to `src/` (configured in `vitest.config.ts` and `tsconfig.json`)
|
||||||
|
- Usage: `import { displayVersion } from '@/utils/version'`
|
||||||
|
- Rust: crate-relative paths (`use crate::module::submodule`) and external crate paths
|
||||||
|
|
||||||
|
## Error Handling
|
||||||
|
|
||||||
|
**Patterns:**
|
||||||
|
- TypeScript: explicit try-catch with error type narrowing (e.g., `if (error instanceof Error) { ... }`)
|
||||||
|
- RPC client (`rpc-client.ts`): catches fetch errors, AbortError, and HTTP errors; distinguishes retryable (502, 503) from permanent (401, 403)
|
||||||
|
- Rust: `anyhow::Result<T>` for fallible operations; `?` operator for error propagation; `.context("message")` for adding context
|
||||||
|
- Backend error responses: JSON-RPC format with `error: { code, message, data? }` structure; UI catches and displays via toast system
|
||||||
|
- Network errors: automatic retry with exponential backoff (600ms × (attempt + 1) with jitter); configurable `maxRetries` per call
|
||||||
|
|
||||||
|
## Logging
|
||||||
|
|
||||||
|
**Framework:** console object for frontend, `tracing` crate for Rust backend
|
||||||
|
|
||||||
|
**Patterns:**
|
||||||
|
- Frontend: `console.warn`, `console.error` used selectively (e.g., `[RPC]` prefixed logs in `rpc-client.ts` for session/CSRF events)
|
||||||
|
- Rust: `tracing::info!`, `tracing::warn!` for structured logging; `println!` avoided in production code
|
||||||
|
- No log levels enforced or documented; pragmatic use based on severity
|
||||||
|
|
||||||
|
## Comments
|
||||||
|
|
||||||
|
**When to Comment:**
|
||||||
|
- Explain non-obvious retry logic, timeout decisions, CSRF handling (see `rpc-client.ts` lines 138–178 for example)
|
||||||
|
- Clarify why a workaround exists (e.g., "Already on the login page: redirecting = a full reload")
|
||||||
|
- Document integration points with backend RPC methods and their expected response shapes
|
||||||
|
- Avoid redundant comments restating what the code obviously does
|
||||||
|
|
||||||
|
**JSDoc/TSDoc:**
|
||||||
|
- Function parameter types documented inline via TypeScript type annotations (e.g., `ext: Ref<string>`)
|
||||||
|
- Minimal use of explicit JSDoc blocks; type signature is the primary documentation
|
||||||
|
- Comments above exports explain purpose in one sentence (e.g., "// RPC Client for connecting to Archipelago backend")
|
||||||
|
- Optional fields in interfaces documented via property-level comments (e.g., `/** Abort the call from the outside … */`)
|
||||||
|
|
||||||
|
## Function Design
|
||||||
|
|
||||||
|
**Size:** Functions are typically 5–50 lines; error-handling paths in `callInner<T>` (`rpc-client.ts`) stretch to 120 lines but remain single-responsibility (retry logic + error classification)
|
||||||
|
|
||||||
|
**Parameters:**
|
||||||
|
- Prefer object parameters for 3+ arguments (e.g., `RPCOptions` object over separate `method, params, timeout`)
|
||||||
|
- Vue composables accept `Ref<T>` types to maintain reactivity (e.g., `useFileType(ext: Ref<string>, isDir: Ref<boolean>)`)
|
||||||
|
- Store action functions accept only necessary parameters; broader state via closure
|
||||||
|
|
||||||
|
**Return Values:**
|
||||||
|
- Composables return object with properties (computed values + reactive refs): `{ category, isImage, isAudio, ... }`
|
||||||
|
- Store actions return `void` or the modified state
|
||||||
|
- Utilities return simple values or objects (e.g., `formatSize` returns string, `formatDate` returns string)
|
||||||
|
- Async functions return `Promise<T>` with explicit type parameters (e.g., `async call<T>(options): Promise<T>`)
|
||||||
|
|
||||||
|
## Module Design
|
||||||
|
|
||||||
|
**Exports:**
|
||||||
|
- Composables export a single named function and helper functions: `export function useFileType(...)`, `export function getFileCategory(...)`
|
||||||
|
- Stores export the Pinia store factory: `export const useControllerStore = defineStore(...)`
|
||||||
|
- RPC client exports as singleton instance: `export const rpcClient = new RPCClient()`
|
||||||
|
- Utilities export multiple helpers from the same file (e.g., `formatSize`, `formatDate` from same module)
|
||||||
|
|
||||||
|
**Barrel Files:**
|
||||||
|
- Not observed as a primary pattern; each file self-documents its exports
|
||||||
|
- Imports use direct paths (e.g., `from '../composables/useFileType'`) rather than barrel `index.ts`
|
||||||
|
- Test files import specific utilities directly to minimize test setup complexity
|
||||||
|
|
||||||
|
## Type Safety
|
||||||
|
|
||||||
|
**Vue 3 with TypeScript:**
|
||||||
|
- Components use `<script setup lang="ts">` with `defineProps<{ ... }>()` and `defineEmits<{ ... }>()`
|
||||||
|
- Props explicitly typed as interfaces/objects with required/optional fields marked
|
||||||
|
- Events typed as call signatures (e.g., `'update:modelValue': [value: boolean]`)
|
||||||
|
- Reactive variables typed at declaration: `const isActive = ref<boolean>(false)`, or via inference when obvious
|
||||||
|
|
||||||
|
**Rust:**
|
||||||
|
- Explicit type annotations on public APIs; inference acceptable inside functions
|
||||||
|
- Generic parameters used to encode interface contracts (e.g., `struct DataUrl<'a>`)
|
||||||
|
- Pattern matching to handle enums and `Option<T>` / `Result<T, E>` types safely
|
||||||
|
|
||||||
|
## Component Architecture (Vue)
|
||||||
|
|
||||||
|
**File structure:**
|
||||||
|
- Single-file components with template → script → (optional) style
|
||||||
|
- Props come first, emits second, internal state/computed/methods follow
|
||||||
|
- One component per file (naming matches the file name)
|
||||||
|
- Slots used minimally; prefer explicit prop configuration over slot forwarding
|
||||||
|
|
||||||
|
**Reactivity:**
|
||||||
|
- `ref()` for primitive/object state; `computed()` for derived values
|
||||||
|
- `watch()` used for side effects on ref changes (not extensively shown in samples but implied)
|
||||||
|
- Pinia stores used for global state (authentication, app list, mesh status, etc.)
|
||||||
|
|
||||||
|
## Constants and Enums
|
||||||
|
|
||||||
|
**Pattern:** Constants are module-level `const` with UPPER_SNAKE_CASE names and immutable type annotations:
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
const IMAGE_EXTS = new Set(['jpg', 'jpeg', ...])
|
||||||
|
const CATEGORY_COLORS: Record<FileCategory, string> = { ... }
|
||||||
|
```
|
||||||
|
|
||||||
|
**Enums:** Type aliases preferred over TypeScript `enum` keyword (e.g., `type FileCategory = 'folder' | 'image' | ...`)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
*Convention analysis: 2026-07-29*
|
||||||
235
.planning/codebase/INTEGRATIONS.md
Normal file
235
.planning/codebase/INTEGRATIONS.md
Normal file
@ -0,0 +1,235 @@
|
|||||||
|
# External Integrations
|
||||||
|
|
||||||
|
**Analysis Date:** 2026-07-29
|
||||||
|
|
||||||
|
## APIs & External Services
|
||||||
|
|
||||||
|
**Bitcoin Protocol:**
|
||||||
|
- Bitcoin Core RPC endpoint - Primary blockchain interaction
|
||||||
|
- SDK/Client: `bitcoin` crate (v0.32.5), `reqwest` HTTP client
|
||||||
|
- Endpoint: `http://127.0.0.1:8332/` (configurable)
|
||||||
|
- Used for: Transaction broadcasting, UTXO validation, network sync status
|
||||||
|
- Auth: Basic HTTP auth (hardcoded RPC credentials in containers)
|
||||||
|
|
||||||
|
**Lightning Network:**
|
||||||
|
- Lightning Network Daemon (LND) - Layer 2 payments
|
||||||
|
- SDK/Client: Native REST API (`reqwest` + `serde_json`)
|
||||||
|
- REST Endpoint: `http://localhost:8080/` (container network)
|
||||||
|
- gRPC Endpoint: `http://localhost:10009/` (not currently used by backend)
|
||||||
|
- P2P Port: 9735
|
||||||
|
- Used for: Channel management, payment invoicing, routing
|
||||||
|
- Auth: Macaroon-based authentication (stored in `lnd-data:/root/.lnd`)
|
||||||
|
- Proxied: `/proxy/lnd/` → backend RPC auth + CORS handling
|
||||||
|
|
||||||
|
**Nostr Protocol:**
|
||||||
|
- Nostr Relays - Node discovery and encrypted messaging
|
||||||
|
- SDK/Client: `nostr-sdk` crate (v0.44, with NIP-04 and NIP-44 support)
|
||||||
|
- Usage: Optional, opt-in via `NOSTR_DISCOVERY_ENABLED` config
|
||||||
|
- Relays: Configurable via comma-separated `NOSTR_RELAYS` env var
|
||||||
|
- Transport: SOCKS5 Tor proxy optional via `NOSTR_TOR_PROXY` config
|
||||||
|
- Features: Ed25519 node identity publishing, encrypted peer handshake
|
||||||
|
- Related files: `core/archipelago/src/nostr_relays.rs`, `nostr_discovery.rs`, `nostr_handshake.rs`
|
||||||
|
|
||||||
|
**Mesh Networking:**
|
||||||
|
- Reticulum Protocol (RNS v1.3.5) - Local mesh radio coordination
|
||||||
|
- Daemon: `reticulum-daemon/` (Python daemon, RNS 1.3.5 + LXMF 1.0.1)
|
||||||
|
- Interface: Supervised as managed container
|
||||||
|
- LoRa Radio: Serial2 communication over USB (Meshtastic-compatible radios)
|
||||||
|
- P2P Discovery: mDNS (multicast DNS via `mdns-sd` crate)
|
||||||
|
- Mesh Ports: IPv6 dual-stack listeners (port mirroring to containers)
|
||||||
|
- Related files: `core/archipelago/src/mesh/`, `core/archipelago/src/mesh_ports.rs`
|
||||||
|
|
||||||
|
**Tor (Optional):**
|
||||||
|
- Tor SOCKS5 Proxy - Anonymous network routing
|
||||||
|
- Endpoint: `socks5h://127.0.0.1:9050` (default, configurable)
|
||||||
|
- Used for: Nostr relay connections (when routed through Tor)
|
||||||
|
- Client: `reqwest` with SOCKS feature enabled
|
||||||
|
- Config key: `nostr_tor_proxy`
|
||||||
|
|
||||||
|
**Fedimint:**
|
||||||
|
- Federated Custody Chaumian Mint - Alternative payment layer
|
||||||
|
- SDK/Client: JSON-RPC API (`reqwest` + `serde_json`)
|
||||||
|
- Endpoint: `http://localhost:8174/` (container network)
|
||||||
|
- P2P Port: 8173
|
||||||
|
- UI Port: 8175 (guardian management)
|
||||||
|
- Used for: Custody alternatives, blind signatures
|
||||||
|
- Related files: `core/archipelago/src/api/rpc/fedimint.rs`
|
||||||
|
|
||||||
|
**Decentralized Web Node (DWN):**
|
||||||
|
- DWN Health Check - Decentralized identity messaging
|
||||||
|
- Endpoint: `http://127.0.0.1:3100/health`
|
||||||
|
- Purpose: Node provisioning verification
|
||||||
|
- Related files: `core/archipelago/src/constants.rs`
|
||||||
|
|
||||||
|
## Data Storage
|
||||||
|
|
||||||
|
**Databases:**
|
||||||
|
- **Application Databases (optional, app-managed):**
|
||||||
|
- PostgreSQL: Immich, IndeedHub, Penpot, Endurain, Nextcloud
|
||||||
|
- MySQL/MariaDB: Mempool, Nextcloud
|
||||||
|
- Redis/Valkey: Immich, IndeedHub, Penpot (caching/sessions)
|
||||||
|
- SQLite: Optional local state (Cargo.toml comments out `sqlx`)
|
||||||
|
|
||||||
|
Connection: Via container network (not directly accessible from backend)
|
||||||
|
|
||||||
|
- **Key-Value Stores:**
|
||||||
|
- In-memory cache: Tokio sync primitives (Arc<DashMap>, Mutex)
|
||||||
|
- Persistent app state: User credentials, device tokens, manifest cache stored in `$DATA_DIR`
|
||||||
|
|
||||||
|
**File Storage:**
|
||||||
|
- Local filesystem only
|
||||||
|
- User data directory: `$ARCHIPELAGO_DATA_DIR` (default `/var/lib/archipelago/`)
|
||||||
|
- Subdirectories: `apps/`, `secrets/`, `backups/`, `content/`, `catalog/`
|
||||||
|
- App-specific: `/var/lib/archipelago/<app>/` (mounted into containers)
|
||||||
|
- Content sharing: Peer-to-peer via HTTP Range requests (see `content_server.rs`)
|
||||||
|
|
||||||
|
**Caching:**
|
||||||
|
- No external cache service
|
||||||
|
- In-app caching: Tokio-spawned tasks, Arc<DashMap> for concurrent access
|
||||||
|
- Browser caching: Workbox service worker (5-min API cache, 30-day asset cache, 1-year font cache)
|
||||||
|
|
||||||
|
## Authentication & Identity
|
||||||
|
|
||||||
|
**Auth Provider:**
|
||||||
|
- Custom JWT-based (node-local)
|
||||||
|
- Implementation: Ed25519 signing (key in `credentials/` directory)
|
||||||
|
- Session tokens: Stored browser-side via cookies (CSRF token middleware)
|
||||||
|
- Related files: `core/archipelago/src/auth.rs`, `core/archipelago/src/identity_manager.rs`
|
||||||
|
|
||||||
|
**BIP-39 Mnemonic Seed:**
|
||||||
|
- Seed generation: `bip39` crate (v2.1.0)
|
||||||
|
- HD key derivation: `bitcoin` crate (v0.32.5) with BIP-32
|
||||||
|
- Signing: Ed25519 for identity, ECDSA for Bitcoin transactions
|
||||||
|
- Related files: `core/archipelago/src/seed.rs`
|
||||||
|
|
||||||
|
**Identity (Decentralized):**
|
||||||
|
- Nostr npub (from Ed25519 keys)
|
||||||
|
- DID (Decentralized Identifier) via did:dht (BitTorrent DHT)
|
||||||
|
- Related files: `core/archipelago/src/identity.rs`, `core/archipelago/src/nostr_discovery.rs`
|
||||||
|
|
||||||
|
**2FA:**
|
||||||
|
- TOTP (Time-based One-Time Password)
|
||||||
|
- Library: `totp-rs` (v5.7, with otpauth and gen_secret)
|
||||||
|
- QR generation: `qrcode` crate (v0.14)
|
||||||
|
- Encrypted storage: Argon2-derived key + ChaCha20-Poly1305
|
||||||
|
- Related files: `core/archipelago/src/totp.rs`
|
||||||
|
|
||||||
|
## Monitoring & Observability
|
||||||
|
|
||||||
|
**Error Tracking:**
|
||||||
|
- Not integrated (logging only)
|
||||||
|
|
||||||
|
**Logs:**
|
||||||
|
- Approach: Structured logging via `tracing` crate
|
||||||
|
- Output: stdout (configurable level via `RUST_LOG` or config file)
|
||||||
|
- Subscriber: `tracing-subscriber` with `env-filter`
|
||||||
|
- Related files: `core/archipelago/src/monitoring.rs`, `core/archipelago/src/health_monitor.rs`
|
||||||
|
|
||||||
|
**Health Monitoring:**
|
||||||
|
- Health checks: Container liveness probes (Docker/Podman healthcheck)
|
||||||
|
- System metrics: Disk space, memory, container startup tiers
|
||||||
|
- Related files: `core/archipelago/src/health_monitor.rs`
|
||||||
|
|
||||||
|
## CI/CD & Deployment
|
||||||
|
|
||||||
|
**Hosting:**
|
||||||
|
- Bare metal (Debian Linux) with Podman rootless containers
|
||||||
|
- Fleet nodes: .198, .228, .116, x250-dev, Framework PT (various test/dev targets)
|
||||||
|
|
||||||
|
**CI Pipeline:**
|
||||||
|
- Git-based CI: Gitea (self-hosted at `.160:3000` and `.168:3000`)
|
||||||
|
- Push accounts: `gitea-ai` (for protected main branch)
|
||||||
|
- Build pipeline: Local `cargo build`/`npm run build` (not centralized CI/CD service)
|
||||||
|
- Release: Manual versioning + signed OTA manifests
|
||||||
|
- Docker builds: Local `docker-compose` or Dockerfile.web/backend
|
||||||
|
|
||||||
|
**Deployment:**
|
||||||
|
- Container orchestration: Podman with Quadlet systemd units (Phase 3+)
|
||||||
|
- Legacy fallback: Direct `podman create + systemctl start`
|
||||||
|
- OTA (Over-The-Air): Signed manifest-driven updates (v1.7+)
|
||||||
|
- Sideload: Binary + tarball to `/usr/local/bin/archipelago` and `/opt/archipelago/`
|
||||||
|
|
||||||
|
## Environment Configuration
|
||||||
|
|
||||||
|
**Required env vars:**
|
||||||
|
- `ARCHIPELAGO_DATA_DIR` - Local state directory (default: platform-specific)
|
||||||
|
- `ARCHIPELAGO_LOG_LEVEL` - Logging verbosity (default: `info`)
|
||||||
|
- `CONTAINER_RUNTIME` - `podman` or `docker` (auto-detect by default)
|
||||||
|
- `NOSTR_DISCOVERY_ENABLED` - Enable node publishing to Nostr relays (false by default)
|
||||||
|
- `NOSTR_RELAYS` - Comma-separated relay URLs (if discovery enabled)
|
||||||
|
- `NOSTR_TOR_PROXY` - SOCKS5 proxy address (optional, routes Nostr through Tor)
|
||||||
|
- `VITE_AIUI_URL` - AIUI chat interface URL (frontend, optional)
|
||||||
|
- `BACKEND_URL` - Backend target for dev server (frontend, default: `http://localhost:5959`)
|
||||||
|
|
||||||
|
**Secrets location:**
|
||||||
|
- At-rest: `$DATA_DIR/credentials/` (user.json with encrypted TOTP, session keys)
|
||||||
|
- In-container: Mounted as read-only volumes, never logged
|
||||||
|
- No `.env` file in production (config-driven)
|
||||||
|
|
||||||
|
## Webhooks & Callbacks
|
||||||
|
|
||||||
|
**Incoming:**
|
||||||
|
- Marketplace callbacks - App catalog updates from registry
|
||||||
|
- Peer discovery webhooks (via Nostr relays)
|
||||||
|
- Related files: `core/archipelago/src/webhooks.rs`
|
||||||
|
|
||||||
|
**Outgoing:**
|
||||||
|
- Device token push notifications (not yet implemented)
|
||||||
|
- App install/uninstall event notifications (framework-pt integration)
|
||||||
|
- Related files: `core/archipelago/src/device_tokens.rs`
|
||||||
|
|
||||||
|
## Container-Based Services (Orchestrated by Archipelago)
|
||||||
|
|
||||||
|
**Bitcoin Stack:**
|
||||||
|
- Bitcoin Core (lncm/bitcoind:v27.0 or knots variant)
|
||||||
|
- ElectrumX (via separate image)
|
||||||
|
- Electrs (alternative to ElectrumX)
|
||||||
|
|
||||||
|
**Lightning/Payments:**
|
||||||
|
- LND (lightninglabs/lnd:v0.17.4-beta+)
|
||||||
|
- BTCPay Server (btcpayserver:1.13.5+)
|
||||||
|
- Fedimint (fedimint/fedimintd:v0.10.0+)
|
||||||
|
|
||||||
|
**Media & Content:**
|
||||||
|
- Immich (self-hosted photo/media library)
|
||||||
|
- Nextcloud (cloud storage)
|
||||||
|
- OnlyOffice (document server)
|
||||||
|
- Penpot (design tool)
|
||||||
|
- SearXNG (search engine)
|
||||||
|
- FileBrowser (file management UI)
|
||||||
|
|
||||||
|
**Infrastructure:**
|
||||||
|
- nginx (reverse proxy, CORS, rate limiting)
|
||||||
|
- Home Assistant (home automation hub)
|
||||||
|
- Grafana (metrics dashboard)
|
||||||
|
- ThunderHub (Lightning node UI)
|
||||||
|
- Mempool Explorer (blockchain monitor)
|
||||||
|
- Endurain (fitness tracking)
|
||||||
|
- Morphos (file converter)
|
||||||
|
- IndeedHub (job board)
|
||||||
|
- Pine (voice assistant)
|
||||||
|
|
||||||
|
## Integration Points (API Contracts)
|
||||||
|
|
||||||
|
**Backend ↔ Frontend (gRPC-style RPC):**
|
||||||
|
- Endpoint: `/rpc/v1/*` (HTTP/REST)
|
||||||
|
- Related files: `core/archipelago/src/api/rpc/` (all RPC handlers)
|
||||||
|
- Major modules: `auth.rs`, `bitcoin.rs`, `lnd/`, `container.rs`, `marketplace.rs`, `mesh/`
|
||||||
|
|
||||||
|
**Frontend ↔ App Iframes:**
|
||||||
|
- Endpoint: `/app/<app-name>/*` (proxied to container)
|
||||||
|
- Sandbox: Cross-origin iframe isolation
|
||||||
|
|
||||||
|
**Mesh Node ↔ Reticulum:**
|
||||||
|
- Serial: USB LoRa radio (Meshtastic-compatible)
|
||||||
|
- Protocol: Binary Meshcore format
|
||||||
|
- Related files: `core/archipelago/src/mesh/`
|
||||||
|
|
||||||
|
**Catalog ↔ App Registry:**
|
||||||
|
- Endpoint: `/api/app-catalog` (cached from registry)
|
||||||
|
- Format: Signed YAML manifest + SHA256 verification
|
||||||
|
- Related files: `core/archipelago/src/marketplace.rs`, `app_catalog/`
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
*Integration audit: 2026-07-29*
|
||||||
176
.planning/codebase/STACK.md
Normal file
176
.planning/codebase/STACK.md
Normal file
@ -0,0 +1,176 @@
|
|||||||
|
# Technology Stack
|
||||||
|
|
||||||
|
**Analysis Date:** 2026-07-29
|
||||||
|
|
||||||
|
## Languages
|
||||||
|
|
||||||
|
**Primary:**
|
||||||
|
- Rust 2021 edition - Backend server (Archipelago daemon, container orchestrator)
|
||||||
|
- TypeScript 5.9 - Frontend (Vue components, application code)
|
||||||
|
- Vue 3 (TypeScript) - UI framework and reactive components
|
||||||
|
- Python 3.13 - Reticulum mesh daemon (RNS/LXMF protocols)
|
||||||
|
|
||||||
|
**Secondary:**
|
||||||
|
- JavaScript - Build scripts, mock backend (`neode-ui/mock-backend.js`), test utilities
|
||||||
|
- YAML - Configuration (docker-compose, app manifests)
|
||||||
|
- Shell - Build scripts, deployment helpers
|
||||||
|
|
||||||
|
## Runtime
|
||||||
|
|
||||||
|
**Environment:**
|
||||||
|
- Rust (1.70+) - Native compiled binaries
|
||||||
|
- Node.js 22 (Alpine-based containers) - Frontend dev/build, mock backend
|
||||||
|
- Python 3.13 - Reticulum daemon runtime
|
||||||
|
- Tokio async runtime (v1, full features) - Async server runtime
|
||||||
|
|
||||||
|
**Package Manager:**
|
||||||
|
- npm (v10+) - JavaScript dependencies
|
||||||
|
- Cargo (v1.70+) - Rust dependencies
|
||||||
|
- pip - Python dependencies (Reticulum stack)
|
||||||
|
- Lockfiles: `neode-ui/package-lock.json`, `core/Cargo.lock`
|
||||||
|
|
||||||
|
## Frameworks
|
||||||
|
|
||||||
|
**Core:**
|
||||||
|
- Tokio (v1) - Async Rust runtime, full features (networking, signals, sync primitives)
|
||||||
|
- Vue 3 (v3.5) - Progressive web framework with TypeScript support
|
||||||
|
- Hyper (v0.14) - HTTP/1 server framework with WebSocket support
|
||||||
|
- Reticulum (v1.3.5) - Mesh networking protocol stack
|
||||||
|
- LXMF (v1.0.1) - Lightweight message format protocol
|
||||||
|
|
||||||
|
**HTTP & WebSocket:**
|
||||||
|
- Hyper v0.14 (HTTP/1, full features) - Core HTTP server
|
||||||
|
- Hyper-util v0.1 - HTTP utilities
|
||||||
|
- Tower v0.5 - Middleware and service composing
|
||||||
|
- Tower-http v0.6 - CORS, tracing middleware
|
||||||
|
- Hyper-ws-listener v0.3 - WebSocket upgrade handler
|
||||||
|
- Tokio-tungstenite v0.20 - WebSocket client/server implementation
|
||||||
|
- Reqwest v0.11 - HTTP client (with rustls-tls, SOCKS proxy support, JSON)
|
||||||
|
|
||||||
|
**Frontend Build:**
|
||||||
|
- Vite v7.2 - Build tool and dev server
|
||||||
|
- Vue-tsc v3.1 - TypeScript compilation for Vue
|
||||||
|
- Tailwind CSS v3.4 - Utility-first CSS framework
|
||||||
|
- Autoprefixer v10.4 - CSS vendor prefixes
|
||||||
|
- PostCSS v8.5 - CSS processing
|
||||||
|
|
||||||
|
**Testing:**
|
||||||
|
- Vitest v3.1 - Unit test runner (Vite-native)
|
||||||
|
- Playwright v1.58 - E2E testing (browser automation)
|
||||||
|
- @vue/test-utils v2.4 - Vue component testing
|
||||||
|
- JSDOM v25 - DOM implementation for tests
|
||||||
|
- Tokio-test v0.4 - Async Rust test utilities
|
||||||
|
|
||||||
|
**PWA:**
|
||||||
|
- Vite-plugin-pwa v1.2 - Progressive Web App support
|
||||||
|
- Workbox integration - Service worker caching strategies
|
||||||
|
|
||||||
|
## Key Dependencies
|
||||||
|
|
||||||
|
**Critical:**
|
||||||
|
- bitcoin v0.32.5 - Bitcoin library (with rand-std feature for BIP-39/BIP-32)
|
||||||
|
- bip39 v2.1.0 - Mnemonic seed generation
|
||||||
|
- nostr-sdk v0.44 - Nostr protocol (NIP-04, NIP-44 encrypted messaging)
|
||||||
|
- mainline v2 - BitTorrent DHT (did:dht decentralized identity)
|
||||||
|
- reticulum v1.3.5 - Mesh networking protocol
|
||||||
|
- lxmf v1.0.1 - Lightweight message format
|
||||||
|
|
||||||
|
**Cryptography:**
|
||||||
|
- ed25519-dalek v2.2 - Ed25519 digital signatures (with rand_core)
|
||||||
|
- curve25519-dalek v4.1 - X25519 elliptic curve (key agreement)
|
||||||
|
- blake3 v1 - BLAKE3 hash function
|
||||||
|
- bcrypt v0.15 - Password hashing
|
||||||
|
- sha2 v0.10 - SHA-256 hashing
|
||||||
|
- hmac v0.12 - HMAC authentication
|
||||||
|
- argon2 v0.5 - Argon2 password hashing
|
||||||
|
- chacha20poly1305 v0.10 - AEAD encryption
|
||||||
|
- zeroize v1.8 - Secure memory wiping
|
||||||
|
|
||||||
|
**Authentication & Identity:**
|
||||||
|
- uuid v1.0 - UUID generation (v4)
|
||||||
|
- totp-rs v5.7 - TOTP 2FA (with otpauth, gen_secret)
|
||||||
|
- qrcode v0.14 - QR code generation (server-side)
|
||||||
|
|
||||||
|
**Data Serialization:**
|
||||||
|
- serde v1.0 - Serialization framework (with derive)
|
||||||
|
- serde_json v1.0 - JSON codec
|
||||||
|
- serde_yaml v0.9 - YAML codec
|
||||||
|
- ciborium v0.2 - CBOR encoding/decoding
|
||||||
|
- serde_bytes v0.11 - Efficient byte serialization
|
||||||
|
- toml v0.8 - TOML config parsing
|
||||||
|
|
||||||
|
**Networking & Mesh:**
|
||||||
|
- mdns-sd v0.18 - mDNS service discovery
|
||||||
|
- serial2-tokio v0.1 - Serial port communication (LoRa radios over USB)
|
||||||
|
- socket2 v0.5 - Low-level socket options (IPv6_V6ONLY for dual-stack)
|
||||||
|
- libc v0.2 - Process group signaling
|
||||||
|
|
||||||
|
**Compression & Archives:**
|
||||||
|
- tar v0.4 - TAR archive creation
|
||||||
|
- flate2 v1.0 - gzip compression
|
||||||
|
- zip v2.0 - ZIP archive handling (LoRa firmware flashing)
|
||||||
|
- reed-solomon-erasure v6.0 - Erasure coding (Phase 2 mesh transport)
|
||||||
|
- hkdf v0.12 - HKDF key derivation (Phase 3 encrypted mesh)
|
||||||
|
|
||||||
|
**Utilities:**
|
||||||
|
- anyhow v1.0 - Error handling
|
||||||
|
- thiserror v1.0 - Error types with derive macros
|
||||||
|
- tracing v0.1 - Structured logging
|
||||||
|
- tracing-subscriber v0.3 - Log filtering and formatting
|
||||||
|
- regex v1.10 - Pattern matching
|
||||||
|
- chrono v0.4 - Date/time handling
|
||||||
|
- hex v0.4 - Hex encoding/decoding
|
||||||
|
- bs58 v0.5 - Base58 encoding (Bitcoin addresses)
|
||||||
|
- base64 v0.21 - Base64 encoding
|
||||||
|
- zbase32 v0.1 - Z-base-32 encoding (DHT)
|
||||||
|
- data-encoding v2.6 - Multiple encoding schemes
|
||||||
|
- bytes v1 - Efficient byte buffer
|
||||||
|
- futures-util v0.3 - Async utilities
|
||||||
|
- http-body-util v0.1 - HTTP body utilities
|
||||||
|
- http-body v1.0 - HTTP body abstractions
|
||||||
|
- indexmap v2.0 - Ordered maps
|
||||||
|
- async-trait v0.1 - Async trait methods
|
||||||
|
- sd-notify v0.4 - Systemd watchdog notification
|
||||||
|
|
||||||
|
**Infrastructure (Optional):**
|
||||||
|
- iroh v1 (optional, feature-gated) - QUIC-based peer swarm engine (Phase 2)
|
||||||
|
- iroh-blobs v0.103 (optional, feature-gated) - Content addressable storage provider
|
||||||
|
|
||||||
|
## Configuration
|
||||||
|
|
||||||
|
**Environment:**
|
||||||
|
- Config file: `config/archipelago.toml` or `$DATA_DIR/config.yml`
|
||||||
|
- Runtime options: Docker/Podman selection, dev/prod modes, FIPS anchor selection, Nostr discovery
|
||||||
|
- Key env vars: `ARCHIPELAGO_DATA_DIR`, `ARCHIPELAGO_LOG_LEVEL`, `CONTAINER_RUNTIME`, `NOSTR_DISCOVERY_ENABLED`, `NOSTR_RELAYS`, `NOSTR_TOR_PROXY`
|
||||||
|
|
||||||
|
**Build:**
|
||||||
|
- Cargo workspace at `core/` (5 members: archipelago, container, openwrt, performance, security)
|
||||||
|
- Release profile: opt-level 3
|
||||||
|
- Dev profile: opt-level 0
|
||||||
|
- Test profile: opt-level 3
|
||||||
|
- Cross-compilation: aarch64-unknown-linux-gnu via `.cargo/config.toml`
|
||||||
|
|
||||||
|
**Frontend Build:**
|
||||||
|
- Vite config: `neode-ui/vite.config.ts` (development port 8100, production build to `../web/dist/neode-ui`)
|
||||||
|
- TypeScript config: `neode-ui/tsconfig.json`
|
||||||
|
- Module path alias: `@` → `src/`
|
||||||
|
|
||||||
|
## Platform Requirements
|
||||||
|
|
||||||
|
**Development:**
|
||||||
|
- Rust 1.70+ with Cargo
|
||||||
|
- Node.js 22+ with npm
|
||||||
|
- Python 3.13+ (for Reticulum daemon)
|
||||||
|
- Docker or Podman (for local app testing)
|
||||||
|
- rustup target: `aarch64-unknown-linux-gnu` (for ARM64 cross-compilation)
|
||||||
|
- gcc-aarch64-linux-gnu (for cross-compilation toolchain on Linux hosts)
|
||||||
|
|
||||||
|
**Production:**
|
||||||
|
- Deployment target: Debian/Alpine Linux (rootless Podman)
|
||||||
|
- Binary output: `/usr/local/bin/archipelago` (sideloaded or via Quadlet systemd units)
|
||||||
|
- Frontend served: nginx with Vite-built SPA (PWA manifest, service worker, CORS proxies)
|
||||||
|
- Database support: Optional (SQLx with SQLite driver available but commented out)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
*Stack analysis: 2026-07-29*
|
||||||
434
.planning/codebase/STRUCTURE.md
Normal file
434
.planning/codebase/STRUCTURE.md
Normal file
@ -0,0 +1,434 @@
|
|||||||
|
# Codebase Structure
|
||||||
|
|
||||||
|
**Analysis Date:** 2026-07-29
|
||||||
|
|
||||||
|
## Directory Layout
|
||||||
|
|
||||||
|
```
|
||||||
|
archipelago-repo/
|
||||||
|
├── core/ # Rust workspace root (Cargo.toml at workspace level)
|
||||||
|
│ ├── archipelago/ # Main daemon binary (backend)
|
||||||
|
│ │ ├── src/
|
||||||
|
│ │ │ ├── main.rs # Entry point, startup, background tasks
|
||||||
|
│ │ │ ├── server.rs # HTTP server (Hyper), listener, connection multiplexing
|
||||||
|
│ │ │ ├── state.rs # StateManager + data_model.rs (central state)
|
||||||
|
│ │ │ ├── auth.rs # User auth, password hashing, session management
|
||||||
|
│ │ │ ├── identity.rs # Node identity, Ed25519 keys, Tor address
|
||||||
|
│ │ │ ├── config.rs # Config loading, data directory setup
|
||||||
|
│ │ │ ├── api/ # HTTP API layer
|
||||||
|
│ │ │ │ ├── handler/ # HTTP request dispatch, WebSocket, content proxy
|
||||||
|
│ │ │ │ └── rpc/ # JSON-RPC 2.0 methods (~40 domain modules)
|
||||||
|
│ │ │ │ ├── auth.rs # auth.login, auth.setup, etc.
|
||||||
|
│ │ │ │ ├── container.rs # container.install, .list, .start, etc.
|
||||||
|
│ │ │ │ ├── bitcoin.rs # bitcoin.status, bitcoin.send, etc.
|
||||||
|
│ │ │ │ ├── mesh.rs # mesh.* (peer discovery, LoRa, federation)
|
||||||
|
│ │ │ │ ├── wallet.rs # wallet.* (lightning, Bitcoin)
|
||||||
|
│ │ │ │ └── [20+ other domains]
|
||||||
|
│ │ │ ├── container/ # Container orchestration (Podman)
|
||||||
|
│ │ │ │ ├── prod_orchestrator.rs # Main Podman lifecycle (>250KB)
|
||||||
|
│ │ │ │ ├── boot_reconciler.rs # Periodic manifest sync loop
|
||||||
|
│ │ │ │ ├── docker_packages.rs # Image registry, image verification
|
||||||
|
│ │ │ │ ├── quadlet.rs # Systemd Quadlet generation
|
||||||
|
│ │ │ │ ├── secrets.rs # Secret injection (0600 files)
|
||||||
|
│ │ │ │ ├── lnd.rs # Lightning Network Daemon container setup
|
||||||
|
│ │ │ │ ├── app_catalog.rs # Signed app catalog, manifest overlay
|
||||||
|
│ │ │ │ └── [data managers, registry, image policy]
|
||||||
|
│ │ │ ├── bootstrap.rs # Post-startup tasks (systemd units, audio stack, gamepad)
|
||||||
|
│ │ │ ├── crash_recovery.rs # Container recovery after crash, PID marker
|
||||||
|
│ │ │ ├── health_monitor.rs # Periodic app health checks, restart
|
||||||
|
│ │ │ ├── mesh.rs # Mesh P2P listener, sender, LoRa radio control
|
||||||
|
│ │ │ ├── federation.rs # Federation (DNS-SD, HTTP API)
|
||||||
|
│ │ │ ├── fips/ # FIPS anchor (Tor bridge to peer)
|
||||||
|
│ │ │ ├── bitcoin_rpc.rs # Bitcoin Core RPC client calls
|
||||||
|
│ │ │ ├── bitcoin_status.rs # Bitcoin sync status polling
|
||||||
|
│ │ │ ├── content_server.rs # Content-addressed blob server (CAP tokens)
|
||||||
|
│ │ │ ├── blobs.rs # BlobStore (hash→file mapping, encryption)
|
||||||
|
│ │ │ ├── wallet.rs # Lightning + Bitcoin wallet logic
|
||||||
|
│ │ │ ├── identity_manager.rs # Seed derivation, key rotation
|
||||||
|
│ │ │ ├── marketplace.rs # Marketplace transaction logic
|
||||||
|
│ │ │ ├── transport.rs # Transport selection (Mesh/FIPS/Tor routing)
|
||||||
|
│ │ │ ├── update.rs # OTA update apply, verification, rollback
|
||||||
|
│ │ │ ├── session.rs # Session store (SQLite or in-memory)
|
||||||
|
│ │ │ ├── rate_limit.rs # Rate limiter (per-IP, per-endpoint, per-user)
|
||||||
|
│ │ │ ├── monitoring.rs # Metrics collection (app count, memory, etc.)
|
||||||
|
│ │ │ ├── data_model.rs # State struct tree (serde Serialize/Deserialize)
|
||||||
|
│ │ │ ├── constants.rs # Global constants (version, defaults)
|
||||||
|
│ │ │ ├── peer*.rs, webhook*.rs, nostr*.rs, vpn.rs, etc.
|
||||||
|
│ │ │ └── seed.rs # Seed storage, backup QR generation
|
||||||
|
│ │ ├── Cargo.toml # Dependencies
|
||||||
|
│ │ └── tests/ # Unit/integration tests
|
||||||
|
│ │
|
||||||
|
│ ├── container/ # Container management library (OCI types, Podman)
|
||||||
|
│ │ ├── src/
|
||||||
|
│ │ │ ├── manifest.rs # Manifest struct (YAML parsing)
|
||||||
|
│ │ │ ├── runtime.rs # Podman CLI calls (create, start, stop, logs)
|
||||||
|
│ │ │ ├── podman_client.rs # Podman socket API client
|
||||||
|
│ │ │ ├── image_verify.rs # Image signature verification (Cosign)
|
||||||
|
│ │ │ └── port_manager.rs # Port allocation, conflict detection
|
||||||
|
│ │ └── Cargo.toml
|
||||||
|
│ │
|
||||||
|
│ ├── security/ # Secrets management library
|
||||||
|
│ │ ├── src/
|
||||||
|
│ │ │ ├── secrets_manager.rs # Secret encryption/decryption (ChaCha20)
|
||||||
|
│ │ │ └── vault.rs # Vault storage, rotation
|
||||||
|
│ │ └── Cargo.toml
|
||||||
|
│ │
|
||||||
|
│ ├── performance/ # Performance monitoring library
|
||||||
|
│ ├── openwrt/ # OpenWrt device integration
|
||||||
|
│ ├── Cargo.toml # Workspace manifest (members: archipelago, container, security, etc.)
|
||||||
|
│ └── Cargo.lock # Locked dependency versions
|
||||||
|
│
|
||||||
|
├── neode-ui/ # Frontend (Vue 3, TypeScript)
|
||||||
|
│ ├── src/
|
||||||
|
│ │ ├── main.ts # Vue app entry point, Router setup, WebSocket init
|
||||||
|
│ │ ├── App.vue # Root component (layout, nav)
|
||||||
|
│ │ ├── router/
|
||||||
|
│ │ │ └── index.ts # Vue Router config (routes, guards)
|
||||||
|
│ │ ├── views/ # Page-level components (one per route)
|
||||||
|
│ │ │ ├── Home.vue # Dashboard
|
||||||
|
│ │ │ ├── Apps.vue # App browser + installer
|
||||||
|
│ │ │ ├── AppDetails.vue # Single app detail + logs
|
||||||
|
│ │ │ ├── AppSession.vue # Iframe container for app content
|
||||||
|
│ │ │ ├── Cloud.vue # File browser (WebDAV/DWN)
|
||||||
|
│ │ │ ├── Mesh.vue # Mesh map, contacts, messages
|
||||||
|
│ │ │ ├── Wallet.vue # Lightning + Bitcoin addresses/sends
|
||||||
|
│ │ │ ├── Marketplace.vue # Paid apps, content marketplace
|
||||||
|
│ │ │ ├── Server.vue # Node status, settings, restart
|
||||||
|
│ │ │ ├── Federation.vue # Federation peers, federation apps
|
||||||
|
│ │ │ ├── Onboarding*/ # Multi-step setup flow
|
||||||
|
│ │ │ └── [15+ other pages]
|
||||||
|
│ │ ├── components/ # Reusable UI components
|
||||||
|
│ │ │ ├── AppCard.vue # App listing card
|
||||||
|
│ │ │ ├── AppInstaller.vue # Install modal
|
||||||
|
│ │ │ ├── Modal.vue # Generic modal (teleported)
|
||||||
|
│ │ │ ├── Toast.vue # Notification toast
|
||||||
|
│ │ │ ├── MeshGraph.vue # Mesh topology graph (D3)
|
||||||
|
│ │ │ ├── MapView.vue # Mesh map (Leaflet)
|
||||||
|
│ │ │ ├── QrScanner.vue # QR code input
|
||||||
|
│ │ │ └── [30+ other components]
|
||||||
|
│ │ ├── composables/ # Logic hooks (Vue composition API)
|
||||||
|
│ │ │ ├── useAuth.ts # Login/logout logic
|
||||||
|
│ │ │ ├── useAppStore.ts # Access app Pinia store
|
||||||
|
│ │ │ ├── useRpc.ts # Make RPC calls
|
||||||
|
│ │ │ ├── useWebSocket.ts # WebSocket connection management
|
||||||
|
│ │ │ ├── useOnboarding.ts # Onboarding flow state
|
||||||
|
│ │ │ ├── useControllerNav.ts # Gamepad controller navigation
|
||||||
|
│ │ │ └── [20+ other composables]
|
||||||
|
│ │ ├── stores/ # Pinia state management (reactive stores)
|
||||||
|
│ │ │ ├── appStore.ts # Apps list, install state
|
||||||
|
│ │ │ ├── walletStore.ts # Lightning/Bitcoin addresses, balance
|
||||||
|
│ │ │ ├── meshStore.ts # Mesh peers, messages
|
||||||
|
│ │ │ ├── settingsStore.ts # User settings, theme, language
|
||||||
|
│ │ │ ├── userStore.ts # Current user identity
|
||||||
|
│ │ │ └── [other stores]
|
||||||
|
│ │ ├── api/
|
||||||
|
│ │ │ └── rpc-client.ts # RPC client library (request, WebSocket, reconnect)
|
||||||
|
│ │ ├── services/ # Business logic (not Vue-dependent)
|
||||||
|
│ │ │ ├── qrScanner.ts # QR scanner initialization
|
||||||
|
│ │ │ └── [other services]
|
||||||
|
│ │ ├── utils/ # Utility functions
|
||||||
|
│ │ │ ├── format.ts # Date, number, currency formatting
|
||||||
|
│ │ │ ├── validate.ts # Input validation (emails, addresses)
|
||||||
|
│ │ │ ├── crypto.ts # Client-side crypto (BIP39, etc.)
|
||||||
|
│ │ │ └── [helpers]
|
||||||
|
│ │ ├── types/ # TypeScript type definitions
|
||||||
|
│ │ │ ├── index.ts # Export all types
|
||||||
|
│ │ │ └── [domain-specific types]
|
||||||
|
│ │ ├── i18n.ts # Internationalization config
|
||||||
|
│ │ ├── locales/ # Translation files
|
||||||
|
│ │ │ ├── en.json # English
|
||||||
|
│ │ │ ├── es.json # Spanish
|
||||||
|
│ │ │ └── [other languages]
|
||||||
|
│ │ ├── assets/ # Static assets
|
||||||
|
│ │ │ └── icon/ # App icons, favicons
|
||||||
|
│ │ ├── style.css # Global CSS (Tailwind + custom)
|
||||||
|
│ │ ├── data/ # Static data (country lists, etc.)
|
||||||
|
│ │ └── e2e/ # Playwright E2E tests
|
||||||
|
│ │ ├── intro-experience.spec.ts
|
||||||
|
│ │ └── app-launch.spec.ts
|
||||||
|
│ │
|
||||||
|
│ ├── public/ # Static web root
|
||||||
|
│ │ ├── index.html # HTML entry point
|
||||||
|
│ │ ├── favicon.ico # Browser tab icon
|
||||||
|
│ │ ├── manifest.json # PWA manifest
|
||||||
|
│ │ └── catalog.json # App catalog (copied from app-catalog/catalog.json)
|
||||||
|
│ │
|
||||||
|
│ ├── package.json # Frontend dependencies + build scripts
|
||||||
|
│ ├── tsconfig.json # TypeScript config
|
||||||
|
│ ├── vite.config.ts # Vite build config
|
||||||
|
│ ├── vitest.config.ts # Vitest test runner config
|
||||||
|
│ ├── tailwind.config.js # Tailwind CSS config
|
||||||
|
│ ├── mock-backend.js # Dev mock server (for `npm run dev:mock`)
|
||||||
|
│ └── [other build configs]
|
||||||
|
│
|
||||||
|
├── apps/ # Containerized applications (app manifests + build scripts)
|
||||||
|
│ ├── bitcoin-core/ # Bitcoin Core container
|
||||||
|
│ │ ├── manifest.yml # Archipelago manifest (interface, ports, secrets, health)
|
||||||
|
│ │ ├── Dockerfile # OCI image definition
|
||||||
|
│ │ ├── bitcoin.conf.template # Config template (secrets injected at runtime)
|
||||||
|
│ │ └── start.sh # Container entrypoint
|
||||||
|
│ │
|
||||||
|
│ ├── lightning-stack/ # Lightning Network stack (LND)
|
||||||
|
│ ├── lnd/ # LND daemon
|
||||||
|
│ ├── immich/ # Photo backup app
|
||||||
|
│ ├── nextcloud/ # Cloud storage
|
||||||
|
│ ├── electrumx/ # Bitcoin block explorer index
|
||||||
|
│ ├── router/ # Mesh router app
|
||||||
|
│ ├── pine/ # Voice assistant (whisper + piper + nginx)
|
||||||
|
│ ├── vaultwarden/ # Password manager
|
||||||
|
│ ├── [40+ other apps]
|
||||||
|
│ ├── QUICKSTART.md # App development guide
|
||||||
|
│ ├── PORTS.md # Port allocation reference
|
||||||
|
│ └── build.sh # App build script (all apps)
|
||||||
|
│
|
||||||
|
├── web/ # Built frontend output
|
||||||
|
│ └── dist/
|
||||||
|
│ └── neode-ui/ # `npm run build` output (served by nginx)
|
||||||
|
│ ├── index.html
|
||||||
|
│ ├── [JS bundles]
|
||||||
|
│ └── [static assets]
|
||||||
|
│
|
||||||
|
├── tests/ # Test suite
|
||||||
|
│ ├── lifecycle/
|
||||||
|
│ │ ├── run-gate.sh # Single-node production gate (5 iterations)
|
||||||
|
│ │ └── TESTING.md # Test plan documentation
|
||||||
|
│ ├── e2e/ # End-to-end tests (Playwright)
|
||||||
|
│ └── [other test directories]
|
||||||
|
│
|
||||||
|
├── docs/ # Documentation (user & developer guides)
|
||||||
|
│ ├── PRODUCTION-MASTER-PLAN.md # North star: manifest-driven, registry-based, decentralized
|
||||||
|
│ ├── UNIFIED-TASK-TRACKER.md # Open tasks (fastest-first)
|
||||||
|
│ ├── APP-PACKAGING-MIGRATION-PLAN.md
|
||||||
|
│ ├── registry-manifest-design.md
|
||||||
|
│ ├── multinode-testing-plan.md
|
||||||
|
│ ├── release-workflow.md # OTA + ISO release process
|
||||||
|
│ ├── app-development.md # Guide for app developers
|
||||||
|
│ ├── api-rpc-reference.md # JSON-RPC 2.0 method documentation
|
||||||
|
│ └── [20+ other docs]
|
||||||
|
│
|
||||||
|
├── image-recipe/ # ISO/image build scripts
|
||||||
|
│ ├── build-debian-iso.sh # Builds bootable Debian ISO
|
||||||
|
│ ├── include/ # Root filesystem overlays
|
||||||
|
│ │ └── opt/archipelago/ # Pre-baked config, scripts, systemd units
|
||||||
|
│ └── [other image components]
|
||||||
|
│
|
||||||
|
├── scripts/ # Utility scripts
|
||||||
|
│ ├── deploy-to-target.sh # Sideload binary to test node (Tailscale SSH + rsync)
|
||||||
|
│ ├── resilience/ # Resilience test scripts
|
||||||
|
│ └── [other scripts]
|
||||||
|
│
|
||||||
|
├── demo/ # Demo deployment (pre-configured node)
|
||||||
|
│ ├── demo-deploy.yml # Docker Compose for vps2 demo
|
||||||
|
│ └── [demo-specific scripts]
|
||||||
|
│
|
||||||
|
├── docker/ # Docker/Podman config
|
||||||
|
│ └── [docker-compose fragments, Dockerfiles]
|
||||||
|
│
|
||||||
|
├── .planning/ # Codebase analysis documents (this is you)
|
||||||
|
│ └── codebase/
|
||||||
|
│ ├── ARCHITECTURE.md
|
||||||
|
│ ├── STRUCTURE.md
|
||||||
|
│ ├── CONVENTIONS.md
|
||||||
|
│ ├── TESTING.md
|
||||||
|
│ ├── STACK.md
|
||||||
|
│ ├── INTEGRATIONS.md
|
||||||
|
│ └── CONCERNS.md
|
||||||
|
│
|
||||||
|
├── .claude/ # Claude Code configuration
|
||||||
|
│ └── skills/ # GSD skills (if any project-specific)
|
||||||
|
│
|
||||||
|
├── .github/ # GitHub Actions CI/CD
|
||||||
|
├── .gitea/ # Gitea CI/CD (local Gitea runner)
|
||||||
|
├── .git/ # Git repository
|
||||||
|
├── .gitignore # Git ignore patterns
|
||||||
|
├── CLAUDE.md # Project instructions (commit rules, invariants, testing)
|
||||||
|
├── Cargo.lock # Locked Rust dependency versions
|
||||||
|
├── CHANGELOG.md # Release notes
|
||||||
|
├── CODE_OF_CONDUCT.md
|
||||||
|
├── CONTRIBUTING.md
|
||||||
|
└── README.md # Project overview
|
||||||
|
```
|
||||||
|
|
||||||
|
## Directory Purposes
|
||||||
|
|
||||||
|
**core/**
|
||||||
|
- Purpose: Rust backend source and dependencies
|
||||||
|
- Contains: Main daemon binary, container orchestration, RPC handlers, business logic
|
||||||
|
- Key files: `archipelago/src/main.rs` (entry point), `archipelago/Cargo.toml` (deps)
|
||||||
|
|
||||||
|
**neode-ui/**
|
||||||
|
- Purpose: Vue 3 frontend SPA source
|
||||||
|
- Contains: Views, components, stores, translations, tests, build config
|
||||||
|
- Key files: `src/main.ts` (entry point), `vite.config.ts` (build), `package.json` (deps)
|
||||||
|
|
||||||
|
**apps/**
|
||||||
|
- Purpose: Containerized application definitions
|
||||||
|
- Contains: Manifests (YAML), Dockerfiles, configs for ~50 apps (Bitcoin, LND, Immich, etc.)
|
||||||
|
- Key files: `*/manifest.yml` (app definition), `*/Dockerfile` (image build)
|
||||||
|
|
||||||
|
**web/dist/neode-ui/**
|
||||||
|
- Purpose: Production frontend output (built by `npm run build`)
|
||||||
|
- Contains: Bundled JS, HTML, static assets
|
||||||
|
- Key files: `index.html` (entry), JS chunks (hashed filenames)
|
||||||
|
- Note: Served by nginx at `/` on node
|
||||||
|
|
||||||
|
**tests/**
|
||||||
|
- Purpose: Test suite (unit, integration, E2E)
|
||||||
|
- Contains: Lifecycle gate (production exit criterion), E2E specs, test utilities
|
||||||
|
- Key files: `lifecycle/run-gate.sh` (the production gate), `e2e/*.spec.ts` (user flows)
|
||||||
|
|
||||||
|
**docs/**
|
||||||
|
- Purpose: User and developer documentation
|
||||||
|
- Contains: Architecture, design decisions, release process, task tracking
|
||||||
|
- Key files: `PRODUCTION-MASTER-PLAN.md` (north star), `UNIFIED-TASK-TRACKER.md` (open tasks)
|
||||||
|
|
||||||
|
**image-recipe/**
|
||||||
|
- Purpose: ISO/image build scripts
|
||||||
|
- Contains: Debian ISO recipe, root filesystem overlays, bootloader config
|
||||||
|
- Key files: `build-debian-iso.sh` (main build), `include/opt/archipelago/` (pre-baked system config)
|
||||||
|
|
||||||
|
**scripts/**
|
||||||
|
- Purpose: Utility and deployment scripts
|
||||||
|
- Contains: Sideload/deploy to test nodes, resilience testing, CI glue
|
||||||
|
- Key files: `deploy-to-target.sh` (ship binary to remote node)
|
||||||
|
|
||||||
|
## Key File Locations
|
||||||
|
|
||||||
|
**Entry Points:**
|
||||||
|
- Backend daemon: `core/archipelago/src/main.rs` (spawns HTTP server, tasks, orchestrator)
|
||||||
|
- Frontend app: `neode-ui/src/main.ts` (Vue app, router, WebSocket init)
|
||||||
|
- App manifest: `apps/*/manifest.yml` (Archipelago-specific; controls container, secrets, UI)
|
||||||
|
- Production gate: `tests/lifecycle/run-gate.sh` (exit criterion for releases)
|
||||||
|
|
||||||
|
**Configuration:**
|
||||||
|
- Backend config: `core/archipelago/src/config.rs` (data dir, bind port, dev mode flag)
|
||||||
|
- Frontend config: `neode-ui/vite.config.ts` (build settings, env vars)
|
||||||
|
- App registries: `/var/lib/archipelago/registries.json` (user-configured Gitea mirrors)
|
||||||
|
- Secrets location: `/var/lib/archipelago/secrets/` (encrypted ChaCha20 files)
|
||||||
|
|
||||||
|
**Core Logic:**
|
||||||
|
- Container orchestration: `core/archipelago/src/container/prod_orchestrator.rs` (300KB+ main logic)
|
||||||
|
- RPC dispatch: `core/archipelago/src/api/rpc/mod.rs` (method routing, ~200 RPCs)
|
||||||
|
- State management: `core/archipelago/src/state.rs` (StateManager) + `core/archipelago/src/data_model.rs` (struct def)
|
||||||
|
- Frontend stores: `neode-ui/src/stores/` (Pinia stores, reactive state)
|
||||||
|
|
||||||
|
**Testing:**
|
||||||
|
- Rust unit tests: Inline in `core/` modules (use `#[test]` and `#[tokio::test]`)
|
||||||
|
- Vitest unit tests: `neode-ui/src/**/__tests__/*.test.ts`
|
||||||
|
- E2E tests: `neode-ui/e2e/*.spec.ts` (Playwright)
|
||||||
|
- Integration tests: `tests/` (shell scripts, node command testing)
|
||||||
|
|
||||||
|
## Naming Conventions
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Rust modules: `snake_case.rs` (e.g., `health_monitor.rs`, `crash_recovery.rs`)
|
||||||
|
- Vue components: `PascalCase.vue` (e.g., `AppCard.vue`, `MeshGraph.vue`)
|
||||||
|
- Composables: `use[Name].ts` (e.g., `useAuth.ts`, `useRpc.ts`)
|
||||||
|
- Stores: `[domain]Store.ts` (e.g., `appStore.ts`, `walletStore.ts`)
|
||||||
|
- Tests: `[name].test.ts` or `[name].spec.ts` (e.g., `rpc-client.test.ts`, `app-launch.spec.ts`)
|
||||||
|
- Scripts: lowercase with hyphens (e.g., `deploy-to-target.sh`, `run-gate.sh`)
|
||||||
|
|
||||||
|
**Directories:**
|
||||||
|
- Rust workspace members: lowercase (e.g., `archipelago`, `container`, `security`)
|
||||||
|
- Feature directories: PascalCase or lowercase depending on context
|
||||||
|
- `neode-ui/src/views/` — page components (mostly PascalCase)
|
||||||
|
- `neode-ui/src/composables/` — logic hooks (lowercase files with `use` prefix)
|
||||||
|
- `core/archipelago/src/api/rpc/` — RPC modules by domain (lowercase: `bitcoin.rs`, `mesh.rs`)
|
||||||
|
|
||||||
|
**Functions/Methods:**
|
||||||
|
- Async functions: `async fn method_name() -> Result<T>` (no special suffix)
|
||||||
|
- Event handlers: `on[Event]` in Vue (e.g., `@click="onInstall"` calls `onInstall()`)
|
||||||
|
- Computed properties: `computed(() => ...)` (no special name)
|
||||||
|
- Public RPC methods: `pub async fn [domain]_[action](...)` (e.g., `container_install`, `bitcoin_send`)
|
||||||
|
|
||||||
|
**Variables & Constants:**
|
||||||
|
- Constants: `UPPER_SNAKE_CASE` (e.g., `RECONCILER_DEFAULT_INTERVAL`, `MAX_FILE_SIZE`)
|
||||||
|
- State variables: `camelCase` (e.g., `appList`, `isLoading`)
|
||||||
|
- Type aliases: `PascalCase` (e.g., `AppId`, `MeshPeer`)
|
||||||
|
|
||||||
|
**Exports & Modules:**
|
||||||
|
- Re-exports barrel files: `mod.rs` exporting `pub use child::*;`
|
||||||
|
- Private internals: `mod private;` (not `pub mod`)
|
||||||
|
- Path aliases (neode-ui): `@/` = `src/`, `@components/` = `src/components/`
|
||||||
|
|
||||||
|
## Where to Add New Code
|
||||||
|
|
||||||
|
**New RPC Method (Backend):**
|
||||||
|
1. Determine domain (auth, container, bitcoin, mesh, wallet, etc.)
|
||||||
|
2. Add async fn to `core/archipelago/src/api/rpc/[domain].rs`
|
||||||
|
3. Function signature: `pub async fn [action](handler: &RpcHandler, params: [ParamType]) -> Result<[ResponseType]>`
|
||||||
|
4. Register in `core/archipelago/src/api/rpc/mod.rs` dispatcher (line ~350+, search for `match method_name`)
|
||||||
|
5. Test: Unit test in same file with `#[tokio::test]`, or E2E in `tests/`
|
||||||
|
|
||||||
|
**New Frontend View (Page):**
|
||||||
|
1. Create `neode-ui/src/views/[ViewName].vue` (PascalCase)
|
||||||
|
2. Import Router in `neode-ui/src/router/index.ts`, add route
|
||||||
|
3. Add navigation link in `neode-ui/src/components/Nav.vue` (if public-facing)
|
||||||
|
4. State: Use or create Pinia store in `neode-ui/src/stores/`
|
||||||
|
5. Test: Add E2E test in `neode-ui/e2e/` if user-facing flow
|
||||||
|
|
||||||
|
**New Container App:**
|
||||||
|
1. Create directory `apps/[app-name]/`
|
||||||
|
2. Write `manifest.yml` (copy structure from existing app; define interfaces.main.ui, health check, secrets)
|
||||||
|
3. Write `Dockerfile` (base image, deps, entrypoint)
|
||||||
|
4. Add to `app-catalog/catalog.json` with entry (id, version, url to manifest)
|
||||||
|
5. Test: `archipelago container.install { manifest_url: "..." }` on dev node
|
||||||
|
|
||||||
|
**New Component (Frontend):**
|
||||||
|
1. Create `neode-ui/src/components/[ComponentName].vue`
|
||||||
|
2. If reusable logic, extract to `neode-ui/src/composables/use[Logic].ts`
|
||||||
|
3. If shared state, use Pinia store (don't create component-local state)
|
||||||
|
4. Example: `components/AppCard.vue` displays one app (reused in Apps.vue listing)
|
||||||
|
|
||||||
|
**New Utility Function:**
|
||||||
|
- Backend service logic: Add to `core/archipelago/src/[domain].rs` or new file if it's cross-cutting
|
||||||
|
- Frontend helper: Add to `neode-ui/src/utils/` (e.g., `format.ts`, `validate.ts`)
|
||||||
|
- Example: Lightning address validation → `neode-ui/src/utils/validate.ts:validateLightningAddress()`
|
||||||
|
|
||||||
|
**New Test:**
|
||||||
|
- Rust unit test: Inline in source file (`#[test]` or `#[tokio::test]`)
|
||||||
|
- Frontend unit test: `neode-ui/src/composables/__tests__/use[Logic].test.ts`
|
||||||
|
- E2E test: `neode-ui/e2e/[feature].spec.ts` (Playwright)
|
||||||
|
- Integration test: `tests/[feature].sh` (shell script running node commands)
|
||||||
|
|
||||||
|
## Special Directories
|
||||||
|
|
||||||
|
**core/target/**
|
||||||
|
- Purpose: Rust build artifacts (generated)
|
||||||
|
- Generated: Yes (by `cargo build`)
|
||||||
|
- Committed: No (in .gitignore)
|
||||||
|
|
||||||
|
**neode-ui/node_modules/**
|
||||||
|
- Purpose: npm dependencies
|
||||||
|
- Generated: Yes (by `npm install` or `pnpm install`)
|
||||||
|
- Committed: No (in .gitignore)
|
||||||
|
|
||||||
|
**web/dist/**
|
||||||
|
- Purpose: Built frontend output (generated)
|
||||||
|
- Generated: Yes (by `npm run build`)
|
||||||
|
- Committed: No (in .gitignore) — distributed via OTA/ISO
|
||||||
|
|
||||||
|
**/var/lib/archipelago/** (at runtime on node)
|
||||||
|
- Purpose: Data directory (user data, settings, secrets, backups)
|
||||||
|
- Generated: Yes (created by daemon on first boot)
|
||||||
|
- Committed: No (runtime data; contains user secrets)
|
||||||
|
- Subdirs:
|
||||||
|
- `identity/` — Node Ed25519 keys
|
||||||
|
- `secrets/` — Encrypted secret vaults (ChaCha20)
|
||||||
|
- `apps/` — App manifests (disk copies or registry overlays)
|
||||||
|
- `backups/` — Encrypted backup archives
|
||||||
|
- `registries.json` — User-configured Gitea mirrors
|
||||||
|
- etc.
|
||||||
|
|
||||||
|
**/opt/archipelago/** (at runtime on node)
|
||||||
|
- Purpose: System-level Archipelago files (read-only on ISO, writable post-install)
|
||||||
|
- Contains:
|
||||||
|
- `web-ui/` — Built frontend (nginx root)
|
||||||
|
- `bin/archipelago` — Daemon binary
|
||||||
|
- `docker/` — Docker Compose or Quadlet files (orchestration)
|
||||||
|
- `scripts/` — System maintenance scripts
|
||||||
|
- Note: OTA updates overwrite `web-ui/` + `bin/` atomically
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
*Structure analysis: 2026-07-29*
|
||||||
449
.planning/codebase/TESTING.md
Normal file
449
.planning/codebase/TESTING.md
Normal file
@ -0,0 +1,449 @@
|
|||||||
|
# Testing Patterns
|
||||||
|
|
||||||
|
**Analysis Date:** 2026-07-29
|
||||||
|
|
||||||
|
## Test Framework
|
||||||
|
|
||||||
|
**Frontend:**
|
||||||
|
- Runner: Vitest 3.1.1
|
||||||
|
- Config: `neode-ui/vitest.config.ts`
|
||||||
|
- Environment: jsdom (DOM testing in Node.js)
|
||||||
|
- Globals: enabled (`globals: true`) — `describe`, `it`, `expect` available without imports
|
||||||
|
- Assertion library: built-in Vitest assertions (compatible with Jest)
|
||||||
|
|
||||||
|
**Backend (Rust):**
|
||||||
|
- Framework: built-in `#[test]` attribute and `cargo test`
|
||||||
|
- Command: `cd core && cargo test --workspace --bins`
|
||||||
|
|
||||||
|
**E2E (Browser):**
|
||||||
|
- Framework: Playwright 1.58.2
|
||||||
|
- Config: implicit (tests in `neode-ui/e2e/` directory)
|
||||||
|
|
||||||
|
**Shell Integration Tests:**
|
||||||
|
- Framework: Bats (Bash Automated Testing System)
|
||||||
|
- Location: `tests/lifecycle/bats/`
|
||||||
|
- Config files: `tests/lifecycle/lib/rpc.bash` (RPC wrapper helpers)
|
||||||
|
|
||||||
|
**Run Commands:**
|
||||||
|
```bash
|
||||||
|
# Unit tests (Vitest)
|
||||||
|
npm run test # Run all tests once
|
||||||
|
npm run test:watch # Watch mode, re-run on file changes
|
||||||
|
|
||||||
|
# Rust tests
|
||||||
|
cd core && cargo test --workspace --bins
|
||||||
|
|
||||||
|
# Specific Vitest suite
|
||||||
|
npm run test -- src/composables/__tests__/useFileType.test.ts
|
||||||
|
|
||||||
|
# Shell lifecycle tests (from repo root)
|
||||||
|
ARCHY_PASSWORD=password123 tests/lifecycle/run.sh # Read-only tests
|
||||||
|
ARCHY_PASSWORD=password123 ARCHY_ALLOW_DESTRUCTIVE=1 tests/lifecycle/run.sh # Include destructive
|
||||||
|
|
||||||
|
# Release gate (5× iterations, must run ON the target node)
|
||||||
|
ARCHY_PASSWORD=password123 ARCHY_ALLOW_DESTRUCTIVE=1 ARCHY_ITERATIONS=5 \
|
||||||
|
tests/lifecycle/run-gate.sh
|
||||||
|
```
|
||||||
|
|
||||||
|
## Test File Organization
|
||||||
|
|
||||||
|
**Location:**
|
||||||
|
- Frontend: co-located with source in `__tests__/` subdirectories
|
||||||
|
- Example: `src/composables/useFileType.ts` → `src/composables/__tests__/useFileType.test.ts`
|
||||||
|
- Example: `src/api/rpc-client.ts` → `src/api/__tests__/rpc-client.test.ts`
|
||||||
|
- E2E: separate `e2e/` directory at root of frontend
|
||||||
|
- Shell: `tests/lifecycle/bats/` directory
|
||||||
|
|
||||||
|
**Naming:**
|
||||||
|
- Vitest: `*.test.ts` or `*.spec.ts` suffix (`.test.ts` preferred)
|
||||||
|
- Playwright: `*.spec.ts` suffix
|
||||||
|
- Bats: `*.bats` suffix
|
||||||
|
- Rust unit: same file with `#[test]` functions at the bottom or in submodules
|
||||||
|
|
||||||
|
**Structure:**
|
||||||
|
```
|
||||||
|
neode-ui/
|
||||||
|
├── src/
|
||||||
|
│ ├── composables/
|
||||||
|
│ │ ├── useFileType.ts
|
||||||
|
│ │ └── __tests__/
|
||||||
|
│ │ ├── useFileType.test.ts
|
||||||
|
│ │ ├── useNavSounds.test.ts
|
||||||
|
│ │ └── ... (other composable tests)
|
||||||
|
│ ├── api/
|
||||||
|
│ │ ├── rpc-client.ts
|
||||||
|
│ │ └── __tests__/
|
||||||
|
│ │ └── rpc-client.test.ts
|
||||||
|
│ └── stores/
|
||||||
|
│ ├── controller.ts
|
||||||
|
│ └── (no tests found for stores in exploration)
|
||||||
|
├── e2e/
|
||||||
|
│ ├── app-launch.spec.ts
|
||||||
|
│ ├── intro-experience.spec.ts
|
||||||
|
│ └── visual-regression.spec.ts
|
||||||
|
```
|
||||||
|
|
||||||
|
## Test Structure
|
||||||
|
|
||||||
|
**Vitest Suite Organization:**
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'
|
||||||
|
import { ref } from 'vue'
|
||||||
|
import { getFileCategory, useFileType, formatSize, formatDate } from '../useFileType'
|
||||||
|
|
||||||
|
describe('getFileCategory', () => {
|
||||||
|
it('returns folder for directories', () => {
|
||||||
|
expect(getFileCategory('', true)).toBe('folder')
|
||||||
|
expect(getFileCategory('jpg', true)).toBe('folder')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('identifies image extensions', () => {
|
||||||
|
expect(getFileCategory('jpg', false)).toBe('image')
|
||||||
|
expect(getFileCategory('png', false)).toBe('image')
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('useFileType', () => {
|
||||||
|
it('returns correct category and computed values for an image', () => {
|
||||||
|
const ext = ref('jpg')
|
||||||
|
const isDir = ref(false)
|
||||||
|
const result = useFileType(ext, isDir)
|
||||||
|
|
||||||
|
expect(result.category.value).toBe('image')
|
||||||
|
expect(result.isImage.value).toBe(true)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('reacts to ref changes', () => {
|
||||||
|
const ext = ref('jpg')
|
||||||
|
const isDir = ref(false)
|
||||||
|
const result = useFileType(ext, isDir)
|
||||||
|
|
||||||
|
expect(result.category.value).toBe('image')
|
||||||
|
ext.value = 'mp3'
|
||||||
|
expect(result.category.value).toBe('audio')
|
||||||
|
})
|
||||||
|
})
|
||||||
|
```
|
||||||
|
|
||||||
|
**Patterns:**
|
||||||
|
- `describe()` blocks group related tests by function or component
|
||||||
|
- `it()` blocks test a single behavior (flat structure, no nesting of describe blocks observed)
|
||||||
|
- `beforeEach()` / `afterEach()` hooks for setup/teardown per test
|
||||||
|
- `beforeAll()` / `afterAll()` hooks for suite-level setup (e.g., login in bats tests)
|
||||||
|
- Assertions use `expect(actual).toBe(expected)` or `expect(actual).toEqual(object)`
|
||||||
|
|
||||||
|
**Playwright E2E Structure:**
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
import { expect, test, type Page } from '@playwright/test'
|
||||||
|
|
||||||
|
async function login(page: Page) {
|
||||||
|
await page.goto('/login', { waitUntil: 'domcontentloaded' })
|
||||||
|
await page.evaluate(() => {
|
||||||
|
localStorage.setItem('neode_intro_seen', '1')
|
||||||
|
})
|
||||||
|
// ... fill form, submit
|
||||||
|
await page.waitForURL('**/dashboard**', { timeout: 20_000 })
|
||||||
|
}
|
||||||
|
|
||||||
|
test('installed app launch opens reachable app URL', async ({ page, context, baseURL }) => {
|
||||||
|
test.skip(!EXPECTED_URL, 'Set ARCHY_EXPECTED_LAUNCH_URL for launch qualification')
|
||||||
|
|
||||||
|
await login(page)
|
||||||
|
await page.goto('/dashboard/apps', { waitUntil: 'domcontentloaded' })
|
||||||
|
|
||||||
|
const appCard = page.locator('[data-controller-container]', {
|
||||||
|
has: page.getByRole('heading', { name: APP_CARD_TITLE, exact: true }),
|
||||||
|
}).first()
|
||||||
|
|
||||||
|
await appCard.waitFor({ timeout: 30_000 })
|
||||||
|
await expect(appCard.locator('button')).toBeVisible()
|
||||||
|
})
|
||||||
|
```
|
||||||
|
|
||||||
|
**Bats Shell Test Structure:**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
#!/usr/bin/env bats
|
||||||
|
# tests/lifecycle/bats/bitcoin-knots.bats
|
||||||
|
|
||||||
|
load '../lib/rpc.bash'
|
||||||
|
|
||||||
|
setup_file() {
|
||||||
|
: "${ARCHY_PASSWORD:?Set ARCHY_PASSWORD env var to the UI password}"
|
||||||
|
export ARCHY_FORCE_LOGIN=1
|
||||||
|
rpc_login
|
||||||
|
unset ARCHY_FORCE_LOGIN
|
||||||
|
}
|
||||||
|
|
||||||
|
teardown_file() {
|
||||||
|
rpc_logout_local
|
||||||
|
}
|
||||||
|
|
||||||
|
@test "container-list includes bitcoin-knots" {
|
||||||
|
run rpc_result container-list
|
||||||
|
[ "$status" -eq 0 ]
|
||||||
|
echo "$output" | jq -e '.[] | select(.name == "bitcoin-knots")' >/dev/null
|
||||||
|
}
|
||||||
|
|
||||||
|
@test "container-status returns a valid status object" {
|
||||||
|
run rpc_call container-status '{"app_id":"bitcoin-knots"}'
|
||||||
|
[ "$status" -eq 0 ]
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
## Mocking
|
||||||
|
|
||||||
|
**Framework (Vitest):** `vi` from Vitest; global stub support
|
||||||
|
|
||||||
|
**Patterns:**
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
const mockFetch = vi.fn()
|
||||||
|
vi.stubGlobal('fetch', mockFetch)
|
||||||
|
|
||||||
|
// Import after stubbing
|
||||||
|
const { rpcClient } = await import('../rpc-client')
|
||||||
|
|
||||||
|
// In tests:
|
||||||
|
mockFetch.mockResolvedValueOnce(jsonResponse({ result: { did: 'did:key:z123' } }))
|
||||||
|
mockFetch.mockRejectedValueOnce(new Error('fetch failed'))
|
||||||
|
|
||||||
|
// Assertions on mock calls:
|
||||||
|
expect(mockFetch).toHaveBeenCalledOnce()
|
||||||
|
const [url, init] = mockFetch.mock.calls[0]!
|
||||||
|
expect(url).toBe('/rpc/v1')
|
||||||
|
expect(init.method).toBe('POST')
|
||||||
|
```
|
||||||
|
|
||||||
|
**Vue Test Utils:**
|
||||||
|
- Component mounting: `mount(Component, { global: { mocks: { $ver: displayVersion } } })`
|
||||||
|
- Props tested by passing to mount options
|
||||||
|
- Events tested by listening to emitted events
|
||||||
|
|
||||||
|
**What to Mock:**
|
||||||
|
- External HTTP requests (fetch, axios)
|
||||||
|
- Timers (for timeout logic; `vi.useFakeTimers()`)
|
||||||
|
- Global objects (localStorage, console, window.location)
|
||||||
|
|
||||||
|
**What NOT to Mock:**
|
||||||
|
- Vue reactivity (ref, computed) — these are core to component behavior
|
||||||
|
- RPC client methods in component tests — prefer integration-style testing
|
||||||
|
- Built-in assertions (expect) — always available
|
||||||
|
- Pinia stores in unit tests of composables that use them — store directly if needed
|
||||||
|
|
||||||
|
## Fixtures and Factories
|
||||||
|
|
||||||
|
**Test Data:**
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
function jsonResponse(body: unknown, status = 200, statusText = 'OK'): Response {
|
||||||
|
return {
|
||||||
|
ok: status >= 200 && status < 300,
|
||||||
|
status,
|
||||||
|
statusText,
|
||||||
|
json: () => Promise.resolve(body),
|
||||||
|
// ... other Response properties
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Usage:
|
||||||
|
mockFetch.mockResolvedValueOnce(jsonResponse({ result: { did: 'did:key:z123' } }))
|
||||||
|
mockFetch.mockResolvedValueOnce(jsonResponse(null, 502, 'Bad Gateway'))
|
||||||
|
```
|
||||||
|
|
||||||
|
**Location:**
|
||||||
|
- Fixtures (test data, helper functions) defined inline in test files or in small helper modules
|
||||||
|
- No central fixture factory observed; each test file is self-contained
|
||||||
|
- Shell test helpers in `tests/lifecycle/lib/rpc.bash` (RPC wrapper for bats)
|
||||||
|
|
||||||
|
## Coverage
|
||||||
|
|
||||||
|
**Requirements:**
|
||||||
|
- Frontend: 80% branch coverage (set in `vitest.config.ts` thresholds)
|
||||||
|
- Rust: no explicit threshold; pragmatic testing of public APIs
|
||||||
|
- Shell: coverage tracked per app in `tests/lifecycle/TESTING.md` (lifecycle matrix)
|
||||||
|
|
||||||
|
**View Coverage:**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Generate coverage report
|
||||||
|
npm run test -- --coverage
|
||||||
|
|
||||||
|
# Output formats: text, text-summary, html
|
||||||
|
# Config in vitest.config.ts: reporter: ['text', 'text-summary']
|
||||||
|
```
|
||||||
|
|
||||||
|
**Coverage Scope (Frontend):**
|
||||||
|
- Included: `src/api/*.ts`, `src/stores/*.ts`, `src/composables/*.ts`, `src/utils/*.ts`, `src/services/*.ts`, `src/router/*.ts`
|
||||||
|
- Excluded: test files (`src/**/__tests__/**`), type definitions (`*.d.ts`), entry point (`src/main.ts`)
|
||||||
|
|
||||||
|
## Test Types
|
||||||
|
|
||||||
|
**Unit Tests (Vitest):**
|
||||||
|
- Scope: individual functions, composables, utility modules
|
||||||
|
- Approach: fast, isolated, mock external dependencies
|
||||||
|
- Example: `useFileType.test.ts` tests `getFileCategory`, `useFileType`, `formatSize`, `formatDate` independently
|
||||||
|
- Latency: ~5s for full suite; individual tests <1s
|
||||||
|
|
||||||
|
**Integration Tests (Vitest + RPCClient):**
|
||||||
|
- Scope: RPC client with mocked fetch, authentication flows, retry logic
|
||||||
|
- Approach: more complex setup, test interactions between layers
|
||||||
|
- Example: `rpc-client.test.ts` tests 70+ scenarios (login, TOTP, federation, package operations)
|
||||||
|
- Latency: ~30s for full suite
|
||||||
|
|
||||||
|
**E2E Tests (Playwright):**
|
||||||
|
- Scope: real browser, real app instance, user journeys (login → navigate → interact)
|
||||||
|
- Approach: full app stack running; no mocks of UI layer
|
||||||
|
- Example: `app-launch.spec.ts` tests app card discovery and launch via button click
|
||||||
|
- Latency: 30–120s per test depending on app startup time
|
||||||
|
|
||||||
|
**Lifecycle Tests (Bats):**
|
||||||
|
- Scope: container operations (install, start, stop, restart, uninstall) on a live node
|
||||||
|
- Approach: RPC calls to backend, shell commands for verification, destructive operations tier-gated
|
||||||
|
- Tiers:
|
||||||
|
- L0 unit: Rust unit tests (cargo test)
|
||||||
|
- L1 RPC: JSON-RPC API responses (bats + rpc.bash)
|
||||||
|
- L2 UI: HTTP probe of app URLs (bats + ui-probes.bash)
|
||||||
|
- L3 lifecycle survival: container restart/reboot survival (bats, gated)
|
||||||
|
- Latency: 30–120s per suite depending on tier and container startup
|
||||||
|
|
||||||
|
## Common Patterns
|
||||||
|
|
||||||
|
**Async Testing (Vitest):**
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
it('makes a successful RPC call and returns the result', async () => {
|
||||||
|
mockFetch.mockResolvedValueOnce(jsonResponse({ result: { did: 'did:key:z123' } }))
|
||||||
|
|
||||||
|
const result = await rpcClient.call<{ did: string }>({
|
||||||
|
method: 'node.did',
|
||||||
|
params: {},
|
||||||
|
})
|
||||||
|
|
||||||
|
expect(result).toEqual({ did: 'did:key:z123' })
|
||||||
|
expect(mockFetch).toHaveBeenCalledOnce()
|
||||||
|
})
|
||||||
|
```
|
||||||
|
|
||||||
|
- `async` keyword on test function
|
||||||
|
- `await` for async operations
|
||||||
|
- No explicit promise handling; expect called after `await` completes
|
||||||
|
- Timeouts set via test config or `{ timeout: N }` in individual tests
|
||||||
|
|
||||||
|
**Error Testing (Vitest):**
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
it('throws after max retries on persistent 502', async () => {
|
||||||
|
mockFetch.mockResolvedValue(jsonResponse(null, 502, 'Bad Gateway'))
|
||||||
|
|
||||||
|
await expect(rpcClient.call({ method: 'test' })).rejects.toThrow('HTTP 502: Bad Gateway')
|
||||||
|
expect(mockFetch).toHaveBeenCalledTimes(3)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('throws immediately on non-retryable HTTP errors', async () => {
|
||||||
|
mockFetch.mockResolvedValueOnce(jsonResponse(null, 401, 'Unauthorized'))
|
||||||
|
|
||||||
|
await expect(rpcClient.call({ method: 'test' })).rejects.toThrow('Session expired')
|
||||||
|
expect(mockFetch).toHaveBeenCalledOnce()
|
||||||
|
})
|
||||||
|
```
|
||||||
|
|
||||||
|
- `expect(...).rejects.toThrow(message)` for expected rejections
|
||||||
|
- Mock returns set per-call (`mockResolvedValueOnce`, `mockResolvedValue`)
|
||||||
|
- Retry logic verified via call count assertions (`toHaveBeenCalledTimes`)
|
||||||
|
|
||||||
|
**Timer Mocking (Vitest):**
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
beforeEach(() => {
|
||||||
|
vi.useFakeTimers({ shouldAdvanceTime: true })
|
||||||
|
})
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
vi.useRealTimers()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('retries on 502 Bad Gateway and eventually succeeds', async () => {
|
||||||
|
mockFetch
|
||||||
|
.mockResolvedValueOnce(jsonResponse(null, 502, 'Bad Gateway'))
|
||||||
|
.mockResolvedValueOnce(jsonResponse({ result: 'ok' }))
|
||||||
|
|
||||||
|
const result = await rpcClient.call({ method: 'test' })
|
||||||
|
|
||||||
|
expect(result).toBe('ok')
|
||||||
|
expect(mockFetch).toHaveBeenCalledTimes(2)
|
||||||
|
})
|
||||||
|
```
|
||||||
|
|
||||||
|
- Fake timers enable testing of timeout/retry delays without blocking real time
|
||||||
|
- `shouldAdvanceTime: true` auto-advances clock for non-blocking tests
|
||||||
|
- Clean up with `vi.useRealTimers()` after each test
|
||||||
|
|
||||||
|
**Vue Component Testing (Vue Test Utils):**
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
it('returns correct values for audio', () => {
|
||||||
|
const ext = ref('mp3')
|
||||||
|
const isDir = ref(false)
|
||||||
|
const result = useFileType(ext, isDir)
|
||||||
|
|
||||||
|
expect(result.category.value).toBe('audio')
|
||||||
|
expect(result.isAudio.value).toBe(true)
|
||||||
|
expect(result.isImage.value).toBe(false)
|
||||||
|
expect(result.iconColor.value).toBe('text-orange-400')
|
||||||
|
})
|
||||||
|
```
|
||||||
|
|
||||||
|
- Refs created with `ref()` passed as test inputs
|
||||||
|
- Computed values accessed via `.value`
|
||||||
|
- No mount overhead for pure composable logic
|
||||||
|
|
||||||
|
**Playwright Browser Testing:**
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
test('installed app launch opens reachable app URL', async ({ page, context, baseURL }) => {
|
||||||
|
await login(page)
|
||||||
|
await page.goto('/dashboard/apps', { waitUntil: 'domcontentloaded' })
|
||||||
|
|
||||||
|
const appCard = page.locator('[data-controller-container]', {
|
||||||
|
has: page.getByRole('heading', { name: APP_CARD_TITLE, exact: true }),
|
||||||
|
}).first()
|
||||||
|
|
||||||
|
await appCard.waitFor({ timeout: 30_000 })
|
||||||
|
await expect(appCard.locator('button')).toBeVisible()
|
||||||
|
})
|
||||||
|
```
|
||||||
|
|
||||||
|
- Locators used to find elements (CSS selector, role, text)
|
||||||
|
- Wait timeouts on slow networks (30s for app startup)
|
||||||
|
- `waitUntil: 'domcontentloaded'` or `'networkidle'` for page load
|
||||||
|
- Screenshots and video recording available via config
|
||||||
|
|
||||||
|
## Test Configuration Details
|
||||||
|
|
||||||
|
**Vitest Config (`vitest.config.ts`):**
|
||||||
|
- Environment: jsdom
|
||||||
|
- Globals: enabled (no imports needed)
|
||||||
|
- Setup file: `vitest.setup.ts` (mocks global Vue config like `$ver`)
|
||||||
|
- Coverage provider: v8
|
||||||
|
- Coverage threshold: 80% branches
|
||||||
|
- Excluded from coverage: tests, types, main.ts
|
||||||
|
|
||||||
|
**Playwright Config (implicit, environment variables used):**
|
||||||
|
- Base URL: derived from `VITE_*` env vars in dev
|
||||||
|
- Timeouts: per-test overrides via `{ timeout: N }`
|
||||||
|
- Retry: 0 (no automatic retries; explicit in tests via polling)
|
||||||
|
- Config environment variables: `ARCHY_PASSWORD`, `ARCHY_APP_ID`, `ARCHY_EXPECTED_LAUNCH_URL`
|
||||||
|
|
||||||
|
**Shell Test Config (environment variables):**
|
||||||
|
- `ARCHY_PASSWORD`: login password (required)
|
||||||
|
- `ARCHY_ALLOW_DESTRUCTIVE`: enable stop/start/restart/uninstall tests
|
||||||
|
- `ARCHY_ALLOW_CASCADE_DESTRUCTIVE`: enable uninstall/reinstall on throwaway app
|
||||||
|
- `ARCHY_ITERATIONS`: loop count for release gate (5× for production readiness)
|
||||||
|
- `ARCHY_FORCE_LOGIN`: fresh RPC token per test file
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
*Testing analysis: 2026-07-29*
|
||||||
66
.planning/intel/SYNTHESIS.md
Normal file
66
.planning/intel/SYNTHESIS.md
Normal file
@ -0,0 +1,66 @@
|
|||||||
|
# Synthesis Summary
|
||||||
|
|
||||||
|
Ingest mode: new (fresh bootstrap; no existing PROJECT.md/REQUIREMENTS.md/ROADMAP.md)
|
||||||
|
Synthesized: 2026-07-29
|
||||||
|
Precedence applied: ADR > SPEC > PRD > DOC (no per-doc overrides)
|
||||||
|
|
||||||
|
## Doc counts by type
|
||||||
|
|
||||||
|
- ADR: 10 (all locked, Status: Accepted, confidence: high)
|
||||||
|
- SPEC: 1 (confidence: high)
|
||||||
|
- PRD: 0
|
||||||
|
- DOC: 0
|
||||||
|
- UNKNOWN: 0
|
||||||
|
- Total: 11
|
||||||
|
|
||||||
|
## Decisions locked (10)
|
||||||
|
|
||||||
|
All in `intel/decisions.md`:
|
||||||
|
- ADR-001 Podman over Docker — docs/adr/001-podman-over-docker.md
|
||||||
|
- ADR-002 did:key (Ed25519) node identity — docs/adr/002-did-key-method.md
|
||||||
|
- ADR-003 Nostr relays for node + app discovery — docs/adr/003-nostr-for-discovery.md
|
||||||
|
- ADR-004 Tor hidden services for inter-node RPC/control plane — docs/adr/004-tor-for-peer-communication.md
|
||||||
|
- ADR-005 ChaCha20-Poly1305 + Argon2id backup encryption — docs/adr/005-chacha20-backup-encryption.md
|
||||||
|
- ADR-006 Nostr relays for marketplace discovery (trust tiers) — docs/adr/006-nostr-marketplace-discovery.md
|
||||||
|
- ADR-007 Bilateral DID federation trust via single-use invite codes — docs/adr/007-did-federation-trust.md
|
||||||
|
- ADR-008 Dual keys (Ed25519 + secp256k1) from one master seed — docs/adr/008-dual-key-strategy.md
|
||||||
|
- ADR-009 Manifest-level container security enforcement — docs/adr/009-manifest-container-security.md
|
||||||
|
- ADR-011 DWN deprioritization (Nostr + Tor federation instead) — docs/adr/011-dwn-deprioritization.md
|
||||||
|
|
||||||
|
## Requirements extracted (0)
|
||||||
|
|
||||||
|
No PRDs in ingest set. `intel/requirements.md` records the absence.
|
||||||
|
|
||||||
|
## Constraints (7 entries)
|
||||||
|
|
||||||
|
From docs/app-manifest-spec.md, in `intel/constraints.md`:
|
||||||
|
- schema: 4 (top-level `app:` block, ContainerConfig, SecurityPolicy + validation, Volumes)
|
||||||
|
- api-contract: 1 (lifecycle hooks)
|
||||||
|
- protocol: 2 (Quadlet installation/reconciler semantics, distribution channels: signed catalog + Nostr marketplace)
|
||||||
|
- nfr: 0
|
||||||
|
|
||||||
|
## Context topics (0)
|
||||||
|
|
||||||
|
No DOC-type documents. `intel/context.md` records the absence.
|
||||||
|
|
||||||
|
## Conflicts
|
||||||
|
|
||||||
|
- Blockers: 0
|
||||||
|
- Competing variants: 0
|
||||||
|
- Auto-resolved: 0
|
||||||
|
- Informational notes: 4 (ADR-003/006 consistent overlap; SPEC validation narrower than ADR-009 mandates — silence, not contradiction; ADR-010 numbering gap; acyclic cross-ref graph)
|
||||||
|
|
||||||
|
Detail: `.planning/INGEST-CONFLICTS.md`
|
||||||
|
|
||||||
|
## Cycle detection
|
||||||
|
|
||||||
|
Cross-ref graph acyclic (max depth well under cap). All 11 docs synthesized; no docs excluded.
|
||||||
|
|
||||||
|
## Files
|
||||||
|
|
||||||
|
- Decisions: `.planning/intel/decisions.md`
|
||||||
|
- Requirements: `.planning/intel/requirements.md`
|
||||||
|
- Constraints: `.planning/intel/constraints.md`
|
||||||
|
- Context: `.planning/intel/context.md`
|
||||||
|
- Conflict report: `.planning/INGEST-CONFLICTS.md`
|
||||||
|
- Raw classifications: `.planning/intel/classifications/*.json`
|
||||||
@ -0,0 +1,13 @@
|
|||||||
|
{
|
||||||
|
"source_path": "/home/archipelago/Projects/archy/docs/adr/001-podman-over-docker.md",
|
||||||
|
"type": "ADR",
|
||||||
|
"confidence": "high",
|
||||||
|
"manifest_override": false,
|
||||||
|
"title": "ADR-001: Podman Over Docker",
|
||||||
|
"summary": "Chose Podman over Docker as the container runtime for rootless, daemonless operation with native systemd integration.",
|
||||||
|
"scope": ["Podman", "Docker", "container runtime", "rootless containers", "systemd integration", "archy-net network"],
|
||||||
|
"cross_refs": [],
|
||||||
|
"locked": true,
|
||||||
|
"precedence": null,
|
||||||
|
"notes": ""
|
||||||
|
}
|
||||||
@ -0,0 +1,13 @@
|
|||||||
|
{
|
||||||
|
"source_path": "/home/archipelago/Projects/archy/docs/adr/002-did-key-method.md",
|
||||||
|
"type": "ADR",
|
||||||
|
"confidence": "high",
|
||||||
|
"manifest_override": false,
|
||||||
|
"title": "ADR-002: DID Key Method for Node Identity",
|
||||||
|
"summary": "Chose did:key (Ed25519) as the primary DID method for node identity; self-contained and offline-capable, with federation trust lists mitigating rotation/revocation gaps.",
|
||||||
|
"scope": ["did:key", "node identity", "DID methods", "Ed25519 keys", "peer authentication", "federation trust lists", "verifiable credentials"],
|
||||||
|
"cross_refs": [],
|
||||||
|
"locked": true,
|
||||||
|
"precedence": null,
|
||||||
|
"notes": ""
|
||||||
|
}
|
||||||
@ -0,0 +1,22 @@
|
|||||||
|
{
|
||||||
|
"source_path": "/home/archipelago/Projects/archy/docs/adr/003-nostr-for-discovery.md",
|
||||||
|
"type": "ADR",
|
||||||
|
"confidence": "high",
|
||||||
|
"manifest_override": false,
|
||||||
|
"title": "ADR-003: Nostr Relays for Node and App Discovery",
|
||||||
|
"summary": "Chose Nostr relays (NIP-78, kind 30078) for decentralized node discovery and marketplace app manifest distribution.",
|
||||||
|
"scope": [
|
||||||
|
"Nostr relays",
|
||||||
|
"node discovery",
|
||||||
|
"app discovery",
|
||||||
|
"marketplace app manifests",
|
||||||
|
"NIP-78",
|
||||||
|
"NIP-33 replaceable events",
|
||||||
|
"trust scoring",
|
||||||
|
"relay caching"
|
||||||
|
],
|
||||||
|
"cross_refs": [],
|
||||||
|
"locked": true,
|
||||||
|
"precedence": null,
|
||||||
|
"notes": ""
|
||||||
|
}
|
||||||
@ -0,0 +1,13 @@
|
|||||||
|
{
|
||||||
|
"source_path": "/home/archipelago/Projects/archy/docs/adr/004-tor-for-peer-communication.md",
|
||||||
|
"type": "ADR",
|
||||||
|
"confidence": "high",
|
||||||
|
"manifest_override": false,
|
||||||
|
"title": "ADR-004: Tor Hidden Services for Peer Communication",
|
||||||
|
"summary": "Chose Tor hidden services (.onion) for all inter-node RPC/control-plane communication; bulk data pulled from registries instead.",
|
||||||
|
"scope": ["Tor hidden services", "inter-node communication", "federation sync", "archy-tor container", "RPC/control plane", "NAT traversal"],
|
||||||
|
"cross_refs": [],
|
||||||
|
"locked": true,
|
||||||
|
"precedence": null,
|
||||||
|
"notes": ""
|
||||||
|
}
|
||||||
@ -0,0 +1,13 @@
|
|||||||
|
{
|
||||||
|
"source_path": "/home/archipelago/Projects/archy/docs/adr/005-chacha20-backup-encryption.md",
|
||||||
|
"type": "ADR",
|
||||||
|
"confidence": "high",
|
||||||
|
"manifest_override": false,
|
||||||
|
"title": "ADR-005: ChaCha20-Poly1305 for Backup Encryption",
|
||||||
|
"summary": "Chose ChaCha20-Poly1305 AEAD with Argon2id key derivation for encrypting backups at rest, over AES-256-GCM and XChaCha20-Poly1305.",
|
||||||
|
"scope": ["backup encryption", "ChaCha20-Poly1305", "Argon2id key derivation", "AEAD", "nonce handling"],
|
||||||
|
"cross_refs": [],
|
||||||
|
"locked": true,
|
||||||
|
"precedence": null,
|
||||||
|
"notes": ""
|
||||||
|
}
|
||||||
@ -0,0 +1,13 @@
|
|||||||
|
{
|
||||||
|
"source_path": "/home/archipelago/Projects/archy/docs/adr/006-nostr-marketplace-discovery.md",
|
||||||
|
"type": "ADR",
|
||||||
|
"confidence": "high",
|
||||||
|
"manifest_override": false,
|
||||||
|
"title": "ADR-006: Nostr Relays for Marketplace Discovery",
|
||||||
|
"summary": "Chose Nostr relays (NIP-78, kind 30078 events) for decentralized app manifest discovery instead of a centralized marketplace server.",
|
||||||
|
"scope": ["Nostr relays", "app manifest discovery", "marketplace", "trust scoring", "trust tiers", "manifest signature verification"],
|
||||||
|
"cross_refs": [],
|
||||||
|
"locked": true,
|
||||||
|
"precedence": null,
|
||||||
|
"notes": ""
|
||||||
|
}
|
||||||
@ -0,0 +1,13 @@
|
|||||||
|
{
|
||||||
|
"source_path": "/home/archipelago/Projects/archy/docs/adr/007-did-federation-trust.md",
|
||||||
|
"type": "ADR",
|
||||||
|
"confidence": "high",
|
||||||
|
"manifest_override": false,
|
||||||
|
"title": "ADR-007: DID-Based Federation Trust",
|
||||||
|
"summary": "Chose bilateral DID-based verification with single-use invite codes over Tor for establishing federation trust between nodes, with Trusted/Observer/Untrusted levels.",
|
||||||
|
"scope": ["federation", "DID verification", "invite codes", "trust levels", "Tor hidden services", "Ed25519 keys"],
|
||||||
|
"cross_refs": ["ADR-003"],
|
||||||
|
"locked": true,
|
||||||
|
"precedence": null,
|
||||||
|
"notes": ""
|
||||||
|
}
|
||||||
@ -0,0 +1,13 @@
|
|||||||
|
{
|
||||||
|
"source_path": "/home/archipelago/Projects/archy/docs/adr/008-dual-key-strategy.md",
|
||||||
|
"type": "ADR",
|
||||||
|
"confidence": "high",
|
||||||
|
"manifest_override": false,
|
||||||
|
"title": "ADR-008: Dual Key Strategy (Ed25519 + Secp256k1)",
|
||||||
|
"summary": "Maintain two key pairs per node identity: Ed25519 for DID/Web5 operations, secp256k1 for Nostr/Bitcoin/Lightning, both derived from one master seed.",
|
||||||
|
"scope": ["node identity", "Ed25519", "secp256k1", "DID documents", "verifiable credentials", "federation authentication", "backup encryption", "Nostr event publishing", "node discovery", "Lightning Network", "key derivation", "master seed"],
|
||||||
|
"cross_refs": [],
|
||||||
|
"locked": true,
|
||||||
|
"precedence": null,
|
||||||
|
"notes": ""
|
||||||
|
}
|
||||||
@ -0,0 +1,13 @@
|
|||||||
|
{
|
||||||
|
"source_path": "/home/archipelago/Projects/archy/docs/adr/009-manifest-container-security.md",
|
||||||
|
"type": "ADR",
|
||||||
|
"confidence": "high",
|
||||||
|
"manifest_override": false,
|
||||||
|
"title": "ADR-009: Manifest-Level Container Security Enforcement",
|
||||||
|
"summary": "Enforce mandatory container security defaults (readonly root, no-new-privileges, non-root UID, dropped capabilities, pinned tags) at the manifest level during container creation.",
|
||||||
|
"scope": ["container security", "app manifests", "manifest validation", "podman container creation", "security defaults", "capability restrictions", "seccomp", "core/container module"],
|
||||||
|
"cross_refs": ["docs/app-manifest-spec.md", "core/container/src/", "core/security/src/"],
|
||||||
|
"locked": true,
|
||||||
|
"precedence": null,
|
||||||
|
"notes": ""
|
||||||
|
}
|
||||||
@ -0,0 +1,13 @@
|
|||||||
|
{
|
||||||
|
"source_path": "/home/archipelago/Projects/archy/docs/adr/011-dwn-deprioritization.md",
|
||||||
|
"type": "ADR",
|
||||||
|
"confidence": "high",
|
||||||
|
"manifest_override": false,
|
||||||
|
"title": "ADR-011: DWN Deprioritization",
|
||||||
|
"summary": "Deprioritizes Web5 DWN spec compliance after TBD's shutdown; keeps existing custom DWN store code and prioritizes Nostr plus Tor federation for peer sync.",
|
||||||
|
"scope": ["DWN (Decentralized Web Node)", "Web5", "dwn_store.rs", "Nostr", "federation", "peer discovery", "peer data sync"],
|
||||||
|
"cross_refs": [],
|
||||||
|
"locked": true,
|
||||||
|
"precedence": null,
|
||||||
|
"notes": ""
|
||||||
|
}
|
||||||
@ -0,0 +1,29 @@
|
|||||||
|
{
|
||||||
|
"source_path": "/home/archipelago/Projects/archy/docs/app-manifest-spec.md",
|
||||||
|
"type": "SPEC",
|
||||||
|
"confidence": "high",
|
||||||
|
"manifest_override": false,
|
||||||
|
"title": "App Manifest Specification",
|
||||||
|
"summary": "Defines the declarative manifest.yml schema for apps: top-level fields, container config, security policy, volumes, hooks, installation semantics, and signed-catalog/marketplace distribution.",
|
||||||
|
"scope": [
|
||||||
|
"app manifest.yml schema",
|
||||||
|
"ContainerConfig (image/build, networks, derived_env, secret_env, generated_secrets, generated_certs)",
|
||||||
|
"SecurityPolicy (capabilities allow-list, network_policy, readonly_root)",
|
||||||
|
"volumes and bind-mount confinement",
|
||||||
|
"lifecycle hooks (post_install, pre_start)",
|
||||||
|
"health checks and interfaces",
|
||||||
|
"Quadlet installation and reconciler semantics",
|
||||||
|
"signed app catalog distribution",
|
||||||
|
"decentralized marketplace distribution"
|
||||||
|
],
|
||||||
|
"cross_refs": [
|
||||||
|
"app-developer-guide.md",
|
||||||
|
"manifest-hooks-design.md",
|
||||||
|
"marketplace-protocol.md",
|
||||||
|
"core/container/src/manifest.rs",
|
||||||
|
"api/rpc/package/stacks.rs"
|
||||||
|
],
|
||||||
|
"locked": false,
|
||||||
|
"precedence": null,
|
||||||
|
"notes": ""
|
||||||
|
}
|
||||||
38
.planning/intel/constraints.md
Normal file
38
.planning/intel/constraints.md
Normal file
@ -0,0 +1,38 @@
|
|||||||
|
# Constraints (from SPECs)
|
||||||
|
|
||||||
|
Extracted from 1 classified SPEC: `docs/app-manifest-spec.md` (accurate as of 2026-07-08). The SPEC self-declares that the canonical schema is the Rust parser in `core/container/src/manifest.rs` — if doc and code disagree, the code wins.
|
||||||
|
|
||||||
|
## App manifest top-level schema (`app:` block)
|
||||||
|
- source: docs/app-manifest-spec.md
|
||||||
|
- type: schema
|
||||||
|
- content: Every app is a directory `apps/<id>/` with a `manifest.yml` containing a single top-level `app:` block. Apps are purely declarative — the orchestrator owns the entire lifecycle; no per-app installer code. Required fields: `id` (lowercase alphanumeric + `-`/`_`, must match directory name), `name`, `version`. Optional fields: `description`, `container` (ContainerConfig), `dependencies` (storage/app_id+version/bare string), `resources` (cpu_limit, memory_limit, disk_limit), `security` (SecurityPolicy), `ports` (host/container/protocol), `volumes`, `files` (GeneratedFile: path/content/overwrite; path must sit under a declared bind mount), `environment` (static KEY=value), `health_check` (type/endpoint/path/interval/timeout/retries; `http` is what the monitor exercises), `devices` (must start with `/dev/`), `interfaces` (launch surfaces keyed by name), `hooks` (LifecycleHooks). Unknown keys are absorbed into an `extensions` map (serde flatten) as transitional metadata — not typed schema, not validated.
|
||||||
|
|
||||||
|
## ContainerConfig schema
|
||||||
|
- source: docs/app-manifest-spec.md
|
||||||
|
- type: schema
|
||||||
|
- content: Exactly one of `image` or `build` must be present (image XOR build). Fields: `image` (registry reference), `image_signature` (optional), `pull_policy` (default `if-not-present`), `build` ({context, dockerfile default "Dockerfile", tag, build_args}), `network` (literal podman `--network` value; omitted = rootless default isolated network), `network_aliases` (extra DNS names on the network), `entrypoint`, `custom_args`, `derived_env` ({key, template} rendered against host facts at apply time; allowed placeholders only: {{HOST_IP}}, {{HOST_MDNS}}, {{DISK_GB}} plus dependency-resolved facts — never hard-code host specifics), `secret_env` ({key, secret_file} read from /var/lib/archipelago/secrets/<secret_file>, injected as a podman secret so it never appears in `podman inspect` or unit files; secret_file must be a bare filename, no `/` or `..`), `generated_secrets` ({name, kind} materialised by the orchestrator on first use, 0600, rootless service user, idempotent + self-healing; kind ∈ hex16|hex32|base64|bcrypt; bcrypt writes <name>=hash and <name>.pw=plaintext), `generated_certs` ({crt, key, common_name?, sans?} self-signed TLS materialised before create), `data_uid` ("UID:GID" applied to the app's bind-mounted data dir before create).
|
||||||
|
|
||||||
|
## SecurityPolicy schema and validation rules
|
||||||
|
- source: docs/app-manifest-spec.md
|
||||||
|
- type: schema
|
||||||
|
- content: Security block defaults: `readonly_root: true`, `no_new_privileges: true`, `capabilities: []` (cap-drop ALL, add back only listed), `network_policy: isolated` (isolated | bridge | host), `apparmor_profile: null` (optional). Validation enforced at `AppManifest::validate()`: capabilities must come from the reviewed allow-list (CHOWN, DAC_OVERRIDE, FOWNER, NET_ADMIN, NET_BIND_SERVICE, NET_RAW, SETGID, SETUID, SYS_ADMIN); `network_policy` must be exactly isolated/bridge/host; no `container:`/`ns:` network modes; devices must be `/dev/*`; bind-mount sources confined to `/var/lib/archipelago` (reviewed exceptions: rootless podman socket and dbus); `derived_env` templates limited to the placeholder allow-list; `secret_env`/`generated_secrets` names must be bare filenames; hook steps validated against the hook allow-list. (Note: the SPEC's documented validation list does not mention ADR-009's non-root UID, pinned-image-tag, or seccomp mandates — see INGEST-CONFLICTS.md INFO entry.)
|
||||||
|
|
||||||
|
## Volumes schema
|
||||||
|
- source: docs/app-manifest-spec.md
|
||||||
|
- type: schema
|
||||||
|
- content: Volume entries: `type` ∈ bind | volume | tmpfs; bind entries take `source` (confined to /var/lib/archipelago per validation), `target`, `options` from an allow-list (rw, ro, z, Z, shared, …); tmpfs entries take `target` and `tmpfs_options` (e.g. "rw,noexec,nosuid,size=256m").
|
||||||
|
|
||||||
|
## Lifecycle hooks contract
|
||||||
|
- source: docs/app-manifest-spec.md
|
||||||
|
- type: api-contract
|
||||||
|
- content: Hooks are declarative, allow-listed operations that run against the app's own container — never the host (design: manifest-hooks-design.md). `post_install` runs once after install with the container running; supported steps: `copy_from_host` (src relative to an allow-listed root — data dir / web-ui; no absolute paths, no '..') and `exec` (podman exec inside the container). `pre_start` is reserved in the schema; its executor is not yet wired.
|
||||||
|
|
||||||
|
## Installation semantics (Quadlet + reconciler)
|
||||||
|
- source: docs/app-manifest-spec.md
|
||||||
|
- type: protocol
|
||||||
|
- content: The orchestrator compiles the manifest into a rootless Podman Quadlet unit under `user.slice` — the container survives backend restarts and reboots. A level-triggered reconciler converges drift every 30 seconds. Multi-container apps are sets of per-member manifests installed together via the stack orchestrator (`api/rpc/package/stacks.rs`) on an app-local network.
|
||||||
|
|
||||||
|
## Manifest distribution channels
|
||||||
|
- source: docs/app-manifest-spec.md
|
||||||
|
- type: protocol
|
||||||
|
- content: Manifests ship two ways. (1) Signed catalog (primary): `releases/app-catalog.json` embeds the full manifest per app with an Ed25519 detached signature verified against the pinned release-root anchor; nodes overlay catalog manifests over disk files — catalog wins for image-only apps; `apps/<id>/manifest.yml` on disk remains the fallback and is still required for build-source apps. (2) Decentralized marketplace: Nostr NIP-78 discovery with DID-signed manifests (marketplace-protocol.md); the marketplace uses its own flatter manifest schema, not this one. Tooling: validate with `scripts/validate-app-manifest.sh`, regenerate catalog with `scripts/generate-app-catalog.py`, drift-checked in CI by `scripts/check-app-catalog-drift.py`.
|
||||||
5
.planning/intel/context.md
Normal file
5
.planning/intel/context.md
Normal file
@ -0,0 +1,5 @@
|
|||||||
|
# Context (from DOCs)
|
||||||
|
|
||||||
|
No DOC-type documents were present in the ingest set (10 ADRs + 1 SPEC). No context notes extracted.
|
||||||
|
|
||||||
|
This file intentionally records absence rather than repurposing ADR/SPEC content as context.
|
||||||
65
.planning/intel/decisions.md
Normal file
65
.planning/intel/decisions.md
Normal file
@ -0,0 +1,65 @@
|
|||||||
|
# Decisions (from ADRs)
|
||||||
|
|
||||||
|
Extracted from 10 classified ADRs. All are `locked: true` (Status: Accepted) and cannot be auto-overridden by any lower-precedence source.
|
||||||
|
|
||||||
|
## ADR-001: Podman Over Docker
|
||||||
|
- source: docs/adr/001-podman-over-docker.md
|
||||||
|
- status: locked (Accepted)
|
||||||
|
- decision: Use Podman as the container runtime instead of Docker. Rootless by default, daemonless, Docker-compatible, native systemd integration, OCI-compliant. Use `archy-net` custom network for inter-container DNS.
|
||||||
|
- scope: container runtime, rootless containers, systemd integration, archy-net network
|
||||||
|
|
||||||
|
## ADR-002: DID Key Method for Node Identity
|
||||||
|
- source: docs/adr/002-did-key-method.md
|
||||||
|
- status: locked (Accepted)
|
||||||
|
- decision: Use `did:key` (Ed25519) as the primary DID method for node identity. Self-contained, offline-capable, local resolution. Known gaps (no rotation, no service endpoints, no revocation) mitigated via federation trust lists and separately-stored service endpoints; future migration to did:peer/did:web possible if rotation is needed.
|
||||||
|
- scope: node identity, DID methods, Ed25519 keys, peer authentication, federation trust lists, verifiable credentials
|
||||||
|
|
||||||
|
## ADR-003: Nostr Relays for Node and App Discovery
|
||||||
|
- source: docs/adr/003-nostr-for-discovery.md
|
||||||
|
- status: locked (Accepted)
|
||||||
|
- decision: Use Nostr relays (NIP-78, kind 30078) for both node discovery and marketplace app manifests. Query multiple relays in parallel with dedupe; local cache with 15-minute TTL; trust scoring (DID verification, relay consensus, federation trust); hashtag filtering (`archipelago-marketplace`); NIP-33 replaceable events for updates; Tor-compatible via SOCKS proxy.
|
||||||
|
- scope: node discovery, app discovery, marketplace app manifests, NIP-78, NIP-33 replaceable events, trust scoring, relay caching
|
||||||
|
|
||||||
|
## ADR-004: Tor Hidden Services for Peer Communication
|
||||||
|
- source: docs/adr/004-tor-for-peer-communication.md
|
||||||
|
- status: locked (Accepted)
|
||||||
|
- decision: Use Tor hidden services (.onion addresses) for all inter-node communication. Scoped to RPC/control plane only — bulk data (container images) pulled from registries. Retry with backoff; `archy-tor` container runs automatically with host networking; federation sync interval (5 min) tolerates occasional failures.
|
||||||
|
- scope: inter-node communication, federation sync, archy-tor container, RPC/control plane, NAT traversal
|
||||||
|
|
||||||
|
## ADR-005: ChaCha20-Poly1305 for Backup Encryption
|
||||||
|
- source: docs/adr/005-chacha20-backup-encryption.md
|
||||||
|
- status: locked (Accepted)
|
||||||
|
- decision: Use ChaCha20-Poly1305 (AEAD) with Argon2id key derivation for backup encryption at rest, chosen over AES-256-GCM and XChaCha20-Poly1305. Random nonce per backup stored alongside ciphertext; Argon2id with 64MB memory cost and 3 iterations for password-to-key derivation.
|
||||||
|
- scope: backup encryption, AEAD, Argon2id key derivation, nonce handling
|
||||||
|
|
||||||
|
## ADR-006: Nostr Relays for Marketplace Discovery
|
||||||
|
- source: docs/adr/006-nostr-marketplace-discovery.md
|
||||||
|
- status: locked (Accepted)
|
||||||
|
- decision: Use Nostr relays (NIP-78, kind 30078 events) for decentralized app manifest discovery instead of a centralized marketplace server. Developers publish signed manifests to public relays; nodes query multiple relays; trust scoring via cross-relay verification count, DID-linked developer reputation, optional community endorsements. Trust tiers: Verified (known developer, 3+ relays, DID-verified), Community (2+ relays, valid manifest, unsigned/new developer), Unverified (single relay, new developer). Local relay-response caching; built-in curated list for essential apps; manifest signature verification before installation.
|
||||||
|
- scope: marketplace, app manifest discovery, trust scoring, trust tiers, manifest signature verification
|
||||||
|
|
||||||
|
## ADR-007: DID-Based Federation Trust
|
||||||
|
- source: docs/adr/007-did-federation-trust.md
|
||||||
|
- status: locked (Accepted)
|
||||||
|
- decision: Use bilateral DID-based verification with single-use invite codes for federation trust establishment. Invite code carries DID, .onion address, and shared secret; exchanged out-of-band; both nodes verify DIDs via signed challenges over Tor; ongoing communication is DID-authenticated over Tor hidden services. Trust levels: Trusted (full access), Observer (read-only), Untrusted (blocked). Discovery (ADR-003) finds nodes; federation trusts them.
|
||||||
|
- scope: federation, DID verification, invite codes, trust levels, Tor hidden services, Ed25519 keys
|
||||||
|
- cross-refs: ADR-003
|
||||||
|
|
||||||
|
## ADR-008: Dual Key Strategy (Ed25519 + Secp256k1)
|
||||||
|
- source: docs/adr/008-dual-key-strategy.md
|
||||||
|
- status: locked (Accepted)
|
||||||
|
- decision: Maintain two key pairs per node identity, both derived from one master seed: Ed25519 as canonical identity (DID documents, verifiable credentials, federation auth, backup encryption via X25519 DH) and secp256k1 for Nostr/Bitcoin/Lightning (event publishing, node discovery, Lightning channel auth). Secp256k1 key linked to the DID via Nostr profile (NIP-05). Backup captures the master seed; DID document includes both verification methods.
|
||||||
|
- scope: node identity, key derivation, master seed, Ed25519, secp256k1, DID documents, federation authentication, backup encryption, Nostr event publishing, node discovery, Lightning Network
|
||||||
|
|
||||||
|
## ADR-009: Manifest-Level Container Security Enforcement
|
||||||
|
- source: docs/adr/009-manifest-container-security.md
|
||||||
|
- status: locked (Accepted)
|
||||||
|
- decision: Enforce mandatory container security defaults at the manifest level, applied automatically during container creation. Non-negotiable defaults: `readonly_root: true`, `no_new_privileges: true`, non-root user (UID > 1000), drop ALL capabilities (add back only required), pinned image tags (no `latest`), default seccomp profile. `core/container/` validates manifests (parse → validate → reject violations → apply security context at `podman create`). Optional overrides (`readonly_root: false`, extra capabilities like NET_ADMIN) require explicit listing and documented justification, with audit trail.
|
||||||
|
- scope: container security, app manifests, manifest validation, podman container creation, security defaults, capability restrictions, seccomp
|
||||||
|
- cross-refs: docs/app-manifest-spec.md, core/container/src/, core/security/src/
|
||||||
|
|
||||||
|
## ADR-011: DWN Deprioritization
|
||||||
|
- source: docs/adr/011-dwn-deprioritization.md
|
||||||
|
- status: locked (Accepted)
|
||||||
|
- decision: Deprioritize Web5 DWN spec compliance following TBD's November 2024 shutdown. Keep existing custom DWN store code (`core/archipelago/src/network/dwn_store.rs`) for peer file catalogs and federation state; stop calling it "Web5 DWN" in user-facing text; do not invest in DWN spec compliance; prioritize Nostr + Tor federation for peer discovery and data exchange; re-evaluate only if DIF produces a viable Rust SDK or the spec regains maintainers.
|
||||||
|
- scope: DWN, Web5, dwn_store.rs, Nostr, federation, peer discovery, peer data sync
|
||||||
5
.planning/intel/requirements.md
Normal file
5
.planning/intel/requirements.md
Normal file
@ -0,0 +1,5 @@
|
|||||||
|
# Requirements (from PRDs)
|
||||||
|
|
||||||
|
No PRD documents were present in the ingest set (10 ADRs + 1 SPEC). No requirements extracted.
|
||||||
|
|
||||||
|
Downstream note: requirements for the roadmap must be derived elsewhere (e.g. from user input or a future PRD ingest); this file intentionally records absence rather than inferring requirements from ADR/SPEC content.
|
||||||
19
.planning/onboarding/SUMMARY.md
Normal file
19
.planning/onboarding/SUMMARY.md
Normal file
@ -0,0 +1,19 @@
|
|||||||
|
# Onboarding Summary
|
||||||
|
|
||||||
|
## Project State
|
||||||
|
- PROJECT.md: present
|
||||||
|
- REQUIREMENTS.md: present
|
||||||
|
- ROADMAP.md: present
|
||||||
|
- STATE.md: present
|
||||||
|
|
||||||
|
## Codebase Context
|
||||||
|
- Brownfield repo: yes
|
||||||
|
- Map readiness: complete
|
||||||
|
- Codebase map: .planning/codebase/ (complete codebase map)
|
||||||
|
- Fast map available: yes
|
||||||
|
|
||||||
|
## Docs Context
|
||||||
|
- Existing ADR/PRD/SPEC/RFC candidates: 11
|
||||||
|
|
||||||
|
## Recommended Next Step
|
||||||
|
- /gsd-manager
|
||||||
53
.planning/phases/01-federation-mesh-hardening/01-CONTEXT.md
Normal file
53
.planning/phases/01-federation-mesh-hardening/01-CONTEXT.md
Normal file
@ -0,0 +1,53 @@
|
|||||||
|
# Phase 1 Context — Federation & Mesh Hardening
|
||||||
|
|
||||||
|
**Source:** User decisions captured in conversation 2026-07-29 (no full discuss-phase run)
|
||||||
|
|
||||||
|
<domain>
|
||||||
|
Federation/fleet + mesh hardening on a live OTA fleet, plus two lightning-adjacent UI features
|
||||||
|
(channel-open UX, on-brand paid-tick animation). Backend: Rust workspace at core/. Frontend:
|
||||||
|
neode-ui (Vue), dev preview :8100 against archi-dev.
|
||||||
|
</domain>
|
||||||
|
|
||||||
|
<decisions>
|
||||||
|
- **FED-05 "public nodes" scope (LOCKED, user 2026-07-29, corrected same day):** the
|
||||||
|
public/other list in the channel-open picker = MESHED PEER NODES THAT HAVE LIGHTNING
|
||||||
|
INSTALLED — nodes known over the mesh (mesh peers/contacts beyond bilateral federation
|
||||||
|
trust) that advertise lightning capability. NOT lnd listpeers, NOT a curated list, NOT
|
||||||
|
a live LN-graph query. Implies peers need to advertise a "lightning installed/available"
|
||||||
|
capability (plus their URI/pubkey) over mesh/federation state so the picker can list
|
||||||
|
them. Manual URI paste can remain as a fallback entry path. Primary lists: (1) trusted
|
||||||
|
federated nodes by hostname, (2) meshed peers with lightning installed — "request to
|
||||||
|
open a channel with" these.
|
||||||
|
- **FED-05 URI sharing default (Claude's discretion, revisable):** a federated peer's
|
||||||
|
Lightning URI/pubkey rides the federation sync payload by default — federation trust is
|
||||||
|
already bilateral and explicit. Follow the existing shared-field pattern in
|
||||||
|
NodeStateSnapshot; if an opt-in toggle already exists for similar fields (e.g.
|
||||||
|
shared_location), mirror that pattern with default ON for lightning URI.
|
||||||
|
- **FED-06 (LOCKED, user):** paid-tick circle = ScreensaverRing.vue style (EQ segments),
|
||||||
|
applied consistently to every paid/success tick surface (SendBitcoinModal success pane,
|
||||||
|
WalletScanModal success-ring).
|
||||||
|
- **FED-04:** demo attachment parity core already shipped on main (c2ce71c6) — remaining
|
||||||
|
scope is the leftover mock gaps found in research (contacts-list/save, reaction/reply/
|
||||||
|
edit/delete/forward stubs that never mutate demo state).
|
||||||
|
- **Priority framing (user):** federation removal/sync correctness is the reason this phase
|
||||||
|
exists — "we should just be working on making that as tight as possible".
|
||||||
|
</decisions>
|
||||||
|
|
||||||
|
<specifics>
|
||||||
|
- UI work verified on the :8100 dev preview against archi-dev BEFORE any deploy (user
|
||||||
|
requirement, applies to FED-05/FED-06).
|
||||||
|
- Deploy discipline per CLAUDE.md: dev pair before OTA; commit+push every unit of work.
|
||||||
|
- Modals must Teleport to body (repeated user complaint — see project feedback memory).
|
||||||
|
</specifics>
|
||||||
|
|
||||||
|
<deferred>
|
||||||
|
- Live Lightning-graph search of arbitrary public nodes (not connected peers) — out of
|
||||||
|
scope for FED-05 v1.
|
||||||
|
- Curated/shipped public-node directory — not wanted.
|
||||||
|
</deferred>
|
||||||
|
|
||||||
|
<scope_fence>
|
||||||
|
Do not touch federation trust/join cryptography beyond what removal/sync correctness
|
||||||
|
requires (STATE.md blocker: tombstone fix touches trust code — re-verify with
|
||||||
|
tests/multinode/smoke.sh, don't patch blind). No data-destroying migrations.
|
||||||
|
</scope_fence>
|
||||||
371
.planning/phases/01-federation-mesh-hardening/01-RESEARCH.md
Normal file
371
.planning/phases/01-federation-mesh-hardening/01-RESEARCH.md
Normal file
@ -0,0 +1,371 @@
|
|||||||
|
# Phase 1: Federation & Mesh Hardening - Research
|
||||||
|
|
||||||
|
**Researched:** 2026-07-29
|
||||||
|
**Domain:** Federation node sync/removal (Rust/Tokio async daemon), mesh RPC parity (Rust + Node.js mock backend), Lightning channel-open UX (Vue 3), on-brand success animation (Vue 3/CSS)
|
||||||
|
**Confidence:** HIGH (backend federation/mesh code — read directly, git-blamed); MEDIUM (FED-05 Lightning-URI UX — net-new surface, no prior art in repo); HIGH (FED-06 — both source components read directly)
|
||||||
|
|
||||||
|
<user_constraints>
|
||||||
|
## User Constraints (from CONTEXT.md)
|
||||||
|
|
||||||
|
No CONTEXT.md exists for this phase (not yet run through `/gsd-discuss-phase`). No locked decisions or discretion areas to honor beyond `REQUIREMENTS.md` and the phase description supplied by the orchestrator. Treat all implementation choices below as recommendations for the planner, not locked decisions — the planner should flag any of these that warrant a user check-in (see `## Assumptions Log`).
|
||||||
|
</user_constraints>
|
||||||
|
|
||||||
|
## Summary
|
||||||
|
|
||||||
|
The federation and mesh code is more mature than `CONCERNS.md` suggests — several concerns it lists (tombstone-write-swallowed, DID-join without signature verification) were already fixed in commit `01cbec27` (2026-07-02) and the `handle_federation_peer_joined` signature-verification path respectively. **Do not treat `CONCERNS.md` as current truth for this phase; the structured review (FED-03) must re-verify each claim against the code read in this research before acting on it.**
|
||||||
|
|
||||||
|
The real, currently-live bug class behind the user's "nodes reappear / sync issues" reports is almost certainly a **concurrency race on `federation/nodes.json`**: `federation/storage.rs` has zero locking (no `Mutex`, no atomic temp-file+rename) around `load_nodes()` → mutate → `save_nodes()`, yet the daemon runs **two independent, overlapping periodic federation-sync loops** (`server.rs` ~line 497, every 90s; `server.rs` ~line 840, every 1800s) plus the manual `federation.sync-state` RPC and `federation.remove-node` RPC — all of which do their own read-modify-write cycle against the same file with no coordination. A `remove_node()` call racing against an in-flight `sync_with_peer()`'s `update_node_state()` (which read the node list *before* the removal landed) will have the sync's stale read clobber the just-written removal when it saves — the removed node reappears with no error, exactly matching the reported symptom, and it is invisible to logs because both loops only `debug!()` on failure. This is the primary hypothesis to design a fix and a regression test around for FED-01/FED-02.
|
||||||
|
|
||||||
|
Mesh attachment-send parity (FED-04) was fixed just before this phase started (commit `c2ce71c6`, uncommitted → committed by another concurrent agent during this research session): `mock-backend.js` now implements `mesh.send-content-inline` / `mesh.send-content` / `mesh.fetch-content` / `mesh.transport-advice` mirroring the daemon's real tier logic. What remains for full "rest of the mesh chat surface" parity: `mesh.contacts-list` / `mesh.contacts-save` (peer aliasing, called live from `Mesh.vue` on mount and on rename) are **not implemented in mock-backend.js at all** and will 404 with "Method not found" on the demo; and `mesh.send-reaction` / `send-reply` / `edit-message` / `delete-message` / `forward-message` are stubbed as bare `{ok:true}` acks that never mutate `meshStore.dynamic`, so reactions/edits/deletes silently don't render on the demo even though the RPC call "succeeds."
|
||||||
|
|
||||||
|
FED-05 (Lightning channel-open UX) is greenfield: no RPC anywhere in the codebase currently returns this node's own Lightning `identity_pubkey`/`uris` (LND's own `/v1/getinfo` provides both, but `handle_lnd_getinfo` in `core/archipelago/src/api/rpc/lnd/info.rs` doesn't parse or forward them), and `NodeStateSnapshot` (the federation sync payload) carries no Lightning fields for a peer's pubkey/host, so there is no way today to look up a *federated* peer's channel-open target. `handle_lnd_openchannel` (channels.rs) already accepts `pubkey` + optional `address` + `amount`/fee params and does the connect-then-open sequence correctly — reuse it as-is. "Public nodes" browse/request has no existing data source in this codebase (no LN graph query, no curated list) and needs a scope decision from the user before planning task breakdown.
|
||||||
|
|
||||||
|
FED-06 is a straightforward swap: `ScreensaverRing.vue` (`compact` size = 240px/320px) renders only the radiating EQ segments (no circle of its own — the "circle" is the separately-layered content in the center, exactly as `Screensaver.vue` does with `ScreensaverLogo`). `SendBitcoinModal.vue`'s `.send-success-burst` is 112px (7rem) with 3 CSS-ripple `.burst-ring` elements plus a `.burst-core` circle+checkmark — swap the `.burst-ring` elements for `<ScreensaverRing size="compact" />`, keep `.burst-core`+checkmark centered on top, and reconcile the size mismatch (ring is 2-3x larger than the current burst container; either scale it down via CSS `transform: scale()` or accept the larger footprint since the modal is `max-w-2xl`). `WalletScanModal.vue` has a second, simpler "paid tick" (`.success-ring`, no ripple animation at all) that the phase's "wherever else the paid tick appears" clause covers — plan to update both.
|
||||||
|
|
||||||
|
**Primary recommendation:** Start FED-03's structured review by (1) auditing every `federation::storage` read-modify-write call site for the missing-lock race described above and design a fix (a `tokio::sync::Mutex` per data_dir, or collapsing the two periodic sync loops into one), (2) re-verifying every `CONCERNS.md` federation/mesh claim against current code before acting on it, (3) filling the two demo-parity gaps in `mock-backend.js` (contacts-list/save + stateful reaction/edit/delete), (4) treating FED-05 as new RPC surface (own-node Lightning URI, peer Lightning info propagation, and a scoped "public nodes" answer) before any UI work, and (5) the FED-06 CSS/component swap.
|
||||||
|
|
||||||
|
## Architectural Responsibility Map
|
||||||
|
|
||||||
|
| Capability | Primary Tier | Secondary Tier | Rationale |
|
||||||
|
|------------|-------------|----------------|-----------|
|
||||||
|
| Federation node list, tombstones, sync loops | API / Backend (`core/archipelago/src/federation/`) | — | Disk-persisted state; must be race-free at the storage layer, not patched in the RPC or UI layer |
|
||||||
|
| Federation removal propagation to mesh chat | API / Backend (`core/archipelago/src/mesh/mod.rs::purge_federation_peer`) | Frontend (Pinia `stores/mesh.ts`) | Backend already purges peer/messages/contacts server-side; frontend must not cache a stale contact after the WebSocket state bump |
|
||||||
|
| Mesh RPC surface (attachments, reactions, contacts) | API / Backend (`core/archipelago/src/api/rpc/mesh/`) | Dev tooling (`neode-ui/mock-backend.js`) | The demo backend is a parity shim over the same RPC surface — it must mirror backend behavior, never invent its own contract |
|
||||||
|
| FIPS/Tor transport dial + fallback | API / Backend (`core/archipelago/src/fips/dial.rs`, `transport/`) | — | Transport selection is a backend concern; UI only displays the resulting badge (`last_transport`) |
|
||||||
|
| Lightning node URI (own + peer) | API / Backend (new: `lnd.getinfo` extension, federation sync payload extension) | Frontend (new modal) | LND is the source of truth for `identity_pubkey`/`uris`; federation sync is the transport for sharing a peer's LN info |
|
||||||
|
| Channel-open UX (initiate) | Frontend (new modal, `Teleport`-to-body, house style) | API / Backend (`lnd.openchannel` — already exists) | Backend channel-open RPC is complete; only the UI (URI share, trusted-peer picker, public-node browse) is missing |
|
||||||
|
| Paid-tick success animation | Frontend (`SendBitcoinModal.vue`, `WalletScanModal.vue`, `ScreensaverRing.vue`) | — | Pure presentation; no backend involvement |
|
||||||
|
|
||||||
|
## Standard Stack
|
||||||
|
|
||||||
|
This phase does not introduce new external dependencies. It is a hardening + UI-surface pass over an existing Rust (Tokio/Hyper/reqwest/serde) backend and Vue 3 (Pinia, Vue Router, Teleport) frontend, plus a Node.js/Express demo backend (`neode-ui/mock-backend.js`). No new libraries are needed for any of FED-01 through FED-06 — `ScreensaverRing.vue` and `handle_lnd_openchannel` already exist and should be reused, not reimplemented.
|
||||||
|
|
||||||
|
### Core (existing, reused)
|
||||||
|
| Library | Version | Purpose | Why Standard |
|
||||||
|
|---------|---------|---------|--------------|
|
||||||
|
| tokio | (workspace pin) | Async runtime, `interval()`/`spawn()` for the periodic sync loops | Already the project's async foundation |
|
||||||
|
| reqwest | (workspace pin) | HTTP client for FIPS/Tor peer dial and LND REST calls | Already used throughout `fips/dial.rs` and `api/rpc/lnd/` |
|
||||||
|
| serde/serde_json | (workspace pin) | Wire format for `NodeStateSnapshot`, RPC params | Project-wide convention |
|
||||||
|
| Vue 3 + Pinia | (package.json pin) | Frontend reactivity/state | Existing frontend stack |
|
||||||
|
|
||||||
|
**Version verification:** No new packages are being added; skip registry verification per protocol (nothing to verify). If the planner introduces any new crate/npm package during execution, verify it then.
|
||||||
|
|
||||||
|
## Package Legitimacy Audit
|
||||||
|
|
||||||
|
No external packages are being introduced by this phase — this section is not applicable. If a later plan step decides to add a dependency (e.g., a curated public-LSP list requires a small crate), run the Package Legitimacy Gate at that time.
|
||||||
|
|
||||||
|
## Architecture Patterns
|
||||||
|
|
||||||
|
### System Architecture Diagram
|
||||||
|
|
||||||
|
```text
|
||||||
|
┌─────────────────────────────────────────┐
|
||||||
|
│ Federation node list (disk) │
|
||||||
|
│ federation/{nodes,removed-nodes}.json │
|
||||||
|
│ NO LOCK — read-modify-write per call │
|
||||||
|
└───────────────┬─────────────────────────┘
|
||||||
|
│ load_nodes() / save_nodes()
|
||||||
|
┌───────────────────────────┼───────────────────────────┬─────────────────────────┐
|
||||||
|
│ │ │ │
|
||||||
|
▼ ▼ ▼ ▼
|
||||||
|
┌───────────────┐ ┌──────────────────┐ ┌──────────────────┐ ┌─────────────────────┐
|
||||||
|
│ 90s auto-sync │ │ 1800s auto-sync │ │ RPC: sync-state, │ │ RPC: remove-node, │
|
||||||
|
│ loop (server.rs│ │ loop (server.rs │ │ (manual "Sync") │ │ set-trust, join, │
|
||||||
|
│ ~L497) │ │ ~L840) │ │ │ │ peer-joined │
|
||||||
|
└───────┬───────┘ └────────┬──────────┘ └────────┬──────────┘ └───────────┬─────────┘
|
||||||
|
│ sync_with_peer() │ sync_with_peer() │ │
|
||||||
|
│ → update_node_state() │ → update_node_state() │ │
|
||||||
|
└──────────────┬─────────────┴──────────────┬─────────────────┘ │
|
||||||
|
▼ ▼ ▼
|
||||||
|
(race: stale in-memory list from an in-flight sync's earlier load_nodes()
|
||||||
|
overwrites a concurrent remove_node()'s just-saved tombstoned list)
|
||||||
|
│
|
||||||
|
▼
|
||||||
|
┌─────────────────────────────────────┐
|
||||||
|
│ mesh/mod.rs: purge_federation_peer │
|
||||||
|
│ (peers, messages, contacts, presence)│
|
||||||
|
└───────────────┬───────────────────────┘
|
||||||
|
▼
|
||||||
|
┌─────────────────────────────────────┐
|
||||||
|
│ StateManager broadcast → WebSocket │
|
||||||
|
│ → Pinia stores → Federation.vue / │
|
||||||
|
│ Mesh.vue re-render │
|
||||||
|
└─────────────────────────────────────┘
|
||||||
|
|
||||||
|
Mesh attachment/parity path (FED-04):
|
||||||
|
Frontend attach flow → mesh.transport-advice → {auto-mesh|choose|tor-only}
|
||||||
|
→ mesh.send-content-inline (small) | mesh.send-content (large, via /api/blob)
|
||||||
|
real daemon: api/rpc/mesh/typed_messages.rs demo: mock-backend.js (mirrors tier logic — DONE)
|
||||||
|
Frontend contacts/reactions/edit/delete
|
||||||
|
real daemon: api/rpc/mesh/typed_messages.rs (contacts-list/save, send-reaction, edit-message, ...)
|
||||||
|
demo: mock-backend.js — contacts-list/save MISSING (404); reaction/edit/delete are no-op acks (GAP)
|
||||||
|
```
|
||||||
|
|
||||||
|
### Recommended Project Structure
|
||||||
|
|
||||||
|
No new directories needed. Touch points:
|
||||||
|
```
|
||||||
|
core/archipelago/src/federation/storage.rs # add locking around load/save
|
||||||
|
core/archipelago/src/server.rs # collapse or coordinate the two sync loops
|
||||||
|
core/archipelago/src/api/rpc/lnd/info.rs # extend handle_lnd_getinfo with identity_pubkey/uris
|
||||||
|
core/archipelago/src/federation/types.rs # (maybe) add lightning fields to NodeStateSnapshot
|
||||||
|
core/archipelago/src/api/rpc/federation/handlers.rs # (maybe) new RPC to fetch a peer's LN info
|
||||||
|
neode-ui/mock-backend.js # add mesh.contacts-list/save, stateful reaction/edit/delete
|
||||||
|
neode-ui/src/components/LightningChannelModal.vue # NEW — FED-05 (name TBD by planner)
|
||||||
|
neode-ui/src/components/SendBitcoinModal.vue # FED-06 swap
|
||||||
|
neode-ui/src/components/WalletScanModal.vue # FED-06 swap (secondary paid-tick site)
|
||||||
|
```
|
||||||
|
|
||||||
|
### Pattern 1: Federation removal is already "belt and suspenders" — reuse, don't rewrite
|
||||||
|
**What:** `handle_federation_remove_node` (handlers.rs:273) captures the peer's pubkey *before* calling `federation::remove_node`, then after removal calls `mesh::purge_federation_peer` to drop the synthetic mesh contact, its messages, presence, and persisted mesh-contacts entry. `federation::remove_node` (storage.rs:180) already tombstones the DID **before** saving the filtered node list and propagates a tombstone-write failure as an error (fixed in `01cbec27`).
|
||||||
|
**When to use:** This is the correct pattern for FED-01 already. Don't redesign it — the actual gap is the concurrency race in the storage layer underneath it (see Pitfall 1), not the removal logic itself.
|
||||||
|
**Example:**
|
||||||
|
```rust
|
||||||
|
// Source: core/archipelago/src/federation/storage.rs:180-198 (already fixed, 01cbec27)
|
||||||
|
pub async fn remove_node(data_dir: &Path, did: &str) -> Result<Vec<FederatedNode>> {
|
||||||
|
let mut nodes = load_nodes(data_dir).await?;
|
||||||
|
let before = nodes.len();
|
||||||
|
nodes.retain(|n| n.did != did);
|
||||||
|
if nodes.len() == before {
|
||||||
|
anyhow::bail!("No federated node with DID {}", did);
|
||||||
|
}
|
||||||
|
// Tombstone FIRST and propagate failure — a remove whose tombstone
|
||||||
|
// never landed isn't a remove.
|
||||||
|
tombstone_did(data_dir, did).await.context("persist removal tombstone")?;
|
||||||
|
save_nodes(data_dir, &nodes).await?;
|
||||||
|
Ok(nodes)
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### Pattern 2: Transitive sync already respects tombstones — verify, don't re-add protection
|
||||||
|
**What:** `merge_transitive_peers` (sync.rs:120) loads `load_removed_dids()` and skips any hint whose DID is tombstoned, and `handle_federation_peer_joined` (handlers.rs:641) independently rejects a `peer-joined` callback for a tombstoned DID. Both paths that could resurrect a removed node already check the tombstone list.
|
||||||
|
**When to use:** The FED-03 review should write a test that concurrently exercises remove + an in-flight sync (see Pitfall 1) rather than re-deriving the (already-correct) tombstone-check logic.
|
||||||
|
|
||||||
|
### Pattern 3: Demo backend must be a byte-for-byte RPC mirror, not a "close enough" mock
|
||||||
|
**What:** `mock-backend.js`'s `mesh.transport-advice` case (line 4318) explicitly duplicates the daemon's size thresholds (`MESH_AUTO_MAX = 1024`, `MESH_HARD_MAX = 2300`) with a comment pointing at `typed_messages.rs handle_mesh_transport_advice` as the source of truth.
|
||||||
|
**When to use:** Apply the same pattern to `mesh.contacts-list`/`mesh.contacts-save` and to the reaction/edit/delete stubs — read the real handler in `core/archipelago/src/api/rpc/mesh/typed_messages.rs` (lines 1180-1371 for contacts/presence, 637-976 for reply/reaction/receipt/forward, 1065-1180 for edit/delete) and mirror its actual state transitions in `meshStore.dynamic`, not just an `{ok:true}` ack.
|
||||||
|
|
||||||
|
### Anti-Patterns to Avoid
|
||||||
|
- **Unlocked read-modify-write on shared JSON files:** `federation/storage.rs` has none of `load_nodes()`/`save_nodes()` behind a mutex, and writes are a direct `fs::write()` (not atomic temp+rename). Any new federation code must NOT add a third code path that does its own read-modify-write without going through a shared lock — that widens the race window instead of closing it.
|
||||||
|
- **Silent `debug!()` on periodic-loop errors:** Both sync loops in `server.rs` log sync failures at `debug!` level only (not surfaced to the state broadcast, not visible in the UI). FED-02 explicitly requires operator-visible sync errors — don't add a third silent loop; extend the existing ones to persist a `last_sync_error` alongside `last_seen`.
|
||||||
|
- **Reinventing `handle_lnd_openchannel`'s connect-then-open sequence:** It already does `perm=false` synchronous peer connect before opening (with a documented reason: `perm=true` races and fails with "peer is not online"). Reuse it; do not write a second Lightning-channel RPC.
|
||||||
|
- **Assuming `ScreensaverRing` is pre-sized for a 112px success badge:** its `compact` class is 240px (mobile) / 320px (≥768px) — 2-3x the current `.send-success-burst`. Naive drop-in will overflow the modal card; must be explicitly scaled or the surrounding layout redesigned.
|
||||||
|
|
||||||
|
## Don't Hand-Roll
|
||||||
|
|
||||||
|
| Problem | Don't Build | Use Instead | Why |
|
||||||
|
|---------|-------------|-------------|-----|
|
||||||
|
| Connecting to + opening a channel with an LN peer | A new RPC | `lnd.openchannel` (`api/rpc/lnd/channels.rs:238`) | Already validates pubkey format, amount bounds, fee params, and does the connect-before-open sequence correctly with the documented `perm=false` fix |
|
||||||
|
| Paid/success radial visual | A new ring/particle component | `ScreensaverRing.vue` (`compact` size) | Explicitly required by FED-06; also already ships a reduced-motion-friendly animation pattern to copy for consistency |
|
||||||
|
| Peer transport badge (FIPS/Tor) | New transport-tracking logic | `FederatedNode.last_transport`/`last_transport_at` (already written by `record_peer_transport`) | Already ground-truth (records what was actually used, not predicted) — FED-05's peer picker can reuse this field to show reachability |
|
||||||
|
| Federated-peer list for the "trusted nodes" picker | A new RPC | `federation.list-nodes` (already returns `did`, `name`, `onion`, `trust_level`, `last_seen`) | FED-05 only needs to ADD Lightning fields to this payload/peer lookup, not build a parallel peer list |
|
||||||
|
|
||||||
|
**Key insight:** Almost every piece of infrastructure FED-01/02/04/05 need already exists in some form — the gaps are narrow (a missing lock, a missing mock method, a missing struct field) rather than missing subsystems. Resist the urge to redesign the federation sync architecture; patch the specific race and the specific parity/field gaps identified here.
|
||||||
|
|
||||||
|
## Common Pitfalls
|
||||||
|
|
||||||
|
### Pitfall 1: Unlocked concurrent read-modify-write on `federation/nodes.json` (PRIMARY SUSPECT for FED-01/FED-02)
|
||||||
|
**What goes wrong:** A federated node the operator removes reappears after "some sync cycles," with no error anywhere — exactly the symptom reported. `federation::remove_node()` and `federation::sync::update_node_state()` (called from `sync_with_peer()`) both do `load_nodes()` → mutate in memory → `save_nodes()` with zero mutex and a non-atomic `fs::write()`. Two async tasks (e.g., the 90s auto-sync loop mid-flight for peer X, and a `federation.remove-node` RPC for the same peer X arriving concurrently) can interleave: the sync task's `load_nodes()` snapshot (taken before the removal) still contains X; the removal completes and saves a list without X; the sync task then finishes and calls `save_nodes()` with its stale in-memory list, silently restoring X.
|
||||||
|
**Why it happens:** No `Mutex`/`RwLock` guards the federation JSON files, and there are TWO independent periodic sync loops (`server.rs` ~line 497, every 90s; ~line 840, every 1800s — the second loop's comment even says "every 30 min" while the interval literal is `Duration::from_secs(1800)`, i.e. that arithmetic is correct but the redundancy with the 90s loop is not otherwise explained or justified anywhere in the code) plus manual "Sync All" and remove/join/set-trust RPCs, all writing the same file.
|
||||||
|
**How to avoid:** Add a per-data-dir `tokio::sync::Mutex<()>` (or an `Arc<Mutex<Vec<FederatedNode>>>` cache) that every `federation::storage` read-modify-write function acquires for the duration of its load+mutate+save; consider switching `save_nodes` to atomic temp-file+rename to avoid partial-write corruption on crash. Separately, evaluate collapsing the two periodic sync loops into one (the 90s loop already does everything the 1800s loop does, plus asymmetry self-heal) — the 1800s loop appears vestigial/redundant and doubles the race exposure for no described benefit.
|
||||||
|
**Warning signs:** `tests/multinode/smoke.sh`'s "removed-node tombstone" section (already covers the transitive-reappear case) intermittently fails only under load/timing variance, or a removed node's `last_seen` timestamp updates *after* a `federation.remove-node` call succeeded — that's the race manifesting as reappearance without any logged error.
|
||||||
|
|
||||||
|
### Pitfall 2: Trusting `CONCERNS.md` as current state for this phase
|
||||||
|
**What goes wrong:** Re-fixing an already-fixed bug (tombstone-write-swallowed was fixed in `01cbec27`, 2026-07-02) wastes the FED-03 review's time and risks reintroducing a regression if the "fix" reverts working code.
|
||||||
|
**Why it happens:** `CONCERNS.md` was generated 2026-07-29 from a static codebase snapshot/analysis pass that in at least two documented cases (tombstone swallow, DID-join-without-verification) predates fixes already on `main`.
|
||||||
|
**How to avoid:** For every `CONCERNS.md` federation/mesh item, `git log -p` the referenced file/line range before deciding it's still open. Two items already verified fixed in this research: "Federation node removal tombstone gap" (fixed `01cbec27`) and part of "Federation DID validation incomplete" (the `peer-joined` RPC does require and verify an ed25519 signature — `handlers.rs:588-607`). The remaining un-verified part of that concern — no proof-of-ownership check on the *original* DID mint, i.e. can anyone claim any DID string on first contact — may still be valid; verify it during the review rather than assuming either way.
|
||||||
|
**Warning signs:** A "finding" in the FED-03 review that exactly matches a `CONCERNS.md` bullet without a fresh code read is a signal to re-verify before filing it.
|
||||||
|
|
||||||
|
### Pitfall 3: Demo mock silently no-ops instead of erroring on unmirrored RPCs
|
||||||
|
**What goes wrong:** `mesh.contacts-list`/`mesh.contacts-save` are called live from `Mesh.vue` (lines 113, 896) but have no case in `mock-backend.js`'s switch — they fall through to the `default` case which returns a proper JSON-RPC error (`Method not found`), but the frontend call sites wrap them in `try {} catch { /* non-fatal */ }`, so the failure is invisible during manual demo testing unless you watch the browser console or server log (`console.log('[RPC] Unknown method: ...')`).
|
||||||
|
**Why it happens:** New frontend RPC call sites get added over time; `mock-backend.js` parity is manual and easy to miss for methods that aren't on the "main" flow (aliasing a peer is a secondary action, not part of onboarding/attach-file).
|
||||||
|
**How to avoid:** Grep `neode-ui/src/api/rpc-client.ts` for every `mesh.*`/`federation.*` method string and cross-reference against `mock-backend.js`'s switch cases as an explicit FED-03/FED-04 checklist item, not just the attachment-send path already fixed.
|
||||||
|
**Warning signs:** Browser console shows `[RPC] Unknown method: mesh.contacts-list` while testing the demo at `:8100`.
|
||||||
|
|
||||||
|
### Pitfall 4: `ScreensaverRing`'s size classes don't have a "success-badge" variant
|
||||||
|
**What goes wrong:** Dropping `<ScreensaverRing size="compact" />` directly into `.send-success-burst` (currently 112px) either overflows the card or looks disproportionate at 240-320px without adjusting the surrounding layout.
|
||||||
|
**Why it happens:** `ScreensaverRing.vue`'s two size classes (`viz-ring-default`, `viz-ring-compact`) were designed for full-screen screensaver and settings-panel contexts (`SystemDangerZone.vue`), not for an inline modal success pane.
|
||||||
|
**How to avoid:** Either (a) wrap the component in a container with `transform: scale(0.5)` (112/240 ≈ 0.47) and compensate for the transform not affecting layout box size (use negative margins or a fixed wrapping box), or (b) add a third `compact-sm`/`badge` size variant to `ScreensaverRing.vue` sized for this use case (cleaner, and reusable for `WalletScanModal.vue`'s `.success-ring` too). Confirm the choice with a UI-spec/sketch before implementation given this affects two components.
|
||||||
|
**Warning signs:** Visual QA on `:8100` shows the ring clipped by the modal's `max-h-[90vh] overflow-y-auto` container or the checkmark badge floating disconnected from the ring's visual center.
|
||||||
|
|
||||||
|
### Pitfall 5: FED-05 has no backend field for a peer's Lightning identity
|
||||||
|
**What goes wrong:** Building the "trusted nodes by hostname, one-click channel open" UI before the backend can supply a federated peer's LN `pubkey`/`host:port` results in a UI that can list *names* but has nothing to pass to `lnd.openchannel`.
|
||||||
|
**Why it happens:** `NodeStateSnapshot` (the payload `federation.get-state`/sync exchanges) has no Lightning fields at all — it was designed for app/CPU/mem/tor status, not payment-channel metadata.
|
||||||
|
**How to avoid:** Plan FED-05 backend-first: (1) extend `handle_lnd_getinfo` to parse and return `identity_pubkey` + `uris` from LND's real `/v1/getinfo` response (both fields already exist in LND's REST API — the daemon's `LndGetInfoResponse` struct just doesn't deserialize them yet), (2) add optional `lightning_pubkey`/`lightning_uri` fields to `NodeStateSnapshot` so a synced peer's info includes it (defaulted via `#[serde(default)]` for backward compat, matching every other optional field in that struct), (3) decide and scope the "public nodes" browse/request feature — no existing data source; recommend a small curated static list (documented, versioned) rather than a live LN graph query (`DescribeGraph` is heavy and not currently proxied anywhere in this codebase) unless the user specifically wants live graph browsing.
|
||||||
|
**Warning signs:** A plan step that starts building `LightningChannelModal.vue` before a corresponding backend RPC/field change is scoped — check the plan's task ordering.
|
||||||
|
|
||||||
|
## Code Examples
|
||||||
|
|
||||||
|
### Reuse: opening a channel (backend already correct)
|
||||||
|
```rust
|
||||||
|
// Source: core/archipelago/src/api/rpc/lnd/channels.rs:238-336 (excerpted)
|
||||||
|
// Params: { pubkey: <66-hex>, amount: <sats>, address?: <host:port>, private?, target_conf?, sat_per_vbyte? }
|
||||||
|
// Validates pubkey format + amount bounds (20,000..=16,777,215 sats) before touching LND.
|
||||||
|
// Connects to the peer synchronously (perm=false) before opening so "peer not online" is
|
||||||
|
// surfaced deterministically instead of racing the open.
|
||||||
|
```
|
||||||
|
|
||||||
|
### Reuse: transport badge already ground-truth per peer
|
||||||
|
```rust
|
||||||
|
// Source: core/archipelago/src/federation/storage.rs:120-147
|
||||||
|
// record_peer_transport() writes last_transport/last_transport_at after every
|
||||||
|
// successful PeerRequest — the FED-05 peer picker can show "reachable via FIPS"
|
||||||
|
// / "reachable via Tor" per trusted node without any new plumbing.
|
||||||
|
```
|
||||||
|
|
||||||
|
### Gap: demo mesh chat action stubs don't mutate state
|
||||||
|
```javascript
|
||||||
|
// Source: neode-ui/mock-backend.js:4479-4490 (current — needs to become stateful)
|
||||||
|
case 'mesh.send-reaction':
|
||||||
|
case 'mesh.send-reply':
|
||||||
|
case 'mesh.send-read-receipt':
|
||||||
|
case 'mesh.edit-message':
|
||||||
|
case 'mesh.delete-message':
|
||||||
|
case 'mesh.forward-message':
|
||||||
|
case 'mesh.send-channel':
|
||||||
|
case 'mesh.refresh':
|
||||||
|
case 'mesh.reboot-radio': {
|
||||||
|
return res.json({ result: { ok: true, sent: true } }) // no meshStore.dynamic mutation
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
## State of the Art
|
||||||
|
|
||||||
|
| Old Approach | Current Approach | When Changed | Impact |
|
||||||
|
|--------------|------------------|---------------|--------|
|
||||||
|
| Tombstone write silently dropped (`let _ = tombstone_did(...)`) | Tombstone write propagated as a hard error, written before the node-list save | 2026-07-02, `01cbec27` | A failed tombstone write now fails the whole remove — matches FED-01's "a failed removal surfaces an error" requirement already, at the single-call level (the remaining gap is the cross-call race in Pitfall 1) |
|
||||||
|
| Mesh attachment send: demo threw "Method not found" and force-opened a demo-only chooser modal | `mock-backend.js` implements the same RPC surface + mirrors the real size-tier logic | 2026-07-29, `c2ce71c6` | FED-04's core attachment-parity requirement is met; remaining gaps are contacts and reaction/edit/delete (see Pitfall 3) |
|
||||||
|
|
||||||
|
**Deprecated/outdated:** None specific to this phase's tech; no framework/library version churn involved.
|
||||||
|
|
||||||
|
## Assumptions Log
|
||||||
|
|
||||||
|
| # | Claim | Section | Risk if Wrong |
|
||||||
|
|---|-------|---------|---------------|
|
||||||
|
| A1 | The unlocked read-modify-write race on `federation/nodes.json` (Pitfall 1) is the primary cause of the user-reported "nodes reappear / sync issues" — this is a code-derived hypothesis, not confirmed via a reproduced failure in this research session | Summary, Pitfall 1 | If wrong, the planner should still fix the race (it's a real bug regardless) but should budget review time for other causes too — start FED-03 with a broader look, not a narrow patch-and-close on this one hypothesis |
|
||||||
|
| A2 | The 1800s periodic federation sync loop (`server.rs` ~line 840) is redundant given the 90s loop and safe to remove/collapse | Pitfall 1, Anti-Patterns | If a maintainer added it for a specific reason not documented in the surrounding comments (e.g. covering a case the 90s loop misses), removing it could regress that unstated behavior — confirm via `git log -p` / git blame on that block before deleting |
|
||||||
|
| A3 | "Public nodes" for channel-open browse/request (FED-05) should be a small curated static list rather than a live LN network graph query | Pitfall 5 | If the user actually wants live graph discovery, the curated-list approach under-delivers; this needs explicit user confirmation before FED-05 backend work starts |
|
||||||
|
| A4 | `ScreensaverRing`'s size mismatch with `.send-success-burst` should be solved with a CSS scale-down rather than a new component size variant | Pitfall 4 | Either approach works technically; scale-down is faster but may look slightly different under `prefers-reduced-motion`; a new size variant is cleaner but touches the shared component. Low risk either way — a UI sketch/spec pass can decide before implementation |
|
||||||
|
| A5 | The remaining "Federation DID validation incomplete" concern (no proof-of-ownership check on first DID mint) is still an open gap, not yet fixed like its sibling claims | Pitfall 2 | Not independently re-verified in this session (only the peer-joined signature check was confirmed); FED-03 should explicitly re-check this specific sub-claim before filing or dismissing it |
|
||||||
|
|
||||||
|
**If this table is empty:** N/A — see rows above.
|
||||||
|
|
||||||
|
## Open Questions
|
||||||
|
|
||||||
|
1. **Is the two-loop federation sync redundancy intentional?**
|
||||||
|
- What we know: Both loops call `sync_with_peer` over all `Trusted`/`Observer` nodes; only the 90s loop does the "asymmetry self-heal" `notify_join` re-assertion; the 1800s loop additionally calls `refresh_federation_mesh_peers()` after its full pass (the 90s loop does not).
|
||||||
|
- What's unclear: Whether the 1800s loop's `refresh_federation_mesh_peers()` call covers a gap the 90s loop leaves (e.g. name/roster propagation to mesh chat), which would mean simply deleting it regresses something.
|
||||||
|
- Recommendation: `git log -p` / blame both loop-insertion commits during FED-03; if the mesh-peer-refresh behavior is the only unique value of the 1800s loop, move that single call into the 90s loop's completion and delete the 1800s loop entirely, closing half the race window.
|
||||||
|
|
||||||
|
2. **What UI/UX should "browse/request channels with public nodes" (FED-05) actually look like?**
|
||||||
|
- What we know: No existing data source; `lnd.openchannel` supports a manual pubkey+address entry today (a user could theoretically paste a public node's URI already, just with no picker/browse UI).
|
||||||
|
- What's unclear: Whether "public nodes" means (a) a curated list Archipelago ships/updates, (b) a live query against some LSP directory API, or (c) simply a well-labeled manual-paste field with format help (lowest-effort, matches what the backend already supports).
|
||||||
|
- Recommendation: Flag for `/gsd-discuss-phase` or a direct user check-in before FED-05 planning — this is a scope decision, not a technical one.
|
||||||
|
|
||||||
|
3. **Does `federation.get-state`'s `federated_peers` hint list need a Lightning field, or should peer LN info be a separate on-demand RPC?**
|
||||||
|
- What we know: `NodeStateSnapshot.federated_peers` already carries a lightweight `FederationPeerHint` (did/pubkey/onion/name/fips_npub) shared during sync; adding `lightning_uri` there means every synced peer's LN info is cached locally without an extra round-trip.
|
||||||
|
- What's unclear: Whether peers want to opt out of advertising their LN URI transitively (privacy consideration, similar to the existing `shared_location` opt-in pattern for lat/lon).
|
||||||
|
- Recommendation: Follow the `shared_location` precedent (`Option<(f64,f64)>` only sent when the node opts in via `server.set-location`) — add an explicit opt-in setting for Lightning URI sharing rather than defaulting it on, since exposing a payment channel target more broadly than intended has real-money implications.
|
||||||
|
|
||||||
|
## Environment Availability
|
||||||
|
|
||||||
|
| Dependency | Required By | Available | Version | Fallback |
|
||||||
|
|------------|------------|-----------|---------|----------|
|
||||||
|
| Rust toolchain / cargo (from `core/`) | FED-01/02/03/04 backend work | Not probed this session (assume present per CLAUDE.md build instructions) | — | — |
|
||||||
|
| Node.js / npm (`neode-ui/`) | FED-04/05/06 frontend + mock-backend.js work | Not probed this session (assume present per CLAUDE.md build instructions) | — | — |
|
||||||
|
| `:8100` dev preview proxying to archi-dev | FED-05/06 required verification step per phase description | Not probed this session — verify at execution time per `docs/../reference_neode_ui_dev_testing.md` (mock=5959, pw password123) | — | — |
|
||||||
|
| LND REST API (`LND_REST_BASE_URL`, local macaroon) | FED-05 `lnd.getinfo` extension + `lnd.openchannel` reuse | Assumed present on real nodes per existing `channels.rs`/`info.rs` code; demo backend has no real LND — FED-05 UI must be exercised against archi-dev (real LND) per phase description, not the pure-mock demo | — | Demo-only mock stub for `lnd.getinfo` identity fields if archi-dev is unavailable during a work session |
|
||||||
|
| `tests/multinode/smoke.sh` | Regression coverage for FED-01/02 fix | Present, already covers removed-node tombstone + transitive-reappear scenarios; does NOT currently exercise the concurrent-race scenario (Pitfall 1) | — | Extend smoke.sh with a concurrent remove+sync test, or add a Rust-level `#[tokio::test]` in `federation/storage.rs` that spawns concurrent remove/save calls |
|
||||||
|
|
||||||
|
**Missing dependencies with no fallback:** None identified — this phase is code-only, no new external services.
|
||||||
|
|
||||||
|
**Missing dependencies with fallback:** LND-backed FED-05 verification (see row above) — use archi-dev per phase instructions; demo-only stubbing is a fallback if archi-dev is temporarily unavailable, but the phase's own success criteria require verification against archi-dev before deploy, so this fallback should not be treated as sufficient sign-off.
|
||||||
|
|
||||||
|
## Validation Architecture
|
||||||
|
|
||||||
|
### Test Framework
|
||||||
|
| Property | Value |
|
||||||
|
|----------|-------|
|
||||||
|
| Framework (backend) | `cargo test` (Rust, workspace root `core/`) — federation/storage.rs and federation/sync.rs already have `#[tokio::test]` unit coverage |
|
||||||
|
| Framework (frontend) | Vitest (`neode-ui/vitest.config.ts`, `vitest run`) |
|
||||||
|
| Config file | `core/Cargo.toml` (workspace); `neode-ui/vitest.config.ts` |
|
||||||
|
| Quick run command | `cd core && cargo test -p archipelago federation:: --lib` (backend); `cd neode-ui && npx vitest run src/components/__tests__/` (frontend, scope to touched files) |
|
||||||
|
| Full suite command | `cd core && CARGO_INCREMENTAL=0 cargo test` (backend, full); `cd neode-ui && npm run test` (frontend, full); `tests/multinode/smoke.sh` (cross-node, requires 2+ live nodes, run on-node per CLAUDE.md gate policy) |
|
||||||
|
|
||||||
|
### Phase Requirements → Test Map
|
||||||
|
| Req ID | Behavior | Test Type | Automated Command | File Exists? |
|
||||||
|
|--------|----------|-----------|-------------------|-------------|
|
||||||
|
| FED-01 | Removed node never reappears, incl. under concurrent sync | unit + integration | `cargo test -p archipelago federation::storage::tests` (existing) + NEW concurrent-race test | ✅ existing tests / ❌ Wave 0 for the new race test |
|
||||||
|
| FED-01 | Failed removal surfaces an error, not a silent no-op | unit | `cargo test -p archipelago federation::storage::tests::test_remove_nonexistent_node_errors` (existing, covers the "not found" case; add one for a simulated tombstone-write I/O failure) | ✅ existing / ❌ Wave 0 for I/O-failure case |
|
||||||
|
| FED-02 | Sync converges; fleet nodes agree on node list | integration (multinode) | `tests/multinode/smoke.sh` section "federation pairing" + "removed-node tombstone" (existing) | ✅ |
|
||||||
|
| FED-02 | Sync errors are operator-visible | manual / UI | No automated test yet — requires a UI element (e.g. per-node "last sync error" badge) that doesn't exist yet | ❌ Wave 0 (needs the field to exist first) |
|
||||||
|
| FED-03 | Structured review findings fixed or deferred with reason | N/A (process requirement) | N/A — tracked via the plan's findings list, not a single automated test | — |
|
||||||
|
| FED-04 | Attachment send parity demo vs real | manual (visual) + existing `mock-backend.js` logic mirrors daemon tier thresholds | Manual walk-through on `:8100` per phase description; consider a Vitest test asserting `mesh.transport-advice` tier boundaries match `MESH_AUTO_MAX`/`MESH_HARD_MAX` constants | ❌ Wave 0 (no existing frontend test pins these thresholds) |
|
||||||
|
| FED-04 | Contacts list/save + reaction/edit/delete parity | manual + NEW mock-backend.js stateful behavior | Manual on `:8100`; no existing automated coverage of `mock-backend.js` behavior (it's a dev tool, not covered by `npm run test`) | ❌ Wave 0 if automated coverage is wanted; otherwise manual-only is acceptable for a demo shim |
|
||||||
|
| FED-05 | Own node Lightning URI is shareable | unit (backend) + manual (UI) | NEW `cargo test` for `handle_lnd_getinfo`'s identity_pubkey/uris parsing (mock LND response fixture); manual UI check on archi-dev | ❌ Wave 0 |
|
||||||
|
| FED-05 | Trusted-node picker + channel open flow | manual (UI, requires archi-dev + a live peer) | Manual per phase description ("tested live on the :8100 dev preview against archi-dev") | ❌ Wave 0 — inherently a live/manual check per the phase's own success criteria |
|
||||||
|
| FED-06 | Paid-tick animation matches screensaver ring everywhere it appears | manual (visual) | Manual visual check of `SendBitcoinModal.vue` + `WalletScanModal.vue` on `:8100` | ❌ Wave 0 — visual-only requirement, no meaningful automated assertion beyond "component renders" |
|
||||||
|
|
||||||
|
### Sampling Rate
|
||||||
|
- **Per task commit:** Backend: `cargo test -p archipelago federation:: mesh::` (scoped). Frontend: `npx vitest run` scoped to touched component test files, or a full quick run if none exist yet for touched files.
|
||||||
|
- **Per wave merge:** Full `cargo test` (backend) + `npm run test` (frontend).
|
||||||
|
- **Phase gate:** Full backend + frontend suites green, plus `tests/multinode/smoke.sh` federation sections green on a real 2-node pair, plus a manual FED-05/FED-06 walkthrough on `:8100` against archi-dev before any deploy (per phase description, "fixed there before any deploy").
|
||||||
|
|
||||||
|
### Wave 0 Gaps
|
||||||
|
- [ ] New `#[tokio::test]` in `core/archipelago/src/federation/storage.rs` (or a new integration test) that spawns concurrent `remove_node()` + `update_node_state()`/`sync_with_peer`-equivalent calls against the same `data_dir` and asserts the removed node stays removed — this is the regression test for Pitfall 1 and does not exist today.
|
||||||
|
- [ ] Test/fixture for `handle_lnd_getinfo` parsing `identity_pubkey`/`uris` from a mocked LND `/v1/getinfo` JSON response (FED-05) — no existing test touches this handler's response shape.
|
||||||
|
- [ ] Decide whether `mock-backend.js` behavior warrants automated (Vitest/Playwright-against-mock) coverage, or manual-only is acceptable given it's a dev-preview tool, not shipped code — recommend manual-only unless the team already has a pattern for testing the mock backend elsewhere (none found in this research).
|
||||||
|
- [ ] Framework install: none — all frameworks (`cargo test`, Vitest) are already configured and running.
|
||||||
|
|
||||||
|
## Security Domain
|
||||||
|
|
||||||
|
### Applicable ASVS Categories
|
||||||
|
|
||||||
|
| ASVS Category | Applies | Standard Control |
|
||||||
|
|---------------|---------|-----------------|
|
||||||
|
| V2 Authentication | Partial | Federation peer identity is DID/ed25519-key-based, not password auth; `peer-joined`/`peer-did-changed`/`peer-address-changed` all require and verify an ed25519 signature over a canonical message before mutating state — already correct, verify no new RPC bypasses this |
|
||||||
|
| V3 Session Management | No | Not applicable — federation/mesh RPCs are peer-signed, not session-cookie based |
|
||||||
|
| V4 Access Control | Yes | `is_peer_allowed_path()` (`server.rs:1270`, tested at `server.rs:2075+`) restricts which HTTP paths a peer-only listener will serve — any new FED-05 RPC (e.g. "fetch peer's Lightning URI") that's meant to be peer-reachable must be added to this allow-list explicitly, not left to fall through |
|
||||||
|
| V5 Input Validation | Yes | `lnd.openchannel` already validates pubkey format (66-hex) and amount bounds server-side (`channels.rs:252-268`) — reuse, and apply the same rigor to any new Lightning-URI-sharing field (validate the URI format before persisting/displaying it) |
|
||||||
|
| V6 Cryptography | Yes | ed25519 signature verification via `identity::NodeIdentity::verify` — never hand-roll signature checks; reuse this existing verification path if FED-05 needs to authenticate a peer's advertised Lightning info |
|
||||||
|
|
||||||
|
### Known Threat Patterns for this stack
|
||||||
|
|
||||||
|
| Pattern | STRIDE | Standard Mitigation |
|
||||||
|
|---------|--------|---------------------|
|
||||||
|
| Federation peer spoofing a DID they don't control | Spoofing | ed25519 signature verification (already implemented for join/address-change/did-rotation — confirm any new FED-05 peer-info exchange follows the same pattern) |
|
||||||
|
| Concurrent-write race corrupting/reverting federation state (Pitfall 1) | Tampering (unintentional, but security-relevant since it undermines the "removal sticks" guarantee — a removed/untrusted peer regaining federation membership is a real access-control regression) | Add locking around the storage layer (see Pitfall 1's fix) |
|
||||||
|
| Unbounded transitive federation exposure (a Trusted peer's peer list auto-added as Observer) | Elevation of Privilege (bounded) | Already mitigated — `merge_transitive_peers` only runs for `Trusted`-level sources and only adds new peers as `Observer` (never auto-escalates to `Trusted`); this is intentional and correct, don't loosen it |
|
||||||
|
| Advertising this node's Lightning payment-channel target more broadly than intended (FED-05 new surface) | Information Disclosure | Follow the existing `shared_location` opt-in pattern — do not default Lightning URI sharing to "on" for all federated peers (see Open Question 3) |
|
||||||
|
|
||||||
|
## Sources
|
||||||
|
|
||||||
|
### Primary (HIGH confidence)
|
||||||
|
- `core/archipelago/src/federation/storage.rs`, `sync.rs`, `types.rs`, `invites.rs` — read directly, current `main`
|
||||||
|
- `core/archipelago/src/api/rpc/federation/handlers.rs` — read directly, current `main`
|
||||||
|
- `core/archipelago/src/server.rs` (periodic sync loop sections, `is_peer_allowed_path`) — read directly
|
||||||
|
- `core/archipelago/src/mesh/mod.rs` (`purge_federation_peer`, `upsert_federation_peer`, `seed_federation_peers_into_mesh`) — read directly
|
||||||
|
- `core/archipelago/src/api/rpc/lnd/channels.rs`, `info.rs` — read directly
|
||||||
|
- `core/archipelago/src/fips/dial.rs` — read directly
|
||||||
|
- `neode-ui/mock-backend.js` (mesh RPC switch cases) — read directly, current `main` (post commit `c2ce71c6`)
|
||||||
|
- `neode-ui/src/views/Federation.vue`, `neode-ui/src/api/rpc-client.ts`, `neode-ui/src/components/ScreensaverRing.vue`, `neode-ui/src/components/Screensaver.vue`, `neode-ui/src/components/SendBitcoinModal.vue`, `neode-ui/src/components/WalletScanModal.vue`, `neode-ui/src/views/Mesh.vue` — read directly
|
||||||
|
- `git log -p` on `core/archipelago/src/federation/storage.rs` (commit `01cbec27`) and `git show c2ce71c6` — verified fix history directly, not from documentation
|
||||||
|
- `tests/multinode/smoke.sh` — read directly for existing federation test coverage
|
||||||
|
- `.planning/REQUIREMENTS.md`, `.planning/STATE.md`, `.planning/codebase/ARCHITECTURE.md`, `.planning/codebase/CONCERNS.md` — project-provided context (CONCERNS.md's federation claims were then verified/refuted against live code per Pitfall 2)
|
||||||
|
|
||||||
|
### Secondary (MEDIUM confidence)
|
||||||
|
- None — this research relied entirely on direct codebase reads and git history, not external web sources, since the phase is about hardening this specific project's existing code rather than adopting new external technology.
|
||||||
|
|
||||||
|
### Tertiary (LOW confidence)
|
||||||
|
- None.
|
||||||
|
|
||||||
|
## Metadata
|
||||||
|
|
||||||
|
**Confidence breakdown:**
|
||||||
|
- Standard stack: HIGH — no new dependencies; existing stack confirmed by direct file reads
|
||||||
|
- Architecture (FED-01/02/03/04): HIGH — read the actual implementation, confirmed fix history via git log, identified a concrete unverified race condition with file:line citations
|
||||||
|
- Architecture (FED-05): MEDIUM — greenfield UI/RPC surface; confirmed what's missing (no existing Lightning-URI field/RPC) but the design (opt-in sharing, public-nodes scope) needs a user decision, not just engineering judgment
|
||||||
|
- Pitfalls: HIGH for Pitfalls 1-3 (backend/demo, code-verified); MEDIUM for Pitfalls 4-5 (frontend sizing and FED-05 scope, judgment calls flagged in Assumptions Log)
|
||||||
|
|
||||||
|
**Research date:** 2026-07-29
|
||||||
|
**Valid until:** 2026-08-12 (14 days — this is a fast-moving area of an actively-developed codebase; other agents were committing federation/mesh-adjacent changes during this very research session, per the mock-backend.js commit observed mid-session)
|
||||||
@ -0,0 +1,78 @@
|
|||||||
|
---
|
||||||
|
phase: 1
|
||||||
|
slug: federation-mesh-hardening
|
||||||
|
# status lifecycle: draft (seeded by plan-phase) → validated (set by validate-phase §6)
|
||||||
|
# audit-milestone §5.5 distinguishes NOT-VALIDATED (draft) from PARTIAL (validated + nyquist_compliant: false) (#2117)
|
||||||
|
status: draft
|
||||||
|
nyquist_compliant: false
|
||||||
|
wave_0_complete: false
|
||||||
|
created: 2026-07-29
|
||||||
|
---
|
||||||
|
|
||||||
|
# Phase 1 — Validation Strategy
|
||||||
|
|
||||||
|
> Per-phase validation contract for feedback sampling during execution.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Test Infrastructure
|
||||||
|
|
||||||
|
| Property | Value |
|
||||||
|
|----------|-------|
|
||||||
|
| **Framework** | cargo test (Rust, workspace at core/) + bash harnesses (tests/multinode/, tests/lifecycle/) + node --check / manual curl for mock-backend |
|
||||||
|
| **Config file** | core/Cargo.toml (workspace); tests/multinode/smoke.sh |
|
||||||
|
| **Quick run command** | `cd core && cargo test -p archipelago federation` |
|
||||||
|
| **Full suite command** | `cd core && cargo test` (plus on-node `tests/multinode/smoke.sh` for cross-node behavior) |
|
||||||
|
| **Estimated runtime** | ~120 seconds (cargo test); multinode smoke is node-gated |
|
||||||
|
|
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Sampling Rate
|
||||||
|
|
||||||
|
- **After every task commit:** Run `cd core && cargo test -p archipelago federation` (or the targeted module's tests)
|
||||||
|
- **After every plan wave:** Run `cd core && cargo test`; frontend waves: `cd neode-ui && npm run build` + grep dist for new strings
|
||||||
|
- **Before `/gsd-verify-work`:** Full suite green + multinode smoke considerations noted (cross-node checks are hardware/node-gated)
|
||||||
|
- **Max feedback latency:** 180 seconds
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Per-Task Verification Map
|
||||||
|
|
||||||
|
| Task ID | Plan | Wave | Requirement | Threat Ref | Secure Behavior | Test Type | Automated Command | File Exists | Status |
|
||||||
|
|---------|------|------|-------------|------------|-----------------|-----------|-------------------|-------------|--------|
|
||||||
|
| (filled by planner) | — | — | FED-01..06 | — | — | — | — | — | ⬜ pending |
|
||||||
|
|
||||||
|
*Status: ⬜ pending · ✅ green · ❌ red · ⚠️ flaky*
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Wave 0 Requirements
|
||||||
|
|
||||||
|
- [ ] Storage-race unit tests for `federation/storage.rs` (concurrent load/save + remove-during-sync) — stubs for FED-01/FED-02
|
||||||
|
- [ ] Mock-backend RPC parity checks (mesh contacts + message-mutation methods) — FED-04 remainder
|
||||||
|
|
||||||
|
*Existing infrastructure covers cargo test; multinode smoke.sh covers cross-node sync but runs on-node only.*
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Manual-Only Verifications
|
||||||
|
|
||||||
|
| Behavior | Requirement | Why Manual | Test Instructions |
|
||||||
|
|----------|-------------|------------|-------------------|
|
||||||
|
| Removed peer never reappears across real fleet sync cycles | FED-01 | Needs two live nodes + wall-clock sync cycles | Remove a peer on archi-dev, watch peer list through ≥2 sync cycles (90s loop), confirm absent + error surfaced on induced failure |
|
||||||
|
| Channel-open UX end-to-end | FED-05 | Visual/UX judgment + live LND | Drive :8100 preview against archi-dev; share URI, open channel to trusted node, request public-node channel |
|
||||||
|
| Paid-tick animation on-brand | FED-06 | Visual judgment | Trigger payment success in preview; compare ring/EQ segments to screensaver |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Validation Sign-Off
|
||||||
|
|
||||||
|
- [ ] All tasks have `<automated>` verify or Wave 0 dependencies
|
||||||
|
- [ ] Sampling continuity: no 3 consecutive tasks without automated verify
|
||||||
|
- [ ] Wave 0 covers all MISSING references
|
||||||
|
- [ ] No watch-mode flags
|
||||||
|
- [ ] Feedback latency < 180s
|
||||||
|
- [ ] `nyquist_compliant: true` set in frontmatter
|
||||||
|
|
||||||
|
**Approval:** pending
|
||||||
4
Android/.gitignore
vendored
4
Android/.gitignore
vendored
@ -19,3 +19,7 @@ local.properties
|
|||||||
# updates then install over the top without an uninstall. Debug keys are not
|
# updates then install over the top without an uninstall. Debug keys are not
|
||||||
# secret (well-known password "android"); never commit a real release keystore.
|
# secret (well-known password "android"); never commit a real release keystore.
|
||||||
!/app/debug.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.
|
**aborts** if any is missing.
|
||||||
5. Stages the signed APK at `neode-ui/public/packages/archipelago-companion.apk`,
|
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).
|
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
|
**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
|
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
|
## Verify the published download after shipping
|
||||||
|
|
||||||
The download served to nodes is Gitea raw-on-main. Confirm the live bytes match
|
The checked-in artifact is Gitea raw-on-main. The QR/App Store download served
|
||||||
what you built and signed:
|
to users is the VPS2 `:2100` URL. Confirm both live byte streams match what you
|
||||||
|
built and signed:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
SERVED=neode-ui/public/packages/archipelago-companion.apk
|
SERVED=neode-ui/public/packages/archipelago-companion.apk
|
||||||
URL=http://146.59.87.168:3000/lfg2025/archy/raw/branch/main/$SERVED
|
GITEA_URL=http://146.59.87.168:3000/lfg2025/archy/raw/branch/main/$SERVED
|
||||||
curl -sS -o /tmp/live.apk "$URL"
|
QR_URL=http://146.59.87.168:2100/packages/archipelago-companion.apk
|
||||||
shasum -a 256 "$SERVED" /tmp/live.apk # must match
|
curl -sS -o /tmp/live-gitea.apk "$GITEA_URL"
|
||||||
apksigner verify -v --min-sdk-version 21 /tmp/live.apk | grep -i "scheme" # v1/v2/v3 = true
|
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"
|
applicationId = "com.archipelago.app"
|
||||||
minSdk = 26
|
minSdk = 26
|
||||||
targetSdk = 35
|
targetSdk = 35
|
||||||
versionCode = 16
|
versionCode = 45
|
||||||
versionName = "0.4.12"
|
versionName = "0.5.25"
|
||||||
|
|
||||||
vectorDrawables {
|
vectorDrawables {
|
||||||
useSupportLibrary = true
|
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 {
|
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 {
|
dependencies {
|
||||||
val composeBom = platform("androidx.compose:compose-bom:2024.05.00")
|
val composeBom = platform("androidx.compose:compose-bom:2024.05.00")
|
||||||
implementation(composeBom)
|
implementation(composeBom)
|
||||||
@ -111,6 +154,12 @@ dependencies {
|
|||||||
// OkHttp for WebSocket (remote input)
|
// OkHttp for WebSocket (remote input)
|
||||||
implementation("com.squareup.okhttp3:okhttp:4.12.0")
|
implementation("com.squareup.okhttp3:okhttp:4.12.0")
|
||||||
|
|
||||||
|
// CameraX + ZXing (Apache-2.0, on-device, no telemetry) for pairing-QR scanning
|
||||||
|
implementation("androidx.camera:camera-camera2:1.3.4")
|
||||||
|
implementation("androidx.camera:camera-lifecycle:1.3.4")
|
||||||
|
implementation("androidx.camera:camera-view:1.3.4")
|
||||||
|
implementation("com.google.zxing:core:3.5.3")
|
||||||
|
|
||||||
debugImplementation("androidx.compose.ui:ui-tooling")
|
debugImplementation("androidx.compose.ui:ui-tooling")
|
||||||
debugImplementation("androidx.compose.ui:ui-test-manifest")
|
debugImplementation("androidx.compose.ui:ui-test-manifest")
|
||||||
}
|
}
|
||||||
|
|||||||
Binary file not shown.
@ -4,6 +4,13 @@
|
|||||||
|
|
||||||
<uses-permission android:name="android.permission.INTERNET" />
|
<uses-permission android:name="android.permission.INTERNET" />
|
||||||
<uses-permission android:name="android.permission.ACCESS_NETWORK_STATE" />
|
<uses-permission android:name="android.permission.ACCESS_NETWORK_STATE" />
|
||||||
|
<!-- 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
|
<application
|
||||||
android:name=".ArchipelagoApp"
|
android:name=".ArchipelagoApp"
|
||||||
@ -16,9 +23,22 @@
|
|||||||
android:usesCleartextTraffic="true"
|
android:usesCleartextTraffic="true"
|
||||||
tools:targetApi="35">
|
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
|
<activity
|
||||||
android:name=".MainActivity"
|
android:name=".MainActivity"
|
||||||
android:exported="true"
|
android:exported="true"
|
||||||
|
android:launchMode="singleTask"
|
||||||
android:theme="@style/Theme.Archipelago.Splash"
|
android:theme="@style/Theme.Archipelago.Splash"
|
||||||
android:windowSoftInputMode="adjustResize"
|
android:windowSoftInputMode="adjustResize"
|
||||||
android:configChanges="orientation|screenSize|screenLayout|keyboardHidden">
|
android:configChanges="orientation|screenSize|screenLayout|keyboardHidden">
|
||||||
@ -26,7 +46,30 @@
|
|||||||
<action android:name="android.intent.action.MAIN" />
|
<action android:name="android.intent.action.MAIN" />
|
||||||
<category android:name="android.intent.category.LAUNCHER" />
|
<category android:name="android.intent.category.LAUNCHER" />
|
||||||
</intent-filter>
|
</intent-filter>
|
||||||
|
<!-- Pairing deep link from the web UI's Companion popup:
|
||||||
|
archipelago://pair?v=1&url=...[&pw=...] (docs/companion-pairing-qr.md) -->
|
||||||
|
<intent-filter>
|
||||||
|
<action android:name="android.intent.action.VIEW" />
|
||||||
|
<category android:name="android.intent.category.DEFAULT" />
|
||||||
|
<category android:name="android.intent.category.BROWSABLE" />
|
||||||
|
<data android:scheme="archipelago" android:host="pair" />
|
||||||
|
</intent-filter>
|
||||||
</activity>
|
</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>
|
</application>
|
||||||
|
|
||||||
</manifest>
|
</manifest>
|
||||||
|
|||||||
@ -1,22 +1,41 @@
|
|||||||
package com.archipelago.app
|
package com.archipelago.app
|
||||||
|
|
||||||
|
import android.content.Intent
|
||||||
import android.os.Bundle
|
import android.os.Bundle
|
||||||
import androidx.activity.ComponentActivity
|
import androidx.activity.ComponentActivity
|
||||||
import androidx.activity.compose.setContent
|
import androidx.activity.compose.setContent
|
||||||
import androidx.activity.enableEdgeToEdge
|
import androidx.activity.enableEdgeToEdge
|
||||||
|
import androidx.compose.runtime.collectAsState
|
||||||
|
import androidx.compose.runtime.getValue
|
||||||
import androidx.core.splashscreen.SplashScreen.Companion.installSplashScreen
|
import androidx.core.splashscreen.SplashScreen.Companion.installSplashScreen
|
||||||
import com.archipelago.app.ui.navigation.AppNavHost
|
import com.archipelago.app.ui.navigation.AppNavHost
|
||||||
import com.archipelago.app.ui.theme.ArchipelagoTheme
|
import com.archipelago.app.ui.theme.ArchipelagoTheme
|
||||||
|
import kotlinx.coroutines.flow.MutableStateFlow
|
||||||
|
|
||||||
class MainActivity : ComponentActivity() {
|
class MainActivity : ComponentActivity() {
|
||||||
|
|
||||||
|
// Pairing deep link (archipelago://pair?...) from the launch intent or a
|
||||||
|
// later one (launchMode=singleTask). Consumed by AppNavHost.
|
||||||
|
private val pendingPairUri = MutableStateFlow<String?>(null)
|
||||||
|
|
||||||
override fun onCreate(savedInstanceState: Bundle?) {
|
override fun onCreate(savedInstanceState: Bundle?) {
|
||||||
installSplashScreen()
|
installSplashScreen()
|
||||||
enableEdgeToEdge()
|
enableEdgeToEdge()
|
||||||
super.onCreate(savedInstanceState)
|
super.onCreate(savedInstanceState)
|
||||||
|
pendingPairUri.value = intent?.dataString
|
||||||
setContent {
|
setContent {
|
||||||
ArchipelagoTheme {
|
ArchipelagoTheme {
|
||||||
AppNavHost()
|
val pairUri by pendingPairUri.collectAsState()
|
||||||
|
AppNavHost(
|
||||||
|
pairUri = pairUri,
|
||||||
|
onPairUriConsumed = { pendingPairUri.value = null },
|
||||||
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
override fun onNewIntent(intent: Intent) {
|
||||||
|
super.onNewIntent(intent)
|
||||||
|
pendingPairUri.value = intent.dataString
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -19,25 +19,45 @@ data class ServerEntry(
|
|||||||
val port: String = "",
|
val port: String = "",
|
||||||
val password: String = "",
|
val password: String = "",
|
||||||
val name: 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. */
|
/** Label to show in lists — the user-given name, or the address if unnamed. */
|
||||||
fun displayName(): String = name.ifBlank { address }
|
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 {
|
fun toUrl(): String {
|
||||||
val scheme = if (useHttps) "https" else "http"
|
val scheme = if (useHttps) "https" else "http"
|
||||||
val portSuffix = if (port.isNotBlank()) ":$port" else ""
|
val portSuffix = if (port.isNotBlank()) ":$port" else ""
|
||||||
return "$scheme://$address$portSuffix"
|
return "$scheme://${urlHost(address)}$portSuffix"
|
||||||
}
|
}
|
||||||
|
|
||||||
fun toWsUrl(): String {
|
fun toWsUrl(): String {
|
||||||
val scheme = if (useHttps) "wss" else "ws"
|
val scheme = if (useHttps) "wss" else "ws"
|
||||||
val portSuffix = if (port.isNotBlank()) ":$port" else ""
|
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
|
/** Mesh-address UI URL, or null when the node never advertised one. */
|
||||||
// (4 fields) still deserialize, with name defaulting to "".
|
fun toMeshUrl(): String? =
|
||||||
fun serialize(): String = "$address|$useHttps|$port|$password|$name"
|
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 {
|
companion object {
|
||||||
fun deserialize(raw: String): ServerEntry? {
|
fun deserialize(raw: String): ServerEntry? {
|
||||||
@ -49,6 +69,8 @@ data class ServerEntry(
|
|||||||
port = parts.getOrElse(2) { "" },
|
port = parts.getOrElse(2) { "" },
|
||||||
password = parts.getOrElse(3) { "" },
|
password = parts.getOrElse(3) { "" },
|
||||||
name = parts.getOrElse(4) { "" },
|
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 activePortKey = stringPreferencesKey("active_port")
|
||||||
private val activePasswordKey = stringPreferencesKey("active_password")
|
private val activePasswordKey = stringPreferencesKey("active_password")
|
||||||
private val activeNameKey = stringPreferencesKey("active_name")
|
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 savedServersKey = stringSetPreferencesKey("saved_servers")
|
||||||
private val introSeenKey = booleanPreferencesKey("intro_seen")
|
private val introSeenKey = booleanPreferencesKey("intro_seen")
|
||||||
|
private val gestureHintSeenKey = booleanPreferencesKey("gesture_hint_seen")
|
||||||
|
|
||||||
val activeServer: Flow<ServerEntry?> = context.dataStore.data.map { prefs ->
|
val activeServer: Flow<ServerEntry?> = context.dataStore.data.map { prefs ->
|
||||||
val address = prefs[activeAddressKey] ?: return@map null
|
val address = prefs[activeAddressKey] ?: return@map null
|
||||||
@ -72,6 +97,8 @@ class ServerPreferences(private val context: Context) {
|
|||||||
port = prefs[activePortKey] ?: "",
|
port = prefs[activePortKey] ?: "",
|
||||||
password = prefs[activePasswordKey] ?: "",
|
password = prefs[activePasswordKey] ?: "",
|
||||||
name = prefs[activeNameKey] ?: "",
|
name = prefs[activeNameKey] ?: "",
|
||||||
|
meshIp = prefs[activeMeshIpKey] ?: "",
|
||||||
|
npub = prefs[activeNpubKey] ?: "",
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -84,6 +111,11 @@ class ServerPreferences(private val context: Context) {
|
|||||||
prefs[introSeenKey] ?: false
|
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) {
|
suspend fun setActiveServer(server: ServerEntry) {
|
||||||
context.dataStore.edit { prefs ->
|
context.dataStore.edit { prefs ->
|
||||||
prefs[activeAddressKey] = server.address
|
prefs[activeAddressKey] = server.address
|
||||||
@ -91,6 +123,8 @@ class ServerPreferences(private val context: Context) {
|
|||||||
prefs[activePortKey] = server.port
|
prefs[activePortKey] = server.port
|
||||||
prefs[activePasswordKey] = server.password
|
prefs[activePasswordKey] = server.password
|
||||||
prefs[activeNameKey] = server.name
|
prefs[activeNameKey] = server.name
|
||||||
|
prefs[activeMeshIpKey] = server.meshIp
|
||||||
|
prefs[activeNpubKey] = server.npub
|
||||||
}
|
}
|
||||||
addSavedServer(server)
|
addSavedServer(server)
|
||||||
}
|
}
|
||||||
@ -102,6 +136,8 @@ class ServerPreferences(private val context: Context) {
|
|||||||
prefs.remove(activePortKey)
|
prefs.remove(activePortKey)
|
||||||
prefs.remove(activePasswordKey)
|
prefs.remove(activePasswordKey)
|
||||||
prefs.remove(activeNameKey)
|
prefs.remove(activeNameKey)
|
||||||
|
prefs.remove(activeMeshIpKey)
|
||||||
|
prefs.remove(activeNpubKey)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -113,48 +149,80 @@ class ServerPreferences(private val context: Context) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Replace a saved server in place. Matches the existing entry by connection
|
* Replace a saved server in place. Matches the existing entry by node
|
||||||
* identity (address/port/scheme) so edits that change the name or password —
|
* identity — npub first, address/port/scheme as the LAN-only fallback
|
||||||
* or that touch a legacy 4-field entry — still update the right record. If the
|
* (ServerEntry.sameNode) — so edits that change the name, password or even
|
||||||
* edited server is also the active one, the active record is kept in sync.
|
* 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) {
|
suspend fun updateSavedServer(original: ServerEntry, updated: ServerEntry) {
|
||||||
|
val toStore = updated.copy(npub = updated.npub.ifBlank { original.npub })
|
||||||
context.dataStore.edit { prefs ->
|
context.dataStore.edit { prefs ->
|
||||||
val current = prefs[savedServersKey] ?: emptySet()
|
val current = prefs[savedServersKey] ?: emptySet()
|
||||||
val filtered = current.filterNot { raw ->
|
val filtered = current.filterNot { raw ->
|
||||||
val e = ServerEntry.deserialize(raw)
|
ServerEntry.deserialize(raw)?.sameNode(original) == true
|
||||||
e != null &&
|
|
||||||
e.address == original.address &&
|
|
||||||
e.port == original.port &&
|
|
||||||
e.useHttps == original.useHttps
|
|
||||||
}.toSet()
|
}.toSet()
|
||||||
prefs[savedServersKey] = filtered + updated.serialize()
|
prefs[savedServersKey] = filtered + toStore.serialize()
|
||||||
|
|
||||||
val isActive = prefs[activeAddressKey] == original.address &&
|
val activeNpub = prefs[activeNpubKey] ?: ""
|
||||||
(prefs[activePortKey] ?: "") == original.port &&
|
val isActive = (activeNpub.isNotBlank() && activeNpub == original.npub) ||
|
||||||
(prefs[activeHttpsKey] ?: false) == original.useHttps
|
(
|
||||||
|
prefs[activeAddressKey] == original.address &&
|
||||||
|
(prefs[activePortKey] ?: "") == original.port &&
|
||||||
|
(prefs[activeHttpsKey] ?: false) == original.useHttps
|
||||||
|
)
|
||||||
if (isActive) {
|
if (isActive) {
|
||||||
prefs[activeAddressKey] = updated.address
|
prefs[activeAddressKey] = toStore.address
|
||||||
prefs[activeHttpsKey] = updated.useHttps
|
prefs[activeHttpsKey] = toStore.useHttps
|
||||||
prefs[activePortKey] = updated.port
|
prefs[activePortKey] = toStore.port
|
||||||
prefs[activePasswordKey] = updated.password
|
prefs[activePasswordKey] = toStore.password
|
||||||
prefs[activeNameKey] = updated.name
|
prefs[activeNameKey] = toStore.name
|
||||||
|
prefs[activeMeshIpKey] = toStore.meshIp
|
||||||
|
prefs[activeNpubKey] = toStore.npub
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Add a server, or update the entry for the same node — npub first,
|
||||||
|
* address/port/scheme as the LAN-only fallback (ServerEntry.sameNode) —
|
||||||
|
* used by QR pairing so re-scanning a node never duplicates it, even after
|
||||||
|
* the LAN renumbered and every address changed (npub-first contract in
|
||||||
|
* docs/companion-pairing-qr.md). A blank incoming password/name keeps the
|
||||||
|
* stored value (a real node's QR never carries the password). Returns the
|
||||||
|
* merged entry.
|
||||||
|
*/
|
||||||
|
suspend fun upsertServer(server: ServerEntry): ServerEntry {
|
||||||
|
var merged = server
|
||||||
|
context.dataStore.edit { prefs ->
|
||||||
|
val current = prefs[savedServersKey] ?: emptySet()
|
||||||
|
val existing = current.mapNotNull { ServerEntry.deserialize(it) }
|
||||||
|
.firstOrNull { it.sameNode(server) }
|
||||||
|
if (existing != null) {
|
||||||
|
merged = server.copy(
|
||||||
|
password = server.password.ifBlank { existing.password },
|
||||||
|
name = server.name.ifBlank { existing.name },
|
||||||
|
meshIp = server.meshIp.ifBlank { existing.meshIp },
|
||||||
|
npub = server.npub.ifBlank { existing.npub },
|
||||||
|
)
|
||||||
|
}
|
||||||
|
val filtered = current.filterNot { raw ->
|
||||||
|
ServerEntry.deserialize(raw)?.sameNode(merged) == true
|
||||||
|
}.toSet()
|
||||||
|
prefs[savedServersKey] = filtered + merged.serialize()
|
||||||
|
}
|
||||||
|
return merged
|
||||||
|
}
|
||||||
|
|
||||||
suspend fun removeSavedServer(server: ServerEntry) {
|
suspend fun removeSavedServer(server: ServerEntry) {
|
||||||
context.dataStore.edit { prefs ->
|
context.dataStore.edit { prefs ->
|
||||||
val current = prefs[savedServersKey] ?: emptySet()
|
val current = prefs[savedServersKey] ?: emptySet()
|
||||||
// Match by connection identity (address/port/scheme) rather than the
|
// Match by node identity (npub, else address/port/scheme) rather
|
||||||
// exact serialized string, so a rename — or the legacy 4-field format
|
// than the exact serialized string, so a rename — or a legacy
|
||||||
// saved before names existed — still removes the right entry.
|
// short-format entry — still removes the right record.
|
||||||
prefs[savedServersKey] = current.filterNot { raw ->
|
prefs[savedServersKey] = current.filterNot { raw ->
|
||||||
val e = ServerEntry.deserialize(raw)
|
ServerEntry.deserialize(raw)?.sameNode(server) == true
|
||||||
e != null &&
|
|
||||||
e.address == server.address &&
|
|
||||||
e.port == server.port &&
|
|
||||||
e.useHttps == server.useHttps
|
|
||||||
}.toSet()
|
}.toSet()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@ -164,4 +232,10 @@ class ServerPreferences(private val context: Context) {
|
|||||||
prefs[introSeenKey] = true
|
prefs[introSeenKey] = true
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
suspend fun markGestureHintSeen() {
|
||||||
|
context.dataStore.edit { prefs ->
|
||||||
|
prefs[gestureHintSeenKey] = true
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -0,0 +1,128 @@
|
|||||||
|
package com.archipelago.app.data
|
||||||
|
|
||||||
|
import android.net.Uri
|
||||||
|
import com.archipelago.app.fips.AnchorPeer
|
||||||
|
import com.archipelago.app.fips.FipsPairInfo
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Result of parsing a pairing QR / deep link.
|
||||||
|
*
|
||||||
|
* UnsupportedVersion means the payload is structurally a pairing URI but its
|
||||||
|
* major version is newer than this app understands — the UI should tell the
|
||||||
|
* user to update the app rather than call the code invalid.
|
||||||
|
*/
|
||||||
|
sealed class PairResult {
|
||||||
|
data class Success(
|
||||||
|
val server: ServerEntry,
|
||||||
|
/** Mesh info when the node advertises FIPS (fnpub present). */
|
||||||
|
val fips: FipsPairInfo? = null,
|
||||||
|
) : PairResult()
|
||||||
|
object UnsupportedVersion : PairResult()
|
||||||
|
object Invalid : PairResult()
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Parser for the companion pairing QR / OS deep link. Contract:
|
||||||
|
* docs/companion-pairing-qr.md (repo root).
|
||||||
|
*
|
||||||
|
* archipelago://pair?v=1&url=<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.
|
||||||
|
* - `tok` is a device token minted by the node; it goes through the password
|
||||||
|
* field on purpose — the whole password auto-login path (WebSocket auth +
|
||||||
|
* WebView form injection) then works unchanged, and the backend accepts
|
||||||
|
* device tokens wherever it accepts the password. `pw` (demo only) wins if
|
||||||
|
* both are ever present.
|
||||||
|
* - `fnpub`/`fip`/`fhost`/`fudp`/`ftcp` describe the node's FIPS mesh; their
|
||||||
|
* absence just means LAN-only pairing (older node, or FIPS not provisioned).
|
||||||
|
* - Unknown extra query params are tolerated (forward compat under v=1).
|
||||||
|
*/
|
||||||
|
object ServerQrParser {
|
||||||
|
private const val SUPPORTED_MAJOR = 1
|
||||||
|
|
||||||
|
fun parse(raw: String): PairResult {
|
||||||
|
val uri = try {
|
||||||
|
Uri.parse(raw.trim())
|
||||||
|
} catch (_: Exception) {
|
||||||
|
return PairResult.Invalid
|
||||||
|
}
|
||||||
|
if (!"archipelago".equals(uri.scheme, ignoreCase = true)) return PairResult.Invalid
|
||||||
|
if (uri.isOpaque || !"pair".equals(uri.host, ignoreCase = true)) return PairResult.Invalid
|
||||||
|
|
||||||
|
val major = uri.getQueryParameter("v")
|
||||||
|
?.trim()
|
||||||
|
?.takeWhile { it.isDigit() }
|
||||||
|
?.toIntOrNull()
|
||||||
|
?: return PairResult.Invalid
|
||||||
|
if (major != SUPPORTED_MAJOR) return PairResult.UnsupportedVersion
|
||||||
|
|
||||||
|
val serverUrl = uri.getQueryParameter("url")?.trim()?.trimEnd('/')
|
||||||
|
if (serverUrl.isNullOrBlank()) return PairResult.Invalid
|
||||||
|
val server = Uri.parse(serverUrl)
|
||||||
|
val scheme = server.scheme?.lowercase()
|
||||||
|
if (scheme != "http" && scheme != "https") return PairResult.Invalid
|
||||||
|
val host = server.host
|
||||||
|
if (host.isNullOrBlank()) return PairResult.Invalid
|
||||||
|
|
||||||
|
val fips = parseFips(uri)
|
||||||
|
val credential = uri.getQueryParameter("pw")?.takeIf { it.isNotBlank() }
|
||||||
|
?: uri.getQueryParameter("tok")
|
||||||
|
?: ""
|
||||||
|
|
||||||
|
return PairResult.Success(
|
||||||
|
server = ServerEntry(
|
||||||
|
address = host,
|
||||||
|
useHttps = scheme == "https",
|
||||||
|
port = if (server.port != -1) server.port.toString() else "",
|
||||||
|
password = credential,
|
||||||
|
name = uri.getQueryParameter("name") ?: "",
|
||||||
|
meshIp = fips?.ula ?: "",
|
||||||
|
// npub is the durable identity — saved-server upserts match on
|
||||||
|
// it, so re-scanning after a LAN renumber updates in place.
|
||||||
|
npub = fips?.npub ?: "",
|
||||||
|
),
|
||||||
|
fips = fips,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun parseFips(uri: Uri): FipsPairInfo? {
|
||||||
|
val npub = uri.getQueryParameter("fnpub")?.trim()
|
||||||
|
var host = uri.getQueryParameter("fhost")?.trim()
|
||||||
|
if (npub.isNullOrBlank() || host.isNullOrBlank()) return null
|
||||||
|
// A .fips fhost is a dead dial hint: Android's system DNS can't
|
||||||
|
// resolve .fips, so every handshake send fails and first connect
|
||||||
|
// falls back to slow anchor discovery. If the QR's `url` host is a
|
||||||
|
// real address, dial that instead (QRs minted while the node UI was
|
||||||
|
// browsed over the mesh carry npub….fips here).
|
||||||
|
if (host.endsWith(".fips")) {
|
||||||
|
val urlHost = uri.getQueryParameter("url")
|
||||||
|
?.let { runCatching { Uri.parse(it).host }.getOrNull() }
|
||||||
|
if (!urlHost.isNullOrBlank() && !urlHost.endsWith(".fips")) host = urlHost
|
||||||
|
}
|
||||||
|
return FipsPairInfo(
|
||||||
|
npub = npub,
|
||||||
|
ula = uri.getQueryParameter("fip")?.trim().orEmpty(),
|
||||||
|
host = host,
|
||||||
|
udpPort = uri.getQueryParameter("fudp")?.toIntOrNull() ?: 2121,
|
||||||
|
tcpPort = uri.getQueryParameter("ftcp")?.toIntOrNull() ?: 8443,
|
||||||
|
anchors = parseAnchors(uri.getQueryParameter("fanchors")),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
/** `fanchors` = comma-joined `npub@host:port/transport`; bad items skipped. */
|
||||||
|
private fun parseAnchors(raw: String?): List<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
|
||||||
|
}
|
||||||
|
}
|
||||||
102
Android/app/src/main/java/com/archipelago/app/fips/PartyQr.kt
Normal file
102
Android/app/src/main/java/com/archipelago/app/fips/PartyQr.kt
Normal file
@ -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
|
@Composable
|
||||||
fun GamepadLayout(
|
fun GamepadLayout(
|
||||||
onKey: (String) -> Unit,
|
onKey: (String) -> Unit,
|
||||||
onTwoFingerHold: () -> Unit,
|
onThreeFingerHold: () -> Unit,
|
||||||
modifier: Modifier = Modifier,
|
modifier: Modifier = Modifier,
|
||||||
) {
|
) {
|
||||||
val surface = Neo.surface()
|
val surface = Neo.surface()
|
||||||
@ -54,9 +54,9 @@ fun GamepadLayout(
|
|||||||
do {
|
do {
|
||||||
val ev = awaitPointerEvent()
|
val ev = awaitPointerEvent()
|
||||||
val a = ev.changes.filter { !it.changedToUp() }
|
val a = ev.changes.filter { !it.changedToUp() }
|
||||||
if (a.size >= 2 && t == 0L) t = System.currentTimeMillis()
|
if (a.size >= 3 && t == 0L) t = System.currentTimeMillis()
|
||||||
if (a.size >= 2 && !fired && t > 0 && System.currentTimeMillis() - t > 500) { fired = true; onTwoFingerHold() }
|
if (a.size >= 3 && !fired && t > 0 && System.currentTimeMillis() - t > 500) { fired = true; onThreeFingerHold() }
|
||||||
if (a.size < 2) t = 0L
|
if (a.size < 3) t = 0L
|
||||||
} while (ev.changes.any { it.pressed })
|
} 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.CircleShape
|
||||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||||
import androidx.compose.material.icons.Icons
|
import androidx.compose.material.icons.Icons
|
||||||
|
import androidx.compose.material.icons.filled.Palette
|
||||||
import androidx.compose.material.icons.filled.Settings
|
import androidx.compose.material.icons.filled.Settings
|
||||||
import androidx.compose.material3.Icon
|
import androidx.compose.material3.Icon
|
||||||
import androidx.compose.material3.Text
|
import androidx.compose.material3.Text
|
||||||
@ -108,6 +109,7 @@ fun NESController(
|
|||||||
onKey: (String) -> Unit,
|
onKey: (String) -> Unit,
|
||||||
onMenu: () -> Unit,
|
onMenu: () -> Unit,
|
||||||
onPlayerToggle: () -> Unit = {},
|
onPlayerToggle: () -> Unit = {},
|
||||||
|
onToggleStyle: (() -> Unit)? = null,
|
||||||
modifier: Modifier = Modifier,
|
modifier: Modifier = Modifier,
|
||||||
) {
|
) {
|
||||||
val c = paletteFor(style)
|
val c = paletteFor(style)
|
||||||
@ -116,7 +118,7 @@ fun NESController(
|
|||||||
Box(
|
Box(
|
||||||
modifier = modifier
|
modifier = modifier
|
||||||
.fillMaxSize()
|
.fillMaxSize()
|
||||||
.twoFingerHold(onMenu)
|
.threeFingerHold(onMenu)
|
||||||
.padding(horizontal = 40.dp, vertical = 24.dp),
|
.padding(horizontal = 40.dp, vertical = 24.dp),
|
||||||
contentAlignment = Alignment.Center,
|
contentAlignment = Alignment.Center,
|
||||||
) {
|
) {
|
||||||
@ -205,6 +207,7 @@ fun NESController(
|
|||||||
) {
|
) {
|
||||||
PlayerPill(c, playerId, onPlayerToggle)
|
PlayerPill(c, playerId, onPlayerToggle)
|
||||||
SettingsBtn(c, Modifier, onMenu)
|
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) */
|
/** Player ID toggle pill (P1/P2/ALL) */
|
||||||
@Composable
|
@Composable
|
||||||
fun PlayerPill(c: NESPalette, playerId: Int, onToggle: () -> Unit) {
|
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 */
|
/** Three-finger hold gesture modifier (two fingers stay free for scrolling) */
|
||||||
fun Modifier.twoFingerHold(onHold: () -> Unit) = this.pointerInput(Unit) {
|
fun Modifier.threeFingerHold(onHold: () -> Unit) = this.pointerInput(Unit) {
|
||||||
awaitEachGesture {
|
awaitEachGesture {
|
||||||
awaitFirstDown(requireUnconsumed = false)
|
awaitFirstDown(requireUnconsumed = false)
|
||||||
var t = 0L; var fired = false
|
var t = 0L; var fired = false
|
||||||
do {
|
do {
|
||||||
val ev = awaitPointerEvent()
|
val ev = awaitPointerEvent()
|
||||||
val a = ev.changes.filter { !it.changedToUp() }
|
val a = ev.changes.filter { !it.changedToUp() }
|
||||||
if (a.size >= 2 && t == 0L) t = System.currentTimeMillis()
|
if (a.size >= 3 && t == 0L) t = System.currentTimeMillis()
|
||||||
if (a.size >= 2 && !fired && t > 0 && System.currentTimeMillis() - t > 500) { fired = true; onHold() }
|
if (a.size >= 3 && !fired && t > 0 && System.currentTimeMillis() - t > 500) { fired = true; onHold() }
|
||||||
if (a.size < 2) t = 0L
|
if (a.size < 3) t = 0L
|
||||||
} while (ev.changes.any { it.pressed })
|
} while (ev.changes.any { it.pressed })
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -21,21 +21,45 @@ import androidx.compose.foundation.layout.padding
|
|||||||
import androidx.compose.foundation.layout.size
|
import androidx.compose.foundation.layout.size
|
||||||
import androidx.compose.foundation.layout.width
|
import androidx.compose.foundation.layout.width
|
||||||
import androidx.compose.foundation.layout.widthIn
|
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.shape.RoundedCornerShape
|
||||||
import androidx.compose.foundation.text.KeyboardActions
|
import androidx.compose.foundation.text.KeyboardActions
|
||||||
import androidx.compose.foundation.text.KeyboardOptions
|
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.OutlinedTextField
|
||||||
import androidx.compose.material3.OutlinedTextFieldDefaults
|
import androidx.compose.material3.OutlinedTextFieldDefaults
|
||||||
import androidx.compose.material3.Text
|
import androidx.compose.material3.Text
|
||||||
import androidx.compose.runtime.Composable
|
import androidx.compose.runtime.Composable
|
||||||
|
import androidx.compose.runtime.LaunchedEffect
|
||||||
import androidx.compose.runtime.getValue
|
import androidx.compose.runtime.getValue
|
||||||
import androidx.compose.runtime.mutableStateOf
|
import androidx.compose.runtime.mutableStateOf
|
||||||
import androidx.compose.runtime.remember
|
import androidx.compose.runtime.remember
|
||||||
import androidx.compose.runtime.setValue
|
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.Alignment
|
||||||
import androidx.compose.ui.Modifier
|
import androidx.compose.ui.Modifier
|
||||||
import androidx.compose.ui.draw.clip
|
import androidx.compose.ui.draw.clip
|
||||||
import androidx.compose.ui.graphics.Color
|
import androidx.compose.ui.graphics.Color
|
||||||
|
import androidx.compose.ui.res.stringResource
|
||||||
import androidx.compose.ui.text.TextStyle
|
import androidx.compose.ui.text.TextStyle
|
||||||
import androidx.compose.ui.text.font.FontWeight
|
import androidx.compose.ui.text.font.FontWeight
|
||||||
import androidx.compose.ui.text.input.ImeAction
|
import androidx.compose.ui.text.input.ImeAction
|
||||||
@ -44,9 +68,9 @@ import androidx.compose.ui.text.input.PasswordVisualTransformation
|
|||||||
import androidx.compose.ui.text.style.TextAlign
|
import androidx.compose.ui.text.style.TextAlign
|
||||||
import androidx.compose.ui.unit.dp
|
import androidx.compose.ui.unit.dp
|
||||||
import androidx.compose.ui.unit.sp
|
import androidx.compose.ui.unit.sp
|
||||||
|
import com.archipelago.app.R
|
||||||
import com.archipelago.app.data.ServerEntry
|
import com.archipelago.app.data.ServerEntry
|
||||||
import com.archipelago.app.ui.theme.BitcoinOrange
|
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.SurfaceDark
|
||||||
import com.archipelago.app.ui.theme.TextMuted
|
import com.archipelago.app.ui.theme.TextMuted
|
||||||
import com.archipelago.app.ui.theme.TextPrimary
|
import com.archipelago.app.ui.theme.TextPrimary
|
||||||
@ -70,25 +94,29 @@ fun NESMenu(
|
|||||||
visible: Boolean,
|
visible: Boolean,
|
||||||
servers: List<ServerEntry>,
|
servers: List<ServerEntry>,
|
||||||
activeServer: ServerEntry?,
|
activeServer: ServerEntry?,
|
||||||
isGamepadMode: Boolean,
|
|
||||||
controllerStyle: ControllerStyle,
|
|
||||||
onDismiss: () -> Unit,
|
onDismiss: () -> Unit,
|
||||||
onSelectServer: (ServerEntry) -> Unit,
|
onSelectServer: (ServerEntry) -> Unit,
|
||||||
onAddServer: (ServerEntry) -> Unit,
|
onAddServer: (ServerEntry) -> Unit,
|
||||||
|
onScanQr: (() -> Unit)? = null,
|
||||||
onEditServer: (ServerEntry, ServerEntry) -> Unit,
|
onEditServer: (ServerEntry, ServerEntry) -> Unit,
|
||||||
onRemoveServer: (ServerEntry) -> Unit,
|
onRemoveServer: (ServerEntry) -> Unit,
|
||||||
onToggleMode: () -> Unit,
|
onRemote: () -> Unit,
|
||||||
onToggleStyle: () -> Unit,
|
onKeyboard: () -> Unit,
|
||||||
onBackToWebView: (() -> Unit)? = null,
|
onBackToWebView: (() -> Unit)? = null,
|
||||||
|
onMeshParty: (() -> Unit)? = null,
|
||||||
) {
|
) {
|
||||||
AnimatedVisibility(visible = visible, enter = fadeIn(), exit = fadeOut()) {
|
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(
|
Box(
|
||||||
Modifier.fillMaxSize().background(Color.Black.copy(alpha = 0.7f))
|
Modifier.fillMaxSize().background(Color.Black.copy(alpha = 0.7f))
|
||||||
.clickable(indication = null, interactionSource = remember { MutableInteractionSource() }) { onDismiss() },
|
.clickable(indication = null, interactionSource = remember { MutableInteractionSource() }) { onDismiss() },
|
||||||
contentAlignment = Alignment.Center,
|
contentAlignment = Alignment.Center,
|
||||||
) {
|
) {
|
||||||
AnimatedVisibility(visible = visible, enter = fadeIn() + scaleIn(initialScale = 0.95f), exit = fadeOut() + scaleOut(targetScale = 0.95f)) {
|
AnimatedVisibility(visible = visible, enter = fadeIn() + scaleIn(initialScale = 0.95f), exit = fadeOut() + scaleOut(targetScale = 0.95f)) {
|
||||||
MenuPanel(servers, activeServer, isGamepadMode, controllerStyle, onDismiss, onSelectServer, onAddServer, onEditServer, onRemoveServer, onToggleMode, onToggleStyle, onBackToWebView)
|
MenuPanel(servers, activeServer, onDismiss, onSelectServer, onAddServer, onScanQr, onEditServer, onRemoveServer, onRemote, onKeyboard, onBackToWebView, onMeshParty)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@ -98,16 +126,16 @@ fun NESMenu(
|
|||||||
private fun MenuPanel(
|
private fun MenuPanel(
|
||||||
servers: List<ServerEntry>,
|
servers: List<ServerEntry>,
|
||||||
activeServer: ServerEntry?,
|
activeServer: ServerEntry?,
|
||||||
isGamepadMode: Boolean,
|
|
||||||
controllerStyle: ControllerStyle,
|
|
||||||
onDismiss: () -> Unit,
|
onDismiss: () -> Unit,
|
||||||
onSelectServer: (ServerEntry) -> Unit,
|
onSelectServer: (ServerEntry) -> Unit,
|
||||||
onAddServer: (ServerEntry) -> Unit,
|
onAddServer: (ServerEntry) -> Unit,
|
||||||
|
onScanQr: (() -> Unit)?,
|
||||||
onEditServer: (ServerEntry, ServerEntry) -> Unit,
|
onEditServer: (ServerEntry, ServerEntry) -> Unit,
|
||||||
onRemoveServer: (ServerEntry) -> Unit,
|
onRemoveServer: (ServerEntry) -> Unit,
|
||||||
onToggleMode: () -> Unit,
|
onRemote: () -> Unit,
|
||||||
onToggleStyle: () -> Unit,
|
onKeyboard: () -> Unit,
|
||||||
onBackToWebView: (() -> Unit)?,
|
onBackToWebView: (() -> Unit)?,
|
||||||
|
onMeshParty: (() -> Unit)?,
|
||||||
) {
|
) {
|
||||||
var showAdd by remember { mutableStateOf(false) }
|
var showAdd by remember { mutableStateOf(false) }
|
||||||
// The saved server being edited, or null when adding a new one.
|
// The saved server being edited, or null when adding a new one.
|
||||||
@ -115,14 +143,15 @@ private fun MenuPanel(
|
|||||||
var nm by remember { mutableStateOf("") }
|
var nm by remember { mutableStateOf("") }
|
||||||
var addr by remember { mutableStateOf("") }
|
var addr by remember { mutableStateOf("") }
|
||||||
var pwd by remember { mutableStateOf("") }
|
var pwd by remember { mutableStateOf("") }
|
||||||
|
var https by remember { mutableStateOf(false) }
|
||||||
|
|
||||||
fun resetForm() {
|
fun resetForm() {
|
||||||
nm = ""; addr = ""; pwd = ""; showAdd = false; editing = null
|
nm = ""; addr = ""; pwd = ""; https = false; showAdd = false; editing = null
|
||||||
}
|
}
|
||||||
|
|
||||||
fun startEdit(server: ServerEntry) {
|
fun startEdit(server: ServerEntry) {
|
||||||
editing = server
|
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
|
showAdd = false
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -130,133 +159,381 @@ private fun MenuPanel(
|
|||||||
if (addr.isBlank()) return
|
if (addr.isBlank()) return
|
||||||
val orig = editing
|
val orig = editing
|
||||||
if (orig != null) {
|
if (orig != null) {
|
||||||
// Preserve fields the compact form doesn't expose (scheme, port).
|
// Preserve port (compact form doesn't expose it); scheme is now editable.
|
||||||
onEditServer(orig, orig.copy(address = addr, password = pwd, name = nm))
|
onEditServer(orig, orig.copy(address = addr, useHttps = https, password = pwd, name = nm))
|
||||||
} else {
|
} else {
|
||||||
onAddServer(ServerEntry(addr, false, password = pwd, name = nm))
|
onAddServer(ServerEntry(addr, https, password = pwd, name = nm))
|
||||||
}
|
}
|
||||||
resetForm()
|
resetForm()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
var page by remember { mutableStateOf(HubPage.HUB) }
|
||||||
|
|
||||||
Column(
|
Column(
|
||||||
modifier = Modifier
|
modifier = Modifier
|
||||||
.widthIn(max = 420.dp)
|
.widthIn(max = 420.dp)
|
||||||
|
.fillMaxWidth()
|
||||||
.padding(horizontal = 20.dp)
|
.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))
|
.clip(RoundedCornerShape(PANEL_R))
|
||||||
.background(PanelBg)
|
.background(PanelBg.copy(alpha = 0.86f))
|
||||||
.border(1.dp, PanelBorder, RoundedCornerShape(PANEL_R))
|
.border(1.dp, PanelBorder, RoundedCornerShape(PANEL_R))
|
||||||
.clickable(indication = null, interactionSource = remember { MutableInteractionSource() }) {}
|
.clickable(indication = null, interactionSource = remember { MutableInteractionSource() }) {}
|
||||||
.padding(22.dp),
|
.verticalScroll(rememberScrollState())
|
||||||
verticalArrangement = Arrangement.spacedBy(10.dp),
|
.padding(20.dp),
|
||||||
|
verticalArrangement = Arrangement.spacedBy(12.dp),
|
||||||
) {
|
) {
|
||||||
// Title
|
// Header: back (on sub-pages) or title, and a close on the hub.
|
||||||
Text(
|
Row(
|
||||||
"Menu",
|
Modifier.fillMaxWidth(),
|
||||||
color = TextPrimary,
|
verticalAlignment = Alignment.CenterVertically,
|
||||||
fontSize = 18.sp,
|
horizontalArrangement = Arrangement.SpaceBetween,
|
||||||
fontWeight = FontWeight.SemiBold,
|
) {
|
||||||
letterSpacing = 2.sp,
|
if (page == HubPage.HUB) {
|
||||||
modifier = Modifier.fillMaxWidth(),
|
Text("Menu", color = TextPrimary, fontSize = 20.sp, fontWeight = FontWeight.SemiBold, letterSpacing = 2.sp)
|
||||||
textAlign = TextAlign.Center,
|
IconRound(Icons.Default.Close, "Close") { onDismiss() }
|
||||||
)
|
} else {
|
||||||
Spacer(Modifier.height(2.dp))
|
Row(verticalAlignment = Alignment.CenterVertically) {
|
||||||
|
IconRound(Icons.AutoMirrored.Filled.ArrowBack, "Back") { resetForm(); page = HubPage.HUB }
|
||||||
|
Spacer(Modifier.width(12.dp))
|
||||||
|
Text(
|
||||||
|
if (page == HubPage.NODES) "Nodes" else "FIPS Mesh",
|
||||||
|
color = TextPrimary, fontSize = 20.sp, fontWeight = FontWeight.SemiBold, letterSpacing = 1.sp,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
IconRound(Icons.Default.Close, "Close") { onDismiss() }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Spacer(Modifier.height(4.dp))
|
||||||
|
|
||||||
// Servers
|
when (page) {
|
||||||
servers.forEach { server ->
|
HubPage.HUB -> {
|
||||||
val active = server.serialize() == activeServer?.serialize()
|
// Card page — one card per destination. Dashboard first: it's a
|
||||||
MenuItem(
|
// peer of the others so three-finger → hub → Dashboard returns
|
||||||
label = server.displayName(),
|
// to the node UI, same shape as every other option.
|
||||||
selected = active,
|
if (onBackToWebView != null) {
|
||||||
onClick = { onSelectServer(server) },
|
HubCard(Icons.Default.Dashboard, "Dashboard", "The node's web interface") { onBackToWebView() }
|
||||||
onEdit = { startEdit(server) },
|
}
|
||||||
onRemove = { onRemoveServer(server) },
|
HubCard(Icons.Default.SportsEsports, "Remote", "Game controller for the node") { onRemote() }
|
||||||
|
HubCard(Icons.Default.Keyboard, "Keyboard", "Type into the node") { onKeyboard() }
|
||||||
|
HubCard(Icons.Default.Dns, "Nodes", activeServer?.displayName() ?: "Add or switch servers") {
|
||||||
|
page = HubPage.NODES
|
||||||
|
}
|
||||||
|
if (FipsNative.available) {
|
||||||
|
HubCard(Icons.Default.Bolt, "FIPS Mesh", "Mesh identity & status") { page = HubPage.FIPS }
|
||||||
|
}
|
||||||
|
if (onMeshParty != null) {
|
||||||
|
HubCard(Icons.Default.Groups, "Mesh Party", "Phone-to-phone chat & beam") { onMeshParty() }
|
||||||
|
}
|
||||||
|
// Dark/Classic style lives on the remote/keyboard screen next to
|
||||||
|
// the settings button — not here.
|
||||||
|
}
|
||||||
|
|
||||||
|
HubPage.NODES -> {
|
||||||
|
servers.forEach { server ->
|
||||||
|
val active = server.serialize() == activeServer?.serialize()
|
||||||
|
MenuItem(
|
||||||
|
label = server.displayName(),
|
||||||
|
selected = active,
|
||||||
|
onClick = { onSelectServer(server) },
|
||||||
|
onEdit = { startEdit(server) },
|
||||||
|
onRemove = { onRemoveServer(server) },
|
||||||
|
)
|
||||||
|
}
|
||||||
|
if (servers.isEmpty()) {
|
||||||
|
Text("No servers", color = TextMuted, fontSize = 14.sp, modifier = Modifier.padding(vertical = 4.dp))
|
||||||
|
}
|
||||||
|
|
||||||
|
if (showAdd || editing != null) {
|
||||||
|
Column(
|
||||||
|
Modifier
|
||||||
|
.fillMaxWidth()
|
||||||
|
.clip(RoundedCornerShape(ROW_R))
|
||||||
|
.background(FieldBg)
|
||||||
|
.border(1.dp, RowBorder, RoundedCornerShape(ROW_R))
|
||||||
|
.padding(12.dp),
|
||||||
|
verticalArrangement = Arrangement.spacedBy(8.dp),
|
||||||
|
) {
|
||||||
|
Row(
|
||||||
|
Modifier.fillMaxWidth(),
|
||||||
|
verticalAlignment = Alignment.CenterVertically,
|
||||||
|
horizontalArrangement = Arrangement.SpaceBetween,
|
||||||
|
) {
|
||||||
|
Text(
|
||||||
|
if (editing != null) "Edit Server" else "Add Server",
|
||||||
|
color = TextMuted, fontSize = 13.sp, letterSpacing = 1.sp, fontWeight = FontWeight.Medium,
|
||||||
|
)
|
||||||
|
Text(
|
||||||
|
"Cancel", color = TextMuted, fontSize = 13.sp,
|
||||||
|
modifier = Modifier.clickable { resetForm() }.padding(start = 8.dp),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
GlassField(
|
||||||
|
value = nm, onValueChange = { nm = it },
|
||||||
|
placeholder = "Name (optional)",
|
||||||
|
keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Text, imeAction = ImeAction.Next),
|
||||||
|
)
|
||||||
|
GlassField(
|
||||||
|
value = addr, onValueChange = { addr = it.trim() },
|
||||||
|
placeholder = "192.168.1.100",
|
||||||
|
keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Uri, imeAction = ImeAction.Next),
|
||||||
|
)
|
||||||
|
Row(horizontalArrangement = Arrangement.spacedBy(8.dp), verticalAlignment = Alignment.CenterVertically) {
|
||||||
|
GlassField(
|
||||||
|
value = pwd, onValueChange = { pwd = it },
|
||||||
|
placeholder = "Password",
|
||||||
|
modifier = Modifier.weight(1f),
|
||||||
|
visualTransformation = PasswordVisualTransformation(),
|
||||||
|
keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Password, imeAction = ImeAction.Go),
|
||||||
|
keyboardActions = KeyboardActions(onGo = { submit() }),
|
||||||
|
)
|
||||||
|
Box(
|
||||||
|
Modifier.size(FIELD_H).clip(RoundedCornerShape(12.dp)).background(BitcoinOrange.copy(alpha = 0.15f))
|
||||||
|
.border(1.dp, BitcoinOrange.copy(alpha = 0.4f), RoundedCornerShape(12.dp))
|
||||||
|
.clickable { submit() },
|
||||||
|
contentAlignment = Alignment.Center,
|
||||||
|
) { Text("OK", color = BitcoinOrange, fontSize = 14.sp, fontWeight = FontWeight.Bold) }
|
||||||
|
}
|
||||||
|
// HTTPS scheme toggle (available on both add and edit).
|
||||||
|
Row(
|
||||||
|
Modifier
|
||||||
|
.fillMaxWidth()
|
||||||
|
.clip(RoundedCornerShape(ROW_R))
|
||||||
|
.clickable { https = !https }
|
||||||
|
.padding(vertical = 2.dp),
|
||||||
|
verticalAlignment = Alignment.CenterVertically,
|
||||||
|
horizontalArrangement = Arrangement.SpaceBetween,
|
||||||
|
) {
|
||||||
|
Text("Use HTTPS", color = TextMuted, fontSize = 13.sp)
|
||||||
|
Box(
|
||||||
|
Modifier
|
||||||
|
.width(46.dp).height(26.dp)
|
||||||
|
.clip(RoundedCornerShape(13.dp))
|
||||||
|
.background(if (https) BitcoinOrange.copy(alpha = 0.9f) else RowBg)
|
||||||
|
.border(1.dp, if (https) BitcoinOrange else RowBorder, RoundedCornerShape(13.dp)),
|
||||||
|
contentAlignment = if (https) Alignment.CenterEnd else Alignment.CenterStart,
|
||||||
|
) {
|
||||||
|
Box(
|
||||||
|
Modifier
|
||||||
|
.padding(horizontal = 3.dp)
|
||||||
|
.size(20.dp)
|
||||||
|
.clip(RoundedCornerShape(10.dp))
|
||||||
|
.background(if (https) Color.White else TextMuted),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
Row(Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.spacedBy(10.dp)) {
|
||||||
|
Box(Modifier.weight(1f)) {
|
||||||
|
MenuItem(label = "Add Server", labelColor = BitcoinOrange, onClick = { showAdd = true })
|
||||||
|
}
|
||||||
|
if (onScanQr != null) {
|
||||||
|
Box(
|
||||||
|
Modifier
|
||||||
|
.size(ROW_H)
|
||||||
|
.clip(RoundedCornerShape(ROW_R))
|
||||||
|
.background(RowBg)
|
||||||
|
.border(1.dp, RowBorder, RoundedCornerShape(ROW_R))
|
||||||
|
.clickable { onScanQr() },
|
||||||
|
contentAlignment = Alignment.Center,
|
||||||
|
) {
|
||||||
|
Icon(
|
||||||
|
Icons.Default.QrCodeScanner,
|
||||||
|
contentDescription = stringResource(R.string.add_server_qr),
|
||||||
|
tint = BitcoinOrange,
|
||||||
|
modifier = Modifier.size(24.dp),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
HubPage.FIPS -> {
|
||||||
|
FipsSection(embedded = true)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private enum class HubPage { HUB, NODES, FIPS }
|
||||||
|
|
||||||
|
/** Big tappable destination card for the hub page: icon + title + subtitle. */
|
||||||
|
@Composable
|
||||||
|
private fun HubCard(
|
||||||
|
icon: androidx.compose.ui.graphics.vector.ImageVector,
|
||||||
|
title: String,
|
||||||
|
subtitle: String,
|
||||||
|
onClick: () -> Unit,
|
||||||
|
) {
|
||||||
|
Row(
|
||||||
|
Modifier
|
||||||
|
.fillMaxWidth()
|
||||||
|
.clip(RoundedCornerShape(ROW_R))
|
||||||
|
.background(RowBg)
|
||||||
|
.border(1.dp, RowBorder, RoundedCornerShape(ROW_R))
|
||||||
|
.clickable { onClick() }
|
||||||
|
.padding(16.dp),
|
||||||
|
verticalAlignment = Alignment.CenterVertically,
|
||||||
|
horizontalArrangement = Arrangement.spacedBy(14.dp),
|
||||||
|
) {
|
||||||
|
Box(
|
||||||
|
Modifier.size(40.dp).clip(RoundedCornerShape(12.dp)).background(BitcoinOrange.copy(alpha = 0.14f)),
|
||||||
|
contentAlignment = Alignment.Center,
|
||||||
|
) {
|
||||||
|
Icon(icon, contentDescription = title, tint = BitcoinOrange, modifier = Modifier.size(22.dp))
|
||||||
|
}
|
||||||
|
Column(Modifier.weight(1f)) {
|
||||||
|
Text(title, color = TextPrimary, fontSize = 16.sp, fontWeight = FontWeight.SemiBold)
|
||||||
|
Text(subtitle, color = TextMuted, fontSize = 12.sp, maxLines = 1)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Small circular icon button used in the hub header. */
|
||||||
|
@Composable
|
||||||
|
private fun IconRound(
|
||||||
|
icon: androidx.compose.ui.graphics.vector.ImageVector,
|
||||||
|
desc: String,
|
||||||
|
onClick: () -> Unit,
|
||||||
|
) {
|
||||||
|
Box(
|
||||||
|
Modifier
|
||||||
|
.size(40.dp)
|
||||||
|
.clip(RoundedCornerShape(20.dp))
|
||||||
|
.background(RowBg)
|
||||||
|
.border(1.dp, RowBorder, RoundedCornerShape(20.dp))
|
||||||
|
.clickable { onClick() },
|
||||||
|
contentAlignment = Alignment.Center,
|
||||||
|
) {
|
||||||
|
Icon(icon, contentDescription = desc, tint = TextPrimary, modifier = Modifier.size(20.dp))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Snapshot of the phone's mesh identity + state for the FIPS menu section. */
|
||||||
|
private data class FipsInfo(
|
||||||
|
val available: Boolean,
|
||||||
|
val running: Boolean,
|
||||||
|
val npub: String,
|
||||||
|
val meshAddress: String,
|
||||||
|
val peerCount: Int,
|
||||||
|
)
|
||||||
|
|
||||||
|
/**
|
||||||
|
* FIPS mesh oversight: shows what the phone's embedded mesh node is doing —
|
||||||
|
* running state, its mesh identity (npub), its mesh address (fd…ULA), how
|
||||||
|
* many peers/anchors it's configured with — and a one-tap Reconnect that
|
||||||
|
* re-homes the mesh (also the manual fix if a network handoff ever misses).
|
||||||
|
* Collapsed by default so the menu stays compact.
|
||||||
|
*/
|
||||||
|
@Composable
|
||||||
|
private fun FipsSection(embedded: Boolean = false) {
|
||||||
|
if (!FipsNative.available) return
|
||||||
|
val context = LocalContext.current
|
||||||
|
val clipboard = LocalClipboardManager.current
|
||||||
|
var expanded by remember { mutableStateOf(embedded) }
|
||||||
|
var info by remember { mutableStateOf<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,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
if (servers.isEmpty()) {
|
Column(Modifier.fillMaxWidth()) {
|
||||||
Text("No servers", color = TextMuted, fontSize = 14.sp, modifier = Modifier.padding(vertical = 4.dp))
|
// 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(
|
||||||
// Add / edit server
|
label = "FIPS Mesh",
|
||||||
if (showAdd || editing != null) {
|
labelColor = BitcoinOrange,
|
||||||
|
onClick = { expanded = !expanded },
|
||||||
|
)
|
||||||
|
if (expanded) {
|
||||||
|
val i = info
|
||||||
Column(
|
Column(
|
||||||
Modifier
|
Modifier
|
||||||
.fillMaxWidth()
|
.fillMaxWidth()
|
||||||
|
.padding(top = 6.dp)
|
||||||
.clip(RoundedCornerShape(ROW_R))
|
.clip(RoundedCornerShape(ROW_R))
|
||||||
.background(FieldBg)
|
.background(FieldBg)
|
||||||
.border(1.dp, RowBorder, RoundedCornerShape(ROW_R))
|
.border(1.dp, RowBorder, RoundedCornerShape(ROW_R))
|
||||||
.padding(12.dp),
|
.padding(14.dp),
|
||||||
verticalArrangement = Arrangement.spacedBy(8.dp),
|
verticalArrangement = Arrangement.spacedBy(10.dp),
|
||||||
) {
|
) {
|
||||||
Row(
|
if (i == null) {
|
||||||
Modifier.fillMaxWidth(),
|
Text("Loading…", color = TextMuted, fontSize = 13.sp)
|
||||||
verticalAlignment = Alignment.CenterVertically,
|
} else {
|
||||||
horizontalArrangement = Arrangement.SpaceBetween,
|
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(
|
Text(
|
||||||
if (editing != null) "Edit Server" else "Add Server",
|
"Your node reaches this phone over the mesh by its npub — no ports opened to the internet.",
|
||||||
color = TextMuted,
|
color = TextMuted, fontSize = 11.sp,
|
||||||
fontSize = 13.sp,
|
|
||||||
letterSpacing = 1.sp,
|
|
||||||
fontWeight = FontWeight.Medium,
|
|
||||||
)
|
)
|
||||||
Text(
|
MenuItem(
|
||||||
"Cancel",
|
label = "Reconnect mesh",
|
||||||
color = TextMuted,
|
labelColor = BitcoinOrange,
|
||||||
fontSize = 13.sp,
|
onClick = {
|
||||||
modifier = Modifier.clickable { resetForm() }.padding(start = 8.dp),
|
FipsManager.requestMeshRestart(context)
|
||||||
|
info = null
|
||||||
|
if (!embedded) expanded = false
|
||||||
|
},
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
GlassField(
|
|
||||||
value = nm, onValueChange = { nm = it },
|
|
||||||
placeholder = "Name (optional)",
|
|
||||||
keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Text, imeAction = ImeAction.Next),
|
|
||||||
)
|
|
||||||
GlassField(
|
|
||||||
value = addr, onValueChange = { addr = it.trim() },
|
|
||||||
placeholder = "192.168.1.100",
|
|
||||||
keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Uri, imeAction = ImeAction.Next),
|
|
||||||
)
|
|
||||||
Row(horizontalArrangement = Arrangement.spacedBy(8.dp), verticalAlignment = Alignment.CenterVertically) {
|
|
||||||
GlassField(
|
|
||||||
value = pwd, onValueChange = { pwd = it },
|
|
||||||
placeholder = "Password",
|
|
||||||
modifier = Modifier.weight(1f),
|
|
||||||
visualTransformation = PasswordVisualTransformation(),
|
|
||||||
keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Password, imeAction = ImeAction.Go),
|
|
||||||
keyboardActions = KeyboardActions(onGo = { submit() }),
|
|
||||||
)
|
|
||||||
Box(
|
|
||||||
Modifier.size(FIELD_H).clip(RoundedCornerShape(12.dp)).background(BitcoinOrange.copy(alpha = 0.15f))
|
|
||||||
.border(1.dp, BitcoinOrange.copy(alpha = 0.4f), RoundedCornerShape(12.dp))
|
|
||||||
.clickable { submit() },
|
|
||||||
contentAlignment = Alignment.Center,
|
|
||||||
) { Text("OK", color = BitcoinOrange, fontSize = 14.sp, fontWeight = FontWeight.Bold) }
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
} else {
|
|
||||||
MenuItem(label = "Add Server", labelColor = BitcoinOrange, onClick = { showAdd = true })
|
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
Spacer(Modifier.height(2.dp))
|
@Composable
|
||||||
Box(Modifier.fillMaxWidth().height(1.dp).background(PanelBorder))
|
private fun FipsRow(
|
||||||
Spacer(Modifier.height(2.dp))
|
label: String,
|
||||||
|
value: String,
|
||||||
// Mode toggle
|
valueColor: Color = TextPrimary,
|
||||||
MenuItem(
|
mono: Boolean = false,
|
||||||
label = if (isGamepadMode) "Switch to Keyboard" else "Switch to Gamepad",
|
onCopy: (() -> Unit)? = null,
|
||||||
onClick = onToggleMode,
|
) {
|
||||||
|
Row(
|
||||||
|
Modifier
|
||||||
|
.fillMaxWidth()
|
||||||
|
.then(if (onCopy != null) Modifier.clickable { onCopy() } else Modifier),
|
||||||
|
verticalAlignment = Alignment.CenterVertically,
|
||||||
|
horizontalArrangement = Arrangement.SpaceBetween,
|
||||||
|
) {
|
||||||
|
Text(label, color = TextMuted, fontSize = 12.sp, modifier = Modifier.width(120.dp))
|
||||||
|
Text(
|
||||||
|
value,
|
||||||
|
color = valueColor,
|
||||||
|
fontSize = if (mono) 11.sp else 13.sp,
|
||||||
|
fontWeight = FontWeight.Medium,
|
||||||
|
modifier = Modifier.weight(1f),
|
||||||
|
textAlign = TextAlign.End,
|
||||||
)
|
)
|
||||||
|
if (onCopy != null) {
|
||||||
// Style toggle
|
Text("⧉", color = TextMuted, fontSize = 13.sp, modifier = Modifier.padding(start = 8.dp))
|
||||||
MenuItem(
|
|
||||||
label = if (controllerStyle == ControllerStyle.CLASSIC) "Style: Classic" else "Style: Dark",
|
|
||||||
onClick = onToggleStyle,
|
|
||||||
)
|
|
||||||
|
|
||||||
// Back to dashboard
|
|
||||||
if (onBackToWebView != null) {
|
|
||||||
MenuItem(label = "Back to Dashboard", onClick = onBackToWebView)
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -43,6 +43,7 @@ fun NESPortraitController(
|
|||||||
onMouseScroll: (Int) -> Unit = { _ -> },
|
onMouseScroll: (Int) -> Unit = { _ -> },
|
||||||
onMenu: () -> Unit,
|
onMenu: () -> Unit,
|
||||||
onPlayerToggle: () -> Unit = {},
|
onPlayerToggle: () -> Unit = {},
|
||||||
|
onToggleStyle: (() -> Unit)? = null,
|
||||||
) {
|
) {
|
||||||
val c = paletteFor(style)
|
val c = paletteFor(style)
|
||||||
val isClassic = style == ControllerStyle.CLASSIC
|
val isClassic = style == ControllerStyle.CLASSIC
|
||||||
@ -50,7 +51,7 @@ fun NESPortraitController(
|
|||||||
Box(
|
Box(
|
||||||
Modifier
|
Modifier
|
||||||
.fillMaxSize()
|
.fillMaxSize()
|
||||||
.twoFingerHold(onMenu)
|
.threeFingerHold(onMenu)
|
||||||
.padding(horizontal = 40.dp, vertical = 24.dp),
|
.padding(horizontal = 40.dp, vertical = 24.dp),
|
||||||
contentAlignment = Alignment.Center,
|
contentAlignment = Alignment.Center,
|
||||||
) {
|
) {
|
||||||
@ -87,7 +88,7 @@ fun NESPortraitController(
|
|||||||
onMove = { dx, dy -> onMouseMove(dx, dy) },
|
onMove = { dx, dy -> onMouseMove(dx, dy) },
|
||||||
onClick = { onMouseClick(it) },
|
onClick = { onMouseClick(it) },
|
||||||
onScroll = { dy -> onMouseScroll(dy) },
|
onScroll = { dy -> onMouseScroll(dy) },
|
||||||
onTwoFingerHold = onMenu,
|
onThreeFingerHold = onMenu,
|
||||||
modifier = Modifier
|
modifier = Modifier
|
||||||
.fillMaxWidth()
|
.fillMaxWidth()
|
||||||
.weight(1f),
|
.weight(1f),
|
||||||
@ -151,6 +152,10 @@ fun NESPortraitController(
|
|||||||
PlayerPill(c, playerId, onPlayerToggle)
|
PlayerPill(c, playerId, onPlayerToggle)
|
||||||
Spacer(Modifier.width(10.dp))
|
Spacer(Modifier.width(10.dp))
|
||||||
SettingsBtn(c, Modifier, onMenu)
|
SettingsBtn(c, Modifier, onMenu)
|
||||||
|
onToggleStyle?.let {
|
||||||
|
Spacer(Modifier.width(10.dp))
|
||||||
|
StyleBtn(c, Modifier, it)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -0,0 +1,352 @@
|
|||||||
|
package com.archipelago.app.ui.components
|
||||||
|
|
||||||
|
import android.Manifest
|
||||||
|
import android.content.pm.PackageManager
|
||||||
|
import androidx.activity.compose.BackHandler
|
||||||
|
import androidx.activity.compose.rememberLauncherForActivityResult
|
||||||
|
import androidx.activity.result.contract.ActivityResultContracts
|
||||||
|
import androidx.camera.core.CameraSelector
|
||||||
|
import androidx.camera.core.ImageAnalysis
|
||||||
|
import androidx.camera.core.ImageProxy
|
||||||
|
import androidx.camera.core.Preview
|
||||||
|
import androidx.camera.lifecycle.ProcessCameraProvider
|
||||||
|
import androidx.camera.view.PreviewView
|
||||||
|
import androidx.compose.animation.AnimatedVisibility
|
||||||
|
import androidx.compose.animation.fadeIn
|
||||||
|
import androidx.compose.animation.fadeOut
|
||||||
|
import androidx.compose.foundation.background
|
||||||
|
import androidx.compose.foundation.border
|
||||||
|
import androidx.compose.foundation.layout.Arrangement
|
||||||
|
import androidx.compose.foundation.layout.Box
|
||||||
|
import androidx.compose.foundation.layout.Column
|
||||||
|
import androidx.compose.foundation.layout.Row
|
||||||
|
import androidx.compose.foundation.layout.Spacer
|
||||||
|
import androidx.compose.foundation.layout.WindowInsets
|
||||||
|
import androidx.compose.foundation.layout.fillMaxSize
|
||||||
|
import androidx.compose.foundation.layout.fillMaxWidth
|
||||||
|
import androidx.compose.foundation.layout.height
|
||||||
|
import androidx.compose.foundation.layout.padding
|
||||||
|
import androidx.compose.foundation.layout.safeDrawing
|
||||||
|
import androidx.compose.foundation.layout.size
|
||||||
|
import androidx.compose.foundation.layout.windowInsetsPadding
|
||||||
|
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||||
|
import androidx.compose.material.icons.Icons
|
||||||
|
import androidx.compose.material.icons.filled.Close
|
||||||
|
import androidx.compose.material3.Icon
|
||||||
|
import androidx.compose.material3.IconButton
|
||||||
|
import androidx.compose.material3.MaterialTheme
|
||||||
|
import androidx.compose.material3.Text
|
||||||
|
import androidx.compose.runtime.Composable
|
||||||
|
import androidx.compose.runtime.DisposableEffect
|
||||||
|
import androidx.compose.runtime.LaunchedEffect
|
||||||
|
import androidx.compose.runtime.getValue
|
||||||
|
import androidx.compose.runtime.mutableStateOf
|
||||||
|
import androidx.compose.runtime.remember
|
||||||
|
import androidx.compose.runtime.rememberUpdatedState
|
||||||
|
import androidx.compose.runtime.setValue
|
||||||
|
import androidx.compose.ui.Alignment
|
||||||
|
import androidx.compose.ui.Modifier
|
||||||
|
import androidx.compose.ui.graphics.Color
|
||||||
|
import androidx.compose.ui.platform.LocalContext
|
||||||
|
import androidx.compose.ui.platform.LocalLifecycleOwner
|
||||||
|
import androidx.compose.ui.res.stringResource
|
||||||
|
import androidx.compose.ui.text.style.TextAlign
|
||||||
|
import androidx.compose.ui.unit.dp
|
||||||
|
import androidx.compose.ui.viewinterop.AndroidView
|
||||||
|
import androidx.core.content.ContextCompat
|
||||||
|
import com.archipelago.app.R
|
||||||
|
import com.archipelago.app.data.PairResult
|
||||||
|
import com.archipelago.app.data.ServerQrParser
|
||||||
|
import com.archipelago.app.ui.screens.GlassButton
|
||||||
|
import com.archipelago.app.ui.theme.BitcoinOrange
|
||||||
|
import com.archipelago.app.ui.theme.TextMuted
|
||||||
|
import com.archipelago.app.ui.theme.TextPrimary
|
||||||
|
import com.google.zxing.BarcodeFormat
|
||||||
|
import com.google.zxing.BinaryBitmap
|
||||||
|
import com.google.zxing.DecodeHintType
|
||||||
|
import com.google.zxing.MultiFormatReader
|
||||||
|
import com.google.zxing.NotFoundException
|
||||||
|
import com.google.zxing.PlanarYUVLuminanceSource
|
||||||
|
import com.google.zxing.common.HybridBinarizer
|
||||||
|
import kotlinx.coroutines.delay
|
||||||
|
import java.util.concurrent.Executors
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Full-screen camera overlay that scans the node pairing QR
|
||||||
|
* (docs/companion-pairing-qr.md) and reports the decoded server entry.
|
||||||
|
* Handles the camera permission itself; foreign/invalid codes show a hint
|
||||||
|
* and scanning continues.
|
||||||
|
*/
|
||||||
|
@Composable
|
||||||
|
fun QrScannerOverlay(
|
||||||
|
visible: Boolean,
|
||||||
|
onDismiss: () -> Unit,
|
||||||
|
onServerScanned: (PairResult.Success) -> Unit,
|
||||||
|
) {
|
||||||
|
val context = LocalContext.current
|
||||||
|
var hasPermission by remember {
|
||||||
|
mutableStateOf(
|
||||||
|
ContextCompat.checkSelfPermission(context, Manifest.permission.CAMERA) ==
|
||||||
|
PackageManager.PERMISSION_GRANTED
|
||||||
|
)
|
||||||
|
}
|
||||||
|
var hintRes by remember { mutableStateOf<Int?>(null) }
|
||||||
|
var handled by remember { mutableStateOf(false) }
|
||||||
|
|
||||||
|
val permissionLauncher = rememberLauncherForActivityResult(
|
||||||
|
ActivityResultContracts.RequestPermission()
|
||||||
|
) { granted -> hasPermission = granted }
|
||||||
|
|
||||||
|
LaunchedEffect(visible) {
|
||||||
|
if (visible) {
|
||||||
|
handled = false
|
||||||
|
hintRes = null
|
||||||
|
val granted = ContextCompat.checkSelfPermission(context, Manifest.permission.CAMERA) ==
|
||||||
|
PackageManager.PERMISSION_GRANTED
|
||||||
|
hasPermission = granted
|
||||||
|
if (!granted) permissionLauncher.launch(Manifest.permission.CAMERA)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Foreign-code hint fades after a moment so scanning feels live again.
|
||||||
|
LaunchedEffect(hintRes) {
|
||||||
|
if (hintRes != null) {
|
||||||
|
delay(2500)
|
||||||
|
hintRes = null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
AnimatedVisibility(visible = visible, enter = fadeIn(), exit = fadeOut()) {
|
||||||
|
BackHandler { onDismiss() }
|
||||||
|
Box(
|
||||||
|
Modifier
|
||||||
|
.fillMaxSize()
|
||||||
|
.background(Color.Black),
|
||||||
|
) {
|
||||||
|
if (hasPermission) {
|
||||||
|
CameraQrPreview(
|
||||||
|
onDecoded = { text ->
|
||||||
|
if (!handled) {
|
||||||
|
when (val result = ServerQrParser.parse(text)) {
|
||||||
|
is PairResult.Success -> {
|
||||||
|
handled = true
|
||||||
|
onServerScanned(result)
|
||||||
|
}
|
||||||
|
is PairResult.UnsupportedVersion -> hintRes = R.string.update_app_for_qr
|
||||||
|
is PairResult.Invalid -> hintRes = R.string.invalid_pairing_qr
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
)
|
||||||
|
// Aim frame
|
||||||
|
Box(
|
||||||
|
Modifier
|
||||||
|
.align(Alignment.Center)
|
||||||
|
.size(260.dp)
|
||||||
|
.border(2.dp, BitcoinOrange.copy(alpha = 0.85f), RoundedCornerShape(20.dp)),
|
||||||
|
)
|
||||||
|
} else {
|
||||||
|
Column(
|
||||||
|
Modifier
|
||||||
|
.align(Alignment.Center)
|
||||||
|
.padding(horizontal = 32.dp),
|
||||||
|
horizontalAlignment = Alignment.CenterHorizontally,
|
||||||
|
verticalArrangement = Arrangement.spacedBy(16.dp),
|
||||||
|
) {
|
||||||
|
Text(
|
||||||
|
text = stringResource(R.string.camera_permission_needed),
|
||||||
|
color = TextPrimary,
|
||||||
|
style = MaterialTheme.typography.bodyLarge,
|
||||||
|
textAlign = TextAlign.Center,
|
||||||
|
)
|
||||||
|
GlassButton(
|
||||||
|
text = stringResource(R.string.grant_camera_access),
|
||||||
|
onClick = { permissionLauncher.launch(Manifest.permission.CAMERA) },
|
||||||
|
modifier = Modifier.fillMaxWidth().height(56.dp),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Top bar: title + close
|
||||||
|
Row(
|
||||||
|
Modifier
|
||||||
|
.fillMaxWidth()
|
||||||
|
.windowInsetsPadding(WindowInsets.safeDrawing)
|
||||||
|
.padding(horizontal = 8.dp, vertical = 4.dp),
|
||||||
|
verticalAlignment = Alignment.CenterVertically,
|
||||||
|
horizontalArrangement = Arrangement.SpaceBetween,
|
||||||
|
) {
|
||||||
|
Text(
|
||||||
|
text = stringResource(R.string.scan_node_qr),
|
||||||
|
color = TextPrimary,
|
||||||
|
style = MaterialTheme.typography.titleMedium,
|
||||||
|
modifier = Modifier.padding(start = 12.dp),
|
||||||
|
)
|
||||||
|
IconButton(onClick = onDismiss) {
|
||||||
|
Icon(Icons.Default.Close, stringResource(R.string.close), tint = TextPrimary)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Bottom hints
|
||||||
|
Column(
|
||||||
|
Modifier
|
||||||
|
.align(Alignment.BottomCenter)
|
||||||
|
.windowInsetsPadding(WindowInsets.safeDrawing)
|
||||||
|
.padding(horizontal = 32.dp, vertical = 24.dp),
|
||||||
|
horizontalAlignment = Alignment.CenterHorizontally,
|
||||||
|
) {
|
||||||
|
hintRes?.let { res ->
|
||||||
|
Text(
|
||||||
|
text = stringResource(res),
|
||||||
|
color = BitcoinOrange,
|
||||||
|
style = MaterialTheme.typography.bodyMedium,
|
||||||
|
textAlign = TextAlign.Center,
|
||||||
|
)
|
||||||
|
Spacer(Modifier.height(8.dp))
|
||||||
|
}
|
||||||
|
if (hasPermission) {
|
||||||
|
Text(
|
||||||
|
text = stringResource(R.string.scan_qr_hint),
|
||||||
|
color = TextMuted,
|
||||||
|
style = MaterialTheme.typography.bodyMedium,
|
||||||
|
textAlign = TextAlign.Center,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Shared by the pairing scanner and the wallet scan modal. */
|
||||||
|
@Composable
|
||||||
|
internal fun CameraQrPreview(onDecoded: (String) -> Unit) {
|
||||||
|
val context = LocalContext.current
|
||||||
|
val lifecycleOwner = LocalLifecycleOwner.current
|
||||||
|
val currentOnDecoded by rememberUpdatedState(onDecoded)
|
||||||
|
val previewView = remember {
|
||||||
|
PreviewView(context).apply {
|
||||||
|
scaleType = PreviewView.ScaleType.FILL_CENTER
|
||||||
|
// TextureView, not the SurfaceView default: SurfaceView punches a
|
||||||
|
// hole in the window, which black-flashes inside Compose fades and
|
||||||
|
// ignores rounded-corner clipping (wallet modal).
|
||||||
|
implementationMode = PreviewView.ImplementationMode.COMPATIBLE
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
DisposableEffect(Unit) {
|
||||||
|
val analysisExecutor = Executors.newSingleThreadExecutor()
|
||||||
|
val mainExecutor = ContextCompat.getMainExecutor(context)
|
||||||
|
val providerFuture = ProcessCameraProvider.getInstance(context)
|
||||||
|
var provider: ProcessCameraProvider? = null
|
||||||
|
val focusScheduler = Executors.newSingleThreadScheduledExecutor()
|
||||||
|
|
||||||
|
providerFuture.addListener({
|
||||||
|
val p = providerFuture.get()
|
||||||
|
provider = p
|
||||||
|
val preview = Preview.Builder().build().also {
|
||||||
|
it.setSurfaceProvider(previewView.surfaceProvider)
|
||||||
|
}
|
||||||
|
// Dense Lightning-invoice QRs need BOTH enough pixels per module and
|
||||||
|
// sharp focus. 1280x720 + a far-focused camera (e.g. Pixel 9a's main
|
||||||
|
// lens, which won't focus close) left dense invoices undecodable
|
||||||
|
// while sparse address QRs still read — the "scanner doesn't pick up
|
||||||
|
// invoices" report. 1920x1080 roughly doubles module resolution so a
|
||||||
|
// QR held at the camera's actual focus distance still resolves.
|
||||||
|
@Suppress("DEPRECATION")
|
||||||
|
val analysis = ImageAnalysis.Builder()
|
||||||
|
.setTargetResolution(android.util.Size(1920, 1080))
|
||||||
|
.setBackpressureStrategy(ImageAnalysis.STRATEGY_KEEP_ONLY_LATEST)
|
||||||
|
.build()
|
||||||
|
.also {
|
||||||
|
it.setAnalyzer(
|
||||||
|
analysisExecutor,
|
||||||
|
QrCodeAnalyzer { text -> mainExecutor.execute { currentOnDecoded(text) } },
|
||||||
|
)
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
p.unbindAll()
|
||||||
|
val cam = p.bindToLifecycle(lifecycleOwner, CameraSelector.DEFAULT_BACK_CAMERA, preview, analysis)
|
||||||
|
// Force a centre autofocus on a repeating tick. A hand-held QR is
|
||||||
|
// a static scene, so continuous-AF often never retriggers and the
|
||||||
|
// lens sits at its resting (far) focus — fatal for dense codes.
|
||||||
|
// A normalized centre point works before the view is measured.
|
||||||
|
val point = androidx.camera.core.SurfaceOrientedMeteringPointFactory(1f, 1f)
|
||||||
|
.createPoint(0.5f, 0.5f)
|
||||||
|
val focusAction = androidx.camera.core.FocusMeteringAction.Builder(
|
||||||
|
point,
|
||||||
|
androidx.camera.core.FocusMeteringAction.FLAG_AF,
|
||||||
|
).disableAutoCancel().build()
|
||||||
|
focusScheduler.scheduleWithFixedDelay({
|
||||||
|
runCatching { cam.cameraControl.startFocusAndMetering(focusAction) }
|
||||||
|
}, 0, 2, java.util.concurrent.TimeUnit.SECONDS)
|
||||||
|
} catch (_: Exception) {
|
||||||
|
// Camera unavailable — the user can dismiss and enter details manually.
|
||||||
|
}
|
||||||
|
}, mainExecutor)
|
||||||
|
|
||||||
|
onDispose {
|
||||||
|
focusScheduler.shutdownNow()
|
||||||
|
provider?.unbindAll()
|
||||||
|
analysisExecutor.shutdown()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
AndroidView(factory = { previewView }, modifier = Modifier.fillMaxSize())
|
||||||
|
}
|
||||||
|
|
||||||
|
/** ZXing-based QR decoder over the camera's Y (luminance) plane. */
|
||||||
|
private class QrCodeAnalyzer(private val onDecoded: (String) -> Unit) : ImageAnalysis.Analyzer {
|
||||||
|
private val reader = MultiFormatReader().apply {
|
||||||
|
setHints(
|
||||||
|
mapOf(
|
||||||
|
DecodeHintType.POSSIBLE_FORMATS to listOf(BarcodeFormat.QR_CODE),
|
||||||
|
// Screen-displayed QRs come with moiré, glare, and soft focus at
|
||||||
|
// close range — the exhaustive search is worth the milliseconds.
|
||||||
|
DecodeHintType.TRY_HARDER to true,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
private var lastAttempt = 0L
|
||||||
|
|
||||||
|
override fun analyze(image: ImageProxy) {
|
||||||
|
// Decode ~7x/s, not on every frame: TRY_HARDER (plus the inverted
|
||||||
|
// retry) pegs a core when run at camera rate, and that CPU contention
|
||||||
|
// is what made the preview itself stutter. KEEP_ONLY_LATEST means the
|
||||||
|
// frames skipped here are simply dropped, so decodes stay current.
|
||||||
|
val now = System.currentTimeMillis()
|
||||||
|
if (now - lastAttempt < 140) {
|
||||||
|
image.close()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
lastAttempt = now
|
||||||
|
try {
|
||||||
|
val plane = image.planes[0]
|
||||||
|
val buffer = plane.buffer
|
||||||
|
// Copy into a rowStride-wide array; the last row of the plane buffer
|
||||||
|
// may be short of the full stride, so the tail stays zero-padded.
|
||||||
|
val data = ByteArray(plane.rowStride * image.height)
|
||||||
|
buffer.get(data, 0, minOf(buffer.remaining(), data.size))
|
||||||
|
val source = PlanarYUVLuminanceSource(
|
||||||
|
data, plane.rowStride, image.height,
|
||||||
|
0, 0, image.width, image.height,
|
||||||
|
false,
|
||||||
|
)
|
||||||
|
val result = try {
|
||||||
|
reader.decodeWithState(BinaryBitmap(HybridBinarizer(source)))
|
||||||
|
} catch (_: NotFoundException) {
|
||||||
|
// Dark-themed pages can render light-on-dark QRs — retry inverted.
|
||||||
|
reader.reset()
|
||||||
|
reader.decodeWithState(BinaryBitmap(HybridBinarizer(source.invert())))
|
||||||
|
}
|
||||||
|
onDecoded(result.text)
|
||||||
|
} catch (_: NotFoundException) {
|
||||||
|
// No QR in this frame — keep scanning.
|
||||||
|
} catch (_: Exception) {
|
||||||
|
// Malformed frame; skip it.
|
||||||
|
} finally {
|
||||||
|
reader.reset()
|
||||||
|
image.close()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -32,7 +32,7 @@ fun Trackpad(
|
|||||||
onMove: (dx: Int, dy: Int) -> Unit,
|
onMove: (dx: Int, dy: Int) -> Unit,
|
||||||
onClick: (button: Int) -> Unit,
|
onClick: (button: Int) -> Unit,
|
||||||
onScroll: (dy: Int) -> Unit,
|
onScroll: (dy: Int) -> Unit,
|
||||||
onTwoFingerHold: () -> Unit,
|
onThreeFingerHold: () -> Unit,
|
||||||
modifier: Modifier = Modifier,
|
modifier: Modifier = Modifier,
|
||||||
) {
|
) {
|
||||||
var fingers by remember { mutableIntStateOf(0) }
|
var fingers by remember { mutableIntStateOf(0) }
|
||||||
@ -53,7 +53,7 @@ fun Trackpad(
|
|||||||
val t0 = System.currentTimeMillis()
|
val t0 = System.currentTimeMillis()
|
||||||
var maxPtrs = 1
|
var maxPtrs = 1
|
||||||
var holdFired = false
|
var holdFired = false
|
||||||
var twoStart = 0L
|
var threeStart = 0L
|
||||||
var scrollAcc = 0f
|
var scrollAcc = 0f
|
||||||
fingers = 1
|
fingers = 1
|
||||||
|
|
||||||
@ -64,19 +64,24 @@ fun Trackpad(
|
|||||||
fingers = active.size
|
fingers = active.size
|
||||||
|
|
||||||
when {
|
when {
|
||||||
active.size >= 2 -> {
|
// Three fingers = hold for menu; two = scroll. Kept
|
||||||
if (twoStart == 0L) twoStart = System.currentTimeMillis()
|
// on separate counts so a long two-finger scroll can
|
||||||
if (!holdFired && System.currentTimeMillis() - twoStart > 500) {
|
// never fire the menu mid-gesture.
|
||||||
|
active.size >= 3 -> {
|
||||||
|
if (threeStart == 0L) threeStart = System.currentTimeMillis()
|
||||||
|
if (!holdFired && System.currentTimeMillis() - threeStart > 500) {
|
||||||
holdFired = true
|
holdFired = true
|
||||||
onTwoFingerHold()
|
onThreeFingerHold()
|
||||||
}
|
}
|
||||||
if (!holdFired) {
|
ev.changes.forEach { it.consume() }
|
||||||
val dy = active.map { it.positionChange().y }.average().toFloat()
|
}
|
||||||
scrollAcc += dy
|
active.size == 2 -> {
|
||||||
if (kotlin.math.abs(scrollAcc) > 12f) {
|
threeStart = 0L
|
||||||
onScroll(if (scrollAcc > 0) 1 else -1)
|
val dy = active.map { it.positionChange().y }.average().toFloat()
|
||||||
scrollAcc = 0f
|
scrollAcc += dy
|
||||||
}
|
if (kotlin.math.abs(scrollAcc) > 12f) {
|
||||||
|
onScroll(if (scrollAcc > 0) 1 else -1)
|
||||||
|
scrollAcc = 0f
|
||||||
}
|
}
|
||||||
ev.changes.forEach { it.consume() }
|
ev.changes.forEach { it.consume() }
|
||||||
}
|
}
|
||||||
@ -99,7 +104,11 @@ fun Trackpad(
|
|||||||
contentAlignment = Alignment.Center,
|
contentAlignment = Alignment.Center,
|
||||||
) {
|
) {
|
||||||
Text(
|
Text(
|
||||||
text = if (fingers >= 2) "hold for menu" else "",
|
text = when {
|
||||||
|
fingers >= 3 -> "hold for menu"
|
||||||
|
fingers == 2 -> "scroll"
|
||||||
|
else -> ""
|
||||||
|
},
|
||||||
style = MaterialTheme.typography.labelSmall,
|
style = MaterialTheme.typography.labelSmall,
|
||||||
color = muted.copy(alpha = 0.4f),
|
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,16 +1,30 @@
|
|||||||
package com.archipelago.app.ui.navigation
|
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.Composable
|
||||||
|
import androidx.compose.runtime.LaunchedEffect
|
||||||
import androidx.compose.runtime.collectAsState
|
import androidx.compose.runtime.collectAsState
|
||||||
import androidx.compose.runtime.getValue
|
import androidx.compose.runtime.getValue
|
||||||
|
import androidx.compose.runtime.mutableStateOf
|
||||||
import androidx.compose.runtime.remember
|
import androidx.compose.runtime.remember
|
||||||
import androidx.compose.runtime.rememberCoroutineScope
|
import androidx.compose.runtime.rememberCoroutineScope
|
||||||
|
import androidx.compose.runtime.setValue
|
||||||
import androidx.compose.ui.platform.LocalContext
|
import androidx.compose.ui.platform.LocalContext
|
||||||
|
import androidx.navigation.NavType
|
||||||
import androidx.navigation.compose.NavHost
|
import androidx.navigation.compose.NavHost
|
||||||
import androidx.navigation.compose.composable
|
import androidx.navigation.compose.composable
|
||||||
import androidx.navigation.compose.rememberNavController
|
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.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.IntroScreen
|
||||||
|
import com.archipelago.app.ui.screens.PartyScreen
|
||||||
import com.archipelago.app.ui.screens.RemoteInputScreen
|
import com.archipelago.app.ui.screens.RemoteInputScreen
|
||||||
import com.archipelago.app.ui.screens.ServerConnectScreen
|
import com.archipelago.app.ui.screens.ServerConnectScreen
|
||||||
import com.archipelago.app.ui.screens.WebViewScreen
|
import com.archipelago.app.ui.screens.WebViewScreen
|
||||||
@ -21,10 +35,15 @@ object Routes {
|
|||||||
const val SERVER_CONNECT = "server_connect"
|
const val SERVER_CONNECT = "server_connect"
|
||||||
const val WEB_VIEW = "web_view"
|
const val WEB_VIEW = "web_view"
|
||||||
const val REMOTE_INPUT = "remote_input"
|
const val REMOTE_INPUT = "remote_input"
|
||||||
|
const val MESH_PARTY = "mesh_party"
|
||||||
|
const val FLARE = "flare"
|
||||||
}
|
}
|
||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
fun AppNavHost() {
|
fun AppNavHost(
|
||||||
|
pairUri: String? = null,
|
||||||
|
onPairUriConsumed: () -> Unit = {},
|
||||||
|
) {
|
||||||
val context = LocalContext.current
|
val context = LocalContext.current
|
||||||
val prefs = remember { ServerPreferences(context) }
|
val prefs = remember { ServerPreferences(context) }
|
||||||
val navController = rememberNavController()
|
val navController = rememberNavController()
|
||||||
@ -33,8 +52,70 @@ fun AppNavHost() {
|
|||||||
val introSeen by prefs.introSeen.collectAsState(initial = null)
|
val introSeen by prefs.introSeen.collectAsState(initial = null)
|
||||||
val activeServer by prefs.activeServer.collectAsState(initial = null)
|
val activeServer by prefs.activeServer.collectAsState(initial = null)
|
||||||
|
|
||||||
|
// Pairing entry from a deep link that carried no password — prefills the
|
||||||
|
// connect form so the user lands on the password prompt for that server.
|
||||||
|
var pairPrefill by remember { mutableStateOf<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
|
if (introSeen == null) return
|
||||||
|
|
||||||
|
// Declared after the introSeen gate so it can't fire before the NavHost
|
||||||
|
// below has set the nav graph; pairUri stays pending until consumed here.
|
||||||
|
LaunchedEffect(pairUri) {
|
||||||
|
val raw = pairUri ?: return@LaunchedEffect
|
||||||
|
onPairUriConsumed()
|
||||||
|
when (val result = ServerQrParser.parse(raw)) {
|
||||||
|
is PairResult.Success -> {
|
||||||
|
// Pairing implies the app is installed and in use — skip the intro.
|
||||||
|
prefs.markIntroSeen()
|
||||||
|
val merged = prefs.upsertServer(result.server)
|
||||||
|
FipsManager.registerNode(context, result.fips, merged.displayName())
|
||||||
|
if (merged.password.isNotBlank()) {
|
||||||
|
// Demo flow: password came with the link — connect in one step.
|
||||||
|
prefs.setActiveServer(merged)
|
||||||
|
navController.navigate(Routes.WEB_VIEW) {
|
||||||
|
popUpTo(0) { inclusive = true }
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
pairPrefill = merged
|
||||||
|
navController.navigate(Routes.SERVER_CONNECT) {
|
||||||
|
popUpTo(0) { inclusive = true }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
else -> {
|
||||||
|
// Invalid or too-new pairing link — ignore; normal startup continues.
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
val startDestination = when {
|
val startDestination = when {
|
||||||
introSeen == false -> Routes.INTRO
|
introSeen == false -> Routes.INTRO
|
||||||
activeServer != null -> Routes.WEB_VIEW
|
activeServer != null -> Routes.WEB_VIEW
|
||||||
@ -47,6 +128,9 @@ fun AppNavHost() {
|
|||||||
) {
|
) {
|
||||||
composable(Routes.INTRO) {
|
composable(Routes.INTRO) {
|
||||||
IntroScreen(
|
IntroScreen(
|
||||||
|
onMeshParty = {
|
||||||
|
navController.navigate(Routes.MESH_PARTY)
|
||||||
|
},
|
||||||
onContinue = {
|
onContinue = {
|
||||||
scope.launch {
|
scope.launch {
|
||||||
prefs.markIntroSeen()
|
prefs.markIntroSeen()
|
||||||
@ -65,6 +149,7 @@ fun AppNavHost() {
|
|||||||
popUpTo(Routes.SERVER_CONNECT) { inclusive = true }
|
popUpTo(Routes.SERVER_CONNECT) { inclusive = true }
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
initialServer = pairPrefill,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -81,6 +166,8 @@ fun AppNavHost() {
|
|||||||
} else {
|
} else {
|
||||||
WebViewScreen(
|
WebViewScreen(
|
||||||
serverUrl = server.toUrl(),
|
serverUrl = server.toUrl(),
|
||||||
|
serverPassword = server.password,
|
||||||
|
meshFallbackUrl = server.toMeshUrl(),
|
||||||
onDisconnect = {
|
onDisconnect = {
|
||||||
scope.launch {
|
scope.launch {
|
||||||
prefs.clearActiveServer()
|
prefs.clearActiveServer()
|
||||||
@ -92,15 +179,46 @@ fun AppNavHost() {
|
|||||||
onRemoteInput = {
|
onRemoteInput = {
|
||||||
navController.navigate(Routes.REMOTE_INPUT)
|
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(
|
RemoteInputScreen(
|
||||||
onBack = {
|
onBack = {
|
||||||
navController.popBackStack()
|
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
|
import kotlinx.coroutines.delay
|
||||||
|
|
||||||
@Composable
|
@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) }
|
val logoAlpha = remember { Animatable(0f) }
|
||||||
var showContent by remember { mutableStateOf(false) }
|
var showContent by remember { mutableStateOf(false) }
|
||||||
|
|
||||||
@ -143,6 +148,14 @@ fun IntroScreen(onContinue: () -> Unit) {
|
|||||||
onClick = onContinue,
|
onClick = onContinue,
|
||||||
modifier = Modifier.fillMaxWidth().height(56.dp),
|
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.activity.compose.BackHandler
|
||||||
import androidx.compose.foundation.Image
|
import androidx.compose.foundation.Image
|
||||||
import androidx.compose.foundation.background
|
import androidx.compose.foundation.background
|
||||||
|
import androidx.compose.foundation.layout.Arrangement
|
||||||
import androidx.compose.foundation.layout.Box
|
import androidx.compose.foundation.layout.Box
|
||||||
import androidx.compose.foundation.layout.Column
|
import androidx.compose.foundation.layout.Column
|
||||||
|
import androidx.compose.foundation.layout.Row
|
||||||
import androidx.compose.foundation.layout.WindowInsets
|
import androidx.compose.foundation.layout.WindowInsets
|
||||||
import androidx.compose.foundation.layout.fillMaxSize
|
import androidx.compose.foundation.layout.fillMaxSize
|
||||||
import androidx.compose.foundation.layout.fillMaxWidth
|
import androidx.compose.foundation.layout.fillMaxWidth
|
||||||
@ -37,12 +39,14 @@ import androidx.compose.ui.res.painterResource
|
|||||||
import androidx.compose.ui.unit.dp
|
import androidx.compose.ui.unit.dp
|
||||||
import com.archipelago.app.R
|
import com.archipelago.app.R
|
||||||
import com.archipelago.app.data.ServerPreferences
|
import com.archipelago.app.data.ServerPreferences
|
||||||
|
import com.archipelago.app.fips.FipsManager
|
||||||
import com.archipelago.app.network.ConnectionState
|
import com.archipelago.app.network.ConnectionState
|
||||||
import com.archipelago.app.network.InputWebSocket
|
import com.archipelago.app.network.InputWebSocket
|
||||||
import com.archipelago.app.ui.components.NESController
|
import com.archipelago.app.ui.components.NESController
|
||||||
import com.archipelago.app.ui.components.NESKeyboard
|
import com.archipelago.app.ui.components.NESKeyboard
|
||||||
import com.archipelago.app.ui.components.NESMenu
|
import com.archipelago.app.ui.components.NESMenu
|
||||||
import com.archipelago.app.ui.components.NESPortraitController
|
import com.archipelago.app.ui.components.NESPortraitController
|
||||||
|
import com.archipelago.app.ui.components.QrScannerOverlay
|
||||||
import com.archipelago.app.ui.components.Trackpad
|
import com.archipelago.app.ui.components.Trackpad
|
||||||
import com.archipelago.app.ui.theme.BitcoinOrange
|
import com.archipelago.app.ui.theme.BitcoinOrange
|
||||||
import com.archipelago.app.ui.theme.ControllerStyle
|
import com.archipelago.app.ui.theme.ControllerStyle
|
||||||
@ -52,7 +56,12 @@ import com.archipelago.app.ui.theme.TextMuted
|
|||||||
import kotlinx.coroutines.launch
|
import kotlinx.coroutines.launch
|
||||||
|
|
||||||
@Composable
|
@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 context = LocalContext.current
|
||||||
val prefs = remember { ServerPreferences(context) }
|
val prefs = remember { ServerPreferences(context) }
|
||||||
val scope = rememberCoroutineScope()
|
val scope = rememberCoroutineScope()
|
||||||
@ -61,8 +70,9 @@ fun RemoteInputScreen(onBack: () -> Unit) {
|
|||||||
val savedServers by prefs.savedServers.collectAsState(initial = emptyList())
|
val savedServers by prefs.savedServers.collectAsState(initial = emptyList())
|
||||||
val activeServer by prefs.activeServer.collectAsState(initial = null)
|
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 showModal by remember { mutableStateOf(false) }
|
||||||
|
var showQrScanner by remember { mutableStateOf(false) }
|
||||||
var controllerStyle by remember { mutableStateOf(ControllerStyle.DARK) }
|
var controllerStyle by remember { mutableStateOf(ControllerStyle.DARK) }
|
||||||
var playerId by remember { mutableStateOf(0) } // 0 = broadcast, 1 = P1, 2 = P2
|
var playerId by remember { mutableStateOf(0) } // 0 = broadcast, 1 = P1, 2 = P2
|
||||||
|
|
||||||
@ -87,6 +97,9 @@ fun RemoteInputScreen(onBack: () -> Unit) {
|
|||||||
playerId = when (playerId) { 0 -> 1; 1 -> 2; else -> 0 }
|
playerId = when (playerId) { 0 -> 1; 1 -> 2; else -> 0 }
|
||||||
ws.playerId = playerId
|
ws.playerId = playerId
|
||||||
}
|
}
|
||||||
|
fun toggleStyle() {
|
||||||
|
controllerStyle = if (controllerStyle == ControllerStyle.CLASSIC) ControllerStyle.DARK else ControllerStyle.CLASSIC
|
||||||
|
}
|
||||||
val connectionState by ws.state.collectAsState()
|
val connectionState by ws.state.collectAsState()
|
||||||
val lifecycleOwner = LocalLifecycleOwner.current
|
val lifecycleOwner = LocalLifecycleOwner.current
|
||||||
|
|
||||||
@ -150,6 +163,7 @@ fun RemoteInputScreen(onBack: () -> Unit) {
|
|||||||
onKey = { ws.sendKey(it) },
|
onKey = { ws.sendKey(it) },
|
||||||
onMenu = { showModal = true },
|
onMenu = { showModal = true },
|
||||||
onPlayerToggle = ::togglePlayer,
|
onPlayerToggle = ::togglePlayer,
|
||||||
|
onToggleStyle = ::toggleStyle,
|
||||||
)
|
)
|
||||||
isGamepadMode && !isLandscape -> NESPortraitController(
|
isGamepadMode && !isLandscape -> NESPortraitController(
|
||||||
style = controllerStyle,
|
style = controllerStyle,
|
||||||
@ -160,6 +174,7 @@ fun RemoteInputScreen(onBack: () -> Unit) {
|
|||||||
onMouseScroll = { ws.sendScroll(it) },
|
onMouseScroll = { ws.sendScroll(it) },
|
||||||
onMenu = { showModal = true },
|
onMenu = { showModal = true },
|
||||||
onPlayerToggle = ::togglePlayer,
|
onPlayerToggle = ::togglePlayer,
|
||||||
|
onToggleStyle = ::toggleStyle,
|
||||||
)
|
)
|
||||||
else -> {
|
else -> {
|
||||||
// Keyboard mode: trackpad fills top, keyboard pinned bottom
|
// Keyboard mode: trackpad fills top, keyboard pinned bottom
|
||||||
@ -169,7 +184,7 @@ fun RemoteInputScreen(onBack: () -> Unit) {
|
|||||||
onMove = { dx, dy -> ws.sendMouseMove(dx, dy) },
|
onMove = { dx, dy -> ws.sendMouseMove(dx, dy) },
|
||||||
onClick = { ws.sendClick(it) },
|
onClick = { ws.sendClick(it) },
|
||||||
onScroll = { ws.sendScroll(it) },
|
onScroll = { ws.sendScroll(it) },
|
||||||
onTwoFingerHold = { showModal = true },
|
onThreeFingerHold = { showModal = true },
|
||||||
modifier = Modifier.fillMaxWidth().weight(1f)
|
modifier = Modifier.fillMaxWidth().weight(1f)
|
||||||
.padding(horizontal = 16.dp, vertical = 8.dp),
|
.padding(horizontal = 16.dp, vertical = 8.dp),
|
||||||
)
|
)
|
||||||
@ -179,12 +194,20 @@ fun RemoteInputScreen(onBack: () -> Unit) {
|
|||||||
modifier = Modifier.fillMaxWidth(),
|
modifier = Modifier.fillMaxWidth(),
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
// Settings icon top-right in keyboard mode
|
// Settings + style icons top-right in keyboard mode
|
||||||
com.archipelago.app.ui.components.SettingsBtn(
|
Row(
|
||||||
c = com.archipelago.app.ui.components.paletteFor(controllerStyle),
|
Modifier.align(Alignment.TopEnd).padding(8.dp),
|
||||||
modifier = Modifier.align(Alignment.TopEnd).padding(8.dp),
|
horizontalArrangement = Arrangement.spacedBy(8.dp),
|
||||||
onClick = { showModal = true },
|
) {
|
||||||
)
|
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,
|
||||||
|
)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@ -207,8 +230,6 @@ fun RemoteInputScreen(onBack: () -> Unit) {
|
|||||||
visible = showModal,
|
visible = showModal,
|
||||||
servers = savedServers,
|
servers = savedServers,
|
||||||
activeServer = activeServer,
|
activeServer = activeServer,
|
||||||
isGamepadMode = isGamepadMode,
|
|
||||||
controllerStyle = controllerStyle,
|
|
||||||
onDismiss = { showModal = false },
|
onDismiss = { showModal = false },
|
||||||
onSelectServer = { server ->
|
onSelectServer = { server ->
|
||||||
scope.launch { ws.disconnect(); prefs.setActiveServer(server) }; showModal = false
|
scope.launch { ws.disconnect(); prefs.setActiveServer(server) }; showModal = false
|
||||||
@ -216,6 +237,7 @@ fun RemoteInputScreen(onBack: () -> Unit) {
|
|||||||
onAddServer = { server ->
|
onAddServer = { server ->
|
||||||
scope.launch { prefs.addSavedServer(server); if (activeServer == null) prefs.setActiveServer(server) }
|
scope.launch { prefs.addSavedServer(server); if (activeServer == null) prefs.setActiveServer(server) }
|
||||||
},
|
},
|
||||||
|
onScanQr = { showQrScanner = true },
|
||||||
onEditServer = { original, updated ->
|
onEditServer = { original, updated ->
|
||||||
scope.launch {
|
scope.launch {
|
||||||
prefs.updateSavedServer(original, updated)
|
prefs.updateSavedServer(original, updated)
|
||||||
@ -241,11 +263,25 @@ fun RemoteInputScreen(onBack: () -> Unit) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
onToggleMode = { isGamepadMode = !isGamepadMode; showModal = false },
|
onRemote = { isGamepadMode = true; showModal = false },
|
||||||
onToggleStyle = {
|
onKeyboard = { isGamepadMode = false; showModal = false },
|
||||||
controllerStyle = if (controllerStyle == ControllerStyle.CLASSIC) ControllerStyle.DARK else ControllerStyle.CLASSIC
|
|
||||||
},
|
|
||||||
onBackToWebView = { showModal = false; onBack() },
|
onBackToWebView = { showModal = false; onBack() },
|
||||||
|
onMeshParty = onMeshParty?.let { open -> { showModal = false; open() } },
|
||||||
|
)
|
||||||
|
|
||||||
|
// Pairing-QR scan launched from the menu's Add Server row. The menu stays
|
||||||
|
// open behind the scanner so the new entry appears as soon as it closes.
|
||||||
|
QrScannerOverlay(
|
||||||
|
visible = showQrScanner,
|
||||||
|
onDismiss = { showQrScanner = false },
|
||||||
|
onServerScanned = { scan ->
|
||||||
|
showQrScanner = false
|
||||||
|
scope.launch {
|
||||||
|
val merged = prefs.upsertServer(scan.server)
|
||||||
|
FipsManager.registerNode(context, scan.fips, merged.displayName())
|
||||||
|
if (activeServer == null) prefs.setActiveServer(merged)
|
||||||
|
}
|
||||||
|
},
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -43,6 +43,7 @@ import androidx.compose.material3.Switch
|
|||||||
import androidx.compose.material3.SwitchDefaults
|
import androidx.compose.material3.SwitchDefaults
|
||||||
import androidx.compose.material3.Text
|
import androidx.compose.material3.Text
|
||||||
import androidx.compose.runtime.Composable
|
import androidx.compose.runtime.Composable
|
||||||
|
import androidx.compose.runtime.LaunchedEffect
|
||||||
import androidx.compose.runtime.collectAsState
|
import androidx.compose.runtime.collectAsState
|
||||||
import androidx.compose.runtime.getValue
|
import androidx.compose.runtime.getValue
|
||||||
import androidx.compose.runtime.mutableStateOf
|
import androidx.compose.runtime.mutableStateOf
|
||||||
@ -70,8 +71,12 @@ import androidx.compose.ui.text.style.TextOverflow
|
|||||||
import androidx.compose.ui.unit.dp
|
import androidx.compose.ui.unit.dp
|
||||||
import androidx.compose.ui.unit.sp
|
import androidx.compose.ui.unit.sp
|
||||||
import com.archipelago.app.R
|
import com.archipelago.app.R
|
||||||
|
import com.archipelago.app.data.PairResult
|
||||||
import com.archipelago.app.data.ServerEntry
|
import com.archipelago.app.data.ServerEntry
|
||||||
import com.archipelago.app.data.ServerPreferences
|
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.BitcoinOrange
|
||||||
import com.archipelago.app.ui.theme.ErrorRed
|
import com.archipelago.app.ui.theme.ErrorRed
|
||||||
import com.archipelago.app.ui.theme.SurfaceBlack
|
import com.archipelago.app.ui.theme.SurfaceBlack
|
||||||
@ -81,6 +86,7 @@ import com.archipelago.app.ui.theme.TextMuted
|
|||||||
import com.archipelago.app.ui.theme.TextPrimary
|
import com.archipelago.app.ui.theme.TextPrimary
|
||||||
import com.archipelago.app.ui.theme.TextSecondary
|
import com.archipelago.app.ui.theme.TextSecondary
|
||||||
import kotlinx.coroutines.Dispatchers
|
import kotlinx.coroutines.Dispatchers
|
||||||
|
import kotlinx.coroutines.delay
|
||||||
import kotlinx.coroutines.launch
|
import kotlinx.coroutines.launch
|
||||||
import kotlinx.coroutines.withContext
|
import kotlinx.coroutines.withContext
|
||||||
import java.net.HttpURLConnection
|
import java.net.HttpURLConnection
|
||||||
@ -93,6 +99,9 @@ import javax.net.ssl.X509TrustManager
|
|||||||
fun ServerConnectScreen(
|
fun ServerConnectScreen(
|
||||||
onConnected: (String) -> Unit,
|
onConnected: (String) -> Unit,
|
||||||
onRemoteInput: () -> Unit = {},
|
onRemoteInput: () -> Unit = {},
|
||||||
|
// Prefill from a pairing deep link (archipelago://pair) that carried no
|
||||||
|
// password — opens the manual form on the password prompt for that server.
|
||||||
|
initialServer: ServerEntry? = null,
|
||||||
) {
|
) {
|
||||||
val context = LocalContext.current
|
val context = LocalContext.current
|
||||||
val prefs = remember { ServerPreferences(context) }
|
val prefs = remember { ServerPreferences(context) }
|
||||||
@ -109,6 +118,9 @@ fun ServerConnectScreen(
|
|||||||
var errorMessage by remember { mutableStateOf<String?>(null) }
|
var errorMessage by remember { mutableStateOf<String?>(null) }
|
||||||
// The saved server currently being edited, or null when adding/connecting.
|
// The saved server currently being edited, or null when adding/connecting.
|
||||||
var editingServer by remember { mutableStateOf<ServerEntry?>(null) }
|
var editingServer by remember { mutableStateOf<ServerEntry?>(null) }
|
||||||
|
// Landing shows Scan/Manual choice; the form appears in manual mode or while editing.
|
||||||
|
var manualMode by remember { mutableStateOf(false) }
|
||||||
|
var showScanner by remember { mutableStateOf(false) }
|
||||||
|
|
||||||
val savedServers by prefs.savedServers.collectAsState(initial = emptyList())
|
val savedServers by prefs.savedServers.collectAsState(initial = emptyList())
|
||||||
|
|
||||||
@ -161,10 +173,35 @@ fun ServerConnectScreen(
|
|||||||
errorMessage = null
|
errorMessage = null
|
||||||
|
|
||||||
scope.launch {
|
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
|
isConnecting = false
|
||||||
|
|
||||||
if (result) {
|
if (reachable) {
|
||||||
prefs.setActiveServer(server)
|
prefs.setActiveServer(server)
|
||||||
onConnected(server.toUrl())
|
onConnected(server.toUrl())
|
||||||
} else {
|
} else {
|
||||||
@ -173,6 +210,39 @@ fun ServerConnectScreen(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fun prefill(server: ServerEntry) {
|
||||||
|
name = server.name
|
||||||
|
address = server.address
|
||||||
|
port = server.port
|
||||||
|
password = server.password
|
||||||
|
useHttps = server.useHttps
|
||||||
|
}
|
||||||
|
|
||||||
|
// Pairing QR scanned: dedupe against saved servers, then either auto-connect
|
||||||
|
// (payload carried a credential — demo password or a real node's device
|
||||||
|
// token) or land on the password prompt with everything else filled in.
|
||||||
|
// Mesh info (when present) is registered so the FIPS tunnel comes up too.
|
||||||
|
fun onQrScanned(scan: PairResult.Success) {
|
||||||
|
showScanner = false
|
||||||
|
scope.launch {
|
||||||
|
val merged = prefs.upsertServer(scan.server)
|
||||||
|
FipsManager.registerNode(context, scan.fips, merged.displayName())
|
||||||
|
prefill(merged)
|
||||||
|
if (merged.password.isNotBlank()) {
|
||||||
|
connect(merged)
|
||||||
|
} else {
|
||||||
|
manualMode = true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
LaunchedEffect(initialServer) {
|
||||||
|
if (initialServer != null) {
|
||||||
|
prefill(prefs.upsertServer(initialServer))
|
||||||
|
manualMode = true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
Box(
|
Box(
|
||||||
modifier = Modifier
|
modifier = Modifier
|
||||||
.fillMaxSize()
|
.fillMaxSize()
|
||||||
@ -208,7 +278,10 @@ fun ServerConnectScreen(
|
|||||||
.padding(horizontal = 24.dp)
|
.padding(horizontal = 24.dp)
|
||||||
.padding(top = 48.dp, bottom = 32.dp),
|
.padding(top = 48.dp, bottom = 32.dp),
|
||||||
horizontalAlignment = Alignment.CenterHorizontally,
|
horizontalAlignment = Alignment.CenterHorizontally,
|
||||||
verticalArrangement = Arrangement.spacedBy(16.dp),
|
// Center the content vertically — the landing (logo + two buttons) is
|
||||||
|
// short and looks stranded at the top otherwise. Taller content (the
|
||||||
|
// manual form, saved servers) still scrolls from the top as normal.
|
||||||
|
verticalArrangement = Arrangement.spacedBy(16.dp, Alignment.CenterVertically),
|
||||||
) {
|
) {
|
||||||
// Circular badge logo
|
// Circular badge logo
|
||||||
Image(
|
Image(
|
||||||
@ -226,8 +299,10 @@ fun ServerConnectScreen(
|
|||||||
textAlign = TextAlign.Center,
|
textAlign = TextAlign.Center,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
val showForm = manualMode || editingServer != null
|
||||||
|
|
||||||
Text(
|
Text(
|
||||||
text = stringResource(R.string.server_address_hint),
|
text = if (showForm) stringResource(R.string.server_address_hint) else stringResource(R.string.connect_landing_hint),
|
||||||
style = MaterialTheme.typography.bodyMedium,
|
style = MaterialTheme.typography.bodyMedium,
|
||||||
color = TextMuted,
|
color = TextMuted,
|
||||||
textAlign = TextAlign.Center,
|
textAlign = TextAlign.Center,
|
||||||
@ -235,8 +310,25 @@ fun ServerConnectScreen(
|
|||||||
|
|
||||||
Spacer(modifier = Modifier.height(4.dp))
|
Spacer(modifier = Modifier.height(4.dp))
|
||||||
|
|
||||||
|
if (!showForm) {
|
||||||
|
// Landing: scan the pairing QR, or fall back to manual entry
|
||||||
|
GlassButton(
|
||||||
|
text = stringResource(R.string.scan_node_qr),
|
||||||
|
onClick = { showScanner = true },
|
||||||
|
modifier = Modifier.fillMaxWidth().height(56.dp),
|
||||||
|
)
|
||||||
|
GlassButton(
|
||||||
|
text = stringResource(R.string.enter_manually),
|
||||||
|
onClick = {
|
||||||
|
errorMessage = null
|
||||||
|
manualMode = true
|
||||||
|
},
|
||||||
|
modifier = Modifier.fillMaxWidth().height(56.dp),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
// Glass card with form
|
// Glass card with form
|
||||||
Box(
|
if (showForm) Box(
|
||||||
modifier = Modifier
|
modifier = Modifier
|
||||||
.fillMaxWidth()
|
.fillMaxWidth()
|
||||||
.clip(RoundedCornerShape(16.dp))
|
.clip(RoundedCornerShape(16.dp))
|
||||||
@ -458,16 +550,30 @@ fun ServerConnectScreen(
|
|||||||
modifier = Modifier.weight(1f).height(56.dp),
|
modifier = Modifier.weight(1f).height(56.dp),
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
} else {
|
} else if (manualMode) {
|
||||||
// Connect button — glass style
|
// Back to the Scan/Manual landing + Connect
|
||||||
GlassButton(
|
Row(
|
||||||
text = if (isConnecting) stringResource(R.string.connecting) else stringResource(R.string.connect),
|
modifier = Modifier.fillMaxWidth(),
|
||||||
onClick = {
|
horizontalArrangement = Arrangement.spacedBy(12.dp),
|
||||||
keyboard?.hide()
|
) {
|
||||||
connect(ServerEntry(address, useHttps, port, password, name))
|
GlassButton(
|
||||||
},
|
text = stringResource(R.string.back),
|
||||||
modifier = Modifier.fillMaxWidth().height(56.dp),
|
onClick = {
|
||||||
)
|
keyboard?.hide()
|
||||||
|
manualMode = false
|
||||||
|
clearForm()
|
||||||
|
},
|
||||||
|
modifier = Modifier.weight(1f).height(56.dp),
|
||||||
|
)
|
||||||
|
GlassButton(
|
||||||
|
text = if (isConnecting) stringResource(R.string.connecting) else stringResource(R.string.connect),
|
||||||
|
onClick = {
|
||||||
|
keyboard?.hide()
|
||||||
|
connect(ServerEntry(address, useHttps, port, password, name))
|
||||||
|
},
|
||||||
|
modifier = Modifier.weight(2f).height(56.dp),
|
||||||
|
)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if (isConnecting) {
|
if (isConnecting) {
|
||||||
@ -499,6 +605,20 @@ fun ServerConnectScreen(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
QrScannerOverlay(
|
||||||
|
visible = showScanner,
|
||||||
|
onDismiss = { showScanner = false },
|
||||||
|
onServerScanned = { onQrScanned(it) },
|
||||||
|
)
|
||||||
|
|
||||||
|
// Full-screen branded loader while the first connect runs — most
|
||||||
|
// visibly right after a pairing-QR scan, when the mesh may still be
|
||||||
|
// establishing (LAN probe → tunnel up → ULA probe can take a while).
|
||||||
|
// The small inline spinner stays for context; this owns the screen.
|
||||||
|
if (isConnecting) {
|
||||||
|
MeshLoadingScreen()
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -566,8 +686,10 @@ private fun sanitizeAddress(input: String): String {
|
|||||||
.trimEnd('/')
|
.trimEnd('/')
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Test RPC connectivity. Accepts self-signed certs for local LAN servers. */
|
/** Test RPC connectivity. Accepts self-signed certs for local LAN servers.
|
||||||
private suspend fun testConnection(server: ServerEntry): Boolean {
|
* [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) {
|
return withContext(Dispatchers.IO) {
|
||||||
try {
|
try {
|
||||||
val url = URL("${server.toUrl()}/rpc/v1")
|
val url = URL("${server.toUrl()}/rpc/v1")
|
||||||
@ -587,8 +709,8 @@ private suspend fun testConnection(server: ServerEntry): Boolean {
|
|||||||
}
|
}
|
||||||
|
|
||||||
connection.requestMethod = "POST"
|
connection.requestMethod = "POST"
|
||||||
connection.connectTimeout = 5000
|
connection.connectTimeout = timeoutMs
|
||||||
connection.readTimeout = 5000
|
connection.readTimeout = timeoutMs
|
||||||
connection.setRequestProperty("Content-Type", "application/json")
|
connection.setRequestProperty("Content-Type", "application/json")
|
||||||
connection.doOutput = true
|
connection.doOutput = true
|
||||||
val body = """{"method":"server.echo","params":{"message":"ping"}}"""
|
val body = """{"method":"server.echo","params":{"message":"ping"}}"""
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
@ -11,6 +11,7 @@
|
|||||||
<string name="welcome_title">Your Sovereign\nPersonal Server</string>
|
<string name="welcome_title">Your Sovereign\nPersonal Server</string>
|
||||||
<string name="welcome_subtitle">Bitcoin node, app platform, and private cloud — all in one box you control.</string>
|
<string name="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="get_started">Get Started</string>
|
||||||
|
<string name="mesh_party">Mesh Party</string>
|
||||||
<string name="use_https">Use HTTPS</string>
|
<string name="use_https">Use HTTPS</string>
|
||||||
<string name="port_label">Port (optional)</string>
|
<string name="port_label">Port (optional)</string>
|
||||||
<string name="saved_servers">Saved Servers</string>
|
<string name="saved_servers">Saved Servers</string>
|
||||||
@ -29,7 +30,23 @@
|
|||||||
<string name="server_name_label">Server Name (optional)</string>
|
<string name="server_name_label">Server Name (optional)</string>
|
||||||
<string name="server_name_placeholder">My Archipelago</string>
|
<string name="server_name_placeholder">My Archipelago</string>
|
||||||
<string name="edit_server">Edit</string>
|
<string name="edit_server">Edit</string>
|
||||||
|
<string name="scan_node_qr">Scan Node\'s QR</string>
|
||||||
|
<string name="enter_manually">Enter Manually</string>
|
||||||
|
<string name="connect_landing_hint">Scan the pairing QR from your node\'s Companion popup, or enter the address manually</string>
|
||||||
|
<string name="scan_qr_hint">Point the camera at the pairing QR shown in the Companion popup</string>
|
||||||
|
<string name="camera_permission_needed">Camera access is needed to scan the pairing QR. You can also enter the server details manually.</string>
|
||||||
|
<string name="grant_camera_access">Grant Camera Access</string>
|
||||||
|
<string name="invalid_pairing_qr">Not an Archipelago pairing code</string>
|
||||||
|
<string name="update_app_for_qr">This pairing code needs a newer app version — please update the companion app</string>
|
||||||
|
<string name="add_server_qr">Add server by QR</string>
|
||||||
<string name="edit_server_title">Edit Server</string>
|
<string name="edit_server_title">Edit Server</string>
|
||||||
<string name="save_changes">Save Changes</string>
|
<string name="save_changes">Save Changes</string>
|
||||||
<string name="cancel">Cancel</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>
|
</resources>
|
||||||
|
|||||||
5
Android/app/src/main/res/xml/file_paths.xml
Normal file
5
Android/app/src/main/res/xml/file_paths.xml
Normal file
@ -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>
|
||||||
1690
Android/rust/archy-fips-core/Cargo.lock
generated
Normal file
1690
Android/rust/archy-fips-core/Cargo.lock
generated
Normal file
File diff suppressed because it is too large
Load Diff
48
Android/rust/archy-fips-core/Cargo.toml
Normal file
48
Android/rust/archy-fips-core/Cargo.toml
Normal file
@ -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]
|
||||||
129
Android/rust/archy-fips-core/src/jni_glue.rs
Normal file
129
Android/rust/archy-fips-core/src/jni_glue.rs
Normal file
@ -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())
|
||||||
|
}
|
||||||
17
Android/rust/archy-fips-core/src/lib.rs
Normal file
17
Android/rust/archy-fips-core/src/lib.rs
Normal file
@ -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;
|
||||||
295
Android/rust/archy-fips-core/src/mesh.rs
Normal file
295
Android/rust/archy-fips-core/src/mesh.rs
Normal file
@ -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"));
|
||||||
|
}
|
||||||
|
}
|
||||||
177
CHANGELOG.md
177
CHANGELOG.md
@ -1,5 +1,176 @@
|
|||||||
# Changelog
|
# Changelog
|
||||||
|
|
||||||
|
## v1.7.118-alpha (2026-07-29)
|
||||||
|
|
||||||
|
- Fixes mesh radios dropping off on nodes that took the v1.7.117 update. Updates only ever replaced the main program, never the packaged radio helpers — so updated nodes were left running an older radio daemon that didn't understand a new option and quietly gave up, showing "device not connected" with a Connect button that did nothing. The node now checks what its radio daemon supports before using new options, and updates finally carry the radio helpers themselves, so every node gets current radio support with the update instead of only from a fresh install.
|
||||||
|
- The in-app "Flash LoRa" flow works on updated nodes. The RNode flashing tool was only ever included on freshly installed nodes; everywhere else flashing failed with a cryptic "No such file or directory". The tool now ships with updates and is included on new install images, and if it's somehow still missing the error says exactly what to do instead.
|
||||||
|
- Message notification badges finally remember what you've read. Unread counts were only kept in memory, so every visit re-counted old messages as new — including a phantom badge for chats with nothing new in them. Read-state is now saved on the device, opening a chat marks all its linked conversations read, and history no longer re-badges after a reload.
|
||||||
|
- Every mesh message now has a visible "⋯" button that opens the route view: watch the path your message took animate — sender and receiver appear, a pulse travels the link, and each relay hop lights up in order — with signal quality for radio links and delivery status. (Tapping the transport pill still works too.)
|
||||||
|
|
||||||
|
## v1.7.117-alpha (2026-07-29)
|
||||||
|
|
||||||
|
- Flash your LoRa radio from inside the app. The Mesh page now has a "Flash LoRa" button that opens a guided flow: pick the firmware family (MeshCore, Meshtastic, or Reticulum RNode) and your board, and the node downloads the latest release and flashes it with live progress — no external flasher website, no cables to a computer. The same flow appears when a freshly plugged-in radio is detected, and a long list of flashing pitfalls was fixed along the way: radios no longer boot-loop after a flash, failures show the real error instead of silently bouncing back, wedged flash jobs can't get stuck forever, and board auto-detection no longer misidentifies Heltec boards.
|
||||||
|
- Every Archipelago node now acts as a Reticulum relay. Nodes forward mesh traffic and re-broadcast peer announcements, so two radios that can't hear each other directly can still discover and message each other through any Archipelago node in between — your nodes become infrastructure for the whole neighbourhood mesh, including non-Archipelago apps like Sideband.
|
||||||
|
- Reticulum (RNode) radios are now first-class mesh citizens. Radios are reliably detected on node startup (a boot-timing race used to leave them unclaimed), settings changes apply live without a restart, your node's name propagates over the Reticulum network so other apps like Sideband see it properly, and a crashed Reticulum daemon is detected and restarted automatically. Photo and file attachments sent over Reticulum now actually arrive — four separate delivery bugs were found and fixed, verified end-to-end over real radio hardware.
|
||||||
|
- Messages to contacts that exist on both the internet mesh and a LoRa radio now prefer the radio when it's live, and attachments follow the same path — so co-located nodes talk over the air even when the internet path exists.
|
||||||
|
- Mesh chat polish: each message in the image viewer shows which transport carried it, a new hop-route view shows the path a message took, reactions moved into a tidy dropdown, and read-tracking now reflects what you've actually seen. The Refresh and Broadcast buttons give real feedback, and the radio-setup modal shows honest probe progress instead of freezing.
|
||||||
|
- The wallet transactions list works properly on phones now: it scrolls (it silently couldn't on touch screens before), and the All / On-chain / Lightning / Ecash filter tabs stay pinned at the top with a subtle blur while the list scrolls underneath.
|
||||||
|
- Backend services no longer masquerade as launchable apps. Anything without a real web interface — databases, APIs, background workers, including stacks you deploy by hand for testing — now files under Services with no Launch button. Apps declare their interface in their manifest; for everything else the node checks the port itself to see whether a browser page actually lives there.
|
||||||
|
- Lightning payments that take a while (slow multi-hop routes) are no longer reported as failed while they're still in flight. The wallet now waits properly, shows an honest "pending" state, and reports the true final outcome.
|
||||||
|
- Server pages feel instant: Server, Federation, Lightning channels, Monitoring, wallet, Cloud, and Credentials screens now render immediately from a shared cache and refresh live in the background (including push updates over the node's websocket), instead of blanking while every panel refetches.
|
||||||
|
- The node stays responsive under heavy load: the connection handler now sheds excess load instead of stalling everything behind it, and companion-app probes no longer trigger container builds during routine checks.
|
||||||
|
- FIPS mesh uptime hardening continues: the node's peer port is opened explicitly everywhere, LAN anchors use the right port, direct peering between co-located nodes works again, dials fail fast instead of hanging, and a connectivity watcher re-applies anchors immediately when the network comes back.
|
||||||
|
- FIPS startup is more reliable on nodes that have the packaged `fips.service` instead of Archipelago's `archipelago-fips.service`. Startup self-heal, onboarding, dashboard Start, and reconnect now use the systemd unit the node actually has, so FIPS no longer looks like it needs to be installed when it only needs to be started.
|
||||||
|
- App screens over the FIPS mesh now bind their relay only to the node's FIPS address instead of reserving the same host ports Podman needs. This keeps apps such as FileBrowser and Botfights from restart-looping because the backend was already holding their published ports.
|
||||||
|
- Companion app 0.5.25: a redesigned settings hub (three-finger tap opens it over the dashboard), seamless transport handoff with FIPS mesh settings, the wallet scanner reads dense invoice QR codes, app webviews clear the phone status bar with an HTTPS toggle on add/edit, and off-LAN loads fall back to the mesh URL instead of a dead LAN address.
|
||||||
|
- Public-source preparation now includes a Nostr Git hosting plan using `ngit`, NIP-34, and GRASP: anyone can clone, fork, review, and propose changes from their Archipelago node, while canonical merge authority stays with a small signed maintainer set in the style of Bitcoin Core.
|
||||||
|
|
||||||
|
## v1.7.116-alpha (2026-07-27)
|
||||||
|
|
||||||
|
- Nodes no longer get stuck on "server starting up" after an update or reboot. On a node running many apps, the backend used to spend minutes recovering containers before it told the system it was ready, and anything that touched it during that window could leave it down for good. It now reports ready immediately and recovers in the background, and it always restarts itself if it ever does go down.
|
||||||
|
- Installing apps no longer crashes the node. A change that made app screens reachable over the mesh was accidentally holding onto every app's network port in advance — so installing an app like Grafana, Photoprism, Uptime Kuma, or Jellyfin collided with it and the port-cleanup step took the whole backend down, rolling the install back. Installs are now clean and the backend can never be caught by that cleanup.
|
||||||
|
- Rolls up everything from v1.7.115: app screens and the dashboard load over the mesh out of the box (firewall openings shipped automatically, IPv6 support end to end), and nodes rejoin the mesh in seconds after their rendezvous point restarts.
|
||||||
|
|
||||||
|
## v1.7.115-alpha (2026-07-26)
|
||||||
|
|
||||||
|
- The companion app can reach your node's screen from anywhere again. The recent security hardening locked down the node's mesh interface so tightly that the dashboard itself was blocked — the phone would pair and connect, then sit on a blank screen. The node now explicitly opens its own web interface (and only that) through the mesh firewall on every install and upgrade, so the phone's view of your node works out of the box, on any network, and can't silently break in a future update.
|
||||||
|
- The node's web interface also answers on IPv6 everywhere it answers on IPv4 — the mesh runs entirely on IPv6, and one v4-only listener was enough to make a working connection show nothing.
|
||||||
|
- Nodes now come back onto the mesh in seconds instead of minutes after their rendezvous anchor restarts: the fast-reconnect tuning proven on the phone this week is now baked into every node's mesh configuration, and it survives upgrades.
|
||||||
|
|
||||||
|
## v1.7.114-alpha (2026-07-26)
|
||||||
|
|
||||||
|
- Plugging in a mesh radio no longer traps it in an endless reboot loop. The device detector itself was causing it: every scan pulsed the radio's reset line, the same board was probed twice under two names, and retries came so fast the radio never finished booting before the next reset hit. Detection now gives the board real time to boot, probes it once, backs off properly between attempts, and no longer fights the "device detected" popup for the port. Radios that could never connect now come up within a minute of being plugged in.
|
||||||
|
- The Lightning channels screen now has All / Active / Pending / Closed tabs. Pending gathers everything in motion (opening, closing, force-closing — each with its own status dot and a link to the closing transaction), and Closed is a real history: how each channel ended, what settled back to you, and the closing transaction for each.
|
||||||
|
- Sending bitcoin on-chain now puts you in charge of the network fee: pick Fast, Standard, or Slow (Standard is the default), or set your own target blocks or sats-per-vByte. The confirmation step shows the estimated fee for your chosen speed before any money moves.
|
||||||
|
- Type on-chain amounts in whichever unit you think in — a sats/BTC switch on the amount field converts as you type.
|
||||||
|
- Back up your seed by scanning it. Every recovery-phrase screen (onboarding, Settings, and the Lightning wallet seed) now has Words and QR code tabs — words always shown first. The QR for your node's recovery phrase uses the SeedQR standard, so hardware wallets like Passport Prime, SeedSigner, and Keystone can import it with a single scan (a plain-text option remains for wallets that read the phrase as text). The Lightning seed's QR is plain text with an honest note: it's an LND-format seed that restores into Lightning wallets like Zeus or Blixt, not into hardware wallets.
|
||||||
|
|
||||||
|
## v1.7.113-alpha (2026-07-25)
|
||||||
|
|
||||||
|
- Fixed a money bug in Cashu ecash sends: the token you handed a recipient could carry your own change proofs along with it, letting the same sats be credited twice. Change now stays in your wallet — only the amount you meant to send leaves it.
|
||||||
|
- Closing a Lightning channel is no longer a leap of faith. The close used to hang (or time out with an error) even though it had actually gone through; it now comes back within seconds with the closing transaction ID. Channels mid-close appear in the channel list as Closing or Force-closing with their transaction attached, and a new closed-channels history keeps past closes visible instead of letting them vanish from the list.
|
||||||
|
- The wallet card now leads with your total bitcoin across everything, and the on-chain balance gets its own chain icon so the rows read at a glance.
|
||||||
|
- The companion phone app (0.5.15) connects dramatically faster away from home: a cold connect over 5G dropped from 40+ seconds to about 5. First connects no longer stall on unreachable mesh dial hints, fresh joins fail fast and retry instead of waiting out long timeouts, and the phone re-announces itself the moment the network around it changes. The node side's mesh-join handling was hardened to match.
|
||||||
|
|
||||||
|
## v1.7.112-alpha (2026-07-23)
|
||||||
|
|
||||||
|
- Sound works on TVs out of the box. Fresh installs were missing the audio system entirely, and even when present a boot-time race left HDMI silent until the cable was unplugged and replugged. Both are fixed: installer images now ship the full audio stack, and a small background helper detects the silent-HDMI state and heals it automatically.
|
||||||
|
- Plug in a game controller and drive the whole TV interface with it — navigation, menus, and media playback all respond to the gamepad, and dialogs that pop up are controller-navigable too.
|
||||||
|
- The companion phone app took a huge leap (0.5.9). Your node and its apps now work from anywhere — on 5G or any internet connection, the phone reaches the node over the encrypted mesh with zero port forwarding or VPN setup. Startup away from home is instant, apps on your node open inside the app, the phone's native camera handles QR scanning, and a branded full-screen loader shows while the mesh connects.
|
||||||
|
- Mesh Party: two phones scan each other's QR and instantly get a direct encrypted chat and app sharing between them — plus a "Share this app" QR that anyone can scan with a normal camera to install the companion app.
|
||||||
|
- Pairing a second phone no longer silently logs out the first. Every device now keeps its own named access credential, ending the mystery reconnects when a household paired more than one phone.
|
||||||
|
- The companion pairing QR is scannable again (it had grown too dense for phone cameras) and now identifies your node by its identity key, so the app recognizes your node even after it moves or gets a new address.
|
||||||
|
- Every app your node serves on your home network is now also reachable over the mesh — remote access covers the apps themselves, not just the dashboard.
|
||||||
|
- Selling files: you now choose which payment methods you accept (Lightning, ecash, …) and buyers are only offered those — enforced by the node itself, not just the buttons. Paying twice for the same file is impossible now, purchases file themselves into a new Paid Files tab, purchased music always plays in the bottom-bar player, and videos get picture-in-picture.
|
||||||
|
- Sending Lightning is invoice-first: paste or scan an invoice and the amount fills in and locks by itself. An expired invoice now tells you plainly to ask for a fresh one instead of failing cryptically, and payment errors always reach your screen.
|
||||||
|
- Sending to a pasted address gets a confirmation step showing exactly what will happen before any money moves, and buying ecash is an explicit two-step — no more accidental purchases.
|
||||||
|
- Apps keep running when you change how they're displayed. Switching an app between windowed and fullscreen used to reload it from scratch (stopping any playing media); the app now stays live through the switch, and each app remembers its own preferred display mode.
|
||||||
|
- A watchdog notices when the Lightning (LND) node wedges and revives it before you do; Fedi ecash gets its own send option with scannable token QR codes.
|
||||||
|
- Fedimint's Lightning gateway and guardian now follow whichever bitcoin version is actually running instead of pointing at a stale address — switching bitcoin versions no longer strands them.
|
||||||
|
- If your router starts handing out different addresses, the Pine voice speaker re-links itself automatically instead of staying silent until someone re-configures it.
|
||||||
|
- Polish: mesh radios never show garbled device names anymore, the TV kiosk uses slim overlay scrollbars instead of fat grey bars, and the AI chat's background artwork shows through again.
|
||||||
|
- Your mesh messages now survive restarts. Chat history — channels and DMs alike — used to live only in memory, so a reboot or update wiped every conversation; worse, other nodes silently discarded the first messages you sent after a reboot. Everything is now saved on the node and restored on startup, and post-reboot messages deliver reliably.
|
||||||
|
- Plug in any LoRa radio and the node walks you through it. A setup window appears every time a radio is connected, shows what firmware is already on it (MeshCore, Meshtastic, or Reticulum RNode — with its current name, region, and channels where available), and offers two honest choices: "Set Up with Archipelago Settings" (a preview screen shows exactly what will be written before anything touches the radio) or "Keep As Is" (the radio is used untouched, and you can hot-swap radios freely). Swapping sticks mid-session now just works — including Reticulum RNodes, which fresh installer images now support out of the box.
|
||||||
|
- Incoming bitcoin appears in your wallet within seconds of being sent — balance and the yellow "unconfirmed" entry update live, no refresh, no waiting for the next poll.
|
||||||
|
- The speaker now announces the very first mesh message a node ever receives, and DMs announce just like channel messages (a safety guard against announcement storms was quietly swallowing them). Announcements also react about twice as fast.
|
||||||
|
- Opening a Lightning channel right after the node starts no longer fails with a scary red error. The node quietly retries while Lightning finishes waking up, and if it's still not ready you get a calm "still finishing its startup — try again shortly" notice instead.
|
||||||
|
- Viewing a transaction works on every node now, including small ones. Nodes with pruned bitcoin storage can't run the Mempool explorer app; transaction links now open your choice of external explorer instead (tx1138.com by default) — after a clear one-time warning that a third-party server will see which transaction you looked up. Set your preferred explorer in Wallet Settings → the new On-chain tab.
|
||||||
|
- Voice commands respond noticeably faster: speech recognition now transcribes in roughly half the time, with identical accuracy on short commands.
|
||||||
|
- Scanning a Lightning invoice with your phone's camera is far more reliable — dense invoice QR codes that the photo scanner missed are now read by the phone's native barcode engine.
|
||||||
|
- The companion app's pairing QR always contains an address your phone can actually reach. If you manage your node over a VPN (Tailscale), the QR used to embed the VPN address, and pairing silently failed; it now advertises the node's home-network address.
|
||||||
|
- Peer requests sent from Nostr discovery now actually arrive: your node checks for incoming requests every five minutes by itself (previously they sat unseen until someone manually pressed "Poll"), requests publish to all your configured relays instead of two hardcoded ones, and a failed send tells you instead of pretending it worked.
|
||||||
|
- The Connected Nodes list refreshes instantly. It previously froze for up to 30 seconds per offline peer while checking who's reachable, one peer at a time; the checks now run all at once in the background while the list shows immediately.
|
||||||
|
- Apps opened from inside a window (like a transaction from the wallet) now animate smoothly on top instead of loading invisibly underneath.
|
||||||
|
- Settings-style windows keep their tabs pinned at the top and their buttons pinned at the bottom; only the middle scrolls. The wallet's tabs are now Channels / Cashu / Fedi / Ark / On-chain so all five fit.
|
||||||
|
- On the TV screen, menus no longer flash open and instantly close. And the interface never follows your computer's light/dark preference anymore — dropdowns and other native controls stay dark on every device.
|
||||||
|
- Error messages tell you what's actually wrong: "Insufficient balance: need 80 sats, have 0 sats" now reaches your screen instead of "Operation failed. Check server logs."
|
||||||
|
- Installing Mempool no longer refuses to start while ElectrumX is mid-resync (it connects by itself once ElectrumX is ready), and installs no longer fail just because the system was momentarily busy.
|
||||||
|
- Much quieter logs: the node no longer tries to start containers that are already running (hundreds of harmless-but-alarming errors per day), and a node that's offline stops hammering unreachable servers every 30 seconds with rebuild attempts.
|
||||||
|
- Phones pairing with the companion app connect over the node's embedded mesh for remote access, with instant QR pairing and per-device access tokens (contributed alongside this release).
|
||||||
|
|
||||||
|
## v1.7.111-alpha (2026-07-22)
|
||||||
|
|
||||||
|
- Ask your node anything, out loud. Install Pine (the voice assistant app) alongside Home Assistant and everything wires itself automatically: speech recognition, the speaking voice, and a Claude-powered brain. Questions about your node — "what's the block height?", "how many peers am I connected to?", "is bitcoin synced?", "what's my Lightning balance?" — are answered instantly from the node itself without costing anything; anything else goes to Claude for a real conversation. New mesh radio messages are read out on your speaker as they arrive.
|
||||||
|
- Pine now ships a wake-word listener, so a paired speaker can sit on standby and activate when it hears its wake word instead of needing a button press. (A custom "Yo Archy" wake word is in the works.)
|
||||||
|
- Pine's launcher page shows your node's live status at a glance: software version, uptime, bitcoin sync progress, and mesh peers.
|
||||||
|
- Fixed: installing Pine could send Home Assistant into a crash loop on startup (a record the installer wrote was missing a timestamp field Home Assistant requires). Two noisy warnings that repeated in Home Assistant's log every half minute are silenced too.
|
||||||
|
- The companion phone app opens every app in its fast built-in browser view again, with native back/forward/reload controls, instead of embedding some apps inside the page where they scroll and render worse. This had quietly regressed.
|
||||||
|
- Turning on federation discovery now shows you exactly what you're about to sign: a panel explains the announcement before your key signs it, you can review the signing details any time from the discoverability strip, and the panel fits and scrolls properly on small phones.
|
||||||
|
- Fixed a bug on nodes using the newer app-management engine where Bitcoin's access credentials were written out incorrectly (a placeholder leaked through as the literal text "/bin/bash"), which broke the node's Bitcoin status display, Lightning's connection to the chain, and any app that reads Bitcoin data.
|
||||||
|
- Bitcoin's access credentials also moved out of the process command line into a protected file, so they're no longer visible to other software on the node.
|
||||||
|
- Desktop app windows have one-click buttons to switch between side panel, overlay, and fullscreen viewing.
|
||||||
|
- On the phone home screen, the wallet card moved up to sit right under My Apps.
|
||||||
|
- Home Assistant updated to 2026.7.3, which keeps voice satellites (like Pine's speaker) connected reliably.
|
||||||
|
|
||||||
|
## v1.7.110-alpha (2026-07-21)
|
||||||
|
|
||||||
|
- Pay by pointing your camera: the wallet has a new Scan button (on the wallet card and inside both the Send and Receive windows) that reads any payment QR code — Lightning invoices, Bitcoin addresses, Cashu tokens, and Fedimint invites — and takes you straight to the right send or redeem screen with everything filled in. It also understands the animated, multi-part QR codes some wallets show for long payloads. If your browser can't open a live camera preview (common when reaching the node over plain http), a "Take photo of QR" button snaps a picture with your phone's camera and reads the code from the photo instead.
|
||||||
|
- The TV screen got a complete overhaul. A deep bug made the display freeze on the intro artwork on 4K TVs — that's fixed, and along the way: the interface now picks a comfortable, sharp size for big screens (a 4K TV gets a full desktop layout at double sharpness), the artwork behind every page shows again instead of a black void, switching between tabs animates smoothly, the built-in AI assistant stays in its dark theme, and the Cashu and Ark wallet icons no longer render as empty squares.
|
||||||
|
- You can now choose how big the interface renders on your node's attached screen: Settings → Display offers Auto (recommended), Large UI, Balanced, and Native — changing it applies immediately.
|
||||||
|
- The companion phone app can steer the TV again. Remote input from the phone was being silently ignored on kiosk displays; the remote-control relay now runs there like everywhere else.
|
||||||
|
- The companion app is also ready to grant its built-in browser camera access, so the wallet scanner can work inside the app (ships with the next companion app build).
|
||||||
|
- Zero-amount Lightning invoices can now be paid: the wallet asks you for the amount and sends it along, instead of failing on invoices that leave the amount up to the payer.
|
||||||
|
- The Lightning setup guidance now reads the same everywhere: "Open a channel with Zeus Olympus node and start sending and receiving Lightning payments. Minimum 150,000 · maximum 1,500,000 on-chain sats required."
|
||||||
|
- Installer images now bundle a color-emoji font, so emoji anywhere in the interface render properly on the TV screen.
|
||||||
|
|
||||||
|
## v1.7.109-alpha (2026-07-21)
|
||||||
|
|
||||||
|
- Meet Pine, your node's voice assistant: a new app in the App Store that gives your node ears and a voice — speech-to-text and text-to-speech engines that run entirely on your own hardware, ready to wire into Home Assistant for private, offline voice control. Install it like any other app; nothing you say leaves your node.
|
||||||
|
- Your node can now program its MeshCore radio's RF settings — frequency, bandwidth, spreading factor, and coding rate — from Mesh → Device settings. Radios that were flashed with mismatched settings could hear that other radios exist but never decode their messages, and until now the only fix was a separate phone app. Set the values once and the node programs the radio automatically (it restarts once to apply); every radio on your mesh must use the same values to talk to each other.
|
||||||
|
- The Device settings panel is tidier: values you can edit (name, region, channel) are no longer also shown as separate read-only rows.
|
||||||
|
|
||||||
|
## v1.7.108-alpha (2026-07-20)
|
||||||
|
|
||||||
|
- Your node connects to the private mesh far more reliably. Nodes rely on a public rendezvous point to find each other, and the only one available was unreachable from many home and office networks — leaving some nodes unable to join the mesh at all. There is now a second, always-reachable rendezvous point, and your node tries every one it knows, so it joins the mesh in seconds instead of being stranded.
|
||||||
|
- Wi-Fi setup now heals itself on older nodes. Some nodes set up before a mid-year fix couldn't connect to a Wi-Fi network from the screen — it failed with a permissions error — because the piece that lets the node manage networking on your behalf was missing. Nodes now put that piece in place automatically on startup, so "scan, pick a network, type the password, connect" works without reinstalling.
|
||||||
|
- Your node rejoins the mesh within seconds after an update. Applying an update briefly restarts the mesh service, and previously a node could sit disconnected from other nodes for up to five minutes before it retried.
|
||||||
|
- The TV screen now fits your television. On a large or 4K TV the interface rendered tiny with no way to zoom on a keyboard-less screen; it now sizes itself to a comfortable, readable scale automatically (and small laptop panels are left unchanged).
|
||||||
|
- More TV-screen polish: the built-in assistant shows its dark theme instead of bright white panels, the on-screen hint for switching between the kiosk and a terminal now points at the right keys, the welcome logo no longer occasionally renders as garbled characters, and an accidental tap of the power button no longer shuts the node down — hold it to power off on purpose.
|
||||||
|
- Behind the scenes: fixed the installer image build so it no longer stops on a component that was removed from the product, and so it correctly includes the private relay it was meant to bundle.
|
||||||
|
|
||||||
|
## v1.7.106-alpha (2026-07-20)
|
||||||
|
|
||||||
|
- Nodes on the same network now find each other directly. Your node announces itself on your local network and connects straight to other Archipelago nodes nearby, instead of every connection having to be introduced by a public rendezvous server out on the internet. Peers in the same home or office stay connected to each other even when that server is unreachable, and they reach each other faster.
|
||||||
|
- On a phone, the peer files screen tells you how you're connected again. The badge showing whether a peer's files are arriving over the fast mesh or over Tor was only visible on desktop — on narrow screens it disappeared entirely. It now appears next to the peer name on mobile too.
|
||||||
|
- Your node's mesh settings can no longer be written in a way that breaks the mesh. The configuration file used to be assembled as free-form text, where one wrong setting would stop the mesh service from starting and quietly drop your node off the network. It's now generated from a checked description of the file, with tests that verify the exact output.
|
||||||
|
- When your node has trouble reaching another node, the logs now record the real reason instead of a generic summary. A failure to open a peer's files previously logged only "Failed to connect to peer" and threw away the actual cause, which made these problems very hard to diagnose. Nothing changes on screen, and no internal detail is exposed.
|
||||||
|
- Behind the scenes: the installer image now builds its mesh component at a fixed, known version instead of whatever upstream had published that day, so two images built from the same source are identical.
|
||||||
|
|
||||||
|
## v1.7.105-alpha (2026-07-20)
|
||||||
|
|
||||||
|
- Fixed a failure loop where a node that lost power or was moved could get stuck on a blank "can't reach your node" screen forever: startup recovery no longer spends minutes retrying containers that no longer exist, and a genuinely large recovery is no longer cut off half-way and forced to start over. The node now reaches its login screen even after the messiest shutdown.
|
||||||
|
- Phone tunnel setup (WireGuard) is now dependable: the QR screen automatically retries while a fresh install is still settling instead of dead-ending at "failed to fetch", and if your node has moved to a different network the QR and downloadable config now carry the node's current address instead of the old one.
|
||||||
|
- Fixed the white screen some laptop displays showed right after the intro on v1.7.104.
|
||||||
|
- The companion phone app no longer suggests installing the companion app from inside itself.
|
||||||
|
- The Tor page now lists onion addresses only for apps you actually have installed — fresh installs no longer come with six pre-made addresses for apps that were never set up.
|
||||||
|
- Running `archipelago --version` or `--help` on the command line now prints and exits instead of silently starting a second copy of the node, which could briefly disrupt running apps.
|
||||||
|
- Behind the scenes: installer image builds now stop loudly if VPN components are missing instead of producing a broken image, and a background file-permission sweep runs far less often, reducing disk churn on busy nodes.
|
||||||
|
|
||||||
|
## v1.7.104-alpha (2026-07-19)
|
||||||
|
|
||||||
|
- Software updates are now much safer to receive: the node will never install an update that isn't completely downloaded and verified byte-for-byte, closing a rare bug where an interrupted or cancelled download could leave a node unable to start.
|
||||||
|
- If a freshly installed update does fail to start, the node now notices and automatically restores the previous working version by itself — no manual rescue needed.
|
||||||
|
- The Electrum server now works with whichever Bitcoin you run: it finds Bitcoin Knots or Bitcoin Core automatically instead of assuming Knots.
|
||||||
|
- While the Electrum server is first building its index, its waiting screen now shows the ElectrumX app icon and live progress.
|
||||||
|
|
||||||
|
## v1.7.103-alpha (2026-07-18)
|
||||||
|
|
||||||
|
- Connecting from the phone app no longer replays the intro cinematic on a loop: signing in after scanning the pairing QR could accidentally trigger "Replay Intro" instead of logging you in. The companion app now lands you straight on your dashboard, and the Android app waits for the login screen to be ready before it types your password.
|
||||||
|
- Pressing Enter in any password box now does what you expect — it signs you in or moves to the next field, and can no longer "click" a nearby button by mistake when using a controller or the companion app.
|
||||||
|
- The public demo no longer interrupts you with an "Update Available" popup that reset the site back to the intro — demo visitors simply get the newest version on their next visit.
|
||||||
|
|
||||||
|
## v1.7.102-alpha (2026-07-17)
|
||||||
|
|
||||||
|
- The password you choose during setup is now truly your node's password: it also becomes the system login for console and SSH access, instead of leaving the factory default in place. If you ever renamed your node and the TV screen went black on the next boot, that's fixed too — renaming no longer breaks the kiosk display.
|
||||||
|
- Setting up Lightning is now a guided journey: a fund-your-wallet step that shows a live countdown while Bitcoin syncs, suggested channels you can open straight into the Zeus mobile wallet with one tap, and a "finish setup" prompt that walks you to the end — goals now complete when you've actually done the steps, not just when apps happen to be running.
|
||||||
|
- Pair your phone by pointing it at the screen: the companion app now connects by scanning a QR code — scan, and it fills in your node's address and logs you in. The App Store has a banner to grab the Android app, and the pairing flow can now also set up secure remote access so your phone reaches home from anywhere.
|
||||||
|
- First installs are far more dependable: app downloads that stall now retry instead of hanging forever (the old "first install fails, the second works" pattern), big multi-part apps show their real download progress instead of sitting at "Preparing", Lightning no longer fails its first install over temporary hiccups, and a brand-new node now comes up with its core apps — file cloud and ecash wallet — even with no internet connection.
|
||||||
|
- The installer image is about 160MB smaller and gets to a working screen faster, because the apps bundled for offline setup are now compressed.
|
||||||
|
- The first-run experience keeps its magic: the typing intro is back on fresh installs and can no longer be cut short by a mid-play refresh — updates now politely wait for the cinematic to finish — and dark backgrounds stay dark instead of flashing black or white.
|
||||||
|
- Your backups now include your secrets — including the key that protects your Lightning wallet's recovery seed — and there's a Download button to take a copy off the node; the seed-backup reminder now actually opens the backup flow when you tap it.
|
||||||
|
- Networking Profits grew into a full dashboard, network cards keep their action buttons in reach on every screen size, "Connect to Mesh" goes to the right page instead of a dead end, and the identity pages got a round of mobile polish.
|
||||||
|
- Behind the scenes: apps that report their own health are no longer second-guessed by a port probe (fewer false "restarting" states), and pressing arrow keys or a gamepad is once again the only thing that shows the controller focus ring.
|
||||||
|
|
||||||
## v1.7.101-alpha (2026-07-15)
|
## v1.7.101-alpha (2026-07-15)
|
||||||
|
|
||||||
- The wallet speaks Ark: a new Ark tab shows your Ark balance and history, you can send and receive over the Ark protocol, pay Lightning invoices from your Ark balance, and Ark payments appear in the transactions view with their own filter chip.
|
- The wallet speaks Ark: a new Ark tab shows your Ark balance and history, you can send and receive over the Ark protocol, pay Lightning invoices from your Ark balance, and Ark payments appear in the transactions view with their own filter chip.
|
||||||
@ -7,10 +178,10 @@
|
|||||||
- "Add Service" in the Tor panel now works for every app, not just a fixed list — the node reads the app's actual web port, so apps like Gitea, Jellyfin, Nextcloud, and Uptime Kuma no longer fail with "see server logs".
|
- "Add Service" in the Tor panel now works for every app, not just a fixed list — the node reads the app's actual web port, so apps like Gitea, Jellyfin, Nextcloud, and Uptime Kuma no longer fail with "see server logs".
|
||||||
- Renaming your node now genuinely renames it everywhere: the machine's hostname, its .local network name (re-announced immediately), the local hosts file, and the HTTPS certificate all follow — so http and https links using your node's name keep working right after a rename.
|
- Renaming your node now genuinely renames it everywhere: the machine's hostname, its .local network name (re-announced immediately), the local hosts file, and the HTTPS certificate all follow — so http and https links using your node's name keep working right after a rename.
|
||||||
- The node no longer mistakes a VPN tunnel for its own address. On fresh installs with NetBird, apps could launch on an internal 10.x address instead of your LAN IP; the node now reads its address from the actual network route, fixing app launch links, generated app configs, and VPN setup.
|
- The node no longer mistakes a VPN tunnel for its own address. On fresh installs with NetBird, apps could launch on an internal 10.x address instead of your LAN IP; the node now reads its address from the actual network route, fixing app launch links, generated app configs, and VPN setup.
|
||||||
- Your cloud got a real layout: Apps-style tabs with categories for Folders, My Files, and Peer Files, readable file rows, and a search that also finds files shared by your federated peer nodes.
|
- Your cloud got a real layout: Apps-style tabs with categories for Folders, My Files, and Peer Files, readable file rows, a search that also finds files shared by your federated peer nodes — and music now opens in the bottom-bar player instead of a broken preview window.
|
||||||
- The first-login dashboard entrance animation is back, and "Replay Intro" in Settings actually replays it.
|
- The first-login experience flows again: the dashboard entrance animation is back — and you can actually hear it now (its sound was silently swallowed before, including on replays) — "Replay Intro" in Settings actually replays it, opening a direct link to an inner page no longer detours through the splash screen, the login screen keeps the intro video until your first login (switching to rotating backgrounds after), and the intro video streams three times lighter so it starts instantly.
|
||||||
- Changing DNS settings no longer blanks the page, and the DNS and WiFi dialogs now cover the whole app instead of only the right panel.
|
- Changing DNS settings no longer blanks the page, and the DNS and WiFi dialogs now cover the whole app instead of only the right panel.
|
||||||
- The public demo is richer and truer: Ark wallet flows, working DNS and Tor service management, and a library of peer content with previews that never break.
|
- The public demo is richer and truer: the intro plays on every fresh visit, Ark wallet flows, working DNS and Tor service management, and a library of peer content with previews that never break.
|
||||||
- Assorted fixes: failed installs clean up after themselves properly, and the transactions view fits mobile screens (capped at 60% of the visible viewport).
|
- Assorted fixes: failed installs clean up after themselves properly, and the transactions view fits mobile screens (capped at 60% of the visible viewport).
|
||||||
|
|
||||||
## v1.7.100-alpha (2026-07-14)
|
## v1.7.100-alpha (2026-07-14)
|
||||||
|
|||||||
19
CODE_OF_CONDUCT.md
Normal file
19
CODE_OF_CONDUCT.md
Normal file
@ -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.
|
||||||
191
CONTRIBUTING.md
191
CONTRIBUTING.md
@ -1,161 +1,100 @@
|
|||||||
# Contributing to Archipelago
|
# 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/).
|
### Frontend
|
||||||
|
|
||||||
## Getting Started
|
|
||||||
|
|
||||||
1. Fork the repository on the project's Gitea instance
|
|
||||||
2. Clone your fork: `git clone <your-fork-url>/archy.git`
|
|
||||||
3. Set up the dev environment (see `docs/developer-guide.md`)
|
|
||||||
4. Create a feature branch: `git checkout -b feature/your-feature`
|
|
||||||
|
|
||||||
## Development Setup
|
|
||||||
|
|
||||||
### Frontend (Vue.js)
|
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
cd neode-ui
|
cd neode-ui
|
||||||
npm install
|
npm install
|
||||||
npm start # Dev server on :8100
|
npm start
|
||||||
npm run type-check # TypeScript validation
|
npm run type-check
|
||||||
npm run build # Production build
|
npm test
|
||||||
npm test # Run tests
|
|
||||||
```
|
```
|
||||||
|
|
||||||
### Backend (Rust)
|
### Backend
|
||||||
|
|
||||||
Build on a Linux server (Debian 13), **not** macOS:
|
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
cargo clippy --all-targets --all-features
|
cd core
|
||||||
cargo fmt --all
|
cargo fmt --all -- --check
|
||||||
|
cargo clippy --all-targets --all-features -- -D warnings
|
||||||
cargo test --all-features
|
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
|
```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
|
## Code style
|
||||||
- TypeScript strict mode — no `any`, use `unknown` or proper types
|
|
||||||
- Global CSS classes in `src/style.css` — never inline Tailwind in components
|
|
||||||
- Pinia for state management — focused single-purpose stores
|
|
||||||
- Use `@/api/rpc-client.ts` for RPC calls
|
|
||||||
|
|
||||||
### Backend (Rust)
|
- 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
|
## Pull requests
|
||||||
- `thiserror` for library errors, `anyhow` for application errors
|
|
||||||
- `tracing` for structured logging — never `println!`
|
|
||||||
- Run `cargo clippy` and `cargo fmt` before commits
|
|
||||||
|
|
||||||
### 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
|
Suggested commit format:
|
||||||
- Comment WHY not WHAT
|
|
||||||
- Remove dead code — never comment it out
|
|
||||||
- No `TODO`/`FIXME` in commits
|
|
||||||
|
|
||||||
## Commit Format
|
```text
|
||||||
|
feat: add backup scheduling
|
||||||
```
|
fix: reject unsafe manifest volume
|
||||||
type: description
|
docs: clarify app deployment flow
|
||||||
|
test: cover catalog drift check
|
||||||
```
|
```
|
||||||
|
|
||||||
**Types**: `feat:`, `fix:`, `docs:`, `refactor:`, `test:`, `chore:`, `perf:`
|
## Reporting bugs
|
||||||
|
|
||||||
Examples:
|
Include:
|
||||||
- `feat: add backup scheduling to settings page`
|
|
||||||
- `fix: handle WiFi connection timeout gracefully`
|
|
||||||
- `test: add unit tests for RPC client retry logic`
|
|
||||||
|
|
||||||
## Pull Request Process
|
- 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`
|
## Security
|
||||||
2. All checks must pass: TypeScript, build, tests, clippy
|
|
||||||
3. Include a clear description of what changed and why
|
|
||||||
4. Link any related issues
|
|
||||||
5. Request review from a maintainer
|
|
||||||
|
|
||||||
### PR Checklist
|
Do not report vulnerabilities in public issues. Follow [SECURITY.md](SECURITY.md).
|
||||||
|
|
||||||
- [ ] TypeScript type-check passes (`npm run type-check`)
|
|
||||||
- [ ] Frontend builds (`npm run build`)
|
|
||||||
- [ ] Tests pass (`npm test`)
|
|
||||||
- [ ] Rust clippy clean (`cargo clippy --all-targets --all-features`)
|
|
||||||
- [ ] No new compiler warnings
|
|
||||||
- [ ] Follows code style guidelines above
|
|
||||||
|
|
||||||
## Testing Requirements
|
|
||||||
|
|
||||||
- New features need tests
|
|
||||||
- Bug fixes need a regression test
|
|
||||||
- Frontend: Vitest + Vue Test Utils
|
|
||||||
- Backend: `#[test]` and `#[tokio::test]`
|
|
||||||
- Target: maintain or improve existing coverage
|
|
||||||
|
|
||||||
## Reporting Bugs
|
|
||||||
|
|
||||||
Use the **Bug Report** issue template. Include:
|
|
||||||
|
|
||||||
1. Steps to reproduce
|
|
||||||
2. Expected behavior
|
|
||||||
3. Actual behavior
|
|
||||||
4. System info (hardware, OS version, Archipelago version)
|
|
||||||
5. Screenshots if applicable
|
|
||||||
6. Relevant logs (`journalctl -u archipelago`)
|
|
||||||
|
|
||||||
## Feature Requests
|
|
||||||
|
|
||||||
Use the **Feature Request** issue template. Include:
|
|
||||||
|
|
||||||
1. Problem description
|
|
||||||
2. Proposed solution
|
|
||||||
3. Alternatives considered
|
|
||||||
4. Impact on existing users
|
|
||||||
|
|
||||||
## App Submissions
|
|
||||||
|
|
||||||
To submit an app for the Archipelago marketplace:
|
|
||||||
|
|
||||||
1. Create a manifest following `docs/app-manifest-spec.md`
|
|
||||||
2. Ensure the container image is published to a public registry
|
|
||||||
3. Test on Archipelago hardware (x86_64 and ARM64 if possible)
|
|
||||||
4. Open a PR adding the app to the curated list
|
|
||||||
5. Include: app description, icon, resource requirements, dependencies
|
|
||||||
|
|
||||||
### App Requirements
|
|
||||||
|
|
||||||
- Container must run as non-root (UID > 1000)
|
|
||||||
- `readonly_root: true` unless explicitly justified
|
|
||||||
- Drop all capabilities except those required
|
|
||||||
- `no-new-privileges: true`
|
|
||||||
- Pin specific image versions (no `latest` tag)
|
|
||||||
- No hardcoded secrets
|
|
||||||
|
|
||||||
## Security Disclosure
|
|
||||||
|
|
||||||
**Do NOT open public issues for security vulnerabilities.**
|
|
||||||
|
|
||||||
Email security concerns to the maintainers directly. Include:
|
|
||||||
|
|
||||||
1. Description of the vulnerability
|
|
||||||
2. Steps to reproduce
|
|
||||||
3. Potential impact
|
|
||||||
4. Suggested fix (if any)
|
|
||||||
|
|
||||||
We will acknowledge receipt within 48 hours and provide a timeline for a fix.
|
|
||||||
|
|
||||||
## License
|
## 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.
|
||||||
|
|||||||
21
LICENSE
Normal file
21
LICENSE
Normal file
@ -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.
|
||||||
73
NOTICE
Normal file
73
NOTICE
Normal file
@ -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).
|
||||||
229
README.md
229
README.md
@ -1,8 +1,11 @@
|
|||||||
# Archipelago
|
# 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/)
|
[](https://www.debian.org/)
|
||||||
[](LICENSE)
|
[](LICENSE)
|
||||||
@ -10,193 +13,101 @@
|
|||||||
[](https://vuejs.org/)
|
[](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.
|
## Platform model
|
||||||
- **Signed distribution.** App manifests ship inside an Ed25519-signed catalog verified against a pinned release-root key, not as loose files on disk. OTA release manifests are signed the same way.
|
|
||||||
- **Decentralized marketplace.** Third-party developers publish apps via Nostr-based discovery (NIP-78) with DID-signed manifests and federation-weighted trust scoring — no gatekept central store.
|
|
||||||
- **Rootless and secure by default.** Rootless Podman only. Read-only root, no-new-privileges, capability allow-list, secrets materialised 0600 and never logged. Never rootful, never a Docker socket mount.
|
|
||||||
- **100%-uptime-capable.** Every container is a systemd Quadlet unit under `user.slice` that survives backend restarts; a level-triggered reconciler self-heals drift every 30 seconds; migrations never destroy data.
|
|
||||||
|
|
||||||
## Features
|
Archipelago is built as a developer-ready app platform, not a fixed appliance:
|
||||||
|
|
||||||
### Bitcoin Infrastructure
|
- Apps are declared in `apps/<app-id>/manifest.yml`.
|
||||||
- **Bitcoin Core and Bitcoin Knots** full nodes with per-app version pinning and bulletproof version switching, automatic prune/full mode based on disk size
|
- The Rust parser in `core/container/src/manifest.rs` is the canonical schema.
|
||||||
- **LND** and **Core Lightning** with channel management
|
- The orchestrator compiles manifests to rootless Podman/Quadlet runtime state.
|
||||||
- **ElectrumX** Electrum server for wallet connectivity
|
- App data lives under `/var/lib/archipelago/<app-id>/`.
|
||||||
- **BTCPay Server** for accepting Bitcoin payments
|
- Secrets are generated or read from `/var/lib/archipelago/secrets/` and
|
||||||
- **Mempool** block explorer and fee estimator
|
injected through Podman secrets rather than static environment values.
|
||||||
- **Fedimint** federation guardian, gateway, and client — plus Cashu ecash wallet support
|
- Release and app catalogs are signed and verified against a pinned trust
|
||||||
|
anchor.
|
||||||
|
|
||||||
### Self-Hosted Apps (50+)
|
Start with:
|
||||||
Storage (FileBrowser, Immich, Nextcloud), Productivity (Vaultwarden), Media (Jellyfin, PhotoPrism, IndeeHub), Search (SearXNG), Network (NetBird, Tailscale), Home (Home Assistant), Nostr (nostr-rs-relay, strfry), Dev/Ops (Gitea, Grafana, Portainer, Uptime Kuma), and more — 27 curated in the store UI, 50+ packaged as manifests.
|
|
||||||
|
|
||||||
### Mesh Networking (tri-protocol)
|
- [Architecture](docs/architecture.md)
|
||||||
- **Meshtastic**, **MeshCore**, and **Reticulum (RNS/LXMF)** LoRa transports behind one mesh chat UI
|
- [Developer Guide](docs/developer-guide.md)
|
||||||
- End-to-end encryption with X3DH key agreement + double-ratchet
|
- [App Developer Guide](docs/app-developer-guide.md)
|
||||||
- RNode radio support with an OS-level `archy-rnodeconf` tool; interop verified against Sideband
|
- [App Manifest Spec](docs/app-manifest-spec.md)
|
||||||
- Image/voice attachments, mesh AI assistant (`!ai`), Bitcoin balance relay over mesh
|
- [Nostr Git Source Hosting Plan](docs/nostr-git-source-hosting.md)
|
||||||
|
- [Operations Runbook](docs/operations-runbook.md)
|
||||||
|
- [Troubleshooting](docs/troubleshooting.md)
|
||||||
|
|
||||||
### Decentralized Identity
|
## Quick start
|
||||||
- Ed25519 node identity with DID Documents (did:key)
|
|
||||||
- Multi-identity management (Personal/Business/Anonymous)
|
|
||||||
- W3C Verifiable Credentials issuance and verification
|
|
||||||
- Nostr integration: NIP-33 node discovery, NIP-44/NIP-04 encryption, NIP-07 signer bridge for iframe apps, relay hosting
|
|
||||||
- Decentralized Web Node (DWN) record sync between federated nodes over Tor
|
|
||||||
|
|
||||||
### Multi-Node Federation
|
### Frontend
|
||||||
- Invite-based node joining over Tor hidden services
|
|
||||||
- Trust levels (Trusted/Verified/Untrusted) with DID-based auth
|
|
||||||
- State sync and app deployment across federated nodes
|
|
||||||
- File sharing with access controls (free/peers-only/paid via Lightning, on-chain, or ecash)
|
|
||||||
|
|
||||||
### System Updates
|
|
||||||
- OTA updates from a self-hosted Gitea release server, Ed25519-signature-verified against a pinned release-root key
|
|
||||||
- Resumable downloads, automatic pre-update backup, rollback with a post-update self-verify window
|
|
||||||
- Manual, scheduled-check, and auto-apply modes (auto-apply refuses unsigned manifests)
|
|
||||||
|
|
||||||
### Security
|
|
||||||
- Argon2id password hashing (transparent upgrade from legacy hashes), ChaCha20-Poly1305 encrypted secrets at rest
|
|
||||||
- Rootless Podman: read-only root, cap-drop ALL with a reviewed allow-list, no-new-privileges
|
|
||||||
- Signed release manifests and signed app catalog (Ed25519, pinned trust anchor)
|
|
||||||
- TOTP two-factor authentication, per-endpoint rate limiting, CSRF protection
|
|
||||||
- AppArmor profiles for container confinement; Tor hidden services for inter-node traffic
|
|
||||||
- Independent security audit of an early version archived in [`docs/archive/`](docs/archive/security-code-audit-2026-03.md); top findings since remediated
|
|
||||||
|
|
||||||
## Roadmap
|
|
||||||
|
|
||||||
**Done**
|
|
||||||
- Single-node production gate **green** — install / stop / start / restart / reinstall / reboot-survive / uninstall, 5 consecutive full runs with zero failures on real hardware
|
|
||||||
- Quadlet migration validated (all backends as `user.slice` services on the canary node)
|
|
||||||
- Release signing ceremony completed — release-root key pinned, catalog and OTA manifests signed
|
|
||||||
- Reticulum third mesh transport (real-RF LoRa gates passed), Bitcoin Core/Knots multi-version switching, decentralized marketplace backend, public demo
|
|
||||||
|
|
||||||
**In progress**
|
|
||||||
- Multinode pass: the same production gate across the whole test fleet ([`docs/multinode-testing-plan.md`](docs/multinode-testing-plan.md))
|
|
||||||
- Quadlet default flip fleet-wide + container-flapping elimination
|
|
||||||
- 1.8.0 release hardening tail ([`docs/1.8.0-RELEASE-HARDENING-PLAN.md`](docs/1.8.0-RELEASE-HARDENING-PLAN.md)): OTA upgrade soak on real hardware, ISO/image hardening (per-device keys, no default creds, signed ISO)
|
|
||||||
|
|
||||||
**Planned**
|
|
||||||
- Developer CLI (`archy app validate/render/install/test`) to open third-party app publishing
|
|
||||||
- External marketplace trust UX + publishing tooling ([`docs/marketplace-protocol.md`](docs/marketplace-protocol.md))
|
|
||||||
- DHT/P2P distribution of releases and app images ([`docs/dht-distribution-design.md`](docs/dht-distribution-design.md))
|
|
||||||
- P2P encrypted voice/video over Tor, dual-ecash (Fedimint + Cashu) phases, paid streaming, hardware signer support
|
|
||||||
|
|
||||||
The live, priority-ordered task list is [`docs/UNIFIED-TASK-TRACKER.md`](docs/UNIFIED-TASK-TRACKER.md); the full narrative plan is [`docs/PRODUCTION-MASTER-PLAN.md`](docs/PRODUCTION-MASTER-PLAN.md).
|
|
||||||
|
|
||||||
## Quick Start
|
|
||||||
|
|
||||||
### Install from ISO
|
|
||||||
|
|
||||||
1. Build or download the ISO for your architecture (x86_64 or ARM64) — see [`image-recipe/`](image-recipe/)
|
|
||||||
2. Flash to USB drive with Balena Etcher or `dd`
|
|
||||||
3. Boot from USB on target hardware and follow the automated installer
|
|
||||||
4. Access the web UI at `http://<device-ip>`
|
|
||||||
5. Set your password and complete the onboarding wizard (seed backup, DID identity)
|
|
||||||
|
|
||||||
### Supported Hardware
|
|
||||||
|
|
||||||
| Platform | Examples | Minimum |
|
|
||||||
|----------|----------|---------|
|
|
||||||
| **x86_64** | Intel NUC, mini PCs, any 64-bit PC | 4GB RAM, 32GB storage |
|
|
||||||
| **ARM64** | Raspberry Pi 5, ARM64 SBCs | 4GB RAM, 32GB storage |
|
|
||||||
|
|
||||||
**Recommended**: 8GB+ RAM, 1TB+ NVMe SSD (for a full Bitcoin node). Optional: an RNode-compatible LoRa radio for mesh networking.
|
|
||||||
|
|
||||||
## Development
|
|
||||||
|
|
||||||
### Prerequisites
|
|
||||||
- macOS or Linux for frontend development
|
|
||||||
- Linux dev server (Debian 13) for backend builds — **never build Rust on macOS for Linux**
|
|
||||||
- Node.js 20+, Rust stable toolchain
|
|
||||||
|
|
||||||
### Frontend Development
|
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
cd neode-ui
|
cd neode-ui
|
||||||
npm install
|
npm install
|
||||||
npm start # Dev server on http://localhost:8100 (mock backend on :5959)
|
npm start
|
||||||
npm run type-check # TypeScript validation
|
|
||||||
npm run build # Production build → web/dist/neode-ui/
|
|
||||||
```
|
```
|
||||||
|
|
||||||
### Backend Development
|
The dev UI runs at `http://localhost:8100` with a mock backend on `:5959`.
|
||||||
|
|
||||||
|
### Backend
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
cd core # Rust workspace root (no Cargo.toml at repo root)
|
cd core
|
||||||
cargo build
|
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
|
```bash
|
||||||
./scripts/deploy-to-target.sh --live # Deploy to primary dev server
|
./scripts/validate-app-manifest.sh apps/filebrowser/manifest.yml
|
||||||
./scripts/deploy-to-target.sh --both # Deploy to both LAN servers
|
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
|
## Documentation map
|
||||||
`releases/manifest.json`, published to the self-hosted Gitea release server.
|
|
||||||
|
|
||||||
```bash
|
|
||||||
./scripts/create-release.sh 1.2.3
|
|
||||||
git push origin main --tags
|
|
||||||
```
|
|
||||||
|
|
||||||
## Architecture
|
|
||||||
|
|
||||||
```
|
|
||||||
Debian 13 (Trixie)
|
|
||||||
├── Rootless Podman — every app a systemd Quadlet unit under user.slice
|
|
||||||
├── Nginx (reverse proxy, security headers, rate limiting)
|
|
||||||
├── Rust Backend (JSON-RPC API on 127.0.0.1:5678, ~380 RPC methods)
|
|
||||||
│ ├── core/archipelago/ — API, orchestrator + reconciler, mesh, identity,
|
|
||||||
│ │ federation, wallet, updates, marketplace
|
|
||||||
│ ├── core/container/ — Podman client, manifest schema, Quadlet compiler,
|
|
||||||
│ │ health monitor, signed app catalog
|
|
||||||
│ ├── core/security/ — AppArmor/seccomp policy, secrets manager
|
|
||||||
│ ├── core/openwrt/ — TollGate gateway provisioning (SSH/UCI)
|
|
||||||
│ └── core/performance/ — resource limits
|
|
||||||
├── Vue 3 Frontend (Composition API + TypeScript strict + Pinia + Tailwind, PWA)
|
|
||||||
│ └── Three UI modes (Pro/Easy/Chat) + gamepad navigation + i18n
|
|
||||||
├── Reticulum daemon (supervised Python/PyInstaller, one per LoRa radio)
|
|
||||||
└── System Tor (hidden services, SOCKS5 proxy)
|
|
||||||
```
|
|
||||||
|
|
||||||
~117,000 lines of Rust | ~69,000 lines of TypeScript/Vue | 51 packaged apps | Android companion app
|
|
||||||
|
|
||||||
## Documentation
|
|
||||||
|
|
||||||
| Doc | Purpose |
|
| Doc | Purpose |
|
||||||
|-----|---------|
|
|-----|---------|
|
||||||
| [Architecture](docs/architecture.md) | System design, crate map, data paths |
|
| [Architecture](docs/architecture.md) | System layers, crates, data paths, security model |
|
||||||
| [Developer Guide](docs/developer-guide.md) | Dev setup, workflow, code conventions |
|
| [Developer Guide](docs/developer-guide.md) | Local setup, code workflow, testing |
|
||||||
| [API Reference](docs/api-reference.md) | RPC endpoint reference |
|
| [API Reference](docs/api-reference.md) | JSON-RPC API overview |
|
||||||
| [App Developer Guide](docs/app-developer-guide.md) | Building and publishing apps |
|
| [App Developer Guide](docs/app-developer-guide.md) | How to package and test apps |
|
||||||
| [App Manifest Spec](docs/app-manifest-spec.md) | The `manifest.yml` schema |
|
| [App Manifest Spec](docs/app-manifest-spec.md) | Manifest schema and validation rules |
|
||||||
| [User Walkthrough](docs/user-walkthrough.md) | End-user installation and usage guide |
|
| [Nostr Git Source Hosting Plan](docs/nostr-git-source-hosting.md) | ngit/NIP-34 contribution workflow and maintainer model |
|
||||||
| [Troubleshooting](docs/troubleshooting.md) | Diagnostic scenarios and solutions |
|
| [Apps README](apps/README.md) | Packaged app catalog overview |
|
||||||
| [Operations Runbook](docs/operations-runbook.md) | Ops commands and emergency recovery |
|
| [Image Recipe](image-recipe/README.md) | Bootable image build flow |
|
||||||
| [Production Master Plan](docs/PRODUCTION-MASTER-PLAN.md) | North star and workstream narrative |
|
| [Operations Runbook](docs/operations-runbook.md) | Production operations and recovery |
|
||||||
| [Unified Task Tracker](docs/UNIFIED-TASK-TRACKER.md) | Live, priority-ordered open items |
|
| [Open Source Readiness](docs/OPEN_SOURCE_READINESS.md) | Public-release cleanup checklist |
|
||||||
| [Test Gate](tests/lifecycle/TESTING.md) | Production lifecycle test gate (definition of done) |
|
| [Roadmap](docs/ROADMAP.md) | Shipped, in-progress, and planned work |
|
||||||
| [Archive](docs/archive/) | Historical audits, session logs, shipped designs |
|
| [Unified Task Tracker](docs/UNIFIED-TASK-TRACKER.md) | Launch hardening task list |
|
||||||
|
| [Archive](docs/archive/) | Historical plans, audits, and handoffs |
|
||||||
|
|
||||||
## Contributing
|
## Contributing
|
||||||
|
|
||||||
1. Fork the repository
|
Read [CONTRIBUTING.md](CONTRIBUTING.md) before opening a pull request. For
|
||||||
2. Create a feature branch (`feature/description`)
|
security issues, follow [SECURITY.md](SECURITY.md) and do not open a public
|
||||||
3. Follow the coding standards in [CONTRIBUTING.md](CONTRIBUTING.md) and [CLAUDE.md](CLAUDE.md)
|
issue.
|
||||||
4. Submit a pull request
|
|
||||||
|
|
||||||
## License
|
## License
|
||||||
|
|
||||||
[MIT License](LICENSE)
|
Archipelago is licensed under the [MIT License](LICENSE). Third-party notices
|
||||||
|
are listed in [NOTICE](NOTICE) and generated license inventories in component
|
||||||
## Acknowledgments
|
release artifacts.
|
||||||
|
|
||||||
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/)
|
|
||||||
|
|||||||
38
SECURITY.md
Normal file
38
SECURITY.md
Normal file
@ -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.
|
||||||
@ -351,12 +351,12 @@
|
|||||||
{
|
{
|
||||||
"id": "homeassistant",
|
"id": "homeassistant",
|
||||||
"title": "Home Assistant",
|
"title": "Home Assistant",
|
||||||
"version": "2024.1.0",
|
"version": "2026.7.3",
|
||||||
"description": "Open source home automation platform. Control and monitor your smart home devices.",
|
"description": "Open source home automation platform. Control and monitor your smart home devices.",
|
||||||
"icon": "/assets/img/app-icons/homeassistant.png",
|
"icon": "/assets/img/app-icons/homeassistant.png",
|
||||||
"author": "Home Assistant",
|
"author": "Home Assistant",
|
||||||
"category": "home",
|
"category": "home",
|
||||||
"dockerImage": "146.59.87.168:3000/lfg2025/home-assistant:2024.1",
|
"dockerImage": "146.59.87.168:3000/lfg2025/home-assistant:2026.7.3",
|
||||||
"repoUrl": "https://github.com/home-assistant/core",
|
"repoUrl": "https://github.com/home-assistant/core",
|
||||||
"containerConfig": {
|
"containerConfig": {
|
||||||
"ports": [
|
"ports": [
|
||||||
@ -370,6 +370,17 @@
|
|||||||
]
|
]
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
"id": "pine",
|
||||||
|
"title": "Pine",
|
||||||
|
"version": "1.3.0",
|
||||||
|
"description": "A private voice assistant for your home. Pine runs speech-to-text (Whisper), text-to-speech (Piper) and wake-word detection (openWakeWord) on your own node and pairs with a PineVoice satellite speaker, so Home Assistant Assist works locally with nothing sent to the cloud. Ask it about your node — block height, sync, peers, Lightning balance — and, when a Claude API key is set, anything else.",
|
||||||
|
"icon": "/assets/img/app-icons/pine.svg",
|
||||||
|
"author": "Archipelago",
|
||||||
|
"category": "home",
|
||||||
|
"dockerImage": "docker.io/library/nginx:1.27-alpine",
|
||||||
|
"repoUrl": "https://github.com/rhasspy/wyoming"
|
||||||
|
},
|
||||||
{
|
{
|
||||||
"id": "grafana",
|
"id": "grafana",
|
||||||
"title": "Grafana",
|
"title": "Grafana",
|
||||||
|
|||||||
@ -76,7 +76,17 @@ podman run -p 18084:8080 \
|
|||||||
|
|
||||||
## Integration Checklist
|
## 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
|
## Port Assignments
|
||||||
|
|
||||||
|
|||||||
@ -35,6 +35,9 @@ app:
|
|||||||
fi;
|
fi;
|
||||||
RPC_USER="$(printenv BITCOIN_RPC_USER)";
|
RPC_USER="$(printenv BITCOIN_RPC_USER)";
|
||||||
RPC_PASS="$(printenv BITCOIN_RPC_PASS)";
|
RPC_PASS="$(printenv BITCOIN_RPC_PASS)";
|
||||||
|
RPC_CONF="/tmp/rpc.conf";
|
||||||
|
umask 077;
|
||||||
|
{ echo "rpcuser=$RPC_USER"; echo "rpcpassword=$RPC_PASS"; } > "$RPC_CONF";
|
||||||
RPC_TXRELAY_AUTH="$(printenv BITCOIN_RPC_TXRELAY_RPCAUTH || true)";
|
RPC_TXRELAY_AUTH="$(printenv BITCOIN_RPC_TXRELAY_RPCAUTH || true)";
|
||||||
DISK_GB_VALUE="$(printenv DISK_GB || true)";
|
DISK_GB_VALUE="$(printenv DISK_GB || true)";
|
||||||
RPC_HEADROOM="-rpcthreads=16 -rpcworkqueue=256";
|
RPC_HEADROOM="-rpcthreads=16 -rpcworkqueue=256";
|
||||||
@ -43,9 +46,9 @@ app:
|
|||||||
RPC_TXRELAY_FLAGS="$RPC_TXRELAY_FLAGS -rpcauth=$RPC_TXRELAY_AUTH -rpcwhitelist=txrelay:sendrawtransaction,submitpackage,testmempoolaccept,getmempoolinfo,getrawmempool,getmempoolentry,getnetworkinfo,getblockchaininfo,getblockcount,getblockhash,getblock,getblockheader,getrawtransaction,gettxout,gettxspendingprevout,decoderawtransaction,decodescript,estimatesmartfee,uptime,ping,getconnectioncount,getpeerinfo,getindexinfo,getdeploymentinfo,getchaintips";
|
RPC_TXRELAY_FLAGS="$RPC_TXRELAY_FLAGS -rpcauth=$RPC_TXRELAY_AUTH -rpcwhitelist=txrelay:sendrawtransaction,submitpackage,testmempoolaccept,getmempoolinfo,getrawmempool,getmempoolentry,getnetworkinfo,getblockchaininfo,getblockcount,getblockhash,getblock,getblockheader,getrawtransaction,gettxout,gettxspendingprevout,decoderawtransaction,decodescript,estimatesmartfee,uptime,ping,getconnectioncount,getpeerinfo,getindexinfo,getdeploymentinfo,getchaintips";
|
||||||
fi;
|
fi;
|
||||||
if [ "${DISK_GB_VALUE:-0}" -lt 1000 ]; then
|
if [ "${DISK_GB_VALUE:-0}" -lt 1000 ]; then
|
||||||
exec "$BITCOIND" -datadir=/home/bitcoin/.bitcoin -noconf -printtoconsole=0 -server=1 -prune=550 -rpcallowip=0.0.0.0/0 -rpcbind=0.0.0.0:8332 -listen=1 -bind=0.0.0.0:8333 -dbcache=1024 -par=0 -maxconnections=125 $RPC_HEADROOM $RPC_TXRELAY_FLAGS -rpcuser="$RPC_USER" -rpcpassword="$RPC_PASS";
|
exec "$BITCOIND" -datadir=/home/bitcoin/.bitcoin -conf="$RPC_CONF" -printtoconsole=0 -server=1 -prune=550 -rpcallowip=0.0.0.0/0 -rpcbind=0.0.0.0:8332 -listen=1 -bind=0.0.0.0:8333 -dbcache=1024 -par=0 -maxconnections=125 $RPC_HEADROOM $RPC_TXRELAY_FLAGS;
|
||||||
else
|
else
|
||||||
exec "$BITCOIND" -datadir=/home/bitcoin/.bitcoin -noconf -printtoconsole=0 -server=1 -txindex=1 -rpcallowip=0.0.0.0/0 -rpcbind=0.0.0.0:8332 -listen=1 -bind=0.0.0.0:8333 -dbcache=4096 -par=0 -maxconnections=125 $RPC_HEADROOM $RPC_TXRELAY_FLAGS -rpcuser="$RPC_USER" -rpcpassword="$RPC_PASS";
|
exec "$BITCOIND" -datadir=/home/bitcoin/.bitcoin -conf="$RPC_CONF" -printtoconsole=0 -server=1 -txindex=1 -rpcallowip=0.0.0.0/0 -rpcbind=0.0.0.0:8332 -listen=1 -bind=0.0.0.0:8333 -dbcache=4096 -par=0 -maxconnections=125 $RPC_HEADROOM $RPC_TXRELAY_FLAGS;
|
||||||
fi
|
fi
|
||||||
derived_env:
|
derived_env:
|
||||||
- key: DISK_GB
|
- key: DISK_GB
|
||||||
|
|||||||
@ -35,6 +35,9 @@ app:
|
|||||||
fi;
|
fi;
|
||||||
RPC_USER="$(printenv BITCOIN_RPC_USER)";
|
RPC_USER="$(printenv BITCOIN_RPC_USER)";
|
||||||
RPC_PASS="$(printenv BITCOIN_RPC_PASS)";
|
RPC_PASS="$(printenv BITCOIN_RPC_PASS)";
|
||||||
|
RPC_CONF="/tmp/rpc.conf";
|
||||||
|
umask 077;
|
||||||
|
{ echo "rpcuser=$RPC_USER"; echo "rpcpassword=$RPC_PASS"; } > "$RPC_CONF";
|
||||||
RPC_TXRELAY_AUTH="$(printenv BITCOIN_RPC_TXRELAY_RPCAUTH || true)";
|
RPC_TXRELAY_AUTH="$(printenv BITCOIN_RPC_TXRELAY_RPCAUTH || true)";
|
||||||
DISK_GB_VALUE="$(printenv DISK_GB || true)";
|
DISK_GB_VALUE="$(printenv DISK_GB || true)";
|
||||||
RPC_HEADROOM="-rpcthreads=16 -rpcworkqueue=256";
|
RPC_HEADROOM="-rpcthreads=16 -rpcworkqueue=256";
|
||||||
@ -43,9 +46,9 @@ app:
|
|||||||
RPC_TXRELAY_FLAGS="$RPC_TXRELAY_FLAGS -rpcauth=$RPC_TXRELAY_AUTH -rpcwhitelist=txrelay:sendrawtransaction,submitpackage,testmempoolaccept,getmempoolinfo,getrawmempool,getmempoolentry,getnetworkinfo,getblockchaininfo,getblockcount,getblockhash,getblock,getblockheader,getrawtransaction,gettxout,gettxspendingprevout,decoderawtransaction,decodescript,estimatesmartfee,uptime,ping,getconnectioncount,getpeerinfo,getindexinfo,getdeploymentinfo,getchaintips";
|
RPC_TXRELAY_FLAGS="$RPC_TXRELAY_FLAGS -rpcauth=$RPC_TXRELAY_AUTH -rpcwhitelist=txrelay:sendrawtransaction,submitpackage,testmempoolaccept,getmempoolinfo,getrawmempool,getmempoolentry,getnetworkinfo,getblockchaininfo,getblockcount,getblockhash,getblock,getblockheader,getrawtransaction,gettxout,gettxspendingprevout,decoderawtransaction,decodescript,estimatesmartfee,uptime,ping,getconnectioncount,getpeerinfo,getindexinfo,getdeploymentinfo,getchaintips";
|
||||||
fi;
|
fi;
|
||||||
if [ "${DISK_GB_VALUE:-0}" -lt 1000 ]; then
|
if [ "${DISK_GB_VALUE:-0}" -lt 1000 ]; then
|
||||||
exec "$BITCOIND" -datadir=/home/bitcoin/.bitcoin -noconf -printtoconsole=0 -server=1 -prune=550 -rpcallowip=0.0.0.0/0 -rpcbind=0.0.0.0:8332 -listen=1 -bind=0.0.0.0:8333 -dbcache=2048 -par=0 -maxconnections=125 $RPC_HEADROOM $RPC_TXRELAY_FLAGS -rpcuser="$RPC_USER" -rpcpassword="$RPC_PASS";
|
exec "$BITCOIND" -datadir=/home/bitcoin/.bitcoin -conf="$RPC_CONF" -printtoconsole=0 -server=1 -prune=550 -rpcallowip=0.0.0.0/0 -rpcbind=0.0.0.0:8332 -listen=1 -bind=0.0.0.0:8333 -dbcache=2048 -par=0 -maxconnections=125 $RPC_HEADROOM $RPC_TXRELAY_FLAGS;
|
||||||
else
|
else
|
||||||
exec "$BITCOIND" -datadir=/home/bitcoin/.bitcoin -noconf -printtoconsole=0 -server=1 -txindex=1 -rpcallowip=0.0.0.0/0 -rpcbind=0.0.0.0:8332 -listen=1 -bind=0.0.0.0:8333 -dbcache=4096 -par=0 -maxconnections=125 $RPC_HEADROOM $RPC_TXRELAY_FLAGS -rpcuser="$RPC_USER" -rpcpassword="$RPC_PASS";
|
exec "$BITCOIND" -datadir=/home/bitcoin/.bitcoin -conf="$RPC_CONF" -printtoconsole=0 -server=1 -txindex=1 -rpcallowip=0.0.0.0/0 -rpcbind=0.0.0.0:8332 -listen=1 -bind=0.0.0.0:8333 -dbcache=4096 -par=0 -maxconnections=125 $RPC_HEADROOM $RPC_TXRELAY_FLAGS;
|
||||||
fi
|
fi
|
||||||
derived_env:
|
derived_env:
|
||||||
- key: DISK_GB
|
- key: DISK_GB
|
||||||
|
|||||||
@ -10,9 +10,16 @@ app:
|
|||||||
network: archy-net
|
network: archy-net
|
||||||
data_uid: "1000:1000"
|
data_uid: "1000:1000"
|
||||||
entrypoint: ["sh", "-lc"]
|
entrypoint: ["sh", "-lc"]
|
||||||
|
# The bitcoin backend container is bitcoin-knots OR bitcoin-core depending
|
||||||
|
# on which version the node runs (multi-version switch) — probe which name
|
||||||
|
# resolves on archy-net instead of hardcoding knots, which left electrumx
|
||||||
|
# permanently disconnected (block index 0) on core nodes.
|
||||||
custom_args:
|
custom_args:
|
||||||
- >-
|
- >-
|
||||||
export DAEMON_URL="http://archipelago:$(printenv BITCOIN_RPC_PASS)@bitcoin-knots:8332/";
|
for h in bitcoin-knots bitcoin-core; do
|
||||||
|
if getent hosts "$h" >/dev/null 2>&1; then BTC_HOST="$h"; break; fi;
|
||||||
|
done;
|
||||||
|
export DAEMON_URL="http://archipelago:$(printenv BITCOIN_RPC_PASS)@${BTC_HOST:-bitcoin-knots}:8332/";
|
||||||
exec electrumx_server
|
exec electrumx_server
|
||||||
secret_env:
|
secret_env:
|
||||||
- key: BITCOIN_RPC_PASS
|
- key: BITCOIN_RPC_PASS
|
||||||
|
|||||||
@ -9,13 +9,22 @@ app:
|
|||||||
pull_policy: if-not-present
|
pull_policy: if-not-present
|
||||||
network: archy-net
|
network: archy-net
|
||||||
entrypoint: ["sh", "-lc"]
|
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:
|
custom_args:
|
||||||
- >-
|
- >-
|
||||||
if [ -f /lnd/tls.cert ] && [ -f /lnd/data/chain/bitcoin/mainnet/admin.macaroon ]; then
|
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
|
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
|
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
|
# 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)
|
# first install (random password + its bcrypt hash, both 0600 rootless-owned)
|
||||||
# so the app installs from its manifest alone — `fedimint-gateway-hash` holds
|
# so the app installs from its manifest alone — `fedimint-gateway-hash` holds
|
||||||
|
|||||||
@ -21,6 +21,11 @@ app:
|
|||||||
template: fedimint://{{HOST_MDNS}}:8173
|
template: fedimint://{{HOST_MDNS}}:8173
|
||||||
- key: FM_API_URL
|
- key: FM_API_URL
|
||||||
template: ws://{{HOST_MDNS}}:8174
|
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:
|
secret_env:
|
||||||
- key: FM_BITCOIND_PASSWORD
|
- key: FM_BITCOIND_PASSWORD
|
||||||
secret_file: bitcoin-rpc-password
|
secret_file: bitcoin-rpc-password
|
||||||
@ -62,7 +67,8 @@ app:
|
|||||||
|
|
||||||
environment:
|
environment:
|
||||||
- FM_DATA_DIR=/data
|
- 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_BITCOIND_USERNAME=archipelago
|
||||||
- FM_BITCOIN_NETWORK=bitcoin
|
- FM_BITCOIN_NETWORK=bitcoin
|
||||||
- FM_BIND_P2P=0.0.0.0:8173
|
- FM_BIND_P2P=0.0.0.0:8173
|
||||||
|
|||||||
@ -1,5 +1,5 @@
|
|||||||
# Home Assistant - uses official image
|
# Home Assistant - uses official image
|
||||||
FROM homeassistant/home-assistant:2024.1
|
FROM homeassistant/home-assistant:2026.7.3
|
||||||
|
|
||||||
# Default configuration is in the image
|
# Default configuration is in the image
|
||||||
# No additional setup needed
|
# No additional setup needed
|
||||||
|
|||||||
@ -1,11 +1,11 @@
|
|||||||
app:
|
app:
|
||||||
id: homeassistant
|
id: homeassistant
|
||||||
name: Home Assistant
|
name: Home Assistant
|
||||||
version: 2024.1.0
|
version: 2026.7.3
|
||||||
description: Open source home automation platform. Control and monitor your smart home devices.
|
description: Open source home automation platform. Control and monitor your smart home devices.
|
||||||
|
|
||||||
container:
|
container:
|
||||||
image: 146.59.87.168:3000/lfg2025/home-assistant:2024.1
|
image: 146.59.87.168:3000/lfg2025/home-assistant:2026.7.3
|
||||||
pull_policy: if-not-present
|
pull_policy: if-not-present
|
||||||
network: pasta
|
network: pasta
|
||||||
|
|
||||||
|
|||||||
70
apps/pine-openwakeword/manifest.yml
Normal file
70
apps/pine-openwakeword/manifest.yml
Normal file
@ -0,0 +1,70 @@
|
|||||||
|
app:
|
||||||
|
id: pine-openwakeword
|
||||||
|
name: Pine Wake Word (openWakeWord)
|
||||||
|
version: "2.1.0"
|
||||||
|
description: Wyoming-protocol openWakeWord wake-word engine. Internal Pine voice-assistant stack member — lets Assist pipelines run wake-word detection on the node (groundwork for the custom "Yo Archy" wake word; stock models like "ok nabu" ship with the image).
|
||||||
|
category: home
|
||||||
|
|
||||||
|
# Hyphen name matches the runtime references (stack member table / startup
|
||||||
|
# order) so the orchestrator adopts a matching running container instead of
|
||||||
|
# recreating it.
|
||||||
|
container_name: pine-openwakeword
|
||||||
|
|
||||||
|
container:
|
||||||
|
image: docker.io/rhasspy/wyoming-openwakeword:2.1.0
|
||||||
|
pull_policy: if-not-present
|
||||||
|
network: archy-net
|
||||||
|
network_aliases: [pine-openwakeword]
|
||||||
|
# The image entrypoint binds tcp://0.0.0.0:10400. Preload the stock
|
||||||
|
# "ok nabu" model; /custom is where a trained custom model (yo_archy)
|
||||||
|
# drops in later — the engine picks up new .tflite files on restart.
|
||||||
|
custom_args: ["--preload-model", "ok_nabu", "--custom-model-dir", "/custom"]
|
||||||
|
|
||||||
|
dependencies:
|
||||||
|
- storage: 512Mi
|
||||||
|
|
||||||
|
resources:
|
||||||
|
memory_limit: 512Mi
|
||||||
|
|
||||||
|
security:
|
||||||
|
# cap-drop=ALL is applied by the orchestrator. A plain Python Wyoming server
|
||||||
|
# on an unprivileged port needs no added capabilities.
|
||||||
|
capabilities: []
|
||||||
|
readonly_root: false
|
||||||
|
no_new_privileges: true
|
||||||
|
network_policy: isolated
|
||||||
|
|
||||||
|
ports:
|
||||||
|
# Published so Home Assistant (on the pasta net) can reach the engine via
|
||||||
|
# host.containers.internal:10400 (the Wyoming integration endpoint).
|
||||||
|
- host: 10400
|
||||||
|
container: 10400
|
||||||
|
protocol: tcp
|
||||||
|
|
||||||
|
volumes:
|
||||||
|
- type: bind
|
||||||
|
source: /var/lib/archipelago/pine-openwakeword
|
||||||
|
target: /custom
|
||||||
|
options: [rw]
|
||||||
|
|
||||||
|
environment: []
|
||||||
|
|
||||||
|
health_check:
|
||||||
|
type: tcp
|
||||||
|
endpoint: localhost:10400
|
||||||
|
interval: 30s
|
||||||
|
timeout: 5s
|
||||||
|
retries: 5
|
||||||
|
start_period: 30s
|
||||||
|
|
||||||
|
metadata:
|
||||||
|
author: Rhasspy / Home Assistant
|
||||||
|
icon: /assets/img/app-icons/pine.svg
|
||||||
|
website: https://github.com/rhasspy/wyoming-openwakeword
|
||||||
|
repo: https://github.com/rhasspy/wyoming-openwakeword
|
||||||
|
license: MIT
|
||||||
|
tags:
|
||||||
|
- home
|
||||||
|
- voice
|
||||||
|
- wake-word
|
||||||
|
- wyoming
|
||||||
70
apps/pine-piper/manifest.yml
Normal file
70
apps/pine-piper/manifest.yml
Normal file
@ -0,0 +1,70 @@
|
|||||||
|
app:
|
||||||
|
id: pine-piper
|
||||||
|
name: Pine Piper (TTS)
|
||||||
|
version: "2.2.2"
|
||||||
|
description: Wyoming-protocol Piper text-to-speech engine. Internal Pine voice-assistant stack member — gives Home Assistant Assist a natural voice for spoken responses on the PineVoice satellite.
|
||||||
|
category: home
|
||||||
|
|
||||||
|
# Hyphen name matches the runtime references (stack member table / startup
|
||||||
|
# order) + the live container, so on an existing node the orchestrator ADOPTS
|
||||||
|
# the running engine rather than recreating it (downloaded voices under /data
|
||||||
|
# preserved).
|
||||||
|
container_name: pine-piper
|
||||||
|
|
||||||
|
container:
|
||||||
|
image: docker.io/rhasspy/wyoming-piper:2.2.2
|
||||||
|
pull_policy: if-not-present
|
||||||
|
network: archy-net
|
||||||
|
network_aliases: [pine-piper]
|
||||||
|
# The image entrypoint already binds tcp://0.0.0.0:10200; this arg only
|
||||||
|
# picks the voice (mirrors the pine ha-stack.yml compose command).
|
||||||
|
custom_args: ["--voice", "en_GB-alba-medium"]
|
||||||
|
|
||||||
|
dependencies:
|
||||||
|
- storage: 1Gi
|
||||||
|
|
||||||
|
resources:
|
||||||
|
memory_limit: 512Mi
|
||||||
|
|
||||||
|
security:
|
||||||
|
# cap-drop=ALL is applied by the orchestrator. A plain Python Wyoming server
|
||||||
|
# on an unprivileged port needs no added capabilities.
|
||||||
|
capabilities: []
|
||||||
|
readonly_root: false # downloads the voice into /data on first run
|
||||||
|
no_new_privileges: true
|
||||||
|
network_policy: isolated
|
||||||
|
|
||||||
|
ports:
|
||||||
|
# Published so Home Assistant (on the pasta net) can reach the engine via
|
||||||
|
# host.containers.internal:10200 (the Wyoming integration endpoint).
|
||||||
|
- host: 10200
|
||||||
|
container: 10200
|
||||||
|
protocol: tcp
|
||||||
|
|
||||||
|
volumes:
|
||||||
|
- type: bind
|
||||||
|
source: /var/lib/archipelago/pine-piper
|
||||||
|
target: /data
|
||||||
|
options: [rw]
|
||||||
|
|
||||||
|
environment: []
|
||||||
|
|
||||||
|
health_check:
|
||||||
|
type: tcp
|
||||||
|
endpoint: localhost:10200
|
||||||
|
interval: 30s
|
||||||
|
timeout: 5s
|
||||||
|
retries: 5
|
||||||
|
start_period: 60s # first start downloads the voice
|
||||||
|
|
||||||
|
metadata:
|
||||||
|
author: Rhasspy / Home Assistant
|
||||||
|
icon: /assets/img/app-icons/pine.svg
|
||||||
|
website: https://github.com/rhasspy/wyoming-piper
|
||||||
|
repo: https://github.com/rhasspy/wyoming-piper
|
||||||
|
license: MIT
|
||||||
|
tags:
|
||||||
|
- home
|
||||||
|
- voice
|
||||||
|
- text-to-speech
|
||||||
|
- wyoming
|
||||||
78
apps/pine-whisper/manifest.yml
Normal file
78
apps/pine-whisper/manifest.yml
Normal file
@ -0,0 +1,78 @@
|
|||||||
|
app:
|
||||||
|
id: pine-whisper
|
||||||
|
name: Pine Whisper (STT)
|
||||||
|
# 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
|
||||||
|
|
||||||
|
# Hyphen name matches the runtime references (stack member table / startup
|
||||||
|
# order) + the live container, so on an existing node the orchestrator ADOPTS
|
||||||
|
# the running engine rather than recreating it (downloaded models under /data
|
||||||
|
# preserved).
|
||||||
|
container_name: pine-whisper
|
||||||
|
|
||||||
|
container:
|
||||||
|
image: docker.io/rhasspy/wyoming-whisper:3.4.1
|
||||||
|
pull_policy: if-not-present
|
||||||
|
network: archy-net
|
||||||
|
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).
|
||||||
|
# --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
|
||||||
|
|
||||||
|
resources:
|
||||||
|
memory_limit: 2Gi
|
||||||
|
|
||||||
|
security:
|
||||||
|
# cap-drop=ALL is applied by the orchestrator. A plain Python Wyoming server
|
||||||
|
# on an unprivileged port needs no added capabilities.
|
||||||
|
capabilities: []
|
||||||
|
readonly_root: false # downloads the whisper model into /data on first run
|
||||||
|
no_new_privileges: true
|
||||||
|
network_policy: isolated
|
||||||
|
|
||||||
|
ports:
|
||||||
|
# Published so Home Assistant (on the pasta net) can reach the engine via
|
||||||
|
# host.containers.internal:10300 (the Wyoming integration endpoint).
|
||||||
|
- host: 10300
|
||||||
|
container: 10300
|
||||||
|
protocol: tcp
|
||||||
|
|
||||||
|
volumes:
|
||||||
|
- type: bind
|
||||||
|
source: /var/lib/archipelago/pine-whisper
|
||||||
|
target: /data
|
||||||
|
options: [rw]
|
||||||
|
|
||||||
|
environment: []
|
||||||
|
|
||||||
|
health_check:
|
||||||
|
type: tcp
|
||||||
|
endpoint: localhost:10300
|
||||||
|
interval: 30s
|
||||||
|
timeout: 5s
|
||||||
|
retries: 5
|
||||||
|
start_period: 60s # first start downloads the model
|
||||||
|
|
||||||
|
metadata:
|
||||||
|
author: Rhasspy / Home Assistant
|
||||||
|
icon: /assets/img/app-icons/pine.svg
|
||||||
|
website: https://github.com/rhasspy/wyoming-faster-whisper
|
||||||
|
repo: https://github.com/rhasspy/wyoming-faster-whisper
|
||||||
|
license: MIT
|
||||||
|
tags:
|
||||||
|
- home
|
||||||
|
- voice
|
||||||
|
- speech-to-text
|
||||||
|
- wyoming
|
||||||
395
apps/pine/manifest.yml
Normal file
395
apps/pine/manifest.yml
Normal file
@ -0,0 +1,395 @@
|
|||||||
|
app:
|
||||||
|
id: pine
|
||||||
|
name: Pine
|
||||||
|
version: "1.3.0"
|
||||||
|
description: A private voice assistant for your home. Pine runs speech-to-text (Whisper), text-to-speech (Piper) and wake-word detection (openWakeWord) on your own node and pairs with a PineVoice satellite speaker, so Home Assistant Assist works locally with nothing sent to the cloud. Ask it about your node — block height, sync, peers, Lightning balance — and, when a Claude API key is set, anything else.
|
||||||
|
category: home
|
||||||
|
|
||||||
|
# The user-facing launcher (app_id + container both "pine", matching the
|
||||||
|
# runtime references + the live container so the orchestrator adopts it). A
|
||||||
|
# tiny nginx that serves the "Connect Pine to WiFi" provisioner page for the
|
||||||
|
# voice stack. The two Wyoming engines (pine-whisper, pine-piper) are internal
|
||||||
|
# stack members.
|
||||||
|
container_name: pine
|
||||||
|
|
||||||
|
container:
|
||||||
|
image: docker.io/library/nginx:1.27-alpine
|
||||||
|
pull_policy: if-not-present
|
||||||
|
network: archy-net
|
||||||
|
network_aliases: [pine]
|
||||||
|
# The provisioner uses Web Bluetooth (Improv-over-BLE) to push WiFi creds to
|
||||||
|
# the PineVoice speaker. navigator.bluetooth only exists in a SECURE CONTEXT
|
||||||
|
# (https or localhost), so the launcher terminates TLS with a self-signed
|
||||||
|
# cert — otherwise the "Connect Pine to WiFi" button is inert on the LAN.
|
||||||
|
# Idempotent: kept as-is when crt+key already exist. Mirrors the netbird
|
||||||
|
# secure-context fix (#15).
|
||||||
|
generated_certs:
|
||||||
|
- crt: /var/lib/archipelago/pine/tls.crt
|
||||||
|
key: /var/lib/archipelago/pine/tls.key
|
||||||
|
|
||||||
|
dependencies:
|
||||||
|
- app_id: pine-whisper
|
||||||
|
- app_id: pine-piper
|
||||||
|
- app_id: pine-openwakeword
|
||||||
|
- storage: 128Mi
|
||||||
|
|
||||||
|
resources:
|
||||||
|
memory_limit: 64Mi
|
||||||
|
|
||||||
|
security:
|
||||||
|
# cap-drop=ALL is applied by the orchestrator. nginx (master as root, drops
|
||||||
|
# workers) binds :443 inside the container — needs the worker-drop caps +
|
||||||
|
# NET_BIND_SERVICE for the privileged port.
|
||||||
|
capabilities: [CHOWN, DAC_OVERRIDE, SETGID, SETUID, NET_BIND_SERVICE]
|
||||||
|
readonly_root: false
|
||||||
|
no_new_privileges: true
|
||||||
|
network_policy: isolated
|
||||||
|
|
||||||
|
ports:
|
||||||
|
# 10380 (http) is the Open target — the UI launches apps as
|
||||||
|
# http://host:10380 (resolveAppUrl). nginx there 301-redirects to the https
|
||||||
|
# listener on 10381, so the new tab lands on a secure context where
|
||||||
|
# navigator.bluetooth (the "Connect Pine to WiFi" provisioner) works.
|
||||||
|
- host: 10380
|
||||||
|
container: 80
|
||||||
|
protocol: tcp
|
||||||
|
- host: 10381
|
||||||
|
container: 443
|
||||||
|
protocol: tcp
|
||||||
|
|
||||||
|
volumes:
|
||||||
|
- type: bind
|
||||||
|
source: /var/lib/archipelago/pine/nginx.conf
|
||||||
|
target: /etc/nginx/conf.d/default.conf
|
||||||
|
options: [ro]
|
||||||
|
- type: bind
|
||||||
|
source: /var/lib/archipelago/pine/tls.crt
|
||||||
|
target: /etc/nginx/tls.crt
|
||||||
|
options: [ro]
|
||||||
|
- type: bind
|
||||||
|
source: /var/lib/archipelago/pine/tls.key
|
||||||
|
target: /etc/nginx/tls.key
|
||||||
|
options: [ro]
|
||||||
|
- type: bind
|
||||||
|
source: /var/lib/archipelago/pine/index.html
|
||||||
|
target: /usr/share/nginx/html/index.html
|
||||||
|
options: [ro]
|
||||||
|
|
||||||
|
environment: []
|
||||||
|
|
||||||
|
files:
|
||||||
|
- path: /var/lib/archipelago/pine/nginx.conf
|
||||||
|
overwrite: true
|
||||||
|
content: |
|
||||||
|
server {
|
||||||
|
listen 80;
|
||||||
|
server_name _;
|
||||||
|
return 301 https://$host:10381$request_uri;
|
||||||
|
}
|
||||||
|
server {
|
||||||
|
listen 443 ssl;
|
||||||
|
server_name _;
|
||||||
|
ssl_certificate /etc/nginx/tls.crt;
|
||||||
|
ssl_certificate_key /etc/nginx/tls.key;
|
||||||
|
root /usr/share/nginx/html;
|
||||||
|
index index.html;
|
||||||
|
# Live node facts for the status card — proxied to the node's
|
||||||
|
# public status tier so the (https) page can fetch same-origin.
|
||||||
|
location = /node-status {
|
||||||
|
proxy_pass http://host.containers.internal:80/api/pine/status;
|
||||||
|
proxy_http_version 1.1;
|
||||||
|
proxy_set_header Host $host;
|
||||||
|
proxy_connect_timeout 5s;
|
||||||
|
proxy_read_timeout 10s;
|
||||||
|
}
|
||||||
|
location / { try_files $uri $uri/ /index.html; }
|
||||||
|
}
|
||||||
|
- path: /var/lib/archipelago/pine/index.html
|
||||||
|
overwrite: true
|
||||||
|
content: |
|
||||||
|
<!doctype html>
|
||||||
|
<html lang="en">
|
||||||
|
<head>
|
||||||
|
<meta charset="utf-8">
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||||
|
<title>Pine — connect your speaker</title>
|
||||||
|
<style>
|
||||||
|
:root { color-scheme: dark; }
|
||||||
|
* { box-sizing: border-box; }
|
||||||
|
body { margin: 0; font: 16px/1.6 system-ui, -apple-system, sans-serif;
|
||||||
|
background: #0f1512; color: #e7efe9; }
|
||||||
|
.wrap { max-width: 520px; margin: 0 auto; padding: 40px 22px 64px; }
|
||||||
|
header { display: flex; align-items: center; gap: 14px; margin-bottom: 6px; }
|
||||||
|
header svg { width: 52px; height: 52px; flex: 0 0 auto; }
|
||||||
|
h1 { font-size: 26px; margin: 0; }
|
||||||
|
.tag { color: #8fbfa5; font-size: 14px; margin: 2px 0 0; }
|
||||||
|
.card { background: #16201b; border: 1px solid #24332b;
|
||||||
|
border-radius: 14px; padding: 20px; margin: 20px 0; }
|
||||||
|
.status .row { display: flex; gap: 10px; align-items: baseline; font-size: 14px; }
|
||||||
|
.status .row b { min-width: 84px; color: #9fd3b6; }
|
||||||
|
.status code { background: #0f1512; padding: 1px 6px; border-radius: 5px;
|
||||||
|
font-size: 13px; color: #cfe9d9; }
|
||||||
|
label { display: block; font-size: 13px; color: #9fbfad; margin: 14px 0 5px; }
|
||||||
|
input { width: 100%; padding: 11px 12px; font-size: 15px; color: #e7efe9;
|
||||||
|
background: #0f1512; border: 1px solid #2b3d33; border-radius: 9px;
|
||||||
|
outline: none; }
|
||||||
|
input:focus { border-color: #4fae82; }
|
||||||
|
button { width: 100%; margin-top: 18px; padding: 13px; font-size: 15px;
|
||||||
|
font-weight: 600; color: #06110b; background: #7fd6a6; border: 0;
|
||||||
|
border-radius: 10px; cursor: pointer; transition: filter .15s; }
|
||||||
|
button:hover:not(:disabled) { filter: brightness(1.08); }
|
||||||
|
button:disabled { opacity: .45; cursor: default; }
|
||||||
|
#log { margin-top: 16px; padding: 12px; min-height: 68px; font-size: 13px;
|
||||||
|
font-family: ui-monospace, monospace; white-space: pre-wrap;
|
||||||
|
background: #0b100d; border: 1px solid #1e2a23; border-radius: 9px;
|
||||||
|
color: #a9c6b6; max-height: 220px; overflow-y: auto; }
|
||||||
|
.ok { color: #7fd6a6; } .err { color: #ee8f8f; } .warn { color: #e8c878; }
|
||||||
|
.ha { color: #6d8578; font-size: 13px; margin-top: 22px; }
|
||||||
|
.ha b { color: #9fbfad; }
|
||||||
|
.insecure { display: none; background: #2a1f12; border-color: #4a3418;
|
||||||
|
color: #e8c878; font-size: 13px; }
|
||||||
|
</style>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<div class="wrap">
|
||||||
|
<header>
|
||||||
|
<svg viewBox="0 0 512 512" aria-hidden="true"><g fill="#7fd6a6">
|
||||||
|
<rect x="236" y="396" width="40" height="72" rx="6"/>
|
||||||
|
<path d="M256 44 L336 168 L296 168 L256 108 L216 168 L176 168 Z"/>
|
||||||
|
<path d="M256 150 L360 300 L300 300 L256 236 L212 300 L152 300 Z"/>
|
||||||
|
<path d="M256 262 L392 430 L120 430 L256 262 Z"/>
|
||||||
|
</g></svg>
|
||||||
|
<div><h1>Pine</h1>
|
||||||
|
<p class="tag">Connect your speaker — everything stays on your node.</p></div>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
<div class="card status">
|
||||||
|
<div class="row"><b>Whisper</b><span>speech-to-text ready on <code>:10300</code></span></div>
|
||||||
|
<div class="row"><b>Piper</b><span>text-to-speech ready on <code>:10200</code></span></div>
|
||||||
|
<div class="row"><b>Wake word</b><span>“Hey Jarvis” on the speaker (openWakeWord on <code>:10400</code>)</span></div>
|
||||||
|
<div class="row"><b>Speaker</b><span>put it in pairing mode — ring LED blinking yellow</span></div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="card status">
|
||||||
|
<div class="row"><b>Node</b><span id="ns-node">checking…</span></div>
|
||||||
|
<div class="row"><b>Bitcoin</b><span id="ns-btc">—</span></div>
|
||||||
|
<div class="row"><b>Peers</b><span id="ns-peers">—</span></div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="card insecure" id="insecure">
|
||||||
|
This page isn’t running over HTTPS, so the browser blocks Bluetooth.
|
||||||
|
Open it via its <b>https://…:10380</b> address (accept the self-signed
|
||||||
|
certificate) and the button below will work.
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="card">
|
||||||
|
<label for="ssid">WiFi network (SSID)</label>
|
||||||
|
<input id="ssid" autocomplete="off" spellcheck="false" placeholder="Your 2.4 GHz network">
|
||||||
|
<label for="pass">WiFi password — typed here, sent only over Bluetooth to the speaker</label>
|
||||||
|
<input id="pass" type="password" autocomplete="off">
|
||||||
|
<button id="go">Connect Pine to WiFi</button>
|
||||||
|
<div id="log">Ready. Click the button, then pick “PineVoice” in the Bluetooth popup.</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<p class="ha">After WiFi joins, one manual step remains — pair the
|
||||||
|
speaker in Home Assistant: <b>Settings → Devices & services →
|
||||||
|
Add Wyoming Protocol</b>, host = the speaker’s IP, port
|
||||||
|
<b>10700</b>. Whisper, Piper, openWakeWord and the Assist pipeline
|
||||||
|
are wired up automatically when Pine installs. Wake word:
|
||||||
|
<b>“Hey Jarvis.”</b> Ask node things like <i>“what’s the block
|
||||||
|
height?”</i>, <i>“how many peers?”</i>, <i>“is the node
|
||||||
|
synced?”</i> or <i>“what’s my lightning balance?”</i> — and when a
|
||||||
|
Claude API key is set on the node, anything else gets answered by
|
||||||
|
Claude. New mesh messages are announced on the speaker too.</p>
|
||||||
|
<p class="ha">Troubleshooting: if it hears you (LED reacts) but answers
|
||||||
|
are silent, unplug and replug the speaker — an interrupted answer can
|
||||||
|
wedge its audio output until it reboots.</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<script>
|
||||||
|
"use strict";
|
||||||
|
// Improv-over-BLE (improv-wifi spec, verified vs improv-wifi-sdk 1.4.0).
|
||||||
|
const SVC = "00467768-6228-2272-4663-277478268000";
|
||||||
|
const CHAR_STATE = "00467768-6228-2272-4663-277478268001";
|
||||||
|
const CHAR_ERROR = "00467768-6228-2272-4663-277478268002";
|
||||||
|
const CHAR_RPC = "00467768-6228-2272-4663-277478268003";
|
||||||
|
const CHAR_RESULT = "00467768-6228-2272-4663-277478268004";
|
||||||
|
const STATES = {1:"authorization required",2:"authorized",3:"provisioning…",4:"PROVISIONED"};
|
||||||
|
const ERRORS = {1:"invalid RPC packet",2:"unknown RPC command",
|
||||||
|
3:"unable to connect — wrong password, or the SSID isn't reachable on 2.4 GHz",
|
||||||
|
4:"not authorized — press the button on the device",5:"bad hostname",
|
||||||
|
255:"unknown device error"};
|
||||||
|
|
||||||
|
const logEl = document.getElementById("log");
|
||||||
|
const log = (msg, cls) => { const l = document.createElement("div");
|
||||||
|
if (cls) l.className = cls; l.textContent = msg; logEl.appendChild(l);
|
||||||
|
logEl.scrollTop = logEl.scrollHeight; };
|
||||||
|
const hex = dv => [...new Uint8Array(dv.buffer, dv.byteOffset, dv.byteLength)]
|
||||||
|
.map(b => b.toString(16).padStart(2, "0")).join(" ");
|
||||||
|
|
||||||
|
const btn = document.getElementById("go");
|
||||||
|
if (!navigator.bluetooth) {
|
||||||
|
document.getElementById("insecure").style.display = "block";
|
||||||
|
logEl.textContent = "Web Bluetooth unavailable — open this page over https (see note above).";
|
||||||
|
}
|
||||||
|
|
||||||
|
btn.addEventListener("click", async () => {
|
||||||
|
const ssid = document.getElementById("ssid").value.trim();
|
||||||
|
const pass = document.getElementById("pass").value;
|
||||||
|
if (!ssid) { log("Enter your WiFi network name first.", "err"); return; }
|
||||||
|
if (!navigator.bluetooth) { log("No Web Bluetooth — open this page over https.", "err"); return; }
|
||||||
|
btn.disabled = true; logEl.textContent = "";
|
||||||
|
let device;
|
||||||
|
try {
|
||||||
|
log("Opening Bluetooth device chooser…");
|
||||||
|
device = await navigator.bluetooth.requestDevice({
|
||||||
|
filters: [{ services: [SVC] }, { namePrefix: "PineVoice" }],
|
||||||
|
optionalServices: [SVC],
|
||||||
|
});
|
||||||
|
log(`Selected: ${device.name || "(unnamed)"}`);
|
||||||
|
device.addEventListener("gattserverdisconnected", () => log("BLE disconnected."));
|
||||||
|
log("Connecting…");
|
||||||
|
const gatt = await device.gatt.connect();
|
||||||
|
const svc = await gatt.getPrimaryService(SVC);
|
||||||
|
const stateChar = await svc.getCharacteristic(CHAR_STATE);
|
||||||
|
const errorChar = await svc.getCharacteristic(CHAR_ERROR);
|
||||||
|
const rpcChar = await svc.getCharacteristic(CHAR_RPC);
|
||||||
|
const resultChar = await svc.getCharacteristic(CHAR_RESULT);
|
||||||
|
|
||||||
|
let done, fail;
|
||||||
|
const outcome = new Promise((res, rej) => { done = res; fail = rej; });
|
||||||
|
let authorize; const authorized = new Promise(res => { authorize = res; });
|
||||||
|
let state = -1;
|
||||||
|
const onState = (s, via = "") => {
|
||||||
|
if (s === state) return; state = s;
|
||||||
|
log(`Device state${via}: ${STATES[s] || s}`, s === 4 ? "ok" : undefined);
|
||||||
|
if (s >= 2) authorize();
|
||||||
|
if (s === 4) done(null);
|
||||||
|
};
|
||||||
|
stateChar.addEventListener("characteristicvaluechanged",
|
||||||
|
e => onState(e.target.value.getUint8(0)));
|
||||||
|
errorChar.addEventListener("characteristicvaluechanged", e => {
|
||||||
|
const c = e.target.value.getUint8(0);
|
||||||
|
if (c !== 0) fail(new Error(ERRORS[c] || `error ${c}`)); });
|
||||||
|
resultChar.addEventListener("characteristicvaluechanged", e => {
|
||||||
|
const v = e.target.value; log(`RPC result: ${hex(v)}`);
|
||||||
|
if (v.byteLength > 2 && v.getUint8(1) > 0) {
|
||||||
|
const n = v.getUint8(2); const b = new Uint8Array(n);
|
||||||
|
for (let i = 0; i < n; i++) b[i] = v.getUint8(3 + i);
|
||||||
|
done(new TextDecoder().decode(b)); } });
|
||||||
|
await stateChar.startNotifications();
|
||||||
|
await errorChar.startNotifications();
|
||||||
|
await resultChar.startNotifications();
|
||||||
|
onState((await stateChar.readValue()).getUint8(0));
|
||||||
|
|
||||||
|
const poller = setInterval(async () => {
|
||||||
|
try { onState((await stateChar.readValue()).getUint8(0), " (polled)");
|
||||||
|
const ec = (await errorChar.readValue()).getUint8(0);
|
||||||
|
if (ec !== 0) fail(new Error(ERRORS[ec] || `error ${ec}`)); } catch {} }, 2000);
|
||||||
|
|
||||||
|
let nextUrl;
|
||||||
|
try {
|
||||||
|
if (state === 1) {
|
||||||
|
log("Authorization required — press the centre button on the speaker now.", "warn");
|
||||||
|
await Promise.race([authorized, outcome,
|
||||||
|
new Promise((_, rej) => setTimeout(
|
||||||
|
() => rej(new Error("timed out waiting for the button press")), 60000))]);
|
||||||
|
log("Authorized.", "ok");
|
||||||
|
}
|
||||||
|
const enc = new TextEncoder();
|
||||||
|
const sb = enc.encode(ssid), pb = enc.encode(pass);
|
||||||
|
const data = new Uint8Array([sb.length, ...sb, pb.length, ...pb]);
|
||||||
|
const pkt = new Uint8Array([1, data.length, ...data, 0]);
|
||||||
|
pkt[pkt.length - 1] = pkt.reduce((s, b) => s + b, 0);
|
||||||
|
log(`Sending WiFi credentials for “${ssid}”…`);
|
||||||
|
if (rpcChar.writeValueWithResponse) await rpcChar.writeValueWithResponse(pkt);
|
||||||
|
else await rpcChar.writeValue(pkt);
|
||||||
|
log("Credentials delivered — speaker acknowledged.", "ok");
|
||||||
|
log("Joining WiFi… (the speaker plays a sound within ~20s either way)");
|
||||||
|
const timeout = new Promise((_, rej) => setTimeout(
|
||||||
|
() => rej(new Error("timed out after 60s waiting for the device")), 60000));
|
||||||
|
nextUrl = await Promise.race([outcome, timeout]);
|
||||||
|
} finally { clearInterval(poller); }
|
||||||
|
|
||||||
|
log("✓ PROVISIONED — the ring LED should breathe dim magenta.", "ok");
|
||||||
|
if (nextUrl) log(`Device reports next step: ${nextUrl}`, "ok");
|
||||||
|
try { gatt.disconnect(); } catch {}
|
||||||
|
} catch (err) {
|
||||||
|
log(`✗ ${err.name || "Error"}: ${err.message}`, "err");
|
||||||
|
if (err.name === "NotFoundError")
|
||||||
|
log("No speaker matched, or you closed the chooser. LED blinking yellow? "
|
||||||
|
+ "Hold the dot button 15s to reset.", "warn");
|
||||||
|
if (err.name === "NetworkError")
|
||||||
|
log("BLE link dropped — move closer, and make sure the Mac isn't already "
|
||||||
|
+ "paired to the speaker (it can hold the link exclusively).", "warn");
|
||||||
|
try { device?.gatt?.disconnect(); } catch {}
|
||||||
|
} finally { btn.disabled = false; }
|
||||||
|
});
|
||||||
|
|
||||||
|
// Live node status card — public tier of /api/pine/status via the
|
||||||
|
// same-origin /node-status proxy. Best-effort: failures just show
|
||||||
|
// "unavailable" and retry on the next tick.
|
||||||
|
const nsNode = document.getElementById("ns-node");
|
||||||
|
const nsBtc = document.getElementById("ns-btc");
|
||||||
|
const nsPeers = document.getElementById("ns-peers");
|
||||||
|
async function refreshNodeStatus() {
|
||||||
|
try {
|
||||||
|
const r = await fetch("/node-status", { cache: "no-store" });
|
||||||
|
if (!r.ok) throw new Error(String(r.status));
|
||||||
|
const s = await r.json();
|
||||||
|
const up = Math.floor((s.uptime_seconds || 0) / 3600);
|
||||||
|
nsNode.textContent = `Archipelago ${s.version || "?"} — up ${up}h`;
|
||||||
|
if (s.bitcoin && s.bitcoin.height != null) {
|
||||||
|
const pct = s.bitcoin.sync_percent;
|
||||||
|
nsBtc.textContent = `block ${s.bitcoin.height}` +
|
||||||
|
(pct != null ? (pct >= 99.99 ? " — synced" : ` — ${pct}% synced`) : "");
|
||||||
|
} else {
|
||||||
|
nsBtc.textContent = "not running";
|
||||||
|
}
|
||||||
|
const btcPeers = s.bitcoin && s.bitcoin.peers != null ? s.bitcoin.peers : "?";
|
||||||
|
const meshPeers = s.mesh ? s.mesh.peers : 0;
|
||||||
|
nsPeers.textContent = `${btcPeers} bitcoin` +
|
||||||
|
(s.mesh && s.mesh.enabled ? `, ${meshPeers} mesh` : "");
|
||||||
|
} catch {
|
||||||
|
nsNode.textContent = "node status unavailable";
|
||||||
|
nsBtc.textContent = "—";
|
||||||
|
nsPeers.textContent = "—";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
refreshNodeStatus();
|
||||||
|
setInterval(refreshNodeStatus, 30000);
|
||||||
|
</script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
|
|
||||||
|
health_check:
|
||||||
|
type: tcp
|
||||||
|
endpoint: localhost:443
|
||||||
|
interval: 30s
|
||||||
|
timeout: 5s
|
||||||
|
retries: 5
|
||||||
|
start_period: 10s
|
||||||
|
|
||||||
|
interfaces:
|
||||||
|
main:
|
||||||
|
name: Pine
|
||||||
|
description: Connect your speaker to WiFi and check the voice assistant
|
||||||
|
type: ui
|
||||||
|
port: 10380
|
||||||
|
protocol: http
|
||||||
|
path: /
|
||||||
|
|
||||||
|
metadata:
|
||||||
|
author: Archipelago
|
||||||
|
icon: /assets/img/app-icons/pine.svg
|
||||||
|
website: https://github.com/rhasspy/wyoming
|
||||||
|
repo: https://github.com/rhasspy/wyoming
|
||||||
|
license: MIT
|
||||||
|
category: home
|
||||||
|
launch:
|
||||||
|
open_in_new_tab: true
|
||||||
|
tags:
|
||||||
|
- home
|
||||||
|
- voice
|
||||||
|
- assistant
|
||||||
|
- privacy
|
||||||
@ -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
|
|
||||||
53
core/Cargo.lock
generated
53
core/Cargo.lock
generated
@ -84,6 +84,15 @@ version = "1.0.100"
|
|||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "a23eb6b1614318a8071c9b2521f36b424b2c83db5eb3a0fead4a6c0809af6e61"
|
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]]
|
[[package]]
|
||||||
name = "arc-swap"
|
name = "arc-swap"
|
||||||
version = "1.9.1"
|
version = "1.9.1"
|
||||||
@ -95,7 +104,7 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "archipelago"
|
name = "archipelago"
|
||||||
version = "1.7.100-alpha"
|
version = "1.7.118-alpha"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"anyhow",
|
"anyhow",
|
||||||
"archipelago-container",
|
"archipelago-container",
|
||||||
@ -145,6 +154,7 @@ dependencies = [
|
|||||||
"serde_yaml",
|
"serde_yaml",
|
||||||
"serial2-tokio",
|
"serial2-tokio",
|
||||||
"sha2 0.10.9",
|
"sha2 0.10.9",
|
||||||
|
"socket2 0.5.10",
|
||||||
"tar",
|
"tar",
|
||||||
"tempfile",
|
"tempfile",
|
||||||
"thiserror 1.0.69",
|
"thiserror 1.0.69",
|
||||||
@ -160,6 +170,7 @@ dependencies = [
|
|||||||
"uuid",
|
"uuid",
|
||||||
"zbase32",
|
"zbase32",
|
||||||
"zeroize",
|
"zeroize",
|
||||||
|
"zip",
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
@ -1174,6 +1185,17 @@ version = "0.5.8"
|
|||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "7cd812cc2bc1d69d4764bd80df88b4317eaef9e773c75226407d9bc0876b211c"
|
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]]
|
[[package]]
|
||||||
name = "derive_builder"
|
name = "derive_builder"
|
||||||
version = "0.20.2"
|
version = "0.20.2"
|
||||||
@ -6894,8 +6916,37 @@ dependencies = [
|
|||||||
"syn 2.0.114",
|
"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]]
|
[[package]]
|
||||||
name = "zmij"
|
name = "zmij"
|
||||||
version = "1.0.16"
|
version = "1.0.16"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "dfcd145825aace48cff44a8844de64bf75feec3080e0aa5cdbde72961ae51a65"
|
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",
|
||||||
|
]
|
||||||
|
|||||||
656
core/THIRD-PARTY-LICENSES.md
Normal file
656
core/THIRD-PARTY-LICENSES.md
Normal file
@ -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]
|
[package]
|
||||||
name = "archipelago"
|
name = "archipelago"
|
||||||
version = "1.7.100-alpha"
|
version = "1.7.118-alpha"
|
||||||
edition = "2021"
|
edition = "2021"
|
||||||
description = "Archipelago Bitcoin Node OS - Native backend"
|
description = "Archipelago Bitcoin Node OS - Native backend"
|
||||||
authors = ["Archipelago Team"]
|
authors = ["Archipelago Team"]
|
||||||
@ -22,6 +22,9 @@ iroh-swarm = ["dep:iroh", "dep:iroh-blobs"]
|
|||||||
[dependencies]
|
[dependencies]
|
||||||
# Core dependencies
|
# Core dependencies
|
||||||
tokio = { version = "1", features = ["full"] }
|
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
|
libc = "0.2" # process-group signalling for the supervised reticulum daemon
|
||||||
serde = { version = "1.0", features = ["derive"] }
|
serde = { version = "1.0", features = ["derive"] }
|
||||||
serde_json = "1.0"
|
serde_json = "1.0"
|
||||||
@ -105,6 +108,10 @@ bytes = "1"
|
|||||||
# Mesh networking (Meshcore serial protocol over USB LoRa radios)
|
# Mesh networking (Meshcore serial protocol over USB LoRa radios)
|
||||||
serial2-tokio = "0.1"
|
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)
|
# Double Ratchet key derivation (Phase 3: encrypted mesh messaging)
|
||||||
hkdf = "0.12.4"
|
hkdf = "0.12.4"
|
||||||
|
|
||||||
|
|||||||
@ -188,7 +188,7 @@ impl ApiHandler {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
let price_sats = match &item.access {
|
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.
|
// Not a paid item — no invoice to issue.
|
||||||
return Ok(build_response(
|
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}");
|
let memo = format!("Archipelago peer file {content_id}");
|
||||||
match self
|
match self
|
||||||
@ -315,7 +324,18 @@ impl ApiHandler {
|
|||||||
.unwrap_or_default();
|
.unwrap_or_default();
|
||||||
let price_sats = match catalog.items.iter().find(|i| i.id == content_id) {
|
let price_sats = match catalog.items.iter().find(|i| i.id == content_id) {
|
||||||
Some(i) => match &i.access {
|
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(
|
return Ok(build_response(
|
||||||
StatusCode::BAD_REQUEST,
|
StatusCode::BAD_REQUEST,
|
||||||
|
|||||||
Some files were not shown because too many files have changed in this diff Show More
Loading…
x
Reference in New Issue
Block a user