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 { if (!value || typeof value !== 'object' || Array.isArray(value)) throw new Error('Некорректные настройки окон') return value as Record } 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) } }