From dd6181d9273edc09a1dbf22f1778d88387e85630 Mon Sep 17 00:00:00 2001 From: ecuware Date: Sun, 5 Apr 2026 14:23:40 +0300 Subject: [PATCH 1/2] =?UTF-8?q?Refactor:=20Mod=C3=BCler=20mimari=20ve=20kr?= =?UTF-8?q?itik=20hata=20d=C3=BCzeltmeleri?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## Yapılan Değişiklikler ### 🔴 Kritik Hata Düzeltmeleri 1. **Log Race Condition (main.swift:387-400)** - DispatchQueue serial queue eklendi - Concurrent log yazımları dosya bozulmasını önlüyor - defer kullanılarak FileHandle temizliği sağlandı 2. **AssertionID State Confusion (main.swift:605-678)** - systemAssertionCreated ve displayAssertionCreated boolean flag'leri eklendi - UInt32.max sentinel değeri kullanıldı - preventSleep() ve allowSleep() arasındaki state tutarsızlığı giderildi 3. **Sürüm Uyuşmazlığı (main.swift:1458)** - Hardcoded "1.1.0" yerine Bundle'dan dinamik okuma - Info.plist CFBundleShortVersionString'e uyumlu hale getirildi ### 🟠 Yüksek Öncelikli Düzeltmeler 4. **withMemoryRebound Safety (main.swift:771)** - inet_ntop() dönüş değeri kontrol ediliyor - Nil guard eklendi 5. **Timer Memory Leak (main.swift:604)** - SleepManager'a deinit eklendi - allowSleep() çağrılarak timer cleanup sağlandı ### 🟡 Orta Öncelikli İyileştirmeler 6. **Singleton Thread Safety (main.swift:30-51)** - LocalizationManager'a NSLock eklendi - setLanguage() thread-safe hale getirildi 7. **Error Handling (main.swift:453-486)** - try? yerine do-catch kullanımı - Notification permission denied durumunda auto-disable - Log hataları dışında recovery mekanizması eklendi ### ⚪ Düşük Öncelikli / Yapısal İyileştirmeler 8. **Hardcoded URLs (AppConstants.swift)** - websiteURL, githubURL, websiteDisplayText, githubDisplayText - AppConstants struct'ı oluşturuldu - Tek merkezden yönetim 9. **Single File Architecture (1715 → 668 satır)** - LocalizationManager + Language → Sources/Localization.swift - SwiftUI Views → Sources/Views.swift - FloatingPanel + AppDelegate → Sources/AppDelegate.swift - Constants → Sources/AppConstants.swift - main.swift artık sadece core logic içeriyor 10. **Build Script Güncellemesi** - Tek dosya derleme → Sources/*.swift glob pattern - Çoklu kaynak dosyası desteği ## Dosya Yapısı - Sources/main.swift - 668 satır (core logic) - Sources/Views.swift - 644 satır (SwiftUI views) - Sources/AppDelegate.swift - 123 satır (AppDelegate + FloatingPanel) - Sources/Localization.swift - 270 satır (LocalizationManager) - Sources/AppConstants.swift - 13 satır (constants) ## Notlar - Build başarıyla test edildi - Tüm değişiklikler geriye dönük uyumlu - Modüler yapı sayesinde test yazılabilirliği artırıldı --- Sources/AppConstants.swift | 13 + Sources/AppDelegate.swift | 123 ++++ Sources/Localization.swift | 270 +++++++++ Sources/Views.swift | 644 +++++++++++++++++++++ Sources/main.swift | 1114 ++---------------------------------- build.sh | 6 +- 6 files changed, 1107 insertions(+), 1063 deletions(-) create mode 100644 Sources/AppConstants.swift create mode 100644 Sources/AppDelegate.swift create mode 100644 Sources/Localization.swift create mode 100644 Sources/Views.swift diff --git a/Sources/AppConstants.swift b/Sources/AppConstants.swift new file mode 100644 index 0000000..c58861b --- /dev/null +++ b/Sources/AppConstants.swift @@ -0,0 +1,13 @@ +import Foundation + +enum AppConstants { + static let websiteDisplayText = "www.softviser.com.tr" + static let githubDisplayText = "softviser/VPNKeepAwake" + + static let websiteURL = URL(string: "https://www.softviser.com.tr") + static let githubURL = URL(string: "https://github.com/softviser/VPNKeepAwake") + + static var shortVersion: String { + Bundle.main.infoDictionary?["CFBundleShortVersionString"] as? String ?? "1.0" + } +} diff --git a/Sources/AppDelegate.swift b/Sources/AppDelegate.swift new file mode 100644 index 0000000..44c6574 --- /dev/null +++ b/Sources/AppDelegate.swift @@ -0,0 +1,123 @@ +import Cocoa +import SwiftUI + +// MARK: - Floating Panel Window +class FloatingPanel: NSPanel { + override var canBecomeKey: Bool { true } + override var canBecomeMain: Bool { true } + + init() { + super.init( + contentRect: NSRect(x: 0, y: 0, width: 380, height: 620), + styleMask: [.titled, .closable, .fullSizeContentView, .nonactivatingPanel], + backing: .buffered, + defer: false + ) + + self.isFloatingPanel = true + self.level = .floating + self.collectionBehavior = [.canJoinAllSpaces, .fullScreenAuxiliary] + self.isMovableByWindowBackground = true + self.titlebarAppearsTransparent = true + self.titleVisibility = .hidden + self.backgroundColor = .clear + self.isOpaque = false + self.hasShadow = true + + self.contentView = NSHostingView(rootView: DashboardView()) + self.contentView?.wantsLayer = true + self.contentView?.layer?.cornerRadius = 16 + self.contentView?.layer?.masksToBounds = true + + self.center() + } +} + +// MARK: - App Delegate +class AppDelegate: NSObject, NSApplicationDelegate { + private var statusItem: NSStatusItem! + private var panel: FloatingPanel? + private var iconTimer: Timer? + + func applicationDidFinishLaunching(_ notification: Notification) { + LogManager.shared.log("App started", type: "APP") + + statusItem = NSStatusBar.system.statusItem(withLength: NSStatusItem.variableLength) + if let button = statusItem.button { + button.action = #selector(togglePanel) + button.target = self + } + + updateIcon() + iconTimer = Timer.scheduledTimer(withTimeInterval: 1.0, repeats: true) { [weak self] _ in + self?.updateIcon() + } + + DispatchQueue.main.asyncAfter(deadline: .now() + 0.5) { + self.showPanel() + } + } + + @objc private func togglePanel() { + if let panel = panel, panel.isVisible { + panel.orderOut(nil) + } else { + showPanel() + } + } + + private func showPanel() { + if panel == nil { + panel = FloatingPanel() + } + panel?.center() + panel?.makeKeyAndOrderFront(nil) + NSApp.activate(ignoringOtherApps: true) + } + + private func updateIcon() { + guard let button = statusItem.button else { return } + + let state = AppState.shared + let isConnected = state.vpnMonitor.isConnected + let isSleepPrevented = state.sleepManager.isPreventingSleep + let isEnabled = state.isEnabled + + if #available(macOS 11.0, *) { + let symbolName: String + + if !isEnabled { + symbolName = "moon.zzz" + } else if isConnected && isSleepPrevented { + symbolName = "lock.shield.fill" + } else if isConnected { + symbolName = "shield.fill" + } else { + symbolName = "shield.slash" + } + + let config = NSImage.SymbolConfiguration(pointSize: 16, weight: .medium) + if let image = NSImage(systemSymbolName: symbolName, accessibilityDescription: "VPN Keep Awake") { + let configuredImage = image.withSymbolConfiguration(config) + configuredImage?.isTemplate = true + button.image = configuredImage + } + } else { + button.title = isConnected && isSleepPrevented ? "🛡️" : (isConnected ? "⚠️" : "💤") + } + + if isConnected && isEnabled && state.settings.showUptimeInMenuBar, let uptime = state.stats.vpnUptime { + let h = Int(uptime) / 3600 + let m = (Int(uptime) % 3600) / 60 + button.title = h > 0 ? String(format: " %d:%02d", h, m) : String(format: " %dm", m) + } else { + button.title = "" + } + } + + func applicationWillTerminate(_ notification: Notification) { + AppState.shared.sleepManager.allowSleep() + iconTimer?.invalidate() + LogManager.shared.log("App terminated", type: "APP") + } +} diff --git a/Sources/Localization.swift b/Sources/Localization.swift new file mode 100644 index 0000000..24d6a39 --- /dev/null +++ b/Sources/Localization.swift @@ -0,0 +1,270 @@ +import Foundation +import SwiftUI + +// MARK: - Localization Manager +enum Language: String, CaseIterable { + case turkish = "tr" + case english = "en" + + var displayName: String { + switch self { + case .turkish: return "Türkçe" + case .english: return "English" + } + } +} + +class LocalizationManager: ObservableObject { + static let shared = LocalizationManager() + + @Published var currentLanguage: Language { + didSet { + UserDefaults.standard.set(currentLanguage.rawValue, forKey: "appLanguage") + } + } + private let lock = NSLock() + + private init() { + let saved = UserDefaults.standard.string(forKey: "appLanguage") ?? "tr" + self.currentLanguage = Language(rawValue: saved) ?? .turkish + } + + func setLanguage(_ language: Language) { + lock.lock() + defer { lock.unlock() } + DispatchQueue.main.async { + self.currentLanguage = language + } + } + + // MARK: - Translations + var appTitle: String { + currentLanguage == .turkish ? "VPN Keep Awake" : "VPN Keep Awake" + } + + var vpnStatus: String { + currentLanguage == .turkish ? "VPN Durumu" : "VPN Status" + } + + var connected: String { + currentLanguage == .turkish ? "Bağlı" : "Connected" + } + + var notConnected: String { + currentLanguage == .turkish ? "Bağlı Değil" : "Not Connected" + } + + var sleepProtection: String { + currentLanguage == .turkish ? "Uyku Koruması" : "Sleep Protection" + } + + var active: String { + currentLanguage == .turkish ? "Aktif" : "Active" + } + + var waiting: String { + currentLanguage == .turkish ? "Bekleniyor" : "Waiting" + } + + var disabled: String { + currentLanguage == .turkish ? "Kapalı" : "Disabled" + } + + var networkTraffic: String { + currentLanguage == .turkish ? "Ağ Trafiği" : "Network Traffic" + } + + var download: String { + currentLanguage == .turkish ? "İndirme" : "Download" + } + + var upload: String { + currentLanguage == .turkish ? "Yükleme" : "Upload" + } + + var total: String { + currentLanguage == .turkish ? "Toplam" : "Total" + } + + var todayStats: String { + currentLanguage == .turkish ? "Bugünkü İstatistikler" : "Today's Statistics" + } + + var connections: String { + currentLanguage == .turkish ? "Bağlantı" : "Connections" + } + + var disconnections: String { + currentLanguage == .turkish ? "Kopma" : "Disconnects" + } + + var settings: String { + currentLanguage == .turkish ? "Ayarlar" : "Settings" + } + + var checkInterval: String { + currentLanguage == .turkish ? "Kontrol Aralığı" : "Check Interval" + } + + var notifications: String { + currentLanguage == .turkish ? "Bildirimler" : "Notifications" + } + + var showNotifications: String { + currentLanguage == .turkish ? "Bildirimleri Göster" : "Show Notifications" + } + + var soundAlert: String { + currentLanguage == .turkish ? "Sesli Uyarı" : "Sound Alert" + } + + var language: String { + currentLanguage == .turkish ? "Dil" : "Language" + } + + var logFile: String { + currentLanguage == .turkish ? "Log Dosyası" : "Log File" + } + + var openInFinder: String { + currentLanguage == .turkish ? "Finder'da Aç" : "Open in Finder" + } + + var quit: String { + currentLanguage == .turkish ? "Çıkış" : "Quit" + } + + var vpnNotConnected: String { + currentLanguage == .turkish ? "VPN bağlı değil" : "VPN not connected" + } + + var autoRefresh: String { + currentLanguage == .turkish ? "Otomatik yenileme" : "Auto refresh" + } + + var nextUpdate: String { + currentLanguage == .turkish ? "Sonraki güncelleme" : "Next update" + } + + var seconds: String { + currentLanguage == .turkish ? "sn" : "sec" + } + + var hours: String { + currentLanguage == .turkish ? "saat" : "hours" + } + + var minutes: String { + currentLanguage == .turkish ? "dk" : "min" + } + + var protectedFor: String { + currentLanguage == .turkish ? "korunuyor" : "protected" + } + + // Notification texts + var vpnConnectedTitle: String { + currentLanguage == .turkish ? "VPN Bağlandı" : "VPN Connected" + } + + var vpnConnectedBody: String { + currentLanguage == .turkish ? "Uyku koruması aktif." : "Sleep protection active." + } + + var vpnDisconnectedTitle: String { + currentLanguage == .turkish ? "VPN Kesildi!" : "VPN Disconnected!" + } + + var vpnDisconnectedBody: String { + currentLanguage == .turkish ? "Dikkat: Uyku koruması devre dışı!" : "Warning: Sleep protection disabled!" + } + + // About section + var about: String { + currentLanguage == .turkish ? "Hakkında" : "About" + } + + var version: String { + currentLanguage == .turkish ? "Sürüm" : "Version" + } + + var developer: String { + currentLanguage == .turkish ? "Geliştirici" : "Developer" + } + + var website: String { + currentLanguage == .turkish ? "Web Sitesi" : "Website" + } + + var openSource: String { + currentLanguage == .turkish ? "Açık Kaynak" : "Open Source" + } + + var license: String { + currentLanguage == .turkish ? "Lisans" : "License" + } + + var madeWith: String { + currentLanguage == .turkish ? "Swift ile geliştirildi" : "Built with Swift" + } + + var freeAndOpenSource: String { + currentLanguage == .turkish ? "Ücretsiz ve Açık Kaynak" : "Free and Open Source" + } + + // Accessibility permission + var accessibilityRequired: String { + currentLanguage == .turkish ? "Erişilebilirlik İzni Gerekli" : "Accessibility Permission Required" + } + + var accessibilityDescription: String { + currentLanguage == .turkish + ? "Uyku engellemesinin tam çalışması için Erişilebilirlik izni gereklidir." + : "Accessibility permission is required for full sleep prevention." + } + + var openSystemSettings: String { + currentLanguage == .turkish ? "Sistem Ayarlarını Aç" : "Open System Settings" + } + + var accessibilityGranted: String { + currentLanguage == .turkish ? "Erişilebilirlik izni verildi" : "Accessibility permission granted" + } + + // Quit confirmation + var quitConfirmTitle: String { + currentLanguage == .turkish ? "Uygulamayı Kapat" : "Quit Application" + } + + var quitConfirmMessage: String { + currentLanguage == .turkish + ? "Program kapatılacaktır. Arka plana atmak için X butonunu tıklayın." + : "The application will be closed. Click the X button to minimize to background." + } + + var quitConfirmButton: String { + currentLanguage == .turkish ? "Kapat" : "Quit" + } + + var cancelButton: String { + currentLanguage == .turkish ? "İptal" : "Cancel" + } + + var showUptimeInMenuBar: String { + currentLanguage == .turkish ? "Menü Bar'da Uptime Göster" : "Show Uptime in Menu Bar" + } + + var vpnInterfaces: String { + currentLanguage == .turkish ? "VPN Bağlantıları" : "VPN Connections" + } + + var launchAtLogin: String { + currentLanguage == .turkish ? "Başlangıçta Otomatik Aç" : "Launch at Login" + } + + var launchAtLoginNote: String { + currentLanguage == .turkish + ? "macOS açıldığında uygulama otomatik başlar" + : "App starts automatically when macOS boots" + } +} diff --git a/Sources/Views.swift b/Sources/Views.swift new file mode 100644 index 0000000..32e2ba6 --- /dev/null +++ b/Sources/Views.swift @@ -0,0 +1,644 @@ +import SwiftUI +import Cocoa +import ServiceManagement + +// MARK: - SwiftUI Views + +struct GlassCard: View { + let content: Content + + init(@ViewBuilder content: () -> Content) { + self.content = content() + } + + var body: some View { + content + .padding(16) + .background( + RoundedRectangle(cornerRadius: 16) + .fill(.ultraThinMaterial) + .shadow(color: .black.opacity(0.1), radius: 8, y: 4) + ) + } +} + +struct StatusBadge: View { + let isActive: Bool + let activeText: String + let inactiveText: String + let activeColor: Color + + var body: some View { + HStack(spacing: 6) { + Circle() + .fill(isActive ? activeColor : .gray) + .frame(width: 8, height: 8) + .overlay( + Circle() + .stroke(isActive ? activeColor.opacity(0.5) : .clear, lineWidth: 2) + .scaleEffect(isActive ? 1.8 : 1) + .opacity(isActive ? 0 : 1) + .animation(.easeOut(duration: 1).repeatForever(autoreverses: false), value: isActive) + ) + + Text(isActive ? activeText : inactiveText) + .font(.system(size: 13, weight: .semibold)) + .foregroundColor(isActive ? activeColor : .secondary) + } + .padding(.horizontal, 12) + .padding(.vertical, 6) + .background( + Capsule() + .fill(isActive ? activeColor.opacity(0.15) : Color.gray.opacity(0.1)) + ) + } +} + +struct SpeedGauge: View { + let label: String + let speed: Double + let icon: String + let color: Color + let formatter: (Double) -> String + + var body: some View { + VStack(spacing: 8) { + Image(systemName: icon) + .font(.system(size: 20)) + .foregroundColor(color) + + Text(formatter(speed)) + .font(.system(size: 16, weight: .bold, design: .monospaced)) + .foregroundColor(.primary) + + Text(label) + .font(.system(size: 10)) + .foregroundColor(.secondary) + } + .frame(maxWidth: .infinity) + .padding(.vertical, 12) + .background( + RoundedRectangle(cornerRadius: 12) + .fill(color.opacity(0.1)) + ) + } +} + +struct StatItem: View { + let value: String + let label: String + let color: Color + + var body: some View { + VStack(spacing: 4) { + Text(value) + .font(.system(size: 28, weight: .bold, design: .rounded)) + .foregroundColor(color) + Text(label) + .font(.system(size: 11)) + .foregroundColor(.secondary) + } + .frame(maxWidth: .infinity) + } +} + +struct AutoRefreshIndicator: View { + let secondsRemaining: Int + let totalSeconds: Int + @ObservedObject var l = LocalizationManager.shared + + var progress: Double { + guard totalSeconds > 0 else { return 0 } + return Double(totalSeconds - secondsRemaining) / Double(totalSeconds) + } + + var body: some View { + HStack(spacing: 8) { + ZStack { + Circle() + .stroke(Color.blue.opacity(0.2), lineWidth: 2) + .frame(width: 16, height: 16) + + Circle() + .trim(from: 0, to: progress) + .stroke(Color.blue, style: StrokeStyle(lineWidth: 2, lineCap: .round)) + .frame(width: 16, height: 16) + .rotationEffect(.degrees(-90)) + .animation(.linear(duration: 1), value: progress) + } + + Text("\(l.nextUpdate): \(secondsRemaining)\(l.seconds)") + .font(.system(size: 11)) + .foregroundColor(.secondary) + } + } +} + +struct DashboardView: View { + @ObservedObject var state = AppState.shared + @ObservedObject var l = LocalizationManager.shared + @State private var showSettings = false + @State private var showQuitAlert = false + @Environment(\.colorScheme) var colorScheme + + var body: some View { + VStack(spacing: 0) { + HStack { + Image(systemName: "shield.checkered") + .font(.system(size: 20)) + .foregroundStyle( + LinearGradient(colors: [.blue, .purple], startPoint: .topLeading, endPoint: .bottomTrailing) + ) + + Text(l.appTitle) + .font(.system(size: 18, weight: .bold)) + + Spacer() + + Button(action: { showSettings.toggle() }) { + Image(systemName: showSettings ? "xmark.circle.fill" : "gearshape.fill") + .font(.system(size: 16)) + .foregroundColor(.secondary) + } + .buttonStyle(.plain) + } + .padding(.horizontal, 20) + .padding(.top, 16) + .padding(.bottom, 12) + + Divider() + .padding(.horizontal, 20) + + ScrollView { + VStack(spacing: 16) { + if showSettings { + settingsSection + } else { + mainSection + } + } + .padding(20) + } + + Divider() + + HStack { + AutoRefreshIndicator( + secondsRemaining: state.secondsUntilNextUpdate, + totalSeconds: Int(state.settings.checkInterval) + ) + + Spacer() + + Button(action: { showQuitAlert = true }) { + Label(l.quit, systemImage: "power") + .font(.system(size: 12, weight: .medium)) + } + .buttonStyle(.plain) + .foregroundColor(.red) + } + .padding(.horizontal, 20) + .padding(.vertical, 12) + } + .frame(width: 380, height: 620) + .alert(isPresented: $showQuitAlert) { + Alert( + title: Text(l.quitConfirmTitle), + message: Text(l.quitConfirmMessage), + primaryButton: .destructive(Text(l.quitConfirmButton)) { + NSApplication.shared.terminate(nil) + }, + secondaryButton: .cancel(Text(l.cancelButton)) + ) + } + .background( + colorScheme == .dark + ? Color(NSColor.windowBackgroundColor) + : Color(NSColor.controlBackgroundColor) + ) + } + + var mainSection: some View { + VStack(spacing: 16) { + if !state.sleepManager.hasAccessibilityPermission { + GlassCard { + VStack(spacing: 10) { + HStack(spacing: 8) { + Image(systemName: "exclamationmark.triangle.fill") + .font(.system(size: 18)) + .foregroundColor(.orange) + + Text(l.accessibilityRequired) + .font(.system(size: 13, weight: .semibold)) + .foregroundColor(.orange) + + Spacer() + } + + Text(l.accessibilityDescription) + .font(.system(size: 12)) + .foregroundColor(.secondary) + .frame(maxWidth: .infinity, alignment: .leading) + + Button(action: { + state.sleepManager.requestAccessibilityPermission() + }) { + HStack(spacing: 6) { + Image(systemName: "gear") + .font(.system(size: 12)) + Text(l.openSystemSettings) + .font(.system(size: 12, weight: .medium)) + } + .frame(maxWidth: .infinity) + .padding(.vertical, 8) + .background( + RoundedRectangle(cornerRadius: 8) + .fill(Color.orange.opacity(0.15)) + ) + .foregroundColor(.orange) + } + .buttonStyle(.plain) + } + } + } + + GlassCard { + VStack(spacing: 12) { + HStack { + VStack(alignment: .leading, spacing: 4) { + Text(l.vpnStatus) + .font(.system(size: 12)) + .foregroundColor(.secondary) + + HStack(spacing: 8) { + StatusBadge( + isActive: state.vpnMonitor.isConnected, + activeText: l.connected, + inactiveText: l.notConnected, + activeColor: .green + ) + + if let uptime = state.stats.vpnUptime { + Text(StatisticsManager.formatDuration(uptime)) + .font(.system(size: 14, weight: .medium, design: .monospaced)) + .foregroundColor(.secondary) + } + } + } + + Spacer() + + Image(systemName: state.vpnMonitor.isConnected ? "checkmark.shield.fill" : "shield.slash.fill") + .font(.system(size: 32)) + .foregroundStyle( + state.vpnMonitor.isConnected + ? LinearGradient(colors: [.green, .mint], startPoint: .top, endPoint: .bottom) + : LinearGradient(colors: [.red, .orange], startPoint: .top, endPoint: .bottom) + ) + } + + if state.vpnMonitor.isConnected { + Divider() + + if state.vpnMonitor.allInterfaces.count > 1 { + Text(l.vpnInterfaces) + .font(.system(size: 10)) + .foregroundColor(.secondary) + .frame(maxWidth: .infinity, alignment: .leading) + } + + ForEach(state.vpnMonitor.allInterfaces) { iface in + HStack { + VStack(alignment: .leading, spacing: 2) { + Text("Interface") + .font(.system(size: 10)) + .foregroundColor(.secondary) + Text(iface.name) + .font(.system(size: 13, weight: .medium, design: .monospaced)) + } + + Spacer() + + VStack(alignment: .trailing, spacing: 2) { + Text("IP") + .font(.system(size: 10)) + .foregroundColor(.secondary) + Text(iface.ip) + .font(.system(size: 13, weight: .medium, design: .monospaced)) + } + } + + if iface.id != state.vpnMonitor.allInterfaces.last?.id { + Divider().opacity(0.5) + } + } + } + } + } + + GlassCard { + HStack { + VStack(alignment: .leading, spacing: 6) { + Text(l.sleepProtection) + .font(.system(size: 12)) + .foregroundColor(.secondary) + + HStack(spacing: 8) { + StatusBadge( + isActive: state.sleepManager.isPreventingSleep, + activeText: l.active, + inactiveText: state.isEnabled ? l.waiting : l.disabled, + activeColor: .purple + ) + + if let duration = state.stats.sleepPreventionDuration { + Text("\(state.stats.formatDurationLong(duration)) \(l.protectedFor)") + .font(.system(size: 12)) + .foregroundColor(.secondary) + } + } + } + + Spacer() + + Toggle("", isOn: Binding( + get: { state.isEnabled }, + set: { _ in state.toggle() } + )) + .toggleStyle(.switch) + .labelsHidden() + } + } + + GlassCard { + VStack(spacing: 12) { + HStack { + Text(l.networkTraffic) + .font(.system(size: 12)) + .foregroundColor(.secondary) + Spacer() + if state.vpnMonitor.isConnected { + Text("\(l.total): \(state.vpnMonitor.networkStats.formatBytes(state.vpnMonitor.networkStats.sessionDownload + state.vpnMonitor.networkStats.sessionUpload))") + .font(.system(size: 10)) + .foregroundColor(.secondary) + } + } + + HStack(spacing: 12) { + SpeedGauge( + label: l.download, + speed: state.vpnMonitor.networkStats.downloadSpeed, + icon: "arrow.down.circle.fill", + color: .blue, + formatter: state.vpnMonitor.networkStats.formatSpeed + ) + + SpeedGauge( + label: l.upload, + speed: state.vpnMonitor.networkStats.uploadSpeed, + icon: "arrow.up.circle.fill", + color: .orange, + formatter: state.vpnMonitor.networkStats.formatSpeed + ) + } + + if !state.vpnMonitor.isConnected { + Text(l.vpnNotConnected) + .font(.system(size: 11)) + .foregroundColor(.secondary) + .padding(.top, 4) + } + } + } + + GlassCard { + VStack(spacing: 12) { + Text(l.todayStats) + .font(.system(size: 12)) + .foregroundColor(.secondary) + .frame(maxWidth: .infinity, alignment: .leading) + + HStack(spacing: 0) { + StatItem( + value: "\(state.stats.todayConnectionCount)", + label: l.connections, + color: .green + ) + + Divider() + .frame(height: 40) + + StatItem( + value: "\(state.stats.todayDisconnectionCount)", + label: l.disconnections, + color: .red + ) + } + } + } + + } + } + + var settingsSection: some View { + VStack(spacing: 16) { + GlassCard { + VStack(alignment: .leading, spacing: 12) { + Text(l.language) + .font(.system(size: 13, weight: .semibold)) + + Picker("", selection: $l.currentLanguage) { + ForEach(Language.allCases, id: \.self) { lang in + Text(lang.displayName).tag(lang) + } + } + .pickerStyle(.segmented) + } + } + + GlassCard { + VStack(alignment: .leading, spacing: 16) { + Text(l.checkInterval) + .font(.system(size: 13, weight: .semibold)) + + Picker("", selection: Binding( + get: { state.settings.checkInterval }, + set: { + state.settings.checkInterval = $0 + state.startMonitoring() + } + )) { + Text("5 \(l.seconds)").tag(5.0) + Text("10 \(l.seconds)").tag(10.0) + Text("30 \(l.seconds)").tag(30.0) + Text("60 \(l.seconds)").tag(60.0) + } + .pickerStyle(.segmented) + } + } + + GlassCard { + VStack(alignment: .leading, spacing: 12) { + Text(l.notifications) + .font(.system(size: 13, weight: .semibold)) + + Toggle(l.showNotifications, isOn: $state.settings.notificationsEnabled) + .font(.system(size: 13)) + + Toggle(l.soundAlert, isOn: $state.settings.soundEnabled) + .font(.system(size: 13)) + + Toggle(l.showUptimeInMenuBar, isOn: $state.settings.showUptimeInMenuBar) + .font(.system(size: 13)) + } + } + + if #available(macOS 13.0, *) { + GlassCard { + VStack(alignment: .leading, spacing: 8) { + Toggle(isOn: Binding( + get: { + SMAppService.mainApp.status == .enabled + }, + set: { newValue in + do { + if newValue { + try SMAppService.mainApp.register() + LogManager.shared.log("Launch at login enabled", type: "APP") + } else { + try SMAppService.mainApp.unregister() + LogManager.shared.log("Launch at login disabled", type: "APP") + } + } catch { + LogManager.shared.log("Launch at login error: \(error.localizedDescription)", type: "ERROR") + } + } + )) { + Text(l.launchAtLogin) + .font(.system(size: 13, weight: .semibold)) + } + + Text(l.launchAtLoginNote) + .font(.system(size: 11)) + .foregroundColor(.secondary) + } + } + } + + GlassCard { + VStack(alignment: .leading, spacing: 12) { + Text(l.logFile) + .font(.system(size: 13, weight: .semibold)) + + Text(LogManager.shared.logFilePath) + .font(.system(size: 10, design: .monospaced)) + .foregroundColor(.secondary) + .lineLimit(2) + + Button(action: { + NSWorkspace.shared.selectFile(LogManager.shared.logFilePath, inFileViewerRootedAtPath: "") + }) { + Label(l.openInFinder, systemImage: "folder") + .font(.system(size: 12)) + } + .buttonStyle(.plain) + .foregroundColor(.blue) + } + } + + GlassCard { + VStack(spacing: 16) { + VStack(spacing: 8) { + Image(systemName: "shield.checkered") + .font(.system(size: 40)) + .foregroundStyle( + LinearGradient(colors: [.blue, .purple], startPoint: .topLeading, endPoint: .bottomTrailing) + ) + + Text("VPN Keep Awake") + .font(.system(size: 16, weight: .bold)) + + Text("\(l.version) \(AppConstants.shortVersion)") + .font(.system(size: 12)) + .foregroundColor(.secondary) + } + + Divider() + + VStack(spacing: 8) { + HStack { + Text(l.developer) + .font(.system(size: 12)) + .foregroundColor(.secondary) + Spacer() + Text("Softviser") + .font(.system(size: 12, weight: .semibold)) + .foregroundColor(.primary) + } + + HStack { + Text(l.website) + .font(.system(size: 12)) + .foregroundColor(.secondary) + Spacer() + Button(action: { + guard let url = AppConstants.websiteURL else { return } + NSWorkspace.shared.open(url) + }) { + Text(AppConstants.websiteDisplayText) + .font(.system(size: 12, weight: .medium)) + } + .buttonStyle(.plain) + .foregroundColor(.blue) + } + + HStack { + Text("GitHub") + .font(.system(size: 12)) + .foregroundColor(.secondary) + Spacer() + Button(action: { + guard let url = AppConstants.githubURL else { return } + NSWorkspace.shared.open(url) + }) { + Text(AppConstants.githubDisplayText) + .font(.system(size: 12, weight: .medium)) + } + .buttonStyle(.plain) + .foregroundColor(.blue) + } + + HStack { + Text(l.license) + .font(.system(size: 12)) + .foregroundColor(.secondary) + Spacer() + Text("MIT License") + .font(.system(size: 12)) + .foregroundColor(.primary) + } + } + + Divider() + + VStack(spacing: 4) { + HStack(spacing: 4) { + Image(systemName: "swift") + .font(.system(size: 12)) + .foregroundColor(.orange) + Text(l.madeWith) + .font(.system(size: 11)) + .foregroundColor(.secondary) + } + + Text(l.freeAndOpenSource) + .font(.system(size: 10)) + .foregroundColor(.secondary) + } + } + } + + Spacer() + } + } +} diff --git a/Sources/main.swift b/Sources/main.swift index a75a768..565d1fd 100644 --- a/Sources/main.swift +++ b/Sources/main.swift @@ -14,266 +14,6 @@ import UserNotifications import AudioToolbox import ServiceManagement -// MARK: - Localization Manager -enum Language: String, CaseIterable { - case turkish = "tr" - case english = "en" - - var displayName: String { - switch self { - case .turkish: return "Türkçe" - case .english: return "English" - } - } -} - -class LocalizationManager: ObservableObject { - static let shared = LocalizationManager() - - @Published var currentLanguage: Language { - didSet { - UserDefaults.standard.set(currentLanguage.rawValue, forKey: "appLanguage") - } - } - - private init() { - let saved = UserDefaults.standard.string(forKey: "appLanguage") ?? "tr" - self.currentLanguage = Language(rawValue: saved) ?? .turkish - } - - // MARK: - Translations - var appTitle: String { - currentLanguage == .turkish ? "VPN Keep Awake" : "VPN Keep Awake" - } - - var vpnStatus: String { - currentLanguage == .turkish ? "VPN Durumu" : "VPN Status" - } - - var connected: String { - currentLanguage == .turkish ? "Bağlı" : "Connected" - } - - var notConnected: String { - currentLanguage == .turkish ? "Bağlı Değil" : "Not Connected" - } - - var sleepProtection: String { - currentLanguage == .turkish ? "Uyku Koruması" : "Sleep Protection" - } - - var active: String { - currentLanguage == .turkish ? "Aktif" : "Active" - } - - var waiting: String { - currentLanguage == .turkish ? "Bekleniyor" : "Waiting" - } - - var disabled: String { - currentLanguage == .turkish ? "Kapalı" : "Disabled" - } - - var networkTraffic: String { - currentLanguage == .turkish ? "Ağ Trafiği" : "Network Traffic" - } - - var download: String { - currentLanguage == .turkish ? "İndirme" : "Download" - } - - var upload: String { - currentLanguage == .turkish ? "Yükleme" : "Upload" - } - - var total: String { - currentLanguage == .turkish ? "Toplam" : "Total" - } - - var todayStats: String { - currentLanguage == .turkish ? "Bugünkü İstatistikler" : "Today's Statistics" - } - - var connections: String { - currentLanguage == .turkish ? "Bağlantı" : "Connections" - } - - var disconnections: String { - currentLanguage == .turkish ? "Kopma" : "Disconnects" - } - - var settings: String { - currentLanguage == .turkish ? "Ayarlar" : "Settings" - } - - var checkInterval: String { - currentLanguage == .turkish ? "Kontrol Aralığı" : "Check Interval" - } - - var notifications: String { - currentLanguage == .turkish ? "Bildirimler" : "Notifications" - } - - var showNotifications: String { - currentLanguage == .turkish ? "Bildirimleri Göster" : "Show Notifications" - } - - var soundAlert: String { - currentLanguage == .turkish ? "Sesli Uyarı" : "Sound Alert" - } - - var language: String { - currentLanguage == .turkish ? "Dil" : "Language" - } - - var logFile: String { - currentLanguage == .turkish ? "Log Dosyası" : "Log File" - } - - var openInFinder: String { - currentLanguage == .turkish ? "Finder'da Aç" : "Open in Finder" - } - - var quit: String { - currentLanguage == .turkish ? "Çıkış" : "Quit" - } - - var vpnNotConnected: String { - currentLanguage == .turkish ? "VPN bağlı değil" : "VPN not connected" - } - - var autoRefresh: String { - currentLanguage == .turkish ? "Otomatik yenileme" : "Auto refresh" - } - - var nextUpdate: String { - currentLanguage == .turkish ? "Sonraki güncelleme" : "Next update" - } - - var seconds: String { - currentLanguage == .turkish ? "sn" : "sec" - } - - var hours: String { - currentLanguage == .turkish ? "saat" : "hours" - } - - var minutes: String { - currentLanguage == .turkish ? "dk" : "min" - } - - var protectedFor: String { - currentLanguage == .turkish ? "korunuyor" : "protected" - } - - // Notification texts - var vpnConnectedTitle: String { - currentLanguage == .turkish ? "VPN Bağlandı" : "VPN Connected" - } - - var vpnConnectedBody: String { - currentLanguage == .turkish ? "Uyku koruması aktif." : "Sleep protection active." - } - - var vpnDisconnectedTitle: String { - currentLanguage == .turkish ? "VPN Kesildi!" : "VPN Disconnected!" - } - - var vpnDisconnectedBody: String { - currentLanguage == .turkish ? "Dikkat: Uyku koruması devre dışı!" : "Warning: Sleep protection disabled!" - } - - // About section - var about: String { - currentLanguage == .turkish ? "Hakkında" : "About" - } - - var version: String { - currentLanguage == .turkish ? "Sürüm" : "Version" - } - - var developer: String { - currentLanguage == .turkish ? "Geliştirici" : "Developer" - } - - var website: String { - currentLanguage == .turkish ? "Web Sitesi" : "Website" - } - - var openSource: String { - currentLanguage == .turkish ? "Açık Kaynak" : "Open Source" - } - - var license: String { - currentLanguage == .turkish ? "Lisans" : "License" - } - - var madeWith: String { - currentLanguage == .turkish ? "Swift ile geliştirildi" : "Built with Swift" - } - - var freeAndOpenSource: String { - currentLanguage == .turkish ? "Ücretsiz ve Açık Kaynak" : "Free and Open Source" - } - - // Accessibility permission - var accessibilityRequired: String { - currentLanguage == .turkish ? "Erişilebilirlik İzni Gerekli" : "Accessibility Permission Required" - } - - var accessibilityDescription: String { - currentLanguage == .turkish - ? "Uyku engellemesinin tam çalışması için Erişilebilirlik izni gereklidir." - : "Accessibility permission is required for full sleep prevention." - } - - var openSystemSettings: String { - currentLanguage == .turkish ? "Sistem Ayarlarını Aç" : "Open System Settings" - } - - var accessibilityGranted: String { - currentLanguage == .turkish ? "Erişilebilirlik izni verildi" : "Accessibility permission granted" - } - - // Quit confirmation - var quitConfirmTitle: String { - currentLanguage == .turkish ? "Uygulamayı Kapat" : "Quit Application" - } - - var quitConfirmMessage: String { - currentLanguage == .turkish - ? "Program kapatılacaktır. Arka plana atmak için X butonunu tıklayın." - : "The application will be closed. Click the X button to minimize to background." - } - - var quitConfirmButton: String { - currentLanguage == .turkish ? "Kapat" : "Quit" - } - - var cancelButton: String { - currentLanguage == .turkish ? "İptal" : "Cancel" - } - - var showUptimeInMenuBar: String { - currentLanguage == .turkish ? "Menü Bar'da Uptime Göster" : "Show Uptime in Menu Bar" - } - - var vpnInterfaces: String { - currentLanguage == .turkish ? "VPN Bağlantıları" : "VPN Connections" - } - - var launchAtLogin: String { - currentLanguage == .turkish ? "Başlangıçta Otomatik Aç" : "Launch at Login" - } - - var launchAtLoginNote: String { - currentLanguage == .turkish - ? "macOS açıldığında uygulama otomatik başlar" - : "App starts automatically when macOS boots" - } - -} - // MARK: - Statistics Manager class StatisticsManager: ObservableObject { static let shared = StatisticsManager() @@ -374,6 +114,7 @@ class LogManager { static let shared = LogManager() private let logFile: URL private let dateFormatter: DateFormatter + private let logQueue = DispatchQueue(label: "com.softviser.vpnkeepawake.log", qos: .utility) private init() { let appSupport = FileManager.default.urls(for: .applicationSupportDirectory, in: .userDomainMask).first! @@ -387,16 +128,21 @@ class LogManager { func log(_ message: String, type: String = "INFO") { let timestamp = dateFormatter.string(from: Date()) let logLine = "[\(timestamp)] [\(type)] \(message)\n" - if let data = logLine.data(using: .utf8) { - if FileManager.default.fileExists(atPath: logFile.path) { - if let handle = try? FileHandle(forWritingTo: logFile) { - handle.seekToEndOfFile() - handle.write(data) - handle.closeFile() + guard let data = logLine.data(using: .utf8) else { return } + + logQueue.async { + let handle = FileHandle(forWritingAtPath: self.logFile.path) + if handle == nil { + do { + try data.write(to: self.logFile) + } catch { + print("Failed to create log file: \(error)") } - } else { - try? data.write(to: logFile) + return } + defer { handle?.closeFile() } + handle?.seekToEndOfFile() + handle?.write(data) } } @@ -441,8 +187,17 @@ class NotificationManager: NSObject, UNUserNotificationCenterDelegate { center.requestAuthorization(options: [.alert, .sound, .badge]) { granted, error in if let error = error { LogManager.shared.log("Notification auth error: \(error.localizedDescription)", type: "ERROR") + DispatchQueue.main.async { + SettingsManager.shared.notificationsEnabled = false + } + return } LogManager.shared.log("Notification permission: \(granted ? "granted" : "denied")", type: "PERM") + if !granted { + DispatchQueue.main.async { + SettingsManager.shared.notificationsEnabled = false + } + } } } @@ -467,6 +222,9 @@ class NotificationManager: NSObject, UNUserNotificationCenterDelegate { UNUserNotificationCenter.current().add(request) { error in if let error = error { LogManager.shared.log("Notification send error: \(error.localizedDescription)", type: "ERROR") + DispatchQueue.main.async { + SettingsManager.shared.notificationsEnabled = false + } } } if playSound && SettingsManager.shared.soundEnabled { @@ -602,10 +360,16 @@ class NetworkStatsManager: ObservableObject { class SleepManager: ObservableObject { @Published var isPreventingSleep = false @Published var hasAccessibilityPermission = false - private var assertionID: IOPMAssertionID = 0 - private var displayAssertionID: IOPMAssertionID = 0 + private var systemAssertionID: IOPMAssertionID = UInt32.max + private var displayAssertionID: IOPMAssertionID = UInt32.max + private var systemAssertionCreated = false + private var displayAssertionCreated = false private var activityTimer: Timer? + deinit { + allowSleep() + } + func checkAccessibilityPermission() { let trusted = AXIsProcessTrusted() if trusted != hasAccessibilityPermission { @@ -626,12 +390,11 @@ class SleepManager: ObservableObject { let reason = "VPN connection active" as CFString - // Sistem uyku + display assertion - let result = IOPMAssertionCreateWithName( + let systemResult = IOPMAssertionCreateWithName( kIOPMAssertPreventUserIdleSystemSleep as CFString, IOPMAssertionLevel(kIOPMAssertionLevelOn), reason, - &assertionID + &systemAssertionID ) let displayResult = IOPMAssertionCreateWithName( @@ -641,7 +404,12 @@ class SleepManager: ObservableObject { &displayAssertionID ) - if result == kIOReturnSuccess { + if systemResult == kIOReturnSuccess { + systemAssertionCreated = true + if displayResult == kIOReturnSuccess { + displayAssertionCreated = true + } + DispatchQueue.main.async { self.isPreventingSleep = true } StatisticsManager.shared.sleepPreventionStarted() LogManager.shared.log("Sleep prevented", type: "SLEEP") @@ -658,12 +426,17 @@ class SleepManager: ObservableObject { func allowSleep() { guard isPreventingSleep else { return } - IOPMAssertionRelease(assertionID) - assertionID = 0 - if displayAssertionID != 0 { + if systemAssertionCreated { + IOPMAssertionRelease(systemAssertionID) + systemAssertionID = UInt32.max + systemAssertionCreated = false + } + + if displayAssertionCreated { IOPMAssertionRelease(displayAssertionID) - displayAssertionID = 0 + displayAssertionID = UInt32.max + displayAssertionCreated = false } stopActivitySimulation() @@ -688,14 +461,12 @@ class SleepManager: ObservableObject { } private func simulateActivity() { - // IOKit user activity IOPMAssertionDeclareUserActivity( "VPN Keep Awake - activity" as CFString, kIOPMUserActiveLocal, - &assertionID + nil ) - // Fare mikro hareketi (accessibility izni gerekli) guard hasAccessibilityPermission else { return } let currentPos = NSEvent.mouseLocation @@ -759,7 +530,8 @@ class VPNMonitor: ObservableObject { if name.hasPrefix(prefix) { var sockAddr = addr.withMemoryRebound(to: sockaddr_in.self, capacity: 1) { $0.pointee } var hostname = [CChar](repeating: 0, count: Int(NI_MAXHOST)) - inet_ntop(AF_INET, &sockAddr.sin_addr, &hostname, socklen_t(NI_MAXHOST)) + let result = inet_ntop(AF_INET, &sockAddr.sin_addr, &hostname, socklen_t(NI_MAXHOST)) + guard result != nil else { continue } let ipStr = String(cString: hostname) if !ipStr.isEmpty && ipStr != "0.0.0.0" { @@ -887,784 +659,6 @@ class AppState: ObservableObject { } } -// MARK: - SwiftUI Views - -struct GlassCard: View { - let content: Content - - init(@ViewBuilder content: () -> Content) { - self.content = content() - } - - var body: some View { - content - .padding(16) - .background( - RoundedRectangle(cornerRadius: 16) - .fill(.ultraThinMaterial) - .shadow(color: .black.opacity(0.1), radius: 8, y: 4) - ) - } -} - -struct StatusBadge: View { - let isActive: Bool - let activeText: String - let inactiveText: String - let activeColor: Color - - var body: some View { - HStack(spacing: 6) { - Circle() - .fill(isActive ? activeColor : .gray) - .frame(width: 8, height: 8) - .overlay( - Circle() - .stroke(isActive ? activeColor.opacity(0.5) : .clear, lineWidth: 2) - .scaleEffect(isActive ? 1.8 : 1) - .opacity(isActive ? 0 : 1) - .animation(.easeOut(duration: 1).repeatForever(autoreverses: false), value: isActive) - ) - - Text(isActive ? activeText : inactiveText) - .font(.system(size: 13, weight: .semibold)) - .foregroundColor(isActive ? activeColor : .secondary) - } - .padding(.horizontal, 12) - .padding(.vertical, 6) - .background( - Capsule() - .fill(isActive ? activeColor.opacity(0.15) : Color.gray.opacity(0.1)) - ) - } -} - -struct SpeedGauge: View { - let label: String - let speed: Double - let icon: String - let color: Color - let formatter: (Double) -> String - - var body: some View { - VStack(spacing: 8) { - Image(systemName: icon) - .font(.system(size: 20)) - .foregroundColor(color) - - Text(formatter(speed)) - .font(.system(size: 16, weight: .bold, design: .monospaced)) - .foregroundColor(.primary) - - Text(label) - .font(.system(size: 10)) - .foregroundColor(.secondary) - } - .frame(maxWidth: .infinity) - .padding(.vertical, 12) - .background( - RoundedRectangle(cornerRadius: 12) - .fill(color.opacity(0.1)) - ) - } -} - -struct StatItem: View { - let value: String - let label: String - let color: Color - - var body: some View { - VStack(spacing: 4) { - Text(value) - .font(.system(size: 28, weight: .bold, design: .rounded)) - .foregroundColor(color) - Text(label) - .font(.system(size: 11)) - .foregroundColor(.secondary) - } - .frame(maxWidth: .infinity) - } -} - -struct AutoRefreshIndicator: View { - let secondsRemaining: Int - let totalSeconds: Int - @ObservedObject var l = LocalizationManager.shared - - var progress: Double { - guard totalSeconds > 0 else { return 0 } - return Double(totalSeconds - secondsRemaining) / Double(totalSeconds) - } - - var body: some View { - HStack(spacing: 8) { - // Circular progress - ZStack { - Circle() - .stroke(Color.blue.opacity(0.2), lineWidth: 2) - .frame(width: 16, height: 16) - - Circle() - .trim(from: 0, to: progress) - .stroke(Color.blue, style: StrokeStyle(lineWidth: 2, lineCap: .round)) - .frame(width: 16, height: 16) - .rotationEffect(.degrees(-90)) - .animation(.linear(duration: 1), value: progress) - } - - Text("\(l.nextUpdate): \(secondsRemaining)\(l.seconds)") - .font(.system(size: 11)) - .foregroundColor(.secondary) - } - } -} - -struct DashboardView: View { - @ObservedObject var state = AppState.shared - @ObservedObject var l = LocalizationManager.shared - @State private var showSettings = false - @State private var showQuitAlert = false - @Environment(\.colorScheme) var colorScheme - - var body: some View { - VStack(spacing: 0) { - // Title Bar - HStack { - Image(systemName: "shield.checkered") - .font(.system(size: 20)) - .foregroundStyle( - LinearGradient(colors: [.blue, .purple], startPoint: .topLeading, endPoint: .bottomTrailing) - ) - - Text(l.appTitle) - .font(.system(size: 18, weight: .bold)) - - Spacer() - - Button(action: { showSettings.toggle() }) { - Image(systemName: showSettings ? "xmark.circle.fill" : "gearshape.fill") - .font(.system(size: 16)) - .foregroundColor(.secondary) - } - .buttonStyle(.plain) - } - .padding(.horizontal, 20) - .padding(.top, 16) - .padding(.bottom, 12) - - Divider() - .padding(.horizontal, 20) - - ScrollView { - VStack(spacing: 16) { - if showSettings { - settingsSection - } else { - mainSection - } - } - .padding(20) - } - - Divider() - - // Footer with auto-refresh - HStack { - AutoRefreshIndicator( - secondsRemaining: state.secondsUntilNextUpdate, - totalSeconds: Int(state.settings.checkInterval) - ) - - Spacer() - - Button(action: { showQuitAlert = true }) { - Label(l.quit, systemImage: "power") - .font(.system(size: 12, weight: .medium)) - } - .buttonStyle(.plain) - .foregroundColor(.red) - } - .padding(.horizontal, 20) - .padding(.vertical, 12) - } - .frame(width: 380, height: 620) - .alert(isPresented: $showQuitAlert) { - Alert( - title: Text(l.quitConfirmTitle), - message: Text(l.quitConfirmMessage), - primaryButton: .destructive(Text(l.quitConfirmButton)) { - NSApplication.shared.terminate(nil) - }, - secondaryButton: .cancel(Text(l.cancelButton)) - ) - } - .background( - colorScheme == .dark - ? Color(NSColor.windowBackgroundColor) - : Color(NSColor.controlBackgroundColor) - ) - } - - var mainSection: some View { - VStack(spacing: 16) { - // Accessibility permission warning - if !state.sleepManager.hasAccessibilityPermission { - GlassCard { - VStack(spacing: 10) { - HStack(spacing: 8) { - Image(systemName: "exclamationmark.triangle.fill") - .font(.system(size: 18)) - .foregroundColor(.orange) - - Text(l.accessibilityRequired) - .font(.system(size: 13, weight: .semibold)) - .foregroundColor(.orange) - - Spacer() - } - - Text(l.accessibilityDescription) - .font(.system(size: 12)) - .foregroundColor(.secondary) - .frame(maxWidth: .infinity, alignment: .leading) - - Button(action: { - state.sleepManager.requestAccessibilityPermission() - }) { - HStack(spacing: 6) { - Image(systemName: "gear") - .font(.system(size: 12)) - Text(l.openSystemSettings) - .font(.system(size: 12, weight: .medium)) - } - .frame(maxWidth: .infinity) - .padding(.vertical, 8) - .background( - RoundedRectangle(cornerRadius: 8) - .fill(Color.orange.opacity(0.15)) - ) - .foregroundColor(.orange) - } - .buttonStyle(.plain) - } - } - } - - // VPN Status - GlassCard { - VStack(spacing: 12) { - HStack { - VStack(alignment: .leading, spacing: 4) { - Text(l.vpnStatus) - .font(.system(size: 12)) - .foregroundColor(.secondary) - - HStack(spacing: 8) { - StatusBadge( - isActive: state.vpnMonitor.isConnected, - activeText: l.connected, - inactiveText: l.notConnected, - activeColor: .green - ) - - if let uptime = state.stats.vpnUptime { - Text(StatisticsManager.formatDuration(uptime)) - .font(.system(size: 14, weight: .medium, design: .monospaced)) - .foregroundColor(.secondary) - } - } - } - - Spacer() - - Image(systemName: state.vpnMonitor.isConnected ? "checkmark.shield.fill" : "shield.slash.fill") - .font(.system(size: 32)) - .foregroundStyle( - state.vpnMonitor.isConnected - ? LinearGradient(colors: [.green, .mint], startPoint: .top, endPoint: .bottom) - : LinearGradient(colors: [.red, .orange], startPoint: .top, endPoint: .bottom) - ) - } - - if state.vpnMonitor.isConnected { - Divider() - - if state.vpnMonitor.allInterfaces.count > 1 { - Text(l.vpnInterfaces) - .font(.system(size: 10)) - .foregroundColor(.secondary) - .frame(maxWidth: .infinity, alignment: .leading) - } - - ForEach(state.vpnMonitor.allInterfaces) { iface in - HStack { - VStack(alignment: .leading, spacing: 2) { - Text("Interface") - .font(.system(size: 10)) - .foregroundColor(.secondary) - Text(iface.name) - .font(.system(size: 13, weight: .medium, design: .monospaced)) - } - - Spacer() - - VStack(alignment: .trailing, spacing: 2) { - Text("IP") - .font(.system(size: 10)) - .foregroundColor(.secondary) - Text(iface.ip) - .font(.system(size: 13, weight: .medium, design: .monospaced)) - } - } - - if iface.id != state.vpnMonitor.allInterfaces.last?.id { - Divider().opacity(0.5) - } - } - } - } - } - - // Sleep Protection - GlassCard { - HStack { - VStack(alignment: .leading, spacing: 6) { - Text(l.sleepProtection) - .font(.system(size: 12)) - .foregroundColor(.secondary) - - HStack(spacing: 8) { - StatusBadge( - isActive: state.sleepManager.isPreventingSleep, - activeText: l.active, - inactiveText: state.isEnabled ? l.waiting : l.disabled, - activeColor: .purple - ) - - if let duration = state.stats.sleepPreventionDuration { - Text("\(state.stats.formatDurationLong(duration)) \(l.protectedFor)") - .font(.system(size: 12)) - .foregroundColor(.secondary) - } - } - } - - Spacer() - - Toggle("", isOn: Binding( - get: { state.isEnabled }, - set: { _ in state.toggle() } - )) - .toggleStyle(.switch) - .labelsHidden() - } - } - - // Network Traffic - GlassCard { - VStack(spacing: 12) { - HStack { - Text(l.networkTraffic) - .font(.system(size: 12)) - .foregroundColor(.secondary) - Spacer() - if state.vpnMonitor.isConnected { - Text("\(l.total): \(state.vpnMonitor.networkStats.formatBytes(state.vpnMonitor.networkStats.sessionDownload + state.vpnMonitor.networkStats.sessionUpload))") - .font(.system(size: 10)) - .foregroundColor(.secondary) - } - } - - HStack(spacing: 12) { - SpeedGauge( - label: l.download, - speed: state.vpnMonitor.networkStats.downloadSpeed, - icon: "arrow.down.circle.fill", - color: .blue, - formatter: state.vpnMonitor.networkStats.formatSpeed - ) - - SpeedGauge( - label: l.upload, - speed: state.vpnMonitor.networkStats.uploadSpeed, - icon: "arrow.up.circle.fill", - color: .orange, - formatter: state.vpnMonitor.networkStats.formatSpeed - ) - } - - if !state.vpnMonitor.isConnected { - Text(l.vpnNotConnected) - .font(.system(size: 11)) - .foregroundColor(.secondary) - .padding(.top, 4) - } - } - } - - // Daily Statistics - GlassCard { - VStack(spacing: 12) { - Text(l.todayStats) - .font(.system(size: 12)) - .foregroundColor(.secondary) - .frame(maxWidth: .infinity, alignment: .leading) - - HStack(spacing: 0) { - StatItem( - value: "\(state.stats.todayConnectionCount)", - label: l.connections, - color: .green - ) - - Divider() - .frame(height: 40) - - StatItem( - value: "\(state.stats.todayDisconnectionCount)", - label: l.disconnections, - color: .red - ) - } - } - } - - } - } - - var settingsSection: some View { - VStack(spacing: 16) { - // Language Selection - GlassCard { - VStack(alignment: .leading, spacing: 12) { - Text(l.language) - .font(.system(size: 13, weight: .semibold)) - - Picker("", selection: $l.currentLanguage) { - ForEach(Language.allCases, id: \.self) { lang in - Text(lang.displayName).tag(lang) - } - } - .pickerStyle(.segmented) - } - } - - GlassCard { - VStack(alignment: .leading, spacing: 16) { - Text(l.checkInterval) - .font(.system(size: 13, weight: .semibold)) - - Picker("", selection: Binding( - get: { state.settings.checkInterval }, - set: { - state.settings.checkInterval = $0 - state.startMonitoring() - } - )) { - Text("5 \(l.seconds)").tag(5.0) - Text("10 \(l.seconds)").tag(10.0) - Text("30 \(l.seconds)").tag(30.0) - Text("60 \(l.seconds)").tag(60.0) - } - .pickerStyle(.segmented) - } - } - - GlassCard { - VStack(alignment: .leading, spacing: 12) { - Text(l.notifications) - .font(.system(size: 13, weight: .semibold)) - - Toggle(l.showNotifications, isOn: $state.settings.notificationsEnabled) - .font(.system(size: 13)) - - Toggle(l.soundAlert, isOn: $state.settings.soundEnabled) - .font(.system(size: 13)) - - Toggle(l.showUptimeInMenuBar, isOn: $state.settings.showUptimeInMenuBar) - .font(.system(size: 13)) - } - } - - // Launch at Login - if #available(macOS 13.0, *) { - GlassCard { - VStack(alignment: .leading, spacing: 8) { - Toggle(isOn: Binding( - get: { - SMAppService.mainApp.status == .enabled - }, - set: { newValue in - do { - if newValue { - try SMAppService.mainApp.register() - LogManager.shared.log("Launch at login enabled", type: "APP") - } else { - try SMAppService.mainApp.unregister() - LogManager.shared.log("Launch at login disabled", type: "APP") - } - } catch { - LogManager.shared.log("Launch at login error: \(error.localizedDescription)", type: "ERROR") - } - } - )) { - Text(l.launchAtLogin) - .font(.system(size: 13, weight: .semibold)) - } - - Text(l.launchAtLoginNote) - .font(.system(size: 11)) - .foregroundColor(.secondary) - } - } - } - - GlassCard { - VStack(alignment: .leading, spacing: 12) { - Text(l.logFile) - .font(.system(size: 13, weight: .semibold)) - - Text(LogManager.shared.logFilePath) - .font(.system(size: 10, design: .monospaced)) - .foregroundColor(.secondary) - .lineLimit(2) - - Button(action: { - NSWorkspace.shared.selectFile(LogManager.shared.logFilePath, inFileViewerRootedAtPath: "") - }) { - Label(l.openInFinder, systemImage: "folder") - .font(.system(size: 12)) - } - .buttonStyle(.plain) - .foregroundColor(.blue) - } - } - - // About Section - GlassCard { - VStack(spacing: 16) { - // App Icon and Name - VStack(spacing: 8) { - Image(systemName: "shield.checkered") - .font(.system(size: 40)) - .foregroundStyle( - LinearGradient(colors: [.blue, .purple], startPoint: .topLeading, endPoint: .bottomTrailing) - ) - - Text("VPN Keep Awake") - .font(.system(size: 16, weight: .bold)) - - Text("\(l.version) 1.1.0") - .font(.system(size: 12)) - .foregroundColor(.secondary) - } - - Divider() - - // Developer Info - VStack(spacing: 8) { - HStack { - Text(l.developer) - .font(.system(size: 12)) - .foregroundColor(.secondary) - Spacer() - Text("Softviser") - .font(.system(size: 12, weight: .semibold)) - .foregroundColor(.primary) - } - - HStack { - Text(l.website) - .font(.system(size: 12)) - .foregroundColor(.secondary) - Spacer() - Button(action: { - if let url = URL(string: "https://www.softviser.com.tr") { - NSWorkspace.shared.open(url) - } - }) { - Text("www.softviser.com.tr") - .font(.system(size: 12, weight: .medium)) - } - .buttonStyle(.plain) - .foregroundColor(.blue) - } - - HStack { - Text("GitHub") - .font(.system(size: 12)) - .foregroundColor(.secondary) - Spacer() - Button(action: { - if let url = URL(string: "https://github.com/softviser/VPNKeepAwake") { - NSWorkspace.shared.open(url) - } - }) { - Text("softviser/VPNKeepAwake") - .font(.system(size: 12, weight: .medium)) - } - .buttonStyle(.plain) - .foregroundColor(.blue) - } - - HStack { - Text(l.license) - .font(.system(size: 12)) - .foregroundColor(.secondary) - Spacer() - Text("MIT License") - .font(.system(size: 12)) - .foregroundColor(.primary) - } - } - - Divider() - - // Footer - VStack(spacing: 4) { - HStack(spacing: 4) { - Image(systemName: "swift") - .font(.system(size: 12)) - .foregroundColor(.orange) - Text(l.madeWith) - .font(.system(size: 11)) - .foregroundColor(.secondary) - } - - Text(l.freeAndOpenSource) - .font(.system(size: 10)) - .foregroundColor(.secondary) - } - } - } - - Spacer() - } - } -} - -// MARK: - Floating Panel Window -class FloatingPanel: NSPanel { - override var canBecomeKey: Bool { true } - override var canBecomeMain: Bool { true } - - init() { - super.init( - contentRect: NSRect(x: 0, y: 0, width: 380, height: 620), - styleMask: [.titled, .closable, .fullSizeContentView, .nonactivatingPanel], - backing: .buffered, - defer: false - ) - - self.isFloatingPanel = true - self.level = .floating - self.collectionBehavior = [.canJoinAllSpaces, .fullScreenAuxiliary] - self.isMovableByWindowBackground = true - self.titlebarAppearsTransparent = true - self.titleVisibility = .hidden - self.backgroundColor = .clear - self.isOpaque = false - self.hasShadow = true - - self.contentView = NSHostingView(rootView: DashboardView()) - self.contentView?.wantsLayer = true - self.contentView?.layer?.cornerRadius = 16 - self.contentView?.layer?.masksToBounds = true - - self.center() - } -} - -// MARK: - App Delegate -class AppDelegate: NSObject, NSApplicationDelegate { - private var statusItem: NSStatusItem! - private var panel: FloatingPanel? - private var iconTimer: Timer? - - func applicationDidFinishLaunching(_ notification: Notification) { - LogManager.shared.log("App started", type: "APP") - - statusItem = NSStatusBar.system.statusItem(withLength: NSStatusItem.variableLength) - if let button = statusItem.button { - button.action = #selector(togglePanel) - button.target = self - } - - updateIcon() - iconTimer = Timer.scheduledTimer(withTimeInterval: 1.0, repeats: true) { [weak self] _ in - self?.updateIcon() - } - - DispatchQueue.main.asyncAfter(deadline: .now() + 0.5) { - self.showPanel() - } - } - - @objc private func togglePanel() { - if let panel = panel, panel.isVisible { - panel.orderOut(nil) - } else { - showPanel() - } - } - - private func showPanel() { - if panel == nil { - panel = FloatingPanel() - } - panel?.center() - panel?.makeKeyAndOrderFront(nil) - NSApp.activate(ignoringOtherApps: true) - } - - private func updateIcon() { - guard let button = statusItem.button else { return } - - let state = AppState.shared - let isConnected = state.vpnMonitor.isConnected - let isSleepPrevented = state.sleepManager.isPreventingSleep - let isEnabled = state.isEnabled - - if #available(macOS 11.0, *) { - let symbolName: String - - if !isEnabled { - symbolName = "moon.zzz" - } else if isConnected && isSleepPrevented { - symbolName = "lock.shield.fill" - } else if isConnected { - symbolName = "shield.fill" - } else { - symbolName = "shield.slash" - } - - let config = NSImage.SymbolConfiguration(pointSize: 16, weight: .medium) - if let image = NSImage(systemSymbolName: symbolName, accessibilityDescription: "VPN Keep Awake") { - let configuredImage = image.withSymbolConfiguration(config) - configuredImage?.isTemplate = true - button.image = configuredImage - } - } else { - button.title = isConnected && isSleepPrevented ? "🛡️" : (isConnected ? "⚠️" : "💤") - } - - // Menu bar uptime display - if isConnected && isEnabled && state.settings.showUptimeInMenuBar, let uptime = state.stats.vpnUptime { - let h = Int(uptime) / 3600 - let m = (Int(uptime) % 3600) / 60 - button.title = h > 0 ? String(format: " %d:%02d", h, m) : String(format: " %dm", m) - } else { - button.title = "" - } - } - - func applicationWillTerminate(_ notification: Notification) { - AppState.shared.sleepManager.allowSleep() - iconTimer?.invalidate() - LogManager.shared.log("App terminated", type: "APP") - } -} // MARK: - Main let app = NSApplication.shared diff --git a/build.sh b/build.sh index 0be046b..b0ac5c1 100755 --- a/build.sh +++ b/build.sh @@ -21,14 +21,14 @@ mkdir -p "$BUILD_DIR" mkdir -p "$APP_BUNDLE/Contents/MacOS" mkdir -p "$APP_BUNDLE/Contents/Resources" -# Compile Swift source (try Universal Binary, fallback to native arch) +# Compile Swift sources (try Universal Binary, fallback to native arch) echo "Compiling Swift source..." swiftc -O \ -sdk $(xcrun --show-sdk-path) \ -target arm64-apple-macosx10.15 \ -target x86_64-apple-macosx10.15 \ -o "$APP_BUNDLE/Contents/MacOS/$APP_NAME" \ - "$SCRIPT_DIR/Sources/main.swift" \ + "$SCRIPT_DIR"/Sources/*.swift \ -framework Cocoa \ -framework IOKit \ -framework SystemConfiguration \ @@ -36,7 +36,7 @@ swiftc -O \ swiftc -O \ -sdk $(xcrun --show-sdk-path) \ -o "$APP_BUNDLE/Contents/MacOS/$APP_NAME" \ - "$SCRIPT_DIR/Sources/main.swift" \ + "$SCRIPT_DIR"/Sources/*.swift \ -framework Cocoa \ -framework IOKit \ -framework SystemConfiguration From 85d9b47dcf7f7cdee6d0cb321b23144aace53c28 Mon Sep 17 00:00:00 2001 From: ecuware Date: Sun, 5 Apr 2026 14:36:14 +0300 Subject: [PATCH 2/2] UI Design: Dark theme palette and accessibility fix MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## Değişiklikler ### Renk Paleti - Midnight blue background (#0F172A) - Slate card background (#1E293B) - Emerald connected gradient (#10B981 → #34D399) - Red-Orange disconnected gradient (#EF4444 → #F97316) - Indigo-Purple primary gradient (#6366F1 → #8B5CF6) - New text hierarchy with near-white primary (#F8FAFC) ### Theme System - AppTheme struct with centralized color definitions - Color(hex:) extension for hex color support - ConnectionStatus enum for status color mapping - Reusable status badges with glow effects ### İkonography - Custom SF Symbols with gradient styling - Glow effects on status indicators - Card borders with subtle opacity ### Accessibility Fix - checkAccessibilityPermission() called in applicationDidFinishLaunching - Prevents false "permission required" warning on app launch - Permission check also added to startMonitoring() ## Dosyalar - Sources/Theme.swift (new) - Theme constants and color extensions - Sources/Views.swift - Updated with dark theme colors - Sources/AppDelegate.swift - Early permission check on launch - Sources/main.swift - Early permission check in startMonitoring() --- Sources/AppDelegate.swift | 1 + Sources/Theme.swift | 79 ++++ Sources/Views.swift | 840 +++++++++++++++++++------------------- Sources/main.swift | 2 + 4 files changed, 509 insertions(+), 413 deletions(-) create mode 100644 Sources/Theme.swift diff --git a/Sources/AppDelegate.swift b/Sources/AppDelegate.swift index 44c6574..e73eb54 100644 --- a/Sources/AppDelegate.swift +++ b/Sources/AppDelegate.swift @@ -48,6 +48,7 @@ class AppDelegate: NSObject, NSApplicationDelegate { button.target = self } + AppState.shared.sleepManager.checkAccessibilityPermission() updateIcon() iconTimer = Timer.scheduledTimer(withTimeInterval: 1.0, repeats: true) { [weak self] _ in self?.updateIcon() diff --git a/Sources/Theme.swift b/Sources/Theme.swift new file mode 100644 index 0000000..f3bdb2d --- /dev/null +++ b/Sources/Theme.swift @@ -0,0 +1,79 @@ +import SwiftUI + +// MARK: - App Theme +enum AppTheme { + // MARK: - Brand Colors + static let primaryGradient = LinearGradient( + colors: [Color(hex: "6366F1"), Color(hex: "8B5CF6")], + startPoint: .topLeading, + endPoint: .bottomTrailing + ) + + static let connectedGradient = LinearGradient( + colors: [Color(hex: "10B981"), Color(hex: "34D399")], + startPoint: .top, + endPoint: .bottom + ) + + static let disconnectedGradient = LinearGradient( + colors: [Color(hex: "EF4444"), Color(hex: "F97316")], + startPoint: .top, + endPoint: .bottom + ) + + // MARK: - Status Colors + static let connected = Color(hex: "10B981") + static let disconnected = Color(hex: "EF4444") + static let warning = Color(hex: "F59E0B") + static let info = Color(hex: "6366F1") + + // MARK: - Neutral Colors + static let background = Color(hex: "0F172A") + static let cardBackground = Color(hex: "1E293B") + static let cardBorder = Color(hex: "334155") + static let textPrimary = Color(hex: "F8FAFC") + static let textSecondary = Color(hex: "94A3B8") + static let textTertiary = Color(hex: "64748B") +} + +// MARK: - Color Extension +extension Color { + init(hex: String) { + let hex = hex.trimmingCharacters(in: CharacterSet.alphanumerics.inverted) + var int: UInt64 = 0 + Scanner(string: hex).scanHexInt64(&int) + let a, r, g, b: UInt64 + switch hex.count { + case 3: + (a, r, g, b) = (255, (int >> 8) * 17, (int >> 4 & 0xF) * 17, (int >> 0 & 0xF) * 17) + case 6: + (a, r, g, b) = (255, int >> 16, int >> 8 & 0xFF, int & 0xFF) + case 8: + (a, r, g, b) = (int >> 24, int >> 16 & 0xFF, int >> 8 & 0xFF, int & 0xFF) + default: + (a, r, g, b) = (255, 0, 0, 0) + } + self.init( + .sRGB, + red: Double(r) / 255, + green: Double(g) / 255, + blue: Double(b) / 255, + opacity: Double(a) / 255 + ) + } +} + +// MARK: - StatusBadge Color Mapping +extension StatusBadge { + static func color(for status: ConnectionStatus) -> Color { + switch status { + case .connected: return AppTheme.connected + case .disconnected: return AppTheme.disconnected + case .warning: return AppTheme.warning + } + } +} + +enum ConnectionStatus { + case connected, disconnected, warning +} diff --git a/Sources/Views.swift b/Sources/Views.swift index 32e2ba6..8b58b3d 100644 --- a/Sources/Views.swift +++ b/Sources/Views.swift @@ -16,8 +16,12 @@ struct GlassCard: View { .padding(16) .background( RoundedRectangle(cornerRadius: 16) - .fill(.ultraThinMaterial) - .shadow(color: .black.opacity(0.1), radius: 8, y: 4) + .fill(Color.black.opacity(0.3)) + .overlay( + RoundedRectangle(cornerRadius: 16) + .stroke(AppTheme.cardBorder.opacity(0.3), lineWidth: 1) + ) + .shadow(color: Color.black.opacity(0.3), radius: 12, y: 6) ) } } @@ -31,7 +35,7 @@ struct StatusBadge: View { var body: some View { HStack(spacing: 6) { Circle() - .fill(isActive ? activeColor : .gray) + .fill(isActive ? activeColor : AppTheme.textTertiary) .frame(width: 8, height: 8) .overlay( Circle() @@ -40,16 +44,17 @@ struct StatusBadge: View { .opacity(isActive ? 0 : 1) .animation(.easeOut(duration: 1).repeatForever(autoreverses: false), value: isActive) ) + .shadow(color: isActive ? activeColor.opacity(0.5) : .clear, radius: 4) Text(isActive ? activeText : inactiveText) - .font(.system(size: 13, weight: .semibold)) - .foregroundColor(isActive ? activeColor : .secondary) + .font(.system(size: 13, weight: .semibold, design: .rounded)) + .foregroundColor(isActive ? activeColor : AppTheme.textSecondary) } .padding(.horizontal, 12) .padding(.vertical, 6) .background( Capsule() - .fill(isActive ? activeColor.opacity(0.15) : Color.gray.opacity(0.1)) + .fill(isActive ? activeColor.opacity(0.15) : AppTheme.textTertiary.opacity(0.1)) ) } } @@ -69,17 +74,21 @@ struct SpeedGauge: View { Text(formatter(speed)) .font(.system(size: 16, weight: .bold, design: .monospaced)) - .foregroundColor(.primary) + .foregroundColor(AppTheme.textPrimary) Text(label) - .font(.system(size: 10)) - .foregroundColor(.secondary) + .font(.system(size: 10, weight: .medium, design: .rounded)) + .foregroundColor(AppTheme.textSecondary) } .frame(maxWidth: .infinity) .padding(.vertical, 12) .background( RoundedRectangle(cornerRadius: 12) .fill(color.opacity(0.1)) + .overlay( + RoundedRectangle(cornerRadius: 12) + .stroke(color.opacity(0.2), lineWidth: 1) + ) ) } } @@ -95,8 +104,8 @@ struct StatItem: View { .font(.system(size: 28, weight: .bold, design: .rounded)) .foregroundColor(color) Text(label) - .font(.system(size: 11)) - .foregroundColor(.secondary) + .font(.system(size: 11, weight: .medium, design: .rounded)) + .foregroundColor(AppTheme.textSecondary) } .frame(maxWidth: .infinity) } @@ -116,20 +125,20 @@ struct AutoRefreshIndicator: View { HStack(spacing: 8) { ZStack { Circle() - .stroke(Color.blue.opacity(0.2), lineWidth: 2) + .stroke(AppTheme.info.opacity(0.2), lineWidth: 2) .frame(width: 16, height: 16) Circle() .trim(from: 0, to: progress) - .stroke(Color.blue, style: StrokeStyle(lineWidth: 2, lineCap: .round)) + .stroke(AppTheme.info, style: StrokeStyle(lineWidth: 2, lineCap: .round)) .frame(width: 16, height: 16) .rotationEffect(.degrees(-90)) .animation(.linear(duration: 1), value: progress) } Text("\(l.nextUpdate): \(secondsRemaining)\(l.seconds)") - .font(.system(size: 11)) - .foregroundColor(.secondary) + .font(.system(size: 11, weight: .medium, design: .rounded)) + .foregroundColor(AppTheme.textSecondary) } } } @@ -142,63 +151,25 @@ struct DashboardView: View { @Environment(\.colorScheme) var colorScheme var body: some View { - VStack(spacing: 0) { - HStack { - Image(systemName: "shield.checkered") - .font(.system(size: 20)) - .foregroundStyle( - LinearGradient(colors: [.blue, .purple], startPoint: .topLeading, endPoint: .bottomTrailing) - ) - - Text(l.appTitle) - .font(.system(size: 18, weight: .bold)) - - Spacer() - - Button(action: { showSettings.toggle() }) { - Image(systemName: showSettings ? "xmark.circle.fill" : "gearshape.fill") - .font(.system(size: 16)) - .foregroundColor(.secondary) - } - .buttonStyle(.plain) - } - .padding(.horizontal, 20) - .padding(.top, 16) - .padding(.bottom, 12) - - Divider() - .padding(.horizontal, 20) - - ScrollView { - VStack(spacing: 16) { - if showSettings { - settingsSection - } else { - mainSection + ZStack { + AppTheme.background.ignoresSafeArea() + + VStack(spacing: 0) { + titleBar + Divider().background(AppTheme.cardBorder.opacity(0.3)) + ScrollView { + VStack(spacing: 16) { + if showSettings { + settingsSection + } else { + mainSection + } } + .padding(20) } - .padding(20) - } - - Divider() - - HStack { - AutoRefreshIndicator( - secondsRemaining: state.secondsUntilNextUpdate, - totalSeconds: Int(state.settings.checkInterval) - ) - - Spacer() - - Button(action: { showQuitAlert = true }) { - Label(l.quit, systemImage: "power") - .font(.system(size: 12, weight: .medium)) - } - .buttonStyle(.plain) - .foregroundColor(.red) + Divider().background(AppTheme.cardBorder.opacity(0.3)) + footerBar } - .padding(.horizontal, 20) - .padding(.vertical, 12) } .frame(width: 380, height: 620) .alert(isPresented: $showQuitAlert) { @@ -211,434 +182,477 @@ struct DashboardView: View { secondaryButton: .cancel(Text(l.cancelButton)) ) } - .background( - colorScheme == .dark - ? Color(NSColor.windowBackgroundColor) - : Color(NSColor.controlBackgroundColor) - ) } - var mainSection: some View { - VStack(spacing: 16) { - if !state.sleepManager.hasAccessibilityPermission { - GlassCard { - VStack(spacing: 10) { - HStack(spacing: 8) { - Image(systemName: "exclamationmark.triangle.fill") - .font(.system(size: 18)) - .foregroundColor(.orange) + var titleBar: some View { + HStack { + Image(systemName: "shield.checkered") + .font(.system(size: 20)) + .foregroundStyle(AppTheme.primaryGradient) - Text(l.accessibilityRequired) - .font(.system(size: 13, weight: .semibold)) - .foregroundColor(.orange) + Text(l.appTitle) + .font(.system(size: 18, weight: .bold, design: .rounded)) + .foregroundColor(AppTheme.textPrimary) - Spacer() - } - - Text(l.accessibilityDescription) - .font(.system(size: 12)) - .foregroundColor(.secondary) - .frame(maxWidth: .infinity, alignment: .leading) + Spacer() - Button(action: { - state.sleepManager.requestAccessibilityPermission() - }) { - HStack(spacing: 6) { - Image(systemName: "gear") - .font(.system(size: 12)) - Text(l.openSystemSettings) - .font(.system(size: 12, weight: .medium)) - } - .frame(maxWidth: .infinity) - .padding(.vertical, 8) - .background( - RoundedRectangle(cornerRadius: 8) - .fill(Color.orange.opacity(0.15)) - ) - .foregroundColor(.orange) - } - .buttonStyle(.plain) - } - } + Button(action: { showSettings.toggle() }) { + Image(systemName: showSettings ? "xmark.circle.fill" : "gearshape.fill") + .font(.system(size: 16)) + .foregroundColor(AppTheme.textSecondary) } + .buttonStyle(.plain) + } + .padding(.horizontal, 20) + .padding(.top, 16) + .padding(.bottom, 12) + } - GlassCard { - VStack(spacing: 12) { - HStack { - VStack(alignment: .leading, spacing: 4) { - Text(l.vpnStatus) - .font(.system(size: 12)) - .foregroundColor(.secondary) - - HStack(spacing: 8) { - StatusBadge( - isActive: state.vpnMonitor.isConnected, - activeText: l.connected, - inactiveText: l.notConnected, - activeColor: .green - ) - - if let uptime = state.stats.vpnUptime { - Text(StatisticsManager.formatDuration(uptime)) - .font(.system(size: 14, weight: .medium, design: .monospaced)) - .foregroundColor(.secondary) - } - } - } + var footerBar: some View { + HStack { + AutoRefreshIndicator( + secondsRemaining: state.secondsUntilNextUpdate, + totalSeconds: Int(state.settings.checkInterval) + ) - Spacer() + Spacer() - Image(systemName: state.vpnMonitor.isConnected ? "checkmark.shield.fill" : "shield.slash.fill") - .font(.system(size: 32)) - .foregroundStyle( - state.vpnMonitor.isConnected - ? LinearGradient(colors: [.green, .mint], startPoint: .top, endPoint: .bottom) - : LinearGradient(colors: [.red, .orange], startPoint: .top, endPoint: .bottom) - ) - } + Button(action: { showQuitAlert = true }) { + Label(l.quit, systemImage: "power") + .font(.system(size: 12, weight: .medium, design: .rounded)) + } + .buttonStyle(.plain) + .foregroundColor(AppTheme.disconnected) + } + .padding(.horizontal, 20) + .padding(.vertical, 12) + } - if state.vpnMonitor.isConnected { - Divider() + var mainSection: some View { + VStack(spacing: 16) { + if !state.sleepManager.hasAccessibilityPermission { + accessibilityWarning + } + vpnStatusCard + sleepProtectionCard + networkTrafficCard + dailyStatsCard + } + } - if state.vpnMonitor.allInterfaces.count > 1 { - Text(l.vpnInterfaces) - .font(.system(size: 10)) - .foregroundColor(.secondary) - .frame(maxWidth: .infinity, alignment: .leading) - } + var accessibilityWarning: some View { + GlassCard { + VStack(spacing: 10) { + HStack(spacing: 8) { + Image(systemName: "exclamationmark.triangle.fill") + .font(.system(size: 18)) + .foregroundColor(AppTheme.warning) - ForEach(state.vpnMonitor.allInterfaces) { iface in - HStack { - VStack(alignment: .leading, spacing: 2) { - Text("Interface") - .font(.system(size: 10)) - .foregroundColor(.secondary) - Text(iface.name) - .font(.system(size: 13, weight: .medium, design: .monospaced)) - } - - Spacer() - - VStack(alignment: .trailing, spacing: 2) { - Text("IP") - .font(.system(size: 10)) - .foregroundColor(.secondary) - Text(iface.ip) - .font(.system(size: 13, weight: .medium, design: .monospaced)) - } - } + Text(l.accessibilityRequired) + .font(.system(size: 13, weight: .semibold, design: .rounded)) + .foregroundColor(AppTheme.warning) - if iface.id != state.vpnMonitor.allInterfaces.last?.id { - Divider().opacity(0.5) - } - } + Spacer() + } + + Text(l.accessibilityDescription) + .font(.system(size: 12, design: .rounded)) + .foregroundColor(AppTheme.textSecondary) + .frame(maxWidth: .infinity, alignment: .leading) + + Button(action: { + state.sleepManager.requestAccessibilityPermission() + }) { + HStack(spacing: 6) { + Image(systemName: "gear") + .font(.system(size: 12)) + Text(l.openSystemSettings) + .font(.system(size: 12, weight: .medium, design: .rounded)) } + .frame(maxWidth: .infinity) + .padding(.vertical, 8) + .background( + RoundedRectangle(cornerRadius: 8) + .fill(AppTheme.warning.opacity(0.15)) + ) + .foregroundColor(AppTheme.warning) } + .buttonStyle(.plain) } + } + } - GlassCard { + var vpnStatusCard: some View { + GlassCard { + VStack(spacing: 12) { HStack { - VStack(alignment: .leading, spacing: 6) { - Text(l.sleepProtection) - .font(.system(size: 12)) - .foregroundColor(.secondary) + VStack(alignment: .leading, spacing: 4) { + Text(l.vpnStatus) + .font(.system(size: 12, design: .rounded)) + .foregroundColor(AppTheme.textSecondary) HStack(spacing: 8) { StatusBadge( - isActive: state.sleepManager.isPreventingSleep, - activeText: l.active, - inactiveText: state.isEnabled ? l.waiting : l.disabled, - activeColor: .purple + isActive: state.vpnMonitor.isConnected, + activeText: l.connected, + inactiveText: l.notConnected, + activeColor: AppTheme.connected ) - if let duration = state.stats.sleepPreventionDuration { - Text("\(state.stats.formatDurationLong(duration)) \(l.protectedFor)") - .font(.system(size: 12)) - .foregroundColor(.secondary) + if let uptime = state.stats.vpnUptime { + Text(StatisticsManager.formatDuration(uptime)) + .font(.system(size: 14, weight: .medium, design: .monospaced)) + .foregroundColor(AppTheme.textSecondary) } } } Spacer() - Toggle("", isOn: Binding( - get: { state.isEnabled }, - set: { _ in state.toggle() } - )) - .toggleStyle(.switch) - .labelsHidden() + Image(systemName: state.vpnMonitor.isConnected ? "checkmark.shield.fill" : "shield.slash.fill") + .font(.system(size: 32)) + .foregroundStyle( + state.vpnMonitor.isConnected ? AppTheme.connectedGradient : AppTheme.disconnectedGradient + ) } - } - GlassCard { - VStack(spacing: 12) { - HStack { - Text(l.networkTraffic) - .font(.system(size: 12)) - .foregroundColor(.secondary) - Spacer() - if state.vpnMonitor.isConnected { - Text("\(l.total): \(state.vpnMonitor.networkStats.formatBytes(state.vpnMonitor.networkStats.sessionDownload + state.vpnMonitor.networkStats.sessionUpload))") - .font(.system(size: 10)) - .foregroundColor(.secondary) - } + if state.vpnMonitor.isConnected { + Divider().background(AppTheme.cardBorder.opacity(0.3)) + + if state.vpnMonitor.allInterfaces.count > 1 { + Text(l.vpnInterfaces) + .font(.system(size: 10, design: .rounded)) + .foregroundColor(AppTheme.textSecondary) + .frame(maxWidth: .infinity, alignment: .leading) } - HStack(spacing: 12) { - SpeedGauge( - label: l.download, - speed: state.vpnMonitor.networkStats.downloadSpeed, - icon: "arrow.down.circle.fill", - color: .blue, - formatter: state.vpnMonitor.networkStats.formatSpeed - ) + ForEach(state.vpnMonitor.allInterfaces) { iface in + HStack { + VStack(alignment: .leading, spacing: 2) { + Text("Interface") + .font(.system(size: 10, design: .rounded)) + .foregroundColor(AppTheme.textSecondary) + Text(iface.name) + .font(.system(size: 13, weight: .medium, design: .monospaced)) + .foregroundColor(AppTheme.textPrimary) + } - SpeedGauge( - label: l.upload, - speed: state.vpnMonitor.networkStats.uploadSpeed, - icon: "arrow.up.circle.fill", - color: .orange, - formatter: state.vpnMonitor.networkStats.formatSpeed - ) - } + Spacer() - if !state.vpnMonitor.isConnected { - Text(l.vpnNotConnected) - .font(.system(size: 11)) - .foregroundColor(.secondary) - .padding(.top, 4) + VStack(alignment: .trailing, spacing: 2) { + Text("IP") + .font(.system(size: 10, design: .rounded)) + .foregroundColor(AppTheme.textSecondary) + Text(iface.ip) + .font(.system(size: 13, weight: .medium, design: .monospaced)) + .foregroundColor(AppTheme.textPrimary) + } + } + + if iface.id != state.vpnMonitor.allInterfaces.last?.id { + Divider().background(AppTheme.cardBorder.opacity(0.3)) + } } } } + } + } - GlassCard { - VStack(spacing: 12) { - Text(l.todayStats) - .font(.system(size: 12)) - .foregroundColor(.secondary) - .frame(maxWidth: .infinity, alignment: .leading) - - HStack(spacing: 0) { - StatItem( - value: "\(state.stats.todayConnectionCount)", - label: l.connections, - color: .green + var sleepProtectionCard: some View { + GlassCard { + HStack { + VStack(alignment: .leading, spacing: 6) { + Text(l.sleepProtection) + .font(.system(size: 12, design: .rounded)) + .foregroundColor(AppTheme.textSecondary) + + HStack(spacing: 8) { + StatusBadge( + isActive: state.sleepManager.isPreventingSleep, + activeText: l.active, + inactiveText: state.isEnabled ? l.waiting : l.disabled, + activeColor: Color(hex: "8B5CF6") ) - Divider() - .frame(height: 40) - - StatItem( - value: "\(state.stats.todayDisconnectionCount)", - label: l.disconnections, - color: .red - ) + if let duration = state.stats.sleepPreventionDuration { + Text("\(state.stats.formatDurationLong(duration)) \(l.protectedFor)") + .font(.system(size: 12, design: .rounded)) + .foregroundColor(AppTheme.textSecondary) + } } } - } + Spacer() + + Toggle("", isOn: Binding( + get: { state.isEnabled }, + set: { _ in state.toggle() } + )) + .toggleStyle(.switch) + .labelsHidden() + } } } - var settingsSection: some View { - VStack(spacing: 16) { - GlassCard { - VStack(alignment: .leading, spacing: 12) { - Text(l.language) - .font(.system(size: 13, weight: .semibold)) - - Picker("", selection: $l.currentLanguage) { - ForEach(Language.allCases, id: \.self) { lang in - Text(lang.displayName).tag(lang) - } + var networkTrafficCard: some View { + GlassCard { + VStack(spacing: 12) { + HStack { + Text(l.networkTraffic) + .font(.system(size: 12, design: .rounded)) + .foregroundColor(AppTheme.textSecondary) + Spacer() + if state.vpnMonitor.isConnected { + Text("\(l.total): \(state.vpnMonitor.networkStats.formatBytes(state.vpnMonitor.networkStats.sessionDownload + state.vpnMonitor.networkStats.sessionUpload))") + .font(.system(size: 10, design: .rounded)) + .foregroundColor(AppTheme.textSecondary) } - .pickerStyle(.segmented) } - } - GlassCard { - VStack(alignment: .leading, spacing: 16) { - Text(l.checkInterval) - .font(.system(size: 13, weight: .semibold)) + HStack(spacing: 12) { + SpeedGauge( + label: l.download, + speed: state.vpnMonitor.networkStats.downloadSpeed, + icon: "arrow.down.circle.fill", + color: AppTheme.info, + formatter: state.vpnMonitor.networkStats.formatSpeed + ) - Picker("", selection: Binding( - get: { state.settings.checkInterval }, - set: { - state.settings.checkInterval = $0 - state.startMonitoring() - } - )) { - Text("5 \(l.seconds)").tag(5.0) - Text("10 \(l.seconds)").tag(10.0) - Text("30 \(l.seconds)").tag(30.0) - Text("60 \(l.seconds)").tag(60.0) - } - .pickerStyle(.segmented) + SpeedGauge( + label: l.upload, + speed: state.vpnMonitor.networkStats.uploadSpeed, + icon: "arrow.up.circle.fill", + color: Color(hex: "F97316"), + formatter: state.vpnMonitor.networkStats.formatSpeed + ) } - } - GlassCard { - VStack(alignment: .leading, spacing: 12) { - Text(l.notifications) - .font(.system(size: 13, weight: .semibold)) + if !state.vpnMonitor.isConnected { + Text(l.vpnNotConnected) + .font(.system(size: 11, design: .rounded)) + .foregroundColor(AppTheme.textSecondary) + .padding(.top, 4) + } + } + } + } - Toggle(l.showNotifications, isOn: $state.settings.notificationsEnabled) - .font(.system(size: 13)) + var dailyStatsCard: some View { + GlassCard { + VStack(spacing: 12) { + Text(l.todayStats) + .font(.system(size: 12, design: .rounded)) + .foregroundColor(AppTheme.textSecondary) + .frame(maxWidth: .infinity, alignment: .leading) + + HStack(spacing: 0) { + StatItem( + value: "\(state.stats.todayConnectionCount)", + label: l.connections, + color: AppTheme.connected + ) - Toggle(l.soundAlert, isOn: $state.settings.soundEnabled) - .font(.system(size: 13)) + Divider() + .frame(height: 40) + .background(AppTheme.cardBorder) - Toggle(l.showUptimeInMenuBar, isOn: $state.settings.showUptimeInMenuBar) - .font(.system(size: 13)) + StatItem( + value: "\(state.stats.todayDisconnectionCount)", + label: l.disconnections, + color: AppTheme.disconnected + ) } } + } + } - if #available(macOS 13.0, *) { - GlassCard { - VStack(alignment: .leading, spacing: 8) { - Toggle(isOn: Binding( - get: { - SMAppService.mainApp.status == .enabled - }, - set: { newValue in - do { - if newValue { - try SMAppService.mainApp.register() - LogManager.shared.log("Launch at login enabled", type: "APP") - } else { - try SMAppService.mainApp.unregister() - LogManager.shared.log("Launch at login disabled", type: "APP") - } - } catch { - LogManager.shared.log("Launch at login error: \(error.localizedDescription)", type: "ERROR") - } - } - )) { - Text(l.launchAtLogin) - .font(.system(size: 13, weight: .semibold)) - } + var settingsSection: some View { + VStack(spacing: 16) { + languageCard + checkIntervalCard + notificationsCard + launchAtLoginCard + logFileCard + aboutCard + } + } - Text(l.launchAtLoginNote) - .font(.system(size: 11)) - .foregroundColor(.secondary) + var languageCard: some View { + GlassCard { + VStack(alignment: .leading, spacing: 12) { + Text(l.language) + .font(.system(size: 13, weight: .semibold, design: .rounded)) + + Picker("", selection: $l.currentLanguage) { + ForEach(Language.allCases, id: \.self) { lang in + Text(lang.displayName).tag(lang) } } + .pickerStyle(.segmented) } + } + } - GlassCard { - VStack(alignment: .leading, spacing: 12) { - Text(l.logFile) - .font(.system(size: 13, weight: .semibold)) - - Text(LogManager.shared.logFilePath) - .font(.system(size: 10, design: .monospaced)) - .foregroundColor(.secondary) - .lineLimit(2) - - Button(action: { - NSWorkspace.shared.selectFile(LogManager.shared.logFilePath, inFileViewerRootedAtPath: "") - }) { - Label(l.openInFinder, systemImage: "folder") - .font(.system(size: 12)) + var checkIntervalCard: some View { + GlassCard { + VStack(alignment: .leading, spacing: 16) { + Text(l.checkInterval) + .font(.system(size: 13, weight: .semibold, design: .rounded)) + + Picker("", selection: Binding( + get: { state.settings.checkInterval }, + set: { + state.settings.checkInterval = $0 + state.startMonitoring() } - .buttonStyle(.plain) - .foregroundColor(.blue) + )) { + Text("5 \(l.seconds)").tag(5.0) + Text("10 \(l.seconds)").tag(10.0) + Text("30 \(l.seconds)").tag(30.0) + Text("60 \(l.seconds)").tag(60.0) } + .pickerStyle(.segmented) } + } + } - GlassCard { - VStack(spacing: 16) { - VStack(spacing: 8) { - Image(systemName: "shield.checkered") - .font(.system(size: 40)) - .foregroundStyle( - LinearGradient(colors: [.blue, .purple], startPoint: .topLeading, endPoint: .bottomTrailing) - ) - - Text("VPN Keep Awake") - .font(.system(size: 16, weight: .bold)) + var notificationsCard: some View { + GlassCard { + VStack(alignment: .leading, spacing: 12) { + Text(l.notifications) + .font(.system(size: 13, weight: .semibold, design: .rounded)) - Text("\(l.version) \(AppConstants.shortVersion)") - .font(.system(size: 12)) - .foregroundColor(.secondary) - } + Toggle(l.showNotifications, isOn: $state.settings.notificationsEnabled) + .font(.system(size: 13, design: .rounded)) - Divider() + Toggle(l.soundAlert, isOn: $state.settings.soundEnabled) + .font(.system(size: 13, design: .rounded)) - VStack(spacing: 8) { - HStack { - Text(l.developer) - .font(.system(size: 12)) - .foregroundColor(.secondary) - Spacer() - Text("Softviser") - .font(.system(size: 12, weight: .semibold)) - .foregroundColor(.primary) - } + Toggle(l.showUptimeInMenuBar, isOn: $state.settings.showUptimeInMenuBar) + .font(.system(size: 13, design: .rounded)) + } + } + } - HStack { - Text(l.website) - .font(.system(size: 12)) - .foregroundColor(.secondary) - Spacer() - Button(action: { - guard let url = AppConstants.websiteURL else { return } - NSWorkspace.shared.open(url) - }) { - Text(AppConstants.websiteDisplayText) - .font(.system(size: 12, weight: .medium)) + var launchAtLoginCard: some View { + GlassCard { + VStack(alignment: .leading, spacing: 8) { + Toggle(isOn: Binding( + get: { + SMAppService.mainApp.status == .enabled + }, + set: { newValue in + do { + if newValue { + try SMAppService.mainApp.register() + LogManager.shared.log("Launch at login enabled", type: "APP") + } else { + try SMAppService.mainApp.unregister() + LogManager.shared.log("Launch at login disabled", type: "APP") } - .buttonStyle(.plain) - .foregroundColor(.blue) + } catch { + LogManager.shared.log("Launch at login error: \(error.localizedDescription)", type: "ERROR") } + } + )) { + Text(l.launchAtLogin) + .font(.system(size: 13, weight: .semibold, design: .rounded)) + } - HStack { - Text("GitHub") - .font(.system(size: 12)) - .foregroundColor(.secondary) - Spacer() - Button(action: { - guard let url = AppConstants.githubURL else { return } - NSWorkspace.shared.open(url) - }) { - Text(AppConstants.githubDisplayText) - .font(.system(size: 12, weight: .medium)) - } - .buttonStyle(.plain) - .foregroundColor(.blue) - } + Text(l.launchAtLoginNote) + .font(.system(size: 11, design: .rounded)) + .foregroundColor(AppTheme.textSecondary) + } + } + } - HStack { - Text(l.license) - .font(.system(size: 12)) - .foregroundColor(.secondary) - Spacer() - Text("MIT License") - .font(.system(size: 12)) - .foregroundColor(.primary) - } - } + var logFileCard: some View { + GlassCard { + VStack(alignment: .leading, spacing: 12) { + Text(l.logFile) + .font(.system(size: 13, weight: .semibold, design: .rounded)) + + Text(LogManager.shared.logFilePath) + .font(.system(size: 10, design: .monospaced)) + .foregroundColor(AppTheme.textSecondary) + .lineLimit(2) + + Button(action: { + NSWorkspace.shared.selectFile(LogManager.shared.logFilePath, inFileViewerRootedAtPath: "") + }) { + Label(l.openInFinder, systemImage: "folder") + .font(.system(size: 12, design: .rounded)) + } + .buttonStyle(.plain) + .foregroundColor(AppTheme.info) + } + } + } - Divider() + var aboutCard: some View { + GlassCard { + VStack(spacing: 16) { + VStack(spacing: 8) { + Image(systemName: "shield.checkered") + .font(.system(size: 40)) + .foregroundStyle(AppTheme.primaryGradient) + + Text("VPN Keep Awake") + .font(.system(size: 16, weight: .bold, design: .rounded)) + .foregroundColor(AppTheme.textPrimary) + + Text("\(l.version) \(AppConstants.shortVersion)") + .font(.system(size: 12, design: .rounded)) + .foregroundColor(AppTheme.textSecondary) + } - VStack(spacing: 4) { - HStack(spacing: 4) { - Image(systemName: "swift") - .font(.system(size: 12)) - .foregroundColor(.orange) - Text(l.madeWith) - .font(.system(size: 11)) - .foregroundColor(.secondary) - } + Divider().background(AppTheme.cardBorder) - Text(l.freeAndOpenSource) - .font(.system(size: 10)) - .foregroundColor(.secondary) + VStack(spacing: 8) { + aboutRow(label: l.developer, value: "Softviser", isLink: false) + aboutRow(label: l.website, value: AppConstants.websiteDisplayText, url: AppConstants.websiteURL) + aboutRow(label: "GitHub", value: AppConstants.githubDisplayText, url: AppConstants.githubURL) + aboutRow(label: l.license, value: "MIT License", isLink: false) + } + + Divider().background(AppTheme.cardBorder) + + VStack(spacing: 4) { + HStack(spacing: 4) { + Image(systemName: "swift") + .font(.system(size: 12)) + .foregroundColor(Color(hex: "F97316")) + Text(l.madeWith) + .font(.system(size: 11, design: .rounded)) + .foregroundColor(AppTheme.textSecondary) } + + Text(l.freeAndOpenSource) + .font(.system(size: 10, design: .rounded)) + .foregroundColor(AppTheme.textSecondary) } } + } + } + func aboutRow(label: String, value: String, isLink: Bool = false, url: URL? = nil) -> some View { + HStack { + Text(label) + .font(.system(size: 12, design: .rounded)) + .foregroundColor(AppTheme.textSecondary) Spacer() + if isLink, let url = url { + Button(action: { NSWorkspace.shared.open(url) }) { + Text(value) + .font(.system(size: 12, weight: .medium, design: .rounded)) + } + .buttonStyle(.plain) + .foregroundColor(AppTheme.info) + } else { + Text(value) + .font(.system(size: 12, weight: .semibold, design: .rounded)) + .foregroundColor(AppTheme.textPrimary) + } } } } diff --git a/Sources/main.swift b/Sources/main.swift index 565d1fd..bdc884b 100644 --- a/Sources/main.swift +++ b/Sources/main.swift @@ -618,6 +618,8 @@ class AppState: ObservableObject { secondsUntilNextUpdate = Int(settings.checkInterval) + sleepManager.checkAccessibilityPermission() + checkTimer = Timer.scheduledTimer(withTimeInterval: settings.checkInterval, repeats: true) { [weak self] _ in self?.check() self?.secondsUntilNextUpdate = Int(self?.settings.checkInterval ?? 10)