Compare commits
32
Commits
@@ -11,8 +11,8 @@ android {
|
||||
applicationId = "com.archipelago.app"
|
||||
minSdk = 26
|
||||
targetSdk = 35
|
||||
versionCode = 18
|
||||
versionName = "0.4.14"
|
||||
versionCode = 19
|
||||
versionName = "0.4.15"
|
||||
|
||||
vectorDrawables {
|
||||
useSupportLibrary = true
|
||||
|
||||
@@ -1,10 +1,13 @@
|
||||
package com.archipelago.app.ui.screens
|
||||
|
||||
import android.Manifest
|
||||
import android.annotation.SuppressLint
|
||||
import android.content.pm.PackageManager
|
||||
import android.graphics.Bitmap
|
||||
import android.graphics.BitmapFactory
|
||||
import android.view.ViewGroup
|
||||
import android.webkit.CookieManager
|
||||
import android.webkit.PermissionRequest
|
||||
import android.webkit.WebChromeClient
|
||||
import android.webkit.WebResourceError
|
||||
import android.webkit.WebResourceRequest
|
||||
@@ -12,6 +15,9 @@ import android.webkit.WebSettings
|
||||
import android.webkit.WebView
|
||||
import android.webkit.WebViewClient
|
||||
import androidx.activity.compose.BackHandler
|
||||
import androidx.activity.compose.rememberLauncherForActivityResult
|
||||
import androidx.activity.result.contract.ActivityResultContracts
|
||||
import androidx.core.content.ContextCompat
|
||||
import androidx.compose.animation.AnimatedVisibility
|
||||
import androidx.compose.animation.fadeIn
|
||||
import androidx.compose.animation.fadeOut
|
||||
@@ -146,6 +152,20 @@ fun WebViewScreen(
|
||||
var hasError by remember { mutableStateOf(false) }
|
||||
var webView by remember { mutableStateOf<WebView?>(null) }
|
||||
|
||||
// Web-page camera access (wallet QR scanner). The WebView's default
|
||||
// WebChromeClient silently denies getUserMedia, so grant video capture —
|
||||
// asking for the app-level CAMERA permission first when needed.
|
||||
val webViewContext = LocalContext.current
|
||||
var pendingWebPermission by remember { mutableStateOf<PermissionRequest?>(null) }
|
||||
val webCameraPermissionLauncher = rememberLauncherForActivityResult(
|
||||
ActivityResultContracts.RequestPermission(),
|
||||
) { granted ->
|
||||
pendingWebPermission?.let { req ->
|
||||
if (granted) req.grant(arrayOf(PermissionRequest.RESOURCE_VIDEO_CAPTURE)) else req.deny()
|
||||
}
|
||||
pendingWebPermission = null
|
||||
}
|
||||
|
||||
// A node app that refused iframing, opened in a local WebView overlay.
|
||||
// null = no overlay. The kiosk WebView underneath stays alive (and warm)
|
||||
// while this is shown, so closing it returns instantly with no reload.
|
||||
@@ -371,6 +391,25 @@ fun WebViewScreen(
|
||||
loadProgress = newProgress
|
||||
}
|
||||
|
||||
// Wallet QR scanner: grant the page camera access.
|
||||
// Only video capture is granted — anything else the
|
||||
// page asks for is denied as before.
|
||||
override fun onPermissionRequest(request: PermissionRequest) {
|
||||
if (PermissionRequest.RESOURCE_VIDEO_CAPTURE !in request.resources) {
|
||||
request.deny()
|
||||
return
|
||||
}
|
||||
val hasCamera = ContextCompat.checkSelfPermission(
|
||||
webViewContext, Manifest.permission.CAMERA,
|
||||
) == PackageManager.PERMISSION_GRANTED
|
||||
if (hasCamera) {
|
||||
request.grant(arrayOf(PermissionRequest.RESOURCE_VIDEO_CAPTURE))
|
||||
} else {
|
||||
pendingWebPermission = request
|
||||
webCameraPermissionLauncher.launch(Manifest.permission.CAMERA)
|
||||
}
|
||||
}
|
||||
|
||||
// window.open() — e.g. the kiosk's "Open in new tab"
|
||||
// for an app that can't be iframed. Capture the target
|
||||
// URL via a throwaway WebView and route it ourselves.
|
||||
@@ -516,6 +555,18 @@ private fun InAppBrowser(
|
||||
var canGoBack by remember { mutableStateOf(false) }
|
||||
var canGoForward by remember { mutableStateOf(false) }
|
||||
|
||||
// Same camera bridge as the main WebView — node apps opened in the overlay
|
||||
// (e.g. anything with a QR scanner) get getUserMedia too.
|
||||
var pendingWebPermission by remember { mutableStateOf<PermissionRequest?>(null) }
|
||||
val webCameraPermissionLauncher = rememberLauncherForActivityResult(
|
||||
ActivityResultContracts.RequestPermission(),
|
||||
) { granted ->
|
||||
pendingWebPermission?.let { req ->
|
||||
if (granted) req.grant(arrayOf(PermissionRequest.RESOURCE_VIDEO_CAPTURE)) else req.deny()
|
||||
}
|
||||
pendingWebPermission = null
|
||||
}
|
||||
|
||||
// Seed the loading-screen icon immediately from a best-effort favicon
|
||||
// pre-fetch (main's app-icon work), then onReceivedIcon upgrades it — so the
|
||||
// loader shows an icon right away instead of staying blank until the page
|
||||
@@ -565,6 +616,22 @@ private fun InAppBrowser(
|
||||
override fun onReceivedIcon(view: WebView?, icon: Bitmap?) {
|
||||
if (icon != null) favicon = icon
|
||||
}
|
||||
|
||||
override fun onPermissionRequest(request: PermissionRequest) {
|
||||
if (PermissionRequest.RESOURCE_VIDEO_CAPTURE !in request.resources) {
|
||||
request.deny()
|
||||
return
|
||||
}
|
||||
val hasCamera = ContextCompat.checkSelfPermission(
|
||||
context, Manifest.permission.CAMERA,
|
||||
) == PackageManager.PERMISSION_GRANTED
|
||||
if (hasCamera) {
|
||||
request.grant(arrayOf(PermissionRequest.RESOURCE_VIDEO_CAPTURE))
|
||||
} else {
|
||||
pendingWebPermission = request
|
||||
webCameraPermissionLauncher.launch(Manifest.permission.CAMERA)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
webViewClient = object : WebViewClient() {
|
||||
|
||||
@@ -1,5 +1,22 @@
|
||||
# Changelog
|
||||
|
||||
## v1.7.110-alpha (2026-07-21)
|
||||
|
||||
- Pay by pointing your camera: the wallet has a new Scan button (on the wallet card and inside both the Send and Receive windows) that reads any payment QR code — Lightning invoices, Bitcoin addresses, Cashu tokens, and Fedimint invites — and takes you straight to the right send or redeem screen with everything filled in. It also understands the animated, multi-part QR codes some wallets show for long payloads. If your browser can't open a live camera preview (common when reaching the node over plain http), a "Take photo of QR" button snaps a picture with your phone's camera and reads the code from the photo instead.
|
||||
- The TV screen got a complete overhaul. A deep bug made the display freeze on the intro artwork on 4K TVs — that's fixed, and along the way: the interface now picks a comfortable, sharp size for big screens (a 4K TV gets a full desktop layout at double sharpness), the artwork behind every page shows again instead of a black void, switching between tabs animates smoothly, the built-in AI assistant stays in its dark theme, and the Cashu and Ark wallet icons no longer render as empty squares.
|
||||
- You can now choose how big the interface renders on your node's attached screen: Settings → Display offers Auto (recommended), Large UI, Balanced, and Native — changing it applies immediately.
|
||||
- The companion phone app can steer the TV again. Remote input from the phone was being silently ignored on kiosk displays; the remote-control relay now runs there like everywhere else.
|
||||
- The companion app is also ready to grant its built-in browser camera access, so the wallet scanner can work inside the app (ships with the next companion app build).
|
||||
- Zero-amount Lightning invoices can now be paid: the wallet asks you for the amount and sends it along, instead of failing on invoices that leave the amount up to the payer.
|
||||
- The Lightning setup guidance now reads the same everywhere: "Open a channel with Zeus Olympus node and start sending and receiving Lightning payments. Minimum 150,000 · maximum 1,500,000 on-chain sats required."
|
||||
- Installer images now bundle a color-emoji font, so emoji anywhere in the interface render properly on the TV screen.
|
||||
|
||||
## v1.7.109-alpha (2026-07-21)
|
||||
|
||||
- Meet Pine, your node's voice assistant: a new app in the App Store that gives your node ears and a voice — speech-to-text and text-to-speech engines that run entirely on your own hardware, ready to wire into Home Assistant for private, offline voice control. Install it like any other app; nothing you say leaves your node.
|
||||
- Your node can now program its MeshCore radio's RF settings — frequency, bandwidth, spreading factor, and coding rate — from Mesh → Device settings. Radios that were flashed with mismatched settings could hear that other radios exist but never decode their messages, and until now the only fix was a separate phone app. Set the values once and the node programs the radio automatically (it restarts once to apply); every radio on your mesh must use the same values to talk to each other.
|
||||
- The Device settings panel is tidier: values you can edit (name, region, channel) are no longer also shown as separate read-only rows.
|
||||
|
||||
## v1.7.108-alpha (2026-07-20)
|
||||
|
||||
- Your node connects to the private mesh far more reliably. Nodes rely on a public rendezvous point to find each other, and the only one available was unreachable from many home and office networks — leaving some nodes unable to join the mesh at all. There is now a second, always-reachable rendezvous point, and your node tries every one it knows, so it joins the mesh in seconds instead of being stranded.
|
||||
|
||||
@@ -370,6 +370,17 @@
|
||||
]
|
||||
}
|
||||
},
|
||||
{
|
||||
"id": "pine",
|
||||
"title": "Pine",
|
||||
"version": "1.1.1",
|
||||
"description": "A private voice assistant for your home. Pine runs speech-to-text (Whisper) and text-to-speech (Piper) on your own node and pairs with a PineVoice satellite speaker, so Home Assistant Assist works locally with nothing sent to the cloud.",
|
||||
"icon": "/assets/img/app-icons/pine.svg",
|
||||
"author": "Archipelago",
|
||||
"category": "home",
|
||||
"dockerImage": "docker.io/library/nginx:1.27-alpine",
|
||||
"repoUrl": "https://github.com/rhasspy/wyoming"
|
||||
},
|
||||
{
|
||||
"id": "grafana",
|
||||
"title": "Grafana",
|
||||
|
||||
@@ -0,0 +1,70 @@
|
||||
app:
|
||||
id: pine-piper
|
||||
name: Pine Piper (TTS)
|
||||
version: "2.2.2"
|
||||
description: Wyoming-protocol Piper text-to-speech engine. Internal Pine voice-assistant stack member — gives Home Assistant Assist a natural voice for spoken responses on the PineVoice satellite.
|
||||
category: home
|
||||
|
||||
# Hyphen name matches the runtime references (stack member table / startup
|
||||
# order) + the live container, so on an existing node the orchestrator ADOPTS
|
||||
# the running engine rather than recreating it (downloaded voices under /data
|
||||
# preserved).
|
||||
container_name: pine-piper
|
||||
|
||||
container:
|
||||
image: docker.io/rhasspy/wyoming-piper:2.2.2
|
||||
pull_policy: if-not-present
|
||||
network: archy-net
|
||||
network_aliases: [pine-piper]
|
||||
# The image entrypoint already binds tcp://0.0.0.0:10200; this arg only
|
||||
# picks the voice (mirrors the pine ha-stack.yml compose command).
|
||||
custom_args: ["--voice", "en_GB-alba-medium"]
|
||||
|
||||
dependencies:
|
||||
- storage: 1Gi
|
||||
|
||||
resources:
|
||||
memory_limit: 512Mi
|
||||
|
||||
security:
|
||||
# cap-drop=ALL is applied by the orchestrator. A plain Python Wyoming server
|
||||
# on an unprivileged port needs no added capabilities.
|
||||
capabilities: []
|
||||
readonly_root: false # downloads the voice into /data on first run
|
||||
no_new_privileges: true
|
||||
network_policy: isolated
|
||||
|
||||
ports:
|
||||
# Published so Home Assistant (on the pasta net) can reach the engine via
|
||||
# host.containers.internal:10200 (the Wyoming integration endpoint).
|
||||
- host: 10200
|
||||
container: 10200
|
||||
protocol: tcp
|
||||
|
||||
volumes:
|
||||
- type: bind
|
||||
source: /var/lib/archipelago/pine-piper
|
||||
target: /data
|
||||
options: [rw]
|
||||
|
||||
environment: []
|
||||
|
||||
health_check:
|
||||
type: tcp
|
||||
endpoint: localhost:10200
|
||||
interval: 30s
|
||||
timeout: 5s
|
||||
retries: 5
|
||||
start_period: 60s # first start downloads the voice
|
||||
|
||||
metadata:
|
||||
author: Rhasspy / Home Assistant
|
||||
icon: /assets/img/app-icons/pine.svg
|
||||
website: https://github.com/rhasspy/wyoming-piper
|
||||
repo: https://github.com/rhasspy/wyoming-piper
|
||||
license: MIT
|
||||
tags:
|
||||
- home
|
||||
- voice
|
||||
- text-to-speech
|
||||
- wyoming
|
||||
@@ -0,0 +1,70 @@
|
||||
app:
|
||||
id: pine-whisper
|
||||
name: Pine Whisper (STT)
|
||||
version: "3.4.1"
|
||||
description: Wyoming-protocol faster-whisper speech-to-text engine. Internal Pine voice-assistant stack member — turns speech captured by a PineVoice satellite into text for Home Assistant Assist.
|
||||
category: home
|
||||
|
||||
# Hyphen name matches the runtime references (stack member table / startup
|
||||
# order) + the live container, so on an existing node the orchestrator ADOPTS
|
||||
# the running engine rather than recreating it (downloaded models under /data
|
||||
# preserved).
|
||||
container_name: pine-whisper
|
||||
|
||||
container:
|
||||
image: docker.io/rhasspy/wyoming-whisper:3.4.1
|
||||
pull_policy: if-not-present
|
||||
network: archy-net
|
||||
network_aliases: [pine-whisper]
|
||||
# The image entrypoint already binds tcp://0.0.0.0:10300; these args only
|
||||
# pick the model + language (mirrors the pine ha-stack.yml compose command).
|
||||
custom_args: ["--model", "base-int8", "--language", "en"]
|
||||
|
||||
dependencies:
|
||||
- storage: 2Gi
|
||||
|
||||
resources:
|
||||
memory_limit: 2Gi
|
||||
|
||||
security:
|
||||
# cap-drop=ALL is applied by the orchestrator. A plain Python Wyoming server
|
||||
# on an unprivileged port needs no added capabilities.
|
||||
capabilities: []
|
||||
readonly_root: false # downloads the whisper model into /data on first run
|
||||
no_new_privileges: true
|
||||
network_policy: isolated
|
||||
|
||||
ports:
|
||||
# Published so Home Assistant (on the pasta net) can reach the engine via
|
||||
# host.containers.internal:10300 (the Wyoming integration endpoint).
|
||||
- host: 10300
|
||||
container: 10300
|
||||
protocol: tcp
|
||||
|
||||
volumes:
|
||||
- type: bind
|
||||
source: /var/lib/archipelago/pine-whisper
|
||||
target: /data
|
||||
options: [rw]
|
||||
|
||||
environment: []
|
||||
|
||||
health_check:
|
||||
type: tcp
|
||||
endpoint: localhost:10300
|
||||
interval: 30s
|
||||
timeout: 5s
|
||||
retries: 5
|
||||
start_period: 60s # first start downloads the model
|
||||
|
||||
metadata:
|
||||
author: Rhasspy / Home Assistant
|
||||
icon: /assets/img/app-icons/pine.svg
|
||||
website: https://github.com/rhasspy/wyoming-faster-whisper
|
||||
repo: https://github.com/rhasspy/wyoming-faster-whisper
|
||||
license: MIT
|
||||
tags:
|
||||
- home
|
||||
- voice
|
||||
- speech-to-text
|
||||
- wyoming
|
||||
@@ -0,0 +1,336 @@
|
||||
app:
|
||||
id: pine
|
||||
name: Pine
|
||||
version: "1.1.1"
|
||||
description: A private voice assistant for your home. Pine runs speech-to-text (Whisper) and text-to-speech (Piper) on your own node and pairs with a PineVoice satellite speaker, so Home Assistant Assist works locally with nothing sent to the cloud.
|
||||
category: home
|
||||
|
||||
# The user-facing launcher (app_id + container both "pine", matching the
|
||||
# runtime references + the live container so the orchestrator adopts it). A
|
||||
# tiny nginx that serves the "Connect Pine to WiFi" provisioner page for the
|
||||
# voice stack. The two Wyoming engines (pine-whisper, pine-piper) are internal
|
||||
# stack members.
|
||||
container_name: pine
|
||||
|
||||
container:
|
||||
image: docker.io/library/nginx:1.27-alpine
|
||||
pull_policy: if-not-present
|
||||
network: archy-net
|
||||
network_aliases: [pine]
|
||||
# The provisioner uses Web Bluetooth (Improv-over-BLE) to push WiFi creds to
|
||||
# the PineVoice speaker. navigator.bluetooth only exists in a SECURE CONTEXT
|
||||
# (https or localhost), so the launcher terminates TLS with a self-signed
|
||||
# cert — otherwise the "Connect Pine to WiFi" button is inert on the LAN.
|
||||
# Idempotent: kept as-is when crt+key already exist. Mirrors the netbird
|
||||
# secure-context fix (#15).
|
||||
generated_certs:
|
||||
- crt: /var/lib/archipelago/pine/tls.crt
|
||||
key: /var/lib/archipelago/pine/tls.key
|
||||
|
||||
dependencies:
|
||||
- app_id: pine-whisper
|
||||
- app_id: pine-piper
|
||||
- storage: 128Mi
|
||||
|
||||
resources:
|
||||
memory_limit: 64Mi
|
||||
|
||||
security:
|
||||
# cap-drop=ALL is applied by the orchestrator. nginx (master as root, drops
|
||||
# workers) binds :443 inside the container — needs the worker-drop caps +
|
||||
# NET_BIND_SERVICE for the privileged port.
|
||||
capabilities: [CHOWN, DAC_OVERRIDE, SETGID, SETUID, NET_BIND_SERVICE]
|
||||
readonly_root: false
|
||||
no_new_privileges: true
|
||||
network_policy: isolated
|
||||
|
||||
ports:
|
||||
# 10380 (http) is the Open target — the UI launches apps as
|
||||
# http://host:10380 (resolveAppUrl). nginx there 301-redirects to the https
|
||||
# listener on 10381, so the new tab lands on a secure context where
|
||||
# navigator.bluetooth (the "Connect Pine to WiFi" provisioner) works.
|
||||
- host: 10380
|
||||
container: 80
|
||||
protocol: tcp
|
||||
- host: 10381
|
||||
container: 443
|
||||
protocol: tcp
|
||||
|
||||
volumes:
|
||||
- type: bind
|
||||
source: /var/lib/archipelago/pine/nginx.conf
|
||||
target: /etc/nginx/conf.d/default.conf
|
||||
options: [ro]
|
||||
- type: bind
|
||||
source: /var/lib/archipelago/pine/tls.crt
|
||||
target: /etc/nginx/tls.crt
|
||||
options: [ro]
|
||||
- type: bind
|
||||
source: /var/lib/archipelago/pine/tls.key
|
||||
target: /etc/nginx/tls.key
|
||||
options: [ro]
|
||||
- type: bind
|
||||
source: /var/lib/archipelago/pine/index.html
|
||||
target: /usr/share/nginx/html/index.html
|
||||
options: [ro]
|
||||
|
||||
environment: []
|
||||
|
||||
files:
|
||||
- path: /var/lib/archipelago/pine/nginx.conf
|
||||
overwrite: true
|
||||
content: |
|
||||
server {
|
||||
listen 80;
|
||||
server_name _;
|
||||
return 301 https://$host:10381$request_uri;
|
||||
}
|
||||
server {
|
||||
listen 443 ssl;
|
||||
server_name _;
|
||||
ssl_certificate /etc/nginx/tls.crt;
|
||||
ssl_certificate_key /etc/nginx/tls.key;
|
||||
root /usr/share/nginx/html;
|
||||
index index.html;
|
||||
location / { try_files $uri $uri/ /index.html; }
|
||||
}
|
||||
- path: /var/lib/archipelago/pine/index.html
|
||||
overwrite: true
|
||||
content: |
|
||||
<!doctype html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
<title>Pine — connect your speaker</title>
|
||||
<style>
|
||||
:root { color-scheme: dark; }
|
||||
* { box-sizing: border-box; }
|
||||
body { margin: 0; font: 16px/1.6 system-ui, -apple-system, sans-serif;
|
||||
background: #0f1512; color: #e7efe9; }
|
||||
.wrap { max-width: 520px; margin: 0 auto; padding: 40px 22px 64px; }
|
||||
header { display: flex; align-items: center; gap: 14px; margin-bottom: 6px; }
|
||||
header svg { width: 52px; height: 52px; flex: 0 0 auto; }
|
||||
h1 { font-size: 26px; margin: 0; }
|
||||
.tag { color: #8fbfa5; font-size: 14px; margin: 2px 0 0; }
|
||||
.card { background: #16201b; border: 1px solid #24332b;
|
||||
border-radius: 14px; padding: 20px; margin: 20px 0; }
|
||||
.status .row { display: flex; gap: 10px; align-items: baseline; font-size: 14px; }
|
||||
.status .row b { min-width: 84px; color: #9fd3b6; }
|
||||
.status code { background: #0f1512; padding: 1px 6px; border-radius: 5px;
|
||||
font-size: 13px; color: #cfe9d9; }
|
||||
label { display: block; font-size: 13px; color: #9fbfad; margin: 14px 0 5px; }
|
||||
input { width: 100%; padding: 11px 12px; font-size: 15px; color: #e7efe9;
|
||||
background: #0f1512; border: 1px solid #2b3d33; border-radius: 9px;
|
||||
outline: none; }
|
||||
input:focus { border-color: #4fae82; }
|
||||
button { width: 100%; margin-top: 18px; padding: 13px; font-size: 15px;
|
||||
font-weight: 600; color: #06110b; background: #7fd6a6; border: 0;
|
||||
border-radius: 10px; cursor: pointer; transition: filter .15s; }
|
||||
button:hover:not(:disabled) { filter: brightness(1.08); }
|
||||
button:disabled { opacity: .45; cursor: default; }
|
||||
#log { margin-top: 16px; padding: 12px; min-height: 68px; font-size: 13px;
|
||||
font-family: ui-monospace, monospace; white-space: pre-wrap;
|
||||
background: #0b100d; border: 1px solid #1e2a23; border-radius: 9px;
|
||||
color: #a9c6b6; max-height: 220px; overflow-y: auto; }
|
||||
.ok { color: #7fd6a6; } .err { color: #ee8f8f; } .warn { color: #e8c878; }
|
||||
.ha { color: #6d8578; font-size: 13px; margin-top: 22px; }
|
||||
.ha b { color: #9fbfad; }
|
||||
.insecure { display: none; background: #2a1f12; border-color: #4a3418;
|
||||
color: #e8c878; font-size: 13px; }
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div class="wrap">
|
||||
<header>
|
||||
<svg viewBox="0 0 512 512" aria-hidden="true"><g fill="#7fd6a6">
|
||||
<rect x="236" y="396" width="40" height="72" rx="6"/>
|
||||
<path d="M256 44 L336 168 L296 168 L256 108 L216 168 L176 168 Z"/>
|
||||
<path d="M256 150 L360 300 L300 300 L256 236 L212 300 L152 300 Z"/>
|
||||
<path d="M256 262 L392 430 L120 430 L256 262 Z"/>
|
||||
</g></svg>
|
||||
<div><h1>Pine</h1>
|
||||
<p class="tag">Connect your speaker — everything stays on your node.</p></div>
|
||||
</header>
|
||||
|
||||
<div class="card status">
|
||||
<div class="row"><b>Whisper</b><span>speech-to-text ready on <code>:10300</code></span></div>
|
||||
<div class="row"><b>Piper</b><span>text-to-speech ready on <code>:10200</code></span></div>
|
||||
<div class="row"><b>Speaker</b><span>put it in pairing mode — ring LED blinking yellow</span></div>
|
||||
</div>
|
||||
|
||||
<div class="card insecure" id="insecure">
|
||||
This page isn’t running over HTTPS, so the browser blocks Bluetooth.
|
||||
Open it via its <b>https://…:10380</b> address (accept the self-signed
|
||||
certificate) and the button below will work.
|
||||
</div>
|
||||
|
||||
<div class="card">
|
||||
<label for="ssid">WiFi network (SSID)</label>
|
||||
<input id="ssid" autocomplete="off" spellcheck="false" placeholder="Your 2.4 GHz network">
|
||||
<label for="pass">WiFi password — typed here, sent only over Bluetooth to the speaker</label>
|
||||
<input id="pass" type="password" autocomplete="off">
|
||||
<button id="go">Connect Pine to WiFi</button>
|
||||
<div id="log">Ready. Click the button, then pick “PineVoice” in the Bluetooth popup.</div>
|
||||
</div>
|
||||
|
||||
<p class="ha">After WiFi joins, add the speaker to Home Assistant:
|
||||
<b>Settings → Devices & services → Add Wyoming Protocol</b>, host =
|
||||
the speaker’s IP, port <b>10700</b>, then pick an Assist pipeline using
|
||||
Whisper + Piper. Wake word: <b>“Hey Jarvis.”</b></p>
|
||||
</div>
|
||||
|
||||
<script>
|
||||
"use strict";
|
||||
// Improv-over-BLE (improv-wifi spec, verified vs improv-wifi-sdk 1.4.0).
|
||||
const SVC = "00467768-6228-2272-4663-277478268000";
|
||||
const CHAR_STATE = "00467768-6228-2272-4663-277478268001";
|
||||
const CHAR_ERROR = "00467768-6228-2272-4663-277478268002";
|
||||
const CHAR_RPC = "00467768-6228-2272-4663-277478268003";
|
||||
const CHAR_RESULT = "00467768-6228-2272-4663-277478268004";
|
||||
const STATES = {1:"authorization required",2:"authorized",3:"provisioning…",4:"PROVISIONED"};
|
||||
const ERRORS = {1:"invalid RPC packet",2:"unknown RPC command",
|
||||
3:"unable to connect — wrong password, or the SSID isn't reachable on 2.4 GHz",
|
||||
4:"not authorized — press the button on the device",5:"bad hostname",
|
||||
255:"unknown device error"};
|
||||
|
||||
const logEl = document.getElementById("log");
|
||||
const log = (msg, cls) => { const l = document.createElement("div");
|
||||
if (cls) l.className = cls; l.textContent = msg; logEl.appendChild(l);
|
||||
logEl.scrollTop = logEl.scrollHeight; };
|
||||
const hex = dv => [...new Uint8Array(dv.buffer, dv.byteOffset, dv.byteLength)]
|
||||
.map(b => b.toString(16).padStart(2, "0")).join(" ");
|
||||
|
||||
const btn = document.getElementById("go");
|
||||
if (!navigator.bluetooth) {
|
||||
document.getElementById("insecure").style.display = "block";
|
||||
logEl.textContent = "Web Bluetooth unavailable — open this page over https (see note above).";
|
||||
}
|
||||
|
||||
btn.addEventListener("click", async () => {
|
||||
const ssid = document.getElementById("ssid").value.trim();
|
||||
const pass = document.getElementById("pass").value;
|
||||
if (!ssid) { log("Enter your WiFi network name first.", "err"); return; }
|
||||
if (!navigator.bluetooth) { log("No Web Bluetooth — open this page over https.", "err"); return; }
|
||||
btn.disabled = true; logEl.textContent = "";
|
||||
let device;
|
||||
try {
|
||||
log("Opening Bluetooth device chooser…");
|
||||
device = await navigator.bluetooth.requestDevice({
|
||||
filters: [{ services: [SVC] }, { namePrefix: "PineVoice" }],
|
||||
optionalServices: [SVC],
|
||||
});
|
||||
log(`Selected: ${device.name || "(unnamed)"}`);
|
||||
device.addEventListener("gattserverdisconnected", () => log("BLE disconnected."));
|
||||
log("Connecting…");
|
||||
const gatt = await device.gatt.connect();
|
||||
const svc = await gatt.getPrimaryService(SVC);
|
||||
const stateChar = await svc.getCharacteristic(CHAR_STATE);
|
||||
const errorChar = await svc.getCharacteristic(CHAR_ERROR);
|
||||
const rpcChar = await svc.getCharacteristic(CHAR_RPC);
|
||||
const resultChar = await svc.getCharacteristic(CHAR_RESULT);
|
||||
|
||||
let done, fail;
|
||||
const outcome = new Promise((res, rej) => { done = res; fail = rej; });
|
||||
let authorize; const authorized = new Promise(res => { authorize = res; });
|
||||
let state = -1;
|
||||
const onState = (s, via = "") => {
|
||||
if (s === state) return; state = s;
|
||||
log(`Device state${via}: ${STATES[s] || s}`, s === 4 ? "ok" : undefined);
|
||||
if (s >= 2) authorize();
|
||||
if (s === 4) done(null);
|
||||
};
|
||||
stateChar.addEventListener("characteristicvaluechanged",
|
||||
e => onState(e.target.value.getUint8(0)));
|
||||
errorChar.addEventListener("characteristicvaluechanged", e => {
|
||||
const c = e.target.value.getUint8(0);
|
||||
if (c !== 0) fail(new Error(ERRORS[c] || `error ${c}`)); });
|
||||
resultChar.addEventListener("characteristicvaluechanged", e => {
|
||||
const v = e.target.value; log(`RPC result: ${hex(v)}`);
|
||||
if (v.byteLength > 2 && v.getUint8(1) > 0) {
|
||||
const n = v.getUint8(2); const b = new Uint8Array(n);
|
||||
for (let i = 0; i < n; i++) b[i] = v.getUint8(3 + i);
|
||||
done(new TextDecoder().decode(b)); } });
|
||||
await stateChar.startNotifications();
|
||||
await errorChar.startNotifications();
|
||||
await resultChar.startNotifications();
|
||||
onState((await stateChar.readValue()).getUint8(0));
|
||||
|
||||
const poller = setInterval(async () => {
|
||||
try { onState((await stateChar.readValue()).getUint8(0), " (polled)");
|
||||
const ec = (await errorChar.readValue()).getUint8(0);
|
||||
if (ec !== 0) fail(new Error(ERRORS[ec] || `error ${ec}`)); } catch {} }, 2000);
|
||||
|
||||
let nextUrl;
|
||||
try {
|
||||
if (state === 1) {
|
||||
log("Authorization required — press the centre button on the speaker now.", "warn");
|
||||
await Promise.race([authorized, outcome,
|
||||
new Promise((_, rej) => setTimeout(
|
||||
() => rej(new Error("timed out waiting for the button press")), 60000))]);
|
||||
log("Authorized.", "ok");
|
||||
}
|
||||
const enc = new TextEncoder();
|
||||
const sb = enc.encode(ssid), pb = enc.encode(pass);
|
||||
const data = new Uint8Array([sb.length, ...sb, pb.length, ...pb]);
|
||||
const pkt = new Uint8Array([1, data.length, ...data, 0]);
|
||||
pkt[pkt.length - 1] = pkt.reduce((s, b) => s + b, 0);
|
||||
log(`Sending WiFi credentials for “${ssid}”…`);
|
||||
if (rpcChar.writeValueWithResponse) await rpcChar.writeValueWithResponse(pkt);
|
||||
else await rpcChar.writeValue(pkt);
|
||||
log("Credentials delivered — speaker acknowledged.", "ok");
|
||||
log("Joining WiFi… (the speaker plays a sound within ~20s either way)");
|
||||
const timeout = new Promise((_, rej) => setTimeout(
|
||||
() => rej(new Error("timed out after 60s waiting for the device")), 60000));
|
||||
nextUrl = await Promise.race([outcome, timeout]);
|
||||
} finally { clearInterval(poller); }
|
||||
|
||||
log("✓ PROVISIONED — the ring LED should breathe dim magenta.", "ok");
|
||||
if (nextUrl) log(`Device reports next step: ${nextUrl}`, "ok");
|
||||
try { gatt.disconnect(); } catch {}
|
||||
} catch (err) {
|
||||
log(`✗ ${err.name || "Error"}: ${err.message}`, "err");
|
||||
if (err.name === "NotFoundError")
|
||||
log("No speaker matched, or you closed the chooser. LED blinking yellow? "
|
||||
+ "Hold the dot button 15s to reset.", "warn");
|
||||
if (err.name === "NetworkError")
|
||||
log("BLE link dropped — move closer, and make sure the Mac isn't already "
|
||||
+ "paired to the speaker (it can hold the link exclusively).", "warn");
|
||||
try { device?.gatt?.disconnect(); } catch {}
|
||||
} finally { btn.disabled = false; }
|
||||
});
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
|
||||
health_check:
|
||||
type: tcp
|
||||
endpoint: localhost:443
|
||||
interval: 30s
|
||||
timeout: 5s
|
||||
retries: 5
|
||||
start_period: 10s
|
||||
|
||||
interfaces:
|
||||
main:
|
||||
name: Pine
|
||||
description: Connect your speaker to WiFi and check the voice assistant
|
||||
type: ui
|
||||
port: 10380
|
||||
protocol: http
|
||||
path: /
|
||||
|
||||
metadata:
|
||||
author: Archipelago
|
||||
icon: /assets/img/app-icons/pine.svg
|
||||
website: https://github.com/rhasspy/wyoming
|
||||
repo: https://github.com/rhasspy/wyoming
|
||||
license: MIT
|
||||
category: home
|
||||
launch:
|
||||
open_in_new_tab: true
|
||||
tags:
|
||||
- home
|
||||
- voice
|
||||
- assistant
|
||||
- privacy
|
||||
Generated
+1
-1
@@ -95,7 +95,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "archipelago"
|
||||
version = "1.7.108-alpha"
|
||||
version = "1.7.109-alpha"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"archipelago-container",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
[package]
|
||||
name = "archipelago"
|
||||
version = "1.7.108-alpha"
|
||||
version = "1.7.110-alpha"
|
||||
edition = "2021"
|
||||
description = "Archipelago Bitcoin Node OS - Native backend"
|
||||
authors = ["Archipelago Team"]
|
||||
|
||||
@@ -456,6 +456,8 @@ impl RpcHandler {
|
||||
"system.factory-reset" => self.handle_system_factory_reset(params).await,
|
||||
"system.settings.get" => self.handle_system_settings_get(params).await,
|
||||
"system.settings.set" => self.handle_system_settings_set(params).await,
|
||||
"system.kiosk-display.get" => self.handle_system_kiosk_display_get().await,
|
||||
"system.kiosk-display.set" => self.handle_system_kiosk_display_set(params).await,
|
||||
|
||||
// Opt-in anonymous analytics
|
||||
"analytics.get-status" => self.handle_analytics_get_status().await,
|
||||
|
||||
@@ -28,13 +28,20 @@ impl RpcHandler {
|
||||
));
|
||||
}
|
||||
|
||||
// Zero-amount invoices need the amount supplied by the payer; LND's
|
||||
// REST API takes it as an `amt` string alongside the payment request.
|
||||
let amount_sats = params.get("amount_sats").and_then(|v| v.as_u64());
|
||||
|
||||
info!("Paying Lightning invoice");
|
||||
|
||||
let (client, macaroon_hex) = self.lnd_client().await?;
|
||||
|
||||
let pay_body = serde_json::json!({
|
||||
let mut pay_body = serde_json::json!({
|
||||
"payment_request": payment_request,
|
||||
});
|
||||
if let Some(amt) = amount_sats {
|
||||
pay_body["amt"] = serde_json::json!(amt.to_string());
|
||||
}
|
||||
|
||||
let resp = client
|
||||
.post(format!("{LND_REST_BASE_URL}/v1/channels/transactions"))
|
||||
|
||||
@@ -158,6 +158,29 @@ impl RpcHandler {
|
||||
anyhow::bail!("Unknown LoRa region: {trimmed}");
|
||||
}
|
||||
}
|
||||
// Meshcore LoRa PHY params (freq/bw/sf/cr, firmware field units — see
|
||||
// mesh::LoraRadioParams). Validated against the firmware's accepted
|
||||
// ranges here so a bad value errors at the API instead of being sent
|
||||
// to the radio and rejected on-device. `null` clears the setting.
|
||||
if let Some(rp) = params.get("lora_radio_params") {
|
||||
if rp.is_null() {
|
||||
config.lora_radio_params = None;
|
||||
} else {
|
||||
let parsed: mesh::LoraRadioParams = serde_json::from_value(rp.clone())
|
||||
.map_err(|e| anyhow::anyhow!("Invalid lora_radio_params: {e}"))?;
|
||||
anyhow::ensure!(
|
||||
(150_000..=2_500_000).contains(&parsed.freq_khz),
|
||||
"freq_khz out of range (150000..=2500000)"
|
||||
);
|
||||
anyhow::ensure!(
|
||||
(7_000..=500_000).contains(&parsed.bw_hz),
|
||||
"bw_hz out of range (7000..=500000)"
|
||||
);
|
||||
anyhow::ensure!((5..=12).contains(&parsed.sf), "sf out of range (5..=12)");
|
||||
anyhow::ensure!((5..=8).contains(&parsed.cr), "cr out of range (5..=8)");
|
||||
config.lora_radio_params = Some(parsed);
|
||||
}
|
||||
}
|
||||
// Firmware pin: probe only the named firmware on the port ("auto"/""
|
||||
// clears the pin and restores strict-probe auto-detect).
|
||||
if let Some(kind) = params.get("device_kind").and_then(|v| v.as_str()) {
|
||||
|
||||
@@ -41,6 +41,10 @@ impl RpcHandler {
|
||||
// live radio-reported `region`): the configured LoRa region and
|
||||
// the firmware pin ("meshcore"|"meshtastic"|"reticulum"|null=auto).
|
||||
obj.insert("lora_region".into(), config.lora_region.clone().into());
|
||||
obj.insert(
|
||||
"lora_radio_params".into(),
|
||||
serde_json::to_value(config.lora_radio_params).unwrap_or_default(),
|
||||
);
|
||||
obj.insert(
|
||||
"device_kind".into(),
|
||||
config
|
||||
|
||||
@@ -496,6 +496,8 @@ pub(super) fn all_container_names(package_id: &str) -> Vec<String> {
|
||||
"netbird-dashboard".into(),
|
||||
"netbird-server".into(),
|
||||
],
|
||||
// Pine voice-assistant stack: launcher + the two Wyoming engines.
|
||||
"pine" => vec!["pine".into(), "pine-whisper".into(), "pine-piper".into()],
|
||||
"nostr-vpn" => vec![
|
||||
"nostr-vpn".into(),
|
||||
"archy-nostr-vpn".into(),
|
||||
|
||||
@@ -595,6 +595,9 @@ pub(super) fn needs_archy_net(package_id: &str) -> bool {
|
||||
| "nbxplorer"
|
||||
| "fedimint"
|
||||
| "fedimint-gateway"
|
||||
| "pine"
|
||||
| "pine-whisper"
|
||||
| "pine-piper"
|
||||
)
|
||||
}
|
||||
|
||||
@@ -626,6 +629,7 @@ pub(super) fn startup_order(package_id: &str) -> &'static [&'static str] {
|
||||
&["archy-btcpay-db", "archy-nbxplorer", "btcpay-server"]
|
||||
}
|
||||
"netbird" => &["netbird-server", "netbird-dashboard", "netbird"],
|
||||
"pine" => &["pine-whisper", "pine-piper", "pine"],
|
||||
"penpot" | "penpot-frontend" => &[
|
||||
"penpot-postgres",
|
||||
"penpot-valkey",
|
||||
|
||||
@@ -294,6 +294,9 @@ impl RpcHandler {
|
||||
if package_id == "netbird" {
|
||||
return self.install_netbird_stack().await;
|
||||
}
|
||||
if package_id == "pine" {
|
||||
return self.install_pine_stack().await;
|
||||
}
|
||||
// Dependency checks. Prefer the scanner's cached package state so a
|
||||
// congested Podman API does not turn an already-running dependency into
|
||||
// a false install failure. Fall back to a bounded direct Podman probe
|
||||
|
||||
@@ -744,6 +744,15 @@ fn netbird_stack_app_ids() -> &'static [&'static str] {
|
||||
&["netbird-server", "netbird-dashboard", "netbird"]
|
||||
}
|
||||
|
||||
fn pine_stack_app_ids() -> &'static [&'static str] {
|
||||
// Dependency/startup order: the two Wyoming engines (STT + TTS) first — they
|
||||
// own their downloaded model/voice under /data and publish 10300/10200 —
|
||||
// then the user-facing launcher ("pine", the nginx that serves the setup /
|
||||
// status page + is the Open target). Mirrors the pine startup_order in
|
||||
// dependencies.rs + the stack member table in app_ops.rs.
|
||||
&["pine-whisper", "pine-piper", "pine"]
|
||||
}
|
||||
|
||||
fn indeedhub_stack_app_ids() -> &'static [&'static str] {
|
||||
// Dependency order: backends + their generated secrets first, then the api
|
||||
// (owns indeedhub-jwt; reads the db/minio secrets the backends materialised),
|
||||
@@ -1907,6 +1916,37 @@ impl RpcHandler {
|
||||
"netbird manifests not available on this node — the signed catalog must provide apps/netbird-*/manifest.yml (legacy hardcoded installer removed in #20 ph4)"
|
||||
)
|
||||
}
|
||||
|
||||
/// Install the Pine voice-assistant stack (Whisper STT + Piper TTS + the
|
||||
/// setup/status launcher). Manifest-driven only, like netbird: render the
|
||||
/// 3-member stack from apps/pine-*/manifest.yml via the orchestrator
|
||||
/// (archy-net + network_aliases, published Wyoming ports 10300/10200 so
|
||||
/// Home Assistant reaches the engines via host.containers.internal, the
|
||||
/// launcher's setup page written via `files:`). The manifests use the exact
|
||||
/// live container names, so on an existing node this ADOPTS the running
|
||||
/// stack rather than recreating it (downloaded models/voices preserved).
|
||||
///
|
||||
/// There is no in-Rust hardcoded fallback: the signed catalog always ships
|
||||
/// apps/pine-*/manifest.yml. If the orchestrator doesn't know these app_ids
|
||||
/// and no running stack exists to adopt, install errors rather than
|
||||
/// silently diverging from the manifest contract.
|
||||
pub(super) async fn install_pine_stack(&self) -> Result<serde_json::Value> {
|
||||
if let Some(orchestrated) =
|
||||
install_stack_via_orchestrator(self, "pine", pine_stack_app_ids()).await?
|
||||
{
|
||||
return Ok(orchestrated);
|
||||
}
|
||||
|
||||
if let Some(adopted) =
|
||||
adopt_stack_if_exists("pine", "pine", &["pine-whisper", "pine-piper", "pine"]).await?
|
||||
{
|
||||
return Ok(adopted);
|
||||
}
|
||||
|
||||
anyhow::bail!(
|
||||
"pine manifests not available on this node — the signed catalog must provide apps/pine-*/manifest.yml"
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
|
||||
@@ -719,4 +719,94 @@ impl RpcHandler {
|
||||
_ => anyhow::bail!("Unknown setting: {}", key),
|
||||
}
|
||||
}
|
||||
|
||||
/// system.kiosk-display.get — Current kiosk display preset + whether this
|
||||
/// node has a kiosk at all (no kiosk unit -> the Settings section hides).
|
||||
pub(in crate::api::rpc) async fn handle_system_kiosk_display_get(
|
||||
&self,
|
||||
) -> Result<serde_json::Value> {
|
||||
let has_kiosk = tokio::fs::metadata("/etc/systemd/system/archipelago-kiosk.service")
|
||||
.await
|
||||
.is_ok();
|
||||
let conf = tokio::fs::read_to_string(KIOSK_DISPLAY_CONF)
|
||||
.await
|
||||
.unwrap_or_default();
|
||||
let preset = if conf.contains("ARCHIPELAGO_KIOSK_SCALE=1") {
|
||||
"native"
|
||||
} else if conf.contains("ARCHIPELAGO_KIOSK_TARGET_CSS_WIDTH=1920") {
|
||||
"balanced"
|
||||
} else if conf.contains("ARCHIPELAGO_KIOSK_TARGET_CSS_WIDTH=1280") {
|
||||
"large"
|
||||
} else {
|
||||
"auto"
|
||||
};
|
||||
Ok(serde_json::json!({ "has_kiosk": has_kiosk, "preset": preset }))
|
||||
}
|
||||
|
||||
/// system.kiosk-display.set — Write the kiosk display preset and restart
|
||||
/// the kiosk (only if it is running) so it takes effect immediately. The
|
||||
/// launcher sources /etc/archipelago/kiosk-display.conf at startup.
|
||||
pub(in crate::api::rpc) async fn handle_system_kiosk_display_set(
|
||||
&self,
|
||||
params: Option<serde_json::Value>,
|
||||
) -> Result<serde_json::Value> {
|
||||
let params = params.ok_or_else(|| anyhow::anyhow!("Missing params"))?;
|
||||
let preset = params
|
||||
.get("preset")
|
||||
.and_then(|v| v.as_str())
|
||||
.ok_or_else(|| anyhow::anyhow!("Missing preset"))?;
|
||||
|
||||
let conf = match preset {
|
||||
// Resolution-derived default: 4K -> 2.0 (1920-wide layout),
|
||||
// 1080p TV -> 1.5, laptop panels -> 1.0.
|
||||
"auto" => String::new(),
|
||||
// Biggest UI: every panel targets a 1280-wide layout.
|
||||
"large" => "ARCHIPELAGO_KIOSK_TARGET_CSS_WIDTH=1280\n".to_string(),
|
||||
// Full-HD layout on any panel that can carry it.
|
||||
"balanced" => "ARCHIPELAGO_KIOSK_TARGET_CSS_WIDTH=1920\n".to_string(),
|
||||
// No scaling: native CSS viewport, most content, smallest UI.
|
||||
"native" => "ARCHIPELAGO_KIOSK_SCALE=1\n".to_string(),
|
||||
other => anyhow::bail!("Unknown display preset: {other}"),
|
||||
};
|
||||
|
||||
host_sudo(&["/usr/bin/mkdir", "-p", "/etc/archipelago"]).await?;
|
||||
if conf.is_empty() {
|
||||
let _ = host_sudo(&["/usr/bin/rm", "-f", KIOSK_DISPLAY_CONF]).await;
|
||||
} else {
|
||||
// tee via sudo — the backend runs unprivileged and /etc is root's.
|
||||
let mut child = tokio::process::Command::new("/usr/bin/sudo")
|
||||
.args(["-n", "/usr/bin/tee", KIOSK_DISPLAY_CONF])
|
||||
.stdin(std::process::Stdio::piped())
|
||||
.stdout(std::process::Stdio::null())
|
||||
.stderr(std::process::Stdio::piped())
|
||||
.spawn()
|
||||
.context("spawn sudo tee for kiosk display conf")?;
|
||||
use tokio::io::AsyncWriteExt;
|
||||
if let Some(mut stdin) = child.stdin.take() {
|
||||
stdin.write_all(conf.as_bytes()).await?;
|
||||
}
|
||||
let out = child.wait_with_output().await?;
|
||||
if !out.status.success() {
|
||||
anyhow::bail!(
|
||||
"writing {} failed: {}",
|
||||
KIOSK_DISPLAY_CONF,
|
||||
String::from_utf8_lossy(&out.stderr).trim()
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// try-restart: only bounces a kiosk that is actually running, so this
|
||||
// never starts a kiosk an operator disabled.
|
||||
let _ = host_sudo(&[
|
||||
"/usr/bin/systemctl",
|
||||
"try-restart",
|
||||
"archipelago-kiosk.service",
|
||||
])
|
||||
.await;
|
||||
|
||||
info!(preset, "Kiosk display preset applied");
|
||||
Ok(serde_json::json!({ "preset": preset, "applied": true }))
|
||||
}
|
||||
}
|
||||
|
||||
const KIOSK_DISPLAY_CONF: &str = "/etc/archipelago/kiosk-display.conf";
|
||||
|
||||
@@ -50,6 +50,7 @@ pub fn stack_member_app_ids(package_id: &str) -> &'static [&'static str] {
|
||||
&["archy-btcpay-db", "archy-nbxplorer", "btcpay-server"]
|
||||
}
|
||||
"netbird" => &["netbird-server", "netbird-dashboard", "netbird"],
|
||||
"pine" => &["pine-whisper", "pine-piper", "pine"],
|
||||
// The legacy umbrella id maps to the split stack (the orchestrator's
|
||||
// umbrella alias handles this too; listing it here keeps the RPC
|
||||
// layer's fan-out explicit).
|
||||
@@ -75,7 +76,14 @@ pub fn address_caching_dependents(package_id: &str) -> &'static [&'static str] {
|
||||
/// `app_id` is a member (RPC ops on "mempool" hold the "mempool" lock while
|
||||
/// they drive archy-mempool-web), otherwise the app itself.
|
||||
fn owning_package(app_id: &str) -> &str {
|
||||
const STACKS: &[&str] = &["immich", "indeedhub", "btcpay-server", "netbird", "mempool"];
|
||||
const STACKS: &[&str] = &[
|
||||
"immich",
|
||||
"indeedhub",
|
||||
"btcpay-server",
|
||||
"netbird",
|
||||
"mempool",
|
||||
"pine",
|
||||
];
|
||||
for stack in STACKS {
|
||||
if stack_member_app_ids(stack).contains(&app_id) {
|
||||
return stack;
|
||||
|
||||
@@ -57,6 +57,8 @@ impl DockerPackageScanner {
|
||||
"indeedhub-build_ffmpeg-worker_1",
|
||||
"netbird-server",
|
||||
"netbird-dashboard",
|
||||
"pine-whisper",
|
||||
"pine-piper",
|
||||
"buildx_buildkit_default",
|
||||
];
|
||||
|
||||
|
||||
@@ -766,6 +766,9 @@ fn should_auto_start_stopped_container(name: &str, include_stack_members: bool)
|
||||
| "netbird-server"
|
||||
| "netbird-dashboard"
|
||||
| "netbird"
|
||||
| "pine-whisper"
|
||||
| "pine-piper"
|
||||
| "pine"
|
||||
)
|
||||
}
|
||||
|
||||
@@ -827,6 +830,20 @@ fn stack_recovery_specs() -> &'static [StackRecoverySpec] {
|
||||
containers: &["netbird-server", "netbird-dashboard", "netbird"],
|
||||
anchor: "netbird-server",
|
||||
},
|
||||
StackRecoverySpec {
|
||||
name: "pine",
|
||||
network: "archy-net",
|
||||
aliases: &[
|
||||
("pine-whisper", "pine-whisper"),
|
||||
("pine-piper", "pine-piper"),
|
||||
("pine", "pine"),
|
||||
],
|
||||
containers: &["pine-whisper", "pine-piper", "pine"],
|
||||
// The launcher only depends on the two engines; whisper is the
|
||||
// heaviest/first member, so treat it as the stack anchor (its
|
||||
// presence means the stack was really installed, not orphan debris).
|
||||
anchor: "pine-whisper",
|
||||
},
|
||||
]
|
||||
}
|
||||
|
||||
|
||||
@@ -421,6 +421,7 @@ pub fn spawn_mesh_listener(
|
||||
our_x25519_pubkey_hex: String,
|
||||
server_name: Option<String>,
|
||||
lora_region: Option<String>,
|
||||
lora_radio_params: Option<super::LoraRadioParams>,
|
||||
channel_name: Option<String>,
|
||||
device_kind: Option<super::types::DeviceType>,
|
||||
reticulum_tcp: Option<super::types::ReticulumTcpConfig>,
|
||||
@@ -456,6 +457,7 @@ pub fn spawn_mesh_listener(
|
||||
&our_x25519_pubkey_hex,
|
||||
server_name.as_deref(),
|
||||
lora_region.as_deref(),
|
||||
lora_radio_params,
|
||||
channel_name.as_deref(),
|
||||
device_kind,
|
||||
reticulum_tcp.clone(),
|
||||
|
||||
@@ -836,6 +836,10 @@ const MAX_REGION_PROVISION_ATTEMPTS: u32 = 3;
|
||||
static REGION_PROVISION_ATTEMPTS: std::sync::atomic::AtomicU32 =
|
||||
std::sync::atomic::AtomicU32::new(0);
|
||||
|
||||
/// Same retry-cap idea as the region, for the Meshcore radio-params write.
|
||||
static RADIO_PARAMS_PROVISION_ATTEMPTS: std::sync::atomic::AtomicU32 =
|
||||
std::sync::atomic::AtomicU32::new(0);
|
||||
|
||||
/// Same retry-cap idea as the region, for the shared-channel write.
|
||||
static CHANNEL_PROVISION_ATTEMPTS: std::sync::atomic::AtomicU32 =
|
||||
std::sync::atomic::AtomicU32::new(0);
|
||||
@@ -851,6 +855,7 @@ pub(super) async fn run_mesh_session(
|
||||
our_x25519_pubkey_hex: &str,
|
||||
server_name: Option<&str>,
|
||||
lora_region: Option<&str>,
|
||||
lora_radio_params: Option<crate::mesh::LoraRadioParams>,
|
||||
channel_name: Option<&str>,
|
||||
device_kind: Option<DeviceType>,
|
||||
reticulum_tcp: Option<ReticulumTcpConfig>,
|
||||
@@ -978,6 +983,62 @@ pub(super) async fn run_mesh_session(
|
||||
);
|
||||
}
|
||||
|
||||
// Provision Meshcore LoRa PHY params (freq/bw/sf/cr) when the operator has
|
||||
// configured them. Meshcore-only: Meshtastic radios get region+preset via
|
||||
// ensure_lora_region above, and Reticulum carries its own RNode profile.
|
||||
// Gated on a persisted marker of the last-applied params rather than the
|
||||
// device's SELF_INFO readback (its field offsets shift across firmware
|
||||
// versions), so we send the set-command once per configured value and never
|
||||
// reboot-loop a radio that refuses it. The firmware reboots on RESP_OK, so
|
||||
// a successful write restarts the session like the region path.
|
||||
if let (Some(params), MeshRadioDevice::Meshcore(dev)) = (lora_radio_params, &mut device) {
|
||||
let marker_path = data_dir.join("meshcore-radio-params.json");
|
||||
let applied: Option<crate::mesh::LoraRadioParams> = tokio::fs::read(&marker_path)
|
||||
.await
|
||||
.ok()
|
||||
.and_then(|b| serde_json::from_slice(&b).ok());
|
||||
if applied != Some(params) {
|
||||
let attempts = RADIO_PARAMS_PROVISION_ATTEMPTS.load(Ordering::Relaxed);
|
||||
if attempts < MAX_REGION_PROVISION_ATTEMPTS {
|
||||
match dev
|
||||
.set_radio_params(params.freq_khz, params.bw_hz, params.sf, params.cr)
|
||||
.await
|
||||
{
|
||||
Ok(()) => {
|
||||
RADIO_PARAMS_PROVISION_ATTEMPTS.fetch_add(1, Ordering::Relaxed);
|
||||
if let Ok(json) = serde_json::to_vec(¶ms) {
|
||||
if let Err(e) = tokio::fs::write(&marker_path, json).await {
|
||||
warn!("Failed to persist radio-params marker: {}", e);
|
||||
}
|
||||
}
|
||||
info!(
|
||||
freq_khz = params.freq_khz,
|
||||
bw_hz = params.bw_hz,
|
||||
sf = params.sf,
|
||||
cr = params.cr,
|
||||
"Provisioned Meshcore radio params — radio rebooting, \
|
||||
restarting mesh session"
|
||||
);
|
||||
tokio::time::sleep(Duration::from_secs(10)).await;
|
||||
return Ok(());
|
||||
}
|
||||
Err(e) => {
|
||||
RADIO_PARAMS_PROVISION_ATTEMPTS.fetch_add(1, Ordering::Relaxed);
|
||||
warn!("Failed to provision Meshcore radio params: {}", e);
|
||||
}
|
||||
}
|
||||
} else {
|
||||
warn!(
|
||||
attempts = MAX_REGION_PROVISION_ATTEMPTS,
|
||||
"Meshcore radio rejected the configured radio params after \
|
||||
repeated attempts — continuing with the device's own settings."
|
||||
);
|
||||
}
|
||||
} else {
|
||||
RADIO_PARAMS_PROVISION_ATTEMPTS.store(0, Ordering::Relaxed);
|
||||
}
|
||||
}
|
||||
|
||||
// Set advert name to the server's human-readable name (e.g. "ThinkPad"),
|
||||
// falling back to the DID fragment if no name is configured.
|
||||
let advert_name = if let Some(name) = server_name {
|
||||
|
||||
@@ -322,6 +322,22 @@ pub(crate) async fn seed_federation_peers_into_mesh(
|
||||
}
|
||||
}
|
||||
|
||||
/// Operator-configured LoRa PHY parameters for a Meshcore radio, in the
|
||||
/// firmware's own field units: `freq_khz` = MHz×1000 (869618 → 869.618 MHz),
|
||||
/// `bw_hz` = kHz×1000 (62500 → 62.5 kHz), `sf` 5..=12, `cr` 5..=8. These are
|
||||
/// region/deployment-specific (e.g. the Portugal preset 869618/62500/8/8) and
|
||||
/// MUST match every radio on the local mesh — a mismatched radio hears RF
|
||||
/// energy but demodulates nothing. None (the default) leaves the device's own
|
||||
/// settings untouched, so nodes outside the configured deployment are never
|
||||
/// affected.
|
||||
#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)]
|
||||
pub struct LoraRadioParams {
|
||||
pub freq_khz: u32,
|
||||
pub bw_hz: u32,
|
||||
pub sf: u8,
|
||||
pub cr: u8,
|
||||
}
|
||||
|
||||
/// Mesh configuration (persisted to disk).
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct MeshConfig {
|
||||
@@ -340,6 +356,11 @@ pub struct MeshConfig {
|
||||
/// unset/None.
|
||||
#[serde(default)]
|
||||
pub lora_region: Option<String>,
|
||||
/// Meshcore LoRa PHY parameters (freq/bw/sf/cr). Provisioned onto the
|
||||
/// radio on connect when set; None leaves the device untouched. Ignored
|
||||
/// for Meshtastic (region/preset covers it) and Reticulum.
|
||||
#[serde(default)]
|
||||
pub lora_radio_params: Option<LoraRadioParams>,
|
||||
/// Whether to periodically broadcast our identity.
|
||||
#[serde(default)]
|
||||
pub broadcast_identity: bool,
|
||||
@@ -422,6 +443,7 @@ impl Default for MeshConfig {
|
||||
device_path: None,
|
||||
channel_name: Some("archipelago".to_string()),
|
||||
lora_region: None,
|
||||
lora_radio_params: None,
|
||||
broadcast_identity: true,
|
||||
advert_name: None,
|
||||
mesh_only_mode: None,
|
||||
@@ -722,6 +744,7 @@ impl MeshService {
|
||||
self.our_x25519_pubkey_hex.clone(),
|
||||
self.server_name.clone(),
|
||||
self.config.lora_region.clone(),
|
||||
self.config.lora_radio_params,
|
||||
self.config.channel_name.clone(),
|
||||
self.config.device_kind,
|
||||
self.config.reticulum_tcp.clone(),
|
||||
|
||||
@@ -210,6 +210,24 @@ pub fn build_set_device_time(unix_secs: u64) -> Vec<u8> {
|
||||
encode_frame(&data)
|
||||
}
|
||||
|
||||
/// CMD_SET_RADIO_PARAMS (0x0B): set the LoRa PHY config. The device reboots to
|
||||
/// apply. `freq_field` and `bw_field` are the raw firmware fields (freq =
|
||||
/// MHz×1000 e.g. 869618 for 869.618 MHz; bw = kHz×1000 e.g. 62500 for 62.5 kHz);
|
||||
/// `sf` is 5..=12 and `cr` is 5..=8. Wire format verified against the MeshCore
|
||||
/// companion firmware handler (`examples/companion_radio/MyMesh.cpp`,
|
||||
/// `CMD_SET_RADIO_PARAMS`): `[11][freq:u32 LE][bw:u32 LE][sf:u8][cr:u8]`. The
|
||||
/// same fields (same units) come back in the SELF_INFO reply, so a caller can
|
||||
/// read them to detect drift. Values outside the firmware's accepted ranges are
|
||||
/// rejected by the device (it replies with an error frame), not clamped here.
|
||||
pub fn build_set_radio_params(freq_field: u32, bw_field: u32, sf: u8, cr: u8) -> Vec<u8> {
|
||||
let mut data = vec![CMD_SET_RADIO_PARAMS];
|
||||
data.extend_from_slice(&freq_field.to_le_bytes());
|
||||
data.extend_from_slice(&bw_field.to_le_bytes());
|
||||
data.push(sf);
|
||||
data.push(cr);
|
||||
encode_frame(&data)
|
||||
}
|
||||
|
||||
/// CMD_SET_ADVERT_NAME (0x08): Set the node's advertised name on the mesh.
|
||||
pub fn build_set_advert_name(name: &str) -> Vec<u8> {
|
||||
let mut data = vec![CMD_SET_ADVERT_NAME];
|
||||
@@ -730,6 +748,29 @@ mod tests {
|
||||
assert_eq!(frame[4], PROTOCOL_VERSION);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_build_set_radio_params_wire_layout() {
|
||||
// Portugal preset: 869.618 MHz, 62.5 kHz BW, SF 8, CR 8.
|
||||
// freq field = MHz*1000 = 869618; bw field = kHz*1000 = 62500.
|
||||
let frame = build_set_radio_params(869_618, 62_500, 8, 8);
|
||||
assert_eq!(frame[0], OUTBOUND_MARKER);
|
||||
// payload length = 1 (cmd) + 4 (freq) + 4 (bw) + 1 (sf) + 1 (cr) = 11
|
||||
assert_eq!(u16::from_le_bytes([frame[1], frame[2]]), 11);
|
||||
let data = &frame[3..];
|
||||
assert_eq!(data[0], CMD_SET_RADIO_PARAMS);
|
||||
assert_eq!(
|
||||
u32::from_le_bytes([data[1], data[2], data[3], data[4]]),
|
||||
869_618
|
||||
);
|
||||
assert_eq!(
|
||||
u32::from_le_bytes([data[5], data[6], data[7], data[8]]),
|
||||
62_500
|
||||
);
|
||||
assert_eq!(data[9], 8); // sf
|
||||
assert_eq!(data[10], 8); // cr
|
||||
assert_eq!(data.len(), 11);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_decode_frame_complete() -> Result<()> {
|
||||
// Simulate an inbound frame: < + len(2) + [RESP_OK]
|
||||
|
||||
@@ -164,6 +164,29 @@ impl MeshcoreDevice {
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Set the radio's LoRa PHY parameters (freq/bw/sf/cr, firmware field
|
||||
/// units — see `protocol::build_set_radio_params`). On RESP_OK the
|
||||
/// firmware persists the params and reboots to apply them, so the caller
|
||||
/// must treat the session as gone and reconnect.
|
||||
pub async fn set_radio_params(
|
||||
&mut self,
|
||||
freq_khz: u32,
|
||||
bw_hz: u32,
|
||||
sf: u8,
|
||||
cr: u8,
|
||||
) -> Result<()> {
|
||||
self.send_raw(&protocol::build_set_radio_params(freq_khz, bw_hz, sf, cr))
|
||||
.await?;
|
||||
let frame = self.recv_frame_timeout(READ_TIMEOUT).await?;
|
||||
if frame.code == protocol::RESP_ERR {
|
||||
anyhow::bail!(
|
||||
"Set radio params failed: {}",
|
||||
protocol::parse_error(&frame.data)
|
||||
);
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Broadcast our advertisement to the mesh.
|
||||
pub async fn send_self_advert(&mut self) -> Result<()> {
|
||||
self.send_raw(&protocol::build_send_self_advert()).await?;
|
||||
|
||||
@@ -384,6 +384,7 @@ RUN apt-get update && apt-get -y full-upgrade && apt-get install -y --no-install
|
||||
chromium \
|
||||
unclutter \
|
||||
fonts-liberation \
|
||||
fonts-noto-color-emoji \
|
||||
xfonts-base \
|
||||
plymouth \
|
||||
plymouth-themes \
|
||||
|
||||
@@ -22,6 +22,11 @@ if [ "$X_READY" != "true" ]; then
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# Settings-managed display overrides (system.kiosk-display.set writes this
|
||||
# file, then restarts the kiosk): may set ARCHIPELAGO_KIOSK_SCALE,
|
||||
# ARCHIPELAGO_KIOSK_TARGET_CSS_WIDTH or ARCHIPELAGO_KIOSK_MAX_WIDTH.
|
||||
[ -f /etc/archipelago/kiosk-display.conf ] && . /etc/archipelago/kiosk-display.conf
|
||||
|
||||
KIOSK_SAFE_AREA_X_PX=${ARCHIPELAGO_KIOSK_SAFE_AREA_X_PX:-}
|
||||
KIOSK_SAFE_AREA_Y_PX=${ARCHIPELAGO_KIOSK_SAFE_AREA_Y_PX:-}
|
||||
|
||||
@@ -54,6 +59,25 @@ configure_display() {
|
||||
' /tmp/archipelago-kiosk-xrandr.txt)
|
||||
[ -n "$mode" ] || mode=1920x1080
|
||||
|
||||
# Optional resolution cap for weak hardware: ARCHIPELAGO_KIOSK_MAX_WIDTH=1920
|
||||
# drops a 4K panel to its best <=1920-wide mode (the TV upscales) so the
|
||||
# software rasterizer paints 1/4 the pixels. Off by default — native mode
|
||||
# is sharp and fits the tile budget now that --window-size is in DIPs.
|
||||
local max_w=${ARCHIPELAGO_KIOSK_MAX_WIDTH:-0}
|
||||
if [ "$max_w" -gt 0 ] 2>/dev/null && [ "${mode%x*}" -gt "$max_w" ] 2>/dev/null; then
|
||||
local capped
|
||||
capped=$(awk -v out="$output" -v maxw="$max_w" '
|
||||
$1 == out { active = 1; next }
|
||||
active && /^[[:space:]]+[0-9]+x[0-9]+/ {
|
||||
split($1, wh, "x")
|
||||
if (wh[1] + 0 <= maxw && wh[1] + 0 > best_w) { best_w = wh[1] + 0; best = $1 }
|
||||
}
|
||||
active && /^[^[:space:]]/ { active = 0 }
|
||||
END { if (best) print best }
|
||||
' /tmp/archipelago-kiosk-xrandr.txt)
|
||||
[ -n "$capped" ] && mode=$capped
|
||||
fi
|
||||
|
||||
# Kiosk should use one native output. A spanning desktop makes Chromium land
|
||||
# on the laptop panel or stretch across both outputs.
|
||||
for internal in $(awk '/ connected/ && $1 ~ /^eDP|^LVDS/{print $1}' /tmp/archipelago-kiosk-xrandr.txt); do
|
||||
@@ -90,9 +114,17 @@ configure_display
|
||||
# panel becomes a 3840px-wide viewport and the UI renders tiny — and a
|
||||
# keyboard-less kiosk can't zoom. Derive Chromium's device-scale-factor from
|
||||
# the detected panel width so the *effective* CSS viewport lands near a
|
||||
# comfortable across-the-room target, scaling the whole UI up on big screens:
|
||||
# 1920x1080 -> 1.50 | 2560x1440 -> 2.00 | 3840x2160 -> 3.00 (clamped 1.0-3.0)
|
||||
KIOSK_TARGET_CSS_WIDTH=${ARCHIPELAGO_KIOSK_TARGET_CSS_WIDTH:-1280}
|
||||
# comfortable target. Panels >=2560 wide get a 1920-wide layout (4K -> scale
|
||||
# 2.0 — user-validated on a 72" TV: spacious desktop layout, 2x sharp);
|
||||
# smaller panels get a 1280-wide layout (1920 -> 1.50, laptops -> 1.0).
|
||||
if [ -z "${ARCHIPELAGO_KIOSK_TARGET_CSS_WIDTH:-}" ]; then
|
||||
if [ "$KIOSK_MODE_W" -ge 2560 ] 2>/dev/null; then
|
||||
ARCHIPELAGO_KIOSK_TARGET_CSS_WIDTH=1920
|
||||
else
|
||||
ARCHIPELAGO_KIOSK_TARGET_CSS_WIDTH=1280
|
||||
fi
|
||||
fi
|
||||
KIOSK_TARGET_CSS_WIDTH=$ARCHIPELAGO_KIOSK_TARGET_CSS_WIDTH
|
||||
if [ -n "${ARCHIPELAGO_KIOSK_SCALE:-}" ]; then
|
||||
KIOSK_SCALE=$ARCHIPELAGO_KIOSK_SCALE
|
||||
else
|
||||
@@ -100,6 +132,14 @@ else
|
||||
'BEGIN{if(t<=0)t=1280; s=w/t; s=int(s*4+0.5)/4; if(s<1)s=1; if(s>3)s=3; printf "%.2f", s}')
|
||||
fi
|
||||
|
||||
# Chromium's --window-size is in DIPs (CSS px), not physical pixels: at scale S
|
||||
# the window paints S x larger. This X session has no window manager, so
|
||||
# --kiosk/--start-fullscreen cannot snap an oversized window back to the panel
|
||||
# — it just hangs off the right/bottom edges (content cropped, background art
|
||||
# offscreen). Size the window in DIPs so DIPs x scale = the panel exactly.
|
||||
KIOSK_WIN_W=$(awk -v w="$KIOSK_MODE_W" -v s="$KIOSK_SCALE" 'BEGIN{printf "%d", w/s}')
|
||||
KIOSK_WIN_H=$(awk -v h="$KIOSK_MODE_H" -v s="$KIOSK_SCALE" 'BEGIN{printf "%d", h/s}')
|
||||
|
||||
xhost +SI:localuser:archipelago 2>/dev/null || true
|
||||
xsetroot -solid black 2>/dev/null || true
|
||||
xset s off 2>/dev/null || true
|
||||
@@ -157,7 +197,7 @@ while true; do
|
||||
--disable-component-update \
|
||||
$GPU_FLAGS \
|
||||
--renderer-process-limit=2 \
|
||||
--window-size=${KIOSK_MODE_W},${KIOSK_MODE_H} \
|
||||
--window-size=${KIOSK_WIN_W},${KIOSK_WIN_H} \
|
||||
--window-position=0,0 \
|
||||
--start-fullscreen \
|
||||
--force-device-scale-factor=${KIOSK_SCALE} \
|
||||
|
||||
Generated
+94
-9
@@ -1,22 +1,25 @@
|
||||
{
|
||||
"name": "neode-ui",
|
||||
"version": "1.7.108-alpha",
|
||||
"version": "1.7.110-alpha",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "neode-ui",
|
||||
"version": "1.7.108-alpha",
|
||||
"version": "1.7.110-alpha",
|
||||
"dependencies": {
|
||||
"@types/dompurify": "^3.0.5",
|
||||
"@vue-leaflet/vue-leaflet": "^0.10.1",
|
||||
"buffer": "^6.0.3",
|
||||
"d3": "^7.9.0",
|
||||
"dompurify": "^3.3.3",
|
||||
"fast-json-patch": "^3.1.1",
|
||||
"fuse.js": "^7.1.0",
|
||||
"leaflet": "^1.9.4",
|
||||
"pinia": "^3.0.4",
|
||||
"qr-scanner": "^1.4.2",
|
||||
"qrcode": "^1.5.4",
|
||||
"qrloop": "^1.4.1",
|
||||
"vue": "^3.5.24",
|
||||
"vue-i18n": "^11.3.0",
|
||||
"vue-router": "^4.6.3"
|
||||
@@ -3896,6 +3899,12 @@
|
||||
"undici-types": "~7.16.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@types/offscreencanvas": {
|
||||
"version": "2019.7.3",
|
||||
"resolved": "https://registry.npmjs.org/@types/offscreencanvas/-/offscreencanvas-2019.7.3.tgz",
|
||||
"integrity": "sha512-ieXiYmgSRXUDeOntE1InxjWyvEelZGP63M+cGuquuRLuIKKT1osnkXjxev9B7d1nXSug5vpunx+gNlbVxMlC9A==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@types/qrcode": {
|
||||
"version": "1.5.6",
|
||||
"resolved": "https://registry.npmjs.org/@types/qrcode/-/qrcode-1.5.6.tgz",
|
||||
@@ -4727,7 +4736,6 @@
|
||||
"version": "1.5.1",
|
||||
"resolved": "https://registry.npmjs.org/base64-js/-/base64-js-1.5.1.tgz",
|
||||
"integrity": "sha512-AKpaYlHn8t4SVbOHCy+b5+KKgvR4vrsD8vbvrbiQJps7fKDTkjkDry6ji0rUJjC0kzbNePLwzxq8iypo41qeWA==",
|
||||
"dev": true,
|
||||
"funding": [
|
||||
{
|
||||
"type": "github",
|
||||
@@ -4801,6 +4809,31 @@
|
||||
"readable-stream": "^3.4.0"
|
||||
}
|
||||
},
|
||||
"node_modules/bl/node_modules/buffer": {
|
||||
"version": "5.7.1",
|
||||
"resolved": "https://registry.npmjs.org/buffer/-/buffer-5.7.1.tgz",
|
||||
"integrity": "sha512-EHcyIPBQ4BSGlvjB16k5KgAJ27CIsHY/2JBmCRReo48y9rQ3MaUzWX3KVlBa4U7MyX02HdVj0K7C3WaB3ju7FQ==",
|
||||
"dev": true,
|
||||
"funding": [
|
||||
{
|
||||
"type": "github",
|
||||
"url": "https://github.com/sponsors/feross"
|
||||
},
|
||||
{
|
||||
"type": "patreon",
|
||||
"url": "https://www.patreon.com/feross"
|
||||
},
|
||||
{
|
||||
"type": "consulting",
|
||||
"url": "https://feross.org/support"
|
||||
}
|
||||
],
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"base64-js": "^1.3.1",
|
||||
"ieee754": "^1.1.13"
|
||||
}
|
||||
},
|
||||
"node_modules/body-parser": {
|
||||
"version": "1.20.4",
|
||||
"resolved": "https://registry.npmjs.org/body-parser/-/body-parser-1.20.4.tgz",
|
||||
@@ -4901,10 +4934,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/buffer": {
|
||||
"version": "5.7.1",
|
||||
"resolved": "https://registry.npmjs.org/buffer/-/buffer-5.7.1.tgz",
|
||||
"integrity": "sha512-EHcyIPBQ4BSGlvjB16k5KgAJ27CIsHY/2JBmCRReo48y9rQ3MaUzWX3KVlBa4U7MyX02HdVj0K7C3WaB3ju7FQ==",
|
||||
"dev": true,
|
||||
"version": "6.0.3",
|
||||
"resolved": "https://registry.npmjs.org/buffer/-/buffer-6.0.3.tgz",
|
||||
"integrity": "sha512-FTiCpNxtwiZZHEZbcbTIcZjERVICn9yq/pDFkTl95/AxzD1naBctN7YO68riM/gLSDY7sdrMby8hofADYuuqOA==",
|
||||
"funding": [
|
||||
{
|
||||
"type": "github",
|
||||
@@ -4922,7 +4954,7 @@
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"base64-js": "^1.3.1",
|
||||
"ieee754": "^1.1.13"
|
||||
"ieee754": "^1.2.1"
|
||||
}
|
||||
},
|
||||
"node_modules/buffer-from": {
|
||||
@@ -5099,6 +5131,15 @@
|
||||
"node": ">=8"
|
||||
}
|
||||
},
|
||||
"node_modules/charenc": {
|
||||
"version": "0.0.2",
|
||||
"resolved": "https://registry.npmjs.org/charenc/-/charenc-0.0.2.tgz",
|
||||
"integrity": "sha512-yrLQ/yVUFXkzg7EDQsPieE/53+0RlaWTs+wBrvW36cyilJ2SaDWfl4Yj7MtLTXleV9uEKefbAGUPv2/iWSooRA==",
|
||||
"license": "BSD-3-Clause",
|
||||
"engines": {
|
||||
"node": "*"
|
||||
}
|
||||
},
|
||||
"node_modules/check-error": {
|
||||
"version": "2.1.3",
|
||||
"resolved": "https://registry.npmjs.org/check-error/-/check-error-2.1.3.tgz",
|
||||
@@ -5499,6 +5540,15 @@
|
||||
"node": ">= 8"
|
||||
}
|
||||
},
|
||||
"node_modules/crypt": {
|
||||
"version": "0.0.2",
|
||||
"resolved": "https://registry.npmjs.org/crypt/-/crypt-0.0.2.tgz",
|
||||
"integrity": "sha512-mCxBlsHFYh9C+HVpiEacem8FEBnMXgU9gy4zmNC+SXAZNB/1idgp/aulFJ4FgCi7GPEVbfyng092GqL2k2rmow==",
|
||||
"license": "BSD-3-Clause",
|
||||
"engines": {
|
||||
"node": "*"
|
||||
}
|
||||
},
|
||||
"node_modules/crypto-random-string": {
|
||||
"version": "2.0.0",
|
||||
"resolved": "https://registry.npmjs.org/crypto-random-string/-/crypto-random-string-2.0.0.tgz",
|
||||
@@ -7408,7 +7458,6 @@
|
||||
"version": "1.2.1",
|
||||
"resolved": "https://registry.npmjs.org/ieee754/-/ieee754-1.2.1.tgz",
|
||||
"integrity": "sha512-dcyqhDvX1C46lXZcVqCpK+FtMRQVdIMN6/Df5js2zouUsqG7I6sFxitIC+7KYK29KdXOLHdu9zL4sFnoVQnqaA==",
|
||||
"dev": true,
|
||||
"funding": [
|
||||
{
|
||||
"type": "github",
|
||||
@@ -7564,6 +7613,12 @@
|
||||
"url": "https://github.com/sponsors/ljharb"
|
||||
}
|
||||
},
|
||||
"node_modules/is-buffer": {
|
||||
"version": "1.1.6",
|
||||
"resolved": "https://registry.npmjs.org/is-buffer/-/is-buffer-1.1.6.tgz",
|
||||
"integrity": "sha512-NcdALwpXkTm5Zvvbk7owOUSvVvBKDgKP5/ewfXEznmQFfs4ZRmanOeKBTjRVjka3QFoN6XJ+9F3USqfHqTaU5w==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/is-callable": {
|
||||
"version": "1.2.7",
|
||||
"resolved": "https://registry.npmjs.org/is-callable/-/is-callable-1.2.7.tgz",
|
||||
@@ -8358,6 +8413,17 @@
|
||||
"node": ">= 0.4"
|
||||
}
|
||||
},
|
||||
"node_modules/md5": {
|
||||
"version": "2.3.0",
|
||||
"resolved": "https://registry.npmjs.org/md5/-/md5-2.3.0.tgz",
|
||||
"integrity": "sha512-T1GITYmFaKuO91vxyoQMFETst+O71VUPEU3ze5GNzDm0OWdP8v1ziTaAEPUr/3kLsY3Sftgz242A1SetQiDL7g==",
|
||||
"license": "BSD-3-Clause",
|
||||
"dependencies": {
|
||||
"charenc": "0.0.2",
|
||||
"crypt": "0.0.2",
|
||||
"is-buffer": "~1.1.6"
|
||||
}
|
||||
},
|
||||
"node_modules/media-typer": {
|
||||
"version": "0.3.0",
|
||||
"resolved": "https://registry.npmjs.org/media-typer/-/media-typer-0.3.0.tgz",
|
||||
@@ -9193,6 +9259,15 @@
|
||||
"node": ">=6"
|
||||
}
|
||||
},
|
||||
"node_modules/qr-scanner": {
|
||||
"version": "1.4.2",
|
||||
"resolved": "https://registry.npmjs.org/qr-scanner/-/qr-scanner-1.4.2.tgz",
|
||||
"integrity": "sha512-kV1yQUe2FENvn59tMZW6mOVfpq9mGxGf8l6+EGaXUOd4RBOLg7tRC83OrirM5AtDvZRpdjdlXURsHreAOSPOUw==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@types/offscreencanvas": "^2019.6.4"
|
||||
}
|
||||
},
|
||||
"node_modules/qrcode": {
|
||||
"version": "1.5.4",
|
||||
"resolved": "https://registry.npmjs.org/qrcode/-/qrcode-1.5.4.tgz",
|
||||
@@ -9317,6 +9392,16 @@
|
||||
"node": ">=6"
|
||||
}
|
||||
},
|
||||
"node_modules/qrloop": {
|
||||
"version": "1.4.1",
|
||||
"resolved": "https://registry.npmjs.org/qrloop/-/qrloop-1.4.1.tgz",
|
||||
"integrity": "sha512-LXkwCl1Qd8imTHb+KqjMn+cHmuncyFT81AXoooWJvbG3+g9q61l9udSRPgY4cgl+5goQHuAK4teEdUF6UErYXw==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"buffer": "^6.0.3",
|
||||
"md5": "^2.3.0"
|
||||
}
|
||||
},
|
||||
"node_modules/qs": {
|
||||
"version": "6.14.2",
|
||||
"resolved": "https://registry.npmjs.org/qs/-/qs-6.14.2.tgz",
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"name": "neode-ui",
|
||||
"private": true,
|
||||
"version": "1.7.108-alpha",
|
||||
"version": "1.7.110-alpha",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"start": "./start-dev.sh",
|
||||
@@ -26,13 +26,16 @@
|
||||
"dependencies": {
|
||||
"@types/dompurify": "^3.0.5",
|
||||
"@vue-leaflet/vue-leaflet": "^0.10.1",
|
||||
"buffer": "^6.0.3",
|
||||
"d3": "^7.9.0",
|
||||
"dompurify": "^3.3.3",
|
||||
"fast-json-patch": "^3.1.1",
|
||||
"fuse.js": "^7.1.0",
|
||||
"leaflet": "^1.9.4",
|
||||
"pinia": "^3.0.4",
|
||||
"qr-scanner": "^1.4.2",
|
||||
"qrcode": "^1.5.4",
|
||||
"qrloop": "^1.4.1",
|
||||
"vue": "^3.5.24",
|
||||
"vue-i18n": "^11.3.0",
|
||||
"vue-router": "^4.6.3"
|
||||
|
||||
@@ -0,0 +1,11 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 512 512" role="img" aria-label="Pine">
|
||||
<rect width="512" height="512" fill="#ffffff"/>
|
||||
<g fill="#000000">
|
||||
<!-- trunk -->
|
||||
<rect x="236" y="396" width="40" height="72" rx="6"/>
|
||||
<!-- three tiers of the evergreen, top to bottom -->
|
||||
<path d="M256 44 L336 168 L296 168 L256 108 L216 168 L176 168 Z"/>
|
||||
<path d="M256 150 L360 300 L300 300 L256 236 L212 300 L152 300 Z"/>
|
||||
<path d="M256 262 L392 430 L120 430 L256 262 Z"/>
|
||||
</g>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 508 B |
@@ -370,6 +370,17 @@
|
||||
]
|
||||
}
|
||||
},
|
||||
{
|
||||
"id": "pine",
|
||||
"title": "Pine",
|
||||
"version": "1.1.1",
|
||||
"description": "A private voice assistant for your home. Pine runs speech-to-text (Whisper) and text-to-speech (Piper) on your own node and pairs with a PineVoice satellite speaker, so Home Assistant Assist works locally with nothing sent to the cloud.",
|
||||
"icon": "/assets/img/app-icons/pine.svg",
|
||||
"author": "Archipelago",
|
||||
"category": "home",
|
||||
"dockerImage": "docker.io/library/nginx:1.27-alpine",
|
||||
"repoUrl": "https://github.com/rhasspy/wyoming"
|
||||
},
|
||||
{
|
||||
"id": "grafana",
|
||||
"title": "Grafana",
|
||||
|
||||
Binary file not shown.
+13
-7
@@ -131,6 +131,13 @@ function syncKioskSafeArea() {
|
||||
// (--in-process-gpu / --disable-gpu), where heavy compositing effects
|
||||
// (3D bg layers, animated mix-blend overlays) fail and paint black.
|
||||
document.documentElement.classList.toggle('kiosk-mode', isKiosk)
|
||||
if (isKiosk) {
|
||||
// AIUI (same-origin iframe) themes from localStorage['aiui-theme'], and a
|
||||
// stored value beats its prefers-color-scheme fallback. Kiosk profiles
|
||||
// that ran before the launcher forced a dark color-scheme have "light"
|
||||
// baked in — white panels on an otherwise dark TV UI — so pin it dark.
|
||||
try { localStorage.setItem('aiui-theme', 'dark') } catch { /* storage full/denied */ }
|
||||
}
|
||||
const rawSafeArea = localStorage.getItem('archipelago_kiosk_safe_area_px') || '0'
|
||||
const safeArea = /^\d{1,3}$/.test(rawSafeArea) ? Number(rawSafeArea) : 0
|
||||
const rawSafeAreaX = localStorage.getItem('archipelago_kiosk_safe_area_x_px') || rawSafeArea
|
||||
@@ -152,13 +159,12 @@ watch(() => appStore.isAuthenticated, (authenticated) => {
|
||||
if (authenticated) {
|
||||
messageToast.startPolling()
|
||||
screensaverStore.resetInactivityTimer()
|
||||
// Don't start relay on kiosk — kiosk gets input via xdotool (system-level),
|
||||
// relay would duplicate every keystroke/click as DOM events
|
||||
const isKiosk = localStorage.getItem('kiosk') === 'true'
|
||||
|| new URLSearchParams(window.location.search).has('kiosk')
|
||||
if (!isKiosk) {
|
||||
startRemoteRelay()
|
||||
}
|
||||
// Kiosk included: the backend's remote-input path is relay-only (it
|
||||
// validates and broadcasts; it never runs xdotool), so the browser-side
|
||||
// relay is the ONLY consumer of companion input. Skipping it on kiosk —
|
||||
// an assumption from a long-gone system-level xdotool injector — left
|
||||
// TVs deaf to the companion remote.
|
||||
startRemoteRelay()
|
||||
} else {
|
||||
messageToast.stopPolling()
|
||||
toastMessage.value = { show: false, text: '', fromPubkey: '' }
|
||||
|
||||
@@ -27,9 +27,8 @@
|
||||
<div class="flex-1 min-w-0">
|
||||
<p class="text-white/90 text-sm font-semibold mb-0.5">Open a channel with Zeus</p>
|
||||
<p class="text-white/55 text-xs leading-relaxed">
|
||||
Pair your node with the Zeus mobile wallet — open a channel to their Olympus node and
|
||||
start sending and receiving Lightning payments from your phone.
|
||||
Minimum 150,000 · maximum 1,500,000 sats.
|
||||
Open a channel with Zeus Olympus node and start sending and receiving Lightning
|
||||
payments. Minimum 150,000 · maximum 1,500,000 on-chain sats required.
|
||||
</p>
|
||||
</div>
|
||||
<div class="flex sm:flex-col items-center gap-2 shrink-0">
|
||||
|
||||
@@ -72,6 +72,12 @@
|
||||
|
||||
<div class="flex gap-3">
|
||||
<button @click="close" class="flex-1 glass-button px-4 py-2 rounded-lg text-sm">{{ t('common.close') }}</button>
|
||||
<button @click="$emit('scan')" class="flex-1 glass-button px-4 py-2 rounded-lg text-sm font-medium flex items-center justify-center gap-2">
|
||||
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M3 8V6a2 2 0 012-2h2M3 16v2a2 2 0 002 2h2m10-16h2a2 2 0 012 2v2m-4 12h2a2 2 0 002-2v-2M7 12h10" />
|
||||
</svg>
|
||||
Scan
|
||||
</button>
|
||||
<button @click="receive" :disabled="processing" class="flex-1 glass-button glass-button-success px-4 py-2 rounded-lg text-sm font-medium disabled:opacity-50">
|
||||
{{ processing ? t('receiveBitcoin.processing') : receiveMethod === 'onchain' ? t('receiveBitcoin.generateAddress') : receiveMethod === 'lightning' ? t('receiveBitcoin.createInvoice') : receiveMethod === 'ark' ? 'Get Ark address' : t('receiveBitcoin.receive') }}
|
||||
</button>
|
||||
@@ -95,7 +101,7 @@ const props = defineProps<{
|
||||
/** Generate an on-chain address immediately when the modal opens */
|
||||
autoGenerate?: boolean
|
||||
}>()
|
||||
const emit = defineEmits<{ close: []; received: [] }>()
|
||||
const emit = defineEmits<{ close: []; received: []; scan: [] }>()
|
||||
|
||||
watch(() => props.show, (open) => {
|
||||
if (open && props.autoGenerate && receiveMethod.value === 'onchain' && !onchainAddress.value) {
|
||||
|
||||
@@ -69,6 +69,12 @@
|
||||
|
||||
<div class="flex gap-3">
|
||||
<button @click="close" class="flex-1 glass-button px-4 py-2 rounded-lg text-sm">{{ t('common.close') }}</button>
|
||||
<button @click="$emit('scan')" class="flex-1 glass-button px-4 py-2 rounded-lg text-sm font-medium flex items-center justify-center gap-2">
|
||||
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M3 8V6a2 2 0 012-2h2M3 16v2a2 2 0 002 2h2m10-16h2a2 2 0 012 2v2m-4 12h2a2 2 0 002-2v-2M7 12h10" />
|
||||
</svg>
|
||||
Scan
|
||||
</button>
|
||||
<button @click="send" :disabled="processing || (!amount && !isSweep)" class="flex-1 glass-button glass-button-warning px-4 py-2 rounded-lg text-sm font-medium disabled:opacity-50">
|
||||
{{ processing ? t('common.sending') : t('common.send') }}
|
||||
</button>
|
||||
@@ -85,7 +91,7 @@ import BaseModal from '@/components/BaseModal.vue'
|
||||
const { t } = useI18n()
|
||||
|
||||
const props = defineProps<{ show: boolean }>()
|
||||
const emit = defineEmits<{ close: []; sent: [] }>()
|
||||
const emit = defineEmits<{ close: []; sent: []; scan: [] }>()
|
||||
|
||||
const sendMethod = ref<'auto' | 'lightning' | 'onchain' | 'ecash' | 'ark'>('auto')
|
||||
const amount = ref<number>(0)
|
||||
|
||||
@@ -0,0 +1,722 @@
|
||||
<template>
|
||||
<Teleport to="body">
|
||||
<Transition name="modal">
|
||||
<div
|
||||
v-if="show"
|
||||
class="fixed inset-0 z-[3000] flex items-center justify-center p-4"
|
||||
@click.self="close"
|
||||
>
|
||||
<div class="absolute inset-0 bg-black/60 backdrop-blur-md"></div>
|
||||
<div
|
||||
ref="modalRef"
|
||||
class="glass-card p-6 w-full max-w-md relative z-10 overflow-hidden"
|
||||
role="dialog"
|
||||
aria-modal="true"
|
||||
@click.stop
|
||||
>
|
||||
<!-- Header -->
|
||||
<div class="flex items-center justify-between gap-4 mb-4">
|
||||
<div class="flex items-center gap-2 min-w-0">
|
||||
<button
|
||||
v-if="pane !== 'scan'"
|
||||
@click="goBack"
|
||||
class="p-2 -ml-2 rounded-lg hover:bg-white/10 text-white/70 hover:text-white transition-colors shrink-0"
|
||||
aria-label="Back"
|
||||
>
|
||||
<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M15 19l-7-7 7-7" />
|
||||
</svg>
|
||||
</button>
|
||||
<h3 class="text-xl font-semibold text-white truncate">{{ paneTitle }}</h3>
|
||||
</div>
|
||||
<button
|
||||
@click="close"
|
||||
class="p-2 rounded-lg hover:bg-white/10 text-white/70 hover:text-white transition-colors shrink-0"
|
||||
aria-label="Close"
|
||||
>
|
||||
<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M6 18L18 6M6 6l12 12" />
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<Transition :name="direction === 'forward' ? 'pane-forward' : 'pane-back'" mode="out-in">
|
||||
<!-- ============ SCAN PANE ============ -->
|
||||
<div v-if="pane === 'scan'" key="scan">
|
||||
<div class="relative w-full aspect-square rounded-xl overflow-hidden bg-black/40 border border-white/10 mb-4">
|
||||
<video ref="videoElement" class="w-full h-full object-cover" autoplay muted playsinline></video>
|
||||
<!-- Viewfinder -->
|
||||
<div class="absolute inset-0 flex items-center justify-center pointer-events-none">
|
||||
<div class="scan-viewfinder"></div>
|
||||
</div>
|
||||
<div v-if="!isScanning" class="absolute inset-0 flex flex-col items-center justify-center gap-3 bg-black/50">
|
||||
<svg class="w-10 h-10 text-white/40" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="1.5" d="M3 9a2 2 0 012-2h.93a2 2 0 001.664-.89l.812-1.22A2 2 0 0110.07 4h3.86a2 2 0 011.664.89l.812 1.22A2 2 0 0018.07 7H19a2 2 0 012 2v9a2 2 0 01-2 2H5a2 2 0 01-2-2V9z" />
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="1.5" d="M15 13a3 3 0 11-6 0 3 3 0 016 0z" />
|
||||
</svg>
|
||||
<button v-if="!liveCameraUnavailable" @click="startScanning" class="glass-button px-4 py-2 rounded-lg text-sm font-medium">
|
||||
{{ cameraError ? 'Retry camera' : 'Start camera' }}
|
||||
</button>
|
||||
<!-- Insecure-context fallback: live getUserMedia preview needs
|
||||
HTTPS, but the native camera via a file input does not —
|
||||
snap a photo of the QR and decode it locally. -->
|
||||
<button @click="photoInput?.click()" class="glass-button px-4 py-2 rounded-lg text-sm font-medium">
|
||||
Take photo of QR
|
||||
</button>
|
||||
<input
|
||||
ref="photoInput"
|
||||
type="file"
|
||||
accept="image/*"
|
||||
capture="environment"
|
||||
class="hidden"
|
||||
@change="onPhotoPicked"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="mb-4 p-3 bg-white/5 rounded-lg min-h-[3rem] flex items-center justify-center">
|
||||
<p class="text-sm text-center" :class="scanStatusIsError ? 'text-red-400' : 'text-white/60'">
|
||||
{{ scanStatus || 'Point the camera at a Lightning invoice, Bitcoin address, Cashu or Fedimint code' }}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<!-- Paste fallback (also the path on camera-less nodes) -->
|
||||
<div class="flex gap-2">
|
||||
<input
|
||||
v-model="pasteInput"
|
||||
type="text"
|
||||
placeholder="…or paste an invoice / address / token"
|
||||
class="flex-1 input-glass font-mono text-xs"
|
||||
@keydown.enter="submitPaste"
|
||||
/>
|
||||
<button @click="submitPaste" :disabled="!pasteInput.trim()" class="glass-button px-4 py-2 rounded-lg text-sm font-medium disabled:opacity-40">
|
||||
Use
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- ============ AMOUNT / CONFIRM PANE ============ -->
|
||||
<div v-else-if="pane === 'amount'" key="amount">
|
||||
<!-- Rail + destination summary -->
|
||||
<div class="flex items-center gap-3 p-3 bg-white/5 rounded-lg mb-4">
|
||||
<span class="w-9 h-9 rounded-lg bg-white/10 flex items-center justify-center shrink-0" :class="railColor">
|
||||
<span v-if="rail === 'onchain'" class="text-lg font-bold">₿</span>
|
||||
<svg v-else-if="rail === 'lightning'" 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="M13 10V3L4 14h7v7l9-11h-7z" />
|
||||
</svg>
|
||||
<!-- SVG, not 🥜: kiosk images ship no color-emoji font -->
|
||||
<svg v-else-if="rail === 'cashu'" class="w-5 h-5" role="img" aria-label="Cashu" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M17 9V7a2 2 0 00-2-2H5a2 2 0 00-2 2v6a2 2 0 002 2h2m2 4h10a2 2 0 002-2v-6a2 2 0 00-2-2H9a2 2 0 00-2 2v6a2 2 0 002 2zm7-5a2 2 0 11-4 0 2 2 0 014 0z" />
|
||||
</svg>
|
||||
<svg v-else 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="M17 20h5v-2a4 4 0 00-3-3.87M9 20H4v-2a4 4 0 013-3.87m6-.13a4 4 0 10-4-4 4 4 0 004 4zm6 0a4 4 0 10-3-6.65" />
|
||||
</svg>
|
||||
</span>
|
||||
<div class="min-w-0">
|
||||
<p class="text-sm font-medium text-white">{{ railLabel }}</p>
|
||||
<p class="text-[11px] text-white/40 font-mono truncate">{{ destDisplay }}</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Federation join is a confirm, not an amount entry -->
|
||||
<template v-if="action === 'fedimint-join'">
|
||||
<p class="text-sm text-white/70 mb-4">
|
||||
This is a Fedimint federation invite. Join it to hold and send ecash backed by this federation.
|
||||
</p>
|
||||
</template>
|
||||
|
||||
<template v-else>
|
||||
<!-- Amount -->
|
||||
<div class="mb-3">
|
||||
<div class="flex items-center justify-between mb-1">
|
||||
<label class="text-white/60 text-sm">Amount (sats)</label>
|
||||
<span v-if="amountLocked" class="text-[11px] px-2 py-0.5 rounded-full bg-white/10 text-white/50">set by invoice</span>
|
||||
</div>
|
||||
<input
|
||||
v-model.number="amount"
|
||||
type="number"
|
||||
min="1"
|
||||
inputmode="numeric"
|
||||
placeholder="0"
|
||||
:disabled="amountLocked || sendMax"
|
||||
class="w-full input-glass text-2xl font-semibold text-center disabled:opacity-70"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<!-- Presets + Max -->
|
||||
<div v-if="!amountLocked" class="grid grid-cols-5 gap-2 mb-4">
|
||||
<button
|
||||
v-for="preset in PRESETS"
|
||||
:key="preset"
|
||||
@click="applyPreset(preset)"
|
||||
:disabled="balance !== null && preset > balance"
|
||||
class="px-1 py-1.5 rounded-lg text-xs font-medium transition-colors disabled:opacity-30"
|
||||
:class="!sendMax && amount === preset ? 'bg-white/20 text-white' : 'bg-white/5 text-white/60 hover:bg-white/10 hover:text-white/90'"
|
||||
>{{ formatPreset(preset) }}</button>
|
||||
<button
|
||||
@click="toggleMax"
|
||||
class="px-1 py-1.5 rounded-lg text-xs font-semibold transition-colors"
|
||||
:class="sendMax ? 'bg-orange-500/25 text-orange-300 border border-orange-500/40' : 'bg-white/5 text-white/60 hover:bg-white/10 hover:text-white/90'"
|
||||
>Max</button>
|
||||
</div>
|
||||
|
||||
<!-- Live balance impact -->
|
||||
<div class="rounded-lg border border-white/10 divide-y divide-white/5 mb-4">
|
||||
<div class="flex items-center justify-between px-3 py-2">
|
||||
<span class="text-xs text-white/50">{{ railLabel }} balance</span>
|
||||
<span class="text-sm font-medium" :class="railColor">
|
||||
{{ balance === null ? '…' : balance.toLocaleString() + ' sats' }}
|
||||
</span>
|
||||
</div>
|
||||
<div class="flex items-center justify-between px-3 py-2">
|
||||
<span class="text-xs text-white/50">This send</span>
|
||||
<span class="text-sm font-medium text-white/80">−{{ effectiveAmount.toLocaleString() }} sats</span>
|
||||
</div>
|
||||
<div class="flex items-center justify-between px-3 py-2">
|
||||
<span class="text-xs text-white/50">Balance after</span>
|
||||
<span class="text-sm font-semibold" :class="insufficient ? 'text-red-400' : 'text-white'">
|
||||
{{ balance === null ? '…' : balanceAfter.toLocaleString() + ' sats' }}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
<p v-if="insufficient" class="text-xs text-red-400 mb-3">Not enough {{ railLabel }} balance for this amount.</p>
|
||||
<p v-else-if="sendMax && rail === 'onchain'" class="text-xs text-white/50 mb-3">Sweeps the entire on-chain balance minus network fees.</p>
|
||||
</template>
|
||||
|
||||
<div v-if="error" class="mb-3 alert-error text-sm">{{ error }}</div>
|
||||
|
||||
<div class="flex gap-3">
|
||||
<button @click="goBack" class="flex-1 glass-button px-4 py-2 rounded-lg text-sm">Back</button>
|
||||
<button
|
||||
@click="confirmSend"
|
||||
:disabled="processing || (action !== 'fedimint-join' && (insufficient || (effectiveAmount <= 0 && !sendMax)))"
|
||||
class="flex-1 glass-button glass-button-warning px-4 py-2 rounded-lg text-sm font-medium disabled:opacity-50"
|
||||
>
|
||||
{{ processing ? 'Sending…' : confirmLabel }}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- ============ SUCCESS PANE ============ -->
|
||||
<div v-else key="success" class="text-center py-2">
|
||||
<div class="inline-flex items-center justify-center w-24 h-24 rounded-full mx-auto mb-5 success-ring">
|
||||
<svg class="w-14 h-14 text-green-400" fill="none" stroke="currentColor" stroke-width="3" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" d="M5 13l4 4L19 7" />
|
||||
</svg>
|
||||
</div>
|
||||
|
||||
<div v-if="successAmount > 0" class="text-5xl font-black text-green-400 mb-1">
|
||||
{{ successAmount.toLocaleString() }}<span class="text-2xl font-bold text-green-400/70"> sats</span>
|
||||
</div>
|
||||
<div class="text-2xl font-bold tracking-widest text-white mb-2">{{ successVerb }}</div>
|
||||
<p class="text-sm text-white/50 mb-1">{{ successDetail }}</p>
|
||||
<p v-if="successRef" class="text-[11px] text-white/30 font-mono break-all px-4 mb-4">{{ successRef }}</p>
|
||||
|
||||
<div v-if="balance !== null" class="inline-flex items-center gap-2 px-3 py-1.5 rounded-full bg-white/5 text-xs text-white/60 mb-5">
|
||||
<span :class="railColor">{{ railLabel }}</span>
|
||||
<span>balance now {{ balance.toLocaleString() }} sats</span>
|
||||
</div>
|
||||
|
||||
<button
|
||||
@click="close"
|
||||
class="w-full py-3 rounded-xl font-semibold text-base bg-white/10 hover:bg-white/20 text-white transition-colors"
|
||||
>
|
||||
Done
|
||||
</button>
|
||||
</div>
|
||||
</Transition>
|
||||
</div>
|
||||
</div>
|
||||
</Transition>
|
||||
</Teleport>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, computed, watch, onUnmounted, nextTick } from 'vue'
|
||||
import QrScanner from 'qr-scanner'
|
||||
import { rpcClient } from '@/api/rpc-client'
|
||||
import { useAnimatedQRDecoder } from '@/composables/useAnimatedQRDecoder'
|
||||
import { useModalKeyboard } from '@/composables/useModalKeyboard'
|
||||
import { useBodyScrollLock } from '@/composables/useBodyScrollLock'
|
||||
|
||||
type Rail = 'onchain' | 'lightning' | 'cashu' | 'fedimint'
|
||||
type Action = 'pay-invoice' | 'send-onchain' | 'redeem-token' | 'fedimint-join'
|
||||
type Pane = 'scan' | 'amount' | 'success'
|
||||
|
||||
const PRESETS = [21, 2100, 21000, 100000]
|
||||
|
||||
const props = defineProps<{ show: boolean }>()
|
||||
const emit = defineEmits<{ close: []; sent: [] }>()
|
||||
|
||||
const modalRef = ref<HTMLElement | null>(null)
|
||||
useModalKeyboard(modalRef, computed(() => props.show), close)
|
||||
useBodyScrollLock(computed(() => props.show))
|
||||
|
||||
// --- Pane state ---
|
||||
const pane = ref<Pane>('scan')
|
||||
const direction = ref<'forward' | 'back'>('forward')
|
||||
const paneTitle = computed(() => {
|
||||
if (pane.value === 'scan') return 'Scan to send'
|
||||
if (pane.value === 'success') return 'Success'
|
||||
if (action.value === 'fedimint-join') return 'Join federation'
|
||||
if (action.value === 'redeem-token') return 'Redeem'
|
||||
return 'Send'
|
||||
})
|
||||
|
||||
function goTo(p: Pane, dir: 'forward' | 'back' = 'forward') {
|
||||
direction.value = dir
|
||||
pane.value = p
|
||||
}
|
||||
|
||||
function goBack() {
|
||||
if (pane.value === 'amount') {
|
||||
error.value = ''
|
||||
goTo('scan', 'back')
|
||||
nextTick(() => startScanning())
|
||||
} else if (pane.value === 'success') {
|
||||
close()
|
||||
}
|
||||
}
|
||||
|
||||
// --- Scanner ---
|
||||
const videoElement = ref<HTMLVideoElement | null>(null)
|
||||
const isScanning = ref(false)
|
||||
const scanStatus = ref('')
|
||||
const scanStatusIsError = ref(false)
|
||||
const cameraError = ref(false)
|
||||
const pasteInput = ref('')
|
||||
const qrScanner = ref<QrScanner | null>(null)
|
||||
const animatedDecoder = useAnimatedQRDecoder()
|
||||
|
||||
async function startScanning() {
|
||||
cameraError.value = false
|
||||
scanStatusIsError.value = false
|
||||
try {
|
||||
if (!videoElement.value) return
|
||||
if (!navigator.mediaDevices?.getUserMedia) {
|
||||
throw new Error(
|
||||
location.protocol !== 'https:' && !['localhost', '127.0.0.1'].includes(location.hostname)
|
||||
? 'Camera needs HTTPS — paste the code below instead'
|
||||
: 'No camera available — paste the code below instead'
|
||||
)
|
||||
}
|
||||
qrScanner.value = new QrScanner(
|
||||
videoElement.value,
|
||||
(result) => handleScanned(result.data),
|
||||
{
|
||||
returnDetailedScanResult: true,
|
||||
highlightScanRegion: false,
|
||||
preferredCamera: 'environment',
|
||||
maxScansPerSecond: 10,
|
||||
}
|
||||
)
|
||||
await qrScanner.value.start()
|
||||
isScanning.value = true
|
||||
scanStatus.value = ''
|
||||
} catch (err) {
|
||||
cameraError.value = true
|
||||
scanStatusIsError.value = true
|
||||
scanStatus.value = err instanceof Error ? err.message : 'Unable to access camera'
|
||||
}
|
||||
}
|
||||
|
||||
function stopScanning() {
|
||||
qrScanner.value?.stop()
|
||||
qrScanner.value?.destroy()
|
||||
qrScanner.value = null
|
||||
isScanning.value = false
|
||||
}
|
||||
|
||||
function submitPaste() {
|
||||
const text = pasteInput.value.trim()
|
||||
if (!text) return
|
||||
handleScanned(text)
|
||||
}
|
||||
|
||||
// --- Photo-capture fallback (works without a secure context) ---
|
||||
// getUserMedia is unreachable over plain http (navigator.mediaDevices is
|
||||
// undefined), but <input capture> opens the native camera in any browser,
|
||||
// PWA or WebView; the shot is decoded locally by qr-scanner's scanImage.
|
||||
const photoInput = ref<HTMLInputElement | null>(null)
|
||||
const liveCameraUnavailable = computed(() => !navigator.mediaDevices?.getUserMedia)
|
||||
|
||||
async function onPhotoPicked(e: Event) {
|
||||
const input = e.target as HTMLInputElement
|
||||
const file = input.files?.[0]
|
||||
if (!file) return
|
||||
try {
|
||||
const result = await QrScanner.scanImage(file, { returnDetailedScanResult: true })
|
||||
handleScanned(result.data)
|
||||
} catch {
|
||||
scanStatusIsError.value = true
|
||||
scanStatus.value = 'No QR code found in that photo — try again, closer and well-lit'
|
||||
} finally {
|
||||
input.value = ''
|
||||
}
|
||||
}
|
||||
|
||||
// --- Detection (ported from k484's scanner) ---
|
||||
const rail = ref<Rail>('lightning')
|
||||
const action = ref<Action>('pay-invoice')
|
||||
const dest = ref('')
|
||||
const amount = ref<number>(0)
|
||||
const amountLocked = ref(false)
|
||||
const sendMax = ref(false)
|
||||
|
||||
const railLabel = computed(() => ({
|
||||
onchain: 'On-chain',
|
||||
lightning: 'Lightning',
|
||||
cashu: 'Cashu',
|
||||
fedimint: 'Fedimint',
|
||||
}[rail.value]))
|
||||
|
||||
const railColor = computed(() => ({
|
||||
onchain: 'text-orange-500',
|
||||
lightning: 'text-yellow-400',
|
||||
cashu: 'text-purple-400',
|
||||
fedimint: 'text-blue-400',
|
||||
}[rail.value]))
|
||||
|
||||
const destDisplay = computed(() =>
|
||||
dest.value.length > 46 ? `${dest.value.slice(0, 26)}…${dest.value.slice(-14)}` : dest.value
|
||||
)
|
||||
|
||||
const confirmLabel = computed(() => {
|
||||
if (action.value === 'fedimint-join') return 'Join'
|
||||
if (action.value === 'redeem-token') return 'Redeem'
|
||||
return 'Send'
|
||||
})
|
||||
|
||||
/** Amount encoded in a BOLT11 invoice's human-readable part, in sats (null = zero-amount). */
|
||||
function parseBolt11AmountSats(invoice: string): number | null {
|
||||
const m = /^ln(?:bcrt|bc|tb)(\d+)?([munp])?1/.exec(invoice.toLowerCase())
|
||||
if (!m || !m[1]) return null
|
||||
const value = Number(m[1])
|
||||
const mult = { m: 1e-3, u: 1e-6, n: 1e-9, p: 1e-12 }[m[2] as 'm' | 'u' | 'n' | 'p'] ?? 1
|
||||
return Math.round(value * mult * 1e8)
|
||||
}
|
||||
|
||||
function isLightningInvoice(text: string): boolean {
|
||||
const t = text.toLowerCase()
|
||||
return t.startsWith('lnbc') || t.startsWith('lntb') || t.startsWith('lnbcrt')
|
||||
}
|
||||
|
||||
function isOnchainAddress(text: string): boolean {
|
||||
return /^(bc1|tb1|bcrt1)[a-z0-9]{20,90}$/i.test(text) || /^[13][a-km-zA-HJ-NP-Z1-9]{25,40}$/.test(text)
|
||||
}
|
||||
|
||||
function isCashuToken(text: string): boolean {
|
||||
return text.startsWith('cashuA') || text.startsWith('cashuB')
|
||||
}
|
||||
|
||||
function isFedimintInvite(text: string): boolean {
|
||||
return text.startsWith('fed11')
|
||||
}
|
||||
|
||||
function isFedimintToken(text: string): boolean {
|
||||
return /^AwE/.test(text)
|
||||
}
|
||||
|
||||
function isAnimatedFrame(text: string): boolean {
|
||||
return text.startsWith('B$') || text.startsWith('AAAH') || text.startsWith('ZAAE') || text.startsWith('AAA')
|
||||
}
|
||||
|
||||
function handleScanned(raw: string) {
|
||||
let text = raw.trim()
|
||||
scanStatusIsError.value = false
|
||||
|
||||
// Animated multi-frame QR (large Fedimint tokens): keep collecting frames
|
||||
if (isAnimatedFrame(text)) {
|
||||
animatedDecoder.addFrame(text)
|
||||
if (animatedDecoder.isComplete.value && animatedDecoder.decodedData.value) {
|
||||
const token = animatedDecoder.decodedData.value
|
||||
animatedDecoder.reset()
|
||||
acceptDetected(token)
|
||||
} else {
|
||||
scanStatus.value = `Animated code… ${animatedDecoder.progressText()}`
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
acceptDetected(text)
|
||||
}
|
||||
|
||||
function acceptDetected(raw: string) {
|
||||
let text = raw.trim()
|
||||
let bip21AmountSats: number | null = null
|
||||
|
||||
// URI schemes
|
||||
if (text.toLowerCase().startsWith('lightning:')) text = text.slice(10)
|
||||
if (text.toLowerCase().startsWith('bitcoin:')) {
|
||||
try {
|
||||
const uri = new URL(text)
|
||||
const lnParam = uri.searchParams.get('lightning')
|
||||
const amt = uri.searchParams.get('amount')
|
||||
if (amt && !Number.isNaN(Number(amt))) bip21AmountSats = Math.round(Number(amt) * 1e8)
|
||||
// Prefer the unified lightning param when present, else the address
|
||||
text = lnParam || text.slice(8).split('?')[0] || ''
|
||||
} catch {
|
||||
text = text.slice(8).split('?')[0] || ''
|
||||
}
|
||||
}
|
||||
|
||||
if (isLightningInvoice(text)) {
|
||||
rail.value = 'lightning'
|
||||
action.value = 'pay-invoice'
|
||||
dest.value = text
|
||||
const invoiceAmount = parseBolt11AmountSats(text)
|
||||
amount.value = invoiceAmount ?? 0
|
||||
amountLocked.value = invoiceAmount !== null
|
||||
} else if (isCashuToken(text)) {
|
||||
rail.value = 'cashu'
|
||||
action.value = 'redeem-token'
|
||||
dest.value = text
|
||||
amount.value = 0
|
||||
amountLocked.value = true
|
||||
} else if (isFedimintInvite(text)) {
|
||||
rail.value = 'fedimint'
|
||||
action.value = 'fedimint-join'
|
||||
dest.value = text
|
||||
amount.value = 0
|
||||
amountLocked.value = true
|
||||
} else if (isFedimintToken(text)) {
|
||||
rail.value = 'fedimint'
|
||||
action.value = 'redeem-token'
|
||||
dest.value = text
|
||||
amount.value = 0
|
||||
amountLocked.value = true
|
||||
} else if (isOnchainAddress(text)) {
|
||||
rail.value = 'onchain'
|
||||
action.value = 'send-onchain'
|
||||
dest.value = text
|
||||
amount.value = bip21AmountSats ?? 0
|
||||
amountLocked.value = false
|
||||
} else {
|
||||
scanStatusIsError.value = true
|
||||
scanStatus.value = 'Not a recognised invoice, address or token'
|
||||
return
|
||||
}
|
||||
|
||||
sendMax.value = false
|
||||
error.value = ''
|
||||
stopScanning()
|
||||
loadBalance()
|
||||
|
||||
// Tokens carry their own value — redeem straight away, success screen follows
|
||||
if (action.value === 'redeem-token') {
|
||||
goTo('amount')
|
||||
confirmSend()
|
||||
} else {
|
||||
goTo('amount')
|
||||
}
|
||||
}
|
||||
|
||||
// --- Balance for the active rail ---
|
||||
const balance = ref<number | null>(null)
|
||||
|
||||
async function loadBalance() {
|
||||
balance.value = null
|
||||
try {
|
||||
if (rail.value === 'cashu') {
|
||||
const res = await rpcClient.call<{ balance_sats: number }>({ method: 'wallet.ecash-balance' })
|
||||
balance.value = res.balance_sats ?? 0
|
||||
} else if (rail.value === 'fedimint') {
|
||||
const res = await rpcClient.call<{ balance_sats: number }>({ method: 'wallet.fedimint-balance' })
|
||||
balance.value = res.balance_sats ?? 0
|
||||
} else {
|
||||
const res = await rpcClient.call<{ balance_sats: number; channel_balance_sats: number }>({ method: 'lnd.getinfo' })
|
||||
balance.value = rail.value === 'onchain' ? (res.balance_sats ?? 0) : (res.channel_balance_sats ?? 0)
|
||||
}
|
||||
} catch {
|
||||
balance.value = null
|
||||
}
|
||||
}
|
||||
|
||||
const effectiveAmount = computed(() => {
|
||||
if (sendMax.value) return balance.value ?? 0
|
||||
return amount.value > 0 ? Math.floor(amount.value) : 0
|
||||
})
|
||||
const balanceAfter = computed(() => (balance.value ?? 0) - effectiveAmount.value)
|
||||
const insufficient = computed(() =>
|
||||
action.value !== 'fedimint-join' && action.value !== 'redeem-token' &&
|
||||
balance.value !== null && effectiveAmount.value > balance.value
|
||||
)
|
||||
|
||||
function applyPreset(preset: number) {
|
||||
sendMax.value = false
|
||||
amount.value = preset
|
||||
}
|
||||
|
||||
function toggleMax() {
|
||||
sendMax.value = !sendMax.value
|
||||
if (sendMax.value && balance.value !== null && rail.value !== 'onchain') {
|
||||
amount.value = balance.value
|
||||
}
|
||||
}
|
||||
|
||||
function formatPreset(preset: number): string {
|
||||
return preset >= 1000 ? `${(preset / 1000).toLocaleString()}k` : String(preset)
|
||||
}
|
||||
|
||||
// --- Send / redeem / join ---
|
||||
const processing = ref(false)
|
||||
const error = ref('')
|
||||
const successAmount = ref(0)
|
||||
const successVerb = ref('SENT')
|
||||
const successDetail = ref('')
|
||||
const successRef = ref('')
|
||||
|
||||
async function confirmSend() {
|
||||
if (processing.value) return
|
||||
processing.value = true
|
||||
error.value = ''
|
||||
try {
|
||||
if (action.value === 'pay-invoice') {
|
||||
const params: Record<string, unknown> = { payment_request: dest.value }
|
||||
if (!amountLocked.value && effectiveAmount.value > 0) params.amount_sats = effectiveAmount.value
|
||||
const res = await rpcClient.call<{ payment_hash: string; amount_sats: number }>({
|
||||
method: 'lnd.payinvoice',
|
||||
params,
|
||||
timeout: 60000,
|
||||
})
|
||||
successAmount.value = res.amount_sats || effectiveAmount.value
|
||||
successVerb.value = 'PAID'
|
||||
successDetail.value = 'Lightning invoice paid'
|
||||
successRef.value = res.payment_hash
|
||||
} else if (action.value === 'send-onchain') {
|
||||
const res = await rpcClient.call<{ txid: string }>({
|
||||
method: 'lnd.sendcoins',
|
||||
params: sendMax.value
|
||||
? { addr: dest.value, send_all: true }
|
||||
: { addr: dest.value, amount: effectiveAmount.value },
|
||||
timeout: 60000,
|
||||
})
|
||||
successAmount.value = sendMax.value ? (balance.value ?? 0) : effectiveAmount.value
|
||||
successVerb.value = 'SENT'
|
||||
successDetail.value = 'On-chain transaction broadcast'
|
||||
successRef.value = res.txid
|
||||
} else if (action.value === 'redeem-token') {
|
||||
const res = await rpcClient.call<{ received_sats: number; kind: string }>({
|
||||
method: 'wallet.ecash-receive',
|
||||
params: { token: dest.value },
|
||||
timeout: 60000,
|
||||
})
|
||||
rail.value = res.kind === 'fedimint' ? 'fedimint' : 'cashu'
|
||||
successAmount.value = res.received_sats || 0
|
||||
successVerb.value = 'RECEIVED'
|
||||
successDetail.value = `${res.kind === 'fedimint' ? 'Fedimint' : 'Cashu'} token redeemed`
|
||||
successRef.value = ''
|
||||
} else {
|
||||
await rpcClient.call({
|
||||
method: 'wallet.fedimint-join',
|
||||
params: { invite_code: dest.value },
|
||||
timeout: 60000,
|
||||
})
|
||||
successAmount.value = 0
|
||||
successVerb.value = 'JOINED'
|
||||
successDetail.value = 'Federation joined — you can now hold its ecash'
|
||||
successRef.value = ''
|
||||
}
|
||||
await loadBalance()
|
||||
goTo('success')
|
||||
emit('sent')
|
||||
} catch (err) {
|
||||
error.value = err instanceof Error ? err.message : 'Send failed'
|
||||
// Token redeems jump straight from scan; surface the failure on the amount pane
|
||||
if (pane.value !== 'amount') goTo('amount')
|
||||
} finally {
|
||||
processing.value = false
|
||||
}
|
||||
}
|
||||
|
||||
// --- Lifecycle ---
|
||||
function resetAll() {
|
||||
stopScanning()
|
||||
animatedDecoder.reset()
|
||||
pane.value = 'scan'
|
||||
direction.value = 'forward'
|
||||
scanStatus.value = ''
|
||||
scanStatusIsError.value = false
|
||||
cameraError.value = false
|
||||
pasteInput.value = ''
|
||||
dest.value = ''
|
||||
amount.value = 0
|
||||
amountLocked.value = false
|
||||
sendMax.value = false
|
||||
balance.value = null
|
||||
processing.value = false
|
||||
error.value = ''
|
||||
}
|
||||
|
||||
function close() {
|
||||
resetAll()
|
||||
emit('close')
|
||||
}
|
||||
|
||||
watch(() => props.show, (open) => {
|
||||
if (open) {
|
||||
resetAll()
|
||||
nextTick(() => startScanning())
|
||||
} else {
|
||||
stopScanning()
|
||||
}
|
||||
})
|
||||
|
||||
onUnmounted(stopScanning)
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.modal-enter-active,
|
||||
.modal-leave-active {
|
||||
transition: opacity 0.2s ease;
|
||||
}
|
||||
.modal-enter-from,
|
||||
.modal-leave-to {
|
||||
opacity: 0;
|
||||
}
|
||||
.modal-enter-active .glass-card,
|
||||
.modal-leave-active .glass-card {
|
||||
transition: transform 0.2s ease;
|
||||
}
|
||||
.modal-enter-from .glass-card,
|
||||
.modal-leave-to .glass-card {
|
||||
transform: scale(0.95);
|
||||
}
|
||||
|
||||
/* Horizontal pane slide: forward = new pane arrives from the right */
|
||||
.pane-forward-enter-active,
|
||||
.pane-forward-leave-active,
|
||||
.pane-back-enter-active,
|
||||
.pane-back-leave-active {
|
||||
transition: transform 0.22s ease, opacity 0.22s ease;
|
||||
}
|
||||
.pane-forward-enter-from {
|
||||
transform: translateX(40px);
|
||||
opacity: 0;
|
||||
}
|
||||
.pane-forward-leave-to {
|
||||
transform: translateX(-40px);
|
||||
opacity: 0;
|
||||
}
|
||||
.pane-back-enter-from {
|
||||
transform: translateX(-40px);
|
||||
opacity: 0;
|
||||
}
|
||||
.pane-back-leave-to {
|
||||
transform: translateX(40px);
|
||||
opacity: 0;
|
||||
}
|
||||
|
||||
.scan-viewfinder {
|
||||
width: 62%;
|
||||
height: 62%;
|
||||
border-radius: 1rem;
|
||||
border: 2px solid rgba(249, 115, 22, 0.85);
|
||||
box-shadow: 0 0 0 9999px rgba(0, 0, 0, 0.35);
|
||||
}
|
||||
|
||||
.success-ring {
|
||||
background: rgba(16, 185, 129, 0.12);
|
||||
box-shadow: 0 0 40px rgba(16, 185, 129, 0.25);
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,60 @@
|
||||
import { ref } from 'vue'
|
||||
import {
|
||||
parseFramesReducer,
|
||||
areFramesComplete,
|
||||
framesToData,
|
||||
totalNumberOfFrames,
|
||||
currentNumberOfFrames,
|
||||
} from 'qrloop'
|
||||
|
||||
/**
|
||||
* Collects animated-QR frames (qrloop format, as used by k484 for large
|
||||
* Fedimint tokens) and reassembles them into the original token string.
|
||||
*/
|
||||
export function useAnimatedQRDecoder() {
|
||||
const framesState = ref<ReturnType<typeof parseFramesReducer> | null>(null)
|
||||
const isComplete = ref(false)
|
||||
const decodedData = ref<string | null>(null)
|
||||
const uniqueFrames = ref<Set<string>>(new Set())
|
||||
|
||||
/** Feed one scanned frame; returns true once the full payload is decoded. */
|
||||
function addFrame(frame: string): boolean {
|
||||
if (isComplete.value) return true
|
||||
if (uniqueFrames.value.has(frame)) return false
|
||||
uniqueFrames.value.add(frame)
|
||||
|
||||
try {
|
||||
framesState.value = parseFramesReducer(framesState.value, frame)
|
||||
if (areFramesComplete(framesState.value)) {
|
||||
const dataBuffer = framesToData(framesState.value)
|
||||
// Tokens travel as URL-safe base64
|
||||
decodedData.value = dataBuffer
|
||||
.toString('base64')
|
||||
.replace(/\+/g, '-')
|
||||
.replace(/\//g, '_')
|
||||
isComplete.value = true
|
||||
return true
|
||||
}
|
||||
return false
|
||||
} catch {
|
||||
// A frame that qrloop rejects may just be a different QR format
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
function progressText(): string {
|
||||
if (!framesState.value) return ''
|
||||
const total = totalNumberOfFrames(framesState.value)
|
||||
const current = currentNumberOfFrames(framesState.value)
|
||||
return `${current}/${total} frames`
|
||||
}
|
||||
|
||||
function reset() {
|
||||
framesState.value = null
|
||||
uniqueFrames.value.clear()
|
||||
isComplete.value = false
|
||||
decodedData.value = null
|
||||
}
|
||||
|
||||
return { isComplete, decodedData, addFrame, reset, progressText }
|
||||
}
|
||||
@@ -15,7 +15,7 @@ const FUND_WALLET_STEP: GoalStep = {
|
||||
id: 'fund-wallet',
|
||||
title: 'Fund Your Bitcoin Wallet',
|
||||
description:
|
||||
"Send bitcoin to your node's on-chain wallet so it can open a Lightning channel. Zeus channels need between 150,000 and 1,500,000 sats. Funding unlocks once your node finishes syncing the blockchain.",
|
||||
"Send bitcoin to your node's on-chain wallet so it can open a Lightning channel. Zeus channels: minimum 150,000 · maximum 1,500,000 on-chain sats required. Funding unlocks once your node finishes syncing the blockchain.",
|
||||
action: 'fund',
|
||||
isAutomatic: false,
|
||||
icon: '/assets/img/app-icons/bitcoin-knots.webp',
|
||||
@@ -51,7 +51,7 @@ export const GOALS: GoalDefinition[] = [
|
||||
id: 'open-zeus-channel',
|
||||
title: 'Open a Channel with Zeus',
|
||||
description:
|
||||
'Open a Lightning channel to Zeus, the mobile wallet that pairs perfectly with your node. Fund it with 150,000–1,500,000 sats and your shop can accept instant Lightning payments.',
|
||||
'Open a channel with Zeus Olympus node and start sending and receiving Lightning payments. Minimum 150,000 · maximum 1,500,000 on-chain sats required.',
|
||||
action: 'configure',
|
||||
isAutomatic: false,
|
||||
icon: ZEUS_ICON,
|
||||
@@ -106,7 +106,7 @@ export const GOALS: GoalDefinition[] = [
|
||||
id: 'open-channel',
|
||||
title: 'Open a Channel with Zeus',
|
||||
description:
|
||||
'Open your first payment channel to Zeus, the mobile wallet built for nodes like yours (150,000–1,500,000 sats). You can then send and receive Lightning payments from your phone.',
|
||||
'Open a channel with Zeus Olympus node and start sending and receiving Lightning payments. Minimum 150,000 · maximum 1,500,000 on-chain sats required.',
|
||||
action: 'configure',
|
||||
isAutomatic: false,
|
||||
icon: ZEUS_ICON,
|
||||
@@ -208,7 +208,7 @@ export const GOALS: GoalDefinition[] = [
|
||||
id: 'open-channels',
|
||||
title: 'Open Payment Channels',
|
||||
description:
|
||||
'Open channels with well-connected nodes to start routing payments. A great first channel is Zeus (150,000–1,500,000 sats) — it also puts your node in your pocket. More channels means more routing opportunities.',
|
||||
'Open channels with well-connected nodes to start routing payments. A great first channel is Zeus Olympus (minimum 150,000 · maximum 1,500,000 on-chain sats) — it also puts your node in your pocket. More channels means more routing opportunities.',
|
||||
action: 'configure',
|
||||
isAutomatic: false,
|
||||
icon: ZEUS_ICON,
|
||||
|
||||
@@ -88,9 +88,9 @@ const router = createRouter({
|
||||
// normal app (onboarding → login → dashboard) like any other client.
|
||||
// This route only persists kiosk mode + safe-area insets, then redirects
|
||||
// to the root app. The launcher still points Chromium here (not directly
|
||||
// at `/`) so the 'kiosk' flag gets set — App.vue uses it to skip the
|
||||
// remote relay, which would otherwise double xdotool input on the kiosk
|
||||
// display. Public so the auth guard doesn't bounce us before beforeEnter.
|
||||
// at `/`) so the 'kiosk' flag gets set — App.vue keys kiosk-only styling
|
||||
// (kiosk-mode/kiosk-safe-area classes, dark AIUI pin) off it. Public so
|
||||
// the auth guard doesn't bounce us before beforeEnter.
|
||||
path: '/kiosk',
|
||||
name: 'kiosk',
|
||||
meta: { public: true },
|
||||
|
||||
@@ -48,6 +48,9 @@ export interface MeshConfigureParams {
|
||||
receive_block_headers?: boolean
|
||||
lora_region?: string
|
||||
device_kind?: string
|
||||
/** MeshCore LoRa PHY params in firmware field units (freq_khz = MHz×1000,
|
||||
* bw_hz = kHz×1000). null clears; omitted leaves unchanged. */
|
||||
lora_radio_params?: { freq_khz: number; bw_hz: number; sf: number; cr: number } | null
|
||||
}
|
||||
|
||||
export interface MeshPeer {
|
||||
|
||||
@@ -1417,6 +1417,15 @@ html.kiosk-safe-area body {
|
||||
width: 100vw;
|
||||
height: 100vh;
|
||||
overflow: hidden;
|
||||
}
|
||||
/* Black fill on BODY only, never on the root element. With a background on
|
||||
<html>, body's background no longer propagates to the canvas and instead
|
||||
paints as a normal block background — which by CSS paint order lands ON TOP
|
||||
of negative-z-index descendants, i.e. over .bg-perspective-container
|
||||
(z-index -10): every dashboard/login background image rendered as solid
|
||||
black on kiosk displays. Body-only matches the non-kiosk pages, where the
|
||||
art has always shown. */
|
||||
html.kiosk-safe-area body {
|
||||
background: #000;
|
||||
}
|
||||
|
||||
|
||||
Vendored
+9
@@ -0,0 +1,9 @@
|
||||
declare module 'qrloop' {
|
||||
export type FramesState = unknown
|
||||
export function parseFramesReducer(state: FramesState | null, frame: string): FramesState
|
||||
export function areFramesComplete(state: FramesState): boolean
|
||||
export function framesToData(state: FramesState): { toString(encoding: string): string }
|
||||
export function totalNumberOfFrames(state: FramesState): number
|
||||
export function currentNumberOfFrames(state: FramesState): number
|
||||
export function progressOfFrames(state: FramesState): number
|
||||
}
|
||||
@@ -100,3 +100,29 @@ export function meshcorePlanFor(code: string | null | undefined): MeshcorePlan |
|
||||
if (!code) return undefined
|
||||
return MESHCORE_PLANS[code.toUpperCase()]
|
||||
}
|
||||
|
||||
/** A named MeshCore RF preset for the Device-panel dropdown. */
|
||||
export interface MeshcoreRfPreset {
|
||||
id: string
|
||||
label: string
|
||||
freqMhz: number
|
||||
bwKhz: number
|
||||
sf: number
|
||||
cr: number
|
||||
}
|
||||
|
||||
/**
|
||||
* Prepopulated MeshCore RF plans by country/region for the Device panel's
|
||||
* preset dropdown ("Custom" in the UI unlocks free entry). Values are only
|
||||
* added here when verifiable: the MeshCore community plans above, the
|
||||
* MeshCore firmware's own build defaults (platformio.ini `arduino_base`:
|
||||
* 869.618/62.5/SF8, CR default 5; companion example: 915.0/250/SF10/CR5),
|
||||
* and operator-attested deployment plans.
|
||||
*/
|
||||
export const MESHCORE_RF_PRESETS: MeshcoreRfPreset[] = [
|
||||
{ id: 'eu_868', label: 'Europe / UK — 869.525 MHz, 250 kHz, SF11, CR4/5', freqMhz: 869.525, bwKhz: 250, sf: 11, cr: 5 },
|
||||
{ id: 'pt', label: 'Portugal — 869.618 MHz, 62.5 kHz, SF8, CR4/8', freqMhz: 869.618, bwKhz: 62.5, sf: 8, cr: 8 },
|
||||
{ id: 'fw_default', label: 'MeshCore firmware default — 869.618 MHz, 62.5 kHz, SF8, CR4/5', freqMhz: 869.618, bwKhz: 62.5, sf: 8, cr: 5 },
|
||||
{ id: 'us_anz_915', label: 'US / Canada / ANZ — 915.0 MHz, 250 kHz, SF10, CR4/5', freqMhz: 915.0, bwKhz: 250, sf: 10, cr: 5 },
|
||||
{ id: 'eu_433', label: 'Europe (433 MHz) — 433.65 MHz, 250 kHz, SF11, CR4/5', freqMhz: 433.65, bwKhz: 250, sf: 11, cr: 5 },
|
||||
]
|
||||
|
||||
@@ -125,7 +125,7 @@
|
||||
{{ walletOnchainSats.toLocaleString() }} sats
|
||||
</span>
|
||||
</div>
|
||||
<p class="text-xs text-white/45 mt-1">Zeus channels need 150,000–1,500,000 sats.</p>
|
||||
<p class="text-xs text-white/45 mt-1">Minimum 150,000 · maximum 1,500,000 on-chain sats required.</p>
|
||||
</div>
|
||||
<div class="flex flex-wrap gap-2">
|
||||
<button
|
||||
@@ -205,7 +205,7 @@
|
||||
<!-- Fund-wallet receive modal (on-chain address + QR, Zeus limits noted) -->
|
||||
<ReceiveBitcoinModal
|
||||
:show="showFundModal"
|
||||
note="Fund your Lightning channel: Zeus channels need a minimum of 150,000 and a maximum of 1,500,000 sats."
|
||||
note="Fund your Lightning channel: minimum 150,000 · maximum 1,500,000 on-chain sats required."
|
||||
auto-generate
|
||||
@close="showFundModal = false"
|
||||
/>
|
||||
|
||||
@@ -136,6 +136,7 @@
|
||||
:wallet-ark="walletArk"
|
||||
:wallet-transactions="walletTransactions"
|
||||
:is-dev="isDev"
|
||||
@show-scan="showScanModal = true"
|
||||
@show-send="showSendModal = true"
|
||||
@show-receive="showReceiveModal = true"
|
||||
@show-transactions="showTransactionsModal = true"
|
||||
@@ -278,8 +279,9 @@
|
||||
</div>
|
||||
|
||||
<!-- Wallet Modals -->
|
||||
<SendBitcoinModal :show="showSendModal" @close="showSendModal = false" @sent="loadWeb5Status()" />
|
||||
<ReceiveBitcoinModal :show="showReceiveModal" @close="showReceiveModal = false" @received="loadWeb5Status()" />
|
||||
<WalletScanModal :show="showScanModal" @close="showScanModal = false" @sent="loadWeb5Status()" />
|
||||
<SendBitcoinModal :show="showSendModal" @close="showSendModal = false" @sent="loadWeb5Status()" @scan="showSendModal = false; showScanModal = true" />
|
||||
<ReceiveBitcoinModal :show="showReceiveModal" @close="showReceiveModal = false" @received="loadWeb5Status()" @scan="showReceiveModal = false; showScanModal = true" />
|
||||
<TransactionsModal :show="showTransactionsModal" :transactions="walletTransactions" @close="showTransactionsModal = false" />
|
||||
<WalletSettingsModal :show="showWalletSettingsModal" @close="showWalletSettingsModal = false" @changed="loadWeb5Status()" />
|
||||
</div>
|
||||
@@ -289,6 +291,7 @@
|
||||
import { computed, ref, watch, onBeforeUnmount, onMounted } from 'vue'
|
||||
import { RouterLink, useRoute, useRouter } from 'vue-router'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
import WalletScanModal from '@/components/WalletScanModal.vue'
|
||||
import SendBitcoinModal from '@/components/SendBitcoinModal.vue'
|
||||
import ReceiveBitcoinModal from '@/components/ReceiveBitcoinModal.vue'
|
||||
import TransactionsModal from '@/components/TransactionsModal.vue'
|
||||
@@ -517,7 +520,7 @@ onMounted(async () => {
|
||||
})
|
||||
|
||||
// Wallet modals
|
||||
const showSendModal = ref(false); const showReceiveModal = ref(false); const showTransactionsModal = ref(false); const showWalletSettingsModal = ref(false)
|
||||
const showScanModal = ref(false); const showSendModal = ref(false); const showReceiveModal = ref(false); const showTransactionsModal = ref(false); const showWalletSettingsModal = ref(false)
|
||||
|
||||
async function devFaucet() { try { await rpcClient.call({ method: 'dev.faucet', params: { amount_sats: 1_000_000 } }); await loadWeb5Status() } catch { /* ignore */ } }
|
||||
|
||||
|
||||
@@ -699,8 +699,12 @@ video.bg-layer {
|
||||
:global(html.kiosk-mode .perspective-container) {
|
||||
perspective: none !important;
|
||||
}
|
||||
/* Scoped to the onboarding viewport: a bare `.view-wrapper` match also killed
|
||||
the dashboard's route transitions on kiosk (transform:none !important beats
|
||||
the transition classes), freezing every tab change. The dashboard has its
|
||||
own kiosk-safe 2D transition overrides in dashboard-styles.css. */
|
||||
:global(html.kiosk-mode .bg-layer),
|
||||
:global(html.kiosk-mode .view-wrapper) {
|
||||
:global(html.kiosk-mode .onb-viewport .view-wrapper) {
|
||||
transform: none !important;
|
||||
transform-style: flat !important;
|
||||
backface-visibility: visible !important;
|
||||
|
||||
@@ -33,7 +33,10 @@ describe('appSessionConfig', () => {
|
||||
configurable: true,
|
||||
})
|
||||
|
||||
expect(resolveAppUrl('did-wallet')).toBe('http://192.168.1.228:8083')
|
||||
// did-wallet's manifest publishes host port 8088 (apps/did-wallet/
|
||||
// manifest.yml) — assert against the manifest-generated value, which is
|
||||
// exactly what this test exists to protect.
|
||||
expect(resolveAppUrl('did-wallet')).toBe('http://192.168.1.228:8088')
|
||||
})
|
||||
|
||||
it('does not treat service-only tcp ports as web launch surfaces', () => {
|
||||
|
||||
@@ -6,26 +6,29 @@ export const GENERATED_APP_PORTS: Record<string, number> = {
|
||||
"archy-nbxplorer": 32838,
|
||||
"botfights": 9100,
|
||||
"btcpay-server": 23000,
|
||||
"did-wallet": 8083,
|
||||
"did-wallet": 8088,
|
||||
"electrumx": 50002,
|
||||
"fedimint": 8175,
|
||||
"filebrowser": 8083,
|
||||
"gitea": 3001,
|
||||
"grafana": 3000,
|
||||
"homeassistant": 8123,
|
||||
"immich": 2283,
|
||||
"indeedhub": 7778,
|
||||
"jellyfin": 8096,
|
||||
"lnd-ui": 18083,
|
||||
"mempool": 4080,
|
||||
"mempool-api": 8999,
|
||||
"morphos-server": 8086,
|
||||
"morphos-server": 8089,
|
||||
"netbird": 8087,
|
||||
"nextcloud": 8085,
|
||||
"nostr-rs-relay": 18081,
|
||||
"photoprism": 2342,
|
||||
"pine": 10380,
|
||||
"portainer": 9000,
|
||||
"router": 8084,
|
||||
"searxng": 8888,
|
||||
"strfry": 8082,
|
||||
"strfry": 8090,
|
||||
"uptime-kuma": 3002,
|
||||
"vaultwarden": 8082,
|
||||
}
|
||||
@@ -36,6 +39,7 @@ export const GENERATED_APP_TITLES: Record<string, string> = {
|
||||
"archy-mempool-db": "Mempool MariaDB",
|
||||
"archy-mempool-web": "Mempool Web",
|
||||
"archy-nbxplorer": "NBXplorer",
|
||||
"barkd": "Ark Wallet",
|
||||
"bitcoin-core": "Bitcoin Core",
|
||||
"bitcoin-knots": "Bitcoin Knots",
|
||||
"bitcoin-ui": "Bitcoin UI",
|
||||
@@ -45,24 +49,40 @@ export const GENERATED_APP_TITLES: Record<string, string> = {
|
||||
"did-wallet": "Web5 DID Wallet",
|
||||
"electrs-ui": "Electrs UI",
|
||||
"electrumx": "ElectrumX",
|
||||
"fedimint": "Fedimint",
|
||||
"fedimint": "Fedimint Guardian",
|
||||
"fedimint-clientd": "Fedimint Client",
|
||||
"fedimint-gateway": "Fedimint Gateway",
|
||||
"filebrowser": "File Browser",
|
||||
"fips-ui": "FIPS Mesh",
|
||||
"gitea": "Gitea",
|
||||
"grafana": "Grafana",
|
||||
"homeassistant": "Home Assistant",
|
||||
"immich": "Immich",
|
||||
"immich-postgres": "Immich Postgres",
|
||||
"immich-redis": "Immich Redis",
|
||||
"indeedhub": "IndeeHub",
|
||||
"indeedhub-api": "IndeedHub API",
|
||||
"indeedhub-ffmpeg": "IndeedHub FFmpeg Worker",
|
||||
"indeedhub-minio": "IndeedHub MinIO",
|
||||
"indeedhub-postgres": "IndeedHub Postgres",
|
||||
"indeedhub-redis": "IndeedHub Redis",
|
||||
"indeedhub-relay": "IndeedHub Nostr Relay",
|
||||
"jellyfin": "Jellyfin",
|
||||
"lightning-stack": "Lightning Stack",
|
||||
"lnd": "LND",
|
||||
"lnd-ui": "LND UI",
|
||||
"mempool": "Mempool Explorer",
|
||||
"mempool-api": "Mempool API",
|
||||
"meshtastic": "Meshtastic",
|
||||
"morphos-server": "MorphOS Server",
|
||||
"netbird": "NetBird",
|
||||
"netbird-dashboard": "NetBird Dashboard",
|
||||
"netbird-server": "NetBird Server",
|
||||
"nextcloud": "Nextcloud",
|
||||
"nostr-rs-relay": "Nostr Relay (Rust)",
|
||||
"photoprism": "PhotoPrism",
|
||||
"pine": "Pine",
|
||||
"pine-piper": "Pine Piper (TTS)",
|
||||
"pine-whisper": "Pine Whisper (STT)",
|
||||
"portainer": "Portainer",
|
||||
"router": "Mesh Router",
|
||||
"searxng": "SearXNG",
|
||||
@@ -76,8 +96,10 @@ export const GENERATED_NEW_TAB_APPS = new Set<string>([
|
||||
"gitea",
|
||||
"grafana",
|
||||
"homeassistant",
|
||||
"immich",
|
||||
"nextcloud",
|
||||
"photoprism",
|
||||
"pine",
|
||||
"portainer",
|
||||
"uptime-kuma",
|
||||
"vaultwarden",
|
||||
|
||||
@@ -26,6 +26,10 @@ export const SERVICE_NAMES = new Set([
|
||||
'indeedhub-relay', 'indeedhub-build_api_1', 'indeedhub-build_ffmpeg-worker_1',
|
||||
'indeedhub-build_postgres_1', 'indeedhub-build_redis_1', 'indeedhub-build_minio_1',
|
||||
'indeedhub-build_minio-init_1', 'indeedhub-build_relay_1',
|
||||
// Pine voice-assistant stack: the two Wyoming engines are backends (STT/TTS)
|
||||
// reached by Home Assistant over host.containers.internal — the user-facing
|
||||
// card is "pine" (the setup/status launcher), so the engines go to Services.
|
||||
'pine-whisper', 'pine-piper',
|
||||
])
|
||||
|
||||
const INTERNAL_TOOLING_NAMES = new Set([
|
||||
@@ -61,7 +65,7 @@ export const APP_CATEGORY_MAP: Record<string, string> = {
|
||||
'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',
|
||||
'homeassistant': 'home', 'lorabell': 'home', 'endurain': 'home', 'pine': 'home',
|
||||
'searxng': 'community', 'ollama': 'community', 'grafana': 'data', 'gitea': 'data',
|
||||
'nostrudel': 'nostr',
|
||||
'tailscale': 'networking', 'netbird': 'networking', 'nginx-proxy-manager': 'networking', 'portainer': 'networking',
|
||||
@@ -212,6 +216,7 @@ const SERVICE_ICON_PREFIXES: Array<[string, string]> = [
|
||||
['indeedhub-', '/assets/img/app-icons/indeedhub.png'],
|
||||
['immich-', '/assets/img/app-icons/immich.png'],
|
||||
['immich_', '/assets/img/app-icons/immich.png'],
|
||||
['pine-', '/assets/img/app-icons/pine.svg'],
|
||||
]
|
||||
|
||||
function serviceParentIcon(id: string): string | undefined {
|
||||
|
||||
@@ -756,6 +756,38 @@ html.kiosk-mode .dashboard-view .bg-layer {
|
||||
transition: opacity 0.45s ease;
|
||||
}
|
||||
|
||||
/* Kiosk: keep tab changes animated with cheap 2D moves. The 3D depth
|
||||
transitions (translateZ + blur + preserve-3d) drop frames / paint black
|
||||
under the software compositor, and the onboarding-era blanket
|
||||
`transform: none` on .view-wrapper killed tab motion entirely. Plain 2D
|
||||
scale + opacity composites fine there. */
|
||||
html.kiosk-mode .depth-forward-enter-active.view-wrapper,
|
||||
html.kiosk-mode .depth-forward-leave-active.view-wrapper,
|
||||
html.kiosk-mode .depth-back-enter-active.view-wrapper,
|
||||
html.kiosk-mode .depth-back-leave-active.view-wrapper {
|
||||
transition: transform 0.45s ease, opacity 0.45s ease;
|
||||
transform-style: flat;
|
||||
backface-visibility: visible;
|
||||
filter: none !important;
|
||||
}
|
||||
html.kiosk-mode .depth-forward-enter-from.view-wrapper,
|
||||
html.kiosk-mode .depth-back-leave-to.view-wrapper {
|
||||
transform: scale(0.94);
|
||||
filter: none !important;
|
||||
}
|
||||
html.kiosk-mode .depth-forward-leave-to.view-wrapper,
|
||||
html.kiosk-mode .depth-back-enter-from.view-wrapper {
|
||||
transform: scale(1.05);
|
||||
filter: none !important;
|
||||
}
|
||||
html.kiosk-mode .depth-forward-enter-to.view-wrapper,
|
||||
html.kiosk-mode .depth-forward-leave-from.view-wrapper,
|
||||
html.kiosk-mode .depth-back-enter-to.view-wrapper,
|
||||
html.kiosk-mode .depth-back-leave-from.view-wrapper {
|
||||
transform: scale(1);
|
||||
filter: none !important;
|
||||
}
|
||||
|
||||
/* Background glitch effect layers - World Fair style */
|
||||
.bg-glitch-layer-1,
|
||||
.bg-glitch-layer-2,
|
||||
|
||||
@@ -122,7 +122,11 @@
|
||||
</div>
|
||||
<div class="flex items-center justify-between p-3 bg-white/5 rounded-lg">
|
||||
<div class="flex items-center gap-3">
|
||||
<span class="w-5 h-5 text-base leading-none flex items-center justify-center" role="img" aria-label="Cashu">🥜</span>
|
||||
<!-- Inline SVG, not an emoji: kiosk images ship no color-emoji
|
||||
font, so 🥜 rendered as a tofu box on TVs -->
|
||||
<svg class="w-5 h-5 text-purple-400" role="img" aria-label="Cashu" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M17 9V7a2 2 0 00-2-2H5a2 2 0 00-2 2v6a2 2 0 002 2h2m2 4h10a2 2 0 002-2v-6a2 2 0 00-2-2H9a2 2 0 00-2 2v6a2 2 0 002 2zm7-5a2 2 0 11-4 0 2 2 0 014 0z" />
|
||||
</svg>
|
||||
<span class="text-sm text-white/80">Cashu</span>
|
||||
</div>
|
||||
<span class="text-purple-400 text-sm font-medium">{{ walletEcash.toLocaleString() }} sats</span>
|
||||
@@ -139,25 +143,37 @@
|
||||
<!-- Only rendered once barkd reports a balance — most nodes don't run the Ark sidecar -->
|
||||
<div v-if="(walletArk ?? 0) > 0" class="flex items-center justify-between p-3 bg-white/5 rounded-lg">
|
||||
<div class="flex items-center gap-3">
|
||||
<span class="w-5 h-5 text-base leading-none flex items-center justify-center" role="img" aria-label="Ark">⚓</span>
|
||||
<svg class="w-5 h-5 text-teal-400" role="img" aria-label="Ark" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 8a2.5 2.5 0 10-2.5-2.5A2.5 2.5 0 0012 8zm0 0v13m-7-7c0 3.87 3.13 7 7 7s7-3.13 7-7m-14 0H3m18 0h-2" />
|
||||
</svg>
|
||||
<span class="text-sm text-white/80">Ark</span>
|
||||
</div>
|
||||
<span class="text-teal-400 text-sm font-medium">{{ (walletArk ?? 0).toLocaleString() }} sats</span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="home-card-buttons grid gap-2 mt-auto pt-4 shrink-0" :class="isDev ? 'grid-cols-4' : 'grid-cols-3'">
|
||||
<button @click="$emit('showSend')" class="home-card-btn px-3 py-2 glass-button rounded-lg text-sm font-medium text-center transition-colors">
|
||||
<div class="home-card-buttons flex gap-2 mt-auto pt-4 shrink-0">
|
||||
<button @click="$emit('showSend')" class="home-card-btn flex-1 px-3 py-2 glass-button rounded-lg text-sm font-medium text-center transition-colors">
|
||||
{{ t('common.send') }}
|
||||
</button>
|
||||
<button @click="$emit('showReceive')" class="home-card-btn px-3 py-2 glass-button rounded-lg text-sm font-medium text-center transition-colors">
|
||||
<button @click="$emit('showReceive')" class="home-card-btn flex-1 px-3 py-2 glass-button rounded-lg text-sm font-medium text-center transition-colors">
|
||||
Receive
|
||||
</button>
|
||||
<button @click="$emit('showTransactions')" class="home-card-btn px-3 py-2 glass-button rounded-lg text-sm font-medium text-center transition-colors">
|
||||
<button @click="$emit('showTransactions')" class="home-card-btn flex-1 px-3 py-2 glass-button rounded-lg text-sm font-medium text-center transition-colors">
|
||||
Transactions
|
||||
</button>
|
||||
<button v-if="isDev" @click="$emit('faucet')" class="home-card-btn px-3 py-2 glass-button rounded-lg text-sm font-medium text-center transition-colors text-green-400">
|
||||
Faucet
|
||||
</button>
|
||||
<button
|
||||
@click="$emit('showScan')"
|
||||
aria-label="Scan QR to send"
|
||||
title="Scan QR to send"
|
||||
class="home-card-btn px-3 py-2 glass-button rounded-lg transition-colors shrink-0 flex items-center justify-center"
|
||||
>
|
||||
<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="M3 8V6a2 2 0 012-2h2M3 16v2a2 2 0 002 2h2m10-16h2a2 2 0 012 2v2m-4 12h2a2 2 0 002-2v-2M7 12h10" />
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -197,6 +213,7 @@ const props = defineProps<{
|
||||
}>()
|
||||
|
||||
defineEmits<{
|
||||
showScan: []
|
||||
showSend: []
|
||||
showReceive: []
|
||||
showTransactions: []
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
<script setup lang="ts">
|
||||
import { ref, computed, watch } from 'vue'
|
||||
import { useMeshStore } from '@/stores/mesh'
|
||||
import { LORA_REGIONS, regionByCode, meshcorePlanFor } from '@/utils/loraRegions'
|
||||
import { LORA_REGIONS, regionByCode, meshcorePlanFor, MESHCORE_RF_PRESETS } from '@/utils/loraRegions'
|
||||
|
||||
const mesh = useMeshStore()
|
||||
|
||||
@@ -27,6 +27,12 @@ const form = ref({
|
||||
channel: 'archipelago',
|
||||
name: '',
|
||||
broadcastIdentity: true,
|
||||
// MeshCore LoRa PHY params (human units; converted to firmware units on
|
||||
// save). All four empty = leave the radio's flashed settings untouched.
|
||||
rfFreqMhz: '',
|
||||
rfBwKhz: '',
|
||||
rfSf: '',
|
||||
rfCr: '',
|
||||
})
|
||||
const saving = ref(false)
|
||||
const saveError = ref<string | null>(null)
|
||||
@@ -43,10 +49,54 @@ watch(
|
||||
form.value.deviceKind = s.device_kind ?? 'auto'
|
||||
form.value.channel = s.channel_name || 'archipelago'
|
||||
form.value.name = s.self_advert_name ?? ''
|
||||
const rp = (s as Record<string, unknown>).lora_radio_params as
|
||||
| { freq_khz: number; bw_hz: number; sf: number; cr: number }
|
||||
| null
|
||||
| undefined
|
||||
if (rp) {
|
||||
applyingPreset = true
|
||||
form.value.rfFreqMhz = String(rp.freq_khz / 1000)
|
||||
form.value.rfBwKhz = String(rp.bw_hz / 1000)
|
||||
form.value.rfSf = String(rp.sf)
|
||||
form.value.rfCr = String(rp.cr)
|
||||
// Show the matching named preset if the stored values are one; else Custom.
|
||||
const match = MESHCORE_RF_PRESETS.find(
|
||||
(p) =>
|
||||
Math.round(p.freqMhz * 1000) === rp.freq_khz &&
|
||||
Math.round(p.bwKhz * 1000) === rp.bw_hz &&
|
||||
p.sf === rp.sf &&
|
||||
p.cr === rp.cr,
|
||||
)
|
||||
rfPreset.value = match ? match.id : 'custom'
|
||||
applyingPreset = false
|
||||
}
|
||||
},
|
||||
{ immediate: true },
|
||||
)
|
||||
|
||||
// RF preset dropdown: picking a named plan fills the four fields; hand-editing
|
||||
// any field flips the dropdown to Custom so it never misrepresents the values.
|
||||
const rfPreset = ref('')
|
||||
let applyingPreset = false
|
||||
function selectRfPreset(id: string) {
|
||||
rfPreset.value = id
|
||||
if (id === 'custom' || id === '') return
|
||||
const p = MESHCORE_RF_PRESETS.find((x) => x.id === id)
|
||||
if (!p) return
|
||||
applyingPreset = true
|
||||
form.value.rfFreqMhz = String(p.freqMhz)
|
||||
form.value.rfBwKhz = String(p.bwKhz)
|
||||
form.value.rfSf = String(p.sf)
|
||||
form.value.rfCr = String(p.cr)
|
||||
applyingPreset = false
|
||||
}
|
||||
watch(
|
||||
() => [form.value.rfFreqMhz, form.value.rfBwKhz, form.value.rfSf, form.value.rfCr],
|
||||
() => {
|
||||
if (!applyingPreset && rfPreset.value && rfPreset.value !== 'custom') rfPreset.value = 'custom'
|
||||
},
|
||||
)
|
||||
|
||||
const selectedRegion = computed(() => regionByCode(form.value.region))
|
||||
const deviceType = computed(() => mesh.status?.device_type ?? 'unknown')
|
||||
// Firmware whose options apply: explicit pin wins, else the connected type.
|
||||
@@ -62,12 +112,39 @@ async function saveSettings() {
|
||||
saveError.value = null
|
||||
saveDone.value = false
|
||||
try {
|
||||
// MeshCore RF params: a named preset sends its plan; Custom sends the
|
||||
// four fields (all required — partial input is an error); "Keep the
|
||||
// radio's current settings" omits the key so the config is untouched.
|
||||
let rfParams: { freq_khz: number; bw_hz: number; sf: number; cr: number } | undefined
|
||||
if (rfPreset.value && rfPreset.value !== 'custom') {
|
||||
const p = MESHCORE_RF_PRESETS.find((x) => x.id === rfPreset.value)
|
||||
if (p) {
|
||||
rfParams = {
|
||||
freq_khz: Math.round(p.freqMhz * 1000),
|
||||
bw_hz: Math.round(p.bwKhz * 1000),
|
||||
sf: p.sf,
|
||||
cr: p.cr,
|
||||
}
|
||||
}
|
||||
} else if (rfPreset.value === 'custom') {
|
||||
const rf = [form.value.rfFreqMhz, form.value.rfBwKhz, form.value.rfSf, form.value.rfCr]
|
||||
if (!rf.every((v) => String(v).trim() !== '')) {
|
||||
throw new Error('Fill in all four RF fields (frequency, bandwidth, SF, CR)')
|
||||
}
|
||||
rfParams = {
|
||||
freq_khz: Math.round(parseFloat(form.value.rfFreqMhz) * 1000),
|
||||
bw_hz: Math.round(parseFloat(form.value.rfBwKhz) * 1000),
|
||||
sf: parseInt(form.value.rfSf, 10),
|
||||
cr: parseInt(form.value.rfCr, 10),
|
||||
}
|
||||
}
|
||||
await mesh.configure({
|
||||
lora_region: form.value.region,
|
||||
device_kind: form.value.deviceKind,
|
||||
channel_name: form.value.channel.trim() || 'archipelago',
|
||||
...(form.value.name.trim() ? { advert_name: form.value.name.trim() } : {}),
|
||||
broadcast_identity: form.value.broadcastIdentity,
|
||||
...(rfParams ? { lora_radio_params: rfParams } : {}),
|
||||
})
|
||||
saveDone.value = true
|
||||
setTimeout(() => { saveDone.value = false }, 3000)
|
||||
@@ -93,18 +170,6 @@ async function saveSettings() {
|
||||
<span class="mesh-stat-label">Node ID</span>
|
||||
<span class="mesh-stat-value">{{ mesh.status.self_node_id != null ? `!${mesh.status.self_node_id.toString(16).padStart(8, '0')}` : '—' }}</span>
|
||||
</div>
|
||||
<div class="mesh-stat">
|
||||
<span class="mesh-stat-label">Name</span>
|
||||
<span class="mesh-stat-value">{{ mesh.status.self_advert_name ?? '—' }}</span>
|
||||
</div>
|
||||
<div class="mesh-stat">
|
||||
<span class="mesh-stat-label">Region (radio)</span>
|
||||
<span class="mesh-stat-value">{{ mesh.status.region ?? 'Not set' }}</span>
|
||||
</div>
|
||||
<div class="mesh-stat">
|
||||
<span class="mesh-stat-label">Channel</span>
|
||||
<span class="mesh-stat-value">{{ mesh.status.channel_name }}</span>
|
||||
</div>
|
||||
<div class="mesh-stat">
|
||||
<span class="mesh-stat-label">Type</span>
|
||||
<span class="mesh-stat-value">{{ deviceType === 'unknown' ? '—' : deviceType }}</span>
|
||||
@@ -128,10 +193,10 @@ async function saveSettings() {
|
||||
Applied to fresh (region-unset) Meshtastic radios; a radio that already has a region keeps it.
|
||||
</p>
|
||||
<p v-else-if="effectiveKind === 'meshcore' && meshcorePlan" class="text-[11px] text-sky-300/80 mt-1">
|
||||
MeshCore community plan for {{ selectedRegion?.code }}: {{ meshcorePlan.freqMhz }} MHz, {{ meshcorePlan.bwKhz }} kHz, SF{{ meshcorePlan.sf }}, CR4/{{ meshcorePlan.cr }} — the radio's flashed RF settings apply; adjust via a MeshCore client if they differ.
|
||||
MeshCore community plan for {{ selectedRegion?.code }}: {{ meshcorePlan.freqMhz }} MHz, {{ meshcorePlan.bwKhz }} kHz, SF{{ meshcorePlan.sf }}, CR4/{{ meshcorePlan.cr }} — set the RF fields below to program the radio.
|
||||
</p>
|
||||
<p v-else-if="effectiveKind === 'meshcore'" class="text-[11px] text-sky-300/80 mt-1">
|
||||
MeshCore radios keep their flashed RF settings — verify the radio matches your region's band{{ selectedRegion ? ` (${selectedRegion.band} MHz)` : '' }}.
|
||||
Program the radio's RF settings with the fields below — every radio on your mesh must match{{ selectedRegion ? ` (${selectedRegion.band} MHz band)` : '' }}.
|
||||
</p>
|
||||
<p v-else-if="effectiveKind === 'reticulum'" class="text-[11px] text-sky-300/80 mt-1">
|
||||
RNode RF parameters are managed by the Reticulum daemon's interface config on this node.
|
||||
@@ -158,6 +223,51 @@ async function saveSettings() {
|
||||
<input v-model="form.name" maxlength="24" placeholder="node name" class="w-full rounded-lg bg-white/[0.06] border border-white/10 text-white px-3 py-2 text-sm focus:outline-none focus:border-orange-400/60" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- MeshCore LoRa PHY params — every radio on the local mesh must match
|
||||
or it hears RF energy but decodes nothing. Empty = leave the radio's
|
||||
flashed settings untouched. -->
|
||||
<div v-if="effectiveKind === 'meshcore'" class="mt-4">
|
||||
<h5 class="text-xs font-semibold text-white/80 mb-2">MeshCore RF parameters</h5>
|
||||
<div class="mb-3">
|
||||
<label class="block text-xs text-white/60 mb-1">Frequency plan</label>
|
||||
<select :value="rfPreset" class="w-full rounded-lg bg-white/[0.06] border border-white/10 text-white px-3 py-2 text-sm focus:outline-none focus:border-orange-400/60" @change="selectRfPreset(($event.target as HTMLSelectElement).value)">
|
||||
<option value="">Keep the radio's current settings</option>
|
||||
<option v-for="p in MESHCORE_RF_PRESETS" :key="p.id" :value="p.id">{{ p.label }}</option>
|
||||
<option value="custom">Custom…</option>
|
||||
</select>
|
||||
</div>
|
||||
<div v-if="rfPreset === 'custom'" class="grid gap-3 grid-cols-2 sm:grid-cols-4">
|
||||
<div>
|
||||
<label class="block text-xs text-white/60 mb-1">Frequency (MHz)</label>
|
||||
<input v-model="form.rfFreqMhz" inputmode="decimal" placeholder="869.618" class="w-full rounded-lg bg-white/[0.06] border border-white/10 text-white px-3 py-2 text-sm focus:outline-none focus:border-orange-400/60" />
|
||||
</div>
|
||||
<div>
|
||||
<label class="block text-xs text-white/60 mb-1">Bandwidth (kHz)</label>
|
||||
<select v-model="form.rfBwKhz" class="w-full rounded-lg bg-white/[0.06] border border-white/10 text-white px-3 py-2 text-sm focus:outline-none focus:border-orange-400/60">
|
||||
<option value="">—</option>
|
||||
<option v-for="bw in ['62.5', '125', '250', '500']" :key="bw" :value="bw">{{ bw }}</option>
|
||||
</select>
|
||||
</div>
|
||||
<div>
|
||||
<label class="block text-xs text-white/60 mb-1">Spreading factor</label>
|
||||
<select v-model="form.rfSf" class="w-full rounded-lg bg-white/[0.06] border border-white/10 text-white px-3 py-2 text-sm focus:outline-none focus:border-orange-400/60">
|
||||
<option value="">—</option>
|
||||
<option v-for="sf in [5, 6, 7, 8, 9, 10, 11, 12]" :key="sf" :value="String(sf)">SF {{ sf }}</option>
|
||||
</select>
|
||||
</div>
|
||||
<div>
|
||||
<label class="block text-xs text-white/60 mb-1">Coding rate</label>
|
||||
<select v-model="form.rfCr" class="w-full rounded-lg bg-white/[0.06] border border-white/10 text-white px-3 py-2 text-sm focus:outline-none focus:border-orange-400/60">
|
||||
<option value="">—</option>
|
||||
<option v-for="cr in [5, 6, 7, 8]" :key="cr" :value="String(cr)">4/{{ cr }}</option>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
<p class="text-[11px] text-white/40 mt-1">
|
||||
Saved settings program the radio on its next connect (it reboots once to apply). Leave all four empty to keep the radio's own settings.
|
||||
</p>
|
||||
</div>
|
||||
<label class="flex items-center gap-2 mt-3 text-sm text-white/80 cursor-pointer">
|
||||
<input v-model="form.broadcastIdentity" type="checkbox" class="h-4 w-4 accent-orange-500" />
|
||||
Periodically broadcast this node's identity on the mesh
|
||||
|
||||
@@ -362,6 +362,35 @@ init()
|
||||
</button>
|
||||
</div>
|
||||
<div class="overflow-y-auto flex-1 min-h-0 space-y-6 pr-1">
|
||||
<!-- v1.7.110-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.110-alpha</span>
|
||||
<span class="text-xs text-white/40">July 21, 2026</span>
|
||||
</div>
|
||||
<div class="space-y-3 text-sm text-white/80 pl-3 border-l border-white/10">
|
||||
<p>Pay by pointing your camera: the wallet has a new Scan button (on the wallet card and inside both the Send and Receive windows) that reads any payment QR code — Lightning invoices, Bitcoin addresses, Cashu tokens, and Fedimint invites — and takes you straight to the right send or redeem screen with everything filled in. It also understands the animated, multi-part QR codes some wallets show for long payloads. If your browser can't open a live camera preview (common when reaching the node over plain http), a "Take photo of QR" button snaps a picture with your phone's camera and reads the code from the photo instead.</p>
|
||||
<p>The TV screen got a complete overhaul. A deep bug made the display freeze on the intro artwork on 4K TVs — that's fixed, and along the way: the interface now picks a comfortable, sharp size for big screens (a 4K TV gets a full desktop layout at double sharpness), the artwork behind every page shows again instead of a black void, switching between tabs animates smoothly, the built-in AI assistant stays in its dark theme, and the Cashu and Ark wallet icons no longer render as empty squares.</p>
|
||||
<p>You can now choose how big the interface renders on your node's attached screen: Settings → Display offers Auto (recommended), Large UI, Balanced, and Native — changing it applies immediately.</p>
|
||||
<p>The companion phone app can steer the TV again. Remote input from the phone was being silently ignored on kiosk displays; the remote-control relay now runs there like everywhere else.</p>
|
||||
<p>The companion app is also ready to grant its built-in browser camera access, so the wallet scanner can work inside the app (ships with the next companion app build).</p>
|
||||
<p>Zero-amount Lightning invoices can now be paid: the wallet asks you for the amount and sends it along, instead of failing on invoices that leave the amount up to the payer.</p>
|
||||
<p>The Lightning setup guidance now reads the same everywhere: "Open a channel with Zeus Olympus node and start sending and receiving Lightning payments. Minimum 150,000 · maximum 1,500,000 on-chain sats required."</p>
|
||||
<p>Installer images now bundle a color-emoji font, so emoji anywhere in the interface render properly on the TV screen.</p>
|
||||
</div>
|
||||
</div>
|
||||
<!-- v1.7.109-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.109-alpha</span>
|
||||
<span class="text-xs text-white/40">July 21, 2026</span>
|
||||
</div>
|
||||
<div class="space-y-3 text-sm text-white/80 pl-3 border-l border-white/10">
|
||||
<p>Meet Pine, your node's voice assistant: a new app in the App Store that gives your node ears and a voice — speech-to-text and text-to-speech engines that run entirely on your own hardware, ready to wire into Home Assistant for private, offline voice control. Install it like any other app; nothing you say leaves your node.</p>
|
||||
<p>Your node can now program its MeshCore radio's RF settings — frequency, bandwidth, spreading factor, and coding rate — from Mesh → Device settings. Radios that were flashed with mismatched settings could hear that other radios exist but never decode their messages, and until now the only fix was a separate phone app. Set the values once and the node programs the radio automatically (it restarts once to apply); every radio on your mesh must use the same values to talk to each other.</p>
|
||||
<p>The Device settings panel is tidier: values you can edit (name, region, channel) are no longer also shown as separate read-only rows.</p>
|
||||
</div>
|
||||
</div>
|
||||
<!-- v1.7.108-alpha -->
|
||||
<div>
|
||||
<div class="flex items-center gap-2 mb-3">
|
||||
|
||||
@@ -0,0 +1,82 @@
|
||||
<script setup lang="ts">
|
||||
import { ref, onMounted } from 'vue'
|
||||
import { rpcClient } from '@/api/rpc-client'
|
||||
|
||||
// Kiosk display resolution/scaling presets. Only shown on nodes that actually
|
||||
// have a kiosk display (has_kiosk from the backend).
|
||||
const hasKiosk = ref(false)
|
||||
const preset = ref('auto')
|
||||
const applying = ref(false)
|
||||
const error = ref('')
|
||||
|
||||
const presets = [
|
||||
{
|
||||
id: 'auto',
|
||||
label: 'Auto (recommended)',
|
||||
description: 'Pick a comfortable size for the detected screen — 4K TVs get a Full-HD-sized layout at double sharpness.',
|
||||
},
|
||||
{
|
||||
id: 'large',
|
||||
label: 'Large UI',
|
||||
description: 'Biggest text and buttons — easiest to read from across the room, fits less on screen.',
|
||||
},
|
||||
{
|
||||
id: 'balanced',
|
||||
label: 'Balanced',
|
||||
description: 'Desktop-sized layout on any screen that can carry it — more on screen, still sharp.',
|
||||
},
|
||||
{
|
||||
id: 'native',
|
||||
label: 'Native (no scaling)',
|
||||
description: 'Use the screen’s full native resolution 1:1 — the most content, the smallest UI.',
|
||||
},
|
||||
]
|
||||
|
||||
onMounted(async () => {
|
||||
try {
|
||||
const res = await rpcClient.call<{ has_kiosk: boolean; preset: string }>({ method: 'system.kiosk-display.get' })
|
||||
hasKiosk.value = res.has_kiosk
|
||||
preset.value = res.preset
|
||||
} catch { /* backend without the RPC — leave the section hidden */ }
|
||||
})
|
||||
|
||||
async function apply(id: string) {
|
||||
if (applying.value || id === preset.value) return
|
||||
applying.value = true
|
||||
error.value = ''
|
||||
const prev = preset.value
|
||||
preset.value = id
|
||||
try {
|
||||
await rpcClient.call({ method: 'system.kiosk-display.set', params: { preset: id }, timeout: 20000 })
|
||||
} catch (e: unknown) {
|
||||
preset.value = prev
|
||||
error.value = e instanceof Error ? e.message : 'Failed to apply display setting'
|
||||
} finally {
|
||||
applying.value = false
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<!-- Kiosk Display Section — only on nodes with an attached kiosk screen -->
|
||||
<div v-if="hasKiosk" class="glass-card px-6 py-6 mb-6">
|
||||
<h2 class="text-xl font-semibold text-white/96 mb-2">Display</h2>
|
||||
<p class="text-sm text-white/60 mb-6">
|
||||
How big the interface renders on the screen attached to this node. Changing this restarts the on-screen display.
|
||||
</p>
|
||||
<div data-controller-container tabindex="0" class="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||
<button
|
||||
v-for="p in presets"
|
||||
:key="p.id"
|
||||
:disabled="applying"
|
||||
@click="apply(p.id)"
|
||||
class="path-option-card text-left p-5 disabled:opacity-60"
|
||||
:class="{ 'path-option-card--selected': preset === p.id }"
|
||||
>
|
||||
<div class="font-medium text-white/90 mb-1">{{ p.label }}</div>
|
||||
<p class="text-sm text-white/60">{{ p.description }}</p>
|
||||
</button>
|
||||
</div>
|
||||
<div v-if="error" class="mt-4 alert-error text-sm">{{ error }}</div>
|
||||
</div>
|
||||
</template>
|
||||
@@ -1,5 +1,6 @@
|
||||
<script setup lang="ts">
|
||||
import InterfaceModeSection from '@/views/settings/InterfaceModeSection.vue'
|
||||
import KioskDisplaySection from '@/views/settings/KioskDisplaySection.vue'
|
||||
import ClaudeAuthSection from '@/views/settings/ClaudeAuthSection.vue'
|
||||
import AIDataAccessSection from '@/views/settings/AIDataAccessSection.vue'
|
||||
import WebhookSection from '@/views/settings/WebhookSection.vue'
|
||||
@@ -10,6 +11,7 @@ import SystemDangerZone from '@/views/settings/SystemDangerZone.vue'
|
||||
|
||||
<template>
|
||||
<InterfaceModeSection />
|
||||
<KioskDisplaySection />
|
||||
<ClaudeAuthSection />
|
||||
<AIDataAccessSection />
|
||||
<WebhookSection />
|
||||
|
||||
+22
-20
@@ -1,32 +1,34 @@
|
||||
{
|
||||
"changelog": [
|
||||
"Your node connects to the private mesh far more reliably. Nodes rely on a public rendezvous point to find each other, and the only one available was unreachable from many home and office networks — leaving some nodes unable to join the mesh at all. There is now a second, always-reachable rendezvous point, and your node tries every one it knows, so it joins the mesh in seconds instead of being stranded.",
|
||||
"Wi-Fi setup now heals itself on older nodes. Some nodes set up before a mid-year fix couldn't connect to a Wi-Fi network from the screen — it failed with a permissions error — because the piece that lets the node manage networking on your behalf was missing. Nodes now put that piece in place automatically on startup, so \"scan, pick a network, type the password, connect\" works without reinstalling.",
|
||||
"Your node rejoins the mesh within seconds after an update. Applying an update briefly restarts the mesh service, and previously a node could sit disconnected from other nodes for up to five minutes before it retried.",
|
||||
"The TV screen now fits your television. On a large or 4K TV the interface rendered tiny with no way to zoom on a keyboard-less screen; it now sizes itself to a comfortable, readable scale automatically (and small laptop panels are left unchanged).",
|
||||
"More TV-screen polish: the built-in assistant shows its dark theme instead of bright white panels, the on-screen hint for switching between the kiosk and a terminal now points at the right keys, the welcome logo no longer occasionally renders as garbled characters, and an accidental tap of the power button no longer shuts the node down — hold it to power off on purpose.",
|
||||
"Behind the scenes: fixed the installer image build so it no longer stops on a component that was removed from the product, and so it correctly includes the private relay it was meant to bundle."
|
||||
"Pay by pointing your camera: the wallet has a new Scan button (on the wallet card and inside both the Send and Receive windows) that reads any payment QR code — Lightning invoices, Bitcoin addresses, Cashu tokens, and Fedimint invites — and takes you straight to the right send or redeem screen with everything filled in. It also understands the animated, multi-part QR codes some wallets show for long payloads. If your browser can't open a live camera preview (common when reaching the node over plain http), a \"Take photo of QR\" button snaps a picture with your phone's camera and reads the code from the photo instead.",
|
||||
"The TV screen got a complete overhaul. A deep bug made the display freeze on the intro artwork on 4K TVs — that's fixed, and along the way: the interface now picks a comfortable, sharp size for big screens (a 4K TV gets a full desktop layout at double sharpness), the artwork behind every page shows again instead of a black void, switching between tabs animates smoothly, the built-in AI assistant stays in its dark theme, and the Cashu and Ark wallet icons no longer render as empty squares.",
|
||||
"You can now choose how big the interface renders on your node's attached screen: Settings → Display offers Auto (recommended), Large UI, Balanced, and Native — changing it applies immediately.",
|
||||
"The companion phone app can steer the TV again. Remote input from the phone was being silently ignored on kiosk displays; the remote-control relay now runs there like everywhere else.",
|
||||
"The companion app is also ready to grant its built-in browser camera access, so the wallet scanner can work inside the app (ships with the next companion app build).",
|
||||
"Zero-amount Lightning invoices can now be paid: the wallet asks you for the amount and sends it along, instead of failing on invoices that leave the amount up to the payer.",
|
||||
"The Lightning setup guidance now reads the same everywhere: \"Open a channel with Zeus Olympus node and start sending and receiving Lightning payments. Minimum 150,000 · maximum 1,500,000 on-chain sats required.\"",
|
||||
"Installer images now bundle a color-emoji font, so emoji anywhere in the interface render properly on the TV screen."
|
||||
],
|
||||
"components": [
|
||||
{
|
||||
"current_version": "1.7.108-alpha",
|
||||
"download_url": "http://146.59.87.168:3000/lfg2025/archy/releases/download/v1.7.108-alpha/archipelago",
|
||||
"current_version": "1.7.110-alpha",
|
||||
"download_url": "http://146.59.87.168:3000/lfg2025/archy/releases/download/v1.7.110-alpha/archipelago",
|
||||
"name": "archipelago",
|
||||
"new_version": "1.7.108-alpha",
|
||||
"sha256": "665d3351980d820287e614b0fdf4916957c042110f1ffd7ef6e93e78766ffa2a",
|
||||
"size_bytes": 50346064
|
||||
"new_version": "1.7.110-alpha",
|
||||
"sha256": "60360d8138a5a5059278c9c5ea1c7255ebd55d3b0abfcb5ec1ae84e9cdd61a33",
|
||||
"size_bytes": 50447256
|
||||
},
|
||||
{
|
||||
"current_version": "1.7.108-alpha",
|
||||
"download_url": "http://146.59.87.168:3000/lfg2025/archy/releases/download/v1.7.108-alpha/archipelago-frontend-1.7.108-alpha.tar.gz",
|
||||
"name": "archipelago-frontend-1.7.108-alpha.tar.gz",
|
||||
"new_version": "1.7.108-alpha",
|
||||
"sha256": "8fba3d3aeb2b506cb6a3b3102a07a1713ae7a2bd1ebd4fc734d83f83ba9a8aa8",
|
||||
"size_bytes": 174598175
|
||||
"current_version": "1.7.110-alpha",
|
||||
"download_url": "http://146.59.87.168:3000/lfg2025/archy/releases/download/v1.7.110-alpha/archipelago-frontend-1.7.110-alpha.tar.gz",
|
||||
"name": "archipelago-frontend-1.7.110-alpha.tar.gz",
|
||||
"new_version": "1.7.110-alpha",
|
||||
"sha256": "f7794cc3343dff5e1c6fc301be23a15fa25ec01a84f5370d1cbad640843575fa",
|
||||
"size_bytes": 174646904
|
||||
}
|
||||
],
|
||||
"release_date": "2026-07-20",
|
||||
"signature": "94e2d15488383858d41c90c49075476ce56e4fed8b5829b92f68b3889229e795f250a57e557049bb46561c7c252d5d30db442d014b796e76d7ea4fbd7215dc00",
|
||||
"release_date": "2026-07-21",
|
||||
"signature": "90d047fa24f4f64dff1145c02156801cdd98e3db52708009269b90809b882271b614f5bcddfbd8e2d6b4dc81f967d01ad5e8bbf1b4c19c91e8874c015a587b06",
|
||||
"signed_by": "did:key:z6MkkidEnEpo6qHMCNSZoNKWtvQvxq3whnaME9wGgEFhq7ur",
|
||||
"version": "1.7.108-alpha"
|
||||
"version": "1.7.110-alpha"
|
||||
}
|
||||
|
||||
+2683
-2376
File diff suppressed because one or more lines are too long
+22
-20
@@ -1,32 +1,34 @@
|
||||
{
|
||||
"changelog": [
|
||||
"Your node connects to the private mesh far more reliably. Nodes rely on a public rendezvous point to find each other, and the only one available was unreachable from many home and office networks — leaving some nodes unable to join the mesh at all. There is now a second, always-reachable rendezvous point, and your node tries every one it knows, so it joins the mesh in seconds instead of being stranded.",
|
||||
"Wi-Fi setup now heals itself on older nodes. Some nodes set up before a mid-year fix couldn't connect to a Wi-Fi network from the screen — it failed with a permissions error — because the piece that lets the node manage networking on your behalf was missing. Nodes now put that piece in place automatically on startup, so \"scan, pick a network, type the password, connect\" works without reinstalling.",
|
||||
"Your node rejoins the mesh within seconds after an update. Applying an update briefly restarts the mesh service, and previously a node could sit disconnected from other nodes for up to five minutes before it retried.",
|
||||
"The TV screen now fits your television. On a large or 4K TV the interface rendered tiny with no way to zoom on a keyboard-less screen; it now sizes itself to a comfortable, readable scale automatically (and small laptop panels are left unchanged).",
|
||||
"More TV-screen polish: the built-in assistant shows its dark theme instead of bright white panels, the on-screen hint for switching between the kiosk and a terminal now points at the right keys, the welcome logo no longer occasionally renders as garbled characters, and an accidental tap of the power button no longer shuts the node down — hold it to power off on purpose.",
|
||||
"Behind the scenes: fixed the installer image build so it no longer stops on a component that was removed from the product, and so it correctly includes the private relay it was meant to bundle."
|
||||
"Pay by pointing your camera: the wallet has a new Scan button (on the wallet card and inside both the Send and Receive windows) that reads any payment QR code — Lightning invoices, Bitcoin addresses, Cashu tokens, and Fedimint invites — and takes you straight to the right send or redeem screen with everything filled in. It also understands the animated, multi-part QR codes some wallets show for long payloads. If your browser can't open a live camera preview (common when reaching the node over plain http), a \"Take photo of QR\" button snaps a picture with your phone's camera and reads the code from the photo instead.",
|
||||
"The TV screen got a complete overhaul. A deep bug made the display freeze on the intro artwork on 4K TVs — that's fixed, and along the way: the interface now picks a comfortable, sharp size for big screens (a 4K TV gets a full desktop layout at double sharpness), the artwork behind every page shows again instead of a black void, switching between tabs animates smoothly, the built-in AI assistant stays in its dark theme, and the Cashu and Ark wallet icons no longer render as empty squares.",
|
||||
"You can now choose how big the interface renders on your node's attached screen: Settings → Display offers Auto (recommended), Large UI, Balanced, and Native — changing it applies immediately.",
|
||||
"The companion phone app can steer the TV again. Remote input from the phone was being silently ignored on kiosk displays; the remote-control relay now runs there like everywhere else.",
|
||||
"The companion app is also ready to grant its built-in browser camera access, so the wallet scanner can work inside the app (ships with the next companion app build).",
|
||||
"Zero-amount Lightning invoices can now be paid: the wallet asks you for the amount and sends it along, instead of failing on invoices that leave the amount up to the payer.",
|
||||
"The Lightning setup guidance now reads the same everywhere: \"Open a channel with Zeus Olympus node and start sending and receiving Lightning payments. Minimum 150,000 · maximum 1,500,000 on-chain sats required.\"",
|
||||
"Installer images now bundle a color-emoji font, so emoji anywhere in the interface render properly on the TV screen."
|
||||
],
|
||||
"components": [
|
||||
{
|
||||
"current_version": "1.7.108-alpha",
|
||||
"download_url": "http://146.59.87.168:3000/lfg2025/archy/releases/download/v1.7.108-alpha/archipelago",
|
||||
"current_version": "1.7.110-alpha",
|
||||
"download_url": "http://146.59.87.168:3000/lfg2025/archy/releases/download/v1.7.110-alpha/archipelago",
|
||||
"name": "archipelago",
|
||||
"new_version": "1.7.108-alpha",
|
||||
"sha256": "665d3351980d820287e614b0fdf4916957c042110f1ffd7ef6e93e78766ffa2a",
|
||||
"size_bytes": 50346064
|
||||
"new_version": "1.7.110-alpha",
|
||||
"sha256": "60360d8138a5a5059278c9c5ea1c7255ebd55d3b0abfcb5ec1ae84e9cdd61a33",
|
||||
"size_bytes": 50447256
|
||||
},
|
||||
{
|
||||
"current_version": "1.7.108-alpha",
|
||||
"download_url": "http://146.59.87.168:3000/lfg2025/archy/releases/download/v1.7.108-alpha/archipelago-frontend-1.7.108-alpha.tar.gz",
|
||||
"name": "archipelago-frontend-1.7.108-alpha.tar.gz",
|
||||
"new_version": "1.7.108-alpha",
|
||||
"sha256": "8fba3d3aeb2b506cb6a3b3102a07a1713ae7a2bd1ebd4fc734d83f83ba9a8aa8",
|
||||
"size_bytes": 174598175
|
||||
"current_version": "1.7.110-alpha",
|
||||
"download_url": "http://146.59.87.168:3000/lfg2025/archy/releases/download/v1.7.110-alpha/archipelago-frontend-1.7.110-alpha.tar.gz",
|
||||
"name": "archipelago-frontend-1.7.110-alpha.tar.gz",
|
||||
"new_version": "1.7.110-alpha",
|
||||
"sha256": "f7794cc3343dff5e1c6fc301be23a15fa25ec01a84f5370d1cbad640843575fa",
|
||||
"size_bytes": 174646904
|
||||
}
|
||||
],
|
||||
"release_date": "2026-07-20",
|
||||
"signature": "94e2d15488383858d41c90c49075476ce56e4fed8b5829b92f68b3889229e795f250a57e557049bb46561c7c252d5d30db442d014b796e76d7ea4fbd7215dc00",
|
||||
"release_date": "2026-07-21",
|
||||
"signature": "90d047fa24f4f64dff1145c02156801cdd98e3db52708009269b90809b882271b614f5bcddfbd8e2d6b4dc81f967d01ad5e8bbf1b4c19c91e8874c015a587b06",
|
||||
"signed_by": "did:key:z6MkkidEnEpo6qHMCNSZoNKWtvQvxq3whnaME9wGgEFhq7ur",
|
||||
"version": "1.7.108-alpha"
|
||||
"version": "1.7.110-alpha"
|
||||
}
|
||||
|
||||
@@ -39,6 +39,8 @@ INTERNAL_MANIFEST_IDS = {
|
||||
"indeedhub-relay",
|
||||
"netbird-dashboard",
|
||||
"netbird-server",
|
||||
"pine-whisper",
|
||||
"pine-piper",
|
||||
}
|
||||
|
||||
LEGACY_STACK_CATALOG_IDS = {
|
||||
|
||||
Reference in New Issue
Block a user