up
This commit is contained in:
@@ -0,0 +1,55 @@
|
||||
import assert from 'node:assert/strict'
|
||||
import { test } from 'node:test'
|
||||
import { Controller, parseCommand } from './controller.js'
|
||||
import { parseRange } from './range.js'
|
||||
|
||||
const command = { id: 'one', action: 'play', media: 'audio', position: 0 }
|
||||
test('валидирует команды на границе IPC / LAN', () => {
|
||||
assert.deepEqual(parseCommand(command), command)
|
||||
for (const value of [
|
||||
null,
|
||||
{},
|
||||
{ ...command, id: '../file' },
|
||||
{ ...command, action: ['play'] },
|
||||
{ ...command, media: ['audio'] },
|
||||
{ ...command, position: NaN },
|
||||
{ ...command, position: -1 },
|
||||
{ ...command, action: 'delete' },
|
||||
{ ...command, media: '/etc/passwd' },
|
||||
]) {
|
||||
assert.throws(() => parseCommand(value))
|
||||
}
|
||||
})
|
||||
test('повтор не применяется; устаревшее подтверждение не меняет новую команду', () => {
|
||||
let broadcasts = 0
|
||||
const c = new Controller(() => broadcasts++)
|
||||
c.command(command)
|
||||
c.command(command)
|
||||
assert.equal(broadcasts, 1)
|
||||
c.command({ ...command, id: 'two', action: 'pause' })
|
||||
c.report({ id: 'one', status: 'playing' })
|
||||
assert.equal(c.state.status, 'pending')
|
||||
c.report({ id: 'two', status: 'paused' })
|
||||
assert.equal(c.state.status, 'paused')
|
||||
assert.equal(c.state.revision, 3)
|
||||
})
|
||||
test('playing подтверждается один раз, ошибки сохраняются', () => {
|
||||
const c = new Controller(() => {})
|
||||
c.command(command)
|
||||
c.report({ id: 'one', status: 'playing' })
|
||||
c.report({ id: 'one', status: 'playing' })
|
||||
assert.equal(c.state.samples.length, 1)
|
||||
c.report({ id: 'one', status: 'error', error: 'Нет файла' })
|
||||
assert.equal(c.state.error, 'Нет файла')
|
||||
assert.throws(() => c.report({ id: 'one', status: 'anything' }))
|
||||
})
|
||||
test('Range: полный файл, границы, суффикс и отказ для недопустимых диапазонов', () => {
|
||||
assert.equal(parseRange(null, 0), null)
|
||||
assert.deepEqual(parseRange(null, 100), { start: 0, end: 99 })
|
||||
assert.deepEqual(parseRange('bytes=20-29', 100), { start: 20, end: 29 })
|
||||
assert.deepEqual(parseRange('bytes=90-', 100), { start: 90, end: 99 })
|
||||
assert.deepEqual(parseRange('bytes=-10', 100), { start: 90, end: 99 })
|
||||
assert.deepEqual(parseRange('bytes=90-999', 100), { start: 90, end: 99 })
|
||||
for (const value of ['bytes=100-', 'bytes=20-10', 'bytes=-0', 'bytes=0-1,3-5', 'bytes=-', 'abc'])
|
||||
assert.equal(parseRange(value, 100), null)
|
||||
})
|
||||
@@ -0,0 +1,87 @@
|
||||
import { performance } from 'node:perf_hooks'
|
||||
import type { Command, State } from '../src/types/screen.js'
|
||||
|
||||
export function parseCommand(value: unknown): Command {
|
||||
if (!value || typeof value !== 'object') throw new Error('Некорректная команда')
|
||||
const c = value as Record<string, unknown>
|
||||
if (
|
||||
typeof c.id !== 'string' ||
|
||||
!/^[a-zA-Z0-9-]{1,80}$/.test(c.id) ||
|
||||
typeof c.action !== 'string' ||
|
||||
!['play', 'pause', 'stop', 'seek'].includes(c.action) ||
|
||||
typeof c.media !== 'string' ||
|
||||
!['audio', 'video'].includes(c.media) ||
|
||||
typeof c.position !== 'number' ||
|
||||
!Number.isFinite(c.position) ||
|
||||
c.position < 0 ||
|
||||
c.position > 60
|
||||
) {
|
||||
throw new Error('Некорректная команда')
|
||||
}
|
||||
return {
|
||||
id: c.id,
|
||||
action: c.action as Command['action'],
|
||||
media: c.media as Command['media'],
|
||||
position: c.position,
|
||||
}
|
||||
}
|
||||
|
||||
export class Controller {
|
||||
state: State = { revision: 0, command: null, status: 'idle', error: '', samples: [] }
|
||||
private seen = new Set<string>()
|
||||
private startedAt = 0
|
||||
private origin: 'computer' | 'phone' = 'computer'
|
||||
constructor(private publish: (state: State) => void) {}
|
||||
command(input: unknown, origin: 'computer' | 'phone' = 'computer') {
|
||||
const command = parseCommand(input)
|
||||
if (this.seen.has(command.id)) return
|
||||
this.seen.add(command.id)
|
||||
// shortcut: помним 4096 команд стенда; долговременная дедупликация — SCREEN-004.
|
||||
if (this.seen.size > 4096) this.seen.delete(this.seen.values().next().value!)
|
||||
this.startedAt = performance.now()
|
||||
this.origin = origin
|
||||
this.state = {
|
||||
...this.state,
|
||||
revision: this.state.revision + 1,
|
||||
command,
|
||||
status: 'pending',
|
||||
error: '',
|
||||
}
|
||||
this.publish(this.state)
|
||||
}
|
||||
report(input: unknown) {
|
||||
if (!input || typeof input !== 'object') throw new Error('Некорректный отчёт')
|
||||
const r = input as Record<string, unknown>
|
||||
if (
|
||||
typeof r.status !== 'string' ||
|
||||
!['playing', 'paused', 'stopped', 'ended', 'error'].includes(r.status) ||
|
||||
typeof r.id !== 'string' ||
|
||||
(r.error !== undefined && typeof r.error !== 'string')
|
||||
)
|
||||
throw new Error('Некорректный отчёт')
|
||||
if (
|
||||
r.id !== this.state.command?.id ||
|
||||
(this.state.status !== 'pending' && r.status !== 'ended' && r.status !== 'error')
|
||||
)
|
||||
return
|
||||
const samples =
|
||||
r.status === 'playing' && this.state.command.action === 'play'
|
||||
? [
|
||||
...this.state.samples.slice(-399),
|
||||
{
|
||||
media: this.state.command.media,
|
||||
origin: this.origin,
|
||||
milliseconds: performance.now() - this.startedAt,
|
||||
},
|
||||
]
|
||||
: this.state.samples
|
||||
this.state = {
|
||||
...this.state,
|
||||
revision: this.state.revision + 1,
|
||||
status: r.status as State['status'],
|
||||
error: String(r.error ?? '').slice(0, 500),
|
||||
samples,
|
||||
}
|
||||
this.publish(this.state)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,106 @@
|
||||
import assert from 'node:assert/strict'
|
||||
import { once } from 'node:events'
|
||||
import { mkdtemp, writeFile, rm } from 'node:fs/promises'
|
||||
import { tmpdir } from 'node:os'
|
||||
import { join } from 'node:path'
|
||||
import { test } from 'node:test'
|
||||
import { WebSocket } from 'ws'
|
||||
import { Controller } from './controller.js'
|
||||
import { LanServer } from './lan.js'
|
||||
|
||||
test('LAN: код, подтверждение, cookie, WebSocket и восстановление без повтора', async () => {
|
||||
const root = await mkdtemp(join(tmpdir(), 'screen-test-'))
|
||||
await writeFile(join(root, 'index.html'), '<html>local</html>')
|
||||
let count = 0
|
||||
const controller = new Controller((state) => {
|
||||
count++
|
||||
server.broadcast(state)
|
||||
})
|
||||
const server = new LanServer(
|
||||
root,
|
||||
() => controller.state,
|
||||
(input) => controller.command(input),
|
||||
)
|
||||
const sockets: WebSocket[] = []
|
||||
try {
|
||||
await server.start('127.0.0.1')
|
||||
const origin = server.url
|
||||
assert.equal((await fetch(origin)).status, 200)
|
||||
assert.equal((await fetch(origin + '/package.json')).status, 404)
|
||||
assert.equal(
|
||||
(
|
||||
await fetch(origin + '/pair', {
|
||||
method: 'POST',
|
||||
headers: { Origin: origin },
|
||||
body: JSON.stringify({ code: 'wrong' }),
|
||||
})
|
||||
).status,
|
||||
403,
|
||||
)
|
||||
assert.equal(
|
||||
(
|
||||
await fetch(origin + '/pair', {
|
||||
method: 'POST',
|
||||
headers: { Origin: 'http://evil.example' },
|
||||
body: '{}',
|
||||
})
|
||||
).status,
|
||||
403,
|
||||
)
|
||||
const denied = new WebSocket(origin.replace('http', 'ws') + '/ws', { origin })
|
||||
sockets.push(denied)
|
||||
await new Promise<void>((resolve) => denied.on('error', () => resolve()))
|
||||
const paired = await fetch(origin + '/pair', {
|
||||
method: 'POST',
|
||||
headers: { Origin: origin },
|
||||
body: JSON.stringify({ code: server.code }),
|
||||
})
|
||||
const { id } = (await paired.json()) as { id: string }
|
||||
assert.equal((await fetch(origin + '/pair/' + id)).status, 202)
|
||||
server.approve(id)
|
||||
const approved = await fetch(origin + '/pair/' + id)
|
||||
assert.equal(approved.status, 200)
|
||||
const cookie = approved.headers.get('set-cookie')!.split(';')[0]
|
||||
assert.equal((await fetch(origin + '/pair/' + id)).status, 404)
|
||||
async function connect() {
|
||||
const ws = new WebSocket(origin.replace('http', 'ws') + '/ws', {
|
||||
origin,
|
||||
headers: { Cookie: cookie },
|
||||
})
|
||||
sockets.push(ws)
|
||||
const [data] = await once(ws, 'message')
|
||||
return { ws, state: JSON.parse(data.toString()) }
|
||||
}
|
||||
const first = await connect()
|
||||
assert.equal(first.state.revision, 0)
|
||||
let received = once(first.ws, 'message')
|
||||
const command = { id: 'phone-1', action: 'play', media: 'audio', position: 0 }
|
||||
first.ws.send(JSON.stringify(command))
|
||||
await received
|
||||
first.ws.send(JSON.stringify(command))
|
||||
// A fresh command is a barrier proving that the duplicate was processed first.
|
||||
received = once(first.ws, 'message')
|
||||
first.ws.send(JSON.stringify({ ...command, id: 'phone-2', action: 'pause' }))
|
||||
await received
|
||||
assert.equal(count, 2)
|
||||
first.ws.close()
|
||||
await once(first.ws, 'close')
|
||||
const restored = await connect()
|
||||
assert.equal(restored.state.revision, 2)
|
||||
assert.equal(count, 2)
|
||||
const closed = once(restored.ws, 'close')
|
||||
restored.ws.send(JSON.stringify({ action: 'delete' }))
|
||||
await closed
|
||||
assert.equal(count, 2)
|
||||
const oversized = await connect()
|
||||
const oversizedClosed = once(oversized.ws, 'close')
|
||||
oversized.ws.send('x'.repeat(3000))
|
||||
await oversizedClosed
|
||||
assert.equal(count, 2)
|
||||
assert.equal((await fetch(origin)).status, 200)
|
||||
} finally {
|
||||
for (const ws of sockets) ws.terminate()
|
||||
await server.close()
|
||||
await rm(root, { recursive: true, force: true })
|
||||
}
|
||||
})
|
||||
+155
@@ -0,0 +1,155 @@
|
||||
import { randomBytes, randomInt, timingSafeEqual } from 'node:crypto'
|
||||
import { createReadStream } from 'node:fs'
|
||||
import { stat } from 'node:fs/promises'
|
||||
import { createServer, type IncomingMessage, type ServerResponse } from 'node:http'
|
||||
import { extname, resolve, sep } from 'node:path'
|
||||
import { WebSocket, WebSocketServer } from 'ws'
|
||||
import type { State } from '../src/types/screen.js'
|
||||
|
||||
const mime: Record<string, string> = {
|
||||
'.html': 'text/html; charset=utf-8',
|
||||
'.js': 'text/javascript',
|
||||
'.css': 'text/css',
|
||||
}
|
||||
export class LanServer {
|
||||
url = ''
|
||||
code = String(randomInt(100000, 1000000))
|
||||
pending = new Map<string, { approved: boolean; expires: number }>()
|
||||
private tokens = new Set<string>()
|
||||
private attempts = 0
|
||||
private attemptWindow = 0
|
||||
private sockets = new WebSocketServer({ noServer: true, maxPayload: 2048 })
|
||||
private server = createServer((req, res) => {
|
||||
void this.handle(req, res).catch(() => {
|
||||
if (!res.headersSent) res.writeHead(400)
|
||||
res.end()
|
||||
})
|
||||
})
|
||||
constructor(
|
||||
private root: string,
|
||||
private getState: () => State,
|
||||
private command: (input: unknown) => void,
|
||||
) {
|
||||
this.server.on('upgrade', (req, socket, head) => {
|
||||
if (req.url !== '/ws' || req.headers.origin !== this.url || !this.authorized(req)) {
|
||||
socket.destroy()
|
||||
return
|
||||
}
|
||||
this.sockets.handleUpgrade(req, socket, head, (ws) => {
|
||||
ws.on('error', (error) => {
|
||||
console.error('Ошибка WebSocket пульта:', error.message)
|
||||
ws.terminate()
|
||||
})
|
||||
ws.send(JSON.stringify(this.getState()))
|
||||
ws.on('message', (data) => {
|
||||
try {
|
||||
this.command(JSON.parse(data.toString()))
|
||||
} catch {
|
||||
ws.close(1008, 'Некорректная команда')
|
||||
}
|
||||
})
|
||||
})
|
||||
})
|
||||
}
|
||||
private authorized(req: IncomingMessage) {
|
||||
const token = /(?:^|;\s*)screen=([a-f0-9]{64})(?:;|$)/.exec(req.headers.cookie ?? '')?.[1]
|
||||
return !!token && this.tokens.has(token)
|
||||
}
|
||||
async start(address: string) {
|
||||
await new Promise<void>((accept, reject) => {
|
||||
this.server.once('error', reject)
|
||||
this.server.listen(0, address, () => {
|
||||
this.server.off('error', reject)
|
||||
accept()
|
||||
})
|
||||
})
|
||||
const info = this.server.address()
|
||||
if (!info || typeof info === 'string') throw new Error('Не удалось открыть пульт')
|
||||
this.url = `http://${address}:${info.port}`
|
||||
}
|
||||
approve(id: string) {
|
||||
const pending = this.pending.get(id)
|
||||
if (!pending || pending.expires < Date.now()) throw new Error('Запрос подключения истёк')
|
||||
pending.approved = true
|
||||
}
|
||||
broadcast(state: State) {
|
||||
for (const ws of this.sockets.clients)
|
||||
if (ws.readyState === WebSocket.OPEN) ws.send(JSON.stringify(state))
|
||||
}
|
||||
async close() {
|
||||
for (const ws of this.sockets.clients) ws.terminate()
|
||||
this.sockets.close()
|
||||
this.tokens.clear()
|
||||
this.pending.clear()
|
||||
await new Promise<void>((done) => {
|
||||
this.server.close(() => done())
|
||||
this.server.closeAllConnections()
|
||||
})
|
||||
}
|
||||
private json(res: ServerResponse, status: number, value: unknown) {
|
||||
res.writeHead(status, { 'Content-Type': 'application/json', 'Cache-Control': 'no-store' })
|
||||
res.end(JSON.stringify(value))
|
||||
}
|
||||
private async handle(req: IncomingMessage, res: ServerResponse) {
|
||||
res.setHeader('X-Content-Type-Options', 'nosniff')
|
||||
res.setHeader('Referrer-Policy', 'no-referrer')
|
||||
res.setHeader(
|
||||
'Content-Security-Policy',
|
||||
"default-src 'self'; script-src 'self'; style-src 'self' 'unsafe-inline'; connect-src 'self'; frame-ancestors 'none'",
|
||||
)
|
||||
if (req.headers.host !== new URL(this.url).host) return this.json(res, 403, {})
|
||||
const url = new URL(req.url ?? '/', this.url)
|
||||
for (const [id, pending] of this.pending)
|
||||
if (pending.expires < Date.now()) this.pending.delete(id)
|
||||
if (req.method === 'POST' && url.pathname === '/pair') {
|
||||
if (req.headers.origin !== this.url) return this.json(res, 403, {})
|
||||
if (Date.now() - this.attemptWindow > 60000) {
|
||||
this.attemptWindow = Date.now()
|
||||
this.attempts = 0
|
||||
}
|
||||
if (++this.attempts > 10 || this.pending.size >= 10) return this.json(res, 429, {})
|
||||
let body = ''
|
||||
for await (const chunk of req) {
|
||||
body += chunk.toString()
|
||||
if (body.length > 256) return this.json(res, 413, {})
|
||||
}
|
||||
const code: unknown = JSON.parse(body).code
|
||||
if (
|
||||
typeof code !== 'string' ||
|
||||
code.length !== this.code.length ||
|
||||
!timingSafeEqual(Buffer.from(code), Buffer.from(this.code))
|
||||
)
|
||||
return this.json(res, 403, {})
|
||||
const id = randomBytes(24).toString('hex')
|
||||
this.pending.set(id, { approved: false, expires: Date.now() + 120000 })
|
||||
return this.json(res, 200, { id })
|
||||
}
|
||||
if (req.method === 'GET' && url.pathname.startsWith('/pair/')) {
|
||||
const id = url.pathname.slice(6)
|
||||
const pending = this.pending.get(id)
|
||||
if (!pending) return this.json(res, 404, {})
|
||||
if (!pending.approved) return this.json(res, 202, {})
|
||||
const token = randomBytes(32).toString('hex')
|
||||
this.tokens.add(token)
|
||||
this.pending.delete(id)
|
||||
res.setHeader('Set-Cookie', `screen=${token}; HttpOnly; SameSite=Strict; Path=/`)
|
||||
return this.json(res, 200, {})
|
||||
}
|
||||
if (req.method !== 'GET' || (url.pathname !== '/' && !url.pathname.startsWith('/assets/')))
|
||||
return this.json(res, 404, {})
|
||||
const filename = resolve(
|
||||
this.root,
|
||||
'.' + decodeURIComponent(url.pathname === '/' ? '/index.html' : url.pathname),
|
||||
)
|
||||
if (!filename.startsWith(resolve(this.root) + sep)) return this.json(res, 403, {})
|
||||
const info = await stat(filename)
|
||||
if (!info.isFile()) return this.json(res, 404, {})
|
||||
res.writeHead(200, {
|
||||
'Content-Type': mime[extname(filename)] ?? 'application/octet-stream',
|
||||
'Content-Length': info.size,
|
||||
})
|
||||
createReadStream(filename)
|
||||
.on('error', () => res.destroy())
|
||||
.pipe(res)
|
||||
}
|
||||
}
|
||||
@@ -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()
|
||||
})
|
||||
@@ -0,0 +1,17 @@
|
||||
import { contextBridge, ipcRenderer } from 'electron'
|
||||
import type { Command, State } from '../src/types/screen.js'
|
||||
|
||||
contextBridge.exposeInMainWorld('screenBridge', {
|
||||
command: (command: Command) => ipcRenderer.invoke('command', command),
|
||||
report: (report: unknown) => ipcRenderer.send('report', report),
|
||||
state: () => ipcRenderer.invoke('state'),
|
||||
subscribe: (listener: (state: State) => void) => {
|
||||
const handler = (_event: Electron.IpcRendererEvent, state: State) => listener(state)
|
||||
ipcRenderer.on('state', handler)
|
||||
return () => ipcRenderer.removeListener('state', handler)
|
||||
},
|
||||
host: () => ipcRenderer.invoke('host'),
|
||||
show: (id: number) => ipcRenderer.invoke('show', id),
|
||||
lan: (address: string | null) => ipcRenderer.invoke('lan', address),
|
||||
approve: (id: string) => ipcRenderer.invoke('approve', id),
|
||||
})
|
||||
@@ -0,0 +1,20 @@
|
||||
export function parseRange(
|
||||
header: string | null,
|
||||
size: number,
|
||||
): { start: number; end: number } | null {
|
||||
if (!Number.isSafeInteger(size) || size <= 0) return null
|
||||
if (header === null) return { start: 0, end: size - 1 }
|
||||
const match = /^bytes=(\d*)-(\d*)$/.exec(header)
|
||||
if (!match || (!match[1] && !match[2])) return null
|
||||
const start = match[1] ? Number(match[1]) : Math.max(0, size - Number(match[2]))
|
||||
const end = match[1] && match[2] ? Math.min(Number(match[2]), size - 1) : size - 1
|
||||
if (
|
||||
!Number.isSafeInteger(start) ||
|
||||
!Number.isSafeInteger(end) ||
|
||||
start > end ||
|
||||
start >= size ||
|
||||
end < 0
|
||||
)
|
||||
return null
|
||||
return { start, end }
|
||||
}
|
||||
Reference in New Issue
Block a user