v1.0.8
Some fixes, but networking issues still persists.
This commit is contained in:
parent
1c456702f4
commit
3f8a00cd19
|
|
@ -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",
|
||||||
|
|
|
||||||
|
|
@ -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) {
|
||||||
|
|
|
||||||
|
|
@ -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>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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 = {
|
||||||
|
|
|
||||||
|
|
@ -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}
|
||||||
/>
|
/>
|
||||||
|
|
|
||||||
|
|
@ -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 });
|
||||||
|
|
|
||||||
|
|
@ -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;
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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') || '[]');
|
||||||
|
|
|
||||||
|
|
@ -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);
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -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();
|
|
||||||
}
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
@ -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() {
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue
Block a user