This commit is contained in:
2026-10-19 00:03:48 +07:00
parent a58f0866d3
commit f74b2a8a41
8 changed files with 290 additions and 28 deletions
+4 -2
View File
@@ -196,8 +196,10 @@ Windows 11, macOS 13+, Ubuntu 24.04 LTS или Debian 13. Эти требова
Без сети продолжайте локально. После её возврата серверное состояние заменяет Без сети продолжайте локально. После её возврата серверное состояние заменяет
локальное; накопленные локальные действия не отправляются. Точная синхронизация локальное; накопленные локальные действия не отправляются. Точная синхронизация
позиций ограничена данными существующего API. «Отключить управление» прекращает позиций ограничена данными существующего API. «Отключиться» сразу прекращает
получение состояний, но не удаляет привязку экрана на сервере. получение состояний и сохраняет отключение локально, затем пытается отвязать экран
на сервере. Если сервер не подтвердил отвязку за пять секунд, появится уведомление;
после возврата сети и перезапуска экран останется отключённым до нового подключения.
## Сбои и восстановление ## Сбои и восстановление
+1 -3
View File
@@ -3,7 +3,6 @@ import {
dialog, dialog,
BrowserWindow, BrowserWindow,
ipcMain, ipcMain,
net,
protocol, protocol,
screen, screen,
session, session,
@@ -26,6 +25,7 @@ import type { OnlineState } from '../src/types/online.js'
import { PackageStore } from './package-store.js' import { PackageStore } from './package-store.js'
import { packageMediaResponse } from './package-media.js' import { packageMediaResponse } from './package-media.js'
import { prepareFetchHeaders } from './response-headers.js' import { prepareFetchHeaders } from './response-headers.js'
import { fetchNetwork } from './network-fetch.js'
import type { HostInfo } from '../src/types/screen.js' import type { HostInfo } from '../src/types/screen.js'
// Ozone выбирается до main.js; поздний appendSwitch смешивает Wayland и X11. // Ozone выбирается до main.js; поздний appendSwitch смешивает Wayland и X11.
@@ -336,8 +336,6 @@ app
hostNotice = error instanceof Error ? error.message : 'Не удалось восстановить окна' hostNotice = error instanceof Error ? error.message : 'Не удалось восстановить окна'
} }
prepareFetchHeaders(session.defaultSession) prepareFetchHeaders(session.defaultSession)
const fetchNetwork: typeof fetch = (input, init) =>
net.fetch(input instanceof URL ? input.href : input, init)
const packages = new PackageStore(join(app.getPath('userData'), 'packages'), { const packages = new PackageStore(join(app.getPath('userData'), 'packages'), {
fetch: fetchNetwork, fetch: fetchNetwork,
}) })
+75
View File
@@ -0,0 +1,75 @@
import { net } from 'electron'
import { Readable } from 'node:stream'
import { byteStringResponseHeaders } from './response-headers.js'
export const fetchNetwork: typeof fetch = async (input, init) => {
const request = new Request(input, init)
if (request.redirect !== 'manual') return net.fetch(request)
if (request.method !== 'GET') throw new Error('Ручные переходы доступны только для загрузки файлов')
// net.fetch rejects manual redirects before it can return their status and headers.
return new Promise<Response>((resolve, reject) => {
const client = net.request({
url: request.url,
method: 'GET',
redirect: 'manual',
credentials: request.credentials,
})
let body: Readable | null = null
const cleanup = () => request.signal.removeEventListener('abort', abort)
const fail = (error: unknown) => {
cleanup()
reject(error)
client.abort()
}
const abort = () => {
const error = request.signal.reason
body?.destroy(error instanceof Error ? error : new Error('Загрузка отменена'))
fail(error)
}
const headersOf = (values: Record<string, string | string[]>) => {
const headers = new Headers()
for (const [name, entries] of Object.entries(byteStringResponseHeaders(values)))
for (const value of entries) headers.append(name, value)
return headers
}
client.on('error', fail)
client.on('redirect', (status, _method, url, values) => {
try {
const headers = headersOf(values)
headers.set('Location', new URL(url).href)
resolve(new Response(null, { status, headers }))
cleanup()
client.abort()
} catch (error) {
fail(error)
}
})
client.on('response', (response) => {
try {
body = response as unknown as Readable
body.once('end', cleanup)
body.once('close', () => {
cleanup()
if (!body!.readableEnded) client.abort()
})
const empty = [204, 205, 304].includes(response.statusCode)
const stream = empty ? null : Readable.toWeb(body) as ReadableStream<Uint8Array>
resolve(new Response(stream, {
status: response.statusCode,
headers: headersOf(response.headers),
}))
if (empty) body.resume()
} catch (error) {
fail(error)
}
})
try {
for (const [name, value] of request.headers) client.setHeader(name, value)
request.signal.addEventListener('abort', abort, { once: true })
if (request.signal.aborted) abort()
else client.end()
} catch (error) {
fail(error)
}
})
}
+90 -8
View File
@@ -48,6 +48,7 @@ async function fixture(t: TestContext, secure = true) {
upload: null as { filename: string; data: Buffer } | null, upload: null as { filename: string; data: Buffer } | null,
bindings: [] as Record<string, unknown>[], bindings: [] as Record<string, unknown>[],
unbindError: 0, unbindError: 0,
unbindResponse: null as ((signal?: AbortSignal) => Promise<Response>) | null,
createScreenError: 0, createScreenError: 0,
offline: false, offline: false,
server: 'https://server.test', server: 'https://server.test',
@@ -62,6 +63,7 @@ async function fixture(t: TestContext, secure = true) {
accessExpired: false, accessExpired: false,
refreshed: false, refreshed: false,
refreshCalls: 0, refreshCalls: 0,
refreshResponse: null as (() => Promise<Response>) | null,
advanceCalls: 0, advanceCalls: 0,
resultError: false, resultError: false,
title: 'Дело', title: 'Дело',
@@ -110,6 +112,7 @@ async function fixture(t: TestContext, secure = true) {
return json({ accessToken: 'ACCESS-SECRET', refreshToken: 'REFRESH-SECRET' }) return json({ accessToken: 'ACCESS-SECRET', refreshToken: 'REFRESH-SECRET' })
if (path === '/api/auth/refresh') { if (path === '/api/auth/refresh') {
model.refreshCalls++ model.refreshCalls++
if (model.refreshResponse) return model.refreshResponse()
await new Promise((resolve) => setTimeout(resolve, 5)) await new Promise((resolve) => setTimeout(resolve, 5))
if (model.refreshExpired) return json({}, 401) if (model.refreshExpired) return json({}, 401)
model.refreshed = true model.refreshed = true
@@ -121,6 +124,7 @@ async function fixture(t: TestContext, secure = true) {
: json({ screenID: '12', accessToken: 'SCREEN-SECRET' }) : json({ screenID: '12', accessToken: 'SCREEN-SECRET' })
if (path.endsWith('/unbind')) { if (path.endsWith('/unbind')) {
assert.equal(auth, 'Bearer ACCESS-SECRET') assert.equal(auth, 'Bearer ACCESS-SECRET')
if (model.unbindResponse) return model.unbindResponse(init?.signal ?? undefined)
return json({}, model.unbindError || 200) return json({}, model.unbindError || 200)
} }
if (path === '/api/screens/state') { if (path === '/api/screens/state') {
@@ -607,6 +611,44 @@ for (const gameId of [7, 8]) {
}) })
} }
test('отмена подготовки не ждёт общий refresh и не отменяет обновление для другого запроса', async (t) => {
const f = await fixture(t)
await f.prepare()
f.model.accessExpired = true
let started!: () => void
let release!: () => void
const refreshStarted = new Promise<void>((resolve) => { started = resolve })
const refreshReleased = new Promise<void>((resolve) => { release = resolve })
f.model.refreshResponse = async () => {
started()
await refreshReleased
f.model.refreshed = true
return Response.json({ accessToken: 'ACCESS-ROTATED', refreshToken: 'REFRESH-ROTATED' })
}
const cancelled = f.online.act({ action: 'check', gameId: 7 }).then(
() => 'completed',
(error: Error) => { assert.match(error.message, /отмен|ABORTED/); return 'cancelled' },
)
await refreshStarted
const background = f.online.refreshPlan()
await new Promise<void>((resolve) => setImmediate(resolve))
await f.online.act({ action: 'cancel' })
try {
assert.equal(await Promise.race([
cancelled,
new Promise<string>((resolve) => { const timer = setTimeout(() => resolve('still waiting'), 1000); timer.unref() }),
]), 'cancelled')
assert.equal(f.model.refreshed, false)
} finally {
release()
await background
await cancelled
}
assert.equal(f.model.refreshCalls, 1)
assert.equal(f.online.snapshot().authenticated, true)
assert.equal(f.online.planStatus().notice, '')
})
for (const status of [200, 404]) { for (const status of [200, 404]) {
test(`отключение отвязывает экран, сохраняет игру и переживает перезапуск: ${status}`, async (t) => { test(`отключение отвязывает экран, сохраняет игру и переживает перезапуск: ${status}`, async (t) => {
const f = await fixture(t) const f = await fixture(t)
@@ -634,23 +676,63 @@ for (const status of [200, 404]) {
} }
for (const failure of ['server', 'network'] as const) { for (const failure of ['server', 'network'] as const) {
test(`ошибка отключения сохраняет сессию для повтора: ${failure}`, async (t) => { test(`ошибка отключения прекращает локальную сессию и не восстанавливает её: ${failure}`, async (t) => {
const f = await fixture(t) const f = await fixture(t)
await f.prepare() await f.prepare()
const saved = await readFile(join(f.directory, 'connection', 'credentials.bin'))
f.model.unbindError = failure === 'server' ? 500 : 0 f.model.unbindError = failure === 'server' ? 500 : 0
f.model.offline = failure === 'network' f.model.offline = failure === 'network'
await assert.rejects(f.online.act({ action: 'disconnect' }))
assert.equal(f.online.snapshot().screenId, '12')
assert.equal(f.online.snapshot().connection, 'waiting')
assert.deepEqual(await readFile(join(f.directory, 'connection', 'credentials.bin')), saved)
f.model.unbindError = 0
f.model.offline = false
await f.online.act({ action: 'disconnect' }) await f.online.act({ action: 'disconnect' })
assert.equal(f.online.snapshot().screenId, null) assert.equal(f.online.snapshot().screenId, null)
assert.equal(f.online.snapshot().connection, 'disabled')
assert.match(f.online.snapshot().notice, /отключён локально/)
const saved = JSON.parse(f.secrets.decrypt(await readFile(join(f.directory, 'connection', 'credentials.bin'))))
assert.equal(saved.screen, null)
f.model.unbindError = 0
f.model.offline = false
const count = f.requests.length
await f.online.poll()
assert.equal(f.requests.length, count)
assert.equal(f.online.snapshot().screenId, null)
const restored = new Online(join(f.directory, 'connection'), f.store, new Controller(() => {}), f.secrets, f.fetchAPI)
t.after(() => restored.close())
await restored.initialize()
assert.equal(restored.snapshot().screenId, null)
}) })
} }
test('локальное отключение сохраняется до ответа сервера, отвязка ограничена пятью секундами', async (t) => {
const f = await fixture(t)
await f.prepare()
const timeout = AbortSignal.timeout
let bounded = false
t.mock.method(AbortSignal, 'timeout', (milliseconds: number) => {
if (milliseconds === 5000) bounded = true
return timeout(milliseconds === 5000 ? 20 : milliseconds)
})
let started!: () => void
const requested = new Promise<void>((resolve) => { started = resolve })
f.model.unbindResponse = async (signal) => {
started()
return new Promise((_resolve, reject) => {
signal!.addEventListener('abort', () => reject(signal!.reason), { once: true })
})
}
const disconnect = f.online.act({ action: 'disconnect' })
await requested
assert.equal(f.online.snapshot().screenId, null)
const saved = JSON.parse(f.secrets.decrypt(await readFile(join(f.directory, 'connection', 'credentials.bin'))))
assert.equal(saved.screen, null)
// AbortSignal timers are unref'ed; keep the test alive until the simulated timeout.
const keepAlive = setTimeout(() => {}, 1000)
try {
await disconnect
} finally {
clearTimeout(keepAlive)
}
assert.equal(bounded, true)
assert.match(f.online.snapshot().notice, /не подтвердил отвязку/)
})
test('ошибка отвязки сохраняет прежнюю сессию и не создаёт новую', async (t) => { test('ошибка отвязки сохраняет прежнюю сессию и не создаёт новую', async (t) => {
const f = await fixture(t) const f = await fixture(t)
await f.prepare() await f.prepare()
+27 -9
View File
@@ -4,6 +4,7 @@ import { apiScreenMode, apiScreenCommand, apiPlan } from './api-values.js'
import { ApiError, NetworkError, SessionChangedError, networkFailure } from './request-error.js' import { ApiError, NetworkError, SessionChangedError, networkFailure } from './request-error.js'
import { mkdir, readFile, readdir, rename, rm, writeFile } from 'node:fs/promises' import { mkdir, readFile, readdir, rename, rm, writeFile } from 'node:fs/promises'
import { join } from 'node:path' import { join } from 'node:path'
import { once } from 'node:events'
import { import {
DEFAULT_SERVER, DEFAULT_SERVER,
type GameMaterial, type GameMaterial,
@@ -1191,7 +1192,21 @@ export class Online {
if (generation !== this.generation) throw new SessionChangedError() if (generation !== this.generation) throw new SessionChangedError()
if (response.status === 401) { if (response.status === 401) {
await response.body?.cancel() await response.body?.cancel()
await this.refresh(c.access, generation) // Отмена прерывает ожидание, но общий refresh остаётся доступен другим запросам.
const waiting = new AbortController()
try {
init.signal?.throwIfAborted()
await Promise.race([
this.refresh(c.access, generation),
once(waiting.signal, 'abort', { signal: init.signal ?? undefined }),
])
init.signal?.throwIfAborted()
} catch (error) {
if (init.signal?.aborted) throw networkFailure(error, init.signal)
throw error
} finally {
waiting.abort()
}
if (generation !== this.generation || !this.credentials) throw new ApiError(401) if (generation !== this.generation || !this.credentials) throw new ApiError(401)
response = await request(this.credentials.access) response = await request(this.credentials.access)
if (response.status === 401) { if (response.status === 401) {
@@ -1538,17 +1553,20 @@ export class Online {
await this.persistSecrets() await this.persistSecrets()
return return
} }
case 'disconnect': case 'disconnect': {
if (this.screen) { const screen = this.screen
try {
await this.client().UnbindScreen({ screenID: this.screen.id })
} catch (error) {
if (!(error instanceof ApiError && error.status === 404)) throw error
}
}
this.detach() this.detach()
await this.persistSecrets() await this.persistSecrets()
if (screen) {
try {
await this.client(true, AbortSignal.timeout(5000)).UnbindScreen({ screenID: screen.id })
} catch (error) {
if (!(error instanceof ApiError && error.status === 404))
this.state.notice = 'Экран отключён локально. Сервер не подтвердил отвязку'
}
}
return return
}
case 'sound': { case 'sound': {
const generation = this.generation const generation = this.generation
if (!this.screen) throw new Error('Сначала подключите управление') if (!this.screen) throw new Error('Сначала подключите управление')
+7 -1
View File
@@ -1,6 +1,12 @@
import assert from 'node:assert/strict' import assert from 'node:assert/strict'
import { test } from 'node:test' import { test } from 'node:test'
import { byteStringResponseHeaders } from './response-headers.js' import { asciiStatusLine, byteStringResponseHeaders } from './response-headers.js'
test('строка статуса сохраняет протокол и код, несовместимый текст удаляется', () => {
assert.equal(asciiStatusLine('HTTP/1.1 200 Привет'), 'HTTP/1.1 200 ')
assert.equal(asciiStatusLine('HTTP/2.0 500 Ошибка'), 'HTTP/2.0 500 ')
assert.equal(asciiStatusLine('HTTP/1.1 404 Not Found'), 'HTTP/1.1 404 Not Found')
})
test('заголовки с кириллицей совместимы с Headers, обычные значения сохраняются', () => { test('заголовки с кириллицей совместимы с Headers, обычные значения сохраняются', () => {
const original = { const original = {
+10 -3
View File
@@ -1,13 +1,20 @@
export function byteStringResponseHeaders(headers: Record<string, string[]> = {}) { export function byteStringResponseHeaders(headers: Record<string, string | string[]> = {}) {
return Object.fromEntries(Object.entries(headers).map(([name, values]) => [ return Object.fromEntries(Object.entries(headers).map(([name, values]) => [
name, name,
values.map((value) => value.replace(/[^\x00-\xff]+/gu, encodeURIComponent)), (Array.isArray(values) ? values : [values]).map((value) => value.replace(/[^\x00-\xff]+/gu, encodeURIComponent)),
])) ]))
} }
export function prepareFetchHeaders(target: Electron.Session) { export function prepareFetchHeaders(target: Electron.Session) {
// Electron net.fetch throws outside its promise when a response header contains Unicode. // Electron net.fetch throws outside its promise when a response header contains Unicode.
target.webRequest.onHeadersReceived((details, callback) => { target.webRequest.onHeadersReceived((details, callback) => {
callback({ responseHeaders: byteStringResponseHeaders(details.responseHeaders) }) callback({
responseHeaders: byteStringResponseHeaders(details.responseHeaders),
statusLine: asciiStatusLine(details.statusLine),
})
}) })
} }
export function asciiStatusLine(line: string) {
return /[^\x20-\x7e]/.test(line) ? line.replace(/^(HTTP\/\S+\s+\d{3}).*$/, '$1 ') : line
}
+76 -2
View File
@@ -1,17 +1,56 @@
const assert = require('node:assert/strict') const assert = require('node:assert/strict')
const { createServer } = require('node:http') const { createServer } = require('node:http')
const { app, net, session } = require('electron') const { app, net, session } = require('electron')
const { mkdtemp, rm } = require('node:fs/promises')
const { join } = require('node:path')
const { tmpdir } = require('node:os')
const { fetchNetwork } = require('../dist-electron/electron/network-fetch.js')
const { PackageStore } = require('../dist-electron/electron/package-store.js')
const { buildScreenPackage } = require('../dist-electron/electron/screen-package.js')
const { prepareFetchHeaders } = require('../dist-electron/electron/response-headers.js') const { prepareFetchHeaders } = require('../dist-electron/electron/response-headers.js')
app.whenReady().then(async () => { app.whenReady().then(async () => {
prepareFetchHeaders(session.defaultSession) prepareFetchHeaders(session.defaultSession)
const requests = [] const requests = []
const bytes = Buffer.from('RIFFtest-WAVE')
const directory = await mkdtemp(join(tmpdir(), 'screen-network-'))
const server = createServer((request, response) => { const server = createServer((request, response) => {
requests.push(request.url) requests.push(request.url)
const headers = { 'X-Name': Buffer.from('пример').toString('latin1') } const headers = { 'X-Name': Buffer.from('пример').toString('latin1') }
if (request.url === '/redirect') { const path = new URL(request.url, 'http://localhost').pathname
assert.equal(request.headers.authorization, undefined)
assert.equal(request.headers.cookie, undefined)
if (path === '/api/echo') {
const parts = []
request.on('data', (chunk) => parts.push(chunk))
request.on('end', () => {
response.writeHead(200, { 'Content-Type': 'application/json' })
response.end(Buffer.concat(parts))
})
} else if (path === '/redirect') {
response.writeHead(302, { ...headers, Location: `/${encodeURIComponent('пример')}` }) response.writeHead(302, { ...headers, Location: `/${encodeURIComponent('пример')}` })
response.end() response.end()
} else if (path === '/absolute') {
response.writeHead(307, { Location: `http://127.0.0.1:${server.address().port}/file.wav` })
response.end()
} else if (path === '/unsafe') {
response.writeHead(302, { Location: 'file:///tmp/secret.wav' })
response.end()
} else if (path === '/waiting') {
return
} else if (path === '/stream') {
response.writeHead(200, { 'Content-Type': 'audio/wav' })
response.write(bytes)
} else if (path === '/broken') {
response.writeHead(200, { 'Content-Type': 'audio/wav' })
response.write(bytes)
setTimeout(() => response.destroy(), 20)
} else if (path === '/file.wav' || path.startsWith('/%')) {
response.writeHead(200, { 'Content-Type': 'audio/wav' })
response.end(bytes)
} else if (path === '/status') {
response.writeHead(200, Buffer.from('Привет').toString('latin1'), { 'Content-Type': 'text/plain' })
response.end('ok')
} else { } else {
response.writeHead(200, { ...headers, 'Content-Type': 'text/plain' }) response.writeHead(200, { ...headers, 'Content-Type': 'text/plain' })
response.end('ok') response.end('ok')
@@ -27,12 +66,47 @@ app.whenReady().then(async () => {
assert.equal(response.headers.get('x-name'), encodeURIComponent('пример')) assert.equal(response.headers.get('x-name'), encodeURIComponent('пример'))
assert.equal(response.headers.get('content-type'), 'text/plain') assert.equal(response.headers.get('content-type'), 'text/plain')
assert.equal(await response.text(), 'ok') assert.equal(await response.text(), 'ok')
const echo = await fetchNetwork(`${url}/api/echo`, { method: 'POST', body: JSON.stringify({ text: 'Привет' }), headers: { 'Content-Type': 'application/json' }, redirect: 'error', credentials: 'omit' })
assert.deepEqual(await echo.json(), { text: 'Привет' })
const followed = await net.fetch(`${url}/redirect`) const followed = await net.fetch(`${url}/redirect`)
assert.equal(await followed.text(), 'ok') assert.deepEqual(Buffer.from(await followed.arrayBuffer()), bytes)
const unicodeStatus = await fetchNetwork(`${url}/status`)
assert.equal(unicodeStatus.status, 200)
assert.equal(await unicodeStatus.text(), 'ok')
const manual = await fetchNetwork(`${url}/redirect`, { redirect: 'manual', credentials: 'omit' })
assert.equal(manual.status, 302)
assert.ok(manual.headers.get('location').endsWith(encodeURIComponent('пример')))
const store = new PackageStore(directory, { fetch: fetchNetwork, wait: async () => {} })
await store.initialize()
const description = (base, name) => buildScreenPackage(base, 7, { game: { game: { ID: 7, name: 'Network smoke', scenario: { story: { introduction: { audio: `${base}/${name}` } } } } }, plan: { plan: { steps: [] } }, music: {} })
for (const name of ['redirect', 'absolute']) {
const pkg = await store.download(description(url, name))
assert.equal(pkg.files.length, 1)
assert.equal(pkg.files[0].size, bytes.length)
}
await assert.rejects(store.download(description(url, 'unsafe')), /адрес|редирект/)
const aborted = new AbortController()
aborted.abort()
await assert.rejects(fetchNetwork(`${url}/waiting`, { redirect: 'manual', credentials: 'omit', signal: aborted.signal }))
await assert.rejects(fetchNetwork(`${url}/waiting`, { redirect: 'manual', credentials: 'omit', signal: AbortSignal.timeout(30) }))
const streamingAbort = new AbortController()
const streaming = await fetchNetwork(`${url}/stream`, { redirect: 'manual', credentials: 'omit', signal: streamingAbort.signal })
const reader = streaming.body.getReader()
assert.deepEqual(Buffer.from((await reader.read()).value), bytes)
streamingAbort.abort()
await assert.rejects(reader.read())
const brokenTimeout = AbortSignal.timeout(1000)
const broken = await fetchNetwork(`${url}/broken`, { redirect: 'manual', credentials: 'omit', signal: brokenTimeout })
await assert.rejects(broken.arrayBuffer())
assert.equal(brokenTimeout.aborted, false)
await session.defaultSession.setProxy({ proxyRules: `http=127.0.0.1:${server.address().port}`, proxyBypassRules: '<-loopback>' }) await session.defaultSession.setProxy({ proxyRules: `http=127.0.0.1:${server.address().port}`, proxyBypassRules: '<-loopback>' })
const proxied = await net.fetch('http://headers-proxy.invalid/example') const proxied = await net.fetch('http://headers-proxy.invalid/example')
assert.equal(await proxied.text(), 'ok') assert.equal(await proxied.text(), 'ok')
assert.ok(requests.includes('http://headers-proxy.invalid/example')) assert.ok(requests.includes('http://headers-proxy.invalid/example'))
const proxiedPackage = await store.download(description('http://headers-proxy.invalid', 'redirect'))
assert.equal(proxiedPackage.files[0].size, bytes.length)
assert.ok(requests.includes('http://headers-proxy.invalid/redirect'))
await rm(directory, { recursive: true, force: true })
console.log('SCREEN_NETWORK_HEADERS_PASS') console.log('SCREEN_NETWORK_HEADERS_PASS')
app.exit(0) app.exit(0)
} catch (error) { } catch (error) {