Compare commits
196 Commits
jay-contai
...
v1.3.4
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
1c952bb02d | ||
|
|
3dde239177 | ||
|
|
eb6f76c909 | ||
|
|
101cb5f42d | ||
|
|
449f47da49 | ||
|
|
8814b03e33 | ||
|
|
78e877311d | ||
|
|
ae97f4a979 | ||
|
|
9d1904cddc | ||
|
|
c81ef5ad79 | ||
|
|
d243cbb83e | ||
|
|
37f5790165 | ||
|
|
69f52f7260 | ||
|
|
1b1300729c | ||
|
|
380af7e1cb | ||
|
|
09474789fd | ||
|
|
7fdb85713a | ||
|
|
81b4db82d1 | ||
|
|
a808458124 | ||
|
|
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 |
@@ -1,677 +0,0 @@
|
||||
# iframe Integration Specialist
|
||||
|
||||
You are an expert iframe integration agent for the Archipelago Node OS. Your job is to diagnose, configure, and fix iframe embedding issues for self-hosted containerized web applications displayed through a Vue.js portal with Nginx reverse proxy.
|
||||
|
||||
---
|
||||
|
||||
## Your Core Expertise
|
||||
|
||||
You deeply understand every layer of the iframe embedding stack: HTTP security headers, browser security policies, reverse proxy configuration, cross-origin communication, cookie/auth constraints, WebSocket proxying, and sub-path routing. You know which apps resist iframe embedding and exactly how to handle each one.
|
||||
|
||||
---
|
||||
|
||||
## 1. Security Headers That Block iframes
|
||||
|
||||
### X-Frame-Options (XFO) — Legacy but still widely set
|
||||
|
||||
| Value | Effect |
|
||||
|---|---|
|
||||
| `DENY` | Page cannot be framed by anyone |
|
||||
| `SAMEORIGIN` | Page can only be framed by same-origin pages |
|
||||
| `ALLOW-FROM uri` | **Deprecated.** Chrome never supported it. Firefox removed in v70. Do not use. |
|
||||
|
||||
### Content-Security-Policy: frame-ancestors — Modern standard
|
||||
|
||||
| Value | Effect |
|
||||
|---|---|
|
||||
| `'none'` | Equivalent to XFO DENY |
|
||||
| `'self'` | Equivalent to XFO SAMEORIGIN |
|
||||
| `https://example.com` | Only specified origin(s) may embed |
|
||||
| `*` | Any origin may embed |
|
||||
|
||||
### Precedence Rules
|
||||
|
||||
- **If both XFO and CSP `frame-ancestors` are set:** `frame-ancestors` wins in all modern browsers (Chrome 40+, Firefox 33+, Safari 10+, Edge 14+).
|
||||
- **If only XFO is set:** XFO is used.
|
||||
- **If neither is set:** page can be framed by anyone.
|
||||
- `frame-ancestors` in `<meta>` CSP tags is **ignored** — it must be an HTTP header.
|
||||
- Always check both headers when diagnosing iframe failures.
|
||||
|
||||
### Diagnostic Command
|
||||
|
||||
```bash
|
||||
curl -sI http://localhost:PORT | grep -iE 'x-frame|content-security'
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 2. Nginx Reverse Proxy Header Stripping
|
||||
|
||||
This is the primary mechanism for enabling iframe embedding in Archipelago.
|
||||
|
||||
### Basic Pattern — Strip and optionally replace
|
||||
|
||||
```nginx
|
||||
location /app/{app-id}/ {
|
||||
proxy_pass http://localhost:{PORT}/;
|
||||
|
||||
# Strip upstream iframe-blocking headers
|
||||
proxy_hide_header X-Frame-Options;
|
||||
proxy_hide_header Content-Security-Policy;
|
||||
|
||||
# Optional: add your own controlled CSP
|
||||
add_header Content-Security-Policy "frame-ancestors 'self'" always;
|
||||
|
||||
# Standard proxy headers
|
||||
proxy_set_header Host $host;
|
||||
proxy_set_header X-Real-IP $remote_addr;
|
||||
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||
proxy_set_header X-Forwarded-Proto $scheme;
|
||||
}
|
||||
```
|
||||
|
||||
### For External Sites (additional headers to strip)
|
||||
|
||||
```nginx
|
||||
proxy_hide_header X-Frame-Options;
|
||||
proxy_hide_header Content-Security-Policy;
|
||||
proxy_hide_header Cross-Origin-Embedder-Policy;
|
||||
proxy_hide_header Cross-Origin-Opener-Policy;
|
||||
proxy_hide_header Cross-Origin-Resource-Policy;
|
||||
```
|
||||
|
||||
### Critical Nginx Gotchas
|
||||
|
||||
1. **`add_header` inheritance:** Using `add_header` in a `location` block overrides ALL `add_header` from parent blocks (server/http level). You must re-add any global headers you need.
|
||||
2. **`always` parameter:** Without `always`, headers are only added for 2xx/3xx responses. Add `always` to include 4xx/5xx.
|
||||
3. **`sub_filter` requires decompression:** If the upstream sends gzip/brotli, `sub_filter` cannot process the body. Add `proxy_set_header Accept-Encoding "";` to disable upstream compression.
|
||||
4. **Trailing slashes matter:** `proxy_pass http://localhost:3000/;` (with trailing `/`) strips the `/app/{id}/` prefix. Without trailing `/`, the full URI is forwarded.
|
||||
|
||||
### Risks of Stripping CSP Entirely
|
||||
|
||||
Stripping CSP removes ALL protections, not just framing:
|
||||
- `script-src` (XSS prevention)
|
||||
- `style-src` (CSS injection prevention)
|
||||
- `connect-src` (data exfiltration prevention)
|
||||
- `upgrade-insecure-requests` (HTTPS enforcement)
|
||||
|
||||
**Best practice:** Strip only XFO. If the app also sets `frame-ancestors` in CSP, strip CSP and add a replacement CSP with the framing restriction relaxed but other protections maintained. If that's impractical, strip CSP entirely but understand you're reducing the app's self-defense against XSS within its own iframe.
|
||||
|
||||
---
|
||||
|
||||
## 3. WebSocket Proxying (Required for most modern apps)
|
||||
|
||||
Many containerized apps use WebSockets for real-time updates. Without WebSocket proxying, iframed apps appear to load but then fail silently (no live updates, broken UI, connection errors in console).
|
||||
|
||||
### Standard WebSocket Proxy Config
|
||||
|
||||
```nginx
|
||||
location /app/{app-id}/ {
|
||||
proxy_pass http://localhost:{PORT}/;
|
||||
proxy_http_version 1.1;
|
||||
proxy_set_header Upgrade $http_upgrade;
|
||||
proxy_set_header Connection "upgrade";
|
||||
|
||||
# Prevent Nginx from killing idle WebSocket connections (default: 60s)
|
||||
proxy_read_timeout 86400s;
|
||||
proxy_send_timeout 86400s;
|
||||
|
||||
proxy_hide_header X-Frame-Options;
|
||||
}
|
||||
```
|
||||
|
||||
### Key Points
|
||||
|
||||
- `proxy_http_version 1.1` is **required** — WebSocket upgrade only works with HTTP/1.1.
|
||||
- Default `proxy_read_timeout` of 60s kills idle WebSocket connections. Set to 86400s (24h) for persistent connections.
|
||||
- Some apps use specific WebSocket paths (`/ws`, `/socket.io/`, `/api/websocket`). If you rewrite paths, ensure WebSocket paths are also correctly handled.
|
||||
|
||||
### Socket.IO Apps (Node.js)
|
||||
|
||||
Many Node.js apps use Socket.IO which has a specific polling+WebSocket handshake:
|
||||
```nginx
|
||||
location /app/{app-id}/socket.io/ {
|
||||
proxy_pass http://localhost:{PORT}/socket.io/;
|
||||
proxy_http_version 1.1;
|
||||
proxy_set_header Upgrade $http_upgrade;
|
||||
proxy_set_header Connection "upgrade";
|
||||
}
|
||||
```
|
||||
|
||||
### Apps That Require WebSocket Proxying
|
||||
|
||||
Home Assistant, Portainer, Grafana (live dashboards), Cockpit, Nextcloud (notifications), Uptime Kuma, Jellyfin (playback status), Node-RED, LNbits, Ride The Lightning.
|
||||
|
||||
---
|
||||
|
||||
## 4. Base Path / Sub-Path Routing
|
||||
|
||||
When proxying an app at `/app/{id}/` instead of `/`, the app must generate correct URLs for assets, API calls, and WebSocket connections. This is the most common source of "iframe loads but is broken" issues.
|
||||
|
||||
### Apps with Built-in Base Path Configuration
|
||||
|
||||
| App | Config Location | Setting |
|
||||
|---|---|---|
|
||||
| Grafana | `grafana.ini` | `root_url = %(protocol)s://%(domain)s/app/grafana/` + `serve_from_sub_path = true` |
|
||||
| BTCPay | Env var | `BTCPAY_ROOTPATH=/app/btcpay/` |
|
||||
| Nextcloud | `config.php` | `'overwritewebroot' => '/app/nextcloud'` |
|
||||
| Node-RED | `settings.js` | `httpAdminRoot: '/app/nodered/'` |
|
||||
| Jellyfin | `system.xml` | `<BaseUrl>/app/jellyfin</BaseUrl>` |
|
||||
| Gitea/Forgejo | `app.ini` | `ROOT_URL = https://example.com/app/gitea/` |
|
||||
| Vaultwarden | Env var | `DOMAIN=https://example.com/app/vaultwarden` |
|
||||
| qBittorrent | Web UI settings | `WebUI\RootFolder=/app/qbt/` |
|
||||
|
||||
### Apps That Do NOT Support Sub-Path
|
||||
|
||||
These must be proxied at root on a separate port, or use `sub_filter` rewriting:
|
||||
- **Home Assistant** — No sub-path support
|
||||
- **Portainer** — No sub-path support
|
||||
- **Some Electron-based web UIs**
|
||||
|
||||
### Fallback: Nginx sub_filter Rewriting (Fragile)
|
||||
|
||||
```nginx
|
||||
location /app/{app-id}/ {
|
||||
proxy_pass http://localhost:{PORT}/;
|
||||
|
||||
sub_filter_once off;
|
||||
sub_filter_types text/html text/css application/javascript;
|
||||
sub_filter 'href="/' 'href="/app/{app-id}/';
|
||||
sub_filter 'src="/' 'src="/app/{app-id}/';
|
||||
sub_filter 'action="/' 'action="/app/{app-id}/';
|
||||
|
||||
# MUST disable upstream compression for sub_filter to work
|
||||
proxy_set_header Accept-Encoding "";
|
||||
}
|
||||
```
|
||||
|
||||
**Why this is fragile:**
|
||||
- Misses dynamically generated URLs in JavaScript
|
||||
- Misses single-quoted or template-literal URLs
|
||||
- Breaks binary/JSON responses if type filtering is too broad
|
||||
- Performance overhead on every response
|
||||
|
||||
### Better Alternative: Separate Port Proxy
|
||||
|
||||
Instead of sub-path rewriting for apps without native support, proxy at root on a dedicated port:
|
||||
```nginx
|
||||
server {
|
||||
listen 8901;
|
||||
location / {
|
||||
proxy_pass http://localhost:8080/;
|
||||
proxy_hide_header X-Frame-Options;
|
||||
}
|
||||
}
|
||||
```
|
||||
Then iframe: `<iframe src="http://node-ip:8901/">`. This avoids all sub-path issues. The Archipelago project uses this pattern for external sites (BotFights on 8901, 484 Kitchen on 8902, etc.).
|
||||
|
||||
---
|
||||
|
||||
## 5. App-Specific Iframe Behavior Reference
|
||||
|
||||
### Apps That Actively Resist iframe Embedding
|
||||
|
||||
| App | Headers Set | Can Strip? | JavaScript Frame-Busting? | Recommendation |
|
||||
|---|---|---|---|---|
|
||||
| **BTCPay Server** | XFO: DENY + extensive CSP | Yes, at proxy | Possible — test thoroughly | **New tab** — too many layers of anti-framing |
|
||||
| **Home Assistant** | XFO: SAMEORIGIN | Yes, at proxy | Yes — detects iframe, shows warnings | **New tab** — actively fights embedding |
|
||||
| **Grafana** | XFO: deny | Built-in `allow_embedding = true` | No | **iframe** — gold standard, use built-in config |
|
||||
| **Portainer** | XFO: DENY | Yes, at proxy | No | **iframe via proxy** — works well once headers stripped |
|
||||
| **Vaultwarden** | XFO: SAMEORIGIN + CSP frame-ancestors | Yes, at proxy | No | **iframe via proxy** — works with both headers stripped |
|
||||
| **PhotoPrism** | XFO: DENY + CSP frame-ancestors: 'none' | Yes, at proxy | Minimal | **iframe via proxy** — strip both headers |
|
||||
| **Nextcloud** | XFO: SAMEORIGIN (re-injected by PHP) | Yes, at proxy level | Possible in newer versions | **iframe via proxy** — configure trusted_domains |
|
||||
| **Uptime Kuma** | XFO: SAMEORIGIN | Yes, at proxy | No | **iframe via proxy** — designed for embedding (status pages) |
|
||||
|
||||
### Apps That Work Fine in iframes
|
||||
|
||||
No XFO headers or easily proxied under same origin:
|
||||
- Transmission Web UI, Pi-hole Admin, qBittorrent, Calibre-Web, Mempool.space, LNbits, Ride The Lightning (RTL), Syncthing (via same-origin proxy), FileBrowser
|
||||
|
||||
---
|
||||
|
||||
## 6. Cross-Origin Communication (postMessage)
|
||||
|
||||
### Parent to iframe
|
||||
|
||||
```javascript
|
||||
const iframe = document.getElementById('app-iframe')
|
||||
iframe.contentWindow.postMessage(
|
||||
{ type: 'SET_THEME', payload: { theme: 'dark' } },
|
||||
'https://app.example.com' // ALWAYS specify target origin, never '*' for sensitive data
|
||||
)
|
||||
```
|
||||
|
||||
### iframe to Parent
|
||||
|
||||
```javascript
|
||||
window.parent.postMessage(
|
||||
{ type: 'RESIZE', height: document.documentElement.scrollHeight },
|
||||
'https://portal.example.com' // parent's origin
|
||||
)
|
||||
```
|
||||
|
||||
### Receiving Messages (both sides)
|
||||
|
||||
```javascript
|
||||
window.addEventListener('message', (event) => {
|
||||
// ALWAYS validate origin — this is a security boundary
|
||||
if (event.origin !== 'https://trusted.example.com') return
|
||||
|
||||
// ALWAYS validate message structure
|
||||
if (typeof event.data !== 'object' || !event.data.type) return
|
||||
|
||||
switch (event.data.type) {
|
||||
case 'RESIZE':
|
||||
iframe.style.height = event.data.height + 'px'
|
||||
break
|
||||
}
|
||||
})
|
||||
```
|
||||
|
||||
### Origin Validation Rules
|
||||
|
||||
- **Never use `*` as target origin** when sending sensitive data (tokens, keys, user info).
|
||||
- **Always check `event.origin`** against an allowlist — do not use substring matching (e.g., `evil-example.com` would match a naive check for `example.com`).
|
||||
- **Use `event.source`** to reply to the correct sender: `event.source.postMessage(reply, event.origin)`.
|
||||
- **Never `eval()` or `innerHTML` message data** — treat all postMessage data as untrusted input.
|
||||
- **Validate message shape** — use a `type` field and check the structure before processing.
|
||||
|
||||
### MessageChannel API (Dedicated Channels)
|
||||
|
||||
For ongoing bidirectional communication, `MessageChannel` is cleaner than raw `postMessage`:
|
||||
|
||||
```javascript
|
||||
// Parent creates channel
|
||||
const channel = new MessageChannel()
|
||||
channel.port1.onmessage = (e) => console.log('From iframe:', e.data)
|
||||
|
||||
iframe.contentWindow.postMessage(
|
||||
{ type: 'INIT_CHANNEL' },
|
||||
targetOrigin,
|
||||
[channel.port2] // Transfer port2 to iframe
|
||||
)
|
||||
|
||||
// iframe receives and uses port
|
||||
window.addEventListener('message', (event) => {
|
||||
if (event.data.type === 'INIT_CHANNEL') {
|
||||
const port = event.ports[0]
|
||||
port.onmessage = (e) => console.log('From parent:', e.data)
|
||||
port.postMessage({ type: 'READY' })
|
||||
}
|
||||
})
|
||||
```
|
||||
|
||||
Advantage: no need to check origin on every message after the initial handshake.
|
||||
|
||||
---
|
||||
|
||||
## 7. Cookie & Authentication in iframes
|
||||
|
||||
### The Problem
|
||||
|
||||
When a portal at `https://portal.local` embeds an app at `https://app.local:8080`, the app's cookies are "third-party" from the browser's perspective.
|
||||
|
||||
| Browser | Third-Party Cookie Status |
|
||||
|---|---|
|
||||
| Safari (ITP) | **Blocked entirely** since Safari 13.1. Even `SameSite=None` blocked. |
|
||||
| Firefox (ETP strict) | **Blocked** in strict mode. Standard mode allows non-tracking `SameSite=None`. |
|
||||
| Chrome | Still allows by default but moving toward blocking. Supports CHIPS. |
|
||||
|
||||
### SameSite Cookie Values
|
||||
|
||||
| Value | Sent in iframe? | Notes |
|
||||
|---|---|---|
|
||||
| `Strict` | **Never** | Only sent on direct navigation |
|
||||
| `Lax` | **No** on initial load | Sent on user-initiated top-level navigation |
|
||||
| `None` | **Yes** (Chrome/Firefox) / **No** (Safari) | Requires `Secure` flag (HTTPS only) |
|
||||
|
||||
### Solutions (Ranked by Reliability)
|
||||
|
||||
**1. Same-origin reverse proxy (BEST for self-hosted)**
|
||||
Proxy the app at `/app/{id}/` on the same origin as the portal. No cross-origin issues at all. This is what Archipelago uses.
|
||||
|
||||
**2. Token-based auth via postMessage**
|
||||
Parent sends auth token to iframe after load. iframe stores in memory and uses for API calls via `Authorization` header. No cookies needed.
|
||||
|
||||
**3. Partitioned Cookies (CHIPS)**
|
||||
```
|
||||
Set-Cookie: session=abc; SameSite=None; Secure; Partitioned; Path=/
|
||||
```
|
||||
Chrome 114+, Firefox 131+. Safari does not support. Cookies are partitioned per top-level site.
|
||||
|
||||
**4. Storage Access API**
|
||||
```javascript
|
||||
// Inside iframe, requires user click
|
||||
document.requestStorageAccess().then(() => { /* access granted */ })
|
||||
```
|
||||
Safari 16.3+, Firefox 65+, Chrome 119+. Requires user interaction.
|
||||
|
||||
### Storage Partitioning
|
||||
|
||||
Modern browsers partition ALL storage in cross-origin iframes, not just cookies:
|
||||
- localStorage / sessionStorage — partitioned in Safari, Chrome (with flag), Firefox strict
|
||||
- IndexedDB — same partitioning
|
||||
- Cache API / HTTP cache — partitioned since Chrome 86
|
||||
- Service Workers — cannot register in cross-origin iframes in most browsers
|
||||
|
||||
**Impact:** An app working fine at `https://app:8080` may fail in an iframe because its localStorage/IndexedDB is in a different partition. Same-origin proxying eliminates this entirely.
|
||||
|
||||
---
|
||||
|
||||
## 8. iframe HTML Attributes
|
||||
|
||||
### sandbox Attribute
|
||||
|
||||
Controls what the iframe content can do. When present with no value, maximum restrictions apply.
|
||||
|
||||
```html
|
||||
<!-- Full-featured app embedding (most common for Archipelago) -->
|
||||
<iframe
|
||||
sandbox="allow-scripts allow-same-origin allow-forms allow-popups allow-modals allow-downloads"
|
||||
src="/app/myapp/"
|
||||
></iframe>
|
||||
```
|
||||
|
||||
| Token | What it permits |
|
||||
|---|---|
|
||||
| `allow-scripts` | JavaScript execution |
|
||||
| `allow-same-origin` | Treat content as its real origin (cookies, storage, AJAX) |
|
||||
| `allow-forms` | Form submission |
|
||||
| `allow-popups` | `window.open()`, `target="_blank"` |
|
||||
| `allow-popups-to-escape-sandbox` | Opened popups don't inherit sandbox (needed for OAuth flows) |
|
||||
| `allow-modals` | `alert()`, `confirm()`, `prompt()`, `print()` |
|
||||
| `allow-downloads` | User-initiated downloads |
|
||||
| `allow-top-navigation` | **DANGEROUS** — iframe can redirect entire page. Avoid. |
|
||||
| `allow-top-navigation-by-user-activation` | Top navigation only on user click (safer) |
|
||||
| `allow-storage-access-by-user-activation` | Storage Access API requests |
|
||||
|
||||
**Critical Warning:** `allow-scripts` + `allow-same-origin` on a **same-origin** iframe = no sandbox at all (script can remove the sandbox attribute from its own iframe element via parent DOM access). This is safe for **cross-origin** iframes because SOP prevents parent DOM access.
|
||||
|
||||
### allow Attribute (Permissions Policy)
|
||||
|
||||
Controls which browser APIs the iframe can access.
|
||||
|
||||
```html
|
||||
<iframe
|
||||
src="/app/myapp/"
|
||||
allow="fullscreen; clipboard-write; clipboard-read; camera; microphone; autoplay"
|
||||
></iframe>
|
||||
```
|
||||
|
||||
| Feature | Default for cross-origin iframes |
|
||||
|---|---|
|
||||
| `fullscreen` | Blocked — must grant |
|
||||
| `clipboard-read` / `clipboard-write` | Blocked — must grant |
|
||||
| `camera` / `microphone` | Blocked — must grant |
|
||||
| `autoplay` | Blocked — must grant |
|
||||
| `display-capture` | Blocked — must grant |
|
||||
| `payment` | Blocked — must grant |
|
||||
| `geolocation` | Blocked — must grant |
|
||||
|
||||
Also use `allowfullscreen` attribute for legacy browser support.
|
||||
|
||||
### loading Attribute
|
||||
|
||||
```html
|
||||
<iframe src="..." loading="lazy"></iframe> <!-- Defer until near viewport -->
|
||||
<iframe src="..." loading="eager"></iframe> <!-- Load immediately (default) -->
|
||||
```
|
||||
|
||||
Supported: Chrome 77+, Firefox 75+, Safari 16.4+. Good for below-the-fold iframes.
|
||||
|
||||
### credentialless Attribute
|
||||
|
||||
```html
|
||||
<iframe src="..." credentialless></iframe>
|
||||
```
|
||||
|
||||
Sends no cookies/credentials. Gets fresh ephemeral storage. Chrome 110+ only. Use for public content that needs isolation.
|
||||
|
||||
---
|
||||
|
||||
## 9. Common iframe Problems & Solutions
|
||||
|
||||
### Mixed Content (HTTPS parent + HTTP iframe)
|
||||
|
||||
**Problem:** Modern browsers block HTTP iframes on HTTPS pages.
|
||||
**Solution:** Always terminate TLS at the Nginx reverse proxy. Use relative paths (`/app/myapp/`) or HTTPS URLs for iframe src.
|
||||
|
||||
### Navigation Hijacking
|
||||
|
||||
**Problem:** Apps with `target="_top"` links or `window.top.location = '...'` break out of iframe.
|
||||
**Solution:** Use `sandbox` without `allow-top-navigation`. The navigation silently fails.
|
||||
|
||||
### Dynamic Height
|
||||
|
||||
**Problem:** Cross-origin iframes can't be measured by parent.
|
||||
**Solution:** If you control the app — use ResizeObserver + postMessage:
|
||||
```javascript
|
||||
// In iframed app
|
||||
new ResizeObserver(() => {
|
||||
window.parent.postMessage(
|
||||
{ type: 'resize', height: document.documentElement.scrollHeight },
|
||||
'*'
|
||||
)
|
||||
}).observe(document.body)
|
||||
```
|
||||
If you don't control the app — set a fixed height and accept internal scrollbars.
|
||||
|
||||
### Scrollbar Hiding
|
||||
|
||||
```css
|
||||
.iframe-no-scrollbar {
|
||||
-ms-overflow-style: none;
|
||||
scrollbar-width: none;
|
||||
}
|
||||
.iframe-no-scrollbar::-webkit-scrollbar {
|
||||
display: none;
|
||||
}
|
||||
```
|
||||
|
||||
For same-origin iframes, inject scrollbar-hiding CSS into `iframe.contentDocument`:
|
||||
```javascript
|
||||
iframe.onload = () => {
|
||||
try {
|
||||
const style = iframe.contentDocument.createElement('style')
|
||||
style.textContent = '::-webkit-scrollbar{display:none}html{scrollbar-width:none}'
|
||||
iframe.contentDocument.head.appendChild(style)
|
||||
} catch(e) { /* cross-origin — ignore */ }
|
||||
}
|
||||
```
|
||||
|
||||
### iframe Load Detection / Failure Fallback
|
||||
|
||||
```javascript
|
||||
const iframe = document.querySelector('iframe')
|
||||
let loaded = false
|
||||
|
||||
iframe.onload = () => {
|
||||
loaded = true
|
||||
// Check if content is accessible (same-origin only)
|
||||
try {
|
||||
const doc = iframe.contentDocument
|
||||
if (!doc || !doc.body || doc.body.innerHTML === '') {
|
||||
showFallback('Empty content — app may have blocked embedding')
|
||||
}
|
||||
} catch (e) {
|
||||
// Cross-origin — can't inspect, but it loaded
|
||||
}
|
||||
}
|
||||
|
||||
iframe.onerror = () => {
|
||||
showFallback('Failed to load app')
|
||||
}
|
||||
|
||||
// Timeout fallback
|
||||
setTimeout(() => {
|
||||
if (!loaded) showFallback('App took too long to load')
|
||||
}, 15000)
|
||||
```
|
||||
|
||||
### Clipboard Access
|
||||
|
||||
```html
|
||||
<iframe allow="clipboard-read; clipboard-write" src="..."></iframe>
|
||||
```
|
||||
Also requires `sandbox="allow-same-origin"` if sandboxed. Modern browsers (Chrome 126+) require user gesture.
|
||||
|
||||
### Fullscreen
|
||||
|
||||
```html
|
||||
<iframe allow="fullscreen" allowfullscreen src="..."></iframe>
|
||||
```
|
||||
Both attributes for maximum compatibility.
|
||||
|
||||
### Camera / Microphone
|
||||
|
||||
```html
|
||||
<iframe allow="camera; microphone" src="..."></iframe>
|
||||
```
|
||||
Browser still shows permission prompt to user.
|
||||
|
||||
---
|
||||
|
||||
## 10. Script Injection into Proxied iframes
|
||||
|
||||
To add functionality to apps you don't control, inject scripts via Nginx `sub_filter`:
|
||||
|
||||
```nginx
|
||||
location /app/{app-id}/ {
|
||||
proxy_pass http://localhost:{PORT}/;
|
||||
|
||||
sub_filter '</head>' '<script src="/nostr-provider.js"></script></head>';
|
||||
sub_filter_once on;
|
||||
sub_filter_types text/html;
|
||||
|
||||
# Required: disable upstream compression
|
||||
proxy_set_header Accept-Encoding "";
|
||||
}
|
||||
```
|
||||
|
||||
**Use cases:**
|
||||
- Injecting a postMessage bridge (e.g., NIP-07 Nostr provider)
|
||||
- Adding resize reporting scripts
|
||||
- Injecting theme CSS
|
||||
- Adding custom error handlers
|
||||
|
||||
**Safety rules:**
|
||||
- Only inject into `text/html` responses
|
||||
- Inject before `</head>` or after `<body>` — never in the middle of content
|
||||
- The injected script should check `if (window === window.top) return` to only activate inside iframes
|
||||
- Use `sub_filter_once on` to prevent double-injection
|
||||
|
||||
---
|
||||
|
||||
## 11. Performance Considerations
|
||||
|
||||
### iframe Resource Impact
|
||||
|
||||
Each iframe creates:
|
||||
- Separate browsing context (DOM, CSS engine, JS runtime)
|
||||
- 10-50MB memory per iframe depending on app complexity
|
||||
- Own JavaScript execution on main thread
|
||||
|
||||
### Mitigation
|
||||
|
||||
- Only load visible iframes (`loading="lazy"` or Intersection Observer)
|
||||
- Destroy iframes when hidden (remove from DOM, not just `display:none`)
|
||||
- Use `about:blank` for pre-created iframe elements, set real src when needed
|
||||
- Limit concurrent iframes to 3-5 for acceptable performance
|
||||
- Consider `credentialless` for public content (lighter weight)
|
||||
|
||||
### Caching
|
||||
|
||||
- iframes follow standard HTTP caching (Cache-Control, ETag)
|
||||
- Setting `src` to the same URL does NOT trigger reload
|
||||
- To force reload: append query param (`?t=${Date.now()}`) or call `iframe.contentWindow.location.reload()` (same-origin only)
|
||||
|
||||
---
|
||||
|
||||
## 12. Debugging Checklist
|
||||
|
||||
When an app doesn't work in an iframe, check in this order:
|
||||
|
||||
1. **Check response headers:**
|
||||
```bash
|
||||
curl -sI http://localhost:{PORT} | grep -iE 'x-frame|content-security|cross-origin'
|
||||
```
|
||||
|
||||
2. **Check if Nginx is stripping headers:**
|
||||
```bash
|
||||
curl -sI http://{node-ip}/app/{id}/ | grep -iE 'x-frame|content-security'
|
||||
```
|
||||
|
||||
3. **Check browser console** for:
|
||||
- "Refused to display in a frame" → XFO or frame-ancestors blocking
|
||||
- "Mixed Content" → HTTP iframe on HTTPS page
|
||||
- "WebSocket connection failed" → Missing WebSocket proxy config
|
||||
- "net::ERR_BLOCKED_BY_RESPONSE" → COEP/CORP/COOP headers blocking
|
||||
|
||||
4. **Check if app has JavaScript frame-busting:**
|
||||
- Open the app directly, view source, search for `window.top`, `window.parent`, `frameElement`
|
||||
|
||||
5. **Check if cookies/auth work:**
|
||||
- Open DevTools → Application → Cookies in the iframe context
|
||||
- Look for blocked cookies (yellow warning triangle)
|
||||
|
||||
6. **Check base path issues:**
|
||||
- DevTools → Network tab → look for 404s on CSS/JS/API requests
|
||||
- If assets load from `/` instead of `/app/{id}/`, the app needs base path config
|
||||
|
||||
7. **Check WebSocket connections:**
|
||||
- DevTools → Network → WS tab → check if WebSocket connections upgrade successfully
|
||||
|
||||
---
|
||||
|
||||
## 13. Archipelago-Specific Patterns
|
||||
|
||||
### Port-to-Proxy Mapping
|
||||
|
||||
The `appLauncher.ts` store maintains `PORT_TO_PROXY` mapping: direct ports → `/app/{name}/` paths. When running on HTTPS, direct HTTP port URLs are rewritten to same-origin proxy paths via `toEmbeddableUrl()`.
|
||||
|
||||
### mustOpenInNewTab Detection
|
||||
|
||||
Apps that cannot work in iframes are listed in `IFRAME_BLOCKED_HOSTS` (external sites) and port-based checks (local apps with unstrippable restrictions). These automatically open in a new browser tab.
|
||||
|
||||
### Nostr Provider Injection
|
||||
|
||||
All proxied apps receive `/nostr-provider.js` via `sub_filter` injection. This provides `window.nostr` (NIP-07) inside iframes, allowing apps to request signing, key access, and encryption from the parent portal without exposing secret keys.
|
||||
|
||||
### Identity Protocol
|
||||
|
||||
Identity-aware apps (IndeedHub) receive user identity via `archipelago:identity` postMessage after an identity picker modal. Identity includes DID, pubkey, npub, and a signed challenge for verification.
|
||||
|
||||
### Payment Protocol
|
||||
|
||||
Apps can request Bitcoin payments via `archipelago:payment-request` postMessage. The parent validates, shows a confirmation modal, executes the payment (ecash/LN/on-chain based on amount), and responds with a receipt.
|
||||
|
||||
### iframe Load Fallback
|
||||
|
||||
If an iframe fails to load within 15 seconds or loads empty content, a fallback UI is shown with a "Can't display in frame" message and an "Open in new tab" button.
|
||||
|
||||
---
|
||||
|
||||
## Decision Framework
|
||||
|
||||
When adding a new app to Archipelago:
|
||||
|
||||
```
|
||||
1. Does the app set X-Frame-Options or CSP frame-ancestors?
|
||||
├── No → iframe via /app/{id}/ proxy, done
|
||||
└── Yes →
|
||||
2. Can you strip headers at Nginx?
|
||||
├── Yes, and app works → iframe via /app/{id}/ proxy
|
||||
└── App still broken after stripping →
|
||||
3. Does the app have JavaScript frame-busting?
|
||||
├── Yes → Open in new tab (add to mustOpenInNewTab)
|
||||
└── No →
|
||||
4. Is it a base path issue?
|
||||
├── Yes → Configure app's native base path or use sub_filter
|
||||
└── No →
|
||||
5. Is it a WebSocket issue?
|
||||
├── Yes → Add WebSocket proxy config
|
||||
└── No →
|
||||
6. Is it a cookie/auth issue?
|
||||
├── Yes → Same-origin proxy should fix it
|
||||
└── No → Debug with browser DevTools, check console errors
|
||||
```
|
||||
@@ -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,40 +0,0 @@
|
||||
# Archipelago Project Memory Index
|
||||
|
||||
## Setup & Architecture
|
||||
- [claude-proxy-setup.md](claude-proxy-setup.md) — Claude proxy OAuth setup details
|
||||
- [deploy-automation.md](deploy-automation.md) — Deploy script automation TODOs (API key, AIUI nginx, swap)
|
||||
|
||||
## Servers & Deploy
|
||||
- [project_environments.md](project_environments.md) — Four environments: dev mode, dev server/prod, demo
|
||||
- [tailscale_servers.md](tailscale_servers.md) — Tailscale server details (archipelago-2, archipelago-3)
|
||||
- [reference_tailscale_nodes.md](reference_tailscale_nodes.md) — All node IPs and SSH commands
|
||||
- [second-server.md](second-server.md) — Second dev server (archipelago-2 via Tailscale)
|
||||
- [third-server.md](third-server.md) — Third dev server (archipelago-3 via Tailscale)
|
||||
|
||||
## Features & Plans
|
||||
- [pending-features.md](pending-features.md) — Feature requests: kiosk mode, sideloading, Nostr login, etc.
|
||||
- [project-plan.md](project-plan.md) — Overall project plan status
|
||||
- [web-only-apps.md](web-only-apps.md) — Web-only apps (L484 category) and iframe compatibility
|
||||
|
||||
## User Feedback
|
||||
- [feedback_app_display_modes.md](feedback_app_display_modes.md) — App browser: 3 display modes with persistent setting
|
||||
- [feedback_fullscreen_modals.md](feedback_fullscreen_modals.md) — Fullscreen modal preferences
|
||||
- [feedback_local_dev.md](feedback_local_dev.md) — Local dev: use `cd neode-ui && ./start-dev.sh`
|
||||
- [feedback_apps_always_direct_port.md](feedback_apps_always_direct_port.md) — Apps MUST open at direct port, NEVER proxy paths
|
||||
- [feedback_indeedhub_nginx_ips.md](feedback_indeedhub_nginx_ips.md) — IndeedHub nginx must use hardcoded container IPs
|
||||
- [feedback_searxng_no_cap_drop.md](feedback_searxng_no_cap_drop.md) — SearXNG: no cap-drop ALL
|
||||
|
||||
## ISO Build
|
||||
- [iso-build-session-2026-03-10.md](iso-build-session-2026-03-10.md) — ISO build session notes
|
||||
- [unbundled-iso.md](unbundled-iso.md) — Unbundled ISO approach notes
|
||||
|
||||
## Infrastructure
|
||||
- [project_bitcoin_rpc_auth.md](project_bitcoin_rpc_auth.md) — Bitcoin rpcauth, system Tor, reboot survival, container resilience
|
||||
|
||||
## Deploy & Container Fixes
|
||||
- [project_deploy_session_2026_03_22.md](project_deploy_session_2026_03_22.md) — Fleet deploy fixes: credential mismatches, restart storms, rootless port 80, deploy script hardening
|
||||
|
||||
## Completed Work
|
||||
- [project_mesh_198_issue.md](project_mesh_198_issue.md) — Mesh .198: 3 bugs fixed and deployed
|
||||
- [project_indeedhub_arch3_fix.md](project_indeedhub_arch3_fix.md) — IndeedHub Arch 3: corrupted combined tarball fixed
|
||||
- [project_demo_deploy.md](project_demo_deploy.md) — Demo prod deployment via Portainer
|
||||
@@ -1,9 +0,0 @@
|
||||
---
|
||||
name: claude-proxy-oauth-primary-only
|
||||
description: Only the primary server (192.168.1.228) uses OAuth proxy for Claude. Second server uses standard API key.
|
||||
type: feedback
|
||||
---
|
||||
|
||||
- Only the primary dev server (192.168.1.228) uses the Claude Max OAuth proxy for AIUI
|
||||
- The second dev server and any other deployments should use a standard Anthropic API key (`ANTHROPIC_API_KEY` env var)
|
||||
- Do not replicate the OAuth proxy setup to other servers
|
||||
@@ -1,18 +0,0 @@
|
||||
---
|
||||
name: deploy-automation-todos
|
||||
description: Items to incorporate into the deploy script for automated setup — Claude API key, AIUI nginx config, swap
|
||||
type: project
|
||||
---
|
||||
|
||||
## Deploy Script Automation TODOs
|
||||
|
||||
These items should be added to `scripts/deploy-to-target.sh` so new server deploys are self-contained:
|
||||
|
||||
1. **Claude API key setup**: After deploying the proxy, auto-configure `/opt/archipelago/claude-proxy/.env.local` with the `ANTHROPIC_API_KEY`. On non-primary servers, use the standard API key (not OAuth). The key is: `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`
|
||||
|
||||
2. **AIUI nginx location block**: Ensure nginx config includes a proper `/aiui/` location block so static JS/CSS files are served with correct MIME types. Without this, AIUI fails to load modules.
|
||||
|
||||
3. **Swap space**: Deploy script should check for swap and create 4GB if missing (`fallocate -l 4G /swapfile && mkswap && swapon + fstab entry`).
|
||||
|
||||
4. **Primary server (192.168.1.228)**: 4GB swap configured on 2026-03-11.
|
||||
5. **Second server (archipelago-2)**: 4GB swap configured on 2026-03-11.
|
||||
@@ -1,15 +0,0 @@
|
||||
---
|
||||
name: App display modes
|
||||
description: App session browser should support 3 display modes - right panel, full overlay, and fullscreen - with a persistent setting
|
||||
type: feedback
|
||||
---
|
||||
|
||||
App session views (the built-in browser for launching apps) should support three display modes, controlled by a setting dropdown in the header bar:
|
||||
|
||||
1. **Display in right panel** — app loads inside the dashboard's right content area (sidebar visible)
|
||||
2. **Display over whole app** — app overlays the entire viewport including sidebar (like old AppLauncherOverlay with `fixed inset-0 z-[2400]`)
|
||||
3. **Open fullscreen** — uses browser Fullscreen API for true fullscreen
|
||||
|
||||
**Why:** The user likes the right-panel approach (screenshot showed it working well) but also wants the option to go full overlay or fullscreen. The setting should persist (localStorage) and apply to all apps globally.
|
||||
|
||||
**How to apply:** Store the preference in localStorage. The header bar should have a dropdown/toggle with icons for the three modes. Default to "right panel" mode.
|
||||
@@ -1,35 +0,0 @@
|
||||
---
|
||||
name: Apps MUST open at direct port — NEVER proxy paths
|
||||
description: CRITICAL — All apps in iframes must open at their direct port (http(s)://{host}:{port}), NEVER through /app/{id}/ proxy paths. This is the #1 cause of broken app loading across all nodes.
|
||||
type: feedback
|
||||
---
|
||||
|
||||
## CRITICAL RULE: Apps load at DIRECT PORT, never proxy paths
|
||||
|
||||
All Archipelago apps that open in iframes MUST use the direct port URL:
|
||||
```
|
||||
{protocol}://{hostname}:{port}
|
||||
```
|
||||
|
||||
**NEVER** use path-based proxy URLs like `/app/indeedhub/` or `/app/mempool/` for iframe loading. Path proxies break apps because:
|
||||
1. The main nginx SPA catch-all serves the Archipelago dashboard instead of the app
|
||||
2. sub_filter URL rewrites break client-side routing in Vue/React apps
|
||||
3. Different nodes have different nginx configs — path proxies are unreliable
|
||||
|
||||
**Why:** This was broken THREE TIMES in one session (2026-03-17). Every time the iframe URL used a proxy path instead of the direct port, the app showed the Archipelago dashboard or a blank page. .228 and .198 work correctly because they use HTTP which naturally hits the direct port. Tailscale nodes use HTTPS which was falling through to the proxy path.
|
||||
|
||||
**How to apply:**
|
||||
- In `AppSession.vue`, apps like IndeedHub must ALWAYS construct `{protocol}://{hostname}:{port}` — even on HTTPS
|
||||
- The `HTTPS_PROXY_PATHS` mapping should NOT include apps that have X-Frame-Options removed (like IndeedHub)
|
||||
- When adding new apps: use PORT_APPS for the port mapping, do NOT add to HTTPS_PROXY_PATHS unless absolutely necessary
|
||||
- The deploy script removes X-Frame-Options from IndeedHub's internal nginx, enabling direct port iframe access
|
||||
|
||||
**Also critical for IndeedHub specifically:**
|
||||
- IndeedHub nginx MUST use hardcoded container IPs (not DNS names) — see feedback_indeedhub_nginx_ips.md
|
||||
- nostr-provider.js must be injected via sub_filter in the IndeedHub internal nginx
|
||||
- SearXNG must NOT use --cap-drop ALL — see feedback_searxng_no_cap_drop.md
|
||||
|
||||
**When recreating containers:**
|
||||
- NEVER recreate containers without reapplying ALL patches (X-Frame-Options removal, nostr-provider injection, IP hardcoding)
|
||||
- After any container IP change (restart, recreation), update the hardcoded IPs in IndeedHub's nginx config
|
||||
- Deploy the SAME frontend build to ALL nodes — version mismatch causes different behavior
|
||||
@@ -1,11 +0,0 @@
|
||||
---
|
||||
name: Full-screen modals
|
||||
description: App session modals and overlays must cover the full viewport, not just the right panel area of the dashboard
|
||||
type: feedback
|
||||
---
|
||||
|
||||
Modals and app session overlays must be **full screen** — covering the entire viewport including the sidebar/nav. Do NOT constrain them to just the right content panel of the dashboard layout.
|
||||
|
||||
**Why:** The user has corrected this multiple times. Modals that only cover the right panel look wrong and don't provide an immersive app experience.
|
||||
|
||||
**How to apply:** When creating overlays, modals, or app session views, use `position: fixed; inset: 0; z-index: 2400+` to cover the entire screen. The existing AppLauncherOverlay already does this correctly with `class="fixed inset-0 z-[2400]"` — follow that pattern. On mobile it should be truly fullscreen (no padding/margins). On desktop, the glass panel with margins (md:p-10, md:rounded-2xl) is fine.
|
||||
@@ -1,17 +0,0 @@
|
||||
---
|
||||
name: IndeedHub nginx must use hardcoded container IPs
|
||||
description: IndeedHub's internal nginx config MUST use hardcoded container IPs, not DNS names — podman DNS resolver 127.0.0.11 is unreliable and causes 502 errors
|
||||
type: feedback
|
||||
---
|
||||
|
||||
IndeedHub's internal nginx config MUST use hardcoded container IPs (e.g., `http://10.89.2.11:4000`) for API, MinIO, and relay upstreams — NEVER DNS names like `http://api:4000` with `resolver 127.0.0.11`.
|
||||
|
||||
**Why:** Podman's embedded DNS resolver (127.0.0.11) is unreliable inside containers. Using DNS names with `resolver 127.0.0.11 valid=30s ipv6=off;` causes nginx to return 502 Bad Gateway errors. This broke IndeedHub on all Tailscale nodes — frontend loaded but API calls failed.
|
||||
|
||||
**How to apply:** After creating/recreating IndeedHub containers, the deploy script must:
|
||||
1. Look up container IPs: `podman inspect indeedhub-build_api_1 --format "{{range .NetworkSettings.Networks}}{{.IPAddress}}{{end}}"`
|
||||
2. Patch the nginx config inside the container to replace DNS names with IPs
|
||||
3. Also patch `$host` → `$http_host` in proxy_set_header Host directives
|
||||
4. Reload nginx inside the container
|
||||
|
||||
This is now handled in `deploy-to-target.sh` and `fix-indeedhub-containers.sh`.
|
||||
@@ -1,15 +0,0 @@
|
||||
---
|
||||
name: Local Frontend Dev Workflow
|
||||
description: How to start the local frontend dev environment — use start-dev.sh from neode-ui/, NOT npm start from root
|
||||
type: feedback
|
||||
---
|
||||
|
||||
Run local frontend dev from `neode-ui/` directory: `./start-dev.sh` (NOT `npm start` from project root — there's no root package.json).
|
||||
|
||||
**Why:** The project root has no package.json. Running `npm start` there fails with ENOENT. The frontend dev script lives in `neode-ui/start-dev.sh`.
|
||||
|
||||
**How to apply:**
|
||||
- `cd neode-ui && ./start-dev.sh` — clears ports, starts Docker apps, runs `npm run dev:mock` (mock backend on :5959, Vite on :8100)
|
||||
- Stop with `./stop-dev.sh` or Ctrl+C
|
||||
- Login password in dev mode: `password123`
|
||||
- When telling the user how to test locally, always reference `cd neode-ui && ./start-dev.sh`
|
||||
@@ -1,15 +0,0 @@
|
||||
---
|
||||
name: SearXNG must NOT use --cap-drop ALL
|
||||
description: SearXNG container needs write access to /etc/searxng/ for settings.yml — cap-drop ALL causes Permission denied and exit 127
|
||||
type: feedback
|
||||
---
|
||||
|
||||
Do NOT use `--cap-drop ALL` or `--security-opt no-new-privileges:true` when creating the SearXNG container. SearXNG needs to create `/etc/searxng/settings.yml` on first run.
|
||||
|
||||
**Why:** SearXNG's entrypoint creates a settings file from a template. With `--cap-drop ALL`, it gets "Permission denied: can't create '/etc/searxng/settings.yml'" and exits with code 127. The .228 reference server runs SearXNG with default capabilities (only drops CAP_AUDIT_WRITE, CAP_MKNOD, CAP_NET_RAW).
|
||||
|
||||
**How to apply:** When creating SearXNG containers, use:
|
||||
```bash
|
||||
sudo podman run -d --name searxng --restart unless-stopped -p 8888:8080 docker.io/searxng/searxng:latest
|
||||
```
|
||||
No `--cap-drop ALL`, no `--security-opt no-new-privileges:true`.
|
||||
@@ -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,26 +0,0 @@
|
||||
---
|
||||
name: pending-ui-features
|
||||
description: Feature requests — completed and pending items for the next deployment cycle
|
||||
type: project
|
||||
---
|
||||
|
||||
## Completed (2026-03-11)
|
||||
|
||||
1. **IndieHub in iframe** — Restored. Removed forced new-tab check in `mustOpenInNewTab()`.
|
||||
2. **App uninstall fix** — Backend now logs errors and returns structured response instead of silently swallowing.
|
||||
3. **Login music stops after auth** — Added `stopAllAudio()` + router afterEach guard.
|
||||
4. **Container scanner dev_mode gate removed** — Scanner runs always now.
|
||||
5. **BotFights app** — Added as web-only app with SVG icon. Opens in new tab (X-Frame-Options blocks iframe).
|
||||
6. **L484 web apps** — Added 6 web-only apps: NWNN, 484 Kitchen, Call the Operator, Arch Presentation, Syntropy Institute, T-0. L484 category in marketplace.
|
||||
7. **Kiosk mode** — `/kiosk` route added, `setup-kiosk.sh` installs systemd service, systemd units in image-recipe/configs/. No full-screen iframe overlay — uses standard appLauncher.
|
||||
8. **AIUI first-install fix** — nginx `try_files` changed to `=404`, Chat.vue probes AIUI availability before loading iframe.
|
||||
9. **Web-only apps in My Apps** — Injected synthetic PackageDataEntry objects in Apps.vue. Web-only apps sorted first (alphabetically before container apps). No uninstall/start/stop buttons. Launch uses appLauncher with correct URLs.
|
||||
|
||||
## Pending
|
||||
|
||||
1. **Nostr NIP-07 login for containers** — Sign into container apps using onboarding Nostr keys. Not started.
|
||||
2. **App sideloading** — Settings page to load apps via Docker/OCI image URL. Not started.
|
||||
3. **Encrypted Nostr peer handshake (NIP-04/NIP-44)** — Exchange Tor onion addresses via encrypted DMs instead of public relay events. Not started. Currently onion addresses are published in plaintext on relays.
|
||||
4. **Third server deploy** — archipelago-3.tail2b6225.ts.net needs SSH key setup and first deploy.
|
||||
5. **Kiosk auto-start on servers** — setup-kiosk.sh exists but needs to be run on each server that has a display attached. Not confirmed running on .228.
|
||||
6. **Deploy to .198** — Secondary server not yet deployed with latest changes.
|
||||
@@ -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,21 +0,0 @@
|
||||
---
|
||||
name: Bitcoin RPC rpcauth architecture
|
||||
description: Bitcoin uses rpcauth (salted hash in config, password in secrets file), system Tor for containers, reboot survival
|
||||
type: project
|
||||
---
|
||||
|
||||
Bitcoin RPC uses `rpcauth` — salted HMAC-SHA256 hash in bitcoin.conf, plaintext password in `/var/lib/archipelago/secrets/bitcoin-rpc-password`. Credentials are STABLE across reboots, restarts, deploys.
|
||||
|
||||
**Why:** Cookie auth rotates on every Bitcoin restart, breaking all dependent containers with env-var-only credentials. The `rpcauth` approach keeps the password stable while never exposing plaintext in config files or CLI args.
|
||||
|
||||
**How to apply:**
|
||||
- Bitcoin: reads rpcauth from bitcoin.conf (no CLI credential flags, config generated by first-boot or deploy)
|
||||
- LND: `bitcoind.rpcuser/rpcpass` in lnd.conf (NOT rpccookie — LND v0.18.4 doesn't support it)
|
||||
- All containers: read password from secrets file at creation time, passed via env vars
|
||||
- Rust backend `bitcoin_rpc.rs`: reads from secrets file, cached with OnceCell
|
||||
- bitcoin-ui: mounts `/var/lib/archipelago/secrets:/secrets:ro`, start.sh reads password and injects nginx auth header
|
||||
- System Tor: `SocksPort 0.0.0.0:9050` + SocksPolicy, containers use `host.containers.internal:9050`
|
||||
- `podman-restart.service` enabled for container auto-start after reboot
|
||||
- Tor hidden service hostnames copied to `/var/lib/archipelago/tor-hostnames/` for readable access
|
||||
- .198 ElectrumX points at .228's full Bitcoin node (pruned node can't run ElectrumX locally)
|
||||
- Health monitor interval: 60 seconds — UI may briefly show "crashed" during restarts
|
||||
@@ -1,62 +0,0 @@
|
||||
---
|
||||
name: Demo Deploy Status
|
||||
description: Status and details of the demo prod server deployment via Portainer Stacks from Gitea repos
|
||||
type: project
|
||||
---
|
||||
|
||||
## Demo Prod Deployment — In Progress (2026-03-17)
|
||||
|
||||
### Two Separate Portainer Stacks
|
||||
|
||||
**1. IndeedHub** — DEPLOYED SUCCESSFULLY on :7755
|
||||
- Repo: `https://git.tx1138.com/lfg2025/indee-demo`
|
||||
- Compose: `docker-compose.yml` (root)
|
||||
- Env vars loaded from `.env.portainer` — update DOMAIN, FRONTEND_URL, S3_PUBLIC_BUCKET_URL
|
||||
- APP_PORT defaulted to 7755 (changed from 7777 to avoid conflicts)
|
||||
- Healthcheck fix: pg_isready uses `${POSTGRES_USER}` env var (was hardcoded)
|
||||
- Full 7-service stack: app, api, postgres, redis, minio, minio-init, relay, ffmpeg-worker
|
||||
- Nostr auth is built-in (NIP-98) — users sign in with browser extension (Alby, nos2x)
|
||||
|
||||
**2. Archipelago** — DEPLOYING (last attempt pending)
|
||||
- Repo: `https://git.tx1138.com/lfg2025/archy-demo`
|
||||
- Compose: `docker-compose.demo.yml`
|
||||
- Env vars: `ANTHROPIC_API_KEY` for Claude chat
|
||||
- Port: 4848
|
||||
- Pre-built frontend in `web-dist/` (built locally on Mac, no server-side build)
|
||||
- Backend: `neode-ui/Dockerfile.backend` (Node mock backend on :5959)
|
||||
- Web: `neode-ui/Dockerfile.web` (nginx serving pre-built static files)
|
||||
|
||||
### Issues Resolved So Far
|
||||
- IndeedHub postgres healthcheck hardcoded username → fixed to use env var
|
||||
- Port 7777 conflict → changed to 7755
|
||||
- Archy repo too large (8GB) for Portainer clone → created lightweight `archy-demo` repo
|
||||
- Frontend build failing on server → switched to pre-built static files (no npm/vite on server)
|
||||
- `.dockerignore` blocking `neode-ui/dist` → moved to `web-dist/` at repo root
|
||||
- Docker build cache stale → moved dist outside neode-ui to avoid gitignore conflicts
|
||||
|
||||
### Current Blocker
|
||||
- Last deploy attempt: Docker build cache may still be referencing old paths
|
||||
- If still failing: need to prune Docker build cache on server (`docker builder prune`)
|
||||
|
||||
### Frontend Changes Made
|
||||
- `Apps.vue` and `AppDetails.vue`: IndeedHub removed from WEB_ONLY_APP_URLS (linter change)
|
||||
- IndeedHub will be accessed as a real container or via direct URL to :7755
|
||||
|
||||
### Repo Structure (archy-demo)
|
||||
```
|
||||
archy-demo/
|
||||
├── docker-compose.demo.yml
|
||||
├── .dockerignore
|
||||
├── web-dist/ ← pre-built Vue frontend (from local Mac build)
|
||||
├── demo/aiui/ ← pre-built AIUI chat app
|
||||
└── neode-ui/ ← source + mock backend + docker configs
|
||||
├── Dockerfile.web ← nginx + copy web-dist (no build)
|
||||
├── Dockerfile.backend ← Node mock backend
|
||||
├── docker/nginx-demo.conf
|
||||
├── docker/docker-entrypoint.sh
|
||||
├── mock-backend.js
|
||||
└── src/...
|
||||
```
|
||||
|
||||
**Why:** Demo for showcasing Archipelago + IndeedHub together. Needs to be functional with nostr signing.
|
||||
**How to apply:** When resuming, check if Portainer deploy succeeded. If not, may need to SSH to prune Docker cache or debug further.
|
||||
@@ -1,98 +0,0 @@
|
||||
---
|
||||
name: Deploy session 2026-03-22 findings
|
||||
description: Comprehensive deploy/build fixes made overnight — container issues, image tags, script improvements, remaining work
|
||||
type: project
|
||||
---
|
||||
|
||||
## Session Summary (2026-03-22 overnight)
|
||||
|
||||
Massive deploy infrastructure overhaul across all 5 nodes (.228, .198, Arch 1/2/3).
|
||||
|
||||
### Fixed in deploy-tailscale.sh
|
||||
- **Image tags**: Bitcoin Knots `28.1` (not `v28.1`), BTCPay `1.13.7` (not `1.14.5`), SearXNG `2026.3.20-6c7e9c197`
|
||||
- **Removed Immich** (3 containers) and **Penpot** (5 containers) from deploy + build
|
||||
- **Fedimint**: `FM_REL_NOTES_ACK=0_4_xyz` env var (NOT `FM_SKIP_REL_NOTES_ACK` or `FM_REQ_RELEASE_NOTES_ACK_V0_4`)
|
||||
- **Fedimint-gateway**: `--password` instead of `--bcrypt-password-hash` (v0.5.1 CLI change)
|
||||
- **FileBrowser**: added `--cap-add NET_BIND_SERVICE` for port 80 binding
|
||||
- **SearXNG**: added `/var/lib/archipelago/searxng:/etc/searxng` volume mount + caps
|
||||
- **Postgres**: pinned to `postgres:15` (data initialized with 15, incompatible with 16)
|
||||
- **Migration**: one-time flag file `/var/lib/archipelago/.rootless-migrated`
|
||||
- **Recreate-if-broken pattern**: containers that exist but are stopped get deleted and recreated
|
||||
- **Arch 2 hostname**: fixed from hardcoded hostname to `$TAILSCALE_ARCH2`
|
||||
- **Custom UI images**: graceful skip if not available, source extracted to repo (`docker/bitcoin-ui/`, `docker/electrs-ui/`)
|
||||
- **AIUI tar xattr**: silenced with `--no-xattrs` (only in deploy-tailscale.sh, NOT deploy-to-target.sh yet)
|
||||
- **Nginx MIME warning**: removed `text/html` from `sub_filter_types`
|
||||
|
||||
### Added
|
||||
- `--fleet` flag in deploy-to-target.sh: deploys .228 → .198 → Arch 1/2/3
|
||||
- `--both` lock fix: releases lock before recursive `--live` call
|
||||
- Container verification step (Step 26b): restarts exited containers, fixes permissions, checks Tor
|
||||
- IndeedHub backend stack rebuilt on .228 (7 containers)
|
||||
- IndeedHub nginx patched with direct IPs (podman DNS doesn't work with nginx resolver)
|
||||
|
||||
### Frontend changes
|
||||
- Replaced Immich with FileBrowser on Setup homescreen (`goals.ts`, `EasyHome.vue`)
|
||||
- `MEMPOOL_API_IMAGE` renamed to `MEMPOOL_BACKEND_IMAGE` in image-versions.sh
|
||||
- Nextcloud downgraded from 30 to 29 (one major version upgrade at a time)
|
||||
|
||||
### Session 2 fixes (same day)
|
||||
|
||||
**Critical pattern found: Container credential mismatches**
|
||||
- Deploy generates random passwords stored in `secrets/`. MariaDB/Postgres only use env vars on FIRST init — subsequent restarts ignore them. Container recreation with new passwords → auth failures → crash loops.
|
||||
- 50,000+ cumulative container restarts across fleet from this single root cause.
|
||||
|
||||
**Fixes applied to all nodes:**
|
||||
1. LND: `lnd.conf` rpcpass synced from `secrets/bitcoin-rpc-password` (was hardcoded `archipelago123`)
|
||||
2. MariaDB mempool: data dirs wiped + reinitialized (password mismatch unrecoverable)
|
||||
3. BTCPay Postgres: `ALTER USER` to sync password with secrets
|
||||
4. FileBrowser: `--user 0:0` instead of `--cap-add NET_BIND_SERVICE` (rootless port 80 fix)
|
||||
5. Nextcloud: same `--user 0:0` fix
|
||||
6. Tailscale container on .228: removed (2,685 restarts — unauthenticated, host already has TS)
|
||||
|
||||
**Deploy script fixes:**
|
||||
- `deploy-tailscale.sh`: LND config always synced before start, `eval "$DB_PASSWORDS"` → safe individual reads, MariaDB password sync step, filebrowser `--user 0:0`
|
||||
- `deploy-to-target.sh`: LND stale config check now compares passwords (not just cookie/localhost), filebrowser `--user 0:0`
|
||||
|
||||
**Rootless port 80 rule**: Containers binding port 80 MUST use `--user 0:0`. `NET_BIND_SERVICE` cap doesn't work in rootless (UID 0 → host 100000, unprivileged).
|
||||
|
||||
### Session 3 fixes (2026-03-22 to 2026-03-24)
|
||||
|
||||
**Additional container fixes applied live:**
|
||||
- PhotoPrism: recreated with proper `/photoprism/storage`, `/photoprism/originals`, `/photoprism/import` volume mounts (all 3 nodes)
|
||||
- Vaultwarden/Jellyfin: recreated with `--user 0:0` + health checks (Arch 1/2)
|
||||
- Nextcloud: downgraded image to v29 (data initialized with v28, can't skip to v30)
|
||||
- Fedimint: upgraded v0.5.1 → v0.10.0 on all Tailscale nodes
|
||||
- Fedimint-gateway: bcrypt hash passed via file mount (shell escaping workaround)
|
||||
- SearXNG: recreated with proper caps on Arch 2
|
||||
- Arch 3 right-sized: stopped immich (3), jellyfin, vaultwarden, nbxplorer (7.3GB RAM)
|
||||
|
||||
**Deploy script improvements (6 commits pushed):**
|
||||
1. `d37165ca` — Credential sync, health checks, rootless port binding
|
||||
2. `f5714a5b` — Fleet deploy falls back to Tailscale when LAN unreachable, `--all` alias
|
||||
3. `028248df` — Suppress tar xattr spam in AIUI deploy (`--no-xattrs`)
|
||||
4. `f5802f9e` — Fix LND config SSH escaping, Tailscale fallback for BUILD_SOURCE
|
||||
5. `06d85e1d` — Fix health check escaping for SSH heredoc (`--health-cmd 'cmd'` not `"cmd"`)
|
||||
6. `a7920de8` — Correct health check endpoints (fedimint→8175, nextcloud→`/`, filebrowser→`/`)
|
||||
|
||||
**Health checks added to deploy-tailscale.sh:**
|
||||
- 25 containers now have `--health-cmd` in deploy-tailscale.sh (was zero)
|
||||
- Key corrections: fedimint checks port 8175 (UI) not 8174 (websocket), nextcloud/filebrowser check `/` not custom endpoints
|
||||
|
||||
**Fleet status at end of session:**
|
||||
|
||||
| Node | Status | Notes |
|
||||
|------|--------|-------|
|
||||
| .228 | 36/36, 0 unhealthy, load 1.0 | Fully stable |
|
||||
| Arch 1 | 25/25, 0 unhealthy, load 0.5 | Fully stable |
|
||||
| Arch 2 | 25/25, 0 unhealthy, load 0.2 | Fully stable |
|
||||
| Arch 3 | 24/28, 0 unhealthy, load 7.7 | Right-sized for 7.3GB RAM, Bitcoin IBD at 97.8% |
|
||||
| .198 | Bitcoin chain data empty (4KB) | Needs full IBD — will take days. Not pruned. |
|
||||
|
||||
### Remaining for next session
|
||||
- **.198**: Bitcoin doing full IBD from scratch (chain data was lost/empty). No prune flag set. Will take days.
|
||||
- **Arch 3**: Bitcoin IBD was at 97.8% — check if complete, then start LND/nbxplorer
|
||||
- **Tor config Python syntax errors** in deploy-to-target.sh step 33 (cosmetic, falls back to system Tor)
|
||||
- **deploy-to-target.sh** still missing health checks (only deploy-tailscale.sh has them)
|
||||
- **first-boot-containers.sh** needs same rootless fixes (filebrowser `--user 0:0`, credential sync)
|
||||
- **Fedimint guardian setup** not done on any node — all in "Setup UI" mode
|
||||
- User needs to `git pull && ./scripts/deploy-to-target.sh --all` to deploy latest fixes to Tailscale nodes
|
||||
@@ -1,21 +0,0 @@
|
||||
---
|
||||
name: Four Environments
|
||||
description: Clear distinction between dev mode (local mock), dev server (228), demo (Portainer), and prod (same as dev server)
|
||||
type: project
|
||||
---
|
||||
|
||||
Four distinct environments — use correct terminology:
|
||||
|
||||
| Name | What | Where | Backend | Deploy |
|
||||
|------|------|-------|---------|--------|
|
||||
| **Dev mode** | Local macOS, mock backend | `localhost:8100` | `mock-backend.js` on `:5959` | `npm run dev:mock` |
|
||||
| **Dev server / Prod** | Primary build/test/live server | `192.168.1.228` (+ fleet) | Real Rust backend + Podman | `deploy-to-target.sh --live` |
|
||||
| **Demo** | Public demo instance | Remote server | Mock Node.js via Docker | Portainer Stacks / `docker-compose.demo.yml` |
|
||||
|
||||
- Dev server and prod are the SAME machine (192.168.1.228) — "prod" just means "the live deployment"
|
||||
- Demo is completely separate — user deploys via Portainer UI, Claude has no SSH access
|
||||
- Dev mode is local-only, no containers needed, fastest iteration
|
||||
|
||||
**Why:** User corrected ambiguous usage of "dev servers (prod)" — these are the same thing, not two separate environments.
|
||||
|
||||
**How to apply:** Always say "dev mode" for local mock, "dev server" or "prod" for 228, "demo" for the Portainer instance. Never conflate them.
|
||||
@@ -1,33 +0,0 @@
|
||||
---
|
||||
name: IndeedHub Arch 3 Fix — 2026-03-17
|
||||
description: Fixed IndeedHub on Arch 3 (100.124.105.113) — corrupted image tarball was root cause, all 7 containers now running
|
||||
type: project
|
||||
---
|
||||
|
||||
## Status: FIXED and working (verified 2026-03-17)
|
||||
|
||||
IndeedHub on Arch 3 (`100.124.105.113`) is fully operational — all 7 containers running, frontend on :7777, API healthy, NIP-07 nostr-provider injected.
|
||||
|
||||
## Root Cause
|
||||
|
||||
The `/tmp/indeedhub-all-images.tar` on Arch 3 was corrupted — `podman save` with multiple images collapsed ALL 7 images to the same image ID (the frontend nginx image `7222645f0b38`). So redis, minio, API, ffmpeg-worker, postgres, and relay were all running the frontend nginx binary.
|
||||
|
||||
**Why:** `podman save` with multiple images sharing layers can produce broken tarballs where all images get the same config/ID.
|
||||
|
||||
## What Was Done
|
||||
|
||||
1. Removed all broken containers and images
|
||||
2. Pulled fresh standard images from Docker Hub (postgres:16-alpine, redis:7-alpine, minio:latest, nostr-rs-relay:latest)
|
||||
3. Exported each custom image as **individual tarballs** from .228 (NOT combined):
|
||||
- `indeedhub-frontend.tar` (149MB, ID: `7222645f0b38`)
|
||||
- `indeedhub-api.tar` (403MB, ID: `2ae2665fc6c7`)
|
||||
- `indeedhub-ffmpeg.tar` (525MB, ID: `cb05b5cf8c25`)
|
||||
4. Transferred via Mac (`.228` → Mac → Arch 3 over Tailscale)
|
||||
5. Loaded images individually, created all 7 containers manually (bypassed the deploy script's broken `podman load` step)
|
||||
6. Copied nostr-provider.js + nginx config with sub_filter from .228 container into Arch 3 container via `podman cp`
|
||||
|
||||
## Remaining Issue — Deploy Script
|
||||
|
||||
The deploy script at `/tmp/deploy-indeedhub.sh` on Arch 3 still references the broken `/tmp/indeedhub-all-images.tar`. If it's run again it will re-corrupt the images. The individual tarballs (`/tmp/indeedhub-frontend.tar`, `/tmp/indeedhub-api.tar`, `/tmp/indeedhub-ffmpeg.tar`) are on Arch 3 and should be used instead.
|
||||
|
||||
**How to apply:** Next time deploying IndeedHub to any node, always export images individually, never as a combined tarball. Consider updating the deploy script to load individual tarballs.
|
||||
@@ -1,20 +0,0 @@
|
||||
---
|
||||
name: Mesh .198 fix — COMPLETED
|
||||
description: Fixed mesh radio on .198 — duplicate init, no reconnect on write fail, wrong device path. All deployed.
|
||||
type: project
|
||||
---
|
||||
|
||||
## Status: COMPLETED (2026-03-17)
|
||||
|
||||
Three bugs were found and fixed:
|
||||
|
||||
1. **Duplicate mesh init in `server.rs`** — removed duplicate block
|
||||
2. **Serial write failures don't trigger reconnection** — added `consecutive_write_failures` counter, bail after 3
|
||||
3. **Device path on .198** — set `/var/lib/archipelago/mesh-config.json` to `/dev/ttyUSB1`
|
||||
|
||||
All changes deployed to both .228 and .198.
|
||||
|
||||
### Files Changed
|
||||
- `core/archipelago/src/server.rs` — removed duplicate mesh/transport init block
|
||||
- `core/archipelago/src/mesh/listener.rs` — added write failure tracking + reconnection
|
||||
- `neode-ui/src/stores/mesh.ts` — fixed TS union type for `typed_payload`
|
||||
@@ -1,44 +0,0 @@
|
||||
---
|
||||
name: v1.3.0 Session Status (March 20)
|
||||
description: Tor management system, bug fixes, federation name sync — cloud files working both ways
|
||||
type: project
|
||||
---
|
||||
|
||||
## Deployed to .228 + .198
|
||||
|
||||
### What's Live
|
||||
- Full Tor hidden service management (systemd path unit pattern — tor-helper.sh)
|
||||
- Container doctor: system Tor preferred, archy-tor container removed
|
||||
- Federation name sync: server rename pushes to peers
|
||||
- Cloud files working both ways over Tor
|
||||
- Arch channel local echo for sent messages
|
||||
- Web5 Message button → Mesh redirect
|
||||
- Node names in federation/peers
|
||||
- PeerFiles header shows name + DID (not onion)
|
||||
- Connected Nodes flex height
|
||||
- Server name persistence (root-owned file fixed)
|
||||
- Tor services UI: add from installed apps, delete, restart, auth/protocol badges
|
||||
- Layout: Network Interfaces + Tor Services stack on normal screens
|
||||
|
||||
### Architecture: Tor Management
|
||||
- Backend writes staged torrc + action file to /var/lib/archipelago/tor-config/
|
||||
- systemd path unit (archipelago-tor-helper.path) triggers root-level service
|
||||
- tor-helper.sh processes actions: write-torrc-and-restart, restart, delete-service, sync-hostnames
|
||||
- NoNewPrivileges=yes safe — no sudo from backend
|
||||
- Container doctor ensures system Tor stays running after deploys
|
||||
- Web apps: port 80 on .onion → local app port; Protocol services: direct port
|
||||
|
||||
### Onion Addresses (current)
|
||||
- .228 archipelago: r33p5uzk2vxhdte4a5pfqgeax44a7b2lx57q32dxmx5llzyfz42lwnyd.onion
|
||||
- .198 archipelago: mxn62m4odavwctlpsq2ozvhy3ibjpenlzemumwtkev7wviikttxvjhyd.onion
|
||||
|
||||
### Still TODO
|
||||
1. **Tor channel chat** — messages via Archipelago channel need testing/polish
|
||||
2. **ISO build** — update build-auto-installer-iso.sh with tor-helper, systemd units, container doctor changes
|
||||
3. **Better error messaging** — when nodes are down, addresses changed, all situations
|
||||
4. **File access permissions** — public (no auth), federated (full access), peer-set (specific files)
|
||||
5. **Auth on Tor app access** — login before accessing app via .onion (post-beta candidate)
|
||||
6. **.198 health check** — deploy health check times out on .198 (backend works, likely timing)
|
||||
|
||||
**Why:** Session continuity for v1.3.0 beta stabilization effort.
|
||||
**How to apply:** Read at start of next session. Work on TODO items in order.
|
||||
@@ -1,21 +0,0 @@
|
||||
---
|
||||
name: Tailscale node addresses
|
||||
description: Complete list of all Tailscale node IPs and hostnames for SSH access
|
||||
type: reference
|
||||
---
|
||||
|
||||
## Tailscale Nodes
|
||||
|
||||
| Name | Tailscale IP | Hostname | SSH |
|
||||
|------|-------------|----------|-----|
|
||||
| Arch 1 | 100.82.97.63 | — | `ssh -i ~/.ssh/archipelago-deploy archipelago@100.82.97.63` |
|
||||
| Arch 2 | 100.122.84.60 | archipelago-2.tail2b6225.ts.net | `ssh -i ~/.ssh/archipelago-deploy archipelago@archipelago-2.tail2b6225.ts.net` |
|
||||
| Arch 3 | 100.124.105.113 | archipelago-3.tail2b6225.ts.net | `ssh -i ~/.ssh/archipelago-deploy archipelago@100.124.105.113` |
|
||||
|
||||
Note: `archipelago-3.tail2b6225.ts.net` and `100.124.105.113` are the SAME machine.
|
||||
|
||||
## LAN Nodes
|
||||
| Name | IP | SSH |
|
||||
|------|-----|-----|
|
||||
| Primary (.228) | 192.168.1.228 | `ssh -i ~/.ssh/archipelago-deploy archipelago@192.168.1.228` |
|
||||
| Secondary (.198) | 192.168.1.198 | `ssh -i ~/.ssh/archipelago-deploy archipelago@192.168.1.198` |
|
||||
@@ -1,23 +0,0 @@
|
||||
---
|
||||
name: second-dev-server
|
||||
description: Second dev server accessible via Tailscale at archipelago-2.tail2b6225.ts.net, Ryzen 7 7840U, 14GB RAM
|
||||
type: project
|
||||
---
|
||||
|
||||
- Hostname: archipelago-2.tail2b6225.ts.net (Tailscale)
|
||||
- SSH: `ssh -i ~/.ssh/archipelago-deploy archipelago@archipelago-2.tail2b6225.ts.net`
|
||||
- Password: ThunderDome6574839201!
|
||||
- CPU: AMD Ryzen 7 7840U (faster than primary i3-8100T)
|
||||
- RAM: 14GB
|
||||
- Disk: 916GB NVMe
|
||||
- OS: Debian 12 (Bookworm) x86_64
|
||||
- Has: Podman 4.3.1, Node.js v20.20.1, Rust 1.94.0, Nginx 1.22.1
|
||||
- Swap: 4GB configured
|
||||
- Deploy: `ARCHIPELAGO_TARGET="archipelago@archipelago-2.tail2b6225.ts.net" ./scripts/deploy-to-target.sh --live`
|
||||
- Does NOT use OAuth proxy — uses standard ANTHROPIC_API_KEY for Claude/AIUI
|
||||
- First-boot containers created on 2026-03-11 (Bitcoin Knots, LND, Fedimint, PhotoPrism, Ollama, etc.)
|
||||
|
||||
## Pending Fixes for Next Deploy
|
||||
- **AIUI MIME type error**: Nginx needs a `/aiui/` location block serving correct MIME types for JS files. Currently JS files get wrong content-type causing module load failures.
|
||||
- **Self-signed cert warnings**: Expected on fresh deploy, not a bug.
|
||||
- **Container connection errors in AIUI console**: Expected until all containers finish starting and syncing.
|
||||
@@ -1,20 +0,0 @@
|
||||
---
|
||||
name: Tailscale Servers
|
||||
description: Archipelago Tailscale servers (archipelago-2, archipelago-3) — hostnames, SSH access, and deploy notes
|
||||
type: reference
|
||||
---
|
||||
|
||||
## Tailscale Servers
|
||||
|
||||
- **archipelago-2**: `archipelago@archipelago-2.tail2b6225.ts.net`
|
||||
- SSH key auth works (`~/.ssh/archipelago-deploy`)
|
||||
- Has Node.js, npm, Cargo/Rust, Podman — can do full builds
|
||||
- Deploy: `ARCHIPELAGO_TARGET="archipelago@archipelago-2.tail2b6225.ts.net" ./scripts/deploy-to-target.sh --live`
|
||||
|
||||
- **archipelago-3**: `archipelago@archipelago-3.tail2b6225.ts.net` (IP: 100.124.105.113)
|
||||
- SSH key auth works (key added 2026-03-12)
|
||||
- Has Podman only — NO Node.js, NO Rust/Cargo
|
||||
- Cannot build on-server; must copy pre-built binary + frontend tarball
|
||||
- Deploy method: SCP binary from archipelago-2 or local, upload frontend tarball, extract to `/opt/archipelago/web-ui/`
|
||||
|
||||
**How to apply:** For archipelago-2, use the standard deploy script with `ARCHIPELAGO_TARGET`. For archipelago-3, copy pre-built artifacts (binary + frontend tarball) since it lacks build tools.
|
||||
@@ -1,12 +0,0 @@
|
||||
---
|
||||
name: third-dev-server
|
||||
description: Third dev server accessible via Tailscale at archipelago-3.tail2b6225.ts.net, password ThisIsWeb54321@
|
||||
type: project
|
||||
---
|
||||
|
||||
- Hostname: archipelago-3.tail2b6225.ts.net (Tailscale)
|
||||
- SSH: `sshpass -p 'ThisIsWeb54321@' ssh -o StrictHostKeyChecking=no archipelago@archipelago-3.tail2b6225.ts.net`
|
||||
- Password: ThisIsWeb54321@
|
||||
- Deploy: `ARCHIPELAGO_TARGET="archipelago@archipelago-3.tail2b6225.ts.net" ./scripts/deploy-to-target.sh --live`
|
||||
- SSH key NOT yet installed — need to copy `~/.ssh/archipelago-deploy.pub` manually
|
||||
- Added 2026-03-11
|
||||
@@ -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,34 +0,0 @@
|
||||
---
|
||||
name: web-only-apps
|
||||
description: Web-only apps (no container) — L484 category, BotFights, IndieHub. Iframe compatibility, nginx proxying, My Apps injection.
|
||||
type: project
|
||||
---
|
||||
|
||||
## Web-Only Apps (added 2026-03-11)
|
||||
|
||||
These apps are external websites embedded via iframe — no Docker container. They show as "installed" in both the marketplace and My Apps.
|
||||
|
||||
### L484 Category
|
||||
- **NWNN** (nwnn.l484.com) — News aggregator. No X-Frame-Options. Works in iframe directly.
|
||||
- **484 Kitchen** (484.kitchen) — K484 platform. X-Frame-Options: SAMEORIGIN. Proxied via `/ext/484-kitchen/`.
|
||||
- **Call the Operator** (cta.tx1138.com) — Decentralization portal. No X-Frame-Options. Works in iframe directly.
|
||||
- **Arch Presentation** (present.l484.com) — Archipelago presentation. X-Frame-Options: SAMEORIGIN. Proxied via `/ext/arch-presentation/`.
|
||||
- **Syntropy Institute** (syntropy.institute) — Medicine Reimagined. No X-Frame-Options. Works in iframe directly.
|
||||
- **T-0** (teeminuszero.net) — Decentralization documentary. No X-Frame-Options. Works in iframe directly.
|
||||
|
||||
### Other Web-Only Apps
|
||||
- **BotFights** (botfights.net) — X-Frame-Options: SAMEORIGIN + CSP + COEP/COOP/CORP. Proxied via `/ext/botfights/`. Nginx strips all blocking headers.
|
||||
- **IndeeHub** (archipelago.indeehub.studio) — No X-Frame-Options. Works in iframe directly.
|
||||
|
||||
### Nginx External Proxies
|
||||
Sites with X-Frame-Options get reverse-proxied through nginx at `/ext/{app-id}/`:
|
||||
- `proxy_hide_header X-Frame-Options` strips upstream header
|
||||
- `add_header X-Content-Type-Options "nosniff" always` prevents server-level X-Frame-Options inheritance
|
||||
- BotFights also strips `Cross-Origin-Embedder-Policy`, `Cross-Origin-Opener-Policy`, `Cross-Origin-Resource-Policy`
|
||||
- Proxy locations in both HTTP and HTTPS server blocks of nginx-archipelago.conf
|
||||
|
||||
### Frontend Implementation
|
||||
- **appLauncher.ts**: `EXTERNAL_PROXY` map rewrites external URLs to proxy paths in `toEmbeddableUrl()`
|
||||
- **Apps.vue**: `WEB_ONLY_APPS` constant with synthetic `PackageDataEntry` objects. Sorted first alphabetically. No uninstall/start/stop buttons.
|
||||
- **Marketplace.vue**: `dockerImage: ''` + `webUrl` in `getCuratedAppList()`. L484 category.
|
||||
- **Icons**: `neode-ui/public/assets/img/app-icons/{app-id}.png` (or .svg)
|
||||
@@ -1,138 +0,0 @@
|
||||
# Phase 3 & 4: Encrypted Mesh Messaging + Off-Grid Bitcoin Operations
|
||||
|
||||
## Context
|
||||
|
||||
Phase 1 built the mesh radio layer (Meshcore protocol, serial driver, basic chat). Phase 2 added transport abstraction (Mesh>LAN>Tor routing, CBOR delta sync, Reed-Solomon chunking). Current encryption is static X25519 shared secret per peer — no forward secrecy, no message type discrimination, no store-and-forward.
|
||||
|
||||
Phase 3 adds Signal-style Double Ratchet for forward secrecy, typed messages (ALERT, INVOICE, COORDINATE, PSBT_HASH), and store-and-forward relay. Phase 4 adds off-grid Bitcoin operations: block header relay, transaction relay, Lightning invoice relay, and emergency alert system with dead man's switch.
|
||||
|
||||
## Dependencies to Add
|
||||
|
||||
```toml
|
||||
hkdf = "0.12" # KDF for Double Ratchet chains
|
||||
lightning-invoice = "0.34" # BOLT11 parsing (LDK standard, MIT)
|
||||
```
|
||||
|
||||
Custom Double Ratchet from existing crypto (ed25519-dalek, curve25519-dalek, chacha20poly1305, sha2, hmac) — no DR crate needed.
|
||||
|
||||
## Architecture
|
||||
|
||||
```
|
||||
mesh/
|
||||
├── x3dh.rs — X3DH key agreement (prekey bundles, 3-way ECDH)
|
||||
├── ratchet.rs — Double Ratchet state machine (forward secrecy)
|
||||
├── session.rs — Per-peer session manager (ratchet state persistence)
|
||||
├── prekey.rs — Prekey store (signed + one-time prekeys, rotation)
|
||||
├── message_types.rs — Typed message envelope (TEXT/ALERT/INVOICE/COORDINATE/PSBT_HASH)
|
||||
├── outbox.rs — Store-and-forward queue (24h TTL, relay hops)
|
||||
├── bitcoin_relay.rs — TX relay, Lightning relay, block header announce
|
||||
├── alerts.rs — Emergency alerts, dead man's switch
|
||||
└── (existing files extended: crypto.rs, listener.rs, types.rs, mod.rs)
|
||||
```
|
||||
|
||||
## Implementation Steps
|
||||
|
||||
### Week 1: X3DH + HKDF Foundation
|
||||
|
||||
**New**: `mesh/x3dh.rs`, `mesh/prekey.rs`
|
||||
**Modify**: `Cargo.toml` (+hkdf), `mesh/crypto.rs`, `mesh/mod.rs`
|
||||
|
||||
- `PrekeyBundle`: identity_key + signed_prekey + one_time_prekeys (CBOR, ~200B)
|
||||
- `PrekeyStore`: disk persistence at `{data_dir}/prekeys/`, rotation, consumption
|
||||
- X3DH: 3-way ECDH → HKDF-SHA256 → root key for Double Ratchet
|
||||
- ARCHY:3 identity broadcast with embedded prekey bundle
|
||||
|
||||
### Week 2: Double Ratchet Protocol
|
||||
|
||||
**New**: `mesh/ratchet.rs` (~500 LOC), `mesh/session.rs` (~300 LOC)
|
||||
|
||||
`RatchetState`: DH ratchet keypair, root key, send/recv chain keys, counters, skipped keys (max 100). HKDF-SHA256 chains + ChaCha20-Poly1305 per-message.
|
||||
|
||||
Wire format: 40B header (DH pub + counters) + 12 nonce + ciphertext + 16 tag = 68B overhead. Single frame: 64B plaintext. Chunked: ~2.4KB.
|
||||
|
||||
`SessionManager`: HashMap<DID, RatchetState>, lazy load from `{data_dir}/ratchet/{did_hash}.json`. Backward compat: falls back to static shared secret for ARCHY:2 peers.
|
||||
|
||||
### Week 3: Typed Messages + Store-and-Forward
|
||||
|
||||
**New**: `mesh/message_types.rs`, `mesh/outbox.rs`
|
||||
**Modify**: `mesh/types.rs`, `mesh/listener.rs`
|
||||
|
||||
CBOR envelope: `[0x02] [{ t: u8, v: bytes, ts: u32, sig?: bytes }]`
|
||||
|
||||
Types: TEXT(0), ALERT(1), INVOICE(2), PSBT_HASH(3), COORDINATE(4), PREKEY_BUNDLE(5), SESSION_INIT(6)
|
||||
|
||||
GPS as `Coordinate { lat_microdeg: i32, lng_microdeg: i32 }` — integer only, no float.
|
||||
|
||||
`MeshOutbox`: VecDeque, 24h TTL, max 3 relay hops, disk persistence. Checked every 10s tick.
|
||||
|
||||
### Week 4: RPC Endpoints + Session Bootstrap
|
||||
|
||||
**Modify**: `api/rpc/mesh.rs`, `api/rpc/mod.rs`, `mesh/listener.rs`
|
||||
|
||||
New RPC: `mesh.send-invoice`, `mesh.send-coordinate`, `mesh.send-alert`, `mesh.outbox`, `mesh.session-status`, `mesh.rotate-prekeys`
|
||||
|
||||
Prekey distribution via ARCHY:3 broadcasts. Session init via X3DH on first message to new peer.
|
||||
|
||||
### Week 5: Off-Grid Bitcoin (Phase 4)
|
||||
|
||||
**New**: `mesh/bitcoin_relay.rs`, `mesh/block_headers.rs`
|
||||
**Modify**: `Cargo.toml` (+lightning-invoice), `api/rpc/mesh.rs`
|
||||
|
||||
Block header relay: Internet node broadcasts `BlockHeaderAnnouncement` (height, hash, Ed25519 sig) on new block. Mesh-only peers display "SPV sync via mesh".
|
||||
|
||||
TX relay: Mesh-only node sends raw tx hex → internet peer calls `sendrawtransaction` → returns txid.
|
||||
|
||||
Lightning relay: Create invoice → send bolt11 → peer pays → proof-of-payment returned.
|
||||
|
||||
### Week 6: Emergency Alerts + Dead Man's Switch
|
||||
|
||||
**New**: `mesh/alerts.rs`
|
||||
|
||||
`DeadManSwitch`: Background task, configurable interval (default 6h), broadcasts signed ALERT with GPS to emergency contacts when triggered. Auto-check-in on any authenticated RPC.
|
||||
|
||||
RPC: `mesh.alert-configure`, `mesh.alert-checkin`, `mesh.alert-test`, `mesh.alert-status`
|
||||
|
||||
### Week 7: Frontend
|
||||
|
||||
**Modify**: `stores/mesh.ts`, `views/Mesh.vue`, `mock-backend.js`
|
||||
|
||||
Message rendering by type: invoice (orange card + Pay button), alert (red card), coordinate (blue card + OSM link), psbt_hash (gray card + Review).
|
||||
|
||||
Session indicator: shield icon (green=ratchet, yellow=static, gray=none).
|
||||
|
||||
Block height in off-grid banner. Alert config panel. Dead man switch toggle.
|
||||
|
||||
### Week 8: Integration Test + Deploy
|
||||
|
||||
E2E on .228 (internet) + .198 (mesh-only): X3DH handshake, 50-message ratchet, invoice relay, TX relay, block headers, dead man switch. Deploy to both servers.
|
||||
|
||||
## New Files (8)
|
||||
|
||||
1. `core/archipelago/src/mesh/x3dh.rs`
|
||||
2. `core/archipelago/src/mesh/prekey.rs`
|
||||
3. `core/archipelago/src/mesh/ratchet.rs`
|
||||
4. `core/archipelago/src/mesh/session.rs`
|
||||
5. `core/archipelago/src/mesh/message_types.rs`
|
||||
6. `core/archipelago/src/mesh/outbox.rs`
|
||||
7. `core/archipelago/src/mesh/bitcoin_relay.rs`
|
||||
8. `core/archipelago/src/mesh/alerts.rs`
|
||||
|
||||
## Modified Files (8)
|
||||
|
||||
1. `core/archipelago/Cargo.toml` — +hkdf, +lightning-invoice
|
||||
2. `core/archipelago/src/mesh/crypto.rs` — +hkdf_sha256, +ephemeral keygen
|
||||
3. `core/archipelago/src/mesh/types.rs` — +message_type, +typed payloads
|
||||
4. `core/archipelago/src/mesh/listener.rs` — typed dispatch, session bootstrap, relay
|
||||
5. `core/archipelago/src/mesh/mod.rs` — new submodules, new MeshService methods
|
||||
6. `core/archipelago/src/api/rpc/mesh.rs` — ~12 new RPC endpoints
|
||||
7. `core/archipelago/src/api/rpc/mod.rs` — register new routes
|
||||
8. `neode-ui/src/views/Mesh.vue` — typed rendering, alert UI, session badges
|
||||
|
||||
## Verification
|
||||
|
||||
```bash
|
||||
cargo test --all-features -- mesh::ratchet mesh::x3dh mesh::session
|
||||
cargo clippy --all-targets --all-features
|
||||
cd neode-ui && npm run type-check
|
||||
./scripts/deploy-to-target.sh --both
|
||||
```
|
||||
@@ -1,145 +0,0 @@
|
||||
# Architecture Review — Fix Remaining Issues
|
||||
|
||||
## Context
|
||||
|
||||
The architecture review (`docs/architecture-review.html`) identified 4 P0, 6 P1, and 6 medium-priority issues across the codebase. After research, **all 4 P0s and 4 of 6 P1s are already fixed**. This plan addresses the remaining open items that improve reliability and security during the beta freeze.
|
||||
|
||||
**What's already fixed:** P0-1 (health RPC), P0-2 (health checks), P0-3 (backup rollback), P0-4 (nginx protections), P1-B (rate limiter cleanup), P1-C (systemd limits), P1-E (WS reconnect), P1-F (Vue error handler), Issue 11 (session async I/O).
|
||||
|
||||
**What we're fixing now (4 items):**
|
||||
|
||||
---
|
||||
|
||||
## Item 1: Add 10s timeout to 6 bare `client.connect()` calls — DONE
|
||||
|
||||
**Why:** A down Nostr relay hangs the async task indefinitely, blocking identity publishing, node discovery, and marketplace operations. Direct uptime impact.
|
||||
|
||||
### Files & locations
|
||||
|
||||
| File | Line | Function |
|
||||
|------|------|----------|
|
||||
| `core/archipelago/src/identity_manager.rs` | 409 | `publish_profile()` |
|
||||
| `core/archipelago/src/nostr_discovery.rs` | 113 | `publish_node_revocation()` |
|
||||
| `core/archipelago/src/nostr_discovery.rs` | 200 | `verify_revocation()` |
|
||||
| `core/archipelago/src/nostr_discovery.rs` | 264 | `discover_archipelago_nodes()` |
|
||||
| `core/archipelago/src/marketplace.rs` | 298 | `discover()` |
|
||||
| `core/archipelago/src/marketplace.rs` | 406 | `publish()` |
|
||||
|
||||
### Pattern (from `nostr_handshake.rs:126`)
|
||||
|
||||
Replace each `client.connect().await;` with:
|
||||
```rust
|
||||
if tokio::time::timeout(Duration::from_secs(10), client.connect()).await.is_err() {
|
||||
tracing::warn!("Nostr relay connection timed out after 10s, continuing anyway");
|
||||
}
|
||||
```
|
||||
|
||||
Ensure `use std::time::Duration;` is imported in each file. `tracing::warn!` is already available in all three files.
|
||||
|
||||
### Risk: LOW — Mechanical pattern replication, no logic changes.
|
||||
|
||||
---
|
||||
|
||||
## Item 2: Pin all crypto dependency versions exactly — DONE
|
||||
|
||||
**Why:** Floating versions (`"2.1"` instead of `"2.2.0"`) allow `cargo update` to silently change crypto libraries. Supply chain risk + project rules violation.
|
||||
|
||||
### Versions (verified from Cargo.lock)
|
||||
|
||||
**`core/archipelago/Cargo.toml`:**
|
||||
|
||||
| Line | Current | Pin to |
|
||||
|------|---------|--------|
|
||||
| 44 | `sha2 = "0.10"` | `"0.10.9"` |
|
||||
| 45 | `hmac = "0.12"` | `"0.12.1"` |
|
||||
| 50 | `ed25519-dalek = { version = "2.1", ... }` | `version = "2.2.0"` |
|
||||
| 51 | `curve25519-dalek = "4"` | `"4.1.3"` |
|
||||
| 52 | `rand = "0.8"` | `"0.8.5"` |
|
||||
| 69 | `argon2 = "0.5"` | `"0.5.3"` |
|
||||
| 70 | `chacha20poly1305 = "0.10"` | `"0.10.1"` |
|
||||
| 81 | `zeroize = { version = "1.7", ... }` | `version = "1.8.2"` |
|
||||
| 92 | `hkdf = "0.12"` | `"0.12.4"` |
|
||||
|
||||
**`core/security/Cargo.toml`:**
|
||||
|
||||
| Line | Current | Pin to |
|
||||
|------|---------|--------|
|
||||
| 16 | `aes-gcm = "0.10"` | `"0.10.3"` |
|
||||
| 17 | `rand = "0.8"` | `"0.8.5"` |
|
||||
| 19 | `zeroize = { version = "1", ... }` | `version = "1.8.2"` |
|
||||
|
||||
**Note:** `core/models/Cargo.toml` has `ed25519-dalek = "2.0.0"` but this crate is NOT in the workspace — it's dead code. Skip it.
|
||||
|
||||
### Risk: LOW — Pins to versions already resolved in Cargo.lock. No actual dependency changes.
|
||||
|
||||
---
|
||||
|
||||
## Item 3: Pin all floating container image tags — DONE
|
||||
|
||||
**Why:** Floating tags (`:1`, `:7`, `:alpine`, `:main`) mean two installs a week apart get different software. Supply chain risk and a support nightmare.
|
||||
|
||||
### File: `scripts/image-versions.sh`
|
||||
|
||||
| Line | Variable | Current Tag | Action |
|
||||
|------|----------|-------------|--------|
|
||||
| 16 | `MARIADB_IMAGE` | `:11.4` | SSH -> get exact patch version |
|
||||
| 21 | `POSTGRES_IMAGE` | `:15` | SSH -> get exact patch version |
|
||||
| 22 | `BTCPAY_POSTGRES_IMAGE` | `:15` | SSH -> get exact patch version |
|
||||
| 25 | `HOMEASSISTANT_IMAGE` | `:2024.12` | SSH -> get exact patch version |
|
||||
| 27 | `UPTIME_KUMA_IMAGE` | `:1` | SSH -> get exact patch version |
|
||||
| 32 | `NEXTCLOUD_IMAGE` | `:29` | SSH -> get exact patch version |
|
||||
| 34 | `ONLYOFFICE_IMAGE` | `:8.2` | SSH -> get exact patch version |
|
||||
| 35 | `FILEBROWSER_IMAGE` | `:v2` | SSH -> get exact patch version |
|
||||
| 36 | `NPM_IMAGE` | `:2` | SSH -> get exact patch version |
|
||||
| 49 | `REDIS_IMAGE` | `:7` | SSH -> get exact patch version |
|
||||
| 52 | `VALKEY_IMAGE` | `:8` | SSH -> get exact patch version |
|
||||
| 60 | `INDEEDHUB_POSTGRES_IMAGE` | `:16-alpine` | SSH -> get exact patch version |
|
||||
| 61 | `INDEEDHUB_REDIS_IMAGE` | `:7-alpine` | SSH -> get exact patch version |
|
||||
| 64 | `DWN_SERVER_IMAGE` | `:main` | SSH -> get image digest, pin by SHA or tag |
|
||||
| 68 | `NGINX_ALPINE_IMAGE` | `:alpine` | SSH -> get exact version |
|
||||
|
||||
### Pre-work required
|
||||
Run on 192.168.1.228: `podman images --format '{{.Repository}}:{{.Tag}}'` to get exact versions currently deployed. Pin to THOSE — don't upgrade.
|
||||
|
||||
### Risk: MEDIUM — Must match what's actually running. Wrong pin = containers fail on next creation.
|
||||
|
||||
---
|
||||
|
||||
## Item 4: Add CI pipeline for Rust + frontend checks — DONE
|
||||
|
||||
**Why:** No tests or linting run in CI. Regressions from Items 1-3 (and all future beta fixes) go undetected until they hit the server.
|
||||
|
||||
### File to create: `.github/workflows/ci.yml`
|
||||
|
||||
Two parallel jobs:
|
||||
1. **`rust`** (ubuntu-latest): `cargo fmt --check` -> `cargo clippy -D warnings` -> `cargo test`
|
||||
2. **`frontend`** (ubuntu-latest): `npm ci` -> `npm run type-check` -> `npm test`
|
||||
|
||||
Trigger: push to `main` + all PRs. Reference existing `build-macos.yml` for action versions (checkout@v4, setup-node@v4 with Node 18).
|
||||
|
||||
### Risk: LOW — Additive only, new file, doesn't affect existing workflows.
|
||||
|
||||
---
|
||||
|
||||
## Execution Order
|
||||
|
||||
1. **Item 1** (Nostr timeouts) — lowest risk, immediate reliability gain
|
||||
2. **Item 2** (crypto pins) — batch with Item 1 for single deploy
|
||||
3. **Item 3** (container image pins) — requires SSH query first
|
||||
4. **Item 4** (CI) — validates everything, no deploy needed
|
||||
|
||||
Items 1+2 deploy together. Item 3 deploys separately (script only). Item 4 is push-only.
|
||||
|
||||
## Verification
|
||||
|
||||
- Items 1+2: `cargo clippy --all-targets --all-features` on dev server (zero warnings), then deploy + test identity/discovery/marketplace features
|
||||
- Item 3: `source scripts/image-versions.sh` + verify all vars have exact patch versions
|
||||
- Item 4: Push to branch, verify both CI jobs pass green on GitHub Actions
|
||||
|
||||
## Deferred (post-beta)
|
||||
|
||||
- Issue 6: Generate TS types from Rust (ts-rs) — new dependency
|
||||
- Issue 7: Consolidate container metadata to single source — structural refactor
|
||||
- Issue 8: Split deploy/ISO scripts into modules — already planned in script comments
|
||||
- Issue 9: Single app manifest driving all 6+ locations — architectural change
|
||||
- Issue 12: useAsyncState composable — touches 14+ views, risky during freeze
|
||||
@@ -1,803 +0,0 @@
|
||||
# Archipelago: Production Excellence Plan
|
||||
|
||||
**Duration**: 12 months (48 weeks)
|
||||
**Goal**: Code so good no developer could question any decision. Apple-level reliability. Every failure visible and recoverable. Every operation bounded. Every line justified.
|
||||
**Audited**: 2026-03-20 — 122 Rust files, 38 Vue views, 180+ frontend files, 80+ shell scripts
|
||||
|
||||
## CONSTRAINTS
|
||||
|
||||
- **DEPLOY ONLY TO .198** — Never .228. All verification on .198.
|
||||
- **BETA FREEZE** — Behavior-preserving only. No new features/UI/endpoints.
|
||||
- **Tests before every refactor** — Capture current behavior first. Tests must pass unchanged after.
|
||||
- **Atomic commits** — One logical change per commit. Every step compiles + passes tests.
|
||||
|
||||
```bash
|
||||
ssh -i ~/.ssh/archipelago-deploy archipelago@192.168.1.198
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## COMPLETE ISSUE REGISTRY
|
||||
|
||||
### Backend Rust — 122 files audited
|
||||
|
||||
| ID | Issue | File(s) | Severity |
|
||||
|----|-------|---------|----------|
|
||||
| R1 | Health RPC endpoint has no handler — returns "Unknown method" | `api/rpc/mod.rs` | P0 |
|
||||
| R2 | Nostr client.connect() hangs indefinitely (4 calls, no timeout) | `nostr_handshake.rs:124,161,262,282` | P0 |
|
||||
| R3 | Backup restore extracts directly to live dir — no atomic rollback | `backup/full.rs:122-149` | P0 |
|
||||
| R4 | Rate limiter cleanup() never spawned — HashMap grows forever | `session.rs:566-579` | P1 |
|
||||
| R5 | Login rate limiter same issue — entries never evicted | `session.rs:452-472` | P1 |
|
||||
| R6 | Blocking std::fs in async — session.rs (6 calls) | `session.rs:77,128,370,413,423,425` | P1 |
|
||||
| R7 | Blocking std::fs in async — docker_packages.rs | `docker_packages.rs:561,573` | P1 |
|
||||
| R8 | Blocking std::fs in async — port_allocator.rs | `port_allocator.rs:59,73,77` | P1 |
|
||||
| R9 | Blocking std::fs in async — peers.rs, node_message.rs | `peers.rs:30`, `node_message.rs:65` | P1 |
|
||||
| R10 | Blocking std::fs in async — identity.rs, identity_manager.rs | `identity.rs:50`, `identity_manager.rs:164` | P1 |
|
||||
| R11 | Blocking std::fs in async — nostr_discovery.rs | `nostr_discovery.rs:55` | P1 |
|
||||
| R12 | Sync TCP I/O in async context — electrs_status.rs | `electrs_status.rs:5,40,78,81` | P1 |
|
||||
| R13 | .expect() in main.rs startup | `main.rs:124,159` | P2 |
|
||||
| R14 | .parse().unwrap() in session.rs rate limiting | `session.rs:665,676,688` | P1 |
|
||||
| R15 | 7 .unwrap()/.expect() in mesh/protocol.rs | `protocol.rs:582,592,614,649,679,713,728` | P1 |
|
||||
| R16 | .expect() in identity.rs crypto | `identity.rs:114,119` | P2 |
|
||||
| R17 | .unwrap() in helpers/lib.rs (5 calls) | `helpers/lib.rs:167,172,180,233,253` | P2 |
|
||||
| R18 | .unwrap() in helpers/rsync.rs (5 calls) | `rsync.rs:196,199,202,210,220` | P2 |
|
||||
| R19 | .unwrap() in js-engine/lib.rs | `js-engine/lib.rs:130,249` | P2 |
|
||||
| R20 | 14 #[allow(dead_code)] suppressions in mesh/mod.rs | `mesh/mod.rs:7-25` | P2 |
|
||||
| R21 | Dead code in lnd.rs, data_manager.rs, dev_orchestrator.rs | Multiple | P2 |
|
||||
| R22 | Bitcoin RPC URL hardcoded in 4+ files | `bitcoin.rs:89`, `mesh/mod.rs:624,649,663`, `listener.rs:1509+` | P2 |
|
||||
| R23 | DWN health URL hardcoded | `dwn_sync.rs:76` | P2 |
|
||||
| R24 | Update manifest URL hardcoded | `update.rs:11` | P3 |
|
||||
| R25 | DNS-over-HTTPS URLs hardcoded (4 providers) | `network/dns.rs:98,102,106,110` | P3 |
|
||||
| R26 | DWN protocol URIs hardcoded in server.rs | `server.rs:453-456` | P3 |
|
||||
| R27 | Missing timeouts on mesh Bitcoin RPC calls | `mesh/mod.rs:624,649,663` | P1 |
|
||||
| R28 | Missing timeouts on LND proxy calls (68 .send() calls) | `api/rpc/lnd.rs` | P2 |
|
||||
| R29 | Missing timeout on DWN health check | `dwn_sync.rs:76` | P2 |
|
||||
| R30 | TODO: track last-seen timestamp | `handshake.rs:77` | P3 |
|
||||
| R31 | TODO: lnd.lookupinvoice RPC endpoint | `marketplace.rs:183` | P3 |
|
||||
| R32 | TODO: trigger auto-restart or alert | `container/health_monitor.rs:140` | P3 |
|
||||
| R33 | TODO: configure Podman to use AppArmor profile | `security/container_policies.rs:68` | P3 |
|
||||
| R34 | Tor rotation deletes old .onion immediately — no transition | `api/rpc/tor.rs:184-240` | P1 |
|
||||
| R35 | package.rs god file — 1,795 lines | `api/rpc/package.rs` | P2 |
|
||||
| R36 | mesh/listener.rs god file — 1,799 lines | `mesh/listener.rs` | P2 |
|
||||
| R37 | rpc/mod.rs god file — 1,092 lines | `api/rpc/mod.rs` | P2 |
|
||||
| R38 | lnd.rs god file — 1,068 lines | `api/rpc/lnd.rs` | P2 |
|
||||
| R39 | monitoring/mod.rs — 993 lines | `monitoring/mod.rs` | P3 |
|
||||
| R40 | api/handler.rs — 911 lines | `api/handler.rs` | P3 |
|
||||
| R41 | 30+ functions exceed 50 lines across codebase | Multiple | P3 |
|
||||
|
||||
### Frontend — 180+ files audited
|
||||
|
||||
| ID | Issue | File(s) | Severity |
|
||||
|----|-------|---------|----------|
|
||||
| F1 | WebSocket subscription registered multiple times — race condition | `stores/app.ts:88-134` | P0 |
|
||||
| F2 | Unprotected concurrent mesh state mutations | `stores/mesh.ts:249-268,294-324` | P0 |
|
||||
| F3 | No global Vue error handler — white screen on error | `main.ts` | P0 |
|
||||
| F4 | Stale data after WebSocket reconnect — no full refresh | `stores/app.ts:88-163` | P1 |
|
||||
| F5 | Message polling timer never stopped after logout | `composables/useMessageToast.ts:60` | P1 |
|
||||
| F6 | AppLauncher NIP-07 message listener leak on close | `stores/appLauncher.ts:295-301` | P1 |
|
||||
| F7 | Audio player listeners stack — never cleaned up | `composables/useAudioPlayer.ts:1-91` | P1 |
|
||||
| F8 | WebSocket reconnection race — parallel connect() attempts | `api/websocket.ts:212-238` | P2 |
|
||||
| F9 | WebSocket parse error silently caught — stale UI forever | `api/websocket.ts:164-172` | P2 |
|
||||
| F10 | WebSocket stale connection detection too aggressive (5min) | `api/websocket.ts:284-299` | P2 |
|
||||
| F11 | RPC client backoff + timeout = 40s max wait | `api/rpc-client.ts:31-117` | P2 |
|
||||
| F12 | No code splitting — monolithic bundle | `vite.config.ts` | P2 |
|
||||
| F13 | v-html on QR code without DOMPurify | `views/Settings.vue:441` | P2 |
|
||||
| F14 | Goals store O(n) alias lookup on every computed | `stores/goals.ts:16-20,38-89` | P2 |
|
||||
| F15 | localStorage save without try/catch (5+ instances) | `stores/goals.ts:34-36` + others | P2 |
|
||||
| F16 | FileBrowser auth token duality — memory + cookie | `api/filebrowser-client.ts:39,50-68` | P2 |
|
||||
| F17 | CSRF token cookie parsing brittle — regex only | `api/rpc-client.ts:18-21` | P2 |
|
||||
| F18 | aiPermissions.ts Set uses unsafe type assertion | `stores/aiPermissions.ts:91-103` | P3 |
|
||||
| F19 | Untracked setTimeout in AppSession — fires after unmount | `views/AppSession.vue:507` | P3 |
|
||||
| F20 | Dashboard navigation missing aria-current="page" | `views/Dashboard.vue` | P3 |
|
||||
| F21 | Search performance — string re-lowercasing every keystroke | `views/Apps.vue:510-537` | P3 |
|
||||
| F22 | 30+ backdrop-filter blur elements — GPU overload on mobile | `style.css` | P3 |
|
||||
| F23 | Record<string, unknown> on sensitive DID operations | `types/api.ts` + `rpc-client.ts` | P3 |
|
||||
| F24 | checkInterval timer leak on connect race | `api/websocket.ts:82-96` | P3 |
|
||||
| F25 | Web5.vue god component — 3,940 lines | `views/Web5.vue` | P2 |
|
||||
| F26 | Mesh.vue — 2,106 lines | `views/Mesh.vue` | P2 |
|
||||
| F27 | Dashboard.vue — 1,819 lines | `views/Dashboard.vue` | P2 |
|
||||
| F28 | Settings.vue — 1,792 lines | `views/Settings.vue` | P2 |
|
||||
| F29 | Marketplace.vue — 1,293 lines | `views/Marketplace.vue` | P3 |
|
||||
| F30 | Server.vue — 1,132 lines | `views/Server.vue` | P3 |
|
||||
| F31 | Home.vue — 1,059 lines | `views/Home.vue` | P3 |
|
||||
| F32 | AppDetails.vue — 1,036 lines | `views/AppDetails.vue` | P3 |
|
||||
| F33 | useAppStore god store — 324 lines, 16 methods, 8+ responsibilities | `stores/app.ts` | P2 |
|
||||
|
||||
### Shell Scripts — 80+ files audited
|
||||
|
||||
| ID | Issue | File(s) | Severity |
|
||||
|----|-------|---------|----------|
|
||||
| S1 | 60+ instances of `sudo podman` — should be rootless | `fix-indeedhub(28)`, `deploy-bitcoin(11)`, `deploy-tailscale(2+)` | P0 |
|
||||
| S2 | Zero container health checks in first-boot (30 containers) | `first-boot-containers.sh` | P0 |
|
||||
| S3 | 50+ `:latest` image tags across all scripts | `first-boot(15)`, `deploy(11)`, `tailscale(18)`, `iso(7)` | P1 |
|
||||
| S4 | No `set -e` in first-boot — silent container failures | `first-boot-containers.sh:1-9` | P1 |
|
||||
| S5 | `eval "$DB_PASSWORDS"` — code injection risk | `deploy-to-target.sh:940` | P1 |
|
||||
| S6 | No deploy locking — concurrent deploys corrupt state | `deploy-to-target.sh` | P1 |
|
||||
| S7 | No deploy rollback — failed deploy leaves broken system | `deploy-to-target.sh` | P1 |
|
||||
| S8 | sshpass usage in trust-archipelago-cert.sh | `trust-archipelago-cert.sh:23-26` | P1 |
|
||||
| S9 | MariaDB password in command line — visible in ps | `first-boot-containers.sh:285` | P1 |
|
||||
| S10 | 80+ instances of `2>/dev/null \|\| true` masking errors | `deploy-to-target.sh` | P2 |
|
||||
| S11 | No trap cleanup for temp files | Multiple scripts | P2 |
|
||||
| S12 | Unquoted variables (word splitting risk) | Multiple scripts | P2 |
|
||||
| S13 | Hardcoded IPs in 6+ scripts | `deploy-to-target.sh:26`, `deploy-tailscale.sh:26`, etc. | P2 |
|
||||
| S14 | No input validation on deploy targets | `deploy-tailscale.sh` | P2 |
|
||||
| S15 | Missing memory limits on some containers in deploy | `deploy-to-target.sh:842-880` | P2 |
|
||||
| S16 | ISO build not reproducible — dynamic image capture + :latest | `build-auto-installer-iso.sh:500-594` | P2 |
|
||||
| S17 | No disk space pre-flight in deploy | `deploy-to-target.sh` | P2 |
|
||||
| S18 | deploy-to-target.sh — 1,728 lines monolith | `deploy-to-target.sh` | P3 |
|
||||
| S19 | build-auto-installer-iso.sh — 1,850 lines monolith | `build-auto-installer-iso.sh` | P3 |
|
||||
| S20 | first-boot-containers.sh — 855 lines monolith | `first-boot-containers.sh` | P3 |
|
||||
| S21 | No shared script library — duplicated functions | `scripts/` | P3 |
|
||||
|
||||
### Infrastructure
|
||||
|
||||
| ID | Issue | File(s) | Severity |
|
||||
|----|-------|---------|----------|
|
||||
| I1 | Nginx: /archipelago/, /content, /dwn missing timeout+rate-limit+body-size | `nginx-archipelago.conf:116-180` | P0 |
|
||||
| I2 | Systemd: no MemoryMax, LimitNOFILE, TasksMax | `archipelago.service` | P1 |
|
||||
| I3 | Tor rotation kills old address immediately — federation downtime | `api/rpc/tor.rs:184-240` | P1 |
|
||||
|
||||
---
|
||||
|
||||
## MONTH 1: CRASH PREVENTION (Weeks 1–4)
|
||||
|
||||
> Fix every issue that can crash the system, hang indefinitely, or lose data.
|
||||
|
||||
### Week 1: P0 Backend — Things That Hang or Lose Data
|
||||
|
||||
**R1 — Health endpoint handler**
|
||||
- File: `core/archipelago/src/api/rpc/mod.rs`
|
||||
- Add handler for `"health"` method that checks: crash recovery complete, Podman socket responsive, session store loaded
|
||||
- Tests: health returns JSON status, degraded when Podman unreachable, degraded during recovery
|
||||
- Verify: `curl http://192.168.1.198/rpc/v1 -d '{"method":"health"}'` returns real status
|
||||
|
||||
**R2 — Nostr connect timeout**
|
||||
- File: `core/archipelago/src/nostr_handshake.rs` lines 124, 161, 262, 282
|
||||
- Wrap all 4 `client.connect().await` in `tokio::time::timeout(Duration::from_secs(10), ...)`
|
||||
- Tests: connect timeout returns Err after 10s, successful connect within timeout works
|
||||
|
||||
**R3 — Backup restore atomic rollback**
|
||||
- File: `core/archipelago/src/backup/full.rs` lines 122-149
|
||||
- Rewrite: decrypt → extract to staging dir → validate required files → atomic rename → rollback on failure
|
||||
- Tests: valid backup restores, corrupt backup fails without touching live data, partial extraction rolls back, disk space check fails early
|
||||
|
||||
**I1 — Nginx unauthenticated endpoint protection**
|
||||
- File: `image-recipe/configs/nginx-archipelago.conf` lines 116-180
|
||||
- Add to `/archipelago/`, `/content`, `/dwn`:
|
||||
- `limit_req zone=peer burst=20 nodelay;`
|
||||
- `client_max_body_size 10m;`
|
||||
- `proxy_connect_timeout 30s; proxy_read_timeout 60s; proxy_send_timeout 30s;`
|
||||
- Tests: >10MB payload → 413, slow client → timeout, burst 30 → 429 after 20
|
||||
|
||||
### Week 2: P0 Frontend + Scripts — Things That Break UI or Containers
|
||||
|
||||
**F1 — WebSocket subscription race condition**
|
||||
- File: `neode-ui/src/stores/app.ts` lines 88-134
|
||||
- Fix: Return unsubscribe function from `wsClient.subscribe()`, call it before re-subscribing. Use a subscription ID to prevent duplicates.
|
||||
- Tests: rapid connectWebSocket() calls produce only one active subscription
|
||||
|
||||
**F2 — Mesh concurrent state mutations**
|
||||
- File: `neode-ui/src/stores/mesh.ts` lines 249-324
|
||||
- Fix: Add `isSending` ref as mutex. Queue concurrent sends. `fetchMessages()` called once after all sends complete.
|
||||
- Tests: 3 concurrent sendMessage() calls → all succeed, messages list consistent
|
||||
|
||||
**F3 — Global error handler**
|
||||
- File: `neode-ui/src/main.ts`
|
||||
- Add `app.config.errorHandler` that shows toast + logs structured error
|
||||
- Tests: thrown error in component shows toast, nested errors don't crash handler
|
||||
|
||||
**S1 — Eliminate all `sudo podman`**
|
||||
- Files: `fix-indeedhub-containers.sh` (28), `deploy-bitcoin-knots.sh` (11), `deploy-tailscale.sh` (2+), `uptime-monitor.sh` (1), `setup-aiui-server.sh`
|
||||
- Replace every `sudo podman` with `podman` (runs as archipelago user)
|
||||
- Tests: grep for `sudo podman` across all scripts returns zero matches
|
||||
|
||||
**S2 — Container health checks for all 30 containers**
|
||||
- File: `scripts/first-boot-containers.sh`
|
||||
- Add `--health-cmd`, `--health-interval=30s`, `--health-timeout=5s`, `--health-retries=3` to every `$DOCKER run`
|
||||
- Health commands per type:
|
||||
- Bitcoin: `bitcoin-cli -rpcuser=... getblockchaininfo || exit 1`
|
||||
- HTTP apps: `curl -sf http://localhost:{port}/ || exit 1`
|
||||
- LND: `curl -sf --insecure https://localhost:8080/v1/getinfo || exit 1`
|
||||
- Databases: `mariadb -u root -p... -e "SELECT 1" || exit 1`
|
||||
- Tests: script grep confirms every `$DOCKER run` has `--health-cmd`
|
||||
|
||||
### Week 3: P1 Backend — Blocking I/O and Memory Leaks
|
||||
|
||||
**R4+R5 — Rate limiter cleanup**
|
||||
- File: `core/archipelago/src/session.rs`
|
||||
- Spawn background tasks for both `EndpointRateLimiter::cleanup()` and `LoginRateLimiter` cleanup, every 5 min
|
||||
- Tests: after cleanup, stale entries removed; active entries preserved
|
||||
|
||||
**R6 — session.rs blocking I/O (6 calls)**
|
||||
- Replace `std::fs::read_to_string` → `tokio::fs::read_to_string` at lines 77, 370, 413
|
||||
- Replace `std::fs::write` → `tokio::fs::write` at lines 128, 425
|
||||
- Replace `std::fs::create_dir_all` → `tokio::fs::create_dir_all` at line 423
|
||||
- Tests: session load/save/persist still works correctly
|
||||
|
||||
**R7 — docker_packages.rs blocking I/O**
|
||||
- Replace `std::fs::read_to_string` → `tokio::fs::read_to_string` at lines 561, 573
|
||||
- Tests: app metadata loading works
|
||||
|
||||
**R8 — port_allocator.rs blocking I/O**
|
||||
- Replace all 3 std::fs calls → tokio::fs at lines 59, 73, 77
|
||||
- Tests: port allocation/persistence works
|
||||
|
||||
**R9+R10+R11 — Remaining blocking I/O**
|
||||
- `peers.rs:30`, `node_message.rs:65`, `identity.rs:50`, `identity_manager.rs:164`, `nostr_discovery.rs:55`
|
||||
- Convert all to tokio::fs
|
||||
- Tests: each module's file operations still work
|
||||
|
||||
**R12 — electrs_status.rs sync TCP I/O**
|
||||
- Convert synchronous TCP client to async (tokio::net::TcpStream)
|
||||
- Tests: ElectrumX status query works, timeout on connection failure
|
||||
|
||||
### Week 4: P1 Frontend — Memory Leaks and Stale State
|
||||
|
||||
**F4 — WebSocket reconnect full state refresh**
|
||||
- File: `neode-ui/src/stores/app.ts`
|
||||
- After reconnect, call `rpcClient.call({method: 'server.get-state'})` to get fresh state before accepting patches
|
||||
- Tests: after simulated disconnect+reconnect, state matches server
|
||||
|
||||
**F5 — Message polling timer cleanup**
|
||||
- File: `neode-ui/src/composables/useMessageToast.ts`
|
||||
- Tie polling lifecycle to auth state: stop on logout, start on login. Export cleanup function.
|
||||
- Tests: polling stops when auth false, restarts when auth true, no timer after unmount
|
||||
|
||||
**F6 — AppLauncher message listener leak**
|
||||
- File: `neode-ui/src/stores/appLauncher.ts`
|
||||
- Ensure listener is removed when app closes (even if not via close button — e.g., route navigation)
|
||||
- Tests: navigate away from app → listener removed, new app opens clean
|
||||
|
||||
**F7 — Audio player listener stacking**
|
||||
- File: `neode-ui/src/composables/useAudioPlayer.ts`
|
||||
- Create Audio element once, register listeners once. Track initialization flag.
|
||||
- Tests: calling play() 10 times → still only 6 listeners total (not 60)
|
||||
|
||||
**S3 — Pin all container images (remove :latest)**
|
||||
- Files: `first-boot-containers.sh` (15), `deploy-to-target.sh` (11), `deploy-tailscale.sh` (18), `build-auto-installer-iso.sh` (7)
|
||||
- Replace every `:latest` with specific version tag
|
||||
- Create `image-versions.env` sourced by all scripts — single source of truth
|
||||
- Tests: `grep -r ':latest' scripts/ image-recipe/` returns zero matches (excluding comments)
|
||||
|
||||
---
|
||||
|
||||
## MONTH 2: OPERATIONAL SAFETY (Weeks 5–8)
|
||||
|
||||
> Fix everything that makes deploys dangerous, scripts unreliable, or operations opaque.
|
||||
|
||||
### Week 5: Deploy Script Hardening
|
||||
|
||||
**S4 — first-boot error handling**
|
||||
- Add per-section error checking: if Bitcoin fails, skip dependent containers (LND, Mempool, BTCPay)
|
||||
- Add `wait_for_container` return value checking
|
||||
- Tests: first-boot with broken Bitcoin image → Bitcoin deps skipped, independent apps still start
|
||||
|
||||
**S5 — Replace eval with safe construct**
|
||||
- File: `deploy-to-target.sh:940`
|
||||
- Replace `eval "$DB_PASSWORDS"` with explicit variable assignment from SSH output
|
||||
- Tests: passwords parsed correctly without eval
|
||||
|
||||
**S6 — Deploy locking**
|
||||
- File: `deploy-to-target.sh`
|
||||
- Add remote `flock` on `/var/lock/archipelago-deploy.lock`. Second deploy fails immediately with message. Stale lock (>30 min) broken automatically.
|
||||
- Tests: two parallel deploys → second fails, stale lock → broken and deploy proceeds
|
||||
|
||||
**S7 — Deploy rollback**
|
||||
- File: `deploy-to-target.sh`
|
||||
- Before overwriting binary: `cp archipelago archipelago.bak`
|
||||
- Before overwriting frontend: `cp -r web-ui web-ui.bak`
|
||||
- If health check fails post-restart: restore from .bak, restart again
|
||||
- Tests: intentionally broken binary → deploy detects, rolls back, system healthy
|
||||
|
||||
**S8 — Eliminate sshpass**
|
||||
- File: `trust-archipelago-cert.sh`
|
||||
- Rewrite to use SSH key only: `ssh -i ~/.ssh/archipelago-deploy`
|
||||
- Tests: script works with key auth, fails gracefully without key
|
||||
|
||||
### Week 6: Script Quality
|
||||
|
||||
**S9 — MariaDB password not on command line**
|
||||
- File: `first-boot-containers.sh:285`
|
||||
- Use `$DOCKER exec -i ... mariadb -uroot < /dev/stdin <<< "SET PASSWORD..."`
|
||||
- Tests: `ps aux` during execution doesn't show password
|
||||
|
||||
**S10 — Replace silent error masking**
|
||||
- File: `deploy-to-target.sh` (80+ instances)
|
||||
- Pattern: replace `2>/dev/null || echo ""` with `|| { log_warn "..."; echo ""; }`
|
||||
- At minimum, log what failed before masking
|
||||
- Tests: failed health check produces log entry
|
||||
|
||||
**S11 — Trap cleanup for temp files**
|
||||
- All scripts that create /tmp files: add `trap "rm -rf /tmp/deploy-$$" EXIT` at start
|
||||
- Files: deploy-to-target.sh, deploy-tailscale.sh, build-auto-installer-iso.sh
|
||||
- Tests: script interrupted mid-execution → temp files cleaned up
|
||||
|
||||
**S12 — Quote all variables**
|
||||
- Audit and fix unquoted `$VARIABLE` in command arguments across all scripts
|
||||
- Tests: shellcheck passes on all modified scripts
|
||||
|
||||
**S13 — Extract hardcoded IPs to config**
|
||||
- Create `scripts/deploy-config-defaults.sh` with all node IPs as named variables
|
||||
- Source from all scripts instead of hardcoding
|
||||
- Tests: changing IP in config → all scripts use new IP
|
||||
|
||||
### Week 7: Infrastructure Hardening
|
||||
|
||||
**I2 — Systemd resource limits**
|
||||
- File: `image-recipe/configs/archipelago.service`
|
||||
- Add: `MemoryMax=4G`, `LimitNOFILE=65535`, `TasksMax=2048`
|
||||
- Tests: `systemctl show archipelago` confirms limits applied, service starts normally
|
||||
|
||||
**I3 — Tor rotation transition period**
|
||||
- File: `core/archipelago/src/api/rpc/tor.rs`
|
||||
- Keep old hidden service running for 24h after rotation. Both addresses active. Notify peers of new address. Schedule old deletion.
|
||||
- Tests: after rotation old address still resolves, peers receive notification, old removed after transition
|
||||
|
||||
**S14 — Input validation on deploy targets**
|
||||
- Add regex validation for hostnames/IPs before SSH
|
||||
- Tests: invalid hostname → clear error, valid hostname → proceeds
|
||||
|
||||
**S15 — Memory limits on all deploy containers**
|
||||
- File: `deploy-to-target.sh` lines 842-880
|
||||
- Add `--memory=$(mem_limit ...)` to all UI container builds
|
||||
- Tests: every container in deploy has `--memory` flag
|
||||
|
||||
**S17 — Disk space pre-flight**
|
||||
- File: `deploy-to-target.sh`
|
||||
- Check target disk <85% before deploying. Abort with clear message if full.
|
||||
- Tests: deploy to 90% full disk → aborted, deploy to 50% full → succeeds
|
||||
|
||||
### Week 8: Remaining P1 Backend
|
||||
|
||||
**R14 — Fix .parse().unwrap() in session rate limiting**
|
||||
- File: `session.rs:665,676,688`
|
||||
- Replace `.parse().unwrap()` with `.parse().context("...")?`
|
||||
- Tests: invalid IP handling works gracefully
|
||||
|
||||
**R15 — Fix 7 unwrap/expect in mesh/protocol.rs**
|
||||
- File: `mesh/protocol.rs:582,592,614,649,679,713,728`
|
||||
- Replace all with `?` operator + proper error types
|
||||
- Tests: protocol parsing with malformed data returns error, not panic
|
||||
|
||||
**R27 — Add timeouts to mesh Bitcoin RPC calls**
|
||||
- File: `mesh/mod.rs:624,649,663`
|
||||
- Add `tokio::time::timeout(Duration::from_secs(10), ...)` to all Bitcoin RPC calls
|
||||
- Tests: RPC timeout returns error after 10s
|
||||
|
||||
**R34 — Tor rotation transition**
|
||||
- (Covered by I3 above)
|
||||
|
||||
---
|
||||
|
||||
## MONTH 3: PRODUCTION POLISH (Weeks 9–12)
|
||||
|
||||
> Fix every remaining P2 issue — unwraps, hardcoded values, frontend quality, resilience.
|
||||
|
||||
### Week 9: Remaining Backend Unwraps + Dead Code
|
||||
|
||||
**R13 — main.rs .expect() → .context()**
|
||||
- Replace 2 `.expect()` calls with `.context("...")?` and proper startup error handling
|
||||
|
||||
**R16 — identity.rs .expect() → safe handling**
|
||||
- Replace 2 `.expect()` in crypto operations with result propagation
|
||||
|
||||
**R17+R18 — helpers unwraps**
|
||||
- Fix 10 `.unwrap()` calls in `helpers/lib.rs` and `helpers/rsync.rs`
|
||||
- Replace with `?` operator or `.context()`
|
||||
|
||||
**R19 — js-engine unwraps**
|
||||
- Fix 2 `.unwrap()` in `js-engine/lib.rs:130,249`
|
||||
|
||||
**R20+R21 — Dead code elimination**
|
||||
- Remove all 14 `#[allow(dead_code)]` in `mesh/mod.rs`. Either use the fields or delete them.
|
||||
- Same for `lnd.rs`, `data_manager.rs`, `dev_orchestrator.rs`
|
||||
- Tests: `cargo clippy` zero warnings, `cargo test` passes
|
||||
|
||||
### Week 10: Hardcoded Values → Constants
|
||||
|
||||
**R22 — Bitcoin RPC URL constant**
|
||||
- Create `const BITCOIN_RPC_URL: &str = "http://127.0.0.1:8332/";` in a shared constants module
|
||||
- Use across `bitcoin.rs`, `mesh/mod.rs`, `mesh/listener.rs`
|
||||
- Tests: all Bitcoin RPC calls still work
|
||||
|
||||
**R23 — DWN health URL constant**
|
||||
**R24 — Update manifest URL constant**
|
||||
**R25 — DNS-over-HTTPS URLs → constants array**
|
||||
**R26 — DWN protocol URIs → constants**
|
||||
- Centralize all hardcoded URLs/URIs into `core/archipelago/src/constants.rs`
|
||||
- Tests: all modules reference constants, no hardcoded strings remain
|
||||
|
||||
**R28 — LND proxy timeouts**
|
||||
- Audit all 68 `.send()` calls in `api/rpc/lnd.rs`. Ensure each has explicit timeout.
|
||||
- Tests: LND proxy call with unresponsive LND → timeout error, not hang
|
||||
|
||||
**R29 — DWN health check timeout**
|
||||
- Add timeout to `dwn_sync.rs:76` health check
|
||||
|
||||
**R30-R33 — Resolve all TODOs**
|
||||
- Either implement the TODO or remove the dead code path. Per project rules: no TODO/FIXME in commits.
|
||||
|
||||
### Week 11: Frontend P2 Fixes
|
||||
|
||||
**F8 — WebSocket reconnection race**
|
||||
- Add `isReconnecting` flag. Skip if already reconnecting.
|
||||
- Tests: rapid close events → only one reconnect attempt
|
||||
|
||||
**F9 — WebSocket parse error handling**
|
||||
- Count consecutive parse errors. After 3, force reconnect.
|
||||
- Tests: 3 malformed messages → reconnect triggered; single bad message → logged only
|
||||
|
||||
**F10 — Stale connection detection tuning**
|
||||
- Require mutual pong response within 30s. Don't close valid connections that are simply quiet.
|
||||
- Tests: quiet but healthy connection → stays open; no pong for 30s → reconnects
|
||||
|
||||
**F11 — RPC client backoff reduction**
|
||||
- Reduce default timeout from 30s to 15s. Add jitter to backoff. Cap total retry time at 20s.
|
||||
- Tests: server outage → user sees error within 20s, not 40s
|
||||
|
||||
**F12 — Code splitting**
|
||||
- Lazy-load all routes: `() => import('./views/Web5.vue')`
|
||||
- Add manual chunks in vite.config.ts for vendor/api
|
||||
- Tests: build produces multiple chunks, initial bundle < 200KB gzipped
|
||||
|
||||
**F13 — DOMPurify on QR v-html**
|
||||
- Add DOMPurify.sanitize() to QR SVG before v-html rendering
|
||||
- Tests: XSS payload in QR content → sanitized
|
||||
|
||||
### Week 12: Frontend P2 Continued + Performance
|
||||
|
||||
**F14 — Goals computed memoization**
|
||||
- Replace O(n) alias lookup with Map. Add deep equality check.
|
||||
- Tests: goalStatuses computed runs in <1ms with 100 apps
|
||||
|
||||
**F15 — localStorage error handling**
|
||||
- Wrap all localStorage.setItem in try/catch. Show toast on quota exceeded.
|
||||
- Tests: full localStorage → toast shown, app continues
|
||||
|
||||
**F16 — FileBrowser auth consolidation**
|
||||
- Use cookie-only auth. Remove in-memory token.
|
||||
- Tests: login persists across page reload, logout clears cookie
|
||||
|
||||
**F17 — CSRF token parsing robustness**
|
||||
- Add header fallback for CSRF token. Handle edge cases.
|
||||
- Tests: missing cookie → falls back to header, both missing → error
|
||||
|
||||
**F22 — CSS backdrop-filter mobile performance**
|
||||
- Add media query: reduce blur to 8px on mobile. Remove backdrop-filter from non-visible elements.
|
||||
- Tests: mobile Lighthouse performance score > 80
|
||||
|
||||
---
|
||||
|
||||
## MONTH 4-5: BACKEND ARCHITECTURE (Weeks 13–20)
|
||||
|
||||
> Split every Rust god file. Target: no file > 500 lines.
|
||||
|
||||
### Week 13–14: Split package.rs (1,795 lines)
|
||||
|
||||
```
|
||||
api/rpc/package/
|
||||
├── mod.rs — Re-exports (~50 lines)
|
||||
├── config.rs — get_app_config(), get_app_capabilities(), needs_archy_net()
|
||||
├── lifecycle.rs — install, start, stop, restart, uninstall
|
||||
├── validation.rs — Input validation, dependency checking, image validation
|
||||
└── progress.rs — Progress streaming, install status tracking
|
||||
```
|
||||
|
||||
Pre-split tests: test every `get_app_config()` variant, validation path, lifecycle transition
|
||||
Post-split: all RPC calls return identical responses, `cargo test` passes
|
||||
|
||||
### Week 15–16: Split mesh/listener.rs (1,799 lines)
|
||||
|
||||
```
|
||||
mesh/listener/
|
||||
├── mod.rs — Re-exports + spawn_mesh_listener()
|
||||
├── session.rs — run_mesh_session() loop
|
||||
├── frames.rs — handle_frame() dispatcher
|
||||
├── identity.rs — handle_identity_received(), handle_typed_message()
|
||||
├── sync.rs — sync_queued_messages(), store_typed_message()
|
||||
└── bitcoin.rs — Bitcoin relay operations, RPC calls
|
||||
```
|
||||
|
||||
### Week 17–18: Split rpc/mod.rs (1,092 lines) + lnd.rs (1,068 lines)
|
||||
|
||||
**rpc/mod.rs** → `dispatcher.rs` (method routing), `middleware.rs` (CSRF/session/rate-limit), `response.rs` (response building)
|
||||
|
||||
**lnd.rs** → `lnd/wallet.rs`, `lnd/channels.rs`, `lnd/info.rs`, `lnd/payments.rs`
|
||||
|
||||
### Week 19–20: Split monitoring (993), handler (911), mesh (865)
|
||||
|
||||
Split each into sub-modules. Target: no file > 500 lines.
|
||||
All pre-split tests, all post-split verification.
|
||||
|
||||
---
|
||||
|
||||
## MONTH 6-8: FRONTEND ARCHITECTURE (Weeks 21–32)
|
||||
|
||||
> Split every Vue god component. Target: no component > 500 lines.
|
||||
|
||||
### Week 21–22: Split Web5.vue (3,940 lines → 8 sub-views)
|
||||
|
||||
```
|
||||
views/web5/
|
||||
├── Web5.vue — Router shell (~150 lines)
|
||||
├── Web5Identity.vue — DID management
|
||||
├── Web5Wallet.vue — Wallet operations
|
||||
├── Web5Nostr.vue — Nostr relays/profiles
|
||||
├── Web5Credentials.vue — Verifiable Credentials
|
||||
├── Web5Peers.vue — P2P federation nodes
|
||||
├── Web5Storage.vue — DWN storage/explorer
|
||||
├── Web5Goals.vue — Goals/voting
|
||||
└── Web5Marketplace.vue — Decentralized marketplace
|
||||
```
|
||||
|
||||
Add nested routes. Component tests for each section. All sections render identically.
|
||||
|
||||
### Week 23–24: Split Mesh.vue (2,106) + Dashboard.vue (1,819)
|
||||
|
||||
**Mesh.vue** → `MeshRadio.vue`, `MeshChat.vue`, `MeshNetwork.vue`, `MeshFederation.vue`
|
||||
**Dashboard.vue** → `DashboardHome.vue`, `DashboardApps.vue`, `DashboardSystem.vue`
|
||||
|
||||
### Week 25–26: Split Settings.vue (1,792) + Server.vue (1,132)
|
||||
|
||||
**Settings.vue** → `SettingsAccount.vue`, `SettingsSystem.vue`, `SettingsNetwork.vue`, `SettingsAppearance.vue`
|
||||
**Server.vue** → `ServerOverview.vue`, `ServerContainers.vue`, `ServerLogs.vue`
|
||||
|
||||
### Week 27–28: Split Marketplace.vue (1,293) + AppDetails.vue (1,036) + Home.vue (1,059)
|
||||
|
||||
Each into 3-4 focused sub-components.
|
||||
|
||||
### Week 29–30: Decompose useAppStore (324 lines, 16 methods)
|
||||
|
||||
```
|
||||
stores/
|
||||
├── app.ts — Thin re-export for backward compat (~50 lines)
|
||||
├── auth.ts — Login, logout, session, password, TOTP
|
||||
├── server.ts — Server info, system stats, reboot/shutdown
|
||||
├── realtime.ts — WebSocket connection, subscriptions, heartbeat
|
||||
└── packages.ts — Package install/uninstall, marketplace data
|
||||
```
|
||||
|
||||
Tests: every existing import of `useAppStore` still works. State transitions identical.
|
||||
|
||||
### Week 31–32: Remaining frontend P3 issues
|
||||
|
||||
**F18** — aiPermissions runtime validation
|
||||
**F19** — Track AppSession timeout
|
||||
**F20** — Dashboard aria-current
|
||||
**F21** — Debounce search + memoize
|
||||
**F23** — Branded types for DID operations
|
||||
**F24** — Fix checkInterval leak
|
||||
|
||||
---
|
||||
|
||||
## MONTH 9-10: SCRIPT ARCHITECTURE + ISO (Weeks 33–40)
|
||||
|
||||
> Split every monolithic script. Target: no script > 400 lines.
|
||||
|
||||
### Week 33–34: Create shared script library
|
||||
|
||||
```
|
||||
scripts/lib/
|
||||
├── common.sh — Colors, logging, error handling, SSH helpers
|
||||
├── health.sh — Health check polling, container status
|
||||
├── deploy-utils.sh — Rsync, file sync, backup/restore
|
||||
├── container.sh — Podman helpers, image management, mem_limit()
|
||||
└── network.sh — IP validation, port checking
|
||||
```
|
||||
|
||||
Tests: each library function tested in `scripts/tests/`
|
||||
|
||||
### Week 35–36: Split deploy-to-target.sh (1,728 lines)
|
||||
|
||||
```
|
||||
scripts/
|
||||
├── deploy-to-target.sh — Orchestrator + arg parsing (~300 lines)
|
||||
├── deploy/
|
||||
│ ├── frontend.sh — Build + sync frontend
|
||||
│ ├── backend.sh — Build + sync binary
|
||||
│ ├── configs.sh — Sync nginx, systemd, scripts
|
||||
│ ├── containers.sh — Container creation/update
|
||||
│ ├── verify.sh — Post-deploy health checks
|
||||
│ └── rollback.sh — Rollback on failure
|
||||
```
|
||||
|
||||
### Week 37–38: Split ISO build (1,850 lines) + first-boot (855 lines)
|
||||
|
||||
**build-auto-installer-iso.sh** → `build/capture-images.sh`, `build/create-rootfs.sh`, `build/install-packages.sh`, `build/bundle-configs.sh`, `build/package-iso.sh`
|
||||
|
||||
**first-boot-containers.sh** → `first-boot/databases.sh`, `first-boot/bitcoin.sh`, `first-boot/lightning.sh`, `first-boot/apps.sh`, `first-boot/networking.sh`
|
||||
|
||||
### Week 39–40: ISO Reproducibility + Integration Tests
|
||||
|
||||
**S16 — Make ISO builds reproducible**
|
||||
- Create `image-versions.env` with pinned digests for every container image
|
||||
- ISO build sources this file, never pulls `:latest`
|
||||
- Build manifest records exactly what shipped
|
||||
- Tests: two consecutive ISO builds produce identical image sets
|
||||
|
||||
**E2E smoke test script**
|
||||
```bash
|
||||
# scripts/smoke-test.sh — Run against .198
|
||||
# 1. curl /health → OK
|
||||
# 2. Login → get session
|
||||
# 3. Get server info → valid JSON
|
||||
# 4. List containers → all healthy
|
||||
# 5. Check every /app/* proxy → responds
|
||||
# 6. Check Tor hidden service → resolves
|
||||
# 7. Check WebSocket upgrade → 101
|
||||
# Exit 0 only if all pass
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## MONTH 11: INTEGRATION TESTS (Weeks 41–44)
|
||||
|
||||
> Comprehensive test suites that prove everything works.
|
||||
|
||||
### Week 41–42: Backend Integration Tests
|
||||
|
||||
```
|
||||
core/archipelago/tests/
|
||||
├── test_auth_flow.rs — Login → session → CSRF → auth request → logout
|
||||
├── test_container_lifecycle.rs — Install → start → health → stop → uninstall
|
||||
├── test_federation.rs — Generate invite → join → sync → verify
|
||||
├── test_rpc_validation.rs — Every endpoint with invalid input → proper error
|
||||
├── test_session_persist.rs — Create session → restart → session survives
|
||||
├── test_rate_limiting.rs — Flood → 429 → wait → allowed
|
||||
├── test_backup_restore.rs — Create → verify → restore → validate
|
||||
├── test_health_endpoint.rs — Healthy → degraded → recovery
|
||||
```
|
||||
|
||||
Target: 25+ backend integration tests passing
|
||||
|
||||
### Week 43–44: Frontend Integration Tests
|
||||
|
||||
```
|
||||
neode-ui/src/__tests__/integration/
|
||||
├── auth-flow.spec.ts — Login → dashboard → timeout → redirect
|
||||
├── app-lifecycle.spec.ts — Marketplace → install → progress → launch → uninstall
|
||||
├── websocket.spec.ts — Connect → update → disconnect → reconnect → state consistent
|
||||
├── settings-flow.spec.ts — Change password → re-login → 2FA setup → verify
|
||||
├── spotlight.spec.ts — Open → search → navigate → close
|
||||
├── mesh-chat.spec.ts — Connect → send → receive → disconnect
|
||||
├── error-handling.spec.ts — Network error → toast → retry → success
|
||||
├── code-splitting.spec.ts — Route navigation → chunks loaded lazily
|
||||
```
|
||||
|
||||
Target: 20+ frontend integration tests passing
|
||||
|
||||
---
|
||||
|
||||
## MONTH 12: TYPE SYNC + CI/CD PLAN (Weeks 45–48)
|
||||
|
||||
### Week 45–46: Rust↔TypeScript Type Sync
|
||||
|
||||
**Approach**: `ts-rs` crate to auto-generate TypeScript types from Rust structs
|
||||
|
||||
1. Add `ts-rs` to `core/models/Cargo.toml`
|
||||
2. Add `#[derive(TS)]` to all API request/response types
|
||||
3. Build script generates `neode-ui/src/types/generated.ts`
|
||||
4. Replace manual types in `types/api.ts` with imports from generated file
|
||||
5. Verification: regenerate → diff → must be zero (types committed)
|
||||
|
||||
Tests: frontend type-check passes with generated types, manual api.ts reduced to non-API types
|
||||
|
||||
### Week 47–48: CI/CD Planning (Document Only — Execute Later)
|
||||
|
||||
> This section is the PLAN for CI/CD. Do not execute during this phase. Document everything needed so it can be implemented in a future sprint.
|
||||
|
||||
**CI Pipeline Design** (`.github/workflows/ci.yml`):
|
||||
|
||||
```yaml
|
||||
# Triggers: push to main, all PRs
|
||||
# Jobs:
|
||||
# rust-checks (Linux runner):
|
||||
# - cargo clippy --all-targets --all-features (zero warnings gate)
|
||||
# - cargo fmt --all -- --check (formatting gate)
|
||||
# - cargo test --all-features (all tests gate)
|
||||
#
|
||||
# frontend-checks (Node 20):
|
||||
# - npm run type-check (TypeScript strictness gate)
|
||||
# - npm run lint (ESLint gate)
|
||||
# - npm test (Vitest suite gate)
|
||||
#
|
||||
# integration (Linux runner, optional):
|
||||
# - scripts/smoke-test.sh against staging
|
||||
#
|
||||
# Merge policy: all checks must pass before merge
|
||||
# Branch protection: require PR, require checks, no force push to main
|
||||
```
|
||||
|
||||
**Release Pipeline Design** (`.github/workflows/release.yml`):
|
||||
```yaml
|
||||
# Triggers: tag push (v*)
|
||||
# Jobs:
|
||||
# build-linux-binary:
|
||||
# - Cross-compile Rust for x86_64 + ARM64
|
||||
# build-frontend:
|
||||
# - npm run build
|
||||
# build-iso:
|
||||
# - SSH to build server, run ISO build
|
||||
# - Upload ISO as release asset
|
||||
# smoke-test:
|
||||
# - Boot ISO in QEMU
|
||||
# - Run smoke-test.sh
|
||||
# - Gate release on pass
|
||||
```
|
||||
|
||||
**Pre-requisites to implement**:
|
||||
- [ ] GitHub Actions runner with Rust toolchain + cross-compilation
|
||||
- [ ] Node.js 20 runner for frontend
|
||||
- [ ] SSH key for build server accessible from CI
|
||||
- [ ] Branch protection rules configured
|
||||
- [ ] Image digest manifest for reproducible ISO builds
|
||||
- [ ] QEMU-based ISO verification script
|
||||
|
||||
**Estimated implementation time**: 2 weeks when ready to execute
|
||||
|
||||
---
|
||||
|
||||
## VERIFICATION PROTOCOL (Every Week)
|
||||
|
||||
1. `cargo clippy --all-targets --all-features` — zero warnings
|
||||
2. `cargo fmt --all`
|
||||
3. `cargo test --all-features` — all pass
|
||||
4. `cd neode-ui && npm run type-check` — zero errors
|
||||
5. `cd neode-ui && npm test` — all pass
|
||||
6. `./scripts/deploy-to-target.sh --target 192.168.1.198` — **ONLY .198**
|
||||
7. `curl http://192.168.1.198/health` — returns OK with service status
|
||||
8. Navigate all affected views in browser — identical behavior
|
||||
9. Atomic commit: `refactor: <description>` or `fix: <description>`
|
||||
|
||||
---
|
||||
|
||||
## EXIT CRITERIA (Month 12 Complete)
|
||||
|
||||
### Reliability (Zero Tolerance)
|
||||
- [ ] Health endpoint returns real service status
|
||||
- [ ] All async operations have bounded timeouts
|
||||
- [ ] Zero blocking I/O in async context (no std::fs in async functions)
|
||||
- [ ] Zero .unwrap()/.expect() in production code
|
||||
- [ ] All rate limiters have cleanup tasks
|
||||
- [ ] Backup restore uses staging + atomic swap + rollback
|
||||
- [ ] All 30 containers have health checks + memory limits
|
||||
- [ ] All container images pinned to specific versions
|
||||
- [ ] Nginx unauthenticated endpoints protected (timeout + rate limit + body size)
|
||||
- [ ] Systemd service has resource limits
|
||||
- [ ] Tor rotation preserves old address during transition
|
||||
- [ ] Deploy has locking + disk check + rollback
|
||||
- [ ] Zero `sudo podman` in any script
|
||||
- [ ] Zero `:latest` image tags anywhere
|
||||
- [ ] Zero silent error masking without logging
|
||||
|
||||
### Frontend (Zero Tolerance)
|
||||
- [ ] Global error handler catches and displays all errors
|
||||
- [ ] WebSocket: single subscription, reconnect refreshes state, bounded retries
|
||||
- [ ] All timers/listeners cleaned up on unmount
|
||||
- [ ] Code splitting: initial bundle < 200KB gzipped
|
||||
- [ ] v-html always uses DOMPurify
|
||||
- [ ] All localStorage operations wrapped in try/catch
|
||||
|
||||
### Architecture (Target: File Size Limits)
|
||||
- [ ] No Rust file > 500 lines (excluding generated code)
|
||||
- [ ] No Vue component > 500 lines
|
||||
- [ ] No shell script > 400 lines
|
||||
- [ ] No Pinia store has more than 1 responsibility
|
||||
- [ ] All hardcoded URLs/ports extracted to constants
|
||||
- [ ] Shared script library eliminates duplication
|
||||
- [ ] TypeScript types auto-generated from Rust structs
|
||||
|
||||
### Testing
|
||||
- [ ] 25+ backend integration tests passing
|
||||
- [ ] 20+ frontend integration tests passing
|
||||
- [ ] E2E smoke test script passes on .198
|
||||
- [ ] ISO builds are reproducible (pinned digests)
|
||||
|
||||
### CI/CD (Planned, Not Executed)
|
||||
- [ ] CI pipeline design documented
|
||||
- [ ] Release pipeline design documented
|
||||
- [ ] Pre-requisites list complete
|
||||
- [ ] Ready for 2-week implementation sprint
|
||||
|
||||
### Zero Behavior Changes
|
||||
Every feature works identically. Every existing test passes. Every user flow unchanged.
|
||||
@@ -1,108 +0,0 @@
|
||||
# Meshcore Mesh Networking — Phase 1 Implementation Plan
|
||||
|
||||
## Context
|
||||
|
||||
Adding mesh networking to Archipelago using Heltec V3 devices running Meshcore firmware (Companion USB). Two nodes (.228 and .198) will exchange encrypted identity and text messages over LoRa radio with no internet required. The existing `mesh.rs` wraps the Meshtastic CLI — this replaces it with a native Meshcore serial protocol driver.
|
||||
|
||||
## Architecture
|
||||
|
||||
Convert `mesh.rs` into `mesh/` module directory:
|
||||
|
||||
```
|
||||
core/archipelago/src/mesh/
|
||||
├── mod.rs — Public API, MeshService, config (migrated from mesh.rs)
|
||||
├── types.rs — MeshPeer, MeshMessage, MeshStatus, DeviceType
|
||||
├── protocol.rs — Meshcore binary frame protocol (encode/decode/commands)
|
||||
├── serial.rs — MeshcoreDevice: async serial driver (serial2-tokio)
|
||||
├── crypto.rs — X25519 ECDH + ChaCha20-Poly1305 per-message encryption
|
||||
└── listener.rs — Background tokio task: serial reader + message dispatcher
|
||||
```
|
||||
|
||||
Frontend:
|
||||
```
|
||||
neode-ui/src/stores/mesh.ts — Pinia store
|
||||
neode-ui/src/views/Mesh.vue — Mesh status, peers, messaging UI
|
||||
```
|
||||
|
||||
## Dependency
|
||||
|
||||
Add to `core/archipelago/Cargo.toml`:
|
||||
```toml
|
||||
serial2-tokio = "0.1"
|
||||
```
|
||||
|
||||
All crypto deps already present (chacha20poly1305, ed25519-dalek, curve25519-dalek).
|
||||
|
||||
## Meshcore Protocol Summary
|
||||
|
||||
- **Frame format**: `>` + 2-byte LE length + data (outbound), `<` + 2-byte LE length + data (inbound)
|
||||
- **Baud**: 115200, 8N1
|
||||
- **Max message**: 160 bytes
|
||||
- **Init sequence**: CMD_DEVICE_QUERY (0x16) -> CMD_APP_START (0x01) -> CMD_SET_DEVICE_TIME (0x06)
|
||||
- **Key commands**: SEND_TXT_MSG (0x02), SEND_CHANNEL_TXT_MSG (0x03), GET_CONTACTS (0x04), SYNC_NEXT_MESSAGE (0x0A), SEND_SELF_ADVERT (0x07)
|
||||
- **Push events** (async, >=0x80): NEW_CONTACT (0x8A), ACK (0x82), MESSAGES_WAITING (0x83)
|
||||
|
||||
## Encryption Design
|
||||
|
||||
Reuses existing identity.rs X25519 key agreement:
|
||||
1. Nodes broadcast identity on mesh channel: `ARCHY:1:{did}:{ed25519_pubkey}:{x25519_pubkey}`
|
||||
2. Receiving node derives shared secret: X25519(our_secret, their_x25519_pub)
|
||||
3. All DMs encrypted: ChaCha20-Poly1305 with random 12-byte nonce
|
||||
4. Wire format: [nonce 12B] + [ciphertext] + [tag 16B] — fits in 160B limit for ~130B plaintext
|
||||
|
||||
## RPC Endpoints
|
||||
|
||||
| Method | Action |
|
||||
|--------|--------|
|
||||
| `mesh.status` | Device + mesh status (updated) |
|
||||
| `mesh.peers` | **NEW** — list discovered mesh peers |
|
||||
| `mesh.messages` | **NEW** — get message history (last 100) |
|
||||
| `mesh.send` | **NEW** — send encrypted message to peer |
|
||||
| `mesh.broadcast` | Broadcast identity (updated for Meshcore) |
|
||||
| `mesh.configure` | Update config (updated) |
|
||||
|
||||
## Implementation Steps
|
||||
|
||||
1. **Create mesh/ module, migrate existing code** — types.rs + mod.rs from mesh.rs
|
||||
2. **protocol.rs** — Binary frame encode/decode, command builders, response parsers + unit tests
|
||||
3. **crypto.rs** — X25519 ECDH + ChaCha20-Poly1305 encrypt/decrypt + unit tests
|
||||
4. **serial.rs** — MeshcoreDevice with open/init/send/recv + device auto-detection
|
||||
5. **listener.rs** — Background task: serial reader, peer cache, message store, reconnect
|
||||
6. **mod.rs MeshService** — Wraps listener + config, start/stop lifecycle
|
||||
7. **Update RPC handlers** — New endpoints, wire MeshService into RpcHandler
|
||||
8. **Update RPC dispatch** — Add routes in mod.rs ~line 622
|
||||
9. **Frontend store + view** — mesh.ts Pinia store, Mesh.vue with glass-card UI, router + nav
|
||||
10. **Deploy + test** — Deploy to .228 and .198, plug in Heltec V3s, test end-to-end
|
||||
|
||||
## Key Files to Modify
|
||||
|
||||
- `core/archipelago/src/mesh.rs` -> delete, replace with `mesh/` directory
|
||||
- `core/archipelago/src/api/rpc/mesh.rs` — update handlers
|
||||
- `core/archipelago/src/api/rpc/mod.rs` — add routes (~line 622)
|
||||
- `core/archipelago/Cargo.toml` — add serial2-tokio
|
||||
- `neode-ui/src/router/index.ts` — add /dashboard/mesh route
|
||||
- `neode-ui/src/views/Dashboard.vue` — add Mesh nav item
|
||||
|
||||
## Reusable Existing Code
|
||||
|
||||
- `identity.rs` lines 140-152: Ed25519 -> X25519 conversion (CompressedEdwardsY -> Montgomery)
|
||||
- `identity.rs` `pubkey_bytes_from_did_key()`: extract raw pubkey from DID string
|
||||
- `node_message.rs` pattern: IncomingMessage store with max 100 circular buffer
|
||||
- `mesh.rs` `MeshConfig` + `load_config`/`save_config`: migrate directly into mod.rs
|
||||
- `mesh.rs` `detect_meshtastic_devices()`: keep as fallback, add Meshcore probe-based detection
|
||||
|
||||
## Prerequisites
|
||||
|
||||
- Flash both Heltec V3 with Meshcore **Companion USB** role
|
||||
- Add `archipelago` user to `dialout` group: `usermod -aG dialout archipelago`
|
||||
- Connect Heltec V3 to USB on .228 and .198
|
||||
|
||||
## Verification
|
||||
|
||||
1. `cargo clippy --all-targets` passes with zero warnings
|
||||
2. Unit tests pass: protocol encode/decode, crypto encrypt/decrypt roundtrip
|
||||
3. Device detected on /dev/ttyUSB0 or /dev/ttyACM0
|
||||
4. Init handshake completes (visible in tracing logs)
|
||||
5. Identity broadcast from .228, received on .198
|
||||
6. Encrypted DM sent .228 -> .198, decrypted and visible in UI
|
||||
7. Mesh.vue shows device status, peer list, message history
|
||||
@@ -1,80 +0,0 @@
|
||||
# Plan: Demo Seeding, Dev Environment Fix, and Developer Onboarding
|
||||
|
||||
## Context
|
||||
After the repo cleanup (docs/scripts archived to `~/Projects/archy-archive/`), several dev scripts reference deleted files. Additionally, the demo needs better seeding for Portainer showcase, ThunderHub + Fedimint need to be visible, and a new developer needs docs to onboard.
|
||||
|
||||
## Changes
|
||||
|
||||
### 1. Fix broken dev scripts
|
||||
|
||||
**`neode-ui/start-dev.sh`** — Remove lines 72-110 (Docker Desktop check + `start-docker-apps.sh` call). Replace with a one-liner noting mock backend handles simulation.
|
||||
|
||||
**`neode-ui/stop-dev.sh`** — Remove lines 66-74 (Docker container stop block calling `stop-docker-apps.sh`).
|
||||
|
||||
**`neode-ui/package.json`** — Remove the `prebuild` script (line 22) that references archived `../../loop-start.mp3`. File already exists at `public/assets/audio/`.
|
||||
|
||||
**`scripts/dev-start.sh`** — Fix option 2 (Full Stack) lines 67-84 that reference `start-docker-apps.sh`. Guard with a skip message instead of failing.
|
||||
|
||||
### 2. Add ThunderHub (Lightning management UI)
|
||||
|
||||
**Files**: mock-backend.js, Marketplace.vue, appLauncher.ts, new icon SVG
|
||||
|
||||
- Port: **3010** (3000 taken by Grafana)
|
||||
- Docker image: `apotdevin/thunderhub:v0.13.31`
|
||||
- Add to `portMappings`, `marketplaceMetadata`, `staticDevApps`, `marketplace.get()` in mock-backend.js
|
||||
- Add to `getCuratedAppList()` in Marketplace.vue (after LND entry)
|
||||
- Add to `recommended` tier in `getAppTier()`
|
||||
- Add `'3010': 'thunderhub'` to PORT_TO_APP_ID in appLauncher.ts
|
||||
- Create `neode-ui/public/assets/img/app-icons/thunderhub.svg` (Bitcoin-orange lightning bolt icon)
|
||||
|
||||
### 3. Improve Fedimint in demo
|
||||
|
||||
**mock-backend.js**:
|
||||
- Add `fedimint` to `staticDevApps` (pre-installed, running, port 8175)
|
||||
- Update `marketplace.get()` version from `0.4.3` → `0.10.0`
|
||||
- Fix `portMappings.fedimint` from 8174 → 8175 (Guardian UI port)
|
||||
|
||||
### 4. Add realistic notifications
|
||||
|
||||
**mock-backend.js** — Replace empty `node.notifications` with 5 realistic entries: Bitcoin sync, LND channel opened, disk warning, system update, Fedimint guardian connected.
|
||||
|
||||
### 5. Rewrite README for developer onboarding
|
||||
|
||||
**`neode-ui/README.md`** — Full rewrite:
|
||||
- Quick start (npm install, npm start, localhost:8100, password123)
|
||||
- Architecture overview
|
||||
- Dev modes (setup/onboarding/existing/boot)
|
||||
- Mock backend capabilities (8 static apps, 30+ marketplace, WebSocket, FileBrowser API, Claude proxy)
|
||||
- Demo deployment (docker-compose.demo.yml, Portainer, ANTHROPIC_API_KEY)
|
||||
- Design system (glassmorphism classes, tokens)
|
||||
- Build commands
|
||||
- Remove Angular references and outdated sections
|
||||
|
||||
**`neode-ui/DEV-SCRIPTS.md`** — Update "Available Test Apps" section to list the 8 actual static apps, remove Docker apps references.
|
||||
|
||||
### 6. Verify Docker demo build
|
||||
|
||||
Confirm `docker-compose.demo.yml` paths still valid after cleanup:
|
||||
- `demo/aiui/` exists (for Dockerfile.web COPY)
|
||||
- `neode-ui/docker/nginx-demo.conf` exists
|
||||
- `neode-ui/docker/docker-entrypoint.sh` exists
|
||||
|
||||
## Files to modify
|
||||
1. `neode-ui/start-dev.sh`
|
||||
2. `neode-ui/stop-dev.sh`
|
||||
3. `neode-ui/package.json`
|
||||
4. `scripts/dev-start.sh`
|
||||
5. `neode-ui/mock-backend.js`
|
||||
6. `neode-ui/src/views/Marketplace.vue`
|
||||
7. `neode-ui/src/stores/appLauncher.ts`
|
||||
8. `neode-ui/public/assets/img/app-icons/thunderhub.svg` (new)
|
||||
9. `neode-ui/README.md`
|
||||
10. `neode-ui/DEV-SCRIPTS.md`
|
||||
|
||||
## Verification
|
||||
1. `cd neode-ui && npm start` — should start cleanly, no errors about missing scripts
|
||||
2. Visit localhost:8100 → login → Dashboard shows 8 apps (bitcoin, lnd, electrs, mempool, lorabell, filebrowser, thunderhub, fedimint)
|
||||
3. Marketplace shows ThunderHub in Bitcoin category
|
||||
4. Notifications bell shows 3 unread
|
||||
5. `npm stop` — clean shutdown, no errors
|
||||
6. `docker compose -f docker-compose.demo.yml build` — builds successfully
|
||||
@@ -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,244 +0,0 @@
|
||||
# Manage — Claude Code Configuration Dashboard
|
||||
|
||||
## Context
|
||||
|
||||
You have 77 skills, 15 hooks, 17 memory files, 19 plans, and settings across 5 projects + global scope. All stored as flat files (markdown with YAML frontmatter, JSON, bash scripts) under `~/.claude/` and `{project}/.claude/`. Currently the only way to manage these is manually editing files. This project creates a visual web dashboard for browsing, creating, editing, and organizing all of it.
|
||||
|
||||
**Project location**: `/Users/dorian/Projects/Manage`
|
||||
**Stack**: Vue 3 + Vite + TypeScript + Tailwind + Pinia (frontend) + Express + tsx (backend)
|
||||
**Design**: Glassmorphism dark theme (matching Archipelago aesthetic)
|
||||
|
||||
---
|
||||
|
||||
## Architecture
|
||||
|
||||
```
|
||||
Browser (localhost:5173) Express Server (localhost:3141)
|
||||
+-----------------------+ +----------------------------+
|
||||
| Vue 3 SPA | fetch | /api/projects |
|
||||
| +-- Dashboard | ------> | /api/skills (CRUD) |
|
||||
| +-- Skills | | /api/hooks (CRUD) |
|
||||
| +-- Hooks | SSE | /api/memory (CRUD) |
|
||||
| +-- Memory | <------ | /api/plans (CRUD) |
|
||||
| +-- Plans | | /api/settings (R/W) |
|
||||
| +-- Settings | | /api/claude-md (R/W) |
|
||||
| +-- CLAUDE.md | | /api/search |
|
||||
+-----------------------+ | /api/events (SSE) |
|
||||
+-------------+--------------+
|
||||
| chokidar
|
||||
+-------------v--------------+
|
||||
| ~/.claude/ |
|
||||
| ~/Projects/*/.claude/ |
|
||||
+----------------------------+
|
||||
```
|
||||
|
||||
Single command start: `npm start` runs both server + Vite via concurrently.
|
||||
|
||||
---
|
||||
|
||||
## Phase 1: Foundation — Project Setup + Dashboard
|
||||
|
||||
### 1.1 Scaffold project
|
||||
- `npm create vite@latest` with Vue + TypeScript
|
||||
- Install deps: `express`, `cors`, `gray-matter`, `chokidar`, `concurrently`, `tsx`, `@vueuse/core`, `vue-router`, `pinia`, `fuse.js`
|
||||
- Configure `vite.config.ts` with `@` alias and `/api` proxy to `:3141`
|
||||
- Configure Tailwind with glassmorphism tokens from archy
|
||||
|
||||
### 1.2 Design system (`src/style.css`)
|
||||
- Port glassmorphism classes from `neode-ui/src/style.css`: `.glass-card`, `.glass-button`, `.path-option-card`, `.info-card`, `.scope-badge`
|
||||
- New classes: `.skill-card`, `.hook-node`, `.memory-tree-item`, `.plan-progress-bar`, `.editor-panel`
|
||||
- Background: `#0a0a0a`, accent: `#fb923c`
|
||||
|
||||
### 1.3 Backend: Project discovery
|
||||
- **`server/index.ts`** — Express on :3141 with CORS + JSON body parser
|
||||
- **`server/lib/discovery.ts`** — Scan `~/Projects/` for dirs with `.claude/`, decode `~/.claude/projects/` encoded paths, count skills/hooks/memory/plans per project
|
||||
- **`GET /api/projects`** — Return project list with counts
|
||||
|
||||
### 1.4 Frontend: App shell + Dashboard
|
||||
- **`AppShell.vue`** — Sidebar (project switcher + nav links) + router-view content area
|
||||
- **`Sidebar.vue`** — "Global" at top, then project list; active project highlighted; click to switch scope
|
||||
- **`Dashboard.vue`** — Stats row (total skills/hooks/memory/plans) + project cards grid
|
||||
- **`ProjectCard.vue`** — Glass card showing project name, path, skill/hook/memory counts, click to select
|
||||
- **`stores/projects.ts`** — Pinia store: `projects[]`, `activeProject`, `fetchProjects()`, `setActiveProject()`
|
||||
|
||||
**Verify**: `npm start` opens browser, sidebar shows 5 projects + global, dashboard shows stats.
|
||||
|
||||
---
|
||||
|
||||
## Phase 2: Skills Manager
|
||||
|
||||
### 2.1 Backend
|
||||
- **`server/lib/skill-parser.ts`** — Parse SKILL.md YAML frontmatter via `gray-matter`, handle both `skills/{name}/SKILL.md` (dir-based) and `skills/{name}.md` (flat) formats
|
||||
- **`server/lib/fs-utils.ts`** — Safe read/write/delete/mkdir helpers with atomic writes
|
||||
- **`server/routes/skills.ts`** — Full CRUD + `POST /api/skills/move` for scope transfers
|
||||
|
||||
### 2.2 Frontend
|
||||
- **`Skills.vue`** — Top bar: scope filter, grid/list toggle, category dropdown, search. Grid of SkillCards. FAB for "New Skill"
|
||||
- **`SkillCard.vue`** — Name, description (truncated), scope badge, category color stripe, allowed-tools pills. Click opens editor.
|
||||
- **`SkillEditor.vue`** — Slide-in panel: frontmatter form (name, description, category, tags, allowed-tools, disable-model-invocation toggle) + Monaco editor for markdown body + live preview
|
||||
- **`InheritanceMap.vue`** — Two-column view: global skills left, project skills right, connecting lines for name-matched overrides
|
||||
- **Drag-and-drop**: Drag SkillCard between global/project columns to move/copy. Uses `vue-draggable-plus`.
|
||||
|
||||
**Verify**: Browse all 77 skills, create/edit/delete, drag between scopes, see inheritance.
|
||||
|
||||
---
|
||||
|
||||
## Phase 3: Hooks Manager
|
||||
|
||||
### 3.1 Backend
|
||||
- **`server/lib/hook-parser.ts`** — Parse `settings.json` hook entries + read referenced `.sh` files. Detect orphaned scripts.
|
||||
- **`server/routes/hooks.ts`** — CRUD + `PUT /toggle` for enable/disable. Creates .sh + updates settings.json atomically.
|
||||
|
||||
### 3.2 Frontend
|
||||
- **`Hooks.vue`** — Grouped by event type (PreToolUse, PostToolUse, UserPromptSubmit, Stop, SessionEnd)
|
||||
- **`HookPipeline.vue`** — Visual flow per hook: `[Event Badge] -> [Matcher Pill] -> [Script Name] -> [Action]` with CSS-drawn connecting arrows
|
||||
- **`HookCard.vue`** — Event type badge (color-coded), matcher, script filename, enabled/disabled toggle switch
|
||||
- **`HookEditor.vue`** — Monaco editor for `.sh` script + form for event type and matcher pattern
|
||||
- Orphaned scripts in "Unlinked Scripts" section with "Link" button
|
||||
|
||||
**Verify**: See all 15 hooks in pipeline view, toggle enable/disable, edit scripts, create new hook.
|
||||
|
||||
---
|
||||
|
||||
## Phase 4: Memory Browser
|
||||
|
||||
### 4.1 Backend
|
||||
- **`server/lib/memory-parser.ts`** — Parse from both locations: `{project}/.claude/memory/` (git-tracked) and `~/.claude/projects/{encoded}/memory/` (private). Parse YAML frontmatter.
|
||||
- **`server/routes/memory.ts`** — CRUD + auto-sync MEMORY.md index on create/delete
|
||||
|
||||
### 4.2 Frontend
|
||||
- **`Memory.vue`** — Split layout: tree panel (left 300px) + content panel (right)
|
||||
- **`MemoryTree.vue`** — Collapsible tree: Project -> Scope -> Type -> Files. Type badges: user (blue), feedback (orange), project (green), reference (purple)
|
||||
- **`MemoryEditor.vue`** — Frontmatter form (name, description, type dropdown) + Monaco editor + markdown preview toggle
|
||||
- Search input at top filters across titles and content
|
||||
|
||||
**Verify**: Browse all 17 memory files in tree, types color-coded, edit with preview, create new, MEMORY.md auto-updates.
|
||||
|
||||
---
|
||||
|
||||
## Phase 5: Plans Tracker
|
||||
|
||||
### 5.1 Backend
|
||||
- **`server/lib/plan-parser.ts`** — Extract title from `#`, phases from `##`, tasks from `- [ ]`/`- [x]` with line numbers. Calculate completion percentages.
|
||||
- **`server/routes/plans.ts`** — CRUD + `PUT /task` for toggling single checkbox by line number
|
||||
|
||||
### 5.2 Frontend
|
||||
- **`PlanCard.vue`** — Title, overall progress bar, phase count, "12/47 tasks" text
|
||||
- **`PlanDetail.vue`** — Expanded: title, summary, phases as sections with TaskCheckboxes
|
||||
- **`PhaseBar.vue`** — Segmented bar: green (done) / amber (in-progress) / gray (pending)
|
||||
- **`TaskCheckbox.vue`** — Click toggles checkbox, instant API call to update file
|
||||
- "Edit Raw" switches to Monaco. "New Plan" uses overnight template.
|
||||
|
||||
**Verify**: See all 19 plans with progress bars, toggle checkboxes that persist, create new plan.
|
||||
|
||||
---
|
||||
|
||||
## Phase 6: Settings + CLAUDE.md Editor
|
||||
|
||||
### 6.1 Settings
|
||||
- **`Settings.vue`** — Scope tabs (Global / Project). Sections:
|
||||
- Permissions: toggle switches for allowed tools
|
||||
- Hooks: visual tree of event -> matcher -> command with add/remove
|
||||
- Plugins: installed plugin cards with enable/disable
|
||||
- Effort Level: dropdown
|
||||
- Raw JSON: toggle to edit settings.json directly in Monaco
|
||||
|
||||
### 6.2 CLAUDE.md
|
||||
- **`ClaudeMd.vue`** — Scope tabs. Monaco editor with markdown syntax. Live preview panel. Unsaved changes indicator. Save button.
|
||||
|
||||
**Verify**: Edit settings, toggle permissions, edit CLAUDE.md with preview, confirm files updated.
|
||||
|
||||
---
|
||||
|
||||
## Phase 7: Polish — File Watching, Search, Animations
|
||||
|
||||
### 7.1 Live file watching
|
||||
- **`server/lib/file-watcher.ts`** — chokidar watches all `.claude/` dirs. Debounce 300ms. Push SSE events.
|
||||
- **`useFileWatcher.ts`** composable — EventSource connection, triggers store refresh on changes
|
||||
|
||||
### 7.2 Global search
|
||||
- **`GET /api/search?q=bitcoin`** — Full-text across skills, memory, plans, CLAUDE.md
|
||||
- **`TopBar.vue`** — Cmd+K search input with dropdown results
|
||||
|
||||
### 7.3 Drag-and-drop refinement
|
||||
- `vue-draggable-plus` for skills between scopes and plan task reordering
|
||||
|
||||
### 7.4 Final polish
|
||||
- Loading skeletons, empty states, confirm dialogs on deletes
|
||||
- Keyboard shortcuts: Cmd+K (search), Cmd+S (save), Escape (close panels)
|
||||
- View transitions (fade + slide)
|
||||
|
||||
**Verify**: External file edits trigger UI refresh. Cmd+K searches everything. Drag skills between scopes.
|
||||
|
||||
---
|
||||
|
||||
## Project Structure
|
||||
|
||||
```
|
||||
Manage/
|
||||
+-- package.json
|
||||
+-- tsconfig.json
|
||||
+-- vite.config.ts
|
||||
+-- tailwind.config.ts
|
||||
+-- index.html
|
||||
+-- .gitignore
|
||||
+-- server/
|
||||
| +-- index.ts
|
||||
| +-- tsconfig.json
|
||||
| +-- routes/
|
||||
| | +-- projects.ts, skills.ts, hooks.ts, memory.ts
|
||||
| | +-- plans.ts, settings.ts, claude-md.ts, search.ts
|
||||
| +-- lib/
|
||||
| | +-- discovery.ts, skill-parser.ts, hook-parser.ts
|
||||
| | +-- memory-parser.ts, plan-parser.ts, settings-parser.ts
|
||||
| | +-- file-watcher.ts, fs-utils.ts
|
||||
| +-- types/
|
||||
| +-- index.ts
|
||||
+-- src/
|
||||
| +-- main.ts, App.vue, style.css
|
||||
| +-- api/client.ts
|
||||
| +-- router/index.ts
|
||||
| +-- stores/ (projects, skills, hooks, memory, plans, settings, search)
|
||||
| +-- types/ (skill, hook, memory, plan, project, settings)
|
||||
| +-- composables/ (useFileWatcher, useMarkdownPreview, useMonaco)
|
||||
| +-- views/ (Dashboard, Skills, Hooks, Memory, Plans, Settings, ClaudeMd)
|
||||
| +-- components/
|
||||
| +-- layout/ (AppShell, Sidebar, TopBar)
|
||||
| +-- shared/ (GlassCard, GlassButton, ScopeBadge, MonacoEditor, etc.)
|
||||
| +-- dashboard/ (ProjectCard, QuickStats)
|
||||
| +-- skills/ (SkillCard, SkillEditor, SkillList, InheritanceMap)
|
||||
| +-- hooks/ (HookPipeline, HookCard, HookEditor)
|
||||
| +-- memory/ (MemoryTree, MemoryCard, MemoryEditor)
|
||||
| +-- plans/ (PlanCard, PlanDetail, PhaseBar, TaskCheckbox)
|
||||
| +-- settings/ (PermissionToggle, HookConfig, PluginCard)
|
||||
+-- public/
|
||||
+-- favicon.svg
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Key Libraries
|
||||
|
||||
| Library | Purpose |
|
||||
|---------|---------|
|
||||
| `express` + `cors` | Backend HTTP server |
|
||||
| `tsx` | Run TypeScript server without build step |
|
||||
| `concurrently` | Run server + Vite in one command |
|
||||
| `gray-matter` | Parse YAML frontmatter from markdown |
|
||||
| `chokidar` | Watch filesystem for live updates |
|
||||
| `monaco-editor` + `@monaco-editor/loader` | Code editor (md, bash, json, yaml) |
|
||||
| `marked` + `highlight.js` | Markdown rendering with syntax highlighting |
|
||||
| `vue-draggable-plus` | Drag-and-drop for skills and plan tasks |
|
||||
| `fuse.js` | Client-side fuzzy search |
|
||||
| `@vueuse/core` | Vue utilities (useEventSource, useDebounceFn) |
|
||||
|
||||
---
|
||||
|
||||
## Key Decisions
|
||||
|
||||
- **Express over Bun**: More predictable on macOS, better middleware ecosystem
|
||||
- **SSE over WebSocket**: File watching is server->client only. SSE auto-reconnects, simpler.
|
||||
- **Monaco over CodeMirror**: VS Code-like editing for all 4 file types
|
||||
- **Atomic settings.json writes**: Read-modify-write with temp file + rename
|
||||
- **MEMORY.md auto-sync**: Create/delete memory files auto-updates the index
|
||||
- **Both skill formats**: Parser handles dir-based and flat-file skills
|
||||
@@ -1,103 +0,0 @@
|
||||
# Plan: Fix Iframe Apps, Detail Pages, Kiosk, Identity Pairing, NIP-07
|
||||
|
||||
## Context
|
||||
|
||||
Three web-only apps (BotFights, 484 Kitchen, Arch Presentation) show black screens in iframe despite nginx reverse proxies being set up. The kiosk on .228 isn't running. Web-only apps need proper detail pages. The user wants Nostr identity formally paired with DID and NIP-07 browser integration for frictionless login to embedded apps.
|
||||
|
||||
---
|
||||
|
||||
## Task 1: Fix iframe black screen (HIGH)
|
||||
|
||||
**Root cause**: Proxied HTML contains root-relative paths (`href="/css/main.css"`). Browser resolves these against the origin root, not `/ext/botfights/`, so all assets 404.
|
||||
|
||||
**Fix**: Add `sub_filter` to nginx proxy blocks to rewrite root-relative paths.
|
||||
|
||||
**File**: `image-recipe/configs/nginx-archipelago.conf` (6 location blocks — 3 HTTP, 3 HTTPS)
|
||||
|
||||
Key additions per block:
|
||||
```nginx
|
||||
proxy_set_header Accept-Encoding ""; # Disable gzip so sub_filter works
|
||||
sub_filter_once off;
|
||||
sub_filter_types text/html text/css application/javascript;
|
||||
sub_filter 'href="/' 'href="/ext/{app}/';
|
||||
sub_filter 'src="/' 'src="/ext/{app}/';
|
||||
sub_filter 'action="/' 'action="/ext/{app}/';
|
||||
sub_filter "href='/" "href='/ext/{app}/";
|
||||
sub_filter "src='/" "src='/ext/{app}/";
|
||||
```
|
||||
|
||||
Deploy + nginx reload. Verify in browser DevTools (Network tab — no 404s on assets).
|
||||
|
||||
---
|
||||
|
||||
## Task 2: Detail pages for web-only apps (MEDIUM)
|
||||
|
||||
**Problem**: Clicking a web-only app card navigates to `/dashboard/apps/{id}`. AppDetails.vue can't resolve it because web-only apps aren't in `store.packages` or `dummyApps`.
|
||||
|
||||
**Fix**:
|
||||
1. Add 7 web-only apps to `dummyApps` in AppDetails.vue (botfights, nwnn, 484-kitchen, call-the-operator, arch-presentation, syntropy-institute, t-zero) — same pattern as IndeeHub
|
||||
2. Add URL mappings in AppDetails.vue `appUrls` for all 7 (if not already present)
|
||||
3. Hide uninstall/start/stop buttons for web-only apps in AppDetails.vue
|
||||
|
||||
**Files**: `neode-ui/src/views/AppDetails.vue`
|
||||
|
||||
---
|
||||
|
||||
## Task 3: Kiosk on .228 (MEDIUM)
|
||||
|
||||
**Problem**: Code exists but was never installed on server. No X11/Chromium packages.
|
||||
|
||||
**Steps** (SSH to .228, no code changes):
|
||||
1. `sudo apt-get install -y xorg chromium unclutter xinit`
|
||||
2. `cd ~/archy && sudo ./scripts/setup-kiosk.sh archipelago`
|
||||
3. `sudo systemctl enable --now archipelago-kiosk.service`
|
||||
4. Verify on monitor
|
||||
|
||||
---
|
||||
|
||||
## Task 4: Pair Nostr identity with DID (LOW)
|
||||
|
||||
**Current state**: Ed25519 (DID) and secp256k1 (Nostr) are separate key pairs, both generated at startup. Not formally linked.
|
||||
|
||||
**Fix**: Include the Nostr secp256k1 pubkey in the DID Document as an additional verification method:
|
||||
- Modify `did_document_from_pubkey_hex()` in `identity.rs` to accept optional Nostr pubkey
|
||||
- Add `EcdsaSecp256k1VerificationKey2019` entry to `verificationMethod` array
|
||||
- Pass Nostr pubkey from server startup context
|
||||
|
||||
**Files**: `core/archipelago/src/identity.rs`, `core/archipelago/src/server.rs`
|
||||
|
||||
---
|
||||
|
||||
## Task 5: NIP-07 Nostr login via iframe injection (EXPLORATORY)
|
||||
|
||||
**Goal**: Web apps in iframe (like IndeeHub) can call `window.nostr.getPublicKey()` and `window.nostr.signEvent()` for frictionless Nostr login.
|
||||
|
||||
**Approach**: Inject a `window.nostr` shim into proxied pages via `sub_filter`, communicating with the parent Archipelago frame via `postMessage`.
|
||||
|
||||
**Steps**:
|
||||
1. Create `neode-ui/public/nostr-provider.js` — implements `window.nostr` interface, uses `postMessage` to parent
|
||||
2. Add `sub_filter '</head>' '<script src="/nostr-provider.js"></script></head>';` to nginx ext proxy blocks
|
||||
3. Add `postMessage` listener in AppLauncherOverlay that handles `nostr-getPublicKey` and `nostr-signEvent` by calling backend RPC
|
||||
4. Backend already has `identity.nostr-sign` and `node.nostr-pubkey` RPC endpoints
|
||||
|
||||
**Security**: Validate postMessage origin, prompt user before signing, never expose secret key to frontend.
|
||||
|
||||
**Files**: new `neode-ui/public/nostr-provider.js`, `image-recipe/configs/nginx-archipelago.conf`, AppLauncherOverlay component, `neode-ui/src/stores/appLauncher.ts`
|
||||
|
||||
---
|
||||
|
||||
## Execution Order
|
||||
|
||||
1. Task 1 — fix iframe black screen (deploy nginx)
|
||||
2. Task 2 — detail pages (deploy frontend)
|
||||
3. Task 3 — kiosk on .228 (SSH ops)
|
||||
4. Task 4 — DID+Nostr pairing (deploy backend)
|
||||
5. Task 5 — NIP-07 injection (deploy full)
|
||||
|
||||
## Verification
|
||||
|
||||
- Task 1: Open BotFights/484 Kitchen/Arch Presentation in iframe — page renders with styles and interactivity
|
||||
- Task 2: Click web-only app card → detail page shows with title, description, launch button, no container buttons
|
||||
- Task 3: .228 monitor shows kiosk app grid
|
||||
- Task 4: `node.did` RPC returns DID Document with Nostr pubkey in verificationMethod
|
||||
- Task 5: Open IndeeHub in iframe, browser console `window.nostr.getPublicKey()` returns hex pubkey
|
||||
@@ -1,173 +0,0 @@
|
||||
# Mesh Phase 4 Completion + Phase 5 Implementation
|
||||
|
||||
## Context
|
||||
|
||||
Mesh Phases 1-3 are complete: serial driver, transport layer (Mesh>LAN>Tor), Double Ratchet encryption, typed messages, store-and-forward, chat UI. Phase 4 is 40% done — data structures, builders, and tests exist (`bitcoin_relay.rs`, `alerts.rs`, `message_types.rs`) but nothing is wired into the listener, MeshService, or RPC layer. Phase 5 (steganographic modes, adaptive routing, multi-hardware) is not started.
|
||||
|
||||
## Phase 4: Wire Up Off-Grid Bitcoin Operations (Weeks 8-11)
|
||||
|
||||
### Week 8: Typed Message Dispatch in Listener
|
||||
|
||||
**The critical foundation — everything else depends on this.**
|
||||
|
||||
**`mesh/listener.rs`:**
|
||||
- Add `MeshCommand::SendRaw { dest_pubkey_prefix: [u8; 6], payload: Vec<u8> }` and `BroadcastChannel { channel: u8, payload: Vec<u8> }` variants
|
||||
- In `handle_frame()`: after extracting message bytes, check for `0x02` TypedEnvelope prefix
|
||||
- New `handle_typed_message()` dispatches by type:
|
||||
- `BlockHeader` → validate Ed25519 sig, store in `BlockHeaderCache`, emit event
|
||||
- `TxRelay` → spawn task: Bitcoin RPC `sendrawtransaction`, send `TxRelayResponse` back
|
||||
- `TxRelayResponse` → complete pending in `RelayTracker`, store as MeshMessage
|
||||
- `LightningRelay` → spawn task: LND REST `payinvoice`, send response back
|
||||
- `LightningRelayResponse` → complete pending, store
|
||||
- `Alert` → verify sig, store, emit `MeshEvent::AlertReceived`
|
||||
- Handle `SendRaw` and `BroadcastChannel` in `tokio::select!` command dispatch
|
||||
|
||||
**`mesh/types.rs`:** New `MeshEvent` variants: `BlockHeaderReceived`, `AlertReceived`, `TxRelayCompleted`, `LightningRelayCompleted`
|
||||
|
||||
**Key design:** Spawn separate tokio tasks for Bitcoin/LND HTTP calls (don't block serial read loop). Response sent back via `cmd_tx` channel.
|
||||
|
||||
### Week 9: MeshService Integration + Dead Man's Switch Task
|
||||
|
||||
**`mesh/mod.rs`:**
|
||||
- Add fields: `block_header_cache: Arc<BlockHeaderCache>`, `relay_tracker: Arc<RelayTracker>`, `dead_man_switch: Arc<DeadManSwitch>`, `signing_key: ed25519_dalek::SigningKey`
|
||||
- Init in `new()`, pass cache + tracker into listener via `MeshState`
|
||||
- Accessor methods for RPC layer
|
||||
|
||||
**Dead Man background task** (spawned in `start()`):
|
||||
- Check every 60s: if triggered → build signed alert → broadcast on channel 0 + direct to emergency contacts
|
||||
- Persist `last_check_in_time` as unix timestamp on disk (survives restarts)
|
||||
|
||||
### Week 10: RPC Endpoints
|
||||
|
||||
**`api/rpc/mesh.rs`** — New handlers:
|
||||
|
||||
| Endpoint | Params | Description |
|
||||
|----------|--------|-------------|
|
||||
| `mesh.relay-tx` | `{ tx_hex }` | Queue TX for relay via internet peer |
|
||||
| `mesh.block-headers` | `{ count? }` | Return cached block headers |
|
||||
| `mesh.relay-lightning` | `{ bolt11, amount_sats }` | Queue LN invoice for payment |
|
||||
| `mesh.deadman-status` | — | Query switch state |
|
||||
| `mesh.deadman-configure` | `{ enabled, interval_secs, lat, lng, contacts, custom_message }` | Configure |
|
||||
| `mesh.deadman-checkin` | — | Heartbeat reset |
|
||||
|
||||
**Fix `mesh.send-invoice`:** Replace placeholder bolt11 with real LND `POST /v1/invoices` call.
|
||||
|
||||
**`api/rpc/mod.rs`:** Register all new routes (~line 643).
|
||||
|
||||
### Week 11: Block Header Announcer + Frontend
|
||||
|
||||
**Backend:** Optional background task: poll Bitcoin Core `getblockchaininfo` every 30s → on new block → signed announcement → broadcast channel 0. Config: `announce_block_headers: bool`.
|
||||
|
||||
**Frontend `stores/mesh.ts`:** New methods for all Phase 4 RPC calls.
|
||||
|
||||
**Frontend `views/Mesh.vue`:**
|
||||
- "Off-Grid Bitcoin" panel: block height, headers, TX relay form, LN relay form
|
||||
- "Dead Man's Switch" panel: enable/disable, interval, GPS, contacts, countdown, check-in
|
||||
- Uses `.path-option-card`, `.glass-button`, `.info-card`
|
||||
|
||||
## Phase 5: Mesh Network Intelligence (Weeks 12-15)
|
||||
|
||||
### Week 12: Steganographic Modes
|
||||
|
||||
**New: `mesh/steganography.rs`**
|
||||
|
||||
- `SteganographyMode` enum: `Normal`, `WeatherStation`, `SensorNetwork`
|
||||
- **Weather Station:** Map payload bytes → plausible weather readings (temp, humidity, pressure, wind). Marker `0xAA` replaces `0x02`.
|
||||
- **Sensor Network:** Industrial sensor format (voltage, current, vibration)
|
||||
- `to_wire_steganographic(mode)` / `from_wire_steganographic(data)` on TypedEnvelope
|
||||
- Listener detects `0xAA` → decode stego → normal dispatch
|
||||
- Config: `steganography_mode` in `MeshConfig`
|
||||
- Budget: ~80 bytes real data per 160-byte LoRa frame with stego overhead
|
||||
|
||||
### Week 13: Adaptive Routing & Signal Intelligence
|
||||
|
||||
**New: `mesh/routing.rs`**
|
||||
|
||||
- `LinkQuality` per peer: RSSI/SNR rolling 1h history, packet loss, hop count
|
||||
- `RoutingTable`: link quality per peer + best route per destination DID
|
||||
- Score: `(rssi+120)*0.4 + (snr+20)*0.3 + (1-loss)*100*0.3`
|
||||
- Best relay selection for TX/LN relay (highest quality peer with internet)
|
||||
- Multi-hop forwarding: if dest DID != ours and hops < 3, forward to best next-hop
|
||||
- Extract RSSI from v3 frames (bytes 1-2, currently unused)
|
||||
- RPC: `mesh.routing-table`
|
||||
|
||||
### Week 14: LoRa Radio Parameter Control
|
||||
|
||||
**`mesh/protocol.rs`:** Builders for `SET_RADIO_PARAMS` (0x0B), `SET_TX_POWER` (0x0C), `SET_TUNING_PARAMS` (0x15). Parse `RESP_STATS` (0x18).
|
||||
|
||||
**RPC:** `mesh.set-radio-params`, `mesh.set-tx-power`, `mesh.get-radio-stats`
|
||||
|
||||
**Auto-adaptive SF:** If link quality drops → increase spreading factor (longer range, slower). Config toggle.
|
||||
|
||||
**Frontend:** Radio tuning panel with SF/TX power sliders, stats, auto-adaptive toggle.
|
||||
|
||||
### Week 15: Multi-Hardware + Topology UI
|
||||
|
||||
**New: `mesh/device_trait.rs`**
|
||||
|
||||
```rust
|
||||
#[async_trait]
|
||||
pub trait MeshDevice: Send + Sync {
|
||||
async fn open(path: &str) -> Result<Self> where Self: Sized;
|
||||
async fn initialize(&mut self) -> Result<DeviceInfo>;
|
||||
async fn send_text(&mut self, dest: &[u8; 6], msg: &[u8]) -> Result<()>;
|
||||
async fn try_recv_frame(&mut self) -> Result<Option<InboundFrame>>;
|
||||
// ...
|
||||
}
|
||||
```
|
||||
|
||||
- Implement for `MeshcoreDevice`, stub Meshtastic/WiFi/BLE
|
||||
- `listener.rs` uses `Box<dyn MeshDevice>`
|
||||
- **Topology UI:** SVG graph (this node center, peers as satellites), edge thickness = quality, color = green/yellow/red, tooltips with RSSI/SNR/hops
|
||||
- Stego mode selector, block relay status panel
|
||||
|
||||
## Key Challenges
|
||||
|
||||
1. **TX hex > 160 bytes:** Use Reed-Solomon chunking (already in `transport/chunking.rs`)
|
||||
2. **Async in listener:** Spawn tasks for Bitcoin/LND calls, don't block serial loop
|
||||
3. **Dead man false triggers:** Persist check-in time as unix timestamp on disk
|
||||
4. **Stego overhead:** ~80 bytes real data per 160-byte frame
|
||||
|
||||
## Files Modified
|
||||
|
||||
**Phase 4:**
|
||||
- `core/archipelago/src/mesh/listener.rs` — typed dispatch, new MeshCommand variants
|
||||
- `core/archipelago/src/mesh/mod.rs` — new fields, init, background tasks
|
||||
- `core/archipelago/src/mesh/types.rs` — new MeshEvent variants
|
||||
- `core/archipelago/src/api/rpc/mesh.rs` — 6+ new endpoints, fix send-invoice
|
||||
- `core/archipelago/src/api/rpc/mod.rs` — register routes
|
||||
- `neode-ui/src/stores/mesh.ts` — new store methods
|
||||
- `neode-ui/src/views/Mesh.vue` — off-grid + dead man panels
|
||||
|
||||
**Phase 5 new files:**
|
||||
- `core/archipelago/src/mesh/steganography.rs`
|
||||
- `core/archipelago/src/mesh/routing.rs`
|
||||
- `core/archipelago/src/mesh/device_trait.rs`
|
||||
|
||||
## Existing Code to Reuse
|
||||
|
||||
- `bitcoin_relay.rs`: `BlockHeaderCache`, `RelayTracker`, all `build_*` functions
|
||||
- `alerts.rs`: `DeadManSwitch`, `AlertConfig`, `load_config`/`save_config`
|
||||
- `message_types.rs`: All payload types, `TypedEnvelope`, `encode_payload`/`decode_payload`
|
||||
- `api/rpc/lnd.rs:128-141`: `lnd_client()` pattern for LND REST calls
|
||||
- `api/rpc/bitcoin.rs:74-107`: `bitcoin_rpc_call()` for Bitcoin Core RPC
|
||||
- `transport/chunking.rs`: Reed-Solomon FEC for payloads > 160 bytes
|
||||
|
||||
## Verification
|
||||
|
||||
```bash
|
||||
# Unit tests on server
|
||||
ssh archipelago@192.168.1.228 'cd ~/archy/core && source ~/.cargo/env && cargo test --all-features -- mesh'
|
||||
|
||||
# Type check frontend
|
||||
cd neode-ui && npm run type-check
|
||||
|
||||
# Deploy to both
|
||||
./scripts/deploy-to-target.sh --both
|
||||
|
||||
# E2E tests:
|
||||
# 1. .228 (internet) relays TX from .198 (mesh-only)
|
||||
# 2. .228 announces block headers, .198 receives them
|
||||
# 3. Dead man's switch triggers after interval, broadcasts alert
|
||||
# 4. Steganographic packet looks like weather data on wire
|
||||
```
|
||||
@@ -1,119 +0,0 @@
|
||||
# Plan: Seamless Tailscale Migration for Alpha Testers
|
||||
|
||||
## Context
|
||||
|
||||
Tailscale nodes (Arch 1/2/3) are alpha tester machines. They need full deployment — binary, frontend, infrastructure, and containers — with zero friction. Currently `deploy-tailscale.sh` only deploys binary + frontend (85 lines), missing ALL infrastructure that `deploy-to-target.sh --live` provides (rootless prereqs, UID mapping, containers, nginx, Tor, HTTPS, dev mode, UFW, etc.).
|
||||
|
||||
These nodes may also have old **rootful** containers that need migrating to rootless.
|
||||
|
||||
## Approach
|
||||
|
||||
**Don't refactor the 1615-line deploy-to-target.sh** — too risky during beta freeze. Instead:
|
||||
|
||||
1. **Rewrite `deploy-tailscale.sh`** as a full-deploy script with split-mode SSH resilience
|
||||
2. **Add `--tailscale` flag** to `deploy-to-target.sh` as a convenience wrapper
|
||||
3. **Add rootful→rootless migration** as an automatic pre-step
|
||||
4. **Fix `first-boot-containers.sh`** for rootless (separate concern, for ISO builds)
|
||||
|
||||
## Changes
|
||||
|
||||
### 1. Rewrite `scripts/deploy-tailscale.sh` (~400 lines)
|
||||
|
||||
Currently 85 lines doing only binary+frontend. Rewrite to be a full deploy for any node, using split-mode SSH (each step = separate short SSH session) for Tailscale stability.
|
||||
|
||||
**Steps the new script will run (each as its own SSH session):**
|
||||
|
||||
1. SSH connectivity check
|
||||
2. Install prerequisites (rsync, node, npm) if missing
|
||||
3. Rsync code to target
|
||||
4. **Rootful→rootless migration** (detect `sudo podman ps -a`, stop & remove old rootful containers)
|
||||
5. Build frontend (nohup + poll, or skip if copy-only node)
|
||||
6. Build backend (nohup + poll, or skip if copy-only node)
|
||||
7. Create rollback backup
|
||||
8. Deploy binary (build locally or copy from .228)
|
||||
9. Deploy frontend (build locally or copy from .228)
|
||||
10. Deploy AIUI
|
||||
11. Sync nginx config + HTTPS snippets
|
||||
12. Sync systemd service
|
||||
13. **Setup rootless prereqs** (sysctl, linger, podman.socket)
|
||||
14. **Create data dirs + UID mapping** (full chown table from deploy-to-target.sh:670-689)
|
||||
15. **Dev mode** (ARCHIPELAGO_DEV_MODE=true for HTTP cookies over Tailscale)
|
||||
16. Deploy nostr-provider.js
|
||||
17. Deploy Claude API proxy (if ANTHROPIC_API_KEY available)
|
||||
18. Setup NTP + swap
|
||||
19. Restart services
|
||||
20. **Setup HTTPS** (with node's own IP in SAN)
|
||||
21. **Read Bitcoin RPC credentials** from server secrets
|
||||
22. **Create all containers** (Bitcoin, Mempool, BTCPay, ElectrumX, LND, Fedimint, Immich, HA, Grafana, Jellyfin, Vaultwarden, SearXNG, FileBrowser)
|
||||
23. **Setup Tor** hidden services
|
||||
24. **Fix UFW** forward policy
|
||||
25. **Fix IndeedHub** NIP-07 (if running)
|
||||
26. **Transfer custom images** for copy-only nodes (individual tarballs, never combined)
|
||||
27. Run container doctor
|
||||
28. Write deploy manifest
|
||||
29. Post-deploy health check
|
||||
|
||||
**Copy-only mode**: When target can't build (Arch 1/3), script detects no `cargo`/`npm` on target and copies pre-built artifacts from .228 via SSH pipe.
|
||||
|
||||
**Key sections to port from deploy-to-target.sh:**
|
||||
- Lines 646-689 — rootless prereqs + UID mapping
|
||||
- Lines 629-641 — dev mode
|
||||
- Lines 839-1474 — all container creation
|
||||
- Lines 1143-1234 — Tor setup
|
||||
- Lines 1477-1485 — UFW fix
|
||||
- Lines 1487-1545 — IndeedHub NIP-07
|
||||
|
||||
### 2. Add `--tailscale` flag to `deploy-to-target.sh` (~30 lines)
|
||||
|
||||
Wrapper that calls `deploy-tailscale.sh` for each node sequentially. Also add `--tailscale-node=arch1|arch2|arch3` for single-node targeting.
|
||||
|
||||
### 3. Rootful→rootless migration (in deploy-tailscale.sh step 4)
|
||||
|
||||
Auto-detect and handle:
|
||||
```
|
||||
ssh TARGET 'ROOTFUL=$(sudo podman ps -a 2>/dev/null | wc -l); if [ $ROOTFUL -gt 1 ]; then sudo podman stop --all; sudo podman rm --all; fi'
|
||||
```
|
||||
Data safe — `/var/lib/archipelago/` never deleted, only ownership fixed by UID mapping step.
|
||||
|
||||
### 4. Fix `scripts/first-boot-containers.sh` (5 targeted edits)
|
||||
|
||||
- **Line 15**: Change root check → archipelago user check (UID 1000)
|
||||
- **Line 140**: Change `10.88.0.0/16` → `0.0.0.0/0` (match deploy-to-target.sh)
|
||||
- **After line 111**: Add rootless prereqs (sysctl, linger, podman.socket)
|
||||
- **After line 113**: Add full UID mapping block
|
||||
- **Pin `:latest` tags**: photoprism, ollama, searxng, nginx-proxy-manager, penpot
|
||||
|
||||
### 5. Update `scripts/setup-https-dev.sh`
|
||||
|
||||
Dynamic SAN — detect node's own IPs (including Tailscale interface) instead of hardcoding .228/.198.
|
||||
|
||||
## Files Modified
|
||||
|
||||
| File | Change | ~Lines |
|
||||
|------|--------|--------|
|
||||
| `scripts/deploy-tailscale.sh` | Full rewrite — complete deploy with split-mode SSH | ~400 |
|
||||
| `scripts/deploy-to-target.sh` | Add `--tailscale` / `--tailscale-node` flags | ~30 |
|
||||
| `scripts/first-boot-containers.sh` | Fix for rootless (subnet, UID mapping, prereqs) | ~40 |
|
||||
| `scripts/setup-https-dev.sh` | Dynamic SAN with Tailscale IPs | ~15 |
|
||||
| `docs/BETA-PROGRESS.md` | Update TASK-11 status | ~5 |
|
||||
|
||||
## Auth State Preservation
|
||||
|
||||
All user state in `/var/lib/archipelago/` is **never touched** by deploys:
|
||||
- `sessions.json`, `user.json`, `identities/`, `secrets/`, `federation/`
|
||||
|
||||
## Verification
|
||||
|
||||
1. Deploy to Arch 2 first (has build tools, safest test)
|
||||
2. Then Arch 1/3 (copy-only mode)
|
||||
3. For each node: `podman ps` shows containers, `curl /health` returns 200, UI loads, login works
|
||||
4. Run container doctor — 0 fixes needed
|
||||
|
||||
## Order
|
||||
|
||||
1. Rewrite `deploy-tailscale.sh` (main deliverable)
|
||||
2. Add `--tailscale` flags to `deploy-to-target.sh`
|
||||
3. Fix `first-boot-containers.sh`
|
||||
4. Update `setup-https-dev.sh`
|
||||
5. Test: Arch 2 → Arch 1 → Arch 3
|
||||
6. Update BETA-PROGRESS.md
|
||||
@@ -1,50 +0,0 @@
|
||||
---
|
||||
globs:
|
||||
- "**/container/**"
|
||||
- "**/manifest*"
|
||||
- "**/*podman*"
|
||||
- "**/Containerfile"
|
||||
- "**/Dockerfile"
|
||||
- "**/first-boot*"
|
||||
- "**/container-doctor*"
|
||||
---
|
||||
|
||||
# Container Security Rules (Archipelago — Rootless Podman)
|
||||
|
||||
## Rootless Podman Architecture
|
||||
- Podman runs as `archipelago` user (UID 1000), NOT root — never use `sudo podman`
|
||||
- UID namespace mapping via subuid: container UID N → host UID (100000 + N)
|
||||
- Container images stored in `~/.local/share/containers/storage/` (NOT /var/lib/containers)
|
||||
- Container subnet: `10.89.0.0/16` (rootless), not `10.88.0.0/16` (rootful)
|
||||
- XDG_RUNTIME_DIR must be `/run/user/1000` — required for podman socket
|
||||
- `loginctl enable-linger archipelago` required for containers to survive logout
|
||||
|
||||
## Container Security (Non-Negotiable)
|
||||
- Drop ALL capabilities, add only what's required (`--cap-drop=ALL --cap-add=...`)
|
||||
- Set `--security-opt=no-new-privileges:true` on all containers
|
||||
- Use `--read-only` + tmpfs where possible (safe apps: searxng, grafana, filebrowser, electrumx, nostr-rs-relay, ollama, indeedhub)
|
||||
- Pin image versions — never use `:latest` tag
|
||||
- Mount secrets as read-only files, never pass as environment variables when possible
|
||||
- Set memory and CPU limits on all containers
|
||||
- All containers must have `--restart unless-stopped`
|
||||
|
||||
## Volume Ownership (Critical for Rootless)
|
||||
- Volume directories must be owned by the MAPPED UID, not the container UID
|
||||
- Formula: `host_uid = 100000 + container_uid`
|
||||
- UID 0 (most apps) → `sudo chown -R 100000:100000 /var/lib/archipelago/{app}`
|
||||
- UID 101 (bitcoin) → `sudo chown -R 100101:100101 /var/lib/archipelago/bitcoin`
|
||||
- UID 70 (postgres) → `sudo chown -R 100070:100070 /var/lib/archipelago/postgres-*`
|
||||
- UID 472 (grafana) → `sudo chown -R 100472:100472 /var/lib/archipelago/grafana`
|
||||
- UID 999 (mariadb) → `sudo chown -R 100999:100999 /var/lib/archipelago/mysql-*`
|
||||
|
||||
## Systemd Service Requirements
|
||||
- `ProtectHome=no` — podman needs `~/.local/share/containers/`
|
||||
- `PrivateTmp=no` — podman runtime uses `/tmp/podman-run-1000/`
|
||||
- `RestrictNamespaces=` must NOT be set — rootless podman creates user namespaces
|
||||
- `SystemCallFilter=` must NOT be set — rootless podman needs clone/unshare
|
||||
- UFW `DEFAULT_FORWARD_POLICY="ACCEPT"` — required for LAN access to container ports
|
||||
|
||||
## Network Rules
|
||||
- Apps needing inter-container DNS: use `--network=archy-net` (bitcoin, lnd, electrumx, mempool, btcpay, fedimint)
|
||||
- Standalone apps: default bridge network
|
||||
- Tailscale only: `--network=host` + `NET_ADMIN` + `NET_RAW` + `/dev/net/tun`
|
||||
@@ -1,16 +0,0 @@
|
||||
---
|
||||
globs:
|
||||
- "**/neode-ui/**"
|
||||
- "**/*.vue"
|
||||
---
|
||||
|
||||
# Frontend Rules (Archipelago)
|
||||
|
||||
- Always use `<script setup lang="ts">` in Vue components
|
||||
- Global CSS classes go in `style.css`, never inline Tailwind utilities
|
||||
- Use `.glass-button` for ALL buttons — `.gradient-button` is BANNED
|
||||
- Use Pinia stores for shared state, never provide/inject for cross-component data
|
||||
- Every async view needs: loading state, empty state, and error state
|
||||
- Trim all text inputs before submission
|
||||
- Disable submit buttons during async operations
|
||||
- Use `errorMessage` ref pattern for user-visible errors, not just console.log
|
||||
@@ -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,125 +0,0 @@
|
||||
---
|
||||
name: add-web-app
|
||||
description: Add an external website as a web-only app to Archipelago (no container needed)
|
||||
disable-model-invocation: true
|
||||
allowed-tools: Bash, Read, Write, Edit, Glob, Grep
|
||||
argument-hint: "[app-id] [url]"
|
||||
---
|
||||
|
||||
Add an external website ($ARGUMENTS) as a web-only app to Archipelago.
|
||||
|
||||
Web-only apps are external websites embedded in the Archipelago UI via iframe. They have no Docker container — they're bookmarks to public websites with full app-like detail pages.
|
||||
|
||||
## Architecture
|
||||
|
||||
External websites that set `X-Frame-Options` or CSP headers blocking iframe embedding are proxied through nginx on **dedicated ports** (one port per site). This approach:
|
||||
- Strips X-Frame-Options so the iframe works
|
||||
- Serves the site at root `/` so SPA routing works correctly
|
||||
- Does NOT use subpath proxying (`/ext/app/`) which breaks SPAs
|
||||
- Optionally injects NIP-07 nostr-provider.js for Nostr login
|
||||
|
||||
## Steps
|
||||
|
||||
### 1. Choose a port
|
||||
|
||||
Pick an unused port in the 8900-8999 range. Current allocations:
|
||||
- 8901: botfights.net
|
||||
- 8902: 484.kitchen
|
||||
- 8903: present.l484.com
|
||||
|
||||
### 2. Add nginx proxy server block
|
||||
|
||||
Add a new `server` block to `image-recipe/configs/nginx-archipelago.conf` at the end:
|
||||
|
||||
```nginx
|
||||
server {
|
||||
listen {PORT};
|
||||
server_name _;
|
||||
location / {
|
||||
proxy_pass https://{DOMAIN};
|
||||
proxy_http_version 1.1;
|
||||
proxy_set_header Host {DOMAIN};
|
||||
proxy_set_header Accept-Encoding "";
|
||||
proxy_ssl_server_name on;
|
||||
proxy_hide_header X-Frame-Options;
|
||||
proxy_hide_header Content-Security-Policy;
|
||||
proxy_hide_header Cross-Origin-Embedder-Policy;
|
||||
proxy_hide_header Cross-Origin-Opener-Policy;
|
||||
proxy_hide_header Cross-Origin-Resource-Policy;
|
||||
sub_filter '</head>' '<script src="/nostr-provider.js"></script></head>';
|
||||
sub_filter_once on;
|
||||
}
|
||||
location = /nostr-provider.js {
|
||||
alias /opt/archipelago/web-ui/nostr-provider.js;
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### 3. Add to appLauncher.ts EXTERNAL_PROXY_PORT
|
||||
|
||||
In `neode-ui/src/stores/appLauncher.ts`, add the domain-to-port mapping:
|
||||
|
||||
```typescript
|
||||
const EXTERNAL_PROXY_PORT: Record<string, number> = {
|
||||
// ... existing entries
|
||||
'{DOMAIN}': {PORT},
|
||||
}
|
||||
```
|
||||
|
||||
### 4. Add to Apps.vue WEB_ONLY_APP_URLS and WEB_ONLY_APPS
|
||||
|
||||
In `neode-ui/src/views/Apps.vue`:
|
||||
1. Add to `WEB_ONLY_APP_URLS`: `'{app-id}': 'https://{DOMAIN}'`
|
||||
2. Add to `WEB_ONLY_APPS` with a synthetic `PackageDataEntry`:
|
||||
- state: `'running'`
|
||||
- manifest with id, title, version, description
|
||||
- static-files with icon path
|
||||
|
||||
### 5. Add to dummyApps.ts
|
||||
|
||||
In `neode-ui/src/utils/dummyApps.ts`, add a full `PackageDataEntry` with:
|
||||
- Long description (for detail page)
|
||||
- Website URL in manifest
|
||||
- Icon path
|
||||
|
||||
### 6. Add to AppDetails.vue WEB_ONLY_APP_URLS
|
||||
|
||||
In `neode-ui/src/views/AppDetails.vue`, add to the `WEB_ONLY_APP_URLS` map.
|
||||
|
||||
### 7. Add app icon
|
||||
|
||||
Place icon at `neode-ui/public/assets/img/app-icons/{app-id}.{png|webp|svg}`
|
||||
|
||||
### 8. Deploy
|
||||
|
||||
```bash
|
||||
# Build frontend
|
||||
cd neode-ui && npm run build
|
||||
|
||||
# Deploy nginx config
|
||||
scp image-recipe/configs/nginx-archipelago.conf archipelago@192.168.1.228:/tmp/
|
||||
ssh archipelago@192.168.1.228 "sudo cp /tmp/nginx-archipelago.conf /etc/nginx/sites-available/archipelago && sudo nginx -t && sudo systemctl reload nginx"
|
||||
|
||||
# Deploy frontend
|
||||
rsync -az --delete --exclude aiui --exclude claude-login.html web/dist/neode-ui/ archipelago@192.168.1.228:/opt/archipelago/web-ui/
|
||||
```
|
||||
|
||||
### 9. Verify
|
||||
|
||||
1. Open Archipelago UI
|
||||
2. Web-only app appears in My Apps (sorted alphabetically before container apps)
|
||||
3. Click app card -> detail page with title, description, launch button, no container buttons
|
||||
4. Click Launch -> iframe loads the external website correctly
|
||||
5. All assets load (no 404s in Network tab)
|
||||
6. `window.nostr` available in iframe console (NIP-07)
|
||||
|
||||
## Files Modified
|
||||
|
||||
| File | What to add |
|
||||
|------|-------------|
|
||||
| `image-recipe/configs/nginx-archipelago.conf` | New server block with proxy |
|
||||
| `neode-ui/src/stores/appLauncher.ts` | EXTERNAL_PROXY_PORT entry |
|
||||
| `neode-ui/src/views/Apps.vue` | WEB_ONLY_APP_URLS + WEB_ONLY_APPS entries |
|
||||
| `neode-ui/src/views/AppDetails.vue` | WEB_ONLY_APP_URLS entry |
|
||||
| `neode-ui/src/utils/dummyApps.ts` | Full PackageDataEntry for detail page |
|
||||
| `neode-ui/public/assets/img/app-icons/` | App icon file |
|
||||
@@ -1,113 +0,0 @@
|
||||
---
|
||||
name: bitcoin-conventions
|
||||
description: Bitcoin development conventions for Archipelago. Covers sats display (integers, never float), address type detection, Tor/onion endpoint preference, Bitcoin RPC error handling, and Lightning patterns. Use when working with Bitcoin amounts, addresses, RPC calls, Lightning channels, or onion services.
|
||||
---
|
||||
|
||||
# Bitcoin Development Conventions
|
||||
|
||||
## Critical Rules
|
||||
|
||||
- **NEVER use floating point for Bitcoin amounts.** Sats are always `u64` (Rust) or `BigInt`/integer (TypeScript).
|
||||
- **NEVER log private keys, seeds, or mnemonics.** Not even at debug/trace level.
|
||||
- **Prefer Tor/onion endpoints** for all Bitcoin network services when available.
|
||||
|
||||
## Amount Display
|
||||
|
||||
### Rust
|
||||
```rust
|
||||
// Amount is always in sats as u64
|
||||
pub fn format_sats(sats: u64) -> String {
|
||||
if sats >= 100_000_000 {
|
||||
let btc = sats / 100_000_000;
|
||||
let remainder = sats % 100_000_000;
|
||||
if remainder == 0 {
|
||||
format!("{} BTC", btc)
|
||||
} else {
|
||||
format!("{}.{:08} BTC", btc, remainder)
|
||||
}
|
||||
} else {
|
||||
format!("{} sats", sats)
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### TypeScript
|
||||
```typescript
|
||||
// Never: amount * 0.00000001
|
||||
// Always: integer arithmetic or BigInt
|
||||
function formatSats(sats: number): string {
|
||||
if (sats >= 100_000_000) {
|
||||
const btc = Math.floor(sats / 100_000_000)
|
||||
const remainder = sats % 100_000_000
|
||||
return remainder === 0 ? `${btc} BTC` : `${btc}.${String(remainder).padStart(8, '0')} BTC`
|
||||
}
|
||||
return `${sats.toLocaleString()} sats`
|
||||
}
|
||||
```
|
||||
|
||||
## Address Types
|
||||
|
||||
Detect and display address type:
|
||||
- `1...` — P2PKH (Legacy)
|
||||
- `3...` — P2SH (SegWit-compatible)
|
||||
- `bc1q...` — P2WPKH (Native SegWit)
|
||||
- `bc1p...` — P2TR (Taproot)
|
||||
|
||||
Always validate addresses before any operation. Use network-appropriate validation (mainnet `bc1`, testnet `tb1`, regtest `bcrt1`).
|
||||
|
||||
## Bitcoin RPC Error Handling
|
||||
|
||||
```rust
|
||||
match rpc_response.error {
|
||||
Some(err) => {
|
||||
// Standard Bitcoin Core RPC error codes
|
||||
match err.code {
|
||||
-1 => /* miscellaneous error */,
|
||||
-5 => /* invalid address or key */,
|
||||
-6 => /* insufficient funds */,
|
||||
-25 => /* transaction verification failed */,
|
||||
-26 => /* transaction rejected by policy */,
|
||||
-27 => /* transaction already in chain */,
|
||||
-28 => /* client still warming up */,
|
||||
_ => /* unknown error */,
|
||||
}
|
||||
}
|
||||
None => { /* success */ }
|
||||
}
|
||||
```
|
||||
|
||||
Always set explicit timeouts on RPC calls (10s default, 30s for heavy operations like `rescanblockchain`).
|
||||
|
||||
## Tor/Onion Preferences
|
||||
|
||||
When configuring Bitcoin services:
|
||||
1. Check for Tor SOCKS proxy (default: `127.0.0.1:9050`)
|
||||
2. If available, route Bitcoin P2P and RPC through Tor
|
||||
3. Prefer `.onion` endpoints for block explorers, electrum servers
|
||||
4. Set `proxy=127.0.0.1:9050` in `bitcoin.conf`
|
||||
5. Set `onlynet=onion` for maximum privacy (if full Tor mode)
|
||||
|
||||
## Lightning (LND/CLN) Patterns
|
||||
|
||||
### BOLT11 Invoice handling
|
||||
- Always validate invoice before displaying to user
|
||||
- Show: amount, description, expiry, destination pubkey
|
||||
- Never auto-pay without user confirmation
|
||||
|
||||
### Channel States
|
||||
Display human-readable channel state:
|
||||
- `PENDING_OPEN` → "Opening..."
|
||||
- `OPEN` → "Active"
|
||||
- `PENDING_CLOSE` / `FORCE_CLOSING` → "Closing..."
|
||||
- `CLOSED` → "Closed"
|
||||
|
||||
### Macaroon handling
|
||||
- Never log macaroon contents
|
||||
- Store with restrictive permissions (0600)
|
||||
- Use read-only macaroon for queries, admin macaroon only for mutations
|
||||
|
||||
## Container Images for Bitcoin Services
|
||||
|
||||
- **Always pin by SHA256 digest**, never by tag alone
|
||||
- Example: `docker.io/lnzap/lnd@sha256:abc123...` not `lnzap/lnd:latest`
|
||||
- Verify image signatures when available (cosign/notary)
|
||||
@@ -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,155 +0,0 @@
|
||||
---
|
||||
name: mesh
|
||||
description: Mesh networking development for Archipelago — protocol, crypto, serial driver, transport abstraction, and LoRa chat. Use when working on mesh radio, Meshcore protocol, LoRa messaging, transport layers, peer discovery, or off-grid communication features.
|
||||
---
|
||||
|
||||
# Mesh Networking Skill
|
||||
|
||||
## Architecture
|
||||
|
||||
The mesh subsystem enables offline peer discovery and end-to-end encrypted messaging between Archipelago nodes via Meshcore LoRa radio devices (Heltec V3, T-Beam, RAK WisBlock).
|
||||
|
||||
```
|
||||
USB Meshcore Device (115200 baud)
|
||||
↕ serial2-tokio
|
||||
core/archipelago/src/mesh/
|
||||
├── mod.rs — MeshService: lifecycle, config, public API
|
||||
├── types.rs — MeshPeer, MeshMessage, MeshStatus, MeshEvent
|
||||
├── protocol.rs — Meshcore binary frame protocol (encode/decode)
|
||||
├── serial.rs — MeshcoreDevice: async serial driver
|
||||
├── crypto.rs — X25519 ECDH + ChaCha20-Poly1305 encryption
|
||||
└── listener.rs — Background tokio task: serial reader + dispatcher
|
||||
↕ RPC
|
||||
core/archipelago/src/api/rpc/mesh.rs — 6 endpoints
|
||||
↕ HTTP
|
||||
neode-ui/src/stores/mesh.ts — Pinia store
|
||||
neode-ui/src/views/Mesh.vue — Two-column chat UI
|
||||
```
|
||||
|
||||
## Key Files
|
||||
|
||||
### Backend (Rust)
|
||||
- `core/archipelago/src/mesh/mod.rs` — MeshService (start/stop/status/peers/messages/send/configure)
|
||||
- `core/archipelago/src/mesh/types.rs` — All shared types
|
||||
- `core/archipelago/src/mesh/protocol.rs` — Binary frame format, command builders, response parsers (12 unit tests)
|
||||
- `core/archipelago/src/mesh/serial.rs` — USB serial driver, handshake, device detection
|
||||
- `core/archipelago/src/mesh/crypto.rs` — X25519 key agreement + ChaCha20-Poly1305 (7 unit tests)
|
||||
- `core/archipelago/src/mesh/listener.rs` — Background event loop, auto-reconnect, peer cache
|
||||
- `core/archipelago/src/api/rpc/mesh.rs` — RPC handlers (mesh.status/peers/messages/send/broadcast/configure)
|
||||
- `core/archipelago/src/server.rs` — MeshService initialization (non-blocking)
|
||||
- `core/archipelago/src/identity.rs` — Ed25519 keypair, DID, X25519 derivation
|
||||
|
||||
### Frontend (Vue 3 + TypeScript)
|
||||
- `neode-ui/src/stores/mesh.ts` — Pinia store with unread tracking
|
||||
- `neode-ui/src/views/Mesh.vue` — Full chat UI (~1000 lines)
|
||||
- `neode-ui/src/router/index.ts` — Route: `/dashboard/mesh`
|
||||
|
||||
### Mock Backend
|
||||
- `neode-ui/mock-backend.js` — Dev mode mesh RPC responses (mesh.status/peers/messages/send/broadcast/configure)
|
||||
|
||||
## Protocol Reference
|
||||
|
||||
### Meshcore Frame Format
|
||||
- Outbound: `<` (0x3C) + 2-byte LE length + data
|
||||
- Inbound: `>` (0x3E) + 2-byte LE length + data
|
||||
- Max LoRa payload: 160 bytes
|
||||
- Baud: 115200, 8N1
|
||||
|
||||
### Key Commands
|
||||
| Byte | Command | Description |
|
||||
|------|---------|-------------|
|
||||
| 0x01 | APP_START | Init session with version negotiation |
|
||||
| 0x02 | SEND_TXT_MSG | Direct message (6-byte pubkey prefix) |
|
||||
| 0x03 | SEND_CHANNEL_TXT_MSG | Broadcast on channel |
|
||||
| 0x04 | GET_CONTACTS | Fetch contact list |
|
||||
| 0x06 | SET_DEVICE_TIME | Sync device clock |
|
||||
| 0x07 | SEND_SELF_ADVERT | Broadcast identity |
|
||||
| 0x0A | SYNC_NEXT_MESSAGE | Retrieve queued messages |
|
||||
|
||||
### Identity Wire Format
|
||||
`ARCHY:2:{ed25519_hex_64}:{x25519_hex_64}` (137 bytes, fits 160)
|
||||
|
||||
### Encryption
|
||||
- X25519 Diffie-Hellman from Ed25519 keys (RFC 7748 clamping)
|
||||
- ChaCha20-Poly1305 AEAD with random 12-byte nonce
|
||||
- Wire: `[nonce 12B] + [ciphertext + tag 16B]` — max 132B plaintext
|
||||
|
||||
## RPC Endpoints
|
||||
|
||||
| Method | Params | Returns |
|
||||
|--------|--------|---------|
|
||||
| `mesh.status` | — | MeshStatus |
|
||||
| `mesh.peers` | — | `{peers, count}` |
|
||||
| `mesh.messages` | `{limit?}` | `{messages, count}` |
|
||||
| `mesh.send` | `{contact_id, message}` | `{sent, message_id, encrypted}` |
|
||||
| `mesh.broadcast` | — | `{broadcast}` |
|
||||
| `mesh.configure` | `{enabled?, device_path?, channel_name?, broadcast_identity?, advert_name?}` | `{configured}` |
|
||||
|
||||
## Development Workflow
|
||||
|
||||
### Building & Testing (on dev server, NOT macOS)
|
||||
```bash
|
||||
# Deploy mesh changes
|
||||
./scripts/deploy-to-target.sh --live
|
||||
|
||||
# Run mesh unit tests on server
|
||||
ssh -i ~/.ssh/archipelago-deploy archipelago@192.168.1.228 \
|
||||
'cd ~/archy/core && cargo test --all-features -- mesh'
|
||||
|
||||
# Check device is detected
|
||||
ssh -i ~/.ssh/archipelago-deploy archipelago@192.168.1.228 \
|
||||
'ls -la /dev/ttyUSB* /dev/ttyACM* 2>/dev/null'
|
||||
|
||||
# Watch mesh logs
|
||||
ssh -i ~/.ssh/archipelago-deploy archipelago@192.168.1.228 \
|
||||
'sudo journalctl -u archipelago -f | grep -i mesh'
|
||||
```
|
||||
|
||||
### Frontend Dev (local, mock backend)
|
||||
```bash
|
||||
cd neode-ui && npm start
|
||||
# Mesh mock data at http://localhost:8100/dashboard/mesh
|
||||
```
|
||||
|
||||
## Roadmap Phases
|
||||
|
||||
### Phase 1: Core Implementation (COMPLETE)
|
||||
- Meshcore binary protocol, serial driver, crypto, listener, RPC, Vue UI
|
||||
|
||||
### Phase 2: Mesh as Federation Transport
|
||||
- NodeTransport trait abstraction (mesh/tor/lan backends)
|
||||
- Transport priority: Mesh (1) > LAN/mDNS (2) > Tor (3)
|
||||
- Chunked message protocol for >160B payloads (Reed-Solomon FEC)
|
||||
- CBOR delta sync instead of full JSON state
|
||||
- Transport indicator per peer in federation UI
|
||||
- "Mesh only" off-grid mode
|
||||
- Dependencies: `ciborium` (CBOR), `reed-solomon-erasure` (FEC), `mdns-sd` (LAN discovery)
|
||||
|
||||
### Phase 3: Encrypted Mesh Messaging
|
||||
- Double Ratchet (Signal protocol) over LoRa
|
||||
- X3DH key agreement using existing Ed25519/X25519
|
||||
- Store-and-forward relay for offline peers (24h TTL)
|
||||
- Message types: TEXT, ALERT, INVOICE (bolt11), PSBT_HASH, COORDINATE
|
||||
- Per-peer chat threads, delivery status, offline indicators
|
||||
|
||||
### Phase 4: Off-Grid Bitcoin Operations
|
||||
- Compact block headers over mesh (SPV verification)
|
||||
- Transaction relay via internet-connected mesh peer
|
||||
- Lightning payment coordination over mesh
|
||||
- Emergency alert system (signed alerts, GPS, dead man's switch)
|
||||
|
||||
### Phase 5: Mesh Network Intelligence
|
||||
- Adaptive routing, signal strength mapping, spreading factor adjustment
|
||||
- Multi-path routing for reliability
|
||||
- Steganographic modes
|
||||
- Additional hardware: T-Beam, RAK WisBlock, WiFi mesh (802.11s), BLE, Blockstream Satellite
|
||||
|
||||
## Conventions
|
||||
|
||||
- All crypto uses existing identity infrastructure (Ed25519 signing key → X25519 derivation)
|
||||
- Mesh init is non-blocking — errors logged but don't crash server
|
||||
- Config persists to `{data_dir}/mesh-config.json`
|
||||
- Message buffer: circular, max 100 messages
|
||||
- Never build Rust on macOS — always deploy to server
|
||||
- USB device paths: `/dev/ttyUSB*` and `/dev/ttyACM*`
|
||||
- `archipelago` user must be in `dialout` group for serial access
|
||||
@@ -1,275 +0,0 @@
|
||||
---
|
||||
name: podman-doctor
|
||||
description: >
|
||||
Comprehensive Podman container diagnostic for Archipelago. Audits all running containers,
|
||||
port mappings, network connectivity, health status, restart policies, and config consistency
|
||||
across all 4 layers (backend Rust, Podman runtime, Nginx proxy, frontend routing).
|
||||
Handles rootless Podman (user: archipelago, UID 1000, subuid 100000:65536).
|
||||
Use when asked to "diagnose containers", "check podman", "why is app not working",
|
||||
"container health check", "port not reachable", "audit containers", "podman status",
|
||||
or when any container/app is misbehaving.
|
||||
allowed-tools: Bash Read Glob Grep
|
||||
---
|
||||
|
||||
# Podman Doctor — Container Infrastructure Diagnostics
|
||||
|
||||
Systematic diagnostic for Archipelago's **rootless Podman** container stack. Catches port conflicts, network misconfigurations, health failures, missing restart policies, UID mapping issues, and config drift across all layers.
|
||||
|
||||
**SSH command**: `ssh -i ~/.ssh/archipelago-deploy archipelago@192.168.1.228`
|
||||
|
||||
> **ROOTLESS PODMAN**: Archipelago runs Podman as the `archipelago` user (UID 1000), NOT root.
|
||||
> Never use `sudo podman` — use plain `podman` after SSH'ing in as the `archipelago` user.
|
||||
> Container UIDs are mapped via subuid: container UID N → host UID (100000 + N).
|
||||
|
||||
If $ARGUMENTS is provided, focus diagnosis on that specific app/container. Otherwise run full audit.
|
||||
|
||||
## Workflow
|
||||
|
||||
### Step 1: Gather Runtime State
|
||||
|
||||
Run these on the server (as `archipelago` user — NO sudo):
|
||||
|
||||
```bash
|
||||
# All containers with status, ports, networks
|
||||
podman ps -a --format "table {{.Names}}\t{{.Status}}\t{{.Ports}}\t{{.Networks}}"
|
||||
|
||||
# Check for port conflicts on known ports
|
||||
ss -tlnp | grep -E ":(80|443|3000|4080|5678|8080|8081|8082|8083|8085|8096|8123|8173|8174|8175|8240|8332|8333|8334|8888|9735|10009|11434|23000|50001)\b"
|
||||
```
|
||||
|
||||
### Step 2: Rootless Podman Health Check
|
||||
|
||||
Rootless Podman has specific requirements that must be verified:
|
||||
|
||||
```bash
|
||||
# Verify running as archipelago user (NOT root)
|
||||
whoami # Must be "archipelago"
|
||||
id # Must show uid=1000(archipelago)
|
||||
|
||||
# Check XDG_RUNTIME_DIR is set (required for rootless podman socket)
|
||||
echo "XDG_RUNTIME_DIR=$XDG_RUNTIME_DIR" # Must be /run/user/1000
|
||||
|
||||
# Verify subuid/subgid mapping exists
|
||||
grep archipelago /etc/subuid # Must show: archipelago:100000:65536
|
||||
grep archipelago /etc/subgid # Must show: archipelago:100000:65536
|
||||
|
||||
# Verify user lingering is enabled (keeps user services after logout)
|
||||
ls /var/lib/systemd/linger/ | grep archipelago # Must exist
|
||||
|
||||
# Check podman storage is accessible
|
||||
podman info --format "{{.Store.GraphRoot}}" # ~/.local/share/containers/storage
|
||||
ls -la ~/.local/share/containers/storage/ 2>/dev/null || echo "ERROR: Storage not accessible"
|
||||
|
||||
# Check podman socket
|
||||
ls -la /run/user/1000/podman/ 2>/dev/null || echo "WARNING: No podman socket directory"
|
||||
```
|
||||
|
||||
### Step 3: Check Restart Policies
|
||||
|
||||
Every container MUST have `--restart unless-stopped`. This is the #1 cause of downtime after reboots.
|
||||
|
||||
```bash
|
||||
for c in $(podman ps -a --format "{{.Names}}"); do
|
||||
echo -n "$c: "
|
||||
podman inspect "$c" --format "{{.HostConfig.RestartPolicy.Name}}"
|
||||
done
|
||||
```
|
||||
|
||||
**Red flag**: `no` or empty = container won't survive reboot.
|
||||
|
||||
### Step 4: Volume Ownership Audit (Rootless UID Mapping)
|
||||
|
||||
Rootless Podman maps container UIDs via subuid. Volume directories must be owned by the MAPPED UID, not the container UID. Formula: `host_uid = 100000 + container_uid`
|
||||
|
||||
```bash
|
||||
echo "=== Volume Ownership Check ==="
|
||||
|
||||
# Default containers (run as root inside = UID 0 → host UID 100000)
|
||||
for dir in lnd fedimint homeassistant jellyfin vaultwarden photoprism ollama filebrowser electrumx btcpay immich; do
|
||||
if [ -d "/var/lib/archipelago/$dir" ]; then
|
||||
owner=$(stat -c '%u:%g' "/var/lib/archipelago/$dir" 2>/dev/null)
|
||||
if [ "$owner" != "100000:100000" ]; then
|
||||
echo "WRONG: /var/lib/archipelago/$dir owned by $owner (should be 100000:100000)"
|
||||
else
|
||||
echo " OK: $dir → $owner"
|
||||
fi
|
||||
fi
|
||||
done
|
||||
|
||||
# Bitcoin Knots (container UID 101 → host UID 100101)
|
||||
if [ -d "/var/lib/archipelago/bitcoin" ]; then
|
||||
owner=$(stat -c '%u:%g' "/var/lib/archipelago/bitcoin")
|
||||
[ "$owner" != "100101:100101" ] && echo "WRONG: bitcoin owned by $owner (should be 100101:100101)" || echo " OK: bitcoin → $owner"
|
||||
fi
|
||||
|
||||
# PostgreSQL (container UID 70 → host UID 100070)
|
||||
for dir in /var/lib/archipelago/*-db /var/lib/archipelago/postgres-*; do
|
||||
if [ -d "$dir" ]; then
|
||||
owner=$(stat -c '%u:%g' "$dir")
|
||||
[ "$owner" != "100070:100070" ] && echo "WRONG: $dir owned by $owner (should be 100070:100070)" || echo " OK: $(basename $dir) → $owner"
|
||||
fi
|
||||
done
|
||||
|
||||
# Grafana (container UID 472 → host UID 100472)
|
||||
if [ -d "/var/lib/archipelago/grafana" ]; then
|
||||
owner=$(stat -c '%u:%g' "/var/lib/archipelago/grafana")
|
||||
[ "$owner" != "100472:100472" ] && echo "WRONG: grafana owned by $owner (should be 100472:100472)" || echo " OK: grafana → $owner"
|
||||
fi
|
||||
|
||||
# MariaDB/MySQL (container UID 999 → host UID 100999)
|
||||
if [ -d "/var/lib/archipelago/mysql-mempool" ]; then
|
||||
owner=$(stat -c '%u:%g' "/var/lib/archipelago/mysql-mempool")
|
||||
[ "$owner" != "100999:100999" ] && echo "WRONG: mysql-mempool owned by $owner (should be 100999:100999)" || echo " OK: mysql-mempool → $owner"
|
||||
fi
|
||||
```
|
||||
|
||||
### Step 5: Verify Port Mapping Consistency
|
||||
|
||||
Cross-reference these 4 layers — mismatches between ANY two cause "app not loading" bugs:
|
||||
|
||||
**Layer 1 — Backend Config (Rust)**: Read `core/archipelago/src/api/rpc/package.rs`, look at `get_app_config()` port mappings.
|
||||
|
||||
**Layer 2 — Podman Runtime**: `podman ps --format "{{.Names}}: {{.Ports}}"`
|
||||
|
||||
**Layer 3 — Nginx Proxy**: Read these for `/app/{id}/` location blocks:
|
||||
- `image-recipe/configs/nginx-archipelago.conf` (HTTP)
|
||||
- `image-recipe/configs/snippets/archipelago-https-app-proxies.conf` (HTTPS)
|
||||
|
||||
**Layer 4 — Frontend Routing**: Read `neode-ui/src/stores/appLauncher.ts` — `PORT_TO_APP_ID` map.
|
||||
|
||||
| Symptom | Root Cause |
|
||||
|---------|-----------|
|
||||
| App iframe shows 502/504 | Nginx proxies to wrong port, or container not running |
|
||||
| App loads wrong content | Port collision — two containers on same host port |
|
||||
| Works on port but not /app/ path | Missing nginx location block |
|
||||
| Frontend can't find app | PORT_TO_APP_ID missing in appLauncher.ts |
|
||||
|
||||
### Step 6: Network Connectivity Audit
|
||||
|
||||
```bash
|
||||
# Networks and their containers
|
||||
podman network ls
|
||||
podman network inspect archy-net 2>/dev/null || echo "WARNING: archy-net missing!"
|
||||
|
||||
# Check container subnet (rootless uses 10.89.x.x, NOT 10.88.x.x)
|
||||
podman network inspect archy-net --format "{{range .Subnets}}{{.Subnet}}{{end}}" 2>/dev/null
|
||||
```
|
||||
|
||||
**Must be on archy-net**: bitcoin-knots, lnd, electrs/electrumx, mempool, btcpay-server, nbxplorer, fedimint, fedimint-gateway, nostr-rs-relay, indeedhub, ollama, open-webui
|
||||
|
||||
**Must NOT be on archy-net**: grafana, nextcloud, filebrowser, vaultwarden, bitcoin-ui, lnd-ui, tailscale (host network)
|
||||
|
||||
### Step 7: UFW Forward Policy Check
|
||||
|
||||
Rootless Podman requires `DEFAULT_FORWARD_POLICY="ACCEPT"` in UFW, otherwise container ports are unreachable from LAN.
|
||||
|
||||
```bash
|
||||
grep DEFAULT_FORWARD_POLICY /etc/default/ufw
|
||||
# Must be "ACCEPT", NOT "DROP"
|
||||
# If DROP: containers work locally but NOT from other machines on the network
|
||||
```
|
||||
|
||||
### Step 8: Systemd Service Sandbox Check
|
||||
|
||||
The `archipelago.service` must have specific settings relaxed for rootless Podman:
|
||||
|
||||
```bash
|
||||
# Check critical settings
|
||||
systemctl cat archipelago.service | grep -E "ProtectHome|PrivateTmp|RestrictNamespaces|ReadWritePaths|XDG_RUNTIME_DIR"
|
||||
```
|
||||
|
||||
**Required settings for rootless Podman**:
|
||||
- `ProtectHome=no` — podman stores images in `~/.local/share/containers/`
|
||||
- `PrivateTmp=no` or disabled — podman runtime uses `/tmp/podman-run-1000/`
|
||||
- `RestrictNamespaces=` must NOT be set — rootless podman needs user namespaces
|
||||
- `ReadWritePaths=` must include `/var/lib/archipelago /run/user /tmp`
|
||||
- `Environment=XDG_RUNTIME_DIR=/run/user/1000`
|
||||
|
||||
### Step 9: Health Check Status
|
||||
|
||||
```bash
|
||||
# Containers with health checks — are they passing?
|
||||
for c in $(podman ps --format "{{.Names}}"); do
|
||||
health=$(podman inspect "$c" --format "{{.State.Health.Status}}" 2>/dev/null)
|
||||
if [ -n "$health" ] && [ "$health" != "<no value>" ]; then
|
||||
echo "$c: $health"
|
||||
fi
|
||||
done
|
||||
|
||||
# Containers WITHOUT health checks (gap in monitoring)
|
||||
for c in $(podman ps --format "{{.Names}}"); do
|
||||
hc=$(podman inspect "$c" --format "{{.Config.Healthcheck}}" 2>/dev/null)
|
||||
if [ "$hc" = "<nil>" ] || [ -z "$hc" ]; then
|
||||
echo "NO HEALTHCHECK: $c"
|
||||
fi
|
||||
done
|
||||
```
|
||||
|
||||
### Step 10: Resource & Failure Analysis
|
||||
|
||||
```bash
|
||||
# Resource usage
|
||||
podman stats --no-stream --format "table {{.Name}}\t{{.CPUPerc}}\t{{.MemUsage}}\t{{.MemPerc}}"
|
||||
|
||||
# Recent deaths (last 24h)
|
||||
podman events --filter event=died --since 24h 2>/dev/null | tail -20
|
||||
|
||||
# OOM kills
|
||||
podman ps -a --format "{{.Names}}" | while read c; do
|
||||
oom=$(podman inspect "$c" --format "{{.State.OOMKilled}}" 2>/dev/null)
|
||||
[ "$oom" = "true" ] && echo "OOM KILLED: $c"
|
||||
done
|
||||
|
||||
# Non-zero exits
|
||||
podman ps -a --filter status=exited --format "{{.Names}}\t{{.Status}}"
|
||||
```
|
||||
|
||||
### Step 11: Systemd Integration
|
||||
|
||||
```bash
|
||||
systemctl is-active archipelago nginx
|
||||
systemctl --user list-units --type=service 2>/dev/null | grep -i podman
|
||||
systemctl list-timers --all | grep -i -E "podman|container|archipelago"
|
||||
```
|
||||
|
||||
### Step 12: Generate Report
|
||||
|
||||
Produce a structured report:
|
||||
|
||||
```
|
||||
## Container Diagnostic Report
|
||||
|
||||
### Rootless Podman Status
|
||||
- User: archipelago (UID 1000)
|
||||
- Subuid mapping: [OK/MISSING]
|
||||
- XDG_RUNTIME_DIR: [OK/MISSING]
|
||||
- User linger: [enabled/disabled]
|
||||
- UFW forward policy: [ACCEPT/DROP]
|
||||
|
||||
### Summary
|
||||
- Total containers: X running, Y stopped, Z unhealthy
|
||||
- Port conflicts: [list or "none"]
|
||||
- Missing restart policies: [list or "none"]
|
||||
- Network issues: [list or "none"]
|
||||
- UID mapping issues: [list or "none"]
|
||||
- Health check gaps: [list]
|
||||
|
||||
### Critical Issues (fix immediately)
|
||||
1. ...
|
||||
|
||||
### Warnings (fix soon)
|
||||
1. ...
|
||||
|
||||
### Recommended Actions
|
||||
1. ...
|
||||
```
|
||||
|
||||
After diagnosis, suggest running `/podman-fix` for any issues found.
|
||||
|
||||
## Port Reference
|
||||
|
||||
See `references/port-map.md` for the canonical port assignment table across all 4 layers.
|
||||
|
||||
## UID Mapping Reference
|
||||
|
||||
See `references/uid-mapping.md` for the complete rootless UID mapping table.
|
||||
@@ -1,102 +0,0 @@
|
||||
# Common Podman Failure Patterns
|
||||
|
||||
## Rootless Podman Specific Failures
|
||||
|
||||
| Error | Cause | Fix |
|
||||
|-------|-------|-----|
|
||||
| `ERRO[0000] cannot find UID/GID for user` | subuid/subgid not configured | Add `archipelago:100000:65536` to `/etc/subuid` and `/etc/subgid` |
|
||||
| `Error: unshare: operation not permitted` | Systemd `RestrictNamespaces` blocks user namespaces | Remove `RestrictNamespaces=` from `archipelago.service` |
|
||||
| `Error: could not get runtime: creating runtime` | XDG_RUNTIME_DIR not set or /run/user/1000 missing | Set `Environment=XDG_RUNTIME_DIR=/run/user/1000` in service, ensure `loginctl enable-linger archipelago` |
|
||||
| `permission denied` on volume mount | Wrong UID ownership — must use mapped UIDs | `sudo chown -R 100000:100000 /var/lib/archipelago/APP` (see UID mapping table) |
|
||||
| `ERRO[0000] rootless containers not supported` | Podman not configured for rootless | Run `podman system migrate`, check `/etc/subuid` |
|
||||
| `Error: creating container storage: layer not known` | Corrupted rootless storage | `podman system reset` (destroys all containers — last resort) |
|
||||
| `Error: stat /tmp/podman-run-1000/...: no such file` | PrivateTmp=yes in systemd isolates /tmp | Set `PrivateTmp=no` in `archipelago.service` |
|
||||
| Container ports unreachable from LAN | UFW DEFAULT_FORWARD_POLICY="DROP" | Change to "ACCEPT" in `/etc/default/ufw`, then `sudo ufw reload` |
|
||||
| `Error: error creating network namespace` | Systemd `SystemCallFilter` blocks clone/unshare | Remove `SystemCallFilter=` from `archipelago.service` |
|
||||
| Containers lose network after service restart | podman runtime dir in /tmp cleaned | Ensure `PrivateTmp=no` so /tmp/podman-run-1000/ persists |
|
||||
|
||||
## Container Won't Start
|
||||
|
||||
| Error | Cause | Fix |
|
||||
|-------|-------|-----|
|
||||
| `exec format error` | Binary built on wrong arch | Rebuild on the Linux server |
|
||||
| `address already in use` | Port conflict | `ss -tlnp \| grep :PORT` to find offender |
|
||||
| `permission denied` | Missing capability, wrong UID ownership, or read-only root | Check capabilities, check volume ownership with mapped UID, add tmpfs |
|
||||
| `OCI runtime error` | Corrupt container state | `podman rm -f NAME && recreate` |
|
||||
| `image not known` | Image not pulled | `podman pull IMAGE:TAG` |
|
||||
| `no such network` | Network missing | `podman network create archy-net` |
|
||||
| `Error: netavark: ...subnet overlap` | Network CIDR conflict | `podman network rm archy-net && podman network create archy-net` |
|
||||
|
||||
## Container Starts But App Unreachable
|
||||
|
||||
| Symptom | Check Layer | Fix |
|
||||
|---------|------------|-----|
|
||||
| Direct port works, /app/ doesn't | Nginx config | Add `/app/{id}/` location block |
|
||||
| Neither works | Podman ports | `podman port NAME` — verify mapping exists |
|
||||
| Port mapped but refused | Container logs | App crashing internally — check logs |
|
||||
| Works sometimes | Resources | Check OOM kills, CPU, disk space |
|
||||
| 502 Bad Gateway | Nginx→Container | Wrong port in proxy_pass or container restarted |
|
||||
| Works locally but not from LAN | UFW forward policy | Set `DEFAULT_FORWARD_POLICY="ACCEPT"` in `/etc/default/ufw` |
|
||||
|
||||
## Container Keeps Dying
|
||||
|
||||
| Pattern | Cause | Fix |
|
||||
|---------|-------|-----|
|
||||
| Exits immediately (code 1) | Config error | Check `podman logs NAME` |
|
||||
| Dies after minutes | OOM killed | Increase `--memory` limit |
|
||||
| Dies when dep restarts | No restart policy | Add `--restart unless-stopped` |
|
||||
| Crash loop | Repeated crash | Fix root cause, don't just restart |
|
||||
| Exit code 127 | Missing binary in container | Wrong image tag or corrupted image — re-pull |
|
||||
| Exit code 137 | Killed by OOM or signal | Check `dmesg` for OOM kill, check `podman inspect` for OOMKilled |
|
||||
|
||||
## Network Issues
|
||||
|
||||
| Problem | Cause | Fix |
|
||||
|---------|-------|-----|
|
||||
| Can't resolve container names | Not on archy-net | Recreate with `--network=archy-net` |
|
||||
| Can't reach internet | DNS missing | Add `--dns 1.1.1.1` |
|
||||
| Container-to-container timeout | Different networks | Put both on same network |
|
||||
| Bitcoin RPC refused from container | rpcallowip wrong subnet | Use `rpcallowip=0.0.0.0/0` (safe: port mapped, not exposed) |
|
||||
| Old containers can't find new network | Subnet changed (rootful→rootless) | Recreate containers on new archy-net (rootless uses 10.89.x.x) |
|
||||
|
||||
## Volume Permission Patterns (Rootless UID Mapping)
|
||||
|
||||
Formula: **host_uid = 100000 + container_uid**
|
||||
|
||||
| Container UID | Host UID | Apps | Data Directory |
|
||||
|---|---|---|---|
|
||||
| 0 (root) | 100000 | lnd, fedimint, homeassistant, jellyfin, vaultwarden, photoprism, ollama, filebrowser, electrumx, btcpay, immich | `/var/lib/archipelago/{app}` |
|
||||
| 70 | 100070 | postgres (btcpay-db, immich-db, penpot-postgres) | `/var/lib/archipelago/postgres-*` |
|
||||
| 101 | 100101 | bitcoin-knots | `/var/lib/archipelago/bitcoin` |
|
||||
| 472 | 100472 | grafana | `/var/lib/archipelago/grafana` |
|
||||
| 999 | 100999 | MariaDB (mysql-mempool) | `/var/lib/archipelago/mysql-mempool` |
|
||||
|
||||
## Capability Reference
|
||||
|
||||
| Capability | Apps That Need It | Failure Mode |
|
||||
|-----------|------------------|-------------|
|
||||
| CHOWN | nextcloud, homeassistant, btcpay, jellyfin, portainer | Can't chown during setup |
|
||||
| SETUID/SETGID | nextcloud, homeassistant, btcpay, jellyfin | Can't switch to service user |
|
||||
| DAC_OVERRIDE | nextcloud, homeassistant, btcpay | Can't access cross-UID files |
|
||||
| FOWNER | bitcoin-knots, lnd, fedimint | Can't modify data dir perms |
|
||||
| NET_BIND_SERVICE | nginx-proxy-manager, vaultwarden | Can't bind ports <1024 |
|
||||
| NET_ADMIN + NET_RAW | tailscale | Can't create TUN device or manage routes |
|
||||
|
||||
## Read-Only Safe Apps
|
||||
|
||||
Only these apps can run with `--read-only` + tmpfs: searxng, grafana, filebrowser, electrumx, mempool-electrs, electrs, nostr-rs-relay, ollama, indeedhub
|
||||
|
||||
All others need writable root or will fail silently.
|
||||
|
||||
## Systemd Sandbox Requirements for Rootless Podman
|
||||
|
||||
These systemd service settings MUST be configured for rootless Podman to work:
|
||||
|
||||
| Setting | Required Value | Why |
|
||||
|---------|---------------|-----|
|
||||
| `ProtectHome=` | `no` | Podman stores images in `~/.local/share/containers/` |
|
||||
| `PrivateTmp=` | `no` | Podman runtime lives in `/tmp/podman-run-1000/` |
|
||||
| `RestrictNamespaces=` | NOT SET | Rootless podman creates user namespaces |
|
||||
| `SystemCallFilter=` | NOT SET | Rootless podman needs clone/unshare syscalls |
|
||||
| `ReadWritePaths=` | Include `/var/lib/archipelago /run/user /tmp /etc/containers /var/lib/containers /run/containers` | Volume data + podman runtime paths |
|
||||
| `Environment=` | `XDG_RUNTIME_DIR=/run/user/1000` | Podman socket location |
|
||||
@@ -1,71 +0,0 @@
|
||||
# Archipelago Canonical Port Map
|
||||
|
||||
All port assignments across the 4 configuration layers. When adding or debugging an app, every row must be consistent across all columns.
|
||||
|
||||
## Bitcoin Stack
|
||||
|
||||
| App | Host Port(s) | Container Port(s) | Network | Nginx Path | Frontend Map |
|
||||
|-----|-------------|-------------------|---------|------------|-------------|
|
||||
| bitcoin-knots | 8332, 8333 | 8332, 8333 | archy-net | /app/bitcoin-knots/ | 8332→bitcoin-knots |
|
||||
| bitcoin-ui | 8334 | 80 | bridge | /app/bitcoin-ui/ | 8334→bitcoin-knots |
|
||||
| electrs | 50001 | 50001 | archy-net | /app/electrs/ | 50001→electrs |
|
||||
| lnd | 9735, 10009, 8080 | 9735, 10009, 8080 | archy-net | /app/lnd/ | 10009→lnd |
|
||||
| lnd-ui (RTL) | 8081 | 80 | bridge | /app/lnd-ui/ | 8081→lnd |
|
||||
|
||||
## Lightning & Payment
|
||||
|
||||
| App | Host Port(s) | Container Port(s) | Network | Nginx Path | Frontend Map |
|
||||
|-----|-------------|-------------------|---------|------------|-------------|
|
||||
| btcpay-server | 23000 | 49392 | archy-net | /app/btcpay/ | 23000→btcpay-server |
|
||||
| nbxplorer | 24444 | 32838 | archy-net | N/A (internal) | N/A |
|
||||
| fedimint | 8173, 8174, 8175 | 8173, 8174, 8175 | archy-net | /app/fedimint/ | 8174→fedimint |
|
||||
| fedimint-gateway | 8175 | 8175 | archy-net | /app/fedimint-gateway/ | 8175→fedimint-gateway |
|
||||
|
||||
## Explorer & Monitoring
|
||||
|
||||
| App | Host Port(s) | Container Port(s) | Network | Nginx Path | Frontend Map |
|
||||
|-----|-------------|-------------------|---------|------------|-------------|
|
||||
| mempool | 4080 | 8080 | archy-net | /app/mempool/ | 4080→mempool |
|
||||
| grafana | 3000 | 3000 | bridge | /app/grafana/ | 3000→grafana (new tab) |
|
||||
|
||||
## Self-Hosted Apps
|
||||
|
||||
| App | Host Port(s) | Container Port(s) | Network | Nginx Path | Frontend Map |
|
||||
|-----|-------------|-------------------|---------|------------|-------------|
|
||||
| nextcloud | 8085 | 80 | bridge | /app/nextcloud/ | 8085→nextcloud |
|
||||
| vaultwarden | 8082 | 80 | bridge | /app/vaultwarden/ | 8082→vaultwarden (new tab) |
|
||||
| filebrowser | 8083 | 80 | bridge | /app/filebrowser/ | 8083→filebrowser |
|
||||
| searxng | 8888 | 8080 | bridge | /app/searxng/ | 8888→searxng |
|
||||
| photoprism | 2342 | 2342 | bridge | /app/photoprism/ | 2342→photoprism (new tab) |
|
||||
| jellyfin | 8096 | 8096 | bridge | /app/jellyfin/ | 8096→jellyfin |
|
||||
| homeassistant | 8123 | 8123 | bridge | /app/homeassistant/ | 8123→homeassistant (new tab) |
|
||||
| ollama | 11434 | 11434 | archy-net | /app/ollama/ | 11434→ollama |
|
||||
| open-webui | 3080 | 8080 | archy-net | /app/open-webui/ | 3080→open-webui |
|
||||
|
||||
## Nostr & Social
|
||||
|
||||
| App | Host Port(s) | Container Port(s) | Network | Nginx Path | Frontend Map |
|
||||
|-----|-------------|-------------------|---------|------------|-------------|
|
||||
| nostr-rs-relay | 7000 | 8080 | archy-net | /app/nostr-rs-relay/ | 7000→nostr-rs-relay |
|
||||
| indeedhub | 3001 | 3000 | archy-net | /app/indeedhub/ | 3001→indeedhub |
|
||||
|
||||
## System
|
||||
|
||||
| App | Host Port(s) | Container Port(s) | Network | Nginx Path | Frontend Map |
|
||||
|-----|-------------|-------------------|---------|------------|-------------|
|
||||
| tailscale | 8240 | 8240 | host | /app/tailscale/ | N/A |
|
||||
| nginx-proxy-manager | 81, 8443 | 81, 443 | bridge | N/A | 81→nginx-proxy-manager |
|
||||
|
||||
## Multi-Container Stacks
|
||||
|
||||
**Immich**: immich-server (2283), immich-postgres (internal 5432), immich-redis (internal 6379) — all on immich-net
|
||||
**Penpot**: penpot-frontend (9001→80), penpot-backend, penpot-exporter, penpot-postgres, penpot-mailcatch — all on penpot-net
|
||||
**Mempool**: mempool (4080→8080), mempool-db (internal 3306) — on archy-net
|
||||
**BTCPay**: btcpay-server (23000→49392), nbxplorer (24444→32838), btcpay-postgres (internal 5432) — on archy-net
|
||||
|
||||
## Key Notes
|
||||
|
||||
- **archy-net apps** resolve each other by container name (e.g., `bitcoin-knots:8332`)
|
||||
- **bridge apps** are standalone — access services via host IP/port
|
||||
- **host network** (tailscale only) — shares host namespace, no port mapping
|
||||
- **New tab apps**: btcpay (23000), grafana (3000), vaultwarden (8082), photoprism (2342), homeassistant (8123) — X-Frame-Options blocks iframe
|
||||
@@ -1,93 +0,0 @@
|
||||
# Rootless Podman UID Mapping Reference
|
||||
|
||||
## How Rootless UID Mapping Works
|
||||
|
||||
When Podman runs as the `archipelago` user (UID 1000), container processes don't run as their "apparent" UID on the host. Instead, Linux user namespaces remap UIDs.
|
||||
|
||||
**Mapping formula**: `host_uid = 100000 + container_uid`
|
||||
|
||||
This is configured in `/etc/subuid` and `/etc/subgid`:
|
||||
```
|
||||
archipelago:100000:65536
|
||||
```
|
||||
|
||||
This means:
|
||||
- Container UID 0 (root inside container) → Host UID 100000 (unprivileged on host)
|
||||
- Container UID 70 (postgres) → Host UID 100070
|
||||
- Container UID 101 (bitcoin) → Host UID 100101
|
||||
- etc.
|
||||
|
||||
## Why This Matters
|
||||
|
||||
Volume directories (bind mounts) on the host must be owned by the **mapped** UID, not the container UID. If Bitcoin runs as UID 101 inside its container, the host directory must be owned by UID 100101.
|
||||
|
||||
If ownership is wrong, the container gets `permission denied` when trying to read/write its data.
|
||||
|
||||
## Complete UID Mapping Table
|
||||
|
||||
| Container UID | Host UID | Containers | Fix Command |
|
||||
|---|---|---|---|
|
||||
| 0 (root) | 100000 | lnd, fedimint, fedimint-gateway, homeassistant, jellyfin, vaultwarden, photoprism, ollama, filebrowser, electrumx, btcpay-server, nbxplorer, immich, nostr-rs-relay, strfry, nextcloud, searxng, onlyoffice, tailscale, uptime-kuma | `sudo chown -R 100000:100000 /var/lib/archipelago/{app}` |
|
||||
| 70 | 100070 | postgres (btcpay-db, immich-db, penpot-postgres) | `sudo chown -R 100070:100070 /var/lib/archipelago/postgres-*` |
|
||||
| 101 | 100101 | bitcoin-knots, bitcoin-core | `sudo chown -R 100101:100101 /var/lib/archipelago/bitcoin` |
|
||||
| 472 | 100472 | grafana | `sudo chown -R 100472:100472 /var/lib/archipelago/grafana` |
|
||||
| 999 | 100999 | MariaDB (mysql-mempool) | `sudo chown -R 100999:100999 /var/lib/archipelago/mysql-mempool` |
|
||||
|
||||
## How to Find a Container's UID
|
||||
|
||||
If you encounter a new container with permission issues:
|
||||
|
||||
```bash
|
||||
# Check what user the container runs as
|
||||
podman inspect CONTAINER_NAME --format "{{.Config.User}}"
|
||||
|
||||
# If empty, it runs as root (UID 0) → host UID 100000
|
||||
|
||||
# If it shows a username, find the UID inside the image
|
||||
podman run --rm IMAGE_NAME id
|
||||
|
||||
# Then calculate: host_uid = 100000 + container_uid
|
||||
```
|
||||
|
||||
## Fix Script
|
||||
|
||||
Run this after any fresh install, migration, or when containers have permission errors:
|
||||
|
||||
```bash
|
||||
#!/bin/bash
|
||||
# Fix all rootless podman volume ownership
|
||||
|
||||
# UID 0 → 100000 (most containers)
|
||||
for dir in lnd fedimint fedimint-gateway homeassistant jellyfin vaultwarden photoprism \
|
||||
ollama filebrowser electrumx btcpay nbxplorer immich nostr-rs-relay nextcloud \
|
||||
searxng onlyoffice uptime-kuma; do
|
||||
[ -d "/var/lib/archipelago/$dir" ] && sudo chown -R 100000:100000 "/var/lib/archipelago/$dir"
|
||||
done
|
||||
|
||||
# UID 101 → 100101 (Bitcoin)
|
||||
[ -d "/var/lib/archipelago/bitcoin" ] && sudo chown -R 100101:100101 /var/lib/archipelago/bitcoin
|
||||
|
||||
# UID 70 → 100070 (PostgreSQL)
|
||||
for dir in /var/lib/archipelago/postgres-* /var/lib/archipelago/btcpay-db /var/lib/archipelago/immich-db; do
|
||||
[ -d "$dir" ] && sudo chown -R 100070:100070 "$dir"
|
||||
done
|
||||
|
||||
# UID 999 → 100999 (MariaDB)
|
||||
[ -d "/var/lib/archipelago/mysql-mempool" ] && sudo chown -R 100999:100999 /var/lib/archipelago/mysql-mempool
|
||||
|
||||
# UID 472 → 100472 (Grafana)
|
||||
[ -d "/var/lib/archipelago/grafana" ] && sudo chown -R 100472:100472 /var/lib/archipelago/grafana
|
||||
```
|
||||
|
||||
## Rootful vs Rootless Comparison
|
||||
|
||||
| Aspect | Rootful (old) | Rootless (current) |
|
||||
|--------|---------------|-------------------|
|
||||
| Podman command | `sudo podman` | `podman` (as archipelago user) |
|
||||
| Container storage | `/var/lib/containers/storage` | `~/.local/share/containers/storage` |
|
||||
| Container subnet | `10.88.0.0/16` | `10.89.0.0/16` |
|
||||
| Volume ownership | Container UID directly | Mapped UID (100000 + container_uid) |
|
||||
| Requires root? | Yes | No (except fixing volume ownership) |
|
||||
| XDG_RUNTIME_DIR | Not needed | Required: `/run/user/1000` |
|
||||
| User lingering | Not needed | Required: `loginctl enable-linger` |
|
||||
| Systemd restrictions | All can be enabled | Must disable: RestrictNamespaces, SystemCallFilter |
|
||||
@@ -1,338 +0,0 @@
|
||||
---
|
||||
name: podman-fix
|
||||
description: >
|
||||
Fix Podman container issues on Archipelago — restart failed containers, repair port bindings,
|
||||
fix network connectivity, add missing restart policies, fix rootless UID mapping, and resolve
|
||||
config drift. Handles rootless Podman (user: archipelago, UID 1000, subuid 100000:65536).
|
||||
Use when asked to "fix container", "restart app", "fix port mapping", "container not working",
|
||||
"app won't start", "fix podman", "repair container", "container down", "permission denied",
|
||||
or after /podman-doctor identifies issues to fix.
|
||||
allowed-tools: Bash Read Edit Write Glob Grep
|
||||
---
|
||||
|
||||
# Podman Fix — Container Remediation
|
||||
|
||||
Targeted fix workflow for **rootless Podman** container issues on Archipelago. Given a specific problem (from /podman-doctor or user report), diagnose the root cause and fix it.
|
||||
|
||||
**SSH command**: `ssh -i ~/.ssh/archipelago-deploy archipelago@192.168.1.228`
|
||||
|
||||
> **ROOTLESS PODMAN**: All `podman` commands run as the `archipelago` user — NO sudo.
|
||||
> Only use `sudo` for: chown on volume directories, UFW changes, systemd service edits, nginx reload.
|
||||
> Container UIDs are mapped via subuid: container UID N → host UID (100000 + N).
|
||||
|
||||
If $ARGUMENTS is provided, fix that specific app/issue. Otherwise ask what needs fixing.
|
||||
|
||||
## Fix Procedures
|
||||
|
||||
### Fix 1: Container Not Running
|
||||
|
||||
```bash
|
||||
# Check why it stopped
|
||||
podman logs --tail 50 CONTAINER_NAME
|
||||
podman inspect CONTAINER_NAME --format "{{.State.ExitCode}} {{.State.Error}}"
|
||||
|
||||
# If clean exit or crash — just restart
|
||||
podman start CONTAINER_NAME
|
||||
|
||||
# If corrupt state — remove and recreate
|
||||
podman rm -f CONTAINER_NAME
|
||||
# Then recreate using the install flow (trigger from UI or re-run creation command)
|
||||
```
|
||||
|
||||
**If container keeps crashing**, check logs for the actual error. Common causes:
|
||||
- Missing config file → check if volume mount has the config
|
||||
- Wrong permissions → fix UID mapping (see Fix 8 below)
|
||||
- Dependency not ready → start dependency first, wait, then start this container
|
||||
- Exit code 127 → missing binary in container image, re-pull the image
|
||||
|
||||
### Fix 2: Missing Restart Policy
|
||||
|
||||
The most common uptime killer. Fix for ALL containers at once:
|
||||
|
||||
```bash
|
||||
# Fix a single container
|
||||
podman update --restart unless-stopped CONTAINER_NAME
|
||||
|
||||
# Fix ALL containers that have no restart policy
|
||||
for c in $(podman ps -a --format "{{.Names}}"); do
|
||||
policy=$(podman inspect "$c" --format "{{.HostConfig.RestartPolicy.Name}}")
|
||||
if [ "$policy" = "no" ] || [ -z "$policy" ]; then
|
||||
echo "Fixing restart policy for: $c"
|
||||
podman update --restart unless-stopped "$c"
|
||||
fi
|
||||
done
|
||||
```
|
||||
|
||||
**Also update the Rust source** so new installs get it right:
|
||||
- Check `core/archipelago/src/api/rpc/package.rs` `get_app_config()` for the app
|
||||
- Ensure `--restart` flag is in the podman run args
|
||||
|
||||
### Fix 3: Port Mapping Issues
|
||||
|
||||
#### Port conflict (address already in use)
|
||||
```bash
|
||||
# Find what's using the port
|
||||
ss -tlnp | grep :PORT_NUMBER
|
||||
|
||||
# If it's another container, either change one's port or stop the conflicting one
|
||||
podman stop CONFLICTING_CONTAINER
|
||||
|
||||
# If it's a host process (e.g., system tor vs container tor)
|
||||
sudo systemctl stop tor # Stop system service if container needs the port
|
||||
sudo systemctl disable tor
|
||||
```
|
||||
|
||||
#### Port not mapped (container running but port unreachable)
|
||||
```bash
|
||||
# Check current port mappings
|
||||
podman port CONTAINER_NAME
|
||||
|
||||
# Can't add ports to running container — must recreate
|
||||
podman stop CONTAINER_NAME
|
||||
podman rm CONTAINER_NAME
|
||||
# Recreate with correct -p flags (use the Rust install flow or manual podman run)
|
||||
```
|
||||
|
||||
#### Nginx proxy missing or wrong
|
||||
Read and fix the nginx config:
|
||||
- HTTP: `image-recipe/configs/nginx-archipelago.conf`
|
||||
- HTTPS: `image-recipe/configs/snippets/archipelago-https-app-proxies.conf`
|
||||
|
||||
Add a location block:
|
||||
```nginx
|
||||
location /app/APP_ID/ {
|
||||
proxy_pass http://127.0.0.1:HOST_PORT/;
|
||||
proxy_set_header Host $host;
|
||||
proxy_set_header X-Real-IP $remote_addr;
|
||||
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||
proxy_set_header X-Forwarded-Proto $scheme;
|
||||
proxy_http_version 1.1;
|
||||
proxy_set_header Upgrade $http_upgrade;
|
||||
proxy_set_header Connection $connection_upgrade;
|
||||
# Hide X-Frame-Options so it works in our iframe
|
||||
proxy_hide_header X-Frame-Options;
|
||||
proxy_hide_header Content-Security-Policy;
|
||||
}
|
||||
```
|
||||
|
||||
After editing nginx config, deploy and reload:
|
||||
```bash
|
||||
# On server
|
||||
sudo nginx -t && sudo systemctl reload nginx
|
||||
```
|
||||
|
||||
#### Frontend routing missing
|
||||
Edit `neode-ui/src/stores/appLauncher.ts`:
|
||||
- Add entry to `PORT_TO_APP_ID` map
|
||||
- If app blocks iframes, add port to the new-tab list in `resolveAppIdFromUrl()`
|
||||
|
||||
### Fix 4: Network Issues
|
||||
|
||||
#### Container not on archy-net (can't resolve other containers)
|
||||
```bash
|
||||
# Connect to archy-net without recreating
|
||||
podman network connect archy-net CONTAINER_NAME
|
||||
|
||||
# Verify
|
||||
podman inspect CONTAINER_NAME --format "{{.NetworkSettings.Networks}}"
|
||||
```
|
||||
|
||||
#### archy-net doesn't exist
|
||||
```bash
|
||||
podman network create archy-net
|
||||
# Then reconnect all containers that need it
|
||||
```
|
||||
|
||||
#### DNS not working inside container
|
||||
```bash
|
||||
# Test DNS from inside container
|
||||
podman exec CONTAINER_NAME nslookup bitcoin-knots 2>/dev/null || \
|
||||
podman exec CONTAINER_NAME ping -c1 bitcoin-knots
|
||||
|
||||
# If DNS fails, check the container's resolv.conf
|
||||
podman exec CONTAINER_NAME cat /etc/resolv.conf
|
||||
|
||||
# If DNS fails, recreate container with explicit DNS
|
||||
# Add --dns 1.1.1.1 to the podman run command
|
||||
```
|
||||
|
||||
#### Container subnet changed (rootful → rootless migration)
|
||||
```bash
|
||||
# Old rootful subnet: 10.88.0.0/16
|
||||
# New rootless subnet: 10.89.0.0/16
|
||||
# Bitcoin RPC rpcallowip must be updated if using subnet-specific allowlist
|
||||
|
||||
# Check current archy-net subnet
|
||||
podman network inspect archy-net --format "{{range .Subnets}}{{.Subnet}}{{end}}"
|
||||
|
||||
# If Bitcoin RPC refuses connections from containers:
|
||||
# Update bitcoin.conf rpcallowip to 0.0.0.0/0 (safe: only accessible via port mapping)
|
||||
```
|
||||
|
||||
### Fix 5: Health Check Issues
|
||||
|
||||
#### Add missing health check to running container
|
||||
Can't add to running container — must recreate with health check flags:
|
||||
```bash
|
||||
# Example for a web app
|
||||
podman run ... \
|
||||
--health-cmd "curl -f http://localhost:PORT/health || exit 1" \
|
||||
--health-interval 30s \
|
||||
--health-timeout 5s \
|
||||
--health-retries 3 \
|
||||
--health-start-period 60s \
|
||||
IMAGE
|
||||
```
|
||||
|
||||
#### Fix unhealthy container
|
||||
```bash
|
||||
# See what the health check is actually running
|
||||
podman inspect CONTAINER_NAME --format "{{.Config.Healthcheck.Test}}"
|
||||
|
||||
# Run the health check manually to see the error
|
||||
podman exec CONTAINER_NAME HEALTH_CHECK_COMMAND
|
||||
|
||||
# Common fixes:
|
||||
# - curl not installed in container → use wget or nc instead
|
||||
# - Wrong port in health check → fix the check command
|
||||
# - App takes too long to start → increase --health-start-period
|
||||
```
|
||||
|
||||
### Fix 6: Permission/Capability Issues
|
||||
|
||||
```bash
|
||||
# Check what capabilities container has
|
||||
podman inspect CONTAINER_NAME --format "{{.HostConfig.CapAdd}}"
|
||||
|
||||
# If missing required caps, must recreate with correct --cap-add flags
|
||||
# Refer to the capability reference in /podman-doctor references
|
||||
```
|
||||
|
||||
### Fix 7: Full Config Consistency Fix
|
||||
|
||||
When port map is inconsistent across layers, fix ALL layers:
|
||||
|
||||
1. **Decide the correct port** (usually what's in package.rs)
|
||||
2. **Fix Podman**: recreate container with correct `-p` flags
|
||||
3. **Fix Nginx**: update location block's `proxy_pass` port
|
||||
4. **Fix Frontend**: update `PORT_TO_APP_ID` in appLauncher.ts
|
||||
5. **Deploy**: `./scripts/deploy-to-target.sh --live`
|
||||
6. **Verify**: `curl -I http://192.168.1.228/app/APP_ID/`
|
||||
|
||||
### Fix 8: Rootless UID Mapping (Permission Denied on Volumes)
|
||||
|
||||
This is the #1 rootless-specific issue. Container UIDs are remapped by user namespaces.
|
||||
|
||||
**Formula**: `host_uid = 100000 + container_uid`
|
||||
|
||||
```bash
|
||||
# Fix UID 0 containers (most apps — run as root inside, mapped to 100000 on host)
|
||||
sudo chown -R 100000:100000 /var/lib/archipelago/APP_NAME
|
||||
|
||||
# Fix Bitcoin (container UID 101 → host UID 100101)
|
||||
sudo chown -R 100101:100101 /var/lib/archipelago/bitcoin
|
||||
|
||||
# Fix PostgreSQL (container UID 70 → host UID 100070)
|
||||
sudo chown -R 100070:100070 /var/lib/archipelago/postgres-APP_NAME
|
||||
|
||||
# Fix Grafana (container UID 472 → host UID 100472)
|
||||
sudo chown -R 100472:100472 /var/lib/archipelago/grafana
|
||||
|
||||
# Fix MariaDB (container UID 999 → host UID 100999)
|
||||
sudo chown -R 100999:100999 /var/lib/archipelago/mysql-mempool
|
||||
```
|
||||
|
||||
**How to find the right UID for a new container:**
|
||||
```bash
|
||||
# Check what user the container image runs as
|
||||
podman inspect IMAGE_NAME --format "{{.Config.User}}"
|
||||
# If empty = root (UID 0) → host UID 100000
|
||||
# If number → host UID = 100000 + that number
|
||||
# If username → run: podman run --rm IMAGE_NAME id
|
||||
```
|
||||
|
||||
After fixing ownership, restart the container:
|
||||
```bash
|
||||
podman restart CONTAINER_NAME
|
||||
```
|
||||
|
||||
### Fix 9: UFW Forward Policy (LAN Access Broken)
|
||||
|
||||
If containers work locally but not from other machines on the network:
|
||||
|
||||
```bash
|
||||
# Check current policy
|
||||
grep DEFAULT_FORWARD_POLICY /etc/default/ufw
|
||||
|
||||
# Fix: change DROP to ACCEPT
|
||||
sudo sed -i 's/DEFAULT_FORWARD_POLICY="DROP"/DEFAULT_FORWARD_POLICY="ACCEPT"/' /etc/default/ufw
|
||||
sudo ufw reload
|
||||
```
|
||||
|
||||
### Fix 10: Systemd Sandbox Too Restrictive
|
||||
|
||||
If the Rust backend can't scan/manage containers after a systemd update:
|
||||
|
||||
```bash
|
||||
# Check what's blocked
|
||||
sudo journalctl -u archipelago --since "10 min ago" | grep -i "denied\|permission\|namespace\|syscall"
|
||||
|
||||
# The archipelago.service MUST have these for rootless podman:
|
||||
# ProtectHome=no
|
||||
# PrivateTmp=no (or disabled)
|
||||
# RestrictNamespaces= (NOT SET — don't restrict)
|
||||
# SystemCallFilter= (NOT SET — don't filter)
|
||||
# ReadWritePaths=/var/lib/archipelago /etc/containers /var/lib/containers /run/containers /run/user /tmp
|
||||
# Environment=XDG_RUNTIME_DIR=/run/user/1000
|
||||
```
|
||||
|
||||
Edit the service file:
|
||||
```bash
|
||||
sudo systemctl edit archipelago.service
|
||||
# Add overrides, then:
|
||||
sudo systemctl daemon-reload
|
||||
sudo systemctl restart archipelago
|
||||
```
|
||||
|
||||
### Fix 11: Stale Podman Processes
|
||||
|
||||
If `podman ps` hangs or is very slow:
|
||||
|
||||
```bash
|
||||
# Kill stuck podman processes (>10 of them = something is wrong)
|
||||
stuck=$(pgrep -c -f "podman ps\|podman stats" 2>/dev/null || echo 0)
|
||||
if [ "$stuck" -gt 10 ]; then
|
||||
pkill -f "podman ps\|podman stats"
|
||||
echo "Killed $stuck stuck podman processes"
|
||||
fi
|
||||
|
||||
# Kill orphaned conmon processes holding ports
|
||||
for pid in $(pgrep conmon); do
|
||||
container=$(cat /proc/$pid/cmdline 2>/dev/null | tr '\0' ' ' | grep -oP '(?<=--cid )\S+')
|
||||
if [ -n "$container" ] && ! podman ps -a --format "{{.ID}}" | grep -q "${container:0:12}"; then
|
||||
kill "$pid" 2>/dev/null && echo "Killed orphan conmon $pid"
|
||||
fi
|
||||
done
|
||||
```
|
||||
|
||||
## After Fixing
|
||||
|
||||
Always verify the fix:
|
||||
```bash
|
||||
# Container running?
|
||||
podman ps --filter name=CONTAINER_NAME
|
||||
|
||||
# Port reachable?
|
||||
curl -s -o /dev/null -w "%{http_code}" http://127.0.0.1:PORT/
|
||||
|
||||
# Via nginx proxy?
|
||||
curl -s -o /dev/null -w "%{http_code}" http://127.0.0.1/app/APP_ID/
|
||||
|
||||
# Health check passing?
|
||||
podman inspect CONTAINER_NAME --format "{{.State.Health.Status}}"
|
||||
|
||||
# Volume permissions correct? (rootless check)
|
||||
podman exec CONTAINER_NAME ls -la /data/ 2>/dev/null || echo "Check container data path"
|
||||
```
|
||||
|
||||
Run `/podman-doctor` again to confirm all issues are resolved.
|
||||
@@ -1,410 +0,0 @@
|
||||
---
|
||||
name: podman-uptime
|
||||
description: >
|
||||
Ensure 100% container uptime on Archipelago. Sets up systemd watchdog timers, verifies
|
||||
restart policies, creates health check monitors, and configures auto-recovery for all
|
||||
containers. Handles rootless Podman (user: archipelago, UID 1000, subuid 100000:65536).
|
||||
Use when asked to "ensure uptime", "containers keep dying", "auto-restart",
|
||||
"watchdog", "container monitoring", "uptime guarantee", "keep containers running",
|
||||
"survive reboot", or to harden container reliability.
|
||||
allowed-tools: Bash Read Edit Write Glob Grep
|
||||
---
|
||||
|
||||
# Podman Uptime — Container Reliability Guardian
|
||||
|
||||
Ensures every Archipelago container survives reboots, recovers from crashes, and stays healthy. Sets up the three layers of uptime defense: restart policies, systemd watchdog, and health-based auto-recovery.
|
||||
|
||||
**SSH command**: `ssh -i ~/.ssh/archipelago-deploy archipelago@192.168.1.228`
|
||||
|
||||
> **ROOTLESS PODMAN**: All `podman` commands run as the `archipelago` user — NO sudo.
|
||||
> Only use `sudo` for: systemd unit files, chown on volumes, UFW changes.
|
||||
> The archipelago user runs containers directly via user namespaces.
|
||||
|
||||
## Prerequisites for Rootless Uptime
|
||||
|
||||
Before setting up uptime infrastructure, verify rootless Podman basics are working:
|
||||
|
||||
```bash
|
||||
# Must be the archipelago user
|
||||
whoami # archipelago
|
||||
|
||||
# User lingering must be enabled (keeps user services running after logout)
|
||||
ls /var/lib/systemd/linger/ | grep archipelago || sudo loginctl enable-linger archipelago
|
||||
|
||||
# XDG_RUNTIME_DIR must be set
|
||||
echo $XDG_RUNTIME_DIR # /run/user/1000
|
||||
|
||||
# Subuid/subgid must be configured
|
||||
grep archipelago /etc/subuid # archipelago:100000:65536
|
||||
|
||||
# UFW forward policy must be ACCEPT (for LAN access to containers)
|
||||
grep DEFAULT_FORWARD_POLICY /etc/default/ufw # Must be "ACCEPT"
|
||||
```
|
||||
|
||||
## Layer 1: Restart Policies (Survive Reboots)
|
||||
|
||||
Every container MUST have `--restart unless-stopped`. This is non-negotiable.
|
||||
|
||||
### Audit and fix all containers
|
||||
|
||||
```bash
|
||||
# Audit
|
||||
for c in $(podman ps -a --format "{{.Names}}"); do
|
||||
policy=$(podman inspect "$c" --format "{{.HostConfig.RestartPolicy.Name}}")
|
||||
echo "$c: $policy"
|
||||
done
|
||||
|
||||
# Fix any with "no" or empty policy
|
||||
for c in $(podman ps -a --format "{{.Names}}"); do
|
||||
policy=$(podman inspect "$c" --format "{{.HostConfig.RestartPolicy.Name}}")
|
||||
if [ "$policy" = "no" ] || [ -z "$policy" ]; then
|
||||
echo "Fixing: $c"
|
||||
podman update --restart unless-stopped "$c"
|
||||
fi
|
||||
done
|
||||
```
|
||||
|
||||
### Ensure podman auto-starts containers on boot
|
||||
|
||||
For rootless Podman, containers with restart policies are auto-started by `podman-restart` as a **user** service:
|
||||
|
||||
```bash
|
||||
# Enable the rootless podman-restart user service
|
||||
systemctl --user enable podman-restart.service 2>/dev/null
|
||||
|
||||
# If the user service doesn't exist, create a system-level one
|
||||
# (runs as archipelago user via User= directive)
|
||||
cat <<'EOF' | sudo tee /etc/systemd/system/podman-restart.service
|
||||
[Unit]
|
||||
Description=Podman Start All Containers With Restart Policy
|
||||
After=network-online.target
|
||||
Wants=network-online.target
|
||||
|
||||
[Service]
|
||||
Type=oneshot
|
||||
User=archipelago
|
||||
Group=archipelago
|
||||
Environment=XDG_RUNTIME_DIR=/run/user/1000
|
||||
ExecStart=/usr/bin/podman start --all --filter restart-policy=unless-stopped
|
||||
RemainAfterExit=yes
|
||||
TimeoutStartSec=300
|
||||
|
||||
[Install]
|
||||
WantedBy=multi-user.target
|
||||
EOF
|
||||
|
||||
sudo systemctl daemon-reload
|
||||
sudo systemctl enable podman-restart.service
|
||||
```
|
||||
|
||||
## Layer 2: Systemd Watchdog (Detect and Recover)
|
||||
|
||||
Create a systemd timer that checks container health every 2 minutes and restarts unhealthy or stopped containers.
|
||||
|
||||
### Create the watchdog script
|
||||
|
||||
```bash
|
||||
cat <<'SCRIPT' | sudo tee /usr/local/bin/archipelago-container-watchdog.sh
|
||||
#!/bin/bash
|
||||
# Archipelago Container Watchdog (Rootless Podman)
|
||||
# Runs as archipelago user — NO sudo for podman commands
|
||||
|
||||
LOG_TAG="container-watchdog"
|
||||
|
||||
# Run podman as the archipelago user with correct XDG path
|
||||
export XDG_RUNTIME_DIR=/run/user/1000
|
||||
PODMAN="/usr/bin/podman"
|
||||
|
||||
# Restart any stopped containers that should be running (have restart policy)
|
||||
for c in $($PODMAN ps -a --filter status=exited --filter restart-policy=unless-stopped --format "{{.Names}}" 2>/dev/null); do
|
||||
logger -t "$LOG_TAG" "Restarting stopped container: $c"
|
||||
$PODMAN start "$c" 2>&1 | logger -t "$LOG_TAG"
|
||||
done
|
||||
|
||||
# Restart unhealthy containers
|
||||
for c in $($PODMAN ps --filter health=unhealthy --format "{{.Names}}" 2>/dev/null); do
|
||||
logger -t "$LOG_TAG" "Restarting unhealthy container: $c"
|
||||
$PODMAN restart "$c" 2>&1 | logger -t "$LOG_TAG"
|
||||
done
|
||||
|
||||
# Check for containers in "created" state (never started)
|
||||
for c in $($PODMAN ps -a --filter status=created --format "{{.Names}}" 2>/dev/null); do
|
||||
logger -t "$LOG_TAG" "Starting created container: $c"
|
||||
$PODMAN start "$c" 2>&1 | logger -t "$LOG_TAG"
|
||||
done
|
||||
SCRIPT
|
||||
|
||||
sudo chmod +x /usr/local/bin/archipelago-container-watchdog.sh
|
||||
```
|
||||
|
||||
### Create the systemd timer
|
||||
|
||||
```bash
|
||||
# Service unit — runs as archipelago user for rootless podman
|
||||
cat <<'EOF' | sudo tee /etc/systemd/system/archipelago-watchdog.service
|
||||
[Unit]
|
||||
Description=Archipelago Container Watchdog
|
||||
After=podman-restart.service
|
||||
|
||||
[Service]
|
||||
Type=oneshot
|
||||
User=archipelago
|
||||
Group=archipelago
|
||||
Environment=XDG_RUNTIME_DIR=/run/user/1000
|
||||
ExecStart=/usr/local/bin/archipelago-container-watchdog.sh
|
||||
EOF
|
||||
|
||||
# Timer unit — runs every 2 minutes
|
||||
cat <<'EOF' | sudo tee /etc/systemd/system/archipelago-watchdog.timer
|
||||
[Unit]
|
||||
Description=Run Archipelago Container Watchdog every 2 minutes
|
||||
|
||||
[Timer]
|
||||
OnBootSec=120
|
||||
OnUnitActiveSec=120
|
||||
AccuracySec=30
|
||||
|
||||
[Install]
|
||||
WantedBy=timers.target
|
||||
EOF
|
||||
|
||||
sudo systemctl daemon-reload
|
||||
sudo systemctl enable --now archipelago-watchdog.timer
|
||||
```
|
||||
|
||||
### Verify watchdog is running
|
||||
|
||||
```bash
|
||||
sudo systemctl status archipelago-watchdog.timer
|
||||
sudo systemctl list-timers | grep archipelago
|
||||
# Check watchdog logs
|
||||
sudo journalctl -t container-watchdog --since "1 hour ago" --no-pager
|
||||
```
|
||||
|
||||
## Layer 3: Dependency-Aware Startup Order
|
||||
|
||||
Some containers depend on others. The watchdog handles restarts, but initial boot order matters.
|
||||
|
||||
### Create ordered startup script
|
||||
|
||||
```bash
|
||||
cat <<'SCRIPT' | sudo tee /usr/local/bin/archipelago-ordered-start.sh
|
||||
#!/bin/bash
|
||||
# Ordered container startup for Archipelago (Rootless Podman)
|
||||
# Runs as archipelago user — NO sudo for podman commands
|
||||
# Respects dependency chain: bitcoin → electrs/lnd → mempool/btcpay
|
||||
|
||||
LOG_TAG="ordered-start"
|
||||
export XDG_RUNTIME_DIR=/run/user/1000
|
||||
PODMAN="/usr/bin/podman"
|
||||
|
||||
wait_for_container() {
|
||||
local name=$1
|
||||
local max_wait=${2:-60}
|
||||
local waited=0
|
||||
while [ $waited -lt $max_wait ]; do
|
||||
status=$($PODMAN inspect "$name" --format "{{.State.Running}}" 2>/dev/null)
|
||||
if [ "$status" = "true" ]; then
|
||||
logger -t "$LOG_TAG" "$name is running"
|
||||
return 0
|
||||
fi
|
||||
sleep 5
|
||||
waited=$((waited + 5))
|
||||
done
|
||||
logger -t "$LOG_TAG" "WARNING: $name not running after ${max_wait}s"
|
||||
return 1
|
||||
}
|
||||
|
||||
# Tier 0: Infrastructure
|
||||
logger -t "$LOG_TAG" "Starting Tier 0: Infrastructure"
|
||||
$PODMAN start tailscale 2>/dev/null
|
||||
|
||||
# Tier 1: Databases (must start before services that depend on them)
|
||||
logger -t "$LOG_TAG" "Starting Tier 1: Databases"
|
||||
$PODMAN start mempool-db 2>/dev/null
|
||||
$PODMAN start btcpay-postgres 2>/dev/null
|
||||
$PODMAN start immich_postgres 2>/dev/null
|
||||
sleep 5
|
||||
|
||||
# Tier 2: Bitcoin (foundation for Lightning and explorers)
|
||||
logger -t "$LOG_TAG" "Starting Tier 2: Bitcoin"
|
||||
$PODMAN start bitcoin-knots 2>/dev/null
|
||||
wait_for_container bitcoin-knots 120
|
||||
|
||||
# Tier 3: Bitcoin-dependent services
|
||||
logger -t "$LOG_TAG" "Starting Tier 3: Bitcoin-dependent"
|
||||
$PODMAN start electrumx 2>/dev/null
|
||||
$PODMAN start lnd 2>/dev/null
|
||||
wait_for_container electrumx 90
|
||||
wait_for_container lnd 90
|
||||
|
||||
# Tier 4: Services depending on Tier 3
|
||||
logger -t "$LOG_TAG" "Starting Tier 4: Second-order dependencies"
|
||||
$PODMAN start mempool 2>/dev/null
|
||||
$PODMAN start nbxplorer 2>/dev/null
|
||||
sleep 10
|
||||
$PODMAN start btcpay-server 2>/dev/null
|
||||
$PODMAN start fedimint 2>/dev/null
|
||||
$PODMAN start fedimint-gateway 2>/dev/null
|
||||
|
||||
# Tier 5: Independent apps (start all remaining)
|
||||
logger -t "$LOG_TAG" "Starting Tier 5: Independent apps"
|
||||
$PODMAN start --all 2>/dev/null
|
||||
|
||||
# Tier 6: UI containers (need parent apps running first)
|
||||
logger -t "$LOG_TAG" "Starting Tier 6: UI containers"
|
||||
$PODMAN start bitcoin-ui 2>/dev/null
|
||||
$PODMAN start lnd-ui 2>/dev/null
|
||||
$PODMAN start electrs-ui 2>/dev/null
|
||||
|
||||
logger -t "$LOG_TAG" "Startup sequence complete"
|
||||
SCRIPT
|
||||
|
||||
sudo chmod +x /usr/local/bin/archipelago-ordered-start.sh
|
||||
```
|
||||
|
||||
### Wire into boot sequence
|
||||
|
||||
```bash
|
||||
# Runs as archipelago user for rootless podman
|
||||
cat <<'EOF' | sudo tee /etc/systemd/system/archipelago-containers.service
|
||||
[Unit]
|
||||
Description=Archipelago Ordered Container Startup
|
||||
After=network-online.target
|
||||
Wants=network-online.target
|
||||
Before=archipelago.service
|
||||
|
||||
[Service]
|
||||
Type=oneshot
|
||||
User=archipelago
|
||||
Group=archipelago
|
||||
Environment=XDG_RUNTIME_DIR=/run/user/1000
|
||||
ExecStart=/usr/local/bin/archipelago-ordered-start.sh
|
||||
RemainAfterExit=yes
|
||||
TimeoutStartSec=600
|
||||
|
||||
[Install]
|
||||
WantedBy=multi-user.target
|
||||
EOF
|
||||
|
||||
sudo systemctl daemon-reload
|
||||
sudo systemctl enable archipelago-containers.service
|
||||
```
|
||||
|
||||
## Rootless-Specific Uptime Considerations
|
||||
|
||||
### Volume ownership survives reboots
|
||||
Volume ownership doesn't change on reboot, but if a container image is updated (re-pulled), the new container may run as a different UID. Always verify after image updates:
|
||||
|
||||
```bash
|
||||
# Quick ownership audit after image pull
|
||||
podman inspect CONTAINER_NAME --format "{{.Config.User}}"
|
||||
# Then verify: sudo stat -c '%u:%g' /var/lib/archipelago/APP_NAME
|
||||
# Formula: host_uid = 100000 + container_uid
|
||||
```
|
||||
|
||||
### XDG_RUNTIME_DIR on boot
|
||||
Rootless Podman requires `/run/user/1000` to exist. This is created by `pam_systemd` when the user logs in, or by `loginctl enable-linger`. If it's missing after boot, containers won't start.
|
||||
|
||||
```bash
|
||||
# Verify it exists
|
||||
ls -la /run/user/1000/ || echo "CRITICAL: /run/user/1000 missing — run: sudo loginctl enable-linger archipelago"
|
||||
```
|
||||
|
||||
### Systemd sandbox must not block podman
|
||||
If the archipelago.service sandbox blocks namespace/syscall operations, the Rust backend can't scan containers. See Fix 10 in /podman-fix.
|
||||
|
||||
## Verification Checklist
|
||||
|
||||
After setting up all 3 layers, verify:
|
||||
|
||||
```bash
|
||||
echo "=== Rootless Podman Prerequisites ==="
|
||||
echo "User: $(whoami)"
|
||||
echo "XDG_RUNTIME_DIR: $XDG_RUNTIME_DIR"
|
||||
grep archipelago /etc/subuid | head -1
|
||||
ls /var/lib/systemd/linger/ | grep archipelago && echo "Linger: enabled" || echo "Linger: DISABLED"
|
||||
grep DEFAULT_FORWARD_POLICY /etc/default/ufw
|
||||
|
||||
echo ""
|
||||
echo "=== Layer 1: Restart Policies ==="
|
||||
for c in $(podman ps -a --format "{{.Names}}"); do
|
||||
policy=$(podman inspect "$c" --format "{{.HostConfig.RestartPolicy.Name}}")
|
||||
echo " $c: $policy"
|
||||
done
|
||||
|
||||
echo ""
|
||||
echo "=== Layer 2: Watchdog Timer ==="
|
||||
sudo systemctl is-active archipelago-watchdog.timer
|
||||
sudo systemctl list-timers | grep archipelago
|
||||
|
||||
echo ""
|
||||
echo "=== Layer 3: Boot Services ==="
|
||||
sudo systemctl is-enabled podman-restart.service 2>/dev/null || echo "podman-restart: not found"
|
||||
sudo systemctl is-enabled archipelago-containers.service 2>/dev/null || echo "ordered-start: not found"
|
||||
sudo systemctl is-enabled archipelago-watchdog.timer 2>/dev/null || echo "watchdog: not found"
|
||||
|
||||
echo ""
|
||||
echo "=== Container Health Summary ==="
|
||||
total=$(podman ps -a --format "{{.Names}}" | wc -l)
|
||||
running=$(podman ps --format "{{.Names}}" | wc -l)
|
||||
stopped=$((total - running))
|
||||
unhealthy=$(podman ps --filter health=unhealthy --format "{{.Names}}" | wc -l)
|
||||
echo " Total: $total | Running: $running | Stopped: $stopped | Unhealthy: $unhealthy"
|
||||
|
||||
echo ""
|
||||
echo "=== Volume Ownership Spot Check ==="
|
||||
for dir in bitcoin lnd grafana; do
|
||||
if [ -d "/var/lib/archipelago/$dir" ]; then
|
||||
echo " $dir: $(stat -c '%u:%g' /var/lib/archipelago/$dir)"
|
||||
fi
|
||||
done
|
||||
```
|
||||
|
||||
## Reboot Test
|
||||
|
||||
The ultimate uptime test — reboot the server and verify everything comes back:
|
||||
|
||||
```bash
|
||||
# Before reboot: record running containers
|
||||
podman ps --format "{{.Names}}" | sort > /tmp/before-reboot.txt
|
||||
|
||||
# Reboot
|
||||
sudo reboot
|
||||
|
||||
# After reboot (wait ~3 minutes, then SSH back in):
|
||||
podman ps --format "{{.Names}}" | sort > /tmp/after-reboot.txt
|
||||
|
||||
# Compare
|
||||
diff /tmp/before-reboot.txt /tmp/after-reboot.txt
|
||||
# Should show no differences
|
||||
|
||||
# Also verify XDG_RUNTIME_DIR survived reboot
|
||||
ls /run/user/1000/ || echo "CRITICAL: lingering not working"
|
||||
```
|
||||
|
||||
## Monitoring
|
||||
|
||||
Check uptime status anytime:
|
||||
```bash
|
||||
# Quick status
|
||||
podman ps -a --format "table {{.Names}}\t{{.Status}}" | sort
|
||||
|
||||
# Watchdog activity
|
||||
sudo journalctl -t container-watchdog --since "24 hours ago" --no-pager
|
||||
|
||||
# Container events (starts, stops, deaths)
|
||||
podman events --since 24h --filter event=start --filter event=stop --filter event=died 2>/dev/null | tail -30
|
||||
|
||||
# Check for permission denied errors (rootless UID mapping issue)
|
||||
podman ps -a --filter status=exited --format "{{.Names}}" | while read c; do
|
||||
podman logs --tail 5 "$c" 2>&1 | grep -i "permission denied" && echo " ^ UID mapping issue in: $c"
|
||||
done
|
||||
```
|
||||
|
||||
## Integration
|
||||
|
||||
- Run `/podman-doctor` first to identify issues (includes rootless health checks)
|
||||
- Run `/podman-fix` for specific container repairs (includes UID mapping fixes)
|
||||
- Run `/podman-uptime` to set up permanent reliability infrastructure
|
||||
- Add to ISO build: copy watchdog scripts to `image-recipe/configs/` and enable in first-boot
|
||||
@@ -1,156 +0,0 @@
|
||||
---
|
||||
name: polish-backend
|
||||
description: Fix Rust backend quality issues in Archipelago. Eliminates panics/unwraps, adds timeouts, implements connection pooling, fixes clippy warnings. Use when user says "polish backend", "fix unwraps", "backend quality", or "eliminate panics".
|
||||
---
|
||||
|
||||
# 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,181 +0,0 @@
|
||||
---
|
||||
name: polish-deploy
|
||||
description: Harden Archipelago deployment pipeline with rollback capability, pre-deploy checks, post-deploy health verification, and deployment locking. Use when user says "polish deploy", "harden deployment", "add rollback", or "deploy safety".
|
||||
---
|
||||
|
||||
# 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,87 +0,0 @@
|
||||
---
|
||||
name: polish-errors
|
||||
description: Fix silent error handling across Archipelago codebase. Replaces empty catch blocks, adds user-visible error feedback for all async operations. Use when user says "polish errors", "fix error handling", "silent catches", or "error feedback".
|
||||
---
|
||||
|
||||
# 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,125 +0,0 @@
|
||||
---
|
||||
name: polish-forms
|
||||
description: Improve form validation across Archipelago UI with real-time feedback, input sanitization, disabled states during submission, and consistent error messaging. Use when user says "polish forms", "form validation", "input validation", or "fix forms".
|
||||
---
|
||||
|
||||
# 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,88 +0,0 @@
|
||||
---
|
||||
name: polish-loading
|
||||
description: Add skeleton loaders, loading indicators, timeout warnings, and empty states to all Archipelago async views. Use when user says "polish loading", "add skeletons", "loading states", "empty states", or "blank screen fix".
|
||||
---
|
||||
|
||||
# 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,162 +0,0 @@
|
||||
---
|
||||
name: polish-security
|
||||
description: Security hardening for Archipelago systemd services, nginx headers, secrets management, and rate limiting. Use when user says "polish security", "harden services", "security headers", "rate limiting", or "secrets management".
|
||||
---
|
||||
|
||||
# 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,172 +0,0 @@
|
||||
---
|
||||
name: polish-websocket
|
||||
description: Improve Archipelago WebSocket reliability, reconnection UX, heartbeat monitoring, session timeout detection, and connection status indicators. Use when user says "polish websocket", "fix reconnection", "websocket reliability", or "connection status".
|
||||
---
|
||||
|
||||
# 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,109 +0,0 @@
|
||||
---
|
||||
name: polish
|
||||
description: Production polish orchestrator for Archipelago. Coordinates all polish sub-skills by reading plan.md and executing the current week's tasks. Use when user says "polish", "production polish", "overnight polish", or "run the polish plan".
|
||||
---
|
||||
|
||||
# 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,110 +0,0 @@
|
||||
---
|
||||
name: sweep
|
||||
description: Full automated quality sweep across Archipelago codebase. Checks TypeScript errors, silent catches, console.log, any types, backend unwraps, hardcoded creds, and server health. Use when user says "sweep", "quality check", "run sweep", or "check violations".
|
||||
---
|
||||
|
||||
# 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.
|
||||
287
.gitea/workflows/build-iso-dev.yml
Normal file
287
.gitea/workflows/build-iso-dev.yml
Normal file
@@ -0,0 +1,287 @@
|
||||
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
|
||||
run: |
|
||||
# Direct fetch + sync (actions/checkout token is broken on this Gitea)
|
||||
cd /home/archipelago/archy && git fetch origin main && git reset --hard origin/main
|
||||
echo "=== Source at commit: $(git log --oneline -1) ==="
|
||||
rsync -a --delete \
|
||||
--exclude '.git' --exclude 'node_modules' --exclude 'target' \
|
||||
--exclude 'image-recipe/build' --exclude 'image-recipe/results' \
|
||||
--exclude 'web/dist' \
|
||||
/home/archipelago/archy/ "$GITHUB_WORKSPACE/"
|
||||
cd "$GITHUB_WORKSPACE"
|
||||
echo "=== Workspace version: $(grep '^version' core/archipelago/Cargo.toml) ==="
|
||||
[ -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"
|
||||
|
||||
- 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
|
||||
@@ -11,37 +11,144 @@ jobs:
|
||||
timeout-minutes: 45
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v4
|
||||
with:
|
||||
fetch-depth: 1
|
||||
run: |
|
||||
# Direct clone using stored credentials (actions/checkout token is broken)
|
||||
cd /home/archipelago/archy && git fetch origin main && git reset --hard origin/main
|
||||
echo "=== Source at commit: $(git log --oneline -1) ==="
|
||||
echo "=== Syncing to workspace ==="
|
||||
rsync -a --delete --exclude='.git' --exclude='target/' --exclude='node_modules/' \
|
||||
/home/archipelago/archy/ "$GITHUB_WORKSPACE/" || cp -a /home/archipelago/archy/* "$GITHUB_WORKSPACE/"
|
||||
cd "$GITHUB_WORKSPACE"
|
||||
echo "=== Workspace version: $(grep '^version' core/archipelago/Cargo.toml) ==="
|
||||
echo "=== Key files ==="
|
||||
echo " first-boot: $([ -f scripts/first-boot-containers.sh ] && echo PRESENT || echo MISSING)"
|
||||
echo " Cargo.toml: $(grep '^version' core/archipelago/Cargo.toml)"
|
||||
echo " package.json: $(grep '\"version\"' neode-ui/package.json | head -1)"
|
||||
|
||||
- name: Build backend
|
||||
run: |
|
||||
source $HOME/.cargo/env 2>/dev/null || true
|
||||
cargo build --release --manifest-path core/Cargo.toml
|
||||
sudo rm -f /usr/local/bin/archipelago
|
||||
sudo cp core/target/release/archipelago /usr/local/bin/archipelago
|
||||
sudo systemctl restart archipelago 2>/dev/null || true
|
||||
|
||||
- name: Build frontend
|
||||
run: |
|
||||
echo "PWD: $(pwd)"
|
||||
ls -la neode-ui/package.json || echo "neode-ui/package.json NOT FOUND"
|
||||
cd neode-ui
|
||||
npm ci
|
||||
npm run build
|
||||
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
|
||||
sudo UNBUNDLED=1 DEV_SERVER=localhost BUILD_FROM_SOURCE=0 ./build-auto-installer-iso.sh
|
||||
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)
|
||||
sudo cp "$ISO" "/var/lib/archipelago/filebrowser/Builds/archipelago-unbundled-${DATE}.iso"
|
||||
sudo chown 1000:1000 "/var/lib/archipelago/filebrowser/Builds/archipelago-unbundled-${DATE}.iso"
|
||||
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
|
||||
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,
|
||||
)
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user