up
This commit is contained in:
@@ -0,0 +1,257 @@
|
||||
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<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)
|
||||
}
|
||||
})
|
||||
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()
|
||||
})
|
||||
Reference in New Issue
Block a user