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

207 lines
7.4 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 } from './controller.js'
import { parseRange } from './range.js'
import type { HostInfo } from '../src/types/screen.js'
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)
const window = output
output.on('closed', () => {
if (output !== window) return
output = null
selectedDisplay = null
controller.report({
id: controller.state.command?.id ?? '',
status: 'error',
error: 'Окно показа закрыто',
})
})
output.once('ready-to-show', () => {
if (!window.isDestroyed()) {
window.show()
window.setFullScreen(true)
}
})
void output.loadURL('screen-app://app/index.html?output=1')
}
app
.whenReady()
.then(async () => {
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:; 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('/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)
})
ipcMain.handle('command', (event, command: unknown) => {
requireControl(event)
if (!output) throw new Error('Сначала откройте окно показа')
controller.command(command)
})
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())