Files
senboost/src/main/uninstall.win32.ts
T
maeneko c71a27c9a3 Переименование проекта: RKNboost → SenBoost
Затрагивает не только видимые строки, но и системные идентификаторы: appId
(com.senboost.app), служба Windows (senboost-zapret) и её каталог в %ProgramData%,
LaunchDaemon и pf-якорь на macOS (com.senboost.zapret, com.apple/senboost),
каталог /run/senboost и таблица nftables на Linux, маркер стратегии в strategy.cfg
(--comment=senboost:<id>:<hash>), а также каталог userData, который Electron
выводит из имени приложения.
2026-09-04 14:41:56 +03:00

146 lines
6.8 KiB
TypeScript

import { execFile, spawn } from 'node:child_process'
import { promisify } from 'node:util'
import {
app,
dialog,
type BrowserWindow,
type MessageBoxOptions,
type MessageBoxReturnValue
} from 'electron'
const run = promisify(execFile)
/** dialog.showMessageBox без родительского окна — отдельная перегрузка, без окна её и зовём. */
function showDialog(
window: BrowserWindow | null,
options: MessageBoxOptions
): Promise<MessageBoxReturnValue> {
return window ? dialog.showMessageBox(window, options) : dialog.showMessageBox(options)
}
/**
* Установщик (per-user, `perMachine: false` в electron-builder.yml) регистрирует
* деинсталлятор в HKCU, не в HKLM — читать оттуда можно без прав администратора.
* Ищем по DisplayName, а не по фиксированному GUID/пути: имя ключа реестра
* electron-builder генерирует детерминированно из `appId`, но полагаться на точное
* значение — лишний повод сломаться при следующей пересборке.
*/
interface UninstallCommand {
command: string
args: string[]
}
/**
* `UninstallString` записывает сам electron-builder (templates/nsis/include/installer.nsh:122)
* строкой `'"$2" $0'`, где `$2` — путь к деинсталлятору, а `$0` — `/currentuser` для
* per-user установки (или `/allusers`). То есть путь в кавычках И аргумент после него:
*
* "C:\Users\...\AppData\Local\Programs\SenBoost\Uninstall SenBoost.exe" /currentuser
*
* Аргумент обязателен: без него деинсталлятор не знает, из какой ветки реестра сниматься.
* Разбираем строку на команду и аргументы, а не пытаемся скормить её `spawn` целиком —
* иначе весь текст вместе с кавычками уходит в имя файла и получается ENOENT.
*/
function parseUninstallString(raw: string): UninstallCommand | null {
const trimmed = raw.trim()
if (!trimmed) return null
const quoted = /^"([^"]+)"\s*(.*)$/.exec(trimmed)
if (quoted) {
return { command: quoted[1], args: quoted[2].split(/\s+/).filter(Boolean) }
}
// Без кавычек путь бывает только если в нём нет пробелов — тогда первое слово и есть команда.
const [command, ...args] = trimmed.split(/\s+/)
return command ? { command, args } : null
}
/**
* Установщик (per-user, `perMachine: false` в electron-builder.yml) регистрирует
* деинсталлятор в HKCU, не в HKLM — читать оттуда можно без прав администратора.
* Ищем по DisplayName, а не по фиксированному GUID/пути: имя ключа реестра
* electron-builder генерирует детерминированно из `appId`, но полагаться на точное
* значение — лишний повод сломаться при следующей пересборке.
*/
async function findUninstallCommand(): Promise<UninstallCommand | null> {
const script =
"Get-ChildItem 'HKCU:\\Software\\Microsoft\\Windows\\CurrentVersion\\Uninstall' " +
'| Get-ItemProperty ' +
"| Where-Object { $_.DisplayName -eq 'SenBoost' } " +
'| Select-Object -First 1 -ExpandProperty UninstallString'
let stdout: string
try {
;({ stdout } = await run('powershell.exe', ['-NoProfile', '-Command', script]))
} catch {
return null
}
return parseUninstallString(stdout)
}
/**
* `spawn` сообщает о ненайденном файле не исключением, а событием `error` на уже
* созданном объекте — без этого обработчика ENOENT всплывал бы необработанным
* отказом промиса в main-процессе. Выходим только после события `spawn`, то есть
* когда деинсталлятор точно стартовал: закрыться раньше значит не закрыться вовсе.
*/
function launchUninstaller({ command, args }: UninstallCommand): Promise<void> {
return new Promise((resolve, reject) => {
// detached: деинсталлятор должен пережить закрытие текущего процесса.
const child = spawn(command, args, { detached: true, stdio: 'ignore' })
child.once('error', reject)
child.once('spawn', () => {
child.unref()
resolve()
})
})
}
/**
* Подтверждение нативным диалогом ОС (не веб-страницей — так надёжнее для
* действия, которое стирает и приложение, и списки сайтов пользователя),
* затем запуск деинсталлятора и выход — деинсталлятор не может удалить
* запущенное приложение.
*/
export async function uninstallApp(window: BrowserWindow | null): Promise<void> {
const { response } = await showDialog(window, {
type: 'warning',
buttons: ['Отмена', 'Удалить'],
defaultId: 0,
cancelId: 0,
noLink: true,
title: 'Удалить SenBoost',
message: 'Удалить SenBoost с этого компьютера?',
detail:
'Приложение закроется и запустится стандартный деинсталлятор Windows. ' +
'Вместе с ним будут удалены списки сайтов и служба обхода блокировок. ' +
'Windows может запросить права администратора.'
})
if (response !== 1) return
const uninstall = await findUninstallCommand()
if (!uninstall) {
await showDialog(window, {
type: 'error',
title: 'Не удалось найти деинсталлятор',
message: 'Удалите приложение вручную через «Параметры Windows → Приложения».'
})
return
}
try {
await launchUninstaller(uninstall)
} catch (error) {
await showDialog(window, {
type: 'error',
title: 'Не удалось запустить деинсталлятор',
message: 'Удалите приложение вручную через «Параметры Windows → Приложения».',
detail: error instanceof Error ? error.message : String(error)
})
return
}
app.quit()
}