Затрагивает не только видимые строки, но и системные идентификаторы: 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 выводит из имени приложения.
146 lines
6.8 KiB
TypeScript
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()
|
|
}
|