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 } } }