/** * Peer connection roster cache. * * Primary path (desktop / Pear): * ~/.config/peardock/cache/peers.json * * Falls back to localStorage when the filesystem is unavailable * (pure browser / restricted environments). */ import fs from 'fs' import path from 'path' import os from 'os' export const PEERS_CACHE_VERSION = 1 export const LOCALSTORAGE_KEY = 'peardock_connections' export const LOCALSTORAGE_FLAG = 'peardock_use_localstorage' /** Last UI-selected active peer id (12-char prefix) */ export const ACTIVE_PEER_KEY = 'peardock_active_peer_id' /** * Absolute path to the peers cache file. * @returns {string} */ export function getPeersCachePath() { const home = process.env.PEARDOCK_HOME || process.env.HOME || process.env.USERPROFILE || (typeof os.homedir === 'function' ? os.homedir() : '') || '' return path.join(home, '.config', 'peardock', 'cache', 'peers.json') } /** * Directory containing peers.json * @returns {string} */ export function getPeersCacheDir() { return path.dirname(getPeersCachePath()) } /** * Normalize optional SERVER_SEED for admin reconnect (64 hex only). * @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 } /** * Normalize one peer entry. * @param {object|string} value * @param {string} [idHint] * @returns {{ id: string, publicKeyHex: string, alias: string|null, inviteToken: string|null, capability: string|null, adminSeed: string|null }|null} */ export function normalizePeerEntry(value, idHint = '') { if (!value) return null if (typeof value === 'string') { const publicKeyHex = value.trim().toLowerCase() if (!/^[0-9a-f]{64}$/.test(publicKeyHex)) return null return { id: publicKeyHex.slice(0, 12), publicKeyHex, alias: null, inviteToken: null, capability: null, adminSeed: null, } } const publicKeyHex = String( value.publicKeyHex || value.topicHex || value.topic || '' ) .trim() .toLowerCase() if (!/^[0-9a-f]{64}$/.test(publicKeyHex)) return null const id = String(idHint || value.id || publicKeyHex.slice(0, 12)).slice(0, 12) // Prefer capability; migrate inviteToken if it looks like an HMAC grant let capability = value.capability || null let inviteToken = value.inviteToken || null if (!capability && inviteToken && String(inviteToken).includes('.')) { capability = inviteToken inviteToken = null } return { id, publicKeyHex, alias: value.alias || null, inviteToken, capability, // Persist for auto-reconnect as admin (file mode 0600) adminSeed: normalizeAdminSeed(value.adminSeed), } } /** * Parse raw JSON (file or localStorage) into an id → peer map. * Accepts: * - { version, peers: { id: {...} } } * - flat { id: {...} } (legacy) * @param {string|object} raw * @returns {Record} */ export function parsePeersPayload(raw) { let parsed = raw if (typeof raw === 'string') { try { parsed = JSON.parse(raw) } catch { return {} } } if (!parsed || typeof parsed !== 'object') return {} const source = parsed.peers && typeof parsed.peers === 'object' && !Array.isArray(parsed.peers) ? parsed.peers : parsed /** @type {Record} */ const out = {} for (const [key, value] of Object.entries(source)) { // Skip meta keys if someone stored a flat object with version if (key === 'version' || key === 'updatedAt' || key === 'peers' || key === 'activePeerId') continue const entry = normalizePeerEntry(value, key) if (!entry) continue out[entry.id] = { publicKeyHex: entry.publicKeyHex, alias: entry.alias, inviteToken: entry.inviteToken, capability: entry.capability, adminSeed: entry.adminSeed, } } return out } /** * Build on-disk payload. * @param {Record} peersMap * @param {{ activePeerId?: string|null }} [opts] */ export function buildPeersPayload(peersMap, opts = {}) { const peers = {} for (const [id, value] of Object.entries(peersMap || {})) { const entry = normalizePeerEntry(value, id) if (!entry) continue const row = { publicKeyHex: entry.publicKeyHex, alias: entry.alias, inviteToken: entry.inviteToken, capability: entry.capability, } // Only write seed when present (omit null to avoid empty secret fields) if (entry.adminSeed) row.adminSeed = entry.adminSeed peers[entry.id] = row } const payload = { version: PEERS_CACHE_VERSION, updatedAt: new Date().toISOString(), peers, } if (Object.prototype.hasOwnProperty.call(opts, 'activePeerId')) { if (opts.activePeerId != null && opts.activePeerId !== '') { payload.activePeerId = String(opts.activePeerId).slice(0, 12) } // null/'' → omit (clear) } else { // preserve existing disk active id when not provided const existing = readActivePeerIdFromFile() if (existing) payload.activePeerId = existing } return payload } /** * @returns {string|null} */ function readActivePeerIdFromFile() { const file = getPeersCachePath() try { if (!fs.existsSync(file)) return null const raw = JSON.parse(fs.readFileSync(file, 'utf8')) const id = raw?.activePeerId return id ? String(id).slice(0, 12) : null } catch { return null } } /** * Last known active peer id (file first, then localStorage). * @returns {string|null} */ export function getLastActivePeerId() { const fromFile = readActivePeerIdFromFile() if (fromFile) return fromFile try { if (typeof localStorage !== 'undefined') { const id = localStorage.getItem(ACTIVE_PEER_KEY) return id ? String(id).slice(0, 12) : null } } catch { // ignore } return null } /** * Persist last active peer id (localStorage + peers.json meta when possible). * @param {string|null} id */ export function setLastActivePeerId(id) { const next = id ? String(id).slice(0, 12) : null try { if (typeof localStorage !== 'undefined') { if (next) localStorage.setItem(ACTIVE_PEER_KEY, next) else localStorage.removeItem(ACTIVE_PEER_KEY) } } catch { // ignore } // Merge into peers.json without rewriting peer entries try { const peers = loadPeers() if (Object.keys(peers).length === 0 && !next) return writePeersToFile(peers, { activePeerId: next }) } catch { // ignore } } function ensureCacheDir() { const dir = getPeersCacheDir() fs.mkdirSync(dir, { recursive: true }) return dir } /** * Read peers from ~/.config/peardock/cache/peers.json * @returns {Record} */ export function readPeersFromFile() { const file = getPeersCachePath() try { if (!fs.existsSync(file)) return {} const raw = fs.readFileSync(file, 'utf8') return parsePeersPayload(raw) } catch (err) { console.warn('[WARN] peerCache: failed to read file', err?.message || err) return {} } } /** * Write peers to ~/.config/peardock/cache/peers.json (atomic replace). * @param {Record} peersMap * @param {{ activePeerId?: string|null }} [opts] * @returns {boolean} */ export function writePeersToFile(peersMap, opts = {}) { const file = getPeersCachePath() const payload = buildPeersPayload(peersMap, opts) const json = JSON.stringify(payload, null, 2) try { ensureCacheDir() const tmp = `${file}.${process.pid}.${Date.now()}.tmp` fs.writeFileSync(tmp, json, { encoding: 'utf8', mode: 0o600 }) fs.renameSync(tmp, file) try { fs.chmodSync(file, 0o600) } catch { // ignore chmod failures on some platforms } return true } catch (err) { console.error('[ERROR] peerCache: failed to write file', err?.message || err) // Best-effort cleanup of temp files is ignored return false } } /** * Remove the peers cache file (and leave empty dir). * @returns {boolean} */ export function clearPeersFile() { try { const file = getPeersCachePath() if (fs.existsSync(file)) fs.unlinkSync(file) return true } catch (err) { console.warn('[WARN] peerCache: failed to clear file', err?.message || err) return false } } /** * Read peers: file first, then migrate from localStorage if file empty. * @returns {Record} */ export function loadPeers() { let peers = readPeersFromFile() if (Object.keys(peers).length > 0) return peers // Migrate legacy browser storage once try { if (typeof localStorage !== 'undefined') { const raw = localStorage.getItem(LOCALSTORAGE_KEY) if (raw) { peers = parsePeersPayload(raw) if (Object.keys(peers).length > 0) { writePeersToFile(peers) console.log( '[INFO] peerCache: migrated', Object.keys(peers).length, 'peer(s) from localStorage →', getPeersCachePath() ) } } } } catch { // ignore } return peers } /** * Persist peers to file (+ mirror localStorage as secondary cache). * @param {Record} peersMap * @param {{ activePeerId?: string|null }} [opts] * @returns {{ ok: boolean, path: string, count: number }} */ export function savePeers(peersMap, opts = {}) { const payload = buildPeersPayload(peersMap, opts) // Pass through only when caller set activePeerId; otherwise preserve on disk const writeOpts = Object.prototype.hasOwnProperty.call(opts, 'activePeerId') ? { activePeerId: opts.activePeerId } : {} const ok = writePeersToFile(payload.peers, writeOpts) // Mirror for quick in-session reads / legacy code paths try { if (typeof localStorage !== 'undefined') { // Keep flat map for older readers localStorage.setItem(LOCALSTORAGE_KEY, JSON.stringify(payload.peers)) localStorage.setItem(LOCALSTORAGE_FLAG, '1') if (payload.activePeerId) { localStorage.setItem(ACTIVE_PEER_KEY, payload.activePeerId) } } } catch { // ignore } return { ok, path: getPeersCachePath(), count: Object.keys(payload.peers).length, } } /** * Clear file + localStorage peer roster. */ export function clearPeers() { clearPeersFile() try { if (typeof localStorage !== 'undefined') { localStorage.removeItem(LOCALSTORAGE_KEY) localStorage.removeItem(LOCALSTORAGE_FLAG) } } catch { // ignore } } /** * List form used by ConnectionManager.loadSaved() * @returns {Array<{ id: string, publicKeyHex: string, alias: string|null, inviteToken: string|null, capability: string|null, adminSeed: string|null }>} */ export function listSavedPeers() { const map = loadPeers() return Object.entries(map).map(([id, value]) => ({ id, publicKeyHex: value.publicKeyHex, alias: value.alias || null, inviteToken: value.inviteToken || null, capability: value.capability || null, adminSeed: value.adminSeed || null, })) } export default { getPeersCachePath, getPeersCacheDir, loadPeers, savePeers, clearPeers, listSavedPeers, parsePeersPayload, buildPeersPayload, normalizePeerEntry, normalizeAdminSeed, readPeersFromFile, writePeersToFile, getLastActivePeerId, setLastActivePeerId, }