diff --git a/neode-ui/mock-backend.js b/neode-ui/mock-backend.js index 0b868ed6..eeb76e16 100755 --- a/neode-ui/mock-backend.js +++ b/neode-ui/mock-backend.js @@ -2159,6 +2159,7 @@ app.post('/rpc/v1', (req, res) => { // Mesh Networking (LoRa radio via Meshcore) // ===================================================================== case 'mesh.status': { + globalThis.__meshHeaders ||= { announce_block_headers: false, receive_block_headers: true } return res.json({ result: { enabled: true, @@ -2173,6 +2174,8 @@ app.post('/rpc/v1', (req, res) => { messages_sent: 23, messages_received: 47, detected_devices: ['/dev/ttyUSB0'], + announce_block_headers: globalThis.__meshHeaders.announce_block_headers, + receive_block_headers: globalThis.__meshHeaders.receive_block_headers, }, }) } @@ -2278,7 +2281,10 @@ app.post('/rpc/v1', (req, res) => { case 'mesh.configure': { console.log(`[Mesh] Configure:`, params) - return res.json({ result: { configured: true } }) + globalThis.__meshHeaders ||= { announce_block_headers: false, receive_block_headers: true } + if (params && typeof params.announce_block_headers === 'boolean') globalThis.__meshHeaders.announce_block_headers = params.announce_block_headers + if (params && typeof params.receive_block_headers === 'boolean') globalThis.__meshHeaders.receive_block_headers = params.receive_block_headers + return res.json({ result: { configured: true, ...globalThis.__meshHeaders } }) } case 'mesh.send-invoice': { @@ -3015,6 +3021,55 @@ app.post('/rpc/v1', (req, res) => { }) } + case 'seed.reveal': { + if (!params || !params.password) { + return res.json({ error: { code: -32000, message: 'Password is required to reveal the recovery phrase' } }) + } + // Demo gate: accept any non-empty password; reject "wrong" to exercise the error path. + if (params.password === 'wrong') { + return res.json({ error: { code: -32000, message: 'Incorrect password' } }) + } + const demo = 'legal winner thank year wave sausage worth useful legal winner thank yellow able cabin dad debris during dose talent layer crater proud drift movie'.split(' ') + return res.json({ result: { words: demo, word_count: demo.length } }) + } + + case 'update.list-mirrors': { + globalThis.__mockMirrors ||= [ + { url: 'http://146.59.87.168:3000/lfg2025/archy/raw/branch/main/releases/manifest.json', label: 'Origin (vps2)' }, + ] + return res.json({ result: { mirrors: globalThis.__mockMirrors } }) + } + + case 'update.get-source': { + globalThis.__swarmPrefs ||= { source: 'origin', provide_dht: true } + return res.json({ + result: { + source: globalThis.__swarmPrefs.source, + provide_dht: globalThis.__swarmPrefs.provide_dht, + swarm_available: false, // default build has no iroh-swarm feature + swarm_enabled: false, + }, + }) + } + + case 'update.set-source': { + globalThis.__swarmPrefs ||= { source: 'origin', provide_dht: true } + if (params && (params.source === 'origin' || params.source === 'swarm')) { + globalThis.__swarmPrefs.source = params.source + } + if (params && typeof params.provide === 'boolean') { + globalThis.__swarmPrefs.provide_dht = params.provide + } + return res.json({ + result: { + source: globalThis.__swarmPrefs.source, + provide_dht: globalThis.__swarmPrefs.provide_dht, + swarm_available: false, + swarm_enabled: false, + }, + }) + } + case 'network.list-requests': { return res.json({ result: { diff --git a/neode-ui/public/catalog.json b/neode-ui/public/catalog.json index e410d4e5..8874ef29 100644 --- a/neode-ui/public/catalog.json +++ b/neode-ui/public/catalog.json @@ -290,6 +290,18 @@ "dockerImage": "146.59.87.168:3000/lfg2025/fedimintd:v0.10.0", "repoUrl": "https://github.com/fedimint/fedimint" }, + { + "id": "fedimint-clientd", + "title": "Fedimint Client", + "version": "0.8.0", + "description": "Fedimint ecash client daemon (fmcd). Lets your node hold Fedimint ecash and join federations; the wallet talks to it over a local REST API.", + "icon": "/assets/img/app-icons/fedimint.png", + "author": "Fedimint", + "category": "money", + "tier": "core", + "dockerImage": "146.59.87.168:3000/lfg2025/fmcd:0.8.0", + "repoUrl": "https://github.com/minmoto/fmcd" + }, { "id": "fedimint-gateway", "title": "Fedimint Gateway", diff --git a/neode-ui/src/api/remote-relay.ts b/neode-ui/src/api/remote-relay.ts index 7787d8b8..be04e57a 100644 --- a/neode-ui/src/api/remote-relay.ts +++ b/neode-ui/src/api/remote-relay.ts @@ -139,6 +139,41 @@ function deepElementFromPoint(x: number, y: number): Element | null { return el } +/** + * Find the nearest scrollable ancestor of `el` for the given delta, hopping out + * of same-origin iframes when needed. Synthetic WheelEvents are untrusted and + * never actually scroll the page, so two-finger scroll must call scrollBy on a + * real scroll container — this locates it (e.g. the right-hand app frame). (#7) + */ +function findScrollable(el: Element | null, dx: number, dy: number): Element | null { + let node: Element | null = el + let guard = 0 + while (node && guard++ < 60) { + const win = node.ownerDocument?.defaultView + const style = win?.getComputedStyle(node) + if (style) { + const oy = style.overflowY + const ox = style.overflowX + const isRoot = node === node.ownerDocument?.scrollingElement + const canY = + (oy === 'auto' || oy === 'scroll' || isRoot) && + node.scrollHeight > node.clientHeight + 1 + const canX = + (ox === 'auto' || ox === 'scroll' || isRoot) && + node.scrollWidth > node.clientWidth + 1 + if ((dy !== 0 && canY) || (dx !== 0 && canX)) return node + } + if (node.parentElement) { + node = node.parentElement + } else if (win?.frameElement) { + node = win.frameElement as Element // same-origin iframe → continue in parent doc + } else { + break + } + } + return null +} + /** The actually-focused element, descending through same-origin iframes. */ function deepActiveElement(): Element | null { let el: Element | null = document.activeElement @@ -267,10 +302,19 @@ function handleMessage(data: string) { break } case 's': { - const dy = msg.y ?? 0 - document.dispatchEvent(new WheelEvent('wheel', { - bubbles: true, deltaY: dy * 100, deltaMode: WheelEvent.DOM_DELTA_PIXEL, - })) + // Scroll the element under the virtual cursor (incl. inside same-origin + // app frames like the right-hand panel), not the top document. A synthetic + // wheel event won't scroll — call scrollBy on a real scroll container. (#7) + const dy = (msg.y ?? 0) * 100 + const dx = (msg.x ?? 0) * 100 + const start = deepElementFromPoint(cursorX, cursorY) + const scroller = findScrollable(start, dx, dy) + if (scroller) { + scroller.scrollBy({ left: dx, top: dy }) + } else { + const win = start?.ownerDocument?.defaultView ?? window + win.scrollBy(dx, dy) + } break } } @@ -308,6 +352,30 @@ function doConnect() { } } +/** + * Ask the companion (phone) to open a URL in its own browser. + * + * "Open in external browser" apps can't be usefully opened on the kiosk when a + * companion is driving it — `window.open` lands on the kiosk, which the phone + * user never sees. When a companion is active we forward the URL over the relay + * socket ({"t":"o","url"}); the backend routes it to the phone, which opens it. + * + * Returns true if the request was forwarded (caller should NOT open locally), + * false if there's no active companion (caller should open normally). + */ +export function requestExternalOpen(url: string): boolean { + if (!url || !/^https?:\/\//i.test(url)) return false + if (!companionActive.value) return false + if (!ws || ws.readyState !== WebSocket.OPEN) return false + try { + ws.send(JSON.stringify({ t: 'o', url })) + if (import.meta.env.DEV) console.log('[RemoteRelay] Forwarded external-open to companion:', url) + return true + } catch { + return false + } +} + /** Start the remote relay listener. Connects to /ws/remote-relay. */ export function startRemoteRelay() { shouldReconnect = true diff --git a/neode-ui/src/components/BackButton.vue b/neode-ui/src/components/BackButton.vue new file mode 100644 index 00000000..eeb4d185 --- /dev/null +++ b/neode-ui/src/components/BackButton.vue @@ -0,0 +1,49 @@ + + + diff --git a/neode-ui/src/components/WalletSettingsModal.vue b/neode-ui/src/components/WalletSettingsModal.vue new file mode 100644 index 00000000..d3e54397 --- /dev/null +++ b/neode-ui/src/components/WalletSettingsModal.vue @@ -0,0 +1,285 @@ + + + diff --git a/neode-ui/src/main.ts b/neode-ui/src/main.ts index d27e2f6e..1b3af2f8 100644 --- a/neode-ui/src/main.ts +++ b/neode-ui/src/main.ts @@ -4,6 +4,7 @@ import './style.css' import App from './App.vue' import router from './router' import i18n from './i18n' +import { displayVersion } from '@/utils/version' // Clipboard polyfill for HTTP (non-secure) contexts where navigator.clipboard is unavailable if (!navigator.clipboard) { @@ -31,6 +32,11 @@ app.use(pinia) app.use(router) app.use(i18n) +// Global version formatter — normalizes version labels to a single "v" prefix +// (some sources already carry one, which produced "vv1.2.3"). Use `$ver(x)` in +// templates instead of hard-coding a `v` prefix. +app.config.globalProperties.$ver = displayVersion + app.config.errorHandler = (err, _instance, info) => { console.error('[Vue Error]', err, info) const { error } = useToast() diff --git a/neode-ui/src/stores/appLauncher.ts b/neode-ui/src/stores/appLauncher.ts index 85956def..68bc66f1 100644 --- a/neode-ui/src/stores/appLauncher.ts +++ b/neode-ui/src/stores/appLauncher.ts @@ -3,6 +3,26 @@ import { ref, watch } from 'vue' import { rpcClient } from '@/api/rpc-client' import router from '@/router' import { recordAppLaunch } from '@/utils/appUsage' +import { requestExternalOpen } from '@/api/remote-relay' + +/** + * Open a URL in a new browser tab — but if a companion (phone) is currently + * driving this kiosk, hand the URL to the phone instead so it opens in the + * phone's browser rather than the (often headless / unattended) kiosk display. + * Falls back to a local `window.open` when no companion is active. + */ +function openExternal(launchUrl: string) { + // Resolve to an absolute URL so the phone can open it (window.open also + // handles absolute URLs fine). + let absolute = launchUrl + try { + absolute = new URL(launchUrl, window.location.origin).href + } catch { + /* keep as-is */ + } + if (requestExternalOpen(absolute)) return + window.open(launchUrl, '_blank', 'noopener,noreferrer') +} /** Ports of apps that set X-Frame-Options (can't iframe, must open in new tab) */ const NEW_TAB_PORTS = new Set([ @@ -29,8 +49,16 @@ const NEW_TAB_APP_IDS = new Set([ 'nginx-proxy-manager', 'uptime-kuma', 'gitea', + // netbird's dashboard needs a secure context (window.crypto.subtle for OIDC + // PKCE), so it's served over HTTPS and must open in a real tab — a + // self-signed-HTTPS iframe is blocked by the browser (you can't accept the + // cert warning inside an iframe). + 'netbird', ]) +// Apps served over HTTPS (self-signed) rather than plain HTTP. +const HTTPS_APP_IDS = new Set(['netbird']) + function mustOpenInNewTab(url: string): boolean { try { const u = new URL(url) @@ -127,12 +155,16 @@ const APP_ID_TO_PORT: Record = { 'nginx-proxy-manager': '8081', 'uptime-kuma': '3002', gitea: '3001', + // Without this, directAppUrl('netbird') returns null and netbird falls + // through to the iframe (and never gets its https URL) — issue #15. + netbird: '8087', } function directAppUrl(appId: string): string | null { const port = APP_ID_TO_PORT[appId] if (!port || typeof window === 'undefined') return null - return `http://${window.location.hostname}:${port}` + const scheme = HTTPS_APP_IDS.has(appId) ? 'https' : 'http' + return `${scheme}://${window.location.hostname}:${port}` } @@ -192,7 +224,7 @@ export const useAppLauncherStore = defineStore('appLauncher', () => { const mobile = isMobileViewport() const launchUrl = NEW_TAB_APP_IDS.has(appId) ? directAppUrl(appId) : null if (launchUrl && !mobile) { - window.open(launchUrl, '_blank', 'noopener,noreferrer') + openExternal(launchUrl) return } @@ -212,12 +244,25 @@ export const useAppLauncherStore = defineStore('appLauncher', () => { /** Legacy: open app in iframe overlay (kept for backward compat) */ function open(payload: { url: string; title: string; openInNewTab?: boolean }) { const titleHintId = inferAppIdFromTitle(payload.title) - const launchUrl = normalizeLaunchUrl(payload.url, titleHintId) + let launchUrl = normalizeLaunchUrl(payload.url, titleHintId) const resolvedId = resolveAppIdFromUrl(launchUrl) || titleHintId + // Apps served over HTTPS (e.g. netbird, which needs a secure context for + // its OIDC dashboard) must be launched over https — a stale http URL hits + // the TLS port and 400s. Upgrade the scheme defensively in every path. + if (resolvedId && HTTPS_APP_IDS.has(resolvedId)) { + try { + const u = new URL(launchUrl, window.location.origin) + if (u.protocol === 'http:') { + u.protocol = 'https:' + launchUrl = u.href + } + } catch { /* leave as-is */ } + } + if (!isMobileViewport() && payload.openInNewTab) { if (resolvedId) recordAppLaunch(resolvedId) - window.open(launchUrl, '_blank', 'noopener,noreferrer') + openExternal(launchUrl) return } @@ -226,7 +271,7 @@ export const useAppLauncherStore = defineStore('appLauncher', () => { // native launchers and keep the user inside Archipelago. if (!isMobileViewport() && resolvedId && NEW_TAB_APP_IDS.has(resolvedId)) { recordAppLaunch(resolvedId) - window.open(launchUrl, '_blank', 'noopener,noreferrer') + openExternal(launchUrl) return } @@ -238,7 +283,7 @@ export const useAppLauncherStore = defineStore('appLauncher', () => { // Unknown apps that block iframes — open directly in new tab if (!isMobileViewport() && mustOpenInNewTab(launchUrl)) { - window.open(launchUrl, '_blank', 'noopener,noreferrer') + openExternal(launchUrl) return } diff --git a/neode-ui/src/stores/mesh.ts b/neode-ui/src/stores/mesh.ts index 8e4517a1..7ccabc88 100644 --- a/neode-ui/src/stores/mesh.ts +++ b/neode-ui/src/stores/mesh.ts @@ -16,6 +16,9 @@ export interface MeshStatus { messages_sent: number messages_received: number detected_devices?: string[] + /** Bitcoin block-header send/receive prefs (issue #28). */ + announce_block_headers?: boolean + receive_block_headers?: boolean } export interface MeshPeer { diff --git a/neode-ui/src/style.css b/neode-ui/src/style.css index fbc1d738..2f43fe54 100644 --- a/neode-ui/src/style.css +++ b/neode-ui/src/style.css @@ -729,6 +729,36 @@ input[type="radio"]:active + * { min-height: 36px; } + /* Transparent "frosted" variant for back buttons — the light counterpart to + the solid black .glass-button. Used by the floating mobile back pill so it + reads as transparent over content rather than a black slab. */ + .back-button-glass { + position: relative; + display: inline-flex; + align-items: center; + justify-content: center; + min-height: 44px; + background: rgba(255, 255, 255, 0.08); + backdrop-filter: blur(24px) saturate(140%); + -webkit-backdrop-filter: blur(24px) saturate(140%); + border: 1px solid rgba(255, 255, 255, 0.16); + box-shadow: + 0 8px 24px rgba(0, 0, 0, 0.28), + inset 0 1px 0 rgba(255, 255, 255, 0.18); + color: rgba(255, 255, 255, 0.92); + transition: transform 0.3s cubic-bezier(0.4, 0, 0.2, 1), background-color 0.2s ease, border-color 0.2s ease; + } + + .back-button-glass:hover { + transform: translateY(-2px); + background: rgba(255, 255, 255, 0.14); + border-color: rgba(255, 255, 255, 0.26); + } + + .back-button-glass:active { + transform: translateY(1px); + } + /* Glass button color variants */ .glass-button-warning { background: rgba(251, 146, 60, 0.2); diff --git a/neode-ui/src/utils/version.ts b/neode-ui/src/utils/version.ts new file mode 100644 index 00000000..7a8bfb15 --- /dev/null +++ b/neode-ui/src/utils/version.ts @@ -0,0 +1,22 @@ +/** + * Format a version string for display with exactly one leading "v". + * + * Version strings reach the UI from several sources — manifests (bare like + * "1.7.96"), node/federation state and the update RPC (sometimes already + * "v1.7.96"). Templates used to hard-code a `v` prefix (`v{{ version }}`), + * which produced "vv1.7.96" whenever the source already carried a "v". This + * normalizes both shapes to a single "v". + */ +export function displayVersion(v?: string | null): string { + if (v === null || v === undefined) return '' + const bare = String(v).trim().replace(/^v+/i, '') + return bare ? `v${bare}` : '' +} + +// Exposed globally as `$ver` (see main.ts) so templates can normalize version +// labels without a per-file import. +declare module 'vue' { + interface ComponentCustomProperties { + $ver: (v?: string | null) => string + } +} diff --git a/neode-ui/src/views/AppDetails.vue b/neode-ui/src/views/AppDetails.vue index 61491aa2..e97fa6cf 100644 --- a/neode-ui/src/views/AppDetails.vue +++ b/neode-ui/src/views/AppDetails.vue @@ -1,25 +1,6 @@