Skip to content

Commit 65d499a

Browse files
committed
Refactor: fix critical bugs, improve code quality and architecture
Critical fixes: - Add missing shortestSessionDuration property to VPNStatistics - Fix use-after-free in HotkeyManager cleanup with isRetainedForEventHandler flag - Fix potential recursion in VPNManager.shared using NotificationCenter - Fix thread safety issue in StatusBarController deinit with cleanup method Architecture improvements: - Split SettingsWindowController into separate view components (GeneralSettingsView, HotkeySettingsView, AboutSettingsView) - Create centralized ImageCache utility to eliminate image caching duplication - Optimize ConnectionHistoryManager with in-memory cache to reduce UserDefaults I/O Code quality: - Replace unsafeBitCast with safe HandlerWrapper class in VPNConfigurationLoader - Refactor connectWithRetry: extract attemptConnection, createSessionAndConnect, scheduleRetry, handleConnectionSuccess, handleConnectionError methods - Eliminate notification content duplication in NotificationManager with buildNotificationContent helper - Replace deprecated lockFocus/unlockFocus with NSImage(size:flipped:drawingHandler:) - Fix force unwrap URLs in AppConstants with guard let and fatalError - Remove useless expression _ = updateInterval - Remove unused vpnConnectionsDidChange notification - Simplify NSApplication.isRunning check to NSApp != nil
1 parent eb5bd4f commit 65d499a

17 files changed

Lines changed: 1199 additions & 967 deletions

Sources/VPNBarApp/AppConstants.swift

Lines changed: 12 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -37,8 +37,18 @@ enum AppConstants {
3737

3838
/// URLs used in the application.
3939
enum URLs {
40-
static let repository = URL(string: "https://github.com/borzov/vpn-bar")!
41-
static let networkPreferences = URL(string: "x-apple.systempreferences:com.apple.Network-Settings.extension")!
40+
static let repository: URL = {
41+
guard let url = URL(string: "https://github.com/borzov/vpn-bar") else {
42+
fatalError("Invalid repository URL")
43+
}
44+
return url
45+
}()
46+
static let networkPreferences: URL = {
47+
guard let url = URL(string: "x-apple.systempreferences:com.apple.Network-Settings.extension") else {
48+
fatalError("Invalid network preferences URL")
49+
}
50+
return url
51+
}()
4252
}
4353
}
4454

Sources/VPNBarApp/AppDelegate.swift

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -45,6 +45,7 @@ class AppDelegate: NSObject, NSApplicationDelegate {
4545
HotkeyManager.shared.cleanup()
4646
Task { @MainActor in
4747
VPNManager.shared.cleanup()
48+
statusBarController?.cleanup()
4849
}
4950
}
5051

Sources/VPNBarApp/Extensions/Notification+Extensions.swift

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -9,8 +9,8 @@ extension Notification.Name {
99

1010
/// Notification about connection name display change.
1111
static let showConnectionNameDidChange = Notification.Name("ShowConnectionNameDidChange")
12-
13-
/// Notification about VPN connections list change.
14-
static let vpnConnectionsDidChange = Notification.Name("VPNConnectionsDidChange")
12+
13+
/// Notification about VPN connection status update.
14+
static let vpnConnectionStatusDidUpdate = Notification.Name("VPNConnectionStatusDidUpdate")
1515
}
1616

Sources/VPNBarApp/HotkeyManager.swift

