Updates
CI / test (push) Successful in 1m1s
Release rolling / release (push) Successful in 6m59s

This commit is contained in:
Raven Scott
2026-07-18 20:32:05 -04:00
parent 41f5476a60
commit a7648dc090
28 changed files with 2539 additions and 333 deletions
+308
View File
@@ -0,0 +1,308 @@
/**
* 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))
}