Files
evening_detective_screen/electron/main.ts
T
2026-10-27 13:07:24 +07:00

820 lines
32 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import {
app,
dialog,
BrowserWindow,
ipcMain,
protocol,
screen,
session,
safeStorage,
shell,
powerMonitor,
powerSaveBlocker,
} from 'electron'
import { createReadStream } from 'node:fs'
import { readFile, stat } from 'node:fs/promises'
import { basename, join } from 'node:path'
import { Readable } from 'node:stream'
import { DiagnosticLog } from './diagnostics.js'
import { Controller, parseCommand } from './controller.js'
import { WindowSettings, fitControlBounds, preferredDisplayId } from './window-settings.js'
import { AudioOutput } from './audio-output.js'
import { parseRange } from './range.js'
import { Online } from './online.js'
import type { OnlineState } from '../src/types/online.js'
import { PackageStore } from './package-store.js'
import { packageMediaResponse } from './package-media.js'
import { prepareFetchHeaders } from './response-headers.js'
import { fetchNetwork } from './network-fetch.js'
import { playSoundCheck, soundCheckWav } from './sound-check.js'
import type { HostInfo } from '../src/types/screen.js'
// Ozone выбирается до main.js; поздний appendSwitch смешивает Wayland и X11.
if (process.platform === 'linux' && app.commandLine.getSwitchValue('ozone-platform') !== 'x11') {
app.relaunch({ args: [...process.argv.slice(1), '--ozone-platform=x11'] })
app.exit(0)
}
app.commandLine.appendSwitch('autoplay-policy', 'no-user-gesture-required')
protocol.registerSchemesAsPrivileged([
{
scheme: 'screen-app',
privileges: { standard: true, secure: true, stream: true, supportFetchAPI: true },
},
])
let control: BrowserWindow | null = null
let output: BrowserWindow | null = null
let audioDevicesWindow: BrowserWindow | null = null
let audioOutput: AudioOutput
let closingWindow: BrowserWindow | null = null
let openingOutput = false
let outputEpoch = 0
let quitting = false
let startup: BrowserWindow | null = null
let selectedDisplay: number | null = null
let online: Online | null = null
let settings: WindowSettings
let sleepBlocker: number | null = null
let hostNotice = ''
let suspended = false
let diagnostics: DiagnosticLog | null = null
const lastErrors = { main: false, music: false, scene: false }
let saveTimer: ReturnType<typeof setTimeout> | undefined
const controller = new Controller((state) => {
for (const channel of ['main', 'music', 'scene'] as const) {
const failed =
channel === 'scene'
? state.scene.status === 'error'
: state.channels[channel].status === 'error'
if (failed && !lastErrors[channel]) diagnostics?.record('media-error', { channel })
lastErrors[channel] = failed
}
for (const window of [control, output])
if (window && !window.isDestroyed()) window.webContents.send('state', state)
})
const rendererRoot = () => join(app.getAppPath(), 'dist')
function host(): HostInfo {
const primary = screen.getPrimaryDisplay().id
return {
audioOutputs: audioOutput?.devices ?? [],
audioOutputId: audioOutput?.selected ?? '',
audioOutputNotice: audioOutput?.notice ?? '',
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)
.map((d) => ({
id: d.id,
width: d.size.width,
height: d.size.height,
label: `${d.label || 'Дисплей'} — ${d.size.width}×${d.size.height}`,
})),
}
}
function windowOptions(isOutput = false) {
return {
backgroundColor: '#111111',
webPreferences: {
...(isOutput ? { partition: 'persist:screen-output' } : {}),
preload: join(__dirname, 'preload.js'),
nodeIntegration: false,
contextIsolation: true,
sandbox: true,
},
}
}
async function setAudioSink(window: BrowserWindow, id: string) {
await window.webContents.executeJavaScript(`(async () => {
const results = await Promise.allSettled(
Array.from(document.querySelectorAll('audio,video')).map(media => media.setSinkId(${JSON.stringify(id)}))
);
const failed = results.find(result => result.status === 'rejected');
if (failed) throw failed.reason;
})()`)
}
function secure(window: BrowserWindow) {
window.webContents.setWindowOpenHandler(() => ({ action: 'deny' }))
window.webContents.on('will-navigate', (event) => event.preventDefault())
}
function requireControl(event: Electron.IpcMainInvokeEvent) {
if (
!control ||
event.sender !== control.webContents ||
event.senderFrame !== control.webContents.mainFrame
)
throw new Error('Недоступно')
}
function releaseSleepBlocker() {
if (sleepBlocker === null) return
powerSaveBlocker.stop(sleepBlocker)
sleepBlocker = null
}
function closeOutput(reason = '', destroy = true) {
if (reason) outputEpoch++
const window = output
if (!window && (!reason || controller.state.timer.status !== 'running')) return
diagnostics?.record('output-close', {
reason: !reason
? 'operator'
: reason.includes('сон')
? 'power'
: reason.includes('полноэкран')
? 'fullscreen'
: reason.includes('Процесс')
? 'renderer'
: reason.includes('не отвечает')
? 'unresponsive'
: reason.includes('загрузить')
? 'document'
: reason.includes('Параметры')
? 'geometry'
: 'display',
})
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 {
diagnostics?.record('settings-error')
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)
diagnostics?.record('output-open')
try {
sleepBlocker = powerSaveBlocker.start('prevent-display-sleep')
} catch {
hostNotice = 'Не удалось заблокировать автоматический сон. Проверьте настройки питания ОС'
}
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)
app.on('second-instance', () => {
const window = control ?? startup
window?.show()
window?.focus()
})
app.on('before-quit', () => {
quitting = true
})
app
.whenReady()
.then(async () => {
startup = new BrowserWindow({
width: 440,
height: 200,
resizable: false,
title: 'Проверка файлов',
webPreferences: { contextIsolation: true, sandbox: true, nodeIntegration: false },
})
const startupWindow = startup
startupWindow.setMenu(null)
startupWindow.webContents.setWindowOpenHandler(() => ({ action: 'deny' }))
startupWindow.webContents.on('will-navigate', (event) => event.preventDefault())
startupWindow.on('closed', () => {
if (startup === startupWindow) {
startup = null
app.quit()
}
})
await startupWindow.loadURL(
'data:text/html;charset=utf-8,' +
encodeURIComponent(
`<!doctype html><html lang="ru"><meta charset="utf-8"><meta http-equiv="Content-Security-Policy" content="default-src 'none'; style-src 'unsafe-inline'"><title>Проверка файлов</title><style>body{background:#18181c;color:#eee;font:16px system-ui;padding:24px}progress{width:100%;accent-color:#63e2b7}p{margin:12px 0}</style><p id="message" role="status">Проверка загруженных файлов…</p><progress id="progress"></progress><p id="count">Поиск и проверка пакетов…</p></html>`,
),
)
diagnostics = new DiagnosticLog(join(app.getPath('userData'), 'logs'))
diagnostics.record('app-start', {
version: app.getVersion(),
electron: process.versions.electron,
platform: process.platform,
arch: process.arch,
})
settings = new WindowSettings(join(app.getPath('userData'), 'windows.json'))
try {
await settings.load()
} catch (error) {
diagnostics.record('settings-error')
hostNotice = error instanceof Error ? error.message : 'Не удалось восстановить окна'
}
prepareFetchHeaders(session.defaultSession)
const packages = new PackageStore(join(app.getPath('userData'), 'packages'), {
fetch: fetchNetwork,
})
let lastProgressUpdate = 0
await packages.initialize((progress) => {
if (startupWindow.isDestroyed()) return
const now = Date.now()
if (
progress.total !== null &&
progress.completed !== progress.total &&
now - lastProgressUpdate < 100
)
return
lastProgressUpdate = now
const { completed, total } = progress
void startupWindow.webContents
.executeJavaScript(
`(() => {
const bar = document.getElementById('progress');
const count = document.getElementById('count');
${total === null ? "bar.removeAttribute('value');" : `bar.max = ${Math.max(total, 1)}; bar.value = ${completed}; count.textContent = 'Проверено файлов: ${completed} из ${total}';`}
})()`,
)
.catch(() => {})
})
if (quitting || startupWindow.isDestroyed()) return
online = new Online(
join(app.getPath('userData'), 'connection'),
packages,
controller,
{
available: () =>
safeStorage.isEncryptionAvailable() &&
(process.platform !== 'linux' ||
!['basic_text', 'unknown'].includes(safeStorage.getSelectedStorageBackend())),
encrypt: (value) => safeStorage.encryptString(value),
decrypt: (value) => safeStorage.decryptString(value),
},
fetchNetwork,
(failure) => diagnostics?.record('request-error', failure),
async () => {
const result = await dialog.showOpenDialog({
properties: ['openFile'],
filters: [{ name: 'MP3', extensions: ['mp3'] }],
})
const path = result.filePaths[0]
if (result.canceled || !path) return null
const info = await stat(path)
if (!info.isFile() || !/\.mp3$/i.test(path) || !info.size || info.size > 64 * 1048576)
throw new Error('Выберите MP3 размером до 64 МиБ')
return { filename: basename(path), data: await readFile(path) }
},
)
await online.initialize()
if (quitting || startupWindow.isDestroyed()) return
void online.restoreGames()
let previousConnection = online.state.connection
const logConnection = () => {
const status = online!.state.connection
if (status !== previousConnection) diagnostics?.record('connection', { status })
previousConnection = status
}
const onlineTick = setInterval(() => {
void online?.poll().then(logConnection)
void online?.refreshGameState()
}, 1000)
const timerTick = setInterval(() => controller.tick(), 250)
app.on('before-quit', () => {
quitting = true
saveWindowSettings()
closeOutput()
releaseSleepBlocker()
clearInterval(timerTick)
clearInterval(onlineTick)
online?.close()
packages.cancel()
diagnostics?.record('app-stop')
})
const handleAppRequest: Parameters<typeof protocol.handle>[1] = async (request) => {
const url = new URL(request.url)
const headers = {
'Content-Security-Policy':
"default-src 'self' screen-app:; script-src 'self'; style-src 'self' 'unsafe-inline'; media-src screen-app:; img-src 'self' screen-app: data:; connect-src 'self'; frame-ancestors 'none'",
'X-Content-Type-Options': 'nosniff',
}
if (url.host !== 'app' || request.method !== 'GET') return new Response(null, { status: 404 })
if (url.pathname === '/sound-check.wav')
return new Response(soundCheckWav, { headers: { ...headers, 'Content-Type': 'audio/wav' } })
if (url.pathname.startsWith('/packages/')) return packageMediaResponse(packages, request)
if (url.pathname.startsWith('/media/')) {
const media =
url.pathname === '/media/audio'
? ['audio.wav', 'audio/wav']
: url.pathname === '/media/video'
? ['video.mp4', 'video/mp4']
: null
if (!media) return new Response(null, { status: 404 })
const filename = join(
app.isPackaged ? process.resourcesPath : app.getAppPath(),
'fixtures',
media[0],
)
try {
const info = await stat(filename)
const rangeHeader = request.headers.get('range')
const range = parseRange(rangeHeader, info.size)
if (!range)
return new Response(null, {
status: 416,
headers: { 'Content-Range': `bytes */${info.size}` },
})
const stream = createReadStream(filename, range)
request.signal.addEventListener('abort', () => stream.destroy(), { once: true })
const responseHeaders: Record<string, string> = {
...headers,
'Content-Type': media[1],
'Accept-Ranges': 'bytes',
'Content-Length': String(range.end - range.start + 1),
}
if (rangeHeader)
responseHeaders['Content-Range'] = `bytes ${range.start}-${range.end}/${info.size}`
return new Response(Readable.toWeb(stream) as ReadableStream<Uint8Array>, {
status: rangeHeader ? 206 : 200,
headers: responseHeaders,
})
} catch {
return new Response('Сначала выполните npm run media:generate', { status: 404 })
}
}
const path = url.pathname
if (path !== '/index.html' && !/^\/assets\/[a-zA-Z0-9_.-]+$/.test(path))
return new Response(null, { status: 404 })
try {
const { readFile } = await import('node:fs/promises')
const content = await readFile(join(rendererRoot(), path.slice(1)))
const type = path.endsWith('.js')
? 'text/javascript'
: path.endsWith('.css')
? 'text/css'
: 'text/html; charset=utf-8'
return new Response(content, { headers: { ...headers, 'Content-Type': type } })
} catch {
return new Response(null, { status: 404 })
}
}
// Выбор выхода разрешён только renderer показа; камера и микрофон остаются запрещены.
for (const target of [session.defaultSession, session.fromPartition('persist:screen-output')]) {
const allowAudioOutput = (contents: Electron.WebContents | null, permission: string) =>
permission === 'speaker-selection' &&
!!contents &&
[audioDevicesWindow?.webContents, output?.webContents].includes(contents) &&
contents.getURL().startsWith('screen-app://app/index.html?')
target.setPermissionRequestHandler((contents, permission, callback, details) =>
callback(details.isMainFrame && allowAudioOutput(contents, permission)),
)
target.setPermissionCheckHandler((contents, permission, origin, details) =>
details.isMainFrame === true &&
(origin === 'screen-app://app' || origin === 'screen-app://app/') &&
allowAudioOutput(contents, permission),
)
target.protocol.handle('screen-app', handleAppRequest)
}
audioDevicesWindow = new BrowserWindow({ ...windowOptions(true), show: false })
secure(audioDevicesWindow)
await audioDevicesWindow.loadURL('screen-app://app/index.html?audio-devices=1')
await audioDevicesWindow.webContents.executeJavaScript(
"document.body.appendChild(document.createElement('audio'))",
)
audioOutput = new AudioOutput(
settings.value.audioOutput ?? '',
() => audioDevicesWindow!.webContents.executeJavaScript(`navigator.mediaDevices.enumerateDevices().then(
devices => devices.filter(d => d.kind === 'audiooutput' && d.deviceId && d.deviceId !== 'default' && d.deviceId !== 'communications')
.map((d, index) => ({ id: d.deviceId, label: d.label || 'Устройство звука ' + (index + 1) }))
)`),
async (id) => {
await setAudioSink(audioDevicesWindow!, id)
const current = output
if (current && !current.isDestroyed() && !current.webContents.isLoadingMainFrame())
await setAudioSink(current, id)
},
(id) => {
const previous = settings.value.audioOutput
settings.value.audioOutput = id
try {
settings.save()
} catch (error) {
settings.value.audioOutput = previous
throw error
}
},
)
const refreshAudioOutputs = () => audioOutput.refresh().catch(() => {
audioOutput.notice = 'Не удалось обновить список устройств звука.'
})
ipcMain.on('audio-devices-changed', (event) => {
if (event.sender === audioDevicesWindow?.webContents && event.senderFrame === event.sender.mainFrame)
void refreshAudioOutputs()
})
await refreshAudioOutputs()
control = new BrowserWindow({
...windowOptions(),
...fitControlBounds(settings.value.control?.bounds, screen.getPrimaryDisplay().workArea),
show: false,
})
control.once('ready-to-show', () => {
keepControlOnPrimary()
if (settings.value.control?.maximized) control!.maximize()
control!.show()
startup = null
if (!startupWindow.isDestroyed()) startupWindow.destroy()
})
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)
let checkingSound = false
ipcMain.handle('check-sound', async (event) => {
requireControl(event)
if (checkingSound) throw new Error('Проверка звука уже выполняется')
checkingSound = true
try {
await audioOutput.prepare(async (id) => {
const window = audioDevicesWindow
if (!window || window.isDestroyed()) throw new Error('Проверка звука недоступна')
await setAudioSink(window, id)
await window.webContents.executeJavaScript(`(${playSoundCheck.toString()})()`)
})
} finally {
checkingSound = false
}
})
ipcMain.handle('select-audio-output', (event, id: unknown) => {
requireControl(event)
if (checkingSound) throw new Error('Подождите завершения проверки звука')
return audioOutput.select(id)
})
ipcMain.handle('prepare-audio-output', async (event) => {
const current = output
if (!current || event.sender !== current.webContents || event.senderFrame !== current.webContents.mainFrame)
throw new Error('Недоступно')
try {
await audioOutput.prepare((id) => setAudioSink(current, id))
} catch (error) {
if (output === current) closeOutput('Не удалось выбрать устройство звука')
throw error
}
})
ipcMain.handle('state', (event) => {
if (
![control?.webContents, output?.webContents].includes(event.sender) ||
event.senderFrame !== event.sender.mainFrame
)
throw new Error('Недоступно')
return controller.state
})
ipcMain.handle('plan-status', (event) => {
requireControl(event)
return online!.planStatus()
})
ipcMain.handle('online-state', (event): OnlineState => {
requireControl(event)
return online!.snapshot()
})
ipcMain.handle('online-action', async (event, intent: unknown) => {
requireControl(event)
if (selectingPackage) throw new Error('Подождите проверки пакета')
const action =
intent && typeof intent === 'object' && 'action' in intent ? intent.action : null
try {
const result = await online!.act(intent)
if (['download', 'check', 'remove', 'clear-games'].includes(String(action)))
diagnostics?.record('package', { action, status: 'success' })
return result
} catch (error) {
if (['download', 'check', 'remove', 'clear-games'].includes(String(action)))
diagnostics?.record('package', { action, status: 'failed' })
throw error
} finally {
logConnection()
}
})
ipcMain.handle('close-output', (event) => {
requireControl(event)
closeOutput()
})
ipcMain.handle('host', (event) => {
requireControl(event)
return host()
})
ipcMain.handle('show', (event, id: unknown) => {
requireControl(event)
if (selectingPackage) throw new Error('Подождите проверки пакета')
if (typeof id !== 'number') throw new Error('Некорректный дисплей')
return show(id)
})
let selectingPackage = false
ipcMain.handle('packages', (event) => {
requireControl(event)
return packages
.list()
.map((pkg) => ({
key: pkg.key,
version: pkg.version,
name: pkg.package.game.name,
downloadedFiles: pkg.package.assets.filter((asset) => packages.canRead(pkg.key, asset.id)).length,
totalFiles: pkg.package.assets.length,
}))
})
ipcMain.handle('select-package', async (event, key: unknown) => {
requireControl(event)
if (selectingPackage) throw new Error('Подождите проверки пакета')
if (key !== null && typeof key !== 'string') throw new Error('Некорректный пакет')
selectingPackage = true
try {
await online!.select(key)
return controller.state
} finally {
selectingPackage = false
}
})
ipcMain.handle('open-materials-folder', async (event, key: unknown) => {
requireControl(event)
if (typeof key !== 'string' || key !== controller.state.catalog.key)
throw new Error('Выберите текущую игру')
for (const path of await packages.folders(key)) {
const error = await shell.openPath(path)
if (error) throw new Error('Не удалось открыть папку материалов')
}
})
let localSession = ''
let localSequence = 0
const pendingCommands = new Set<string>()
ipcMain.handle('command', async (event, command: unknown) => {
requireControl(event)
if (selectingPackage) throw new Error('Подождите проверки пакета')
const parsed = parseCommand(command)
if (
!output &&
['play', 'pause', 'resume', 'stop', 'replay', 'seek', 'next', 'group'].includes(parsed.action)
)
throw new Error('Сначала откройте окно показа')
if (parsed.session !== controller.state.session) return
if (localSession !== parsed.session) {
localSession = parsed.session
localSequence = 0
pendingCommands.clear()
}
const pendingKey = `${parsed.session}:${parsed.sequence}`
if (parsed.sequence <= localSequence || pendingCommands.has(pendingKey)) return
if (parsed.action !== 'mute') {
pendingCommands.add(pendingKey)
try {
const before = controller.state.sequence
const operation = online!.planCommand(parsed)
if (controller.state.sequence !== before)
localSequence = Math.max(localSequence, parsed.sequence)
await operation
if (parsed.session === controller.state.session)
localSequence = Math.max(localSequence, parsed.sequence)
} finally {
pendingCommands.delete(pendingKey)
}
} else {
controller.command({ ...parsed, sequence: controller.state.sequence + 1 })
localSequence = parsed.sequence
}
})
let capturing = false
let lastCapture = -Infinity
ipcMain.handle('preview', async (event) => {
requireControl(event)
const window = output
const state = controller.state
if (
!window ||
window.isDestroyed() ||
window.webContents.isLoading() ||
!control?.isVisible() ||
control.isMinimized() ||
state.scene.status !== 'ready' ||
capturing ||
performance.now() - lastCapture < 500
)
return null
const session = state.session
const revision = state.scene.revision
capturing = true
lastCapture = performance.now()
try {
const image = await window.webContents.capturePage()
if (
window !== output ||
window.isDestroyed() ||
session !== controller.state.session ||
revision !== controller.state.scene.revision ||
controller.state.scene.status !== 'ready' ||
image.isEmpty()
)
return null
return {
session,
revision,
dataURL: image.resize({ width: Math.min(960, image.getSize().width) }).toDataURL(),
}
} catch {
return null
} finally {
capturing = false
}
})
ipcMain.on('scene-report', (event, report: unknown) => {
if (
event.sender !== output?.webContents ||
event.senderFrame !== output.webContents.mainFrame
)
return
try {
controller.reportScene(report)
} catch {
diagnostics?.record('invalid-report')
}
})
ipcMain.on('report', (event, report: unknown) => {
if (
event.sender !== output?.webContents ||
event.senderFrame !== output.webContents.mainFrame
)
return
try {
controller.report(report)
} catch {
diagnostics?.record('invalid-report')
}
})
screen.on('display-removed', (_event, display) => {
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()
})
await control.loadURL('screen-app://app/index.html')
})
.catch(() => {
diagnostics?.record('startup-error')
console.error('SCREEN_STARTUP_FAILED')
if (startup && !startup.isDestroyed()) {
void startup.webContents
.executeJavaScript(
`document.getElementById('message').textContent = 'Не удалось проверить файлы. Проверьте доступ к папке данных приложения и перезапустите его.'; document.getElementById('progress').hidden = true; document.getElementById('count').textContent = '';`,
)
.catch(() => app.quit())
} else app.quit()
})
app.on('window-all-closed', () => app.quit())