From 7b45e6f296b85f1b09e9f74f6a8f9145cb9bc128 Mon Sep 17 00:00:00 2001 From: hzrd149 Date: Mon, 22 Jul 2024 21:52:42 -0500 Subject: [PATCH] finish basic webrtc classes --- src/classes/nostr-webrtc-broker.ts | 151 ++++++++++++++++++ .../nostr-webrtc-peer.tsx} | 129 +++++---------- src/classes/simple-signer.ts | 31 ++++ src/classes/webrtc-relay-client.ts | 118 ++++++++++++++ src/classes/webrtc-relay-server.ts | 108 +++++++++++++ src/hooks/use-thread-timeline-loader.ts | 2 +- src/services/local-settings.ts | 37 ++++- src/services/webrtc-relays.ts | 113 +++++++++++++ src/views/relays/index.tsx | 22 +-- src/views/relays/webrtc/index.tsx | 124 ++++++++++++-- 10 files changed, 720 insertions(+), 115 deletions(-) create mode 100644 src/classes/nostr-webrtc-broker.ts rename src/{views/relays/webrtc/connect.tsx => classes/nostr-webrtc-peer.tsx} (76%) create mode 100644 src/classes/simple-signer.ts create mode 100644 src/classes/webrtc-relay-client.ts create mode 100644 src/classes/webrtc-relay-server.ts create mode 100644 src/services/webrtc-relays.ts diff --git a/src/classes/nostr-webrtc-broker.ts b/src/classes/nostr-webrtc-broker.ts new file mode 100644 index 000000000..bc273dabc --- /dev/null +++ b/src/classes/nostr-webrtc-broker.ts @@ -0,0 +1,151 @@ +import { SubCloser } from "nostr-tools/abstract-pool"; +import EventEmitter from "eventemitter3"; +import { generateSecretKey, nip19, NostrEvent } from "nostr-tools"; +import { bytesToHex, hexToBytes } from "@noble/hashes/utils"; +import dayjs from "dayjs"; + +import NostrWebRTCPeer, { Pool, RTCDescriptionEventKind, Signer } from "./nostr-webrtc-peer"; +import { isHex } from "../helpers/nip19"; +import { logger } from "../helpers/debug"; +import SimpleSigner from "./simple-signer"; + +type EventMap = { + call: [NostrEvent]; +}; + +export default class NostrWebRtcBroker extends EventEmitter { + log = logger.extend("NostrWebRtcBroker"); + signer: Signer; + pool: Pool; + defaultRelays: string[]; + + peers = new Map(); + signers = new Map(); + relays = new Map(); + + constructor(signer: Signer, pool: Pool, relays: string[]) { + super(); + this.signer = signer; + this.pool = pool; + this.defaultRelays = relays; + } + + getConnection(pubkey: string) { + return this.peers.get(pubkey); + } + + async requestConnection(uri: string) { + const { pubkey, relays, key } = NostrWebRtcBroker.parseNostrWebRtcURI(uri); + + const cached = this.peers.get(pubkey); + if (cached) return cached; + + this.log(`Creating new connection for ${pubkey}`); + + // set signer + let signer = this.signer; + if (key) { + signer = new SimpleSigner(key); + this.signers.set(pubkey, signer); + } + + // set relays + if (relays.length > 0) this.relays.set(pubkey, relays); + else this.relays.set(pubkey, this.defaultRelays); + + const peer = new NostrWebRTCPeer(signer, this.pool, relays.length > 0 ? relays : this.defaultRelays); + this.peers.set(pubkey, peer); + await peer.makeCall(pubkey); + + return peer; + } + + setPeerSigner(pubkey: string, signer: Signer) { + this.signers.set(pubkey, signer); + } + + async answerCall(event: NostrEvent): Promise { + if (this.peers.has(event.pubkey)) throw new Error("Already have a peer connection for this pubkey"); + + // set signer + let signer = this.signers.get(event.pubkey); + if (!signer) { + signer = this.signer; + this.signers.set(event.pubkey, signer); + } + + const peer = new NostrWebRTCPeer(signer, this.pool, this.defaultRelays); + this.peers.set(event.pubkey, peer); + await peer.answerCall(event); + + return peer; + } + + closeConnection(pubkey: string) { + const peer = this.peers.get(pubkey); + if (peer) { + this.log(`Hanging up connection to ${pubkey}`); + peer.hangup(); + this.peers.delete(pubkey); + } + } + + listening = false; + subscription?: SubCloser; + + async listenForCalls() { + if (this.listening) throw new Error("Already listening"); + + this.log("Listening for calls"); + + this.listening = true; + this.subscription = this.pool.subscribeMany( + this.defaultRelays, + [{ kinds: [RTCDescriptionEventKind], "#p": [await this.signer.getPublicKey()], since: dayjs().unix() }], + { + onevent: (event) => { + this.emit("call", event); + }, + onclose: () => { + this.listening = false; + }, + }, + ); + } + + stopListening() { + if (!this.listening) return; + + this.log("Stop listening for calls"); + + if (this.subscription) this.subscription.close(); + this.subscription = undefined; + this.listening = false; + } + + static parseNostrWebRtcURI(uri: string | URL) { + const url = typeof uri === "string" ? new URL(uri) : uri; + if (url.protocol !== "webrtc+nostr:") throw new Error("Incorrect protocol"); + const parsedPath = nip19.decode(url.pathname); + const keyParam = url.searchParams.get("key"); + const relays = url.searchParams.getAll("relay"); + if (parsedPath.type !== "npub") throw new Error("Incorrect npub"); + const pubkey = parsedPath.data; + if (keyParam && !isHex(keyParam)) throw new Error("Key must be in hex format"); + const key = keyParam ? hexToBytes(keyParam) : null; + return { pubkey, key, relays }; + } + + static createNostrWebRtcURI(pubkey: string, relays: string[], key?: Uint8Array | boolean) { + const uri = new URL(`webrtc+nostr:${nip19.npubEncode(pubkey)}`); + for (const relay of relays) uri.searchParams.append("relay", relay); + if (key === true) uri.searchParams.append("key", bytesToHex(generateSecretKey())); + else if (key instanceof Uint8Array) uri.searchParams.append("key", bytesToHex(key)); + return uri.toString(); + } +} + +if (import.meta.env.DEV) { + // @ts-expect-error + window.NostrWebRtcBroker = NostrWebRtcBroker; +} diff --git a/src/views/relays/webrtc/connect.tsx b/src/classes/nostr-webrtc-peer.tsx similarity index 76% rename from src/views/relays/webrtc/connect.tsx rename to src/classes/nostr-webrtc-peer.tsx index ce57ebfbc..2c7429d9b 100644 --- a/src/views/relays/webrtc/connect.tsx +++ b/src/classes/nostr-webrtc-peer.tsx @@ -1,23 +1,15 @@ import { Debugger } from "debug"; import EventEmitter from "eventemitter3"; import dayjs from "dayjs"; -import { - EventTemplate, - Filter, - NostrEvent, - SimplePool, - finalizeEvent, - generateSecretKey, - getPublicKey, - nip44, -} from "nostr-tools"; +import { EventTemplate, Filter, NostrEvent } from "nostr-tools"; import { SubCloser, SubscribeManyParams } from "nostr-tools/abstract-pool"; -import { logger } from "../../../helpers/debug"; +import { logger } from "../helpers/debug"; -const RTCDescriptionEventKind = 25050; -const RTCICEEventKind = 25051; -type Signer = { +export const RTCDescriptionEventKind = 25050; +export const RTCICEEventKind = 25051; + +export type Signer = { getPublicKey: () => Promise | string; signEvent: (event: EventTemplate) => Promise | NostrEvent; nip44: { @@ -26,42 +18,18 @@ type Signer = { }; }; -type Pool = { +export type Pool = { subscribeMany(relays: string[], filters: Filter[], params: SubscribeManyParams): SubCloser; publish(relays: string[], event: NostrEvent): Promise[]; }; type EventMap = { - connect: []; - disconnect: []; - incomingCall: [NostrEvent]; + connected: []; + disconnected: []; message: [string]; }; -class SimpleSigner { - key: Uint8Array; - constructor() { - this.key = generateSecretKey(); - } - - async getPublicKey() { - return getPublicKey(this.key); - } - async signEvent(event: EventTemplate) { - return finalizeEvent(event, this.key); - } - - nip44 = { - encrypt: async (pubkey: string, plaintext: string) => - nip44.v2.encrypt(plaintext, nip44.v2.utils.getConversationKey(this.key, pubkey)), - decrypt: async (pubkey: string, ciphertext: string) => - nip44.v2.decrypt(ciphertext, nip44.v2.utils.getConversationKey(this.key, pubkey)), - }; -} - -const defaultPool = new SimplePool(); - -class WebRTCPeer extends EventEmitter { +export default class NostrWebRTCPeer extends EventEmitter { log: Debugger; signer: Signer; pool: Pool; @@ -72,7 +40,6 @@ class WebRTCPeer extends EventEmitter { connection?: RTCPeerConnection; channel?: RTCDataChannel; - listening = false; subscription?: SubCloser; async isCaller() { @@ -90,9 +57,9 @@ class WebRTCPeer extends EventEmitter { private candidateQueue: RTCIceCandidateInit[] = []; - constructor(signer: Signer, pool: Pool = defaultPool, relays?: string[], iceServers?: RTCIceServer[]) { + constructor(signer: Signer, pool: Pool, relays?: string[], iceServers?: RTCIceServer[]) { super(); - this.log = logger.extend(`webrtc`); + this.log = logger.extend(`NostrWebRTCPeer`); this.signer = signer; this.pool = pool; @@ -115,7 +82,7 @@ class WebRTCPeer extends EventEmitter { this.connection.onicegatheringstatechange = this.flushCandidateQueue.bind(this); this.connection.ondatachannel = ({ channel }) => { - this.log("Got data channel", channel); + this.log("Got data channel", channel.id, channel.label); if (channel.label !== "nostr") return; @@ -125,6 +92,17 @@ class WebRTCPeer extends EventEmitter { this.channel.onmessage = this.handleChannelMessage.bind(this); }; + this.connection.onconnectionstatechange = (event) => { + switch (this.connection?.connectionState) { + case "connected": + this.emit("connected"); + break; + case "disconnected": + this.emit("disconnected"); + break; + } + }; + return this.connection; } @@ -140,7 +118,7 @@ class WebRTCPeer extends EventEmitter { created_at: dayjs().unix(), }); - this.log(`Publishing ICE candidates`, this.candidateQueue); + this.log(`Publishing ${this.candidateQueue.length} ICE candidates`); await this.pool.publish(this.relays, iceEvent); this.candidateQueue = []; } @@ -149,7 +127,6 @@ class WebRTCPeer extends EventEmitter { async makeCall(peer: string) { if (this.peer) throw new Error("Already calling peer"); - this.stopListening(); const pc = this.createConnection(); this.channel = pc.createDataChannel("nostr", { ordered: true }); @@ -168,7 +145,7 @@ class WebRTCPeer extends EventEmitter { created_at: dayjs().unix(), }); - this.log("Created offer", offer); + this.log("Created offer"); // listen for answers and ice events this.subscription = this.pool.subscribeMany( @@ -199,14 +176,14 @@ class WebRTCPeer extends EventEmitter { } }, onclose: () => { - this.log("Subscription Closed"); + this.log("Signaling subscription closed"); }, }, ); this.peer = peer; - this.log("Publishing event", offerEvent); + this.log("Publishing event", offerEvent.id); await this.pool.publish(this.relays, offerEvent); await pc.setLocalDescription(offer); @@ -222,7 +199,7 @@ class WebRTCPeer extends EventEmitter { const answer = JSON.parse(plaintext) as RTCSessionDescriptionInit; if (answer.type !== "answer") throw new Error("Unexpected rtc description type"); - this.log("Got answer", answer); + this.log("Got answer"); await pc.setRemoteDescription(answer); @@ -230,7 +207,6 @@ class WebRTCPeer extends EventEmitter { } async answerCall(event: NostrEvent) { - this.stopListening(); const pc = this.createConnection(); this.log(`Answering call ${event.id} from ${event.pubkey}`); @@ -240,6 +216,8 @@ class WebRTCPeer extends EventEmitter { if (offer.type !== "offer") throw new Error("Unexpected rtc description type"); this.relays = event.tags.filter((t) => t[0] === "relay" && t[1]).map((t) => t[1]); + this.log(`Switching to callers signaling relays`, this.relays); + await pc.setRemoteDescription(offer); const answer = await pc.createAnswer(); @@ -254,7 +232,7 @@ class WebRTCPeer extends EventEmitter { created_at: dayjs().unix(), }); - this.log("Created answer", answer); + this.log("Created answer"); this.peer = event.pubkey; this.offerEvent = event; @@ -275,12 +253,12 @@ class WebRTCPeer extends EventEmitter { } }, onclose: () => { - this.log("Subscription Closed"); + this.log("Signaling subscription closed"); }, }, ); - this.log("Publishing event", answerEvent); + this.log("Publishing event", answerEvent.id); await this.pool.publish(this.relays, answerEvent); await pc.setLocalDescription(answer); @@ -297,39 +275,13 @@ class WebRTCPeer extends EventEmitter { const plaintext = await this.signer.nip44.decrypt(event.pubkey, event.content); const candidates = JSON.parse(plaintext) as RTCIceCandidateInit[]; - this.log("Got candidates", candidates); + this.log(`Got ${candidates.length} candidates`); for (let candidate of candidates) { await pc.addIceCandidate(candidate); } } - async listenForCall() { - if (this.listening) throw new Error("Already listening"); - - this.listening = true; - this.subscription = this.pool.subscribeMany( - this.relays, - [{ kinds: [RTCDescriptionEventKind], "#p": [await this.signer.getPublicKey()], since: dayjs().unix() }], - { - onevent: (event) => { - this.emit("incomingCall", event); - }, - onclose: () => { - this.listening = false; - }, - }, - ); - } - - stopListening() { - if (!this.listening) return; - - if (this.subscription) this.subscription.close(); - this.subscription = undefined; - this.listening = false; - } - private onChannelStateChange() { const readyState = this.channel?.readyState; console.log("Send channel state is: " + readyState); @@ -343,14 +295,15 @@ class WebRTCPeer extends EventEmitter { this.channel?.send(message); } - disconnect() { + hangup() { this.log("Closing data channel"); if (this.channel) this.channel.close(); + this.log("Closing connection"); if (this.connection) this.connection.close(); } } -// @ts-expect-error -window.SimpleSigner = SimpleSigner; -// @ts-expect-error -window.WebRTCPeer = WebRTCPeer; +if (import.meta.env.DEV) { + // @ts-expect-error + window.WebRTCPeer = NostrWebRTCPeer; +} diff --git a/src/classes/simple-signer.ts b/src/classes/simple-signer.ts new file mode 100644 index 000000000..985fcd88b --- /dev/null +++ b/src/classes/simple-signer.ts @@ -0,0 +1,31 @@ +import { EventTemplate, finalizeEvent, generateSecretKey, getPublicKey, nip04, nip44 } from "nostr-tools"; + +export default class SimpleSigner { + key: Uint8Array; + constructor(key?: Uint8Array) { + this.key = key || generateSecretKey(); + } + + async getPublicKey() { + return getPublicKey(this.key); + } + async signEvent(event: EventTemplate) { + return finalizeEvent(event, this.key); + } + + nip04 = { + encrypt: async (pubkey: string, plaintext: string) => nip04.encrypt(this.key, pubkey, plaintext), + decrypt: async (pubkey: string, ciphertext: string) => nip04.decrypt(this.key, pubkey, ciphertext), + }; + nip44 = { + encrypt: async (pubkey: string, plaintext: string) => + nip44.v2.encrypt(plaintext, nip44.v2.utils.getConversationKey(this.key, pubkey)), + decrypt: async (pubkey: string, ciphertext: string) => + nip44.v2.decrypt(ciphertext, nip44.v2.utils.getConversationKey(this.key, pubkey)), + }; +} + +if (import.meta.env.DEV) { + // @ts-expect-error + window.SimpleSigner = SimpleSigner; +} diff --git a/src/classes/webrtc-relay-client.ts b/src/classes/webrtc-relay-client.ts new file mode 100644 index 000000000..a36be7a1c --- /dev/null +++ b/src/classes/webrtc-relay-client.ts @@ -0,0 +1,118 @@ +import NostrWebRTCPeer from "./nostr-webrtc-peer"; +import { AbstractRelay, AbstractRelayConstructorOptions } from "nostr-tools/abstract-relay"; + +export class WebRtcWebSocket extends EventTarget implements WebSocket { + binaryType: BinaryType = "blob"; + bufferedAmount: number = 0; + extensions: string = ""; + protocol: string = "webrtc"; + + peer: NostrWebRTCPeer; + url: string; + + onclose: ((this: WebSocket, ev: CloseEvent) => any) | null = null; + onerror: ((this: WebSocket, ev: Event) => any) | null = null; + onmessage: ((this: WebSocket, ev: MessageEvent) => any) | null = null; + onopen: ((this: WebSocket, ev: Event) => any) | null = null; + + constructor(peer: NostrWebRTCPeer) { + super(); + this.peer = peer; + this.url = `webrtc+nostr:` + peer.answerEvent?.pubkey; + + this.peer.on("message", this.handleMessage, this); + this.peer.on("connected", this.handleConnect, this); + this.peer.on("disconnected", this.handleDisconnect, this); + + if (this.readyState === WebRtcWebSocket.OPEN) { + setTimeout(() => this.handleConnect(), 100); + } + } + + get readyState() { + const state = this.peer.connection?.connectionState; + switch (state) { + case "closed": + case "disconnected": + return this.CLOSED; + case "failed": + return this.CLOSED; + case "connected": + return this.OPEN; + case "new": + case "connecting": + default: + return this.CONNECTING; + } + } + + private handleMessage(data: string) { + const event = new MessageEvent("message", { data }); + this.onmessage?.(event); + this.dispatchEvent(event); + } + private handleConnect() { + const event = new Event("open"); + this.onopen?.(event); + this.dispatchEvent(event); + } + private handleDisconnect() { + const event = new CloseEvent("close", { reason: "none" }); + this.onclose?.(event); + this.dispatchEvent(event); + + this.peer.off("message", this.handleMessage, this); + this.peer.off("connected", this.handleConnect, this); + this.peer.off("disconnected", this.handleDisconnect, this); + } + + send(data: string | ArrayBufferLike | Blob | ArrayBufferView): void; + send(data: string | ArrayBufferLike | Blob | ArrayBufferView): void; + send(data: unknown): void { + if (typeof data === "string") { + this.peer.send(data); + } else throw new Error("Unsupported data type"); + } + + close(code?: number, reason?: string): void; + close(code?: number, reason?: string): void; + close(code?: unknown, reason?: unknown): void { + this.peer.hangup(); + + this.peer.off("message", this.handleMessage, this); + this.peer.off("connected", this.handleConnect, this); + this.peer.off("disconnected", this.handleDisconnect, this); + } + + readonly CONNECTING = WebSocket.CONNECTING; + readonly OPEN = WebSocket.OPEN; + readonly CLOSING = WebSocket.CLOSING; + readonly CLOSED = WebSocket.CLOSED; + static readonly CONNECTING = WebSocket.CONNECTING; + static readonly OPEN = WebSocket.OPEN; + static readonly CLOSING = WebSocket.CLOSING; + static readonly CLOSED = WebSocket.CLOSED; +} + +export default class WebRtcRelayClient extends AbstractRelay { + constructor(peer: NostrWebRTCPeer, opts: AbstractRelayConstructorOptions) { + super("wss://example.com", opts); + + // @ts-expect-error + this.url = `webrtc+nostr:` + peer.answerEvent?.pubkey; + + this.connectionTimeout = 30_000; + + // @ts-expect-error + this._WebSocket = function () { + return new WebRtcWebSocket(peer); + }; + } +} + +if (import.meta.env.DEV) { + // @ts-expect-error + window.WebRtcWebSocket = WebRtcWebSocket; + // @ts-expect-error + window.WebRtcRelayClient = WebRtcRelayClient; +} diff --git a/src/classes/webrtc-relay-server.ts b/src/classes/webrtc-relay-server.ts new file mode 100644 index 000000000..8e2178fb9 --- /dev/null +++ b/src/classes/webrtc-relay-server.ts @@ -0,0 +1,108 @@ +import EventEmitter from "eventemitter3"; +import { Filter, NostrEvent } from "nostr-tools"; +import { AbstractRelay, Subscription } from "nostr-tools/abstract-relay"; + +import NostrWebRTCPeer from "./nostr-webrtc-peer"; +import { logger } from "../helpers/debug"; + +type EventMap = { + call: [NostrEvent]; +}; + +export default class WebRtcRelayServer extends EventEmitter { + log = logger.extend("WebRtcRelayServer"); + + peer: NostrWebRTCPeer; + upstream: AbstractRelay; + + // A map of subscriptions + subscriptions = new Map(); + + constructor(peer: NostrWebRTCPeer, upstream: AbstractRelay) { + super(); + this.peer = peer; + this.upstream = upstream; + + this.peer.on("message", this.handleMessage, this); + this.peer.on("disconnected", this.handleDisconnect, this); + } + + private send(data: any[]) { + this.peer.send(JSON.stringify(data)); + } + + async handleMessage(message: string) { + let data; + + try { + data = JSON.parse(message); + + if (!Array.isArray(data)) throw new Error("Message is not an array"); + + // Pass the data to appropriate handler + switch (data[0]) { + case "REQ": + case "COUNT": + await this.handleSubscriptionMessage(data); + break; + case "EVENT": + await this.handleEventMessage(data); + break; + case "CLOSE": + await this.handleCloseMessage(data); + break; + } + } catch (err) { + this.log("Failed to handle message", message, err); + } + + return data; + } + + handleSubscriptionMessage(data: any[]) { + const [_, id, ...filters] = data as [string, string, ...Filter[]]; + + let sub = this.subscriptions.get(id); + if (sub) { + sub.filters = filters; + sub.fire(); + } else { + sub = this.upstream.subscribe(filters, { + onevent: (event) => this.send(["EVENT", id, event]), + onclose: (reason) => this.send(["CLOSED", id, reason]), + oneose: () => this.send(["EOSE", id]), + }); + } + } + + handleCloseMessage(data: any[]) { + const [_, id] = data as [string, string, ...Filter[]]; + + let sub = this.subscriptions.get(id); + if (sub) { + sub.close(); + this.subscriptions.delete(id); + } + } + + async handleEventMessage(data: any[]) { + const [_, event] = data as [string, NostrEvent]; + + try { + const result = await this.upstream.publish(event); + this.peer.send(JSON.stringify(["OK", event.id, true, result])); + } catch (error) { + if (error instanceof Error) this.peer.send(JSON.stringify(["OK", event.id, false, error.message])); + } + } + + handleDisconnect() { + for (const [id, sub] of this.subscriptions) sub.close(); + this.subscriptions.clear(); + } + + destroy() { + this.peer.off("message", this.handleMessage, this); + this.peer.off("disconnected", this.handleDisconnect, this); + } +} diff --git a/src/hooks/use-thread-timeline-loader.ts b/src/hooks/use-thread-timeline-loader.ts index 7e7067017..fd1e1c76a 100644 --- a/src/hooks/use-thread-timeline-loader.ts +++ b/src/hooks/use-thread-timeline-loader.ts @@ -17,7 +17,7 @@ export default function useThreadTimelineLoader( const refs = focusedEvent && getThreadReferences(focusedEvent); const rootPointer = refs?.root?.e || (focusedEvent && { id: focusedEvent?.id }); - const readRelays = unique([...relays, ...(rootPointer?.relays ?? [])]); + const readRelays = useMemo(() => unique([...relays, ...(rootPointer?.relays ?? [])]), [relays, rootPointer?.relays]); const timelineId = `${rootPointer?.id}-thread`; const timeline = useTimelineLoader( diff --git a/src/services/local-settings.ts b/src/services/local-settings.ts index e52b2af4d..82bc51344 100644 --- a/src/services/local-settings.ts +++ b/src/services/local-settings.ts @@ -1,3 +1,6 @@ +import { generateSecretKey } from "nostr-tools"; +import { bytesToHex, hexToBytes } from "@noble/hashes/utils"; + import { PersistentSubject } from "../classes/subject"; class NullableLocalStorageEntry extends PersistentSubject { @@ -49,25 +52,40 @@ class LocalStorageEntry extends PersistentSubject { decode?: (raw: string) => T; encode?: (value: T) => string | null; - constructor(key: string, fallback: T, decode?: (raw: string) => T, encode?: (value: T) => string | null) { + setDefault = false; + + constructor( + key: string, + fallback: T, + decode?: (raw: string) => T, + encode?: (value: T) => string | null, + setDefault = false, + ) { let value = fallback; if (localStorage.hasOwnProperty(key)) { const raw = localStorage.getItem(key); if (decode && raw) value = decode(raw); else if (raw) value = raw as T; + } else if (setDefault) { + const encoded = encode ? encode(fallback) : String(fallback); + if (!encoded) throw new Error("encode can not return null when setDefault is set"); + localStorage.setItem(key, encoded); } super(value); + this.key = key; this.decode = decode; this.encode = encode; this.fallback = fallback; + this.setDefault = setDefault; } next(value: T) { const encoded = this.encode ? this.encode(value) : String(value); if (encoded !== null) localStorage.setItem(this.key, encoded); + else if (this.setDefault && encoded) localStorage.setItem(this.key, encoded); else localStorage.removeItem(this.key); super.next(value); @@ -112,10 +130,27 @@ const enableNoteThreadDrawer = new LocalStorageEntry( (v) => String(v), ); +// webrtc relay +const webRtcUseLocalIdentity = new LocalStorageEntry( + "nostr-webrtc-use-identity", + true, + (raw) => raw === "true", + (v) => String(v), +); +const webRtcLocalIdentity = new LocalStorageEntry( + "nostr-webrtc-identity", + generateSecretKey(), + (raw) => hexToBytes(raw), + (key) => bytesToHex(key), + true, +); + const localSettings = { idbMaxEvents, wasmPersistForDays, enableNoteThreadDrawer, + webRtcUseLocalIdentity, + webRtcLocalIdentity, }; if (import.meta.env.DEV) { diff --git a/src/services/webrtc-relays.ts b/src/services/webrtc-relays.ts new file mode 100644 index 000000000..cbec06a06 --- /dev/null +++ b/src/services/webrtc-relays.ts @@ -0,0 +1,113 @@ +import { NostrEvent, SimplePool } from "nostr-tools"; +import { AbstractRelay } from "nostr-tools/abstract-relay"; + +import { logger } from "../helpers/debug"; +import NostrWebRtcBroker from "../classes/nostr-webrtc-broker"; +import WebRtcRelayClient from "../classes/webrtc-relay-client"; +import WebRtcRelayServer from "../classes/webrtc-relay-server"; +import verifyEventMethod from "./verify-event"; +import SimpleSigner from "../classes/simple-signer"; +import { localRelay } from "./local-relay"; +import localSettings from "./local-settings"; + +class WebRtcRelaysService { + log = logger.extend("NostrWebRtcBroker"); + broker: NostrWebRtcBroker; + upstream: AbstractRelay | null; + + approved: string[] = []; + + calls: NostrEvent[] = []; + get answered() { + return this.calls.filter((event) => this.broker.peers.has(event.pubkey)); + } + get unanswered() { + return this.calls.filter((event) => this.broker.peers.has(event.pubkey) === false); + } + + clients = new Map(); + servers = new Map(); + + get relays() { + return Array.from(this.clients.values()); + } + + constructor(broker: NostrWebRtcBroker, upstream: AbstractRelay | null) { + this.upstream = upstream; + this.broker = broker; + } + + async handleCall(event: NostrEvent) { + if (!this.calls.includes(event)) { + this.log(`Received request from ${event.pubkey}`); + this.calls.push(event); + } + + if (this.approved.includes(event.pubkey)) { + this.log(`Answering call from ${event.pubkey}`); + const peer = await this.broker.answerCall(event); + if (!peer.peer) return; + + if (this.upstream) { + const server = new WebRtcRelayServer(peer, this.upstream); + this.servers.set(peer.peer, server); + } + + const client = new WebRtcRelayClient(peer, { + websocketImplementation: WebSocket, + verifyEvent: verifyEventMethod, + }); + this.clients.set(peer.peer, client); + } + } + + async acceptCall(event: NostrEvent) { + this.log(`Accepting connection from ${event.pubkey}`); + this.approved.push(event.pubkey); + await this.handleCall(event); + } + + async connect(uri: string) { + this.log(`Connecting to ${uri}`); + const peer = await this.broker.requestConnection(uri); + if (!peer.peer) return; + + if (this.upstream) { + const server = new WebRtcRelayServer(peer, this.upstream); + this.servers.set(peer.peer, server); + } + + const client = new WebRtcRelayClient(peer, { + websocketImplementation: WebSocket, + verifyEvent: verifyEventMethod, + }); + this.clients.set(peer.peer, client); + await client.connect(); + } + + start() { + this.broker.listenForCalls(); + this.broker.on("call", this.handleCall, this); + } + + stop() { + this.broker.stopListening(); + this.broker.off("call", this.handleCall, this); + } +} + +const signer = new SimpleSigner(localSettings.webRtcLocalIdentity.value); + +const webRtcRelaysService = new WebRtcRelaysService( + new NostrWebRtcBroker(signer, new SimplePool(), ["wss://nos.lol"]), + localRelay as AbstractRelay | null, +); + +webRtcRelaysService.start(); + +// if (import.meta.env.DEV) { +// @ts-expect-error +window.webRtcRelaysService = webRtcRelaysService; +// } + +export default webRtcRelaysService; diff --git a/src/views/relays/index.tsx b/src/views/relays/index.tsx index 2cf72de0e..580c43616 100644 --- a/src/views/relays/index.tsx +++ b/src/views/relays/index.tsx @@ -91,15 +91,19 @@ export default function RelaysView() { NIP-05 Relays )} - + {account && ( + <> + + + )} {/* {account && ( <> diff --git a/src/views/relays/webrtc/index.tsx b/src/views/relays/webrtc/index.tsx index e219cb50d..294a6bd09 100644 --- a/src/views/relays/webrtc/index.tsx +++ b/src/views/relays/webrtc/index.tsx @@ -1,30 +1,122 @@ -import { Button, ButtonGroup, Code, Flex, Heading, Link, Text } from "@chakra-ui/react"; +import { useEffect, useMemo, useState } from "react"; +import { + Button, + ButtonGroup, + Flex, + FormControl, + FormLabel, + Heading, + Input, + useForceUpdate, + useInterval, +} from "@chakra-ui/react"; +import { getPublicKey, nip19 } from "nostr-tools"; + import BackButton from "../../../components/router/back-button"; -import useCurrentAccount from "../../../hooks/use-current-account"; -import { useUserDNSIdentity } from "../../../hooks/use-user-dns-identity"; -import { Link as RouterLink } from "react-router-dom"; - -import { RelayFavicon } from "../../../components/relay-favicon"; import { QrCodeIcon } from "../../../components/icons"; +import webRtcRelaysService from "../../../services/webrtc-relays"; +import useSubject from "../../../hooks/use-subject"; +import localSettings from "../../../services/local-settings"; +import { CopyIconButton } from "../../../components/copy-icon-button"; +import QRCodeScannerButton from "../../../components/qr-code/qr-code-scanner-button"; +import UserAvatar from "../../../components/user/user-avatar"; +import UserName from "../../../components/user/user-name"; -import "./connect"; +function WebRtcRelaysPage() { + const update = useForceUpdate(); + const identity = useSubject(localSettings.webRtcLocalIdentity); + const pubkey = useMemo(() => getPublicKey(identity), [identity]); + const npub = useMemo(() => nip19.npubEncode(pubkey), [pubkey]); + + const uri = "webrtc+nostr:" + npub; + + const [connectURI, setConnectURI] = useState(""); + + useEffect(() => { + webRtcRelaysService.broker.on("call", update); + + return () => { + webRtcRelaysService.broker.off("call", update); + }; + }, [update]); + + useInterval(update, 1000); -export default function WebRtcRelaysView() { return ( WebRTC Relays - - - - - - {/* - These relays cant be modified by noStrudel, they must be set manually on your - */} + + WebRTC Connection URI + + + + + + + + Connect: + + setConnectURI(e.target.value)} /> + setConnectURI(data)} /> + + + + {webRtcRelaysService.answered.length > 0 && ( + <> + Connections: + {webRtcRelaysService.answered.map((event) => ( + + + + + + ))} + + )} + + {webRtcRelaysService.unanswered.length > 0 && ( + <> + Connection Requests: + {webRtcRelaysService.unanswered.map((event) => ( + + + + + + ))} + + )} ); } + +export default function WebRtcRelaysView() { + if (webRtcRelaysService) { + return ; + } + return WebRTC Relays don't work without; +}