Refine VPN installer and menu-bar app integration
This commit is contained in:
@@ -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")
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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()
|
||||
|
||||
Reference in New Issue
Block a user