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
+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
}