diff --git a/README.md b/README.md index 625a33a..a2a0365 100644 --- a/README.md +++ b/README.md @@ -51,7 +51,10 @@ sudo "/Library/Application Support/SenBoost/zapret/stop.sh" и ставятся сами; для AppImage — своими силами, `sudo apt install nftables policykit-1`). Ни службы, ни автозапуска с системой на Linux нет: демон и правила nftables переживают выход из приложения (правила закрыты флагом `bypass` — без работающего nfqws трафик просто проходит -как есть, а не блокируется), но после перезагрузки заново их никто не поднимает. +как есть, а не блокируется), но после перезагрузки заново их никто не поднимает. Автозапуск +самого приложения (см. ниже) частично это закрывает — обход поднимется при входе в систему, +но потребует `pkexec` на каждый вход. + ## Ссылки diff --git a/build/installer.nsh b/build/installer.nsh index 2b90b04..503bb8b 100644 --- a/build/installer.nsh +++ b/build/installer.nsh @@ -8,9 +8,13 @@ ; Поэтому именно для этого шага отдельно запрашиваем UAC (тем же приёмом Start-Process ; -Verb RunAs, что и src/main/zapret/service.win32.ts), а не поднимаем весь деинсталлятор — ; удаление файлов приложения из %LocalAppData% в повышении не нуждается. +; +; Тем же повышенным вызовом снимаем задачу автозапуска приложения («SenBoost Autostart», +; src/main/app-autostart.win32.ts): она создана с правами администратора и после удаления +; приложения указывала бы на несуществующий exe. !macro customUnInstall - DetailPrint "Останавливаю службу обхода блокировок..." - nsExec::Exec `powershell.exe -NoProfile -Command "try { Start-Process powershell -Verb RunAs -Wait -ArgumentList @('-NoProfile','-Command','sc.exe stop senboost-zapret; sc.exe delete senboost-zapret; Remove-Item -Recurse -Force $$env:ProgramData\senboost -ErrorAction SilentlyContinue') } catch {}"` + DetailPrint "Останавливаю службу обхода блокировок и снимаю автозапуск..." + nsExec::Exec `powershell.exe -NoProfile -Command "try { Start-Process powershell -Verb RunAs -Wait -ArgumentList @('-NoProfile','-Command','sc.exe stop senboost-zapret; sc.exe delete senboost-zapret; schtasks.exe /Delete /TN ''SenBoost Autostart'' /F; Remove-Item -Recurse -Force $$env:ProgramData\senboost -ErrorAction SilentlyContinue') } catch {}"` Pop $0 !macroend diff --git a/package-lock.json b/package-lock.json index 18ceb94..5f54bf8 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "senboost", - "version": "0.6.0", + "version": "0.6.1", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "senboost", - "version": "0.6.0", + "version": "0.6.1", "hasInstallScript": true, "license": "MIT", "dependencies": { diff --git a/package.json b/package.json index da9ccd1..1d17704 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "senboost", - "version": "0.6.0", + "version": "0.6.1", "description": "Кроссплатформенное десктопное приложение на Electron + React + TypeScript", "main": "./out/main/index.js", "desktopName": "senboost.desktop", diff --git a/resources/tray/off-16.png b/resources/tray/off-16.png new file mode 100644 index 0000000..4dca018 Binary files /dev/null and b/resources/tray/off-16.png differ diff --git a/resources/tray/off-32.png b/resources/tray/off-32.png new file mode 100644 index 0000000..38087a3 Binary files /dev/null and b/resources/tray/off-32.png differ diff --git a/resources/tray/on-16.png b/resources/tray/on-16.png new file mode 100644 index 0000000..ea19483 Binary files /dev/null and b/resources/tray/on-16.png differ diff --git a/resources/tray/on-32.png b/resources/tray/on-32.png new file mode 100644 index 0000000..927b146 Binary files /dev/null and b/resources/tray/on-32.png differ diff --git a/scripts/make-tray-icons.mjs b/scripts/make-tray-icons.mjs new file mode 100644 index 0000000..a718cc1 --- /dev/null +++ b/scripts/make-tray-icons.mjs @@ -0,0 +1,140 @@ +// Рисует иконки для трея: щит с заливкой («обход включён») и щит контуром («выключен»), +// по паре размеров на каждую (16 и 32 px — 1x и 2x для меню-бара macOS). +// +// Чёрный цвет + альфа — это ровно то, что macOS ждёт от template-картинки: систему интересует +// только альфа-канал, цвет она подставляет сама под светлое или тёмное меню-бар. +// +// Скрипт запускается руками (`node scripts/make-tray-icons.mjs`), результат лежит в +// resources/tray и коммитится: в сборку его не включаем, чтобы не тащить генерацию картинок +// в каждый `npm run build`. +import { deflateSync } from 'node:zlib' +import { mkdir, writeFile } from 'node:fs/promises' +import { dirname, join } from 'node:path' +import { fileURLToPath } from 'node:url' + +const OUT_DIR = join(dirname(fileURLToPath(import.meta.url)), '..', 'resources', 'tray') + +/** Сглаживание: считаем SUPERSAMPLE² проб на пиксель и усредняем — иначе на 16 px рвань. */ +const SUPERSAMPLE = 4 + +/** Толщина контура у «выключенного» щита, в долях размера. */ +const STROKE = 0.11 + +/** Центр, относительно которого сжимаем щит, чтобы получить внутренний край контура. */ +const CENTER = { x: 0.5, y: 0.54 } + +const TOP = 0.1 +const BOTTOM = 0.94 +/** Ниже этой линии бока щита сходятся к острию. */ +const SHOULDER = 0.56 +const HALF_WIDTH = 0.34 +/** Скругление верхних углов. */ +const CORNER = 0.1 + +/** + * Точка внутри щита? Щит — прямоугольник со скруглённым верхом, ниже «плеч» сужающийся + * по четверти эллипса к острию внизу. + */ +function insideShield(x, y) { + if (y < TOP || y > BOTTOM) return false + + let halfWidth = HALF_WIDTH + if (y > SHOULDER) { + const t = (y - SHOULDER) / (BOTTOM - SHOULDER) + halfWidth = HALF_WIDTH * Math.sqrt(Math.max(0, 1 - t * t)) + } + + const dx = Math.abs(x - 0.5) + if (dx > halfWidth) return false + + // Скругление верхних углов: за пределами прямых участков считаем расстояние до центра дуги. + if (y < TOP + CORNER && dx > halfWidth - CORNER) { + const cx = halfWidth - CORNER + const cy = TOP + CORNER + return (dx - cx) ** 2 + (y - cy) ** 2 <= CORNER * CORNER + } + + return true +} + +/** Тот же щит, сжатый к центру на толщину контура, — внутренняя граница обводки. */ +function insideInnerShield(x, y) { + const scale = 1 / (1 - 2 * STROKE) + return insideShield((x - CENTER.x) * scale + CENTER.x, (y - CENTER.y) * scale + CENTER.y) +} + +/** Альфа пикселя: доля проб, попавших в фигуру. */ +function coverage(px, py, size, filled) { + let hits = 0 + for (let sy = 0; sy < SUPERSAMPLE; sy++) { + for (let sx = 0; sx < SUPERSAMPLE; sx++) { + const x = (px + (sx + 0.5) / SUPERSAMPLE) / size + const y = (py + (sy + 0.5) / SUPERSAMPLE) / size + if (!insideShield(x, y)) continue + if (!filled && insideInnerShield(x, y)) continue + hits++ + } + } + return Math.round((hits / (SUPERSAMPLE * SUPERSAMPLE)) * 255) +} + +function rgba(size, filled) { + // По байту на канал, плюс ведущий байт фильтра в начале каждой строки (формат PNG). + const rows = Buffer.alloc(size * (size * 4 + 1)) + let offset = 0 + for (let y = 0; y < size; y++) { + rows[offset++] = 0 // filter type 0 (None) + for (let x = 0; x < size; x++) { + offset += 3 // R,G,B остаются нулями — чёрный + rows[offset++] = coverage(x, y, size, filled) + } + } + return rows +} + +function chunk(type, data) { + const length = Buffer.alloc(4) + length.writeUInt32BE(data.length) + const body = Buffer.concat([Buffer.from(type, 'ascii'), data]) + const crc = Buffer.alloc(4) + crc.writeUInt32BE(crc32(body)) + return Buffer.concat([length, body, crc]) +} + +const CRC_TABLE = Array.from({ length: 256 }, (_, n) => { + let c = n + for (let k = 0; k < 8; k++) c = c & 1 ? 0xedb88320 ^ (c >>> 1) : c >>> 1 + return c >>> 0 +}) + +function crc32(buffer) { + let c = 0xffffffff + for (const byte of buffer) c = CRC_TABLE[(c ^ byte) & 0xff] ^ (c >>> 8) + return (c ^ 0xffffffff) >>> 0 +} + +function png(size, filled) { + const header = Buffer.alloc(13) + header.writeUInt32BE(size, 0) + header.writeUInt32BE(size, 4) + header[8] = 8 // бит на канал + header[9] = 6 // цветовой тип: RGBA + return Buffer.concat([ + Buffer.from([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a]), + chunk('IHDR', header), + chunk('IDAT', deflateSync(rgba(size, filled), { level: 9 })), + chunk('IEND', Buffer.alloc(0)) + ]) +} + +await mkdir(OUT_DIR, { recursive: true }) +for (const [name, filled] of [ + ['on', true], + ['off', false] +]) { + for (const size of [16, 32]) { + const path = join(OUT_DIR, `${name}-${size}.png`) + await writeFile(path, png(size, filled)) + console.log(`создано: ${path}`) + } +} diff --git a/src/main/app-autostart.linux.ts b/src/main/app-autostart.linux.ts new file mode 100644 index 0000000..07fc0ae --- /dev/null +++ b/src/main/app-autostart.linux.ts @@ -0,0 +1,58 @@ +import { mkdir, readFile, rm, writeFile } from 'node:fs/promises' +import { homedir } from 'node:os' +import { join } from 'node:path' +import { app } from 'electron' + +/** + * На Linux `app.setLoginItemSettings` не работает (Electron поддерживает его только на macOS + * и Windows), поэтому пишем .desktop-файл в каталог автозапуска — общее для GNOME, KDE, XFCE + * соглашение freedesktop. + */ +function autostartFilePath(): string { + const configHome = process.env['XDG_CONFIG_HOME'] || join(homedir(), '.config') + return join(configHome, 'autostart', 'senboost.desktop') +} + +/** + * Внутри AppImage `process.execPath` указывает во временную точку монтирования, которая + * исчезает после выхода, — запускать надо сам файл образа. + */ +function execCommand(): string { + const binary = process.env['APPIMAGE'] || process.execPath + const args = app.isPackaged ? [] : [app.getAppPath()] + return [binary, ...args, '--hidden'].map((part) => `"${part}"`).join(' ') +} + +export async function isAppAutoStartEnabled(): Promise { + try { + const content = await readFile(autostartFilePath(), 'utf8') + // Файл могли оставить включённым, но отключить галочкой в «Автозапуске» окружения — + // тогда в нём стоит X-GNOME-Autostart-enabled=false. + return !/^X-GNOME-Autostart-enabled\s*=\s*false\s*$/im.test(content) + } catch { + return false + } +} + +export async function setAppAutoStart(enabled: boolean): Promise { + const path = autostartFilePath() + + if (!enabled) { + await rm(path, { force: true }) + return + } + + const entry = [ + '[Desktop Entry]', + 'Type=Application', + 'Name=SenBoost', + 'Comment=Обход блокировок через zapret', + `Exec=${execCommand()}`, + 'Terminal=false', + 'X-GNOME-Autostart-enabled=true', + '' + ].join('\n') + + await mkdir(join(path, '..'), { recursive: true }) + await writeFile(path, entry, 'utf8') +} diff --git a/src/main/app-autostart.ts b/src/main/app-autostart.ts new file mode 100644 index 0000000..528f1c8 --- /dev/null +++ b/src/main/app-autostart.ts @@ -0,0 +1,56 @@ +import { app } from 'electron' +import * as linux from './app-autostart.linux' +import * as win32 from './app-autostart.win32' + +/** + * Автозапуск САМОГО приложения вместе с системой — не путать с автозапуском обхода + * (`zapret:set-autostart`): тот поднимает службу Windows или LaunchDaemon macOS вообще без + * приложения, а этот открывает приложение свёрнутым в трей, чтобы обход можно было включить + * и выключить одним кликом. + * + * Источник истины — сама система (реестр/планировщик, SMAppService, каталог автозапуска), + * в settings.json ничего не дублируем: после переустановки системы или приложения + * сохранённое значение всё равно разошлось бы с реальностью. + */ +export function isAppAutoStartEnabled(): Promise { + switch (process.platform) { + case 'darwin': + return Promise.resolve(app.getLoginItemSettings().openAtLogin) + case 'win32': + return win32.isAppAutoStartEnabled() + case 'linux': + return linux.isAppAutoStartEnabled() + default: + return Promise.resolve(false) + } +} + +export async function setAppAutoStart(enabled: boolean): Promise { + switch (process.platform) { + case 'darwin': + // Electron 44 регистрирует приложение через SMAppService: пароль администратора не + // нужен, но система может попросить подтвердить запись в «Объектах входа». + app.setLoginItemSettings({ openAtLogin: enabled }) + return + case 'win32': + await win32.setAppAutoStart(enabled) + return + case 'linux': + await linux.setAppAutoStart(enabled) + return + default: + throw new Error('Автозапуск приложения на этой платформе не поддерживается.') + } +} + +/** + * Запуск пришёл из автозапуска — окно показывать не нужно, приложение садится в трей. + * + * На Windows и Linux мы сами передаём `--hidden` в команде автозапуска; на macOS аргументы + * задать нельзя (поле `args` у `setLoginItemSettings` только для Windows), поэтому спрашиваем + * систему, она ли нас открыла. + */ +export function shouldStartHidden(): boolean { + if (process.argv.includes('--hidden')) return true + return process.platform === 'darwin' && app.getLoginItemSettings().wasOpenedAtLogin +} diff --git a/src/main/app-autostart.win32.ts b/src/main/app-autostart.win32.ts new file mode 100644 index 0000000..f6ed4c2 --- /dev/null +++ b/src/main/app-autostart.win32.ts @@ -0,0 +1,59 @@ +import { execFile } from 'node:child_process' +import { promisify } from 'node:util' +import { app } from 'electron' +import { isElevated } from './zapret/elevated-task.win32' + +const run = promisify(execFile) + +/** Имя задачи в планировщике. Оно же снимается деинсталлятором (`build/installer.nsh`). */ +const TASK_NAME = 'SenBoost Autostart' + +/** + * Автозапуск приложения на Windows делаем задачей планировщика, а НЕ ключом реестра + * (`app.setLoginItemSettings`): exe собран с `requestedExecutionLevel: requireAdministrator` + * (см. electron-builder.yml), а UAC при входе в систему не спрашивают — приложение из ключа + * реестра просто не запустилось бы. Задача с `/RL HIGHEST` поднимает его с правами сразу. + */ +function taskCommand(): string { + // В dev первым аргументом electron.exe нужен каталог приложения, в сборке — нет. + const args = app.isPackaged ? [] : [app.getAppPath()] + return [`"${process.execPath}"`, ...args.map((arg) => `"${arg}"`), '--hidden'].join(' ') +} + +export async function isAppAutoStartEnabled(): Promise { + try { + await run('schtasks.exe', ['/Query', '/TN', TASK_NAME], { windowsHide: true }) + return true + } catch { + // Единственный ожидаемый случай — задачи нет; отличать его от сломанного schtasks нечем + // (текст сообщения локализован), да и результат для пользователя один и тот же. + return false + } +} + +export async function setAppAutoStart(enabled: boolean): Promise { + // Создание и удаление задачи требуют прав администратора. В собранном приложении они уже + // есть всегда, лишнего UAC не будет; в dev-режиме — нет, и молчать об этом нельзя. + if (!(await isElevated())) { + throw new Error( + 'Автозапуск настраивается только в установленном приложении: задача планировщика ' + + 'создаётся с правами администратора, а в режиме разработки их нет.' + ) + } + + const args = enabled + ? ['/Create', '/TN', TASK_NAME, '/TR', taskCommand(), '/SC', 'ONLOGON', '/RL', 'HIGHEST', '/F'] + : ['/Delete', '/TN', TASK_NAME, '/F'] + + try { + await run('schtasks.exe', args, { windowsHide: true }) + } catch (error) { + // Снятие несуществующей задачи — цель уже достигнута, не ошибка. + if (!enabled && !(await isAppAutoStartEnabled())) return + throw new Error( + `Не удалось ${enabled ? 'включить' : 'выключить'} автозапуск приложения: ` + + `${error instanceof Error ? error.message : String(error)}`, + { cause: error } + ) + } +} diff --git a/src/main/index.ts b/src/main/index.ts index 8e702cf..f179d40 100644 --- a/src/main/index.ts +++ b/src/main/index.ts @@ -1,8 +1,11 @@ -import { app, BrowserWindow } from 'electron' +import { app } from 'electron' import { electronApp, optimizer } from '@electron-toolkit/utils' -import { createWindow } from './window' +import { shouldStartHidden } from './app-autostart' +import { beginQuit, createWindow, showMainWindow } from './window' import { showFirstRunSupportDialog } from './first-run' import { registerIpcHandlers } from './ipc' +import { loadSettings } from './settings' +import { createTray } from './tray' import { disposeZapret, recoverZapret } from './zapret' import { elevatedTaskDir, runElevatedServiceTask } from './zapret/elevated-task.win32' @@ -22,16 +25,11 @@ if (elevatedTask) { app.quit() } else { app.on('second-instance', () => { - const [window] = BrowserWindow.getAllWindows() - if (!window) { - createWindow() - return - } - if (window.isMinimized()) window.restore() - window.focus() + // Окна может не быть вовсе: приложение живёт в трее, в том числе стартовав свёрнутым. + showMainWindow() }) - app.whenReady().then(() => { + app.whenReady().then(async () => { // Нужно Windows для группировки в панели задач и уведомлений. electronApp.setAppUserModelId(APP_ID) @@ -40,23 +38,54 @@ if (elevatedTask) { optimizer.watchWindowShortcuts(window) }) + // Настройки читаем один раз здесь: их ждут и recoverZapret (выбранная стратегия), + // и окно (прятать ли его в трей по закрытию). + await loadSettings() + registerIpcHandlers() + // Трей создаём до окна: окно спрашивает у него, есть ли куда прятаться по закрытию. + createTray({ + showWindow: () => { + showMainWindow() + }, + quit: () => { + beginQuit() + app.quit() + } + }) + + // Запуск из автозапуска системы — приложение садится в трей, окно не показываем. + const startHidden = shouldStartHidden() + // На macOS чинит прокси, оставшийся от аварийно завершённого прошлого запуска, // до того как окно вообще откроется. void recoverZapret().then(() => { - const window = createWindow() + const window = createWindow({ show: !startHidden }) // Ждём первого кадра: диалог должен появиться поверх готового окна, а не пустого. - window.once('ready-to-show', () => void showFirstRunSupportDialog(window)) + // Свёрнутому в трей приложению показывать модальное окно некуда — пользователь его + // не увидит и не закроет, а метку «уже показывали» диалог поставит. + if (!startHidden) { + window.once('ready-to-show', () => void showFirstRunSupportDialog(window)) + } }) - // macOS: клик по иконке в доке при закрытых окнах открывает окно заново. + // macOS: клик по иконке в доке (в том числе при закрытых окнах) открывает окно. + // Система шлёт это событие и при самом запуске приложения — на первое из них при + // свёрнутом старте не реагируем, иначе автозапуск сразу же показал бы окно. + let ignoreActivate = startHidden app.on('activate', () => { - if (BrowserWindow.getAllWindows().length === 0) createWindow() + if (ignoreActivate) { + ignoreActivate = false + return + } + showMainWindow() }) }) // Windows / Linux: закрыли последнее окно — вышли. macOS: приложение живёт до Cmd+Q. + // При включённом «сворачивать в трей» окно не закрывается, а прячется, и сюда мы не попадаем + // (см. обработчик `close` в window.ts). app.on('window-all-closed', () => { if (process.platform !== 'darwin') { app.quit() @@ -67,6 +96,8 @@ if (elevatedTask) { // иначе процесс останется висеть, а на macOS ещё и интернет пропадёт. let zapretCleaned = false app.on('before-quit', (event) => { + // С этого момента закрытие окна означает выход, а не сворачивание в трей. + beginQuit() if (zapretCleaned) return event.preventDefault() void disposeZapret().finally(() => { diff --git a/src/main/ipc.ts b/src/main/ipc.ts index 89cb4fb..e504287 100644 --- a/src/main/ipc.ts +++ b/src/main/ipc.ts @@ -1,5 +1,13 @@ import { app, BrowserWindow, ipcMain, nativeTheme, type IpcMainInvokeEvent } from 'electron' -import type { IpcChannel, IpcHandlers, ThemeState } from '../shared/ipc-contract' +import type { + BackgroundSettings, + IpcChannel, + IpcHandlers, + ThemeState +} from '../shared/ipc-contract' +import { isAppAutoStartEnabled, setAppAutoStart } from './app-autostart' +import { rememberPreferences, getSettings } from './settings' +import { isTrayActive } from './tray' import { uninstallApp } from './uninstall.win32' import { resetZapretList, @@ -40,6 +48,15 @@ function themeState(): ThemeState { } } +async function backgroundSettings(): Promise { + return { + // `null` в настройках — «не выбирали»: по умолчанию прячем в трей. + closeToTray: getSettings().closeToTray !== false, + launchAtLogin: await isAppAutoStartEnabled(), + trayAvailable: isTrayActive() + } +} + export function registerIpcHandlers(): void { handle('theme:get', () => themeState()) @@ -54,6 +71,15 @@ export function registerIpcHandlers(): void { if (process.platform === 'win32') void uninstallApp(windowOf(event)) }) + handle('app:background-get', () => backgroundSettings()) + handle('app:background-set', async (_event, patch) => { + if (patch.closeToTray !== undefined) rememberPreferences({ closeToTray: patch.closeToTray }) + // Автозапуск меняем последним и не глотаем ошибку: пользователь должен узнать, что + // задача в планировщике (или объект входа) не создалась. + if (patch.launchAtLogin !== undefined) await setAppAutoStart(patch.launchAtLogin) + return backgroundSettings() + }) + handle('zapret:status', () => zapretStatus()) handle('zapret:strategies', () => zapretStrategies()) handle('zapret:start', (_event, strategyId) => startZapret(strategyId)) diff --git a/src/main/settings.ts b/src/main/settings.ts index e12a570..89c889d 100644 --- a/src/main/settings.ts +++ b/src/main/settings.ts @@ -16,9 +16,18 @@ export interface AppSettings { strategyId: string | null /** Windows: запускать службу вместе с системой. На macOS не используется. */ autoStart: boolean | null + /** Закрытие окна прячет его в трей вместо выхода. `null` — не выбирали, считаем включённым. */ + closeToTray: boolean | null + /** Подсказку «свернулся в трей и продолжаю работать» уже показывали. */ + trayHintShown: boolean | null } -const DEFAULTS: AppSettings = { strategyId: null, autoStart: null } +const DEFAULTS: AppSettings = { + strategyId: null, + autoStart: null, + closeToTray: null, + trayHintShown: null +} let cache: AppSettings = { ...DEFAULTS } @@ -37,7 +46,9 @@ function sanitize(raw: unknown): AppSettings { const value = (typeof raw === 'object' && raw !== null ? raw : {}) as Record return { strategyId: typeof value.strategyId === 'string' && value.strategyId ? value.strategyId : null, - autoStart: typeof value.autoStart === 'boolean' ? value.autoStart : null + autoStart: typeof value.autoStart === 'boolean' ? value.autoStart : null, + closeToTray: typeof value.closeToTray === 'boolean' ? value.closeToTray : null, + trayHintShown: typeof value.trayHintShown === 'boolean' ? value.trayHintShown : null } } @@ -60,14 +71,22 @@ async function writeSettings(settings: AppSettings): Promise { await rename(tmp, path) } +/** Настройки, прочитанные при старте. До `loadSettings()` — умолчания. */ +export function getSettings(): AppSettings { + return { ...cache } +} + /** - * Вызывается из `ZapretEngine.patch()`, то есть на каждое изменение статуса — отсюда - * сравнение с прошлым значением. Записи не ждём: интерфейс не должен зависеть от диска, - * а не сохранившийся выбор не повод ломать работающий обход. + * Сохранить изменившиеся поля. Вызывается в том числе из `ZapretEngine.patch()`, то есть на + * каждое изменение статуса — отсюда сравнение с прошлым значением. Записи не ждём: интерфейс + * не должен зависеть от диска, а не сохранившийся выбор не повод ломать работающий обход. */ -export function rememberPreferences(settings: AppSettings): void { +export function rememberPreferences(patch: Partial): void { if (!loaded) return - if (settings.strategyId === cache.strategyId && settings.autoStart === cache.autoStart) return + + const settings: AppSettings = { ...cache, ...patch } + const keys = Object.keys(settings) as (keyof AppSettings)[] + if (keys.every((key) => settings[key] === cache[key])) return cache = settings writeQueue = writeQueue diff --git a/src/main/tray.ts b/src/main/tray.ts new file mode 100644 index 0000000..1b2fe2e --- /dev/null +++ b/src/main/tray.ts @@ -0,0 +1,156 @@ +import { readFileSync } from 'node:fs' +import { app, dialog, Menu, nativeImage, Notification, Tray, type NativeImage } from 'electron' +import type { ZapretStatus } from '../shared/ipc-contract' +import appIcon from '../../resources/icon.png?asset' +import trayOn1x from '../../resources/tray/on-16.png?asset' +import trayOn2x from '../../resources/tray/on-32.png?asset' +import trayOff1x from '../../resources/tray/off-16.png?asset' +import trayOff2x from '../../resources/tray/off-32.png?asset' +import { onZapretStatusChanged, startZapret, stopZapret, zapretStatus } from './zapret' + +/** + * Ссылку держим на уровне модуля: `Tray` живёт ровно столько, сколько живёт объект, и + * локальная переменная означала бы исчезающую при сборке мусора иконку. + */ +let tray: Tray | null = null + +/** Действия, которые трею делает не сам — их владелец `src/main/index.ts`. */ +interface TrayHandlers { + showWindow: () => void + quit: () => void +} + +let handlers: TrayHandlers | null = null + +/** Идёт включение или выключение — пункт меню на это время недоступен. */ +function isBusy(status: ZapretStatus): boolean { + return status.state === 'starting' || status.state === 'stopping' +} + +function statusLabel(status: ZapretStatus): string { + switch (status.state) { + case 'running': + return 'Обход включён' + case 'starting': + return 'Обход включается…' + case 'stopping': + return 'Обход выключается…' + case 'error': + return 'Ошибка обхода' + default: + return 'Обход выключен' + } +} + +/** + * Картинка в трее. + * + * macOS: монохромный щит с прозрачным фоном (`resources/tray`, генерируется скриптом + * `scripts/make-tray-icons.mjs`), помеченный как template — систему интересует только альфа, + * цвет она подставляет сама под светлый и тёмный меню-бар. Вариант 2x добавляем через + * `addRepresentation`, а не соседним файлом `@2x`: electron-vite при импорте `?asset` + * переносит файлы поштучно, и соглашение об именах через сборку не переживает. + * + * Windows и Linux: уменьшенная иконка приложения — она непрозрачный квадрат, поэтому + * состояние обхода показываем не картинкой, а подсказкой и текстом меню. + */ +function trayImage(status: ZapretStatus): NativeImage { + if (process.platform !== 'darwin') { + return nativeImage + .createFromPath(appIcon) + .resize(process.platform === 'linux' ? { width: 22, height: 22 } : { width: 16, height: 16 }) + } + + const running = status.state === 'running' + const image = nativeImage.createFromPath(running ? trayOn1x : trayOff1x) + image.addRepresentation({ scaleFactor: 2, buffer: readFileSync(running ? trayOn2x : trayOff2x) }) + image.setTemplateImage(true) + return image +} + +/** Ошибку показываем системным окном: главного окна на экране может и не быть. */ +function reportError(error: unknown): void { + dialog.showErrorBox('SenBoost', error instanceof Error ? error.message : String(error)) +} + +function toggle(status: ZapretStatus): void { + const action = status.state === 'running' ? stopZapret() : startZapret(status.strategyId) + action.catch(reportError) +} + +function render(status: ZapretStatus): void { + if (!tray || !handlers) return + + const supported = status.backend !== 'unsupported' + const running = status.state === 'running' + + tray.setImage(trayImage(status)) + tray.setToolTip(`SenBoost — ${statusLabel(status).toLowerCase()}`) + tray.setContextMenu( + Menu.buildFromTemplate([ + { label: statusLabel(status), enabled: false }, + { type: 'separator' }, + { + label: running ? 'Выключить обход' : 'Включить обход', + enabled: supported && !isBusy(status), + click: () => toggle(status) + }, + { label: 'Показать окно', click: () => handlers?.showWindow() }, + { type: 'separator' }, + { label: 'Выход', click: () => handlers?.quit() } + ]) + ) +} + +/** + * Создаёт иконку в трее. Может не получиться: на Linux иконка держится на AppIndicator + * (в GNOME — расширение), и без него `Tray` бросает исключение. Тогда трея просто нет, + * `isTrayActive()` вернёт false, и окно ведёт себя по-старому — закрытие закрывает + * приложение, а не прячет его туда, откуда пользователь не сможет достать. + */ +export function createTray(trayHandlers: TrayHandlers): void { + handlers = trayHandlers + + try { + tray = new Tray(trayImage(zapretStatus())) + } catch (error) { + console.warn('[tray] не удалось создать иконку в трее:', error) + tray = null + return + } + + // Левый клик открывает окно на Windows и Linux. На macOS клик по иконке в меню-баре + // открывает контекстное меню — это поведение системы, и трогать его не нужно. + if (process.platform !== 'darwin') tray.on('click', () => handlers?.showWindow()) + + render(zapretStatus()) + onZapretStatusChanged(render) + + // Иконка должна исчезнуть в момент выхода, а не когда до объекта дойдёт сборщик мусора. + app.on('will-quit', destroyTray) +} + +export function isTrayActive(): boolean { + return tray !== null && !tray.isDestroyed() +} + +/** Первое сворачивание в трей: объясняем, куда делось окно и что приложение ещё работает. */ +export function notifyRunsInBackground(): void { + const title = 'SenBoost работает в фоне' + const body = + process.platform === 'darwin' + ? 'Окно свёрнуто в меню-бар. Обход продолжает работать — выйти можно через меню значка.' + : 'Окно свёрнуто в трей. Обход продолжает работать — выйти можно через меню значка.' + + if (process.platform === 'win32' && tray && !tray.isDestroyed()) { + tray.displayBalloon({ title, content: body, icon: nativeImage.createFromPath(appIcon) }) + return + } + + if (Notification.isSupported()) new Notification({ title, body }).show() +} + +function destroyTray(): void { + tray?.destroy() + tray = null +} diff --git a/src/main/window.ts b/src/main/window.ts index ceb0ae6..291eb76 100644 --- a/src/main/window.ts +++ b/src/main/window.ts @@ -3,6 +3,43 @@ 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' +import { getSettings, rememberPreferences } from './settings' +import { isTrayActive, notifyRunsInBackground } from './tray' + +/** Единственное окно приложения — чтобы трей и второй запуск показывали именно его. */ +let mainWindow: BrowserWindow | null = null + +/** + * Пользователь действительно выходит («Выход» в трее, Cmd+Q, деинсталляция), а не просто + * закрывает окно. Пока флага нет, закрытие окна прячет его в трей — см. `createWindow()`. + */ +let quitting = false + +export function beginQuit(): void { + quitting = true +} + +export function isQuitting(): boolean { + return quitting +} + +/** Прятать окно в трей вместо выхода: настройка пользователя (по умолчанию — да) + живой трей. */ +function shouldHideToTray(): boolean { + return isTrayActive() && getSettings().closeToTray !== false +} + +/** + * Показать окно: создать заново, если его нет (macOS оставляет приложение жить без окон), + * развернуть свёрнутое, вывести на передний план. Точка входа для трея, второго запуска + * приложения и клика по иконке в доке. + */ +export function showMainWindow(): BrowserWindow { + const window = mainWindow && !mainWindow.isDestroyed() ? mainWindow : createWindow() + if (window.isMinimized()) window.restore() + window.show() + window.focus() + return window +} function protocolOf(url: string): string | null { try { @@ -23,8 +60,13 @@ export function openExternal(url: string): void { } } -/** Главное (и единственное) окно приложения. */ -export function createWindow(): BrowserWindow { +/** + * Главное (и единственное) окно приложения. + * + * `show: false` нужен запуску из автозапуска системы: приложение поднимается сразу свёрнутым + * в трей, окно готово, но на экран не выходит. + */ +export function createWindow(options: { show?: boolean } = {}): BrowserWindow { const window = new BrowserWindow({ width: 440, height: 660, @@ -48,8 +90,31 @@ export function createWindow(): BrowserWindow { } }) + mainWindow = window + window.on('closed', () => { + if (mainWindow === window) mainWindow = null + }) + // Показываем окно только когда отрисован первый кадр — без «белой вспышки». - window.on('ready-to-show', () => window.show()) + window.on('ready-to-show', () => { + if (options.show !== false) window.show() + }) + + // Крестик прячет окно в трей: обход продолжает работать, приложение остаётся в фоне. + // Реальный выход идёт через «Выход» в трее или Cmd+Q — там выставлен флаг `quitting`, + // и это условие уже не срабатывает. + window.on('close', (event) => { + if (quitting || !shouldHideToTray()) return + + event.preventDefault() + window.hide() + + // Иконка в трее маленькая, и первое исчезновение окна пугает — объясняем один раз. + if (!getSettings().trayHintShown) { + rememberPreferences({ trayHintShown: true }) + notifyRunsInBackground() + } + }) // window.open(...) и target="_blank" уходят в системный браузер, а не в новое окно Electron. window.webContents.setWindowOpenHandler(({ url }) => { diff --git a/src/main/zapret/engine.ts b/src/main/zapret/engine.ts index 939a87d..4594567 100644 --- a/src/main/zapret/engine.ts +++ b/src/main/zapret/engine.ts @@ -10,6 +10,20 @@ import { rememberPreferences, type AppSettings } from '../settings' /** Сколько строк вывода бинарника держим для UI. */ const LOG_LIMIT = 200 +const statusListeners = new Set<(status: ZapretStatus) => void>() + +/** + * Подписка на изменение статуса внутри main-процесса. Окнам статус уходит событием + * `zapret:status-changed`, но трей — не окно (`src/main/tray.ts`), а знать о включении + * и выключении ему нужно ровно так же. Возвращает функцию отписки. + */ +export function onZapretStatusChanged(listener: (status: ZapretStatus) => void): () => void { + statusListeners.add(listener) + return () => { + statusListeners.delete(listener) + } +} + /** Общая часть обоих движков: хранение статуса и рассылка событий в окна. */ export abstract class ZapretEngine { protected status: ZapretStatus @@ -39,7 +53,7 @@ export abstract class ZapretEngine { * * `null` в поле означает «не выбирали» — тогда остаётся то, что решил конструктор движка. */ - restorePreferences(settings: AppSettings): void { + restorePreferences(settings: Pick): void { this.patch({ strategyId: settings.strategyId ?? this.status.strategyId, autoStart: settings.autoStart ?? this.status.autoStart @@ -81,6 +95,7 @@ export abstract class ZapretEngine { // проходит через patch, так что забыть сохранить выбор здесь просто негде. rememberPreferences({ strategyId: this.status.strategyId, autoStart: this.status.autoStart }) this.broadcast('zapret:status-changed', this.status) + for (const listener of statusListeners) listener(this.getStatus()) return this.getStatus() } diff --git a/src/main/zapret/index.ts b/src/main/zapret/index.ts index 1f06ca1..16ce2da 100644 --- a/src/main/zapret/index.ts +++ b/src/main/zapret/index.ts @@ -7,10 +7,10 @@ import type { ZapretStatus, ZapretStrategy } from '../../shared/ipc-contract' -import { loadSettings } from '../settings' +import { getSettings } from '../settings' import { DIAGNOSTIC_TARGETS, runDiagnostics } from './diagnostics' import { DarwinEngine } from './engine.darwin' -import { ZapretEngine, UnsupportedEngine } from './engine' +import { ZapretEngine, UnsupportedEngine, onZapretStatusChanged } from './engine' import { LinuxEngine } from './engine.linux' import { Win32Engine } from './engine.win32' import { readAllLists, resetList, seedLists, writeList } from './lists' @@ -46,7 +46,8 @@ export async function recoverZapret(): Promise { // Сохранённый выбор применяем ДО sync(): у уже установленной службы/демона реальное // состояние (стратегия в strategy.cfg, автозапуск) важнее записанного нами и перекроет его. - const settings = await loadSettings() + // Файл к этому моменту уже прочитан в `app.whenReady()` (см. `src/main/index.ts`). + const settings = getSettings() engine.restorePreferences({ // Стратегия могла исчезнуть в обновлении (подборка Flowseal меняется) или остаться от // запуска на другой платформе — тогда безопаснее вернуться к умолчанию движка, чем @@ -71,6 +72,8 @@ function knownStrategyId(strategyId: string | null): string | null { return known ? strategyId : null } +export { onZapretStatusChanged } + export function zapretStatus(): ZapretStatus { return engine.getStatus() } diff --git a/src/preload/index.ts b/src/preload/index.ts index 0e85854..a938109 100644 --- a/src/preload/index.ts +++ b/src/preload/index.ts @@ -1,5 +1,6 @@ import { contextBridge, ipcRenderer, type IpcRendererEvent } from 'electron' import type { + BackgroundSettings, IpcChannel, IpcEventChannel, IpcEvents, @@ -53,6 +54,11 @@ const api = { /** Windows: подтверждает диалогом ОС, запускает деинсталлятор и закрывает приложение. */ uninstallApp: (): Promise => invoke('app:uninstall'), + /** Трей и автозапуск приложения — см. `BackgroundSettings` в контракте. */ + getBackgroundSettings: (): Promise => invoke('app:background-get'), + setBackgroundSettings: (patch: Partial): Promise => + invoke('app:background-set', patch), + getZapretStatus: (): Promise => invoke('zapret:status'), getZapretStrategies: (): Promise => invoke('zapret:strategies'), startZapret: (strategyId: string): Promise => invoke('zapret:start', strategyId), diff --git a/src/renderer/src/components/SettingsView.tsx b/src/renderer/src/components/SettingsView.tsx index bb8efe6..5e51395 100644 --- a/src/renderer/src/components/SettingsView.tsx +++ b/src/renderer/src/components/SettingsView.tsx @@ -2,6 +2,7 @@ import { useState } from 'react' import type { ZapretListId, ZapretStatus } from '@shared/ipc-contract' import { GITHUB_URL } from '@shared/links' import { useAppVersion } from '../hooks/useAppVersion' +import { useBackgroundSettings } from '../hooks/useBackgroundSettings' import { useZapretLists } from '../hooks/useZapretLists' import { LIST_TITLES } from '../lib/zapretListMeta' import ListEditor from './ListEditor' @@ -26,6 +27,7 @@ export default function SettingsView({ onSetAutoStart: (enabled: boolean) => Promise }): React.JSX.Element { const { lists, save, reset } = useZapretLists() + const background = useBackgroundSettings() const [openListId, setOpenListId] = useState(null) const appVersion = useAppVersion() @@ -54,9 +56,49 @@ export default function SettingsView({

Настройки

+ {background.settings?.trayAvailable && ( +
+ Фоновая работа + + + {background.error &&

{background.error}

} +

+ Приложение открывается свёрнутым в трей и обход можно включить одним кликом по значку. + Это не то же самое, что автозапуск обхода ниже: тот работает вообще без запущенного + приложения. +

+
+ )} + {supportsAutoStart && (
- Автозапуск + Автозапуск обхода