mirror of
https://github.com/purrgrammer/grimoire.git
synced 2026-04-11 07:56:50 +02:00
feat: add QR code support for NIP-46 remote signer connection
- Install qrcode.react for QR code generation - Default to QR code mode for easier mobile connection - Generate nostrconnect:// URI with app metadata (Grimoire name and URL) - Display QR code that remote signers can scan - Auto-wait for remote signer connection with 5-minute timeout - Add toggle to switch between QR and manual bunker:// input - Show loading states during QR generation and connection waiting - Include popular relays (relay.nsec.app, relay.damus.io, nos.lol) - Support scanning from Amber, nsec.app, and other NIP-46 apps
This commit is contained in:
10
package-lock.json
generated
10
package-lock.json
generated
@@ -48,6 +48,7 @@
|
||||
"media-chrome": "^4.17.2",
|
||||
"prismjs": "^1.30.0",
|
||||
"qrcode": "^1.5.4",
|
||||
"qrcode.react": "^4.2.0",
|
||||
"react": "^19.2.1",
|
||||
"react-dom": "^19.2.1",
|
||||
"react-markdown": "^10.1.0",
|
||||
@@ -8682,6 +8683,15 @@
|
||||
"node": ">=10.13.0"
|
||||
}
|
||||
},
|
||||
"node_modules/qrcode.react": {
|
||||
"version": "4.2.0",
|
||||
"resolved": "https://registry.npmjs.org/qrcode.react/-/qrcode.react-4.2.0.tgz",
|
||||
"integrity": "sha512-QpgqWi8rD9DsS9EP3z7BT+5lY5SFhsqGjpgW5DY/i3mK4M9DTBNz3ErMi8BWYEfI3L0d8GIbGmcdFAS1uIRGjA==",
|
||||
"license": "ISC",
|
||||
"peerDependencies": {
|
||||
"react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/queue-microtask": {
|
||||
"version": "1.2.3",
|
||||
"resolved": "https://registry.npmjs.org/queue-microtask/-/queue-microtask-1.2.3.tgz",
|
||||
|
||||
@@ -56,6 +56,7 @@
|
||||
"media-chrome": "^4.17.2",
|
||||
"prismjs": "^1.30.0",
|
||||
"qrcode": "^1.5.4",
|
||||
"qrcode.react": "^4.2.0",
|
||||
"react": "^19.2.1",
|
||||
"react-dom": "^19.2.1",
|
||||
"react-markdown": "^10.1.0",
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { useState } from "react";
|
||||
import { useState, useEffect } from "react";
|
||||
import { toast } from "sonner";
|
||||
import { Eye, Puzzle, Link2 } from "lucide-react";
|
||||
import { Eye, Puzzle, Link2, QrCode, Keyboard } from "lucide-react";
|
||||
import { QRCodeSVG } from "qrcode.react";
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
@@ -11,7 +12,7 @@ import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import accountManager from "@/services/accounts";
|
||||
import { ExtensionSigner, NostrConnectSigner } from "applesauce-signers";
|
||||
import { ExtensionSigner, NostrConnectSigner, PrivateKeySigner } from "applesauce-signers";
|
||||
import { ExtensionAccount, NostrConnectAccount } from "applesauce-accounts/accounts";
|
||||
import { createAccountFromInput } from "@/lib/login-parser";
|
||||
|
||||
@@ -25,6 +26,92 @@ export default function LoginDialog({ open, onOpenChange }: LoginDialogProps) {
|
||||
const [bunkerInput, setBunkerInput] = useState("");
|
||||
const [loading, setLoading] = useState(false);
|
||||
|
||||
// NIP-46 QR mode state
|
||||
const [useQrMode, setUseQrMode] = useState(true);
|
||||
const [nostrConnectUri, setNostrConnectUri] = useState("");
|
||||
const [remoteSigner, setRemoteSigner] = useState<NostrConnectSigner | null>(null);
|
||||
const [isWaitingForConnection, setIsWaitingForConnection] = useState(false);
|
||||
|
||||
// Generate nostrconnect:// URI when dialog opens in QR mode
|
||||
useEffect(() => {
|
||||
if (open && useQrMode && !remoteSigner) {
|
||||
const initQrMode = async () => {
|
||||
try {
|
||||
// Create a temporary client signer
|
||||
const clientSigner = new PrivateKeySigner();
|
||||
|
||||
// Create NostrConnectSigner with default relays
|
||||
const signer = new NostrConnectSigner({
|
||||
signer: clientSigner,
|
||||
relays: [
|
||||
"wss://relay.nsec.app",
|
||||
"wss://relay.damus.io",
|
||||
"wss://nos.lol",
|
||||
],
|
||||
});
|
||||
|
||||
await signer.open();
|
||||
|
||||
// Generate nostrconnect:// URI with app metadata
|
||||
const uri = signer.getNostrConnectURI({
|
||||
name: "Grimoire",
|
||||
url: window.location.origin,
|
||||
});
|
||||
|
||||
setNostrConnectUri(uri);
|
||||
setRemoteSigner(signer);
|
||||
|
||||
// Start waiting for connection
|
||||
setIsWaitingForConnection(true);
|
||||
|
||||
// Wait for remote signer to connect (with 5 minute timeout)
|
||||
const abortController = new AbortController();
|
||||
const timeoutId = setTimeout(() => abortController.abort(), 5 * 60 * 1000);
|
||||
|
||||
try {
|
||||
await signer.waitForSigner(abortController.signal);
|
||||
clearTimeout(timeoutId);
|
||||
|
||||
// Connection established, get pubkey and create account
|
||||
const pubkey = await signer.getPublicKey();
|
||||
const account = new NostrConnectAccount(pubkey, signer);
|
||||
accountManager.addAccount(account);
|
||||
accountManager.setActive(account.id);
|
||||
|
||||
toast.success("Connected to remote signer");
|
||||
onOpenChange(false);
|
||||
|
||||
// Cleanup
|
||||
setRemoteSigner(null);
|
||||
setNostrConnectUri("");
|
||||
setIsWaitingForConnection(false);
|
||||
} catch (error) {
|
||||
clearTimeout(timeoutId);
|
||||
if (error instanceof Error && error.name === "AbortError") {
|
||||
toast.error("Connection timeout. Please try again.");
|
||||
}
|
||||
// Reset on error but keep dialog open
|
||||
setIsWaitingForConnection(false);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("Failed to initialize QR mode:", error);
|
||||
toast.error("Failed to generate connection code");
|
||||
}
|
||||
};
|
||||
|
||||
initQrMode();
|
||||
}
|
||||
|
||||
// Cleanup when dialog closes
|
||||
return () => {
|
||||
if (remoteSigner && !isWaitingForConnection) {
|
||||
remoteSigner.close();
|
||||
setRemoteSigner(null);
|
||||
setNostrConnectUri("");
|
||||
}
|
||||
};
|
||||
}, [open, useQrMode]);
|
||||
|
||||
const handleReadonlyLogin = async () => {
|
||||
if (!readonlyInput.trim()) {
|
||||
toast.error("Please enter an identifier");
|
||||
@@ -175,33 +262,90 @@ export default function LoginDialog({ open, onOpenChange }: LoginDialogProps) {
|
||||
</TabsContent>
|
||||
|
||||
<TabsContent value="remote" className="space-y-4 pt-4">
|
||||
<div className="space-y-2">
|
||||
<label htmlFor="bunker-uri" className="text-sm font-medium">
|
||||
Bunker URI
|
||||
</label>
|
||||
<Input
|
||||
id="bunker-uri"
|
||||
placeholder="bunker://..."
|
||||
value={bunkerInput}
|
||||
onChange={(e) => setBunkerInput(e.target.value)}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === "Enter") handleRemoteSignerLogin();
|
||||
}}
|
||||
disabled={loading}
|
||||
/>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
Connect to a remote signer using NIP-46 (Nostr Connect). Paste
|
||||
your bunker:// URI from your remote signer app.
|
||||
</p>
|
||||
{/* Toggle between QR and manual input */}
|
||||
<div className="flex gap-2">
|
||||
<Button
|
||||
variant={useQrMode ? "default" : "outline"}
|
||||
size="sm"
|
||||
onClick={() => setUseQrMode(true)}
|
||||
className="flex-1"
|
||||
>
|
||||
<QrCode className="size-4 mr-2" />
|
||||
QR Code
|
||||
</Button>
|
||||
<Button
|
||||
variant={!useQrMode ? "default" : "outline"}
|
||||
size="sm"
|
||||
onClick={() => setUseQrMode(false)}
|
||||
className="flex-1"
|
||||
>
|
||||
<Keyboard className="size-4 mr-2" />
|
||||
Manual
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<Button
|
||||
onClick={handleRemoteSignerLogin}
|
||||
disabled={loading || !bunkerInput.trim()}
|
||||
className="w-full"
|
||||
>
|
||||
{loading ? "Connecting..." : "Connect Remote Signer"}
|
||||
</Button>
|
||||
{useQrMode ? (
|
||||
<div className="space-y-4">
|
||||
<div className="flex flex-col items-center gap-4 p-4 bg-muted/50 rounded-lg">
|
||||
{nostrConnectUri ? (
|
||||
<>
|
||||
<QRCodeSVG
|
||||
value={nostrConnectUri}
|
||||
size={256}
|
||||
level="M"
|
||||
className="border-4 border-white rounded"
|
||||
/>
|
||||
{isWaitingForConnection && (
|
||||
<div className="text-center">
|
||||
<div className="flex items-center gap-2 text-sm text-muted-foreground">
|
||||
<div className="size-4 border-2 border-primary border-t-transparent rounded-full animate-spin" />
|
||||
Waiting for remote signer...
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
) : (
|
||||
<div className="flex items-center gap-2 text-sm text-muted-foreground">
|
||||
<div className="size-4 border-2 border-primary border-t-transparent rounded-full animate-spin" />
|
||||
Generating connection code...
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<p className="text-xs text-muted-foreground text-center">
|
||||
Scan this QR code with your remote signer app (like Amber,
|
||||
nsec.app, or any NIP-46 compatible app)
|
||||
</p>
|
||||
</div>
|
||||
) : (
|
||||
<div className="space-y-4">
|
||||
<div className="space-y-2">
|
||||
<label htmlFor="bunker-uri" className="text-sm font-medium">
|
||||
Bunker URI
|
||||
</label>
|
||||
<Input
|
||||
id="bunker-uri"
|
||||
placeholder="bunker://..."
|
||||
value={bunkerInput}
|
||||
onChange={(e) => setBunkerInput(e.target.value)}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === "Enter") handleRemoteSignerLogin();
|
||||
}}
|
||||
disabled={loading}
|
||||
/>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
Paste your bunker:// URI from your remote signer app
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<Button
|
||||
onClick={handleRemoteSignerLogin}
|
||||
disabled={loading || !bunkerInput.trim()}
|
||||
className="w-full"
|
||||
>
|
||||
{loading ? "Connecting..." : "Connect Remote Signer"}
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
</TabsContent>
|
||||
</Tabs>
|
||||
</DialogContent>
|
||||
|
||||
Reference in New Issue
Block a user