mirror of
https://github.com/tompro/sattle.git
synced 2026-08-27 07:15:59 +00:00
feat: capability layer for clipboard, share and deep links with tests
This commit is contained in:
@@ -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);
|
||||
}
|
||||
};
|
||||
@@ -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();
|
||||
};
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -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));
|
||||
};
|
||||
@@ -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();
|
||||
@@ -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.',
|
||||
});
|
||||
};
|
||||
|
||||
|
||||
@@ -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>
|
||||
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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.'),
|
||||
);
|
||||
};
|
||||
|
||||
Reference in New Issue
Block a user