Updates
This commit is contained in:
@@ -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))
|
||||
}
|
||||
Reference in New Issue
Block a user