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 v-else class="column items-center q-gutter-sm">
|
||||||
<div class="text-caption text-grey-5 text-center">
|
<div class="text-caption text-grey-5 text-center">
|
||||||
Not watching right now — if the invoice gets paid, the sats are still
|
Not watching right now — if the invoice gets paid, the sats are still claimed into your
|
||||||
claimed into your wallet automatically.
|
wallet automatically.
|
||||||
</div>
|
</div>
|
||||||
<q-btn
|
<q-btn
|
||||||
outline
|
outline
|
||||||
@@ -157,8 +157,8 @@
|
|||||||
<template #avatar>
|
<template #avatar>
|
||||||
<q-icon name="warning" color="warning" />
|
<q-icon name="warning" color="warning" />
|
||||||
</template>
|
</template>
|
||||||
The note is in your wallet, but it could not be fully secured yet — the
|
The note is in your wallet, but it could not be fully secured yet — the sender may still
|
||||||
sender may still hold a copy. You can secure it later.
|
hold a copy. You can secure it later.
|
||||||
</q-banner>
|
</q-banner>
|
||||||
|
|
||||||
<q-btn
|
<q-btn
|
||||||
@@ -182,8 +182,8 @@
|
|||||||
<template v-if="trustNodeAlias"> ({{ trustNodeAlias }})</template>
|
<template v-if="trustNodeAlias"> ({{ trustNodeAlias }})</template>
|
||||||
</div>
|
</div>
|
||||||
<div class="text-caption text-grey-5 q-mb-md">
|
<div class="text-caption text-grey-5 q-mb-md">
|
||||||
Trusting saves the mint so it is offered next time. You can manage trusted
|
Trusting saves the mint so it is offered next time. You can manage trusted mints in
|
||||||
mints in Settings.
|
Settings.
|
||||||
</div>
|
</div>
|
||||||
<div class="row q-gutter-sm justify-end">
|
<div class="row q-gutter-sm justify-end">
|
||||||
<q-btn flat no-caps color="grey-5" label="Just this once" @click="skipTrust" />
|
<q-btn flat no-caps color="grey-5" label="Just this once" @click="skipTrust" />
|
||||||
@@ -203,9 +203,10 @@
|
|||||||
|
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { computed, ref, watch } from 'vue';
|
import { computed, ref, watch } from 'vue';
|
||||||
import { Notify, copyToClipboard } from 'quasar';
|
import { Notify } from 'quasar';
|
||||||
|
|
||||||
import QrCode from '../QrCode.vue';
|
import QrCode from '../QrCode.vue';
|
||||||
|
import { writeClipboard } from '@/capabilities/clipboard';
|
||||||
import { prepareMint, claimMintedNote } from '@/lnurlcash/ops';
|
import { prepareMint, claimMintedNote } from '@/lnurlcash/ops';
|
||||||
import type { ClaimedNote, PreparedMint } from '@/lnurlcash/ops';
|
import type { ClaimedNote, PreparedMint } from '@/lnurlcash/ops';
|
||||||
import type { NewBearer } from '@/lnurlcash/types';
|
import type { NewBearer } from '@/lnurlcash/types';
|
||||||
@@ -277,7 +278,9 @@ const defaultChoice = (): string => {
|
|||||||
|
|
||||||
const formValid = computed(() => {
|
const formValid = computed(() => {
|
||||||
if (!Number.isInteger(amountSats.value) || (amountSats.value ?? 0) < 1) return false;
|
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 () => {
|
const createInvoice = async () => {
|
||||||
@@ -326,7 +329,7 @@ const feeSats = computed(() => grossSats.value - netSats.value);
|
|||||||
const copyInvoice = async () => {
|
const copyInvoice = async () => {
|
||||||
if (!prepared.value) return;
|
if (!prepared.value) return;
|
||||||
try {
|
try {
|
||||||
await copyToClipboard(prepared.value.invoice);
|
await writeClipboard(prepared.value.invoice);
|
||||||
Notify.create({ type: 'positive', message: 'Invoice copied.' });
|
Notify.create({ type: 'positive', message: 'Invoice copied.' });
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
Notify.create({ type: 'negative', message: errorMessage(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);
|
receivedSats.value = displaySats(claimed.note.amount);
|
||||||
receivedServer.value = server;
|
receivedServer.value = server;
|
||||||
rotationWarning.value = claimed.rotationError ?? '';
|
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);
|
const nodeInfo = mintAddressCacheInfo(from.nodeInfo, from.username);
|
||||||
if (nodeInfo) mints.cacheNodeInfo(server, nodeInfo);
|
if (nodeInfo) mints.cacheNodeInfo(server, nodeInfo);
|
||||||
Notify.create({
|
Notify.create({
|
||||||
@@ -420,7 +426,8 @@ const skipTrust = () => {
|
|||||||
showTrust.value = false;
|
showTrust.value = false;
|
||||||
Notify.create({
|
Notify.create({
|
||||||
type: 'warning',
|
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 { decodeBolt11AmountMsat, isBolt11Invoice, resolveLnurlInput } from 'lnurlcash-kit';
|
||||||
|
|
||||||
import QrScanner from '@/components/QrScanner.vue';
|
import QrScanner from '@/components/QrScanner.vue';
|
||||||
|
import { readClipboard } from '@/capabilities/clipboard';
|
||||||
import { payWithBearers, UncertainOutcomeError } from '@/lnurlcash/ops';
|
import { payWithBearers, UncertainOutcomeError } from '@/lnurlcash/ops';
|
||||||
import type { CarveResult, PayOutcome } from '@/lnurlcash/ops';
|
import type { CarveResult, PayOutcome } from '@/lnurlcash/ops';
|
||||||
import type { NewBearer } from '@/lnurlcash/types';
|
import type { NewBearer } from '@/lnurlcash/types';
|
||||||
@@ -121,7 +122,7 @@ const onScanError = (message: string) => {
|
|||||||
|
|
||||||
const paste = async () => {
|
const paste = async () => {
|
||||||
try {
|
try {
|
||||||
const text = await navigator.clipboard.readText();
|
const text = await readClipboard();
|
||||||
if (text) input.value = text.trim();
|
if (text) input.value = text.trim();
|
||||||
} catch {
|
} catch {
|
||||||
toast('negative', "Couldn't read the clipboard - paste manually.");
|
toast('negative', "Couldn't read the clipboard - paste manually.");
|
||||||
@@ -349,18 +350,11 @@ const closeResult = () => {
|
|||||||
</q-item>
|
</q-item>
|
||||||
</q-list>
|
</q-list>
|
||||||
<div class="text-caption text-grey-5 q-mt-md">
|
<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
|
If the mint charges a fee, it comes out of your change - you pay exactly the amount shown.
|
||||||
shown.
|
|
||||||
</div>
|
</div>
|
||||||
<div class="row justify-end q-gutter-sm q-mt-lg">
|
<div class="row justify-end q-gutter-sm q-mt-lg">
|
||||||
<q-btn flat label="Back" color="grey-5" @click="step = 'input'" />
|
<q-btn flat label="Back" color="grey-5" @click="step = 'input'" />
|
||||||
<q-btn
|
<q-btn unelevated color="primary" text-color="dark" label="Pay now" @click="pay" />
|
||||||
unelevated
|
|
||||||
color="primary"
|
|
||||||
text-color="dark"
|
|
||||||
label="Pay now"
|
|
||||||
@click="pay"
|
|
||||||
/>
|
|
||||||
</div>
|
</div>
|
||||||
</q-card-section>
|
</q-card-section>
|
||||||
|
|
||||||
|
|||||||
@@ -12,6 +12,8 @@ import { useQuasar } from 'quasar';
|
|||||||
import { toBech32Lnurl } from 'lnurlcash-kit';
|
import { toBech32Lnurl } from 'lnurlcash-kit';
|
||||||
|
|
||||||
import QrCode from '@/components/QrCode.vue';
|
import QrCode from '@/components/QrCode.vue';
|
||||||
|
import { writeClipboard } from '@/capabilities/clipboard';
|
||||||
|
import { canShareText, shareText } from '@/capabilities/share';
|
||||||
import { ensureExactAmount, UncertainOutcomeError } from '@/lnurlcash/ops';
|
import { ensureExactAmount, UncertainOutcomeError } from '@/lnurlcash/ops';
|
||||||
import type { CarveResult } from '@/lnurlcash/ops';
|
import type { CarveResult } from '@/lnurlcash/ops';
|
||||||
import type { Bearer, NewBearer } from '@/lnurlcash/types';
|
import type { Bearer, NewBearer } from '@/lnurlcash/types';
|
||||||
@@ -71,12 +73,9 @@ const canPrepare = computed(
|
|||||||
() => parsedAmount.value !== null && amountError.value === null && !preparing.value,
|
() => parsedAmount.value !== null && amountError.value === null && !preparing.value,
|
||||||
);
|
);
|
||||||
|
|
||||||
const noteDisplayValue = computed(() =>
|
const noteDisplayValue = computed(() => (prepared.value ? toBech32Lnurl(prepared.value.url) : ''));
|
||||||
prepared.value ? toBech32Lnurl(prepared.value.url) : '',
|
|
||||||
);
|
|
||||||
|
|
||||||
const canShare =
|
const canShare = canShareText();
|
||||||
typeof navigator !== 'undefined' && typeof navigator.share === 'function';
|
|
||||||
|
|
||||||
const reset = () => {
|
const reset = () => {
|
||||||
step.value = 'amount';
|
step.value = 'amount';
|
||||||
@@ -153,7 +152,7 @@ const prepare = async () => {
|
|||||||
|
|
||||||
const copyNote = async () => {
|
const copyNote = async () => {
|
||||||
try {
|
try {
|
||||||
await navigator.clipboard.writeText(noteDisplayValue.value);
|
await writeClipboard(noteDisplayValue.value);
|
||||||
toast('positive', 'Note copied to clipboard.');
|
toast('positive', 'Note copied to clipboard.');
|
||||||
} catch {
|
} catch {
|
||||||
toast('negative', "Couldn't copy - reveal the note and copy it manually.");
|
toast('negative', "Couldn't copy - reveal the note and copy it manually.");
|
||||||
@@ -162,7 +161,7 @@ const copyNote = async () => {
|
|||||||
|
|
||||||
const shareNote = async () => {
|
const shareNote = async () => {
|
||||||
try {
|
try {
|
||||||
await navigator.share({ title: 'sattle bearer note', text: noteDisplayValue.value });
|
await shareText('sattle bearer note', noteDisplayValue.value);
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
// the user dismissing the share sheet is not an error
|
// the user dismissing the share sheet is not an error
|
||||||
if (err instanceof DOMException && err.name === 'AbortError') return;
|
if (err instanceof DOMException && err.name === 'AbortError') return;
|
||||||
@@ -302,7 +301,13 @@ const finishKeep = () => {
|
|||||||
:loading="removing"
|
:loading="removing"
|
||||||
@click="finishRemove"
|
@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>
|
</div>
|
||||||
</q-card-section>
|
</q-card-section>
|
||||||
</q-card>
|
</q-card>
|
||||||
|
|||||||
@@ -148,8 +148,9 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { computed, ref } from 'vue';
|
import { computed, ref } from 'vue';
|
||||||
import { useRouter } from 'vue-router';
|
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 { buildBackup } from '@/lnurlcash/storage';
|
||||||
import { useWalletStore } from '@/stores/wallet';
|
import { useWalletStore } from '@/stores/wallet';
|
||||||
import { useNostrBackupStore } from '@/stores/nostrBackup';
|
import { useNostrBackupStore } from '@/stores/nostrBackup';
|
||||||
@@ -191,7 +192,7 @@ const relayModel = computed({
|
|||||||
|
|
||||||
const copyPubkey = () => {
|
const copyPubkey = () => {
|
||||||
if (!nostr.pubkey) return;
|
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);
|
const backupBusy = ref(false);
|
||||||
|
|||||||
@@ -224,8 +224,9 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { ref } from 'vue';
|
import { ref } from 'vue';
|
||||||
import { useRouter } from 'vue-router';
|
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 type { NwcBudget, NwcConnectionRecord } from '@/lnurlcash/nwc';
|
||||||
import { msatToSats } from '@/lnurlcash/units';
|
import { msatToSats } from '@/lnurlcash/units';
|
||||||
import { useWalletStore } from '@/stores/wallet';
|
import { useWalletStore } from '@/stores/wallet';
|
||||||
@@ -282,7 +283,7 @@ const createNewConnection = (): void => {
|
|||||||
};
|
};
|
||||||
|
|
||||||
const copyConnectionString = (): void => {
|
const copyConnectionString = (): void => {
|
||||||
void copyToClipboard(createdString.value).then(() =>
|
void writeClipboard(createdString.value).then(() =>
|
||||||
toast('positive', 'Connection string copied.'),
|
toast('positive', 'Connection string copied.'),
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|||||||
Reference in New Issue
Block a user