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

Signed-off-by: cls_宁波本机 <908705107@qq.com>
This commit is contained in:
2026-08-13 18:34:25 +08:00
parent 61c3fa1df9
commit 9dfa06ffee
3785 changed files with 1085458 additions and 2 deletions
@@ -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);
}
}