This commit is contained in:
2026-10-17 18:25:04 +07:00
parent 701bca234e
commit d3683ac6cf
15 changed files with 124 additions and 691 deletions
+1 -1
View File
@@ -4,7 +4,7 @@ import { Controller, parseCommand } from './controller.js'
import { parseRange } from './range.js'
const command = { id: 'one', action: 'play', media: 'audio', position: 0 }
test('валидирует команды на границе IPC / LAN', () => {
test('валидирует команды на границе IPC', () => {
assert.deepEqual(parseCommand(command), command)
for (const value of [
null,
+1 -4
View File
@@ -30,16 +30,14 @@ 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') {
command(input: unknown) {
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,
@@ -70,7 +68,6 @@ export class Controller {
...this.state.samples.slice(-399),
{
media: this.state.command.media,
origin: this.origin,
milliseconds: performance.now() - this.startedAt,
},
]
-106
View File
@@ -1,106 +0,0 @@
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
View File
@@ -1,155 +0,0 @@
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)
}
}
-51
View File
@@ -1,11 +1,9 @@
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'
@@ -18,20 +16,12 @@ protocol.registerSchemesAsPrivileged([
])
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 {
@@ -42,12 +32,6 @@ function host(): HostInfo {
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() {
@@ -207,38 +191,6 @@ app
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()
})
@@ -252,6 +204,3 @@ app
app.quit()
})
app.on('window-all-closed', () => app.quit())
app.on('before-quit', () => {
void lan?.close()
})
-2
View File
@@ -12,6 +12,4 @@ contextBridge.exposeInMainWorld('screenBridge', {
},
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),
})