Files
peardock/client/peerCache.js
T
2026-07-14 20:04:49 -04:00

414 lines
12 KiB
JavaScript

/**
* 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<string, { publicKeyHex: string, alias: string|null, inviteToken: string|null }>}
*/
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<string, { publicKeyHex: string, alias: string|null, inviteToken: string|null, capability: string|null, adminSeed: string|null }>} */
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<string, { publicKeyHex?: string, topicHex?: string, alias?: string|null, inviteToken?: string|null, capability?: string|null, adminSeed?: string|null }>} 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<string, { publicKeyHex: string, alias: string|null, inviteToken: string|null }>}
*/
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<string, object>} 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<string, { publicKeyHex: string, alias: string|null, inviteToken: string|null }>}
*/
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<string, object>} 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,
}