401 lines
18 KiB
Swift
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
|
|
}
|
|
}
|
|
}
|