const b4a = window.require('b4a'); import { generateUUID, Hyperswarm, Corestore, Hyperbee, sodium, fs, os, path, http } from './utils.js'; import * as Identity from './modules/identity.js'; import { handleData } from './handlers.js'; import { getAllMessages, processMessage, sendDMRequest, sendMessage, sendDM, sendEditMessage, sendDeleteMessage, acceptDMRequest, sendEphemeral, sendReadReceipt, sendDeliveredReceipt, sendOffline, sendTyping, sendReaction, _appendSignedMessage, _appendEncryptedMessage } from './modules/messaging.js'; import { createServer, joinServer, deleteServer, leaveServer, sendServerInvite, updateServerSettings, sendGroupChatAdd } from './modules/servers.js'; import { searchUser, queueFriendRequest, trackPeerCore } from './modules/discovery.js'; import { sendFile, sendDMFile, downloadFile } from './modules/files.js'; import { addWebRTCListener, removeWebRTCListener, sendWebRTCSignal } from './modules/webrtc.js'; class P2PNetwork { constructor() { this.swarm = null; this.store = null; this.localCore = null; this.db = null; this.serverDb = null; this.dirDb = null; this.pendingRequestsDb = null; this.localFilesDb = null; this.coresDb = null; this.profilesDb = null; this.seedHex = null; this.coreKey = null; this.myKey = null; this.secretKey = null; this.displayName = ''; this.username = ''; this.avatar = null; this.bio = ''; this.connections = []; this.storagePath = null; this.peers = new Map(); this.peerCores = new Map(); this.knownProfiles = new Map(); this.userDirectory = new Map(); this.pendingWhois = new Map(); this.pendingFriendRequests = new Set(); this.messages = new Map(); this.deletedMessages = new Set(); this.dms = {}; this.servers =[]; this.serverMembers = {}; this.joinedTopics = new Set(); this.syncTimeout = null; this._msgTimeout = null; this._identityTimeout = null; this.transfers = {}; this.webrtcListeners = new Set(); this.logicalClock = 0; this.timeOffset = 0; this.activeCalls = 0; this.onInit = null; this.onPeerUpdate = null; this.onMessage = null; this.onEphemeral = null; this.onDMsUpdate = null; this.onKnownProfilesUpdate = null; this.onServersUpdate = null; this.onServerMembersUpdate = null; this.onSync = null; this.onTransfersUpdate = null; } getAllMessages = () => getAllMessages(this); processMessage = (msg) => processMessage(this, msg); sendDMRequest = (targetKey, profile) => sendDMRequest(this, targetKey, profile); acceptDMRequest = (targetKey) => acceptDMRequest(this, targetKey); sendMessage = (channel, text, replyTo) => sendMessage(this, channel, text, replyTo); sendDM = (targetKey, text, replyTo) => sendDM(this, targetKey, text, replyTo); sendEditMessage = (targetId, newText) => sendEditMessage(this, targetId, newText); sendDeleteMessage = (targetId) => sendDeleteMessage(this, targetId); sendReaction = (targetId, emoji, isDM, targetKey) => sendReaction(this, targetId, emoji, isDM, targetKey); sendEphemeral = (payload) => sendEphemeral(this, payload); sendReadReceipt = (channel, messageId) => sendReadReceipt(this, channel, messageId); sendDeliveredReceipt = (channel, messageId) => sendDeliveredReceipt(this, channel, messageId); sendOffline = () => sendOffline(this); sendTyping = (channel) => sendTyping(this, channel); pruneFile = (msgId) => this._pruneFile(msgId); getStorageStats = () => this._getStorageStats(); _appendSignedMessage = (payloadObj) => _appendSignedMessage(this, payloadObj); _appendEncryptedMessage = (targetKey, payloadObj) => _appendEncryptedMessage(this, targetKey, payloadObj); _downloadFile = (msgId, fileMeta, isSender) => downloadFile(this, msgId, fileMeta, isSender); _emitMessages() { if (!this.onMessage) return; if (this._msgTimeout) clearTimeout(this._msgTimeout); this._msgTimeout = setTimeout(() => { this.onMessage(this.getAllMessages()); this._msgTimeout = null; }, 50); } _wipeLocalServerData = async (topicHex) => { this.servers = this.servers.filter(s => s.topicHex !== topicHex); if (this.serverDb) await this.serverDb.del(topicHex); delete this.serverMembers[topicHex]; const msgsToDelete =[]; for (const [msgId, msg] of this.messages.entries()) { const ch = msg.payload?.channel; if (ch === topicHex || (ch && ch.startsWith(topicHex + '-'))) { msgsToDelete.push(msgId); } } let localDeleted =[]; if (typeof window !== 'undefined') { localDeleted = JSON.parse(localStorage.getItem('pear_local_deleted_msgs') || '[]'); } for (const msgId of msgsToDelete) { const msg = this.messages.get(msgId); if (msg) { if (msg.localPath && fs && fs.existsSync(msg.localPath)) { try { fs.unlinkSync(msg.localPath); } catch (e) {} } if (msg.payload?.file?.coreKey) { try { const core = this.store.get({ key: b4a.from(msg.payload.file.coreKey, 'hex') }); await core.ready(); await core.clear(0, core.length); } catch (e) {} } this.deletedMessages.add(msgId); this.messages.delete(msgId); if (!localDeleted.includes(msgId)) localDeleted.push(msgId); if (this.transfers[msgId]) delete this.transfers[msgId]; } } if (typeof window !== 'undefined' && msgsToDelete.length > 0) { localStorage.setItem('pear_local_deleted_msgs', JSON.stringify(localDeleted)); } if (this.onTransfersUpdate) this.onTransfersUpdate(this.transfers); this._emitMessages(); this._emitServers(); this._emitServerMembers(); }; _reloadCores = async () => { this._emitSync(); }; createServer = (...args) => createServer(this, ...args); joinServer = (...args) => joinServer(this, ...args); deleteServer = (...args) => deleteServer(this, ...args); leaveServer = (...args) => leaveServer(this, ...args); sendServerInvite = (...args) => sendServerInvite(this, ...args); updateServerSettings = (...args) => updateServerSettings(this, ...args); sendGroupChatAdd = (...args) => sendGroupChatAdd(this, ...args); searchUser = (username) => searchUser(this, username); queueFriendRequest = (username) => queueFriendRequest(this, username); trackPeerCore = (coreKeyHex) => trackPeerCore(this, coreKeyHex); sendFile = (...args) => sendFile(this, ...args); sendDMFile = (...args) => sendDMFile(this, ...args); addWebRTCListener = (fn) => addWebRTCListener(this, fn); removeWebRTCListener = (fn) => removeWebRTCListener(this, fn); sendWebRTCSignal = (target, payload) => sendWebRTCSignal(this, target, payload); updateProfile = (name, avatar, username, bio, connections) => Identity.updateProfile(this, name, avatar, username, bio, connections); async closeDM(targetKey) { if (this.dms[targetKey]) { this.dms[targetKey].isOpen = false; await this.db.put('dm:' + targetKey, this.dms[targetKey]); if (this.onDMsUpdate) this.onDMsUpdate({ ...this.dms }); } } async removeFriend(targetKey) { if (this.dms[targetKey]) { delete this.dms[targetKey]; await this.db.del('dm:' + targetKey); if (this.onDMsUpdate) this.onDMsUpdate({ ...this.dms }); } } async blockUser(targetKey) { if (this.dms[targetKey]) { this.dms[targetKey].status = 'blocked'; this.dms[targetKey].isOpen = false; await this.db.put('dm:' + targetKey, this.dms[targetKey]); if (this.onDMsUpdate) this.onDMsUpdate({ ...this.dms }); } else { this.dms[targetKey] = { status: 'blocked', isOpen: false, profile: this.knownProfiles.get(targetKey) || { displayName: 'Unknown' } }; await this.db.put('dm:' + targetKey, this.dms[targetKey]); if (this.onDMsUpdate) this.onDMsUpdate({ ...this.dms }); } } async exportAccount() { const exportData = { profile: { displayName: this.displayName, username: this.username, avatar: this.avatar, bio: this.bio, connections: this.connections, seedHex: this.seedHex }, dms: this.dms, servers: this.servers, knownProfiles: Array.from(this.knownProfiles.entries()), userDirectory: Array.from(this.userDirectory.entries()), settings: { theme: localStorage.getItem('peercord_theme'), audioInput: localStorage.getItem('pear_audio_input'), audioOutput: localStorage.getItem('pear_audio_output'), videoInput: localStorage.getItem('pear_video_input'), autoRestart: localStorage.getItem('pear_auto_restart'), liveDecryption: localStorage.getItem('pear_live_decryption'), ircMode: localStorage.getItem('pear_irc_mode'), noiseSuppression: localStorage.getItem('pear_noise_suppression'), closeToTray: localStorage.getItem('pear_close_to_tray'), pinMembers: localStorage.getItem('pear_pin_members'), notifyDMs: localStorage.getItem('pear_notify_dms'), notifyHubs: localStorage.getItem('pear_notify_hubs'), notifyMentions: localStorage.getItem('pear_notify_mentions'), notifyCalls: localStorage.getItem('pear_notify_calls') } }; return JSON.stringify(exportData); } async importAccount(jsonString) { const data = JSON.parse(jsonString); for (const [key, value] of Object.entries(data.dms)) { await this.db.put('dm:' + key, value); } for (const server of data.servers) { await this.serverDb.put(server.topicHex, server); } for (const [key, value] of data.knownProfiles) { await this.profilesDb.put(key, value); } for (const [key, value] of data.userDirectory) { await this.dirDb.put(key, value); } if (data.settings) { for (const [k, v] of Object.entries(data.settings)) { if (v !== null && v !== undefined) { const storageKey = k === 'theme' ? 'peercord_theme' : `pear_${k.replace(/[A-Z]/g, letter => `_${letter.toLowerCase()}`)}`; localStorage.setItem(storageKey, v); } } } return data.profile; } _checkPendingRequests = (uname, pubKey, profile) => { if (this.pendingFriendRequests.has(uname)) { this.pendingFriendRequests.delete(uname); this.pendingRequestsDb.del(uname); this.sendDMRequest(pubKey, profile); } } _emitKnownProfiles() { if (this.onKnownProfilesUpdate) { this.onKnownProfilesUpdate(Array.from(this.knownProfiles.entries()).map(([key, profile]) => ({ key, ...profile }))); } } _emitServers() { if (this.onServersUpdate) this.onServersUpdate([...this.servers]); } _emitServerMembers() { if (this.onServerMembersUpdate) { const formatted = {}; for (const topic in this.serverMembers) { formatted[topic] = Array.from(this.serverMembers[topic]); } this.onServerMembersUpdate(formatted); } } _emitSync() { if (this.onSync) this.onSync(true); if (this.syncTimeout) clearTimeout(this.syncTimeout); this.syncTimeout = setTimeout(() => { if (this.onSync) this.onSync(false); }, 500); } getBusyReasons() { const reasons =[]; let activeUploads = 0; let activeDownloads = 0; let processing = 0; for (const t of Object.values(this.transfers)) { if (t.state === 'processing') { processing++; } else if (t.state === 'downloading') { if (t.speed > 0 || (t.progress > 0 && t.progress < 1)) { activeDownloads++; } } else if (t.state === 'uploading') { if (t.progress < 1) { activeUploads++; } } } if (processing > 0) reasons.push("Processing local files"); if (activeUploads > 0) reasons.push("Uploading files to peers"); if (activeDownloads > 0) reasons.push("Downloading files"); if (this.activeCalls > 0) reasons.push("Active voice/video call"); return reasons; } isBusy() { return this.getBusyReasons().length > 0; } async _syncTimeWithServer() { try { if (!http) throw new Error("HTTP module not loaded"); await new Promise((resolve, reject) => { const req = http.request({ hostname: '1.1.1.1', method: 'HEAD', port: 80, timeout: 5000 }, (res) => { const dateHeader = res.headers.date; if (dateHeader) { const serverTime = new Date(dateHeader).getTime(); const localTime = Date.now(); this.timeOffset = serverTime - localTime; console.log(`[Time Sync] Offset calculated: ${this.timeOffset}ms`); } else { console.warn('[Time Sync] No date header found in response.'); } resolve(); }); req.on('timeout', () => { req.destroy(); reject(new Error('Connection timed out')); }); req.on('error', (err) => { reject(err); }); req.end(); }); } catch (err) { console.warn('[Time Sync] Failed to reach time server, falling back to local system clock.', err.message || err); this.timeOffset = 0; } } async checkUsernameAvailable(username) { const normalized = username.toLowerCase(); const tempSwarm = new Hyperswarm({ maxPeers: 3, maxClientConnections: 3, maxServerConnections: 0 }); const topic = b4a.alloc(32); sodium.crypto_generichash(topic, b4a.from('peercord-user:' + normalized)); let isTaken = false; tempSwarm.on('connection', (conn) => { isTaken = true; conn.destroy(); }); tempSwarm.join(topic, { client: true, server: false }); for (let i = 0; i < 30; i++) { if (isTaken) break; await new Promise(resolve => setTimeout(resolve, 100)); } await tempSwarm.destroy(); return !isTaken; } async reconnect() { if (!this.swarm) return; console.log("[P2P] Network online event detected. Reconnecting..."); try { // Hyperswarm handles reconnections automatically. // Forcing a non-blocking flush is enough to kickstart the DHT without causing a UDP flood. this.swarm.flush().catch(()=>{}); } catch (e) { console.warn("[P2P] Reconnect flush failed:", e); } } _broadcastIdentity() { if (!this.swarm) return; const pendingTargets = Object.entries(this.dms) .filter(([_, data]) => data.status === 'pending_outgoing') .map(([key]) => key); const identityMsg = JSON.stringify({ type: 'identity', displayName: this.displayName, username: this.username, avatar: this.avatar, bio: this.bio, connections: this.connections, coreKey: this.coreKey, topics: Array.from(this.joinedTopics), pendingTargets }); const payload = b4a.from(identityMsg); for (const { conn } of this.peers.values()) { try { conn.write(payload); } catch(e) {} } } async initialize(seedHex, displayName, username, avatar = null, bio = '', connections = []) { this.seedHex = seedHex; this.displayName = displayName; this.username = (username || 'unknown').toLowerCase(); this.avatar = avatar; this.bio = bio; this.connections = connections; this._syncTimeWithServer().catch(() => {}); let instanceId = 'default'; if (typeof window !== 'undefined') { instanceId = localStorage.getItem('pear_instance_id'); if (!instanceId) { instanceId = generateUUID(); localStorage.setItem('pear_instance_id', instanceId); } const localDeleted = JSON.parse(localStorage.getItem('pear_local_deleted_msgs') || '[]'); localDeleted.forEach(id => this.deletedMessages.add(id)); } let basePath = './p2p-storage'; if (os && path && typeof os.homedir === 'function') { const home = os.homedir(); const appData = process.platform === 'win32' ? process.env.APPDATA : (process.platform === 'darwin' ? path.join(home, 'Library', 'Application Support') : path.join(home, '.config')); basePath = path.join(appData || home, 'Peercord', 'p2p-storage'); } const hashBuf = b4a.alloc(32); sodium.crypto_generichash(hashBuf, b4a.from(seedHex, 'hex')); const accountHash = b4a.toString(hashBuf, 'hex').substring(0, 16); this.storagePath = path.join(basePath, `${instanceId}-${accountHash}`); if (fs && fs.existsSync) { const badDownloadsPath = path.join(this.storagePath, 'downloads'); if (fs.existsSync(badDownloadsPath)) { try { fs.rmSync(badDownloadsPath, { recursive: true, force: true }); } catch (e) {} } } this.store = new Corestore(this.storagePath); await this.store.ready(); const dbCore = this.store.get({ name: 'dm-db' }); await dbCore.ready(); this.db = new Hyperbee(dbCore, { keyEncoding: 'utf-8', valueEncoding: 'json' }); await this.db.ready(); const serverDbCore = this.store.get({ name: 'server-db' }); await serverDbCore.ready(); this.serverDb = new Hyperbee(serverDbCore, { keyEncoding: 'utf-8', valueEncoding: 'json' }); await this.serverDb.ready(); const dirDbCore = this.store.get({ name: 'directory-db' }); await dirDbCore.ready(); this.dirDb = new Hyperbee(dirDbCore, { keyEncoding: 'utf-8', valueEncoding: 'json' }); await this.dirDb.ready(); const pendingDbCore = this.store.get({ name: 'pending-requests-db' }); await pendingDbCore.ready(); this.pendingRequestsDb = new Hyperbee(pendingDbCore, { keyEncoding: 'utf-8', valueEncoding: 'json' }); await this.pendingRequestsDb.ready(); const localFilesDbCore = this.store.get({ name: 'local-files-db' }); await localFilesDbCore.ready(); this.localFilesDb = new Hyperbee(localFilesDbCore, { keyEncoding: 'utf-8', valueEncoding: 'utf-8' }); await this.localFilesDb.ready(); const coresDbCore = this.store.get({ name: 'peer-cores-db' }); await coresDbCore.ready(); this.coresDb = new Hyperbee(coresDbCore, { keyEncoding: 'utf-8', valueEncoding: 'utf-8' }); await this.coresDb.ready(); const profilesDbCore = this.store.get({ name: 'profiles-db' }); await profilesDbCore.ready(); this.profilesDb = new Hyperbee(profilesDbCore, { keyEncoding: 'utf-8', valueEncoding: 'json' }); await this.profilesDb.ready(); for await (const { key, value } of this.db.createReadStream({ gt: 'dm:', lt: 'dm:~' })) { const pubKey = key.split(':')[1]; if (value.isOpen === undefined) value.isOpen = true; this.dms[pubKey] = value; if (value.profile) this.knownProfiles.set(pubKey, value.profile); } for await (const { key, value } of this.serverDb.createReadStream()) { this.servers.push({ topicHex: key, ...value }); } for await (const { key, value } of this.dirDb.createReadStream()) { this.userDirectory.set(key, value); if (value.pubKey && value.profile) this.knownProfiles.set(value.pubKey, value.profile); } for await (const { key } of this.pendingRequestsDb.createReadStream()) { this.pendingFriendRequests.add(key); } for await (const { key, value } of this.profilesDb.createReadStream()) { this.knownProfiles.set(key, value); } this.localCore = this.store.get({ name: 'user-messages', valueEncoding: 'json' }); await this.localCore.ready(); this.coreKey = b4a.toString(this.localCore.key, 'hex'); const seed = b4a.from(seedHex, 'hex'); const publicKey = b4a.alloc(32); const secretKey = b4a.alloc(64); sodium.crypto_sign_seed_keypair(publicKey, secretKey, seed); this.myKey = b4a.toString(publicKey, 'hex'); this.secretKey = secretKey; this.knownProfiles.set(this.myKey, { displayName: this.displayName, username: this.username, avatar: this.avatar, bio: this.bio, connections: this.connections }); // EMIT IMMEDIATELY BEFORE SWARM JOINS TO PREVENT UI BLOCKING this._emitKnownProfiles(); if (this.onInit) this.onInit(this.myKey); if (this.onDMsUpdate) this.onDMsUpdate({ ...this.dms }); this._emitServers(); for (let i = 0; i < this.localCore.length; i++) { this.processMessage(await this.localCore.get(i)); } this._emitMessages(); const corePromises =[]; for await (const { key, value } of this.coresDb.createReadStream()) { corePromises.push(this.trackPeerCore(value)); } await Promise.all(corePromises); // SETUP SWARM this.swarm = new Hyperswarm({ keyPair: { publicKey, secretKey } }); this.swarm.on('connection', (conn, info) => { conn.on('error', () => {}); // Prevent ECONNRESET crashes this.store.replicate(conn); const peerKey = b4a.toString(info.publicKey, 'hex'); // Preserve existing peer info if connection multiplexes const existingPeer = this.peers.get(peerKey); if (existingPeer) { existingPeer.conn = conn; } else { this.peers.set(peerKey, { conn, displayName: 'Unknown', username: 'unknown', avatar: null, bio: '', connections: [], coreKey: null }); } const pendingTargets = Object.entries(this.dms) .filter(([_, data]) => data.status === 'pending_outgoing') .map(([key]) => key); const identityMsg = JSON.stringify({ type: 'identity', displayName: this.displayName, username: this.username, avatar: this.avatar, bio: this.bio, connections: this.connections, coreKey: this.coreKey, topics: Array.from(this.joinedTopics), pendingTargets }); conn.write(b4a.from(identityMsg)); if (this.onPeerUpdate) this.onPeerUpdate(this.getPeerList()); conn.on('data', async (data) => handleData(this, peerKey, data, conn)); conn.on('close', () => { // Only delete if this specific connection is still the active one const currentPeer = this.peers.get(peerKey); if (currentPeer && currentPeer.conn === conn) { this.peers.delete(peerKey); if (this.onPeerUpdate) this.onPeerUpdate(this.getPeerList()); } }); }); // BACKGROUND JOINS TO PREVENT UDP FLOOD / NAT EXHAUSTION (async () => { const pace = () => new Promise(r => setTimeout(r, 400)); // 400ms between DHT lookups if (this.username && this.username !== 'unknown') { const myTopic = b4a.alloc(32); sodium.crypto_generichash(myTopic, b4a.from('peercord-user:' + this.username)); this.swarm.join(myTopic, { client: false, server: true }); await pace(); } for (const uname of this.pendingFriendRequests) { const topic = b4a.alloc(32); sodium.crypto_generichash(topic, b4a.from('peercord-user:' + uname)); this.swarm.join(topic, { client: true, server: false }); await pace(); } for (const server of this.servers) { await this._joinTopic(server.topicHex, true); await pace(); } const globalUpdateTopic = b4a.alloc(32); sodium.crypto_generichash(globalUpdateTopic, b4a.from('peercord-global-updates')); this.swarm.join(globalUpdateTopic, { client: true, server: true }); this.swarm.flush().then(() => { console.log("[P2P] Swarm flushed and announced."); }).catch(err => console.warn("[P2P] Swarm flush failed:", err)); })(); } getPeerList() { return Array.from(this.peers.entries()).map(([key, info]) => ({ key, displayName: info.displayName, username: info.username, avatar: info.avatar, bio: info.bio, connections: info.connections })); } async _joinTopic(topicHex, skipFlush = false) { if (!this.swarm) return; if (this.joinedTopics.has(topicHex)) return; this.joinedTopics.add(topicHex); const topic = b4a.from(topicHex, 'hex'); this.swarm.join(topic, { client: true, server: true }); // Debounce identity broadcast to prevent TCP floods when joining many topics if (this._identityTimeout) clearTimeout(this._identityTimeout); this._identityTimeout = setTimeout(() => { this._broadcastIdentity(); }, 1000); // Request sync from existing members to fetch history immediately this.sendEphemeral({ type: 'sync_request', topic: topicHex }); if (!skipFlush) { // Don't await flush, it blocks the caller. this.swarm.flush().catch(()=>{}); } } async close() { if (this.swarm) { for (const peer of this.swarm.connections) peer.destroy(); await this.swarm.destroy(); this.swarm = null; } if (this.db) { await this.db.close(); this.db = null; } if (this.serverDb) { await this.serverDb.close(); this.serverDb = null; } if (this.dirDb) { await this.dirDb.close(); this.dirDb = null; } if (this.pendingRequestsDb) { await this.pendingRequestsDb.close(); this.pendingRequestsDb = null; } if (this.localFilesDb) { await this.localFilesDb.close(); this.localFilesDb = null; } if (this.coresDb) { await this.coresDb.close(); this.coresDb = null; } if (this.profilesDb) { await this.profilesDb.close(); this.profilesDb = null; } if (this.store) { await this.store.close(); this.store = null; } this.peers.clear(); this.peerCores.clear(); this.knownProfiles.clear(); this.userDirectory.clear(); this.pendingWhois.clear(); this.pendingFriendRequests.clear(); this.messages.clear(); this.deletedMessages.clear(); this.dms = {}; this.servers =[]; this.serverMembers = {}; this.joinedTopics.clear(); this.transfers = {}; this.webrtcListeners.clear(); if (this._msgTimeout) clearTimeout(this._msgTimeout); if (this._identityTimeout) clearTimeout(this._identityTimeout); } async wipeAllData() { await this.close(); if (typeof window !== 'undefined') localStorage.removeItem('pear_discord_identity'); try { if (this.storagePath && fs) await fs.promises.rm(this.storagePath, { recursive: true, force: true }); } catch (err) { console.error("Failed to delete storage directory:", err); } window.location.reload(); } async _getStorageStats() { const stats = { total: 0, dms: {}, servers: {}, files: [] }; const guessServerName = (topicHex) => { const s = this.servers.find(s => s.topicHex === topicHex); if (s) return s.name; for (const msg of this.messages.values()) { if (msg.payload?.serverTopicHex === topicHex && msg.payload?.name) return msg.payload.name; if (msg.payload?.type === 'server_invite' && msg.payload?.serverTopicHex === topicHex) return msg.payload.serverName; if (msg.payload?.type === 'group_chat_add' && msg.payload?.topicHex === topicHex) return msg.payload.name; } return 'Unknown Hub'; }; for (const msg of this.messages.values()) { if (msg.payload?.type === 'file' && msg.localPath) { const size = msg.payload.file.size || 0; stats.total += size; const target = msg.recipient ? (msg.sender === this.myKey ? msg.recipient : msg.sender) : null; let serverName = null; let isGroupChat = false; if (!msg.recipient && msg.payload.channel) { const topicHex = msg.payload.channel.substring(0, 64); serverName = guessServerName(topicHex); const s = this.servers.find(s => s.topicHex === topicHex); if (s) isGroupChat = s.isGroupChat; } const fileInfo = { id: msg.payload.id, name: msg.payload.file.name, size: size, coreKey: msg.payload.file.coreKey, timestamp: msg.payload.timestamp, channel: msg.payload.channel, recipient: msg.recipient, sender: msg.sender, target: target, serverName: serverName, isGroupChat: isGroupChat }; stats.files.push(fileInfo); if (msg.recipient) { stats.dms[target] = (stats.dms[target] || 0) + size; } else if (msg.payload.channel) { const topicHex = msg.payload.channel.substring(0, 64); const channelName = msg.payload.channel.substring(65) || 'general'; if (!stats.servers[topicHex]) stats.servers[topicHex] = { total: 0, channels: {}, name: serverName, isGroupChat }; stats.servers[topicHex].total += size; stats.servers[topicHex].channels[channelName] = (stats.servers[topicHex].channels[channelName] || 0) + size; } } } stats.files.sort((a, b) => b.size - a.size); return stats; } async _pruneFile(msgId) { const msg = this.messages.get(msgId); if (!msg) return; try { if (msg.localPath && fs && fs.existsSync(msg.localPath)) { try { fs.unlinkSync(msg.localPath); } catch (e) { console.error("Failed to delete physical file:", e); } } if (msg.payload?.file?.coreKey) { try { const core = this.store.get({ key: b4a.from(msg.payload.file.coreKey, 'hex') }); await core.ready(); await core.clear(0, core.length); } catch (e) { console.error("Failed to clear hypercore:", e); } } this.deletedMessages.add(msgId); this.messages.delete(msgId); if (typeof window !== 'undefined') { const localDeleted = JSON.parse(localStorage.getItem('pear_local_deleted_msgs') || '[]'); if (!localDeleted.includes(msgId)) { localDeleted.push(msgId); localStorage.setItem('pear_local_deleted_msgs', JSON.stringify(localDeleted)); } } if (this.transfers[msgId]) { delete this.transfers[msgId]; if (this.onTransfersUpdate) this.onTransfersUpdate(this.transfers); } this._emitMessages(); } catch (err) { console.error("Failed to prune file:", err); } } } export const network = new P2PNetwork(); export { initP2P, ADMIN_PUBLIC_KEY } from './utils.js'; export { generateIdentitySeed } from './modules/identity.js';