Compare commits

..
Author SHA1 Message Date
archipelago bdd5a2c43e chore: release v1.7.80-alpha 2026-05-21 00:38:57 -04:00
archipelago 8eb03d106e fix(apps): repair saleor storefront graphql origin 2026-05-21 00:30:22 -04:00
archipelago 4da6e3b43c chore: sync cargo lock for v1.7.79-alpha 2026-05-20 23:17:04 -04:00
archipelago 7be7420c4f chore: release v1.7.79-alpha 2026-05-20 23:11:54 -04:00
archipelago 34c4e87d14 feat(apps): add saleor storefront 2026-05-20 23:02:57 -04:00
archipelago e61c757633 chore: release v1.7.78-alpha 2026-05-20 20:53:23 -04:00
archipelago cc1f8fba72 fix(apps): stabilize saleor and netbird release paths 2026-05-20 20:38:52 -04:00
archipelago 556f2e7cac chore: release v1.7.77-alpha 2026-05-20 01:03:48 -04:00
archipelago 0898c54765 chore: bump version to v1.7.77-alpha 2026-05-20 00:38:26 -04:00
archipelago f4368785f0 fix(apps): unblock saleor and netbird first-use flows 2026-05-20 00:28:30 -04:00
archipelago 608f4c17f0 chore: release v1.7.76-alpha 2026-05-19 21:55:48 -04:00
archipelago 92c58141af fix(apps): stabilize saleor and netbird launch 2026-05-19 21:45:17 -04:00
archipelago 7b2f4cb05f chore: sync cargo lock for v1.7.75-alpha 2026-05-19 20:27:34 -04:00
archipelago e65e76cd9d chore: release v1.7.75-alpha 2026-05-19 20:19:24 -04:00
archipelago 6d03ed5a69 docs: add v1.7.75-alpha changelog 2026-05-19 20:11:41 -04:00
archipelago 522c046525 feat(apps): add saleor and harden netbird repair 2026-05-19 20:11:22 -04:00
archipelago 56f956973e chore: release v1.7.74-alpha 2026-05-19 19:29:15 -04:00
archipelago bd69ef41d5 fix(apps): repair netbird login and iframe focus 2026-05-19 19:21:43 -04:00
archipelago eeb08fc78f chore: release v1.7.73-alpha 2026-05-19 18:40:10 -04:00
archipelago 1836b035b4 fix(mobile): improve app store search and launches 2026-05-19 18:29:04 -04:00
archipelago 3e01e57c8d chore: release v1.7.72-alpha 2026-05-19 17:42:11 -04:00
archipelago ca3e2ee0ca fix(settings): update whats new release notes 2026-05-19 17:33:45 -04:00
archipelago 5859ef77e7 chore: release v1.7.71-alpha 2026-05-19 17:30:20 -04:00
archipelago f0bd49d03d fix(apps): repair netbird install and app icons 2026-05-19 17:20:32 -04:00
archipelago cede77f3bc chore: update release lockfile 2026-05-19 16:17:13 -04:00
archipelago dd8a6cd9d7 chore: release v1.7.70-alpha 2026-05-19 16:10:43 -04:00
archipelago ab96c97cb9 fix(apps): self-host netbird and stabilize app sessions 2026-05-19 16:02:35 -04:00
archipelago 881779005a chore: update release lockfile 2026-05-19 14:45:20 -04:00
archipelago 20bc9f250c chore: release v1.7.69-alpha 2026-05-19 14:39:15 -04:00
archipelago 87be717f40 fix(apps): keep slow installs visible 2026-05-19 14:29:20 -04:00
archipelago 75d147b69f fix(release): verify published OTA artifacts 2026-05-19 12:10:42 -04:00
archipelago edaece8716 chore: update release lockfile 2026-05-19 09:41:57 -04:00
archipelago ab27fb97f8 chore: release v1.7.68-alpha 2026-05-19 09:37:47 -04:00
archipelago d736364ad7 fix(apps): stabilize btcpay and public proxy launch flows 2026-05-19 09:26:43 -04:00
archipelago e9898ead76 chore: update release lockfile 2026-05-18 11:55:20 -04:00
archipelago b25d41c5c6 chore: release v1.7.67-alpha 2026-05-18 11:54:57 -04:00
archipelago 32902d3891 fix(ui): stabilize system status metrics 2026-05-18 11:47:12 -04:00
archipelago 92c578d3d9 chore: update release lockfile 2026-05-18 10:17:20 -04:00
archipelago 6240064acf chore: release v1.7.66-alpha 2026-05-18 10:15:56 -04:00
archipelago 19dbf60f03 fix(apps): detect stale npm created containers 2026-05-18 10:04:22 -04:00
archipelago b49d8f1f8a chore: update release lockfile 2026-05-18 09:31:57 -04:00
archipelago ec36ac7e2c chore: release v1.7.65-alpha 2026-05-18 09:31:41 -04:00
archipelago 7104ba0cbf fix(apps): repair orchestrator starts before launch 2026-05-18 09:20:12 -04:00
archipelago d0b08d2790 chore: update release lockfile 2026-05-17 23:25:16 -04:00
archipelago 76288f541e chore: release v1.7.64-alpha 2026-05-17 23:24:39 -04:00
archipelago b701e125b4 fix(update): relax apply rate limit 2026-05-17 23:15:07 -04:00
archipelago 837ba63466 chore: update release lockfile 2026-05-17 23:03:44 -04:00
archipelago 8191d92bed chore: release v1.7.63-alpha 2026-05-17 23:03:06 -04:00
archipelago ae8359da4b fix(release): rebuild backend artifacts 2026-05-17 22:54:37 -04:00
archipelago d91b858d9b chore: release v1.7.62-alpha 2026-05-17 22:40:36 -04:00
archipelago 19f2125a4d fix(apps): repair stale nginx proxy manager ports 2026-05-17 22:38:04 -04:00
archipelago a992abcd06 chore: release v1.7.61-alpha 2026-05-17 22:13:21 -04:00
archipelago 4d6b4f76af chore: release v1.7.60-alpha 2026-05-17 20:45:56 -04:00
archipelago 0a94c0097f chore: release v1.7.59-alpha 2026-05-17 19:44:54 -04:00
archipelago 413d50116e fix(apps): restore mobile and website launching 2026-05-17 19:22:18 -04:00
archipelago daad50325b chore(release): require curated release notes 2026-05-17 18:59:12 -04:00
archipelago e05e356d64 chore: release v1.7.58-alpha 2026-05-17 18:40:50 -04:00
archipelago cfb304a001 feat(mesh): add meshtastic serial radio support 2026-05-17 18:07:40 -04:00
archipelago 7804223152 chore: release v1.7.57-alpha 2026-05-17 17:30:04 -04:00
archipelago a322b04021 fix(iso): avoid polkit in live debootstrap seed 2026-05-15 18:32:14 -04:00
archipelago 645cf69ed7 chore(release): refresh v1.7.56-alpha manifest after wifi fix 2026-05-15 18:26:17 -04:00
archipelago 01ec0565a6 fix: restore wifi setup and ssh password updates 2026-05-15 18:15:06 -04:00
archipelago 30505f41ff chore(release): refresh v1.7.56-alpha notes and artifacts 2026-05-15 17:54:32 -04:00
95 changed files with 5353 additions and 538 deletions
@@ -132,6 +132,16 @@ fun WebViewScreen(
AndroidView(
modifier = Modifier.fillMaxSize(),
factory = { context ->
fun openExternalUrl(url: String) {
try {
val intent = android.content.Intent(
android.content.Intent.ACTION_VIEW,
android.net.Uri.parse(url),
)
context.startActivity(intent)
} catch (_: Exception) {}
}
WebView(context).apply {
layoutParams = ViewGroup.LayoutParams(
ViewGroup.LayoutParams.MATCH_PARENT,
@@ -220,13 +230,7 @@ fun WebViewScreen(
// Keep navigation within the Archipelago server
if (url.startsWith(serverUrl)) return false
// Open external URLs in the system browser
try {
val intent = android.content.Intent(
android.content.Intent.ACTION_VIEW,
android.net.Uri.parse(url),
)
context.startActivity(intent)
} catch (_: Exception) {}
openExternalUrl(url)
return true
}
}
@@ -243,18 +247,30 @@ fun WebViewScreen(
isUserGesture: Boolean,
resultMsg: android.os.Message?,
): Boolean {
// Extract the URL from the hit test
val data = view?.hitTestResult?.extra
if (data != null) {
try {
val intent = android.content.Intent(
android.content.Intent.ACTION_VIEW,
android.net.Uri.parse(data),
)
context.startActivity(intent)
} catch (_: Exception) {}
val transport = resultMsg?.obj as? WebView.WebViewTransport
?: return false
val popup = WebView(context).apply {
settings.javaScriptEnabled = true
webViewClient = object : WebViewClient() {
override fun shouldOverrideUrlLoading(
view: WebView?,
request: WebResourceRequest?,
): Boolean {
val url = request?.url?.toString() ?: return true
openExternalUrl(url)
return true
}
override fun onPageStarted(view: WebView?, url: String?, favicon: Bitmap?) {
if (url != null) openExternalUrl(url)
view?.stopLoading()
}
}
}
return false
transport.webView = popup
resultMsg.sendToTarget()
return true
}
}
+210 -1
View File
@@ -1,11 +1,220 @@
# Changelog
## v1.7.56-alpha (2026-05-14)
## v1.7.80-alpha (2026-05-21)
- Saleor storefront proxying now falls back to the direct request scheme when no forwarded protocol header is present, fixing direct `http://node:9011` launches that could generate an invalid same-origin GraphQL URL.
- The Saleor storefront release path keeps public proxy support intact by still honoring forwarded HTTPS headers for Nginx Proxy Manager domains while repairing local/direct port launches.
- Validation passed with `cargo fmt --check` and `cargo check` for the Archipelago backend before release staging.
## v1.7.79-alpha (2026-05-20)
- Saleor now installs the official Saleor Storefront as part of the stack, built from the pinned `saleor/storefront` source and served as the customer-facing shop on port `9011`.
- Saleor app launches now open the storefront while the admin dashboard remains available on port `9010` with the generated `admin@example.com` credentials shown in Archipelago.
- Public Nginx Proxy Manager hosts forwarding to the Saleor storefront also expose same-origin `/graphql/`, so public storefront domains can talk to the local Saleor API without mixed-content or private-LAN reachability failures.
- Saleor stack metadata, marketplace descriptions, catalog ports, scanner exclusions, and app-session routing now describe the storefront/dashboard/API split explicitly.
## v1.7.78-alpha (2026-05-20)
- Public Nginx Proxy Manager hosts for Saleor now keep browser GraphQL calls same-origin at `/graphql/` and proxy them to the local API on `8000`, fixing `Failed to fetch` when a public domain such as `noderunner.shop` was loaded from devices that cannot reach the node's private LAN/tailnet API address.
- Saleor's validated stack changes are now release-ready: dashboard origins on port `9010` are explicitly allowed for dashboard/API calls, preserving the working test-node install path for production nodes.
- NetBird launches now stay pinned to the unified dashboard/proxy origin on port `8087` instead of following stale runtime-discovered server URLs on `8086`.
- NetBird's local nginx proxy now routes browser API, OAuth, relay, and WebSocket traffic through `host.containers.internal:8086` instead of a hard-coded rootless Podman gateway IP, and includes the upstream `management.ProxyService` gRPC path.
- The mobile credentials interstitial now keeps credential lists scrollable and action buttons reachable in both My Apps and the mobile app icon grid.
- Android WebView popup windows now hand external popup URLs to the system browser, covering app login/signup flows that open secondary windows.
- Validation passed with `git diff --check`, `cargo check -p archipelago`, and the focused `npm test -- src/views/appSession/__tests__/appSessionConfig.test.ts` suite.
## v1.7.77-alpha (2026-05-20)
- Saleor first-use now exposes generated credentials through Archipelago instead of leaving users at an unexplained dashboard login: App Details shows copyable `admin@example.com` credentials, and My Apps/mobile icon launches show a pre-launch credentials modal.
- Saleor installs now create or repair the `admin@example.com` staff account idempotently after sample data loads, use the correct dashboard mount path, and re-check stack containers after startup so stopped containers are caught.
- NetBird embedded login now uses the upstream-compatible IdP signing-key behavior and sends ID tokens from the dashboard to the management API, fixing the post-signup `Unauthenticated` state while preserving the unified local proxy/logout routes.
- Transient unnamed Podman helper containers created during app install tasks are hidden from My Apps, so generated names like `eager_keldysh` no longer appear as user applications.
- Validation passed with catalog/release JSON checks, `npm run type-check`, and `cargo fmt --all --check --manifest-path core/Cargo.toml`; live checks on `100.114.134.21` confirmed Saleor dashboard/API availability, generated Saleor admin login, NetBird OAuth availability, and NetBird logout redirects.
## v1.7.76-alpha (2026-05-20)
- Saleor installs now use dashboard port `9010`, avoiding the existing Portainer `9000` binding on the test node while keeping API `8000`, Mailpit `8025`, and Jaeger `16686` unchanged.
- Saleor's Valkey cache no longer bind-mounts `/var/lib/archipelago/saleor-cache`, and the dashboard container has the minimal rootless nginx capabilities it needs to chown cache files, bind port 80 inside the container, and drop workers to the nginx user.
- NetBird's browser proxy now sends API, OAuth, relay, WebSocket, and management traffic through the stable host-published server port at `169.254.1.2:8086`, avoiding stale rootless Podman DNS/IPs after `netbird-server` restarts.
- Mobile App Store category chips now stay visible above the tab bar, Discover is available on mobile, and category selection updates the page route/query so the selected category is actually shown.
- Apps that require a real browser tab now open directly from the app icon tap instead of first entering an in-shell app-session route, including BTCPay, Grafana, Home Assistant, Vaultwarden, Nextcloud, Portainer, OnlyOffice, Tailscale, Uptime Kuma, Gitea, and Nginx Proxy Manager.
- Validation passed with catalog JSON checks, `npm run type-check`, `cargo fmt --all --check --manifest-path core/Cargo.toml`, and `cargo check -p archipelago --manifest-path core/Cargo.toml`; live checks on `100.70.96.88` confirmed Saleor dashboard `9010`/API `8000` and NetBird API/OAuth routes survive `netbird-server` restart.
## v1.7.75-alpha (2026-05-19)
- Saleor is now published as a recommended commerce app with catalog metadata, icon, direct app-session launch on port `9000`, scanner metadata, image pins, and a full stack installer for dashboard, API, worker, PostgreSQL, Valkey, Mailpit, and Jaeger.
- Existing NetBird installs are repaired more aggressively by rewriting unified-origin config, recreating the dashboard/proxy containers, restarting the server, preserving data, and handling exact `/api` and `/oauth2` routes plus dashboard logout redirects through the local proxy.
- Desktop dashboard scrolling now hands focus back from the sidebar to the main content when the pointer or wheel moves over the main pane, preventing the sidebar scroll area from trapping wheel input on short screens.
- Validation passed with catalog JSON checks, `npm run type-check`, `cargo fmt --all --check --manifest-path core/Cargo.toml`, and `cargo check -p archipelago --manifest-path core/Cargo.toml` before release.
## v1.7.74-alpha (2026-05-19)
- App-session right panels now re-focus the iframe after load and when the frame area is activated, so wheel/touch scrolling works immediately after switching tabs or selecting an app on shorter screens.
- NetBird now launches through a unified local origin on port `8087` that proxies the dashboard plus `/oauth2`, `/api`, relay, WebSocket, and gRPC routes to `netbird-server`, fixing the embedded login flow that previously ended in `Unauthenticated` or `404 page not found` after logout.
- Existing NetBird installs are repaired on adopt/start by rewriting `config.yaml`, `dashboard.env`, and the local nginx proxy config, then creating the missing `netbird-dashboard` and `netbird` proxy containers when needed while preserving NetBird data.
- Saleor is still pending and is not included in this release; its registry/installer work remains local until it can be validated separately.
- Validation passed with catalog JSON checks, `npm run type-check`, `cargo fmt --all --check --manifest-path core/Cargo.toml`, and `cargo check -p archipelago --manifest-path core/Cargo.toml`.
## v1.7.73-alpha (2026-05-19)
- Mobile app launches for iframe-blocked apps now open the direct app URL in a new browser tab immediately instead of landing in a broken in-shell webview that requires a second tap.
- Mobile My Apps/Websites tabs now react to route query changes, App Store pages label the mobile view as Discover, mobile filters have safe bottom spacing, and App Store search ignores the current category so searches cover all available apps.
- My Apps search now surfaces matching App Store entries when the app is not installed, making it possible to jump directly from a failed My Apps search to the installable app details.
- NetBird self-host installs now prefer a `100.x` tailnet/CGNAT address for dashboard, management, relay, STUN, and auth redirect origins when one is present; live repair on `100.89.209.89` updated the existing stack from LAN origins to `100.89.209.89` and restored `netbird-server`.
- App-session iframe frames now focus automatically and wrap the iframe in a scroll host so wheel/touch scrolling works in the active right frame without requiring an initial click.
## v1.7.72-alpha (2026-05-19)
- Settings What's New now includes the missing release notes for `v1.7.68-alpha` through `v1.7.71-alpha`, so the modal reflects the current OTA history instead of stopping at `v1.7.67-alpha`.
- The follow-up release carries the NetBird install fix, Gitea icon polish, mobile app-session fallback updates, and rounder app icon masks from `v1.7.71-alpha` with the Settings modal notes included.
- The local Cargo lockfile version metadata is kept in sync with the release bump after the previous release build updated it.
## v1.7.71-alpha (2026-05-19)
- NetBird stack installs now pre-create `/var/lib/archipelago/netbird/data` before binding it into `netbird-server`, fixing the failed install/start path seen on `100.70.96.88` where Podman rejected the missing host directory.
- NetBird start/restart ordering now starts `netbird-server` before the dashboard container so lifecycle actions bring the control plane up before the UI.
- App-session invalid IDs and panel-mode fallbacks now return to `/dashboard/apps`, avoiding the stale `/apps` route that could render a 404.
- Mobile launches for apps that block iframes now stay inside the Archipelago app-session fallback instead of automatically opening an external browser tab.
- Installed Gitea containers now report the packaged Gitea icon, and app icon masks use a rounder radius on mobile grids, app cards, and detail headers.
- Validation passed with `npm run type-check`, focused Vitest app-session/app-grid tests, `cargo fmt --all --check --manifest-path core/Cargo.toml`, and `cargo check -p archipelago --manifest-path core/Cargo.toml`.
## v1.7.70-alpha (2026-05-19)
- NetBird is being corrected from the peer/client daemon image to the self-hosted NetBird control-plane stack with a launchable dashboard on port `8087`, a combined management/signal/relay server on `8086`, and STUN on UDP `3478`.
- App sessions now always launch local apps through direct host ports and carry an explicit dashboard return target, so closing an iframe returns to the launching dashboard screen instead of falling through to browser history or a 404.
- Mobile app launches ignore stale desktop panel state and route into the full app-session webview consistently.
- The desktop sidebar now pins the logo/version at the top and controller/online/mode controls at the bottom, with only the navigation section scrolling on shorter screens.
- Validation passed with catalog JSON checks, `scripts/image-versions.sh` syntax check, `npm run type-check`, `cargo fmt --all --check --manifest-path core/Cargo.toml`, and `cargo check -p archipelago --manifest-path core/Cargo.toml`.
## v1.7.69-alpha (2026-05-19)
- App installs now allow up to 10 minutes for the initial `package.install` RPC to return, matching slow container image pulls and preventing apps from disappearing from My Apps while the backend is still pulling or retrying mirrors.
- Live diagnostics on `100.70.96.88` confirmed the Gitea install did not fail; the primary registry pull timed out after 300 seconds, the fallback mirror succeeded, and Gitea came up healthy on `3001` while the frontend had already timed out at 15 seconds.
- Gitea and other Docker-image app installs now stay visible during slow registry pulls instead of being marked as failed by the browser before backend install progress can complete.
- Gitea is now categorized as a known Data app in My Apps, so a running Gitea container appears with installed apps instead of being filtered into the Websites/Services split.
- NetBird `0.71.2` is now available in the app catalog and fallback marketplace data as a recommended networking app using the official `docker.io/netbirdio/netbird:0.71.2` image.
- NetBird installs get persistent state under `/var/lib/archipelago/netbird`, `NET_ADMIN`/`NET_RAW`, `/dev/net/tun`, `slirp4netns`, image-version pinning, backend metadata, and health checks through `netbird status`.
- The Archipelago terminal now includes `nano` on new disk installs and ISO builds, and self-update installs it on existing nodes if it is missing.
- Validation passed with catalog JSON checks, shell syntax checks, `npm run type-check`, `cargo fmt --all --check --manifest-path core/Cargo.toml`, and `cargo check -p archipelago --manifest-path core/Cargo.toml`.
## v1.7.68-alpha (2026-05-19)
- BTCPay Server now ships on the official `docker.io/btcpayserver/btcpayserver:2.3.9` image, fixing the plugin catalog crash caused by newer plugin dependency version metadata while preserving existing datadirs and Postgres databases.
- BTCPay release and first-boot health checks no longer depend on `curl` inside the container; they use a bash TCP probe that works with the official image out of the box.
- Host nginx now serves Nginx Proxy Manager HTTP-01 challenge files before the Archipelago SPA fallback and is marked as the default HTTP/HTTPS virtual host, so public proxy hosts can issue certificates without hijacking local API traffic.
- Nginx Proxy Manager first-boot, runtime repair, and container-doctor paths now pre-create the ACME webroot, keep bind mounts owned by the rootless Archipelago user, and sync issued public proxy hosts into host nginx vhosts.
- The Nginx Proxy Manager host-nginx sync now skips proxy hosts with missing certificate files and rolls back the generated nginx include if validation fails, preventing a bad certificate path from poisoning later nginx reloads.
- App session close buttons now return to the previous dashboard screen when possible and otherwise fall back to My Apps, avoiding the 404 page after closing an app launched from an invalid or stale history entry.
- System Update confirmation and mirror modals now teleport to the document body with a full-screen overlay, so they cover the whole app instead of only the right-hand dashboard panel.
- Mobile app launches stay inside Archipelago's app-session webview and hide desktop-only new-tab launch affordances, including apps such as Home Assistant that previously looked like they would leave the mobile shell.
- Live recovery on `100.70.96.88` upgraded only the `btcpay-server` container to `docker.io/btcpayserver/btcpayserver:2.3.9`, preserved the existing datadir and Postgres database, and confirmed the container is healthy after a pre-upgrade backup.
- Public validation confirmed `spay.tx1138.com`/`www` redirect to BTCPay login over HTTPS and `sapien.tx1138.com`/`www` serve the L484 page over HTTPS using the issued Let's Encrypt certificates.
## v1.7.67-alpha (2026-05-18)
- Home dashboard status cards now keep the last known good system, VPN, Bitcoin, and FIPS values while route changes or transient RPC failures are in flight, avoiding false "not configured" or "not running" flashes.
- Home, Web5 Monitoring, and the Monitoring page headline cards now share the same live system-stat snapshot for CPU, memory, disk, uptime, and load so the visible numbers agree across the UI.
- Settings What's New is filled through `v1.7.67-alpha`, including the missing historical `v1.7.44-alpha` through `v1.7.66-alpha` entries.
- Bitcoin/Knots/Core shell lifecycle specs now match the Rust app config memory policy: 8 GiB on normal hosts, 4 GiB on low-memory hosts, and pruned Knots uses a larger dbcache on hosts with enough RAM to improve IBD throughput.
- ElectrumX/electrs shell lifecycle specs now match the 4 GiB memory policy used by the Rust app config, reducing drift between first boot, reconcile, and app lifecycle paths.
- Live assessment of `100.70.96.88` identified the current IBD bottlenecks as CPU/thermal/I/O pressure rather than RAM exhaustion, with follow-up work planned for existing-node swap repair, kiosk Chromium CPU reduction, and reconcile failure cleanup.
## v1.7.66-alpha (2026-05-18)
- Nginx Proxy Manager stale-port repair now detects stopped or `Created` Podman records by inspecting `podman ps -a` port metadata, covering records where `podman port nginx-proxy-manager` returns no mapping until start.
- Live recovery on `100.70.96.88` removed only the stale Nginx Proxy Manager container record and recreated it with `8081:81`, `8084:80`, and `8444:443`, preserving `/var/lib/archipelago/nginx-proxy-manager` data.
- Validation confirmed Nginx Proxy Manager recovered as healthy and responds through direct admin port `8081`, host compatibility port `81`, and `/app/nginx-proxy-manager/`.
## v1.7.65-alpha (2026-05-18)
- Orchestrator-backed app starts now run the same pre-start repairs as the legacy Podman path, so Nginx Proxy Manager stale `81:81` container metadata is removed and recreated before the orchestrator tries to start it.
- Live diagnostics on `100.70.96.88` confirmed host nginx is healthy while Nginx Proxy Manager has no listeners on `8081`, `8084`, or `8444`, causing host nginx `502` responses for NPM proxy paths.
## v1.7.64-alpha (2026-05-18)
- Update apply rate limiting is relaxed for authenticated admins from 2 attempts per 10 minutes to 10 attempts per minute, preventing the System Update page from getting stuck behind `429 Too Many Requests` during legitimate OTA retry/troubleshooting flows.
- The corrected backend artifact rebuild protection from `v1.7.63-alpha` remains in place, so this release is built from a fresh Rust backend binary before publishing.
## v1.7.63-alpha (2026-05-18)
- Release automation now rebuilds the Rust backend after bumping the version and before hashing release artifacts, preventing OTA manifests from pointing at a stale backend binary.
- This corrected release carries the Nginx Proxy Manager stale-port repair in an updated backend binary, so nodes running `1.7.61-alpha` can actually receive and execute the fix.
- Validation confirmed the previously published `v1.7.62-alpha` backend artifact still contained `1.7.61-alpha`, explaining why nodes did not advance after applying that update.
## v1.7.62-alpha (2026-05-18)
- Nginx Proxy Manager start and restart now repair stale Podman containers that still publish the admin UI on host port `81`, which conflicts with host nginx on updated nodes.
- The repair recreates only the stale Nginx Proxy Manager container metadata while preserving `/var/lib/archipelago/nginx-proxy-manager` data and using the current `8081:81`, `8084:80`, and `8444:443` mappings.
- Runtime stale-listener cleanup for Nginx Proxy Manager is shared across start and restart paths so rootless port helper leftovers are still cleared before lifecycle retries.
- Validation passed with `cargo fmt --all --check --manifest-path core/Cargo.toml` and `cargo check -p archipelago --manifest-path core/Cargo.toml`.
## v1.7.61-alpha (2026-05-18)
- Multi-container stack installs now keep their app card in the `Installing` state for up to 20 minutes while dependency containers are being pulled and prepared.
- BTCPay Server installs no longer appear to vanish or fail after two minutes while Postgres and NBXplorer are still being created before the primary `btcpay-server` container exists.
- The stale-transition escape hatch remains short for start, stop, restart, update, and removal operations, so genuinely wedged lifecycle actions still recover quickly.
- Live validation on `100.70.96.88` confirmed BTCPay Server completed installation and responds on port `23000` with the expected HTTP redirect.
## v1.7.60-alpha (2026-05-18)
- Meshtastic serial detection now rejects malformed or incomplete handshakes instead of accepting unrelated serial devices as a fallback Meshtastic radio.
- Mesh radio auto-detection now skips known non-mesh serial devices such as Sierra Wireless LTE modems and Zooz/Z-Wave sticks, avoiding interference with production peripherals.
- Meshtastic config sync now sends `want_config_id` with the correct protobuf wire type, fixing radio-side `ignore malformed toradio` errors and allowing node-info/contact ingestion.
- The stable `/dev/mesh-radio` udev rule no longer claims every `ttyACM*` device; it only matches known mesh USB serial adapters and known USB CDC ACM radio vendors.
- Live validation on `100.70.96.88` confirmed Archipelago selects `/dev/ttyUSB0`, identifies the Meshtastic node, and refreshes 103 mesh contacts.
## v1.7.59-alpha (2026-05-17)
- Mobile app launching now keeps known container apps inside Archipelago's app-session flow instead of forcing desktop-only new-tab behavior on phones.
- App sessions on mobile now respect the status-bar safe area so foreground iframe content starts below the device chrome while the fullscreen backdrop remains edge-to-edge.
- Prepackaged website launch buttons now resolve their curated website URLs before website-container fallback logic, restoring launches for the L484 sites and adding the Arch Presentation bookmark.
- Meshtastic contact discovery now drains the radio config stream through completion and retries config sync when the contact cache is empty, so nearby nodes already known by the radio are more likely to appear in Archipelago.
- The Apps page now includes a compact sideload button and modal for installing trusted Docker images with optional title, description, and port mapping metadata.
- Sideloaded app title and description metadata now persist through the backend app-config file so refreshed package scans do not collapse custom apps back to generic IDs.
- Validation passed with `npm test -- appLauncher`, `npm run build`, `cargo check -p archipelago`, and `cargo fmt --all --check`.
## v1.7.58-alpha (2026-05-17)
- Mesh networking now supports Meshtastic radios over the Meshtastic serial API in addition to existing MeshCore Companion USB radios.
- The mesh listener now probes preferred and auto-detected serial paths for both MeshCore and Meshtastic firmware, preserving the existing reconnect loop so unplug/replug and firmware hot-swap behavior stays consistent.
- Meshtastic text packets are translated into the existing Archipelago mesh frame pipeline, so current RPC handlers, transport routing, message storage, typed-message decoding, and UI state continue to work without a separate frontend path.
- Meshtastic node information is surfaced as normal mesh contacts using stable synthetic public keys derived from Meshtastic node numbers, allowing peer refresh and message attribution to reuse existing MeshCore contact handling.
- Outbound Archipelago mesh messages can now be sent through Meshtastic as channel text packets using the same command path used by MeshCore channel broadcasts.
- Device status now reports the detected firmware family as `meshcore` or `meshtastic` from the shared listener abstraction.
- Radio udev rules now include USB CDC ACM serial devices (`ttyACM*`) alongside CP2102, CH340, and FTDI adapters so Meshtastic boards are more likely to appear through the stable `/dev/mesh-radio` symlink.
- Host nginx now serves `/assets/*` hashed frontend chunks as immutable static files with a hard 404 on misses instead of falling back to `index.html`, preventing strict MIME errors when a browser has a stale pre-update HTML shell.
- The SPA HTML shell and service-worker files now revalidate on every load, reducing stale frontend references after OTA updates.
- OTA runtime promotion now installs the bundled `nginx-archipelago.conf` into `/etc/nginx/sites-available/archipelago` and reloads nginx after a successful config test, so frontend cache/fallback fixes reach existing nodes without a manual deploy.
- Local validation passed with `cargo check -p archipelago`; live SSH testing against `100.70.96.88` was not completed because temporary public-key authentication was rejected on the target.
## v1.7.57-alpha (2026-05-17)
- Nginx Proxy Manager now avoids privileged rootless Podman host port `81`, preferring `8081:81` while host nginx keeps a compatibility proxy on `:81` for stale cached launch buttons.
- App installs now allocate ports by checking live host bind availability, falling back to a free high port when preferred ports are already occupied.
- Portainer-created launchable containers are separated into a `Websites` tab and launch through their discovered published host port instead of hard-coded app URLs.
- Internal BuildKit helper containers such as `buildx_buildkit_default` are hidden from the Apps UI.
- Portainer works out of the box on Debian 13/Podman installs by including `catatonit` and by preserving the Podman socket mount as a socket rather than creating it as a directory.
## v1.7.56-alpha (2026-05-15)
- Health notifications now clear when an app is no longer unhealthy, including stale alerts for removed containers such as Portainer.
- Fresh installs now include the full Wi-Fi userspace stack (`wpasupplicant`, `wireless-regdb`, `iw`, `rfkill`, `polkitd`, `pciutils`, and `usbutils`) so NetworkManager can scan and connect with Intel Wi-Fi cards out of the box.
- The installed system now grants the `archipelago` service user explicit NetworkManager PolicyKit access for web-triggered Wi-Fi scans and connection changes.
- Wi-Fi connect now replaces stale/partial NetworkManager profiles and creates an explicit WPA-PSK profile with the supplied password, avoiding no-secret retry failures after a failed attempt.
- Settings password changes now update the Linux/SSH password through non-interactive sudo, so the web password and SSH password stay in sync when the checkbox is enabled.
- Quadlet environment values with spaces or shell metacharacters are quoted consistently, preventing env drift recreate loops for apps like nostr-rs-relay and Grafana.
- Boot/bootstrap reconcile avoids restarting running Bitcoin containers while repairing RPC config, preserving IBD progress on active nodes.
- Exit code 137 is labeled as SIGKILL instead of assuming OOM, avoiding false OOM alerts for orchestrator-managed recreates.
- Container reconcile force-recreates Podman records stuck in `Stopping`, preserving bind-mounted app data while recovering wedged containers automatically.
- Container health reporting is honest for running containers: Archipelago surfaces Podman's actual health state instead of marking every running container healthy.
- Quadlet reconciliation restarts services when stale health gates, port bindings, network aliases, exec commands, or healthchecks drift from the current manifest.
- Bitcoin Knots sync performance improves on fresh installs and updates with 8Gi container memory, a 4Gi dbcache, and full CPU parallelism.
- ElectrumX initial indexing gets more headroom: CPU caps are removed, memory is raised to 4Gi, cache is raised to 3Gi, and oversized sends are allowed for heavier wallet/indexing workloads.
- Mempool/ElectrumX lifecycle qualification respects pruned/non-archival Bitcoin nodes instead of installing a half-running stack with unhealthy dependencies.
- LND wallet/RPC helpers are more tolerant of container-owned files and updated REST port metadata, improving LND lifecycle and wallet-connect flows.
- Marketplace/catalog metadata carries richer container config so remote lifecycle tests install apps using the same settings users get from the UI.
- The app screensaver no longer activates during media-heavy app sessions such as IndeeHub, Jellyfin, Immich, PhotoPrism, and File Browser; apps can also pause/resume it with media playback messages.
- A fresh `1.7.56-alpha` unbundled installer ISO is built from the same primary VPS2 release line for easy download and USB flashing.
## v1.7.55-alpha (2026-05-13)
+36 -2
View File
@@ -52,18 +52,35 @@
{
"id": "btcpay-server",
"title": "BTCPay Server",
"version": "1.13.7",
"version": "2.3.9",
"description": "Self-hosted Bitcoin payment processor.",
"icon": "/assets/img/app-icons/btcpay-server.png",
"author": "BTCPay Server Foundation",
"category": "commerce",
"tier": "core",
"dockerImage": "146.59.87.168:3000/lfg2025/btcpayserver:1.13.7",
"dockerImage": "docker.io/btcpayserver/btcpayserver:2.3.9",
"repoUrl": "https://github.com/btcpayserver/btcpayserver",
"requires": [
"bitcoin-knots"
]
},
{
"id": "saleor",
"title": "Saleor",
"version": "3.23",
"description": "Composable commerce platform with customer storefront, GraphQL API, dashboard, worker, mail testing, and tracing.",
"icon": "/assets/img/app-icons/saleor.svg",
"author": "Saleor",
"category": "commerce",
"tier": "recommended",
"dockerImage": "ghcr.io/saleor/saleor:3.23",
"repoUrl": "https://github.com/saleor/saleor",
"containerConfig": {
"ports": ["9011:80", "9010:80", "8000:8000", "8025:8025", "16686:16686"],
"volumes": ["/var/lib/archipelago/saleor:/app/media", "/var/lib/archipelago/saleor-db:/var/lib/postgresql/data"],
"notes": "Installed as a Saleor stack: customer storefront on 9011, admin dashboard on 9010, API on 8000, Mailpit on 8025, and Jaeger on 16686. Supporting containers include PostgreSQL, Valkey, Celery worker, and services required by Saleor."
}
},
{
"id": "mempool",
"title": "Mempool Explorer",
@@ -275,6 +292,23 @@
"args": ["sh", "-c", "tailscaled --tun=userspace-networking & sleep 2; tailscale web --listen 0.0.0.0:8240 & wait"]
}
},
{
"id": "netbird",
"title": "NetBird",
"version": "0.71.2",
"description": "Self-hosted WireGuard mesh VPN control plane with dashboard, embedded identity provider, management API, signal, relay, and STUN service.",
"icon": "/assets/img/app-icons/netbird.svg",
"author": "NetBird",
"category": "networking",
"tier": "recommended",
"dockerImage": "docker.io/netbirdio/dashboard:v2.38.0",
"repoUrl": "https://github.com/netbirdio/netbird",
"containerConfig": {
"ports": ["8087:80", "8086:80", "3478:3478/udp"],
"volumes": ["/var/lib/archipelago/netbird:/var/lib/netbird"],
"notes": "Installed as a two-container stack: netbird dashboard on 8087 and netbird-server control plane on 8086 plus UDP 3478. For production clients, publish a DNS name over HTTPS with gRPC/WebSocket routing."
}
},
{
"id": "uptime-kuma",
"title": "Uptime Kuma",
+2 -2
View File
@@ -1,11 +1,11 @@
app:
id: btcpay-server
name: BTCPay Server
version: 1.13.7
version: 2.3.9
description: Self-hosted Bitcoin payment processor. Accept Bitcoin payments without intermediaries.
container:
image: git.tx1138.com/lfg2025/btcpayserver:1.13.7
image: docker.io/btcpayserver/btcpayserver:2.3.9
pull_policy: if-not-present
network: archy-net
secret_env:
+1 -1
View File
@@ -80,7 +80,7 @@ checksum = "a23eb6b1614318a8071c9b2521f36b424b2c83db5eb3a0fead4a6c0809af6e61"
[[package]]
name = "archipelago"
version = "1.7.56-alpha"
version = "1.7.79-alpha"
dependencies = [
"anyhow",
"archipelago-container",
+1 -1
View File
@@ -1,6 +1,6 @@
[package]
name = "archipelago"
version = "1.7.56-alpha"
version = "1.7.80-alpha"
edition = "2021"
description = "Archipelago Bitcoin Node OS - Native backend"
authors = ["Archipelago Team"]
@@ -55,6 +55,7 @@ impl RpcHandler {
"package.restart" => self.handle_package_restart(params).await,
"package.uninstall" => self.clone().spawn_package_uninstall(params).await,
"package.update" => self.clone().spawn_package_update(params).await,
"package.credentials" => self.handle_package_credentials(params).await,
"app.filebrowser-token" => self.handle_filebrowser_token().await,
// Bundled app management (for pre-loaded container images)
+51 -3
View File
@@ -307,14 +307,62 @@ async fn scan_wifi() -> Result<Vec<serde_json::Value>> {
/// Connect to a WiFi network using nmcli.
async fn connect_wifi(ssid: &str, password: &str) -> Result<()> {
let conn_name = format!("archipelago-wifi-{ssid}");
// Delete prior profiles for this SSID/name first. Failed attempts can leave
// a profile with key-mgmt but no saved PSK, causing future retries to fail
// with "no secrets" before the supplied password is used.
let _ = tokio::process::Command::new("nmcli")
.args(["connection", "delete", &conn_name])
.output()
.await;
let _ = tokio::process::Command::new("nmcli")
.args(["connection", "delete", ssid])
.output()
.await;
let output = tokio::process::Command::new("nmcli")
.args(["device", "wifi", "connect", ssid, "password", password])
.args([
"connection",
"add",
"type",
"wifi",
"con-name",
&conn_name,
"ifname",
"*",
"ssid",
ssid,
"wifi-sec.key-mgmt",
"wpa-psk",
"wifi-sec.psk",
password,
"ipv4.method",
"auto",
"ipv6.method",
"auto",
])
.output()
.await
.context("Failed to run nmcli wifi profile create")?;
if !output.status.success() {
let stderr = String::from_utf8_lossy(&output.stderr);
anyhow::bail!("WiFi profile create failed: {}", stderr);
}
let activate = tokio::process::Command::new("nmcli")
.args(["connection", "up", &conn_name])
.output()
.await
.context("Failed to run nmcli wifi connect")?;
if !output.status.success() {
let stderr = String::from_utf8_lossy(&output.stderr);
if !activate.status.success() {
let stderr = String::from_utf8_lossy(&activate.stderr);
let _ = tokio::process::Command::new("nmcli")
.args(["connection", "delete", &conn_name])
.output()
.await;
anyhow::bail!("WiFi connection failed: {}", stderr);
}
+58 -17
View File
@@ -31,6 +31,7 @@ fn is_platform_managed_app(app_id: &str) -> bool {
| "fedimint"
| "fedimint-gateway"
| "indeedhub"
| "saleor"
| "immich"
)
}
@@ -153,11 +154,13 @@ pub(super) fn get_app_capabilities(app_id: &str) -> Vec<String> {
"--cap-add=SETGID".to_string(),
"--cap-add=DAC_OVERRIDE".to_string(),
],
// Nginx Proxy Manager needs to bind low ports
// Nginx Proxy Manager initializes/chowns mounted state on first boot.
"nginx-proxy-manager" => vec![
"--cap-add=CHOWN".to_string(),
"--cap-add=FOWNER".to_string(),
"--cap-add=SETUID".to_string(),
"--cap-add=SETGID".to_string(),
"--cap-add=DAC_OVERRIDE".to_string(),
"--cap-add=NET_BIND_SERVICE".to_string(),
],
// Bitcoin needs only file-ownership ops + NET_BIND_SERVICE for the
@@ -215,7 +218,7 @@ pub(super) fn get_app_capabilities(app_id: &str) -> Vec<String> {
"--cap-add=DAC_OVERRIDE".to_string(),
"--cap-add=NET_BIND_SERVICE".to_string(),
],
// Nostr VPN and FIPS: mesh networking daemons need TUN + NET_ADMIN
// VPN/mesh daemons need TUN + NET_ADMIN.
// Note: --device=/dev/net/tun is added separately in install.rs
"nostr-vpn" | "fips" => vec![
"--cap-add=NET_ADMIN".to_string(),
@@ -260,7 +263,7 @@ pub(super) fn get_health_check_args(app_id: &str, _rpc_pass: &str) -> Vec<String
"bitcoin" | "bitcoin-core" | "bitcoin-knots" => return vec![],
"lnd" => ("lncli getinfo || exit 1", "30s", "3"),
"btcpay-server" | "btcpayserver" => {
("curl -sf http://localhost:49392/ || exit 1", "30s", "3")
("bash -ec '</dev/tcp/127.0.0.1/49392'", "30s", "3")
}
"mempool-api" => (
http_probe_cmd("http://localhost:8999/api/v1/backend-info"),
@@ -387,6 +390,7 @@ pub(super) fn get_memory_limit(app_id: &str) -> &'static str {
"nostr-rs-relay" | "nostr-relay" => "256m",
"routstr" => "512m",
"nostr-vpn" => "256m",
"netbird" => "1g",
"fips" => "256m",
"nginx-proxy-manager" => "256m",
// Databases
@@ -492,6 +496,20 @@ pub(super) fn all_container_names(package_id: &str) -> Vec<String> {
"indeedhub-ffmpeg".into(),
"indeedhub".into(),
],
"netbird" => vec![
"netbird".into(),
"netbird-dashboard".into(),
"netbird-server".into(),
],
"saleor" => vec![
"saleor-db".into(),
"saleor-cache".into(),
"saleor-api".into(),
"saleor-worker".into(),
"saleor-jaeger".into(),
"saleor-mailpit".into(),
"saleor".into(),
],
"nostr-vpn" => vec![
"nostr-vpn".into(),
"archy-nostr-vpn".into(),
@@ -580,6 +598,8 @@ pub(super) fn get_data_dirs_for_app(package_id: &str) -> Vec<String> {
format!("{}/penpot-assets", base),
format!("{}/penpot-postgres", base),
],
"netbird" => vec![format!("{}/netbird", base)],
"saleor" => vec![format!("{}/saleor", base), format!("{}/saleor-db", base)],
_ => vec![format!("{}/{}", base, package_id)],
}
}
@@ -924,20 +944,34 @@ pub(super) async fn get_app_config(
]),
)
}
"nginx-proxy-manager" => (
vec![
"81:81".to_string(),
"8084:80".to_string(),
"8444:443".to_string(),
],
vec![
"/var/lib/archipelago/nginx-proxy-manager/data:/data".to_string(),
"/var/lib/archipelago/nginx-proxy-manager/letsencrypt:/etc/letsencrypt".to_string(),
],
vec![],
None,
None,
),
"nginx-proxy-manager" => {
let admin_port = allocator
.allocate_or_get(app_id, 8081, 81)
.await
.unwrap_or(8081);
let http_port = allocator
.allocate_or_get("nginx-proxy-manager-http", 8084, 80)
.await
.unwrap_or(8084);
let https_port = allocator
.allocate_or_get("nginx-proxy-manager-https", 8444, 443)
.await
.unwrap_or(8444);
(
vec![
format!("{}:81", admin_port),
format!("{}:80", http_port),
format!("{}:443", https_port),
],
vec![
"/var/lib/archipelago/nginx-proxy-manager/data:/data".to_string(),
"/var/lib/archipelago/nginx-proxy-manager/letsencrypt:/etc/letsencrypt".to_string(),
],
vec![],
None,
None,
)
}
"portainer" => (
vec!["9000:9000".to_string()],
vec![
@@ -1045,6 +1079,13 @@ pub(super) async fn get_app_config(
None,
None,
),
"saleor" => (
vec!["9010:80".to_string(), "8000:8000".to_string()],
vec!["/var/lib/archipelago/saleor:/app/media".to_string()],
vec![],
None,
None,
),
"nostr-rs-relay" => (
vec!["18081:8080".to_string()],
vec!["/var/lib/archipelago/nostr-rs-relay:/usr/src/app/db".to_string()],
@@ -288,6 +288,16 @@ pub(super) fn startup_order(package_id: &str) -> &'static [&'static str] {
"btcpay-server" | "btcpayserver" | "btcpay" => {
&["archy-btcpay-db", "archy-nbxplorer", "btcpay-server"]
}
"netbird" => &["netbird-server", "netbird-dashboard", "netbird"],
"saleor" => &[
"saleor-db",
"saleor-cache",
"saleor-jaeger",
"saleor-mailpit",
"saleor-api",
"saleor-worker",
"saleor",
],
"penpot" | "penpot-frontend" => &[
"penpot-postgres",
"penpot-valkey",
@@ -389,6 +399,14 @@ mod tests {
);
}
#[test]
fn netbird_start_order_starts_server_before_dashboard() {
assert_eq!(
startup_order("netbird"),
&["netbird-server", "netbird-dashboard", "netbird"]
);
}
#[test]
fn unpruned_bitcoin_required_for_electrum_indexers_and_mempool() {
for package_id in [
@@ -241,6 +241,12 @@ impl RpcHandler {
if package_id == "indeedhub" {
return self.install_indeedhub_stack().await;
}
if package_id == "netbird" {
return self.install_netbird_stack().await;
}
if package_id == "saleor" {
return self.install_saleor_stack().await;
}
// Dependency checks. Prefer the scanner's cached package state so a
// congested Podman API does not turn an already-running dependency into
@@ -376,7 +382,8 @@ impl RpcHandler {
package_id
))
.await;
if let Err(e) = ensure_host_port_listener(package_id, package_id).await {
if let Err(e) = ensure_host_port_listener(package_id, package_id, &[]).await
{
install_log(&format!(
"INSTALL ADOPT RECREATE: {} — host listener repair failed, removing wedged container: {}",
package_id, e
@@ -402,7 +409,7 @@ impl RpcHandler {
package_id
))
.await;
if let Err(e) = ensure_host_port_listener(package_id, package_id).await {
if let Err(e) = ensure_host_port_listener(package_id, package_id, &[]).await {
install_log(&format!(
"INSTALL ADOPT RECREATE: {} — host listener repair failed, removing wedged container: {}",
package_id, e
@@ -880,7 +887,7 @@ impl RpcHandler {
repair_nextcloud_permissions().await;
}
ensure_host_port_listener(package_id, container_name).await?;
ensure_host_port_listener(package_id, container_name, &ports).await?;
install_log(&format!(
"INSTALL OK: {} (container: {})",
@@ -1852,6 +1859,42 @@ autopilot.active=false\n",
Ok(serde_json::json!({ "token": token }))
}
pub(in crate::api::rpc) async fn handle_package_credentials(
&self,
params: Option<serde_json::Value>,
) -> Result<serde_json::Value> {
let app_id = params
.as_ref()
.and_then(|p| p.get("app_id"))
.and_then(|v| v.as_str())
.unwrap_or_default();
super::validation::validate_app_id(app_id)?;
match app_id {
"saleor" => {
let password =
tokio::fs::read_to_string("/var/lib/archipelago/secrets/saleor-admin-password")
.await
.unwrap_or_default()
.trim()
.to_string();
if password.is_empty() {
return Ok(serde_json::json!({ "credentials": [] }));
}
Ok(serde_json::json!({
"title": "Saleor admin login",
"description": "Saleor opens to its own dashboard login. Use this generated admin account to sign in.",
"credentials": [
{ "label": "Email", "value": "admin@example.com", "sensitive": false },
{ "label": "Password", "value": password, "sensitive": true }
]
}))
}
_ => Ok(serde_json::json!({ "credentials": [] })),
}
}
}
async fn cleanup_stale_package_ports(package_id: &str) {
@@ -1866,7 +1909,7 @@ async fn cleanup_stale_package_ports(package_id: &str) {
cleanup_stale_pasta_port("3000").await;
}
"nginx-proxy-manager" => {
cleanup_stale_pasta_port("81").await;
cleanup_stale_pasta_port("8081").await;
cleanup_stale_pasta_port("8084").await;
cleanup_stale_pasta_port("8444").await;
}
@@ -1914,7 +1957,7 @@ async fn cleanup_start_conflict(package_id: &str, stderr: &str) -> bool {
"nginx-proxy-manager"
if stderr.contains("pasta failed") || stderr.contains("address already in use") =>
{
cleanup_stale_pasta_port("81").await;
cleanup_stale_pasta_port("8081").await;
cleanup_stale_pasta_port("8084").await;
cleanup_stale_pasta_port("8444").await;
true
@@ -1968,8 +2011,18 @@ async fn repair_nextcloud_permissions() {
}
}
async fn ensure_host_port_listener(package_id: &str, container_name: &str) -> Result<()> {
let Some(port) = required_host_port(package_id) else {
async fn ensure_host_port_listener(
package_id: &str,
container_name: &str,
runtime_ports: &[String],
) -> Result<()> {
let Some(port) = runtime_ports
.first()
.and_then(|p| p.split(':').next())
.and_then(|p| p.parse::<u16>().ok())
.or_else(|| published_host_port(container_name))
.or_else(|| required_host_port(package_id))
else {
return Ok(());
};
@@ -2015,6 +2068,22 @@ async fn ensure_host_port_listener(package_id: &str, container_name: &str) -> Re
))
}
fn published_host_port(container_name: &str) -> Option<u16> {
let output = std::process::Command::new("podman")
.args(["port", container_name])
.output()
.ok()?;
if !output.status.success() {
return None;
}
let stdout = String::from_utf8_lossy(&output.stdout);
stdout.lines().find_map(|line| {
line.rsplit(':')
.next()
.and_then(|p| p.trim().parse::<u16>().ok())
})
}
async fn ensure_user_podman_socket() -> Result<()> {
let socket_path = "/run/user/1000/podman/podman.sock";
if tokio::fs::try_exists(socket_path).await.unwrap_or(false) {
@@ -2048,7 +2117,7 @@ fn required_host_port(package_id: &str) -> Option<u16> {
"uptime-kuma" => Some(3002),
"gitea" => Some(3001),
"nextcloud" => Some(8085),
"nginx-proxy-manager" => Some(81),
"nginx-proxy-manager" => Some(8081),
_ => None,
}
}
+153 -7
View File
@@ -1,4 +1,7 @@
use super::config::{get_containers_for_app, get_data_dirs_for_app, is_valid_docker_image};
use super::config::{
get_app_capabilities, get_containers_for_app, get_data_dirs_for_app, get_health_check_args,
get_memory_limit, is_valid_docker_image,
};
use super::dependencies::ordered_containers_for_start;
use super::install::install_log;
use super::validation::validate_app_id;
@@ -366,6 +369,10 @@ impl RpcHandler {
{
let mut allocator = self.port_allocator.lock().await;
let _ = allocator.release(package_id).await;
if package_id == "nginx-proxy-manager" {
let _ = allocator.release("nginx-proxy-manager-http").await;
let _ = allocator.release("nginx-proxy-manager-https").await;
}
}
// Clean data directories unless preserve_data
@@ -661,6 +668,7 @@ async fn do_orchestrator_package_start(
if i > 0 {
tokio::time::sleep(std::time::Duration::from_secs(2)).await;
}
repair_before_package_start(name).await;
match orchestrator.start(name).await {
Ok(()) => wait_after_orchestrator_start(name).await,
Err(e) if is_unknown_app_id_error(&e) => {
@@ -859,11 +867,147 @@ async fn repair_before_package_start(container_name: &str) {
repair_nextcloud_dirs().await;
cleanup_stale_pasta_port("8085").await;
}
"nginx-proxy-manager" => repair_nginx_proxy_manager_container().await,
"gitea" => cleanup_gitea_stale_ports().await,
_ => {}
}
}
async fn repair_nginx_proxy_manager_container() {
repair_nginx_proxy_manager_dirs().await;
if !nginx_proxy_manager_has_legacy_admin_port().await {
cleanup_nginx_proxy_manager_ports().await;
return;
}
install_log(
"START REPAIR: nginx-proxy-manager - recreating stale container using host port 8081",
)
.await;
let _ = podman_control(&["rm", "-f", "nginx-proxy-manager"]).await;
cleanup_nginx_proxy_manager_ports().await;
if let Err(err) = recreate_nginx_proxy_manager_container().await {
tracing::warn!(error = %err, "failed to recreate stale nginx-proxy-manager container");
}
}
async fn repair_nginx_proxy_manager_dirs() {
let _ = tokio::process::Command::new("sudo")
.args([
"mkdir",
"-p",
"/var/lib/archipelago/nginx-proxy-manager/data/letsencrypt-acme-challenge/.well-known/acme-challenge",
"/var/lib/archipelago/nginx-proxy-manager/letsencrypt",
])
.output()
.await;
let _ = tokio::process::Command::new("sudo")
.args([
"chown",
"-R",
"1000:1000",
"/var/lib/archipelago/nginx-proxy-manager",
])
.output()
.await;
}
async fn nginx_proxy_manager_has_legacy_admin_port() -> bool {
if let Ok(output) = podman_control(&["port", "nginx-proxy-manager", "81/tcp"]).await {
if output.status.success()
&& String::from_utf8_lossy(&output.stdout).lines().any(|line| {
line.rsplit(':')
.next()
.is_some_and(|port| port.trim() == "81")
})
{
return true;
}
}
let Ok(output) = podman_control(&[
"ps",
"-a",
"--format",
"{{.Ports}}",
"--filter",
"name=^nginx-proxy-manager$",
])
.await
else {
return false;
};
if !output.status.success() {
return false;
}
let ports = String::from_utf8_lossy(&output.stdout);
ports.contains(":81->81/tcp") || ports.contains(":8443->443/tcp")
}
async fn recreate_nginx_proxy_manager_container() -> Result<()> {
tokio::process::Command::new("sudo")
.args([
"mkdir",
"-p",
"/var/lib/archipelago/nginx-proxy-manager/data/letsencrypt-acme-challenge/.well-known/acme-challenge",
"/var/lib/archipelago/nginx-proxy-manager/letsencrypt",
])
.output()
.await
.context("failed to create nginx-proxy-manager data directories")?;
let _ = tokio::process::Command::new("sudo")
.args([
"chown",
"-R",
"1000:1000",
"/var/lib/archipelago/nginx-proxy-manager",
])
.output()
.await;
let image = crate::container::image_versions::pinned_image_for_app("nginx-proxy-manager")
.unwrap_or_else(|| "docker.io/jc21/nginx-proxy-manager:latest".to_string());
let mut args = vec![
"run".to_string(),
"-d".to_string(),
"--name".to_string(),
"nginx-proxy-manager".to_string(),
"--restart=unless-stopped".to_string(),
"--network=slirp4netns:allow_host_loopback=true".to_string(),
"--cap-drop=ALL".to_string(),
"--security-opt=no-new-privileges:true".to_string(),
"--pids-limit=4096".to_string(),
];
args.extend(get_app_capabilities("nginx-proxy-manager"));
args.extend([
"-p".to_string(),
"8081:81".to_string(),
"-p".to_string(),
"8084:80".to_string(),
"-p".to_string(),
"8444:443".to_string(),
"-v".to_string(),
"/var/lib/archipelago/nginx-proxy-manager/data:/data".to_string(),
"-v".to_string(),
"/var/lib/archipelago/nginx-proxy-manager/letsencrypt:/etc/letsencrypt".to_string(),
"--memory".to_string(),
get_memory_limit("nginx-proxy-manager").to_string(),
"--cpus=2".to_string(),
]);
args.extend(get_health_check_args("nginx-proxy-manager", ""));
args.push(image);
let refs = args.iter().map(String::as_str).collect::<Vec<_>>();
let output = podman_control(&refs).await?;
if !output.status.success() {
anyhow::bail!(
"podman run nginx-proxy-manager failed: {}",
String::from_utf8_lossy(&output.stderr).trim()
);
}
Ok(())
}
async fn ensure_runtime_host_port_listener(container_name: &str) -> Result<()> {
let Some(port) = runtime_required_host_port(container_name) else {
return Ok(());
@@ -916,7 +1060,7 @@ fn runtime_required_host_port(container_name: &str) -> Option<u16> {
"vaultwarden" => Some(8082),
"gitea" => Some(3001),
"nextcloud" => Some(8085),
"nginx-proxy-manager" => Some(81),
"nginx-proxy-manager" => Some(8081),
_ => None,
}
}
@@ -1071,15 +1215,17 @@ async fn cleanup_start_conflict(container_name: &str, stderr: &str) {
"homeassistant" | "home-assistant" => cleanup_stale_pasta_port("8123").await,
"vaultwarden" => cleanup_stale_pasta_port("8082").await,
"nextcloud" => cleanup_stale_pasta_port("8085").await,
"nginx-proxy-manager" => {
cleanup_stale_pasta_port("81").await;
cleanup_stale_pasta_port("8084").await;
cleanup_stale_pasta_port("8444").await;
}
"nginx-proxy-manager" => cleanup_nginx_proxy_manager_ports().await,
_ => {}
}
}
async fn cleanup_nginx_proxy_manager_ports() {
cleanup_stale_pasta_port("8081").await;
cleanup_stale_pasta_port("8084").await;
cleanup_stale_pasta_port("8444").await;
}
async fn cleanup_stale_pasta_port(port: &str) {
let kill_listener = format!(
"ss -ltnp 'sport = :{}' 2>/dev/null | sed -n 's/.*pid=\\([0-9]*\\).*/\\1/p' | xargs -r kill 2>/dev/null || true",
File diff suppressed because it is too large Load Diff
+1 -1
View File
@@ -353,7 +353,7 @@ pub(in crate::api::rpc) fn known_service_port(name: &str) -> u16 {
"immich" => 2283,
"photoprism" => 2342,
"penpot" => 9001,
"nginx-proxy-manager" => 81,
"nginx-proxy-manager" => 8081,
"vaultwarden" => 8343,
"indeedhub" => 7778,
_ => 0,
+3 -3
View File
@@ -381,14 +381,14 @@ async fn change_ssh_password(new_password: &str) -> Result<()> {
// usermod -p writes directly to /etc/shadow, bypassing PAM
// Use /usr/sbin/usermod - not always in systemd's PATH
let status = tokio::process::Command::new("/usr/sbin/usermod")
.args(["-p", &hash, &ssh_user])
let status = tokio::process::Command::new("/usr/bin/sudo")
.args(["-n", "/usr/sbin/usermod", "-p", &hash, &ssh_user])
.output()
.await?;
if !status.status.success() {
let stderr = String::from_utf8_lossy(&status.stderr);
anyhow::bail!("usermod failed: {}", stderr);
anyhow::bail!("sudo usermod failed: {}", stderr);
}
tracing::info!("SSH password updated for user {}", ssh_user);
+31
View File
@@ -233,6 +233,24 @@ async fn run_runtime_assets() -> Result<bool> {
}
let configs = runtime_dir.join("image-recipe/configs");
let nginx_src = configs.join("nginx-archipelago.conf");
if nginx_src.exists() {
let src_s = nginx_src.to_string_lossy().to_string();
let status = host_sudo(&[
"install",
"-m",
"644",
&src_s,
"/etc/nginx/sites-available/archipelago",
])
.await
.context("install nginx-archipelago.conf")?;
if !status.success() {
anyhow::bail!("install nginx-archipelago.conf exited with {}", status);
}
changed = true;
}
for unit in ["archipelago-doctor.service", "archipelago-doctor.timer"] {
let src = configs.join(unit);
if src.exists() {
@@ -250,6 +268,19 @@ async fn run_runtime_assets() -> Result<bool> {
if changed {
let _ = host_sudo(&["systemctl", "daemon-reload"]).await;
if nginx_src.exists() {
match host_sudo(&["nginx", "-t"]).await {
Ok(status) if status.success() => {
let _ = host_sudo(&["systemctl", "reload", "nginx"]).await;
}
Ok(status) => {
tracing::warn!("nginx config test failed after runtime sync: {}", status);
}
Err(e) => {
tracing::warn!("failed to test nginx config after runtime sync: {}", e);
}
}
}
}
Ok(changed)
}
@@ -61,6 +61,17 @@ impl DockerPackageScanner {
"indeedhub-build_minio-init_1",
"indeedhub-build_relay_1",
"indeedhub-build_ffmpeg-worker_1",
"netbird-server",
"netbird-dashboard",
"saleor-api",
"saleor-worker",
"saleor-db",
"saleor-cache",
"saleor-jaeger",
"saleor-mailpit",
"saleor-storefront",
"saleor-storefront-app",
"buildx_buildkit_default",
];
// First pass: collect running UI containers. Custom UI-backed apps must
@@ -116,6 +127,11 @@ impl DockerPackageScanner {
continue;
}
if is_transient_podman_helper(&app_id, &container.ports) {
debug!("Skipping transient Podman helper container: {}", app_id);
continue;
}
// Skip podman-compose infrastructure containers (e.g. indeedhub-build_api_1)
// These have the project prefix pattern: {project}_{service}_{instance}
if app_id.starts_with("indeedhub-build_") {
@@ -123,6 +139,11 @@ impl DockerPackageScanner {
continue;
}
if app_id.starts_with("buildx_buildkit") {
debug!("Skipping BuildKit helper container: {}", app_id);
continue;
}
// Skip UI containers (they're merged with their parent apps)
if app_id.ends_with("-ui") {
debug!("Skipping UI container: {}", app_id);
@@ -133,15 +154,22 @@ impl DockerPackageScanner {
let metadata = get_app_metadata(&app_id);
// Resolve UI address: separate UI containers > static map > dynamic ports
let lan_address = if let Some(ui_address) = ui_containers.get(&app_id) {
let lan_address = if app_id == "netbird" {
reachable_lan_address(&app_id, netbird_configured_launch_url().await).await
} else if let Some(ui_address) = ui_containers.get(&app_id) {
// Apps with separate UI containers (e.g. archy-bitcoin-ui, archy-lnd-ui)
debug!("Using UI container for {}: {}", app_id, ui_address);
reachable_lan_address(&app_id, Some(ui_address.clone())).await
} else {
// Prefer the known web UI port over arbitrary first binding
// (for example Gitea exposes SSH on 2222 before web on 3001).
let candidate = PodmanClient::lan_address_for(&app_id)
.or_else(|| extract_lan_address(&container.ports));
let candidate = if uses_allocated_launch_port(&app_id) {
extract_lan_address(&container.ports)
.or_else(|| PodmanClient::lan_address_for(&app_id))
} else {
PodmanClient::lan_address_for(&app_id)
.or_else(|| extract_lan_address(&container.ports))
};
reachable_lan_address(&app_id, candidate).await
};
@@ -270,13 +298,29 @@ fn get_app_tier(app_id: &str) -> &'static str {
"uptime-kuma" => "recommended",
"grafana" => "recommended",
"searxng" => "recommended",
"tailscale" => "recommended",
"saleor" => "recommended",
"tailscale" | "netbird" => "recommended",
"portainer" => "recommended",
// Optional: everything else
_ => "optional",
}
}
fn is_transient_podman_helper(app_id: &str, ports: &[String]) -> bool {
if !ports.is_empty() {
return false;
}
let Some((left, right)) = app_id.split_once('_') else {
return false;
};
!left.is_empty()
&& !right.is_empty()
&& left.chars().all(|c| c.is_ascii_lowercase())
&& right.chars().all(|c| c.is_ascii_lowercase())
}
fn get_app_metadata(app_id: &str) -> AppMetadata {
let mut meta = match app_id {
"bitcoin-core" => AppMetadata {
@@ -468,6 +512,27 @@ fn get_app_metadata(app_id: &str) -> AppMetadata {
repo: "https://github.com/tailscale/tailscale".to_string(),
tier: "",
},
"netbird" => AppMetadata {
title: "NetBird".to_string(),
description: "Self-hosted WireGuard mesh VPN control plane and dashboard".to_string(),
icon: "/assets/img/app-icons/netbird.svg".to_string(),
repo: "https://github.com/netbirdio/netbird".to_string(),
tier: "",
},
"saleor" => AppMetadata {
title: "Saleor".to_string(),
description: "Composable commerce platform with storefront, dashboard, and GraphQL API. The customer storefront opens on port 9011; admin dashboard is on 9010 with admin@example.com credentials stored on the node.".to_string(),
icon: "/assets/img/app-icons/saleor.svg".to_string(),
repo: "https://github.com/saleor/saleor".to_string(),
tier: "",
},
"gitea" => AppMetadata {
title: "Gitea".to_string(),
description: "Self-hosted Git service with repository and package hosting".to_string(),
icon: "/assets/img/app-icons/gitea.svg".to_string(),
repo: "https://gitea.com".to_string(),
tier: "",
},
"indeedhub" | "indeehub" => AppMetadata {
title: "IndeedHub".to_string(),
description: "Decentralized media streaming platform".to_string(),
@@ -546,10 +611,37 @@ fn get_app_metadata(app_id: &str) -> AppMetadata {
tier: "",
},
};
apply_dynamic_metadata(app_id, &mut meta);
meta.tier = get_app_tier(app_id);
meta
}
fn apply_dynamic_metadata(app_id: &str, meta: &mut AppMetadata) {
let config_path = format!("/var/lib/archipelago/app-configs/{}.json", app_id);
let Ok(data) = std::fs::read_to_string(config_path) else {
return;
};
let Ok(cfg) = serde_json::from_str::<serde_json::Value>(&data) else {
return;
};
if let Some(title) = cfg
.get("title")
.and_then(|v| v.as_str())
.map(str::trim)
.filter(|s| !s.is_empty() && s.len() <= 80)
{
meta.title = title.to_string();
}
if let Some(description) = cfg
.get("description")
.and_then(|v| v.as_str())
.map(str::trim)
.filter(|s| !s.is_empty() && s.len() <= 240)
{
meta.description = description.to_string();
}
}
/// Map app_id to Tor hidden service directory name.
/// "archipelago" is the main web UI (nginx port 80).
/// Supports container names from deploy (archy-*, btcpay-server, etc.).
@@ -620,6 +712,18 @@ fn extract_lan_address(ports: &[String]) -> Option<String> {
None
}
async fn netbird_configured_launch_url() -> Option<String> {
let env = tokio::fs::read_to_string("/var/lib/archipelago/netbird/dashboard.env")
.await
.ok()?;
env.lines()
.find_map(|line| line.strip_prefix("NETBIRD_MGMT_API_ENDPOINT="))
.map(str::trim)
.filter(|s| !s.is_empty())
.map(ToOwned::to_owned)
.or_else(|| PodmanClient::lan_address_for("netbird"))
}
async fn reachable_lan_address(app_id: &str, candidate: Option<String>) -> Option<String> {
let url = candidate?;
if !requires_reachable_launch(app_id) {
@@ -662,6 +766,7 @@ fn requires_reachable_launch(app_id: &str) -> bool {
| "tailscale"
| "immich"
| "searxng"
| "saleor"
)
}
@@ -673,6 +778,13 @@ fn companion_lan_address(app_id: &str) -> Option<String> {
}
}
fn uses_allocated_launch_port(app_id: &str) -> bool {
matches!(
app_id,
"filebrowser" | "nextcloud" | "nginx-proxy-manager" | "vaultwarden"
)
}
fn convert_state(container_state: &ContainerState) -> (PackageState, ServiceStatus) {
match container_state {
ContainerState::Running => (PackageState::Running, ServiceStatus::Running),
@@ -168,6 +168,15 @@ fn image_var_for_app(app_id: &str) -> Option<&'static str> {
"nginx-proxy-manager" => Some("NPM_IMAGE"),
"portainer" => Some("PORTAINER_IMAGE"),
"tailscale" => Some("TAILSCALE_IMAGE"),
"netbird" => Some("NETBIRD_DASHBOARD_IMAGE"),
"netbird-dashboard" => Some("NETBIRD_DASHBOARD_IMAGE"),
"netbird-server" => Some("NETBIRD_SERVER_IMAGE"),
"saleor" => Some("SALEOR_DASHBOARD_IMAGE"),
"saleor-api" | "saleor-worker" => Some("SALEOR_API_IMAGE"),
"saleor-db" => Some("SALEOR_POSTGRES_IMAGE"),
"saleor-cache" => Some("SALEOR_VALKEY_IMAGE"),
"saleor-jaeger" => Some("SALEOR_JAEGER_IMAGE"),
"saleor-mailpit" => Some("SALEOR_MAILPIT_IMAGE"),
// Fedimint
"fedimint" | "fedimintd" => Some("FEDIMINT_IMAGE"),
@@ -299,6 +308,20 @@ pub fn containers_for_stack(app_id: &str) -> Vec<(&'static str, &'static str)> {
("penpot-exporter", "PENPOT_EXPORTER_IMAGE"),
("penpot-frontend", "PENPOT_FRONTEND_IMAGE"),
],
"netbird" => vec![
("netbird", "NETBIRD_PROXY_IMAGE"),
("netbird-dashboard", "NETBIRD_DASHBOARD_IMAGE"),
("netbird-server", "NETBIRD_SERVER_IMAGE"),
],
"saleor" => vec![
("saleor-db", "SALEOR_POSTGRES_IMAGE"),
("saleor-cache", "SALEOR_VALKEY_IMAGE"),
("saleor-api", "SALEOR_API_IMAGE"),
("saleor-worker", "SALEOR_API_IMAGE"),
("saleor-jaeger", "SALEOR_JAEGER_IMAGE"),
("saleor-mailpit", "SALEOR_MAILPIT_IMAGE"),
("saleor", "SALEOR_DASHBOARD_IMAGE"),
],
_ => vec![],
}
}
+113 -24
View File
@@ -1,42 +1,140 @@
//! Mesh session lifecycle: connect, initialize, main loop.
use super::super::meshtastic::MeshtasticDevice;
use super::super::serial::MeshcoreDevice;
use super::super::types::*;
use super::{
frames, MeshCommand, MeshState, ADVERT_INTERVAL, MAX_CONSECUTIVE_WRITE_FAILURES, SYNC_INTERVAL,
};
use anyhow::Result;
use anyhow::{Context, Result};
use std::sync::Arc;
use std::time::Duration;
use tokio::sync::mpsc;
use tracing::{debug, error, info, warn};
/// Scan all candidate serial ports and open the first Meshcore device found.
async fn auto_detect_and_open() -> Result<(String, MeshcoreDevice, DeviceInfo)> {
enum MeshRadioDevice {
Meshcore(MeshcoreDevice),
Meshtastic(MeshtasticDevice),
}
impl MeshRadioDevice {
fn device_type(&self) -> DeviceType {
match self {
Self::Meshcore(_) => DeviceType::Meshcore,
Self::Meshtastic(_) => DeviceType::Meshtastic,
}
}
fn advert_name(&self) -> Option<String> {
match self {
Self::Meshcore(device) => device.advert_name.clone(),
Self::Meshtastic(device) => device.advert_name(),
}
}
async fn set_advert_name(&mut self, name: &str) -> Result<()> {
match self {
Self::Meshcore(device) => device.set_advert_name(name).await,
Self::Meshtastic(device) => device.set_advert_name(name).await,
}
}
async fn send_self_advert(&mut self) -> Result<()> {
match self {
Self::Meshcore(device) => device.send_self_advert().await,
Self::Meshtastic(device) => device.send_self_advert().await,
}
}
async fn send_channel_text(&mut self, channel: u8, payload: &[u8]) -> Result<()> {
match self {
Self::Meshcore(device) => device.send_channel_text(channel, payload).await,
Self::Meshtastic(device) => device.send_channel_text(channel, payload).await,
}
}
async fn get_contacts(&mut self) -> Result<Vec<super::super::protocol::ParsedContact>> {
match self {
Self::Meshcore(device) => device.get_contacts().await,
Self::Meshtastic(device) => device.get_contacts().await,
}
}
async fn reset_contact_path(&mut self, pubkey: &[u8; 32]) -> Result<()> {
match self {
Self::Meshcore(device) => device.reset_contact_path(pubkey).await,
Self::Meshtastic(device) => device.reset_contact_path(pubkey).await,
}
}
async fn sync_messages(&mut self) -> Result<Vec<super::super::protocol::InboundFrame>> {
match self {
Self::Meshcore(device) => device.sync_messages().await,
Self::Meshtastic(device) => device.sync_messages().await,
}
}
async fn try_recv_frame(&mut self) -> Result<Option<super::super::protocol::InboundFrame>> {
match self {
Self::Meshcore(device) => device.try_recv_frame().await,
Self::Meshtastic(device) => device.try_recv_frame().await,
}
}
}
/// Scan all candidate serial ports and open the first supported mesh device found.
async fn auto_detect_and_open() -> Result<(String, MeshRadioDevice, DeviceInfo)> {
let paths = super::super::serial::detect_serial_devices().await;
if paths.is_empty() {
anyhow::bail!("No serial devices found in /dev");
}
for path in &paths {
debug!(path = %path, "Probing for Meshcore device");
debug!(path = %path, "Probing for mesh radio device");
match MeshcoreDevice::open(path).await {
Ok(mut dev) => match dev.initialize().await {
Ok(info) => {
info!(path = %path, firmware = %info.firmware_version, "Found Meshcore device via auto-detect");
return Ok((path.clone(), dev, info));
return Ok((path.clone(), MeshRadioDevice::Meshcore(dev), info));
}
Err(e) => debug!(path = %path, error = %e, "Not a Meshcore device"),
},
Err(e) => debug!(path = %path, error = %e, "Could not open serial port"),
}
match MeshtasticDevice::open(path).await {
Ok(mut dev) => match dev.initialize().await {
Ok(info) => {
info!(path = %path, firmware = %info.firmware_version, "Found Meshtastic device via auto-detect");
return Ok((path.clone(), MeshRadioDevice::Meshtastic(dev), info));
}
Err(e) => debug!(path = %path, error = %e, "Not a Meshtastic device"),
},
Err(e) => debug!(path = %path, error = %e, "Could not open serial port for Meshtastic"),
}
}
anyhow::bail!(
"No Meshcore device found on {} candidate ports: {:?}",
"No supported mesh radio found on {} candidate ports: {:?}",
paths.len(),
paths
)
}
async fn open_preferred_path(path: &str) -> Result<(MeshRadioDevice, DeviceInfo)> {
match MeshcoreDevice::open(path).await {
Ok(mut dev) => match dev.initialize().await {
Ok(info) => return Ok((MeshRadioDevice::Meshcore(dev), info)),
Err(e) => debug!(path = %path, error = %e, "Preferred path is not Meshcore"),
},
Err(e) => debug!(path = %path, error = %e, "Could not open preferred path as Meshcore"),
}
match MeshtasticDevice::open(path).await {
Ok(mut dev) => match dev.initialize().await {
Ok(info) => Ok((MeshRadioDevice::Meshtastic(dev), info)),
Err(e) => Err(e).context("Preferred path is not Meshtastic"),
},
Err(e) => Err(e).context("Could not open preferred path as Meshtastic"),
}
}
/// ASCII marker for the original DM-via-channel format:
/// `@DM:` + base64(`[dest_prefix(6)][inner…]`). No sender info on the wire,
/// so the receiver has to guess the sender from its contact table — which
@@ -90,7 +188,7 @@ fn our_sender_prefix(state: &Arc<MeshState>) -> [u8; 6] {
/// with both the recipient and sender prefixes so attribution works on
/// the receiver side.
async fn send_dm_via_channel(
device: &mut MeshcoreDevice,
device: &mut MeshRadioDevice,
state: &Arc<MeshState>,
dest_pubkey_prefix: &[u8; 6],
payload: &[u8],
@@ -166,7 +264,7 @@ async fn send_dm_via_channel(
}
/// Fetch the contacts list from the device and update the peer cache.
async fn refresh_contacts(device: &mut MeshcoreDevice, state: &Arc<MeshState>) {
async fn refresh_contacts(device: &mut MeshRadioDevice, state: &Arc<MeshState>) {
match device.get_contacts().await {
Ok(contacts) => {
// Skip firmware contacts the user has explicitly wiped via
@@ -239,7 +337,7 @@ async fn refresh_contacts(device: &mut MeshcoreDevice, state: &Arc<MeshState>) {
/// Drain any queued messages from the device.
/// Returns `true` if a write/communication error occurred (for failure tracking).
async fn sync_queued_messages(
device: &mut MeshcoreDevice,
device: &mut MeshRadioDevice,
state: &Arc<MeshState>,
our_x25519_secret: &[u8; 32],
) -> bool {
@@ -274,20 +372,11 @@ pub(super) async fn run_mesh_session(
) -> Result<()> {
// Detect device — try preferred path first, fall back to auto-detect
let (device_path, mut device, device_info) = if let Some(path) = preferred_path {
match MeshcoreDevice::open(path).await {
Ok(mut dev) => match dev.initialize().await {
Ok(info) => (path.to_string(), dev, info),
Err(e) => {
warn!(
"Preferred path {} handshake failed: {} — trying auto-detect",
path, e
);
auto_detect_and_open().await?
}
},
match open_preferred_path(path).await {
Ok((dev, info)) => (path.to_string(), dev, info),
Err(e) => {
warn!(
"Preferred path {} open failed: {} — trying auto-detect",
"Preferred path {} probe failed: {} — trying auto-detect",
path, e
);
auto_detect_and_open().await?
@@ -301,11 +390,11 @@ pub(super) async fn run_mesh_session(
{
let mut status = state.status.write().await;
status.device_connected = true;
status.device_type = DeviceType::Meshcore;
status.device_type = device.device_type();
status.device_path = Some(device_path.clone());
status.firmware_version = Some(device_info.firmware_version.clone());
status.self_node_id = Some(device_info.node_id);
status.self_advert_name = device.advert_name.clone();
status.self_advert_name = device.advert_name();
}
let _ = state.event_tx.send(MeshEvent::DeviceConnected(device_info));
@@ -434,7 +523,7 @@ pub(super) async fn run_mesh_session(
/// Process a single outbound command from MeshService.
async fn handle_send_command(
cmd: MeshCommand,
device: &mut MeshcoreDevice,
device: &mut MeshRadioDevice,
state: &Arc<MeshState>,
consecutive_write_failures: &mut u32,
) {
+621
View File
@@ -0,0 +1,621 @@
//! Async serial driver for Meshtastic devices.
//!
//! Meshtastic uses protobuf payloads over a SLIP-like serial stream. This
//! module implements only the small subset Archipelago needs: connect,
//! discover the local node, send/receive text packets, and provide synthetic
//! contacts to the existing mesh listener.
use super::protocol::{InboundFrame, ParsedContact};
use super::types::{DeviceInfo, DeviceType};
use anyhow::{Context, Result};
use std::collections::HashMap;
use std::time::Duration;
use tracing::{debug, info, warn};
const BAUD_RATE: u32 = 115200;
const READ_TIMEOUT: Duration = Duration::from_secs(5);
const WRITE_TIMEOUT: Duration = Duration::from_secs(2);
const READ_BUF_SIZE: usize = 512;
const START1: u8 = 0x94;
const START2: u8 = 0xc3;
const TO_RADIO_MAX: usize = 512;
const BROADCAST_NUM: u32 = 0xffff_ffff;
const TEXT_MESSAGE_APP: u32 = 1;
const TO_RADIO_PACKET: u64 = 1;
const TO_RADIO_WANT_CONFIG_ID: u64 = 3;
const TO_RADIO_HEARTBEAT: u64 = 7;
const FROM_RADIO_PACKET: u64 = 2;
const FROM_RADIO_MY_INFO: u64 = 3;
const FROM_RADIO_NODE_INFO: u64 = 4;
const FROM_RADIO_CONFIG_COMPLETE_ID: u64 = 7;
const FROM_RADIO_REBOOTED: u64 = 8;
/// Async Meshtastic device handle.
pub struct MeshtasticDevice {
port: serial2_tokio::SerialPort,
read_buf: Vec<u8>,
node_num: Option<u32>,
user_id: Option<String>,
long_name: Option<String>,
short_name: Option<String>,
contacts: HashMap<u32, ParsedContact>,
device_path: String,
}
impl MeshtasticDevice {
pub async fn open(path: &str) -> Result<Self> {
match tokio::fs::metadata(path).await {
Ok(meta) => {
debug!(path = %path, permissions = ?meta.permissions(), "Device node exists")
}
Err(e) => anyhow::bail!("Serial device {} not accessible: {}", path, e),
}
let port = serial2_tokio::SerialPort::open(path, BAUD_RATE).context(format!(
"Failed to open serial port {} (permission denied? device busy?)",
path
))?;
info!(path = %path, baud = BAUD_RATE, "Opened Meshtastic serial port");
Ok(Self {
port,
read_buf: Vec::with_capacity(READ_BUF_SIZE),
node_num: None,
user_id: None,
long_name: None,
short_name: None,
contacts: HashMap::new(),
device_path: path.to_string(),
})
}
pub async fn initialize(&mut self) -> Result<DeviceInfo> {
info!(path = %self.device_path, "Starting Meshtastic handshake");
self.send_to_radio(&encode_want_config()).await?;
let deadline = tokio::time::Instant::now() + READ_TIMEOUT;
let mut saw_meshtastic_frame = false;
let mut saw_config_complete = false;
loop {
let remaining = deadline.saturating_duration_since(tokio::time::Instant::now());
if remaining.is_zero() {
break;
}
match tokio::time::timeout(
remaining.min(Duration::from_millis(250)),
self.read_from_radio(),
)
.await
{
Ok(Ok(Some(frame))) => {
saw_meshtastic_frame = true;
if matches!(
decode_top_level_variant(&frame),
Some((FROM_RADIO_CONFIG_COMPLETE_ID, _))
) {
saw_config_complete = true;
}
self.handle_from_radio(&frame);
if saw_config_complete && self.node_num.is_some() {
break;
}
}
Ok(Ok(None)) | Err(_) => {}
Ok(Err(e)) => return Err(e),
}
}
if !saw_meshtastic_frame {
anyhow::bail!("No Meshtastic serial API response");
}
let node_id = self
.node_num
.ok_or_else(|| anyhow::anyhow!("Meshtastic serial API did not provide MyInfo"))?;
if self.user_id.is_none() && self.long_name.is_none() && self.short_name.is_none() {
anyhow::bail!("Meshtastic serial API did not provide node identity");
}
let firmware_version = self
.long_name
.clone()
.or_else(|| self.user_id.clone())
.unwrap_or_else(|| "Meshtastic".to_string());
info!(node_id, name = %firmware_version, "Meshtastic identity");
Ok(DeviceInfo {
firmware_version,
node_id,
max_contacts: 200,
device_type: DeviceType::Meshtastic,
})
}
pub async fn set_advert_name(&mut self, name: &str) -> Result<()> {
self.long_name = Some(name.to_string());
Ok(())
}
pub async fn send_self_advert(&mut self) -> Result<()> {
self.send_to_radio(&encode_heartbeat()).await
}
pub async fn send_channel_text(&mut self, _channel: u8, msg: &[u8]) -> Result<()> {
let text = String::from_utf8_lossy(msg);
let packet = encode_mesh_packet(BROADCAST_NUM, TEXT_MESSAGE_APP, text.as_bytes());
self.send_to_radio(&encode_to_radio_variant(TO_RADIO_PACKET, &packet))
.await
}
pub async fn get_contacts(&mut self) -> Result<Vec<ParsedContact>> {
if self.contacts.is_empty() {
self.send_to_radio(&encode_want_config()).await?;
let deadline = tokio::time::Instant::now() + Duration::from_secs(2);
while tokio::time::Instant::now() < deadline {
match self.read_from_radio().await? {
Some(frame) => {
let config_complete = matches!(
decode_top_level_variant(&frame),
Some((FROM_RADIO_CONFIG_COMPLETE_ID, _))
);
self.handle_from_radio(&frame);
if config_complete || !self.contacts.is_empty() {
break;
}
}
None => tokio::time::sleep(Duration::from_millis(50)).await,
}
}
}
Ok(self.contacts.values().cloned().collect())
}
pub async fn reset_contact_path(&mut self, _pubkey: &[u8; 32]) -> Result<()> {
Ok(())
}
pub async fn sync_messages(&mut self) -> Result<Vec<InboundFrame>> {
Ok(Vec::new())
}
pub async fn try_recv_frame(&mut self) -> Result<Option<InboundFrame>> {
let Some(frame) = self.read_from_radio().await? else {
return Ok(None);
};
Ok(self.handle_from_radio(&frame))
}
pub fn advert_name(&self) -> Option<String> {
self.long_name
.clone()
.or_else(|| self.short_name.clone())
.or_else(|| self.user_id.clone())
}
async fn send_to_radio(&mut self, payload: &[u8]) -> Result<()> {
if payload.len() > TO_RADIO_MAX {
anyhow::bail!("Meshtastic payload too large: {} bytes", payload.len());
}
let mut frame = Vec::with_capacity(4 + payload.len());
frame.push(START1);
frame.push(START2);
frame.extend_from_slice(&(payload.len() as u16).to_be_bytes());
frame.extend_from_slice(payload);
tokio::time::timeout(WRITE_TIMEOUT, self.port.write_all(&frame))
.await
.context("Meshtastic serial write timed out")?
.context("Meshtastic serial write failed")?;
Ok(())
}
async fn read_from_radio(&mut self) -> Result<Option<Vec<u8>>> {
if let Some(frame) = decode_serial_frame(&mut self.read_buf) {
return Ok(Some(frame));
}
let mut tmp = [0u8; READ_BUF_SIZE];
match tokio::time::timeout(Duration::from_millis(50), self.port.read(&mut tmp)).await {
Ok(Ok(0)) => anyhow::bail!("Meshtastic serial port closed"),
Ok(Ok(n)) => self.read_buf.extend_from_slice(&tmp[..n]),
Ok(Err(e)) => return Err(e).context("Meshtastic serial read error"),
Err(_) => return Ok(None),
}
Ok(decode_serial_frame(&mut self.read_buf))
}
fn handle_from_radio(&mut self, frame: &[u8]) -> Option<InboundFrame> {
let Some((field, value)) = decode_top_level_variant(frame) else {
return None;
};
match field {
FROM_RADIO_MY_INFO => {
if let Some((node_num, user_id)) = parse_my_info(value) {
self.node_num = Some(node_num);
if let Some(user_id) = user_id {
self.user_id = Some(user_id);
}
}
None
}
FROM_RADIO_NODE_INFO => {
self.update_node_info(value);
None
}
FROM_RADIO_PACKET => self.packet_to_inbound_frame(value),
FROM_RADIO_CONFIG_COMPLETE_ID | FROM_RADIO_REBOOTED => None,
other => {
debug!(
field = other,
len = value.len(),
"Unhandled Meshtastic FromRadio field"
);
None
}
}
}
fn update_node_info(&mut self, data: &[u8]) {
if let Some(node) = parse_node_info(data) {
let key = synthetic_pubkey(node.num);
let name = node
.long_name
.or(node.short_name)
.or(node.id)
.unwrap_or_else(|| format!("Meshtastic !{:08x}", node.num));
if Some(node.num) == self.node_num {
self.long_name = Some(name.clone());
}
self.contacts.insert(
node.num,
ParsedContact {
public_key_hex: hex::encode(key),
advert_name: name,
last_advert: node.last_heard.unwrap_or_default(),
contact_type: 1,
path_len: 0xff,
flags: 0,
},
);
}
}
fn packet_to_inbound_frame(&mut self, data: &[u8]) -> Option<InboundFrame> {
let packet = parse_mesh_packet(data)?;
if packet.portnum != TEXT_MESSAGE_APP || packet.payload.is_empty() {
return None;
}
let from = packet.from.unwrap_or(0);
if Some(from) == self.node_num {
return None;
}
let from_key = synthetic_pubkey(from);
self.contacts.entry(from).or_insert_with(|| ParsedContact {
public_key_hex: hex::encode(synthetic_pubkey(from)),
advert_name: format!("Meshtastic !{:08x}", from),
last_advert: 0,
contact_type: 1,
path_len: 0xff,
flags: 0,
});
let mut payload = Vec::with_capacity(15 + packet.payload.len());
payload.push(0); // SNR unknown
payload.extend_from_slice(&[0, 0]); // reserved
payload.extend_from_slice(&from_key[..6]);
payload.push(0xff); // unknown/flood path
payload.push(0); // text type
payload.extend_from_slice(&0u32.to_le_bytes());
payload.extend_from_slice(&packet.payload);
Some(InboundFrame {
code: super::protocol::RESP_CONTACT_MSG_V3,
data: payload,
bytes_consumed: 0,
})
}
}
fn decode_serial_frame(buf: &mut Vec<u8>) -> Option<Vec<u8>> {
let start = buf.windows(2).position(|w| w == [START1, START2])?;
if start > 0 {
buf.drain(..start);
}
if buf.len() < 4 {
return None;
}
let len = u16::from_be_bytes([buf[2], buf[3]]) as usize;
if buf.len() < 4 + len {
return None;
}
let payload = buf[4..4 + len].to_vec();
buf.drain(..4 + len);
Some(payload)
}
fn encode_want_config() -> Vec<u8> {
encode_varint_field(TO_RADIO_WANT_CONFIG_ID, 1)
}
fn encode_heartbeat() -> Vec<u8> {
encode_to_radio_variant(TO_RADIO_HEARTBEAT, &[])
}
fn encode_to_radio_variant(field: u64, bytes: &[u8]) -> Vec<u8> {
let mut out = Vec::new();
encode_len_field(field, bytes, &mut out);
out
}
fn encode_mesh_packet(to: u32, portnum: u32, payload: &[u8]) -> Vec<u8> {
let mut decoded = Vec::new();
encode_varint_field_into(1, portnum as u64, &mut decoded);
encode_len_field(2, payload, &mut decoded);
let mut packet = Vec::new();
encode_fixed32_field(2, to, &mut packet);
encode_len_field(4, &decoded, &mut packet);
packet
}
fn decode_top_level_variant(buf: &[u8]) -> Option<(u64, &[u8])> {
let mut idx = 0;
while idx < buf.len() {
let (key, n) = read_varint(&buf[idx..])?;
idx += n;
let field = key >> 3;
match key & 0x07 {
0 => {
let (_, n) = read_varint(&buf[idx..])?;
idx += n;
if matches!(field, FROM_RADIO_CONFIG_COMPLETE_ID | FROM_RADIO_REBOOTED) {
return Some((field, &[]));
}
}
2 => {
let (len, n) = read_varint(&buf[idx..])?;
idx += n;
let end = idx.checked_add(len as usize)?;
if end > buf.len() {
return None;
}
if matches!(
field,
FROM_RADIO_PACKET | FROM_RADIO_MY_INFO | FROM_RADIO_NODE_INFO
) {
return Some((field, &buf[idx..end]));
}
idx = end;
}
_ => return None,
}
}
None
}
fn parse_my_info(data: &[u8]) -> Option<(u32, Option<String>)> {
let mut idx = 0;
let mut node_num = None;
let mut user_id = None;
while idx < data.len() {
let (field, value, next) = next_field(data, idx)?;
idx = next;
match (field, value) {
(1, FieldValue::Varint(v)) => node_num = Some(v as u32),
(1, FieldValue::Fixed32(v)) => node_num = Some(v),
(3, FieldValue::Bytes(b)) => user_id = parse_user(b).and_then(|u| u.id),
_ => {}
}
}
node_num.map(|n| (n, user_id))
}
struct ParsedNode {
num: u32,
id: Option<String>,
long_name: Option<String>,
short_name: Option<String>,
last_heard: Option<u32>,
}
fn parse_node_info(data: &[u8]) -> Option<ParsedNode> {
let mut idx = 0;
let mut node = ParsedNode {
num: 0,
id: None,
long_name: None,
short_name: None,
last_heard: None,
};
while idx < data.len() {
let (field, value, next) = next_field(data, idx)?;
idx = next;
match (field, value) {
(1, FieldValue::Varint(v)) => node.num = v as u32,
(1, FieldValue::Fixed32(v)) => node.num = v,
(2, FieldValue::Bytes(b)) => {
if let Some(user) = parse_user(b) {
node.id = user.id;
node.long_name = user.long_name;
node.short_name = user.short_name;
}
}
(5, FieldValue::Fixed32(v)) => node.last_heard = Some(v),
_ => {}
}
}
if node.num == 0 {
None
} else {
Some(node)
}
}
struct ParsedUser {
id: Option<String>,
long_name: Option<String>,
short_name: Option<String>,
}
fn parse_user(data: &[u8]) -> Option<ParsedUser> {
let mut idx = 0;
let mut user = ParsedUser {
id: None,
long_name: None,
short_name: None,
};
while idx < data.len() {
let (field, value, next) = next_field(data, idx)?;
idx = next;
match (field, value) {
(1, FieldValue::Bytes(b)) => user.id = string_field(b),
(2, FieldValue::Bytes(b)) => user.long_name = string_field(b),
(3, FieldValue::Bytes(b)) => user.short_name = string_field(b),
_ => {}
}
}
Some(user)
}
struct ParsedPacket {
from: Option<u32>,
portnum: u32,
payload: Vec<u8>,
}
fn parse_mesh_packet(data: &[u8]) -> Option<ParsedPacket> {
let mut idx = 0;
let mut from = None;
let mut decoded = None;
while idx < data.len() {
let (field, value, next) = next_field(data, idx)?;
idx = next;
match (field, value) {
(1, FieldValue::Fixed32(v)) => from = Some(v),
(4, FieldValue::Bytes(b)) => decoded = Some(b),
_ => {}
}
}
let decoded = decoded?;
let mut didx = 0;
let mut portnum = 0;
let mut payload = Vec::new();
while didx < decoded.len() {
let (field, value, next) = next_field(decoded, didx)?;
didx = next;
match (field, value) {
(1, FieldValue::Varint(v)) => portnum = v as u32,
(2, FieldValue::Bytes(b)) => payload = b.to_vec(),
_ => {}
}
}
Some(ParsedPacket {
from,
portnum,
payload,
})
}
enum FieldValue<'a> {
Varint(u64),
Fixed32(u32),
Bytes(&'a [u8]),
}
fn next_field(buf: &[u8], idx: usize) -> Option<(u64, FieldValue<'_>, usize)> {
let (key, n) = read_varint(&buf[idx..])?;
let field = key >> 3;
let mut pos = idx + n;
match key & 0x07 {
0 => {
let (v, n) = read_varint(&buf[pos..])?;
pos += n;
Some((field, FieldValue::Varint(v), pos))
}
2 => {
let (len, n) = read_varint(&buf[pos..])?;
pos += n;
let end = pos.checked_add(len as usize)?;
if end > buf.len() {
return None;
}
Some((field, FieldValue::Bytes(&buf[pos..end]), end))
}
5 => {
let end = pos.checked_add(4)?;
if end > buf.len() {
None
} else {
let value =
u32::from_le_bytes([buf[pos], buf[pos + 1], buf[pos + 2], buf[pos + 3]]);
Some((field, FieldValue::Fixed32(value), end))
}
}
1 => {
let end = pos.checked_add(8)?;
if end > buf.len() {
None
} else {
Some((field, FieldValue::Bytes(&buf[pos..end]), end))
}
}
wire => {
warn!(wire, "Unsupported Meshtastic protobuf wire type");
None
}
}
}
fn read_varint(buf: &[u8]) -> Option<(u64, usize)> {
let mut out = 0u64;
for (i, b) in buf.iter().copied().enumerate().take(10) {
out |= ((b & 0x7f) as u64) << (7 * i);
if b & 0x80 == 0 {
return Some((out, i + 1));
}
}
None
}
fn encode_varint_field(field: u64, value: u64) -> Vec<u8> {
let mut out = Vec::new();
encode_varint_field_into(field, value, &mut out);
out
}
fn encode_varint_field_into(field: u64, value: u64, out: &mut Vec<u8>) {
write_varint((field << 3) | 0, out);
write_varint(value, out);
}
fn encode_len_field(field: u64, bytes: &[u8], out: &mut Vec<u8>) {
write_varint((field << 3) | 2, out);
write_varint(bytes.len() as u64, out);
out.extend_from_slice(bytes);
}
fn encode_fixed32_field(field: u64, value: u32, out: &mut Vec<u8>) {
write_varint((field << 3) | 5, out);
out.extend_from_slice(&value.to_le_bytes());
}
fn write_varint(mut value: u64, out: &mut Vec<u8>) {
while value >= 0x80 {
out.push((value as u8 & 0x7f) | 0x80);
value >>= 7;
}
out.push(value as u8);
}
fn string_field(bytes: &[u8]) -> Option<String> {
std::str::from_utf8(bytes).ok().map(|s| s.to_string())
}
fn synthetic_pubkey(node_num: u32) -> [u8; 32] {
let mut out = [0u8; 32];
out[..4].copy_from_slice(&node_num.to_le_bytes());
out[4..15].copy_from_slice(b"meshtastic:");
out
}
+5 -3
View File
@@ -1,13 +1,15 @@
//! Mesh networking: Meshcore LoRa radio integration for offline peer discovery
//! Mesh networking: LoRa radio integration for offline peer discovery
//! and encrypted messaging between Archipelago nodes.
//!
//! Supports Meshcore firmware on Heltec V3, T-Beam, RAK WisBlock, Station G2,
//! and other ESP32/nRF52-based LoRa boards via USB serial (Companion USB mode).
//! Supports Meshcore firmware via Companion USB and Meshtastic firmware via
//! the Meshtastic serial API on Heltec, T-Beam, RAK WisBlock, Station G2,
//! and other ESP32/nRF52-based LoRa boards.
pub mod alerts;
pub mod bitcoin_relay;
pub mod crypto;
pub mod listener;
pub mod meshtastic;
pub mod message_types;
pub mod outbox;
pub mod protocol;
+1
View File
@@ -323,6 +323,7 @@ pub fn parse_self_info(data: &[u8]) -> Result<(u32, String)> {
}
/// Parsed contact from RESP_CONTACT (0x03).
#[derive(Clone)]
pub struct ParsedContact {
pub public_key_hex: String,
pub advert_name: String,
+32
View File
@@ -8,6 +8,7 @@
use super::protocol::{self, InboundFrame};
use super::types::DeviceInfo;
use anyhow::{Context, Result};
use std::path::Path;
use std::time::Duration;
use tracing::{debug, info, warn};
@@ -400,12 +401,43 @@ const SERIAL_CANDIDATES: &[&str] = &[
"/dev/ttyACM2",
];
const SKIP_SERIAL_MODEL_SUBSTRINGS: &[&str] = &["Sierra_Wireless", "Z-Wave", "Zooz"];
fn likely_non_mesh_serial_device(path: &str) -> bool {
let Some(name) = Path::new(path).file_name().and_then(|s| s.to_str()) else {
return false;
};
let by_id = Path::new("/dev/serial/by-id");
let Ok(entries) = std::fs::read_dir(by_id) else {
return false;
};
for entry in entries.flatten() {
let file_name = entry.file_name().to_string_lossy().to_string();
if !SKIP_SERIAL_MODEL_SUBSTRINGS
.iter()
.any(|needle| file_name.contains(needle))
{
continue;
}
if let Ok(target) = std::fs::read_link(entry.path()) {
if target.file_name().and_then(|s| s.to_str()) == Some(name) {
return true;
}
}
}
false
}
/// Scan for serial devices that could be Meshcore radios.
/// Returns paths to existing serial device files.
pub async fn detect_serial_devices() -> Vec<String> {
let mut devices = Vec::new();
for path in SERIAL_CANDIDATES {
if tokio::fs::metadata(path).await.is_ok() {
if likely_non_mesh_serial_device(path) {
debug!(path = %path, "Skipping known non-mesh serial device");
continue;
}
devices.push(path.to_string());
}
}
+5 -2
View File
@@ -28,7 +28,6 @@ const RESERVED_PORTS: &[u16] = &[
8888, // SearXNG
8096, 2342, 2283, // Jellyfin, Photoprism, Immich
8443, // FIPS TCP fallback
8444, 8084, // NPM
];
/// Start of range for allocating web app ports when preferred is taken.
@@ -94,8 +93,12 @@ impl PortAllocator {
.any(|m| m.host_port == port)
}
fn can_bind(port: u16) -> bool {
std::net::TcpListener::bind(("0.0.0.0", port)).is_ok()
}
fn is_available(&self, port: u16) -> bool {
!self.is_reserved(port) && !self.is_allocated(port)
!self.is_reserved(port) && !self.is_allocated(port) && Self::can_bind(port)
}
/// Allocate a host port for an app. Uses preferred_port if available, else finds next free.
+4 -1
View File
@@ -80,7 +80,10 @@ impl EndpointRateLimiter {
limits.insert("backup.upload-s3".to_string(), (3, 600));
limits.insert("backup.download-s3".to_string(), (3, 600));
// System operations
limits.insert("update.apply".to_string(), (2, 600));
// Update apply is an authenticated local admin action. Keep a guard
// against accidental button storms without locking operators out for
// ten minutes during OTA troubleshooting.
limits.insert("update.apply".to_string(), (10, 60));
limits.insert("system.reboot".to_string(), (2, 300));
limits.insert("system.shutdown".to_string(), (2, 300));
// Password and TOTP changes
+29 -4
View File
@@ -840,6 +840,20 @@ const CONTAINER_ABSENCE_THRESHOLD: u32 = 3;
/// scanner's authoritative view. Applies to all transitional variants.
const TRANSITIONAL_STUCK_TIMEOUT: Duration = Duration::from_secs(120);
/// Multi-container installs can legitimately spend several minutes before the
/// primary user-facing container exists. BTCPay, for example, pulls/starts
/// Postgres and NBXplorer before `btcpay-server`; do not erase its installing
/// card just because the primary container is absent during that setup window.
const INSTALLING_STUCK_TIMEOUT: Duration = Duration::from_secs(20 * 60);
fn transitional_stuck_timeout(state: &crate::data_model::PackageState) -> Duration {
if *state == crate::data_model::PackageState::Installing {
INSTALLING_STUCK_TIMEOUT
} else {
TRANSITIONAL_STUCK_TIMEOUT
}
}
/// Returns true if `state` is one of the transitional variants that a
/// `spawn_transitional`-style background task owns. While such a state is
/// set, the package scanner must not overwrite it with whatever podman
@@ -961,13 +975,14 @@ async fn scan_and_update_packages(
let overwrite = match existing {
Some(existing_entry) if is_transitional(&existing_entry.state) => {
let entered = *transitional_since.entry(id.clone()).or_insert(now);
let stuck = now.duration_since(entered) > TRANSITIONAL_STUCK_TIMEOUT;
let timeout = transitional_stuck_timeout(&existing_entry.state);
let stuck = now.duration_since(entered) > timeout;
if stuck {
warn!(
"Container {} stuck in {:?} for >{}s; overriding with scan state {:?}",
id,
existing_entry.state,
TRANSITIONAL_STUCK_TIMEOUT.as_secs(),
timeout.as_secs(),
pkg.state
);
transitional_since.remove(id);
@@ -1015,12 +1030,13 @@ async fn scan_and_update_packages(
if let Some(entry) = merged.get(&id) {
if is_transitional(&entry.state) {
let entered = *transitional_since.entry(id.clone()).or_insert(now);
if now.duration_since(entered) > TRANSITIONAL_STUCK_TIMEOUT {
let timeout = transitional_stuck_timeout(&entry.state);
if now.duration_since(entered) > timeout {
warn!(
"Container {} stuck in {:?} and absent for >{}s; removing stale transitional state",
id,
entry.state,
TRANSITIONAL_STUCK_TIMEOUT.as_secs()
timeout.as_secs()
);
merged.remove(&id);
transitional_since.remove(&id);
@@ -1247,4 +1263,13 @@ mod merge_tests {
assert!(!is_transitional(&s), "{:?} should NOT be transitional", s);
}
}
#[test]
fn installing_uses_longer_stale_timeout_than_other_transitions() {
assert!(transitional_stuck_timeout(&PackageState::Installing) > TRANSITIONAL_STUCK_TIMEOUT);
assert_eq!(
transitional_stuck_timeout(&PackageState::Stopping),
TRANSITIONAL_STUCK_TIMEOUT
);
}
}
+3 -1
View File
@@ -127,8 +127,9 @@ impl PodmanClient {
"photoprism" => "http://localhost:2342",
"immich_server" | "immich" => "http://localhost:2283",
"filebrowser" => "http://localhost:8083",
"nginx-proxy-manager" => "http://localhost:81",
"nginx-proxy-manager" => "http://localhost:8081",
"portainer" => "http://localhost:9000",
"saleor" => "http://localhost:9011",
"uptime-kuma" => "http://localhost:3002",
"fedimint" | "fedimintd" => "http://localhost:8175",
"fedimint-gateway" => "http://localhost:8176",
@@ -136,6 +137,7 @@ impl PodmanClient {
"indeedhub" => "http://localhost:7778",
"dwn" => "http://localhost:3100",
"endurain" => "http://localhost:8080",
"netbird" => "http://localhost:8087",
"electrs" | "archy-electrs-ui" => "http://localhost:50002",
_ => return None,
};
+15
View File
@@ -0,0 +1,15 @@
# Release Notes Backlog
## Next Release Required Work
- Backfill missing or thin historical release notes before cutting the next release.
- Audit every `CHANGELOG.md` section from `v1.7.44-alpha` through the current release.
- Replace raw commit-hash entries with user/operator-facing bullets that explain behavior changes, operational impact, validation, and known limitations.
- Ensure `releases/manifest.json` changelog entries come from curated `CHANGELOG.md` notes only.
## Release Note Policy
- Every release must have at least three curated bullets.
- Raw `git log --oneline` output is not acceptable release documentation.
- Notes should answer what changed, why it matters, what operators should expect, and any known limitations.
- `scripts/check-release-manifest.sh` is the enforcement gate before publishing artifacts.
+1 -1
View File
@@ -1169,7 +1169,7 @@
<div class="card-header"><span class="card-name">btcpay-server</span><span class="card-badge badge-red">archy-net</span></div>
<div class="card-desc">Self-hosted Bitcoin payment processor. Accept Bitcoin payments with invoices, checkout pages, and POS.</div>
<div class="card-layman">Your own payment terminal for Bitcoin. Create invoices, get paid, no middleman taking a cut.</div>
<div class="card-image">btcpayserver:1.13.7</div>
<div class="card-image">btcpayserver:2.3.9</div>
<div class="card-ports">Ports: <span>23000</span></div>
<div class="card-details">
<div class="detail-row"><span class="detail-label">Memory</span><span class="detail-value">1 GB</span></div>
@@ -316,9 +316,15 @@ RUN apt-get update && apt-get -y full-upgrade && apt-get install -y --no-install
dbus \
sudo \
network-manager \
wpasupplicant \
wireless-regdb \
iw \
rfkill \
polkitd \
openssh-server \
nginx \
podman \
catatonit \
uidmap \
slirp4netns \
passt \
@@ -331,6 +337,7 @@ RUN apt-get update && apt-get -y full-upgrade && apt-get install -y --no-install
curl \
git \
vim-tiny \
nano \
ca-certificates \
openssl \
chrony \
@@ -455,8 +462,21 @@ RUN mkdir -p /etc/systemd/system/user@.service.d && \
# Allow unprivileged ping inside rootless containers
RUN printf 'net.ipv4.ping_group_range=0 2147483647\n' > /etc/sysctl.d/90-podman-ping.conf
# Archipelago's web UI manages Wi-Fi through the backend service, not a local
# desktop seat. Allow the dedicated system user to control NetworkManager.
RUN mkdir -p /etc/polkit-1/rules.d && \
printf '%s\n' \
'polkit.addRule(function(action, subject) {' \
' if (subject.user == "archipelago" && action.id.indexOf("org.freedesktop.NetworkManager.") == 0) {' \
' return polkit.Result.YES;' \
' }' \
'});' \
> /etc/polkit-1/rules.d/49-archipelago-networkmanager.rules && \
chmod 644 /etc/polkit-1/rules.d/49-archipelago-networkmanager.rules
# Enable services
RUN systemctl enable NetworkManager || true && \
systemctl enable polkit || systemctl enable polkit.service || true && \
systemctl enable ssh || true && \
systemctl enable nginx || true && \
systemctl enable archipelago || true && \
@@ -696,6 +716,7 @@ kmod,procps,iproute2,ca-certificates,gdisk,\
cryptsetup,cryptsetup-initramfs,parted,dosfstools,e2fsprogs,\
linux-image-${DEB_ARCH},grub-efi-${DEB_ARCH},grub-pc-bin,\
ifupdown,isc-dhcp-client,\
wpasupplicant,wireless-regdb,iw,rfkill,\
pciutils,usbutils,less,nano \
trixie /installer http://deb.debian.org/debian
@@ -166,17 +166,25 @@ chroot /mnt/archipelago apt-get install -y linux-image-amd64 grub-efi-amd64 grub
echo "📦 Installing essential packages..."
chroot /mnt/archipelago apt-get install -y \
sudo \
networkmanager \
network-manager \
wpasupplicant \
wireless-regdb \
iw \
rfkill \
pciutils \
usbutils \
polkitd \
openssh-server \
curl \
wget \
htop \
vim-tiny \
nano \
ca-certificates \
chrony
echo "📦 Installing container tools..."
chroot /mnt/archipelago apt-get install -y podman || echo "⚠️ Podman not available in base repos, will use containers.io later"
chroot /mnt/archipelago apt-get install -y podman catatonit || echo "⚠️ Podman/catatonit not available in base repos, will use containers.io later"
echo "🔧 Installing GRUB bootloader..."
# Need to run grub-install inside chroot with proper environment
@@ -198,9 +206,20 @@ echo "archipelago:archipelago" | chroot /mnt/archipelago chpasswd
echo "⚙️ Enabling services..."
chroot /mnt/archipelago systemctl enable NetworkManager || true
chroot /mnt/archipelago systemctl enable polkit || chroot /mnt/archipelago systemctl enable polkit.service || true
chroot /mnt/archipelago systemctl enable ssh || chroot /mnt/archipelago systemctl enable sshd || true
chroot /mnt/archipelago systemctl enable chrony || true
mkdir -p /mnt/archipelago/etc/polkit-1/rules.d
cat > /mnt/archipelago/etc/polkit-1/rules.d/49-archipelago-networkmanager.rules <<'EOF'
polkit.addRule(function(action, subject) {
if (subject.user == "archipelago" && action.id.indexOf("org.freedesktop.NetworkManager.") == 0) {
return polkit.Result.YES;
}
});
EOF
chmod 644 /mnt/archipelago/etc/polkit-1/rules.d/49-archipelago-networkmanager.rules
# Remove policy-rc.d so services can start on first boot
rm -f /mnt/archipelago/usr/sbin/policy-rc.d
+4 -1
View File
@@ -1,6 +1,9 @@
# Stable symlink for USB serial adapters used as mesh radios.
# Creates /dev/mesh-radio pointing to the underlying ttyUSB device.
# Supports: CP2102 (Heltec V3), CH340 (T-Beam), FTDI (RAK WisBlock).
# Supports MeshCore and Meshtastic radios using CP2102 (Heltec V3),
# CH340 (T-Beam), FTDI (RAK WisBlock), and known USB CDC ACM radios.
SUBSYSTEM=="tty", ATTRS{idVendor}=="10c4", ATTRS{idProduct}=="ea60", SYMLINK+="mesh-radio", MODE="0660", GROUP="dialout"
SUBSYSTEM=="tty", ATTRS{idVendor}=="1a86", ATTRS{idProduct}=="7523", SYMLINK+="mesh-radio", MODE="0660", GROUP="dialout"
SUBSYSTEM=="tty", ATTRS{idVendor}=="0403", ATTRS{idProduct}=="6001", SYMLINK+="mesh-radio", MODE="0660", GROUP="dialout"
SUBSYSTEM=="tty", ATTRS{idVendor}=="239a", KERNEL=="ttyACM[0-9]*", SYMLINK+="mesh-radio", MODE="0660", GROUP="dialout"
SUBSYSTEM=="tty", ATTRS{idVendor}=="2e8a", KERNEL=="ttyACM[0-9]*", SYMLINK+="mesh-radio", MODE="0660", GROUP="dialout"
+43 -3
View File
@@ -8,7 +8,7 @@ resolver 1.1.1.1 8.8.8.8 valid=300s ipv6=off;
resolver_timeout 5s;
server {
listen 80;
listen 80 default_server;
server_name _;
root /opt/archipelago/web-ui;
@@ -23,6 +23,13 @@ server {
add_header X-DNS-Prefetch-Control "off" always;
add_header Content-Security-Policy "default-src 'self'; script-src 'self'; style-src 'self' 'unsafe-inline'; img-src 'self' data: blob: https://*.basemaps.cartocdn.com https://tile.openstreetmap.org; 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;
# Serve Nginx Proxy Manager HTTP-01 challenge files before the SPA fallback.
location ^~ /.well-known/acme-challenge/ {
default_type text/plain;
root /var/lib/archipelago/nginx-proxy-manager/data/letsencrypt-acme-challenge;
try_files $uri =404;
}
# AIUI SPA (Chat mode iframe) — SPA fallback for client-side routing
location /aiui/ {
try_files $uri $uri/ /aiui/index.html;
@@ -105,9 +112,25 @@ server {
try_files $uri =404;
}
# Versioned Vite assets must never fall through to index.html. During OTA
# a browser can keep an old HTML shell that references now-removed hashed
# chunks; returning HTML for /assets/*.js triggers strict MIME failures.
# A real 404 plus immutable/no-cache split lets the app/browser recover on
# refresh without caching the wrong content type.
location /assets/ {
try_files $uri =404;
add_header Cache-Control "public, max-age=31536000, immutable" always;
}
location ~* ^/(registerSW\.js|sw\.js|workbox-[^/]+\.js)$ {
try_files $uri =404;
add_header Cache-Control "no-cache, must-revalidate" always;
}
# Serve static files (Vue.js SPA)
location / {
try_files $uri $uri/ /index.html;
add_header Cache-Control "no-cache, must-revalidate";
}
# Peer-to-peer node messaging (receives from other nodes over Tor)
@@ -741,7 +764,7 @@ server {
sub_filter '</head>' '<script src="/nostr-provider.js"></script></head>';
}
location /app/nginx-proxy-manager/ {
proxy_pass http://127.0.0.1:81/;
proxy_pass http://127.0.0.1:8081/;
proxy_http_version 1.1;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
@@ -873,9 +896,26 @@ server {
}
}
# Compatibility proxy for cached PWA bundles that still launch Nginx Proxy
# Manager on :81. Rootless Podman cannot bind host ports below 1024, so the
# container admin UI runs on :8081 and host nginx owns the old :81 entrypoint.
server {
listen 81;
server_name _;
location / {
proxy_pass http://127.0.0.1:8081/;
proxy_http_version 1.1;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
}
}
# HTTPS - required for PWA install (Add to Home Screen) from dev servers
server {
listen 443 ssl;
listen 443 ssl default_server;
server_name _;
ssl_certificate /etc/archipelago/ssl/archipelago.crt;
@@ -358,7 +358,7 @@ location /app/indeedhub/ {
sub_filter '</head>' '<script src="/nostr-provider.js"></script></head>';
}
location /app/nginx-proxy-manager/ {
proxy_pass http://127.0.0.1:81/;
proxy_pass http://127.0.0.1:8081/;
proxy_http_version 1.1;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
+2 -2
View File
@@ -1,12 +1,12 @@
{
"name": "neode-ui",
"version": "1.7.56-alpha",
"version": "1.7.80-alpha",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "neode-ui",
"version": "1.7.56-alpha",
"version": "1.7.80-alpha",
"dependencies": {
"@types/dompurify": "^3.0.5",
"@vue-leaflet/vue-leaflet": "^0.10.1",
+1 -1
View File
@@ -1,7 +1,7 @@
{
"name": "neode-ui",
"private": true,
"version": "1.7.56-alpha",
"version": "1.7.80-alpha",
"type": "module",
"scripts": {
"start": "./start-dev.sh",
@@ -0,0 +1,12 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 128 128" role="img" aria-label="NetBird">
<defs>
<linearGradient id="g" x1="18" y1="14" x2="110" y2="116" gradientUnits="userSpaceOnUse">
<stop stop-color="#24c8ff"/>
<stop offset="1" stop-color="#3157ff"/>
</linearGradient>
</defs>
<rect width="128" height="128" rx="28" fill="#071422"/>
<path d="M28 72c16-30 39-46 72-50-11 13-18 26-21 40 10-1 19 1 29 5-19 4-35 13-48 27-8 8-18 12-30 12 7-7 12-14 15-22-7 0-13-4-17-12Z" fill="url(#g)"/>
<circle cx="82" cy="43" r="6" fill="#fff" opacity=".95"/>
<path d="M36 72c10 3 20 4 30 2" fill="none" stroke="#fff" stroke-width="6" stroke-linecap="round" opacity=".8"/>
</svg>

After

Width:  |  Height:  |  Size: 702 B

@@ -0,0 +1,5 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 128 128" role="img" aria-label="Saleor">
<rect width="128" height="128" rx="30" fill="#111827"/>
<path d="M34 42c0-10 9-18 22-18h38v16H56c-5 0-8 2-8 5 0 4 4 5 13 7l15 3c15 3 24 11 24 24 0 15-12 25-31 25H31V88h39c8 0 13-3 13-8 0-4-4-6-12-8l-16-3C41 66 34 57 34 42Z" fill="#fff"/>
<path d="M29 103h70v8H29z" fill="#7C3AED"/>
</svg>

After

Width:  |  Height:  |  Size: 389 B

+36 -2
View File
@@ -52,18 +52,35 @@
{
"id": "btcpay-server",
"title": "BTCPay Server",
"version": "1.13.7",
"version": "2.3.9",
"description": "Self-hosted Bitcoin payment processor.",
"icon": "/assets/img/app-icons/btcpay-server.png",
"author": "BTCPay Server Foundation",
"category": "commerce",
"tier": "core",
"dockerImage": "146.59.87.168:3000/lfg2025/btcpayserver:1.13.7",
"dockerImage": "docker.io/btcpayserver/btcpayserver:2.3.9",
"repoUrl": "https://github.com/btcpayserver/btcpayserver",
"requires": [
"bitcoin-knots"
]
},
{
"id": "saleor",
"title": "Saleor",
"version": "3.23",
"description": "Composable commerce platform with customer storefront, GraphQL API, dashboard, worker, mail testing, and tracing.",
"icon": "/assets/img/app-icons/saleor.svg",
"author": "Saleor",
"category": "commerce",
"tier": "recommended",
"dockerImage": "ghcr.io/saleor/saleor:3.23",
"repoUrl": "https://github.com/saleor/saleor",
"containerConfig": {
"ports": ["9011:80", "9010:80", "8000:8000", "8025:8025", "16686:16686"],
"volumes": ["/var/lib/archipelago/saleor:/app/media", "/var/lib/archipelago/saleor-db:/var/lib/postgresql/data"],
"notes": "Installed as a Saleor stack: customer storefront on 9011, admin dashboard on 9010, API on 8000, Mailpit on 8025, and Jaeger on 16686. Supporting containers include PostgreSQL, Valkey, Celery worker, and services required by Saleor."
}
},
{
"id": "mempool",
"title": "Mempool Explorer",
@@ -275,6 +292,23 @@
"args": ["sh", "-c", "tailscaled --tun=userspace-networking & sleep 2; tailscale web --listen 0.0.0.0:8240 & wait"]
}
},
{
"id": "netbird",
"title": "NetBird",
"version": "0.71.2",
"description": "Self-hosted WireGuard mesh VPN control plane with dashboard, embedded identity provider, management API, signal, relay, and STUN service.",
"icon": "/assets/img/app-icons/netbird.svg",
"author": "NetBird",
"category": "networking",
"tier": "recommended",
"dockerImage": "docker.io/netbirdio/dashboard:v2.38.0",
"repoUrl": "https://github.com/netbirdio/netbird",
"containerConfig": {
"ports": ["8087:80", "8086:80", "3478:3478/udp"],
"volumes": ["/var/lib/archipelago/netbird:/var/lib/netbird"],
"notes": "Installed as a two-container stack: netbird dashboard on 8087 and netbird-server control plane on 8086 plus UDP 3478. For production clients, publish a DNS name over HTTPS with gRPC/WebSocket routing."
}
},
{
"id": "uptime-kuma",
"title": "Uptime Kuma",
+1 -1
View File
@@ -161,7 +161,7 @@ function onKeyDown(e: KeyboardEvent) {
}
// 's' key activates screensaver when authenticated (skip if typing in input)
if (e.key === 's' || e.key === 'S') {
if (!isInput && appStore.isAuthenticated && !screensaverStore.isActive) {
if (!isInput && appStore.isAuthenticated && !screensaverStore.isActive && !screensaverStore.isSuppressed) {
e.preventDefault()
screensaverStore.activate()
}
+1 -2
View File
@@ -532,7 +532,7 @@ class RPCClient {
return this.call({
method: 'package.install',
params: { id, 'marketplace-url': marketplaceUrl, version },
timeout: 15000,
timeout: 600000,
})
}
@@ -940,4 +940,3 @@ class RPCClient {
}
export const rpcClient = new RPCClient()
@@ -98,7 +98,7 @@ describe('useAppLauncherStore', () => {
expect(store.panelAppId).toBe('btcpay-server')
})
it('opens Nginx Proxy Manager in new tab even when URL resolves', () => {
it('normalizes old Nginx Proxy Manager port 81 to 8081', () => {
const store = useAppLauncherStore()
store.open({ url: 'http://192.168.1.228:81', title: 'Nginx Proxy Manager' })
@@ -106,12 +106,28 @@ describe('useAppLauncherStore', () => {
expect(store.isOpen).toBe(false)
expect(store.panelAppId).toBe(null)
expect(mockWindowOpen).toHaveBeenCalledWith(
'http://192.168.1.228:81',
'http://192.168.1.228:8081',
'_blank',
'noopener,noreferrer',
)
})
it('routes desktop new-tab apps into app session on mobile', () => {
Object.defineProperty(window, 'innerWidth', {
value: 390,
writable: true,
configurable: true,
})
const store = useAppLauncherStore()
store.open({ url: 'http://192.168.1.228:8081', title: 'Nginx Proxy Manager' })
expect(store.isOpen).toBe(false)
expect(store.panelAppId).toBe(null)
expect(mockWindowOpen).not.toHaveBeenCalled()
expect(mockPush).toHaveBeenCalledWith({ name: 'app-session', params: { appId: 'nginx-proxy-manager' } })
})
it('opens Nginx Proxy Manager in new tab using title hint when URL is path-only', () => {
const store = useAppLauncherStore()
@@ -125,13 +141,13 @@ describe('useAppLauncherStore', () => {
expect(store.panelAppId).toBe(null)
})
it('normalizes legacy Nginx Proxy Manager port 8181 to 81', () => {
it('normalizes legacy Nginx Proxy Manager ports to 8081', () => {
const store = useAppLauncherStore()
store.open({ url: 'http://192.168.1.228:8181', title: 'Nginx Proxy Manager' })
expect(mockWindowOpen).toHaveBeenCalledWith(
'http://192.168.1.228:81',
'http://192.168.1.228:8081',
'_blank',
'noopener,noreferrer',
)
@@ -219,6 +235,35 @@ describe('useAppLauncherStore', () => {
)
})
it('opens known prepackaged websites in new tab on desktop when requested', () => {
const store = useAppLauncherStore()
store.open({ url: 'https://nwnn.l484.com', title: 'Next Web News Network', openInNewTab: true })
expect(store.isOpen).toBe(false)
expect(store.panelAppId).toBe(null)
expect(mockWindowOpen).toHaveBeenCalledWith(
'https://nwnn.l484.com',
'_blank',
'noopener,noreferrer',
)
})
it('routes prepackaged websites into app session on mobile', () => {
Object.defineProperty(window, 'innerWidth', {
value: 390,
writable: true,
configurable: true,
})
const store = useAppLauncherStore()
store.open({ url: 'https://present.l484.com', title: 'Arch Presentation', openInNewTab: true })
expect(store.isOpen).toBe(false)
expect(mockWindowOpen).not.toHaveBeenCalled()
expect(mockPush).toHaveBeenCalledWith({ name: 'app-session', params: { appId: 'arch-presentation' } })
})
it('routes HTTPS same-host apps via session view', () => {
Object.defineProperty(window, 'location', {
value: { origin: 'https://192.168.1.228', protocol: 'https:', hostname: '192.168.1.228' },
@@ -68,6 +68,24 @@ describe('useScreensaverStore', () => {
expect(store.isActive).toBe(false)
})
it('suppression prevents automatic and manual activation until resumed', () => {
const store = useScreensaverStore()
store.suppress('video')
expect(store.isSuppressed).toBe(true)
store.resetInactivityTimer()
vi.advanceTimersByTime(5 * 60 * 1000)
expect(store.isActive).toBe(false)
store.activate()
expect(store.isActive).toBe(false)
store.resume('video')
expect(store.isSuppressed).toBe(false)
vi.advanceTimersByTime(3 * 60 * 1000)
expect(store.isActive).toBe(true)
})
it('activate clears any pending timer', () => {
const store = useScreensaverStore()
store.deactivate()
+66 -8
View File
@@ -17,6 +17,15 @@ const NEW_TAB_PORTS = new Set([
])
const NEW_TAB_APP_IDS = new Set([
'btcpay-server',
'grafana',
'photoprism',
'homeassistant',
'vaultwarden',
'nextcloud',
'portainer',
'onlyoffice',
'tailscale',
'nginx-proxy-manager',
'uptime-kuma',
'gitea',
@@ -31,6 +40,10 @@ function mustOpenInNewTab(url: string): boolean {
}
}
function isMobileViewport(): boolean {
return typeof window !== 'undefined' && window.innerWidth < 768
}
function inferAppIdFromTitle(title?: string): string | null {
const t = (title || '').toLowerCase()
if (!t) return null
@@ -61,8 +74,8 @@ function normalizeLaunchUrl(urlStr: string, appIdHint?: string | null): string {
return rebuilt('3002')
}
if (sameHost && appIdHint === 'nginx-proxy-manager' && u.port === '8181') {
return rebuilt('81')
if (sameHost && appIdHint === 'nginx-proxy-manager' && (u.port === '81' || u.port === '8181')) {
return rebuilt('8081')
}
return rewrittenLocalhost ? u.toString() : urlStr
@@ -74,6 +87,7 @@ function normalizeLaunchUrl(urlStr: string, appIdHint?: string | null): string {
/** Port → app ID for resolving URLs to AppSession routes */
const PORT_TO_APP_ID: Record<string, string> = {
'81': 'nginx-proxy-manager',
'8081': 'nginx-proxy-manager',
'8181': 'nginx-proxy-manager',
'3000': 'grafana',
'3002': 'uptime-kuma',
@@ -88,6 +102,10 @@ const PORT_TO_APP_ID: Record<string, string> = {
'8334': 'bitcoin-knots',
'8888': 'searxng',
'9000': 'portainer',
'9010': 'saleor',
'9011': 'saleor',
'8087': 'netbird',
'8086': 'netbird',
'9980': 'onlyoffice',
'11434': 'ollama',
'2283': 'immich',
@@ -102,6 +120,27 @@ const PORT_TO_APP_ID: Record<string, string> = {
'3010': 'thunderhub',
}
const APP_ID_TO_PORT: Record<string, string> = {
'btcpay-server': '23000',
grafana: '3000',
photoprism: '2342',
homeassistant: '8123',
vaultwarden: '8082',
nextcloud: '8085',
portainer: '9000',
onlyoffice: '8044',
tailscale: '8240',
'nginx-proxy-manager': '8081',
'uptime-kuma': '3002',
gitea: '3001',
}
function directAppUrl(appId: string): string | null {
const port = APP_ID_TO_PORT[appId]
if (!port || typeof window === 'undefined') return null
return `http://${window.location.hostname}:${port}`
}
const APPROVED_ORIGINS_KEY = 'neode_nostr_approved_origins'
@@ -146,14 +185,26 @@ export const useAppLauncherStore = defineStore('appLauncher', () => {
const panelAppId = ref<string | null>(null)
/** Open app in session view — panel mode uses store, overlay/fullscreen uses route */
function dashboardReturnPath(): string {
const current = router.currentRoute.value
const fullPath = current.fullPath || '/dashboard/apps'
if (!fullPath.startsWith('/dashboard') || current.name === 'app-session') return '/dashboard/apps'
return fullPath
}
function openSession(appId: string) {
const launchUrl = NEW_TAB_APP_IDS.has(appId) ? directAppUrl(appId) : null
if (launchUrl) {
window.open(launchUrl, '_blank', 'noopener,noreferrer')
return
}
const mode = localStorage.getItem(DISPLAY_MODE_KEY) || 'panel'
const isMobile = typeof window !== 'undefined' && window.innerWidth < 768
if (mode === 'panel' && !isMobile) {
if (mode === 'panel' && !isMobileViewport()) {
panelAppId.value = appId
} else {
panelAppId.value = null
router.push({ name: 'app-session', params: { appId } })
router.push({ name: 'app-session', params: { appId }, query: { returnTo: dashboardReturnPath() } })
}
}
@@ -167,8 +218,15 @@ export const useAppLauncherStore = defineStore('appLauncher', () => {
const launchUrl = normalizeLaunchUrl(payload.url, titleHintId)
const resolvedId = resolveAppIdFromUrl(launchUrl) || titleHintId
// Force selected apps to open directly in new tab
if (resolvedId && NEW_TAB_APP_IDS.has(resolvedId)) {
if (!isMobileViewport() && payload.openInNewTab) {
window.open(launchUrl, '_blank', 'noopener,noreferrer')
return
}
// Force selected apps to open directly in new tab on desktop only. On
// phones, route through the app session/webview so app icons behave like
// native launchers and keep the user inside Archipelago.
if (!isMobileViewport() && resolvedId && NEW_TAB_APP_IDS.has(resolvedId)) {
window.open(launchUrl, '_blank', 'noopener,noreferrer')
return
}
@@ -180,7 +238,7 @@ export const useAppLauncherStore = defineStore('appLauncher', () => {
}
// Unknown apps that block iframes — open directly in new tab
if (payload.openInNewTab || mustOpenInNewTab(launchUrl)) {
if (!isMobileViewport() && mustOpenInNewTab(launchUrl)) {
window.open(launchUrl, '_blank', 'noopener,noreferrer')
return
}
+206
View File
@@ -0,0 +1,206 @@
import { defineStore } from 'pinia'
import { computed, reactive, ref } from 'vue'
import { rpcClient } from '@/api/rpc-client'
import { PackageState, type PackageDataEntry } from '@/types/api'
type LoadState = 'idle' | 'loading' | 'ready' | 'error'
interface SystemStatsSnapshot {
cpuPercent: number
memUsed: number
memTotal: number
memPercent: number
diskUsed: number
diskTotal: number
diskPercent: number
uptimeSecs: number
loadAvg1: number
loadAvg5: number
loadAvg15: number
bitcoinSyncPercent: number
bitcoinBlockHeight: number
bitcoinAvailable: boolean | null
}
const emptyStats = (): SystemStatsSnapshot => ({
cpuPercent: 0,
memUsed: 0,
memTotal: 0,
memPercent: 0,
diskUsed: 0,
diskTotal: 0,
diskPercent: 0,
uptimeSecs: 0,
loadAvg1: 0,
loadAvg5: 0,
loadAvg15: 0,
bitcoinSyncPercent: 0,
bitcoinBlockHeight: 0,
bitcoinAvailable: null,
})
export const useHomeStatusStore = defineStore('homeStatus', () => {
const stats = reactive<SystemStatsSnapshot>(emptyStats())
const systemLoadState = ref<LoadState>('idle')
const bitcoinLoadState = ref<LoadState>('idle')
const vpnLoadState = ref<LoadState>('idle')
const fipsLoadState = ref<LoadState>('idle')
const lastSystemRefreshAt = ref<number | null>(null)
const lastBitcoinRefreshAt = ref<number | null>(null)
const lastVpnRefreshAt = ref<number | null>(null)
const lastFipsRefreshAt = ref<number | null>(null)
const vpnStatus = ref<{
connected: boolean | null
provider: string
}>({ connected: null, provider: '' })
const fipsStatus = ref<{
installed: boolean
service_active: boolean
key_present: boolean
anchor_connected?: boolean
authenticated_peer_count?: number
} | null>(null)
const systemStatsLoaded = computed(() => systemLoadState.value === 'ready')
const bitcoinKnown = computed(() => stats.bitcoinAvailable !== null)
const vpnKnown = computed(() => vpnStatus.value.connected !== null)
async function refreshSystemStats() {
systemLoadState.value = systemLoadState.value === 'ready' ? 'ready' : 'loading'
try {
const res = await rpcClient.call<{
cpu_usage_percent: number
mem_used_bytes: number
mem_total_bytes: number
disk_used_bytes: number
disk_total_bytes: number
uptime_secs: number
load_avg_1?: number
load_avg_5?: number
load_avg_15?: number
}>({ method: 'system.stats' })
stats.cpuPercent = res.cpu_usage_percent
stats.memUsed = res.mem_used_bytes
stats.memTotal = res.mem_total_bytes
stats.memPercent = res.mem_total_bytes > 0 ? (res.mem_used_bytes / res.mem_total_bytes) * 100 : 0
stats.diskUsed = res.disk_used_bytes
stats.diskTotal = res.disk_total_bytes
stats.diskPercent = res.disk_total_bytes > 0 ? (res.disk_used_bytes / res.disk_total_bytes) * 100 : 0
stats.uptimeSecs = res.uptime_secs
stats.loadAvg1 = res.load_avg_1 ?? 0
stats.loadAvg5 = res.load_avg_5 ?? 0
stats.loadAvg15 = res.load_avg_15 ?? 0
systemLoadState.value = 'ready'
lastSystemRefreshAt.value = Date.now()
} catch {
systemLoadState.value = stats.uptimeSecs > 0 ? 'ready' : 'error'
}
}
async function refreshBitcoin(packages: Record<string, PackageDataEntry>) {
bitcoinLoadState.value = bitcoinLoadState.value === 'ready' ? 'ready' : 'loading'
try {
const btc = await rpcClient.call<{ block_height: number; sync_progress: number }>({
method: 'bitcoin.getinfo',
timeout: 5000,
})
stats.bitcoinSyncPercent = (btc.sync_progress ?? 0) * 100
stats.bitcoinBlockHeight = btc.block_height ?? 0
stats.bitcoinAvailable = true
bitcoinLoadState.value = 'ready'
lastBitcoinRefreshAt.value = Date.now()
} catch {
const btcPkg = packages['bitcoin-knots'] || packages['bitcoin-core'] || packages.bitcoin
if (btcPkg?.state === PackageState.Running) {
stats.bitcoinAvailable = true
bitcoinLoadState.value = 'ready'
lastBitcoinRefreshAt.value = Date.now()
return
}
if (btcPkg && (btcPkg.state === PackageState.Stopped || btcPkg.state === PackageState.Exited)) {
stats.bitcoinAvailable = false
bitcoinLoadState.value = 'ready'
lastBitcoinRefreshAt.value = Date.now()
return
}
// No authoritative package data yet. Keep the previous known value
// rather than flashing "Not running" during route changes/scans.
bitcoinLoadState.value = stats.bitcoinAvailable === null ? 'error' : 'ready'
}
}
async function refreshVpn(packages: Record<string, PackageDataEntry>) {
vpnLoadState.value = vpnLoadState.value === 'ready' ? 'ready' : 'loading'
try {
const status = await rpcClient.vpnStatus()
vpnStatus.value = {
connected: status.connected,
provider: status.provider ?? status.configured_provider ?? '',
}
vpnLoadState.value = 'ready'
lastVpnRefreshAt.value = Date.now()
} catch {
const tailscale = packages.tailscale
if (tailscale?.state === PackageState.Running) {
vpnStatus.value = { connected: true, provider: 'tailscale' }
vpnLoadState.value = 'ready'
lastVpnRefreshAt.value = Date.now()
return
}
vpnLoadState.value = vpnStatus.value.connected === null ? 'error' : 'ready'
}
}
async function refreshFips() {
fipsLoadState.value = fipsLoadState.value === 'ready' ? 'ready' : 'loading'
try {
const status = await rpcClient.call<{
installed: boolean
service_active: boolean
key_present: boolean
anchor_connected?: boolean
authenticated_peer_count?: number
}>({ method: 'fips.status' })
fipsStatus.value = status
fipsLoadState.value = 'ready'
lastFipsRefreshAt.value = Date.now()
} catch {
fipsLoadState.value = fipsStatus.value ? 'ready' : 'error'
}
}
async function refresh(packages: Record<string, PackageDataEntry>) {
await Promise.all([
refreshSystemStats(),
refreshBitcoin(packages),
refreshVpn(packages),
refreshFips(),
])
}
return {
stats,
systemLoadState,
bitcoinLoadState,
vpnLoadState,
fipsLoadState,
systemStatsLoaded,
bitcoinKnown,
vpnKnown,
vpnStatus,
fipsStatus,
lastSystemRefreshAt,
lastBitcoinRefreshAt,
lastVpnRefreshAt,
lastFipsRefreshAt,
refresh,
refreshSystemStats,
refreshBitcoin,
refreshVpn,
refreshFips,
}
})
+22
View File
@@ -6,12 +6,15 @@ const INACTIVITY_MS = 3 * 60 * 1000 // 3 minutes
export const useScreensaverStore = defineStore('screensaver', () => {
const isActive = ref(false)
const activationCount = ref(0)
const suppressionReasons = ref<Set<string>>(new Set())
let inactivityTimer: ReturnType<typeof setTimeout> | null = null
/** True when the current activation is the ASCII variant (every 3rd time) */
const isAsciiMode = computed(() => activationCount.value > 0 && activationCount.value % 3 === 0)
const isSuppressed = computed(() => suppressionReasons.value.size > 0)
function activate() {
if (isSuppressed.value) return
activationCount.value++
isActive.value = true
clearInactivityTimer()
@@ -24,8 +27,10 @@ export const useScreensaverStore = defineStore('screensaver', () => {
function resetInactivityTimer() {
clearInactivityTimer()
if (isSuppressed.value) return
inactivityTimer = setTimeout(() => {
inactivityTimer = null
if (isSuppressed.value) return
isActive.value = true
}, INACTIVITY_MS)
}
@@ -37,13 +42,30 @@ export const useScreensaverStore = defineStore('screensaver', () => {
}
}
function suppress(reason: string) {
suppressionReasons.value = new Set(suppressionReasons.value).add(reason)
clearInactivityTimer()
isActive.value = false
}
function resume(reason: string) {
if (!suppressionReasons.value.has(reason)) return
const next = new Set(suppressionReasons.value)
next.delete(reason)
suppressionReasons.value = next
if (next.size === 0) resetInactivityTimer()
}
return {
isActive,
isAsciiMode,
isSuppressed,
activationCount,
activate,
deactivate,
resetInactivityTimer,
clearInactivityTimer,
suppress,
resume,
}
})
+51 -3
View File
@@ -1659,6 +1659,45 @@ html:has(body.video-background-active)::before {
filter: drop-shadow(0 10px 25px rgba(0, 0, 0, 0.5));
}
.mobile-filter-btn {
bottom: calc(var(--mobile-tab-bar-height, 72px) + var(--safe-area-bottom, env(safe-area-inset-bottom, 0px)) + 12px);
filter: drop-shadow(0 10px 25px rgba(0, 0, 0, 0.5));
}
.mobile-filter-sheet {
padding-bottom: calc(var(--safe-area-bottom, env(safe-area-inset-bottom, 0px)) + 1.5rem);
}
.mobile-category-strip {
display: flex;
gap: 0.5rem;
overflow-x: auto;
overscroll-behavior-x: contain;
padding-bottom: 0.25rem;
scrollbar-width: none;
}
.mobile-category-strip::-webkit-scrollbar {
display: none;
}
.mobile-category-pill {
flex: 0 0 auto;
border: 1px solid rgba(255, 255, 255, 0.14);
border-radius: 999px;
background: rgba(255, 255, 255, 0.08);
color: rgba(255, 255, 255, 0.78);
padding: 0.55rem 0.9rem;
font-size: 0.85rem;
font-weight: 600;
}
.mobile-category-pill-active {
border-color: rgba(255, 255, 255, 0.36);
background: rgba(255, 255, 255, 0.2);
color: white;
}
/* ── Cloud Audio Player (mini bar) ──── */
.cloud-audio-player {
@@ -2098,7 +2137,7 @@ html:has(body.video-background-active)::before {
position: relative;
width: 60px;
height: 60px;
border-radius: 14px;
border-radius: 18px;
overflow: visible;
background: rgba(255, 255, 255, 0.08);
box-shadow: 0 1px 3px rgba(0, 0, 0, 0.3);
@@ -2108,7 +2147,16 @@ html:has(body.video-background-active)::before {
width: 100%;
height: 100%;
object-fit: cover;
border-radius: 14px;
border-radius: 18px;
}
.app-card-icon {
border-radius: 16px;
}
.app-detail-icon {
border-radius: 22px;
object-fit: cover;
}
/* Status dot — top-right of icon */
@@ -2140,7 +2188,7 @@ html:has(body.video-background-active)::before {
align-items: center;
justify-content: center;
background: rgba(0, 0, 0, 0.6);
border-radius: 14px;
border-radius: 18px;
}
.app-icon-label {
+12 -1
View File
@@ -140,6 +140,18 @@ export interface InterfaceAddress {
'lan-address': string | null
}
export interface AppCredential {
label: string
value: string
sensitive?: boolean
}
export interface AppCredentialsResponse {
title?: string
description?: string
credentials: AppCredential[]
}
export const ServiceStatus = {
Stopped: 'stopped',
Starting: 'starting',
@@ -275,4 +287,3 @@ export interface Update {
sequence: number
patch: PatchOperation[]
}
+36 -2
View File
@@ -56,6 +56,7 @@
:lan-url="lanUrl"
:tor-url="torUrl"
:show-tor-address="showTorAddress"
:credentials="credentials"
/>
</div>
</div>
@@ -137,10 +138,12 @@ import { useAppLauncherStore } from '../stores/appLauncher'
import { useModalKeyboard } from '@/composables/useModalKeyboard'
import { dummyApps } from '../utils/dummyApps'
import { rpcClient } from '@/api/rpc-client'
import type { AppCredentialsResponse } from '@/types/api'
import AppHeroSection from './appDetails/AppHeroSection.vue'
import AppContentSection from './appDetails/AppContentSection.vue'
import AppSidebar from './appDetails/AppSidebar.vue'
import { resolveAppUrl } from './appSession/appSessionConfig'
import { isWebsitePackage, resolveRuntimeLaunchUrl } from './apps/appsConfig'
import {
WEB_ONLY_APP_URLS,
PACKAGE_ALIASES,
@@ -157,7 +160,7 @@ const { t } = useI18n()
const appId = computed(() => {
const id = route.params.id
if (typeof id !== 'string' || !/^[a-z0-9][a-z0-9._-]*$/.test(id) || id.length > 64) {
router.replace('/apps')
router.replace('/dashboard/apps')
return ''
}
return id
@@ -213,6 +216,7 @@ const needsBitcoinSync = computed(() => BITCOIN_DEPENDENT_APPS.includes(packageK
const bitcoinSyncPercent = ref(0)
const bitcoinBlockHeight = ref(0)
const bitcoinSynced = computed(() => bitcoinSyncPercent.value >= 99.9)
const credentials = ref<AppCredentialsResponse | null>(null)
async function loadBitcoinSync() {
if (!needsBitcoinSync.value) return
@@ -229,8 +233,23 @@ async function loadBitcoinSync() {
}
}
async function loadCredentials() {
if (!appId.value) return
try {
const result = await rpcClient.call<AppCredentialsResponse>({
method: 'package.credentials',
params: { app_id: packageKey.value },
timeout: 5000,
})
credentials.value = result.credentials?.length ? result : null
} catch {
credentials.value = null
}
}
onMounted(() => {
loadBitcoinSync()
loadCredentials()
})
const actionError = ref('')
@@ -287,10 +306,25 @@ function goBack() {
function launchApp() {
if (!pkg.value) return
const id = appId.value
const isMobile = typeof window !== 'undefined' && window.innerWidth < 768
const webOnlyUrl = WEB_ONLY_APP_URLS[id]
if (webOnlyUrl) {
useAppLauncherStore().open({ url: webOnlyUrl, title: pkg.value.manifest.title })
useAppLauncherStore().open({ url: webOnlyUrl, title: pkg.value.manifest.title, openInNewTab: !isMobile })
return
}
if (isWebsitePackage(id, pkg.value)) {
const url = resolveRuntimeLaunchUrl(pkg.value)
if (url) {
useAppLauncherStore().open({ url, title: pkg.value.manifest.title, openInNewTab: !isMobile })
}
return
}
const runtimeUrl = resolveRuntimeLaunchUrl(pkg.value)
if (runtimeUrl) {
useAppLauncherStore().open({ url: runtimeUrl, title: pkg.value.manifest.title })
return
}
+49 -9
View File
@@ -91,6 +91,8 @@
import { ref, computed, onMounted, onBeforeUnmount, watch } from 'vue'
import { useRoute, useRouter } from 'vue-router'
import { useAppLauncherStore } from '@/stores/appLauncher'
import { useAppStore } from '@/stores/app'
import { useScreensaverStore } from '@/stores/screensaver'
import NostrIdentityPicker from '@/components/NostrIdentityPicker.vue'
import AppSessionHeader from './appSession/AppSessionHeader.vue'
import AppSessionFrame from './appSession/AppSessionFrame.vue'
@@ -115,6 +117,8 @@ const isInlinePanel = computed(() => !!props.appIdProp)
const route = useRoute()
const router = useRouter()
const store = useAppStore()
const screensaverStore = useScreensaverStore()
const sessionRef = ref<HTMLElement | null>(null)
const frameRef = ref<InstanceType<typeof AppSessionFrame> | null>(null)
@@ -136,7 +140,7 @@ const displayMode = ref<DisplayMode>(
const appId = computed(() => {
const id = props.appIdProp || (route.params.appId as string)
if (typeof id !== 'string' || !/^[a-z0-9][a-z0-9._-]*$/.test(id) || id.length > 64) {
router.replace('/apps')
router.replace('/dashboard/apps')
return ''
}
return id
@@ -145,11 +149,28 @@ const appId = computed(() => {
const appTitle = computed(() => resolveAppTitle(appId.value))
const isMobile = typeof window !== 'undefined' && window.innerWidth < 768
const mustOpenNewTab = computed(() => NEW_TAB_APPS.has(appId.value))
const screensaverReason = computed(() => `app-session:${appId.value}`)
const screensaverSuppressedApps = new Set([
'indeedhub',
'jellyfin',
'immich',
'photoprism',
'filebrowser',
])
const appUrl = computed(() => {
return resolveAppUrl(appId.value, route.query.path as string | undefined)
const runtimeUrl = store.data?.['package-data']?.[appId.value]?.installed?.['interface-addresses']?.main?.['lan-address'] || undefined
return resolveAppUrl(appId.value, route.query.path as string | undefined, runtimeUrl)
})
function closeRouteSession() {
const fallback = route.query.returnTo
const fallbackPath = typeof fallback === 'string' && fallback.startsWith('/dashboard')
? fallback
: '/dashboard/apps'
router.replace(fallbackPath).catch(() => {})
}
// --- Identity & Nostr bridge ---
const iframeRef = computed(() => frameRef.value?.iframeRef ?? null)
@@ -170,7 +191,8 @@ function setMode(mode: DisplayMode) {
if (isInlinePanel.value && mode !== 'panel') {
const id = appId.value
emit('close')
router.push({ name: 'app-session', params: { appId: id } })
const returnTo = route.fullPath.startsWith('/dashboard') ? route.fullPath : '/dashboard/apps'
router.push({ name: 'app-session', params: { appId: id }, query: { returnTo } })
return
}
@@ -178,7 +200,11 @@ function setMode(mode: DisplayMode) {
if (!isInlinePanel.value && mode === 'panel') {
const id = appId.value
const launcher = useAppLauncherStore()
router.push({ name: 'apps' }).then(() => {
const fallback = route.query.returnTo
const fallbackPath = typeof fallback === 'string' && fallback.startsWith('/dashboard')
? fallback
: '/dashboard/apps'
router.push(fallbackPath).then(() => {
launcher.panelAppId = id
})
return
@@ -285,7 +311,7 @@ function handleBackdropClick() {
function closeSession() {
if (document.fullscreenElement) document.exitFullscreen().catch(() => {})
if (isInlinePanel.value) emit('close')
else router.back()
else closeRouteSession()
}
function onKeyDown(e: KeyboardEvent) {
@@ -306,6 +332,8 @@ function onFullscreenChange() {
function onMessage(e: MessageEvent) {
if (e.data?.type === 'nostr-request') nostrBridge.handleNostrRequest(e)
if (e.data?.type === 'archipelago:identity:request') identity.handleIdentityRequest()
if (e.data?.type === 'archipelago:media:playing') screensaverStore.suppress(screensaverReason.value)
if (e.data?.type === 'archipelago:media:idle') screensaverStore.resume(screensaverReason.value)
}
// Enter fullscreen on mount if mode is fullscreen
@@ -316,11 +344,11 @@ watch(displayMode, (mode) => {
})
onMounted(() => {
// Apps that block iframes (X-Frame-Options) -- open in new tab, close session
// Apps that block iframes open externally instead of landing in a broken webview.
if (mustOpenNewTab.value && appUrl.value) {
window.open(appUrl.value, '_blank', 'noopener,noreferrer')
if (isInlinePanel.value) emit('close')
else router.back()
else closeRouteSession()
return
}
@@ -338,6 +366,9 @@ onMounted(() => {
sessionRef.value?.requestFullscreen().catch(() => {})
})
}
if (screensaverSuppressedApps.has(appId.value)) {
screensaverStore.suppress(screensaverReason.value)
}
})
onBeforeUnmount(() => {
@@ -347,6 +378,7 @@ onBeforeUnmount(() => {
window.removeEventListener('keydown', onKeyDown, true)
window.removeEventListener('message', onMessage)
document.removeEventListener('fullscreenchange', onFullscreenChange)
screensaverStore.resume(screensaverReason.value)
if (document.fullscreenElement) document.exitFullscreen().catch(() => {})
})
</script>
@@ -502,6 +534,12 @@ onBeforeUnmount(() => {
opacity: 0;
}
.app-session-frame-scroll-host {
overflow: auto;
overscroll-behavior: contain;
-webkit-overflow-scrolling: touch;
}
/* Mobile: full-bleed app sessions — no border, no radius, no shadow */
@media (max-width: 767px) {
.app-session-root {
@@ -514,6 +552,8 @@ onBeforeUnmount(() => {
.app-session-fullscreen {
height: 100vh;
height: 100dvh;
padding-top: var(--safe-area-top, env(safe-area-inset-top, 0px));
background: black;
}
.app-session-panel.glass-card {
border: none !important;
@@ -527,8 +567,8 @@ onBeforeUnmount(() => {
}
.app-session-frame-safe {
flex: none !important;
height: calc(100vh - var(--app-session-mobile-bar-height, 84px));
height: calc(100dvh - var(--app-session-mobile-bar-height, 84px));
height: calc(100vh - var(--app-session-mobile-bar-height, 84px) - var(--safe-area-top, env(safe-area-inset-top, 0px)));
height: calc(100dvh - var(--app-session-mobile-bar-height, 84px) - var(--safe-area-top, env(safe-area-inset-top, 0px)));
padding-bottom: 0;
}
}
+432 -18
View File
@@ -7,7 +7,7 @@
<div class="mode-switcher flex-shrink-0">
<button class="mode-switcher-btn" :class="{ 'mode-switcher-btn-active': activeTab === 'apps' }" @click="activeTab = 'apps'; router.replace({ query: {} })">My Apps</button>
<RouterLink to="/dashboard/discover" class="mode-switcher-btn">App Store</RouterLink>
<button class="mode-switcher-btn" :class="{ 'mode-switcher-btn-active': activeTab === 'services' }" @click="activeTab = 'services'; router.replace({ query: { tab: 'services' } })">Services</button>
<button class="mode-switcher-btn" :class="{ 'mode-switcher-btn-active': activeTab === 'websites' }" @click="activeTab = 'websites'; router.replace({ query: { tab: 'websites' } })">Websites</button>
</div>
<div v-if="activeTab === 'apps' && categoriesWithApps.length > 1" class="mode-switcher flex-shrink-0">
<button
@@ -18,24 +18,48 @@
:class="{ 'mode-switcher-btn-active': selectedCategory === category.id }"
>{{ category.name }}</button>
</div>
<input
v-model="searchQuery"
type="text"
:placeholder="t('apps.searchPlaceholder')"
:aria-label="t('apps.searchLabel')"
class="flex-1 px-4 py-2 bg-white/10 border border-white/20 rounded-lg text-white placeholder-white/50 focus:outline-none focus:border-white/40 transition-colors"
/>
<div class="flex-1 flex items-center gap-2">
<input
v-model="searchQuery"
type="text"
:placeholder="t('apps.searchPlaceholder')"
:aria-label="t('apps.searchLabel')"
class="min-w-0 flex-1 px-4 py-2 bg-white/10 border border-white/20 rounded-lg text-white placeholder-white/50 focus:outline-none focus:border-white/40 transition-colors"
/>
<button
type="button"
class="sideload-icon-btn"
aria-label="Sideload app"
title="Sideload app"
@click="showSideload = true"
>
<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 16V4m0 0l-4 4m4-4l4 4M4 16v2a2 2 0 002 2h12a2 2 0 002-2v-2" />
</svg>
</button>
</div>
</div>
<!-- Mobile: search only (tabs handled by Dashboard.vue header) -->
<div class="md:hidden">
<!-- Mobile: search + sideload button (tabs handled by Dashboard.vue header) -->
<div class="md:hidden flex items-center gap-2">
<input
v-model="searchQuery"
type="text"
:placeholder="t('apps.searchPlaceholder')"
:aria-label="t('apps.searchLabel')"
class="w-full px-4 py-3 bg-white/10 border border-white/20 rounded-lg text-white placeholder-white/50 focus:outline-none focus:border-white/40 transition-colors"
class="min-w-0 flex-1 px-4 py-3 bg-white/10 border border-white/20 rounded-lg text-white placeholder-white/50 focus:outline-none focus:border-white/40 transition-colors"
/>
<button
type="button"
class="sideload-icon-btn sideload-icon-btn-mobile"
aria-label="Sideload app"
title="Sideload app"
@click="showSideload = true"
>
<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 16V4m0 0l-4 4m4-4l4 4M4 16v2a2 2 0 002 2h12a2 2 0 002-2v-2" />
</svg>
</button>
</div>
</div>
@@ -82,10 +106,34 @@
</div>
<!-- No Results -->
<div v-if="filteredPackageEntries.length === 0 && searchQuery" class="text-center py-12">
<div v-if="filteredPackageEntries.length === 0 && marketplaceMatches.length === 0 && searchQuery" class="text-center py-12">
<p class="text-white/70">{{ t('apps.noResults', { query: searchQuery }) }}</p>
</div>
<div v-if="marketplaceMatches.length > 0" class="mb-5">
<div class="flex items-center gap-3 mb-3">
<span class="discover-terminal-tag">app store</span>
<h2 class="text-lg font-bold text-white">Available in Discover</h2>
<div class="flex-1 h-px bg-white/10"></div>
</div>
<div class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4">
<button
v-for="app in marketplaceMatches"
:key="app.id"
type="button"
class="glass-card p-4 text-left flex items-center gap-3 hover:bg-orange-500/5 hover:border-orange-500/15 transition-colors"
@click="openMarketplaceResult(app)"
>
<img v-if="app.icon" :src="app.icon" :alt="app.title" class="w-12 h-12 rounded-xl object-cover bg-white/10" />
<div v-else class="w-12 h-12 rounded-xl bg-white/10 flex-shrink-0"></div>
<div class="min-w-0 flex-1">
<p class="font-semibold text-white truncate">{{ app.title }}</p>
<p class="text-xs text-white/50 truncate">Available in App Store</p>
</div>
</button>
</div>
</div>
<!-- Mobile: iPhone-style icon grid -->
<div class="md:hidden">
<AppIconGrid
@@ -125,6 +173,94 @@
@confirm="onConfirmUninstall"
/>
<Transition name="fade">
<div
v-if="credentialModal.show"
class="fixed inset-0 z-[2700] flex items-end justify-center bg-black/60 backdrop-blur-md p-0 md:items-center md:p-6"
@click.self="closeCredentialModal"
>
<div class="sideload-modal credential-modal">
<div class="flex items-start justify-between gap-4 mb-5">
<div>
<h2 class="text-lg font-semibold text-white">{{ credentialModal.title }}</h2>
<p class="text-sm text-white/55 mt-1">{{ credentialModal.description }}</p>
</div>
<button type="button" class="sideload-close-btn" aria-label="Close" @click="closeCredentialModal">&times;</button>
</div>
<div class="credential-modal-body space-y-3">
<div v-for="cred in credentialModal.credentials" :key="cred.label" class="rounded-lg border border-white/10 bg-white/[0.04] p-3">
<div class="flex items-center justify-between gap-3 mb-1">
<span class="text-white/60 text-xs uppercase tracking-wide">{{ cred.label }}</span>
<button type="button" class="text-xs text-blue-300 hover:text-blue-200" @click="copyModalCredential(cred.label, cred.value)">{{ credentialModal.copied === cred.label ? 'Copied' : 'Copy' }}</button>
</div>
<p class="font-mono text-sm text-white break-all">{{ cred.value }}</p>
</div>
</div>
<div class="credential-modal-actions mt-5 flex flex-col sm:flex-row gap-3">
<button type="button" class="w-full sm:flex-1 glass-button px-4 py-3 rounded-lg" @click="closeCredentialModal">Cancel</button>
<button type="button" class="w-full sm:flex-1 glass-button px-4 py-3 rounded-lg font-semibold" @click="continueCredentialLaunch">Continue to app</button>
</div>
</div>
</div>
</Transition>
<Transition name="fade">
<div
v-if="showSideload"
class="fixed inset-0 z-[2600] flex items-end justify-center bg-black/60 backdrop-blur-md p-0 md:items-center md:p-6"
@click.self="closeSideload"
>
<form class="sideload-modal" @submit.prevent="submitSideload">
<div class="flex items-start justify-between gap-4 mb-5">
<div>
<h2 class="text-lg font-semibold text-white">Sideload app</h2>
<p class="text-sm text-white/55 mt-1">Install a trusted Docker image with a simple web UI.</p>
</div>
<button type="button" class="sideload-close-btn" aria-label="Close" @click="closeSideload">&times;</button>
</div>
<div class="space-y-4">
<label class="block">
<span class="sideload-label">App ID</span>
<input v-model.trim="sideloadForm.id" class="sideload-input" placeholder="excalidraw" pattern="[a-z0-9][a-z0-9-]{0,63}" required />
</label>
<label class="block">
<span class="sideload-label">Docker image</span>
<input v-model.trim="sideloadForm.image" class="sideload-input" placeholder="docker.io/excalidraw/excalidraw:latest" required />
</label>
<div class="grid grid-cols-1 sm:grid-cols-2 gap-3">
<label class="block">
<span class="sideload-label">Title</span>
<input v-model.trim="sideloadForm.title" class="sideload-input" placeholder="Excalidraw" />
</label>
<label class="block">
<span class="sideload-label">Port mapping</span>
<input v-model.trim="sideloadForm.port" class="sideload-input" placeholder="3009:80" />
</label>
</div>
<label class="block">
<span class="sideload-label">Description</span>
<input v-model.trim="sideloadForm.description" class="sideload-input" placeholder="Collaborative whiteboard" />
</label>
</div>
<div v-if="sideloadError" class="alert-error mt-4 text-sm">{{ sideloadError }}</div>
<div class="mt-5 rounded-xl border border-white/10 bg-white/[0.04] p-4 text-sm text-white/65">
<p class="font-medium text-white/80 mb-2">Easy sources</p>
<p>Use images from Docker Hub, GHCR, git.tx1138.com, the VPS2 Gitea registry, or localhost. Good first candidates: Excalidraw, Stirling PDF, FreshRSS, Wallabag, HedgeDoc, CyberChef, Mealie, or PairDrop.</p>
</div>
<div class="mt-5 flex gap-3">
<button type="button" class="flex-1 glass-button px-4 py-3 rounded-lg" @click="closeSideload">Cancel</button>
<button type="submit" class="flex-1 glass-button px-4 py-3 rounded-lg font-semibold" :disabled="sideloading">
{{ sideloading ? 'Installing...' : 'Install' }}
</button>
</div>
</form>
</div>
</Transition>
<!-- Action error toast -->
<Transition name="fade">
<div v-if="actions.actionError.value" 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">
@@ -149,15 +285,18 @@ import { useI18n } from 'vue-i18n'
import { useAppStore } from '@/stores/app'
import { useServerStore } from '@/stores/server'
import { useAppLauncherStore } from '@/stores/appLauncher'
import { type PackageDataEntry, type PackageState } from '@/types/api'
import { rpcClient } from '@/api/rpc-client'
import { type AppCredential, type AppCredentialsResponse, type PackageDataEntry, type PackageState } from '@/types/api'
import AppCard from './apps/AppCard.vue'
import AppIconGrid from './apps/AppIconGrid.vue'
import AppsUninstallModal from './apps/AppsUninstallModal.vue'
import { useAppsActions } from './apps/useAppsActions'
import { useMarketplaceApp } from '@/composables/useMarketplaceApp'
import {
filterEntriesForTab, isWebOnlyApp,
WEB_ONLY_APPS, buildAllCategories, useCategoriesWithApps,
type AppsTab, filterEntriesForTab, isWebOnlyApp, isWebsitePackage, opensInTab, resolveRuntimeLaunchUrl,
WEB_ONLY_APPS, WEB_ONLY_APP_URLS, buildAllCategories, useCategoriesWithApps,
} from './apps/appsConfig'
import { getCuratedAppList, INSTALLED_ALIASES, type MarketplaceApp } from './marketplace/marketplaceData'
const { t } = useI18n()
const router = useRouter()
@@ -165,15 +304,38 @@ const route = useRoute()
const store = useAppStore()
const serverStore = useServerStore()
const actions = useAppsActions()
const { setCurrentApp } = useMarketplaceApp()
const showSideload = ref(false)
const sideloading = ref(false)
const sideloadError = ref('')
const sideloadForm = ref({
id: '',
image: '',
title: '',
port: '',
description: '',
})
const credentialModal = ref({
show: false,
appId: '',
title: '',
description: '',
credentials: [] as AppCredential[],
copied: '',
})
// Only stagger-animate on first mount
const showStagger = !appsAnimationDone
// Tabs
const activeTab = ref<'apps' | 'services'>(
route.query.tab === 'services' ? 'services' : 'apps'
const activeTab = ref<AppsTab>(
route.query.tab === 'websites' || route.query.tab === 'services' ? 'websites' : 'apps'
)
watch(() => route.query.tab, (tab) => {
activeTab.value = tab === 'websites' || tab === 'services' ? 'websites' : 'apps'
})
// Search (debounced)
const searchQuery = ref('')
const debouncedSearchQuery = ref('')
@@ -217,6 +379,19 @@ const packages = computed(() => {
const categoriesWithApps = useCategoriesWithApps(packages, ALL_CATEGORIES)
const curatedApps = getCuratedAppList()
const marketplaceMatches = computed(() => {
const q = debouncedSearchQuery.value.trim().toLowerCase()
if (!q || activeTab.value !== 'apps') return [] as MarketplaceApp[]
return curatedApps.filter(app => {
if (isInstalledInMyApps(app.id)) return false
return app.title?.toLowerCase().includes(q) ||
app.id.toLowerCase().includes(q) ||
app.author?.toLowerCase().includes(q) ||
(typeof app.description === 'string' && app.description.toLowerCase().includes(q))
}).slice(0, 6)
})
const isLoadingApps = computed(() => !store.hasLoadedInitialData && !connectionError.value)
// Connection error state
@@ -260,6 +435,17 @@ const filteredPackageEntries = computed(() => {
)
})
function isInstalledInMyApps(appId: string): boolean {
if (appId in packages.value) return true
const aliases = INSTALLED_ALIASES[appId]
return aliases ? aliases.some(alias => alias in packages.value) : false
}
function openMarketplaceResult(app: MarketplaceApp) {
setCurrentApp(app)
router.push({ name: 'marketplace-app-detail', params: { id: app.id }, query: { from: 'apps' } }).catch(() => {})
}
// Uninstall modal
const uninstallModal = ref({ show: false, appId: '', appTitle: '' })
@@ -284,10 +470,83 @@ function goToApp(id: string) {
router.push(`/dashboard/apps/${id}`).catch(() => {})
}
function launchApp(id: string) {
async function launchApp(id: string) {
const shown = await maybeShowCredentialsBeforeLaunch(id)
if (shown) return
launchAppNow(id)
}
function launchAppNow(id: string) {
const pkg = packages.value[id]
const isMobile = typeof window !== 'undefined' && window.innerWidth < 768
const webOnlyUrl = WEB_ONLY_APP_URLS[id]
if (pkg && webOnlyUrl) {
useAppLauncherStore().open({ url: webOnlyUrl, title: pkg.manifest.title, openInNewTab: !isMobile })
return
}
if (pkg && isWebsitePackage(id, pkg)) {
const url = resolveRuntimeLaunchUrl(pkg)
if (url) {
useAppLauncherStore().open({ url, title: pkg.manifest.title, openInNewTab: !isMobile })
}
return
}
if (!isMobile && pkg && opensInTab(id)) {
const url = resolveRuntimeLaunchUrl(pkg)
if (url) {
window.open(url, '_blank', 'noopener,noreferrer')
return
}
}
useAppLauncherStore().openSession(id)
}
async function maybeShowCredentialsBeforeLaunch(id: string): Promise<boolean> {
try {
const result = await rpcClient.call<AppCredentialsResponse>({
method: 'package.credentials',
params: { app_id: id },
timeout: 5000,
})
if (!result.credentials?.length) return false
credentialModal.value = {
show: true,
appId: id,
title: result.title || `${packages.value[id]?.manifest.title || id} credentials`,
description: result.description || 'Use these credentials when the app asks you to sign in.',
credentials: result.credentials,
copied: '',
}
return true
} catch {
return false
}
}
function closeCredentialModal() {
credentialModal.value.show = false
}
function continueCredentialLaunch() {
const id = credentialModal.value.appId
closeCredentialModal()
if (id) launchAppNow(id)
}
async function copyModalCredential(label: string, value: string) {
try {
await navigator.clipboard.writeText(value)
} catch {
const textarea = document.createElement('textarea')
textarea.value = value
document.body.appendChild(textarea)
textarea.select()
document.execCommand('copy')
document.body.removeChild(textarea)
}
credentialModal.value.copied = label
}
async function updateApp(id: string) {
try {
await serverStore.updatePackage(id)
@@ -295,4 +554,159 @@ async function updateApp(id: string) {
actions.actionError.value = `Failed to update ${id}: ${err instanceof Error ? err.message : 'Unknown error'}`
}
}
function closeSideload() {
if (sideloading.value) return
showSideload.value = false
sideloadError.value = ''
}
function inferPortMapping(image: string): string {
const lower = image.toLowerCase()
if (lower.includes('excalidraw')) return '3009:80'
if (lower.includes('stirling')) return '3011:8080'
if (lower.includes('freshrss')) return '3012:80'
if (lower.includes('wallabag')) return '3013:80'
if (lower.includes('hedgedoc')) return '3014:3000'
if (lower.includes('cyberchef')) return '3015:80'
if (lower.includes('mealie')) return '3016:9000'
if (lower.includes('pairdrop')) return '3017:3000'
return ''
}
async function submitSideload() {
const id = sideloadForm.value.id.trim().toLowerCase()
const image = sideloadForm.value.image.trim()
const title = sideloadForm.value.title.trim() || id.replace(/-/g, ' ').replace(/\b\w/g, c => c.toUpperCase())
const port = sideloadForm.value.port.trim() || inferPortMapping(image)
if (!/^[a-z0-9][a-z0-9-]{0,63}$/.test(id)) {
sideloadError.value = 'Use lowercase letters, numbers, and hyphens only.'
return
}
if (!image || !image.includes('/')) {
sideloadError.value = 'Enter a full image name, for example docker.io/library/nginx:alpine.'
return
}
sideloading.value = true
sideloadError.value = ''
const containerConfig: Record<string, unknown> = {}
containerConfig.title = title
if (sideloadForm.value.description.trim()) containerConfig.description = sideloadForm.value.description.trim()
if (port) containerConfig.ports = [port]
try {
serverStore.setInstallProgress(id, {
id,
title,
status: 'downloading',
progress: 2,
message: 'Sideload queued...',
attempt: 0,
})
await rpcClient.call({
method: 'package.install',
params: {
id,
dockerImage: image,
version: 'sideload',
containerConfig,
},
timeout: 600000,
})
closeSideload()
sideloadForm.value = { id: '', image: '', title: '', port: '', description: '' }
} catch (err) {
sideloadError.value = err instanceof Error ? err.message : 'Install failed'
serverStore.setInstallProgress(id, {
id,
title,
status: 'error',
progress: 0,
message: sideloadError.value,
attempt: 0,
})
} finally {
sideloading.value = false
}
}
</script>
<style scoped>
.sideload-icon-btn {
display: inline-flex;
align-items: center;
justify-content: center;
flex-shrink: 0;
width: 42px;
height: 42px;
border-radius: 0.75rem;
border: 1px solid rgba(255, 255, 255, 0.2);
background: rgba(255, 255, 255, 0.1);
color: rgba(255, 255, 255, 0.78);
transition: border-color 0.15s ease, background 0.15s ease, color 0.15s ease;
}
.sideload-icon-btn:hover,
.sideload-icon-btn:focus-visible {
border-color: rgba(255, 255, 255, 0.38);
background: rgba(255, 255, 255, 0.15);
color: white;
}
.sideload-icon-btn-mobile {
width: 48px;
height: 48px;
}
.sideload-modal {
width: 100%;
max-width: 34rem;
max-height: calc(100dvh - var(--safe-area-top, env(safe-area-inset-top, 0px)) - 12px);
overflow-y: auto;
border: 1px solid rgba(255, 255, 255, 0.14);
border-radius: 1.5rem 1.5rem 0 0;
background: rgba(8, 10, 18, 0.94);
padding: 1.25rem;
padding-bottom: calc(1.25rem + var(--safe-area-bottom, env(safe-area-inset-bottom, 0px)));
box-shadow: 0 -24px 70px rgba(0, 0, 0, 0.55);
}
.sideload-close-btn {
width: 2.25rem;
height: 2.25rem;
border-radius: 0.75rem;
color: rgba(255, 255, 255, 0.55);
background: rgba(255, 255, 255, 0.06);
}
.sideload-label {
display: block;
margin-bottom: 0.4rem;
font-size: 0.75rem;
color: rgba(255, 255, 255, 0.62);
}
.sideload-input {
width: 100%;
border-radius: 0.75rem;
border: 1px solid rgba(255, 255, 255, 0.16);
background: rgba(255, 255, 255, 0.08);
padding: 0.75rem 0.9rem;
color: white;
outline: none;
}
.sideload-input::placeholder { color: rgba(255, 255, 255, 0.38); }
.sideload-input:focus { border-color: rgba(255, 255, 255, 0.38); }
.credential-modal {
display: flex;
flex-direction: column;
}
.credential-modal-body {
min-height: 0;
overflow-y: auto;
-webkit-overflow-scrolling: touch;
}
.credential-modal-actions {
flex-shrink: 0;
}
@media (min-width: 768px) {
.sideload-modal {
border-radius: 1.25rem;
padding: 1.5rem;
box-shadow: 0 25px 80px rgba(0, 0, 0, 0.55);
}
}
</style>
+11
View File
@@ -67,6 +67,9 @@
data-controller-zone="main"
class="flex-1 overflow-hidden relative pb-0 glass-piece z-10"
:class="{ 'glass-throw-main': showZoomIn }"
tabindex="-1"
@pointerenter="activateMainScroll"
@wheel.capture="activateMainScroll"
>
<div data-controller-main-entry class="absolute top-4 right-4 md:top-6 md:right-8 z-20">
<!-- Controller zone entry point - no switcher -->
@@ -234,6 +237,14 @@ function restoreScroll(path: string) {
})
}
function activateMainScroll() {
const active = document.activeElement as HTMLElement | null
if (active?.closest?.('[data-controller-zone="sidebar"]')) {
active.blur()
document.getElementById('main-content')?.focus({ preventScroll: true })
}
}
watch(() => route.path, (newPath) => {
const isAppDetails = isDetailRoute(newPath)
const wasAppDetails = showAltBackground.value
+22 -18
View File
@@ -7,7 +7,7 @@
<div class="mode-switcher flex-shrink-0">
<RouterLink to="/dashboard/apps" class="mode-switcher-btn">My Apps</RouterLink>
<RouterLink to="/dashboard/discover" class="mode-switcher-btn mode-switcher-btn-active">App Store</RouterLink>
<RouterLink to="/dashboard/apps?tab=services" class="mode-switcher-btn">Services</RouterLink>
<RouterLink to="/dashboard/apps?tab=websites" class="mode-switcher-btn">Websites</RouterLink>
</div>
<div class="mode-switcher flex-shrink-0">
<RouterLink
@@ -33,8 +33,22 @@
/>
</div>
<!-- Mobile: search -->
<!-- Mobile: categories + search -->
<div class="md:hidden mb-4">
<div class="flex items-center gap-2 mb-3">
<span class="discover-terminal-tag">discover</span>
<h1 class="text-lg font-bold text-white">App Store</h1>
</div>
<div class="mobile-category-strip mb-3" aria-label="App Store categories">
<button class="mobile-category-pill mobile-category-pill-active" type="button">Discover</button>
<button
v-for="category in categoriesWithApps"
:key="category.id"
@click="navigateToMarketplace(category.id)"
class="mobile-category-pill"
type="button"
>{{ category.name }}</button>
</div>
<input
v-model="searchQuery"
type="text"
@@ -163,11 +177,6 @@
<p class="text-white/60 text-xl mt-4 font-mono">// Cypherpunks write code. We run nodes.</p>
</div>
<FilterModal
:categories="categoriesWithApps"
:selected-category="selectedCategory"
@select-category="selectCategory"
/>
</div>
</template>
@@ -187,7 +196,6 @@ import { useToast } from '@/composables/useToast'
import DiscoverHero from './discover/DiscoverHero.vue'
import FeaturedApps from './discover/FeaturedApps.vue'
import AppGrid from './discover/AppGrid.vue'
import FilterModal from './discover/FilterModal.vue'
import type { MarketplaceApp, FeaturedApp } from './discover/types'
import { getCuratedAppList, INSTALLED_ALIASES, FEATURED_DEFINITIONS, categorizeCommunityApp, fetchAppCatalog, type CatalogFeatured } from './discover/curatedApps'
@@ -224,13 +232,6 @@ const categories = computed(() => [
// been removed in favour of the store's phase-aware mapping.
const installingApps = serverStore.installingApps
function selectCategory(id: string) {
selectedCategory.value = id
if (id === 'nostr' && nostrApps.value.length === 0 && !nostrLoading.value) {
loadNostrMarketplace()
}
}
function navigateToMarketplace(categoryId: string) {
router.push({ name: 'marketplace', query: { category: categoryId } })
}
@@ -312,6 +313,9 @@ const categoriesWithApps = computed(() => {
const filteredApps = computed(() => {
let apps = allApps.value
if (selectedCategory.value && selectedCategory.value !== 'all' && !searchQuery.value) {
apps = apps.filter(app => app.category === selectedCategory.value)
}
if (searchQuery.value) {
const query = searchQuery.value.toLowerCase()
apps = apps.filter(app =>
@@ -383,7 +387,7 @@ function isStartingUp(appId: string): boolean {
function getAppTier(appId: string): string {
const core = ['bitcoin-knots', 'bitcoin', 'lnd', 'mempool', 'btcpay-server', 'dwn', 'filebrowser']
const recommended = ['fedimint', 'thunderhub', 'vaultwarden', 'uptime-kuma', 'grafana', 'searxng', 'tailscale', 'portainer']
const recommended = ['fedimint', 'thunderhub', 'vaultwarden', 'uptime-kuma', 'grafana', 'searxng', 'tailscale', 'netbird', 'portainer']
if (core.includes(appId)) return 'core'
if (recommended.includes(appId)) return 'recommended'
return 'optional'
@@ -487,7 +491,7 @@ async function installApp(app: MarketplaceApp) {
router.push('/dashboard/apps').catch(() => {})
try {
const installUrl = app.url || app.manifestUrl || app.s9pkUrl
await rpcClient.call({ method: 'package.install', params: { id: app.id, url: installUrl, version: app.version }, timeout: 15000 })
await rpcClient.call({ method: 'package.install', params: { id: app.id, url: installUrl, version: app.version }, timeout: 600000 })
} catch (err) {
if (import.meta.env.DEV) console.error('Installation failed:', err)
failInstall(app, err)
@@ -504,7 +508,7 @@ async function installCommunityApp(app: MarketplaceApp) {
if ((app as Record<string, unknown>).containerConfig) {
installParams.containerConfig = (app as Record<string, unknown>).containerConfig
}
await rpcClient.call({ method: 'package.install', params: installParams, timeout: 15000 })
await rpcClient.call({ method: 'package.install', params: installParams, timeout: 600000 })
} catch (err) {
if (import.meta.env.DEV) console.error('[Discover] Installation failed:', err)
failInstall(app, err)
+40 -23
View File
@@ -144,12 +144,12 @@
<span class="text-sm font-medium" :class="torConnected ? 'text-purple-400' : 'text-white/40'">{{ torConnected ? 'Connected' : 'Offline' }}</span>
</div>
<div class="flex items-center justify-between p-3 bg-white/5 rounded-lg">
<div class="flex items-center gap-3"><div class="w-2 h-2 rounded-full" :class="vpnConnected ? 'bg-orange-400' : 'bg-white/40'"></div><span class="text-sm text-white/80">VPN</span></div>
<span class="text-sm font-medium" :class="vpnConnected ? 'text-orange-400' : 'text-white/40'">{{ vpnConnected ? 'WireGuard' : 'Not configured' }}</span>
<div class="flex items-center gap-3"><div class="w-2 h-2 rounded-full" :class="vpnDotClass"></div><span class="text-sm text-white/80">VPN</span></div>
<span class="text-sm font-medium" :class="vpnTextClass">{{ vpnStatusLabel }}</span>
</div>
<div class="flex items-center justify-between p-3 bg-white/5 rounded-lg">
<div class="flex items-center gap-3"><div class="w-2 h-2 rounded-full" :class="systemStats.bitcoinAvailable ? 'bg-orange-400' : 'bg-white/40'"></div><span class="text-sm text-white/80">Bitcoin</span></div>
<span class="text-sm font-medium" :class="systemStats.bitcoinAvailable ? 'text-orange-400' : 'text-white/40'">{{ bitcoinSyncDisplay }}</span>
<div class="flex items-center gap-3"><div class="w-2 h-2 rounded-full" :class="bitcoinDotClass"></div><span class="text-sm text-white/80">Bitcoin</span></div>
<span class="text-sm font-medium" :class="bitcoinTextClass">{{ bitcoinSyncDisplay }}</span>
</div>
<div class="flex items-center justify-between p-3 bg-white/5 rounded-lg">
<div class="flex items-center gap-3"><div class="w-2 h-2 rounded-full" :class="fipsDotClass"></div><span class="text-sm text-white/80">FIPS</span></div>
@@ -229,7 +229,7 @@
</template>
<script setup lang="ts">
import { computed, reactive, ref, watch, onBeforeUnmount, onMounted } from 'vue'
import { computed, ref, watch, onBeforeUnmount, onMounted } from 'vue'
import { RouterLink, useRouter } from 'vue-router'
import { useI18n } from 'vue-i18n'
import SendBitcoinModal from '@/components/SendBitcoinModal.vue'
@@ -239,6 +239,7 @@ import { useAppStore } from '../stores/app'
import { useAppLauncherStore } from '@/stores/appLauncher'
import { useLoginTransitionStore } from '../stores/loginTransition'
import { useUIModeStore } from '@/stores/uiMode'
import { useHomeStatusStore } from '@/stores/homeStatus'
import { PackageState } from '../types/api'
import { playTypingSound } from '@/composables/useLoginSounds'
import { GOALS } from '@/data/goals'
@@ -261,6 +262,7 @@ const QUICK_START_KEY = 'archipelago-quick-start-dismissed'
const QUICK_START_RESHOW_LOGINS = 5
const store = useAppStore()
const homeStatus = useHomeStatusStore()
const loginTransition = useLoginTransitionStore()
const LINE1 = t('home.title')
@@ -315,11 +317,19 @@ const torConnected = computed(() => {
const torAddr = store.data?.['server-info']?.['tor-address']
return !!torAddr && torAddr.length > 0
})
const vpnStatus = ref({ connected: false, provider: '' })
const vpnConnected = computed(() => vpnStatus.value.connected || (!!packages.value['tailscale'] && packages.value['tailscale'].state === PackageState.Running))
const fipsStatus = ref<{ installed: boolean; service_active: boolean; key_present: boolean; anchor_connected?: boolean; authenticated_peer_count?: number } | null>(null)
const vpnConnected = computed(() => homeStatus.vpnStatus.connected === true || (!!packages.value['tailscale'] && packages.value['tailscale'].state === PackageState.Running))
const vpnDotClass = computed(() => {
if (vpnConnected.value) return 'bg-orange-400'
return homeStatus.vpnKnown ? 'bg-white/40' : 'bg-white/25 animate-pulse'
})
const vpnTextClass = computed(() => vpnConnected.value ? 'text-orange-400' : (homeStatus.vpnKnown ? 'text-white/40' : 'text-white/50'))
const vpnStatusLabel = computed(() => {
if (vpnConnected.value) return homeStatus.vpnStatus.provider || 'WireGuard'
if (!homeStatus.vpnKnown) return 'Checking…'
return 'Not configured'
})
const fipsDotClass = computed(() => {
const s = fipsStatus.value
const s = homeStatus.fipsStatus
if (!s || !s.installed) return 'bg-white/40'
if (!s.service_active) return 'bg-white/40'
// Active but no anchor = degraded, not fully green
@@ -327,15 +337,15 @@ const fipsDotClass = computed(() => {
return 'bg-green-400'
})
const fipsTextClass = computed(() => {
const s = fipsStatus.value
const s = homeStatus.fipsStatus
if (!s || !s.installed) return 'text-white/40'
if (!s.service_active) return 'text-white/40'
if (s.anchor_connected === false) return 'text-orange-400'
return 'text-green-400'
})
const fipsStatusLabel = computed(() => {
const s = fipsStatus.value
if (!s) return '…'
const s = homeStatus.fipsStatus
if (!s) return homeStatus.fipsLoadState === 'loading' ? 'Checking…' : '…'
if (!s.installed) return 'Not installed'
if (!s.service_active) {
if (!s.key_present) return 'Awaiting seed'
@@ -348,11 +358,18 @@ const fipsStatusLabel = computed(() => {
return peers === 1 ? 'Active · 1 peer' : `Active · ${peers} peers`
})
const bitcoinSyncDisplay = computed(() => {
if (!systemStats.bitcoinAvailable) return 'Not running'
if (systemStats.bitcoinSyncPercent >= 99.9) return 'Synced'
if (systemStats.bitcoinSyncPercent < 0.01 && systemStats.bitcoinBlockHeight === 0) return 'Loading...'
return `${systemStats.bitcoinSyncPercent.toFixed(1)}%`
if (homeStatus.stats.bitcoinAvailable === null) return 'Checking'
if (!homeStatus.stats.bitcoinAvailable) return 'Not running'
if (homeStatus.stats.bitcoinSyncPercent >= 99.9) return 'Synced'
if (homeStatus.stats.bitcoinSyncPercent < 0.01 && homeStatus.stats.bitcoinBlockHeight === 0) return 'Loading...'
return `${homeStatus.stats.bitcoinSyncPercent.toFixed(1)}%`
})
const bitcoinDotClass = computed(() => {
if (homeStatus.stats.bitcoinAvailable === true) return 'bg-orange-400'
if (homeStatus.stats.bitcoinAvailable === false) return 'bg-white/40'
return 'bg-white/25 animate-pulse'
})
const bitcoinTextClass = computed(() => homeStatus.stats.bitcoinAvailable ? 'text-orange-400' : (homeStatus.stats.bitcoinAvailable === null ? 'text-white/50' : 'text-white/40'))
// Quick Start
const quickStartDismissed = ref(false)
@@ -382,8 +399,6 @@ const cloudFolderDisplay = computed(() => cloudFolderCount.value !== null ? Stri
onMounted(async () => {
try { const usage = await fileBrowserClient.getUsage(); cloudStorageUsed.value = usage.totalSize; cloudFolderCount.value = usage.folderCount } catch { /* not running */ }
loadSystemStats(); systemStatsInterval = setInterval(loadSystemStats, 30000); checkUpdateStatus(); loadWeb5Status()
rpcClient.vpnStatus().then(s => { vpnStatus.value = { connected: s.connected, provider: s.provider ?? '' } }).catch(() => {})
rpcClient.call<{ installed: boolean; service_active: boolean; key_present: boolean }>({ method: 'fips.status' }).then(s => { fipsStatus.value = s }).catch(() => {})
})
// Wallet modals
@@ -403,15 +418,17 @@ async function loadWeb5Status() {
}
// System stats
const systemStatsLoaded = ref(false)
const systemStats = reactive({ cpuPercent: 0, memUsed: 0, memTotal: 0, memPercent: 0, diskUsed: 0, diskTotal: 0, diskPercent: 0, uptimeSecs: 0, bitcoinSyncPercent: 0, bitcoinBlockHeight: 0, bitcoinAvailable: false })
const systemUptimeDisplay = computed(() => { if (systemStats.uptimeSecs === 0) return t('home.systemMonitoring'); const days = Math.floor(systemStats.uptimeSecs / 86400); const hours = Math.floor((systemStats.uptimeSecs % 86400) / 3600); if (days > 0) return `Uptime: ${days}d ${hours}h`; const mins = Math.floor((systemStats.uptimeSecs % 3600) / 60); return `Uptime: ${hours}h ${mins}m` })
const systemStatsLoaded = computed(() => homeStatus.systemStatsLoaded)
const systemStats = computed(() => ({
...homeStatus.stats,
bitcoinAvailable: homeStatus.stats.bitcoinAvailable === true,
}))
const systemUptimeDisplay = computed(() => { if (homeStatus.stats.uptimeSecs === 0) return t('home.systemMonitoring'); const days = Math.floor(homeStatus.stats.uptimeSecs / 86400); const hours = Math.floor((homeStatus.stats.uptimeSecs % 86400) / 3600); if (days > 0) return `Uptime: ${days}d ${hours}h`; const mins = Math.floor((homeStatus.stats.uptimeSecs % 3600) / 60); return `Uptime: ${hours}h ${mins}m` })
let systemStatsInterval: ReturnType<typeof setInterval> | null = null
async function loadSystemStats() {
try { const res = await rpcClient.call<{ cpu_usage_percent: number; mem_used_bytes: number; mem_total_bytes: number; disk_used_bytes: number; disk_total_bytes: number; uptime_secs: number }>({ method: 'system.stats' }); systemStats.cpuPercent = res.cpu_usage_percent; systemStats.memUsed = res.mem_used_bytes; systemStats.memTotal = res.mem_total_bytes; systemStats.memPercent = res.mem_total_bytes > 0 ? (res.mem_used_bytes / res.mem_total_bytes) * 100 : 0; systemStats.diskUsed = res.disk_used_bytes; systemStats.diskTotal = res.disk_total_bytes; systemStats.diskPercent = res.disk_total_bytes > 0 ? (res.disk_used_bytes / res.disk_total_bytes) * 100 : 0; systemStats.uptimeSecs = res.uptime_secs; systemStatsLoaded.value = true } catch { systemStatsLoaded.value = true }
try { const btc = await rpcClient.call<{ block_height: number; sync_progress: number }>({ method: 'bitcoin.getinfo', timeout: 5000 }); systemStats.bitcoinSyncPercent = (btc.sync_progress ?? 0) * 100; systemStats.bitcoinBlockHeight = btc.block_height ?? 0; systemStats.bitcoinAvailable = true } catch { /* RPC failed — check if container is at least running (loading/syncing) */ const btcPkg = packages.value['bitcoin-knots']; systemStats.bitcoinAvailable = btcPkg?.state === PackageState.Running }
await homeStatus.refresh(packages.value)
}
function uploadFiles() { const pkg = packages.value['filebrowser']; if (pkg && pkg.state === PackageState.Running) { const host = window.location.hostname; useAppLauncherStore().open({ url: `http://${host}:8083`, title: 'File Browser' }) } else { router.push('/dashboard/cloud') } }
+35 -12
View File
@@ -7,7 +7,7 @@
<div class="mode-switcher flex-shrink-0">
<RouterLink to="/dashboard/apps" class="mode-switcher-btn">My Apps</RouterLink>
<RouterLink to="/dashboard/discover" class="mode-switcher-btn mode-switcher-btn-active">App Store</RouterLink>
<RouterLink to="/dashboard/apps?tab=services" class="mode-switcher-btn">Services</RouterLink>
<RouterLink to="/dashboard/apps?tab=websites" class="mode-switcher-btn">Websites</RouterLink>
</div>
<div class="mode-switcher flex-shrink-0">
<RouterLink
@@ -35,8 +35,27 @@
/>
</div>
<!-- Mobile: search (tabs handled by Dashboard.vue header) -->
<!-- Mobile: categories + search (tabs handled by Dashboard.vue header) -->
<div class="md:hidden mb-4">
<div class="flex items-center gap-2 mb-3">
<span class="discover-terminal-tag">discover</span>
<h1 class="text-lg font-bold text-white">App Store</h1>
</div>
<div class="mobile-category-strip mb-3" aria-label="App Store categories">
<button
@click="router.push({ name: 'discover' })"
class="mobile-category-pill"
type="button"
>Discover</button>
<button
v-for="category in categoriesWithApps"
:key="category.id"
@click="selectCategory(category.id)"
class="mobile-category-pill"
:class="{ 'mobile-category-pill-active': selectedCategory === category.id }"
type="button"
>{{ category.name }}</button>
</div>
<input
v-model="searchQuery"
type="text"
@@ -96,11 +115,6 @@
</div>
<!-- End Scrollable Apps Section -->
<MarketplaceFilterModal
:categories="categoriesWithApps"
:selected-category="selectedCategory"
@select="selectCategory"
/>
</div>
</template>
@@ -109,7 +123,7 @@ let marketplaceAnimationDone = false
</script>
<script setup lang="ts">
import { ref, computed, onMounted, onBeforeUnmount } from 'vue'
import { ref, computed, onMounted, onBeforeUnmount, watch } from 'vue'
import { useRouter, useRoute, RouterLink } from 'vue-router'
import { useI18n } from 'vue-i18n'
import { useAppStore } from '@/stores/app'
@@ -119,7 +133,6 @@ import { useMarketplaceApp } from '@/composables/useMarketplaceApp'
import { useAppLauncherStore } from '@/stores/appLauncher'
import { useToast } from '@/composables/useToast'
import MarketplaceAppCard from './marketplace/MarketplaceAppCard.vue'
import MarketplaceFilterModal from './marketplace/MarketplaceFilterModal.vue'
import {
type MarketplaceApp,
INSTALLED_ALIASES,
@@ -166,11 +179,21 @@ const electrumxArchiveWarning = 'You need a full archival bitcoin node before do
// Select category and trigger Nostr relay discovery when 'nostr' is chosen
function selectCategory(id: string) {
selectedCategory.value = id
const query = id === 'all' ? {} : { category: id }
router.replace({ name: 'marketplace', query }).catch(() => {})
if (id === 'nostr' && nostrApps.value.length === 0 && !nostrLoading.value) {
loadNostrMarketplace()
}
}
watch(() => route.query.category, (category) => {
const next = typeof category === 'string' && category ? category : 'all'
selectedCategory.value = next
if (next === 'nostr' && nostrApps.value.length === 0 && !nostrLoading.value) {
loadNostrMarketplace()
}
})
// Community marketplace state
const loadingCommunity = ref(false)
const communityError = ref('')
@@ -258,7 +281,7 @@ const categoriesWithApps = computed(() => {
const filteredApps = computed(() => {
let apps = allApps.value
if (selectedCategory.value && selectedCategory.value !== 'all') {
if (selectedCategory.value && selectedCategory.value !== 'all' && !searchQuery.value) {
apps = apps.filter(app => app.category === selectedCategory.value)
}
@@ -415,7 +438,7 @@ async function installApp(app: MarketplaceApp) {
await rpcClient.call({
method: 'package.install',
params: { id: app.id, url: installUrl, version: app.version },
timeout: 15000,
timeout: 600000,
})
} catch (err) {
if (import.meta.env.DEV) console.error('Installation failed:', err)
@@ -441,7 +464,7 @@ async function installCommunityApp(app: MarketplaceApp) {
await rpcClient.call({
method: 'package.install',
params: installParams,
timeout: 15000,
timeout: 600000,
})
} catch (err) {
if (import.meta.env.DEV) console.error('[Marketplace] Installation failed:', err)
+2 -2
View File
@@ -616,7 +616,7 @@ async function installApp() {
await rpcClient.call({
method: 'package.install',
params: installParams,
timeout: 15000,
timeout: 600000,
})
} else {
// Package-based installation
@@ -628,7 +628,7 @@ async function installApp() {
url: installUrl,
version: app.value.version,
},
timeout: 15000,
timeout: 600000,
})
}
+26 -10
View File
@@ -41,23 +41,23 @@
<div class="grid grid-cols-2 lg:grid-cols-4 gap-4 mb-6">
<div class="monitoring-stat-card">
<p class="text-xs text-white/50 uppercase tracking-wide">{{ t('monitoring.cpu') }}</p>
<p class="text-2xl font-bold text-white">{{ current?.system.cpu_percent.toFixed(1) ?? '--' }}%</p>
<p class="text-xs text-white/40">{{ t('monitoring.load') }} {{ current?.system.load_avg_1.toFixed(2) ?? '--' }}</p>
<p class="text-2xl font-bold text-white">{{ liveSystem.cpu_percent.toFixed(1) }}%</p>
<p class="text-xs text-white/40">{{ t('monitoring.load') }} {{ liveSystem.load_avg_1.toFixed(2) }}</p>
</div>
<div class="monitoring-stat-card">
<p class="text-xs text-white/50 uppercase tracking-wide">{{ t('monitoring.memory') }}</p>
<p class="text-2xl font-bold text-white">{{ memPercent }}%</p>
<p class="text-xs text-white/40">{{ formatBytes(current?.system.mem_used_bytes ?? 0) }} / {{ formatBytes(current?.system.mem_total_bytes ?? 0) }}</p>
<p class="text-xs text-white/40">{{ formatBytes(liveSystem.mem_used_bytes) }} / {{ formatBytes(liveSystem.mem_total_bytes) }}</p>
</div>
<div class="monitoring-stat-card">
<p class="text-xs text-white/50 uppercase tracking-wide">{{ t('monitoring.diskUsage') }}</p>
<p class="text-2xl font-bold text-white">{{ diskPercent }}%</p>
<p class="text-xs text-white/40">{{ formatBytes(current?.system.disk_used_bytes ?? 0) }} / {{ formatBytes(current?.system.disk_total_bytes ?? 0) }}</p>
<p class="text-xs text-white/40">{{ formatBytes(liveSystem.disk_used_bytes) }} / {{ formatBytes(liveSystem.disk_total_bytes) }}</p>
</div>
<div class="monitoring-stat-card">
<p class="text-xs text-white/50 uppercase tracking-wide">{{ t('monitoring.network') }}</p>
<p class="text-2xl font-bold text-white">{{ formatBytes(current?.system.net_rx_bytes ?? 0) }}</p>
<p class="text-xs text-white/40">TX: {{ formatBytes(current?.system.net_tx_bytes ?? 0) }}</p>
<p class="text-2xl font-bold text-white">{{ formatBytes(liveSystem.net_rx_bytes) }}</p>
<p class="text-xs text-white/40">TX: {{ formatBytes(liveSystem.net_tx_bytes) }}</p>
</div>
</div>
@@ -236,6 +236,7 @@ import { ref, computed, onMounted, onUnmounted } from 'vue'
import { useRouter } from 'vue-router'
import { useI18n } from 'vue-i18n'
import { rpcClient } from '@/api/rpc-client'
import { useHomeStatusStore } from '@/stores/homeStatus'
import LineChart from '@/components/LineChart.vue'
import ToggleSwitch from '@/components/ToggleSwitch.vue'
import type { ChartDataset } from '@/components/LineChart.vue'
@@ -297,6 +298,7 @@ interface FiredAlert {
const router = useRouter()
const { t } = useI18n()
const homeStatus = useHomeStatusStore()
const current = ref<MetricSnapshot | null>(null)
const history = ref<MetricSnapshot[]>([])
@@ -307,14 +309,27 @@ const showAlertConfig = ref(false)
const chartWidth = ref(380)
let pollTimer: ReturnType<typeof setInterval> | null = null
const liveSystem = computed<SystemMetrics>(() => ({
cpu_percent: homeStatus.stats.cpuPercent,
mem_used_bytes: homeStatus.stats.memUsed,
mem_total_bytes: homeStatus.stats.memTotal,
disk_used_bytes: homeStatus.stats.diskUsed,
disk_total_bytes: homeStatus.stats.diskTotal,
net_rx_bytes: current.value?.system.net_rx_bytes ?? 0,
net_tx_bytes: current.value?.system.net_tx_bytes ?? 0,
load_avg_1: homeStatus.stats.loadAvg1,
load_avg_5: homeStatus.stats.loadAvg5,
load_avg_15: homeStatus.stats.loadAvg15,
}))
const memPercent = computed(() => {
if (!current.value?.system.mem_total_bytes) return '--'
return ((current.value.system.mem_used_bytes / current.value.system.mem_total_bytes) * 100).toFixed(1)
if (!liveSystem.value.mem_total_bytes) return '--'
return ((liveSystem.value.mem_used_bytes / liveSystem.value.mem_total_bytes) * 100).toFixed(1)
})
const diskPercent = computed(() => {
if (!current.value?.system.disk_total_bytes) return '--'
return ((current.value.system.disk_used_bytes / current.value.system.disk_total_bytes) * 100).toFixed(1)
if (!liveSystem.value.disk_total_bytes) return '--'
return ((liveSystem.value.disk_used_bytes / liveSystem.value.disk_total_bytes) * 100).toFixed(1)
})
const historyMinutesAgo = computed(() => history.value.length || 60)
@@ -460,6 +475,7 @@ async function exportMetrics(format: 'csv' | 'json') {
async function fetchCurrent() {
try {
await homeStatus.refreshSystemStats()
const data = await rpcClient.call<MetricSnapshot | { status: string }>({
method: 'monitoring.current',
})
+88 -86
View File
@@ -344,94 +344,96 @@
</Transition>
</Teleport>
<!-- Add-mirror modal -->
<Transition name="fade">
<div v-if="addingMirror" class="fixed inset-0 z-50 flex items-center justify-center bg-black/10 backdrop-blur-md" @click.self="cancelAddMirror">
<div class="glass-card p-6 max-w-md w-full mx-4">
<h3 class="text-lg font-semibold text-white mb-1">Add update mirror</h3>
<p class="text-sm text-white/60 mb-5">
The URL should point directly at a <span class="font-mono text-white/80">manifest.json</span> served by a Gitea mirror or equivalent. It's added to the end of the list; use "Make primary" to change order.
</p>
<form class="space-y-3" @submit.prevent="submitMirror">
<div>
<label class="block text-xs text-white/60 mb-1">Manifest URL</label>
<input
v-model="mirrorDraft.url"
type="text"
autofocus
placeholder="https://host/.../manifest.json"
class="w-full px-3 py-2 rounded-md bg-white/5 border border-white/10 text-sm text-white focus:border-white/30 focus:outline-none font-mono"
/>
</div>
<div>
<label class="block text-xs text-white/60 mb-1">Label (optional)</label>
<input
v-model="mirrorDraft.label"
type="text"
placeholder="Home VPS"
class="w-full px-3 py-2 rounded-md bg-white/5 border border-white/10 text-sm text-white focus:border-white/30 focus:outline-none"
/>
</div>
<div class="flex gap-3 justify-end pt-2">
<button
type="button"
@click="cancelAddMirror"
class="glass-button rounded-lg px-4 py-2 text-sm font-medium"
>{{ t('common.cancel') }}</button>
<button
type="submit"
class="glass-button rounded-lg px-4 py-2 text-sm font-medium bg-orange-500/20 border-orange-400/30 disabled:opacity-50 disabled:cursor-not-allowed"
:disabled="mirrorSaving || !mirrorDraft.url.trim()"
>{{ mirrorSaving ? 'Adding…' : 'Add mirror' }}</button>
</div>
</form>
</div>
</div>
</Transition>
<!-- Confirmation modal -->
<Transition name="fade">
<div v-if="confirmAction" class="fixed inset-0 z-50 flex items-center justify-center bg-black/10 backdrop-blur-md" @click.self="cancelConfirm">
<div class="glass-card p-6 max-w-sm w-full mx-4">
<h3 class="text-lg font-semibold text-white mb-3">
{{ confirmAction === 'rollback'
? t('systemUpdate.rollbackTitle')
: confirmAction === 'git-apply'
? t('systemUpdate.gitApplyTitle')
: confirmAction === 'cancel-download'
? t('systemUpdate.cancelDownloadTitle')
: t('systemUpdate.applyTitle') }}
</h3>
<p class="text-sm text-white/70 mb-6">
{{ confirmAction === 'rollback'
? t('systemUpdate.rollbackMessage')
: confirmAction === 'git-apply'
? t('systemUpdate.gitApplyMessage')
: confirmAction === 'cancel-download'
? t('systemUpdate.cancelDownloadConfirm')
: t('systemUpdate.applyMessage') }}
</p>
<div class="flex gap-3 justify-end">
<button @click="cancelConfirm" class="glass-button rounded-lg px-4 py-2 text-sm font-medium">
{{ t('common.cancel') }}
</button>
<button
@click="executeConfirm"
class="glass-button rounded-lg px-4 py-2 text-sm font-medium"
:class="(confirmAction === 'rollback' || confirmAction === 'cancel-download') ? 'bg-red-500/20 border-red-400/30' : 'bg-orange-500/20 border-orange-400/30'"
>
{{ confirmAction === 'rollback'
? t('systemUpdate.rollbackButton')
: confirmAction === 'git-apply'
? t('systemUpdate.pullAndRebuild')
: confirmAction === 'cancel-download'
? t('systemUpdate.cancelDownloadButton')
: t('systemUpdate.applyNow') }}
</button>
<Teleport to="body">
<!-- Add-mirror modal -->
<Transition name="fade">
<div v-if="addingMirror" class="fixed inset-0 z-[3000] flex items-center justify-center p-4 bg-black/60 backdrop-blur-md" @click.self="cancelAddMirror">
<div class="glass-card p-6 max-w-md w-full">
<h3 class="text-lg font-semibold text-white mb-1">Add update mirror</h3>
<p class="text-sm text-white/60 mb-5">
The URL should point directly at a <span class="font-mono text-white/80">manifest.json</span> served by a Gitea mirror or equivalent. It's added to the end of the list; use "Make primary" to change order.
</p>
<form class="space-y-3" @submit.prevent="submitMirror">
<div>
<label class="block text-xs text-white/60 mb-1">Manifest URL</label>
<input
v-model="mirrorDraft.url"
type="text"
autofocus
placeholder="https://host/.../manifest.json"
class="w-full px-3 py-2 rounded-md bg-white/5 border border-white/10 text-sm text-white focus:border-white/30 focus:outline-none font-mono"
/>
</div>
<div>
<label class="block text-xs text-white/60 mb-1">Label (optional)</label>
<input
v-model="mirrorDraft.label"
type="text"
placeholder="Home VPS"
class="w-full px-3 py-2 rounded-md bg-white/5 border border-white/10 text-sm text-white focus:border-white/30 focus:outline-none"
/>
</div>
<div class="flex gap-3 justify-end pt-2">
<button
type="button"
@click="cancelAddMirror"
class="glass-button rounded-lg px-4 py-2 text-sm font-medium"
>{{ t('common.cancel') }}</button>
<button
type="submit"
class="glass-button rounded-lg px-4 py-2 text-sm font-medium bg-orange-500/20 border-orange-400/30 disabled:opacity-50 disabled:cursor-not-allowed"
:disabled="mirrorSaving || !mirrorDraft.url.trim()"
>{{ mirrorSaving ? 'Adding…' : 'Add mirror' }}</button>
</div>
</form>
</div>
</div>
</div>
</Transition>
</Transition>
<!-- Confirmation modal -->
<Transition name="fade">
<div v-if="confirmAction" class="fixed inset-0 z-[3000] flex items-center justify-center p-4 bg-black/60 backdrop-blur-md" @click.self="cancelConfirm">
<div class="glass-card p-6 max-w-sm w-full">
<h3 class="text-lg font-semibold text-white mb-3">
{{ confirmAction === 'rollback'
? t('systemUpdate.rollbackTitle')
: confirmAction === 'git-apply'
? t('systemUpdate.gitApplyTitle')
: confirmAction === 'cancel-download'
? t('systemUpdate.cancelDownloadTitle')
: t('systemUpdate.applyTitle') }}
</h3>
<p class="text-sm text-white/70 mb-6">
{{ confirmAction === 'rollback'
? t('systemUpdate.rollbackMessage')
: confirmAction === 'git-apply'
? t('systemUpdate.gitApplyMessage')
: confirmAction === 'cancel-download'
? t('systemUpdate.cancelDownloadConfirm')
: t('systemUpdate.applyMessage') }}
</p>
<div class="flex gap-3 justify-end">
<button @click="cancelConfirm" class="glass-button rounded-lg px-4 py-2 text-sm font-medium">
{{ t('common.cancel') }}
</button>
<button
@click="executeConfirm"
class="glass-button rounded-lg px-4 py-2 text-sm font-medium"
:class="(confirmAction === 'rollback' || confirmAction === 'cancel-download') ? 'bg-red-500/20 border-red-400/30' : 'bg-orange-500/20 border-orange-400/30'"
>
{{ confirmAction === 'rollback'
? t('systemUpdate.rollbackButton')
: confirmAction === 'git-apply'
? t('systemUpdate.pullAndRebuild')
: confirmAction === 'cancel-download'
? t('systemUpdate.cancelDownloadButton')
: t('systemUpdate.applyNow') }}
</button>
</div>
</div>
</div>
</Transition>
</Teleport>
</div>
</template>
@@ -5,7 +5,7 @@
<img
:src="icon"
:alt="pkg.manifest.title"
class="w-20 h-20 rounded-xl shadow-xl flex-shrink-0"
class="app-detail-icon w-20 h-20 shadow-xl flex-shrink-0"
@error="handleImageError"
/>
@@ -119,7 +119,7 @@
<img
:src="icon"
:alt="pkg.manifest.title"
class="w-20 h-20 rounded-xl shadow-xl flex-shrink-0"
class="app-detail-icon w-20 h-20 shadow-xl flex-shrink-0"
@error="handleImageError"
/>
@@ -86,6 +86,22 @@
</div>
</div>
<div v-if="credentials?.credentials?.length" class="glass-card p-6">
<h3 class="text-lg font-bold text-white mb-2">Credentials</h3>
<p v-if="credentials.description" class="text-sm text-white/60 mb-4">{{ credentials.description }}</p>
<div class="space-y-3">
<div v-for="cred in credentials.credentials" :key="cred.label" class="rounded-lg border border-white/10 bg-white/[0.04] p-3">
<div class="flex items-center justify-between gap-3 mb-1">
<span class="text-white/60 text-xs uppercase tracking-wide">{{ cred.label }}</span>
<button type="button" class="text-xs text-blue-300 hover:text-blue-200" @click="copyCredential(cred.label, cred.value)">
{{ copiedCredential === cred.label ? 'Copied' : 'Copy' }}
</button>
</div>
<p class="font-mono text-sm text-white break-all">{{ cred.value }}</p>
</div>
</div>
</div>
<!-- Requirements Card (hidden for web-only apps) -->
<div v-if="!isWebOnly" class="glass-card p-6">
<h3 class="text-lg font-bold text-white mb-4">{{ t('appDetails.requirements') }}</h3>
@@ -151,9 +167,29 @@
</template>
<script setup lang="ts">
import { ref } from 'vue'
import { useI18n } from 'vue-i18n'
import type { AppCredentialsResponse } from '@/types/api'
const { t } = useI18n()
const copiedCredential = ref('')
async function copyCredential(label: string, value: string) {
try {
await navigator.clipboard.writeText(value)
} catch {
const textarea = document.createElement('textarea')
textarea.value = value
document.body.appendChild(textarea)
textarea.select()
document.execCommand('copy')
document.body.removeChild(textarea)
}
copiedCredential.value = label
setTimeout(() => {
if (copiedCredential.value === label) copiedCredential.value = ''
}, 1800)
}
defineProps<{
pkg: Record<string, any>
@@ -164,5 +200,6 @@ defineProps<{
lanUrl: string
torUrl: string
showTorAddress: boolean
credentials: AppCredentialsResponse | null
}>()
</script>
@@ -44,6 +44,7 @@ export const ROUTE_TO_PACKAGE_KEY: Record<string, string> = {
portainer: 'portainer',
'uptime-kuma': 'uptime-kuma',
tailscale: 'tailscale',
netbird: 'netbird',
}
/** Backend may register under variant container names */
@@ -83,7 +84,7 @@ export const APP_URLS: Record<string, { dev: string; prod: string }> = {
'photoprism': { dev: 'http://localhost:2342', prod: 'http://localhost:2342' },
'immich': { dev: 'http://localhost:2283', prod: 'http://localhost:2283' },
'filebrowser': { dev: 'http://localhost:8083', prod: 'http://localhost:8083' },
'nginx-proxy-manager': { dev: 'http://localhost:81', prod: 'http://localhost:81' },
'nginx-proxy-manager': { dev: 'http://localhost:8081', prod: 'http://localhost:8081' },
'gitea': { dev: 'http://localhost:3001', prod: 'http://localhost:3001' },
'portainer': { dev: 'http://localhost:9000', prod: 'http://localhost:9000' },
'uptime-kuma': { dev: 'http://localhost:3002', prod: 'http://localhost:3002' },
@@ -9,16 +9,24 @@
</div>
</Transition>
<iframe
<div
v-if="appUrl && !iframeBlocked"
ref="iframeRef"
:key="refreshKey"
:src="appUrl"
class="absolute inset-0 w-full h-full border-0 iframe-scrollbar-hide"
title="App content"
@load="$emit('iframeLoad')"
@error="$emit('iframeError')"
/>
class="absolute inset-0 app-session-frame-scroll-host"
tabindex="-1"
@pointerdown="focusIframe"
@focusin="focusIframe"
>
<iframe
ref="iframeRef"
:key="refreshKey"
:src="appUrl"
class="w-full h-full border-0 iframe-scrollbar-hide"
title="App content"
tabindex="0"
@load="handleIframeLoad"
@error="$emit('iframeError')"
/>
</div>
<!-- Iframe blocked fallback -->
<Transition name="content-fade">
@@ -69,9 +77,9 @@
</template>
<script setup lang="ts">
import { ref } from 'vue'
import { nextTick, ref, watch } from 'vue'
defineProps<{
const props = defineProps<{
appUrl: string
appId: string
appTitle: string
@@ -82,7 +90,7 @@ defineProps<{
refreshKey: number
}>()
defineEmits<{
const emit = defineEmits<{
iframeLoad: []
iframeError: []
refresh: []
@@ -91,5 +99,21 @@ defineEmits<{
const iframeRef = ref<HTMLIFrameElement | null>(null)
function focusIframe() {
iframeRef.value?.focus({ preventScroll: true })
}
async function handleIframeLoad() {
emit('iframeLoad')
await nextTick()
requestAnimationFrame(focusIframe)
}
watch(() => [props.appUrl, props.refreshKey, props.iframeBlocked], async () => {
if (!props.appUrl || props.iframeBlocked) return
await nextTick()
requestAnimationFrame(focusIframe)
}, { immediate: true })
defineExpose({ iframeRef })
</script>
@@ -17,5 +17,16 @@ describe('appSessionConfig', () => {
expect(resolveAppUrl('mempool')).toBe('http://192.168.1.228:4080')
expect(resolveAppUrl('indeedhub')).toBe('http://192.168.1.228:7778')
expect(resolveAppUrl('saleor')).toBe('http://192.168.1.228:9011')
})
it('keeps NetBird on the unified dashboard proxy port', () => {
Object.defineProperty(window, 'location', {
value: { hostname: '192.168.1.228' },
writable: true,
configurable: true,
})
expect(resolveAppUrl('netbird', undefined, 'http://localhost:8086')).toBe('http://192.168.1.228:8087')
})
})
@@ -14,6 +14,7 @@ export const APP_PORTS: Record<string, number> = {
'archy-electrs-ui': 50002,
'mempool-electrs': 50002,
'btcpay-server': 23000,
'saleor': 9011,
'lnd': 18083,
'archy-lnd-ui': 18083,
'mempool': 4080,
@@ -31,9 +32,10 @@ export const APP_PORTS: Record<string, number> = {
'immich': 2283,
'immich_server': 2283,
'filebrowser': 8083,
'nginx-proxy-manager': 81,
'nginx-proxy-manager': 8081,
'gitea': 3001,
'portainer': 9000,
'netbird': 8087,
'tailscale': 8240,
'uptime-kuma': 3002,
'fedimint': 8175,
@@ -62,6 +64,7 @@ export const EXTERNAL_URLS: Record<string, string> = {
'nwnn': 'https://nwnn.l484.com',
'484-kitchen': 'https://484.kitchen',
'call-the-operator': 'https://cta.tx1138.com',
'arch-presentation': 'https://present.l484.com',
'syntropy-institute': 'https://syntropy.institute',
't-zero': 'https://teeminuszero.net',
'nostrudel': 'https://nostrudel.ninja',
@@ -69,7 +72,7 @@ export const EXTERNAL_URLS: Record<string, string> = {
export const APP_TITLES: Record<string, string> = {
'bitcoin-knots': 'Bitcoin Knots', 'bitcoin-core': 'Bitcoin Core',
'btcpay-server': 'BTCPay Server', 'indeedhub': 'Indeehub',
'btcpay-server': 'BTCPay Server', 'saleor': 'Saleor', 'indeedhub': 'Indeehub',
'botfights': 'BotFights', 'gitea': 'Gitea', '484-kitchen': '484 Kitchen', 'arch-presentation': 'Presentation',
'homeassistant': 'Home Assistant', 'uptime-kuma': 'Uptime Kuma',
'nginx-proxy-manager': 'Nginx Proxy Manager',
@@ -97,7 +100,7 @@ export const NEW_TAB_APPS = new Set([
export const IFRAME_BLOCKED_APPS = new Set<string>([])
/** Resolve app URL using direct port mapping (source of truth) */
export function resolveAppUrl(id: string, routeQueryPath?: string): string {
export function resolveAppUrl(id: string, routeQueryPath?: string, _runtimeUrl?: string): string {
// External HTTPS apps
const ext = EXTERNAL_URLS[id]
if (ext) return ext
+3 -3
View File
@@ -31,7 +31,7 @@
<img
:src="icon"
:alt="title"
class="w-14 h-14 rounded-lg object-cover bg-white/10"
class="app-card-icon w-14 h-14 object-cover bg-white/10"
@error="handleImageError"
/>
<div class="flex-1 min-w-0 overflow-hidden">
@@ -145,7 +145,7 @@
class="flex-1 px-4 py-2 glass-button glass-button-sm rounded-lg text-sm font-medium flex items-center justify-center gap-1.5"
>
{{ t('common.launch') }}
<svg v-if="opensInTab(id)" class="w-3.5 h-3.5 opacity-60" 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>
<svg v-if="opensInTab(id)" class="hidden md:block w-3.5 h-3.5 opacity-60" 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>
</button>
<!-- Start (play icon) -->
<button
@@ -266,7 +266,7 @@ const tier = computed(() => {
const t = props.pkg.manifest?.tier
if (t && t !== '') return t
const core = ['bitcoin-knots', 'bitcoin', 'lnd', 'mempool', 'btcpay-server', 'dwn', 'filebrowser']
const recommended = ['fedimint', 'thunderhub', 'vaultwarden', 'uptime-kuma', 'grafana', 'searxng', 'tailscale', 'portainer']
const recommended = ['fedimint', 'thunderhub', 'vaultwarden', 'uptime-kuma', 'grafana', 'searxng', 'tailscale', 'netbird', 'portainer']
if (core.includes(props.id)) return 'core'
if (recommended.includes(props.id)) return 'recommended'
return 'optional'
+145 -11
View File
@@ -70,6 +70,33 @@
@click="scrollToPage(i)"
></button>
</div>
<Transition name="fade">
<div v-if="credentialModal.show" class="fixed inset-0 z-[2700] flex items-end justify-center bg-black/60 backdrop-blur-md p-0 md:items-center md:p-6" @click.self="closeCredentialModal">
<div class="sideload-modal credential-modal">
<div class="flex items-start justify-between gap-4 mb-5">
<div>
<h2 class="text-lg font-semibold text-white">{{ credentialModal.title }}</h2>
<p class="text-sm text-white/55 mt-1">{{ credentialModal.description }}</p>
</div>
<button type="button" class="sideload-close-btn" aria-label="Close" @click="closeCredentialModal">&times;</button>
</div>
<div class="credential-modal-body space-y-3">
<div v-for="cred in credentialModal.credentials" :key="cred.label" class="rounded-lg border border-white/10 bg-white/[0.04] p-3">
<div class="flex items-center justify-between gap-3 mb-1">
<span class="text-white/60 text-xs uppercase tracking-wide">{{ cred.label }}</span>
<button type="button" class="text-xs text-blue-300 hover:text-blue-200" @click="copyModalCredential(cred.label, cred.value)">{{ credentialModal.copied === cred.label ? 'Copied' : 'Copy' }}</button>
</div>
<p class="font-mono text-sm text-white break-all">{{ cred.value }}</p>
</div>
</div>
<div class="credential-modal-actions mt-5 flex flex-col sm:flex-row gap-3">
<button type="button" class="w-full sm:flex-1 glass-button px-4 py-3 rounded-lg" @click="closeCredentialModal">Cancel</button>
<button type="button" class="w-full sm:flex-1 glass-button px-4 py-3 rounded-lg font-semibold" @click="continueCredentialLaunch">Continue to app</button>
</div>
</div>
</div>
</Transition>
</div>
</template>
@@ -77,9 +104,10 @@
import { computed, ref } from 'vue'
import { useServerStore } from '@/stores/server'
import { useAppLauncherStore } from '@/stores/appLauncher'
import type { PackageDataEntry } from '@/types/api'
import type { AppCredential, AppCredentialsResponse, PackageDataEntry } from '@/types/api'
import { rpcClient } from '@/api/rpc-client'
import { resolveAppUrl } from '@/views/appSession/appSessionConfig'
import { canLaunch, handleImageError, opensInTab, resolveAppIcon } from './appsConfig'
import { canLaunch, handleImageError, isWebsitePackage, opensInTab, resolveAppIcon, resolveRuntimeLaunchUrl, WEB_ONLY_APP_URLS } from './appsConfig'
import { getCuratedAppList } from '../discover/curatedApps'
const ITEMS_PER_PAGE = 16 // 4 columns x 4 rows
@@ -88,6 +116,14 @@ const serverStore = useServerStore()
const appLauncher = useAppLauncherStore()
const curatedMap = new Map(getCuratedAppList().map(a => [a.id, a]))
const credentialModal = ref({
show: false,
appId: '',
title: '',
description: '',
credentials: [] as AppCredential[],
copied: '',
})
const props = defineProps<{
apps: [string, PackageDataEntry][]
@@ -118,21 +154,79 @@ function getIcon(id: string, pkg: PackageDataEntry): string {
return resolveAppIcon(id, pkg, curatedMap.get(id)?.icon)
}
function handleTap(id: string, pkg: PackageDataEntry) {
async function handleTap(id: string, pkg: PackageDataEntry) {
if (canLaunch(pkg)) {
if (opensInTab(id)) {
const appUrl = resolveAppUrl(id)
if (appUrl) {
window.open(appUrl, '_blank', 'noopener,noreferrer')
return
}
}
appLauncher.openSession(id)
const shown = await maybeShowCredentialsBeforeLaunch(id, pkg)
if (shown) return
launchNow(id, pkg)
} else {
emit('goToApp', id)
}
}
function launchNow(id: string, pkg: PackageDataEntry) {
const isMobile = typeof window !== 'undefined' && window.innerWidth < 768
const webOnlyUrl = WEB_ONLY_APP_URLS[id]
if (webOnlyUrl) {
appLauncher.open({ url: webOnlyUrl, title: getTitle(id, pkg), openInNewTab: !isMobile })
return
}
if (isWebsitePackage(id, pkg)) {
const url = resolveRuntimeLaunchUrl(pkg)
if (url) {
appLauncher.open({ url, title: getTitle(id, pkg), openInNewTab: !isMobile })
return
}
}
if (!isMobile && opensInTab(id)) {
const appUrl = resolveRuntimeLaunchUrl(pkg) || resolveAppUrl(id)
if (appUrl) {
window.open(appUrl, '_blank', 'noopener,noreferrer')
return
}
}
appLauncher.openSession(id)
}
async function maybeShowCredentialsBeforeLaunch(id: string, pkg: PackageDataEntry): Promise<boolean> {
try {
const result = await rpcClient.call<AppCredentialsResponse>({ method: 'package.credentials', params: { app_id: id }, timeout: 5000 })
if (!result.credentials?.length) return false
credentialModal.value = {
show: true,
appId: id,
title: result.title || `${getTitle(id, pkg)} credentials`,
description: result.description || 'Use these credentials when the app asks you to sign in.',
credentials: result.credentials,
copied: '',
}
return true
} catch {
return false
}
}
function closeCredentialModal() { credentialModal.value.show = false }
function continueCredentialLaunch() {
const id = credentialModal.value.appId
const entry = props.apps.find(([appId]) => appId === id)
closeCredentialModal()
if (entry) launchNow(entry[0], entry[1])
}
async function copyModalCredential(label: string, value: string) {
try { await navigator.clipboard.writeText(value) } catch {
const textarea = document.createElement('textarea')
textarea.value = value
document.body.appendChild(textarea)
textarea.select()
document.execCommand('copy')
document.body.removeChild(textarea)
}
credentialModal.value.copied = label
}
function onScroll() {
const el = scrollContainer.value
if (!el) return
@@ -147,3 +241,43 @@ function scrollToPage(index: number) {
el.scrollTo({ left: index * el.clientWidth, behavior: 'smooth' })
}
</script>
<style scoped>
.sideload-modal {
display: flex;
flex-direction: column;
width: 100%;
max-width: 34rem;
max-height: calc(100dvh - var(--safe-area-top, env(safe-area-inset-top, 0px)) - 12px);
overflow: hidden;
border: 1px solid rgba(255, 255, 255, 0.14);
border-radius: 1.5rem 1.5rem 0 0;
background: rgba(8, 10, 18, 0.94);
padding: 1.25rem;
padding-bottom: calc(1.25rem + var(--safe-area-bottom, env(safe-area-inset-bottom, 0px)));
box-shadow: 0 -24px 70px rgba(0, 0, 0, 0.55);
}
.sideload-close-btn {
width: 2.25rem;
height: 2.25rem;
flex-shrink: 0;
border-radius: 0.75rem;
color: rgba(255, 255, 255, 0.55);
background: rgba(255, 255, 255, 0.06);
}
.credential-modal-body {
min-height: 0;
overflow-y: auto;
-webkit-overflow-scrolling: touch;
}
.credential-modal-actions {
flex-shrink: 0;
}
@media (min-width: 768px) {
.sideload-modal {
border-radius: 1.25rem;
padding: 1.5rem;
box-shadow: 0 25px 80px rgba(0, 0, 0, 0.55);
}
}
</style>
@@ -35,6 +35,11 @@ describe('AppIconGrid', () => {
setActivePinia(createPinia())
vi.clearAllMocks()
localStorage.clear()
Object.defineProperty(window, 'innerWidth', {
value: 1024,
writable: true,
configurable: true,
})
Object.defineProperty(window, 'location', {
value: { hostname: '192.168.1.198' },
writable: true,
@@ -55,4 +60,24 @@ describe('AppIconGrid', () => {
expect(mockWindowOpen).not.toHaveBeenCalled()
expect(useAppLauncherStore().panelAppId).toBe('lnd')
})
it('routes desktop new-tab apps through app session on mobile', async () => {
Object.defineProperty(window, 'innerWidth', {
value: 390,
writable: true,
configurable: true,
})
const wrapper = mount(AppIconGrid, {
props: { apps: [['gitea', makePkg('gitea')]] },
global: {
plugins: [createPinia()],
},
})
await wrapper.get('.app-icon-item').trigger('click')
expect(mockWindowOpen).not.toHaveBeenCalled()
expect(useAppLauncherStore().panelAppId).toBeNull()
})
})
+50 -8
View File
@@ -5,6 +5,8 @@ import { computed } from 'vue'
import { PackageState, type PackageDataEntry } from '@/types/api'
import { resolveAppUrl } from '../appSession/appSessionConfig'
export type AppsTab = 'apps' | 'websites' | 'services'
// Service container name patterns (backend/infra, not user-facing)
export const SERVICE_NAMES = new Set([
'dwn', 'archy-mempool-db', 'archy-btcpay-db', 'archy-nbxplorer', 'archy-tor',
@@ -22,6 +24,15 @@ export const SERVICE_NAMES = new Set([
'syntropy-institute', 't-zero', 'arch-presentation',
])
const INTERNAL_TOOLING_NAMES = new Set([
'buildx_buildkit_default',
])
export function isInternalToolingPackage(id: string, pkg?: PackageDataEntry): boolean {
const manifestId = pkg?.manifest?.id || ''
return INTERNAL_TOOLING_NAMES.has(id) || INTERNAL_TOOLING_NAMES.has(manifestId) || id.startsWith('buildx_buildkit') || manifestId.startsWith('buildx_buildkit')
}
export function isServiceContainer(id: string): boolean {
if (SERVICE_NAMES.has(id)) return true
if (id.startsWith('indeedhub-build_')) return true
@@ -39,14 +50,14 @@ export function isServicePackage(id: string, pkg?: PackageDataEntry): boolean {
// Known app -> category mappings (matches App Store categorisation)
export const APP_CATEGORY_MAP: Record<string, string> = {
'bitcoin-knots': 'money', 'bitcoin-ui': 'money', 'electrumx': 'money', 'electrs': 'money',
'lnd': 'money', 'mempool': 'money', 'mempool-web': 'money', 'btcpay-server': 'commerce',
'lnd': 'money', 'mempool': 'money', 'mempool-web': 'money', 'btcpay-server': 'commerce', 'saleor': 'commerce',
'fedimint': 'money', 'fedimint-gateway': 'money',
'indeedhub': 'media', 'jellyfin': 'media', 'photoprism': 'media', 'immich': 'media',
'nextcloud': 'data', 'vaultwarden': 'data', 'filebrowser': 'data', 'cryptpad': 'data',
'homeassistant': 'home', 'lorabell': 'home', 'endurain': 'home',
'searxng': 'community', 'ollama': 'community', 'grafana': 'data',
'searxng': 'community', 'ollama': 'community', 'grafana': 'data', 'gitea': 'data',
'nostrudel': 'nostr',
'tailscale': 'networking', 'nginx-proxy-manager': 'networking', 'portainer': 'networking',
'tailscale': 'networking', 'netbird': 'networking', 'nginx-proxy-manager': 'networking', 'portainer': 'networking',
'uptime-kuma': 'networking', 'dwn': 'data',
'botfights': 'community', 'nwnn': 'l484', '484-kitchen': 'l484',
'call-the-operator': 'l484', 'syntropy-institute': 'l484', 't-zero': 'l484',
@@ -58,14 +69,32 @@ export function getAppCategory(id: string, pkg: PackageDataEntry): string {
return cat || 'other'
}
export function runtimeLanAddress(pkg: PackageDataEntry): string {
return pkg.installed?.['interface-addresses']?.main?.['lan-address'] || ''
}
export function isKnownApp(id: string, pkg?: PackageDataEntry): boolean {
const manifestId = pkg?.manifest?.id
return !!(APP_CATEGORY_MAP[id] || (manifestId && APP_CATEGORY_MAP[manifestId]) || isWebOnlyApp(id))
}
export function isWebsitePackage(id: string, pkg?: PackageDataEntry): boolean {
if (isInternalToolingPackage(id, pkg)) return false
if (isServicePackage(id, pkg)) return true
if (isKnownApp(id, pkg)) return false
return !!pkg && !!runtimeLanAddress(pkg)
}
export function filterEntriesForTab(
entries: Array<[string, PackageDataEntry]>,
activeTab: 'apps' | 'services',
activeTab: AppsTab,
selectedCategory: string,
): Array<[string, PackageDataEntry]> {
return entries.filter(([id, pkg]) => {
const isSvc = isServicePackage(id, pkg)
if (activeTab === 'services' ? !isSvc : isSvc) return false
if (isInternalToolingPackage(id, pkg)) return false
const wantsWebsites = activeTab === 'websites' || activeTab === 'services'
const isWebsite = isWebsitePackage(id, pkg)
if (wantsWebsites ? !isWebsite : isWebsite) return false
if (activeTab === 'apps' && selectedCategory !== 'all') {
return getAppCategory(id, pkg) === selectedCategory
}
@@ -78,6 +107,7 @@ export const WEB_ONLY_APP_URLS: Record<string, string> = {
'nwnn': 'https://nwnn.l484.com',
'484-kitchen': 'https://484.kitchen',
'call-the-operator': 'https://cta.tx1138.com',
'arch-presentation': 'https://present.l484.com',
'syntropy-institute': 'https://syntropy.institute',
't-zero': 'https://teeminuszero.net',
}
@@ -103,6 +133,11 @@ export const WEB_ONLY_APPS: Record<string, PackageDataEntry> = {
manifest: { id: 'call-the-operator', title: 'Call the Operator', version: '1.0.0', description: { short: 'Escape the Matrix — explore decentralized alternatives', long: '' }, 'release-notes': '', license: '', 'wrapper-repo': '', 'upstream-repo': '', 'support-site': '', 'marketing-site': '', 'donation-url': null },
'static-files': { license: '', instructions: '', icon: '/assets/img/app-icons/call-the-operator.png' },
},
'arch-presentation': {
state: 'running' as PackageState,
manifest: { id: 'arch-presentation', title: 'Arch Presentation', version: '1.0.0', description: { short: 'Archipelago: The Future of Decentralized Infrastructure', long: '' }, 'release-notes': '', license: '', 'wrapper-repo': '', 'upstream-repo': '', 'support-site': '', 'marketing-site': '', 'donation-url': null },
'static-files': { license: '', instructions: '', icon: '/assets/img/app-icons/arch-presentation.png' },
},
'syntropy-institute': {
state: 'running' as PackageState,
manifest: { id: 'syntropy-institute', title: 'Syntropy Institute', version: '1.0.0', description: { short: 'Medicine Reimagined — frequency analysis-therapy', long: '' }, 'release-notes': '', license: '', 'wrapper-repo': '', 'upstream-repo': '', 'support-site': '', 'marketing-site': '', 'donation-url': null },
@@ -131,7 +166,8 @@ const APP_ICON_FALLBACKS: Record<string, string> = {
}
export function resolveAppIcon(id: string, pkg: PackageDataEntry, curatedIcon?: string): string {
const icon = (pkg["static-files"]?.icon || "").trim()
const rawIcon = (pkg["static-files"]?.icon || "").trim()
const icon = rawIcon === '/assets/img/favico.png' ? '' : rawIcon
if (
icon.startsWith("/") ||
icon.startsWith("http://") ||
@@ -151,6 +187,12 @@ export function canLaunch(pkg: PackageDataEntry): boolean {
return !!hasUI && pkg.state === 'running' && pkg.health !== 'starting' && pkg.health !== 'unhealthy'
}
export function resolveRuntimeLaunchUrl(pkg: PackageDataEntry): string {
const addr = runtimeLanAddress(pkg)
if (!addr || typeof window === 'undefined') return addr
return addr.replace(/^http:\/\/(localhost|127\.0\.0\.1)(?=[:/]|$)/, `http://${window.location.hostname}`)
}
export function getStatusClass(state: PackageState, health?: string | null, exitCode?: number | 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'
@@ -212,7 +254,7 @@ export function useCategoriesWithApps(
allCategories: Ref<Array<{ id: string; name: string }>>,
) {
return computed(() => {
const entries = Object.entries(packages.value).filter(([id, pkg]) => !isServicePackage(id, pkg))
const entries = Object.entries(packages.value).filter(([id, pkg]) => !isWebsitePackage(id, pkg) && !isInternalToolingPackage(id, pkg))
return allCategories.value.filter(cat => {
if (cat.id === 'all') return true
return entries.some(([id, pkg]) => getAppCategory(id, pkg) === cat.id)
@@ -10,7 +10,7 @@
<RouterLink
to="/dashboard/apps"
class="mode-switcher-btn"
:class="{ 'mode-switcher-btn-active': (route.path === '/dashboard/apps' || route.path.startsWith('/dashboard/apps/')) && route.query.tab !== 'services' }"
:class="{ 'mode-switcher-btn-active': (route.path === '/dashboard/apps' || route.path.startsWith('/dashboard/apps/')) && route.query.tab !== 'services' && route.query.tab !== 'websites' }"
@click.prevent="router.push({ path: '/dashboard/apps', query: {} })"
>My Apps</RouterLink>
<RouterLink
@@ -19,10 +19,11 @@
:class="{ 'mode-switcher-btn-active': route.path === '/dashboard/marketplace' || route.path.startsWith('/dashboard/marketplace/') || route.path === '/dashboard/discover' }"
>App Store</RouterLink>
<RouterLink
to="/dashboard/apps?tab=services"
to="/dashboard/apps?tab=websites"
class="mode-switcher-btn"
:class="{ 'mode-switcher-btn-active': route.query.tab === 'services' }"
>Services</RouterLink>
:class="{ 'mode-switcher-btn-active': route.query.tab === 'services' || route.query.tab === 'websites' }"
@click.prevent="router.push({ path: '/dashboard/apps', query: { tab: 'websites' } })"
>Websites</RouterLink>
</div>
</div>
@@ -142,7 +143,7 @@ const mobileTabBar = ref<HTMLElement | null>(null)
// App sessions own their mobile controls. Normal mobile launches use the route
// session; keeping this guard also protects any desktop-panel state on resize.
const isAppSessionActive = computed(() => route.name === 'app-session' || !!appLauncher.panelAppId)
const isAppSessionActive = computed(() => route.name === 'app-session')
// Show persistent tabs for Apps/Marketplace on mobile
const showAppsTabs = computed(() => {
@@ -2,11 +2,11 @@
<aside
v-show="!chatFullscreen"
data-controller-zone="sidebar"
class="hidden md:flex w-[256px] flex-shrink-0 relative flex-col z-10"
class="hidden md:flex w-[256px] h-screen flex-shrink-0 sticky top-0 relative flex-col z-10"
:class="{ 'sidebar-animate': showZoomIn }"
>
<div class="sidebar-shell">
<div class="sidebar-inner flex flex-col min-h-full">
<div class="sidebar-inner flex flex-col h-full min-h-0">
<div class="sidebar-logo flex items-center gap-3 mb-8 p-6 pb-0 shrink-0">
<AnimatedLogo />
<div class="min-w-0 flex-1">
@@ -15,7 +15,7 @@
</div>
</div>
<nav class="sidebar-nav flex-1 min-h-0 space-y-2 p-6 pt-4" :aria-label="t('dashboard.mainNav')">
<nav class="sidebar-nav flex-1 min-h-0 overflow-y-auto overscroll-contain space-y-2 px-6 py-4" :aria-label="t('dashboard.mainNav')">
<RouterLink
v-for="(item, idx) in desktopNavItems"
:key="item.path"
@@ -74,21 +74,23 @@
</button>
</nav>
<div class="sidebar-controller px-6 pb-2 shrink-0">
<ControllerIndicator />
<CompanionIndicator />
</div>
<!-- Online status -->
<div class="px-6 pb-2 shrink-0">
<div class="rounded-lg bg-white/5 border border-white/10 px-4 py-2.5">
<OnlineStatusPill />
<div class="sidebar-bottom shrink-0">
<div class="sidebar-controller px-6 pb-2">
<ControllerIndicator />
<CompanionIndicator />
</div>
</div>
<!-- Mode switcher -->
<div class="px-6 pb-6 shrink-0">
<ModeSwitcher />
<!-- Online status -->
<div class="px-6 pb-2">
<div class="rounded-lg bg-white/5 border border-white/10 px-4 py-2.5">
<OnlineStatusPill />
</div>
</div>
<!-- Mode switcher -->
<div class="px-6 pb-6">
<ModeSwitcher />
</div>
</div>
</div>
</div>
@@ -47,7 +47,7 @@
.sidebar-shell {
width: 100%;
height: 100%;
min-height: 100vh;
min-height: 0;
background: rgba(0, 0, 0, 0.25);
backdrop-filter: blur(18px);
-webkit-backdrop-filter: blur(18px);
@@ -85,6 +85,24 @@
overflow: hidden;
}
.sidebar-nav {
scrollbar-width: thin;
scrollbar-color: rgba(255, 255, 255, 0.24) transparent;
}
.sidebar-nav::-webkit-scrollbar {
width: 6px;
}
.sidebar-nav::-webkit-scrollbar-thumb {
background: rgba(255, 255, 255, 0.22);
border-radius: 999px;
}
.sidebar-bottom {
background: linear-gradient(to top, rgba(0, 0, 0, 0.18), transparent 100%);
}
/* Only hide sidebar content when doing the login entrance animation */
.sidebar-animate .sidebar-inner {
opacity: 0;
@@ -96,8 +96,8 @@ export function useRouteTransitions() {
// Mobile: Horizontal slide transitions between sub-tabs
if (typeof window !== 'undefined' && window.innerWidth < 768) {
const isServices = currentPath === '/dashboard/apps' && currentRoute.query.tab === 'services'
const wasServices = previousTab === 'services'
const isServices = currentPath === '/dashboard/apps' && (currentRoute.query.tab === 'services' || currentRoute.query.tab === 'websites')
const wasServices = previousTab === 'services' || previousTab === 'websites'
const currentAppsIdx = isServices ? 2
: currentPath === '/dashboard/marketplace' ? 1
: currentPath === '/dashboard/apps' ? 0 : -1
+3 -4
View File
@@ -4,8 +4,7 @@
<Teleport to="body">
<button
@click="showFilter = true"
class="md:hidden fixed right-4 z-40 w-14 h-14 rounded-full glass-button flex items-center justify-center shadow-2xl mobile-back-btn"
style="left: auto;"
class="md:hidden fixed right-4 z-[2400] w-14 h-14 rounded-full glass-button flex items-center justify-center shadow-2xl mobile-filter-btn"
>
<svg class="w-6 h-6" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M3 4a1 1 0 011-1h16a1 1 0 011 1v2.586a1 1 0 01-.293.707l-6.414 6.414a1 1 0 00-.293.707V17l-4 4v-6.586a1 1 0 00-.293-.707L3.293 7.293A1 1 0 013 6.586V4z" />
@@ -17,10 +16,10 @@
<Transition name="modal">
<div
v-if="showFilter"
class="fixed inset-0 z-50 flex items-end justify-center md:hidden bg-black/10 backdrop-blur-md"
class="fixed inset-0 z-[3000] flex items-end justify-center md:hidden bg-black/10 backdrop-blur-md"
@click.self="closeFilter"
>
<div ref="filterModalRef" class="glass-card p-6 w-full rounded-t-3xl max-h-[80vh] overflow-y-auto">
<div ref="filterModalRef" class="glass-card p-6 w-full rounded-t-3xl max-h-[80vh] overflow-y-auto mobile-filter-sheet">
<div class="flex items-center justify-between mb-6">
<h2 class="text-2xl font-bold text-white">Filter</h2>
<button @click="closeFilter" class="text-white/60 hover:text-white transition-colors">
+7 -3
View File
@@ -78,7 +78,8 @@ export function getCuratedAppList(): MarketplaceApp[] {
return [
{ id: 'bitcoin-knots', title: 'Bitcoin Knots', version: '28.1.0', description: 'Run a full Bitcoin node. Validate and relay blocks and transactions on the Bitcoin network.', icon: '/assets/img/app-icons/bitcoin-knots.webp', author: 'Bitcoin Knots', dockerImage: `${R}/bitcoin-knots:latest`, repoUrl: 'https://github.com/bitcoinknots/bitcoin' },
{ id: 'bitcoin-core', title: 'Bitcoin Core', version: '28.4', description: 'Reference implementation of the Bitcoin protocol. Run a full node validating and relaying blocks on the Bitcoin network.', icon: '/assets/img/app-icons/bitcoin-core.svg', author: 'Bitcoin Core contributors', dockerImage: 'docker.io/bitcoin/bitcoin:28.4', repoUrl: 'https://github.com/bitcoin/bitcoin' },
{ id: 'btcpay-server', title: 'BTCPay Server', version: '1.13.7', description: 'Self-hosted Bitcoin payment processor. Accept Bitcoin payments without intermediaries or fees.', icon: '/assets/img/app-icons/btcpay-server.png', author: 'BTCPay Server Foundation', dockerImage: `${R}/btcpayserver:1.13.7`, repoUrl: 'https://github.com/btcpayserver/btcpayserver' },
{ id: 'btcpay-server', title: 'BTCPay Server', version: '2.3.9', description: 'Self-hosted Bitcoin payment processor. Accept Bitcoin payments without intermediaries or fees.', icon: '/assets/img/app-icons/btcpay-server.png', author: 'BTCPay Server Foundation', dockerImage: 'docker.io/btcpayserver/btcpayserver:2.3.9', repoUrl: 'https://github.com/btcpayserver/btcpayserver' },
{ id: 'saleor', title: 'Saleor', version: '3.23', category: 'commerce', description: 'Composable commerce platform with customer storefront, GraphQL API, dashboard, worker, mail testing, and tracing. Storefront opens on port 9011; admin dashboard remains on 9010.', icon: '/assets/img/app-icons/saleor.svg', author: 'Saleor', dockerImage: 'ghcr.io/saleor/saleor:3.23', repoUrl: 'https://github.com/saleor/saleor' },
{ id: 'lnd', title: 'LND', version: '0.18.4', description: 'Lightning Network Daemon. Fast and cheap Bitcoin payments through the Lightning Network.', icon: '/assets/img/app-icons/lnd.svg', author: 'Lightning Labs', dockerImage: `${R}/lnd:v0.18.4-beta`, repoUrl: 'https://github.com/lightningnetwork/lnd' },
{ id: 'mempool', title: 'Mempool Explorer', version: '3.0.0', description: 'Self-hosted Bitcoin blockchain and mempool visualizer. Monitor transactions without revealing your addresses to third parties.', icon: '/assets/img/app-icons/mempool.webp', author: 'Mempool', dockerImage: `${R}/mempool-frontend:v3.0.0`, repoUrl: 'https://github.com/mempool/mempool' },
{ id: 'homeassistant', title: 'Home Assistant', version: '2024.1', description: 'Open-source home automation. Control smart home devices privately, on your own hardware.', icon: '/assets/img/app-icons/homeassistant.png', author: 'Home Assistant', dockerImage: `${R}/home-assistant:2024.1`, repoUrl: 'https://github.com/home-assistant/core' },
@@ -96,6 +97,7 @@ export function getCuratedAppList(): MarketplaceApp[] {
{ id: 'portainer', title: 'Portainer', version: '2.19.4', description: 'Container management UI. Manage your containerized services through the web.', icon: '/assets/img/app-icons/portainer.webp', author: 'Portainer', dockerImage: `${R}/portainer:latest`, repoUrl: 'https://github.com/portainer/portainer' },
{ id: 'uptime-kuma', title: 'Uptime Kuma', version: '1.23.0', description: 'Self-hosted uptime monitoring. Track HTTP, TCP, DNS, and more.', icon: '/assets/img/app-icons/uptime-kuma.webp', author: 'Uptime Kuma', dockerImage: `${R}/uptime-kuma:1`, repoUrl: 'https://github.com/louislam/uptime-kuma' },
{ id: 'tailscale', title: 'Tailscale', version: '1.78.0', description: 'Zero-config VPN. Secure remote access with WireGuard mesh networking.', icon: '/assets/img/app-icons/tailscale.webp', author: 'Tailscale', dockerImage: `${R}/tailscale:stable`, repoUrl: 'https://github.com/tailscale/tailscale' },
{ id: 'netbird', title: 'NetBird', version: '0.71.2', description: 'Self-hosted WireGuard mesh VPN control plane with dashboard, embedded identity provider, management API, signal, relay, and STUN.', icon: '/assets/img/app-icons/netbird.svg', author: 'NetBird', dockerImage: 'docker.io/netbirdio/dashboard:v2.38.0', repoUrl: 'https://github.com/netbirdio/netbird' },
{ id: 'electrumx', title: 'ElectrumX', version: '1.18.0', description: 'Electrum protocol server. Index the blockchain for fast wallet lookups, privately.', icon: '/assets/img/app-icons/electrumx.png', author: 'Luke Childs', dockerImage: `${R}/electrumx:v1.18.0`, repoUrl: 'https://github.com/spesmilo/electrumx' },
{ id: 'fedimint', title: 'Fedimint', version: '0.10.0', description: 'Federated Bitcoin mint. Private, scalable Bitcoin through federated guardians.', icon: '/assets/img/app-icons/fedimint.png', author: 'Fedimint', dockerImage: `${R}/fedimintd:v0.10.0`, repoUrl: 'https://github.com/fedimint/fedimint' },
{ id: 'indeedhub', title: 'Indeehub', version: '1.0.0', description: 'Bitcoin documentary streaming with Nostr identity. Stream sovereignty content.', icon: '/assets/img/app-icons/indeedhub.png', author: 'Indeehub Team', dockerImage: `${R}/indeedhub:1.0.0`, repoUrl: 'https://github.com/indeedhub/indeedhub' },
@@ -119,6 +121,7 @@ export const INSTALLED_ALIASES: Record<string, string[]> = {
mempool: ['mempool', 'mempool-web', 'archy-mempool-web'],
bitcoin: ['bitcoin-knots'],
btcpay: ['btcpay-server'],
saleor: ['saleor'],
immich: ['immich-server', 'immich-app', 'immich_server'],
nextcloud: ['nextcloud-aio', 'nextcloud-server'],
fedimint: ['fedimint-gateway'],
@@ -132,6 +135,7 @@ export const INSTALLED_ALIASES: Record<string, string[]> = {
lnd: ['lnd'],
filebrowser: ['filebrowser'],
tailscale: ['tailscale'],
netbird: ['netbird'],
ollama: ['ollama'],
indeedhub: ['indeedhub'],
botfights: ['botfights'],
@@ -187,11 +191,11 @@ export function categorizeCommunityApp(app: MarketplaceApp): string {
const combined = `${id} ${title} ${description}`
if (id.includes('bitcoin') || id.includes('btc') || id.includes('lightning') || id.includes('lnd') || id.includes('electr') || id.includes('fedimint') || id.includes('cashu') || combined.includes('wallet')) return 'money'
if (id.includes('btcpay') || id.includes('commerce') || id.includes('shop') || id.includes('pos') || combined.includes('merchant')) return 'commerce'
if (id.includes('btcpay') || id.includes('saleor') || id.includes('commerce') || id.includes('shop') || id.includes('pos') || combined.includes('merchant')) return 'commerce'
if (id.includes('cloud') || id.includes('nextcloud') || id.includes('storage') || id.includes('file') || id.includes('photo') || id.includes('immich') || id.includes('jellyfin') || id.includes('media') || id.includes('vault') || combined.includes('password manager')) return 'data'
if (id.includes('home-assistant') || id.includes('homeassistant') || combined.includes('home automation')) return 'home'
if (id.includes('nostr') || combined.includes('nostr relay')) return 'nostr'
if (id.includes('vpn') || id.includes('wireguard') || id.includes('tailscale') || id.includes('proxy') || id.includes('dns') || id.includes('tor') || combined.includes('network')) return 'networking'
if (id.includes('vpn') || id.includes('wireguard') || id.includes('tailscale') || id.includes('netbird') || id.includes('proxy') || id.includes('dns') || id.includes('tor') || combined.includes('network')) return 'networking'
if (id.includes('matrix') || id.includes('mastodon') || id.includes('chat') || id.includes('social') || combined.includes('messaging')) return 'community'
return 'other'
}
@@ -3,8 +3,7 @@
<Teleport to="body">
<button
@click="showModal = true"
class="md:hidden fixed right-4 z-40 w-14 h-14 rounded-full glass-button flex items-center justify-center shadow-2xl mobile-back-btn"
style="left: auto;"
class="md:hidden fixed right-4 z-[2400] w-14 h-14 rounded-full glass-button flex items-center justify-center shadow-2xl mobile-filter-btn"
>
<svg class="w-6 h-6" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M3 4a1 1 0 011-1h16a1 1 0 011 1v2.586a1 1 0 01-.293.707l-6.414 6.414a1 1 0 00-.293.707V17l-4 4v-6.586a1 1 0 00-.293-.707L3.293 7.293A1 1 0 013 6.586V4z" />
@@ -16,10 +15,10 @@
<Transition name="modal">
<div
v-if="showModal"
class="fixed inset-0 z-50 flex items-end justify-center md:hidden bg-black/10 backdrop-blur-md"
class="fixed inset-0 z-[3000] flex items-end justify-center md:hidden bg-black/10 backdrop-blur-md"
@click.self="close()"
>
<div ref="modalRef" class="glass-card p-6 w-full rounded-t-3xl max-h-[80vh] overflow-y-auto">
<div ref="modalRef" class="glass-card p-6 w-full rounded-t-3xl max-h-[80vh] overflow-y-auto mobile-filter-sheet">
<!-- Header -->
<div class="flex items-center justify-between mb-6">
<h2 class="text-2xl font-bold text-white">{{ t('marketplace.filterByCategory') }}</h2>
@@ -47,6 +47,7 @@ export const INSTALLED_ALIASES: Record<string, string[]> = {
mempool: ['mempool-web', 'mempool-api', 'archy-mempool-web', 'archy-mempool-db'],
bitcoin: ['bitcoin-knots'],
btcpay: ['btcpay-server', 'archy-btcpay-db', 'archy-nbxplorer'],
saleor: ['saleor'],
immich: ['immich-server', 'immich-app', 'immich_server', 'immich_postgres', 'immich_redis'],
nextcloud: ['nextcloud-aio', 'nextcloud-server'],
fedimint: ['fedimint-gateway'],
@@ -60,13 +61,14 @@ export const INSTALLED_ALIASES: Record<string, string[]> = {
lnd: ['lnd', 'archy-lnd-ui'],
filebrowser: ['filebrowser'],
tailscale: ['tailscale'],
netbird: ['netbird'],
ollama: ['ollama'],
}
/** Get app tier classification (matches backend get_app_tier) */
export function getAppTier(appId: string): string {
const core = ['bitcoin-knots', 'bitcoin', 'lnd', 'mempool', 'btcpay-server', 'dwn', 'filebrowser']
const recommended = ['fedimint', 'thunderhub', 'vaultwarden', 'uptime-kuma', 'grafana', 'searxng', 'tailscale', 'portainer']
const recommended = ['fedimint', 'thunderhub', 'vaultwarden', 'uptime-kuma', 'grafana', 'searxng', 'tailscale', 'netbird', 'portainer', 'saleor']
if (core.includes(appId)) return 'core'
if (recommended.includes(appId)) return 'recommended'
return 'optional'
@@ -88,7 +90,7 @@ export function categorizeCommunityApp(app: MarketplaceApp): string {
return 'money'
}
if (id.includes('btcpay') || id.includes('commerce') || id.includes('shop') ||
if (id.includes('btcpay') || id.includes('saleor') || id.includes('commerce') || id.includes('shop') ||
id.includes('store') || id.includes('pos') || id.includes('payment') ||
combined.includes('merchant') || combined.includes('invoice')) {
return 'commerce'
@@ -113,7 +115,7 @@ export function categorizeCommunityApp(app: MarketplaceApp): string {
return 'nostr'
}
if (id.includes('vpn') || id.includes('wireguard') || id.includes('tailscale') ||
if (id.includes('vpn') || id.includes('wireguard') || id.includes('tailscale') || id.includes('netbird') ||
id.includes('proxy') || id.includes('dns') || id.includes('pihole') ||
id.includes('adguard') || id.includes('nginx') || id.includes('tor') ||
combined.includes('network') || combined.includes('firewall')) {
@@ -148,14 +150,26 @@ export function getCuratedAppList(): MarketplaceApp[] {
{
id: 'btcpay-server',
title: 'BTCPay Server',
version: '1.13.7',
version: '2.3.9',
description: 'Self-hosted Bitcoin payment processor. Accept Bitcoin payments without intermediaries or fees.',
icon: '/assets/img/app-icons/btcpay-server.png',
author: 'BTCPay Server Foundation',
dockerImage: `${REGISTRY}/btcpayserver:1.13.7`,
dockerImage: 'docker.io/btcpayserver/btcpayserver:2.3.9',
manifestUrl: undefined,
repoUrl: 'https://github.com/btcpayserver/btcpayserver'
},
{
id: 'saleor',
title: 'Saleor',
version: '3.23',
category: 'commerce',
description: 'Composable commerce platform with customer storefront, GraphQL API, dashboard, worker, mail testing, and tracing.',
icon: '/assets/img/app-icons/saleor.svg',
author: 'Saleor',
dockerImage: 'ghcr.io/saleor/saleor:3.23',
manifestUrl: undefined,
repoUrl: 'https://github.com/saleor/saleor'
},
{
id: 'lnd',
title: 'LND',
@@ -365,6 +379,17 @@ export function getCuratedAppList(): MarketplaceApp[] {
manifestUrl: undefined,
repoUrl: 'https://github.com/tailscale/tailscale'
},
{
id: 'netbird',
title: 'NetBird',
version: '0.71.2',
description: 'Self-hosted WireGuard mesh VPN control plane with dashboard, embedded identity provider, management API, signal, relay, and STUN.',
icon: '/assets/img/app-icons/netbird.svg',
author: 'NetBird',
dockerImage: 'docker.io/netbirdio/dashboard:v2.38.0',
manifestUrl: undefined,
repoUrl: 'https://github.com/netbirdio/netbird'
},
{
id: 'fedimint',
title: 'Fedimint',
@@ -180,6 +180,393 @@ init()
</button>
</div>
<div class="overflow-y-auto flex-1 min-h-0 space-y-6 pr-1">
<!-- v1.7.79-alpha -->
<div>
<div class="flex items-center gap-2 mb-3">
<span class="text-xs font-mono px-2 py-0.5 rounded bg-orange-500/20 text-orange-300">v1.7.79-alpha</span>
<span class="text-xs text-white/40">May 20, 2026</span>
</div>
<div class="space-y-3 text-sm text-white/80 pl-3 border-l border-white/10">
<p>Saleor now includes the official Saleor Storefront as the customer-facing shop on port 9011.</p>
<p>Launching Saleor opens the storefront, while the admin dashboard remains on port 9010 with generated admin@example.com credentials shown in Archipelago.</p>
<p>Public storefront domains also get same-origin /graphql/ proxying so phones and laptops can reach the local Saleor API through the domain instead of a private node address.</p>
<p>Saleor catalog, marketplace, scanner, and app-session metadata now describe the storefront/dashboard/API split explicitly.</p>
</div>
</div>
<!-- v1.7.78-alpha -->
<div>
<div class="flex items-center gap-2 mb-3">
<span class="text-xs font-mono px-2 py-0.5 rounded bg-orange-500/20 text-orange-300">v1.7.78-alpha</span>
<span class="text-xs text-white/40">May 20, 2026</span>
</div>
<div class="space-y-3 text-sm text-white/80 pl-3 border-l border-white/10">
<p>Saleor's validated port 9010 dashboard/API origin settings are now ready for production release, preserving the working test-node install path.</p>
<p>NetBird launches now stay on the unified dashboard/proxy origin at port 8087 instead of following stale server URLs on 8086.</p>
<p>NetBird proxy routing no longer depends on a hard-coded rootless Podman gateway IP and now includes the upstream management proxy gRPC path.</p>
<p>Mobile credential prompts keep long credential lists scrollable and the Cancel/Continue buttons reachable in both My Apps and the mobile icon grid.</p>
<p>Android app-session popups hand external login/signup windows to the system browser instead of dropping them inside the WebView.</p>
</div>
</div>
<!-- v1.7.76-alpha -->
<div>
<div class="flex items-center gap-2 mb-3">
<span class="text-xs font-mono px-2 py-0.5 rounded bg-orange-500/20 text-orange-300">v1.7.76-alpha</span>
<span class="text-xs text-white/40">May 20, 2026</span>
</div>
<div class="space-y-3 text-sm text-white/80 pl-3 border-l border-white/10">
<p>Saleor now installs on dashboard port 9010 instead of conflicting with Portainer on 9000, keeps its API on 8000, creates or repairs the admin@example.com staff account after sample data loads, and starts cleanly with the correct dashboard mount path.</p>
<p>Apps with generated first-use credentials now show them in Archipelago before launch and in App Details. Saleor exposes copyable admin email/password fields so the dashboard login screen is no longer a dead end.</p>
<p>NetBird API and OAuth routes now proxy through the stable host-published server port, and the embedded IdP keeps upstream-compatible signing-key refresh settings while the dashboard sends ID tokens to the API so signup no longer lands in an Unauthenticated dashboard state.</p>
<p>Transient unnamed Podman helper containers created during app installs are hidden from My Apps, so random generated names like eager_keldysh no longer appear as applications.</p>
<p>Mobile App Store categories are now visible as horizontal chips above the tab bar, Discover is reachable on mobile, category choices update the actual view, and apps that require a real tab open directly from the icon tap.</p>
</div>
</div>
<!-- v1.7.75-alpha -->
<div>
<div class="flex items-center gap-2 mb-3">
<span class="text-xs font-mono px-2 py-0.5 rounded bg-orange-500/20 text-orange-300">v1.7.75-alpha</span>
<span class="text-xs text-white/40">May 19, 2026</span>
</div>
<div class="space-y-3 text-sm text-white/80 pl-3 border-l border-white/10">
<p>Saleor joined the App Store as a recommended commerce stack with dashboard, API, worker, PostgreSQL, Valkey, Mailpit, and Jaeger containers.</p>
<p>NetBird repair now rewrites the unified-origin config and recreates the browser-facing proxy/dashboard while preserving existing control-plane data.</p>
<p>Desktop dashboard scrolling hands focus back from the sidebar to the main content when the pointer or wheel moves over the main pane.</p>
</div>
</div>
<!-- v1.7.74-alpha -->
<div>
<div class="flex items-center gap-2 mb-3">
<span class="text-xs font-mono px-2 py-0.5 rounded bg-orange-500/20 text-orange-300">v1.7.74-alpha</span>
<span class="text-xs text-white/40">May 19, 2026</span>
</div>
<div class="space-y-3 text-sm text-white/80 pl-3 border-l border-white/10">
<p>App-session right panels now re-focus the iframe after load and when the frame area is activated, so scrolling works immediately after selecting an app or switching tabs on shorter screens.</p>
<p>NetBird now uses a unified local launch origin on port 8087 that serves the dashboard and proxies auth/API routes to the server, fixing the Unauthenticated and 404 logout/login loop.</p>
<p>Existing NetBird installs are repaired during adopt/start by rewriting the config files and creating the missing dashboard/proxy containers while preserving data.</p>
<p>Saleor is not in this release yet; it remains pending for separate validation.</p>
</div>
</div>
<!-- v1.7.73-alpha -->
<div>
<div class="flex items-center gap-2 mb-3">
<span class="text-xs font-mono px-2 py-0.5 rounded bg-orange-500/20 text-orange-300">v1.7.73-alpha</span>
<span class="text-xs text-white/40">May 19, 2026</span>
</div>
<div class="space-y-3 text-sm text-white/80 pl-3 border-l border-white/10">
<p>Mobile apps that block iframe embedding now open directly in a browser tab instead of first landing in a broken in-shell webview.</p>
<p>App Store search covers all apps while searching, My Apps search can surface matching installable App Store entries, and mobile My Apps/Websites tab switching updates the view reliably.</p>
<p>NetBird installs prefer a 100.x tailnet address when available, and app sessions gained iframe auto-focus plus a scroll host for right-frame scrolling.</p>
</div>
</div>
<!-- v1.7.72-alpha -->
<div>
<div class="flex items-center gap-2 mb-3">
<span class="text-xs font-mono px-2 py-0.5 rounded bg-orange-500/20 text-orange-300">v1.7.72-alpha</span>
<span class="text-xs text-white/40">May 19, 2026</span>
</div>
<div class="space-y-3 text-sm text-white/80 pl-3 border-l border-white/10">
<p>Settings What's New is caught up again. The modal now includes the missing entries for v1.7.68-alpha through v1.7.71-alpha instead of stopping at v1.7.67-alpha.</p>
<p>The release lockfile metadata is also kept in sync with the previous release bump.</p>
</div>
</div>
<!-- v1.7.71-alpha -->
<div>
<div class="flex items-center gap-2 mb-3">
<span class="text-xs font-mono px-2 py-0.5 rounded bg-orange-500/20 text-orange-300">v1.7.71-alpha</span>
<span class="text-xs text-white/40">May 19, 2026</span>
</div>
<div class="space-y-3 text-sm text-white/80 pl-3 border-l border-white/10">
<p>NetBird stack installs now create the exact persistent data directory before binding it into the server container, fixing the failed install path seen on the test node.</p>
<p>NetBird start and restart actions bring up the control-plane server before the dashboard, so lifecycle actions use the correct dependency order.</p>
<p>App-session fallbacks now return to My Apps under /dashboard, mobile iframe-blocked apps stay inside Archipelago with an explicit fallback, and installed Gitea containers show the packaged Gitea icon with rounder app icon masks.</p>
</div>
</div>
<!-- v1.7.70-alpha -->
<div>
<div class="flex items-center gap-2 mb-3">
<span class="text-xs font-mono px-2 py-0.5 rounded bg-orange-500/20 text-orange-300">v1.7.70-alpha</span>
<span class="text-xs text-white/40">May 19, 2026</span>
</div>
<div class="space-y-3 text-sm text-white/80 pl-3 border-l border-white/10">
<p>NetBird was corrected from the peer/client daemon image to the self-hosted control-plane stack, with a launchable dashboard on port 8087, management/signal/relay server on 8086, and STUN on UDP 3478.</p>
<p>Local app launches use direct host ports and carry an explicit dashboard return target, so closing an app session goes back to the launching dashboard screen instead of falling through to browser history or a 404.</p>
<p>Mobile launches ignore stale desktop panel state and route into the full app-session webview. The desktop sidebar also keeps top and bottom regions pinned while only the middle navigation scrolls on short screens.</p>
</div>
</div>
<!-- v1.7.69-alpha -->
<div>
<div class="flex items-center gap-2 mb-3">
<span class="text-xs font-mono px-2 py-0.5 rounded bg-orange-500/20 text-orange-300">v1.7.69-alpha</span>
<span class="text-xs text-white/40">May 19, 2026</span>
</div>
<div class="space-y-3 text-sm text-white/80 pl-3 border-l border-white/10">
<p>App installs now allow up to 10 minutes for slow initial install RPCs, matching large container pulls and preventing apps from disappearing from My Apps while the backend is still pulling or retrying mirrors.</p>
<p>Gitea is now categorized as a known Data app and stays visible during slow registry pulls. Live diagnostics confirmed the Gitea container came up healthy on port 3001 after the frontend had previously timed out too early.</p>
<p>NetBird was added to the catalog as a recommended networking app, and the Archipelago terminal includes nano on new installs and existing-node self-update fallback.</p>
</div>
</div>
<!-- v1.7.68-alpha -->
<div>
<div class="flex items-center gap-2 mb-3">
<span class="text-xs font-mono px-2 py-0.5 rounded bg-orange-500/20 text-orange-300">v1.7.68-alpha</span>
<span class="text-xs text-white/40">May 19, 2026</span>
</div>
<div class="space-y-3 text-sm text-white/80 pl-3 border-l border-white/10">
<p>BTCPay Server now ships on the official btcpayserver image, fixing the plugin catalog crash caused by newer plugin dependency metadata while preserving existing data and Postgres databases.</p>
<p>BTCPay health checks no longer require curl inside the container, and Nginx Proxy Manager certificate challenge handling now avoids hijacking local API traffic while syncing issued public proxy hosts into host nginx.</p>
<p>System Update confirmation and mirror modals now cover the whole app, app-session close returns to the previous dashboard screen, and mobile app launches stay inside Archipelago's app-session webview.</p>
</div>
</div>
<!-- v1.7.67-alpha -->
<div>
<div class="flex items-center gap-2 mb-3">
<span class="text-xs font-mono px-2 py-0.5 rounded bg-orange-500/20 text-orange-300">v1.7.67-alpha</span>
<span class="text-xs text-white/40">May 18, 2026</span>
</div>
<div class="space-y-3 text-sm text-white/80 pl-3 border-l border-white/10">
<p>Home status cards are calmer and more honest now. System, VPN, Bitcoin, and FIPS values keep their last known good state while route changes or short RPC failures are in flight, so the dashboard no longer flashes false "not configured" or "not running" states during normal refreshes.</p>
<p>Home, Web5 Monitoring, and the full Monitoring page now agree on the headline CPU, memory, disk, uptime, and load numbers. The UI uses one live system-stat snapshot for the visible cards while keeping the Monitoring page's historical store for charts, alerts, and container history.</p>
<p>The missing What's New history is filled in through this release, including every curated entry from v1.7.44-alpha through v1.7.66-alpha.</p>
<p>Bitcoin lifecycle specs are aligned again across Rust, first boot, and reconcile. Bitcoin Core/Knots get the intended memory headroom on normal hosts, and pruned Knots uses a larger dbcache when the node has enough RAM, improving IBD throughput without raising pressure on low-memory machines.</p>
<p>ElectrumX/electrs lifecycle specs now use the same memory policy everywhere, reducing drift between fresh installs, app lifecycle actions, and reconciliation.</p>
</div>
</div>
<!-- v1.7.66-alpha -->
<div>
<div class="flex items-center gap-2 mb-3">
<span class="text-xs font-mono px-2 py-0.5 rounded bg-orange-500/20 text-orange-300">v1.7.66-alpha</span>
<span class="text-xs text-white/40">May 18, 2026</span>
</div>
<div class="space-y-3 text-sm text-white/80 pl-3 border-l border-white/10">
<p>Nginx Proxy Manager stale-port repair now catches stopped or Created Podman records that still remember old port mappings. That means a stuck record can be removed and recreated before it blocks the current NPM ports.</p>
<p>Live recovery on the field node preserved the existing Nginx Proxy Manager data directory while recreating only the stale container metadata with the current 8081, 8084, and 8444 host ports.</p>
</div>
</div>
<!-- v1.7.65-alpha -->
<div>
<div class="flex items-center gap-2 mb-3">
<span class="text-xs font-mono px-2 py-0.5 rounded bg-orange-500/20 text-orange-300">v1.7.65-alpha</span>
<span class="text-xs text-white/40">May 18, 2026</span>
</div>
<div class="space-y-3 text-sm text-white/80 pl-3 border-l border-white/10">
<p>Orchestrator-backed app starts now run the same pre-start repair path as the legacy Podman start flow. Nginx Proxy Manager can clean up stale port metadata before the orchestrator tries to bring it online.</p>
<p>Diagnostics confirmed host nginx was healthy while Nginx Proxy Manager itself had no listeners on its expected ports, narrowing the outage to NPM container lifecycle repair instead of the system proxy.</p>
</div>
</div>
<!-- v1.7.64-alpha -->
<div>
<div class="flex items-center gap-2 mb-3">
<span class="text-xs font-mono px-2 py-0.5 rounded bg-orange-500/20 text-orange-300">v1.7.64-alpha</span>
<span class="text-xs text-white/40">May 18, 2026</span>
</div>
<div class="space-y-3 text-sm text-white/80 pl-3 border-l border-white/10">
<p>Authenticated update applies are no longer throttled so aggressively during troubleshooting. The System Update page now allows legitimate retry flows without immediately running into 429 Too Many Requests.</p>
<p>The release still includes the corrected backend rebuild protection, so OTA artifacts are built from the fresh Rust binary instead of an older compiled version.</p>
</div>
</div>
<!-- v1.7.63-alpha -->
<div>
<div class="flex items-center gap-2 mb-3">
<span class="text-xs font-mono px-2 py-0.5 rounded bg-orange-500/20 text-orange-300">v1.7.63-alpha</span>
<span class="text-xs text-white/40">May 18, 2026</span>
</div>
<div class="space-y-3 text-sm text-white/80 pl-3 border-l border-white/10">
<p>The release script now rebuilds the backend after bumping the version and before hashing artifacts. OTA manifests no longer point at a stale backend binary.</p>
<p>This corrected the previous stale-artifact issue and carries the Nginx Proxy Manager stale-port repair in a backend binary that nodes can actually install and run.</p>
</div>
</div>
<!-- v1.7.62-alpha -->
<div>
<div class="flex items-center gap-2 mb-3">
<span class="text-xs font-mono px-2 py-0.5 rounded bg-orange-500/20 text-orange-300">v1.7.62-alpha</span>
<span class="text-xs text-white/40">May 18, 2026</span>
</div>
<div class="space-y-3 text-sm text-white/80 pl-3 border-l border-white/10">
<p>Nginx Proxy Manager start and restart now repair stale Podman containers that still publish the admin UI on host port 81, which conflicts with host nginx on updated nodes.</p>
<p>The repair recreates only NPM container metadata while preserving its persistent data and using the current 8081, 8084, and 8444 host mappings.</p>
</div>
</div>
<!-- v1.7.61-alpha -->
<div>
<div class="flex items-center gap-2 mb-3">
<span class="text-xs font-mono px-2 py-0.5 rounded bg-orange-500/20 text-orange-300">v1.7.61-alpha</span>
<span class="text-xs text-white/40">May 18, 2026</span>
</div>
<div class="space-y-3 text-sm text-white/80 pl-3 border-l border-white/10">
<p>Multi-container stack installs stay in Installing for up to 20 minutes while dependency containers are being pulled and prepared. BTCPay no longer appears to vanish after two minutes while Postgres and NBXplorer are still being created.</p>
<p>Lifecycle stale-state recovery remains short for start, stop, restart, update, and removal actions, so genuinely wedged operations still clear quickly.</p>
</div>
</div>
<!-- v1.7.60-alpha -->
<div>
<div class="flex items-center gap-2 mb-3">
<span class="text-xs font-mono px-2 py-0.5 rounded bg-orange-500/20 text-orange-300">v1.7.60-alpha</span>
<span class="text-xs text-white/40">May 18, 2026</span>
</div>
<div class="space-y-3 text-sm text-white/80 pl-3 border-l border-white/10">
<p>Meshtastic serial detection now rejects malformed handshakes and skips known non-mesh serial devices such as Sierra Wireless LTE modems and Zooz/Z-Wave sticks.</p>
<p>Meshtastic config sync sends the correct protobuf wire type, allowing node-info and contact ingestion to work reliably. The mesh udev rule also stops claiming every ttyACM device and now targets known mesh adapters/vendors.</p>
</div>
</div>
<!-- v1.7.59-alpha -->
<div>
<div class="flex items-center gap-2 mb-3">
<span class="text-xs font-mono px-2 py-0.5 rounded bg-orange-500/20 text-orange-300">v1.7.59-alpha</span>
<span class="text-xs text-white/40">May 17, 2026</span>
</div>
<div class="space-y-3 text-sm text-white/80 pl-3 border-l border-white/10">
<p>Mobile app launching now keeps known container apps inside Archipelago's app-session flow instead of forcing desktop-only new-tab behavior.</p>
<p>App sessions on mobile respect the status-bar safe area, while the fullscreen backdrop remains edge-to-edge. The Apps page also gained a compact sideload button and modal for trusted Docker images.</p>
<p>Sideloaded app title and description metadata now persist through backend app config, and Meshtastic contact discovery retries config sync when the radio contact cache is empty.</p>
</div>
</div>
<!-- v1.7.58-alpha -->
<div>
<div class="flex items-center gap-2 mb-3">
<span class="text-xs font-mono px-2 py-0.5 rounded bg-orange-500/20 text-orange-300">v1.7.58-alpha</span>
<span class="text-xs text-white/40">May 17, 2026</span>
</div>
<div class="space-y-3 text-sm text-white/80 pl-3 border-l border-white/10">
<p>Mesh networking now supports Meshtastic radios over the serial API alongside existing MeshCore Companion USB radios. The shared listener probes preferred and auto-detected serial paths for both firmware families.</p>
<p>Meshtastic text packets are translated into Archipelago's existing mesh frame pipeline, and Meshtastic node information appears as normal mesh contacts using stable synthetic public keys.</p>
<p>Frontend OTA behavior improved: hashed assets no longer fall back to index.html, the HTML shell revalidates on every load, and runtime promotion installs the bundled nginx config on update.</p>
</div>
</div>
<!-- v1.7.57-alpha -->
<div>
<div class="flex items-center gap-2 mb-3">
<span class="text-xs font-mono px-2 py-0.5 rounded bg-orange-500/20 text-orange-300">v1.7.57-alpha</span>
<span class="text-xs text-white/40">May 17, 2026</span>
</div>
<div class="space-y-3 text-sm text-white/80 pl-3 border-l border-white/10">
<p>Nginx Proxy Manager now avoids privileged rootless Podman host port 81, preferring 8081 for its admin UI while host nginx keeps a compatibility proxy on :81 for stale launch buttons.</p>
<p>App installs allocate ports by checking live host bind availability, falling back to a free high port when preferred ports are occupied. Portainer-created launchable containers now appear in a Websites tab through their discovered host ports.</p>
</div>
</div>
<!-- v1.7.56-alpha -->
<div>
<div class="flex items-center gap-2 mb-3">
<span class="text-xs font-mono px-2 py-0.5 rounded bg-orange-500/20 text-orange-300">v1.7.56-alpha</span>
<span class="text-xs text-white/40">May 15, 2026</span>
</div>
<div class="space-y-3 text-sm text-white/80 pl-3 border-l border-white/10">
<p>Fresh installs include the full Wi-Fi userspace stack and grant the Archipelago service user NetworkManager PolicyKit access, so Intel Wi-Fi scanning and connection changes work from the web UI.</p>
<p>Container health and reconciliation are more honest and resilient: stale alerts clear, Stopping containers can be recreated, health states come from Podman, and drifted Quadlet settings trigger proper restarts.</p>
<p>Bitcoin Knots and ElectrumX get more CPU and memory headroom, LND helpers tolerate container-owned files better, and the screensaver stays out of media-heavy app sessions.</p>
</div>
</div>
<!-- v1.7.55-alpha -->
<div>
<div class="flex items-center gap-2 mb-3">
<span class="text-xs font-mono px-2 py-0.5 rounded bg-orange-500/20 text-orange-300">v1.7.55-alpha</span>
<span class="text-xs text-white/40">May 13, 2026</span>
</div>
<div class="space-y-3 text-sm text-white/80 pl-3 border-l border-white/10">
<p>Container reconcile can force-recreate Podman records stuck in Stopping while preserving bind-mounted app data, recovering wedged containers automatically.</p>
<p>Lifecycle audits on the hardened container layer passed on the validation node, with direct app probes returning healthy responses.</p>
</div>
</div>
<!-- v1.7.54-alpha -->
<div>
<div class="flex items-center gap-2 mb-3">
<span class="text-xs font-mono px-2 py-0.5 rounded bg-orange-500/20 text-orange-300">v1.7.54-alpha</span>
<span class="text-xs text-white/40">May 6, 2026</span>
</div>
<div class="space-y-3 text-sm text-white/80 pl-3 border-l border-white/10">
<p>Existing installs now self-repair nginx backend proxy locations for Bitcoin status and app catalog calls, including hosts where the active config is a copied file rather than a symlink.</p>
<p>LND UI is consistently served on port 18083 across first boot, Tor config, Quadlet reconciliation, OTA runtime payloads, and ISO scripts. OTA frontend tarballs also carry a cleaner runtime payload so startup promotion does not reintroduce stale host assets.</p>
</div>
</div>
<!-- v1.7.53-alpha -->
<div>
<div class="flex items-center gap-2 mb-3">
<span class="text-xs font-mono px-2 py-0.5 rounded bg-orange-500/20 text-orange-300">v1.7.53-alpha</span>
<span class="text-xs text-white/40">May 5, 2026</span>
</div>
<div class="space-y-3 text-sm text-white/80 pl-3 border-l border-white/10">
<p>Bitcoin Knots/Core config generation no longer duplicates RPC bind and port settings between bitcoin.conf and container command arguments, fixing startup failures from RPC endpoint binding conflicts.</p>
<p>Legacy Bitcoin healthchecks no longer depend on bitcoin-cli being present in current images. Update checks now prefer manifest OTA releases over stale git remotes unless git updates are explicitly enabled.</p>
</div>
</div>
<!-- v1.7.52-alpha -->
<div>
<div class="flex items-center gap-2 mb-3">
<span class="text-xs font-mono px-2 py-0.5 rounded bg-orange-500/20 text-orange-300">v1.7.52-alpha</span>
<span class="text-xs text-white/40">May 5, 2026</span>
</div>
<div class="space-y-3 text-sm text-white/80 pl-3 border-l border-white/10">
<p>Tailscale now launches its local installed web UI on port 8240 and starts tailscaled before tailscale web, fixing unreachable installs after container creation.</p>
<p>Grafana lifecycle actions repair missing rootless host listeners on port 3000, and Debian 13 install paths pull security updates from trixie-security during image/install creation.</p>
</div>
</div>
<!-- v1.7.49-alpha -->
<div>
<div class="flex items-center gap-2 mb-3">
<span class="text-xs font-mono px-2 py-0.5 rounded bg-orange-500/20 text-orange-300">v1.7.49-alpha</span>
<span class="text-xs text-white/40">Apr 30, 2026</span>
</div>
<div class="space-y-3 text-sm text-white/80 pl-3 border-l border-white/10">
<p>Bitcoin Knots/Core UI now reports connection, reconnecting, syncing, and error states from a backend status bridge instead of showing stale connection failures while the node warms up.</p>
<p>ElectrumX exposes indexed height, local Bitcoin height, known headers, status, and progress source, making long initial indexing states readable. Bitcoin Core and Bitcoin Knots are now mutually exclusive variants with corrected install conflict handling.</p>
<p>IndeeHub launches only on its direct web UI port, and BTCPay/NBXplorer Postgres environment formatting was fixed to avoid malformed connection strings.</p>
</div>
</div>
<!-- v1.7.48-alpha -->
<div>
<div class="flex items-center gap-2 mb-3">
<span class="text-xs font-mono px-2 py-0.5 rounded bg-orange-500/20 text-orange-300">v1.7.48-alpha</span>
<span class="text-xs text-white/40">Apr 29, 2026</span>
</div>
<div class="space-y-3 text-sm text-white/80 pl-3 border-l border-white/10">
<p>archipelago.service now creates /run/containers before startup, fixing systemd mount-namespace failures on nodes where that runtime directory did not already exist.</p>
</div>
</div>
<!-- v1.7.47-alpha -->
<div>
<div class="flex items-center gap-2 mb-3">
<span class="text-xs font-mono px-2 py-0.5 rounded bg-orange-500/20 text-orange-300">v1.7.47-alpha</span>
<span class="text-xs text-white/40">Apr 29, 2026</span>
</div>
<div class="space-y-3 text-sm text-white/80 pl-3 border-l border-white/10">
<p>Bitcoin Knots/Core sync is significantly faster: containers now use every available core for script verification and have 8 GB of memory so the 4 GB UTXO cache has headroom.</p>
<p>ElectrumX initial indexing is faster too, with CPU caps removed, 4 GB of container memory, and a 3 GB internal cache.</p>
</div>
</div>
<!-- v1.7.46-alpha -->
<div>
<div class="flex items-center gap-2 mb-3">
<span class="text-xs font-mono px-2 py-0.5 rounded bg-orange-500/20 text-orange-300">v1.7.46-alpha</span>
<span class="text-xs text-white/40">Apr 29, 2026</span>
</div>
<div class="space-y-3 text-sm text-white/80 pl-3 border-l border-white/10">
<p>Health monitoring no longer pages auto-restart failures for orphaned containers left behind after Bitcoin variant switches.</p>
<p>Apps no longer disappear from My Apps when an install fails, and multi-image stack pull progress now advances during the download phase instead of sticking at 20%.</p>
<p>Several docker.io images were mirrored into Archipelago registries, reducing first-boot install dependency on Docker Hub.</p>
</div>
</div>
<!-- v1.7.45-alpha -->
<div>
<div class="flex items-center gap-2 mb-3">
<span class="text-xs font-mono px-2 py-0.5 rounded bg-orange-500/20 text-orange-300">v1.7.45-alpha</span>
<span class="text-xs text-white/40">Apr 29, 2026</span>
</div>
<div class="space-y-3 text-sm text-white/80 pl-3 border-l border-white/10">
<p>Bitcoin RPC auth is durable across container restart, image update, and reboot. The dashboard no longer fails because registry-pulled images shipped stale baked-in credentials.</p>
<p>Multi-container apps show real install progress, app cards stay visible while containers are being created, IndeedHub installs cleanly on fresh nodes, and Tailscale install no longer fails from a malformed command.</p>
<p>The installer now allocates swap on the encrypted data partition, capped at 8 GB, so image builds and memory spikes are less likely to OOM the system.</p>
</div>
</div>
<!-- v1.7.44-alpha -->
<div>
<div class="flex items-center gap-2 mb-3">
<span class="text-xs font-mono px-2 py-0.5 rounded bg-orange-500/20 text-orange-300">v1.7.44-alpha</span>
<span class="text-xs text-white/40">Apr 28, 2026</span>
</div>
<div class="space-y-3 text-sm text-white/80 pl-3 border-l border-white/10">
<p>Container orchestration migration and release hardening continued, including OTA synchronization for rebuilt UI containers and aligned LND UI port handling across runtime specs.</p>
<p>Release packaging moved toward tarball-only artifacts with archived ISO build recipes, keeping update payloads focused on the files existing nodes need.</p>
</div>
</div>
<!-- v1.7.43-alpha -->
<div>
<div class="flex items-center gap-2 mb-3">
+8 -27
View File
@@ -71,22 +71,18 @@
</template>
<script setup lang="ts">
import { ref, computed, onMounted, onBeforeUnmount } from 'vue'
import { computed, onMounted, onBeforeUnmount } from 'vue'
import { RouterLink } from 'vue-router'
import { rpcClient } from '@/api/rpc-client'
import { useHomeStatusStore } from '@/stores/homeStatus'
const cpuPercent = ref(0)
const memUsed = ref(0)
const memTotal = ref(0)
const diskUsed = ref(0)
const diskTotal = ref(0)
const uptimeSecs = ref(0)
const homeStatus = useHomeStatusStore()
const memPercent = computed(() => memTotal.value > 0 ? (memUsed.value / memTotal.value) * 100 : 0)
const diskPercent = computed(() => diskTotal.value > 0 ? (diskUsed.value / diskTotal.value) * 100 : 0)
const cpuPercent = computed(() => homeStatus.stats.cpuPercent)
const memPercent = computed(() => homeStatus.stats.memPercent)
const diskPercent = computed(() => homeStatus.stats.diskPercent)
const uptimeDisplay = computed(() => {
const s = uptimeSecs.value
const s = homeStatus.stats.uptimeSecs
if (s === 0) return '--'
const days = Math.floor(s / 86400)
const hours = Math.floor((s % 86400) / 3600)
@@ -102,22 +98,7 @@ function barColor(pct: number): string {
}
async function loadStats() {
try {
const res = await rpcClient.call<{
cpu_usage_percent: number
mem_used_bytes: number
mem_total_bytes: number
disk_used_bytes: number
disk_total_bytes: number
uptime_secs: number
}>({ method: 'system.stats' })
cpuPercent.value = res.cpu_usage_percent
memUsed.value = res.mem_used_bytes
memTotal.value = res.mem_total_bytes
diskUsed.value = res.disk_used_bytes
diskTotal.value = res.disk_total_bytes
uptimeSecs.value = res.uptime_secs
} catch { /* unavailable */ }
await homeStatus.refreshSystemStats()
}
let refreshInterval: ReturnType<typeof setInterval> | null = null
+1
View File
@@ -38,6 +38,7 @@ export default defineConfig({
},
workbox: {
navigateFallbackDenylist: [/^\/app\//, /^\/rpc\//, /^\/ws/, /^\/aiui\//],
cleanupOutdatedCaches: true,
globPatterns: ['**/*.{js,css,html,ico,png,svg,jpg,jpeg,mp4,webp}'],
globIgnores: [
'**/*-backup-*.mp4',
+16 -17
View File
@@ -1,28 +1,27 @@
{
"version": "1.7.56-alpha",
"release_date": "2026-05-14",
"version": "1.7.80-alpha",
"release_date": "2026-05-21",
"changelog": [
"Health notifications now clear when an app is no longer unhealthy, including stale alerts for removed containers such as Portainer.",
"Quadlet environment values with spaces or shell metacharacters are quoted consistently, preventing env drift recreate loops for apps like nostr-rs-relay and Grafana.",
"Boot/bootstrap reconcile avoids restarting running Bitcoin containers while repairing RPC config, preserving IBD progress on active nodes.",
"Exit code 137 is labeled as SIGKILL instead of assuming OOM, avoiding false OOM alerts for orchestrator-managed recreates."
"Saleor storefront proxying now falls back to the direct request scheme when no forwarded protocol header is present, fixing direct `http://node:9011` launches that could generate an invalid same-origin GraphQL URL.",
"The Saleor storefront release path keeps public proxy support intact by still honoring forwarded HTTPS headers for Nginx Proxy Manager domains while repairing local/direct port launches.",
"Validation passed with `cargo fmt --check` and `cargo check` for the Archipelago backend before release staging."
],
"components": [
{
"name": "archipelago",
"current_version": "1.7.56-alpha",
"new_version": "1.7.56-alpha",
"download_url": "http://146.59.87.168:3000/lfg2025/archy/releases/download/v1.7.56-alpha/archipelago",
"sha256": "11217a0e40c1704ee9f5bbcdeb59e38c9efc5e00ea7c673c5c3f0f69de720109",
"size_bytes": 42647960
"current_version": "1.7.80-alpha",
"new_version": "1.7.80-alpha",
"download_url": "http://146.59.87.168:3000/lfg2025/archy/releases/download/v1.7.80-alpha/archipelago",
"sha256": "6e2d796e9395b578944991970b7f07d27b4185d3aae2beb0af10e47d212f5ea7",
"size_bytes": 43071632
},
{
"name": "archipelago-frontend-1.7.56-alpha.tar.gz",
"current_version": "1.7.56-alpha",
"new_version": "1.7.56-alpha",
"download_url": "http://146.59.87.168:3000/lfg2025/archy/releases/download/v1.7.56-alpha/archipelago-frontend-1.7.56-alpha.tar.gz",
"sha256": "9cee503e260891267fed33e4c97f067a4a592b10695ecea4cbf730d517ff6c8b",
"size_bytes": 166493666
"name": "archipelago-frontend-1.7.80-alpha.tar.gz",
"current_version": "1.7.80-alpha",
"new_version": "1.7.80-alpha",
"download_url": "http://146.59.87.168:3000/lfg2025/archy/releases/download/v1.7.80-alpha/archipelago-frontend-1.7.80-alpha.tar.gz",
"sha256": "65a75fbe732635ebc658eba1f6d36a7e158de3b88ef49db0a23202d0564e30c0",
"size_bytes": 166481159
}
]
}
+16 -17
View File
@@ -1,28 +1,27 @@
{
"version": "1.7.56-alpha",
"release_date": "2026-05-14",
"version": "1.7.80-alpha",
"release_date": "2026-05-21",
"changelog": [
"Health notifications now clear when an app is no longer unhealthy, including stale alerts for removed containers such as Portainer.",
"Quadlet environment values with spaces or shell metacharacters are quoted consistently, preventing env drift recreate loops for apps like nostr-rs-relay and Grafana.",
"Boot/bootstrap reconcile avoids restarting running Bitcoin containers while repairing RPC config, preserving IBD progress on active nodes.",
"Exit code 137 is labeled as SIGKILL instead of assuming OOM, avoiding false OOM alerts for orchestrator-managed recreates."
"Saleor storefront proxying now falls back to the direct request scheme when no forwarded protocol header is present, fixing direct `http://node:9011` launches that could generate an invalid same-origin GraphQL URL.",
"The Saleor storefront release path keeps public proxy support intact by still honoring forwarded HTTPS headers for Nginx Proxy Manager domains while repairing local/direct port launches.",
"Validation passed with `cargo fmt --check` and `cargo check` for the Archipelago backend before release staging."
],
"components": [
{
"name": "archipelago",
"current_version": "1.7.56-alpha",
"new_version": "1.7.56-alpha",
"download_url": "http://146.59.87.168:3000/lfg2025/archy/releases/download/v1.7.56-alpha/archipelago",
"sha256": "11217a0e40c1704ee9f5bbcdeb59e38c9efc5e00ea7c673c5c3f0f69de720109",
"size_bytes": 42647960
"current_version": "1.7.80-alpha",
"new_version": "1.7.80-alpha",
"download_url": "http://146.59.87.168:3000/lfg2025/archy/releases/download/v1.7.80-alpha/archipelago",
"sha256": "6e2d796e9395b578944991970b7f07d27b4185d3aae2beb0af10e47d212f5ea7",
"size_bytes": 43071632
},
{
"name": "archipelago-frontend-1.7.56-alpha.tar.gz",
"current_version": "1.7.56-alpha",
"new_version": "1.7.56-alpha",
"download_url": "http://146.59.87.168:3000/lfg2025/archy/releases/download/v1.7.56-alpha/archipelago-frontend-1.7.56-alpha.tar.gz",
"sha256": "9cee503e260891267fed33e4c97f067a4a592b10695ecea4cbf730d517ff6c8b",
"size_bytes": 166493666
"name": "archipelago-frontend-1.7.80-alpha.tar.gz",
"current_version": "1.7.80-alpha",
"new_version": "1.7.80-alpha",
"download_url": "http://146.59.87.168:3000/lfg2025/archy/releases/download/v1.7.80-alpha/archipelago-frontend-1.7.80-alpha.tar.gz",
"sha256": "65a75fbe732635ebc658eba1f6d36a7e158de3b88ef49db0a23202d0564e30c0",
"size_bytes": 166481159
}
]
}
+37 -7
View File
@@ -1,7 +1,7 @@
#!/bin/bash
# Validate releases/manifest.json:
# - version matches core/archipelago/Cargo.toml
# - changelog is non-empty (release notes are mandatory per product policy)
# - changelog contains curated release notes, not raw git log output
# - every component's download_url exists on disk and matches sha256/size
#
# Run on every push from CI, and also locally before publishing a release:
@@ -30,12 +30,42 @@ if [ "$MANIFEST_VERSION" != "$CARGO_VERSION" ]; then
fi
ok "version matches: $MANIFEST_VERSION"
# Release notes mandatory — ships stuff nobody can read otherwise.
CHANGELOG_COUNT=$(python3 -c "import json; print(len(json.load(open('$MANIFEST'))['changelog']))")
if [ "$CHANGELOG_COUNT" -eq 0 ]; then
fail "changelog is empty — every release MUST have release notes"
fi
ok "changelog has $CHANGELOG_COUNT lines"
# Release notes mandatory — ships stuff nobody can read otherwise. Require
# curated user/operator-facing notes and reject raw `git log --oneline` output.
NOTES_CHECK=$(python3 - "$MANIFEST" <<'PY'
import json
import re
import sys
manifest = sys.argv[1]
notes = json.load(open(manifest)).get("changelog", [])
if len(notes) < 3:
print(f"FAIL: changelog has {len(notes)} lines; need at least 3 curated release-note bullets")
sys.exit(0)
bad = []
for note in notes:
text = str(note).strip()
if not text:
bad.append("empty release-note entry")
if len(text) < 40:
bad.append(f"too short: {text!r}")
if re.match(r"^[0-9a-f]{7,40}\s+", text):
bad.append(f"raw commit hash entry: {text!r}")
if re.match(r"^(feat|fix|chore|docs|test|refactor|build|ci|perf)(\([^)]+\))?:\s", text):
bad.append(f"raw conventional-commit entry: {text!r}")
if bad:
print("FAIL: release notes must be curated user/operator-facing bullets, not raw git log lines:\n" + "\n".join(bad))
else:
print(f"OK: changelog has {len(notes)} curated lines")
PY
)
case "$NOTES_CHECK" in
OK:*) ok "${NOTES_CHECK#OK: }" ;;
FAIL:*) fail "${NOTES_CHECK#FAIL: }" ;;
*) fail "unexpected release-note validation output: $NOTES_CHECK" ;;
esac
# Each component: the artifact on disk under releases/v<version>/ must match
# the declared sha256 and size_bytes.
+17
View File
@@ -510,6 +510,22 @@ fix_missing_rootless_ports() {
$fixed && return 0 || return 1
}
# ── Fix 11: Nginx Proxy Manager public host bridge ───────────
# Host nginx owns public 80/443 on Archipelago. Mirror NPM proxy hosts into
# host nginx so issued certs and public traffic reach the intended upstreams.
fix_npm_public_hosts() {
local script="/opt/archipelago/scripts/sync-npm-public-hosts.sh"
[ -x "$script" ] || script="$SCRIPT_DIR/sync-npm-public-hosts.sh"
[ -x "$script" ] || return 1
[ -f /var/lib/archipelago/nginx-proxy-manager/data/database.sqlite ] || return 1
if "$script" >/dev/null 2>&1; then
log "Synced Nginx Proxy Manager public hosts into host nginx"
return 0
fi
return 1
}
# ── Main ─────────────────────────────────────────────────────
# If remote host provided, run via SSH
@@ -539,6 +555,7 @@ run_fix "exit-127" fix_exit_127
run_fix "netns-egress" fix_rootless_netns_egress
run_fix "stopped-core" fix_stopped_core_containers
run_fix "rootless-ports" fix_missing_rootless_ports
run_fix "npm-public-hosts" fix_npm_public_hosts
echo ""
if [ $FIXES_APPLIED -gt 0 ]; then
+14 -4
View File
@@ -99,6 +99,10 @@ reset_spec() {
SPEC_ENTRYPOINT=""
}
if ! declare -F alloc_port >/dev/null 2>&1; then
alloc_port() { printf '%s' "$2"; }
fi
# ── Tier 0: Databases ────────────────────────────────────────────────
load_spec_archy-mempool-db() {
@@ -171,9 +175,11 @@ load_spec_bitcoin-knots() {
SPEC_TIER="1"
SPEC_DATA_DIR="/var/lib/archipelago/bitcoin"
SPEC_DATA_UID="100101:100101"
local btc_dbcache=4096
[ "${LOW_MEM:-false}" = "true" ] && btc_dbcache=2048
# Dynamic: prune on small disk
if [ "${DISK_GB:-0}" -lt 1000 ]; then
SPEC_CUSTOM_ARGS="-server=1 -prune=550 -rpcallowip=0.0.0.0/0 -rpcbind=0.0.0.0:8332 -listen=1 -bind=0.0.0.0:8333 -dbcache=2048 -par=0 -maxconnections=125"
SPEC_CUSTOM_ARGS="-server=1 -prune=550 -rpcallowip=0.0.0.0/0 -rpcbind=0.0.0.0:8332 -listen=1 -bind=0.0.0.0:8333 -dbcache=${btc_dbcache} -par=0 -maxconnections=125"
else
SPEC_CUSTOM_ARGS="-server=1 -txindex=1 -rpcallowip=0.0.0.0/0 -rpcbind=0.0.0.0:8332 -listen=1 -bind=0.0.0.0:8333 -dbcache=4096 -par=0 -maxconnections=125"
fi
@@ -267,7 +273,7 @@ load_spec_btcpay-server() {
SPEC_PORTS="23000:49392"
SPEC_VOLUMES="/var/lib/archipelago/btcpay:/datadir"
SPEC_MEMORY="$(mem_limit btcpay-server)"
SPEC_HEALTH_CMD="curl -sf http://localhost:49392/ || exit 1"
SPEC_HEALTH_CMD="bash -ec '</dev/tcp/127.0.0.1/49392'"
SPEC_ENV="ASPNETCORE_URLS=http://0.0.0.0:49392 BTCPAY_PROTOCOL=http BTCPAY_HOST=$HOST_IP:23000 BTCPAY_CHAINS=btc BTCPAY_BTCEXPLORERURL=http://archy-nbxplorer:32838 BTCPAY_BTCRPCURL=http://bitcoin-knots:8332 BTCPAY_BTCRPCUSER=$BITCOIN_RPC_USER BTCPAY_BTCRPCPASSWORD=$BITCOIN_RPC_PASS BTCPAY_POSTGRES=Username=btcpay;Password=$BTCPAY_DB_PASS;Host=archy-btcpay-db;Port=5432;Database=btcpay"
SPEC_TIER="2"
SPEC_DATA_DIR="/var/lib/archipelago/btcpay"
@@ -493,13 +499,17 @@ load_spec_nginx-proxy-manager() {
reset_spec
SPEC_NAME="nginx-proxy-manager"
SPEC_IMAGE="${NPM_IMAGE}"
SPEC_PORTS="81:81 8084:80 8444:443"
local admin_port http_port https_port
admin_port=$(alloc_port nginx-proxy-manager 8081 81)
http_port=$(alloc_port nginx-proxy-manager-http 8084 80)
https_port=$(alloc_port nginx-proxy-manager-https 8444 443)
SPEC_PORTS="$admin_port:81 $http_port:80 $https_port:443"
SPEC_VOLUMES="/var/lib/archipelago/nginx-proxy-manager/data:/data /var/lib/archipelago/nginx-proxy-manager/letsencrypt:/etc/letsencrypt"
SPEC_MEMORY="$(mem_limit nginx-proxy-manager)"
SPEC_HEALTH_CMD="curl -sf http://localhost:81/ || exit 1"
SPEC_TIER="3"
SPEC_DATA_DIR="/var/lib/archipelago/nginx-proxy-manager"
SPEC_CAPS="CHOWN SETUID SETGID NET_BIND_SERVICE"
SPEC_CAPS="CHOWN FOWNER SETUID SETGID DAC_OVERRIDE NET_BIND_SERVICE"
SPEC_OPTIONAL="true"
}
+44 -47
View File
@@ -28,11 +28,12 @@ for arg in "$@"; do
echo "Steps performed:"
echo " 1. Validate version format (SemVer)"
echo " 2. Bump version in Cargo.toml and package.json"
echo " 3. Build frontend"
echo " 4. Generate changelog from git log"
echo " 5. Create release manifest"
echo " 6. Commit version bump"
echo " 7. Create git tag v{VERSION}"
echo " 3. Build backend"
echo " 4. Build frontend"
echo " 5. Generate changelog from git log"
echo " 6. Create release manifest"
echo " 7. Commit version bump"
echo " 8. Create git tag v{VERSION}"
echo ""
echo "Options:"
echo " --dry-run Show what would be done without making changes"
@@ -95,11 +96,12 @@ if $DRY_RUN; then
echo "[DRY RUN] Would perform the following:"
echo " 1. Update core/archipelago/Cargo.toml version to $VERSION"
echo " 2. Update neode-ui/package.json version to $VERSION"
echo " 3. Build frontend (npm run build)"
echo " 4. Generate changelog from git log since v${CURRENT_CARGO_VERSION}"
echo " 5. Create release manifest"
echo " 6. Commit: 'chore: release v${VERSION}'"
echo " 7. Tag: v${VERSION}"
echo " 3. Build backend (cargo build --release -p archipelago)"
echo " 4. Build frontend (npm run build)"
echo " 5. Generate changelog from git log since v${CURRENT_CARGO_VERSION}"
echo " 6. Create release manifest"
echo " 7. Commit: 'chore: release v${VERSION}'"
echo " 8. Tag: v${VERSION}"
echo ""
echo "After this script, you would:"
echo " - Push: git push && git push --tags"
@@ -123,49 +125,46 @@ cd "$PROJECT_ROOT/neode-ui"
npm version "$VERSION" --no-git-tag-version --allow-same-version 2>/dev/null || true
cd "$PROJECT_ROOT"
echo "[3/7] Building frontend..."
echo "[3/8] Building backend..."
cd "$PROJECT_ROOT/core"
cargo build --release -p archipelago
cd "$PROJECT_ROOT"
echo "[4/8] Building frontend..."
cd "$PROJECT_ROOT/neode-ui"
npm run build 2>&1 | tail -3
cd "$PROJECT_ROOT"
echo "[4/7] Generating changelog..."
# Get commits since last tag (or last 50 if no tags)
LAST_TAG=$(git -C "$PROJECT_ROOT" describe --tags --abbrev=0 2>/dev/null || echo "")
if [ -n "$LAST_TAG" ]; then
GIT_LOG=$(git -C "$PROJECT_ROOT" log "$LAST_TAG"..HEAD --oneline --no-merges 2>/dev/null || echo "")
else
GIT_LOG=$(git -C "$PROJECT_ROOT" log --oneline --no-merges -50 2>/dev/null || echo "")
fi
echo "[5/8] Validating curated changelog..."
# Create/update CHANGELOG.md entry
CHANGELOG_FILE="$PROJECT_ROOT/CHANGELOG.md"
RELEASE_DATE=$(date +%Y-%m-%d)
CHANGELOG_ENTRY="## v${VERSION} (${RELEASE_DATE})
$GIT_LOG
"
if [ -f "$CHANGELOG_FILE" ]; then
# Prepend new entry after the first line (title)
EXISTING=$(cat "$CHANGELOG_FILE")
FIRST_LINE=$(head -1 "$CHANGELOG_FILE")
REST=$(tail -n +2 "$CHANGELOG_FILE")
echo "$FIRST_LINE" > "$CHANGELOG_FILE"
echo "" >> "$CHANGELOG_FILE"
echo "$CHANGELOG_ENTRY" >> "$CHANGELOG_FILE"
echo "$REST" >> "$CHANGELOG_FILE"
else
echo "# Changelog" > "$CHANGELOG_FILE"
echo "" >> "$CHANGELOG_FILE"
echo "$CHANGELOG_ENTRY" >> "$CHANGELOG_FILE"
if [ ! -f "$CHANGELOG_FILE" ] || ! grep -q "^## v${VERSION} (" "$CHANGELOG_FILE"; then
echo "Error: CHANGELOG.md must already contain curated notes for v${VERSION}."
echo "Add a section like:"
echo ""
echo "## v${VERSION} (${RELEASE_DATE})"
echo ""
echo "- User/operator-facing change ..."
echo "- Another concrete change ..."
echo "- Validation or operational note ..."
exit 1
fi
echo "[5/7] Creating release manifest..."
echo "[6/8] Creating release manifest..."
mkdir -p "$PROJECT_ROOT/releases"
"$SCRIPT_DIR/create-release-manifest.sh" --version "$VERSION" --date "$RELEASE_DATE" --output "$PROJECT_ROOT/releases/manifest.json" 2>&1 | grep -v "^$"
cp "$PROJECT_ROOT/releases/manifest.json" "$PROJECT_ROOT/release-manifest.json"
echo "[6/7] Committing version bump..."
echo "[6b/8] Staging release artifacts for validation..."
VERSION_DIR="$PROJECT_ROOT/releases/v${VERSION}"
FRONTEND_ARCHIVE="/tmp/archipelago-frontend-${VERSION}.tar.gz"
mkdir -p "$VERSION_DIR"
install -m 0755 "$PROJECT_ROOT/core/target/release/archipelago" "$VERSION_DIR/archipelago"
install -m 0644 "$FRONTEND_ARCHIVE" "$VERSION_DIR/archipelago-frontend-${VERSION}.tar.gz"
"$SCRIPT_DIR/check-release-manifest.sh"
echo "[7/8] Committing version bump..."
git -C "$PROJECT_ROOT" add \
core/archipelago/Cargo.toml \
neode-ui/package.json \
@@ -177,7 +176,7 @@ git -C "$PROJECT_ROOT" add \
git -C "$PROJECT_ROOT" commit -m "chore: release v${VERSION}"
echo "[7/7] Creating git tag..."
echo "[8/8] Creating git tag..."
git -C "$PROJECT_ROOT" tag -a "v${VERSION}" -m "Release v${VERSION}"
echo ""
@@ -188,15 +187,13 @@ echo " - Version bumped in Cargo.toml and package.json"
echo " - Changelog updated in CHANGELOG.md"
echo " - Release manifest: releases/manifest.json"
echo " - Release manifest copy: release-manifest.json"
echo " - Staged artifacts: releases/v${VERSION}/"
echo " - Git tag: v${VERSION}"
echo ""
echo "Next steps:"
echo " 1. Review: git log --oneline -5"
echo " 2. Create Gitea release v${VERSION} and upload artifacts:"
echo " archipelago"
echo " archipelago-frontend-${VERSION}.tar.gz"
echo " 3. Push to gitea-local and gitea-vps2:"
echo " git push gitea-local main --tags && git push gitea-vps2 main --tags"
echo " 4. Verify manifest is live on both mirrors:"
echo " 2. Publish commits, tag, artifacts, and verify download URLs:"
echo " scripts/publish-release-assets.sh ${VERSION} gitea-vps2"
echo " 3. Verify manifest is live on both mirrors:"
echo " curl -fsS http://localhost:3000/lfg2025/archy/raw/branch/main/releases/manifest.json"
echo " curl -fsS http://146.59.87.168:3000/lfg2025/archy/raw/branch/main/releases/manifest.json"
+4 -3
View File
@@ -625,7 +625,7 @@ deploy_node() {
echo ' Creating btcpay-server...'
sudo mkdir -p /var/lib/archipelago/btcpay
\$DOCKER run -d --name btcpay-server --restart unless-stopped \$NET_OPT \
--health-cmd 'curl -sf http://localhost:49392/' --health-interval=30s --health-timeout=10s --health-retries=3 \
--health-cmd "bash -ec '</dev/tcp/127.0.0.1/49392'" --health-interval=30s --health-timeout=10s --health-retries=3 \
--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 \
@@ -909,12 +909,13 @@ LNDCONF
if \$DOCKER ps -a --format '{{.Names}}' 2>/dev/null | grep -qx nginx-proxy-manager; then
\$DOCKER start nginx-proxy-manager 2>/dev/null || true
else
sudo mkdir -p /var/lib/archipelago/nginx-proxy-manager/data /var/lib/archipelago/nginx-proxy-manager/letsencrypt
sudo mkdir -p /var/lib/archipelago/nginx-proxy-manager/data/letsencrypt-acme-challenge/.well-known/acme-challenge /var/lib/archipelago/nginx-proxy-manager/letsencrypt
sudo chown -R 1000:1000 /var/lib/archipelago/nginx-proxy-manager 2>/dev/null || true
\$DOCKER run -d --name nginx-proxy-manager --restart unless-stopped \
--health-cmd 'curl -sf http://localhost:81/' --health-interval=30s --health-timeout=5s --health-retries=3 \
--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 8444:443 \
-p 8081:81 -p 8084:80 -p 8444:443 \
-v /var/lib/archipelago/nginx-proxy-manager/data:/data \
-v /var/lib/archipelago/nginx-proxy-manager/letsencrypt:/etc/letsencrypt \
$NPM_IMAGE
+52 -9
View File
@@ -48,6 +48,37 @@ SCRIPT_DIR_FBC="$(cd "$(dirname "$0")" && pwd)"
# as root (rootful podman), the backend can't see them at all.
DOCKER="runuser -u archipelago -- env XDG_RUNTIME_DIR=/run/user/$(id -u archipelago) podman"
PORT_ALLOC_FILE="/var/lib/archipelago/port-allocations.env"
mkdir -p /var/lib/archipelago 2>/dev/null || true
[ -f "$PORT_ALLOC_FILE" ] && . "$PORT_ALLOC_FILE"
port_available() {
local port="$1"
ss -ltn 2>/dev/null | awk -v p=":$port" '$4 == p || $4 ~ p "$" { found=1 } END { exit found ? 1 : 0 }'
}
alloc_port() {
local key="$1" preferred="$2" var="PORT_${key//[^A-Za-z0-9]/_}" cur=""
eval "cur=\${$var:-}"
if [ -n "$cur" ] && port_available "$cur"; then
printf '%s' "$cur"
return
fi
if port_available "$preferred"; then
cur="$preferred"
else
cur=""
for p in $(seq 8085 9999); do
if port_available "$p"; then cur="$p"; break; fi
done
fi
[ -n "$cur" ] || cur="$preferred"
if ! grep -q "^$var=" "$PORT_ALLOC_FILE" 2>/dev/null; then
printf '%s=%s\n' "$var" "$cur" >> "$PORT_ALLOC_FILE"
fi
printf '%s' "$cur"
}
# UNBUNDLED mode: only create FileBrowser, skip all other containers.
# Users install apps on-demand from the Marketplace.
UNBUNDLED_MARKER="/opt/archipelago/.unbundled"
@@ -450,10 +481,13 @@ log "archy-net network ready"
log "Fixing rootless podman UID mapping..."
# Containers running as root (UID 0 → host UID 100000)
for dir in lnd electrumx btcpay nbxplorer jellyfin vaultwarden \
home-assistant fedimint fedimint-gateway photoprism ollama filebrowser \
nextcloud uptime-kuma nginx-proxy-manager portainer nostr-rs-relay; do
home-assistant fedimint fedimint-gateway photoprism ollama filebrowser \
nextcloud uptime-kuma portainer nostr-rs-relay; do
[ -d "/var/lib/archipelago/$dir" ] && chown -R 100000:100000 "/var/lib/archipelago/$dir" 2>/dev/null
done
# Nginx Proxy Manager runs as root in the rootless user namespace, which maps to
# the archipelago user on host bind mounts. Keep certbot's webroot writable.
[ -d /var/lib/archipelago/nginx-proxy-manager ] && chown -R 1000:1000 /var/lib/archipelago/nginx-proxy-manager 2>/dev/null
# 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
@@ -477,13 +511,13 @@ LOW_MEM=false
mem_limit() {
case "$1" in
bitcoin-knots) $LOW_MEM && echo "2g" || echo "4g";;
bitcoin|bitcoin-core|bitcoin-knots) $LOW_MEM && echo "4g" || echo "8g";;
cryptpad) echo "512m";;
ollama) $LOW_MEM && echo "1g" || echo "4g";;
lnd) echo "512m";;
electrumx) echo "1g";;
electrumx|mempool-electrs|electrs) echo "4g";;
nextcloud) echo "1g";;
btcpay-server) echo "1g";;
btcpay-server|btcpayserver) echo "1g";;
homeassistant) echo "512m";;
fedimint) echo "512m";;
fedimint-gateway) echo "512m";;
@@ -557,7 +591,11 @@ if ! $DOCKER ps --format '{{.Names}}' 2>/dev/null | grep -qE 'bitcoin-knots|arch
[ -z "$DISK_GB" ] && DISK_GB=$(df --output=size -BG / 2>/dev/null | tail -1 | tr -dc '0-9')
if [ "${DISK_GB:-0}" -lt 1000 ]; then
BTC_EXTRA_ARGS="-prune=550"
BTC_DBCACHE=2048
if [ "$LOW_MEM" = "true" ]; then
BTC_DBCACHE=2048
else
BTC_DBCACHE=4096
fi
log " Small disk (${DISK_GB}GB) — enabling pruning"
else
BTC_EXTRA_ARGS="-txindex=1"
@@ -840,7 +878,7 @@ if ! $DOCKER ps --format '{{.Names}}' 2>/dev/null | grep -q btcpay-server; then
log "Creating BTCPay Server..."
mkdir -p /var/lib/archipelago/btcpay/Main
$DOCKER run -d --name btcpay-server --restart unless-stopped \
--health-cmd="curl -sf http://localhost:49392/ || exit 1" --health-interval=120s --health-timeout=5s --health-retries=3 \
--health-cmd="bash -ec '</dev/tcp/127.0.0.1/49392'" --health-interval=120s --health-timeout=5s --health-retries=3 \
--memory=$(mem_limit btcpay-server) --network archy-net --network-alias btcpay-server \
--cap-drop ALL --cap-add CHOWN --cap-add SETUID --cap-add SETGID --cap-add DAC_OVERRIDE \
--security-opt no-new-privileges:true \
@@ -1146,12 +1184,17 @@ track_container "filebrowser"
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
mkdir -p /var/lib/archipelago/nginx-proxy-manager/data/letsencrypt-acme-challenge/.well-known/acme-challenge
chown -R 1000:1000 /var/lib/archipelago/nginx-proxy-manager 2>/dev/null || true
NPM_ADMIN_PORT=$(alloc_port nginx-proxy-manager 8081)
NPM_HTTP_PORT=$(alloc_port nginx-proxy-manager-http 8084)
NPM_HTTPS_PORT=$(alloc_port nginx-proxy-manager-https 8444)
$DOCKER run -d --name nginx-proxy-manager --restart unless-stopped \
--health-cmd="curl -sf http://localhost:81/ || exit 1" --health-interval=120s --health-timeout=5s --health-retries=3 \
--memory=$(mem_limit nginx-proxy-manager) \
--cap-drop ALL --cap-add CHOWN --cap-add SETUID --cap-add SETGID --cap-add NET_BIND_SERVICE \
--cap-drop ALL --cap-add CHOWN --cap-add FOWNER --cap-add SETUID --cap-add SETGID --cap-add DAC_OVERRIDE --cap-add NET_BIND_SERVICE \
--security-opt no-new-privileges:true \
-p 81:81 -p 8084:80 -p 8444:443 \
-p ${NPM_ADMIN_PORT}:81 -p ${NPM_HTTP_PORT}:80 -p ${NPM_HTTPS_PORT}:443 \
-v /var/lib/archipelago/nginx-proxy-manager/data:/data \
-v /var/lib/archipelago/nginx-proxy-manager/letsencrypt:/etc/letsencrypt \
"${NPM_IMAGE}" 2>>"$LOG" || true
+12 -1
View File
@@ -24,7 +24,7 @@ MEMPOOL_WEB_IMAGE="$ARCHY_REGISTRY/mempool-frontend:v3.0.0"
MARIADB_IMAGE="$ARCHY_REGISTRY/mariadb:11.4.10"
# BTCPay
BTCPAY_IMAGE="$ARCHY_REGISTRY/btcpayserver:1.13.7"
BTCPAY_IMAGE="docker.io/btcpayserver/btcpayserver:2.3.9"
NBXPLORER_IMAGE="$ARCHY_REGISTRY/nbxplorer:2.6.0"
POSTGRES_IMAGE="$ARCHY_REGISTRY/postgres:15.17"
BTCPAY_POSTGRES_IMAGE="$ARCHY_REGISTRY/postgres:15.17"
@@ -48,9 +48,20 @@ PORTAINER_IMAGE="$ARCHY_REGISTRY/portainer:latest"
# Networking
TAILSCALE_IMAGE="$ARCHY_REGISTRY/tailscale:stable"
NETBIRD_DASHBOARD_IMAGE="docker.io/netbirdio/dashboard:v2.38.0"
NETBIRD_SERVER_IMAGE="docker.io/netbirdio/netbird-server:0.71.2"
NETBIRD_PROXY_IMAGE="docker.io/library/nginx:1.27-alpine"
ALPINE_TOR_IMAGE="$ARCHY_REGISTRY/alpine-tor:0.4.8.13"
ADGUARDHOME_IMAGE="$ARCHY_REGISTRY/adguardhome:v0.107.55"
# Saleor stack
SALEOR_API_IMAGE="ghcr.io/saleor/saleor:3.23"
SALEOR_DASHBOARD_IMAGE="ghcr.io/saleor/saleor-dashboard:3.23"
SALEOR_POSTGRES_IMAGE="docker.io/library/postgres:15-alpine"
SALEOR_VALKEY_IMAGE="docker.io/valkey/valkey:8.1-alpine"
SALEOR_JAEGER_IMAGE="docker.io/jaegertracing/jaeger:latest"
SALEOR_MAILPIT_IMAGE="docker.io/axllent/mailpit:latest"
# Fedimint
FEDIMINT_IMAGE="$ARCHY_REGISTRY/fedimintd:v0.10.0"
FEDIMINT_GATEWAY_IMAGE="$ARCHY_REGISTRY/gatewayd:v0.10.0"
+4 -4
View File
@@ -96,17 +96,17 @@ mem_limit() {
return
fi
# Built-in defaults (keep in sync with first-boot-containers.sh)
# Built-in defaults (keep in sync with first-boot-containers.sh and Rust package config)
local low="${LOW_MEM:-false}"
case "$name" in
bitcoin-knots) $low && echo "1g" || echo "2g" ;;
bitcoin|bitcoin-core|bitcoin-knots) $low && echo "4g" || echo "8g" ;;
onlyoffice) $low && echo "1g" || echo "2g" ;;
ollama) $low && echo "1g" || echo "4g" ;;
lnd) echo "512m" ;;
electrumx) echo "1g" ;;
electrumx|mempool-electrs|electrs) echo "4g" ;;
nextcloud) echo "1g" ;;
immich_server) echo "1g" ;;
btcpay-server) echo "1g" ;;
btcpay-server|btcpayserver) echo "1g" ;;
homeassistant) echo "512m" ;;
fedimint) echo "512m" ;;
fedimint-gateway) echo "512m" ;;
+1 -1
View File
@@ -204,7 +204,7 @@ location /app/electrs-ui/ {
proxy_hide_header Content-Security-Policy;
}
location /app/nginx-proxy-manager/ {
proxy_pass http://127.0.0.1:81/;
proxy_pass http://127.0.0.1:8081/;
proxy_http_version 1.1;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
+102
View File
@@ -0,0 +1,102 @@
#!/usr/bin/env bash
# Publish an Archipelago OTA release to a Gitea remote and verify downloads.
set -euo pipefail
VERSION="${1:-}"
REMOTE="${2:-gitea-vps2}"
if [ -z "$VERSION" ]; then
echo "Usage: $0 VERSION [remote]"
exit 1
fi
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
PROJECT_ROOT="$(cd "$SCRIPT_DIR/.." && pwd)"
VERSION_DIR="$PROJECT_ROOT/releases/v${VERSION}"
BACKEND="$VERSION_DIR/archipelago"
FRONTEND="$VERSION_DIR/archipelago-frontend-${VERSION}.tar.gz"
fail() { echo "Error: $*" >&2; exit 1; }
[ -f "$PROJECT_ROOT/releases/manifest.json" ] || fail "releases/manifest.json missing"
[ -f "$BACKEND" ] || fail "backend artifact missing: $BACKEND"
[ -f "$FRONTEND" ] || fail "frontend artifact missing: $FRONTEND"
"$SCRIPT_DIR/check-release-manifest.sh"
remote_url=$(git -C "$PROJECT_ROOT" remote get-url "$REMOTE")
case "$remote_url" in
http://*@*) ;;
*) fail "$REMOTE must be an authenticated http:// Gitea remote URL for API uploads" ;;
esac
auth=${remote_url#http://}
auth=${auth%@*}
host_path=${remote_url#http://$auth@}
host=${host_path%%/*}
repo_path=${host_path#*/}
repo_path=${repo_path%.git}
api="http://$host/api/v1/repos/$repo_path"
release_url="$api/releases/tags/v${VERSION}"
echo "Pushing main and v${VERSION} to $REMOTE..."
git -C "$PROJECT_ROOT" push "$REMOTE" main "refs/tags/v${VERSION}"
release_json=$(curl -fsS -u "$auth" "$release_url" || true)
if [ -z "$release_json" ]; then
echo "Creating Gitea release v${VERSION}..."
release_body=$(python3 - "$VERSION" <<'PY'
import json
import sys
version = sys.argv[1]
print(json.dumps({
"tag_name": f"v{version}",
"target_commitish": "main",
"name": f"v{version}",
"body": f"Archipelago v{version} release artifacts for OTA updates.",
"draft": False,
"prerelease": True,
}))
PY
)
release_json=$(curl -fsS -u "$auth" -H 'Content-Type: application/json' -d "$release_body" "$api/releases")
fi
release_id=$(printf '%s' "$release_json" | python3 -c 'import json,sys; print(json.load(sys.stdin)["id"])')
asset_names=$(curl -fsS -u "$auth" "$api/releases/$release_id/assets" | python3 -c 'import json,sys; print("\n".join(a["name"] for a in json.load(sys.stdin)))')
upload_asset() {
local path="$1"
local name="$2"
if printf '%s\n' "$asset_names" | grep -Fxq "$name"; then
echo "Asset $name already exists; leaving it in place."
return
fi
echo "Uploading $name..."
curl --fail --show-error --silent --http1.1 --connect-timeout 20 --max-time 900 \
-u "$auth" \
-F "attachment=@$path" \
"$api/releases/$release_id/assets?name=$name" >/dev/null
asset_names=$(printf '%s\n%s\n' "$asset_names" "$name")
}
upload_asset "$BACKEND" "archipelago"
upload_asset "$FRONTEND" "archipelago-frontend-${VERSION}.tar.gz"
echo "Verifying public download URLs from manifest..."
python3 - "$PROJECT_ROOT/releases/manifest.json" <<'PY' | while read -r url size; do
import json
import sys
manifest = json.load(open(sys.argv[1]))
for component in manifest["components"]:
print(component["download_url"], component["size_bytes"])
PY
headers=$(curl -fsSI -L --max-time 60 "$url") || fail "download URL failed: $url"
actual_size=$(printf '%s\n' "$headers" | awk 'tolower($1)=="content-length:" { size=$2 } END { gsub("\r", "", size); print size }')
[ "$actual_size" = "$size" ] || fail "download size mismatch for $url (expected $size, got ${actual_size:-missing})"
done
echo "Release v${VERSION} published and verified on $REMOTE."
+69 -1
View File
@@ -63,6 +63,37 @@ header(){ echo -e "\n${BOLD}$*${NC}"; }
source "$SCRIPT_DIR/container-specs.sh" || { echo "Cannot source container-specs.sh"; exit 1; }
detect_environment
PORT_ALLOC_FILE="/var/lib/archipelago/port-allocations.env"
[ -f "$PORT_ALLOC_FILE" ] && . "$PORT_ALLOC_FILE"
port_available() {
local port="$1"
ss -ltn 2>/dev/null | awk -v p=":$port" '$4 == p || $4 ~ p "$" { found=1 } END { exit found ? 1 : 0 }'
}
alloc_port() {
local key="$1" preferred="$2" var="PORT_${key//[^A-Za-z0-9]/_}" cur=""
eval "cur=\${$var:-}"
if [ -n "$cur" ] && port_available "$cur"; then
printf '%s' "$cur"
return
fi
if port_available "$preferred"; then
cur="$preferred"
else
cur=""
for p in $(seq 8085 9999); do
if port_available "$p"; then cur="$p"; break; fi
done
fi
[ -n "$cur" ] || cur="$preferred"
sudo mkdir -p "$(dirname "$PORT_ALLOC_FILE")" 2>/dev/null || true
if ! grep -q "^$var=" "$PORT_ALLOC_FILE" 2>/dev/null; then
printf '%s=%s\n' "$var" "$cur" | sudo tee -a "$PORT_ALLOC_FILE" >/dev/null
fi
printf '%s' "$cur"
}
# ── Podman command ───────────────────────────────────────────────────
# Run as archipelago user — podman sees rootless containers directly.
# Use sudo only for chown/mkdir operations.
@@ -154,6 +185,39 @@ host_port_listening() {
'
}
prepare_bind_source() {
local source="$1"
[ -n "$source" ] || return 0
case "$source" in
/run/user/*/podman/podman.sock)
if [ ! -S "$source" ]; then
local runtime_dir="${source%/podman/podman.sock}"
XDG_RUNTIME_DIR="$runtime_dir" systemctl --user start podman.socket 2>/dev/null || true
for _ in 1 2 3 4 5 6 7 8 9 10; do
[ -S "$source" ] && return 0
sleep 0.25
done
fi
;;
esac
case "$source" in
/var/lib/archipelago/*)
sudo mkdir -p "$source" 2>/dev/null
;;
*)
# Non-data bind mounts can be files/sockets/devices. Creating the full
# path would turn e.g. podman.sock into a directory and break Portainer.
if [ -e "$source" ]; then
return 0
fi
fail "bind source missing: $source"
return 1
;;
esac
}
container_has_mount() {
local name="$1" source="$2" target="$3"
$PODMAN inspect "$name" --format '{{range .Mounts}}{{println .Source "|" .Destination}}{{end}}' 2>/dev/null \
@@ -536,7 +600,11 @@ reconcile() {
else
for v in $SPEC_VOLUMES; do
local host_dir="${v%%:*}"
[ -n "$host_dir" ] && sudo mkdir -p "$host_dir" 2>/dev/null
prepare_bind_source "$host_dir" || {
COUNT_FAILED=$((COUNT_FAILED + 1))
FAILED_LIST+=" $name"
return
}
done
if eval "$(build_run_cmd)" >/dev/null 2>&1; then
fixed "$name — created"
+10 -1
View File
@@ -75,6 +75,15 @@ fi
cd "$REPO_DIR"
if ! command -v nano >/dev/null 2>&1; then
log "Installing nano for Archipelago terminal..."
if sudo apt-get update -qq 2>>"$LOG_FILE" && sudo apt-get install -y -qq nano 2>>"$LOG_FILE"; then
ok "nano installed"
else
warn "Unable to install nano automatically; continuing update"
fi
fi
# Fetch latest
log "Fetching from origin..."
git fetch origin main --quiet 2>>"$LOG_FILE"
@@ -186,7 +195,7 @@ fi
# for backward compatibility with older binaries that still look there.
SCRIPTS_DEST="/opt/archipelago/scripts"
sudo mkdir -p "$SCRIPTS_DEST"
for script in image-versions.sh reconcile-containers.sh container-specs.sh container-doctor.sh app-surface-smoke-test.sh bitcoin-stack-lifecycle-test.sh; do
for script in image-versions.sh reconcile-containers.sh container-specs.sh container-doctor.sh sync-npm-public-hosts.sh app-surface-smoke-test.sh bitcoin-stack-lifecycle-test.sh; do
src="$REPO_DIR/scripts/$script"
if [ -f "$src" ]; then
sudo install -m 755 "$src" "$SCRIPTS_DEST/$script"
+158
View File
@@ -0,0 +1,158 @@
#!/bin/bash
set -euo pipefail
DB="/var/lib/archipelago/nginx-proxy-manager/data/database.sqlite"
OUT="/etc/nginx/conf.d/public-npm-proxy-hosts.conf"
ACME_ROOT="/var/lib/archipelago/nginx-proxy-manager/data/letsencrypt-acme-challenge"
LE_ROOT="/var/lib/archipelago/nginx-proxy-manager/letsencrypt/live"
[ -f "$DB" ] || exit 0
mkdir -p "$ACME_ROOT/.well-known/acme-challenge"
chown -R 1000:1000 /var/lib/archipelago/nginx-proxy-manager 2>/dev/null || true
tmp=$(mktemp)
trap 'rm -f "$tmp"' EXIT
python3 - "$DB" "$ACME_ROOT" "$LE_ROOT" >"$tmp" <<'PY'
import json
import os
import sqlite3
import sys
db, acme_root, le_root = sys.argv[1:]
con = sqlite3.connect(db)
con.row_factory = sqlite3.Row
rows = con.execute(
"""
select p.id, p.domain_names, p.forward_scheme, p.forward_host, p.forward_port,
p.certificate_id, p.ssl_forced, c.provider
from proxy_host p
left join certificate c on c.id = p.certificate_id
where p.enabled = 1 and p.certificate_id > 0
order by p.id
"""
).fetchall()
print("# Generated by sync-npm-public-hosts.sh; do not edit by hand.")
for row in rows:
try:
domains = [d for d in json.loads(row["domain_names"] or "[]") if d]
except Exception:
domains = []
if not domains:
continue
cert_id = row["certificate_id"]
cert = f"{le_root}/npm-{cert_id}/fullchain.pem"
key = f"{le_root}/npm-{cert_id}/privkey.pem"
if row["provider"] != "letsencrypt":
continue
if not os.path.isfile(cert) or not os.path.isfile(key):
continue
names = " ".join(domains)
scheme = row["forward_scheme"] or "http"
host = row["forward_host"]
port = row["forward_port"]
if not host or not port:
continue
# NPM containers use this name to reach host-published services; host nginx
# itself should use loopback for the same services.
nginx_host = "127.0.0.1" if host == "host.containers.internal" else host
try:
forward_port = int(port)
except (TypeError, ValueError):
forward_port = None
is_saleor_dashboard = forward_port == 9010
is_saleor_storefront = forward_port == 9011
is_saleor = is_saleor_dashboard or is_saleor_storefront
graphql_location = ""
saleor_proxy_headers = ""
if is_saleor:
graphql_location = """
location ^~ /graphql/ {
proxy_pass http://127.0.0.1:8000/graphql/;
proxy_http_version 1.1;
proxy_set_header Host 127.0.0.1;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto https;
proxy_set_header X-Forwarded-Scheme https;
proxy_set_header Origin "";
}
"""
if is_saleor_storefront:
saleor_proxy_headers = """
proxy_set_header Accept-Encoding "";
sub_filter_once off;
sub_filter_types text/html application/javascript text/javascript;
sub_filter 'http://api:8000/graphql/' 'https://$host/graphql/';
"""
if is_saleor_dashboard:
saleor_proxy_headers = """
proxy_set_header Accept-Encoding "";
sub_filter_once off;
sub_filter_types text/html;
sub_filter '</head>' '<script>if(window.__SALEOR_CONFIG__){window.__SALEOR_CONFIG__.API_URL=location.origin+"/graphql/";window.__SALEOR_CONFIG__.EXTENSIONS_API_URL=location.origin+"/graphql/";}</script></head>';
"""
print(f"""
server {{
listen 80;
server_name {names};
location ^~ /.well-known/acme-challenge/ {{
default_type text/plain;
root {acme_root};
try_files $uri =404;
}}
location / {{
return 301 https://$host$request_uri;
}}
}}
server {{
listen 443 ssl;
server_name {names};
ssl_certificate {cert};
ssl_certificate_key {key};
{graphql_location}
location / {{
proxy_pass {scheme}://{nginx_host}:{port};
proxy_http_version 1.1;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto https;
proxy_set_header X-Forwarded-Scheme https;
proxy_set_header Upgrade $http_upgrade;
proxy_set_header Connection "upgrade";
{saleor_proxy_headers}
}}
}}
""")
PY
backup=""
if [ -f "$OUT" ]; then
backup=$(mktemp)
cp "$OUT" "$backup"
fi
restore_previous() {
if [ -n "$backup" ] && [ -f "$backup" ]; then
install -m 0644 "$backup" "$OUT"
else
rm -f "$OUT"
fi
}
if ! install -m 0644 "$tmp" "$OUT" || ! nginx -t >/dev/null; then
restore_previous
nginx -t >/dev/null 2>&1 || true
exit 1
fi
systemctl reload nginx
+2 -2
View File
@@ -135,7 +135,7 @@ image_for() {
case "$1" in
bitcoin-knots) echo "146.59.87.168:3000/lfg2025/bitcoin-knots:latest" ;;
bitcoin-core) echo "docker.io/bitcoin/bitcoin:28.4" ;;
btcpay-server) echo "146.59.87.168:3000/lfg2025/btcpayserver:1.13.7" ;;
btcpay-server) echo "docker.io/btcpayserver/btcpayserver:2.3.9" ;;
lnd) echo "146.59.87.168:3000/lfg2025/lnd:v0.18.4-beta" ;;
mempool) echo "146.59.87.168:3000/lfg2025/mempool-frontend:v3.0.0" ;;
homeassistant) echo "146.59.87.168:3000/lfg2025/home-assistant:2024.1" ;;
@@ -178,7 +178,7 @@ launch_url_for() {
ollama) echo "http://${ARCHY_HOST}:11434/" ;;
immich|immich_server) echo "http://${ARCHY_HOST}:2283/" ;;
portainer) echo "http://${ARCHY_HOST}:9000/" ;;
nginx-proxy-manager) echo "http://${ARCHY_HOST}:81/" ;;
nginx-proxy-manager) echo "http://${ARCHY_HOST}:8081/" ;;
tailscale) echo "http://${ARCHY_HOST}:8240/" ;;
uptime-kuma) echo "http://${ARCHY_HOST}:3002/" ;;
homeassistant) echo "http://${ARCHY_HOST}:8123/" ;;