Compare commits
1
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
ead3a9ae44 |
Generated
+105
@@ -8,6 +8,7 @@
|
|||||||
"name": "podsteadr-player",
|
"name": "podsteadr-player",
|
||||||
"version": "0.1.0",
|
"version": "0.1.0",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
|
"nostr-tools": "^2.15.0",
|
||||||
"qrcode": "^1.5.4",
|
"qrcode": "^1.5.4",
|
||||||
"vue": "^3.5.0"
|
"vue": "^3.5.0"
|
||||||
},
|
},
|
||||||
@@ -561,6 +562,45 @@
|
|||||||
"@jridgewell/sourcemap-codec": "^1.4.14"
|
"@jridgewell/sourcemap-codec": "^1.4.14"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/@noble/ciphers": {
|
||||||
|
"version": "2.1.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/@noble/ciphers/-/ciphers-2.1.1.tgz",
|
||||||
|
"integrity": "sha512-bysYuiVfhxNJuldNXlFEitTVdNnYUc+XNJZd7Qm2a5j1vZHgY+fazadNFWFaMK/2vye0JVlxV3gHmC0WDfAOQw==",
|
||||||
|
"license": "MIT",
|
||||||
|
"engines": {
|
||||||
|
"node": ">= 20.19.0"
|
||||||
|
},
|
||||||
|
"funding": {
|
||||||
|
"url": "https://paulmillr.com/funding/"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@noble/curves": {
|
||||||
|
"version": "2.0.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/@noble/curves/-/curves-2.0.1.tgz",
|
||||||
|
"integrity": "sha512-vs1Az2OOTBiP4q0pwjW5aF0xp9n4MxVrmkFBxc6EKZc6ddYx5gaZiAsZoq0uRRXWbi3AT/sBqn05eRPtn1JCPw==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"@noble/hashes": "2.0.1"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">= 20.19.0"
|
||||||
|
},
|
||||||
|
"funding": {
|
||||||
|
"url": "https://paulmillr.com/funding/"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@noble/hashes": {
|
||||||
|
"version": "2.0.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/@noble/hashes/-/hashes-2.0.1.tgz",
|
||||||
|
"integrity": "sha512-XlOlEbQcE9fmuXxrVTXCTlG2nlRXa9Rj3rr5Ue/+tX+nmkgbX720YHh0VR3hBF9xDvwnb8D2shVGOwNx+ulArw==",
|
||||||
|
"license": "MIT",
|
||||||
|
"engines": {
|
||||||
|
"node": ">= 20.19.0"
|
||||||
|
},
|
||||||
|
"funding": {
|
||||||
|
"url": "https://paulmillr.com/funding/"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/@nodelib/fs.scandir": {
|
"node_modules/@nodelib/fs.scandir": {
|
||||||
"version": "2.1.5",
|
"version": "2.1.5",
|
||||||
"resolved": "https://registry.npmjs.org/@nodelib/fs.scandir/-/fs.scandir-2.1.5.tgz",
|
"resolved": "https://registry.npmjs.org/@nodelib/fs.scandir/-/fs.scandir-2.1.5.tgz",
|
||||||
@@ -956,6 +996,42 @@
|
|||||||
"win32"
|
"win32"
|
||||||
]
|
]
|
||||||
},
|
},
|
||||||
|
"node_modules/@scure/base": {
|
||||||
|
"version": "2.0.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/@scure/base/-/base-2.0.0.tgz",
|
||||||
|
"integrity": "sha512-3E1kpuZginKkek01ovG8krQ0Z44E3DHPjc5S2rjJw9lZn3KSQOs8S7wqikF/AH7iRanHypj85uGyxk0XAyC37w==",
|
||||||
|
"license": "MIT",
|
||||||
|
"funding": {
|
||||||
|
"url": "https://paulmillr.com/funding/"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@scure/bip32": {
|
||||||
|
"version": "2.0.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/@scure/bip32/-/bip32-2.0.1.tgz",
|
||||||
|
"integrity": "sha512-4Md1NI5BzoVP+bhyJaY3K6yMesEFzNS1sE/cP+9nuvE7p/b0kx9XbpDHHFl8dHtufcbdHRUUQdRqLIPHN/s7yA==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"@noble/curves": "2.0.1",
|
||||||
|
"@noble/hashes": "2.0.1",
|
||||||
|
"@scure/base": "2.0.0"
|
||||||
|
},
|
||||||
|
"funding": {
|
||||||
|
"url": "https://paulmillr.com/funding/"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@scure/bip39": {
|
||||||
|
"version": "2.0.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/@scure/bip39/-/bip39-2.0.1.tgz",
|
||||||
|
"integrity": "sha512-PsxdFj/d2AcJcZDX1FXN3dDgitDDTmwf78rKZq1a6c1P1Nan1X/Sxc7667zU3U+AN60g7SxxP0YCVw2H/hBycg==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"@noble/hashes": "2.0.1",
|
||||||
|
"@scure/base": "2.0.0"
|
||||||
|
},
|
||||||
|
"funding": {
|
||||||
|
"url": "https://paulmillr.com/funding/"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/@types/estree": {
|
"node_modules/@types/estree": {
|
||||||
"version": "1.0.9",
|
"version": "1.0.9",
|
||||||
"resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.9.tgz",
|
"resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.9.tgz",
|
||||||
@@ -1926,6 +2002,35 @@
|
|||||||
"node": ">=0.10.0"
|
"node": ">=0.10.0"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/nostr-tools": {
|
||||||
|
"version": "2.24.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/nostr-tools/-/nostr-tools-2.24.1.tgz",
|
||||||
|
"integrity": "sha512-KdrKjC74n/rr6J3eCSfZj8dcbZFvolHYe4S22SefNZ5YWbhHiB0KL/mmJjEZ0u6B9mZK0YcQtl+WQ46KzwapeQ==",
|
||||||
|
"license": "Unlicense",
|
||||||
|
"dependencies": {
|
||||||
|
"@noble/ciphers": "2.1.1",
|
||||||
|
"@noble/curves": "2.0.1",
|
||||||
|
"@noble/hashes": "2.0.1",
|
||||||
|
"@scure/base": "2.0.0",
|
||||||
|
"@scure/bip32": "2.0.1",
|
||||||
|
"@scure/bip39": "2.0.1",
|
||||||
|
"nostr-wasm": "0.1.0"
|
||||||
|
},
|
||||||
|
"peerDependencies": {
|
||||||
|
"typescript": ">=5.0.0"
|
||||||
|
},
|
||||||
|
"peerDependenciesMeta": {
|
||||||
|
"typescript": {
|
||||||
|
"optional": true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/nostr-wasm": {
|
||||||
|
"version": "0.1.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/nostr-wasm/-/nostr-wasm-0.1.0.tgz",
|
||||||
|
"integrity": "sha512-78BTryCLcLYv96ONU8Ws3Q1JzjlAt+43pWQhIl86xZmWeegYCNLPml7yQ+gG3vR6V5h4XGj+TxO+SS5dsThQIA==",
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
"node_modules/object-assign": {
|
"node_modules/object-assign": {
|
||||||
"version": "4.1.1",
|
"version": "4.1.1",
|
||||||
"resolved": "https://registry.npmjs.org/object-assign/-/object-assign-4.1.1.tgz",
|
"resolved": "https://registry.npmjs.org/object-assign/-/object-assign-4.1.1.tgz",
|
||||||
|
|||||||
@@ -9,6 +9,7 @@
|
|||||||
"preview": "vite preview"
|
"preview": "vite preview"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
|
"nostr-tools": "^2.15.0",
|
||||||
"qrcode": "^1.5.4",
|
"qrcode": "^1.5.4",
|
||||||
"vue": "^3.5.0"
|
"vue": "^3.5.0"
|
||||||
},
|
},
|
||||||
|
|||||||
+41
-8
@@ -1,6 +1,6 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { computed, reactive, ref } from 'vue';
|
import { onMounted, reactive, ref } from 'vue';
|
||||||
import { hasNip07 } from './lib/nip07';
|
import { activeSignerPubkey, disconnectNip46, hasNip46, hasSigner, restoreNip46Session } from './lib/signer';
|
||||||
import { parseCatalogOpml, type CatalogEntry } from './lib/opml';
|
import { parseCatalogOpml, type CatalogEntry } from './lib/opml';
|
||||||
import { parseFeedXml, type PlayerEpisode, type PlayerFeed } from './lib/rss';
|
import { parseFeedXml, type PlayerEpisode, type PlayerFeed } from './lib/rss';
|
||||||
import {
|
import {
|
||||||
@@ -20,6 +20,7 @@ import {
|
|||||||
savePurchase,
|
savePurchase,
|
||||||
} from './lib/storage';
|
} from './lib/storage';
|
||||||
import PurchaseModal from './components/PurchaseModal.vue';
|
import PurchaseModal from './components/PurchaseModal.vue';
|
||||||
|
import ConnectSignerModal from './components/ConnectSignerModal.vue';
|
||||||
import Player from './components/Player.vue';
|
import Player from './components/Player.vue';
|
||||||
|
|
||||||
const subscriptions = ref(loadSubscriptions());
|
const subscriptions = ref(loadSubscriptions());
|
||||||
@@ -130,8 +131,8 @@ async function playOrBuy(episode: PlayerEpisode): Promise<void> {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!hasNip07()) {
|
if (!hasSigner()) {
|
||||||
feedError.value = 'This episode is priced — install a NIP-07 Nostr extension (e.g. Alby, nos2x) to buy it.';
|
feedError.value = 'This episode is priced — connect a NIP-07 extension or a QR signer (e.g. Primal) to buy it.';
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -183,7 +184,23 @@ async function checkPayment(): Promise<void> {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const nip07Available = computed(() => hasNip07());
|
const signerPubkey = ref<string | null>(null);
|
||||||
|
const showConnectModal = ref(false);
|
||||||
|
|
||||||
|
onMounted(async () => {
|
||||||
|
await restoreNip46Session();
|
||||||
|
signerPubkey.value = await activeSignerPubkey();
|
||||||
|
});
|
||||||
|
|
||||||
|
function onSignerConnected(pubkey: string): void {
|
||||||
|
signerPubkey.value = pubkey;
|
||||||
|
showConnectModal.value = false;
|
||||||
|
}
|
||||||
|
|
||||||
|
function disconnectSigner(): void {
|
||||||
|
disconnectNip46();
|
||||||
|
signerPubkey.value = null;
|
||||||
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
@@ -191,9 +208,19 @@ const nip07Available = computed(() => hasNip07());
|
|||||||
<header>
|
<header>
|
||||||
<h1 class="text-2xl font-bold text-white">podsteadr player</h1>
|
<h1 class="text-2xl font-bold text-white">podsteadr player</h1>
|
||||||
<p class="text-sm text-white/60">Browse, pay, and play podsteadr feeds.</p>
|
<p class="text-sm text-white/60">Browse, pay, and play podsteadr feeds.</p>
|
||||||
<p v-if="!nip07Available" class="mt-1 text-xs text-orange-400">
|
|
||||||
No NIP-07 extension detected — free episodes still play, but buying priced ones needs one.
|
<div v-if="signerPubkey" class="mt-1 flex items-center gap-2 text-xs text-white/50">
|
||||||
</p>
|
<span>Signed in as <span class="font-mono">{{ signerPubkey.slice(0, 12) }}…</span></span>
|
||||||
|
<button v-if="hasNip46()" type="button" class="text-orange-400 underline" @click="disconnectSigner">
|
||||||
|
Disconnect
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<div v-else class="mt-1 flex items-center gap-2 text-xs text-orange-400">
|
||||||
|
<span>No signer connected — free episodes still play, but buying priced ones needs one.</span>
|
||||||
|
<button type="button" class="text-orange-400 underline" @click="showConnectModal = true">
|
||||||
|
Connect with QR
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
</header>
|
</header>
|
||||||
|
|
||||||
<section class="card space-y-3">
|
<section class="card space-y-3">
|
||||||
@@ -273,5 +300,11 @@ const nip07Available = computed(() => hasNip07());
|
|||||||
@check="checkPayment"
|
@check="checkPayment"
|
||||||
@close="purchase = null"
|
@close="purchase = null"
|
||||||
/>
|
/>
|
||||||
|
|
||||||
|
<ConnectSignerModal
|
||||||
|
v-if="showConnectModal"
|
||||||
|
@connected="onSignerConnected"
|
||||||
|
@close="showConnectModal = false"
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|||||||
@@ -0,0 +1,51 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import { onMounted, ref } from 'vue';
|
||||||
|
import QRCode from 'qrcode';
|
||||||
|
import { connectNip46ViaQr } from '../lib/signer';
|
||||||
|
|
||||||
|
const emit = defineEmits<{
|
||||||
|
connected: [pubkey: string];
|
||||||
|
close: [];
|
||||||
|
}>();
|
||||||
|
|
||||||
|
const qrDataUrl = ref<string | null>(null);
|
||||||
|
const connecting = ref(true);
|
||||||
|
const error = ref<string | null>(null);
|
||||||
|
|
||||||
|
async function start(): Promise<void> {
|
||||||
|
connecting.value = true;
|
||||||
|
error.value = null;
|
||||||
|
qrDataUrl.value = null;
|
||||||
|
try {
|
||||||
|
const pubkey = await connectNip46ViaQr(async (uri) => {
|
||||||
|
qrDataUrl.value = await QRCode.toDataURL(uri, { margin: 1, width: 320 });
|
||||||
|
});
|
||||||
|
emit('connected', pubkey);
|
||||||
|
} catch (err) {
|
||||||
|
error.value = (err as Error).message;
|
||||||
|
} finally {
|
||||||
|
connecting.value = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
onMounted(start);
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<div class="fixed inset-0 z-50 flex items-center justify-center bg-black/70 p-4">
|
||||||
|
<div class="card w-full max-w-sm space-y-4 text-center">
|
||||||
|
<h2 class="text-lg font-semibold text-white">Connect a remote signer</h2>
|
||||||
|
<p class="text-sm text-white/60">Scan this with Primal (or any NIP-46-compatible signer app) to sign in without a browser extension.</p>
|
||||||
|
|
||||||
|
<img v-if="qrDataUrl" :src="qrDataUrl" alt="Nostr Connect QR code" class="mx-auto rounded-lg" />
|
||||||
|
|
||||||
|
<p v-if="connecting && !error" class="text-xs text-white/50">Waiting for approval…</p>
|
||||||
|
<p v-if="error" class="text-sm text-red-400">{{ error }}</p>
|
||||||
|
|
||||||
|
<div class="flex gap-2">
|
||||||
|
<button type="button" class="btn-secondary flex-1" @click="emit('close')">Cancel</button>
|
||||||
|
<button v-if="error" type="button" class="btn-primary flex-1" @click="start">Retry</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
+1
-1
@@ -1,4 +1,4 @@
|
|||||||
import { buildNip98Header } from './nip07';
|
import { buildNip98Header } from './signer';
|
||||||
|
|
||||||
export interface PurchaseQuote {
|
export interface PurchaseQuote {
|
||||||
quoteId: string;
|
quoteId: string;
|
||||||
|
|||||||
@@ -1,50 +0,0 @@
|
|||||||
// NIP-07 browser extension bridge + NIP-98 header construction.
|
|
||||||
// Copied from podsteadr/frontend/src/lib/nip07.ts — same bridge, same header format, so a
|
|
||||||
// purchase/confirm/download-url request signed here verifies identically server-side.
|
|
||||||
|
|
||||||
export interface UnsignedEvent {
|
|
||||||
kind: number;
|
|
||||||
created_at: number;
|
|
||||||
content: string;
|
|
||||||
tags: string[][];
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface SignedEvent extends UnsignedEvent {
|
|
||||||
id: string;
|
|
||||||
pubkey: string;
|
|
||||||
sig: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
interface Nip07Provider {
|
|
||||||
getPublicKey(): Promise<string>;
|
|
||||||
signEvent(event: UnsignedEvent): Promise<SignedEvent>;
|
|
||||||
}
|
|
||||||
|
|
||||||
declare global {
|
|
||||||
interface Window {
|
|
||||||
nostr?: Nip07Provider;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
export function hasNip07(): boolean {
|
|
||||||
return typeof window !== 'undefined' && !!window.nostr;
|
|
||||||
}
|
|
||||||
|
|
||||||
export function nip07(): Nip07Provider {
|
|
||||||
if (!window.nostr) throw new Error('No NIP-07 nostr extension found');
|
|
||||||
return window.nostr;
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Sign a NIP-98 (kind 27235) event for the given request and return the Authorization header value. */
|
|
||||||
export async function buildNip98Header(url: string, method: string): Promise<string> {
|
|
||||||
const event = await nip07().signEvent({
|
|
||||||
kind: 27235,
|
|
||||||
created_at: Math.floor(Date.now() / 1000),
|
|
||||||
content: '',
|
|
||||||
tags: [
|
|
||||||
['u', url],
|
|
||||||
['method', method],
|
|
||||||
],
|
|
||||||
});
|
|
||||||
return `Nostr ${btoa(JSON.stringify(event))}`;
|
|
||||||
}
|
|
||||||
@@ -0,0 +1,147 @@
|
|||||||
|
// Signing abstraction: either a NIP-07 browser extension, or a NIP-46 remote signer connected
|
||||||
|
// by scanning a "nostrconnect://" QR code (Primal mobile, or any other NIP-46-compatible
|
||||||
|
// signer app). Both are exposed through the same shape so the rest of the app (NIP-98 header
|
||||||
|
// construction, the "do we have a signer at all" check) doesn't care which one is active.
|
||||||
|
|
||||||
|
import { generateSecretKey, getPublicKey as pubkeyFromSecret } from 'nostr-tools/pure';
|
||||||
|
import { BunkerSigner, createNostrConnectURI } from 'nostr-tools/nip46';
|
||||||
|
import { hexToBytes, bytesToHex } from 'nostr-tools/utils';
|
||||||
|
|
||||||
|
export interface UnsignedEvent {
|
||||||
|
kind: number;
|
||||||
|
created_at: number;
|
||||||
|
content: string;
|
||||||
|
tags: string[][];
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface SignedEvent extends UnsignedEvent {
|
||||||
|
id: string;
|
||||||
|
pubkey: string;
|
||||||
|
sig: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface Nip07Provider {
|
||||||
|
getPublicKey(): Promise<string>;
|
||||||
|
signEvent(event: UnsignedEvent): Promise<SignedEvent>;
|
||||||
|
}
|
||||||
|
|
||||||
|
declare global {
|
||||||
|
interface Window {
|
||||||
|
nostr?: Nip07Provider;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function hasNip07(): boolean {
|
||||||
|
return typeof window !== 'undefined' && !!window.nostr;
|
||||||
|
}
|
||||||
|
|
||||||
|
function nip07(): Nip07Provider {
|
||||||
|
if (!window.nostr) throw new Error('No NIP-07 nostr extension found');
|
||||||
|
return window.nostr;
|
||||||
|
}
|
||||||
|
|
||||||
|
const NIP46_RELAYS = ['wss://relay.damus.io', 'wss://nos.lol', 'wss://relay.nostr.band'];
|
||||||
|
const NIP46_STORAGE_KEY = 'podsteadr-player:nip46-session';
|
||||||
|
|
||||||
|
interface Nip46Session {
|
||||||
|
clientSecretKeyHex: string;
|
||||||
|
bunkerPubkey: string;
|
||||||
|
relays: string[];
|
||||||
|
secret: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
let nip46Signer: BunkerSigner | null = null;
|
||||||
|
let nip46Pubkey: string | null = null;
|
||||||
|
|
||||||
|
export function hasNip46(): boolean {
|
||||||
|
return nip46Signer !== null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** True if either a NIP-07 extension or a connected NIP-46 remote signer is available. */
|
||||||
|
export function hasSigner(): boolean {
|
||||||
|
return hasNip07() || hasNip46();
|
||||||
|
}
|
||||||
|
|
||||||
|
async function signWithActiveSigner(event: UnsignedEvent): Promise<SignedEvent> {
|
||||||
|
if (nip46Signer) return nip46Signer.signEvent(event) as Promise<SignedEvent>;
|
||||||
|
return nip07().signEvent(event);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Sign a NIP-98 (kind 27235) event for the given request and return the Authorization header value. */
|
||||||
|
export async function buildNip98Header(url: string, method: string): Promise<string> {
|
||||||
|
const event = await signWithActiveSigner({
|
||||||
|
kind: 27235,
|
||||||
|
created_at: Math.floor(Date.now() / 1000),
|
||||||
|
content: '',
|
||||||
|
tags: [
|
||||||
|
['u', url],
|
||||||
|
['method', method],
|
||||||
|
],
|
||||||
|
});
|
||||||
|
return `Nostr ${btoa(JSON.stringify(event))}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Starts a NIP-46 handshake: generates a "nostrconnect://" URI (passed to `onUri` so the caller
|
||||||
|
* can render it as a QR code), then resolves once a remote signer scans it and approves the
|
||||||
|
* connection. The session is persisted so a page reload doesn't require scanning again.
|
||||||
|
*/
|
||||||
|
export async function connectNip46ViaQr(onUri: (uri: string) => void, timeoutMs = 120_000): Promise<string> {
|
||||||
|
const clientSecretKey = generateSecretKey();
|
||||||
|
const secret = Math.random().toString(36).slice(2);
|
||||||
|
const uri = createNostrConnectURI({
|
||||||
|
clientPubkey: pubkeyFromSecret(clientSecretKey),
|
||||||
|
relays: NIP46_RELAYS,
|
||||||
|
secret,
|
||||||
|
name: 'podsteadr player',
|
||||||
|
});
|
||||||
|
onUri(uri);
|
||||||
|
|
||||||
|
const signer = await BunkerSigner.fromURI(clientSecretKey, uri, {}, timeoutMs);
|
||||||
|
nip46Signer = signer;
|
||||||
|
nip46Pubkey = await signer.getPublicKey();
|
||||||
|
|
||||||
|
const session: Nip46Session = {
|
||||||
|
clientSecretKeyHex: bytesToHex(clientSecretKey),
|
||||||
|
bunkerPubkey: signer.bp.pubkey,
|
||||||
|
relays: signer.bp.relays,
|
||||||
|
secret: signer.bp.secret ?? '',
|
||||||
|
};
|
||||||
|
localStorage.setItem(NIP46_STORAGE_KEY, JSON.stringify(session));
|
||||||
|
|
||||||
|
return nip46Pubkey;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Reconnects to a previously-approved NIP-46 session without needing to scan a QR again. */
|
||||||
|
export async function restoreNip46Session(): Promise<boolean> {
|
||||||
|
const raw = localStorage.getItem(NIP46_STORAGE_KEY);
|
||||||
|
if (!raw) return false;
|
||||||
|
try {
|
||||||
|
const session = JSON.parse(raw) as Nip46Session;
|
||||||
|
const signer = BunkerSigner.fromBunker(hexToBytes(session.clientSecretKeyHex), {
|
||||||
|
pubkey: session.bunkerPubkey,
|
||||||
|
relays: session.relays,
|
||||||
|
secret: session.secret || null,
|
||||||
|
});
|
||||||
|
nip46Pubkey = await signer.getPublicKey();
|
||||||
|
nip46Signer = signer;
|
||||||
|
return true;
|
||||||
|
} catch {
|
||||||
|
localStorage.removeItem(NIP46_STORAGE_KEY);
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function disconnectNip46(): void {
|
||||||
|
void nip46Signer?.close();
|
||||||
|
nip46Signer = null;
|
||||||
|
nip46Pubkey = null;
|
||||||
|
localStorage.removeItem(NIP46_STORAGE_KEY);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Pubkey of whichever signer is currently active, or null if none. */
|
||||||
|
export async function activeSignerPubkey(): Promise<string | null> {
|
||||||
|
if (nip46Signer) return nip46Pubkey;
|
||||||
|
if (hasNip07()) return nip07().getPublicKey();
|
||||||
|
return null;
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user