Files
lemana-vpn/app/Sources/LemanaVPN/LemanaVPNApp.swift
T

401 lines
18 KiB
Swift

import AppKit
import SwiftUI
@main
struct LemanaVPNApp: App {
@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, indicatorStyle: $indicatorStyle)
} label: {
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) {
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 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 .connected:
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 {
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() }
}
@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, чтобы настраивать способ входа здесь.")
}
}
}
@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 {
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 !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) }
}
}
@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 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
}
}
}