Improve VPN status reporting and recovery
This commit is contained in:
@@ -3,190 +3,398 @@ import SwiftUI
|
||||
|
||||
@main
|
||||
struct LemanaVPNApp: App {
|
||||
@StateObject private var vpnManager = VPNManager()
|
||||
@NSApplicationDelegateAdaptor(LemanaAppDelegate.self) private var appDelegate
|
||||
@AppStorage("menuBarIndicatorStyle") private var indicatorStyle = VPNIndicatorStyle.shield
|
||||
|
||||
private var vpnManager: VPNManager { appDelegate.vpnManager }
|
||||
|
||||
var body: some Scene {
|
||||
MenuBarExtra {
|
||||
VPNMenuView(vpnManager: vpnManager)
|
||||
VPNMenuView(vpnManager: vpnManager, indicatorStyle: $indicatorStyle)
|
||||
} label: {
|
||||
StatusBarLabel(vpnManager: vpnManager)
|
||||
StatusBarLabel(vpnManager: vpnManager, style: indicatorStyle)
|
||||
}
|
||||
.menuBarExtraStyle(.window)
|
||||
}
|
||||
}
|
||||
|
||||
@MainActor
|
||||
final class LemanaAppDelegate: NSObject, NSApplicationDelegate {
|
||||
let vpnManager = VPNManager()
|
||||
private var terminationPending = false
|
||||
|
||||
func applicationShouldTerminate(_ sender: NSApplication) -> NSApplication.TerminateReply {
|
||||
guard vpnManager.needsDisconnectBeforeQuit else { return .terminateNow }
|
||||
if !terminationPending {
|
||||
terminationPending = true
|
||||
vpnManager.quit { [weak self] success in
|
||||
self?.terminationPending = false
|
||||
sender.reply(toApplicationShouldTerminate: success)
|
||||
}
|
||||
}
|
||||
return .terminateLater
|
||||
}
|
||||
}
|
||||
|
||||
private extension SessionTiming.Urgency {
|
||||
var color: NSColor {
|
||||
switch self {
|
||||
case .normal: return .systemGreen
|
||||
case .warning: return .systemOrange
|
||||
case .critical: return .systemRed
|
||||
case .unknown: return .secondaryLabelColor
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private extension VPNManager {
|
||||
var statusIcon: VPNIcon {
|
||||
if state == .disconnecting { return .connecting }
|
||||
if isCheckingConnection { return .connecting }
|
||||
if connectionCheckFailed { return .setup }
|
||||
if isExternallyActive { return .unknown }
|
||||
switch state {
|
||||
case .disconnected:
|
||||
if moduleStatusSystemImage == "exclamationmark.triangle" { return .setup }
|
||||
if moduleStatusSystemImage == "questionmark.circle" { return .unknown }
|
||||
return .off
|
||||
case .unlocking: return .unlock
|
||||
case .connecting: return .connecting
|
||||
case .disconnecting: return .connecting
|
||||
case .connected:
|
||||
if !tunnelHealthy { return .network }
|
||||
return sessionTiming.remaining(at: Date()) == 0 ? .expired : .connected
|
||||
case .reconnecting: return .reconnect
|
||||
case .error: return .error
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private struct VPNIconView: View {
|
||||
@Environment(\.colorScheme) private var colorScheme
|
||||
let icon: VPNIcon
|
||||
|
||||
var body: some View {
|
||||
Image(nsImage: VPNIcons.image(icon, dark: colorScheme == .dark))
|
||||
.frame(width: 16, height: 16)
|
||||
.accessibilityHidden(true)
|
||||
}
|
||||
}
|
||||
|
||||
struct StatusBarLabel: View {
|
||||
@ObservedObject var vpnManager: VPNManager
|
||||
@Environment(\.colorScheme) private var colorScheme
|
||||
let style: VPNIndicatorStyle
|
||||
|
||||
var body: some View {
|
||||
HStack(spacing: 4) {
|
||||
Image(systemName: iconName)
|
||||
if !vpnManager.statusBarTitle.isEmpty {
|
||||
Text(vpnManager.statusBarTitle)
|
||||
.monospacedDigit()
|
||||
if case .connected = vpnManager.state, vpnManager.tunnelHealthy {
|
||||
Image(nsImage: VPNIcons.sessionImage(
|
||||
style: style,
|
||||
progress: vpnManager.sessionTiming.progress(at: Date()),
|
||||
urgency: vpnManager.sessionTiming.urgency(at: Date()),
|
||||
dark: colorScheme == .dark
|
||||
))
|
||||
.renderingMode(.original)
|
||||
.frame(width: 16, height: 16)
|
||||
if style == .time, !vpnManager.remainingTime.isEmpty {
|
||||
Text(vpnManager.remainingTime)
|
||||
.font(.system(size: 11, weight: .medium, design: .monospaced))
|
||||
.fixedSize()
|
||||
}
|
||||
} else {
|
||||
VPNIconView(icon: vpnManager.statusIcon)
|
||||
}
|
||||
}
|
||||
.accessibilityElement(children: .ignore)
|
||||
.accessibilityLabel(accessibilityText)
|
||||
.help(accessibilityText)
|
||||
}
|
||||
|
||||
private var iconName: String {
|
||||
private var accessibilityText: String {
|
||||
if vpnManager.state == .disconnecting { return "Lemana VPN отключается и восстанавливает DNS" }
|
||||
if vpnManager.isCheckingConnection || vpnManager.connectionCheckFailed || vpnManager.isExternallyActive,
|
||||
let message = vpnManager.connectionCheckMessage { return "Lemana VPN: \(message)" }
|
||||
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"
|
||||
case .connected:
|
||||
return vpnManager.tunnelHealthy ? "checkmark.shield" : "exclamationmark.shield"
|
||||
case .reconnecting:
|
||||
return "arrow.trianglehead.2.clockwise"
|
||||
case .error:
|
||||
return "exclamationmark.shield"
|
||||
return "Lemana VPN: \(vpnManager.tunnelHealthy ? "подключён" : "проблема соединения"), \(vpnManager.sessionTiming.remainingLabel(at: Date()).lowercased())"
|
||||
case .disconnected: return "Lemana VPN отключён"
|
||||
case .connecting: return "Lemana VPN подключается"
|
||||
case .disconnecting: return "Lemana VPN отключается и восстанавливает DNS"
|
||||
case .unlocking: return "Lemana VPN ожидает вход в Bitwarden"
|
||||
case .reconnecting: return "Lemana VPN переподключается"
|
||||
case .error(let message): return "Lemana VPN: \(message)"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
struct VPNMenuView: View {
|
||||
@ObservedObject var vpnManager: VPNManager
|
||||
@Binding var indicatorStyle: VPNIndicatorStyle
|
||||
|
||||
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: vpnManager.moduleStatusSystemImage)
|
||||
.disabled(true)
|
||||
Button("Обновить статус модулей") {
|
||||
vpnManager.refreshStatus()
|
||||
}
|
||||
Button("Изменить логин и пароль") {
|
||||
vpnManager.configureLogin()
|
||||
}
|
||||
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)
|
||||
Label(vpnManager.installModeLabel, systemImage: "slider.horizontal.3")
|
||||
.disabled(true)
|
||||
if let setupHint = vpnManager.installSetupHint {
|
||||
Label(setupHint, systemImage: "info.circle")
|
||||
.disabled(true)
|
||||
}
|
||||
Divider()
|
||||
ForEach(vpnManager.availableLaunchModes) { mode in
|
||||
Button(mode.menuTitle) {
|
||||
vpnManager.connect(mode: mode)
|
||||
VStack(alignment: .leading, spacing: 0) {
|
||||
HStack {
|
||||
Text("Lemana VPN").fontWeight(.semibold)
|
||||
Spacer(minLength: 8)
|
||||
HStack(spacing: 5) {
|
||||
VPNIconView(icon: vpnManager.statusIcon)
|
||||
Text(statusTitle).font(.system(size: 11)).foregroundColor(statusColor)
|
||||
}
|
||||
}
|
||||
.padding(.bottom, 10)
|
||||
|
||||
connectionDetails.padding(.bottom, 9)
|
||||
connectionActions.padding(.bottom, 3)
|
||||
separator
|
||||
authenticationSettings
|
||||
if let hint = vpnManager.installSetupHint { warning(hint) }
|
||||
ForEach(moduleWarnings, id: \.self) { warning($0) }
|
||||
if let error = additionalError { warning(error) }
|
||||
|
||||
separator
|
||||
menuButton("Импорт из Яндекс Ключа…", image: .importKey) {
|
||||
vpnManager.importYandexKey()
|
||||
}
|
||||
.help("Мастер импорта откроется в Terminal")
|
||||
.disabled(!vpnManager.canConfigureCredentials)
|
||||
menuButton("Логин и пароль…", image: .password) { vpnManager.configureLogin() }
|
||||
.disabled(!vpnManager.canConfigureCredentials)
|
||||
|
||||
separator
|
||||
Text("Значок в строке меню")
|
||||
.font(.system(size: 11))
|
||||
.foregroundColor(.secondary)
|
||||
.padding(.bottom, 6)
|
||||
Picker("Значок в строке меню", selection: $indicatorStyle) {
|
||||
ForEach(VPNIndicatorStyle.allCases) { style in Text(style.title).tag(style) }
|
||||
}
|
||||
.labelsHidden()
|
||||
.pickerStyle(.segmented)
|
||||
.controlSize(.small)
|
||||
.padding(.bottom, 3)
|
||||
|
||||
separator
|
||||
menuButton("Открыть логи", image: .logs) {
|
||||
let url = FileManager.default.homeDirectoryForCurrentUser.appendingPathComponent("Library/Logs/LemanaVPN.log")
|
||||
NSWorkspace.shared.open(url)
|
||||
}
|
||||
HStack {
|
||||
Button { vpnManager.refreshConnectionStatus() } label: {
|
||||
iconLabel("Обновить статус", icon: .refresh)
|
||||
}
|
||||
.disabled(vpnManager.isCheckingConnection || vpnManager.isSavingAuth)
|
||||
Spacer()
|
||||
Button {
|
||||
NSApplication.shared.terminate(nil)
|
||||
} label: {
|
||||
iconLabel("Выход", icon: .quit)
|
||||
}
|
||||
.keyboardShortcut("q")
|
||||
}
|
||||
.buttonStyle(.plain)
|
||||
.font(.system(size: 11))
|
||||
.foregroundColor(.secondary)
|
||||
.padding(.top, 7)
|
||||
}
|
||||
.font(.system(size: 13))
|
||||
.padding(12)
|
||||
.frame(width: 298)
|
||||
.background(Color(nsColor: .windowBackgroundColor))
|
||||
.onAppear { vpnManager.refreshStatus() }
|
||||
}
|
||||
|
||||
private func unlockingMenu(tier: String) -> some View {
|
||||
Group {
|
||||
Label("Bitwarden: \(tier)...", systemImage: "key")
|
||||
.disabled(true)
|
||||
Divider()
|
||||
Button("Отменить") {
|
||||
vpnManager.disconnect()
|
||||
@ViewBuilder private var authenticationSettings: some View {
|
||||
if let auth = vpnManager.moduleStatus?.auth {
|
||||
Text("Способ входа")
|
||||
.font(.system(size: 11)).foregroundColor(.secondary).padding(.bottom, 6)
|
||||
Picker("Способ входа", selection: Binding(
|
||||
get: { auth.mode },
|
||||
set: { vpnManager.setAuth($0) }
|
||||
)) {
|
||||
iconLabel("Вручную", icon: .manual).tag("manual")
|
||||
iconLabel("Автоматически", icon: .auto).tag("auto")
|
||||
}
|
||||
.pickerStyle(.segmented)
|
||||
.labelsHidden()
|
||||
.controlSize(.small)
|
||||
.disabled(!vpnManager.canSetAuth)
|
||||
if auth.mode == "auto" {
|
||||
Picker("Источник", selection: Binding(
|
||||
get: { auth.auto_source },
|
||||
set: { vpnManager.setAuth($0) }
|
||||
)) {
|
||||
iconLabel("Ключ на этом Mac", icon: .local).tag("keychain")
|
||||
iconLabel("Bitwarden", icon: .vault).tag("bitwarden")
|
||||
}
|
||||
.pickerStyle(.menu)
|
||||
.controlSize(.small)
|
||||
.padding(.top, 7)
|
||||
.disabled(!vpnManager.canSetAuth)
|
||||
}
|
||||
Text(vpnManager.isSavingAuth ? "Сохраняем настройку…" : auth.detail)
|
||||
.font(.system(size: 11)).foregroundColor(.secondary).fixedSize(horizontal: false, vertical: true)
|
||||
.padding(.top, 6)
|
||||
if vpnManager.isRunning {
|
||||
Text("Изменения применятся при следующем подключении")
|
||||
.font(.system(size: 11)).foregroundColor(.secondary).padding(.top, 4)
|
||||
}
|
||||
} else {
|
||||
Text(vpnManager.installModeLabel).font(.system(size: 11)).foregroundColor(.secondary)
|
||||
if !vpnManager.isCheckingConnection {
|
||||
warning("Обновите CLI Lemana VPN, чтобы настраивать способ входа здесь.")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
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)
|
||||
@ViewBuilder private var connectionDetails: some View {
|
||||
switch vpnManager.state {
|
||||
case .connected(let ip, _, _):
|
||||
HStack(alignment: .firstTextBaseline) {
|
||||
Text(vpnManager.sessionTiming.remainingLabel(at: Date())).monospacedDigit()
|
||||
Spacer(minLength: 6)
|
||||
if let expiry = vpnManager.sessionTiming.expiresAt, vpnManager.sessionTiming.remaining(at: Date()) != nil {
|
||||
Text("до \(expiry.formatted(date: .omitted, time: .shortened))")
|
||||
.font(.system(size: 11)).foregroundColor(.secondary)
|
||||
}
|
||||
}
|
||||
if !ip.isEmpty {
|
||||
Text("IP: \(ip)")
|
||||
.foregroundColor(.secondary)
|
||||
HStack(spacing: 7) {
|
||||
Text(ip).font(.system(size: 11)).foregroundColor(.secondary)
|
||||
Button {
|
||||
NSPasteboard.general.clearContents()
|
||||
NSPasteboard.general.setString(ip, forType: .string)
|
||||
} label: {
|
||||
VPNIconView(icon: .copy)
|
||||
}
|
||||
.buttonStyle(.plain)
|
||||
.keyboardShortcut("c", modifiers: [.command])
|
||||
.accessibilityLabel("Копировать IP")
|
||||
.help("Копировать IP")
|
||||
}
|
||||
.padding(.top, 4)
|
||||
}
|
||||
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")
|
||||
if !vpnManager.tunnelHealthy { warning("Проблема соединения: VPN не отвечает") }
|
||||
case .disconnected:
|
||||
Text(vpnManager.connectionCheckMessage ?? "Готов к подключению")
|
||||
.font(.system(size: 11)).foregroundColor(.secondary)
|
||||
.fixedSize(horizontal: false, vertical: true)
|
||||
case .disconnecting:
|
||||
Text("Завершаем VPN и восстанавливаем DNS…").foregroundColor(.secondary)
|
||||
case .unlocking:
|
||||
Text("Подтвердите вход в Bitwarden").foregroundColor(.secondary)
|
||||
case .connecting:
|
||||
Text("Ожидаем VPN-соединение").foregroundColor(.secondary)
|
||||
case .reconnecting(let attempt):
|
||||
Text("Попытка \(attempt)").foregroundColor(.secondary)
|
||||
case .error(let message):
|
||||
warning(message)
|
||||
if let checkMessage = vpnManager.connectionCheckMessage { warning(checkMessage) }
|
||||
}
|
||||
}
|
||||
|
||||
private func reconnectingMenu(attempt: Int) -> some View {
|
||||
Group {
|
||||
Label("Переподключение (\(attempt)/3)...", systemImage: "arrow.clockwise")
|
||||
.disabled(true)
|
||||
Divider()
|
||||
Button("Отменить") {
|
||||
vpnManager.disconnect()
|
||||
@ViewBuilder private var connectionActions: some View {
|
||||
if vpnManager.canRetryDisconnect {
|
||||
Button { vpnManager.disconnect() } label: { iconLabel("Повторить отключение", icon: .disconnect).frame(maxWidth: .infinity) }
|
||||
.buttonStyle(.bordered)
|
||||
.controlSize(.small)
|
||||
} else {
|
||||
switch vpnManager.state {
|
||||
case .disconnected, .error:
|
||||
Button { vpnManager.connect() } label: { iconLabel("Подключить", icon: .connect).frame(maxWidth: .infinity) }
|
||||
.buttonStyle(.bordered)
|
||||
.controlSize(.small)
|
||||
.disabled(!vpnManager.canConnect)
|
||||
case .connected:
|
||||
Button { vpnManager.disconnect() } label: { iconLabel("Отключить", icon: .disconnect).frame(maxWidth: .infinity) }
|
||||
.buttonStyle(.bordered)
|
||||
.controlSize(.small)
|
||||
.keyboardShortcut("d")
|
||||
case .disconnecting:
|
||||
Button {} label: { iconLabel("Отключение…", icon: .disconnect).frame(maxWidth: .infinity) }
|
||||
.buttonStyle(.bordered)
|
||||
.controlSize(.small)
|
||||
.disabled(true)
|
||||
case .unlocking, .connecting, .reconnecting:
|
||||
Button { vpnManager.disconnect() } label: { iconLabel("Отменить", icon: .cancel).frame(maxWidth: .infinity) }
|
||||
.buttonStyle(.bordered)
|
||||
.controlSize(.small)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private func errorMenu(message: String) -> some View {
|
||||
Group {
|
||||
Label("Ошибка", systemImage: "exclamationmark.triangle")
|
||||
.disabled(true)
|
||||
Text(message)
|
||||
.foregroundColor(.secondary)
|
||||
.lineLimit(3)
|
||||
Divider()
|
||||
Button("Подключить заново") {
|
||||
vpnManager.connect()
|
||||
private var separator: some View { Divider().padding(.vertical, 7) }
|
||||
|
||||
private func menuButton(_ title: String, image: VPNIcon, action: @escaping () -> Void) -> some View {
|
||||
Button(action: action) {
|
||||
HStack(spacing: 9) {
|
||||
VPNIconView(icon: image).foregroundColor(.secondary)
|
||||
Text(title)
|
||||
Spacer(minLength: 0)
|
||||
}
|
||||
.padding(.vertical, 6)
|
||||
.contentShape(Rectangle())
|
||||
}
|
||||
.buttonStyle(.plain)
|
||||
}
|
||||
|
||||
private func iconLabel(_ title: String, icon: VPNIcon) -> some View {
|
||||
Label { Text(title) } icon: { VPNIconView(icon: icon) }
|
||||
}
|
||||
|
||||
private func warning(_ message: String) -> some View {
|
||||
iconLabel(message, icon: .setup)
|
||||
.font(.system(size: 11))
|
||||
.foregroundColor(.secondary)
|
||||
.fixedSize(horizontal: false, vertical: true)
|
||||
.padding(.top, 6)
|
||||
}
|
||||
|
||||
private var additionalError: String? {
|
||||
if case .error(let message) = vpnManager.state, message == vpnManager.lastError { return nil }
|
||||
return vpnManager.lastError
|
||||
}
|
||||
|
||||
private var moduleWarnings: [String] {
|
||||
if vpnManager.isCheckingConnection { return [] }
|
||||
guard let modules = vpnManager.moduleStatus else { return ["Не удалось проверить установку. Обновите статус."] }
|
||||
var warnings: [String] = []
|
||||
if !modules.core.openconnect || !modules.core.openconnect_lite || !modules.core.openconnect_lite_config {
|
||||
warnings.append("Установка не завершена. Запустите установщик Lemana VPN.")
|
||||
}
|
||||
if modules.bitwarden.enabled && !modules.bitwarden.installed { warnings.append("Bitwarden не установлен.") }
|
||||
if modules.touchid.enabled && !modules.touchid.installed { warnings.append("Touch ID не настроен.") }
|
||||
if !modules.dns_cleanup.installed { warnings.append("Очистка DNS не настроена. Обновите установку.") }
|
||||
if !modules.patches.active { warnings.append("Подготовка SSO завершится при следующем подключении.") }
|
||||
if modules.app?.installed == false { warnings.append("Приложение не установлено. Запустите установщик Lemana VPN.") }
|
||||
return warnings
|
||||
}
|
||||
|
||||
private var statusTitle: String {
|
||||
if vpnManager.state == .disconnecting { return "Отключение" }
|
||||
if vpnManager.isCheckingConnection { return "Проверка" }
|
||||
if vpnManager.connectionCheckFailed { return "Нужна проверка" }
|
||||
if vpnManager.isExternallyActive { return "VPN ещё запущен" }
|
||||
switch vpnManager.state {
|
||||
case .disconnected: return "Отключён"
|
||||
case .disconnecting: return "Отключение"
|
||||
case .unlocking: return "Ожидаем вход"
|
||||
case .connecting: return "Подключение"
|
||||
case .connected:
|
||||
if !vpnManager.tunnelHealthy { return "Проблема сети" }
|
||||
return vpnManager.sessionTiming.remaining(at: Date()) == 0 ? "Срок истёк" : "Подключён"
|
||||
case .reconnecting: return "Переподключение"
|
||||
case .error: return "Ошибка"
|
||||
}
|
||||
}
|
||||
|
||||
private var statusColor: Color {
|
||||
if vpnManager.connectionCheckFailed { return .orange }
|
||||
switch vpnManager.state {
|
||||
case .connected:
|
||||
return vpnManager.tunnelHealthy ? Color(nsColor: vpnManager.sessionTiming.urgency(at: Date()).color) : .red
|
||||
case .error: return .red
|
||||
default: return .secondary
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user