Files
evening_detective_screen/electron/main.ts
T
2026-10-17 19:52:24 +07:00

307 lines
11 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, BrowserWindow, ipcMain, protocol, screen, session } from 'electron'
import { createReadStream } from 'node:fs'
import { stat } from 'node:fs/promises'
import { join } from 'node:path'
import { Readable } from 'node:stream'
import { Controller, parseCommand } from './controller.js'
import { parseRange } from './range.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 selectedDisplay: number | null = null
const controller = new Controller((state) => {
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 {
displays: screen
.getAllDisplays()
.filter((d) => d.id !== primary)
.map((d) => ({
id: d.id,
label: `${d.label || 'Дисплей'} — ${d.size.width}×${d.size.height}`,
})),
}
}
function windowOptions() {
return {
backgroundColor: '#111111',
webPreferences: {
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 show(displayId: number) {
const display = screen.getAllDisplays().find((d) => d.id === displayId)
if (!display || display.id === screen.getPrimaryDisplay().id)
throw new Error('Выберите внешний дисплей; включите расширенный рабочий стол')
if (output) output.destroy()
selectedDisplay = display.id
output = new BrowserWindow({ ...windowOptions(), ...display.bounds, show: false })
secure(output)
controller.output(true)
const window = output
output.on('closed', () => {
if (output !== window) return
output = null
selectedDisplay = null
controller.output(false)
})
output.once('ready-to-show', () => {
if (!window.isDestroyed()) {
window.show()
window.setBounds(display.bounds)
window.setFullScreen(true)
}
})
void output.loadURL('screen-app://app/index.html?output=1')
}
// Две копии приложения не должны одновременно менять указатели пакетов.
if (!app.requestSingleInstanceLock()) app.exit(0)
app.on('second-instance', () => {
control?.show()
control?.focus()
})
app
.whenReady()
.then(async () => {
const packages = new PackageStore(join(app.getPath('userData'), 'packages'))
await packages.initialize()
const timerTick = setInterval(() => controller.tick(), 250)
app.on('before-quit', () => {
clearInterval(timerTick)
packages.cancel()
})
session.defaultSession.setPermissionRequestHandler((_contents, _permission, callback) =>
callback(false),
)
session.defaultSession.setPermissionCheckHandler(() => false)
protocol.handle('screen-app', 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 })
}
})
control = new BrowserWindow({ ...windowOptions(), width: 1000, height: 800 })
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('host', (event) => {
requireControl(event)
return host()
})
ipcMain.handle('show', (event, id: unknown) => {
requireControl(event)
if (typeof id !== 'number') throw new Error('Некорректный дисплей')
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 {
controller.selectPackage(key === null ? null : await packages.verify(key))
return controller.state
} catch (error) {
if (key === controller.state.catalog.key) controller.selectPackage(null)
throw error
} finally {
selectingPackage = false
}
})
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('Сначала откройте окно показа')
controller.command(parsed)
})
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 (error) {
console.error('Ошибка подтверждения сцены', error)
}
})
ipcMain.on('report', (event, report: unknown) => {
if (
event.sender !== output?.webContents ||
event.senderFrame !== output.webContents.mainFrame
)
return
try {
controller.report(report)
} catch (error) {
console.error('Ошибка отчёта проигрывателя', error)
}
})
screen.on('display-removed', (_event, display) => {
if (display.id === selectedDisplay) output?.close()
})
control.on('closed', () => {
app.quit()
})
await control.loadURL('screen-app://app/index.html')
})
.catch((error) => {
console.error(error)
app.quit()
})
app.on('window-all-closed', () => app.quit())