diff --git a/neode-ui/src/App.vue b/neode-ui/src/App.vue index b70d65af..4ce503d0 100644 --- a/neode-ui/src/App.vue +++ b/neode-ui/src/App.vue @@ -44,6 +44,11 @@ + + + @@ -98,6 +103,7 @@ import GlobalAudioPlayer from './components/GlobalAudioPlayer.vue' import MeshDeviceSetupModal from './components/mesh/MeshDeviceSetupModal.vue' import ExternalExplorerModal from './components/ExternalExplorerModal.vue' import LndSeedBackupPrompt from './components/LndSeedBackupPrompt.vue' +import LightningRequiredModal from './components/LightningRequiredModal.vue' import { useMeshStore } from './stores/mesh' import { useControllerNav } from '@/composables/useControllerNav' diff --git a/neode-ui/src/components/AppLauncherOverlay.vue b/neode-ui/src/components/AppLauncherOverlay.vue index 50f51ae0..4bae8a02 100644 --- a/neode-ui/src/components/AppLauncherOverlay.vue +++ b/neode-ui/src/components/AppLauncherOverlay.vue @@ -196,6 +196,9 @@ import NostrIdentityPicker from '@/components/NostrIdentityPicker.vue' import AppLoadingScreen from '@/components/AppLoadingScreen.vue' import { DEFAULT_APP_ICON } from '@/views/apps/appsConfig' import { rpcClient } from '@/api/rpc-client' +import { useLightningRequired } from '@/composables/useLightningRequired' + +const lightning = useLightningRequired() interface PaymentRequest { request_id: string @@ -524,6 +527,9 @@ async function approvePayment() { }) receipt = { method: 'ecash', token: res.token, amount_sats: res.amount_sats } } else if (method === 'lightning') { + // Both arms below need a Lightning node — paying an invoice and minting + // one. With none installed, raise the install modal instead of failing. + if (!lightning.requireLightningNode()) return if (pay.invoice) { // Tracked to a real terminal state — slow routing is not a failure. const res = await rpcClient.payLightningInvoice({ payment_request: pay.invoice }) diff --git a/neode-ui/src/components/LightningRequiredModal.vue b/neode-ui/src/components/LightningRequiredModal.vue new file mode 100644 index 00000000..805c1df7 --- /dev/null +++ b/neode-ui/src/components/LightningRequiredModal.vue @@ -0,0 +1,128 @@ + + + diff --git a/neode-ui/src/components/ReceiveBitcoinModal.vue b/neode-ui/src/components/ReceiveBitcoinModal.vue index 56abd8e0..7138f554 100644 --- a/neode-ui/src/components/ReceiveBitcoinModal.vue +++ b/neode-ui/src/components/ReceiveBitcoinModal.vue @@ -91,8 +91,10 @@ import { useI18n } from 'vue-i18n' import { rpcClient } from '@/api/rpc-client' import BaseModal from '@/components/BaseModal.vue' import { explainReceiveAddressFailure } from '@/utils/bitcoinReceive' +import { useLightningRequired } from '@/composables/useLightningRequired' const { t } = useI18n() +const lightning = useLightningRequired() const props = defineProps<{ show: boolean @@ -154,6 +156,10 @@ async function receive() { error.value = '' try { if (receiveMethod.value === 'lightning') { + // No Lightning implementation installed is not an error — it is a + // missing prerequisite. Raise the install modal instead of letting + // lnd.createinvoice fail with connection-refused (FED-08 follow-up). + if (!lightning.requireLightningNode()) return if (!invoiceAmount.value) { error.value = t('receiveBitcoin.enterAnAmount'); return } const res = await rpcClient.call<{ payment_request: string }>({ method: 'lnd.createinvoice', diff --git a/neode-ui/src/composables/__tests__/useLightningRequired.test.ts b/neode-ui/src/composables/__tests__/useLightningRequired.test.ts new file mode 100644 index 00000000..9d933c78 --- /dev/null +++ b/neode-ui/src/composables/__tests__/useLightningRequired.test.ts @@ -0,0 +1,61 @@ +import { describe, it, expect, beforeEach, vi } from 'vitest' +import { createPinia, setActivePinia } from 'pinia' +import { useLightningRequired } from '../useLightningRequired' + +// The gate reads install state off the app store's package list. Stub the +// store rather than the RPC layer so the test pins the decision, not the +// transport. +const packages = vi.hoisted(() => ({ value: {} as Record })) +vi.mock('@/stores/app', () => ({ + useAppStore: () => ({ + get packages() { + return packages.value + }, + }), +})) + +describe('useLightningRequired', () => { + beforeEach(() => { + setActivePinia(createPinia()) + packages.value = {} + // Module-scope `show` is shared by design (one global modal), so reset it + // between cases or the first opener leaks into the next test. + useLightningRequired().close() + }) + + it('lets the action through when a Lightning node is installed', () => { + packages.value = { lnd: {}, 'bitcoin-knots': {} } + const lightning = useLightningRequired() + + expect(lightning.hasLightningNode()).toBe(true) + expect(lightning.requireLightningNode()).toBe(true) + expect(lightning.show.value).toBe(false) + }) + + it('blocks and raises the install modal when no Lightning node is installed', () => { + packages.value = { 'bitcoin-knots': {}, immich: {} } + const lightning = useLightningRequired() + + expect(lightning.hasLightningNode()).toBe(false) + // Returns false so the caller bails WITHOUT surfacing an error string — + // that was the whole defect: a missing prerequisite rendered as a failure. + expect(lightning.requireLightningNode()).toBe(false) + expect(lightning.show.value).toBe(true) + }) + + it('shares one modal state across call sites', () => { + packages.value = {} + const a = useLightningRequired() + const b = useLightningRequired() + + a.requireLightningNode() + expect(b.show.value).toBe(true) + b.close() + expect(a.show.value).toBe(false) + }) + + it('treats an empty package list as no Lightning node', () => { + packages.value = {} + expect(useLightningRequired().hasLightningNode()).toBe(false) + }) +}) diff --git a/neode-ui/src/composables/useLightningRequired.ts b/neode-ui/src/composables/useLightningRequired.ts new file mode 100644 index 00000000..d266475b --- /dev/null +++ b/neode-ui/src/composables/useLightningRequired.ts @@ -0,0 +1,58 @@ +// Shared "this action needs a Lightning node" gate (2026-08-02). +// +// Creating a Lightning invoice with no Lightning node installed used to fail +// at the RPC layer — `lnd.createinvoice` returns a connection-refused error +// and the Receive screen showed it as a red failure string. That reads as the +// wallet being broken, when in fact the node simply has no Lightning +// implementation installed yet. +// +// Callers ask `requireLightningNode()` BEFORE attempting the call. When no +// node is installed it opens the global LightningRequiredModal (which offers +// to install one) and returns false, so the caller bails without surfacing an +// error at all. +// +// Detection is install-state, not reachability, on purpose: an installed node +// that is merely stopped or still starting is a different situation (wait or +// start it) and must NOT be answered with "install a Lightning node". +import { ref } from 'vue' +import { useAppStore } from '@/stores/app' + +/** Package ids that provide a Lightning node. + * + * `lnd` ships today. Core Lightning is the next implementation the modal + * offers — when its app id lands in the catalog, add it here and flip its + * `available` flag in LightningRequiredModal so the same gate recognises it + * with no other change. */ +export const LIGHTNING_NODE_APP_IDS = ['lnd'] as const + +// Module-scope: one source of truth shared by every caller and the single +// global modal mounted in App.vue. +const show = ref(false) + +export function useLightningRequired() { + const appStore = useAppStore() + + /** True when some Lightning implementation is installed on this node. */ + function hasLightningNode(): boolean { + const installed = Object.keys(appStore.packages ?? {}) + return installed.some((pkgId) => + (LIGHTNING_NODE_APP_IDS as readonly string[]).includes(pkgId), + ) + } + + /** + * Gate a Lightning-only action. Returns true to proceed; returns false and + * opens the install modal when this node has no Lightning implementation. + */ + function requireLightningNode(): boolean { + if (hasLightningNode()) return true + show.value = true + return false + } + + function close() { + show.value = false + } + + return { show, hasLightningNode, requireLightningNode, close } +} diff --git a/neode-ui/src/views/Apps.vue b/neode-ui/src/views/Apps.vue index dd4a2838..6b4b7ca2 100644 --- a/neode-ui/src/views/Apps.vue +++ b/neode-ui/src/views/Apps.vue @@ -262,42 +262,36 @@ @confirm="onConfirmUninstall" /> - - -
-
-
-
-

{{ credentialModal.title }}

-

{{ credentialModal.description }}

-
- -
-
-
-
- {{ cred.label }} - -
-

{{ cred.value }}

-
-
-
- - + + +

+ {{ credentialModal.description }} +

+
+
+
+ {{ cred.label }} +
+

{{ cred.value }}

- - + +
@@ -388,6 +382,7 @@ import { useServerStore } from '@/stores/server' import { useAppLauncherStore } from '@/stores/appLauncher' import { rpcClient } from '@/api/rpc-client' import { type AppCredential, type AppCredentialsResponse, type PackageDataEntry, type PackageState } from '@/types/api' +import BaseModal from '@/components/BaseModal.vue' import AppCard from './apps/AppCard.vue' import AppIconGrid from './apps/AppIconGrid.vue' import AppsUninstallModal from './apps/AppsUninstallModal.vue' @@ -910,34 +905,4 @@ async function submitSideload() { } .sideload-input::placeholder { color: rgba(255, 255, 255, 0.38); } .sideload-input:focus { border-color: rgba(255, 255, 255, 0.38); } -.credential-modal-panel { - display: flex; - flex-direction: column; - width: 100%; - max-width: 34rem; - /* Centered card that never exceeds the visible viewport (minus safe areas), - matching the wallet receive modal / AppIconGrid credential modal. The body - scrolls if content overflows rather than the panel stretching edge-to-edge. */ - max-height: calc( - 100dvh - var(--safe-area-top, env(safe-area-inset-top, 0px)) - - var(--safe-area-bottom, env(safe-area-inset-bottom, 0px)) - 2rem - ); - min-height: 0; - overflow: hidden; - border: 1px solid rgba(255, 255, 255, 0.14); - border-radius: 1.5rem; - background: rgba(8, 10, 18, 0.98); - padding: 1.25rem; - padding-bottom: calc(1.25rem + var(--safe-area-bottom, env(safe-area-inset-bottom, 0px))); - box-shadow: 0 24px 70px rgba(0, 0, 0, 0.55); -} -.credential-modal-body { - flex: 1 1 auto; - min-height: 0; - overflow-y: auto; - -webkit-overflow-scrolling: touch; -} -.credential-modal-actions { - flex-shrink: 0; -} diff --git a/neode-ui/src/views/apps/AppIconGrid.vue b/neode-ui/src/views/apps/AppIconGrid.vue index 96d4353c..d7baafaf 100644 --- a/neode-ui/src/views/apps/AppIconGrid.vue +++ b/neode-ui/src/views/apps/AppIconGrid.vue @@ -91,43 +91,41 @@ >
- - -
-
-
-
-

{{ credentialModal.title }}

-

{{ credentialModal.description }}

-
- -
-
-
-
- {{ cred.label }} - -
-

{{ cred.value }}

-
-
-
- - + + +

+ {{ credentialModal.description }} +

+
+
+
+ {{ cred.label }} +
+

{{ cred.value }}

- - + +