223 lines
8.2 KiB
TypeScript
223 lines
8.2 KiB
TypeScript
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 { 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)
|
||
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.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<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())
|