Some fixes, but networking issues still persists.
This commit is contained in:
0% [█ █ █ █ █ █ █ █ █ █] 100% 2026-06-16 22:20:41 -05:00
parent 1c456702f4
commit 3f8a00cd19
13 changed files with 303 additions and 582 deletions

View File

@ -1,6 +1,6 @@
{ {
"name": "peercord", "name": "peercord",
"version": "1.0.5", "version": "1.0.8",
"description": "Peercord, A P2P Discord clone powered by Pear Runtime", "description": "Peercord, A P2P Discord clone powered by Pear Runtime",
"author": "Mastercodeon", "author": "Mastercodeon",
"main": "index.js", "main": "index.js",
@ -44,11 +44,13 @@
"dependencies": { "dependencies": {
"autobase": "latest", "autobase": "latest",
"b4a": "latest", "b4a": "latest",
"compact-encoding": "latest",
"corestore": "latest", "corestore": "latest",
"hyperbee": "latest", "hyperbee": "latest",
"hypercore": "latest", "hypercore": "latest",
"hyperswarm": "latest", "hyperswarm": "latest",
"pear-runtime": "latest", "pear-runtime": "latest",
"protomux": "latest",
"react": "latest", "react": "latest",
"react-dom": "latest", "react-dom": "latest",
"react-markdown": "^9.0.1", "react-markdown": "^9.0.1",

View File

@ -243,6 +243,7 @@ export default function CallView({ targetKey, targetProfile, myProfile, isCaller
// eslint-disable-next-line react-hooks/exhaustive-deps // eslint-disable-next-line react-hooks/exhaustive-deps
},[status, isCaller, targetKey, mediaReady]); },[status, isCaller, targetKey, mediaReady]);
// FIX: Removed onConnected from dependency array to prevent listener recreation
useEffect(() => { useEffect(() => {
const processSignal = async (payload) => { const processSignal = async (payload) => {
const pc = pcRef.current; const pc = pcRef.current;
@ -265,7 +266,6 @@ export default function CallView({ targetKey, targetProfile, myProfile, isCaller
await pc.addIceCandidate(candidate).catch(console.error); await pc.addIceCandidate(candidate).catch(console.error);
} }
pendingCandidates.current =[]; pendingCandidates.current =[];
onConnected();
} else if (payload.type === 'webrtc-ice-candidate') { } else if (payload.type === 'webrtc-ice-candidate') {
const candidate = new RTCIceCandidate(payload.candidate); const candidate = new RTCIceCandidate(payload.candidate);
@ -314,7 +314,7 @@ export default function CallView({ targetKey, targetProfile, myProfile, isCaller
network.addWebRTCListener(handleSignal); network.addWebRTCListener(handleSignal);
return () => network.removeWebRTCListener(handleSignal); return () => network.removeWebRTCListener(handleSignal);
}, [targetKey, onConnected]); }, [targetKey]);
const toggleMute = () => { const toggleMute = () => {
if (localStreamRef.current) { if (localStreamRef.current) {

View File

@ -70,7 +70,7 @@ export default function FriendsView({ dms, onNavigateToDM }) {
onClick={() => setActiveTab('pending')} 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'}`} 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 && <span className="bg-red-500 text-white text-xs px-1.5 rounded-full ml-1">{pendingIncoming.length}</span>} Pending {(pendingIncoming.length + pendingOutgoing.length) > 0 && <span className="bg-red-500 text-white text-xs px-1.5 rounded-full ml-1">{pendingIncoming.length + pendingOutgoing.length}</span>}
</button> </button>
<button <button
onClick={() => setActiveTab('blocked')} onClick={() => setActiveTab('blocked')}
@ -105,7 +105,10 @@ export default function FriendsView({ dms, onNavigateToDM }) {
</div> </div>
</div> </div>
<div className="flex gap-2 opacity-0 group-hover:opacity-100 transition-opacity"> <div className="flex gap-2 opacity-0 group-hover:opacity-100 transition-opacity">
<button onClick={() => onNavigateToDM(pubKey)} className="w-9 h-9 rounded-full bg-surface flex items-center justify-center text-text hover:bg-accent hover:text-white transition-colors border border-panel" title="Message"> <button onClick={() => {
network.openDM(pubKey, data.profile);
onNavigateToDM(pubKey);
}} className="w-9 h-9 rounded-full bg-surface flex items-center justify-center text-text hover:bg-accent hover:text-white transition-colors border border-panel" title="Message">
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round"><path d="M21 15a2 2 0 0 1-2 2H7l-4 4V5a2 2 0 0 1 2-2h14a2 2 0 0 1 2 2z"></path></svg> <svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round"><path d="M21 15a2 2 0 0 1-2 2H7l-4 4V5a2 2 0 0 1 2-2h14a2 2 0 0 1 2 2z"></path></svg>
</button> </button>
<button onClick={() => handleRemove(pubKey)} className="w-9 h-9 rounded-full bg-surface flex items-center justify-center text-red-500 hover:bg-red-500 hover:text-white transition-colors border border-panel" title="Remove"> <button onClick={() => handleRemove(pubKey)} className="w-9 h-9 rounded-full bg-surface flex items-center justify-center text-red-500 hover:bg-red-500 hover:text-white transition-colors border border-panel" title="Remove">
@ -126,53 +129,64 @@ export default function FriendsView({ dms, onNavigateToDM }) {
{activeTab === 'pending' && ( {activeTab === 'pending' && (
<div> <div>
<h2 className="text-xs font-bold text-muted uppercase mb-4">Pending Requests {pendingIncoming.length + pendingOutgoing.length}</h2> {pendingIncoming.length > 0 && (
<div className="space-y-2"> <div className="mb-6">
{pendingIncoming.map(([pubKey, data]) => ( <h2 className="text-xs font-bold text-muted uppercase mb-4">Incoming Requests {pendingIncoming.length}</h2>
<div key={pubKey} className="flex items-center justify-between p-3 hover:bg-panel rounded-lg border-t border-surface group transition-colors"> <div className="space-y-2">
<div className="flex items-center gap-3"> {pendingIncoming.map(([pubKey, data]) => (
<div className="w-10 h-10 rounded-md bg-indigo-500 flex items-center justify-center text-white font-bold overflow-hidden"> <div key={pubKey} className="flex items-center justify-between p-3 hover:bg-panel rounded-lg border-t border-surface group transition-colors">
{data.profile?.avatar ? <img src={data.profile.avatar} className="w-full h-full object-cover"/> : data.profile?.displayName?.substring(0, 2).toUpperCase()} <div className="flex items-center gap-3">
<div className="w-10 h-10 rounded-md bg-indigo-500 flex items-center justify-center text-white font-bold overflow-hidden">
{data.profile?.avatar ? <img src={data.profile.avatar} className="w-full h-full object-cover"/> : data.profile?.displayName?.substring(0, 2).toUpperCase()}
</div>
<div className="flex flex-col">
<span className="text-text font-bold">{data.profile?.displayName}</span>
<span className="text-xs text-muted">@{data.profile?.username}</span>
</div>
</div>
<div className="flex gap-2">
<button onClick={() => network.acceptDMRequest(pubKey)} className="w-9 h-9 rounded-full bg-surface flex items-center justify-center text-green-500 hover:bg-green-500 hover:text-white transition-colors border border-panel" title="Accept">
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round"><polyline points="20 6 9 17 4 12"></polyline></svg>
</button>
<button onClick={() => handleRemove(pubKey)} className="w-9 h-9 rounded-full bg-surface flex items-center justify-center text-red-500 hover:bg-red-500 hover:text-white transition-colors border border-panel" title="Decline">
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round"><line x1="18" y1="6" x2="6" y2="18"></line><line x1="6" y1="6" x2="18" y2="18"></line></svg>
</button>
</div>
</div> </div>
<div className="flex flex-col"> ))}
<span className="text-text font-bold">{data.profile?.displayName}</span>
<span className="text-xs text-muted">@{data.profile?.username} Incoming Contact Request</span>
</div>
</div>
<div className="flex gap-2">
<button onClick={() => network.acceptDMRequest(pubKey)} className="w-9 h-9 rounded-full bg-surface flex items-center justify-center text-green-500 hover:bg-green-500 hover:text-white transition-colors border border-panel" title="Accept">
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round"><polyline points="20 6 9 17 4 12"></polyline></svg>
</button>
<button onClick={() => handleRemove(pubKey)} className="w-9 h-9 rounded-full bg-surface flex items-center justify-center text-red-500 hover:bg-red-500 hover:text-white transition-colors border border-panel" title="Decline">
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round"><line x1="18" y1="6" x2="6" y2="18"></line><line x1="6" y1="6" x2="18" y2="18"></line></svg>
</button>
</div>
</div> </div>
))} </div>
)}
{pendingOutgoing.map(([pubKey, data]) => ( {pendingOutgoing.length > 0 && (
<div key={pubKey} className="flex items-center justify-between p-3 hover:bg-panel rounded-lg border-t border-surface group transition-colors"> <div>
<div className="flex items-center gap-3"> <h2 className="text-xs font-bold text-muted uppercase mb-4">Outgoing Requests {pendingOutgoing.length}</h2>
<div className="w-10 h-10 rounded-md bg-surface flex items-center justify-center text-muted font-bold overflow-hidden border border-panel"> <div className="space-y-2">
{data.profile?.avatar ? <img src={data.profile.avatar} className="w-full h-full object-cover"/> : data.profile?.displayName?.substring(0, 2).toUpperCase()} {pendingOutgoing.map(([pubKey, data]) => (
<div key={pubKey} className="flex items-center justify-between p-3 hover:bg-panel rounded-lg border-t border-surface group transition-colors">
<div className="flex items-center gap-3">
<div className="w-10 h-10 rounded-md bg-surface flex items-center justify-center text-muted font-bold overflow-hidden border border-panel">
{data.profile?.avatar ? <img src={data.profile.avatar} className="w-full h-full object-cover"/> : data.profile?.displayName?.substring(0, 2).toUpperCase()}
</div>
<div className="flex flex-col">
<span className="text-text font-bold">{data.profile?.displayName}</span>
<span className="text-xs text-muted">@{data.profile?.username}</span>
</div>
</div>
<div className="flex gap-2 opacity-0 group-hover:opacity-100 transition-opacity">
<button onClick={() => handleRemove(pubKey)} className="w-9 h-9 rounded-full bg-surface flex items-center justify-center text-red-500 hover:bg-red-500 hover:text-white transition-colors border border-panel" title="Cancel Request">
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round"><line x1="18" y1="6" x2="6" y2="18"></line><line x1="6" y1="6" x2="18" y2="18"></line></svg>
</button>
</div>
</div> </div>
<div className="flex flex-col"> ))}
<span className="text-text font-bold">{data.profile?.displayName}</span>
<span className="text-xs text-muted">@{data.profile?.username} Outgoing Contact Request</span>
</div>
</div>
<div className="flex gap-2 opacity-0 group-hover:opacity-100 transition-opacity">
<button onClick={() => handleRemove(pubKey)} className="w-9 h-9 rounded-full bg-surface flex items-center justify-center text-red-500 hover:bg-red-500 hover:text-white transition-colors border border-panel" title="Cancel Request">
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round"><line x1="18" y1="6" x2="6" y2="18"></line><line x1="6" y1="6" x2="18" y2="18"></line></svg>
</button>
</div>
</div> </div>
))} </div>
)}
{pendingIncoming.length === 0 && pendingOutgoing.length === 0 && ( {pendingIncoming.length === 0 && pendingOutgoing.length === 0 && (
<div className="text-center text-muted mt-10">No pending requests.</div> <div className="text-center text-muted mt-10">No pending requests.</div>
)} )}
</div>
</div> </div>
)} )}

View File

@ -61,10 +61,17 @@ export default function MainApp({ profile, setProfile, onLogout, updateState, si
const [isFocused, setIsFocused] = useState(true); const [isFocused, setIsFocused] = useState(true);
const activeStateRef = useRef({ view: 'dms', dm: 'friends', channel: 'general-chat', focused: 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(() => { useEffect(() => {
activeStateRef.current = { view: activeView, dm: activeDm, channel: activeChannel, focused: isFocused }; activeStateRef.current = { view: activeView, dm: activeDm, channel: activeChannel, focused: isFocused };
}, [activeView, activeDm, activeChannel, isFocused]); }, [activeView, activeDm, activeChannel, isFocused]);
useEffect(() => {
callStateRef.current = { activeCall, activeGroupCall, activeVc, knownUsers, dms, servers, profile };
}, [activeCall, activeGroupCall, activeVc, knownUsers, dms, servers, profile]);
useEffect(() => { useEffect(() => {
const onFocus = () => setIsFocused(true); const onFocus = () => setIsFocused(true);
const onBlur = () => setIsFocused(false); 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)); localStorage.setItem('pear_delivered_receipts', JSON.stringify(deliveredReceipts));
},[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(() => { useEffect(() => {
if (!initialized.current && typeof window !== 'undefined') { if (!initialized.current && typeof window !== 'undefined') {
initialized.current = true; initialized.current = true;
@ -429,8 +393,10 @@ export default function MainApp({ profile, setProfile, onLogout, updateState, si
} }
},[servers, dms, activeView, activeDm]); },[servers, dms, activeView, activeDm]);
// FIX: Use stable ref for WebRTC listener to prevent dropped calls
useEffect(() => { useEffect(() => {
const handleWebRTC = (peerKey, payload) => { const handleWebRTC = (peerKey, payload) => {
const { activeCall, activeGroupCall, activeVc, knownUsers, dms, servers, profile } = callStateRef.current;
const notifyCalls = localStorage.getItem('pear_notify_calls') !== 'false'; const notifyCalls = localStorage.getItem('pear_notify_calls') !== 'false';
if (payload.type === 'webrtc-init') { if (payload.type === 'webrtc-init') {
@ -474,9 +440,10 @@ export default function MainApp({ profile, setProfile, onLogout, updateState, si
} }
} }
}; };
network.addWebRTCListener(handleWebRTC); network.addWebRTCListener(handleWebRTC);
return () => network.removeWebRTCListener(handleWebRTC); return () => network.removeWebRTCListener(handleWebRTC);
},[activeCall, activeGroupCall, activeVc, knownUsers, dms, servers]); }, []);
const handleSaveProfile = (newName, newAvatar, newUsername, newBio, newConnections) => { const handleSaveProfile = (newName, newAvatar, newUsername, newBio, newConnections) => {
const updatedProfile = { const updatedProfile = {

View File

@ -104,9 +104,7 @@ export default function OnlineUsers({ onlinePeers, knownUsers, dms, myKey, profi
user={selectedUser} user={selectedUser}
onClose={() => setSelectedUser(null)} onClose={() => setSelectedUser(null)}
onSendDM={selectedUser.key !== myKey ? (u) => { onSendDM={selectedUser.key !== myKey ? (u) => {
if (!dms[u.key]) { network.openDM(u.key, { displayName: u.displayName, username: u.username, avatar: u.avatar, bio: u.bio, connections: u.connections });
network.sendDMRequest(u.key, { displayName: u.displayName, username: u.username, avatar: u.avatar, bio: u.bio, connections: u.connections });
}
if (onNavigateToDM) onNavigateToDM(u.key); if (onNavigateToDM) onNavigateToDM(u.key);
} : null} } : null}
/> />

View File

@ -77,7 +77,8 @@ export default function SetupScreen({ setProfile }) {
sodium.crypto_sign_seed_keypair(realPubKey, realSecKey, seedBuf); sodium.crypto_sign_seed_keypair(realPubKey, realSecKey, seedBuf);
const realPubKeyHex = b4a.toString(realPubKey, 'hex'); 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); const syncTopic = b4a.alloc(32);
sodium.crypto_generichash(syncTopic, b4a.from('peercord-sync:' + realPubKeyHex)); 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); const sigBuf = b4a.alloc(sodium.crypto_sign_BYTES);
sodium.crypto_sign_detached(sigBuf, msgBuf, realSecKey); sodium.crypto_sign_detached(sigBuf, msgBuf, realSecKey);
conn.write(b4a.from(JSON.stringify({ const Protomux = window.require('protomux');
type: 'account_sync_request', const cenc = window.require('compact-encoding');
tempKey: tempKeyHex,
signature: b4a.toString(sigBuf, 'hex') const mux = Protomux.from(conn);
}))); const channel = mux.createChannel({ protocol: 'peercord/app' });
if (!channel) return;
conn.on('data', async (data) => { const appEncoding = {
try { preencode(state, m) { cenc.string.preencode(state, JSON.stringify(m)); },
const msg = JSON.parse(b4a.toString(data)); encode(state, m) { cenc.string.encode(state, JSON.stringify(m)); },
if (msg.type === 'account_sync_reply' && msg.data) { 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...'); setSyncStatus('Syncing data...');
const importedProfile = await network.importAccount(msg.data); const importedProfile = await network.importAccount(msg.payload.data);
synced = true; synced = true;
tempSwarm.destroy(); tempSwarm.destroy();
@ -108,10 +116,21 @@ export default function SetupScreen({ setProfile }) {
localStorage.setItem('pear_discord_identity', JSON.stringify(importedProfile)); localStorage.setItem('pear_discord_identity', JSON.stringify(importedProfile));
setProfile(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 }); tempSwarm.join(syncTopic, { client: true, server: false });

View File

@ -1,21 +1,19 @@
const b4a = window.require('b4a'); const b4a = window.require('b4a');
export async function handleData(network, peerKey, data, conn) { export async function handleData(network, peerKey, parsed, send) {
try { try {
const parsed = JSON.parse(b4a.toString(data));
switch (parsed.type) { switch (parsed.type) {
case 'identity': case 'identity':
await handleIdentity(network, peerKey, parsed); await handleIdentity(network, peerKey, parsed);
break; break;
case 'whois': case 'whois':
handleWhois(network, parsed, conn); handleWhois(network, parsed, send);
break; break;
case 'whois_reply': case 'whois_reply':
handleWhoisReply(network, parsed); handleWhoisReply(network, parsed);
break; break;
case 'ephemeral': case 'ephemeral':
handleEphemeral(network, peerKey, parsed, conn); handleEphemeral(network, peerKey, parsed, send);
break; break;
default: default:
// Could be a standard message core, which is handled by replication, not this handler. // 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; const uname = parsed.username;
if (network.userDirectory.has(uname)) { if (network.userDirectory.has(uname)) {
const cached = network.userDirectory.get(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 })); send({ type: 'whois_reply', queryId: parsed.queryId, username: uname, pubKey: cached.pubKey, profile: cached.profile });
conn.write(reply);
} }
} }
@ -114,7 +111,7 @@ function handleWhoisReply(network, parsed) {
network._checkPendingRequests(parsed.username, parsed.pubKey, parsed.profile); network._checkPendingRequests(parsed.username, parsed.pubKey, parsed.profile);
} }
function handleEphemeral(network, peerKey, parsed, conn) { function handleEphemeral(network, peerKey, parsed, send) {
const { payload } = parsed; const { payload } = parsed;
if (!payload) return; if (!payload) return;
@ -124,7 +121,7 @@ function handleEphemeral(network, peerKey, parsed, conn) {
.filter(([_, data]) => data.status === 'pending_outgoing') .filter(([_, data]) => data.status === 'pending_outgoing')
.map(([key]) => key); .map(([key]) => key);
const identityMsg = JSON.stringify({ const identityMsg = {
type: 'identity', type: 'identity',
displayName: network.displayName, displayName: network.displayName,
username: network.username, username: network.username,
@ -134,8 +131,8 @@ function handleEphemeral(network, peerKey, parsed, conn) {
coreKey: network.coreKey, coreKey: network.coreKey,
topics: Array.from(network.joinedTopics), topics: Array.from(network.joinedTopics),
pendingTargets pendingTargets
}); };
try { conn.write(b4a.from(identityMsg)); } catch(e) {} try { send(identityMsg); } catch(e) {}
} }
return; return;
} }

View File

@ -1,5 +1,5 @@
const b4a = window.require('b4a'); 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 * as Identity from './modules/identity.js';
import { handleData } from './handlers.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'; 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.pendingFriendRequests = new Set();
this.messages = new Map(); 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.deletedMessages = new Set();
this.dms = {}; this.dms = {};
this.servers =[]; this.servers =[];
@ -133,6 +135,7 @@ class P2PNetwork {
} }
this.deletedMessages.add(msgId); this.deletedMessages.add(msgId);
this.messages.delete(msgId); this.messages.delete(msgId);
this.reactions.delete(msgId);
if (!localDeleted.includes(msgId)) localDeleted.push(msgId); if (!localDeleted.includes(msgId)) localDeleted.push(msgId);
if (this.transfers[msgId]) delete this.transfers[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); 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) { async closeDM(targetKey) {
if (this.dms[targetKey]) { if (this.dms[targetKey]) {
this.dms[targetKey].isOpen = false; this.dms[targetKey].isOpen = false;
@ -374,7 +392,9 @@ class P2PNetwork {
async checkUsernameAvailable(username) { async checkUsernameAvailable(username) {
const normalized = username.toLowerCase(); 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); const topic = b4a.alloc(32);
sodium.crypto_generichash(topic, b4a.from('peercord-user:' + normalized)); sodium.crypto_generichash(topic, b4a.from('peercord-user:' + normalized));
@ -418,21 +438,20 @@ class P2PNetwork {
.filter(([_, data]) => data.status === 'pending_outgoing') .filter(([_, data]) => data.status === 'pending_outgoing')
.map(([key]) => key); .map(([key]) => key);
const identityMsg = JSON.stringify({ const identityMsg = {
type: 'identity', type: 'identity',
displayName: this.displayName, displayName: this.displayName,
username: this.username, username: this.username,
avatar: this.avatar, avatar: this.avatar,
bio: this.bio, bio: this.bio,
connections: this.connections, connections: this.connections,
coreKey: this.coreKey, coreKey: this.coreKey,
topics: Array.from(this.joinedTopics), topics: Array.from(this.joinedTopics),
pendingTargets pendingTargets
}); };
const payload = b4a.from(identityMsg); for (const { send } of this.peers.values()) {
for (const { conn } of this.peers.values()) { if (send) send(identityMsg);
try { conn.write(payload); } catch(e) {}
} }
} }
@ -534,47 +553,104 @@ class P2PNetwork {
} }
await Promise.all(corePromises); 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({ this.swarm = new Hyperswarm({
keyPair: { publicKey, secretKey }, keyPair: { publicKey, secretKey },
ephemeral: true, // CRITICAL FIX: Prevents node from becoming a routing node and exhausting router NAT table dht,
maxPeers: 128 maxPeers: 24,
maxClientConnections: 12,
maxServerConnections: 12
}); });
this.swarm.on('connection', (conn, info) => { this.swarm.on('connection', (conn, info) => {
conn.on('error', () => {}); // Prevent ECONNRESET crashes conn.on('error', () => {}); // Prevent ECONNRESET crashes
this.store.replicate(conn); this.store.replicate(conn);
const peerKey = b4a.toString(info.publicKey, 'hex'); 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 // Preserve existing peer info if connection multiplexes
const existingPeer = this.peers.get(peerKey); const existingPeer = this.peers.get(peerKey);
if (existingPeer) { if (existingPeer) {
existingPeer.conn = conn; existingPeer.conn = conn;
existingPeer.send = send;
} else { } 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) const pendingTargets = Object.entries(this.dms)
.filter(([_, data]) => data.status === 'pending_outgoing') .filter(([_, data]) => data.status === 'pending_outgoing')
.map(([key]) => key); .map(([key]) => key);
const identityMsg = JSON.stringify({ send({
type: 'identity', type: 'identity',
displayName: this.displayName, displayName: this.displayName,
username: this.username, username: this.username,
avatar: this.avatar, avatar: this.avatar,
bio: this.bio, bio: this.bio,
connections: this.connections, connections: this.connections,
coreKey: this.coreKey, coreKey: this.coreKey,
topics: Array.from(this.joinedTopics), topics: Array.from(this.joinedTopics),
pendingTargets pendingTargets
}); });
conn.write(b4a.from(identityMsg));
if (this.onPeerUpdate) this.onPeerUpdate(this.getPeerList()); if (this.onPeerUpdate) this.onPeerUpdate(this.getPeerList());
conn.on('data', async (data) => handleData(this, peerKey, data, conn));
conn.on('close', () => { conn.on('close', () => {
// Only delete if this specific connection is still the active one // Only delete if this specific connection is still the active one
const currentPeer = this.peers.get(peerKey); const currentPeer = this.peers.get(peerKey);
@ -587,7 +663,14 @@ class P2PNetwork {
// BACKGROUND JOINS TO PREVENT UDP FLOOD / NAT EXHAUSTION // BACKGROUND JOINS TO PREVENT UDP FLOOD / NAT EXHAUSTION
(async () => { (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') { if (this.username && this.username !== 'unknown') {
const myTopic = b4a.alloc(32); const myTopic = b4a.alloc(32);
@ -668,6 +751,8 @@ class P2PNetwork {
this.pendingWhois.clear(); this.pendingWhois.clear();
this.pendingFriendRequests.clear(); this.pendingFriendRequests.clear();
this.messages.clear(); this.messages.clear();
this.reactions.clear();
this.processedSigs.clear();
this.deletedMessages.clear(); this.deletedMessages.clear();
this.dms = {}; this.dms = {};
this.servers =[]; this.servers =[];
@ -774,6 +859,7 @@ class P2PNetwork {
this.deletedMessages.add(msgId); this.deletedMessages.add(msgId);
this.messages.delete(msgId); this.messages.delete(msgId);
this.reactions.delete(msgId);
if (typeof window !== 'undefined') { if (typeof window !== 'undefined') {
const localDeleted = JSON.parse(localStorage.getItem('pear_local_deleted_msgs') || '[]'); const localDeleted = JSON.parse(localStorage.getItem('pear_local_deleted_msgs') || '[]');

View File

@ -41,9 +41,9 @@ export async function searchUser(network, targetUsername) {
finish(result); finish(result);
}); });
const msg = b4a.from(JSON.stringify({ type: 'whois', queryId, username: normalized })); const msg = { type: 'whois', queryId, username: normalized };
for (const { conn } of network.peers.values()) { for (const { send } of network.peers.values()) {
try { conn.write(msg); } catch(e) {} if (send) send(msg);
} }
}); });
} }

View File

@ -45,6 +45,13 @@ export function getAllMessages(network) {
const isInvite = m.payload.type === 'server_invite'; const isInvite = m.payload.type === 'server_invite';
const isFile = m.payload.type === 'file'; 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 { return {
id: m.payload.id, id: m.payload.id,
channel: m.recipient ? m.recipient : m.payload.channel, channel: m.recipient ? m.recipient : m.payload.channel,
@ -58,7 +65,7 @@ export function getAllMessages(network) {
logicalTime: m.payload.logicalTime || 0, logicalTime: m.payload.logicalTime || 0,
edited: m.payload.edited || false, edited: m.payload.edited || false,
replyTo: m.payload.replyTo || null, replyTo: m.payload.replyTo || null,
reactions: m.reactions || {}, reactions: clonedReactions,
sender: m.sender, sender: m.sender,
senderName: known ? known.displayName : 'Unknown', senderName: known ? known.displayName : 'Unknown',
senderAvatar: known ? known.avatar : null, senderAvatar: known ? known.avatar : null,
@ -74,7 +81,26 @@ export function getAllMessages(network) {
} }
export async function processMessage(network, msg) { 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) {
if (msg.recipient !== network.myKey && msg.sender !== network.myKey) return; if (msg.recipient !== network.myKey && msg.sender !== network.myKey) return;
@ -114,20 +140,7 @@ export async function processMessage(network, msg) {
} }
if (decrypted.type === 'reaction') { if (decrypted.type === 'reaction') {
const targetMsg = network.messages.get(decrypted.targetId); applyReaction(decrypted.targetId, decrypted.emoji, msg.sender);
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();
}
return; return;
} }
@ -138,6 +151,20 @@ export async function processMessage(network, msg) {
network.knownProfiles.set(msg.sender, msg.payload.profile); network.knownProfiles.set(msg.sender, msg.payload.profile);
network._emitKnownProfiles(); network._emitKnownProfiles();
if (network.onDMsUpdate) network.onDMsUpdate({ ...network.dms }); 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) { } else if (msg.payload.type === 'dm_accept' && msg.sender !== network.myKey) {
if (network.dms[msg.sender] && network.dms[msg.sender].status === 'pending_outgoing') { 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 }); if (network.onDMsUpdate) network.onDMsUpdate({ ...network.dms });
} }
} else if (msg.payload.type === 'dm_chat' || msg.payload.type === 'file') { } 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)) { if (!network.deletedMessages.has(msg.payload.id) && !network.messages.has(msg.payload.id)) {
network.messages.set(msg.payload.id, msg); network.messages.set(msg.payload.id, msg);
network._emitMessages(); network._emitMessages();
@ -323,18 +357,8 @@ export async function processMessage(network, msg) {
} }
} }
if (canReact && targetMsg) { if (canReact) {
if (!targetMsg.reactions) targetMsg.reactions = {}; applyReaction(targetId, emoji, msg.sender);
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();
} }
return; return;
} }
@ -363,6 +387,7 @@ export async function processMessage(network, msg) {
if (canDelete) { if (canDelete) {
network.deletedMessages.add(targetId); network.deletedMessages.add(targetId);
network.messages.delete(targetId); network.messages.delete(targetId);
network.reactions.delete(targetId);
network._emitMessages(); network._emitMessages();
} }
return; return;
@ -532,9 +557,9 @@ export async function sendReaction(network, targetId, emoji, isDM = false, targe
export function sendEphemeral(network, payload) { export function sendEphemeral(network, payload) {
if (!network.swarm) return; if (!network.swarm) return;
const msg = b4a.from(JSON.stringify({ type: 'ephemeral', payload })); const msg = { type: 'ephemeral', payload };
for (const { conn } of network.peers.values()) { for (const { send } of network.peers.values()) {
try { conn.write(msg); } catch(e) {} if (send) send(msg);
} }
} }

View File

@ -11,8 +11,7 @@ export function removeWebRTCListener(network, fn) {
export function sendWebRTCSignal(network, targetKey, payload) { export function sendWebRTCSignal(network, targetKey, payload) {
if (!network.swarm) return; if (!network.swarm) return;
const peerInfo = network.peers.get(targetKey); const peerInfo = network.peers.get(targetKey);
if (peerInfo && peerInfo.conn) { if (peerInfo && peerInfo.send) {
const msg = b4a.from(JSON.stringify({ type: 'ephemeral', payload })); peerInfo.send({ type: 'ephemeral', payload });
peerInfo.conn.write(msg);
} }
} }

View File

@ -1,393 +1,4 @@
const b4a = window.require('b4a'); // DEPRECATED - This file is no longer used and has been emptied to prevent confusion.
const crypto = window.require('crypto'); // All networking logic is now handled in src/p2p/index.js
const Hyperswarm = window.require('hyperswarm'); export async function initNetwork() {}
export const networkLegacy = {};
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<buf.length; i+=chunkSize) {
const chunk = buf.subarray(i, i+chunkSize);
await fileCore.append(chunk);
updateProcessingProgress(chunk.length);
}
}
const msg = network.messages.get(id);
if (msg) {
if (fileObj.path) {
msg.localPath = fileObj.path;
msg.isMediaInDB = false;
await network.localFilesDb.put(id, fileObj.path);
} else if (fileObj.fileObj && typeof URL !== 'undefined') {
msg.localBlobUrl = URL.createObjectURL(fileObj.fileObj);
msg.isMediaInDB = false;
} else if (fileObj.buffer && typeof URL !== 'undefined') {
const blob = new Blob([fileObj.buffer], { type: fileObj.type });
msg.localBlobUrl = URL.createObjectURL(blob);
msg.isMediaInDB = false;
} else {
msg.isMediaInDB = false;
}
network._emitMessages();
}
network.transfers[id] = { progress: 0, speed: 0, state: 'uploading' };
if (network.onTransfersUpdate) network.onTransfersUpdate(network.transfers);
}
export async function sendFile(network, channel, text, fileObj) {
const id = generateUUID();
const fileCore = network.store.get({ name: 'file-' + id });
await fileCore.ready();
const coreKey = b4a.toString(fileCore.key, 'hex');
network.transfers[id] = { progress: 0, speed: 0, state: 'processing' };
if (network.onTransfersUpdate) network.onTransfersUpdate(network.transfers);
await network._appendSignedMessage({
type: 'file', id, channel, text,
file: { name: fileObj.name, size: fileObj.size, mimeType: fileObj.type, coreKey },
timestamp: Date.now()
});
await _hostFile(network, id, fileObj, fileCore);
}
export async function sendDMFile(network, targetKey, text, fileObj) {
const id = generateUUID();
const fileCore = network.store.get({ name: 'file-' + id });
await fileCore.ready();
const coreKey = b4a.toString(fileCore.key, 'hex');
network.transfers[id] = { progress: 0, speed: 0, state: 'processing' };
if (network.onTransfersUpdate) network.onTransfersUpdate(network.transfers);
await network._appendEncryptedMessage(targetKey, {
type: 'file', id, text,
file: { name: fileObj.name, size: fileObj.size, mimeType: fileObj.type, coreKey },
timestamp: Date.now()
});
await _hostFile(network, id, fileObj, fileCore);
}
export async function downloadFile(network, msgId, fileMeta, isSender) {
if (typeof window !== 'undefined') {
const localDeleted = JSON.parse(localStorage.getItem('pear_local_deleted_msgs') || '[]');
if (localDeleted.includes(msgId)) return;
}
if (isSender) {
if (network.transfers[msgId] && network.transfers[msgId].state === 'processing') {
return;
}
}
const storedPath = await network.localFilesDb.get(msgId);
if (storedPath && storedPath.value && fs && fs.existsSync(storedPath.value)) {
const msg = network.messages.get(msgId);
if (msg) {
msg.localPath = storedPath.value;
msg.isMediaInDB = fileMeta.mimeType?.startsWith('image/') || fileMeta.mimeType?.startsWith('video/');
network._emitMessages();
}
network.transfers[msgId] = { progress: 1, speed: 0, state: 'completed' };
if (network.onTransfersUpdate) network.onTransfersUpdate(network.transfers);
return;
}
const core = network.store.get({ key: b4a.from(fileMeta.coreKey, 'hex') });
await core.ready();
const isMedia = fileMeta.mimeType?.startsWith('image/') || fileMeta.mimeType?.startsWith('video/');
let downloadsDir;
let filePath;
if (isMedia) {
downloadsDir = path.join(network.storagePath, 'downloads');
if (!fs.existsSync(downloadsDir)) fs.mkdirSync(downloadsDir, { recursive: true });
const safeName = fileMeta.name.replace(/[^a-zA-Z0-9.-]/g, '_');
filePath = path.join(downloadsDir, `${msgId}-${safeName}`);
} else {
downloadsDir = path.join(os.homedir(), 'Downloads');
if (!fs.existsSync(downloadsDir)) fs.mkdirSync(downloadsDir, { recursive: true });
const safeName = fileMeta.name.replace(/[^a-zA-Z0-9.\-_ ]/g, '');
filePath = path.join(downloadsDir, safeName);
const existingMsg = network.messages.get(msgId);
if (existingMsg && existingMsg.localPath) {
filePath = existingMsg.localPath;
} else if (fs.existsSync(filePath)) {
const stats = fs.statSync(filePath);
if (stats.size !== fileMeta.size) {
let baseName = path.basename(safeName, path.extname(safeName));
let ext = path.extname(safeName);
let counter = 1;
while (fs.existsSync(filePath)) {
filePath = path.join(downloadsDir, `${baseName} (${counter})${ext}`);
counter++;
}
}
}
}
if (fs.existsSync(filePath)) {
const stats = fs.statSync(filePath);
if (stats.size >= 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();
}
});
}

View File

@ -1,6 +1,6 @@
const b4a = window.require('b4a'); 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. // The PUBLIC key is 100% safe to be in the open-source code.
// It is mathematically impossible to derive your private seed from it. // It is mathematically impossible to derive your private seed from it.
@ -16,6 +16,9 @@ export async function initP2P() {
os = req('os'); os = req('os');
path = req('path'); path = req('path');
http = req('http'); http = req('http');
Protomux = req('protomux');
cenc = req('compact-encoding');
DHT = req('hyperdht');
} }
export function generateUUID() { export function generateUUID() {