Files
2026-10-18 19:17:40 +07:00

107 lines
4.0 KiB
TypeScript

import { readFile } from 'node:fs/promises'
import { mkdirSync, renameSync, writeFileSync } from 'node:fs'
import { dirname } from 'node:path'
import type { Rectangle } from 'electron'
export interface WindowPreferences {
audioOutput?: string
control: { bounds: Rectangle; maximized: boolean } | null
display: { id: number; label: string } | null
}
const defaults = (): WindowPreferences => ({ control: null, display: null })
function object(value: unknown): Record<string, unknown> {
if (!value || typeof value !== 'object' || Array.isArray(value))
throw new Error('Некорректные настройки окон')
return value as Record<string, unknown>
}
function integer(value: unknown, min: number, max: number): number {
if (typeof value !== 'number' || !Number.isSafeInteger(value) || value < min || value > max)
throw new Error('Некорректная геометрия окна')
return value
}
export function parseWindowPreferences(value: unknown): WindowPreferences {
const data = object(value)
if (data.version !== 1) throw new Error('Неизвестная версия настроек окон')
const result = defaults()
if (data.audioOutput !== undefined) {
if (typeof data.audioOutput !== 'string' || data.audioOutput.length > 1024)
throw new Error('Некорректное устройство звука')
result.audioOutput = data.audioOutput
}
if (data.control !== null) {
const c = object(data.control)
const b = object(c.bounds)
if (typeof c.maximized !== 'boolean') throw new Error('Некорректное состояние окна')
result.control = {
maximized: c.maximized,
bounds: {
x: integer(b.x, -10000000, 10000000),
y: integer(b.y, -10000000, 10000000),
width: integer(b.width, 1, 32768),
height: integer(b.height, 1, 32768),
},
}
}
if (data.display !== null) {
const d = object(data.display)
if (typeof d.label !== 'string' || d.label.length > 1024)
throw new Error('Некорректный дисплей')
result.display = {
id: integer(d.id, Number.MIN_SAFE_INTEGER, Number.MAX_SAFE_INTEGER),
label: d.label,
}
}
return result
}
export function fitControlBounds(saved: Rectangle | undefined, work: Rectangle): Rectangle {
const width = Math.min(work.width, Math.max(Math.min(640, work.width), saved?.width ?? 1000))
const height = Math.min(work.height, Math.max(Math.min(480, work.height), saved?.height ?? 800))
return {
x: Math.max(
work.x,
Math.min(
saved?.x ?? work.x + Math.floor((work.width - width) / 2),
work.x + work.width - width,
),
),
y: Math.max(
work.y,
Math.min(
saved?.y ?? work.y + Math.floor((work.height - height) / 2),
work.y + work.height - height,
),
),
width,
height,
}
}
export function preferredDisplayId(
preferred: WindowPreferences['display'],
displays: { id: number; label: string }[],
primary: number,
): number | null {
return (
displays.find((d) => d.id !== primary && d.id === preferred?.id && d.label === preferred.label)
?.id ?? null
)
}
export class WindowSettings {
value: WindowPreferences = defaults()
constructor(private path: string) {}
async load() {
try {
this.value = parseWindowPreferences(JSON.parse(await readFile(this.path, 'utf8')))
} catch (error) {
if ((error as NodeJS.ErrnoException).code !== 'ENOENT')
throw new Error('Настройки окон повреждены; использовано безопасное расположение')
}
}
save() {
const data = JSON.stringify({ version: 1, ...this.value })
mkdirSync(dirname(this.path), { recursive: true })
// Маленький файл пишется синхронно, чтобы последний resize не потерялся при выходе.
writeFileSync(`${this.path}.tmp`, data, { mode: 0o600 })
renameSync(`${this.path}.tmp`, this.path)
}
}