/** * Desktop UI preferences — PearDock-style cache + FOUC localStorage mirror. * * Primary: ~/.config/peardata/cache/settings.json * Mirror: localStorage peardata.settings.v1 */ import fs from 'fs' import path from 'path' import { getPeardataCacheDir, getPeardataHome } from './paths.js' import { atomicWriteJson } from './jsonCache.js' export const SETTINGS_CACHE_VERSION = 1 export const SETTINGS_LOCALSTORAGE_KEY = 'peardata.settings.v1' /** @typedef {{ * theme: 'dark'|'light', * accent: string, * density: 'comfortable'|'compact'|'spacious', * sidebarCollapsed: boolean, * notifyDesktop: boolean, * comparePeers: boolean, * chartPoints: number, * defaultExplore: string, * reduceMotion: boolean, * metricsCardHeight: number, * metricsDimSort: 'name'|'value', * metricsCollapsed: string[], * metricsChartTypes: Record, * metricsPinned: string[], * metricsGroup: 'average'|'min'|'max'|'sum', * metricsForcePlay: boolean, * metricsFiltersOpen: boolean, * reconnectMaxAttempts: number, * autoRestorePeers: boolean, * qvacOnboarded?: boolean, * qvacProfile?: string, * qvacMode?: string, * qvacCacheDir?: string, * qvacRag?: boolean, * qvacIdleUnloadMin?: number, * qvacSubAgents?: boolean, * qvacMaxSubAgents?: number, * qvacToolDepth?: 'auto'|'core'|'deep', * qvacAutoNavigate?: 'off'|'ask'|'on', * customDashboards?: Array<{ * id: string, * name: string, * description?: string, * tiles: Array<{ id: string, chart: string, mode?: string, title?: string }>, * createdAt?: number, * updatedAt?: number, * }>, * activeDashboardId?: string|null, * }} UiSettings */ /** @returns {UiSettings} */ export function defaultSettings() { return { theme: 'dark', accent: 'teal', density: 'comfortable', sidebarCollapsed: false, notifyDesktop: true, comparePeers: false, chartPoints: 90, defaultExplore: 'system.io', reduceMotion: false, metricsCardHeight: 160, metricsDimSort: 'name', metricsCollapsed: [], metricsChartTypes: {}, metricsPinned: [], metricsGroup: 'average', metricsForcePlay: false, metricsFiltersOpen: false, reconnectMaxAttempts: 20, autoRestorePeers: true, qvacOnboarded: false, qvacProfile: 'recommended', qvacMode: '', qvacCacheDir: '', qvacRag: true, qvacIdleUnloadMin: 30, qvacSubAgents: true, qvacMaxSubAgents: 3, qvacToolDepth: 'auto', /** off = never switch tabs; ask = confirm each time; on = always when tools request */ qvacAutoNavigate: 'ask', customDashboards: [], activeDashboardId: null, } } export function getSettingsCachePath() { return path.join(getPeardataCacheDir(), 'settings.json') } /** @deprecated use getSettingsCachePath */ export function settingsPath() { return getSettingsCachePath() } function readSettingsFile(file) { try { if (!fs.existsSync(file)) return null const raw = JSON.parse(fs.readFileSync(file, 'utf8')) return raw?.settings || raw || null } catch { return null } } function migrateLegacySettings() { const candidates = [ path.join(getPeardataHome(), 'ui-settings.json'), path.join(getPeardataHome(), 'cache', 'ui-settings.json'), ] for (const file of candidates) { const s = readSettingsFile(file) if (s && typeof s === 'object') return s } try { if (typeof localStorage !== 'undefined') { const boot = localStorage.getItem('peardata-ui-boot') if (boot) { const parsed = JSON.parse(boot) if (parsed?.theme) return { theme: parsed.theme } } } } catch { // ignore } return null } function readLocalStorageMirror() { try { if (typeof localStorage === 'undefined') return null const mir = localStorage.getItem(SETTINGS_LOCALSTORAGE_KEY) if (!mir) return null const parsed = JSON.parse(mir) return parsed && typeof parsed === 'object' ? parsed : null } catch { return null } } /** Side-effect free merge of defaults + disk/legacy/LS. Never writes. */ function peekSettings() { const defaults = defaultSettings() const fromFile = readSettingsFile(getSettingsCachePath()) || migrateLegacySettings() || readLocalStorageMirror() return { ...defaults, ...(fromFile || {}) } } /** * Persist settings envelope to disk + LS mirror. Does not call loadSettings. * @param {UiSettings} settings */ function persistSettings(settings) { const payload = { version: SETTINGS_CACHE_VERSION, updatedAt: new Date().toISOString(), settings, } try { atomicWriteJson(getSettingsCachePath(), payload) } catch { // ignore persistence failures } try { if (typeof localStorage !== 'undefined') { localStorage.setItem(SETTINGS_LOCALSTORAGE_KEY, JSON.stringify(settings)) localStorage.setItem( 'peardata-ui-boot', JSON.stringify({ theme: settings.theme }) ) } } catch { // ignore } } export function loadSettings() { const defaults = defaultSettings() const cached = readSettingsFile(getSettingsCachePath()) if (cached && typeof cached === 'object') { return { ...defaults, ...cached } } const legacy = migrateLegacySettings() if (legacy) { const merged = { ...defaults, ...legacy } persistSettings(merged) return merged } const mir = readLocalStorageMirror() return { ...defaults, ...(mir || {}) } } /** @param {Partial} patch */ export function saveSettings(patch) { const next = { ...peekSettings(), ...patch } persistSettings(next) return next } /** * @param {UiSettings} s */ export function applySettingsToDom(s) { const html = document.documentElement const body = document.body html.dataset.theme = s.theme body.dataset.theme = s.theme body.dataset.accent = s.accent || 'teal' body.dataset.density = s.density || 'comfortable' body.dataset.reduceMotion = s.reduceMotion ? '1' : '0' body.classList.toggle('sidebar-collapsed', Boolean(s.sidebarCollapsed)) }