Files
evening_detective_screen/electron/main.ts
T
2026-10-18 05:25:23 +07:00

638 lines
24 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,
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 { 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 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 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 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 {
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: 'screen-output' } : {}),
preload: join(__dirname, 'preload.js'),
nodeIntegration: false,
contextIsolation: true,
sandbox: true,
},
}
}
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', () => {
control?.show()
control?.focus()
})
app
.whenReady()
.then(async () => {
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 : 'Не удалось восстановить окна'
}
const packages = new PackageStore(join(app.getPath('userData'), 'packages'))
await packages.initialize()
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),
},
undefined,
(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()
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)
}, 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.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('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.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 (
![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'].includes(String(action)))
diagnostics?.record('package', { action, status: 'success' })
return result
} catch (error) {
if (['download', 'check', 'remove'].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 }))
})
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
}
})
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')
app.quit()
})
app.on('window-all-closed', () => app.quit())