From 0cd736f6820292d6131bdf19fccb3ff444d200c3 Mon Sep 17 00:00:00 2001 From: Fedorov Vladimir Date: Sun, 18 Oct 2026 13:01:44 +0700 Subject: [PATCH] fix --- README.md | 5 +++++ TASKS.md | 7 ++++++ electron/main.ts | 9 ++++++-- electron/request-error.test.ts | 32 +++++++++++++++++++++++++++ electron/request-error.ts | 40 ++++++++++++++++++++++++++-------- scripts/online-smoke.cjs | 32 +++++++++++++++++++++++---- 6 files changed, 110 insertions(+), 15 deletions(-) diff --git a/README.md b/README.md index f093e10..81d915b 100644 --- a/README.md +++ b/README.md @@ -235,6 +235,11 @@ URL, независимо от исходного имени файла. Лок реальные игровые материалы и p95 на M1 пока не подтверждены. Все восемь визуальных режимов доступны в SCREEN-005; авторизация и получение серверных команд остаются в SCREEN-006. +API и загрузка материалов используют сетевой стек Chromium (`net.fetch` Electron) +с системными настройками прокси. Собственный прокси приложение не запускает; +воспроизведение остаётся локальным. Ошибки DNS, подключения, TLS и прокси +показываются с безопасным кодом и контекстом запроса. + ## Пульт и режимы SCREEN-005 Пульт включает подключение игры через сервер, план шагов с восемью режимами, текстом и параметрами таймера, diff --git a/TASKS.md b/TASKS.md index af89678..945634d 100644 --- a/TASKS.md +++ b/TASKS.md @@ -245,3 +245,10 @@ SCREEN-009 отменена владельцем; ID не переиспольз проектор/звук и физические замеры на M1. Статус IN_PROGRESS до получения результатов всех стендов. SCREEN-001/004/007 также не закрываются. Бэкенд, сайт, protobuf и зависимости не менялись; commit/push не выполнялись. + +## SCREEN-024 — Сетевой стек Electron для API и материалов + +- Статус: `IN_PROGRESS`. +- Причина: сайт авторизуется, приложение получает UND_ERR_CONNECT_TIMEOUT; API и файлы приложения используют Node fetch, обходящий браузерный сетевой стек и системный прокси Chromium. +- Изменение: внедрить net.fetch Electron в существующие Online и PackageStore без смены API/адресов/таймаутов; сохранить безопасные ошибки сети. +- Проверка: тесты, type-check, lint, lint:css, build, Electron smoke с HTTP-прокси и неразрешимым доменом. diff --git a/electron/main.ts b/electron/main.ts index 82382e8..397d1b9 100644 --- a/electron/main.ts +++ b/electron/main.ts @@ -3,6 +3,7 @@ import { dialog, BrowserWindow, ipcMain, + net, protocol, screen, session, @@ -289,7 +290,11 @@ app diagnostics.record('settings-error') hostNotice = error instanceof Error ? error.message : 'Не удалось восстановить окна' } - const packages = new PackageStore(join(app.getPath('userData'), 'packages')) + 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, + }) await packages.initialize() online = new Online( join(app.getPath('userData'), 'connection'), @@ -303,7 +308,7 @@ app encrypt: (value) => safeStorage.encryptString(value), decrypt: (value) => safeStorage.decryptString(value), }, - undefined, + fetchNetwork, (failure) => diagnostics?.record('request-error', failure), async () => { const result = await dialog.showOpenDialog({ diff --git a/electron/request-error.test.ts b/electron/request-error.test.ts index 42084e9..bebc2a2 100644 --- a/electron/request-error.test.ts +++ b/electron/request-error.test.ts @@ -33,3 +33,35 @@ test('контекст HTTP сохраняет тип ошибки, статус result.message, ) }) + +test('ошибки Chromium сохраняют безопасный код без адресов и содержимого', () => { + for (const [input, code, message] of [ + ['ERR_NAME_NOT_RESOLVED', 'ENOTFOUND', 'Не удалось определить адрес сервера'], + ['ERR_CONNECTION_REFUSED', 'ECONNREFUSED', 'Сервер отклонил соединение'], + ['ERR_CONNECTION_TIMED_OUT', 'ETIMEDOUT', 'Превышено время ожидания соединения с сервером'], + [ + 'ERR_PROXY_CONNECTION_FAILED', + 'ERR_PROXY_CONNECTION_FAILED', + 'Не удалось подключиться через системный прокси', + ], + [ + 'ERR_TUNNEL_CONNECTION_FAILED', + 'ERR_TUNNEL_CONNECTION_FAILED', + 'Не удалось подключиться через системный прокси', + ], + [ + 'ERR_CERT_AUTHORITY_INVALID', + 'UNABLE_TO_VERIFY_LEAF_SIGNATURE', + 'Ошибка защищённого соединения с сервером', + ], + ['ERR_UNKNOWN', 'NETWORK', 'Сервер недоступен'], + ]) { + const result = networkFailure( + new Error(`net::${input} at https://SECRET.invalid`), + new AbortController().signal, + ) + assert.equal(result.code, code) + assert.equal(result.message, message) + assert.ok(!result.message.includes('SECRET')) + } +}) diff --git a/electron/request-error.ts b/electron/request-error.ts index e67cfac..17c7c2c 100644 --- a/electron/request-error.ts +++ b/electron/request-error.ts @@ -1,5 +1,7 @@ export const NETWORK_ERROR_CODES = [ 'NETWORK', + 'ERR_PROXY_CONNECTION_FAILED', + 'ERR_TUNNEL_CONNECTION_FAILED', 'TIMEOUT', 'ABORTED', 'ENOTFOUND', @@ -41,8 +43,26 @@ export class ApiError extends Error { } } +const chromiumCodes: Record = { + ERR_NAME_NOT_RESOLVED: 'ENOTFOUND', + ERR_CONNECTION_REFUSED: 'ECONNREFUSED', + ERR_CONNECTION_RESET: 'ECONNRESET', + ERR_CONNECTION_TIMED_OUT: 'ETIMEDOUT', + ERR_TIMED_OUT: 'ETIMEDOUT', + ERR_PROXY_CONNECTION_FAILED: 'ERR_PROXY_CONNECTION_FAILED', + ERR_TUNNEL_CONNECTION_FAILED: 'ERR_TUNNEL_CONNECTION_FAILED', + ERR_CERT_DATE_INVALID: 'CERT_HAS_EXPIRED', + ERR_CERT_AUTHORITY_INVALID: 'UNABLE_TO_VERIFY_LEAF_SIGNATURE', + ERR_CERT_COMMON_NAME_INVALID: 'ERR_TLS_CERT_ALTNAME_INVALID', +} + function causeCode(error: unknown, depth = 0): NetworkErrorCode | undefined { if (!error || typeof error !== 'object' || depth > 3) return + if (error instanceof Error) { + const match = /^net::(ERR_[A-Z_]+)(?:\s|$)/.exec(error.message) + const code = match ? chromiumCodes[match[1]!] : undefined + if (code) return code + } const value = error as { code?: unknown; cause?: unknown; errors?: unknown[] } if (NETWORK_ERROR_CODES.includes(value.code as NetworkErrorCode)) return value.code as NetworkErrorCode @@ -64,15 +84,17 @@ export function networkFailure(error: unknown, signal: AbortSignal): NetworkErro const message = code === 'ENOTFOUND' || code === 'EAI_AGAIN' ? 'Не удалось определить адрес сервера' - : code.includes('CERT') || code === 'UNABLE_TO_VERIFY_LEAF_SIGNATURE' - ? 'Ошибка защищённого соединения с сервером' - : code === 'ETIMEDOUT' || code === 'UND_ERR_CONNECT_TIMEOUT' - ? 'Превышено время ожидания соединения с сервером' - : code === 'ECONNREFUSED' - ? 'Сервер отклонил соединение' - : code === 'ECONNRESET' - ? 'Соединение с сервером прервано' - : 'Сервер недоступен' + : code === 'ERR_PROXY_CONNECTION_FAILED' || code === 'ERR_TUNNEL_CONNECTION_FAILED' + ? 'Не удалось подключиться через системный прокси' + : code.includes('CERT') || code === 'UNABLE_TO_VERIFY_LEAF_SIGNATURE' + ? 'Ошибка защищённого соединения с сервером' + : code === 'ETIMEDOUT' || code === 'UND_ERR_CONNECT_TIMEOUT' + ? 'Превышено время ожидания соединения с сервером' + : code === 'ECONNREFUSED' + ? 'Сервер отклонил соединение' + : code === 'ECONNRESET' + ? 'Соединение с сервером прервано' + : 'Сервер недоступен' return new NetworkError(message, code) } diff --git a/scripts/online-smoke.cjs b/scripts/online-smoke.cjs index b89b023..5d4b6e6 100644 --- a/scripts/online-smoke.cjs +++ b/scripts/online-smoke.cjs @@ -2,7 +2,7 @@ const assert = require('node:assert/strict') const { createServer } = require('node:http') const { readFileSync, writeFileSync, mkdtempSync, rmSync } = require('node:fs') const { tmpdir } = require('node:os') -const { dialog } = require('electron') +const { dialog, session } = require('electron') const { join } = require('node:path') module.exports = async function onlineSmoke({ @@ -49,7 +49,11 @@ module.exports = async function onlineSmoke({ const chunks = [] for await (const chunk of req) chunks.push(chunk) const body = chunks.length ? JSON.parse(Buffer.concat(chunks).toString()) : {} - const path = req.url + assert.ok( + req.url.startsWith('http://screen-proxy.invalid/'), + 'API and media use Chromium proxy', + ) + const path = new URL(req.url).pathname if (offline && path !== '/api/screens/state') { res.destroy() return @@ -168,8 +172,17 @@ module.exports = async function onlineSmoke({ ) } if ( - ['/api/files/intro.wav', '/api/files/music-a.wav', '/api/files/music-b.wav'].includes(path) + [ + '/api/files/intro.wav', + '/api/files/cached-intro.wav', + '/api/files/music-a.wav', + '/api/files/music-b.wav', + ].includes(path) ) { + if (path === '/api/files/intro.wav') { + res.writeHead(302, { Location: '/api/files/cached-intro.wav' }) + return res.end() + } if (path.endsWith('music-b.wav') && holdTrack) { trackRequested = true await new Promise((resolve) => { @@ -224,7 +237,13 @@ module.exports = async function onlineSmoke({ send({}, 404) }) await new Promise((resolve) => server.listen(0, '127.0.0.1', resolve)) - const url = `http://127.0.0.1:${server.address().port}` + const url = 'http://screen-proxy.invalid' + await session.defaultSession.setProxy({ + mode: 'fixed_servers', + proxyRules: `http=127.0.0.1:${server.address().port}`, + proxyBypassRules: '<-loopback>', + }) + await session.defaultSession.closeAllConnections() const act = (intent) => evaluate(`window.screenBridge.onlineAction(${JSON.stringify(intent)})`) try { await evaluate('window.screenBridge.closeOutput()') @@ -832,8 +851,13 @@ module.exports = async function onlineSmoke({ ), 'current local game stays visible after logout', ) + assert.ok(requests.includes('/api/auth/login'), 'login through proxy') + assert.ok(requests.includes('/api/files/cached-intro.wav'), 'redirected media through proxy') + console.log('SCREEN_PROXY_SMOKE_PASS') console.log('SCREEN_ONLINE_SMOKE_PASS', JSON.stringify({ polls, requests: requests.length })) } finally { + await session.defaultSession.setProxy({ mode: 'system' }) + await session.defaultSession.closeAllConnections() server.closeAllConnections() await new Promise((resolve) => server.close(resolve)) }