fix(wallet): gate lightning on node STATE, gate send too, add a shared CopyButton
Three defects from testing the previous commit on archi-dev-box: 1. The gate keyed on `id in packages`, which is not "installed and usable" — package-data carries an entry for a Lightning app that is known but not running. On a box with no lnd container at all the gate passed and the raw error came through as "Operation failed. Check server logs for details." Now keyed on PackageState.Running. 2. Because installed-but-stopped is a real and different situation, the modal has two modes: absent offers the install choices, stopped says the node isn't running and offers "Open My Apps". Neither dead-ends in an error. 3. Lightning SEND let you walk all the way to confirm-send with no node. The gate now runs in review(), before the confirm step — failing at submit after a review screen is the defect, not a smaller version of it. Also adds CopyButton, the start of one consistent copy affordance: icon + label, an emerald tick held 1.6s, a fixed box so the width never jumps, and a document.execCommand fallback so copy still works over plain http on a LAN IP (navigator.clipboard rejects on insecure origins, which is how a lot of nodes are reached). Converted the wallet's own copies — the lightning invoice the user reported, plus the on-chain/Ark addresses and the payment hash/txid. 20 of 25 copy sites across 15 other files still use ad-hoc markup; converting them is mechanical but was not attempted here rather than half-done. Verified: 5 gate tests; npm run build clean. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 5
parent
90ce4bcc46
commit
fa26c5fc56
@@ -23,24 +23,39 @@ describe('useLightningRequired', () => {
|
||||
useLightningRequired().close()
|
||||
})
|
||||
|
||||
it('lets the action through when a Lightning node is installed', () => {
|
||||
packages.value = { lnd: {}, 'bitcoin-knots': {} }
|
||||
it('lets the action through when a Lightning node is running', () => {
|
||||
packages.value = { lnd: { state: 'running' }, 'bitcoin-knots': { state: 'running' } }
|
||||
const lightning = useLightningRequired()
|
||||
|
||||
expect(lightning.hasLightningNode()).toBe(true)
|
||||
expect(lightning.lightningStatus()).toBe('running')
|
||||
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: {} }
|
||||
it('blocks when the node is present but NOT running, and says so', () => {
|
||||
// The bug this closes: `id in packages` is not "usable". A node with an
|
||||
// lnd entry in a non-running state produced a raw connection-refused
|
||||
// error ("Operation failed. Check server logs for details.").
|
||||
packages.value = { lnd: { state: 'stopped' } }
|
||||
const lightning = useLightningRequired()
|
||||
|
||||
expect(lightning.lightningStatus()).toBe('stopped')
|
||||
expect(lightning.requireLightningNode()).toBe(false)
|
||||
expect(lightning.show.value).toBe(true)
|
||||
expect(lightning.status.value).toBe('stopped')
|
||||
})
|
||||
|
||||
it('blocks and raises the install modal when no Lightning node is installed', () => {
|
||||
packages.value = { 'bitcoin-knots': { state: 'running' }, immich: { state: 'running' } }
|
||||
const lightning = useLightningRequired()
|
||||
|
||||
expect(lightning.lightningStatus()).toBe('absent')
|
||||
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)
|
||||
expect(lightning.status.value).toBe('absent')
|
||||
})
|
||||
|
||||
it('shares one modal state across call sites', () => {
|
||||
@@ -54,8 +69,8 @@ describe('useLightningRequired', () => {
|
||||
expect(a.show.value).toBe(false)
|
||||
})
|
||||
|
||||
it('treats an empty package list as no Lightning node', () => {
|
||||
it('treats an empty package list as absent', () => {
|
||||
packages.value = {}
|
||||
expect(useLightningRequired().hasLightningNode()).toBe(false)
|
||||
expect(useLightningRequired().lightningStatus()).toBe('absent')
|
||||
})
|
||||
})
|
||||
|
||||
@@ -1,51 +1,70 @@
|
||||
// Shared "this action needs a Lightning node" gate (2026-08-02).
|
||||
// Shared "this action needs a working 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.
|
||||
// Creating a Lightning invoice used to fail at the RPC layer whenever this
|
||||
// node had no usable Lightning implementation — `lnd.createinvoice` returned
|
||||
// connection-refused and the Receive screen rendered "Operation failed. Check
|
||||
// server logs for details." That reads as the wallet being broken, when the
|
||||
// truth is a missing (or stopped) prerequisite the user can act on.
|
||||
//
|
||||
// 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.
|
||||
// Callers ask `requireLightningNode()` BEFORE attempting the call. When there
|
||||
// is no usable node it opens the global LightningRequiredModal 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".
|
||||
// Keyed on package STATE, not mere presence: `package-data` carries an entry
|
||||
// for a Lightning app that is known to this node but not actually running, so
|
||||
// `id in packages` is NOT "installed and usable" — that assumption was the
|
||||
// first version's bug, and it let the raw RPC error through on a node with no
|
||||
// lnd container at all.
|
||||
import { ref } from 'vue'
|
||||
import { useAppStore } from '@/stores/app'
|
||||
import { PackageState } from '@/types/api'
|
||||
|
||||
/** 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. */
|
||||
* `available` flag in LightningRequiredModal; nothing else changes. */
|
||||
export const LIGHTNING_NODE_APP_IDS = ['lnd'] as const
|
||||
|
||||
/** `absent` — nothing installed, offer to install one.
|
||||
* `stopped` — installed but not running, point the user at My Apps.
|
||||
* `running` — good to go. */
|
||||
export type LightningStatus = 'absent' | 'stopped' | 'running'
|
||||
|
||||
// Module-scope: one source of truth shared by every caller and the single
|
||||
// global modal mounted in App.vue.
|
||||
const show = ref(false)
|
||||
const status = ref<LightningStatus>('absent')
|
||||
|
||||
export function useLightningRequired() {
|
||||
const appStore = useAppStore()
|
||||
|
||||
/** True when some Lightning implementation is installed on this node. */
|
||||
/** Best status across every known Lightning implementation. */
|
||||
function lightningStatus(): LightningStatus {
|
||||
const pkgs = (appStore.packages ?? {}) as Record<string, { state?: string } | undefined>
|
||||
let best: LightningStatus = 'absent'
|
||||
for (const id of LIGHTNING_NODE_APP_IDS) {
|
||||
const entry = pkgs[id]
|
||||
if (!entry) continue
|
||||
if (entry.state === PackageState.Running) return 'running'
|
||||
// Present but not running: installing, starting, stopped, exited…
|
||||
best = 'stopped'
|
||||
}
|
||||
return best
|
||||
}
|
||||
|
||||
function hasLightningNode(): boolean {
|
||||
const installed = Object.keys(appStore.packages ?? {})
|
||||
return installed.some((pkgId) =>
|
||||
(LIGHTNING_NODE_APP_IDS as readonly string[]).includes(pkgId),
|
||||
)
|
||||
return lightningStatus() === 'running'
|
||||
}
|
||||
|
||||
/**
|
||||
* Gate a Lightning-only action. Returns true to proceed; returns false and
|
||||
* opens the install modal when this node has no Lightning implementation.
|
||||
* opens the modal (in the mode matching why) when there is no usable node.
|
||||
*/
|
||||
function requireLightningNode(): boolean {
|
||||
if (hasLightningNode()) return true
|
||||
const s = lightningStatus()
|
||||
if (s === 'running') return true
|
||||
status.value = s
|
||||
show.value = true
|
||||
return false
|
||||
}
|
||||
@@ -54,5 +73,5 @@ export function useLightningRequired() {
|
||||
show.value = false
|
||||
}
|
||||
|
||||
return { show, hasLightningNode, requireLightningNode, close }
|
||||
return { show, status, lightningStatus, hasLightningNode, requireLightningNode, close }
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user