Files
SecScore/src/main/services/SettingsService.ts
T
2026-03-01 13:43:52 +08:00

306 lines
9.3 KiB
TypeScript

import { Service } from '../../shared/kernel'
import { MainContext } from '../context'
import { BrowserWindow, webContents } from 'electron'
import type { IpcMainInvokeEvent } from 'electron'
import type { settingsKey, settingsSpec, settingChange } from '../../preload/types'
import type { permissionLevel } from './PermissionService'
import { SettingEntity } from '../db/entities'
declare module '../../shared/kernel' {
interface Context {
settings: SettingsService
}
}
type settingValueKind = 'string' | 'boolean' | 'number' | 'json'
type settingDefinition = {
kind: settingValueKind
defaultValue: unknown
readPermission?: permissionLevel | 'any'
writePermission?: permissionLevel | 'any'
validate?: (value: unknown) => boolean
normalize?: (value: unknown) => unknown
onChanged?: (ctx: MainContext, next: unknown, prev: unknown) => void
}
export class SettingsService extends Service {
constructor(ctx: MainContext) {
super(ctx, 'settings')
this.registerIpc()
}
private get mainCtx() {
return this.ctx as MainContext
}
private definitions: Record<settingsKey, settingDefinition> = {
is_wizard_completed: {
kind: 'boolean',
defaultValue: false,
writePermission: 'any'
},
log_level: {
kind: 'string',
defaultValue: 'info',
writePermission: 'admin',
validate: (v) => v === 'debug' || v === 'info' || v === 'warn' || v === 'error',
onChanged: (ctx, next) => {
ctx.logger.setLevel(next as any)
}
},
window_zoom: {
kind: 'number',
defaultValue: 1.0,
writePermission: 'admin',
onChanged: (_ctx, next) => {
const zoom = Number(next) || 1.0
webContents.getAllWebContents().forEach((wc: any) => {
wc.setZoomFactor(zoom)
})
}
},
window_theme: {
kind: 'string',
defaultValue: 'auto',
writePermission: 'admin',
validate: (v) => v === 'auto' || v === 'dark' || v === 'light'
},
window_effect: {
kind: 'string',
defaultValue: 'mica',
writePermission: 'admin',
validate: (v) =>
['mica', 'tabbed', 'acrylic', 'blur', 'transparent', 'none'].includes(v as string)
},
window_radius: {
kind: 'string',
defaultValue: 'rounded',
writePermission: 'admin',
validate: (v) => v === 'rounded' || v === 'small' || v === 'square'
},
auto_score_enabled: {
kind: 'boolean',
defaultValue: false,
writePermission: 'admin'
},
auto_score_rules: {
kind: 'json',
defaultValue: [],
writePermission: 'admin'
},
current_theme_id: {
kind: 'string',
defaultValue: 'light-default',
writePermission: 'admin'
},
pg_connection_string: {
kind: 'string',
defaultValue: '',
writePermission: 'admin'
},
pg_connection_status: {
kind: 'json',
defaultValue: { connected: false, type: 'sqlite' },
writePermission: 'admin'
}
}
private cache = new Map<string, string>()
private initPromise: Promise<void> | null = null
async initialize(): Promise<void> {
if (this.initPromise) return this.initPromise
this.initPromise = (async () => {
await this.loadCache()
await this.ensureDefaults()
})()
return this.initPromise
}
private async loadCache() {
const repo = this.ctx.db.dataSource.getRepository(SettingEntity)
const rows = await repo.find()
this.cache.clear()
for (const r of rows) this.cache.set(r.key, String(r.value ?? ''))
}
private async ensureDefaults() {
const repo = this.ctx.db.dataSource.getRepository(SettingEntity)
for (const key of Object.keys(this.definitions) as settingsKey[]) {
if (this.cache.has(key)) continue
const def = this.definitions[key]
const raw = this.serializeValue(key, def.defaultValue)
await repo
.createQueryBuilder()
.insert()
.into(SettingEntity)
.values({ key, value: raw })
.orIgnore()
.execute()
this.cache.set(key, raw)
}
}
private serializeValue(key: settingsKey, value: unknown): string {
const def = this.definitions[key]
switch (def.kind) {
case 'boolean':
return value ? '1' : '0'
case 'number':
return String(value)
case 'json':
return JSON.stringify(value)
case 'string':
default:
return String(value)
}
}
private deserializeValue(key: settingsKey, raw: string): unknown {
const def = this.definitions[key]
switch (def.kind) {
case 'boolean':
return raw === '1' || raw.toLowerCase() === 'true'
case 'number':
return Number(raw)
case 'json':
try {
return JSON.parse(raw)
} catch {
return def.defaultValue
}
case 'string':
default:
return raw
}
}
private parseValue(key: settingsKey, raw: string | undefined): unknown {
const def = this.definitions[key]
if (raw == null) return def.defaultValue
const deserialized = this.deserializeValue(key, raw)
const normalized = def.normalize ? def.normalize(deserialized) : deserialized
if (def.validate && !def.validate(normalized)) return def.defaultValue
return normalized
}
private canWrite(event: IpcMainInvokeEvent, key: settingsKey): boolean {
const required = this.definitions[key].writePermission
if (!required || required === 'any') return true
return this.mainCtx.permissions.requirePermission(event, required)
}
private notifyChanged(key: settingsKey, value: unknown) {
const change: settingChange = { key, value } as any
const windows = BrowserWindow.getAllWindows()
for (const win of windows) {
win.webContents.send('settings:changed', change)
}
}
private registerIpc() {
this.mainCtx.handle('settings:getAll', async () => {
await this.initialize()
return { success: true, data: this.getAll() }
})
this.mainCtx.handle('settings:get', async (_event, key: settingsKey) => {
await this.initialize()
return { success: true, data: this.getValue(key) }
})
this.mainCtx.handle('settings:set', async (event, key: settingsKey, value: any) => {
if (!this.canWrite(event, key)) return { success: false, message: 'Permission denied' }
await this.setValue(key, value)
return { success: true }
})
}
async reloadFromDb(options: { notify?: boolean } = {}): Promise<void> {
await this.initialize()
const notify = options.notify ?? true
const prev = this.getAll()
await this.loadCache()
await this.ensureDefaults()
if (!notify) return
for (const key of Object.keys(this.definitions) as settingsKey[]) {
const def = this.definitions[key]
const next = this.getValue(key as any) as any
const prevValue = prev[key]
if (next !== prevValue) {
def.onChanged?.(this.mainCtx, next, prevValue)
this.notifyChanged(key, next)
}
}
}
getRaw(key: string): string {
return this.cache.get(key) ?? ''
}
async setRaw(key: string, value: string): Promise<void> {
await this.initialize()
const prev = this.cache.get(key) ?? ''
if (key in this.definitions) {
const typedKey = key as settingsKey
const def = this.definitions[typedKey]
const nextDeserialized = this.deserializeValue(typedKey, value)
const nextNormalized = def.normalize ? def.normalize(nextDeserialized) : nextDeserialized
const nextRaw =
def.validate && !def.validate(nextNormalized)
? this.serializeValue(typedKey, def.defaultValue)
: this.serializeValue(typedKey, nextNormalized)
await this.ctx.db.dataSource.getRepository(SettingEntity).save({ key, value: nextRaw })
this.cache.set(key, nextRaw)
const nextTyped = this.parseValue(typedKey, nextRaw)
const prevTyped = this.parseValue(typedKey, prev)
if (nextTyped !== prevTyped) {
def.onChanged?.(this.mainCtx, nextTyped, prevTyped)
this.notifyChanged(typedKey, nextTyped)
}
return
}
await this.ctx.db.dataSource.getRepository(SettingEntity).save({ key, value })
this.cache.set(key, value)
}
getValue<K extends settingsKey>(key: K): settingsSpec[K] {
return this.parseValue(key, this.cache.get(key)) as settingsSpec[K]
}
async setValue<K extends settingsKey>(key: K, value: settingsSpec[K]): Promise<void> {
await this.initialize()
const def = this.definitions[key]
const prev = this.getValue(key)
const normalized = def.normalize ? def.normalize(value) : value
if (def.validate && !def.validate(normalized)) {
throw new Error(`Invalid value for setting: ${String(key)}`)
}
const raw = this.serializeValue(key, normalized)
await this.ctx.db.dataSource.getRepository(SettingEntity).save({ key, value: raw })
this.cache.set(key, raw)
const next = this.getValue(key)
if (next !== prev) {
def.onChanged?.(this.mainCtx, next, prev)
this.notifyChanged(key, next)
}
}
getAllRaw(): Record<string, string> {
const out: Record<string, string> = {}
for (const [k, v] of this.cache.entries()) out[k] = v
return out
}
getAll(): settingsSpec {
const out: any = {}
for (const key of Object.keys(this.definitions) as settingsKey[]) {
out[key] = this.getValue(key)
}
return out as settingsSpec
}
}