---
.../onboarding/ui/IdentityRecoveryPairing.tsx | 17 +-
.../onboarding/ui/MachineOnboardingFlow.tsx | 99 +++++++--
desktop/tests/e2e/identity-lost.spec.ts | 11 +-
desktop/tests/e2e/key-import-reveal.spec.ts | 3 +
.../onboarding-docked-cta-screenshots.spec.ts | 6 +
desktop/tests/e2e/onboarding.spec.ts | 35 +++
mobile/lib/features/pairing/pairing_page.dart | 5 +-
.../features/pairing/pairing_provider.dart | 3 +-
.../features/pairing/pairing_page_test.dart | 90 +++++++-
.../pairing/pairing_provider_test.dart | 203 ++++++++++++++++++
10 files changed, 436 insertions(+), 36 deletions(-)
diff --git a/desktop/src/features/onboarding/ui/IdentityRecoveryPairing.tsx b/desktop/src/features/onboarding/ui/IdentityRecoveryPairing.tsx
index 708f5649ca..7f7e130b7c 100644
--- a/desktop/src/features/onboarding/ui/IdentityRecoveryPairing.tsx
+++ b/desktop/src/features/onboarding/ui/IdentityRecoveryPairing.tsx
@@ -90,20 +90,15 @@ export function IdentityRecoveryPairing({
return (
-
Scan with Buzz on your phone
-
- On your signed-in phone, open Settings → Send identity to desktop. This
- code expires shortly and works once.
-
-
+
{step === "qr" && qrUri ? (
@@ -149,7 +144,11 @@ export function IdentityRecoveryPairing({
)}
-
+
+ On your phone, open Settings → Send identity to desktop. This code
+ expires shortly and works once.
+
+
Your phone will grant this desktop permanent access to your full Buzz
identity. Only approve a desktop you trust and verify the six-digit code
on both screens.
diff --git a/desktop/src/features/onboarding/ui/MachineOnboardingFlow.tsx b/desktop/src/features/onboarding/ui/MachineOnboardingFlow.tsx
index 7e957cb7ab..b30dfc2797 100644
--- a/desktop/src/features/onboarding/ui/MachineOnboardingFlow.tsx
+++ b/desktop/src/features/onboarding/ui/MachineOnboardingFlow.tsx
@@ -80,6 +80,9 @@ export function MachineOnboardingFlow({
const [identityWasImported, setIdentityWasImported] = React.useState(false);
const [keyImportStage, setKeyImportStage] =
React.useState("key-entry");
+ const [keyImportMethod, setKeyImportMethod] = React.useState<"phone" | "key">(
+ "phone",
+ );
const [selectedPubkey, setSelectedPubkey] = React.useState(
null,
);
@@ -244,6 +247,7 @@ export function MachineOnboardingFlow({
className={`${ONBOARDING_SECONDARY_CTA_CLASS} px-5`}
disabled={isPending}
onClick={() => {
+ setKeyImportMethod("phone");
setKeyImportStage("key-entry");
setPage("key-import");
}}
@@ -277,33 +281,86 @@ export function MachineOnboardingFlow({
{keyImportStage === "backup-password"
? "Unlock your account"
- : identityLost
- ? "Re-import your key"
- : "Enter your private key"}
+ : keyImportMethod === "phone"
+ ? identityLost
+ ? "Recover from your phone"
+ : "Use your Buzz identity"
+ : identityLost
+ ? "Re-import your key"
+ : "Enter your private key"}
{keyImportStage === "backup-password"
? "Enter your backup password to unlock your key and restore your identity."
- : identityLost
- ? "Your identity is no longer in the system keyring. Re-import your nsec to restore it."
- : "If you already have a Buzz account, enter your private key below to get started."}
+ : keyImportMethod === "phone"
+ ? "Scan with a signed-in Buzz phone to securely bring this identity to your desktop."
+ : identityLost
+ ? "Re-import your nsec or encrypted backup to restore this identity."
+ : "Enter your nsec or choose an encrypted backup file."}
-
- {identityLost && keyImportStage === "key-entry" ? (
-
- ) : null}
-
void replaceLostIdentity()
- : () => setPage("identity")
- }
- onImport={importExistingIdentity}
- onStageChange={setKeyImportStage}
- variant="spotlight"
- />
+
+ {keyImportMethod === "phone" ? (
+
+
+ setKeyImportMethod("key")}
+ type="button"
+ variant="ghost"
+ >
+ Use a private key or backup instead
+
+ {identityLost ? (
+ void replaceLostIdentity()}
+ type="button"
+ variant="ghost"
+ >
+ Start new identity
+
+ ) : (
+ setPage("identity")}
+ type="button"
+ variant="ghost"
+ >
+ Back
+
+ )}
+
+ ) : (
+
+ {
+ setKeyImportStage("key-entry");
+ setKeyImportMethod("phone");
+ }}
+ onImport={importExistingIdentity}
+ onStageChange={setKeyImportStage}
+ variant="spotlight"
+ />
+ {identityLost && keyImportStage === "key-entry" ? (
+ void replaceLostIdentity()}
+ type="button"
+ variant="ghost"
+ >
+ Start new identity
+
+ ) : null}
+
+ )}
) : page === "backup" ? (
diff --git a/desktop/tests/e2e/identity-lost.spec.ts b/desktop/tests/e2e/identity-lost.spec.ts
index 2c19864216..524b2021b8 100644
--- a/desktop/tests/e2e/identity-lost.spec.ts
+++ b/desktop/tests/e2e/identity-lost.spec.ts
@@ -62,7 +62,7 @@ test("lost boot opens onboarding gate directly on the key-import page", async ({
await expect(page.getByTestId("machine-onboarding-gate")).toBeVisible();
await expect(
- page.getByRole("heading", { name: "Re-import your key" }),
+ page.getByRole("heading", { name: "Recover from your phone" }),
).toBeVisible();
});
@@ -114,6 +114,9 @@ test("importing a key from lost mode shows the relaunch-required screen", async
{ skipOnboardingSeed: true },
);
await page.goto("/");
+ await page
+ .getByRole("button", { name: "Use a private key or backup instead" })
+ .click();
await expect(
page.getByRole("heading", { name: "Re-import your key" }),
@@ -136,6 +139,9 @@ test("start-new-identity from lost mode persists the ephemeral key after confirm
{ skipOnboardingSeed: true },
);
await page.goto("/");
+ await page
+ .getByRole("button", { name: "Use a private key or backup instead" })
+ .click();
await expect(
page.getByRole("heading", { name: "Re-import your key" }),
@@ -170,6 +176,9 @@ test("cancelling start-new-identity in lost mode stays on the import screen", as
{ skipOnboardingSeed: true },
);
await page.goto("/");
+ await page
+ .getByRole("button", { name: "Use a private key or backup instead" })
+ .click();
await expect(
page.getByRole("heading", { name: "Re-import your key" }),
diff --git a/desktop/tests/e2e/key-import-reveal.spec.ts b/desktop/tests/e2e/key-import-reveal.spec.ts
index cd05f71e76..1c1f529a96 100644
--- a/desktop/tests/e2e/key-import-reveal.spec.ts
+++ b/desktop/tests/e2e/key-import-reveal.spec.ts
@@ -19,6 +19,9 @@ test("key import masks the key with a reveal toggle", async ({ page }) => {
await page.goto("/");
await page.getByRole("button", { name: "Use an existing key" }).click();
+ await page
+ .getByRole("button", { name: "Use a private key or backup instead" })
+ .click();
const input = page.getByTestId("nostr-import-nsec-input");
await expect(input).toBeVisible();
await waitForAnimations(page);
diff --git a/desktop/tests/e2e/onboarding-docked-cta-screenshots.spec.ts b/desktop/tests/e2e/onboarding-docked-cta-screenshots.spec.ts
index efae7c3784..0475672735 100644
--- a/desktop/tests/e2e/onboarding-docked-cta-screenshots.spec.ts
+++ b/desktop/tests/e2e/onboarding-docked-cta-screenshots.spec.ts
@@ -30,6 +30,9 @@ test("machine onboarding: landing, backup, setup docked CTAs", async ({
await page.screenshot({ path: `${SHOT_DIR}/01-landing.png` });
await page.getByRole("button", { name: "Use an existing key" }).click();
+ await page
+ .getByRole("button", { name: "Use a private key or backup instead" })
+ .click();
await expect(
page.getByRole("heading", { name: "Enter your private key" }),
).toBeVisible();
@@ -143,6 +146,9 @@ test("machine key import remains usable in a short viewport", async ({
});
await page.goto("/");
await page.getByRole("button", { name: "Use an existing key" }).click();
+ await page
+ .getByRole("button", { name: "Use a private key or backup instead" })
+ .click();
const heading = page.getByRole("heading", { name: "Enter your private key" });
const input = page.getByLabel("Private key", { exact: true });
diff --git a/desktop/tests/e2e/onboarding.spec.ts b/desktop/tests/e2e/onboarding.spec.ts
index 57e812091c..89291c0e5f 100644
--- a/desktop/tests/e2e/onboarding.spec.ts
+++ b/desktop/tests/e2e/onboarding.spec.ts
@@ -620,6 +620,32 @@ test("completed users skip the loading gate while profile is still settling", as
await expectHomeView(page);
});
+test("fresh existing-identity path leads with phone recovery", async ({
+ page,
+}) => {
+ await installMockBridge(page, undefined, {
+ skipCommunitySeed: true,
+ skipOnboardingSeed: true,
+ });
+ await page.goto("/");
+
+ await page.getByRole("button", { name: "Use an existing key" }).click();
+ await expect(
+ page.getByRole("heading", { name: "Use your Buzz identity" }),
+ ).toBeVisible();
+ await expect(page.getByTestId("identity-recovery-qr")).toBeVisible();
+ await expect(page.getByTestId("nostr-import-card")).toHaveCount(0);
+
+ await page
+ .getByRole("button", { name: "Use a private key or backup instead" })
+ .click();
+ await expect(
+ page.getByRole("heading", { name: "Enter your private key" }),
+ ).toBeVisible();
+ await expect(page.getByTestId("nostr-import-card")).toBeVisible();
+ await expect(page.getByTestId("identity-recovery-pairing")).toHaveCount(0);
+});
+
test("first-launch key import continues to machine setup", async ({ page }) => {
await installMockBridge(page, undefined, {
skipCommunitySeed: true,
@@ -628,6 +654,9 @@ test("first-launch key import continues to machine setup", async ({ page }) => {
await page.goto("/");
await page.getByRole("button", { name: "Use an existing key" }).click();
+ await page
+ .getByRole("button", { name: "Use a private key or backup instead" })
+ .click();
const importedNsec = nsecEncode(hexToBytes(TEST_IDENTITIES.alice.privateKey));
await page.getByTestId("nostr-import-nsec-input").fill(importedNsec);
await page.getByTestId("nostr-import-submit").click();
@@ -648,6 +677,9 @@ test("first-launch encrypted backup import asks for a passphrase and continues",
await page.goto("/");
await page.getByRole("button", { name: "Use an existing key" }).click();
+ await page
+ .getByRole("button", { name: "Use a private key or backup instead" })
+ .click();
// Spec-vector blob the mock bridge accepts with the mock passphrase.
const mockNcryptsec =
"ncryptsec1qgg9947rlpvqu76pj5ecreduf9jxhselq2nae2kghhvd5g7dgjtcxfqtd67p9m0w57lspw8gsq6yphnm8623nsl8xn9j4jdzz84zm3frztj3z7s35vpzmqf6ksu8r89qk5z2zxfmu5gv8th8wclt0h4p";
@@ -702,6 +734,9 @@ test("first-launch import accepts an .ncryptsec backup file", async ({
await page.goto("/");
await page.getByRole("button", { name: "Use an existing key" }).click();
+ await page
+ .getByRole("button", { name: "Use a private key or backup instead" })
+ .click();
// The spotlight variant must expose a file path: a wiped user returns with
// exactly the identity.ncryptsec our own save dialog produced. The accept
diff --git a/mobile/lib/features/pairing/pairing_page.dart b/mobile/lib/features/pairing/pairing_page.dart
index bed449e07a..7061180b12 100644
--- a/mobile/lib/features/pairing/pairing_page.dart
+++ b/mobile/lib/features/pairing/pairing_page.dart
@@ -126,8 +126,7 @@ class PairingPage extends HookConsumerWidget {
child: _SasVerificationView(
sasCode: pairingState.sasCode ?? '------',
confirmed: pairingState.userConfirmedSas,
- sendsIdentityToDesktop:
- pairingState.sendsIdentityToDesktop,
+ sendsIdentityToDesktop: pairingState.sendsIdentityToDesktop,
onConfirm: () =>
ref.read(pairingProvider.notifier).confirmSas(),
onDeny: () => ref.read(pairingProvider.notifier).denySas(),
@@ -160,7 +159,7 @@ class PairingPage extends HookConsumerWidget {
onConnect: () {
final code = codeController.text.trim();
if (code.isNotEmpty) {
- ref.read(pairingProvider.notifier).pair(code);
+ unawaited(handleScannerResult(code));
}
},
),
diff --git a/mobile/lib/features/pairing/pairing_provider.dart b/mobile/lib/features/pairing/pairing_provider.dart
index e8f27aa5bc..5adde987eb 100644
--- a/mobile/lib/features/pairing/pairing_provider.dart
+++ b/mobile/lib/features/pairing/pairing_provider.dart
@@ -185,7 +185,8 @@ class PairingNotifier extends Notifier {
final qr = parseNostrpairUri(uri);
_sourcePubkey = qr.sourcePubkey;
_sessionSecret = qr.sessionSecret;
- _sendIdentityToSource = Uri.parse(uri).queryParameters['mode'] == 'recover';
+ _sendIdentityToSource =
+ Uri.parse(uri).queryParameters['mode'] == 'recover';
final relayWsUrl = qr.relays.first;
diff --git a/mobile/test/features/pairing/pairing_page_test.dart b/mobile/test/features/pairing/pairing_page_test.dart
index 678be9dfe7..e8f34a6f71 100644
--- a/mobile/test/features/pairing/pairing_page_test.dart
+++ b/mobile/test/features/pairing/pairing_page_test.dart
@@ -179,6 +179,69 @@ void main() {
expect(scanButton.onPressed, isNull);
expect(pairingCodeButton.onPressed, isNull);
});
+
+ testWidgets('recovery entry rejects ordinary nostrpair codes', (
+ tester,
+ ) async {
+ final notifier = _RecordingPairingNotifier();
+ await tester.pumpWidget(
+ WidgetHelpers.testable(
+ overrides: [pairingProvider.overrideWith(() => notifier)],
+ child: const PairingPage(
+ addingCommunity: true,
+ identityRecoveryOnly: true,
+ ),
+ ),
+ );
+
+ await _expandPairingCode(tester);
+ await tester.enterText(find.byType(TextField), 'nostrpair://ordinary');
+ await tester.tap(find.text('Connect'));
+ await tester.pump();
+
+ expect(find.text('Scan a desktop recovery code.'), findsOneWidget);
+ expect(notifier.pairedCodes, isEmpty);
+ });
+
+ testWidgets('recovery entry accepts mode=recover codes', (tester) async {
+ final notifier = _RecordingPairingNotifier();
+ await tester.pumpWidget(
+ WidgetHelpers.testable(
+ overrides: [pairingProvider.overrideWith(() => notifier)],
+ child: const PairingPage(
+ addingCommunity: true,
+ identityRecoveryOnly: true,
+ ),
+ ),
+ );
+
+ await _expandPairingCode(tester);
+ const code = 'nostrpair://desktop?mode=recover';
+ await tester.enterText(find.byType(TextField), code);
+ await tester.tap(find.text('Connect'));
+ await tester.pump();
+
+ expect(notifier.pairedCodes, [code]);
+ });
+
+ testWidgets('recovery SAS warns about permanent desktop access', (
+ tester,
+ ) async {
+ await tester.pumpWidget(
+ ProviderScope(
+ overrides: [
+ pairingProvider.overrideWith(
+ () => _ConfirmingSasPairingNotifier(sendsIdentityToDesktop: true),
+ ),
+ ],
+ child: MaterialApp(theme: AppTheme.dark(), home: const PairingPage()),
+ ),
+ );
+
+ expect(find.textContaining('full Buzz identity'), findsOneWidget);
+ expect(find.textContaining('permanent access'), findsOneWidget);
+ expect(find.text('Codes Match'), findsOneWidget);
+ });
});
}
@@ -227,12 +290,37 @@ class _ConnectingPairingNotifier extends Notifier
void denySas() {}
}
+class _RecordingPairingNotifier extends Notifier
+ implements PairingNotifier {
+ final pairedCodes = [];
+
+ @override
+ PairingState build() => const PairingState();
+
+ @override
+ Future pair(String rawInput) async => pairedCodes.add(rawInput);
+
+ @override
+ void reset() {}
+
+ @override
+ void confirmSas() {}
+
+ @override
+ void denySas() {}
+}
+
class _ConfirmingSasPairingNotifier extends Notifier
implements PairingNotifier {
+ _ConfirmingSasPairingNotifier({this.sendsIdentityToDesktop = false});
+
+ final bool sendsIdentityToDesktop;
+
@override
- PairingState build() => const PairingState(
+ PairingState build() => PairingState(
status: PairingStatus.confirmingSas,
sasCode: '123456',
+ sendsIdentityToDesktop: sendsIdentityToDesktop,
);
@override
diff --git a/mobile/test/features/pairing/pairing_provider_test.dart b/mobile/test/features/pairing/pairing_provider_test.dart
index 6f49f71921..c14599bbef 100644
--- a/mobile/test/features/pairing/pairing_provider_test.dart
+++ b/mobile/test/features/pairing/pairing_provider_test.dart
@@ -2,9 +2,14 @@ import 'dart:convert';
import 'package:flutter_test/flutter_test.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart';
+import 'package:nostr/nostr.dart' as nostr;
+import 'package:buzz/features/pairing/pairing_crypto.dart';
import 'package:buzz/features/pairing/pairing_provider.dart';
import 'package:buzz/features/pairing/pairing_socket.dart';
import 'package:buzz/shared/auth/auth.dart';
+import 'package:buzz/shared/crypto/ecdh.dart';
+import 'package:buzz/shared/crypto/nip44.dart';
+import 'package:buzz/shared/relay/relay.dart';
/// Tests for [PairingNotifier]'s legacy `buzz://` payload parsing and
/// SSRF-prevention validation.
@@ -180,6 +185,115 @@ void main() {
container.read(pairingProvider.notifier).reset();
expect(container.read(pairingProvider).status, PairingStatus.idle);
});
+
+ group('desktop identity recovery', () {
+ const sourceSecret =
+ '09b3065e3570a3a4054660dccd66e12774a99a904fdb0ca02dbc6c3136249506';
+ const sessionSecretHex =
+ 'abcdef0123456789abcdef0123456789abcdef0123456789abcdef0123456789';
+ late _ControllableSocket socket;
+ late PairingNotifier notifier;
+ late String recoveryCode;
+
+ setUp(() {
+ final source = nostr.Keys(sourceSecret);
+ recoveryCode =
+ 'nostrpair://${source.public}'
+ '?secret=$sessionSecretHex'
+ '&relay=wss%3A%2F%2Fpairing.buzz.xyz&v=1&mode=recover';
+ notifier = PairingNotifier(
+ socketFactory:
+ ({
+ required wsUrl,
+ required ephemeralPrivkey,
+ required onMessage,
+ required onDisconnected,
+ }) {
+ socket = _ControllableSocket(
+ ephemeralPrivkey: ephemeralPrivkey,
+ onMessage: onMessage,
+ onDisconnected: onDisconnected,
+ );
+ return socket;
+ },
+ );
+ container = ProviderContainer(
+ overrides: [
+ pairingProvider.overrideWith(() => notifier),
+ relayConfigProvider.overrideWith(_RecoveryRelayConfig.new),
+ ],
+ );
+ container.read(pairingProvider);
+ notifier = container.read(pairingProvider.notifier);
+ });
+
+ test('recovery URI enables phone-to-desktop transfer', () async {
+ await notifier.pair(recoveryCode);
+
+ final state = container.read(pairingProvider);
+ expect(state.status, PairingStatus.confirmingSas);
+ expect(state.sendsIdentityToDesktop, isTrue);
+ expect(state.sasCode, hasLength(6));
+ });
+
+ test(
+ 'matching SAS sends nsec and successful completion finishes',
+ () async {
+ await notifier.pair(recoveryCode);
+ notifier.confirmSas();
+ expect(container.read(pairingProvider).userConfirmedSas, isTrue);
+
+ socket.sendSourceMessage(
+ sourceSecret: sourceSecret,
+ sessionSecretHex: sessionSecretHex,
+ message: {'type': 'sas-confirm'},
+ includeTranscriptHash: true,
+ );
+
+ expect(
+ container.read(pairingProvider).status,
+ PairingStatus.transferring,
+ );
+ final sentMessages = socket.decryptedPublishedMessages(sourceSecret);
+ expect(
+ sentMessages.any(
+ (message) =>
+ message['type'] == 'payload' &&
+ message['payload_type'] == 'nsec' &&
+ message['payload'] == _RecoveryRelayConfig.nsec,
+ ),
+ isTrue,
+ );
+
+ socket.sendSourceMessage(
+ sourceSecret: sourceSecret,
+ sessionSecretHex: sessionSecretHex,
+ message: {'type': 'complete', 'success': true},
+ );
+ expect(container.read(pairingProvider).status, PairingStatus.success);
+ },
+ );
+
+ test('desktop storage failure surfaces an error', () async {
+ await notifier.pair(recoveryCode);
+ notifier.confirmSas();
+ socket.sendSourceMessage(
+ sourceSecret: sourceSecret,
+ sessionSecretHex: sessionSecretHex,
+ message: {'type': 'sas-confirm'},
+ includeTranscriptHash: true,
+ );
+ socket.sendSourceMessage(
+ sourceSecret: sourceSecret,
+ sessionSecretHex: sessionSecretHex,
+ message: {'type': 'complete', 'success': false},
+ );
+
+ final state = container.read(pairingProvider);
+ expect(state.status, PairingStatus.error);
+ expect(state.errorMessage, contains('could not store'));
+ });
+ });
});
}
@@ -241,3 +355,92 @@ class _DisconnectingSocket extends PairingSocket {
disconnectCallback(Exception('Connection closed'));
}
}
+
+class _RecoveryRelayConfig extends RelayConfigNotifier {
+ static final nsec = nostr.Keys(
+ '1111111111111111111111111111111111111111111111111111111111111111',
+ ).nsec;
+
+ @override
+ RelayConfig build() => RelayConfig(baseUrl: 'https://relay.test', nsec: nsec);
+}
+
+class _ControllableSocket extends PairingSocket {
+ final String ephemeralPrivkey;
+ final void Function(List message) relayMessageCallback;
+ final List> published = [];
+ bool _connected = false;
+ int _eventSequence = 0;
+
+ _ControllableSocket({
+ required this.ephemeralPrivkey,
+ required super.onMessage,
+ required super.onDisconnected,
+ }) : relayMessageCallback = onMessage,
+ super(wsUrl: 'ws://unused', ephemeralPrivkey: ephemeralPrivkey);
+
+ @override
+ bool get isConnected => _connected;
+
+ @override
+ Future connect() async => _connected = true;
+
+ @override
+ void subscribe(String subId, int kind, String pubkeyHex) {}
+
+ @override
+ void publishEvent(Map event) => published.add(event);
+
+ @override
+ void dispose() => _connected = false;
+
+ List> decryptedPublishedMessages(String sourceSecret) {
+ final key = getConversationKey(
+ sourceSecret,
+ nostr.Keys(ephemeralPrivkey).public,
+ );
+ return published
+ .map(
+ (event) =>
+ jsonDecode(nip44Decrypt(key, event['content'] as String))
+ as Map,
+ )
+ .toList();
+ }
+
+ void sendSourceMessage({
+ required String sourceSecret,
+ required String sessionSecretHex,
+ required Map message,
+ bool includeTranscriptHash = false,
+ }) {
+ final source = nostr.Keys(sourceSecret);
+ final targetPubkey = nostr.Keys(ephemeralPrivkey).public;
+ final sessionSecret = hexToBytes(sessionSecretHex);
+ final body = Map.from(message);
+ if (includeTranscriptHash) {
+ final shared = ecdhSharedSecret(sourceSecret, targetPubkey);
+ final (_, sasInput) = deriveSas(shared, sessionSecret);
+ body['transcript_hash'] = bytesToHex(
+ deriveTranscriptHash(
+ deriveSessionId(sessionSecret),
+ hexToBytes(source.public),
+ hexToBytes(targetPubkey),
+ sasInput,
+ sessionSecret,
+ ),
+ );
+ }
+ final key = getConversationKey(sourceSecret, targetPubkey);
+ final event = nostr.Event.from(
+ kind: 24134,
+ content: nip44Encrypt(key, jsonEncode(body)),
+ tags: [
+ ['p', targetPubkey],
+ ],
+ secretKey: sourceSecret,
+ createdAt: 1_700_000_000 + _eventSequence++,
+ );
+ relayMessageCallback(['EVENT', 'pair', event.toMap()]);
+ }
+}
From 74ab122f98be2af45ab0d37ec3764c5a44927e5b Mon Sep 17 00:00:00 2001
From: npub1223z34hd7vtwc6qj4s7flsxkj644nlre2nthu7lrrmkumhu3xddsrx9r6w
<52a228d6edf316ec6812ac3c9fc0d696ab59fc7954d77e7be31eedcddf91335b@buzz.block.builderlab.xyz>
Date: Sat, 1 Aug 2026 20:15:23 -0700
Subject: [PATCH 03/20] feat(pairing): copy desktop recovery code
Co-authored-by: Taylor Ho
Signed-off-by: Taylor Ho
---
.../onboarding/ui/IdentityRecoveryPairing.tsx | 45 ++++++++++++++++++-
desktop/tests/e2e/identity-lost.spec.ts | 20 +++++++++
2 files changed, 64 insertions(+), 1 deletion(-)
diff --git a/desktop/src/features/onboarding/ui/IdentityRecoveryPairing.tsx b/desktop/src/features/onboarding/ui/IdentityRecoveryPairing.tsx
index 7f7e130b7c..dcbeb920b0 100644
--- a/desktop/src/features/onboarding/ui/IdentityRecoveryPairing.tsx
+++ b/desktop/src/features/onboarding/ui/IdentityRecoveryPairing.tsx
@@ -1,9 +1,16 @@
import * as React from "react";
import { listen } from "@tauri-apps/api/event";
-import { Check, LoaderCircle, RefreshCw, ShieldCheck } from "lucide-react";
+import {
+ Check,
+ Copy,
+ LoaderCircle,
+ RefreshCw,
+ ShieldCheck,
+} from "lucide-react";
import { cancelPairing, confirmPairingSas } from "@/shared/api/tauri";
import { startIdentityRecoveryPairing } from "@/shared/api/tauriPairing";
+import { writeTextToClipboard } from "@/shared/lib/clipboard";
import { Button } from "@/shared/ui/button";
import { StyledQrCode } from "@/shared/ui/styled-qr-code";
@@ -18,7 +25,9 @@ export function IdentityRecoveryPairing({
const [qrUri, setQrUri] = React.useState(null);
const [sas, setSas] = React.useState(null);
const [error, setError] = React.useState(null);
+ const [copied, setCopied] = React.useState(false);
const active = React.useRef(true);
+ const copyTimer = React.useRef(null);
const start = React.useCallback(async () => {
active.current = true;
@@ -26,6 +35,7 @@ export function IdentityRecoveryPairing({
setError(null);
setSas(null);
setQrUri(null);
+ setCopied(false);
try {
setQrUri(await startIdentityRecoveryPairing());
setStep("qr");
@@ -72,10 +82,23 @@ export function IdentityRecoveryPairing({
disposed = true;
active.current = false;
for (const unlisten of unlisteners) unlisten();
+ if (copyTimer.current !== null) window.clearTimeout(copyTimer.current);
void cancelPairing();
};
}, [onRecovered, start]);
+ async function copyPairingCode() {
+ if (!qrUri) return;
+ try {
+ await writeTextToClipboard(qrUri);
+ setCopied(true);
+ if (copyTimer.current !== null) window.clearTimeout(copyTimer.current);
+ copyTimer.current = window.setTimeout(() => setCopied(false), 2_000);
+ } catch {
+ setError("Could not copy the pairing code. Try again.");
+ }
+ }
+
async function confirm() {
setStep("receiving");
try {
@@ -144,6 +167,26 @@ export function IdentityRecoveryPairing({
)}
+ {step === "qr" && qrUri ? (
+ void copyPairingCode()}
+ size="sm"
+ type="button"
+ variant="outline"
+ >
+ {copied ? (
+
+ ) : (
+
+ )}
+ {copied ? "Copied" : "Copy pairing code"}
+
+ ) : null}
+ {step === "qr" && error ? (
+ {error}
+ ) : null}
On your phone, open Settings → Send identity to desktop. This code
expires shortly and works once.
diff --git a/desktop/tests/e2e/identity-lost.spec.ts b/desktop/tests/e2e/identity-lost.spec.ts
index 524b2021b8..dbfcbaa92a 100644
--- a/desktop/tests/e2e/identity-lost.spec.ts
+++ b/desktop/tests/e2e/identity-lost.spec.ts
@@ -90,6 +90,26 @@ test("lost boot offers phone recovery with a single-use QR", async ({
fullPage: true,
});
+ const copyButton = page.getByTestId("copy-identity-recovery-code");
+ await expect(copyButton).toHaveText("Copy pairing code");
+ await page.context().grantPermissions(["clipboard-read", "clipboard-write"]);
+ await copyButton.click();
+ await expect(copyButton).toHaveText("Copied");
+
+ const copiedPayload = await page.evaluate(() => {
+ const log = (
+ window as Window & {
+ __BUZZ_E2E_COMMAND_LOG__?: Array<{
+ command: string;
+ payload: Record | null;
+ }>;
+ }
+ ).__BUZZ_E2E_COMMAND_LOG__;
+ return log?.findLast(({ command }) => command === "copy_text_to_clipboard")
+ ?.payload;
+ });
+ expect(copiedPayload?.text).toMatch(/^nostrpair:\/\/.+&mode=recover$/);
+
const commands = await page.evaluate(
() =>
(
From bef33e390cd5719719298d5a5b73604ad3fb4236 Mon Sep 17 00:00:00 2001
From: npub1223z34hd7vtwc6qj4s7flsxkj644nlre2nthu7lrrmkumhu3xddsrx9r6w
<52a228d6edf316ec6812ac3c9fc0d696ab59fc7954d77e7be31eedcddf91335b@buzz.block.builderlab.xyz>
Date: Sat, 1 Aug 2026 20:23:18 -0700
Subject: [PATCH 04/20] fix(pairing): refresh stale recovery sessions
Co-authored-by: Taylor Ho
Signed-off-by: Taylor Ho
---
desktop/src-tauri/src/commands/pairing.rs | 2 +-
.../onboarding/ui/IdentityRecoveryPairing.tsx | 33 +++++++++++-
desktop/tests/e2e/identity-lost.spec.ts | 50 +++++++++++++++++++
3 files changed, 82 insertions(+), 3 deletions(-)
diff --git a/desktop/src-tauri/src/commands/pairing.rs b/desktop/src-tauri/src/commands/pairing.rs
index 8a24a00256..777db384f6 100644
--- a/desktop/src-tauri/src/commands/pairing.rs
+++ b/desktop/src-tauri/src/commands/pairing.rs
@@ -189,7 +189,7 @@ pub async fn confirm_pairing_sas(pairing: State<'_, PairingHandle>) -> Result<()
tx.send(sas_confirm_json)
.await
- .map_err(|_| "failed to send sas-confirm")?;
+ .map_err(|_| "Pairing code expired. Create a new code and try again.")?;
let mode = *pairing.mode.lock().map_err(|e| e.to_string())?;
if mode == PairingMode::SendIdentity {
diff --git a/desktop/src/features/onboarding/ui/IdentityRecoveryPairing.tsx b/desktop/src/features/onboarding/ui/IdentityRecoveryPairing.tsx
index dcbeb920b0..fcb6642f73 100644
--- a/desktop/src/features/onboarding/ui/IdentityRecoveryPairing.tsx
+++ b/desktop/src/features/onboarding/ui/IdentityRecoveryPairing.tsx
@@ -16,6 +16,24 @@ import { StyledQrCode } from "@/shared/ui/styled-qr-code";
type Step = "loading" | "qr" | "sas" | "receiving" | "done" | "error";
+// Refresh before the pairing relay's two-minute connection cap so Desktop never
+// leaves a code on screen after its publishing channel has closed.
+const QR_REFRESH_MS = 90_000;
+
+function recoveryErrorMessage(message: string): string {
+ const normalized = message.toLowerCase();
+ if (
+ normalized.includes("sas-confirm") ||
+ normalized.includes("relay connection closed") ||
+ normalized.includes("websocket") ||
+ normalized.includes("expired") ||
+ normalized.includes("timed out")
+ ) {
+ return "This pairing code expired or lost its connection. Create a new code and try again.";
+ }
+ return message;
+}
+
export function IdentityRecoveryPairing({
onRecovered,
}: {
@@ -67,7 +85,7 @@ export function IdentityRecoveryPairing({
listen<{ message: string }>("pairing-error", ({ payload }) => {
if (!disposed && active.current) {
active.current = false;
- setError(payload.message);
+ setError(recoveryErrorMessage(payload.message));
setStep("error");
}
}).then((unlisten) => (disposed ? unlisten() : unlisteners.push(unlisten)));
@@ -87,6 +105,12 @@ export function IdentityRecoveryPairing({
};
}, [onRecovered, start]);
+ React.useEffect(() => {
+ if (step !== "qr") return;
+ const timer = window.setTimeout(() => void start(), QR_REFRESH_MS);
+ return () => window.clearTimeout(timer);
+ }, [start, step]);
+
async function copyPairingCode() {
if (!qrUri) return;
try {
@@ -104,8 +128,13 @@ export function IdentityRecoveryPairing({
try {
await confirmPairingSas();
} catch (cause) {
+ if (!active.current) return;
setError(
- cause instanceof Error ? cause.message : "Could not confirm recovery.",
+ recoveryErrorMessage(
+ cause instanceof Error
+ ? cause.message
+ : "Could not confirm recovery.",
+ ),
);
setStep("error");
}
diff --git a/desktop/tests/e2e/identity-lost.spec.ts b/desktop/tests/e2e/identity-lost.spec.ts
index dbfcbaa92a..9d01d9acbe 100644
--- a/desktop/tests/e2e/identity-lost.spec.ts
+++ b/desktop/tests/e2e/identity-lost.spec.ts
@@ -125,6 +125,56 @@ test("lost boot offers phone recovery with a single-use QR", async ({
).toBe(true);
});
+test("recovery turns relay failures into actionable copy", async ({ page }) => {
+ await installMockBridge(
+ page,
+ { identityLost: true },
+ { skipOnboardingSeed: true },
+ );
+ await page.goto("/");
+ await expect(page.getByTestId("identity-recovery-qr")).toBeVisible();
+
+ await page.evaluate(async () => {
+ await window.__TAURI_INTERNALS__?.invoke?.("plugin:event|emit", {
+ event: "pairing-error",
+ payload: { message: "failed to send sas-confirm" },
+ });
+ });
+
+ await expect(
+ page.getByText(
+ "This pairing code expired or lost its connection. Create a new code and try again.",
+ ),
+ ).toBeVisible();
+ await expect(page.getByRole("button", { name: "Try again" })).toBeVisible();
+});
+
+test("desktop refreshes recovery codes before the relay expires them", async ({
+ page,
+}) => {
+ await page.clock.install();
+ await installMockBridge(
+ page,
+ { identityLost: true },
+ { skipOnboardingSeed: true },
+ );
+ await page.goto("/");
+ await expect(page.getByTestId("identity-recovery-qr")).toBeVisible();
+
+ const recoveryStarts = () =>
+ page.evaluate(
+ () =>
+ (window.__BUZZ_E2E_COMMAND_LOG__ ?? []).filter(
+ ({ command }) => command === "start_identity_recovery_pairing",
+ ).length,
+ );
+ await expect.poll(recoveryStarts).toBe(1);
+
+ await page.clock.fastForward(90_000);
+ await expect.poll(recoveryStarts).toBe(2);
+ await expect(page.getByTestId("identity-recovery-qr")).toBeVisible();
+});
+
test("importing a key from lost mode shows the relaunch-required screen", async ({
page,
}) => {
From ef00c993e6c9ee6310304354220966c594508e5d Mon Sep 17 00:00:00 2001
From: npub1223z34hd7vtwc6qj4s7flsxkj644nlre2nthu7lrrmkumhu3xddsrx9r6w
<52a228d6edf316ec6812ac3c9fc0d696ab59fc7954d77e7be31eedcddf91335b@buzz.block.builderlab.xyz>
Date: Sat, 1 Aug 2026 20:32:23 -0700
Subject: [PATCH 05/20] fix(onboarding): continue after phone recovery
Co-authored-by: Taylor Ho
Signed-off-by: Taylor Ho
---
desktop/src/app/App.tsx | 1 +
.../features/onboarding/machineOnboarding.ts | 13 ++++++++-
.../onboarding/ui/MachineOnboardingFlow.tsx | 26 ++++++++++++++++-
desktop/src/testing/e2eBridge.ts | 9 ++++--
desktop/tests/e2e/identity-lost.spec.ts | 28 +++++++++++++++++++
5 files changed, 72 insertions(+), 5 deletions(-)
diff --git a/desktop/src/app/App.tsx b/desktop/src/app/App.tsx
index 811da043f9..0f311f3a65 100644
--- a/desktop/src/app/App.tsx
+++ b/desktop/src/app/App.tsx
@@ -706,6 +706,7 @@ function MachineBootstrap({ sharedIdentity }: { sharedIdentity: boolean }) {
{
+ continuingPubkeyRef.current = pubkey;
+ setBootedLost(false);
+ setBootedLocked(false);
+ }, []);
+
const reopen = React.useCallback(() => {
clearMachineOnboardingCompletion(currentPubkey);
setCompletedPubkey((pubkey) => (pubkey === currentPubkey ? null : pubkey));
@@ -224,7 +230,11 @@ export function useMachineOnboardingState({
continuingPubkeyRef.current !== currentPubkey)
) {
stage = "blocking";
- } else if (identityLost || !hasCompletedCurrentPubkey) {
+ } else if (
+ identityLost ||
+ continuingPubkeyRef.current === currentPubkey ||
+ !hasCompletedCurrentPubkey
+ ) {
stage = "onboarding";
} else {
stage = "ready";
@@ -233,6 +243,7 @@ export function useMachineOnboardingState({
return {
complete,
continueWithIdentity,
+ continueWithRecoveredIdentity,
currentPubkey,
identityLost,
queryClient,
diff --git a/desktop/src/features/onboarding/ui/MachineOnboardingFlow.tsx b/desktop/src/features/onboarding/ui/MachineOnboardingFlow.tsx
index b30dfc2797..2fa2afb764 100644
--- a/desktop/src/features/onboarding/ui/MachineOnboardingFlow.tsx
+++ b/desktop/src/features/onboarding/ui/MachineOnboardingFlow.tsx
@@ -54,6 +54,7 @@ export type PostOnboardingNavigation = {
export function MachineOnboardingFlow({
complete,
continueWithIdentity,
+ continueWithRecoveredIdentity,
identityLost,
initialPage,
queryClient,
@@ -61,6 +62,7 @@ export function MachineOnboardingFlow({
}: {
complete: (pubkey?: string) => void;
continueWithIdentity: (pubkey: string) => void;
+ continueWithRecoveredIdentity: (pubkey: string) => void;
identityLost: boolean;
initialPage?: MachineOnboardingPage;
queryClient: QueryClient;
@@ -132,6 +134,26 @@ export function MachineOnboardingFlow({
}
}, [queryClient]);
+ const loadRecoveredIdentity = React.useCallback(async () => {
+ setIsPending(true);
+ setError(null);
+ try {
+ const identity = await getIdentity();
+ continueWithRecoveredIdentity(identity.pubkey);
+ queryClient.setQueryData(["identity"], identity);
+ setIdentityWasImported(true);
+ setSelectedPubkey(identity.pubkey);
+ setIdentityStorage(identity.storage);
+ setPage("setup");
+ } catch (cause) {
+ setError(
+ cause instanceof Error ? cause.message : "Failed to load identity",
+ );
+ } finally {
+ setIsPending(false);
+ }
+ }, [continueWithRecoveredIdentity, queryClient]);
+
const replaceLostIdentity = React.useCallback(async () => {
const confirmed = window.confirm(
"This will create a new identity and abandon your previous key. This cannot be undone. Continue?",
@@ -308,7 +330,9 @@ export function MachineOnboardingFlow({
>
{keyImportMethod === "phone" ? (
-
+
setKeyImportMethod("key")}
diff --git a/desktop/src/testing/e2eBridge.ts b/desktop/src/testing/e2eBridge.ts
index b863313267..566936afa0 100644
--- a/desktop/src/testing/e2eBridge.ts
+++ b/desktop/src/testing/e2eBridge.ts
@@ -12743,18 +12743,21 @@ export function maybeInstallE2eTauriMocks() {
window.__BUZZ_E2E_WEBVIEW_ZOOM__ = (payload as { value: number }).value;
return;
case "start_pairing":
+ return "nostrpair://8f4b8db31967ce14fef970a1ff1e8eecf19a430aa1c83875e2f5be68dcac0f1a?relay=wss%3A%2F%2Frelay.example.com&secret=87d5a8cfd5807a0cb44f728b67d88d6dcb8daf99be137c158f21a50c1e913c0a&v=1";
case "start_identity_recovery_pairing": {
const delayMs = activeConfig?.mock?.pairingStartDelayMs ?? 0;
if (delayMs > 0) {
await new Promise((resolve) => window.setTimeout(resolve, delayMs));
}
- const mode =
- command === "start_identity_recovery_pairing" ? "&mode=recover" : "";
- return `nostrpair://8f4b8db31967ce14fef970a1ff1e8eecf19a430aa1c83875e2f5be68dcac0f1a?relay=wss%3A%2F%2Frelay.example.com&secret=87d5a8cfd5807a0cb44f728b67d88d6dcb8daf99be137c158f21a50c1e913c0a&v=1${mode}`;
+ return `nostrpair://8f4b8db31967ce14fef970a1ff1e8eecf19a430aa1c83875e2f5be68dcac0f1a?relay=wss%3A%2F%2Frelay.example.com&secret=87d5a8cfd5807a0cb44f728b67d88d6dcb8daf99be137c158f21a50c1e913c0a&v=1&mode=recover`;
}
case "cancel_pairing":
case "confirm_pairing_sas":
return null;
+ case "complete_identity_recovery_pairing":
+ mockIdentityLostCleared = true;
+ await emit("pairing-complete", {});
+ return null;
// ── NIP-IA identity archival ────────────────────────────────────────
// These mocks drive the archive-button gate matrix in
// tests/e2e/identity-archive.spec.ts. Defaults keep the button hidden
diff --git a/desktop/tests/e2e/identity-lost.spec.ts b/desktop/tests/e2e/identity-lost.spec.ts
index 9d01d9acbe..fd5e5e2a75 100644
--- a/desktop/tests/e2e/identity-lost.spec.ts
+++ b/desktop/tests/e2e/identity-lost.spec.ts
@@ -125,6 +125,34 @@ test("lost boot offers phone recovery with a single-use QR", async ({
).toBe(true);
});
+test("phone recovery continues to harness setup without creating or restarting", async ({
+ page,
+}) => {
+ await installMockBridge(
+ page,
+ { identityLost: true },
+ { skipOnboardingSeed: true },
+ );
+ await page.goto("/");
+ await expect(page.getByTestId("identity-recovery-qr")).toBeVisible();
+
+ await page.evaluate(async () => {
+ await window.__TAURI_INTERNALS__?.invoke?.(
+ "complete_identity_recovery_pairing",
+ );
+ });
+
+ await expect(
+ page.getByRole("heading", { name: "Set up your agent harnesses" }),
+ ).toBeVisible();
+ await expect(page.getByTestId("relaunch-required")).toHaveCount(0);
+ await expect(
+ page.getByRole("heading", {
+ name: "Your unique identity key has been created",
+ }),
+ ).toHaveCount(0);
+});
+
test("recovery turns relay failures into actionable copy", async ({ page }) => {
await installMockBridge(
page,
From 70f965fc3a88eadf5bb81f4de2c5091ab295c542 Mon Sep 17 00:00:00 2001
From: npub1223z34hd7vtwc6qj4s7flsxkj644nlre2nthu7lrrmkumhu3xddsrx9r6w
<52a228d6edf316ec6812ac3c9fc0d696ab59fc7954d77e7be31eedcddf91335b@buzz.block.builderlab.xyz>
Date: Tue, 4 Aug 2026 16:49:21 -0700
Subject: [PATCH 06/20] fix(pairing): align desktop recovery semantics
Co-authored-by: Taylor Ho
Signed-off-by: Taylor Ho
---
.../onboarding/ui/IdentityRecoveryPairing.tsx | 104 ++++++++++++------
desktop/tests/e2e/identity-lost.spec.ts | 84 ++++++++++++++
2 files changed, 155 insertions(+), 33 deletions(-)
diff --git a/desktop/src/features/onboarding/ui/IdentityRecoveryPairing.tsx b/desktop/src/features/onboarding/ui/IdentityRecoveryPairing.tsx
index fcb6642f73..9b08e8e473 100644
--- a/desktop/src/features/onboarding/ui/IdentityRecoveryPairing.tsx
+++ b/desktop/src/features/onboarding/ui/IdentityRecoveryPairing.tsx
@@ -6,6 +6,8 @@ import {
LoaderCircle,
RefreshCw,
ShieldCheck,
+ TriangleAlert,
+ X,
} from "lucide-react";
import { cancelPairing, confirmPairingSas } from "@/shared/api/tauri";
@@ -123,6 +125,13 @@ export function IdentityRecoveryPairing({
}
}
+ async function deny() {
+ active.current = false;
+ await cancelPairing().catch(() => {});
+ setError("The codes didn't match. Pairing was canceled.");
+ setStep("error");
+ }
+
async function confirm() {
setStep("receiving");
try {
@@ -142,63 +151,90 @@ export function IdentityRecoveryPairing({
return (
-
+
{step === "qr" && qrUri ? (
) : step === "sas" && sas ? (
-
-
-
- {sas.slice(0, 3)} {sas.slice(3)}
+
+
+
+ Verify this code matches your mobile device
-
void confirm()}>
-
- Codes match
-
+
+
+ {sas.slice(0, 3)} {sas.slice(3)}
+
+
+
+ Your phone is about to transfer your Buzz identity to this
+ desktop. Only confirm if you initiated this pairing.
+
+
+ void deny()}
+ variant="outline"
+ >
+
+ Cancel
+
+ void confirm()}
+ >
+
+ Codes match
+
+
) : step === "done" ? (
-
-
Identity received securely
+
+
+
+
Identity received securely
) : step === "error" ? (
-
+
+
{error}
-
void start()}
- variant="outline"
- >
-
+ void start()} size="sm" variant="outline">
+
Try again
) : (
-
-
+
+
{step === "receiving"
- ? "Waiting for your phone to send…"
- : "Creating secure code…"}
+ ? "Receiving identity from mobile device..."
+ : "Starting pairing..."}
)}
{step === "qr" && qrUri ? (
void copyPairingCode()}
size="sm"
@@ -206,21 +242,23 @@ export function IdentityRecoveryPairing({
variant="outline"
>
{copied ? (
-
+
) : (
-
+
)}
{copied ? "Copied" : "Copy pairing code"}
) : null}
{step === "qr" && error ? (
-
{error}
+
+ {error}
+
) : null}
-
+
On your phone, open Settings → Send identity to desktop. This code
expires shortly and works once.
-
+
Your phone will grant this desktop permanent access to your full Buzz
identity. Only approve a desktop you trust and verify the six-digit code
on both screens.
diff --git a/desktop/tests/e2e/identity-lost.spec.ts b/desktop/tests/e2e/identity-lost.spec.ts
index fd5e5e2a75..d75c9b87c3 100644
--- a/desktop/tests/e2e/identity-lost.spec.ts
+++ b/desktop/tests/e2e/identity-lost.spec.ts
@@ -125,6 +125,90 @@ test("lost boot offers phone recovery with a single-use QR", async ({
).toBe(true);
});
+test("phone recovery uses the desktop pairing card semantics", async ({
+ page,
+}) => {
+ await installMockBridge(
+ page,
+ { identityLost: true },
+ { skipOnboardingSeed: true },
+ );
+ await page.goto("/");
+
+ const card = page.getByTestId("identity-recovery-pairing");
+ const qrContainer = card.getByTestId("identity-recovery-qr-container");
+ const qrCode = card.getByTestId("identity-recovery-qr");
+ const copyButton = card.getByTestId("copy-identity-recovery-code");
+ await expect(qrCode).toBeVisible();
+ await expect(qrCode).toHaveAttribute("data-qr-matrix-size", "57");
+ await expect(qrCode.locator("[data-qr-finder-pattern]")).toHaveCount(3);
+ await expect(qrCode.locator(".buzz-qr-cell-reveal").first()).toHaveCSS(
+ "animation-name",
+ "buzz-qr-cell-reveal",
+ );
+ const qrBox = await qrContainer.boundingBox();
+ const copyBox = await copyButton.boundingBox();
+ expect(qrBox).not.toBeNull();
+ expect(copyBox).not.toBeNull();
+ expect(Math.abs((copyBox?.x ?? 0) - (qrBox?.x ?? 0))).toBeLessThan(0.5);
+ expect(Math.abs((copyBox?.width ?? 0) - (qrBox?.width ?? 0))).toBeLessThan(
+ 0.5,
+ );
+
+ await page.evaluate(async () => {
+ await window.__TAURI_INTERNALS__?.invoke?.("plugin:event|emit", {
+ event: "pairing-sas-received",
+ payload: { sas: "123456" },
+ });
+ });
+
+ await expect(
+ card.getByText("Verify this code matches your mobile device"),
+ ).toBeVisible();
+ await expect(card.getByTestId("identity-recovery-sas")).toHaveText("123 456");
+ await expect(card.getByTestId("confirm-identity-recovery-sas")).toHaveText(
+ "Codes match",
+ );
+ await expect(card.getByTestId("deny-identity-recovery-sas")).toHaveText(
+ "Cancel",
+ );
+});
+
+test("canceling recovery uses the standard pairing cancellation state", async ({
+ page,
+}) => {
+ await installMockBridge(
+ page,
+ { identityLost: true },
+ { skipOnboardingSeed: true },
+ );
+ await page.goto("/");
+ await expect(page.getByTestId("identity-recovery-qr")).toBeVisible();
+
+ await page.evaluate(async () => {
+ await window.__TAURI_INTERNALS__?.invoke?.("plugin:event|emit", {
+ event: "pairing-sas-received",
+ payload: { sas: "123456" },
+ });
+ });
+ await page.getByTestId("deny-identity-recovery-sas").click();
+
+ await expect(
+ page.getByText("The codes didn't match. Pairing was canceled."),
+ ).toBeVisible();
+ await expect(page.getByRole("button", { name: "Try again" })).toBeVisible();
+ await expect
+ .poll(() =>
+ page.evaluate(
+ () =>
+ (window.__BUZZ_E2E_COMMAND_LOG__ ?? []).filter(
+ ({ command }) => command === "cancel_pairing",
+ ).length,
+ ),
+ )
+ .toBeGreaterThan(0);
+});
+
test("phone recovery continues to harness setup without creating or restarting", async ({
page,
}) => {
From 79a5aee0dd5c56dd8c195033d5a61e895bbf1c97 Mon Sep 17 00:00:00 2001
From: npub1223z34hd7vtwc6qj4s7flsxkj644nlre2nthu7lrrmkumhu3xddsrx9r6w
<52a228d6edf316ec6812ac3c9fc0d696ab59fc7954d77e7be31eedcddf91335b@buzz.block.builderlab.xyz>
Date: Tue, 4 Aug 2026 18:00:35 -0700
Subject: [PATCH 07/20] fix(desktop): lead identity recovery with private key
Co-authored-by: Taylor Ho
Signed-off-by: Taylor Ho
---
.../onboarding/ui/MachineOnboardingFlow.tsx | 25 +++----
.../onboarding/ui/NostrKeyImportForm.tsx | 65 ++++++++++++-------
desktop/tests/e2e/identity-lost.spec.ts | 36 +++++-----
desktop/tests/e2e/key-import-reveal.spec.ts | 3 -
.../onboarding-docked-cta-screenshots.spec.ts | 6 --
desktop/tests/e2e/onboarding.spec.ts | 34 +++++-----
6 files changed, 88 insertions(+), 81 deletions(-)
diff --git a/desktop/src/features/onboarding/ui/MachineOnboardingFlow.tsx b/desktop/src/features/onboarding/ui/MachineOnboardingFlow.tsx
index 2fa2afb764..e52cef1fd5 100644
--- a/desktop/src/features/onboarding/ui/MachineOnboardingFlow.tsx
+++ b/desktop/src/features/onboarding/ui/MachineOnboardingFlow.tsx
@@ -83,7 +83,7 @@ export function MachineOnboardingFlow({
const [keyImportStage, setKeyImportStage] =
React.useState("key-entry");
const [keyImportMethod, setKeyImportMethod] = React.useState<"phone" | "key">(
- "phone",
+ "key",
);
const [selectedPubkey, setSelectedPubkey] = React.useState(
null,
@@ -269,7 +269,7 @@ export function MachineOnboardingFlow({
className={`${ONBOARDING_SECONDARY_CTA_CLASS} px-5`}
disabled={isPending}
onClick={() => {
- setKeyImportMethod("phone");
+ setKeyImportMethod("key");
setKeyImportStage("key-entry");
setPage("key-import");
}}
@@ -307,18 +307,14 @@ export function MachineOnboardingFlow({
? identityLost
? "Recover from your phone"
: "Use your Buzz identity"
- : identityLost
- ? "Re-import your key"
- : "Enter your private key"}
+ : "Enter your private key"}
{keyImportStage === "backup-password"
- ? "Enter your backup password to unlock your key and restore your identity."
+ ? "Enter your backup password to restore your identity."
: keyImportMethod === "phone"
- ? "Scan with a signed-in Buzz phone to securely bring this identity to your desktop."
- : identityLost
- ? "Re-import your nsec or encrypted backup to restore this identity."
- : "Enter your nsec or choose an encrypted backup file."}
+ ? "Scan this code with a signed-in Buzz phone."
+ : "Paste your private key to sign in to Buzz."}
{
setKeyImportStage("key-entry");
- setKeyImportMethod("phone");
+ if (identityLost) {
+ return;
+ }
+ setPage("identity");
}}
onImport={importExistingIdentity}
+ onPhoneRecovery={() => setKeyImportMethod("phone")}
onStageChange={setKeyImportStage}
+ showBack={!identityLost}
variant="spotlight"
/>
{identityLost && keyImportStage === "key-entry" ? (
diff --git a/desktop/src/features/onboarding/ui/NostrKeyImportForm.tsx b/desktop/src/features/onboarding/ui/NostrKeyImportForm.tsx
index 59e5bfdb0b..95177b0b9c 100644
--- a/desktop/src/features/onboarding/ui/NostrKeyImportForm.tsx
+++ b/desktop/src/features/onboarding/ui/NostrKeyImportForm.tsx
@@ -29,7 +29,9 @@ type NostrKeyImportFormProps = {
errorMessage?: string | null;
onBack: () => void;
onImport: (nsec: string, password?: string) => Promise;
+ onPhoneRecovery?: () => void;
onStageChange?: (stage: NostrKeyImportStage) => void;
+ showBack?: boolean;
/** "spotlight" is the first-launch treatment: glowy centered input, no drop zone, pill buttons. */
variant?: "default" | "spotlight";
};
@@ -47,7 +49,9 @@ export function NostrKeyImportForm({
errorMessage: externalErrorMessage = null,
onBack,
onImport,
+ onPhoneRecovery,
onStageChange,
+ showBack = true,
variant = "default",
}: NostrKeyImportFormProps) {
const [nsecInput, setNsecInput] = React.useState("");
@@ -301,21 +305,34 @@ export function NostrKeyImportForm({
/>
{!isPasswordStage && variant === "spotlight" ? (
- // First-launch/wiped-identity treatment: no drop zone, but the file
- // path must still exist — a backup saved through the OS dialog is
- // exactly what a wiped user returns with.
-
-
+ You can also use a{" "}
+
- Choose a backup file
-
-
+ backup file
+
+ {onPhoneRecovery ? (
+ <>
+ {" "}
+ or{" "}
+
+ recover from your phone
+
+ >
+ ) : null}
+ .
+
) : !isPasswordStage ? (
-
- {isPasswordStage ? "Back" : backLabel}
-
+ {showBack || isPasswordStage ? (
+
+ {isPasswordStage ? "Back" : backLabel}
+
+ ) : null}
);
diff --git a/desktop/tests/e2e/identity-lost.spec.ts b/desktop/tests/e2e/identity-lost.spec.ts
index d75c9b87c3..827a778e22 100644
--- a/desktop/tests/e2e/identity-lost.spec.ts
+++ b/desktop/tests/e2e/identity-lost.spec.ts
@@ -52,7 +52,7 @@ test("normal first launch uses the already-persisted identity", async ({
test("lost boot opens onboarding gate directly on the key-import page", async ({
page,
-}) => {
+}, testInfo) => {
await installMockBridge(
page,
{ identityLost: true },
@@ -62,8 +62,12 @@ test("lost boot opens onboarding gate directly on the key-import page", async ({
await expect(page.getByTestId("machine-onboarding-gate")).toBeVisible();
await expect(
- page.getByRole("heading", { name: "Recover from your phone" }),
+ page.getByRole("heading", { name: "Enter your private key" }),
).toBeVisible();
+ await page.waitForTimeout(1_000);
+ await page.screenshot({
+ path: testInfo.outputPath("desktop-private-key-recovery.png"),
+ });
});
test("lost boot offers phone recovery with a single-use QR", async ({
@@ -76,6 +80,7 @@ test("lost boot offers phone recovery with a single-use QR", async ({
);
await page.goto("/");
+ await page.getByTestId("nostr-import-phone-link").click();
await expect(page.getByTestId("identity-recovery-pairing")).toBeVisible();
await expect(page.getByTestId("identity-recovery-qr")).toBeVisible();
await expect(
@@ -135,6 +140,7 @@ test("phone recovery uses the desktop pairing card semantics", async ({
);
await page.goto("/");
+ await page.getByTestId("nostr-import-phone-link").click();
const card = page.getByTestId("identity-recovery-pairing");
const qrContainer = card.getByTestId("identity-recovery-qr-container");
const qrCode = card.getByTestId("identity-recovery-qr");
@@ -183,6 +189,7 @@ test("canceling recovery uses the standard pairing cancellation state", async ({
{ skipOnboardingSeed: true },
);
await page.goto("/");
+ await page.getByTestId("nostr-import-phone-link").click();
await expect(page.getByTestId("identity-recovery-qr")).toBeVisible();
await page.evaluate(async () => {
@@ -218,6 +225,7 @@ test("phone recovery continues to harness setup without creating or restarting",
{ skipOnboardingSeed: true },
);
await page.goto("/");
+ await page.getByTestId("nostr-import-phone-link").click();
await expect(page.getByTestId("identity-recovery-qr")).toBeVisible();
await page.evaluate(async () => {
@@ -244,6 +252,7 @@ test("recovery turns relay failures into actionable copy", async ({ page }) => {
{ skipOnboardingSeed: true },
);
await page.goto("/");
+ await page.getByTestId("nostr-import-phone-link").click();
await expect(page.getByTestId("identity-recovery-qr")).toBeVisible();
await page.evaluate(async () => {
@@ -271,6 +280,7 @@ test("desktop refreshes recovery codes before the relay expires them", async ({
{ skipOnboardingSeed: true },
);
await page.goto("/");
+ await page.getByTestId("nostr-import-phone-link").click();
await expect(page.getByTestId("identity-recovery-qr")).toBeVisible();
const recoveryStarts = () =>
@@ -296,12 +306,8 @@ test("importing a key from lost mode shows the relaunch-required screen", async
{ skipOnboardingSeed: true },
);
await page.goto("/");
- await page
- .getByRole("button", { name: "Use a private key or backup instead" })
- .click();
-
await expect(
- page.getByRole("heading", { name: "Re-import your key" }),
+ page.getByRole("heading", { name: "Enter your private key" }),
).toBeVisible();
const importedNsec = nsecEncode(hexToBytes(TEST_IDENTITIES.alice.privateKey));
@@ -321,12 +327,8 @@ test("start-new-identity from lost mode persists the ephemeral key after confirm
{ skipOnboardingSeed: true },
);
await page.goto("/");
- await page
- .getByRole("button", { name: "Use a private key or backup instead" })
- .click();
-
await expect(
- page.getByRole("heading", { name: "Re-import your key" }),
+ page.getByRole("heading", { name: "Enter your private key" }),
).toBeVisible();
page.on("dialog", (dialog) => dialog.accept());
@@ -358,12 +360,8 @@ test("cancelling start-new-identity in lost mode stays on the import screen", as
{ skipOnboardingSeed: true },
);
await page.goto("/");
- await page
- .getByRole("button", { name: "Use a private key or backup instead" })
- .click();
-
await expect(
- page.getByRole("heading", { name: "Re-import your key" }),
+ page.getByRole("heading", { name: "Enter your private key" }),
).toBeVisible();
page.on("dialog", (dialog) => dialog.dismiss());
@@ -371,7 +369,7 @@ test("cancelling start-new-identity in lost mode stays on the import screen", as
// Still on the import screen — no navigation, no persist
await expect(
- page.getByRole("heading", { name: "Re-import your key" }),
+ page.getByRole("heading", { name: "Enter your private key" }),
).toBeVisible();
await expect(page.getByTestId("relaunch-required")).toHaveCount(0);
});
@@ -389,7 +387,7 @@ test("locked boot shows the keyring-locked screen without the onboarding gate or
await expect(page.getByTestId("keyring-locked")).toBeVisible();
await expect(page.getByTestId("onboarding-gate")).toHaveCount(0);
await expect(
- page.getByRole("heading", { name: "Re-import your key" }),
+ page.getByRole("heading", { name: "Enter your private key" }),
).toHaveCount(0);
});
diff --git a/desktop/tests/e2e/key-import-reveal.spec.ts b/desktop/tests/e2e/key-import-reveal.spec.ts
index 1c1f529a96..cd05f71e76 100644
--- a/desktop/tests/e2e/key-import-reveal.spec.ts
+++ b/desktop/tests/e2e/key-import-reveal.spec.ts
@@ -19,9 +19,6 @@ test("key import masks the key with a reveal toggle", async ({ page }) => {
await page.goto("/");
await page.getByRole("button", { name: "Use an existing key" }).click();
- await page
- .getByRole("button", { name: "Use a private key or backup instead" })
- .click();
const input = page.getByTestId("nostr-import-nsec-input");
await expect(input).toBeVisible();
await waitForAnimations(page);
diff --git a/desktop/tests/e2e/onboarding-docked-cta-screenshots.spec.ts b/desktop/tests/e2e/onboarding-docked-cta-screenshots.spec.ts
index 0475672735..efae7c3784 100644
--- a/desktop/tests/e2e/onboarding-docked-cta-screenshots.spec.ts
+++ b/desktop/tests/e2e/onboarding-docked-cta-screenshots.spec.ts
@@ -30,9 +30,6 @@ test("machine onboarding: landing, backup, setup docked CTAs", async ({
await page.screenshot({ path: `${SHOT_DIR}/01-landing.png` });
await page.getByRole("button", { name: "Use an existing key" }).click();
- await page
- .getByRole("button", { name: "Use a private key or backup instead" })
- .click();
await expect(
page.getByRole("heading", { name: "Enter your private key" }),
).toBeVisible();
@@ -146,9 +143,6 @@ test("machine key import remains usable in a short viewport", async ({
});
await page.goto("/");
await page.getByRole("button", { name: "Use an existing key" }).click();
- await page
- .getByRole("button", { name: "Use a private key or backup instead" })
- .click();
const heading = page.getByRole("heading", { name: "Enter your private key" });
const input = page.getByLabel("Private key", { exact: true });
diff --git a/desktop/tests/e2e/onboarding.spec.ts b/desktop/tests/e2e/onboarding.spec.ts
index 89291c0e5f..fbff2e3025 100644
--- a/desktop/tests/e2e/onboarding.spec.ts
+++ b/desktop/tests/e2e/onboarding.spec.ts
@@ -620,7 +620,7 @@ test("completed users skip the loading gate while profile is still settling", as
await expectHomeView(page);
});
-test("fresh existing-identity path leads with phone recovery", async ({
+test("fresh existing-identity path leads with private-key recovery", async ({
page,
}) => {
await installMockBridge(page, undefined, {
@@ -631,19 +631,26 @@ test("fresh existing-identity path leads with phone recovery", async ({
await page.getByRole("button", { name: "Use an existing key" }).click();
await expect(
- page.getByRole("heading", { name: "Use your Buzz identity" }),
+ page.getByRole("heading", { name: "Enter your private key" }),
).toBeVisible();
- await expect(page.getByTestId("identity-recovery-qr")).toBeVisible();
- await expect(page.getByTestId("nostr-import-card")).toHaveCount(0);
-
- await page
- .getByRole("button", { name: "Use a private key or backup instead" })
- .click();
await expect(
- page.getByRole("heading", { name: "Enter your private key" }),
+ page.getByText("Paste your private key to sign in to Buzz."),
).toBeVisible();
await expect(page.getByTestId("nostr-import-card")).toBeVisible();
+ await expect(page.getByTestId("nostr-import-file-button")).toHaveText(
+ "backup file",
+ );
+ await expect(page.getByTestId("nostr-import-phone-link")).toHaveText(
+ "recover from your phone",
+ );
await expect(page.getByTestId("identity-recovery-pairing")).toHaveCount(0);
+
+ await page.getByTestId("nostr-import-phone-link").click();
+ await expect(
+ page.getByRole("heading", { name: "Use your Buzz identity" }),
+ ).toBeVisible();
+ await expect(page.getByTestId("identity-recovery-qr")).toBeVisible();
+ await expect(page.getByTestId("nostr-import-card")).toHaveCount(0);
});
test("first-launch key import continues to machine setup", async ({ page }) => {
@@ -654,9 +661,6 @@ test("first-launch key import continues to machine setup", async ({ page }) => {
await page.goto("/");
await page.getByRole("button", { name: "Use an existing key" }).click();
- await page
- .getByRole("button", { name: "Use a private key or backup instead" })
- .click();
const importedNsec = nsecEncode(hexToBytes(TEST_IDENTITIES.alice.privateKey));
await page.getByTestId("nostr-import-nsec-input").fill(importedNsec);
await page.getByTestId("nostr-import-submit").click();
@@ -677,9 +681,6 @@ test("first-launch encrypted backup import asks for a passphrase and continues",
await page.goto("/");
await page.getByRole("button", { name: "Use an existing key" }).click();
- await page
- .getByRole("button", { name: "Use a private key or backup instead" })
- .click();
// Spec-vector blob the mock bridge accepts with the mock passphrase.
const mockNcryptsec =
"ncryptsec1qgg9947rlpvqu76pj5ecreduf9jxhselq2nae2kghhvd5g7dgjtcxfqtd67p9m0w57lspw8gsq6yphnm8623nsl8xn9j4jdzz84zm3frztj3z7s35vpzmqf6ksu8r89qk5z2zxfmu5gv8th8wclt0h4p";
@@ -734,9 +735,6 @@ test("first-launch import accepts an .ncryptsec backup file", async ({
await page.goto("/");
await page.getByRole("button", { name: "Use an existing key" }).click();
- await page
- .getByRole("button", { name: "Use a private key or backup instead" })
- .click();
// The spotlight variant must expose a file path: a wiped user returns with
// exactly the identity.ncryptsec our own save dialog produced. The accept
From bda65905729b5a88371324f3172f88ff265e375d Mon Sep 17 00:00:00 2001
From: npub1223z34hd7vtwc6qj4s7flsxkj644nlre2nthu7lrrmkumhu3xddsrx9r6w
<52a228d6edf316ec6812ac3c9fc0d696ab59fc7954d77e7be31eedcddf91335b@buzz.block.builderlab.xyz>
Date: Tue, 4 Aug 2026 18:05:15 -0700
Subject: [PATCH 08/20] fix(desktop): move recovery links into description
Co-authored-by: Taylor Ho
Signed-off-by: Taylor Ho
---
.../onboarding/ui/MachineOnboardingFlow.tsx | 38 +++++++++++++++----
.../onboarding/ui/NostrKeyImportForm.tsx | 34 +----------------
2 files changed, 32 insertions(+), 40 deletions(-)
diff --git a/desktop/src/features/onboarding/ui/MachineOnboardingFlow.tsx b/desktop/src/features/onboarding/ui/MachineOnboardingFlow.tsx
index e52cef1fd5..279915a166 100644
--- a/desktop/src/features/onboarding/ui/MachineOnboardingFlow.tsx
+++ b/desktop/src/features/onboarding/ui/MachineOnboardingFlow.tsx
@@ -309,13 +309,36 @@ export function MachineOnboardingFlow({
: "Use your Buzz identity"
: "Enter your private key"}
-
- {keyImportStage === "backup-password"
- ? "Enter your backup password to restore your identity."
- : keyImportMethod === "phone"
- ? "Scan this code with a signed-in Buzz phone."
- : "Paste your private key to sign in to Buzz."}
-
+
+ {keyImportStage === "backup-password" ? (
+ "Enter your backup password to restore your identity."
+ ) : keyImportMethod === "phone" ? (
+ "Scan this code with a signed-in Buzz phone."
+ ) : (
+
+ Paste your private key to sign in to Buzz. You can also
+ use a{" "}
+
+ backup file
+
+ , or{" "}
+ setKeyImportMethod("phone")}
+ type="button"
+ >
+ recover from your phone
+
+ .
+
+ )}
+
setKeyImportMethod("phone")}
onStageChange={setKeyImportStage}
showBack={!identityLost}
variant="spotlight"
diff --git a/desktop/src/features/onboarding/ui/NostrKeyImportForm.tsx b/desktop/src/features/onboarding/ui/NostrKeyImportForm.tsx
index 95177b0b9c..74e0d99088 100644
--- a/desktop/src/features/onboarding/ui/NostrKeyImportForm.tsx
+++ b/desktop/src/features/onboarding/ui/NostrKeyImportForm.tsx
@@ -29,7 +29,6 @@ type NostrKeyImportFormProps = {
errorMessage?: string | null;
onBack: () => void;
onImport: (nsec: string, password?: string) => Promise
;
- onPhoneRecovery?: () => void;
onStageChange?: (stage: NostrKeyImportStage) => void;
showBack?: boolean;
/** "spotlight" is the first-launch treatment: glowy centered input, no drop zone, pill buttons. */
@@ -49,7 +48,6 @@ export function NostrKeyImportForm({
errorMessage: externalErrorMessage = null,
onBack,
onImport,
- onPhoneRecovery,
onStageChange,
showBack = true,
variant = "default",
@@ -295,6 +293,7 @@ export function NostrKeyImportForm({
className="sr-only"
data-testid="nostr-import-file-input"
disabled={isInteractionDisabled}
+ id="nostr-import-file-input"
onChange={(event) => {
void handleFiles(event.currentTarget.files);
event.currentTarget.value = "";
@@ -304,36 +303,7 @@ export function NostrKeyImportForm({
type="file"
/>
- {!isPasswordStage && variant === "spotlight" ? (
-
- You can also use a{" "}
-
- backup file
-
- {onPhoneRecovery ? (
- <>
- {" "}
- or{" "}
-
- recover from your phone
-
- >
- ) : null}
- .
-
- ) : !isPasswordStage ? (
+ {!isPasswordStage && variant !== "spotlight" ? (
Date: Tue, 4 Aug 2026 20:32:11 -0700
Subject: [PATCH 09/20] fix(onboarding): show recovery options in dialogs
Co-authored-by: Taylor Ho
Signed-off-by: Taylor Ho
---
.../onboarding/ui/MachineOnboardingFlow.tsx | 177 ++++++++++--------
.../onboarding/ui/NostrKeyImportForm.tsx | 118 ++++++++----
desktop/tests/e2e/identity-lost.spec.ts | 6 +-
desktop/tests/e2e/onboarding.spec.ts | 60 ++++--
4 files changed, 220 insertions(+), 141 deletions(-)
diff --git a/desktop/src/features/onboarding/ui/MachineOnboardingFlow.tsx b/desktop/src/features/onboarding/ui/MachineOnboardingFlow.tsx
index 279915a166..4a36e01b05 100644
--- a/desktop/src/features/onboarding/ui/MachineOnboardingFlow.tsx
+++ b/desktop/src/features/onboarding/ui/MachineOnboardingFlow.tsx
@@ -10,6 +10,12 @@ import {
} from "@/shared/api/tauriIdentity";
import type { IdentityStorage } from "@/shared/api/types";
import { Button } from "@/shared/ui/button";
+import {
+ Dialog,
+ DialogContent,
+ DialogDescription,
+ DialogTitle,
+} from "@/shared/ui/dialog";
import { StartupWindowDragRegion } from "@/shared/ui/StartupWindowDragRegion";
import { BackupStep } from "./BackupStep";
import { DefaultConfigStep } from "./DefaultConfigStep";
@@ -27,6 +33,7 @@ import {
type NostrKeyImportStage,
} from "./NostrKeyImportForm";
import {
+ ONBOARDING_INK_ICON_CLASS,
ONBOARDING_LANDING_CTA_CLASS,
ONBOARDING_SECONDARY_CTA_CLASS,
OnboardingChrome,
@@ -82,9 +89,9 @@ export function MachineOnboardingFlow({
const [identityWasImported, setIdentityWasImported] = React.useState(false);
const [keyImportStage, setKeyImportStage] =
React.useState("key-entry");
- const [keyImportMethod, setKeyImportMethod] = React.useState<"phone" | "key">(
- "key",
- );
+ const [keyImportDialog, setKeyImportDialog] = React.useState<
+ "backup" | "phone" | null
+ >(null);
const [selectedPubkey, setSelectedPubkey] = React.useState(
null,
);
@@ -269,7 +276,7 @@ export function MachineOnboardingFlow({
className={`${ONBOARDING_SECONDARY_CTA_CLASS} px-5`}
disabled={isPending}
onClick={() => {
- setKeyImportMethod("key");
+ setKeyImportDialog(null);
setKeyImportStage("key-entry");
setPage("key-import");
}}
@@ -292,7 +299,7 @@ export function MachineOnboardingFlow({
>
{keyImportStage === "backup-password"
? "Unlock your account"
- : keyImportMethod === "phone"
- ? identityLost
- ? "Recover from your phone"
- : "Use your Buzz identity"
- : "Enter your private key"}
+ : "Enter your private key"}
{keyImportStage === "backup-password" ? (
"Enter your backup password to restore your identity."
- ) : keyImportMethod === "phone" ? (
- "Scan this code with a signed-in Buzz phone."
) : (
Paste your private key to sign in to Buzz. You can also
use a{" "}
- setKeyImportDialog("backup")}
+ type="button"
>
backup file
-
+
, or{" "}
setKeyImportMethod("phone")}
+ onClick={() => setKeyImportDialog("phone")}
type="button"
>
recover from your phone
@@ -340,75 +343,97 @@ export function MachineOnboardingFlow({
)}
-
- {keyImportMethod === "phone" ? (
-
-
+
+
+ {
+ setKeyImportStage("key-entry");
+ if (identityLost) {
+ return;
+ }
+ setPage("identity");
+ }}
+ onImport={importExistingIdentity}
+ onStageChange={setKeyImportStage}
+ showBack={!identityLost}
+ variant="spotlight"
+ />
+ {identityLost && keyImportStage === "key-entry" ? (
setKeyImportMethod("key")}
+ className={`${ONBOARDING_SECONDARY_CTA_CLASS} mt-2 px-5`}
+ onClick={() => void replaceLostIdentity()}
type="button"
variant="ghost"
>
- Use a private key or backup instead
+ Start new identity
- {identityLost ? (
- void replaceLostIdentity()}
- type="button"
- variant="ghost"
- >
- Start new identity
-
- ) : (
- setPage("identity")}
- type="button"
- variant="ghost"
- >
- Back
-
- )}
-
- ) : (
-
+ ) : null}
+
+
+
{
+ if (!open) setKeyImportDialog(null);
+ }}
+ open={keyImportDialog === "backup"}
+ >
+
+
+
+ Restore from a backup file
+
+
+ Choose the encrypted backup file you saved from Buzz.
+
{
- setKeyImportStage("key-entry");
- if (identityLost) {
- return;
- }
- setPage("identity");
- }}
+ footerMode="inline"
+ mode="backup"
+ onBack={() => setKeyImportDialog(null)}
onImport={importExistingIdentity}
- onStageChange={setKeyImportStage}
- showBack={!identityLost}
+ showBack={false}
variant="spotlight"
/>
- {identityLost && keyImportStage === "key-entry" ? (
- void replaceLostIdentity()}
- type="button"
- variant="ghost"
- >
- Start new identity
-
- ) : null}
- )}
-
+
+
+
{
+ if (!open) setKeyImportDialog(null);
+ }}
+ open={keyImportDialog === "phone"}
+ >
+
+
+
+ {identityLost
+ ? "Recover from your phone"
+ : "Use your Buzz identity"}
+
+
+ Scan this code with a signed-in Buzz phone.
+
+
+
+
+
+
+
) : page === "backup" ? (
backupSubview === "password" ? (
diff --git a/desktop/src/features/onboarding/ui/NostrKeyImportForm.tsx b/desktop/src/features/onboarding/ui/NostrKeyImportForm.tsx
index 74e0d99088..cb42333b15 100644
--- a/desktop/src/features/onboarding/ui/NostrKeyImportForm.tsx
+++ b/desktop/src/features/onboarding/ui/NostrKeyImportForm.tsx
@@ -31,6 +31,10 @@ type NostrKeyImportFormProps = {
onImport: (nsec: string, password?: string) => Promise
;
onStageChange?: (stage: NostrKeyImportStage) => void;
showBack?: boolean;
+ /** Restrict this instance to selecting a backup file instead of typing a key. */
+ mode?: "key" | "backup";
+ /** Dialogs keep their actions inside the surface instead of the onboarding dock. */
+ footerMode?: "onboarding" | "inline";
/** "spotlight" is the first-launch treatment: glowy centered input, no drop zone, pill buttons. */
variant?: "default" | "spotlight";
};
@@ -50,6 +54,8 @@ export function NostrKeyImportForm({
onImport,
onStageChange,
showBack = true,
+ mode = "key",
+ footerMode = "onboarding",
variant = "default",
}: NostrKeyImportFormProps) {
const [nsecInput, setNsecInput] = React.useState("");
@@ -91,6 +97,7 @@ export function NostrKeyImportForm({
previewNpub === null &&
trimmedInput.length >= 5;
const errorMessage = importError ?? externalErrorMessage;
+ const Footer = footerMode === "inline" ? "div" : OnboardingFooter;
React.useLayoutEffect(() => {
if (isPasswordStage) {
@@ -201,7 +208,7 @@ export function NostrKeyImportForm({
void handleSubmit();
}}
>
- {!isPasswordStage ? (
+ {!isPasswordStage && mode === "key" ? (
{
- void handleFiles(event.currentTarget.files);
- event.currentTarget.value = "";
- }}
- ref={fileInputRef}
- tabIndex={-1}
- type="file"
- />
+ {mode === "backup" || variant !== "spotlight" ? (
+ {
+ void handleFiles(event.currentTarget.files);
+ event.currentTarget.value = "";
+ }}
+ ref={fileInputRef}
+ tabIndex={-1}
+ type="file"
+ />
+ ) : null}
- {!isPasswordStage && variant !== "spotlight" ? (
+ {!isPasswordStage && mode === "backup" ? (
+
+ Choose a backup file
+
+ ) : null}
+
+ {!isPasswordStage && mode === "key" && variant !== "spotlight" ? (
) : null}
-
- void handleSubmit()}
- type="button"
- >
- {isImporting ? (
-
- ) : variant === "spotlight" ? (
- "Next"
- ) : (
- "Continue with this key"
- )}
-
+
+ {mode === "key" || isPasswordStage ? (
+ void handleSubmit()}
+ type="button"
+ >
+ {isImporting ? (
+
+ ) : variant === "spotlight" ? (
+ "Next"
+ ) : (
+ "Continue with this key"
+ )}
+
+ ) : null}
{showBack || isPasswordStage ? (
) : null}
-
+
);
}
diff --git a/desktop/tests/e2e/identity-lost.spec.ts b/desktop/tests/e2e/identity-lost.spec.ts
index 827a778e22..d473a54b62 100644
--- a/desktop/tests/e2e/identity-lost.spec.ts
+++ b/desktop/tests/e2e/identity-lost.spec.ts
@@ -156,10 +156,8 @@ test("phone recovery uses the desktop pairing card semantics", async ({
const copyBox = await copyButton.boundingBox();
expect(qrBox).not.toBeNull();
expect(copyBox).not.toBeNull();
- expect(Math.abs((copyBox?.x ?? 0) - (qrBox?.x ?? 0))).toBeLessThan(0.5);
- expect(Math.abs((copyBox?.width ?? 0) - (qrBox?.width ?? 0))).toBeLessThan(
- 0.5,
- );
+ expect(Math.abs((copyBox?.x ?? 0) - (qrBox?.x ?? 0))).toBeLessThan(1);
+ expect(Math.abs((copyBox?.width ?? 0) - (qrBox?.width ?? 0))).toBeLessThan(1);
await page.evaluate(async () => {
await window.__TAURI_INTERNALS__?.invoke?.("plugin:event|emit", {
diff --git a/desktop/tests/e2e/onboarding.spec.ts b/desktop/tests/e2e/onboarding.spec.ts
index fbff2e3025..f2006d8676 100644
--- a/desktop/tests/e2e/onboarding.spec.ts
+++ b/desktop/tests/e2e/onboarding.spec.ts
@@ -645,12 +645,26 @@ test("fresh existing-identity path leads with private-key recovery", async ({
);
await expect(page.getByTestId("identity-recovery-pairing")).toHaveCount(0);
+ await page.getByTestId("nostr-import-file-button").click();
+ const backupDialog = page.getByTestId("backup-recovery-dialog");
+ await expect(backupDialog).toBeVisible();
+ await expect(
+ backupDialog.getByRole("heading", { name: "Restore from a backup file" }),
+ ).toBeVisible();
+ await expect(
+ backupDialog.getByTestId("nostr-import-backup-picker"),
+ ).toBeVisible();
+ await expect(page.getByTestId("nostr-import-card")).toBeVisible();
+ await backupDialog.getByRole("button", { name: "Close" }).click();
+
await page.getByTestId("nostr-import-phone-link").click();
+ const phoneDialog = page.getByTestId("phone-recovery-dialog");
+ await expect(phoneDialog).toBeVisible();
await expect(
- page.getByRole("heading", { name: "Use your Buzz identity" }),
+ phoneDialog.getByRole("heading", { name: "Use your Buzz identity" }),
).toBeVisible();
- await expect(page.getByTestId("identity-recovery-qr")).toBeVisible();
- await expect(page.getByTestId("nostr-import-card")).toHaveCount(0);
+ await expect(phoneDialog.getByTestId("identity-recovery-qr")).toBeVisible();
+ await expect(page.getByTestId("nostr-import-card")).toBeVisible();
});
test("first-launch key import continues to machine setup", async ({ page }) => {
@@ -740,8 +754,10 @@ test("first-launch import accepts an .ncryptsec backup file", async ({
// exactly the identity.ncryptsec our own save dialog produced. The accept
// attribute is asserted explicitly because setInputFiles bypasses it — the
// OS picker is what filters on it in real use.
- await expect(page.getByTestId("nostr-import-file-button")).toBeVisible();
- const fileInput = page.getByTestId("nostr-import-file-input");
+ await page.getByTestId("nostr-import-file-button").click();
+ const fileInput = page
+ .getByTestId("backup-recovery-dialog")
+ .getByTestId("nostr-import-file-input");
await expect(fileInput).toHaveAttribute(
"accept",
".key,.ncryptsec,text/plain",
@@ -752,9 +768,11 @@ test("first-launch import accepts an .ncryptsec backup file", async ({
mimeType: "text/plain",
name: "not-a-backup.txt",
});
- await expect(page.getByTestId("nostr-import-feedback")).toContainText(
- /too large to be a key backup/i,
- );
+ await expect(
+ page
+ .getByTestId("backup-recovery-dialog")
+ .getByTestId("nostr-import-feedback"),
+ ).toContainText(/too large to be a key backup/i);
// Spec-vector blob the mock bridge accepts with the mock passphrase.
const mockNcryptsec =
@@ -765,31 +783,33 @@ test("first-launch import accepts an .ncryptsec backup file", async ({
name: "identity.ncryptsec",
});
- // File contents advance to the same focused password stage as manual input.
+ // File contents advance to the password stage inside the same dialog.
+ const backupDialog = page.getByTestId("backup-recovery-dialog");
await expect(
- page.getByRole("heading", { name: "Unlock your account" }),
+ backupDialog.getByTestId("backup-password-timeline"),
).toBeVisible();
- await expect(page.getByTestId("backup-password-timeline")).toBeVisible();
- await expect(page.getByTestId("nostr-import-passphrase")).toBeFocused();
+ await expect(
+ backupDialog.getByTestId("nostr-import-passphrase"),
+ ).toBeFocused();
- // Back first returns to key/file selection instead of leaving import.
- await page.getByRole("button", { name: "Back", exact: true }).click();
+ // Back first returns to backup-file selection instead of closing the dialog.
+ await backupDialog.getByRole("button", { name: "Back", exact: true }).click();
await expect(
- page.getByRole("heading", { name: "Enter your private key" }),
+ backupDialog.getByRole("heading", { name: "Restore from a backup file" }),
+ ).toBeVisible();
+ await expect(
+ backupDialog.getByTestId("nostr-import-backup-picker"),
).toBeVisible();
- await expect(page.getByTestId("nostr-import-card")).toBeVisible();
- await expect(page.getByTestId("nostr-import-file-button")).toBeVisible();
- await expect(page.getByTestId("nostr-import-nsec-input")).toHaveValue("");
await fileInput.setInputFiles({
buffer: Buffer.from(`${mockNcryptsec}\n`),
mimeType: "text/plain",
name: "identity.ncryptsec",
});
- await page
+ await backupDialog
.getByTestId("nostr-import-passphrase")
.fill("mock horse battery staple lake orbit");
- await page.getByTestId("nostr-import-submit").click();
+ await backupDialog.getByTestId("nostr-import-submit").click();
await expect(page.getByTestId("onboarding-page-2")).toBeVisible();
await expect(page.getByTestId("machine-onboarding-gate")).toBeVisible();
From 3d81f4e708753e8618a2fe0ed82f6485e1d450bf Mon Sep 17 00:00:00 2001
From: Bart
<52a228d6edf316ec6812ac3c9fc0d696ab59fc7954d77e7be31eedcddf91335b@buzz.block.builderlab.xyz>
Date: Tue, 4 Aug 2026 20:38:39 -0700
Subject: [PATCH 10/20] fix(onboarding): scrim recovery dialogs
Co-authored-by: Taylor Ho
Signed-off-by: Taylor Ho
---
desktop/src/features/onboarding/ui/MachineOnboardingFlow.tsx | 2 --
1 file changed, 2 deletions(-)
diff --git a/desktop/src/features/onboarding/ui/MachineOnboardingFlow.tsx b/desktop/src/features/onboarding/ui/MachineOnboardingFlow.tsx
index 4a36e01b05..3a20e5b5b9 100644
--- a/desktop/src/features/onboarding/ui/MachineOnboardingFlow.tsx
+++ b/desktop/src/features/onboarding/ui/MachineOnboardingFlow.tsx
@@ -382,7 +382,6 @@ export function MachineOnboardingFlow({
closeButtonClassName={ONBOARDING_INK_ICON_CLASS}
data-system-color-scheme="light"
data-testid="backup-recovery-dialog"
- overlayVariant="transparent"
surface="textured"
>
@@ -414,7 +413,6 @@ export function MachineOnboardingFlow({
closeButtonClassName={ONBOARDING_INK_ICON_CLASS}
data-system-color-scheme="light"
data-testid="phone-recovery-dialog"
- overlayVariant="transparent"
surface="textured"
>
From f7d5c4847f357f0f39723f8410607411b550c682 Mon Sep 17 00:00:00 2001
From: Bart
<52a228d6edf316ec6812ac3c9fc0d696ab59fc7954d77e7be31eedcddf91335b@buzz.block.builderlab.xyz>
Date: Tue, 4 Aug 2026 20:45:13 -0700
Subject: [PATCH 11/20] fix(onboarding): polish recovery dialogs
Co-authored-by: Taylor Ho
Signed-off-by: Taylor Ho
---
.../onboarding/ui/IdentityRecoveryPairing.tsx | 29 ++++++++++---------
.../onboarding/ui/MachineOnboardingFlow.tsx | 11 +++++--
desktop/tests/e2e/identity-lost.spec.ts | 17 +++++++++--
3 files changed, 38 insertions(+), 19 deletions(-)
diff --git a/desktop/src/features/onboarding/ui/IdentityRecoveryPairing.tsx b/desktop/src/features/onboarding/ui/IdentityRecoveryPairing.tsx
index 9b08e8e473..d325b4e6f8 100644
--- a/desktop/src/features/onboarding/ui/IdentityRecoveryPairing.tsx
+++ b/desktop/src/features/onboarding/ui/IdentityRecoveryPairing.tsx
@@ -38,8 +38,10 @@ function recoveryErrorMessage(message: string): string {
export function IdentityRecoveryPairing({
onRecovered,
+ onStepChange,
}: {
onRecovered: () => Promise;
+ onStepChange?: (step: Step) => void;
}) {
const [step, setStep] = React.useState("loading");
const [qrUri, setQrUri] = React.useState(null);
@@ -49,6 +51,10 @@ export function IdentityRecoveryPairing({
const active = React.useRef(true);
const copyTimer = React.useRef(null);
+ React.useEffect(() => {
+ onStepChange?.(step);
+ }, [onStepChange, step]);
+
const start = React.useCallback(async () => {
active.current = true;
setStep("loading");
@@ -171,7 +177,7 @@ export function IdentityRecoveryPairing({
- Verify this code matches your mobile device
+ Does this code match your phone?
- Your phone is about to transfer your Buzz identity to this
- desktop. Only confirm if you initiated this pairing.
+ This gives this desktop permanent access to your Buzz identity.
+ Only continue if you trust it.
{step === "qr" && qrUri ? (
void copyPairingCode()}
size="sm"
@@ -254,15 +260,12 @@ export function IdentityRecoveryPairing({
{error}
) : null}
-
- On your phone, open Settings → Send identity to desktop. This code
- expires shortly and works once.
-
-
- Your phone will grant this desktop permanent access to your full Buzz
- identity. Only approve a desktop you trust and verify the six-digit code
- on both screens.
-
+ {step === "qr" || step === "loading" ? (
+
+ On your phone, open Settings → Send identity to desktop. This code
+ expires shortly and works once.
+
+ ) : null}
);
}
diff --git a/desktop/src/features/onboarding/ui/MachineOnboardingFlow.tsx b/desktop/src/features/onboarding/ui/MachineOnboardingFlow.tsx
index 3a20e5b5b9..c0cc2d8f79 100644
--- a/desktop/src/features/onboarding/ui/MachineOnboardingFlow.tsx
+++ b/desktop/src/features/onboarding/ui/MachineOnboardingFlow.tsx
@@ -92,6 +92,7 @@ export function MachineOnboardingFlow({
const [keyImportDialog, setKeyImportDialog] = React.useState<
"backup" | "phone" | null
>(null);
+ const [phoneRecoveryStep, setPhoneRecoveryStep] = React.useState("loading");
const [selectedPubkey, setSelectedPubkey] = React.useState
(
null,
);
@@ -384,7 +385,7 @@ export function MachineOnboardingFlow({
data-testid="backup-recovery-dialog"
surface="textured"
>
-
+
Restore from a backup file
@@ -415,18 +416,22 @@ export function MachineOnboardingFlow({
data-testid="phone-recovery-dialog"
surface="textured"
>
-
+
{identityLost
? "Recover from your phone"
: "Use your Buzz identity"}
- Scan this code with a signed-in Buzz phone.
+ {phoneRecoveryStep === "loading" ||
+ phoneRecoveryStep === "qr"
+ ? "Scan this code with a signed-in Buzz phone."
+ : "Confirm the code before sharing your identity."}
diff --git a/desktop/tests/e2e/identity-lost.spec.ts b/desktop/tests/e2e/identity-lost.spec.ts
index d473a54b62..88e3bd7c8b 100644
--- a/desktop/tests/e2e/identity-lost.spec.ts
+++ b/desktop/tests/e2e/identity-lost.spec.ts
@@ -84,10 +84,10 @@ test("lost boot offers phone recovery with a single-use QR", async ({
await expect(page.getByTestId("identity-recovery-pairing")).toBeVisible();
await expect(page.getByTestId("identity-recovery-qr")).toBeVisible();
await expect(
- page.getByText("This code expires shortly and works once."),
+ page.getByText("Scan this code with a signed-in Buzz phone."),
).toBeVisible();
await expect(
- page.getByText(/grant this desktop permanent access/i),
+ page.getByText("On your phone, open Settings → Send identity to desktop."),
).toBeVisible();
await page.waitForTimeout(1_000); // Let the onboarding entrance motion settle.
await page.screenshot({
@@ -167,8 +167,19 @@ test("phone recovery uses the desktop pairing card semantics", async ({
});
await expect(
- card.getByText("Verify this code matches your mobile device"),
+ card.getByText("Does this code match your phone?"),
).toBeVisible();
+ await expect(
+ page.getByText("Confirm the code before sharing your identity."),
+ ).toBeVisible();
+ await expect(
+ card.getByText(
+ "This gives this desktop permanent access to your Buzz identity. Only continue if you trust it.",
+ ),
+ ).toBeVisible();
+ await expect(
+ card.getByText(/On your phone, open Settings/),
+ ).not.toBeVisible();
await expect(card.getByTestId("identity-recovery-sas")).toHaveText("123 456");
await expect(card.getByTestId("confirm-identity-recovery-sas")).toHaveText(
"Codes match",
From a07673bbc6179f75f3f5458ed1a7a3150d6fcdea Mon Sep 17 00:00:00 2001
From: Bart
<52a228d6edf316ec6812ac3c9fc0d696ab59fc7954d77e7be31eedcddf91335b@buzz.block.builderlab.xyz>
Date: Tue, 4 Aug 2026 21:03:34 -0700
Subject: [PATCH 12/20] feat(onboarding): add backup file drag and drop
Co-authored-by: Taylor Ho
Signed-off-by: Taylor Ho
---
.../onboarding/ui/BackupPasswordTimeline.tsx | 60 ++++++++++++
.../onboarding/ui/NostrKeyImportForm.tsx | 95 +++++++++++++++---
desktop/tests/e2e/onboarding.spec.ts | 97 +++++++++++++++++--
3 files changed, 235 insertions(+), 17 deletions(-)
diff --git a/desktop/src/features/onboarding/ui/BackupPasswordTimeline.tsx b/desktop/src/features/onboarding/ui/BackupPasswordTimeline.tsx
index 610c104d95..555d6e1365 100644
--- a/desktop/src/features/onboarding/ui/BackupPasswordTimeline.tsx
+++ b/desktop/src/features/onboarding/ui/BackupPasswordTimeline.tsx
@@ -46,6 +46,66 @@ const TIMELINE_BOTTOM_DOT_TRANSITIONS = TIMELINE_CONNECTOR_DOTS.map(
}),
);
+export function BackupFileUnlockPreview() {
+ const reduceMotion = useReducedMotion() ?? false;
+
+ return (
+
+
+
+ {BACKUP_KEY_DOTS.map((dot) => (
+
+ ))}
+
+
+
+
+ );
+}
+
+function TimelineDots({
+ reduceMotion,
+ transitions,
+}: {
+ reduceMotion: boolean;
+ transitions: ReadonlyArray<
+ typeof TIMELINE_DOT_TRANSITION & { delay: number }
+ >;
+}) {
+ return (
+
+ {TIMELINE_CONNECTOR_DOTS.map((dot, index) => (
+
+ ))}
+
+ );
+}
+
/**
* Decorative timeline shared by backup creation and encrypted-backup restore.
* Backup creation reads key → password → lock; restore reads encrypted file →
diff --git a/desktop/src/features/onboarding/ui/NostrKeyImportForm.tsx b/desktop/src/features/onboarding/ui/NostrKeyImportForm.tsx
index cb42333b15..a424236eb6 100644
--- a/desktop/src/features/onboarding/ui/NostrKeyImportForm.tsx
+++ b/desktop/src/features/onboarding/ui/NostrKeyImportForm.tsx
@@ -1,5 +1,5 @@
import * as React from "react";
-import { Check, Eye, EyeOff, KeyRound } from "lucide-react";
+import { Check, Eye, EyeOff, FileKey2, KeyRound } from "lucide-react";
import { cn } from "@/shared/lib/cn";
import { nsecToNpub } from "@/shared/lib/nostrUtils";
@@ -16,7 +16,10 @@ import {
ONBOARDING_PRIMARY_CTA_CLASS,
ONBOARDING_SECONDARY_CTA_CLASS,
} from "./OnboardingChrome";
-import { BackupPasswordTimeline } from "./BackupPasswordTimeline";
+import {
+ BackupFileUnlockPreview,
+ BackupPasswordTimeline,
+} from "./BackupPasswordTimeline";
import { OnboardingFooter } from "./OnboardingFooter";
const NOSTR_KEY_FILE_MAX_BYTES = 1024;
@@ -63,6 +66,7 @@ export function NostrKeyImportForm({
const [isImporting, setIsImporting] = React.useState(false);
const [importError, setImportError] = React.useState(null);
const [isDragging, setIsDragging] = React.useState(false);
+ const dragDepthRef = React.useRef(0);
const [isRevealed, setIsRevealed] = React.useState(false);
const inputRef = React.useRef(null);
const passphraseInputRef = React.useRef(null);
@@ -111,6 +115,39 @@ export function NostrKeyImportForm({
onStageChange?.(isPasswordStage ? "backup-password" : "key-entry");
}, [isPasswordStage, onStageChange]);
+ React.useEffect(() => {
+ if (mode !== "backup" || isPasswordStage || isInteractionDisabled) {
+ dragDepthRef.current = 0;
+ setIsDragging(false);
+ return;
+ }
+
+ const handleDragEnter = (event: DragEvent) => {
+ if (!event.dataTransfer?.types.includes("Files")) return;
+ dragDepthRef.current += 1;
+ setIsDragging(true);
+ };
+ const handleDragLeave = () => {
+ dragDepthRef.current = Math.max(0, dragDepthRef.current - 1);
+ if (dragDepthRef.current === 0) setIsDragging(false);
+ };
+ const handleDragEnd = () => {
+ dragDepthRef.current = 0;
+ setIsDragging(false);
+ };
+
+ window.addEventListener("dragenter", handleDragEnter);
+ window.addEventListener("dragleave", handleDragLeave);
+ window.addEventListener("drop", handleDragEnd);
+ window.addEventListener("dragend", handleDragEnd);
+ return () => {
+ window.removeEventListener("dragenter", handleDragEnter);
+ window.removeEventListener("dragleave", handleDragLeave);
+ window.removeEventListener("drop", handleDragEnd);
+ window.removeEventListener("dragend", handleDragEnd);
+ };
+ }, [isInteractionDisabled, isPasswordStage, mode]);
+
const openFilePicker = React.useCallback(() => {
if (isInteractionDisabled) {
return;
@@ -203,6 +240,21 @@ export function NostrKeyImportForm({
return (
)}
- {step === "qr" && qrUri ? (
+ {step === "loading" || (step === "qr" && qrUri) ? (
void copyPairingCode()}
size="sm"
type="button"
variant="outline"
>
- {copied ? (
+ {step === "loading" ? (
+
+ ) : copied ? (
) : (
)}
- {copied ? "Copied" : "Copy pairing code"}
+ {step === "loading"
+ ? "Generating pairing code..."
+ : copied
+ ? "Copied"
+ : "Copy pairing code"}
) : null}
{step === "qr" && error ? (
diff --git a/desktop/tests/e2e/identity-lost.spec.ts b/desktop/tests/e2e/identity-lost.spec.ts
index 88e3bd7c8b..2ab41cb52d 100644
--- a/desktop/tests/e2e/identity-lost.spec.ts
+++ b/desktop/tests/e2e/identity-lost.spec.ts
@@ -70,6 +70,33 @@ test("lost boot opens onboarding gate directly on the key-import page", async ({
});
});
+test("lost boot keeps the pairing-code action stable while generating", async ({
+ page,
+}) => {
+ await installMockBridge(
+ page,
+ { identityLost: true, pairingStartDelayMs: 2_500 },
+ { skipOnboardingSeed: true },
+ );
+ await page.goto("/");
+
+ await page.getByTestId("nostr-import-phone-link").click();
+ const copyButton = page.getByTestId("copy-identity-recovery-code");
+ await expect(copyButton).toBeVisible();
+ await expect(copyButton).toBeDisabled();
+ await expect(copyButton).toHaveText("Generating pairing code...");
+ const loadingButton = await copyButton.elementHandle();
+
+ await expect(copyButton).toBeEnabled();
+ await expect(copyButton).toHaveText("Copy pairing code");
+ expect(
+ await copyButton.evaluate(
+ (button, loading) => button === loading,
+ loadingButton,
+ ),
+ ).toBe(true);
+});
+
test("lost boot offers phone recovery with a single-use QR", async ({
page,
}, testInfo) => {
@@ -187,6 +214,17 @@ test("phone recovery uses the desktop pairing card semantics", async ({
await expect(card.getByTestId("deny-identity-recovery-sas")).toHaveText(
"Cancel",
);
+ const cancelBox = await card
+ .getByTestId("deny-identity-recovery-sas")
+ .boundingBox();
+ const confirmBox = await card
+ .getByTestId("confirm-identity-recovery-sas")
+ .boundingBox();
+ expect(cancelBox).not.toBeNull();
+ expect(confirmBox).not.toBeNull();
+ expect((cancelBox?.y ?? 0) - (confirmBox?.y ?? 0)).toBeGreaterThan(
+ confirmBox?.height ?? 0,
+ );
});
test("canceling recovery uses the standard pairing cancellation state", async ({
From aaf51763a5856cd8e28fcd2ce0f084f3aaa50715 Mon Sep 17 00:00:00 2001
From: Carl
Date: Tue, 4 Aug 2026 22:38:42 -0700
Subject: [PATCH 14/20] fix(pairing): harden recovery session transitions
Co-authored-by: Taylor Ho
Signed-off-by: Taylor Ho
---
desktop/src-tauri/src/commands/pairing.rs | 67 ++++++++++++++++++-
mobile/lib/app.dart | 7 +-
.../lib/features/settings/settings_page.dart | 12 +++-
.../settings_page/connection_section.dart | 11 ++-
4 files changed, 83 insertions(+), 14 deletions(-)
diff --git a/desktop/src-tauri/src/commands/pairing.rs b/desktop/src-tauri/src/commands/pairing.rs
index 777db384f6..cf78494a59 100644
--- a/desktop/src-tauri/src/commands/pairing.rs
+++ b/desktop/src-tauri/src/commands/pairing.rs
@@ -50,6 +50,9 @@ struct PairingTaskContext {
pub struct PairingHandle {
session: Arc>>,
generation: Arc,
+ /// Serializes session setup so an older start cannot resume after relay
+ /// discovery and overwrite a newer session's shared state.
+ start_lock: tokio::sync::Mutex<()>,
cancel: std::sync::Mutex>,
/// Send JSON-serialized events to the background WS task for relay publication.
outbound_tx: std::sync::Mutex >>,
@@ -64,6 +67,7 @@ impl PairingHandle {
Self {
session: Arc::new(tokio::sync::Mutex::new(None)),
generation: Arc::new(AtomicU64::new(0)),
+ start_lock: tokio::sync::Mutex::new(()),
cancel: std::sync::Mutex::new(None),
outbound_tx: std::sync::Mutex::new(None),
payload: std::sync::Mutex::new(None),
@@ -105,6 +109,7 @@ async fn start_pairing_session(
pairing: State<'_, PairingHandle>,
mode: PairingMode,
) -> Result {
+ let _start_guard = pairing.start_lock.lock().await;
let task_generation = pairing
.generation
.fetch_add(1, Ordering::SeqCst)
@@ -369,7 +374,27 @@ async fn pairing_ws_task_inner(
}
if context.mode == PairingMode::RecoverIdentity {
- if let Ok((PayloadType::Nsec, payload)) = s.handle_return_payload(&event) {
+ if let Ok((payload_type, payload)) = s.handle_return_payload(&event) {
+ if let Err(message) = validate_recovery_payload_type(payload_type) {
+ let complete = s
+ .send_source_complete(false)
+ .map_err(|e| e.to_string())?;
+ write
+ .send(Message::Text(event_to_relay_json(&complete).into()))
+ .await
+ .map_err(|e| format!("publish complete failed: {e}"))?;
+ if pairing_task_is_current(
+ &context.generation,
+ context.task_generation,
+ ) {
+ let _ = app.emit(
+ "pairing-error",
+ PairingErrorPayload { message },
+ );
+ }
+ break;
+ }
+
let imported = import_recovered_identity(app, payload).await;
let success = imported.is_ok();
let complete = s
@@ -448,6 +473,14 @@ fn pairing_task_is_current(generation: &AtomicU64, task_generation: u64) -> bool
generation.load(Ordering::SeqCst) == task_generation
}
+fn validate_recovery_payload_type(payload_type: PayloadType) -> Result<(), String> {
+ if payload_type == PayloadType::Nsec {
+ Ok(())
+ } else {
+ Err("Mobile device sent an unsupported recovery payload".into())
+ }
+}
+
async fn clear_pairing_session_if_current(
session: &Arc>>,
generation: &AtomicU64,
@@ -672,8 +705,38 @@ where
mod pairing_generation_tests {
use std::sync::atomic::{AtomicU64, Ordering};
use std::sync::Arc;
+ use std::time::Duration;
- use super::{clear_pairing_session_if_current, PairingSession};
+ use super::{
+ clear_pairing_session_if_current, validate_recovery_payload_type, PairingHandle,
+ PairingSession, PayloadType,
+ };
+
+ #[tokio::test]
+ async fn overlapping_starts_are_serialized() {
+ let pairing = Arc::new(PairingHandle::new());
+ let first_pairing = Arc::clone(&pairing);
+ let (locked_tx, locked_rx) = tokio::sync::oneshot::channel();
+ let first = tokio::spawn(async move {
+ let _guard = first_pairing.start_lock.lock().await;
+ locked_tx.send(()).expect("signal acquired start lock");
+ tokio::time::sleep(Duration::from_millis(50)).await;
+ });
+
+ locked_rx.await.expect("first start acquired lock");
+ assert!(pairing.start_lock.try_lock().is_err());
+ first.await.expect("first start task");
+ assert!(pairing.start_lock.try_lock().is_ok());
+ }
+
+ #[test]
+ fn recovery_rejects_non_nsec_payloads() {
+ assert!(validate_recovery_payload_type(PayloadType::Nsec).is_ok());
+ assert_eq!(
+ validate_recovery_payload_type(PayloadType::Custom).unwrap_err(),
+ "Mobile device sent an unsupported recovery payload"
+ );
+ }
#[tokio::test]
async fn stale_task_does_not_clear_replacement_session() {
diff --git a/mobile/lib/app.dart b/mobile/lib/app.dart
index 057594dfad..d5ae326afa 100644
--- a/mobile/lib/app.dart
+++ b/mobile/lib/app.dart
@@ -147,8 +147,11 @@ class App extends HookConsumerWidget {
}
}
-Widget _buildSettingsPage(BuildContext context) =>
- const SettingsPage(profileHeader: SettingsProfileHeader());
+Widget _buildSettingsPage(BuildContext context) => SettingsPage(
+ profileHeader: const SettingsProfileHeader(),
+ identityRecoveryPageBuilder: (_) =>
+ const PairingPage(addingCommunity: true, identityRecoveryOnly: true),
+);
class _SplashScreen extends StatelessWidget {
const _SplashScreen();
diff --git a/mobile/lib/features/settings/settings_page.dart b/mobile/lib/features/settings/settings_page.dart
index 3c9797f15a..066dd1fd3d 100644
--- a/mobile/lib/features/settings/settings_page.dart
+++ b/mobile/lib/features/settings/settings_page.dart
@@ -8,7 +8,6 @@ import 'package:lucide_icons_flutter/lucide_icons.dart';
import 'package:nostr/nostr.dart' as nostr;
import 'package:package_info_plus/package_info_plus.dart';
-import '../../features/pairing/pairing_page.dart';
import '../../shared/auth/auth.dart';
import '../../shared/clipboard_utils.dart';
import '../../shared/relay/relay.dart';
@@ -25,9 +24,14 @@ part 'settings_page/appearance_section.dart';
part 'settings_page/connection_section.dart';
class SettingsPage extends HookConsumerWidget {
- const SettingsPage({super.key, required this.profileHeader});
+ const SettingsPage({
+ super.key,
+ required this.profileHeader,
+ required this.identityRecoveryPageBuilder,
+ });
final Widget profileHeader;
+ final WidgetBuilder identityRecoveryPageBuilder;
@override
Widget build(BuildContext context, WidgetRef ref) {
@@ -68,7 +72,9 @@ class SettingsPage extends HookConsumerWidget {
children: [
profileHeader,
const _AppearanceSection(),
- const _ConnectionSection(),
+ _ConnectionSection(
+ identityRecoveryPageBuilder: identityRecoveryPageBuilder,
+ ),
const _RemoveCommunitySection(),
],
),
diff --git a/mobile/lib/features/settings/settings_page/connection_section.dart b/mobile/lib/features/settings/settings_page/connection_section.dart
index d155b789a5..631f870abc 100644
--- a/mobile/lib/features/settings/settings_page/connection_section.dart
+++ b/mobile/lib/features/settings/settings_page/connection_section.dart
@@ -1,7 +1,9 @@
part of '../settings_page.dart';
class _ConnectionSection extends ConsumerWidget {
- const _ConnectionSection();
+ const _ConnectionSection({required this.identityRecoveryPageBuilder});
+
+ final WidgetBuilder identityRecoveryPageBuilder;
@override
Widget build(BuildContext context, WidgetRef ref) {
@@ -24,12 +26,7 @@ class _ConnectionSection extends ConsumerWidget {
subtitle: 'Scan a recovery code shown by Buzz Desktop',
trailing: const _RowChevron(),
onTap: () => Navigator.of(context).push(
- MaterialPageRoute(
- builder: (_) => const PairingPage(
- addingCommunity: true,
- identityRecoveryOnly: true,
- ),
- ),
+ MaterialPageRoute(builder: identityRecoveryPageBuilder),
),
),
],
From 9beaf69bbeed80af314c55c85a83c5792e819e48 Mon Sep 17 00:00:00 2001
From: Taylor Ho
Date: Wed, 5 Aug 2026 10:46:23 -0700
Subject: [PATCH 15/20] chore(desktop): format pairing command registration
Co-authored-by: Taylor Ho
Signed-off-by: Taylor Ho
---
desktop/src-tauri/src/lib.rs | 3 ++-
1 file changed, 2 insertions(+), 1 deletion(-)
diff --git a/desktop/src-tauri/src/lib.rs b/desktop/src-tauri/src/lib.rs
index 2468c858cd..4f935631b6 100644
--- a/desktop/src-tauri/src/lib.rs
+++ b/desktop/src-tauri/src/lib.rs
@@ -879,7 +879,8 @@ pub fn run() {
list_audio_output_devices,
set_audio_output_device,
get_audio_output_device,
- start_pairing, start_identity_recovery_pairing,
+ start_pairing,
+ start_identity_recovery_pairing,
confirm_pairing_sas,
cancel_pairing,
apply_workspace,
From 02520d540ee9477088ca2e9da3bd95811f76955a Mon Sep 17 00:00:00 2001
From: Taylor Ho
Date: Wed, 5 Aug 2026 11:08:15 -0700
Subject: [PATCH 16/20] test(pairing): preserve standard pairing start delay
Co-authored-by: Taylor Ho
Signed-off-by: Taylor Ho
---
desktop/src/testing/e2eBridge.ts | 7 ++++++-
1 file changed, 6 insertions(+), 1 deletion(-)
diff --git a/desktop/src/testing/e2eBridge.ts b/desktop/src/testing/e2eBridge.ts
index 566936afa0..750a57ccaf 100644
--- a/desktop/src/testing/e2eBridge.ts
+++ b/desktop/src/testing/e2eBridge.ts
@@ -12742,8 +12742,13 @@ export function maybeInstallE2eTauriMocks() {
case "plugin:webview|set_webview_zoom":
window.__BUZZ_E2E_WEBVIEW_ZOOM__ = (payload as { value: number }).value;
return;
- case "start_pairing":
+ case "start_pairing": {
+ const delayMs = activeConfig?.mock?.pairingStartDelayMs ?? 0;
+ if (delayMs > 0) {
+ await new Promise((resolve) => window.setTimeout(resolve, delayMs));
+ }
return "nostrpair://8f4b8db31967ce14fef970a1ff1e8eecf19a430aa1c83875e2f5be68dcac0f1a?relay=wss%3A%2F%2Frelay.example.com&secret=87d5a8cfd5807a0cb44f728b67d88d6dcb8daf99be137c158f21a50c1e913c0a&v=1";
+ }
case "start_identity_recovery_pairing": {
const delayMs = activeConfig?.mock?.pairingStartDelayMs ?? 0;
if (delayMs > 0) {
From 7c53a1f2bffd76827bb4cad31f62a3be4d1e8fbd Mon Sep 17 00:00:00 2001
From: Taylor Ho
Date: Wed, 5 Aug 2026 14:41:53 -0700
Subject: [PATCH 17/20] fix(pairing): fence recovered identity commits
Co-authored-by: Taylor Ho
Signed-off-by: Taylor Ho
---
desktop/src-tauri/src/commands/pairing.rs | 189 +++++++++++++++++-----
1 file changed, 152 insertions(+), 37 deletions(-)
diff --git a/desktop/src-tauri/src/commands/pairing.rs b/desktop/src-tauri/src/commands/pairing.rs
index cf78494a59..1f3576a8c1 100644
--- a/desktop/src-tauri/src/commands/pairing.rs
+++ b/desktop/src-tauri/src/commands/pairing.rs
@@ -225,6 +225,14 @@ pub async fn confirm_pairing_sas(pairing: State<'_, PairingHandle>) -> Result<()
/// Cancel the active pairing session.
#[tauri::command]
pub async fn cancel_pairing(pairing: State<'_, PairingHandle>) -> Result<(), String> {
+ // Invalidate the task before waiting for its session lock. Recovery may be
+ // blocked on identity persistence after releasing this lock, and must see
+ // cancellation before crossing the durable commit boundary.
+ pairing.generation.fetch_add(1, Ordering::SeqCst);
+ if let Some(token) = pairing.cancel.lock().map_err(|e| e.to_string())?.take() {
+ token.cancel();
+ }
+
let abort_json = {
let mut guard = pairing.session.lock().await;
if let Some(session) = guard.as_mut() {
@@ -249,11 +257,6 @@ pub async fn cancel_pairing(pairing: State<'_, PairingHandle>) -> Result<(), Str
}
}
- pairing.generation.fetch_add(1, Ordering::SeqCst);
-
- if let Some(token) = pairing.cancel.lock().map_err(|e| e.to_string())?.take() {
- token.cancel();
- }
pairing.clear();
{
@@ -395,27 +398,34 @@ async fn pairing_ws_task_inner(
break;
}
- let imported = import_recovered_identity(app, payload).await;
+ let payload = payload;
+ drop(guard);
+
+ let imported = import_recovered_identity(
+ app,
+ payload,
+ &context.generation,
+ context.task_generation,
+ )
+ .await;
let success = imported.is_ok();
- let complete = s
- .send_source_complete(success)
- .map_err(|e| e.to_string())?;
- write
+ let complete = {
+ let mut guard = session.lock().await;
+ if !pairing_task_is_current(
+ &context.generation,
+ context.task_generation,
+ ) {
+ break;
+ }
+ let Some(s) = guard.as_mut() else { break };
+ s.send_source_complete(success)
+ .map_err(|e| e.to_string())?
+ };
+ let completion_result = write
.send(Message::Text(event_to_relay_json(&complete).into()))
.await
- .map_err(|e| format!("publish complete failed: {e}"))?;
- match imported {
- Ok(()) => {
- if pairing_task_is_current(&context.generation, context.task_generation) {
- let _ = app.emit("pairing-complete", serde_json::json!({}));
- }
- }
- Err(message) => {
- if pairing_task_is_current(&context.generation, context.task_generation) {
- let _ = app.emit("pairing-error", PairingErrorPayload { message });
- }
- }
- }
+ .map_err(|e| format!("publish complete failed: {e}"));
+ finish_recovery(imported, completion_result, context, app)?;
break;
}
} else {
@@ -445,30 +455,87 @@ async fn pairing_ws_task_inner(
Ok(())
}
-async fn import_recovered_identity(app: &AppHandle, nsec: Zeroizing) -> Result<(), String> {
+async fn import_recovered_identity(
+ app: &AppHandle,
+ nsec: Zeroizing,
+ generation: &Arc,
+ task_generation: u64,
+) -> Result<(), String> {
let app = app.clone();
+ let generation = Arc::clone(generation);
tokio::task::spawn_blocking(move || {
let keys = nostr::Keys::parse(nsec.trim())
.map_err(|e| format!("Phone sent an invalid identity: {e}"))?;
let state = app.state::();
let _mutation_guard = state.identity_mutation.lock().map_err(|e| e.to_string())?;
- let data_dir = app
- .path()
- .app_data_dir()
- .map_err(|e| format!("app data dir: {e}"))?;
- std::fs::create_dir_all(&data_dir).map_err(|e| format!("create app data dir: {e}"))?;
- let key_path = data_dir.join("identity.key");
- crate::commands::identity::commit_imported_identity(&state, &data_dir, keys, |keys| {
- let store =
- crate::secret_store::SecretStore::shared(crate::app_state::keyring_service());
- crate::app_state::persist_imported_identity(store, keys, &key_path, &data_dir)
- })?;
- Ok(())
+ commit_recovery_if_current(&generation, task_generation, || {
+ let data_dir = app
+ .path()
+ .app_data_dir()
+ .map_err(|e| format!("app data dir: {e}"))?;
+ std::fs::create_dir_all(&data_dir).map_err(|e| format!("create app data dir: {e}"))?;
+ let key_path = data_dir.join("identity.key");
+ crate::commands::identity::commit_imported_identity(&state, &data_dir, keys, |keys| {
+ let store =
+ crate::secret_store::SecretStore::shared(crate::app_state::keyring_service());
+ crate::app_state::persist_imported_identity(store, keys, &key_path, &data_dir)
+ })?;
+ Ok(())
+ })
})
.await
.map_err(|e| format!("identity recovery task failed: {e}"))?
}
+fn ensure_pairing_task_is_current(
+ generation: &AtomicU64,
+ task_generation: u64,
+) -> Result<(), String> {
+ if pairing_task_is_current(generation, task_generation) {
+ Ok(())
+ } else {
+ Err("Pairing session was superseded or cancelled".into())
+ }
+}
+
+fn commit_recovery_if_current(
+ generation: &AtomicU64,
+ task_generation: u64,
+ commit: impl FnOnce() -> Result,
+) -> Result {
+ ensure_pairing_task_is_current(generation, task_generation)?;
+ commit()
+}
+
+fn recovery_result_after_completion(
+ imported: Result<(), String>,
+ _completion_result: Result<(), String>,
+) -> Result<(), String> {
+ // Once the identity is durable, notifying the peer cannot roll it back.
+ imported
+}
+
+fn finish_recovery(
+ imported: Result<(), String>,
+ completion_result: Result<(), String>,
+ context: &PairingTaskContext,
+ app: &AppHandle,
+) -> Result<(), String> {
+ if !pairing_task_is_current(&context.generation, context.task_generation) {
+ return Ok(());
+ }
+
+ match recovery_result_after_completion(imported, completion_result) {
+ Ok(()) => {
+ let _ = app.emit("pairing-complete", serde_json::json!({}));
+ }
+ Err(message) => {
+ let _ = app.emit("pairing-error", PairingErrorPayload { message });
+ }
+ }
+ Ok(())
+}
+
fn pairing_task_is_current(generation: &AtomicU64, task_generation: u64) -> bool {
generation.load(Ordering::SeqCst) == task_generation
}
@@ -708,7 +775,8 @@ mod pairing_generation_tests {
use std::time::Duration;
use super::{
- clear_pairing_session_if_current, validate_recovery_payload_type, PairingHandle,
+ clear_pairing_session_if_current, commit_recovery_if_current,
+ recovery_result_after_completion, validate_recovery_payload_type, PairingHandle,
PairingSession, PayloadType,
};
@@ -738,6 +806,53 @@ mod pairing_generation_tests {
);
}
+ #[test]
+ fn superseded_recovery_cannot_commit_identity() {
+ let generation = AtomicU64::new(2);
+ let committed = std::sync::atomic::AtomicBool::new(false);
+
+ let result = commit_recovery_if_current(&generation, 1, || {
+ committed.store(true, Ordering::SeqCst);
+ Ok(())
+ });
+
+ assert_eq!(
+ result.unwrap_err(),
+ "Pairing session was superseded or cancelled"
+ );
+ assert!(!committed.load(Ordering::SeqCst));
+ }
+
+ #[test]
+ fn cancelled_recovery_waiting_for_mutation_cannot_commit_identity() {
+ let generation = Arc::new(AtomicU64::new(7));
+ let mutation = Arc::new(std::sync::Mutex::new(()));
+ let blocker = mutation.lock().expect("lock identity mutation");
+ let (started_tx, started_rx) = std::sync::mpsc::channel();
+ let task_generation = 7;
+ let task_mutation = Arc::clone(&mutation);
+ let task_generation_state = Arc::clone(&generation);
+ let recovery = std::thread::spawn(move || {
+ started_tx.send(()).expect("signal recovery started");
+ let _guard = task_mutation.lock().expect("wait for identity mutation");
+ commit_recovery_if_current(&task_generation_state, task_generation, || Ok(()))
+ });
+
+ started_rx.recv().expect("recovery started");
+ generation.fetch_add(1, Ordering::SeqCst);
+ drop(blocker);
+
+ assert_eq!(
+ recovery.join().expect("recovery task").unwrap_err(),
+ "Pairing session was superseded or cancelled"
+ );
+ }
+
+ #[test]
+ fn completion_publish_failure_does_not_undo_successful_import() {
+ assert!(recovery_result_after_completion(Ok(()), Err("socket closed".into())).is_ok());
+ }
+
#[tokio::test]
async fn stale_task_does_not_clear_replacement_session() {
let (initial, _) = PairingSession::new_source("ws://initial.example".to_string());
From db4ae235519e573052b63a4d23605423d28bf444 Mon Sep 17 00:00:00 2001
From: Taylor Ho
Date: Wed, 5 Aug 2026 16:25:36 -0700
Subject: [PATCH 18/20] fix(pairing): linearize recovery commit invalidation
Co-authored-by: Taylor Ho
Signed-off-by: Taylor Ho
---
desktop/src-tauri/src/commands/pairing.rs | 89 ++++++++++++++++-------
1 file changed, 63 insertions(+), 26 deletions(-)
diff --git a/desktop/src-tauri/src/commands/pairing.rs b/desktop/src-tauri/src/commands/pairing.rs
index 1f3576a8c1..f44aa366ee 100644
--- a/desktop/src-tauri/src/commands/pairing.rs
+++ b/desktop/src-tauri/src/commands/pairing.rs
@@ -43,6 +43,7 @@ enum PairingMode {
struct PairingTaskContext {
mode: PairingMode,
generation: Arc,
+ generation_fence: Arc>,
task_generation: u64,
}
@@ -50,6 +51,8 @@ struct PairingTaskContext {
pub struct PairingHandle {
session: Arc>>,
generation: Arc,
+ /// Linearizes cancellation/replacement against recovered identity commits.
+ generation_fence: Arc>,
/// Serializes session setup so an older start cannot resume after relay
/// discovery and overwrite a newer session's shared state.
start_lock: tokio::sync::Mutex<()>,
@@ -67,6 +70,7 @@ impl PairingHandle {
Self {
session: Arc::new(tokio::sync::Mutex::new(None)),
generation: Arc::new(AtomicU64::new(0)),
+ generation_fence: Arc::new(std::sync::Mutex::new(())),
start_lock: tokio::sync::Mutex::new(()),
cancel: std::sync::Mutex::new(None),
outbound_tx: std::sync::Mutex::new(None),
@@ -110,10 +114,8 @@ async fn start_pairing_session(
mode: PairingMode,
) -> Result {
let _start_guard = pairing.start_lock.lock().await;
- let task_generation = pairing
- .generation
- .fetch_add(1, Ordering::SeqCst)
- .wrapping_add(1);
+ let task_generation =
+ invalidate_pairing_generation(&pairing.generation, &pairing.generation_fence)?;
if let Some(token) = pairing.cancel.lock().map_err(|e| e.to_string())?.take() {
token.cancel();
}
@@ -165,6 +167,7 @@ async fn start_pairing_session(
PairingTaskContext {
mode,
generation: Arc::clone(&pairing.generation),
+ generation_fence: Arc::clone(&pairing.generation_fence),
task_generation,
},
cancel,
@@ -228,7 +231,7 @@ pub async fn cancel_pairing(pairing: State<'_, PairingHandle>) -> Result<(), Str
// Invalidate the task before waiting for its session lock. Recovery may be
// blocked on identity persistence after releasing this lock, and must see
// cancellation before crossing the durable commit boundary.
- pairing.generation.fetch_add(1, Ordering::SeqCst);
+ invalidate_pairing_generation(&pairing.generation, &pairing.generation_fence)?;
if let Some(token) = pairing.cancel.lock().map_err(|e| e.to_string())?.take() {
token.cancel();
}
@@ -405,6 +408,7 @@ async fn pairing_ws_task_inner(
app,
payload,
&context.generation,
+ &context.generation_fence,
context.task_generation,
)
.await;
@@ -459,16 +463,18 @@ async fn import_recovered_identity(
app: &AppHandle,
nsec: Zeroizing,
generation: &Arc,
+ generation_fence: &Arc>,
task_generation: u64,
) -> Result<(), String> {
let app = app.clone();
let generation = Arc::clone(generation);
+ let generation_fence = Arc::clone(generation_fence);
tokio::task::spawn_blocking(move || {
let keys = nostr::Keys::parse(nsec.trim())
.map_err(|e| format!("Phone sent an invalid identity: {e}"))?;
let state = app.state::();
let _mutation_guard = state.identity_mutation.lock().map_err(|e| e.to_string())?;
- commit_recovery_if_current(&generation, task_generation, || {
+ commit_recovery_if_current(&generation, &generation_fence, task_generation, || {
let data_dir = app
.path()
.app_data_dir()
@@ -498,11 +504,21 @@ fn ensure_pairing_task_is_current(
}
}
+fn invalidate_pairing_generation(
+ generation: &AtomicU64,
+ generation_fence: &std::sync::Mutex<()>,
+) -> Result {
+ let _fence = generation_fence.lock().map_err(|e| e.to_string())?;
+ Ok(generation.fetch_add(1, Ordering::SeqCst).wrapping_add(1))
+}
+
fn commit_recovery_if_current(
generation: &AtomicU64,
+ generation_fence: &std::sync::Mutex<()>,
task_generation: u64,
commit: impl FnOnce() -> Result,
) -> Result {
+ let _fence = generation_fence.lock().map_err(|e| e.to_string())?;
ensure_pairing_task_is_current(generation, task_generation)?;
commit()
}
@@ -776,8 +792,8 @@ mod pairing_generation_tests {
use super::{
clear_pairing_session_if_current, commit_recovery_if_current,
- recovery_result_after_completion, validate_recovery_payload_type, PairingHandle,
- PairingSession, PayloadType,
+ invalidate_pairing_generation, recovery_result_after_completion,
+ validate_recovery_payload_type, PairingHandle, PairingSession, PayloadType,
};
#[tokio::test]
@@ -811,7 +827,8 @@ mod pairing_generation_tests {
let generation = AtomicU64::new(2);
let committed = std::sync::atomic::AtomicBool::new(false);
- let result = commit_recovery_if_current(&generation, 1, || {
+ let generation_fence = std::sync::Mutex::new(());
+ let result = commit_recovery_if_current(&generation, &generation_fence, 1, || {
committed.store(true, Ordering::SeqCst);
Ok(())
});
@@ -824,28 +841,48 @@ mod pairing_generation_tests {
}
#[test]
- fn cancelled_recovery_waiting_for_mutation_cannot_commit_identity() {
+ fn invalidation_after_check_waits_for_identity_commit() {
let generation = Arc::new(AtomicU64::new(7));
- let mutation = Arc::new(std::sync::Mutex::new(()));
- let blocker = mutation.lock().expect("lock identity mutation");
- let (started_tx, started_rx) = std::sync::mpsc::channel();
- let task_generation = 7;
- let task_mutation = Arc::clone(&mutation);
- let task_generation_state = Arc::clone(&generation);
+ let generation_fence = Arc::new(std::sync::Mutex::new(()));
+ let (checked_tx, checked_rx) = std::sync::mpsc::channel();
+ let (finish_tx, finish_rx) = std::sync::mpsc::channel();
+ let committed = Arc::new(std::sync::atomic::AtomicBool::new(false));
+
+ let recovery_generation = Arc::clone(&generation);
+ let recovery_fence = Arc::clone(&generation_fence);
+ let recovery_committed = Arc::clone(&committed);
let recovery = std::thread::spawn(move || {
- started_tx.send(()).expect("signal recovery started");
- let _guard = task_mutation.lock().expect("wait for identity mutation");
- commit_recovery_if_current(&task_generation_state, task_generation, || Ok(()))
+ commit_recovery_if_current(&recovery_generation, &recovery_fence, 7, || {
+ checked_tx.send(()).expect("signal generation checked");
+ finish_rx.recv().expect("release identity commit");
+ recovery_committed.store(true, Ordering::SeqCst);
+ Ok(())
+ })
});
- started_rx.recv().expect("recovery started");
- generation.fetch_add(1, Ordering::SeqCst);
- drop(blocker);
+ checked_rx.recv().expect("generation checked");
+ let invalidation_generation = Arc::clone(&generation);
+ let invalidation_fence = Arc::clone(&generation_fence);
+ let (attempted_tx, attempted_rx) = std::sync::mpsc::channel();
+ let (invalidated_tx, invalidated_rx) = std::sync::mpsc::channel();
+ let invalidation = std::thread::spawn(move || {
+ attempted_tx.send(()).expect("signal invalidation attempt");
+ let next = invalidate_pairing_generation(&invalidation_generation, &invalidation_fence)
+ .expect("invalidate generation");
+ invalidated_tx.send(next).expect("signal invalidated");
+ });
- assert_eq!(
- recovery.join().expect("recovery task").unwrap_err(),
- "Pairing session was superseded or cancelled"
- );
+ attempted_rx.recv().expect("invalidation attempted");
+ assert!(invalidated_rx
+ .recv_timeout(Duration::from_millis(50))
+ .is_err());
+ assert!(!committed.load(Ordering::SeqCst));
+
+ finish_tx.send(()).expect("finish identity commit");
+ recovery.join().expect("recovery task").unwrap();
+ assert!(committed.load(Ordering::SeqCst));
+ assert_eq!(invalidated_rx.recv().expect("invalidation completed"), 8);
+ invalidation.join().expect("invalidation task");
}
#[test]
From 76af3f8b811036a7131d7716cc5a8a2768fb99ea Mon Sep 17 00:00:00 2001
From: Taylor Ho
Date: Wed, 5 Aug 2026 16:39:03 -0700
Subject: [PATCH 19/20] test(pairing): split command unit coverage
Co-authored-by: Taylor Ho
Signed-off-by: Taylor Ho
---
desktop/src-tauri/src/commands/pairing.rs | 241 +-----------------
.../src/commands/pairing_generation_tests.rs | 129 ++++++++++
.../src/commands/pairing_relay_tests.rs | 104 ++++++++
3 files changed, 237 insertions(+), 237 deletions(-)
create mode 100644 desktop/src-tauri/src/commands/pairing_generation_tests.rs
create mode 100644 desktop/src-tauri/src/commands/pairing_relay_tests.rs
diff --git a/desktop/src-tauri/src/commands/pairing.rs b/desktop/src-tauri/src/commands/pairing.rs
index f44aa366ee..aedd67854c 100644
--- a/desktop/src-tauri/src/commands/pairing.rs
+++ b/desktop/src-tauri/src/commands/pairing.rs
@@ -785,242 +785,9 @@ where
}
#[cfg(test)]
-mod pairing_generation_tests {
- use std::sync::atomic::{AtomicU64, Ordering};
- use std::sync::Arc;
- use std::time::Duration;
-
- use super::{
- clear_pairing_session_if_current, commit_recovery_if_current,
- invalidate_pairing_generation, recovery_result_after_completion,
- validate_recovery_payload_type, PairingHandle, PairingSession, PayloadType,
- };
-
- #[tokio::test]
- async fn overlapping_starts_are_serialized() {
- let pairing = Arc::new(PairingHandle::new());
- let first_pairing = Arc::clone(&pairing);
- let (locked_tx, locked_rx) = tokio::sync::oneshot::channel();
- let first = tokio::spawn(async move {
- let _guard = first_pairing.start_lock.lock().await;
- locked_tx.send(()).expect("signal acquired start lock");
- tokio::time::sleep(Duration::from_millis(50)).await;
- });
-
- locked_rx.await.expect("first start acquired lock");
- assert!(pairing.start_lock.try_lock().is_err());
- first.await.expect("first start task");
- assert!(pairing.start_lock.try_lock().is_ok());
- }
-
- #[test]
- fn recovery_rejects_non_nsec_payloads() {
- assert!(validate_recovery_payload_type(PayloadType::Nsec).is_ok());
- assert_eq!(
- validate_recovery_payload_type(PayloadType::Custom).unwrap_err(),
- "Mobile device sent an unsupported recovery payload"
- );
- }
-
- #[test]
- fn superseded_recovery_cannot_commit_identity() {
- let generation = AtomicU64::new(2);
- let committed = std::sync::atomic::AtomicBool::new(false);
-
- let generation_fence = std::sync::Mutex::new(());
- let result = commit_recovery_if_current(&generation, &generation_fence, 1, || {
- committed.store(true, Ordering::SeqCst);
- Ok(())
- });
-
- assert_eq!(
- result.unwrap_err(),
- "Pairing session was superseded or cancelled"
- );
- assert!(!committed.load(Ordering::SeqCst));
- }
-
- #[test]
- fn invalidation_after_check_waits_for_identity_commit() {
- let generation = Arc::new(AtomicU64::new(7));
- let generation_fence = Arc::new(std::sync::Mutex::new(()));
- let (checked_tx, checked_rx) = std::sync::mpsc::channel();
- let (finish_tx, finish_rx) = std::sync::mpsc::channel();
- let committed = Arc::new(std::sync::atomic::AtomicBool::new(false));
-
- let recovery_generation = Arc::clone(&generation);
- let recovery_fence = Arc::clone(&generation_fence);
- let recovery_committed = Arc::clone(&committed);
- let recovery = std::thread::spawn(move || {
- commit_recovery_if_current(&recovery_generation, &recovery_fence, 7, || {
- checked_tx.send(()).expect("signal generation checked");
- finish_rx.recv().expect("release identity commit");
- recovery_committed.store(true, Ordering::SeqCst);
- Ok(())
- })
- });
-
- checked_rx.recv().expect("generation checked");
- let invalidation_generation = Arc::clone(&generation);
- let invalidation_fence = Arc::clone(&generation_fence);
- let (attempted_tx, attempted_rx) = std::sync::mpsc::channel();
- let (invalidated_tx, invalidated_rx) = std::sync::mpsc::channel();
- let invalidation = std::thread::spawn(move || {
- attempted_tx.send(()).expect("signal invalidation attempt");
- let next = invalidate_pairing_generation(&invalidation_generation, &invalidation_fence)
- .expect("invalidate generation");
- invalidated_tx.send(next).expect("signal invalidated");
- });
-
- attempted_rx.recv().expect("invalidation attempted");
- assert!(invalidated_rx
- .recv_timeout(Duration::from_millis(50))
- .is_err());
- assert!(!committed.load(Ordering::SeqCst));
-
- finish_tx.send(()).expect("finish identity commit");
- recovery.join().expect("recovery task").unwrap();
- assert!(committed.load(Ordering::SeqCst));
- assert_eq!(invalidated_rx.recv().expect("invalidation completed"), 8);
- invalidation.join().expect("invalidation task");
- }
-
- #[test]
- fn completion_publish_failure_does_not_undo_successful_import() {
- assert!(recovery_result_after_completion(Ok(()), Err("socket closed".into())).is_ok());
- }
-
- #[tokio::test]
- async fn stale_task_does_not_clear_replacement_session() {
- let (initial, _) = PairingSession::new_source("ws://initial.example".to_string());
- let session = Arc::new(tokio::sync::Mutex::new(Some(initial)));
- let generation = AtomicU64::new(1);
-
- generation.store(2, Ordering::SeqCst);
- let (replacement, _) = PairingSession::new_source("ws://replacement.example".to_string());
- *session.lock().await = Some(replacement);
-
- clear_pairing_session_if_current(&session, &generation, 1).await;
-
- assert!(session.lock().await.is_some());
- }
-
- #[tokio::test]
- async fn current_task_clears_its_session() {
- let (active, _) = PairingSession::new_source("ws://active.example".to_string());
- let session = Arc::new(tokio::sync::Mutex::new(Some(active)));
- let generation = AtomicU64::new(3);
-
- clear_pairing_session_if_current(&session, &generation, 3).await;
-
- assert!(session.lock().await.is_none());
- }
-}
+#[path = "pairing_generation_tests.rs"]
+mod pairing_generation_tests;
#[cfg(test)]
-mod pairing_relay_tests {
- use super::{
- pairing_relay_from_nip11, probe_pairing_relay, resolve_pairing_relay_url, PairingRelay,
- };
- use tokio::io::{AsyncReadExt, AsyncWriteExt};
-
- #[tokio::test]
- async fn live_nip11_probe_discovers_configured_pairing_relay() {
- let listener = tokio::net::TcpListener::bind("127.0.0.1:0")
- .await
- .expect("bind test NIP-11 server");
- let addr = listener.local_addr().expect("test server address");
- let server = tokio::spawn(async move {
- let (mut stream, _) = listener.accept().await.expect("accept NIP-11 request");
- let mut request = vec![0; 2048];
- let bytes_read = stream.read(&mut request).await.expect("read request");
- let request = String::from_utf8_lossy(&request[..bytes_read]);
- assert!(request.starts_with("GET / HTTP/1.1"));
- assert!(request
- .to_ascii_lowercase()
- .contains("accept: application/nostr+json"));
-
- let body = r#"{"pairing_relay_url":"ws://127.0.0.1:5000"}"#;
- let response = format!(
- "HTTP/1.1 200 OK\r\nContent-Type: application/nostr+json\r\nContent-Length: {}\r\nConnection: close\r\n\r\n{body}",
- body.len()
- );
- stream
- .write_all(response.as_bytes())
- .await
- .expect("write response");
- });
-
- assert_eq!(
- probe_pairing_relay(&format!("ws://{addr}")).await,
- PairingRelay::Configured("ws://127.0.0.1:5000".to_string())
- );
- server.await.expect("NIP-11 server task");
- }
-
- #[test]
- fn configured_pairing_relay_takes_precedence_over_legacy_path() {
- let document = serde_json::json!({
- "pairing_relay_url": "wss://pairing.buzz.xyz",
- "supported_nips": [43]
- });
-
- assert_eq!(
- pairing_relay_from_nip11(&document),
- PairingRelay::Configured("wss://pairing.buzz.xyz".to_string())
- );
- }
-
- #[test]
- fn invalid_pairing_relay_url_falls_back_to_legacy_path() {
- let document = serde_json::json!({
- "pairing_relay_url": "https://pairing.buzz.xyz",
- "supported_nips": [43]
- });
-
- assert_eq!(
- pairing_relay_from_nip11(&document),
- PairingRelay::LegacyPath
- );
- }
-
- #[test]
- fn document_without_pairing_configuration_uses_main_relay() {
- let document = serde_json::json!({ "supported_nips": [1, 11] });
-
- assert_eq!(pairing_relay_from_nip11(&document), PairingRelay::MainRelay);
- }
-
- #[test]
- fn configured_pairing_relay_resolves_to_configured_url() {
- let resolved = resolve_pairing_relay_url(
- "wss://flint.communities.buzz.xyz",
- PairingRelay::Configured("wss://pairing.buzz.xyz".to_string()),
- )
- .expect("resolve configured pairing relay");
-
- assert_eq!(resolved, "wss://pairing.buzz.xyz");
- }
-
- #[test]
- fn legacy_pairing_relay_appends_pair_path() {
- let resolved = resolve_pairing_relay_url(
- "wss://flint.communities.buzz.xyz/community",
- PairingRelay::LegacyPath,
- )
- .expect("resolve legacy pairing relay");
-
- assert_eq!(resolved, "wss://flint.communities.buzz.xyz/community/pair");
- }
-
- #[test]
- fn main_relay_pairing_uses_main_relay_url() {
- let resolved = resolve_pairing_relay_url(
- "wss://sprout-oss.stage.blox.sqprod.co",
- PairingRelay::MainRelay,
- )
- .expect("resolve main pairing relay");
-
- assert_eq!(resolved, "wss://sprout-oss.stage.blox.sqprod.co");
- }
-}
+#[path = "pairing_relay_tests.rs"]
+mod pairing_relay_tests;
diff --git a/desktop/src-tauri/src/commands/pairing_generation_tests.rs b/desktop/src-tauri/src/commands/pairing_generation_tests.rs
new file mode 100644
index 0000000000..8a2291ae86
--- /dev/null
+++ b/desktop/src-tauri/src/commands/pairing_generation_tests.rs
@@ -0,0 +1,129 @@
+use std::sync::atomic::{AtomicU64, Ordering};
+use std::sync::Arc;
+use std::time::Duration;
+
+use super::{
+ clear_pairing_session_if_current, commit_recovery_if_current, invalidate_pairing_generation,
+ recovery_result_after_completion, validate_recovery_payload_type, PairingHandle,
+ PairingSession, PayloadType,
+};
+
+#[tokio::test]
+async fn overlapping_starts_are_serialized() {
+ let pairing = Arc::new(PairingHandle::new());
+ let first_pairing = Arc::clone(&pairing);
+ let (locked_tx, locked_rx) = tokio::sync::oneshot::channel();
+ let first = tokio::spawn(async move {
+ let _guard = first_pairing.start_lock.lock().await;
+ locked_tx.send(()).expect("signal acquired start lock");
+ tokio::time::sleep(Duration::from_millis(50)).await;
+ });
+
+ locked_rx.await.expect("first start acquired lock");
+ assert!(pairing.start_lock.try_lock().is_err());
+ first.await.expect("first start task");
+ assert!(pairing.start_lock.try_lock().is_ok());
+}
+
+#[test]
+fn recovery_rejects_non_nsec_payloads() {
+ assert!(validate_recovery_payload_type(PayloadType::Nsec).is_ok());
+ assert_eq!(
+ validate_recovery_payload_type(PayloadType::Custom).unwrap_err(),
+ "Mobile device sent an unsupported recovery payload"
+ );
+}
+
+#[test]
+fn superseded_recovery_cannot_commit_identity() {
+ let generation = AtomicU64::new(2);
+ let committed = std::sync::atomic::AtomicBool::new(false);
+
+ let generation_fence = std::sync::Mutex::new(());
+ let result = commit_recovery_if_current(&generation, &generation_fence, 1, || {
+ committed.store(true, Ordering::SeqCst);
+ Ok(())
+ });
+
+ assert_eq!(
+ result.unwrap_err(),
+ "Pairing session was superseded or cancelled"
+ );
+ assert!(!committed.load(Ordering::SeqCst));
+}
+
+#[test]
+fn invalidation_after_check_waits_for_identity_commit() {
+ let generation = Arc::new(AtomicU64::new(7));
+ let generation_fence = Arc::new(std::sync::Mutex::new(()));
+ let (checked_tx, checked_rx) = std::sync::mpsc::channel();
+ let (finish_tx, finish_rx) = std::sync::mpsc::channel();
+ let committed = Arc::new(std::sync::atomic::AtomicBool::new(false));
+
+ let recovery_generation = Arc::clone(&generation);
+ let recovery_fence = Arc::clone(&generation_fence);
+ let recovery_committed = Arc::clone(&committed);
+ let recovery = std::thread::spawn(move || {
+ commit_recovery_if_current(&recovery_generation, &recovery_fence, 7, || {
+ checked_tx.send(()).expect("signal generation checked");
+ finish_rx.recv().expect("release identity commit");
+ recovery_committed.store(true, Ordering::SeqCst);
+ Ok(())
+ })
+ });
+
+ checked_rx.recv().expect("generation checked");
+ let invalidation_generation = Arc::clone(&generation);
+ let invalidation_fence = Arc::clone(&generation_fence);
+ let (attempted_tx, attempted_rx) = std::sync::mpsc::channel();
+ let (invalidated_tx, invalidated_rx) = std::sync::mpsc::channel();
+ let invalidation = std::thread::spawn(move || {
+ attempted_tx.send(()).expect("signal invalidation attempt");
+ let next = invalidate_pairing_generation(&invalidation_generation, &invalidation_fence)
+ .expect("invalidate generation");
+ invalidated_tx.send(next).expect("signal invalidated");
+ });
+
+ attempted_rx.recv().expect("invalidation attempted");
+ assert!(invalidated_rx
+ .recv_timeout(Duration::from_millis(50))
+ .is_err());
+ assert!(!committed.load(Ordering::SeqCst));
+
+ finish_tx.send(()).expect("finish identity commit");
+ recovery.join().expect("recovery task").unwrap();
+ assert!(committed.load(Ordering::SeqCst));
+ assert_eq!(invalidated_rx.recv().expect("invalidation completed"), 8);
+ invalidation.join().expect("invalidation task");
+}
+
+#[test]
+fn completion_publish_failure_does_not_undo_successful_import() {
+ assert!(recovery_result_after_completion(Ok(()), Err("socket closed".into())).is_ok());
+}
+
+#[tokio::test]
+async fn stale_task_does_not_clear_replacement_session() {
+ let (initial, _) = PairingSession::new_source("ws://initial.example".to_string());
+ let session = Arc::new(tokio::sync::Mutex::new(Some(initial)));
+ let generation = AtomicU64::new(1);
+
+ generation.store(2, Ordering::SeqCst);
+ let (replacement, _) = PairingSession::new_source("ws://replacement.example".to_string());
+ *session.lock().await = Some(replacement);
+
+ clear_pairing_session_if_current(&session, &generation, 1).await;
+
+ assert!(session.lock().await.is_some());
+}
+
+#[tokio::test]
+async fn current_task_clears_its_session() {
+ let (active, _) = PairingSession::new_source("ws://active.example".to_string());
+ let session = Arc::new(tokio::sync::Mutex::new(Some(active)));
+ let generation = AtomicU64::new(3);
+
+ clear_pairing_session_if_current(&session, &generation, 3).await;
+
+ assert!(session.lock().await.is_none());
+}
diff --git a/desktop/src-tauri/src/commands/pairing_relay_tests.rs b/desktop/src-tauri/src/commands/pairing_relay_tests.rs
new file mode 100644
index 0000000000..f0e765eb9c
--- /dev/null
+++ b/desktop/src-tauri/src/commands/pairing_relay_tests.rs
@@ -0,0 +1,104 @@
+use super::{
+ pairing_relay_from_nip11, probe_pairing_relay, resolve_pairing_relay_url, PairingRelay,
+};
+use tokio::io::{AsyncReadExt, AsyncWriteExt};
+
+#[tokio::test]
+async fn live_nip11_probe_discovers_configured_pairing_relay() {
+ let listener = tokio::net::TcpListener::bind("127.0.0.1:0")
+ .await
+ .expect("bind test NIP-11 server");
+ let addr = listener.local_addr().expect("test server address");
+ let server = tokio::spawn(async move {
+ let (mut stream, _) = listener.accept().await.expect("accept NIP-11 request");
+ let mut request = vec![0; 2048];
+ let bytes_read = stream.read(&mut request).await.expect("read request");
+ let request = String::from_utf8_lossy(&request[..bytes_read]);
+ assert!(request.starts_with("GET / HTTP/1.1"));
+ assert!(request
+ .to_ascii_lowercase()
+ .contains("accept: application/nostr+json"));
+
+ let body = r#"{"pairing_relay_url":"ws://127.0.0.1:5000"}"#;
+ let response = format!(
+ "HTTP/1.1 200 OK\r\nContent-Type: application/nostr+json\r\nContent-Length: {}\r\nConnection: close\r\n\r\n{body}",
+ body.len()
+ );
+ stream
+ .write_all(response.as_bytes())
+ .await
+ .expect("write response");
+ });
+
+ assert_eq!(
+ probe_pairing_relay(&format!("ws://{addr}")).await,
+ PairingRelay::Configured("ws://127.0.0.1:5000".to_string())
+ );
+ server.await.expect("NIP-11 server task");
+}
+
+#[test]
+fn configured_pairing_relay_takes_precedence_over_legacy_path() {
+ let document = serde_json::json!({
+ "pairing_relay_url": "wss://pairing.buzz.xyz",
+ "supported_nips": [43]
+ });
+
+ assert_eq!(
+ pairing_relay_from_nip11(&document),
+ PairingRelay::Configured("wss://pairing.buzz.xyz".to_string())
+ );
+}
+
+#[test]
+fn invalid_pairing_relay_url_falls_back_to_legacy_path() {
+ let document = serde_json::json!({
+ "pairing_relay_url": "https://pairing.buzz.xyz",
+ "supported_nips": [43]
+ });
+
+ assert_eq!(
+ pairing_relay_from_nip11(&document),
+ PairingRelay::LegacyPath
+ );
+}
+
+#[test]
+fn document_without_pairing_configuration_uses_main_relay() {
+ let document = serde_json::json!({ "supported_nips": [1, 11] });
+
+ assert_eq!(pairing_relay_from_nip11(&document), PairingRelay::MainRelay);
+}
+
+#[test]
+fn configured_pairing_relay_resolves_to_configured_url() {
+ let resolved = resolve_pairing_relay_url(
+ "wss://flint.communities.buzz.xyz",
+ PairingRelay::Configured("wss://pairing.buzz.xyz".to_string()),
+ )
+ .expect("resolve configured pairing relay");
+
+ assert_eq!(resolved, "wss://pairing.buzz.xyz");
+}
+
+#[test]
+fn legacy_pairing_relay_appends_pair_path() {
+ let resolved = resolve_pairing_relay_url(
+ "wss://flint.communities.buzz.xyz/community",
+ PairingRelay::LegacyPath,
+ )
+ .expect("resolve legacy pairing relay");
+
+ assert_eq!(resolved, "wss://flint.communities.buzz.xyz/community/pair");
+}
+
+#[test]
+fn main_relay_pairing_uses_main_relay_url() {
+ let resolved = resolve_pairing_relay_url(
+ "wss://sprout-oss.stage.blox.sqprod.co",
+ PairingRelay::MainRelay,
+ )
+ .expect("resolve main pairing relay");
+
+ assert_eq!(resolved, "wss://sprout-oss.stage.blox.sqprod.co");
+}
From 9fea883bb63b26a3d45d7faa286a53f96f39b975 Mon Sep 17 00:00:00 2001
From: Carl
Date: Wed, 5 Aug 2026 19:05:15 -0700
Subject: [PATCH 20/20] test(settings): supply identity recovery builder
Co-authored-by: Taylor Ho
Signed-off-by: Taylor Ho
---
.../test/features/settings/theme_picker_page_test.dart | 10 ++++++++--
1 file changed, 8 insertions(+), 2 deletions(-)
diff --git a/mobile/test/features/settings/theme_picker_page_test.dart b/mobile/test/features/settings/theme_picker_page_test.dart
index 010db98ba3..6b166c8efa 100644
--- a/mobile/test/features/settings/theme_picker_page_test.dart
+++ b/mobile/test/features/settings/theme_picker_page_test.dart
@@ -172,7 +172,10 @@ void main() {
testWidgets('settings hides accent navigation for Buzz', (tester) async {
await _pumpPicker(
tester,
- const SettingsPage(profileHeader: SizedBox.shrink()),
+ SettingsPage(
+ profileHeader: const SizedBox.shrink(),
+ identityRecoveryPageBuilder: (_) => const SizedBox.shrink(),
+ ),
prefs: {'buzz_color_scheme': 'buzz', 'buzz_accent_color': 4},
);
@@ -184,7 +187,10 @@ void main() {
) async {
await _pumpPicker(
tester,
- const SettingsPage(profileHeader: SizedBox.shrink()),
+ SettingsPage(
+ profileHeader: const SizedBox.shrink(),
+ identityRecoveryPageBuilder: (_) => const SizedBox.shrink(),
+ ),
prefs: {
'buzz_theme_mode': 'light',
'buzz_color_scheme': 'github-light',