security+feat: v1.3.0 — pentest remediation, container reliability, UI overhaul

Security (33 pentest findings addressed):
- CRITICAL: backend binds 127.0.0.1, path traversal in tor.rs/dwn fixed
- HIGH: federation requires signatures, XSS login redirect, RBAC viewer restricted
- HIGH: tar slip prevention, S3 SSRF validation, backup ID validation
- MEDIUM: remember-me random secret, TOTP session rotation, password re-auth
- LOW: CSP unsafe-inline removed, CORS dev-only, onion/webhook validation

Container reliability:
- Memory limits on all 37 containers (OOM prevention)
- Exited vs stopped state distinction with health-aware status badges
- Crash recovery coordination (no more restart cascade)
- User-stopped tracking survives reboots
- Tiered boot recovery (databases → core → services → apps)

UI:
- Wallet TransactionsModal, health-aware app status badges
- Restart button on containers, exited/crashed red state
- Mesh view overhaul, glass button updates, BaseModal/ToggleSwitch
- Apps sticky header removed, dev faucet, mutable mock wallet

Infrastructure:
- LND REST port 8080 exposed over Tor (LND Connect fix)
- Nginx cookie_session fix, deploy script Tor config updated
- Dev environment: podman auto-start, boot mode simulation

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
Dorian 2026-03-19 12:44:31 +00:00
parent 28763c4f09
commit 84a56c80de
77 changed files with 2485 additions and 966 deletions

View File

@ -5,6 +5,7 @@
- [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)

View File

@ -0,0 +1,21 @@
---
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.

View File

@ -0,0 +1,78 @@
---
name: Repo Cleanup & Dev Environment Overhaul (2026-03-18)
description: Major session — repo cleanup to archy-archive, demo seeding, dev-start.sh rewrite, ThunderHub/Fedimint/ecash, Podman install, wallet mock endpoints
type: project
---
## What Was Done
### 1. Repo Cleanup
- Moved ~200 files (docs, scripts, loops, legacy Docker UIs, duplicate videos) to `~/Projects/archy-archive/` (outside repo)
- Kept: all active docs (BETA-PROGRESS, MASTER_PLAN, architecture, ADRs, api-reference, developer-guide, troubleshooting, operations-runbook), all source code, active scripts
- Three "user's call" docs kept: `multi-node-architecture.md`, `marketplace-protocol.md`, `app-developer-guide.md`
### 2. docker-compose.yml Switched from Regtest to Signet
- All Bitcoin/LND/Fedimint containers now use **signet** (not regtest)
- Ports updated: RPC 38332, P2P 38333
- Removed archived `bitcoin-ui` and `lnd-ui` nginx services (referenced deleted `docker/` dir)
- Added ThunderHub service (port 3010) to main compose
### 3. New Testnet Compose (`docker-compose.testnet.yml`)
- Standalone signet stack: bitcoind + LND + ThunderHub + Fedimint
- Config at `testnet/thunderhub-config.yaml`
- README at `testnet/README.md` with faucet links and commands
### 4. Mock Backend Enhancements (`neode-ui/mock-backend.js`)
- **Container socket auto-detection**: tries `DOCKER_HOST` → Podman TMPDIR socket → Docker socket → null (simulation). No more `/var/run/docker.sock` spam
- **8 static dev apps** (was 6): added ThunderHub (port 3010) and Fedimint (port 8175)
- **25+ new RPC endpoints**: lnd.getinfo, lnd.newaddress, lnd.createinvoice, lnd.payinvoice, lnd.sendcoins, lnd.listchannels, lnd.openchannel, lnd.closechannel, wallet.ecash-balance, wallet.ecash-send, wallet.ecash-receive, wallet.ecash-history, wallet.networking-profits, bitcoin.getinfo, system.stats, update.status, network.list-requests, dev.faucet, etc.
- **Fedimint version** synced to 0.10.0, port fixed from 8174 → 8175
- **5 realistic notifications** (was empty array)
- **Mock ThunderHub UI** at `/app/thunderhub/` — full HTML dashboard
### 5. Dev Scripts Fixed
- `neode-ui/start-dev.sh`: removed broken `start-docker-apps.sh` call, fixed EAGAIN via safe `while read` loop
- `neode-ui/stop-dev.sh`: removed broken `stop-docker-apps.sh` call
- `neode-ui/package.json`: removed stale `prebuild`, added `--raw` to concurrently (fixes EAGAIN pgrep spawn)
- `scripts/dev-start.sh`: complete rewrite with 8 options including boot mode and testnet stack
### 6. ThunderHub Added Everywhere
- Icon: `neode-ui/public/assets/img/app-icons/thunderhub.svg`
- Mock backend: portMappings, marketplaceMetadata, staticDevApps, marketplace.get()
- Marketplace.vue: getCuratedAppList(), recommended tier
- appLauncher.ts: PORT_TO_APP_ID `'3010': 'thunderhub'`
### 7. Podman Installed on Mac
- `podman 5.8.1` + `podman-compose 1.5.0` via Homebrew
- Machine initialized and running
### 8. Home Wallet Card
- Fixed `lnd.getinfo` response to include `balance_sats` and `channel_balance_sats`
- Fixed `lnd.gettransactions` to use `amount_sats` and include `incoming_pending_count`
- Added **Faucet button** (green) — calls `dev.faucet` RPC
- Grid changed from 3-col to 4-col (Send, Receive, Faucet, Web5)
### 9. Developer Onboarding Docs
- `neode-ui/README.md`: full rewrite
- `neode-ui/DEV-SCRIPTS.md`: updated with actual 8 static apps
## Current State / Resume Here
- **`npm start` works** — no Docker needed, all wallet actions mocked, 8 apps visible
- **Send/Receive modals** open from Home wallet card — if still issues, check browser console
- **Faucet button** calls dev.faucet and refreshes balances
- **Not yet tested**: `podman-compose -f docker-compose.testnet.yml up` (signet sync ~10 min)
- **Not yet committed** — all changes are local, uncommitted
- **Demo prod server** not redeployed — push changes then redeploy via Portainer
## Key Files Modified This Session
- `neode-ui/mock-backend.js` (major — container socket, 25+ RPC endpoints, ThunderHub mock UI)
- `neode-ui/src/views/Home.vue` (faucet button, 4-col grid)
- `neode-ui/src/views/Marketplace.vue` (ThunderHub entry)
- `neode-ui/src/stores/appLauncher.ts` (ThunderHub port)
- `neode-ui/start-dev.sh`, `neode-ui/stop-dev.sh`, `neode-ui/package.json`
- `scripts/dev-start.sh` (complete rewrite)
- `docker-compose.yml` (regtest→signet, ThunderHub, removed archived UIs)
- `docker-compose.testnet.yml` (new)
- `testnet/thunderhub-config.yaml`, `testnet/README.md` (new)
- `neode-ui/public/assets/img/app-icons/thunderhub.svg` (new)
- `neode-ui/README.md`, `neode-ui/DEV-SCRIPTS.md` (rewrites)

View File

@ -7,6 +7,87 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
## [Unreleased]
## [1.3.0] - 2026-03-19
### Security
#### Pentest Remediation (33 findings, all addressed)
- **Critical**: Backend now binds to 127.0.0.1 only — no more direct LAN access to port 5678
- **Critical**: Fixed path traversal in Tor service management that could allow `sudo rm -rf` on arbitrary directories
- **Critical**: Fixed unauthenticated file read/delete via DWN recordId path traversal
- **High**: Federation peers now require cryptographic signature — unsigned peers rejected
- **High**: Login redirect XSS vulnerability fixed with proper URL validation
- **High**: Viewer role restricted to read-only node methods (was granting sign/export access)
- **High**: Backup restore/verify now validates IDs against path traversal
- **High**: Tar archive extraction validates every entry path (prevents tar slip attacks)
- **High**: S3 backup endpoints require HTTPS and reject private IP ranges
- **Medium**: Remember-me token secret now uses cryptographic random (not machine-id)
- **Medium**: Destructive operations (factory reset, onboarding reset) now require password re-verification
- **Medium**: Session token rotated after TOTP verification (prevents interception reuse)
- **Medium**: Webhook URL validation hardened against IPv6 bypass, DNS rebinding, redirect chains
- **Low**: CORS localhost:8100 only included in dev mode
- **Low**: CSP `unsafe-inline` removed from `script-src`
- **Low**: Content filenames validated against path separators and hidden file prefixes
- **Low**: Nostr relay URLs restricted to `wss://` with private IP rejection
- **Low**: Onion address validation enforces v3 format (56 base32 chars)
- **Low**: Router detection restricted to private IP ranges only
#### Nginx Authentication
- Fixed session cookie name mismatch (`session_id``session`) across all nginx auth checks
- LND Connect info endpoint now properly authenticated
### Container Reliability
#### Memory Limits (prevents OOM crashes)
- All 37 containers in `first-boot-containers.sh` now have `--memory=` limits
- Automatic RAM tier detection — reduced limits on 8GB machines
- Prevents a single runaway container from crashing the entire system
#### Smart Container States
- New `exited` state distinguishes crashed containers from intentionally stopped ones
- Crashed containers show red "crashed" badge with restart button
- Health-aware status: "healthy" (green), "starting up" (yellow spinner), "unhealthy" (orange pulse)
- Restart button added next to Stop on running containers
#### Crash Recovery Improvements
- Boot recovery and health monitor now coordinate via shared flag (no more restart cascade)
- User-stopped containers tracked in `user-stopped.json` — survive reboots without auto-restart
- Boot recovery uses tiered ordering: databases → core → services → apps → UIs
- Health monitor waits for boot recovery to complete before starting checks
### UI Improvements
#### Home Dashboard
- Wallet card now matches Web5 wallet display
- New Transactions modal with full history (incoming/outgoing, amounts, confirmations)
- Transactions button in header — switches to "Incoming" badge when pending transactions exist
- Dev faucet button (dev mode only) with mutable wallet state
- Fixed system stats crash (`cpu_usage_percent` field name mismatch)
#### Apps & App Details
- Container restart button (icon) next to Stop on all running apps
- Exited/crashed containers show "Restart" instead of "Start" with red styling
- Removed broken sticky header from Apps page
- Health-aware status badges throughout
#### Mesh, Cloud, Settings & More
- Mesh view overhaul with improved layout
- Glass button styling updates across components
- New BaseModal and ToggleSwitch components
- Updated translations (English + Spanish)
- Spotlight search improvements
### Infrastructure
#### LND Connect
- Tor hidden service now exposes LND REST port (8080) for remote wallet connections
- Fixed in ISO build script, deploy script, and live servers
#### Dev Environment
- Mock backend has mutable wallet state (faucet/send/receive actually change balances)
- Testnet stack option auto-starts Podman machine on macOS
- Boot mode simulation for testing startup screens
## [1.2.0] - 2026-03-14
### Fixed

View File

