feat: import Chinese-localized Buzz source snapshot
Docker image / Build (linux/amd64) (push) Has been cancelled
Docker image / Build (linux/arm64) (push) Has been cancelled
Docker image / Merge release multi-arch manifest (push) Has been cancelled
Docker image / Merge debug multi-arch manifest (push) Has been cancelled
Docker image / Build public push gateway (linux/amd64) (push) Has been cancelled
Docker image / Build public push gateway (linux/arm64) (push) Has been cancelled
Docker image / Publish public push gateway image (push) Has been cancelled
Sprig image / Build (linux/amd64) (push) Has been cancelled
Sprig image / Build (linux/arm64) (push) Has been cancelled
Sprig image / Merge multi-arch manifest (push) Has been cancelled
Harbor Buzz Orchestra / Python tests and lint (push) Has been cancelled
CI / Detect Changed Paths (push) Has been cancelled
CI / Rust Lint (push) Has been cancelled
CI / Unit Tests (push) Has been cancelled
CI / Desktop Core (push) Has been cancelled
CI / Desktop Smoke E2E (1) (push) Has been cancelled
CI / Desktop Smoke E2E (2) (push) Has been cancelled
CI / Desktop Smoke E2E (3) (push) Has been cancelled
CI / Desktop Smoke E2E (4) (push) Has been cancelled
CI / Desktop (push) Has been cancelled
CI / Desktop E2E Relay (push) Has been cancelled
CI / Desktop E2E Integration (1/2) (push) Has been cancelled
CI / Desktop E2E Integration (2/2) (push) Has been cancelled
CI / Desktop E2E Integration (push) Has been cancelled
CI / Backend Integration (relay e2e) (push) Has been cancelled
CI / Relay E2E (push) Has been cancelled
CI / Web (push) Has been cancelled
CI / Mobile (push) Has been cancelled
CI / Security (push) Has been cancelled
CI / Dead Token Reference Guard (push) Has been cancelled
CI / Server Cross-Compile (aarch64-unknown-linux-musl) (push) Has been cancelled
CI / Server Cross-Compile (x86_64-unknown-linux-musl) (push) Has been cancelled
CI / Windows Rust (x86_64-pc-windows-msvc) (push) Has been cancelled
CI / Desktop Build (macOS) (push) Has been cancelled
helm chart / lint + unittest + render matrix (push) Has been cancelled
helm chart / install on kind (gated) (push) Has been cancelled
helm chart / publish chart to GHCR (push) Has been cancelled
Mesh Lifecycle / Relay-Driven Mesh Lifecycle Smoke (push) Has been cancelled
Sprig / Build (aarch64-unknown-linux-musl) (push) Has been cancelled
Sprig / Build (x86_64-unknown-linux-musl) (push) Has been cancelled
Sprig / Publish rolling release (push) Has been cancelled
Sprig / Publish tagged release (push) Has been cancelled
Docker image / Build (linux/amd64) (push) Has been cancelled
Docker image / Build (linux/arm64) (push) Has been cancelled
Docker image / Merge release multi-arch manifest (push) Has been cancelled
Docker image / Merge debug multi-arch manifest (push) Has been cancelled
Docker image / Build public push gateway (linux/amd64) (push) Has been cancelled
Docker image / Build public push gateway (linux/arm64) (push) Has been cancelled
Docker image / Publish public push gateway image (push) Has been cancelled
Sprig image / Build (linux/amd64) (push) Has been cancelled
Sprig image / Build (linux/arm64) (push) Has been cancelled
Sprig image / Merge multi-arch manifest (push) Has been cancelled
Harbor Buzz Orchestra / Python tests and lint (push) Has been cancelled
CI / Detect Changed Paths (push) Has been cancelled
CI / Rust Lint (push) Has been cancelled
CI / Unit Tests (push) Has been cancelled
CI / Desktop Core (push) Has been cancelled
CI / Desktop Smoke E2E (1) (push) Has been cancelled
CI / Desktop Smoke E2E (2) (push) Has been cancelled
CI / Desktop Smoke E2E (3) (push) Has been cancelled
CI / Desktop Smoke E2E (4) (push) Has been cancelled
CI / Desktop (push) Has been cancelled
CI / Desktop E2E Relay (push) Has been cancelled
CI / Desktop E2E Integration (1/2) (push) Has been cancelled
CI / Desktop E2E Integration (2/2) (push) Has been cancelled
CI / Desktop E2E Integration (push) Has been cancelled
CI / Backend Integration (relay e2e) (push) Has been cancelled
CI / Relay E2E (push) Has been cancelled
CI / Web (push) Has been cancelled
CI / Mobile (push) Has been cancelled
CI / Security (push) Has been cancelled
CI / Dead Token Reference Guard (push) Has been cancelled
CI / Server Cross-Compile (aarch64-unknown-linux-musl) (push) Has been cancelled
CI / Server Cross-Compile (x86_64-unknown-linux-musl) (push) Has been cancelled
CI / Windows Rust (x86_64-pc-windows-msvc) (push) Has been cancelled
CI / Desktop Build (macOS) (push) Has been cancelled
helm chart / lint + unittest + render matrix (push) Has been cancelled
helm chart / install on kind (gated) (push) Has been cancelled
helm chart / publish chart to GHCR (push) Has been cancelled
Mesh Lifecycle / Relay-Driven Mesh Lifecycle Smoke (push) Has been cancelled
Sprig / Build (aarch64-unknown-linux-musl) (push) Has been cancelled
Sprig / Build (x86_64-unknown-linux-musl) (push) Has been cancelled
Sprig / Publish rolling release (push) Has been cancelled
Sprig / Publish tagged release (push) Has been cancelled
Signed-off-by: cls_宁波本机 <908705107@qq.com>
This commit is contained in:
@@ -0,0 +1,164 @@
|
||||
import 'dart:typed_data';
|
||||
|
||||
import '../../shared/crypto/ecdh.dart';
|
||||
import '../../shared/crypto/hkdf.dart';
|
||||
|
||||
/// Derive session ID from session secret.
|
||||
/// session_id = HKDF-SHA256(IKM=session_secret, salt="", info="nostr-pair-session-id", L=32)
|
||||
Uint8List deriveSessionId(Uint8List sessionSecret) {
|
||||
return hkdf(
|
||||
sessionSecret,
|
||||
Uint8List(0), // empty salt
|
||||
Uint8List.fromList('nostr-pair-session-id'.codeUnits),
|
||||
32,
|
||||
);
|
||||
}
|
||||
|
||||
/// Derive SAS code and SAS input from ECDH shared secret and session secret.
|
||||
/// sas_input = HKDF-SHA256(IKM=ecdh_shared, salt=session_secret, info="nostr-pair-sas-v1", L=32)
|
||||
/// sas_code = be_u32(sas_input[0..4]) % 1_000_000
|
||||
(int, Uint8List) deriveSas(Uint8List ecdhShared, Uint8List sessionSecret) {
|
||||
final sasInput = hkdf(
|
||||
ecdhShared,
|
||||
sessionSecret,
|
||||
Uint8List.fromList('nostr-pair-sas-v1'.codeUnits),
|
||||
32,
|
||||
);
|
||||
|
||||
// be_u32 from first 4 bytes.
|
||||
final code =
|
||||
((sasInput[0] << 24) |
|
||||
(sasInput[1] << 16) |
|
||||
(sasInput[2] << 8) |
|
||||
sasInput[3]) %
|
||||
1000000;
|
||||
|
||||
return (code, sasInput);
|
||||
}
|
||||
|
||||
/// Format a SAS code as a 6-digit zero-padded string.
|
||||
String formatSas(int code) => code.toString().padLeft(6, '0');
|
||||
|
||||
/// Derive transcript hash binding all session parameters.
|
||||
/// transcript = session_id || source_pubkey || target_pubkey || sas_input (128 bytes)
|
||||
/// transcript_hash = HKDF-SHA256(IKM=transcript, salt=session_secret, info="nostr-pair-transcript-v1", L=32)
|
||||
Uint8List deriveTranscriptHash(
|
||||
Uint8List sessionId,
|
||||
Uint8List sourcePubkey,
|
||||
Uint8List targetPubkey,
|
||||
Uint8List sasInput,
|
||||
Uint8List sessionSecret,
|
||||
) {
|
||||
final transcript = Uint8List(128);
|
||||
transcript.setRange(0, 32, sessionId);
|
||||
transcript.setRange(32, 64, sourcePubkey);
|
||||
transcript.setRange(64, 96, targetPubkey);
|
||||
transcript.setRange(96, 128, sasInput);
|
||||
|
||||
return hkdf(
|
||||
transcript,
|
||||
sessionSecret,
|
||||
Uint8List.fromList('nostr-pair-transcript-v1'.codeUnits),
|
||||
32,
|
||||
);
|
||||
}
|
||||
|
||||
/// Parse a `nostrpair://` QR URI.
|
||||
({
|
||||
String sourcePubkey,
|
||||
Uint8List sessionSecret,
|
||||
List<String> relays,
|
||||
int version,
|
||||
})
|
||||
parseNostrpairUri(String uri) {
|
||||
if (uri.length > 2048) {
|
||||
throw FormatException('URI exceeds 2048-character limit');
|
||||
}
|
||||
|
||||
if (!uri.startsWith('nostrpair://')) {
|
||||
throw const FormatException('URI must start with nostrpair://');
|
||||
}
|
||||
|
||||
final rest = uri.substring('nostrpair://'.length);
|
||||
final qIdx = rest.indexOf('?');
|
||||
if (qIdx < 0) {
|
||||
throw const FormatException('Missing query string');
|
||||
}
|
||||
|
||||
final pubkeyHex = rest.substring(0, qIdx);
|
||||
final query = rest.substring(qIdx + 1);
|
||||
|
||||
// Validate pubkey: 64 lowercase hex chars.
|
||||
if (pubkeyHex.length != 64 || !_isLowercaseHex(pubkeyHex)) {
|
||||
throw const FormatException('Pubkey must be 64 lowercase hex chars');
|
||||
}
|
||||
|
||||
// Parse query params.
|
||||
String? secretHex;
|
||||
final relays = <String>[];
|
||||
int? version;
|
||||
|
||||
for (final pair in query.split('&')) {
|
||||
final eqIdx = pair.indexOf('=');
|
||||
if (eqIdx < 0) continue;
|
||||
final key = pair.substring(0, eqIdx);
|
||||
final value = pair.substring(eqIdx + 1);
|
||||
switch (key) {
|
||||
case 'secret':
|
||||
secretHex = value;
|
||||
case 'relay':
|
||||
relays.add(Uri.decodeComponent(value));
|
||||
case 'v':
|
||||
version = int.tryParse(value);
|
||||
}
|
||||
}
|
||||
|
||||
// Default version to 1.
|
||||
version ??= 1;
|
||||
if (version != 1) {
|
||||
throw FormatException(
|
||||
'Unsupported protocol version $version. Please update the app.',
|
||||
);
|
||||
}
|
||||
|
||||
// Validate secret.
|
||||
if (secretHex == null ||
|
||||
secretHex.length != 64 ||
|
||||
!_isLowercaseHex(secretHex)) {
|
||||
throw const FormatException('Secret must be 64 lowercase hex chars');
|
||||
}
|
||||
final sessionSecret = hexToBytes(secretHex);
|
||||
|
||||
// Reject all-zeros.
|
||||
if (sessionSecret.every((b) => b == 0)) {
|
||||
throw const FormatException('Session secret must not be all zeros');
|
||||
}
|
||||
|
||||
if (relays.isEmpty) {
|
||||
throw const FormatException('At least one relay URL is required');
|
||||
}
|
||||
|
||||
// Validate relay URLs.
|
||||
for (final relay in relays) {
|
||||
final parsed = Uri.tryParse(relay);
|
||||
if (parsed == null || (parsed.scheme != 'wss' && parsed.scheme != 'ws')) {
|
||||
throw FormatException('Invalid relay URL: $relay');
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
sourcePubkey: pubkeyHex,
|
||||
sessionSecret: sessionSecret,
|
||||
relays: relays,
|
||||
version: version,
|
||||
);
|
||||
}
|
||||
|
||||
bool _isLowercaseHex(String s) {
|
||||
for (final c in s.codeUnits) {
|
||||
if (!((c >= 0x30 && c <= 0x39) || (c >= 0x61 && c <= 0x66))) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
return true;
|
||||
}
|
||||
@@ -0,0 +1,380 @@
|
||||
import 'dart:async';
|
||||
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter/services.dart';
|
||||
import 'package:flutter_hooks/flutter_hooks.dart';
|
||||
import 'package:hooks_riverpod/hooks_riverpod.dart';
|
||||
import 'package:lucide_icons_flutter/lucide_icons.dart';
|
||||
|
||||
import '../../shared/theme/theme.dart';
|
||||
import '../../shared/l10n/l10n.dart';
|
||||
import '../../shared/widgets/buzz_loading_indicator.dart';
|
||||
import '../../shared/widgets/tappable_flapping_bee.dart';
|
||||
import 'pairing_provider.dart';
|
||||
import 'pairing_qr_scanner.dart';
|
||||
|
||||
part 'pairing_page/onboarding_background.dart';
|
||||
part 'pairing_page/pairing_welcome_view.dart';
|
||||
|
||||
const _onboardingChartreuse = Color(0xFFD7D72E);
|
||||
const _onboardingShellBottom = Color(0xFFD7E7F6);
|
||||
const _onboardingCtaLabel = Color(0xFFD7E6F0);
|
||||
const _onboardingInk = Color(0xFF111111);
|
||||
const _onboardingMutedInk = Color(0xB3111111);
|
||||
|
||||
class PairingPage extends HookConsumerWidget {
|
||||
/// When true, the pairing page is being used to add a new community
|
||||
/// (user is already authenticated with at least one community).
|
||||
final bool addingCommunity;
|
||||
final bool identityRecoveryOnly;
|
||||
|
||||
const PairingPage({
|
||||
super.key,
|
||||
this.addingCommunity = false,
|
||||
this.identityRecoveryOnly = false,
|
||||
});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context, WidgetRef ref) {
|
||||
final pairingState = ref.watch(pairingProvider);
|
||||
final codeController = useTextEditingController();
|
||||
final fallbackScannerVisible = useState(false);
|
||||
final pairingCodeExpanded = useState(false);
|
||||
final isBusy =
|
||||
pairingState.status == PairingStatus.connecting ||
|
||||
pairingState.status == PairingStatus.transferring ||
|
||||
pairingState.status == PairingStatus.storing;
|
||||
|
||||
// When adding a community and pairing succeeds, pop back.
|
||||
if (addingCommunity && pairingState.status == PairingStatus.success) {
|
||||
WidgetsBinding.instance.addPostFrameCallback((_) {
|
||||
if (context.mounted) {
|
||||
ref.read(pairingProvider.notifier).reset();
|
||||
Navigator.of(context).pop();
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
Future<void> handleScannerResult(String? code) async {
|
||||
if (code != null && context.mounted) {
|
||||
if (identityRecoveryOnly &&
|
||||
Uri.tryParse(code)?.queryParameters['mode'] != 'recover') {
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
SnackBar(content: Text(context.l10n.pairingScanDesktopCode)),
|
||||
);
|
||||
return;
|
||||
}
|
||||
await ref.read(pairingProvider.notifier).pair(code);
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> openScanner() async {
|
||||
final usesDynamicIslandPortal = await usesDynamicIslandQrScannerPortal();
|
||||
if (!context.mounted) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (!usesDynamicIslandPortal) {
|
||||
fallbackScannerVisible.value = true;
|
||||
return;
|
||||
}
|
||||
|
||||
final code = await showDynamicIslandPairingQrScanner(context);
|
||||
await handleScannerResult(code);
|
||||
}
|
||||
|
||||
final isVerifyingSas = pairingState.status == PairingStatus.confirmingSas;
|
||||
final themedSystemOverlayStyle =
|
||||
(context.theme.brightness == Brightness.dark
|
||||
? SystemUiOverlayStyle.light
|
||||
: SystemUiOverlayStyle.dark)
|
||||
.copyWith(statusBarColor: Colors.transparent);
|
||||
final pairingAppBar = addingCommunity
|
||||
? AppBar(
|
||||
foregroundColor: isVerifyingSas
|
||||
? context.colors.onSurface
|
||||
: _onboardingInk,
|
||||
systemOverlayStyle: isVerifyingSas
|
||||
? themedSystemOverlayStyle
|
||||
: SystemUiOverlayStyle.dark.copyWith(
|
||||
statusBarColor: Colors.transparent,
|
||||
),
|
||||
leading: IconButton(
|
||||
icon: const Icon(LucideIcons.arrowLeft),
|
||||
onPressed: () => Navigator.of(context).pop(),
|
||||
),
|
||||
title: Text(
|
||||
identityRecoveryOnly
|
||||
? context.l10n.pairingSendToDesktop
|
||||
: context.l10n.pairingAddCommunity,
|
||||
style: isVerifyingSas
|
||||
? null
|
||||
: context.textTheme.titleMedium?.copyWith(
|
||||
color: _onboardingInk,
|
||||
),
|
||||
),
|
||||
)
|
||||
: null;
|
||||
|
||||
final pairingScaffold = isVerifyingSas
|
||||
? AnnotatedRegion<SystemUiOverlayStyle>(
|
||||
key: const Key('pairing-sas-system-overlay'),
|
||||
value: themedSystemOverlayStyle,
|
||||
child: Scaffold(
|
||||
backgroundColor: context.colors.surface,
|
||||
appBar: pairingAppBar,
|
||||
body: SafeArea(
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.symmetric(horizontal: Grid.sm),
|
||||
child: _SasVerificationView(
|
||||
sasCode: pairingState.sasCode ?? '------',
|
||||
confirmed: pairingState.userConfirmedSas,
|
||||
sendsIdentityToDesktop: pairingState.sendsIdentityToDesktop,
|
||||
onConfirm: () =>
|
||||
ref.read(pairingProvider.notifier).confirmSas(),
|
||||
onDeny: () => ref.read(pairingProvider.notifier).denySas(),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
)
|
||||
: AnnotatedRegion<SystemUiOverlayStyle>(
|
||||
key: const Key('pairing-onboarding-system-overlay'),
|
||||
value: SystemUiOverlayStyle.dark.copyWith(
|
||||
statusBarColor: Colors.transparent,
|
||||
),
|
||||
child: _OnboardingBackground(
|
||||
child: Scaffold(
|
||||
backgroundColor: Colors.transparent,
|
||||
appBar: pairingAppBar,
|
||||
body: SafeArea(
|
||||
child: _PairingWelcomeView(
|
||||
codeController: codeController,
|
||||
isBusy: isBusy,
|
||||
pairingCodeExpanded: pairingCodeExpanded.value,
|
||||
errorMessage: pairingState.status == PairingStatus.error
|
||||
? _localizedPairingError(
|
||||
context,
|
||||
pairingState.errorMessage,
|
||||
)
|
||||
: null,
|
||||
onScan: openScanner,
|
||||
onTogglePairingCode: () {
|
||||
pairingCodeExpanded.value = !pairingCodeExpanded.value;
|
||||
},
|
||||
onConnect: () {
|
||||
final code = codeController.text.trim();
|
||||
if (code.isNotEmpty) {
|
||||
unawaited(handleScannerResult(code));
|
||||
}
|
||||
},
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
|
||||
final appSurface = PopScope(
|
||||
onPopInvokedWithResult: (didPop, _) {
|
||||
if (didPop) {
|
||||
ref.read(pairingProvider.notifier).reset();
|
||||
}
|
||||
},
|
||||
child: pairingScaffold,
|
||||
);
|
||||
|
||||
if (!fallbackScannerVisible.value) {
|
||||
return appSurface;
|
||||
}
|
||||
|
||||
return FallbackPairingQrScanner(
|
||||
appSurface: appSurface,
|
||||
onClosed: (code) {
|
||||
fallbackScannerVisible.value = false;
|
||||
unawaited(handleScannerResult(code));
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
String? _localizedPairingError(BuildContext context, String? error) {
|
||||
if (error == null || Localizations.localeOf(context).languageCode == 'en') {
|
||||
return error;
|
||||
}
|
||||
if (error.startsWith('SAS code mismatch')) {
|
||||
return context.l10n.pairingSasMismatch;
|
||||
}
|
||||
if (error.startsWith('Pairing session timed out')) {
|
||||
return context.l10n.pairingSessionTimedOut;
|
||||
}
|
||||
if (error.startsWith('Invalid pairing code')) {
|
||||
return context.l10n.pairingInvalidCode;
|
||||
}
|
||||
if (error.startsWith('Could not reach the pairing relay')) {
|
||||
return context.l10n.pairingRelayUnreachable;
|
||||
}
|
||||
if (error.startsWith('The pairing relay rejected authentication')) {
|
||||
return context.l10n.pairingAuthenticationRejected;
|
||||
}
|
||||
if (error.startsWith('Pairing stopped because of an internal error')) {
|
||||
return context.l10n.pairingInternalError;
|
||||
}
|
||||
if (error.startsWith('Secure connection failed')) {
|
||||
return context.l10n.pairingSecureConnectionFailed;
|
||||
}
|
||||
if (error.startsWith('Connection timed out')) {
|
||||
return context.l10n.pairingConnectionTimedOut;
|
||||
}
|
||||
if (error.startsWith('Security verification failed')) {
|
||||
return context.l10n.pairingSecurityVerificationFailed;
|
||||
}
|
||||
if (error.startsWith('No identity is available')) {
|
||||
return context.l10n.pairingNoIdentity;
|
||||
}
|
||||
if (error.startsWith('Received empty payload')) {
|
||||
return context.l10n.pairingEmptyPayload;
|
||||
}
|
||||
if (error.startsWith('Desktop could not store')) {
|
||||
return context.l10n.pairingDesktopStoreFailed;
|
||||
}
|
||||
if (error.startsWith('Source device aborted pairing')) {
|
||||
return context.l10n.pairingSourceAborted;
|
||||
}
|
||||
if (error.startsWith('Failed to import credentials')) {
|
||||
return context.l10n.pairingImportFailed;
|
||||
}
|
||||
if (error.startsWith('Lost connection to pairing relay')) {
|
||||
return context.l10n.pairingLostConnection;
|
||||
}
|
||||
if (error.startsWith('Could not connect to relay')) {
|
||||
return context.l10n.pairingRelayRejectedCode;
|
||||
}
|
||||
if (error.startsWith('Connection failed')) {
|
||||
return context.l10n.pairingConnectionFailed;
|
||||
}
|
||||
return context.l10n.pairingConnectionFailed;
|
||||
}
|
||||
|
||||
/// SAS verification screen shown during NIP-AB pairing.
|
||||
class _SasVerificationView extends StatelessWidget {
|
||||
final String sasCode;
|
||||
final bool confirmed;
|
||||
final bool sendsIdentityToDesktop;
|
||||
final VoidCallback onConfirm;
|
||||
final VoidCallback onDeny;
|
||||
|
||||
const _SasVerificationView({
|
||||
required this.sasCode,
|
||||
required this.confirmed,
|
||||
required this.sendsIdentityToDesktop,
|
||||
required this.onConfirm,
|
||||
required this.onDeny,
|
||||
});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Column(
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
children: [
|
||||
const Spacer(flex: 2),
|
||||
|
||||
Icon(LucideIcons.shieldCheck, size: 56, color: context.colors.primary),
|
||||
const SizedBox(height: Grid.sm),
|
||||
|
||||
Text(context.l10n.sasTitle, style: context.textTheme.headlineSmall),
|
||||
const SizedBox(height: Grid.xs),
|
||||
|
||||
Text(
|
||||
confirmed
|
||||
? context.l10n.sasWaitingForDesktop
|
||||
: context.l10n.sasDesktopPrompt,
|
||||
textAlign: TextAlign.center,
|
||||
style: context.textTheme.bodyMedium?.copyWith(
|
||||
color: context.colors.onSurfaceVariant,
|
||||
),
|
||||
),
|
||||
|
||||
const SizedBox(height: Grid.lg),
|
||||
|
||||
// Large SAS code display
|
||||
Container(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 32, vertical: 20),
|
||||
decoration: BoxDecoration(
|
||||
color: context.colors.primaryContainer.withValues(alpha: 0.3),
|
||||
borderRadius: BorderRadius.circular(16),
|
||||
border: Border.all(
|
||||
color: context.colors.primary.withValues(alpha: 0.3),
|
||||
width: 2,
|
||||
),
|
||||
),
|
||||
child: Text(
|
||||
'${sasCode.substring(0, 3)} ${sasCode.substring(3)}',
|
||||
style: context.textTheme.displayMedium?.copyWith(
|
||||
fontFamily: 'GeistMono',
|
||||
fontWeight: FontWeight.w700,
|
||||
letterSpacing: 8,
|
||||
color: context.colors.primary,
|
||||
),
|
||||
),
|
||||
),
|
||||
|
||||
const SizedBox(height: Grid.lg),
|
||||
|
||||
Text(
|
||||
sendsIdentityToDesktop
|
||||
? context.l10n.sasTransferFull
|
||||
: context.l10n.sasTransferToDevice,
|
||||
textAlign: TextAlign.center,
|
||||
style: context.textTheme.bodySmall?.copyWith(
|
||||
color: context.colors.onSurfaceVariant,
|
||||
),
|
||||
),
|
||||
|
||||
const SizedBox(height: Grid.lg),
|
||||
|
||||
// Confirm / Deny buttons
|
||||
if (confirmed)
|
||||
Row(
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
children: [
|
||||
BuzzLoadingIndicator(
|
||||
size: 24,
|
||||
color: context.colors.primary,
|
||||
semanticLabel: context.l10n.connecting,
|
||||
),
|
||||
const SizedBox(width: Grid.twelve),
|
||||
Text(
|
||||
context.l10n.sasConfirmedWaiting,
|
||||
style: context.textTheme.bodySmall?.copyWith(
|
||||
color: context.colors.onSurfaceVariant,
|
||||
),
|
||||
),
|
||||
],
|
||||
)
|
||||
else
|
||||
Row(
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
children: [
|
||||
Expanded(
|
||||
child: OutlinedButton.icon(
|
||||
onPressed: onDeny,
|
||||
icon: const Icon(LucideIcons.x),
|
||||
label: Text(context.l10n.cancel),
|
||||
),
|
||||
),
|
||||
const SizedBox(width: Grid.sm),
|
||||
Expanded(
|
||||
child: FilledButton.icon(
|
||||
onPressed: onConfirm,
|
||||
icon: const Icon(LucideIcons.check),
|
||||
label: Text(context.l10n.codesMatch),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
|
||||
const Spacer(flex: 3),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,41 @@
|
||||
part of '../pairing_page.dart';
|
||||
|
||||
class _OnboardingBackground extends StatelessWidget {
|
||||
final Widget child;
|
||||
|
||||
const _OnboardingBackground({required this.child});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return DecoratedBox(
|
||||
key: const Key('pairing-onboarding-background'),
|
||||
decoration: const BoxDecoration(
|
||||
gradient: LinearGradient(
|
||||
begin: Alignment.topCenter,
|
||||
end: Alignment.bottomCenter,
|
||||
colors: [_onboardingChartreuse, _onboardingShellBottom],
|
||||
),
|
||||
),
|
||||
child: CustomPaint(painter: const _DotGridPainter(), child: child),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _DotGridPainter extends CustomPainter {
|
||||
const _DotGridPainter();
|
||||
|
||||
@override
|
||||
void paint(Canvas canvas, Size size) {
|
||||
final dotPaint = Paint()..color = _onboardingInk.withValues(alpha: 0.08);
|
||||
const spacing = 24.0;
|
||||
|
||||
for (var x = 0.0; x <= size.width; x += spacing) {
|
||||
for (var y = 0.0; y <= size.height; y += spacing) {
|
||||
canvas.drawCircle(Offset(x, y), 1, dotPaint);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
bool shouldRepaint(_DotGridPainter oldDelegate) => false;
|
||||
}
|
||||
@@ -0,0 +1,261 @@
|
||||
part of '../pairing_page.dart';
|
||||
|
||||
class _PairingWelcomeView extends StatelessWidget {
|
||||
final TextEditingController codeController;
|
||||
final bool isBusy;
|
||||
final bool pairingCodeExpanded;
|
||||
final String? errorMessage;
|
||||
final VoidCallback onScan;
|
||||
final VoidCallback onTogglePairingCode;
|
||||
final VoidCallback onConnect;
|
||||
|
||||
const _PairingWelcomeView({
|
||||
required this.codeController,
|
||||
required this.isBusy,
|
||||
required this.pairingCodeExpanded,
|
||||
required this.errorMessage,
|
||||
required this.onScan,
|
||||
required this.onTogglePairingCode,
|
||||
required this.onConnect,
|
||||
});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final reducedMotion = MediaQuery.disableAnimationsOf(context);
|
||||
final revealDuration = reducedMotion
|
||||
? Duration.zero
|
||||
: const Duration(milliseconds: 220);
|
||||
|
||||
return LayoutBuilder(
|
||||
builder: (context, constraints) {
|
||||
return SingleChildScrollView(
|
||||
padding: const EdgeInsets.fromLTRB(
|
||||
Grid.gutter,
|
||||
Grid.sm,
|
||||
Grid.gutter,
|
||||
Grid.sm,
|
||||
),
|
||||
child: ConstrainedBox(
|
||||
constraints: BoxConstraints(
|
||||
minHeight: constraints.maxHeight - (Grid.sm * 2),
|
||||
),
|
||||
child: Column(
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
children: [
|
||||
Container(
|
||||
width: 136,
|
||||
height: 136,
|
||||
alignment: Alignment.center,
|
||||
decoration: const BoxDecoration(
|
||||
shape: BoxShape.circle,
|
||||
color: Color(0x4DFFFFFF),
|
||||
),
|
||||
child: const TappableFlappingBee(
|
||||
width: 76,
|
||||
color: _onboardingInk,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: Grid.sm),
|
||||
Text(
|
||||
context.l10n.pairingWelcome,
|
||||
textAlign: TextAlign.center,
|
||||
style: context.textTheme.headlineSmall?.copyWith(
|
||||
color: _onboardingInk,
|
||||
fontWeight: FontWeight.w600,
|
||||
letterSpacing: -0.4,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: Grid.xxs),
|
||||
Text(
|
||||
context.l10n.pairingDescription,
|
||||
textAlign: TextAlign.center,
|
||||
style: context.textTheme.bodyMedium?.copyWith(
|
||||
color: _onboardingMutedInk,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: Grid.md),
|
||||
ConstrainedBox(
|
||||
constraints: const BoxConstraints(maxWidth: 440),
|
||||
child: SizedBox(
|
||||
width: double.infinity,
|
||||
child: Column(
|
||||
children: [
|
||||
FilledButton(
|
||||
style: _onboardingButtonStyle,
|
||||
onPressed: isBusy ? null : onScan,
|
||||
child: isBusy && !pairingCodeExpanded
|
||||
? SizedBox(
|
||||
width: 20,
|
||||
height: 20,
|
||||
child: BuzzLoadingIndicator(
|
||||
size: 20,
|
||||
color: _onboardingCtaLabel,
|
||||
semanticLabel: context.l10n.openingScanner,
|
||||
),
|
||||
)
|
||||
: Text(context.l10n.scanQrCode),
|
||||
),
|
||||
const SizedBox(height: Grid.xxs),
|
||||
TextButton(
|
||||
style: _onboardingSecondaryButtonStyle,
|
||||
onPressed: isBusy ? null : onTogglePairingCode,
|
||||
child: Text(
|
||||
pairingCodeExpanded
|
||||
? context.l10n.hidePairingCode
|
||||
: context.l10n.usePairingCode,
|
||||
),
|
||||
),
|
||||
AnimatedSwitcher(
|
||||
duration: revealDuration,
|
||||
switchInCurve: Curves.easeOutCubic,
|
||||
switchOutCurve: Curves.easeInCubic,
|
||||
transitionBuilder: (child, animation) {
|
||||
return SizeTransition(
|
||||
sizeFactor: animation,
|
||||
axisAlignment: -1,
|
||||
child: FadeTransition(
|
||||
opacity: animation,
|
||||
child: child,
|
||||
),
|
||||
);
|
||||
},
|
||||
child: pairingCodeExpanded
|
||||
? Column(
|
||||
key: const ValueKey('pairing-code-fields'),
|
||||
children: [
|
||||
const SizedBox(height: Grid.twelve),
|
||||
TextField(
|
||||
controller: codeController,
|
||||
style: context.textTheme.bodyMedium
|
||||
?.copyWith(color: _onboardingInk),
|
||||
cursorColor: _onboardingInk,
|
||||
decoration: InputDecoration(
|
||||
filled: true,
|
||||
fillColor: Colors.white.withValues(
|
||||
alpha: 0.7,
|
||||
),
|
||||
hintText:
|
||||
context.l10n.pairingCodeHint,
|
||||
hintStyle: context.textTheme.bodyMedium
|
||||
?.copyWith(
|
||||
color: _onboardingMutedInk,
|
||||
),
|
||||
prefixIcon: const Icon(
|
||||
LucideIcons.link,
|
||||
color: _onboardingInk,
|
||||
),
|
||||
enabledBorder: _inputBorder,
|
||||
disabledBorder: _inputBorder,
|
||||
focusedBorder: _inputBorder.copyWith(
|
||||
borderSide: const BorderSide(
|
||||
color: _onboardingInk,
|
||||
),
|
||||
),
|
||||
isDense: true,
|
||||
),
|
||||
autocorrect: false,
|
||||
enableSuggestions: false,
|
||||
enabled: !isBusy,
|
||||
contextMenuBuilder:
|
||||
(context, editableTextState) {
|
||||
return AdaptiveTextSelectionToolbar.editableText(
|
||||
editableTextState:
|
||||
editableTextState,
|
||||
);
|
||||
},
|
||||
),
|
||||
const SizedBox(height: Grid.twelve),
|
||||
SizedBox(
|
||||
width: double.infinity,
|
||||
child: FilledButton(
|
||||
style: _onboardingButtonStyle,
|
||||
onPressed: isBusy ? null : onConnect,
|
||||
child: isBusy
|
||||
? SizedBox(
|
||||
width: 20,
|
||||
height: 20,
|
||||
child: BuzzLoadingIndicator(
|
||||
size: 20,
|
||||
color: _onboardingCtaLabel,
|
||||
semanticLabel: context.l10n.connecting,
|
||||
),
|
||||
)
|
||||
: Text(context.l10n.connect),
|
||||
),
|
||||
),
|
||||
],
|
||||
)
|
||||
: const SizedBox(
|
||||
key: ValueKey('pairing-code-fields-hidden'),
|
||||
),
|
||||
),
|
||||
if (errorMessage != null) ...[
|
||||
const SizedBox(height: Grid.twelve),
|
||||
Container(
|
||||
padding: const EdgeInsets.all(Grid.twelve),
|
||||
decoration: BoxDecoration(
|
||||
color: context.colors.errorContainer,
|
||||
borderRadius: BorderRadius.circular(Radii.md),
|
||||
),
|
||||
child: Row(
|
||||
children: [
|
||||
Icon(
|
||||
LucideIcons.triangleAlert,
|
||||
size: 16,
|
||||
color: context.colors.onErrorContainer,
|
||||
),
|
||||
const SizedBox(width: Grid.xxs),
|
||||
Expanded(
|
||||
child: Text(
|
||||
errorMessage!,
|
||||
style: context.textTheme.bodySmall
|
||||
?.copyWith(
|
||||
color:
|
||||
context.colors.onErrorContainer,
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
final _inputBorder = OutlineInputBorder(
|
||||
borderRadius: BorderRadius.circular(Radii.md),
|
||||
borderSide: BorderSide(color: _onboardingInk.withValues(alpha: 0.18)),
|
||||
);
|
||||
|
||||
final _onboardingButtonStyle = FilledButton.styleFrom(
|
||||
minimumSize: const Size(0, 48),
|
||||
padding: const EdgeInsets.symmetric(
|
||||
horizontal: Grid.lg,
|
||||
vertical: Grid.twelve,
|
||||
),
|
||||
backgroundColor: _onboardingInk,
|
||||
foregroundColor: _onboardingCtaLabel,
|
||||
disabledBackgroundColor: _onboardingInk.withValues(alpha: 0.38),
|
||||
disabledForegroundColor: _onboardingCtaLabel.withValues(alpha: 0.7),
|
||||
shape: const StadiumBorder(),
|
||||
);
|
||||
|
||||
final _onboardingSecondaryButtonStyle = TextButton.styleFrom(
|
||||
minimumSize: const Size(0, 44),
|
||||
padding: const EdgeInsets.symmetric(horizontal: Grid.md, vertical: Grid.xxs),
|
||||
backgroundColor: _onboardingInk.withValues(alpha: 0.1),
|
||||
foregroundColor: _onboardingInk,
|
||||
disabledBackgroundColor: _onboardingInk.withValues(alpha: 0.05),
|
||||
disabledForegroundColor: _onboardingInk.withValues(alpha: 0.45),
|
||||
shape: const StadiumBorder(),
|
||||
);
|
||||
@@ -0,0 +1,761 @@
|
||||
import 'dart:async';
|
||||
import 'dart:convert';
|
||||
import 'dart:math' as math;
|
||||
|
||||
import 'package:flutter/foundation.dart';
|
||||
import 'package:hooks_riverpod/hooks_riverpod.dart';
|
||||
import 'package:http/http.dart' as http;
|
||||
import 'package:nostr/nostr.dart' as nostr;
|
||||
|
||||
import '../../shared/auth/auth.dart';
|
||||
import '../../shared/crypto/ecdh.dart';
|
||||
import '../../shared/crypto/nip44.dart';
|
||||
import '../../shared/relay/relay.dart';
|
||||
import 'pairing_crypto.dart';
|
||||
import 'pairing_socket.dart';
|
||||
|
||||
/// HTTP client used by [PairingNotifier] for the validation request.
|
||||
final pairingHttpClientProvider = Provider<http.Client>((ref) {
|
||||
final client = http.Client();
|
||||
ref.onDispose(client.close);
|
||||
return client;
|
||||
});
|
||||
|
||||
enum PairingStatus {
|
||||
idle,
|
||||
connecting,
|
||||
confirmingSas,
|
||||
transferring,
|
||||
storing,
|
||||
success,
|
||||
error,
|
||||
}
|
||||
|
||||
class PairingState {
|
||||
final PairingStatus status;
|
||||
final String? errorMessage;
|
||||
final String? sasCode;
|
||||
final bool userConfirmedSas;
|
||||
final bool sendsIdentityToDesktop;
|
||||
|
||||
const PairingState({
|
||||
this.status = PairingStatus.idle,
|
||||
this.errorMessage,
|
||||
this.sasCode,
|
||||
this.userConfirmedSas = false,
|
||||
this.sendsIdentityToDesktop = false,
|
||||
});
|
||||
|
||||
PairingState copyWith({
|
||||
PairingStatus? status,
|
||||
String? errorMessage,
|
||||
String? sasCode,
|
||||
bool? userConfirmedSas,
|
||||
bool? sendsIdentityToDesktop,
|
||||
}) => PairingState(
|
||||
status: status ?? this.status,
|
||||
errorMessage: errorMessage ?? this.errorMessage,
|
||||
sasCode: sasCode ?? this.sasCode,
|
||||
userConfirmedSas: userConfirmedSas ?? this.userConfirmedSas,
|
||||
sendsIdentityToDesktop:
|
||||
sendsIdentityToDesktop ?? this.sendsIdentityToDesktop,
|
||||
);
|
||||
}
|
||||
|
||||
typedef PairingSocketFactory =
|
||||
PairingSocket Function({
|
||||
required String wsUrl,
|
||||
required String ephemeralPrivkey,
|
||||
required void Function(List<dynamic> message) onMessage,
|
||||
required void Function(Object? error) onDisconnected,
|
||||
});
|
||||
|
||||
class PairingNotifier extends Notifier<PairingState> {
|
||||
final PairingSocketFactory _socketFactory;
|
||||
PairingSocket? _socket;
|
||||
Timer? _sessionTimeout;
|
||||
|
||||
PairingNotifier({PairingSocketFactory? socketFactory})
|
||||
: _socketFactory = socketFactory ?? _createPairingSocket;
|
||||
|
||||
static PairingSocket _createPairingSocket({
|
||||
required String wsUrl,
|
||||
required String ephemeralPrivkey,
|
||||
required void Function(List<dynamic> message) onMessage,
|
||||
required void Function(Object? error) onDisconnected,
|
||||
}) => PairingSocket(
|
||||
wsUrl: wsUrl,
|
||||
ephemeralPrivkey: ephemeralPrivkey,
|
||||
onMessage: onMessage,
|
||||
onDisconnected: onDisconnected,
|
||||
);
|
||||
|
||||
@override
|
||||
PairingState build() => const PairingState();
|
||||
|
||||
Future<void> pair(String rawInput) async {
|
||||
if (state.status == PairingStatus.connecting ||
|
||||
state.status == PairingStatus.confirmingSas ||
|
||||
state.status == PairingStatus.transferring) {
|
||||
return;
|
||||
}
|
||||
|
||||
final trimmed = rawInput.trim();
|
||||
if (trimmed.startsWith('nostrpair://')) {
|
||||
return _pairNipAb(trimmed);
|
||||
}
|
||||
// Legacy buzz:// flow.
|
||||
return _pairLegacy(trimmed);
|
||||
}
|
||||
|
||||
/// Confirm that the SAS code matches. Called by the UI after user approval.
|
||||
void confirmSas() {
|
||||
if (state.status != PairingStatus.confirmingSas) return;
|
||||
|
||||
// If the desktop's sas-confirm has already arrived and been verified,
|
||||
// transition immediately and process any buffered payload.
|
||||
if (_sasConfirmReceived) {
|
||||
state = state.copyWith(status: PairingStatus.transferring);
|
||||
if (_sendIdentityToSource) {
|
||||
_sendIdentityPayload();
|
||||
} else {
|
||||
final pending = _pendingPayload;
|
||||
if (pending != null) {
|
||||
_pendingPayload = null;
|
||||
_handlePayload(pending);
|
||||
}
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
// Desktop hasn't confirmed yet — record intent and wait. The transition
|
||||
// will happen in _handleSasConfirm() once the transcript hash is verified.
|
||||
_userConfirmedSas = true;
|
||||
state = state.copyWith(userConfirmedSas: true);
|
||||
}
|
||||
|
||||
/// Deny the SAS code. Send abort and terminate.
|
||||
void denySas() {
|
||||
_sendAbort('sas_mismatch');
|
||||
_cleanup();
|
||||
state = PairingState(
|
||||
status: PairingStatus.error,
|
||||
errorMessage: 'SAS code mismatch — pairing cancelled for security.',
|
||||
);
|
||||
}
|
||||
|
||||
void reset() {
|
||||
_cleanup();
|
||||
state = const PairingState();
|
||||
}
|
||||
|
||||
void _cleanup() {
|
||||
_sessionTimeout?.cancel();
|
||||
_sessionTimeout = null;
|
||||
_socket?.dispose();
|
||||
_socket = null;
|
||||
_processedEventIds.clear();
|
||||
_sasConfirmReceived = false;
|
||||
_userConfirmedSas = false;
|
||||
_pendingPayload = null;
|
||||
_sendIdentityToSource = false;
|
||||
}
|
||||
|
||||
// ── NIP-AB pairing flow ─────────────────────────────────────────────────
|
||||
|
||||
// Session state kept between steps.
|
||||
String? _ephemeralPrivkey;
|
||||
String? _ephemeralPubkey;
|
||||
Uint8List? _sessionSecret;
|
||||
String? _sourcePubkey;
|
||||
Uint8List? _sessionId;
|
||||
Uint8List? _sasInput;
|
||||
Uint8List? _conversationKey;
|
||||
bool _sasConfirmReceived = false;
|
||||
bool _userConfirmedSas = false;
|
||||
bool _sendIdentityToSource = false;
|
||||
Map<String, dynamic>? _pendingPayload; // buffered until user confirms SAS
|
||||
final Set<String> _processedEventIds = {}; // NIP-AB §Duplicate Event Handling
|
||||
|
||||
Future<void> _pairNipAb(String uri) async {
|
||||
state = const PairingState(status: PairingStatus.connecting);
|
||||
|
||||
try {
|
||||
// 1. Parse the nostrpair:// URI.
|
||||
final qr = parseNostrpairUri(uri);
|
||||
_sourcePubkey = qr.sourcePubkey;
|
||||
_sessionSecret = qr.sessionSecret;
|
||||
_sendIdentityToSource =
|
||||
Uri.parse(uri).queryParameters['mode'] == 'recover';
|
||||
|
||||
final relayWsUrl = qr.relays.first;
|
||||
|
||||
// 2. Generate ephemeral keypair.
|
||||
final keychain = nostr.Keys.generate();
|
||||
_ephemeralPrivkey = keychain.secret;
|
||||
_ephemeralPubkey = keychain.public;
|
||||
|
||||
// 3. Derive session ID and SAS immediately (we know source pubkey from QR).
|
||||
_sessionId = deriveSessionId(qr.sessionSecret);
|
||||
final ecdhShared = ecdhSharedSecret(_ephemeralPrivkey!, qr.sourcePubkey);
|
||||
final (sasCode, sasInput) = deriveSas(ecdhShared, qr.sessionSecret);
|
||||
_sasInput = sasInput;
|
||||
|
||||
// Pre-compute NIP-44 conversation key for encrypting events.
|
||||
_conversationKey = getConversationKey(
|
||||
_ephemeralPrivkey!,
|
||||
qr.sourcePubkey,
|
||||
);
|
||||
|
||||
// 4. Connect to relay with ephemeral keys.
|
||||
final socket = _socketFactory(
|
||||
wsUrl: relayWsUrl,
|
||||
ephemeralPrivkey: _ephemeralPrivkey!,
|
||||
onMessage: _handleRelayMessage,
|
||||
onDisconnected: _handleDisconnected,
|
||||
);
|
||||
_socket = socket;
|
||||
await socket.connect();
|
||||
|
||||
if (!socket.isConnected) {
|
||||
throw StateError('Pairing socket did not reach the connected state');
|
||||
}
|
||||
|
||||
// 5. Subscribe for kind:24134 events tagged to our ephemeral pubkey.
|
||||
socket.subscribe('pair', 24134, _ephemeralPubkey!);
|
||||
|
||||
// 6. Wait briefly for EOSE, then send offer.
|
||||
// (In practice, we send the offer immediately — the relay will buffer it.)
|
||||
await Future.delayed(const Duration(milliseconds: 500));
|
||||
|
||||
// 7. Build and send the offer event.
|
||||
final offerContent = _encryptMessage({
|
||||
'type': 'offer',
|
||||
'version': 1,
|
||||
'session_id': bytesToHex(_sessionId!),
|
||||
});
|
||||
|
||||
_publishEvent(
|
||||
kind: 24134,
|
||||
content: offerContent,
|
||||
tags: [
|
||||
['p', qr.sourcePubkey],
|
||||
],
|
||||
);
|
||||
|
||||
// 8. Display SAS code and wait for sas-confirm from source.
|
||||
state = PairingState(
|
||||
status: PairingStatus.confirmingSas,
|
||||
sasCode: formatSas(sasCode),
|
||||
sendsIdentityToDesktop: _sendIdentityToSource,
|
||||
);
|
||||
|
||||
// 9. Start 120s session timeout.
|
||||
_sessionTimeout = Timer(const Duration(seconds: 120), () {
|
||||
if (state.status != PairingStatus.success &&
|
||||
state.status != PairingStatus.error) {
|
||||
_cleanup();
|
||||
state = const PairingState(
|
||||
status: PairingStatus.error,
|
||||
errorMessage: 'Pairing session timed out.',
|
||||
);
|
||||
}
|
||||
});
|
||||
} on FormatException catch (e) {
|
||||
_cleanup();
|
||||
state = PairingState(
|
||||
status: PairingStatus.error,
|
||||
errorMessage: 'Invalid pairing code: ${e.message}',
|
||||
);
|
||||
} catch (e) {
|
||||
debugPrint('Pairing connection error: $e');
|
||||
_cleanup();
|
||||
state = PairingState(
|
||||
status: PairingStatus.error,
|
||||
errorMessage: _friendlyErrorMessage(e),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
static String _friendlyErrorMessage(Object error) {
|
||||
final message = error.toString();
|
||||
if (message.contains('SocketException') ||
|
||||
message.contains('Connection refused') ||
|
||||
message.contains('Network is unreachable') ||
|
||||
message.contains('No route to host') ||
|
||||
message.contains('Failed to connect')) {
|
||||
return 'Could not reach the pairing relay. Check your internet '
|
||||
'connection and VPN, then try again.';
|
||||
}
|
||||
if (error is PairingAuthException) {
|
||||
return 'The pairing relay rejected authentication. Try creating a new '
|
||||
'pairing code.';
|
||||
}
|
||||
if (error is StateError ||
|
||||
message.contains('Null check operator used on a null value')) {
|
||||
return 'Pairing stopped because of an internal error. Please try again.';
|
||||
}
|
||||
if (message.contains('HandshakeException') ||
|
||||
message.contains('CERTIFICATE_VERIFY_FAILED')) {
|
||||
return 'Secure connection failed. Check your network settings '
|
||||
'and try again.';
|
||||
}
|
||||
if (message.contains('TimeoutException') || message.contains('timed out')) {
|
||||
return 'Connection timed out. Check your internet connection and '
|
||||
'try again.';
|
||||
}
|
||||
return 'Connection failed. Please check your internet connection '
|
||||
'and try again.';
|
||||
}
|
||||
|
||||
void _handleRelayMessage(List<dynamic> data) {
|
||||
if (data.isEmpty) return;
|
||||
final type = data[0] as String;
|
||||
|
||||
if (type == 'EVENT' && data.length >= 3) {
|
||||
final eventJson = data[2] as Map<String, dynamic>;
|
||||
_handlePairingEvent(eventJson);
|
||||
}
|
||||
// Ignore EOSE, NOTICE, etc.
|
||||
}
|
||||
|
||||
void _handlePairingEvent(Map<String, dynamic> eventJson) {
|
||||
try {
|
||||
// NIP-AB §Event Validation: validate kind.
|
||||
final kind = eventJson['kind'] as int?;
|
||||
if (kind != 24134) return;
|
||||
|
||||
// NIP-AB §Event Validation: validate pubkey is from expected source.
|
||||
final eventPubkey = eventJson['pubkey'] as String?;
|
||||
if (eventPubkey == null) return;
|
||||
if (_sourcePubkey != null && eventPubkey != _sourcePubkey) return;
|
||||
|
||||
// NIP-AB §Duplicate Event Handling: discard already-processed events.
|
||||
final eventId = eventJson['id'] as String?;
|
||||
if (eventId == null) return;
|
||||
if (_processedEventIds.contains(eventId)) return;
|
||||
|
||||
// NIP-AB §Event Validation: check p-tag points to us.
|
||||
final tags = (eventJson['tags'] as List<dynamic>?) ?? [];
|
||||
final hasOurPTag = tags.any((t) {
|
||||
if (t is List && t.length >= 2) {
|
||||
return t[0] == 'p' && t[1] == _ephemeralPubkey;
|
||||
}
|
||||
return false;
|
||||
});
|
||||
if (!hasOurPTag) return;
|
||||
|
||||
// NIP-AB §Event Validation: verify event signature (NIP-01).
|
||||
// The nostr package's Event.fromJson verifies id + sig on construction.
|
||||
try {
|
||||
final event = nostr.Event.fromJson(jsonEncode(eventJson));
|
||||
if (event.id != eventId) return; // id mismatch
|
||||
} catch (_) {
|
||||
return; // invalid signature or malformed event
|
||||
}
|
||||
|
||||
// Decrypt NIP-44 content.
|
||||
final content = eventJson['content'] as String?;
|
||||
if (content == null || content.isEmpty) return;
|
||||
|
||||
final decryptKey = getConversationKey(_ephemeralPrivkey!, eventPubkey);
|
||||
final decrypted = nip44Decrypt(decryptKey, content);
|
||||
final msg = jsonDecode(decrypted) as Map<String, dynamic>;
|
||||
final msgType = msg['type'] as String?;
|
||||
|
||||
switch (msgType) {
|
||||
case 'sas-confirm':
|
||||
_handleSasConfirm(msg);
|
||||
_processedEventIds.add(eventId); // record after successful processing
|
||||
case 'payload':
|
||||
_handlePayload(msg);
|
||||
_processedEventIds.add(eventId);
|
||||
case 'abort':
|
||||
_handleAbort(msg);
|
||||
_processedEventIds.add(eventId);
|
||||
case 'complete':
|
||||
_handleComplete(msg);
|
||||
_processedEventIds.add(eventId);
|
||||
}
|
||||
} catch (e) {
|
||||
// Silently discard invalid events per NIP-AB §Event Validation.
|
||||
}
|
||||
}
|
||||
|
||||
void _handleSasConfirm(Map<String, dynamic> msg) {
|
||||
if (state.status != PairingStatus.confirmingSas) return;
|
||||
|
||||
final receivedHash = msg['transcript_hash'] as String?;
|
||||
if (receivedHash == null) return;
|
||||
|
||||
// Verify transcript hash.
|
||||
final expectedHash = deriveTranscriptHash(
|
||||
_sessionId!,
|
||||
hexToBytes(_sourcePubkey!),
|
||||
hexToBytes(_ephemeralPubkey!),
|
||||
_sasInput!,
|
||||
_sessionSecret!,
|
||||
);
|
||||
|
||||
final receivedBytes = hexToBytes(receivedHash);
|
||||
if (!constantTimeEquals(receivedBytes, expectedHash)) {
|
||||
// NIP-AB §Step 3: target MUST send abort with reason "sas_mismatch".
|
||||
_sendAbort('sas_mismatch');
|
||||
_cleanup();
|
||||
state = const PairingState(
|
||||
status: PairingStatus.error,
|
||||
errorMessage:
|
||||
'Security verification failed — possible attack. Pairing aborted.',
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
_sasConfirmReceived = true;
|
||||
|
||||
// If the user already tapped "Codes Match", complete the transition now
|
||||
// that the transcript hash is verified.
|
||||
if (_userConfirmedSas) {
|
||||
_userConfirmedSas = false;
|
||||
state = state.copyWith(status: PairingStatus.transferring);
|
||||
if (_sendIdentityToSource) {
|
||||
_sendIdentityPayload();
|
||||
} else {
|
||||
final pending = _pendingPayload;
|
||||
if (pending != null) {
|
||||
_pendingPayload = null;
|
||||
_handlePayload(pending);
|
||||
}
|
||||
}
|
||||
}
|
||||
// Otherwise stay in confirmingSas — user must still confirm via confirmSas().
|
||||
}
|
||||
|
||||
void _sendIdentityPayload() {
|
||||
final nsec = ref.read(relayConfigProvider).nsec;
|
||||
if (nsec == null || nsec.isEmpty) {
|
||||
_sendAbort('protocol_error');
|
||||
_cleanup();
|
||||
state = const PairingState(
|
||||
status: PairingStatus.error,
|
||||
errorMessage: 'No identity is available on this phone.',
|
||||
);
|
||||
return;
|
||||
}
|
||||
final content = _encryptMessage({
|
||||
'type': 'payload',
|
||||
'payload_type': 'nsec',
|
||||
'payload': nsec,
|
||||
});
|
||||
_publishEvent(
|
||||
kind: 24134,
|
||||
content: content,
|
||||
tags: [
|
||||
['p', _sourcePubkey!],
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
void _handlePayload(Map<String, dynamic> msg) {
|
||||
// Only accept payload after the transcript hash was verified.
|
||||
if (!_sasConfirmReceived) return;
|
||||
|
||||
// If the user hasn't confirmed SAS yet, buffer the payload.
|
||||
// It will be processed when confirmSas() is called.
|
||||
if (state.status == PairingStatus.confirmingSas) {
|
||||
_pendingPayload = msg;
|
||||
return;
|
||||
}
|
||||
if (state.status != PairingStatus.transferring) return;
|
||||
|
||||
state = state.copyWith(status: PairingStatus.storing);
|
||||
|
||||
final payloadType = msg['payload_type'] as String?;
|
||||
final payload = msg['payload'] as String?;
|
||||
if (payload == null) {
|
||||
state = const PairingState(
|
||||
status: PairingStatus.error,
|
||||
errorMessage: 'Received empty payload from source.',
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
_processPayload(payloadType, payload);
|
||||
}
|
||||
|
||||
void _handleComplete(Map<String, dynamic> msg) {
|
||||
if (!_sendIdentityToSource || state.status != PairingStatus.transferring) {
|
||||
return;
|
||||
}
|
||||
if (msg['success'] != true) {
|
||||
_cleanup();
|
||||
state = const PairingState(
|
||||
status: PairingStatus.error,
|
||||
errorMessage: 'Desktop could not store the identity.',
|
||||
);
|
||||
return;
|
||||
}
|
||||
_cleanup();
|
||||
state = const PairingState(status: PairingStatus.success);
|
||||
}
|
||||
|
||||
void _handleAbort(Map<String, dynamic> msg) {
|
||||
final reason = msg['reason'] as String? ?? 'unknown';
|
||||
_cleanup();
|
||||
state = PairingState(
|
||||
status: PairingStatus.error,
|
||||
errorMessage: 'Source device aborted pairing: $reason',
|
||||
);
|
||||
}
|
||||
|
||||
Future<void> _processPayload(String? payloadType, String payload) async {
|
||||
try {
|
||||
// Parse the custom payload.
|
||||
final data = jsonDecode(payload) as Map<String, dynamic>;
|
||||
final relayUrl = data['relayUrl'] as String?;
|
||||
final pubkey = data['pubkey'] as String?;
|
||||
final nsec = data['nsec'] as String?;
|
||||
|
||||
if (relayUrl == null) {
|
||||
throw const FormatException('Missing relayUrl in payload');
|
||||
}
|
||||
|
||||
// Validate relay URL to prevent SSRF via private network addresses.
|
||||
_validateRelayUrl(relayUrl);
|
||||
|
||||
// Validate credentials against the relay via NIP-42 WS handshake.
|
||||
await _validateCredentials(relayUrl: relayUrl, nsec: nsec);
|
||||
|
||||
// Send complete only after credentials are validated.
|
||||
_sendComplete(true);
|
||||
|
||||
// Store as community and switch to it.
|
||||
final community = Community.create(
|
||||
name: Community.nameFromUrl(relayUrl),
|
||||
relayUrl: relayUrl,
|
||||
pubkey: pubkey,
|
||||
nsec: nsec,
|
||||
);
|
||||
await ref
|
||||
.read(authProvider.notifier)
|
||||
.authenticateWithCommunity(community);
|
||||
|
||||
_cleanup();
|
||||
state = const PairingState(status: PairingStatus.success);
|
||||
} catch (e) {
|
||||
_sendComplete(false);
|
||||
_cleanup();
|
||||
state = PairingState(
|
||||
status: PairingStatus.error,
|
||||
errorMessage: 'Failed to import credentials: $e',
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
void _sendAbort(String reason) {
|
||||
try {
|
||||
final content = _encryptMessage({'type': 'abort', 'reason': reason});
|
||||
_publishEvent(
|
||||
kind: 24134,
|
||||
content: content,
|
||||
tags: [
|
||||
['p', _sourcePubkey!],
|
||||
],
|
||||
);
|
||||
} catch (_) {
|
||||
// Best-effort.
|
||||
}
|
||||
}
|
||||
|
||||
void _sendComplete(bool success) {
|
||||
try {
|
||||
final content = _encryptMessage({'type': 'complete', 'success': success});
|
||||
_publishEvent(
|
||||
kind: 24134,
|
||||
content: content,
|
||||
tags: [
|
||||
['p', _sourcePubkey!],
|
||||
],
|
||||
);
|
||||
} catch (_) {
|
||||
// Best-effort — complete is advisory per NIP-AB.
|
||||
}
|
||||
}
|
||||
|
||||
/// Encrypt a message using NIP-44 with the ephemeral conversation key.
|
||||
String _encryptMessage(Map<String, dynamic> message) {
|
||||
final plaintext = jsonEncode(message);
|
||||
return nip44Encrypt(_conversationKey!, plaintext);
|
||||
}
|
||||
|
||||
/// Build and publish a kind:24134 event signed with ephemeral keys.
|
||||
void _publishEvent({
|
||||
required int kind,
|
||||
required String content,
|
||||
required List<List<String>> tags,
|
||||
}) {
|
||||
// Add timestamp jitter (0-30s) for metadata privacy.
|
||||
final jitter = math.Random.secure().nextInt(31);
|
||||
final createdAt = (DateTime.now().millisecondsSinceEpoch ~/ 1000) - jitter;
|
||||
|
||||
final event = nostr.Event.from(
|
||||
kind: kind,
|
||||
content: content,
|
||||
tags: tags,
|
||||
secretKey: _ephemeralPrivkey!,
|
||||
createdAt: createdAt,
|
||||
);
|
||||
|
||||
_socket?.publishEvent(event.toMap());
|
||||
}
|
||||
|
||||
void _handleDisconnected(Object? error) {
|
||||
if (state.status == PairingStatus.success ||
|
||||
state.status == PairingStatus.error) {
|
||||
return;
|
||||
}
|
||||
_cleanup();
|
||||
state = PairingState(
|
||||
status: PairingStatus.error,
|
||||
errorMessage: 'Lost connection to pairing relay.',
|
||||
);
|
||||
}
|
||||
|
||||
// ── Legacy buzz:// flow ───────────────────────────────────────────────
|
||||
|
||||
Future<void> _pairLegacy(String rawInput) async {
|
||||
state = const PairingState(status: PairingStatus.connecting);
|
||||
|
||||
try {
|
||||
final community = _parseLegacyInput(rawInput);
|
||||
|
||||
await _validateCredentials(
|
||||
relayUrl: community.relayUrl,
|
||||
nsec: community.nsec,
|
||||
);
|
||||
|
||||
await ref
|
||||
.read(authProvider.notifier)
|
||||
.authenticateWithCommunity(community);
|
||||
state = const PairingState(status: PairingStatus.success);
|
||||
} on FormatException catch (e) {
|
||||
state = PairingState(
|
||||
status: PairingStatus.error,
|
||||
errorMessage: 'Invalid pairing code: ${e.message}',
|
||||
);
|
||||
} on RelayException catch (e) {
|
||||
state = PairingState(
|
||||
status: PairingStatus.error,
|
||||
errorMessage:
|
||||
'Could not connect to relay (${e.statusCode}). '
|
||||
'Check that the pairing code is valid.',
|
||||
);
|
||||
} catch (e) {
|
||||
state = PairingState(
|
||||
status: PairingStatus.error,
|
||||
errorMessage:
|
||||
'Connection failed. Make sure your device can reach the '
|
||||
'relay server.',
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> _validateCredentials({
|
||||
required String relayUrl,
|
||||
required String? nsec,
|
||||
}) async {
|
||||
if (nsec == null || nsec.isEmpty) {
|
||||
throw const FormatException('Pairing payload missing nsec');
|
||||
}
|
||||
final uri = Uri.parse(relayUrl);
|
||||
final scheme = uri.scheme == 'https' ? 'wss' : 'ws';
|
||||
final wsUrl = uri.replace(scheme: scheme).toString();
|
||||
|
||||
final socket = RelaySocket(
|
||||
wsUrl: wsUrl,
|
||||
nsec: nsec,
|
||||
onMessage: (_) {},
|
||||
onConnected: () {},
|
||||
onDisconnected: (_) {},
|
||||
);
|
||||
try {
|
||||
await socket.connect().timeout(const Duration(seconds: 8));
|
||||
} finally {
|
||||
await socket.disconnect();
|
||||
}
|
||||
}
|
||||
|
||||
Community _parseLegacyInput(String raw) {
|
||||
var payload = raw.trim();
|
||||
|
||||
if (payload.startsWith('buzz://')) {
|
||||
payload = payload.substring('buzz://'.length);
|
||||
}
|
||||
|
||||
final normalized = base64Url.normalize(payload);
|
||||
final jsonStr = utf8.decode(base64Url.decode(normalized));
|
||||
final decoded = jsonDecode(jsonStr);
|
||||
if (decoded is! Map<String, dynamic>) {
|
||||
throw const FormatException('Pairing payload is not a JSON object');
|
||||
}
|
||||
|
||||
final relayUrl = decoded['relayUrl'] as String?;
|
||||
if (relayUrl == null) {
|
||||
throw const FormatException('Missing relayUrl in payload');
|
||||
}
|
||||
|
||||
_validateRelayUrl(relayUrl);
|
||||
|
||||
return Community.create(
|
||||
name: Community.nameFromUrl(relayUrl),
|
||||
relayUrl: relayUrl,
|
||||
pubkey: decoded['pubkey'] as String?,
|
||||
nsec: decoded['nsec'] as String?,
|
||||
);
|
||||
}
|
||||
|
||||
void _validateRelayUrl(String url) {
|
||||
final uri = Uri.parse(url);
|
||||
|
||||
if (!kDebugMode && uri.scheme != 'https') {
|
||||
throw const FormatException('Relay URL must use HTTPS');
|
||||
}
|
||||
if (uri.scheme != 'http' && uri.scheme != 'https') {
|
||||
throw FormatException('Invalid URL scheme: ${uri.scheme}');
|
||||
}
|
||||
|
||||
final host = uri.host.toLowerCase();
|
||||
if (host == 'localhost' || host == '127.0.0.1' || host == '::1') {
|
||||
if (!kDebugMode) {
|
||||
throw const FormatException('Relay URL cannot target localhost');
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
final ip = Uri.tryParse('http://$host')?.host ?? host;
|
||||
if (_isPrivateHost(ip)) {
|
||||
throw const FormatException(
|
||||
'Relay URL cannot target private network addresses',
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
static bool _isPrivateHost(String host) {
|
||||
final parts = host.split('.');
|
||||
if (parts.length != 4) return false;
|
||||
final octets = parts.map(int.tryParse).toList();
|
||||
if (octets.any((o) => o == null)) return false;
|
||||
|
||||
final a = octets[0]!;
|
||||
final b = octets[1]!;
|
||||
|
||||
if (a == 10) return true;
|
||||
if (a == 172 && b >= 16 && b <= 31) return true;
|
||||
if (a == 192 && b == 168) return true;
|
||||
if (a == 169 && b == 254) return true;
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
final pairingProvider = NotifierProvider<PairingNotifier, PairingState>(
|
||||
PairingNotifier.new,
|
||||
);
|
||||
@@ -0,0 +1,176 @@
|
||||
import 'dart:async';
|
||||
import 'dart:math' as math;
|
||||
import 'dart:ui' show lerpDouble;
|
||||
|
||||
import 'package:flutter/foundation.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter/services.dart';
|
||||
import 'package:flutter_hooks/flutter_hooks.dart';
|
||||
import 'package:lucide_icons_flutter/lucide_icons.dart';
|
||||
import 'package:mobile_scanner/mobile_scanner.dart';
|
||||
|
||||
import '../../shared/l10n/l10n.dart';
|
||||
import '../../shared/theme/theme.dart';
|
||||
|
||||
part 'pairing_qr_scanner/dynamic_island_portal.dart';
|
||||
part 'pairing_qr_scanner/fallback_scanner.dart';
|
||||
part 'pairing_qr_scanner/scanner_camera.dart';
|
||||
|
||||
const _qrScannerPlatformChannel = MethodChannel('buzz/qr_scanner');
|
||||
|
||||
/// Returns whether the current iPhone should use the Dynamic Island scanner.
|
||||
///
|
||||
/// Android, iPad, and iPhones without a Dynamic Island always use the standard
|
||||
/// full-screen scanner.
|
||||
Future<bool> usesDynamicIslandQrScannerPortal() async {
|
||||
if (defaultTargetPlatform != TargetPlatform.iOS) {
|
||||
return false;
|
||||
}
|
||||
|
||||
try {
|
||||
return await _qrScannerPlatformChannel.invokeMethod<bool>(
|
||||
'usesDynamicIslandQrScannerPortal',
|
||||
) ??
|
||||
false;
|
||||
} on PlatformException {
|
||||
return false;
|
||||
} on MissingPluginException {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/// Opens the Dynamic Island QR scanner portal.
|
||||
///
|
||||
/// Callers determine device support with
|
||||
/// [usesDynamicIslandQrScannerPortal]. Standard devices reveal the camera
|
||||
/// behind their existing app surface with [FallbackPairingQrScanner].
|
||||
Future<String?> showDynamicIslandPairingQrScanner(BuildContext context) {
|
||||
return Navigator.of(context).push<String>(
|
||||
PageRouteBuilder<String>(
|
||||
opaque: false,
|
||||
barrierColor: Colors.transparent,
|
||||
barrierDismissible: false,
|
||||
transitionDuration: Duration.zero,
|
||||
reverseTransitionDuration: Duration.zero,
|
||||
pageBuilder: (_, _, _) => const _DynamicIslandQrScannerPortal(),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Future<void> _setDynamicIslandScannerStatusBarHidden(bool hidden) async {
|
||||
try {
|
||||
await _qrScannerPlatformChannel.invokeMethod<void>(
|
||||
'setDynamicIslandScannerStatusBarHidden',
|
||||
hidden,
|
||||
);
|
||||
} on PlatformException {
|
||||
// The scanner still works if the native status-bar bridge is unavailable.
|
||||
} on MissingPluginException {
|
||||
// Widget tests and non-iOS embedders do not register the bridge.
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> _performDynamicIslandQrScanSuccessHaptic() async {
|
||||
try {
|
||||
await _qrScannerPlatformChannel.invokeMethod<void>(
|
||||
'performDynamicIslandQrScanSuccessHaptic',
|
||||
);
|
||||
} on PlatformException {
|
||||
// Scanning should still complete if haptics are unavailable.
|
||||
} on MissingPluginException {
|
||||
// Widget tests and non-iOS embedders do not register the bridge.
|
||||
}
|
||||
}
|
||||
|
||||
String? _firstScannedValue(BarcodeCapture capture) {
|
||||
for (final barcode in capture.barcodes) {
|
||||
final value = barcode.rawValue;
|
||||
if (value != null && value.isNotEmpty) {
|
||||
return value;
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/// Geometry for the iPhone Dynamic Island scanner portal.
|
||||
///
|
||||
/// The collapsed and expanded frames share one top edge. This makes the camera
|
||||
/// grow down from the hardware cutout instead of scaling around the center of
|
||||
/// its final square.
|
||||
@visibleForTesting
|
||||
class DynamicIslandQrScannerGeometry {
|
||||
/// Creates geometry for a viewport and its top safe-area inset.
|
||||
const DynamicIslandQrScannerGeometry({
|
||||
required this.viewport,
|
||||
required this.safeAreaTop,
|
||||
});
|
||||
|
||||
/// The full logical-pixel size available to the portal route.
|
||||
final Size viewport;
|
||||
|
||||
/// The top safe-area inset reported by iOS.
|
||||
final double safeAreaTop;
|
||||
|
||||
static const _collapsedWidth = 120.0;
|
||||
static const _collapsedHeight = 36.0;
|
||||
static const _fallbackTop = 11.0;
|
||||
static const _referenceSafeAreaTop = 59.0;
|
||||
static const _edgeMargin = 15.0;
|
||||
static const _expandedCornerRadius = 40.0;
|
||||
static const _scannerFadeStart = 0.18;
|
||||
static const _introLabelFadeEnd = 0.42;
|
||||
|
||||
/// The physical-island-aligned top edge used throughout the morph.
|
||||
double get top =>
|
||||
_fallbackTop + math.max(0, safeAreaTop - _referenceSafeAreaTop);
|
||||
|
||||
/// The portal frame before the opening animation starts.
|
||||
Rect get collapsedFrame => Rect.fromLTWH(
|
||||
(viewport.width - _collapsedWidth) / 2,
|
||||
top,
|
||||
_collapsedWidth,
|
||||
_collapsedHeight,
|
||||
);
|
||||
|
||||
/// The square camera frame after the opening animation completes.
|
||||
Rect get expandedFrame {
|
||||
final maxHeight = math.max(
|
||||
_collapsedHeight,
|
||||
viewport.height - top - _edgeMargin,
|
||||
);
|
||||
final size = math.min(viewport.width - (_edgeMargin * 2), maxHeight);
|
||||
return Rect.fromLTWH(_edgeMargin, top, size, size);
|
||||
}
|
||||
|
||||
/// Returns the top-anchored portal frame at [progress].
|
||||
Rect frameAt(double progress) {
|
||||
final t = progress.clamp(0.0, 1.0);
|
||||
final start = collapsedFrame;
|
||||
final end = expandedFrame;
|
||||
return Rect.fromLTRB(
|
||||
lerpDouble(start.left, end.left, t)!,
|
||||
top,
|
||||
lerpDouble(start.right, end.right, t)!,
|
||||
lerpDouble(start.bottom, end.bottom, t)!,
|
||||
);
|
||||
}
|
||||
|
||||
/// Returns the portal corner radius at [progress].
|
||||
double cornerRadiusAt(double progress) {
|
||||
final t = progress.clamp(0.0, 1.0);
|
||||
return lerpDouble(_collapsedHeight / 2, _expandedCornerRadius, t)!;
|
||||
}
|
||||
|
||||
/// Returns the camera opacity after its delayed entrance.
|
||||
double scannerOpacityAt(double progress) {
|
||||
return ((progress - _scannerFadeStart) / (1 - _scannerFadeStart)).clamp(
|
||||
0.0,
|
||||
1.0,
|
||||
);
|
||||
}
|
||||
|
||||
/// Returns the opacity of the compact prompt inside the island pill.
|
||||
double introLabelOpacityAt(double progress) {
|
||||
return (1 - (progress / _introLabelFadeEnd)).clamp(0.0, 1.0);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,177 @@
|
||||
part of '../pairing_qr_scanner.dart';
|
||||
|
||||
const _dynamicIslandOpenDuration = Duration(milliseconds: 460);
|
||||
const _dynamicIslandCloseDuration = Duration(milliseconds: 340);
|
||||
const _dynamicIslandEaseOut = Cubic(0.16, 1, 0.3, 1);
|
||||
|
||||
class _DynamicIslandQrScannerPortal extends HookWidget {
|
||||
const _DynamicIslandQrScannerPortal();
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final controller = useMemoized(MobileScannerController.new);
|
||||
final animation = useAnimationController(
|
||||
duration: _dynamicIslandOpenDuration,
|
||||
reverseDuration: _dynamicIslandCloseDuration,
|
||||
);
|
||||
final isClosing = useState(false);
|
||||
final canPop = useState(false);
|
||||
final hasHandledResult = useRef(false);
|
||||
final reduceMotion = MediaQuery.disableAnimationsOf(context);
|
||||
|
||||
useEffect(() {
|
||||
unawaited(_setDynamicIslandScannerStatusBarHidden(true));
|
||||
if (reduceMotion) {
|
||||
animation.value = 1;
|
||||
} else {
|
||||
unawaited(animation.forward());
|
||||
}
|
||||
|
||||
return () {
|
||||
unawaited(_setDynamicIslandScannerStatusBarHidden(false));
|
||||
unawaited(controller.dispose());
|
||||
};
|
||||
}, [animation, controller, reduceMotion]);
|
||||
|
||||
Future<void> finish(String? result) async {
|
||||
canPop.value = true;
|
||||
await WidgetsBinding.instance.endOfFrame;
|
||||
if (context.mounted) {
|
||||
Navigator.of(context).pop(result);
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> closePortal() async {
|
||||
if (isClosing.value || hasHandledResult.value) {
|
||||
return;
|
||||
}
|
||||
hasHandledResult.value = true;
|
||||
isClosing.value = true;
|
||||
canPop.value = true;
|
||||
|
||||
if (reduceMotion) {
|
||||
animation.value = 0;
|
||||
await WidgetsBinding.instance.endOfFrame;
|
||||
} else {
|
||||
await animation.reverse();
|
||||
}
|
||||
if (context.mounted) {
|
||||
Navigator.of(context).pop();
|
||||
}
|
||||
}
|
||||
|
||||
void handleDetection(BarcodeCapture capture) {
|
||||
if (isClosing.value || hasHandledResult.value) {
|
||||
return;
|
||||
}
|
||||
final value = _firstScannedValue(capture);
|
||||
if (value == null) {
|
||||
return;
|
||||
}
|
||||
|
||||
hasHandledResult.value = true;
|
||||
unawaited(_performDynamicIslandQrScanSuccessHaptic());
|
||||
unawaited(finish(value));
|
||||
}
|
||||
|
||||
return PopScope(
|
||||
canPop: canPop.value,
|
||||
onPopInvokedWithResult: (didPop, _) {
|
||||
if (!didPop) {
|
||||
unawaited(closePortal());
|
||||
}
|
||||
},
|
||||
child: Material(
|
||||
type: MaterialType.transparency,
|
||||
child: GestureDetector(
|
||||
behavior: HitTestBehavior.opaque,
|
||||
onTap: closePortal,
|
||||
child: LayoutBuilder(
|
||||
builder: (context, constraints) {
|
||||
final geometry = DynamicIslandQrScannerGeometry(
|
||||
viewport: constraints.biggest,
|
||||
safeAreaTop: MediaQuery.viewPaddingOf(context).top,
|
||||
);
|
||||
|
||||
return Stack(
|
||||
children: [
|
||||
Positioned.fill(
|
||||
child: AnimatedBuilder(
|
||||
animation: animation,
|
||||
builder: (context, _) => ColoredBox(
|
||||
color: Colors.black.withValues(
|
||||
alpha: 0.12 * animation.value,
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
AnimatedBuilder(
|
||||
animation: animation,
|
||||
builder: (context, _) {
|
||||
final progress = _dynamicIslandEaseOut.transform(
|
||||
animation.value.clamp(0.0, 1.0),
|
||||
);
|
||||
final frame = geometry.frameAt(progress);
|
||||
final scannerOpacity = geometry.scannerOpacityAt(
|
||||
progress,
|
||||
);
|
||||
final introLabelOpacity = isClosing.value
|
||||
? 0.0
|
||||
: geometry.introLabelOpacityAt(progress);
|
||||
final promptOpacity = isClosing.value
|
||||
? 0.0
|
||||
: math.max(introLabelOpacity, scannerOpacity);
|
||||
|
||||
return Positioned.fromRect(
|
||||
rect: frame,
|
||||
child: ClipRRect(
|
||||
key: const ValueKey(
|
||||
'dynamic-island-qr-scanner-portal',
|
||||
),
|
||||
borderRadius: BorderRadius.circular(
|
||||
geometry.cornerRadiusAt(progress),
|
||||
),
|
||||
child: ColoredBox(
|
||||
color: Colors.black,
|
||||
child: Stack(
|
||||
fit: StackFit.expand,
|
||||
children: [
|
||||
if (!isClosing.value && scannerOpacity > 0)
|
||||
Opacity(
|
||||
opacity: scannerOpacity,
|
||||
child: _QrScannerCamera(
|
||||
controller: controller,
|
||||
onDetect: handleDetection,
|
||||
),
|
||||
),
|
||||
IgnorePointer(
|
||||
child: Opacity(
|
||||
opacity: promptOpacity,
|
||||
child: Center(
|
||||
child: Text(
|
||||
context.l10n.scanQrCodeShort,
|
||||
style: context.textTheme.bodyMedium
|
||||
?.copyWith(
|
||||
color: Colors.white,
|
||||
fontWeight: FontWeight.w600,
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
},
|
||||
),
|
||||
],
|
||||
);
|
||||
},
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,282 @@
|
||||
part of '../pairing_qr_scanner.dart';
|
||||
|
||||
const _fallbackScannerOpenDuration = Duration(milliseconds: 420);
|
||||
const _fallbackScannerCloseDuration = Duration(milliseconds: 320);
|
||||
const _fallbackScannerSheetPeekHeight = 112.0;
|
||||
const _fallbackScannerSheetCornerRadius = 32.0;
|
||||
const _fallbackScannerDrawerCurve = Cubic(0.32, 0.72, 0, 1);
|
||||
|
||||
/// Reveals the scanner behind the current app surface on standard devices.
|
||||
///
|
||||
/// The app surface moves down as a rounded sheet instead of navigating to a
|
||||
/// separate scanner page. This is used by Android and iPhones without a
|
||||
/// Dynamic Island.
|
||||
class FallbackPairingQrScanner extends HookWidget {
|
||||
const FallbackPairingQrScanner({
|
||||
required this.appSurface,
|
||||
required this.onClosed,
|
||||
super.key,
|
||||
});
|
||||
|
||||
/// The current Buzz screen that moves down to reveal the camera.
|
||||
final Widget appSurface;
|
||||
|
||||
/// Called after the app surface has returned, with a scanned value if any.
|
||||
final ValueChanged<String?> onClosed;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final controller = useMemoized(MobileScannerController.new);
|
||||
final animation = useAnimationController(
|
||||
duration: _fallbackScannerOpenDuration,
|
||||
reverseDuration: _fallbackScannerCloseDuration,
|
||||
);
|
||||
final isClosing = useRef(false);
|
||||
final reduceMotion = MediaQuery.disableAnimationsOf(context);
|
||||
|
||||
useEffect(
|
||||
() => () {
|
||||
unawaited(controller.dispose());
|
||||
},
|
||||
[controller],
|
||||
);
|
||||
|
||||
useEffect(() {
|
||||
if (reduceMotion) {
|
||||
animation.value = 1;
|
||||
} else {
|
||||
unawaited(animation.forward());
|
||||
}
|
||||
return null;
|
||||
}, [animation, reduceMotion]);
|
||||
|
||||
Future<void> closeScanner([String? result]) async {
|
||||
if (isClosing.value) {
|
||||
return;
|
||||
}
|
||||
isClosing.value = true;
|
||||
|
||||
if (reduceMotion) {
|
||||
animation.value = 0;
|
||||
await WidgetsBinding.instance.endOfFrame;
|
||||
} else {
|
||||
await animation.reverse();
|
||||
}
|
||||
|
||||
if (context.mounted) {
|
||||
onClosed(result);
|
||||
}
|
||||
}
|
||||
|
||||
void handleDetection(BarcodeCapture capture) {
|
||||
if (isClosing.value) {
|
||||
return;
|
||||
}
|
||||
final value = _firstScannedValue(capture);
|
||||
if (value == null) {
|
||||
return;
|
||||
}
|
||||
|
||||
unawaited(closeScanner(value));
|
||||
}
|
||||
|
||||
return PopScope(
|
||||
canPop: false,
|
||||
onPopInvokedWithResult: (didPop, _) {
|
||||
if (!didPop) {
|
||||
unawaited(closeScanner());
|
||||
}
|
||||
},
|
||||
child: AnnotatedRegion<SystemUiOverlayStyle>(
|
||||
value: SystemUiOverlayStyle.light,
|
||||
child: Material(
|
||||
key: const ValueKey('fallback-qr-scanner-reveal'),
|
||||
color: Colors.black,
|
||||
child: LayoutBuilder(
|
||||
builder: (context, constraints) {
|
||||
final viewport = constraints.biggest;
|
||||
final sheetTravel = math.max(
|
||||
0.0,
|
||||
viewport.height - _fallbackScannerSheetPeekHeight,
|
||||
);
|
||||
|
||||
return Stack(
|
||||
clipBehavior: Clip.hardEdge,
|
||||
children: [
|
||||
Positioned.fill(
|
||||
child: Semantics(
|
||||
button: true,
|
||||
label: context.l10n.closeQrScanner,
|
||||
child: GestureDetector(
|
||||
behavior: HitTestBehavior.opaque,
|
||||
onTap: closeScanner,
|
||||
child: Stack(
|
||||
fit: StackFit.expand,
|
||||
children: [
|
||||
_QrScannerCamera(
|
||||
controller: controller,
|
||||
onDetect: handleDetection,
|
||||
),
|
||||
const IgnorePointer(
|
||||
child: _FallbackQrScannerViewfinder(),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
AnimatedBuilder(
|
||||
animation: animation,
|
||||
child: appSurface,
|
||||
builder: (context, child) {
|
||||
final progress = _fallbackScannerDrawerCurve.transform(
|
||||
animation.value.clamp(0.0, 1.0),
|
||||
);
|
||||
final offset = sheetTravel * progress;
|
||||
final radius =
|
||||
_fallbackScannerSheetCornerRadius * progress;
|
||||
|
||||
return Positioned(
|
||||
top: offset,
|
||||
left: 0,
|
||||
right: 0,
|
||||
height: viewport.height,
|
||||
child: Semantics(
|
||||
button: true,
|
||||
label: context.l10n.closeQrScanner,
|
||||
child: GestureDetector(
|
||||
behavior: HitTestBehavior.opaque,
|
||||
onTap: closeScanner,
|
||||
child: ClipRRect(
|
||||
key: const ValueKey(
|
||||
'fallback-qr-scanner-app-sheet',
|
||||
),
|
||||
borderRadius: BorderRadius.vertical(
|
||||
top: Radius.circular(radius),
|
||||
),
|
||||
child: Stack(
|
||||
fit: StackFit.expand,
|
||||
children: [
|
||||
AbsorbPointer(child: child),
|
||||
if (progress > 0)
|
||||
Align(
|
||||
alignment: Alignment.topCenter,
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.only(
|
||||
top: Grid.twelve,
|
||||
),
|
||||
child: Opacity(
|
||||
opacity: progress,
|
||||
child: Container(
|
||||
width: Grid.md,
|
||||
height: Grid.half,
|
||||
decoration: BoxDecoration(
|
||||
color: context
|
||||
.colors
|
||||
.onSurfaceVariant
|
||||
.withValues(alpha: 0.45),
|
||||
borderRadius:
|
||||
BorderRadius.circular(
|
||||
Grid.half,
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
},
|
||||
),
|
||||
],
|
||||
);
|
||||
},
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _FallbackQrScannerViewfinder extends StatelessWidget {
|
||||
const _FallbackQrScannerViewfinder();
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return LayoutBuilder(
|
||||
builder: (context, constraints) {
|
||||
final size = math.min(
|
||||
constraints.maxWidth - (Grid.xl * 2),
|
||||
constraints.maxHeight - 240,
|
||||
);
|
||||
|
||||
return Stack(
|
||||
fit: StackFit.expand,
|
||||
children: [
|
||||
CustomPaint(
|
||||
painter: _FallbackQrScannerViewfinderPainter(
|
||||
viewfinderSize: math.max(0, size),
|
||||
),
|
||||
),
|
||||
Align(
|
||||
alignment: Alignment.center,
|
||||
child: Transform.translate(
|
||||
offset: Offset(0, (size / 2) + Grid.md),
|
||||
child: Text(
|
||||
context.l10n.scanQrCodeShort,
|
||||
style: context.textTheme.bodyMedium?.copyWith(
|
||||
color: Colors.white,
|
||||
fontWeight: FontWeight.w600,
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _FallbackQrScannerViewfinderPainter extends CustomPainter {
|
||||
const _FallbackQrScannerViewfinderPainter({required this.viewfinderSize});
|
||||
|
||||
final double viewfinderSize;
|
||||
|
||||
@override
|
||||
void paint(Canvas canvas, Size size) {
|
||||
final rect = Rect.fromCenter(
|
||||
center: size.center(Offset.zero),
|
||||
width: viewfinderSize,
|
||||
height: viewfinderSize,
|
||||
);
|
||||
final roundedRect = RRect.fromRectAndRadius(
|
||||
rect,
|
||||
const Radius.circular(40),
|
||||
);
|
||||
|
||||
canvas.saveLayer(Offset.zero & size, Paint());
|
||||
canvas.drawRect(
|
||||
Offset.zero & size,
|
||||
Paint()..color = Colors.black.withValues(alpha: 0.6),
|
||||
);
|
||||
canvas.drawRRect(roundedRect, Paint()..blendMode = BlendMode.clear);
|
||||
canvas.restore();
|
||||
canvas.drawRRect(
|
||||
roundedRect,
|
||||
Paint()
|
||||
..color = Colors.white
|
||||
..style = PaintingStyle.stroke
|
||||
..strokeWidth = 3,
|
||||
);
|
||||
}
|
||||
|
||||
@override
|
||||
bool shouldRepaint(_FallbackQrScannerViewfinderPainter oldDelegate) {
|
||||
return oldDelegate.viewfinderSize != viewfinderSize;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,52 @@
|
||||
part of '../pairing_qr_scanner.dart';
|
||||
|
||||
class _QrScannerCamera extends StatelessWidget {
|
||||
const _QrScannerCamera({required this.controller, required this.onDetect});
|
||||
|
||||
final MobileScannerController controller;
|
||||
final ValueChanged<BarcodeCapture> onDetect;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return MobileScanner(
|
||||
controller: controller,
|
||||
fit: BoxFit.cover,
|
||||
errorBuilder: (context, error) {
|
||||
final message = switch (error.errorCode) {
|
||||
MobileScannerErrorCode.permissionDenied =>
|
||||
context.l10n.cameraPermissionRequired,
|
||||
_ => context.l10n.cameraStartFailed(
|
||||
error.errorDetails?.message ?? context.l10n.unknownError,
|
||||
),
|
||||
};
|
||||
return ColoredBox(
|
||||
color: Colors.black,
|
||||
child: Center(
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.all(Grid.sm),
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
Icon(
|
||||
LucideIcons.cameraOff,
|
||||
size: 48,
|
||||
color: Colors.white.withValues(alpha: 0.72),
|
||||
),
|
||||
const SizedBox(height: Grid.xs),
|
||||
Text(
|
||||
message,
|
||||
textAlign: TextAlign.center,
|
||||
style: context.textTheme.bodyMedium?.copyWith(
|
||||
color: Colors.white.withValues(alpha: 0.72),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
},
|
||||
onDetect: onDetect,
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,234 @@
|
||||
import 'dart:async';
|
||||
import 'dart:convert';
|
||||
|
||||
import 'package:nostr/nostr.dart' as nostr;
|
||||
import 'package:web_socket_channel/web_socket_channel.dart';
|
||||
|
||||
import '../../shared/relay/nostr_models.dart';
|
||||
|
||||
const _desktopPairingAuthChallengeGrace = Duration(seconds: 3);
|
||||
const _pairingAuthOkTimeout = Duration(seconds: 8);
|
||||
|
||||
/// Ephemeral WebSocket connection for NIP-AB pairing.
|
||||
///
|
||||
/// Uses ephemeral keys for NIP-42 auth (not the stored user keys).
|
||||
/// Single-use — disposed after the pairing session completes.
|
||||
class PairingAuthException implements Exception {
|
||||
final String message;
|
||||
|
||||
const PairingAuthException(this.message);
|
||||
|
||||
@override
|
||||
String toString() => 'PairingAuthException: $message';
|
||||
}
|
||||
|
||||
class PairingSocket {
|
||||
final String _wsUrl;
|
||||
final String _ephemeralPrivkey;
|
||||
final void Function(List<dynamic> message) _onMessage;
|
||||
final void Function(Object? error) _onDisconnected;
|
||||
final Duration _authChallengeTimeout;
|
||||
final Duration _authResponseTimeout;
|
||||
final WebSocketChannel Function(Uri uri) _channelFactory;
|
||||
|
||||
WebSocketChannel? _channel;
|
||||
StreamSubscription<dynamic>? _subscription;
|
||||
Completer<void>? _authCompleter;
|
||||
Timer? _authChallengeTimer;
|
||||
Timer? _authResponseTimer;
|
||||
String? _pendingAuthEventId;
|
||||
bool _connected = false;
|
||||
|
||||
PairingSocket({
|
||||
required String wsUrl,
|
||||
required String ephemeralPrivkey,
|
||||
required void Function(List<dynamic> message) onMessage,
|
||||
required void Function(Object? error) onDisconnected,
|
||||
Duration authChallengeTimeout = _desktopPairingAuthChallengeGrace,
|
||||
Duration authResponseTimeout = _pairingAuthOkTimeout,
|
||||
WebSocketChannel Function(Uri uri) channelFactory =
|
||||
WebSocketChannel.connect,
|
||||
}) : _wsUrl = wsUrl,
|
||||
_ephemeralPrivkey = ephemeralPrivkey,
|
||||
_onMessage = onMessage,
|
||||
_onDisconnected = onDisconnected,
|
||||
_authChallengeTimeout = authChallengeTimeout,
|
||||
_authResponseTimeout = authResponseTimeout,
|
||||
_channelFactory = channelFactory;
|
||||
|
||||
bool get isConnected => _connected;
|
||||
|
||||
/// Connect and answer a NIP-42 challenge when the relay requires one.
|
||||
Future<void> connect() async {
|
||||
_channel = _channelFactory(Uri.parse(_wsUrl));
|
||||
await _channel!.ready;
|
||||
|
||||
_authCompleter = Completer<void>();
|
||||
|
||||
_subscription = _channel!.stream.listen(
|
||||
_handleRawMessage,
|
||||
onError: _failAuth,
|
||||
onDone: () => _failAuth(null),
|
||||
);
|
||||
|
||||
// Dedicated pairing relays may be open and send no NIP-42 challenge.
|
||||
_authChallengeTimer = Timer(_authChallengeTimeout, () {
|
||||
if (_pendingAuthEventId == null &&
|
||||
_authCompleter != null &&
|
||||
!_authCompleter!.isCompleted) {
|
||||
_authCompleter!.complete();
|
||||
}
|
||||
});
|
||||
|
||||
try {
|
||||
await _authCompleter!.future;
|
||||
_connected = true;
|
||||
} catch (error) {
|
||||
await disconnect();
|
||||
_onDisconnected(error);
|
||||
rethrow;
|
||||
} finally {
|
||||
_authChallengeTimer?.cancel();
|
||||
_authResponseTimer?.cancel();
|
||||
}
|
||||
}
|
||||
|
||||
/// Send a raw JSON array.
|
||||
void send(List<dynamic> payload) {
|
||||
_channel?.sink.add(jsonEncode(payload));
|
||||
}
|
||||
|
||||
/// Send a subscribe request.
|
||||
void subscribe(String subId, int kind, String pubkeyHex) {
|
||||
send([
|
||||
'REQ',
|
||||
subId,
|
||||
{
|
||||
'kinds': [kind],
|
||||
'#p': [pubkeyHex],
|
||||
},
|
||||
]);
|
||||
}
|
||||
|
||||
/// Publish a Nostr event (already JSON-encoded map).
|
||||
void publishEvent(Map<String, dynamic> event) {
|
||||
send(['EVENT', event]);
|
||||
}
|
||||
|
||||
Future<void> disconnect() async {
|
||||
_connected = false;
|
||||
_subscription?.cancel();
|
||||
_subscription = null;
|
||||
_authChallengeTimer?.cancel();
|
||||
_authResponseTimer?.cancel();
|
||||
final channel = _channel;
|
||||
_channel = null;
|
||||
if (channel != null) {
|
||||
await channel.sink.close();
|
||||
}
|
||||
}
|
||||
|
||||
void dispose() {
|
||||
_connected = false;
|
||||
_subscription?.cancel();
|
||||
_channel?.sink.close();
|
||||
_channel = null;
|
||||
_authChallengeTimer?.cancel();
|
||||
_authResponseTimer?.cancel();
|
||||
}
|
||||
|
||||
void _failAuth(Object? error) {
|
||||
final authError = error ?? Exception('Connection closed');
|
||||
if (_authCompleter != null && !_authCompleter!.isCompleted) {
|
||||
_authCompleter!.completeError(authError);
|
||||
return;
|
||||
}
|
||||
if (_connected) {
|
||||
unawaited(disconnect());
|
||||
_onDisconnected(authError);
|
||||
}
|
||||
}
|
||||
|
||||
void _handleRawMessage(dynamic raw) {
|
||||
if (raw is! String) return;
|
||||
|
||||
final List<dynamic> data;
|
||||
try {
|
||||
data = jsonDecode(raw) as List<dynamic>;
|
||||
} catch (_) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (data.isEmpty) return;
|
||||
final type = data[0] as String;
|
||||
|
||||
switch (type) {
|
||||
case 'AUTH':
|
||||
_handleAuthChallenge(data);
|
||||
case 'OK':
|
||||
_handleOk(data);
|
||||
default:
|
||||
// Pass EVENT, EOSE, NOTICE upstream.
|
||||
_onMessage(data);
|
||||
}
|
||||
}
|
||||
|
||||
void _handleAuthChallenge(List<dynamic> data) {
|
||||
if (data.length < 2) return;
|
||||
final challenge = data[1] as String;
|
||||
|
||||
_authChallengeTimer?.cancel();
|
||||
_authResponseTimer?.cancel();
|
||||
_authResponseTimer = Timer(_authResponseTimeout, () {
|
||||
_failAuth(
|
||||
const PairingAuthException('Relay did not confirm authentication'),
|
||||
);
|
||||
});
|
||||
|
||||
try {
|
||||
// Build NIP-42 auth event (kind:22242) with ephemeral keys.
|
||||
final tags = <List<String>>[
|
||||
['relay', _wsUrl],
|
||||
['challenge', challenge],
|
||||
];
|
||||
|
||||
final event = nostr.Event.from(
|
||||
kind: EventKind.auth,
|
||||
content: '',
|
||||
tags: tags,
|
||||
secretKey: _ephemeralPrivkey,
|
||||
createdAt: DateTime.now().millisecondsSinceEpoch ~/ 1000,
|
||||
);
|
||||
|
||||
_pendingAuthEventId = event.id;
|
||||
send(['AUTH', event.toMap()]);
|
||||
} catch (e) {
|
||||
_failAuth(e);
|
||||
}
|
||||
}
|
||||
|
||||
void _handleOk(List<dynamic> data) {
|
||||
if (data.length < 3) return;
|
||||
final eventId = data[1] as String;
|
||||
final accepted = data[2] as bool;
|
||||
|
||||
if (_pendingAuthEventId != null && eventId == _pendingAuthEventId) {
|
||||
_pendingAuthEventId = null;
|
||||
_authResponseTimer?.cancel();
|
||||
if (accepted) {
|
||||
if (_authCompleter != null && !_authCompleter!.isCompleted) {
|
||||
_authCompleter!.complete();
|
||||
}
|
||||
} else {
|
||||
final message = data.length > 3
|
||||
? data[3] as String
|
||||
: 'Auth rejected by relay';
|
||||
_failAuth(PairingAuthException(message));
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
// Pass non-auth OK upstream.
|
||||
_onMessage(data);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user