diff --git a/package.json b/package.json index 42bc119..d64ff4b 100644 --- a/package.json +++ b/package.json @@ -1,7 +1,7 @@ { "name": "rondevu-demo", - "version": "0.5.0", - "description": "Demo application for Rondevu topic-based peer discovery and signaling", + "version": "2.0.0", + "description": "Demo application for Rondevu DNS-like WebRTC with username claiming and service discovery", "type": "module", "scripts": { "dev": "vite", @@ -10,7 +10,7 @@ "deploy": "npm run build && npx wrangler pages deploy dist --project-name=rondevu-demo" }, "dependencies": { - "@xtr-dev/rondevu-client": "^0.7.4", + "@xtr-dev/rondevu-client": "^0.8.0", "@zxing/library": "^0.21.3", "qrcode": "^1.5.4", "react": "^18.2.0", diff --git a/src/App.jsx b/src/App.jsx index df841e3..b33c1ce 100644 --- a/src/App.jsx +++ b/src/App.jsx @@ -1,14 +1,12 @@ -import React, {useState, useEffect} from 'react'; -import {Rondevu} from '@xtr-dev/rondevu-client'; -import toast, {Toaster} from 'react-hot-toast'; +import React, { useState, useEffect, useRef } from 'react'; +import { Rondevu } from '@xtr-dev/rondevu-client'; +import toast, { Toaster } from 'react-hot-toast'; const API_URL = 'https://api.ronde.vu'; const RTC_CONFIG = { iceServers: [ - { - urls: ["stun:stun.ronde.vu:3478"] - }, + { urls: ["stun:stun.ronde.vu:3478"] }, { urls: [ "turn:turn.ronde.vu:3478?transport=tcp", @@ -17,997 +15,1004 @@ const RTC_CONFIG = { username: "webrtcuser", credential: "supersecretpassword" } - ], - // Force relay to test TURN server (comment out for normal operation) - // iceTransportPolicy: 'relay' + ] }; export default function App() { const [client, setClient] = useState(null); const [credentials, setCredentials] = useState(null); - const [activeTab, setActiveTab] = useState('setup'); - const [status, setStatus] = useState('Not registered'); + const [myUsername, setMyUsername] = useState(null); - // Offer state - const [offerTopics, setOfferTopics] = useState('demo-chat'); - const [myConnections, setMyConnections] = useState([]); + // Setup + const [setupStep, setSetupStep] = useState('register'); // register, claim, ready + const [usernameInput, setUsernameInput] = useState(''); - // Discovery state - const [selectedTopic, setSelectedTopic] = useState(null); - const [discoveredOffers, setDiscoveredOffers] = useState([]); - const [topics, setTopics] = useState([]); - const [topicsLoading, setTopicsLoading] = useState(false); - const [topicsError, setTopicsError] = useState(null); + // Contacts + const [contacts, setContacts] = useState([]); + const [contactInput, setContactInput] = useState(''); + const [onlineUsers, setOnlineUsers] = useState(new Set()); - // Messages - const [messages, setMessages] = useState([]); - const [messageInput, setMessageInput] = useState(''); + // Chat + const [activeChats, setActiveChats] = useState({}); + const [selectedChat, setSelectedChat] = useState(null); + const [messageInputs, setMessageInputs] = useState({}); - // Load credentials + // Service + const [serviceHandle, setServiceHandle] = useState(null); + const chatEndRef = useRef(null); + + // Load saved data useEffect(() => { - const saved = localStorage.getItem('rondevu-credentials'); - if (saved) { + const savedCreds = localStorage.getItem('rondevu-chat-credentials'); + const savedUsername = localStorage.getItem('rondevu-chat-username'); + const savedContacts = localStorage.getItem('rondevu-chat-contacts'); + + if (savedCreds) { try { - const creds = JSON.parse(saved); - // Validate credentials have required fields - if (creds && creds.peerId && creds.secret) { - setCredentials(creds); - setClient(new Rondevu({baseUrl: API_URL, credentials: creds})); - setStatus('Registered (from storage)'); - } else { - // Invalid credentials, remove them - localStorage.removeItem('rondevu-credentials'); - setClient(new Rondevu({baseUrl: API_URL})); - setStatus('Not registered'); - } + const creds = JSON.parse(savedCreds); + setCredentials(creds); + setClient(new Rondevu({ baseUrl: API_URL, credentials: creds })); } catch (err) { - // Corrupted credentials, remove them console.error('Failed to load credentials:', err); - localStorage.removeItem('rondevu-credentials'); - setClient(new Rondevu({baseUrl: API_URL})); - setStatus('Not registered'); + setClient(new Rondevu({ baseUrl: API_URL })); } } else { - setClient(new Rondevu({baseUrl: API_URL})); + setClient(new Rondevu({ baseUrl: API_URL })); + } + + if (savedUsername) { + setMyUsername(savedUsername); + setSetupStep('ready'); + } + + if (savedContacts) { + try { + setContacts(JSON.parse(savedContacts)); + } catch (err) { + console.error('Failed to load contacts:', err); + } } }, []); - // Cleanup on unmount only (empty dependency array) + // Auto-scroll chat useEffect(() => { - return () => { - // Close all peer connections when component unmounts - myConnections.forEach(c => c.peer?.close()); + chatEndRef.current?.scrollIntoView({ behavior: 'smooth' }); + }, [activeChats, selectedChat]); + + // Start chat service when ready + useEffect(() => { + if (setupStep === 'ready' && myUsername && client && !serviceHandle) { + startChatService(); + } + }, [setupStep, myUsername, client]); + + // Check online status periodically + useEffect(() => { + if (setupStep !== 'ready' || !client) return; + + const checkOnlineStatus = async () => { + const online = new Set(); + for (const contact of contacts) { + try { + const services = await client.discovery.listServices(contact); + if (services.services.some(s => s.serviceFqn === 'chat.rondevu@1.0.0')) { + online.add(contact); + } + } catch (err) { + // User offline or doesn't exist + } + } + setOnlineUsers(online); }; - // eslint-disable-next-line react-hooks/exhaustive-deps - }, []); // Empty deps = only run on mount/unmount + + checkOnlineStatus(); + const interval = setInterval(checkOnlineStatus, 10000); // Check every 10s + + return () => clearInterval(interval); + }, [contacts, setupStep, client]); // Register const handleRegister = async () => { if (!client) return; try { - setStatus('Registering...'); const creds = await client.register(); setCredentials(creds); - localStorage.setItem('rondevu-credentials', JSON.stringify(creds)); - setClient(new Rondevu({baseUrl: API_URL, credentials: creds})); - setStatus('Registered!'); - setActiveTab('offer'); + localStorage.setItem('rondevu-chat-credentials', JSON.stringify(creds)); + setClient(new Rondevu({ baseUrl: API_URL, credentials: creds })); + setSetupStep('claim'); + toast.success('Registered!'); } catch (err) { - setStatus(`Error: ${err.message}`); - } - }; - - // Create offer with peer connection manager - const handleCreateOffer = async () => { - if (!client || !credentials) { - toast.error('Please register first!'); - return; - } - - try { - const topics = offerTopics.split(',').map(t => t.trim()).filter(Boolean); - - // Create peer connection using the manager - const peer = client.createPeer(RTC_CONFIG); - - // Add debugging - addApiLogging(client); - addIceLogging(peer); - - // Setup event listeners - peer.on('state', (state) => { - console.log(`🔄 Peer state: ${state}`); - updateConnectionStatus(peer.offerId, state); - }); - - peer.on('connected', () => { - updateConnectionStatus(peer.offerId, 'connected'); - }); - - peer.on('disconnected', () => { - updateConnectionStatus(peer.offerId, 'disconnected'); - }); - - peer.on('failed', (error) => { - console.error('❌ Peer connection failed:', error); - toast.error(`Connection failed: ${error.message}`); - updateConnectionStatus(peer.offerId, 'failed'); - }); - - peer.on('datachannel', (channel) => { - console.log(`📡 Data channel received, state: ${channel.readyState}`); - - // Handle data channel - channel.onmessage = (event) => { - setMessages(prev => [...prev, { - from: 'peer', - text: event.data, - timestamp: Date.now(), - connId: peer.offerId - }]); - }; - - channel.onopen = () => { - console.log(`✅ Data channel opened for offer ${peer.offerId}`); - updateConnectionChannel(peer.offerId, channel); - }; - - channel.onerror = (error) => { - console.error('❌ Data channel error:', error); - }; - - channel.onclose = () => { - console.log('🔒 Data channel closed'); - }; - - // If already open, update immediately - if (channel.readyState === 'open') { - updateConnectionChannel(peer.offerId, channel); - } - }); - - // Create offer - const offerId = await peer.createOffer({ - topics, - ttl: 300000, - timeouts: { - iceGathering: 15000, - waitingForAnswer: 60000, - iceConnection: 45000 - } - }); - - // Add to connections list - setMyConnections(prev => [...prev, { - id: offerId, - topics, - status: 'waiting-for-answer', - role: 'offerer', - peer, - channel: null - }]); - - setOfferTopics(''); - toast.success(`Created offer! Share topic "${topics[0]}" with peers.`); - } catch (err) { - console.error('Error creating offer:', err); toast.error(`Error: ${err.message}`); } }; - // Fetch available topics from server - const fetchTopics = async () => { - if (!client) return; - + // Claim username + const handleClaimUsername = async () => { + if (!client || !usernameInput) return; try { - setTopicsLoading(true); - setTopicsError(null); - const result = await client.offers.getTopics({ limit: 100 }); - setTopics(result.topics); + const claim = await client.usernames.claimUsername(usernameInput); + client.usernames.saveKeypairToStorage(usernameInput, claim.publicKey, claim.privateKey); + setMyUsername(usernameInput); + localStorage.setItem('rondevu-chat-username', usernameInput); + setSetupStep('ready'); + toast.success(`Welcome, ${usernameInput}!`); } catch (err) { - console.error('Error fetching topics:', err); - setTopicsError(err.message); - } finally { - setTopicsLoading(false); + toast.error(`Error: ${err.message}`); } }; - // Fetch topics when discover tab is opened - useEffect(() => { - if (activeTab === 'discover' && topics.length === 0 && !topicsLoading && client) { - fetchTopics(); - } - }, [activeTab, client]); - - // Discover peers by topic - const handleDiscoverPeers = async (topicName) => { - if (!client) return; - - if (!client.isAuthenticated()) { - toast.error('Please register first!'); - return; - } + // Start pooled chat service + const startChatService = async () => { + if (!client || !myUsername || serviceHandle) return; try { - setSelectedTopic(topicName); - const offers = await client.offers.findByTopic(topicName, {limit: 50}); - setDiscoveredOffers(offers); - - if (offers.length === 0) { - toast(`No peers found for "${topicName}"`); - } else { - toast.success(`Found ${offers.length} peer(s) for "${topicName}"`); + const keypair = client.usernames.loadKeypairFromStorage(myUsername); + if (!keypair) { + toast.error('Username keypair not found'); + return; } - } catch (err) { - toast.error(`Error: ${err.message}`); - } - }; - // Answer an offer - const handleAnswerOffer = async (offer) => { - if (!client || !credentials) { - toast.error('Please register first!'); - return; - } + const handle = await client.services.exposeService({ + username: myUsername, + privateKey: keypair.privateKey, + serviceFqn: 'chat.rondevu@1.0.0', + isPublic: true, + poolSize: 10, // Support up to 10 simultaneous connections + rtcConfig: RTC_CONFIG, + handler: (channel, peer, connectionId) => { + console.log(`📡 New chat connection: ${connectionId}`); - try { - // Create peer connection using the manager - const peer = client.createPeer(RTC_CONFIG); + // Wait for peer to identify themselves + channel.onmessage = (event) => { + try { + const msg = JSON.parse(event.data); - // Add debugging - addApiLogging(client); - addIceLogging(peer); - - // Setup event listeners - peer.on('state', (state) => { - console.log(`🔄 Peer state: ${state}`); - updateConnectionStatus(offer.id, state); - }); - - peer.on('connected', () => { - updateConnectionStatus(offer.id, 'connected'); - }); - - peer.on('disconnected', () => { - updateConnectionStatus(offer.id, 'disconnected'); - }); - - peer.on('failed', (error) => { - console.error('❌ Peer connection failed:', error); - toast.error(`Connection failed: ${error.message}`); - updateConnectionStatus(offer.id, 'failed'); - }); - - peer.on('datachannel', (channel) => { - console.log(`📡 Data channel received, state: ${channel.readyState}`); - - // Handle data channel - channel.onmessage = (event) => { - setMessages(prev => [...prev, { - from: 'peer', - text: event.data, - timestamp: Date.now(), - connId: offer.id - }]); - }; - - channel.onopen = () => { - console.log(`✅ Data channel opened for offer ${offer.id}`); - updateConnectionChannel(offer.id, channel); - }; - - channel.onerror = (error) => { - console.error('❌ Data channel error:', error); - }; - - channel.onclose = () => { - console.log('🔒 Data channel closed'); - }; - - // If already open, update immediately - if (channel.readyState === 'open') { - updateConnectionChannel(offer.id, channel); - } - }); - - // Answer the offer - await peer.answer(offer.id, offer.sdp, { - topics: offer.topics, - timeouts: { - iceGathering: 15000, - creatingAnswer: 15000, - iceConnection: 45000 - } - }); - - // Add to connections list - setMyConnections(prev => [...prev, { - id: offer.id, - topics: offer.topics, - status: 'answering', - role: 'answerer', - peer, - channel: null - }]); - - setActiveTab('connections'); - toast.success('Answering offer...'); - } catch (err) { - console.error('Error answering offer:', err); - toast.error(`Error: ${err.message}`); - } - }; - - // Helper functions - const updateConnectionStatus = (connId, status) => { - setMyConnections(prev => prev.map(c => - c.id === connId ? {...c, status} : c - )); - }; - - const updateConnectionChannel = (connId, channel) => { - setMyConnections(prev => prev.map(c => - c.id === connId ? {...c, channel} : c - )); - }; - - // Add API-level ICE candidate logging - const addApiLogging = (client) => { - const originalAddIceCandidates = client.offers.addIceCandidates.bind(client.offers); - const originalGetIceCandidates = client.offers.getIceCandidates.bind(client.offers); - - client.offers.addIceCandidates = async (offerId, candidates) => { - console.log(`📤 Sending ${candidates.length} ICE candidate(s) to server for offer ${offerId}`); - console.log(`📤 Candidates:`, candidates); - const result = await originalAddIceCandidates(offerId, candidates); - console.log(`📤 Send result:`, result); - return result; - }; - - client.offers.getIceCandidates = async (offerId, since) => { - const result = await originalGetIceCandidates(offerId, since); - console.log(`📥 Received ${result.length} ICE candidate(s) from server for offer ${offerId}, since=${since}`); - if (result.length > 0) { - console.log(`📥 All candidates:`, result); - result.forEach((cand, i) => { - console.log(`📥 Candidate ${i}:`, { - role: cand.role, - peerId: cand.peerId, - candidate: cand.candidate, - createdAt: cand.createdAt - }); - }); - } - return result; - }; - }; - - // Add ICE debugging to a peer connection - const addIceLogging = (peer) => { - const pc = peer.pc; // Access underlying peer connection for debugging - if (pc) { - // Add new handlers that don't override existing ones - pc.addEventListener('icecandidate', (event) => { - if (event.candidate) { - // Skip empty/end-of-candidates markers in logs - if (!event.candidate.candidate || event.candidate.candidate === '') { - console.log('🧊 ICE gathering complete (end-of-candidates marker)'); - return; - } - - console.log('🧊 ICE candidate gathered:', { - type: event.candidate.type, - protocol: event.candidate.protocol, - address: event.candidate.address, - port: event.candidate.port, - candidate: event.candidate.candidate - }); - } else { - console.log('🧊 ICE gathering complete'); - } - }); - - pc.addEventListener('icegatheringstatechange', () => { - console.log('🧊 ICE gathering state:', pc.iceGatheringState); - }); - - pc.addEventListener('iceconnectionstatechange', () => { - console.log('🧊 ICE connection state:', pc.iceConnectionState); - if (pc.iceConnectionState === 'failed') { - console.error('❌ ICE connection failed! Check firewall/NAT/TURN server.'); - // Log stats when failed - pc.getStats().then(stats => { - console.log('📊 Connection stats at failure:', stats); - }); - } else if (pc.iceConnectionState === 'checking') { - console.log('⏳ ICE checking candidates...'); - // Set a timeout to detect if we're stuck - setTimeout(() => { - if (pc.iceConnectionState === 'checking') { - console.warn('⚠️ Still in checking state after 30s - connection may be stuck'); - pc.getStats().then(stats => { - stats.forEach(report => { - if (report.type === 'candidate-pair') { - console.log('Candidate pair:', report); + if (msg.type === 'identify') { + // Peer identified themselves + setActiveChats(prev => ({ + ...prev, + [msg.from]: { + username: msg.from, + channel, + connectionId, + messages: prev[msg.from]?.messages || [], + status: 'connected' } - }); - }); + })); + + // Update message handler for actual chat messages + channel.onmessage = (e) => { + try { + const chatMsg = JSON.parse(e.data); + if (chatMsg.type === 'message') { + setActiveChats(prev => ({ + ...prev, + [msg.from]: { + ...prev[msg.from], + messages: [...(prev[msg.from]?.messages || []), { + from: msg.from, + text: chatMsg.text, + timestamp: Date.now() + }] + } + })); + } + } catch (err) { + console.error('Failed to parse chat message:', err); + } + }; + + // Send acknowledgment + channel.send(JSON.stringify({ + type: 'identify_ack', + from: myUsername + })); + } + } catch (err) { + console.error('Failed to parse identify message:', err); } - }, 30000); + }; + + channel.onclose = () => { + console.log(`👋 Chat closed: ${connectionId}`); + setActiveChats(prev => { + const updated = { ...prev }; + Object.keys(updated).forEach(user => { + if (updated[user].connectionId === connectionId) { + updated[user] = { ...updated[user], status: 'disconnected' }; + } + }); + return updated; + }); + }; + }, + onError: (error, context) => { + console.error(`Chat service error (${context}):`, error); } }); - pc.addEventListener('connectionstatechange', () => { - console.log('🔌 Connection state:', pc.connectionState); - if (pc.connectionState === 'failed') { - console.error('❌ Connection failed!'); - // Log the selected candidate pair to see what was attempted - pc.getStats().then(stats => { - stats.forEach(report => { - if (report.type === 'candidate-pair' && report.selected) { - console.log('Selected candidate pair:', report); - } - }); - }); - } - }); + setServiceHandle(handle); + console.log('✅ Chat service started'); + } catch (err) { + console.error('Error starting chat service:', err); + toast.error(`Failed to start chat: ${err.message}`); + } + }; - // Log ICE candidate pair changes - pc.addEventListener('icecandidate', () => { - setTimeout(() => { - pc.getStats().then(stats => { - stats.forEach(report => { - if (report.type === 'candidate-pair' && report.state === 'succeeded') { - console.log('✅ ICE candidate pair succeeded:', { - local: report.localCandidateId, - remote: report.remoteCandidateId, - nominated: report.nominated, - state: report.state - }); - } else if (report.type === 'candidate-pair' && report.state === 'failed') { - console.log('❌ ICE candidate pair failed:', { - local: report.localCandidateId, - remote: report.remoteCandidateId, - state: report.state - }); + // Add contact + const handleAddContact = () => { + if (!contactInput || contacts.includes(contactInput)) { + toast.error('Invalid or duplicate contact'); + return; + } + if (contactInput === myUsername) { + toast.error("You can't add yourself!"); + return; + } + + const newContacts = [...contacts, contactInput]; + setContacts(newContacts); + localStorage.setItem('rondevu-chat-contacts', JSON.stringify(newContacts)); + setContactInput(''); + toast.success(`Added ${contactInput}`); + }; + + // Remove contact + const handleRemoveContact = (contact) => { + const newContacts = contacts.filter(c => c !== contact); + setContacts(newContacts); + localStorage.setItem('rondevu-chat-contacts', JSON.stringify(newContacts)); + if (selectedChat === contact) { + setSelectedChat(null); + } + toast.success(`Removed ${contact}`); + }; + + // Start chat with contact + const handleStartChat = async (contact) => { + if (!client || activeChats[contact]?.status === 'connected') { + setSelectedChat(contact); + return; + } + + try { + toast.loading(`Connecting to ${contact}...`, { id: 'connecting' }); + + const { peer, channel } = await client.discovery.connect(contact, 'chat.rondevu@1.0.0', { rtcConfig: RTC_CONFIG }); + + // Send identification + channel.send(JSON.stringify({ + type: 'identify', + from: myUsername + })); + + // Wait for acknowledgment + channel.onmessage = (event) => { + try { + const msg = JSON.parse(event.data); + + if (msg.type === 'identify_ack') { + // Connection established + toast.success(`Connected to ${contact}`, { id: 'connecting' }); + + setActiveChats(prev => ({ + ...prev, + [contact]: { + username: contact, + channel, + peer, + messages: prev[contact]?.messages || [], + status: 'connected' } - }); - }); - }, 1000); - }); + })); + setSelectedChat(contact); + + // Update handler for chat messages + channel.onmessage = (e) => { + try { + const chatMsg = JSON.parse(e.data); + if (chatMsg.type === 'message') { + setActiveChats(prev => ({ + ...prev, + [contact]: { + ...prev[contact], + messages: [...(prev[contact]?.messages || []), { + from: contact, + text: chatMsg.text, + timestamp: Date.now() + }] + } + })); + } + } catch (err) { + console.error('Failed to parse message:', err); + } + }; + } + } catch (err) { + console.error('Failed to parse ack:', err); + } + }; + + channel.onclose = () => { + setActiveChats(prev => ({ + ...prev, + [contact]: { ...prev[contact], status: 'disconnected' } + })); + toast.error(`Disconnected from ${contact}`); + }; + + } catch (err) { + console.error('Failed to connect:', err); + toast.error(`Failed to connect to ${contact}`, { id: 'connecting' }); } }; // Send message - const handleSendMessage = (connection) => { - if (!messageInput.trim() || !connection.channel) return; + const handleSendMessage = (contact) => { + const text = messageInputs[contact]; + if (!text || !activeChats[contact]?.channel) return; - if (connection.channel.readyState !== 'open') { - toast.error('Channel not open yet!'); + const chat = activeChats[contact]; + if (chat.status !== 'connected') { + toast.error('Not connected'); return; } - connection.channel.send(messageInput); - setMessages(prev => [...prev, { - from: 'me', - text: messageInput, - timestamp: Date.now(), - connId: connection.id - }]); - setMessageInput(''); + try { + chat.channel.send(JSON.stringify({ + type: 'message', + text + })); + + setActiveChats(prev => ({ + ...prev, + [contact]: { + ...prev[contact], + messages: [...prev[contact].messages, { + from: myUsername, + text, + timestamp: Date.now() + }] + } + })); + + setMessageInputs(prev => ({ ...prev, [contact]: '' })); + } catch (err) { + console.error('Failed to send message:', err); + toast.error('Failed to send message'); + } }; - // Clear credentials - const handleClearCredentials = () => { - localStorage.removeItem('rondevu-credentials'); - setCredentials(null); - setStatus('Not registered'); - myConnections.forEach(c => c.peer?.close()); - setMyConnections([]); - setMessages([]); - setClient(new Rondevu({baseUrl: API_URL})); + // Clear all data + const handleLogout = () => { + if (window.confirm('Are you sure you want to logout? This will clear all data.')) { + localStorage.clear(); + window.location.reload(); + } }; + if (!client) { + return
Topic-Based Peer Discovery & WebRTC
-v0.5.0 - State-Based Peer Manager
-Decentralized P2P Chat
- {/* Content */} -Get your credentials to start connecting
- -{credentials.peerId.substring(0, 20)}...Get started by registering with the server
+Choose your unique username
+ setUsernameInput(e.target.value.toLowerCase())} + onKeyPress={(e) => e.key === 'Enter' && handleClaimUsername()} + style={styles.setupInput} + autoFocus + /> + ++ 3-32 characters, lowercase letters, numbers, and dashes only +
+Create a WebRTC offer that peers can discover
- - {!credentials ? ( -Browse topics to find peers
- - {!selectedTopic ? ( -No friends yet
++ Add friends by their username above +
+ Click on a friend from the sidebar to start chatting +
+- Found {discoveredOffers.length} peer(s) + {/* Messages */} +
No messages yet
++ Send a message to start the conversation
- {discoveredOffers.map(offer => { - const isConnected = myConnections.some(c => c.id === offer.id); - const isMine = credentials && offer.peerId === credentials.peerId; - - return ( -Chat with connected peers
- - {myConnections.length === 0 ? ( -Server: {API_URL}
-Open in multiple tabs to test peer-to-peer connections
+ {/* Input */} +