Улучши удаление и строку статуса VPN

This commit is contained in:
2026-05-19 14:13:33 +03:00
parent 4187cb6544
commit 417138b3b1
6 changed files with 224 additions and 40 deletions

View File

@@ -97,10 +97,16 @@ curl -fsSL https://example.org/dokril/lemana-vpn/raw/branch/main/install.sh \
```sh
vpn --status
Modules: core=ok, bitwarden=on, touchid=on, dns=on, app=on, autostart=on, patches=active, keychain=password:yes/totp_seed:yes
Modules: core=ok, bitwarden=on, touchid=on, dns=on, app=on, autostart=on, patches=active, keychain=password:yes/totp_seed:yes
VPN disconnected
```
Emoji в human-выводе помогают быстро отличать норму, отключённый опциональный модуль и проблему:
- `✅` — модуль установлен или состояние готово;
- `⏭️` — модуль осознанно отключён;
- `⚠️` — модуль включён, но чего-то не хватает.
Значения:
| Поле | Значение |
@@ -157,20 +163,22 @@ Detected state:
В неинтерактивной среде скрипт не задаёт вопросов и использует выбранные флаги/дефолты. Для CI или повторяемой установки лучше явно указывать `--non-interactive`.
## Логи установщика
## Логи установщика и удаления
Установщик печатает пошаговый лог с emoji, цветом в интерактивном терминале и коротким пояснением, зачем нужен каждый шаг. Например, перед сборкой Swift-приложения он отдельно пишет, что `swift build` может занять время и что строки компилятора вида `[2/5] Write swift-version...` являются нормальным выводом.
Установщик и uninstall script печатают пошаговый лог с emoji, цветом в интерактивном терминале и коротким пояснением, зачем нужен каждый шаг. Например, перед сборкой Swift-приложения установщик отдельно пишет, что `swift build` может занять время и что строки компилятора вида `[2/5] Write swift-version...` являются нормальным выводом. При удалении отдельно показывается откат runtime-патчей `openconnect-lite`, удаление sudoers/DNS wrapper, приложения, aliases и config.
Отключить цвет:
```sh
NO_COLOR=1 sh install.sh
NO_COLOR=1 sh uninstall.sh
```
Отключить emoji:
```sh
LEMANA_VPN_NO_EMOJI=1 sh install.sh
LEMANA_VPN_NO_EMOJI=1 sh uninstall.sh
```
## Модули
@@ -254,6 +262,8 @@ sh install.sh --without-touchid
Приложение живёт в macOS status bar, запускает `~/bin/vpn-lemanapro.sh --json`, показывает состояние VPN, IP, оставшееся время сессии, health-check тоннеля и строку состояния модулей.
Строка состояния модулей в меню приложения использует те же маркеры, что CLI: `✅` для готового модуля, `⏭️` для отключённого опционального модуля и `⚠️` для проблемы. Иконка строки тоже меняется: `checkmark.circle` для полностью готового набора и `exclamationmark.triangle` для неполной установки.
Если в меню видно `modules unavailable: update CLI`, значит запущенное приложение обращается к старому `~/bin/vpn-lemanapro.sh`, который ещё не умеет отдавать модульный статус. Повтори установку через `curl`; установщик обновит CLI и перезапустит уже запущенное `LemanaVPN.app`.
Для сборки нужен Swift 5.9+ из Xcode Command Line Tools:

View File

