up
This commit is contained in:
@@ -208,7 +208,15 @@ export class Controller {
|
||||
position: 0,
|
||||
})
|
||||
}
|
||||
output(open: boolean) {
|
||||
output(open: boolean, pauseTimer = false) {
|
||||
if (!open && pauseTimer && this.state.timer.status === 'running') {
|
||||
this.updateTimer()
|
||||
if (this.state.timer.status === 'running') {
|
||||
this.timerDeadline = null
|
||||
this.state.timer.status = 'paused'
|
||||
this.state.timer.revision++
|
||||
}
|
||||
}
|
||||
this.newSession()
|
||||
this.state.outputOpen = open
|
||||
this.state.scene.status = open ? 'pending' : 'idle'
|
||||
|
||||
+223
-34
@@ -1,9 +1,20 @@
|
||||
import { app, BrowserWindow, ipcMain, protocol, screen, session, safeStorage } from 'electron'
|
||||
import {
|
||||
app,
|
||||
BrowserWindow,
|
||||
ipcMain,
|
||||
protocol,
|
||||
screen,
|
||||
session,
|
||||
safeStorage,
|
||||
powerMonitor,
|
||||
powerSaveBlocker,
|
||||
} from 'electron'
|
||||
import { createReadStream } from 'node:fs'
|
||||
import { stat } from 'node:fs/promises'
|
||||
import { join } from 'node:path'
|
||||
import { Readable } from 'node:stream'
|
||||
import { Controller, parseCommand } from './controller.js'
|
||||
import { WindowSettings, fitControlBounds, preferredDisplayId } from './window-settings.js'
|
||||
import { parseRange } from './range.js'
|
||||
import { Online } from './online.js'
|
||||
import type { OnlineState } from '../src/types/online.js'
|
||||
@@ -22,8 +33,17 @@ protocol.registerSchemesAsPrivileged([
|
||||
])
|
||||
let control: BrowserWindow | null = null
|
||||
let output: BrowserWindow | null = null
|
||||
let closingWindow: BrowserWindow | null = null
|
||||
let openingOutput = false
|
||||
let outputEpoch = 0
|
||||
let quitting = false
|
||||
let selectedDisplay: number | null = null
|
||||
let online: Online | null = null
|
||||
let settings: WindowSettings
|
||||
let sleepBlocker: number | null = null
|
||||
let hostNotice = ''
|
||||
let suspended = false
|
||||
let saveTimer: ReturnType<typeof setTimeout> | undefined
|
||||
const controller = new Controller((state) => {
|
||||
for (const window of [control, output])
|
||||
if (window && !window.isDestroyed()) window.webContents.send('state', state)
|
||||
@@ -32,6 +52,13 @@ const rendererRoot = () => join(app.getAppPath(), 'dist')
|
||||
function host(): HostInfo {
|
||||
const primary = screen.getPrimaryDisplay().id
|
||||
return {
|
||||
notice: hostNotice,
|
||||
sleepBlocked: sleepBlocker !== null && powerSaveBlocker.isStarted(sleepBlocker),
|
||||
preferredDisplayId: preferredDisplayId(
|
||||
settings?.value.display ?? null,
|
||||
screen.getAllDisplays(),
|
||||
primary,
|
||||
),
|
||||
displays: screen
|
||||
.getAllDisplays()
|
||||
.filter((d) => d.id !== primary)
|
||||
@@ -41,10 +68,11 @@ function host(): HostInfo {
|
||||
})),
|
||||
}
|
||||
}
|
||||
function windowOptions() {
|
||||
function windowOptions(isOutput = false) {
|
||||
return {
|
||||
backgroundColor: '#111111',
|
||||
webPreferences: {
|
||||
...(isOutput ? { partition: 'screen-output' } : {}),
|
||||
preload: join(__dirname, 'preload.js'),
|
||||
nodeIntegration: false,
|
||||
contextIsolation: true,
|
||||
@@ -64,31 +92,144 @@ function requireControl(event: Electron.IpcMainInvokeEvent) {
|
||||
)
|
||||
throw new Error('Недоступно')
|
||||
}
|
||||
function show(displayId: number) {
|
||||
if (online?.blocksOutput) throw new Error('Подождите завершения подготовки материалов')
|
||||
const display = screen.getAllDisplays().find((d) => d.id === displayId)
|
||||
if (!display || display.id === screen.getPrimaryDisplay().id)
|
||||
throw new Error('Выберите внешний дисплей; включите расширенный рабочий стол')
|
||||
if (output) output.destroy()
|
||||
selectedDisplay = display.id
|
||||
output = new BrowserWindow({ ...windowOptions(), ...display.bounds, show: false })
|
||||
secure(output)
|
||||
controller.output(true)
|
||||
function releaseSleepBlocker() {
|
||||
if (sleepBlocker === null) return
|
||||
powerSaveBlocker.stop(sleepBlocker)
|
||||
sleepBlocker = null
|
||||
}
|
||||
function closeOutput(reason = '', destroy = true) {
|
||||
if (reason) outputEpoch++
|
||||
const window = output
|
||||
output.on('closed', () => {
|
||||
if (output !== window) return
|
||||
output = null
|
||||
selectedDisplay = null
|
||||
controller.output(false)
|
||||
})
|
||||
output.once('ready-to-show', () => {
|
||||
if (!window.isDestroyed()) {
|
||||
window.show()
|
||||
window.setBounds(display.bounds)
|
||||
window.setFullScreen(true)
|
||||
if (!window && (!reason || controller.state.timer.status !== 'running')) return
|
||||
output = null
|
||||
if (window) closingWindow = window
|
||||
selectedDisplay = null
|
||||
if (reason)
|
||||
hostNotice = `${reason}. Откройте показ заново; медиа остановлены, работающий таймер поставлен на паузу`
|
||||
controller.output(false, !!reason)
|
||||
releaseSleepBlocker()
|
||||
if (destroy && window) {
|
||||
// Не уничтожать native-окно повторно внутри его fullscreen/close callback.
|
||||
setImmediate(() => {
|
||||
if (!window.isDestroyed()) window.destroy()
|
||||
})
|
||||
}
|
||||
}
|
||||
function saveWindowSettings() {
|
||||
clearTimeout(saveTimer)
|
||||
if (!settings) return
|
||||
if (control && !control.isDestroyed() && !control.isMinimized())
|
||||
settings.value.control = { bounds: control.getNormalBounds(), maximized: control.isMaximized() }
|
||||
try {
|
||||
settings.save()
|
||||
} catch {
|
||||
hostNotice = 'Не удалось сохранить расположение окон; материалы игры не затронуты'
|
||||
}
|
||||
}
|
||||
function keepControlOnPrimary() {
|
||||
if (!control || control.isDestroyed()) return
|
||||
const before = control.getNormalBounds()
|
||||
const after = fitControlBounds(before, screen.getPrimaryDisplay().workArea)
|
||||
if (JSON.stringify(before) === JSON.stringify(after)) return
|
||||
const maximized = control.isMaximized()
|
||||
if (maximized) control.unmaximize()
|
||||
control.setBounds(after)
|
||||
if (maximized) control.maximize()
|
||||
}
|
||||
function reconcileDisplays() {
|
||||
if (
|
||||
output &&
|
||||
!screen
|
||||
.getAllDisplays()
|
||||
.some((d) => d.id === selectedDisplay && d.id !== screen.getPrimaryDisplay().id)
|
||||
)
|
||||
closeOutput('Выбранный дисплей отключён или стал основным')
|
||||
keepControlOnPrimary()
|
||||
}
|
||||
async function show(displayId: number) {
|
||||
if (openingOutput) throw new Error('Подождите открытия окна показа')
|
||||
openingOutput = true
|
||||
const epoch = outputEpoch
|
||||
try {
|
||||
if (suspended || quitting) throw new Error('Дождитесь пробуждения компьютера')
|
||||
if (online?.blocksOutput) throw new Error('Подождите завершения подготовки материалов')
|
||||
const display = screen.getAllDisplays().find((d) => d.id === displayId)
|
||||
if (!display || display.id === screen.getPrimaryDisplay().id)
|
||||
throw new Error('Выберите внешний дисплей; включите расширенный рабочий стол')
|
||||
const geometry = JSON.stringify([display.bounds, display.scaleFactor, display.rotation])
|
||||
closeOutput()
|
||||
const closing = closingWindow
|
||||
if (closing && !closing.isDestroyed())
|
||||
await new Promise<void>((resolve) => closing.once('closed', () => setImmediate(resolve)))
|
||||
if (online?.blocksOutput) throw new Error('Подождите завершения подготовки материалов')
|
||||
const current = screen.getAllDisplays().find((d) => d.id === displayId)
|
||||
if (
|
||||
epoch !== outputEpoch ||
|
||||
suspended ||
|
||||
quitting ||
|
||||
!current ||
|
||||
current.id === screen.getPrimaryDisplay().id ||
|
||||
geometry !== JSON.stringify([current.bounds, current.scaleFactor, current.rotation])
|
||||
)
|
||||
throw new Error('Открытие отменено: изменилось состояние компьютера или дисплея')
|
||||
hostNotice = ''
|
||||
const window = new BrowserWindow({ ...windowOptions(true), ...display.bounds, show: false })
|
||||
output = window
|
||||
selectedDisplay = display.id
|
||||
settings.value.display = { id: display.id, label: display.label }
|
||||
saveWindowSettings()
|
||||
secure(window)
|
||||
controller.output(true)
|
||||
try {
|
||||
sleepBlocker = powerSaveBlocker.start('prevent-display-sleep')
|
||||
} catch {
|
||||
hostNotice = 'Не удалось заблокировать автоматический сон. Проверьте настройки питания ОС'
|
||||
}
|
||||
})
|
||||
void output.loadURL('screen-app://app/index.html?output=1')
|
||||
window.on('close', () => {
|
||||
if (output === window) closeOutput('', false)
|
||||
})
|
||||
window.on('closed', () => {
|
||||
if (output === window) closeOutput('', false)
|
||||
if (closingWindow === window) closingWindow = null
|
||||
})
|
||||
window.on('unresponsive', () => {
|
||||
if (output === window) closeOutput('Окно показа не отвечает')
|
||||
})
|
||||
window.webContents.on('render-process-gone', () => {
|
||||
if (output === window) closeOutput('Процесс окна показа завершился')
|
||||
})
|
||||
window.webContents.on('did-fail-load', (_event, _code, _description, _url, isMainFrame) => {
|
||||
if (isMainFrame && output === window) closeOutput('Не удалось загрузить окно показа')
|
||||
})
|
||||
let enteredFullscreen = false
|
||||
window.on('enter-full-screen', () => {
|
||||
enteredFullscreen = true
|
||||
})
|
||||
window.on('leave-full-screen', () => {
|
||||
if (enteredFullscreen && output === window)
|
||||
closeOutput('Окно показа вышло из полноэкранного режима')
|
||||
})
|
||||
window.once('ready-to-show', () => {
|
||||
if (output !== window || window.isDestroyed()) return
|
||||
const current = screen.getAllDisplays().find((d) => d.id === display.id)
|
||||
if (
|
||||
!current ||
|
||||
current.id === screen.getPrimaryDisplay().id ||
|
||||
geometry !== JSON.stringify([current.bounds, current.scaleFactor, current.rotation])
|
||||
) {
|
||||
closeOutput('Параметры дисплея изменились при открытии показа')
|
||||
return
|
||||
}
|
||||
window.show()
|
||||
window.setBounds(current.bounds)
|
||||
window.setFullScreen(true)
|
||||
})
|
||||
void window.loadURL('screen-app://app/index.html?output=1').catch(() => {
|
||||
if (output === window) closeOutput('Не удалось загрузить окно показа')
|
||||
})
|
||||
} finally {
|
||||
openingOutput = false
|
||||
}
|
||||
}
|
||||
// Две копии приложения не должны одновременно менять указатели пакетов.
|
||||
if (!app.requestSingleInstanceLock()) app.exit(0)
|
||||
@@ -100,6 +241,12 @@ app.on('second-instance', () => {
|
||||
app
|
||||
.whenReady()
|
||||
.then(async () => {
|
||||
settings = new WindowSettings(join(app.getPath('userData'), 'windows.json'))
|
||||
try {
|
||||
await settings.load()
|
||||
} catch (error) {
|
||||
hostNotice = error instanceof Error ? error.message : 'Не удалось восстановить окна'
|
||||
}
|
||||
const packages = new PackageStore(join(app.getPath('userData'), 'packages'))
|
||||
await packages.initialize()
|
||||
online = new Online(join(app.getPath('userData'), 'connection'), packages, controller, {
|
||||
@@ -116,16 +263,16 @@ app
|
||||
}, 1000)
|
||||
const timerTick = setInterval(() => controller.tick(), 250)
|
||||
app.on('before-quit', () => {
|
||||
quitting = true
|
||||
saveWindowSettings()
|
||||
closeOutput()
|
||||
releaseSleepBlocker()
|
||||
clearInterval(timerTick)
|
||||
clearInterval(onlineTick)
|
||||
online?.close()
|
||||
packages.cancel()
|
||||
})
|
||||
session.defaultSession.setPermissionRequestHandler((_contents, _permission, callback) =>
|
||||
callback(false),
|
||||
)
|
||||
session.defaultSession.setPermissionCheckHandler(() => false)
|
||||
protocol.handle('screen-app', async (request) => {
|
||||
const handleAppRequest: Parameters<typeof protocol.handle>[1] = async (request) => {
|
||||
const url = new URL(request.url)
|
||||
const headers = {
|
||||
'Content-Security-Policy':
|
||||
@@ -189,8 +336,32 @@ app
|
||||
} catch {
|
||||
return new Response(null, { status: 404 })
|
||||
}
|
||||
}
|
||||
// Разные сессии изолируют renderer показа от пульта, протокол и запреты одинаковы.
|
||||
for (const target of [session.defaultSession, session.fromPartition('screen-output')]) {
|
||||
target.setPermissionRequestHandler((_contents, _permission, callback) => callback(false))
|
||||
target.setPermissionCheckHandler(() => false)
|
||||
target.protocol.handle('screen-app', handleAppRequest)
|
||||
}
|
||||
control = new BrowserWindow({
|
||||
...windowOptions(),
|
||||
...fitControlBounds(settings.value.control?.bounds, screen.getPrimaryDisplay().workArea),
|
||||
show: false,
|
||||
})
|
||||
control = new BrowserWindow({ ...windowOptions(), width: 1000, height: 800 })
|
||||
control.once('ready-to-show', () => {
|
||||
keepControlOnPrimary()
|
||||
if (settings.value.control?.maximized) control!.maximize()
|
||||
control!.show()
|
||||
})
|
||||
const scheduleSave = () => {
|
||||
clearTimeout(saveTimer)
|
||||
saveTimer = setTimeout(saveWindowSettings, 250)
|
||||
}
|
||||
control.on('move', scheduleSave)
|
||||
control.on('resize', scheduleSave)
|
||||
control.on('maximize', scheduleSave)
|
||||
control.on('unmaximize', scheduleSave)
|
||||
control.on('close', saveWindowSettings)
|
||||
secure(control)
|
||||
ipcMain.handle('state', (event) => {
|
||||
if (
|
||||
@@ -211,7 +382,7 @@ app
|
||||
})
|
||||
ipcMain.handle('close-output', (event) => {
|
||||
requireControl(event)
|
||||
output?.close()
|
||||
closeOutput()
|
||||
})
|
||||
ipcMain.handle('host', (event) => {
|
||||
requireControl(event)
|
||||
@@ -221,7 +392,7 @@ app
|
||||
requireControl(event)
|
||||
if (selectingPackage) throw new Error('Подождите проверки пакета')
|
||||
if (typeof id !== 'number') throw new Error('Некорректный дисплей')
|
||||
show(id)
|
||||
return show(id)
|
||||
})
|
||||
let selectingPackage = false
|
||||
ipcMain.handle('packages', (event) => {
|
||||
@@ -330,7 +501,25 @@ app
|
||||
}
|
||||
})
|
||||
screen.on('display-removed', (_event, display) => {
|
||||
if (display.id === selectedDisplay) output?.close()
|
||||
if (display.id === selectedDisplay) closeOutput('Дисплей показа отключён')
|
||||
reconcileDisplays()
|
||||
})
|
||||
screen.on('display-added', reconcileDisplays)
|
||||
screen.on('display-metrics-changed', (_event, display, metrics) => {
|
||||
if (
|
||||
display.id === selectedDisplay &&
|
||||
metrics.some((m) => ['bounds', 'scaleFactor', 'rotation'].includes(m))
|
||||
)
|
||||
closeOutput('Изменились разрешение, масштаб или ориентация дисплея')
|
||||
reconcileDisplays()
|
||||
})
|
||||
powerMonitor.on('suspend', () => {
|
||||
suspended = true
|
||||
closeOutput('Компьютер переходит в сон')
|
||||
})
|
||||
powerMonitor.on('resume', () => {
|
||||
suspended = false
|
||||
reconcileDisplays()
|
||||
})
|
||||
control.on('closed', () => {
|
||||
app.quit()
|
||||
|
||||
@@ -282,3 +282,31 @@ test('не принимает повреждённые снимки, повто
|
||||
f.c.tick()
|
||||
assert.equal(f.c.state.timer.remainingMs, 299500)
|
||||
})
|
||||
|
||||
test('аварийное закрытие сохраняет точный остаток таймера на паузе, повторное открытие не запускает его', () => {
|
||||
const f = fixture()
|
||||
f.send({ action: 'timer-set', label: 'Перерыв', durationSeconds: 60 })
|
||||
f.send({ action: 'timer-start' })
|
||||
f.send({ action: 'play', channel: 'main' })
|
||||
const oldSession = f.c.state.session
|
||||
f.advance(1234)
|
||||
f.c.output(false, true)
|
||||
assert.equal(f.c.state.timer.status, 'paused')
|
||||
assert.equal(f.c.state.timer.remainingMs, 58766)
|
||||
assert.equal(f.c.state.channels.main.desired, 'stopped')
|
||||
assert.equal(f.c.state.channels.music.desired, 'stopped')
|
||||
assert.notEqual(f.c.state.session, oldSession)
|
||||
f.advance(10000)
|
||||
f.c.tick()
|
||||
f.c.output(true)
|
||||
assert.equal(f.c.state.timer.status, 'paused')
|
||||
assert.equal(f.c.state.timer.remainingMs, 58766)
|
||||
f.send({ action: 'timer-resume' })
|
||||
f.advance(1000)
|
||||
f.c.tick()
|
||||
assert.equal(f.c.state.timer.remainingMs, 57766)
|
||||
f.advance(60000)
|
||||
f.c.output(false, true)
|
||||
assert.equal(f.c.state.timer.status, 'finished')
|
||||
assert.equal(f.c.state.timer.remainingMs, 0)
|
||||
})
|
||||
|
||||
@@ -0,0 +1,80 @@
|
||||
import assert from 'node:assert/strict'
|
||||
import { test } from 'node:test'
|
||||
import { mkdtemp, readFile, rm, writeFile } from 'node:fs/promises'
|
||||
import { tmpdir } from 'node:os'
|
||||
import { join } from 'node:path'
|
||||
import {
|
||||
WindowSettings,
|
||||
fitControlBounds,
|
||||
parseWindowPreferences,
|
||||
preferredDisplayId,
|
||||
} from './window-settings.js'
|
||||
|
||||
const primary = { x: -1920, y: -200, width: 1920, height: 1080 }
|
||||
test('пульт возвращается с отключённого монитора и помещается в рабочую область основного', () => {
|
||||
const bounds = fitControlBounds({ x: 5000, y: -5000, width: 3840, height: 2160 }, primary)
|
||||
assert.deepEqual(bounds, primary)
|
||||
assert.deepEqual(fitControlBounds(undefined, { x: 100, y: 50, width: 500, height: 300 }), {
|
||||
x: 100,
|
||||
y: 50,
|
||||
width: 500,
|
||||
height: 300,
|
||||
})
|
||||
const saved = { x: -1800, y: 0, width: 1000, height: 800 }
|
||||
assert.deepEqual(fitControlBounds(saved, primary), saved)
|
||||
const centered = fitControlBounds(undefined, primary)
|
||||
assert.equal(centered.x, -1460)
|
||||
assert.equal(centered.y, -60)
|
||||
})
|
||||
|
||||
test('внешний дисплей предвыбирается только при совпадении ID и названия', () => {
|
||||
const saved = { id: 2, label: 'Projector' }
|
||||
const displays = [{ id: 1, label: 'Laptop' }, saved]
|
||||
assert.equal(preferredDisplayId(saved, displays, 1), 2)
|
||||
assert.equal(preferredDisplayId(saved, displays, 2), null)
|
||||
assert.equal(preferredDisplayId(saved, [{ id: 2, label: 'Other' }], 1), null)
|
||||
assert.equal(preferredDisplayId(saved, [{ id: 3, label: 'Projector' }], 1), null)
|
||||
assert.equal(preferredDisplayId(saved, [], 1), null)
|
||||
})
|
||||
|
||||
test('проверяет локальный файл настроек и не принимает повреждённую геометрию', () => {
|
||||
const valid = {
|
||||
version: 1,
|
||||
control: { bounds: primary, maximized: true },
|
||||
display: { id: -2, label: 'Экран' },
|
||||
}
|
||||
assert.equal(parseWindowPreferences(valid).control?.maximized, true)
|
||||
for (const bad of [
|
||||
null,
|
||||
{},
|
||||
{ ...valid, version: 2 },
|
||||
{ ...valid, control: { bounds: { ...primary, width: 0 }, maximized: true } },
|
||||
{ ...valid, control: { bounds: primary, maximized: 'true' } },
|
||||
{ ...valid, display: { id: 2.5, label: 'Other' } },
|
||||
])
|
||||
assert.throws(() => parseWindowPreferences(bad))
|
||||
})
|
||||
|
||||
test('последняя геометрия и выбор дисплея сохраняются атомарно, ошибочный файл не мешает запуску', async (t) => {
|
||||
const dir = await mkdtemp(join(tmpdir(), 'screen-windows-'))
|
||||
t.after(() => rm(dir, { recursive: true, force: true }))
|
||||
const path = join(dir, 'windows.json')
|
||||
const store = new WindowSettings(path)
|
||||
await store.load()
|
||||
assert.deepEqual(store.value, { control: null, display: null })
|
||||
store.value = {
|
||||
control: { bounds: primary, maximized: true },
|
||||
display: { id: 2, label: 'Projector' },
|
||||
}
|
||||
store.save()
|
||||
store.value.control!.bounds = { ...primary, width: 800 }
|
||||
store.save()
|
||||
const restored = new WindowSettings(path)
|
||||
await restored.load()
|
||||
assert.deepEqual(restored.value, store.value)
|
||||
assert.equal(JSON.parse(await readFile(path, 'utf8')).version, 1)
|
||||
await writeFile(path, '{bad')
|
||||
const broken = new WindowSettings(path)
|
||||
await assert.rejects(broken.load(), /повреждены/)
|
||||
assert.deepEqual(broken.value, { control: null, display: null })
|
||||
})
|
||||
@@ -0,0 +1,100 @@
|
||||
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 {
|
||||
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.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)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user