/** * Persistent multi-host peer roster (PearDock-style). * * ~/.config/peardata/cache/peers.json * * Migrates legacy bookmarks.json on first load. */ import fs from 'fs' import path from 'path' import { getPeardataCacheDir, getPeardataHome } from './paths.js' import { atomicWriteJson } from './jsonCache.js' export const PEERS_CACHE_VERSION = 1 export const ACTIVE_PEER_LS_KEY = 'peardata_active_peer_id' export const PEERS_LS_KEY = 'peardata.cache.peers' /** * @typedef {{ * publicKeyHex: string, * alias: string, * invite: string|null, * capability: string|null, * adminSeed: string|null, * lastConnectedAt: number|null, * autoConnect: boolean, * createdAt: number, * }} PeerEntry */ export function getPeersCachePath() { return path.join(getPeardataCacheDir(), 'peers.json') } /** * @param {unknown} raw * @returns {string|null} */ export function normalizeAdminSeed(raw) { if (raw == null || raw === '') return null const seed = String(raw).trim().toLowerCase() return /^[0-9a-f]{64}$/.test(seed) ? seed : null } /** * @param {object} value * @returns {PeerEntry|null} */ export function normalizePeerEntry(value) { if (!value) return null const publicKeyHex = String(value.publicKeyHex || value.id || '') .trim() .toLowerCase() if (!/^[0-9a-f]{64}$/.test(publicKeyHex)) return null let capability = value.capability || null let invite = value.invite || value.inviteToken || null if (!capability && invite && String(invite).includes('.')) { // pd1 invites stay as invite; HMAC grants look like a.b if (!String(invite).startsWith('pd1.')) { capability = invite invite = null } } return { publicKeyHex, alias: value.alias ? String(value.alias).slice(0, 64) : '', invite: invite || null, capability: capability || null, adminSeed: normalizeAdminSeed(value.adminSeed), lastConnectedAt: value.lastConnectedAt || null, autoConnect: value.autoConnect !== false, createdAt: value.createdAt || Date.now(), } } /** * @returns {Record} */ export function loadPeers() { const file = getPeersCachePath() try { if (fs.existsSync(file)) { const raw = JSON.parse(fs.readFileSync(file, 'utf8')) return parsePeersPayload(raw) } } catch (err) { console.warn('[WARN] peerCache: read failed', err?.message || err) } // Migrate legacy bookmarks.json const migrated = migrateBookmarks() if (Object.keys(migrated).length) { savePeers(migrated) return migrated } try { if (typeof localStorage !== 'undefined') { const mir = localStorage.getItem(PEERS_LS_KEY) if (mir) return parsePeersPayload(JSON.parse(mir)) } } catch { // ignore } return {} } /** * @param {unknown} raw * @returns {Record} */ export function parsePeersPayload(raw) { if (!raw || typeof raw !== 'object') return {} const source = raw.peers && typeof raw.peers === 'object' && !Array.isArray(raw.peers) ? raw.peers : Array.isArray(raw.bookmarks) ? Object.fromEntries( raw.bookmarks.map((b) => [String(b.publicKeyHex || '').toLowerCase(), b]) ) : raw /** @type {Record} */ const out = {} for (const [key, value] of Object.entries(source)) { if (key === 'version' || key === 'updatedAt' || key === 'peers' || key === 'activePeerId') { continue } const entry = normalizePeerEntry(value) if (!entry) continue out[entry.publicKeyHex] = entry } return out } /** * @returns {Record} */ function migrateBookmarks() { const candidates = [ path.join(getPeardataHome(), 'bookmarks.json'), path.join(getPeardataCacheDir(), 'bookmarks.json'), ] for (const file of candidates) { try { if (!fs.existsSync(file)) continue const raw = JSON.parse(fs.readFileSync(file, 'utf8')) const list = Array.isArray(raw?.bookmarks) ? raw.bookmarks : [] /** @type {Record} */ const out = {} for (const b of list) { const entry = normalizePeerEntry(b) if (entry) out[entry.publicKeyHex] = entry } if (Object.keys(out).length) return out } catch { // try next } } return {} } /** * @param {Record} peersMap * @param {{ activePeerId?: string|null }} [opts] */ export function savePeers(peersMap, opts = {}) { /** @type {Record} */ const peers = {} for (const value of Object.values(peersMap || {})) { const entry = normalizePeerEntry(value) if (!entry) continue peers[entry.publicKeyHex] = entry } // Wipe protection: refuse empty write unless intentional clear if (!Object.keys(peers).length && opts.activePeerId !== null && Object.keys(loadPeers()).length) { if (!opts.force) { console.warn('[WARN] peerCache: refused empty wipe') return false } } let activePeerId = opts.activePeerId !== undefined ? opts.activePeerId : getLastActivePeerId() if (activePeerId) activePeerId = String(activePeerId).toLowerCase() if (activePeerId && !peers[activePeerId]) activePeerId = null const payload = { version: PEERS_CACHE_VERSION, updatedAt: new Date().toISOString(), peers, } if (activePeerId) payload.activePeerId = activePeerId try { atomicWriteJson(getPeersCachePath(), payload) } catch (err) { console.warn('[WARN] peerCache: write failed', err?.message || err) return false } try { if (typeof localStorage !== 'undefined') { localStorage.setItem(PEERS_LS_KEY, JSON.stringify(payload)) if (activePeerId) localStorage.setItem(ACTIVE_PEER_LS_KEY, activePeerId) else localStorage.removeItem(ACTIVE_PEER_LS_KEY) } } catch { // ignore } return true } /** * @returns {string|null} */ export function getLastActivePeerId() { try { const file = getPeersCachePath() if (fs.existsSync(file)) { const raw = JSON.parse(fs.readFileSync(file, 'utf8')) const id = raw?.activePeerId if (id && /^[0-9a-f]{64}$/i.test(id)) return String(id).toLowerCase() } } catch { // ignore } try { if (typeof localStorage !== 'undefined') { const id = localStorage.getItem(ACTIVE_PEER_LS_KEY) if (id && /^[0-9a-f]{64}$/i.test(id)) return String(id).toLowerCase() } } catch { // ignore } return null } /** * @param {string|null} id */ export function setLastActivePeerId(id) { const next = id && /^[0-9a-f]{64}$/i.test(id) ? String(id).toLowerCase() : null const peers = loadPeers() savePeers(peers, { activePeerId: next, force: true }) } /** * @param {Partial & { publicKeyHex: string }} peer * @param {{ makeActive?: boolean }} [opts] */ export function upsertPeer(peer, opts = {}) { const entry = normalizePeerEntry(peer) if (!entry) return loadPeers() const peers = loadPeers() const prev = peers[entry.publicKeyHex] peers[entry.publicKeyHex] = { ...entry, alias: peer.alias != null ? entry.alias : prev?.alias || entry.alias, invite: entry.invite || prev?.invite || null, capability: entry.capability || prev?.capability || null, adminSeed: entry.adminSeed || prev?.adminSeed || null, lastConnectedAt: peer.lastConnectedAt ?? Date.now(), createdAt: prev?.createdAt || entry.createdAt, autoConnect: peer.autoConnect != null ? entry.autoConnect : prev?.autoConnect !== false, } const active = opts.makeActive !== false ? entry.publicKeyHex : getLastActivePeerId() savePeers(peers, { activePeerId: active }) return peers } /** * @param {string} publicKeyHex * @param {string} alias */ export function setPeerAlias(publicKeyHex, alias) { const id = String(publicKeyHex || '').toLowerCase() const peers = loadPeers() if (!peers[id]) return peers peers[id].alias = String(alias || '').slice(0, 64) savePeers(peers) return peers } /** * @param {string} publicKeyHex */ export function removePeer(publicKeyHex) { const id = String(publicKeyHex || '').toLowerCase() const peers = loadPeers() delete peers[id] const active = getLastActivePeerId() savePeers(peers, { activePeerId: active === id ? null : active, force: true, }) return peers } /** * Bookmark-shaped list for UI compatibility. * @returns {Array} */ export function listPeersAsBookmarks() { return Object.values(loadPeers()) .map((p) => ({ ...p, id: p.publicKeyHex })) .sort((a, b) => (b.lastConnectedAt || 0) - (a.lastConnectedAt || 0)) }