@ -77,11 +77,14 @@ impl ApiHandler {
/// Allowed CORS origins derived from the config host IP.
fn allowed_origins(&self) -> Vec<String> {
vec![
let mut origins = vec![
format!("http://{}", self.config.host_ip),
format!("https://{}", self.config.host_ip),
"http://localhost:8100".to_string(), // Vite dev server
]
];
if self.config.dev_mode {
origins.push("http://localhost:8100".to_string()); // Vite dev server
}
origins
}
/// Validate the Origin header against allowed origins.

View File

@ -76,7 +76,21 @@ impl RpcHandler {
Ok(serde_json::json!(complete))
}
pub(super) async fn handle_auth_reset_onboarding(&self) -> Result<serde_json::Value> {
pub(super) async fn handle_auth_reset_onboarding(
&self,
params: Option<serde_json::Value>,
) -> Result<serde_json::Value> {
let params = params.ok_or_else(|| anyhow::anyhow!("Missing params"))?;
let password = params
.get("password")
.and_then(|v| v.as_str())
.ok_or_else(|| anyhow::anyhow!("Missing password — re-authentication required"))?;
let valid = self.auth_manager.verify_password(password).await?;
if !valid {
return Err(anyhow::anyhow!("Password Incorrect"));
}
self.auth_manager.reset_onboarding().await?;
Ok(serde_json::json!(true))
}

View File

@ -1,8 +1,61 @@
use super::RpcHandler;
use crate::backup::full;
use anyhow::{Context, Result};
use std::net::IpAddr;
use tracing::info;
/// Validate an S3 endpoint URL: require https, reject private/loopback IPs and localhost.
fn validate_s3_endpoint(endpoint: &str) -> Result<()> {
// Require HTTPS scheme
if !endpoint.starts_with("https://") {
anyhow::bail!("S3 endpoint must use https://");
}
// Extract host from URL (strip scheme, path, port)
let after_scheme = &endpoint["https://".len()..];
let host_port = after_scheme.split('/').next().unwrap_or("");
// Strip port if present (handle IPv6 bracket notation)
let host = if host_port.starts_with('[') {
// IPv6: [::1]:443
host_port.split(']').next().unwrap_or("").trim_start_matches('[')
} else {
host_port.split(':').next().unwrap_or("")
};
if host.is_empty() {
anyhow::bail!("S3 endpoint missing host");
}
// Reject localhost
if host == "localhost" || host.ends_with(".localhost") {
anyhow::bail!("S3 endpoint must not point to localhost");
}
// Parse as IP and reject private/reserved ranges
if let Ok(ip) = host.parse::<IpAddr>() {
let is_private = match ip {
IpAddr::V4(v4) => {
v4.is_loopback() // 127.0.0.0/8
|| v4.octets()[0] == 10 // 10.0.0.0/8
|| (v4.octets()[0] == 172 && (v4.octets()[1] & 0xf0) == 16) // 172.16.0.0/12
|| (v4.octets()[0] == 192 && v4.octets()[1] == 168) // 192.168.0.0/16
|| (v4.octets()[0] == 169 && v4.octets()[1] == 254) // 169.254.0.0/16
|| v4.is_unspecified() // 0.0.0.0
}
IpAddr::V6(v6) => {
v6.is_loopback() // ::1
|| (v6.segments()[0] & 0xfe00) == 0xfc00 // fc00::/7
|| v6.is_unspecified() // ::
}
};
if is_private {
anyhow::bail!("S3 endpoint must not point to a private or reserved IP address");
}
}
Ok(())
}
impl RpcHandler {
/// Create a full encrypted backup. Params: { passphrase, description? }
pub(super) async fn handle_backup_create(
@ -55,6 +108,11 @@ impl RpcHandler {
.as_str()
.ok_or_else(|| anyhow::anyhow!("Missing 'passphrase' parameter"))?;
// Validate backup ID to prevent path traversal
if id.is_empty() || id.len() > 128 || id.contains('/') || id.contains('\\') || id.contains("..") || id.contains('\0') {
anyhow::bail!("Invalid backup ID");
}
let result = full::verify_backup(&self.config.data_dir, id, passphrase).await?;
Ok(serde_json::json!({
@ -78,6 +136,11 @@ impl RpcHandler {
.as_str()
.ok_or_else(|| anyhow::anyhow!("Missing 'passphrase' parameter"))?;
// Validate backup ID to prevent path traversal
if id.is_empty() || id.len() > 128 || id.contains('/') || id.contains('\\') || id.contains("..") || id.contains('\0') {
anyhow::bail!("Invalid backup ID");
}
full::restore_full_backup(&self.config.data_dir, id, passphrase).await?;
Ok(serde_json::json!({ "restored": true, "id": id }))
@ -183,6 +246,9 @@ impl RpcHandler {
anyhow::bail!("Invalid backup ID");
}
// Validate endpoint to prevent SSRF against internal services
validate_s3_endpoint(endpoint)?;
let bak_path = full::backup_file_path(&self.config.data_dir, id);
if !bak_path.exists() {
anyhow::bail!("Backup not found: {}", id);
@ -255,6 +321,9 @@ impl RpcHandler {
anyhow::bail!("Invalid backup ID");
}
// Validate endpoint to prevent SSRF against internal services
validate_s3_endpoint(endpoint)?;
let key = format!("archipelago-backups/{}.tar.gz.enc", id);
let url = format!("{}/{}/{}", endpoint.trim_end_matches('/'), bucket, key);

View File

@ -4,6 +4,16 @@ use crate::network::dwn_store::DwnStore;
use anyhow::{Context, Result};
use tracing::debug;
/// Validate a v3 Tor onion address.
/// Must be exactly 62 chars: 56 base32 characters (a-z, 2-7) followed by ".onion".
fn is_valid_v3_onion(addr: &str) -> bool {
if addr.len() != 62 || !addr.ends_with(".onion") {
return false;
}
let prefix = &addr[..56];
prefix.chars().all(|c| c.is_ascii_lowercase() || ('2'..='7').contains(&c))
}
const FILE_CATALOG_PROTOCOL: &str = "https://archipelago.dev/protocols/file-catalog/v1";
impl RpcHandler {
@ -25,10 +35,16 @@ impl RpcHandler {
.get("filename")
.and_then(|v| v.as_str())
.ok_or_else(|| anyhow::anyhow!("Missing filename"))?;
// Prevent path traversal
if filename.contains("..") || filename.contains('\0') {
// Validate filename: prevent path traversal, hidden files, and excessive length
if filename.contains("..") || filename.contains('\0') || filename.contains('/') || filename.contains('\\') {
anyhow::bail!("Invalid filename: path traversal not allowed");
}
if filename.starts_with('.') {
anyhow::bail!("Invalid filename: hidden files not allowed");
}
if filename.is_empty() || filename.len() > 255 {
anyhow::bail!("Invalid filename: must be 1-255 characters");
}
let mime_type = params
.get("mime_type")
.and_then(|v| v.as_str())
@ -191,8 +207,9 @@ impl RpcHandler {
.and_then(|v| v.as_str())
.ok_or_else(|| anyhow::anyhow!("Missing content_id"))?;
if !onion.ends_with(".onion") || onion.len() < 10 {
return Err(anyhow::anyhow!("Invalid onion address"));
// Validate v3 onion address: 56 base32 chars + ".onion" = 62 chars total
if !is_valid_v3_onion(onion) {
return Err(anyhow::anyhow!("Invalid v3 onion address"));
}
let socks_proxy = reqwest::Proxy::all("socks5h://127.0.0.1:9050")
@ -252,9 +269,9 @@ impl RpcHandler {
.and_then(|v| v.as_str())
.ok_or_else(|| anyhow::anyhow!("Missing onion address"))?;
// Validate onion address format
if !onion.ends_with(".onion") || onion.len() < 10 {
return Err(anyhow::anyhow!("Invalid onion address"));
// Validate v3 onion address: 56 base32 chars + ".onion" = 62 chars total
if !is_valid_v3_onion(onion) {
return Err(anyhow::anyhow!("Invalid v3 onion address"));
}
// Connect via Tor SOCKS proxy to the peer's content catalog endpoint

View File

@ -371,7 +371,8 @@ impl RpcHandler {
}
}
None => {
tracing::warn!(peer_did = %did, "Peer-joined without signature — accepting but unverified");
tracing::warn!(peer_did = %did, "Rejected peer-joined: missing signature");
anyhow::bail!("Missing signature — all federation peers must be cryptographically verified");
}
}

View File

@ -442,7 +442,7 @@ impl RpcHandler {
"auth.changePassword" => self.handle_auth_change_password(params, &session_token).await,
"auth.onboardingComplete" => self.handle_auth_onboarding_complete().await,
"auth.isOnboardingComplete" => self.handle_auth_is_onboarding_complete().await,
"auth.resetOnboarding" => self.handle_auth_reset_onboarding().await,
"auth.resetOnboarding" => self.handle_auth_reset_onboarding(params).await,
// Container orchestration (for Archipelago-managed containers)
"container-install" => self.handle_container_install(params).await,
@ -789,7 +789,7 @@ impl RpcHandler {
self.metrics_store.record_rpc_latency(elapsed_ms).await;
// Build response (cache successful results for cacheable methods)
let rpc_resp = match result {
let mut rpc_resp = match result {
Ok(data) => {
if is_cacheable {
self.response_cache.set(rpc_req.method.clone(), data.clone()).await;
@ -893,8 +893,62 @@ impl RpcHandler {
}
}
// On successful TOTP verification, the session is already upgraded to full
// (handled inside handle_login_totp/handle_login_backup)
// On successful TOTP verification, set the rotated session cookie
if (rpc_req.method == "auth.login.totp" || rpc_req.method == "auth.login.backup")
&& rpc_resp.error.is_none()
{
// Extract token (clone to release immutable borrow before mutable borrow below)
let new_token_opt = rpc_resp
.result
.as_ref()
.and_then(|r| r.get("new_session_token"))
.and_then(|v| v.as_str())
.map(|s| s.to_string());
if let Some(new_token) = new_token_opt {
let csrf_token = derive_csrf_token(&new_token);
let remember_token = self.session_store.create_remember_token();
response.headers_mut().append(
"Set-Cookie",
format!(
"session={}; HttpOnly; SameSite=Strict; Path=/{}",
new_token,
self.cookie_suffix()
)
.parse()
.unwrap(),
);
response.headers_mut().append(
"Set-Cookie",
format!(
"csrf_token={}; SameSite=Strict; Path=/{}",
csrf_token,
self.cookie_suffix()
)
.parse()
.unwrap(),
);
response.headers_mut().append(
"Set-Cookie",
format!(
"remember={}; HttpOnly; SameSite=Strict; Path=/; Max-Age={}{}",
remember_token,
REMEMBER_TTL,
self.cookie_suffix()
)
.parse()
.unwrap(),
);
// Strip the token from the response body — don't leak it to JS
if let Some(result) = rpc_resp.result.as_mut() {
if let Some(obj) = result.as_object_mut() {
obj.remove("new_session_token");
}
}
let body_bytes = serde_json::to_vec(&rpc_resp).unwrap_or_default();
*response.body_mut() = hyper::Body::from(body_bytes);
}
}
// On password change, rotate the session token for the caller
if rpc_req.method == "auth.changePassword" && rpc_resp.error.is_none() {

View File

@ -686,6 +686,12 @@ printtoconsole=1\n", rpc_pass);
sorted
};
// Clear user-stopped flag — user explicitly started this app
crate::crash_recovery::clear_user_stopped(&self.config.data_dir, package_id).await;
for name in &to_start {
crate::crash_recovery::clear_user_stopped(&self.config.data_dir, name).await;
}
for name in to_start {
let _ = tokio::process::Command::new("podman")
.args(["start", &name])
@ -707,9 +713,13 @@ printtoconsole=1\n", rpc_pass);
.ok_or_else(|| anyhow::anyhow!("Missing package id"))?;
validate_app_id(package_id)?;
// Mark as user-stopped so health monitor and crash recovery don't auto-restart
crate::crash_recovery::mark_user_stopped(&self.config.data_dir, package_id).await;
let containers = get_containers_for_app(package_id).await?;
if containers.is_empty() {
let container_name = format!("archy-{}", package_id);
crate::crash_recovery::mark_user_stopped(&self.config.data_dir, &container_name).await;
let _ = tokio::process::Command::new("podman")
.args(["stop", &container_name])
.output()
@ -717,6 +727,9 @@ printtoconsole=1\n", rpc_pass);
return Ok(serde_json::Value::Null);
}
for name in &containers {
crate::crash_recovery::mark_user_stopped(&self.config.data_dir, name).await;
}
for name in containers {
let _ = tokio::process::Command::new("podman")
.args(["stop", &name])
@ -1025,6 +1038,7 @@ printtoconsole=1\n", rpc_pass);
fn create_installing_entry(package_id: &str) -> PackageDataEntry {
PackageDataEntry {
state: PackageState::Installing,
health: None,
static_files: StaticFiles {
license: String::new(),
instructions: String::new(),

View File

@ -609,6 +609,18 @@ impl RpcHandler {
anyhow::bail!("Factory reset requires {{ \"confirm\": true }}");
}
// Require password re-authentication for destructive operations
let password = params
.as_ref()
.and_then(|p| p.get("password"))
.and_then(|v| v.as_str())
.ok_or_else(|| anyhow::anyhow!("Missing password — re-authentication required"))?;
let valid = self.auth_manager.verify_password(password).await?;
if !valid {
return Err(anyhow::anyhow!("Password Incorrect"));
}
tracing::warn!("Factory reset initiated — wiping ALL user data and containers");
let data_dir = &self.config.data_dir;

View File

@ -118,6 +118,11 @@ impl RpcHandler {
.and_then(|v| v.as_str())
.ok_or_else(|| anyhow::anyhow!("Missing name"))?;
// Validate name to prevent path traversal
if name.is_empty() || name.len() > 64 || !name.chars().all(|c| c.is_alphanumeric() || c == '-' || c == '_') {
return Err(anyhow::anyhow!("Invalid service name (alphanumeric, hyphens, underscores only)"));
}
let onion = read_onion_address(name);
Ok(serde_json::json!({ "name": name, "onion_address": onion }))
}
@ -134,6 +139,11 @@ impl RpcHandler {
.and_then(|v| v.as_str())
.ok_or_else(|| anyhow::anyhow!("Missing name"))?;
// Validate name to prevent path traversal
if name.is_empty() || name.len() > 64 || !name.chars().all(|c| c.is_alphanumeric() || c == '-' || c == '_') {
return Err(anyhow::anyhow!("Invalid service name (alphanumeric, hyphens, underscores only)"));
}
let base = tor_data_dir();
let service_dir = format!("{}/hidden_service_{}", base, name);
@ -277,6 +287,12 @@ impl RpcHandler {
.get("app_id")
.and_then(|v| v.as_str())
.ok_or_else(|| anyhow::anyhow!("Missing app_id"))?;
// Validate app_id to prevent path traversal
if app_id.is_empty() || app_id.len() > 64 || !app_id.chars().all(|c| c.is_alphanumeric() || c == '-' || c == '_') {
return Err(anyhow::anyhow!("Invalid app_id (alphanumeric, hyphens, underscores only)"));
}
let enabled = params
.get("enabled")
.and_then(|v| v.as_bool())

View File

@ -192,10 +192,11 @@ impl RpcHandler {
let _ = self.auth_manager.update_totp(data).await;
}
// Upgrade pending session to full
self.session_store.upgrade_to_full(token).await;
// Upgrade pending session to full (rotates token)
let new_token = self.session_store.upgrade_to_full(token).await
.ok_or_else(|| anyhow::anyhow!("Session expired. Please log in again."))?;
Ok(serde_json::json!({ "success": true }))
Ok(serde_json::json!({ "success": true, "new_session_token": new_token }))
}
None => {
anyhow::bail!("Invalid code. Please try again.");
@ -241,13 +242,14 @@ impl RpcHandler {
totp_data.backup_codes.remove(idx);
self.auth_manager.update_totp(totp_data).await?;
// Upgrade pending session to full
self.session_store.upgrade_to_full(token).await;
// Upgrade pending session to full (rotates token)
let new_token = self.session_store.upgrade_to_full(token).await
.ok_or_else(|| anyhow::anyhow!("Session expired. Please log in again."))?;
tracing::info!("Login via backup code (codes remaining: {})",
self.auth_manager.get_totp_data().await?.map(|d| d.backup_codes.len()).unwrap_or(0));
Ok(serde_json::json!({ "success": true }))
Ok(serde_json::json!({ "success": true, "new_session_token": new_token }))
}
None => {
anyhow::bail!("Invalid backup code");

View File

@ -3,6 +3,90 @@ use crate::webhooks;
use anyhow::Result;
use tracing::info;
/// Check if a hostname/IP points to a private or internal address.
/// Handles: IPv4, IPv6 (including mapped IPv4 like ::ffff:127.0.0.1),
/// decimal/octal IP representations, and well-known internal hostnames.
fn is_webhook_host_private(host: &str) -> bool {
// Strip IPv6 brackets if present
let h = host.trim_start_matches('[').trim_end_matches(']');
// Check well-known internal hostnames
let lower = h.to_lowercase();
if lower == "localhost"
|| lower == "localhost.localdomain"
|| lower.ends_with(".local")
|| lower.ends_with(".internal")
|| lower == "metadata.google.internal"
|| lower == "169.254.169.254"
{
return true;
}
// Try to parse as IP address
if let Ok(ip) = h.parse::<std::net::IpAddr>() {
return match ip {
std::net::IpAddr::V4(v4) => {
v4.is_loopback()
|| v4.is_private()
|| v4.is_link_local()
|| v4.is_unspecified()
|| v4.octets()[0] == 100 && (64..=127).contains(&v4.octets()[1]) // CGNAT
}
std::net::IpAddr::V6(v6) => {
if v6.is_loopback() || v6.is_unspecified() {
return true;
}
// Check IPv4-mapped IPv6 (::ffff:x.x.x.x)
if let Some(v4) = v6.to_ipv4_mapped() {
return v4.is_loopback()
|| v4.is_private()
|| v4.is_link_local()
|| v4.is_unspecified();
}
// Unique local (fd00::/8, fc00::/7)
let segments = v6.segments();
(segments[0] & 0xfe00) == 0xfc00
|| (segments[0] & 0xffc0) == 0xfe80 // link-local
}
};
}
// Detect decimal IP notation (e.g., "2130706433" = 127.0.0.1)
if let Ok(decimal) = h.parse::<u32>() {
let octets = decimal.to_be_bytes();
let v4 = std::net::Ipv4Addr::new(octets[0], octets[1], octets[2], octets[3]);
return v4.is_loopback() || v4.is_private() || v4.is_link_local() || v4.is_unspecified();
}
// Detect octal IP notation (e.g., "0177.0.0.1" = 127.0.0.1)
if h.contains('.') {
let parts: Vec<&str> = h.split('.').collect();
if parts.len() == 4 {
let mut octets = [0u8; 4];
let mut all_ok = true;
for (i, part) in parts.iter().enumerate() {
let val = if part.starts_with("0x") || part.starts_with("0X") {
u64::from_str_radix(part.trim_start_matches("0x").trim_start_matches("0X"), 16).ok()
} else if part.starts_with('0') && part.len() > 1 {
u64::from_str_radix(part, 8).ok()
} else {
part.parse::<u64>().ok()
};
match val {
Some(v) if v <= 255 => octets[i] = v as u8,
_ => { all_ok = false; break; }
}
}
if all_ok {
let v4 = std::net::Ipv4Addr::new(octets[0], octets[1], octets[2], octets[3]);
return v4.is_loopback() || v4.is_private() || v4.is_link_local() || v4.is_unspecified();
}
}
}
false
}
impl RpcHandler {
/// webhook.get-config — Get current webhook configuration.
pub(super) async fn handle_webhook_get_config(&self) -> Result<serde_json::Value> {
@ -28,37 +112,35 @@ impl RpcHandler {
config.enabled = enabled;
}
if let Some(url) = params.get("url").and_then(|v| v.as_str()) {
// Validate webhook URL scheme and reject obviously dangerous targets
// Validate webhook URL scheme and reject dangerous targets
if !url.is_empty() {
if !url.starts_with("https://") && !url.starts_with("http://") {
anyhow::bail!("Webhook URL must use HTTP(S)");
}
if !self.config.dev_mode && !url.starts_with("https://") {
anyhow::bail!("Webhook URL must use HTTPS in production");
}
// Extract host portion and reject private/internal addresses
let host_part = url
.trim_start_matches("https://")
.trim_start_matches("http://")
.split('/')
.next()
.unwrap_or("")
.split(':')
.next()
.unwrap_or("");
let is_private = host_part == "localhost"
|| host_part == "127.0.0.1"
|| host_part == "::1"
|| host_part.starts_with("10.")
|| host_part.starts_with("172.")
|| host_part.starts_with("192.168.")
|| host_part.starts_with("169.254.");
if is_private && !self.config.dev_mode {
anyhow::bail!("Webhook URL must not point to private/local addresses");
}
if url.len() > 2048 {
anyhow::bail!("Webhook URL too long");
}
// Parse URL properly to handle edge cases (IPv6, userinfo, etc.)
let parsed = reqwest::Url::parse(url)
.map_err(|_| anyhow::anyhow!("Invalid webhook URL"))?;
// Require https:// in production
if !self.config.dev_mode && parsed.scheme() != "https" {
anyhow::bail!("Webhook URL must use HTTPS in production");
}
if parsed.scheme() != "https" && parsed.scheme() != "http" {
anyhow::bail!("Webhook URL must use HTTP(S)");
}
// Reject URLs with userinfo (user:pass@host) — can be used for credential smuggling
if parsed.username() != "" || parsed.password().is_some() {
anyhow::bail!("Webhook URL must not contain credentials");
}
// Extract and validate the host
let host = parsed.host_str().unwrap_or("");
if host.is_empty() {
anyhow::bail!("Webhook URL must have a valid host");
}
// Reject private/internal addresses (handle IPv4, IPv6, decimal/octal IPs, hostnames)
let is_private = is_webhook_host_private(host);
if is_private && !self.config.dev_mode {
anyhow::bail!("Webhook URL must not point to private/local addresses");
}
}
config.url = url.to_string();
}

View File

@ -39,7 +39,9 @@ impl UserRole {
|| method == "system.temperature"
|| method == "system.disk-status"
|| method == "system.detect-usb-devices"
|| method.starts_with("node.")
|| method == "node.did"
|| method == "node.tor-address"
|| method == "node.nostr-pubkey"
|| method.starts_with("federation.list")
|| method.starts_with("dwn.status")
|| method.starts_with("dwn.list")

View File

@ -435,10 +435,46 @@ fn create_tar_gz(data_dir: &Path) -> Result<Vec<u8>> {
fn extract_tar_gz(data_dir: &Path, tar_gz_data: &[u8]) -> Result<()> {
let gz = GzDecoder::new(tar_gz_data);
let mut archive = Archive::new(gz);
let canonical_base = data_dir
.canonicalize()
.context("Failed to canonicalize data_dir")?;
archive
.unpack(data_dir)
.context("Failed to extract backup archive")?;
for entry_result in archive.entries().context("Failed to read tar entries")? {
let mut entry = entry_result.context("Failed to read tar entry")?;
let entry_path = entry.path().context("Failed to get entry path")?.to_path_buf();
// Reject entries with path traversal components
for component in entry_path.components() {
if matches!(component, std::path::Component::ParentDir) {
anyhow::bail!(
"Tar entry contains path traversal: {}",
entry_path.display()
);
}
}
let target = data_dir.join(&entry_path);
// Verify the resolved path stays within data_dir
// For new files that don't exist yet, check the parent directory
let check_path = if target.exists() {
target.canonicalize()?
} else if let Some(parent) = target.parent() {
std::fs::create_dir_all(parent)?;
parent.canonicalize()?.join(target.file_name().unwrap_or_default())
} else {
target.clone()
};
if !check_path.starts_with(&canonical_base) {
anyhow::bail!(
"Tar entry escapes target directory: {}",
entry_path.display()
);
}
entry
.unpack(&target)
.with_context(|| format!("Failed to extract: {}", entry_path.display()))?;
}
debug!("Backup extracted to {:?}", data_dir);
Ok(())

View File

@ -217,7 +217,7 @@ mod tests {
fn test_default_config_values() {
let config = Config::default();
assert_eq!(config.data_dir, PathBuf::from("/var/lib/archipelago"));
assert_eq!(config.bind_host, "0.0.0.0");
assert_eq!(config.bind_host, "127.0.0.1");
assert_eq!(config.bind_port, 5678);
assert_eq!(config.log_level, "info");
assert_eq!(config.host_ip, "127.0.0.1");

View File

@ -145,6 +145,7 @@ impl DockerPackageScanner {
let package = PackageDataEntry {
state: package_state.clone(),
health: container.health.clone(),
static_files: StaticFiles {
license: "MIT".to_string(),
instructions: metadata.description.clone(),
@ -592,9 +593,8 @@ fn extract_lan_address(ports: &[String]) -> Option<String> {
fn convert_state(container_state: &ContainerState) -> (PackageState, ServiceStatus) {
match container_state {
ContainerState::Running => (PackageState::Running, ServiceStatus::Running),
ContainerState::Stopped | ContainerState::Exited => {
(PackageState::Stopped, ServiceStatus::Stopped)
}
ContainerState::Stopped => (PackageState::Stopped, ServiceStatus::Stopped),
ContainerState::Exited => (PackageState::Exited, ServiceStatus::Stopped),
ContainerState::Created => (PackageState::Stopped, ServiceStatus::Stopped),
ContainerState::Paused => (PackageState::Stopped, ServiceStatus::Stopped),
ContainerState::Unknown(_) => (PackageState::Stopped, ServiceStatus::Stopped),
@ -607,6 +607,7 @@ fn package_state_str(state: &PackageState) -> &str {
PackageState::Installed => "installed",
PackageState::Stopping => "stopping",
PackageState::Stopped => "stopped",
PackageState::Exited => "exited",
PackageState::Starting => "starting",
PackageState::Running => "running",
PackageState::Restarting => "restarting",

View File

@ -11,11 +11,64 @@
use anyhow::{Context, Result};
use serde::{Deserialize, Serialize};
use std::path::{Path, PathBuf};
use std::sync::atomic::{AtomicBool, Ordering};
use std::sync::Arc;
use tokio::fs;
use tracing::{info, warn};
const PID_FILE: &str = "archipelago.pid";
const CONTAINER_STATE_FILE: &str = "running-containers.json";
const USER_STOPPED_FILE: &str = "user-stopped.json";
/// Shared flag: true once boot recovery is complete. Health monitor should wait for this.
pub static RECOVERY_COMPLETE: AtomicBool = AtomicBool::new(false);
/// Mark boot recovery as complete. Call after crash recovery + start_stopped_containers finish.
pub fn mark_recovery_complete() {
RECOVERY_COMPLETE.store(true, Ordering::SeqCst);
info!("Boot recovery complete — health monitor may proceed");
}
/// Check if boot recovery is done.
pub fn is_recovery_complete() -> bool {
RECOVERY_COMPLETE.load(Ordering::SeqCst)
}
// ── User-stopped tracking ───────────────────────────────────────────────
// When a user explicitly stops a container via the UI, we record it here
// so crash recovery and health monitor don't auto-restart it.
/// Load the set of user-stopped containers from disk.
pub async fn load_user_stopped(data_dir: &Path) -> std::collections::HashSet<String> {
let path = data_dir.join(USER_STOPPED_FILE);
match fs::read_to_string(&path).await {
Ok(content) => serde_json::from_str(&content).unwrap_or_default(),
Err(_) => std::collections::HashSet::new(),
}
}
/// Save the set of user-stopped containers to disk.
pub async fn save_user_stopped(data_dir: &Path, stopped: &std::collections::HashSet<String>) {
let path = data_dir.join(USER_STOPPED_FILE);
if let Ok(json) = serde_json::to_string_pretty(stopped) {
let _ = fs::write(&path, json).await;
}
}
/// Mark a container as user-stopped (won't be auto-restarted).
pub async fn mark_user_stopped(data_dir: &Path, name: &str) {
let mut stopped = load_user_stopped(data_dir).await;
stopped.insert(name.to_string());
save_user_stopped(data_dir, &stopped).await;
}
/// Clear user-stopped flag (container was manually started by user).
pub async fn clear_user_stopped(data_dir: &Path, name: &str) {
let mut stopped = load_user_stopped(data_dir).await;
if stopped.remove(name) {
save_user_stopped(data_dir, &stopped).await;
}
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct RunningContainerRecord {
@ -241,7 +294,8 @@ fn is_process_running(pid: u32) -> bool {
/// Start all stopped containers that were previously installed.
/// Runs on every startup to ensure containers come back after clean reboots.
/// The crash recovery (PID-based) handles dirty shutdowns; this handles clean ones.
pub async fn start_stopped_containers() -> RecoveryReport {
/// Skips containers that the user intentionally stopped via the UI.
pub async fn start_stopped_containers(data_dir: &Path) -> RecoveryReport {
let output = match tokio::time::timeout(
std::time::Duration::from_secs(30),
tokio::process::Command::new("podman")
@ -257,7 +311,7 @@ pub async fn start_stopped_containers() -> RecoveryReport {
}
};
let names: Vec<String> = match output {
let all_names: Vec<String> = match output {
Ok(o) if o.status.success() => {
String::from_utf8_lossy(&o.stdout)
.lines()
@ -268,17 +322,52 @@ pub async fn start_stopped_containers() -> RecoveryReport {
_ => Vec::new(),
};
if all_names.is_empty() {
return RecoveryReport { total: 0, recovered: 0, failed: Vec::new() };
}
// Filter out user-stopped containers
let user_stopped = load_user_stopped(data_dir).await;
let names: Vec<String> = all_names.into_iter()
.filter(|n| {
if user_stopped.contains(n) {
info!("Skipping user-stopped container: {}", n);
false
} else {
true
}
})
.collect();
if names.is_empty() {
return RecoveryReport { total: 0, recovered: 0, failed: Vec::new() };
}
info!("Starting {} stopped containers after boot...", names.len());
let records: Vec<RunningContainerRecord> = names.iter()
// Sort by startup tier: databases first, then core, then dependent services, then apps
let mut records: Vec<RunningContainerRecord> = names.iter()
.map(|n| RunningContainerRecord { name: n.clone(), image: String::new() })
.collect();
records.sort_by_key(|r| container_boot_tier(&r.name));
info!("Starting {} stopped containers after boot (skipped {} user-stopped)...",
records.len(), user_stopped.len());
recover_containers(&records).await
}
/// Simple tier ordering for boot recovery (mirrors health_monitor tiers).
fn container_boot_tier(name: &str) -> u8 {
let id = name.strip_prefix("archy-").unwrap_or(name);
match id {
"btcpay-db" | "mempool-db" | "penpot-postgres" | "immich_postgres"
| "immich_redis" | "penpot-valkey" => 0,
"bitcoin-knots" | "bitcoin-core" | "bitcoin" => 1,
"lnd" | "electrumx" | "mempool-electrs" | "electrs" | "nbxplorer" => 2,
"mempool-web" | "bitcoin-ui" | "lnd-ui" | "electrs-ui"
| "penpot-frontend" | "penpot-exporter" => 4,
_ => 3,
}
}
/// Spawn a background task that periodically saves the container snapshot.
pub fn spawn_snapshot_task(data_dir: PathBuf) {
tokio::spawn(async move {

View File

@ -102,6 +102,7 @@ pub enum PackageState {
Installed,
Stopping,
Stopped,
Exited,
Starting,
Running,
Restarting,
@ -117,6 +118,9 @@ pub enum PackageState {
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
pub struct PackageDataEntry {
pub state: PackageState,
/// Container health: "healthy", "unhealthy", "starting", or null
#[serde(skip_serializing_if = "Option::is_none")]
pub health: Option<String>,
#[serde(rename = "static-files")]
pub static_files: StaticFiles,
pub manifest: Manifest,

View File

@ -350,8 +350,26 @@ async fn restart_container(name: &str) -> bool {
/// Spawn the health monitor background task.
pub fn spawn_health_monitor(state: Arc<StateManager>, data_dir: PathBuf) {
tokio::spawn(async move {
// Wait 2 minutes for containers to start up
tokio::time::sleep(std::time::Duration::from_secs(120)).await;
// Wait for boot recovery to complete before starting health checks.
// This prevents the health monitor from fighting with crash_recovery
// which is starting containers in tier order.
info!("Health monitor: waiting for boot recovery to complete...");
let wait_start = std::time::Instant::now();
loop {
if crate::crash_recovery::is_recovery_complete() {
break;
}
// Safety timeout: start anyway after 5 minutes even if recovery hangs
if wait_start.elapsed().as_secs() > 300 {
warn!("Health monitor: boot recovery did not complete within 5 minutes, starting anyway");
break;
}
tokio::time::sleep(std::time::Duration::from_secs(5)).await;
}
// Additional cooldown after recovery to let containers stabilize
info!("Health monitor: recovery done, waiting 60s for containers to stabilize...");
tokio::time::sleep(std::time::Duration::from_secs(60)).await;
info!("Health monitor: starting health checks");
let mut tracker = RestartTracker::new();
let mut mem_tracker = MemoryTracker::new();
@ -378,6 +396,9 @@ pub fn spawn_health_monitor(state: Arc<StateManager>, data_dir: PathBuf) {
continue;
}
// Load user-stopped list to skip intentionally stopped containers
let user_stopped = crate::crash_recovery::load_user_stopped(&data_dir).await;
// Sort containers by startup tier so databases restart before dependent services
let mut unhealthy: Vec<&ContainerHealth> = Vec::new();
let mut state_changed = false;
@ -392,6 +413,11 @@ pub fn spawn_health_monitor(state: Arc<StateManager>, data_dir: PathBuf) {
continue;
}
if container.state == "exited" || container.state == "stopped" {
// Skip user-stopped containers
if user_stopped.contains(&container.name) {
debug!("Skipping user-stopped container: {}", container.name);
continue;
}
unhealthy.push(container);
}
}

View File

@ -92,13 +92,17 @@ async fn main() -> Result<()> {
}
// Start any stopped containers (handles clean reboot)
let boot_report = crash_recovery::start_stopped_containers().await;
// Skips user-stopped containers, uses tier ordering
let boot_report = crash_recovery::start_stopped_containers(&data_dir).await;
if boot_report.total > 0 {
info!(
"🔄 Boot startup: {}/{} containers started (failed: {:?})",
boot_report.recovered, boot_report.total, boot_report.failed
);
}
// Signal to health monitor that boot recovery is done
crash_recovery::mark_recovery_complete();
});
}

View File

@ -121,8 +121,24 @@ impl DwnStore {
Ok(message)
}
/// Validate a record ID to prevent path traversal.
fn validate_record_id(record_id: &str) -> Result<()> {
if record_id.is_empty()
|| record_id.len() > 128
|| !record_id
.chars()
.all(|c| c.is_alphanumeric() || c == '-' || c == '_')
{
return Err(anyhow::anyhow!(
"Invalid record ID (alphanumeric, hyphens, underscores only)"
));
}
Ok(())
}
/// Read a message by record ID.
pub async fn read_message(&self, record_id: &str) -> Result<Option<DwnMessage>> {
Self::validate_record_id(record_id)?;
let path = self.messages_dir.join(format!("{}.json", record_id));
if !path.exists() {
return Ok(None);
@ -137,6 +153,7 @@ impl DwnStore {
/// Delete a message by record ID.
pub async fn delete_message(&self, record_id: &str) -> Result<bool> {
Self::validate_record_id(record_id)?;
let path = self.messages_dir.join(format!("{}.json", record_id));
if !path.exists() {
return Ok(false);

View File

@ -322,11 +322,37 @@ pub async fn save_router_config(data_dir: &Path, config: &RouterConfig) -> Resul
fs::write(&path, data).await.context("Writing router config")
}
/// Validate that an IP string is a private/LAN address (not public, not localhost).
fn is_valid_private_ip(ip_str: &str) -> bool {
let ip: std::net::IpAddr = match ip_str.parse() {
Ok(ip) => ip,
Err(_) => return false, // Reject hostnames
};
match ip {
std::net::IpAddr::V4(v4) => {
// Allow only RFC1918 private ranges, reject localhost and public
let octets = v4.octets();
let is_10 = octets[0] == 10;
let is_172_private = octets[0] == 172 && (16..=31).contains(&octets[1]);
let is_192_168 = octets[0] == 192 && octets[1] == 168;
is_10 || is_172_private || is_192_168
}
std::net::IpAddr::V6(_) => false, // Reject IPv6 for gateway detection
}
}
/// Detect router type by probing common endpoints on the gateway.
pub async fn detect_router_type(gateway_ip: &str) -> RouterType {
// Validate that gateway is a private IP — prevent SSRF to arbitrary hosts
if !is_valid_private_ip(gateway_ip) {
tracing::warn!(gateway = gateway_ip, "Rejected non-private gateway IP");
return RouterType::Unknown;
}
let client = reqwest::Client::builder()
.timeout(std::time::Duration::from_secs(5))
.danger_accept_invalid_certs(true)
.redirect(reqwest::redirect::Policy::none())
.build()
.unwrap_or_default();

View File

@ -158,17 +158,77 @@ pub async fn get_stats(data_dir: &Path) -> Result<RelayStats> {
})
}
/// Normalize a relay URL (ensure wss:// prefix).
/// Normalize and validate a relay URL.
/// Only allows wss:// scheme (not ws://) for security.
/// Rejects URLs pointing to private/internal IPs.
fn normalize_relay_url(url: &str) -> Result<String> {
let trimmed = url.trim();
if trimmed.is_empty() {
return Err(anyhow::anyhow!("Relay URL cannot be empty"));
}
if trimmed.starts_with("wss://") || trimmed.starts_with("ws://") {
Ok(trimmed.to_string())
} else {
Ok(format!("wss://{}", trimmed))
if trimmed.len() > 2048 {
return Err(anyhow::anyhow!("Relay URL too long"));
}
// Apply wss:// prefix if no scheme
let with_scheme = if trimmed.starts_with("wss://") || trimmed.starts_with("ws://") {
trimmed.to_string()
} else {
format!("wss://{}", trimmed)
};
// Only allow wss:// scheme for security
if !with_scheme.starts_with("wss://") {
return Err(anyhow::anyhow!("Relay URL must use wss:// scheme"));
}
// Extract host portion for SSRF validation
let host = with_scheme
.strip_prefix("wss://")
.unwrap_or("")
.split('/')
.next()
.unwrap_or("")
.split(':')
.next()
.unwrap_or("");
if host.is_empty() {
return Err(anyhow::anyhow!("Relay URL must have a valid host"));
}
// Reject private/internal addresses
if is_relay_host_private(host) {
return Err(anyhow::anyhow!("Relay URL must not point to private/local addresses"));
}
Ok(with_scheme)
}
/// Check if a relay host points to a private or internal address.
fn is_relay_host_private(host: &str) -> bool {
let lower = host.to_lowercase();
if lower == "localhost" || lower == "localhost.localdomain" || lower.ends_with(".local") {
return true;
}
if let Ok(ip) = host.parse::<std::net::IpAddr>() {
return match ip {
std::net::IpAddr::V4(v4) => {
v4.is_loopback() || v4.is_private() || v4.is_link_local() || v4.is_unspecified()
}
std::net::IpAddr::V6(v6) => {
if v6.is_loopback() || v6.is_unspecified() {
return true;
}
if let Some(v4) = v6.to_ipv4_mapped() {
return v4.is_loopback() || v4.is_private() || v4.is_link_local() || v4.is_unspecified();
}
let segments = v6.segments();
(segments[0] & 0xfe00) == 0xfc00 || (segments[0] & 0xffc0) == 0xfe80
}
};
}
false
}
#[cfg(test)]
@ -183,9 +243,9 @@ mod tests {
}
#[test]
fn test_normalize_relay_url_with_ws() {
let result = normalize_relay_url("ws://relay.example.com").unwrap();
assert_eq!(result, "ws://relay.example.com");
fn test_normalize_relay_url_rejects_ws() {
let result = normalize_relay_url("ws://relay.example.com");
assert!(result.is_err(), "ws:// scheme should be rejected — only wss:// is allowed");
}
#[test]
@ -209,6 +269,14 @@ mod tests {
assert!(result.is_err());
}
#[test]
fn test_normalize_relay_url_rejects_private_ips() {
assert!(normalize_relay_url("wss://127.0.0.1").is_err());
assert!(normalize_relay_url("wss://localhost").is_err());
assert!(normalize_relay_url("wss://192.168.1.1").is_err());
assert!(normalize_relay_url("wss://10.0.0.1").is_err());
}
#[test]
fn test_seed_defaults_has_expected_count() {
let store = seed_defaults();

View File

@ -1,4 +1,5 @@
use hmac::{Hmac, Mac};
use rand::RngCore;
use sha2::{Digest, Sha256};
use std::collections::HashMap;
use std::net::IpAddr;
@ -234,16 +235,31 @@ impl SessionStore {
None
}
/// Upgrade a pending session to a full session.
pub async fn upgrade_to_full(&self, token: &str) {
let hash = hash_token(token);
/// Upgrade a pending session to a full session with token rotation.
/// Deletes the old pending session and creates a new full session with a fresh token.
/// Returns the new plaintext token so the caller can set it as the new cookie.
pub async fn upgrade_to_full(&self, token: &str) -> Option<String> {
let old_hash = hash_token(token);
let mut sessions = self.sessions.write().await;
if let Some(session) = sessions.get_mut(&hash) {
session.session_type = SessionType::Full;
// Only upgrade if the old session exists and is pending
if sessions.remove(&old_hash).is_some() {
let new_token_bytes: [u8; 32] = rand::random();
let new_token = hex::encode(new_token_bytes);
let new_hash = hash_token(&new_token);
let now = SystemTime::now();
session.created_at = now;
session.last_activity = now;
self.evict_if_over_limit(&mut sessions);
sessions.insert(
new_hash,
Session {
created_at: now,
last_activity: now,
session_type: SessionType::Full,
},
);
Self::save_to_disk_sync(&sessions, &self.persist_path);
Some(new_token)
} else {
None
}
}
@ -393,25 +409,21 @@ impl SessionStore {
}
pub fn load_or_create_remember_secret() -> Vec<u8> {
// Try existing secret file first (backwards compatibility)
// Try existing secret file first
if let Ok(secret) = std::fs::read(REMEMBER_SECRET_FILE) {
if secret.len() == 32 {
return secret;
}
}
// Derive a deterministic secret from machine-id so it survives restarts
// without storing plaintext key material
let machine_id = std::fs::read_to_string("/etc/machine-id")
.unwrap_or_else(|_| uuid::Uuid::new_v4().to_string());
let salt = b"archipelago-remember-me-v1";
let mut hasher = sha2::Sha256::new();
use sha2::Digest;
hasher.update(machine_id.trim().as_bytes());
hasher.update(salt);
let secret = hasher.finalize();
let secret_vec = secret.to_vec();
let _ = std::fs::write(REMEMBER_SECRET_FILE, &secret_vec);
secret_vec
// Generate a cryptographically random 32-byte secret on first boot
let mut secret = [0u8; 32];
rand::rngs::OsRng.fill_bytes(&mut secret);
// Ensure parent directory exists
if let Some(parent) = std::path::Path::new(REMEMBER_SECRET_FILE).parent() {
let _ = std::fs::create_dir_all(parent);
}
let _ = std::fs::write(REMEMBER_SECRET_FILE, &secret);
secret.to_vec()
}
}
@ -605,9 +617,15 @@ mod tests {
let got = store.get_pending_secret(&token).await;
assert_eq!(got, Some(secret));
// Upgrade to full
store.upgrade_to_full(&token).await;
assert!(store.validate(&token).await);
// Upgrade to full — returns a new rotated token
let new_token = store.upgrade_to_full(&token).await;
assert!(new_token.is_some());
let new_token = new_token.unwrap();
// Old token should be invalid (rotated)
assert!(!store.validate(&token).await);
// New token should be valid
assert!(store.validate(&new_token).await);
}
#[tokio::test]

View File

@ -124,6 +124,7 @@ async fn send_http_webhook(
let client = reqwest::Client::builder()
.timeout(std::time::Duration::from_secs(10))
.redirect(reqwest::redirect::Policy::none())
.build()
.context("Failed to create HTTP client")?;

View File

@ -20,12 +20,26 @@ pub struct ContainerStatus {
pub id: String,
pub name: String,
pub state: ContainerState,
pub health: Option<String>, // "healthy", "unhealthy", "starting", or None if no healthcheck
pub started_at: Option<String>,
pub image: String,
pub created: String,
pub ports: Vec<String>,
pub lan_address: Option<String>, // Launch URL for UI access
}
/// Parse health status from podman's Status string (e.g., "Up 5 minutes (healthy)")
fn parse_health_from_status(status: &str) -> Option<String> {
if let Some(start) = status.rfind('(') {
if let Some(end) = status.rfind(')') {
if start < end {
return Some(status[start + 1..end].to_string());
}
}
}
None
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
pub enum ContainerState {
Created,
@ -301,6 +315,8 @@ impl PodmanClient {
id: parts[0].to_string(),
name: parts[1].to_string(),
state: ContainerState::from(parts[2]),
health: None,
started_at: None,
image: parts[3].to_string(),
created: parts[4].to_string(),
ports: vec![], // TODO: Parse ports from parts[5]
@ -387,10 +403,15 @@ impl PodmanClient {
};
let lan_address = Self::lan_address_for(&name);
let status_str = container["Status"].as_str().unwrap_or("");
let health = parse_health_from_status(status_str);
let started_at = container["StartedAt"].as_str().or_else(|| container["Started"].as_str()).map(|s| s.to_string());
result.push(ContainerStatus {
id: container["Id"].as_str().unwrap_or("").to_string(),
name,
state: ContainerState::from(container["State"].as_str().unwrap_or("unknown")),
health,
started_at,
image: container["Image"].as_str().unwrap_or("").to_string(),
created: container["Created"].as_str().unwrap_or("").to_string(),
ports,
@ -431,10 +452,15 @@ impl PodmanClient {
};
let lan_address = Self::lan_address_for(&name);
let status_str = container["Status"].as_str().unwrap_or("");
let health = parse_health_from_status(status_str);
let started_at = container["StartedAt"].as_str().or_else(|| container["Started"].as_str()).map(|s| s.to_string());
result.push(ContainerStatus {
id: container["Id"].as_str().unwrap_or("").to_string(),
name,
state: ContainerState::from(container["State"].as_str().unwrap_or("unknown")),
health,
started_at,
image: container["Image"].as_str().unwrap_or("").to_string(),
created: container["Created"].as_str().unwrap_or("").to_string(),
ports,

View File

@ -284,6 +284,8 @@ impl ContainerRuntime for DockerRuntime {
id: parts[0].to_string(),
name: parts[1].to_string(),
state: crate::podman_client::ContainerState::from(parts[2]),
health: None,
started_at: None,
image: parts[3].to_string(),
created: parts[4].to_string(),
ports: vec![],
@ -356,6 +358,8 @@ impl ContainerRuntime for DockerRuntime {
state: ContainerState::from(
container["State"].as_str().unwrap_or("unknown")
),
health: None,
started_at: None,
image: container["Image"].as_str().unwrap_or("").to_string(),
created: container["CreatedAt"].as_str().unwrap_or("").to_string(),
ports,

View File

@ -773,6 +773,7 @@ HiddenServicePort 50001 127.0.0.1:50001
HiddenServiceDir $TOR_DIR/hidden_service_lnd
HiddenServicePort 9735 127.0.0.1:9735
HiddenServicePort 8080 127.0.0.1:8080
HiddenServiceDir $TOR_DIR/hidden_service_btcpay
HiddenServicePort 23000 127.0.0.1:23000

View File

@ -17,7 +17,7 @@ server {
add_header Permissions-Policy "camera=(), microphone=(), geolocation=(), payment=()" always;
add_header Strict-Transport-Security "max-age=31536000; includeSubDomains" always;
add_header X-DNS-Prefetch-Control "off" always;
add_header Content-Security-Policy "default-src 'self'; script-src 'self' 'unsafe-inline'; style-src 'self' 'unsafe-inline'; img-src 'self' data: blob:; font-src 'self' data:; connect-src 'self' ws: wss: http://$host:* https:; frame-src 'self' http://$host:* https:; frame-ancestors 'self'; base-uri 'self'; form-action 'self';" always;
add_header Content-Security-Policy "default-src 'self'; script-src 'self'; style-src 'self' 'unsafe-inline'; img-src 'self' data: blob:; font-src 'self' data:; connect-src 'self' ws: wss: http://$host:* https:; frame-src 'self' http://$host:* https:; frame-ancestors 'self'; base-uri 'self'; form-action 'self';" always;
# AIUI SPA (Chat mode iframe)
# Use =404 fallback instead of index.html to prevent serving HTML with wrong
@ -37,7 +37,7 @@ server {
# AIUI Claude API proxy — requires valid session cookie
location /aiui/api/claude/ {
if ($cookie_session_id = "") {
if ($cookie_session = "") {
return 401 '{"error":"Unauthorized"}';
}
proxy_pass http://127.0.0.1:3142/;
@ -54,7 +54,7 @@ server {
# AIUI OpenRouter API proxy — requires valid session cookie
location /aiui/api/openrouter/ {
if ($cookie_session_id = "") {
if ($cookie_session = "") {
return 401 '{"error":"Unauthorized"}';
}
proxy_pass https://openrouter.ai/api/;
@ -69,7 +69,7 @@ server {
# AIUI Ollama (local AI) proxy — localhost:11434
location /aiui/api/ollama/ {
if ($cookie_session_id = "") {
if ($cookie_session = "") {
return 401 '{"error":"Unauthorized"}';
}
proxy_pass http://127.0.0.1:11434/;
@ -85,7 +85,7 @@ server {
# AIUI web search proxy — SearXNG on port 8888
location /aiui/api/web-search {
if ($cookie_session_id = "") {
if ($cookie_session = "") {
return 401 '{"error":"Unauthorized"}';
}
proxy_pass http://127.0.0.1:8888/search;
@ -154,7 +154,7 @@ server {
location /lnd-connect-info {
# Requires authenticated session — exposes LND admin macaroon
if ($cookie_session_id = "") { return 401; }
if ($cookie_session = "") { return 401; }
proxy_pass http://127.0.0.1:5678/lnd-connect-info;
proxy_http_version 1.1;
proxy_set_header Host $host;
@ -725,7 +725,7 @@ server {
add_header Permissions-Policy "camera=(), microphone=(), geolocation=(), payment=()" always;
add_header Strict-Transport-Security "max-age=31536000; includeSubDomains" always;
add_header X-DNS-Prefetch-Control "off" always;
add_header Content-Security-Policy "default-src 'self'; script-src 'self' 'unsafe-inline'; style-src 'self' 'unsafe-inline'; img-src 'self' data: blob:; font-src 'self' data:; connect-src 'self' ws: wss: http://$host:* https:; frame-src 'self' http://$host:* https:; frame-ancestors 'self'; base-uri 'self'; form-action 'self';" always;
add_header Content-Security-Policy "default-src 'self'; script-src 'self'; style-src 'self' 'unsafe-inline'; img-src 'self' data: blob:; font-src 'self' data:; connect-src 'self' ws: wss: http://$host:* https:; frame-src 'self' http://$host:* https:; frame-ancestors 'self'; base-uri 'self'; form-action 'self';" always;
# AIUI SPA (Chat mode iframe)
location /aiui/ {
@ -735,7 +735,7 @@ server {
add_header Cache-Control "no-cache, no-store, must-revalidate";
}
location /aiui/api/claude/ {
if ($cookie_session_id = "") {
if ($cookie_session = "") {
return 401 '{"error":"Unauthorized"}';
}
proxy_pass http://127.0.0.1:3142/;
@ -750,7 +750,7 @@ server {
proxy_send_timeout 120s;
}
location /aiui/api/ollama/ {
if ($cookie_session_id = "") {
if ($cookie_session = "") {
return 401 '{"error":"Unauthorized"}';
}
proxy_pass http://127.0.0.1:11434/;
@ -764,7 +764,7 @@ server {
# Connection header managed by nginx default
}
location /aiui/api/openrouter/ {
if ($cookie_session_id = "") {
if ($cookie_session = "") {
return 401 '{"error":"Unauthorized"}';
}
proxy_pass https://openrouter.ai/api/;
@ -808,7 +808,7 @@ server {
location /lnd-connect-info {
# Requires authenticated session — exposes LND admin macaroon
if ($cookie_session_id = "") { return 401; }
if ($cookie_session = "") { return 401; }
proxy_pass http://127.0.0.1:5678/lnd-connect-info;
proxy_http_version 1.1;
proxy_set_header Host $host;

View File

@ -82,7 +82,7 @@ define(['./workbox-21a80088'], (function (workbox) { 'use strict';
"revision": "3ca0b8505b4bec776b69afdba2768812"
}, {
"url": "index.html",
"revision": "0.g6vfn35hb3c"
"revision": "0.3ur9h1c6gak"
}], {});
workbox.cleanupOutdatedCaches();
workbox.registerRoute(new workbox.NavigationRoute(workbox.createHandlerBoundToURL("index.html"), {

View File

@ -343,6 +343,7 @@ async function getDockerContainers() {
version: '1.0.0',
status: isRunning ? 'running' : 'stopped',
state: isRunning ? 'running' : 'stopped',
health: isRunning ? 'healthy' : null,
'static-files': {
license: 'MIT',
instructions: metadata.description,

View File

@ -0,0 +1,3 @@
<svg width="1631" height="1624" viewBox="0 0 1631 1624" fill="none" xmlns="http://www.w3.org/2000/svg">
<path fill-rule="evenodd" clip-rule="evenodd" d="M914.932 359.228H916.229V715.252H1630.47V1088.98H1451.41V1267.98H1274.33V1445H1093.31V1624H715.534V1264.77H714.237V908.748H0V535.02H179.051V356.025H356.135V178.996H537.154V0H914.932V359.228ZM916.229 1425.33H1073.64V1248.31H1254.66V1071.28H1431.74V913.918H916.229V1425.33ZM556.83 375.695H375.811V552.723H198.727V710.082H714.237V198.666H556.83V375.695Z" fill="currentColor"/>
</svg>

After

Width:  |  Height:  |  Size: 534 B

View File

@ -141,15 +141,15 @@
</div>
</div>
<div v-if="paymentError" class="mb-3 p-2 bg-red-500/15 border border-red-500/20 rounded-lg">
<p class="text-red-400 text-xs">{{ paymentError }}</p>
<div v-if="paymentError" class="mb-3 alert-error">
<p class="text-xs">{{ paymentError }}</p>
</div>
<div class="flex gap-3">
<button @click="rejectPayment" class="flex-1 px-4 py-2.5 bg-white/5 border border-white/10 rounded-lg text-sm text-white/70 hover:bg-white/10 transition-colors">
Deny
</button>
<button @click="approvePayment" :disabled="paymentProcessing" class="flex-1 px-4 py-2.5 bg-orange-500/20 border border-orange-500/30 rounded-lg text-sm font-medium text-orange-300 hover:bg-orange-500/30 transition-colors disabled:opacity-50">
<button @click="approvePayment" :disabled="paymentProcessing" class="flex-1 px-4 py-2.5 glass-button glass-button-warning rounded-lg text-sm font-medium disabled:opacity-50">
{{ paymentProcessing ? 'Paying...' : 'Approve' }}
</button>
</div>

View File

@ -0,0 +1,89 @@
<template>
<Teleport to="body">
<Transition name="modal">
<div
v-if="show"
class="fixed inset-0 flex items-center justify-center p-4"
:class="zClass"
@click.self="close"
>
<div class="absolute inset-0 bg-black/60 backdrop-blur-md"></div>
<div
ref="modalRef"
class="glass-card p-6 w-full relative z-10"
:class="[maxWidth, contentClass]"
role="dialog"
aria-modal="true"
@click.stop
>
<div class="flex items-start justify-between gap-4 mb-4 shrink-0">
<h3 class="text-xl font-semibold text-white">{{ title }}</h3>
<button
@click="close"
class="p-2 rounded-lg hover:bg-white/10 text-white/70 hover:text-white transition-colors"
aria-label="Close"
>
<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M6 18L18 6M6 6l12 12" />
</svg>
</button>
</div>
<slot />
<slot name="footer" />
</div>
</div>
</Transition>
</Teleport>
</template>
<script setup lang="ts">
import { ref, computed } from 'vue'
import { useModalKeyboard } from '@/composables/useModalKeyboard'
const props = withDefaults(defineProps<{
show: boolean
title: string
maxWidth?: string
zIndex?: string
contentClass?: string
}>(), {
maxWidth: 'max-w-md',
zIndex: 'z-[3000]',
contentClass: '',
})
const emit = defineEmits<{
close: []
}>()
const modalRef = ref<HTMLElement | null>(null)
const zClass = computed(() => props.zIndex)
function close() {
emit('close')
}
useModalKeyboard(modalRef, computed(() => props.show), close)
</script>
<style scoped>
.modal-enter-active,
.modal-leave-active {
transition: opacity 0.2s ease;
}
.modal-enter-from,
.modal-leave-to {
opacity: 0;
}
.modal-enter-active .glass-card,
.modal-leave-active .glass-card {
transition: transform 0.2s ease;
}
.modal-enter-from .glass-card {
transform: scale(0.95);
}
.modal-leave-to .glass-card {
transform: scale(0.95);
}
</style>

View File

@ -12,7 +12,18 @@
:style="{ '--card-stagger': idx }"
>
<div class="flex items-start justify-between mb-4">
<div class="w-10 h-10 rounded-xl bg-white/10 flex items-center justify-center shrink-0">
<!-- App icons for goals with required apps, emoji fallback otherwise -->
<div v-if="goalAppIcons(goal).length > 0" class="flex items-center gap-1.5 shrink-0">
<img
v-for="icon in goalAppIcons(goal)"
:key="icon.appId"
:src="icon.url"
:alt="icon.appId"
class="w-8 h-8 rounded-lg object-contain bg-white/5 border border-white/10 p-0.5"
@error="($event.target as HTMLImageElement).style.display = 'none'"
/>
</div>
<div v-else class="w-10 h-10 rounded-xl bg-white/10 flex items-center justify-center shrink-0">
<span class="text-xl">{{ goalIcon(goal.icon) }}</span>
</div>
<span class="goal-status-badge" :class="statusBadgeClass(goal.id)">
@ -28,7 +39,7 @@
<div class="flex items-center justify-between">
<span class="text-xs text-white/40">{{ goal.estimatedTime }}</span>
<span class="text-xs text-white/50 flex items-center gap-1">
{{ goal.difficulty === 'beginner' ? 'Beginner' : 'Intermediate' }}
{{ goal.difficulty === 'beginner' ? t('easyHome.beginner') : t('easyHome.intermediate') }}
</span>
</div>
</RouterLink>
@ -37,18 +48,40 @@
<script setup lang="ts">
import { RouterLink } from 'vue-router'
import { useI18n } from 'vue-i18n'
import { GOALS } from '@/data/goals'
import { useGoalStore } from '@/stores/goals'
import type { GoalDefinition } from '@/types/goals'
defineProps<{
show: boolean
animate: boolean
}>()
const { t } = useI18n()
const goalStore = useGoalStore()
const goals = GOALS
const goalStatuses = goalStore.goalStatuses
/** Map appId to its icon file path under /assets/img/app-icons/ */
const APP_ICON_MAP: Record<string, string> = {
'bitcoin-knots': '/assets/img/app-icons/bitcoin-knots.webp',
lnd: '/assets/img/app-icons/lnd.svg',
'btcpay-server': '/assets/img/app-icons/btcpay-server.png',
immich: '/assets/img/app-icons/immich.png',
nextcloud: '/assets/img/app-icons/nextcloud.webp',
fedimint: '/assets/img/app-icons/fedimint.png',
mempool: '/assets/img/app-icons/mempool.webp',
electrs: '/assets/img/app-icons/electrs.svg',
'nostr-rs-relay': '/assets/img/app-icons/nostr-rs-relay.svg',
}
function goalAppIcons(goal: GoalDefinition): { appId: string; url: string }[] {
return goal.requiredApps
.filter((appId) => APP_ICON_MAP[appId] !== undefined)
.map((appId) => ({ appId, url: APP_ICON_MAP[appId] as string }))
}
function goalIcon(icon: string): string {
const icons: Record<string, string> = {
shop: '🏪',
@ -64,9 +97,9 @@ function goalIcon(icon: string): string {
function statusLabel(goalId: string): string {
const status = goalStatuses[goalId]
if (status === 'completed') return 'Done'
if (status === 'in-progress') return 'In Progress'
return 'Start'
if (status === 'completed') return t('easyHome.done')
if (status === 'in-progress') return t('easyHome.inProgress')
return t('easyHome.start')
}
function statusBadgeClass(goalId: string): string {

View File

@ -1,29 +1,5 @@
<template>
<Teleport to="body">
<Transition name="modal">
<div
v-if="show"
class="fixed inset-0 z-[3000] flex items-center justify-center p-4"
@click="$emit('close')"
>
<div class="absolute inset-0 bg-black/60 backdrop-blur-sm"></div>
<div
ref="modalRef"
@click.stop
class="glass-card p-6 max-w-lg w-full relative z-10 max-h-[80vh] overflow-y-auto"
>
<div class="flex items-start justify-between gap-4 mb-4">
<h3 class="text-xl font-semibold text-white">{{ title }}</h3>
<button
@click="$emit('close')"
class="p-2 rounded-lg hover:bg-white/10 text-white/70 hover:text-white transition-colors"
aria-label="Close"
>
<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M6 18L18 6M6 6l12 12" />
</svg>
</button>
</div>
<BaseModal :show="show" :title="title" max-width="max-w-lg" content-class="max-h-[80vh] overflow-y-auto" @close="$emit('close')">
<div class="text-white/80 prose prose-invert max-w-none">
<p class="whitespace-pre-wrap">{{ content }}</p>
</div>
@ -39,27 +15,20 @@
</svg>
</router-link>
</div>
</div>
</div>
</Transition>
</Teleport>
</BaseModal>
</template>
<script setup lang="ts">
import { ref, computed } from 'vue'
import { useModalKeyboard } from '@/composables/useModalKeyboard'
import BaseModal from '@/components/BaseModal.vue'
const props = defineProps<{
defineProps<{
show: boolean
title: string
content: string
relatedPath?: string
}>()
const emit = defineEmits<{
defineEmits<{
close: []
}>()
const modalRef = ref<HTMLElement | null>(null)
useModalKeyboard(modalRef, computed(() => props.show), () => emit('close'))
</script>

View File

@ -1,32 +1,9 @@
<template>
<Teleport to="body">
<Transition name="modal">
<div
v-if="showUpdatePrompt"
class="fixed inset-0 z-[9999] flex items-center justify-center p-4"
@click.self="dismissUpdate"
>
<div class="absolute inset-0 bg-black/60 backdrop-blur-sm"></div>
<div
ref="modalRef"
class="glass-card p-6 max-w-md w-full relative z-10"
@click.stop
>
<div class="flex items-start justify-between gap-4 mb-4">
<h3 class="text-xl font-semibold text-white">Update Available</h3>
<button
@click="dismissUpdate"
class="p-2 rounded-lg hover:bg-white/10 text-white/70 hover:text-white transition-colors"
aria-label="Dismiss"
>
<svg xmlns="http://www.w3.org/2000/svg" class="h-5 w-5" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M6 18L18 6M6 6l12 12" />
</svg>
</button>
</div>
<BaseModal :show="showUpdatePrompt" title="Update Available" z-index="z-[9999]" @close="dismissUpdate">
<p class="text-white/80 mb-6">
A new version of Archipelago is available. Update now to get the latest features and fixes.
</p>
<template #footer>
<div class="flex gap-3 justify-end">
<button
@click="dismissUpdate"
@ -41,19 +18,16 @@
Update Now
</button>
</div>
</div>
</div>
</Transition>
</Teleport>
</template>
</BaseModal>
</template>
<script setup lang="ts">
import { ref, onMounted } from 'vue'
import { useModalKeyboard } from '@/composables/useModalKeyboard'
import BaseModal from '@/components/BaseModal.vue'
const showUpdatePrompt = ref(false)
let updateCallback: (() => Promise<void>) | null = null
const modalRef = ref<HTMLElement | null>(null)
onMounted(() => {
// Listen for service worker updates
@ -106,8 +80,6 @@ onMounted(() => {
}
})
useModalKeyboard(modalRef, showUpdatePrompt, dismissUpdate)
function dismissUpdate() {
showUpdatePrompt.value = false
}

View File

@ -1,9 +1,5 @@
<template>
<Teleport to="body">
<div v-if="show" class="fixed inset-0 z-50 flex items-center justify-center bg-black/60 backdrop-blur-md" @click.self="close" @keydown.escape="close">
<div class="glass-card p-6 w-full max-w-2xl mx-4 max-h-[90vh] overflow-y-auto" role="dialog" aria-modal="true">
<h2 class="text-lg font-bold text-white mb-4">{{ t('web5.receiveBitcoinTitle') }}</h2>
<BaseModal :show="show" :title="t('web5.receiveBitcoinTitle')" max-width="max-w-2xl" content-class="max-h-[90vh] overflow-y-auto" @close="close">
<!-- Method tabs -->
<div class="flex gap-1 mb-4 p-1 bg-white/5 rounded-lg">
<button
@ -12,24 +8,24 @@
@click="receiveMethod = m"
class="flex-1 px-2 py-1.5 rounded text-xs font-medium capitalize transition-colors"
:class="receiveMethod === m ? 'bg-white/15 text-white' : 'text-white/50 hover:text-white/80'"
>{{ m === 'onchain' ? 'On-chain' : m }}</button>
>{{ m === 'onchain' ? t('receiveBitcoin.onChain') : m === 'lightning' ? t('receiveBitcoin.lightning') : t('receiveBitcoin.ecash') }}</button>
</div>
<!-- Lightning -->
<div v-if="receiveMethod === 'lightning'">
<div class="mb-3">
<label class="text-white/60 text-sm block mb-1">Amount (sats)</label>
<input v-model.number="invoiceAmount" type="number" min="1" placeholder="1000" class="w-full bg-white/5 border border-white/10 rounded-lg px-3 py-2 text-white text-sm focus:outline-none focus:border-white/30" />
<label class="text-white/60 text-sm block mb-1">{{ t('receiveBitcoin.amountSats') }}</label>
<input v-model.number="invoiceAmount" type="number" min="1" placeholder="1000" class="w-full input-glass" />
</div>
<div class="mb-3">
<label class="text-white/60 text-sm block mb-1">Memo (optional)</label>
<input v-model="invoiceMemo" type="text" placeholder="Payment for..." class="w-full bg-white/5 border border-white/10 rounded-lg px-3 py-2 text-white text-sm focus:outline-none focus:border-white/30" />
<label class="text-white/60 text-sm block mb-1">{{ t('receiveBitcoin.memoOptional') }}</label>
<input v-model="invoiceMemo" type="text" :placeholder="t('receiveBitcoin.memoPlaceholder')" class="w-full input-glass" />
</div>
<div v-if="invoiceResult" class="mb-3 p-3 bg-white/5 rounded-lg text-center">
<canvas ref="lightningQrCanvas" class="mx-auto mb-3 rounded-lg" style="image-rendering: pixelated;"></canvas>
<p class="text-white/50 text-xs mb-1">Invoice (share with sender):</p>
<p class="text-white/50 text-xs mb-1">{{ t('receiveBitcoin.invoiceShareLabel') }}</p>
<p class="text-xs font-mono text-white/80 break-all">{{ invoiceResult }}</p>
<button @click="copyText(invoiceResult)" class="mt-2 text-xs text-orange-400 hover:text-orange-300">Copy</button>
<button @click="copyText(invoiceResult)" class="mt-2 text-xs text-orange-400 hover:text-orange-300">{{ t('common.copy') }}</button>
</div>
</div>
@ -37,9 +33,9 @@
<div v-if="receiveMethod === 'onchain'">
<div v-if="onchainAddress" class="mb-3 p-3 bg-white/5 rounded-lg text-center">
<canvas ref="onchainQrCanvas" class="mx-auto mb-3 rounded-lg" style="image-rendering: pixelated;"></canvas>
<p class="text-white/50 text-xs mb-2">Your Bitcoin address:</p>
<p class="text-white/50 text-xs mb-2">{{ t('receiveBitcoin.yourBitcoinAddress') }}</p>
<p class="text-sm font-mono text-white/90 break-all">{{ onchainAddress }}</p>
<button @click="copyText(onchainAddress)" class="mt-2 text-xs text-orange-400 hover:text-orange-300">Copy</button>
<button @click="copyText(onchainAddress)" class="mt-2 text-xs text-orange-400 hover:text-orange-300">{{ t('common.copy') }}</button>
</div>
<div v-else class="mb-3 text-center">
<p class="text-white/50 text-sm mb-2">{{ t('web5.generateFreshAddress') }}</p>
@ -49,29 +45,28 @@
<!-- Ecash -->
<div v-if="receiveMethod === 'ecash'">
<div class="mb-3">
<label class="text-white/60 text-sm block mb-1">Paste ecash token</label>
<textarea v-model="ecashToken" rows="3" placeholder="cashuSend_..." class="w-full bg-white/5 border border-white/10 rounded-lg px-3 py-2 text-white text-sm focus:outline-none focus:border-white/30"></textarea>
<label class="text-white/60 text-sm block mb-1">{{ t('receiveBitcoin.pasteEcashToken') }}</label>
<textarea v-model="ecashToken" rows="3" placeholder="cashuSend_..." class="w-full input-glass font-mono"></textarea>
</div>
<div v-if="ecashResult" class="mb-3 text-xs text-green-400">{{ ecashResult }}</div>
</div>
<div v-if="error" class="mb-3 text-xs text-red-400">{{ error }}</div>
<div v-if="error" class="mb-3 alert-error">{{ error }}</div>
<div class="flex gap-3">
<button @click="close" class="flex-1 glass-button px-4 py-2 rounded-lg text-sm">{{ t('common.close') }}</button>
<button @click="receive" :disabled="processing" class="flex-1 glass-button px-4 py-2 rounded-lg text-sm font-medium bg-green-500/20 border-green-500/30 disabled:opacity-50">
{{ processing ? 'Processing...' : receiveMethod === 'onchain' ? 'Generate Address' : receiveMethod === 'lightning' ? 'Create Invoice' : 'Receive' }}
<button @click="receive" :disabled="processing" class="flex-1 glass-button glass-button-success px-4 py-2 rounded-lg text-sm font-medium disabled:opacity-50">
{{ processing ? t('receiveBitcoin.processing') : receiveMethod === 'onchain' ? t('receiveBitcoin.generateAddress') : receiveMethod === 'lightning' ? t('receiveBitcoin.createInvoice') : t('receiveBitcoin.receive') }}
</button>
</div>
</div>
</div>
</Teleport>
</BaseModal>
</template>
<script setup lang="ts">
import { ref, nextTick } from 'vue'
import { useI18n } from 'vue-i18n'
import { rpcClient } from '@/api/rpc-client'
import BaseModal from '@/components/BaseModal.vue'
const { t } = useI18n()
@ -120,7 +115,7 @@ async function receive() {
error.value = ''
try {
if (receiveMethod.value === 'lightning') {
if (!invoiceAmount.value) { error.value = 'Enter an amount'; return }
if (!invoiceAmount.value) { error.value = t('receiveBitcoin.enterAnAmount'); return }
const res = await rpcClient.call<{ payment_request: string }>({
method: 'lnd.addinvoice',
params: { amount_sats: invoiceAmount.value, memo: invoiceMemo.value || undefined },
@ -132,12 +127,12 @@ async function receive() {
onchainAddress.value = res.address
nextTick(() => renderQr(res.address, onchainQrCanvas.value, 'bitcoin:'))
} else {
if (!ecashToken.value.trim()) { error.value = 'Paste an ecash token'; return }
if (!ecashToken.value.trim()) { error.value = t('receiveBitcoin.pasteAnEcashToken'); return }
await rpcClient.call<{ amount_sats: number }>({
method: 'wallet.ecash-receive',
params: { token: ecashToken.value.trim() },
})
ecashResult.value = 'Token received successfully!'
ecashResult.value = t('receiveBitcoin.tokenReceivedSuccess')
emit('received')
}
} catch (err: unknown) {

View File

@ -1,9 +1,5 @@
<template>
<Teleport to="body">
<div v-if="show" class="fixed inset-0 z-50 flex items-center justify-center bg-black/60 backdrop-blur-md" @click.self="close" @keydown.escape="close">
<div class="glass-card p-6 w-full max-w-2xl mx-4 max-h-[90vh] overflow-y-auto" role="dialog" aria-modal="true">
<h2 class="text-lg font-bold text-white mb-4">{{ t('web5.sendBitcoinTitle') }}</h2>
<BaseModal :show="show" :title="t('web5.sendBitcoinTitle')" max-width="max-w-2xl" content-class="max-h-[90vh] overflow-y-auto" @close="close">
<!-- Method tabs -->
<div class="flex gap-1 mb-4 p-1 bg-white/5 rounded-lg">
<button
@ -12,55 +8,54 @@
@click="sendMethod = m"
class="flex-1 px-2 py-1.5 rounded text-xs font-medium capitalize transition-colors"
:class="sendMethod === m ? 'bg-white/15 text-white' : 'text-white/50 hover:text-white/80'"
>{{ m === 'onchain' ? 'On-chain' : m }}</button>
>{{ m === 'onchain' ? t('sendBitcoin.onChain') : m === 'lightning' ? t('sendBitcoin.lightning') : m === 'ecash' ? t('sendBitcoin.ecash') : t('sendBitcoin.auto') }}</button>
</div>
<div v-if="sendMethod === 'auto'" class="mb-3 p-2 bg-white/5 rounded-lg">
<p class="text-xs text-white/50">Auto-selects method based on amount: ecash &lt; 1k sats, Lightning 1k500k, on-chain &gt; 500k</p>
<p class="text-xs text-white/50">{{ t('sendBitcoin.autoMethodDesc') }}</p>
</div>
<div class="mb-3">
<label class="text-white/60 text-sm block mb-1">Amount (sats)</label>
<input v-model.number="amount" type="number" min="1" placeholder="1000" class="w-full bg-white/5 border border-white/10 rounded-lg px-3 py-2 text-white text-sm focus:outline-none focus:border-white/30" />
<label class="text-white/60 text-sm block mb-1">{{ t('sendBitcoin.amountSats') }}</label>
<input v-model.number="amount" type="number" min="1" placeholder="1000" class="w-full input-glass" />
</div>
<div v-if="effectiveMethod !== 'ecash'" class="mb-3">
<label class="text-white/60 text-sm block mb-1">
{{ effectiveMethod === 'lightning' ? 'Lightning Invoice (BOLT11)' : 'Bitcoin Address' }}
{{ effectiveMethod === 'lightning' ? t('sendBitcoin.lightningInvoice') : t('sendBitcoin.bitcoinAddress') }}
</label>
<textarea v-model="dest" rows="2" :placeholder="effectiveMethod === 'lightning' ? 'lnbc...' : 'bc1...'" class="w-full bg-white/5 border border-white/10 rounded-lg px-3 py-2 text-white text-sm font-mono focus:outline-none focus:border-white/30"></textarea>
<textarea v-model="dest" rows="2" :placeholder="effectiveMethod === 'lightning' ? 'lnbc...' : 'bc1...'" class="w-full input-glass font-mono"></textarea>
</div>
<div v-if="ecashToken && effectiveMethod === 'ecash'" class="mb-3 p-2 bg-white/5 rounded-lg">
<p class="text-white/50 text-xs mb-1">Token (share with recipient):</p>
<p class="text-white/50 text-xs mb-1">{{ t('sendBitcoin.tokenShareLabel') }}</p>
<p class="text-xs font-mono text-white/80 break-all">{{ ecashToken }}</p>
<button @click="copyText(ecashToken)" class="mt-2 text-xs text-orange-400 hover:text-orange-300">Copy</button>
<button @click="copyText(ecashToken)" class="mt-2 text-xs text-orange-400 hover:text-orange-300">{{ t('common.copy') }}</button>
</div>
<div v-if="resultTxid" class="mb-3 p-2 bg-green-500/10 border border-green-500/20 rounded-lg">
<p class="text-green-400 text-xs">Sent! TX: {{ resultTxid }}</p>
<div v-if="resultTxid" class="mb-3 alert-success">
<p class="text-xs">{{ t('sendBitcoin.sentTx', { txid: resultTxid }) }}</p>
</div>
<div v-if="resultHash" class="mb-3 p-2 bg-green-500/10 border border-green-500/20 rounded-lg">
<p class="text-green-400 text-xs">Paid! Hash: {{ resultHash }}</p>
<div v-if="resultHash" class="mb-3 alert-success">
<p class="text-xs">{{ t('sendBitcoin.paidHash', { hash: resultHash }) }}</p>
</div>
<div v-if="error" class="mb-3 text-xs text-red-400">{{ error }}</div>
<div v-if="error" class="mb-3 alert-error">{{ error }}</div>
<div class="flex gap-3">
<button @click="close" class="flex-1 glass-button px-4 py-2 rounded-lg text-sm">{{ t('common.close') }}</button>
<button @click="send" :disabled="processing || !amount" class="flex-1 glass-button px-4 py-2 rounded-lg text-sm font-medium bg-orange-500/20 border-orange-500/30 disabled:opacity-50">
{{ processing ? 'Sending...' : 'Send' }}
<button @click="send" :disabled="processing || !amount" class="flex-1 glass-button glass-button-warning px-4 py-2 rounded-lg text-sm font-medium disabled:opacity-50">
{{ processing ? t('common.sending') : t('common.send') }}
</button>
</div>
</div>
</div>
</Teleport>
</BaseModal>
</template>
<script setup lang="ts">
import { ref, computed } from 'vue'
import { useI18n } from 'vue-i18n'
import { rpcClient } from '@/api/rpc-client'
import BaseModal from '@/components/BaseModal.vue'
const { t } = useI18n()

View File

@ -0,0 +1,25 @@
<template>
<button
type="button"
role="switch"
:aria-checked="modelValue"
class="w-10 h-6 rounded-full shrink-0 transition-colors relative"
:class="modelValue ? 'bg-orange-500' : 'bg-white/15'"
@click="$emit('update:modelValue', !modelValue)"
>
<div
class="absolute top-1 w-4 h-4 rounded-full bg-white shadow transition-transform"
:class="modelValue ? 'translate-x-5' : 'translate-x-1'"
/>
</button>
</template>
<script setup lang="ts">
defineProps<{
modelValue: boolean
}>()
defineEmits<{
'update:modelValue': [value: boolean]
}>()
</script>

View File

@ -0,0 +1,116 @@
<template>
<BaseModal :show="show" :title="t('transactions.title')" max-width="max-w-2xl" content-class="max-h-[90vh] flex flex-col" @close="close">
<div v-if="transactions.length === 0" class="flex-1 flex items-center justify-center py-12">
<p class="text-white/40 text-sm">{{ t('transactions.noTransactionsYet') }}</p>
</div>
<div v-else class="flex-1 overflow-y-auto -mx-2 px-2 divide-y divide-white/5">
<div
v-for="tx in transactions"
:key="tx.tx_hash"
class="flex items-center justify-between gap-3 py-3 hover:bg-white/5 rounded-lg px-2 cursor-pointer transition-colors"
@click="openInMempool(tx.tx_hash)"
>
<div class="flex items-center gap-3 min-w-0 flex-1">
<div
class="w-8 h-8 rounded-full flex items-center justify-center shrink-0"
:class="tx.direction === 'incoming'
? (tx.num_confirmations === 0 ? 'bg-yellow-500/15' : 'bg-green-500/15')
: 'bg-red-500/10'"
>
<svg
class="w-4 h-4"
:class="tx.direction === 'incoming'
? (tx.num_confirmations === 0 ? 'text-yellow-400' : 'text-green-400')
: 'text-red-400'"
fill="none" stroke="currentColor" viewBox="0 0 24 24"
>
<path v-if="tx.direction === 'incoming'" stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M19 14l-7 7m0 0l-7-7m7 7V3" />
<path v-else stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M5 10l7-7m0 0l7 7m-7-7v18" />
</svg>
</div>
<div class="min-w-0 flex-1">
<div class="flex items-center gap-2">
<span
class="text-sm font-medium"
:class="tx.direction === 'incoming' ? 'text-green-400' : 'text-red-400'"
>
{{ tx.direction === 'incoming' ? '+' : '-' }}{{ Math.abs(tx.amount_sats).toLocaleString() }} sats
</span>
<span
class="text-[10px] px-1.5 py-0.5 rounded-full font-medium"
:class="tx.num_confirmations === 0
? 'bg-yellow-500/15 text-yellow-400'
: tx.num_confirmations < 3
? 'bg-green-500/15 text-green-400'
: 'bg-white/10 text-white/50'"
>
{{ tx.num_confirmations === 0 ? t('transactions.unconfirmed') : t('transactions.confirmations', { count: tx.num_confirmations }) }}
</span>
</div>
<div class="flex items-center gap-2 mt-0.5">
<p class="text-[11px] text-white/40 font-mono truncate">{{ tx.tx_hash }}</p>
<span v-if="tx.label" class="text-[10px] text-white/30 shrink-0">{{ tx.label }}</span>
</div>
</div>
</div>
<div class="flex items-center gap-2 shrink-0">
<span class="text-[11px] text-white/40">{{ formatTxTime(tx.time_stamp) }}</span>
<svg class="w-3.5 h-3.5 text-white/30" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M10 6H6a2 2 0 00-2 2v10a2 2 0 002 2h10a2 2 0 002-2v-4M14 4h6m0 0v6m0-6L10 14" />
</svg>
</div>
</div>
</div>
</BaseModal>
</template>
<script setup lang="ts">
import { useRouter } from 'vue-router'
import { useI18n } from 'vue-i18n'
import BaseModal from '@/components/BaseModal.vue'
interface WalletTransaction {
tx_hash: string
amount_sats: number
direction: 'incoming' | 'outgoing'
num_confirmations: number
time_stamp: number
total_fees: number
dest_addresses: string[]
label: string
block_height: number
}
defineProps<{
show: boolean
transactions: WalletTransaction[]
}>()
const emit = defineEmits<{ close: [] }>()
const { t } = useI18n()
const router = useRouter()
function close() {
emit('close')
}
function openInMempool(txHash: string) {
router.push({ name: 'app-session', params: { appId: 'mempool' }, query: { path: `/tx/${txHash}` } })
}
function formatTxTime(timestamp: number): string {
if (!timestamp) return ''
const date = new Date(timestamp * 1000)
const now = new Date()
const diffMs = now.getTime() - date.getTime()
const diffMins = Math.floor(diffMs / 60000)
if (diffMins < 1) return t('transactions.justNow')
if (diffMins < 60) return t('transactions.minutesAgo', { count: diffMins })
const diffHours = Math.floor(diffMins / 60)
if (diffHours < 24) return t('transactions.hoursAgo', { count: diffHours })
const diffDays = Math.floor(diffHours / 24)
if (diffDays < 7) return t('transactions.daysAgo', { count: diffDays })
return date.toLocaleDateString()
}
</script>

View File

@ -28,10 +28,7 @@
<p class="text-sm font-medium text-white/90">Share this {{ isDir ? 'folder' : 'file' }}</p>
<p class="text-xs text-white/50 mt-0.5">Make visible to connected peers</p>
</div>
<label class="share-toggle">
<input type="checkbox" v-model="shared" />
<span class="share-toggle-slider"></span>
</label>
<ToggleSwitch v-model="shared" />
</div>
<!-- Access Type (only when shared) -->
@ -126,6 +123,7 @@
<script setup lang="ts">
import { ref, onMounted } from 'vue'
import { rpcClient } from '@/api/rpc-client'
import ToggleSwitch from '@/components/ToggleSwitch.vue'
const props = defineProps<{
filename: string

View File

@ -703,5 +703,53 @@
"containers": "Containers",
"goToLogin": "Go to Login",
"lastChecked": "Last checked: {time}"
},
"receiveBitcoin": {
"onChain": "On-Chain",
"lightning": "Lightning",
"ecash": "Ecash",
"amountSats": "Amount (sats)",
"memoOptional": "Memo (optional)",
"memoPlaceholder": "Payment for...",
"invoiceShareLabel": "Invoice (share with sender):",
"yourBitcoinAddress": "Your Bitcoin address:",
"pasteEcashToken": "Paste ecash token",
"processing": "Processing...",
"generateAddress": "Generate Address",
"createInvoice": "Create Invoice",
"receive": "Receive",
"enterAnAmount": "Enter an amount",
"pasteAnEcashToken": "Paste an ecash token",
"tokenReceivedSuccess": "Token received successfully!"
},
"sendBitcoin": {
"onChain": "On-chain",
"auto": "Auto",
"lightning": "Lightning",
"ecash": "Ecash",
"autoMethodDesc": "Auto-selects method based on amount: ecash < 1k sats, Lightning 1k\u2013500k, on-chain > 500k",
"amountSats": "Amount (sats)",
"lightningInvoice": "Lightning Invoice (BOLT11)",
"bitcoinAddress": "Bitcoin Address",
"tokenShareLabel": "Token (share with recipient):",
"sentTx": "Sent! TX: {txid}",
"paidHash": "Paid! Hash: {hash}"
},
"transactions": {
"title": "Transactions",
"noTransactionsYet": "No transactions yet",
"unconfirmed": "Unconfirmed",
"confirmations": "{count} conf",
"justNow": "just now",
"minutesAgo": "{count}m ago",
"hoursAgo": "{count}h ago",
"daysAgo": "{count}d ago"
},
"easyHome": {
"beginner": "Beginner",
"intermediate": "Intermediate",
"done": "Done",
"inProgress": "In Progress",
"start": "Start"
}
}

View File

@ -702,5 +702,53 @@
"containers": "Contenedores",
"goToLogin": "Ir a inicio de sesi\u00f3n",
"lastChecked": "\u00daltima verificaci\u00f3n: {time}"
},
"receiveBitcoin": {
"onChain": "On-Chain",
"lightning": "Lightning",
"ecash": "Ecash",
"amountSats": "Monto (sats)",
"memoOptional": "Nota (opcional)",
"memoPlaceholder": "Pago por...",
"invoiceShareLabel": "Factura (compartir con el remitente):",
"yourBitcoinAddress": "Su direcci\u00f3n Bitcoin:",
"pasteEcashToken": "Pegar token Ecash",
"processing": "Procesando...",
"generateAddress": "Generar direcci\u00f3n",
"createInvoice": "Crear factura",
"receive": "Recibir",
"enterAnAmount": "Ingrese un monto",
"pasteAnEcashToken": "Pegue un token Ecash",
"tokenReceivedSuccess": "\u00a1Token recibido exitosamente!"
},
"sendBitcoin": {
"onChain": "On-chain",
"auto": "Auto",
"lightning": "Lightning",
"ecash": "Ecash",
"autoMethodDesc": "Selecci\u00f3n autom\u00e1tica seg\u00fan monto: ecash < 1k sats, Lightning 1k\u2013500k, on-chain > 500k",
"amountSats": "Monto (sats)",
"lightningInvoice": "Factura Lightning (BOLT11)",
"bitcoinAddress": "Direcci\u00f3n Bitcoin",
"tokenShareLabel": "Token (compartir con el destinatario):",
"sentTx": "\u00a1Enviado! TX: {txid}",
"paidHash": "\u00a1Pagado! Hash: {hash}"
},
"transactions": {
"title": "Transacciones",
"noTransactionsYet": "A\u00fan no hay transacciones",
"unconfirmed": "Sin confirmar",
"confirmations": "{count} conf",
"justNow": "justo ahora",
"minutesAgo": "hace {count}m",
"hoursAgo": "hace {count}h",
"daysAgo": "hace {count}d"
},
"easyHome": {
"beginner": "Principiante",
"intermediate": "Intermedio",
"done": "Listo",
"inProgress": "En progreso",
"start": "Iniciar"
}
}

View File

@ -218,7 +218,7 @@ async function checkSessionWithTimeout(store: ReturnType<typeof useAppStore>): P
* Navigation Guard
* Handles authentication and onboarding flow routing
*/
function isLocalRedirect(path: unknown): path is string {
export function isLocalRedirect(path: unknown): path is string {
if (typeof path !== 'string') return false
try {
if (path.startsWith('//') || path.includes('://')) return false

View File

@ -92,8 +92,9 @@ export const useAIPermissionsStore = defineStore('aiPermissions', () => {
try {
const stored = localStorage.getItem(STORAGE_KEY)
if (stored) {
const parsed = JSON.parse(stored) as AIContextCategory[]
return new Set(parsed.filter(c => AI_PERMISSION_CATEGORIES.some(cat => cat.id === c)))
const parsed: unknown = JSON.parse(stored)
if (!Array.isArray(parsed)) return new Set()
return new Set(parsed.filter((c: unknown) => typeof c === 'string' && AI_PERMISSION_CATEGORIES.some(cat => cat.id === c)) as AIContextCategory[])
}
} catch (e) {
if (import.meta.env.DEV) console.warn('Failed to load AI permissions from storage', e)

View File

@ -63,7 +63,10 @@ const APPROVED_ORIGINS_KEY = 'neode_nostr_approved_origins'
function getApprovedOrigins(): Set<string> {
try {
const stored = localStorage.getItem(APPROVED_ORIGINS_KEY)
return stored ? new Set(JSON.parse(stored) as string[]) : new Set()
if (!stored) return new Set()
const parsed: unknown = JSON.parse(stored)
if (!Array.isArray(parsed)) return new Set()
return new Set(parsed.filter((s: unknown) => typeof s === 'string'))
} catch {
return new Set()
}
@ -205,8 +208,11 @@ export const useAppLauncherStore = defineStore('appLauncher', () => {
try {
const stored = localStorage.getItem(appKey)
if (stored) {
const parsed = JSON.parse(stored) as { id?: string }
appIdentityId = parsed.id || null
const parsed: unknown = JSON.parse(stored)
if (typeof parsed === 'object' && parsed !== null && 'id' in parsed) {
const idVal = (parsed as Record<string, unknown>).id
appIdentityId = typeof idVal === 'string' ? idVal : null
}
}
} catch { /* ignore */ }

View File

@ -23,8 +23,17 @@ export const useSpotlightStore = defineStore('spotlight', () => {
try {
const raw = localStorage.getItem(RECENT_ITEMS_KEY)
if (raw) {
const parsed = JSON.parse(raw) as RecentItem[]
recentItems.value = parsed.slice(0, MAX_RECENT_ITEMS)
const parsed: unknown = JSON.parse(raw)
if (!Array.isArray(parsed)) {
recentItems.value = []
return
}
recentItems.value = parsed
.filter((item: unknown) =>
typeof item === 'object' && item !== null &&
'id' in item && 'label' in item && 'type' in item && 'timestamp' in item
)
.slice(0, MAX_RECENT_ITEMS) as RecentItem[]
} else {
recentItems.value = []
}

View File

@ -55,7 +55,7 @@
/* Mobile touch targets — ensure tappable elements meet 44px minimum */
@media (max-width: 767px) {
button:not(.mode-switcher-btn):not(.sidebar-nav-item):not([class*="w-9"]):not([class*="w-8"]):not([class*="w-7"]) {
button:not(.mode-switcher-btn):not(.sidebar-nav-item):not([class*="w-9"]):not([class*="w-8"]):not([class*="w-7"]):not([class*="w-10"]):not([class*="w-11"]):not([class*="w-12"]) {
min-height: 44px;
}
}
@ -81,7 +81,6 @@ button:active:not(:disabled),
[role="button"]:active,
a.glass-card:active,
a.goal-card:active,
.info-card-button:active,
.path-action-button:active {
transform: scale(0.97) !important;
transition: transform 0.1s ease !important;
@ -244,18 +243,21 @@ input[type="radio"]:active + * {
position: relative;
}
/* On mobile browsers, cap chat height to the dynamic viewport to prevent
content extending behind browser chrome (address bar / toolbar). */
@media (max-width: 767px) {
.chat-fullscreen {
max-height: 100vh;
max-height: 100dvh;
}
}
.chat-mode-pill {
display: none;
position: absolute;
top: 2.25rem;
right: 1.25rem;
z-index: 10;
}
@media (min-width: 768px) {
.chat-mode-pill {
display: flex;
}
}
.chat-iframe {
flex: 1;
@ -265,10 +267,15 @@ input[type="radio"]:active + * {
background: transparent;
}
/* On mobile, shrink iframe height so AIUI ends above the Archipelago tab bar */
/* On mobile, shrink iframe height so AIUI ends above the Archipelago tab bar.
Use dvh (dynamic viewport height) instead of 100% on a normal mobile browser,
100% resolves through the parent chain to the large viewport (100vh) which
is taller than the visible area when the browser chrome is showing. dvh
tracks the actual visible viewport. */
@media (max-width: 767px) {
.chat-iframe-mobile {
height: calc(100% - var(--mobile-tab-bar-height, 72px)) !important;
height: calc(100vh - var(--mobile-tab-bar-height, 72px)) !important;
height: calc(100dvh - var(--mobile-tab-bar-height, 72px)) !important;
flex: none;
}
}
@ -441,6 +448,112 @@ input[type="radio"]:active + * {
min-height: 36px;
}
/* Glass button color variants */
.glass-button-warning {
background: rgba(251, 146, 60, 0.2);
border: 1px solid rgba(251, 146, 60, 0.3);
color: #fdba74;
}
.glass-button-warning:hover {
background: rgba(251, 146, 60, 0.3);
}
.glass-button-danger {
background: rgba(239, 68, 68, 0.2);
border: 1px solid rgba(239, 68, 68, 0.3);
color: #fca5a5;
}
.glass-button-danger:hover {
background: rgba(239, 68, 68, 0.3);
}
.glass-button-success {
background: rgba(74, 222, 128, 0.2);
border: 1px solid rgba(74, 222, 128, 0.4);
color: #bbf7d0;
}
.glass-button-success:hover {
background: rgba(74, 222, 128, 0.3);
}
/* Status badges — inline colored pills */
.status-success {
background: rgba(74, 222, 128, 0.2);
color: #4ade80;
}
.status-error {
background: rgba(239, 68, 68, 0.2);
color: #f87171;
}
.status-warning {
background: rgba(251, 146, 60, 0.2);
color: #fb923c;
}
.status-info {
background: rgba(59, 130, 246, 0.2);
color: #60a5fa;
}
/* Alert banners — padded containers with border */
.alert-success {
padding: 0.5rem 0.75rem;
background: rgba(74, 222, 128, 0.1);
border: 1px solid rgba(74, 222, 128, 0.2);
border-radius: 0.5rem;
color: #bbf7d0;
font-size: 0.875rem;
}
.alert-error {
padding: 0.5rem 0.75rem;
background: rgba(239, 68, 68, 0.2);
border: 1px solid rgba(239, 68, 68, 0.3);
border-radius: 0.5rem;
color: #fca5a5;
font-size: 0.875rem;
}
.alert-warning {
padding: 0.5rem 0.75rem;
background: rgba(251, 146, 60, 0.1);
border: 1px solid rgba(251, 146, 60, 0.2);
border-radius: 0.5rem;
color: #fdba74;
font-size: 0.875rem;
}
.alert-info {
padding: 0.5rem 0.75rem;
background: rgba(59, 130, 246, 0.1);
border: 1px solid rgba(59, 130, 246, 0.2);
border-radius: 0.5rem;
color: #93c5fd;
font-size: 0.875rem;
}
/* Form input focus ring */
.input-glass {
background: rgba(255, 255, 255, 0.05);
border: 1px solid rgba(255, 255, 255, 0.1);
border-radius: 0.5rem;
padding: 0.5rem 0.75rem;
color: rgba(255, 255, 255, 0.9);
font-size: 0.875rem;
transition: border-color 0.2s ease;
}
.input-glass:focus {
outline: none;
border-color: #fb923c;
box-shadow: 0 0 0 1px #fb923c;
}
/* Toast - glassmorphic, top-right */
.toast-glass {
background-color: rgba(0, 0, 0, 0.65);
@ -593,22 +706,6 @@ input[type="radio"]:active + * {
z-index: 1;
}
/* Gradient border container for large content areas */
.gradient-border-container {
position: relative;
border-radius: 1.5rem;
padding: 4px;
background: linear-gradient(135deg, rgba(255, 255, 255, 0.4) 0%, rgba(255, 255, 255, 0.1) 50%, rgba(0, 0, 0, 0.6) 100%);
box-shadow: 0 12px 48px rgba(0, 0, 0, 0.6);
}
.gradient-border-container-inner {
background: rgba(0, 0, 0, 0.35);
backdrop-filter: blur(18px);
-webkit-backdrop-filter: blur(18px);
border-radius: 1.25rem;
}
/* Choose Your Path - Main Container */
.path-glass-container {
width: calc(100% - 48px);
@ -876,6 +973,28 @@ input[type="radio"]:active + * {
}
}
/* Modal transition (Vue <Transition name="modal">) — shared across all modal components */
.modal-enter-active,
.modal-leave-active {
transition: opacity 0.3s ease;
}
.modal-enter-active .glass-card,
.modal-leave-active .glass-card {
transition: transform 0.3s ease, opacity 0.3s ease;
}
.modal-enter-from,
.modal-leave-to {
opacity: 0;
}
.modal-enter-from .glass-card,
.modal-leave-to .glass-card {
transform: scale(0.95);
opacity: 0;
}
/* Background image */
body {
margin: 0;
@ -959,7 +1078,7 @@ iframe.iframe-scrollbar-hide {
@keyframes caretBlink {
0%, 100% {
border-right-color: #00ffff;
border-right-color: #fbbf24;
}
50% {
border-right-color: transparent;
@ -999,15 +1118,6 @@ iframe.iframe-scrollbar-hide {
}
}
@keyframes caretBlink {
0%, 100% {
border-right-color: #fbbf24;
}
50% {
border-right-color: transparent;
}
}
/* Splash screen styles */
.splash-complete .login-card {
animation: fadeUpIn 900ms cubic-bezier(0.22, 1, 0.36, 1) 120ms both;
@ -1065,9 +1175,9 @@ body::after {
animation-fill-mode: backwards;
}
/* Dashboard: full viewport width, no letterboxing */
/* Dashboard: full viewport width, no letterboxing, no body scroll */
body.dashboard-active {
overflow-x: hidden;
overflow: hidden;
width: 100%;
}
@ -1590,46 +1700,6 @@ html:has(body.video-background-active)::before {
border: 1px solid rgba(255, 255, 255, 0.08);
}
/* Toggle switch */
.share-toggle {
position: relative;
display: inline-flex;
width: 2.75rem;
height: 1.5rem;
flex-shrink: 0;
cursor: pointer;
}
.share-toggle input {
position: absolute;
opacity: 0;
width: 0;
height: 0;
}
.share-toggle-slider {
position: absolute;
inset: 0;
border-radius: 9999px;
background: rgba(255, 255, 255, 0.15);
transition: background-color 0.2s ease;
}
.share-toggle-slider::before {
content: '';
position: absolute;
width: 1.125rem;
height: 1.125rem;
left: 0.1875rem;
bottom: 0.1875rem;
border-radius: 9999px;
background: white;
transition: transform 0.2s ease;
}
.share-toggle input:checked + .share-toggle-slider {
background: #fb923c;
}
.share-toggle input:checked + .share-toggle-slider::before {
transform: translateX(1.25rem);
}
/* Access type options */
.share-access-options {
display: grid;
@ -1823,44 +1893,6 @@ html:has(body.video-background-active)::before {
.monitoring-bar-danger {
background: #ef4444;
}
.monitoring-alert-toggle {
position: relative;
display: inline-block;
width: 36px;
height: 20px;
flex-shrink: 0;
}
.monitoring-alert-toggle input {
opacity: 0;
width: 0;
height: 0;
}
.monitoring-alert-toggle-slider {
position: absolute;
cursor: pointer;
inset: 0;
background: rgba(255, 255, 255, 0.1);
border-radius: 10px;
transition: background 0.2s ease;
}
.monitoring-alert-toggle-slider::before {
content: '';
position: absolute;
height: 14px;
width: 14px;
left: 3px;
bottom: 3px;
background: rgba(255, 255, 255, 0.6);
border-radius: 50%;
transition: transform 0.2s ease;
}
.monitoring-alert-toggle input:checked + .monitoring-alert-toggle-slider {
background: rgba(74, 222, 128, 0.4);
}
.monitoring-alert-toggle input:checked + .monitoring-alert-toggle-slider::before {
transform: translateX(16px);
background: #4ade80;
}
.monitoring-threshold-input {
width: 60px;
padding: 4px 8px;
@ -1872,42 +1904,3 @@ html:has(body.video-background-active)::before {
text-align: right;
}
/* Toggle switch for Tor services and similar on/off controls */
.tor-toggle-label {
position: relative;
display: inline-block;
width: 36px;
height: 20px;
cursor: pointer;
}
.tor-toggle-input {
opacity: 0;
width: 0;
height: 0;
position: absolute;
}
.tor-toggle-slider {
position: absolute;
inset: 0;
background: rgba(255, 255, 255, 0.1);
border-radius: 10px;
transition: background 0.3s ease;
}
.tor-toggle-slider::before {
content: '';
position: absolute;
width: 16px;
height: 16px;
left: 2px;
top: 2px;
background: rgba(255, 255, 255, 0.6);
border-radius: 50%;
transition: transform 0.3s ease, background 0.3s ease;
}
.tor-toggle-input:checked + .tor-toggle-slider {
background: rgba(251, 146, 60, 0.4);
}
.tor-toggle-input:checked + .tor-toggle-slider::before {
transform: translateX(16px);
background: #fb923c;
}

View File

@ -79,6 +79,7 @@ export type PackageState = typeof PackageState[keyof typeof PackageState]
export interface PackageDataEntry {
state: PackageState
health?: string | null // "healthy", "unhealthy", "starting", or null
'static-files'?: {
license: string
instructions: string

View File

@ -41,10 +41,10 @@
<div class="flex items-center gap-2">
<span
class="inline-flex items-center px-2.5 py-1 rounded-lg text-xs font-medium"
:class="getStatusClass(pkg.state)"
:class="getStatusClass(pkg.state, pkg.health)"
>
<span class="w-1.5 h-1.5 rounded-full mr-1.5" :class="getStatusDotClass(pkg.state)"></span>
{{ pkg.state }}
<span class="w-1.5 h-1.5 rounded-full mr-1.5" :class="getStatusDotClass(pkg.state, pkg.health)"></span>
{{ getStatusLabel(pkg.state, pkg.health) }}
</span>
<span class="text-white/50 text-xs">v{{ pkg.manifest.version }}</span>
</div>
@ -74,14 +74,15 @@
</button>
<template v-if="!isWebOnly">
<button
v-if="pkg.state === 'stopped'"
v-if="pkg.state === 'stopped' || pkg.state === 'exited'"
@click="startApp"
class="px-4 py-2.5 bg-green-500/20 border border-green-500/40 rounded-lg text-green-200 text-sm font-medium hover:bg-green-500/30 transition-colors flex items-center gap-2"
class="px-4 py-2.5 glass-button rounded-lg text-sm font-medium transition-colors flex items-center gap-2"
:class="pkg.state === 'exited' ? 'glass-button-danger' : 'glass-button-success'"
>
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M14.752 11.168l-3.197-2.132A1 1 0 0010 9.87v4.263a1 1 0 001.555.832l3.197-2.132a1 1 0 000-1.664z" />
</svg>
{{ t('common.start') }}
{{ pkg.state === 'exited' ? 'Restart' : t('common.start') }}
</button>
<button
@click="restartApp"
@ -105,7 +106,7 @@
</button>
<button
@click="uninstallApp"
class="px-4 py-2.5 bg-red-600/20 border border-red-600/40 rounded-lg text-red-300 text-sm font-medium hover:bg-red-600/30 transition-colors flex items-center gap-2"
class="px-4 py-2.5 glass-button glass-button-danger rounded-lg text-sm font-medium transition-colors flex items-center gap-2"
>
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M19 7l-.867 12.142A2 2 0 0116.138 21H7.862a2 2 0 01-1.995-1.858L5 7m5 4v6m4-6v6m1-10V4a1 1 0 00-1-1h-4a1 1 0 00-1 1v3M4 7h16" />
@ -135,10 +136,10 @@
<div class="flex flex-wrap items-center gap-2">
<span
class="inline-flex items-center px-2 py-0.5 rounded text-xs font-medium"
:class="getStatusClass(pkg.state)"
:class="getStatusClass(pkg.state, pkg.health)"
>
<span class="w-1.5 h-1.5 rounded-full mr-1" :class="getStatusDotClass(pkg.state)"></span>
{{ pkg.state }}
<span class="w-1.5 h-1.5 rounded-full mr-1" :class="getStatusDotClass(pkg.state, pkg.health)"></span>
{{ getStatusLabel(pkg.state, pkg.health) }}
</span>
<span class="text-white/50 text-xs">v{{ pkg.manifest.version }}</span>
</div>
@ -148,7 +149,7 @@
<button
v-if="!isWebOnly"
@click="uninstallApp"
class="flex-shrink-0 w-10 h-10 rounded-lg bg-red-600/20 border border-red-600/40 text-red-300 hover:bg-red-600/30 transition-colors flex items-center justify-center"
class="flex-shrink-0 w-10 h-10 rounded-lg glass-button glass-button-danger transition-colors flex items-center justify-center"
:title="t('common.uninstall')"
>
<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
@ -172,14 +173,15 @@
</button>
<template v-if="!isWebOnly">
<button
v-if="pkg.state === 'stopped'"
v-if="pkg.state === 'stopped' || pkg.state === 'exited'"
@click="startApp"
class="px-4 py-2.5 bg-green-500/20 border border-green-500/40 rounded-lg text-green-200 text-sm font-medium hover:bg-green-500/30 transition-colors flex items-center justify-center gap-2"
class="px-4 py-2.5 glass-button rounded-lg text-sm font-medium transition-colors flex items-center justify-center gap-2"
:class="pkg.state === 'exited' ? 'glass-button-danger' : 'glass-button-success'"
>
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M14.752 11.168l-3.197-2.132A1 1 0 0010 9.87v4.263a1 1 0 001.555.832l3.197-2.132a1 1 0 000-1.664z" />
</svg>
{{ t('common.start') }}
{{ pkg.state === 'exited' ? 'Restart' : t('common.start') }}
</button>
<button
v-if="pkg.state === 'running'"
@ -194,7 +196,7 @@
</button>
<button
@click="restartApp"
:class="[canLaunch && (pkg.state === 'stopped' || pkg.state === 'running') ? 'col-span-2' : '']"
:class="[canLaunch && (pkg.state === 'stopped' || pkg.state === 'exited' || pkg.state === 'running') ? 'col-span-2' : '']"
class="px-4 py-2.5 glass-button rounded-lg text-sm font-medium hover:bg-white/15 transition-colors flex items-center justify-center gap-2"
>
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
@ -447,7 +449,7 @@
</button>
<button
@click="confirmUninstall"
class="w-full md:w-auto px-6 py-3 bg-red-600/80 hover:bg-red-600 rounded-lg text-white text-sm font-medium transition-colors"
class="w-full md:w-auto px-6 py-3 glass-button glass-button-danger rounded-lg text-sm font-medium"
>
{{ t('common.uninstall') }}
</button>
@ -460,7 +462,7 @@
<!-- Action error toast -->
<Transition name="fade">
<div v-if="actionError" class="fixed bottom-20 left-1/2 -translate-x-1/2 z-50 max-w-md w-full px-4" role="alert" aria-live="assertive">
<div class="bg-red-500/20 border border-red-500/40 backdrop-blur-sm rounded-lg px-4 py-3 text-red-200 text-sm flex items-center justify-between gap-3">
<div class="alert-error backdrop-blur-sm rounded-lg px-4 py-3 text-sm flex items-center justify-between gap-3">
<span>{{ actionError }}</span>
<button @click="actionError = ''" :aria-label="t('apps.dismissError')" class="text-red-300 hover:text-white shrink-0">&times;</button>
</div>
@ -898,12 +900,16 @@ async function uninstallApp() {
showUninstallModal()
}
function getStatusClass(state: PackageState): string {
function getStatusClass(state: PackageState, health?: string | null): string {
if (state === PackageState.Running && health === 'starting') return 'bg-yellow-500/20 text-yellow-200 border border-yellow-500/30'
if (state === PackageState.Running && health === 'unhealthy') return 'bg-orange-500/20 text-orange-200 border border-orange-500/30'
switch (state) {
case PackageState.Running:
return 'bg-green-500/20 text-green-200 border border-green-500/30'
case PackageState.Stopped:
return 'bg-gray-500/20 text-gray-200 border border-gray-500/30'
case PackageState.Exited:
return 'bg-red-500/20 text-red-200 border border-red-500/30'
case PackageState.Starting:
case PackageState.Stopping:
case PackageState.Restarting:
@ -915,12 +921,16 @@ function getStatusClass(state: PackageState): string {
}
}
function getStatusDotClass(state: PackageState): string {
function getStatusDotClass(state: PackageState, health?: string | null): string {
if (state === PackageState.Running && health === 'starting') return 'bg-yellow-400 animate-pulse'
if (state === PackageState.Running && health === 'unhealthy') return 'bg-orange-400 animate-pulse'
switch (state) {
case PackageState.Running:
return 'bg-green-400'
case PackageState.Stopped:
return 'bg-gray-400'
case PackageState.Exited:
return 'bg-red-400 animate-pulse'
case PackageState.Starting:
case PackageState.Stopping:
case PackageState.Restarting:
@ -931,6 +941,14 @@ function getStatusDotClass(state: PackageState): string {
return 'bg-gray-400'
}
}
function getStatusLabel(state: PackageState, health?: string | null): string {
if (state === PackageState.Running && health === 'starting') return 'starting up'
if (state === PackageState.Running && health === 'unhealthy') return 'unhealthy'
if (state === PackageState.Running && health === 'healthy') return 'healthy'
if (state === PackageState.Exited) return 'crashed'
return state
}
</script>
<style scoped>

View File

@ -555,7 +555,7 @@ function handleBackdropClick() {
function closeSession() {
if (document.fullscreenElement) document.exitFullscreen().catch(() => {})
if (isInlinePanel.value) emit('close')
else router.push({ name: 'apps' })
else router.back()
}
function onKeyDown(e: KeyboardEvent) {

View File

@ -40,7 +40,7 @@
</div>
<!-- Loading Skeleton -->
<div v-if="!store.isConnected && sortedPackageEntries.length === 0" class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4 pb-6">
<div v-if="!store.isConnected && sortedPackageEntries.length === 0 && !connectionError" class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4 pb-6">
<div v-for="i in 3" :key="i" class="glass-card p-6 animate-pulse">
<div class="flex items-start gap-4">
<div class="w-16 h-16 rounded-lg bg-white/10"></div>
@ -56,6 +56,19 @@
</div>
</div>
<!-- Connection Error -->
<div v-else-if="connectionError && sortedPackageEntries.length === 0" class="text-center py-12 pb-6">
<div class="glass-card p-8 max-w-md mx-auto">
<div class="alert-error mb-4">{{ connectionError }}</div>
<button
@click="connectionError = ''; store.connectWebSocket()"
class="glass-button px-6 py-3 rounded-lg font-medium"
>
Retry Connection
</button>
</div>
</div>
<!-- Empty State -->
<div v-else-if="sortedPackageEntries.length === 0 && !searchQuery" class="text-center py-16 pb-6">
<div class="glass-card p-12 max-w-md mx-auto">
@ -136,10 +149,10 @@
<div class="flex items-center gap-2">
<span
class="inline-flex items-center gap-1.5 px-2 py-1 rounded text-xs font-medium"
:class="getStatusClass(pkg.state)"
:class="getStatusClass(pkg.state, pkg.health)"
>
<svg
v-if="pkg.state === 'starting' || pkg.state === 'installing' || pkg.state === 'stopping' || pkg.state === 'restarting'"
v-if="pkg.state === 'starting' || pkg.state === 'installing' || pkg.state === 'stopping' || pkg.state === 'restarting' || (pkg.state === 'running' && pkg.health === 'starting')"
class="animate-spin h-3 w-3"
xmlns="http://www.w3.org/2000/svg"
fill="none"
@ -148,7 +161,8 @@
<circle class="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" stroke-width="4"></circle>
<path class="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"></path>
</svg>
{{ pkg.state }}
<span v-if="pkg.state === 'running' && pkg.health === 'unhealthy'" class="w-1.5 h-1.5 rounded-full bg-orange-400 animate-pulse"></span>
{{ getStatusLabel(pkg.state, pkg.health) }}
</span>
<span class="text-xs text-white/50">
v{{ pkg.manifest.version }}
@ -171,14 +185,14 @@
<button
v-if="!isWebOnlyApp(id as string) && !loadingActions[id as string] && (pkg.state === 'stopped' || pkg.state === 'exited')"
@click.stop="startApp(id as string)"
class="flex-1 px-4 py-2 bg-green-500/20 border border-green-500/40 rounded-lg text-green-200 text-sm font-medium hover:bg-green-500/30 transition-colors flex items-center justify-center gap-2"
class="flex-1 px-4 py-2 glass-button glass-button-success rounded-lg text-sm font-medium flex items-center justify-center gap-2"
>
<span>{{ t('common.start') }}</span>
<span>{{ pkg.state === 'exited' ? 'Restart' : t('common.start') }}</span>
</button>
<button
v-if="!isWebOnlyApp(id as string) && loadingActions[id as string] && (pkg.state === 'stopped' || pkg.state === 'exited' || pkg.state === 'starting')"
disabled
class="flex-1 px-4 py-2 bg-green-500/20 border border-green-500/40 rounded-lg text-green-200 text-sm font-medium opacity-50 cursor-not-allowed flex items-center justify-center gap-2"
class="flex-1 px-4 py-2 glass-button glass-button-success rounded-lg text-sm font-medium opacity-50 cursor-not-allowed flex items-center justify-center gap-2"
>
<svg class="animate-spin h-4 w-4" aria-hidden="true" xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24">
<circle class="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" stroke-width="4"></circle>
@ -193,6 +207,16 @@
>
<span>{{ t('common.stop') }}</span>
</button>
<button
v-if="!isWebOnlyApp(id as string) && !loadingActions[id as string] && (pkg.state === 'running' || pkg.state === 'starting')"
@click.stop="restartApp(id as string)"
class="px-2.5 py-2 glass-button glass-button-sm rounded-lg flex items-center justify-center"
:title="t('common.restart')"
>
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M4 4v5h.582m15.356 2A8.001 8.001 0 004.582 9m0 0H9m11 11v-5h-.581m0 0a8.003 8.003 0 01-15.357-2m15.357 2H15" />
</svg>
</button>
<button
v-if="!isWebOnlyApp(id as string) && loadingActions[id as string] && (pkg.state === 'running' || pkg.state === 'starting' || pkg.state === 'stopping')"
disabled
@ -249,7 +273,7 @@
<button
@click="confirmUninstall"
:disabled="uninstalling"
class="px-4 py-2 bg-red-600/80 hover:bg-red-600 rounded-lg text-white text-sm font-medium transition-colors disabled:opacity-50 disabled:cursor-not-allowed flex items-center justify-center gap-2"
class="px-4 py-2 glass-button glass-button-danger rounded-lg text-sm font-medium disabled:opacity-50 disabled:cursor-not-allowed flex items-center justify-center gap-2"
>
<svg
v-if="uninstalling"
@ -274,7 +298,7 @@
<!-- Action error toast -->
<Transition name="fade">
<div v-if="actionError" class="fixed bottom-20 left-1/2 -translate-x-1/2 z-50 max-w-md w-full px-4" role="alert" aria-live="assertive">
<div class="bg-red-500/20 border border-red-500/40 backdrop-blur-sm rounded-lg px-4 py-3 text-red-200 text-sm flex items-center justify-between gap-3">
<div class="alert-error backdrop-blur-sm rounded-lg px-4 py-3 text-sm flex items-center justify-between gap-3">
<span>{{ actionError }}</span>
<button @click="actionError = ''" :aria-label="t('apps.dismissError')" class="text-red-300 hover:text-white shrink-0">&times;</button>
</div>
@ -284,7 +308,7 @@
</template>
<script setup lang="ts">
import { computed, ref, onBeforeUnmount } from 'vue'
import { computed, ref, onMounted, onBeforeUnmount } from 'vue'
import { useRouter, useRoute, RouterLink } from 'vue-router'
import { useI18n } from 'vue-i18n'
import { useAppStore } from '../stores/app'
@ -377,6 +401,20 @@ const categoriesWithApps = computed(() => {
})
// Connection error state show after timeout if backend never connects
const connectionError = ref('')
let connectionTimer: ReturnType<typeof setTimeout> | undefined
onMounted(() => {
if (!store.isConnected) {
connectionTimer = setTimeout(() => {
if (!store.isConnected && sortedPackageEntries.value.length === 0) {
connectionError.value = 'Unable to connect to server. Check that the backend is running.'
}
}, 15000)
}
})
// Track loading states for each app action
const loadingActions = ref<Record<string, boolean>>({})
@ -523,12 +561,16 @@ function launchApp(id: string) {
useAppLauncherStore().openSession(id)
}
function getStatusClass(state: PackageState): string {
function getStatusClass(state: PackageState, health?: string | null): string {
if (state === PackageState.Running && health === 'starting') return 'bg-yellow-500/20 text-yellow-200'
if (state === PackageState.Running && health === 'unhealthy') return 'bg-orange-500/20 text-orange-200'
switch (state) {
case PackageState.Running:
return 'bg-green-500/20 text-green-200'
case PackageState.Stopped:
return 'bg-gray-500/20 text-gray-200'
case PackageState.Exited:
return 'bg-red-500/20 text-red-200'
case PackageState.Starting:
case PackageState.Stopping:
case PackageState.Restarting:
@ -540,6 +582,14 @@ function getStatusClass(state: PackageState): string {
}
}
function getStatusLabel(state: PackageState, health?: string | null): string {
if (state === PackageState.Running && health === 'starting') return 'starting up'
if (state === PackageState.Running && health === 'unhealthy') return 'unhealthy'
if (state === PackageState.Running && health === 'healthy') return 'healthy'
if (state === PackageState.Exited) return 'crashed'
return state
}
function goToApp(id: string) {
router.push(`/dashboard/apps/${id}`).catch(() => {})
}
@ -578,9 +628,26 @@ async function stopApp(id: string) {
}
}
async function restartApp(id: string) {
loadingActions.value[id] = true
try {
await store.restartPackage(id)
if (actionTimers.has(id)) clearTimeout(actionTimers.get(id)!)
actionTimers.set(id, setTimeout(() => {
loadingActions.value[id] = false
actionTimers.delete(id)
}, 8000))
} catch (err) {
if (import.meta.env.DEV) console.error('Failed to restart app:', err)
showActionError(`Failed to restart app: ${err instanceof Error ? err.message : 'Unknown error'}`)
loadingActions.value[id] = false
}
}
onBeforeUnmount(() => {
for (const t of actionTimers.values()) clearTimeout(t)
actionTimers.clear()
if (connectionTimer) clearTimeout(connectionTimer)
})
@ -638,25 +705,3 @@ function handleImageError(e: Event) {
</script>
<style scoped>
.modal-enter-active,
.modal-leave-active {
transition: opacity 0.3s ease;
}
.modal-enter-active .glass-card,
.modal-leave-active .glass-card {
transition: transform 0.3s ease, opacity 0.3s ease;
}
.modal-enter-from,
.modal-leave-to {
opacity: 0;
}
.modal-enter-from .glass-card,
.modal-leave-to .glass-card {
transform: scale(0.95);
opacity: 0;
}
</style>

View File

@ -1,7 +1,7 @@
<template>
<div class="chat-fullscreen">
<!-- Close button (desktop: top-right pill) -->
<div class="chat-mode-pill flex">
<div class="chat-mode-pill hidden md:flex">
<button class="chat-close-btn" :aria-label="t('chat.closeAssistant')" @click="closeChat">
<svg class="w-4 h-4" aria-hidden="true" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M6 18L18 6M6 6l12 12" />

View File

@ -114,6 +114,11 @@
</div>
</div>
<!-- Error State -->
<div v-if="loadError" class="alert-error mb-4">
{{ loadError }}
</div>
<!-- Not Installed Hint -->
<div v-if="!fileBrowserRunning" class="glass-card p-8 mt-6 text-center">
<p class="text-white/60 mb-3">Install File Browser from the App Store to get started with your cloud storage.</p>
@ -146,6 +151,7 @@ interface PeerNode {
const peerNodes = ref<PeerNode[]>([])
const peersLoading = ref(true)
const loadError = ref('')
const APP_ALIASES: Record<string, string[]> = {
immich: ['immich_server', 'immich-server'],
@ -244,7 +250,8 @@ async function loadCounts() {
}
}
} catch (e) {
if (import.meta.env.DEV) console.warn('FileBrowser count loading failed silently', e)
loadError.value = e instanceof Error ? e.message : 'Failed to load file counts'
if (import.meta.env.DEV) console.warn('FileBrowser count loading failed', e)
} finally {
countsLoading.value = false
}
@ -260,8 +267,9 @@ async function loadPeers() {
try {
const result = await rpcClient.federationListNodes()
peerNodes.value = result?.nodes ?? []
} catch {
} catch (e) {
peerNodes.value = []
loadError.value = e instanceof Error ? e.message : 'Failed to load peer nodes'
} finally {
peersLoading.value = false
}

View File

@ -69,6 +69,12 @@
</RouterLink>
</div>
<!-- Cloud Store Error -->
<div v-else-if="cloudStore.error" class="glass-card p-6 flex-1 flex flex-col items-center justify-center text-center">
<div class="alert-error mb-4">{{ cloudStore.error }}</div>
<button class="glass-button px-4 py-2 rounded-lg text-sm" @click="cloudStore.refresh()">Retry</button>
</div>
<!-- Native File Browser (for FileBrowser-backed sections) -->
<div
v-else-if="useNativeUI"

View File

@ -86,7 +86,10 @@
@click="appLauncher.closePanel()"
:style="{ '--nav-stagger': idx }"
>
<svg class="w-5 h-5" aria-hidden="true" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<svg v-if="item.icon === 'web5'" class="w-5 h-5" aria-hidden="true" fill="currentColor" viewBox="0 0 1631 1624">
<path fill-rule="evenodd" clip-rule="evenodd" d="M914.932 359.228H916.229V715.252H1630.47V1088.98H1451.41V1267.98H1274.33V1445H1093.31V1624H715.534V1264.77H714.237V908.748H0V535.02H179.051V356.025H356.135V178.996H537.154V0H914.932V359.228ZM916.229 1425.33H1073.64V1248.31H1254.66V1071.28H1431.74V913.918H916.229V1425.33ZM556.83 375.695H375.811V552.723H198.727V710.082H714.237V198.666H556.83V375.695Z" />
</svg>
<svg v-else class="w-5 h-5" aria-hidden="true" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path
v-for="(path, index) in getIconPath(item.icon)"
:key="index"
@ -247,7 +250,8 @@
<div :key="route.path" class="view-wrapper">
<div
v-if="route.path === '/dashboard/chat' || route.path === '/dashboard/mesh'"
class="h-full"
:class="['h-full', mobileTabPaddingTop ? 'overflow-y-auto' : '']"
:style="mobileTabPaddingTop ? { paddingTop: (mobileTabPaddingTop + 16) + 'px' } : undefined"
>
<component :is="Component" />
</div>
@ -304,7 +308,10 @@
}"
:exact-active-class="item.isCombined ? undefined : 'nav-tab-active'"
>
<svg class="w-6 h-6 transition-all duration-300" aria-hidden="true" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<svg v-if="item.icon === 'web5'" class="w-6 h-6 transition-all duration-300" aria-hidden="true" fill="currentColor" viewBox="0 0 1631 1624">
<path fill-rule="evenodd" clip-rule="evenodd" d="M914.932 359.228H916.229V715.252H1630.47V1088.98H1451.41V1267.98H1274.33V1445H1093.31V1624H715.534V1264.77H714.237V908.748H0V535.02H179.051V356.025H356.135V178.996H537.154V0H914.932V359.228ZM916.229 1425.33H1073.64V1248.31H1254.66V1071.28H1431.74V913.918H916.229V1425.33ZM556.83 375.695H375.811V552.723H198.727V710.082H714.237V198.666H556.83V375.695Z" />
</svg>
<svg v-else class="w-6 h-6 transition-all duration-300" aria-hidden="true" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path
v-for="(path, index) in getIconPath(item.icon)"
:key="index"

View File

@ -304,7 +304,7 @@
<div v-if="!confirmRemove">
<button
@click="confirmRemove = true"
class="w-full mt-4 px-4 py-2 rounded text-sm text-red-400 border border-red-400/30 hover:bg-red-400/10 transition-colors"
class="w-full mt-4 px-4 py-2 rounded text-sm glass-button glass-button-danger transition-colors"
>
Remove from Federation
</button>
@ -318,7 +318,7 @@
>Cancel</button>
<button
@click="removeNode(selectedNode!.did)"
class="flex-1 px-3 py-1.5 rounded text-sm text-red-400 border border-red-400/30 hover:bg-red-400/10 transition-colors font-medium"
class="flex-1 px-3 py-1.5 rounded text-sm glass-button glass-button-danger transition-colors font-medium"
>Confirm Remove</button>
</div>
</div>

View File

@ -80,6 +80,15 @@
</div>
</div>
<!-- App icon -->
<img
v-if="stepIconUrl(step)"
:src="stepIconUrl(step)"
:alt="step.title"
class="w-7 h-7 rounded-md object-contain shrink-0 mt-0.5"
@error="($event.target as HTMLImageElement).style.display = 'none'"
/>
<!-- Step content -->
<div class="flex-1 min-w-0">
<h3 class="text-base font-semibold text-white/90 mb-1">{{ step.title }}</h3>
@ -141,7 +150,7 @@
<!-- Action error toast -->
<Transition name="fade">
<div v-if="actionError" class="fixed bottom-20 left-1/2 -translate-x-1/2 z-50 max-w-md w-full px-4" role="alert" aria-live="assertive">
<div class="bg-red-500/20 border border-red-500/40 backdrop-blur-sm rounded-lg px-4 py-3 text-red-200 text-sm flex items-center justify-between gap-3">
<div class="alert-error backdrop-blur-sm rounded-lg px-4 py-3 text-sm flex items-center justify-between gap-3">
<span>{{ actionError }}</span>
<button @click="actionError = ''" class="text-red-300 hover:text-white shrink-0">&times;</button>
</div>
@ -159,6 +168,24 @@ import { useGoalStore } from '@/stores/goals'
import { getGoalById } from '@/data/goals'
import type { GoalStep } from '@/types/goals'
/** Map appId to its icon file path under /assets/img/app-icons/ */
const APP_ICON_MAP: Record<string, string> = {
'bitcoin-knots': '/assets/img/app-icons/bitcoin-knots.webp',
lnd: '/assets/img/app-icons/lnd.svg',
'btcpay-server': '/assets/img/app-icons/btcpay-server.png',
immich: '/assets/img/app-icons/immich.png',
nextcloud: '/assets/img/app-icons/nextcloud.webp',
fedimint: '/assets/img/app-icons/fedimint.png',
mempool: '/assets/img/app-icons/mempool.webp',
electrs: '/assets/img/app-icons/electrs.svg',
'nostr-rs-relay': '/assets/img/app-icons/nostr-rs-relay.svg',
}
function stepIconUrl(step: GoalStep): string | undefined {
if (!step.appId) return undefined
return APP_ICON_MAP[step.appId]
}
const { t } = useI18n()
const route = useRoute()
const router = useRouter()

View File

@ -12,7 +12,7 @@
</div>
</div>
<div class="flex items-center gap-3">
<div class="kiosk-status-pill" :class="isConnected ? 'bg-green-500/20 text-green-400' : 'bg-red-500/20 text-red-400'">
<div class="kiosk-status-pill" :class="isConnected ? 'status-success' : 'status-error'">
<div class="w-2 h-2 rounded-full" :class="isConnected ? 'bg-green-400' : 'bg-red-400'"></div>
{{ isConnected ? t('kiosk.online') : t('kiosk.offline') }}
</div>

View File

@ -47,7 +47,7 @@
<button @click="refreshDiagnostics" class="glass-button px-4 py-2 rounded-lg text-sm flex-1">
{{ t('common.refresh') }}
</button>
<button @click="goToLogin" class="glass-button px-4 py-2 rounded-lg text-sm flex-1 bg-orange-500/20 border-orange-500/30">
<button @click="goToLogin" class="glass-button glass-button-warning px-4 py-2 rounded-lg text-sm flex-1">
{{ t('kioskRecovery.goToLogin') }}
</button>
</div>

View File

@ -204,6 +204,7 @@
<script setup lang="ts">
import { ref, computed, onMounted, onBeforeUnmount } from 'vue'
import { useRouter, useRoute } from 'vue-router'
import { isLocalRedirect } from '../router'
import { useI18n } from 'vue-i18n'
import AnimatedLogo from '@/components/AnimatedLogo.vue'
import { useAppStore } from '../stores/app'
@ -217,7 +218,11 @@ const router = useRouter()
const currentRoute = useRoute()
/** After login, redirect to the intended page or default to home */
const loginRedirectTo = computed(() => (currentRoute.query.redirect as string) || '/dashboard')
const loginRedirectTo = computed(() => {
const redirect = currentRoute.query.redirect as string
if (redirect && isLocalRedirect(redirect)) return redirect
return '/dashboard'
})
const store = useAppStore()
const loginTransition = useLoginTransitionStore()

View File

@ -116,6 +116,12 @@
<!-- Scrollable Apps Section -->
<div class="pb-8">
<!-- Community Load Error -->
<div v-if="communityError" class="alert-error mb-4">
{{ communityError }}
<button @click="loadCommunityMarketplace()" class="ml-2 underline hover:no-underline">Retry</button>
</div>
<!-- Apps Grid -->
<div class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6">
<div
@ -1288,30 +1294,6 @@ function handleImageError(event: Event) {
overflow: hidden;
}
/* Modal transition animations */
.modal-enter-active,
.modal-leave-active {
transition: opacity 0.3s ease;
}
.modal-enter-from,
.modal-leave-to {
opacity: 0;
}
.modal-enter-active .glass-card,
.modal-leave-active .glass-card {
transition: transform 0.3s ease;
}
.modal-enter-from .glass-card {
transform: scale(0.95);
}
.modal-leave-to .glass-card {
transform: scale(0.95);
}
/* Custom scrollbar styling for apps section */
.marketplace-container ::-webkit-scrollbar {
width: 8px;

View File

@ -4,11 +4,21 @@ import { useMeshStore } from '@/stores/mesh'
import { useTransportStore } from '@/stores/transport'
import type { MeshPeer, SessionStatus } from '@/stores/mesh'
import AnimatedLogo from '@/components/AnimatedLogo.vue'
import ToggleSwitch from '@/components/ToggleSwitch.vue'
import { rpcClient } from '@/api/rpc-client'
const mesh = useMeshStore()
const transport = useTransportStore()
// Responsive layout breakpoints
const isWideDesktop = ref(window.innerWidth >= 1536)
const isMobile = ref(window.innerWidth < 1280)
function handleResize() {
isWideDesktop.value = window.innerWidth >= 1536
isMobile.value = window.innerWidth < 1280
}
// Active chat: either a peer or a channel
const activeChatPeer = ref<MeshPeer | null>(null)
const activeChatChannel = ref<{ index: number; name: string } | null>(null)
@ -18,6 +28,7 @@ const broadcasting = ref(false)
const configuring = ref(false)
const connectingDevice = ref<string | null>(null)
const chatScrollEl = ref<HTMLElement | null>(null)
const mobileShowChat = ref(false)
let pollInterval: ReturnType<typeof setInterval> | null = null
// The Public channel (always available on Meshcore)
@ -43,6 +54,27 @@ const deadmanInterval = ref('21600')
const deadmanEnabled = ref(false)
const deadmanCustomMsg = ref('')
// Tools tab for 3rd column on wide desktop and mobile below-chat
const toolsTab = ref<'bitcoin' | 'deadman'>('bitcoin')
// Panel visibility computeds
const showChatPanel = computed(() =>
activeTab.value === 'chat' || isWideDesktop.value || (isMobile.value && mobileShowChat.value)
)
// On wide desktop + mobile first view: tools use their own tab bar
const showBitcoinPanel = computed(() => {
if (isWideDesktop.value || (isMobile.value && !mobileShowChat.value)) return toolsTab.value === 'bitcoin'
return activeTab.value === 'bitcoin'
})
const showDeadmanPanel = computed(() => {
if (isWideDesktop.value || (isMobile.value && !mobileShowChat.value)) return toolsTab.value === 'deadman'
return activeTab.value === 'deadman'
})
// Mobile tools: show on first view (peers), hide when in chat
const showMobileTools = computed(() => isMobile.value && !mobileShowChat.value)
// Medium desktop: show 3-tab bar. Wide + mobile: hidden (tools has own tab bar)
const showTabBar = computed(() => !isWideDesktop.value && !isMobile.value)
// Fetch session status when active peer changes
watch(() => activeChatPeer.value, async (peer) => {
if (peer) {
@ -184,6 +216,7 @@ function formatTimeRemaining(secs: number): string {
}
onMounted(async () => {
window.addEventListener('resize', handleResize)
await Promise.all([mesh.refreshAll(), transport.fetchStatus()])
// Sync deadman UI state from server
if (mesh.deadmanStatus) {
@ -200,6 +233,7 @@ onMounted(async () => {
})
onUnmounted(() => {
window.removeEventListener('resize', handleResize)
if (pollInterval) clearInterval(pollInterval)
})
@ -253,6 +287,7 @@ function openChat(peer: MeshPeer) {
sendError.value = ''
messageText.value = ''
activeTab.value = 'chat'
mobileShowChat.value = true
mesh.markChatRead(peer.contact_id)
nextTick(() => scrollChatToBottom())
}
@ -263,12 +298,14 @@ function openChannelChat(channel: { index: number; name: string }) {
sendError.value = ''
messageText.value = ''
activeTab.value = 'chat'
mobileShowChat.value = true
nextTick(() => scrollChatToBottom())
}
function closeChat() {
activeChatPeer.value = null
activeChatChannel.value = null
mobileShowChat.value = false
mesh.clearViewingChat()
}
@ -360,10 +397,10 @@ function truncatePubkey(hex: string | null): string {
<!-- Error banner -->
<div v-if="mesh.error" class="mesh-error">{{ mesh.error }}</div>
<!-- Two-column layout (desktop) / single-column (mobile) -->
<div class="mesh-columns">
<!-- Responsive column layout: 3-col (wide), 2-col (medium), 1-col (mobile) -->
<div class="mesh-columns" :class="{ 'mesh-columns-wide': isWideDesktop }">
<!-- LEFT COLUMN: Status + Peers -->
<div class="mesh-left">
<div class="mesh-left" :class="{ 'mobile-hidden': mobileShowChat }">
<!-- Device Status -->
<div class="glass-card mesh-status-card">
<div class="mesh-status-header">
@ -499,9 +536,9 @@ function truncatePubkey(hex: string | null): string {
</div>
<!-- RIGHT COLUMN: Tabbed panels -->
<div class="mesh-right">
<!-- Tab bar -->
<div class="mesh-tab-bar">
<div class="mesh-right" :class="{ 'mobile-hidden': !mobileShowChat }">
<!-- Tab bar (medium desktop only) -->
<div v-if="showTabBar" class="mesh-tab-bar">
<button class="mesh-tab" :class="{ active: activeTab === 'chat' }" @click="activeTab = 'chat'">Chat</button>
<button class="mesh-tab" :class="{ active: activeTab === 'bitcoin' }" @click="activeTab = 'bitcoin'">
Off-Grid Bitcoin
@ -513,146 +550,8 @@ function truncatePubkey(hex: string | null): string {
</button>
</div>
<!-- Off-Grid Bitcoin Panel -->
<div v-if="activeTab === 'bitcoin'" class="glass-card mesh-bitcoin-panel">
<h3 class="mesh-panel-title">Off-Grid Bitcoin</h3>
<p class="mesh-panel-sub">Relay transactions and receive block headers via mesh radio</p>
<!-- Relay status notification -->
<div v-if="relayResult" class="mesh-relay-result" :class="relayResult.includes('failed') || relayResult.includes('Failed') ? 'error' : 'success'">
{{ relayResult }}
</div>
<!-- Block Headers -->
<div class="mesh-bitcoin-section">
<div class="mesh-bitcoin-section-header">
<span class="mesh-bitcoin-label">Latest Block</span>
<span v-if="mesh.latestBlockHeight > 0" class="mesh-bitcoin-height">#{{ mesh.latestBlockHeight.toLocaleString() }}</span>
<span v-else class="mesh-bitcoin-height mesh-muted">No headers yet</span>
</div>
<div v-if="mesh.blockHeaders.length" class="mesh-block-list">
<div v-for="h in mesh.blockHeaders.slice(0, 2)" :key="h.height" class="mesh-block-row">
<span class="mesh-block-height">#{{ h.height.toLocaleString() }}</span>
<span class="mesh-block-hash">{{ h.hash.slice(0, 12) }}...{{ h.hash.slice(-8) }}</span>
</div>
</div>
</div>
<!-- On-Chain / Lightning tabs -->
<div class="mesh-send-tabs">
<button class="mesh-send-tab" :class="{ active: sendTab === 'onchain' }" @click="sendTab = 'onchain'">On-Chain</button>
<button class="mesh-send-tab" :class="{ active: sendTab === 'lightning' }" @click="sendTab = 'lightning'">Lightning</button>
</div>
<!-- On-Chain tab -->
<div v-if="sendTab === 'onchain'" class="mesh-bitcoin-section">
<p class="mesh-bitcoin-hint">Creates a signed transaction locally and relays via mesh peers</p>
<input v-model="meshSendAddr" class="mesh-bitcoin-input" placeholder="Bitcoin address (bc1...)" />
<input v-model="meshSendAmount" class="mesh-bitcoin-input mesh-bitcoin-input-sm" type="number" placeholder="Amount (sats)" min="546" />
<div class="mesh-relay-mode">
<label class="mesh-relay-mode-option" :class="{ active: relayMode === 'archy' }">
<input type="radio" v-model="relayMode" value="archy" />
<span>Archy Peers <small>(E2E encrypted, direct)</small></span>
</label>
<label class="mesh-relay-mode-option" :class="{ active: relayMode === 'broadcast' }">
<input type="radio" v-model="relayMode" value="broadcast" />
<span>Mesh Broadcast <small>(multi-hop, wider reach)</small></span>
</label>
</div>
<button class="glass-button" :disabled="!meshSendAddr.trim() || !meshSendAmount || relayingTx" @click="handleMeshSendBitcoin">
{{ relayingTx ? 'Sending...' : 'Send via Mesh' }}
</button>
<details class="mesh-bitcoin-advanced">
<summary class="mesh-bitcoin-label">Raw TX Relay</summary>
<div style="margin-top: 8px;">
<textarea v-model="txHexInput" class="mesh-bitcoin-input" placeholder="Paste raw transaction hex..." rows="3" />
<button class="glass-button" :disabled="!txHexInput.trim() || relayingTx" @click="handleRelayTx">
{{ relayingTx ? 'Relaying...' : 'Relay Raw TX' }}
</button>
</div>
</details>
</div>
<!-- Lightning tab -->
<div v-if="sendTab === 'lightning'" class="mesh-bitcoin-section">
<p class="mesh-bitcoin-hint">Relays a Lightning invoice to an internet-connected peer for payment</p>
<input v-model="bolt11Input" class="mesh-bitcoin-input" placeholder="lnbc... (bolt11 invoice)" />
<input v-model="bolt11AmountInput" class="mesh-bitcoin-input mesh-bitcoin-input-sm" type="number" placeholder="Amount (sats)" />
<div class="mesh-relay-mode">
<label class="mesh-relay-mode-option" :class="{ active: relayMode === 'archy' }">
<input type="radio" v-model="relayMode" value="archy" />
<span>Archy Peers <small>(E2E encrypted, direct)</small></span>
</label>
<label class="mesh-relay-mode-option" :class="{ active: relayMode === 'broadcast' }">
<input type="radio" v-model="relayMode" value="broadcast" />
<span>Mesh Broadcast <small>(multi-hop, wider reach)</small></span>
</label>
</div>
<button class="glass-button" :disabled="!bolt11Input.trim() || !bolt11AmountInput || relayingLn" @click="handleRelayLightning">
{{ relayingLn ? 'Relaying...' : 'Pay via Mesh' }}
</button>
</div>
</div>
<!-- Dead Man's Switch Panel -->
<div v-if="activeTab === 'deadman'" class="glass-card mesh-deadman-panel">
<h3 class="mesh-panel-title">Dead Man's Switch</h3>
<p class="mesh-panel-sub">Auto-broadcasts a signed emergency alert if you don't check in</p>
<!-- Status -->
<div v-if="mesh.deadmanStatus" class="mesh-deadman-status">
<div class="mesh-deadman-indicator" :class="mesh.deadmanStatus.triggered ? 'triggered' : mesh.deadmanStatus.dead_man_enabled ? 'armed' : 'disabled'">
{{ mesh.deadmanStatus.triggered ? 'TRIGGERED' : mesh.deadmanStatus.dead_man_enabled ? 'ARMED' : 'DISABLED' }}
</div>
<div v-if="mesh.deadmanStatus.dead_man_enabled && !mesh.deadmanStatus.triggered" class="mesh-deadman-timer">
{{ formatTimeRemaining(mesh.deadmanStatus.time_remaining_secs) }}
</div>
<div v-if="deadmanCustomMsg || mesh.deadmanStatus.dead_man_enabled" class="mesh-deadman-message">
{{ deadmanCustomMsg || 'Dead man\'s switch triggered — operator unresponsive' }}
</div>
<button v-if="mesh.deadmanStatus.dead_man_enabled" class="glass-button mesh-deadman-checkin-btn" @click="handleDeadmanCheckin">
I'm OK Check In
</button>
</div>
<!-- Configuration -->
<div class="mesh-deadman-config">
<label class="mesh-deadman-toggle">
<input v-model="deadmanEnabled" type="checkbox" @change="handleDeadmanToggle" />
<span>{{ deadmanEnabled ? 'Dead Man\'s Switch Active' : 'Enable Dead Man\'s Switch' }}</span>
</label>
<template v-if="deadmanEnabled">
<div class="mesh-deadman-field">
<label class="mesh-bitcoin-label">Trigger Interval</label>
<select v-model="deadmanInterval" class="mesh-bitcoin-input mesh-bitcoin-input-sm">
<option value="3600">1 hour</option>
<option value="21600">6 hours</option>
<option value="43200">12 hours</option>
<option value="86400">24 hours</option>
</select>
</div>
<div class="mesh-deadman-field">
<label class="mesh-bitcoin-label">Alert Message</label>
<input v-model="deadmanCustomMsg" class="mesh-bitcoin-input" placeholder="Dead man's switch triggered — operator unresponsive" />
</div>
<div class="mesh-deadman-info">
<span v-if="mesh.deadmanStatus?.has_gps" class="mesh-deadman-info-item">GPS: included</span>
<span class="mesh-deadman-info-item">Contacts: {{ mesh.deadmanStatus?.emergency_contacts ?? 0 }}</span>
</div>
<button class="glass-button" :disabled="deadmanConfiguring" @click="handleDeadmanConfigure">
{{ deadmanConfiguring ? 'Saving...' : 'Save Configuration' }}
</button>
</template>
</div>
</div>
<!-- Chat Panel (existing) -->
<div v-if="activeTab === 'chat'" class="glass-card mesh-chat-card">
<!-- Chat Panel (before tools so it gets grid-column 2 on wide) -->
<div v-if="showChatPanel" class="glass-card mesh-chat-card">
<!-- No chat selected -->
<div v-if="!hasActiveChat" class="mesh-chat-empty">
<div class="mesh-chat-empty-icon">&#x1F4E1;</div>
@ -758,12 +657,228 @@ function truncatePubkey(hex: string | null): string {
{{ mesh.sending ? '...' : 'Send' }}
</button>
</div>
<div class="mesh-chat-compose-meta">
<span>{{ messageText.length }}/160</span>
</div>
</div>
</template>
</div>
<!-- Off-Grid Bitcoin + Dead Man's Switch panels -->
<div class="mesh-tools-wrapper">
<!-- Tools tab bar (wide desktop only mobile has its own outside mesh-right) -->
<div v-if="isWideDesktop" class="mesh-tools-tab-bar">
<button class="mesh-tab" :class="{ active: toolsTab === 'bitcoin' }" @click="toolsTab = 'bitcoin'">
Off-Grid Bitcoin
<span v-if="mesh.latestBlockHeight > 0" class="mesh-tab-badge">{{ mesh.latestBlockHeight }}</span>
</button>
<button class="mesh-tab" :class="{ active: toolsTab === 'deadman' }" @click="toolsTab = 'deadman'">
Dead Man
<span v-if="mesh.deadmanStatus?.triggered" class="mesh-tab-badge mesh-tab-badge-alert">!</span>
</button>
</div>
<!-- Off-Grid Bitcoin Panel -->
<div v-if="showBitcoinPanel" class="glass-card mesh-bitcoin-panel">
<h3 class="mesh-panel-title">Off-Grid Bitcoin</h3>
<p class="mesh-panel-sub">Relay transactions and receive block headers via mesh radio</p>
<!-- Relay status notification -->
<div v-if="relayResult" class="mesh-relay-result" :class="relayResult.includes('failed') || relayResult.includes('Failed') ? 'error' : 'success'">
{{ relayResult }}
</div>
<!-- Block Headers -->
<div class="mesh-bitcoin-section">
<div class="mesh-bitcoin-section-header">
<span class="mesh-bitcoin-label">Latest Block</span>
<span v-if="mesh.latestBlockHeight > 0" class="mesh-bitcoin-height">#{{ mesh.latestBlockHeight.toLocaleString() }}</span>
<span v-else class="mesh-bitcoin-height mesh-muted">No headers yet</span>
</div>
<div v-if="mesh.blockHeaders.length" class="mesh-block-list">
<div v-for="h in mesh.blockHeaders.slice(0, 2)" :key="h.height" class="mesh-block-row">
<span class="mesh-block-height">#{{ h.height.toLocaleString() }}</span>
<span class="mesh-block-hash">{{ h.hash.slice(0, 12) }}...{{ h.hash.slice(-8) }}</span>
</div>
</div>
</div>
<!-- On-Chain / Lightning tabs -->
<div class="mesh-send-tabs">
<button class="mesh-send-tab" :class="{ active: sendTab === 'onchain' }" @click="sendTab = 'onchain'">On-Chain</button>
<button class="mesh-send-tab" :class="{ active: sendTab === 'lightning' }" @click="sendTab = 'lightning'">Lightning</button>
</div>
<!-- On-Chain tab -->
<div v-if="sendTab === 'onchain'" class="mesh-bitcoin-section">
<p class="mesh-bitcoin-hint">Creates a signed transaction locally and relays via mesh peers</p>
<input v-model="meshSendAddr" class="mesh-bitcoin-input" placeholder="Bitcoin address (bc1...)" />
<input v-model="meshSendAmount" class="mesh-bitcoin-input mesh-bitcoin-input-sm" type="number" placeholder="Amount (sats)" min="546" />
<div class="mesh-relay-mode">
<label class="mesh-relay-mode-option" :class="{ active: relayMode === 'archy' }">
<input type="radio" v-model="relayMode" value="archy" />
<span>Archy Peers <small>(E2E encrypted, direct)</small></span>
</label>
<label class="mesh-relay-mode-option" :class="{ active: relayMode === 'broadcast' }">
<input type="radio" v-model="relayMode" value="broadcast" />
<span>Mesh Broadcast <small>(multi-hop, wider reach)</small></span>
</label>
</div>
<button class="glass-button" :disabled="!meshSendAddr.trim() || !meshSendAmount || relayingTx" @click="handleMeshSendBitcoin">
{{ relayingTx ? 'Sending...' : 'Send via Mesh' }}
</button>
<details class="mesh-bitcoin-advanced">
<summary class="mesh-bitcoin-label">Raw TX Relay</summary>
<div style="margin-top: 8px;">
<textarea v-model="txHexInput" class="mesh-bitcoin-input" placeholder="Paste raw transaction hex..." rows="3" />
<button class="glass-button" :disabled="!txHexInput.trim() || relayingTx" @click="handleRelayTx">
{{ relayingTx ? 'Relaying...' : 'Relay Raw TX' }}
</button>
</div>
</details>
</div>
<!-- Lightning tab -->
<div v-if="sendTab === 'lightning'" class="mesh-bitcoin-section">
<p class="mesh-bitcoin-hint">Relays a Lightning invoice to an internet-connected peer for payment</p>
<input v-model="bolt11Input" class="mesh-bitcoin-input" placeholder="lnbc... (bolt11 invoice)" />
<input v-model="bolt11AmountInput" class="mesh-bitcoin-input mesh-bitcoin-input-sm" type="number" placeholder="Amount (sats)" />
<div class="mesh-relay-mode">
<label class="mesh-relay-mode-option" :class="{ active: relayMode === 'archy' }">
<input type="radio" v-model="relayMode" value="archy" />
<span>Archy Peers <small>(E2E encrypted, direct)</small></span>
</label>
<label class="mesh-relay-mode-option" :class="{ active: relayMode === 'broadcast' }">
<input type="radio" v-model="relayMode" value="broadcast" />
<span>Mesh Broadcast <small>(multi-hop, wider reach)</small></span>
</label>
</div>
<button class="glass-button" :disabled="!bolt11Input.trim() || !bolt11AmountInput || relayingLn" @click="handleRelayLightning">
{{ relayingLn ? 'Relaying...' : 'Pay via Mesh' }}
</button>
</div>
</div>
<!-- Dead Man's Switch Panel -->
<div v-if="showDeadmanPanel" class="glass-card mesh-deadman-panel">
<h3 class="mesh-panel-title">Dead Man's Switch</h3>
<p class="mesh-panel-sub">Auto-broadcasts a signed emergency alert if you don't check in</p>
<!-- Status -->
<div v-if="mesh.deadmanStatus" class="mesh-deadman-status">
<div class="mesh-deadman-indicator" :class="mesh.deadmanStatus.triggered ? 'triggered' : mesh.deadmanStatus.dead_man_enabled ? 'armed' : 'disabled'">
{{ mesh.deadmanStatus.triggered ? 'TRIGGERED' : mesh.deadmanStatus.dead_man_enabled ? 'ARMED' : 'DISABLED' }}
</div>
<div v-if="mesh.deadmanStatus.dead_man_enabled && !mesh.deadmanStatus.triggered" class="mesh-deadman-timer">
{{ formatTimeRemaining(mesh.deadmanStatus.time_remaining_secs) }}
</div>
<div v-if="deadmanCustomMsg || mesh.deadmanStatus.dead_man_enabled" class="mesh-deadman-message">
{{ deadmanCustomMsg || 'Dead man\'s switch triggered — operator unresponsive' }}
</div>
<button v-if="mesh.deadmanStatus.dead_man_enabled" class="glass-button mesh-deadman-checkin-btn" @click="handleDeadmanCheckin">
I'm OK Check In
</button>
</div>
<!-- Configuration -->
<div class="mesh-deadman-config">
<button
@click="deadmanEnabled = !deadmanEnabled; handleDeadmanToggle()"
class="w-full flex items-center gap-4 p-4 rounded-xl border transition-all text-left mb-3"
:class="deadmanEnabled
? 'bg-white/10 border-orange-500/40'
: 'bg-black/20 border-white/10 hover:border-white/20'"
>
<svg class="w-5 h-5 shrink-0" :class="deadmanEnabled ? 'text-orange-400' : 'text-white/40'" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 9v2m0 4h.01m-6.938 4h13.856c1.54 0 2.502-1.667 1.732-2.5L13.732 4.5c-.77-.833-2.694-.833-3.464 0L3.34 16.5c-.77.833.192 2.5 1.732 2.5z" />
</svg>
<div class="flex-1 min-w-0">
<p class="text-sm font-medium" :class="deadmanEnabled ? 'text-white/95' : 'text-white/70'">{{ deadmanEnabled ? 'Dead Man\'s Switch Active' : 'Enable Dead Man\'s Switch' }}</p>
<p class="text-xs text-white/50 mt-0.5">Auto-alerts your contacts if you don't check in</p>
</div>
<ToggleSwitch :model-value="deadmanEnabled" @click.stop @update:model-value="deadmanEnabled = $event; handleDeadmanToggle()" />
</button>
<template v-if="deadmanEnabled">
<div class="mesh-deadman-field">
<label class="mesh-bitcoin-label">Trigger Interval</label>
<select v-model="deadmanInterval" class="mesh-bitcoin-input mesh-bitcoin-input-sm">
<option value="3600">1 hour</option>
<option value="21600">6 hours</option>
<option value="43200">12 hours</option>
<option value="86400">24 hours</option>
</select>
</div>
<div class="mesh-deadman-field">
<label class="mesh-bitcoin-label">Alert Message</label>
<input v-model="deadmanCustomMsg" class="mesh-bitcoin-input" placeholder="Dead man's switch triggered — operator unresponsive" />
</div>
<div class="mesh-deadman-info">
<span v-if="mesh.deadmanStatus?.has_gps" class="mesh-deadman-info-item">GPS: included</span>
<span class="mesh-deadman-info-item">Contacts: {{ mesh.deadmanStatus?.emergency_contacts ?? 0 }}</span>
</div>
<button class="glass-button" :disabled="deadmanConfiguring" @click="handleDeadmanConfigure">
{{ deadmanConfiguring ? 'Saving...' : 'Save Configuration' }}
</button>
</template>
</div>
</div>
</div><!-- /.mesh-tools-wrapper -->
</div>
<!-- Mobile tools: show under peers list on first view -->
<div v-if="showMobileTools" class="mesh-mobile-tools">
<div class="mesh-tools-tab-bar">
<button class="mesh-tab" :class="{ active: toolsTab === 'bitcoin' }" @click="toolsTab = 'bitcoin'">
Off-Grid Bitcoin
<span v-if="mesh.latestBlockHeight > 0" class="mesh-tab-badge">{{ mesh.latestBlockHeight }}</span>
</button>
<button class="mesh-tab" :class="{ active: toolsTab === 'deadman' }" @click="toolsTab = 'deadman'">
Dead Man
<span v-if="mesh.deadmanStatus?.triggered" class="mesh-tab-badge mesh-tab-badge-alert">!</span>
</button>
</div>
<div v-if="showBitcoinPanel" class="glass-card mesh-bitcoin-panel">
<!-- Reuse same content via a shared approach - for now inline -->
<h3 class="mesh-panel-title">Off-Grid Bitcoin</h3>
<p class="mesh-panel-sub">Relay transactions and receive block headers via mesh radio</p>
<div class="mesh-bitcoin-section">
<div class="mesh-bitcoin-section-header">
<span class="mesh-bitcoin-label">Latest Block</span>
<span v-if="mesh.latestBlockHeight > 0" class="mesh-bitcoin-height">#{{ mesh.latestBlockHeight.toLocaleString() }}</span>
<span v-else class="mesh-bitcoin-height mesh-muted">No headers yet</span>
</div>
</div>
</div>
<div v-if="showDeadmanPanel" class="glass-card mesh-deadman-panel">
<h3 class="mesh-panel-title">Dead Man's Switch</h3>
<p class="mesh-panel-sub">Auto-broadcasts a signed emergency alert if you don't check in</p>
<div v-if="mesh.deadmanStatus" class="mesh-deadman-status">
<div class="mesh-deadman-indicator" :class="mesh.deadmanStatus.triggered ? 'triggered' : mesh.deadmanStatus.dead_man_enabled ? 'armed' : 'disabled'">
{{ mesh.deadmanStatus.triggered ? 'TRIGGERED' : mesh.deadmanStatus.dead_man_enabled ? 'ARMED' : 'DISABLED' }}
</div>
</div>
<div class="mesh-deadman-config">
<button
@click="deadmanEnabled = !deadmanEnabled; handleDeadmanToggle()"
class="w-full flex items-center gap-4 p-4 rounded-xl border transition-all text-left"
:class="deadmanEnabled
? 'bg-white/10 border-orange-500/40'
: 'bg-black/20 border-white/10 hover:border-white/20'"
>
<svg class="w-5 h-5 shrink-0" :class="deadmanEnabled ? 'text-orange-400' : 'text-white/40'" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 9v2m0 4h.01m-6.938 4h13.856c1.54 0 2.502-1.667 1.732-2.5L13.732 4.5c-.77-.833-2.694-.833-3.464 0L3.34 16.5c-.77.833.192 2.5 1.732 2.5z" />
</svg>
<div class="flex-1 min-w-0">
<p class="text-sm font-medium" :class="deadmanEnabled ? 'text-white/95' : 'text-white/70'">{{ deadmanEnabled ? 'Dead Man\'s Switch Active' : 'Enable Dead Man\'s Switch' }}</p>
<p class="text-xs text-white/50 mt-0.5">Auto-alerts your contacts if you don't check in</p>
</div>
<ToggleSwitch :model-value="deadmanEnabled" @click.stop @update:model-value="deadmanEnabled = $event; handleDeadmanToggle()" />
</button>
</div>
</div>
</div>
</div>
</div>
@ -772,7 +887,7 @@ function truncatePubkey(hex: string | null): string {
<style scoped>
.mesh-view {
padding: 24px;
max-width: 1200px;
max-width: 1600px;
margin: 0 auto;
display: flex;
flex-direction: column;
@ -873,6 +988,65 @@ function truncatePubkey(hex: string | null): string {
overflow: hidden;
}
/* Tools wrapper: holds Off-Grid Bitcoin + Dead Man panels */
.mesh-tools-wrapper {
display: contents;
}
/* Tools tab bar: hidden by default (medium desktop uses main tab bar) */
.mesh-tools-tab-bar {
display: none;
}
/* ─── Wide desktop: 3-column layout ─── */
.mesh-columns-wide {
display: grid;
grid-template-columns: 340px 1fr 1fr;
gap: 16px;
}
.mesh-columns-wide .mesh-left {
grid-column: 1;
width: auto;
}
.mesh-columns-wide .mesh-right {
display: contents;
}
.mesh-columns-wide .mesh-chat-card {
grid-column: 2;
grid-row: 1;
min-height: 0;
overflow: hidden;
}
.mesh-columns-wide .mesh-tools-wrapper {
grid-column: 3;
grid-row: 1;
display: flex;
flex-direction: column;
gap: 0;
min-height: 0;
overflow-y: auto;
}
.mesh-columns-wide .mesh-tools-tab-bar {
display: flex;
gap: 2px;
background: rgba(0,0,0,0.3);
border-radius: 10px;
padding: 3px;
flex-shrink: 0;
margin-bottom: 12px;
}
/* Hide main tab bar and mobile back button on wide desktop */
.mesh-columns-wide .mesh-mobile-back-btn,
.mesh-columns-wide .mesh-tab-bar {
display: none;
}
/* ─── Status card ─── */
.mesh-status-card { padding: 16px; flex-shrink: 0; }
@ -1408,12 +1582,17 @@ function truncatePubkey(hex: string | null): string {
margin-top: 4px;
}
/* ─── Mobile: keep single column ─── */
@media (max-width: 768px) {
/* ─── Mobile back button (hidden on desktop) ─── */
.mesh-mobile-back-btn {
display: none;
}
/* ─── Mobile: single column with panel switching ─── */
@media (max-width: 1279px) {
.mesh-view {
height: auto;
overflow: visible;
padding: 0 12px 12px 12px;
padding: 0 12px 100px 12px; /* bottom padding clears tab bar */
}
.mesh-columns {
@ -1427,7 +1606,44 @@ function truncatePubkey(hex: string | null): string {
}
.mesh-right {
min-height: 400px;
min-height: auto;
overflow: visible;
}
/* Chat takes available viewport height minus tab bars */
.mesh-chat-card {
min-height: 60dvh;
max-height: 75dvh;
overflow: hidden;
display: flex;
flex-direction: column;
}
/* Hide tools-wrapper inside mesh-right (shown via mesh-mobile-tools instead) */
.mesh-tools-wrapper {
display: none !important;
}
/* Tools section under peers — fixed height so no jump on tab switch */
.mesh-mobile-tools {
margin-top: 12px;
display: flex;
flex-direction: column;
gap: 12px;
}
.mesh-mobile-tools .mesh-tools-tab-bar {
display: flex;
gap: 2px;
background: rgba(0,0,0,0.3);
border-radius: 10px;
padding: 3px;
}
/* Fixed-height panel container so switching tabs doesn't resize */
.mesh-mobile-tools .mesh-bitcoin-panel,
.mesh-mobile-tools .mesh-deadman-panel {
min-height: 320px;
}
.mesh-status-grid {
@ -1437,6 +1653,23 @@ function truncatePubkey(hex: string | null): string {
.mesh-chat-back {
display: block;
}
/* Hide panel on mobile when toggled */
.mobile-hidden {
display: none !important;
}
/* Bitcoin and deadman panels should not flex-grow on mobile */
.mesh-bitcoin-panel,
.mesh-deadman-panel {
flex: none;
cursor: pointer;
flex-shrink: 0;
}
.mesh-mobile-back-btn:hover {
color: rgba(255, 255, 255, 0.9);
}
}
/* ─── Session badge ─── */
@ -1571,7 +1804,7 @@ function truncatePubkey(hex: string | null): string {
}
.mesh-bitcoin-input::placeholder { color: rgba(255,255,255,0.3); }
.mesh-bitcoin-input:focus { outline: none; border-color: rgba(251,146,60,0.4); }
.mesh-bitcoin-input-sm { max-width: 200px; }
.mesh-bitcoin-input-sm { width: 100%; }
.mesh-relay-mode {
display: flex;
gap: 8px;

View File

@ -102,14 +102,7 @@
:key="rule.kind"
class="flex items-center gap-3 p-3 bg-white/5 rounded-lg"
>
<label class="monitoring-alert-toggle">
<input
type="checkbox"
:checked="rule.enabled"
@change="toggleAlertRule(rule.kind, !rule.enabled)"
/>
<span class="monitoring-alert-toggle-slider"></span>
</label>
<ToggleSwitch :model-value="rule.enabled" @update:model-value="toggleAlertRule(rule.kind, $event)" />
<div class="flex-1 min-w-0">
<p class="text-sm text-white">{{ ruleLabel(rule.kind) }}</p>
<p class="text-xs text-white/40">{{ rule.description }}</p>
@ -223,6 +216,7 @@ import { ref, computed, onMounted, onUnmounted } from 'vue'
import { useI18n } from 'vue-i18n'
import { rpcClient } from '@/api/rpc-client'
import LineChart from '@/components/LineChart.vue'
import ToggleSwitch from '@/components/ToggleSwitch.vue'
import type { ChartDataset } from '@/components/LineChart.vue'
interface SystemMetrics {

View File

@ -47,7 +47,7 @@
<!-- Error -->
<div v-else-if="catalogError" class="glass-card p-6">
<p class="text-red-400 text-sm mb-3">{{ catalogError }}</p>
<div class="alert-error mb-4">{{ catalogError }}</div>
<button class="glass-button px-4 py-2 rounded-lg text-sm" @click="loadCatalog">Retry</button>
</div>

View File

@ -88,16 +88,7 @@
<p class="text-xs text-white/60">{{ autoSyncEnabled ? 'Enabled' : 'Disabled' }}</p>
</div>
</div>
<button
@click="toggleAutoSync"
class="relative inline-flex h-8 w-14 items-center rounded-full transition-colors shrink-0"
:class="autoSyncEnabled ? 'bg-green-500' : 'bg-white/20'"
>
<span
class="inline-block h-6 w-6 transform rounded-full bg-white transition-transform shadow"
:class="autoSyncEnabled ? 'translate-x-7' : 'translate-x-1'"
/>
</button>
<ToggleSwitch v-model="autoSyncEnabled" />
</div>
</div>
@ -123,7 +114,7 @@
</div>
<!-- Overview Cards -->
<div class="grid grid-cols-1 lg:grid-cols-2 gap-6 mb-8">
<div class="grid grid-cols-1 xl:grid-cols-2 gap-6 mb-8">
<!-- Local Network Card -->
<div data-controller-container tabindex="0" class="glass-card p-6 flex flex-col">
<div class="flex items-start gap-4 mb-4 shrink-0">
@ -284,8 +275,9 @@
</div>
</div>
<div class="grid grid-cols-1 xl:grid-cols-2 gap-6 mb-6">
<!-- Network Interfaces -->
<div class="glass-card p-6 mb-6">
<div class="glass-card p-6">
<div class="flex items-center justify-between mb-4">
<div>
<h2 class="text-xl font-semibold text-white mb-1">Network Interfaces</h2>
@ -330,7 +322,7 @@
</div>
<!-- Tor Services -->
<div class="glass-card px-6 py-6 mb-6">
<div class="glass-card p-6">
<div class="flex items-center justify-between mb-4">
<div>
<h2 class="text-xl font-semibold text-white/96">Tor Services</h2>
@ -361,15 +353,8 @@
>
{{ torRotating === svc.name ? 'Rotating...' : 'Rotate' }}
</button>
<label class="tor-toggle-label">
<input
type="checkbox"
:checked="svc.enabled"
@change="toggleTorApp(svc.name, !svc.enabled)"
class="tor-toggle-input"
/>
<span class="tor-toggle-slider"></span>
</label>
<ToggleSwitch :model-value="svc.enabled" @update:model-value="toggleTorApp(svc.name, $event)" />
</div>
</div>
</div>
</div>
@ -512,6 +497,7 @@
<script setup lang="ts">
import { ref, computed, onMounted, watch } from 'vue'
import { rpcClient } from '@/api/rpc-client'
import ToggleSwitch from '@/components/ToggleSwitch.vue'
// Connected nodes
const connectedNodes = ref(0)
@ -904,10 +890,6 @@ async function checkConnectivity() {
}
}
function toggleAutoSync() {
autoSyncEnabled.value = !autoSyncEnabled.value
}
const logsToast = ref('')
function viewLogs() {

View File

@ -247,7 +247,7 @@
<div data-controller-container tabindex="0" class="mb-6">
<button
@click="showChangePasswordModal = true"
class="w-full flex items-center justify-center gap-2 mb-4 px-4 py-2 rounded-lg border border-orange-500/50 text-orange-400 font-medium hover:bg-orange-500/10 transition-colors"
class="w-full flex items-center justify-center gap-2 mb-4 px-4 py-2 rounded-lg glass-button glass-button-warning font-medium"
>
<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M15 7a2 2 0 012 2m4 0a6 6 0 01-7.743 5.743L11 17H9v2H7v2H4a1 1 0 01-1-1v-2.586a1 1 0 01.293-.707l5.964-5.964A6 6 0 1121 9z" />
@ -341,7 +341,7 @@
</div>
<span
class="text-xs font-semibold px-2 py-1 rounded-full"
:class="totpEnabled ? 'bg-green-500/20 text-green-400' : 'bg-white/10 text-white/50'"
:class="totpEnabled ? 'status-success' : 'bg-white/10 text-white/50'"
>
{{ totpEnabled ? t('common.enabled') : t('common.disabled') }}
</span>
@ -349,7 +349,7 @@
<button
v-if="!totpEnabled"
@click="showTotpSetupModal = true"
class="w-full flex items-center justify-center gap-2 px-4 py-2 rounded-lg border border-orange-500/50 text-orange-400 font-medium hover:bg-orange-500/10 transition-colors"
class="w-full flex items-center justify-center gap-2 px-4 py-2 rounded-lg glass-button glass-button-warning font-medium"
>
<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 15v2m-6 4h12a2 2 0 002-2v-6a2 2 0 00-2-2H6a2 2 0 00-2 2v6a2 2 0 002 2zm10-10V7a4 4 0 00-8 0v4h8z" />
@ -359,7 +359,7 @@
<button
v-else
@click="showTotpDisableModal = true"
class="w-full flex items-center justify-center gap-2 px-4 py-2 rounded-lg border border-red-500/50 text-red-400 font-medium hover:bg-red-500/10 transition-colors"
class="w-full flex items-center justify-center gap-2 px-4 py-2 rounded-lg glass-button glass-button-danger font-medium"
>
<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M8 11V7a4 4 0 118 0m-4 8v2m-6 4h12a2 2 0 002-2v-6a2 2 0 00-2-2H6a2 2 0 00-2 2v6a2 2 0 002 2z" />
@ -617,7 +617,7 @@
class="w-full flex items-center justify-center gap-2 px-4 py-3 rounded-lg border transition-colors"
:class="claudeConnected
? 'border-white/20 text-white/70 hover:bg-white/5'
: 'border-orange-500/50 text-orange-400 font-medium hover:bg-orange-500/10'"
: 'glass-button-warning font-medium'"
>
<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M11 16l-4-4m0 0l4-4m-4 4h14m-5 4v1a3 3 0 01-3 3H6a3 3 0 01-3-3V7a3 3 0 013-3h7a3 3 0 013 3v1" />
@ -674,15 +674,7 @@
<p class="text-sm font-medium" :class="aiPermissions.allEnabled ? 'text-white/95' : 'text-white/70'">{{ t('common.enableAll') }}</p>
<p class="text-xs text-white/50 mt-0.5">{{ t('settings.enableAllDesc') }}</p>
</div>
<div
class="w-10 h-6 rounded-full shrink-0 transition-colors relative"
:class="aiPermissions.allEnabled ? 'bg-orange-500' : 'bg-white/15'"
>
<div
class="absolute top-1 w-4 h-4 rounded-full bg-white shadow transition-transform"
:class="aiPermissions.allEnabled ? 'translate-x-5' : 'translate-x-1'"
/>
</div>
<ToggleSwitch :model-value="aiPermissions.allEnabled" @update:model-value="aiPermissions.allEnabled ? aiPermissions.disableAll() : aiPermissions.enableAll()" @click.stop />
</button>
<div class="space-y-5">
@ -705,15 +697,7 @@
<p class="text-sm font-medium" :class="aiPermissions.isEnabled(cat.id) ? 'text-white/95' : 'text-white/70'">{{ cat.label }}</p>
<p class="text-xs text-white/50 mt-0.5">{{ cat.description }}</p>
</div>
<div
class="w-10 h-6 rounded-full shrink-0 transition-colors relative"
:class="aiPermissions.isEnabled(cat.id) ? 'bg-orange-500' : 'bg-white/15'"
>
<div
class="absolute top-1 w-4 h-4 rounded-full bg-white shadow transition-transform"
:class="aiPermissions.isEnabled(cat.id) ? 'translate-x-5' : 'translate-x-1'"
/>
</div>
<ToggleSwitch :model-value="aiPermissions.isEnabled(cat.id)" @update:model-value="aiPermissions.toggle(cat.id)" @click.stop />
</button>
</div>
</div>
@ -744,22 +728,7 @@
<h2 class="text-xl font-semibold text-white/96">{{ t('settings.webhookNotifications') }}</h2>
<p class="text-sm text-white/60 mt-1">{{ t('settings.webhookNotificationsDesc') }}</p>
</div>
<div class="flex items-center gap-3">
<button
@click="toggleWebhookEnabled"
role="switch"
:aria-checked="webhookConfig.enabled"
:aria-label="webhookConfig.enabled ? t('settings.disableWebhooks') : t('settings.enableWebhooks')"
class="w-10 h-6 rounded-full shrink-0 transition-colors relative"
:class="webhookConfig.enabled ? 'bg-orange-500' : 'bg-white/15'"
:title="webhookConfig.enabled ? t('settings.disableWebhooks') : t('settings.enableWebhooks')"
>
<div
class="absolute top-1 w-4 h-4 rounded-full bg-white shadow transition-transform"
:class="webhookConfig.enabled ? 'translate-x-5' : 'translate-x-1'"
/>
</button>
</div>
<ToggleSwitch :model-value="webhookConfig.enabled" @update:model-value="toggleWebhookEnabled" />
</div>
<div class="space-y-4">
@ -824,7 +793,7 @@
<button
@click="saveWebhookConfig"
:disabled="savingWebhook"
class="glass-button px-4 py-2 rounded-lg text-sm flex items-center justify-center gap-2 bg-orange-500/20 border-orange-500/30 disabled:opacity-50"
class="glass-button glass-button-warning px-4 py-2 rounded-lg text-sm flex items-center justify-center gap-2 disabled:opacity-50"
>
{{ savingWebhook ? t('settings.savingWebhook') : t('common.saveConfiguration') }}
</button>
@ -839,7 +808,7 @@
</div>
<!-- Webhook status message -->
<div v-if="webhookStatusMsg" role="status" aria-live="polite" class="mt-3 text-xs px-3 py-2 rounded-lg" :class="webhookStatusType === 'error' ? 'bg-red-500/15 text-red-300' : 'bg-green-500/15 text-green-300'">
<div v-if="webhookStatusMsg" role="status" aria-live="polite" class="mt-3 text-xs px-3 py-2 rounded-lg" :class="webhookStatusType === 'error' ? 'alert-error' : 'alert-success'">
{{ webhookStatusMsg }}
</div>
</div>
@ -855,7 +824,7 @@
@click="toggleTelemetry"
:disabled="telemetryLoading"
class="shrink-0 ml-4 px-4 py-2 rounded-lg text-sm font-medium transition-colors"
:class="telemetryEnabled ? 'bg-green-500/20 text-green-300 border border-green-500/30 hover:bg-green-500/30' : 'glass-button'"
:class="telemetryEnabled ? 'glass-button glass-button-success' : 'glass-button'"
>
{{ telemetryLoading ? '...' : telemetryEnabled ? 'Enabled' : 'Enable' }}
</button>
@ -906,7 +875,7 @@
</div>
<!-- Backup status message -->
<div v-if="backupStatusMsg" role="status" aria-live="polite" class="mt-3 text-xs px-3 py-2 rounded-lg" :class="backupStatusType === 'error' ? 'bg-red-500/15 text-red-300' : 'bg-green-500/15 text-green-300'">
<div v-if="backupStatusMsg" role="status" aria-live="polite" class="mt-3 text-xs px-3 py-2 rounded-lg" :class="backupStatusType === 'error' ? 'alert-error' : 'alert-success'">
{{ backupStatusMsg }}
</div>
</div>
@ -928,7 +897,7 @@
</div>
<div class="flex gap-3 mt-5">
<button @click="showCreateBackupModal = false" class="glass-button px-4 py-2 rounded-lg text-sm flex-1">{{ t('common.cancel') }}</button>
<button @click="createBackup" :disabled="creatingBackup || !backupPassphrase" class="glass-button px-4 py-2 rounded-lg text-sm flex-1 bg-orange-500/20 border-orange-500/30 disabled:opacity-50">
<button @click="createBackup" :disabled="creatingBackup || !backupPassphrase" class="glass-button glass-button-warning px-4 py-2 rounded-lg text-sm flex-1 disabled:opacity-50">
{{ creatingBackup ? t('settings.creatingBackup') : t('settings.createBackup') }}
</button>
</div>
@ -948,7 +917,7 @@
</div>
<div class="flex gap-3 mt-5">
<button @click="showRestoreModal = false" class="glass-button px-4 py-2 rounded-lg text-sm flex-1">{{ t('common.cancel') }}</button>
<button @click="restoreBackup" :disabled="restoringBackup || !restorePassphrase" class="glass-button px-4 py-2 rounded-lg text-sm flex-1 bg-red-500/20 border-red-500/30 disabled:opacity-50">
<button @click="restoreBackup" :disabled="restoringBackup || !restorePassphrase" class="glass-button glass-button-danger px-4 py-2 rounded-lg text-sm flex-1 disabled:opacity-50">
{{ restoringBackup ? t('common.restoring') : t('common.restore') }}
</button>
</div>
@ -979,7 +948,7 @@
Wipe all user data, identities, and credentials. Container images are preserved. The node will restart and show the onboarding screen.
</p>
<button
class="glass-button text-red-400 border-red-500/30 hover:border-red-500/50"
class="glass-button glass-button-danger"
@click="showFactoryResetConfirm = true"
>
Factory Reset
@ -997,7 +966,7 @@
<div class="flex gap-3 justify-end">
<button class="glass-button" @click="showFactoryResetConfirm = false">Cancel</button>
<button
class="glass-button text-red-400 border-red-500/30"
class="glass-button glass-button-danger"
:disabled="factoryResetLoading"
@click="performFactoryReset"
>
@ -1020,6 +989,7 @@ import { useAppStore } from '../stores/app'
import { useUIModeStore } from '@/stores/uiMode'
import { useAIPermissionsStore, AI_PERMISSION_CATEGORIES } from '@/stores/aiPermissions'
import ControllerIndicator from '@/components/ControllerIndicator.vue'
import ToggleSwitch from '@/components/ToggleSwitch.vue'
import { rpcClient } from '@/api/rpc-client'
import { useModalKeyboard } from '@/composables/useModalKeyboard'
import type { UIMode } from '@/types/api'

View File

@ -34,16 +34,16 @@
<p v-else class="text-xs text-white/60 capitalize">{{ didStatus }}</p>
</div>
</div>
<div v-if="userDid" class="flex gap-2">
<div v-if="userDid" class="flex gap-2 mt-auto">
<button
@click="copyDid"
class="px-3 py-1.5 glass-button glass-button-sm rounded text-xs font-medium text-white/90 hover:text-white transition-colors"
class="flex-1 px-3 py-1.5 glass-button glass-button-sm rounded text-xs font-medium text-white/90 hover:text-white transition-colors"
>
{{ didCopied ? t('common.copiedBang') : t('web5.copyDid') }}
</button>
<button
@click="showDidDocument"
class="px-3 py-1.5 glass-button glass-button-sm rounded text-xs font-medium text-white/90 hover:text-white transition-colors"
class="flex-1 px-3 py-1.5 glass-button glass-button-sm rounded text-xs font-medium text-white/90 hover:text-white transition-colors"
>
{{ t('web5.viewDidDocument') }}
</button>
@ -52,7 +52,7 @@
v-else
@click="createDID"
:disabled="creatingDid"
class="w-fit px-3 py-1.5 glass-button glass-button-sm rounded text-xs font-medium text-white/90 hover:text-white transition-colors disabled:opacity-50"
class="w-full mt-auto px-3 py-1.5 glass-button glass-button-sm rounded text-xs font-medium text-white/90 hover:text-white transition-colors disabled:opacity-50"
>
{{ creatingDid ? t('web5.creatingDid') : t('web5.createDid') }}
</button>
@ -70,17 +70,17 @@
<p v-else class="text-xs text-white/60">Not published</p>
</div>
</div>
<div v-if="dhtDid" class="flex gap-2">
<div v-if="dhtDid" class="flex gap-2 mt-auto">
<button
@click="copyDhtDid"
class="px-3 py-1.5 glass-button glass-button-sm rounded text-xs font-medium text-white/90 hover:text-white transition-colors"
class="flex-1 px-3 py-1.5 glass-button glass-button-sm rounded text-xs font-medium text-white/90 hover:text-white transition-colors"
>
{{ dhtDidCopied ? 'Copied!' : 'Copy' }}
</button>
<button
@click="refreshDhtDid"
:disabled="publishingDht"
class="px-3 py-1.5 glass-button glass-button-sm rounded text-xs font-medium text-white/90 hover:text-white transition-colors disabled:opacity-50"
class="flex-1 px-3 py-1.5 glass-button glass-button-sm rounded text-xs font-medium text-white/90 hover:text-white transition-colors disabled:opacity-50"
>
{{ publishingDht ? 'Refreshing...' : 'Refresh DHT' }}
</button>
@ -89,7 +89,7 @@
v-else-if="userDid"
@click="publishDhtDid"
:disabled="publishingDht"
class="w-fit px-3 py-1.5 glass-button glass-button-sm rounded text-xs font-medium text-white/90 hover:text-white transition-colors disabled:opacity-50"
class="w-full mt-auto px-3 py-1.5 glass-button glass-button-sm rounded text-xs font-medium text-white/90 hover:text-white transition-colors disabled:opacity-50"
>
{{ publishingDht ? 'Publishing...' : 'Publish to DHT' }}
</button>
@ -109,7 +109,7 @@
</div>
<button
@click="connectWallet"
class="w-fit px-3 py-1.5 glass-button glass-button-sm rounded text-xs font-medium text-white/90 hover:text-white transition-colors disabled:opacity-50"
class="w-full mt-auto px-3 py-1.5 glass-button glass-button-sm rounded text-xs font-medium text-white/90 hover:text-white transition-colors disabled:opacity-50"
:disabled="connectingWallet"
>
{{ connectingWallet ? t('common.connecting') : walletConnected ? t('common.disconnect') : t('common.connect') }}
@ -130,7 +130,7 @@
</div>
<button
@click="manageRelays"
class="w-fit px-3 py-1.5 glass-button glass-button-sm rounded text-xs font-medium text-white/90 hover:text-white transition-colors"
class="w-full mt-auto px-3 py-1.5 glass-button glass-button-sm rounded text-xs font-medium text-white/90 hover:text-white transition-colors"
>
{{ t('common.manage') }}
</button>
@ -148,18 +148,18 @@
<p class="text-xs text-white/60">{{ t('web5.peersKnown', { count: connectedNodesCount }) }}</p>
</div>
</div>
<div class="flex gap-2">
<div class="flex gap-2 mt-auto">
<button
@click="router.push('/dashboard/server/federation')"
class="px-3 py-1.5 glass-button glass-button-sm rounded text-xs font-medium text-white/90 hover:text-white transition-colors"
class="flex-1 px-3 py-1.5 glass-button glass-button-sm rounded text-xs font-medium text-white/90 hover:text-white transition-colors"
>
{{ t('web5.findNodes') }}
Nodes
</button>
<button
@click="showSendMessageModal = true"
class="px-3 py-1.5 glass-button glass-button-sm rounded text-xs font-medium text-white/90 hover:text-white transition-colors"
class="flex-1 px-3 py-1.5 glass-button glass-button-sm rounded text-xs font-medium text-white/90 hover:text-white transition-colors"
>
{{ t('web5.sendMessage') }}
Message
</button>
</div>
</div>
@ -167,7 +167,7 @@
</div>
<!-- Hardware Wallet Detected Banner -->
<div v-if="detectedHwWallets.length > 0" class="mb-6 p-4 bg-orange-500/10 border border-orange-500/20 rounded-xl flex items-center gap-3">
<div v-if="detectedHwWallets.length > 0" class="mb-6 alert-warning flex items-center gap-3">
<div class="w-8 h-8 rounded-lg bg-orange-500/20 flex items-center justify-center flex-shrink-0">
<svg class="w-5 h-5 text-orange-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 15v2m-6 4h12a2 2 0 002-2v-6a2 2 0 00-2-2H6a2 2 0 00-2 2v6a2 2 0 002 2zm10-10V7a4 4 0 00-8 0v4h8z" />
@ -413,6 +413,8 @@
</div>
</transition>
<div v-if="walletError" class="alert-error mb-3">{{ walletError }}</div>
<div class="space-y-3 flex-1 min-h-0">
<!-- On-chain Balance -->
<div class="flex items-center justify-between p-3 bg-white/5 rounded-lg">
@ -587,8 +589,11 @@
</div>
</div>
<!-- Connected Nodes + Shared Content grid -->
<div class="grid grid-cols-1 xl:grid-cols-2 gap-6 mb-8">
<!-- Connected Nodes (P2P over Tor) -->
<div ref="nodesContainerRef" data-controller-container tabindex="0" class="glass-card p-6 mb-8 scroll-mt-24">
<div ref="nodesContainerRef" data-controller-container tabindex="0" class="glass-card p-6 scroll-mt-24 flex flex-col">
<!-- Desktop: side-by-side layout -->
<div class="hidden md: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">
@ -758,11 +763,12 @@
</div>
</div>
<div class="mt-auto pt-4">
<button
v-if="nodesContainerTab === 'peers'"
@click="discoverAndAddPeers"
:disabled="discovering"
class="mt-4 w-full px-4 py-2 glass-button rounded-lg text-sm font-medium text-white/90 hover:text-white transition-colors disabled:opacity-50"
class="w-full px-4 py-2 glass-button rounded-lg text-sm font-medium text-white/90 hover:text-white transition-colors disabled:opacity-50"
>
{{ discovering ? t('web5.discovering') : t('web5.discoverNodes') }}
</button>
@ -770,7 +776,7 @@
v-else-if="nodesContainerTab === 'messages'"
@click="loadReceivedMessages"
:disabled="loadingMessages"
class="mt-4 w-full px-4 py-2 glass-button rounded-lg text-sm font-medium text-white/90 hover:text-white transition-colors disabled:opacity-50"
class="w-full px-4 py-2 glass-button rounded-lg text-sm font-medium text-white/90 hover:text-white transition-colors disabled:opacity-50"
>
{{ loadingMessages ? t('common.loading') : t('web5.refreshMessages') }}
</button>
@ -778,14 +784,15 @@
v-else
@click="loadConnectionRequests"
:disabled="loadingRequests"
class="mt-4 w-full px-4 py-2 glass-button rounded-lg text-sm font-medium text-white/90 hover:text-white transition-colors disabled:opacity-50"
class="w-full px-4 py-2 glass-button rounded-lg text-sm font-medium text-white/90 hover:text-white transition-colors disabled:opacity-50"
>
{{ loadingRequests ? t('common.loading') : t('web5.refreshRequests') }}
</button>
</div>
</div>
<!-- Shared Content -->
<div class="glass-card p-6 mb-8">
<div class="glass-card p-6">
<!-- Desktop: side-by-side -->
<div class="hidden md:flex items-center justify-between mb-4">
<div class="flex items-center gap-3">
@ -835,6 +842,29 @@
</div>
</div>
<!-- Browse Peer Selector -->
<div class="mb-4">
<div class="flex items-center gap-3">
<select
v-model="browsePeerOnion"
class="flex-1 px-3 py-2 rounded-lg bg-white/10 text-white text-sm border border-white/20 focus:border-orange-500 focus:ring-1 focus:ring-orange-500"
>
<option value="">{{ t('web5.selectPeer') }}</option>
<option v-for="p in peers" :key="p.pubkey" :value="p.onion">
{{ p.name || p.onion || (p.pubkey || '').slice(0, 12) + '...' }}
</option>
</select>
<button
@click="browsePeerContent"
:disabled="!browsePeerOnion || browsingPeerContent"
class="glass-button glass-button-sm px-4 py-2 rounded-lg text-sm font-medium disabled:opacity-50"
>
{{ browsingPeerContent ? t('common.loading') : t('web5.browse') }}
</button>
</div>
<p v-if="browsePeerError" class="text-xs text-red-400 mt-2">{{ browsePeerError }}</p>
</div>
<!-- Tabs: My Content | Browse Peers -->
<div class="flex gap-1 mb-4 border-b border-white/10">
<button
@ -941,29 +971,6 @@
<!-- Browse Peers tab -->
<div v-show="contentTab === 'browse'">
<!-- Peer Selector -->
<div class="mb-4">
<div class="flex items-center gap-3">
<select
v-model="browsePeerOnion"
class="flex-1 px-3 py-2 rounded-lg bg-white/10 text-white text-sm border border-white/20 focus:border-orange-500 focus:ring-1 focus:ring-orange-500"
>
<option value="">{{ t('web5.selectPeer') }}</option>
<option v-for="p in peers" :key="p.pubkey" :value="p.onion">
{{ p.name || p.onion || (p.pubkey || '').slice(0, 12) + '...' }}
</option>
</select>
<button
@click="browsePeerContent"
:disabled="!browsePeerOnion || browsingPeerContent"
class="glass-button glass-button-sm px-4 py-2 rounded-lg text-sm font-medium disabled:opacity-50"
>
{{ browsingPeerContent ? t('common.loading') : t('web5.browse') }}
</button>
</div>
<p v-if="browsePeerError" class="text-xs text-red-400 mt-2">{{ browsePeerError }}</p>
</div>
<!-- Peer Content Loading -->
<div v-if="browsingPeerContent" class="py-4 text-center">
<svg class="animate-spin h-6 w-6 text-blue-400 mx-auto mb-2" fill="none" viewBox="0 0 24 24">
@ -1038,6 +1045,8 @@
</div>
</div>
</div> <!-- end Connected Nodes + Shared Content grid -->
<!-- Content Streaming Player -->
<Teleport to="body">
<div v-if="streamingItem" class="fixed inset-0 z-50 flex items-center justify-center p-4 bg-black/60 backdrop-blur-md" @click.self="closePlayer" @keydown.escape="closePlayer">
@ -1087,8 +1096,8 @@
</div>
<!-- Player Error -->
<div v-if="playerError" class="mt-3 p-3 bg-red-500/10 border border-red-500/20 rounded-lg">
<p class="text-red-400 text-sm">{{ playerError }}</p>
<div v-if="playerError" class="mt-3 alert-error">
<p>{{ playerError }}</p>
<p class="text-white/50 text-xs mt-1">This may be a Tor-only resource. Copy the URL to use with a Tor-enabled media player.</p>
</div>
@ -1118,15 +1127,15 @@
<div class="space-y-4">
<div>
<label class="text-white/60 text-sm block mb-1">Filename</label>
<input v-model="newContentFilename" type="text" placeholder="my-file.mp3" class="w-full bg-white/5 border border-white/10 rounded-lg px-3 py-2 text-white text-sm focus:outline-none focus:border-white/30" />
<input v-model="newContentFilename" type="text" placeholder="my-file.mp3" class="w-full input-glass" />
</div>
<div>
<label class="text-white/60 text-sm block mb-1">MIME Type</label>
<input v-model="newContentMimeType" type="text" placeholder="audio/mpeg" class="w-full bg-white/5 border border-white/10 rounded-lg px-3 py-2 text-white text-sm focus:outline-none focus:border-white/30" />
<input v-model="newContentMimeType" type="text" placeholder="audio/mpeg" class="w-full input-glass" />
</div>
<div>
<label class="text-white/60 text-sm block mb-1">Description (optional)</label>
<input v-model="newContentDescription" type="text" placeholder="A short description" class="w-full bg-white/5 border border-white/10 rounded-lg px-3 py-2 text-white text-sm focus:outline-none focus:border-white/30" />
<input v-model="newContentDescription" type="text" placeholder="A short description" class="w-full input-glass" />
</div>
<div>
<label class="text-white/60 text-sm block mb-2">Access</label>
@ -1144,16 +1153,16 @@
</div>
<div v-if="newContentAccess === 'paid'">
<label class="text-white/60 text-sm block mb-1">Price (sats)</label>
<input v-model.number="newContentPrice" type="number" min="1" placeholder="100" class="w-full bg-white/5 border border-white/10 rounded-lg px-3 py-2 text-white text-sm focus:outline-none focus:border-white/30" />
<input v-model.number="newContentPrice" type="number" min="1" placeholder="100" class="w-full input-glass" />
<p v-if="newContentPrice > 0" class="text-xs text-orange-400/80 mt-1">Peers will pay {{ newContentPrice }} sats to access this</p>
</div>
</div>
<div v-if="addContentError" class="mt-3 p-2 bg-red-500/20 border border-red-500/30 rounded-lg">
<p class="text-red-300 text-xs">{{ addContentError }}</p>
<div v-if="addContentError" class="mt-3 alert-error">
<p class="text-xs">{{ addContentError }}</p>
</div>
<div class="flex gap-3 mt-6">
<button @click="showAddContentModal = false; addContentError = ''" class="flex-1 glass-button px-4 py-2 rounded-lg text-sm">{{ t('common.cancel') }}</button>
<button @click="addContentItem" :disabled="addingContent || !newContentFilename.trim()" class="flex-1 glass-button px-4 py-2 rounded-lg text-sm font-medium bg-orange-500/20 border-orange-500/30 disabled:opacity-50">
<button @click="addContentItem" :disabled="addingContent || !newContentFilename.trim()" class="flex-1 glass-button glass-button-warning px-4 py-2 rounded-lg text-sm font-medium disabled:opacity-50">
{{ addingContent ? 'Adding...' : 'Add' }}
</button>
</div>
@ -1161,8 +1170,11 @@
</div>
</Teleport>
<!-- Identities + DWN grid -->
<div class="grid grid-cols-1 xl:grid-cols-2 gap-6 mb-8">
<!-- Identity Management -->
<div class="glass-card p-6 mb-8">
<div class="glass-card p-6">
<!-- Desktop: side-by-side -->
<div class="hidden md:flex items-center justify-between mb-4">
<div class="flex items-center gap-3">
@ -1302,7 +1314,7 @@
<div class="space-y-4">
<div>
<label class="text-white/60 text-sm block mb-1">Name</label>
<input v-model="newIdentityName" type="text" placeholder="Personal" class="w-full bg-white/5 border border-white/10 rounded-lg px-3 py-2 text-white text-sm focus:outline-none focus:border-white/30" />
<input v-model="newIdentityName" type="text" placeholder="Personal" class="w-full input-glass" />
</div>
<div>
<label class="text-white/60 text-sm block mb-1">Purpose</label>
@ -1317,8 +1329,8 @@
</div>
</div>
</div>
<div v-if="createIdentityError" class="mt-3 p-2 bg-red-500/20 border border-red-500/30 rounded-lg">
<p class="text-red-300 text-xs">{{ createIdentityError }}</p>
<div v-if="createIdentityError" class="mt-3 alert-error">
<p class="text-xs">{{ createIdentityError }}</p>
</div>
<div class="flex gap-3 mt-6">
<button @click="showCreateIdentityModal = false" class="flex-1 glass-button px-4 py-2 rounded-lg text-sm">{{ t('common.cancel') }}</button>
@ -1338,7 +1350,7 @@
<p class="text-white/60 text-sm mb-4">{{ t('web5.deleteIdentityConfirm') }}</p>
<div class="flex gap-3">
<button @click="deleteIdentityTarget = null" class="flex-1 glass-button px-4 py-2 rounded-lg text-sm">{{ t('common.cancel') }}</button>
<button @click="deleteIdentity" :disabled="deletingIdentity" class="flex-1 glass-button px-4 py-2 rounded-lg text-sm font-medium bg-red-500/20 border-red-500/30">
<button @click="deleteIdentity" :disabled="deletingIdentity" class="flex-1 glass-button glass-button-danger px-4 py-2 rounded-lg text-sm font-medium">
{{ deletingIdentity ? t('web5.deleting') : t('common.delete') }}
</button>
</div>
@ -1430,7 +1442,7 @@
v-model="keyViewerPassword"
type="password"
placeholder="Password"
class="flex-1 bg-white/5 border border-white/10 rounded-lg px-3 py-2 text-white text-sm focus:outline-none focus:border-white/30"
class="flex-1 input-glass"
@keydown.enter="unlockPrivateKeys"
/>
<button
@ -1507,41 +1519,41 @@
<div class="space-y-3">
<div>
<label class="text-white/60 text-xs block mb-1">Display Name</label>
<input v-model="profileForm.display_name" type="text" :placeholder="profileEditorIdentity.name" class="w-full bg-white/5 border border-white/10 rounded-lg px-3 py-2 text-white text-sm focus:outline-none focus:border-white/30" />
<input v-model="profileForm.display_name" type="text" :placeholder="profileEditorIdentity.name" class="w-full input-glass" />
</div>
<div>
<label class="text-white/60 text-xs block mb-1">About / Bio</label>
<textarea v-model="profileForm.about" rows="3" placeholder="A short bio..." class="w-full bg-white/5 border border-white/10 rounded-lg px-3 py-2 text-white text-sm focus:outline-none focus:border-white/30 resize-none"></textarea>
<textarea v-model="profileForm.about" rows="3" placeholder="A short bio..." class="w-full input-glass resize-none"></textarea>
</div>
<div>
<label class="text-white/60 text-xs block mb-1">Profile Picture URL</label>
<input v-model="profileForm.picture" type="url" placeholder="https://..." class="w-full bg-white/5 border border-white/10 rounded-lg px-3 py-2 text-white text-sm focus:outline-none focus:border-white/30" />
<input v-model="profileForm.picture" type="url" placeholder="https://..." class="w-full input-glass" />
</div>
<div>
<label class="text-white/60 text-xs block mb-1">Banner Image URL</label>
<input v-model="profileForm.banner" type="url" placeholder="https://..." class="w-full bg-white/5 border border-white/10 rounded-lg px-3 py-2 text-white text-sm focus:outline-none focus:border-white/30" />
<input v-model="profileForm.banner" type="url" placeholder="https://..." class="w-full input-glass" />
</div>
<div>
<label class="text-white/60 text-xs block mb-1">Website</label>
<input v-model="profileForm.website" type="url" placeholder="https://..." class="w-full bg-white/5 border border-white/10 rounded-lg px-3 py-2 text-white text-sm focus:outline-none focus:border-white/30" />
<input v-model="profileForm.website" type="url" placeholder="https://..." class="w-full input-glass" />
</div>
<div class="grid grid-cols-2 gap-3">
<div>
<label class="text-white/60 text-xs block mb-1">NIP-05 (Nostr address)</label>
<input v-model="profileForm.nip05" type="text" placeholder="you@domain.com" class="w-full bg-white/5 border border-white/10 rounded-lg px-3 py-2 text-white text-sm focus:outline-none focus:border-white/30" />
<input v-model="profileForm.nip05" type="text" placeholder="you@domain.com" class="w-full input-glass" />
</div>
<div>
<label class="text-white/60 text-xs block mb-1">Lightning Address (LUD-16)</label>
<input v-model="profileForm.lud16" type="text" placeholder="you@getalby.com" class="w-full bg-white/5 border border-white/10 rounded-lg px-3 py-2 text-white text-sm focus:outline-none focus:border-white/30" />
<input v-model="profileForm.lud16" type="text" placeholder="you@getalby.com" class="w-full input-glass" />
</div>
</div>
</div>
<div v-if="profileError" class="mt-3 p-2 bg-red-500/20 border border-red-500/30 rounded-lg">
<p class="text-red-300 text-xs">{{ profileError }}</p>
<div v-if="profileError" class="mt-3 alert-error">
<p class="text-xs">{{ profileError }}</p>
</div>
<div v-if="profileSuccess" class="mt-3 p-2 bg-green-500/20 border border-green-500/30 rounded-lg">
<p class="text-green-300 text-xs">{{ profileSuccess }}</p>
<div v-if="profileSuccess" class="mt-3 alert-success">
<p class="text-xs">{{ profileSuccess }}</p>
</div>
<div class="flex gap-3 mt-5">
@ -1549,7 +1561,7 @@
<button @click="saveProfile" :disabled="profileSaving" class="flex-1 glass-button px-4 py-2 rounded-lg text-sm font-medium">
{{ profileSaving ? 'Saving...' : 'Save' }}
</button>
<button @click="publishProfile" :disabled="profilePublishing" class="flex-1 glass-button px-4 py-2 rounded-lg text-sm font-medium bg-orange-500/20 border-orange-500/30">
<button @click="publishProfile" :disabled="profilePublishing" class="flex-1 glass-button glass-button-warning px-4 py-2 rounded-lg text-sm font-medium">
{{ profilePublishing ? 'Publishing...' : 'Save & Publish' }}
</button>
</div>
@ -1582,7 +1594,7 @@
<!-- Amount -->
<div class="mb-3">
<label class="text-white/60 text-sm block mb-1">Amount (sats)</label>
<input v-model.number="unifiedSendAmount" type="number" min="1" placeholder="1000" class="w-full bg-white/5 border border-white/10 rounded-lg px-3 py-2 text-white text-sm focus:outline-none focus:border-white/30" />
<input v-model.number="unifiedSendAmount" type="number" min="1" placeholder="1000" class="w-full input-glass" />
</div>
<!-- Destination (varies by method) -->
@ -1590,7 +1602,7 @@
<label class="text-white/60 text-sm block mb-1">
{{ effectiveSendMethod === 'lightning' ? 'Lightning Invoice (BOLT11)' : 'Bitcoin Address' }}
</label>
<textarea v-model="unifiedSendDest" rows="2" :placeholder="effectiveSendMethod === 'lightning' ? 'lnbc...' : 'bc1...'" class="w-full bg-white/5 border border-white/10 rounded-lg px-3 py-2 text-white text-sm font-mono focus:outline-none focus:border-white/30"></textarea>
<textarea v-model="unifiedSendDest" rows="2" :placeholder="effectiveSendMethod === 'lightning' ? 'lnbc...' : 'bc1...'" class="w-full input-glass font-mono"></textarea>
</div>
<!-- Ecash token output -->
@ -1635,7 +1647,7 @@
</div>
<!-- Mesh Relay Prompt shown when offline -->
<div v-if="showMeshRelayPrompt" class="mb-3 p-4 bg-orange-500/10 border border-orange-500/30 rounded-lg">
<div v-if="showMeshRelayPrompt" class="mb-3 alert-warning">
<div class="flex items-center gap-2 mb-2">
<span class="text-lg">&#x1F4E1;</span>
<p class="text-orange-300 text-sm font-medium">You are offline</p>
@ -1643,12 +1655,12 @@
<p class="text-white/70 text-xs mb-3">Send this transaction via mesh radio? It will be relayed by the nearest internet-connected node and you'll receive confirmation updates.</p>
<div class="flex gap-2">
<button @click="dismissMeshRelayPrompt" class="flex-1 glass-button px-3 py-2 rounded-lg text-xs">Cancel</button>
<button @click="handleMeshRelaySend" class="flex-1 glass-button px-3 py-2 rounded-lg text-xs font-medium bg-orange-500/20 border-orange-500/30">Send via Mesh</button>
<button @click="handleMeshRelaySend" class="flex-1 glass-button glass-button-warning px-3 py-2 rounded-lg text-xs font-medium">Send via Mesh</button>
</div>
</div>
<!-- Mesh Relay Status -->
<div v-if="meshRelayActive" class="mb-3 p-3 bg-orange-500/10 border border-orange-500/20 rounded-lg">
<div v-if="meshRelayActive" class="mb-3 alert-warning">
<div class="flex items-center gap-2 mb-1">
<svg class="animate-spin h-3 w-3 text-orange-400" fill="none" viewBox="0 0 24 24">
<circle class="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" stroke-width="4"></circle>
@ -1660,23 +1672,23 @@
</div>
<!-- On-chain txid result -->
<div v-if="sendResultTxid" class="mb-3 p-2 bg-green-500/10 border border-green-500/20 rounded-lg">
<p class="text-green-400 text-xs">Sent! TX: {{ sendResultTxid }}</p>
<div v-if="sendResultTxid" class="mb-3 alert-success">
<p class="text-xs">Sent! TX: {{ sendResultTxid }}</p>
</div>
<!-- Lightning payment result -->
<div v-if="sendResultHash" class="mb-3 p-2 bg-green-500/10 border border-green-500/20 rounded-lg">
<p class="text-green-400 text-xs">Paid! Hash: {{ sendResultHash }}</p>
<div v-if="sendResultHash" class="mb-3 alert-success">
<p class="text-xs">Paid! Hash: {{ sendResultHash }}</p>
</div>
<div v-if="unifiedSendError" class="mb-3 text-xs text-red-400">{{ unifiedSendError }}</div>
<div class="flex gap-3">
<button @click="closeUnifiedSendModal" class="flex-1 glass-button px-4 py-2 rounded-lg text-sm">{{ t('common.close') }}</button>
<button v-if="psbtStep === 'created'" @click="finalizePsbt" :disabled="unifiedSendProcessing || !signedPsbtInput.trim()" class="flex-1 glass-button px-4 py-2 rounded-lg text-sm font-medium bg-orange-500/20 border-orange-500/30 disabled:opacity-50">
<button v-if="psbtStep === 'created'" @click="finalizePsbt" :disabled="unifiedSendProcessing || !signedPsbtInput.trim()" class="flex-1 glass-button glass-button-warning px-4 py-2 rounded-lg text-sm font-medium disabled:opacity-50">
{{ unifiedSendProcessing ? 'Broadcasting...' : 'Broadcast' }}
</button>
<button v-else @click="unifiedSend" :disabled="unifiedSendProcessing || !unifiedSendAmount" class="flex-1 glass-button px-4 py-2 rounded-lg text-sm font-medium bg-orange-500/20 border-orange-500/30 disabled:opacity-50">
<button v-else @click="unifiedSend" :disabled="unifiedSendProcessing || !unifiedSendAmount" class="flex-1 glass-button glass-button-warning px-4 py-2 rounded-lg text-sm font-medium disabled:opacity-50">
{{ unifiedSendProcessing ? 'Sending...' : (useHardwareWallet && effectiveSendMethod === 'onchain' ? 'Create PSBT' : 'Send') }}
</button>
</div>
@ -1705,11 +1717,11 @@
<div v-if="receiveMethod === 'lightning'">
<div class="mb-3">
<label class="text-white/60 text-sm block mb-1">Amount (sats)</label>
<input v-model.number="receiveInvoiceAmount" type="number" min="1" placeholder="1000" class="w-full bg-white/5 border border-white/10 rounded-lg px-3 py-2 text-white text-sm focus:outline-none focus:border-white/30" />
<input v-model.number="receiveInvoiceAmount" type="number" min="1" placeholder="1000" class="w-full input-glass" />
</div>
<div class="mb-3">
<label class="text-white/60 text-sm block mb-1">Memo (optional)</label>
<input v-model="receiveInvoiceMemo" type="text" placeholder="Payment for..." class="w-full bg-white/5 border border-white/10 rounded-lg px-3 py-2 text-white text-sm focus:outline-none focus:border-white/30" />
<input v-model="receiveInvoiceMemo" type="text" placeholder="Payment for..." class="w-full input-glass" />
</div>
<div v-if="receiveInvoiceResult" class="mb-3 p-2 bg-white/5 rounded-lg">
<p class="text-white/50 text-xs mb-1">Invoice (share with sender):</p>
@ -1735,7 +1747,7 @@
<div v-if="receiveMethod === 'ecash'">
<div class="mb-3">
<label class="text-white/60 text-sm block mb-1">Paste ecash token</label>
<textarea v-model="ecashReceiveToken" rows="3" placeholder="cashuSend_..." class="w-full bg-white/5 border border-white/10 rounded-lg px-3 py-2 text-white text-sm focus:outline-none focus:border-white/30"></textarea>
<textarea v-model="ecashReceiveToken" rows="3" placeholder="cashuSend_..." class="w-full input-glass"></textarea>
</div>
<div v-if="ecashReceiveResult" class="mb-3 text-xs text-green-400">{{ ecashReceiveResult }}</div>
</div>
@ -1744,7 +1756,7 @@
<div class="flex gap-3">
<button @click="closeUnifiedReceiveModal" class="flex-1 glass-button px-4 py-2 rounded-lg text-sm">{{ t('common.close') }}</button>
<button @click="unifiedReceive" :disabled="unifiedReceiveProcessing" class="flex-1 glass-button px-4 py-2 rounded-lg text-sm font-medium bg-green-500/20 border-green-500/30 disabled:opacity-50">
<button @click="unifiedReceive" :disabled="unifiedReceiveProcessing" class="flex-1 glass-button glass-button-success px-4 py-2 rounded-lg text-sm font-medium disabled:opacity-50">
{{ unifiedReceiveProcessing ? 'Processing...' : receiveMethod === 'onchain' ? 'Generate Address' : receiveMethod === 'lightning' ? 'Create Invoice' : 'Receive' }}
</button>
</div>
@ -1753,7 +1765,7 @@
</Teleport>
<!-- Decentralized Web Node (DWN) -->
<div class="glass-card p-6 mb-8">
<div class="glass-card p-6">
<div class="flex items-center justify-between mb-4">
<div class="flex items-center gap-3">
<div class="flex-shrink-0 w-10 h-10 rounded-lg bg-white/10 flex items-center justify-center">
@ -1907,6 +1919,8 @@
</template>
</div>
</div> <!-- end Identities + DWN grid -->
<!-- Verifiable Credentials -->
<div class="glass-card p-6 mb-8">
<!-- Desktop: side-by-side -->
@ -2018,16 +2032,16 @@
<div class="grid grid-cols-2 gap-3 mb-3">
<div>
<label class="text-white/60 text-xs block mb-1">Username</label>
<input v-model="newDomainName" type="text" placeholder="satoshi" class="w-full bg-white/5 border border-white/10 rounded-lg px-3 py-2 text-white text-sm focus:outline-none focus:border-white/30" />
<input v-model="newDomainName" type="text" placeholder="satoshi" class="w-full input-glass" />
</div>
<div>
<label class="text-white/60 text-xs block mb-1">Domain</label>
<input v-model="newDomainDomain" type="text" placeholder="example.com" class="w-full bg-white/5 border border-white/10 rounded-lg px-3 py-2 text-white text-sm focus:outline-none focus:border-white/30" />
<input v-model="newDomainDomain" type="text" placeholder="example.com" class="w-full input-glass" />
</div>
</div>
<div class="mb-3">
<label class="text-white/60 text-xs block mb-1">Link to Identity</label>
<select v-model="newDomainIdentityId" class="w-full bg-white/5 border border-white/10 rounded-lg px-3 py-2 text-white text-sm focus:outline-none focus:border-white/30">
<select v-model="newDomainIdentityId" class="w-full input-glass">
<option value="" disabled>Select identity...</option>
<option v-for="id in managedIdentities" :key="id.id" :value="id.id">{{ id.name }} ({{ (id.did || '').slice(0, 24) }}...)</option>
</select>
@ -2042,7 +2056,7 @@
<div class="border-t border-white/10 pt-4 mt-4">
<h3 class="text-sm font-semibold text-white mb-3">{{ t('web5.verifyNip05') }}</h3>
<div class="flex gap-2">
<input v-model="verifyNip05Input" type="text" placeholder="user@domain.com" class="flex-1 bg-white/5 border border-white/10 rounded-lg px-3 py-2 text-white text-sm focus:outline-none focus:border-white/30" />
<input v-model="verifyNip05Input" type="text" placeholder="user@domain.com" class="flex-1 input-glass" />
<button @click="verifyNip05" :disabled="nip05Verifying || !verifyNip05Input.trim()" class="glass-button px-4 py-2 rounded-lg text-sm font-medium disabled:opacity-50">
{{ nip05Verifying ? '...' : 'Verify' }}
</button>
@ -2093,7 +2107,7 @@
<div class="border-t border-white/10 pt-4">
<h3 class="text-sm font-semibold text-white mb-3">{{ t('web5.addRelay') }}</h3>
<div class="flex gap-2">
<input v-model="newRelayUrl" type="text" :placeholder="t('web5.relayUrlPlaceholder')" class="flex-1 bg-white/5 border border-white/10 rounded-lg px-3 py-2 text-white text-sm focus:outline-none focus:border-white/30" @keyup.enter="addNostrRelay" />
<input v-model="newRelayUrl" type="text" :placeholder="t('web5.relayUrlPlaceholder')" class="flex-1 input-glass" @keyup.enter="addNostrRelay" />
<button @click="addNostrRelay" :disabled="!newRelayUrl.trim()" class="glass-button px-4 py-2 rounded-lg text-sm font-medium disabled:opacity-50">
Add
</button>
@ -2541,6 +2555,7 @@ const walletConnected = ref(false)
const connectingWallet = ref(false)
const lndOnchainBalance = ref(0)
const lndChannelBalance = ref(0)
const walletError = ref('')
// Incoming Transactions
interface WalletTransaction {
@ -2567,13 +2582,15 @@ async function loadTransactions() {
try {
const res = await rpcClient.call<{ transactions: WalletTransaction[]; incoming_pending_count: number }>({ method: 'lnd.gettransactions' })
walletTransactions.value = res.transactions || []
walletError.value = ''
// Auto-show panel when new unconfirmed incoming txs appear
const pending = res.incoming_pending_count || 0
if (pending > 0 && !showIncomingTxPanel.value) {
showIncomingTxPanel.value = true
}
} catch {
} catch (e) {
walletTransactions.value = []
walletError.value = e instanceof Error ? e.message : 'Failed to load transactions'
}
}
@ -3876,10 +3893,12 @@ async function loadLndBalances() {
lndOnchainBalance.value = res.balance_sats || 0
lndChannelBalance.value = res.channel_balance_sats || 0
walletConnected.value = true
} catch {
walletError.value = ''
} catch (e) {
walletConnected.value = false
lndOnchainBalance.value = 0
lndChannelBalance.value = 0
walletError.value = e instanceof Error ? e.message : 'Failed to load wallet balances'
}
}

View File

@ -130,7 +130,7 @@
v-model="openForm.peerUri"
type="text"
placeholder="pubkey@host:port"
class="w-full bg-white/5 border border-white/10 rounded-lg px-3 py-2 text-white text-sm focus:outline-none focus:border-white/30"
class="w-full input-glass"
/>
<p class="text-white/40 text-xs mt-1">Format: pubkey@host:port</p>
</div>
@ -141,14 +141,14 @@
type="number"
min="20000"
placeholder="100000"
class="w-full bg-white/5 border border-white/10 rounded-lg px-3 py-2 text-white text-sm focus:outline-none focus:border-white/30"
class="w-full input-glass"
/>
<p class="text-white/40 text-xs mt-1">Minimum 20,000 sats</p>
</div>
</div>
<div v-if="openError" class="mt-3 p-2 bg-red-500/20 border border-red-500/30 rounded-lg">
<p class="text-red-300 text-xs">{{ openError }}</p>
<div v-if="openError" class="mt-3 alert-error">
<p class="text-xs">{{ openError }}</p>
</div>
<div class="flex gap-3 mt-6">
@ -169,15 +169,15 @@
<div class="glass-card p-6 w-full max-w-sm mx-4">
<h2 class="text-lg font-bold text-white mb-2">Close Channel?</h2>
<p class="text-white/60 text-sm mb-4">This will cooperatively close the channel with peer {{ closeTarget.remote_pubkey.slice(0, 16) }}...</p>
<div v-if="closeError" class="mb-3 p-2 bg-red-500/20 border border-red-500/30 rounded-lg">
<p class="text-red-300 text-xs">{{ closeError }}</p>
<div v-if="closeError" class="mb-3 alert-error">
<p class="text-xs">{{ closeError }}</p>
</div>
<div class="flex gap-3">
<button @click="closeTarget = null" class="flex-1 glass-button px-4 py-2 rounded-lg text-sm">Cancel</button>
<button
@click="closeChannel"
:disabled="closingChannel"
class="flex-1 glass-button px-4 py-2 rounded-lg text-sm font-medium bg-red-500/20 border-red-500/30"
class="flex-1 glass-button glass-button-danger px-4 py-2 rounded-lg text-sm font-medium"
>
{{ closingChannel ? 'Closing...' : 'Close' }}
</button>

View File

@ -1192,16 +1192,20 @@ lines = ["SocksPort 9050", "ControlPort 0", ""]
try:
with open("/var/lib/archipelago/tor/services.json") as f:
cfg = json.load(f)
extra_ports = {"lnd": [8080]} # LND REST API over Tor
for svc in cfg.get("services", []):
if svc.get("enabled", True):
n = svc["name"]
p = svc["local_port"]
lines.append("HiddenServiceDir /var/lib/tor/hidden_service_%s" % n)
lines.append("HiddenServicePort %d 127.0.0.1:%d" % (p, p))
for ep in extra_ports.get(n, []):
lines.append("HiddenServicePort %d 127.0.0.1:%d" % (ep, ep))
lines.append("")
except Exception:
for n, p in [("archipelago",80),("bitcoin",8333),("electrumx",50001),("lnd",9735),("btcpay",23000),("mempool",4080),("fedimint",8175)]:
for n, ports in [("archipelago",[80]),("bitcoin",[8333]),("electrumx",[50001]),("lnd",[9735,8080]),("btcpay",[23000]),("mempool",[4080]),("fedimint",[8175])]:
lines.append("HiddenServiceDir /var/lib/tor/hidden_service_%s" % n)
for p in ports:
lines.append("HiddenServicePort %d 127.0.0.1:%d" % (p, p))
lines.append("")
with open("/etc/tor/torrc", "w") as f:

View File

@ -156,24 +156,38 @@ case $choice in
fi
if [ -z "$RUNTIME" ]; then
echo ""
echo "No working container runtime detected."
echo ""
if command -v podman &>/dev/null; then
echo "Podman is installed but the machine isn't running:"
echo " podman machine start"
echo " Podman machine not running — starting it..."
if ! podman machine ls --format '{{.Name}}' 2>/dev/null | grep -q .; then
echo " No Podman machine found — initializing..."
podman machine init
fi
podman machine start
if podman ps &>/dev/null; then
if command -v podman-compose &>/dev/null; then
RUNTIME="podman"
COMPOSE="podman-compose"
else
RUNTIME="podman"
COMPOSE="podman compose"
fi
else
echo " Failed to start Podman machine."
exit 1
fi
elif command -v docker &>/dev/null; then
echo ""
echo "Docker is installed but the daemon isn't running."
echo "Start Docker Desktop and try again."
else
echo "Install Docker Desktop or Podman:"
echo " brew install --cask docker"
echo " # or"
echo " brew install podman podman-compose"
echo " podman machine init && podman machine start"
fi
echo ""
exit 1
else
echo ""
echo "No container runtime found. Install one:"
echo " brew install podman podman-compose"
echo " # or"
echo " brew install --cask docker"
exit 1
fi
fi
echo " Using: $RUNTIME"

View File

@ -109,9 +109,97 @@ else
log "Swap already configured"
fi
# Rootless podman prerequisites (run as root, configures for archipelago user)
log "Setting up rootless podman prerequisites..."
# Allow binding to ports >= 80 (rootless default is 1024)
if ! grep -q "unprivileged_port_start=80" /etc/sysctl.d/99-rootless-podman.conf 2>/dev/null; then
echo "net.ipv4.ip_unprivileged_port_start=80" > /etc/sysctl.d/99-rootless-podman.conf
sysctl -p /etc/sysctl.d/99-rootless-podman.conf 2>/dev/null
log " Rootless port binding enabled (>=80)"
fi
# Linger for container persistence after logout
if [ "$(loginctl show-user archipelago 2>/dev/null | grep Linger)" != "Linger=yes" ]; then
loginctl enable-linger archipelago 2>/dev/null
log " Linger enabled for archipelago user"
fi
# Ensure subuid/subgid mappings
grep -q "^archipelago:" /etc/subuid 2>/dev/null || {
echo "archipelago:100000:65536" >> /etc/subuid
echo "archipelago:100000:65536" >> /etc/subgid
log " subuid/subgid configured"
}
# Ensure /etc/hosts is readable (rootless podman needs it)
chmod 644 /etc/hosts 2>/dev/null
# Ensure network exists (matches deploy)
$DOCKER network create archy-net 2>/dev/null || true
# Rootless podman UID mapping: fix data dir ownership so container processes
# can write. Rootless podman maps container UIDs via subuid (container UID N
# → host UID 100000+N). Must run BEFORE container creation.
log "Fixing rootless podman UID mapping..."
# Containers running as root (UID 0 → host UID 100000)
for dir in lnd electrumx btcpay nbxplorer immich jellyfin vaultwarden \
home-assistant fedimint fedimint-gateway photoprism ollama filebrowser \
nextcloud uptime-kuma onlyoffice nginx-proxy-manager portainer nostr-rs-relay; do
[ -d "/var/lib/archipelago/$dir" ] && chown -R 100000:100000 "/var/lib/archipelago/$dir" 2>/dev/null
done
# Bitcoin Knots: container UID 101 → host UID 100101
[ -d /var/lib/archipelago/bitcoin ] && chown -R 100101:100101 /var/lib/archipelago/bitcoin 2>/dev/null
# Postgres: container UID 70 → host UID 100070
for dir in postgres-btcpay immich-db penpot-postgres; do
[ -d "/var/lib/archipelago/$dir" ] && chown -R 100070:100070 "/var/lib/archipelago/$dir" 2>/dev/null
done
# MariaDB: container UID 999 → host UID 100999
for dir in mempool mysql-mempool; do
[ -d "/var/lib/archipelago/$dir" ] && chown -R 100999:100999 "/var/lib/archipelago/$dir" 2>/dev/null
done
# Grafana: container UID 472 → host UID 100472
[ -d /var/lib/archipelago/grafana ] && chown -R 100472:100472 /var/lib/archipelago/grafana 2>/dev/null
log "UID mapping done"
# ── Memory limits per container ──────────────────────────────────────────
# Matches core/archipelago/src/api/rpc/package.rs get_memory_limit()
# Prevents a single runaway container from OOMing the whole system.
TOTAL_MEM_MB=$(($(awk '/MemTotal/{print $2}' /proc/meminfo) / 1024))
LOW_MEM=false
[ "$TOTAL_MEM_MB" -lt 12000 ] && LOW_MEM=true && log "Low-memory system (${TOTAL_MEM_MB}MB) — reducing limits"
mem_limit() {
case "$1" in
bitcoin-knots) $LOW_MEM && echo "1g" || echo "2g";;
onlyoffice) $LOW_MEM && echo "1g" || echo "2g";;
ollama) $LOW_MEM && echo "1g" || echo "4g";;
lnd) echo "512m";;
electrumx) echo "1g";;
nextcloud) echo "1g";;
immich_server) echo "1g";;
btcpay-server) echo "1g";;
homeassistant) echo "512m";;
fedimint) echo "512m";;
fedimint-gateway) echo "512m";;
photoprism) $LOW_MEM && echo "512m" || echo "1g";;
mempool-api) echo "512m";;
jellyfin) echo "1g";;
searxng) echo "512m";;
archy-btcpay-db) echo "512m";;
archy-nbxplorer) echo "512m";;
archy-mempool-db) echo "512m";;
archy-mempool-web) echo "256m";;
grafana) echo "256m";;
vaultwarden) echo "256m";;
uptime-kuma) echo "256m";;
filebrowser) echo "256m";;
portainer) echo "256m";;
nginx-proxy-manager) echo "256m";;
immich_postgres) echo "256m";;
immich_redis) echo "128m";;
tailscale) echo "256m";;
indeedhub|archy-bitcoin-ui|archy-lnd-ui|archy-electrs-ui) echo "128m";;
*) echo "512m";;
esac
}
# ── Tier 1: Databases & Core Infrastructure ──────────────────────────────
log "=== Tier 1: Databases & Core Infrastructure ==="
@ -130,14 +218,14 @@ if ! $DOCKER ps --format '{{.Names}}' 2>/dev/null | grep -qE 'bitcoin-knots|arch
BTC_DBCACHE=4096
log " Large disk (${DISK_GB}GB) — enabling txindex"
fi
if $DOCKER run -d --name bitcoin-knots --restart unless-stopped --network archy-net \
if $DOCKER run -d --name bitcoin-knots --restart unless-stopped --memory=$(mem_limit bitcoin-knots) --network archy-net \
--cap-drop ALL --cap-add CHOWN --cap-add FOWNER --cap-add SETUID --cap-add SETGID --cap-add DAC_OVERRIDE \
--security-opt no-new-privileges:true \
-p 8332:8332 -p 8333:8333 \
-v /var/lib/archipelago/bitcoin:/home/bitcoin/.bitcoin \
docker.io/bitcoinknots/bitcoin:latest \
-server=1 $BTC_EXTRA_ARGS \
-rpcallowip=127.0.0.1/32 -rpcallowip=10.88.0.0/16 -rpcbind=0.0.0.0:8332 \
-rpcallowip=0.0.0.0/0 -rpcbind=0.0.0.0:8332 \
-rpcuser=$BITCOIN_RPC_USER -rpcpassword=$BITCOIN_RPC_PASS \
-proxy=127.0.0.1:9050 -listen=1 -bind=0.0.0.0:8333 \
-dbcache=$BTC_DBCACHE 2>>"$LOG"; then
@ -163,7 +251,7 @@ fi
if ! $DOCKER ps -a --format '{{.Names}}' 2>/dev/null | grep -qE 'archy-mempool-db|mysql-mempool'; then
log "Creating mysql-mempool..."
mkdir -p /var/lib/archipelago/mysql-mempool
$DOCKER run -d --name archy-mempool-db --restart unless-stopped --network archy-net \
$DOCKER run -d --name archy-mempool-db --restart unless-stopped --memory=$(mem_limit archy-mempool-db) --network archy-net \
-v /var/lib/archipelago/mysql-mempool:/var/lib/mysql \
-e MYSQL_DATABASE=mempool -e MYSQL_USER=mempool -e MYSQL_PASSWORD=$MEMPOOL_DB_PASS \
-e MYSQL_ROOT_PASSWORD=$MYSQL_ROOT_PASS \
@ -180,7 +268,7 @@ if ! $DOCKER ps --format '{{.Names}}' 2>/dev/null | grep -q electrumx; then
else
log "Creating electrumx..."
mkdir -p /var/lib/archipelago/electrumx
$DOCKER run -d --name electrumx --restart unless-stopped --network archy-net \
$DOCKER run -d --name electrumx --restart unless-stopped --memory=$(mem_limit electrumx) --network archy-net \
-p 50001:50001 -v /var/lib/archipelago/electrumx:/data \
-e DAEMON_URL=http://$BITCOIN_RPC_USER:$BITCOIN_RPC_PASS@bitcoin-knots:8332/ \
-e COIN=Bitcoin -e DB_DIRECTORY=/data \
@ -192,7 +280,7 @@ fi
if ! $DOCKER ps --format '{{.Names}}' 2>/dev/null | grep -q mempool-api; then
log "Creating mempool-api..."
mkdir -p /var/lib/archipelago/mempool
$DOCKER run -d --name mempool-api --restart unless-stopped --network archy-net \
$DOCKER run -d --name mempool-api --restart unless-stopped --memory=$(mem_limit mempool-api) --network archy-net \
-p 8999:8999 -v /var/lib/archipelago/mempool:/data \
-e MEMPOOL_BACKEND=electrum -e ELECTRUM_HOST=electrumx -e ELECTRUM_PORT=50001 \
-e ELECTRUM_TLS_ENABLED=false -e CORE_RPC_HOST="$TARGET_IP" -e CORE_RPC_PORT=8332 \
@ -204,7 +292,7 @@ fi
if ! $DOCKER ps --format '{{.Names}}' 2>/dev/null | grep -qE 'archy-mempool-web|mempool-web'; then
log "Creating mempool frontend..."
$DOCKER run -d --name archy-mempool-web --restart unless-stopped --network archy-net \
$DOCKER run -d --name archy-mempool-web --restart unless-stopped --memory=$(mem_limit archy-mempool-web) --network archy-net \
-p 4080:8080 -e FRONTEND_HTTP_PORT=8080 -e BACKEND_MAINNET_HTTP_HOST=mempool-api \
docker.io/mempool/frontend:v2.5.0 2>>"$LOG" || true
fi
@ -231,7 +319,7 @@ fi
if ! $DOCKER ps -a --format '{{.Names}}' 2>/dev/null | grep -qE 'archy-btcpay-db|postgres-btcpay'; then
log "Creating PostgreSQL for BTCPay..."
mkdir -p /var/lib/archipelago/postgres-btcpay
$DOCKER run -d --name archy-btcpay-db --restart unless-stopped --network archy-net \
$DOCKER run -d --name archy-btcpay-db --restart unless-stopped --memory=$(mem_limit archy-btcpay-db) --network archy-net \
-v /var/lib/archipelago/postgres-btcpay:/var/lib/postgresql/data \
-e POSTGRES_DB=btcpay -e POSTGRES_USER=btcpay -e POSTGRES_PASSWORD=$BTCPAY_DB_PASS \
docker.io/postgres:15-alpine 2>>"$LOG" || true
@ -249,7 +337,7 @@ if ! $DOCKER ps --format '{{.Names}}' 2>/dev/null | grep -q archy-nbxplorer; the
else
log "Creating NBXplorer..."
mkdir -p /var/lib/archipelago/nbxplorer
$DOCKER run -d --name archy-nbxplorer --restart unless-stopped --network archy-net \
$DOCKER run -d --name archy-nbxplorer --restart unless-stopped --memory=$(mem_limit archy-nbxplorer) --network archy-net \
-p 32838:32838 -v /var/lib/archipelago/nbxplorer:/data \
-e NBXPLORER_DATADIR=/data -e NBXPLORER_NETWORK=mainnet -e NBXPLORER_CHAINS=btc \
-e NBXPLORER_BIND=0.0.0.0:32838 -e NBXPLORER_BTCRPCURL=http://bitcoin-knots:8332 \
@ -262,7 +350,7 @@ fi
if ! $DOCKER ps --format '{{.Names}}' 2>/dev/null | grep -q btcpay-server; then
log "Creating BTCPay Server..."
mkdir -p /var/lib/archipelago/btcpay
$DOCKER run -d --name btcpay-server --restart unless-stopped --network archy-net \
$DOCKER run -d --name btcpay-server --restart unless-stopped --memory=$(mem_limit btcpay-server) --network archy-net \
--cap-drop ALL --cap-add CHOWN --cap-add SETUID --cap-add SETGID --cap-add DAC_OVERRIDE \
--security-opt no-new-privileges:true \
-p 23000:49392 -v /var/lib/archipelago/btcpay:/datadir \
@ -312,7 +400,7 @@ autopilot.active=false
LNDCONF
log "LND config created (archy-net → bitcoin-knots:8332, rpcpolling)"
fi
$DOCKER run -d --name lnd --restart unless-stopped --network archy-net \
$DOCKER run -d --name lnd --restart unless-stopped --memory=$(mem_limit lnd) --network archy-net \
--cap-drop ALL --cap-add CHOWN --cap-add FOWNER --cap-add SETUID --cap-add SETGID --cap-add DAC_OVERRIDE \
--security-opt no-new-privileges:true \
-p 9735:9735 -p 10009:10009 -p 8080:8080 \
@ -324,7 +412,7 @@ fi
if ! $DOCKER ps --format '{{.Names}}' 2>/dev/null | grep -q fedimint; then
log "Creating Fedimint..."
mkdir -p /var/lib/archipelago/fedimint
$DOCKER run -d --name fedimint --restart unless-stopped --network archy-net \
$DOCKER run -d --name fedimint --restart unless-stopped --memory=$(mem_limit fedimint) --network archy-net \
--cap-drop ALL --cap-add CHOWN --cap-add FOWNER --cap-add SETUID --cap-add SETGID --cap-add DAC_OVERRIDE \
--security-opt no-new-privileges:true \
-p 8173:8173 -p 8174:8174 -p 8175:8175 \
@ -346,7 +434,7 @@ if ! $DOCKER ps --format '{{.Names}}' 2>/dev/null | grep -q fedimint-gateway; th
LND_MACAROON=/var/lib/archipelago/lnd/data/chain/bitcoin/mainnet/admin.macaroon
if $DOCKER ps --format '{{.Names}}' 2>/dev/null | grep -q '^lnd$' && [ -f "$LND_CERT" ] && [ -f "$LND_MACAROON" ]; then
log " LND detected — using lnd mode"
$DOCKER run -d --name fedimint-gateway --restart unless-stopped --network archy-net \
$DOCKER run -d --name fedimint-gateway --restart unless-stopped --memory=$(mem_limit fedimint-gateway) --network archy-net \
--cap-drop ALL --cap-add CHOWN --cap-add FOWNER --cap-add SETUID --cap-add SETGID --cap-add DAC_OVERRIDE \
--security-opt no-new-privileges:true \
-p 8176:8176 \
@ -361,7 +449,7 @@ if ! $DOCKER ps --format '{{.Names}}' 2>/dev/null | grep -q fedimint-gateway; th
lnd --lnd-rpc-host "$TARGET_IP":10009 --lnd-tls-cert /lnd/tls.cert --lnd-macaroon /lnd/admin.macaroon 2>>"$LOG" || true
else
log " No LND found — using ldk (built-in Lightning)"
$DOCKER run -d --name fedimint-gateway --restart unless-stopped --network archy-net \
$DOCKER run -d --name fedimint-gateway --restart unless-stopped --memory=$(mem_limit fedimint-gateway) --network archy-net \
--cap-drop ALL --cap-add CHOWN --cap-add FOWNER --cap-add SETUID --cap-add SETGID --cap-add DAC_OVERRIDE \
--security-opt no-new-privileges:true \
-p 8176:8176 -p 9737:9737 \
@ -383,7 +471,7 @@ sleep 5 # Let core services stabilize
if ! $DOCKER ps --format '{{.Names}}' 2>/dev/null | grep -qE 'homeassistant|home-assistant'; then
log "Creating Home Assistant..."
mkdir -p /var/lib/archipelago/home-assistant
$DOCKER run -d --name homeassistant --restart unless-stopped \
$DOCKER run -d --name homeassistant --restart unless-stopped --memory=$(mem_limit homeassistant) \
--cap-drop ALL --cap-add CHOWN --cap-add SETUID --cap-add SETGID --cap-add DAC_OVERRIDE \
--security-opt no-new-privileges:true \
-p 8123:8123 -v /var/lib/archipelago/home-assistant:/config \
@ -396,7 +484,7 @@ if ! $DOCKER ps --format '{{.Names}}' 2>/dev/null | grep -q grafana; then
log "Creating Grafana..."
mkdir -p /var/lib/archipelago/grafana
chown 472:472 /var/lib/archipelago/grafana 2>/dev/null || true
$DOCKER run -d --name grafana --restart unless-stopped \
$DOCKER run -d --name grafana --restart unless-stopped --memory=$(mem_limit grafana) \
--cap-drop ALL --cap-add CHOWN --cap-add SETUID --cap-add SETGID \
--security-opt no-new-privileges:true \
--read-only --tmpfs /tmp:rw,noexec,nosuid,size=256m --tmpfs /run:rw,noexec,nosuid,size=64m \
@ -407,7 +495,7 @@ fi
if ! $DOCKER ps --format '{{.Names}}' 2>/dev/null | grep -q uptime-kuma; then
log "Creating Uptime Kuma..."
mkdir -p /var/lib/archipelago/uptime-kuma
$DOCKER run -d --name uptime-kuma --restart unless-stopped \
$DOCKER run -d --name uptime-kuma --restart unless-stopped --memory=$(mem_limit uptime-kuma) \
--cap-drop ALL --cap-add CHOWN --cap-add FOWNER --cap-add SETUID --cap-add SETGID \
--security-opt no-new-privileges:true \
-p 3001:3001 -v /var/lib/archipelago/uptime-kuma:/app/data \
@ -417,7 +505,7 @@ fi
if ! $DOCKER ps --format '{{.Names}}' 2>/dev/null | grep -q jellyfin; then
log "Creating Jellyfin..."
mkdir -p /var/lib/archipelago/jellyfin/config /var/lib/archipelago/jellyfin/cache
$DOCKER run -d --name jellyfin --restart unless-stopped \
$DOCKER run -d --name jellyfin --restart unless-stopped --memory=$(mem_limit jellyfin) \
--cap-drop ALL --security-opt no-new-privileges:true \
-p 8096:8096 \
-v /var/lib/archipelago/jellyfin/config:/config \
@ -427,7 +515,7 @@ fi
if ! $DOCKER ps --format '{{.Names}}' 2>/dev/null | grep -q photoprism; then
log "Creating PhotoPrism..."
mkdir -p /var/lib/archipelago/photoprism
$DOCKER run -d --name photoprism --restart unless-stopped \
$DOCKER run -d --name photoprism --restart unless-stopped --memory=$(mem_limit photoprism) \
--cap-drop ALL --cap-add CHOWN --cap-add SETUID --cap-add SETGID \
--security-opt no-new-privileges:true \
-p 2342:2342 -v /var/lib/archipelago/photoprism:/photoprism/storage \
@ -437,7 +525,7 @@ fi
if ! $DOCKER ps --format '{{.Names}}' 2>/dev/null | grep -q ollama; then
log "Creating Ollama..."
mkdir -p /var/lib/archipelago/ollama
$DOCKER run -d --name ollama --restart unless-stopped \
$DOCKER run -d --name ollama --restart unless-stopped --memory=$(mem_limit ollama) \
--cap-drop ALL --security-opt no-new-privileges:true \
--read-only --tmpfs /tmp:rw,noexec,nosuid,size=256m --tmpfs /run:rw,noexec,nosuid,size=64m \
-p 11434:11434 -v /var/lib/archipelago/ollama:/root/.ollama \
@ -446,7 +534,7 @@ fi
if ! $DOCKER ps --format '{{.Names}}' 2>/dev/null | grep -q vaultwarden; then
log "Creating Vaultwarden..."
mkdir -p /var/lib/archipelago/vaultwarden
$DOCKER run -d --name vaultwarden --restart unless-stopped \
$DOCKER run -d --name vaultwarden --restart unless-stopped --memory=$(mem_limit vaultwarden) \
--cap-drop ALL --cap-add CHOWN --cap-add SETUID --cap-add SETGID --cap-add NET_BIND_SERVICE \
--security-opt no-new-privileges:true \
-p 8082:80 -v /var/lib/archipelago/vaultwarden:/data \
@ -455,7 +543,7 @@ fi
if ! $DOCKER ps --format '{{.Names}}' 2>/dev/null | grep -q nextcloud; then
log "Creating Nextcloud..."
mkdir -p /var/lib/archipelago/nextcloud
$DOCKER run -d --name nextcloud --restart unless-stopped \
$DOCKER run -d --name nextcloud --restart unless-stopped --memory=$(mem_limit nextcloud) \
--cap-drop ALL --cap-add CHOWN --cap-add SETUID --cap-add SETGID --cap-add DAC_OVERRIDE \
--security-opt no-new-privileges:true \
-p 8085:80 -v /var/lib/archipelago/nextcloud:/var/www/html \
@ -463,7 +551,7 @@ if ! $DOCKER ps --format '{{.Names}}' 2>/dev/null | grep -q nextcloud; then
fi
if ! $DOCKER ps --format '{{.Names}}' 2>/dev/null | grep -q searxng; then
log "Creating SearXNG..."
$DOCKER run -d --name searxng --restart unless-stopped \
$DOCKER run -d --name searxng --restart unless-stopped --memory=$(mem_limit searxng) \
--cap-drop ALL --security-opt no-new-privileges:true \
--read-only --tmpfs /tmp:rw,noexec,nosuid,size=256m --tmpfs /run:rw,noexec,nosuid,size=64m \
-p 8888:8080 \
@ -471,7 +559,7 @@ if ! $DOCKER ps --format '{{.Names}}' 2>/dev/null | grep -q searxng; then
fi
if ! $DOCKER ps --format '{{.Names}}' 2>/dev/null | grep -q onlyoffice; then
log "Creating OnlyOffice..."
$DOCKER run -d --name onlyoffice --restart unless-stopped \
$DOCKER run -d --name onlyoffice --restart unless-stopped --memory=$(mem_limit onlyoffice) \
--cap-drop ALL --cap-add CHOWN --cap-add SETUID --cap-add SETGID --cap-add DAC_OVERRIDE \
--security-opt no-new-privileges:true \
-p 9980:80 \
@ -480,14 +568,14 @@ fi
if ! $DOCKER ps --format '{{.Names}}' 2>/dev/null | grep -q filebrowser; then
log "Creating File Browser..."
mkdir -p /var/lib/archipelago/filebrowser /var/lib/archipelago/filebrowser-db
$DOCKER run -d --name filebrowser --restart unless-stopped \
$DOCKER run -d --name filebrowser --restart unless-stopped --memory=$(mem_limit filebrowser) \
-p 8083:80 -v /var/lib/archipelago/filebrowser:/srv \
docker.io/filebrowser/filebrowser:v2.27.0 2>>"$LOG" || true
fi
if ! $DOCKER ps --format '{{.Names}}' 2>/dev/null | grep -q nginx-proxy-manager; then
log "Creating Nginx Proxy Manager..."
mkdir -p /var/lib/archipelago/nginx-proxy-manager/data /var/lib/archipelago/nginx-proxy-manager/letsencrypt
$DOCKER run -d --name nginx-proxy-manager --restart unless-stopped \
$DOCKER run -d --name nginx-proxy-manager --restart unless-stopped --memory=$(mem_limit nginx-proxy-manager) \
--cap-drop ALL --cap-add CHOWN --cap-add SETUID --cap-add SETGID --cap-add NET_BIND_SERVICE \
--security-opt no-new-privileges:true \
-p 81:81 -p 8084:80 -p 8443:443 \
@ -498,7 +586,7 @@ fi
if ! $DOCKER ps --format '{{.Names}}' 2>/dev/null | grep -q portainer; then
log "Creating Portainer..."
mkdir -p /var/lib/archipelago/portainer
$DOCKER run -d --name portainer --restart unless-stopped \
$DOCKER run -d --name portainer --restart unless-stopped --memory=$(mem_limit portainer) \
--cap-drop ALL --cap-add CHOWN --cap-add SETUID --cap-add SETGID --cap-add DAC_OVERRIDE \
--security-opt no-new-privileges:true \
-p 9000:9000 \
@ -510,7 +598,7 @@ if ! $DOCKER ps --format '{{.Names}}' 2>/dev/null | grep -q tailscale; then
log "Creating Tailscale..."
mkdir -p /var/lib/archipelago/tailscale
# Tailscale needs NET_ADMIN + NET_RAW + TUN device (no --privileged)
$DOCKER run -d --name tailscale --restart unless-stopped \
$DOCKER run -d --name tailscale --restart unless-stopped --memory=$(mem_limit tailscale) \
--network host \
--cap-drop=ALL \
--cap-add=NET_ADMIN \
@ -537,7 +625,7 @@ if ! $DOCKER ps --format '{{.Names}}' 2>/dev/null | grep -q immich_server; then
mkdir -p /var/lib/archipelago/immich /var/lib/archipelago/immich-db
$DOCKER network create immich-net 2>/dev/null || true
if ! $DOCKER ps -a --format '{{.Names}}' 2>/dev/null | grep -q immich_postgres; then
$DOCKER run -d --name immich_postgres --restart unless-stopped --network immich-net \
$DOCKER run -d --name immich_postgres --restart unless-stopped --memory=$(mem_limit immich_postgres) --network immich-net \
-v /var/lib/archipelago/immich-db:/var/lib/postgresql/data \
-e POSTGRES_PASSWORD=$IMMICH_DB_PASS -e POSTGRES_USER=postgres -e POSTGRES_DB=immich \
ghcr.io/immich-app/postgres:14-vectorchord0.4.3-pgvectors0.2.0 2>>"$LOG" || true
@ -548,12 +636,12 @@ if ! $DOCKER ps --format '{{.Names}}' 2>/dev/null | grep -q immich_server; then
done
fi
if ! $DOCKER ps --format '{{.Names}}' 2>/dev/null | grep -q immich_redis; then
$DOCKER run -d --name immich_redis --restart unless-stopped --network immich-net \
$DOCKER run -d --name immich_redis --restart unless-stopped --memory=$(mem_limit immich_redis) --network immich-net \
docker.io/valkey/valkey:7-alpine 2>>"$LOG" || true
sleep 2
fi
if ! $DOCKER ps --format '{{.Names}}' 2>/dev/null | grep -q immich_server; then
$DOCKER run -d --name immich_server --restart unless-stopped --network immich-net \
$DOCKER run -d --name immich_server --restart unless-stopped --memory=$(mem_limit immich_server) --network immich-net \
-p 2283:2283 -v /var/lib/archipelago/immich:/usr/src/app/upload \
-e DB_HOSTNAME=immich_postgres -e DB_USERNAME=postgres -e DB_PASSWORD=$IMMICH_DB_PASS \
-e DB_DATABASE_NAME=immich -e REDIS_HOSTNAME=immich_redis \
@ -568,20 +656,20 @@ if ! $DOCKER ps --format '{{.Names}}' 2>/dev/null | grep -q penpot-frontend; the
mkdir -p /var/lib/archipelago/penpot-assets /var/lib/archipelago/penpot-postgres
$DOCKER network create penpot-net 2>/dev/null || true
if ! $DOCKER ps -a --format '{{.Names}}' 2>/dev/null | grep -q penpot-postgres; then
$DOCKER run -d --name penpot-postgres --restart unless-stopped --network penpot-net \
$DOCKER run -d --name penpot-postgres --restart unless-stopped --memory=$(mem_limit penpot-postgres) --network penpot-net \
-v /var/lib/archipelago/penpot-postgres:/var/lib/postgresql/data \
-e POSTGRES_DB=penpot -e POSTGRES_USER=penpot -e POSTGRES_PASSWORD=$PENPOT_DB_PASS \
docker.io/postgres:15 2>>"$LOG" || true
sleep 5
fi
if ! $DOCKER ps --format '{{.Names}}' 2>/dev/null | grep -q penpot-valkey; then
$DOCKER run -d --name penpot-valkey --restart unless-stopped --network penpot-net \
$DOCKER run -d --name penpot-valkey --restart unless-stopped --memory=$(mem_limit penpot-valkey) --network penpot-net \
-e VALKEY_EXTRA_FLAGS="--maxmemory 128mb --maxmemory-policy volatile-lfu" \
docker.io/valkey/valkey:8.1 2>>"$LOG" || true
sleep 3
fi
if ! $DOCKER ps --format '{{.Names}}' 2>/dev/null | grep -q penpot-backend; then
$DOCKER run -d --name penpot-backend --restart unless-stopped --network penpot-net \
$DOCKER run -d --name penpot-backend --restart unless-stopped --memory=$(mem_limit penpot-backend) --network penpot-net \
-v /var/lib/archipelago/penpot-assets:/opt/data/assets \
-e PENPOT_PUBLIC_URI="http://${TARGET_IP}:9001" \
-e PENPOT_SECRET_KEY=archipelago-penpot-secret-key-change-in-production \
@ -595,7 +683,7 @@ if ! $DOCKER ps --format '{{.Names}}' 2>/dev/null | grep -q penpot-frontend; the
sleep 5
fi
if ! $DOCKER ps --format '{{.Names}}' 2>/dev/null | grep -q penpot-exporter; then
$DOCKER run -d --name penpot-exporter --restart unless-stopped --network penpot-net \
$DOCKER run -d --name penpot-exporter --restart unless-stopped --memory=$(mem_limit penpot-exporter) --network penpot-net \
-e PENPOT_SECRET_KEY=archipelago-penpot-secret-key-change-in-production \
-e PENPOT_PUBLIC_URI=http://penpot-frontend:8080 \
-e PENPOT_REDIS_URI=redis://penpot-valkey/0 \
@ -603,7 +691,7 @@ if ! $DOCKER ps --format '{{.Names}}' 2>/dev/null | grep -q penpot-frontend; the
sleep 2
fi
if ! $DOCKER ps --format '{{.Names}}' 2>/dev/null | grep -q penpot-frontend; then
$DOCKER run -d --name penpot-frontend --restart unless-stopped --network penpot-net \
$DOCKER run -d --name penpot-frontend --restart unless-stopped --memory=$(mem_limit penpot-frontend) --network penpot-net \
-p 9001:8080 -v /var/lib/archipelago/penpot-assets:/opt/data/assets \
-e PENPOT_PUBLIC_URI="http://${TARGET_IP}:9001" \
-e PENPOT_FLAGS=disable-email-verification enable-smtp enable-prepl-server disable-secure-session-cookies \
@ -617,7 +705,7 @@ if $DOCKER images --format '{{.Repository}}:{{.Tag}}' 2>/dev/null | grep -q 'nos
if ! $DOCKER ps --format '{{.Names}}' 2>/dev/null | grep -q nostr-rs-relay; then
log "Creating nostr-rs-relay..."
mkdir -p /var/lib/archipelago/nostr-rs-relay
$DOCKER run -d --name nostr-rs-relay --restart unless-stopped \
$DOCKER run -d --name nostr-rs-relay --restart unless-stopped --memory=$(mem_limit nostr-rs-relay) \
-p 7047:7047 -v /var/lib/archipelago/nostr-rs-relay:/data \
scsibug/nostr-rs-relay:latest 2>>"$LOG" || true
fi
@ -626,7 +714,7 @@ if $DOCKER images --format '{{.Repository}}:{{.Tag}}' 2>/dev/null | grep -q 'str
if ! $DOCKER ps --format '{{.Names}}' 2>/dev/null | grep -q strfry; then
log "Creating strfry..."
mkdir -p /var/lib/archipelago/strfry
$DOCKER run -d --name strfry --restart unless-stopped \
$DOCKER run -d --name strfry --restart unless-stopped --memory=$(mem_limit strfry) \
-p 7777:7777 -v /var/lib/archipelago/strfry:/data \
hoytech/strfry:latest 2>>"$LOG" || true
fi
@ -644,7 +732,7 @@ if ! $DOCKER ps --format '{{.Names}}' 2>/dev/null | grep -q indeedhub; then
fi
if [ -n "$INDEEDHUB_IMAGE" ]; then
log "Creating Indeehub from $INDEEDHUB_IMAGE..."
$DOCKER run -d --name indeedhub --restart unless-stopped \
$DOCKER run -d --name indeedhub --restart unless-stopped --memory=$(mem_limit indeedhub) \
--cap-drop ALL --security-opt no-new-privileges:true \
--read-only --tmpfs /tmp:rw,noexec,nosuid,size=64m --tmpfs /app/.next/cache:rw,noexec,nosuid,size=128m \
-p 7777:7777 \

View File

@ -21,13 +21,38 @@ chmod 755 "$SSL_DIR"
# Generate self-signed cert if missing (valid 365 days)
# SAN includes common dev IPs so cert works when accessing via IP
# Build dynamic SAN with all node IPs (LAN + Tailscale + loopback)
SAN_IPS="DNS:archipelago.local,DNS:localhost,IP:127.0.0.1"
# Add all IPv4 addresses on this machine (LAN, Tailscale, etc.)
for ip in $(hostname -I 2>/dev/null | tr ' ' '\n' | grep -E '^[0-9]+\.' | grep -v '^127\.'); do
SAN_IPS="$SAN_IPS,IP:$ip"
done
# Always include common LAN IPs as fallback
for ip in 192.168.1.228 192.168.1.198 10.0.0.1; do
echo "$SAN_IPS" | grep -q "$ip" || SAN_IPS="$SAN_IPS,IP:$ip"
done
# Regenerate cert if missing OR if current cert doesn't include this node's primary IP
REGEN=false
if [ ! -f "$CERT" ] || [ ! -f "$KEY" ]; then
REGEN=true
else
# Check if cert has this node's primary IP
MY_IP=$(hostname -I 2>/dev/null | awk '{print $1}')
if [ -n "$MY_IP" ] && ! openssl x509 -in "$CERT" -noout -text 2>/dev/null | grep -q "$MY_IP"; then
echo " Certificate missing this node's IP ($MY_IP) — regenerating..."
REGEN=true
fi
fi
if [ "$REGEN" = true ]; then
echo "Generating self-signed certificate for PWA (HTTPS)..."
echo " SAN: $SAN_IPS"
openssl req -x509 -nodes -days 365 -newkey rsa:2048 \
-keyout "$KEY" \
-out "$CERT" \
-subj "/CN=archipelago.local/O=Archipelago/C=US" \
-addext "subjectAltName=DNS:archipelago.local,DNS:localhost,IP:127.0.0.1,IP:192.168.1.228,IP:192.168.1.198,IP:10.0.0.1"
-addext "subjectAltName=$SAN_IPS"
chmod 644 "$CERT"
chmod 600 "$KEY"
echo " Certificate created at $CERT"
@ -74,8 +99,9 @@ fi
if grep -q "listen 443 ssl" "$NGINX_CFG" 2>/dev/null; then
echo "HTTPS already configured in nginx."
nginx -t 2>/dev/null && systemctl reload nginx
MY_IP=$(hostname -I 2>/dev/null | awk '{print $1}')
echo ""
echo "PWA: Use https://192.168.1.228 (not http) - accept cert once, then Install app."
echo "PWA: Use https://${MY_IP:-192.168.1.228} (not http) - accept cert once, then Install app."
exit 0
fi
@ -250,4 +276,5 @@ echo "Added HTTPS (port 443) to nginx config."
# Test and reload
nginx -t && systemctl reload nginx
echo ""
echo "HTTPS enabled. PWA install: https://192.168.1.228 (accept the certificate warning once, then Install app)."
MY_IP=$(hostname -I 2>/dev/null | awk '{print $1}')
echo "HTTPS enabled. PWA install: https://${MY_IP:-192.168.1.228} (accept the certificate warning once, then Install app)."