@@ -30,6 +30,12 @@ struct StatusBarLabel: View {
private var iconName: String {
switch vpnManager.state {
case .disconnected:
if vpnManager.moduleStatusSystemImage == "exclamationmark.triangle" {
return "exclamationmark.shield"
}
if vpnManager.moduleStatusSystemImage == "questionmark.circle" {
return "questionmark.circle"
}
return "shield.slash"
case .unlocking, .connecting:
return "shield.lefthalf.filled"
@@ -63,7 +69,7 @@ struct VPNMenuView: View {
}
Divider()
Label(vpnManager.moduleSummary, systemImage: "puzzlepiece.extension")
Label(vpnManager.moduleSummary, systemImage: vpnManager.moduleStatusSystemImage)
.disabled(true)
Button("Обновить статус модулей") {
vpnManager.refreshStatus()
@@ -174,4 +180,3 @@ struct VPNMenuView: View {
}
}
}

View File

@@ -58,15 +58,35 @@ struct ModuleStatus: Decodable {
var patches: Patches
var app: AppModule?
var hasWarnings: Bool {
let coreReady = core.openconnect && core.openconnect_lite && core.openconnect_lite_config
let bitwardenReady = !bitwarden.enabled || bitwarden.installed
let touchReady = !touchid.enabled || touchid.installed
let keychainReady = keychain.password && keychain.totp_seed
let appReady = app?.installed ?? true
return !coreReady
|| !bitwardenReady
|| !touchReady
|| !keychainReady
|| !dns_cleanup.installed
|| !patches.active
|| !appReady
}
var systemImage: String {
hasWarnings ? "exclamationmark.triangle" : "checkmark.circle"
}
var summary: String {
let coreState = core.openconnect && core.openconnect_lite && core.openconnect_lite_config ? "core ok" : "core missing"
let bwState = bitwarden.enabled ? (bitwarden.installed ? "bw on" : "bw missing") : "bw off"
let touchState = touchid.enabled ? (touchid.installed ? "touch on" : "touch missing") : "touch off"
let dnsState = dns_cleanup.installed ? "dns on" : "dns missing"
let appState = app.map { $0.installed ? "app on" : "app missing" } ?? "app unknown"
let autostartState = app.map { $0.autostart ? "autostart on" : "autostart off" } ?? "autostart unknown"
let patchState = patches.active ? "patches active" : "patches pending"
let keychainState = "kc \(keychain.password ? "pass" : "-")/\(keychain.totp_seed ? "totp" : "-")"
let coreState = core.openconnect && core.openconnect_lite && core.openconnect_lite_config ? "core" : "⚠️ core"
let bwState = bitwarden.enabled ? (bitwarden.installed ? "bw" : "⚠️ bw") : "⏭️ bw"
let touchState = touchid.enabled ? (touchid.installed ? "touch" : "⚠️ touch") : "⏭️ touch"
let dnsState = dns_cleanup.installed ? "dns" : "⚠️ dns"
let appState = app.map { $0.installed ? "app" : "⚠️ app" } ?? "app"
let autostartState = app.map { $0.autostart ? "autostart" : "⏭️ autostart" } ?? "autostart"
let patchState = patches.active ? "patches" : "⚠️ patches"
let keychainState = "\(keychain.password && keychain.totp_seed ? "" : "⚠️") kc \(keychain.password ? "pass" : "-")/\(keychain.totp_seed ? "totp" : "-")"
return [coreState, bwState, touchState, dnsState, appState, autostartState, patchState, keychainState].joined(separator: " | ")
}
}
@@ -97,6 +117,7 @@ class VPNManager: ObservableObject {
@Published var lastError: String?
@Published var tunnelHealthy: Bool = true
@Published var moduleSummary: String = "modules loading..."
@Published var moduleStatusSystemImage: String = "hourglass"
private var process: Process?
private var outputPipe: Pipe?
@@ -166,6 +187,7 @@ class VPNManager: ObservableObject {
Task { @MainActor in
guard !lastLine.isEmpty, let jsonData = lastLine.data(using: .utf8) else {
self.moduleSummary = "modules unavailable"
self.moduleStatusSystemImage = "questionmark.circle"
self.log("[modules] status refresh returned no JSON output")
return
}
@@ -175,6 +197,7 @@ class VPNManager: ObservableObject {
response = try JSONDecoder().decode(VPNStatusResponse.self, from: jsonData)
} catch {
self.moduleSummary = "modules unavailable"
self.moduleStatusSystemImage = "exclamationmark.triangle"
let compact = text.replacingOccurrences(of: "\n", with: "\\n")
let preview = compact.count > 500 ? String(compact.prefix(500)) + "..." : compact
self.log("[modules] status decode failed: \(error.localizedDescription); output=\(preview)")
@@ -183,11 +206,13 @@ class VPNManager: ObservableObject {
guard let modules = response.modules else {
self.moduleSummary = "modules unavailable: update CLI"
self.moduleStatusSystemImage = "exclamationmark.triangle"
self.log("[modules] status has no modules field; reinstall CLI with install.sh")
return
}
self.moduleSummary = modules.summary
self.moduleStatusSystemImage = modules.systemImage
}
}
@@ -307,6 +332,7 @@ class VPNManager: ObservableObject {
if let modules = event.modules {
moduleSummary = modules.summary
moduleStatusSystemImage = modules.systemImage
}
log("[event] \(event.event)" + {

View File

@@ -146,11 +146,11 @@ _module_status_json() {
_module_human_part() {
local name="$1" enabled="$2" installed="$3"
if [[ "$enabled" == "0" ]]; then
printf '%s=off' "$name"
printf '⏭️ %s=off' "$name"
elif [[ "$installed" == "true" ]]; then
printf '%s=on' "$name"
printf '%s=on' "$name"
else
printf '%s=missing' "$name"
printf '⚠️ %s=missing' "$name"
fi
}
@@ -172,15 +172,20 @@ _module_status_human() {
app_installed="$(_module_bool test -x "$APP_DIR/Contents/MacOS/LemanaVPN")"
app_autostart="$(_module_bool test -f "$LAUNCH_AGENT")"
printf 'Modules: %s, ' "$core"
printf 'Modules: %s %s, ' "$([[ "$core" == "core=ok" ]] && printf '✅' || printf '⚠️')" "$core"
_module_human_part "bitwarden" "$USE_BITWARDEN" "$bitwarden_installed"
printf ', '
_module_human_part "touchid" "$USE_TOUCHID" "$touchid_installed"
printf ', dns=%s, app=%s, autostart=%s, patches=%s, keychain=password:%s/totp_seed:%s\n' \
printf ', %s dns=%s, %s app=%s, %s autostart=%s, %s patches=%s, %s keychain=password:%s/totp_seed:%s\n' \
"$([[ "$dns_cleanup_installed" == "true" ]] && printf '✅' || printf '⚠️')" \
"$([[ "$dns_cleanup_installed" == "true" ]] && printf on || printf missing)" \
"$([[ "$app_installed" == "true" ]] && printf '✅' || printf '⚠️')" \
"$([[ "$app_installed" == "true" ]] && printf on || printf missing)" \
"$([[ "$app_autostart" == "true" ]] && printf '✅' || printf '⏭️')" \
"$([[ "$app_autostart" == "true" ]] && printf on || printf off)" \
"$([[ "$patches_active" == "true" ]] && printf '✅' || printf '⚠️')" \
"$([[ "$patches_active" == "true" ]] && printf active || printf pending)" \
"$([[ "$keychain_password" == "true" && "$keychain_totp_seed" == "true" ]] && printf '✅' || printf '⚠️')" \
"$([[ "$keychain_password" == "true" ]] && printf yes || printf no)" \
"$([[ "$keychain_totp_seed" == "true" ]] && printf yes || printf no)"
}

View File

@@ -34,9 +34,31 @@ printf '%s\n' "$status_json" | grep -q '"modules":'
printf '%s\n' "$status_json" | grep -q '"app":'
status_text="$(bash "$ROOT/bin/vpn-lemanapro.sh" --status)"
printf '%s\n' "$status_text" | grep -q 'Modules:'
printf '%s\n' "$status_text" | grep -q 'core='
printf '%s\n' "$status_text" | grep -q 'app='
printf '%s\n' "$status_text" | grep -q 'autostart='
uninstall_home="$TMP_DIR/uninstall-home"
mkdir -p "$uninstall_home"
uninstall_output="$(
HOME="$uninstall_home" \
LEMANA_VPN_BIN_DIR="$uninstall_home/bin" \
LEMANA_VPN_CONFIG_DIR="$uninstall_home/.config/lemana-vpn" \
OPENCONNECT_LITE_CONFIG_DIR="$uninstall_home/.config/openconnect-lite" \
sh "$ROOT/uninstall.sh" --dry-run --remove-keychain --remove-touchid-helper --remove-openconnect-lite
)"
printf '%s\n' "$uninstall_output" | grep -q 'Начинаю удаление Lemana VPN'
printf '%s\n' "$uninstall_output" | grep -q 'Проверяю runtime-патчи openconnect-lite'
printf '%s\n' "$uninstall_output" | grep -q 'Удаляю sudoers и DNS cleanup wrapper'
printf '%s\n' "$uninstall_output" | grep -q 'Удаляю VPN-записи из macOS Keychain'
if printf '%s\n' "$uninstall_output" | grep -q "$esc"; then
echo "non-tty uninstall dry-run output contains ANSI color codes" >&2
exit 1
fi
fake_pwd="$TMP_DIR/fake-pwd"
mkdir -p "$fake_pwd/bin"
printf 'stale local cli\n' > "$fake_pwd/bin/vpn-lemanapro.sh"

View File

@@ -16,6 +16,42 @@ REMOVE_KEYCHAIN=0
REMOVE_TOUCHID_HELPER=0
REMOVE_OPENCONNECT_LITE=0
if [ -t 1 ] && [ -z "${NO_COLOR:-}" ] && [ "${TERM:-}" != "dumb" ]; then
C_RESET="$(printf '\033[0m')"
C_BOLD="$(printf '\033[1m')"
C_DIM="$(printf '\033[2m')"
C_RED="$(printf '\033[31m')"
C_GREEN="$(printf '\033[32m')"
C_YELLOW="$(printf '\033[33m')"
C_BLUE="$(printf '\033[34m')"
C_CYAN="$(printf '\033[36m')"
else
C_RESET=""
C_BOLD=""
C_DIM=""
C_RED=""
C_GREEN=""
C_YELLOW=""
C_BLUE=""
C_CYAN=""
fi
if [ "${LEMANA_VPN_NO_EMOJI:-0}" = "1" ]; then
E_STEP=">"
E_INFO="i"
E_OK="+"
E_WARN="!"
E_SKIP="-"
E_ERROR="x"
else
E_STEP="➡️"
E_INFO=""
E_OK="✅"
E_WARN="⚠️"
E_SKIP="⏭️"
E_ERROR="❌"
fi
usage() {
cat <<'USAGE'
Usage:
@@ -60,6 +96,41 @@ log() {
printf '%s\n' "$*"
}
color_line() {
color="$1"
shift
printf '%s%s%s\n' "$color" "$*" "$C_RESET"
}
log_step() {
color_line "$C_BOLD$C_CYAN" "$E_STEP $*"
}
log_info() {
color_line "$C_BLUE" "$E_INFO $*"
}
log_detail() {
color_line "$C_DIM" " $*"
}
log_ok() {
color_line "$C_GREEN" "$E_OK $*"
}
log_warn() {
color_line "$C_YELLOW" "$E_WARN $*"
}
log_skip() {
color_line "$C_DIM" "$E_SKIP $*"
}
die() {
printf '%s%s ERROR: %s%s\n' "$C_RED" "$E_ERROR" "$*" "$C_RESET" >&2
exit 1
}
run() {
if [ "$DRY_RUN" -eq 1 ]; then
printf '+'
@@ -84,23 +155,30 @@ restore_openconnect_lite_patch() {
backup="$CONFIG_DIR/patch-backups/webengine_process.py.before-lemana-vpn"
wep="$(find_webengine_process)"
log_step "Проверяю runtime-патчи openconnect-lite"
log_detail "Если Lemana VPN менял webengine_process.py, перед удалением возвращаем исходник из backup."
if [ ! -f "$backup" ]; then
log "No openconnect-lite patch backup found; patch rollback skipped."
log_skip "No openconnect-lite patch backup found; patch rollback skipped."
return 0
fi
if [ -z "$wep" ] || [ ! -f "$wep" ]; then
log "openconnect-lite source not found; patch rollback skipped."
log_warn "openconnect-lite source not found; patch rollback skipped."
return 0
fi
log "Restoring openconnect-lite source from patch backup"
log_info "Restoring openconnect-lite source from patch backup"
run cp "$backup" "$wep"
log_ok "openconnect-lite patch rollback completed"
}
remove_zshrc_block() {
zshrc="$HOME/.zshrc"
[ -f "$zshrc" ] || return 0
if [ ! -f "$zshrc" ]; then
log_skip "~/.zshrc not found; shell aliases skipped."
return 0
fi
tmp="$(mktemp)"
if [ "$DRY_RUN" -eq 1 ]; then
@@ -115,39 +193,61 @@ remove_zshrc_block() {
skip != 1 { print }
' "$zshrc" > "$tmp"
mv "$tmp" "$zshrc"
log_ok "Shell aliases removed from $zshrc"
}
remove_keychain_entries() {
[ "$REMOVE_KEYCHAIN" -eq 1 ] || return 0
if [ "$REMOVE_KEYCHAIN" -ne 1 ]; then
log_skip "Keychain cleanup disabled; LDAP/TOTP and Bitwarden cached entries are kept."
return 0
fi
log "Removing VPN-related Keychain entries"
run security delete-generic-password -s openconnect-lite -a "$USERNAME" >/dev/null 2>&1 || true
run security delete-generic-password -s openconnect-lite -a "totp/$USERNAME" >/dev/null 2>&1 || true
run security delete-generic-password -s vpn-lemanapro -a bw-session >/dev/null 2>&1 || true
run security delete-generic-password -s vpn-lemanapro -a bw-master >/dev/null 2>&1 || true
log_step "Удаляю VPN-записи из macOS Keychain"
log_detail "Удаляются только записи openconnect-lite для пользователя $USERNAME и кэш Bitwarden Lemana VPN."
if [ "$DRY_RUN" -eq 1 ]; then
run security delete-generic-password -s openconnect-lite -a "$USERNAME"
run security delete-generic-password -s openconnect-lite -a "totp/$USERNAME"
run security delete-generic-password -s vpn-lemanapro -a bw-session
run security delete-generic-password -s vpn-lemanapro -a bw-master
else
security delete-generic-password -s openconnect-lite -a "$USERNAME" >/dev/null 2>&1 || true
security delete-generic-password -s openconnect-lite -a "totp/$USERNAME" >/dev/null 2>&1 || true
security delete-generic-password -s vpn-lemanapro -a bw-session >/dev/null 2>&1 || true
security delete-generic-password -s vpn-lemanapro -a bw-master >/dev/null 2>&1 || true
fi
log_ok "Keychain cleanup completed"
}
main() {
[ "$(uname -s)" = "Darwin" ] || {
echo "This uninstaller supports macOS only" >&2
exit 1
}
[ "$(uname -s)" = "Darwin" ] || die "This uninstaller supports macOS only"
log_step "Начинаю удаление Lemana VPN"
log_detail "По умолчанию удаляются скрипты, sudoers, DNS wrapper, config, aliases и приложение."
log_detail "Shared-зависимости Homebrew не удаляются; openconnect-lite удаляется только с --remove-openconnect-lite."
restore_openconnect_lite_patch
log "Removing installed scripts"
log_step "Удаляю CLI-скрипты"
log_detail "Убираю vpn-lemanapro.sh и локальный uninstall helper из $INSTALL_BIN_DIR."
run rm -f "$INSTALL_BIN_DIR/vpn-lemanapro.sh"
run rm -f "$INSTALL_BIN_DIR/uninstall-lemana-vpn.sh"
log_ok "CLI scripts removed"
if [ "$REMOVE_TOUCHID_HELPER" -eq 1 ]; then
log_info "Removing Touch ID helper: $INSTALL_BIN_DIR/keychain-fingerprint"
run rm -f "$INSTALL_BIN_DIR/keychain-fingerprint"
else
log_skip "Touch ID helper kept; use --remove-touchid-helper to remove it."
fi
log "Removing sudoers and DNS cleanup wrapper"
log_step "Удаляю sudoers и DNS cleanup wrapper"
log_detail "macOS может запросить sudo-пароль, потому что эти файлы принадлежат root."
run sudo rm -f /etc/sudoers.d/lemana-vpn-openconnect /etc/sudoers.d/lemana-vpn-dns
run sudo rm -f "$DNS_CLEANUP"
log_ok "sudoers and DNS cleanup wrapper removed"
if [ "$KEEP_APP" -eq 0 ]; then
log "Removing Menu Bar app"
log_step "Удаляю Menu Bar app"
log_detail "Сначала отключаю LaunchAgent, затем удаляю $APP_DIR."
if [ "$DRY_RUN" -eq 0 ]; then
launchctl unload "$LAUNCH_AGENT" >/dev/null 2>&1 || true
else
@@ -155,29 +255,45 @@ main() {
fi
run rm -f "$LAUNCH_AGENT"
run rm -rf "$APP_DIR"
log_ok "Menu Bar app removed"
else
log_skip "Menu Bar app kept because --keep-app is set."
fi
log "Removing shell aliases"
log_step "Удаляю shell aliases"
log_detail "Из ~/.zshrc удаляется только блок между # >>> lemana-vpn и # <<< lemana-vpn."
remove_zshrc_block
log "Removing openconnect-lite config"
log_step "Удаляю openconnect-lite config"
log_detail "Удаляется профиль SSO, который был создан установщиком Lemana VPN."
run rm -f "$OC_CONFIG_DIR/config.toml"
log_ok "openconnect-lite config removed"
if [ "$KEEP_CONFIG" -eq 0 ]; then
log "Removing Lemana VPN config"
log_step "Удаляю Lemana VPN config"
log_detail "Удаляется $CONFIG_DIR, включая backup runtime-патчей после их отката."
run rm -rf "$CONFIG_DIR"
log_ok "Lemana VPN config removed"
else
log_skip "Lemana VPN config kept because --keep-config is set."
fi
remove_keychain_entries
if [ "$REMOVE_OPENCONNECT_LITE" -eq 1 ]; then
if command -v pipx >/dev/null 2>&1; then
log "Removing openconnect-lite from pipx"
log_step "Удаляю openconnect-lite из pipx"
log_detail "Это опционально: пакет может использоваться не только Lemana VPN."
run pipx uninstall openconnect-lite
log_ok "openconnect-lite removed from pipx"
else
log_warn "pipx not found; openconnect-lite package removal skipped."
fi
else
log_skip "openconnect-lite kept; use --remove-openconnect-lite to uninstall it from pipx."
fi
log "Done."
log_ok "Done."
}
main "$@"