From 3f8a00cd19fe1c2da968d5f1a5b84d5ca8be17a2 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?0=25=20=5B=E2=96=88=20=E2=96=88=20=E2=96=88=20=E2=96=88=20?= =?UTF-8?q?=E2=96=88=20=E2=96=88=20=E2=96=88=20=E2=96=88=20=E2=96=88=20?= =?UTF-8?q?=E2=96=88=5D=20100=25?= Date: Tue, 16 Jun 2026 22:20:41 -0500 Subject: [PATCH] v1.0.8 Some fixes, but networking issues still persists. --- Peercord Source/package.json | 4 +- Peercord Source/src/components/CallView.jsx | 4 +- .../src/components/FriendsView.jsx | 100 +++-- Peercord Source/src/components/MainApp.jsx | 55 +-- .../src/components/OnlineUsers.jsx | 4 +- .../src/components/SetupScreen.jsx | 45 +- Peercord Source/src/p2p/handlers.js | 21 +- Peercord Source/src/p2p/index.js | 152 +++++-- Peercord Source/src/p2p/modules/discovery.js | 6 +- Peercord Source/src/p2p/modules/messaging.js | 87 ++-- Peercord Source/src/p2p/modules/webrtc.js | 5 +- Peercord Source/src/p2p/network.js | 397 +----------------- Peercord Source/src/p2p/utils.js | 5 +- 13 files changed, 303 insertions(+), 582 deletions(-) diff --git a/Peercord Source/package.json b/Peercord Source/package.json index 30b6791..4ac4857 100644 --- a/Peercord Source/package.json +++ b/Peercord Source/package.json @@ -1,6 +1,6 @@ { "name": "peercord", - "version": "1.0.5", + "version": "1.0.8", "description": "Peercord, A P2P Discord clone powered by Pear Runtime", "author": "Mastercodeon", "main": "index.js", @@ -44,11 +44,13 @@ "dependencies": { "autobase": "latest", "b4a": "latest", + "compact-encoding": "latest", "corestore": "latest", "hyperbee": "latest", "hypercore": "latest", "hyperswarm": "latest", "pear-runtime": "latest", + "protomux": "latest", "react": "latest", "react-dom": "latest", "react-markdown": "^9.0.1", diff --git a/Peercord Source/src/components/CallView.jsx b/Peercord Source/src/components/CallView.jsx index 0d3e9bd..ca9838a 100644 --- a/Peercord Source/src/components/CallView.jsx +++ b/Peercord Source/src/components/CallView.jsx @@ -243,6 +243,7 @@ export default function CallView({ targetKey, targetProfile, myProfile, isCaller // eslint-disable-next-line react-hooks/exhaustive-deps },[status, isCaller, targetKey, mediaReady]); + // FIX: Removed onConnected from dependency array to prevent listener recreation useEffect(() => { const processSignal = async (payload) => { const pc = pcRef.current; @@ -265,7 +266,6 @@ export default function CallView({ targetKey, targetProfile, myProfile, isCaller await pc.addIceCandidate(candidate).catch(console.error); } pendingCandidates.current =[]; - onConnected(); } else if (payload.type === 'webrtc-ice-candidate') { const candidate = new RTCIceCandidate(payload.candidate); @@ -314,7 +314,7 @@ export default function CallView({ targetKey, targetProfile, myProfile, isCaller network.addWebRTCListener(handleSignal); return () => network.removeWebRTCListener(handleSignal); - }, [targetKey, onConnected]); + }, [targetKey]); const toggleMute = () => { if (localStreamRef.current) { diff --git a/Peercord Source/src/components/FriendsView.jsx b/Peercord Source/src/components/FriendsView.jsx index b6b874d..0ec50ab 100644 --- a/Peercord Source/src/components/FriendsView.jsx +++ b/Peercord Source/src/components/FriendsView.jsx @@ -70,7 +70,7 @@ export default function FriendsView({ dms, onNavigateToDM }) { onClick={() => setActiveTab('pending')} className={`px-3 py-1 rounded font-medium text-sm transition-colors ${activeTab === 'pending' ? 'bg-surface text-text' : 'text-muted hover:bg-surface/50 hover:text-text'}`} > - Pending {pendingIncoming.length > 0 && {pendingIncoming.length}} + Pending {(pendingIncoming.length + pendingOutgoing.length) > 0 && {pendingIncoming.length + pendingOutgoing.length}} + + -
- {data.profile?.displayName} - @{data.profile?.username} • Incoming Contact Request -
- -
- - -
+ ))} - ))} + + )} - {pendingOutgoing.map(([pubKey, data]) => ( -
-
-
- {data.profile?.avatar ? : data.profile?.displayName?.substring(0, 2).toUpperCase()} + {pendingOutgoing.length > 0 && ( +
+

Outgoing Requests — {pendingOutgoing.length}

+
+ {pendingOutgoing.map(([pubKey, data]) => ( +
+
+
+ {data.profile?.avatar ? : data.profile?.displayName?.substring(0, 2).toUpperCase()} +
+
+ {data.profile?.displayName} + @{data.profile?.username} +
+
+
+ +
-
- {data.profile?.displayName} - @{data.profile?.username} • Outgoing Contact Request -
-
-
- -
+ ))}
- ))} +
+ )} - {pendingIncoming.length === 0 && pendingOutgoing.length === 0 && ( -
No pending requests.
- )} -
+ {pendingIncoming.length === 0 && pendingOutgoing.length === 0 && ( +
No pending requests.
+ )}
)} diff --git a/Peercord Source/src/components/MainApp.jsx b/Peercord Source/src/components/MainApp.jsx index c2c388e..63546da 100644 --- a/Peercord Source/src/components/MainApp.jsx +++ b/Peercord Source/src/components/MainApp.jsx @@ -61,10 +61,17 @@ export default function MainApp({ profile, setProfile, onLogout, updateState, si const [isFocused, setIsFocused] = useState(true); const activeStateRef = useRef({ view: 'dms', dm: 'friends', channel: 'general-chat', focused: true }); + // FIX: Stable reference for WebRTC listener to prevent dropped calls during React re-renders + const callStateRef = useRef({ activeCall, activeGroupCall, activeVc, knownUsers, dms, servers, profile }); + useEffect(() => { activeStateRef.current = { view: activeView, dm: activeDm, channel: activeChannel, focused: isFocused }; }, [activeView, activeDm, activeChannel, isFocused]); + useEffect(() => { + callStateRef.current = { activeCall, activeGroupCall, activeVc, knownUsers, dms, servers, profile }; + }, [activeCall, activeGroupCall, activeVc, knownUsers, dms, servers, profile]); + useEffect(() => { const onFocus = () => setIsFocused(true); const onBlur = () => setIsFocused(false); @@ -142,49 +149,6 @@ export default function MainApp({ profile, setProfile, onLogout, updateState, si localStorage.setItem('pear_delivered_receipts', JSON.stringify(deliveredReceipts)); },[deliveredReceipts]); - // Secure P2P Account Sync Responder - useEffect(() => { - if (!myKey) return; - let syncSwarm; - try { - const b4a = window.require('b4a'); - const sodium = window.require('sodium-native'); - const Hyperswarm = window.require('hyperswarm'); - - syncSwarm = new Hyperswarm(); - const syncTopic = b4a.alloc(32); - sodium.crypto_generichash(syncTopic, b4a.from('peercord-sync:' + myKey)); - - syncSwarm.on('connection', (conn) => { - conn.on('data', async (data) => { - try { - const msg = JSON.parse(b4a.toString(data)); - if (msg.type === 'account_sync_request') { - const sigBuf = b4a.from(msg.signature, 'hex'); - const msgBuf = b4a.from('sync-request:' + msg.tempKey); - const pubBuf = b4a.from(myKey, 'hex'); - - if (sodium.crypto_sign_verify_detached(sigBuf, msgBuf, pubBuf)) { - console.log("[Sync] Valid sync request received. Exporting account..."); - const exportData = await network.exportAccount(); - const payload = b4a.from(JSON.stringify({ type: 'account_sync_reply', data: exportData })); - conn.write(payload); - } - } - } catch (e) {} - }); - }); - - syncSwarm.join(syncTopic, { server: true, client: false }); - } catch (e) { - console.error("Failed to start sync swarm", e); - } - - return () => { - if (syncSwarm) syncSwarm.destroy(); - }; - }, [myKey]); - useEffect(() => { if (!initialized.current && typeof window !== 'undefined') { initialized.current = true; @@ -429,8 +393,10 @@ export default function MainApp({ profile, setProfile, onLogout, updateState, si } },[servers, dms, activeView, activeDm]); + // FIX: Use stable ref for WebRTC listener to prevent dropped calls useEffect(() => { const handleWebRTC = (peerKey, payload) => { + const { activeCall, activeGroupCall, activeVc, knownUsers, dms, servers, profile } = callStateRef.current; const notifyCalls = localStorage.getItem('pear_notify_calls') !== 'false'; if (payload.type === 'webrtc-init') { @@ -474,9 +440,10 @@ export default function MainApp({ profile, setProfile, onLogout, updateState, si } } }; + network.addWebRTCListener(handleWebRTC); return () => network.removeWebRTCListener(handleWebRTC); - },[activeCall, activeGroupCall, activeVc, knownUsers, dms, servers]); + }, []); const handleSaveProfile = (newName, newAvatar, newUsername, newBio, newConnections) => { const updatedProfile = { diff --git a/Peercord Source/src/components/OnlineUsers.jsx b/Peercord Source/src/components/OnlineUsers.jsx index be96e86..c9fc820 100644 --- a/Peercord Source/src/components/OnlineUsers.jsx +++ b/Peercord Source/src/components/OnlineUsers.jsx @@ -104,9 +104,7 @@ export default function OnlineUsers({ onlinePeers, knownUsers, dms, myKey, profi user={selectedUser} onClose={() => setSelectedUser(null)} onSendDM={selectedUser.key !== myKey ? (u) => { - if (!dms[u.key]) { - network.sendDMRequest(u.key, { displayName: u.displayName, username: u.username, avatar: u.avatar, bio: u.bio, connections: u.connections }); - } + network.openDM(u.key, { displayName: u.displayName, username: u.username, avatar: u.avatar, bio: u.bio, connections: u.connections }); if (onNavigateToDM) onNavigateToDM(u.key); } : null} /> diff --git a/Peercord Source/src/components/SetupScreen.jsx b/Peercord Source/src/components/SetupScreen.jsx index bcaa9f2..0a19baf 100644 --- a/Peercord Source/src/components/SetupScreen.jsx +++ b/Peercord Source/src/components/SetupScreen.jsx @@ -77,7 +77,8 @@ export default function SetupScreen({ setProfile }) { sodium.crypto_sign_seed_keypair(realPubKey, realSecKey, seedBuf); const realPubKeyHex = b4a.toString(realPubKey, 'hex'); - const tempSwarm = new Hyperswarm(); + // FIX: Added ephemeral: true to prevent this background swarm from exhausting the router NAT table + const tempSwarm = new Hyperswarm({ ephemeral: true }); const syncTopic = b4a.alloc(32); sodium.crypto_generichash(syncTopic, b4a.from('peercord-sync:' + realPubKeyHex)); @@ -89,18 +90,25 @@ export default function SetupScreen({ setProfile }) { const sigBuf = b4a.alloc(sodium.crypto_sign_BYTES); sodium.crypto_sign_detached(sigBuf, msgBuf, realSecKey); - conn.write(b4a.from(JSON.stringify({ - type: 'account_sync_request', - tempKey: tempKeyHex, - signature: b4a.toString(sigBuf, 'hex') - }))); + const Protomux = window.require('protomux'); + const cenc = window.require('compact-encoding'); + + const mux = Protomux.from(conn); + const channel = mux.createChannel({ protocol: 'peercord/app' }); + if (!channel) return; - conn.on('data', async (data) => { - try { - const msg = JSON.parse(b4a.toString(data)); - if (msg.type === 'account_sync_reply' && msg.data) { + const appEncoding = { + preencode(state, m) { cenc.string.preencode(state, JSON.stringify(m)); }, + encode(state, m) { cenc.string.encode(state, JSON.stringify(m)); }, + decode(state) { return JSON.parse(cenc.string.decode(state)); } + }; + + const appMessage = channel.addMessage({ + encoding: appEncoding, + onmessage: async (msg) => { + if (msg.type === 'ephemeral' && msg.payload?.type === 'account_sync_reply' && msg.payload.data) { setSyncStatus('Syncing data...'); - const importedProfile = await network.importAccount(msg.data); + const importedProfile = await network.importAccount(msg.payload.data); synced = true; tempSwarm.destroy(); @@ -108,10 +116,21 @@ export default function SetupScreen({ setProfile }) { localStorage.setItem('pear_discord_identity', JSON.stringify(importedProfile)); setProfile(importedProfile); } - } catch (err) { - console.error("Sync parse error", err); } }); + + channel.open(); + + try { + appMessage.send({ + type: 'ephemeral', + payload: { + type: 'account_sync_request', + tempKey: tempKeyHex, + signature: b4a.toString(sigBuf, 'hex') + } + }); + } catch (e) {} }); tempSwarm.join(syncTopic, { client: true, server: false }); diff --git a/Peercord Source/src/p2p/handlers.js b/Peercord Source/src/p2p/handlers.js index a902ad3..bcef91a 100644 --- a/Peercord Source/src/p2p/handlers.js +++ b/Peercord Source/src/p2p/handlers.js @@ -1,21 +1,19 @@ const b4a = window.require('b4a'); -export async function handleData(network, peerKey, data, conn) { +export async function handleData(network, peerKey, parsed, send) { try { - const parsed = JSON.parse(b4a.toString(data)); - switch (parsed.type) { case 'identity': await handleIdentity(network, peerKey, parsed); break; case 'whois': - handleWhois(network, parsed, conn); + handleWhois(network, parsed, send); break; case 'whois_reply': handleWhoisReply(network, parsed); break; case 'ephemeral': - handleEphemeral(network, peerKey, parsed, conn); + handleEphemeral(network, peerKey, parsed, send); break; default: // Could be a standard message core, which is handled by replication, not this handler. @@ -96,12 +94,11 @@ async function handleIdentity(network, peerKey, parsed) { } } -function handleWhois(network, parsed, conn) { +function handleWhois(network, parsed, send) { const uname = parsed.username; if (network.userDirectory.has(uname)) { const cached = network.userDirectory.get(uname); - const reply = b4a.from(JSON.stringify({ type: 'whois_reply', queryId: parsed.queryId, username: uname, pubKey: cached.pubKey, profile: cached.profile })); - conn.write(reply); + send({ type: 'whois_reply', queryId: parsed.queryId, username: uname, pubKey: cached.pubKey, profile: cached.profile }); } } @@ -114,7 +111,7 @@ function handleWhoisReply(network, parsed) { network._checkPendingRequests(parsed.username, parsed.pubKey, parsed.profile); } -function handleEphemeral(network, peerKey, parsed, conn) { +function handleEphemeral(network, peerKey, parsed, send) { const { payload } = parsed; if (!payload) return; @@ -124,7 +121,7 @@ function handleEphemeral(network, peerKey, parsed, conn) { .filter(([_, data]) => data.status === 'pending_outgoing') .map(([key]) => key); - const identityMsg = JSON.stringify({ + const identityMsg = { type: 'identity', displayName: network.displayName, username: network.username, @@ -134,8 +131,8 @@ function handleEphemeral(network, peerKey, parsed, conn) { coreKey: network.coreKey, topics: Array.from(network.joinedTopics), pendingTargets - }); - try { conn.write(b4a.from(identityMsg)); } catch(e) {} + }; + try { send(identityMsg); } catch(e) {} } return; } diff --git a/Peercord Source/src/p2p/index.js b/Peercord Source/src/p2p/index.js index d4482cf..9906614 100644 --- a/Peercord Source/src/p2p/index.js +++ b/Peercord Source/src/p2p/index.js @@ -1,5 +1,5 @@ const b4a = window.require('b4a'); -import { generateUUID, Hyperswarm, Corestore, Hyperbee, sodium, fs, os, path, http } from './utils.js'; +import { generateUUID, Hyperswarm, Corestore, Hyperbee, sodium, fs, os, path, http, Protomux, cenc, DHT } from './utils.js'; import * as Identity from './modules/identity.js'; import { handleData } from './handlers.js'; import { getAllMessages, processMessage, sendDMRequest, sendMessage, sendDM, sendEditMessage, sendDeleteMessage, acceptDMRequest, sendEphemeral, sendReadReceipt, sendDeliveredReceipt, sendOffline, sendTyping, sendReaction, _appendSignedMessage, _appendEncryptedMessage } from './modules/messaging.js'; @@ -40,6 +40,8 @@ class P2PNetwork { this.pendingFriendRequests = new Set(); this.messages = new Map(); + this.reactions = new Map(); // targetId -> { emoji: [senders] } + this.processedSigs = new Set(); // signature -> true (prevents double processing) this.deletedMessages = new Set(); this.dms = {}; this.servers =[]; @@ -133,6 +135,7 @@ class P2PNetwork { } this.deletedMessages.add(msgId); this.messages.delete(msgId); + this.reactions.delete(msgId); if (!localDeleted.includes(msgId)) localDeleted.push(msgId); if (this.transfers[msgId]) delete this.transfers[msgId]; } @@ -173,6 +176,21 @@ class P2PNetwork { updateProfile = (name, avatar, username, bio, connections) => Identity.updateProfile(this, name, avatar, username, bio, connections); + async openDM(targetKey, profile) { + if (this.dms[targetKey]) { + this.dms[targetKey].isOpen = true; + if (profile) { + this.dms[targetKey].profile = { ...this.dms[targetKey].profile, ...profile }; + this.knownProfiles.set(targetKey, this.dms[targetKey].profile); + this._emitKnownProfiles(); + } + await this.db.put('dm:' + targetKey, this.dms[targetKey]); + if (this.onDMsUpdate) this.onDMsUpdate({ ...this.dms }); + } else { + await this.sendDMRequest(targetKey, profile); + } + } + async closeDM(targetKey) { if (this.dms[targetKey]) { this.dms[targetKey].isOpen = false; @@ -374,7 +392,9 @@ class P2PNetwork { async checkUsernameAvailable(username) { const normalized = username.toLowerCase(); - const tempSwarm = new Hyperswarm({ maxPeers: 3, maxClientConnections: 3, maxServerConnections: 0 }); + // FIX: Explicitly pass ephemeral DHT to prevent router exhaustion + const dht = new DHT({ ephemeral: true }); + const tempSwarm = new Hyperswarm({ dht, maxPeers: 3, maxClientConnections: 3, maxServerConnections: 0 }); const topic = b4a.alloc(32); sodium.crypto_generichash(topic, b4a.from('peercord-user:' + normalized)); @@ -418,21 +438,20 @@ class P2PNetwork { .filter(([_, data]) => data.status === 'pending_outgoing') .map(([key]) => key); - const identityMsg = JSON.stringify({ - type: 'identity', - displayName: this.displayName, - username: this.username, - avatar: this.avatar, - bio: this.bio, - connections: this.connections, + const identityMsg = { + type: 'identity', + displayName: this.displayName, + username: this.username, + avatar: this.avatar, + bio: this.bio, + connections: this.connections, coreKey: this.coreKey, topics: Array.from(this.joinedTopics), pendingTargets - }); - - const payload = b4a.from(identityMsg); - for (const { conn } of this.peers.values()) { - try { conn.write(payload); } catch(e) {} + }; + + for (const { send } of this.peers.values()) { + if (send) send(identityMsg); } } @@ -534,47 +553,104 @@ class P2PNetwork { } await Promise.all(corePromises); - // SETUP SWARM + // Compact-encoding codec for the JSON app protocol. Built on cenc.string + // (utf-8) so it works regardless of whether this compact-encoding build + // ships a dedicated `json` codec. + const appEncoding = { + preencode(state, m) { cenc.string.preencode(state, JSON.stringify(m)); }, + encode(state, m) { cenc.string.encode(state, JSON.stringify(m)); }, + decode(state) { return JSON.parse(cenc.string.decode(state)); } + }; + + // FIX: Explicitly create an ephemeral DHT instance to guarantee we don't route traffic for others + // Also limit maxPeers to protect cheap home routers from NAT exhaustion + const dht = new DHT({ ephemeral: true }); this.swarm = new Hyperswarm({ keyPair: { publicKey, secretKey }, - ephemeral: true, // CRITICAL FIX: Prevents node from becoming a routing node and exhausting router NAT table - maxPeers: 128 + dht, + maxPeers: 24, + maxClientConnections: 12, + maxServerConnections: 12 }); this.swarm.on('connection', (conn, info) => { conn.on('error', () => {}); // Prevent ECONNRESET crashes this.store.replicate(conn); const peerKey = b4a.toString(info.publicKey, 'hex'); - + + // The hyperswarm connection is a Noise stream that corestore wraps in + // Protomux for replication framing. Raw conn.write would corrupt that + // mux, so the JSON app protocol rides on its own Protomux channel that + // shares the same connection with replication. + const mux = Protomux.from(conn); + const channel = mux.createChannel({ protocol: 'peercord/app' }); + + // createChannel returns null if a channel for this protocol already + // exists on the connection (e.g. a duplicate/multiplexed link). Bail + // gracefully but keep the connection alive for replication. + if (!channel) { + if (this.onPeerUpdate) this.onPeerUpdate(this.getPeerList()); + return; + } + + const appMessage = channel.addMessage({ + encoding: appEncoding, + onmessage: (msg) => { + // Intercept Account Sync Requests directly on the main swarm + if (msg.type === 'ephemeral' && msg.payload?.type === 'account_sync_request') { + try { + const sigBuf = b4a.from(msg.payload.signature, 'hex'); + const msgBuf = b4a.from('sync-request:' + msg.payload.tempKey); + const pubBuf = b4a.from(this.myKey, 'hex'); + + if (sodium.crypto_sign_verify_detached(sigBuf, msgBuf, pubBuf)) { + console.log("[Sync] Valid sync request received. Exporting account..."); + this.exportAccount().then(exportData => { + send({ type: 'ephemeral', payload: { type: 'account_sync_reply', data: exportData } }); + }); + } + } catch (e) { + console.error("Sync request error:", e); + } + return; + } + handleData(this, peerKey, msg, send); + } + }); + + const send = (obj) => { + try { appMessage.send(obj); } catch (e) {} + }; + + channel.open(); + // Preserve existing peer info if connection multiplexes const existingPeer = this.peers.get(peerKey); if (existingPeer) { existingPeer.conn = conn; + existingPeer.send = send; } else { - this.peers.set(peerKey, { conn, displayName: 'Unknown', username: 'unknown', avatar: null, bio: '', connections: [], coreKey: null }); + this.peers.set(peerKey, { conn, send, displayName: 'Unknown', username: 'unknown', avatar: null, bio: '', connections: [], coreKey: null }); } - + const pendingTargets = Object.entries(this.dms) .filter(([_, data]) => data.status === 'pending_outgoing') .map(([key]) => key); - const identityMsg = JSON.stringify({ - type: 'identity', - displayName: this.displayName, - username: this.username, - avatar: this.avatar, - bio: this.bio, - connections: this.connections, + send({ + type: 'identity', + displayName: this.displayName, + username: this.username, + avatar: this.avatar, + bio: this.bio, + connections: this.connections, coreKey: this.coreKey, topics: Array.from(this.joinedTopics), pendingTargets }); - - conn.write(b4a.from(identityMsg)); + if (this.onPeerUpdate) this.onPeerUpdate(this.getPeerList()); - - conn.on('data', async (data) => handleData(this, peerKey, data, conn)); - + conn.on('close', () => { // Only delete if this specific connection is still the active one const currentPeer = this.peers.get(peerKey); @@ -587,7 +663,14 @@ class P2PNetwork { // BACKGROUND JOINS TO PREVENT UDP FLOOD / NAT EXHAUSTION (async () => { - const pace = () => new Promise(r => setTimeout(r, 1000)); // 1000ms between DHT lookups + // FIX: Increased pacing to 3 seconds to protect router NAT tables + const pace = () => new Promise(r => setTimeout(r, 3000)); + + // Join the sync topic on the main swarm instead of creating a second swarm + const syncTopic = b4a.alloc(32); + sodium.crypto_generichash(syncTopic, b4a.from('peercord-sync:' + this.myKey)); + this.swarm.join(syncTopic, { server: true, client: false }); + await pace(); if (this.username && this.username !== 'unknown') { const myTopic = b4a.alloc(32); @@ -668,6 +751,8 @@ class P2PNetwork { this.pendingWhois.clear(); this.pendingFriendRequests.clear(); this.messages.clear(); + this.reactions.clear(); + this.processedSigs.clear(); this.deletedMessages.clear(); this.dms = {}; this.servers =[]; @@ -774,6 +859,7 @@ class P2PNetwork { this.deletedMessages.add(msgId); this.messages.delete(msgId); + this.reactions.delete(msgId); if (typeof window !== 'undefined') { const localDeleted = JSON.parse(localStorage.getItem('pear_local_deleted_msgs') || '[]'); diff --git a/Peercord Source/src/p2p/modules/discovery.js b/Peercord Source/src/p2p/modules/discovery.js index 83bfd4f..3ec9660 100644 --- a/Peercord Source/src/p2p/modules/discovery.js +++ b/Peercord Source/src/p2p/modules/discovery.js @@ -41,9 +41,9 @@ export async function searchUser(network, targetUsername) { finish(result); }); - const msg = b4a.from(JSON.stringify({ type: 'whois', queryId, username: normalized })); - for (const { conn } of network.peers.values()) { - try { conn.write(msg); } catch(e) {} + const msg = { type: 'whois', queryId, username: normalized }; + for (const { send } of network.peers.values()) { + if (send) send(msg); } }); } diff --git a/Peercord Source/src/p2p/modules/messaging.js b/Peercord Source/src/p2p/modules/messaging.js index c6ef076..545e9ea 100644 --- a/Peercord Source/src/p2p/modules/messaging.js +++ b/Peercord Source/src/p2p/modules/messaging.js @@ -45,6 +45,13 @@ export function getAllMessages(network) { const isInvite = m.payload.type === 'server_invite'; const isFile = m.payload.type === 'file'; + // Deep clone reactions to ensure React detects the state change and re-renders + const rawReactions = network.reactions.get(m.payload.id) || {}; + const clonedReactions = {}; + for (const [emoji, users] of Object.entries(rawReactions)) { + clonedReactions[emoji] = [...users]; + } + return { id: m.payload.id, channel: m.recipient ? m.recipient : m.payload.channel, @@ -58,7 +65,7 @@ export function getAllMessages(network) { logicalTime: m.payload.logicalTime || 0, edited: m.payload.edited || false, replyTo: m.payload.replyTo || null, - reactions: m.reactions || {}, + reactions: clonedReactions, sender: m.sender, senderName: known ? known.displayName : 'Unknown', senderAvatar: known ? known.avatar : null, @@ -74,7 +81,26 @@ export function getAllMessages(network) { } export async function processMessage(network, msg) { - if (!msg || !msg.sender) return; + if (!msg || !msg.sender || !msg.signature) return; + + // FIX: Prevent double-processing of messages caused by Hypercore's download+append event race condition + if (network.processedSigs.has(msg.signature)) return; + network.processedSigs.add(msg.signature); + + const applyReaction = (targetId, emoji, sender) => { + if (!network.reactions.has(targetId)) network.reactions.set(targetId, {}); + const msgReactions = network.reactions.get(targetId); + if (!msgReactions[emoji]) msgReactions[emoji] = []; + + const idx = msgReactions[emoji].indexOf(sender); + if (idx > -1) { + msgReactions[emoji].splice(idx, 1); + if (msgReactions[emoji].length === 0) delete msgReactions[emoji]; + } else { + msgReactions[emoji].push(sender); + } + network._emitMessages(); + }; if (msg.recipient) { if (msg.recipient !== network.myKey && msg.sender !== network.myKey) return; @@ -114,20 +140,7 @@ export async function processMessage(network, msg) { } if (decrypted.type === 'reaction') { - const targetMsg = network.messages.get(decrypted.targetId); - if (targetMsg) { - if (!targetMsg.reactions) targetMsg.reactions = {}; - if (!targetMsg.reactions[decrypted.emoji]) targetMsg.reactions[decrypted.emoji] = []; - - const idx = targetMsg.reactions[decrypted.emoji].indexOf(msg.sender); - if (idx > -1) { - targetMsg.reactions[decrypted.emoji].splice(idx, 1); - if (targetMsg.reactions[decrypted.emoji].length === 0) delete targetMsg.reactions[decrypted.emoji]; - } else { - targetMsg.reactions[decrypted.emoji].push(msg.sender); - } - network._emitMessages(); - } + applyReaction(decrypted.targetId, decrypted.emoji, msg.sender); return; } @@ -138,6 +151,20 @@ export async function processMessage(network, msg) { network.knownProfiles.set(msg.sender, msg.payload.profile); network._emitKnownProfiles(); if (network.onDMsUpdate) network.onDMsUpdate({ ...network.dms }); + } else if (network.dms[msg.sender].status === 'pending_outgoing') { + // Mutual request! Auto-accept. + network.dms[msg.sender].status = 'accepted'; + network.dms[msg.sender].isOpen = true; + if (msg.payload.profile) { + network.dms[msg.sender].profile = { ...network.dms[msg.sender].profile, ...msg.payload.profile }; + network.knownProfiles.set(msg.sender, network.dms[msg.sender].profile); + network._emitKnownProfiles(); + } + await network.db.put('dm:' + msg.sender, network.dms[msg.sender]); + if (network.onDMsUpdate) network.onDMsUpdate({ ...network.dms }); + + // Send an accept back just in case + acceptDMRequest(network, msg.sender); } } else if (msg.payload.type === 'dm_accept' && msg.sender !== network.myKey) { if (network.dms[msg.sender] && network.dms[msg.sender].status === 'pending_outgoing') { @@ -147,6 +174,13 @@ export async function processMessage(network, msg) { if (network.onDMsUpdate) network.onDMsUpdate({ ...network.dms }); } } else if (msg.payload.type === 'dm_chat' || msg.payload.type === 'file') { + if (network.dms[msg.sender] && network.dms[msg.sender].status === 'pending_outgoing') { + network.dms[msg.sender].status = 'accepted'; + network.dms[msg.sender].isOpen = true; + await network.db.put('dm:' + msg.sender, network.dms[msg.sender]); + if (network.onDMsUpdate) network.onDMsUpdate({ ...network.dms }); + } + if (!network.deletedMessages.has(msg.payload.id) && !network.messages.has(msg.payload.id)) { network.messages.set(msg.payload.id, msg); network._emitMessages(); @@ -323,18 +357,8 @@ export async function processMessage(network, msg) { } } - if (canReact && targetMsg) { - if (!targetMsg.reactions) targetMsg.reactions = {}; - if (!targetMsg.reactions[emoji]) targetMsg.reactions[emoji] = []; - - const idx = targetMsg.reactions[emoji].indexOf(msg.sender); - if (idx > -1) { - targetMsg.reactions[emoji].splice(idx, 1); - if (targetMsg.reactions[emoji].length === 0) delete targetMsg.reactions[emoji]; - } else { - targetMsg.reactions[emoji].push(msg.sender); - } - network._emitMessages(); + if (canReact) { + applyReaction(targetId, emoji, msg.sender); } return; } @@ -363,6 +387,7 @@ export async function processMessage(network, msg) { if (canDelete) { network.deletedMessages.add(targetId); network.messages.delete(targetId); + network.reactions.delete(targetId); network._emitMessages(); } return; @@ -532,9 +557,9 @@ export async function sendReaction(network, targetId, emoji, isDM = false, targe export function sendEphemeral(network, payload) { if (!network.swarm) return; - const msg = b4a.from(JSON.stringify({ type: 'ephemeral', payload })); - for (const { conn } of network.peers.values()) { - try { conn.write(msg); } catch(e) {} + const msg = { type: 'ephemeral', payload }; + for (const { send } of network.peers.values()) { + if (send) send(msg); } } diff --git a/Peercord Source/src/p2p/modules/webrtc.js b/Peercord Source/src/p2p/modules/webrtc.js index 677bbc1..7e50c46 100644 --- a/Peercord Source/src/p2p/modules/webrtc.js +++ b/Peercord Source/src/p2p/modules/webrtc.js @@ -11,8 +11,7 @@ export function removeWebRTCListener(network, fn) { export function sendWebRTCSignal(network, targetKey, payload) { if (!network.swarm) return; const peerInfo = network.peers.get(targetKey); - if (peerInfo && peerInfo.conn) { - const msg = b4a.from(JSON.stringify({ type: 'ephemeral', payload })); - peerInfo.conn.write(msg); + if (peerInfo && peerInfo.send) { + peerInfo.send({ type: 'ephemeral', payload }); } } \ No newline at end of file diff --git a/Peercord Source/src/p2p/network.js b/Peercord Source/src/p2p/network.js index ee90c29..27aea57 100644 --- a/Peercord Source/src/p2p/network.js +++ b/Peercord Source/src/p2p/network.js @@ -1,393 +1,4 @@ -const b4a = window.require('b4a'); -const crypto = window.require('crypto'); -const Hyperswarm = window.require('hyperswarm'); - -export async function initNetwork() { - // Kept for legacy compatibility if imported elsewhere -} - -class P2PNetwork { - constructor() { - this.swarm = null; - this.peers = new Set(); - this.onPeerConnect = null; - this.onPeerDisconnect = null; - } - - async initialize() { - try { - this.swarm = new Hyperswarm(); - - this.swarm.on('connection', (conn, info) => { - const peerKey = b4a.toString(info.publicKey, 'hex'); - this.peers.add(peerKey); - - if (this.onPeerConnect) this.onPeerConnect(peerKey); - - conn.on('close', () => { - this.peers.delete(peerKey); - if (this.onPeerDisconnect) this.onPeerDisconnect(peerKey); - }); - - conn.on('data', (data) => { - console.log(`Received data from ${peerKey}:`, data.toString()); - }); - }); - - console.log('P2P Network Initialized'); - } catch (err) { - console.error('Failed to initialize Hyperswarm.', err); - } - } - - async joinGlobalServer() { - if (!this.swarm) return; - const globalTopicSeed = crypto.createHash('sha256').update('GLOBAL_MAIN_SERVER_V1').digest(); - - const discovery = this.swarm.join(globalTopicSeed, { client: true, server: true }); - await discovery.flushed(); - console.log('Joined Global Main Server Swarm'); - } -} - -export const networkLegacy = new P2PNetwork(); - ---- START OF FILE src/p2p/modules/discovery.js --- -const b4a = window.require('b4a'); -import { generateUUID, sodium } from '../utils.js'; - -export async function searchUser(network, targetUsername) { - const normalized = targetUsername.toLowerCase(); - - if (network.userDirectory.has(normalized)) { - return network.userDirectory.get(normalized); - } - - const topic = b4a.alloc(32); - sodium.crypto_generichash(topic, b4a.from('peercord-user:' + normalized)); - network.swarm.join(topic, { client: true, server: false }); - - return new Promise((resolve) => { - let resolved = false; - - const finish = (result) => { - if (resolved) return; - resolved = true; - network.swarm.leave(topic); - resolve(result); - }; - - const timeout = setTimeout(() => { - finish(null); - }, 5000); - - const interval = setInterval(() => { - if (network.userDirectory.has(normalized)) { - clearTimeout(timeout); - clearInterval(interval); - finish(network.userDirectory.get(normalized)); - } - }, 500); - - const queryId = generateUUID(); - network.pendingWhois.set(queryId, (result) => { - clearTimeout(timeout); - clearInterval(interval); - finish(result); - }); - - const msg = b4a.from(JSON.stringify({ type: 'whois', queryId, username: normalized })); - for (const { conn } of network.peers.values()) { - conn.write(msg); - } - }); -} - -export async function queueFriendRequest(network, targetUsername) { - const uname = targetUsername.toLowerCase(); - network.pendingFriendRequests.add(uname); - await network.pendingRequestsDb.put(uname, { timestamp: Date.now() }); - - const topic = b4a.alloc(32); - sodium.crypto_generichash(topic, b4a.from('peercord-user:' + uname)); - network.swarm.join(topic, { client: true, server: false }); -} - -export async function trackPeerCore(network, coreKeyHex) { - if (network.peerCores.has(coreKeyHex)) return; - const core = network.store.get({ key: b4a.from(coreKeyHex, 'hex'), valueEncoding: 'json' }); - await core.ready(); - network.peerCores.set(coreKeyHex, core); - - let processedSeq = -1; - - for (let i = 0; i < core.length; i++) { - const msg = await core.get(i); - network.processMessage(msg); - processedSeq = i; - } - - core.on('append', async () => { - network._emitSync(); - for (let i = processedSeq + 1; i < core.length; i++) { - const msg = await core.get(i); - network.processMessage(msg); - processedSeq = i; - } - }); -} - ---- START OF FILE src/p2p/modules/files.js --- -const b4a = window.require('b4a'); -import { generateUUID, fs, path, os } from '../utils.js'; - -async function _hostFile(network, id, fileObj, fileCore) { - network.transfers[id] = { progress: 0, speed: 0, state: 'processing' }; - if (network.onTransfersUpdate) network.onTransfersUpdate(network.transfers); - - let processedBytes = 0; - let lastTime = Date.now(); - let lastBytes = 0; - - const updateProcessingProgress = (chunkLength) => { - processedBytes += chunkLength; - const now = Date.now(); - if (now - lastTime >= 250 || processedBytes >= fileObj.size) { - const timeDiff = (now - lastTime) / 1000; - const speed = timeDiff > 0 ? (processedBytes - lastBytes) / timeDiff : 0; - const progress = Math.min(1, processedBytes / fileObj.size); - - network.transfers[id] = { progress, speed, state: 'processing' }; - if (network.onTransfersUpdate) network.onTransfersUpdate(network.transfers); - - lastTime = now; - lastBytes = processedBytes; - } - }; - - if (fileObj.path && fs) { - const stream = fs.createReadStream(fileObj.path, { highWaterMark: 64 * 1024 }); - for await (const chunk of stream) { - await fileCore.append(chunk); - updateProcessingProgress(chunk.length); - } - } else if (fileObj.fileObj && typeof fileObj.fileObj.stream === 'function') { - const stream = fileObj.fileObj.stream(); - const reader = stream.getReader(); - while (true) { - const { done, value } = await reader.read(); - if (done) break; - await fileCore.append(b4a.from(value)); - updateProcessingProgress(value.length); - } - } else if (fileObj.buffer) { - const buf = b4a.from(fileObj.buffer); - const chunkSize = 64 * 1024; - for(let i=0; i= fileMeta.size) { - const msg = network.messages.get(msgId); - if (msg) { - msg.localPath = filePath; - msg.isMediaInDB = isMedia; - network._emitMessages(); - } - await network.localFilesDb.put(msgId, filePath); - network.transfers[msgId] = { progress: 1, speed: 0, state: 'completed' }; - if (network.onTransfersUpdate) network.onTransfersUpdate(network.transfers); - return; - } else { - try { fs.unlinkSync(filePath); } catch(e) {} - } - } - - network.transfers[msgId] = { progress: 0, speed: 0, state: 'downloading' }; - if (network.onTransfersUpdate) network.onTransfersUpdate(network.transfers); - - const readStream = core.createReadStream({ live: true }); - const writeStream = fs.createWriteStream(filePath); - - let downloadedBytes = 0; - let lastTime = Date.now(); - let lastBytes = 0; - let isFinished = false; - - const sendProgress = (progress, speed) => { - network.transfers[msgId] = { progress, speed, state: 'downloading' }; - if (network.onTransfersUpdate) network.onTransfersUpdate(network.transfers); - network.sendEphemeral({ type: 'transfer_progress', id: msgId, progress, speed }); - }; - - writeStream.on('finish', async () => { - const msg = network.messages.get(msgId); - if (msg) { - msg.localPath = filePath; - msg.isMediaInDB = isMedia; - network._emitMessages(); - } - await network.localFilesDb.put(msgId, filePath); - network.transfers[msgId] = { progress: 1, speed: 0, state: 'completed' }; - if (network.onTransfersUpdate) network.onTransfersUpdate(network.transfers); - sendProgress(1, 0); - }); - - writeStream.on('error', (err) => { - console.error("File write error:", err); - }); - - if (fileMeta.size === 0) { - writeStream.end(); - return; - } - - readStream.on('data', (chunk) => { - if (isFinished) return; - - downloadedBytes += chunk.length; - writeStream.write(chunk); - - const now = Date.now(); - if (now - lastTime >= 500 || downloadedBytes >= fileMeta.size) { - const timeDiff = (now - lastTime) / 1000; - const speed = timeDiff > 0 ? (downloadedBytes - lastBytes) / timeDiff : 0; - const progress = Math.min(1, downloadedBytes / fileMeta.size); - - sendProgress(progress, Math.max(0, speed)); - - lastTime = now; - lastBytes = downloadedBytes; - } - - if (downloadedBytes >= fileMeta.size) { - isFinished = true; - readStream.destroy(); - writeStream.end(); - } - }); -} \ No newline at end of file +// DEPRECATED - This file is no longer used and has been emptied to prevent confusion. +// All networking logic is now handled in src/p2p/index.js +export async function initNetwork() {} +export const networkLegacy = {}; \ No newline at end of file diff --git a/Peercord Source/src/p2p/utils.js b/Peercord Source/src/p2p/utils.js index c8b4be7..c235c46 100644 --- a/Peercord Source/src/p2p/utils.js +++ b/Peercord Source/src/p2p/utils.js @@ -1,6 +1,6 @@ const b4a = window.require('b4a'); -export let Hyperswarm, Corestore, Hyperbee, sodium, fs, os, path, http; +export let Hyperswarm, Corestore, Hyperbee, sodium, fs, os, path, http, Protomux, cenc, DHT; // The PUBLIC key is 100% safe to be in the open-source code. // It is mathematically impossible to derive your private seed from it. @@ -16,6 +16,9 @@ export async function initP2P() { os = req('os'); path = req('path'); http = req('http'); + Protomux = req('protomux'); + cenc = req('compact-encoding'); + DHT = req('hyperdht'); } export function generateUUID() {