Добавлена работа в трее и версия 0.6.1

Приложение теперь может сворачиваться в трей вместо закрытия: обход
продолжает работать в фоне, иконка в трее показывает статус и
позволяет включить/выключить обход без открытия окна. Добавлен
автозапуск самого приложения вместе с системой (свёрнутым в трей).
This commit is contained in:
2026-09-04 20:01:05 +03:00
parent 30248656a8
commit 781d8a574f
23 changed files with 793 additions and 36 deletions
+58
View File
@@ -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<boolean> {
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<void> {
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')
}
+56
View File
@@ -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<boolean> {
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<void> {
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
}
+59
View File
@@ -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<boolean> {
try {
await run('schtasks.exe', ['/Query', '/TN', TASK_NAME], { windowsHide: true })
return true
} catch {
// Единственный ожидаемый случай — задачи нет; отличать его от сломанного schtasks нечем
// (текст сообщения локализован), да и результат для пользователя один и тот же.
return false
}
}
export async function setAppAutoStart(enabled: boolean): Promise<void> {
// Создание и удаление задачи требуют прав администратора. В собранном приложении они уже
// есть всегда, лишнего 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 }
)
}
}
+45 -14
View File
@@ -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(() => {
+27 -1
View File
@@ -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<BackgroundSettings> {
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))
+26 -7
View File
@@ -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<string, unknown>
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<void> {
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<AppSettings>): 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
+156
View File
@@ -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
}
+68 -3
View File
@@ -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 }) => {
+16 -1
View File
@@ -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<AppSettings, 'strategyId' | 'autoStart'>): 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()
}
+6 -3
View File
@@ -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<void> {
// Сохранённый выбор применяем ДО 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()
}