593 lines
22 KiB
TypeScript
593 lines
22 KiB
TypeScript
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 { 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'
|
||
|
||
// Native Wayland запрещает размещение окна на выбранном дисплее по координатам.
|
||
if (process.platform === 'linux') app.commandLine.appendSwitch('ozone-platform', 'x11')
|
||
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),
|
||
})
|
||
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('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
|
||
ipcMain.handle('command', (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
|
||
}
|
||
if (parsed.sequence <= localSequence) return
|
||
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())
|