diff --git a/.gitignore b/.gitignore
index 725a02f..9cf8b85 100644
--- a/.gitignore
+++ b/.gitignore
@@ -1,4 +1,5 @@
.DS_Store
*.log
*.tmp
-
+.build/
+app/.build/
diff --git a/README.md b/README.md
index 38af1c2..1f79e45 100644
--- a/README.md
+++ b/README.md
@@ -2,7 +2,7 @@
CLI-установка корпоративного VPN `vpn.lemanapro.ru` для macOS.
-**Модули по умолчанию:** Core: включён; Bitwarden: включён; Touch ID: включён; DNS cleanup: включён; runtime-патчи: применяются автоматически перед подключением.
+**Модули по умолчанию:** Core: включён; Bitwarden: включён; Touch ID: включён; DNS cleanup: включён; Swift Menu Bar app: включён; автозапуск приложения: включён; runtime-патчи: применяются автоматически перед подключением.
Репозиторий собирает в один воспроизводимый пакет то, что раньше было ручной локальной настройкой:
@@ -10,6 +10,7 @@ CLI-установка корпоративного VPN `vpn.lemanapro.ru` дл
- `openconnect-lite` для SAML SSO через Keycloak;
- опциональный Bitwarden CLI для LDAP-пароля и TOTP seed;
- опциональный Touch ID helper для мастер-пароля Bitwarden;
+- Swift Menu Bar app `LemanaVPN.app`;
- безопасный DNS cleanup через root-owned wrapper;
- алиасы `vpn`, `vpn-debug`, `vpn-fix-dns`.
@@ -80,6 +81,8 @@ curl -fsSL https://example.org/dokril/lemana-vpn/raw/branch/main/install.sh \
| `~/bin/vpn-lemanapro.sh` | Основной CLI для подключения, статуса и sync секретов |
| `~/bin/uninstall-lemana-vpn.sh` | Локальный uninstall helper |
| `~/bin/keychain-fingerprint` | Опциональный Touch ID helper для мастер-пароля Bitwarden |
+| `~/Applications/LemanaVPN.app` | Swift Menu Bar app для подключения из status bar |
+| `~/Library/LaunchAgents/ru.dokops.LemanaVPN.plist` | Автозапуск Menu Bar app при логине |
| `~/.config/lemana-vpn/env` | Локальная конфигурация модулей |
| `~/.config/lemana-vpn/patch-backups/` | Backup исходника `openconnect-lite` перед runtime-патчами |
| `~/.config/openconnect-lite/config.toml` | Профиль SSO и auto-fill правила Keycloak |
@@ -128,6 +131,9 @@ Detected state:
openconnect-lite: yes
Bitwarden CLI: no
Touch ID helper: no
+ Swift: yes
+ Menu Bar app: no
+ LaunchAgent: no
DNS cleanup: no
sudoers: no/no
shell aliases: no
@@ -139,6 +145,8 @@ Detected state:
- поставить ли Bitwarden CLI, если `bw` не найден;
- собрать ли Touch ID helper, если его нет и Bitwarden включён;
+- собрать ли Swift Menu Bar app, если `~/Applications/LemanaVPN.app` не найден;
+- включить ли автозапуск Menu Bar app при логине;
- настроить ли sudoers для `openconnect` и DNS cleanup;
- добавить ли алиасы в `~/.zshrc`;
- записать ли LDAP-пароль и TOTP seed в Keychain, если Bitwarden отключён.
@@ -218,6 +226,40 @@ curl -fsSL https://git.dokops.ru/dokril/lemana-vpn/raw/branch/main/install.sh \
sh install.sh --without-touchid
```
+### Swift Menu Bar app
+
+Включён по умолчанию. Установщик собирает Swift-приложение из исходников в репозитории и кладёт bundle в:
+
+```sh
+~/Applications/LemanaVPN.app
+```
+
+Приложение живёт в macOS status bar, запускает `~/bin/vpn-lemanapro.sh --json`, показывает состояние VPN, IP, оставшееся время сессии, health-check тоннеля и строку состояния модулей.
+
+Для сборки нужен Swift 5.9+ из Xcode Command Line Tools:
+
+```sh
+xcode-select --install
+```
+
+Отключить установку приложения:
+
+```sh
+curl -fsSL https://git.dokops.ru/dokril/lemana-vpn/raw/branch/main/install.sh | sh -s -- --without-app
+```
+
+Оставить приложение, но отключить автозапуск:
+
+```sh
+curl -fsSL https://git.dokops.ru/dokril/lemana-vpn/raw/branch/main/install.sh | sh -s -- --without-autostart
+```
+
+Ручной запуск:
+
+```sh
+open ~/Applications/LemanaVPN.app
+```
+
## Использование
```sh
@@ -226,6 +268,7 @@ vpn --status # статус без нового подключения
vpn --status --json # статус в JSON
vpn-debug # видимый браузер и debug-логи
vpn-fix-dns # сбросить корпоративные DNS после аварийного завершения
+open ~/Applications/LemanaVPN.app # открыть Swift-приложение в menu bar
```
Первый запуск с Bitwarden:
@@ -343,6 +386,7 @@ uninstall-lemana-vpn.sh
- удаляет sudoers rules и DNS cleanup wrapper;
- удаляет блок `lemana-vpn` из `~/.zshrc`;
- удаляет `~/.config/openconnect-lite/config.toml`;
+- удаляет `~/Applications/LemanaVPN.app` и LaunchAgent автозапуска;
- удаляет `~/.config/lemana-vpn`, если не передан `--keep-config`.
Опциональные режимы:
@@ -350,6 +394,7 @@ uninstall-lemana-vpn.sh
```sh
uninstall-lemana-vpn.sh --dry-run
uninstall-lemana-vpn.sh --keep-config
+uninstall-lemana-vpn.sh --keep-app
uninstall-lemana-vpn.sh --remove-keychain
uninstall-lemana-vpn.sh --remove-touchid-helper
uninstall-lemana-vpn.sh --remove-openconnect-lite
diff --git a/app/Package.swift b/app/Package.swift
new file mode 100644
index 0000000..514d1fe
--- /dev/null
+++ b/app/Package.swift
@@ -0,0 +1,14 @@
+// swift-tools-version: 5.9
+import PackageDescription
+
+let package = Package(
+ name: "LemanaVPN",
+ platforms: [.macOS(.v13)],
+ targets: [
+ .executableTarget(
+ name: "LemanaVPN",
+ path: "Sources/LemanaVPN"
+ )
+ ]
+)
+
diff --git a/app/Sources/LemanaVPN/LemanaVPNApp.swift b/app/Sources/LemanaVPN/LemanaVPNApp.swift
new file mode 100644
index 0000000..7461e1b
--- /dev/null
+++ b/app/Sources/LemanaVPN/LemanaVPNApp.swift
@@ -0,0 +1,177 @@
+import AppKit
+import SwiftUI
+
+@main
+struct LemanaVPNApp: App {
+ @StateObject private var vpnManager = VPNManager()
+
+ var body: some Scene {
+ MenuBarExtra {
+ VPNMenuView(vpnManager: vpnManager)
+ } label: {
+ StatusBarLabel(vpnManager: vpnManager)
+ }
+ }
+}
+
+struct StatusBarLabel: View {
+ @ObservedObject var vpnManager: VPNManager
+
+ var body: some View {
+ HStack(spacing: 4) {
+ Image(systemName: iconName)
+ if !vpnManager.statusBarTitle.isEmpty {
+ Text(vpnManager.statusBarTitle)
+ .monospacedDigit()
+ }
+ }
+ }
+
+ private var iconName: String {
+ switch vpnManager.state {
+ case .disconnected:
+ return "shield.slash"
+ case .unlocking, .connecting:
+ return "shield.lefthalf.filled"
+ case .connected:
+ return vpnManager.tunnelHealthy ? "checkmark.shield" : "exclamationmark.shield"
+ case .reconnecting:
+ return "arrow.trianglehead.2.clockwise"
+ case .error:
+ return "exclamationmark.shield"
+ }
+ }
+}
+
+struct VPNMenuView: View {
+ @ObservedObject var vpnManager: VPNManager
+
+ var body: some View {
+ switch vpnManager.state {
+ case .disconnected:
+ disconnectedMenu
+ case .unlocking(let tier):
+ unlockingMenu(tier: tier)
+ case .connecting:
+ connectingMenu
+ case .connected(let ip, _, let remainingSec):
+ connectedMenu(ip: ip, remainingSec: remainingSec)
+ case .reconnecting(let attempt):
+ reconnectingMenu(attempt: attempt)
+ case .error(let message):
+ errorMenu(message: message)
+ }
+
+ Divider()
+ Label(vpnManager.moduleSummary, systemImage: "puzzlepiece.extension")
+ .disabled(true)
+ Button("Обновить статус модулей") {
+ vpnManager.refreshStatus()
+ }
+ Divider()
+ Button("Открыть логи") {
+ let logPath = FileManager.default.homeDirectoryForCurrentUser
+ .appendingPathComponent("Library/Logs/LemanaVPN.log")
+ NSWorkspace.shared.open(logPath)
+ }
+ Button("Выход") {
+ vpnManager.quit()
+ NSApplication.shared.terminate(nil)
+ }
+ .keyboardShortcut("q")
+ }
+
+ private var disconnectedMenu: some View {
+ Group {
+ Label("VPN отключён", systemImage: "circle")
+ .disabled(true)
+ Divider()
+ Button("Подключить") {
+ vpnManager.connect()
+ }
+ .keyboardShortcut("c")
+ }
+ }
+
+ private func unlockingMenu(tier: String) -> some View {
+ Group {
+ Label("Bitwarden: \(tier)...", systemImage: "key")
+ .disabled(true)
+ Divider()
+ Button("Отменить") {
+ vpnManager.disconnect()
+ }
+ }
+ }
+
+ private var connectingMenu: some View {
+ Group {
+ Label("Подключение...", systemImage: "circle.dotted")
+ .disabled(true)
+ Divider()
+ Button("Отменить") {
+ vpnManager.disconnect()
+ }
+ }
+ }
+
+ private func connectedMenu(ip: String, remainingSec: Int) -> some View {
+ let hours = remainingSec / 3600
+ let mins = (remainingSec % 3600) / 60
+
+ return Group {
+ Label("VPN подключён", systemImage: "circle.fill")
+ .disabled(true)
+ if !vpnManager.tunnelHealthy {
+ Label("Проблемы с подключением", systemImage: "exclamationmark.triangle")
+ .disabled(true)
+ }
+ if !ip.isEmpty {
+ Text("IP: \(ip)")
+ .foregroundColor(.secondary)
+ }
+ if remainingSec > 0 {
+ Text("Сессия: \(hours)ч \(mins)м")
+ .foregroundColor(.secondary)
+ }
+ Divider()
+ Button("Копировать IP") {
+ NSPasteboard.general.clearContents()
+ NSPasteboard.general.setString(ip, forType: .string)
+ }
+ .keyboardShortcut("c", modifiers: [.command])
+ .disabled(ip.isEmpty)
+
+ Button("Отключить") {
+ vpnManager.disconnect()
+ }
+ .keyboardShortcut("d")
+ }
+ }
+
+ private func reconnectingMenu(attempt: Int) -> some View {
+ Group {
+ Label("Переподключение (\(attempt)/3)...", systemImage: "arrow.clockwise")
+ .disabled(true)
+ Divider()
+ Button("Отменить") {
+ vpnManager.disconnect()
+ }
+ }
+ }
+
+ private func errorMenu(message: String) -> some View {
+ Group {
+ Label("Ошибка", systemImage: "exclamationmark.triangle")
+ .disabled(true)
+ Text(message)
+ .foregroundColor(.secondary)
+ .lineLimit(3)
+ Divider()
+ Button("Подключить заново") {
+ vpnManager.connect()
+ }
+ }
+ }
+}
+
diff --git a/app/Sources/LemanaVPN/VPNManager.swift b/app/Sources/LemanaVPN/VPNManager.swift
new file mode 100644
index 0000000..9a1a1b6
--- /dev/null
+++ b/app/Sources/LemanaVPN/VPNManager.swift
@@ -0,0 +1,594 @@
+import Combine
+import Foundation
+
+struct VPNEvent: Decodable {
+ let event: String
+ var ip: String?
+ var expires: String?
+ var remaining_sec: Int?
+ var attempt: Int?
+ var delay: Int?
+ var reason: String?
+ var message: String?
+ var tier: Int?
+ var modules: ModuleStatus?
+}
+
+struct VPNStatusResponse: Decodable {
+ var modules: ModuleStatus?
+}
+
+struct ModuleStatus: Decodable {
+ struct Core: Decodable {
+ var openconnect: Bool
+ var openconnect_lite: Bool
+ var config: Bool
+ var openconnect_lite_config: Bool
+ }
+
+ struct ToggleModule: Decodable {
+ var enabled: Bool
+ var installed: Bool
+ }
+
+ struct Keychain: Decodable {
+ var password: Bool
+ var totp_seed: Bool
+ }
+
+ struct DnsCleanup: Decodable {
+ var installed: Bool
+ }
+
+ struct Patches: Decodable {
+ var active: Bool
+ var backup: Bool
+ }
+
+ var core: Core
+ var bitwarden: ToggleModule
+ var touchid: ToggleModule
+ var keychain: Keychain
+ var dns_cleanup: DnsCleanup
+ var patches: Patches
+
+ 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 patchState = patches.active ? "patches active" : "patches pending"
+ let keychainState = "kc \(keychain.password ? "pass" : "-")/\(keychain.totp_seed ? "totp" : "-")"
+ return [coreState, bwState, touchState, dnsState, patchState, keychainState].joined(separator: " | ")
+ }
+}
+
+enum VPNState: Equatable {
+ case disconnected
+ case unlocking(tier: String)
+ case connecting
+ case connected(ip: String, expiresAt: Date?, remainingSec: Int)
+ case reconnecting(attempt: Int)
+ case error(message: String)
+
+ var label: String {
+ switch self {
+ case .disconnected: return "disconnected"
+ case .unlocking(let t): return "unlocking(\(t))"
+ case .connecting: return "connecting"
+ case .connected(let ip, _, let r): return "connected(ip=\(ip), remaining=\(r)s)"
+ case .reconnecting(let a): return "reconnecting(#\(a))"
+ case .error(let m): return "error(\(m))"
+ }
+ }
+}
+
+@MainActor
+class VPNManager: ObservableObject {
+ @Published var state: VPNState = .disconnected
+ @Published var lastError: String?
+ @Published var tunnelHealthy: Bool = true
+ @Published var moduleSummary: String = "modules unknown"
+
+ private var process: Process?
+ private var outputPipe: Pipe?
+ private var stderrPipe: Pipe?
+ private var timer: Timer?
+ private var healthTimer: Timer?
+ private var healthCheckTarget: String?
+ private var sessionExpiresAt: Date?
+ private var currentIP: String = ""
+ private var userInitiatedDisconnect: Bool = false
+ private var autoReconnectAttempts: Int = 0
+ private var reconnectTimer: Timer?
+ private var consecutiveHealthFailures: Int = 0
+
+ private let healthCheckInterval: TimeInterval = 10
+ private let maxAutoReconnectAttempts: Int = 3
+ private let maxHealthFailuresBeforeKill: Int = 3
+ private let autoReconnectDelay: TimeInterval = 5
+
+ private let scriptPath: String = {
+ let home = FileManager.default.homeDirectoryForCurrentUser.path
+ return "\(home)/bin/vpn-lemanapro.sh"
+ }()
+
+ init() {
+ refreshStatus()
+ }
+
+ var isRunning: Bool {
+ process?.isRunning ?? false
+ }
+
+ var remainingTime: String {
+ guard let expires = sessionExpiresAt else { return "" }
+ let remaining = Int(expires.timeIntervalSinceNow)
+ guard remaining > 0 else { return "expired" }
+ let h = remaining / 3600
+ let m = (remaining % 3600) / 60
+ return String(format: "%d:%02d", h, m)
+ }
+
+ var statusBarTitle: String {
+ switch state {
+ case .connected:
+ return remainingTime
+ default:
+ return ""
+ }
+ }
+
+ func refreshStatus() {
+ let proc = Process()
+ proc.executableURL = URL(fileURLWithPath: "/bin/bash")
+ proc.arguments = ["-l", scriptPath, "--status", "--json"]
+ proc.environment = processEnvironment()
+
+ let pipe = Pipe()
+ proc.standardOutput = pipe
+ proc.standardError = FileHandle.nullDevice
+
+ proc.terminationHandler = { [weak self] _ in
+ let data = pipe.fileHandleForReading.readDataToEndOfFile()
+ guard let self = self, let text = String(data: data, encoding: .utf8) else { return }
+ let lastLine = text.split(separator: "\n").map(String.init).last ?? ""
+ guard let jsonData = lastLine.data(using: .utf8),
+ let response = try? JSONDecoder().decode(VPNStatusResponse.self, from: jsonData),
+ let modules = response.modules else { return }
+ Task { @MainActor in
+ self.moduleSummary = modules.summary
+ }
+ }
+
+ do {
+ try proc.run()
+ } catch {
+ log("[modules] status refresh failed: \(error.localizedDescription)")
+ }
+ }
+
+ func connect() {
+ guard !isRunning else {
+ log("connect() called but process already running")
+ return
+ }
+ log("-- VPN connect requested --")
+ refreshStatus()
+ state = .connecting
+ lastError = nil
+ sessionExpiresAt = nil
+ currentIP = ""
+ userInitiatedDisconnect = false
+ reconnectTimer?.invalidate()
+ reconnectTimer = nil
+
+ let proc = Process()
+ proc.executableURL = URL(fileURLWithPath: "/bin/bash")
+ proc.arguments = ["-l", scriptPath, "--json"]
+ proc.environment = processEnvironment()
+
+ let stdoutPipe = Pipe()
+ proc.standardOutput = stdoutPipe
+
+ let errPipe = Pipe()
+ proc.standardError = errPipe
+
+ self.process = proc
+ self.outputPipe = stdoutPipe
+ self.stderrPipe = errPipe
+
+ stdoutPipe.fileHandleForReading.readabilityHandler = { [weak self] handle in
+ let data = handle.availableData
+ guard !data.isEmpty else {
+ handle.readabilityHandler = nil
+ return
+ }
+ if let text = String(data: data, encoding: .utf8) {
+ let lines = text.split(separator: "\n").map(String.init)
+ Task { @MainActor in
+ for jsonLine in lines {
+ self?.parseEvent(jsonLine)
+ }
+ }
+ }
+ }
+
+ errPipe.fileHandleForReading.readabilityHandler = { [weak self] handle in
+ let data = handle.availableData
+ guard !data.isEmpty else {
+ handle.readabilityHandler = nil
+ return
+ }
+ if let text = String(data: data, encoding: .utf8) {
+ let lines = text.split(separator: "\n", omittingEmptySubsequences: true).map(String.init)
+ Task { @MainActor in
+ for line in lines {
+ self?.log("[script] \(line)")
+ }
+ }
+ }
+ }
+
+ proc.terminationHandler = { [weak self] proc in
+ Task { @MainActor in
+ self?.handleTermination(exitCode: proc.terminationStatus)
+ }
+ }
+
+ do {
+ try proc.run()
+ log("Process started (pid=\(proc.processIdentifier))")
+ } catch {
+ log("Process launch failed: \(error.localizedDescription)")
+ state = .error(message: error.localizedDescription)
+ }
+ }
+
+ func disconnect() {
+ guard let proc = process, proc.isRunning else {
+ log("disconnect() called but no running process")
+ reconnectTimer?.invalidate()
+ reconnectTimer = nil
+ if case .reconnecting = state { state = .disconnected }
+ return
+ }
+ userInitiatedDisconnect = true
+ log("-- VPN disconnect requested (SIGINT -> pid \(proc.processIdentifier)) --")
+ proc.interrupt()
+ }
+
+ func quit() {
+ log("-- App quit --")
+ userInitiatedDisconnect = true
+ reconnectTimer?.invalidate()
+ reconnectTimer = nil
+ disconnect()
+ stopTimer()
+ stopHealthCheck()
+ }
+
+ private func parseEvent(_ jsonString: String) {
+ guard let data = jsonString.data(using: .utf8),
+ let event = try? JSONDecoder().decode(VPNEvent.self, from: data) else {
+ log("[stdout] (non-JSON) \(jsonString)")
+ return
+ }
+
+ if let modules = event.modules {
+ moduleSummary = modules.summary
+ }
+
+ log("[event] \(event.event)" + {
+ var extras: [String] = []
+ if let ip = event.ip { extras.append("ip=\(ip)") }
+ if let exp = event.expires { extras.append("expires=\(exp)") }
+ if let r = event.remaining_sec { extras.append("remaining=\(r)s") }
+ if let a = event.attempt { extras.append("attempt=\(a)") }
+ if let m = event.message { extras.append("msg=\(m)") }
+ if let r = event.reason { extras.append("reason=\(r)") }
+ return extras.isEmpty ? "" : " (\(extras.joined(separator: ", ")))"
+ }())
+
+ Task { @MainActor in
+ let prevState = state.label
+
+ switch event.event {
+ case "modules":
+ return
+ case "bw_cached":
+ state = .unlocking(tier: "cached")
+ case "bw_touchid":
+ state = .unlocking(tier: "Touch ID")
+ case "bw_manual":
+ state = .unlocking(tier: "manual")
+ case "bw_synced":
+ log(" Credentials synced to Keychain")
+ return
+ case "connecting":
+ state = .connecting
+ case "connected":
+ currentIP = event.ip ?? ""
+ if let isoStr = event.expires {
+ sessionExpiresAt = ISO8601DateFormatter().date(from: isoStr)
+ }
+ let remaining = event.remaining_sec ?? Int(sessionExpiresAt?.timeIntervalSinceNow ?? 0)
+ state = .connected(ip: currentIP, expiresAt: sessionExpiresAt, remainingSec: remaining)
+ autoReconnectAttempts = 0
+ startTimer()
+ detectVPNDNS()
+ case "session_info":
+ currentIP = event.ip ?? currentIP
+ if let isoStr = event.expires {
+ sessionExpiresAt = ISO8601DateFormatter().date(from: isoStr)
+ }
+ let remaining = event.remaining_sec ?? Int(sessionExpiresAt?.timeIntervalSinceNow ?? 0)
+ state = .connected(ip: currentIP, expiresAt: sessionExpiresAt, remainingSec: remaining)
+ case "disconnected":
+ state = .disconnected
+ stopTimer()
+ stopHealthCheck()
+ case "reconnecting":
+ state = .reconnecting(attempt: event.attempt ?? 1)
+ case "dns_cleanup":
+ log(" DNS cleanup in progress")
+ return
+ case "error":
+ lastError = event.message
+ state = .error(message: event.message ?? "Unknown error")
+ default:
+ log(" Unknown event type, ignoring")
+ return
+ }
+
+ if prevState != state.label {
+ log("[state] \(prevState) -> \(state.label)")
+ }
+ }
+ }
+
+ private func handleTermination(exitCode: Int32) {
+ log("-- Process terminated (exit=\(exitCode), userInitiated=\(userInitiatedDisconnect)) --")
+ stopTimer()
+ stopHealthCheck()
+ process = nil
+ outputPipe = nil
+ stderrPipe = nil
+ refreshStatus()
+
+ let prevState = state.label
+
+ if userInitiatedDisconnect {
+ state = .disconnected
+ userInitiatedDisconnect = false
+ autoReconnectAttempts = 0
+ } else if [0, 2, 130, 143].contains(exitCode) {
+ scheduleAutoReconnect(reason: "session ended (exit \(exitCode))")
+ } else {
+ scheduleAutoReconnect(reason: "process crashed (exit \(exitCode))")
+ }
+
+ if prevState != state.label {
+ log("[state] \(prevState) -> \(state.label)")
+ }
+ }
+
+ private func scheduleAutoReconnect(reason: String) {
+ autoReconnectAttempts += 1
+ if autoReconnectAttempts > maxAutoReconnectAttempts {
+ log("Auto-reconnect limit reached (\(maxAutoReconnectAttempts) attempts). Giving up.")
+ state = .error(message: "Не удалось переподключиться (\(autoReconnectAttempts - 1) попыток)")
+ autoReconnectAttempts = 0
+ return
+ }
+
+ log("Auto-reconnect #\(autoReconnectAttempts)/\(maxAutoReconnectAttempts) in \(Int(autoReconnectDelay))s (reason: \(reason))")
+ state = .reconnecting(attempt: autoReconnectAttempts)
+
+ reconnectTimer?.invalidate()
+ reconnectTimer = Timer.scheduledTimer(withTimeInterval: autoReconnectDelay, repeats: false) { [weak self] _ in
+ Task { @MainActor in
+ guard let self = self else { return }
+ self.reconnectTimer = nil
+ guard case .reconnecting = self.state else {
+ self.log("Auto-reconnect cancelled (state changed)")
+ return
+ }
+ self.connect()
+ }
+ }
+ }
+
+ private func startTimer() {
+ stopTimer()
+ timer = Timer.scheduledTimer(withTimeInterval: 60, repeats: true) { [weak self] _ in
+ Task { @MainActor in
+ guard let self = self else { return }
+ if case .connected = self.state {
+ let remaining = Int(self.sessionExpiresAt?.timeIntervalSinceNow ?? 0)
+ self.state = .connected(
+ ip: self.currentIP,
+ expiresAt: self.sessionExpiresAt,
+ remainingSec: remaining
+ )
+ }
+ }
+ }
+ }
+
+ private func stopTimer() {
+ timer?.invalidate()
+ timer = nil
+ }
+
+ private func detectVPNDNS() {
+ log("[health] Detecting VPN DNS via scutil --dns...")
+ let proc = Process()
+ proc.executableURL = URL(fileURLWithPath: "/usr/sbin/scutil")
+ proc.arguments = ["--dns"]
+
+ let pipe = Pipe()
+ proc.standardOutput = pipe
+ proc.standardError = FileHandle.nullDevice
+
+ proc.terminationHandler = { [weak self] _ in
+ let data = pipe.fileHandleForReading.readDataToEndOfFile()
+ guard let output = String(data: data, encoding: .utf8) else { return }
+
+ var dnsServer: String?
+ let resolvers = output.components(separatedBy: "resolver #")
+ for resolver in resolvers {
+ guard resolver.contains("Supplemental") else { continue }
+ for line in resolver.components(separatedBy: "\n") {
+ let trimmed = line.trimmingCharacters(in: .whitespaces)
+ if trimmed.hasPrefix("nameserver[0]"),
+ let ip = trimmed.components(separatedBy: " : ").last?
+ .trimmingCharacters(in: .whitespaces),
+ ip.hasPrefix("10.") {
+ dnsServer = ip
+ break
+ }
+ }
+ if dnsServer != nil { break }
+ }
+
+ Task { @MainActor in
+ guard let self = self else { return }
+ self.healthCheckTarget = dnsServer
+ if let dns = dnsServer {
+ self.log("[health] VPN DNS discovered: \(dns)")
+ self.startHealthCheck()
+ } else {
+ self.log("[health] No VPN DNS found (no Supplemental 10.x resolver)")
+ }
+ }
+ }
+
+ do {
+ try proc.run()
+ } catch {
+ log("[health] scutil failed: \(error.localizedDescription)")
+ }
+ }
+
+ private func startHealthCheck() {
+ healthTimer?.invalidate()
+ healthTimer = nil
+ tunnelHealthy = true
+ consecutiveHealthFailures = 0
+ guard let target = healthCheckTarget else { return }
+ log("[health] Started: ping \(target) every \(Int(healthCheckInterval))s")
+ healthTimer = Timer.scheduledTimer(withTimeInterval: healthCheckInterval, repeats: true) { [weak self] _ in
+ Task { @MainActor in
+ self?.checkTunnelHealth()
+ }
+ }
+ }
+
+ private func stopHealthCheck() {
+ if healthTimer != nil {
+ log("[health] Stopped")
+ }
+ healthTimer?.invalidate()
+ healthTimer = nil
+ healthCheckTarget = nil
+ tunnelHealthy = true
+ consecutiveHealthFailures = 0
+ }
+
+ private func checkTunnelHealth() {
+ guard case .connected = state, let target = healthCheckTarget else { return }
+
+ let proc = Process()
+ proc.executableURL = URL(fileURLWithPath: "/sbin/ping")
+ proc.arguments = ["-c", "1", "-W", "2000", target]
+ proc.standardOutput = FileHandle.nullDevice
+ proc.standardError = FileHandle.nullDevice
+
+ let wasHealthy = tunnelHealthy
+ proc.terminationHandler = { [weak self] p in
+ Task { @MainActor in
+ guard let self = self, case .connected = self.state else { return }
+ let nowHealthy = p.terminationStatus == 0
+
+ if nowHealthy {
+ self.consecutiveHealthFailures = 0
+ if !wasHealthy {
+ self.log("[health] Recovered - ping \(target)")
+ }
+ } else {
+ self.consecutiveHealthFailures += 1
+ if wasHealthy {
+ self.log("[health] Unreachable - ping \(target) (fail #\(self.consecutiveHealthFailures))")
+ } else {
+ self.log("[health] Still unreachable (fail #\(self.consecutiveHealthFailures)/\(self.maxHealthFailuresBeforeKill))")
+ }
+
+ if self.consecutiveHealthFailures >= self.maxHealthFailuresBeforeKill {
+ self.log("[health] Tunnel dead after \(self.consecutiveHealthFailures) failures - killing process for reconnect")
+ self.stopHealthCheck()
+ if let proc = self.process, proc.isRunning {
+ proc.terminate()
+ }
+ return
+ }
+ }
+ self.tunnelHealthy = nowHealthy
+ }
+ }
+
+ do {
+ try proc.run()
+ } catch {
+ consecutiveHealthFailures += 1
+ if tunnelHealthy {
+ log("[health] ping failed to launch: \(error.localizedDescription)")
+ }
+ tunnelHealthy = false
+ }
+ }
+
+ private func processEnvironment() -> [String: String] {
+ var env = ProcessInfo.processInfo.environment
+ let home = FileManager.default.homeDirectoryForCurrentUser.path
+ let extraPaths = [
+ "\(home)/bin",
+ "\(home)/.local/bin",
+ "/opt/homebrew/bin",
+ "/opt/homebrew/sbin",
+ "/usr/local/bin",
+ "/usr/local/sbin"
+ ]
+ let existingPath = env["PATH"] ?? "/usr/bin:/bin:/usr/sbin:/sbin"
+ env["PATH"] = (extraPaths + [existingPath]).joined(separator: ":")
+ env["HOME"] = home
+ return env
+ }
+
+ private static let logURL: URL = {
+ FileManager.default.homeDirectoryForCurrentUser
+ .appendingPathComponent("Library/Logs/LemanaVPN.log")
+ }()
+
+ private static let tsFormatter: DateFormatter = {
+ let f = DateFormatter()
+ f.dateFormat = "yyyy-MM-dd HH:mm:ss"
+ f.locale = Locale(identifier: "en_US_POSIX")
+ return f
+ }()
+
+ func log(_ message: String) {
+ let ts = Self.tsFormatter.string(from: Date())
+ let line = "[\(ts)] \(message)\n"
+
+ let path = Self.logURL.path
+ if !FileManager.default.fileExists(atPath: path) {
+ FileManager.default.createFile(atPath: path, contents: nil)
+ }
+ if let handle = FileHandle(forWritingAtPath: path) {
+ handle.seekToEndOfFile()
+ handle.write(line.data(using: .utf8) ?? Data())
+ handle.closeFile()
+ }
+ }
+}
diff --git a/install.sh b/install.sh
index 456b85c..7af8521 100755
--- a/install.sh
+++ b/install.sh
@@ -15,6 +15,8 @@ USE_BITWARDEN=1
USE_TOUCHID=1
INSTALL_SUDOERS=1
INSTALL_ALIASES=1
+INSTALL_APP=1
+INSTALL_AUTOSTART=1
CONFIGURE_KEYCHAIN=0
DRY_RUN=0
FORCE=0
@@ -23,7 +25,11 @@ BITWARDEN_FORCED=0
TOUCHID_FORCED=0
SUDOERS_FORCED=0
SHELL_FORCED=0
+APP_FORCED=0
+AUTOSTART_FORCED=0
CONFIGURE_KEYCHAIN_FORCED=0
+APP_DIR="${LEMANA_VPN_APP_DIR:-$HOME/Applications/LemanaVPN.app}"
+LAUNCH_AGENT="$HOME/Library/LaunchAgents/ru.dokops.LemanaVPN.plist"
usage() {
cat <<'USAGE'
@@ -41,6 +47,10 @@ Options:
--raw-base-url URL Raw file base URL for curl installs
--no-sudoers Do not install sudoers rules
--no-shell Do not update ~/.zshrc aliases
+ --with-app Build/install macOS menu bar app (default)
+ --without-app Do not build/install macOS menu bar app
+ --with-autostart Install LaunchAgent for menu bar app (default)
+ --without-autostart Do not install LaunchAgent
--interactive Ask before installing optional missing modules
--non-interactive Use selected/default modules without prompts
--minimal Same as --without-bitwarden --without-touchid
@@ -100,6 +110,24 @@ while [ "$#" -gt 0 ]; do
INSTALL_ALIASES=0
SHELL_FORCED=1
;;
+ --with-app)
+ INSTALL_APP=1
+ APP_FORCED=1
+ ;;
+ --without-app)
+ INSTALL_APP=0
+ INSTALL_AUTOSTART=0
+ APP_FORCED=1
+ AUTOSTART_FORCED=1
+ ;;
+ --with-autostart)
+ INSTALL_AUTOSTART=1
+ AUTOSTART_FORCED=1
+ ;;
+ --without-autostart)
+ INSTALL_AUTOSTART=0
+ AUTOSTART_FORCED=1
+ ;;
--interactive) INTERACTIVE=1 ;;
--non-interactive) INTERACTIVE=0 ;;
--minimal)
@@ -215,6 +243,9 @@ print_detected_state() {
log " DNS cleanup: $(bool_word test -x "$DNS_CLEANUP")"
log " sudoers: $(bool_word test -f /etc/sudoers.d/lemana-vpn-openconnect)/$(bool_word test -f /etc/sudoers.d/lemana-vpn-dns)"
log " shell aliases: $(bool_word zsh_aliases_installed)"
+ log " Swift: $(bool_word command -v swift)"
+ log " Menu Bar app: $(bool_word test -x "$APP_DIR/Contents/MacOS/LemanaVPN")"
+ log " LaunchAgent: $(bool_word test -f "$LAUNCH_AGENT")"
log " Keychain password: $(bool_word keychain_has openconnect-lite "$USERNAME")"
log " Keychain TOTP seed: $(bool_word keychain_has openconnect-lite "totp/$USERNAME")"
}
@@ -269,6 +300,23 @@ choose_modules() {
fi
fi
+ if [ "$APP_FORCED" -eq 0 ] && ! [ -x "$APP_DIR/Contents/MacOS/LemanaVPN" ]; then
+ if yes_no "Swift Menu Bar app не найден. Собрать и установить LemanaVPN.app?" y; then
+ INSTALL_APP=1
+ else
+ INSTALL_APP=0
+ INSTALL_AUTOSTART=0
+ fi
+ fi
+
+ if [ "$AUTOSTART_FORCED" -eq 0 ] && [ "$INSTALL_APP" -eq 1 ] && ! [ -f "$LAUNCH_AGENT" ]; then
+ if yes_no "Включить автозапуск LemanaVPN.app при логине?" y; then
+ INSTALL_AUTOSTART=1
+ else
+ INSTALL_AUTOSTART=0
+ fi
+ fi
+
if [ "$CONFIGURE_KEYCHAIN_FORCED" -eq 0 ] && [ "$USE_BITWARDEN" -eq 0 ]; then
if ! keychain_has openconnect-lite "$USERNAME" || ! keychain_has openconnect-lite "totp/$USERNAME"; then
if yes_no "Bitwarden отключён, а Keychain credentials неполные. Записать LDAP-пароль и TOTP seed после установки?" y; then
@@ -432,6 +480,92 @@ install_touchid_helper() {
run install -m 700 "$tmp/keychain-fingerprint-bin" "$INSTALL_BIN_DIR/keychain-fingerprint"
}
+install_menu_bar_app() {
+ [ "$INSTALL_APP" -eq 1 ] || return 0
+
+ need_cmd swift
+
+ tmp="$1"
+ app_src="$tmp/app"
+ run mkdir -p "$app_src/Sources/LemanaVPN"
+
+ download_file "app/Package.swift" "$app_src/Package.swift"
+ download_file "app/Sources/LemanaVPN/LemanaVPNApp.swift" "$app_src/Sources/LemanaVPN/LemanaVPNApp.swift"
+ download_file "app/Sources/LemanaVPN/VPNManager.swift" "$app_src/Sources/LemanaVPN/VPNManager.swift"
+
+ log "Building LemanaVPN.app"
+ run swift build -c release --package-path "$app_src"
+
+ app_bin="$app_src/.build/release/LemanaVPN"
+ info_plist="$tmp/Info.plist"
+
+ if [ "$DRY_RUN" -eq 0 ] && [ ! -x "$app_bin" ]; then
+ die "Swift build did not produce $app_bin"
+ fi
+
+ run mkdir -p "$APP_DIR/Contents/MacOS" "$APP_DIR/Contents/Resources"
+ run install -m 755 "$app_bin" "$APP_DIR/Contents/MacOS/LemanaVPN"
+
+ write_file "$info_plist" '
+
+
+
+ CFBundleExecutable
+ LemanaVPN
+ CFBundleIdentifier
+ ru.dokops.LemanaVPN
+ CFBundleName
+ LemanaVPN
+ CFBundleDisplayName
+ LemanaVPN
+ CFBundlePackageType
+ APPL
+ CFBundleShortVersionString
+ 1.0
+ CFBundleVersion
+ 1
+ LSMinimumSystemVersion
+ 13.0
+ LSUIElement
+
+
+'
+ run install -m 644 "$info_plist" "$APP_DIR/Contents/Info.plist"
+}
+
+install_launch_agent() {
+ [ "$INSTALL_AUTOSTART" -eq 1 ] || return 0
+ [ "$INSTALL_APP" -eq 1 ] || return 0
+
+ tmp="$1"
+ plist="$tmp/ru.dokops.LemanaVPN.plist"
+
+ run mkdir -p "$HOME/Library/LaunchAgents"
+ write_file "$plist" "
+
+
+
+ Label
+ ru.dokops.LemanaVPN
+ ProgramArguments
+
+ $APP_DIR/Contents/MacOS/LemanaVPN
+
+ RunAtLoad
+
+ KeepAlive
+
+
+"
+ run install -m 644 "$plist" "$LAUNCH_AGENT"
+ if [ "$DRY_RUN" -eq 0 ]; then
+ launchctl unload "$LAUNCH_AGENT" >/dev/null 2>&1 || true
+ launchctl load "$LAUNCH_AGENT" >/dev/null 2>&1 || true
+ else
+ printf '+ launchctl load %s\n' "$LAUNCH_AGENT"
+ fi
+}
+
install_shell_aliases() {
[ "$INSTALL_ALIASES" -eq 1 ] || return 0
@@ -491,7 +625,7 @@ main() {
choose_modules
log "Installing Lemana VPN"
- log "Modules: bitwarden=$USE_BITWARDEN touchid=$USE_TOUCHID sudoers=$INSTALL_SUDOERS shell=$INSTALL_ALIASES"
+ log "Modules: bitwarden=$USE_BITWARDEN touchid=$USE_TOUCHID sudoers=$INSTALL_SUDOERS shell=$INSTALL_ALIASES app=$INSTALL_APP autostart=$INSTALL_AUTOSTART"
install_homebrew_packages
install_openconnect_lite
@@ -500,6 +634,8 @@ main() {
install_dns_cleanup "$tmp"
install_sudoers "$tmp"
install_touchid_helper "$tmp"
+ install_menu_bar_app "$tmp"
+ install_launch_agent "$tmp"
install_shell_aliases "$tmp"
maybe_login_bitwarden
@@ -512,6 +648,9 @@ main() {
log "Open a new shell or run: exec zsh"
log "Connect: vpn"
log "Status: vpn --status"
+ if [ "$INSTALL_APP" -eq 1 ]; then
+ log "App: open '$APP_DIR'"
+ fi
}
main "$@"
diff --git a/tests/smoke.sh b/tests/smoke.sh
index 14b45b1..e9674b9 100755
--- a/tests/smoke.sh
+++ b/tests/smoke.sh
@@ -15,8 +15,9 @@ output="$(cd "$ROOT" && sh install.sh --dry-run --non-interactive --minimal)"
printf '%s\n' "$output" | grep -q 'Detected state:'
printf '%s\n' "$output" | grep -q 'Interactive prompts: off'
-printf '%s\n' "$output" | grep -q 'Modules: bitwarden=0 touchid=0 sudoers=1 shell=1'
+printf '%s\n' "$output" | grep -q 'Modules: bitwarden=0 touchid=0 sudoers=1 shell=1 app=1 autostart=1'
printf '%s\n' "$output" | grep -q 'sudo install -d -m 755 -o root -g wheel /usr/local/sbin'
+printf '%s\n' "$output" | grep -q 'swift build -c release --package-path'
+printf '%s\n' "$output" | grep -q 'launchctl load'
printf 'smoke ok\n'
-
diff --git a/uninstall.sh b/uninstall.sh
index 5fbb18f..e475eb5 100755
--- a/uninstall.sh
+++ b/uninstall.sh
@@ -7,8 +7,11 @@ OC_CONFIG_DIR="${OPENCONNECT_LITE_CONFIG_DIR:-$HOME/.config/openconnect-lite}"
OC_VENV="${LEMANA_VPN_OC_VENV:-$HOME/.local/pipx/venvs/openconnect-lite}"
DNS_CLEANUP="${LEMANA_VPN_DNS_CLEANUP:-/usr/local/sbin/lemana-vpn-dns-cleanup}"
USERNAME="${LEMANA_VPN_USERNAME:-60103293}"
+APP_DIR="${LEMANA_VPN_APP_DIR:-$HOME/Applications/LemanaVPN.app}"
+LAUNCH_AGENT="$HOME/Library/LaunchAgents/ru.dokops.LemanaVPN.plist"
DRY_RUN=0
KEEP_CONFIG=0
+KEEP_APP=0
REMOVE_KEYCHAIN=0
REMOVE_TOUCHID_HELPER=0
REMOVE_OPENCONNECT_LITE=0
@@ -20,6 +23,7 @@ Usage:
Options:
--keep-config Keep ~/.config/lemana-vpn
+ --keep-app Keep ~/Applications/LemanaVPN.app and LaunchAgent
--remove-keychain Remove VPN-related Keychain entries
--remove-touchid-helper Remove ~/bin/keychain-fingerprint
--remove-openconnect-lite Remove pipx openconnect-lite after patch rollback
@@ -34,6 +38,7 @@ USAGE
while [ "$#" -gt 0 ]; do
case "$1" in
--keep-config) KEEP_CONFIG=1 ;;
+ --keep-app) KEEP_APP=1 ;;
--remove-keychain) REMOVE_KEYCHAIN=1 ;;
--remove-touchid-helper) REMOVE_TOUCHID_HELPER=1 ;;
--remove-openconnect-lite) REMOVE_OPENCONNECT_LITE=1 ;;
@@ -141,6 +146,17 @@ main() {
run sudo rm -f /etc/sudoers.d/lemana-vpn-openconnect /etc/sudoers.d/lemana-vpn-dns
run sudo rm -f "$DNS_CLEANUP"
+ if [ "$KEEP_APP" -eq 0 ]; then
+ log "Removing Menu Bar app"
+ if [ "$DRY_RUN" -eq 0 ]; then
+ launchctl unload "$LAUNCH_AGENT" >/dev/null 2>&1 || true
+ else
+ printf '+ launchctl unload %s\n' "$LAUNCH_AGENT"
+ fi
+ run rm -f "$LAUNCH_AGENT"
+ run rm -rf "$APP_DIR"
+ fi
+
log "Removing shell aliases"
remove_zshrc_block
@@ -165,4 +181,3 @@ main() {
}
main "$@"
-