-
Notifications
You must be signed in to change notification settings - Fork 17
feat: implement battery optimizations with adaptive polling #38
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: main
Are you sure you want to change the base?
Changes from 45 commits
989c769
6464cc3
916501c
bd8b313
3e276c0
61b45d7
91b9612
28b9202
8322eac
0fe91e0
b7b335a
da5e3a6
db55401
5916ea3
ddf5533
c79b09b
d2935ec
9269ac6
b2a3f49
0a167fe
53a5a8c
fd236dd
4eeda7b
0cfd0bf
a325acc
56985fd
2a44312
65cff11
ac20fe4
fb2dbb4
233a1fc
77c9adc
6fc69cb
819fa3c
b27ce99
c0eb283
de4d772
b9eeb74
524e845
c6db3e0
6c36bcc
e76586a
32515c2
ce25926
ef68b4e
e60fef8
805caf9
a6ed65b
bbd7e98
cc5db36
7392d2b
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -97,13 +97,57 @@ class ViewModel: ObservableObject { | |
| DispatchQueue.main.asyncAfter(deadline: .now() + 3) { | ||
| self.buttonLock = false | ||
| } | ||
| // Set UI state to "Connecting" immediately for better UX | ||
| self.extensionStateText = "Connecting" | ||
| Task { | ||
| await self.networkExtensionAdapter.start() | ||
| print("Connected pressed set to false") | ||
|
|
||
| // Poll extension state repeatedly with short intervals until connected | ||
| // This ensures UI updates immediately when extension becomes connected | ||
| // instead of waiting for the 30s periodic check | ||
| self.pollExtensionStateUntilConnected(attempt: 0, maxAttempts: 15) | ||
| } | ||
| } | ||
| } | ||
|
|
||
| // Poll extension state repeatedly until connected or max attempts reached | ||
| // This provides immediate UI feedback after connect() instead of waiting for periodic check | ||
| private func pollExtensionStateUntilConnected(attempt: Int, maxAttempts: Int) { | ||
| // Cancel existing polling task if connect() was called again | ||
| connectionPollingTask?.cancel() | ||
|
|
||
| connectionPollingTask = Task { @MainActor in | ||
| var currentAttempt = attempt | ||
| while currentAttempt < maxAttempts { | ||
| guard !Task.isCancelled else { | ||
| print("Connection polling task was cancelled") | ||
| return | ||
| } | ||
|
|
||
| checkExtensionState() | ||
|
|
||
| // If connected, stop polling (checkExtensionState will start polling if needed) | ||
| if self.extensionState == .connected { | ||
| print("Extension connected, stopping state polling") | ||
| return | ||
| } | ||
|
|
||
| // Wait 1 second before next check | ||
| try? await Task.sleep(nanoseconds: 1_000_000_000) | ||
|
|
||
| guard !Task.isCancelled else { | ||
| print("Connection polling task was cancelled during sleep") | ||
| return | ||
| } | ||
|
|
||
| currentAttempt += 1 | ||
| } | ||
|
|
||
| print("Max attempts reached for extension state polling") | ||
| } | ||
| } | ||
|
Comment on lines
+114
to
+149
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Polling loop may check stale state due to async
Consider awaiting the state update or using a completion-based check: - checkExtensionState()
-
- // If connected, stop polling (checkExtensionState will start polling if needed)
- if self.extensionState == .connected {
+ await withCheckedContinuation { continuation in
+ networkExtensionAdapter.getExtensionStatus { [weak self] status in
+ DispatchQueue.main.async {
+ if let self = self, status == .connected && self.extensionState != .connected {
+ self.extensionState = status
+ self.extensionStateText = "Connected"
+ self.startPollingDetails()
+ }
+ continuation.resume()
+ }
+ }
+ }
+
+ if self.extensionState == .connected {
🤖 Prompt for AI Agents |
||
|
|
||
| func close() -> Void { | ||
| self.disconnectPressed = true | ||
| DispatchQueue.main.async { | ||
|
|
@@ -112,54 +156,96 @@ class ViewModel: ObservableObject { | |
| DispatchQueue.main.asyncAfter(deadline: .now() + 3) { | ||
| self.buttonLock = false | ||
| } | ||
| // Set UI state to "Disconnecting" immediately for better UX | ||
| self.extensionStateText = "Disconnecting" | ||
| self.networkExtensionAdapter.stop() | ||
|
|
||
| // Check extension state immediately to update UI | ||
| // This ensures UI updates immediately when extension becomes disconnected | ||
| // instead of waiting for the 30s periodic check | ||
| self.checkExtensionState() | ||
| } | ||
| } | ||
|
|
||
| // Battery optimization: Track last extension state check | ||
| private var lastExtensionStateCheck: Date = Date.distantPast | ||
| private let extensionStateCheckInterval: TimeInterval = 30.0 // Check every 30 seconds instead of every poll | ||
|
|
||
| // Prevent repeated stop() calls due to asynchronous state update timing | ||
| private var hasStoppedForLoginFailure: Bool = false | ||
|
|
||
coderabbitai[bot] marked this conversation as resolved.
Show resolved
Hide resolved
|
||
| // Track connection polling task to cancel it if connect() is called again | ||
| private var connectionPollingTask: Task<Void, Never>? | ||
|
|
||
| func startPollingDetails() { | ||
| networkExtensionAdapter.startTimer { details in | ||
| networkExtensionAdapter.startTimer { [weak self] details in | ||
| guard let self = self else { return } | ||
|
|
||
| self.checkExtensionState() | ||
| if self.extensionState == .disconnected && self.extensionStateText == "Connected" { | ||
| self.showAuthenticationRequired = true | ||
| self.extensionStateText = "Disconnected" | ||
| } | ||
|
|
||
| if details.ip != self.ip || details.fqdn != self.fqdn || details.managementStatus != self.managementStatus | ||
| { | ||
| if !details.fqdn.isEmpty && details.fqdn != self.fqdn { | ||
| self.defaults.set(details.fqdn, forKey: "fqdn") | ||
| self.fqdn = details.fqdn | ||
|
|
||
| // Ensure all UI updates happen on the main thread | ||
| Task { @MainActor in | ||
| // Battery optimization: Only check extension state periodically, not on every poll | ||
| let now = Date() | ||
| if now.timeIntervalSince(self.lastExtensionStateCheck) >= self.extensionStateCheckInterval { | ||
| self.checkExtensionState() | ||
| self.lastExtensionStateCheck = now | ||
| } | ||
| if !details.ip.isEmpty && details.ip != self.ip { | ||
| self.defaults.set(details.ip, forKey: "ip") | ||
| self.ip = details.ip | ||
|
|
||
| // Reset stop guard when extension disconnects (unconditional to avoid coupling to extensionStateText) | ||
| if self.extensionState == .disconnected { | ||
| self.hasStoppedForLoginFailure = false | ||
|
|
||
| // UX logic: Update UI state if needed | ||
| if self.extensionStateText == "Connected" { | ||
| self.showAuthenticationRequired = true | ||
| self.extensionStateText = "Disconnected" | ||
| } | ||
| } | ||
| print("Status: \(details.managementStatus) - Extension: \(self.extensionState) - LoginRequired: \(self.networkExtensionAdapter.isLoginRequired())") | ||
|
|
||
| if details.managementStatus != self.managementStatus { | ||
| self.managementStatus = details.managementStatus | ||
| if details.ip != self.ip || details.fqdn != self.fqdn || details.managementStatus != self.managementStatus | ||
| { | ||
| if !details.fqdn.isEmpty && details.fqdn != self.fqdn { | ||
| self.defaults.set(details.fqdn, forKey: "fqdn") | ||
| self.fqdn = details.fqdn | ||
| } | ||
| if !details.ip.isEmpty && details.ip != self.ip { | ||
| self.defaults.set(details.ip, forKey: "ip") | ||
| self.ip = details.ip | ||
| } | ||
|
|
||
| // Compute isLoginRequired() once to avoid UI hitching from multiple calls | ||
| // Always compute for accurate debug output, but only use in condition when relevant | ||
| let loginRequired = self.networkExtensionAdapter.isLoginRequired() | ||
|
|
||
| print("Status: \(details.managementStatus) - Extension: \(self.extensionState) - LoginRequired: \(loginRequired)") | ||
|
|
||
| if details.managementStatus != self.managementStatus { | ||
| self.managementStatus = details.managementStatus | ||
| } | ||
|
|
||
| // Prevent repeated stop() calls due to asynchronous state update timing | ||
| // Only call stop() once per login failure state, until extensionState updates | ||
| if details.managementStatus == .disconnected && | ||
| self.extensionState == .connected && | ||
| loginRequired && | ||
| !self.hasStoppedForLoginFailure { | ||
| self.hasStoppedForLoginFailure = true | ||
| self.networkExtensionAdapter.stop() | ||
| self.showAuthenticationRequired = true | ||
| } | ||
| } | ||
|
|
||
| if details.managementStatus == .disconnected && self.extensionState == .connected && self.networkExtensionAdapter.isLoginRequired() { | ||
| self.networkExtensionAdapter.stop() | ||
| self.showAuthenticationRequired = true | ||
| self.statusDetailsValid = true | ||
|
|
||
| let sortedPeerInfo = details.peerInfo.sorted(by: { a, b in | ||
| a.ip < b.ip | ||
| }) | ||
| if sortedPeerInfo.count != self.peerViewModel.peerInfo.count || !sortedPeerInfo.elementsEqual(self.peerViewModel.peerInfo, by: { a, b in | ||
| a.ip == b.ip && a.connStatus == b.connStatus && a.relayed == b.relayed && a.direct == b.direct && a.connStatusUpdate == b.connStatusUpdate && Set(a.routes) == Set(b.routes) | ||
| }) { | ||
| print("Setting new peer info: \(sortedPeerInfo.count) Peers") | ||
| self.peerViewModel.peerInfo = sortedPeerInfo | ||
| } | ||
| } | ||
|
|
||
| self.statusDetailsValid = true | ||
|
|
||
| let sortedPeerInfo = details.peerInfo.sorted(by: { a, b in | ||
| a.ip < b.ip | ||
| }) | ||
| if sortedPeerInfo.count != self.peerViewModel.peerInfo.count || !sortedPeerInfo.elementsEqual(self.peerViewModel.peerInfo, by: { a, b in | ||
| a.ip == b.ip && a.connStatus == b.connStatus && a.relayed == b.relayed && a.direct == b.direct && a.connStatusUpdate == b.connStatusUpdate && a.routes.count == b.routes.count | ||
| }) { | ||
| print("Setting new peer info: \(sortedPeerInfo.count) Peers") | ||
| self.peerViewModel.peerInfo = sortedPeerInfo | ||
| } | ||
|
|
||
| } | ||
| } | ||
|
|
||
|
|
@@ -171,9 +257,27 @@ class ViewModel: ObservableObject { | |
| networkExtensionAdapter.getExtensionStatus { status in | ||
| let statuses : [NEVPNStatus] = [.connected, .disconnected, .connecting, .disconnecting] | ||
| DispatchQueue.main.async { | ||
| let wasConnected = self.extensionState == .connected | ||
| if statuses.contains(status) && self.extensionState != status { | ||
| print("Changing extension status") | ||
| self.extensionState = status | ||
|
|
||
| // Update extensionStateText immediately for better UX | ||
| // CustomLottieView will also update it, but this ensures immediate feedback | ||
| if status == .connected { | ||
| self.extensionStateText = "Connected" | ||
| } else if status == .connecting { | ||
| self.extensionStateText = "Connecting" | ||
| } else if status == .disconnected { | ||
| self.extensionStateText = "Disconnected" | ||
| } | ||
|
|
||
| // Start polling when extension becomes connected (if not already polling) | ||
| // This ensures polling starts immediately after connect() without waiting for .active event | ||
| if status == .connected && !wasConnected { | ||
| print("Extension connected, starting polling") | ||
| self.startPollingDetails() | ||
| } | ||
| } | ||
| } | ||
| } | ||
|
|
||
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
🧩 Analysis chain
🏁 Script executed:
Repository: netbirdio/ios-client
Length of output: 1149
🏁 Script executed:
Repository: netbirdio/ios-client
Length of output: 104
🏁 Script executed:
Repository: netbirdio/ios-client
Length of output: 3335
🏁 Script executed:
Repository: netbirdio/ios-client
Length of output: 2475
🏁 Script executed:
Repository: netbirdio/ios-client
Length of output: 1365
🏁 Script executed:
Repository: netbirdio/ios-client
Length of output: 4734
🏁 Script executed:
Repository: netbirdio/ios-client
Length of output: 1156
Add synchronization to prevent concurrent
loadAllFromPreferences()calls ingetExtensionStatus().The polling loop calls
checkExtensionState()every second for up to 15 seconds, potentially spawning 15 concurrentTaskinstances that each callgetExtensionStatus(). Each invocation triggersNETunnelProviderManager.loadAllFromPreferences()without synchronization, allowing overlapping concurrent calls. While Apple's NetworkExtension framework may handle concurrent preference reads, the pattern is inconsistent with the rest of the codebase, which uses the existingpollingQueueto serialize similar operations. Consider serializing these calls using the existingpollingQueueor another appropriate synchronization mechanism.