817 lines
32 KiB
TypeScript
817 lines
32 KiB
TypeScript
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,
|
||
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
|
||
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'].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())
|