feat(nip46): pair a second signer account — park the live session, switch re-dials it
One live NIP-46 session, many saved ones (Option A): - start_pairing/connect while a session is live PARKS it instead of refusing: row, pairing secret, and persisted client key stay intact, so the parked account is restorable with no fresh scan. - SelectProfile follows the switch: target has a restorable connection -> park current + re-dial target's row (expected_identity guard applies); target is local-key or unpaired -> live session untouched. - New nip46_cancel_pairing IPC: aborts ONLY an in-flight pairing and re-dials the parked session, so cancel-after-park is transparent. The QR cancel paths (Add-profile modal, Signer Mode screen) use it — plain disconnect would revoke the parked connection. - e2e: two fake Ambers on one relay; A pairs, B's pairing parks A (revoked_at none, client key resolvable), switch back re-dials A and signs; no-op switch; local profile leaves session alone; B restorable.
This commit is contained in:
parent
1b4655c07b
commit
c89b31aaf1
9 changed files with 482 additions and 23 deletions
|
|
@ -121,6 +121,7 @@ export const api = {
|
|||
call<Nip46SignerStatus>('nip46_connect', { uri, label }),
|
||||
nip46PairStart: (label: string) => call<Nip46SignerStatus>('nip46_pair_start', { label }),
|
||||
nip46Disconnect: () => call<Nip46SignerStatus>('nip46_disconnect'),
|
||||
nip46CancelPairing: () => call<Nip46SignerStatus>('nip46_cancel_pairing'),
|
||||
nip46Status: () => call<Nip46SignerStatus>('nip46_status'),
|
||||
nip46Approve: (id: string, approved: boolean, always = false) =>
|
||||
call<Nip46SignerStatus>('nip46_approve', { id, approved, always }),
|
||||
|
|
|
|||
|
|
@ -15,7 +15,8 @@ interface CreateProfileModalProps {
|
|||
type Phase = 'choice' | 'pairing' | 'paired' | 'local' | 'creating' | 'success';
|
||||
|
||||
export function CreateProfileModal({ open, onClose }: CreateProfileModalProps) {
|
||||
const { state, createProfile, nip46PairStart, nip46Status, nip46Disconnect, refresh } = useApp();
|
||||
const { state, createProfile, nip46PairStart, nip46Status, nip46CancelPairing, refresh } =
|
||||
useApp();
|
||||
const [label, setLabel] = useState('');
|
||||
const [phase, setPhase] = useState<Phase>('choice');
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
|
@ -149,13 +150,16 @@ export function CreateProfileModal({ open, onClose }: CreateProfileModalProps) {
|
|||
|
||||
// Leaving the QR view mid-pairing aborts the in-flight pairing; nothing
|
||||
// was persisted yet, so teardown is safe at any point (same as Signer
|
||||
// Mode's "Cancel pairing").
|
||||
// Mode's "Cancel pairing"). Cancel (not disconnect): when pairing a
|
||||
// second signer account parked the first one, cancelling must restore
|
||||
// the parked session rather than revoke anything.
|
||||
const cancelPairing = async () => {
|
||||
setLivePairingUri(null);
|
||||
setPairingQr(null);
|
||||
setPhase('choice');
|
||||
try {
|
||||
await nip46Disconnect();
|
||||
await nip46CancelPairing();
|
||||
void refresh();
|
||||
} catch {
|
||||
// Best-effort abort; a dead pairing attempt expires on its own.
|
||||
}
|
||||
|
|
|
|||
|
|
@ -17,6 +17,7 @@ export function SignerModeScreen() {
|
|||
nip46Connect,
|
||||
nip46PairStart,
|
||||
nip46Disconnect,
|
||||
nip46CancelPairing,
|
||||
nip46Approve,
|
||||
embeddedSignerApprove,
|
||||
refresh,
|
||||
|
|
@ -192,17 +193,19 @@ export function SignerModeScreen() {
|
|||
};
|
||||
}, [pairingUri]);
|
||||
|
||||
// Abort an in-flight pairing (e.g. expired QR) — same teardown as a
|
||||
// disconnect; nothing was persisted yet so it is safe at any point.
|
||||
// Abort an in-flight pairing (e.g. expired QR): cancel the pairing
|
||||
// attempt only. Never disconnect here — if pairing a second signer
|
||||
// account parked the first one, a cancel must bring the parked session
|
||||
// back instead of revoking it.
|
||||
const handlePairCancel = useCallback(async () => {
|
||||
setPairError(null);
|
||||
try {
|
||||
const status = await nip46Disconnect();
|
||||
const status = await nip46CancelPairing();
|
||||
setNip46StatusState(status);
|
||||
} catch (err) {
|
||||
setPairError(err instanceof Error ? err.message : String(err));
|
||||
}
|
||||
}, [nip46Disconnect]);
|
||||
}, [nip46CancelPairing]);
|
||||
|
||||
const handleNip46Disconnect = useCallback(async () => {
|
||||
setError(null);
|
||||
|
|
|
|||
|
|
@ -82,6 +82,7 @@ interface AppContextValue {
|
|||
nip46Connect: (uri: string, label: string) => Promise<Nip46SignerStatus>;
|
||||
nip46PairStart: (label: string) => Promise<Nip46SignerStatus>;
|
||||
nip46Disconnect: () => Promise<Nip46SignerStatus>;
|
||||
nip46CancelPairing: () => Promise<Nip46SignerStatus>;
|
||||
nip46Status: () => Promise<Nip46SignerStatus>;
|
||||
nip46Approve: (id: string, approved: boolean, always?: boolean) => Promise<Nip46SignerStatus>;
|
||||
// Legacy NIP-46 bunker (deprecated)
|
||||
|
|
@ -289,6 +290,7 @@ export function AppProvider({ children }: { children: ReactNode }) {
|
|||
[],
|
||||
);
|
||||
const nip46Disconnect = useCallback(() => api.nip46Disconnect(), []);
|
||||
const nip46CancelPairing = useCallback(() => api.nip46CancelPairing(), []);
|
||||
const nip46Status = useCallback(() => api.nip46Status(), []);
|
||||
const nip46PairStart = useCallback((label: string) => api.nip46PairStart(label), []);
|
||||
const nip46Approve = useCallback(
|
||||
|
|
@ -385,6 +387,7 @@ export function AppProvider({ children }: { children: ReactNode }) {
|
|||
nip46Connect,
|
||||
nip46PairStart,
|
||||
nip46Disconnect,
|
||||
nip46CancelPairing,
|
||||
nip46Status,
|
||||
nip46Approve,
|
||||
signerConnect,
|
||||
|
|
@ -445,6 +448,7 @@ export function AppProvider({ children }: { children: ReactNode }) {
|
|||
nip46Connect,
|
||||
nip46PairStart,
|
||||
nip46Disconnect,
|
||||
nip46CancelPairing,
|
||||
nip46Status,
|
||||
nip46Approve,
|
||||
signerConnect,
|
||||
|
|
|
|||
|
|
@ -137,7 +137,10 @@ describe('CreateProfileModal', () => {
|
|||
await screen.findByText(/Waiting for the signer to scan/i);
|
||||
|
||||
await user.click(screen.getByRole('button', { name: 'Cancel pairing' }));
|
||||
expect(backend.requests.some((r) => r.method === 'nip46_disconnect')).toBe(true);
|
||||
// Cancel must be the NON-revoking cancel (parked sessions survive it),
|
||||
// never the disconnect that revokes the stored connection.
|
||||
expect(backend.requests.some((r) => r.method === 'nip46_cancel_pairing')).toBe(true);
|
||||
expect(backend.requests.some((r) => r.method === 'nip46_disconnect')).toBe(false);
|
||||
expect(
|
||||
screen.getByRole('button', { name: /Sign in with a signer app \(Amber\)/ }),
|
||||
).toBeInTheDocument();
|
||||
|
|
|
|||
|
|
@ -226,6 +226,14 @@ export function createFakeBackend(initial?: AppState): FakeBackend {
|
|||
backend.setNip46(next);
|
||||
return next;
|
||||
}
|
||||
case 'nip46_cancel_pairing': {
|
||||
// Mirrors the real backend: aborts ONLY the pairing attempt and
|
||||
// re-dials the parked session — a cancel must not clear a
|
||||
// connected session, only the pairing URI.
|
||||
const next = { ...backend.nip46, pairing_uri: undefined };
|
||||
backend.setNip46(next);
|
||||
return next;
|
||||
}
|
||||
case 'nip46_approve':
|
||||
return backend.nip46;
|
||||
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue