Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
2126d2b3ef | ||
|
|
9b8a187073 | ||
|
|
781d8a574f |
@@ -51,7 +51,17 @@ sudo "/Library/Application Support/SenBoost/zapret/stop.sh"
|
||||
и ставятся сами; для AppImage — своими силами, `sudo apt install nftables policykit-1`).
|
||||
Ни службы, ни автозапуска с системой на Linux нет: демон и правила nftables переживают выход
|
||||
из приложения (правила закрыты флагом `bypass` — без работающего nfqws трафик просто проходит
|
||||
как есть, а не блокируется), но после перезагрузки заново их никто не поднимает.
|
||||
как есть, а не блокируется), но после перезагрузки заново их никто не поднимает. Автозапуск
|
||||
самого приложения (см. ниже) частично это закрывает — обход поднимется при входе в систему,
|
||||
но потребует `pkexec` на каждый вход.
|
||||
|
||||
## Планы до 1.0
|
||||
|
||||
- [ ] Очистка и ревью нейросгенерированного кода
|
||||
- [ ] Полная поддержка MacOS и Linux
|
||||
- [x] Фоновая работа в трей
|
||||
- [ ] Поддержка Telegram-прокси
|
||||
- [ ] Обновление из приложения
|
||||
|
||||
## Ссылки
|
||||
|
||||
|
||||
+6
-2
@@ -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
|
||||
|
||||
Generated
+2
-2
@@ -1,12 +1,12 @@
|
||||
{
|
||||
"name": "senboost",
|
||||
"version": "0.6.0",
|
||||
"version": "0.6.2",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "senboost",
|
||||
"version": "0.6.0",
|
||||
"version": "0.6.2",
|
||||
"hasInstallScript": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
|
||||
+1
-1
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "senboost",
|
||||
"version": "0.6.0",
|
||||
"version": "0.6.2",
|
||||
"description": "Кроссплатформенное десктопное приложение на Electron + React + TypeScript",
|
||||
"main": "./out/main/index.js",
|
||||
"desktopName": "senboost.desktop",
|
||||
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 167 B |
Binary file not shown.
|
After Width: | Height: | Size: 284 B |
Binary file not shown.
|
After Width: | Height: | Size: 136 B |
Binary file not shown.
|
After Width: | Height: | Size: 212 B |
@@ -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}`)
|
||||
}
|
||||
}
|
||||
@@ -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')
|
||||
}
|
||||
@@ -0,0 +1,99 @@
|
||||
import { app } from 'electron'
|
||||
import type { ZapretStatus } from '../shared/ipc-contract'
|
||||
import * as linux from './app-autostart.linux'
|
||||
import * as win32 from './app-autostart.win32'
|
||||
import { isTrayActive } from './tray'
|
||||
import { onZapretStatusChanged, zapretStatus } from './zapret'
|
||||
|
||||
/**
|
||||
* Автозапуск САМОГО приложения вместе с системой — не путать с автозапуском обхода
|
||||
* (`zapret:set-autostart`): тот поднимает службу Windows или LaunchDaemon macOS вообще без
|
||||
* приложения, а этот открывает приложение свёрнутым в трей, чтобы обход можно было включить
|
||||
* и выключить одним кликом.
|
||||
*
|
||||
* На Windows отдельного переключателя у него нет — он следует за автозапуском службы
|
||||
* (см. `followZapretAutoStart`), в настройках одна кнопка «Запускать вместе с Windows».
|
||||
*
|
||||
* Источник истины — сама система (реестр/планировщик, 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: служба обхода стартует при загрузке — значит, и приложение должно подняться
|
||||
* при входе, свёрнутым в трей, чтобы обход было чем выключить. Две кнопки для этого путали:
|
||||
* обе назывались «запускать вместе с Windows», а по отдельности смысла почти не имели.
|
||||
*
|
||||
* Задача планировщика существует ровно тогда, когда служба установлена и стоит на `Auto`.
|
||||
* Приложение и так работает с правами администратора, так что лишнего UAC это не добавляет —
|
||||
* пользователь видит один запрос, на смену типа запуска службы. Подписка на статус, а не
|
||||
* вызов из IPC: обход включают и из меню трея, и первое включение ставит службу уже с `Auto`.
|
||||
*
|
||||
* Вызывать после `recoverZapret()` — до него статус ещё не знает о службе.
|
||||
*/
|
||||
export function followZapretAutoStart(): void {
|
||||
if (process.platform !== 'win32') return
|
||||
|
||||
let applied: boolean | null = null
|
||||
let queue = Promise.resolve()
|
||||
|
||||
const sync = (status: ZapretStatus): void => {
|
||||
const wanted = status.serviceInstalled && status.autoStart && isTrayActive()
|
||||
if (wanted === applied) return
|
||||
applied = wanted
|
||||
queue = queue
|
||||
.then(async () => {
|
||||
if ((await win32.isAppAutoStartEnabled()) !== wanted) await win32.setAppAutoStart(wanted)
|
||||
})
|
||||
.catch((error) => {
|
||||
// Главное — служба, она уже настроена. Без задачи обход всё равно поднимется при
|
||||
// загрузке, просто без значка в трее; в dev-режиме сюда попадаем всегда (нет прав).
|
||||
console.warn('[autostart] не удалось синхронизировать задачу планировщика:', error)
|
||||
})
|
||||
}
|
||||
|
||||
onZapretStatusChanged(sync)
|
||||
sync(zapretStatus())
|
||||
}
|
||||
|
||||
/**
|
||||
* Запуск пришёл из автозапуска — окно показывать не нужно, приложение садится в трей.
|
||||
*
|
||||
* На 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
|
||||
}
|
||||
@@ -0,0 +1,62 @@
|
||||
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` поднимает его с правами сразу.
|
||||
*
|
||||
* Сама по себе из интерфейса не переключается: её ставит и снимает `followZapretAutoStart`
|
||||
* (`app-autostart.ts`) вслед за автозапуском службы обхода.
|
||||
*/
|
||||
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
-13
@@ -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 { followZapretAutoStart, 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,55 @@ 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()
|
||||
followZapretAutoStart()
|
||||
const window = createWindow({ show: !startHidden })
|
||||
// Ждём первого кадра: диалог должен появиться поверх готового окна, а не пустого.
|
||||
// Свёрнутому в трей приложению показывать модальное окно некуда — пользователь его
|
||||
// не увидит и не закроет, а метку «уже показывали» диалог поставит.
|
||||
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 +97,8 @@ if (elevatedTask) {
|
||||
// иначе процесс останется висеть, а на macOS ещё и интернет пропадёт.
|
||||
let zapretCleaned = false
|
||||
app.on('before-quit', (event) => {
|
||||
// С этого момента закрытие окна означает выход, а не сворачивание в трей.
|
||||
beginQuit()
|
||||
if (zapretCleaned) return
|
||||
event.preventDefault()
|
||||
void disposeZapret().finally(() => {
|
||||
|
||||
+27
-1
@@ -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
@@ -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
|
||||
|
||||
@@ -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
@@ -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 }) => {
|
||||
|
||||
@@ -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()
|
||||
}
|
||||
|
||||
|
||||
@@ -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()
|
||||
}
|
||||
|
||||
@@ -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<void> => invoke('app:uninstall'),
|
||||
|
||||
/** Трей и автозапуск приложения — см. `BackgroundSettings` в контракте. */
|
||||
getBackgroundSettings: (): Promise<BackgroundSettings> => invoke('app:background-get'),
|
||||
setBackgroundSettings: (patch: Partial<BackgroundSettings>): Promise<BackgroundSettings> =>
|
||||
invoke('app:background-set', patch),
|
||||
|
||||
getZapretStatus: (): Promise<ZapretStatus> => invoke('zapret:status'),
|
||||
getZapretStrategies: (): Promise<ZapretStrategy[]> => invoke('zapret:strategies'),
|
||||
startZapret: (strategyId: string): Promise<ZapretStatus> => invoke('zapret:start', strategyId),
|
||||
|
||||
@@ -2,12 +2,14 @@ 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'
|
||||
|
||||
/**
|
||||
* Настройки: списки сайтов + автозапуск (macOS/Windows). Выбор стратегии и проверка
|
||||
* Настройки: списки сайтов + автозапуск (macOS/Windows; на Windows одна кнопка на службу
|
||||
* и приложение в трее). Выбор стратегии и проверка
|
||||
* соединения переехали на главный экран (ZapretCard.tsx) — рядом с кнопкой
|
||||
* включения, где ими и пользуются. Отдельный экран за шестерёнкой в шапке.
|
||||
*/
|
||||
@@ -26,6 +28,7 @@ export default function SettingsView({
|
||||
onSetAutoStart: (enabled: boolean) => Promise<void>
|
||||
}): React.JSX.Element {
|
||||
const { lists, save, reset } = useZapretLists()
|
||||
const background = useBackgroundSettings()
|
||||
const [openListId, setOpenListId] = useState<ZapretListId | null>(null)
|
||||
const appVersion = useAppVersion()
|
||||
|
||||
@@ -54,9 +57,56 @@ export default function SettingsView({
|
||||
<h2 className="settings__title">Настройки</h2>
|
||||
</div>
|
||||
|
||||
{background.settings?.trayAvailable && (
|
||||
<div className="settings__group">
|
||||
<span className="settings__label">Фоновая работа</span>
|
||||
<button
|
||||
type="button"
|
||||
className="settings-row"
|
||||
disabled={background.busy}
|
||||
onClick={() => void background.save({ closeToTray: !background.settings?.closeToTray })}
|
||||
>
|
||||
<span>Сворачивать в трей вместо выхода</span>
|
||||
<span
|
||||
className={`chip ${background.settings.closeToTray ? 'chip--success' : 'chip--neutral'}`}
|
||||
>
|
||||
{background.settings.closeToTray ? 'Включено' : 'Выключено'}
|
||||
</span>
|
||||
</button>
|
||||
{/* На Windows автозапуск приложения идёт в комплекте с автозапуском службы —
|
||||
одна кнопка ниже (см. followZapretAutoStart в main). */}
|
||||
{!isWindows && (
|
||||
<button
|
||||
type="button"
|
||||
className="settings-row"
|
||||
disabled={background.busy}
|
||||
onClick={() =>
|
||||
void background.save({ launchAtLogin: !background.settings?.launchAtLogin })
|
||||
}
|
||||
>
|
||||
<span>Запускать приложение вместе с системой</span>
|
||||
<span
|
||||
className={`chip ${background.settings.launchAtLogin ? 'chip--success' : 'chip--neutral'}`}
|
||||
>
|
||||
{background.settings.launchAtLogin ? 'Включён' : 'Выключен'}
|
||||
</span>
|
||||
</button>
|
||||
)}
|
||||
{background.error && <p className="list-editor__error">{background.error}</p>}
|
||||
<p className="list-editor__hint">
|
||||
{isWindows
|
||||
? 'Закрытое окно прячется в трей, обход продолжает работать. Включить и ' +
|
||||
'выключить его можно одним кликом по значку.'
|
||||
: 'Приложение открывается свёрнутым в трей и обход можно включить одним кликом ' +
|
||||
'по значку. Это не то же самое, что автозапуск обхода ниже: тот работает ' +
|
||||
'вообще без запущенного приложения.'}
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{supportsAutoStart && (
|
||||
<div className="settings__group">
|
||||
<span className="settings__label">Автозапуск</span>
|
||||
<span className="settings__label">{isWindows ? 'Автозапуск' : 'Автозапуск обхода'}</span>
|
||||
<button
|
||||
type="button"
|
||||
className="settings-row"
|
||||
@@ -69,9 +119,10 @@ export default function SettingsView({
|
||||
</button>
|
||||
<p className="list-editor__hint">
|
||||
{isWindows
|
||||
? 'Меняется через запрос прав администратора. Если выключить обход кнопкой на ' +
|
||||
'главном экране, при включённом автозапуске он вернётся не раньше следующей ' +
|
||||
'перезагрузки.'
|
||||
? 'Обход включается при загрузке Windows, а SenBoost открывается свёрнутым в ' +
|
||||
'трей, без окна. Меняется через запрос прав администратора. Если выключить ' +
|
||||
'обход кнопкой на главном экране, при включённом автозапуске он вернётся ' +
|
||||
'не раньше следующей перезагрузки.'
|
||||
: 'Меняется через пароль администратора. Действует, пока обход включён: ' +
|
||||
'выключение кнопкой на главном экране полностью снимает установку — ' +
|
||||
'автозапуск снова понадобится включить вместе с обходом.'}
|
||||
|
||||
@@ -0,0 +1,48 @@
|
||||
import { useCallback, useEffect, useState } from 'react'
|
||||
import type { BackgroundSettings } from '@shared/ipc-contract'
|
||||
|
||||
/**
|
||||
* `ipcRenderer.invoke` заворачивает сообщение из main в «Error invoking remote method
|
||||
* 'app:background-set': Error: …» — пользователю нужен только текст, который написали мы.
|
||||
*/
|
||||
function readableError(cause: unknown): string {
|
||||
const message = cause instanceof Error ? cause.message : String(cause)
|
||||
return message.replace(/^Error invoking remote method '[^']*':\s*(Error:\s*)?/, '')
|
||||
}
|
||||
|
||||
/**
|
||||
* Трей и автозапуск приложения, в синхроне с main-процессом. Тот же паттерн, что и
|
||||
* `useZapretLists`, с одним отличием: `launchAtLogin` меняет систему (планировщик задач,
|
||||
* объекты входа, каталог автозапуска) и может не примениться — текст ошибки возвращаем,
|
||||
* чтобы настройки показали его рядом с переключателем, а не проглотили.
|
||||
*/
|
||||
export function useBackgroundSettings(): {
|
||||
settings: BackgroundSettings | null
|
||||
save: (patch: Partial<BackgroundSettings>) => Promise<void>
|
||||
error: string | null
|
||||
busy: boolean
|
||||
} {
|
||||
const [settings, setSettings] = useState<BackgroundSettings | null>(null)
|
||||
const [error, setError] = useState<string | null>(null)
|
||||
const [busy, setBusy] = useState(false)
|
||||
|
||||
useEffect(() => {
|
||||
void window.api.getBackgroundSettings().then(setSettings)
|
||||
}, [])
|
||||
|
||||
const save = useCallback(async (patch: Partial<BackgroundSettings>) => {
|
||||
setBusy(true)
|
||||
setError(null)
|
||||
try {
|
||||
setSettings(await window.api.setBackgroundSettings(patch))
|
||||
} catch (cause) {
|
||||
setError(readableError(cause))
|
||||
// Переключатель мог уже показать новое значение — возвращаем к тому, что в системе.
|
||||
setSettings(await window.api.getBackgroundSettings())
|
||||
} finally {
|
||||
setBusy(false)
|
||||
}
|
||||
}, [])
|
||||
|
||||
return { settings, save, error, busy }
|
||||
}
|
||||
@@ -19,6 +19,27 @@ export interface ThemeState {
|
||||
shouldUseDarkColors: boolean
|
||||
}
|
||||
|
||||
/**
|
||||
* Фоновая работа: трей + автозапуск самого приложения.
|
||||
*
|
||||
* Не путать с `ZapretStatus.autoStart` — тот про автозапуск обхода (служба Windows,
|
||||
* LaunchDaemon macOS) вообще без приложения.
|
||||
*/
|
||||
export interface BackgroundSettings {
|
||||
/** Закрытие окна прячет его в трей, а не завершает приложение. */
|
||||
closeToTray: boolean
|
||||
/**
|
||||
* Приложение запускается вместе с системой, сразу свёрнутым в трей. На Windows только
|
||||
* для чтения: следует за `ZapretStatus.autoStart` (см. `followZapretAutoStart`).
|
||||
*/
|
||||
launchAtLogin: boolean
|
||||
/**
|
||||
* Иконка в трее реально создалась. На Linux без AppIndicator трея нет — тогда прятать
|
||||
* туда окно нельзя, и всю группу настроек показывать бессмысленно.
|
||||
*/
|
||||
trayAvailable: boolean
|
||||
}
|
||||
|
||||
/** Состояние обхода блокировок. */
|
||||
export type ZapretState = 'stopped' | 'starting' | 'running' | 'stopping' | 'error'
|
||||
|
||||
@@ -112,6 +133,14 @@ export interface IpcHandlers {
|
||||
/** Windows: подтверждает диалогом ОС, запускает деинсталлятор и закрывает приложение. */
|
||||
'app:uninstall': () => void
|
||||
|
||||
'app:background-get': () => BackgroundSettings
|
||||
/**
|
||||
* Меняет переданные поля и возвращает состояние целиком. `launchAtLogin` идёт в систему
|
||||
* (планировщик задач, SMAppService, каталог автозапуска) и может не примениться — тогда
|
||||
* канал отвечает ошибкой, а не молча возвращает старое значение.
|
||||
*/
|
||||
'app:background-set': (patch: Partial<BackgroundSettings>) => BackgroundSettings
|
||||
|
||||
'zapret:status': () => ZapretStatus
|
||||
'zapret:strategies': () => ZapretStrategy[]
|
||||
'zapret:start': (strategyId: string) => ZapretStatus
|
||||
@@ -123,7 +152,10 @@ export interface IpcHandlers {
|
||||
* nftables (потребуется пароль через pkexec).
|
||||
*/
|
||||
'zapret:set-strategy': (strategyId: string) => ZapretStatus
|
||||
/** macOS/Windows: автозапуск с системой; требует пароль администратора/UAC. */
|
||||
/**
|
||||
* macOS/Windows: автозапуск с системой; требует пароль администратора/UAC. На Windows
|
||||
* вместе со службой при входе поднимается и приложение, свёрнутым в трей.
|
||||
*/
|
||||
'zapret:set-autostart': (enabled: boolean) => ZapretStatus
|
||||
|
||||
'zapret:lists': () => ZapretList[]
|
||||
|
||||
Reference in New Issue
Block a user