diff --git a/docs/USER_GUIDE.md b/docs/USER_GUIDE.md index 4ae9d78..1e74829 100644 --- a/docs/USER_GUIDE.md +++ b/docs/USER_GUIDE.md @@ -196,8 +196,10 @@ Windows 11, macOS 13+, Ubuntu 24.04 LTS или Debian 13. Эти требова Без сети продолжайте локально. После её возврата серверное состояние заменяет локальное; накопленные локальные действия не отправляются. Точная синхронизация -позиций ограничена данными существующего API. «Отключить управление» прекращает -получение состояний, но не удаляет привязку экрана на сервере. +позиций ограничена данными существующего API. «Отключиться» сразу прекращает +получение состояний и сохраняет отключение локально, затем пытается отвязать экран +на сервере. Если сервер не подтвердил отвязку за пять секунд, появится уведомление; +после возврата сети и перезапуска экран останется отключённым до нового подключения. ## Сбои и восстановление diff --git a/electron/main.ts b/electron/main.ts index 52250e6..ee51693 100644 --- a/electron/main.ts +++ b/electron/main.ts @@ -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, }) diff --git a/electron/network-fetch.ts b/electron/network-fetch.ts new file mode 100644 index 0000000..272e3ad --- /dev/null +++ b/electron/network-fetch.ts @@ -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((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) => { + 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 + 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) + } + }) +} diff --git a/electron/online.test.ts b/electron/online.test.ts index 8eedf07..57f7400 100644 --- a/electron/online.test.ts +++ b/electron/online.test.ts @@ -48,6 +48,7 @@ async function fixture(t: TestContext, secure = true) { upload: null as { filename: string; data: Buffer } | null, bindings: [] as Record[], unbindError: 0, + unbindResponse: null as ((signal?: AbortSignal) => Promise) | 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) | 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((resolve) => { started = resolve }) + const refreshReleased = new Promise((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((resolve) => setImmediate(resolve)) + await f.online.act({ action: 'cancel' }) + try { + assert.equal(await Promise.race([ + cancelled, + new Promise((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((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() diff --git a/electron/online.ts b/electron/online.ts index 82a4d1b..debad16 100644 --- a/electron/online.ts +++ b/electron/online.ts @@ -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('Сначала подключите управление') diff --git a/electron/response-headers.test.ts b/electron/response-headers.test.ts index b83601e..dce6192 100644 --- a/electron/response-headers.test.ts +++ b/electron/response-headers.test.ts @@ -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 = { diff --git a/electron/response-headers.ts b/electron/response-headers.ts index e02e0f8..66dfd9f 100644 --- a/electron/response-headers.ts +++ b/electron/response-headers.ts @@ -1,13 +1,20 @@ -export function byteStringResponseHeaders(headers: Record = {}) { +export function byteStringResponseHeaders(headers: Record = {}) { 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 +} diff --git a/scripts/network-headers-smoke.cjs b/scripts/network-headers-smoke.cjs index 929e45c..60101ef 100644 --- a/scripts/network-headers-smoke.cjs +++ b/scripts/network-headers-smoke.cjs @@ -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) {