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() app.on('before-quit', () => 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:; 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 = { ...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, { 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) }) 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())