224 lines
5.9 KiB
JavaScript
224 lines
5.9 KiB
JavaScript
/**
|
|
* 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<string, string>,
|
|
* 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<UiSettings>} 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))
|
|
}
|