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:
parent
28763c4f09
commit
84a56c80de
@ -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)
|
||||
|
||||
21
.claude/memory/project_environments.md
Normal file
21
.claude/memory/project_environments.md
Normal 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.
|
||||
78
.claude/memory/project_repo_cleanup_and_dev_env.md
Normal file
78
.claude/memory/project_repo_cleanup_and_dev_env.md
Normal 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)
|
||||
81
CHANGELOG.md
81
CHANGELOG.md
@ -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
|
||||
|
||||
@ -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.
|
||||
|
||||
@ -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))
|
||||
}
|
||||
|
||||
@ -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);
|
||||
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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");
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@ -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() {
|
||||
|
||||
@ -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(),
|
||||
|
||||
@ -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;
|
||||
|
||||
@ -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())
|
||||
|
||||
@ -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");
|
||||
|
||||
@ -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();
|
||||
}
|
||||
|
||||
@ -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")
|
||||
|
||||
@ -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(())
|
||||
|
||||
@ -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");
|
||||
|
||||
@ -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",
|
||||
|
||||
@ -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 {
|
||||
|
||||
@ -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,
|
||||
|
||||
@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
@ -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();
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
@ -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);
|
||||
|
||||
@ -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();
|
||||
|
||||
|
||||
@ -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();
|
||||
|
||||
@ -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]
|
||||
|
||||
@ -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")?;
|
||||
|
||||
|
||||
@ -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,
|
||||
|
||||
@ -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,
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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;
|
||||
|
||||
@ -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"), {
|
||||
|
||||
@ -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,
|
||||
|
||||
3
neode-ui/public/assets/icon/web5.svg
Normal file
3
neode-ui/public/assets/icon/web5.svg
Normal 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 |
@ -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>
|
||||
|
||||
89
neode-ui/src/components/BaseModal.vue
Normal file
89
neode-ui/src/components/BaseModal.vue
Normal 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>
|
||||
@ -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 {
|
||||
|
||||
@ -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>
|
||||
|
||||
@ -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
|
||||
}
|
||||
|
||||
@ -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) {
|
||||
|
||||
@ -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 < 1k sats, Lightning 1k–500k, on-chain > 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()
|
||||
|
||||
|
||||
25
neode-ui/src/components/ToggleSwitch.vue
Normal file
25
neode-ui/src/components/ToggleSwitch.vue
Normal 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>
|
||||
116
neode-ui/src/components/TransactionsModal.vue
Normal file
116
neode-ui/src/components/TransactionsModal.vue
Normal 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>
|
||||
@ -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
|
||||
|
||||
@ -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"
|
||||
}
|
||||
}
|
||||
|
||||
@ -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"
|
||||
}
|
||||
}
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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)
|
||||
|
||||
@ -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 */ }
|
||||
|
||||
|
||||
@ -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 = []
|
||||
}
|
||||
|
||||
@ -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;
|
||||
}
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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">×</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>
|
||||
|
||||
@ -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) {
|
||||
|
||||
@ -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">×</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>
|
||||
|
||||
@ -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" />
|
||||
|
||||
@ -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
|
||||
}
|
||||
|
||||
@ -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"
|
||||
|
||||
@ -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"
|
||||
|
||||
@ -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>
|
||||
|
||||
@ -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">×</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()
|
||||
|
||||
@ -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>
|
||||
|
||||
@ -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>
|
||||
|
||||
@ -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()
|
||||
|
||||
|
||||
@ -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;
|
||||
|
||||
@ -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">📡</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;
|
||||
|
||||
@ -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 {
|
||||
|
||||
@ -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>
|
||||
|
||||
|
||||
@ -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() {
|
||||
|
||||
@ -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'
|
||||
|
||||
@ -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">📡</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'
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@ -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>
|
||||
|
||||
@ -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:
|
||||
|
||||
@ -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"
|
||||
|
||||
@ -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 \
|
||||
|
||||
@ -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)."
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user