309 lines
8.4 KiB
JavaScript
309 lines
8.4 KiB
JavaScript
/**
|
|
* 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<string, PeerEntry>}
|
|
*/
|
|
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<string, PeerEntry>}
|
|
*/
|
|
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<string, PeerEntry>} */
|
|
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<string, PeerEntry>}
|
|
*/
|
|
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<string, PeerEntry>} */
|
|
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<string, PeerEntry|object>} peersMap
|
|
* @param {{ activePeerId?: string|null }} [opts]
|
|
*/
|
|
export function savePeers(peersMap, opts = {}) {
|
|
/** @type {Record<string, PeerEntry>} */
|
|
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<PeerEntry> & { 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<PeerEntry & { id: string }>}
|
|
*/
|
|
export function listPeersAsBookmarks() {
|
|
return Object.values(loadPeers())
|
|
.map((p) => ({ ...p, id: p.publicKeyHex }))
|
|
.sort((a, b) => (b.lastConnectedAt || 0) - (a.lastConnectedAt || 0))
|
|
}
|