Compare commits
535 Commits
v1.7.68-al
...
v1.3.2
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
c6e55e9dd0 | ||
|
|
56d11f5c99 | ||
|
|
00cc6f77c3 | ||
|
|
1dccbbdd23 | ||
|
|
0d39ab8d9d | ||
|
|
8328dfde43 | ||
|
|
9389478eea | ||
|
|
42707c4276 | ||
|
|
ae4791d438 | ||
|
|
f0ef424ce2 | ||
|
|
2a17303590 | ||
|
|
94524e150a | ||
|
|
34a37191dd | ||
|
|
2c866ad158 | ||
|
|
7982714588 | ||
|
|
5ec4a7285a | ||
|
|
8de5db6518 | ||
|
|
ee7b5980dd | ||
|
|
9d4fb805f5 | ||
|
|
21071e73f1 | ||
|
|
479fbe0d21 | ||
|
|
cd874cb711 | ||
|
|
1ceca4479c | ||
|
|
1b3a3f401b | ||
|
|
9a1edfb377 | ||
|
|
e4eea40e67 | ||
|
|
04d272d8e0 | ||
|
|
b05f72f50f | ||
|
|
6b90ab9eb0 | ||
|
|
05544a1856 | ||
|
|
e0f2fd6f02 | ||
|
|
24cc941b72 | ||
|
|
02f73a4789 | ||
|
|
7cb5c13627 | ||
|
|
0c7dffb38e | ||
|
|
9ad8924c80 | ||
|
|
6656fed9d6 | ||
|
|
ce3e64e2d5 | ||
|
|
919faf54ca | ||
|
|
6d3704fff5 | ||
|
|
b4a57e83d0 | ||
|
|
51f8cf117d | ||
|
|
6cad154028 | ||
|
|
22609abd64 | ||
|
|
8e8020833d | ||
|
|
2d5866d486 | ||
|
|
31e87d98c1 | ||
|
|
843d778f90 | ||
|
|
56151e26e7 | ||
|
|
c7884919d2 | ||
|
|
4b0e1cfbe3 | ||
|
|
030015fce6 | ||
|
|
a279cbe5dd | ||
|
|
64b57dca7d | ||
|
|
cdff10a8bc | ||
|
|
5244e09fb1 | ||
|
|
d8d1601dea | ||
|
|
08330e13f7 | ||
|
|
2f1515b9c6 | ||
|
|
6cced5d042 | ||
|
|
f8ffc7f0a8 | ||
|
|
f162ff85db | ||
|
|
6c5e50b4d5 | ||
|
|
92a429535a | ||
|
|
e6fe00d61d | ||
|
|
a8292ab622 | ||
|
|
3d50fb9888 | ||
|
|
588ce53833 | ||
|
|
77a46fae8d | ||
|
|
953b03f327 | ||
|
|
251447b17a | ||
|
|
768ca26e90 | ||
|
|
4dd3d29dc4 | ||
|
|
d67c636988 | ||
|
|
e8c363e4f5 | ||
|
|
dd0c3982b0 | ||
|
|
bc6b4e0bec | ||
|
|
6bd515cb82 | ||
|
|
7288bff6e0 | ||
|
|
c191dddd2b | ||
|
|
ffeb49e608 | ||
|
|
6fecf081a4 | ||
|
|
27c9d33329 | ||
|
|
031b3c34f4 | ||
|
|
e65b039914 | ||
|
|
5bd3caf141 | ||
|
|
377195f7e0 | ||
|
|
9ea8877d20 | ||
|
|
1c82b8285e | ||
|
|
b773ba610f | ||
|
|
ff85754aa2 | ||
|
|
ccad4737de | ||
|
|
b214b2f52f | ||
|
|
c85534357e | ||
|
|
70254b1bb7 | ||
|
|
a69aef53b5 | ||
|
|
9dd7539edc | ||
|
|
11f7434866 | ||
|
|
9d437ea476 | ||
|
|
89a9f69a9b | ||
|
|
37f32f4e54 | ||
|
|
2c0d4a7393 | ||
|
|
5b186da770 | ||
|
|
08ddc73c75 | ||
|
|
0b5fb4c90b | ||
|
|
e8735b39ec | ||
|
|
25b789bd3f | ||
|
|
9b49ab6d99 | ||
|
|
cba87e2c28 | ||
|
|
48e87d0cfb | ||
|
|
09a9dbc6ca | ||
|
|
9085a7e79f | ||
|
|
d989535a9a | ||
|
|
20289c5bec | ||
|
|
d25969e2e5 | ||
|
|
cb1f252e4d | ||
|
|
39d7bd07b9 | ||
|
|
2e29a41627 | ||
|
|
840ecfaa5f | ||
|
|
b47fec7fba | ||
|
|
6be30b99fa | ||
|
|
4f90cf39cf | ||
|
|
53e62ea25b | ||
|
|
aff9e5111b | ||
|
|
cfe4a03ffb | ||
|
|
aada19754d | ||
|
|
1444bcb0c4 | ||
|
|
2c03dce947 | ||
|
|
7f03e39f58 | ||
|
|
82eeb915a3 | ||
|
|
e28de77596 | ||
|
|
2021de5cda | ||
|
|
9db55b0b34 | ||
|
|
9d38989048 | ||
|
|
782a4a62d5 | ||
|
|
24a5ed7601 | ||
|
|
eecc7e0e71 | ||
|
|
b94428a97b | ||
|
|
3bb91e90f3 | ||
|
|
56be32e55b | ||
|
|
34a476d0a1 | ||
|
|
013b724e02 | ||
|
|
f3f7b8b72f | ||
|
|
e8c80263f3 | ||
|
|
9e3c0b85ea | ||
|
|
93b2af203a | ||
|
|
0212bfdc1d | ||
|
|
c1ff912cb1 | ||
|
|
71b93548c3 | ||
|
|
69c62eb47a | ||
|
|
7183ebfa2b | ||
|
|
39857c775a | ||
|
|
f940b4562a | ||
|
|
4325c15541 | ||
|
|
127a36c5c8 | ||
|
|
b684c2972e | ||
|
|
320c9f5b19 | ||
|
|
bc5121b33f | ||
|
|
0bef26badd | ||
|
|
1ddf90ae50 | ||
|
|
ab48266353 | ||
|
|
493a659ed4 | ||
|
|
e4bdc775e4 | ||
|
|
13b832fdd3 | ||
|
|
3db9ff9216 | ||
|
|
5b60d13693 | ||
|
|
71d7d8c918 | ||
|
|
fad79ff955 | ||
|
|
732b04c9df | ||
|
|
6063ac553c | ||
|
|
bda8b38a95 | ||
|
|
9354a27909 | ||
|
|
3a31c2aa95 | ||
|
|
1eea46542e | ||
|
|
1a64b14354 | ||
|
|
f7a57b8f1f | ||
|
|
1d9fe06f97 | ||
|
|
9aaf8d4b95 | ||
|
|
ea222895be | ||
|
|
27f1b8d21b | ||
|
|
d71eae1815 | ||
|
|
3daf889f74 | ||
|
|
e96acc9023 | ||
|
|
2d47fd800e | ||
|
|
008573b6ac | ||
|
|
ae13c0dad2 | ||
|
|
fc1e763cff | ||
|
|
1f9124789f | ||
|
|
99e32b877f | ||
|
|
5af4c71ab7 | ||
|
|
059913d3dd | ||
|
|
08bb2c80d4 | ||
|
|
5c15c52113 | ||
|
|
aa78d92f7f | ||
|
|
997d9d36ff | ||
|
|
809e471e2b | ||
|
|
54451103f3 | ||
|
|
35f1aa2e13 | ||
|
|
74abbef00d | ||
|
|
5d8365f001 | ||
|
|
c16fa8013a | ||
|
|
0e0c97c203 | ||
|
|
0fe4ebc7d5 | ||
|
|
a7920de824 | ||
|
|
06d85e1d6f | ||
|
|
f5802f9ed0 | ||
|
|
028248dfd7 | ||
|
|
f5714a5b2e | ||
|
|
d37165ca52 | ||
|
|
13e4a738be | ||
|
|
01942cea95 | ||
|
|
24f86632d0 | ||
|
|
5099f6f763 | ||
|
|
bfbaa36709 | ||
|
|
ea1b1f826b | ||
|
|
77f550fb5e | ||
|
|
8e4d352393 | ||
|
|
3b35b1bee0 | ||
|
|
f3976ba03a | ||
|
|
5c3a3ffa8e | ||
|
|
2f60ef44ea | ||
|
|
3b7d541224 | ||
|
|
4d17c60da7 | ||
|
|
38dc845f57 | ||
|
|
c299199d37 | ||
|
|
b5024c29df | ||
|
|
196682f2f2 | ||
|
|
b31148a8b7 | ||
|
|
b4d204d1d6 | ||
|
|
c82158c7c8 | ||
|
|
9b6adfc42d | ||
|
|
f0a403b224 | ||
|
|
fc1120338d | ||
|
|
4c0c8a83a9 | ||
|
|
b3949fdcf7 | ||
|
|
c4853fe746 | ||
|
|
c5417640a2 | ||
|
|
1f732d8d08 | ||
|
|
867e56cb84 | ||
|
|
203b044646 | ||
|
|
d98a2512b7 | ||
|
|
93aaeb4abe | ||
|
|
12679b77b7 | ||
|
|
781cbf3263 | ||
|
|
f1d9ecc392 | ||
|
|
973beb887a | ||
|
|
cf184661d9 | ||
|
|
1a138c0409 | ||
|
|
f8794791f3 | ||
|
|
f8eefa87d2 | ||
|
|
96d722ed0f | ||
|
|
42a1526b70 | ||
|
|
86df0bcaf2 | ||
|
|
9fe680def1 | ||
|
|
9e15444228 | ||
|
|
c78a123e9c | ||
|
|
ca65a8172c | ||
|
|
f20f0650cf | ||
|
|
9b4aa712f2 | ||
|
|
e574b6dd18 | ||
|
|
6033199864 | ||
|
|
5e19a80f9d | ||
|
|
aabeb2e679 | ||
|
|
e8674a3801 | ||
|
|
ba6a0e6fe6 | ||
|
|
f292ebf63e | ||
|
|
1dfceeb957 | ||
|
|
c037db9d42 | ||
|
|
1a74a930f7 | ||
|
|
d1b48388fb | ||
|
|
8c800525c0 | ||
|
|
aad98dec08 | ||
|
|
a9bb5a28ce | ||
|
|
7cb4fd6812 | ||
|
|
75018da1da | ||
|
|
41ab499698 | ||
|
|
b8afb10ec6 | ||
|
|
165972e75c | ||
|
|
b7edada7fe | ||
|
|
a2bf51615f | ||
|
|
adcc3fddc7 | ||
|
|
7bbd8f889a | ||
|
|
12412c70db | ||
|
|
41ff1021ad | ||
|
|
00bfd62393 | ||
|
|
a6f1ab8d53 | ||
|
|
c1db74ed28 | ||
|
|
27f205f38a | ||
|
|
25ad68ac4c | ||
|
|
1ffc377a9c | ||
|
|
19ab5c0749 | ||
|
|
c080c12629 | ||
|
|
0281229425 | ||
|
|
02d9bc3e44 | ||
|
|
cb11871b03 | ||
|
|
ba82fa1564 | ||
|
|
bd5a24515f | ||
|
|
dd5ab6b10a | ||
|
|
f54206d231 | ||
|
|
9f90c2cc91 | ||
|
|
db472691c9 | ||
|
|
836290840c | ||
|
|
00eebfbb3d | ||
|
|
a6f2e6743f | ||
|
|
0c5b7db4a2 | ||
|
|
fef7e8cb24 | ||
|
|
280c61f857 | ||
|
|
3682855668 | ||
|
|
93c2c3ee67 | ||
|
|
cc8a6fd4d8 | ||
|
|
500c605348 | ||
|
|
0c8dd582fa | ||
|
|
870ff095d8 | ||
|
|
934d120243 | ||
|
|
6a56d4972d | ||
|
|
3187d1ad28 | ||
|
|
b9c9881e4b | ||
|
|
7278397209 | ||
|
|
428d11c8e2 | ||
|
|
0c3df827f8 | ||
|
|
c21f57ebb2 | ||
|
|
d341585bed | ||
|
|
36a33f3575 | ||
|
|
022e7e484a | ||
|
|
3418c273d4 | ||
|
|
5853b6a065 | ||
|
|
dd8e8e9e4f | ||
|
|
c005dc9a22 | ||
|
|
809a976960 | ||
|
|
f273816405 | ||
|
|
d1ac098edb | ||
|
|
4b7c765cd1 | ||
|
|
c6f1894e10 | ||
|
|
f504f08cd4 | ||
|
|
c5c3dc856b | ||
|
|
2dafd2ea57 | ||
|
|
6c23360522 | ||
|
|
e60ac99b12 | ||
|
|
af0f96268d | ||
|
|
802964291a | ||
|
|
37a591618d | ||
|
|
e162ff8b3b | ||
|
|
7867ac1931 | ||
|
|
f42ff45475 | ||
|
|
32f89fa8d5 | ||
|
|
9156eee017 | ||
|
|
2c67d0c6f1 | ||
|
|
392330cea4 | ||
|
|
e7e7d38950 | ||
|
|
c7b100d6b6 | ||
|
|
df86dc3314 | ||
|
|
c3333fdf6a | ||
|
|
57f3416d60 | ||
|
|
e78d117e00 | ||
|
|
fd40a4d96a | ||
|
|
1aeee6e7b1 | ||
|
|
63db28d0ef | ||
|
|
dabf7966d1 | ||
|
|
9f6443b537 | ||
|
|
30164fd12a | ||
|
|
07e46dce56 | ||
|
|
2e289d6d7d | ||
|
|
f6a3068514 | ||
|
|
cc270bcf34 | ||
|
|
7b9fa08493 | ||
|
|
c545b79b65 | ||
|
|
b447100637 | ||
|
|
53ac7e5f65 | ||
|
|
ae5d04993c | ||
|
|
76a0910c0a | ||
|
|
c1927ee6b2 | ||
|
|
f08e3fd57a | ||
|
|
ef30a38969 | ||
|
|
9a3bff1c61 | ||
|
|
ef58b2ad18 | ||
|
|
299357e908 | ||
|
|
9d24e1f44b | ||
|
|
edb74d1249 | ||
|
|
7506337db1 | ||
|
|
a6ab181136 | ||
|
|
50f484b181 | ||
|
|
d7ad039147 | ||
|
|
ffcbc02837 | ||
|
|
9ba8731816 | ||
|
|
b29f798e05 | ||
|
|
bd40fac0e6 | ||
|
|
bf34060f9d | ||
|
|
b6f401e7f6 | ||
|
|
ee15fbc457 | ||
|
|
dfffa8606d | ||
|
|
8669dfc3ca | ||
|
|
a7e0a847a8 | ||
|
|
5ea45d77a1 | ||
|
|
6c71e525ea | ||
|
|
139c89d27b | ||
|
|
8044c08279 | ||
|
|
8e27c11b74 | ||
|
|
077e2887b5 | ||
|
|
855b3c5209 | ||
|
|
b4588867af | ||
|
|
ad49670da5 | ||
|
|
f49340e179 | ||
|
|
c5064b6979 | ||
|
|
3db4685b7e | ||
|
|
85f3a0d982 | ||
|
|
067df69ce9 | ||
|
|
8b76a4d4fd | ||
|
|
1b43e7dfeb | ||
|
|
e7fadf93cc | ||
|
|
22996d3c1c | ||
|
|
0cecc06d16 | ||
|
|
16b389dda1 | ||
|
|
b2b6d44d26 | ||
|
|
3ba835b3ff | ||
|
|
aabe28fc98 | ||
|
|
93615e1bbb | ||
|
|
dc48d6fc8c | ||
|
|
b48b30b927 | ||
|
|
4a3611f3b4 | ||
|
|
0e9df969f1 | ||
|
|
e9a71c5422 | ||
|
|
66eba4a46d | ||
|
|
1f11926d2d | ||
|
|
e56ff65407 | ||
|
|
24f0596272 | ||
|
|
fdb890e78a | ||
|
|
6da58943a7 | ||
|
|
6c05b27ec2 | ||
|
|
75d63d26b4 | ||
|
|
a8f8ce4e1a | ||
|
|
f608523e3d | ||
|
|
49b7c400c1 | ||
|
|
176336b555 | ||
|
|
19d2143f55 | ||
|
|
81a8c256d5 | ||
|
|
ebad38cdaf | ||
|
|
a38cd87fbb | ||
|
|
7442f17a10 | ||
|
|
e37d61cb81 | ||
|
|
281c4a807e | ||
|
|
1ea49fd3db | ||
|
|
728df8780d | ||
|
|
85343ab481 | ||
|
|
c0d5034e56 | ||
|
|
510dd8b05f | ||
|
|
d765164c48 | ||
|
|
4ab1223566 | ||
|
|
0f6df9a021 | ||
|
|
642446312d | ||
|
|
d2f5e68bb3 | ||
|
|
65fde5c965 | ||
|
|
f8fdf05ff6 | ||
|
|
6335ea17ee | ||
|
|
f9a47a2602 | ||
|
|
65b5d5db8e | ||
|
|
a64d1b2d12 | ||
|
|
655cb4edbe | ||
|
|
dc140ac457 | ||
|
|
27eabbce92 | ||
|
|
fe61fbf39c | ||
|
|
f3371864f7 | ||
|
|
5a3b5362f3 | ||
|
|
ac7bf8c62b | ||
|
|
12f951ada4 | ||
|
|
3e121b525f | ||
|
|
4500e949d8 | ||
|
|
193f80f1c1 | ||
|
|
a98529868e | ||
|
|
1ac6034457 | ||
|
|
d80cfb0d8d | ||
|
|
3bbb5c17bb | ||
|
|
696c6d176b | ||
|
|
6787e11e4e | ||
|
|
3eca0cb6c7 | ||
|
|
2e20984686 | ||
|
|
bd7911843d | ||
|
|
92ac73fc20 | ||
|
|
aa733a7daa | ||
|
|
2ecfdc234e | ||
|
|
1a31b971d9 | ||
|
|
c45f0c8fb8 | ||
|
|
16f6cda679 | ||
|
|
698b23f707 | ||
|
|
ccaeb10a92 | ||
|
|
fe2934a917 | ||
|
|
3383b43a75 | ||
|
|
398e94b5d3 | ||
|
|
d9f833878c | ||
|
|
efdea936fa | ||
|
|
1806e63a2a | ||
|
|
ccafd19531 | ||
|
|
30ec4c5401 | ||
|
|
e1d723b24e | ||
|
|
701b202b41 | ||
|
|
a227ca8c32 | ||
|
|
5e6aaa74aa | ||
|
|
73e0a1b74d | ||
|
|
f07ce10b1a | ||
|
|
fd2a837bea | ||
|
|
367763e2fe | ||
|
|
1c5e8efb75 | ||
|
|
cc3a46f54f | ||
|
|
96ac8c4167 | ||
|
|
39f67e15e2 | ||
|
|
6d2017a97c | ||
|
|
e91cc33568 | ||
|
|
a8c5514b85 | ||
|
|
1505b1b1cc | ||
|
|
6700152416 | ||
|
|
abd974957e | ||
|
|
36a8b001ab | ||
|
|
2b2bc96ade | ||
|
|
e4d0eca910 | ||
|
|
45cd28bb04 | ||
|
|
0fe5a80a95 | ||
|
|
6cea156df6 | ||
|
|
2d0ac12a6a | ||
|
|
1f178a2dcb | ||
|
|
2b19ca9641 | ||
|
|
02b2746203 | ||
|
|
4234fb3343 | ||
|
|
4995dc2656 | ||
|
|
ec92e5e756 | ||
|
|
89acc3ed5c | ||
|
|
daa33d098b | ||
|
|
d15e90c26d | ||
|
|
c1131251f9 | ||
|
|
46747607ea | ||
|
|
224681f1e0 | ||
|
|
f7ed67bac9 | ||
|
|
8ffa89ba16 | ||
|
|
980fc3af6d | ||
|
|
1b8a8cfd32 | ||
|
|
592548066e | ||
|
|
45032d937b |
@@ -1,76 +0,0 @@
|
||||
#!/usr/bin/env bash
|
||||
# PreToolUse Bash guard: block dangerous shell commands.
|
||||
# Denies: rm -rf, git reset --hard, git push -f, git clean -fd, chmod -R 777,
|
||||
# fork bombs, block device overwrites, mkfs, building Rust on macOS for Linux.
|
||||
set -euo pipefail
|
||||
|
||||
INPUT=$(cat)
|
||||
CMD=$(python3 -c "
|
||||
import json, sys
|
||||
try:
|
||||
data = json.loads(sys.stdin.read())
|
||||
print(data.get('tool_input', {}).get('command', ''))
|
||||
except: pass
|
||||
" <<< "$INPUT")
|
||||
BASE="${CLAUDE_PROJECT_DIR:-}"
|
||||
[[ -z "$BASE" ]] && BASE=$(python3 -c "
|
||||
import json, sys
|
||||
try:
|
||||
data = json.loads(sys.stdin.read())
|
||||
print(data.get('cwd', ''))
|
||||
except: pass
|
||||
" <<< "$INPUT")
|
||||
[[ -z "$BASE" ]] && BASE="$(pwd)"
|
||||
|
||||
# Normalize: collapse whitespace, strip leading/trailing
|
||||
CMD_NORM=$(echo "$CMD" | tr -s '[:space:]' ' ' | sed 's/^[[:space:]]*//;s/[[:space:]]*$//')
|
||||
|
||||
deny() {
|
||||
local reason="$1"
|
||||
python3 -c "
|
||||
import json
|
||||
print(json.dumps({
|
||||
'hookSpecificOutput': {
|
||||
'hookEventName': 'PreToolUse',
|
||||
'permissionDecision': 'deny',
|
||||
'permissionDecisionReason': '$reason'
|
||||
}
|
||||
}))
|
||||
"
|
||||
exit 0
|
||||
}
|
||||
|
||||
# Dangerous patterns
|
||||
case "$CMD_NORM" in
|
||||
*"rm -rf"*|*"rm -fr"*|*"rm -f -r"*|*"rm -r -f"*) deny "Destructive rm -rf blocked by security hook" ;;
|
||||
*"git reset --hard"*) deny "git reset --hard would lose uncommitted work" ;;
|
||||
*"git push --force"*|*"git push -f"*|*"git push -f "*) deny "git push --force would rewrite history" ;;
|
||||
*"git clean -fd"*|*"git clean -f -d"*) deny "git clean -fd deletes untracked files" ;;
|
||||
*"chmod -R 777"*|*"chmod -R 0777"*) deny "chmod -R 777 is a security risk" ;;
|
||||
*":(){ :"*"};:"*) deny "Fork bomb pattern blocked" ;;
|
||||
*"> /dev/sd"*|*">/dev/sd"*) deny "Block device overwrite blocked" ;;
|
||||
*"mkfs "*|*"mkfs."*) deny "Disk format command blocked" ;;
|
||||
esac
|
||||
|
||||
# Block building Rust locally on macOS (should always build on dev server)
|
||||
if [[ "$(uname)" == "Darwin" ]]; then
|
||||
if echo "$CMD_NORM" | grep -qE '^\s*cargo\s+build'; then
|
||||
# Allow if it's clearly an SSH command (building on remote)
|
||||
if ! echo "$CMD_NORM" | grep -qE 'ssh|sshpass'; then
|
||||
deny "NEVER build Rust on macOS — use ./scripts/deploy-to-target.sh --live or build on dev server via SSH"
|
||||
fi
|
||||
fi
|
||||
fi
|
||||
|
||||
# Check for path traversal escaping project root
|
||||
if [[ -n "$BASE" ]] && [[ -d "$BASE" ]]; then
|
||||
if echo "$CMD_NORM" | grep -qE '\.\./|/\.\.'; then
|
||||
if echo "$CMD_NORM" | grep -qE '(rm|mv|cp|cat|chmod|chown)\s+.*\.\.'; then
|
||||
if echo "$CMD_NORM" | grep -qE '\brm\b.*\.\.'; then
|
||||
deny "Path traversal with rm blocked"
|
||||
fi
|
||||
fi
|
||||
fi
|
||||
fi
|
||||
|
||||
exit 0
|
||||
@@ -1,43 +0,0 @@
|
||||
#!/usr/bin/env bash
|
||||
# PostToolUse Bash hook: detect deploy commands and remind to test.
|
||||
# Triggers after deploy-to-target.sh runs.
|
||||
set -euo pipefail
|
||||
|
||||
INPUT=$(cat)
|
||||
|
||||
CMD=$(python3 -c "
|
||||
import json, sys
|
||||
try:
|
||||
data = json.loads(sys.stdin.read())
|
||||
print(data.get('tool_input', {}).get('command', ''))
|
||||
except: pass
|
||||
" <<< "$INPUT")
|
||||
|
||||
# Only trigger on deploy commands or git push
|
||||
if ! echo "$CMD" | grep -qE 'deploy-to-target|git\s+push'; then
|
||||
exit 0
|
||||
fi
|
||||
|
||||
TIMESTAMP=$(date '+%Y-%m-%d %H:%M')
|
||||
|
||||
python3 -c "
|
||||
import json
|
||||
|
||||
message = '''Deploy detected at $TIMESTAMP.
|
||||
|
||||
Post-deploy checklist:
|
||||
1. Test the web UI at http://192.168.1.228
|
||||
2. Verify modified apps load correctly
|
||||
3. Check backend logs: sudo journalctl -u archipelago -n 20
|
||||
4. Check nginx: sudo tail -f /var/log/nginx/error.log
|
||||
5. If building ISO, sync system configs to image-recipe/configs/
|
||||
6. Update CHANGELOG.md if this is a notable change'''
|
||||
|
||||
output = {
|
||||
'hookSpecificOutput': {
|
||||
'hookEventName': 'PostToolUse',
|
||||
'deployReminder': message
|
||||
}
|
||||
}
|
||||
print(json.dumps(output))
|
||||
"
|
||||
@@ -1,82 +0,0 @@
|
||||
#!/usr/bin/env bash
|
||||
# PreToolUse Edit|Write guard: block edits outside project and to protected paths.
|
||||
# Denies: paths outside project, .git/, .env*, lockfiles, node_modules/, deploy-config.sh
|
||||
set -euo pipefail
|
||||
|
||||
INPUT=$(cat)
|
||||
FILE_PATH=$(python3 -c "
|
||||
import json, sys
|
||||
try:
|
||||
data = json.loads(sys.stdin.read())
|
||||
print(data.get('tool_input', {}).get('file_path', ''))
|
||||
except: pass
|
||||
" <<< "$INPUT")
|
||||
BASE="${CLAUDE_PROJECT_DIR:-}"
|
||||
[[ -z "$BASE" ]] && BASE=$(python3 -c "
|
||||
import json, sys
|
||||
try:
|
||||
data = json.loads(sys.stdin.read())
|
||||
print(data.get('cwd', ''))
|
||||
except: pass
|
||||
" <<< "$INPUT")
|
||||
[[ -z "$BASE" ]] && BASE="$(pwd)"
|
||||
|
||||
# Resolve to absolute path
|
||||
if [[ -z "$FILE_PATH" ]]; then
|
||||
exit 0
|
||||
fi
|
||||
ABS_BASE=$(cd "$BASE" 2>/dev/null && pwd) || true
|
||||
[[ -z "$ABS_BASE" ]] && ABS_BASE=$(python3 -c "import os,sys; print(os.path.abspath(os.path.normpath(sys.argv[1])))" "$BASE" 2>/dev/null) || true
|
||||
[[ -z "$ABS_BASE" ]] && ABS_BASE="$BASE"
|
||||
[[ "$ABS_BASE" != */ ]] && ABS_BASE="${ABS_BASE}/"
|
||||
if [[ "$FILE_PATH" != /* ]]; then
|
||||
ABS_PATH="$ABS_BASE${FILE_PATH#./}"
|
||||
else
|
||||
ABS_PATH="$FILE_PATH"
|
||||
fi
|
||||
ABS_PATH=$(python3 -c "import os,sys; print(os.path.abspath(os.path.normpath(sys.argv[1])))" "$ABS_PATH" 2>/dev/null) || true
|
||||
[[ -z "$ABS_PATH" ]] && ABS_PATH="$ABS_BASE${FILE_PATH#./}"
|
||||
|
||||
deny() {
|
||||
local reason="$1"
|
||||
echo "Blocked: $ABS_PATH — $reason" >&2
|
||||
python3 -c "
|
||||
import json
|
||||
print(json.dumps({
|
||||
'hookSpecificOutput': {
|
||||
'hookEventName': 'PreToolUse',
|
||||
'permissionDecision': 'deny',
|
||||
'permissionDecisionReason': '$reason'
|
||||
}
|
||||
}))
|
||||
"
|
||||
exit 0
|
||||
}
|
||||
|
||||
# Protected patterns
|
||||
PROTECTED_PATTERNS=(
|
||||
".git/"
|
||||
".env"
|
||||
".env.local"
|
||||
"node_modules/"
|
||||
"package-lock.json"
|
||||
"scripts/deploy-config.sh"
|
||||
)
|
||||
|
||||
for pattern in "${PROTECTED_PATTERNS[@]}"; do
|
||||
if [[ "$ABS_PATH" == *"$pattern"* ]] || [[ "$ABS_PATH" == *"/$pattern" ]]; then
|
||||
deny "Edit blocked: path matches protected pattern ($pattern)"
|
||||
fi
|
||||
done
|
||||
|
||||
# .env.*.local
|
||||
if [[ "$ABS_PATH" =~ \.env\..*\.local$ ]]; then
|
||||
deny "Edit blocked: .env.*.local files contain secrets"
|
||||
fi
|
||||
|
||||
# Ensure path is under project root
|
||||
if [[ "$ABS_PATH" != "$ABS_BASE"* ]] && [[ "$ABS_PATH" != "$BASE"* ]]; then
|
||||
deny "Edit blocked: path is outside project directory"
|
||||
fi
|
||||
|
||||
exit 0
|
||||
@@ -1,84 +0,0 @@
|
||||
# ISO Build Session — 2026-03-10
|
||||
|
||||
## Status: Changes ready, NOT yet deployed or built
|
||||
|
||||
All changes are local. Servers were unreachable at end of session (network issue, not crash).
|
||||
Need to: deploy to .228 → build new ISO → copy to File Browser Builds folder.
|
||||
|
||||
## Changes Made (Local, Uncommitted)
|
||||
|
||||
### 1. ISO Login Fix (`image-recipe/build-auto-installer-iso.sh`)
|
||||
- **Problem**: `chpasswd` fails silently in chroot (PAM not available), leaving password locked
|
||||
- **Fix**: Direct `/etc/shadow` manipulation with `sed` using SHA-512 hash from `openssl passwd -6`
|
||||
- Pre-computed hash as fallback if openssl unavailable
|
||||
- Verification check + chpasswd fallback
|
||||
- Also added `root:archipelago` password in Dockerfile
|
||||
- **Credentials**: `archipelago` / `archipelago` (TTY/SSH), `password123` (Web UI)
|
||||
|
||||
### 2. Onboarding "Server Starting Up" UX (4 Vue files)
|
||||
- **Problem**: On fresh install, backend takes 2-5 min to start. Onboarding shows scary error messages.
|
||||
- **OnboardingDid.vue**: Replaced 3-attempt retry with persistent auto-retry every 4s. Shows "Server starting up" with elapsed timer (e.g. `1:23`) to the right. Keeps trying until backend responds.
|
||||
- **OnboardingIdentity.vue**: Detects 502/503, shows orange "Server is still starting up" instead of red error.
|
||||
- **OnboardingBackup.vue**: Same friendly server-starting message.
|
||||
- **OnboardingVerify.vue**: Same friendly server-starting message.
|
||||
|
||||
### 3. First-Boot Container Fixes (`scripts/first-boot-containers.sh`)
|
||||
- **Problem**: Race conditions — services start before dependencies are ready
|
||||
- Added `wait_for_container()` function with configurable timeout and logging
|
||||
- **Bitcoin Knots**: Added RPC health check wait (up to 60s) before LND/NBXplorer/mempool start
|
||||
- **BTCPay PostgreSQL**: Replaced `sleep 3` with `pg_isready` health check (up to 30s)
|
||||
- **Mempool MariaDB**: Replaced `sleep 3` with connection check (up to 30s)
|
||||
- **File Browser**: Removed `--read-only` and `--cap-drop ALL` (was preventing database creation). Added separate `/database` volume mount.
|
||||
|
||||
### 4. Build Skill Updated (`.claude/skills/build-iso/SKILL.md`)
|
||||
- Added "Post-build: Publish to File Browser" step
|
||||
- ISO gets copied to `/var/lib/archipelago/filebrowser/Builds/` after every build
|
||||
|
||||
## Fresh Install Issues Found on .198
|
||||
- Login was broken (fixed in #1)
|
||||
- Onboarding showed 502 errors at every step (fixed in #2)
|
||||
- Containers not launching: Bitcoin Knots, BTCPay, File Browser, Grafana, LND (fixed in #3)
|
||||
- File Browser specifically: `--read-only` prevented database creation (fixed in #3)
|
||||
- Could not fully diagnose .198 — went offline before SSH diagnostic completed
|
||||
|
||||
## Deploy Steps When Servers Are Back
|
||||
```bash
|
||||
# 1. Deploy to live server
|
||||
./scripts/deploy-to-target.sh --live
|
||||
|
||||
# 2. Sync build script
|
||||
rsync -avz -e "ssh -i ~/.ssh/archipelago-deploy" \
|
||||
image-recipe/build-auto-installer-iso.sh \
|
||||
archipelago@192.168.1.228:~/archy/image-recipe/
|
||||
|
||||
# 3. Sync first-boot script
|
||||
rsync -avz -e "ssh -i ~/.ssh/archipelago-deploy" \
|
||||
scripts/first-boot-containers.sh \
|
||||
archipelago@192.168.1.228:~/archy/scripts/
|
||||
|
||||
# 4. Build ISO on server
|
||||
ssh -i ~/.ssh/archipelago-deploy archipelago@192.168.1.228 \
|
||||
'cd ~/archy/image-recipe && sudo ./build-auto-installer-iso.sh'
|
||||
|
||||
# 5. Copy to File Browser
|
||||
ssh -i ~/.ssh/archipelago-deploy archipelago@192.168.1.228 \
|
||||
'sudo mkdir -p /var/lib/archipelago/filebrowser/Builds && \
|
||||
sudo cp ~/archy/image-recipe/results/archipelago-installer-x86_64.iso \
|
||||
/var/lib/archipelago/filebrowser/Builds/'
|
||||
|
||||
# 6. Download to Mac
|
||||
scp -i ~/.ssh/archipelago-deploy \
|
||||
archipelago@192.168.1.228:~/archy/image-recipe/results/archipelago-installer-x86_64.iso \
|
||||
~/Downloads/
|
||||
```
|
||||
|
||||
## Files Modified (git diff summary)
|
||||
- `image-recipe/build-auto-installer-iso.sh` — password fix + Dockerfile root password
|
||||
- `scripts/first-boot-containers.sh` — health checks + filebrowser fix
|
||||
- `scripts/deploy-to-target.sh` — Tor permission fixes (from earlier)
|
||||
- `neode-ui/src/views/OnboardingDid.vue` — auto-retry with timer
|
||||
- `neode-ui/src/views/OnboardingIdentity.vue` — server-starting detection
|
||||
- `neode-ui/src/views/OnboardingBackup.vue` — server-starting detection
|
||||
- `neode-ui/src/views/OnboardingVerify.vue` — server-starting detection
|
||||
- `.claude/skills/build-iso/SKILL.md` — added File Browser publish step
|
||||
- Frontend already built: `web/dist/neode-ui/` is up to date
|
||||
@@ -1,292 +0,0 @@
|
||||
# Archipelago 3-Year Project Plan
|
||||
|
||||
**Version**: 1.0
|
||||
**Period**: March 2026 -- March 2029
|
||||
**Goal**: Production-ready Bitcoin Node OS with zero issues for end users
|
||||
**Visual constraint**: NEVER change animations, user experience, or visuals -- only neater layouts where highlighted
|
||||
|
||||
## Current Status: Year 1, Q1, Sprint 1 (Starting)
|
||||
|
||||
---
|
||||
|
||||
## Year 1: Foundation & Core Functionality (March 2026 -- February 2027)
|
||||
|
||||
### Q1 2026 (March -- May): Fix Broken UI, Testing Infrastructure, Networking
|
||||
|
||||
#### Sprint 1: Test Infrastructure (Week 1-2)
|
||||
- [ ] Install Vitest and configure frontend test runner
|
||||
- [ ] Create first frontend unit tests: RPC client (8+ test cases)
|
||||
- [ ] Create frontend unit tests: app store (6+ test cases)
|
||||
- [ ] Create frontend unit tests: container store (5+ test cases)
|
||||
- [ ] Create frontend unit tests: router guards (6+ test cases)
|
||||
- [ ] Create backend integration test scaffolding
|
||||
- [ ] Create backend unit tests: auth module (6+ test cases)
|
||||
- [ ] Create backend unit tests: identity module (5+ test cases)
|
||||
- [ ] Add CI-compatible test runner script (scripts/run-tests.sh)
|
||||
|
||||
#### Sprint 2: Fix Broken UI (Week 3-4)
|
||||
- [ ] Fix Settings.vue: replace .path-option-card with .glass-card
|
||||
- [ ] Fix Web5.vue top bar: verify glass sub-card consistency with Server.vue
|
||||
- [ ] Remove duplicate network diagnostics from Settings.vue
|
||||
- [ ] Server.vue: wire real RPC data to Local Network card
|
||||
- [ ] Server.vue: wire real RPC data to Web3 card (show "Coming Soon")
|
||||
|
||||
#### Sprint 3: Backend Robustness (Week 5-6)
|
||||
- [ ] Add system monitoring RPC endpoints (system.stats, system.processes, system.temperature)
|
||||
- [ ] Add system monitoring to frontend Dashboard (CPU/RAM/Disk gauges)
|
||||
- [ ] Add WiFi/Ethernet configuration RPC endpoints
|
||||
- [ ] Add WiFi/Ethernet UI to Server.vue
|
||||
- [ ] Implement CSRF protection on RPC layer
|
||||
- [ ] Fix CORS policy: restrict to same-origin
|
||||
- [ ] Add Nginx security headers
|
||||
|
||||
#### Sprint 4: Quality Baseline (Week 7-8)
|
||||
- [ ] Run full sweep and record baseline in docs/quality-baseline.md
|
||||
- [ ] Fix all silent catch blocks
|
||||
- [ ] Remove all console.log in production paths
|
||||
- [ ] Eliminate any-type usage in frontend
|
||||
- [ ] Health-gated deploy: add pre-deploy health check
|
||||
- [ ] Run canary deploy to secondary server
|
||||
|
||||
### Q2 2026 (June -- August): DWN, Backup/Restore, Kiosk Mode, StartOS Independence
|
||||
|
||||
#### Sprint 5: DWN Protocol Implementation (Week 1-3)
|
||||
- [ ] Implement DWN message store (dwn_store.rs)
|
||||
- [ ] Implement DWN HTTP API (POST /dwn)
|
||||
- [ ] Implement DWN peer sync protocol
|
||||
- [ ] Add DWN management UI (DwnManager.vue)
|
||||
- [ ] Add DWN RPC endpoints for protocol management
|
||||
|
||||
#### Sprint 6: Full Backup/Restore System (Week 4-5)
|
||||
- [ ] Extend backup module for full system backup
|
||||
- [ ] Add backup/restore RPC endpoints
|
||||
- [ ] Add backup/restore UI to Settings
|
||||
- [ ] Add backup to USB drive support
|
||||
|
||||
#### Sprint 7: Kiosk Mode Hardening (Week 6-7)
|
||||
- [ ] Add kiosk mode crash recovery
|
||||
- [ ] Add kiosk failsafe route (/recovery)
|
||||
- [ ] Add kiosk-specific keyboard shortcuts
|
||||
- [ ] Create kiosk systemd service
|
||||
|
||||
#### Sprint 8: StartOS Independence (Week 8-10)
|
||||
- [ ] Audit StartOS code usage → docs/startos-dependency-audit.md
|
||||
- [ ] Migrate essential StartOS utilities to archipelago
|
||||
- [ ] Remove core/startos from workspace
|
||||
- [ ] Run full regression test after removal
|
||||
|
||||
### Q3 2026 (September -- November): App Integration, Auto-Updates, ARM64
|
||||
|
||||
#### Sprint 9: App Integration Testing (Week 1-3)
|
||||
- [ ] Create app integration test suite (scripts/test-all-apps.sh)
|
||||
- [ ] Fix all app integration failures
|
||||
- [ ] Test dependency chains
|
||||
- [ ] Test fresh install end-to-end
|
||||
|
||||
#### Sprint 10: Auto-Update System (Week 4-6)
|
||||
- [ ] Implement update download and apply
|
||||
- [ ] Add update notification to frontend
|
||||
- [ ] Implement automatic update scheduling
|
||||
- [ ] Create release manifest infrastructure
|
||||
|
||||
#### Sprint 11: ARM64 Support (Week 7-9)
|
||||
- [ ] Set up ARM64 cross-compilation
|
||||
- [ ] Test ARM64 container images
|
||||
- [ ] Build ARM64 ISO
|
||||
- [ ] Test ARM64 on Raspberry Pi 5
|
||||
|
||||
#### Sprint 12: Quality Hardening (Week 10-12)
|
||||
- [ ] Achieve 50% frontend test coverage
|
||||
- [ ] Achieve 50% backend test coverage
|
||||
- [ ] Run overnight chaos test
|
||||
- [ ] Run full quality sweep vs baseline
|
||||
|
||||
### Q4 2026 (December -- February 2027): Security, Performance, Beta
|
||||
|
||||
#### Sprint 13: Security Hardening (Week 1-3)
|
||||
- [ ] Implement session expiry and rotation
|
||||
- [ ] Harden container security profiles
|
||||
- [ ] Add secrets rotation mechanism
|
||||
- [ ] Sanitize FileBrowser path traversal
|
||||
- [ ] Remove FileBrowser token from URLs
|
||||
- [ ] Run automated security scan
|
||||
|
||||
#### Sprint 14: Performance Optimization (Week 4-6)
|
||||
- [ ] Profile and optimize backend startup (<3s)
|
||||
- [ ] Optimize frontend bundle size (<500KB gzipped)
|
||||
- [ ] Add WebSocket connection pooling and heartbeat
|
||||
- [ ] Optimize container image pull performance
|
||||
|
||||
#### Sprint 15: Beta Release Prep (Week 7-10)
|
||||
- [ ] Create comprehensive user documentation
|
||||
- [ ] Create beta testing checklist
|
||||
- [ ] Build and test beta ISO
|
||||
- [ ] Publish v0.5.0-beta release
|
||||
- [ ] Run 72-hour stability test
|
||||
|
||||
---
|
||||
|
||||
## Year 2: Feature Completeness & Reliability (March 2027 -- February 2028)
|
||||
|
||||
### Q1 2027 (March -- May): W3C DIDs, JSON-LD VCs, Hardware Wallet
|
||||
|
||||
#### Sprint 16: W3C-Compliant DIDs (Week 1-3)
|
||||
- [ ] Implement W3C DID Document format
|
||||
- [ ] Implement DID Document verification
|
||||
- [ ] Update DID display in Web5.vue
|
||||
- [ ] Add DID resolution across peers
|
||||
|
||||
#### Sprint 17: JSON-LD Verifiable Credentials (Week 4-6)
|
||||
- [ ] Implement JSON-LD credential format
|
||||
- [ ] Add credential presentation protocol
|
||||
- [ ] Add credential management UI
|
||||
|
||||
#### Sprint 18: Hardware Wallet Integration (Week 7-10)
|
||||
- [ ] Research and document hardware wallet integration
|
||||
- [ ] Implement PSBT signing flow in LND RPC
|
||||
- [ ] Add hardware wallet UI flow
|
||||
- [ ] Add USB hardware wallet detection
|
||||
|
||||
### Q2 2027 (June -- August): Multi-Node, VPN, Community Marketplace
|
||||
|
||||
#### Sprint 19: Multi-Node Orchestration (Week 1-4)
|
||||
- [ ] Design multi-node architecture
|
||||
- [ ] Implement node federation protocol
|
||||
- [ ] Add multi-node dashboard
|
||||
- [ ] Implement federated app deployment
|
||||
|
||||
#### Sprint 20: VPN and Mesh Networking (Week 5-8)
|
||||
- [ ] Add Tailscale/WireGuard VPN integration
|
||||
- [ ] Add VPN status to Server.vue
|
||||
- [ ] Implement mesh networking discovery
|
||||
- [ ] Add DNS-over-HTTPS configuration
|
||||
|
||||
#### Sprint 21: Community App Marketplace (Week 9-12)
|
||||
- [ ] Design decentralized marketplace protocol
|
||||
- [ ] Implement marketplace manifest discovery
|
||||
- [ ] Implement app manifest publishing
|
||||
- [ ] Add community marketplace tab to frontend
|
||||
|
||||
### Q3 2027 (September -- November): Documentation, Reliability, Pre-Release
|
||||
|
||||
#### Sprint 22: Comprehensive Documentation (Week 1-3)
|
||||
- [ ] Write developer documentation
|
||||
- [ ] Write API documentation
|
||||
- [ ] Write app developer SDK documentation
|
||||
- [ ] Create Architecture Decision Records
|
||||
|
||||
#### Sprint 23: Reliability Engineering (Week 4-8)
|
||||
- [ ] Implement graceful shutdown
|
||||
- [ ] Add crash recovery
|
||||
- [ ] Implement disk space management
|
||||
- [ ] Add container health monitoring and auto-recovery
|
||||
- [ ] Run 1-week continuous uptime test
|
||||
|
||||
#### Sprint 24: Pre-Release Quality (Week 9-12)
|
||||
- [ ] Achieve 70% frontend test coverage
|
||||
- [ ] Achieve 70% backend test coverage
|
||||
- [ ] Run full regression screenshot comparison
|
||||
- [ ] Publish v0.8.0-rc1 release candidate
|
||||
|
||||
### Q4 2027 (December -- February 2028): Polish, Community, v0.9.0
|
||||
|
||||
#### Sprint 25: User Experience Polish (Week 1-4)
|
||||
- [ ] Run complete UX audit
|
||||
- [ ] Fix all UX audit findings
|
||||
- [ ] Polish error handling across entire frontend
|
||||
- [ ] Polish all forms
|
||||
|
||||
#### Sprint 26: Community Infrastructure (Week 5-8)
|
||||
- [ ] Set up update server infrastructure
|
||||
- [ ] Create community contribution guidelines
|
||||
- [ ] Set up issue tracker and roadmap
|
||||
- [ ] Publish v0.9.0 release
|
||||
|
||||
---
|
||||
|
||||
## Year 3: Production Polish & Scale (March 2028 -- March 2029)
|
||||
|
||||
### Q1 2028 (March -- May): Monitoring, Remote Management, Accessibility
|
||||
|
||||
#### Sprint 27: Advanced Monitoring (Week 1-4)
|
||||
- [ ] Implement real-time metrics collection
|
||||
- [ ] Add monitoring dashboard page
|
||||
- [ ] Implement alerting system
|
||||
- [ ] Add historical data export
|
||||
|
||||
#### Sprint 28: Remote Management (Week 5-8)
|
||||
- [ ] Implement Tailscale-based remote access
|
||||
- [ ] Add mobile-optimized remote management
|
||||
- [ ] Implement remote notification system
|
||||
|
||||
#### Sprint 29: Accessibility and i18n (Week 9-12)
|
||||
- [ ] Add ARIA labels and roles
|
||||
- [ ] Add keyboard navigation testing
|
||||
- [ ] Set up i18n infrastructure
|
||||
|
||||
### Q2 2028 (June -- August): Pen Testing, Final QA
|
||||
|
||||
#### Sprint 30: Security Penetration Testing (Week 1-4)
|
||||
- [ ] Run automated penetration test suite
|
||||
- [ ] Manual security review of all RPC endpoints
|
||||
- [ ] Harden Podman container isolation
|
||||
- [ ] Add rate limiting to all sensitive endpoints
|
||||
|
||||
#### Sprint 31: End-to-End QA (Week 5-8)
|
||||
- [ ] Create golden path test suite
|
||||
- [ ] Run regression test across all hardware
|
||||
- [ ] Achieve 80% test coverage
|
||||
- [ ] Run 30-day soak test
|
||||
|
||||
#### Sprint 32: Documentation and Community (Week 9-12)
|
||||
- [ ] Write troubleshooting guide
|
||||
- [ ] Create walkthrough documentation
|
||||
- [ ] Finalize all ADRs
|
||||
- [ ] Publish v0.95.0-rc2
|
||||
|
||||
### Q3 2028 (September -- November): v1.0 Release
|
||||
|
||||
#### Sprint 33: Final Polish (Week 1-4)
|
||||
- [ ] Final UX audit
|
||||
- [ ] Final security audit
|
||||
- [ ] Final sweep
|
||||
- [ ] Performance benchmark and optimize
|
||||
|
||||
#### Sprint 34: Release Engineering (Week 5-8)
|
||||
- [ ] Create release automation
|
||||
- [ ] Set up download/update infrastructure
|
||||
- [ ] Write v1.0 release notes
|
||||
- [ ] Build v1.0.0 release ISOs
|
||||
|
||||
#### Sprint 35: Launch (Week 9-12)
|
||||
- [ ] Tag and publish v1.0.0
|
||||
- [ ] Run 7-day post-release monitoring
|
||||
- [ ] Create v1.1 roadmap
|
||||
|
||||
### Q4 2028 (December -- February 2029): Maintenance
|
||||
|
||||
#### Sprint 36-39: Ongoing
|
||||
- [ ] Monthly dependency update cycle
|
||||
- [ ] Monthly security scan
|
||||
- [ ] Quarterly quality sweep
|
||||
- [ ] Community app reviews
|
||||
- [ ] Plan v2.0 features
|
||||
|
||||
---
|
||||
|
||||
## Milestone Summary
|
||||
|
||||
| Date | Milestone | Key Deliverables |
|
||||
|------|-----------|-----------------|
|
||||
| May 2026 | Q1 Complete | Tests, UI fixes, security, quality baseline |
|
||||
| Aug 2026 | Q2 Complete | DWN, backup/restore, kiosk, StartOS independence |
|
||||
| Nov 2026 | Q3 Complete | App testing, auto-updates, ARM64 |
|
||||
| Feb 2027 | **v0.5.0-beta** | First public beta |
|
||||
| Nov 2027 | **v0.8.0-rc1** | Release candidate |
|
||||
| Feb 2028 | **v0.9.0** | Pre-release |
|
||||
| Nov 2028 | **v1.0.0** | Production release |
|
||||
|
||||
## Execution Method
|
||||
- Execute via `/overnight` skill — each session picks up next uncompleted tasks
|
||||
- Full detailed acceptance criteria in the original plan conversation
|
||||
- Track progress by checking off items in this file as [x]
|
||||
@@ -1,30 +0,0 @@
|
||||
# Unbundled ISO Build (In Progress)
|
||||
|
||||
## Status: NOT YET BUILT
|
||||
- Server was unreachable (SSH timeout) when we tried to build — user rebooting
|
||||
- Changes are in working tree only, NOT YET COMMITTED
|
||||
|
||||
## What Was Done
|
||||
- Created `image-recipe/build-unbundled-iso.sh` — thin wrapper that sets `UNBUNDLED=1` and delegates to main script
|
||||
- Modified `image-recipe/build-auto-installer-iso.sh` to support `UNBUNDLED=1` env var
|
||||
|
||||
## Changes to build-auto-installer-iso.sh
|
||||
1. Added `UNBUNDLED="${UNBUNDLED:-0}"` config variable
|
||||
2. Step 3b: Skips container image capture from server AND registry pull (~20 tars)
|
||||
3. Skips `first-boot-containers.sh` bundling (no images to create containers from)
|
||||
4. Skips docker UI source bundling (bitcoin-ui, lnd-ui, electrs-ui)
|
||||
5. Different ISO filename: `archipelago-installer-unbundled-x86_64.iso`
|
||||
6. Updated installer completion message (tells user to install from Marketplace)
|
||||
7. Updated build summary output
|
||||
|
||||
## What Still Works in Unbundled
|
||||
- Full rootfs (Debian 12 + Podman + nginx + SSH)
|
||||
- Backend binary + web UI captured from server
|
||||
- Tor setup on first boot
|
||||
- Image loader service (harmlessly handles empty dir)
|
||||
- `package.install` already does `podman pull` — Marketplace works out of the box
|
||||
|
||||
## Next Steps
|
||||
1. Rsync updated scripts to dev server (192.168.1.228)
|
||||
2. Run: `sudo ./build-unbundled-iso.sh`
|
||||
3. Result appears in: `image-recipe/results/archipelago-installer-unbundled-x86_64.iso`
|
||||
@@ -1,514 +0,0 @@
|
||||
# Archipelago Production Polish Plan
|
||||
|
||||
**Duration**: 8 weeks (March 10 – May 4, 2026)
|
||||
**Goal**: Zero new features. Every existing feature polished to flawless production quality.
|
||||
**Philosophy**: The iPhone moment — everything just works, feels inevitable, no rough edges.
|
||||
|
||||
## SSH Access
|
||||
|
||||
All remote commands use SSH key auth (password auth is disabled):
|
||||
```bash
|
||||
ssh -i ~/.ssh/archipelago-deploy archipelago@192.168.1.228
|
||||
```
|
||||
Never use `sshpass`. The deploy script handles this automatically via `SSH_KEY`.
|
||||
|
||||
---
|
||||
|
||||
## Audit Summary
|
||||
|
||||
Full codebase audit completed March 8, 2026. Findings:
|
||||
|
||||
| Layer | Critical | High | Medium | Low |
|
||||
|-------|----------|------|--------|-----|
|
||||
| Frontend (Vue/TS) | 4 | 6 | 10 | 4 |
|
||||
| Backend (Rust) | 6 | 6 | 6 | 7 |
|
||||
| Infrastructure | 5 | 6 | 7 | 3 |
|
||||
| UX Flows | 4 | 4 | 6 | 3 |
|
||||
| **Total** | **19** | **22** | **29** | **17** |
|
||||
|
||||
---
|
||||
|
||||
## Skills Required
|
||||
|
||||
### Existing Skills (14)
|
||||
`deploy`, `deploy-both`, `diagnose`, `check-server`, `frontend-dev`, `sync-configs`, `build-iso`, `server-logs`, `add-app`, `harden`, `test`, `lint`, `ux-review`, `refactor`
|
||||
|
||||
### New Skills (9)
|
||||
| Skill | Purpose |
|
||||
|-------|---------|
|
||||
| `polish` | Main orchestrator — reads this plan, detects week, executes tasks |
|
||||
| `polish-errors` | Fix silent error handling, add user-facing error states |
|
||||
| `polish-loading` | Add skeleton loaders, loading indicators, empty states |
|
||||
| `polish-forms` | Input validation, trimming, real-time feedback |
|
||||
| `polish-backend` | Fix unwrap/expect, add timeouts, connection pooling |
|
||||
| `polish-deploy` | Add rollback, health checks, pre-deploy validation |
|
||||
| `polish-security` | Systemd hardening, nginx CSP, secrets management |
|
||||
| `polish-websocket` | Reconnection UX, connection status indicator, heartbeat |
|
||||
| `sweep` | Full automated quality sweep: lint + type-check + verify fixes |
|
||||
|
||||
---
|
||||
|
||||
## Week 1: Silent Failures & Error Handling (March 10–16)
|
||||
|
||||
**Theme**: Nothing fails silently. Every error is visible, actionable, recoverable.
|
||||
|
||||
### Tasks
|
||||
|
||||
#### 1.1 Frontend: Kill all silent catch blocks
|
||||
- **Files**: Settings.vue, Web5.vue, router/index.ts, Apps.vue, OnboardingIntro.vue
|
||||
- **Action**: Replace 21+ `.catch(() => {})` patterns with proper error handling
|
||||
- **Pattern**: Log to console in dev, show toast/inline error to user in prod
|
||||
- **Acceptance**: Zero `.catch(() => {})` in codebase (grep confirms)
|
||||
- **Skill**: `/polish-errors`
|
||||
|
||||
#### 1.2 Frontend: Remove all console.log from production
|
||||
- **Files**: stores/app.ts (15+), api/websocket.ts (12+)
|
||||
- **Action**: Replace with conditional dev-only logging or remove
|
||||
- **Pattern**: `if (import.meta.env.DEV) console.log(...)` or remove entirely
|
||||
- **Acceptance**: Zero `console.log` outside of dev guards (grep confirms)
|
||||
- **Skill**: `/lint`
|
||||
|
||||
#### 1.3 Backend: Fix all unwrap/expect in handler.rs
|
||||
- **Files**: core/archipelago/src/api/handler.rs (11 unwraps)
|
||||
- **Action**: Replace `.unwrap()` on Response builders with `.map_err()` and `?`
|
||||
- **Acceptance**: Zero `unwrap()` in handler.rs
|
||||
- **Skill**: `/polish-backend`
|
||||
|
||||
#### 1.4 Backend: Fix unwrap/expect across all production paths
|
||||
- **Files**: main.rs, identity.rs, totp.rs, rpc/mod.rs, image_verifier.rs
|
||||
- **Action**: Audit all 32 `.unwrap()`/`.expect()` calls, replace with `?` or `.context()`
|
||||
- **Acceptance**: Zero unwrap/expect outside of test modules
|
||||
- **Skill**: `/polish-backend`
|
||||
|
||||
#### 1.5 Backend: Hardcoded Bitcoin RPC credentials
|
||||
- **Files**: core/archipelago/src/api/rpc/bitcoin.rs:89
|
||||
- **Action**: Move `archipelago/archipelago123` to env var or secrets manager
|
||||
- **Pattern**: `std::env::var("ARCHIPELAGO_BITCOIN_RPC_USER").unwrap_or("archipelago".into())`
|
||||
- **Acceptance**: No hardcoded credentials in Rust source
|
||||
|
||||
#### 1.6 Deploy & verify
|
||||
- Run `/lint` to confirm zero violations
|
||||
- Run `/deploy` to live server
|
||||
- Run `/check-server` to verify health
|
||||
- Manual spot-check: trigger errors in UI, confirm they're visible
|
||||
|
||||
---
|
||||
|
||||
## Week 2: Loading States & Visual Feedback (March 17–23)
|
||||
|
||||
**Theme**: The user always knows what's happening. No blank screens, no mystery waits.
|
||||
|
||||
### Tasks
|
||||
|
||||
#### 2.1 Add skeleton loaders to all async views
|
||||
- **Files**: Apps.vue, AppDetails.vue, Marketplace.vue, Cloud.vue, Server.vue, Settings.vue
|
||||
- **Action**: Create `SkeletonLoader.vue` component, add to every view that fetches data
|
||||
- **Pattern**: Show skeleton immediately, swap to real content on load
|
||||
- **Acceptance**: Every view shows placeholder content during load
|
||||
- **Skill**: `/polish-loading`
|
||||
|
||||
#### 2.2 Add timeout warnings to long operations
|
||||
- **Files**: Login.vue (server startup), Marketplace.vue (app install)
|
||||
- **Action**: After 15s show "Taking longer than expected...", after 30s show troubleshoot options
|
||||
- **Acceptance**: No operation silently hangs
|
||||
|
||||
#### 2.3 Fix Start/Stop button state mismatch
|
||||
- **Files**: Apps.vue, AppDetails.vue, ContainerApps.vue
|
||||
- **Action**: Button reflects actual backend state, not a fixed 5s timer
|
||||
- **Pattern**: Poll backend every 2s during state transition, update button immediately on response
|
||||
- **Acceptance**: Button state always matches container state within 3s
|
||||
|
||||
#### 2.4 Connection status indicator
|
||||
- **Files**: Create `ConnectionStatus.vue`, integrate into App.vue header
|
||||
- **Action**: Show green/amber/red dot based on WebSocket connection state
|
||||
- **Pattern**: Use `wsClient.isConnected()` — green=connected, amber=reconnecting, red=disconnected
|
||||
- **Acceptance**: User always knows if they're connected
|
||||
- **Skill**: `/polish-websocket`
|
||||
|
||||
#### 2.5 Fix OnlineStatusPill to use real data
|
||||
- **Files**: components/OnlineStatusPill.vue
|
||||
- **Action**: Connect to actual WebSocket state instead of hardcoded "Online"
|
||||
- **Acceptance**: Pill reflects real connection state
|
||||
|
||||
#### 2.6 Empty states for all views
|
||||
- **Files**: Apps.vue, Cloud.vue, ContainerApps.vue
|
||||
- **Action**: When no data, show helpful message with CTA (e.g., "No apps installed — Browse Marketplace")
|
||||
- **Acceptance**: Every view handles the zero-data case gracefully
|
||||
|
||||
#### 2.7 Deploy & verify
|
||||
- `/deploy` then `/check-server`
|
||||
- Test: disconnect network, observe status indicator
|
||||
- Test: slow network (throttle), observe skeleton loaders
|
||||
- Test: fresh account with no apps, observe empty states
|
||||
|
||||
---
|
||||
|
||||
## Week 3: Form Validation & Input Quality (March 24–30)
|
||||
|
||||
**Theme**: Every input feels responsive, validated, impossible to misuse.
|
||||
|
||||
### Tasks
|
||||
|
||||
#### 3.1 Real-time password validation
|
||||
- **Files**: Login.vue (password setup), Settings.vue (password change)
|
||||
- **Action**: Show inline validation as user types: length check, match check, strength meter
|
||||
- **Pattern**: Debounced validation on input, green checkmark / red X per rule
|
||||
- **Acceptance**: User sees validation state before clicking submit
|
||||
- **Skill**: `/polish-forms`
|
||||
|
||||
#### 3.2 TOTP input improvements
|
||||
- **Files**: Login.vue (TOTP verify step)
|
||||
- **Action**: Auto-submit on 6 digits, show session countdown timer, trim whitespace
|
||||
- **Pattern**: `watch(code, () => { if (code.length === 6) submit() })`
|
||||
- **Acceptance**: TOTP flow is fast and clear, session timeout is visible
|
||||
|
||||
#### 3.3 Input trimming on all forms
|
||||
- **Files**: Login.vue, Settings.vue, any form input
|
||||
- **Action**: `.trim()` all text inputs before submission
|
||||
- **Acceptance**: Leading/trailing whitespace never causes failures
|
||||
|
||||
#### 3.4 Disable submit buttons during operations
|
||||
- **Files**: Settings.vue (password change), Login.vue (login), Marketplace.vue (install)
|
||||
- **Action**: Add `:disabled="isSubmitting"` to all action buttons
|
||||
- **Pattern**: Button shows spinner + disabled state during async operation
|
||||
- **Acceptance**: No button can be double-clicked during an operation
|
||||
|
||||
#### 3.5 Error message consistency
|
||||
- **Files**: All views with error messages
|
||||
- **Action**: Create `formatError()` utility that normalizes error messages
|
||||
- **Pattern**: Network errors -> "Can't reach server", Auth errors -> "Session expired", Server errors -> "Something went wrong"
|
||||
- **Acceptance**: Error messages are user-friendly, never show raw error strings
|
||||
|
||||
#### 3.6 Deploy & verify
|
||||
- Test every form: login, password change, TOTP setup, app install
|
||||
- Try invalid inputs, verify feedback is immediate and clear
|
||||
|
||||
---
|
||||
|
||||
## Week 4: Backend Robustness (March 31 – April 6)
|
||||
|
||||
**Theme**: The backend never crashes, never hangs, handles every edge case.
|
||||
|
||||
### Tasks
|
||||
|
||||
#### 4.1 Add timeouts to all container operations
|
||||
- **Files**: core/archipelago/src/container/dev_orchestrator.rs
|
||||
- **Action**: Wrap all podman calls with `tokio::time::timeout(Duration::from_secs(30), ...)`
|
||||
- **Acceptance**: No container operation can hang indefinitely
|
||||
|
||||
#### 4.2 Add timeouts to all external HTTP calls
|
||||
- **Files**: bitcoin.rs, handler.rs (LND proxy)
|
||||
- **Action**: Explicit `reqwest::Client` with timeout, not default
|
||||
- **Pattern**: Reuse a single `Client` stored in `RpcHandler` state
|
||||
- **Acceptance**: Every HTTP call has an explicit timeout
|
||||
|
||||
#### 4.3 Connection pooling for Bitcoin RPC
|
||||
- **Files**: core/archipelago/src/api/rpc/bitcoin.rs
|
||||
- **Action**: Store `reqwest::Client` in `RpcHandler`, reuse across requests
|
||||
- **Acceptance**: One client instance, connection pooled
|
||||
|
||||
#### 4.4 Fix all clippy warnings
|
||||
- **Action**: Run `cargo clippy --all-targets --all-features` on dev server, fix all 10 warnings
|
||||
- **Warnings**: `should_implement_trait`, `get_first`, `assign_op_pattern`, `wildcard_in_or_patterns`, `redundant_field_names`, `unused_import`, `ptr_arg`, `very_complex_type`, `if_else_collapse`, `io::Error::other`
|
||||
- **Acceptance**: `cargo clippy` returns zero warnings
|
||||
- **Skill**: `/lint`
|
||||
|
||||
#### 4.5 Rate limiting on unauthenticated endpoints
|
||||
- **Files**: core/archipelago/src/api/handler.rs
|
||||
- **Action**: Add per-IP rate limiting to `/archipelago/node-message` and `/electrs-status`
|
||||
- **Pattern**: In-memory rate limiter with 60 req/min per IP
|
||||
- **Acceptance**: Endpoints return 429 when rate exceeded
|
||||
|
||||
#### 4.6 Consistent error codes and messages
|
||||
- **Files**: All RPC endpoints
|
||||
- **Action**: Define error code constants, consistent capitalization
|
||||
- **Pattern**: `const ERR_AUTH: i32 = -1001;` etc.
|
||||
- **Acceptance**: All error responses use defined constants
|
||||
|
||||
#### 4.7 Remove dead code
|
||||
- **Files**: identity.rs (unused field, unused methods), auth.rs (dead_code allows)
|
||||
- **Action**: Remove `identity_dir` field, remove unused `verify()` and `did_key()` methods, remove `#[allow(dead_code)]` and verify usage
|
||||
- **Acceptance**: Zero `#[allow(dead_code)]` outside of generated code
|
||||
|
||||
#### 4.8 Replace println/eprintln with tracing
|
||||
- **Files**: core/startos/src/* (23+ instances)
|
||||
- **Action**: Replace `println!` -> `tracing::info!`, `eprintln!` -> `tracing::warn!`
|
||||
- **Acceptance**: Zero `println!` / `eprintln!` in non-test code
|
||||
|
||||
#### 4.9 Deploy & verify
|
||||
- `/deploy` then `/check-server` then `/diagnose`
|
||||
- Test: kill Bitcoin container, verify backend doesn't crash
|
||||
- Test: flood unauthenticated endpoint, verify rate limiting
|
||||
- Test: restart backend, verify graceful startup
|
||||
|
||||
---
|
||||
|
||||
## Week 5: WebSocket & Real-Time Quality (April 7–13)
|
||||
|
||||
**Theme**: Real-time updates are bulletproof. Connection issues are transparent to the user.
|
||||
|
||||
### Tasks
|
||||
|
||||
#### 5.1 WebSocket reconnection UX
|
||||
- **Files**: api/websocket.ts, App.vue
|
||||
- **Action**: After max reconnect attempts, show persistent banner "Connection lost. Click to retry."
|
||||
- **Pattern**: Don't silently give up after 10 attempts
|
||||
- **Acceptance**: User always has a path to reconnect
|
||||
- **Skill**: `/polish-websocket`
|
||||
|
||||
#### 5.2 WebSocket heartbeat improvement
|
||||
- **Files**: api/websocket.ts
|
||||
- **Action**: Send ping every 30s, expect pong within 5s, reconnect if missed
|
||||
- **Acceptance**: Stale connections detected within 35s, not 60s
|
||||
|
||||
#### 5.3 RPC client session detection
|
||||
- **Files**: api/rpc-client.ts
|
||||
- **Action**: On 401/403 response, redirect to login page instead of showing generic error
|
||||
- **Pattern**: `if (status === 401) { router.push('/login'); return; }`
|
||||
- **Acceptance**: Expired sessions redirect to login immediately
|
||||
|
||||
#### 5.4 Message queuing during reconnection
|
||||
- **Files**: api/rpc-client.ts, api/websocket.ts
|
||||
- **Action**: If WebSocket is down, queue state-update subscriptions, replay on reconnect
|
||||
- **Pattern**: Don't lose container state updates during brief disconnects
|
||||
- **Acceptance**: State is consistent after reconnection without page refresh
|
||||
|
||||
#### 5.5 WebSocket race condition fix
|
||||
- **Files**: stores/app.ts, api/websocket.ts
|
||||
- **Action**: Fix duplicate listener issue on rapid reconnect (`isWsSubscribed` flag)
|
||||
- **Pattern**: Use a Set of listener IDs, deduplicate on registration
|
||||
- **Acceptance**: No duplicate event handlers after reconnect cycles
|
||||
|
||||
#### 5.6 Deploy & verify
|
||||
- Test: kill backend, observe frontend reconnection behavior
|
||||
- Test: toggle wifi, observe status indicator + reconnection
|
||||
- Test: let session expire, verify redirect to login
|
||||
|
||||
---
|
||||
|
||||
## Week 6: Deployment & Infrastructure Hardening (April 14–20)
|
||||
|
||||
**Theme**: Deployments are safe, reversible, and verified. Infrastructure is production-grade.
|
||||
|
||||
### Tasks
|
||||
|
||||
#### 6.1 Deploy script: add rollback capability
|
||||
- **Files**: scripts/deploy-to-target.sh
|
||||
- **Action**: Before overwriting binary/frontend, backup to `.backup` suffix
|
||||
- **Pattern**: On health check failure after restart, restore from backup
|
||||
- **Acceptance**: Failed deploy auto-restores previous working version
|
||||
- **Skill**: `/polish-deploy`
|
||||
|
||||
#### 6.2 Deploy script: pre-deploy sanity checks
|
||||
- **Files**: scripts/deploy-to-target.sh
|
||||
- **Action**: Check disk space (2GB min), verify SSH key exists, verify target dir exists
|
||||
- **Acceptance**: Deploy fails early with clear message if preconditions not met
|
||||
|
||||
#### 6.3 Deploy script: post-deploy health verification
|
||||
- **Files**: scripts/deploy-to-target.sh
|
||||
- **Action**: After restart, poll `/health` endpoint for 30s. If no 200, trigger rollback
|
||||
- **Acceptance**: Every deploy is verified healthy before declaring success
|
||||
|
||||
#### 6.4 Deploy script: deployment locking
|
||||
- **Files**: scripts/deploy-to-target.sh
|
||||
- **Action**: Use flock to prevent concurrent deploys
|
||||
- **Acceptance**: Second simultaneous deploy fails immediately with message
|
||||
|
||||
#### 6.5 First-boot script: add error handling
|
||||
- **Files**: scripts/first-boot-containers.sh
|
||||
- **Action**: Add `set -e`, verify each container starts before creating dependents
|
||||
- **Acceptance**: If Bitcoin fails, Mempool is not attempted
|
||||
|
||||
#### 6.6 Systemd service hardening
|
||||
- **Files**: image-recipe/configs/archipelago.service
|
||||
- **Action**: Add `PrivateTmp=yes`, `NoNewPrivileges=true`, `ProtectSystem=strict`, `ProtectHome=yes`, `SystemCallFilter=@system-service`
|
||||
- **Acceptance**: Service runs with minimal privileges
|
||||
- **Skill**: `/harden`
|
||||
|
||||
#### 6.7 Nginx security headers
|
||||
- **Files**: image-recipe/configs/nginx-archipelago.conf
|
||||
- **Action**: Add HSTS, fix CSP (remove unsafe-inline), add rate limiting zones, custom log format that strips tokens
|
||||
- **Acceptance**: Security headers pass Mozilla Observatory scan
|
||||
|
||||
#### 6.8 Nginx config: test before reload
|
||||
- **Files**: scripts/deploy-to-target.sh
|
||||
- **Action**: `nginx -t` failure should abort deploy and restore backup config
|
||||
- **Acceptance**: Invalid nginx config never goes live
|
||||
|
||||
#### 6.9 Deploy & verify
|
||||
- Test: deploy with intentionally broken binary, verify rollback
|
||||
- Test: deploy with invalid nginx config, verify rollback
|
||||
- Test: concurrent deploy attempt, verify lock
|
||||
- Run `/diagnose` full check
|
||||
|
||||
---
|
||||
|
||||
## Week 7: Accessibility, Polish & Edge Cases (April 21–27)
|
||||
|
||||
**Theme**: Every interaction is crisp. Keyboard users, slow networks, edge cases — all handled.
|
||||
|
||||
### Tasks
|
||||
|
||||
#### 7.1 ARIA labels on all interactive elements
|
||||
- **Files**: All views and components
|
||||
- **Action**: Add `aria-label` to buttons, links, form inputs that lack visible labels
|
||||
- **Pattern**: `<button aria-label="Install Bitcoin Core" ...>`
|
||||
- **Acceptance**: Every interactive element has accessible name
|
||||
|
||||
#### 7.2 Focus management in modals
|
||||
- **Files**: Apps.vue (uninstall modal), Marketplace.vue (filter modal), Settings.vue
|
||||
- **Action**: Trap focus inside modals, return focus on close, autofocus first interactive element
|
||||
- **Pattern**: Use `useFocusTrap` composable
|
||||
- **Acceptance**: Tab key never leaves modal; Escape closes; focus returns to trigger
|
||||
|
||||
#### 7.3 Keyboard navigation completeness
|
||||
- **Files**: All views
|
||||
- **Action**: Verify every action is reachable via keyboard (Tab/Enter/Escape)
|
||||
- **Acceptance**: Full app usable without mouse
|
||||
|
||||
#### 7.4 Fix inline Tailwind violations
|
||||
- **Files**: Web5.vue, AppDetails.vue, Cloud.vue, onboarding views
|
||||
- **Action**: Extract inline classes to global classes in style.css
|
||||
- **Pattern**: `px-3 py-1.5 rounded-lg bg-white/5` -> `.info-row` class
|
||||
- **Acceptance**: Zero inline Tailwind utility classes in components
|
||||
- **Skill**: `/ux-review`
|
||||
|
||||
#### 7.5 Touch feedback on mobile
|
||||
- **Files**: style.css, app card components
|
||||
- **Action**: Add `:active` states for mobile touch feedback
|
||||
- **Pattern**: `.app-card:active { transform: scale(0.98); }`
|
||||
- **Acceptance**: Every tappable element has tactile feedback
|
||||
|
||||
#### 7.6 Responsive edge cases
|
||||
- **Files**: Marketplace.vue, Dashboard.vue, AppDetails.vue
|
||||
- **Action**: Test at 320px, 375px, 768px, 1024px, 1440px widths
|
||||
- **Fix**: Any overflow, text truncation, or broken layouts
|
||||
- **Acceptance**: No horizontal scroll or broken layout at any standard width
|
||||
|
||||
#### 7.7 Fix template crash risks
|
||||
- **Files**: ContainerApps.vue:76 (`app.image.split('/').pop()`)
|
||||
- **Action**: Add null guards on all template expressions that chain methods
|
||||
- **Pattern**: `app.image?.split('/').pop() ?? 'unknown'`
|
||||
- **Acceptance**: No template expression can crash on null/undefined data
|
||||
|
||||
#### 7.8 Remove all TODO/FIXME from production code
|
||||
- **Files**: Web5.vue, AppDetails.vue, backend TODO comments
|
||||
- **Action**: Either implement the TODO or remove the dead code
|
||||
- **Pattern**: If feature isn't ready, remove the UI element entirely
|
||||
- **Acceptance**: Zero TODO/FIXME/HACK in committed code
|
||||
- **Skill**: `/refactor`
|
||||
|
||||
#### 7.9 Deploy & verify
|
||||
- Test: navigate entire app with keyboard only
|
||||
- Test: resize browser through all breakpoints
|
||||
- Test: screen reader (VoiceOver) basic navigation
|
||||
- Run `/ux-review` on every view
|
||||
|
||||
---
|
||||
|
||||
## Week 8: Integration Testing, Final Sweep & ISO (April 28 – May 4)
|
||||
|
||||
**Theme**: Everything works together. The final product is tested end-to-end and burned to ISO.
|
||||
|
||||
### Tasks
|
||||
|
||||
#### 8.1 Create critical path tests — Frontend
|
||||
- **Files**: Create `neode-ui/src/__tests__/` directory
|
||||
- **Tests to write**:
|
||||
- Login flow: valid password, invalid password, TOTP, session timeout
|
||||
- App lifecycle: install -> start -> launch -> stop -> uninstall
|
||||
- Settings: password change, TOTP setup, TOTP disable
|
||||
- WebSocket: connect, disconnect, reconnect
|
||||
- **Framework**: Vitest + @vue/test-utils (already in package.json)
|
||||
- **Acceptance**: 10+ critical path tests passing
|
||||
- **Skill**: `/test`
|
||||
|
||||
#### 8.2 Create critical path tests — Backend
|
||||
- **Tests to write**:
|
||||
- RPC endpoint validation (good/bad input for each endpoint)
|
||||
- Session management (create, validate, expire, invalidate)
|
||||
- Container manifest parsing (valid, invalid, missing fields)
|
||||
- Rate limiting (under limit, at limit, over limit)
|
||||
- **Acceptance**: 10+ backend tests passing
|
||||
- **Skill**: `/test`
|
||||
|
||||
#### 8.3 Create deployment verification test
|
||||
- **Files**: scripts/verify-deploy.sh (new)
|
||||
- **Action**: Script that hits every endpoint, checks every container, verifies every UI route
|
||||
- **Pattern**: Automated smoke test run after every deploy
|
||||
- **Acceptance**: Script exits 0 only if everything works
|
||||
|
||||
#### 8.4 Full quality sweep
|
||||
- Run `/lint` — zero violations
|
||||
- Run `/harden` — zero findings
|
||||
- Run `/ux-review` — zero findings
|
||||
- Run `/diagnose` — all green
|
||||
- Run `/sweep` — clean bill of health
|
||||
- **Acceptance**: All skills report zero issues
|
||||
|
||||
#### 8.5 Build final ISO
|
||||
- Sync all configs: `/sync-configs`
|
||||
- Build ISO: `/build-iso`
|
||||
- Flash to USB, boot on clean hardware
|
||||
- Verify first-boot experience end-to-end
|
||||
- **Acceptance**: ISO boots, onboarding works, Bitcoin syncs, apps install
|
||||
|
||||
#### 8.6 Performance baseline
|
||||
- Measure and document:
|
||||
- Time to first meaningful paint (target: <2s)
|
||||
- Login flow completion time (target: <3s)
|
||||
- App install completion time (document actual)
|
||||
- WebSocket reconnection time (target: <5s)
|
||||
- Backend cold start time (target: <3s)
|
||||
- **Acceptance**: All targets met or documented with explanation
|
||||
|
||||
#### 8.7 Final documentation pass
|
||||
- Update `docs/current-state.md` to reflect production status
|
||||
- Update `CHANGELOG.md` with all polish work
|
||||
- Verify all CLAUDE.md instructions are still accurate
|
||||
- **Acceptance**: Docs match reality
|
||||
|
||||
---
|
||||
|
||||
## Metrics & Definition of Done
|
||||
|
||||
### Per-Week Exit Criteria
|
||||
Each week is "done" when:
|
||||
1. All tasks for that week have acceptance criteria met
|
||||
2. `/sweep` returns zero violations for that week's focus area
|
||||
3. `/deploy` succeeds and `/check-server` is green
|
||||
4. Manual spot-check of affected features passes
|
||||
|
||||
### Project Exit Criteria (Week 8)
|
||||
The project is done when ALL of these are true:
|
||||
- [ ] Zero `.catch(() => {})` in frontend
|
||||
- [ ] Zero `console.log` outside dev guards
|
||||
- [ ] Zero `unwrap()`/`expect()` in backend production paths
|
||||
- [ ] Zero clippy warnings
|
||||
- [ ] Zero inline Tailwind in components
|
||||
- [ ] Zero TODO/FIXME in committed code
|
||||
- [ ] Every view has: loading state, error state, empty state
|
||||
- [ ] Every form has: real-time validation, disabled during submit
|
||||
- [ ] Every button action has: loading feedback, error feedback
|
||||
- [ ] WebSocket shows connection status to user
|
||||
- [ ] Session timeout redirects to login
|
||||
- [ ] Deploy has: rollback, health check, locking
|
||||
- [ ] Systemd service is hardened
|
||||
- [ ] Nginx has: HSTS, proper CSP, rate limiting, clean logs
|
||||
- [ ] 10+ frontend tests passing
|
||||
- [ ] 10+ backend tests passing
|
||||
- [ ] ISO boots and onboards successfully
|
||||
- [ ] All performance targets met
|
||||
|
||||
---
|
||||
|
||||
## Risk Register
|
||||
|
||||
| Risk | Mitigation |
|
||||
|------|------------|
|
||||
| Skeleton loaders change visual feel | Match exact glassmorphism style, use existing color tokens |
|
||||
| Backend changes break existing functionality | Deploy to secondary server (198) first, test, then primary |
|
||||
| Nginx CSP changes break app iframes | Test each framed app individually before deploying |
|
||||
| Rate limiting blocks legitimate use | Set generous limits (60/min), monitor false positives |
|
||||
| Test suite becomes maintenance burden | Only test critical paths, no unit tests for trivial code |
|
||||
| ISO build captures incomplete state | Always build ISO from clean deploy, never mid-development |
|
||||
@@ -1,145 +0,0 @@
|
||||
# Expand AIUI Node Capabilities
|
||||
|
||||
## Context
|
||||
AIUI currently sees basic app status and file names but can't read files, check Bitcoin/LND details, or view app logs. Expanding these 4 capabilities makes AIUI a truly useful node assistant.
|
||||
|
||||
---
|
||||
|
||||
## 1. File Reading (frontend-only) [DONE]
|
||||
|
||||
### `neode-ui/src/api/filebrowser-client.ts`
|
||||
Add `readFileAsText(path, maxBytes = 102400)` method:
|
||||
- Fetch from existing `/app/filebrowser/api/raw{path}?auth={token}` endpoint
|
||||
- Limit response to 100KB (truncate with note)
|
||||
- Only allow text-like extensions: `.txt`, `.md`, `.json`, `.csv`, `.log`, `.conf`, `.yaml`, `.yml`, `.toml`, `.xml`, `.html`, `.css`, `.js`, `.ts`, `.py`, `.sh`
|
||||
- Return `{ content: string, truncated: boolean, size: number }`
|
||||
|
||||
### `neode-ui/src/types/aiui-protocol.ts`
|
||||
Add `'read-file'` and `'tail-logs'` to `AIActionType` union.
|
||||
|
||||
### `neode-ui/src/services/contextBroker.ts`
|
||||
Add `read-file` action handler:
|
||||
- Check `files` permission is enabled
|
||||
- Validate path param exists, validate extension
|
||||
- Call `fileBrowserClient.readFileAsText(path)`
|
||||
- Return content in action response
|
||||
|
||||
### `AIUI/packages/app/src/composables/useArchy.ts`
|
||||
- Add `readFile(path: string)` helper that calls `archyBridge.requestAction('read-file', { path })`
|
||||
- Update `buildArchyContext()` files section: mention "You can read file contents by requesting the read-file action with a file path."
|
||||
|
||||
---
|
||||
|
||||
## 2. App Log Viewing (frontend-only) [DONE]
|
||||
|
||||
### `neode-ui/src/services/contextBroker.ts`
|
||||
Add `tail-logs` action handler:
|
||||
- Check `apps` permission is enabled
|
||||
- Params: `{ appId: string, lines?: string }` (default 50, max 200)
|
||||
- Call existing `rpcClient.call({ method: 'container-logs', params: { app_id, lines } })`
|
||||
- Return log lines in action response
|
||||
|
||||
### `AIUI/packages/app/src/composables/useArchy.ts`
|
||||
- Add `tailLogs(appId: string, lines?: number)` helper
|
||||
- Update `buildArchyContext()` apps section: "You can view recent app logs by requesting the tail-logs action with an appId."
|
||||
|
||||
---
|
||||
|
||||
## 3. Bitcoin Deep Data (backend + frontend) [DONE]
|
||||
|
||||
### `core/archipelago/src/api/rpc/mod.rs`
|
||||
Add routing: `"bitcoin.getinfo" => self.handle_bitcoin_getinfo().await`
|
||||
|
||||
### New: `core/archipelago/src/api/rpc/bitcoin.rs`
|
||||
Add `handle_bitcoin_getinfo()`:
|
||||
- Use `reqwest` to POST to `http://127.0.0.1:8332` with Basic Auth `archipelago:archipelago123`
|
||||
- Call `getblockchaininfo` JSON-RPC method
|
||||
- Call `getmempoolinfo` JSON-RPC method
|
||||
- Return sanitized JSON:
|
||||
```json
|
||||
{
|
||||
"block_height": 800000,
|
||||
"sync_progress": 0.9999,
|
||||
"chain": "main",
|
||||
"difficulty": 72006146,
|
||||
"mempool_size": 45000000,
|
||||
"mempool_tx_count": 12500,
|
||||
"verification_progress": 0.9999
|
||||
}
|
||||
```
|
||||
- Handle connection errors gracefully (Bitcoin Core might be syncing or down)
|
||||
|
||||
### `neode-ui/src/services/contextBroker.ts`
|
||||
Enrich `bitcoin` category sanitizer:
|
||||
- Call `rpcClient.call({ method: 'bitcoin.getinfo' })`
|
||||
- Merge with existing container status data
|
||||
- Return block height, sync %, chain, mempool stats
|
||||
|
||||
### `AIUI/packages/app/src/composables/useArchy.ts`
|
||||
- Add `bitcoinInfo` ref with block height, sync %, etc.
|
||||
- Update `buildArchyContext()`: "**Bitcoin:** Block 800,000 (99.99% synced), mainnet, mempool: 12,500 txs"
|
||||
|
||||
---
|
||||
|
||||
## 4. LND Deep Data (backend + frontend) [DONE]
|
||||
|
||||
### `core/archipelago/src/api/rpc/mod.rs`
|
||||
Add routing: `"lnd.getinfo" => self.handle_lnd_getinfo().await`
|
||||
|
||||
### New: `core/archipelago/src/api/rpc/lnd.rs`
|
||||
Add `handle_lnd_getinfo()`:
|
||||
- Read admin macaroon from `/var/lib/archipelago/lnd/data/chain/bitcoin/mainnet/admin.macaroon`
|
||||
- Use `reqwest` to GET `https://127.0.0.1:8080/v1/getinfo` with `Grpc-Metadata-macaroon` header (hex-encoded)
|
||||
- GET `https://127.0.0.1:8080/v1/balance/channels` for channel balance
|
||||
- GET `https://127.0.0.1:8080/v1/balance/blockchain` for on-chain balance
|
||||
- Accept self-signed cert (`reqwest::Client::builder().danger_accept_invalid_certs(true)`)
|
||||
- Return sanitized JSON:
|
||||
```json
|
||||
{
|
||||
"alias": "my-node",
|
||||
"num_active_channels": 5,
|
||||
"num_peers": 8,
|
||||
"synced_to_chain": true,
|
||||
"block_height": 800000,
|
||||
"balance_sats": 1500000,
|
||||
"channel_balance_sats": 3000000,
|
||||
"pending_open_balance": 0
|
||||
}
|
||||
```
|
||||
- **Never expose**: private keys, seed, macaroon, node pubkey (optional — could include for identification)
|
||||
- Handle errors: LND might be locked, syncing, or not installed
|
||||
|
||||
### `neode-ui/src/services/contextBroker.ts`
|
||||
Enrich `wallet` category:
|
||||
- Call `rpcClient.call({ method: 'lnd.getinfo' })`
|
||||
- Return alias, channels, balances, sync status
|
||||
|
||||
### `AIUI/packages/app/src/composables/useArchy.ts`
|
||||
- Add `lndInfo` ref
|
||||
- Update `buildArchyContext()`: "**Lightning:** 5 channels, 3M sats in channels, 1.5M on-chain, synced"
|
||||
|
||||
---
|
||||
|
||||
## File Summary
|
||||
|
||||
| File | Change |
|
||||
|------|--------|
|
||||
| `neode-ui/src/api/filebrowser-client.ts` | Add `readFileAsText()` |
|
||||
| `neode-ui/src/types/aiui-protocol.ts` | Add `read-file`, `tail-logs` action types |
|
||||
| `neode-ui/src/services/contextBroker.ts` | Add 2 action handlers + enrich bitcoin/wallet categories |
|
||||
| `neode-ui/src/stores/aiPermissions.ts` | Update category descriptions |
|
||||
| `core/archipelago/src/api/rpc/mod.rs` | Add 2 route entries |
|
||||
| `core/archipelago/src/api/rpc/bitcoin.rs` | New: Bitcoin Core RPC proxy |
|
||||
| `core/archipelago/src/api/rpc/lnd.rs` | New: LND REST proxy |
|
||||
| `AIUI/packages/app/src/composables/useArchy.ts` | Add helpers + enrich buildArchyContext() |
|
||||
|
||||
## Verification
|
||||
1. `cd neode-ui && npm run build` — frontend builds
|
||||
2. `./scripts/deploy-to-target.sh --live` — deploys + builds Rust backend on server
|
||||
3. Test in AIUI chat:
|
||||
- "What files do I have?" → sees file list
|
||||
- "Read my config.txt" → gets file content
|
||||
- "How's my Bitcoin node?" → block height, sync %, mempool
|
||||
- "What's my Lightning balance?" → channel count, sats balance
|
||||
- "Why is Mempool not working?" → views recent logs
|
||||
- "Show me the last 50 lines of Bitcoin logs" → log output
|
||||
@@ -1,35 +0,0 @@
|
||||
{
|
||||
"hooks": {
|
||||
"PreToolUse": [
|
||||
{
|
||||
"matcher": "Bash",
|
||||
"hooks": [
|
||||
{
|
||||
"type": "command",
|
||||
"command": "\"$CLAUDE_PROJECT_DIR\"/.claude/hooks/block-risky-bash.sh"
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"matcher": "Edit|Write",
|
||||
"hooks": [
|
||||
{
|
||||
"type": "command",
|
||||
"command": "\"$CLAUDE_PROJECT_DIR\"/.claude/hooks/protect-files.sh"
|
||||
}
|
||||
]
|
||||
}
|
||||
],
|
||||
"PostToolUse": [
|
||||
{
|
||||
"matcher": "Bash",
|
||||
"hooks": [
|
||||
{
|
||||
"type": "command",
|
||||
"command": "\"$CLAUDE_PROJECT_DIR\"/.claude/hooks/post-deploy-check.sh"
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
@@ -1,49 +0,0 @@
|
||||
---
|
||||
name: add-app
|
||||
description: Step-by-step guide for adding a new containerized app to Archipelago
|
||||
disable-model-invocation: true
|
||||
allowed-tools: Bash, Read, Write, Edit, Glob, Grep
|
||||
argument-hint: "[app-name]"
|
||||
---
|
||||
|
||||
Add a new containerized app ($ARGUMENTS) to Archipelago.
|
||||
|
||||
## Steps
|
||||
|
||||
### 1. Create the manifest
|
||||
|
||||
Create `apps/{app-id}/manifest.yml` following the spec in `docs/app-manifest-spec.md`:
|
||||
- `app.id` (kebab-case), `app.name`, `app.version` (SemVer)
|
||||
- `container.image` (pinned version, **NEVER** `latest`)
|
||||
- `security`: readonly_root, dropped capabilities, non-root UID > 1000
|
||||
- `health_check`, `dependencies`
|
||||
|
||||
### 2. Add app icon
|
||||
|
||||
Place icon at `neode-ui/public/assets/img/app-icons/{app-id}.{png|webp|svg}`
|
||||
|
||||
### 3. Create status UI (if no native web UI)
|
||||
|
||||
For apps without their own web interface, create a UI container in `docker/{app-id}-ui/` following the patterns in `.cursor/rules/APP-UI-STANDARDS.md`.
|
||||
|
||||
Reference implementations:
|
||||
- Bitcoin UI: `docker/bitcoin-ui/`
|
||||
- LND UI: `docker/lnd-ui/`
|
||||
|
||||
### 4. Update backend
|
||||
|
||||
- Add port mapping in `core/archipelago/src/container/docker_packages.rs`
|
||||
- Add env vars in `get_app_config()` in `core/archipelago/src/api/rpc.rs`
|
||||
|
||||
### 5. Deploy and test
|
||||
|
||||
- Deploy: `./scripts/deploy-to-target.sh --live`
|
||||
- Install from marketplace UI at http://192.168.1.228
|
||||
- Verify it launches and auto-connects to dependencies
|
||||
- Check logs: `sudo podman logs {container-name}`
|
||||
|
||||
### 6. Security review
|
||||
|
||||
- Verify readonly root, dropped caps, non-root user
|
||||
- Check network isolation
|
||||
- No hardcoded secrets
|
||||
@@ -1,87 +0,0 @@
|
||||
---
|
||||
name: build-iso
|
||||
description: Build a new Archipelago auto-installer ISO image (bundled or unbundled)
|
||||
disable-model-invocation: true
|
||||
allowed-tools: Bash, Read
|
||||
---
|
||||
|
||||
Build a new Archipelago auto-installer ISO.
|
||||
|
||||
## Pre-build checklist
|
||||
|
||||
1. Latest code deployed to server (`/deploy` first)
|
||||
2. System configs synced (`/sync-configs` first)
|
||||
3. Everything tested and working on live server
|
||||
4. Sync build scripts to server before building:
|
||||
```bash
|
||||
rsync -avz -e "ssh -i ~/.ssh/archipelago-deploy" \
|
||||
/Users/dorian/Projects/archy/image-recipe/build-auto-installer-iso.sh \
|
||||
/Users/dorian/Projects/archy/image-recipe/build-unbundled-iso.sh \
|
||||
archipelago@192.168.1.228:~/archy/image-recipe/
|
||||
```
|
||||
|
||||
## Build variants
|
||||
|
||||
### Unbundled ISO (recommended for distribution — ~3GB)
|
||||
No pre-bundled container images. Apps install on-demand from Marketplace (requires internet).
|
||||
|
||||
```bash
|
||||
ssh -i ~/.ssh/archipelago-deploy archipelago@192.168.1.228 \
|
||||
'cd ~/archy/image-recipe && sudo ./build-unbundled-iso.sh'
|
||||
```
|
||||
|
||||
Output: `results/archipelago-installer-unbundled-x86_64.iso`
|
||||
|
||||
### Full bundled ISO (~11GB)
|
||||
All container images pre-bundled for offline install.
|
||||
|
||||
```bash
|
||||
ssh -i ~/.ssh/archipelago-deploy archipelago@192.168.1.228 \
|
||||
'cd ~/archy/image-recipe && sudo ./build-auto-installer-iso.sh'
|
||||
```
|
||||
|
||||
Output: `results/archipelago-installer-x86_64.iso`
|
||||
|
||||
## Post-build: ALWAYS publish to FileBrowser
|
||||
|
||||
After EVERY successful build, copy the ISO to the FileBrowser `Builds` folder so it's downloadable from the web UI. This is mandatory — do not skip.
|
||||
|
||||
**FileBrowser data root**: `/var/lib/archipelago/filebrowser/`
|
||||
|
||||
```bash
|
||||
# For unbundled:
|
||||
ssh -i ~/.ssh/archipelago-deploy archipelago@192.168.1.228 \
|
||||
'sudo mkdir -p /var/lib/archipelago/filebrowser/Builds && \
|
||||
sudo cp ~/archy/image-recipe/results/archipelago-installer-unbundled-x86_64.iso /var/lib/archipelago/filebrowser/Builds/ && \
|
||||
sudo chown 1000:1000 /var/lib/archipelago/filebrowser/Builds/archipelago-installer-unbundled-x86_64.iso'
|
||||
|
||||
# For bundled:
|
||||
ssh -i ~/.ssh/archipelago-deploy archipelago@192.168.1.228 \
|
||||
'sudo mkdir -p /var/lib/archipelago/filebrowser/Builds && \
|
||||
sudo cp ~/archy/image-recipe/results/archipelago-installer-x86_64.iso /var/lib/archipelago/filebrowser/Builds/ && \
|
||||
sudo chown 1000:1000 /var/lib/archipelago/filebrowser/Builds/archipelago-installer-x86_64.iso'
|
||||
```
|
||||
|
||||
## Post-build: Download to Mac (optional)
|
||||
|
||||
```bash
|
||||
# Unbundled:
|
||||
scp -i ~/.ssh/archipelago-deploy archipelago@192.168.1.228:~/archy/image-recipe/results/archipelago-installer-unbundled-x86_64.iso ~/Downloads/
|
||||
|
||||
# Bundled:
|
||||
scp -i ~/.ssh/archipelago-deploy archipelago@192.168.1.228:~/archy/image-recipe/results/archipelago-installer-x86_64.iso ~/Downloads/
|
||||
```
|
||||
|
||||
## Key paths on server
|
||||
|
||||
- Build scripts: `~/archy/image-recipe/build-auto-installer-iso.sh`, `build-unbundled-iso.sh`
|
||||
- Build output: `~/archy/image-recipe/results/`
|
||||
- Build cache (rootfs, base ISO): `~/archy/image-recipe/build/auto-installer/`
|
||||
- FileBrowser Builds: `/var/lib/archipelago/filebrowser/Builds/`
|
||||
|
||||
## Notes
|
||||
|
||||
- Use `--rebuild` flag to force rootfs rebuild (otherwise uses cached)
|
||||
- FileBrowser container mounts `/var/lib/archipelago/filebrowser` → `/srv`
|
||||
- Always `chown 1000:1000` files in FileBrowser so the app can serve them
|
||||
- **IMPORTANT**: Use `build-auto-installer-iso.sh` (or `build-unbundled-iso.sh`) only. The deprecated `build-debian-iso.sh` causes boot-to-prompt issues.
|
||||
@@ -1,14 +0,0 @@
|
||||
---
|
||||
name: check-server
|
||||
description: Quick health check of the live Archipelago server
|
||||
allowed-tools: Bash
|
||||
---
|
||||
|
||||
Quick health check of the live server. SSH into `archipelago@192.168.1.228` (password: `EwPDR8q45l0Upx@`) and run:
|
||||
|
||||
1. `systemctl is-active archipelago nginx` — are services running?
|
||||
2. `sudo podman ps --format '{{.Names}} {{.Status}}'` — what containers are up?
|
||||
3. `curl -s http://127.0.0.1:5678/health` — is the backend responding?
|
||||
4. `sudo journalctl -u archipelago -n 10 --no-pager` — any recent errors?
|
||||
|
||||
Report a brief one-paragraph status summary.
|
||||
@@ -1,23 +0,0 @@
|
||||
---
|
||||
name: deploy-both
|
||||
description: Deploy all changes to both Archipelago servers
|
||||
disable-model-invocation: true
|
||||
allowed-tools: Bash, Read
|
||||
---
|
||||
|
||||
Deploy all changes to BOTH servers (primary: 192.168.1.228, secondary: 192.168.1.198).
|
||||
|
||||
## Steps
|
||||
|
||||
1. Run:
|
||||
```bash
|
||||
./scripts/deploy-to-target.sh --both
|
||||
```
|
||||
|
||||
2. This builds on the primary server first, then copies built artifacts to the secondary.
|
||||
|
||||
3. Verify both servers respond:
|
||||
```bash
|
||||
ssh -i ~/.ssh/archipelago-deploy archipelago@192.168.1.228 'systemctl is-active archipelago'
|
||||
ssh -i ~/.ssh/archipelago-deploy archipelago@192.168.1.198 'systemctl is-active archipelago'
|
||||
```
|
||||
@@ -1,24 +0,0 @@
|
||||
---
|
||||
name: deploy
|
||||
description: Deploy all changes to the live Archipelago server
|
||||
disable-model-invocation: true
|
||||
allowed-tools: Bash, Read
|
||||
---
|
||||
|
||||
Deploy all changes to the live server (192.168.1.228).
|
||||
|
||||
## Steps
|
||||
|
||||
1. Run the deploy script from the project root:
|
||||
```bash
|
||||
./scripts/deploy-to-target.sh --live
|
||||
```
|
||||
|
||||
2. This syncs frontend and backend code, builds the Rust backend **on the server** (never locally on macOS), deploys frontend to `/opt/archipelago/web-ui/`, deploys backend binary to `/usr/local/bin/archipelago`, and restarts systemd + nginx.
|
||||
|
||||
3. After deploy completes, verify the server is healthy:
|
||||
```bash
|
||||
ssh -i ~/.ssh/archipelago-deploy archipelago@192.168.1.228 'systemctl is-active archipelago nginx && sudo journalctl -u archipelago -n 10 --no-pager'
|
||||
```
|
||||
|
||||
4. Report whether the deploy succeeded and if any errors appeared in the logs.
|
||||
@@ -1,21 +0,0 @@
|
||||
---
|
||||
name: diagnose
|
||||
description: Run a full diagnostic check on the Archipelago dev server
|
||||
allowed-tools: Bash
|
||||
---
|
||||
|
||||
SSH into the dev server and run a comprehensive diagnostic. Use `ssh -i ~/.ssh/archipelago-deploy archipelago@192.168.1.228` for all commands.
|
||||
|
||||
## Checks to run
|
||||
|
||||
1. **Services**: `systemctl is-active archipelago nginx`
|
||||
2. **Backend status**: `sudo systemctl status archipelago --no-pager`
|
||||
3. **Containers**: `sudo podman ps -a`
|
||||
4. **Backend logs** (last 50): `sudo journalctl -u archipelago -n 50 --no-pager`
|
||||
5. **Nginx errors**: `sudo tail -20 /var/log/nginx/error.log`
|
||||
6. **RPC test**: `curl -s -X POST http://127.0.0.1:5678/rpc/v1 -H "Content-Type: application/json" -d '{"jsonrpc":"2.0","id":1,"method":"echo","params":{}}'`
|
||||
7. **Tor hostname**: `sudo cat /var/lib/archipelago/tor/hidden_service_archipelago/hostname`
|
||||
8. **Disk space**: `df -h /`
|
||||
9. **Memory**: `free -h`
|
||||
|
||||
Report findings clearly and suggest fixes for any issues found. If $ARGUMENTS is provided, focus the diagnosis on that specific area.
|
||||
@@ -1,20 +0,0 @@
|
||||
---
|
||||
name: frontend-dev
|
||||
description: Start the local frontend development environment for Archipelago
|
||||
disable-model-invocation: true
|
||||
allowed-tools: Bash
|
||||
---
|
||||
|
||||
Start the local frontend development environment.
|
||||
|
||||
```bash
|
||||
cd neode-ui && npm start
|
||||
```
|
||||
|
||||
This starts:
|
||||
- **Mock backend** on port 5959 (simulates the Rust backend API)
|
||||
- **Vite dev server** on port 8100
|
||||
|
||||
Access at http://localhost:8100 (password: `password123`)
|
||||
|
||||
The mock backend lets you develop the UI without needing the live server.
|
||||
@@ -1,49 +0,0 @@
|
||||
---
|
||||
name: harden
|
||||
description: Security hardening review and fixes for Archipelago code and infrastructure
|
||||
disable-model-invocation: true
|
||||
allowed-tools: Read, Edit, Write, Glob, Grep, Bash
|
||||
argument-hint: "[area: backend|frontend|containers|scripts|all]"
|
||||
---
|
||||
|
||||
Perform a security hardening pass on $ARGUMENTS (default: all).
|
||||
|
||||
## Backend Hardening (Rust)
|
||||
|
||||
- [ ] No hardcoded credentials — check for Base64-encoded auth strings, passwords in source
|
||||
- [ ] Secrets use `core/security/secrets_manager.rs` — verify encryption is implemented (not plaintext)
|
||||
- [ ] All RPC endpoints validate inputs before processing
|
||||
- [ ] No `unwrap()` on user-supplied data — handle errors gracefully
|
||||
- [ ] Rate limiting on auth endpoints (login, password change)
|
||||
- [ ] Session tokens have proper expiry and rotation
|
||||
- [ ] File permissions: keys at 0o600, dirs at 0o700
|
||||
- [ ] Tracing never logs secrets, passwords, keys, or tokens
|
||||
|
||||
## Frontend Hardening (Vue/TypeScript)
|
||||
|
||||
- [ ] No secrets in source (API keys, passwords, tokens)
|
||||
- [ ] No `eval()` or `innerHTML` with untrusted content
|
||||
- [ ] XSS prevention — sanitize all user inputs
|
||||
- [ ] CSRF protection on state-changing requests
|
||||
- [ ] Credentials use `credentials: 'include'` not localStorage tokens
|
||||
- [ ] No sensitive data in console.log statements
|
||||
|
||||
## Container Hardening
|
||||
|
||||
- [ ] All manifests: `readonly_root: true` (unless documented exception)
|
||||
- [ ] All manifests: capabilities dropped, only required ones added
|
||||
- [ ] All manifests: non-root user (UID > 1000)
|
||||
- [ ] All manifests: `no-new-privileges: true`
|
||||
- [ ] All images pinned to specific versions (no `:latest`)
|
||||
- [ ] Network isolation — no `host` network unless required and documented
|
||||
- [ ] AppArmor profiles defined and enforced
|
||||
|
||||
## Script Hardening
|
||||
|
||||
- [ ] All scripts use `set -euo pipefail`
|
||||
- [ ] No hardcoded passwords (use deploy-config.sh or env vars)
|
||||
- [ ] SSH uses proper key-based auth where possible
|
||||
- [ ] No `chmod 777` or overly permissive permissions
|
||||
- [ ] Temp files use `mktemp` not predictable paths
|
||||
|
||||
Report all findings with file paths and line numbers. Fix issues directly where safe to do so. Flag anything that needs discussion.
|
||||
@@ -1,52 +0,0 @@
|
||||
---
|
||||
name: lint
|
||||
description: Run all linters and type checks for the Archipelago project
|
||||
allowed-tools: Bash, Read, Grep
|
||||
argument-hint: "[backend|frontend|all]"
|
||||
---
|
||||
|
||||
Run linters and type-checks for $ARGUMENTS (default: all).
|
||||
|
||||
## Frontend Linting
|
||||
|
||||
```bash
|
||||
cd neode-ui
|
||||
|
||||
# Type check
|
||||
npm run type-check 2>&1
|
||||
|
||||
# Check for any `any` types (should be zero)
|
||||
grep -rn ': any' src/ --include='*.ts' --include='*.vue' | grep -v node_modules | grep -v '.d.ts'
|
||||
|
||||
# Check for inline Tailwind violations (long class strings)
|
||||
grep -rn 'class="[^"]\{100,\}"' src/ --include='*.vue'
|
||||
|
||||
# Check for TODO/FIXME
|
||||
grep -rn 'TODO\|FIXME' src/ --include='*.ts' --include='*.vue'
|
||||
|
||||
# Check for console.log (should be cleaned before production)
|
||||
grep -rn 'console\.\(log\|warn\|error\)' src/ --include='*.ts' --include='*.vue' | wc -l
|
||||
```
|
||||
|
||||
## Backend Linting (on dev server)
|
||||
|
||||
```bash
|
||||
ssh -i ~/.ssh/archipelago-deploy archipelago@192.168.1.228 \
|
||||
'source ~/.cargo/env && cd ~/archy/core && cargo clippy --all-targets --all-features 2>&1 && cargo fmt --all -- --check 2>&1'
|
||||
```
|
||||
|
||||
## Script Linting
|
||||
|
||||
```bash
|
||||
# Check for scripts missing set -e
|
||||
for f in scripts/*.sh; do
|
||||
if ! head -5 "$f" | grep -q 'set -e'; then
|
||||
echo "MISSING set -e: $f"
|
||||
fi
|
||||
done
|
||||
|
||||
# Check for hardcoded IPs (should use variables)
|
||||
grep -rn '192\.168\.1\.' scripts/ --include='*.sh' | grep -v deploy-config
|
||||
```
|
||||
|
||||
Report all issues found with severity (critical/warning/info).
|
||||
@@ -1,151 +0,0 @@
|
||||
# Skill: Polish Backend Quality
|
||||
|
||||
Fix Rust backend quality issues: eliminate panics, add timeouts, implement connection pooling, fix clippy warnings. The backend must never crash in production.
|
||||
|
||||
## Priority 1: Eliminate Panics
|
||||
|
||||
### Find all unwrap/expect in production code
|
||||
```bash
|
||||
ssh archipelago@192.168.1.228 "cd ~/archy && grep -rn 'unwrap()\|\.expect(' core/archipelago/src/ core/container/src/ core/security/src/ core/performance/src/ --include='*.rs' | grep -v test | grep -v '#\[test\]' | grep -v '_test.rs'"
|
||||
```
|
||||
|
||||
### Fix patterns:
|
||||
|
||||
**Response builder unwraps** (handler.rs):
|
||||
```rust
|
||||
// BAD
|
||||
Response::builder().body(body).unwrap()
|
||||
|
||||
// GOOD
|
||||
Response::builder().body(body).map_err(|e| {
|
||||
tracing::error!("Failed to build response: {}", e);
|
||||
// Return a minimal 500 response
|
||||
})?
|
||||
```
|
||||
|
||||
**Socket address parsing** (main.rs):
|
||||
```rust
|
||||
// BAD
|
||||
addr.parse().expect("Invalid bind address")
|
||||
|
||||
// GOOD
|
||||
addr.parse().context("Invalid bind address")?
|
||||
```
|
||||
|
||||
**TOTP secret creation** (totp.rs):
|
||||
```rust
|
||||
// BAD
|
||||
TOTP::new(...).unwrap()
|
||||
|
||||
// GOOD
|
||||
TOTP::new(...).map_err(|e| anyhow::anyhow!("Failed to create TOTP: {}", e))?
|
||||
```
|
||||
|
||||
**Cosign URL parsing** (image_verifier.rs):
|
||||
```rust
|
||||
// BAD
|
||||
sig_url.strip_prefix("cosign://").unwrap()
|
||||
|
||||
// GOOD
|
||||
sig_url.strip_prefix("cosign://")
|
||||
.ok_or_else(|| anyhow::anyhow!("Invalid cosign URL format: {}", sig_url))?
|
||||
```
|
||||
|
||||
## Priority 2: Add Timeouts
|
||||
|
||||
Every external call must have an explicit timeout:
|
||||
|
||||
```rust
|
||||
// Container operations
|
||||
tokio::time::timeout(Duration::from_secs(30), podman_operation()).await
|
||||
.context("Container operation timed out after 30s")??;
|
||||
|
||||
// HTTP calls (Bitcoin RPC, LND proxy)
|
||||
let client = reqwest::Client::builder()
|
||||
.timeout(Duration::from_secs(10))
|
||||
.build()?;
|
||||
|
||||
// Nostr operations
|
||||
tokio::time::timeout(Duration::from_secs(15), nostr_publish()).await
|
||||
.context("Nostr publish timed out")?;
|
||||
```
|
||||
|
||||
## Priority 3: Connection Pooling
|
||||
|
||||
Store a reusable `reqwest::Client` in `RpcHandler`:
|
||||
```rust
|
||||
pub struct RpcHandler {
|
||||
// ... existing fields
|
||||
http_client: reqwest::Client,
|
||||
}
|
||||
|
||||
impl RpcHandler {
|
||||
pub fn new(...) -> Self {
|
||||
let http_client = reqwest::Client::builder()
|
||||
.timeout(Duration::from_secs(10))
|
||||
.pool_max_idle_per_host(5)
|
||||
.build()
|
||||
.expect("Failed to create HTTP client");
|
||||
// ...
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
Use `self.http_client` everywhere instead of creating new clients per request.
|
||||
|
||||
## Priority 4: Fix Clippy Warnings
|
||||
|
||||
Run on dev server:
|
||||
```bash
|
||||
ssh archipelago@192.168.1.228 "cd ~/archy && cargo clippy --all-targets --all-features 2>&1"
|
||||
```
|
||||
|
||||
Known warnings to fix:
|
||||
- `should_implement_trait`: Implement `FromStr` for `AppManifest`
|
||||
- `get_first` → `.first()`
|
||||
- `assign_op_pattern` → use `+=`
|
||||
- `wildcard_in_or_patterns` → remove redundant `_`
|
||||
- `redundant_field_names` → shorthand
|
||||
- `very_complex_type` → type alias
|
||||
- `if_else_collapse` → simplify
|
||||
|
||||
## Priority 5: Replace println with tracing
|
||||
|
||||
```bash
|
||||
ssh archipelago@192.168.1.228 "cd ~/archy && grep -rn 'println!\|eprintln!' core/ --include='*.rs' | grep -v test | grep -v target/"
|
||||
```
|
||||
|
||||
Replace:
|
||||
- `println!("...")` → `tracing::info!("...")`
|
||||
- `eprintln!("...")` → `tracing::warn!("...")`
|
||||
|
||||
## Priority 6: Remove Dead Code
|
||||
|
||||
- Remove `#[allow(dead_code)]` annotations, verify if types are actually used
|
||||
- Remove unused fields (e.g., `identity_dir` in NodeIdentity)
|
||||
- Remove unused methods (e.g., `verify()`, `did_key()` in NodeIdentity)
|
||||
|
||||
## Verification
|
||||
|
||||
```bash
|
||||
ssh archipelago@192.168.1.228 "cd ~/archy && cargo clippy --all-targets --all-features 2>&1 | grep -c 'warning'"
|
||||
# Should be 0
|
||||
|
||||
ssh archipelago@192.168.1.228 "cd ~/archy && grep -rn 'unwrap()\|\.expect(' core/archipelago/src/ --include='*.rs' | grep -v test | grep -v '_test.rs' | wc -l"
|
||||
# Should be 0 (or near-zero with justified exceptions)
|
||||
|
||||
ssh archipelago@192.168.1.228 "cd ~/archy && grep -rn 'println!\|eprintln!' core/ --include='*.rs' | grep -v test | grep -v target/ | wc -l"
|
||||
# Should be 0
|
||||
```
|
||||
|
||||
## Build & Deploy
|
||||
|
||||
All Rust changes MUST be built on the dev server, never macOS:
|
||||
```bash
|
||||
./scripts/deploy-to-target.sh --live
|
||||
```
|
||||
|
||||
After deploy, verify:
|
||||
```bash
|
||||
ssh -i ~/.ssh/archipelago-deploy archipelago@192.168.1.228 "systemctl status archipelago && curl -s http://localhost:5678/health"
|
||||
```
|
||||
@@ -1,176 +0,0 @@
|
||||
# Skill: Polish Deployment Pipeline
|
||||
|
||||
Harden deploy-to-target.sh with rollback capability, pre-deploy checks, post-deploy health verification, and deployment locking.
|
||||
|
||||
## 1. Pre-Deploy Checks
|
||||
|
||||
Add to the beginning of deploy-to-target.sh:
|
||||
|
||||
```bash
|
||||
pre_deploy_checks() {
|
||||
echo "Running pre-deploy checks..."
|
||||
|
||||
# SSH key exists
|
||||
if [ ! -f "$SSH_KEY" ]; then
|
||||
echo "ERROR: SSH key not found at $SSH_KEY"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# Target reachable
|
||||
ssh $SSH_OPTS "$TARGET_HOST" "echo ok" >/dev/null 2>&1 || {
|
||||
echo "ERROR: Cannot reach $TARGET_HOST"
|
||||
exit 1
|
||||
}
|
||||
|
||||
# Disk space (need 2GB free)
|
||||
local free_kb=$(ssh $SSH_OPTS "$TARGET_HOST" "df /home | tail -1 | awk '{print \$4}'")
|
||||
if [ "$free_kb" -lt 2097152 ]; then
|
||||
echo "ERROR: Need 2GB free disk space, have $(( free_kb / 1024 ))MB"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
echo "Pre-deploy checks passed"
|
||||
}
|
||||
```
|
||||
|
||||
## 2. Backup Before Deploy
|
||||
|
||||
Before overwriting binary or frontend:
|
||||
|
||||
```bash
|
||||
backup_current() {
|
||||
echo "Backing up current deployment..."
|
||||
ssh $SSH_OPTS "$TARGET_HOST" "
|
||||
# Backup binary
|
||||
if [ -f /usr/local/bin/archipelago ]; then
|
||||
sudo cp /usr/local/bin/archipelago /usr/local/bin/archipelago.backup
|
||||
fi
|
||||
# Backup frontend
|
||||
if [ -d /opt/archipelago/web-ui ]; then
|
||||
sudo cp -a /opt/archipelago/web-ui /opt/archipelago/web-ui.backup
|
||||
fi
|
||||
# Backup nginx config
|
||||
if [ -f /etc/nginx/sites-available/archipelago ]; then
|
||||
sudo cp /etc/nginx/sites-available/archipelago /etc/nginx/sites-available/archipelago.backup
|
||||
fi
|
||||
"
|
||||
echo "Backup complete"
|
||||
}
|
||||
```
|
||||
|
||||
## 3. Post-Deploy Health Check
|
||||
|
||||
After restarting services:
|
||||
|
||||
```bash
|
||||
health_check() {
|
||||
echo "Running post-deploy health check..."
|
||||
local max_attempts=15
|
||||
local attempt=0
|
||||
|
||||
while [ $attempt -lt $max_attempts ]; do
|
||||
attempt=$((attempt + 1))
|
||||
local status=$(ssh $SSH_OPTS "$TARGET_HOST" "curl -s -o /dev/null -w '%{http_code}' http://localhost:5678/health" 2>/dev/null)
|
||||
if [ "$status" = "200" ]; then
|
||||
echo "Health check passed (attempt $attempt)"
|
||||
return 0
|
||||
fi
|
||||
echo "Health check attempt $attempt/$max_attempts (status: $status)"
|
||||
sleep 2
|
||||
done
|
||||
|
||||
echo "ERROR: Health check failed after $max_attempts attempts"
|
||||
return 1
|
||||
}
|
||||
```
|
||||
|
||||
## 4. Rollback on Failure
|
||||
|
||||
If health check fails:
|
||||
|
||||
```bash
|
||||
rollback() {
|
||||
echo "ROLLING BACK deployment..."
|
||||
ssh $SSH_OPTS "$TARGET_HOST" "
|
||||
# Restore binary
|
||||
if [ -f /usr/local/bin/archipelago.backup ]; then
|
||||
sudo cp /usr/local/bin/archipelago.backup /usr/local/bin/archipelago
|
||||
fi
|
||||
# Restore frontend
|
||||
if [ -d /opt/archipelago/web-ui.backup ]; then
|
||||
sudo rm -rf /opt/archipelago/web-ui
|
||||
sudo mv /opt/archipelago/web-ui.backup /opt/archipelago/web-ui
|
||||
fi
|
||||
# Restore nginx
|
||||
if [ -f /etc/nginx/sites-available/archipelago.backup ]; then
|
||||
sudo cp /etc/nginx/sites-available/archipelago.backup /etc/nginx/sites-available/archipelago
|
||||
sudo nginx -t && sudo systemctl reload nginx
|
||||
fi
|
||||
# Restart with old binary
|
||||
sudo systemctl restart archipelago
|
||||
"
|
||||
echo "Rollback complete. Previous version restored."
|
||||
}
|
||||
```
|
||||
|
||||
## 5. Deployment Lock
|
||||
|
||||
Prevent concurrent deploys:
|
||||
|
||||
```bash
|
||||
LOCK_FILE="/tmp/archipelago-deploy.lock"
|
||||
|
||||
acquire_lock() {
|
||||
exec 9>"$LOCK_FILE"
|
||||
flock -n 9 || {
|
||||
echo "ERROR: Another deployment is in progress"
|
||||
exit 1
|
||||
}
|
||||
trap "flock -u 9; rm -f $LOCK_FILE" EXIT
|
||||
}
|
||||
```
|
||||
|
||||
## 6. Nginx Config Validation
|
||||
|
||||
Before reloading nginx:
|
||||
|
||||
```bash
|
||||
validate_nginx() {
|
||||
ssh $SSH_OPTS "$TARGET_HOST" "sudo nginx -t" 2>&1 || {
|
||||
echo "ERROR: Nginx config invalid. Restoring backup..."
|
||||
ssh $SSH_OPTS "$TARGET_HOST" "
|
||||
sudo cp /etc/nginx/sites-available/archipelago.backup /etc/nginx/sites-available/archipelago
|
||||
sudo nginx -t && sudo systemctl reload nginx
|
||||
"
|
||||
return 1
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## Integration
|
||||
|
||||
The deploy flow becomes:
|
||||
1. `acquire_lock`
|
||||
2. `pre_deploy_checks`
|
||||
3. `backup_current`
|
||||
4. Build + deploy (existing logic)
|
||||
5. `validate_nginx`
|
||||
6. Restart services
|
||||
7. `health_check || rollback`
|
||||
|
||||
## Verification
|
||||
|
||||
Test the rollback:
|
||||
1. Deploy a working version
|
||||
2. Intentionally break the binary (e.g., truncate it)
|
||||
3. Deploy the broken version
|
||||
4. Verify rollback triggers and previous version is restored
|
||||
5. Verify service is healthy after rollback
|
||||
|
||||
## Deploy
|
||||
|
||||
```bash
|
||||
./scripts/deploy-to-target.sh --live
|
||||
```
|
||||
|
||||
After modifying the deploy script itself, test with a known-good deploy first.
|
||||
@@ -1,82 +0,0 @@
|
||||
# Skill: Polish Error Handling
|
||||
|
||||
Fix silent error handling patterns across the entire codebase. Every async operation must have visible, actionable error feedback for the user.
|
||||
|
||||
## What to Fix
|
||||
|
||||
### Frontend (neode-ui/src/)
|
||||
|
||||
1. **Silent catch blocks**: Find and replace all `.catch(() => {})` patterns
|
||||
- Search: `grep -rn "catch.*=>.*{}" --include="*.vue" --include="*.ts" src/`
|
||||
- Replace with: proper error logging + user-visible feedback (toast, inline error, or modal)
|
||||
- Pattern:
|
||||
```typescript
|
||||
.catch((err) => {
|
||||
console.error('[ComponentName] operation failed:', err)
|
||||
errorMessage.value = formatError(err)
|
||||
})
|
||||
```
|
||||
|
||||
2. **Unhandled router.push**: Find `router.push(...).catch(() => {})`
|
||||
- Replace with: `router.push(...).catch(console.error)` minimum
|
||||
- Or handle NavigationDuplicated gracefully
|
||||
|
||||
3. **Silent try/catch**: Find `try { ... } catch { /* empty */ }`
|
||||
- Every catch block must either: log the error, show user feedback, or explicitly comment why it's safe to ignore
|
||||
|
||||
4. **Missing error states**: For each view, verify:
|
||||
- `ref<string | null>` error variable exists
|
||||
- Error is displayed in template (inline message, not just console)
|
||||
- Error clears on retry or navigation
|
||||
|
||||
### Backend (core/)
|
||||
|
||||
5. **Silent error swallowing**: Find `unwrap_or_default()` on serialization
|
||||
- Replace with proper error propagation or logging
|
||||
- Pattern: `.map_err(|e| anyhow::anyhow!("Serialization failed: {}", e))?`
|
||||
|
||||
6. **Error response consistency**: All RPC errors should use:
|
||||
- Consistent error codes (not random negative numbers)
|
||||
- Human-readable messages
|
||||
- Consistent JSON structure
|
||||
|
||||
## Verification
|
||||
|
||||
After fixes, run:
|
||||
```bash
|
||||
# Zero silent catches
|
||||
grep -rn "catch.*=>.*{}\|catch\s*{" neode-ui/src/ --include="*.vue" --include="*.ts" | grep -v node_modules | grep -v "console\|error\|log\|warn"
|
||||
|
||||
# Zero empty catch blocks
|
||||
grep -rn "catch.*{$" neode-ui/src/ --include="*.vue" --include="*.ts" -A1 | grep -P "^\d+-\s*\}"
|
||||
```
|
||||
|
||||
Both should return zero results.
|
||||
|
||||
## Error Display Pattern
|
||||
|
||||
Use this consistent pattern for user-facing errors:
|
||||
```typescript
|
||||
const errorMessage = ref<string | null>(null)
|
||||
|
||||
async function doAction() {
|
||||
errorMessage.value = null
|
||||
try {
|
||||
await rpcClient.someCall()
|
||||
} catch (err) {
|
||||
errorMessage.value = err instanceof Error ? err.message : 'Operation failed'
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
Template:
|
||||
```vue
|
||||
<p v-if="errorMessage" class="text-red-400 text-sm mt-2">{{ errorMessage }}</p>
|
||||
```
|
||||
|
||||
## Deploy After Fixes
|
||||
|
||||
Always deploy and verify on live server after making changes:
|
||||
```bash
|
||||
./scripts/deploy-to-target.sh --live
|
||||
```
|
||||
@@ -1,120 +0,0 @@
|
||||
# Skill: Polish Form Validation
|
||||
|
||||
Improve all form inputs to have real-time validation feedback, proper trimming, disabled states during submission, and consistent error messaging.
|
||||
|
||||
## Forms to Polish
|
||||
|
||||
### 1. Login.vue — Password Setup
|
||||
- Real-time validation as user types (debounced 300ms):
|
||||
- Length >= 8 chars (show checkmark/X)
|
||||
- Passwords match (show match indicator)
|
||||
- Trim input on submit
|
||||
- Disable submit button while `isSubmitting`
|
||||
- Clear error on new input
|
||||
|
||||
### 2. Login.vue — TOTP Verification
|
||||
- `inputmode="numeric"` + `pattern="[0-9]*"`
|
||||
- Auto-submit when 6 digits entered
|
||||
- Show session timeout countdown if applicable
|
||||
- Trim and strip non-numeric characters on paste
|
||||
|
||||
### 3. Settings.vue — Password Change
|
||||
- Real-time strength validation:
|
||||
- 12+ characters
|
||||
- Has uppercase, lowercase, digit, special char
|
||||
- New password matches confirmation
|
||||
- Show strength meter (weak/medium/strong)
|
||||
- Disable button during submission
|
||||
- Show spinner in button during async operation
|
||||
|
||||
### 4. Any other form inputs found across views
|
||||
|
||||
## Validation Pattern
|
||||
|
||||
```typescript
|
||||
const password = ref('')
|
||||
const confirmPassword = ref('')
|
||||
const isSubmitting = ref(false)
|
||||
|
||||
const passwordErrors = computed(() => {
|
||||
const errors: string[] = []
|
||||
if (password.value.length > 0 && password.value.length < 8)
|
||||
errors.push('Must be at least 8 characters')
|
||||
return errors
|
||||
})
|
||||
|
||||
const passwordsMatch = computed(() =>
|
||||
confirmPassword.value.length > 0 && password.value === confirmPassword.value
|
||||
)
|
||||
|
||||
async function submit() {
|
||||
if (isSubmitting.value) return
|
||||
isSubmitting.value = true
|
||||
try {
|
||||
await rpcClient.call(...)
|
||||
} catch (err) {
|
||||
errorMessage.value = formatError(err)
|
||||
} finally {
|
||||
isSubmitting.value = false
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## Template Pattern
|
||||
|
||||
```vue
|
||||
<input v-model="password" type="password" class="glass-input" />
|
||||
<ul v-if="passwordErrors.length" class="text-red-400 text-xs mt-1 space-y-0.5">
|
||||
<li v-for="err in passwordErrors" :key="err">{{ err }}</li>
|
||||
</ul>
|
||||
|
||||
<button
|
||||
class="glass-button"
|
||||
:disabled="isSubmitting || passwordErrors.length > 0"
|
||||
@click="submit"
|
||||
>
|
||||
<span v-if="isSubmitting">Saving...</span>
|
||||
<span v-else>Save</span>
|
||||
</button>
|
||||
```
|
||||
|
||||
## Input Trimming
|
||||
|
||||
All text inputs should be trimmed before submission:
|
||||
```typescript
|
||||
const trimmed = password.value.trim()
|
||||
```
|
||||
|
||||
## Error Message Consistency
|
||||
|
||||
Create or use a `formatError` utility:
|
||||
```typescript
|
||||
function formatError(err: unknown): string {
|
||||
if (err instanceof Error) {
|
||||
if (err.message.includes('fetch') || err.message.includes('network'))
|
||||
return 'Unable to reach server. Check your connection.'
|
||||
if (err.message.includes('401') || err.message.includes('unauthorized'))
|
||||
return 'Session expired. Please log in again.'
|
||||
return err.message
|
||||
}
|
||||
return 'Something went wrong. Please try again.'
|
||||
}
|
||||
```
|
||||
|
||||
## Verification
|
||||
|
||||
For each form:
|
||||
- [ ] Real-time validation shows feedback as user types
|
||||
- [ ] Submit button disabled during operation
|
||||
- [ ] Submit button disabled when validation fails
|
||||
- [ ] Inputs trimmed before submission
|
||||
- [ ] Error messages are user-friendly (no raw error strings)
|
||||
- [ ] Success feedback shown after completion
|
||||
|
||||
## Deploy After Fixes
|
||||
|
||||
```bash
|
||||
./scripts/deploy-to-target.sh --live
|
||||
```
|
||||
|
||||
Test each form with: valid input, invalid input, empty input, whitespace-only input, rapid double-click on submit.
|
||||
@@ -1,83 +0,0 @@
|
||||
# Skill: Polish Loading States
|
||||
|
||||
Add skeleton loaders, loading indicators, timeout warnings, and empty states to all async views. No view should ever show a blank screen.
|
||||
|
||||
## Skeleton Loader Component
|
||||
|
||||
Create or use a `SkeletonLoader.vue` component with the glassmorphism style:
|
||||
- Background: `bg-white/5` with shimmer animation
|
||||
- Rounded corners matching the card it replaces
|
||||
- Animate with CSS `@keyframes shimmer` (translate gradient left to right)
|
||||
- Must use global classes from style.css, not inline Tailwind
|
||||
|
||||
## Views to Fix
|
||||
|
||||
For EACH view in `neode-ui/src/views/`, verify these states exist:
|
||||
|
||||
### 1. Loading State
|
||||
- Show skeleton placeholders immediately on mount
|
||||
- Pattern:
|
||||
```vue
|
||||
<template>
|
||||
<div v-if="isLoading">
|
||||
<!-- Skeleton matching the layout -->
|
||||
</div>
|
||||
<div v-else>
|
||||
<!-- Real content -->
|
||||
</div>
|
||||
</template>
|
||||
```
|
||||
|
||||
### 2. Empty State
|
||||
- When data loads but is empty (zero items)
|
||||
- Show helpful message with CTA
|
||||
- Pattern:
|
||||
```vue
|
||||
<div v-if="!isLoading && items.length === 0" class="glass-card text-center py-12">
|
||||
<p class="text-white/60">No apps installed yet</p>
|
||||
<router-link to="/marketplace" class="glass-button mt-4">Browse Marketplace</router-link>
|
||||
</div>
|
||||
```
|
||||
|
||||
### 3. Timeout Warning
|
||||
- After 15 seconds of loading, show "Taking longer than expected..."
|
||||
- After 30 seconds, show troubleshooting options
|
||||
- Pattern:
|
||||
```typescript
|
||||
const loadingTooLong = ref(false)
|
||||
let timeout: ReturnType<typeof setTimeout>
|
||||
|
||||
onMounted(() => {
|
||||
timeout = setTimeout(() => { loadingTooLong.value = true }, 15000)
|
||||
})
|
||||
|
||||
watch(isLoading, (val) => { if (!val) clearTimeout(timeout) })
|
||||
```
|
||||
|
||||
## Priority Views (must have all 3 states)
|
||||
|
||||
1. **Apps.vue** — app grid skeleton, "No apps installed" empty state
|
||||
2. **AppDetails.vue** — detail card skeleton, loading indicator
|
||||
3. **Marketplace.vue** — app card grid skeleton, "Loading apps..." with timeout
|
||||
4. **Dashboard.vue** — metric card skeletons
|
||||
5. **Cloud.vue** — file list skeleton, "No files" empty state
|
||||
6. **Settings.vue** — settings section skeleton
|
||||
7. **Server.vue** — server info skeleton
|
||||
|
||||
## Verification
|
||||
|
||||
For each view, confirm:
|
||||
- [ ] `isLoading` ref exists and is set properly
|
||||
- [ ] Template has `v-if="isLoading"` skeleton section
|
||||
- [ ] Template has empty state for zero-data case
|
||||
- [ ] Loading timeout warning after 15s
|
||||
- [ ] Skeleton uses global classes, not inline Tailwind
|
||||
|
||||
## Deploy After Fixes
|
||||
|
||||
Always deploy and verify on live server:
|
||||
```bash
|
||||
./scripts/deploy-to-target.sh --live
|
||||
```
|
||||
|
||||
Test by throttling network in browser DevTools to observe loading states.
|
||||
@@ -1,157 +0,0 @@
|
||||
# Skill: Polish Security
|
||||
|
||||
Security hardening pass for systemd, nginx, secrets management, and rate limiting.
|
||||
|
||||
## 1. Systemd Service Hardening
|
||||
|
||||
File: `image-recipe/configs/archipelago.service`
|
||||
|
||||
Add these directives to the `[Service]` section:
|
||||
```ini
|
||||
PrivateTmp=yes
|
||||
NoNewPrivileges=true
|
||||
ProtectSystem=strict
|
||||
ProtectHome=yes
|
||||
ReadWritePaths=/var/lib/archipelago
|
||||
SystemCallFilter=@system-service
|
||||
SystemCallFilter=~@privileged @resources
|
||||
```
|
||||
|
||||
After editing, sync to server and verify:
|
||||
```bash
|
||||
ssh archipelago@192.168.1.228 "sudo systemd-analyze security archipelago"
|
||||
```
|
||||
|
||||
## 2. Nginx Security Headers
|
||||
|
||||
File: `image-recipe/configs/nginx-archipelago.conf`
|
||||
|
||||
### Add HSTS (HTTPS block only):
|
||||
```nginx
|
||||
add_header Strict-Transport-Security "max-age=31536000; includeSubDomains" always;
|
||||
```
|
||||
|
||||
### Fix CSP (remove unsafe-inline):
|
||||
Replace:
|
||||
```nginx
|
||||
add_header Content-Security-Policy "default-src 'self'; script-src 'self' 'unsafe-inline'; style-src 'self' 'unsafe-inline'; img-src 'self' data: blob:; connect-src 'self' ws: wss:; frame-src 'self' http://localhost:* http://192.168.*:*;" always;
|
||||
```
|
||||
|
||||
With CSP that uses nonces or hashes for inline scripts/styles. If inline scripts can't be removed yet, document which ones and plan their removal.
|
||||
|
||||
### Add rate limiting zones:
|
||||
```nginx
|
||||
# In http block:
|
||||
limit_req_zone $binary_remote_addr zone=api:10m rate=30r/s;
|
||||
limit_req_zone $binary_remote_addr zone=auth:10m rate=5r/m;
|
||||
|
||||
# On login/auth endpoints:
|
||||
limit_req zone=auth burst=3 nodelay;
|
||||
|
||||
# On API endpoints:
|
||||
limit_req zone=api burst=50 nodelay;
|
||||
```
|
||||
|
||||
### Custom log format (strip tokens):
|
||||
```nginx
|
||||
log_format no_tokens '$remote_addr - $remote_user [$time_local] "$request_method $uri $server_protocol" $status $body_bytes_sent "$http_referer"';
|
||||
access_log /var/log/nginx/archipelago_access.log no_tokens;
|
||||
```
|
||||
|
||||
## 3. Secrets Management
|
||||
|
||||
### Remove hardcoded RPC credentials from scripts
|
||||
File: `scripts/deploy-to-target.sh`
|
||||
|
||||
Replace:
|
||||
```bash
|
||||
-e CORE_RPC_USERNAME=archipelago -e CORE_RPC_PASSWORD=archipelago123
|
||||
```
|
||||
|
||||
With:
|
||||
```bash
|
||||
-e CORE_RPC_USERNAME=archipelago -e CORE_RPC_PASSWORD=$(cat /var/lib/archipelago/secrets/bitcoin-rpc-pass)
|
||||
```
|
||||
|
||||
### Generate secrets on first boot
|
||||
File: `scripts/first-boot-containers.sh`
|
||||
|
||||
Add at the top:
|
||||
```bash
|
||||
SECRETS_DIR="/var/lib/archipelago/secrets"
|
||||
mkdir -p "$SECRETS_DIR"
|
||||
chmod 700 "$SECRETS_DIR"
|
||||
|
||||
# Generate Bitcoin RPC password if not exists
|
||||
if [ ! -f "$SECRETS_DIR/bitcoin-rpc-pass" ]; then
|
||||
openssl rand -base64 24 > "$SECRETS_DIR/bitcoin-rpc-pass"
|
||||
chmod 600 "$SECRETS_DIR/bitcoin-rpc-pass"
|
||||
fi
|
||||
```
|
||||
|
||||
### Remove hardcoded credentials from Rust backend
|
||||
File: `core/archipelago/src/api/rpc/bitcoin.rs`
|
||||
|
||||
Replace:
|
||||
```rust
|
||||
.basic_auth("archipelago", Some("archipelago123"))
|
||||
```
|
||||
|
||||
With:
|
||||
```rust
|
||||
let rpc_user = std::env::var("ARCHIPELAGO_BITCOIN_RPC_USER").unwrap_or_else(|_| "archipelago".into());
|
||||
let rpc_pass = std::env::var("ARCHIPELAGO_BITCOIN_RPC_PASS").unwrap_or_else(|_| "archipelago123".into());
|
||||
.basic_auth(&rpc_user, Some(&rpc_pass))
|
||||
```
|
||||
|
||||
## 4. Rate Limiting on Backend
|
||||
|
||||
File: `core/archipelago/src/api/handler.rs`
|
||||
|
||||
Add rate limiting to unauthenticated endpoints:
|
||||
- `/archipelago/node-message` — 10 req/min per IP
|
||||
- `/electrs-status` — 30 req/min per IP
|
||||
|
||||
Use an in-memory `HashMap<IpAddr, (Instant, u32)>` with cleanup on access.
|
||||
|
||||
## 5. SSH Hardening
|
||||
|
||||
File: `scripts/deploy-to-target.sh`
|
||||
|
||||
Replace:
|
||||
```bash
|
||||
SSH_OPTS="-o StrictHostKeyChecking=no"
|
||||
```
|
||||
|
||||
With:
|
||||
```bash
|
||||
SSH_OPTS="-o StrictHostKeyChecking=accept-new"
|
||||
```
|
||||
|
||||
And add SSH key validation:
|
||||
```bash
|
||||
if [ ! -f "$SSH_KEY" ]; then
|
||||
echo "ERROR: SSH key not found at $SSH_KEY"
|
||||
exit 1
|
||||
fi
|
||||
```
|
||||
|
||||
## Verification Checklist
|
||||
|
||||
- [ ] `systemd-analyze security archipelago` score < 5.0 (lower is better)
|
||||
- [ ] Nginx headers pass: `curl -I http://192.168.1.228 | grep -i 'strict-transport\|content-security\|x-frame'`
|
||||
- [ ] No hardcoded passwords in scripts: `grep -rn 'archipelago123' scripts/ core/`
|
||||
- [ ] Rate limiting works: rapid-fire requests get 429
|
||||
- [ ] SSH key required (no password fallback)
|
||||
|
||||
## Deploy
|
||||
|
||||
After changes, sync configs and deploy:
|
||||
```bash
|
||||
./scripts/deploy-to-target.sh --live
|
||||
```
|
||||
|
||||
Then sync to ISO recipe:
|
||||
```bash
|
||||
# Run /sync-configs skill
|
||||
```
|
||||
@@ -1,167 +0,0 @@
|
||||
# Skill: Polish WebSocket & Real-Time
|
||||
|
||||
Improve WebSocket reliability, reconnection UX, heartbeat, session timeout detection, and connection status indicators.
|
||||
|
||||
## 1. Connection Status Indicator
|
||||
|
||||
### Create or update connection status display
|
||||
- **Location**: App.vue header or create ConnectionStatus.vue component
|
||||
- **States**: Connected (green), Reconnecting (amber pulse), Disconnected (red)
|
||||
- **Data source**: `wsClient.isConnected()` from websocket.ts
|
||||
- **Style**: Use existing design tokens, small dot + text in header area
|
||||
|
||||
```vue
|
||||
<div class="flex items-center gap-1.5">
|
||||
<div :class="[
|
||||
'w-2 h-2 rounded-full',
|
||||
isConnected ? 'bg-green-400' : isReconnecting ? 'bg-amber-400 animate-pulse' : 'bg-red-400'
|
||||
]" />
|
||||
<span class="text-xs text-white/40">
|
||||
{{ isConnected ? '' : isReconnecting ? 'Reconnecting...' : 'Offline' }}
|
||||
</span>
|
||||
</div>
|
||||
```
|
||||
|
||||
### Fix OnlineStatusPill.vue
|
||||
- Connect to actual WebSocket state instead of hardcoded "Online"
|
||||
- Use the app store's connection state
|
||||
|
||||
## 2. Reconnection UX
|
||||
|
||||
### Don't silently give up
|
||||
File: `api/websocket.ts`
|
||||
|
||||
After max reconnect attempts (currently 10), instead of silently stopping:
|
||||
- Set a `permanentlyDisconnected` flag
|
||||
- Emit event that App.vue listens to
|
||||
- Show persistent banner: "Connection lost. Click to retry." or "Refresh page to reconnect."
|
||||
|
||||
```typescript
|
||||
if (this.reconnectAttempts >= this.maxReconnectAttempts) {
|
||||
this.shouldReconnect = false
|
||||
this.notifyConnectionState(false)
|
||||
// Emit permanent disconnect event
|
||||
this.onPermanentDisconnect?.()
|
||||
}
|
||||
```
|
||||
|
||||
### Allow manual reconnect
|
||||
Add a `forceReconnect()` method that resets attempt counter and tries again:
|
||||
```typescript
|
||||
forceReconnect() {
|
||||
this.reconnectAttempts = 0
|
||||
this.shouldReconnect = true
|
||||
this.connect()
|
||||
}
|
||||
```
|
||||
|
||||
## 3. Heartbeat Improvement
|
||||
|
||||
File: `api/websocket.ts`
|
||||
|
||||
Current: 60-second stale detection (passive).
|
||||
Target: 30-second active ping with 5-second pong timeout.
|
||||
|
||||
```typescript
|
||||
private startHeartbeat() {
|
||||
this.heartbeatInterval = setInterval(() => {
|
||||
if (this.ws?.readyState === WebSocket.OPEN) {
|
||||
this.ws.send(JSON.stringify({ type: 'ping' }))
|
||||
this.pongTimeout = setTimeout(() => {
|
||||
// No pong received — connection is dead
|
||||
this.ws?.close()
|
||||
this.handleReconnect()
|
||||
}, 5000)
|
||||
}
|
||||
}, 30000)
|
||||
}
|
||||
|
||||
// In message handler:
|
||||
if (data.type === 'pong') {
|
||||
clearTimeout(this.pongTimeout)
|
||||
return
|
||||
}
|
||||
```
|
||||
|
||||
Note: Backend must respond to `ping` with `pong`. Check handler.rs WebSocket handler.
|
||||
|
||||
## 4. Session Timeout Detection
|
||||
|
||||
File: `api/rpc-client.ts`
|
||||
|
||||
When RPC returns 401 or 403:
|
||||
```typescript
|
||||
if (response.status === 401 || response.status === 403) {
|
||||
// Session expired — redirect to login
|
||||
window.location.href = '/login'
|
||||
return
|
||||
}
|
||||
```
|
||||
|
||||
This should be in the base `call()` method so it applies to all RPC calls.
|
||||
|
||||
## 5. Fix Race Condition on Reconnect
|
||||
|
||||
File: `stores/app.ts` or `api/websocket.ts`
|
||||
|
||||
Problem: `isWsSubscribed` flag doesn't prevent duplicate listeners on rapid reconnect.
|
||||
|
||||
Fix: Use listener deduplication:
|
||||
```typescript
|
||||
private listeners = new Map<string, Set<Function>>()
|
||||
|
||||
subscribe(event: string, callback: Function) {
|
||||
if (!this.listeners.has(event)) {
|
||||
this.listeners.set(event, new Set())
|
||||
}
|
||||
this.listeners.get(event)!.add(callback)
|
||||
}
|
||||
```
|
||||
|
||||
Or simpler: remove all listeners before reconnect, then re-add:
|
||||
```typescript
|
||||
onReconnect() {
|
||||
// Clear old subscriptions
|
||||
this.removeAllListeners()
|
||||
// Re-subscribe
|
||||
this.setupSubscriptions()
|
||||
}
|
||||
```
|
||||
|
||||
## 6. Message Queuing During Disconnect
|
||||
|
||||
When WebSocket is down, queue subscription requests:
|
||||
```typescript
|
||||
private pendingSubscriptions: Array<() => void> = []
|
||||
|
||||
subscribe(event: string, callback: Function) {
|
||||
if (this.ws?.readyState !== WebSocket.OPEN) {
|
||||
this.pendingSubscriptions.push(() => this.subscribe(event, callback))
|
||||
return
|
||||
}
|
||||
// Normal subscribe logic
|
||||
}
|
||||
|
||||
onReconnected() {
|
||||
// Replay pending subscriptions
|
||||
const pending = [...this.pendingSubscriptions]
|
||||
this.pendingSubscriptions = []
|
||||
pending.forEach(fn => fn())
|
||||
}
|
||||
```
|
||||
|
||||
## Verification
|
||||
|
||||
1. **Kill backend** → frontend shows "Disconnected" → restart backend → frontend reconnects and shows "Connected"
|
||||
2. **Toggle wifi** → status indicator updates → wifi back → auto-reconnect
|
||||
3. **Wait for session timeout** → next RPC call redirects to login
|
||||
4. **Rapid reconnect** → no duplicate event handlers (check with DevTools)
|
||||
5. **Leave tab in background** → come back → status is accurate
|
||||
|
||||
## Deploy
|
||||
|
||||
```bash
|
||||
./scripts/deploy-to-target.sh --live
|
||||
```
|
||||
|
||||
Test with browser DevTools Network tab to observe WebSocket frames.
|
||||
@@ -1,104 +0,0 @@
|
||||
# Skill: Production Polish (Overnight Orchestrator)
|
||||
|
||||
Main entry point for the Archipelago production polish plan. Reads `plan.md` at the project root, determines the current week based on today's date, and executes the tasks for that week.
|
||||
|
||||
## How It Works
|
||||
|
||||
1. Read `plan.md` from the project root
|
||||
2. Determine the current week from the schedule:
|
||||
- Week 1: March 10–16 — Silent Failures & Error Handling
|
||||
- Week 2: March 17–23 — Loading States & Visual Feedback
|
||||
- Week 3: March 24–30 — Form Validation & Input Quality
|
||||
- Week 4: March 31 – April 6 — Backend Robustness
|
||||
- Week 5: April 7–13 — WebSocket & Real-Time Quality
|
||||
- Week 6: April 14–20 — Deployment & Infrastructure Hardening
|
||||
- Week 7: April 21–27 — Accessibility, Polish & Edge Cases
|
||||
- Week 8: April 28 – May 4 — Integration Testing, Final Sweep & ISO
|
||||
3. Execute tasks for the current week, in order
|
||||
4. After completing tasks, run `/sweep` to verify
|
||||
5. Deploy and verify with `/deploy` then `/check-server`
|
||||
|
||||
## Execution Flow
|
||||
|
||||
### Step 1: Read the plan
|
||||
```
|
||||
Read plan.md and find the current week's section
|
||||
```
|
||||
|
||||
### Step 2: Check what's already done
|
||||
Run the verification checks for the current week's tasks. For example in Week 1:
|
||||
- Count remaining `.catch(() => {})` patterns
|
||||
- Count remaining `console.log` outside dev guards
|
||||
- Count remaining `unwrap()` in backend production code
|
||||
- Check if hardcoded credentials still exist
|
||||
|
||||
### Step 3: Work on the next incomplete task
|
||||
Pick the first task in the current week that still has violations (hasn't met its acceptance criteria). Fix violations one file at a time:
|
||||
1. Read the file
|
||||
2. Apply the fix following the pattern described in the task
|
||||
3. Verify the fix compiles/type-checks
|
||||
4. Move to the next violation
|
||||
|
||||
### Step 4: Verify after each batch of fixes
|
||||
After fixing all violations for a task:
|
||||
- Frontend: `cd neode-ui && npx vue-tsc --noEmit`
|
||||
- Backend: `ssh archipelago@192.168.1.228 "cd ~/archy && cargo check"`
|
||||
- Run the task's specific acceptance grep/check
|
||||
|
||||
### Step 5: Deploy when a task is complete
|
||||
When all violations for a task are fixed and verified:
|
||||
```bash
|
||||
./scripts/deploy-to-target.sh --live
|
||||
```
|
||||
Then verify:
|
||||
```bash
|
||||
ssh -i ~/.ssh/archipelago-deploy archipelago@192.168.1.228 "systemctl is-active archipelago && curl -s http://localhost:5678/health"
|
||||
```
|
||||
|
||||
### Step 6: Move to the next task
|
||||
Repeat Steps 3-5 for the next incomplete task in the current week.
|
||||
|
||||
### Step 7: When all tasks are done
|
||||
Run `/sweep` for a full quality report. If clean, the week is complete.
|
||||
|
||||
## Rules
|
||||
|
||||
- **Never change functionality** — only improve quality of existing code
|
||||
- **Never change the design** — use existing glassmorphism classes, color tokens, and layout patterns
|
||||
- **Always deploy after changes** — don't leave undeployed code
|
||||
- **Always verify after deploy** — check server health
|
||||
- **Build Rust on the dev server** — never compile Rust on macOS
|
||||
- **Commit after each completed task** — atomic commits with `fix:` or `refactor:` prefix
|
||||
- **If something breaks, revert** — don't push forward with broken code
|
||||
|
||||
## Arguments
|
||||
|
||||
If `$ARGUMENTS` is provided:
|
||||
- `week N` — Force execution of week N regardless of date
|
||||
- `task N.M` — Execute only task N.M (e.g., `task 1.3`)
|
||||
- `status` — Show completion status for all weeks without executing
|
||||
- `sweep` — Run sweep only, no fixes
|
||||
|
||||
## Example Usage
|
||||
|
||||
```
|
||||
/polish # Auto-detect week, work on next incomplete task
|
||||
/polish week 1 # Force Week 1 tasks
|
||||
/polish task 1.3 # Work on just task 1.3
|
||||
/polish status # Show what's done and what's left
|
||||
/polish sweep # Just run the quality sweep
|
||||
```
|
||||
|
||||
## For Overnight TUI
|
||||
|
||||
Launch with:
|
||||
```
|
||||
/loop 30m /polish
|
||||
```
|
||||
|
||||
Each 30-minute cycle:
|
||||
1. Checks current week
|
||||
2. Finds next incomplete task
|
||||
3. Fixes as many violations as possible in the time available
|
||||
4. Deploys and verifies
|
||||
5. Reports progress
|
||||
@@ -1,41 +0,0 @@
|
||||
---
|
||||
name: refactor
|
||||
description: Refactor code for quality, maintainability, and adherence to project standards
|
||||
disable-model-invocation: true
|
||||
allowed-tools: Read, Edit, Write, Glob, Grep, Bash
|
||||
argument-hint: "[file-or-area]"
|
||||
---
|
||||
|
||||
Refactor the specified code ($ARGUMENTS) following Archipelago coding standards.
|
||||
|
||||
## Checklist
|
||||
|
||||
### Rust Backend
|
||||
- [ ] No `unwrap()` or `expect()` — use `?` operator with context
|
||||
- [ ] Replace `#[allow(dead_code)]` — either use it or remove it
|
||||
- [ ] Functions under 50 lines, single responsibility
|
||||
- [ ] Custom error types per module with `thiserror`
|
||||
- [ ] `tracing` for logging — no `println!` or secrets in logs
|
||||
- [ ] Split files over 500 lines into focused modules
|
||||
- [ ] Run `cargo clippy --all-targets --all-features` mentally and fix issues
|
||||
|
||||
### Vue Frontend
|
||||
- [ ] Extract ALL inline Tailwind to global classes in `neode-ui/src/style.css`
|
||||
- [ ] Use semantic class names: `.glass-card`, `.info-card`, `.glass-button`, `.path-option-card`
|
||||
- [ ] Replace ALL `.gradient-button` with `.glass-button` (gradient buttons are BANNED)
|
||||
- [ ] Replace ALL `.gradient-card` / `.gradient-card-dark` with `.glass-card` or `.path-option-card`
|
||||
- [ ] Settings.vue is the gold standard — all screens should match its patterns
|
||||
- [ ] Replace `any` types with proper interfaces or `unknown`
|
||||
- [ ] Ensure `<script setup lang="ts">` on all components
|
||||
- [ ] Remove dead code (unused imports, components like HelloWorld.vue)
|
||||
- [ ] Remove all `TODO`/`FIXME` — fix now or create GitHub issues
|
||||
- [ ] Consolidate `console.log` calls to use a logging utility
|
||||
- [ ] Split views over 800 LOC into sub-components
|
||||
|
||||
### General
|
||||
- [ ] No hardcoded paths (`/Users/dorian/...`)
|
||||
- [ ] No hardcoded credentials — use env vars or secrets manager
|
||||
- [ ] Comment WHY not WHAT
|
||||
- [ ] Remove commented-out code entirely
|
||||
|
||||
After refactoring, verify the code still compiles/type-checks. For frontend: `cd neode-ui && npm run type-check`. Do NOT deploy — leave that to `/deploy`.
|
||||
@@ -1,19 +0,0 @@
|
||||
---
|
||||
name: server-logs
|
||||
description: View live server logs from the Archipelago dev server
|
||||
allowed-tools: Bash
|
||||
argument-hint: "[backend|nginx|container-name]"
|
||||
---
|
||||
|
||||
View logs from the Archipelago server (192.168.1.228). Use `ssh -i ~/.ssh/archipelago-deploy archipelago@192.168.1.228` for all commands.
|
||||
|
||||
If $ARGUMENTS is provided, show logs for that specific service. Otherwise, show backend logs by default.
|
||||
|
||||
## Log sources
|
||||
|
||||
- **backend** (default): `sudo journalctl -u archipelago -n 50 --no-pager`
|
||||
- **nginx**: `sudo tail -50 /var/log/nginx/error.log`
|
||||
- **nginx-access**: `sudo tail -50 /var/log/nginx/access.log`
|
||||
- **Any container name**: `sudo podman logs --tail 50 $ARGUMENTS`
|
||||
|
||||
Show the last 50 lines. If the user needs live streaming, use `-f` flag instead of `--tail`/`-n`.
|
||||
@@ -1,105 +0,0 @@
|
||||
# Skill: Quality Sweep
|
||||
|
||||
Full automated quality sweep across the entire codebase. Detects regressions, violations, and quality issues. This is the overnight watchdog.
|
||||
|
||||
Run all checks below sequentially. For each check, use the Grep tool (not bash grep) for local file scanning, and Bash for remote/build commands. Report a summary at the end.
|
||||
|
||||
## Checks
|
||||
|
||||
### 1. TypeScript Type Check
|
||||
Run in bash:
|
||||
```bash
|
||||
cd /Users/dorian/Projects/archy/neode-ui && npx vue-tsc --noEmit 2>&1 | tail -20
|
||||
```
|
||||
PASS = zero errors. Count any errors found.
|
||||
|
||||
### 2. Frontend Violations
|
||||
Use the Grep tool to scan `neode-ui/src/` for each pattern. Count matches for each:
|
||||
|
||||
**Silent catch blocks** — pattern: `catch\s*\(\s*\)\s*=>?\s*\{\s*\}` or `\.catch\(\(\)\s*=>\s*\{\}` in `*.vue` and `*.ts` files
|
||||
|
||||
**console.log in prod** — pattern: `console\.(log|warn|error)` in `*.vue` and `*.ts` files. Exclude lines containing `import.meta.env.DEV` or `// dev-only`
|
||||
|
||||
**any type usage** — pattern: `:\s*any[^a-zA-Z]|as\s+any[^a-zA-Z]` in `*.vue` and `*.ts` files. Exclude `.d.ts` files
|
||||
|
||||
**TODO/FIXME/HACK** — pattern: `TODO|FIXME|HACK|XXX` in `*.vue` and `*.ts` files
|
||||
|
||||
**Banned CSS classes** — pattern: `gradient-button|gradient-card` in `*.vue` files
|
||||
|
||||
### 3. Backend Violations (via SSH)
|
||||
Run in bash:
|
||||
```bash
|
||||
ssh -i ~/.ssh/archipelago-deploy archipelago@192.168.1.228 "
|
||||
echo '--- unwrap/expect ---'
|
||||
grep -rn 'unwrap()\|\.expect(' ~/archy/core/archipelago/src/ ~/archy/core/container/src/ ~/archy/core/security/src/ --include='*.rs' | grep -v test | grep -v '_test.rs' | grep -v target/ | wc -l
|
||||
|
||||
echo '--- println/eprintln ---'
|
||||
grep -rn 'println!\|eprintln!' ~/archy/core/ --include='*.rs' | grep -v test | grep -v target/ | wc -l
|
||||
|
||||
echo '--- TODO/FIXME ---'
|
||||
grep -rn 'TODO\|FIXME\|HACK' ~/archy/core/ --include='*.rs' | grep -v target/ | wc -l
|
||||
"
|
||||
```
|
||||
|
||||
### 4. Hardcoded Credentials
|
||||
Use Grep tool locally — pattern: `archipelago123|password123` in `core/` and `scripts/` directories, excluding `target/`, `node_modules/`, and `deploy-config.sh`
|
||||
|
||||
### 5. Server Health
|
||||
Run in bash:
|
||||
```bash
|
||||
ssh -i ~/.ssh/archipelago-deploy archipelago@192.168.1.228 "
|
||||
echo 'service:' \$(systemctl is-active archipelago)
|
||||
echo 'health:' \$(curl -s -o /dev/null -w '%{http_code}' http://localhost:5678/health)
|
||||
echo 'containers:' \$(podman ps -q 2>/dev/null | wc -l || docker ps -q | wc -l)
|
||||
echo 'errors:' \$(journalctl -u archipelago --since '1 hour ago' --no-pager -p err 2>/dev/null | wc -l)
|
||||
echo 'disk:' \$(df -h / | tail -1 | awk '{print \$5}')
|
||||
"
|
||||
```
|
||||
|
||||
### 6. Frontend Build
|
||||
Run in bash:
|
||||
```bash
|
||||
cd /Users/dorian/Projects/archy/neode-ui && npm run build 2>&1 | tail -5
|
||||
```
|
||||
PASS = exit code 0.
|
||||
|
||||
## Report Format
|
||||
|
||||
After all checks, output a summary exactly like this:
|
||||
|
||||
```
|
||||
=== SWEEP REPORT ===
|
||||
|
||||
TypeScript: PASS/FAIL (N errors)
|
||||
Silent catches: PASS/FAIL (N)
|
||||
Console.log: PASS/FAIL (N)
|
||||
Any types: PASS/FAIL (N)
|
||||
TODOs: PASS/FAIL (N)
|
||||
Banned classes: PASS/FAIL (N)
|
||||
Backend unwrap: PASS/FAIL (N)
|
||||
Backend println: PASS/FAIL (N)
|
||||
Hardcoded creds: PASS/FAIL (N)
|
||||
Server health: PASS/FAIL
|
||||
Frontend build: PASS/FAIL
|
||||
|
||||
Total violations: N
|
||||
```
|
||||
|
||||
PASS = zero violations for that check. FAIL = one or more.
|
||||
|
||||
## Auto-Fix Rules
|
||||
|
||||
Safe to auto-fix without asking:
|
||||
- `cargo fmt --all` on dev server (formatting only)
|
||||
- Trailing whitespace removal
|
||||
- Import ordering
|
||||
|
||||
Do NOT auto-fix (flag for review):
|
||||
- Error handling changes
|
||||
- Logic or behavior changes
|
||||
- Anything in core/ Rust files beyond formatting
|
||||
|
||||
## Reference
|
||||
|
||||
Full plan with weekly task breakdown: `plan.md` (project root)
|
||||
Current week's focus determines which violations are highest priority.
|
||||
@@ -1,24 +0,0 @@
|
||||
---
|
||||
name: sync-configs
|
||||
description: Sync system configs from live server to repo for ISO builds
|
||||
disable-model-invocation: true
|
||||
allowed-tools: Bash, Read
|
||||
---
|
||||
|
||||
Sync system configuration files from the live server back to the repo for ISO builds.
|
||||
|
||||
## Steps
|
||||
|
||||
1. **Capture systemd service**:
|
||||
```bash
|
||||
ssh -i ~/.ssh/archipelago-deploy archipelago@192.168.1.228 'sudo cat /etc/systemd/system/archipelago.service' > image-recipe/configs/archipelago.service
|
||||
```
|
||||
|
||||
2. **Capture nginx config**:
|
||||
```bash
|
||||
ssh -i ~/.ssh/archipelago-deploy archipelago@192.168.1.228 'sudo cat /etc/nginx/sites-available/archipelago' > image-recipe/configs/nginx-archipelago.conf
|
||||
```
|
||||
|
||||
3. **Capture any custom scripts** in `/opt/archipelago/scripts/` if they've changed.
|
||||
|
||||
4. After syncing, read the captured files and verify they look correct. These configs are used by the ISO build to create new installations.
|
||||
@@ -1,59 +0,0 @@
|
||||
---
|
||||
name: test
|
||||
description: Run tests or create test coverage for Archipelago
|
||||
disable-model-invocation: true
|
||||
allowed-tools: Read, Edit, Write, Glob, Grep, Bash
|
||||
argument-hint: "[area: backend|frontend|all] or [specific-file]"
|
||||
---
|
||||
|
||||
Run or create tests for $ARGUMENTS.
|
||||
|
||||
## Backend Testing (Rust)
|
||||
|
||||
### Run existing tests
|
||||
```bash
|
||||
# On dev server (never build Rust on macOS)
|
||||
ssh -i ~/.ssh/archipelago-deploy archipelago@192.168.1.228 \
|
||||
'source ~/.cargo/env && cd ~/archy/core && cargo test --all-features 2>&1'
|
||||
```
|
||||
|
||||
### Creating new tests
|
||||
- Place unit tests in the same file with `#[cfg(test)]` module
|
||||
- Place integration tests in `core/{crate}/tests/`
|
||||
- Use `#[tokio::test]` for async tests
|
||||
- Mock external dependencies (filesystem, network, Podman)
|
||||
- Test error cases, not just happy paths
|
||||
- Aim for >80% coverage on core logic
|
||||
|
||||
### Priority areas needing tests
|
||||
1. RPC endpoint handlers (core/archipelago/src/api/)
|
||||
2. Manifest parsing (core/container/src/manifest.rs)
|
||||
3. Dependency resolver (core/container/src/dependency_resolver.rs)
|
||||
4. Auth flows (core/archipelago/src/auth.rs)
|
||||
5. Secrets manager (core/security/src/secrets_manager.rs)
|
||||
6. Port allocation (core/container/src/port_manager.rs)
|
||||
|
||||
## Frontend Testing (Vue/TypeScript)
|
||||
|
||||
### Setup (if not already configured)
|
||||
Ensure vitest is configured in `neode-ui/`:
|
||||
```bash
|
||||
cd neode-ui && npm run test 2>&1 || echo "No test script configured"
|
||||
```
|
||||
|
||||
### Creating new tests
|
||||
- Use Vitest + @vue/test-utils
|
||||
- Place tests in `neode-ui/src/__tests__/` or co-located `*.test.ts`
|
||||
- Test stores (Pinia) with `createTestingPinia()`
|
||||
- Test API clients with mocked fetch
|
||||
- Test component rendering and interactions
|
||||
- Test routing guards
|
||||
|
||||
### Priority areas needing tests
|
||||
1. Pinia stores (app.ts, container.ts, appLauncher.ts)
|
||||
2. RPC client (api/rpc-client.ts) — error handling, retry logic
|
||||
3. WebSocket client (api/websocket.ts) — reconnection
|
||||
4. Router guards — auth flow, session timeout
|
||||
5. Key components — ContainerStatus, SpotlightSearch
|
||||
|
||||
Report test results and any new tests created.
|
||||
@@ -1,90 +0,0 @@
|
||||
---
|
||||
name: ux-review
|
||||
description: Review UI components against Archipelago glassmorphism design standards and UX conventions
|
||||
disable-model-invocation: true
|
||||
allowed-tools: Read, Glob, Grep, Edit, Write
|
||||
argument-hint: "[component-or-view-name]"
|
||||
---
|
||||
|
||||
Review the UI of $ARGUMENTS against Archipelago's glassmorphism design system and UX standards.
|
||||
|
||||
## Design System Compliance
|
||||
|
||||
### Glass Classes (must use global classes from style.css)
|
||||
- [ ] Section containers use `.path-option-card cursor-default px-6 py-6` (Settings-style sections)
|
||||
- [ ] Content containers/modals use `.glass-card`
|
||||
- [ ] Interactive selectable cards use `.path-option-card` (with hover)
|
||||
- [ ] Status displays use `.info-card` (no hover effects)
|
||||
- [ ] ALL buttons use `.glass-button` — NEVER `.gradient-button` (BANNED)
|
||||
- [ ] Large primary actions use `.path-action-button`
|
||||
- [ ] Info sub-cards use `bg-black/20 rounded-xl border border-white/10`
|
||||
- [ ] Info rows use `bg-white/5 rounded-lg` pattern
|
||||
- [ ] Action buttons in info sections use `.info-card-button`
|
||||
|
||||
### BANNED — Flag These as Violations
|
||||
- [ ] No `.gradient-button` anywhere (replace with `.glass-button`)
|
||||
- [ ] No `.gradient-card` / `.gradient-card-dark` (replace with `.glass-card` or `.path-option-card`)
|
||||
|
||||
### NO Inline Tailwind
|
||||
- [ ] Check for long `class="..."` strings with layout/color utilities
|
||||
- [ ] Extract to semantic classes in `neode-ui/src/style.css`
|
||||
- [ ] Name classes semantically: `.app-card`, `.status-badge`, `.nav-item`
|
||||
|
||||
### Color Compliance
|
||||
- [ ] Primary text: `text-white/90` (not `text-white` or arbitrary opacity)
|
||||
- [ ] Muted text: `text-white/60` to `text-white/70`
|
||||
- [ ] Backgrounds: `rgba(0,0,0,0.60)` with `backdrop-filter: blur(24px)`
|
||||
- [ ] Borders: `rgba(255,255,255,0.18)` standard
|
||||
- [ ] Status colors: green=#4ade80, red=#ef4444, yellow=#facc15, blue=#3b82f6, orange=#fb923c
|
||||
|
||||
### Typography
|
||||
- [ ] Font: Avenir Next (body), Montserrat (headings via `font-archipelago`)
|
||||
- [ ] H1: text-3xl font-bold, H2: text-2xl font-semibold, H3: text-xl font-semibold
|
||||
- [ ] Body: text-base, Small: text-sm, Labels: text-xs
|
||||
|
||||
### Interaction States
|
||||
- [ ] Hover: `translateY(-2px)` lift + background brighten + enhanced shadow
|
||||
- [ ] Active: `translateY(1px)` press
|
||||
- [ ] Selected: brighter background + glow shadow + enhanced gradient border
|
||||
- [ ] Disabled: reduced opacity (~50%), no pointer events
|
||||
- [ ] Loading: spinner SVG + descriptive text, button disabled
|
||||
- [ ] Focus-visible: soft blue glow `rgba(120, 180, 255, 0.2)`
|
||||
|
||||
### Transitions
|
||||
- [ ] Standard: `all 0.3s ease`
|
||||
- [ ] All interactive elements have transitions (no jarring state changes)
|
||||
- [ ] Respect `prefers-reduced-motion`
|
||||
|
||||
### Spacing
|
||||
- [ ] 4px grid system (p-1=4px, p-2=8px, p-3=12px, p-4=16px)
|
||||
- [ ] 16px default padding on cards
|
||||
- [ ] Consistent gap values between grid items
|
||||
|
||||
### Responsive
|
||||
- [ ] Mobile: single column, reduced padding, touch targets >= 44x44px
|
||||
- [ ] Tablet (md:): two columns
|
||||
- [ ] Desktop (lg:): three columns, full effects
|
||||
|
||||
### Accessibility
|
||||
- [ ] Semantic HTML (`<button>`, `<nav>`, `<main>`, not div soup)
|
||||
- [ ] ARIA labels on icon-only buttons
|
||||
- [ ] Keyboard navigable (Tab order, Enter to activate, Esc to close)
|
||||
- [ ] Color contrast WCAG AA (4.5:1 normal text, 3:1 large)
|
||||
- [ ] Images have alt text (decorative: `alt=""`)
|
||||
|
||||
### Icons
|
||||
- [ ] Stroke-based SVGs, stroke-width 2.5 default
|
||||
- [ ] Color: `text-white/85` default, `text-white` on hover
|
||||
- [ ] Drop-shadow filter applied on interactive icons
|
||||
- [ ] Size: w-5 h-5 standard, w-4 h-4 small
|
||||
|
||||
## Service UI Review (if reviewing docker/*-ui/)
|
||||
- [ ] Uses `.glass-card` for main sections
|
||||
- [ ] Uses `.info-card` for status (no hover)
|
||||
- [ ] Uses `.info-card-button` for actions (with hover)
|
||||
- [ ] Uses `bg-white/5` for info rows
|
||||
- [ ] Header: logo + title + description + status
|
||||
- [ ] Background image loads correctly
|
||||
- [ ] Mobile responsive
|
||||
|
||||
Report violations with file paths and specific fixes.
|
||||
@@ -1,98 +0,0 @@
|
||||
# Quick Reference: Archipelago App UI Classes
|
||||
|
||||
## Core CSS Classes
|
||||
|
||||
### Containers
|
||||
|
||||
| Class | Use Case | Features |
|
||||
|-------|----------|----------|
|
||||
| `.glass-card` | Main sections, modals, headers | Gradient border, strong blur (24px), inset highlights |
|
||||
| `.info-card` | Status badges, metric displays | Gradient border, no hover effects |
|
||||
| `bg-white/5` | Simple info rows | Plain dark background, no borders |
|
||||
|
||||
### Buttons
|
||||
|
||||
| Class | Use Case | Features |
|
||||
|-------|----------|----------|
|
||||
| `.info-card-button` | Primary actions (Copy Info, View Logs) | Looks like `.info-card`, lifts and brightens on hover |
|
||||
| `.glass-button` | Secondary actions (Settings, Close ×) | Simple glass effect, subtle hover |
|
||||
|
||||
---
|
||||
|
||||
## HTML Snippets
|
||||
|
||||
### Info Card (Display Only)
|
||||
```html
|
||||
<div class="info-card flex items-center gap-3">
|
||||
<svg class="w-5 h-5 text-white/60"><!-- icon --></svg>
|
||||
<div>
|
||||
<p class="text-xs text-white/60">Label</p>
|
||||
<p class="text-sm font-medium text-white">Value</p>
|
||||
</div>
|
||||
</div>
|
||||
```
|
||||
|
||||
### Action Button
|
||||
```html
|
||||
<button class="mt-4 w-full info-card-button text-sm font-medium" onclick="doAction()">
|
||||
Button Text
|
||||
</button>
|
||||
```
|
||||
|
||||
### Info Row
|
||||
```html
|
||||
<div class="flex items-center justify-between p-3 bg-white/5 rounded-lg">
|
||||
<div class="flex items-center gap-3">
|
||||
<svg class="w-5 h-5 text-white/60"><!-- icon --></svg>
|
||||
<span class="text-white/80 text-sm">Label</span>
|
||||
</div>
|
||||
<span class="text-white/60 text-sm">Value</span>
|
||||
</div>
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Service UI Ports
|
||||
|
||||
| Service | Port | Status |
|
||||
|---------|------|--------|
|
||||
| Bitcoin Knots | 8334 | ✅ Live |
|
||||
| LND | 8081 | ✅ Live |
|
||||
| Core Lightning | 8082 | 🚧 Planned |
|
||||
| Mempool | 8083 | 🚧 Planned |
|
||||
|
||||
---
|
||||
|
||||
## Quick Deploy
|
||||
|
||||
```bash
|
||||
# From docker/{service}-ui/ directory
|
||||
sshpass -p "archipelago" rsync -avz --delete ./ archipelago@192.168.1.228:/tmp/{service}-ui-build/
|
||||
sshpass -p "archipelago" ssh archipelago@192.168.1.228 \
|
||||
"cd /tmp/{service}-ui-build && \
|
||||
sudo podman build -t {service}-ui:latest . && \
|
||||
sudo podman stop {service}-ui 2>/dev/null || true && \
|
||||
sudo podman rm {service}-ui 2>/dev/null || true && \
|
||||
sudo podman run -d --name {service}-ui --restart unless-stopped \
|
||||
--network=host --label 'com.archipelago.parent-app={service-id}' \
|
||||
{service}-ui:latest"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Visual Hierarchy
|
||||
|
||||
```
|
||||
┌─────────────────────────────────────┐
|
||||
│ .glass-card (Main Container) │ ← Strongest visual weight
|
||||
│ ┌─────────────────────────────────┐ │
|
||||
│ │ .info-card (Status Badge) │ │ ← Medium weight, no hover
|
||||
│ └─────────────────────────────────┘ │
|
||||
│ ┌─────────────────────────────────┐ │
|
||||
│ │ bg-white/5 (Info Row) │ │ ← Lightest weight
|
||||
│ └─────────────────────────────────┘ │
|
||||
│ ┌─────────────────────────────────┐ │
|
||||
│ │ .info-card-button (Action) │ │ ← Interactive, lifts on hover
|
||||
│ └─────────────────────────────────┘ │
|
||||
└─────────────────────────────────────┘
|
||||
```
|
||||
@@ -1,588 +0,0 @@
|
||||
# Archipelago App UI Standards - For Apps Without Native UIs
|
||||
|
||||
**Version:** 1.0
|
||||
**Last Updated:** 2026-02-03
|
||||
|
||||
## Overview
|
||||
|
||||
This document defines the **standard UI pattern** for containerized applications that don't have their own web interface (e.g., Bitcoin Core, LND, Core Lightning, mempool backend services, etc.).
|
||||
|
||||
These UIs provide a simple, elegant way to:
|
||||
- Monitor service status and metrics
|
||||
- View connection information (RPC, REST, gRPC endpoints)
|
||||
- Access logs and settings
|
||||
- Copy configuration details for external tools
|
||||
|
||||
---
|
||||
|
||||
## Architecture
|
||||
|
||||
```
|
||||
┌─────────────────────────────────────┐
|
||||
│ Nginx Container (Alpine) │
|
||||
│ - Serves static HTML/CSS/JS │
|
||||
│ - Port 8XXX (unique per service) │
|
||||
│ - Optional: Proxies RPC/API calls │
|
||||
└─────────────────────────────────────┘
|
||||
```
|
||||
|
||||
### File Structure
|
||||
|
||||
```
|
||||
docker/
|
||||
├── {service-name}-ui/
|
||||
│ ├── index.html # Main UI file
|
||||
│ ├── Dockerfile # Container build
|
||||
│ ├── nginx.conf # Nginx config
|
||||
│ ├── {service-icon} # App icon (svg/webp/png)
|
||||
│ └── bg-{theme}.jpg # Background image
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Standard UI Components
|
||||
|
||||
### 1. **CSS Class System**
|
||||
|
||||
#### `.glass-card` - Main Container Cards
|
||||
Used for: Header, main sections, modals
|
||||
|
||||
```css
|
||||
.glass-card {
|
||||
position: relative;
|
||||
background: rgba(0, 0, 0, 0.60);
|
||||
backdrop-filter: blur(24px);
|
||||
-webkit-backdrop-filter: blur(24px);
|
||||
box-shadow:
|
||||
0 8px 24px rgba(0, 0, 0, 0.45),
|
||||
inset 0 1px 0 rgba(255, 255, 255, 0.22);
|
||||
border-radius: 1rem;
|
||||
overflow-x: hidden;
|
||||
overflow-y: visible;
|
||||
border: none;
|
||||
}
|
||||
|
||||
.glass-card::before {
|
||||
content: '';
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
border-radius: inherit;
|
||||
padding: 2px;
|
||||
background: linear-gradient(135deg, rgba(0, 0, 0, 0.8), transparent);
|
||||
-webkit-mask:
|
||||
linear-gradient(#fff 0 0) content-box,
|
||||
linear-gradient(#fff 0 0);
|
||||
-webkit-mask-composite: xor;
|
||||
mask-composite: exclude;
|
||||
pointer-events: none;
|
||||
z-index: 1;
|
||||
}
|
||||
|
||||
.glass-card > * {
|
||||
position: relative;
|
||||
z-index: 2;
|
||||
}
|
||||
```
|
||||
|
||||
#### `.info-card` - Stat Display Cards
|
||||
Used for: Status badges, metric displays (non-interactive)
|
||||
|
||||
```css
|
||||
.info-card {
|
||||
position: relative;
|
||||
background: rgba(0, 0, 0, 0.60);
|
||||
backdrop-filter: blur(24px);
|
||||
-webkit-backdrop-filter: blur(24px);
|
||||
box-shadow:
|
||||
0 8px 24px rgba(0, 0, 0, 0.45),
|
||||
inset 0 1px 0 rgba(255, 255, 255, 0.22);
|
||||
border-radius: 16px;
|
||||
padding: 12px;
|
||||
border: none;
|
||||
}
|
||||
|
||||
.info-card::before {
|
||||
content: '';
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
border-radius: inherit;
|
||||
padding: 2px;
|
||||
background: linear-gradient(135deg, rgba(0, 0, 0, 0.8), transparent);
|
||||
-webkit-mask:
|
||||
linear-gradient(#fff 0 0) content-box,
|
||||
linear-gradient(#fff 0 0);
|
||||
-webkit-mask-composite: xor;
|
||||
mask-composite: exclude;
|
||||
pointer-events: none;
|
||||
}
|
||||
```
|
||||
|
||||
**No hover effects** - These are display-only elements.
|
||||
|
||||
#### `.info-card-button` - Interactive Action Buttons
|
||||
Used for: Primary action buttons (Copy Info, View Logs, etc.)
|
||||
|
||||
```css
|
||||
.info-card-button {
|
||||
/* Same base styles as .info-card */
|
||||
position: relative;
|
||||
background: rgba(0, 0, 0, 0.60);
|
||||
backdrop-filter: blur(24px);
|
||||
-webkit-backdrop-filter: blur(24px);
|
||||
box-shadow:
|
||||
0 8px 24px rgba(0, 0, 0, 0.45),
|
||||
inset 0 1px 0 rgba(255, 255, 255, 0.22);
|
||||
border-radius: 16px;
|
||||
padding: 12px;
|
||||
transition: all 0.3s ease;
|
||||
border: none;
|
||||
cursor: pointer;
|
||||
color: rgba(255, 255, 255, 0.9);
|
||||
}
|
||||
|
||||
.info-card-button::before {
|
||||
/* Same gradient as .info-card */
|
||||
content: '';
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
border-radius: inherit;
|
||||
padding: 2px;
|
||||
background: linear-gradient(135deg, rgba(0, 0, 0, 0.8), transparent);
|
||||
-webkit-mask:
|
||||
linear-gradient(#fff 0 0) content-box,
|
||||
linear-gradient(#fff 0 0);
|
||||
-webkit-mask-composite: xor;
|
||||
mask-composite: exclude;
|
||||
pointer-events: none;
|
||||
transition: all 0.3s ease;
|
||||
}
|
||||
|
||||
/* Hover state - lifts and brightens */
|
||||
.info-card-button:hover {
|
||||
transform: translateY(-2px);
|
||||
background: rgba(0, 0, 0, 0.35);
|
||||
box-shadow:
|
||||
0 12px 32px rgba(0, 0, 0, 0.6),
|
||||
inset 0 1px 0 rgba(255, 255, 255, 0.25);
|
||||
color: rgba(255, 255, 255, 1);
|
||||
}
|
||||
|
||||
.info-card-button:hover::before {
|
||||
background: linear-gradient(135deg, rgba(255, 255, 255, 0.3), transparent);
|
||||
}
|
||||
|
||||
/* Active state - press down */
|
||||
.info-card-button:active {
|
||||
transform: translateY(1px);
|
||||
}
|
||||
```
|
||||
|
||||
#### `.glass-button` - Secondary Buttons
|
||||
Used for: Settings, Close (×), secondary actions
|
||||
|
||||
```css
|
||||
.glass-button {
|
||||
background-color: rgba(0, 0, 0, 0.6);
|
||||
backdrop-filter: blur(18px);
|
||||
-webkit-backdrop-filter: blur(18px);
|
||||
border: 1px solid rgba(255, 255, 255, 0.18);
|
||||
color: rgba(255, 255, 255, 0.9);
|
||||
transition: all 0.3s ease;
|
||||
}
|
||||
|
||||
.glass-button:hover {
|
||||
color: white;
|
||||
background-color: rgba(0, 0, 0, 0.7);
|
||||
}
|
||||
```
|
||||
|
||||
#### Simple Info Rows - `bg-white/5`
|
||||
Used for: Non-interactive info rows (RPC Host, Network, Status, etc.)
|
||||
|
||||
```html
|
||||
<div class="flex items-center justify-between p-3 bg-white/5 rounded-lg">
|
||||
<div class="flex items-center gap-3">
|
||||
<svg class="w-5 h-5 text-white/60"><!-- icon --></svg>
|
||||
<span class="text-white/80 text-sm">Label</span>
|
||||
</div>
|
||||
<span class="text-white/60 text-sm">Value</span>
|
||||
</div>
|
||||
```
|
||||
|
||||
**No gradient borders** - These are simple read-only display elements.
|
||||
|
||||
---
|
||||
|
||||
## Standard Layout Pattern
|
||||
|
||||
### 1. **Header Section** (`.glass-card`)
|
||||
|
||||
```html
|
||||
<div class="glass-card p-6 mb-6">
|
||||
<div class="flex flex-col md:flex-row items-center md:items-center gap-4 md:gap-6">
|
||||
<!-- Logo (left) -->
|
||||
<div class="flex-shrink-0">
|
||||
<div class="logo-gradient-border">
|
||||
<img src="/assets/img/app-icons/{service-icon}" alt="{Service Name}" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Title and Description (center) -->
|
||||
<div class="flex-1 min-w-0">
|
||||
<h1 class="text-3xl font-bold text-white mb-2">{Service Name}</h1>
|
||||
<p class="text-white/70">{Service Description}</p>
|
||||
</div>
|
||||
|
||||
<!-- Status Info (right) - OPTIONAL for headers with status -->
|
||||
<div class="w-full md:w-auto flex flex-col md:flex-row gap-3 md:gap-4">
|
||||
<div class="info-card flex items-center gap-3">
|
||||
<!-- Status info -->
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
```
|
||||
|
||||
### 2. **Quick Status Bar** (`.glass-card` with `.info-card` grid)
|
||||
|
||||
```html
|
||||
<div class="glass-card p-6 mb-6">
|
||||
<div class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-4">
|
||||
<div class="info-card flex items-center justify-between">
|
||||
<!-- Status indicator -->
|
||||
</div>
|
||||
<!-- ... more status cards -->
|
||||
</div>
|
||||
</div>
|
||||
```
|
||||
|
||||
### 3. **Main Content Sections** (`.glass-card` grid)
|
||||
|
||||
```html
|
||||
<div class="grid grid-cols-1 lg:grid-cols-2 gap-6 mb-8">
|
||||
<!-- Service 1: Node Status -->
|
||||
<div class="glass-card p-6">
|
||||
<div class="flex items-start gap-4 mb-4">
|
||||
<div class="flex-shrink-0 w-12 h-12 rounded-lg bg-white/10 flex items-center justify-center">
|
||||
<svg><!-- icon --></svg>
|
||||
</div>
|
||||
<div class="flex-1">
|
||||
<h2 class="text-xl font-semibold text-white mb-2">Section Title</h2>
|
||||
<p class="text-white/70 text-sm mb-4">Section description</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="space-y-3">
|
||||
<!-- Info rows (bg-white/5) -->
|
||||
</div>
|
||||
|
||||
<button class="mt-4 w-full info-card-button text-sm font-medium" onclick="action()">
|
||||
Action Button
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- ... more sections -->
|
||||
</div>
|
||||
```
|
||||
|
||||
### 4. **Modals** (`.glass-card` with backdrop)
|
||||
|
||||
```html
|
||||
<div class="modal hidden fixed inset-0 bg-black/80 backdrop-blur-sm z-50 items-center justify-center p-4" id="modalId">
|
||||
<div class="glass-card p-6 max-w-2xl w-full max-h-[80vh] overflow-y-auto">
|
||||
<div class="flex justify-between items-center mb-4">
|
||||
<h2 class="text-2xl font-bold text-white">Modal Title</h2>
|
||||
<button onclick="closeModal()" class="glass-button px-3 py-2 rounded-lg text-xl font-medium">×</button>
|
||||
</div>
|
||||
<!-- Modal content -->
|
||||
</div>
|
||||
</div>
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Visual Hierarchy
|
||||
|
||||
### **Container Importance (Most → Least)**
|
||||
|
||||
1. **`.glass-card`** - Main containers, sections, modals
|
||||
- Gradient border, strong blur (24px), inset highlights
|
||||
|
||||
2. **`.info-card`** - Stat displays, status badges
|
||||
- Gradient border, backdrop blur, **NO hover effects**
|
||||
|
||||
3. **`.info-card-button`** - Primary action buttons
|
||||
- Same as `.info-card` in default state
|
||||
- **WITH hover effects** (lift, brighten, enhanced gradient)
|
||||
|
||||
4. **`bg-white/5`** - Simple info rows
|
||||
- Dark background, **NO borders**, **NO hover**
|
||||
|
||||
5. **`.glass-button`** - Secondary buttons
|
||||
- Simple glass effect, minimal hover
|
||||
|
||||
---
|
||||
|
||||
## Port Assignments
|
||||
|
||||
Reserve unique ports for each service UI:
|
||||
|
||||
```
|
||||
8334 - Bitcoin Knots UI
|
||||
8081 - LND UI
|
||||
8082 - Core Lightning UI (future)
|
||||
8083 - Mempool UI (future)
|
||||
8084 - BTCPay Server UI (future)
|
||||
...
|
||||
```
|
||||
|
||||
Update backend's `docker_packages.rs` to map these ports:
|
||||
|
||||
```rust
|
||||
} else if app_id == "lnd" {
|
||||
Some("http://localhost:8081".to_string())
|
||||
} else if app_id == "bitcoin-knots" {
|
||||
Some("http://localhost:8334".to_string())
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Dockerfile Template
|
||||
|
||||
```dockerfile
|
||||
FROM docker.io/library/nginx:alpine
|
||||
|
||||
# Copy the HTML file
|
||||
COPY index.html /usr/share/nginx/html/
|
||||
|
||||
# Create directories for assets
|
||||
RUN mkdir -p /usr/share/nginx/html/assets/img/app-icons && \
|
||||
mkdir -p /usr/share/nginx/html/assets/img
|
||||
|
||||
# Copy assets
|
||||
COPY {service-icon} /usr/share/nginx/html/assets/img/app-icons/
|
||||
COPY bg-{theme}.jpg /usr/share/nginx/html/assets/img/
|
||||
|
||||
# Copy nginx config
|
||||
COPY nginx.conf /etc/nginx/conf.d/default.conf
|
||||
|
||||
EXPOSE 80
|
||||
|
||||
CMD ["nginx", "-g", "daemon off;"]
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Nginx Config Template
|
||||
|
||||
### Simple Static Serving (LND, most services)
|
||||
|
||||
```nginx
|
||||
server {
|
||||
listen 8XXX; # Unique port for this service
|
||||
server_name _;
|
||||
|
||||
root /usr/share/nginx/html;
|
||||
index index.html;
|
||||
|
||||
location / {
|
||||
try_files $uri $uri/ /index.html;
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### With RPC Proxy (Bitcoin Knots)
|
||||
|
||||
```nginx
|
||||
server {
|
||||
listen 8334;
|
||||
server_name _;
|
||||
|
||||
root /usr/share/nginx/html;
|
||||
index index.html;
|
||||
|
||||
# RPC proxy to avoid CORS issues
|
||||
location /bitcoin-rpc/ {
|
||||
proxy_pass http://127.0.0.1:8332/;
|
||||
proxy_http_version 1.1;
|
||||
proxy_set_header Host $host;
|
||||
proxy_set_header X-Real-IP $remote_addr;
|
||||
proxy_set_header Authorization "Basic {BASE64_ENCODED_CREDS}";
|
||||
add_header Access-Control-Allow-Origin *;
|
||||
add_header Access-Control-Allow-Methods "POST, GET, OPTIONS";
|
||||
add_header Access-Control-Allow-Headers "Content-Type, Authorization";
|
||||
if ($request_method = OPTIONS) {
|
||||
return 204;
|
||||
}
|
||||
}
|
||||
|
||||
location / {
|
||||
try_files $uri $uri/ /index.html;
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Deployment
|
||||
|
||||
### Build and Run
|
||||
|
||||
```bash
|
||||
# Build the image
|
||||
cd docker/{service}-ui
|
||||
sudo podman build -t {service}-ui:latest .
|
||||
|
||||
# Run the container
|
||||
sudo podman run -d \
|
||||
--name {service}-ui \
|
||||
--restart unless-stopped \
|
||||
--network=host \
|
||||
--label 'com.archipelago.parent-app={service-id}' \
|
||||
{service}-ui:latest
|
||||
```
|
||||
|
||||
### Backend Integration
|
||||
|
||||
Update `core/archipelago/src/container/docker_packages.rs`:
|
||||
|
||||
```rust
|
||||
} else if app_id == "{service-id}" {
|
||||
Some("http://localhost:8XXX".to_string())
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Reference Implementations
|
||||
|
||||
### ✅ Bitcoin Knots UI
|
||||
- **Location:** `docker/bitcoin-ui/`
|
||||
- **Port:** 8334
|
||||
- **Features:**
|
||||
- Live sync status with animations
|
||||
- RPC proxy for CORS handling
|
||||
- Real-time block updates
|
||||
- Connection info display
|
||||
|
||||
### ✅ LND UI
|
||||
- **Location:** `docker/lnd-ui/`
|
||||
- **Port:** 8081
|
||||
- **Features:**
|
||||
- Node status monitoring
|
||||
- Channel count display
|
||||
- REST API + gRPC info
|
||||
- Settings and logs modals
|
||||
|
||||
---
|
||||
|
||||
## Benefits of This Approach
|
||||
|
||||
1. **Consistency** - All service UIs look and feel the same
|
||||
2. **Lightweight** - Nginx Alpine base (~10MB)
|
||||
3. **Fast Development** - Copy template, customize content
|
||||
4. **Mobile Responsive** - Works on all screen sizes
|
||||
5. **Low Resource Usage** - Static files, minimal CPU/RAM
|
||||
6. **Easy Maintenance** - Single pattern to update globally
|
||||
|
||||
---
|
||||
|
||||
## Creating a New Service UI
|
||||
|
||||
### Step-by-Step Process
|
||||
|
||||
1. **Create Directory Structure**
|
||||
```bash
|
||||
mkdir -p docker/{service}-ui
|
||||
cd docker/{service}-ui
|
||||
```
|
||||
|
||||
2. **Copy Template Files**
|
||||
```bash
|
||||
cp ../bitcoin-ui/index.html ./
|
||||
cp ../bitcoin-ui/Dockerfile ./
|
||||
cp ../bitcoin-ui/nginx.conf ./
|
||||
```
|
||||
|
||||
3. **Customize `index.html`**
|
||||
- Update title, service name, description
|
||||
- Modify status cards for your service's metrics
|
||||
- Update connection info sections (RPC/REST/gRPC)
|
||||
- Adjust modal content
|
||||
|
||||
4. **Copy Assets**
|
||||
```bash
|
||||
cp ../../neode-ui/public/assets/img/app-icons/{service-icon} ./
|
||||
cp ../../neode-ui/public/assets/img/bg-{theme}.jpg ./
|
||||
```
|
||||
|
||||
5. **Update Nginx Config**
|
||||
- Set unique port number
|
||||
- Add RPC proxy if needed
|
||||
|
||||
6. **Update Dockerfile**
|
||||
- Update asset COPY commands
|
||||
- Verify port EXPOSE
|
||||
|
||||
7. **Build and Deploy**
|
||||
```bash
|
||||
# Deploy to dev server
|
||||
sshpass -p "archipelago" rsync -avz --delete ./ archipelago@192.168.1.228:/tmp/{service}-ui-build/
|
||||
|
||||
# Build on server
|
||||
sshpass -p "archipelago" ssh archipelago@192.168.1.228 \
|
||||
"cd /tmp/{service}-ui-build && \
|
||||
sudo podman build -t {service}-ui:latest . && \
|
||||
sudo podman stop {service}-ui 2>/dev/null || true && \
|
||||
sudo podman rm {service}-ui 2>/dev/null || true && \
|
||||
sudo podman run -d --name {service}-ui --restart unless-stopped \
|
||||
--network=host --label 'com.archipelago.parent-app={service-id}' \
|
||||
{service}-ui:latest"
|
||||
```
|
||||
|
||||
8. **Update Backend**
|
||||
- Edit `core/archipelago/src/container/docker_packages.rs`
|
||||
- Add port mapping for your service
|
||||
|
||||
---
|
||||
|
||||
## Testing Checklist
|
||||
|
||||
- [ ] UI loads correctly at `http://{server}:8XXX/`
|
||||
- [ ] Logo displays properly
|
||||
- [ ] Background image loads
|
||||
- [ ] All status cards show correct info
|
||||
- [ ] Buttons have proper hover effects
|
||||
- [ ] Modals open and close correctly
|
||||
- [ ] Mobile responsive (test on phone)
|
||||
- [ ] Glass effects render correctly
|
||||
- [ ] Gradient borders visible
|
||||
- [ ] Cache busting works (no stale content)
|
||||
|
||||
---
|
||||
|
||||
## Future Enhancements
|
||||
|
||||
- **Live Data Updates** - WebSocket connections for real-time status
|
||||
- **Interactive Charts** - Add Chart.js for visualizing metrics
|
||||
- **Theme Variations** - Allow users to select background themes
|
||||
- **Dark/Light Mode** - Toggle between color schemes
|
||||
- **Internationalization** - Support multiple languages
|
||||
- **Accessibility** - Improve screen reader support
|
||||
|
||||
---
|
||||
|
||||
## File Locations
|
||||
|
||||
- **UI Standards Doc:** `/Users/dorian/Projects/archy/.cursor/rules/APP-UI-STANDARDS.md`
|
||||
- **Global UI Standards:** `/Users/dorian/Projects/archy/.cursor/rules/UI-STANDARDS.md`
|
||||
- **Reference Implementations:**
|
||||
- Bitcoin UI: `/Users/dorian/Projects/archy/docker/bitcoin-ui/`
|
||||
- LND UI: `/Users/dorian/Projects/archy/docker/lnd-ui/`
|
||||
|
||||
---
|
||||
|
||||
**Version:** 1.0
|
||||
**Maintained by:** Archipelago Development Team
|
||||
**Last Updated:** 2026-02-03
|
||||
@@ -1,188 +0,0 @@
|
||||
---
|
||||
alwaysApply: true
|
||||
---
|
||||
|
||||
# Archipelago Bitcoin Node OS - Architecture Documentation
|
||||
|
||||
## Overview
|
||||
|
||||
Archipelago is a next-generation Bitcoin Node OS built on Debian Linux with Podman containerization, combining the modularity of Parmanode with the security and reliability of a proven server OS. Similar to StartOS, we use Debian Live for reliable USB boot and installation.
|
||||
|
||||
## System Architecture
|
||||
|
||||
```
|
||||
┌─────────────────────────────────────────────────────────┐
|
||||
│ Debian Linux Base (Bookworm) │
|
||||
│ - Stable, well-supported kernel │
|
||||
│ - Systemd service management │
|
||||
│ - Extensive hardware support │
|
||||
└─────────────────────────────────────────────────────────┘
|
||||
│
|
||||
┌───────────────┼───────────────┐
|
||||
│ │ │
|
||||
┌───────▼──────┐ ┌──────▼──────┐ ┌─────▼──────┐
|
||||
│ Podman │ │ Rust Backend│ │ Vue.js UI │
|
||||
│ (rootless) │ │ (core/) │ │ (neode-ui/) │
|
||||
└───────┬──────┘ └──────┬──────┘ └─────────────┘
|
||||
│ │
|
||||
└───────┬───────┘
|
||||
│
|
||||
┌───────────▼───────────┐
|
||||
│ Container Orchestration│
|
||||
│ Layer │
|
||||
│ - Manifest parser │
|
||||
│ - Podman client │
|
||||
│ - Dependency resolver │
|
||||
│ - Health monitor │
|
||||
└───────────┬───────────┘
|
||||
│
|
||||
┌───────────▼───────────┐
|
||||
│ Containerized Apps │
|
||||
│ - Bitcoin Core │
|
||||
│ - LND / CLN │
|
||||
│ - BTCPay Server │
|
||||
│ - Nostr Relays │
|
||||
│ - Meshtastic │
|
||||
│ - Web5 DWN │
|
||||
└───────────────────────┘
|
||||
```
|
||||
|
||||
## Key Components
|
||||
|
||||
### 1. Debian Linux Base
|
||||
|
||||
- **Distribution**: Debian 12 (Bookworm) - stable, LTS support
|
||||
- **Init System**: Systemd for service management
|
||||
- **Security**: AppArmor, standard Debian hardening
|
||||
- **Multi-arch**: ARM64 (Raspberry Pi) and x86_64 support
|
||||
- **Hardware Profiles**: Optimized builds for specific hardware
|
||||
- Start9 Server Pure (Intel i7-10710U, NVMe)
|
||||
- HP ProDesk 400 G4 DM
|
||||
- Dell OptiPlex
|
||||
- Generic x86_64
|
||||
|
||||
### 2. Container Orchestration Layer
|
||||
|
||||
Located in `core/container/`:
|
||||
- **manifest.rs**: Parses YAML app manifests
|
||||
- **podman_client.rs**: Wraps Podman API for container management
|
||||
- **dependency_resolver.rs**: Resolves app dependencies and conflicts
|
||||
- **health_monitor.rs**: Monitors container health and auto-restarts
|
||||
|
||||
### 3. Backend API Extensions
|
||||
|
||||
New RPC endpoints in `core/archipelago/src/container/`:
|
||||
- `container-install`: Install app from Docker image
|
||||
- `container-start/stop/remove`: Container lifecycle
|
||||
- `container-status/logs`: Status and debugging
|
||||
- `container-list`: List all containers
|
||||
- `container-health`: Health status aggregation
|
||||
|
||||
### 4. Vue.js UI Integration
|
||||
|
||||
New components in `neode-ui/`:
|
||||
- **ContainerApps.vue**: List of containerized apps
|
||||
- **ContainerAppDetails.vue**: Detailed app view with logs
|
||||
- **ContainerStatus.vue**: Status indicator component
|
||||
- **container-client.ts**: API client for container operations
|
||||
- **container.ts**: Pinia store for container state
|
||||
|
||||
### 5. App Manifest System
|
||||
|
||||
Standardized YAML format in `apps/`:
|
||||
- Defines container image, resources, dependencies
|
||||
- Security policies and health checks
|
||||
- Bitcoin/Lightning/Web5 integration metadata
|
||||
|
||||
### 6. Parmanode Compatibility
|
||||
|
||||
Located in `core/parmanode/`:
|
||||
- **script_runner.rs**: Executes Parmanode scripts in containers
|
||||
- **converter.rs**: Converts Parmanode modules to app manifests
|
||||
- **parmanode-wrapper.sh**: Shell wrapper for direct script execution
|
||||
|
||||
### 7. Security Modules
|
||||
|
||||
Located in `core/security/`:
|
||||
- **container_policies.rs**: Generates AppArmor profiles
|
||||
- **secrets_manager.rs**: Encrypted secrets storage
|
||||
- **image_verifier.rs**: Cosign signature verification
|
||||
|
||||
### 8. Performance Optimization
|
||||
|
||||
Located in `core/performance/`:
|
||||
- **resource_manager.rs**: CPU/memory/disk allocation
|
||||
- **optimize-debian.sh**: OS-level optimizations
|
||||
|
||||
## App Categories
|
||||
|
||||
### Bitcoin & Lightning
|
||||
- Bitcoin Core (full node)
|
||||
- LND (Lightning Network Daemon)
|
||||
- Core Lightning (CLN)
|
||||
- BTCPay Server
|
||||
- Mempool (blockchain explorer)
|
||||
|
||||
### Web5 & Decentralized Protocols
|
||||
- Nostr relays (nostr-rs-relay, strfry)
|
||||
- Web5 DWN (Decentralized Web Node)
|
||||
- DID Wallet
|
||||
- Bitcoin Domain Names
|
||||
|
||||
### Mesh Networking & Routing
|
||||
- Meshtastic (LoRa mesh networking)
|
||||
- Router (mesh routing, device discovery)
|
||||
- Local network management
|
||||
|
||||
### Self-Hosted Services
|
||||
- Home Assistant
|
||||
- Grafana
|
||||
- SearXNG
|
||||
- OnlyOffice
|
||||
- Ollama (local AI)
|
||||
- Penpot
|
||||
|
||||
## Security Model
|
||||
|
||||
1. **OS Level**: Debian hardening, AppArmor, minimal installed packages
|
||||
2. **Container Level**: Rootless Podman, capability dropping, network isolation
|
||||
3. **Secrets**: Encrypted storage, runtime injection only
|
||||
4. **Supply Chain**: Signed images (Cosign), SBOM generation
|
||||
5. **Network**: Firewall (nftables/iptables), rate limiting, Tor integration
|
||||
6. **Audit**: Journald logging, configuration tracking
|
||||
|
||||
## Networking
|
||||
|
||||
- **Isolated Networks**: Each app on separate bridge network by default
|
||||
- **Bitcoin Core**: Isolated network, explicit RPC access
|
||||
- **Lightning Nodes**: Separate network, gRPC/REST exposed
|
||||
- **Tor Integration**: Optional, default for privacy-sensitive apps
|
||||
- **Mesh Networking**: Meshtastic and router support for decentralized communication
|
||||
|
||||
## Data Persistence
|
||||
|
||||
- **App Data**: `/var/lib/archipelago/{app-id}/`
|
||||
- **Secrets**: `/var/lib/archipelago/secrets/{app-id}/` (encrypted)
|
||||
- **Logs**: `/var/lib/archipelago/logs/{app-id}/`
|
||||
- **Backups**: `/var/lib/archipelago/backups/`
|
||||
|
||||
## Build System
|
||||
|
||||
### ISO Creation
|
||||
|
||||
- **build-debian-iso.sh**: Creates bootable Debian Live ISO
|
||||
- **install-to-disk.sh**: Installs Archipelago to target disk via debootstrap
|
||||
- Uses Debian Live for reliable USB boot (same approach as StartOS)
|
||||
|
||||
### Installation Methods
|
||||
|
||||
1. **Live USB**: Boot from USB, run in live mode or install to disk
|
||||
2. **Disk Install**: Full installation with persistence via `install-to-disk.sh`
|
||||
|
||||
## Future Enhancements
|
||||
|
||||
- Time-travel snapshots (ZFS/BTRFS)
|
||||
- Decentralized app marketplace (IPFS + Nostr)
|
||||
- Multi-node clustering
|
||||
- Hardware attestation (TPM 2.0)
|
||||
- Protocol-agnostic design (multi-chain support)
|
||||
@@ -1,248 +0,0 @@
|
||||
# Archipelago Development Workflow
|
||||
|
||||
## Overview
|
||||
|
||||
Archipelago is a Bitcoin Node OS that users install from a bootable USB. We develop on a live development server, then package that server's state into an auto-installer ISO.
|
||||
|
||||
## Target Experience (Like Other Bitcoin Nodes)
|
||||
|
||||
Users interact with Archipelago like **Umbrel**, **Start9**, **RaspiBlitz**:
|
||||
1. Flash ISO to USB
|
||||
2. Boot from USB → Auto-installer runs
|
||||
3. Installer detects internal disk and installs Archipelago
|
||||
4. Remove USB, reboot
|
||||
5. **Access web UI at http://<IP>** (port 80, served by Nginx)
|
||||
6. Manage Bitcoin, Lightning, apps through web interface
|
||||
|
||||
## Development Workflow
|
||||
|
||||
### 1. Development Server (Primary Development Environment)
|
||||
|
||||
**Server**: `archipelago@192.168.1.228`
|
||||
**Purpose**: Live development and testing environment
|
||||
|
||||
This is where ALL development happens:
|
||||
- Backend changes: `/usr/local/bin/archipelago` (Rust binary)
|
||||
- Frontend changes: `/opt/archipelago/web-ui` (Vue.js, served by Nginx on port 80)
|
||||
- Backend API: `localhost:5678` (proxied by Nginx)
|
||||
- System configs: Nginx, systemd services, etc.
|
||||
- Container apps: Podman containers for Bitcoin, LND, etc.
|
||||
|
||||
**CRITICAL**: This is the AUTHORITATIVE source. The ISO must capture THIS server's exact state.
|
||||
|
||||
### 2. Build Process (Snapshot → ISO)
|
||||
|
||||
**Goal**: Create an auto-installer ISO that installs the EXACT state of the dev server
|
||||
|
||||
**Process**:
|
||||
1. **Snapshot the dev server** (192.168.1.228):
|
||||
- Capture current backend binary (`/usr/local/bin/archipelago`)
|
||||
- Capture current frontend files (`/opt/archipelago/web-ui`)
|
||||
- When `DEV_SERVER` is set: capture container images from the live server so the ISO prepackages current apps
|
||||
- Capture system configs (Nginx, systemd, etc.)
|
||||
- Capture app manifests and configs
|
||||
|
||||
2. **Package into bootable ISO**:
|
||||
- Base: Debian Live (minimal installer environment)
|
||||
- Includes: Pre-built rootfs with all Archipelago components
|
||||
- Auto-installer script detects internal disk and installs system
|
||||
|
||||
3. **Result**: Bootable ISO that users can flash to USB
|
||||
|
||||
### 3. ISO Flash & Install (End User Experience)
|
||||
|
||||
**User steps**:
|
||||
1. Flash `archipelago-installer-x86_64.iso` to USB
|
||||
2. Boot from USB
|
||||
3. Press Enter at "Install Archipelago" prompt
|
||||
4. Installer automatically:
|
||||
- Detects internal disk (NVMe/SSD)
|
||||
- Creates partitions (EFI + Root)
|
||||
- Installs Archipelago system
|
||||
- Installs GRUB bootloader
|
||||
- Shows "INSTALLATION COMPLETE" with Web UI URL
|
||||
5. Remove USB and reboot
|
||||
6. Access Web UI at `http://<IP>`
|
||||
|
||||
### 4. Deployment Targets
|
||||
|
||||
- **Development Server**: `192.168.1.228` (always up to date)
|
||||
- **Test Devices**:
|
||||
- Dell OptiPlex (current test device)
|
||||
- Start9 Server Pure (Intel i7, NVMe)
|
||||
- HP ProDesk 400 G4 DM
|
||||
- **Production**: Any x86_64 device with NVMe/SSD
|
||||
|
||||
## Architecture
|
||||
|
||||
### Frontend (Web UI)
|
||||
- **Framework**: Vue.js 3 + Vite
|
||||
- **Build Output**: `web/dist/neode-ui/` (NOT `neode-ui/dist/`)
|
||||
- **Deployment**: Copied to `/opt/archipelago/web-ui` on dev server
|
||||
- **Served By**: Nginx on port 80
|
||||
- **API Proxy**: Nginx proxies `/rpc/`, `/ws/`, `/health` to `localhost:5678`
|
||||
|
||||
### Backend (API Server)
|
||||
- **Language**: Rust
|
||||
- **Binary Location**: `/usr/local/bin/archipelago`
|
||||
- **Bind Address**: `0.0.0.0:5678`
|
||||
- **Systemd Service**: `archipelago.service`
|
||||
- **Managed By**: systemd (auto-start on boot)
|
||||
|
||||
### System Integration
|
||||
- **OS**: Debian 12 (Bookworm)
|
||||
- **Web Server**: Nginx (port 80)
|
||||
- **Container Runtime**: Podman (rootless)
|
||||
- **Apps**: Bitcoin Core, LND, BTCPay, Nostr relays, etc.
|
||||
|
||||
## Build Scripts
|
||||
|
||||
### `build-auto-installer-iso.sh` (CORRECT SCRIPT)
|
||||
Creates a bootable auto-installer ISO (like the working build from this morning).
|
||||
|
||||
**Features**:
|
||||
- Pre-built rootfs (no network needed during install)
|
||||
- Auto-detects internal disk
|
||||
- One-button installation
|
||||
- Boots directly to web UI after install
|
||||
- Pre-bundles container images (Bitcoin, LND, etc.)
|
||||
|
||||
**Usage**:
|
||||
```bash
|
||||
cd image-recipe
|
||||
sudo bash build-auto-installer-iso.sh
|
||||
```
|
||||
|
||||
**IMPORTANT**: Must capture LIVE SERVER state, not build from source.
|
||||
|
||||
### `build-debian-iso.sh` (DEPRECATED)
|
||||
Creates a live system ISO (boots into a live environment, doesn't install).
|
||||
**DO NOT USE** - This was causing the boot-to-prompt issue.
|
||||
|
||||
## Deployment to Dev Server
|
||||
|
||||
### Dev server access
|
||||
|
||||
- **Host:** `archipelago@192.168.1.228`
|
||||
- **Password:** `archipelago` — use this for deployment. For non-interactive sync/deploy from scripts or the agent, use: `sshpass -p "archipelago"` (e.g. `sshpass -p "archipelago" rsync ...` or prepend it to ssh/rsync when running `./scripts/deploy-to-target.sh` or equivalent).
|
||||
- **Build approach:** We build **directly on the server** by SSHing in and running `cargo build --release` there. Do not build the backend on macOS and copy the binary.
|
||||
|
||||
### ⚠️ CRITICAL: Backend Compilation Architecture
|
||||
|
||||
**NEVER compile the Rust backend on macOS and deploy to Linux!**
|
||||
|
||||
The dev server (`192.168.1.228`) is **x86_64 Linux (Debian 12)**. Binaries compiled on macOS (even with cross-compilation) can cause "Exec format error" due to:
|
||||
- Different architecture (macOS ARM64/Intel vs Linux x86_64)
|
||||
- Different libc (macOS vs glibc)
|
||||
- Different system call interfaces
|
||||
|
||||
**ALWAYS build the backend directly on the Linux dev server.**
|
||||
|
||||
### Deployment Procedures
|
||||
|
||||
1. **Backend** (MUST build on Linux — use rsync then build on server):
|
||||
```bash
|
||||
# From project root. Sync source to server (exclude local target/.git).
|
||||
sshpass -p "archipelago" rsync -avz --exclude target --exclude .git -e "ssh -o StrictHostKeyChecking=no" \
|
||||
core/ archipelago@192.168.1.228:~/archy/core/
|
||||
|
||||
# Build on server and deploy binary
|
||||
sshpass -p "archipelago" ssh -o StrictHostKeyChecking=no archipelago@192.168.1.228 \
|
||||
'source ~/.cargo/env && cd ~/archy/core/archipelago && cargo build --release && \
|
||||
sudo systemctl stop archipelago && \
|
||||
sudo cp ~/archy/core/target/release/archipelago /usr/local/bin/ && \
|
||||
sudo systemctl start archipelago'
|
||||
```
|
||||
**Do not** build the binary on macOS and copy it; always rsync source and build on the server.
|
||||
|
||||
2. **Frontend** (can build locally):
|
||||
```bash
|
||||
# Build locally (macOS is fine for frontend)
|
||||
cd neode-ui
|
||||
npm run build
|
||||
|
||||
# Deploy to server
|
||||
rsync -avz ../web/dist/neode-ui/ archipelago@192.168.1.228:/tmp/neode-ui-build/
|
||||
ssh archipelago@192.168.1.228 'sudo rm -rf /opt/archipelago/web-ui/* && sudo cp -r /tmp/neode-ui-build/* /opt/archipelago/web-ui/ && sudo chown -R www-data:www-data /opt/archipelago/web-ui'
|
||||
```
|
||||
|
||||
3. **Container Images** (Docker/Podman):
|
||||
```bash
|
||||
# Build locally and push to server
|
||||
cd docker/<app-name>
|
||||
podman build -t localhost/<app-name>:latest .
|
||||
podman save localhost/<app-name>:latest | ssh archipelago@192.168.1.228 'podman load'
|
||||
```
|
||||
|
||||
## CRITICAL RULES
|
||||
|
||||
### 🚨 NEVER VIOLATE THESE
|
||||
|
||||
1. **ALWAYS deploy to the live development server (192.168.1.228)** for testing
|
||||
2. **After every change: sync and build on the live server.** When you finish implementing a feature or fix, run the deploy script so the live server has the latest code. Command: `./scripts/deploy-to-target.sh --live` (from project root). If SSH is not available in the current environment, tell the user to run it locally. Do not skip this step. **App UIs** (e.g. `docker/lnd-ui/`, `docker/bitcoin-ui/`) are served by their own containers; the deploy script rebuilds the LND UI image and restarts its container so changes to the LND UI are visible after deploy.
|
||||
3. **🔴 NEVER EVER compile the Rust backend on macOS and deploy to Linux**
|
||||
- Dev server is `x86_64 Linux (Debian 12)`
|
||||
- Always build backend **ON the Linux server** using `source ~/.cargo/env && cargo build --release`
|
||||
- macOS binaries will cause "Exec format error" and break the system
|
||||
- Frontend (Vue.js) CAN be built on macOS - it's just HTML/CSS/JS
|
||||
4. **The ISO must capture the CURRENT STATE of the dev server**, not build from source
|
||||
5. **Frontend build output is in `web/dist/neode-ui/`**, NOT `neode-ui/dist/`
|
||||
6. **Nginx serves on port 80** and proxies backend on `localhost:5678`
|
||||
7. **App icons are in `neode-ui/public/assets/img/app-icons/`**
|
||||
8. **The auto-installer ISO is the ONLY way to deploy** - no live systems
|
||||
|
||||
## Testing Checklist
|
||||
|
||||
Before creating ISO:
|
||||
- [ ] Backend running on dev server (`curl http://192.168.1.228:5678/health`)
|
||||
- [ ] Frontend accessible (`curl http://192.168.1.228/`)
|
||||
- [ ] Web UI shows correct apps and icons
|
||||
- [ ] API calls working (check browser console)
|
||||
- [ ] All systemd services enabled and running
|
||||
|
||||
After flashing ISO:
|
||||
- [ ] ISO boots to installer menu
|
||||
- [ ] Auto-installer detects internal disk
|
||||
- [ ] Installation completes without errors
|
||||
- [ ] System reboots and shows Web UI URL
|
||||
- [ ] Web UI accessible at `http://<IP>`
|
||||
- [ ] Backend API responding
|
||||
- [ ] Apps visible in marketplace
|
||||
|
||||
## Common Issues
|
||||
|
||||
**Issue**: ISO boots to prompt instead of auto-starting
|
||||
- **Cause**: Using `build-debian-iso.sh` (live system) instead of `build-auto-installer-iso.sh`
|
||||
- **Fix**: Use correct auto-installer script
|
||||
|
||||
**Issue**: macOS backend binary on Linux server ("Exec format error")
|
||||
- **Cause**: Compiling Rust backend on macOS and copying to Linux server
|
||||
- **Symptom**: `systemd` service fails with "status=203/EXEC" and "Failed to execute: Exec format error"
|
||||
- **Why it happens**: Different architectures and system ABIs between macOS and Linux
|
||||
- **Fix**: **ALWAYS build the backend ON the Linux server**:
|
||||
```bash
|
||||
ssh archipelago@192.168.1.228
|
||||
cd ~/archy/core/archipelago
|
||||
source ~/.cargo/env
|
||||
cargo build --release
|
||||
sudo systemctl stop archipelago
|
||||
sudo cp ../target/release/archipelago /usr/local/bin/
|
||||
sudo systemctl start archipelago
|
||||
```
|
||||
- **Prevention**: Never use local `cargo build` for deployment - always build on target system
|
||||
|
||||
**Issue**: Frontend not updating on server
|
||||
- **Cause**: Building to wrong output directory or not deploying to correct Nginx root
|
||||
- **Fix**: Build to `web/dist/neode-ui/`, deploy to `/opt/archipelago/web-ui`
|
||||
|
||||
**Issue**: ISO doesn't have latest changes
|
||||
- **Cause**: Building from source instead of capturing live server state
|
||||
- **Fix**: Modify build script to snapshot dev server, not compile from scratch
|
||||
|
||||
## Next Steps
|
||||
|
||||
- [ ] Fix `build-auto-installer-iso.sh` to capture live server state
|
||||
- [ ] Create snapshot script for dev server
|
||||
- [ ] Document container image bundling process
|
||||
- [ ] Create automated testing framework
|
||||
- [ ] Set up CI/CD for ISO builds
|
||||
@@ -1,271 +0,0 @@
|
||||
---
|
||||
description: Development workflow and deployment practices for Archipelago
|
||||
alwaysApply: true
|
||||
---
|
||||
|
||||
# Archipelago Development Workflow
|
||||
|
||||
## Priority: Deploy-Test-Fix Loop
|
||||
|
||||
**This is the primary workflow. Follow it for every change.**
|
||||
|
||||
1. **Make the change** the user requests
|
||||
2. **SSH and build to live server** - Run `./scripts/deploy-to-target.sh --live` once done
|
||||
3. **Test that it works** - Verify apps launch: iframe for most apps, new tab for BTCPay/Home Assistant
|
||||
|
||||
## App Launcher (iframe + new tab fallback)
|
||||
|
||||
Most apps launch in the iframe overlay. BTCPay (port 23000) and Home Assistant (port 8123) set `X-Frame-Options` and don't support subpath proxying—they open in a new tab instead.
|
||||
4. **If broken, fix and repeat** - Debug, fix, redeploy, and test again until complete
|
||||
5. **End loop** only when everything works
|
||||
|
||||
Do not leave deployment or testing to the user. The agent has SSH access to perform all building and work on the live server.
|
||||
|
||||
## Deployment Strategy
|
||||
|
||||
**Always deploy to live system for testing** - The target device (192.168.1.228) is a development machine, so deploy changes directly to the live system rather than using dev servers.
|
||||
|
||||
**When making changes, always run deploy** - After editing code (frontend, backend, scripts, or configs), run `./scripts/deploy-to-target.sh --live` to sync, build, and deploy. Do not leave the user to deploy manually.
|
||||
|
||||
### Backend: build on server via rsync (never on macOS)
|
||||
- **Always** deploy backend by: (1) rsync `core/` to `archipelago@192.168.1.228:~/archy/core/`, then (2) SSH and run `cargo build --release` on the server, then copy binary to `/usr/local/bin/` and restart `archipelago.service`.
|
||||
- Use `sshpass -p 'EwPDR8q45l0Upx@'` for non-interactive rsync/SSH. The password is stored in `scripts/deploy-config.sh` (gitignored) and sourced by the deploy script automatically.
|
||||
- **Do not** build the Rust binary on macOS and copy it (causes Exec format error on Linux).
|
||||
|
||||
### Standard Deployment Command
|
||||
|
||||
```bash
|
||||
./scripts/deploy-to-target.sh --live
|
||||
```
|
||||
|
||||
This command:
|
||||
1. Syncs code from local Mac to remote target
|
||||
2. Builds frontend (Vue.js) and backend (Rust)
|
||||
3. Deploys to live paths:
|
||||
- Frontend: `/opt/archipelago/web-ui/`
|
||||
- Backend: `/usr/local/bin/archipelago`
|
||||
4. Restarts services (systemd + nginx)
|
||||
|
||||
### Deploy to Both Servers
|
||||
|
||||
```bash
|
||||
./scripts/deploy-to-target.sh --both
|
||||
```
|
||||
|
||||
Deploys to 192.168.1.228 first (builds there), then copies binary and web-ui to 192.168.1.198 (which has no rsync/cargo).
|
||||
|
||||
### Target Environment
|
||||
|
||||
- **Host**: archipelago@192.168.1.228 (primary), archipelago@192.168.1.198 (secondary)
|
||||
- **OS**: Debian-based server
|
||||
- **Container Runtime**: Podman (root context for system services)
|
||||
- **Web Server**: Nginx
|
||||
- **Backend**: Systemd service (`archipelago.service`) running as root
|
||||
|
||||
## SSH Access
|
||||
|
||||
**Current credentials**: `archipelago@192.168.1.228` with password `EwPDR8q45l0Upx@`
|
||||
|
||||
The deploy script sources `scripts/deploy-config.sh` (gitignored) which sets `ARCHIPELAGO_PASSWORD`. For manual SSH/rsync commands, use:
|
||||
```bash
|
||||
sshpass -p 'EwPDR8q45l0Upx@' ssh -o StrictHostKeyChecking=no archipelago@192.168.1.228
|
||||
```
|
||||
|
||||
If `sshpass` hangs, SSH may be rate-limited from too many connections. Wait 10-15 seconds and retry.
|
||||
|
||||
## Development Paths
|
||||
|
||||
### Local (Mac)
|
||||
- Project root: `/Users/dorian/Projects/archy`
|
||||
- Frontend: `neode-ui/`
|
||||
- Backend: `core/`
|
||||
- Scripts: `scripts/`
|
||||
- ISO Build: `image-recipe/`
|
||||
|
||||
### Remote (Target)
|
||||
- Dev directory: `~/archy/`
|
||||
- Live frontend: `/opt/archipelago/web-ui/`
|
||||
- Live backend: `/usr/local/bin/archipelago`
|
||||
- Data: `/var/lib/archipelago/`
|
||||
- Systemd service: `/etc/systemd/system/archipelago.service`
|
||||
- Nginx config: `/etc/nginx/sites-available/archipelago`
|
||||
|
||||
## App Icons
|
||||
|
||||
**Single source of truth**: `neode-ui/public/assets/img/app-icons/`
|
||||
|
||||
- All app icons live here. Do not duplicate icons elsewhere.
|
||||
- Naming: `{app-id}.{png|webp|svg}` (e.g. `fedimint.png`, `mempool.webp`)
|
||||
- References use `/assets/img/app-icons/{filename}`. Build outputs copy from this folder.
|
||||
- See `neode-ui/public/assets/img/app-icons/README.md` for details.
|
||||
|
||||
## App Integration Standards
|
||||
|
||||
**When adding or fixing apps, always verify end-to-end:**
|
||||
|
||||
1. **Test the app UI on its port** - After getting an app working, confirm the web UI loads at its configured port (e.g. `http://192.168.1.228:4080` for Mempool).
|
||||
2. **Auto-connect dependencies** - Apps must connect to their dependencies on installation:
|
||||
- **Bitcoin node**: LND, Fedimint, BTCPay Server, Mempool all need Bitcoin RPC (host.containers.internal:8332 or bitcoin-knots container).
|
||||
- **LND**: BTCPay Server and other Lightning apps need LND connection.
|
||||
3. **Works out of the box** - After autoinstaller flash, apps should work without manual configuration. Ensure `get_app_config()` in `core/archipelago/src/api/rpc.rs` has correct env vars for each app.
|
||||
|
||||
## Testing Workflow
|
||||
|
||||
1. Make changes locally
|
||||
2. Deploy with `--live` flag
|
||||
3. Test at http://192.168.1.228
|
||||
4. **Verify each modified app**: Open its UI URL and confirm it loads and connects to dependencies
|
||||
5. **Test with Cursor browser MCP** (when available): After app installs or fixes, use the browser MCP to open the app URL, check for console errors (502, WebSocket failures, etc.), debug, fix, redeploy, and repeat until working.
|
||||
6. Check logs if needed:
|
||||
- Backend: `ssh archipelago@192.168.1.228 'sudo journalctl -u archipelago -f'`
|
||||
- Nginx: `ssh archipelago@192.168.1.228 'sudo tail -f /var/log/nginx/error.log'`
|
||||
5. **Sync changes back to ISO build** (see below)
|
||||
|
||||
## Running Containers
|
||||
|
||||
Check container status:
|
||||
```bash
|
||||
ssh archipelago@192.168.1.228 'sudo podman ps'
|
||||
```
|
||||
|
||||
Common containers:
|
||||
- Home Assistant (port 8123)
|
||||
- Bitcoin Knots (ports 8332, 8333)
|
||||
- LND (ports 9735, 10009)
|
||||
|
||||
## ISO Build Debug Workflow (Flash-and-Debug)
|
||||
|
||||
**Primary way to improve ISO builds.** After flashing a new machine from the ISO, SSH in and diagnose. Fix issues in the build, rebuild ISO, reflash, repeat.
|
||||
|
||||
### Debug a Fresh ISO Install
|
||||
|
||||
1. **Flash** the ISO to a test machine (e.g. 192.168.1.198)
|
||||
2. **SSH** after first boot (default ISO password is `archipelago`, dev server uses `EwPDR8q45l0Upx@`):
|
||||
```bash
|
||||
ssh-keygen -R 192.168.1.198 # if host key changed after reflash
|
||||
sshpass -p "archipelago" ssh -o StrictHostKeyChecking=no archipelago@192.168.1.198
|
||||
```
|
||||
3. **Run diagnostics** to find issues:
|
||||
```bash
|
||||
# Services
|
||||
systemctl is-active archipelago nginx
|
||||
# Containers
|
||||
sudo podman ps -a
|
||||
# Tor hostname (backend needs this for peer discovery)
|
||||
sudo cat /var/lib/archipelago/tor/hidden_service_archipelago/hostname
|
||||
sudo -u archipelago cat /var/lib/archipelago/tor/hidden_service_archipelago/hostname 2>&1 # should NOT be "Permission denied"
|
||||
# Backend logs
|
||||
sudo journalctl -u archipelago -n 50
|
||||
# Nginx errors
|
||||
sudo tail -20 /var/log/nginx/error.log
|
||||
# RPC reachable?
|
||||
curl -s -X POST http://127.0.0.1:5678/rpc/v1 -H "Content-Type: application/json" -d '{"jsonrpc":"2.0","id":1,"method":"echo","params":{}}'
|
||||
```
|
||||
4. **Fix** issues in `image-recipe/build-auto-installer-iso.sh`, scripts, or configs
|
||||
5. **Rebuild** ISO, **reflash**, **re-diagnose** until clean
|
||||
|
||||
### Common ISO Issues to Check
|
||||
|
||||
| Issue | Check | Fix |
|
||||
|-------|-------|-----|
|
||||
| Tor hostname unreadable | `sudo -u archipelago cat .../hostname` | setup-tor.sh must chmod 711 on tor dir + hidden_service_* dirs, 644 on hostname files |
|
||||
| Node not discoverable | Tor hostname + Nostr publish | Fix Tor perms so node_address is set |
|
||||
| RPC timeouts | nginx error.log | Increase proxy timeouts or optimize slow RPCs |
|
||||
| Missing containers | `sudo podman ps -a` | ISO is minimal; apps install from marketplace |
|
||||
| bitcoin-ui 404 | Port 8334 not listening | Add bitcoin-ui to first-boot or document |
|
||||
|
||||
## ISO Build Integration
|
||||
|
||||
**CRITICAL**: After testing on the live server, always update the ISO build to include your changes.
|
||||
|
||||
### Building the ISO
|
||||
|
||||
**Recommended**: Build on the target server (has all dependencies):
|
||||
|
||||
```bash
|
||||
# SSH to target server
|
||||
ssh archipelago@192.168.1.228
|
||||
|
||||
# Navigate to project
|
||||
cd ~/archy/image-recipe
|
||||
|
||||
# Run build with sudo (auto-installs missing deps like xorriso)
|
||||
sudo ./build-auto-installer-iso.sh
|
||||
|
||||
# The ISO will be at: results/archipelago-auto-installer-*.iso
|
||||
|
||||
# Copy back to Mac
|
||||
# On your Mac:
|
||||
scp archipelago@192.168.1.228:~/archy/image-recipe/results/archipelago-auto-installer-*.iso .
|
||||
```
|
||||
|
||||
**Alternative**: Build from Mac (requires Docker Desktop installed).
|
||||
|
||||
### Common ISO Build Issues
|
||||
|
||||
- **Missing xorriso**: Run with `sudo` to auto-install, or: `sudo apt install -y xorriso`
|
||||
- **Missing podman**: Run with `sudo` to auto-install, or: `sudo apt install -y podman`
|
||||
- **No Docker on Mac**: Either install Docker Desktop or build on target server (recommended)
|
||||
|
||||
### System Configuration Files to Sync
|
||||
|
||||
When you make system-level changes on the live server, capture them for the ISO build:
|
||||
|
||||
1. **Systemd Service** (`/etc/systemd/system/archipelago.service`)
|
||||
- Location in repo: `image-recipe/configs/archipelago.service`
|
||||
- Capture command: `ssh archipelago@192.168.1.228 'sudo cat /etc/systemd/system/archipelago.service' > image-recipe/configs/archipelago.service`
|
||||
|
||||
2. **Nginx Configuration** (`/etc/nginx/sites-available/archipelago`)
|
||||
- Location in repo: `image-recipe/configs/nginx-archipelago.conf`
|
||||
- Capture command: `ssh archipelago@192.168.1.228 'sudo cat /etc/nginx/sites-available/archipelago' > image-recipe/configs/nginx-archipelago.conf`
|
||||
|
||||
3. **Other System Files**
|
||||
- Logrotate: `image-recipe/configs/logrotate.conf`
|
||||
- Any new scripts in `/opt/archipelago/scripts/`
|
||||
|
||||
### Build Process Checklist
|
||||
|
||||
Before building a new ISO, ensure:
|
||||
|
||||
- [ ] Latest backend built: `cd image-recipe && ./scripts/build-backend.sh`
|
||||
- [ ] Latest frontend built: `cd image-recipe && ./scripts/build-frontend.sh`
|
||||
- [ ] System configs synced from live server
|
||||
- [ ] Integration script updated: `./integrate-archipelago.sh`
|
||||
- [ ] ISO built: `./build-debian-iso.sh`
|
||||
- [ ] ISO tested in QEMU: `./test-iso-qemu.sh`
|
||||
|
||||
### Key Configuration Values
|
||||
|
||||
**Backend Service (archipelago.service)**:
|
||||
- **User**: `root` (required to access root Podman containers)
|
||||
- **Environment**:
|
||||
- `ARCHIPELAGO_BIND=0.0.0.0:5678`
|
||||
- `ARCHIPELAGO_DEV_MODE=true` (for container auto-detection)
|
||||
|
||||
**Nginx Configuration**:
|
||||
- Serves frontend from `/opt/archipelago/web-ui`
|
||||
- Proxies `/rpc/` to backend at `127.0.0.1:5678`
|
||||
- Proxies `/ws` for WebSocket connections
|
||||
|
||||
### Deployment Paths in ISO
|
||||
|
||||
The ISO build must install files to:
|
||||
- `/usr/local/bin/archipelago` - Backend binary
|
||||
- `/opt/archipelago/web-ui/` - Frontend files
|
||||
- `/etc/systemd/system/archipelago.service` - Service definition
|
||||
- `/etc/nginx/sites-available/archipelago` - Nginx config
|
||||
- `/opt/archipelago/` - Base directory for scripts and data
|
||||
|
||||
## Common Issues
|
||||
|
||||
### Container Detection
|
||||
- Containers must be in **root Podman context** (started with `sudo podman`)
|
||||
- Backend must run as **root** to see root containers
|
||||
- Check: `sudo podman ps` (should show containers)
|
||||
- Check: `podman ps` (should be empty if using root containers)
|
||||
|
||||
### Service Not Starting
|
||||
- Check systemd status: `sudo systemctl status archipelago`
|
||||
- Check logs: `sudo journalctl -u archipelago -n 50`
|
||||
- Verify binary: `ls -lh /usr/local/bin/archipelago`
|
||||
- Test manually: `sudo /usr/local/bin/archipelago`
|
||||
@@ -1,355 +0,0 @@
|
||||
# Archipelago UI Standards & Coding Rules
|
||||
|
||||
## Core Design System
|
||||
|
||||
Archipelago uses a **glassmorphism-based design system** with dark backgrounds, subtle transparency, and elegant blur effects. All UI components should follow these established patterns.
|
||||
|
||||
---
|
||||
|
||||
## Standard Interactive Card: `.path-option-card`
|
||||
|
||||
**This is our PRIMARY interactive card component.** Use this pattern for all selectable/clickable card containers.
|
||||
|
||||
### Base Styles
|
||||
```css
|
||||
.path-option-card {
|
||||
position: relative;
|
||||
background: rgba(0, 0, 0, 0.60);
|
||||
backdrop-filter: blur(24px);
|
||||
-webkit-backdrop-filter: blur(24px);
|
||||
box-shadow:
|
||||
0 8px 24px rgba(0, 0, 0, 0.45),
|
||||
inset 0 1px 0 rgba(255, 255, 255, 0.22);
|
||||
border-radius: 16px;
|
||||
padding: 12px 10px;
|
||||
transition: all 0.3s ease;
|
||||
cursor: pointer;
|
||||
border: none;
|
||||
}
|
||||
```
|
||||
|
||||
### Gradient Border Effect (Default - Subtle)
|
||||
```css
|
||||
.path-option-card::before {
|
||||
content: '';
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
border-radius: inherit;
|
||||
padding: 2px;
|
||||
background: linear-gradient(135deg, rgba(0, 0, 0, 0.8), transparent);
|
||||
-webkit-mask:
|
||||
linear-gradient(#fff 0 0) content-box,
|
||||
linear-gradient(#fff 0 0);
|
||||
-webkit-mask-composite: xor;
|
||||
mask-composite: exclude;
|
||||
pointer-events: none;
|
||||
}
|
||||
```
|
||||
|
||||
### Hover State
|
||||
```css
|
||||
.path-option-card:hover {
|
||||
transform: translateY(-2px);
|
||||
background: rgba(0, 0, 0, 0.35);
|
||||
box-shadow:
|
||||
0 12px 32px rgba(0, 0, 0, 0.6),
|
||||
inset 0 1px 0 rgba(255, 255, 255, 0.25);
|
||||
}
|
||||
|
||||
.path-option-card:hover::before {
|
||||
background: linear-gradient(135deg, rgba(255, 255, 255, 0.3), transparent);
|
||||
}
|
||||
```
|
||||
|
||||
### Selected State
|
||||
```css
|
||||
.path-option-card--selected {
|
||||
background: rgba(255, 255, 255, 0.12);
|
||||
box-shadow:
|
||||
0 12px 32px rgba(0, 0, 0, 0.6),
|
||||
0 0 30px rgba(255, 255, 255, 0.2),
|
||||
inset 0 1px 0 rgba(255, 255, 255, 0.35);
|
||||
transform: translateY(-2px);
|
||||
}
|
||||
|
||||
.path-option-card--selected::before {
|
||||
background: linear-gradient(135deg, rgba(255, 255, 255, 0.6), transparent);
|
||||
}
|
||||
```
|
||||
|
||||
### Icon Styling
|
||||
```css
|
||||
.path-option-card svg {
|
||||
color: rgba(255, 255, 255, 0.85);
|
||||
filter:
|
||||
drop-shadow(0 1px 1px rgba(255, 255, 255, 0.3))
|
||||
drop-shadow(0 2px 4px rgba(0, 0, 0, 0.8))
|
||||
drop-shadow(0 -1px 2px rgba(0, 0, 0, 0.6));
|
||||
stroke-width: 2.5;
|
||||
}
|
||||
|
||||
.path-option-card:hover svg {
|
||||
color: rgba(255, 255, 255, 1);
|
||||
filter:
|
||||
drop-shadow(0 1px 2px rgba(255, 255, 255, 0.5))
|
||||
drop-shadow(0 3px 6px rgba(0, 0, 0, 0.9));
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Button Standards
|
||||
|
||||
### Primary Action Button: `.gradient-button`
|
||||
Use for main actions like **Launch**, **Install**, **Save**, **Submit**
|
||||
|
||||
```css
|
||||
.gradient-button {
|
||||
background: linear-gradient(135deg, rgba(255, 255, 255, 0.15) 0%, rgba(0, 0, 0, 0.8) 100%);
|
||||
backdrop-filter: blur(10px);
|
||||
border: 1px solid rgba(255, 255, 255, 0.2);
|
||||
color: rgba(255, 255, 255, 0.95);
|
||||
transition: all 0.3s ease;
|
||||
}
|
||||
|
||||
.gradient-button:hover {
|
||||
background: linear-gradient(135deg, rgba(255, 255, 255, 0.2) 0%, rgba(0, 0, 0, 0.9) 100%);
|
||||
border-color: rgba(255, 255, 255, 0.3);
|
||||
box-shadow: 0 8px 24px rgba(0, 0, 0, 0.6);
|
||||
}
|
||||
```
|
||||
|
||||
### Secondary Action Button: `.glass-button`
|
||||
Use for secondary actions like **Cancel**, **Close**, **Back**
|
||||
|
||||
```css
|
||||
.glass-button {
|
||||
background-color: rgba(0, 0, 0, 0.6);
|
||||
backdrop-filter: blur(18px);
|
||||
border: 1px solid rgba(255, 255, 255, 0.18);
|
||||
color: rgba(255, 255, 255, 0.9);
|
||||
transition: all 0.3s ease;
|
||||
}
|
||||
|
||||
.glass-button:hover {
|
||||
color: white;
|
||||
}
|
||||
```
|
||||
|
||||
### Path Action Button: `.path-action-button`
|
||||
Use for onboarding/path selection flows (**Continue**, **Skip**)
|
||||
|
||||
```css
|
||||
.path-action-button {
|
||||
font-size: 18px;
|
||||
font-weight: 500;
|
||||
border-radius: 16px;
|
||||
background: rgba(0, 0, 0, 0.25);
|
||||
color: rgba(255, 255, 255, 0.96);
|
||||
box-shadow:
|
||||
0 8px 24px rgba(0, 0, 0, 0.45),
|
||||
inset 0 1px 0 rgba(255, 255, 255, 0.22);
|
||||
backdrop-filter: blur(24px);
|
||||
border: none;
|
||||
cursor: pointer;
|
||||
transition: all 0.3s ease;
|
||||
}
|
||||
|
||||
.path-action-button:hover {
|
||||
transform: translateY(-2px);
|
||||
background: rgba(0, 0, 0, 0.35);
|
||||
box-shadow:
|
||||
0 12px 32px rgba(0, 0, 0, 0.6),
|
||||
inset 0 1px 0 rgba(255, 255, 255, 0.25);
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Container Standards
|
||||
|
||||
### Glass Card: `.glass-card`
|
||||
Use for content containers, modals, panels
|
||||
|
||||
```css
|
||||
.glass-card {
|
||||
background-color: rgba(0, 0, 0, 0.65);
|
||||
backdrop-filter: blur(18px);
|
||||
-webkit-backdrop-filter: blur(18px);
|
||||
border: 1px solid rgba(255, 255, 255, 0.18);
|
||||
box-shadow: 0 8px 24px rgba(0, 0, 0, 0.45);
|
||||
border-radius: 1rem;
|
||||
overflow-x: hidden;
|
||||
overflow-y: visible;
|
||||
}
|
||||
```
|
||||
|
||||
### Gradient Card: `.gradient-card`
|
||||
Use for featured content, highlighted sections
|
||||
|
||||
```css
|
||||
.gradient-card {
|
||||
background: linear-gradient(135deg, rgba(255, 255, 255, 0.1) 0%, rgba(0, 0, 0, 0.8) 100%);
|
||||
backdrop-filter: blur(18px);
|
||||
border: 1px solid rgba(255, 255, 255, 0.15);
|
||||
box-shadow: 0 8px 24px rgba(0, 0, 0, 0.45);
|
||||
border-radius: 1rem;
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Color Palette
|
||||
|
||||
### Primary Colors
|
||||
- **White text**: `rgba(255, 255, 255, 0.9)` (primary)
|
||||
- **White text hover**: `rgba(255, 255, 255, 1)` (full white)
|
||||
- **Muted text**: `rgba(255, 255, 255, 0.6)` - `rgba(255, 255, 255, 0.7)`
|
||||
|
||||
### Background Colors
|
||||
- **Dark overlay**: `rgba(0, 0, 0, 0.8)` - `rgba(0, 0, 0, 0.9)`
|
||||
- **Glass background**: `rgba(0, 0, 0, 0.6)` - `rgba(0, 0, 0, 0.65)`
|
||||
- **Light glass**: `rgba(0, 0, 0, 0.35)`
|
||||
|
||||
### Border Colors
|
||||
- **Subtle border**: `rgba(255, 255, 255, 0.18)`
|
||||
- **Prominent border**: `rgba(255, 255, 255, 0.2)` - `rgba(255, 255, 255, 0.3)`
|
||||
|
||||
### Accent Colors
|
||||
- **Orange** (Bitcoin/sync): `#fb923c` - `#f59e0b`
|
||||
- **Green** (success): `#4ade80`
|
||||
- **Red** (danger): `#ef4444`
|
||||
- **Blue** (info): `#3b82f6`
|
||||
|
||||
---
|
||||
|
||||
## Animation Standards
|
||||
|
||||
### Transitions
|
||||
- **Standard**: `all 0.3s ease`
|
||||
- **Fast**: `all 0.15s ease`
|
||||
- **Slow**: `all 0.5s ease-in-out`
|
||||
|
||||
### Transform on Hover
|
||||
```css
|
||||
transform: translateY(-2px);
|
||||
```
|
||||
|
||||
### Transform on Active/Click
|
||||
```css
|
||||
transform: translateY(1px);
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Blur Effects
|
||||
|
||||
- **Standard blur**: `blur(18px)`
|
||||
- **Strong blur**: `blur(24px)` - `blur(40px)`
|
||||
- **Light blur**: `blur(10px)`
|
||||
|
||||
---
|
||||
|
||||
## Shadow Standards
|
||||
|
||||
### Card Shadows
|
||||
```css
|
||||
/* Default */
|
||||
box-shadow: 0 8px 24px rgba(0, 0, 0, 0.45);
|
||||
|
||||
/* Hover */
|
||||
box-shadow: 0 12px 32px rgba(0, 0, 0, 0.6);
|
||||
|
||||
/* With inset highlight */
|
||||
box-shadow:
|
||||
0 8px 24px rgba(0, 0, 0, 0.45),
|
||||
inset 0 1px 0 rgba(255, 255, 255, 0.22);
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Icon Guidelines
|
||||
|
||||
### Icon Shadow Effects
|
||||
```css
|
||||
filter:
|
||||
drop-shadow(0 1px 1px rgba(255, 255, 255, 0.3))
|
||||
drop-shadow(0 2px 4px rgba(0, 0, 0, 0.8))
|
||||
drop-shadow(0 -1px 2px rgba(0, 0, 0, 0.6));
|
||||
```
|
||||
|
||||
### Icon Colors
|
||||
- **Default**: `rgba(255, 255, 255, 0.85)`
|
||||
- **Hover**: `rgba(255, 255, 255, 1)`
|
||||
- **Muted**: `rgba(255, 255, 255, 0.6)`
|
||||
|
||||
### Stroke Width
|
||||
- **Standard**: `2.5`
|
||||
- **Thin**: `2`
|
||||
- **Bold**: `3`
|
||||
|
||||
---
|
||||
|
||||
## Usage Rules
|
||||
|
||||
### DO:
|
||||
✅ Use `.path-option-card` for all interactive/selectable cards
|
||||
✅ Use `.gradient-button` for primary actions
|
||||
✅ Use `.glass-card` for content containers
|
||||
✅ Add subtle `translateY(-2px)` on hover
|
||||
✅ Use `backdrop-filter: blur()` for glass effects
|
||||
✅ Include inset highlights: `inset 0 1px 0 rgba(255, 255, 255, 0.22)`
|
||||
✅ Use gradient borders with CSS masks for subtle elevation
|
||||
✅ Maintain 0.3s ease transitions for smooth interactions
|
||||
|
||||
### DON'T:
|
||||
❌ Create custom card styles - extend existing ones
|
||||
❌ Use solid backgrounds - always use transparency + blur
|
||||
❌ Ignore hover states - all interactive elements need hover feedback
|
||||
❌ Mix different border styles - use gradient mask or single border
|
||||
❌ Use hard shadows - keep shadows soft with blur
|
||||
❌ Forget `-webkit-backdrop-filter` for Safari support
|
||||
|
||||
---
|
||||
|
||||
## Responsive Considerations
|
||||
|
||||
### Mobile Adjustments
|
||||
- Reduce padding by ~25% on small screens
|
||||
- Reduce blur slightly for performance (`blur(12px)` instead of `blur(18px)`)
|
||||
- Simplify animations (consider `prefers-reduced-motion`)
|
||||
- Touch targets minimum 44x44px
|
||||
|
||||
### Breakpoints
|
||||
```css
|
||||
/* Mobile first */
|
||||
sm: 640px /* Small tablets */
|
||||
md: 768px /* Tablets */
|
||||
lg: 1024px /* Desktops */
|
||||
xl: 1280px /* Large desktops */
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Accessibility
|
||||
|
||||
- Ensure sufficient contrast (WCAG AA minimum)
|
||||
- Include `:focus-visible` states matching `:hover`
|
||||
- Use semantic HTML (`<button>`, `<nav>`, etc.)
|
||||
- Include ARIA labels where needed
|
||||
- Support keyboard navigation
|
||||
|
||||
---
|
||||
|
||||
## File Locations
|
||||
|
||||
- **Global styles**: `/neode-ui/src/style.css`
|
||||
- **Component styles**: Scoped `<style>` blocks in `.vue` files
|
||||
- **Tailwind config**: `/neode-ui/tailwind.config.js`
|
||||
- **Assets**: `/neode-ui/public/assets/`
|
||||
|
||||
---
|
||||
|
||||
## Version
|
||||
Last updated: 2026-02-03
|
||||
Archipelago UI Standards v1.0
|
||||
@@ -1,751 +0,0 @@
|
||||
# Archipelago Development Rules
|
||||
|
||||
**Mission**: Build a production-ready, open-source Bitcoin Node OS that's secure, minimal, and user-friendly from day one.
|
||||
|
||||
**Philosophy**: Code in development should mirror production quality. Write it right the first time.
|
||||
|
||||
---
|
||||
|
||||
## Table of Contents
|
||||
1. [Project Structure & Location](#project-structure--location)
|
||||
2. [Open Source & Licensing](#open-source--licensing)
|
||||
3. [Production-Ready Development](#production-ready-development)
|
||||
4. [Architecture & System Design](#architecture--system-design)
|
||||
5. [Backend Development (Rust)](#backend-development-rust)
|
||||
6. [Frontend Development (Vue.js)](#frontend-development-vuejs)
|
||||
7. [Container & Security](#container--security)
|
||||
8. [Code Quality & Testing](#code-quality--testing)
|
||||
9. [Documentation](#documentation)
|
||||
10. [Common Mistakes](#common-mistakes)
|
||||
|
||||
---
|
||||
|
||||
## Project Structure & Location
|
||||
|
||||
### CRITICAL: Workspace-Relative Paths Only
|
||||
- ❌ **NEVER** reference absolute user paths (`/Users/username/...`) in code, scripts, or documentation
|
||||
- ✅ **ALWAYS** use workspace-relative paths: `./`, `../`, or environment variables
|
||||
- ✅ All files must be created in the workspace, never in external directories
|
||||
- ✅ When copying from external sources, copy TO workspace, then update all references
|
||||
|
||||
### File Creation Rules
|
||||
- ✅ Create files directly in the workspace using relative paths
|
||||
- ❌ Never assume files exist elsewhere - check first, create if missing
|
||||
- ✅ Use environment variables for paths that change between environments
|
||||
- ✅ Document all path dependencies in README or setup guides
|
||||
|
||||
---
|
||||
|
||||
## Open Source & Licensing
|
||||
|
||||
### License Compliance
|
||||
- ✅ Project is **open source** under [specify license: MIT/Apache 2.0/GPL]
|
||||
- ✅ All dependencies must be compatible with our license
|
||||
- ✅ Check license compatibility before adding dependencies
|
||||
- ✅ Document all third-party licenses in `LICENSES.md` or `THIRD_PARTY_NOTICES.md`
|
||||
|
||||
### Third-Party Code
|
||||
- ✅ Use permissive licenses (MIT, Apache 2.0, BSD) when possible
|
||||
- ⚠️ Be cautious with GPL/AGPL dependencies (viral licensing)
|
||||
- ✅ Always include license headers in source files
|
||||
- ✅ Document attribution for copied/adapted code
|
||||
|
||||
### Community Standards
|
||||
- ✅ Follow [Contributor Covenant](https://www.contributor-covenant.org/) code of conduct
|
||||
- ✅ Provide clear CONTRIBUTING.md with guidelines
|
||||
- ✅ Use semantic versioning (SemVer) for releases
|
||||
- ✅ Maintain comprehensive changelog (CHANGELOG.md)
|
||||
- ✅ Accept community contributions via pull requests
|
||||
- ✅ Respond to issues and PRs within reasonable timeframes
|
||||
|
||||
### Open Source Best Practices
|
||||
- ✅ Never commit secrets, API keys, or credentials
|
||||
- ✅ Use `.gitignore` to exclude sensitive/generated files
|
||||
- ✅ Keep commit messages clear and descriptive
|
||||
- ✅ Write documentation as if explaining to new contributors
|
||||
- ✅ Include setup/installation scripts for easy onboarding
|
||||
|
||||
---
|
||||
|
||||
## Production-Ready Development
|
||||
|
||||
### Development = Production Mindset
|
||||
- 🎯 **CRITICAL**: Write production-quality code from the start
|
||||
- ✅ No "TODO: Fix before production" comments - fix it now
|
||||
- ✅ No hardcoded values - use configuration from day one
|
||||
- ✅ No "works on my machine" - test in clean environments
|
||||
- ✅ Security is NOT optional - implement it in development
|
||||
|
||||
### Configuration Management
|
||||
- ✅ Use `.env` files for environment-specific configuration
|
||||
- ✅ Provide `.env.example` with all required variables
|
||||
- ✅ Never commit `.env` files to git
|
||||
- ✅ Validate configuration at startup with clear error messages
|
||||
- ✅ Support multiple environments: dev, staging, production
|
||||
|
||||
### Infrastructure as Code
|
||||
- ✅ All infrastructure should be reproducible from code
|
||||
- ✅ Container definitions = production-ready from first commit
|
||||
- ✅ Scripts should work on fresh systems (document prerequisites)
|
||||
- ✅ Use Alpine Linux base for containers (production-ready minimal OS)
|
||||
- ✅ Test multi-arch builds early (ARM64, x86_64)
|
||||
|
||||
### Development Environments
|
||||
- ✅ Provide dev containers or Docker Compose setups
|
||||
- ✅ Mock external services for local development
|
||||
- ✅ Minimize differences between dev and production
|
||||
- ✅ Document all system prerequisites clearly
|
||||
- ✅ Use version managers for language runtimes (rustup, nvm)
|
||||
|
||||
### Continuous Integration Preparation
|
||||
- ✅ Write code that can be automatically tested
|
||||
- ✅ Keep builds fast (parallelize, cache dependencies)
|
||||
- ✅ Lint and format code automatically
|
||||
- ✅ Run security checks on dependencies
|
||||
- ✅ Test on multiple platforms (Linux, macOS, ARM64)
|
||||
|
||||
---
|
||||
|
||||
## Design System & Styling
|
||||
|
||||
### Tailwind CSS Rules
|
||||
- ✅ **ALWAYS** create global utility classes in `neode-ui/src/style.css` or a dedicated `tailwind.css`
|
||||
- ❌ **NEVER** use inline Tailwind classes directly in components
|
||||
- ✅ Create semantic class names: `.glass-card`, `.glass-button`, `.nav-tab-active`
|
||||
- ✅ Use CSS variables for design tokens: `--color-primary`, `--spacing-base`
|
||||
|
||||
### Design Standards (From Memory)
|
||||
- **Font**: Avenir Next font family (preferred)
|
||||
- **Padding**: 4px grid system, 16px default padding
|
||||
- **Containers**: iOS-style glassmorphism
|
||||
- Background: `rgba(255,255,255,0.15)`
|
||||
- Backdrop blur: `20px`
|
||||
- Subtle white borders
|
||||
- **Backgrounds**: Persistent background images (not dark themes)
|
||||
- **Animations**: Smooth 2s splash screens with logo draw/glitch animations
|
||||
|
||||
### Example Global Classes
|
||||
```css
|
||||
.glass-card {
|
||||
background: rgba(255, 255, 255, 0.15);
|
||||
backdrop-filter: blur(20px);
|
||||
-webkit-backdrop-filter: blur(20px);
|
||||
border: 1px solid rgba(255, 255, 255, 0.2);
|
||||
border-radius: 16px;
|
||||
padding: 16px; /* 4px grid */
|
||||
}
|
||||
|
||||
.glass-button {
|
||||
background: rgba(255, 255, 255, 0.1);
|
||||
backdrop-filter: blur(10px);
|
||||
border: 1px solid rgba(255, 255, 255, 0.15);
|
||||
transition: all 0.2s ease;
|
||||
}
|
||||
|
||||
.glass-button:hover {
|
||||
background: rgba(255, 255, 255, 0.2);
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Architecture & System Design
|
||||
|
||||
### Docker & Podman Architecture
|
||||
- ✅ **Development**: Use Docker Compose with official Docker images
|
||||
- ✅ **Production**: Use Podman with same Docker images on Alpine Linux
|
||||
- ✅ **ALWAYS** use standard Docker Hub images (never proprietary formats)
|
||||
- ✅ Use our own container orchestration (`core/container/`)
|
||||
- ✅ Use our own security modules (`core/security/`)
|
||||
- ✅ Use our own performance modules (`core/performance/`)
|
||||
|
||||
### Backend Architecture
|
||||
- ✅ Use `archipelago-container` crate for container management
|
||||
- ✅ Use our RPC endpoints in `core/archipelago/src/`
|
||||
- ✅ For development: Use mock backend for UI work when possible
|
||||
- ✅ All new features must use our modules (`archipelago-*` crates)
|
||||
- ✅ Build Archipelago-native implementations, not wrappers
|
||||
|
||||
### System Architecture Principles
|
||||
- ✅ **Alpine Linux Base**: 130MB minimal, secure, multi-arch
|
||||
- ✅ **Podman Only**: Rootless containers, no Docker dependencies
|
||||
- ✅ **Manifest-Driven**: All apps defined by YAML manifests
|
||||
- ✅ **Security First**: Read-only filesystems, capability dropping, network isolation
|
||||
- ✅ **Dependency Resolution**: Automatic dependency management between apps
|
||||
- ✅ **Health Monitoring**: Built-in health checks and auto-restart
|
||||
|
||||
### Multi-Architecture Support
|
||||
- ✅ Support both ARM64 (Raspberry Pi) and x86_64 from day one
|
||||
- ✅ Test builds on both architectures regularly
|
||||
- ✅ Use multi-arch container images
|
||||
- ✅ Document architecture-specific differences
|
||||
|
||||
### Modular Design
|
||||
- ✅ Each crate in `core/` should be independent and reusable
|
||||
- ✅ Minimize coupling between modules
|
||||
- ✅ Define clear interfaces between components
|
||||
- ✅ Use traits for abstraction and testability
|
||||
|
||||
---
|
||||
|
||||
## Container & Security
|
||||
|
||||
### App Manifest Rules (Production Standards)
|
||||
- ✅ **ALWAYS** create manifests in `apps/{app-id}/manifest.yml`
|
||||
- ✅ Follow the manifest specification in `docs/app-manifest-spec.md`
|
||||
- ✅ Use semantic versioning: `MAJOR.MINOR.PATCH`
|
||||
- ✅ Include security policies, resource limits, health checks
|
||||
- ✅ Define explicit dependencies with version constraints
|
||||
- ✅ Include license information and attribution
|
||||
- ✅ Document configuration options clearly
|
||||
- ✅ Provide default values that are secure
|
||||
|
||||
### Container Orchestration
|
||||
- ✅ Use `archipelago_container::PodmanClient` for all container operations
|
||||
- ✅ Use `archipelago_container::AppManifest` for manifest parsing
|
||||
- ✅ Use `archipelago_container::DependencyResolver` for dependency management
|
||||
- ❌ Never use Docker directly - always use Podman via our client
|
||||
- ✅ Implement graceful shutdown (handle SIGTERM)
|
||||
- ✅ Set resource limits (CPU, memory, disk)
|
||||
- ✅ Monitor container health continuously
|
||||
|
||||
### Security First (CRITICAL - Production Requirement)
|
||||
- 🔒 **Security is NOT optional** - every container must be hardened
|
||||
|
||||
#### Container Security
|
||||
- ✅ **ALWAYS** set `readonly_root: true` unless explicitly needed
|
||||
- ✅ **ALWAYS** drop all capabilities, add only required ones
|
||||
- ✅ **ALWAYS** use isolated networks (never `host` network unless required)
|
||||
- ✅ **ALWAYS** run as non-root user (UID > 1000)
|
||||
- ✅ **ALWAYS** set `no-new-privileges: true`
|
||||
- ✅ Use AppArmor/SELinux profiles from `core/security/`
|
||||
- ✅ Implement seccomp profiles to restrict syscalls
|
||||
|
||||
#### Image Security
|
||||
- ✅ **ALWAYS** verify container images with Cosign signatures
|
||||
- ✅ Use official base images from trusted registries
|
||||
- ✅ Pin image versions (never use `latest` tag)
|
||||
- ✅ Scan images for vulnerabilities (Trivy, Grype)
|
||||
- ✅ Rebuild images regularly for security updates
|
||||
- ✅ Generate and publish SBOM (Software Bill of Materials)
|
||||
|
||||
#### Secrets Management
|
||||
- ✅ **NEVER** hardcode secrets in code or config files
|
||||
- ✅ Use encrypted secrets storage (`core/security/secrets_manager.rs`)
|
||||
- ✅ Inject secrets at runtime only (environment variables or mounted files)
|
||||
- ✅ Rotate secrets regularly
|
||||
- ✅ Use minimal secret scopes (principle of least privilege)
|
||||
- ✅ Clear secrets from memory after use
|
||||
- ✅ Log secret access for audit trails (without logging values)
|
||||
|
||||
#### Network Security
|
||||
- ✅ Use isolated bridge networks per app
|
||||
- ✅ Implement firewall rules (iptables/nftables)
|
||||
- ✅ Rate limit API endpoints
|
||||
- ✅ Use TLS for all external communication
|
||||
- ✅ Support Tor for privacy-sensitive apps
|
||||
- ✅ Implement intrusion detection (fail2ban)
|
||||
|
||||
#### Data Security
|
||||
- ✅ Encrypt sensitive data at rest
|
||||
- ✅ Use encrypted volumes for secrets
|
||||
- ✅ Implement secure backup/restore
|
||||
- ✅ Sanitize logs (no secrets in logs)
|
||||
- ✅ Implement data retention policies
|
||||
- ✅ Support secure data deletion
|
||||
|
||||
---
|
||||
|
||||
## Frontend Development (Vue.js)
|
||||
|
||||
### Vue.js Component Rules
|
||||
- ✅ Use Composition API (`<script setup lang="ts">`) for all components
|
||||
- ✅ Use Pinia stores for state management
|
||||
- ✅ Use TypeScript for all components (no `.vue` with JS)
|
||||
- ✅ Create reusable components in `neode-ui/src/components/`
|
||||
- ✅ Use global Tailwind classes, not inline utilities
|
||||
|
||||
### Production-Ready Frontend Code
|
||||
- ✅ Handle loading states for all async operations
|
||||
- ✅ Handle error states with user-friendly messages
|
||||
- ✅ Implement retry logic for failed requests
|
||||
- ✅ Show loading skeletons, not just spinners
|
||||
- ✅ Debounce user inputs (search, filters)
|
||||
- ✅ Implement infinite scroll/pagination for large lists
|
||||
- ✅ Optimize images (WebP, lazy loading)
|
||||
- ✅ Use Vue's `Suspense` for async components
|
||||
|
||||
### API Client Rules
|
||||
- ✅ Use `neode-ui/src/api/rpc-client.ts` for RPC calls
|
||||
- ✅ Use `neode-ui/src/api/container-client.ts` for container operations
|
||||
- ✅ **NEVER** hardcode API endpoints - use environment variables
|
||||
- ✅ Implement request timeouts (default: 30s)
|
||||
- ✅ Retry failed requests with exponential backoff
|
||||
- ✅ Cancel in-flight requests when component unmounts
|
||||
- ✅ Handle errors gracefully with user-friendly messages
|
||||
- ✅ Log errors to monitoring service (in production)
|
||||
|
||||
### State Management (Production Standards)
|
||||
- ✅ Use Pinia stores for all application state
|
||||
- ✅ Keep stores focused and single-purpose
|
||||
- ✅ Use TypeScript interfaces for store state
|
||||
- ✅ Don't duplicate state - use computed properties
|
||||
- ✅ Persist auth state to localStorage/sessionStorage
|
||||
- ✅ Clear sensitive data on logout
|
||||
- ✅ Implement optimistic updates for better UX
|
||||
- ✅ Handle state hydration errors gracefully
|
||||
|
||||
### TypeScript Frontend Best Practices
|
||||
- ✅ Enable strict mode in `tsconfig.json`
|
||||
- ✅ Define interfaces for all API responses
|
||||
- ✅ Use type guards for runtime type checking
|
||||
- ✅ Avoid `any` - use `unknown` or proper types
|
||||
- ✅ Use discriminated unions for state machines
|
||||
- ✅ Export types from dedicated `.types.ts` files
|
||||
- ✅ Use Zod or similar for runtime validation
|
||||
|
||||
### Accessibility (A11y) - Production Requirement
|
||||
- ✅ All interactive elements must be keyboard accessible
|
||||
- ✅ Use semantic HTML (`<button>`, `<nav>`, `<main>`)
|
||||
- ✅ Include ARIA labels where needed
|
||||
- ✅ Maintain proper heading hierarchy (h1 → h2 → h3)
|
||||
- ✅ Ensure color contrast meets WCAG AA standards
|
||||
- ✅ Test with screen readers (VoiceOver, NVDA)
|
||||
- ✅ Support light/dark mode (via CSS variables)
|
||||
|
||||
### Performance Optimization
|
||||
- ✅ Lazy load routes and heavy components
|
||||
- ✅ Use `v-memo` for expensive list renders
|
||||
- ✅ Implement virtual scrolling for long lists
|
||||
- ✅ Minimize bundle size (analyze with `vite-bundle-visualizer`)
|
||||
- ✅ Use dynamic imports for code splitting
|
||||
- ✅ Optimize assets (images, fonts, icons)
|
||||
- ✅ Enable gzip/brotli compression in production
|
||||
|
||||
---
|
||||
|
||||
## Backend Development (Rust)
|
||||
|
||||
### Rust Code Organization
|
||||
- ✅ New modules go in `core/{module-name}/`
|
||||
- ✅ Use workspace structure: add to `core/Cargo.toml` members
|
||||
- ✅ Follow Rust naming conventions: `snake_case` for modules/files
|
||||
- ✅ Keep crates small and focused (single responsibility)
|
||||
- ✅ Use `lib.rs` for public APIs, keep implementation in separate files
|
||||
|
||||
### Production-Ready Rust Code
|
||||
- ✅ **No `unwrap()` or `expect()` in production code** - handle all errors properly
|
||||
- ✅ Use `?` operator for error propagation
|
||||
- ✅ Implement `Debug`, `Clone`, `PartialEq` where appropriate
|
||||
- ✅ Use `#[non_exhaustive]` for public enums/structs that may evolve
|
||||
- ✅ Add `#[must_use]` to functions whose return value should be checked
|
||||
- ✅ Use `#[inline]` for small hot-path functions
|
||||
|
||||
### Error Handling (Production Standards)
|
||||
- ✅ Use `thiserror` for library error types
|
||||
- ✅ Use `anyhow` for application-level error handling
|
||||
- ✅ Create custom error types per module: `{module}::Error`
|
||||
- ✅ Include context in errors: `.context("What failed and why")`
|
||||
- ✅ Return user-friendly error messages (no internal details)
|
||||
- ✅ Log errors with appropriate levels: `error!`, `warn!`, `info!`, `debug!`, `trace!`
|
||||
- ✅ Never expose stack traces to users (log internally only)
|
||||
|
||||
### RPC Endpoint Rules
|
||||
- ✅ Use `rpc_toolkit::command` macro for all endpoints
|
||||
- ✅ Use `#[context] ctx: RpcContext` for context
|
||||
- ✅ Use `#[arg]` for parameters with validation
|
||||
- ✅ Return `Result<T, Error>` for all endpoints
|
||||
- ✅ Validate all inputs before processing
|
||||
- ✅ Document endpoints with `///` doc comments
|
||||
- ✅ Include usage examples in documentation
|
||||
|
||||
### Async Rust Best Practices
|
||||
- ✅ Use `tokio` runtime consistently (don't mix with other runtimes)
|
||||
- ✅ Prefer `async/await` over manual futures
|
||||
- ✅ Use channels (`mpsc`, `oneshot`) for inter-task communication
|
||||
- ✅ Set timeouts on all external operations
|
||||
- ✅ Use `select!` for racing futures with timeouts
|
||||
- ✅ Handle shutdown gracefully with cancellation tokens
|
||||
|
||||
### Memory Safety & Performance
|
||||
- ✅ Minimize allocations in hot paths
|
||||
- ✅ Use `Arc` for shared ownership, `Rc` for single-threaded
|
||||
- ✅ Use `Cow` for potentially borrowed data
|
||||
- ✅ Prefer zero-copy when possible (slices, references)
|
||||
- ✅ Run `clippy` with `--all-targets --all-features`
|
||||
- ✅ Fix all clippy warnings before committing
|
||||
|
||||
### Testing (Production Standards)
|
||||
- ✅ Write unit tests for all public functions
|
||||
- ✅ Write integration tests for API endpoints
|
||||
- ✅ Use `#[cfg(test)]` for test-only code
|
||||
- ✅ Mock external dependencies (filesystem, network, time)
|
||||
- ✅ Test error cases, not just happy paths
|
||||
- ✅ Use property-based testing for complex logic (proptest)
|
||||
- ✅ Aim for >80% code coverage on core logic
|
||||
|
||||
### Logging & Observability
|
||||
- ✅ Use `tracing` for structured logging
|
||||
- ✅ Include context in log messages: `tracing::info!(user_id = %id, "Action")`
|
||||
- ✅ Use appropriate log levels consistently
|
||||
- ✅ Don't log sensitive data (passwords, keys, tokens)
|
||||
- ✅ Include request IDs for tracing across services
|
||||
- ✅ Emit metrics for monitoring (response times, error rates)
|
||||
|
||||
---
|
||||
|
||||
## Documentation
|
||||
|
||||
### Documentation Standards (Production Requirement)
|
||||
- 📖 **CRITICAL**: Documentation is as important as code
|
||||
|
||||
### Code Documentation
|
||||
- ✅ Document all public APIs (Rust `///`, JSDoc for TypeScript)
|
||||
- ✅ Include usage examples in documentation
|
||||
- ✅ Explain edge cases and error conditions
|
||||
- ✅ Document panics/unwraps (should be none in production)
|
||||
- ✅ Keep documentation in sync with code
|
||||
|
||||
### Project Documentation
|
||||
- ✅ Keep `README.md` up to date with installation instructions
|
||||
- ✅ Update `docs/` when adding features
|
||||
- ✅ Document architecture decisions (ADRs in `docs/architecture/`)
|
||||
- ✅ Maintain changelog (`CHANGELOG.md`) with every release
|
||||
- ✅ Document breaking changes prominently
|
||||
- ✅ Include troubleshooting guide (`docs/troubleshooting.md`)
|
||||
|
||||
### User Documentation
|
||||
- ✅ Write user-facing documentation for all features
|
||||
- ✅ Include screenshots/screencasts where helpful
|
||||
- ✅ Document configuration options with examples
|
||||
- ✅ Provide step-by-step tutorials
|
||||
- ✅ Keep FAQ updated with common questions
|
||||
|
||||
### API Documentation
|
||||
- ✅ Document all RPC endpoints with examples
|
||||
- ✅ Include request/response schemas
|
||||
- ✅ Document error codes and meanings
|
||||
- ✅ Provide API versioning strategy
|
||||
- ✅ Auto-generate API docs from code (cargo doc, TypeDoc)
|
||||
|
||||
### Contributing Documentation
|
||||
- ✅ Provide `CONTRIBUTING.md` with guidelines
|
||||
- ✅ Document development setup in detail
|
||||
- ✅ Explain project structure
|
||||
- ✅ Include code style guidelines
|
||||
- ✅ Document release process
|
||||
|
||||
---
|
||||
|
||||
## Development Workflow
|
||||
|
||||
### Backend: always build on the dev server (never on macOS)
|
||||
- **CRITICAL**: The Rust backend **must** be built **on the Linux dev server**, not on macOS. Deploy by **rsync then build**:
|
||||
1. **Rsync** source to server: `sshpass -p "archipelago" rsync -avz --exclude target --exclude .git -e "ssh -o StrictHostKeyChecking=no" core/ archipelago@192.168.1.228:~/archy/core/`
|
||||
2. **Build and deploy on server**: `sshpass -p "archipelago" ssh -o StrictHostKeyChecking=no archipelago@192.168.1.228 'source ~/.cargo/env && cd ~/archy/core/archipelago && cargo build --release && sudo systemctl stop archipelago && sudo cp ~/archy/core/target/release/archipelago /usr/local/bin/ && sudo systemctl start archipelago'`
|
||||
- When making backend changes, **action the build**: run the rsync + SSH build/deploy steps above. Do not build the binary locally and copy it (causes Exec format error on Linux).
|
||||
- Dev server: `archipelago@192.168.1.228`, password: `archipelago`.
|
||||
|
||||
### Scripts & Automation
|
||||
- ✅ All scripts in `scripts/` directory
|
||||
- ✅ Use `#!/usr/bin/env bash` for portability
|
||||
- ✅ Use `set -euo pipefail` (exit on error, undefined vars, pipe failures)
|
||||
- ✅ Check for prerequisites before running
|
||||
- ✅ Provide clear error messages with solutions
|
||||
- ✅ Use workspace-relative paths (never absolute)
|
||||
- ✅ Make scripts idempotent (safe to run multiple times)
|
||||
- ✅ Log what the script is doing (with timestamps)
|
||||
|
||||
### Dependency Management
|
||||
|
||||
#### Node.js & Dependencies
|
||||
- ⚠️ **Node.js Version**: Requires Node.js 20.19+ or 22.12+ for Vite 7
|
||||
- ✅ Use `nvm` or `fnm` for Node.js version management
|
||||
- ✅ Commit `package-lock.json` (ensures reproducible builds)
|
||||
- ✅ Use `npm ci` for CI/CD (clean install from lock file)
|
||||
- ✅ Run `npm audit` regularly and fix vulnerabilities
|
||||
- ✅ Keep dependencies up to date (use Dependabot/Renovate)
|
||||
- ✅ Document any dependencies that must be at specific versions
|
||||
|
||||
#### Rust Dependencies
|
||||
- ✅ Keep `Cargo.lock` committed (ensures reproducible builds)
|
||||
- ✅ Use `cargo update` carefully (test after updating)
|
||||
- ✅ Run `cargo audit` regularly for security vulnerabilities
|
||||
- ✅ Prefer well-maintained crates with active communities
|
||||
- ✅ Check license compatibility before adding dependencies
|
||||
- ✅ Document why specific versions are required
|
||||
|
||||
### Git Workflow
|
||||
- ✅ Use conventional commits: `feat:`, `fix:`, `docs:`, `refactor:`, `test:`
|
||||
- ✅ Write clear, descriptive commit messages
|
||||
- ✅ Keep commits atomic (one logical change per commit)
|
||||
- ✅ Rebase feature branches before merging
|
||||
- ✅ Never commit secrets, API keys, or credentials
|
||||
- ✅ Use `.gitignore` for generated files
|
||||
- ✅ Tag releases with semantic versions (`v1.2.3`)
|
||||
|
||||
### Branch Strategy
|
||||
- ✅ `main` branch is production-ready at all times
|
||||
- ✅ Feature branches: `feature/description`
|
||||
- ✅ Bug fixes: `fix/description`
|
||||
- ✅ Use pull requests for all changes
|
||||
- ✅ Require CI passing before merge
|
||||
- ✅ Delete branches after merging
|
||||
|
||||
---
|
||||
|
||||
## Common Mistakes
|
||||
|
||||
### ❌ NEVER DO:
|
||||
1. **Hardcode absolute paths** - Use workspace-relative paths
|
||||
2. **Use inline Tailwind classes** - Create global utility classes
|
||||
3. **Skip security policies** - Security is mandatory
|
||||
4. **Hardcode secrets/URLs** - Use environment variables
|
||||
5. **Use `unwrap()` in production** - Handle errors properly
|
||||
6. **Skip tests** - Test coverage is required
|
||||
7. **Commit secrets** - Use `.env` files (not committed)
|
||||
8. **Leave TODOs** - Fix now or create issues
|
||||
9. **Use `any` in TypeScript** - Use proper types
|
||||
10. **Ignore compiler warnings** - Fix all warnings
|
||||
11. **Use `latest` tag** - Pin specific versions
|
||||
12. **Run as root** - Use non-root users
|
||||
13. **Forget documentation** - Document as you code
|
||||
14. **Use proprietary package formats** - Use standard Docker images
|
||||
15. **Depend on external registries** - Host our own or use Docker Hub
|
||||
|
||||
### ✅ ALWAYS DO:
|
||||
1. **Build backend on the dev server** - Rsync `core/` to `archipelago@192.168.1.228:~/archy/core/`, then SSH in and run `cargo build --release` and deploy the binary. Never build the Rust binary on macOS for deployment.
|
||||
2. **Use workspace-relative paths** - Portable code
|
||||
3. **Create global Tailwind classes** - Consistent styling
|
||||
4. **Build Archipelago-native solutions** - Clean architecture
|
||||
5. **Include security in all containers** - Security first
|
||||
6. **Use environment variables** - Configurable deployments
|
||||
7. **Add modules to Cargo.toml** - Workspace coherence
|
||||
8. **Create reusable components** - DRY principle
|
||||
9. **Use Docker (dev) or Podman (prod)** - Standard containers
|
||||
10. **Handle all errors gracefully** - User-friendly messages
|
||||
11. **Follow the architecture plan** - Consistency
|
||||
12. **Write tests** - Prevent regressions
|
||||
13. **Document code** - Help future contributors
|
||||
14. **Review your own code** - Catch issues early
|
||||
15. **Run CI checks locally** - Before pushing
|
||||
16. **Think production first** - Build it right
|
||||
|
||||
## Architecture Adherence
|
||||
|
||||
### Stick to the Plan
|
||||
- ✅ Follow `docs/architecture.md` for system design
|
||||
- ✅ Use Alpine Linux base (not Ubuntu/Debian)
|
||||
- ✅ Use Podman (not Docker)
|
||||
- ✅ Use rootless containers
|
||||
- ✅ Implement security hardening
|
||||
- ✅ Support multi-arch (ARM64, x86_64)
|
||||
|
||||
### Container Orchestration
|
||||
- ✅ Use manifest-based app definitions
|
||||
- ✅ Implement dependency resolution
|
||||
- ✅ Monitor container health
|
||||
- ✅ Support Parmanode compatibility
|
||||
- ✅ Enable secrets management
|
||||
|
||||
### Future-Proofing
|
||||
- ✅ Design for time-travel snapshots
|
||||
- ✅ Plan for decentralized marketplace
|
||||
- ✅ Support multi-node clustering
|
||||
- ✅ Enable hardware attestation
|
||||
- ✅ Keep protocol-agnostic design
|
||||
|
||||
---
|
||||
|
||||
## Code Quality & Testing
|
||||
|
||||
### Code Quality Standards (Production Requirement)
|
||||
- 🎯 **CRITICAL**: All code must pass CI checks before merging
|
||||
- ✅ Zero compiler warnings (Rust and TypeScript)
|
||||
- ✅ Zero linter errors (clippy, eslint)
|
||||
- ✅ Consistent formatting (rustfmt, prettier)
|
||||
- ✅ No commented-out code in commits
|
||||
- ✅ Remove `TODO`/`FIXME` or create issues for them
|
||||
|
||||
### Rust Code Quality
|
||||
- ✅ Run `cargo clippy --all-targets --all-features` before commit
|
||||
- ✅ Run `cargo fmt --all` before commit
|
||||
- ✅ Run `cargo test --all-features` before commit
|
||||
- ✅ Use `#[deny(clippy::all)]` and `#[warn(clippy::pedantic)]` in lib.rs
|
||||
- ✅ Document all public APIs with `///` doc comments
|
||||
- ✅ Include usage examples in documentation
|
||||
- ✅ Use `#[derive(Debug)]` for all types where possible
|
||||
|
||||
### TypeScript Code Quality
|
||||
- ✅ Enable strict mode in `tsconfig.json`
|
||||
- ✅ Run `npm run lint` before commit
|
||||
- ✅ Run `npm run type-check` before commit
|
||||
- ✅ Fix all ESLint warnings, not just errors
|
||||
- ✅ Use Prettier for consistent formatting
|
||||
- ✅ Define interfaces for all data structures
|
||||
- ✅ Use type guards for runtime checks
|
||||
- ✅ Avoid `any` - use `unknown` or proper types
|
||||
|
||||
### General Code Quality
|
||||
- ✅ Keep functions small (<50 lines) and focused (single responsibility)
|
||||
- ✅ Use descriptive variable names (no `x`, `tmp`, `data`)
|
||||
- ✅ Comment WHY, not WHAT (code should be self-documenting)
|
||||
- ✅ Extract magic numbers to named constants
|
||||
- ✅ Remove dead code (don't comment it out)
|
||||
- ✅ Follow existing code style in the file
|
||||
- ✅ DRY principle: Don't Repeat Yourself (extract common logic)
|
||||
|
||||
### Testing (Production Requirement)
|
||||
- 🎯 **CRITICAL**: All features must have tests
|
||||
|
||||
#### Rust Testing
|
||||
- ✅ Write unit tests for all public functions
|
||||
- ✅ Write integration tests for API endpoints
|
||||
- ✅ Test error cases, not just happy paths
|
||||
- ✅ Use `#[cfg(test)]` for test-only code
|
||||
- ✅ Mock external dependencies (filesystem, network)
|
||||
- ✅ Test concurrency/race conditions
|
||||
- ✅ Use property-based testing for complex logic (proptest)
|
||||
- ✅ Aim for >80% code coverage on core logic
|
||||
|
||||
#### Frontend Testing
|
||||
- ✅ Test UI components with Vitest
|
||||
- ✅ Test user interactions (clicks, inputs)
|
||||
- ✅ Test accessibility (ARIA, keyboard navigation)
|
||||
- ✅ Test error states and edge cases
|
||||
- ✅ Mock API calls in component tests
|
||||
- ✅ Use snapshot testing sparingly (they break often)
|
||||
|
||||
#### Integration Testing
|
||||
- ✅ Test full user flows end-to-end
|
||||
- ✅ Test container lifecycle (install, start, stop, remove)
|
||||
- ✅ Test dependency resolution
|
||||
- ✅ Test backup/restore functionality
|
||||
- ✅ Test upgrade scenarios
|
||||
- ✅ Test multi-user scenarios (if applicable)
|
||||
|
||||
### Code Review Standards
|
||||
- ✅ All code must be reviewed by at least one other developer
|
||||
- ✅ Reviewer must test the changes locally
|
||||
- ✅ Check for security vulnerabilities
|
||||
- ✅ Verify tests are comprehensive
|
||||
- ✅ Ensure documentation is updated
|
||||
- ✅ Look for performance issues
|
||||
|
||||
---
|
||||
|
||||
## Performance & Monitoring
|
||||
|
||||
### Performance Optimization (Production Standards)
|
||||
- ✅ Set resource limits in all containers (CPU, memory, disk I/O)
|
||||
- ✅ Implement caching at multiple layers (API, database, assets)
|
||||
- ✅ Use connection pooling for databases
|
||||
- ✅ Lazy load components and routes
|
||||
- ✅ Optimize images (WebP, responsive sizes)
|
||||
- ✅ Enable compression (gzip, brotli)
|
||||
- ✅ Use CDN for static assets (in production)
|
||||
- ✅ Implement database indexes on queried fields
|
||||
- ✅ Profile before optimizing (don't guess)
|
||||
- ✅ Set up performance budgets (load time, bundle size)
|
||||
|
||||
### Monitoring & Observability (Production Requirement)
|
||||
- 📊 **CRITICAL**: Production requires comprehensive monitoring
|
||||
|
||||
#### Logging
|
||||
- ✅ Use structured logging (JSON format)
|
||||
- ✅ Include context (request ID, user ID, timestamps)
|
||||
- ✅ Log at appropriate levels (error, warn, info, debug)
|
||||
- ✅ Aggregate logs centrally (Loki, Elasticsearch)
|
||||
- ✅ Set up log retention policies
|
||||
- ✅ Never log secrets or sensitive data
|
||||
|
||||
#### Metrics
|
||||
- ✅ Track container resource usage (CPU, memory, disk)
|
||||
- ✅ Monitor API response times
|
||||
- ✅ Track error rates and types
|
||||
- ✅ Monitor health check status
|
||||
- ✅ Track user actions (anonymized)
|
||||
- ✅ Set up dashboards (Grafana)
|
||||
|
||||
#### Alerting
|
||||
- ✅ Alert on container failures
|
||||
- ✅ Alert on high resource usage
|
||||
- ✅ Alert on error rate spikes
|
||||
- ✅ Alert on health check failures
|
||||
- ✅ Use appropriate alert channels (email, Slack, PagerDuty)
|
||||
- ✅ Document incident response procedures
|
||||
|
||||
#### Health Checks
|
||||
- ✅ Implement liveness probes (is container running?)
|
||||
- ✅ Implement readiness probes (is container ready for traffic?)
|
||||
- ✅ Set appropriate timeouts and intervals
|
||||
- ✅ Restart containers on health check failures
|
||||
- ✅ Expose health endpoints (`/health`, `/ready`)
|
||||
|
||||
---
|
||||
|
||||
## Production Deployment
|
||||
|
||||
### Pre-Production Checklist
|
||||
- ✅ All tests passing (unit, integration, e2e)
|
||||
- ✅ All linters passing (no warnings)
|
||||
- ✅ Security audit completed
|
||||
- ✅ Performance testing completed
|
||||
- ✅ Load testing completed
|
||||
- ✅ Documentation updated
|
||||
- ✅ Changelog updated
|
||||
- ✅ Migration scripts tested
|
||||
- ✅ Rollback plan documented
|
||||
- ✅ Monitoring configured
|
||||
|
||||
### Deployment Strategy
|
||||
- ✅ Use blue-green or canary deployments
|
||||
- ✅ Test in staging environment first
|
||||
- ✅ Deploy during low-traffic windows
|
||||
- ✅ Monitor metrics closely after deployment
|
||||
- ✅ Have rollback plan ready
|
||||
- ✅ Communicate with users about maintenance
|
||||
|
||||
### Post-Deployment
|
||||
- ✅ Verify all services are healthy
|
||||
- ✅ Check logs for errors
|
||||
- ✅ Monitor metrics for anomalies
|
||||
- ✅ Test critical user flows
|
||||
- ✅ Document any issues encountered
|
||||
- ✅ Update status page
|
||||
|
||||
---
|
||||
|
||||
## Final Principles
|
||||
|
||||
### The Archipelago Way
|
||||
|
||||
1. **Production-Ready from Day One**
|
||||
- Write code as if it's going to production tomorrow
|
||||
- No "we'll fix it later" - fix it now
|
||||
|
||||
2. **Open Source First**
|
||||
- Code in the open, collaborate freely
|
||||
- Document everything for community contributors
|
||||
- Respect licenses and attribution
|
||||
|
||||
3. **Security is Not Optional**
|
||||
- Every container is hardened
|
||||
- Every secret is encrypted
|
||||
- Every network is isolated
|
||||
|
||||
4. **Simplicity Over Complexity**
|
||||
- Minimal codebase, maximum functionality
|
||||
- Alpine Linux base: 130MB, not 1.5GB
|
||||
- Clear architecture, no magic
|
||||
|
||||
5. **Community-Driven**
|
||||
- Listen to users and contributors
|
||||
- Accept feedback graciously
|
||||
- Build what the community needs
|
||||
|
||||
---
|
||||
|
||||
**Remember**: This is Archipelago - a clean, modern Bitcoin Node OS built with standard Docker containers, Alpine Linux, and Podman.
|
||||
|
||||
**Mission**: A production-ready, open-source Bitcoin Node OS that anyone can trust, deploy, and contribute to.
|
||||
12
.dockerignore
Normal file
12
.dockerignore
Normal file
@@ -0,0 +1,12 @@
|
||||
# Ignore everything except what the demo Dockerfiles need
|
||||
*
|
||||
|
||||
# Allow neode-ui (frontend + mock backend + docker configs)
|
||||
!neode-ui/
|
||||
|
||||
# Allow demo assets (AIUI pre-built dist)
|
||||
!demo/
|
||||
|
||||
# Exclude nested node_modules (will npm install in container)
|
||||
neode-ui/node_modules
|
||||
neode-ui/dist
|
||||
301
.gitea/workflows/build-iso-dev.yml
Normal file
301
.gitea/workflows/build-iso-dev.yml
Normal file
@@ -0,0 +1,301 @@
|
||||
name: Build Archipelago ISO (dev)
|
||||
|
||||
on:
|
||||
push:
|
||||
branches: [main, dev-iso]
|
||||
workflow_dispatch:
|
||||
|
||||
jobs:
|
||||
build-iso:
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 60
|
||||
steps:
|
||||
- name: Checkout
|
||||
id: checkout
|
||||
uses: actions/checkout@v4
|
||||
with:
|
||||
fetch-depth: 1
|
||||
timeout-minutes: 5
|
||||
continue-on-error: true
|
||||
|
||||
- name: Sync from local repo (fallback if checkout failed)
|
||||
run: |
|
||||
# Only sync from ~/archy if checkout failed or workspace is empty
|
||||
if [ -f "CLAUDE.md" ] && [ -d "core" ] && [ -d "neode-ui" ]; then
|
||||
echo "Checkout succeeded — using checked-out code"
|
||||
elif [ -d "$HOME/archy/core" ] && [ -d "$HOME/archy/neode-ui" ]; then
|
||||
echo "Checkout failed — syncing from ~/archy (LAN fallback)..."
|
||||
rsync -a \
|
||||
--exclude '.git' --exclude 'node_modules' --exclude 'target' \
|
||||
--exclude 'image-recipe/build' --exclude 'image-recipe/results' \
|
||||
--exclude 'web/dist' \
|
||||
"$HOME/archy/" ./
|
||||
else
|
||||
echo "ERROR: No checkout and no local fallback"
|
||||
exit 1
|
||||
fi
|
||||
echo "Workspace verification:"
|
||||
[ -f "scripts/first-boot-containers.sh" ] && echo " first-boot-containers.sh: PRESENT" || echo " first-boot-containers.sh: MISSING"
|
||||
grep -q 'network-alias' scripts/first-boot-containers.sh 2>/dev/null && echo " network-alias fix: PRESENT" || echo " network-alias fix: MISSING"
|
||||
grep -q 'apache2-utils' image-recipe/build-auto-installer-iso.sh 2>/dev/null && echo " apache2-utils: PRESENT" || echo " apache2-utils: MISSING"
|
||||
|
||||
- name: Install ISO build dependencies
|
||||
run: |
|
||||
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
|
||||
|
||||
- name: Build backend
|
||||
run: |
|
||||
source $HOME/.cargo/env 2>/dev/null || true
|
||||
export GIT_HASH=$(git rev-parse --short HEAD)
|
||||
cargo build --release --manifest-path core/Cargo.toml
|
||||
|
||||
- name: Build frontend
|
||||
run: cd neode-ui && npm ci && npm run build
|
||||
|
||||
- name: Type check frontend
|
||||
run: cd neode-ui && npx vue-tsc -b --noEmit
|
||||
|
||||
- name: Run frontend tests
|
||||
run: cd neode-ui && npx vitest run
|
||||
|
||||
- name: Run container orchestration unit tests
|
||||
run: |
|
||||
source $HOME/.cargo/env 2>/dev/null || true
|
||||
echo "=== Container crate tests ==="
|
||||
cargo test -p archipelago-container --no-fail-fast --manifest-path core/Cargo.toml
|
||||
echo ""
|
||||
echo "=== Orchestration integration tests ==="
|
||||
cargo test --test orchestration_tests --no-fail-fast --manifest-path core/Cargo.toml 2>/dev/null || echo "orchestration_tests not found, skipping"
|
||||
|
||||
- name: Configure root podman for insecure registry
|
||||
run: |
|
||||
sudo mkdir -p /etc/containers/registries.conf.d
|
||||
echo '[[registry]]
|
||||
location = "80.71.235.15:3000"
|
||||
insecure = true' | sudo tee /etc/containers/registries.conf.d/archipelago.conf
|
||||
|
||||
- name: Build unbundled ISO
|
||||
run: |
|
||||
cd image-recipe
|
||||
export ARCHIPELAGO_BIN="$(pwd)/../core/target/release/archipelago"
|
||||
ls -la "$ARCHIPELAGO_BIN" || echo "WARNING: binary not found"
|
||||
sudo -E UNBUNDLED=1 DEV_SERVER=localhost BUILD_FROM_SOURCE=0 \
|
||||
ARCHIPELAGO_BIN="$ARCHIPELAGO_BIN" \
|
||||
./build-auto-installer-iso.sh
|
||||
|
||||
- name: Smoke test ISO
|
||||
run: |
|
||||
ISO=$(ls image-recipe/results/archipelago-installer-unbundled-*.iso 2>/dev/null | head -1)
|
||||
if [ -z "$ISO" ]; then
|
||||
echo "FAIL: No ISO produced"
|
||||
exit 1
|
||||
fi
|
||||
echo "ISO: $ISO ($(du -h "$ISO" | cut -f1))"
|
||||
|
||||
# Mount and verify structure
|
||||
MNT=$(mktemp -d)
|
||||
sudo mount -o loop,ro "$ISO" "$MNT"
|
||||
|
||||
FAIL=0
|
||||
for f in live/vmlinuz live/initrd.img live/filesystem.squashfs \
|
||||
isolinux/isolinux.bin isolinux/isolinux.cfg \
|
||||
boot/grub/grub.cfg EFI/BOOT/BOOTX64.EFI \
|
||||
archipelago/auto-install.sh archipelago/rootfs.tar; do
|
||||
if [ -e "$MNT/$f" ]; then
|
||||
echo " OK: $f ($(sudo du -h "$MNT/$f" 2>/dev/null | cut -f1))"
|
||||
else
|
||||
echo " MISSING: $f"
|
||||
FAIL=1
|
||||
fi
|
||||
done
|
||||
|
||||
# Verify initrd has live-boot
|
||||
INITRD_DIR=$(mktemp -d)
|
||||
sudo unmkinitramfs "$MNT/live/initrd.img" "$INITRD_DIR" 2>/dev/null
|
||||
if [ -e "$INITRD_DIR/scripts/live" ] || [ -e "$INITRD_DIR/main/scripts/live" ]; then
|
||||
echo " OK: initrd has live-boot scripts"
|
||||
else
|
||||
echo " MISSING: live-boot scripts in initrd!"
|
||||
echo " initrd scripts/: $(ls "$INITRD_DIR/scripts/" 2>/dev/null || ls "$INITRD_DIR/main/scripts/" 2>/dev/null)"
|
||||
FAIL=1
|
||||
fi
|
||||
|
||||
# Check GRUB config has boot=live
|
||||
if grep -q "boot=live" "$MNT/boot/grub/grub.cfg"; then
|
||||
echo " OK: grub.cfg has boot=live"
|
||||
else
|
||||
echo " MISSING: boot=live in grub.cfg"
|
||||
FAIL=1
|
||||
fi
|
||||
|
||||
sudo umount "$MNT" 2>/dev/null
|
||||
rmdir "$MNT" 2>/dev/null
|
||||
sudo rm -r "$INITRD_DIR" 2>/dev/null
|
||||
|
||||
if [ "$FAIL" = "1" ]; then
|
||||
echo "SMOKE TEST FAILED"
|
||||
exit 1
|
||||
fi
|
||||
echo "SMOKE TEST PASSED"
|
||||
|
||||
- name: QEMU boot test
|
||||
timeout-minutes: 5
|
||||
continue-on-error: true
|
||||
run: |
|
||||
ISO=$(ls image-recipe/results/archipelago-installer-unbundled-*.iso 2>/dev/null | head -1)
|
||||
if [ -n "$ISO" ] && command -v qemu-system-x86_64 >/dev/null 2>&1; then
|
||||
echo "Running headless QEMU boot test..."
|
||||
bash image-recipe/test-iso-qemu.sh "$ISO" 120
|
||||
else
|
||||
echo "Skipping QEMU test (no ISO or QEMU not available)"
|
||||
fi
|
||||
|
||||
- name: Copy to Builds
|
||||
run: |
|
||||
ISO=$(ls image-recipe/results/archipelago-installer-unbundled-*.iso 2>/dev/null | head -1)
|
||||
if [ -n "$ISO" ]; then
|
||||
DATE=$(date +%Y%m%d-%H%M)
|
||||
DEST="/var/lib/archipelago/filebrowser/Builds/archipelago-dev-unbundled-${DATE}.iso"
|
||||
sudo cp "$ISO" "$DEST"
|
||||
sudo chown 1000:1000 "$DEST"
|
||||
echo "ISO: archipelago-dev-unbundled-${DATE}.iso"
|
||||
echo "Size: $(du -h "$DEST" | cut -f1)"
|
||||
echo "SHA256: $(sha256sum "$DEST" | cut -d' ' -f1)"
|
||||
fi
|
||||
|
||||
- name: Publish release artifacts and manifest
|
||||
run: |
|
||||
VERSION=$(grep '^version' core/archipelago/Cargo.toml | head -1 | sed 's/.*"\(.*\)".*/\1/')
|
||||
DATE=$(date +%Y-%m-%d)
|
||||
RELEASE_DIR="/var/lib/archipelago/filebrowser/Builds/releases/v${VERSION}"
|
||||
sudo mkdir -p "$RELEASE_DIR"
|
||||
|
||||
# Copy backend binary
|
||||
BINARY="core/target/release/archipelago"
|
||||
if [ -f "$BINARY" ]; then
|
||||
sudo cp "$BINARY" "$RELEASE_DIR/archipelago"
|
||||
sudo chmod 755 "$RELEASE_DIR/archipelago"
|
||||
echo "Backend: $(du -h "$RELEASE_DIR/archipelago" | cut -f1)"
|
||||
fi
|
||||
|
||||
# Create frontend archive
|
||||
if [ -d "web/dist/neode-ui" ]; then
|
||||
FRONTEND_ARCHIVE="$RELEASE_DIR/archipelago-frontend-${VERSION}.tar.gz"
|
||||
sudo tar -czf "$FRONTEND_ARCHIVE" -C web/dist neode-ui
|
||||
echo "Frontend: $(du -h "$FRONTEND_ARCHIVE" | cut -f1)"
|
||||
fi
|
||||
|
||||
# Generate manifest with SHA256 hashes
|
||||
BACKEND_HASH=$(sha256sum "$RELEASE_DIR/archipelago" 2>/dev/null | awk '{print $1}')
|
||||
BACKEND_SIZE=$(stat -c%s "$RELEASE_DIR/archipelago" 2>/dev/null || echo 0)
|
||||
FRONTEND_NAME="archipelago-frontend-${VERSION}.tar.gz"
|
||||
FRONTEND_HASH=$(sha256sum "$RELEASE_DIR/$FRONTEND_NAME" 2>/dev/null | awk '{print $1}')
|
||||
FRONTEND_SIZE=$(stat -c%s "$RELEASE_DIR/$FRONTEND_NAME" 2>/dev/null || echo 0)
|
||||
|
||||
# Build download base URL (FileBrowser serves from /Builds/)
|
||||
HOST=$(hostname -I 2>/dev/null | awk '{print $1}')
|
||||
BASE_URL="http://${HOST:-192.168.1.228}:8083/Builds/releases/v${VERSION}"
|
||||
|
||||
# Generate manifest JSON
|
||||
python3 -c "
|
||||
import json
|
||||
manifest = {
|
||||
'version': '$VERSION',
|
||||
'release_date': '$DATE',
|
||||
'changelog': ['Update to version $VERSION'],
|
||||
'components': []
|
||||
}
|
||||
if '$BACKEND_HASH':
|
||||
manifest['components'].append({
|
||||
'name': 'archipelago',
|
||||
'current_version': '$VERSION',
|
||||
'new_version': '$VERSION',
|
||||
'download_url': '$BASE_URL/archipelago',
|
||||
'sha256': '$BACKEND_HASH',
|
||||
'size_bytes': int('$BACKEND_SIZE' or '0')
|
||||
})
|
||||
if '$FRONTEND_HASH':
|
||||
manifest['components'].append({
|
||||
'name': '$FRONTEND_NAME',
|
||||
'current_version': '$VERSION',
|
||||
'new_version': '$VERSION',
|
||||
'download_url': '$BASE_URL/$FRONTEND_NAME',
|
||||
'sha256': '$FRONTEND_HASH',
|
||||
'size_bytes': int('$FRONTEND_SIZE' or '0')
|
||||
})
|
||||
print(json.dumps(manifest, indent=2))
|
||||
" | sudo tee "$RELEASE_DIR/manifest.json" > /dev/null
|
||||
|
||||
# Also copy manifest to repo releases/ dir for git-based serving
|
||||
cp "$RELEASE_DIR/manifest.json" releases/manifest.json 2>/dev/null || true
|
||||
|
||||
sudo chown -R 1000:1000 "$RELEASE_DIR"
|
||||
echo ""
|
||||
echo "Release manifest:"
|
||||
cat "$RELEASE_DIR/manifest.json"
|
||||
echo ""
|
||||
echo "Artifacts published to: $RELEASE_DIR"
|
||||
|
||||
- name: Build report
|
||||
if: always()
|
||||
continue-on-error: true
|
||||
run: |
|
||||
set +eo pipefail
|
||||
echo "══════════════════════════════════════════"
|
||||
echo "DEV ISO BUILD REPORT"
|
||||
echo "══════════════════════════════════════════"
|
||||
echo "Commit: $(git rev-parse --short HEAD) ($(git log -1 --format=%s))"
|
||||
echo "Branch: ${GITHUB_REF_NAME:-dev-iso}"
|
||||
echo "Date: $(date -u '+%Y-%m-%d %H:%M:%S UTC')"
|
||||
echo "Runner: $(hostname)"
|
||||
echo ""
|
||||
echo "── Artifacts ──"
|
||||
ls -lh image-recipe/results/*.iso 2>/dev/null || echo " No ISO produced"
|
||||
ls -lh /var/lib/archipelago/filebrowser/Builds/archipelago-dev-*.iso 2>/dev/null | tail -3
|
||||
echo ""
|
||||
echo "── Rootfs contents check ──"
|
||||
ROOTFS=$(ls image-recipe/build/auto-installer/archipelago-rootfs.tar 2>/dev/null) || true
|
||||
if [ -n "$ROOTFS" ]; then
|
||||
echo " rootfs.tar: $(sudo du -h "$ROOTFS" 2>/dev/null | cut -f1 || echo 'unknown')"
|
||||
echo " nginx config: $(sudo tar tf "$ROOTFS" ./etc/nginx/sites-available/archipelago 2>/dev/null && echo 'PRESENT' || echo 'MISSING')"
|
||||
echo " SSL cert: $(sudo tar tf "$ROOTFS" ./etc/archipelago/ssl/archipelago.crt 2>/dev/null && echo 'PRESENT' || echo 'MISSING')"
|
||||
echo " kiosk launcher: $(sudo tar tf "$ROOTFS" ./usr/local/bin/archipelago-kiosk-launcher 2>/dev/null && echo 'PRESENT' || echo 'MISSING')"
|
||||
echo " backend binary: $(sudo tar tf "$ROOTFS" ./usr/local/bin/archipelago 2>/dev/null && echo 'PRESENT' || echo 'MISSING')"
|
||||
echo " web-ui index: $(sudo tar tf "$ROOTFS" ./opt/archipelago/web-ui/index.html 2>/dev/null && echo 'PRESENT' || echo 'MISSING')"
|
||||
else
|
||||
echo " rootfs.tar not found in workspace"
|
||||
fi
|
||||
echo ""
|
||||
echo "── ISO contents check ──"
|
||||
ISO=$(ls image-recipe/results/archipelago-installer-unbundled-*.iso 2>/dev/null | head -1) || true
|
||||
if [ -n "$ISO" ]; then
|
||||
echo " ISO size: $(sudo du -h "$ISO" 2>/dev/null | cut -f1 || echo 'unknown')"
|
||||
ISO_MOUNT=$(mktemp -d)
|
||||
if sudo mount -o loop,ro "$ISO" "$ISO_MOUNT" 2>/dev/null; then
|
||||
echo " auto-install.sh: $([ -f "$ISO_MOUNT/archipelago/auto-install.sh" ] && echo 'PRESENT' || echo 'MISSING')"
|
||||
echo " rootfs.tar: $([ -f "$ISO_MOUNT/archipelago/rootfs.tar" ] && echo "PRESENT ($(sudo du -h "$ISO_MOUNT/archipelago/rootfs.tar" 2>/dev/null | cut -f1))" || echo 'MISSING')"
|
||||
echo " backend bin: $([ -f "$ISO_MOUNT/archipelago/bin/archipelago" ] && echo "PRESENT ($(sudo du -h "$ISO_MOUNT/archipelago/bin/archipelago" 2>/dev/null | cut -f1))" || echo 'MISSING')"
|
||||
echo " frontend: $([ -f "$ISO_MOUNT/archipelago/web-ui/index.html" ] && echo 'PRESENT' || echo 'MISSING')"
|
||||
echo " vmlinuz: $([ -f "$ISO_MOUNT/live/vmlinuz" ] && echo 'PRESENT' || echo 'MISSING')"
|
||||
echo " initrd: $([ -f "$ISO_MOUNT/live/initrd.img" ] && echo 'PRESENT' || echo 'MISSING')"
|
||||
echo " squashfs: $([ -f "$ISO_MOUNT/live/filesystem.squashfs" ] && echo "PRESENT ($(sudo du -h "$ISO_MOUNT/live/filesystem.squashfs" 2>/dev/null | cut -f1))" || echo 'MISSING')"
|
||||
echo " grub theme: $([ -d "$ISO_MOUNT/boot/grub/themes/archipelago" ] && echo 'PRESENT' || echo 'MISSING')"
|
||||
sudo umount "$ISO_MOUNT" 2>/dev/null || true
|
||||
else
|
||||
echo " Could not mount ISO for inspection"
|
||||
fi
|
||||
rmdir "$ISO_MOUNT" 2>/dev/null || true
|
||||
fi
|
||||
echo "══════════════════════════════════════════"
|
||||
|
||||
- name: Fix workspace permissions
|
||||
if: always()
|
||||
run: |
|
||||
sudo chown -R $(id -u):$(id -g) . 2>/dev/null || true
|
||||
sudo chmod -R u+rwX . 2>/dev/null || true
|
||||
sudo chown -R $(id -u):$(id -g) "$HOME/.cache/act" 2>/dev/null || true
|
||||
sudo chmod -R u+rwX "$HOME/.cache/act" 2>/dev/null || true
|
||||
145
.gitea/workflows/build-iso.yml
Normal file
145
.gitea/workflows/build-iso.yml
Normal file
@@ -0,0 +1,145 @@
|
||||
name: Build Archipelago ISO
|
||||
|
||||
on:
|
||||
push:
|
||||
branches: [main]
|
||||
workflow_dispatch:
|
||||
|
||||
jobs:
|
||||
build-iso:
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 45
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v4
|
||||
with:
|
||||
fetch-depth: 1
|
||||
clean: true
|
||||
|
||||
- name: Build backend
|
||||
run: |
|
||||
source $HOME/.cargo/env 2>/dev/null || true
|
||||
cargo build --release --manifest-path core/Cargo.toml
|
||||
|
||||
- name: Build frontend
|
||||
run: |
|
||||
rm -rf web/dist/neode-ui
|
||||
cd neode-ui && npm ci && npm run build
|
||||
|
||||
- name: Type check frontend
|
||||
run: cd neode-ui && npx vue-tsc -b --noEmit
|
||||
|
||||
- name: Run frontend tests
|
||||
run: cd neode-ui && npx vitest run
|
||||
|
||||
- name: Cache Debian Live ISO
|
||||
run: |
|
||||
WORK_DIR="image-recipe/build/auto-installer"
|
||||
mkdir -p "$WORK_DIR"
|
||||
CACHED="/home/archipelago/archy/image-recipe/build/auto-installer/debian-live-installer.iso"
|
||||
if [ -f "$CACHED" ] && [ ! -f "$WORK_DIR/debian-live-installer.iso" ]; then
|
||||
cp "$CACHED" "$WORK_DIR/debian-live-installer.iso"
|
||||
echo "Cached Debian Live ISO copied ($(du -h "$WORK_DIR/debian-live-installer.iso" | cut -f1))"
|
||||
fi
|
||||
|
||||
- name: Configure root podman for insecure registry
|
||||
run: |
|
||||
sudo mkdir -p /etc/containers/registries.conf.d
|
||||
echo '[[registry]]
|
||||
location = "80.71.235.15:3000"
|
||||
insecure = true' | sudo tee /etc/containers/registries.conf.d/archipelago.conf
|
||||
|
||||
- name: Include AIUI if available
|
||||
run: |
|
||||
# Copy AIUI from the deployed system (build server has it at /opt/archipelago/web-ui/aiui/)
|
||||
if [ -d "/opt/archipelago/web-ui/aiui" ] && [ -f "/opt/archipelago/web-ui/aiui/index.html" ]; then
|
||||
mkdir -p web/dist/neode-ui/aiui
|
||||
cp -r /opt/archipelago/web-ui/aiui/* web/dist/neode-ui/aiui/
|
||||
echo "AIUI included from /opt/archipelago/web-ui/aiui/"
|
||||
else
|
||||
echo "WARNING: AIUI not found on build server"
|
||||
fi
|
||||
|
||||
- name: Build unbundled ISO
|
||||
run: |
|
||||
cd image-recipe
|
||||
export ARCHIPELAGO_BIN="$(pwd)/../core/target/release/archipelago"
|
||||
ls -la "$ARCHIPELAGO_BIN" || echo "WARNING: binary not found"
|
||||
sudo -E UNBUNDLED=1 DEV_SERVER=localhost BUILD_FROM_SOURCE=0 \
|
||||
ARCHIPELAGO_BIN="$ARCHIPELAGO_BIN" \
|
||||
./build-auto-installer-iso.sh
|
||||
|
||||
- name: Copy to Builds
|
||||
run: |
|
||||
ISO=$(ls image-recipe/results/archipelago-installer-unbundled-*.iso 2>/dev/null | head -1)
|
||||
if [ -n "$ISO" ]; then
|
||||
DATE=$(date +%Y%m%d-%H%M)
|
||||
DEST="/var/lib/archipelago/filebrowser/Builds/archipelago-unbundled-${DATE}.iso"
|
||||
sudo cp "$ISO" "$DEST"
|
||||
sudo chown 1000:1000 "$DEST"
|
||||
echo "ISO: archipelago-unbundled-${DATE}.iso"
|
||||
echo "Size: $(du -h "$DEST" | cut -f1)"
|
||||
echo "SHA256: $(sha256sum "$DEST" | cut -d' ' -f1)"
|
||||
fi
|
||||
|
||||
- name: Build report
|
||||
if: always()
|
||||
continue-on-error: true
|
||||
run: |
|
||||
set +eo pipefail
|
||||
echo "══════════════════════════════════════════"
|
||||
echo "BUILD REPORT"
|
||||
echo "══════════════════════════════════════════"
|
||||
echo "Commit: $(git rev-parse --short HEAD) ($(git log -1 --format=%s))"
|
||||
echo "Branch: ${GITHUB_REF_NAME:-unknown}"
|
||||
echo "Date: $(date -u '+%Y-%m-%d %H:%M:%S UTC')"
|
||||
echo "Runner: $(hostname)"
|
||||
echo ""
|
||||
echo "── Artifacts ──"
|
||||
ls -lh image-recipe/results/*.iso 2>/dev/null || echo " No ISO produced"
|
||||
ls -lh /var/lib/archipelago/filebrowser/Builds/archipelago-unbundled-*.iso 2>/dev/null | tail -3
|
||||
echo ""
|
||||
echo "── Rootfs contents check ──"
|
||||
ROOTFS=$(ls image-recipe/build/auto-installer/archipelago-rootfs.tar 2>/dev/null) || true
|
||||
if [ -n "$ROOTFS" ]; then
|
||||
echo " rootfs.tar: $(sudo du -h "$ROOTFS" 2>/dev/null | cut -f1 || echo 'unknown')"
|
||||
echo " nginx config: $(sudo tar tf "$ROOTFS" ./etc/nginx/sites-available/archipelago 2>/dev/null && echo 'PRESENT' || echo 'MISSING')"
|
||||
echo " SSL cert: $(sudo tar tf "$ROOTFS" ./etc/archipelago/ssl/archipelago.crt 2>/dev/null && echo 'PRESENT' || echo 'MISSING')"
|
||||
echo " keyboard config: $(sudo tar tf "$ROOTFS" ./etc/default/keyboard 2>/dev/null && echo 'PRESENT' || echo 'MISSING')"
|
||||
echo " console-setup: $(sudo tar tf "$ROOTFS" ./etc/default/console-setup 2>/dev/null && echo 'PRESENT' || echo 'MISSING')"
|
||||
echo " kiosk launcher: $(sudo tar tf "$ROOTFS" ./usr/local/bin/archipelago-kiosk-launcher 2>/dev/null && echo 'PRESENT' || echo 'MISSING')"
|
||||
echo " logind lid: $(sudo tar tf "$ROOTFS" ./etc/systemd/logind.conf.d/lid-ignore.conf 2>/dev/null && echo 'PRESENT' || echo 'MISSING')"
|
||||
echo " backend binary: $(sudo tar tf "$ROOTFS" ./usr/local/bin/archipelago 2>/dev/null && echo 'PRESENT' || echo 'MISSING')"
|
||||
echo " web-ui index: $(sudo tar tf "$ROOTFS" ./opt/archipelago/web-ui/index.html 2>/dev/null && echo 'PRESENT' || echo 'MISSING')"
|
||||
echo " AIUI: $(sudo tar tf "$ROOTFS" ./opt/archipelago/web-ui/aiui/index.html 2>/dev/null && echo 'PRESENT' || echo 'MISSING')"
|
||||
echo " claude-api-proxy: $(sudo tar tf "$ROOTFS" ./opt/archipelago/claude-api-proxy.py 2>/dev/null && echo 'PRESENT' || echo 'MISSING')"
|
||||
else
|
||||
echo " rootfs.tar not found in workspace"
|
||||
fi
|
||||
echo ""
|
||||
echo "── ISO contents check ──"
|
||||
ISO=$(ls image-recipe/results/archipelago-installer-unbundled-*.iso 2>/dev/null | head -1) || true
|
||||
if [ -n "$ISO" ]; then
|
||||
echo " ISO size: $(sudo du -h "$ISO" 2>/dev/null | cut -f1 || echo 'unknown')"
|
||||
ISO_MOUNT=$(mktemp -d)
|
||||
if sudo mount -o loop,ro "$ISO" "$ISO_MOUNT" 2>/dev/null; then
|
||||
echo " auto-install.sh: $([ -f "$ISO_MOUNT/archipelago/auto-install.sh" ] && echo 'PRESENT' || echo 'MISSING')"
|
||||
echo " rootfs.tar: $([ -f "$ISO_MOUNT/archipelago/rootfs.tar" ] && echo "PRESENT ($(sudo du -h "$ISO_MOUNT/archipelago/rootfs.tar" 2>/dev/null | cut -f1))" || echo 'MISSING')"
|
||||
echo " backend bin: $([ -f "$ISO_MOUNT/archipelago/bin/archipelago" ] && echo "PRESENT ($(sudo du -h "$ISO_MOUNT/archipelago/bin/archipelago" 2>/dev/null | cut -f1))" || echo 'MISSING')"
|
||||
echo " frontend: $([ -f "$ISO_MOUNT/archipelago/web-ui/index.html" ] && echo 'PRESENT' || echo 'MISSING')"
|
||||
echo " image-versions: $([ -f "$ISO_MOUNT/archipelago/scripts/image-versions.sh" ] && echo 'PRESENT' || echo 'MISSING')"
|
||||
sudo umount "$ISO_MOUNT" 2>/dev/null || true
|
||||
else
|
||||
echo " Could not mount ISO for inspection"
|
||||
fi
|
||||
rmdir "$ISO_MOUNT" 2>/dev/null || true
|
||||
fi
|
||||
echo "══════════════════════════════════════════"
|
||||
|
||||
- name: Fix workspace permissions
|
||||
if: always()
|
||||
run: |
|
||||
sudo chown -R $(id -u):$(id -g) . 2>/dev/null || true
|
||||
sudo chmod -R u+rwX . 2>/dev/null || true
|
||||
sudo chown -R $(id -u):$(id -g) "$HOME/.cache/act" 2>/dev/null || true
|
||||
sudo chmod -R u+rwX "$HOME/.cache/act" 2>/dev/null || true
|
||||
63
.gitea/workflows/container-tests.yml
Normal file
63
.gitea/workflows/container-tests.yml
Normal file
@@ -0,0 +1,63 @@
|
||||
name: Container Orchestration Tests
|
||||
on:
|
||||
push:
|
||||
branches: [dev-iso, main]
|
||||
paths:
|
||||
- 'core/archipelago/src/**'
|
||||
- 'core/container/src/**'
|
||||
- 'scripts/container-*.sh'
|
||||
- 'scripts/reconcile-*.sh'
|
||||
- 'scripts/image-versions.sh'
|
||||
workflow_dispatch:
|
||||
|
||||
jobs:
|
||||
unit-tests:
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 15
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
|
||||
- name: Cache cargo registry
|
||||
uses: actions/cache@v3
|
||||
with:
|
||||
path: |
|
||||
~/.cargo/registry
|
||||
~/.cargo/git
|
||||
core/target
|
||||
key: cargo-test-${{ hashFiles('core/Cargo.lock') }}
|
||||
|
||||
- name: Run orchestration unit tests
|
||||
working-directory: core
|
||||
run: |
|
||||
source $HOME/.cargo/env 2>/dev/null || true
|
||||
echo "=== Container crate tests ==="
|
||||
cargo test -p archipelago-container --no-fail-fast 2>&1
|
||||
|
||||
echo ""
|
||||
echo "=== Orchestration integration tests ==="
|
||||
cargo test --test orchestration_tests --no-fail-fast 2>&1
|
||||
|
||||
- name: Verify cargo check (full crate)
|
||||
working-directory: core
|
||||
run: |
|
||||
source $HOME/.cargo/env 2>/dev/null || true
|
||||
cargo check --release 2>&1
|
||||
|
||||
smoke-tests:
|
||||
runs-on: ubuntu-latest
|
||||
needs: unit-tests
|
||||
timeout-minutes: 10
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
|
||||
- name: Run container smoke tests on .228
|
||||
env:
|
||||
ARCHIPELAGO_SSH_KEY: ~/.ssh/archipelago-deploy
|
||||
run: |
|
||||
# Only run if SSH key exists (CI runner has deploy access)
|
||||
if [ -f "$ARCHIPELAGO_SSH_KEY" ]; then
|
||||
bash scripts/dev-container-test.sh --once
|
||||
else
|
||||
echo "⚠ SSH key not available — skipping live smoke tests"
|
||||
echo " To enable: add archipelago-deploy key to CI runner"
|
||||
fi
|
||||
72
.gitea/workflows/post-install-tests.yml
Normal file
72
.gitea/workflows/post-install-tests.yml
Normal file
@@ -0,0 +1,72 @@
|
||||
name: Post-Install Tests
|
||||
|
||||
on:
|
||||
workflow_dispatch:
|
||||
inputs:
|
||||
target:
|
||||
description: 'Target node IP (e.g. 192.168.1.198)'
|
||||
required: true
|
||||
default: '192.168.1.198'
|
||||
password:
|
||||
description: 'Node password (or "auto" for fresh install)'
|
||||
required: false
|
||||
default: 'auto'
|
||||
|
||||
jobs:
|
||||
post-install-tests:
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 15
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v4
|
||||
with:
|
||||
fetch-depth: 1
|
||||
|
||||
- name: Run post-install tests on target
|
||||
run: |
|
||||
TARGET="${{ github.event.inputs.target }}"
|
||||
PASSWORD="${{ github.event.inputs.password }}"
|
||||
if [ "$PASSWORD" = "auto" ]; then
|
||||
PASSWORD="testpass123!"
|
||||
fi
|
||||
|
||||
echo "══════════════════════════════════════════"
|
||||
echo "Running post-install tests on $TARGET"
|
||||
echo "══════════════════════════════════════════"
|
||||
|
||||
# Copy test script to target and run
|
||||
sshpass -p 'archipelago' scp -o StrictHostKeyChecking=no \
|
||||
scripts/run-post-install-tests.sh \
|
||||
archipelago@${TARGET}:/tmp/run-post-install-tests.sh 2>/dev/null || \
|
||||
scp -o StrictHostKeyChecking=no \
|
||||
scripts/run-post-install-tests.sh \
|
||||
archipelago@${TARGET}:/tmp/run-post-install-tests.sh
|
||||
|
||||
# Run tests (with sudo for service checks)
|
||||
sshpass -p 'archipelago' ssh -o StrictHostKeyChecking=no \
|
||||
archipelago@${TARGET} \
|
||||
"sudo bash /tmp/run-post-install-tests.sh '$PASSWORD'" 2>/dev/null || \
|
||||
ssh -o StrictHostKeyChecking=no \
|
||||
archipelago@${TARGET} \
|
||||
"sudo bash /tmp/run-post-install-tests.sh '$PASSWORD'"
|
||||
|
||||
frontend-tests:
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 10
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v4
|
||||
with:
|
||||
fetch-depth: 1
|
||||
|
||||
- name: Install dependencies
|
||||
run: cd neode-ui && npm ci
|
||||
|
||||
- name: Type check
|
||||
run: cd neode-ui && npx vue-tsc -b --noEmit
|
||||
|
||||
- name: Run tests
|
||||
run: cd neode-ui && npx vitest run
|
||||
|
||||
- name: Audit dependencies
|
||||
run: cd neode-ui && npm audit --omit=dev
|
||||
78
.github/ISSUE_TEMPLATE/app_submission.yml
vendored
Normal file
78
.github/ISSUE_TEMPLATE/app_submission.yml
vendored
Normal file
@@ -0,0 +1,78 @@
|
||||
name: App Submission
|
||||
description: Submit an app for the Archipelago marketplace
|
||||
title: "[App]: "
|
||||
labels: ["app-submission"]
|
||||
body:
|
||||
- type: input
|
||||
id: app_name
|
||||
attributes:
|
||||
label: App Name
|
||||
placeholder: My Bitcoin App
|
||||
validations:
|
||||
required: true
|
||||
|
||||
- type: input
|
||||
id: docker_image
|
||||
attributes:
|
||||
label: Container Image
|
||||
description: Full image reference with tag (no :latest)
|
||||
placeholder: "ghcr.io/org/app:1.2.3"
|
||||
validations:
|
||||
required: true
|
||||
|
||||
- type: textarea
|
||||
id: description
|
||||
attributes:
|
||||
label: Description
|
||||
description: What does this app do?
|
||||
validations:
|
||||
required: true
|
||||
|
||||
- type: input
|
||||
id: homepage
|
||||
attributes:
|
||||
label: Homepage / Repository
|
||||
placeholder: "https://github.com/..."
|
||||
|
||||
- type: dropdown
|
||||
id: category
|
||||
attributes:
|
||||
label: Category
|
||||
options:
|
||||
- Bitcoin
|
||||
- Lightning
|
||||
- Privacy
|
||||
- Storage
|
||||
- Communication
|
||||
- Development
|
||||
- Other
|
||||
validations:
|
||||
required: true
|
||||
|
||||
- type: checkboxes
|
||||
id: requirements
|
||||
attributes:
|
||||
label: App Requirements Met
|
||||
options:
|
||||
- label: Runs as non-root user (UID > 1000)
|
||||
required: true
|
||||
- label: No `latest` tag — pinned version
|
||||
required: true
|
||||
- label: "Supports x86_64"
|
||||
required: true
|
||||
- label: "Supports ARM64"
|
||||
- label: Tested on Archipelago hardware
|
||||
required: true
|
||||
|
||||
- type: textarea
|
||||
id: ports
|
||||
attributes:
|
||||
label: Required Ports
|
||||
description: List ports the app needs exposed
|
||||
placeholder: "8080 (web UI), 9735 (Lightning)"
|
||||
|
||||
- type: textarea
|
||||
id: dependencies
|
||||
attributes:
|
||||
label: Dependencies
|
||||
description: Does this app require other apps (e.g., Bitcoin, LND)?
|
||||
81
.github/ISSUE_TEMPLATE/bug_report.yml
vendored
Normal file
81
.github/ISSUE_TEMPLATE/bug_report.yml
vendored
Normal file
@@ -0,0 +1,81 @@
|
||||
name: Bug Report
|
||||
description: Report a bug in Archipelago
|
||||
title: "[Bug]: "
|
||||
labels: ["bug", "triage"]
|
||||
body:
|
||||
- type: markdown
|
||||
attributes:
|
||||
value: |
|
||||
Thank you for reporting a bug. Please fill out the sections below.
|
||||
|
||||
- type: textarea
|
||||
id: description
|
||||
attributes:
|
||||
label: Description
|
||||
description: A clear description of the bug.
|
||||
placeholder: What happened?
|
||||
validations:
|
||||
required: true
|
||||
|
||||
- type: textarea
|
||||
id: steps
|
||||
attributes:
|
||||
label: Steps to Reproduce
|
||||
description: Minimal steps to reproduce the issue.
|
||||
placeholder: |
|
||||
1. Go to '...'
|
||||
2. Click on '...'
|
||||
3. See error
|
||||
validations:
|
||||
required: true
|
||||
|
||||
- type: textarea
|
||||
id: expected
|
||||
attributes:
|
||||
label: Expected Behavior
|
||||
description: What should have happened?
|
||||
validations:
|
||||
required: true
|
||||
|
||||
- type: textarea
|
||||
id: actual
|
||||
attributes:
|
||||
label: Actual Behavior
|
||||
description: What actually happened?
|
||||
validations:
|
||||
required: true
|
||||
|
||||
- type: input
|
||||
id: version
|
||||
attributes:
|
||||
label: Archipelago Version
|
||||
description: Check Settings page or run `archipelago --version`
|
||||
placeholder: "0.1.0"
|
||||
validations:
|
||||
required: true
|
||||
|
||||
- type: dropdown
|
||||
id: hardware
|
||||
attributes:
|
||||
label: Hardware
|
||||
options:
|
||||
- x86_64 (Intel/AMD)
|
||||
- ARM64 (Raspberry Pi 5)
|
||||
- ARM64 (Other)
|
||||
- Other
|
||||
validations:
|
||||
required: true
|
||||
|
||||
- type: textarea
|
||||
id: logs
|
||||
attributes:
|
||||
label: Relevant Logs
|
||||
description: |
|
||||
Run `journalctl -u archipelago --since "1 hour ago"` and paste relevant output.
|
||||
render: shell
|
||||
|
||||
- type: textarea
|
||||
id: screenshots
|
||||
attributes:
|
||||
label: Screenshots
|
||||
description: If applicable, add screenshots.
|
||||
5
.github/ISSUE_TEMPLATE/config.yml
vendored
Normal file
5
.github/ISSUE_TEMPLATE/config.yml
vendored
Normal file
@@ -0,0 +1,5 @@
|
||||
blank_issues_enabled: true
|
||||
contact_links:
|
||||
- name: Security Vulnerability
|
||||
url: mailto:security@archipelago-os.org
|
||||
about: Do NOT open public issues for security vulnerabilities. Email us directly.
|
||||
44
.github/ISSUE_TEMPLATE/feature_request.yml
vendored
Normal file
44
.github/ISSUE_TEMPLATE/feature_request.yml
vendored
Normal file
@@ -0,0 +1,44 @@
|
||||
name: Feature Request
|
||||
description: Suggest a new feature or improvement
|
||||
title: "[Feature]: "
|
||||
labels: ["enhancement"]
|
||||
body:
|
||||
- type: textarea
|
||||
id: problem
|
||||
attributes:
|
||||
label: Problem
|
||||
description: What problem does this solve?
|
||||
placeholder: I'm always frustrated when...
|
||||
validations:
|
||||
required: true
|
||||
|
||||
- type: textarea
|
||||
id: solution
|
||||
attributes:
|
||||
label: Proposed Solution
|
||||
description: How should this work?
|
||||
validations:
|
||||
required: true
|
||||
|
||||
- type: textarea
|
||||
id: alternatives
|
||||
attributes:
|
||||
label: Alternatives Considered
|
||||
description: What other approaches did you consider?
|
||||
|
||||
- type: dropdown
|
||||
id: area
|
||||
attributes:
|
||||
label: Area
|
||||
options:
|
||||
- Web UI
|
||||
- Backend / API
|
||||
- App Management
|
||||
- Networking
|
||||
- Security
|
||||
- Web5 / Identity
|
||||
- ISO / Installation
|
||||
- Documentation
|
||||
- Other
|
||||
validations:
|
||||
required: true
|
||||
16
.github/pull_request_template.md
vendored
Normal file
16
.github/pull_request_template.md
vendored
Normal file
@@ -0,0 +1,16 @@
|
||||
## Summary
|
||||
|
||||
<!-- Brief description of what this PR does -->
|
||||
|
||||
## Changes
|
||||
|
||||
-
|
||||
|
||||
## Checklist
|
||||
|
||||
- [ ] TypeScript type-check passes (`npm run type-check`)
|
||||
- [ ] Frontend builds (`npm run build`)
|
||||
- [ ] Tests pass (`npm test`)
|
||||
- [ ] Rust clippy clean (if backend changes)
|
||||
- [ ] No new compiler warnings
|
||||
- [ ] Tested on live server
|
||||
65
.github/workflows/ci.yml
vendored
Normal file
65
.github/workflows/ci.yml
vendored
Normal file
@@ -0,0 +1,65 @@
|
||||
name: CI
|
||||
|
||||
on:
|
||||
push:
|
||||
branches: [main]
|
||||
pull_request:
|
||||
branches: [main]
|
||||
|
||||
env:
|
||||
RUST_VERSION: stable
|
||||
NODE_VERSION: 18
|
||||
|
||||
jobs:
|
||||
rust:
|
||||
name: Rust (fmt + clippy + test)
|
||||
runs-on: ubuntu-latest
|
||||
defaults:
|
||||
run:
|
||||
working-directory: core
|
||||
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: Setup Rust
|
||||
uses: actions-rust-lang/setup-rust-toolchain@v1
|
||||
with:
|
||||
toolchain: ${{ env.RUST_VERSION }}
|
||||
components: rustfmt, clippy
|
||||
|
||||
- name: Check formatting
|
||||
run: cargo fmt --all -- --check
|
||||
|
||||
- name: Clippy
|
||||
run: cargo clippy --all-targets --all-features -- -D warnings
|
||||
|
||||
- name: Tests
|
||||
run: cargo test --all-features
|
||||
|
||||
frontend:
|
||||
name: Frontend (type-check + lint)
|
||||
runs-on: ubuntu-latest
|
||||
defaults:
|
||||
run:
|
||||
working-directory: neode-ui
|
||||
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: Setup Node.js
|
||||
uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: ${{ env.NODE_VERSION }}
|
||||
cache: 'npm'
|
||||
cache-dependency-path: neode-ui/package-lock.json
|
||||
|
||||
- name: Install dependencies
|
||||
run: npm ci
|
||||
|
||||
- name: Type check
|
||||
run: npm run type-check
|
||||
|
||||
- name: Build
|
||||
run: npm run build
|
||||
1
.gitignore
vendored
1
.gitignore
vendored
@@ -72,3 +72,4 @@ loop/loop.log.bak
|
||||
|
||||
# Separate repos nested in tree
|
||||
web/
|
||||
|
||||
|
||||
3
.gitmodules
vendored
Normal file
3
.gitmodules
vendored
Normal file
@@ -0,0 +1,3 @@
|
||||
[submodule "indeedhub"]
|
||||
path = indeedhub
|
||||
url = https://git.tx1138.com/lfg2025/indeehub.git
|
||||
16
Android/.gitignore
vendored
Normal file
16
Android/.gitignore
vendored
Normal file
@@ -0,0 +1,16 @@
|
||||
*.iml
|
||||
.gradle
|
||||
/local.properties
|
||||
/.idea
|
||||
.DS_Store
|
||||
/build
|
||||
/captures
|
||||
.externalNativeBuild
|
||||
.cxx
|
||||
local.properties
|
||||
/app/build
|
||||
/app/release
|
||||
*.apk
|
||||
*.aab
|
||||
*.jks
|
||||
*.keystore
|
||||
90
Android/app/build.gradle.kts
Normal file
90
Android/app/build.gradle.kts
Normal file
@@ -0,0 +1,90 @@
|
||||
plugins {
|
||||
id("com.android.application")
|
||||
id("org.jetbrains.kotlin.android")
|
||||
}
|
||||
|
||||
android {
|
||||
namespace = "com.archipelago.app"
|
||||
compileSdk = 35
|
||||
|
||||
defaultConfig {
|
||||
applicationId = "com.archipelago.app"
|
||||
minSdk = 26
|
||||
targetSdk = 35
|
||||
versionCode = 1
|
||||
versionName = "0.1.0"
|
||||
|
||||
vectorDrawables {
|
||||
useSupportLibrary = true
|
||||
}
|
||||
}
|
||||
|
||||
buildTypes {
|
||||
release {
|
||||
isMinifyEnabled = true
|
||||
isShrinkResources = true
|
||||
proguardFiles(
|
||||
getDefaultProguardFile("proguard-android-optimize.txt"),
|
||||
"proguard-rules.pro"
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
compileOptions {
|
||||
sourceCompatibility = JavaVersion.VERSION_17
|
||||
targetCompatibility = JavaVersion.VERSION_17
|
||||
}
|
||||
|
||||
kotlinOptions {
|
||||
jvmTarget = "17"
|
||||
}
|
||||
|
||||
buildFeatures {
|
||||
compose = true
|
||||
}
|
||||
|
||||
composeOptions {
|
||||
kotlinCompilerExtensionVersion = "1.5.14"
|
||||
}
|
||||
|
||||
packaging {
|
||||
resources {
|
||||
excludes += "/META-INF/{AL2.0,LGPL2.1}"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
dependencies {
|
||||
val composeBom = platform("androidx.compose:compose-bom:2024.05.00")
|
||||
implementation(composeBom)
|
||||
|
||||
implementation("androidx.core:core-ktx:1.13.1")
|
||||
implementation("androidx.lifecycle:lifecycle-runtime-ktx:2.8.2")
|
||||
implementation("androidx.activity:activity-compose:1.9.0")
|
||||
|
||||
// Compose
|
||||
implementation("androidx.compose.ui:ui")
|
||||
implementation("androidx.compose.ui:ui-graphics")
|
||||
implementation("androidx.compose.ui:ui-tooling-preview")
|
||||
implementation("androidx.compose.material3:material3")
|
||||
implementation("androidx.compose.material:material-icons-extended")
|
||||
implementation("androidx.compose.animation:animation")
|
||||
|
||||
// Navigation
|
||||
implementation("androidx.navigation:navigation-compose:2.7.7")
|
||||
|
||||
// DataStore for preferences
|
||||
implementation("androidx.datastore:datastore-preferences:1.1.1")
|
||||
|
||||
// WebView
|
||||
implementation("androidx.webkit:webkit:1.11.0")
|
||||
|
||||
// Splash screen
|
||||
implementation("androidx.core:core-splashscreen:1.0.1")
|
||||
|
||||
// OkHttp for WebSocket (remote input)
|
||||
implementation("com.squareup.okhttp3:okhttp:4.12.0")
|
||||
|
||||
debugImplementation("androidx.compose.ui:ui-tooling")
|
||||
debugImplementation("androidx.compose.ui:ui-test-manifest")
|
||||
}
|
||||
7
Android/app/proguard-rules.pro
vendored
Normal file
7
Android/app/proguard-rules.pro
vendored
Normal file
@@ -0,0 +1,7 @@
|
||||
# Keep WebView JavaScript interface
|
||||
-keepclassmembers class com.archipelago.app.ui.screens.WebViewScreen$* {
|
||||
public *;
|
||||
}
|
||||
|
||||
# Keep Compose
|
||||
-dontwarn androidx.compose.**
|
||||
32
Android/app/src/main/AndroidManifest.xml
Normal file
32
Android/app/src/main/AndroidManifest.xml
Normal file
@@ -0,0 +1,32 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<manifest xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
xmlns:tools="http://schemas.android.com/tools">
|
||||
|
||||
<uses-permission android:name="android.permission.INTERNET" />
|
||||
<uses-permission android:name="android.permission.ACCESS_NETWORK_STATE" />
|
||||
|
||||
<application
|
||||
android:name=".ArchipelagoApp"
|
||||
android:allowBackup="true"
|
||||
android:icon="@mipmap/ic_launcher"
|
||||
android:roundIcon="@mipmap/ic_launcher_round"
|
||||
android:label="@string/app_name"
|
||||
android:supportsRtl="true"
|
||||
android:theme="@style/Theme.Archipelago"
|
||||
android:usesCleartextTraffic="true"
|
||||
tools:targetApi="35">
|
||||
|
||||
<activity
|
||||
android:name=".MainActivity"
|
||||
android:exported="true"
|
||||
android:theme="@style/Theme.Archipelago.Splash"
|
||||
android:windowSoftInputMode="adjustResize"
|
||||
android:configChanges="orientation|screenSize|screenLayout|keyboardHidden">
|
||||
<intent-filter>
|
||||
<action android:name="android.intent.action.MAIN" />
|
||||
<category android:name="android.intent.category.LAUNCHER" />
|
||||
</intent-filter>
|
||||
</activity>
|
||||
</application>
|
||||
|
||||
</manifest>
|
||||
492
Android/app/src/main/assets/connect.html
Normal file
492
Android/app/src/main/assets/connect.html
Normal file
@@ -0,0 +1,492 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0, maximum-scale=1.0, user-scalable=no, viewport-fit=cover">
|
||||
<title>Archipelago</title>
|
||||
<style>
|
||||
@import url('https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700&display=swap');
|
||||
|
||||
* { margin: 0; padding: 0; box-sizing: border-box; }
|
||||
|
||||
body {
|
||||
font-family: 'Inter', -apple-system, BlinkMacSystemFont, sans-serif;
|
||||
background: #000;
|
||||
color: #f5f5f5;
|
||||
min-height: 100vh;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding: 24px;
|
||||
padding-top: calc(24px + env(safe-area-inset-top, 0px));
|
||||
overflow: hidden;
|
||||
-webkit-tap-highlight-color: transparent;
|
||||
}
|
||||
|
||||
/* --- Intro Screen --- */
|
||||
#intro {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
text-align: center;
|
||||
gap: 20px;
|
||||
animation: fadeIn 0.6s ease;
|
||||
}
|
||||
|
||||
#intro.hidden, #connect.hidden, #connecting.hidden { display: none; }
|
||||
|
||||
.logo-container {
|
||||
width: 88px;
|
||||
height: 88px;
|
||||
border-radius: 20px;
|
||||
overflow: hidden;
|
||||
border: 2px solid rgba(255,255,255,0.12);
|
||||
background: #030202;
|
||||
}
|
||||
|
||||
.logo-container svg { width: 100%; height: 100%; }
|
||||
|
||||
.logo-square {
|
||||
opacity: 0;
|
||||
animation: squareIn 3s ease-out infinite;
|
||||
}
|
||||
|
||||
@keyframes squareIn {
|
||||
0% { opacity: 0; }
|
||||
15% { opacity: 1; }
|
||||
100% { opacity: 1; }
|
||||
}
|
||||
|
||||
.brand-name {
|
||||
font-size: 12px;
|
||||
font-weight: 600;
|
||||
letter-spacing: 6px;
|
||||
color: #F7931A;
|
||||
text-transform: uppercase;
|
||||
}
|
||||
|
||||
h1 {
|
||||
font-size: 28px;
|
||||
font-weight: 600;
|
||||
line-height: 1.3;
|
||||
color: #f5f5f5;
|
||||
margin-top: 16px;
|
||||
}
|
||||
|
||||
.subtitle {
|
||||
font-size: 16px;
|
||||
color: #666;
|
||||
line-height: 1.6;
|
||||
max-width: 320px;
|
||||
}
|
||||
|
||||
/* --- Glass Button --- */
|
||||
.glass-button {
|
||||
width: 100%;
|
||||
max-width: 340px;
|
||||
padding: 16px 24px;
|
||||
border-radius: 12px;
|
||||
font-size: 16px;
|
||||
font-weight: 600;
|
||||
cursor: pointer;
|
||||
border: none;
|
||||
transition: all 0.15s ease;
|
||||
-webkit-tap-highlight-color: transparent;
|
||||
}
|
||||
|
||||
.glass-button:active { transform: scale(0.97); }
|
||||
|
||||
.glass-button-primary {
|
||||
background: #F7931A;
|
||||
color: #000;
|
||||
}
|
||||
.glass-button-primary:disabled {
|
||||
background: rgba(247,147,26,0.3);
|
||||
color: rgba(0,0,0,0.5);
|
||||
}
|
||||
|
||||
.glass-button-outline {
|
||||
background: rgba(255,255,255,0.06);
|
||||
color: #f5f5f5;
|
||||
border: 1px solid rgba(255,255,255,0.12);
|
||||
backdrop-filter: blur(12px);
|
||||
-webkit-backdrop-filter: blur(12px);
|
||||
}
|
||||
|
||||
/* --- Connect Screen --- */
|
||||
#connect {
|
||||
width: 100%;
|
||||
max-width: 400px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
gap: 20px;
|
||||
animation: fadeIn 0.4s ease;
|
||||
}
|
||||
|
||||
.glass-card {
|
||||
width: 100%;
|
||||
padding: 20px;
|
||||
border-radius: 16px;
|
||||
background: rgba(255,255,255,0.04);
|
||||
border: 1px solid rgba(255,255,255,0.08);
|
||||
backdrop-filter: blur(16px);
|
||||
-webkit-backdrop-filter: blur(16px);
|
||||
}
|
||||
|
||||
.form-group { margin-bottom: 16px; }
|
||||
.form-group:last-child { margin-bottom: 0; }
|
||||
|
||||
label {
|
||||
display: block;
|
||||
font-size: 13px;
|
||||
font-weight: 500;
|
||||
color: rgba(255,255,255,0.5);
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
|
||||
input[type="text"] {
|
||||
width: 100%;
|
||||
padding: 14px 16px;
|
||||
border-radius: 12px;
|
||||
border: 1px solid rgba(255,255,255,0.12);
|
||||
background: transparent;
|
||||
color: #f5f5f5;
|
||||
font-size: 16px;
|
||||
font-family: inherit;
|
||||
outline: none;
|
||||
transition: border-color 0.2s;
|
||||
}
|
||||
|
||||
input[type="text"]:focus {
|
||||
border-color: #F7931A;
|
||||
}
|
||||
|
||||
input[type="text"]::placeholder {
|
||||
color: rgba(255,255,255,0.25);
|
||||
}
|
||||
|
||||
.port-row {
|
||||
display: flex;
|
||||
gap: 12px;
|
||||
align-items: flex-end;
|
||||
}
|
||||
|
||||
.port-input { width: 120px; }
|
||||
|
||||
.toggle-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
padding: 4px 0;
|
||||
}
|
||||
|
||||
.toggle-label {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
font-size: 14px;
|
||||
color: rgba(255,255,255,0.7);
|
||||
}
|
||||
|
||||
.toggle-label svg { width: 18px; height: 18px; opacity: 0.5; }
|
||||
|
||||
/* Toggle switch */
|
||||
.toggle {
|
||||
position: relative;
|
||||
width: 48px;
|
||||
height: 28px;
|
||||
-webkit-appearance: none;
|
||||
appearance: none;
|
||||
background: rgba(255,255,255,0.12);
|
||||
border-radius: 14px;
|
||||
border: none;
|
||||
cursor: pointer;
|
||||
transition: background 0.2s;
|
||||
}
|
||||
.toggle:checked { background: #F7931A; }
|
||||
.toggle::before {
|
||||
content: '';
|
||||
position: absolute;
|
||||
top: 3px;
|
||||
left: 3px;
|
||||
width: 22px;
|
||||
height: 22px;
|
||||
border-radius: 50%;
|
||||
background: #fff;
|
||||
transition: transform 0.2s;
|
||||
}
|
||||
.toggle:checked::before { transform: translateX(20px); }
|
||||
|
||||
/* Error */
|
||||
.error-msg {
|
||||
width: 100%;
|
||||
padding: 12px 16px;
|
||||
border-radius: 12px;
|
||||
background: rgba(239,68,68,0.12);
|
||||
border: 1px solid rgba(239,68,68,0.25);
|
||||
color: #ef4444;
|
||||
font-size: 14px;
|
||||
display: none;
|
||||
}
|
||||
.error-msg.visible { display: block; }
|
||||
|
||||
/* Saved servers */
|
||||
.saved-title {
|
||||
font-size: 11px;
|
||||
font-weight: 600;
|
||||
letter-spacing: 1px;
|
||||
color: rgba(255,255,255,0.3);
|
||||
text-transform: uppercase;
|
||||
}
|
||||
|
||||
.saved-item {
|
||||
width: 100%;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
padding: 14px 16px;
|
||||
border-radius: 12px;
|
||||
background: rgba(255,255,255,0.04);
|
||||
border: 1px solid rgba(255,255,255,0.08);
|
||||
cursor: pointer;
|
||||
transition: background 0.15s;
|
||||
}
|
||||
.saved-item:active { background: rgba(255,255,255,0.08); }
|
||||
|
||||
.saved-addr {
|
||||
font-size: 14px;
|
||||
color: #f5f5f5;
|
||||
}
|
||||
|
||||
.saved-remove {
|
||||
background: none;
|
||||
border: none;
|
||||
color: rgba(255,255,255,0.3);
|
||||
font-size: 18px;
|
||||
cursor: pointer;
|
||||
padding: 4px 8px;
|
||||
}
|
||||
|
||||
/* Connecting overlay */
|
||||
#connecting {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
gap: 16px;
|
||||
animation: fadeIn 0.3s ease;
|
||||
}
|
||||
|
||||
.spinner {
|
||||
width: 32px;
|
||||
height: 32px;
|
||||
border: 3px solid rgba(247,147,26,0.2);
|
||||
border-top-color: #F7931A;
|
||||
border-radius: 50%;
|
||||
animation: spin 0.8s linear infinite;
|
||||
}
|
||||
|
||||
@keyframes spin { to { transform: rotate(360deg); } }
|
||||
@keyframes fadeIn { from { opacity: 0; transform: translateY(8px); } to { opacity: 1; transform: none; } }
|
||||
|
||||
/* Hide scrollbar */
|
||||
::-webkit-scrollbar { display: none; }
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
|
||||
<!-- Intro -->
|
||||
<div id="intro">
|
||||
<div class="logo-container">
|
||||
<svg viewBox="0 0 1024 1024" fill="none">
|
||||
<rect width="1024" height="1024" fill="#030202"/>
|
||||
<rect x="357.614" y="318" width="71.007" height="70.936" fill="white" class="logo-square" style="animation-delay:0ms"/>
|
||||
<rect x="436.152" y="318" width="72.082" height="70.936" fill="white" class="logo-square" style="animation-delay:100ms"/>
|
||||
<rect x="515.766" y="318" width="72.082" height="70.936" fill="white" class="logo-square" style="animation-delay:200ms"/>
|
||||
<rect x="595.379" y="318" width="71.007" height="70.936" fill="white" class="logo-square" style="animation-delay:300ms"/>
|
||||
<rect x="595.379" y="396.46" width="71.007" height="72.011" fill="white" class="logo-square" style="animation-delay:400ms"/>
|
||||
<rect x="673.917" y="396.46" width="72.083" height="72.011" fill="white" class="logo-square" style="animation-delay:500ms"/>
|
||||
<rect x="278" y="475.994" width="72.083" height="72.012" fill="white" class="logo-square" style="animation-delay:600ms"/>
|
||||
<rect x="357.614" y="475.994" width="71.007" height="72.012" fill="white" class="logo-square" style="animation-delay:700ms"/>
|
||||
<rect x="436.152" y="475.994" width="72.082" height="72.012" fill="white" class="logo-square" style="animation-delay:800ms"/>
|
||||
<rect x="515.766" y="475.994" width="72.082" height="72.012" fill="white" class="logo-square" style="animation-delay:900ms"/>
|
||||
<rect x="595.379" y="475.994" width="71.007" height="72.012" fill="white" class="logo-square" style="animation-delay:1000ms"/>
|
||||
<rect x="673.917" y="475.994" width="72.083" height="72.012" fill="white" class="logo-square" style="animation-delay:1100ms"/>
|
||||
<rect x="278" y="555.529" width="72.083" height="70.936" fill="white" class="logo-square" style="animation-delay:1200ms"/>
|
||||
<rect x="357.614" y="555.529" width="71.007" height="70.936" fill="white" class="logo-square" style="animation-delay:1300ms"/>
|
||||
<rect x="595.379" y="555.529" width="71.007" height="70.936" fill="white" class="logo-square" style="animation-delay:1400ms"/>
|
||||
<rect x="673.917" y="555.529" width="72.083" height="70.936" fill="white" class="logo-square" style="animation-delay:1500ms"/>
|
||||
<rect x="357.614" y="633.989" width="71.007" height="72.011" fill="white" class="logo-square" style="animation-delay:1600ms"/>
|
||||
<rect x="436.152" y="633.989" width="72.082" height="72.011" fill="white" class="logo-square" style="animation-delay:1700ms"/>
|
||||
<rect x="515.766" y="633.989" width="72.082" height="72.011" fill="white" class="logo-square" style="animation-delay:1800ms"/>
|
||||
<rect x="595.379" y="633.989" width="71.007" height="72.011" fill="white" class="logo-square" style="animation-delay:1900ms"/>
|
||||
</svg>
|
||||
</div>
|
||||
<span class="brand-name">Archipelago</span>
|
||||
<h1>Your Sovereign<br>Personal Server</h1>
|
||||
<p class="subtitle">Bitcoin node, app platform, and private cloud — all in one box you control.</p>
|
||||
<button class="glass-button glass-button-primary" onclick="showConnect()" style="margin-top:16px">Get Started</button>
|
||||
</div>
|
||||
|
||||
<!-- Connect -->
|
||||
<div id="connect" class="hidden">
|
||||
<div class="logo-container" style="width:56px;height:56px;border-radius:14px">
|
||||
<svg viewBox="0 0 1024 1024" fill="none">
|
||||
<rect width="1024" height="1024" fill="#030202"/>
|
||||
<rect x="357.614" y="318" width="71.007" height="70.936" fill="white"/>
|
||||
<rect x="436.152" y="318" width="72.082" height="70.936" fill="white"/>
|
||||
<rect x="515.766" y="318" width="72.082" height="70.936" fill="white"/>
|
||||
<rect x="595.379" y="318" width="71.007" height="70.936" fill="white"/>
|
||||
<rect x="595.379" y="396.46" width="71.007" height="72.011" fill="white"/>
|
||||
<rect x="673.917" y="396.46" width="72.083" height="72.011" fill="white"/>
|
||||
<rect x="278" y="475.994" width="72.083" height="72.012" fill="white"/>
|
||||
<rect x="357.614" y="475.994" width="71.007" height="72.012" fill="white"/>
|
||||
<rect x="436.152" y="475.994" width="72.082" height="72.012" fill="white"/>
|
||||
<rect x="515.766" y="475.994" width="72.082" height="72.012" fill="white"/>
|
||||
<rect x="595.379" y="475.994" width="71.007" height="72.012" fill="white"/>
|
||||
<rect x="673.917" y="475.994" width="72.083" height="72.012" fill="white"/>
|
||||
<rect x="278" y="555.529" width="72.083" height="70.936" fill="white"/>
|
||||
<rect x="357.614" y="555.529" width="71.007" height="70.936" fill="white"/>
|
||||
<rect x="595.379" y="555.529" width="71.007" height="70.936" fill="white"/>
|
||||
<rect x="673.917" y="555.529" width="72.083" height="70.936" fill="white"/>
|
||||
<rect x="357.614" y="633.989" width="71.007" height="72.011" fill="white"/>
|
||||
<rect x="436.152" y="633.989" width="72.082" height="72.011" fill="white"/>
|
||||
<rect x="515.766" y="633.989" width="72.082" height="72.011" fill="white"/>
|
||||
<rect x="595.379" y="633.989" width="71.007" height="72.011" fill="white"/>
|
||||
</svg>
|
||||
</div>
|
||||
<h1 style="font-size:22px">Connect to Server</h1>
|
||||
<p class="subtitle" style="font-size:14px">Enter your Archipelago server IP or hostname</p>
|
||||
|
||||
<div class="glass-card">
|
||||
<div class="form-group">
|
||||
<label>Server Address</label>
|
||||
<input type="text" id="address" placeholder="192.168.1.100" autocomplete="off" autocorrect="off" autocapitalize="off" spellcheck="false">
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<div class="port-row">
|
||||
<div class="port-input">
|
||||
<label>Port (optional)</label>
|
||||
<input type="text" id="port" placeholder="80" inputmode="numeric" pattern="[0-9]*">
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<div class="toggle-row">
|
||||
<span class="toggle-label">
|
||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><rect x="3" y="11" width="18" height="11" rx="2"/><path d="M7 11V7a5 5 0 0110 0v4"/></svg>
|
||||
Use HTTPS
|
||||
</span>
|
||||
<input type="checkbox" id="https" class="toggle">
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div id="error" class="error-msg"></div>
|
||||
|
||||
<button class="glass-button glass-button-primary" id="connectBtn" onclick="doConnect()" disabled>Connect</button>
|
||||
|
||||
<div id="savedServers"></div>
|
||||
</div>
|
||||
|
||||
<!-- Connecting -->
|
||||
<div id="connecting" class="hidden">
|
||||
<div class="spinner"></div>
|
||||
<p style="color:rgba(255,255,255,0.6);font-size:14px">Connecting…</p>
|
||||
</div>
|
||||
|
||||
<script>
|
||||
var STORAGE_KEY = 'archipelago_servers';
|
||||
var ACTIVE_KEY = 'archipelago_active';
|
||||
|
||||
function showConnect() {
|
||||
document.getElementById('intro').classList.add('hidden');
|
||||
document.getElementById('connect').classList.remove('hidden');
|
||||
document.getElementById('address').focus();
|
||||
renderSaved();
|
||||
}
|
||||
|
||||
// Enable button when address has content
|
||||
document.getElementById('address').addEventListener('input', function() {
|
||||
document.getElementById('connectBtn').disabled = !this.value.trim();
|
||||
});
|
||||
|
||||
// Enter to connect
|
||||
document.getElementById('address').addEventListener('keydown', function(e) {
|
||||
if (e.key === 'Enter' && this.value.trim()) doConnect();
|
||||
});
|
||||
document.getElementById('port').addEventListener('keydown', function(e) {
|
||||
if (e.key === 'Enter') doConnect();
|
||||
});
|
||||
|
||||
function buildUrl() {
|
||||
var addr = document.getElementById('address').value.trim();
|
||||
var port = document.getElementById('port').value.trim();
|
||||
var https = document.getElementById('https').checked;
|
||||
var scheme = https ? 'https' : 'http';
|
||||
var portSuffix = port ? ':' + port : '';
|
||||
return scheme + '://' + addr + portSuffix;
|
||||
}
|
||||
|
||||
function doConnect() {
|
||||
var addr = document.getElementById('address').value.trim();
|
||||
if (!addr) return;
|
||||
var url = buildUrl();
|
||||
|
||||
document.getElementById('connect').classList.add('hidden');
|
||||
document.getElementById('connecting').classList.remove('hidden');
|
||||
document.getElementById('error').classList.remove('visible');
|
||||
|
||||
// Save and navigate directly — no XHR test needed,
|
||||
// the WebView error handler catches failures
|
||||
saveServer(url);
|
||||
localStorage.setItem(ACTIVE_KEY, url);
|
||||
AndroidBridge.onConnected(url);
|
||||
}
|
||||
|
||||
function saveServer(url) {
|
||||
var saved = JSON.parse(localStorage.getItem(STORAGE_KEY) || '[]');
|
||||
if (saved.indexOf(url) === -1) saved.push(url);
|
||||
localStorage.setItem(STORAGE_KEY, JSON.stringify(saved));
|
||||
}
|
||||
|
||||
function removeServer(url) {
|
||||
var saved = JSON.parse(localStorage.getItem(STORAGE_KEY) || '[]');
|
||||
saved = saved.filter(function(s) { return s !== url; });
|
||||
localStorage.setItem(STORAGE_KEY, JSON.stringify(saved));
|
||||
renderSaved();
|
||||
}
|
||||
|
||||
function connectSaved(url) {
|
||||
document.getElementById('intro').classList.add('hidden');
|
||||
document.getElementById('connect').classList.add('hidden');
|
||||
document.getElementById('connecting').classList.remove('hidden');
|
||||
localStorage.setItem(ACTIVE_KEY, url);
|
||||
AndroidBridge.onConnected(url);
|
||||
}
|
||||
|
||||
function renderSaved() {
|
||||
var saved = JSON.parse(localStorage.getItem(STORAGE_KEY) || '[]');
|
||||
var container = document.getElementById('savedServers');
|
||||
if (!saved.length) { container.innerHTML = ''; return; }
|
||||
var html = '<p class="saved-title" style="margin-top:8px;margin-bottom:8px">Saved Servers</p>';
|
||||
saved.forEach(function(url) {
|
||||
html += '<div class="saved-item" onclick="connectSaved(\'' + url + '\')">' +
|
||||
'<span class="saved-addr">' + url.replace(/^https?:\/\//, '') + '</span>' +
|
||||
'<button class="saved-remove" onclick="event.stopPropagation();removeServer(\'' + url + '\')">×</button>' +
|
||||
'</div>';
|
||||
});
|
||||
container.innerHTML = html;
|
||||
}
|
||||
|
||||
// On load: check if already connected
|
||||
(function() {
|
||||
var active = localStorage.getItem(ACTIVE_KEY);
|
||||
if (active) {
|
||||
connectSaved(active);
|
||||
}
|
||||
})();
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,5 @@
|
||||
package com.archipelago.app
|
||||
|
||||
import android.app.Application
|
||||
|
||||
class ArchipelagoApp : Application()
|
||||
@@ -0,0 +1,22 @@
|
||||
package com.archipelago.app
|
||||
|
||||
import android.os.Bundle
|
||||
import androidx.activity.ComponentActivity
|
||||
import androidx.activity.compose.setContent
|
||||
import androidx.activity.enableEdgeToEdge
|
||||
import androidx.core.splashscreen.SplashScreen.Companion.installSplashScreen
|
||||
import com.archipelago.app.ui.navigation.AppNavHost
|
||||
import com.archipelago.app.ui.theme.ArchipelagoTheme
|
||||
|
||||
class MainActivity : ComponentActivity() {
|
||||
override fun onCreate(savedInstanceState: Bundle?) {
|
||||
installSplashScreen()
|
||||
enableEdgeToEdge()
|
||||
super.onCreate(savedInstanceState)
|
||||
setContent {
|
||||
ArchipelagoTheme {
|
||||
AppNavHost()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,116 @@
|
||||
package com.archipelago.app.data
|
||||
|
||||
import android.content.Context
|
||||
import androidx.datastore.core.DataStore
|
||||
import androidx.datastore.preferences.core.Preferences
|
||||
import androidx.datastore.preferences.core.booleanPreferencesKey
|
||||
import androidx.datastore.preferences.core.edit
|
||||
import androidx.datastore.preferences.core.stringPreferencesKey
|
||||
import androidx.datastore.preferences.core.stringSetPreferencesKey
|
||||
import androidx.datastore.preferences.preferencesDataStore
|
||||
import kotlinx.coroutines.flow.Flow
|
||||
import kotlinx.coroutines.flow.map
|
||||
|
||||
private val Context.dataStore: DataStore<Preferences> by preferencesDataStore(name = "server_prefs")
|
||||
|
||||
data class ServerEntry(
|
||||
val address: String,
|
||||
val useHttps: Boolean,
|
||||
val port: String = "",
|
||||
val password: String = "",
|
||||
) {
|
||||
fun toUrl(): String {
|
||||
val scheme = if (useHttps) "https" else "http"
|
||||
val portSuffix = if (port.isNotBlank()) ":$port" else ""
|
||||
return "$scheme://$address$portSuffix"
|
||||
}
|
||||
|
||||
fun toWsUrl(): String {
|
||||
val scheme = if (useHttps) "wss" else "ws"
|
||||
val portSuffix = if (port.isNotBlank()) ":$port" else ""
|
||||
return "$scheme://$address$portSuffix"
|
||||
}
|
||||
|
||||
fun serialize(): String = "$address|$useHttps|$port|$password"
|
||||
|
||||
companion object {
|
||||
fun deserialize(raw: String): ServerEntry? {
|
||||
val parts = raw.split("|")
|
||||
if (parts.size < 2) return null
|
||||
return ServerEntry(
|
||||
address = parts[0],
|
||||
useHttps = parts[1].toBooleanStrictOrNull() ?: false,
|
||||
port = parts.getOrElse(2) { "" },
|
||||
password = parts.getOrElse(3) { "" },
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
class ServerPreferences(private val context: Context) {
|
||||
|
||||
private val activeAddressKey = stringPreferencesKey("active_address")
|
||||
private val activeHttpsKey = booleanPreferencesKey("active_https")
|
||||
private val activePortKey = stringPreferencesKey("active_port")
|
||||
private val activePasswordKey = stringPreferencesKey("active_password")
|
||||
private val savedServersKey = stringSetPreferencesKey("saved_servers")
|
||||
private val introSeenKey = booleanPreferencesKey("intro_seen")
|
||||
|
||||
val activeServer: Flow<ServerEntry?> = context.dataStore.data.map { prefs ->
|
||||
val address = prefs[activeAddressKey] ?: return@map null
|
||||
ServerEntry(
|
||||
address = address,
|
||||
useHttps = prefs[activeHttpsKey] ?: false,
|
||||
port = prefs[activePortKey] ?: "",
|
||||
password = prefs[activePasswordKey] ?: "",
|
||||
)
|
||||
}
|
||||
|
||||
val savedServers: Flow<List<ServerEntry>> = context.dataStore.data.map { prefs ->
|
||||
val raw = prefs[savedServersKey] ?: emptySet()
|
||||
raw.mapNotNull { ServerEntry.deserialize(it) }
|
||||
}
|
||||
|
||||
val introSeen: Flow<Boolean> = context.dataStore.data.map { prefs ->
|
||||
prefs[introSeenKey] ?: false
|
||||
}
|
||||
|
||||
suspend fun setActiveServer(server: ServerEntry) {
|
||||
context.dataStore.edit { prefs ->
|
||||
prefs[activeAddressKey] = server.address
|
||||
prefs[activeHttpsKey] = server.useHttps
|
||||
prefs[activePortKey] = server.port
|
||||
prefs[activePasswordKey] = server.password
|
||||
}
|
||||
addSavedServer(server)
|
||||
}
|
||||
|
||||
suspend fun clearActiveServer() {
|
||||
context.dataStore.edit { prefs ->
|
||||
prefs.remove(activeAddressKey)
|
||||
prefs.remove(activeHttpsKey)
|
||||
prefs.remove(activePortKey)
|
||||
prefs.remove(activePasswordKey)
|
||||
}
|
||||
}
|
||||
|
||||
suspend fun addSavedServer(server: ServerEntry) {
|
||||
context.dataStore.edit { prefs ->
|
||||
val current = prefs[savedServersKey] ?: emptySet()
|
||||
prefs[savedServersKey] = current + server.serialize()
|
||||
}
|
||||
}
|
||||
|
||||
suspend fun removeSavedServer(server: ServerEntry) {
|
||||
context.dataStore.edit { prefs ->
|
||||
val current = prefs[savedServersKey] ?: emptySet()
|
||||
prefs[savedServersKey] = current - server.serialize()
|
||||
}
|
||||
}
|
||||
|
||||
suspend fun markIntroSeen() {
|
||||
context.dataStore.edit { prefs ->
|
||||
prefs[introSeenKey] = true
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,177 @@
|
||||
package com.archipelago.app.network
|
||||
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.Job
|
||||
import kotlinx.coroutines.delay
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.flow.StateFlow
|
||||
import kotlinx.coroutines.launch
|
||||
import kotlinx.coroutines.withContext
|
||||
import okhttp3.MediaType.Companion.toMediaType
|
||||
import okhttp3.OkHttpClient
|
||||
import okhttp3.Request
|
||||
import okhttp3.RequestBody.Companion.toRequestBody
|
||||
import okhttp3.Response
|
||||
import okhttp3.WebSocket
|
||||
import okhttp3.WebSocketListener
|
||||
import java.security.cert.X509Certificate
|
||||
import java.util.concurrent.TimeUnit
|
||||
import javax.net.ssl.SSLContext
|
||||
import javax.net.ssl.X509TrustManager
|
||||
|
||||
enum class ConnectionState { DISCONNECTED, CONNECTING, CONNECTED, AUTH_FAILED, ERROR }
|
||||
|
||||
class InputWebSocket(
|
||||
private val scope: CoroutineScope,
|
||||
) {
|
||||
private var ws: WebSocket? = null
|
||||
private var reconnectJob: Job? = null
|
||||
private var reconnectAttempt = 0
|
||||
private var serverUrl: String = ""
|
||||
private var password: String = ""
|
||||
private var sessionCookie: String? = null
|
||||
|
||||
private val _state = MutableStateFlow(ConnectionState.DISCONNECTED)
|
||||
val state: StateFlow<ConnectionState> = _state
|
||||
|
||||
private val trustManager = object : X509TrustManager {
|
||||
override fun checkClientTrusted(chain: Array<X509Certificate>?, authType: String?) {}
|
||||
override fun checkServerTrusted(chain: Array<X509Certificate>?, authType: String?) {}
|
||||
override fun getAcceptedIssuers(): Array<X509Certificate> = arrayOf()
|
||||
}
|
||||
|
||||
private val client: OkHttpClient by lazy {
|
||||
val sc = SSLContext.getInstance("TLS")
|
||||
sc.init(null, arrayOf(trustManager), java.security.SecureRandom())
|
||||
|
||||
OkHttpClient.Builder()
|
||||
.sslSocketFactory(sc.socketFactory, trustManager)
|
||||
.hostnameVerifier { _, _ -> true }
|
||||
.pingInterval(30, TimeUnit.SECONDS)
|
||||
.readTimeout(0, TimeUnit.MILLISECONDS)
|
||||
.connectTimeout(10, TimeUnit.SECONDS)
|
||||
.build()
|
||||
}
|
||||
|
||||
fun connect(httpUrl: String, pwd: String = "") {
|
||||
disconnect()
|
||||
serverUrl = httpUrl
|
||||
password = pwd
|
||||
sessionCookie = null
|
||||
reconnectAttempt = 0
|
||||
scope.launch(Dispatchers.IO) { doAuth() }
|
||||
}
|
||||
|
||||
private suspend fun doAuth() {
|
||||
_state.value = ConnectionState.CONNECTING
|
||||
|
||||
if (password.isBlank()) {
|
||||
doConnect()
|
||||
return
|
||||
}
|
||||
|
||||
try {
|
||||
val body = """{"method":"auth.login","params":{"password":"$password"}}"""
|
||||
.toRequestBody("application/json".toMediaType())
|
||||
val req = Request.Builder()
|
||||
.url("$serverUrl/rpc/v1")
|
||||
.post(body)
|
||||
.build()
|
||||
|
||||
val response = withContext(Dispatchers.IO) { client.newCall(req).execute() }
|
||||
|
||||
if (response.isSuccessful) {
|
||||
sessionCookie = response.headers("Set-Cookie")
|
||||
.mapNotNull { cookie ->
|
||||
cookie.split(";")
|
||||
.firstOrNull()
|
||||
?.trim()
|
||||
?.takeIf { it.startsWith("session=") }
|
||||
?.removePrefix("session=")
|
||||
}
|
||||
.firstOrNull()
|
||||
response.close()
|
||||
|
||||
if (sessionCookie != null) {
|
||||
doConnect()
|
||||
} else {
|
||||
_state.value = ConnectionState.AUTH_FAILED
|
||||
}
|
||||
} else {
|
||||
response.close()
|
||||
_state.value = ConnectionState.AUTH_FAILED
|
||||
}
|
||||
} catch (_: Exception) {
|
||||
_state.value = ConnectionState.ERROR
|
||||
scheduleReconnect()
|
||||
}
|
||||
}
|
||||
|
||||
private fun doConnect() {
|
||||
val wsUrl = serverUrl
|
||||
.replace("https://", "wss://")
|
||||
.replace("http://", "ws://")
|
||||
.trimEnd('/') + "/ws/remote-input"
|
||||
|
||||
val reqBuilder = Request.Builder().url(wsUrl)
|
||||
sessionCookie?.let { reqBuilder.header("Cookie", "session=$it") }
|
||||
|
||||
ws = client.newWebSocket(reqBuilder.build(), object : WebSocketListener() {
|
||||
override fun onOpen(webSocket: WebSocket, response: Response) {
|
||||
_state.value = ConnectionState.CONNECTED
|
||||
reconnectAttempt = 0
|
||||
}
|
||||
|
||||
override fun onFailure(webSocket: WebSocket, t: Throwable, response: Response?) {
|
||||
_state.value = ConnectionState.ERROR
|
||||
scheduleReconnect()
|
||||
}
|
||||
|
||||
override fun onClosing(webSocket: WebSocket, code: Int, reason: String) {
|
||||
webSocket.close(1000, null)
|
||||
_state.value = ConnectionState.DISCONNECTED
|
||||
if (code != 1000) scheduleReconnect()
|
||||
}
|
||||
|
||||
override fun onClosed(webSocket: WebSocket, code: Int, reason: String) {
|
||||
_state.value = ConnectionState.DISCONNECTED
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
private fun scheduleReconnect() {
|
||||
reconnectJob?.cancel()
|
||||
reconnectJob = scope.launch(Dispatchers.IO) {
|
||||
val delayMs = minOf(1000L * (1 shl minOf(reconnectAttempt, 5)), 30_000L)
|
||||
reconnectAttempt++
|
||||
delay(delayMs)
|
||||
doAuth()
|
||||
}
|
||||
}
|
||||
|
||||
fun disconnect() {
|
||||
reconnectJob?.cancel()
|
||||
ws?.close(1000, "bye")
|
||||
ws = null
|
||||
_state.value = ConnectionState.DISCONNECTED
|
||||
}
|
||||
|
||||
// ─── Input senders ──────────────────────────────────────────
|
||||
|
||||
fun sendKey(key: String) {
|
||||
ws?.send("""{"t":"k","k":"$key"}""")
|
||||
}
|
||||
|
||||
fun sendMouseMove(dx: Int, dy: Int) {
|
||||
ws?.send("""{"t":"m","x":$dx,"y":$dy}""")
|
||||
}
|
||||
|
||||
fun sendClick(button: Int = 1) {
|
||||
ws?.send("""{"t":"c","b":$button}""")
|
||||
}
|
||||
|
||||
fun sendScroll(dy: Int) {
|
||||
ws?.send("""{"t":"s","y":$dy}""")
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,56 @@
|
||||
package com.archipelago.app.ui.components
|
||||
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.gestures.detectTapGestures
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.draw.clip
|
||||
import androidx.compose.ui.input.pointer.pointerInput
|
||||
import androidx.compose.ui.text.font.FontWeight
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.compose.ui.unit.sp
|
||||
import com.archipelago.app.ui.theme.BitcoinOrange
|
||||
import com.archipelago.app.ui.theme.Neo
|
||||
import com.archipelago.app.ui.theme.neoInset
|
||||
import com.archipelago.app.ui.theme.neoRaised
|
||||
|
||||
private val R = 14.dp
|
||||
|
||||
@Composable
|
||||
fun ActionButtons(
|
||||
onEscape: () -> Unit,
|
||||
onEnter: () -> Unit,
|
||||
modifier: Modifier = Modifier,
|
||||
) {
|
||||
Column(modifier = modifier, verticalArrangement = Arrangement.spacedBy(10.dp), horizontalAlignment = Alignment.CenterHorizontally) {
|
||||
NeoBtn("ESC", Neo.textSecondary(), Modifier.fillMaxWidth().weight(1f), onEscape)
|
||||
NeoBtn("ENTER", BitcoinOrange.copy(alpha = 0.7f), Modifier.fillMaxWidth().weight(1f), onEnter)
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun NeoBtn(label: String, color: androidx.compose.ui.graphics.Color, modifier: Modifier, onClick: () -> Unit) {
|
||||
var p by remember { mutableStateOf(false) }
|
||||
val l = Neo.shadowLight(); val d = Neo.shadowDark()
|
||||
Box(
|
||||
modifier = modifier
|
||||
.then(if (p) Modifier.neoInset(l, d, R, 1.dp, 2.dp) else Modifier.neoRaised(l, d, R, 2.dp, 4.dp))
|
||||
.clip(RoundedCornerShape(R))
|
||||
.background(Neo.surfaceRaised())
|
||||
.pointerInput(Unit) { detectTapGestures(onPress = { p = true; onClick(); tryAwaitRelease(); p = false }) },
|
||||
contentAlignment = Alignment.Center,
|
||||
) {
|
||||
Text(label, color = if (p) color else color.copy(alpha = 0.7f), fontSize = 12.sp, fontWeight = FontWeight.Bold, letterSpacing = 1.5.sp)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,119 @@
|
||||
package com.archipelago.app.ui.components
|
||||
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.gestures.detectTapGestures
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.layout.size
|
||||
import androidx.compose.foundation.shape.CircleShape
|
||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.automirrored.filled.KeyboardArrowLeft
|
||||
import androidx.compose.material.icons.automirrored.filled.KeyboardArrowRight
|
||||
import androidx.compose.material.icons.filled.KeyboardArrowDown
|
||||
import androidx.compose.material.icons.filled.KeyboardArrowUp
|
||||
import androidx.compose.material3.Icon
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.DisposableEffect
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.rememberCoroutineScope
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.draw.clip
|
||||
import androidx.compose.ui.graphics.vector.ImageVector
|
||||
import androidx.compose.ui.input.pointer.pointerInput
|
||||
import androidx.compose.ui.unit.dp
|
||||
import com.archipelago.app.ui.theme.BitcoinOrange
|
||||
import com.archipelago.app.ui.theme.Neo
|
||||
import com.archipelago.app.ui.theme.neoInset
|
||||
import com.archipelago.app.ui.theme.neoRaised
|
||||
import kotlinx.coroutines.Job
|
||||
import kotlinx.coroutines.delay
|
||||
import kotlinx.coroutines.launch
|
||||
|
||||
private val BTN = 50.dp
|
||||
private val BTN_R = 12.dp
|
||||
private val GAP = 8.dp
|
||||
private val NOB = 24.dp
|
||||
|
||||
@Composable
|
||||
fun DPad(
|
||||
onDirection: (String) -> Unit,
|
||||
modifier: Modifier = Modifier,
|
||||
) {
|
||||
val surface = Neo.surface()
|
||||
val raised = Neo.surfaceRaised()
|
||||
val l = Neo.shadowLight()
|
||||
val d = Neo.shadowDark()
|
||||
|
||||
// Recessed well
|
||||
Box(
|
||||
modifier = modifier
|
||||
.neoInset(l, d, 20.dp, 2.dp, 4.dp)
|
||||
.clip(RoundedCornerShape(20.dp))
|
||||
.background(surface)
|
||||
.padding(14.dp),
|
||||
contentAlignment = Alignment.Center,
|
||||
) {
|
||||
// Cross layout with explicit spacing
|
||||
Column(horizontalAlignment = Alignment.CenterHorizontally) {
|
||||
Btn(Icons.Default.KeyboardArrowUp, "Up", onDirection)
|
||||
Box(modifier = Modifier.size(height = GAP, width = BTN)) // spacer
|
||||
Row(verticalAlignment = Alignment.CenterVertically) {
|
||||
Btn(Icons.AutoMirrored.Filled.KeyboardArrowLeft, "Left", onDirection)
|
||||
Box(modifier = Modifier.size(width = GAP, height = BTN)) // spacer
|
||||
// Center nob
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.size(NOB)
|
||||
.neoRaised(l, d, NOB / 2, 1.dp, 2.dp)
|
||||
.clip(CircleShape)
|
||||
.background(raised),
|
||||
contentAlignment = Alignment.Center,
|
||||
) {
|
||||
Box(Modifier.size(8.dp).clip(CircleShape).background(BitcoinOrange.copy(alpha = 0.15f)))
|
||||
}
|
||||
Box(modifier = Modifier.size(width = GAP, height = BTN)) // spacer
|
||||
Btn(Icons.AutoMirrored.Filled.KeyboardArrowRight, "Right", onDirection)
|
||||
}
|
||||
Box(modifier = Modifier.size(height = GAP, width = BTN)) // spacer
|
||||
Btn(Icons.Default.KeyboardArrowDown, "Down", onDirection)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun Btn(icon: ImageVector, key: String, onDir: (String) -> Unit) {
|
||||
val scope = rememberCoroutineScope()
|
||||
var job by remember { mutableStateOf<Job?>(null) }
|
||||
var p by remember { mutableStateOf(false) }
|
||||
val bg = Neo.surfaceRaised()
|
||||
val l = Neo.shadowLight()
|
||||
val d = Neo.shadowDark()
|
||||
val tint = Neo.textPrimary()
|
||||
DisposableEffect(Unit) { onDispose { job?.cancel() } }
|
||||
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.size(BTN)
|
||||
.then(if (p) Modifier.neoInset(l, d, BTN_R, 1.dp, 2.dp) else Modifier.neoRaised(l, d, BTN_R, 2.dp, 4.dp))
|
||||
.clip(RoundedCornerShape(BTN_R))
|
||||
.background(bg)
|
||||
.pointerInput(key) {
|
||||
detectTapGestures(onPress = {
|
||||
p = true; onDir(key)
|
||||
job = scope.launch { delay(350); while (true) { onDir(key); delay(100) } }
|
||||
tryAwaitRelease(); p = false; job?.cancel()
|
||||
})
|
||||
},
|
||||
contentAlignment = Alignment.Center,
|
||||
) {
|
||||
Icon(icon, key, Modifier.fillMaxSize(0.48f), tint = if (p) tint.copy(alpha = 0.9f) else tint.copy(alpha = 0.5f))
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,134 @@
|
||||
package com.archipelago.app.ui.components
|
||||
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.gestures.awaitEachGesture
|
||||
import androidx.compose.foundation.gestures.awaitFirstDown
|
||||
import androidx.compose.foundation.gestures.detectTapGestures
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.foundation.layout.height
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.layout.size
|
||||
import androidx.compose.foundation.layout.width
|
||||
import androidx.compose.foundation.shape.CircleShape
|
||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.draw.clip
|
||||
import androidx.compose.ui.input.pointer.changedToUp
|
||||
import androidx.compose.ui.input.pointer.pointerInput
|
||||
import androidx.compose.ui.text.font.FontWeight
|
||||
import androidx.compose.ui.unit.Dp
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.compose.ui.unit.sp
|
||||
import com.archipelago.app.ui.theme.BitcoinOrange
|
||||
import com.archipelago.app.ui.theme.Neo
|
||||
import com.archipelago.app.ui.theme.neoInset
|
||||
import com.archipelago.app.ui.theme.neoRaised
|
||||
|
||||
@Composable
|
||||
fun GamepadLayout(
|
||||
onKey: (String) -> Unit,
|
||||
onTwoFingerHold: () -> Unit,
|
||||
modifier: Modifier = Modifier,
|
||||
) {
|
||||
val surface = Neo.surface()
|
||||
|
||||
Box(
|
||||
modifier = modifier
|
||||
.fillMaxSize()
|
||||
.background(surface)
|
||||
.pointerInput(Unit) {
|
||||
awaitEachGesture {
|
||||
awaitFirstDown(requireUnconsumed = false)
|
||||
var t = 0L; var fired = false
|
||||
do {
|
||||
val ev = awaitPointerEvent()
|
||||
val a = ev.changes.filter { !it.changedToUp() }
|
||||
if (a.size >= 2 && t == 0L) t = System.currentTimeMillis()
|
||||
if (a.size >= 2 && !fired && t > 0 && System.currentTimeMillis() - t > 500) { fired = true; onTwoFingerHold() }
|
||||
if (a.size < 2) t = 0L
|
||||
} while (ev.changes.any { it.pressed })
|
||||
}
|
||||
}
|
||||
.padding(horizontal = 24.dp, vertical = 16.dp),
|
||||
) {
|
||||
// D-pad — centered left
|
||||
DPad(
|
||||
onDirection = onKey,
|
||||
modifier = Modifier.align(Alignment.CenterStart).size(200.dp),
|
||||
)
|
||||
|
||||
// Face buttons — centered right (diamond)
|
||||
Column(
|
||||
modifier = Modifier.align(Alignment.CenterEnd),
|
||||
horizontalAlignment = Alignment.CenterHorizontally,
|
||||
verticalArrangement = Arrangement.spacedBy(6.dp),
|
||||
) {
|
||||
FaceBtn("esc", 64.dp) { onKey("Escape") }
|
||||
Row(horizontalArrangement = Arrangement.spacedBy(28.dp)) {
|
||||
FaceBtn("tab", 64.dp) { onKey("Tab") }
|
||||
FaceBtn("enter", 64.dp, accent = true) { onKey("Return") }
|
||||
}
|
||||
FaceBtn("bksp", 64.dp) { onKey("BackSpace") }
|
||||
}
|
||||
|
||||
// Bottom: L, SELECT, START, R
|
||||
Row(
|
||||
modifier = Modifier.align(Alignment.BottomCenter),
|
||||
horizontalArrangement = Arrangement.spacedBy(16.dp),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
) {
|
||||
PillBtn("L", 56.dp) { onKey("Prior") }
|
||||
PillBtn("SELECT", 80.dp) { onKey("Escape") }
|
||||
PillBtn("START", 80.dp) { onKey("Return") }
|
||||
PillBtn("R", 56.dp) { onKey("Next") }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun FaceBtn(label: String, size: Dp, accent: Boolean = false, onClick: () -> Unit) {
|
||||
var p by remember { mutableStateOf(false) }
|
||||
val l = Neo.shadowLight(); val d = Neo.shadowDark()
|
||||
val tc = if (accent) BitcoinOrange.copy(alpha = 0.7f) else Neo.textSecondary()
|
||||
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.size(size)
|
||||
.then(if (p) Modifier.neoInset(l, d, size / 2, 1.dp, 3.dp) else Modifier.neoRaised(l, d, size / 2, 2.dp, 4.dp))
|
||||
.clip(CircleShape)
|
||||
.background(Neo.surfaceRaised())
|
||||
.pointerInput(Unit) { detectTapGestures(onPress = { p = true; onClick(); tryAwaitRelease(); p = false }) },
|
||||
contentAlignment = Alignment.Center,
|
||||
) {
|
||||
Text(label, color = if (p) tc.copy(alpha = 1f) else tc, fontSize = 12.sp, fontWeight = FontWeight.SemiBold, letterSpacing = 0.5.sp)
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun PillBtn(label: String, w: Dp, onClick: () -> Unit) {
|
||||
var p by remember { mutableStateOf(false) }
|
||||
val l = Neo.shadowLight(); val d = Neo.shadowDark()
|
||||
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.width(w).height(34.dp)
|
||||
.then(if (p) Modifier.neoInset(l, d, 8.dp, 1.dp, 2.dp) else Modifier.neoRaised(l, d, 8.dp, 2.dp, 4.dp))
|
||||
.clip(RoundedCornerShape(8.dp))
|
||||
.background(Neo.surfaceRaised())
|
||||
.pointerInput(Unit) { detectTapGestures(onPress = { p = true; onClick(); tryAwaitRelease(); p = false }) },
|
||||
contentAlignment = Alignment.Center,
|
||||
) {
|
||||
Text(label, color = Neo.textMuted(), fontSize = 9.sp, fontWeight = FontWeight.Medium, letterSpacing = 1.sp)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,403 @@
|
||||
package com.archipelago.app.ui.components
|
||||
|
||||
import androidx.compose.foundation.Canvas
|
||||
import androidx.compose.foundation.Image
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.border
|
||||
import androidx.compose.foundation.gestures.awaitEachGesture
|
||||
import androidx.compose.foundation.gestures.awaitFirstDown
|
||||
import androidx.compose.foundation.gestures.detectTapGestures
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.Spacer
|
||||
import androidx.compose.foundation.layout.aspectRatio
|
||||
import androidx.compose.foundation.layout.fillMaxHeight
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.height
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.layout.size
|
||||
import androidx.compose.foundation.layout.width
|
||||
import androidx.compose.foundation.shape.CircleShape
|
||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.filled.Settings
|
||||
import androidx.compose.material3.Icon
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.DisposableEffect
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.rememberCoroutineScope
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.draw.clip
|
||||
import androidx.compose.ui.draw.shadow
|
||||
import androidx.compose.ui.geometry.CornerRadius
|
||||
import androidx.compose.ui.geometry.Offset
|
||||
import androidx.compose.ui.geometry.Size
|
||||
import androidx.compose.ui.graphics.Brush
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.graphics.ColorFilter
|
||||
import androidx.compose.ui.graphics.Path
|
||||
import androidx.compose.ui.graphics.drawscope.DrawScope
|
||||
import androidx.compose.ui.graphics.drawscope.Fill
|
||||
import androidx.compose.ui.input.pointer.changedToUp
|
||||
import androidx.compose.ui.input.pointer.pointerInput
|
||||
import androidx.compose.ui.res.painterResource
|
||||
import androidx.compose.ui.text.font.FontWeight
|
||||
import androidx.compose.ui.unit.Dp
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.compose.ui.unit.sp
|
||||
import com.archipelago.app.R
|
||||
import com.archipelago.app.ui.theme.ControllerStyle
|
||||
import com.archipelago.app.ui.theme.NES
|
||||
import kotlinx.coroutines.Job
|
||||
import kotlinx.coroutines.delay
|
||||
import kotlinx.coroutines.launch
|
||||
import kotlin.math.abs
|
||||
|
||||
// ═══════════════════════════════════════════════════════════
|
||||
// Palettes
|
||||
// ═══════════════════════════════════════════════════════════
|
||||
|
||||
data class NESPalette(
|
||||
val body: Color, val face: Color, val ridge: Color,
|
||||
val label: Color, val labelMuted: Color,
|
||||
val dpad: Color, val dpadHi: Color,
|
||||
val btn: Color, val btnPress: Color,
|
||||
val capsule: Color, val capsulePress: Color,
|
||||
val inlayBg: Color, val inlayBorder: Color,
|
||||
)
|
||||
|
||||
val ClassicPalette = NESPalette(
|
||||
body = NES.ClassicBody, face = NES.ClassicFace, ridge = NES.ClassicRidge,
|
||||
label = NES.ClassicLabel, labelMuted = NES.ClassicLabelMuted,
|
||||
dpad = Color(0xFF0C0C0C), dpadHi = Color(0xFF1A1A1A),
|
||||
btn = NES.ClassicButtonRed, btnPress = NES.ClassicButtonRedPress,
|
||||
capsule = Color(0xFF1C1C1C), capsulePress = Color(0xFF0E0E0E),
|
||||
inlayBg = Color(0xFF080808), inlayBorder = Color(0xFF999999),
|
||||
)
|
||||
|
||||
val DarkPalette = NESPalette(
|
||||
body = NES.DarkBody, face = NES.DarkFace, ridge = NES.DarkRidge,
|
||||
label = NES.DarkLabel, labelMuted = NES.DarkLabelMuted,
|
||||
dpad = Color(0xFF080808), dpadHi = Color(0xFF141418),
|
||||
btn = NES.DarkButtonMain, btnPress = NES.DarkButtonMainPress,
|
||||
capsule = Color(0xFF121216), capsulePress = Color(0xFF0A0A0C),
|
||||
inlayBg = Color(0xFF060608), inlayBorder = Color(0xFF444448),
|
||||
)
|
||||
|
||||
fun paletteFor(style: ControllerStyle) = if (style == ControllerStyle.CLASSIC) ClassicPalette else DarkPalette
|
||||
|
||||
// ═══════════════════════════════════════════════════════════
|
||||
// Landscape NES Controller
|
||||
// ═══════════════════════════════════════════════════════════
|
||||
|
||||
@Composable
|
||||
fun NESController(
|
||||
style: ControllerStyle = ControllerStyle.CLASSIC,
|
||||
onKey: (String) -> Unit,
|
||||
onMenu: () -> Unit,
|
||||
modifier: Modifier = Modifier,
|
||||
) {
|
||||
val c = paletteFor(style)
|
||||
val isClassic = style == ControllerStyle.CLASSIC
|
||||
|
||||
Box(
|
||||
modifier = modifier
|
||||
.fillMaxSize()
|
||||
.background(Color(0xFF0C0C0C)) // Slightly lighter than black for shadow visibility
|
||||
.twoFingerHold(onMenu)
|
||||
.padding(horizontal = 40.dp, vertical = 24.dp),
|
||||
contentAlignment = Alignment.Center,
|
||||
) {
|
||||
// Shadow platform
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth(0.86f)
|
||||
.aspectRatio(2.3f)
|
||||
.padding(top = 6.dp)
|
||||
.clip(RoundedCornerShape(18.dp))
|
||||
.background(Color(0xFF000000)),
|
||||
)
|
||||
// Controller body
|
||||
Box(
|
||||
Modifier
|
||||
.fillMaxWidth(0.86f)
|
||||
.aspectRatio(2.3f)
|
||||
.shadow(32.dp, RoundedCornerShape(16.dp), ambientColor = Color(0xFF000000), spotColor = Color(0xFF000000))
|
||||
.clip(RoundedCornerShape(16.dp))
|
||||
.background(
|
||||
Brush.verticalGradient(listOf(c.body, c.body.copy(alpha = 0.95f)))
|
||||
)
|
||||
.border(1.dp, Color.White.copy(alpha = if (isClassic) 0.08f else 0.04f), RoundedCornerShape(16.dp)),
|
||||
) {
|
||||
// Top highlight edge
|
||||
Box(
|
||||
Modifier.fillMaxWidth().height(1.dp).align(Alignment.TopCenter)
|
||||
.background(Color.White.copy(alpha = if (isClassic) 0.12f else 0.05f))
|
||||
)
|
||||
|
||||
// Face plate
|
||||
Box(
|
||||
Modifier
|
||||
.fillMaxSize()
|
||||
.padding(14.dp)
|
||||
.clip(RoundedCornerShape(10.dp))
|
||||
.background(c.face)
|
||||
.border(0.5.dp, Color.White.copy(alpha = 0.03f), RoundedCornerShape(10.dp)),
|
||||
) {
|
||||
// Ridges
|
||||
Ridges(c.ridge, Modifier.align(Alignment.CenterStart).width(7.dp).fillMaxHeight().padding(vertical = 12.dp))
|
||||
Ridges(c.ridge, Modifier.align(Alignment.CenterEnd).width(7.dp).fillMaxHeight().padding(vertical = 12.dp))
|
||||
|
||||
// D-Pad in inlay (more left margin)
|
||||
Inlay(c, Modifier.align(Alignment.CenterStart).padding(start = 48.dp).size(140.dp)) {
|
||||
OnePointDPad(c, 120.dp, onKey)
|
||||
}
|
||||
|
||||
// Center: Logo + START/SELECT
|
||||
Column(
|
||||
Modifier.align(Alignment.Center),
|
||||
horizontalAlignment = Alignment.CenterHorizontally,
|
||||
) {
|
||||
Image(
|
||||
painter = painterResource(id = R.drawable.ic_logo_wide),
|
||||
contentDescription = "Archipelago",
|
||||
modifier = Modifier.width(180.dp),
|
||||
colorFilter = ColorFilter.tint(if (isClassic) NES.ClassicLabel else c.label),
|
||||
)
|
||||
Spacer(Modifier.height(10.dp))
|
||||
Inlay(c, Modifier.padding(horizontal = 4.dp)) {
|
||||
Row(
|
||||
Modifier.padding(horizontal = 12.dp, vertical = 8.dp),
|
||||
horizontalArrangement = Arrangement.spacedBy(16.dp),
|
||||
) {
|
||||
CapsuleBtn("SELECT", c, 64.dp, 28.dp) { onKey("Escape") }
|
||||
CapsuleBtn("START", c, 64.dp, 28.dp) { onKey("Return") }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// A/B Buttons in inlay (same size as D-pad inlay, more right margin)
|
||||
Inlay(c, Modifier.align(Alignment.CenterEnd).padding(end = 48.dp).size(140.dp)) {
|
||||
Row(
|
||||
Modifier.fillMaxSize(),
|
||||
horizontalArrangement = Arrangement.Center,
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
) {
|
||||
Column(horizontalAlignment = Alignment.CenterHorizontally) {
|
||||
Spacer(Modifier.height(10.dp))
|
||||
RoundBtn(c, 52.dp) { onKey("Escape") }
|
||||
Text("B", color = c.labelMuted, fontSize = 9.sp, fontWeight = FontWeight.Bold)
|
||||
}
|
||||
Spacer(Modifier.width(16.dp))
|
||||
Column(horizontalAlignment = Alignment.CenterHorizontally) {
|
||||
RoundBtn(c, 52.dp) { onKey("Return") }
|
||||
Text("A", color = c.labelMuted, fontSize = 9.sp, fontWeight = FontWeight.Bold)
|
||||
Spacer(Modifier.height(10.dp))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Settings button (bottom center)
|
||||
SettingsBtn(c, Modifier.align(Alignment.BottomCenter).padding(bottom = 4.dp), onMenu)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ═══════════════════════════════════════════════════════════
|
||||
// Shared sub-components
|
||||
// ═══════════════════════════════════════════════════════════
|
||||
|
||||
/** Inlay well — dark recessed area with border */
|
||||
@Composable
|
||||
fun Inlay(c: NESPalette, modifier: Modifier = Modifier, content: @Composable () -> Unit) {
|
||||
Box(
|
||||
modifier = modifier
|
||||
.clip(RoundedCornerShape(10.dp))
|
||||
.background(c.inlayBg)
|
||||
.border(3.dp, c.inlayBorder, RoundedCornerShape(10.dp))
|
||||
.padding(4.dp),
|
||||
contentAlignment = Alignment.Center,
|
||||
) { content() }
|
||||
}
|
||||
|
||||
/** One-piece D-pad — single cross shape, touch detects direction */
|
||||
@Composable
|
||||
fun OnePointDPad(c: NESPalette, size: Dp, onDir: (String) -> Unit) {
|
||||
val scope = rememberCoroutineScope()
|
||||
var job by remember { mutableStateOf<Job?>(null) }
|
||||
var activeDir by remember { mutableStateOf<String?>(null) }
|
||||
DisposableEffect(Unit) { onDispose { job?.cancel() } }
|
||||
|
||||
Canvas(
|
||||
modifier = Modifier
|
||||
.size(size)
|
||||
.pointerInput(Unit) {
|
||||
detectTapGestures(
|
||||
onPress = { offset ->
|
||||
val cx = this@pointerInput.size.width / 2f
|
||||
val cy = this@pointerInput.size.height / 2f
|
||||
val dx = offset.x - cx
|
||||
val dy = offset.y - cy
|
||||
val dead = cx * 0.24f
|
||||
if (abs(dx) < dead && abs(dy) < dead) {
|
||||
tryAwaitRelease(); return@detectTapGestures
|
||||
}
|
||||
val dir = if (abs(dx) > abs(dy)) {
|
||||
if (dx > 0) "Right" else "Left"
|
||||
} else {
|
||||
if (dy > 0) "Down" else "Up"
|
||||
}
|
||||
activeDir = dir; onDir(dir)
|
||||
job?.cancel()
|
||||
job = scope.launch { delay(300); while (true) { onDir(dir); delay(90) } }
|
||||
tryAwaitRelease()
|
||||
job?.cancel(); activeDir = null
|
||||
},
|
||||
)
|
||||
},
|
||||
) {
|
||||
val w = size.toPx()
|
||||
val arm = w * 0.33f // arm width = 1/3 of total
|
||||
val offset = (w - arm) / 2f
|
||||
|
||||
// Cross shape
|
||||
val crossColor = c.dpad
|
||||
|
||||
// Vertical bar
|
||||
drawRoundRect(
|
||||
color = crossColor,
|
||||
topLeft = Offset(offset, 0f),
|
||||
size = Size(arm, w),
|
||||
cornerRadius = CornerRadius(4.dp.toPx()),
|
||||
)
|
||||
// Horizontal bar
|
||||
drawRoundRect(
|
||||
color = crossColor,
|
||||
topLeft = Offset(0f, offset),
|
||||
size = Size(w, arm),
|
||||
cornerRadius = CornerRadius(4.dp.toPx()),
|
||||
)
|
||||
|
||||
// Top-edge lighting
|
||||
drawRoundRect(
|
||||
brush = Brush.verticalGradient(listOf(Color.White.copy(alpha = 0.06f), Color.Transparent)),
|
||||
topLeft = Offset(offset, 0f),
|
||||
size = Size(arm, w * 0.15f),
|
||||
cornerRadius = CornerRadius(4.dp.toPx()),
|
||||
)
|
||||
drawRoundRect(
|
||||
brush = Brush.verticalGradient(listOf(Color.White.copy(alpha = 0.06f), Color.Transparent)),
|
||||
topLeft = Offset(0f, offset),
|
||||
size = Size(w, arm * 0.3f),
|
||||
cornerRadius = CornerRadius(4.dp.toPx()),
|
||||
)
|
||||
|
||||
// Active direction highlight
|
||||
activeDir?.let { dir ->
|
||||
val hi = c.dpadHi
|
||||
when (dir) {
|
||||
"Up" -> drawRoundRect(hi, Offset(offset, 0f), Size(arm, arm), CornerRadius(4.dp.toPx()))
|
||||
"Down" -> drawRoundRect(hi, Offset(offset, w - arm), Size(arm, arm), CornerRadius(4.dp.toPx()))
|
||||
"Left" -> drawRoundRect(hi, Offset(0f, offset), Size(arm, arm), CornerRadius(4.dp.toPx()))
|
||||
"Right" -> drawRoundRect(hi, Offset(w - arm, offset), Size(arm, arm), CornerRadius(4.dp.toPx()))
|
||||
}
|
||||
}
|
||||
|
||||
// Center circle
|
||||
drawCircle(c.dpadHi, radius = w * 0.06f, center = Offset(w / 2f, w / 2f))
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun Ridges(color: Color, modifier: Modifier) {
|
||||
Canvas(modifier = modifier) {
|
||||
val h = 1.5.dp.toPx(); val gap = 3.dp.toPx(); var y = 0f
|
||||
while (y < size.height) { drawRect(color, Offset(0f, y), Size(size.width, h)); y += h + gap }
|
||||
}
|
||||
}
|
||||
|
||||
/** A/B round button with lighting */
|
||||
@Composable
|
||||
fun RoundBtn(c: NESPalette, sz: Dp = 52.dp, onClick: () -> Unit) {
|
||||
var p by remember { mutableStateOf(false) }
|
||||
Box(
|
||||
Modifier
|
||||
.size(sz)
|
||||
.shadow(if (p) 1.dp else 4.dp, CircleShape)
|
||||
.clip(CircleShape)
|
||||
.background(Brush.verticalGradient(
|
||||
if (p) listOf(c.btnPress, c.btn.copy(alpha = 0.85f))
|
||||
else listOf(c.btn, c.btn.copy(alpha = 0.8f))
|
||||
))
|
||||
.pointerInput(Unit) { detectTapGestures(onPress = { p = true; onClick(); tryAwaitRelease(); p = false }) },
|
||||
contentAlignment = Alignment.Center,
|
||||
) {
|
||||
if (!p) Box(Modifier.fillMaxSize().clip(CircleShape).background(
|
||||
Brush.verticalGradient(listOf(Color.White.copy(alpha = 0.18f), Color.Transparent))
|
||||
))
|
||||
}
|
||||
}
|
||||
|
||||
/** START/SELECT capsule */
|
||||
@Composable
|
||||
fun CapsuleBtn(label: String, c: NESPalette, w: Dp = 64.dp, h: Dp = 28.dp, onClick: () -> Unit) {
|
||||
var p by remember { mutableStateOf(false) }
|
||||
Box(
|
||||
Modifier
|
||||
.width(w).height(h)
|
||||
.shadow(if (p) 0.dp else 2.dp, RoundedCornerShape(4.dp))
|
||||
.clip(RoundedCornerShape(4.dp))
|
||||
.background(Brush.verticalGradient(
|
||||
if (p) listOf(c.capsulePress, c.capsule)
|
||||
else listOf(c.capsule, c.capsule.copy(alpha = 0.85f))
|
||||
))
|
||||
.pointerInput(Unit) { detectTapGestures(onPress = { p = true; onClick(); tryAwaitRelease(); p = false }) },
|
||||
contentAlignment = Alignment.Center,
|
||||
) {
|
||||
if (!p) Box(Modifier.fillMaxSize().clip(RoundedCornerShape(4.dp)).background(
|
||||
Brush.verticalGradient(listOf(Color.White.copy(alpha = 0.05f), Color.Transparent))
|
||||
))
|
||||
Text(label, color = c.labelMuted, fontSize = 8.sp, fontWeight = FontWeight.Bold, letterSpacing = 1.sp)
|
||||
}
|
||||
}
|
||||
|
||||
/** Small settings gear button */
|
||||
@Composable
|
||||
fun SettingsBtn(c: NESPalette, modifier: Modifier = Modifier, onClick: () -> Unit) {
|
||||
var p by remember { mutableStateOf(false) }
|
||||
Box(
|
||||
modifier = modifier
|
||||
.size(24.dp)
|
||||
.clip(CircleShape)
|
||||
.background(if (p) c.capsulePress else c.capsule)
|
||||
.pointerInput(Unit) { detectTapGestures(onPress = { p = true; onClick(); tryAwaitRelease(); p = false }) },
|
||||
contentAlignment = Alignment.Center,
|
||||
) {
|
||||
Icon(Icons.Default.Settings, "Settings", Modifier.size(14.dp), tint = c.labelMuted)
|
||||
}
|
||||
}
|
||||
|
||||
/** Two-finger hold gesture modifier */
|
||||
fun Modifier.twoFingerHold(onHold: () -> Unit) = this.pointerInput(Unit) {
|
||||
awaitEachGesture {
|
||||
awaitFirstDown(requireUnconsumed = false)
|
||||
var t = 0L; var fired = false
|
||||
do {
|
||||
val ev = awaitPointerEvent()
|
||||
val a = ev.changes.filter { !it.changedToUp() }
|
||||
if (a.size >= 2 && t == 0L) t = System.currentTimeMillis()
|
||||
if (a.size >= 2 && !fired && t > 0 && System.currentTimeMillis() - t > 500) { fired = true; onHold() }
|
||||
if (a.size < 2) t = 0L
|
||||
} while (ev.changes.any { it.pressed })
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,202 @@
|
||||
package com.archipelago.app.ui.components
|
||||
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.border
|
||||
import androidx.compose.foundation.gestures.detectTapGestures
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.height
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.DisposableEffect
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.rememberCoroutineScope
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.draw.clip
|
||||
import androidx.compose.ui.graphics.Brush
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.input.pointer.pointerInput
|
||||
import androidx.compose.ui.text.style.TextAlign
|
||||
import androidx.compose.ui.unit.Dp
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.compose.ui.unit.sp
|
||||
import com.archipelago.app.ui.theme.ControllerStyle
|
||||
import com.archipelago.app.ui.theme.NES
|
||||
import kotlinx.coroutines.Job
|
||||
import kotlinx.coroutines.delay
|
||||
import kotlinx.coroutines.launch
|
||||
|
||||
private enum class NKLayer { ALPHA, NUM, SYM }
|
||||
private val KEY_H = 42.dp
|
||||
private val GAP = 4.dp
|
||||
|
||||
@Composable
|
||||
fun NESKeyboard(
|
||||
style: ControllerStyle = ControllerStyle.CLASSIC,
|
||||
onKey: (String) -> Unit,
|
||||
modifier: Modifier = Modifier,
|
||||
) {
|
||||
val c = paletteFor(style)
|
||||
val isClassic = style == ControllerStyle.CLASSIC
|
||||
val keyBg = c.dpad
|
||||
val keyBgP = c.dpadHi
|
||||
val keyTxt = c.labelMuted
|
||||
val accent = if (isClassic) NES.ClassicLabel else c.labelMuted
|
||||
|
||||
var layer by remember { mutableStateOf(NKLayer.ALPHA) }
|
||||
var shifted by remember { mutableStateOf(false) }
|
||||
var capsLock by remember { mutableStateOf(false) }
|
||||
val up = shifted || capsLock
|
||||
|
||||
fun emit(k: String) { onKey(k); if (shifted && !capsLock) shifted = false }
|
||||
fun ch(cc: String) { emit(if (up && layer == NKLayer.ALPHA) "shift+$cc" else cc) }
|
||||
|
||||
// NES body wrapping keyboard
|
||||
Column(
|
||||
modifier = modifier
|
||||
.clip(RoundedCornerShape(14.dp))
|
||||
.background(c.body)
|
||||
.padding(8.dp)
|
||||
.clip(RoundedCornerShape(8.dp))
|
||||
.background(c.face)
|
||||
.padding(horizontal = 8.dp, vertical = 6.dp),
|
||||
verticalArrangement = Arrangement.spacedBy(GAP),
|
||||
) {
|
||||
when (layer) {
|
||||
NKLayer.ALPHA -> {
|
||||
KeyRow("q w e r t y u i o p".split(" "), up, keyBg, keyBgP, keyTxt, ::ch)
|
||||
KeyRow("a s d f g h j k l".split(" "), up, keyBg, keyBgP, keyTxt, ::ch, inset = 16.dp)
|
||||
Row(Modifier.fillMaxWidth().height(KEY_H), Arrangement.spacedBy(GAP)) {
|
||||
NKey(if (capsLock) "\u21EA" else "\u21E7", Modifier.weight(1.4f), keyBg, keyBgP, if (up) accent else keyTxt) {
|
||||
if (capsLock) { capsLock = false; shifted = false } else if (shifted) capsLock = true else shifted = true
|
||||
}
|
||||
"z x c v b n m".split(" ").forEach { k ->
|
||||
NKey(if (up) k.uppercase() else k, Modifier.weight(1f), keyBg, keyBgP, keyTxt, 17) { ch(k) }
|
||||
}
|
||||
NRepKey("\u232B", Modifier.weight(1.4f), keyBg, keyBgP, keyTxt) { emit("BackSpace") }
|
||||
}
|
||||
}
|
||||
NKLayer.NUM -> {
|
||||
KeyRow("1 2 3 4 5 6 7 8 9 0".split(" "), false, keyBg, keyBgP, keyTxt, ::emit)
|
||||
KeyRow("- / : ; ( ) \$ & @ \"".split(" "), false, keyBg, keyBgP, keyTxt, ::emit)
|
||||
Row(Modifier.fillMaxWidth().height(KEY_H), Arrangement.spacedBy(GAP)) {
|
||||
NKey("#+=", Modifier.weight(1.4f), keyBg, keyBgP, keyTxt) { layer = NKLayer.SYM }
|
||||
". , ? ! '".split(" ").forEach { k ->
|
||||
NKey(k, Modifier.weight(1f), keyBg, keyBgP, keyTxt) { emit(k) }
|
||||
}
|
||||
NRepKey("\u232B", Modifier.weight(1.4f), keyBg, keyBgP, keyTxt) { emit("BackSpace") }
|
||||
}
|
||||
}
|
||||
NKLayer.SYM -> {
|
||||
KeyRow("[ ] { } # % ^ * + =".split(" "), false, keyBg, keyBgP, keyTxt, ::emit)
|
||||
KeyRow("_ \\ | ~ < > ` @ !".split(" "), false, keyBg, keyBgP, keyTxt, ::emit)
|
||||
Row(Modifier.fillMaxWidth().height(KEY_H), Arrangement.spacedBy(GAP)) {
|
||||
NKey("123", Modifier.weight(1.4f), keyBg, keyBgP, keyTxt) { layer = NKLayer.NUM }
|
||||
". , ? ! '".split(" ").forEach { k ->
|
||||
NKey(k, Modifier.weight(1f), keyBg, keyBgP, keyTxt) { emit(k) }
|
||||
}
|
||||
NRepKey("\u232B", Modifier.weight(1.4f), keyBg, keyBgP, keyTxt) { emit("BackSpace") }
|
||||
}
|
||||
}
|
||||
}
|
||||
// Bottom row
|
||||
Row(Modifier.fillMaxWidth().height(KEY_H), Arrangement.spacedBy(GAP)) {
|
||||
NKey(if (layer == NKLayer.ALPHA) "123" else "ABC", Modifier.weight(1.4f), keyBg, keyBgP, keyTxt) {
|
||||
layer = if (layer == NKLayer.ALPHA) NKLayer.NUM else NKLayer.ALPHA; shifted = false; capsLock = false
|
||||
}
|
||||
NKey(",", Modifier.weight(1f), keyBg, keyBgP, keyTxt) { emit("comma") }
|
||||
NKey("space", Modifier.weight(5f), keyBg, keyBgP, keyTxt, 12) { emit("space") }
|
||||
NKey(".", Modifier.weight(1f), keyBg, keyBgP, keyTxt) { emit("period") }
|
||||
NKey("\u23CE", Modifier.weight(1.4f), keyBg, keyBgP, accent, 15) { emit("Return") }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** Key row — each key gets equal weight */
|
||||
@Composable
|
||||
private fun KeyRow(
|
||||
keys: List<String>, up: Boolean,
|
||||
bg: Color, bgP: Color, txt: Color,
|
||||
onKey: (String) -> Unit, inset: Dp = 0.dp,
|
||||
) {
|
||||
Row(
|
||||
Modifier.fillMaxWidth().height(KEY_H).padding(horizontal = inset),
|
||||
Arrangement.spacedBy(GAP),
|
||||
) {
|
||||
keys.forEach { k ->
|
||||
NKey(
|
||||
label = if (up) k.uppercase() else k,
|
||||
modifier = Modifier.weight(1f),
|
||||
bg = bg, bgP = bgP, txt = txt,
|
||||
fontSize = 17,
|
||||
onTap = { onKey(k) },
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** Single NES key — D-pad style flat dark button */
|
||||
@Composable
|
||||
private fun NKey(
|
||||
label: String, modifier: Modifier = Modifier,
|
||||
bg: Color, bgP: Color, txt: Color,
|
||||
fontSize: Int = 13, onTap: () -> Unit,
|
||||
) {
|
||||
var p by remember { mutableStateOf(false) }
|
||||
Box(
|
||||
modifier = modifier
|
||||
.height(KEY_H)
|
||||
.clip(RoundedCornerShape(4.dp))
|
||||
.background(Brush.verticalGradient(if (p) listOf(bgP, bg) else listOf(bg, bg.copy(alpha = 0.9f))))
|
||||
.then(
|
||||
if (!p) Modifier.border(0.5.dp,
|
||||
Brush.verticalGradient(listOf(Color.White.copy(alpha = 0.06f), Color.Transparent)),
|
||||
RoundedCornerShape(4.dp))
|
||||
else Modifier
|
||||
)
|
||||
.pointerInput(label) {
|
||||
detectTapGestures(onPress = { p = true; onTap(); tryAwaitRelease(); p = false })
|
||||
},
|
||||
contentAlignment = Alignment.Center,
|
||||
) {
|
||||
Text(label, color = txt, fontSize = fontSize.sp, textAlign = TextAlign.Center, maxLines = 1)
|
||||
}
|
||||
}
|
||||
|
||||
/** Repeatable NES key (backspace) */
|
||||
@Composable
|
||||
private fun NRepKey(
|
||||
label: String, modifier: Modifier,
|
||||
bg: Color, bgP: Color, txt: Color, onTap: () -> Unit,
|
||||
) {
|
||||
var p by remember { mutableStateOf(false) }
|
||||
val scope = rememberCoroutineScope()
|
||||
var job by remember { mutableStateOf<Job?>(null) }
|
||||
DisposableEffect(Unit) { onDispose { job?.cancel() } }
|
||||
|
||||
Box(
|
||||
modifier = modifier
|
||||
.height(KEY_H)
|
||||
.clip(RoundedCornerShape(4.dp))
|
||||
.background(Brush.verticalGradient(if (p) listOf(bgP, bg) else listOf(bg, bg.copy(alpha = 0.9f))))
|
||||
.pointerInput(Unit) {
|
||||
detectTapGestures(onPress = {
|
||||
p = true; onTap()
|
||||
job = scope.launch { delay(400); while (true) { onTap(); delay(55) } }
|
||||
tryAwaitRelease(); job?.cancel(); p = false
|
||||
})
|
||||
},
|
||||
contentAlignment = Alignment.Center,
|
||||
) {
|
||||
Text(label, color = txt, fontSize = 16.sp)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,218 @@
|
||||
package com.archipelago.app.ui.components
|
||||
|
||||
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.fillMaxSize
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.height
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.layout.size
|
||||
import androidx.compose.foundation.layout.width
|
||||
import androidx.compose.foundation.layout.widthIn
|
||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
import androidx.compose.foundation.text.KeyboardActions
|
||||
import androidx.compose.foundation.text.KeyboardOptions
|
||||
import androidx.compose.material3.OutlinedTextField
|
||||
import androidx.compose.material3.OutlinedTextFieldDefaults
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.draw.clip
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.text.font.FontWeight
|
||||
import androidx.compose.ui.text.input.ImeAction
|
||||
import androidx.compose.ui.text.input.KeyboardType
|
||||
import androidx.compose.ui.text.input.PasswordVisualTransformation
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.compose.ui.unit.sp
|
||||
import com.archipelago.app.data.ServerEntry
|
||||
import com.archipelago.app.ui.theme.ControllerStyle
|
||||
import com.archipelago.app.ui.theme.NES
|
||||
|
||||
/** NES-styled modal menu — dark blue panel with white borders */
|
||||
@Composable
|
||||
fun NESMenu(
|
||||
visible: Boolean,
|
||||
servers: List<ServerEntry>,
|
||||
activeServer: ServerEntry?,
|
||||
isGamepadMode: Boolean,
|
||||
controllerStyle: ControllerStyle,
|
||||
onDismiss: () -> Unit,
|
||||
onSelectServer: (ServerEntry) -> Unit,
|
||||
onAddServer: (ServerEntry) -> Unit,
|
||||
onRemoveServer: (ServerEntry) -> Unit,
|
||||
onToggleMode: () -> Unit,
|
||||
onToggleStyle: () -> Unit,
|
||||
onBackToWebView: (() -> Unit)? = null,
|
||||
) {
|
||||
AnimatedVisibility(visible = visible, enter = fadeIn(), exit = fadeOut()) {
|
||||
Box(
|
||||
Modifier.fillMaxSize().background(Color.Black.copy(alpha = 0.7f))
|
||||
.clickable(indication = null, interactionSource = remember { MutableInteractionSource() }) { onDismiss() },
|
||||
contentAlignment = Alignment.Center,
|
||||
) {
|
||||
MenuPanel(servers, activeServer, isGamepadMode, controllerStyle, onDismiss, onSelectServer, onAddServer, onRemoveServer, onToggleMode, onToggleStyle, onBackToWebView)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun MenuPanel(
|
||||
servers: List<ServerEntry>,
|
||||
activeServer: ServerEntry?,
|
||||
isGamepadMode: Boolean,
|
||||
controllerStyle: ControllerStyle,
|
||||
onDismiss: () -> Unit,
|
||||
onSelectServer: (ServerEntry) -> Unit,
|
||||
onAddServer: (ServerEntry) -> Unit,
|
||||
onRemoveServer: (ServerEntry) -> Unit,
|
||||
onToggleMode: () -> Unit,
|
||||
onToggleStyle: () -> Unit,
|
||||
onBackToWebView: (() -> Unit)?,
|
||||
) {
|
||||
var showAdd by remember { mutableStateOf(false) }
|
||||
var addr by remember { mutableStateOf("") }
|
||||
var pwd by remember { mutableStateOf("") }
|
||||
|
||||
Column(
|
||||
modifier = Modifier
|
||||
.widthIn(max = 360.dp)
|
||||
.clip(RoundedCornerShape(4.dp))
|
||||
.background(NES.MenuPanel)
|
||||
.border(3.dp, NES.MenuBorder, RoundedCornerShape(4.dp))
|
||||
.clickable(indication = null, interactionSource = remember { MutableInteractionSource() }) {}
|
||||
.padding(16.dp),
|
||||
verticalArrangement = Arrangement.spacedBy(6.dp),
|
||||
) {
|
||||
// Title
|
||||
Text("- MENU -", color = NES.MenuText, fontSize = 14.sp, fontWeight = FontWeight.Bold, letterSpacing = 4.sp,
|
||||
modifier = Modifier.fillMaxWidth(), textAlign = androidx.compose.ui.text.style.TextAlign.Center)
|
||||
Spacer(Modifier.height(4.dp))
|
||||
|
||||
// Servers
|
||||
servers.forEach { server ->
|
||||
val active = server.serialize() == activeServer?.serialize()
|
||||
MenuItem(
|
||||
label = (if (active) "\u25B6 " else " ") + server.address,
|
||||
selected = active,
|
||||
onClick = { onSelectServer(server) },
|
||||
onRemove = { onRemoveServer(server) },
|
||||
)
|
||||
}
|
||||
|
||||
if (servers.isEmpty()) {
|
||||
Text(" NO SERVERS", color = NES.MenuMuted, fontSize = 11.sp, modifier = Modifier.padding(vertical = 4.dp))
|
||||
}
|
||||
|
||||
// Add server
|
||||
if (showAdd) {
|
||||
Column(
|
||||
Modifier.fillMaxWidth().background(Color.Black.copy(alpha = 0.3f)).padding(8.dp),
|
||||
verticalArrangement = Arrangement.spacedBy(6.dp),
|
||||
) {
|
||||
OutlinedTextField(
|
||||
value = addr, onValueChange = { addr = it.trim() },
|
||||
placeholder = { Text("192.168.1.100", color = NES.MenuMuted, fontSize = 11.sp) },
|
||||
modifier = Modifier.fillMaxWidth().height(48.dp), singleLine = true,
|
||||
textStyle = androidx.compose.ui.text.TextStyle(color = NES.MenuText, fontSize = 12.sp),
|
||||
colors = nesFieldColors(),
|
||||
shape = RoundedCornerShape(2.dp),
|
||||
)
|
||||
Row(horizontalArrangement = Arrangement.spacedBy(6.dp), verticalAlignment = Alignment.CenterVertically) {
|
||||
OutlinedTextField(
|
||||
value = pwd, onValueChange = { pwd = it },
|
||||
placeholder = { Text("PASSWORD", color = NES.MenuMuted, fontSize = 11.sp) },
|
||||
modifier = Modifier.weight(1f).height(48.dp), singleLine = true,
|
||||
visualTransformation = PasswordVisualTransformation(),
|
||||
keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Password, imeAction = ImeAction.Go),
|
||||
keyboardActions = KeyboardActions(onGo = {
|
||||
if (addr.isNotBlank()) { onAddServer(ServerEntry(addr, false, password = pwd)); addr = ""; pwd = ""; showAdd = false }
|
||||
}),
|
||||
textStyle = androidx.compose.ui.text.TextStyle(color = NES.MenuText, fontSize = 12.sp),
|
||||
colors = nesFieldColors(),
|
||||
shape = RoundedCornerShape(2.dp),
|
||||
)
|
||||
Box(
|
||||
Modifier.size(48.dp).clip(RoundedCornerShape(2.dp)).background(NES.MenuSelected)
|
||||
.clickable {
|
||||
if (addr.isNotBlank()) { onAddServer(ServerEntry(addr, false, password = pwd)); addr = ""; pwd = ""; showAdd = false }
|
||||
},
|
||||
contentAlignment = Alignment.Center,
|
||||
) { Text("OK", color = NES.MenuText, fontSize = 10.sp, fontWeight = FontWeight.Bold) }
|
||||
}
|
||||
}
|
||||
} else {
|
||||
MenuItem(label = " ADD SERVER", onClick = { showAdd = true })
|
||||
}
|
||||
|
||||
Spacer(Modifier.height(2.dp))
|
||||
Box(Modifier.fillMaxWidth().height(1.dp).background(NES.MenuBorder.copy(alpha = 0.3f)))
|
||||
Spacer(Modifier.height(2.dp))
|
||||
|
||||
// Mode toggle
|
||||
MenuItem(
|
||||
label = if (isGamepadMode) " SWITCH TO KEYBOARD" else " SWITCH TO GAMEPAD",
|
||||
onClick = onToggleMode,
|
||||
)
|
||||
|
||||
// Style toggle
|
||||
MenuItem(
|
||||
label = if (controllerStyle == ControllerStyle.CLASSIC) " STYLE: CLASSIC" else " STYLE: DARK",
|
||||
onClick = onToggleStyle,
|
||||
)
|
||||
|
||||
// Back to dashboard
|
||||
if (onBackToWebView != null) {
|
||||
MenuItem(label = " BACK TO DASHBOARD", onClick = onBackToWebView)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun MenuItem(
|
||||
label: String,
|
||||
selected: Boolean = false,
|
||||
onClick: () -> Unit,
|
||||
onRemove: (() -> Unit)? = null,
|
||||
) {
|
||||
Row(
|
||||
Modifier
|
||||
.fillMaxWidth()
|
||||
.height(32.dp)
|
||||
.background(if (selected) NES.MenuSelected.copy(alpha = 0.15f) else Color.Transparent)
|
||||
.clickable { onClick() }
|
||||
.padding(horizontal = 8.dp),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
horizontalArrangement = Arrangement.SpaceBetween,
|
||||
) {
|
||||
Text(label, color = if (selected) NES.MenuSelected else NES.MenuText, fontSize = 11.sp, fontWeight = FontWeight.Medium)
|
||||
if (onRemove != null) {
|
||||
Text("\u2715", color = NES.MenuMuted, fontSize = 10.sp,
|
||||
modifier = Modifier.clickable { onRemove() }.padding(horizontal = 8.dp))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun nesFieldColors() = OutlinedTextFieldDefaults.colors(
|
||||
focusedBorderColor = NES.MenuBorder,
|
||||
unfocusedBorderColor = NES.MenuMuted,
|
||||
cursorColor = NES.MenuText,
|
||||
focusedTextColor = NES.MenuText,
|
||||
unfocusedTextColor = NES.MenuText,
|
||||
)
|
||||
@@ -0,0 +1,147 @@
|
||||
package com.archipelago.app.ui.components
|
||||
|
||||
import androidx.compose.foundation.Image
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.border
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.Spacer
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.height
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.layout.size
|
||||
import androidx.compose.foundation.layout.width
|
||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.draw.clip
|
||||
import androidx.compose.ui.draw.shadow
|
||||
import androidx.compose.ui.graphics.Brush
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.graphics.ColorFilter
|
||||
import androidx.compose.ui.res.painterResource
|
||||
import androidx.compose.ui.unit.dp
|
||||
import com.archipelago.app.R
|
||||
import com.archipelago.app.ui.theme.ControllerStyle
|
||||
import com.archipelago.app.ui.theme.NES
|
||||
|
||||
/**
|
||||
* Portrait gamepad — vertical remote shape like Apple TV but NES-styled.
|
||||
* Large trackpad top, D-pad middle, A/B + START/SELECT bottom.
|
||||
*/
|
||||
@Composable
|
||||
fun NESPortraitController(
|
||||
style: ControllerStyle = ControllerStyle.CLASSIC,
|
||||
onKey: (String) -> Unit,
|
||||
onMouseMove: (Int, Int) -> Unit = { _, _ -> },
|
||||
onMouseClick: (Int) -> Unit = { _ -> },
|
||||
onMouseScroll: (Int) -> Unit = { _ -> },
|
||||
onMenu: () -> Unit,
|
||||
) {
|
||||
val c = paletteFor(style)
|
||||
val isClassic = style == ControllerStyle.CLASSIC
|
||||
|
||||
Box(
|
||||
Modifier
|
||||
.fillMaxSize()
|
||||
.background(Color(0xFF0C0C0C))
|
||||
.twoFingerHold(onMenu)
|
||||
.padding(horizontal = 40.dp, vertical = 24.dp),
|
||||
contentAlignment = Alignment.Center,
|
||||
) {
|
||||
// Remote body — tall vertical shape
|
||||
Box(
|
||||
Modifier
|
||||
.fillMaxWidth(0.75f)
|
||||
.fillMaxSize()
|
||||
.shadow(28.dp, RoundedCornerShape(20.dp), ambientColor = Color.Black, spotColor = Color.Black)
|
||||
.clip(RoundedCornerShape(20.dp))
|
||||
.background(Brush.verticalGradient(listOf(c.body, c.body.copy(alpha = 0.95f))))
|
||||
.border(1.dp, Color.White.copy(alpha = if (isClassic) 0.08f else 0.04f), RoundedCornerShape(20.dp)),
|
||||
) {
|
||||
// Top highlight
|
||||
Box(
|
||||
Modifier.fillMaxWidth().height(1.dp).align(Alignment.TopCenter)
|
||||
.background(Color.White.copy(alpha = if (isClassic) 0.12f else 0.05f))
|
||||
)
|
||||
|
||||
// Face plate
|
||||
Column(
|
||||
Modifier
|
||||
.fillMaxSize()
|
||||
.padding(14.dp)
|
||||
.clip(RoundedCornerShape(14.dp))
|
||||
.background(c.face)
|
||||
.border(0.5.dp, Color.White.copy(alpha = 0.03f), RoundedCornerShape(14.dp))
|
||||
.padding(16.dp),
|
||||
horizontalAlignment = Alignment.CenterHorizontally,
|
||||
verticalArrangement = Arrangement.SpaceBetween,
|
||||
) {
|
||||
// Trackpad area (touch surface for mouse)
|
||||
Trackpad(
|
||||
onMove = { dx, dy -> onMouseMove(dx, dy) },
|
||||
onClick = { onMouseClick(it) },
|
||||
onScroll = { dy -> onMouseScroll(dy) },
|
||||
onTwoFingerHold = onMenu,
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.weight(1f),
|
||||
)
|
||||
|
||||
Spacer(Modifier.height(12.dp))
|
||||
|
||||
// D-Pad
|
||||
Inlay(c, Modifier.size(150.dp)) {
|
||||
OnePointDPad(c, 130.dp, onKey)
|
||||
}
|
||||
|
||||
Spacer(Modifier.height(12.dp))
|
||||
|
||||
// Logo
|
||||
Image(
|
||||
painter = painterResource(id = R.drawable.ic_logo_wide),
|
||||
contentDescription = "Archipelago",
|
||||
modifier = Modifier.width(140.dp),
|
||||
colorFilter = ColorFilter.tint(if (isClassic) NES.ClassicLabel else c.label),
|
||||
)
|
||||
|
||||
Spacer(Modifier.height(12.dp))
|
||||
|
||||
// A/B Buttons
|
||||
Inlay(c, Modifier.fillMaxWidth()) {
|
||||
Row(
|
||||
Modifier.fillMaxWidth().padding(horizontal = 16.dp, vertical = 10.dp),
|
||||
horizontalArrangement = Arrangement.Center,
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
) {
|
||||
RoundBtn(c, 52.dp) { onKey("Escape") }
|
||||
Spacer(Modifier.width(24.dp))
|
||||
RoundBtn(c, 52.dp) { onKey("Return") }
|
||||
}
|
||||
}
|
||||
|
||||
Spacer(Modifier.height(10.dp))
|
||||
|
||||
// START / SELECT
|
||||
Inlay(c, Modifier) {
|
||||
Row(
|
||||
Modifier.padding(horizontal = 10.dp, vertical = 6.dp),
|
||||
horizontalArrangement = Arrangement.spacedBy(14.dp),
|
||||
) {
|
||||
CapsuleBtn("SELECT", c, 64.dp, 28.dp) { onKey("Escape") }
|
||||
CapsuleBtn("START", c, 64.dp, 28.dp) { onKey("Return") }
|
||||
}
|
||||
}
|
||||
|
||||
Spacer(Modifier.height(6.dp))
|
||||
|
||||
// Settings
|
||||
SettingsBtn(c, Modifier, onMenu)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,263 @@
|
||||
package com.archipelago.app.ui.components
|
||||
|
||||
import androidx.compose.animation.AnimatedVisibility
|
||||
import androidx.compose.animation.fadeIn
|
||||
import androidx.compose.animation.fadeOut
|
||||
import androidx.compose.animation.scaleIn
|
||||
import androidx.compose.animation.scaleOut
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.border
|
||||
import androidx.compose.foundation.clickable
|
||||
import androidx.compose.foundation.interaction.MutableInteractionSource
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.Spacer
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.height
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.layout.size
|
||||
import androidx.compose.foundation.layout.width
|
||||
import androidx.compose.foundation.layout.widthIn
|
||||
import androidx.compose.foundation.shape.CircleShape
|
||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
import androidx.compose.foundation.text.KeyboardActions
|
||||
import androidx.compose.foundation.text.KeyboardOptions
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.filled.Add
|
||||
import androidx.compose.material.icons.filled.Close
|
||||
import androidx.compose.material.icons.filled.Gamepad
|
||||
import androidx.compose.material.icons.filled.Keyboard
|
||||
import androidx.compose.material.icons.filled.RadioButtonChecked
|
||||
import androidx.compose.material.icons.filled.RadioButtonUnchecked
|
||||
import androidx.compose.material.icons.filled.Web
|
||||
import androidx.compose.material3.HorizontalDivider
|
||||
import androidx.compose.material3.Icon
|
||||
import androidx.compose.material3.IconButton
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.OutlinedTextField
|
||||
import androidx.compose.material3.OutlinedTextFieldDefaults
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.draw.clip
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.graphics.vector.ImageVector
|
||||
import androidx.compose.ui.text.input.ImeAction
|
||||
import androidx.compose.ui.text.input.KeyboardType
|
||||
import androidx.compose.ui.text.input.PasswordVisualTransformation
|
||||
import androidx.compose.ui.unit.dp
|
||||
import com.archipelago.app.data.ServerEntry
|
||||
import com.archipelago.app.ui.theme.BitcoinOrange
|
||||
import com.archipelago.app.ui.theme.Neo
|
||||
import com.archipelago.app.ui.theme.TextMuted
|
||||
import com.archipelago.app.ui.theme.TextPrimary
|
||||
import com.archipelago.app.ui.theme.neoRaised
|
||||
|
||||
private val ROW_H = 48.dp
|
||||
private val ROW_R = 12.dp
|
||||
|
||||
@Composable
|
||||
fun ServerModal(
|
||||
visible: Boolean,
|
||||
servers: List<ServerEntry>,
|
||||
activeServer: ServerEntry?,
|
||||
isGamepadMode: Boolean,
|
||||
onDismiss: () -> Unit,
|
||||
onSelectServer: (ServerEntry) -> Unit,
|
||||
onAddServer: (ServerEntry) -> Unit,
|
||||
onRemoveServer: (ServerEntry) -> Unit,
|
||||
onToggleGamepadMode: () -> Unit,
|
||||
onBackToWebView: (() -> Unit)? = null,
|
||||
) {
|
||||
AnimatedVisibility(visible = visible, enter = fadeIn(), exit = fadeOut()) {
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.fillMaxSize()
|
||||
.background(Color.Black.copy(alpha = 0.55f))
|
||||
.clickable(
|
||||
indication = null,
|
||||
interactionSource = remember { MutableInteractionSource() },
|
||||
) { onDismiss() },
|
||||
contentAlignment = Alignment.Center,
|
||||
) {
|
||||
AnimatedVisibility(visible = visible, enter = fadeIn() + scaleIn(initialScale = 0.95f), exit = fadeOut() + scaleOut(targetScale = 0.95f)) {
|
||||
ModalBody(servers, activeServer, isGamepadMode, onDismiss, onSelectServer, onAddServer, onRemoveServer, onToggleGamepadMode, onBackToWebView)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun ModalBody(
|
||||
servers: List<ServerEntry>,
|
||||
activeServer: ServerEntry?,
|
||||
isGamepadMode: Boolean,
|
||||
onDismiss: () -> Unit,
|
||||
onSelectServer: (ServerEntry) -> Unit,
|
||||
onAddServer: (ServerEntry) -> Unit,
|
||||
onRemoveServer: (ServerEntry) -> Unit,
|
||||
onToggleGamepadMode: () -> Unit,
|
||||
onBackToWebView: (() -> Unit)?,
|
||||
) {
|
||||
val surface = Neo.surfaceRaised()
|
||||
val light = Neo.shadowLight()
|
||||
val dark = Neo.shadowDark()
|
||||
var showAddForm by remember { mutableStateOf(false) }
|
||||
var newAddress by remember { mutableStateOf("") }
|
||||
var newPassword by remember { mutableStateOf("") }
|
||||
|
||||
Column(
|
||||
modifier = Modifier
|
||||
.widthIn(max = 380.dp)
|
||||
.neoRaised(light, dark, 24.dp, 6.dp, 12.dp)
|
||||
.clip(RoundedCornerShape(24.dp))
|
||||
.background(surface)
|
||||
.clickable(indication = null, interactionSource = remember { MutableInteractionSource() }) {}
|
||||
.padding(20.dp),
|
||||
verticalArrangement = Arrangement.spacedBy(8.dp),
|
||||
) {
|
||||
// Header
|
||||
Row(Modifier.fillMaxWidth(), Arrangement.SpaceBetween, Alignment.CenterVertically) {
|
||||
Text("Servers", style = MaterialTheme.typography.titleMedium, color = Neo.textPrimary())
|
||||
IconButton(onClick = onDismiss, modifier = Modifier.size(32.dp)) {
|
||||
Icon(Icons.Default.Close, "Close", Modifier.size(16.dp), tint = Neo.textMuted())
|
||||
}
|
||||
}
|
||||
|
||||
// Server rows
|
||||
servers.forEach { server ->
|
||||
val isActive = server.serialize() == activeServer?.serialize()
|
||||
ModalRow(
|
||||
icon = if (isActive) Icons.Default.RadioButtonChecked else Icons.Default.RadioButtonUnchecked,
|
||||
iconTint = if (isActive) BitcoinOrange else Neo.textMuted(),
|
||||
label = server.address + if (server.port.isNotBlank()) ":${server.port}" else "",
|
||||
onClick = { onSelectServer(server) },
|
||||
trailing = {
|
||||
IconButton(onClick = { onRemoveServer(server) }, modifier = Modifier.size(28.dp)) {
|
||||
Icon(Icons.Default.Close, "Remove", Modifier.size(14.dp), tint = Neo.textMuted())
|
||||
}
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
if (servers.isEmpty()) {
|
||||
Text("No servers", style = MaterialTheme.typography.bodyMedium, color = Neo.textMuted(), modifier = Modifier.padding(vertical = 4.dp))
|
||||
}
|
||||
|
||||
// Add server
|
||||
if (showAddForm) {
|
||||
Column(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.clip(RoundedCornerShape(ROW_R))
|
||||
.background(Neo.surface())
|
||||
.padding(12.dp),
|
||||
verticalArrangement = Arrangement.spacedBy(8.dp),
|
||||
) {
|
||||
OutlinedTextField(
|
||||
value = newAddress, onValueChange = { newAddress = it.trim() },
|
||||
placeholder = { Text("192.168.1.100") },
|
||||
modifier = Modifier.fillMaxWidth(), singleLine = true,
|
||||
keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Uri, imeAction = ImeAction.Next),
|
||||
colors = neoFieldColors(),
|
||||
shape = RoundedCornerShape(10.dp),
|
||||
textStyle = MaterialTheme.typography.bodyMedium,
|
||||
)
|
||||
Row(horizontalArrangement = Arrangement.spacedBy(8.dp), verticalAlignment = Alignment.CenterVertically) {
|
||||
OutlinedTextField(
|
||||
value = newPassword, onValueChange = { newPassword = it },
|
||||
placeholder = { Text("Password") },
|
||||
modifier = Modifier.weight(1f), singleLine = true,
|
||||
visualTransformation = PasswordVisualTransformation(),
|
||||
keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Password, imeAction = ImeAction.Go),
|
||||
keyboardActions = KeyboardActions(onGo = {
|
||||
if (newAddress.isNotBlank()) {
|
||||
onAddServer(ServerEntry(newAddress, false, password = newPassword))
|
||||
newAddress = ""; newPassword = ""; showAddForm = false
|
||||
}
|
||||
}),
|
||||
colors = neoFieldColors(),
|
||||
shape = RoundedCornerShape(10.dp),
|
||||
textStyle = MaterialTheme.typography.bodyMedium,
|
||||
)
|
||||
Box(
|
||||
modifier = Modifier.size(36.dp).clip(CircleShape).background(BitcoinOrange.copy(alpha = 0.15f))
|
||||
.clickable {
|
||||
if (newAddress.isNotBlank()) {
|
||||
onAddServer(ServerEntry(newAddress, false, password = newPassword))
|
||||
newAddress = ""; newPassword = ""; showAddForm = false
|
||||
}
|
||||
},
|
||||
contentAlignment = Alignment.Center,
|
||||
) { Icon(Icons.Default.Add, "Add", Modifier.size(16.dp), tint = BitcoinOrange) }
|
||||
}
|
||||
}
|
||||
} else {
|
||||
ModalRow(icon = Icons.Default.Add, iconTint = BitcoinOrange, label = "Add Server", labelColor = BitcoinOrange, onClick = { showAddForm = true })
|
||||
}
|
||||
|
||||
HorizontalDivider(color = Neo.border(), modifier = Modifier.padding(vertical = 4.dp))
|
||||
|
||||
// Gamepad toggle — label says what you switch TO
|
||||
ModalRow(
|
||||
icon = if (isGamepadMode) Icons.Default.Keyboard else Icons.Default.Gamepad,
|
||||
iconTint = Neo.textSecondary(),
|
||||
label = if (isGamepadMode) "Switch to Keyboard" else "Switch to Gamepad",
|
||||
onClick = onToggleGamepadMode,
|
||||
)
|
||||
|
||||
// Back to dashboard
|
||||
if (onBackToWebView != null) {
|
||||
ModalRow(icon = Icons.Default.Web, iconTint = Neo.textSecondary(), label = "Back to Dashboard", onClick = onBackToWebView)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** Uniform-height row used for all modal actions */
|
||||
@Composable
|
||||
private fun ModalRow(
|
||||
icon: ImageVector,
|
||||
iconTint: Color,
|
||||
label: String,
|
||||
onClick: () -> Unit,
|
||||
labelColor: Color = Neo.textPrimary(),
|
||||
trailing: (@Composable () -> Unit)? = null,
|
||||
) {
|
||||
val bg = Neo.surface()
|
||||
val light = Neo.shadowLight()
|
||||
val dark = Neo.shadowDark()
|
||||
|
||||
Row(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.height(ROW_H)
|
||||
.neoRaised(light, dark, ROW_R, 2.dp, 5.dp)
|
||||
.clip(RoundedCornerShape(ROW_R))
|
||||
.background(bg)
|
||||
.clickable { onClick() }
|
||||
.padding(horizontal = 14.dp),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
) {
|
||||
Icon(icon, null, Modifier.size(18.dp), tint = iconTint)
|
||||
Spacer(Modifier.width(12.dp))
|
||||
Text(label, style = MaterialTheme.typography.bodyMedium, color = labelColor, modifier = Modifier.weight(1f))
|
||||
if (trailing != null) trailing()
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun neoFieldColors() = OutlinedTextFieldDefaults.colors(
|
||||
focusedBorderColor = BitcoinOrange.copy(alpha = 0.4f),
|
||||
unfocusedBorderColor = Neo.border(),
|
||||
cursorColor = BitcoinOrange,
|
||||
focusedTextColor = Neo.textPrimary(),
|
||||
unfocusedTextColor = Neo.textPrimary(),
|
||||
)
|
||||
@@ -0,0 +1,107 @@
|
||||
package com.archipelago.app.ui.components
|
||||
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.gestures.awaitEachGesture
|
||||
import androidx.compose.foundation.gestures.awaitFirstDown
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableIntStateOf
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.draw.clip
|
||||
import androidx.compose.ui.geometry.Offset
|
||||
import androidx.compose.ui.input.pointer.changedToUp
|
||||
import androidx.compose.ui.input.pointer.pointerInput
|
||||
import androidx.compose.ui.input.pointer.positionChange
|
||||
import androidx.compose.ui.unit.dp
|
||||
import com.archipelago.app.ui.theme.Neo
|
||||
import com.archipelago.app.ui.theme.neoInset
|
||||
|
||||
private const val TAP_THRESHOLD = 12f
|
||||
private const val TAP_TIMEOUT = 250L
|
||||
|
||||
@Composable
|
||||
fun Trackpad(
|
||||
onMove: (dx: Int, dy: Int) -> Unit,
|
||||
onClick: (button: Int) -> Unit,
|
||||
onScroll: (dy: Int) -> Unit,
|
||||
onTwoFingerHold: () -> Unit,
|
||||
modifier: Modifier = Modifier,
|
||||
) {
|
||||
var fingers by remember { mutableIntStateOf(0) }
|
||||
val surface = Neo.surface()
|
||||
val light = Neo.shadowLight()
|
||||
val dark = Neo.shadowDark()
|
||||
val muted = Neo.textMuted()
|
||||
|
||||
Box(
|
||||
modifier = modifier
|
||||
.neoInset(light, dark, 20.dp, 3.dp, 6.dp)
|
||||
.clip(RoundedCornerShape(20.dp))
|
||||
.background(surface)
|
||||
.pointerInput(Unit) {
|
||||
awaitEachGesture {
|
||||
val first = awaitFirstDown(requireUnconsumed = false)
|
||||
var total = Offset.Zero
|
||||
val t0 = System.currentTimeMillis()
|
||||
var maxPtrs = 1
|
||||
var holdFired = false
|
||||
var twoStart = 0L
|
||||
var scrollAcc = 0f
|
||||
fingers = 1
|
||||
|
||||
do {
|
||||
val ev = awaitPointerEvent()
|
||||
val active = ev.changes.filter { !it.changedToUp() }
|
||||
maxPtrs = maxOf(maxPtrs, active.size)
|
||||
fingers = active.size
|
||||
|
||||
when {
|
||||
active.size >= 2 -> {
|
||||
if (twoStart == 0L) twoStart = System.currentTimeMillis()
|
||||
if (!holdFired && System.currentTimeMillis() - twoStart > 500) {
|
||||
holdFired = true
|
||||
onTwoFingerHold()
|
||||
}
|
||||
if (!holdFired) {
|
||||
val dy = active.map { it.positionChange().y }.average().toFloat()
|
||||
scrollAcc += dy
|
||||
if (kotlin.math.abs(scrollAcc) > 12f) {
|
||||
onScroll(if (scrollAcc > 0) 1 else -1)
|
||||
scrollAcc = 0f
|
||||
}
|
||||
}
|
||||
ev.changes.forEach { it.consume() }
|
||||
}
|
||||
active.size == 1 && maxPtrs == 1 -> {
|
||||
val d = active.first().positionChange()
|
||||
total += d
|
||||
if (d != Offset.Zero) onMove(d.x.toInt(), d.y.toInt())
|
||||
active.first().consume()
|
||||
}
|
||||
}
|
||||
} while (ev.changes.any { it.pressed })
|
||||
|
||||
fingers = 0
|
||||
val elapsed = System.currentTimeMillis() - t0
|
||||
if (maxPtrs == 1 && elapsed < TAP_TIMEOUT && total.getDistance() < TAP_THRESHOLD) {
|
||||
onClick(1)
|
||||
}
|
||||
}
|
||||
},
|
||||
contentAlignment = Alignment.Center,
|
||||
) {
|
||||
Text(
|
||||
text = if (fingers >= 2) "hold for menu" else "",
|
||||
style = MaterialTheme.typography.labelSmall,
|
||||
color = muted.copy(alpha = 0.4f),
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,173 @@
|
||||
package com.archipelago.app.ui.components
|
||||
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.gestures.detectTapGestures
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.height
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.DisposableEffect
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.rememberCoroutineScope
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.draw.clip
|
||||
import androidx.compose.ui.input.pointer.pointerInput
|
||||
import androidx.compose.ui.text.font.FontWeight
|
||||
import androidx.compose.ui.text.style.TextAlign
|
||||
import androidx.compose.ui.unit.Dp
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.compose.ui.unit.sp
|
||||
import com.archipelago.app.ui.theme.BitcoinOrange
|
||||
import com.archipelago.app.ui.theme.Neo
|
||||
import com.archipelago.app.ui.theme.neoInset
|
||||
import com.archipelago.app.ui.theme.neoRaised
|
||||
import kotlinx.coroutines.Job
|
||||
import kotlinx.coroutines.delay
|
||||
import kotlinx.coroutines.launch
|
||||
|
||||
private enum class Layer { ALPHA, NUM, SYM }
|
||||
private val KEY_H = 46.dp
|
||||
private val KEY_R = 10.dp
|
||||
private val GAP = 5.dp
|
||||
|
||||
@Composable
|
||||
fun VirtualKeyboard(onKey: (String) -> Unit, modifier: Modifier = Modifier) {
|
||||
var layer by remember { mutableStateOf(Layer.ALPHA) }
|
||||
var shifted by remember { mutableStateOf(false) }
|
||||
var capsLock by remember { mutableStateOf(false) }
|
||||
val up = shifted || capsLock
|
||||
|
||||
fun emit(k: String) { onKey(k); if (shifted && !capsLock) shifted = false }
|
||||
fun ch(c: String) { emit(if (up && layer == Layer.ALPHA) "shift+$c" else c) }
|
||||
|
||||
Column(
|
||||
modifier = modifier.background(Neo.surface()).padding(horizontal = 6.dp, vertical = 6.dp),
|
||||
verticalArrangement = Arrangement.spacedBy(GAP),
|
||||
) {
|
||||
when (layer) {
|
||||
Layer.ALPHA -> {
|
||||
CRow("q w e r t y u i o p".split(" "), up, ::ch)
|
||||
CRow("a s d f g h j k l".split(" "), up, ::ch, inset = 18.dp)
|
||||
Row(Modifier.fillMaxWidth().height(KEY_H), Arrangement.spacedBy(GAP)) {
|
||||
SKey(if (capsLock) "\u21EA" else "\u21E7", Modifier.weight(1.4f), active = up) {
|
||||
if (capsLock) { capsLock = false; shifted = false } else if (shifted) capsLock = true else shifted = true
|
||||
}
|
||||
"z x c v b n m".split(" ").forEach { c -> CKey(if (up) c.uppercase() else c, Modifier.weight(1f)) { ch(c) } }
|
||||
RKey("\u232B", Modifier.weight(1.4f)) { emit("BackSpace") }
|
||||
}
|
||||
}
|
||||
Layer.NUM -> {
|
||||
SRow("1 2 3 4 5 6 7 8 9 0".split(" "), ::emit)
|
||||
SRow("- / : ; ( ) \$ & @ \"".split(" "), ::emit)
|
||||
Row(Modifier.fillMaxWidth().height(KEY_H), Arrangement.spacedBy(GAP)) {
|
||||
SKey("#+=", Modifier.weight(1.4f)) { layer = Layer.SYM }
|
||||
". , ? ! '".split(" ").forEach { c -> CKey(c, Modifier.weight(1f)) { emit(c) } }
|
||||
RKey("\u232B", Modifier.weight(1.4f)) { emit("BackSpace") }
|
||||
}
|
||||
}
|
||||
Layer.SYM -> {
|
||||
SRow("[ ] { } # % ^ * + =".split(" "), ::emit)
|
||||
SRow("_ \\ | ~ < > ` @ !".split(" "), ::emit)
|
||||
Row(Modifier.fillMaxWidth().height(KEY_H), Arrangement.spacedBy(GAP)) {
|
||||
SKey("123", Modifier.weight(1.4f)) { layer = Layer.NUM }
|
||||
". , ? ! '".split(" ").forEach { c -> CKey(c, Modifier.weight(1f)) { emit(c) } }
|
||||
RKey("\u232B", Modifier.weight(1.4f)) { emit("BackSpace") }
|
||||
}
|
||||
}
|
||||
}
|
||||
Row(Modifier.fillMaxWidth().height(KEY_H), Arrangement.spacedBy(GAP)) {
|
||||
SKey(if (layer == Layer.ALPHA) "123" else "ABC", Modifier.weight(1.4f)) {
|
||||
layer = if (layer == Layer.ALPHA) Layer.NUM else Layer.ALPHA; shifted = false; capsLock = false
|
||||
}
|
||||
CKey(",", Modifier.weight(1f)) { emit("comma") }
|
||||
CKey("space", Modifier.weight(5f), fontSize = 13) { emit("space") }
|
||||
CKey(".", Modifier.weight(1f)) { emit("period") }
|
||||
AKey("\u23CE", Modifier.weight(1.4f)) { emit("Return") }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun CRow(keys: List<String>, up: Boolean, onKey: (String) -> Unit, inset: Dp = 0.dp) {
|
||||
Row(Modifier.fillMaxWidth().height(KEY_H).padding(horizontal = inset), Arrangement.spacedBy(GAP)) {
|
||||
keys.forEach { c -> CKey(if (up) c.uppercase() else c, Modifier.weight(1f)) { onKey(c) } }
|
||||
}
|
||||
}
|
||||
@Composable
|
||||
private fun SRow(keys: List<String>, onKey: (String) -> Unit) {
|
||||
Row(Modifier.fillMaxWidth().height(KEY_H), Arrangement.spacedBy(GAP)) {
|
||||
keys.forEach { c -> CKey(c, Modifier.weight(1f)) { onKey(c) } }
|
||||
}
|
||||
}
|
||||
|
||||
/** Character key */
|
||||
@Composable
|
||||
private fun CKey(label: String, modifier: Modifier = Modifier, fontSize: Int = 19, onTap: () -> Unit) {
|
||||
var p by remember { mutableStateOf(false) }
|
||||
val bg = Neo.surfaceRaised(); val l = Neo.shadowLight(); val d = Neo.shadowDark(); val t = Neo.textPrimary()
|
||||
Box(
|
||||
modifier = modifier.height(KEY_H)
|
||||
.then(if (p) Modifier.neoInset(l, d, KEY_R) else Modifier.neoRaised(l, d, KEY_R))
|
||||
.clip(RoundedCornerShape(KEY_R)).background(bg)
|
||||
.pointerInput(label) { detectTapGestures(onPress = { p = true; onTap(); tryAwaitRelease(); p = false }) },
|
||||
contentAlignment = Alignment.Center,
|
||||
) { Text(label, color = t.copy(alpha = if (p) 0.9f else 0.7f), fontSize = fontSize.sp, textAlign = TextAlign.Center, maxLines = 1) }
|
||||
}
|
||||
|
||||
/** Special key */
|
||||
@Composable
|
||||
private fun SKey(label: String, modifier: Modifier = Modifier, active: Boolean = false, onTap: () -> Unit) {
|
||||
var p by remember { mutableStateOf(false) }
|
||||
val bg = Neo.surfaceRaised(); val l = Neo.shadowLight(); val d = Neo.shadowDark()
|
||||
val tc = if (active) BitcoinOrange.copy(alpha = 0.8f) else Neo.textSecondary()
|
||||
Box(
|
||||
modifier = modifier.height(KEY_H)
|
||||
.then(if (p) Modifier.neoInset(l, d, KEY_R) else Modifier.neoRaised(l, d, KEY_R))
|
||||
.clip(RoundedCornerShape(KEY_R)).background(bg)
|
||||
.pointerInput(label) { detectTapGestures(onPress = { p = true; onTap(); tryAwaitRelease(); p = false }) },
|
||||
contentAlignment = Alignment.Center,
|
||||
) { Text(label, color = tc, fontSize = 14.sp, fontWeight = FontWeight.Medium, textAlign = TextAlign.Center) }
|
||||
}
|
||||
|
||||
/** Accent key (return) */
|
||||
@Composable
|
||||
private fun AKey(label: String, modifier: Modifier = Modifier, onTap: () -> Unit) {
|
||||
var p by remember { mutableStateOf(false) }
|
||||
val l = Neo.shadowLight(); val d = Neo.shadowDark()
|
||||
Box(
|
||||
modifier = modifier.height(KEY_H)
|
||||
.then(if (p) Modifier.neoInset(l, d, KEY_R) else Modifier.neoRaised(l, d, KEY_R))
|
||||
.clip(RoundedCornerShape(KEY_R)).background(Neo.surfaceRaised())
|
||||
.pointerInput(Unit) { detectTapGestures(onPress = { p = true; onTap(); tryAwaitRelease(); p = false }) },
|
||||
contentAlignment = Alignment.Center,
|
||||
) { Text(label, color = BitcoinOrange.copy(alpha = 0.7f), fontSize = 17.sp, fontWeight = FontWeight.Bold) }
|
||||
}
|
||||
|
||||
/** Repeatable key (backspace) */
|
||||
@Composable
|
||||
private fun RKey(label: String, modifier: Modifier = Modifier, onTap: () -> Unit) {
|
||||
var p by remember { mutableStateOf(false) }
|
||||
val scope = rememberCoroutineScope(); var job by remember { mutableStateOf<Job?>(null) }
|
||||
val l = Neo.shadowLight(); val d = Neo.shadowDark()
|
||||
DisposableEffect(Unit) { onDispose { job?.cancel() } }
|
||||
Box(
|
||||
modifier = modifier.height(KEY_H)
|
||||
.then(if (p) Modifier.neoInset(l, d, KEY_R) else Modifier.neoRaised(l, d, KEY_R))
|
||||
.clip(RoundedCornerShape(KEY_R)).background(Neo.surfaceRaised())
|
||||
.pointerInput(Unit) { detectTapGestures(onPress = {
|
||||
p = true; onTap(); job = scope.launch { delay(400); while (true) { onTap(); delay(55) } }
|
||||
tryAwaitRelease(); job?.cancel(); p = false
|
||||
}) },
|
||||
contentAlignment = Alignment.Center,
|
||||
) { Text(label, color = Neo.textSecondary(), fontSize = 17.sp) }
|
||||
}
|
||||
@@ -0,0 +1,107 @@
|
||||
package com.archipelago.app.ui.navigation
|
||||
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.collectAsState
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.rememberCoroutineScope
|
||||
import androidx.compose.ui.platform.LocalContext
|
||||
import androidx.navigation.compose.NavHost
|
||||
import androidx.navigation.compose.composable
|
||||
import androidx.navigation.compose.rememberNavController
|
||||
import com.archipelago.app.data.ServerPreferences
|
||||
import com.archipelago.app.ui.screens.IntroScreen
|
||||
import com.archipelago.app.ui.screens.RemoteInputScreen
|
||||
import com.archipelago.app.ui.screens.ServerConnectScreen
|
||||
import com.archipelago.app.ui.screens.WebViewScreen
|
||||
import kotlinx.coroutines.launch
|
||||
|
||||
object Routes {
|
||||
const val INTRO = "intro"
|
||||
const val SERVER_CONNECT = "server_connect"
|
||||
const val WEB_VIEW = "web_view"
|
||||
const val REMOTE_INPUT = "remote_input"
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun AppNavHost() {
|
||||
val context = LocalContext.current
|
||||
val prefs = remember { ServerPreferences(context) }
|
||||
val navController = rememberNavController()
|
||||
val scope = rememberCoroutineScope()
|
||||
|
||||
val introSeen by prefs.introSeen.collectAsState(initial = null)
|
||||
val activeServer by prefs.activeServer.collectAsState(initial = null)
|
||||
|
||||
if (introSeen == null) return
|
||||
|
||||
val startDestination = when {
|
||||
introSeen == false -> Routes.INTRO
|
||||
activeServer != null -> Routes.WEB_VIEW
|
||||
else -> Routes.SERVER_CONNECT
|
||||
}
|
||||
|
||||
NavHost(
|
||||
navController = navController,
|
||||
startDestination = startDestination,
|
||||
) {
|
||||
composable(Routes.INTRO) {
|
||||
IntroScreen(
|
||||
onContinue = {
|
||||
scope.launch {
|
||||
prefs.markIntroSeen()
|
||||
navController.navigate(Routes.SERVER_CONNECT) {
|
||||
popUpTo(Routes.INTRO) { inclusive = true }
|
||||
}
|
||||
}
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
composable(Routes.SERVER_CONNECT) {
|
||||
ServerConnectScreen(
|
||||
onConnected = { _ ->
|
||||
navController.navigate(Routes.WEB_VIEW) {
|
||||
popUpTo(Routes.SERVER_CONNECT) { inclusive = true }
|
||||
}
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
composable(Routes.WEB_VIEW) {
|
||||
val server = activeServer
|
||||
if (server == null) {
|
||||
ServerConnectScreen(
|
||||
onConnected = { _ ->
|
||||
navController.navigate(Routes.WEB_VIEW) {
|
||||
popUpTo(0) { inclusive = true }
|
||||
}
|
||||
},
|
||||
)
|
||||
} else {
|
||||
WebViewScreen(
|
||||
serverUrl = server.toUrl(),
|
||||
onDisconnect = {
|
||||
scope.launch {
|
||||
prefs.clearActiveServer()
|
||||
navController.navigate(Routes.SERVER_CONNECT) {
|
||||
popUpTo(0) { inclusive = true }
|
||||
}
|
||||
}
|
||||
},
|
||||
onRemoteInput = {
|
||||
navController.navigate(Routes.REMOTE_INPUT)
|
||||
},
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
composable(Routes.REMOTE_INPUT) {
|
||||
RemoteInputScreen(
|
||||
onBack = {
|
||||
navController.popBackStack()
|
||||
},
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,223 @@
|
||||
package com.archipelago.app.ui.screens
|
||||
|
||||
import androidx.compose.animation.AnimatedVisibility
|
||||
import androidx.compose.animation.core.Animatable
|
||||
import androidx.compose.animation.core.animateFloatAsState
|
||||
import androidx.compose.animation.core.tween
|
||||
import androidx.compose.animation.fadeIn
|
||||
import androidx.compose.animation.slideInVertically
|
||||
import androidx.compose.foundation.Canvas
|
||||
import androidx.compose.foundation.Image
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.border
|
||||
import androidx.compose.foundation.clickable
|
||||
import androidx.compose.foundation.interaction.MutableInteractionSource
|
||||
import androidx.compose.foundation.interaction.collectIsPressedAsState
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.Spacer
|
||||
import androidx.compose.foundation.layout.WindowInsets
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.height
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.layout.safeDrawing
|
||||
import androidx.compose.foundation.layout.windowInsetsPadding
|
||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.LaunchedEffect
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.draw.alpha
|
||||
import androidx.compose.ui.draw.clip
|
||||
import androidx.compose.ui.geometry.Offset
|
||||
import androidx.compose.ui.geometry.Size
|
||||
import androidx.compose.ui.graphics.Brush
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.graphics.ColorFilter
|
||||
import androidx.compose.ui.res.painterResource
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import androidx.compose.ui.text.style.TextAlign
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.compose.ui.unit.sp
|
||||
import com.archipelago.app.R
|
||||
import com.archipelago.app.ui.theme.SurfaceBlack
|
||||
import com.archipelago.app.ui.theme.TextMuted
|
||||
import com.archipelago.app.ui.theme.TextPrimary
|
||||
import kotlinx.coroutines.delay
|
||||
|
||||
@Composable
|
||||
fun IntroScreen(onContinue: () -> Unit) {
|
||||
val logoAlpha = remember { Animatable(0f) }
|
||||
var showContent by remember { mutableStateOf(false) }
|
||||
|
||||
LaunchedEffect(Unit) {
|
||||
logoAlpha.animateTo(1f, animationSpec = tween(800))
|
||||
delay(300)
|
||||
showContent = true
|
||||
}
|
||||
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.fillMaxSize()
|
||||
.background(SurfaceBlack)
|
||||
.windowInsetsPadding(WindowInsets.safeDrawing),
|
||||
contentAlignment = Alignment.Center,
|
||||
) {
|
||||
Column(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(horizontal = 32.dp),
|
||||
horizontalAlignment = Alignment.CenterHorizontally,
|
||||
verticalArrangement = Arrangement.Center,
|
||||
) {
|
||||
// Wide pixel-art logo
|
||||
Image(
|
||||
painter = painterResource(id = R.drawable.ic_logo_wide),
|
||||
contentDescription = "Archipelago",
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(horizontal = 8.dp)
|
||||
.alpha(logoAlpha.value),
|
||||
colorFilter = ColorFilter.tint(Color.White),
|
||||
)
|
||||
|
||||
Spacer(modifier = Modifier.height(48.dp))
|
||||
|
||||
AnimatedVisibility(
|
||||
visible = showContent,
|
||||
enter = fadeIn(tween(600)) + slideInVertically(
|
||||
initialOffsetY = { it / 4 },
|
||||
animationSpec = tween(600),
|
||||
),
|
||||
) {
|
||||
Column(horizontalAlignment = Alignment.CenterHorizontally) {
|
||||
Text(
|
||||
text = stringResource(R.string.welcome_title),
|
||||
style = MaterialTheme.typography.headlineLarge,
|
||||
color = TextPrimary,
|
||||
textAlign = TextAlign.Center,
|
||||
)
|
||||
|
||||
Spacer(modifier = Modifier.height(16.dp))
|
||||
|
||||
Text(
|
||||
text = stringResource(R.string.welcome_subtitle),
|
||||
style = MaterialTheme.typography.bodyLarge,
|
||||
color = TextMuted,
|
||||
textAlign = TextAlign.Center,
|
||||
lineHeight = 26.sp,
|
||||
)
|
||||
|
||||
Spacer(modifier = Modifier.height(48.dp))
|
||||
|
||||
GlassButton(
|
||||
text = stringResource(R.string.get_started),
|
||||
onClick = onContinue,
|
||||
modifier = Modifier.fillMaxWidth().height(56.dp),
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** The pixel-art "A" from AnimatedLogo.vue — 20 white squares */
|
||||
@Composable
|
||||
fun PixelArtLogo(modifier: Modifier = Modifier) {
|
||||
Canvas(modifier = modifier) {
|
||||
val s = size.width / 1024f
|
||||
val rects = listOf(
|
||||
floatArrayOf(357.614f, 318f, 71.007f, 70.936f),
|
||||
floatArrayOf(436.152f, 318f, 72.082f, 70.936f),
|
||||
floatArrayOf(515.766f, 318f, 72.082f, 70.936f),
|
||||
floatArrayOf(595.379f, 318f, 71.007f, 70.936f),
|
||||
floatArrayOf(595.379f, 396.46f, 71.007f, 72.011f),
|
||||
floatArrayOf(673.917f, 396.46f, 72.083f, 72.011f),
|
||||
floatArrayOf(278f, 475.994f, 72.083f, 72.012f),
|
||||
floatArrayOf(357.614f, 475.994f, 71.007f, 72.012f),
|
||||
floatArrayOf(436.152f, 475.994f, 72.082f, 72.012f),
|
||||
floatArrayOf(515.766f, 475.994f, 72.082f, 72.012f),
|
||||
floatArrayOf(595.379f, 475.994f, 71.007f, 72.012f),
|
||||
floatArrayOf(673.917f, 475.994f, 72.083f, 72.012f),
|
||||
floatArrayOf(278f, 555.529f, 72.083f, 70.936f),
|
||||
floatArrayOf(357.614f, 555.529f, 71.007f, 70.936f),
|
||||
floatArrayOf(595.379f, 555.529f, 71.007f, 70.936f),
|
||||
floatArrayOf(673.917f, 555.529f, 72.083f, 70.936f),
|
||||
floatArrayOf(357.614f, 633.989f, 71.007f, 72.011f),
|
||||
floatArrayOf(436.152f, 633.989f, 72.082f, 72.011f),
|
||||
floatArrayOf(515.766f, 633.989f, 72.082f, 72.011f),
|
||||
floatArrayOf(595.379f, 633.989f, 71.007f, 72.011f),
|
||||
)
|
||||
for (r in rects) {
|
||||
drawRect(
|
||||
color = Color.White,
|
||||
topLeft = Offset(r[0] * s, r[1] * s),
|
||||
size = Size(r[2] * s, r[3] * s),
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Glass-style button matching Archipelago's .glass-button.
|
||||
* Custom press state (subtle brighten) instead of Material ripple.
|
||||
*/
|
||||
@Composable
|
||||
fun GlassButton(
|
||||
text: String,
|
||||
onClick: () -> Unit,
|
||||
modifier: Modifier = Modifier,
|
||||
) {
|
||||
val interactionSource = remember { MutableInteractionSource() }
|
||||
val isPressed by interactionSource.collectIsPressedAsState()
|
||||
val pressAlpha by animateFloatAsState(
|
||||
targetValue = if (isPressed) 1f else 0f,
|
||||
animationSpec = tween(if (isPressed) 0 else 150),
|
||||
label = "press",
|
||||
)
|
||||
|
||||
// Lerp between rest and pressed states
|
||||
val bgTop = 0.12f + pressAlpha * 0.08f // 0.12 → 0.20
|
||||
val bgBottom = 0.04f + pressAlpha * 0.06f // 0.04 → 0.10
|
||||
val borderA = 0.15f + pressAlpha * 0.10f // 0.15 → 0.25
|
||||
val textAlpha = 1f - pressAlpha * 0.2f // 1.0 → 0.8
|
||||
|
||||
Box(
|
||||
modifier = modifier
|
||||
.clip(RoundedCornerShape(12.dp))
|
||||
.background(
|
||||
Brush.verticalGradient(
|
||||
colors = listOf(
|
||||
Color.White.copy(alpha = bgTop),
|
||||
Color.White.copy(alpha = bgBottom),
|
||||
),
|
||||
)
|
||||
)
|
||||
.border(
|
||||
width = 1.dp,
|
||||
color = Color.White.copy(alpha = borderA),
|
||||
shape = RoundedCornerShape(12.dp),
|
||||
)
|
||||
.clickable(
|
||||
interactionSource = interactionSource,
|
||||
indication = null,
|
||||
onClick = onClick,
|
||||
),
|
||||
contentAlignment = Alignment.Center,
|
||||
) {
|
||||
Text(
|
||||
text = text,
|
||||
color = Color.White.copy(alpha = textAlpha),
|
||||
style = MaterialTheme.typography.labelLarge,
|
||||
fontSize = 16.sp,
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,166 @@
|
||||
package com.archipelago.app.ui.screens
|
||||
|
||||
import android.content.res.Configuration
|
||||
import androidx.activity.compose.BackHandler
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.WindowInsets
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.layout.safeDrawing
|
||||
import androidx.compose.foundation.layout.size
|
||||
import androidx.compose.foundation.layout.windowInsetsPadding
|
||||
import androidx.compose.foundation.shape.CircleShape
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.DisposableEffect
|
||||
import androidx.compose.runtime.collectAsState
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.rememberCoroutineScope
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.draw.clip
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.platform.LocalConfiguration
|
||||
import androidx.compose.ui.platform.LocalContext
|
||||
import androidx.compose.ui.platform.LocalLifecycleOwner
|
||||
import androidx.lifecycle.Lifecycle
|
||||
import androidx.lifecycle.LifecycleEventObserver
|
||||
import androidx.compose.ui.unit.dp
|
||||
import com.archipelago.app.data.ServerPreferences
|
||||
import com.archipelago.app.network.ConnectionState
|
||||
import com.archipelago.app.network.InputWebSocket
|
||||
import com.archipelago.app.ui.components.NESController
|
||||
import com.archipelago.app.ui.components.NESKeyboard
|
||||
import com.archipelago.app.ui.components.NESMenu
|
||||
import com.archipelago.app.ui.components.NESPortraitController
|
||||
import com.archipelago.app.ui.components.Trackpad
|
||||
import com.archipelago.app.ui.theme.BitcoinOrange
|
||||
import com.archipelago.app.ui.theme.ControllerStyle
|
||||
import com.archipelago.app.ui.theme.ErrorRed
|
||||
import com.archipelago.app.ui.theme.SuccessGreen
|
||||
import com.archipelago.app.ui.theme.TextMuted
|
||||
import kotlinx.coroutines.launch
|
||||
|
||||
@Composable
|
||||
fun RemoteInputScreen(onBack: () -> Unit) {
|
||||
val context = LocalContext.current
|
||||
val prefs = remember { ServerPreferences(context) }
|
||||
val scope = rememberCoroutineScope()
|
||||
val isLandscape = LocalConfiguration.current.orientation == Configuration.ORIENTATION_LANDSCAPE
|
||||
|
||||
val savedServers by prefs.savedServers.collectAsState(initial = emptyList())
|
||||
val activeServer by prefs.activeServer.collectAsState(initial = null)
|
||||
|
||||
var isGamepadMode by remember { mutableStateOf(true) }
|
||||
var showModal by remember { mutableStateOf(false) }
|
||||
var controllerStyle by remember { mutableStateOf(ControllerStyle.CLASSIC) }
|
||||
|
||||
val ws = remember { InputWebSocket(scope) }
|
||||
val connectionState by ws.state.collectAsState()
|
||||
val lifecycleOwner = LocalLifecycleOwner.current
|
||||
|
||||
BackHandler { onBack() }
|
||||
|
||||
// Connect on server change + reconnect when app resumes from background
|
||||
DisposableEffect(lifecycleOwner, activeServer) {
|
||||
val server = activeServer
|
||||
if (server != null) {
|
||||
ws.connect(server.toUrl(), server.password)
|
||||
}
|
||||
|
||||
val observer = LifecycleEventObserver { _, event ->
|
||||
if (event == Lifecycle.Event.ON_RESUME && server != null) {
|
||||
val state = ws.state.value
|
||||
if (state != ConnectionState.CONNECTED && state != ConnectionState.CONNECTING) {
|
||||
ws.connect(server.toUrl(), server.password)
|
||||
}
|
||||
}
|
||||
}
|
||||
lifecycleOwner.lifecycle.addObserver(observer)
|
||||
|
||||
onDispose {
|
||||
lifecycleOwner.lifecycle.removeObserver(observer)
|
||||
ws.disconnect()
|
||||
}
|
||||
}
|
||||
|
||||
Box(
|
||||
Modifier
|
||||
.fillMaxSize()
|
||||
.background(Color(0xFF0C0C0C))
|
||||
.windowInsetsPadding(WindowInsets.safeDrawing),
|
||||
) {
|
||||
when {
|
||||
isGamepadMode && isLandscape -> NESController(
|
||||
style = controllerStyle,
|
||||
onKey = { ws.sendKey(it) },
|
||||
onMenu = { showModal = true },
|
||||
)
|
||||
isGamepadMode && !isLandscape -> NESPortraitController(
|
||||
style = controllerStyle,
|
||||
onKey = { ws.sendKey(it) },
|
||||
onMouseMove = { dx, dy -> ws.sendMouseMove(dx, dy) },
|
||||
onMouseClick = { ws.sendClick(it) },
|
||||
onMouseScroll = { ws.sendScroll(it) },
|
||||
onMenu = { showModal = true },
|
||||
)
|
||||
else -> {
|
||||
// Keyboard mode: trackpad fills top, keyboard pinned bottom
|
||||
Column(Modifier.fillMaxSize()) {
|
||||
Trackpad(
|
||||
onMove = { dx, dy -> ws.sendMouseMove(dx, dy) },
|
||||
onClick = { ws.sendClick(it) },
|
||||
onScroll = { ws.sendScroll(it) },
|
||||
onTwoFingerHold = { showModal = true },
|
||||
modifier = Modifier.fillMaxWidth().weight(1f)
|
||||
.padding(horizontal = 16.dp, vertical = 8.dp),
|
||||
)
|
||||
NESKeyboard(
|
||||
style = controllerStyle,
|
||||
onKey = { ws.sendKey(it) },
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Connection dot
|
||||
Box(
|
||||
Modifier.align(Alignment.TopStart).padding(6.dp).size(8.dp)
|
||||
.clip(CircleShape).background(
|
||||
when (connectionState) {
|
||||
ConnectionState.CONNECTED -> SuccessGreen
|
||||
ConnectionState.CONNECTING -> BitcoinOrange
|
||||
ConnectionState.ERROR, ConnectionState.AUTH_FAILED -> ErrorRed
|
||||
ConnectionState.DISCONNECTED -> TextMuted
|
||||
}
|
||||
),
|
||||
)
|
||||
|
||||
NESMenu(
|
||||
visible = showModal,
|
||||
servers = savedServers,
|
||||
activeServer = activeServer,
|
||||
isGamepadMode = isGamepadMode,
|
||||
controllerStyle = controllerStyle,
|
||||
onDismiss = { showModal = false },
|
||||
onSelectServer = { server ->
|
||||
scope.launch { ws.disconnect(); prefs.setActiveServer(server) }; showModal = false
|
||||
},
|
||||
onAddServer = { server ->
|
||||
scope.launch { prefs.addSavedServer(server); if (activeServer == null) prefs.setActiveServer(server) }
|
||||
},
|
||||
onRemoveServer = { server -> scope.launch { prefs.removeSavedServer(server) } },
|
||||
onToggleMode = { isGamepadMode = !isGamepadMode; showModal = false },
|
||||
onToggleStyle = {
|
||||
controllerStyle = if (controllerStyle == ControllerStyle.CLASSIC) ControllerStyle.DARK else ControllerStyle.CLASSIC
|
||||
},
|
||||
onBackToWebView = { showModal = false; onBack() },
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,471 @@
|
||||
package com.archipelago.app.ui.screens
|
||||
|
||||
import androidx.compose.animation.AnimatedVisibility
|
||||
import androidx.compose.animation.fadeIn
|
||||
import androidx.compose.animation.fadeOut
|
||||
import androidx.compose.foundation.Image
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.border
|
||||
import androidx.compose.foundation.clickable
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.Spacer
|
||||
import androidx.compose.foundation.layout.WindowInsets
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.height
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.layout.safeDrawing
|
||||
import androidx.compose.foundation.layout.size
|
||||
import androidx.compose.foundation.layout.width
|
||||
import androidx.compose.foundation.layout.windowInsetsPadding
|
||||
import androidx.compose.foundation.rememberScrollState
|
||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
import androidx.compose.foundation.text.KeyboardActions
|
||||
import androidx.compose.foundation.text.KeyboardOptions
|
||||
import androidx.compose.material.icons.filled.Visibility
|
||||
import androidx.compose.material.icons.filled.VisibilityOff
|
||||
import androidx.compose.foundation.verticalScroll
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.filled.Close
|
||||
import androidx.compose.material.icons.filled.Lock
|
||||
import androidx.compose.material.icons.filled.LockOpen
|
||||
import androidx.compose.material3.CircularProgressIndicator
|
||||
import androidx.compose.material3.Icon
|
||||
import androidx.compose.material3.IconButton
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.OutlinedTextField
|
||||
import androidx.compose.material3.OutlinedTextFieldDefaults
|
||||
import androidx.compose.material3.Switch
|
||||
import androidx.compose.material3.SwitchDefaults
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.collectAsState
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.rememberCoroutineScope
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.draw.clip
|
||||
import androidx.compose.ui.draw.drawWithContent
|
||||
import androidx.compose.ui.graphics.Brush
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.graphics.ColorFilter
|
||||
import androidx.compose.ui.platform.LocalContext
|
||||
import androidx.compose.ui.platform.LocalSoftwareKeyboardController
|
||||
import androidx.compose.ui.res.painterResource
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import androidx.compose.ui.text.input.ImeAction
|
||||
import androidx.compose.ui.text.input.KeyboardType
|
||||
import androidx.compose.ui.text.input.PasswordVisualTransformation
|
||||
import androidx.compose.ui.text.input.VisualTransformation
|
||||
import androidx.compose.ui.text.style.TextAlign
|
||||
import androidx.compose.ui.text.style.TextOverflow
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.compose.ui.unit.sp
|
||||
import com.archipelago.app.R
|
||||
import com.archipelago.app.data.ServerEntry
|
||||
import com.archipelago.app.data.ServerPreferences
|
||||
import com.archipelago.app.ui.theme.BitcoinOrange
|
||||
import com.archipelago.app.ui.theme.ErrorRed
|
||||
import com.archipelago.app.ui.theme.SurfaceBlack
|
||||
import com.archipelago.app.ui.theme.SurfaceCard
|
||||
import com.archipelago.app.ui.theme.SuccessGreen
|
||||
import com.archipelago.app.ui.theme.TextMuted
|
||||
import com.archipelago.app.ui.theme.TextPrimary
|
||||
import com.archipelago.app.ui.theme.TextSecondary
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.launch
|
||||
import kotlinx.coroutines.withContext
|
||||
import java.net.HttpURLConnection
|
||||
import java.net.URL
|
||||
import javax.net.ssl.HttpsURLConnection
|
||||
import javax.net.ssl.SSLContext
|
||||
import javax.net.ssl.X509TrustManager
|
||||
|
||||
@Composable
|
||||
fun ServerConnectScreen(
|
||||
onConnected: (String) -> Unit,
|
||||
onRemoteInput: () -> Unit = {},
|
||||
) {
|
||||
val context = LocalContext.current
|
||||
val prefs = remember { ServerPreferences(context) }
|
||||
val scope = rememberCoroutineScope()
|
||||
val keyboard = LocalSoftwareKeyboardController.current
|
||||
|
||||
var address by remember { mutableStateOf("") }
|
||||
var port by remember { mutableStateOf("") }
|
||||
var password by remember { mutableStateOf("") }
|
||||
var passwordVisible by remember { mutableStateOf(false) }
|
||||
var useHttps by remember { mutableStateOf(false) }
|
||||
var isConnecting by remember { mutableStateOf(false) }
|
||||
var errorMessage by remember { mutableStateOf<String?>(null) }
|
||||
|
||||
val savedServers by prefs.savedServers.collectAsState(initial = emptyList())
|
||||
|
||||
fun connect(server: ServerEntry) {
|
||||
if (isConnecting) return
|
||||
if (server.address.isBlank()) {
|
||||
errorMessage = "Enter a server address"
|
||||
return
|
||||
}
|
||||
isConnecting = true
|
||||
errorMessage = null
|
||||
|
||||
scope.launch {
|
||||
val result = testConnection(server)
|
||||
isConnecting = false
|
||||
|
||||
if (result) {
|
||||
prefs.setActiveServer(server)
|
||||
onConnected(server.toUrl())
|
||||
} else {
|
||||
errorMessage = context.getString(R.string.connection_failed)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.fillMaxSize()
|
||||
.background(SurfaceBlack)
|
||||
.windowInsetsPadding(WindowInsets.safeDrawing),
|
||||
) {
|
||||
Column(
|
||||
modifier = Modifier
|
||||
.fillMaxSize()
|
||||
.verticalScroll(state = rememberScrollState())
|
||||
.drawWithContent { drawContent() }
|
||||
.padding(horizontal = 24.dp)
|
||||
.padding(top = 48.dp, bottom = 32.dp),
|
||||
horizontalAlignment = Alignment.CenterHorizontally,
|
||||
verticalArrangement = Arrangement.spacedBy(16.dp),
|
||||
) {
|
||||
// Wide logo
|
||||
Image(
|
||||
painter = painterResource(id = R.drawable.ic_logo_wide),
|
||||
contentDescription = "Archipelago",
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(horizontal = 16.dp),
|
||||
colorFilter = ColorFilter.tint(Color.White),
|
||||
)
|
||||
|
||||
Spacer(modifier = Modifier.height(4.dp))
|
||||
|
||||
Text(
|
||||
text = "Connect to Server",
|
||||
style = MaterialTheme.typography.headlineMedium,
|
||||
color = TextPrimary,
|
||||
textAlign = TextAlign.Center,
|
||||
)
|
||||
|
||||
Text(
|
||||
text = stringResource(R.string.server_address_hint),
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
color = TextMuted,
|
||||
textAlign = TextAlign.Center,
|
||||
)
|
||||
|
||||
Spacer(modifier = Modifier.height(4.dp))
|
||||
|
||||
// Glass card with form
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.clip(RoundedCornerShape(16.dp))
|
||||
.background(
|
||||
Brush.verticalGradient(
|
||||
colors = listOf(
|
||||
Color.White.copy(alpha = 0.06f),
|
||||
Color.White.copy(alpha = 0.02f),
|
||||
),
|
||||
)
|
||||
)
|
||||
.border(1.dp, Color.White.copy(alpha = 0.1f), RoundedCornerShape(16.dp))
|
||||
.padding(20.dp),
|
||||
) {
|
||||
Column {
|
||||
OutlinedTextField(
|
||||
value = address,
|
||||
onValueChange = {
|
||||
address = sanitizeAddress(it)
|
||||
errorMessage = null
|
||||
},
|
||||
label = { Text(stringResource(R.string.server_address_label)) },
|
||||
placeholder = { Text(stringResource(R.string.server_address_placeholder)) },
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
singleLine = true,
|
||||
keyboardOptions = KeyboardOptions(
|
||||
keyboardType = KeyboardType.Uri,
|
||||
imeAction = ImeAction.Next,
|
||||
),
|
||||
colors = OutlinedTextFieldDefaults.colors(
|
||||
focusedBorderColor = Color.White.copy(alpha = 0.3f),
|
||||
unfocusedBorderColor = Color.White.copy(alpha = 0.12f),
|
||||
cursorColor = Color.White,
|
||||
focusedLabelColor = Color.White.copy(alpha = 0.7f),
|
||||
unfocusedLabelColor = TextMuted,
|
||||
focusedTextColor = TextPrimary,
|
||||
unfocusedTextColor = TextPrimary,
|
||||
),
|
||||
shape = RoundedCornerShape(12.dp),
|
||||
)
|
||||
|
||||
Spacer(modifier = Modifier.height(12.dp))
|
||||
|
||||
Row(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
horizontalArrangement = Arrangement.spacedBy(12.dp),
|
||||
) {
|
||||
OutlinedTextField(
|
||||
value = port,
|
||||
onValueChange = {
|
||||
port = it.filter { c -> c.isDigit() }.take(5)
|
||||
errorMessage = null
|
||||
},
|
||||
label = { Text(stringResource(R.string.port_label)) },
|
||||
placeholder = { Text("80") },
|
||||
modifier = Modifier.weight(1f),
|
||||
singleLine = true,
|
||||
keyboardOptions = KeyboardOptions(
|
||||
keyboardType = KeyboardType.Number,
|
||||
imeAction = ImeAction.Next,
|
||||
),
|
||||
colors = OutlinedTextFieldDefaults.colors(
|
||||
focusedBorderColor = Color.White.copy(alpha = 0.3f),
|
||||
unfocusedBorderColor = Color.White.copy(alpha = 0.12f),
|
||||
cursorColor = Color.White,
|
||||
focusedLabelColor = Color.White.copy(alpha = 0.7f),
|
||||
unfocusedLabelColor = TextMuted,
|
||||
focusedTextColor = TextPrimary,
|
||||
unfocusedTextColor = TextPrimary,
|
||||
),
|
||||
shape = RoundedCornerShape(12.dp),
|
||||
)
|
||||
|
||||
OutlinedTextField(
|
||||
value = password,
|
||||
onValueChange = {
|
||||
password = it
|
||||
errorMessage = null
|
||||
},
|
||||
label = { Text("Password") },
|
||||
modifier = Modifier.weight(2f),
|
||||
singleLine = true,
|
||||
visualTransformation = if (passwordVisible) VisualTransformation.None else PasswordVisualTransformation(),
|
||||
trailingIcon = {
|
||||
IconButton(onClick = { passwordVisible = !passwordVisible }) {
|
||||
Icon(
|
||||
imageVector = if (passwordVisible) Icons.Default.VisibilityOff else Icons.Default.Visibility,
|
||||
contentDescription = if (passwordVisible) "Hide password" else "Show password",
|
||||
tint = TextMuted,
|
||||
modifier = Modifier.size(20.dp),
|
||||
)
|
||||
}
|
||||
},
|
||||
keyboardOptions = KeyboardOptions(
|
||||
keyboardType = KeyboardType.Password,
|
||||
imeAction = ImeAction.Go,
|
||||
),
|
||||
keyboardActions = KeyboardActions(
|
||||
onGo = {
|
||||
keyboard?.hide()
|
||||
connect(ServerEntry(address, useHttps, port, password))
|
||||
},
|
||||
),
|
||||
colors = OutlinedTextFieldDefaults.colors(
|
||||
focusedBorderColor = Color.White.copy(alpha = 0.3f),
|
||||
unfocusedBorderColor = Color.White.copy(alpha = 0.12f),
|
||||
cursorColor = Color.White,
|
||||
focusedLabelColor = Color.White.copy(alpha = 0.7f),
|
||||
unfocusedLabelColor = TextMuted,
|
||||
focusedTextColor = TextPrimary,
|
||||
unfocusedTextColor = TextPrimary,
|
||||
),
|
||||
shape = RoundedCornerShape(12.dp),
|
||||
)
|
||||
}
|
||||
|
||||
Spacer(modifier = Modifier.height(16.dp))
|
||||
|
||||
Row(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
horizontalArrangement = Arrangement.SpaceBetween,
|
||||
) {
|
||||
Row(verticalAlignment = Alignment.CenterVertically) {
|
||||
Icon(
|
||||
imageVector = if (useHttps) Icons.Default.Lock else Icons.Default.LockOpen,
|
||||
contentDescription = null,
|
||||
modifier = Modifier.size(20.dp),
|
||||
tint = if (useHttps) SuccessGreen else TextMuted,
|
||||
)
|
||||
Spacer(modifier = Modifier.width(8.dp))
|
||||
Text(
|
||||
text = stringResource(R.string.use_https),
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
color = TextSecondary,
|
||||
)
|
||||
}
|
||||
Switch(
|
||||
checked = useHttps,
|
||||
onCheckedChange = { useHttps = it },
|
||||
colors = SwitchDefaults.colors(
|
||||
checkedThumbColor = SurfaceBlack,
|
||||
checkedTrackColor = BitcoinOrange,
|
||||
uncheckedThumbColor = TextMuted,
|
||||
uncheckedTrackColor = SurfaceCard,
|
||||
),
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Error
|
||||
AnimatedVisibility(visible = errorMessage != null, enter = fadeIn(), exit = fadeOut()) {
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.clip(RoundedCornerShape(12.dp))
|
||||
.background(ErrorRed.copy(alpha = 0.12f))
|
||||
.border(1.dp, ErrorRed.copy(alpha = 0.25f), RoundedCornerShape(12.dp))
|
||||
.padding(12.dp),
|
||||
) {
|
||||
Text(text = errorMessage ?: "", color = ErrorRed, style = MaterialTheme.typography.bodyMedium)
|
||||
}
|
||||
}
|
||||
|
||||
// Connect button — glass style
|
||||
GlassButton(
|
||||
text = if (isConnecting) stringResource(R.string.connecting) else stringResource(R.string.connect),
|
||||
onClick = {
|
||||
keyboard?.hide()
|
||||
connect(ServerEntry(address, useHttps, port, password))
|
||||
},
|
||||
modifier = Modifier.fillMaxWidth().height(56.dp),
|
||||
)
|
||||
|
||||
if (isConnecting) {
|
||||
CircularProgressIndicator(
|
||||
modifier = Modifier.size(24.dp),
|
||||
color = Color.White.copy(alpha = 0.6f),
|
||||
strokeWidth = 2.dp,
|
||||
)
|
||||
}
|
||||
|
||||
// Saved servers
|
||||
if (savedServers.isNotEmpty()) {
|
||||
Spacer(modifier = Modifier.height(8.dp))
|
||||
Text(
|
||||
text = stringResource(R.string.saved_servers),
|
||||
style = MaterialTheme.typography.labelMedium,
|
||||
color = TextMuted,
|
||||
letterSpacing = 1.sp,
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
)
|
||||
|
||||
savedServers.forEach { server ->
|
||||
SavedServerItem(
|
||||
server = server,
|
||||
onConnect = { connect(it) },
|
||||
onRemove = { scope.launch { prefs.removeSavedServer(it) } },
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun SavedServerItem(
|
||||
server: ServerEntry,
|
||||
onConnect: (ServerEntry) -> Unit,
|
||||
onRemove: (ServerEntry) -> Unit,
|
||||
) {
|
||||
Row(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.clip(RoundedCornerShape(12.dp))
|
||||
.background(
|
||||
Brush.verticalGradient(
|
||||
colors = listOf(
|
||||
Color.White.copy(alpha = 0.06f),
|
||||
Color.White.copy(alpha = 0.02f),
|
||||
),
|
||||
)
|
||||
)
|
||||
.border(1.dp, Color.White.copy(alpha = 0.1f), RoundedCornerShape(12.dp))
|
||||
.clickable { onConnect(server) }
|
||||
.padding(horizontal = 16.dp, vertical = 14.dp),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
horizontalArrangement = Arrangement.SpaceBetween,
|
||||
) {
|
||||
Row(verticalAlignment = Alignment.CenterVertically, modifier = Modifier.weight(1f)) {
|
||||
Icon(
|
||||
imageVector = if (server.useHttps) Icons.Default.Lock else Icons.Default.LockOpen,
|
||||
contentDescription = null,
|
||||
modifier = Modifier.size(20.dp),
|
||||
tint = if (server.useHttps) SuccessGreen else BitcoinOrange,
|
||||
)
|
||||
Spacer(modifier = Modifier.width(12.dp))
|
||||
Column {
|
||||
Text(text = server.address, style = MaterialTheme.typography.bodyMedium, color = TextPrimary, maxLines = 1, overflow = TextOverflow.Ellipsis)
|
||||
if (server.port.isNotBlank()) {
|
||||
Text(text = "Port ${server.port}", style = MaterialTheme.typography.labelMedium, color = TextMuted)
|
||||
}
|
||||
}
|
||||
}
|
||||
IconButton(onClick = { onRemove(server) }) {
|
||||
Icon(imageVector = Icons.Default.Close, contentDescription = stringResource(R.string.remove_server), modifier = Modifier.size(18.dp), tint = TextMuted)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** Strip protocol prefixes and trailing slashes from address input. */
|
||||
private fun sanitizeAddress(input: String): String {
|
||||
return input.trim()
|
||||
.removePrefix("https://")
|
||||
.removePrefix("http://")
|
||||
.trimEnd('/')
|
||||
}
|
||||
|
||||
/** Test RPC connectivity. Accepts self-signed certs for local LAN servers. */
|
||||
private suspend fun testConnection(server: ServerEntry): Boolean {
|
||||
return withContext(Dispatchers.IO) {
|
||||
try {
|
||||
val url = URL("${server.toUrl()}/rpc/v1")
|
||||
val connection = url.openConnection() as HttpURLConnection
|
||||
|
||||
// Trust self-signed certs for local HTTPS (Archipelago nodes rarely have CA certs)
|
||||
if (connection is HttpsURLConnection) {
|
||||
val trustAll = arrayOf<javax.net.ssl.TrustManager>(object : X509TrustManager {
|
||||
override fun checkClientTrusted(chain: Array<java.security.cert.X509Certificate>?, authType: String?) {}
|
||||
override fun checkServerTrusted(chain: Array<java.security.cert.X509Certificate>?, authType: String?) {}
|
||||
override fun getAcceptedIssuers(): Array<java.security.cert.X509Certificate> = arrayOf()
|
||||
})
|
||||
val sc = SSLContext.getInstance("TLS")
|
||||
sc.init(null, trustAll, java.security.SecureRandom())
|
||||
connection.sslSocketFactory = sc.socketFactory
|
||||
connection.hostnameVerifier = javax.net.ssl.HostnameVerifier { _, _ -> true }
|
||||
}
|
||||
|
||||
connection.requestMethod = "POST"
|
||||
connection.connectTimeout = 5000
|
||||
connection.readTimeout = 5000
|
||||
connection.setRequestProperty("Content-Type", "application/json")
|
||||
connection.doOutput = true
|
||||
val body = """{"method":"server.echo","params":{"message":"ping"}}"""
|
||||
connection.outputStream.use { it.write(body.toByteArray()) }
|
||||
val code = connection.responseCode
|
||||
connection.disconnect()
|
||||
code in 200..499
|
||||
} catch (_: Exception) {
|
||||
false
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,314 @@
|
||||
package com.archipelago.app.ui.screens
|
||||
|
||||
import android.annotation.SuppressLint
|
||||
import android.graphics.Bitmap
|
||||
import android.view.ViewGroup
|
||||
import android.webkit.CookieManager
|
||||
import android.webkit.WebChromeClient
|
||||
import android.webkit.WebResourceError
|
||||
import android.webkit.WebResourceRequest
|
||||
import android.webkit.WebSettings
|
||||
import android.webkit.WebView
|
||||
import android.webkit.WebViewClient
|
||||
import androidx.activity.compose.BackHandler
|
||||
import androidx.compose.animation.AnimatedVisibility
|
||||
import androidx.compose.animation.fadeIn
|
||||
import androidx.compose.animation.fadeOut
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.Spacer
|
||||
import androidx.compose.foundation.layout.WindowInsets
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.height
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.layout.safeDrawing
|
||||
import androidx.compose.foundation.layout.size
|
||||
import androidx.compose.foundation.layout.windowInsetsPadding
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.filled.CloudOff
|
||||
import androidx.compose.material3.Icon
|
||||
import androidx.compose.material3.LinearProgressIndicator
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableIntStateOf
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import androidx.compose.ui.text.style.TextAlign
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.compose.ui.viewinterop.AndroidView
|
||||
import com.archipelago.app.R
|
||||
import com.archipelago.app.ui.theme.BitcoinOrange
|
||||
import com.archipelago.app.ui.theme.SurfaceBlack
|
||||
import com.archipelago.app.ui.theme.TextMuted
|
||||
import com.archipelago.app.ui.theme.TextPrimary
|
||||
|
||||
@SuppressLint("SetJavaScriptEnabled")
|
||||
@Composable
|
||||
fun WebViewScreen(
|
||||
serverUrl: String,
|
||||
onDisconnect: () -> Unit,
|
||||
onRemoteInput: () -> Unit = {},
|
||||
) {
|
||||
var isLoading by remember { mutableStateOf(true) }
|
||||
var loadProgress by remember { mutableIntStateOf(0) }
|
||||
var hasError by remember { mutableStateOf(false) }
|
||||
var webView by remember { mutableStateOf<WebView?>(null) }
|
||||
|
||||
BackHandler(enabled = webView?.canGoBack() == true) {
|
||||
webView?.goBack()
|
||||
}
|
||||
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.fillMaxSize()
|
||||
.background(SurfaceBlack),
|
||||
) {
|
||||
if (hasError) {
|
||||
Column(
|
||||
modifier = Modifier
|
||||
.fillMaxSize()
|
||||
.windowInsetsPadding(WindowInsets.safeDrawing)
|
||||
.padding(32.dp),
|
||||
horizontalAlignment = Alignment.CenterHorizontally,
|
||||
verticalArrangement = Arrangement.Center,
|
||||
) {
|
||||
Icon(
|
||||
imageVector = Icons.Default.CloudOff,
|
||||
contentDescription = null,
|
||||
modifier = Modifier.size(64.dp),
|
||||
tint = TextMuted,
|
||||
)
|
||||
|
||||
Spacer(modifier = Modifier.height(24.dp))
|
||||
|
||||
Text(
|
||||
text = stringResource(R.string.server_unreachable),
|
||||
style = MaterialTheme.typography.headlineMedium,
|
||||
color = TextPrimary,
|
||||
textAlign = TextAlign.Center,
|
||||
)
|
||||
|
||||
Spacer(modifier = Modifier.height(12.dp))
|
||||
|
||||
Text(
|
||||
text = stringResource(R.string.connection_failed),
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
color = TextMuted,
|
||||
textAlign = TextAlign.Center,
|
||||
)
|
||||
|
||||
Spacer(modifier = Modifier.height(32.dp))
|
||||
|
||||
GlassButton(
|
||||
text = stringResource(R.string.retry),
|
||||
onClick = {
|
||||
hasError = false
|
||||
isLoading = true
|
||||
webView?.loadUrl(serverUrl)
|
||||
},
|
||||
modifier = Modifier.fillMaxWidth().height(56.dp),
|
||||
)
|
||||
|
||||
Spacer(modifier = Modifier.height(12.dp))
|
||||
|
||||
GlassButton(
|
||||
text = stringResource(R.string.disconnect),
|
||||
onClick = onDisconnect,
|
||||
modifier = Modifier.fillMaxWidth().height(48.dp),
|
||||
)
|
||||
}
|
||||
} else {
|
||||
// Edge-to-edge WebView — background bleeds behind status bar.
|
||||
// Safe area values injected as CSS env() polyfill on each page load.
|
||||
AndroidView(
|
||||
modifier = Modifier.fillMaxSize(),
|
||||
factory = { context ->
|
||||
WebView(context).apply {
|
||||
layoutParams = ViewGroup.LayoutParams(
|
||||
ViewGroup.LayoutParams.MATCH_PARENT,
|
||||
ViewGroup.LayoutParams.MATCH_PARENT,
|
||||
)
|
||||
|
||||
isVerticalScrollBarEnabled = false
|
||||
isHorizontalScrollBarEnabled = false
|
||||
|
||||
val cookieManager = CookieManager.getInstance()
|
||||
cookieManager.setAcceptCookie(true)
|
||||
cookieManager.setAcceptThirdPartyCookies(this, true)
|
||||
|
||||
settings.apply {
|
||||
javaScriptEnabled = true
|
||||
domStorageEnabled = true
|
||||
databaseEnabled = true
|
||||
mediaPlaybackRequiresUserGesture = false
|
||||
mixedContentMode = WebSettings.MIXED_CONTENT_COMPATIBILITY_MODE
|
||||
useWideViewPort = true
|
||||
loadWithOverviewMode = true
|
||||
setSupportZoom(false)
|
||||
builtInZoomControls = false
|
||||
cacheMode = WebSettings.LOAD_DEFAULT
|
||||
allowContentAccess = true
|
||||
allowFileAccess = false
|
||||
setSupportMultipleWindows(true) // enables onCreateWindow for window.open
|
||||
}
|
||||
|
||||
webViewClient = object : WebViewClient() {
|
||||
override fun onPageStarted(view: WebView?, url: String?, favicon: Bitmap?) {
|
||||
isLoading = true
|
||||
hasError = false
|
||||
}
|
||||
|
||||
override fun onPageFinished(view: WebView?, url: String?) {
|
||||
isLoading = false
|
||||
if (view == null) return
|
||||
|
||||
// Convert physical pixels → CSS pixels
|
||||
val density = view.resources.displayMetrics.density
|
||||
val satPx = view.rootWindowInsets
|
||||
?.getInsets(android.view.WindowInsets.Type.statusBars())
|
||||
?.top ?: 0
|
||||
val sabPx = view.rootWindowInsets
|
||||
?.getInsets(android.view.WindowInsets.Type.navigationBars())
|
||||
?.bottom ?: 0
|
||||
val sat = (satPx / density).toInt()
|
||||
val sab = (sabPx / density).toInt()
|
||||
|
||||
// Android WebView doesn't populate env(safe-area-inset-*).
|
||||
// Set CSS custom properties the web UI can use as fallback:
|
||||
// var(--safe-area-top, env(safe-area-inset-top, 0px))
|
||||
view.evaluateJavascript(
|
||||
"""
|
||||
(function() {
|
||||
var style = document.getElementById('archipelago-android-insets');
|
||||
if (!style) {
|
||||
style = document.createElement('style');
|
||||
style.id = 'archipelago-android-insets';
|
||||
document.head.appendChild(style);
|
||||
}
|
||||
style.textContent = ':root { --safe-area-top: ${sat}px; --safe-area-bottom: ${sab}px; }';
|
||||
})();
|
||||
""".trimIndent(),
|
||||
null,
|
||||
)
|
||||
}
|
||||
|
||||
override fun onReceivedError(
|
||||
view: WebView?,
|
||||
request: WebResourceRequest?,
|
||||
error: WebResourceError?,
|
||||
) {
|
||||
if (request?.isForMainFrame == true) {
|
||||
hasError = true
|
||||
isLoading = false
|
||||
}
|
||||
}
|
||||
|
||||
override fun shouldOverrideUrlLoading(
|
||||
view: WebView?,
|
||||
request: WebResourceRequest?,
|
||||
): Boolean {
|
||||
val url = request?.url?.toString() ?: return false
|
||||
// Keep navigation within the Archipelago server
|
||||
if (url.startsWith(serverUrl)) return false
|
||||
// Open external URLs in the system browser
|
||||
try {
|
||||
val intent = android.content.Intent(
|
||||
android.content.Intent.ACTION_VIEW,
|
||||
android.net.Uri.parse(url),
|
||||
)
|
||||
context.startActivity(intent)
|
||||
} catch (_: Exception) {}
|
||||
return true
|
||||
}
|
||||
}
|
||||
|
||||
webChromeClient = object : WebChromeClient() {
|
||||
override fun onProgressChanged(view: WebView?, newProgress: Int) {
|
||||
loadProgress = newProgress
|
||||
}
|
||||
|
||||
// Handle window.open() — open in system browser
|
||||
override fun onCreateWindow(
|
||||
view: WebView?,
|
||||
isDialog: Boolean,
|
||||
isUserGesture: Boolean,
|
||||
resultMsg: android.os.Message?,
|
||||
): Boolean {
|
||||
// Extract the URL from the hit test
|
||||
val data = view?.hitTestResult?.extra
|
||||
if (data != null) {
|
||||
try {
|
||||
val intent = android.content.Intent(
|
||||
android.content.Intent.ACTION_VIEW,
|
||||
android.net.Uri.parse(data),
|
||||
)
|
||||
context.startActivity(intent)
|
||||
} catch (_: Exception) {}
|
||||
}
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
// Two-finger hold (500ms) → navigate to remote input
|
||||
var twoFingerStart = 0L
|
||||
var twoFingerFired = false
|
||||
setOnTouchListener { _, event ->
|
||||
val pointerCount = event.pointerCount
|
||||
when (event.actionMasked) {
|
||||
android.view.MotionEvent.ACTION_POINTER_DOWN -> {
|
||||
if (pointerCount >= 2) {
|
||||
twoFingerStart = System.currentTimeMillis()
|
||||
twoFingerFired = false
|
||||
}
|
||||
}
|
||||
android.view.MotionEvent.ACTION_MOVE -> {
|
||||
if (pointerCount >= 2 && !twoFingerFired && twoFingerStart > 0) {
|
||||
if (System.currentTimeMillis() - twoFingerStart > 500) {
|
||||
twoFingerFired = true
|
||||
onRemoteInput()
|
||||
}
|
||||
}
|
||||
}
|
||||
android.view.MotionEvent.ACTION_UP,
|
||||
android.view.MotionEvent.ACTION_POINTER_UP,
|
||||
android.view.MotionEvent.ACTION_CANCEL -> {
|
||||
if (event.pointerCount <= 2) {
|
||||
twoFingerStart = 0L
|
||||
}
|
||||
}
|
||||
}
|
||||
false // don't consume — let WebView handle normally
|
||||
}
|
||||
|
||||
webView = this
|
||||
loadUrl(serverUrl)
|
||||
}
|
||||
},
|
||||
)
|
||||
|
||||
// Loading bar at top edge
|
||||
AnimatedVisibility(
|
||||
visible = isLoading,
|
||||
enter = fadeIn(),
|
||||
exit = fadeOut(),
|
||||
) {
|
||||
LinearProgressIndicator(
|
||||
progress = { loadProgress / 100f },
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
color = BitcoinOrange,
|
||||
trackColor = SurfaceBlack,
|
||||
)
|
||||
}
|
||||
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,24 @@
|
||||
package com.archipelago.app.ui.theme
|
||||
|
||||
import androidx.compose.ui.graphics.Color
|
||||
|
||||
// Archipelago brand palette — Bitcoin orange on dark
|
||||
val BitcoinOrange = Color(0xFFF7931A)
|
||||
val BitcoinOrangeLight = Color(0xFFFFB74D)
|
||||
val BitcoinOrangeDark = Color(0xFFE07C00)
|
||||
|
||||
val SurfaceBlack = Color(0xFF000000)
|
||||
val SurfaceDark = Color(0xFF0A0A0A)
|
||||
val SurfaceCard = Color(0xFF1A1A1A)
|
||||
val SurfaceCardHover = Color(0xFF222222)
|
||||
val SurfaceElevated = Color(0xFF2A2A2A)
|
||||
|
||||
val TextPrimary = Color(0xFFF5F5F5)
|
||||
val TextSecondary = Color(0xFFB0B0B0)
|
||||
val TextMuted = Color(0xFF666666)
|
||||
|
||||
val BorderSubtle = Color(0xFF2A2A2A)
|
||||
val BorderDefault = Color(0xFF3A3A3A)
|
||||
|
||||
val ErrorRed = Color(0xFFEF4444)
|
||||
val SuccessGreen = Color(0xFF22C55E)
|
||||
@@ -0,0 +1,44 @@
|
||||
package com.archipelago.app.ui.theme
|
||||
|
||||
import androidx.compose.ui.graphics.Color
|
||||
|
||||
/** NES/8BitDo controller palettes */
|
||||
object NES {
|
||||
// ── Classic (light body, red buttons) ──────────────
|
||||
val ClassicBody = Color(0xFFD4D0C8) // warm light gray plastic
|
||||
val ClassicFace = Color(0xFF1C1C1C) // dark face plate
|
||||
val ClassicAccent = Color(0xFF8A8A8A) // mid gray trim
|
||||
val ClassicRidge = Color(0xFFBBB8B0) // grip lines
|
||||
val ClassicButtonRed = Color(0xFFC1121C) // A/B red
|
||||
val ClassicButtonRedPress = Color(0xFF8A0D14)
|
||||
val ClassicButtonGray = Color(0xFF5A5A5A) // turbo buttons
|
||||
val ClassicButtonGrayPress = Color(0xFF3A3A3A)
|
||||
val ClassicDPad = Color(0xFF1A1A1A)
|
||||
val ClassicDPadPress = Color(0xFF2A2A2A)
|
||||
val ClassicLabel = Color(0xFFC1121C) // red text labels
|
||||
val ClassicLabelMuted = Color(0xFF6A6A6A)
|
||||
val ClassicSelect = Color(0xFF2A2A2A) // START/SELECT
|
||||
|
||||
// ── Transparent Dark ───────────────────────────────
|
||||
val DarkBody = Color(0xFF2A2A2E) // smoky translucent dark
|
||||
val DarkFace = Color(0xFF151518) // darker face
|
||||
val DarkAccent = Color(0xFF3A3A3E) // trim
|
||||
val DarkRidge = Color(0xFF222226) // grip lines
|
||||
val DarkButtonMain = Color(0xFF3A3A3E) // all buttons dark
|
||||
val DarkButtonMainPress = Color(0xFF222226)
|
||||
val DarkDPad = Color(0xFF0E0E10)
|
||||
val DarkDPadPress = Color(0xFF1A1A1E)
|
||||
val DarkLabel = Color(0xFF5A5A60) // muted labels
|
||||
val DarkLabelMuted = Color(0xFF3A3A3E)
|
||||
val DarkSelect = Color(0xFF1A1A1E)
|
||||
|
||||
// ── Menu UI (NES-style) ────────────────────────────
|
||||
val MenuBg = Color(0xFF000000)
|
||||
val MenuPanel = Color(0xFF0B1B4A) // dark navy
|
||||
val MenuBorder = Color(0xFFFFFFFF)
|
||||
val MenuText = Color(0xFFFFFFFF)
|
||||
val MenuSelected = Color(0xFFC1121C)
|
||||
val MenuMuted = Color(0xFF7A7A7A)
|
||||
}
|
||||
|
||||
enum class ControllerStyle { CLASSIC, DARK }
|
||||
106
Android/app/src/main/java/com/archipelago/app/ui/theme/Neo.kt
Normal file
106
Android/app/src/main/java/com/archipelago/app/ui/theme/Neo.kt
Normal file
@@ -0,0 +1,106 @@
|
||||
package com.archipelago.app.ui.theme
|
||||
|
||||
import androidx.compose.foundation.isSystemInDarkTheme
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.ReadOnlyComposable
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.draw.drawBehind
|
||||
import androidx.compose.ui.geometry.CornerRadius
|
||||
import androidx.compose.ui.geometry.RoundRect
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.graphics.Paint
|
||||
import androidx.compose.ui.graphics.Path
|
||||
import androidx.compose.ui.graphics.drawscope.drawIntoCanvas
|
||||
import androidx.compose.ui.graphics.toArgb
|
||||
import androidx.compose.ui.unit.Dp
|
||||
import androidx.compose.ui.unit.dp
|
||||
|
||||
object Neo {
|
||||
// ── Dark ───────────────────────────────────────────
|
||||
val DarkSurface = Color(0xFF0A0A0A)
|
||||
val DarkSurfaceRaised = Color(0xFF0F0F11)
|
||||
val DarkShadowLight = Color(0xFF151517)
|
||||
val DarkShadowDark = Color(0xFF000000)
|
||||
val DarkBorder = Color(0x0AFFFFFF)
|
||||
|
||||
// ── Light ──────────────────────────────────────────
|
||||
val LightSurface = Color(0xFFE0E0E4)
|
||||
val LightSurfaceRaised = Color(0xFFE6E6EA)
|
||||
val LightShadowLight = Color(0xFFF2F2F6)
|
||||
val LightShadowDark = Color(0xFFB4B4BA)
|
||||
val LightBorder = Color(0x0A000000)
|
||||
|
||||
val LightTextPrimary = Color(0xFF141414)
|
||||
val LightTextSecondary = Color(0xFF5A5A5A)
|
||||
val LightTextMuted = Color(0xFF9A9A9A)
|
||||
|
||||
// ── Accessors ──────────────────────────────────────
|
||||
|
||||
@Composable @ReadOnlyComposable
|
||||
fun surface() = if (isSystemInDarkTheme()) DarkSurface else LightSurface
|
||||
|
||||
@Composable @ReadOnlyComposable
|
||||
fun surfaceRaised() = if (isSystemInDarkTheme()) DarkSurfaceRaised else LightSurfaceRaised
|
||||
|
||||
@Composable @ReadOnlyComposable
|
||||
fun shadowLight() = if (isSystemInDarkTheme()) DarkShadowLight else LightShadowLight
|
||||
|
||||
@Composable @ReadOnlyComposable
|
||||
fun shadowDark() = if (isSystemInDarkTheme()) DarkShadowDark else LightShadowDark
|
||||
|
||||
@Composable @ReadOnlyComposable
|
||||
fun border() = if (isSystemInDarkTheme()) DarkBorder else LightBorder
|
||||
|
||||
@Composable @ReadOnlyComposable
|
||||
fun textPrimary() = if (isSystemInDarkTheme()) Color(0xFFD0D0D0) else LightTextPrimary
|
||||
|
||||
@Composable @ReadOnlyComposable
|
||||
fun textSecondary() = if (isSystemInDarkTheme()) Color(0xFF666666) else LightTextSecondary
|
||||
|
||||
@Composable @ReadOnlyComposable
|
||||
fun textMuted() = if (isSystemInDarkTheme()) Color(0xFF333333) else LightTextMuted
|
||||
}
|
||||
|
||||
/** Subtle neomorphic raised shadow */
|
||||
fun Modifier.neoRaised(
|
||||
lightShadow: Color,
|
||||
darkShadow: Color,
|
||||
radius: Dp = 14.dp,
|
||||
shadowOffset: Dp = 2.dp,
|
||||
shadowBlur: Dp = 4.dp,
|
||||
) = this.drawBehind {
|
||||
val r = radius.toPx()
|
||||
val off = shadowOffset.toPx()
|
||||
val blur = shadowBlur.toPx()
|
||||
drawIntoCanvas { canvas ->
|
||||
val path = Path().apply { addRoundRect(RoundRect(0f, 0f, size.width, size.height, CornerRadius(r))) }
|
||||
canvas.drawPath(path, Paint().also {
|
||||
it.asFrameworkPaint().apply { isAntiAlias = true; color = android.graphics.Color.TRANSPARENT; setShadowLayer(blur, off, off, darkShadow.toArgb()) }
|
||||
})
|
||||
canvas.drawPath(path, Paint().also {
|
||||
it.asFrameworkPaint().apply { isAntiAlias = true; color = android.graphics.Color.TRANSPARENT; setShadowLayer(blur, -off, -off, lightShadow.toArgb()) }
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
/** Subtle neomorphic inset shadow */
|
||||
fun Modifier.neoInset(
|
||||
lightShadow: Color,
|
||||
darkShadow: Color,
|
||||
radius: Dp = 14.dp,
|
||||
shadowOffset: Dp = 1.dp,
|
||||
shadowBlur: Dp = 3.dp,
|
||||
) = this.drawBehind {
|
||||
val r = radius.toPx()
|
||||
val off = shadowOffset.toPx()
|
||||
val blur = shadowBlur.toPx()
|
||||
drawIntoCanvas { canvas ->
|
||||
val path = Path().apply { addRoundRect(RoundRect(0f, 0f, size.width, size.height, CornerRadius(r))) }
|
||||
canvas.drawPath(path, Paint().also {
|
||||
it.asFrameworkPaint().apply { isAntiAlias = true; color = android.graphics.Color.TRANSPARENT; setShadowLayer(blur, -off, -off, darkShadow.toArgb()) }
|
||||
})
|
||||
canvas.drawPath(path, Paint().also {
|
||||
it.asFrameworkPaint().apply { isAntiAlias = true; color = android.graphics.Color.TRANSPARENT; setShadowLayer(blur, off, off, lightShadow.toArgb()) }
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,56 @@
|
||||
package com.archipelago.app.ui.theme
|
||||
|
||||
import androidx.compose.foundation.isSystemInDarkTheme
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.darkColorScheme
|
||||
import androidx.compose.material3.lightColorScheme
|
||||
import androidx.compose.runtime.Composable
|
||||
|
||||
private val DarkColorScheme = darkColorScheme(
|
||||
primary = BitcoinOrange,
|
||||
onPrimary = SurfaceBlack,
|
||||
primaryContainer = BitcoinOrangeDark,
|
||||
onPrimaryContainer = TextPrimary,
|
||||
secondary = BitcoinOrangeLight,
|
||||
onSecondary = SurfaceBlack,
|
||||
background = SurfaceBlack,
|
||||
onBackground = TextPrimary,
|
||||
surface = SurfaceDark,
|
||||
onSurface = TextPrimary,
|
||||
surfaceVariant = SurfaceCard,
|
||||
onSurfaceVariant = TextSecondary,
|
||||
outline = BorderDefault,
|
||||
outlineVariant = BorderSubtle,
|
||||
error = ErrorRed,
|
||||
onError = TextPrimary,
|
||||
)
|
||||
|
||||
private val LightColorScheme = lightColorScheme(
|
||||
primary = BitcoinOrange,
|
||||
onPrimary = SurfaceBlack,
|
||||
primaryContainer = BitcoinOrangeLight,
|
||||
onPrimaryContainer = SurfaceBlack,
|
||||
secondary = BitcoinOrangeDark,
|
||||
onSecondary = TextPrimary,
|
||||
background = Neo.LightSurface,
|
||||
onBackground = Neo.LightTextPrimary,
|
||||
surface = Neo.LightSurfaceRaised,
|
||||
onSurface = Neo.LightTextPrimary,
|
||||
surfaceVariant = Neo.LightSurface,
|
||||
onSurfaceVariant = Neo.LightTextSecondary,
|
||||
outline = Neo.LightBorder,
|
||||
outlineVariant = Neo.LightBorder,
|
||||
error = ErrorRed,
|
||||
onError = TextPrimary,
|
||||
)
|
||||
|
||||
@Composable
|
||||
fun ArchipelagoTheme(content: @Composable () -> Unit) {
|
||||
val colorScheme = if (isSystemInDarkTheme()) DarkColorScheme else LightColorScheme
|
||||
|
||||
MaterialTheme(
|
||||
colorScheme = colorScheme,
|
||||
typography = Typography,
|
||||
content = content,
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,60 @@
|
||||
package com.archipelago.app.ui.theme
|
||||
|
||||
import androidx.compose.material3.Typography
|
||||
import androidx.compose.ui.text.TextStyle
|
||||
import androidx.compose.ui.text.font.FontWeight
|
||||
import androidx.compose.ui.unit.sp
|
||||
|
||||
val Typography = Typography(
|
||||
displayLarge = TextStyle(
|
||||
fontWeight = FontWeight.Bold,
|
||||
fontSize = 32.sp,
|
||||
lineHeight = 40.sp,
|
||||
letterSpacing = (-0.5).sp,
|
||||
),
|
||||
headlineLarge = TextStyle(
|
||||
fontWeight = FontWeight.SemiBold,
|
||||
fontSize = 28.sp,
|
||||
lineHeight = 36.sp,
|
||||
),
|
||||
headlineMedium = TextStyle(
|
||||
fontWeight = FontWeight.SemiBold,
|
||||
fontSize = 24.sp,
|
||||
lineHeight = 32.sp,
|
||||
),
|
||||
titleLarge = TextStyle(
|
||||
fontWeight = FontWeight.Medium,
|
||||
fontSize = 20.sp,
|
||||
lineHeight = 28.sp,
|
||||
),
|
||||
titleMedium = TextStyle(
|
||||
fontWeight = FontWeight.Medium,
|
||||
fontSize = 16.sp,
|
||||
lineHeight = 24.sp,
|
||||
letterSpacing = 0.15.sp,
|
||||
),
|
||||
bodyLarge = TextStyle(
|
||||
fontWeight = FontWeight.Normal,
|
||||
fontSize = 16.sp,
|
||||
lineHeight = 24.sp,
|
||||
letterSpacing = 0.5.sp,
|
||||
),
|
||||
bodyMedium = TextStyle(
|
||||
fontWeight = FontWeight.Normal,
|
||||
fontSize = 14.sp,
|
||||
lineHeight = 20.sp,
|
||||
letterSpacing = 0.25.sp,
|
||||
),
|
||||
labelLarge = TextStyle(
|
||||
fontWeight = FontWeight.SemiBold,
|
||||
fontSize = 14.sp,
|
||||
lineHeight = 20.sp,
|
||||
letterSpacing = 0.1.sp,
|
||||
),
|
||||
labelMedium = TextStyle(
|
||||
fontWeight = FontWeight.Medium,
|
||||
fontSize = 12.sp,
|
||||
lineHeight = 16.sp,
|
||||
letterSpacing = 0.5.sp,
|
||||
),
|
||||
)
|
||||
10
Android/app/src/main/res/drawable/ic_launcher_background.xml
Normal file
10
Android/app/src/main/res/drawable/ic_launcher_background.xml
Normal file
@@ -0,0 +1,10 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<vector xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
android:width="108dp"
|
||||
android:height="108dp"
|
||||
android:viewportWidth="108"
|
||||
android:viewportHeight="108">
|
||||
<path
|
||||
android:fillColor="#030202"
|
||||
android:pathData="M0,0h108v108H0z" />
|
||||
</vector>
|
||||
45
Android/app/src/main/res/drawable/ic_launcher_foreground.xml
Normal file
45
Android/app/src/main/res/drawable/ic_launcher_foreground.xml
Normal file
@@ -0,0 +1,45 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<!-- Archipelago pixel-art "A" logo — scaled 90% and centered -->
|
||||
<vector xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
android:width="108dp"
|
||||
android:height="108dp"
|
||||
android:viewportWidth="1024"
|
||||
android:viewportHeight="1024">
|
||||
|
||||
<group
|
||||
android:pivotX="512"
|
||||
android:pivotY="512"
|
||||
android:scaleX="0.55"
|
||||
android:scaleY="0.55">
|
||||
|
||||
<!-- Row 1: 4 blocks -->
|
||||
<path android:fillColor="#FFFFFF" android:pathData="M357.614,318h71.007v70.936h-71.007z" />
|
||||
<path android:fillColor="#FFFFFF" android:pathData="M436.152,318h72.082v70.936h-72.082z" />
|
||||
<path android:fillColor="#FFFFFF" android:pathData="M515.766,318h72.082v70.936h-72.082z" />
|
||||
<path android:fillColor="#FFFFFF" android:pathData="M595.379,318h71.007v70.936h-71.007z" />
|
||||
|
||||
<!-- Row 2: 2 blocks (right side) -->
|
||||
<path android:fillColor="#FFFFFF" android:pathData="M595.379,396.46h71.007v72.011h-71.007z" />
|
||||
<path android:fillColor="#FFFFFF" android:pathData="M673.917,396.46h72.083v72.011h-72.083z" />
|
||||
|
||||
<!-- Row 3: 6 blocks (full width) -->
|
||||
<path android:fillColor="#FFFFFF" android:pathData="M278,475.994h72.083v72.012h-72.083z" />
|
||||
<path android:fillColor="#FFFFFF" android:pathData="M357.614,475.994h71.007v72.012h-71.007z" />
|
||||
<path android:fillColor="#FFFFFF" android:pathData="M436.152,475.994h72.082v72.012h-72.082z" />
|
||||
<path android:fillColor="#FFFFFF" android:pathData="M515.766,475.994h72.082v72.012h-72.082z" />
|
||||
<path android:fillColor="#FFFFFF" android:pathData="M595.379,475.994h71.007v72.012h-71.007z" />
|
||||
<path android:fillColor="#FFFFFF" android:pathData="M673.917,475.994h72.083v72.012h-72.083z" />
|
||||
|
||||
<!-- Row 4: 4 blocks (sides only — the "A" gap) -->
|
||||
<path android:fillColor="#FFFFFF" android:pathData="M278,555.529h72.083v70.936h-72.083z" />
|
||||
<path android:fillColor="#FFFFFF" android:pathData="M357.614,555.529h71.007v70.936h-71.007z" />
|
||||
<path android:fillColor="#FFFFFF" android:pathData="M595.379,555.529h71.007v70.936h-71.007z" />
|
||||
<path android:fillColor="#FFFFFF" android:pathData="M673.917,555.529h72.083v70.936h-72.083z" />
|
||||
|
||||
<!-- Row 5: 4 blocks (bottom) -->
|
||||
<path android:fillColor="#FFFFFF" android:pathData="M357.614,633.989h71.007v72.011h-71.007z" />
|
||||
<path android:fillColor="#FFFFFF" android:pathData="M436.152,633.989h72.082v72.011h-72.082z" />
|
||||
<path android:fillColor="#FFFFFF" android:pathData="M515.766,633.989h72.082v72.011h-72.082z" />
|
||||
<path android:fillColor="#FFFFFF" android:pathData="M595.379,633.989h71.007v72.011h-71.007z" />
|
||||
</group>
|
||||
</vector>
|
||||
51
Android/app/src/main/res/drawable/ic_logo_wide.xml
Normal file
51
Android/app/src/main/res/drawable/ic_logo_wide.xml
Normal file
@@ -0,0 +1,51 @@
|
||||
<vector xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
android:width="240dp"
|
||||
android:height="30dp"
|
||||
android:viewportWidth="2079"
|
||||
android:viewportHeight="263">
|
||||
|
||||
<!-- A -->
|
||||
<path android:fillColor="#FFFFFF"
|
||||
android:pathData="M29.6,85.6V59.2H56V85.6H29.6ZM58.8,85.6V59.2H85.6V85.6H58.8ZM88.4,85.6V59.2H115.2V85.6H88.4ZM118,85.6V59.2H144.4V85.6H118ZM118,115.2V88.4H144.4V115.2H118ZM147.2,115.2V88.4H174V115.2H147.2ZM0,144.8V118H26.8V144.8H0ZM29.6,144.8V118H56V144.8H29.6ZM58.8,144.8V118H85.6V144.8H58.8ZM88.4,144.8V118H115.2V144.8H88.4ZM118,144.8V118H144.4V144.8H118ZM147.2,144.8V118H174V144.8H147.2ZM0,174V147.6H26.8V174H0ZM29.6,174V147.6H56V174H29.6ZM118,174V147.6H144.4V174H118ZM147.2,174V147.6H174V174H147.2ZM29.6,203.6V176.8H56V203.6H29.6ZM58.8,203.6V176.8H85.6V203.6H58.8ZM88.4,203.6V176.8H115.2V203.6H88.4ZM118,203.6V176.8H144.4V203.6H118Z" />
|
||||
|
||||
<!-- R -->
|
||||
<path android:fillColor="#FFFFFF"
|
||||
android:pathData="M243.663,85.6V59.2H270.062V85.6H243.663ZM272.863,85.6V59.2H299.663V85.6H272.863ZM302.463,85.6V59.2H329.263V85.6H302.463ZM332.062,85.6V59.2H358.462V85.6H332.062ZM332.062,115.2V88.4H358.462V115.2H332.062ZM361.263,115.2V88.4H388.062V115.2H361.263ZM214.062,115.2V88.4H240.863V115.2H214.062ZM243.663,115.2V88.4H270.062V115.2H243.663ZM214.062,144.8V118H240.863V144.8H214.062ZM243.663,144.8V118H270.062V144.8H243.663ZM214.062,174V147.6H240.863V174H214.062ZM243.663,174V147.6H270.062V174H243.663ZM243.663,203.6V176.8H270.062V203.6H243.663Z" />
|
||||
|
||||
<!-- C -->
|
||||
<path android:fillColor="#FFFFFF"
|
||||
android:pathData="M457.725,85.6V59.2H484.125V85.6H457.725ZM486.925,85.6V59.2H513.725V85.6H486.925ZM516.525,85.6V59.2H543.325V85.6H516.525ZM546.125,85.6V59.2H572.525V85.6H546.125ZM428.125,115.2V88.4H454.925V115.2H428.125ZM457.725,115.2V88.4H484.125V115.2H457.725ZM546.125,115.2V88.4H572.525V115.2H546.125ZM575.325,115.2V88.4H602.125V115.2H575.325ZM428.125,144.8V118H454.925V144.8H428.125ZM457.725,144.8V118H484.125V144.8H457.725ZM428.125,174V147.6H454.925V174H428.125ZM457.725,174V147.6H484.125V174H457.725ZM546.125,174V147.6H572.525V174H546.125ZM575.325,174V147.6H602.125V174H575.325ZM457.725,203.6V176.8H484.125V203.6H457.725ZM486.925,203.6V176.8H513.725V203.6H486.925ZM516.525,203.6V176.8H543.325V203.6H516.525ZM546.125,203.6V176.8H572.525V203.6H546.125Z" />
|
||||
|
||||
<!-- H -->
|
||||
<path android:fillColor="#FFFFFF"
|
||||
android:pathData="M671.787,26.8V0H698.188V26.8H671.787ZM642.188,56.4V29.6H668.987V56.4H642.188ZM671.787,56.4V29.6H698.188V56.4H671.787ZM642.188,85.6V59.2H668.987V85.6H642.188ZM671.787,85.6V59.2H698.188V85.6H671.787ZM700.987,85.6V59.2H727.787V85.6H700.987ZM730.588,85.6V59.2H757.388V85.6H730.588ZM760.188,85.6V59.2H786.588V85.6H760.188ZM642.188,115.2V88.4H668.987V115.2H642.188ZM671.787,115.2V88.4H698.188V115.2H671.787ZM760.188,115.2V88.4H786.588V115.2H760.188ZM789.388,115.2V88.4H816.188V115.2H789.388ZM642.188,144.8V118H668.987V144.8H642.188ZM671.787,144.8V118H698.188V144.8H671.787ZM760.188,144.8V118H786.588V144.8H760.188ZM789.388,144.8V118H816.188V144.8H789.388ZM642.188,174V147.6H668.987V174H642.188ZM671.787,174V147.6H698.188V174H671.787ZM760.188,174V147.6H786.588V174H760.188ZM789.388,174V147.6H816.188V174H789.388ZM671.787,203.6V176.8H698.188V203.6H671.787ZM760.188,203.6V176.8H786.588V203.6H760.188Z" />
|
||||
|
||||
<!-- I -->
|
||||
<path android:fillColor="#FFFFFF"
|
||||
android:pathData="M856.25,26.8V0H883.05V26.8H856.25ZM885.85,26.8V0H912.25V26.8H885.85ZM856.25,85.6V59.2H883.05V85.6H856.25ZM856.25,115.2V88.4H883.05V115.2H856.25ZM885.85,115.2V88.4H912.25V115.2H885.85ZM856.25,144.8V118H883.05V144.8H856.25ZM885.85,144.8V118H912.25V144.8H885.85ZM856.25,174V147.6H883.05V174H856.25ZM885.85,174V147.6H912.25V174H885.85ZM885.85,203.6V176.8H912.25V203.6H885.85Z" />
|
||||
|
||||
<!-- P -->
|
||||
<path android:fillColor="#FFFFFF"
|
||||
android:pathData="M981.944,85.6V59.2H1008.34V85.6H981.944ZM1011.14,85.6V59.2H1037.94V85.6H1011.14ZM1040.74,85.6V59.2H1067.54V85.6H1040.74ZM1070.34,85.6V59.2H1096.74V85.6H1070.34ZM952.344,115.2V88.4H979.144V115.2H952.344ZM981.944,115.2V88.4H1008.34V115.2H981.944ZM1070.34,115.2V88.4H1096.74V115.2H1070.34ZM1099.54,115.2V88.4H1126.34V115.2H1099.54ZM952.344,144.8V118H979.144V144.8H952.344ZM981.944,144.8V118H1008.34V144.8H981.944ZM1070.34,144.8V118H1096.74V144.8H1070.34ZM1099.54,144.8V118H1126.34V144.8H1099.54ZM952.344,174V147.6H979.144V174H952.344ZM981.944,174V147.6H1008.34V174H981.944ZM1070.34,174V147.6H1096.74V174H1070.34ZM1099.54,174V147.6H1126.34V174H1099.54ZM952.344,203.6V176.8H979.144V203.6H952.344ZM981.944,203.6V176.8H1008.34V203.6H981.944ZM1011.14,203.6V176.8H1037.94V203.6H1011.14ZM1040.74,203.6V176.8H1067.54V203.6H1040.74ZM1070.34,203.6V176.8H1096.74V203.6H1070.34ZM952.344,233.2V206.4H979.144V233.2H952.344ZM981.944,233.2V206.4H1008.34V233.2H981.944ZM981.944,262.4V236H1008.34V262.4H981.944Z" />
|
||||
|
||||
<!-- E -->
|
||||
<path android:fillColor="#FFFFFF"
|
||||
android:pathData="M1196.01,85.6V59.2H1222.41V85.6H1196.01ZM1225.21,85.6V59.2H1252.01V85.6H1225.21ZM1254.81,85.6V59.2H1281.61V85.6H1254.81ZM1284.41,85.6V59.2H1310.81V85.6H1284.41ZM1166.41,115.2V88.4H1193.21V115.2H1166.41ZM1196.01,115.2V88.4H1222.41V115.2H1196.01ZM1284.41,115.2V88.4H1310.81V115.2H1284.41ZM1313.61,115.2V88.4H1340.41V115.2H1313.61ZM1166.41,144.8V118H1193.21V144.8H1166.41ZM1196.01,144.8V118H1222.41V144.8H1196.01ZM1225.21,144.8V118H1252.01V144.8H1225.21ZM1254.81,144.8V118H1281.61V144.8H1254.81ZM1284.41,144.8V118H1310.81V144.8H1284.41ZM1313.61,144.8V118H1340.41V144.8H1313.61ZM1166.41,174V147.6H1193.21V174H1166.41ZM1196.01,174V147.6H1222.41V174H1196.01ZM1196.01,203.6V176.8H1222.41V203.6H1196.01ZM1225.21,203.6V176.8H1252.01V203.6H1225.21ZM1254.81,203.6V176.8H1281.61V203.6H1254.81ZM1284.41,203.6V176.8H1310.81V203.6H1284.41Z" />
|
||||
|
||||
<!-- L -->
|
||||
<path android:fillColor="#FFFFFF"
|
||||
android:pathData="M1380.47,26.8V0H1407.27V26.8H1380.47ZM1380.47,56.4V29.6H1407.27V56.4H1380.47ZM1410.07,56.4V29.6H1436.47V56.4H1410.07ZM1380.47,85.6V59.2H1407.27V85.6H1380.47ZM1410.07,85.6V59.2H1436.47V85.6H1410.07ZM1380.47,115.2V88.4H1407.27V115.2H1380.47ZM1410.07,115.2V88.4H1436.47V115.2H1410.07ZM1380.47,144.8V118H1407.27V144.8H1380.47ZM1410.07,144.8V118H1436.47V144.8H1410.07ZM1380.47,174V147.6H1407.27V174H1380.47ZM1410.07,174V147.6H1436.47V174H1410.07ZM1410.07,203.6V176.8H1436.47V203.6H1410.07Z" />
|
||||
|
||||
<!-- A (second) -->
|
||||
<path android:fillColor="#FFFFFF"
|
||||
android:pathData="M1506.16,85.6V59.2H1532.56V85.6H1506.16ZM1535.36,85.6V59.2H1562.16V85.6H1535.36ZM1564.96,85.6V59.2H1591.76V85.6H1564.96ZM1594.56,85.6V59.2H1620.96V85.6H1594.56ZM1594.56,115.2V88.4H1620.96V115.2H1594.56ZM1623.76,115.2V88.4H1650.56V115.2H1623.76ZM1476.56,144.8V118H1503.36V144.8H1476.56ZM1506.16,144.8V118H1532.56V144.8H1506.16ZM1535.36,144.8V118H1562.16V144.8H1535.36ZM1564.96,144.8V118H1591.76V144.8H1564.96ZM1594.56,144.8V118H1620.96V144.8H1594.56ZM1623.76,144.8V118H1650.56V144.8H1623.76ZM1476.56,174V147.6H1503.36V174H1476.56ZM1506.16,174V147.6H1532.56V174H1506.16ZM1594.56,174V147.6H1620.96V174H1594.56ZM1623.76,174V147.6H1650.56V174H1623.76ZM1506.16,203.6V176.8H1532.56V203.6H1506.16ZM1535.36,203.6V176.8H1562.16V203.6H1535.36ZM1564.96,203.6V176.8H1591.76V203.6H1564.96ZM1594.56,203.6V176.8H1620.96V203.6H1594.56Z" />
|
||||
|
||||
<!-- G -->
|
||||
<path android:fillColor="#FFFFFF"
|
||||
android:pathData="M1720.22,85.6V59.2H1746.62V85.6H1720.22ZM1749.43,85.6V59.2H1776.22V85.6H1749.43ZM1779.03,85.6V59.2H1805.82V85.6H1779.03ZM1808.62,85.6V59.2H1835.03V85.6H1808.62ZM1690.62,115.2V88.4H1717.43V115.2H1690.62ZM1720.22,115.2V88.4H1746.62V115.2H1720.22ZM1808.62,115.2V88.4H1835.03V115.2H1808.62ZM1837.82,115.2V88.4H1864.62V115.2H1837.82ZM1690.62,144.8V118H1717.43V144.8H1690.62ZM1720.22,144.8V118H1746.62V144.8H1720.22ZM1808.62,144.8V118H1835.03V144.8H1808.62ZM1837.82,144.8V118H1864.62V144.8H1837.82ZM1690.62,174V147.6H1717.43V174H1690.62ZM1720.22,174V147.6H1746.62V174H1720.22ZM1808.62,174V147.6H1835.03V174H1808.62ZM1837.82,174V147.6H1864.62V174H1837.82ZM1720.22,203.6V176.8H1746.62V203.6H1720.22ZM1749.43,203.6V176.8H1776.22V203.6H1749.43ZM1779.03,203.6V176.8H1805.82V203.6H1779.03ZM1808.62,203.6V176.8H1835.03V203.6H1808.62ZM1837.82,203.6V176.8H1864.62V203.6H1837.82ZM1808.62,233.2V206.4H1835.03V233.2H1808.62ZM1837.82,233.2V206.4H1864.62V233.2H1837.82ZM1720.22,262.4V236H1746.62V262.4H1720.22ZM1749.43,262.4V236H1776.22V262.4H1749.43ZM1779.03,262.4V236H1805.82V262.4H1779.03ZM1808.62,262.4V236H1835.03V262.4H1808.62Z" />
|
||||
|
||||
<!-- O -->
|
||||
<path android:fillColor="#FFFFFF"
|
||||
android:pathData="M1934.29,85.6V59.2H1960.69V85.6H1934.29ZM1963.49,85.6V59.2H1990.29V85.6H1963.49ZM1993.09,85.6V59.2H2019.89V85.6H1993.09ZM2022.69,85.6V59.2H2049.09V85.6H2022.69ZM1904.69,115.2V88.4H1931.49V115.2H1904.69ZM1934.29,115.2V88.4H1960.69V115.2H1934.29ZM2022.69,115.2V88.4H2049.09V115.2H2022.69ZM2051.89,115.2V88.4H2078.69V115.2H2051.89ZM1904.69,144.8V118H1931.49V144.8H1904.69ZM1934.29,144.8V118H1960.69V144.8H1934.29ZM2022.69,144.8V118H2049.09V144.8H2022.69ZM2051.89,144.8V118H2078.69V144.8H2051.89ZM1904.69,174V147.6H1931.49V174H1904.69ZM1934.29,174V147.6H1960.69V174H1934.29ZM2022.69,174V147.6H2049.09V174H2022.69ZM2051.89,174V147.6H2078.69V174H2051.89ZM1963.49,203.6V176.8H1990.29V203.6H1963.49ZM1993.09,203.6V176.8H2019.89V203.6H1993.09ZM1934.29,203.6V176.8H1960.69V203.6H1934.29ZM2022.69,203.6V176.8H2049.09V203.6H2022.69Z" />
|
||||
|
||||
</vector>
|
||||
36
Android/app/src/main/res/drawable/ic_splash_logo.xml
Normal file
36
Android/app/src/main/res/drawable/ic_splash_logo.xml
Normal file
@@ -0,0 +1,36 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<!-- Archipelago pixel-art "A" for splash screen -->
|
||||
<vector xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
android:width="108dp"
|
||||
android:height="108dp"
|
||||
android:viewportWidth="1024"
|
||||
android:viewportHeight="1024">
|
||||
|
||||
<group
|
||||
android:pivotX="512"
|
||||
android:pivotY="512"
|
||||
android:scaleX="0.55"
|
||||
android:scaleY="0.55">
|
||||
|
||||
<path android:fillColor="#FFFFFF" android:pathData="M357.614,318h71.007v70.936h-71.007z" />
|
||||
<path android:fillColor="#FFFFFF" android:pathData="M436.152,318h72.082v70.936h-72.082z" />
|
||||
<path android:fillColor="#FFFFFF" android:pathData="M515.766,318h72.082v70.936h-72.082z" />
|
||||
<path android:fillColor="#FFFFFF" android:pathData="M595.379,318h71.007v70.936h-71.007z" />
|
||||
<path android:fillColor="#FFFFFF" android:pathData="M595.379,396.46h71.007v72.011h-71.007z" />
|
||||
<path android:fillColor="#FFFFFF" android:pathData="M673.917,396.46h72.083v72.011h-72.083z" />
|
||||
<path android:fillColor="#FFFFFF" android:pathData="M278,475.994h72.083v72.012h-72.083z" />
|
||||
<path android:fillColor="#FFFFFF" android:pathData="M357.614,475.994h71.007v72.012h-71.007z" />
|
||||
<path android:fillColor="#FFFFFF" android:pathData="M436.152,475.994h72.082v72.012h-72.082z" />
|
||||
<path android:fillColor="#FFFFFF" android:pathData="M515.766,475.994h72.082v72.012h-72.082z" />
|
||||
<path android:fillColor="#FFFFFF" android:pathData="M595.379,475.994h71.007v72.012h-71.007z" />
|
||||
<path android:fillColor="#FFFFFF" android:pathData="M673.917,475.994h72.083v72.012h-72.083z" />
|
||||
<path android:fillColor="#FFFFFF" android:pathData="M278,555.529h72.083v70.936h-72.083z" />
|
||||
<path android:fillColor="#FFFFFF" android:pathData="M357.614,555.529h71.007v70.936h-71.007z" />
|
||||
<path android:fillColor="#FFFFFF" android:pathData="M595.379,555.529h71.007v70.936h-71.007z" />
|
||||
<path android:fillColor="#FFFFFF" android:pathData="M673.917,555.529h72.083v70.936h-72.083z" />
|
||||
<path android:fillColor="#FFFFFF" android:pathData="M357.614,633.989h71.007v72.011h-71.007z" />
|
||||
<path android:fillColor="#FFFFFF" android:pathData="M436.152,633.989h72.082v72.011h-72.082z" />
|
||||
<path android:fillColor="#FFFFFF" android:pathData="M515.766,633.989h72.082v72.011h-72.082z" />
|
||||
<path android:fillColor="#FFFFFF" android:pathData="M595.379,633.989h71.007v72.011h-71.007z" />
|
||||
</group>
|
||||
</vector>
|
||||
@@ -0,0 +1,5 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<adaptive-icon xmlns:android="http://schemas.android.com/apk/res/android">
|
||||
<background android:drawable="@drawable/ic_launcher_background" />
|
||||
<foreground android:drawable="@drawable/ic_launcher_foreground" />
|
||||
</adaptive-icon>
|
||||
@@ -0,0 +1,5 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<adaptive-icon xmlns:android="http://schemas.android.com/apk/res/android">
|
||||
<background android:drawable="@drawable/ic_launcher_background" />
|
||||
<foreground android:drawable="@drawable/ic_launcher_foreground" />
|
||||
</adaptive-icon>
|
||||
9
Android/app/src/main/res/values/colors.xml
Normal file
9
Android/app/src/main/res/values/colors.xml
Normal file
@@ -0,0 +1,9 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<resources>
|
||||
<color name="black">#FF000000</color>
|
||||
<color name="white">#FFFFFFFF</color>
|
||||
<color name="bitcoin_orange">#FFF7931A</color>
|
||||
<color name="surface_dark">#FF0A0A0A</color>
|
||||
<color name="surface_card">#FF1A1A1A</color>
|
||||
<color name="splash_background">#FF000000</color>
|
||||
</resources>
|
||||
24
Android/app/src/main/res/values/strings.xml
Normal file
24
Android/app/src/main/res/values/strings.xml
Normal file
@@ -0,0 +1,24 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<resources>
|
||||
<string name="app_name">Archipelago</string>
|
||||
<string name="server_address_label">Server Address</string>
|
||||
<string name="server_address_placeholder">192.168.1.100</string>
|
||||
<string name="server_address_hint">Enter your Archipelago server IP or hostname</string>
|
||||
<string name="connect">Connect</string>
|
||||
<string name="connecting">Connecting…</string>
|
||||
<string name="connection_failed">Could not reach server. Check the address and try again.</string>
|
||||
<string name="connection_timeout">Connection timed out. Is the server running?</string>
|
||||
<string name="welcome_title">Your Sovereign\nPersonal Server</string>
|
||||
<string name="welcome_subtitle">Bitcoin node, app platform, and private cloud — all in one box you control.</string>
|
||||
<string name="get_started">Get Started</string>
|
||||
<string name="use_https">Use HTTPS</string>
|
||||
<string name="port_label">Port (optional)</string>
|
||||
<string name="saved_servers">Saved Servers</string>
|
||||
<string name="no_saved_servers">No saved servers yet</string>
|
||||
<string name="remove_server">Remove</string>
|
||||
<string name="disconnect">Disconnect</string>
|
||||
<string name="server_unreachable">Server unreachable</string>
|
||||
<string name="retry">Retry</string>
|
||||
<string name="remote_input">Remote Control</string>
|
||||
<string name="remote_input_hint">Use your phone as a keyboard and mouse for the kiosk</string>
|
||||
</resources>
|
||||
14
Android/app/src/main/res/values/themes.xml
Normal file
14
Android/app/src/main/res/values/themes.xml
Normal file
@@ -0,0 +1,14 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<resources>
|
||||
<style name="Theme.Archipelago" parent="android:Theme.Material.NoActionBar">
|
||||
<item name="android:statusBarColor">@android:color/transparent</item>
|
||||
<item name="android:navigationBarColor">@android:color/transparent</item>
|
||||
<item name="android:windowBackground">@color/black</item>
|
||||
</style>
|
||||
|
||||
<style name="Theme.Archipelago.Splash" parent="Theme.SplashScreen">
|
||||
<item name="windowSplashScreenBackground">@color/splash_background</item>
|
||||
<item name="windowSplashScreenAnimatedIcon">@drawable/ic_splash_logo</item>
|
||||
<item name="postSplashScreenTheme">@style/Theme.Archipelago</item>
|
||||
</style>
|
||||
</resources>
|
||||
9
Android/app/src/main/res/xml/network_security_config.xml
Normal file
9
Android/app/src/main/res/xml/network_security_config.xml
Normal file
@@ -0,0 +1,9 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<network-security-config>
|
||||
<!-- Allow cleartext for local network Archipelago servers -->
|
||||
<base-config cleartextTrafficPermitted="true">
|
||||
<trust-anchors>
|
||||
<certificates src="system" />
|
||||
</trust-anchors>
|
||||
</base-config>
|
||||
</network-security-config>
|
||||
4
Android/build.gradle.kts
Normal file
4
Android/build.gradle.kts
Normal file
@@ -0,0 +1,4 @@
|
||||
plugins {
|
||||
id("com.android.application") version "8.4.0" apply false
|
||||
id("org.jetbrains.kotlin.android") version "1.9.24" apply false
|
||||
}
|
||||
5
Android/gradle.properties
Normal file
5
Android/gradle.properties
Normal file
@@ -0,0 +1,5 @@
|
||||
org.gradle.jvmargs=-Xmx2048m -Dfile.encoding=UTF-8
|
||||
android.useAndroidX=true
|
||||
kotlin.code.style=official
|
||||
android.nonTransitiveRClass=true
|
||||
android.suppressUnsupportedCompileSdk=35
|
||||
BIN
Android/gradle/wrapper/gradle-wrapper.jar
vendored
Normal file
BIN
Android/gradle/wrapper/gradle-wrapper.jar
vendored
Normal file
Binary file not shown.
7
Android/gradle/wrapper/gradle-wrapper.properties
vendored
Normal file
7
Android/gradle/wrapper/gradle-wrapper.properties
vendored
Normal file
@@ -0,0 +1,7 @@
|
||||
distributionBase=GRADLE_USER_HOME
|
||||
distributionPath=wrapper/dists
|
||||
distributionUrl=https\://services.gradle.org/distributions/gradle-8.7-bin.zip
|
||||
networkTimeout=10000
|
||||
validateDistributionUrl=true
|
||||
zipStoreBase=GRADLE_USER_HOME
|
||||
zipStorePath=wrapper/dists
|
||||
249
Android/gradlew
vendored
Executable file
249
Android/gradlew
vendored
Executable file
@@ -0,0 +1,249 @@
|
||||
#!/bin/sh
|
||||
|
||||
#
|
||||
# Copyright © 2015-2021 the original authors.
|
||||
#
|
||||
# Licensed under the Apache License, Version 2.0 (the "License");
|
||||
# you may not use this file except in compliance with the License.
|
||||
# You may obtain a copy of the License at
|
||||
#
|
||||
# https://www.apache.org/licenses/LICENSE-2.0
|
||||
#
|
||||
# Unless required by applicable law or agreed to in writing, software
|
||||
# distributed under the License is distributed on an "AS IS" BASIS,
|
||||
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
# See the License for the specific language governing permissions and
|
||||
# limitations under the License.
|
||||
#
|
||||
|
||||
##############################################################################
|
||||
#
|
||||
# Gradle start up script for POSIX generated by Gradle.
|
||||
#
|
||||
# Important for running:
|
||||
#
|
||||
# (1) You need a POSIX-compliant shell to run this script. If your /bin/sh is
|
||||
# noncompliant, but you have some other compliant shell such as ksh or
|
||||
# bash, then to run this script, type that shell name before the whole
|
||||
# command line, like:
|
||||
#
|
||||
# ksh Gradle
|
||||
#
|
||||
# Busybox and similar reduced shells will NOT work, because this script
|
||||
# requires all of these POSIX shell features:
|
||||
# * functions;
|
||||
# * expansions «$var», «${var}», «${var:-default}», «${var+SET}»,
|
||||
# «${var#prefix}», «${var%suffix}», and «$( cmd )»;
|
||||
# * compound commands having a testable exit status, especially «case»;
|
||||
# * various built-in commands including «command», «set», and «ulimit».
|
||||
#
|
||||
# Important for patching:
|
||||
#
|
||||
# (2) This script targets any POSIX shell, so it avoids extensions provided
|
||||
# by Bash, Ksh, etc; in particular arrays are avoided.
|
||||
#
|
||||
# The "traditional" practice of packing multiple parameters into a
|
||||
# space-separated string is a well documented source of bugs and security
|
||||
# problems, so this is (mostly) avoided, by progressively accumulating
|
||||
# options in "$@", and eventually passing that to Java.
|
||||
#
|
||||
# Where the inherited environment variables (DEFAULT_JVM_OPTS, JAVA_OPTS,
|
||||
# and GRADLE_OPTS) rely on word-splitting, this is performed explicitly;
|
||||
# see the in-line comments for details.
|
||||
#
|
||||
# There are tweaks for specific operating systems such as AIX, CygWin,
|
||||
# Darwin, MinGW, and NonStop.
|
||||
#
|
||||
# (3) This script is generated from the Groovy template
|
||||
# https://github.com/gradle/gradle/blob/HEAD/subprojects/plugins/src/main/resources/org/gradle/api/internal/plugins/unixStartScript.txt
|
||||
# within the Gradle project.
|
||||
#
|
||||
# You can find Gradle at https://github.com/gradle/gradle/.
|
||||
#
|
||||
##############################################################################
|
||||
|
||||
# Attempt to set APP_HOME
|
||||
|
||||
# Resolve links: $0 may be a link
|
||||
app_path=$0
|
||||
|
||||
# Need this for daisy-chained symlinks.
|
||||
while
|
||||
APP_HOME=${app_path%"${app_path##*/}"} # leaves a trailing /; empty if no leading path
|
||||
[ -h "$app_path" ]
|
||||
do
|
||||
ls=$( ls -ld "$app_path" )
|
||||
link=${ls#*' -> '}
|
||||
case $link in #(
|
||||
/*) app_path=$link ;; #(
|
||||
*) app_path=$APP_HOME$link ;;
|
||||
esac
|
||||
done
|
||||
|
||||
# This is normally unused
|
||||
# shellcheck disable=SC2034
|
||||
APP_BASE_NAME=${0##*/}
|
||||
# Discard cd standard output in case $CDPATH is set (https://github.com/gradle/gradle/issues/25036)
|
||||
APP_HOME=$( cd "${APP_HOME:-./}" > /dev/null && pwd -P ) || exit
|
||||
|
||||
# Use the maximum available, or set MAX_FD != -1 to use that value.
|
||||
MAX_FD=maximum
|
||||
|
||||
warn () {
|
||||
echo "$*"
|
||||
} >&2
|
||||
|
||||
die () {
|
||||
echo
|
||||
echo "$*"
|
||||
echo
|
||||
exit 1
|
||||
} >&2
|
||||
|
||||
# OS specific support (must be 'true' or 'false').
|
||||
cygwin=false
|
||||
msys=false
|
||||
darwin=false
|
||||
nonstop=false
|
||||
case "$( uname )" in #(
|
||||
CYGWIN* ) cygwin=true ;; #(
|
||||
Darwin* ) darwin=true ;; #(
|
||||
MSYS* | MINGW* ) msys=true ;; #(
|
||||
NONSTOP* ) nonstop=true ;;
|
||||
esac
|
||||
|
||||
CLASSPATH=$APP_HOME/gradle/wrapper/gradle-wrapper.jar
|
||||
|
||||
|
||||
# Determine the Java command to use to start the JVM.
|
||||
if [ -n "$JAVA_HOME" ] ; then
|
||||
if [ -x "$JAVA_HOME/jre/sh/java" ] ; then
|
||||
# IBM's JDK on AIX uses strange locations for the executables
|
||||
JAVACMD=$JAVA_HOME/jre/sh/java
|
||||
else
|
||||
JAVACMD=$JAVA_HOME/bin/java
|
||||
fi
|
||||
if [ ! -x "$JAVACMD" ] ; then
|
||||
die "ERROR: JAVA_HOME is set to an invalid directory: $JAVA_HOME
|
||||
|
||||
Please set the JAVA_HOME variable in your environment to match the
|
||||
location of your Java installation."
|
||||
fi
|
||||
else
|
||||
JAVACMD=java
|
||||
if ! command -v java >/dev/null 2>&1
|
||||
then
|
||||
die "ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH.
|
||||
|
||||
Please set the JAVA_HOME variable in your environment to match the
|
||||
location of your Java installation."
|
||||
fi
|
||||
fi
|
||||
|
||||
# Increase the maximum file descriptors if we can.
|
||||
if ! "$cygwin" && ! "$darwin" && ! "$nonstop" ; then
|
||||
case $MAX_FD in #(
|
||||
max*)
|
||||
# In POSIX sh, ulimit -H is undefined. That's why the result is checked to see if it worked.
|
||||
# shellcheck disable=SC2039,SC3045
|
||||
MAX_FD=$( ulimit -H -n ) ||
|
||||
warn "Could not query maximum file descriptor limit"
|
||||
esac
|
||||
case $MAX_FD in #(
|
||||
'' | soft) :;; #(
|
||||
*)
|
||||
# In POSIX sh, ulimit -n is undefined. That's why the result is checked to see if it worked.
|
||||
# shellcheck disable=SC2039,SC3045
|
||||
ulimit -n "$MAX_FD" ||
|
||||
warn "Could not set maximum file descriptor limit to $MAX_FD"
|
||||
esac
|
||||
fi
|
||||
|
||||
# Collect all arguments for the java command, stacking in reverse order:
|
||||
# * args from the command line
|
||||
# * the main class name
|
||||
# * -classpath
|
||||
# * -D...appname settings
|
||||
# * --module-path (only if needed)
|
||||
# * DEFAULT_JVM_OPTS, JAVA_OPTS, and GRADLE_OPTS environment variables.
|
||||
|
||||
# For Cygwin or MSYS, switch paths to Windows format before running java
|
||||
if "$cygwin" || "$msys" ; then
|
||||
APP_HOME=$( cygpath --path --mixed "$APP_HOME" )
|
||||
CLASSPATH=$( cygpath --path --mixed "$CLASSPATH" )
|
||||
|
||||
JAVACMD=$( cygpath --unix "$JAVACMD" )
|
||||
|
||||
# Now convert the arguments - kludge to limit ourselves to /bin/sh
|
||||
for arg do
|
||||
if
|
||||
case $arg in #(
|
||||
-*) false ;; # don't mess with options #(
|
||||
/?*) t=${arg#/} t=/${t%%/*} # looks like a POSIX filepath
|
||||
[ -e "$t" ] ;; #(
|
||||
*) false ;;
|
||||
esac
|
||||
then
|
||||
arg=$( cygpath --path --ignore --mixed "$arg" )
|
||||
fi
|
||||
# Roll the args list around exactly as many times as the number of
|
||||
# args, so each arg winds up back in the position where it started, but
|
||||
# possibly modified.
|
||||
#
|
||||
# NB: a `for` loop captures its iteration list before it begins, so
|
||||
# changing the positional parameters here affects neither the number of
|
||||
# iterations, nor the values presented in `arg`.
|
||||
shift # remove old arg
|
||||
set -- "$@" "$arg" # push replacement arg
|
||||
done
|
||||
fi
|
||||
|
||||
|
||||
# Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script.
|
||||
DEFAULT_JVM_OPTS='"-Xmx64m" "-Xms64m"'
|
||||
|
||||
# Collect all arguments for the java command:
|
||||
# * DEFAULT_JVM_OPTS, JAVA_OPTS, JAVA_OPTS, and optsEnvironmentVar are not allowed to contain shell fragments,
|
||||
# and any embedded shellness will be escaped.
|
||||
# * For example: A user cannot expect ${Hostname} to be expanded, as it is an environment variable and will be
|
||||
# treated as '${Hostname}' itself on the command line.
|
||||
|
||||
set -- \
|
||||
"-Dorg.gradle.appname=$APP_BASE_NAME" \
|
||||
-classpath "$CLASSPATH" \
|
||||
org.gradle.wrapper.GradleWrapperMain \
|
||||
"$@"
|
||||
|
||||
# Stop when "xargs" is not available.
|
||||
if ! command -v xargs >/dev/null 2>&1
|
||||
then
|
||||
die "xargs is not available"
|
||||
fi
|
||||
|
||||
# Use "xargs" to parse quoted args.
|
||||
#
|
||||
# With -n1 it outputs one arg per line, with the quotes and backslashes removed.
|
||||
#
|
||||
# In Bash we could simply go:
|
||||
#
|
||||
# readarray ARGS < <( xargs -n1 <<<"$var" ) &&
|
||||
# set -- "${ARGS[@]}" "$@"
|
||||
#
|
||||
# but POSIX shell has neither arrays nor command substitution, so instead we
|
||||
# post-process each arg (as a line of input to sed) to backslash-escape any
|
||||
# character that might be a shell metacharacter, then use eval to reverse
|
||||
# that process (while maintaining the separation between arguments), and wrap
|
||||
# the whole thing up as a single "set" statement.
|
||||
#
|
||||
# This will of course break if any of these variables contains a newline or
|
||||
# an unmatched quote.
|
||||
#
|
||||
|
||||
eval "set -- $(
|
||||
printf '%s\n' "$DEFAULT_JVM_OPTS $JAVA_OPTS $GRADLE_OPTS" |
|
||||
xargs -n1 |
|
||||
sed ' s~[^-[:alnum:]+,./:=@_]~\\&~g; ' |
|
||||
tr '\n' ' '
|
||||
)" '"$@"'
|
||||
|
||||
exec "$JAVACMD" "$@"
|
||||
92
Android/gradlew.bat
vendored
Normal file
92
Android/gradlew.bat
vendored
Normal file
@@ -0,0 +1,92 @@
|
||||
@rem
|
||||
@rem Copyright 2015 the original author or authors.
|
||||
@rem
|
||||
@rem Licensed under the Apache License, Version 2.0 (the "License");
|
||||
@rem you may not use this file except in compliance with the License.
|
||||
@rem You may obtain a copy of the License at
|
||||
@rem
|
||||
@rem https://www.apache.org/licenses/LICENSE-2.0
|
||||
@rem
|
||||
@rem Unless required by applicable law or agreed to in writing, software
|
||||
@rem distributed under the License is distributed on an "AS IS" BASIS,
|
||||
@rem WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
@rem See the License for the specific language governing permissions and
|
||||
@rem limitations under the License.
|
||||
@rem
|
||||
|
||||
@if "%DEBUG%"=="" @echo off
|
||||
@rem ##########################################################################
|
||||
@rem
|
||||
@rem Gradle startup script for Windows
|
||||
@rem
|
||||
@rem ##########################################################################
|
||||
|
||||
@rem Set local scope for the variables with windows NT shell
|
||||
if "%OS%"=="Windows_NT" setlocal
|
||||
|
||||
set DIRNAME=%~dp0
|
||||
if "%DIRNAME%"=="" set DIRNAME=.
|
||||
@rem This is normally unused
|
||||
set APP_BASE_NAME=%~n0
|
||||
set APP_HOME=%DIRNAME%
|
||||
|
||||
@rem Resolve any "." and ".." in APP_HOME to make it shorter.
|
||||
for %%i in ("%APP_HOME%") do set APP_HOME=%%~fi
|
||||
|
||||
@rem Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script.
|
||||
set DEFAULT_JVM_OPTS="-Xmx64m" "-Xms64m"
|
||||
|
||||
@rem Find java.exe
|
||||
if defined JAVA_HOME goto findJavaFromJavaHome
|
||||
|
||||
set JAVA_EXE=java.exe
|
||||
%JAVA_EXE% -version >NUL 2>&1
|
||||
if %ERRORLEVEL% equ 0 goto execute
|
||||
|
||||
echo. 1>&2
|
||||
echo ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH. 1>&2
|
||||
echo. 1>&2
|
||||
echo Please set the JAVA_HOME variable in your environment to match the 1>&2
|
||||
echo location of your Java installation. 1>&2
|
||||
|
||||
goto fail
|
||||
|
||||
:findJavaFromJavaHome
|
||||
set JAVA_HOME=%JAVA_HOME:"=%
|
||||
set JAVA_EXE=%JAVA_HOME%/bin/java.exe
|
||||
|
||||
if exist "%JAVA_EXE%" goto execute
|
||||
|
||||
echo. 1>&2
|
||||
echo ERROR: JAVA_HOME is set to an invalid directory: %JAVA_HOME% 1>&2
|
||||
echo. 1>&2
|
||||
echo Please set the JAVA_HOME variable in your environment to match the 1>&2
|
||||
echo location of your Java installation. 1>&2
|
||||
|
||||
goto fail
|
||||
|
||||
:execute
|
||||
@rem Setup the command line
|
||||
|
||||
set CLASSPATH=%APP_HOME%\gradle\wrapper\gradle-wrapper.jar
|
||||
|
||||
|
||||
@rem Execute Gradle
|
||||
"%JAVA_EXE%" %DEFAULT_JVM_OPTS% %JAVA_OPTS% %GRADLE_OPTS% "-Dorg.gradle.appname=%APP_BASE_NAME%" -classpath "%CLASSPATH%" org.gradle.wrapper.GradleWrapperMain %*
|
||||
|
||||
:end
|
||||
@rem End local scope for the variables with windows NT shell
|
||||
if %ERRORLEVEL% equ 0 goto mainEnd
|
||||
|
||||
:fail
|
||||
rem Set variable GRADLE_EXIT_CONSOLE if you need the _script_ return code instead of
|
||||
rem the _cmd.exe /c_ return code!
|
||||
set EXIT_CODE=%ERRORLEVEL%
|
||||
if %EXIT_CODE% equ 0 set EXIT_CODE=1
|
||||
if not ""=="%GRADLE_EXIT_CONSOLE%" exit %EXIT_CODE%
|
||||
exit /b %EXIT_CODE%
|
||||
|
||||
:mainEnd
|
||||
if "%OS%"=="Windows_NT" endlocal
|
||||
|
||||
:omega
|
||||
18
Android/settings.gradle.kts
Normal file
18
Android/settings.gradle.kts
Normal file
@@ -0,0 +1,18 @@
|
||||
pluginManagement {
|
||||
repositories {
|
||||
google()
|
||||
mavenCentral()
|
||||
gradlePluginPortal()
|
||||
}
|
||||
}
|
||||
|
||||
dependencyResolutionManagement {
|
||||
repositoriesMode.set(RepositoriesMode.FAIL_ON_PROJECT_REPOS)
|
||||
repositories {
|
||||
google()
|
||||
mavenCentral()
|
||||
}
|
||||
}
|
||||
|
||||
rootProject.name = "Archipelago"
|
||||
include(":app")
|
||||
34
BACKLOG.md
34
BACKLOG.md
@@ -1,34 +0,0 @@
|
||||
# Archipelago Backlog
|
||||
|
||||
## Node Discovery & Spatial Map (Alpha Demo Feature)
|
||||
|
||||
**Priority:** High (needed for live alpha demo)
|
||||
|
||||
### "Find Nodes" — Spatial Node Discovery
|
||||
|
||||
Add a "Find Nodes" button to the Messages tab that opens a modal with an interactive spatial node map.
|
||||
|
||||
**Requirements:**
|
||||
- Visual spatial map showing discovered Archipelago nodes
|
||||
- Each node displays its self-chosen name (pseudonym)
|
||||
- Connection request flow: discover → request → peer approves → connected
|
||||
- Optional locality broadcasting (toggle: share general area or stay anonymous)
|
||||
- Cool, visual, presentation-worthy UI for live alpha demo
|
||||
|
||||
**Onboarding Addition:**
|
||||
- Add "Name your node" step during setup/onboarding
|
||||
- Include privacy guidance: "Use a pseudonym if you want privacy"
|
||||
- Node name is broadcast on the discovery network
|
||||
|
||||
**Technical Notes:**
|
||||
- Builds on existing Nostr-based node discovery (`node-nostr-discover` RPC)
|
||||
- Existing peer system: `node-add-peer`, `node-remove-peer`, `node-list-peers`
|
||||
- Need to add: connection request/approval flow (currently peers are added directly)
|
||||
- Spatial visualization could use force-directed graph or map-based layout
|
||||
- Locality data is optional and coarse-grained (city/region level, never precise)
|
||||
|
||||
---
|
||||
|
||||
## Settings (TBD)
|
||||
|
||||
*User mentioned settings changes needed — details to be clarified.*
|
||||
@@ -1,226 +0,0 @@
|
||||
# Archipelago Build System - Summary
|
||||
|
||||
## ✅ What We Created Today
|
||||
|
||||
### 1. **Complete One-Script Build System** (`build-iso-complete.sh`)
|
||||
- Handles backend compilation (Rust)
|
||||
- Handles frontend build (Vue.js)
|
||||
- Creates bootable ISO image
|
||||
- Supports local and remote builds
|
||||
- Smart artifact caching
|
||||
- Full error checking and validation
|
||||
|
||||
### 2. **Comprehensive Documentation** (`BUILD-GUIDE.md`)
|
||||
- Quick start guide
|
||||
- Detailed build options
|
||||
- Troubleshooting section
|
||||
- Development workflow
|
||||
- CI/CD integration examples
|
||||
|
||||
### 3. **Fixed ISO Auto-Start Issue**
|
||||
- Identified root cause: `read -p` prompt blocking auto-launch
|
||||
- Restored working auto-start logic from previous builds
|
||||
- Menu now launches automatically after 1 second
|
||||
|
||||
## 🚀 How to Use
|
||||
|
||||
### Quick Build
|
||||
```bash
|
||||
# One command - builds everything and creates flashable ISO
|
||||
./build-iso-complete.sh --remote archipelago@192.168.1.228
|
||||
```
|
||||
|
||||
### Flash to USB
|
||||
```bash
|
||||
# After build completes
|
||||
./flash-to-usb.sh /dev/diskN
|
||||
```
|
||||
|
||||
## 📦 What the Build Process Does
|
||||
|
||||
```
|
||||
Source Code
|
||||
│
|
||||
├─→ Backend (Rust) ────→ Binary (10MB)
|
||||
│ ↓
|
||||
├─→ Frontend (Vue) ────→ Assets (5MB)
|
||||
│ ↓
|
||||
└─→ ISO Builder ────────→ Bootable ISO (1.2GB)
|
||||
↓
|
||||
Flash to USB
|
||||
↓
|
||||
Boot & Install
|
||||
```
|
||||
|
||||
### Build Steps
|
||||
|
||||
1. **Backend Compilation** (Rust → Native Binary)
|
||||
- `core/archipelago/` → `image-recipe/build/backend/archipelago`
|
||||
- Can build locally or on remote server
|
||||
- Incremental builds supported
|
||||
|
||||
2. **Frontend Build** (Vue.js → Static Assets)
|
||||
- `neode-ui/` → `image-recipe/build/frontend/`
|
||||
- Includes PWA manifest
|
||||
- Optimized production build
|
||||
|
||||
3. **ISO Creation** (Debian Live)
|
||||
- Downloads base Debian 12 ISO (~352MB)
|
||||
- Integrates backend + frontend
|
||||
- Configures auto-start services
|
||||
- Creates bootable image
|
||||
|
||||
4. **Verification**
|
||||
- Validates all artifacts
|
||||
- Generates MD5 checksum
|
||||
- Reports sizes
|
||||
|
||||
## 🎯 Key Features
|
||||
|
||||
### ✅ Smart Caching
|
||||
- Skip backend build: `--skip-backend`
|
||||
- Skip frontend build: `--skip-frontend`
|
||||
- Debian ISO cached after first download
|
||||
|
||||
### ✅ Remote Build Support
|
||||
- Build on development server (recommended)
|
||||
- Automatically syncs code
|
||||
- Copies artifacts back
|
||||
|
||||
### ✅ Clean Build Option
|
||||
- `--clean` flag removes all artifacts
|
||||
- Ensures fresh compilation
|
||||
|
||||
### ✅ Convenience Scripts
|
||||
- `build-iso-complete.sh` - Main build script
|
||||
- `flash-to-usb.sh` - Quick USB flashing
|
||||
- Auto-generated after each build
|
||||
|
||||
## 📊 Build Time
|
||||
|
||||
| Build Type | Time |
|
||||
|-----------|------|
|
||||
| **First build** (clean) | 15-20 min |
|
||||
| **Incremental** (code changes) | 3-5 min |
|
||||
| **ISO only** (skip backend/frontend) | 2-3 min |
|
||||
|
||||
Breakdown:
|
||||
- Debian ISO download: 5-10 min (first time only)
|
||||
- Backend compile: 3-5 min (first time), ~30sec (incremental)
|
||||
- Frontend build: 1-2 min
|
||||
- ISO creation: 2-3 min
|
||||
|
||||
## 🔧 Development Workflow
|
||||
|
||||
### Making Backend Changes
|
||||
```bash
|
||||
# Edit Rust code in core/archipelago/src/
|
||||
# Then rebuild:
|
||||
./build-iso-complete.sh --remote HOST --skip-frontend
|
||||
```
|
||||
|
||||
### Making Frontend Changes
|
||||
```bash
|
||||
# Edit Vue.js code in neode-ui/src/
|
||||
# Then rebuild:
|
||||
./build-iso-complete.sh --remote HOST --skip-backend
|
||||
```
|
||||
|
||||
### Making Both Changes
|
||||
```bash
|
||||
./build-iso-complete.sh --remote HOST
|
||||
```
|
||||
|
||||
## 📝 Current Build Status
|
||||
|
||||
### ✅ Completed
|
||||
- Build system scripts created
|
||||
- Documentation written
|
||||
- Auto-start issue fixed
|
||||
- README updated
|
||||
|
||||
### 🔄 In Progress
|
||||
- ISO build running on `archipelago@192.168.1.228`
|
||||
- Status: Downloading Debian ISO (34% complete)
|
||||
- ETA: ~10 more minutes
|
||||
|
||||
### ⏳ Next
|
||||
- Test new ISO on Dell OptiPlex
|
||||
- Verify auto-start works
|
||||
- Confirm Web UI accessible
|
||||
|
||||
## 🎯 What This Solves
|
||||
|
||||
### Before
|
||||
- Manual backend compilation
|
||||
- Manual frontend build
|
||||
- Manual file copying
|
||||
- Complex multi-step process
|
||||
- Easy to miss steps
|
||||
- Inconsistent builds
|
||||
|
||||
### After
|
||||
- ✅ One command builds everything
|
||||
- ✅ Automatic artifact management
|
||||
- ✅ Smart caching for speed
|
||||
- ✅ Consistent, reproducible builds
|
||||
- ✅ Clear error messages
|
||||
- ✅ Build verification
|
||||
|
||||
## 📂 File Structure
|
||||
|
||||
```
|
||||
archy/
|
||||
├── build-iso-complete.sh # Main build script (NEW)
|
||||
├── flash-to-usb.sh # USB flash helper (auto-generated)
|
||||
├── BUILD-GUIDE.md # Build documentation (NEW)
|
||||
├── README.md # Updated with build info
|
||||
├── core/archipelago/ # Rust backend
|
||||
├── neode-ui/ # Vue.js frontend
|
||||
└── image-recipe/
|
||||
├── build/ # Build artifacts
|
||||
│ ├── backend/ # Compiled binary
|
||||
│ └── frontend/ # Built assets
|
||||
├── results/ # Final ISO output
|
||||
│ └── archipelago-debian-12-x86_64.iso
|
||||
└── build-debian-iso.sh # ISO creation script
|
||||
```
|
||||
|
||||
## 🔐 Security
|
||||
|
||||
Build system is designed to be secure:
|
||||
- No hardcoded credentials
|
||||
- SSH key authentication recommended
|
||||
- `sudo` only when required (ISO creation)
|
||||
- Build artifacts isolated in `build/` directory
|
||||
- Clean separation of build/source directories
|
||||
|
||||
## 🌟 Future Enhancements
|
||||
|
||||
Potential improvements:
|
||||
- [ ] GitHub Actions CI/CD workflow
|
||||
- [ ] Automatic version numbering
|
||||
- [ ] Build signing for verification
|
||||
- [ ] Multi-architecture support (ARM64)
|
||||
- [ ] Docker-based builds
|
||||
- [ ] Build caching improvements
|
||||
- [ ] Parallel compilation
|
||||
|
||||
## 📚 Documentation
|
||||
|
||||
- **BUILD-GUIDE.md** - Comprehensive build guide
|
||||
- **README.md** - Project overview with build quick start
|
||||
- **build-iso-complete.sh** - Inline help with `--help` flag
|
||||
|
||||
## 🎉 Result
|
||||
|
||||
You now have a **production-grade build system** that:
|
||||
- ✅ Builds from source with one command
|
||||
- ✅ Handles all dependencies automatically
|
||||
- ✅ Validates output
|
||||
- ✅ Creates flashable ISO
|
||||
- ✅ Supports iterative development
|
||||
- ✅ Well-documented
|
||||
- ✅ Easy to extend
|
||||
|
||||
**Next step:** Once the current ISO build completes, test it on the Dell OptiPlex to verify auto-start works!
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user