Refine VPN installer and menu-bar app integration

This commit is contained in:
2026-06-24 23:15:38 +03:00
parent 2acf5dd50b
commit fdb15ae462
6 changed files with 1126 additions and 228 deletions

View File

@@ -74,6 +74,9 @@ struct VPNMenuView: View {
Button("Обновить статус модулей") {
vpnManager.refreshStatus()
}
Button("Изменить логин и пароль") {
vpnManager.configureLogin()
}
Divider()
Button("Открыть логи") {
let logPath = FileManager.default.homeDirectoryForCurrentUser
@@ -91,19 +94,18 @@ struct VPNMenuView: View {
Group {
Label("VPN отключён", systemImage: "circle")
.disabled(true)
Label(vpnManager.installModeLabel, systemImage: "slider.horizontal.3")
.disabled(true)
if let setupHint = vpnManager.installSetupHint {
Label(setupHint, systemImage: "info.circle")
.disabled(true)
}
Divider()
Button("Подключить автоматически") {
vpnManager.connect(mode: .auto)
ForEach(vpnManager.availableLaunchModes) { mode in
Button(mode.menuTitle) {
vpnManager.connect(mode: mode)
}
}
.keyboardShortcut("c")
Button("Подключить вручную") {
vpnManager.connect(mode: .manual)
}
.keyboardShortcut("m")
Button("Подключить полностью вручную") {
vpnManager.connect(mode: .manualFull)
}
.keyboardShortcut("f")
}
}

View File

@@ -11,6 +11,8 @@ struct VPNEvent: Decodable {
var reason: String?
var message: String?
var source: String?
var profile: String?
var auth_profile: String?
var tier: Int?
var modules: ModuleStatus?
}
@@ -34,9 +36,18 @@ struct ModuleStatus: Decodable {
struct Credentials: Decodable {
var source: String
var auth_profile: String?
var login_ready: Bool?
var keychain_ready: Bool
}
struct AppMenu: Decodable {
var install_label: String
var configured_mode: String
var available_modes: [String]
var setup_hint: String?
}
struct Keychain: Decodable {
var password: Bool
var totp_seed: Bool
@@ -58,6 +69,7 @@ struct ModuleStatus: Decodable {
var core: Core
var credentials: Credentials?
var app_menu: AppMenu?
var bitwarden: ToggleModule
var touchid: ToggleModule
var keychain: Keychain
@@ -69,7 +81,16 @@ struct ModuleStatus: Decodable {
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 profile = credentials?.auth_profile ?? "manual"
let keychainReady: Bool
switch profile {
case "manual":
keychainReady = true
case "saved-login":
keychainReady = keychain.password
default:
keychainReady = keychain.password && keychain.totp_seed
}
let appReady = app?.installed ?? true
return !coreReady
@@ -87,14 +108,23 @@ struct ModuleStatus: Decodable {
var summary: String {
let coreState = core.openconnect && core.openconnect_lite && core.openconnect_lite_config ? "✅ core" : "⚠️ core"
let credentialState = credentials.map { "🔐 \($0.source)" } ?? "🔐 legacy"
let credentialState = credentials.map { "🔐 \($0.auth_profile ?? "manual")/\($0.source)" } ?? "🔐 legacy"
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" : "-")"
let keychainReady: Bool
switch credentials?.auth_profile ?? "manual" {
case "manual":
keychainReady = true
case "saved-login":
keychainReady = keychain.password
default:
keychainReady = keychain.password && keychain.totp_seed
}
let keychainState = "\(keychainReady ? "" : "⚠️") kc \(keychain.password ? "pass" : "-")/\(keychain.totp_seed ? "totp" : "-")"
return [coreState, credentialState, bwState, touchState, dnsState, appState, autostartState, patchState, keychainState].joined(separator: " | ")
}
}
@@ -119,18 +149,34 @@ enum VPNState: Equatable {
}
}
enum VPNLaunchMode: String {
case auto
case manual
case manualFull
enum VPNLaunchMode: String, Identifiable {
case configured = "configured"
case auto = "auto"
case savedLogin = "saved-login"
case manual = "saved-totp"
case manualFull = "manual-full"
var cliArgument: String {
var id: String { rawValue }
var cliArgument: String? {
switch self {
case .configured: return nil
case .auto: return "--auto"
case .savedLogin: return "--saved-login"
case .manual: return "--manual"
case .manualFull: return "--manual-full"
}
}
var menuTitle: String {
switch self {
case .configured: return "Подключить"
case .auto: return "Подключить автоматически"
case .savedLogin: return "Подставить только логин и пароль"
case .manual: return "Подключить с сохранёнными данными"
case .manualFull: return "Подключить полностью вручную"
}
}
}
@MainActor
@@ -140,6 +186,7 @@ class VPNManager: ObservableObject {
@Published var tunnelHealthy: Bool = true
@Published var moduleSummary: String = "modules loading..."
@Published var moduleStatusSystemImage: String = "hourglass"
@Published var moduleStatus: ModuleStatus?
private var process: Process?
private var outputPipe: Pipe?
@@ -153,7 +200,7 @@ class VPNManager: ObservableObject {
private var autoReconnectAttempts: Int = 0
private var reconnectTimer: Timer?
private var consecutiveHealthFailures: Int = 0
private var currentLaunchMode: VPNLaunchMode = .auto
private var currentLaunchMode: VPNLaunchMode = .configured
private let healthCheckInterval: TimeInterval = 10
private let maxAutoReconnectAttempts: Int = 3
@@ -191,6 +238,28 @@ class VPNManager: ObservableObject {
}
}
var installModeLabel: String {
moduleStatus?.app_menu?.install_label ?? "Режим: проверяю установку"
}
var installSetupHint: String? {
if let hint = moduleStatus?.app_menu?.setup_hint {
return hint
}
if moduleStatus != nil, moduleStatus?.app_menu == nil {
return "Обновите CLI: sh install.sh"
}
return nil
}
var availableLaunchModes: [VPNLaunchMode] {
guard let menu = moduleStatus?.app_menu else {
return [.configured]
}
let modes = menu.available_modes.compactMap(VPNLaunchMode.init(rawValue:))
return modes.isEmpty ? [.configured] : modes
}
func refreshStatus() {
let proc = Process()
proc.executableURL = URL(fileURLWithPath: "/bin/bash")
@@ -211,6 +280,7 @@ class VPNManager: ObservableObject {
guard !lastLine.isEmpty, let jsonData = lastLine.data(using: .utf8) else {
self.moduleSummary = "modules unavailable"
self.moduleStatusSystemImage = "questionmark.circle"
self.moduleStatus = nil
self.log("[modules] status refresh returned no JSON output")
return
}
@@ -221,6 +291,7 @@ class VPNManager: ObservableObject {
} catch {
self.moduleSummary = "modules unavailable"
self.moduleStatusSystemImage = "exclamationmark.triangle"
self.moduleStatus = nil
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)")
@@ -230,12 +301,14 @@ class VPNManager: ObservableObject {
guard let modules = response.modules else {
self.moduleSummary = "modules unavailable: update CLI"
self.moduleStatusSystemImage = "exclamationmark.triangle"
self.moduleStatus = nil
self.log("[modules] status has no modules field; reinstall CLI with install.sh")
return
}
self.moduleSummary = modules.summary
self.moduleStatusSystemImage = modules.systemImage
self.moduleStatus = modules
}
}
@@ -246,7 +319,7 @@ class VPNManager: ObservableObject {
}
}
func connect(mode: VPNLaunchMode = .auto) {
func connect(mode: VPNLaunchMode = .configured) {
guard !isRunning else {
log("connect() called but process already running")
return
@@ -264,7 +337,11 @@ class VPNManager: ObservableObject {
let proc = Process()
proc.executableURL = URL(fileURLWithPath: "/bin/bash")
proc.arguments = ["-l", scriptPath, "--json", mode.cliArgument]
var arguments = ["-l", scriptPath, "--json"]
if let cliArgument = mode.cliArgument {
arguments.append(cliArgument)
}
proc.arguments = arguments
proc.environment = processEnvironment()
let stdoutPipe = Pipe()
@@ -337,6 +414,10 @@ class VPNManager: ObservableObject {
proc.interrupt()
}
func configureLogin() {
openTerminalCommand(name: "configure-login", command: "\(shellQuote(scriptPath)) --configure-login")
}
func quit() {
log("-- App quit --")
userInitiatedDisconnect = true
@@ -357,6 +438,7 @@ class VPNManager: ObservableObject {
if let modules = event.modules {
moduleSummary = modules.summary
moduleStatusSystemImage = modules.systemImage
moduleStatus = modules
}
log("[event] \(event.event)" + {
@@ -367,6 +449,7 @@ class VPNManager: ObservableObject {
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)") }
if let p = event.auth_profile ?? event.profile { extras.append("profile=\(p)") }
return extras.isEmpty ? "" : " (\(extras.joined(separator: ", ")))"
}())
@@ -383,6 +466,26 @@ class VPNManager: ObservableObject {
log(" Credential source: \(source)")
}
return
case "auth_profile":
if let message = event.message {
log(" \(message)")
} else if let profile = event.profile {
log(" Auth profile: \(profile)")
}
return
case "manual_sso", "auto_sso":
let profile = event.auth_profile ?? event.profile ?? "configured"
log(" SSO mode: \(profile)")
return
case "username_required", "username_saved":
if let message = event.message {
log(" \(message)")
} else if event.event == "username_required" {
log(" LDAP username is missing; configure it in Terminal")
} else {
log(" LDAP username saved")
}
return
case "bw_cached":
state = .unlocking(tier: "cached")
case "bw_touchid":
@@ -446,6 +549,46 @@ class VPNManager: ObservableObject {
}
}
private func openTerminalCommand(name: String, command: String) {
let stateDir = FileManager.default.homeDirectoryForCurrentUser
.appendingPathComponent(".local/state/vpn-lemanapro")
let commandURL = stateDir.appendingPathComponent("\(name).command")
let script = """
#!/bin/zsh
\(command)
status=$?
printf '\\n'
if [ "$status" -eq 0 ]; then
printf 'Логин и пароль обновлены.\\n'
else
printf 'Команда завершилась с ошибкой: %s\\n' "$status"
fi
printf 'Нажмите Enter, чтобы закрыть окно...'
read -r _
exit "$status"
"""
do {
try FileManager.default.createDirectory(at: stateDir, withIntermediateDirectories: true)
try script.write(to: commandURL, atomically: true, encoding: .utf8)
try FileManager.default.setAttributes([.posixPermissions: 0o700], ofItemAtPath: commandURL.path)
let proc = Process()
proc.executableURL = URL(fileURLWithPath: "/usr/bin/open")
proc.arguments = ["-a", "Terminal", commandURL.path]
try proc.run()
log("Opened Terminal credential setup: \(commandURL.path)")
} catch {
log("Credential setup launch failed: \(error.localizedDescription)")
lastError = error.localizedDescription
state = .error(message: error.localizedDescription)
}
}
private func shellQuote(_ value: String) -> String {
"'" + value.replacingOccurrences(of: "'", with: "'\\''") + "'"
}
private func handleTermination(exitCode: Int32) {
log("-- Process terminated (exit=\(exitCode), userInitiated=\(userInitiatedDisconnect)) --")
stopTimer()