fix
This commit is contained in:
+4
-2
@@ -196,8 +196,10 @@ Windows 11, macOS 13+, Ubuntu 24.04 LTS или Debian 13. Эти требова
|
|||||||
|
|
||||||
Без сети продолжайте локально. После её возврата серверное состояние заменяет
|
Без сети продолжайте локально. После её возврата серверное состояние заменяет
|
||||||
локальное; накопленные локальные действия не отправляются. Точная синхронизация
|
локальное; накопленные локальные действия не отправляются. Точная синхронизация
|
||||||
позиций ограничена данными существующего API. «Отключить управление» прекращает
|
позиций ограничена данными существующего API. «Отключиться» сразу прекращает
|
||||||
получение состояний, но не удаляет привязку экрана на сервере.
|
получение состояний и сохраняет отключение локально, затем пытается отвязать экран
|
||||||
|
на сервере. Если сервер не подтвердил отвязку за пять секунд, появится уведомление;
|
||||||
|
после возврата сети и перезапуска экран останется отключённым до нового подключения.
|
||||||
|
|
||||||
## Сбои и восстановление
|
## Сбои и восстановление
|
||||||
|
|
||||||
|
|||||||
+1
-3
@@ -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,
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -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
@@ -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
@@ -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('Сначала подключите управление')
|
||||||
|
|||||||
@@ -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 = {
|
||||||
|
|||||||
@@ -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
|
||||||
|
}
|
||||||
|
|||||||
@@ -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) {
|
||||||
|
|||||||
Reference in New Issue
Block a user