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,
BrowserWindow,
ipcMain,
net,
protocol,
screen,
session,
@@ -26,6 +25,7 @@ import type { OnlineState } from '../src/types/online.js'
import { PackageStore } from './package-store.js'
import { packageMediaResponse } from './package-media.js'
import { prepareFetchHeaders } from './response-headers.js'
import { fetchNetwork } from './network-fetch.js'
import type { HostInfo } from '../src/types/screen.js'
// Ozone выбирается до main.js; поздний appendSwitch смешивает Wayland и X11.
@@ -336,8 +336,6 @@ app
hostNotice = error instanceof Error ? error.message : 'Не удалось восстановить окна'
}
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'), {
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,
bindings: [] as Record<string, unknown>[],
unbindError: 0,
unbindResponse: null as ((signal?: AbortSignal) => Promise<Response>) | null,
createScreenError: 0,
offline: false,
server: 'https://server.test',
@@ -62,6 +63,7 @@ async function fixture(t: TestContext, secure = true) {
accessExpired: false,
refreshed: false,
refreshCalls: 0,
refreshResponse: null as (() => Promise<Response>) | null,
advanceCalls: 0,
resultError: false,
title: 'Дело',
@@ -110,6 +112,7 @@ async function fixture(t: TestContext, secure = true) {
return json({ accessToken: 'ACCESS-SECRET', refreshToken: 'REFRESH-SECRET' })
if (path === '/api/auth/refresh') {
model.refreshCalls++
if (model.refreshResponse) return model.refreshResponse()
await new Promise((resolve) => setTimeout(resolve, 5))
if (model.refreshExpired) return json({}, 401)
model.refreshed = true
@@ -121,6 +124,7 @@ async function fixture(t: TestContext, secure = true) {
: json({ screenID: '12', accessToken: 'SCREEN-SECRET' })
if (path.endsWith('/unbind')) {
assert.equal(auth, 'Bearer ACCESS-SECRET')
if (model.unbindResponse) return model.unbindResponse(init?.signal ?? undefined)
return json({}, model.unbindError || 200)
}
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]) {
test(`отключение отвязывает экран, сохраняет игру и переживает перезапуск: ${status}`, async (t) => {
const f = await fixture(t)
@@ -634,23 +676,63 @@ for (const status of [200, 404]) {
}
for (const failure of ['server', 'network'] as const) {
test(`ошибка отключения сохраняет сессию для повтора: ${failure}`, async (t) => {
test(`ошибка отключения прекращает локальную сессию и не восстанавливает её: ${failure}`, async (t) => {
const f = await fixture(t)
await f.prepare()
const saved = await readFile(join(f.directory, 'connection', 'credentials.bin'))
f.model.unbindError = failure === 'server' ? 500 : 0
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' })
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) => {
const f = await fixture(t)
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 { mkdir, readFile, readdir, rename, rm, writeFile } from 'node:fs/promises'
import { join } from 'node:path'
import { once } from 'node:events'
import {
DEFAULT_SERVER,
type GameMaterial,
@@ -1191,7 +1192,21 @@ export class Online {
if (generation !== this.generation) throw new SessionChangedError()
if (response.status === 401) {
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)
response = await request(this.credentials.access)
if (response.status === 401) {
@@ -1538,17 +1553,20 @@ export class Online {
await this.persistSecrets()
return
}
case 'disconnect':
if (this.screen) {
try {
await this.client().UnbindScreen({ screenID: this.screen.id })
} catch (error) {
if (!(error instanceof ApiError && error.status === 404)) throw error
}
}
case 'disconnect': {
const screen = this.screen
this.detach()
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
}
case 'sound': {
const generation = this.generation
if (!this.screen) throw new Error('Сначала подключите управление')
+7 -1
View File
@@ -1,6 +1,12 @@
import assert from 'node:assert/strict'
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, обычные значения сохраняются', () => {
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]) => [
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) {
// Electron net.fetch throws outside its promise when a response header contains Unicode.
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 { createServer } = require('node:http')
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')
app.whenReady().then(async () => {
prepareFetchHeaders(session.defaultSession)
const requests = []
const bytes = Buffer.from('RIFFtest-WAVE')
const directory = await mkdtemp(join(tmpdir(), 'screen-network-'))
const server = createServer((request, response) => {
requests.push(request.url)
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.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 {
response.writeHead(200, { ...headers, 'Content-Type': 'text/plain' })
response.end('ok')
@@ -27,12 +66,47 @@ app.whenReady().then(async () => {
assert.equal(response.headers.get('x-name'), encodeURIComponent('пример'))
assert.equal(response.headers.get('content-type'), 'text/plain')
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`)
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>' })
const proxied = await net.fetch('http://headers-proxy.invalid/example')
assert.equal(await proxied.text(), 'ok')
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')
app.exit(0)
} catch (error) {