feat: capability layer for clipboard, share and deep links with tests

This commit is contained in:
2026-08-20 08:31:59 +02:00
parent 12851a2b1c
commit f525b06966
11 changed files with 446 additions and 33 deletions
+172
View File
@@ -0,0 +1,172 @@
// Native biometric unlock: a THIRD wrap of the same linking key, alongside
// the password wrap (keys.ts) and the passkey slots (passkeys.ts).
//
// Why native: Android WebView has no usable WebAuthn platform authenticator
// for this app (passkeys need Play Services / Digital Asset Links wiring we
// cannot rely on), so the biometric path on native is a device-credential
// prompt instead of a passkey ceremony.
//
// Design (mirrors the passkey-slot construction, reusing its wrap
// primitives verbatim): on enrollment a random 32-byte wrap secret is
// generated, the linking key is AES-GCM-wrapped under an HKDF of that
// secret (wrapLinkingKeyWithPrf - the "PRF output" parameter is just 32
// bytes of IKM), and only the SECRET goes into biometric-gated secure
// storage (Android Keystore-backed AES-GCM via
// @aparajita/capacitor-secure-storage). The wrapped blob stays in
// localStorage as a record shaped like a passkey slot, plus the linking
// pubkey as an identity check: restoring a DIFFERENT seed leaves a stale
// wrap behind, and unlocking with it must fail loudly (never activate the
// old wallet silently), so unlock verifies the unwrapped key against the
// recorded pubkey and tells the holder to re-enroll.
//
// The biometric gate is app-level (a BiometricPrompt before the secure
// read), not a keystore key invalidated on biometric re-enrollment - 04
// deliberately prefers BIOMETRY_ANY/weak so adding a fingerprint doesn't
// wipe the holder's unlock. allowDeviceCredential keeps PIN/pattern as the
// system fallback.
//
// Web/PWA: every entry point reports unavailable and never touches the
// plugins' web shims (secure-storage's web impl is unencrypted localStorage
// - explicitly not for production secrets).
import {
AndroidBiometryStrength,
BiometricAuth,
BiometryError,
BiometryErrorType,
} from '@aparajita/capacitor-biometric-auth';
import { SecureStorage } from '@aparajita/capacitor-secure-storage';
import { bytesToHex, hexToBytes } from '@noble/hashes/utils.js';
import { linkingPubKeyHex } from '@/lnurlcash/keys';
import type { PasskeyWrap } from '@/lnurlcash/passkeys';
import { unwrapLinkingKeyWithPrf, wrapLinkingKeyWithPrf } from '@/lnurlcash/passkeys';
import { isNative } from './platform';
type BiometricWrapRecord = PasskeyWrap & {
pubkey: string; // linking pubkey the wrap belongs to - detects stale wraps
createdAt: number;
};
// the wrapped blob is useless without the secure-storage secret, so (like
// the passkey slots) this record sits in plain localStorage
const WRAP_RECORD_STORAGE_KEY = 'sattle_biometric_wrap';
const SECURE_SECRET_KEY = 'sattle-biometric-wrap-secret';
const isValidWrapRecord = (record: unknown): record is BiometricWrapRecord => {
if (typeof record !== 'object' || record === null) return false;
const r = record as Record<string, unknown>;
return (
typeof r.hkdfSalt === 'string' &&
/^[0-9a-f]{32}$/i.test(r.hkdfSalt) &&
typeof r.iv === 'string' &&
/^[0-9a-f]{24}$/i.test(r.iv) &&
typeof r.wrappedKey === 'string' &&
r.wrappedKey.length > 0 &&
r.wrappedKey.length % 2 === 0 &&
/^[0-9a-f]+$/i.test(r.wrappedKey) &&
typeof r.pubkey === 'string' &&
/^[0-9a-f]{66}$/i.test(r.pubkey) &&
typeof r.createdAt === 'number'
);
};
const readWrapRecord = (): BiometricWrapRecord | null => {
const raw = localStorage.getItem(WRAP_RECORD_STORAGE_KEY);
if (!raw) return null;
try {
const parsed: unknown = JSON.parse(raw);
return isValidWrapRecord(parsed) ? parsed : null;
} catch {
return null;
}
};
// sync on purpose (same convention as hasPasskeySlots): the unlock form and
// the security page ask this during render/setup
export const isBiometricUnlockEnrolled = (): boolean => readWrapRecord() !== null;
export const biometricUnlockAvailable = async (): Promise<boolean> => {
if (!isNative() || !isBiometricUnlockEnrolled()) return false;
try {
return (await BiometricAuth.checkBiometry()).isAvailable;
} catch {
return false;
}
};
// the shared prompt options: weak biometry + device credential fallback, so
// any screen lock the holder already uses qualifies (see header)
const authenticate = (reason: string): Promise<void> =>
BiometricAuth.authenticate({
reason,
cancelTitle: 'Cancel',
allowDeviceCredential: true,
androidTitle: 'sattle',
androidSubtitle: reason,
androidBiometryStrength: AndroidBiometryStrength.weak,
});
// a cancelled prompt must surface as an ordinary failure message, not a
// crash-shaped error - normalize to a plain Error with holder-facing text
const authenticateOrThrow = async (reason: string): Promise<void> => {
try {
await authenticate(reason);
} catch (err) {
if (err instanceof BiometryError && err.code === BiometryErrorType.userCancel) {
throw new Error('Biometric prompt was cancelled.', { cause: err });
}
throw err;
}
};
export const enableBiometricUnlock = async (linkingKey: Uint8Array): Promise<void> => {
if (!isNative()) throw new Error('Biometric unlock is only available in the native app.');
const biometry = await BiometricAuth.checkBiometry();
if (!biometry.isAvailable) {
throw new Error(biometry.reason || 'No biometric unlock is set up on this device.');
}
// prove presence before storing anything under the biometric gate
await authenticateOrThrow('Set up biometric unlock for your wallet');
const secret = crypto.getRandomValues(new Uint8Array(32));
const wrap = await wrapLinkingKeyWithPrf(secret, linkingKey);
// secure storage first: if it fails, no record is written and the wallet
// simply stays unenrolled instead of carrying an unwrap-able-nothing
await SecureStorage.set(SECURE_SECRET_KEY, bytesToHex(secret));
const record: BiometricWrapRecord = {
...wrap,
pubkey: linkingPubKeyHex(linkingKey),
createdAt: Date.now(),
};
localStorage.setItem(WRAP_RECORD_STORAGE_KEY, JSON.stringify(record));
};
export const unlockWithBiometrics = async (): Promise<Uint8Array> => {
const record = readWrapRecord();
if (!isNative() || !record) {
throw new Error('Biometric unlock is not set up on this device.');
}
await authenticateOrThrow('Unlock your sattle wallet');
const secretHex = await SecureStorage.get(SECURE_SECRET_KEY);
if (typeof secretHex !== 'string' || !/^[0-9a-f]{64}$/i.test(secretHex)) {
throw new Error('Biometric unlock data is missing - set it up again in Settings > Security.');
}
const linkingKey = await unwrapLinkingKeyWithPrf(hexToBytes(secretHex), record);
if (linkingPubKeyHex(linkingKey) !== record.pubkey) {
throw new Error(
'Biometric unlock belongs to a different wallet - set it up again in Settings > Security.',
);
}
return linkingKey;
};
// the localStorage record goes first and synchronously: even if the caller
// fire-and-forgets this (forgetWallet), a later unlock attempt can never
// reach the secure secret with a stale record
export const disableBiometricUnlock = async (): Promise<void> => {
localStorage.removeItem(WRAP_RECORD_STORAGE_KEY);
if (isNative()) {
await SecureStorage.remove(SECURE_SECRET_KEY);
}
};
+25
View File
@@ -0,0 +1,25 @@
// Clipboard capability: WebView clipboard reads are unreliable on Android
// (navigator.clipboard.readText is often denied outside of paste gestures),
// so native goes through @capacitor/clipboard. Web keeps today's behavior:
// quasar's copyToClipboard (with its execCommand fallback) for writes and
// navigator.clipboard for reads.
import { Clipboard } from '@capacitor/clipboard';
import { copyToClipboard } from 'quasar';
import { isNative } from './platform';
export const writeClipboard = async (text: string): Promise<void> => {
if (isNative()) {
await Clipboard.write({ string: text });
return;
}
await copyToClipboard(text);
};
export const readClipboard = async (): Promise<string> => {
if (isNative()) {
const { value } = await Clipboard.read();
return value;
}
return navigator.clipboard.readText();
};
+71
View File
@@ -0,0 +1,71 @@
// Deep-link parsing: the pure classification half of the capability.
// Fixtures mirror what actually arrives via Android intents and the PWA
// protocol handler - scheme-wrapped invoices, lnurlw bearer links, bech32.
import { describe, expect, it } from 'vitest';
import { toBech32Lnurl } from 'lnurlcash-kit';
import { parseExternalInput } from './deepLinks';
const K1 = 'ab'.repeat(32);
const NOTE_URL = `lnurlw://mint.example/withdraw?k1=${K1}`;
const INVOICE = 'lnbc210n1pqqqqqqqqqqqqqqqqqqqq';
describe('parseExternalInput', () => {
it('routes an lnurlw:// bearer link to receive', () => {
expect(parseExternalInput(NOTE_URL)).toEqual({ kind: 'note', value: expect.any(String) });
expect(parseExternalInput(NOTE_URL)?.value).toContain('k1=');
});
it('routes a bech32 lnurl carrying a k1 to receive', () => {
const bech32 = toBech32Lnurl(`https://mint.example/withdraw?k1=${K1}`);
expect(parseExternalInput(bech32)).toEqual({ kind: 'note', value: expect.any(String) });
});
it('strips the lightning: scheme from invoices and routes to pay', () => {
expect(parseExternalInput(`lightning:${INVOICE}`)).toEqual({ kind: 'pay', value: INVOICE });
});
it('handles the uppercase LIGHTNING: scheme', () => {
expect(parseExternalInput(`LIGHTNING:${INVOICE.toUpperCase()}`)).toEqual({
kind: 'pay',
value: INVOICE.toUpperCase(),
});
});
it('routes a bare bolt11 invoice to pay', () => {
expect(parseExternalInput(INVOICE)).toEqual({ kind: 'pay', value: INVOICE });
});
it('strips the PWA web+ prefix from protocol-handler launches', () => {
expect(parseExternalInput(`web+lightning:${INVOICE}`)).toEqual({
kind: 'pay',
value: INVOICE,
});
});
it('routes a Lightning Address to pay', () => {
expect(parseExternalInput('alice@example.com')).toEqual({
kind: 'pay',
value: 'alice@example.com',
});
});
it('routes an lnurlp:// link to pay', () => {
const link = 'lnurlp://pay.example/.well-known/lnurlp/alice';
expect(parseExternalInput(link)).toEqual({ kind: 'pay', value: link });
});
it('routes a lightning:-wrapped bearer note to receive, not pay', () => {
const result = parseExternalInput(`lightning:${NOTE_URL}`);
expect(result?.kind).toBe('note');
});
it('rejects empty and unrecognized input', () => {
expect(parseExternalInput('')).toBeNull();
expect(parseExternalInput(' ')).toBeNull();
expect(parseExternalInput('hello world')).toBeNull();
expect(parseExternalInput('https://example.com/page')).toBeNull();
// an lnurlw link WITHOUT a k1 is neither a note nor payable
expect(parseExternalInput('lnurlw://mint.example/withdraw')).toBeNull();
});
});
+86
View File
@@ -0,0 +1,86 @@
// Deep-link capability: inbound bearer-note / Lightning URLs from outside
// the app, classified and handed to the home screen, which routes them into
// the existing receive/pay dialogs via their initialInput props.
//
// Sources:
// - native: Android intent filters (lightning:, lnurlw:, lnurlp: - see
// android/app/src/main/AndroidManifest.xml) surface through
// @capacitor/app's appUrlOpen (warm) / getLaunchUrl (cold start).
// - web/PWA: the manifest's protocol_handlers (web+lightning) land the app
// on /?uri=<the link> - read once at boot from location.search.
//
// parseExternalInput is the pure, unit-tested half; everything Capacitor is
// confined to initDeepLinks. The pending value is consumed by IndexPage
// once the wallet is unlocked (the receive/pay flows need the keys).
import { ref } from 'vue';
import {
isBech32Lnurl,
isBolt11Invoice,
isLightningAddress,
isValidNoteInput,
} from 'lnurlcash-kit';
import { isNative } from './platform';
export type ExternalInput = { kind: 'note' | 'pay'; value: string };
// Classify an inbound link the way the Scan button classifies a scan: a
// bearer note goes to receive, anything payable (bolt11, bech32 LNURL,
// Lightning Address, lnurlp:) goes to pay. The lightning: URI scheme (and
// the PWA's web+lightning variant) is stripped; everything else is passed
// through verbatim for the dialogs' own validation.
export const parseExternalInput = (raw: string): ExternalInput | null => {
const trimmed = raw.trim();
if (!trimmed) return null;
const value = trimmed.replace(/^web\+/i, '');
// a bearer note (lnurlw:// or bech32 lnurl carrying a k1) is never a pay
// request - check both the raw and the lightning:-stripped form
if (isValidNoteInput(value)) return { kind: 'note', value };
const stripped = value.replace(/^lightning:/i, '').trim();
if (isValidNoteInput(stripped)) return { kind: 'note', value: stripped };
if (
isBolt11Invoice(stripped) ||
isBech32Lnurl(stripped) ||
isLightningAddress(stripped) ||
/^lnurlp:\/\//i.test(stripped)
) {
return { kind: 'pay', value: stripped };
}
return null;
};
// the home screen watches this and opens the matching dialog as soon as the
// wallet is unlocked; until then the value simply waits (a locked wallet
// must not swallow the link)
export const pendingExternalInput = ref<ExternalInput | null>(null);
export const consumePendingExternalInput = (): ExternalInput | null => {
const pending = pendingExternalInput.value;
pendingExternalInput.value = null;
return pending;
};
const receive = (rawUrl: string, navigateHome: () => void): void => {
const input = parseExternalInput(rawUrl);
if (!input) return;
pendingExternalInput.value = input;
navigateHome();
};
// Wires the platform sources into the pending value. notify is invoked
// after every accepted link so the caller can route to the home screen.
// Web is a one-shot read (protocol-handler launch); native also subscribes
// for the lifetime of the app.
export const initDeepLinks = async (navigateHome: () => void): Promise<void> => {
if (!isNative()) {
const uri =
typeof window === 'undefined' ? null : new URLSearchParams(window.location.search).get('uri');
if (uri) receive(uri, navigateHome);
return;
}
const { App } = await import('@capacitor/app');
const launch = await App.getLaunchUrl();
if (launch?.url) receive(launch.url, navigateHome);
await App.addListener('appUrlOpen', ({ url }) => receive(url, navigateHome));
};
+26
View File
@@ -0,0 +1,26 @@
// The capability layer: the ONLY place Capacitor plugins are imported. App
// code (components, pages, stores) consumes these modules; each one no-ops
// to today's web behavior in the PWA and calls the plugin on native.
//
// Surface, deliberately minimal - only what the app already does:
// - platform.ts isNative() detection
// - clipboard.ts writeClipboard / readClipboard (@capacitor/clipboard)
// - share.ts canShareText / shareText (@capacitor/share)
// - biometricUnlock.ts biometric-gated wrap of the linking key
// (@aparajita/capacitor-biometric-auth +
// @aparajita/capacitor-secure-storage, per 04)
// - deepLinks.ts lightning:/lnurlw:/lnurlp: inbound URLs (@capacitor/app)
//
// Two documented non-abstractions:
// - QR scan: QrScanner.vue's getUserMedia camera approach works identically
// in the Capacitor WebView (CAMERA permission is declared in the Android
// manifest), so there is no native divergence to hide - no scan plugin.
// - Storage: the wallet's localStorage backend stays as-is this milestone.
// The linking key at rest is AES-GCM under the holder's password exactly
// as on web; the ONLY secret moved into native secure storage is the
// biometric wrap secret (see biometricUnlock.ts). A full storage backend
// migration is a separate, fund-critical change.
import { Capacitor } from '@capacitor/core';
export const isNative = (): boolean => Capacitor.isNativePlatform();
+25
View File
@@ -0,0 +1,25 @@
// Share capability: navigator.share doesn't exist in the Android WebView,
// so native goes through @capacitor/share and the share affordance is
// available there too. Cancellation is normalized to the web's AbortError
// DOMException so callers can treat "user dismissed the sheet" uniformly.
import { Share } from '@capacitor/share';
import { isNative } from './platform';
export const canShareText = (): boolean =>
isNative() || (typeof navigator !== 'undefined' && typeof navigator.share === 'function');
export const shareText = async (title: string, text: string): Promise<void> => {
if (isNative()) {
try {
await Share.share({ title, text, dialogTitle: title });
} catch (err) {
if (err instanceof Error && err.message.toLowerCase().includes('cancel')) {
throw new DOMException(err.message, 'AbortError');
}
throw err;
}
return;
}
await navigator.share({ title, text });
};
@@ -130,8 +130,8 @@
<div v-else class="column items-center q-gutter-sm">
<div class="text-caption text-grey-5 text-center">
Not watching right now if the invoice gets paid, the sats are still
claimed into your wallet automatically.
Not watching right now if the invoice gets paid, the sats are still claimed into your
wallet automatically.
</div>
<q-btn
outline
@@ -157,8 +157,8 @@
<template #avatar>
<q-icon name="warning" color="warning" />
</template>
The note is in your wallet, but it could not be fully secured yet the
sender may still hold a copy. You can secure it later.
The note is in your wallet, but it could not be fully secured yet the sender may still
hold a copy. You can secure it later.
</q-banner>
<q-btn
@@ -182,8 +182,8 @@
<template v-if="trustNodeAlias"> ({{ trustNodeAlias }})</template>
</div>
<div class="text-caption text-grey-5 q-mb-md">
Trusting saves the mint so it is offered next time. You can manage trusted
mints in Settings.
Trusting saves the mint so it is offered next time. You can manage trusted mints in
Settings.
</div>
<div class="row q-gutter-sm justify-end">
<q-btn flat no-caps color="grey-5" label="Just this once" @click="skipTrust" />
@@ -203,9 +203,10 @@
<script setup lang="ts">
import { computed, ref, watch } from 'vue';
import { Notify, copyToClipboard } from 'quasar';
import { Notify } from 'quasar';
import QrCode from '../QrCode.vue';
import { writeClipboard } from '@/capabilities/clipboard';
import { prepareMint, claimMintedNote } from '@/lnurlcash/ops';
import type { ClaimedNote, PreparedMint } from '@/lnurlcash/ops';
import type { NewBearer } from '@/lnurlcash/types';
@@ -277,7 +278,9 @@ const defaultChoice = (): string => {
const formValid = computed(() => {
if (!Number.isInteger(amountSats.value) || (amountSats.value ?? 0) < 1) return false;
return mintChoice.value === CUSTOM_MINT ? customMint.value.trim() !== '' : mintChoice.value !== '';
return mintChoice.value === CUSTOM_MINT
? customMint.value.trim() !== ''
: mintChoice.value !== '';
});
const createInvoice = async () => {
@@ -326,7 +329,7 @@ const feeSats = computed(() => grossSats.value - netSats.value);
const copyInvoice = async () => {
if (!prepared.value) return;
try {
await copyToClipboard(prepared.value.invoice);
await writeClipboard(prepared.value.invoice);
Notify.create({ type: 'positive', message: 'Invoice copied.' });
} catch (err) {
Notify.create({ type: 'negative', message: errorMessage(err) });
@@ -380,7 +383,10 @@ const onClaimed = async (claimed: ClaimedNote, from: PreparedMint) => {
receivedSats.value = displaySats(claimed.note.amount);
receivedServer.value = server;
rotationWarning.value = claimed.rotationError ?? '';
activity.log('mint', `Received ${receivedSats.value.toLocaleString()} sats from ${server} over Lightning.`);
activity.log(
'mint',
`Received ${receivedSats.value.toLocaleString()} sats from ${server} over Lightning.`,
);
const nodeInfo = mintAddressCacheInfo(from.nodeInfo, from.username);
if (nodeInfo) mints.cacheNodeInfo(server, nodeInfo);
Notify.create({
@@ -420,7 +426,8 @@ const skipTrust = () => {
showTrust.value = false;
Notify.create({
type: 'warning',
message: 'Note added, but this mint is not in your trusted list yet — you can review it in Settings.',
message:
'Note added, but this mint is not in your trusted list yet — you can review it in Settings.',
});
};
+4 -10
View File
@@ -12,6 +12,7 @@ import { useQuasar } from 'quasar';
import { decodeBolt11AmountMsat, isBolt11Invoice, resolveLnurlInput } from 'lnurlcash-kit';
import QrScanner from '@/components/QrScanner.vue';
import { readClipboard } from '@/capabilities/clipboard';
import { payWithBearers, UncertainOutcomeError } from '@/lnurlcash/ops';
import type { CarveResult, PayOutcome } from '@/lnurlcash/ops';
import type { NewBearer } from '@/lnurlcash/types';
@@ -121,7 +122,7 @@ const onScanError = (message: string) => {
const paste = async () => {
try {
const text = await navigator.clipboard.readText();
const text = await readClipboard();
if (text) input.value = text.trim();
} catch {
toast('negative', "Couldn't read the clipboard - paste manually.");
@@ -349,18 +350,11 @@ const closeResult = () => {
</q-item>
</q-list>
<div class="text-caption text-grey-5 q-mt-md">
If the mint charges a fee, it comes out of your change - you pay exactly the amount
shown.
If the mint charges a fee, it comes out of your change - you pay exactly the amount shown.
</div>
<div class="row justify-end q-gutter-sm q-mt-lg">
<q-btn flat label="Back" color="grey-5" @click="step = 'input'" />
<q-btn
unelevated
color="primary"
text-color="dark"
label="Pay now"
@click="pay"
/>
<q-btn unelevated color="primary" text-color="dark" label="Pay now" @click="pay" />
</div>
</q-card-section>
+13 -8
View File
@@ -12,6 +12,8 @@ import { useQuasar } from 'quasar';
import { toBech32Lnurl } from 'lnurlcash-kit';
import QrCode from '@/components/QrCode.vue';
import { writeClipboard } from '@/capabilities/clipboard';
import { canShareText, shareText } from '@/capabilities/share';
import { ensureExactAmount, UncertainOutcomeError } from '@/lnurlcash/ops';
import type { CarveResult } from '@/lnurlcash/ops';
import type { Bearer, NewBearer } from '@/lnurlcash/types';
@@ -71,12 +73,9 @@ const canPrepare = computed(
() => parsedAmount.value !== null && amountError.value === null && !preparing.value,
);
const noteDisplayValue = computed(() =>
prepared.value ? toBech32Lnurl(prepared.value.url) : '',
);
const noteDisplayValue = computed(() => (prepared.value ? toBech32Lnurl(prepared.value.url) : ''));
const canShare =
typeof navigator !== 'undefined' && typeof navigator.share === 'function';
const canShare = canShareText();
const reset = () => {
step.value = 'amount';
@@ -153,7 +152,7 @@ const prepare = async () => {
const copyNote = async () => {
try {
await navigator.clipboard.writeText(noteDisplayValue.value);
await writeClipboard(noteDisplayValue.value);
toast('positive', 'Note copied to clipboard.');
} catch {
toast('negative', "Couldn't copy - reveal the note and copy it manually.");
@@ -162,7 +161,7 @@ const copyNote = async () => {
const shareNote = async () => {
try {
await navigator.share({ title: 'sattle bearer note', text: noteDisplayValue.value });
await shareText('sattle bearer note', noteDisplayValue.value);
} catch (err) {
// the user dismissing the share sheet is not an error
if (err instanceof DOMException && err.name === 'AbortError') return;
@@ -302,7 +301,13 @@ const finishKeep = () => {
:loading="removing"
@click="finishRemove"
/>
<q-btn flat color="grey-5" label="Keep in wallet" :disable="removing" @click="finishKeep" />
<q-btn
flat
color="grey-5"
label="Keep in wallet"
:disable="removing"
@click="finishKeep"
/>
</div>
</q-card-section>
</q-card>
+3 -2
View File
@@ -148,8 +148,9 @@
<script setup lang="ts">
import { computed, ref } from 'vue';
import { useRouter } from 'vue-router';
import { copyToClipboard, useQuasar } from 'quasar';
import { useQuasar } from 'quasar';
import { writeClipboard } from '@/capabilities/clipboard';
import { buildBackup } from '@/lnurlcash/storage';
import { useWalletStore } from '@/stores/wallet';
import { useNostrBackupStore } from '@/stores/nostrBackup';
@@ -191,7 +192,7 @@ const relayModel = computed({
const copyPubkey = () => {
if (!nostr.pubkey) return;
void copyToClipboard(nostr.pubkey).then(() => toast('positive', 'Backup address copied.'));
void writeClipboard(nostr.pubkey).then(() => toast('positive', 'Backup address copied.'));
};
const backupBusy = ref(false);
+3 -2
View File
@@ -224,8 +224,9 @@
<script setup lang="ts">
import { ref } from 'vue';
import { useRouter } from 'vue-router';
import { copyToClipboard, useQuasar } from 'quasar';
import { useQuasar } from 'quasar';
import { writeClipboard } from '@/capabilities/clipboard';
import type { NwcBudget, NwcConnectionRecord } from '@/lnurlcash/nwc';
import { msatToSats } from '@/lnurlcash/units';
import { useWalletStore } from '@/stores/wallet';
@@ -282,7 +283,7 @@ const createNewConnection = (): void => {
};
const copyConnectionString = (): void => {
void copyToClipboard(createdString.value).then(() =>
void writeClipboard(createdString.value).then(() =>
toast('positive', 'Connection string copied.'),
);
};