Initial commit

This commit is contained in:
2026-09-03 03:48:00 +03:00
commit 635c920be7
57 changed files with 14375 additions and 0 deletions
+55
View File
@@ -0,0 +1,55 @@
import { access, writeFile } from 'node:fs/promises'
import { join } from 'node:path'
import { app, dialog, type BrowserWindow } from 'electron'
import { GITHUB_URL } from '../shared/links'
import { openExternal } from './window'
/**
* Метка «приветствие уже показывали». Лежит в userData, поэтому переживает обновление
* приложения — окно показывается один раз за всё время, а не после каждой установки.
*/
function markerPath(): string {
return join(app.getPath('userData'), 'first-run.json')
}
async function alreadyShown(): Promise<boolean> {
try {
await access(markerPath())
return true
} catch {
return false
}
}
/**
* Приветственное окно при первом запуске: просьба поддержать проект звёздочкой.
*
* Рисует его сама ОС (`dialog.showMessageBox`), а не HTML-страница: так окно получается
* маленьким, модальным к главному и выглядит системным, а не как ещё один экран приложения.
*/
export async function showFirstRunSupportDialog(window: BrowserWindow): Promise<void> {
if (await alreadyShown()) return
// Метку ставим ДО показа, а не после ответа: если приложение закроют прямо на диалоге,
// лучше не показать его больше никогда, чем спрашивать при каждом запуске.
try {
await writeFile(markerPath(), JSON.stringify({ shownAt: new Date().toISOString() }), 'utf8')
} catch {
// Не смогли записать метку — покажем окно ещё раз в следующий запуск. Не повод падать.
}
const { response } = await dialog.showMessageBox(window, {
type: 'info',
buttons: ['Перейти', 'Закрыть'],
defaultId: 0,
cancelId: 1,
noLink: true,
title: 'Поддержите RKNboost',
message: 'Поддержите проект',
detail: 'Поддержите проект звездочкой на GitHub, и расскажите друзьям :З'
})
// openExternal (window.ts) проверяет протокол по белому списку — ссылка уходит
// в системный браузер, а не открывается внутри приложения.
if (response === 0) openExternal(GITHUB_URL)
}
+77
View File
@@ -0,0 +1,77 @@
import { app, BrowserWindow } from 'electron'
import { electronApp, optimizer } from '@electron-toolkit/utils'
import { createWindow } from './window'
import { showFirstRunSupportDialog } from './first-run'
import { registerIpcHandlers } from './ipc'
import { disposeZapret, recoverZapret } from './zapret'
import { elevatedTaskDir, runElevatedServiceTask } from './zapret/elevated-task.win32'
const APP_ID = 'com.rknboost.app'
// Приложение может быть запущено само собой через UAC как «повышенный помощник» — тогда
// оно не открывает окно и не занимает единственный экземпляр (иначе отдало бы фокус уже
// запущенной обычной копии и вышло, не сделав работу), а выполняет задачу службы и выходит.
const elevatedTask = process.platform === 'win32' ? elevatedTaskDir(process.argv) : null
if (elevatedTask) {
void app.whenReady().then(async () => {
app.exit(await runElevatedServiceTask(elevatedTask))
})
} else if (!app.requestSingleInstanceLock()) {
// Одна копия приложения на систему: второй запуск отдаёт фокус уже открытому окну.
app.quit()
} else {
app.on('second-instance', () => {
const [window] = BrowserWindow.getAllWindows()
if (!window) {
createWindow()
return
}
if (window.isMinimized()) window.restore()
window.focus()
})
app.whenReady().then(() => {
// Нужно Windows для группировки в панели задач и уведомлений.
electronApp.setAppUserModelId(APP_ID)
// F12 открывает DevTools в dev-режиме, Ctrl/Cmd+R не перезагружает прод-сборку.
app.on('browser-window-created', (_event, window) => {
optimizer.watchWindowShortcuts(window)
})
registerIpcHandlers()
// На macOS чинит прокси, оставшийся от аварийно завершённого прошлого запуска,
// до того как окно вообще откроется.
void recoverZapret().then(() => {
const window = createWindow()
// Ждём первого кадра: диалог должен появиться поверх готового окна, а не пустого.
window.once('ready-to-show', () => void showFirstRunSupportDialog(window))
})
// macOS: клик по иконке в доке при закрытых окнах открывает окно заново.
app.on('activate', () => {
if (BrowserWindow.getAllWindows().length === 0) createWindow()
})
})
// Windows / Linux: закрыли последнее окно — вышли. macOS: приложение живёт до Cmd+Q.
app.on('window-all-closed', () => {
if (process.platform !== 'darwin') {
app.quit()
}
})
// Гасим tpws/winws и откатываем системный прокси до фактического выхода —
// иначе процесс останется висеть, а на macOS ещё и интернет пропадёт.
let zapretCleaned = false
app.on('before-quit', (event) => {
if (zapretCleaned) return
event.preventDefault()
void disposeZapret().finally(() => {
zapretCleaned = true
app.quit()
})
})
}
+78
View File
@@ -0,0 +1,78 @@
import { BrowserWindow, ipcMain, nativeTheme, type IpcMainInvokeEvent } from 'electron'
import type { IpcChannel, IpcHandlers, ThemeState } from '../shared/ipc-contract'
import { uninstallApp } from './uninstall.win32'
import {
clearZapretAutoHostlist,
resetZapretList,
saveZapretList,
setZapretAutoStart,
setZapretStrategy,
setZapretSystemProxy,
startZapret,
stopZapret,
zapretAutoHostlist,
zapretDiagnostics,
zapretLists,
zapretStatus,
zapretStrategies
} from './zapret'
/**
* Обёртка над `ipcMain.handle`, которая сверяет имя канала, аргументы и результат
* с контрактом из `src/shared/ipc-contract.ts`.
*/
function handle<K extends IpcChannel>(
channel: K,
handler: (
event: IpcMainInvokeEvent,
...args: Parameters<IpcHandlers[K]>
) => ReturnType<IpcHandlers[K]> | Promise<ReturnType<IpcHandlers[K]>>
): void {
ipcMain.handle(channel, handler as Parameters<typeof ipcMain.handle>[1])
}
/** Окно, из которого пришёл запрос (а не «первое попавшееся») — для диалога подтверждения. */
function windowOf(event: IpcMainInvokeEvent): BrowserWindow | null {
return BrowserWindow.fromWebContents(event.sender)
}
function themeState(): ThemeState {
return {
source: nativeTheme.themeSource,
shouldUseDarkColors: nativeTheme.shouldUseDarkColors
}
}
export function registerIpcHandlers(): void {
handle('theme:get', () => themeState())
// Кнопка «Удалить приложение» есть в интерфейсе только на Windows (SettingsView.tsx),
// но канал регистрируем всегда — так и должно быть по контракту, просто на других
// платформах он никогда не вызывается.
handle('app:uninstall', (event) => {
if (process.platform === 'win32') void uninstallApp(windowOf(event))
})
handle('zapret:status', () => zapretStatus())
handle('zapret:strategies', () => zapretStrategies())
handle('zapret:start', (_event, strategyId) => startZapret(strategyId))
handle('zapret:stop', () => stopZapret())
handle('zapret:set-system-proxy', (_event, enabled) => setZapretSystemProxy(enabled))
handle('zapret:set-strategy', (_event, strategyId) => setZapretStrategy(strategyId))
handle('zapret:set-autostart', (_event, enabled) => setZapretAutoStart(enabled))
handle('zapret:lists', () => zapretLists())
handle('zapret:list-save', (_event, id, entries) => saveZapretList(id, entries))
handle('zapret:list-reset', (_event, id) => resetZapretList(id))
handle('zapret:auto-hostlist', () => zapretAutoHostlist())
handle('zapret:auto-hostlist-clear', () => clearZapretAutoHostlist())
handle('zapret:diagnose', () => zapretDiagnostics())
// Системная тема поменялась (например, пользователь включил тёмный режим в ОС) —
// сообщаем всем открытым окнам.
nativeTheme.on('updated', () => {
for (const window of BrowserWindow.getAllWindows()) {
window.webContents.send('theme:changed', themeState())
}
})
}
+145
View File
@@ -0,0 +1,145 @@
import { execFile, spawn } from 'node:child_process'
import { promisify } from 'node:util'
import {
app,
dialog,
type BrowserWindow,
type MessageBoxOptions,
type MessageBoxReturnValue
} from 'electron'
const run = promisify(execFile)
/** dialog.showMessageBox без родительского окна — отдельная перегрузка, без окна её и зовём. */
function showDialog(
window: BrowserWindow | null,
options: MessageBoxOptions
): Promise<MessageBoxReturnValue> {
return window ? dialog.showMessageBox(window, options) : dialog.showMessageBox(options)
}
/**
* Установщик (per-user, `perMachine: false` в electron-builder.yml) регистрирует
* деинсталлятор в HKCU, не в HKLM — читать оттуда можно без прав администратора.
* Ищем по DisplayName, а не по фиксированному GUID/пути: имя ключа реестра
* electron-builder генерирует детерминированно из `appId`, но полагаться на точное
* значение — лишний повод сломаться при следующей пересборке.
*/
interface UninstallCommand {
command: string
args: string[]
}
/**
* `UninstallString` записывает сам electron-builder (templates/nsis/include/installer.nsh:122)
* строкой `'"$2" $0'`, где `$2` — путь к деинсталлятору, а `$0` — `/currentuser` для
* per-user установки (или `/allusers`). То есть путь в кавычках И аргумент после него:
*
* "C:\Users\...\AppData\Local\Programs\RKNboost\Uninstall RKNboost.exe" /currentuser
*
* Аргумент обязателен: без него деинсталлятор не знает, из какой ветки реестра сниматься.
* Разбираем строку на команду и аргументы, а не пытаемся скормить её `spawn` целиком —
* иначе весь текст вместе с кавычками уходит в имя файла и получается ENOENT.
*/
function parseUninstallString(raw: string): UninstallCommand | null {
const trimmed = raw.trim()
if (!trimmed) return null
const quoted = /^"([^"]+)"\s*(.*)$/.exec(trimmed)
if (quoted) {
return { command: quoted[1], args: quoted[2].split(/\s+/).filter(Boolean) }
}
// Без кавычек путь бывает только если в нём нет пробелов — тогда первое слово и есть команда.
const [command, ...args] = trimmed.split(/\s+/)
return command ? { command, args } : null
}
/**
* Установщик (per-user, `perMachine: false` в electron-builder.yml) регистрирует
* деинсталлятор в HKCU, не в HKLM — читать оттуда можно без прав администратора.
* Ищем по DisplayName, а не по фиксированному GUID/пути: имя ключа реестра
* electron-builder генерирует детерминированно из `appId`, но полагаться на точное
* значение — лишний повод сломаться при следующей пересборке.
*/
async function findUninstallCommand(): Promise<UninstallCommand | null> {
const script =
"Get-ChildItem 'HKCU:\\Software\\Microsoft\\Windows\\CurrentVersion\\Uninstall' " +
'| Get-ItemProperty ' +
"| Where-Object { $_.DisplayName -eq 'RKNboost' } " +
'| Select-Object -First 1 -ExpandProperty UninstallString'
let stdout: string
try {
;({ stdout } = await run('powershell.exe', ['-NoProfile', '-Command', script]))
} catch {
return null
}
return parseUninstallString(stdout)
}
/**
* `spawn` сообщает о ненайденном файле не исключением, а событием `error` на уже
* созданном объекте — без этого обработчика ENOENT всплывал бы необработанным
* отказом промиса в main-процессе. Выходим только после события `spawn`, то есть
* когда деинсталлятор точно стартовал: закрыться раньше значит не закрыться вовсе.
*/
function launchUninstaller({ command, args }: UninstallCommand): Promise<void> {
return new Promise((resolve, reject) => {
// detached: деинсталлятор должен пережить закрытие текущего процесса.
const child = spawn(command, args, { detached: true, stdio: 'ignore' })
child.once('error', reject)
child.once('spawn', () => {
child.unref()
resolve()
})
})
}
/**
* Подтверждение нативным диалогом ОС (не веб-страницей — так надёжнее для
* действия, которое стирает и приложение, и списки сайтов пользователя),
* затем запуск деинсталлятора и выход — деинсталлятор не может удалить
* запущенное приложение.
*/
export async function uninstallApp(window: BrowserWindow | null): Promise<void> {
const { response } = await showDialog(window, {
type: 'warning',
buttons: ['Отмена', 'Удалить'],
defaultId: 0,
cancelId: 0,
noLink: true,
title: 'Удалить RKNboost',
message: 'Удалить RKNboost с этого компьютера?',
detail:
'Приложение закроется и запустится стандартный деинсталлятор Windows. ' +
'Вместе с ним будут удалены списки сайтов и служба обхода блокировок. ' +
'Windows может запросить права администратора.'
})
if (response !== 1) return
const uninstall = await findUninstallCommand()
if (!uninstall) {
await showDialog(window, {
type: 'error',
title: 'Не удалось найти деинсталлятор',
message: 'Удалите приложение вручную через «Параметры Windows → Приложения».'
})
return
}
try {
await launchUninstaller(uninstall)
} catch (error) {
await showDialog(window, {
type: 'error',
title: 'Не удалось запустить деинсталлятор',
message: 'Удалите приложение вручную через «Параметры Windows → Приложения».',
detail: error instanceof Error ? error.message : String(error)
})
return
}
app.quit()
}
+77
View File
@@ -0,0 +1,77 @@
import { BrowserWindow, shell } from 'electron'
import { join } from 'node:path'
import { is } from '@electron-toolkit/utils'
import icon from '../../resources/icon.png?asset'
import { ALLOWED_EXTERNAL_PROTOCOLS } from '../shared/ipc-contract'
function protocolOf(url: string): string | null {
try {
return new URL(url).protocol
} catch {
return null
}
}
/** Открыть ссылку во внешнем браузере, но только если протокол в белом списке. */
export function openExternal(url: string): void {
const protocol = protocolOf(url)
if (protocol && (ALLOWED_EXTERNAL_PROTOCOLS as readonly string[]).includes(protocol)) {
void shell.openExternal(url)
} else {
console.warn(`[security] заблокирована внешняя ссылка: ${url}`)
}
}
/** Главное (и единственное) окно приложения. */
export function createWindow(): BrowserWindow {
const window = new BrowserWindow({
width: 440,
height: 660,
minWidth: 360,
// С запасом на самое высокое обычное состояние главного экрана (ошибка + три
// результата проверки), чтобы полоса прокрутки в карточке не появлялась на ровном месте.
minHeight: 560,
show: false,
autoHideMenuBar: true,
backgroundColor: '#1a1611',
// Иконка нужна только Linux — на macOS и Windows её берут из собранного бандла.
...(process.platform === 'linux' ? { icon } : {}),
webPreferences: {
preload: join(__dirname, '../preload/index.js'),
// Значения по умолчанию у Electron уже безопасные, но фиксируем их явно,
// чтобы случайная правка была заметна в ревью.
sandbox: true,
contextIsolation: true,
nodeIntegration: false,
webSecurity: true
}
})
// Показываем окно только когда отрисован первый кадр — без «белой вспышки».
window.on('ready-to-show', () => window.show())
// window.open(...) и target="_blank" уходят в системный браузер, а не в новое окно Electron.
window.webContents.setWindowOpenHandler(({ url }) => {
openExternal(url)
return { action: 'deny' }
})
// Запрещаем renderer'у уходить с локальной страницы (защита от навигации на чужой origin).
window.webContents.on('will-navigate', (event, url) => {
const devServerUrl = process.env['ELECTRON_RENDERER_URL']
const isDevServer = Boolean(devServerUrl && url.startsWith(devServerUrl))
if (!isDevServer && !url.startsWith('file://')) {
event.preventDefault()
openExternal(url)
}
})
if (is.dev && process.env['ELECTRON_RENDERER_URL']) {
void window.loadURL(process.env['ELECTRON_RENDERER_URL'])
} else {
void window.loadFile(join(__dirname, '../renderer/index.html'))
}
return window
}
+205
View File
@@ -0,0 +1,205 @@
import { connect as netConnect, type Socket } from 'node:net'
import { connect as tlsConnect } from 'node:tls'
import { net } from 'electron'
import type { ZapretDiagnosticResult, ZapretStatus } from '../../shared/ipc-contract'
// 6 секунд не хватало: десинк с `--dpi-desync-repeats` заметно удлиняет установку соединения,
// и медленный, но живой сайт успевал попасть в «Недоступно».
const TIMEOUT_MS = 10000
const TARGET_PORT = 443
const RETRY_DELAY_MS = 300
interface DiagnosticTarget {
id: string
label: string
host: string
}
/** Сайты, которые нужно проверять в первую очередь — по просьбе пользователя. */
const TARGETS: DiagnosticTarget[] = [
{ id: 'instagram', label: 'Instagram', host: 'instagram.com' },
{ id: 'discord', label: 'Discord', host: 'discord.com' },
{ id: 'youtube', label: 'YouTube', host: 'youtube.com' }
]
/**
* Расшифровка кодов Chromium: без неё в интерфейсе у всех трёх сайтов одинаковое
* «Недоступно», и по нему невозможно отличить «DPI режет» от «домен не резолвится»
* или «антивирус перехватывает HTTPS» — а лечатся они совершенно по-разному.
*/
const ERROR_HINTS: [pattern: RegExp, hint: string][] = [
[/ERR_NAME_NOT_RESOLVED|ERR_NAME_RESOLUTION_FAILED/, 'домен не резолвится — блокировка по DNS'],
[/ERR_INTERNET_DISCONNECTED|ERR_NETWORK_CHANGED/, 'нет подключения к интернету'],
[/ERR_CONNECTION_RESET|ERR_CONNECTION_CLOSED/, 'соединение сброшено — похоже на DPI'],
[/ERR_CONNECTION_TIMED_OUT|ERR_TIMED_OUT/, 'таймаут — пакеты теряются молча'],
[/ERR_CONNECTION_REFUSED/, 'соединение отклонено'],
[/ERR_CERT_|ERR_SSL_/, 'сертификат не принят — вероятно, антивирус перехватывает HTTPS'],
[/ERR_PROXY_/, 'мешает системный прокси']
]
function describeNetError(message: string): string {
const code = /net::(ERR_[A-Z_]+)/.exec(message)?.[1] ?? message
const hint = ERROR_HINTS.find(([pattern]) => pattern.test(message))?.[1]
return hint ? `${hint} (${code})` : code
}
/**
* SOCKS5 CONNECT без авторизации — ровно то, что понимает tpws в режиме `--socks`.
* Три сообщения: приветствие (метод «без авторизации»), запрос на домен, ответ сервера.
*/
function socksConnect(socket: Socket, host: string, port: number): Promise<void> {
return new Promise((resolve, reject) => {
const hostBuf = Buffer.from(host, 'utf8')
socket.once('data', (greeting: Buffer) => {
if (greeting[0] !== 0x05 || greeting[1] !== 0x00) {
reject(new Error('SOCKS5: сервер отклонил метод авторизации'))
return
}
socket.once('data', (reply: Buffer) => {
if (reply[1] !== 0x00) {
reject(new Error(`SOCKS5: код ошибки 0x${reply[1].toString(16)}`))
return
}
resolve()
})
socket.write(
Buffer.concat([
Buffer.from([0x05, 0x01, 0x00, 0x03, hostBuf.length]),
hostBuf,
Buffer.from([port >> 8, port & 0xff])
])
)
})
socket.write(Buffer.from([0x05, 0x01, 0x00]))
})
}
/**
* Прямая проверка — путь для Windows (десинк общесистемный, через WinDivert) и для
* выключенного обхода.
*
* Намеренно через `net` Electron, то есть через сетевой стек Chromium, а НЕ через
* `node:tls`. Тест должен отвечать на вопрос «откроется ли этот сайт в браузере», а
* Node-стек отвечает на другой: у него собственный список корневых сертификатов (мимо
* хранилища Windows — и любой антивирус с проверкой HTTPS ломает проверку на ровном
* месте), собственный резолвер (мимо DoH) и заметно более простой ClientHello, чем у
* браузера, — а стратегии zapret настраивают именно под браузерный. Из-за этого проверка
* показывала «Недоступно» на всех трёх сайтах, которые в браузере при включённом обходе
* открывались нормально.
*/
function checkDirect(host: string): Promise<number> {
return new Promise((resolve, reject) => {
const start = Date.now()
// HEAD, а не GET: нас интересует только сам факт, что до сайта дошли и он ответил,
// тело страницы качать незачем. redirect: 'manual' — редирект (например,
// instagram.com → www.instagram.com) это тоже ответ, гнаться за ним не нужно.
const request = net.request({ method: 'HEAD', url: `https://${host}/`, redirect: 'manual' })
let settled = false
const finish = (error: Error | null): void => {
if (settled) return
settled = true
clearTimeout(timer)
request.abort()
if (error) reject(new Error(describeNetError(error.message)))
else resolve(Date.now() - start)
}
const timer = setTimeout(
() => finish(new Error(`net::ERR_TIMED_OUT ${TIMEOUT_MS} мс`)),
TIMEOUT_MS
)
request.on('response', () => finish(null))
request.on('redirect', () => finish(null))
request.on('error', finish)
request.end()
})
}
/** Тот же успех, но через локальный SOCKS5 tpws — путь для macOS, пока обход включён. */
function checkViaSocks(host: string, socksHost: string, socksPort: number): Promise<number> {
return new Promise((resolve, reject) => {
const start = Date.now()
const socket = netConnect({ host: socksHost, port: socksPort })
let settled = false
const finish = (error: Error | null): void => {
if (settled) return
settled = true
socket.destroy()
if (error) reject(error)
else resolve(Date.now() - start)
}
socket.setTimeout(TIMEOUT_MS, () => finish(new Error(`Таймаут ${TIMEOUT_MS} мс`)))
socket.once('error', finish)
socket.once('connect', () => {
socksConnect(socket, host, TARGET_PORT)
.then(() => {
const tlsSocket = tlsConnect({ socket, servername: host })
tlsSocket.once('secureConnect', () => finish(null))
tlsSocket.once('error', finish)
})
.catch(finish)
})
})
}
function delay(ms: number): Promise<void> {
return new Promise((resolve) => setTimeout(resolve, ms))
}
/**
* Одна повторная попытка. Десинк срабатывает не всегда с первого соединения, и браузер это
* скрывает: он переустанавливает соединение сам, а пользователь видит только итог — сайт
* открылся. Проверка в одну попытку получалась строже браузера и писала «Недоступно» там,
* где сайт на самом деле открывается. Ошибку второй попытки отдаём наружу как есть — если
* не сработало дважды, показать причину важнее, чем спрятать её за повтором.
*/
async function withRetry(attempt: () => Promise<number>): Promise<number> {
try {
return await attempt()
} catch {
await delay(RETRY_DELAY_MS)
return attempt()
}
}
/**
* Успех означает «до сайта дошли и он ответил» — этого достаточно, чтобы отличить
* «DPI оборвал соединение» от «соединение установилось», разбирать ответ незачем.
*
* `status.socksAddress` заполнен только на macOS и только пока обход запущен — во всех
* остальных случаях (Windows, обход выключен, платформа не поддерживается) бьём напрямую:
* на Windows десинк уже общесистемный через WinDivert, а без обхода тест просто показывает
* то, что есть сейчас.
*/
export async function runDiagnostics(status: ZapretStatus): Promise<ZapretDiagnosticResult[]> {
const socksMatch = status.socksAddress ? /^(.+):(\d+)$/.exec(status.socksAddress) : null
return Promise.all(
TARGETS.map(async (target) => {
const attempt = (): Promise<number> =>
socksMatch
? checkViaSocks(target.host, socksMatch[1], Number(socksMatch[2]))
: checkDirect(target.host)
try {
const ms = await withRetry(attempt)
return { ...target, ok: true, ms, error: null }
} catch (error) {
return {
...target,
ok: false,
ms: null,
error: error instanceof Error ? error.message : String(error)
}
}
})
)
}
+90
View File
@@ -0,0 +1,90 @@
import { execFile } from 'node:child_process'
import { join } from 'node:path'
import { promisify } from 'node:util'
const run = promisify(execFile)
/**
* Аргумент, по которому приложение понимает, что его запустили не как приложение,
* а как «повышенного помощника»: отработать задачу службы и выйти, без окна.
*/
export const ELEVATED_TASK_FLAG = '--elevated-service-task'
/**
* Имена файлов внутри временного каталога задачи. Каталог готовит неповышенная сторона
* (`runElevated` в service.win32.ts), читает — повышенная, поэтому имена держим в одном
* месте, а не дублируем строками с обеих сторон.
*/
export const TASK_FILES = {
worker: 'worker.ps1',
payload: 'payload.json',
result: 'result.json'
} as const
let elevationCheck: Promise<boolean> | null = null
/**
* Запущены ли мы уже от администратора. В собранном приложении это всегда так
* (`requestedExecutionLevel: requireAdministrator` в electron-builder.yml), но проверяем
* фактически, а не по предположению: в dev-режиме манифеста нет, а от неверного ответа
* зависит, ждать пользователю запрос UAC или нет. Ответ не меняется в течение работы
* процесса, поэтому считаем его один раз.
*/
export function isElevated(): Promise<boolean> {
elevationCheck ??= run('powershell.exe', [
'-NoProfile',
'-Command',
'[Security.Principal.WindowsPrincipal]::new(' +
'[Security.Principal.WindowsIdentity]::GetCurrent()' +
').IsInRole([Security.Principal.WindowsBuiltInRole]::Administrator)'
])
.then(({ stdout }) => stdout.trim().toLowerCase() === 'true')
// Не смогли выяснить — считаем, что прав нет: лишний запрос UAC не страшен,
// а вот молча провалившаяся настройка службы — страшна.
.catch(() => false)
return elevationCheck
}
/** Каталог задачи из argv, если приложение запустили как помощника. Иначе null. */
export function elevatedTaskDir(argv: readonly string[]): string | null {
const index = argv.indexOf(ELEVATED_TASK_FLAG)
if (index === -1) return null
return argv[index + 1] ?? null
}
/**
* Выполняется уже с правами администратора: UAC запросили на нас самих, поэтому
* дочернему powershell повышаться не нужно — он наследует права.
*
* Смысл всей этой конструкции в том, ЧТО пользователь видит в окне UAC. Раньше приложение
* повышало powershell.exe, и Windows честно показывала «Windows PowerShell» с подписью
* Microsoft — запрос выглядел так, будто права просит не то, что запросил пользователь.
* Теперь повышается сам RKNboost.exe, и в запросе стоит имя приложения.
*
* Сам worker.ps1 при этом не изменился — вся логика sc/icacls осталась ровно та же,
* поменялось только то, кого повышают.
*/
export async function runElevatedServiceTask(taskDir: string): Promise<number> {
try {
await run(
'powershell.exe',
[
'-NoProfile',
'-ExecutionPolicy',
'Bypass',
'-File',
join(taskDir, TASK_FILES.worker),
'-PayloadPath',
join(taskDir, TASK_FILES.payload),
'-ResultPath',
join(taskDir, TASK_FILES.result)
],
{ windowsHide: true }
)
return 0
} catch (error) {
const code = (error as { code?: unknown } | undefined)?.code
return typeof code === 'number' ? code : 1
}
}
+243
View File
@@ -0,0 +1,243 @@
import { spawn, type ChildProcessByStdio } from 'node:child_process'
import type { Readable } from 'node:stream'
import { createConnection, createServer } from 'node:net'
import type { ZapretStatus } from '../../shared/ipc-contract'
import { ZapretEngine } from './engine'
import { assertExecutable, tpwsPath } from './paths'
import { applySystemProxy, hasStaleSnapshot, restoreSystemProxy } from './proxy.darwin'
import { defaultStrategyId, tpwsArgs } from './strategies'
const HOST = '127.0.0.1'
const PREFERRED_PORT = 1080
const READY_TIMEOUT_MS = 3000
const STOP_TIMEOUT_MS = 3000
/** Свободен ли порт на localhost. */
function isPortFree(port: number): Promise<boolean> {
return new Promise((resolve) => {
const server = createServer()
server.once('error', () => resolve(false))
server.listen(port, HOST, () => server.close(() => resolve(true)))
})
}
async function pickPort(): Promise<number> {
for (let port = PREFERRED_PORT; port < PREFERRED_PORT + 20; port += 1) {
if (await isPortFree(port)) return port
}
throw new Error('Не нашлось свободного порта для socks-прокси.')
}
/** У tpws нет сигнала готовности — просто ждём, пока порт начнёт принимать соединения. */
function canConnect(port: number): Promise<boolean> {
return new Promise((resolve) => {
const socket = createConnection({ host: HOST, port })
const finish = (ok: boolean): void => {
socket.destroy()
resolve(ok)
}
socket.once('connect', () => finish(true))
socket.once('error', () => finish(false))
})
}
async function waitUntilReady(port: number, isAlive: () => boolean): Promise<void> {
const deadline = Date.now() + READY_TIMEOUT_MS
while (Date.now() < deadline) {
if (!isAlive()) return // процесс уже умер, ошибку соберёт вызывающий код
if (await canConnect(port)) return
await new Promise((resolve) => setTimeout(resolve, 100))
}
throw new Error(`tpws не начал слушать ${HOST}:${port} за ${READY_TIMEOUT_MS} мс.`)
}
/**
* macOS: tpws поднимается обычным пользовательским процессом в режиме socks5
* (документация zapret: «Режим --socks не требует повышенных привилегий»),
* а трафик заворачивается системной настройкой прокси.
*
* Важное ограничение платформы: tpws не умеет UDP, поэтому QUIC не обходится.
* Практика это скрадывает — при системном socks-прокси браузеры сами уходят с QUIC на TCP.
*/
export class DarwinEngine extends ZapretEngine {
private child: ChildProcessByStdio<null, Readable, Readable> | null = null
private port = PREFERRED_PORT
/** Просили ли включить системный прокси. Переживает перезапуск tpws. */
private wantSystemProxy = true
constructor() {
super('tpws-socks', defaultStrategyId('darwin'))
}
/**
* Прошлый запуск мог не снять прокси (например, kill -9). Тогда система смотрит
* в мёртвый socks и интернета нет вовсе — чиним до того, как показать окно.
*/
async recoverFromCrash(): Promise<void> {
if (!(await hasStaleSnapshot())) return
this.appendLog(
'info',
'Найдены незакрытые настройки прокси от прошлого запуска — восстанавливаю.'
)
try {
await restoreSystemProxy()
} catch (error) {
this.appendLog('error', error instanceof Error ? error.message : String(error))
}
}
async start(strategyId: string): Promise<ZapretStatus> {
if (this.child) await this.stop()
const binary = tpwsPath()
await assertExecutable(binary)
this.patch({ state: 'starting', strategyId, error: null })
try {
this.port = await pickPort()
const child = spawn(binary, tpwsArgs(strategyId, this.port), {
stdio: ['ignore', 'pipe', 'pipe']
})
this.child = child
// Вывод tpws идёт в stderr целиком, включая обычные сообщения о старте.
child.stdout.setEncoding('utf8')
child.stderr.setEncoding('utf8')
child.stdout.on('data', (chunk: string) => this.appendLog('info', chunk))
let stderrTail = ''
child.stderr.on('data', (chunk: string) => {
stderrTail = `${stderrTail}${chunk}`.slice(-2000)
this.appendLog('info', chunk)
})
child.on('exit', (code, signal) => {
this.child = null
// Плановую остановку обрабатывает stop(), здесь ловим только падение.
if (this.status.state === 'stopping' || this.status.state === 'stopped') return
void this.handleCrash(code, signal, stderrTail)
})
await waitUntilReady(this.port, () => this.child !== null)
if (!this.child) {
throw new Error(stderrTail.trim() || 'tpws завершился сразу после запуска.')
}
if (this.wantSystemProxy) {
await applySystemProxy(HOST, this.port)
}
return this.patch({
state: 'running',
startedAt: new Date().toISOString(),
error: null,
socksAddress: `${HOST}:${this.port}`,
systemProxyApplied: this.wantSystemProxy
})
} catch (error) {
await this.cleanup()
const message = error instanceof Error ? error.message : String(error)
this.appendLog('error', message)
return this.patch({
state: 'error',
error: message,
startedAt: null,
socksAddress: null,
systemProxyApplied: false
})
}
}
async stop(): Promise<ZapretStatus> {
if (!this.child && !this.status.systemProxyApplied) {
return this.patch({ state: 'stopped', startedAt: null, socksAddress: null })
}
this.patch({ state: 'stopping' })
await this.cleanup()
return this.patch({
state: 'stopped',
startedAt: null,
error: null,
socksAddress: null,
systemProxyApplied: false
})
}
async setSystemProxy(enabled: boolean): Promise<ZapretStatus> {
this.wantSystemProxy = enabled
// Пока обход выключен, менять нечего — запомнили выбор и всё.
if (this.status.state !== 'running') return this.patch({ systemProxyApplied: false })
if (enabled) {
await applySystemProxy(HOST, this.port)
} else {
await restoreSystemProxy()
}
return this.patch({ systemProxyApplied: enabled })
}
async dispose(): Promise<void> {
await this.cleanup()
}
/**
* Порядок важен: сначала снимаем прокси, потом гасим tpws. Наоборот нельзя —
* между шагами система осталась бы с прокси, который уже никто не слушает.
*/
private async cleanup(): Promise<void> {
try {
await restoreSystemProxy()
} catch (error) {
this.appendLog('error', error instanceof Error ? error.message : String(error))
}
const child = this.child
if (!child) return
this.child = null
child.kill('SIGTERM')
await new Promise<void>((resolve) => {
const timer = setTimeout(() => {
child.kill('SIGKILL')
resolve()
}, STOP_TIMEOUT_MS)
child.once('exit', () => {
clearTimeout(timer)
resolve()
})
})
}
private async handleCrash(
code: number | null,
signal: NodeJS.Signals | null,
stderrTail: string
): Promise<void> {
const reason = signal ? `сигнал ${signal}` : `код ${code}`
const details = stderrTail.trim().split('\n').slice(-3).join(' ')
// Прокси обязательно снять: иначе система указывает на мёртвый socks и сети не будет.
try {
await restoreSystemProxy()
} catch (error) {
this.appendLog('error', error instanceof Error ? error.message : String(error))
}
this.patch({
state: 'error',
error: `tpws неожиданно завершился (${reason}). ${details}`.trim(),
startedAt: null,
socksAddress: null,
systemProxyApplied: false
})
}
}
+114
View File
@@ -0,0 +1,114 @@
import { BrowserWindow } from 'electron'
import type {
ZapretBackend,
ZapretLogLine,
ZapretStatus,
ZapretState
} from '../../shared/ipc-contract'
/** Сколько строк вывода бинарника держим для UI. */
const LOG_LIMIT = 200
/** Общая часть обоих движков: хранение статуса и рассылка событий в окна. */
export abstract class ZapretEngine {
protected status: ZapretStatus
private readonly log: ZapretLogLine[] = []
constructor(backend: ZapretBackend, defaultStrategyId: string) {
this.status = {
state: 'stopped',
backend,
strategyId: defaultStrategyId,
startedAt: null,
error: null,
socksAddress: null,
systemProxyApplied: false,
serviceInstalled: false,
autoStart: false
}
}
getStatus(): ZapretStatus {
return { ...this.status }
}
getLog(): ZapretLogLine[] {
return [...this.log]
}
abstract start(strategyId: string): Promise<ZapretStatus>
abstract stop(): Promise<ZapretStatus>
/** Вызывается при выходе из приложения: вернуть систему в исходное состояние. */
abstract dispose(): Promise<void>
/** macOS-специфика; на других платформах переопределять не нужно. */
async setSystemProxy(enabled: boolean): Promise<ZapretStatus> {
void enabled
throw new Error('Системный прокси на этой платформе не настраивается.')
}
/**
* Выключен — просто запоминаем выбор (никаких системных действий и никакого UAC).
* Работает — перезапускаем с новой стратегией через `start()`; на Windows тот сам
* остановит службу перед перенастройкой (см. `Win32Engine.start()`), потому что менять
* конфигурацию работающей службы нельзя.
*/
async setStrategy(strategyId: string): Promise<ZapretStatus> {
if (this.status.state !== 'running') {
return this.patch({ strategyId })
}
return this.start(strategyId)
}
/** Windows-специфика (автозапуск службы); на других платформах переопределять не нужно. */
async setAutoStart(enabled: boolean): Promise<ZapretStatus> {
void enabled
throw new Error('Автозапуск на этой платформе не настраивается.')
}
protected patch(changes: Partial<ZapretStatus>): ZapretStatus {
this.status = { ...this.status, ...changes }
this.broadcast('zapret:status-changed', this.status)
return this.getStatus()
}
protected setState(state: ZapretState, error: string | null = null): ZapretStatus {
return this.patch({ state, error })
}
/** Вывод бинарника: копим кольцевым буфером и сразу отдаём в UI. */
protected appendLog(level: ZapretLogLine['level'], chunk: string): void {
for (const raw of chunk.split('\n')) {
const line = raw.trimEnd()
if (!line) continue
const entry: ZapretLogLine = { level, line }
this.log.push(entry)
if (this.log.length > LOG_LIMIT) this.log.shift()
this.broadcast('zapret:log', entry)
}
}
private broadcast<T>(channel: string, payload: T): void {
for (const window of BrowserWindow.getAllWindows()) {
if (!window.isDestroyed()) window.webContents.send(channel, payload)
}
}
}
/** Заглушка для платформ, под которые мы бинарники не собираем (Linux). */
export class UnsupportedEngine extends ZapretEngine {
constructor() {
super('unsupported', '')
this.status.error = 'Обход блокировок доступен только на macOS и Windows.'
}
async start(): Promise<ZapretStatus> {
throw new Error('Обход блокировок доступен только на macOS и Windows.')
}
async stop(): Promise<ZapretStatus> {
return this.getStatus()
}
async dispose(): Promise<void> {}
}
+197
View File
@@ -0,0 +1,197 @@
import { execFile } from 'node:child_process'
import { mkdtemp, readFile, rm, stat, writeFile } from 'node:fs/promises'
import { tmpdir } from 'node:os'
import { join } from 'node:path'
import { promisify } from 'node:util'
import type { ZapretStatus } from '../../shared/ipc-contract'
import { ZapretEngine } from './engine'
import { assertExecutable, protectedWinwsPath, serviceConfigPath, winwsPath } from './paths'
import {
applyServiceConfig,
queryService,
setServiceAutoStart,
startService,
stopService,
type ServiceInfo
} from './service.win32'
import { defaultStrategyId, winwsConfigFile, type WinwsConfig } from './strategies'
const run = promisify(execFile)
/** Первая строка `strategy.cfg` — из `winwsConfigFile()`, формат: `--comment=rknboost:<id>:<hash>`. */
async function readInstalledMarkerLine(): Promise<string | null> {
try {
const content = await readFile(serviceConfigPath(), 'utf8')
return content.split('\n')[0]?.trim() ?? null
} catch {
return null
}
}
/**
* Служба всегда запускает защищённую копию winws.exe (protectedWinwsPath), а не оригинал
* из папки установки приложения (winwsPath) — значит переустановку/обновление приложения
* по самому пути службы не отследить, как раньше. Вместо этого сравниваем размер и время
* изменения оригинала с уже скопированным файлом: разошлись — обновление zapret вышло
* позже последней копии, нужно копировать заново (а значит и UAC).
*/
async function binariesNeedCopy(): Promise<boolean> {
try {
const [source, copy] = await Promise.all([stat(winwsPath()), stat(protectedWinwsPath())])
return source.size !== copy.size || source.mtimeMs > copy.mtimeMs
} catch {
return true
}
}
/**
* Перенастройка (а значит и UAC) нужна, если службы ещё нет, если защищённая копия
* winws.exe отстала от оригинала в папке приложения, или если аргументы стратегии
* изменились (маркер в `strategy.cfg` не совпадает с тем, что сгенерировали сейчас).
*/
async function needsReconfigure(config: WinwsConfig, info: ServiceInfo | null): Promise<boolean> {
if (!info) return true
if (await binariesNeedCopy()) return true
return (await readInstalledMarkerLine()) !== config.marker
}
/**
* Windows: winws требует драйвер WinDivert и права администратора, поэтому работает как служба
* Windows (`rknboost-zapret`), а не как обычный дочерний процесс приложения.
*
* UAC запрашивается только при первой установке службы и при смене аргументов (новая стратегия,
* переключение автозапуска) — этим занимается `service.win32.ts`. Обычные включение/выключение
* идут через `sc start`/`sc stop`, на которые интерактивным пользователям выдаётся право при
* установке (`sc sdset`), поэтому UAC для них не нужен.
*/
export class Win32Engine extends ZapretEngine {
constructor() {
super('winws-service', defaultStrategyId('win32'))
// Решение по умолчанию для первой установки: обход должен работать и после перезагрузки,
// не дожидаясь запуска приложения. Дальше пользователь может выключить переключателем.
this.status.autoStart = true
}
/**
* Вызывается из `recoverZapret()` до открытия окна: подхватывает состояние службы,
* которая могла работать ещё при загрузке системы (автозапуск), — иначе UI показал бы
* «Выключен» при уже работающем обходе.
*/
async sync(): Promise<ZapretStatus> {
const info = await queryService().catch(() => null)
if (!info) {
return this.patch({ serviceInstalled: false, state: 'stopped', startedAt: null })
}
const installedLine = await readInstalledMarkerLine()
const match = installedLine ? /^--comment=rknboost:([^:]+):/.exec(installedLine) : null
const strategyId = match?.[1] ?? this.status.strategyId
return this.patch({
serviceInstalled: true,
autoStart: info.startMode === 'Auto',
strategyId,
state: info.state === 'Running' ? 'running' : 'stopped',
// Точный момент запуска нам недоступен — служба могла подняться ещё при загрузке
// системы, до старта приложения. Берём момент обнаружения, лишь бы поле не пустовало.
startedAt: info.state === 'Running' ? new Date().toISOString() : null,
error: null
})
}
async start(strategyId: string): Promise<ZapretStatus> {
this.patch({ state: 'starting', strategyId, error: null })
let config: WinwsConfig
try {
config = winwsConfigFile(strategyId)
} catch (error) {
const message = error instanceof Error ? error.message : String(error)
return this.patch({ state: 'error', error: message, startedAt: null })
}
try {
await assertExecutable(winwsPath())
const info = await queryService().catch(() => null)
if (await needsReconfigure(config, info)) {
// Перенастраивать работающую службу нельзя — её сначала надо остановить, иначе
// ломается всё сразу: работающий winws держит открытым свой же winws.exe, и
// Copy-Item в защищённый каталог падает на занятом файле; --dry-run упирается в
// тот же именованный mutex WinDivert («A copy of winws is already running with
// the same filter»); а sc config применяется только к СЛЕДУЮЩЕМУ запуску, так что
// служба молча продолжила бы работать со старой стратегией.
if (info?.state === 'Running') await stopService()
// --dry-run трогает mutex WinDivert, а не только парсит аргументы, поэтому зовём его
// здесь — после остановки и только когда действительно собираемся писать новый конфиг,
// а не когда просто подтверждаем уже работающую стратегию.
await this.dryRun(config.body)
await applyServiceConfig({ configBody: config.body, autoStart: this.status.autoStart })
}
const running = await startService()
return this.patch({
state: 'running',
startedAt: new Date().toISOString(),
error: null,
serviceInstalled: true,
autoStart: running?.startMode === 'Auto'
})
} catch (error) {
const message = error instanceof Error ? error.message : String(error)
this.appendLog('error', message)
return this.patch({ state: 'error', error: message, startedAt: null })
}
}
async stop(): Promise<ZapretStatus> {
this.patch({ state: 'stopping' })
try {
await stopService()
} catch (error) {
const message = error instanceof Error ? error.message : String(error)
this.appendLog('error', message)
return this.patch({ state: 'error', error: message })
}
return this.patch({ state: 'stopped', startedAt: null, error: null })
}
/**
* Только тип автозапуска службы — если она ещё не установлена, лишь запоминаем выбор
* до первого включения (никакого UAC на пустом месте).
*/
async setAutoStart(enabled: boolean): Promise<ZapretStatus> {
if (!this.status.serviceInstalled) {
return this.patch({ autoStart: enabled })
}
await setServiceAutoStart(enabled)
return this.patch({ autoStart: enabled })
}
async dispose(): Promise<void> {
// Служба живёт своей жизнью в системе — выход приложения её не останавливает.
}
/**
* `winws.exe --dry-run @<файл>` только проверяет аргументы и завершается без открытия
* WinDivert — не требует прав администратора. Ошибку в стратегии видно до запроса UAC,
* а не после него.
*/
private async dryRun(configBody: string): Promise<void> {
const dir = await mkdtemp(join(tmpdir(), 'rknboost-dryrun-'))
const configPath = join(dir, 'dry-run.cfg')
try {
await writeFile(configPath, configBody, 'utf8')
await run(winwsPath(), ['--dry-run', `@${configPath}`])
} catch (error) {
const message = error instanceof Error ? error.message : String(error)
throw new Error(`Стратегия содержит недопустимые параметры winws: ${message}`, {
cause: error
})
} finally {
await rm(dir, { recursive: true, force: true })
}
}
}
+110
View File
@@ -0,0 +1,110 @@
import { readFile, rm } from 'node:fs/promises'
import type {
ZapretDiagnosticResult,
ZapretList,
ZapretListId,
ZapretLogLine,
ZapretStatus,
ZapretStrategy
} from '../../shared/ipc-contract'
import { runDiagnostics } from './diagnostics'
import { DarwinEngine } from './engine.darwin'
import { ZapretEngine, UnsupportedEngine } from './engine'
import { Win32Engine } from './engine.win32'
import { readAllLists, resetList, seedLists, writeList } from './lists'
import { autoHostlistPath } from './paths'
import { listStrategies } from './strategies'
function createEngine(): ZapretEngine {
switch (process.platform) {
case 'darwin':
return new DarwinEngine()
case 'win32':
return new Win32Engine()
default:
return new UnsupportedEngine()
}
}
const engine = createEngine()
/**
* Готовим списки сайтов и синхронизируем состояние с системой — на macOS чиним прокси
* от прошлого аварийного завершения, на Windows подхватываем службу, которая могла работать
* ещё до открытия окна (автозапуск с системой). Оба шага нужно сделать до открытия окна.
*/
export async function recoverZapret(): Promise<void> {
await seedLists()
if (engine instanceof DarwinEngine) await engine.recoverFromCrash()
if (engine instanceof Win32Engine) await engine.sync()
}
export function zapretStatus(): ZapretStatus {
return engine.getStatus()
}
export function zapretStrategies(): ZapretStrategy[] {
return listStrategies(process.platform)
}
export function zapretLog(): ZapretLogLine[] {
return engine.getLog()
}
export function startZapret(strategyId: string): Promise<ZapretStatus> {
return engine.start(strategyId)
}
export function stopZapret(): Promise<ZapretStatus> {
return engine.stop()
}
export function setZapretSystemProxy(enabled: boolean): Promise<ZapretStatus> {
return engine.setSystemProxy(enabled)
}
export function setZapretStrategy(strategyId: string): Promise<ZapretStatus> {
return engine.setStrategy(strategyId)
}
export function setZapretAutoStart(enabled: boolean): Promise<ZapretStatus> {
return engine.setAutoStart(enabled)
}
/** Вызывать при выходе из приложения — гасит процесс и откатывает системные настройки. */
export function disposeZapret(): Promise<void> {
return engine.dispose()
}
export function zapretLists(): Promise<ZapretList[]> {
return readAllLists()
}
export function saveZapretList(id: ZapretListId, entries: string[]): Promise<ZapretList> {
return writeList(id, entries)
}
export function resetZapretList(id: ZapretListId): Promise<ZapretList> {
return resetList(id)
}
/** Домены, которые zapret нашёл сам через `--hostlist-auto` (только macOS-профили). */
export async function zapretAutoHostlist(): Promise<string[]> {
try {
const content = await readFile(autoHostlistPath(), 'utf8')
return content
.split('\n')
.map((line) => line.trim())
.filter(Boolean)
} catch {
return []
}
}
export async function clearZapretAutoHostlist(): Promise<void> {
await rm(autoHostlistPath(), { force: true })
}
export function zapretDiagnostics(): Promise<ZapretDiagnosticResult[]> {
return runDiagnostics(engine.getStatus())
}
+151
View File
@@ -0,0 +1,151 @@
import { createHash, randomUUID } from 'node:crypto'
import { mkdir, readFile, rename, writeFile } from 'node:fs/promises'
import { join } from 'node:path'
import type { ZapretList, ZapretListId } from '../../shared/ipc-contract'
import { bundledListsDir, userListsDir } from './paths'
/**
* Списки сайтов — данные, а не аргументы запуска. Пользователь правит их
* полностью свободно (это его решение), включая встроенные из Flowseal.
* Рабочая копия лежит в userData и переживает обновление приложения; встроенная
* версия в `resources/zapret/lists` только читается — от неё берём исходное
* содержимое при первом запуске и при «Сбросить к встроенному».
*
* Важно: и tpws, и winws сами перечитывают список, если у файла изменились
* mtime или размер («automatically reload lists if their modification time or
* file size is changed», docs/readme zapret) — перезапускать обход после правки
* списка не нужно. Чтобы это гарантированно сработало (а не поймать читателя
* на файле, который ещё не дописан), пишем во временный файл и делаем `rename` —
* это меняет mtime одним атомарным шагом.
*/
const FILE_NAMES: Record<ZapretListId, string> = {
general: 'general.txt',
google: 'google.txt',
exclude: 'exclude.txt',
'ipset-exclude': 'ipset-exclude.txt',
'ipset-all': 'ipset-all.txt'
}
export const LIST_IDS = Object.keys(FILE_NAMES) as ZapretListId[]
/** Списки доменов (--hostlist=...) — в отличие от ipset-exclude/ipset-all, где строки это IP/CIDR. */
const HOSTLIST_IDS: ZapretListId[] = ['general', 'google', 'exclude']
function sha256(text: string): string {
return createHash('sha256').update(text).digest('hex')
}
function parseEntries(content: string): string[] {
return content
.split('\n')
.map((line) => line.trim())
.filter((line) => line.length > 0 && !line.startsWith('#'))
}
function serializeEntries(entries: string[]): string {
return entries.join('\n') + (entries.length > 0 ? '\n' : '')
}
const DOMAIN_RE = /^\^?[a-z0-9]([a-z0-9-]*[a-z0-9])?(\.[a-z0-9]([a-z0-9-]*[a-z0-9])?)+$/
const IP_CIDR_RE = /^([0-9a-f:]+|\d{1,3}(\.\d{1,3}){3})(\/\d{1,3})?$/i
/** Понятная ошибка вместо того, чтобы молча положить в список мусор, который tpws пропустит мимо ушей. */
function validateEntry(id: ZapretListId, raw: string): string {
const entry = raw.trim().toLowerCase()
if (!entry) throw new Error('Пустая строка в списке.')
const isHostlist = HOSTLIST_IDS.includes(id)
const pattern = isHostlist ? DOMAIN_RE : IP_CIDR_RE
if (!pattern.test(entry)) {
throw new Error(
isHostlist
? `«${raw}» не похоже на домен (пример: example.com или ^example.com для точного совпадения).`
: `«${raw}» не похоже на IP-адрес или подсеть (пример: 192.168.0.0/16).`
)
}
return entry
}
function bundledPath(id: ZapretListId): string {
return join(bundledListsDir(), FILE_NAMES[id])
}
function userPath(id: ZapretListId): string {
return join(userListsDir(), FILE_NAMES[id])
}
/** Атомарная запись: временный файл в том же каталоге (для atomic rename на одной ФС) + rename. */
async function writeAtomic(path: string, content: string): Promise<void> {
const tmp = `${path}.${randomUUID()}.tmp`
await writeFile(tmp, content, 'utf8')
await rename(tmp, path)
}
async function readBundled(id: ZapretListId): Promise<string> {
return readFile(bundledPath(id), 'utf8')
}
async function fileExists(path: string): Promise<boolean> {
try {
await readFile(path)
return true
} catch {
return false
}
}
/** Копирует встроенные списки в userData при первом запуске. Идемпотентно — уже существующие не трогает. */
export async function seedLists(): Promise<void> {
await mkdir(userListsDir(), { recursive: true })
for (const id of LIST_IDS) {
if (await fileExists(userPath(id))) continue
await writeAtomic(userPath(id), await readBundled(id))
}
}
async function toZapretList(id: ZapretListId): Promise<ZapretList> {
const [userContent, bundledContent] = await Promise.all([
readFile(userPath(id), 'utf8').catch(() => readBundled(id)),
readBundled(id)
])
return {
id,
entries: parseEntries(userContent),
// Список не тронут пользователем относительно текущей встроенной версии.
isDefault: sha256(userContent) === sha256(bundledContent)
}
}
export async function readList(id: ZapretListId): Promise<ZapretList> {
return toZapretList(id)
}
export async function readAllLists(): Promise<ZapretList[]> {
return Promise.all(LIST_IDS.map(readList))
}
export async function writeList(id: ZapretListId, rawEntries: string[]): Promise<ZapretList> {
const seen = new Set<string>()
const entries: string[] = []
for (const raw of rawEntries) {
const entry = validateEntry(id, raw)
if (seen.has(entry)) continue
seen.add(entry)
entries.push(entry)
}
await writeAtomic(userPath(id), serializeEntries(entries))
return toZapretList(id)
}
export async function resetList(id: ZapretListId): Promise<ZapretList> {
await writeAtomic(userPath(id), await readBundled(id))
return toZapretList(id)
}
/** Путь к рабочему списку — нужен `strategies.ts`, чтобы подставить его в аргументы движка. */
export function listFilePath(id: ZapretListId): string {
return userPath(id)
}
+94
View File
@@ -0,0 +1,94 @@
import { app } from 'electron'
import { access, constants } from 'node:fs/promises'
import { join, resolve } from 'node:path'
/**
* Бинарники zapret кладёт `scripts/fetch-zapret.mjs` в `resources/zapret`.
* В упакованном приложении electron-builder переносит их в `extraResources`,
* то есть в `process.resourcesPath/zapret` (вне asar — запускать код из архива нельзя).
*/
function zapretRoot(): string {
return app.isPackaged
? join(process.resourcesPath, 'zapret')
: resolve(app.getAppPath(), 'resources', 'zapret')
}
/** macOS: universal-бинарник tpws (arm64 + x86_64). */
export function tpwsPath(): string {
return join(zapretRoot(), 'darwin', 'tpws')
}
/** Windows: winws.exe рядом с cygwin1.dll и WinDivert. */
export function winwsPath(): string {
return join(zapretRoot(), 'win32', 'x64', 'winws.exe')
}
/** Встроенные списки (из Flowseal/zapret-discord-youtube, см. scripts/fetch-zapret.mjs) — только для чтения. */
export function bundledListsDir(): string {
return join(zapretRoot(), 'lists')
}
/**
* Рабочая копия списков — здесь их правит пользователь. Живёт вне сборки
* (userData), чтобы пережить обновление приложения: встроенные списки в
* `bundledListsDir()` при апдейте перезатираются, а эта копия — нет.
*/
export function userListsDir(): string {
return join(app.getPath('userData'), 'zapret-lists')
}
/** Fake-пакеты (TLS ClientHello, QUIC initial, ...) для стратегий winws. */
export function fakesDir(): string {
return join(zapretRoot(), 'fakes')
}
/**
* Windows: служба `rknboost-zapret` работает от LocalSystem, поэтому её конфиг не может
* жить в userData обычного пользователя — нужен каталог машинного уровня. `%ProgramData%`
* почти всегда задан; `C:\ProgramData` — тот же путь, на который он указывает по умолчанию.
*/
export function serviceDataDir(): string {
return join(process.env.ProgramData ?? 'C:\\ProgramData', 'rknboost', 'zapret')
}
/** Файл аргументов `winws.exe @strategy.cfg` — пишется только повышенным скриптом службы. */
export function serviceConfigPath(): string {
return join(serviceDataDir(), 'strategy.cfg')
}
/**
* Защищённая копия `winws.exe` (+ cygwin1.dll, WinDivert.dll, WinDivert64.sys) — на неё,
* а не на оригинал из папки установки приложения, указывает служба. Установка теперь
* per-user (`%LocalAppData%`), эта папка доступна на запись обычному пользователю —
* если бы SYSTEM-служба запускала winws.exe прямо оттуда, кто угодно, работающий под
* тем же пользователем, мог бы подменить бинарник и получить код с правами SYSTEM.
* Копию (как и `strategy.cfg`) кладёт и защищает ACL только повышенный скрипт
* (`applyServiceConfig` в service.win32.ts), рядом, в том же serviceDataDir().
*/
export function protectedWinwsPath(): string {
return join(serviceDataDir(), 'winws.exe')
}
/**
* Список, который zapret пополняет сам (`--hostlist-auto`): при похожей на блокировку
* ошибке домен дописывается сюда и на лету попадает под десинк, без участия пользователя.
* Не входит в `ZapretListId` — это нередактируемый журнал находок, а не список на правку.
*/
export function autoHostlistPath(): string {
return join(userListsDir(), 'auto.txt')
}
/**
* Понятная ошибка вместо ENOENT из глубины spawn: без этой проверки пользователь
* увидит «spawn ... ENOENT» и не поймёт, что надо запустить `npm run zapret:fetch`.
*/
export async function assertExecutable(path: string): Promise<void> {
try {
await access(path, constants.X_OK)
} catch {
throw new Error(
`Не найден исполняемый файл zapret: ${path}\n` +
'Скачайте бинарники командой «npm run zapret:fetch».'
)
}
}
+167
View File
@@ -0,0 +1,167 @@
import { app } from 'electron'
import { execFile } from 'node:child_process'
import { readFile, writeFile, rm } from 'node:fs/promises'
import { join } from 'node:path'
import { promisify } from 'node:util'
const run = promisify(execFile)
/**
* Домены и адреса, которые не должны идти через прокси. Без этого списка отвалится
* всё локальное: tpws по документации запрещает подключения к тому же хосту,
* на котором работает сам, включая localhost.
*/
const BYPASS = ['*.local', '169.254/16', 'localhost', '127.0.0.1', '::1']
interface ServiceProxyState {
service: string
enabled: boolean
server: string
port: string
bypass: string[]
}
/** Что было до нас — чтобы вернуть настройки ровно в исходное состояние. */
interface ProxySnapshot {
takenAt: string
services: ServiceProxyState[]
}
function snapshotPath(): string {
return join(app.getPath('userData'), 'proxy-snapshot.json')
}
/**
* `networksetup` требует членства в группе admin. На обычном Mac пользователь в ней
* состоит и пароль не спрашивается. Если же включена политика «требовать пароль
* администратора для системных настроек», команда падает — тогда повторяем её через
* системный диалог авторизации.
*/
async function networksetup(args: string[]): Promise<string> {
try {
const { stdout } = await run('/usr/sbin/networksetup', args)
return stdout
} catch (error) {
const message = error instanceof Error ? error.message : String(error)
if (!/root|denied|permission/i.test(message)) throw error
const command = ['/usr/sbin/networksetup', ...args]
.map((part) => `'${part.replaceAll("'", `'\\''`)}'`)
.join(' ')
try {
const { stdout } = await run('/usr/bin/osascript', [
'-e',
`do shell script "${command.replaceAll('\\', '\\\\').replaceAll('"', '\\"')}" with administrator privileges`
])
return stdout
} catch (elevationError) {
const text = elevationError instanceof Error ? elevationError.message : String(elevationError)
if (text.includes('-128'))
throw new Error('Изменение системного прокси отменено.', { cause: elevationError })
throw new Error(`Не удалось изменить настройки прокси: ${text}`, { cause: elevationError })
}
}
}
/** Сетевые сервисы, кроме выключенных: у тех `networksetup` ставит «*» перед именем. */
async function activeServices(): Promise<string[]> {
const stdout = await networksetup(['-listallnetworkservices'])
return stdout
.split('\n')
.slice(1) // первая строка — пояснение про звёздочку
.map((line) => line.trim())
.filter((line) => line.length > 0 && !line.startsWith('*'))
}
async function readServiceState(service: string): Promise<ServiceProxyState> {
const stdout = await networksetup(['-getsocksfirewallproxy', service])
const value = (key: string): string =>
new RegExp(`^${key}:\\s*(.*)$`, 'm').exec(stdout)?.[1]?.trim() ?? ''
const bypassOutput = await networksetup(['-getproxybypassdomains', service])
// Пустой список macOS печатает фразой «There aren't any bypass domains…»,
// а в доменах пробелов не бывает — по этому и отличаем.
const bypass = bypassOutput
.split('\n')
.map((line) => line.trim())
.filter((line) => line.length > 0 && !line.includes(' '))
return {
service,
enabled: value('Enabled').toLowerCase() === 'yes',
server: value('Server'),
port: value('Port'),
bypass
}
}
async function saveSnapshot(snapshot: ProxySnapshot): Promise<void> {
await writeFile(snapshotPath(), JSON.stringify(snapshot, null, 2), 'utf8')
}
async function loadSnapshot(): Promise<ProxySnapshot | null> {
try {
return JSON.parse(await readFile(snapshotPath(), 'utf8')) as ProxySnapshot
} catch {
return null
}
}
async function restoreService(state: ServiceProxyState): Promise<void> {
if (state.enabled && state.server) {
await networksetup([
'-setsocksfirewallproxy',
state.service,
state.server,
state.port || '1080'
])
await networksetup(['-setsocksfirewallproxystate', state.service, 'on'])
} else {
await networksetup(['-setsocksfirewallproxystate', state.service, 'off'])
}
// Пустой список сбрасывается специальным словом Empty — иначе останется наш.
const bypass = state.bypass.length > 0 ? state.bypass : ['Empty']
await networksetup(['-setproxybypassdomains', state.service, ...bypass])
}
/** Прописать socks-прокси во все активные сетевые сервисы, запомнив прежнее состояние. */
export async function applySystemProxy(host: string, port: number): Promise<void> {
const services = await activeServices()
if (services.length === 0) throw new Error('Не найдено ни одного активного сетевого сервиса.')
// Снимок делаем до первой правки и сразу пишем на диск: если приложение
// упадёт посреди применения, следующий запуск сможет всё вернуть.
const snapshot: ProxySnapshot = {
takenAt: new Date().toISOString(),
services: await Promise.all(services.map(readServiceState))
}
await saveSnapshot(snapshot)
for (const service of services) {
await networksetup(['-setsocksfirewallproxy', service, host, String(port)])
await networksetup(['-setproxybypassdomains', service, ...BYPASS])
await networksetup(['-setsocksfirewallproxystate', service, 'on'])
}
}
/** Вернуть настройки прокси в состояние из снимка. Без снимка ничего не трогаем. */
export async function restoreSystemProxy(): Promise<boolean> {
const snapshot = await loadSnapshot()
if (!snapshot) return false
for (const state of snapshot.services) {
await restoreService(state)
}
await rm(snapshotPath(), { force: true })
return true
}
/**
* Снимок остался с прошлого запуска — значит приложение завершилось аварийно,
* не сняв прокси, и система сейчас смотрит в несуществующий socks. Чиним на старте.
*/
export async function hasStaleSnapshot(): Promise<boolean> {
return (await loadSnapshot()) !== null
}
+471
View File
@@ -0,0 +1,471 @@
import { execFile } from 'node:child_process'
import { mkdtemp, readFile, rm, writeFile } from 'node:fs/promises'
import { tmpdir } from 'node:os'
import { join } from 'node:path'
import { promisify } from 'node:util'
import { app } from 'electron'
import {
ELEVATED_TASK_FLAG,
TASK_FILES,
isElevated,
runElevatedServiceTask
} from './elevated-task.win32'
import { serviceConfigPath, serviceDataDir, winwsPath } from './paths'
const run = promisify(execFile)
export const SERVICE_NAME = 'rknboost-zapret'
const POLL_INTERVAL_MS = 300
const POLL_TIMEOUT_MS = 10000
/** Код возврата Windows для «пользователь отклонил запрос UAC» (ERROR_CANCELLED). */
const UAC_CANCELLED_CODE = 1223
/**
* `sc.exe` выходит с самим кодом ошибки Win32, и это единственный надёжный способ их
* различать: текст сообщения локализован, на русской Windows никакой `/not started/`
* не сматчится.
*/
const ERROR_SERVICE_ALREADY_RUNNING = 1056
const ERROR_SERVICE_NOT_ACTIVE = 1062
export type ServiceState = 'Running' | 'Stopped' | 'StartPending' | 'StopPending' | 'Other'
export type ServiceStartMode = 'Auto' | 'Manual' | 'Disabled' | 'Other'
export interface ServiceInfo {
state: ServiceState
startMode: ServiceStartMode
/** Значение `PathName` из CIM — то, что реально прописано в `binPath` службы. */
binPath: string
}
function sleep(ms: number): Promise<void> {
return new Promise((resolve) => setTimeout(resolve, ms))
}
function exitCodeOf(error: unknown): number {
const code = (error as { code?: number | string } | undefined)?.code
return typeof code === 'number' ? code : 1
}
function normalizeState(raw: string | undefined): ServiceState {
switch (raw) {
case 'Running':
return 'Running'
case 'Stopped':
return 'Stopped'
case 'Start Pending':
return 'StartPending'
case 'Stop Pending':
return 'StopPending'
default:
return 'Other'
}
}
function normalizeStartMode(raw: string | undefined): ServiceStartMode {
switch (raw) {
case 'Auto':
return 'Auto'
case 'Manual':
return 'Manual'
case 'Disabled':
return 'Disabled'
default:
return 'Other'
}
}
/**
* Опрос через `Get-CimInstance Win32_Service`, а не парсинг вывода `sc query` — у CIM
* `State`/`StartMode` это английские константы независимо от языка Windows, а `sc` печатает
* локализованный текст, который парсить надёжно нельзя.
*/
export async function queryService(): Promise<ServiceInfo | null> {
const script =
`Get-CimInstance Win32_Service -Filter "Name='${SERVICE_NAME}'" | ` +
'Select-Object State,StartMode,PathName | ConvertTo-Json -Compress'
let stdout: string
try {
;({ stdout } = await run('powershell.exe', ['-NoProfile', '-Command', script]))
} catch (error) {
throw new Error(
`Не удалось опросить службу: ${error instanceof Error ? error.message : String(error)}`,
{ cause: error }
)
}
const trimmed = stdout.trim()
if (!trimmed || trimmed === 'null') return null
const raw = JSON.parse(trimmed) as { State?: string; StartMode?: string; PathName?: string }
return {
state: normalizeState(raw.State),
startMode: normalizeStartMode(raw.StartMode),
binPath: raw.PathName ?? ''
}
}
function explainScError(error: unknown, verb: string): string {
const message = error instanceof Error ? error.message : String(error)
if (/access is denied/i.test(message)) {
return (
`Не удалось ${verb} службу: отказано в доступе. Похоже, права для обычных пользователей ` +
'не выданы — переустановите приложение.'
)
}
return `Не удалось ${verb} службу: ${message}`
}
async function pollUntil(
predicate: (info: ServiceInfo | null) => boolean
): Promise<ServiceInfo | null> {
const deadline = Date.now() + POLL_TIMEOUT_MS
for (;;) {
const info = await queryService()
if (predicate(info)) return info
if (Date.now() >= deadline) return info
await sleep(POLL_INTERVAL_MS)
}
}
/**
* `sc start` только просит SCM перевести службу в running и возвращается сразу — если не
* дождаться реального состояния, свежепадающая служба (например, WinDivert не загрузился)
* выглядела бы как успех.
*/
export async function startService(): Promise<ServiceInfo | null> {
try {
await run('sc.exe', ['start', SERVICE_NAME])
} catch (error) {
// «Служба уже запущена» — цель достигнута, не ошибка: так бывает, когда пользователь
// выбрал в списке ту же стратегию, что уже работает, и перенастройка не понадобилась.
if (exitCodeOf(error) !== ERROR_SERVICE_ALREADY_RUNNING) {
throw new Error(explainScError(error, 'запустить'), { cause: error })
}
}
const info = await pollUntil((i) => i?.state === 'Running' || i?.state === 'Stopped')
if (info?.state !== 'Running') {
throw new Error(
`Служба запущена системой, но не перешла в рабочее состояние (текущее: ` +
`${info?.state ?? 'неизвестно'}). Возможная причина — не загрузился драйвер WinDivert ` +
'(включён Secure Boot, драйвер без действующей подписи, или Windows 7 без нужного обновления).'
)
}
return info
}
export async function stopService(): Promise<ServiceInfo | null> {
try {
await run('sc.exe', ['stop', SERVICE_NAME])
} catch (error) {
// «Служба не запущена» — не ошибка с точки зрения пользователя, тот же паттерн, что в
// engine.darwin.ts. Раньше это ловилось регуляркой по тексту `/not.*started/` — на русской
// Windows она не срабатывала никогда, и остановка уже остановленной службы падала ошибкой.
if (exitCodeOf(error) !== ERROR_SERVICE_NOT_ACTIVE) {
throw new Error(explainScError(error, 'остановить'), { cause: error })
}
}
// `pollUntil` возвращает последнее состояние и по истечении таймаута, независимо от предиката,
// поэтому результат обязательно проверяем. Иначе «остановка», не уложившаяся в POLL_TIMEOUT_MS,
// молча сходила бы за успешную — и вызывающий код (перенастройка стратегии в engine.win32.ts)
// пошёл бы копировать winws.exe поверх ещё работающего, ровно в ту ошибку, от которой
// остановка и защищает.
const info = await pollUntil((i) => i === null || i.state === 'Stopped')
if (info && info.state !== 'Stopped') {
throw new Error(
`Служба не остановилась за ${POLL_TIMEOUT_MS / 1000} с (состояние: ${info.state}).`
)
}
return info
}
/**
* PowerShell 5.1 (то, что стоит на Windows по умолчанию) требует BOM, чтобы правильно
* прочитать кириллицу в UTF-8 файле скрипта — без BOM она превращается в кракозябры.
*/
const UTF8_BOM = '\uFEFF'
function withBom(script: string): string {
return UTF8_BOM + script
}
/**
* Выполняется без прав администратора: запрашивает повышение через `Start-Process -Verb RunAs`
* и ждёт завершения. Если пользователь отклонил UAC, `Start-Process` бросает исключение —
* ловим его и выходим с ERROR_CANCELLED, чтобы вызывающий код отличил отказ от реальной ошибки.
*
* Повышаем САМО приложение (`$AppPath` с флагом `--elevated-service-task`), а не powershell.exe:
* Windows пишет в окне UAC имя того файла, который повышают, и запрос от «Windows PowerShell»
* с подписью Microsoft выглядит так, будто права просит что-то постороннее. Повышенный
* экземпляр приложения уже сам, без второго UAC, запускает worker.ps1 из того же каталога
* (см. runElevatedServiceTask в elevated-task.win32.ts).
*
* `$AppDir` нужен только в dev-режиме: там `$AppPath` — это electron.exe, которому каталог
* приложения передаётся первым аргументом. В собранном приложении он пустой.
*/
const OUTER_SCRIPT = `
param(
[Parameter(Mandatory=$true)][string]$AppPath,
[Parameter(Mandatory=$true)][string]$TaskDir,
[string]$AppDir = ''
)
try {
$argList = @()
if ($AppDir) { $argList += $AppDir }
$argList += @('${ELEVATED_TASK_FLAG}', $TaskDir)
$p = Start-Process -FilePath $AppPath -Verb RunAs -Wait -PassThru -ArgumentList $argList
exit $p.ExitCode
}
catch {
exit ${UAC_CANCELLED_CODE}
}
`
/**
* Работает от администратора. Три действия: установить/перенастроить службу (`install`),
* поменять только тип автозапуска (`setAutoStart`), снять службу целиком (`remove`).
* Результат пишет в `$ResultPath` — код возврата процесса тут ненадёжен (PowerShell не всегда
* пробрасывает его наружу через несколько уровней Start-Process), а JSON-файл однозначен.
*/
const WORKER_SCRIPT = `
param(
[Parameter(Mandatory=$true)][string]$PayloadPath,
[Parameter(Mandatory=$true)][string]$ResultPath
)
$ErrorActionPreference = 'Stop'
$ServiceName = '${SERVICE_NAME}'
function Write-Result($ok, $errorMessage) {
$obj = @{ ok = $ok; error = $errorMessage }
$json = $obj | ConvertTo-Json -Compress
[System.IO.File]::WriteAllText($ResultPath, $json, (New-Object System.Text.UTF8Encoding($false)))
}
# Прямой вызов "& sc.exe create ... binPath= $binPath" ломает значения с embedded-кавычками:
# PowerShell (& / call operator, и точно так же Start-Process -ArgumentList) перепаковывает
# аргументы по-своему и режет уже готовую строку binPath на два токена по пробелу перед "@" —
# sc.exe получает лишний позиционный аргумент и падает с кодом 1639 (Invalid command line),
# хотя та же строка, набранная руками в cmd.exe, разбирается верно. ProcessStartInfo.Arguments —
# нижний уровень (.NET передаёт эту строку в CreateProcess как есть, без повторной токенизации
# самим PowerShell) — единственный способ гарантированно передать её той же строкой.
function Invoke-ScRaw($rawArgs) {
$psi = New-Object System.Diagnostics.ProcessStartInfo
$psi.FileName = 'sc.exe'
$psi.Arguments = $rawArgs
$psi.UseShellExecute = $false
$psi.RedirectStandardOutput = $true
$psi.RedirectStandardError = $true
$psi.CreateNoWindow = $true
$proc = [System.Diagnostics.Process]::Start($psi)
$stdout = $proc.StandardOutput.ReadToEnd()
$stderr = $proc.StandardError.ReadToEnd()
$proc.WaitForExit()
return [pscustomobject]@{ ExitCode = $proc.ExitCode; Output = ("$stdout $stderr").Trim() }
}
try {
$payload = Get-Content -Raw -Path $PayloadPath | ConvertFrom-Json
switch ($payload.action) {
'install' {
$dir = $payload.serviceDataDir
New-Item -ItemType Directory -Path $dir -Force | Out-Null
# Снимаем наследование прав каталога: конфиг читает служба от SYSTEM, писать в него
# должны только SYSTEM и администраторы — иначе обычный пользователь мог бы подменить
# аргументы winws, которые выполняются с правами SYSTEM.
& icacls $dir /inheritance:r | Out-Null
& icacls $dir /grant '*S-1-5-18:(OI)(CI)F' | Out-Null
& icacls $dir /grant '*S-1-5-32-544:(OI)(CI)F' | Out-Null
& icacls $dir /grant '*S-1-5-32-545:(OI)(CI)RX' | Out-Null
$configPath = $payload.configPath
# UTF-8 без BOM — этого требует сам winws при чтении @<config_file> (docs/windows.md zapret).
$utf8NoBom = New-Object System.Text.UTF8Encoding($false)
[System.IO.File]::WriteAllText($configPath, $payload.configBody, $utf8NoBom)
# Служба должна ссылаться на копию winws.exe в этом же защищённом каталоге, а не на
# оригинал в папке установки приложения: установка теперь per-user (%LocalAppData%),
# эта папка доступна на запись обычному пользователю — SYSTEM-служба, ссылающаяся туда,
# была бы локальным повышением привилегий. Копируем winws.exe и рядом лежащие
# cygwin1.dll/WinDivert.dll/WinDivert64.sys, от них он зависит при запуске.
$sourceDir = Split-Path -Parent $payload.winwsPath
$protectedWinws = Join-Path $dir 'winws.exe'
foreach ($name in @('winws.exe', 'cygwin1.dll', 'WinDivert.dll', 'WinDivert64.sys')) {
Copy-Item -Path (Join-Path $sourceDir $name) -Destination (Join-Path $dir $name) -Force
}
# sc.exe читает binPath как ОДИН аргумент — значит всю строку нужно взять в одну пару
# кавычек, а внутренние кавычки экранировать как \\" (без этого CommandLineToArgvW режет
# значение на два токена по пробелу перед "@", и sc create падает с кодом 1639).
# Тот же приём — в service_create.cmd из zapret-win-bundle.
$binPath = '"\\"' + $protectedWinws + '\\" @\\"' + $configPath + '\\""'
$startType = if ($payload.autoStart) { 'auto' } else { 'demand' }
& sc.exe query $ServiceName | Out-Null
$serviceExists = ($LASTEXITCODE -eq 0)
if ($serviceExists) {
$scArgs = 'config ' + $ServiceName + ' binPath= ' + $binPath + ' start= ' + $startType
$result = Invoke-ScRaw $scArgs
if ($result.ExitCode -ne 0) { throw "sc config завершился с кодом $($result.ExitCode) : $($result.Output)" }
} else {
$scArgs = 'create ' + $ServiceName + ' binPath= ' + $binPath + ' start= ' + $startType + ' DisplayName= "RKNboost: обход блокировок"'
$result = Invoke-ScRaw $scArgs
if ($result.ExitCode -ne 0) { throw "sc create завершился с кодом $($result.ExitCode) : $($result.Output)" }
}
& sc.exe description $ServiceName 'Служба RKNboost: обходит блокировки через winws/WinDivert.' | Out-Null
# Разрешаем интерактивным пользователям запуск/остановку/чтение статуса службы, но не
# смену конфигурации и не смену прав — иначе любой пользователь на машине мог бы подменить
# аргументы, с которыми служба запускает winws от имени SYSTEM.
$sddlLines = & sc.exe sdshow $ServiceName
$sddl = ($sddlLines | Where-Object { $_ -match '^D:' } | Select-Object -First 1)
if (-not $sddl) { throw 'sc sdshow не вернул дескриптор безопасности' }
$sddl = $sddl -replace '\\(A;;[^;]*;;;IU\\)', ''
$sddl = $sddl -replace '^D:', 'D:(A;;CCLCSWRPWPLORC;;;IU)'
$sdsetOut = & sc.exe sdset $ServiceName $sddl 2>&1 | Out-String
if ($LASTEXITCODE -ne 0) { throw "sc sdset завершился с кодом $LASTEXITCODE : $($sdsetOut.Trim())" }
Write-Result $true $null
}
'setAutoStart' {
$startType = if ($payload.autoStart) { 'auto' } else { 'demand' }
$out = & sc.exe config $ServiceName start= $startType 2>&1 | Out-String
if ($LASTEXITCODE -ne 0) { throw "sc config завершился с кодом $LASTEXITCODE : $($out.Trim())" }
Write-Result $true $null
}
'remove' {
& sc.exe stop $ServiceName 2>$null | Out-Null
Start-Sleep -Milliseconds 500
& sc.exe delete $ServiceName | Out-Null
if (Test-Path $payload.serviceDataDir) {
Remove-Item -Path $payload.serviceDataDir -Recurse -Force
}
Write-Result $true $null
}
default {
throw "Неизвестное действие: $($payload.action)"
}
}
}
catch {
Write-Result $false $_.Exception.Message
exit 1
}
`
async function readResult(path: string): Promise<{ ok: boolean; error: string | null } | null> {
try {
return JSON.parse(await readFile(path, 'utf8')) as { ok: boolean; error: string | null }
} catch {
return null
}
}
/**
* Пишет payload во временный каталог и выполняет `worker.ps1`.
*
* Если приложение уже запущено от администратора (в собранном виде — всегда), worker
* запускается напрямую: ни UAC, ни лишних процессов. Иначе — не более одного запроса UAC
* на всё действие: `outer.ps1` (без прав) через `Start-Process -Verb RunAs` поднимает
* приложение с флагом задачи, а уже оно запускает worker.
*
* Результат в обоих случаях читаем из `result.json`, а не из кода возврата процесса.
*/
async function runElevated(payload: Record<string, unknown>): Promise<void> {
const dir = await mkdtemp(join(tmpdir(), 'rknboost-svc-'))
const workerPath = join(dir, TASK_FILES.worker)
const outerPath = join(dir, 'outer.ps1')
const payloadPath = join(dir, TASK_FILES.payload)
const resultPath = join(dir, TASK_FILES.result)
try {
await writeFile(workerPath, withBom(WORKER_SCRIPT), 'utf8')
await writeFile(payloadPath, JSON.stringify(payload), 'utf8')
let exitCode = 0
if (await isElevated()) {
// Приложение и так запущено от администратора (в собранном виде — всегда, см.
// requestedExecutionLevel в electron-builder.yml). Повышать нечего: запускаем worker
// напрямую, без outer.ps1 и без второго экземпляра приложения на каждое действие.
exitCode = await runElevatedServiceTask(dir)
} else {
await writeFile(outerPath, withBom(OUTER_SCRIPT), 'utf8')
try {
await run('powershell.exe', [
'-NoProfile',
'-ExecutionPolicy',
'Bypass',
'-File',
outerPath,
'-AppPath',
process.execPath,
'-TaskDir',
dir,
// В dev electron.exe нужен каталог приложения первым аргументом; в сборке — нет.
...(app.isPackaged ? [] : ['-AppDir', app.getAppPath()])
])
} catch (error) {
exitCode = exitCodeOf(error)
}
}
if (exitCode === UAC_CANCELLED_CODE) {
throw new Error('Установка отменена — запрос на права администратора был отклонён.')
}
const result = await readResult(resultPath)
if (!result?.ok) {
throw new Error(
result?.error
? `Не удалось настроить службу: ${result.error}`
: `Настройка службы завершилась с ошибкой (код ${exitCode}).`
)
}
} finally {
await rm(dir, { recursive: true, force: true })
}
}
export interface ApplyServiceConfigOptions {
/** Тело `strategy.cfg` — из `winwsConfigFile()` в `strategies.ts`. */
configBody: string
autoStart: boolean
}
/**
* Установить службу (если её ещё нет) или перенастроить существующую — один запрос UAC.
* Заодно копирует `winws.exe` + WinDivert из папки установки приложения (per-user,
* доступна на запись обычному пользователю) в защищённый каталог службы — см.
* `protectedWinwsPath()` в `paths.ts` — и указывает binPath именно туда.
* Саму службу не запускает — вызывающий код (`Win32Engine.start()`) делает это отдельным
* вызовом `startService()`, который уже не требует прав благодаря ACL, выставленному здесь.
*/
export async function applyServiceConfig(options: ApplyServiceConfigOptions): Promise<void> {
await runElevated({
action: 'install',
serviceDataDir: serviceDataDir(),
configPath: serviceConfigPath(),
configBody: options.configBody,
winwsPath: winwsPath(),
autoStart: options.autoStart
})
}
/** Только тип автозапуска — не трогает аргументы winws, отдельный (более лёгкий) UAC-запрос. */
export async function setServiceAutoStart(autoStart: boolean): Promise<void> {
await runElevated({ action: 'setAutoStart', autoStart })
}
/** Используется деинсталлятором (`build/installer.nsh`) и на случай ручной переустановки. */
export async function removeService(): Promise<void> {
await runElevated({ action: 'remove', serviceDataDir: serviceDataDir() })
}
+247
View File
@@ -0,0 +1,247 @@
import { createHash } from 'node:crypto'
import type { ZapretStrategy } from '../../shared/ipc-contract'
import { autoHostlistPath, fakesDir, userListsDir } from './paths'
import { WIN32_STRATEGIES } from './strategies.win32.generated'
/**
* Пресеты обхода.
*
* Универсального набора параметров не существует: что сработает, зависит от DPI
* конкретного провайдера.
*
* `tpws` (macOS) — прокси уровня TCP-соединений, а не пакетов: умеет только резать/
* переупорядочивать сегменты (`--split-pos`, `--disorder`, `--oob`, `--tlsrec`), но не
* умеет ни fake-пакетов, ни UDP/QUIC вообще. Профили ниже — свои, под это ограничение.
*
* `winws` (Windows) работает на уровне пакетов через WinDivert. Его пресеты — 22 варианта
* из Flowseal/zapret-discord-youtube, перенесённые в `strategies.win32.generated.ts`
* генератором `scripts/import-flowseal-strategies.mjs` (не батниками — кодом).
*
* Оба движка используют одни и те же списки сайтов (`src/main/zapret/lists.ts`):
* `{LISTS}/<id>.txt` — путь резолвится в `resolvePlaceholders()` ниже.
*/
interface DarwinStrategyDefinition extends ZapretStrategy {
/** Аргументы tpws после общих `--socks --port=...`; плейсхолдеры ещё не резолвлены. */
tpws: string[]
}
const LISTS_PLACEHOLDER = '{LISTS}'
const FAKES_PLACEHOLDER = '{FAKES}'
const AUTO_PLACEHOLDER = '{AUTO}'
/** `--hostlist-auto` заставляет zapret самому копить сюда домены, похожие на заблокированные. */
const AUTO_HOSTLIST_ARGS = [`--hostlist-auto=${AUTO_PLACEHOLDER}`]
const DARWIN_DEFINITIONS: DarwinStrategyDefinition[] = [
{
id: 'split',
name: 'Разрез + переупорядочивание',
description: 'Базовый вариант из поставки zapret: разрез TLS ClientHello по середине SNI.',
platforms: ['darwin'],
tpws: [
'--filter-tcp=80',
`--hostlist=${LISTS_PLACEHOLDER}/general.txt`,
`--hostlist=${LISTS_PLACEHOLDER}/google.txt`,
`--hostlist-exclude=${LISTS_PLACEHOLDER}/exclude.txt`,
'--methodeol',
'--new',
'--filter-tcp=443',
`--hostlist=${LISTS_PLACEHOLDER}/general.txt`,
`--hostlist=${LISTS_PLACEHOLDER}/google.txt`,
`--hostlist-exclude=${LISTS_PLACEHOLDER}/exclude.txt`,
...AUTO_HOSTLIST_ARGS,
'--split-pos=1,midsld',
'--disorder'
]
},
{
id: 'multisplit',
name: 'Многократный разрез',
description: 'Больше точек разреза. Помогает, когда DPI склеивает сегменты обратно.',
platforms: ['darwin'],
tpws: [
'--filter-tcp=80',
`--hostlist=${LISTS_PLACEHOLDER}/general.txt`,
`--hostlist=${LISTS_PLACEHOLDER}/google.txt`,
`--hostlist-exclude=${LISTS_PLACEHOLDER}/exclude.txt`,
'--methodeol',
'--new',
'--filter-tcp=443',
`--hostlist=${LISTS_PLACEHOLDER}/general.txt`,
`--hostlist=${LISTS_PLACEHOLDER}/google.txt`,
`--hostlist-exclude=${LISTS_PLACEHOLDER}/exclude.txt`,
...AUTO_HOSTLIST_ARGS,
// --oob здесь не добавляем: tpws отказывается совмещать его с --disorder вне Linux.
'--split-pos=1,midsld,host+1',
'--disorder'
]
},
{
id: 'tlsrec',
name: 'Разрез TLS-записи',
description: 'Другой способ разреза (--tlsrec) — стоит попробовать, если «Разрез» не помогает.',
platforms: ['darwin'],
tpws: [
'--filter-tcp=80',
`--hostlist=${LISTS_PLACEHOLDER}/general.txt`,
`--hostlist=${LISTS_PLACEHOLDER}/google.txt`,
`--hostlist-exclude=${LISTS_PLACEHOLDER}/exclude.txt`,
'--methodeol',
'--new',
'--filter-tcp=443',
`--hostlist=${LISTS_PLACEHOLDER}/general.txt`,
`--hostlist=${LISTS_PLACEHOLDER}/google.txt`,
`--hostlist-exclude=${LISTS_PLACEHOLDER}/exclude.txt`,
...AUTO_HOSTLIST_ARGS,
'--split-pos=1',
'--tlsrec=midsld'
]
},
{
id: 'oob',
name: 'Разрез с OOB-байтом',
description: 'Внеполосный байт вместо переупорядочивания — третий вариант на выбор.',
platforms: ['darwin'],
tpws: [
'--filter-tcp=80',
`--hostlist=${LISTS_PLACEHOLDER}/general.txt`,
`--hostlist=${LISTS_PLACEHOLDER}/google.txt`,
`--hostlist-exclude=${LISTS_PLACEHOLDER}/exclude.txt`,
'--methodeol',
'--new',
'--filter-tcp=443',
`--hostlist=${LISTS_PLACEHOLDER}/general.txt`,
`--hostlist=${LISTS_PLACEHOLDER}/google.txt`,
`--hostlist-exclude=${LISTS_PLACEHOLDER}/exclude.txt`,
...AUTO_HOSTLIST_ARGS,
'--split-pos=1,midsld',
'--oob'
]
},
{
id: 'all-traffic',
name: 'Весь трафик, без списков',
description:
'Разрез применяется ко всем сайтам, а не только из списка «Обходить». Диагностика.',
platforms: ['darwin'],
tpws: [
'--filter-tcp=80',
'--methodeol',
'--new',
'--filter-tcp=443',
'--split-pos=1,midsld',
'--disorder'
]
},
{
id: 'passthrough',
name: 'Без изменения трафика',
description:
'Прокси работает, но пакеты не трогает. Нужен, чтобы отделить проблемы сети от стратегии.',
platforms: ['darwin'],
tpws: []
}
]
/** Стратегия по умолчанию — своя на каждой платформе, наборы id не пересекаются. */
export function defaultStrategyId(platform: NodeJS.Platform): string {
return platform === 'win32' ? 'general' : 'split'
}
function resolvePlaceholders(arg: string): string {
return arg
.replaceAll(LISTS_PLACEHOLDER, userListsDir())
.replaceAll(FAKES_PLACEHOLDER, fakesDir())
.replaceAll(AUTO_PLACEHOLDER, autoHostlistPath())
}
/** Пресеты, применимые к текущей платформе. */
export function listStrategies(platform: NodeJS.Platform): ZapretStrategy[] {
if (platform === 'darwin') {
return DARWIN_DEFINITIONS.map(({ id, name, description, platforms }) => ({
id,
name,
description,
platforms
}))
}
if (platform === 'win32') {
return WIN32_STRATEGIES.map(({ id, name }) => ({
id,
name,
// У Flowseal нет описаний стратегий — только имена вариантов, подобранных под провайдера.
description: 'Стратегия из подборки Flowseal/zapret-discord-youtube.',
platforms: ['win32']
}))
}
return []
}
function findDarwin(strategyId: string): DarwinStrategyDefinition {
const strategy = DARWIN_DEFINITIONS.find((item) => item.id === strategyId)
if (!strategy) throw new Error(`Неизвестная стратегия: ${strategyId}`)
return strategy
}
/** Полная командная строка tpws в режиме socks5 на localhost. */
export function tpwsArgs(strategyId: string, port: number): string[] {
return [
'--socks',
`--port=${port}`,
'--bind-addr=127.0.0.1',
'--maxconn=512',
...findDarwin(strategyId).tpws.map(resolvePlaceholders)
]
}
/** Аргументы winws — служба читает их из файла как `winws.exe @strategy.txt`. */
export function winwsArgs(strategyId: string): string[] {
const strategy = WIN32_STRATEGIES.find((item) => item.id === strategyId)
if (!strategy) throw new Error(`Неизвестная стратегия: ${strategyId}`)
return strategy.args.map(resolvePlaceholders)
}
/** Читает служба: `winws.exe @strategy.cfg`, ограничение размера у самого winws — 16 КБ. */
const MAX_CONFIG_FILE_BYTES = 16000
/**
* winws разбирает файл конфигурации через POSIX `wordexp()` (см. `nfq/nfqws.c` в поставке
* zapret) — без кавычек он режет аргумент по пробелам и съедает обратные слэши в путях вида
* `C:\Users\...`. Каждый аргумент поэтому кладём в одинарные кавычки, а `'` внутри значения
* экранируем как `'\''` — тем же приёмом, что и `networksetup()` в `proxy.darwin.ts` для shell.
*/
function quoteConfigArg(arg: string): string {
return `'${arg.replaceAll("'", `'\\''`)}'`
}
/**
* Первая строка — маркер: по нему `Win32Engine.sync()` узнаёт, какая стратегия уже установлена
* в службу, не перечитывая все аргументы. Сам маркер — штатная опция `--comment` winws,
* в работу процесса не вмешивается.
*/
function configMarker(strategyId: string, body: string): string {
const hash = createHash('sha256').update(body).digest('hex').slice(0, 12)
return `--comment=rknboost:${strategyId}:${hash}`
}
export interface WinwsConfig {
/** Содержимое `strategy.cfg` — UTF-8 без BOM, как требует winws (docs/windows.md zapret). */
body: string
/** Маркер первой строки — сравнить с уже установленным, чтобы понять, нужна ли перенастройка. */
marker: string
}
/** Рендерит стратегию в файл аргументов для `winws.exe @<file>` — см. `WinwsConfig`. */
export function winwsConfigFile(strategyId: string): WinwsConfig {
const args = winwsArgs(strategyId).map(quoteConfigArg).join('\n')
const marker = configMarker(strategyId, args)
const body = `${marker}\n${args}\n`
if (Buffer.byteLength(body, 'utf8') > MAX_CONFIG_FILE_BYTES) {
throw new Error(
`Стратегия «${strategyId}» не помещается в лимит winws на файл конфигурации (16 КБ).`
)
}
return { body, marker }
}
File diff suppressed because it is too large Load Diff
+9
View File
@@ -0,0 +1,9 @@
import type { RendererApi } from './index'
declare global {
interface Window {
api: RendererApi
}
}
export {}
+79
View File
@@ -0,0 +1,79 @@
import { contextBridge, ipcRenderer, type IpcRendererEvent } from 'electron'
import type {
IpcChannel,
IpcEventChannel,
IpcEvents,
IpcHandlers,
ThemeState,
ZapretDiagnosticResult,
ZapretList,
ZapretListId,
ZapretLogLine,
ZapretStatus,
ZapretStrategy
} from '../shared/ipc-contract'
/** Типизированный `invoke`: канал, аргументы и результат берутся из контракта. */
function invoke<K extends IpcChannel>(
channel: K,
...args: Parameters<IpcHandlers[K]>
): Promise<ReturnType<IpcHandlers[K]>> {
return ipcRenderer.invoke(channel, ...args) as Promise<ReturnType<IpcHandlers[K]>>
}
/** Подписка на событие main → renderer. Возвращает функцию отписки (для useEffect). */
function subscribe<K extends IpcEventChannel>(
channel: K,
listener: (payload: IpcEvents[K]) => void
): () => void {
const wrapped = (_event: IpcRendererEvent, payload: IpcEvents[K]): void => listener(payload)
ipcRenderer.on(channel, wrapped)
return () => {
ipcRenderer.off(channel, wrapped)
}
}
/**
* Единственное, что видит renderer. Никакого прямого доступа к `ipcRenderer`,
* к Node API или к файловой системе — только эти методы.
*/
const api = {
/** 'darwin' | 'win32' | 'linux' — renderer'у нужно, например, чтобы показывать автозапуск
* службы только на Windows (см. SettingsView.tsx). */
platform: process.platform,
getTheme: (): Promise<ThemeState> => invoke('theme:get'),
onThemeChanged: (listener: (state: ThemeState) => void): (() => void) =>
subscribe('theme:changed', listener),
/** Windows: подтверждает диалогом ОС, запускает деинсталлятор и закрывает приложение. */
uninstallApp: (): Promise<void> => invoke('app:uninstall'),
getZapretStatus: (): Promise<ZapretStatus> => invoke('zapret:status'),
getZapretStrategies: (): Promise<ZapretStrategy[]> => invoke('zapret:strategies'),
startZapret: (strategyId: string): Promise<ZapretStatus> => invoke('zapret:start', strategyId),
stopZapret: (): Promise<ZapretStatus> => invoke('zapret:stop'),
setZapretSystemProxy: (enabled: boolean): Promise<ZapretStatus> =>
invoke('zapret:set-system-proxy', enabled),
setZapretStrategy: (strategyId: string): Promise<ZapretStatus> =>
invoke('zapret:set-strategy', strategyId),
setZapretAutoStart: (enabled: boolean): Promise<ZapretStatus> =>
invoke('zapret:set-autostart', enabled),
onZapretStatusChanged: (listener: (status: ZapretStatus) => void): (() => void) =>
subscribe('zapret:status-changed', listener),
onZapretLog: (listener: (entry: ZapretLogLine) => void): (() => void) =>
subscribe('zapret:log', listener),
getZapretLists: (): Promise<ZapretList[]> => invoke('zapret:lists'),
saveZapretList: (id: ZapretListId, entries: string[]): Promise<ZapretList> =>
invoke('zapret:list-save', id, entries),
resetZapretList: (id: ZapretListId): Promise<ZapretList> => invoke('zapret:list-reset', id),
getZapretAutoHostlist: (): Promise<string[]> => invoke('zapret:auto-hostlist'),
clearZapretAutoHostlist: (): Promise<void> => invoke('zapret:auto-hostlist-clear'),
runZapretDiagnostics: (): Promise<ZapretDiagnosticResult[]> => invoke('zapret:diagnose')
}
export type RendererApi = typeof api
contextBridge.exposeInMainWorld('api', api)
+18
View File
@@ -0,0 +1,18 @@
<!doctype html>
<html lang="ru">
<head>
<meta charset="UTF-8" />
<title>RKNboost</title>
<!-- Жёсткий CSP: никаких внешних скриптов, стилей и запросов.
https://developer.mozilla.org/en-US/docs/Web/HTTP/CSP -->
<meta
http-equiv="Content-Security-Policy"
content="default-src 'self'; script-src 'self'; style-src 'self' 'unsafe-inline'; img-src 'self' data:; font-src 'self' data:"
/>
</head>
<body>
<div id="root"></div>
<script type="module" src="/src/main.tsx"></script>
</body>
</html>
+54
View File
@@ -0,0 +1,54 @@
import { useState } from 'react'
import SettingsView from './components/SettingsView'
import ZapretCard from './components/ZapretCard'
import { useTheme } from './hooks/useTheme'
import { useZapret } from './hooks/useZapret'
export default function App(): React.JSX.Element {
useTheme()
const { status, setAutoStart } = useZapret()
const [showSettings, setShowSettings] = useState(false)
return (
<main className="content">
{showSettings ? (
<SettingsView
onBack={() => setShowSettings(false)}
status={status}
onSetAutoStart={setAutoStart}
/>
) : (
<>
<div className="app-header">
<span className="app-header__name">RKNboost</span>
<button
type="button"
className="app-header__settings"
aria-label="Настройки"
onClick={() => setShowSettings(true)}
>
<svg viewBox="0 0 24 24" width="16" height="16" aria-hidden="true">
<circle cx="12" cy="12" r="7" fill="none" stroke="currentColor" strokeWidth="2" />
<circle cx="12" cy="12" r="2.5" fill="currentColor" />
{[0, 45, 90, 135, 180, 225, 270, 315].map((angle) => (
<rect
key={angle}
x="10.5"
y="0.5"
width="3"
height="3.5"
rx="0.8"
fill="currentColor"
transform={`rotate(${angle} 12 12)`}
/>
))}
</svg>
</button>
</div>
<ZapretCard />
</>
)}
</main>
)
}
+641
View File
@@ -0,0 +1,641 @@
/**
* Дизайн-система из design.md: роли Material 3, тёплая кремовая база
* и прохладный фиолетовый акцент.
*
* Правила, которые тут зашиты:
* • иерархия — тональностью поверхностей, а не тенями (теней нет вовсе);
* • скругления только из shape-шкалы (pill / 24 / 16 / 9);
* • два веса шрифта (400 и 500), sentence case, технические данные — моноширинным;
* • состояния — полупрозрачный state layer поверх роли (hover 8%, focus/pressed 12%).
*/
:root {
/* --- Роли Material 3, светлая тема (design.md §2) --- */
--primary: #6a45b8;
--on-primary: #ffffff;
--primary-container: #ebddff;
--on-primary-container: #5a2da8;
--secondary-container: #ebddff;
--on-secondary-container: #5a2da8;
/* Светлый фиолетовый: только крупные элементы и иконки, под мелкий текст не годится. */
--accent: #a96bff;
--surface: #faf6f0;
--on-surface: #3a2e22;
--surface-container: #f0e8dd;
--surface-container-high: #ece2d3;
--surface-bright: #fdfaf6;
/* Текст: тёмно-коричневый, не чёрный и не фиолетовый (design.md §6). */
--on-surface-variant: #5a4a3a;
--text-muted: #7a6a55;
--outline: #d8cdbe;
--outline-variant: #ece2d3;
--error: #ba1a1a;
--on-error: #ffffff;
--error-outline: #e6c4bc;
--on-error-outline: #b34a3a;
/* --- Статусные цвета, тёплый подтон (design.md §2) --- */
--status-success-bg: #c0dd97;
--status-success-fg: #2c4d0a;
--status-success-dot: #639922;
--status-neutral-bg: #e4dac9;
--status-neutral-fg: #7a6a55;
--status-neutral-dot: #a99a85;
--status-error-bg: #ffdad6;
--status-error-fg: #410002;
--status-error-dot: #ba1a1a;
/* --- Форма (design.md §4) --- */
--shape-pill: 100px;
--shape-card: 24px;
--shape-inner: 16px;
--shape-icon: 9px;
--font-sans: Inter, system-ui, -apple-system, 'Segoe UI', Roboto, 'Helvetica Neue', sans-serif;
--font-mono: ui-monospace, SFMono-Regular, 'SF Mono', Menlo, Consolas, monospace;
color-scheme: light;
}
/* Тёплый уголь, а не баклажан: тот же тёплый подтон, что у крема (design.md §7). */
:root.dark {
--primary: #cbb3ff;
--on-primary: #381b6c;
--primary-container: #52389e;
--on-primary-container: #ebddff;
--secondary-container: #483a55;
--on-secondary-container: #ebddff;
--accent: #cbb3ff;
--surface: #1a1611;
--on-surface: #ede2d4;
--surface-container: #251f18;
--surface-container-high: #302820;
--surface-bright: #2b251d;
--on-surface-variant: #cfc2ae;
--text-muted: #b6a994;
--outline: #4d4334;
--outline-variant: #342c20;
--error: #ffb4ab;
--on-error: #5c1414;
--error-outline: #6e2a24;
--on-error-outline: #ffb4ab;
/* --- Статусные цвета, тёмная тема (design.md §7) --- */
--status-success-bg: #2d4413;
--status-success-fg: #c8e3a0;
--status-success-dot: #8fc24f;
--status-neutral-bg: #34291c;
--status-neutral-fg: #d0c0a4;
--status-neutral-dot: #b0a085;
--status-error-bg: #5c1414;
--status-error-fg: #ffb4ab;
--status-error-dot: #ff5449;
color-scheme: dark;
}
* {
box-sizing: border-box;
}
html,
body,
#root {
height: 100%;
}
/* Контент занимает всю высоту окна — оно маленькое (400×300), пустой хвост
снизу иначе выглядит перекошенным. */
#root {
display: flex;
flex-direction: column;
}
body {
margin: 0;
background: var(--surface);
color: var(--on-surface);
font-family: var(--font-sans);
font-size: 14px;
font-weight: 400;
line-height: 1.5;
-webkit-font-smoothing: antialiased;
}
::selection {
background: var(--primary-container);
color: var(--on-primary-container);
}
/* Общее кольцо фокуса — тот же 12%-слой, что и у нажатия. */
:focus-visible {
outline: 2px solid var(--primary);
outline-offset: 2px;
}
/* ---------- Контент ---------- */
/* Прокрутки на уровне оболочки нет: главный экран рассчитан на то, чтобы умещаться
в окно целиком. Прокручиваются только настройки и редактор списка — там содержимое
заведомо длиннее окна (правила ниже, .settings / .list-editor). */
.content {
flex: 1 1 auto;
display: flex;
flex-direction: column;
padding: 20px;
overflow: hidden;
/* Без этого flex-элемент не даёт себя сжать ниже размера содержимого,
и внутренняя прокрутка настроек не включается. */
min-height: 0;
}
/* Шапка приложения: вордмарк слева, версия справа (design.md, шапка drawer-панели). */
.app-header {
display: flex;
align-items: baseline;
justify-content: space-between;
gap: 12px;
margin-bottom: 16px;
}
.app-header__name {
font-size: 20px;
font-weight: 500;
}
.app-header__settings {
display: grid;
place-items: center;
width: 28px;
height: 28px;
padding: 0;
border: none;
border-radius: var(--shape-icon);
background: transparent;
color: var(--text-muted);
font-size: 15px;
cursor: pointer;
}
.app-header__settings:hover {
background: color-mix(in srgb, var(--on-surface) 8%, transparent);
color: var(--on-surface);
}
.app-header__settings:active {
background: color-mix(in srgb, var(--on-surface) 12%, transparent);
}
/* Рабочая зона — светлее крема; разделение тональностью плюс тонкий контур. */
.card {
padding: 20px;
background: var(--surface-bright);
border: 1px solid var(--outline-variant);
border-radius: var(--shape-card);
}
.card h2 {
margin: 0 0 4px;
font-size: 16px;
font-weight: 500;
}
/* ---------- Карточка обхода блокировок (design.md §4.1, «карточка VPN-сервера») ---------- */
.zapret-card {
display: flex;
flex-direction: column;
gap: 16px;
/* Предохранитель. В обычных состояниях карточка умещается целиком и никакой полосы
не видно — ради этого прокрутка и убрана с .content. Но текст ошибки приходит из
системы и по длине не ограничен, поэтому переполнение забирает на себя карточка,
а не оболочка: в патологическом случае пользователь увидит полосу внутри карточки,
а не обрезанное содержимое. */
flex: 0 1 auto;
min-height: 0;
overflow-y: auto;
}
/* Текст ошибки приходит из системы и по длине ничем не ограничен (например, вывод sc.exe).
Без потолка он растянул бы карточку и вытолкнул содержимое за пределы окна — а прокрутки
на главном экране нет, значит это было бы обрезано. Ограничиваем и прокручиваем сам блок
ошибки: главный экран при этом остаётся фиксированной высоты при любой длине ошибки. */
.zapret-card .list-editor__error {
max-height: 96px;
overflow-y: auto;
}
.zapret-card__head {
display: flex;
align-items: center;
gap: 14px;
}
.zapret-card__title {
flex: 1 1 auto;
min-width: 0;
margin: 0;
}
/* Кнопка включения — отдельно от названия и статуса, во всю ширину карточки
(design.md, часть II: главное действие — заливка primary, pill). */
.toggle-button {
width: 100%;
min-height: 44px;
padding: 0 20px;
border: 1px solid transparent;
border-radius: var(--shape-pill);
background: var(--primary);
color: var(--on-primary);
font: inherit;
font-weight: 500;
cursor: pointer;
}
.toggle-button:hover {
background: color-mix(in srgb, var(--on-primary) 8%, var(--primary));
}
.toggle-button:active {
background: color-mix(in srgb, var(--on-primary) 12%, var(--primary));
}
/* Обход уже активен — приглашаем выключить менее настойчивым, тональным цветом. */
.toggle-button--on {
background: var(--secondary-container);
color: var(--on-secondary-container);
}
.toggle-button--on:hover {
background: color-mix(in srgb, var(--on-secondary-container) 8%, var(--secondary-container));
}
.toggle-button--on:active {
background: color-mix(in srgb, var(--on-secondary-container) 12%, var(--secondary-container));
}
.toggle-button:disabled {
cursor: default;
opacity: 0.6;
}
/* Чип статуса: точка + подпись, роль зависит от состояния (design.md, статусные цвета). */
.chip {
display: inline-flex;
flex: 0 0 auto;
align-items: center;
gap: 6px;
padding: 4px 12px;
border-radius: var(--shape-pill);
font-size: 12px;
font-weight: 500;
white-space: nowrap;
}
.chip::before {
content: '';
width: 8px;
height: 8px;
border-radius: 50%;
}
.chip--success {
background: var(--status-success-bg);
color: var(--status-success-fg);
}
.chip--success::before {
background: var(--status-success-dot);
}
.chip--neutral {
background: var(--status-neutral-bg);
color: var(--status-neutral-fg);
}
.chip--neutral::before {
background: var(--status-neutral-dot);
}
.chip--error {
background: var(--status-error-bg);
color: var(--status-error-fg);
}
.chip--error::before {
background: var(--status-error-dot);
}
/* Убираемая версия чипа — список сайтов (design.md: состояние hover/active тем же слоем). */
.chip--removable {
gap: 8px;
background: var(--surface-container-high);
color: var(--on-surface-variant);
}
.chip--removable::before {
content: none;
}
.chip__remove {
display: grid;
place-items: center;
width: 16px;
height: 16px;
padding: 0;
border: none;
border-radius: 50%;
background: transparent;
color: inherit;
font-size: 10px;
line-height: 1;
cursor: pointer;
}
.chip__remove:hover {
background: color-mix(in srgb, var(--on-surface) 12%, transparent);
}
/* ---------- Настройки: стратегия + списки сайтов ---------- */
/* Прокрутка живёт здесь, а не на .content: списки сайтов и настройки длиннее окна,
а главный экран прокручиваться не должен. */
.settings,
.list-editor {
display: flex;
flex-direction: column;
gap: 14px;
flex: 1 1 auto;
min-height: 0;
overflow-y: auto;
/* Полоса прокрутки не должна наезжать на карточки и кнопку удаления. */
padding-right: 4px;
margin-right: -4px;
}
.settings__back-row {
display: flex;
align-items: center;
gap: 10px;
}
.settings__back {
padding: 4px 10px 4px 4px;
border: none;
border-radius: var(--shape-pill);
background: transparent;
color: var(--text-muted);
font: inherit;
cursor: pointer;
}
.settings__back:hover {
color: var(--on-surface);
}
.settings__title {
margin: 0;
font-size: 16px;
font-weight: 500;
}
.settings__field {
display: flex;
flex-direction: column;
gap: 6px;
}
.settings__label {
color: var(--text-muted);
font-size: 12px;
font-weight: 500;
}
.select {
height: 40px;
padding: 0 12px;
border: 1px solid var(--outline);
border-radius: var(--shape-inner);
background: var(--surface-bright);
color: var(--on-surface);
font: inherit;
}
.settings__group {
display: flex;
flex-direction: column;
gap: 2px;
}
.settings-row {
display: flex;
align-items: center;
justify-content: space-between;
padding: 10px 4px;
border: none;
border-top: 1px solid var(--outline-variant);
background: transparent;
color: var(--on-surface);
font: inherit;
text-align: left;
cursor: pointer;
}
.settings-row:hover {
background: color-mix(in srgb, var(--on-surface) 4%, transparent);
}
.settings-row--static {
cursor: default;
}
.settings-row--static:hover {
background: transparent;
}
.diagnostic__label {
display: flex;
flex-direction: column;
gap: 2px;
min-width: 0;
padding-right: 12px;
}
.diagnostic__error {
color: var(--text-muted);
font-size: 12px;
line-height: 1.3;
}
.settings-row__count {
display: flex;
align-items: center;
gap: 8px;
color: var(--text-muted);
font-size: 13px;
}
.settings-row__clear {
padding: 2px 10px;
border: 1px solid var(--outline);
border-radius: var(--shape-pill);
background: transparent;
color: var(--on-surface-variant);
font-size: 12px;
cursor: pointer;
}
.settings-row__clear:hover {
background: color-mix(in srgb, var(--on-surface) 8%, transparent);
}
.settings__support {
display: flex;
flex-direction: column;
align-items: flex-start;
gap: 8px;
}
.settings__support-link {
width: 100%;
}
.settings__support-hint {
align-self: stretch;
margin: 0;
color: var(--text-muted);
font-size: 12px;
text-align: center;
}
.settings__version {
margin: 0;
color: var(--text-muted);
font-size: 12px;
font-family: var(--font-mono);
text-align: left;
}
.list-editor__hint {
margin: 0;
color: var(--text-muted);
font-size: 12px;
}
.list-editor__chips {
display: flex;
flex-wrap: wrap;
gap: 6px;
}
.list-editor__empty {
margin: 0;
color: var(--text-muted);
font-size: 13px;
}
.list-editor__add-row {
display: flex;
gap: 8px;
}
.text-input {
flex: 1 1 auto;
min-width: 0;
height: 36px;
padding: 0 12px;
border: 1px solid var(--outline);
border-radius: var(--shape-inner);
background: var(--surface-bright);
color: var(--on-surface);
font: inherit;
}
.action--outline {
flex: 0 0 auto;
height: 36px;
padding: 0 16px;
border: 1px solid var(--primary);
border-radius: var(--shape-pill);
background: transparent;
color: var(--primary);
font: inherit;
font-weight: 500;
cursor: pointer;
}
.action--outline:hover {
background: color-mix(in srgb, var(--primary) 8%, transparent);
}
/* .action--outline на <a> вместо <button> — нужно вернуть центрирование текста
и убрать подчёркивание, которые браузер даёт ссылкам по умолчанию. */
a.action--outline {
display: inline-flex;
align-items: center;
justify-content: center;
text-decoration: none;
}
.action--outline:disabled {
cursor: default;
opacity: 0.5;
background: transparent;
}
.list-editor__error {
margin: 0;
color: var(--on-error-outline);
font-size: 12px;
}
.list-editor__reset {
align-self: flex-start;
padding: 6px 4px;
border: none;
background: transparent;
color: var(--text-muted);
font: inherit;
font-size: 12px;
text-decoration: underline;
cursor: pointer;
}
.list-editor__reset:hover:not(:disabled) {
color: var(--on-surface);
}
.list-editor__reset:disabled {
cursor: default;
opacity: 0.5;
text-decoration: none;
}
.action--danger {
width: 100%;
min-height: 44px;
padding: 0 20px;
border: 1px solid transparent;
border-radius: var(--shape-pill);
background: var(--error);
color: var(--on-error);
font: inherit;
font-weight: 500;
cursor: pointer;
}
.action--danger:hover {
background: color-mix(in srgb, var(--error) 88%, black);
}
.action--danger:active {
background: color-mix(in srgb, var(--error) 78%, black);
}
+111
View File
@@ -0,0 +1,111 @@
import { useState } from 'react'
import type { ZapretList } from '@shared/ipc-contract'
import { LIST_HINTS, LIST_TITLES } from '../lib/zapretListMeta'
/** Экран одного списка: чипы с удалением, поле добавления, сброс к встроенному. */
export default function ListEditor({
list,
onBack,
onSave,
onReset
}: {
list: ZapretList
onBack: () => void
onSave: (entries: string[]) => Promise<void>
onReset: () => Promise<void>
}): React.JSX.Element {
const [draft, setDraft] = useState('')
const [error, setError] = useState<string | null>(null)
const [busy, setBusy] = useState(false)
const remove = (entry: string): void => {
void run(list.entries.filter((item) => item !== entry))
}
const add = (): void => {
const value = draft.trim()
if (!value) return
void run([...list.entries, value])
}
const run = async (entries: string[]): Promise<void> => {
setBusy(true)
setError(null)
try {
await onSave(entries)
setDraft('')
} catch (err) {
setError(err instanceof Error ? err.message : String(err))
} finally {
setBusy(false)
}
}
return (
<section className="list-editor">
<div className="settings__back-row">
<button type="button" className="settings__back" onClick={onBack}>
‹ Назад
</button>
<h2 className="settings__title">{LIST_TITLES[list.id]}</h2>
</div>
<p className="list-editor__hint">{LIST_HINTS[list.id]}</p>
<div className="list-editor__chips">
{list.entries.length === 0 && <p className="list-editor__empty">Список пуст.</p>}
{list.entries.map((entry) => (
<span key={entry} className="chip chip--removable">
{entry}
<button
type="button"
className="chip__remove"
aria-label={`Убрать ${entry}`}
disabled={busy}
onClick={() => remove(entry)}
>
✕
</button>
</span>
))}
</div>
<div className="list-editor__add-row">
<input
type="text"
className="text-input"
placeholder={
list.id === 'ipset-exclude' || list.id === 'ipset-all'
? '192.168.0.0/16'
: 'example.com'
}
value={draft}
disabled={busy}
onChange={(event) => setDraft(event.target.value)}
onKeyDown={(event) => {
if (event.key === 'Enter') add()
}}
/>
<button
type="button"
className="action--outline"
disabled={busy || !draft.trim()}
onClick={add}
>
Добавить
</button>
</div>
{error && <p className="list-editor__error">{error}</p>}
<button
type="button"
className="list-editor__reset"
disabled={busy || list.isDefault}
onClick={() => void onReset()}
>
Сбросить к встроенному
</button>
</section>
)
}
@@ -0,0 +1,137 @@
import { useState } from 'react'
import type { ZapretListId, ZapretStatus } from '@shared/ipc-contract'
import { GITHUB_URL } from '@shared/links'
import { useZapretLists } from '../hooks/useZapretLists'
import { LIST_TITLES } from '../lib/zapretListMeta'
import ListEditor from './ListEditor'
/**
* Настройки: списки сайтов + автозапуск (Windows). Выбор стратегии и проверка
* соединения переехали на главный экран (ZapretCard.tsx) — рядом с кнопкой
* включения, где ими и пользуются. Отдельный экран за шестерёнкой в шапке.
*/
const isWindows = window.api.platform === 'win32'
const APP_CHANNEL = 'beta'
// Держать в синхроне с "version" в package.json — оттуда её берёт electron-builder
// для имени установщика и для «Установленные приложения» в Windows.
const APP_VERSION = '0.4.0'
export default function SettingsView({
onBack,
status,
onSetAutoStart
}: {
onBack: () => void
status: ZapretStatus | null
onSetAutoStart: (enabled: boolean) => Promise<void>
}): React.JSX.Element {
const { lists, autoHostlist, save, reset, clearAuto } = useZapretLists()
const [openListId, setOpenListId] = useState<ZapretListId | null>(null)
const openList = lists.find((list) => list.id === openListId)
if (openList) {
return (
<ListEditor
list={openList}
onBack={() => setOpenListId(null)}
onSave={async (entries) => {
await save(openList.id, entries)
}}
onReset={async () => {
await reset(openList.id)
}}
/>
)
}
return (
<section className="settings">
<div className="settings__back-row">
<button type="button" className="settings__back" onClick={onBack}>
‹ Назад
</button>
<h2 className="settings__title">Настройки</h2>
</div>
{isWindows && (
<div className="settings__group">
<span className="settings__label">Автозапуск</span>
<button
type="button"
className="settings-row"
onClick={() => void onSetAutoStart(!status?.autoStart)}
>
<span>Запускать вместе с Windows</span>
<span className={`chip ${status?.autoStart ? 'chip--success' : 'chip--neutral'}`}>
{status?.autoStart ? 'Включён' : 'Выключен'}
</span>
</button>
<p className="list-editor__hint">
Меняется через запрос прав администратора. Если выключить обход кнопкой на главном
экране, при включённом автозапуске он вернётся не раньше следующей перезагрузки.
</p>
</div>
)}
<div className="settings__group">
<span className="settings__label">Сайты</span>
{lists.map((list) => (
<button
key={list.id}
type="button"
className="settings-row"
onClick={() => setOpenListId(list.id)}
>
<span>{LIST_TITLES[list.id]}</span>
<span className="settings-row__count">{list.entries.length} ›</span>
</button>
))}
{/* На Windows ни одна из 22 стратегий Flowseal не использует --hostlist-auto — счётчик
всегда пуст, показывать его смысла нет. */}
{!isWindows && (
<div className="settings-row settings-row--static">
<span>Найдено автоматически</span>
<span className="settings-row__count">
{autoHostlist.length}
{autoHostlist.length > 0 && (
<button
type="button"
className="settings-row__clear"
onClick={() => void clearAuto()}
>
Очистить
</button>
)}
</span>
</div>
)}
</div>
<section className="card settings__support">
<h2>Поддержка</h2>
{/* Ссылка уходит в системный браузер: will-navigate в window.ts перехватывает
переход и отдаёт его openExternal с проверкой протокола. */}
<a href={GITHUB_URL} className="action--outline settings__support-link">
GitHub
</a>
<p className="settings__support-hint">Поддержите звёздочкой</p>
</section>
<p className="settings__version">
{APP_CHANNEL}-{APP_VERSION}-{window.api.platform}
</p>
{isWindows && (
<button
type="button"
className="action--danger"
onClick={() => void window.api.uninstallApp()}
>
Удалить приложение
</button>
)}
</section>
)
}
+103
View File
@@ -0,0 +1,103 @@
import { useZapret } from '../hooks/useZapret'
import { useZapretDiagnostics } from '../hooks/useZapretDiagnostics'
/** Карточка обхода блокировок: статус и включение сверху, выбор стратегии и проверка соединения ниже. */
export default function ZapretCard(): React.JSX.Element {
const { status, strategies, start, stop, setStrategy, busy } = useZapret()
const { results, running: testing, run: runTest } = useZapretDiagnostics()
const running = status?.state === 'running' || status?.state === 'starting'
const strategyId = status?.strategyId ?? strategies[0]?.id
const toggle = (): void => {
if (!strategyId) return
void (running ? stop() : start(strategyId))
}
const chip = ((): { className: string; label: string } => {
switch (status?.state) {
case 'running':
return { className: 'chip--success', label: 'Активен' }
case 'starting':
case 'stopping':
return { className: 'chip--neutral', label: 'Переключение…' }
case 'error':
return { className: 'chip--error', label: 'Ошибка' }
default:
return { className: 'chip--neutral', label: 'Выключен' }
}
})()
const toggleLabel =
status?.state === 'stopping' ? 'Выключение…' : running ? 'Выключить' : 'Включить'
return (
<section className="card zapret-card">
<div className="zapret-card__head">
<h2 className="zapret-card__title">Обход блокировок</h2>
<span className={`chip ${chip.className}`}>{chip.label}</span>
</div>
<button
type="button"
className={`toggle-button${running ? ' toggle-button--on' : ''}`}
disabled={busy || !strategyId}
onClick={toggle}
>
{toggleLabel}
</button>
{status?.state === 'error' && status.error && (
<p className="list-editor__error">{status.error}</p>
)}
<div className="settings__field">
<span className="settings__label">Стратегия</span>
{/* На время переключения список заблокирован: смена стратегии на работающем
обходе останавливает и перенастраивает службу, и второй выбор посреди этого
наложился бы на первый. */}
<select
className="select"
disabled={busy}
value={status?.strategyId ?? ''}
onChange={(event) => void setStrategy(event.target.value)}
>
{strategies.map((strategy) => (
<option key={strategy.id} value={strategy.id}>
{strategy.name}
</option>
))}
</select>
</div>
<button
type="button"
className="action--outline zapret-card__test-button"
disabled={testing}
onClick={() => void runTest()}
>
{testing ? 'Проверяю…' : 'Протестить'}
</button>
{results.length > 0 && (
<div className="settings__group">
{results.map((result) => (
<div key={result.id} className="settings-row settings-row--static">
{/* Причину показываем текстом, а не только подсказкой при наведении:
«Недоступно» само по себе не говорит, что чинить. */}
<span className="diagnostic__label">
{result.label}
{!result.ok && result.error && (
<small className="diagnostic__error">{result.error}</small>
)}
</span>
<span className={`chip ${result.ok ? 'chip--success' : 'chip--error'}`}>
{result.ok ? `${result.ms} мс` : 'Недоступно'}
</span>
</div>
))}
</div>
)}
</section>
)
}
+1
View File
@@ -0,0 +1 @@
/// <reference types="vite/client" />
+18
View File
@@ -0,0 +1,18 @@
import { useEffect } from 'react'
/**
* Синхронизирует окно с системной темой: следит за `nativeTheme` main-процесса
* и вешает класс `.dark` на <html>. Ручного переключателя нет — приложение
* всегда следует настройке ОС.
*/
export function useTheme(): void {
useEffect(() => {
void window.api.getTheme().then((theme) => {
document.documentElement.classList.toggle('dark', theme.shouldUseDarkColors)
})
return window.api.onThemeChanged((theme) => {
document.documentElement.classList.toggle('dark', theme.shouldUseDarkColors)
})
}, [])
}
+80
View File
@@ -0,0 +1,80 @@
import { useCallback, useEffect, useState } from 'react'
import type { ZapretLogLine, ZapretStatus, ZapretStrategy } from '@shared/ipc-contract'
const LOG_LIMIT = 50
/**
* Статус обхода блокировок + список пресетов, в синхроне с main-процессом.
* Тот же паттерн, что и `useTheme`: получить текущее значение, подписаться
* на изменения, вернуть функцию отписки из useEffect.
*/
export function useZapret(): {
status: ZapretStatus | null
strategies: ZapretStrategy[]
log: ZapretLogLine[]
start: (strategyId: string) => Promise<void>
stop: () => Promise<void>
setSystemProxy: (enabled: boolean) => Promise<void>
setStrategy: (strategyId: string) => Promise<void>
setAutoStart: (enabled: boolean) => Promise<void>
busy: boolean
} {
const [status, setStatus] = useState<ZapretStatus | null>(null)
const [strategies, setStrategies] = useState<ZapretStrategy[]>([])
const [log, setLog] = useState<ZapretLogLine[]>([])
const [busy, setBusy] = useState(false)
useEffect(() => {
void window.api.getZapretStatus().then(setStatus)
void window.api.getZapretStrategies().then(setStrategies)
return window.api.onZapretStatusChanged(setStatus)
}, [])
useEffect(() => {
return window.api.onZapretLog((entry) => {
setLog((previous) => [...previous, entry].slice(-LOG_LIMIT))
})
}, [])
const start = useCallback(async (strategyId: string) => {
setBusy(true)
try {
setStatus(await window.api.startZapret(strategyId))
} finally {
setBusy(false)
}
}, [])
const stop = useCallback(async () => {
setBusy(true)
try {
setStatus(await window.api.stopZapret())
} finally {
setBusy(false)
}
}, [])
const setSystemProxy = useCallback(async (enabled: boolean) => {
setStatus(await window.api.setZapretSystemProxy(enabled))
}, [])
const setStrategy = useCallback(async (strategyId: string) => {
setBusy(true)
try {
setStatus(await window.api.setZapretStrategy(strategyId))
} finally {
setBusy(false)
}
}, [])
const setAutoStart = useCallback(async (enabled: boolean) => {
setBusy(true)
try {
setStatus(await window.api.setZapretAutoStart(enabled))
} finally {
setBusy(false)
}
}, [])
return { status, strategies, log, start, stop, setSystemProxy, setStrategy, setAutoStart, busy }
}
@@ -0,0 +1,23 @@
import { useCallback, useState } from 'react'
import type { ZapretDiagnosticResult } from '@shared/ipc-contract'
/** Проверка соединения по клику — не авто-подписка, как в useZapret/useZapretLists. */
export function useZapretDiagnostics(): {
results: ZapretDiagnosticResult[]
running: boolean
run: () => Promise<void>
} {
const [results, setResults] = useState<ZapretDiagnosticResult[]>([])
const [running, setRunning] = useState(false)
const run = useCallback(async () => {
setRunning(true)
try {
setResults(await window.api.runZapretDiagnostics())
} finally {
setRunning(false)
}
}, [])
return { results, running, run }
}
+48
View File
@@ -0,0 +1,48 @@
import { useCallback, useEffect, useState } from 'react'
import type { ZapretList, ZapretListId } from '@shared/ipc-contract'
/**
* Списки сайтов + журнал автообнаружения, в синхроне с main-процессом.
* Тот же паттерн, что и `useZapret`/`useTheme`.
*/
export function useZapretLists(): {
lists: ZapretList[]
autoHostlist: string[]
save: (id: ZapretListId, entries: string[]) => Promise<ZapretList>
reset: (id: ZapretListId) => Promise<ZapretList>
clearAuto: () => Promise<void>
loading: boolean
} {
const [lists, setLists] = useState<ZapretList[]>([])
const [autoHostlist, setAutoHostlist] = useState<string[]>([])
const [loading, setLoading] = useState(true)
useEffect(() => {
void Promise.all([window.api.getZapretLists(), window.api.getZapretAutoHostlist()]).then(
([nextLists, nextAuto]) => {
setLists(nextLists)
setAutoHostlist(nextAuto)
setLoading(false)
}
)
}, [])
const save = useCallback(async (id: ZapretListId, entries: string[]) => {
const updated = await window.api.saveZapretList(id, entries)
setLists((previous) => previous.map((list) => (list.id === id ? updated : list)))
return updated
}, [])
const reset = useCallback(async (id: ZapretListId) => {
const updated = await window.api.resetZapretList(id)
setLists((previous) => previous.map((list) => (list.id === id ? updated : list)))
return updated
}, [])
const clearAuto = useCallback(async () => {
await window.api.clearZapretAutoHostlist()
setAutoHostlist([])
}, [])
return { lists, autoHostlist, save, reset, clearAuto, loading }
}
+19
View File
@@ -0,0 +1,19 @@
import type { ZapretListId } from '@shared/ipc-contract'
export const LIST_TITLES: Record<ZapretListId, string> = {
general: 'Обходить',
google: 'YouTube / Google',
exclude: 'Не трогать',
'ipset-exclude': 'Исключённые подсети',
'ipset-all': 'Подсети (IP)'
}
export const LIST_HINTS: Record<ZapretListId, string> = {
general: 'Обход применяется только к этим сайтам.',
google: 'Отдельный список — YouTube и Google обрабатываются иначе, чем остальные сайты.',
exclude: 'Эти сайты обход не трогает никогда — банки и похожие сервисы стоит оставить здесь.',
'ipset-exclude': 'Подсети, которые обход тоже не трогает (локальная сеть и т. п.).',
'ipset-all':
'Windows: адреса, к которым обход применяется по IP, а не по имени сайта — на случай, ' +
'если один хост прячет за собой сразу несколько доменов.'
}
+13
View File
@@ -0,0 +1,13 @@
import { StrictMode } from 'react'
import { createRoot } from 'react-dom/client'
import App from './App'
import './assets/main.css'
const container = document.getElementById('root')
if (!container) throw new Error('Не найден #root в index.html')
createRoot(container).render(
<StrictMode>
<App />
</StrictMode>
)
+140
View File
@@ -0,0 +1,140 @@
/**
* Единый контракт общения main ↔ renderer.
*
* Файл импортируется всеми тремя процессами (main, preload, renderer), поэтому
* здесь не должно быть импортов из `electron` или из DOM — только типы и константы.
*
* Чтобы добавить новый канал:
* 1. описать его в `IpcHandlers` (аргументы + результат);
* 2. зарегистрировать обработчик в `src/main/ipc.ts` (TS не даст ошибиться в типах);
* 3. пробросить метод наружу в `src/preload/index.ts`.
*/
export type ThemeSource = 'system' | 'light' | 'dark'
export interface ThemeState {
/** Что выбрал пользователь. */
source: ThemeSource
/** Что реально показывать сейчас (учитывает системную тему). */
shouldUseDarkColors: boolean
}
/** Состояние обхода блокировок. */
export type ZapretState = 'stopped' | 'starting' | 'running' | 'stopping' | 'error'
/**
* Чем именно обходим на этой платформе:
* • `tpws-socks` — macOS: локальный socks5-прокси, права не нужны;
* • `winws-service` — Windows: служба с драйвером WinDivert;
* • `unsupported` — платформа без поддержки (Linux у нас не собран).
*/
export type ZapretBackend = 'tpws-socks' | 'winws-service' | 'unsupported'
export interface ZapretStatus {
state: ZapretState
backend: ZapretBackend
/** Выбранный пресет из `zapret:strategies`. */
strategyId: string
/** Когда обход включён, ISO-8601. */
startedAt: string | null
/** Текст последней ошибки — показываем пользователю как есть. */
error: string | null
/** macOS: адрес socks5-прокси, пока обход работает. */
socksAddress: string | null
/** macOS: прописан ли прокси в системных настройках сети. */
systemProxyApplied: boolean
/** Windows: установлена ли служба (её ставит и настраивает само приложение при первом включении). */
serviceInstalled: boolean
/** Windows: стоит ли служба на автозапуск с системой (`sc config start=`). */
autoStart: boolean
}
/** Готовый набор параметров обхода. Подбирается под провайдера. */
export interface ZapretStrategy {
id: string
name: string
description: string
platforms: NodeJS.Platform[]
}
export interface ZapretLogLine {
level: 'info' | 'error'
line: string
}
/**
* Списки сайтов, применяемые обоими движками (см. `src/main/zapret/lists.ts`).
* Встроенное содержимое берётся из Flowseal/zapret-discord-youtube (MIT) — пользователь
* может редактировать любой список полностью, включая встроенные.
*/
export type ZapretListId = 'general' | 'google' | 'exclude' | 'ipset-exclude' | 'ipset-all'
export interface ZapretList {
id: ZapretListId
/** Домены (general/google/exclude) или IP/CIDR (ipset-exclude/ipset-all), уже без пустых строк и комментариев. */
entries: string[]
/** Совпадает ли содержимое с текущей встроенной версией — пользователь ничего не менял. */
isDefault: boolean
}
/**
* Результат проверки соединения (`src/main/zapret/diagnostics.ts`): успех — это успешный
* TLS-хендшейк до хоста через текущий бэкенд обхода, не полноценный HTTP-запрос.
*/
export interface ZapretDiagnosticResult {
id: string
label: string
host: string
ok: boolean
ms: number | null
error: string | null
}
/**
* Каналы вида renderer → main → ответ (`ipcRenderer.invoke` / `ipcMain.handle`).
* Ключ — имя канала, значение — сигнатура обработчика.
*/
export interface IpcHandlers {
'theme:get': () => ThemeState
/** Windows: подтверждает диалогом ОС, запускает деинсталлятор и закрывает приложение. */
'app:uninstall': () => void
'zapret:status': () => ZapretStatus
'zapret:strategies': () => ZapretStrategy[]
'zapret:start': (strategyId: string) => ZapretStatus
'zapret:stop': () => ZapretStatus
/** macOS: прописать/убрать socks-прокси в системных настройках сети. */
'zapret:set-system-proxy': (enabled: boolean) => ZapretStatus
/**
* Выбор пресета. Если обход выключен — просто запоминается. Если включён — на macOS
* перезапускает tpws, на Windows перенастраивает службу (потребуется UAC).
*/
'zapret:set-strategy': (strategyId: string) => ZapretStatus
/** Windows: автозапуск службы с системой (`sc config start=`); требует UAC. */
'zapret:set-autostart': (enabled: boolean) => ZapretStatus
'zapret:lists': () => ZapretList[]
'zapret:list-save': (id: ZapretListId, entries: string[]) => ZapretList
'zapret:list-reset': (id: ZapretListId) => ZapretList
/** Список, который zapret пополняет сам через --hostlist-auto (только macOS-профили). */
'zapret:auto-hostlist': () => string[]
'zapret:auto-hostlist-clear': () => void
/** Проверка соединения: успешный TLS-хендшейк до нескольких сайтов через текущий бэкенд. */
'zapret:diagnose': () => ZapretDiagnosticResult[]
}
export type IpcChannel = keyof IpcHandlers
/** Каналы вида main → renderer (`webContents.send` / `ipcRenderer.on`). */
export interface IpcEvents {
'theme:changed': ThemeState
'zapret:status-changed': ZapretStatus
'zapret:log': ZapretLogLine
}
export type IpcEventChannel = keyof IpcEvents
/** Разрешённые протоколы для `shell.openExternal` — всё остальное main отклоняет. */
export const ALLOWED_EXTERNAL_PROTOCOLS = ['https:', 'mailto:'] as const
+9
View File
@@ -0,0 +1,9 @@
/**
* Адрес репозитория — одно место на всё приложение. Отсюда его берут и кнопка «GitHub»
* в настройках (`SettingsView.tsx`), и приветственное окно при первом запуске
* (`src/main/first-run.ts`).
*
* TODO: заглушка. Пока ведёт на главную GitHub — заменить на настоящий адрес репозитория,
* когда он появится. Менять только здесь, оба места подхватят автоматически.
*/
export const GITHUB_URL = 'https://github.com'