import { app, BrowserWindow, ipcMain, protocol, screen, session } from 'electron' import { createReadStream } from 'node:fs' import { stat } from 'node:fs/promises' import { networkInterfaces } from 'node:os' import { join } from 'node:path' import { Readable } from 'node:stream' import { Controller } from './controller.js' import { LanServer } from './lan.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 lan: LanServer | 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) lan?.broadcast(state) }) const rendererRoot = () => join(app.getAppPath(), 'dist') function addresses() { return Object.values(networkInterfaces()) .flatMap((items) => items ?? []) .filter((item) => !item.internal && item.family === 'IPv4') .map((item) => ({ address: item.address, family: item.family })) } 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}`, })), lan: addresses(), url: lan?.url ?? '', code: lan?.code ?? '', pending: [...(lan?.pending.entries() ?? [])] .filter(([, p]) => !p.approved && p.expires > Date.now()) .map(([id]) => id), } } 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 = { ...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) }) 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) } }) ipcMain.handle('lan', async (event, address: unknown) => { requireControl(event) if ( address !== null && (typeof address !== 'string' || !addresses().some((a) => a.address === address)) ) throw new Error('Некорректный сетевой интерфейс') await lan?.close() lan = null if (address !== null) { const server = new LanServer( rendererRoot(), () => controller.state, (command) => { if (!output) throw new Error('Окно показа закрыто') controller.command(command, 'phone') }, ) try { await server.start(address) lan = server } catch (error) { await server.close() throw error } } }) ipcMain.handle('approve', (event, id: unknown) => { requireControl(event) if (typeof id !== 'string' || !lan) throw new Error('Пульт отключён') lan.approve(id) }) 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()) app.on('before-quit', () => { void lan?.close() })