Improve VPN status reporting and recovery

This commit is contained in:
2026-10-13 06:53:52 +03:00
parent fdb15ae462
commit 7a716b4c9c
188 changed files with 6953 additions and 1911 deletions
+38
View File
@@ -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)
}
}
+345 -137
View File
@@ -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
}
}
}
+71
View File
@@ -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) мин"
}
}
+215
View File
@@ -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