Skip to content

Commit f14d98b

Browse files
refactor: optimize memory usage with caching in AppState, PortForwardManager, TunnelManager, and MenuBarView
1 parent 2937408 commit f14d98b

File tree

5 files changed

+118
-44
lines changed

5 files changed

+118
-44
lines changed

platforms/macos/Sources/AppState.swift

Lines changed: 43 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -77,8 +77,51 @@ final class AppState {
7777
return portForwardManager.connections.first { $0.id == id }
7878
}
7979

80+
// MARK: - Cached Filtered Ports (Memory Optimization)
81+
82+
/// Cache for filtered ports to avoid repeated allocations
83+
@ObservationIgnored private var _cachedFilteredPorts: [PortInfo] = []
84+
@ObservationIgnored private var _filterCacheKey: FilterCacheKey?
85+
86+
/// Cache key to detect when recalculation is needed
87+
private struct FilterCacheKey: Equatable {
88+
let portsCount: Int
89+
let portsHash: Int
90+
let sidebarItem: SidebarItem
91+
let filterActive: Bool
92+
let filterText: String
93+
let hideSystem: Bool
94+
let favoritesCount: Int
95+
let watchedCount: Int
96+
}
97+
8098
/// Returns filtered ports based on sidebar selection and active filters.
99+
/// Uses caching to avoid repeated array allocations on each access.
81100
var filteredPorts: [PortInfo] {
101+
let currentKey = FilterCacheKey(
102+
portsCount: ports.count,
103+
portsHash: ports.isEmpty ? 0 : ports[0].hashValue ^ ports.count,
104+
sidebarItem: selectedSidebarItem,
105+
filterActive: filter.isActive,
106+
filterText: filter.searchText,
107+
hideSystem: Defaults[.hideSystemProcesses],
108+
favoritesCount: favorites.count,
109+
watchedCount: watchedPorts.count
110+
)
111+
112+
// Return cached value if nothing changed
113+
if currentKey == _filterCacheKey {
114+
return _cachedFilteredPorts
115+
}
116+
117+
// Recompute and cache
118+
_cachedFilteredPorts = computeFilteredPorts()
119+
_filterCacheKey = currentKey
120+
return _cachedFilteredPorts
121+
}
122+
123+
/// Computes filtered ports (called only when cache is invalidated)
124+
private func computeFilteredPorts() -> [PortInfo] {
82125
if case .settings = selectedSidebarItem { return [] }
83126

84127
var result: [PortInfo]

platforms/macos/Sources/Managers/PortForwardManager.swift

Lines changed: 9 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -145,19 +145,21 @@ final class PortForwardManager {
145145
state.portForwardTask = Task { [weak self, weak state] in
146146
guard let self = self, let state = state else { return }
147147

148-
// Set log handler with proper weak capture
148+
// Set log handler with proper weak capture (including inner Task)
149149
let logHandler: LogHandler = { [weak state] message, type, isError in
150150
guard let state = state else { return }
151-
Task { @MainActor in
151+
Task { @MainActor [weak state] in
152+
guard let state = state else { return }
152153
state.appendLog(message, type: type, isError: isError)
153154
}
154155
}
155156
await self.processManager.setLogHandler(for: id, handler: logHandler)
156157

157-
// Set port conflict handler with proper weak capture
158+
// Set port conflict handler with proper weak capture (including inner Task)
158159
let conflictHandler: PortConflictHandler = { [weak self, weak state] port in
159160
guard let self = self, let state = state else { return }
160-
Task { @MainActor in
161+
Task { @MainActor [weak self, weak state] in
162+
guard let self = self, let state = state else { return }
161163
state.appendLog("Port \(port) in use, auto-recovering...", type: .portForward, isError: false)
162164

163165
await self.processManager.killProcessOnPort(port)
@@ -188,6 +190,9 @@ final class PortForwardManager {
188190
state.portForwardTask = nil
189191
state.portForwardStatus = .disconnected
190192

193+
// Clear logs to free memory when connection is stopped
194+
state.clearLogs()
195+
191196
Task {
192197
await processManager.killProcesses(for: id)
193198
await processManager.removeLogHandler(for: id)

platforms/macos/Sources/Managers/TunnelManager.swift

Lines changed: 6 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -101,10 +101,11 @@ final class TunnelManager {
101101
Task { [weak self, weak tunnelState] in
102102
guard let self = self, let tunnelState = tunnelState else { return }
103103

104-
// Set URL handler
104+
// Set URL handler with proper weak capture in inner Task
105105
let urlHandler: @Sendable (String) -> Void = { [weak self, weak tunnelState] url in
106106
guard let tunnelState = tunnelState else { return }
107-
Task { @MainActor in
107+
Task { @MainActor [weak self, weak tunnelState] in
108+
guard let tunnelState = tunnelState else { return }
108109
tunnelState.tunnelURL = url
109110
tunnelState.status = .active
110111
tunnelState.startTime = Date()
@@ -117,10 +118,11 @@ final class TunnelManager {
117118
}
118119
await self.cloudflaredService.setURLHandler(for: tunnelState.id, handler: urlHandler)
119120

120-
// Set error handler
121+
// Set error handler with proper weak capture in inner Task
121122
let errorHandler: @Sendable (String) -> Void = { [weak tunnelState] error in
122123
guard let tunnelState = tunnelState else { return }
123-
Task { @MainActor in
124+
Task { @MainActor [weak tunnelState] in
125+
guard let tunnelState = tunnelState else { return }
124126
tunnelState.lastError = error
125127
if tunnelState.status != .active {
126128
tunnelState.status = .error

platforms/macos/Sources/Models/PortForwardConnection.swift

Lines changed: 6 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -130,12 +130,15 @@ final class PortForwardConnectionState: Identifiable, Hashable {
130130
/// Tracks if the connection was stopped intentionally by the user (vs unexpected disconnect)
131131
var isIntentionallyStopped: Bool = false
132132

133+
/// Maximum log entries to keep per connection (memory optimization)
134+
private static let maxLogEntries = 100
135+
133136
func appendLog(_ message: String, type: PortForwardProcessType, isError: Bool = false) {
134137
let entry = PortForwardLogEntry(timestamp: Date(), message: message, type: type, isError: isError)
135138
logs.append(entry)
136-
// Keep only last 500 log entries
137-
if logs.count > 500 {
138-
logs.removeFirst(logs.count - 500)
139+
// Keep only last N log entries to prevent memory accumulation
140+
if logs.count > Self.maxLogEntries {
141+
logs.removeFirst(logs.count - Self.maxLogEntries)
139142
}
140143
}
141144

platforms/macos/Sources/Views/MenuBar/MenuBarView.swift

Lines changed: 54 additions & 33 deletions
Original file line numberDiff line numberDiff line change
@@ -19,57 +19,83 @@ struct MenuBarView: View {
1919
@State private var expandedProcesses: Set<Int> = []
2020
@Default(.useTreeView) private var useTreeView
2121
@Default(.hideSystemProcesses) private var hideSystemProcesses
22+
23+
// MARK: - Cached Data (Memory Optimization)
24+
@State private var cachedFilteredPorts: [PortInfo] = []
2225
@State private var cachedGroups: [ProcessGroup] = []
23-
@State private var groupingTrigger = 0
26+
@State private var lastCacheKey: CacheKey?
27+
28+
/// Cache key to detect when recalculation is needed
29+
private struct CacheKey: Equatable {
30+
let portsCount: Int
31+
let firstPortHash: Int
32+
let searchText: String
33+
let hideSystem: Bool
34+
}
2435

2536
private var groupedByProcess: [ProcessGroup] { cachedGroups }
2637

27-
/// Updates cached process groups from filtered ports
28-
private func updateGroupedByProcess() {
29-
let grouped = Dictionary(grouping: filteredPorts) { $0.pid }
38+
/// Updates all cached data only when inputs change
39+
private func updateCachedData() {
40+
let currentKey = CacheKey(
41+
portsCount: state.ports.count,
42+
firstPortHash: state.ports.first?.hashValue ?? 0,
43+
searchText: searchText,
44+
hideSystem: hideSystemProcesses
45+
)
46+
47+
// Skip if nothing changed
48+
guard currentKey != lastCacheKey else { return }
49+
lastCacheKey = currentKey
50+
51+
// Compute filtered ports once
52+
var filtered: [PortInfo]
53+
if searchText.isEmpty {
54+
filtered = state.ports
55+
} else {
56+
filtered = state.ports.filter {
57+
String($0.port).contains(searchText) || $0.processName.localizedCaseInsensitiveContains(searchText)
58+
}
59+
}
60+
61+
if hideSystemProcesses {
62+
filtered = filtered.filter { $0.processType != .system }
63+
}
64+
65+
cachedFilteredPorts = filtered.sorted { a, b in
66+
let aFav = state.isFavorite(a.port)
67+
let bFav = state.isFavorite(b.port)
68+
if aFav != bFav { return aFav }
69+
return a.port < b.port
70+
}
71+
72+
// Compute groups from cached filtered ports
73+
let grouped = Dictionary(grouping: cachedFilteredPorts) { $0.pid }
3074
cachedGroups = grouped.map { pid, ports in
3175
ProcessGroup(
3276
id: pid,
3377
processName: ports.first?.processName ?? "Unknown",
3478
ports: ports.sorted { $0.port < $1.port }
3579
)
3680
}.sorted { a, b in
37-
// Check if groups have favorite or watched ports
3881
let aHasFavorite = a.ports.contains(where: { state.isFavorite($0.port) })
3982
let aHasWatched = a.ports.contains(where: { state.isWatching($0.port) })
4083
let bHasFavorite = b.ports.contains(where: { state.isFavorite($0.port) })
4184
let bHasWatched = b.ports.contains(where: { state.isWatching($0.port) })
4285

43-
// Priority: Favorite > Watched > Neither
4486
let aPriority = aHasFavorite ? 2 : (aHasWatched ? 1 : 0)
4587
let bPriority = bHasFavorite ? 2 : (bHasWatched ? 1 : 0)
4688

4789
if aPriority != bPriority {
4890
return aPriority > bPriority
4991
} else {
50-
// Same priority, sort alphabetically by process name
5192
return a.processName.localizedCaseInsensitiveCompare(b.processName) == .orderedAscending
5293
}
5394
}
5495
}
5596

56-
/// Filters ports based on search text and sorts by favorites
57-
private var filteredPorts: [PortInfo] {
58-
var filtered = searchText.isEmpty ? state.ports : state.ports.filter {
59-
String($0.port).contains(searchText) || $0.processName.localizedCaseInsensitiveContains(searchText)
60-
}
61-
62-
if hideSystemProcesses {
63-
filtered = filtered.filter { $0.processType != .system }
64-
}
65-
66-
return filtered.sorted { a, b in
67-
let aFav = state.isFavorite(a.port)
68-
let bFav = state.isFavorite(b.port)
69-
if aFav != bFav { return aFav }
70-
return a.port < b.port
71-
}
72-
}
97+
/// Cached filtered ports (no allocation on access)
98+
private var filteredPorts: [PortInfo] { cachedFilteredPorts }
7399

74100
/// Filters port-forward connections based on search text
75101
private var filteredPortForwardConnections: [PortForwardConnectionState] {
@@ -109,14 +135,9 @@ struct MenuBarView: View {
109135
)
110136
}
111137
.frame(width: 340)
112-
.onAppear { groupingTrigger += 1 }
113-
.onChange(of: state.ports) { _, _ in groupingTrigger += 1 }
114-
.onChange(of: searchText) { _, _ in groupingTrigger += 1 }
115-
.onChange(of: hideSystemProcesses) { _, _ in groupingTrigger += 1 }
116-
.task(id: groupingTrigger) {
117-
// Debounce rapid changes to avoid excessive CPU/memory churn
118-
try? await Task.sleep(for: .milliseconds(100))
119-
updateGroupedByProcess()
120-
}
138+
.onAppear { updateCachedData() }
139+
.onChange(of: state.ports) { _, _ in updateCachedData() }
140+
.onChange(of: searchText) { _, _ in updateCachedData() }
141+
.onChange(of: hideSystemProcesses) { _, _ in updateCachedData() }
121142
}
122143
}

0 commit comments

Comments
 (0)