Improve VPN status reporting and recovery
This commit is contained in:
@@ -0,0 +1,38 @@
|
||||
import Foundation
|
||||
|
||||
/// One current file and one bounded archive. The owning process serializes writes.
|
||||
struct BoundedLog {
|
||||
let url: URL
|
||||
var maxBytes = 5 * 1024 * 1024
|
||||
|
||||
func append(_ line: String) throws {
|
||||
let files = FileManager.default
|
||||
try files.createDirectory(at: url.deletingLastPathComponent(), withIntermediateDirectories: true)
|
||||
if !files.fileExists(atPath: url.path) {
|
||||
guard files.createFile(atPath: url.path, contents: nil, attributes: [.posixPermissions: 0o600]) else {
|
||||
throw CocoaError(.fileWriteUnknown)
|
||||
}
|
||||
}
|
||||
try files.setAttributes([.posixPermissions: 0o600], ofItemAtPath: url.path)
|
||||
let handle = try FileHandle(forUpdating: url)
|
||||
defer { try? handle.close() }
|
||||
let size = try handle.seekToEnd()
|
||||
let data = Data(line.utf8).suffix(maxBytes)
|
||||
let archive = url.appendingPathExtension("1")
|
||||
var archiveSize: UInt64 = 0
|
||||
if files.fileExists(atPath: archive.path) {
|
||||
try files.setAttributes([.posixPermissions: 0o600], ofItemAtPath: archive.path)
|
||||
archiveSize = (try files.attributesOfItem(atPath: archive.path)[.size] as? NSNumber)?.uint64Value ?? 0
|
||||
}
|
||||
if size + UInt64(data.count) > UInt64(maxBytes) || archiveSize > UInt64(maxBytes) {
|
||||
try handle.seek(toOffset: size > UInt64(maxBytes) ? size - UInt64(maxBytes) : 0)
|
||||
let tail = try handle.read(upToCount: maxBytes) ?? Data()
|
||||
guard files.createFile(atPath: archive.path, contents: tail, attributes: [.posixPermissions: 0o600]) else {
|
||||
throw CocoaError(.fileWriteUnknown)
|
||||
}
|
||||
try handle.truncate(atOffset: 0)
|
||||
try handle.seek(toOffset: 0)
|
||||
}
|
||||
try handle.write(contentsOf: data)
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,71 @@
|
||||
import Foundation
|
||||
|
||||
enum VPNIndicatorStyle: String, CaseIterable, Identifiable {
|
||||
case shield, ring, clock, time
|
||||
|
||||
var id: String { rawValue }
|
||||
var title: String {
|
||||
switch self {
|
||||
case .shield: return "Щит"
|
||||
case .ring: return "Кольцо"
|
||||
case .clock: return "Часы"
|
||||
case .time: return "Время"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
struct SessionTiming {
|
||||
enum Urgency { case normal, warning, critical, unknown }
|
||||
|
||||
private(set) var startedAt: Date?
|
||||
private(set) var expiresAt: Date?
|
||||
|
||||
private var duration: TimeInterval? {
|
||||
guard let start = startedAt, let end = expiresAt, end > start else { return nil }
|
||||
return end.timeIntervalSince(start)
|
||||
}
|
||||
|
||||
mutating func observe(startedAt: String?, expires: String?, now: Date = Date()) {
|
||||
let formatter = ISO8601DateFormatter()
|
||||
if let start = startedAt.flatMap({ formatter.date(from: $0) }) {
|
||||
// The CLI timestamp is authoritative, including after a legacy fallback.
|
||||
self.startedAt = start
|
||||
} else if self.startedAt == nil {
|
||||
self.startedAt = now
|
||||
}
|
||||
if let expires = expires {
|
||||
expiresAt = formatter.date(from: expires)
|
||||
}
|
||||
}
|
||||
|
||||
func remaining(at now: Date) -> TimeInterval? {
|
||||
guard duration != nil, let end = expiresAt else { return nil }
|
||||
return max(0, end.timeIntervalSince(now))
|
||||
}
|
||||
|
||||
func progress(at now: Date) -> Double? {
|
||||
guard let duration = duration, let start = startedAt else { return nil }
|
||||
return min(1, max(0, now.timeIntervalSince(start) / duration))
|
||||
}
|
||||
|
||||
func urgency(at now: Date) -> Urgency {
|
||||
guard let remaining = remaining(at: now) else { return .unknown }
|
||||
if remaining <= 15 * 60 { return .critical }
|
||||
if remaining <= 60 * 60 { return .warning }
|
||||
return .normal
|
||||
}
|
||||
|
||||
func compactRemaining(at now: Date) -> String? {
|
||||
guard let seconds = remaining(at: now) else { return nil }
|
||||
let minutes = Int(ceil(seconds / 60))
|
||||
return String(format: "%d:%02d", minutes / 60, minutes % 60)
|
||||
}
|
||||
|
||||
func remainingLabel(at now: Date) -> String {
|
||||
guard let seconds = remaining(at: now) else { return "Срок сессии неизвестен" }
|
||||
if seconds <= 0 { return "Время сессии истекло" }
|
||||
let minutes = Int(ceil(seconds / 60))
|
||||
if minutes < 60 { return "Осталось \(minutes) мин" }
|
||||
return "Осталось \(minutes / 60) ч \(minutes % 60) мин"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,215 @@
|
||||
import AppKit
|
||||
|
||||
enum VPNIcon: CaseIterable {
|
||||
case off, connecting, unlock, connected, reconnect, network, error, setup, unknown, noTime, expired, clock
|
||||
case connect, disconnect, cancel, importKey, password, copy, logs, refresh, quit, manual, auto, local, vault
|
||||
|
||||
var isAction: Bool {
|
||||
switch self {
|
||||
case .connect, .disconnect, .cancel, .importKey, .password, .copy, .logs, .refresh, .quit, .manual, .auto, .local, .vault: return true
|
||||
default: return false
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
enum VPNIcons {
|
||||
// Geometry and palettes from the approved artifacts/icon-family-v1/draw_icons.py.
|
||||
static let shield: CGPath = {
|
||||
let p = CGMutablePath()
|
||||
p.move(to: CGPoint(x: 12, y: 2))
|
||||
p.addCurve(to: CGPoint(x: 20, y: 5), control1: CGPoint(x: 14.6, y: 3.7), control2: CGPoint(x: 17.3, y: 4.7))
|
||||
p.addLine(to: CGPoint(x: 20, y: 11.2))
|
||||
p.addCurve(to: CGPoint(x: 12, y: 22), control1: CGPoint(x: 20, y: 16.1), control2: CGPoint(x: 16.7, y: 19.6))
|
||||
p.addCurve(to: CGPoint(x: 4, y: 11.2), control1: CGPoint(x: 7.3, y: 19.6), control2: CGPoint(x: 4, y: 16.1))
|
||||
p.addLine(to: CGPoint(x: 4, y: 5))
|
||||
p.addCurve(to: CGPoint(x: 12, y: 2), control1: CGPoint(x: 6.7, y: 4.7), control2: CGPoint(x: 9.4, y: 3.7))
|
||||
p.closeSubpath()
|
||||
return p
|
||||
}()
|
||||
|
||||
struct Palette {
|
||||
let ink, muted, green, amber, red: NSColor
|
||||
|
||||
init(dark: Bool) {
|
||||
func color(_ hex: UInt32) -> NSColor {
|
||||
NSColor(srgbRed: CGFloat((hex >> 16) & 255) / 255, green: CGFloat((hex >> 8) & 255) / 255, blue: CGFloat(hex & 255) / 255, alpha: 1)
|
||||
}
|
||||
ink = color(dark ? 0xE5EAF0 : 0x27333E)
|
||||
muted = color(dark ? 0xACB7C3 : 0x657381)
|
||||
green = color(dark ? 0x78CAA2 : 0x278055)
|
||||
amber = color(dark ? 0xF0BB68 : 0xA56A13)
|
||||
red = color(dark ? 0xF58C95 : 0xBE4149)
|
||||
}
|
||||
|
||||
func color(_ urgency: SessionTiming.Urgency) -> NSColor {
|
||||
switch urgency {
|
||||
case .normal: return green
|
||||
case .warning: return amber
|
||||
case .critical: return red
|
||||
case .unknown: return muted
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
static func image(_ icon: VPNIcon, dark: Bool) -> NSImage {
|
||||
let result = image { draw(icon, in: $0, dark: dark) }
|
||||
result.isTemplate = icon.isAction || [.off, .connecting, .unlock, .reconnect, .unknown, .noTime].contains(icon)
|
||||
return result
|
||||
}
|
||||
|
||||
static func sessionImage(style: VPNIndicatorStyle, progress: Double?, urgency: SessionTiming.Urgency, dark: Bool) -> NSImage {
|
||||
image { drawSession(style: style, progress: progress, urgency: urgency, in: $0, dark: dark) }
|
||||
}
|
||||
|
||||
private static func image(_ draw: @escaping (CGContext) -> Void) -> NSImage {
|
||||
NSImage(size: NSSize(width: 16, height: 16), flipped: true) { _ in
|
||||
guard let context = NSGraphicsContext.current?.cgContext else { return false }
|
||||
context.saveGState()
|
||||
context.scaleBy(x: 16 / 24, y: 16 / 24)
|
||||
draw(context)
|
||||
context.restoreGState()
|
||||
return true
|
||||
}
|
||||
}
|
||||
|
||||
static func draw(_ icon: VPNIcon, in context: CGContext, dark: Bool) {
|
||||
let palette = Palette(dark: dark)
|
||||
let color: NSColor
|
||||
switch icon {
|
||||
case .connected, .clock: color = palette.green
|
||||
case .network, .setup: color = palette.amber
|
||||
case .error, .expired: color = palette.red
|
||||
default: color = palette.ink
|
||||
}
|
||||
context.saveGState()
|
||||
if !icon.isAction { stroke(shield, in: context, color: palette.ink) }
|
||||
stroke(mark(icon), in: context, color: color)
|
||||
context.setFillColor(color.cgColor)
|
||||
if icon == .connecting {
|
||||
for x in [9.0, 12.0, 15.0] { context.fillEllipse(in: CGRect(x: x - 0.7, y: 11.3, width: 1.4, height: 1.4)) }
|
||||
} else if icon == .setup || icon == .unknown {
|
||||
context.fillEllipse(in: CGRect(x: 11.35, y: (icon == .setup ? 15.5 : 15.9) - 0.65, width: 1.3, height: 1.3))
|
||||
}
|
||||
context.restoreGState()
|
||||
}
|
||||
|
||||
static func drawSession(style: VPNIndicatorStyle, progress: Double?, urgency: SessionTiming.Urgency, in context: CGContext, dark: Bool) {
|
||||
guard let value = progress, value.isFinite else {
|
||||
draw(.noTime, in: context, dark: dark)
|
||||
return
|
||||
}
|
||||
let progress = min(1, max(0, value))
|
||||
let remaining = 1 - progress
|
||||
let palette = Palette(dark: dark)
|
||||
let accent = palette.color(urgency)
|
||||
if style == .time {
|
||||
stroke(shield, in: context, color: palette.ink)
|
||||
stroke(mark(progress == 1 ? .expired : .connected), in: context, color: accent)
|
||||
return
|
||||
}
|
||||
if style == .clock {
|
||||
stroke(shield, in: context, color: palette.ink)
|
||||
if progress == 1 {
|
||||
stroke(mark(.expired), in: context, color: accent)
|
||||
} else {
|
||||
let hand = CGMutablePath()
|
||||
hand.move(to: CGPoint(x: 12, y: 7.5))
|
||||
hand.addLine(to: CGPoint(x: 12, y: 12))
|
||||
let angle = progress * .pi * 2
|
||||
hand.addLine(to: CGPoint(x: 12 + sin(angle) * 4.5, y: 12 - cos(angle) * 4.5))
|
||||
stroke(hand, in: context, color: accent)
|
||||
}
|
||||
return
|
||||
}
|
||||
let ring = style == .ring
|
||||
let shape = ring ? CGPath(ellipseIn: CGRect(x: 3, y: 3, width: 18, height: 18), transform: nil) : shield
|
||||
let height: CGFloat = ring ? 18 : 20
|
||||
let bottom: CGFloat = ring ? 21 : 22
|
||||
context.saveGState()
|
||||
context.addPath(shape)
|
||||
context.clip()
|
||||
context.setFillColor(palette.muted.withAlphaComponent(0.30).cgColor)
|
||||
context.fill(CGRect(x: 0, y: 0, width: 24, height: 24))
|
||||
context.setFillColor(accent.withAlphaComponent(0.80).cgColor)
|
||||
context.fill(CGRect(x: 0, y: bottom - height * remaining, width: 24, height: height * remaining))
|
||||
context.restoreGState()
|
||||
stroke(shape, in: context, color: palette.muted.withAlphaComponent(ring ? 0.65 : 0.68))
|
||||
if remaining > 0 {
|
||||
context.saveGState()
|
||||
context.setLineCap(.butt)
|
||||
context.setLineWidth(1.75)
|
||||
context.setLineJoin(.round)
|
||||
context.setStrokeColor(accent.cgColor)
|
||||
if ring {
|
||||
context.addArc(center: CGPoint(x: 12, y: 12), radius: 9, startAngle: -.pi / 2, endAngle: -.pi / 2 + remaining * .pi * 2, clockwise: false)
|
||||
} else {
|
||||
context.setLineDash(phase: 0, lengths: [remaining * 58.04152102, 58.04152102])
|
||||
context.addPath(shape)
|
||||
}
|
||||
context.strokePath()
|
||||
context.restoreGState()
|
||||
}
|
||||
if progress == 1 { stroke(mark(.expired), in: context, color: palette.ink) }
|
||||
}
|
||||
|
||||
private static func stroke(_ path: CGPath, in context: CGContext, color: NSColor) {
|
||||
context.setLineWidth(1.75)
|
||||
context.setLineCap(.round)
|
||||
context.setLineJoin(.round)
|
||||
context.setStrokeColor(color.cgColor)
|
||||
context.addPath(path)
|
||||
context.strokePath()
|
||||
}
|
||||
|
||||
private static func mark(_ icon: VPNIcon) -> CGPath {
|
||||
let p = CGMutablePath()
|
||||
func m(_ x: CGFloat, _ y: CGFloat) { p.move(to: CGPoint(x: x, y: y)) }
|
||||
func l(_ x: CGFloat, _ y: CGFloat) { p.addLine(to: CGPoint(x: x, y: y)) }
|
||||
func c(_ x1: CGFloat, _ y1: CGFloat, _ x2: CGFloat, _ y2: CGFloat, _ x: CGFloat, _ y: CGFloat) {
|
||||
p.addCurve(to: CGPoint(x: x, y: y), control1: CGPoint(x: x1, y: y1), control2: CGPoint(x: x2, y: y2))
|
||||
}
|
||||
func circle(_ x: CGFloat, _ y: CGFloat, _ r: CGFloat) { p.addEllipse(in: CGRect(x: x - r, y: y - r, width: 2 * r, height: 2 * r)) }
|
||||
func rect(_ x: CGFloat, _ y: CGFloat, _ w: CGFloat, _ h: CGFloat, _ r: CGFloat) {
|
||||
p.addRoundedRect(in: CGRect(x: x, y: y, width: w, height: h), cornerWidth: r, cornerHeight: r)
|
||||
}
|
||||
switch icon {
|
||||
case .off: m(8.7, 8.7); l(15.3, 15.3)
|
||||
case .connecting: break
|
||||
case .unlock: circle(10, 10.5, 1.8); m(11.4, 11.9); l(15.4, 15.9); m(13.5, 14); l(15, 12.5)
|
||||
case .connected: m(7.8, 11.8); l(10.8, 14.7); l(16.2, 9.3)
|
||||
case .reconnect:
|
||||
p.addArc(center: CGPoint(x: 12.107508, y: 12.15), radius: 4, startAngle: -0.509224, endAngle: 0.509224, clockwise: true)
|
||||
m(15.6, 7.3); l(15.6, 10.2); l(12.7, 10.2)
|
||||
case .network:
|
||||
m(10, 10); l(9, 10); p.addArc(center: CGPoint(x: 9, y: 12), radius: 2, startAngle: -.pi / 2, endAngle: -.pi * 1.5, clockwise: true); l(10, 14)
|
||||
m(14, 10); l(15, 10); p.addArc(center: CGPoint(x: 15, y: 12), radius: 2, startAngle: -.pi / 2, endAngle: .pi / 2, clockwise: false); l(14, 14)
|
||||
case .error: m(9, 9); l(15, 15); m(15, 9); l(9, 15)
|
||||
case .setup: m(12, 7.8); l(12, 12.3)
|
||||
case .unknown: m(9.7, 9.2); c(9.8, 6.7, 14.6, 6.7, 14.6, 9.4); c(14.6, 11.3, 12, 11.3, 12, 13)
|
||||
case .expired: m(9, 8); l(15, 8); m(9, 16); l(15, 16); m(9.7, 8); c(9.7, 10.3, 14.3, 13.7, 14.3, 16); m(14.3, 8); c(14.3, 10.3, 9.7, 13.7, 9.7, 16)
|
||||
case .noTime: m(9, 12); l(15, 12)
|
||||
case .clock: m(12, 7.5); l(12, 12); l(15, 13.7)
|
||||
case .connect:
|
||||
m(8, 3); l(8, 8); m(16, 3); l(16, 8); m(6, 8); l(18, 8); l(18, 11); c(18, 14.3, 15.3, 17, 12, 17); c(8.7, 17, 6, 14.3, 6, 11); p.closeSubpath(); m(12, 17); l(12, 21)
|
||||
case .disconnect:
|
||||
m(8, 2.5); l(8, 6); m(16, 2.5); l(16, 6); m(6, 6); l(18, 6); l(18, 8); c(18, 10.2, 15.3, 11.5, 12, 11.5); c(8.7, 11.5, 6, 10.2, 6, 8); p.closeSubpath()
|
||||
m(7.5, 15.5); p.addQuadCurve(to: CGPoint(x: 16.5, y: 15.5), control: CGPoint(x: 12, y: 19.5)); m(12, 17.5); l(12, 21.5)
|
||||
case .cancel: m(6, 6); l(18, 18); m(18, 6); l(6, 18)
|
||||
case .importKey: m(5, 13); l(5, 20); l(19, 20); l(19, 13); m(12, 3); l(12, 15); m(8, 11); l(12, 15); l(16, 11)
|
||||
case .password: circle(8, 8, 4); m(11, 11); l(20, 20); m(15, 15); l(17, 13); m(18, 18); l(20, 16)
|
||||
case .copy: m(9, 4); l(19, 4); l(19, 15); m(5, 8); l(15, 8); l(15, 21); l(5, 21); p.closeSubpath()
|
||||
case .logs: m(6, 3); l(14, 3); l(19, 8); l(19, 21); l(6, 21); p.closeSubpath(); m(14, 3); l(14, 8); l(19, 8); m(9, 12); l(16, 12); m(9, 16); l(16, 16)
|
||||
case .refresh:
|
||||
p.addArc(center: CGPoint(x: 12.174321, y: 11.291312), radius: 7.2, startAngle: -0.323870, endAngle: -2.413483, clockwise: true)
|
||||
l(4, 9); m(4, 4); l(4, 9); l(9, 9)
|
||||
m(5, 15); p.addArc(center: CGPoint(x: 11.825679, y: 12.708688), radius: 7.2, startAngle: 2.817723, endAngle: 0.728110, clockwise: true)
|
||||
l(20, 15); m(20, 20); l(20, 15); l(15, 15)
|
||||
case .quit: m(11, 3); l(4, 3); l(4, 21); l(11, 21); m(10, 12); l(21, 12); m(17, 8); l(21, 12); l(17, 16)
|
||||
case .manual: rect(3, 6, 18, 12, 2); m(6.5, 10); l(7, 10); m(11.5, 10); l(12, 10); m(16.5, 10); l(17, 10); m(7, 14); l(17, 14)
|
||||
case .auto: m(13, 3); l(6, 13); l(11, 13); l(10, 21); l(18, 10); l(13, 10); p.closeSubpath()
|
||||
case .local: rect(5, 4, 14, 12, 1.5); m(2.5, 19); l(21.5, 19); l(20, 21); l(4, 21); p.closeSubpath()
|
||||
case .vault: rect(4.5, 3, 15, 18, 2.5); circle(12, 12, 3.8); m(12, 8.2); l(12, 10); m(15.8, 12); l(14, 12); m(12, 15.8); l(12, 14); m(8.2, 12); l(10, 12)
|
||||
}
|
||||
return p
|
||||
}
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
Reference in New Issue
Block a user