Add NIP-46 remote signer (external signing)
Add a remote-signer (bunker) role so other Nostr apps can delegate signing to this app's active profile keys via nostrconnect:// links. Backend: new src/signer.rs implementing the NIP-46 protocol (kind 24133 events encrypted with NIP-44 v2 conversation keys). It parses nostrconnect:// connect URIs, spawns an async task in the serve process that reads relay requests, auto-approves once the handshake completes, signs delegate events, and publishes responses. Exposes status, connect, and disconnect via IPC and CLI (signer status / signer connect). Other: ipc.rs serve/handle now share Arc<Mutex<App>>; main.rs adds the signer CLI commands; Cargo.toml enables nostr nip46 feature. Frontend: new Signer screen (nav item + sidebar entry with key icon) to paste a nostrconnect:// link, connect/disconnect, and show the connected peer and relays; wires signer_connect/_disconnect/_status through api.ts and AppProvider; adds tests and test mocks.
This commit is contained in:
parent
13efee6b4b
commit
73cb08d856
17 changed files with 1125 additions and 7 deletions
|
|
@ -9,6 +9,7 @@ import { HomeScreen } from './screens/HomeScreen';
|
|||
import { ProfilesScreen } from './screens/ProfilesScreen';
|
||||
import { ComposeScreen } from './screens/ComposeScreen';
|
||||
import { RelaysScreen } from './screens/RelaysScreen';
|
||||
import { SignerScreen } from './screens/SignerScreen';
|
||||
import { SettingsScreen } from './screens/SettingsScreen';
|
||||
import { CreateProfileModal } from './screens/CreateProfileModal';
|
||||
import { AppProvider, useApp, useThemeSync } from './state/AppProvider';
|
||||
|
|
@ -70,6 +71,7 @@ function Shell() {
|
|||
{screen === 'profiles' && <ProfilesScreen onCreateProfile={() => setCreateOpen(true)} />}
|
||||
{screen === 'compose' && <ComposeScreen />}
|
||||
{screen === 'relays' && <RelaysScreen />}
|
||||
{screen === 'signer' && <SignerScreen />}
|
||||
{screen === 'settings' && <SettingsScreen />}
|
||||
</main>
|
||||
<CreateProfileModal open={createOpen} onClose={() => setCreateOpen(false)} />
|
||||
|
|
|
|||
|
|
@ -10,6 +10,7 @@ const NAV_ITEMS: { id: Screen; label: string; icon: IconName }[] = [
|
|||
{ id: 'profiles', label: 'Profiles', icon: 'users' },
|
||||
{ id: 'compose', label: 'Compose', icon: 'edit' },
|
||||
{ id: 'relays', label: 'Relays', icon: 'relay' },
|
||||
{ id: 'signer', label: 'Signer', icon: 'key' },
|
||||
{ id: 'settings', label: 'Settings', icon: 'settings' },
|
||||
];
|
||||
|
||||
|
|
|
|||
|
|
@ -8,6 +8,7 @@ import type {
|
|||
RelayTestResult,
|
||||
RevealedKey,
|
||||
Settings,
|
||||
SignerStatus,
|
||||
UploadedImage,
|
||||
} from './types';
|
||||
|
||||
|
|
@ -70,5 +71,8 @@ export const api = {
|
|||
pickImages: () => call<PickedImage[]>('pick_image'),
|
||||
uploadImage: (path: string) => call<UploadedImage>('upload_image', { path }),
|
||||
linkPreview: (url: string) => call<LinkPreview | null>('link_preview', { url }),
|
||||
signerConnect: (uri: string) => call<SignerStatus>('signer_connect', { uri }),
|
||||
signerDisconnect: () => call<SignerStatus>('signer_disconnect'),
|
||||
signerStatus: () => call<SignerStatus>('signer_status'),
|
||||
copyText: (text: string) => window.backend.copyText(text),
|
||||
};
|
||||
|
|
|
|||
|
|
@ -1,9 +1,10 @@
|
|||
export type Screen = 'home' | 'profiles' | 'compose' | 'relays' | 'settings';
|
||||
export type Screen = 'home' | 'profiles' | 'compose' | 'relays' | 'signer' | 'settings';
|
||||
|
||||
export const SCREEN_TITLES: Record<Screen, string> = {
|
||||
home: 'Home',
|
||||
profiles: 'Profiles',
|
||||
compose: 'Compose',
|
||||
relays: 'Relays',
|
||||
signer: 'Signer',
|
||||
settings: 'Settings',
|
||||
};
|
||||
|
|
|
|||
|
|
@ -1,5 +1,19 @@
|
|||
export type Theme = 'light' | 'dark' | 'system';
|
||||
|
||||
/** Lifecycle of the NIP-46 remote signer. */
|
||||
export type SignerPhase = 'stopped' | 'connecting' | 'connected';
|
||||
|
||||
/** Non-secret snapshot of the NIP-46 remote signer for display. */
|
||||
export interface SignerStatus {
|
||||
phase: SignerPhase;
|
||||
/** The connected client's hex public key, if any. */
|
||||
peer: string | null;
|
||||
/** Relays used for the connection. */
|
||||
relays: string[];
|
||||
/** A user-facing error if the signer stopped because of one. */
|
||||
error: string | null;
|
||||
}
|
||||
|
||||
/** A safe view of a profile with no secret key material. */
|
||||
export interface ProfileSummary {
|
||||
label: string;
|
||||
|
|
|
|||
204
frontend/src/screens/SignerScreen.tsx
Normal file
204
frontend/src/screens/SignerScreen.tsx
Normal file
|
|
@ -0,0 +1,204 @@
|
|||
import { useEffect, useState, type FormEvent } from 'react';
|
||||
import { Alert } from '../components/Alert';
|
||||
import { Badge } from '../components/Badge';
|
||||
import { Button } from '../components/Button';
|
||||
import { ErrorText } from '../components/ErrorText';
|
||||
import { Icon } from '../components/Icon';
|
||||
import type { SignerStatus } from '../lib/types';
|
||||
import { useApp } from '../state/AppProvider';
|
||||
|
||||
const EMPTY_STATUS: SignerStatus = { phase: 'stopped', peer: null, relays: [], error: null };
|
||||
|
||||
/** Shorten a 64-char hex key for display. */
|
||||
function shortHex(value: string): string {
|
||||
return value.length > 16 ? `${value.slice(0, 8)}…${value.slice(-8)}` : value;
|
||||
}
|
||||
|
||||
export function SignerScreen() {
|
||||
const { state, signerConnect, signerDisconnect, signerStatus } = useApp();
|
||||
const [status, setStatus] = useState<SignerStatus>(EMPTY_STATUS);
|
||||
const [uri, setUri] = useState('');
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [connecting, setConnecting] = useState(false);
|
||||
const [loading, setLoading] = useState(true);
|
||||
|
||||
const refresh = async () => {
|
||||
try {
|
||||
setStatus(await signerStatus());
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : String(err));
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
void refresh();
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, []);
|
||||
|
||||
const vaultLocked = state?.vault_locked ?? false;
|
||||
|
||||
const onConnect = async (event: FormEvent) => {
|
||||
event.preventDefault();
|
||||
const trimmed = uri.trim();
|
||||
if (!trimmed.startsWith('nostrconnect://')) {
|
||||
setError('Paste the nostrconnect:// link that the Nostr app generated.');
|
||||
return;
|
||||
}
|
||||
setError(null);
|
||||
setConnecting(true);
|
||||
try {
|
||||
setStatus(await signerConnect(trimmed));
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : String(err));
|
||||
setStatus(await signerStatus().catch(() => EMPTY_STATUS));
|
||||
} finally {
|
||||
setConnecting(false);
|
||||
}
|
||||
};
|
||||
|
||||
const onDisconnect = async () => {
|
||||
setError(null);
|
||||
try {
|
||||
setStatus(await signerDisconnect());
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : String(err));
|
||||
}
|
||||
};
|
||||
|
||||
const badge = () => {
|
||||
switch (status.phase) {
|
||||
case 'connected':
|
||||
return <Badge tone="success">Connected</Badge>;
|
||||
case 'connecting':
|
||||
return <Badge tone="info">Connecting…</Badge>;
|
||||
default:
|
||||
return <Badge tone="neutral">Not connected</Badge>;
|
||||
}
|
||||
};
|
||||
|
||||
const isActive = status.phase === 'connected' || status.phase === 'connecting';
|
||||
|
||||
return (
|
||||
<div className="screen">
|
||||
<div className="screen-inner">
|
||||
<header className="page-head">
|
||||
<div>
|
||||
<h1>Signer</h1>
|
||||
<p className="page-subtitle">
|
||||
Securely sign for another Nostr app. Paste its nostrconnect:// link to let this app
|
||||
approve its requests with the active profile's keys.
|
||||
</p>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
{vaultLocked && (
|
||||
<Alert tone="warning" title="Vault is locked">
|
||||
The signer signs with the active profile's key, which is locked. Unlock the vault
|
||||
before connecting.
|
||||
</Alert>
|
||||
)}
|
||||
|
||||
<section className="card">
|
||||
<header className="card-header">
|
||||
<h2>Status</h2>
|
||||
<div className="signer-badge">{badge()}</div>
|
||||
</header>
|
||||
<div className="card-body signer-status">
|
||||
<dl className="info-list">
|
||||
<div>
|
||||
<dt>Client</dt>
|
||||
<dd>
|
||||
{status.peer ? (
|
||||
<code className="mono" title={status.peer}>
|
||||
{shortHex(status.peer)}
|
||||
</code>
|
||||
) : (
|
||||
<span className="muted">None yet</span>
|
||||
)}
|
||||
</dd>
|
||||
</div>
|
||||
<div>
|
||||
<dt>Relays</dt>
|
||||
<dd>
|
||||
{status.relays.length > 0 ? (
|
||||
status.relays.map((relay) => (
|
||||
<span key={relay} className="mono signer-relay">
|
||||
{relay}
|
||||
</span>
|
||||
))
|
||||
) : (
|
||||
<span className="muted">None</span>
|
||||
)}
|
||||
</dd>
|
||||
</div>
|
||||
</dl>
|
||||
{status.error && (
|
||||
<Alert tone="error" title="Signer error">
|
||||
{status.error}
|
||||
</Alert>
|
||||
)}
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section className="card">
|
||||
<header className="card-header">
|
||||
<h2>Connect a Nostr app</h2>
|
||||
</header>
|
||||
<div className="card-body signer-connect">
|
||||
{isActive ? (
|
||||
<div className="signer-actions">
|
||||
<p className="hint">
|
||||
The signer is listening. Requests from the connected app are approved
|
||||
automatically.
|
||||
</p>
|
||||
<Button variant="danger" onClick={() => void onDisconnect()}>
|
||||
<Icon name="trash" size={16} />
|
||||
Disconnect
|
||||
</Button>
|
||||
</div>
|
||||
) : (
|
||||
<form onSubmit={onConnect} noValidate>
|
||||
<div className="field">
|
||||
<label htmlFor="signer-uri" className="visually-hidden">
|
||||
nostrconnect:// link
|
||||
</label>
|
||||
<input
|
||||
id="signer-uri"
|
||||
type="text"
|
||||
placeholder="nostrconnect://…"
|
||||
value={uri}
|
||||
onChange={(event) => setUri(event.target.value)}
|
||||
autoComplete="off"
|
||||
spellCheck={false}
|
||||
/>
|
||||
<p className="hint">
|
||||
In the Nostr app, choose “use a remote signer” and copy the link it
|
||||
generates here.
|
||||
</p>
|
||||
</div>
|
||||
{error && <ErrorText>{error}</ErrorText>}
|
||||
<div className="settings-inline">
|
||||
<Button
|
||||
variant="primary"
|
||||
type="submit"
|
||||
loading={connecting}
|
||||
disabled={uri.trim().length === 0 || vaultLocked}
|
||||
>
|
||||
<Icon name="key" size={16} />
|
||||
Connect
|
||||
</Button>
|
||||
<Button variant="ghost" onClick={() => void refresh()} disabled={loading}>
|
||||
<Icon name="refresh" size={16} />
|
||||
Refresh
|
||||
</Button>
|
||||
</div>
|
||||
</form>
|
||||
)}
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
@ -17,6 +17,7 @@ import type {
|
|||
RelayTestResult,
|
||||
RevealedKey,
|
||||
Settings,
|
||||
SignerStatus,
|
||||
Theme,
|
||||
UploadedImage,
|
||||
} from '../lib/types';
|
||||
|
|
@ -55,6 +56,9 @@ interface AppContextValue {
|
|||
pickImages: () => Promise<PickedImage[]>;
|
||||
uploadImage: (path: string) => Promise<UploadedImage>;
|
||||
linkPreview: (url: string) => Promise<LinkPreview | null>;
|
||||
signerConnect: (uri: string) => Promise<SignerStatus>;
|
||||
signerDisconnect: () => Promise<SignerStatus>;
|
||||
signerStatus: () => Promise<SignerStatus>;
|
||||
copyText: (text: string) => Promise<void>;
|
||||
}
|
||||
|
||||
|
|
@ -168,6 +172,9 @@ export function AppProvider({ children }: { children: ReactNode }) {
|
|||
const pickImages = useCallback(() => api.pickImages(), []);
|
||||
const uploadImage = useCallback((path: string) => api.uploadImage(path), []);
|
||||
const linkPreview = useCallback((url: string) => api.linkPreview(url), []);
|
||||
const signerConnect = useCallback((uri: string) => api.signerConnect(uri), []);
|
||||
const signerDisconnect = useCallback(() => api.signerDisconnect(), []);
|
||||
const signerStatus = useCallback(() => api.signerStatus(), []);
|
||||
|
||||
const copyText = useCallback((text: string) => api.copyText(text), []);
|
||||
|
||||
|
|
@ -199,6 +206,9 @@ export function AppProvider({ children }: { children: ReactNode }) {
|
|||
pickImages,
|
||||
uploadImage,
|
||||
linkPreview,
|
||||
signerConnect,
|
||||
signerDisconnect,
|
||||
signerStatus,
|
||||
copyText,
|
||||
}),
|
||||
[
|
||||
|
|
@ -226,6 +236,9 @@ export function AppProvider({ children }: { children: ReactNode }) {
|
|||
pickImages,
|
||||
uploadImage,
|
||||
linkPreview,
|
||||
signerConnect,
|
||||
signerDisconnect,
|
||||
signerStatus,
|
||||
copyText,
|
||||
],
|
||||
);
|
||||
|
|
|
|||
|
|
@ -1481,3 +1481,31 @@ select {
|
|||
font-size: 13px;
|
||||
word-break: break-word;
|
||||
}
|
||||
|
||||
.signer-badge {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.signer-status {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 14px;
|
||||
}
|
||||
|
||||
.signer-relay {
|
||||
display: inline-block;
|
||||
margin-right: 8px;
|
||||
padding: 2px 8px;
|
||||
background: var(--surface-2);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: var(--radius-sm);
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.signer-actions {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: flex-start;
|
||||
gap: 12px;
|
||||
}
|
||||
|
|
|
|||
71
frontend/src/test/SignerScreen.test.tsx
Normal file
71
frontend/src/test/SignerScreen.test.tsx
Normal file
|
|
@ -0,0 +1,71 @@
|
|||
import { screen, waitFor } from '@testing-library/react';
|
||||
import userEvent from '@testing-library/user-event';
|
||||
import { SignerScreen } from '../screens/SignerScreen';
|
||||
import { renderWithApp } from './render';
|
||||
import { createFakeBackend, installFakeBackend } from './fakeBackend';
|
||||
|
||||
describe('SignerScreen', () => {
|
||||
it('shows the not-connected state by default', async () => {
|
||||
const backend = createFakeBackend();
|
||||
installFakeBackend(backend);
|
||||
renderWithApp(<SignerScreen />);
|
||||
|
||||
expect(await screen.findByText('Not connected')).toBeInTheDocument();
|
||||
expect(backend.requests.some((r) => r.method === 'signer_status')).toBe(true);
|
||||
expect(screen.getByPlaceholderText('nostrconnect://…')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('rejects a link that does not start with nostrconnect://', async () => {
|
||||
const backend = createFakeBackend();
|
||||
installFakeBackend(backend);
|
||||
const user = userEvent.setup();
|
||||
renderWithApp(<SignerScreen />);
|
||||
|
||||
await screen.findByText('Not connected');
|
||||
await user.type(screen.getByPlaceholderText('nostrconnect://…'), 'https://example.com');
|
||||
await user.click(screen.getByRole('button', { name: 'Connect' }));
|
||||
|
||||
expect(await screen.findByRole('alert')).toHaveTextContent('nostrconnect://');
|
||||
expect(backend.signer.phase).toBe('stopped');
|
||||
});
|
||||
|
||||
it('connects via a valid nostrconnect:// link and shows the peer + relays', async () => {
|
||||
const backend = createFakeBackend();
|
||||
installFakeBackend(backend);
|
||||
const user = userEvent.setup();
|
||||
renderWithApp(<SignerScreen />);
|
||||
|
||||
await screen.findByText('Not connected');
|
||||
await user.type(
|
||||
screen.getByPlaceholderText('nostrconnect://…'),
|
||||
'nostrconnect://alice@relay.damus.io?relay=wss%3A%2F%2Frelay.damus.io',
|
||||
);
|
||||
await user.click(screen.getByRole('button', { name: 'Connect' }));
|
||||
|
||||
expect(await screen.findByText('Connected')).toBeInTheDocument();
|
||||
expect(screen.getByText('wss://relay.damus.io')).toBeInTheDocument();
|
||||
expect(backend.signer.phase).toBe('connected');
|
||||
expect(backend.requests.some((r) => r.method === 'signer_connect')).toBe(true);
|
||||
});
|
||||
|
||||
it('disconnects an active connection', async () => {
|
||||
const backend = createFakeBackend();
|
||||
installFakeBackend(backend);
|
||||
const user = userEvent.setup();
|
||||
renderWithApp(<SignerScreen />);
|
||||
|
||||
await screen.findByText('Not connected');
|
||||
await user.type(
|
||||
screen.getByPlaceholderText('nostrconnect://…'),
|
||||
'nostrconnect://alice@relay.damus.io?relay=wss%3A%2F%2Frelay.damus.io',
|
||||
);
|
||||
await user.click(screen.getByRole('button', { name: 'Connect' }));
|
||||
await screen.findByText('Connected');
|
||||
await user.click(screen.getByRole('button', { name: 'Disconnect' }));
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText('Not connected')).toBeInTheDocument();
|
||||
});
|
||||
expect(backend.signer.phase).toBe('stopped');
|
||||
});
|
||||
});
|
||||
|
|
@ -4,6 +4,7 @@ import type {
|
|||
ProfileSummary,
|
||||
RelayTestResult,
|
||||
Settings,
|
||||
SignerStatus,
|
||||
} from '../lib/types';
|
||||
|
||||
export const ALICE = 'npub1aliceaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa';
|
||||
|
|
@ -69,6 +70,10 @@ export function makeRelayTest(url: string, overrides?: Partial<RelayTestResult>)
|
|||
return { url, connected: true, latency_ms: 42, ...overrides };
|
||||
}
|
||||
|
||||
export function makeSignerStatus(overrides?: Partial<SignerStatus>): SignerStatus {
|
||||
return { phase: 'stopped', peer: null, relays: [], error: null, ...overrides };
|
||||
}
|
||||
|
||||
/**
|
||||
* A configurable mock of the `../lib/api` module. Each test creates one,
|
||||
* registers it with `vi.mock`, and can inspect/override behaviour.
|
||||
|
|
@ -94,15 +99,31 @@ export interface ApiMock {
|
|||
pickImages: ReturnType<typeof vi.fn>;
|
||||
uploadImage: ReturnType<typeof vi.fn>;
|
||||
linkPreview: ReturnType<typeof vi.fn>;
|
||||
signerConnect: ReturnType<typeof vi.fn>;
|
||||
signerDisconnect: ReturnType<typeof vi.fn>;
|
||||
signerStatus: ReturnType<typeof vi.fn>;
|
||||
copyText: ReturnType<typeof vi.fn>;
|
||||
};
|
||||
/** Current state object backing init/getState. */
|
||||
state: AppState;
|
||||
setState: (next: AppState) => void;
|
||||
/** State of the NIP-46 signer backing the signer_* mocks. */
|
||||
signer: SignerStatus;
|
||||
setSigner: (next: SignerStatus) => void;
|
||||
}
|
||||
|
||||
export function createApiMock(initial: AppState = makeState()): ApiMock {
|
||||
let state: AppState = initial;
|
||||
let signer: SignerStatus = makeSignerStatus();
|
||||
|
||||
/**
|
||||
* Fold a new signer status into the snapshot, mirroring the real backend's
|
||||
* auto-approve behaviour: a successful connect reports phase 'connected'.
|
||||
*/
|
||||
const applySigner = (next: SignerStatus): SignerStatus => {
|
||||
signer = next;
|
||||
return signer;
|
||||
};
|
||||
|
||||
const api = {
|
||||
init: vi.fn(async () => state),
|
||||
|
|
@ -198,6 +219,16 @@ export function createApiMock(initial: AppState = makeState()): ApiMock {
|
|||
image: 'https://example.com/cover.jpg',
|
||||
site_name: 'Example',
|
||||
})),
|
||||
signerConnect: vi.fn(async () =>
|
||||
applySigner({
|
||||
phase: 'connected',
|
||||
peer: '7f8b9a0c1d2e3f405162738495a6b7c8d9e0f1a2b3c4d5e6f708192a3b4c5d6e7f',
|
||||
relays: ['wss://relay.damus.io'],
|
||||
error: null,
|
||||
}),
|
||||
),
|
||||
signerDisconnect: vi.fn(async () => applySigner(makeSignerStatus())),
|
||||
signerStatus: vi.fn(async () => signer),
|
||||
copyText: vi.fn(async () => undefined),
|
||||
};
|
||||
|
||||
|
|
@ -207,5 +238,9 @@ export function createApiMock(initial: AppState = makeState()): ApiMock {
|
|||
setState: (next: AppState) => {
|
||||
state = next;
|
||||
},
|
||||
signer,
|
||||
setSigner: (next: SignerStatus) => {
|
||||
signer = next;
|
||||
},
|
||||
};
|
||||
}
|
||||
|
|
|
|||
|
|
@ -5,8 +5,9 @@ import type {
|
|||
PublishReport,
|
||||
RelayTestResult,
|
||||
Settings,
|
||||
SignerStatus,
|
||||
} from '../lib/types';
|
||||
import { makePublishReport, makeRelayTest, makeState } from './apiMock';
|
||||
import { makePublishReport, makeRelayTest, makeSignerStatus, makeState } from './apiMock';
|
||||
|
||||
/**
|
||||
* An in-memory stand-in for the Rust `serve` IPC server. Exposes the same
|
||||
|
|
@ -34,6 +35,9 @@ export interface FakeBackend {
|
|||
pickedImages: { path: string; name: string; mime: string }[];
|
||||
/** URLs returned by `upload_image`, one per call. */
|
||||
uploadUrls: string[];
|
||||
/** Current NIP-46 signer status. */
|
||||
signer: SignerStatus;
|
||||
setSigner: (next: SignerStatus) => void;
|
||||
}
|
||||
|
||||
export function createFakeBackend(initial?: AppState): FakeBackend {
|
||||
|
|
@ -85,6 +89,10 @@ export function createFakeBackend(initial?: AppState): FakeBackend {
|
|||
requests: [],
|
||||
pickedImages: [{ path: '/tmp/picked.png', name: 'picked.png', mime: 'image/png' }],
|
||||
uploadUrls: ['https://cdn.nostr.build/i/uploaded.png'],
|
||||
signer: makeSignerStatus(),
|
||||
setSigner(next) {
|
||||
backend.signer = next;
|
||||
},
|
||||
};
|
||||
|
||||
async function dispatch(method: string, params: Record<string, unknown>): Promise<unknown> {
|
||||
|
|
@ -162,6 +170,28 @@ export function createFakeBackend(initial?: AppState): FakeBackend {
|
|||
site_name: 'Example',
|
||||
};
|
||||
|
||||
case 'signer_connect':
|
||||
if (String(params.uri ?? '').startsWith('nostrconnect://')) {
|
||||
const next: SignerStatus = {
|
||||
phase: 'connected',
|
||||
peer: '7f8b9a0c1d2e3f405162738495a6b7c8d9e0f1a2b3c4d5e6f708192a3b4c5d6e7f',
|
||||
relays: ['wss://relay.damus.io'],
|
||||
error: null,
|
||||
};
|
||||
backend.setSigner(next);
|
||||
return next;
|
||||
}
|
||||
throw new Error('Invalid nostrconnect:// link.');
|
||||
|
||||
case 'signer_disconnect': {
|
||||
const next = makeSignerStatus();
|
||||
backend.setSigner(next);
|
||||
return next;
|
||||
}
|
||||
|
||||
case 'signer_status':
|
||||
return backend.signer;
|
||||
|
||||
case 'relay_add': {
|
||||
const url = String(params.url);
|
||||
const nextSettings: Settings = {
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue