diff --git a/electron/main.ts b/electron/main.ts index 85472f4..52250e6 100644 --- a/electron/main.ts +++ b/electron/main.ts @@ -25,6 +25,7 @@ import { Online } from './online.js' 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 type { HostInfo } from '../src/types/screen.js' // Ozone выбирается до main.js; поздний appendSwitch смешивает Wayland и X11. @@ -334,6 +335,7 @@ app diagnostics.record('settings-error') 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'), { diff --git a/electron/response-headers.test.ts b/electron/response-headers.test.ts new file mode 100644 index 0000000..b83601e --- /dev/null +++ b/electron/response-headers.test.ts @@ -0,0 +1,24 @@ +import assert from 'node:assert/strict' +import { test } from 'node:test' +import { byteStringResponseHeaders } from './response-headers.js' + +test('заголовки с кириллицей совместимы с Headers, обычные значения сохраняются', () => { + const original = { + 'X-Name': ['пример'], + Location: ['https://media.test/папка/трек.mp3?name=%D0%B0'], + 'Content-Disposition': ['attachment; filename="пример.mp3"'], + 'Content-Length': ['1024'], + 'Content-Type': ['audio/wav'], + 'X-Latin': ['caf\u00e9'], + 'Set-Cookie': ['a=1', 'b=2'], + } + const result = byteStringResponseHeaders(original) + const headers = new Headers(Object.fromEntries(Object.entries(result).map(([name, values]) => [name, values.join(', ')]))) + assert.equal(headers.get('x-name'), encodeURIComponent('пример')) + assert.equal(headers.get('location'), new URL(original.Location[0]!).href) + assert.equal(result['Content-Disposition']![0], `attachment; filename="${encodeURIComponent('пример')}.mp3"`) + for (const name of ['Content-Length', 'Content-Type', 'X-Latin', 'Set-Cookie'] as const) + assert.deepEqual(result[name], original[name]) + assert.equal(original['X-Name'][0], 'пример') + assert.deepEqual(byteStringResponseHeaders(), {}) +}) diff --git a/electron/response-headers.ts b/electron/response-headers.ts new file mode 100644 index 0000000..e02e0f8 --- /dev/null +++ b/electron/response-headers.ts @@ -0,0 +1,13 @@ +export function byteStringResponseHeaders(headers: Record = {}) { + return Object.fromEntries(Object.entries(headers).map(([name, values]) => [ + name, + 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) }) + }) +} diff --git a/scripts/network-headers-smoke.cjs b/scripts/network-headers-smoke.cjs new file mode 100644 index 0000000..929e45c --- /dev/null +++ b/scripts/network-headers-smoke.cjs @@ -0,0 +1,44 @@ +const assert = require('node:assert/strict') +const { createServer } = require('node:http') +const { app, net, session } = require('electron') +const { prepareFetchHeaders } = require('../dist-electron/electron/response-headers.js') + +app.whenReady().then(async () => { + prepareFetchHeaders(session.defaultSession) + const requests = [] + const server = createServer((request, response) => { + requests.push(request.url) + const headers = { 'X-Name': Buffer.from('пример').toString('latin1') } + if (request.url === '/redirect') { + response.writeHead(302, { ...headers, Location: `/${encodeURIComponent('пример')}` }) + response.end() + } else { + response.writeHead(200, { ...headers, 'Content-Type': 'text/plain' }) + response.end('ok') + } + }) + try { + await new Promise((resolve, reject) => { + server.once('error', reject) + server.listen(0, '127.0.0.1', resolve) + }) + const url = `http://127.0.0.1:${server.address().port}` + const response = await net.fetch(url) + assert.equal(response.headers.get('x-name'), encodeURIComponent('пример')) + assert.equal(response.headers.get('content-type'), 'text/plain') + assert.equal(await response.text(), 'ok') + const followed = await net.fetch(`${url}/redirect`) + assert.equal(await followed.text(), 'ok') + 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')) + console.log('SCREEN_NETWORK_HEADERS_PASS') + app.exit(0) + } catch (error) { + console.error(error) + app.exit(1) + } finally { + server.close() + } +})