Lines changed: 8 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,9 @@ class HotkeyManager: HotkeyManagerProtocol {
1717
/// Set to false in deinit and cleanup to prevent callback execution
1818
/// after manager memory is deallocated. Checked before each callback invocation.
1919
private var isValid = true
20+
/// Flag to track if self was retained for event handler.
21+
/// Used to properly balance retain/release calls.
22+
private var isRetainedForEventHandler = false
2023

2124
private init() {
2225
setupEventHandler()
@@ -38,6 +41,7 @@ class HotkeyManager: HotkeyManagerProtocol {
3841

3942
// Use passRetained to ensure manager stays alive during event handler lifetime
4043
let userData = Unmanaged.passRetained(self).toOpaque()
44+
isRetainedForEventHandler = true
4145

4246
let eventHandlerUPP: EventHandlerUPP = { (nextHandler, theEvent, userData) -> OSStatus in
4347
guard let userData = userData else {
@@ -150,7 +154,10 @@ class HotkeyManager: HotkeyManagerProtocol {
150154

151155
// Release the retained reference from passRetained
152156
// This balances the retain from setupEventHandler
153-
Unmanaged.passUnretained(self).release()
157+
if isRetainedForEventHandler {
158+
Unmanaged.passUnretained(self).release()
159+
isRetainedForEventHandler = false
160+
}
154161

155162
eventHandler = nil
156163
}

Sources/VPNBarApp/Managers/VPNConfigurationLoader.swift

Lines changed: 12 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,15 @@ import Foundation
22
import Darwin
33
import os.log
44

5+
/// Wrapper for handler block to safely pass to Objective-C runtime.
6+
private class HandlerWrapper: NSObject {
7+
let handler: @convention(block) (NSArray?, NSError?) -> Void
8+
9+
init(_ handler: @escaping @convention(block) (NSArray?, NSError?) -> Void) {
10+
self.handler = handler
11+
}
12+
}
13+
514
/// Loads VPN configurations from the system.
615
@MainActor
716
final class VPNConfigurationLoader: VPNConfigurationLoaderProtocol {
@@ -93,8 +102,9 @@ final class VPNConfigurationLoader: VPNConfigurationLoaderProtocol {
93102
}
94103
}
95104

96-
// Use safer approach with NSInvocation instead of unsafeBitCast
97-
let handlerObject = unsafeBitCast(handler, to: AnyObject.self)
105+
// Use HandlerWrapper to safely pass block to Objective-C runtime
106+
let handlerWrapper = HandlerWrapper(handler)
107+
let handlerObject = handlerWrapper as AnyObject
98108

99109
// Validate method signature before calling
100110
let methodSignature = manager.method(for: selector)

Sources/VPNBarApp/MenuController.swift

Lines changed: 6 additions & 34 deletions
Original file line numberDiff line numberDiff line change
@@ -7,10 +7,6 @@ class MenuController {
77
static let shared = MenuController()
88

99
// MARK: - Cached Images
10-
private static var cachedActiveImage: NSImage?
11-
private static var cachedInactiveImage: NSImage?
12-
private static var cachedErrorImage: NSImage?
13-
private static var cachedDisconnectAllImage: NSImage?
1410

1511
private var menu: NSMenu?
1612
private var statusItem: NSStatusItem?
@@ -53,8 +49,8 @@ class MenuController {
5349
private func buildMenu() {
5450
let newMenu = NSMenu()
5551
// Set appearance only if NSApplication is available (not in test environment)
56-
if let app = NSApplication.shared as NSApplication?, app.isRunning {
57-
newMenu.appearance = app.effectiveAppearance
52+
if NSApp != nil {
53+
newMenu.appearance = NSApp.effectiveAppearance
5854
}
5955

6056
if let error = vpnManager.loadingError {
@@ -203,42 +199,18 @@ class MenuController {
203199
// MARK: - Cached Image Helpers
204200

205201
private static func activeImage() -> NSImage? {
206-
if let cached = cachedActiveImage { return cached }
207-
guard let image = NSImage(systemSymbolName: "checkmark.circle.fill", accessibilityDescription: nil) else {
208-
return nil
209-
}
210-
image.isTemplate = true
211-
cachedActiveImage = image
212-
return image
202+
return ImageCache.shared.image(systemSymbolName: "checkmark.circle.fill")
213203
}
214204

215205
private static func inactiveImage() -> NSImage? {
216-
if let cached = cachedInactiveImage { return cached }
217-
guard let image = NSImage(systemSymbolName: "circle", accessibilityDescription: nil) else {
218-
return nil
219-
}
220-
image.isTemplate = true
221-
cachedInactiveImage = image
222-
return image
206+
return ImageCache.shared.image(systemSymbolName: "circle")
223207
}
224208

225209
private static func errorImage() -> NSImage? {
226-
if let cached = cachedErrorImage { return cached }
227-
guard let image = NSImage(systemSymbolName: "exclamationmark.triangle", accessibilityDescription: nil) else {
228-
return nil
229-
}
230-
image.isTemplate = true
231-
cachedErrorImage = image
232-
return image
210+
return ImageCache.shared.image(systemSymbolName: "exclamationmark.triangle")
233211
}
234212

235213
private static func disconnectAllImage() -> NSImage? {
236-
if let cached = cachedDisconnectAllImage { return cached }
237-
guard let image = NSImage(systemSymbolName: "xmark.circle", accessibilityDescription: nil) else {
238-
return nil
239-
}
240-
image.isTemplate = true
241-
cachedDisconnectAllImage = image
242-
return image
214+
return ImageCache.shared.image(systemSymbolName: "xmark.circle")
243215
}
244216
}

Sources/VPNBarApp/Models/ConnectionHistory.swift

Lines changed: 47 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -23,18 +23,48 @@ final class ConnectionHistoryManager {
2323
private let historyKey = "connectionHistory"
2424
private let maxHistoryEntries = 100
2525

26-
private init() {}
26+
/// In-memory cache to avoid frequent UserDefaults I/O.
27+
private var cachedHistory: [ConnectionHistoryEntry]?
28+
private var isDirty = false
29+
30+
private init() {
31+
loadHistoryFromUserDefaults()
32+
}
33+
34+
/// Loads history from UserDefaults into cache.
35+
private func loadHistoryFromUserDefaults() {
36+
guard let data = userDefaults.data(forKey: historyKey),
37+
let entries = try? JSONDecoder().decode([ConnectionHistoryEntry].self, from: data) else {
38+
cachedHistory = []
39+
return
40+
}
41+
cachedHistory = entries.sorted { $0.timestamp > $1.timestamp }
42+
}
43+
44+
/// Saves history from cache to UserDefaults.
45+
private func saveHistoryToUserDefaults() {
46+
guard isDirty, let history = cachedHistory else { return }
47+
48+
if let data = try? JSONEncoder().encode(history) {
49+
userDefaults.set(data, forKey: historyKey)
50+
isDirty = false
51+
}
52+
}
2753

2854
/// Gets connection history.
2955
/// - Parameter limit: Maximum number of entries (default is 50).
3056
/// - Returns: Array of history entries sorted by time (newest first).
3157
func getHistory(limit: Int = 50) -> [ConnectionHistoryEntry] {
32-
guard let data = userDefaults.data(forKey: historyKey),
33-
let entries = try? JSONDecoder().decode([ConnectionHistoryEntry].self, from: data) else {
58+
// Ensure cache is loaded
59+
if cachedHistory == nil {
60+
loadHistoryFromUserDefaults()
61+
}
62+
63+
guard let history = cachedHistory else {
3464
return []
3565
}
3666

37-
return Array(entries.sorted { $0.timestamp > $1.timestamp }.prefix(limit))
67+
return Array(history.prefix(limit))
3868
}
3969

4070
/// Adds entry to connection history.
@@ -51,31 +81,32 @@ final class ConnectionHistoryManager {
5181
action: action
5282
)
5383

54-
// Load existing history without sorting (raw data)
55-
guard let data = userDefaults.data(forKey: historyKey),
56-
var history = try? JSONDecoder().decode([ConnectionHistoryEntry].self, from: data) else {
57-
// No existing history, create new with single entry
58-
if let data = try? JSONEncoder().encode([entry]) {
59-
userDefaults.set(data, forKey: historyKey)
60-
}
61-
return
84+
// Ensure cache is loaded
85+
if cachedHistory == nil {
86+
loadHistoryFromUserDefaults()
6287
}
6388

89+
var history = cachedHistory ?? []
90+
6491
// Insert new entry at the beginning (most recent)
6592
history.insert(entry, at: 0)
6693

67-
// Trim to max entries if needed (no sorting required, already in order)
94+
// Trim to max entries if needed
6895
if history.count > maxHistoryEntries {
6996
history = Array(history.prefix(maxHistoryEntries))
7097
}
7198

72-
if let data = try? JSONEncoder().encode(history) {
73-
userDefaults.set(data, forKey: historyKey)
74-
}
99+
cachedHistory = history
100+
isDirty = true
101+
102+
// Save immediately for data persistence
103+
saveHistoryToUserDefaults()
75104
}
76105

77106
/// Clears connection history.
78107
func clearHistory() {
108+
cachedHistory = []
109+
isDirty = true
79110
userDefaults.removeObject(forKey: historyKey)
80111
}
81112
}

Sources/VPNBarApp/Models/VPNStatistics.swift

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@ struct VPNStatistics: Codable, Equatable {
88
var lastConnectionDate: Date?
99
var lastDisconnectionDate: Date?
1010
var longestSessionDuration: TimeInterval = 0
11+
var shortestSessionDuration: TimeInterval? = nil
1112
/// Average session duration in seconds.
1213
var averageSessionDuration: TimeInterval {
1314
guard totalConnections > 0 else { return 0 }

0 commit comments

Comments
 (0)