Skip to content

Commit cd6bcfd

Browse files
Merge branch 'master' into KM-15917-connected-protected-timer
2 parents 8f36a26 + e528d19 commit cd6bcfd

26 files changed

Lines changed: 264 additions & 440 deletions

LocalPackages/PIALibrary/Package.swift

Lines changed: 1 addition & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -25,16 +25,14 @@ let package = Package(
2525
.package(url: "git@github.com:pia-foss/mobile-ios-openvpn.git", exact: "2.2.6"),
2626
.package(url: "git@github.com:pia-foss/mobile-ios-wireguard.git", exact: "1.0.6"),
2727
.package(url: "https://github.com/apple/swift-algorithms", exact: "1.2.1"),
28-
.package(url: "https://github.com/apple/swift-log", exact: "1.13.1"),
29-
.package(url: "https://github.com/ashleymills/Reachability.swift.git", exact: "5.2.4")
28+
.package(url: "https://github.com/apple/swift-log", exact: "1.13.1")
3029
],
3130
targets: [
3231
.target(
3332
name: "PIALibrary",
3433
dependencies: [
3534
.product(name: "Algorithms", package: "swift-algorithms"),
3635
.product(name: "Logging", package: "swift-log"),
37-
.product(name: "Reachability", package: "Reachability.swift"),
3836
.product(name: "PIAKPI", package: "PIAKPI"),
3937
.product(name: "PIACSI", package: "PIACSI"),
4038
.product(name: "PIARegions", package: "PIARegions"),

LocalPackages/PIALibrary/README.md

Lines changed: 0 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -302,7 +302,6 @@ This project is licensed under the [MIT (Expat) license](https://choosealicense.
302302

303303
## Acknowledgements
304304

305-
- ReachabilitySwift - © 2016 Ashley Mills
306305
- TunnelKit - © 2018 - Present Davide de Rosa (https://github.com/passepartoutvpn/tunnelkit) - TunnelKit is not MIT software and remains under the terms of the GPL license (https://github.com/passepartoutvpn/tunnelkit/blob/master/LICENSE)
307306

308307
[pia-image]: https://www.privateinternetaccess.com/assets/PIALogo2x-0d1e1094ac909ea4c93df06e2da3db4ee8a73d8b2770f0f7d768a8603c62a82f.png

LocalPackages/PIALibrary/Sources/PIALibrary/Client+Daemons.swift

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -33,6 +33,11 @@ extension Client {
3333
return accessedDatabase.transient.isNetworkReachable
3434
}
3535

36+
/// It's `true` when the Internet is reachable.
37+
public var isInternetReachable: Bool {
38+
return accessedDatabase.transient.isInternetReachable
39+
}
40+
3641
/// The public IP address while not on VPN.
3742
public var publicIP: String? {
3843
return accessedDatabase.plain.publicIP

LocalPackages/PIALibrary/Sources/PIALibrary/Daemons/ConnectivityDaemon.swift

Lines changed: 13 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -21,7 +21,6 @@
2121
//
2222

2323
import Foundation
24-
import Reachability
2524

2625
private let log = PIALogger.logger(for: ConnectivityDaemon.self)
2726

@@ -39,7 +38,7 @@ final class ConnectivityDaemon: Daemon, ConfigurationAccess, DatabaseAccess, Pre
3938

4039
private(set) var hasEnabledUpdates: Bool = false
4140

42-
private let reachability = try! Reachability(hostname: "8.8.8.8")
41+
private let networkObserver = NetworkObserver()
4342

4443
private lazy var checker = ConnectivityChecker(webServices: accessedWebServices)
4544

@@ -58,7 +57,7 @@ final class ConnectivityDaemon: Daemon, ConfigurationAccess, DatabaseAccess, Pre
5857

5958
let nc = NotificationCenter.default
6059
nc.addObserver(self, selector: #selector(vpnStatusDidChange(notification:)), name: .PIADaemonsDidUpdateVPNStatus, object: nil)
61-
startReachability()
60+
startNetworkObserver()
6261
}
6362

6463
func enableUpdates() {
@@ -70,14 +69,14 @@ final class ConnectivityDaemon: Daemon, ConfigurationAccess, DatabaseAccess, Pre
7069
checkConnectivityOrRetry()
7170
}
7271

73-
private func startReachability() {
74-
log.debug("Configuring for reachability...")
75-
accessedDatabase.transient.isNetworkReachable = (reachability.connection != .unavailable)
72+
private func startNetworkObserver() {
73+
log.debug("Configuring for network observer...")
74+
accessedDatabase.transient.isNetworkReachable = networkObserver.isReachable
7675
log.debug("Initial network state is \(accessedDatabase.transient.isNetworkReachable ? "REACHABLE" : "NOT REACHABLE")")
7776

78-
reachability.whenReachable = { [weak self] reach in
77+
networkObserver.whenReachable = { [weak self] in
78+
guard let self else { return }
7979
DispatchQueue.main.async {
80-
guard let self else { return }
8180
guard !self.accessedDatabase.transient.isNetworkReachable else {
8281
if (self.accessedDatabase.transient.vpnStatus != .connected) {
8382
self.checkConnectivityOrRetry()
@@ -89,9 +88,9 @@ final class ConnectivityDaemon: Daemon, ConfigurationAccess, DatabaseAccess, Pre
8988
Macros.postNotification(.ConnectivityDaemonDidGetReachable)
9089
}
9190
}
92-
reachability.whenUnreachable = { [weak self] reach in
91+
networkObserver.whenUnreachable = { [weak self] in
92+
guard let self else { return }
9393
DispatchQueue.main.async {
94-
guard let self else { return }
9594
guard self.accessedDatabase.transient.isNetworkReachable else {
9695
return
9796
}
@@ -100,9 +99,9 @@ final class ConnectivityDaemon: Daemon, ConfigurationAccess, DatabaseAccess, Pre
10099
Macros.postNotification(.ConnectivityDaemonDidGetUnreachable)
101100
}
102101
}
103-
try? reachability.startNotifier()
104102

105-
log.debug("Reachability notifier started")
103+
networkObserver.start()
104+
log.debug("Network observer started")
106105
}
107106

108107
private func checkConnectivityOrRetry() {
@@ -131,7 +130,7 @@ final class ConnectivityDaemon: Daemon, ConfigurationAccess, DatabaseAccess, Pre
131130
guard (self.failedConnectivityAttempts < self.accessedConfiguration.connectivityMaxAttempts) else {
132131
log.debug("Giving up, network is unreachable")
133132
self.failedConnectivityAttempts = 0
134-
self.accessedDatabase.transient.isNetworkReachable = false
133+
self.accessedDatabase.transient.isInternetReachable = false
135134
Macros.postNotification(.PIADaemonsDidUpdateConnectivity)
136135
return
137136
}
@@ -144,7 +143,7 @@ final class ConnectivityDaemon: Daemon, ConfigurationAccess, DatabaseAccess, Pre
144143

145144
case .success(let connectivity):
146145
self.failedConnectivityAttempts = 0
147-
self.accessedDatabase.transient.isNetworkReachable = true
146+
self.accessedDatabase.transient.isInternetReachable = true
148147
log.debug("Saving new info about network connectivity: \(connectivity)")
149148

150149
let ipAddress = connectivity.ipAddress
Lines changed: 53 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,53 @@
1+
//
2+
// NetworkObserver.swift
3+
// PIALibrary
4+
//
5+
// Created by Mario on 08/07/2026.
6+
// Copyright © 2020 Private Internet Access, Inc.
7+
//
8+
// This file is part of the Private Internet Access iOS Client.
9+
//
10+
// The Private Internet Access iOS Client is free software: you can redistribute it and/or
11+
// modify it under the terms of the GNU General Public License as published by the Free
12+
// Software Foundation, either version 3 of the License, or (at your option) any later version.
13+
//
14+
// The Private Internet Access iOS Client is distributed in the hope that it will be useful,
15+
// but WITHOUT ANY WARRANTY; without even the implied warranty of MERCHANTABILITY
16+
// or FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License for more
17+
// details.
18+
//
19+
// You should have received a copy of the GNU General Public License along with the Private
20+
// Internet Access iOS Client. If not, see <https://www.gnu.org/licenses/>.
21+
//
22+
23+
import Foundation
24+
import Network
25+
26+
final class NetworkObserver {
27+
private let monitor: NWPathMonitor = NWPathMonitor()
28+
private(set) var isReachable: Bool = false
29+
30+
var whenReachable: (() -> Void) = {}
31+
var whenUnreachable: (() -> Void) = {}
32+
33+
func start() {
34+
monitor.pathUpdateHandler = { [weak self] path in
35+
switch path.status {
36+
case .satisfied:
37+
self?.isReachable = true
38+
self?.whenReachable()
39+
case .unsatisfied, .requiresConnection:
40+
self?.isReachable = false
41+
self?.whenUnreachable()
42+
@unknown default:
43+
break
44+
}
45+
46+
}
47+
monitor.start(queue: .global(qos: .background))
48+
}
49+
50+
func stop() {
51+
monitor.cancel()
52+
}
53+
}

LocalPackages/PIALibrary/Sources/PIALibrary/Daemons/VPNDaemon.swift

Lines changed: 44 additions & 104 deletions
Original file line numberDiff line numberDiff line change
@@ -153,7 +153,43 @@ final class VPNDaemon: Daemon, DatabaseAccess, ProvidersAccess {
153153
ServiceQualityManager.shared.connectionAttemptEvent()
154154
}
155155

156-
scheduleFallbackTimerIfNeeded()
156+
if fallbackTimer == nil {
157+
log.debug("Setting up fallbackTimer...")
158+
159+
fallbackTimer = Timer.scheduledTimer(withTimeInterval: Client.configuration.vpnConnectivityRetryDelay, repeats: true) { [weak self] timer in
160+
guard let self else { return }
161+
log.debug("Executing fallbackTimer...")
162+
163+
let address = try? Client.providers.serverProvider.targetServer.bestAddress()
164+
address?.markServerAsUnavailable()
165+
166+
self.numberOfAttempts += 1
167+
if self.numberOfAttempts < Client.configuration.vpnConnectivityMaxAttempts || self.isReconnectingAfterConnectivityFailure {
168+
log.debug("NEVPNManager is still connecting. Reconnecting with a different server...")
169+
self.updateUIWithAttemptNumber(self.numberOfAttempts)
170+
self.isReconnecting = true
171+
Client.providers.vpnProvider.reconnect(after: 0, forceDisconnect: true) { error in
172+
if error != nil {
173+
// Reconnect initiation failed — clear flag immediately so the
174+
// subsequent .disconnected status change can clean up normally.
175+
self.isReconnecting = false
176+
}
177+
// On success: leave isReconnecting=true. It will be cleared in
178+
// tryUpdateStatus when .connecting status arrives, ensuring that
179+
// the intermediate .disconnecting → .disconnected transitions do
180+
// not briefly expose vpnStatus = .disconnected to the rest of the app.
181+
}
182+
} else {
183+
log.debug("Max number of VPN reconnections. Disconnecting...")
184+
Client.providers.vpnProvider.disconnect { error in
185+
Macros.postNotification(.PIAVPNDidFail)
186+
self.reset()
187+
self.invalidateTimer()
188+
}
189+
}
190+
}
191+
192+
}
157193

158194
case .disconnecting:
159195
nextStatus = .disconnecting
@@ -167,31 +203,7 @@ final class VPNDaemon: Daemon, DatabaseAccess, ProvidersAccess {
167203
return
168204
}
169205

170-
// No internet while a connection was already in progress: keep trying
171-
// indefinitely instead of giving up. Reaching this point means the previous
172-
// status was not .disconnected, so a connection attempt (or an established
173-
// connection) was underway. Force the status back to .connecting and keep
174-
// the fallback timer alive — recreating it if it was already invalidated —
175-
// so we reconnect as soon as the network becomes reachable again.
176-
// Skipped when the user disconnected manually.
177-
if !accessedDatabase.transient.isNetworkReachable,
178-
!Client.configuration.disconnectedManually
179-
{
180-
log.debug("No internet while connecting — staying in .connecting and keeping the retry timer alive")
181-
isReconnecting = true
182-
scheduleFallbackTimerIfNeeded()
183-
184-
accessedDatabase.plain.lastKnownVpnStatus = .connecting
185-
if previousStatus != .connecting {
186-
accessedDatabase.transient.vpnStatus = .connecting
187-
}
188-
return
189-
}
190-
191-
// A manual disconnect must always tear down the retry loop, even mid-reconnect
192-
// (isReconnecting == true), otherwise the fallback timer would keep firing and
193-
// force the status back to .connecting after the user asked to disconnect.
194-
if !isReconnecting || Client.configuration.disconnectedManually {
206+
if !isReconnecting {
195207
invalidateTimer()
196208
reset()
197209
}
@@ -261,25 +273,19 @@ final class VPNDaemon: Daemon, DatabaseAccess, ProvidersAccess {
261273
}
262274
}
263275

264-
log.debug("connectivityCheckFailed=\(connectivityCheckFailed) previousStatus=\(previousStatus)")
276+
log.debug("[VPNDaemon] connectivityCheckFailed=\(connectivityCheckFailed) previousStatus=\(previousStatus)")
265277

266-
if disconnectedManually {
267-
// The user asked to disconnect — never reconnect on their behalf,
268-
// whatever error the tunnel died with.
269-
log.debug("Manual disconnect — ignoring last disconnect error")
270-
} else if connectivityCheckFailed {
271-
let wholeInternetIsReachable = accessedDatabase.transient.isNetworkReachable
272-
if wholeInternetIsReachable, let lastConnectedCN = accessedDatabase.plain.lastServerCN {
273-
log.debug("connectivityCheckFailed — marking current server as unavailable and triggering reconnect")
278+
if connectivityCheckFailed {
279+
log.debug("[VPNDaemon] connectivityCheckFailed — marking current server as unavailable and triggering reconnect")
280+
281+
if let lastConnectedCN = accessedDatabase.plain.lastServerCN {
274282
let targetRegion = try? Client.providers.serverProvider.targetServer
275283
let lastConnectedServer = targetRegion?.addresses().first(where: { $0.cn == lastConnectedCN })
276284
lastConnectedServer?.markServerAsUnavailable()
277-
} else if !wholeInternetIsReachable {
278-
log.debug("There's no internet!")
279285
}
280286

281287
isReconnectingAfterConnectivityFailure = true
282-
Client.providers.vpnProvider.reconnect(forceDisconnect: true, nil)
288+
Client.providers.vpnProvider.reconnect(after: nil, forceDisconnect: true, nil)
283289
} else {
284290
if previousStatus == .connecting {
285291
log.error("The VPN did fail \(lastDisconnectError)")
@@ -291,72 +297,6 @@ final class VPNDaemon: Daemon, DatabaseAccess, ProvidersAccess {
291297
}
292298
}
293299

294-
// MARK: Fallback timer
295-
296-
/// Schedules the repeating reconnection timer if it is not already running.
297-
/// The timer fires every `vpnConnectivityRetryDelay` seconds, marking the current
298-
/// server as unavailable and attempting a reconnect. It keeps retrying while there
299-
/// are attempts left, while there is no internet, or while recovering from a
300-
/// connectivity failure; otherwise it gives up and disconnects.
301-
private func scheduleFallbackTimerIfNeeded() {
302-
guard fallbackTimer == nil else { return }
303-
log.debug("Setting up fallbackTimer...")
304-
305-
fallbackTimer = Timer.scheduledTimer(withTimeInterval: Client.configuration.vpnConnectivityRetryDelay, repeats: true) { [weak self] timer in
306-
guard let self else { return }
307-
log.debug("Executing fallbackTimer...")
308-
309-
// The user disconnected manually: stop retrying. Without this check
310-
// the timer keeps reconnecting when no NEVPNStatusDidChange arrives
311-
// to tear it down (e.g. the tunnel was already dead when the user
312-
// tapped disconnect during a network change).
313-
guard !Client.configuration.disconnectedManually else {
314-
log.debug("Manual disconnect — stopping the reconnection retry timer")
315-
self.invalidateTimer()
316-
self.reset()
317-
return
318-
}
319-
320-
let address = try? Client.providers.serverProvider.targetServer.bestAddress()
321-
address?.markServerAsUnavailable()
322-
323-
self.numberOfAttempts += 1
324-
325-
let shouldKeepTrying =
326-
numberOfAttempts < Client.configuration.vpnConnectivityMaxAttempts
327-
|| !accessedDatabase.transient.isNetworkReachable
328-
|| isReconnectingAfterConnectivityFailure
329-
330-
if shouldKeepTrying {
331-
log.debug("NEVPNManager is still connecting. Reconnecting with a different server...")
332-
self.updateUIWithAttemptNumber(self.numberOfAttempts)
333-
self.isReconnecting = true
334-
Client.providers.vpnProvider.reconnect(forceDisconnect: true) { error in
335-
if let clientError = error as? ClientError, clientError == .internetUnreachable {
336-
self.isReconnecting = true
337-
self.isReconnectingAfterConnectivityFailure = true
338-
339-
} else if error != nil {
340-
// Reconnect initiation failed — clear flag immediately so the
341-
// subsequent .disconnected status change can clean up normally.
342-
self.isReconnecting = false
343-
}
344-
// On success: leave isReconnecting=true. It will be cleared in
345-
// tryUpdateStatus when .connecting status arrives, ensuring that
346-
// the intermediate .disconnecting → .disconnected transitions do
347-
// not briefly expose vpnStatus = .disconnected to the rest of the app.
348-
}
349-
} else {
350-
log.debug("Max number of VPN reconnections. Disconnecting...")
351-
Client.providers.vpnProvider.disconnect { error in
352-
Macros.postNotification(.PIAVPNDidFail)
353-
self.reset()
354-
self.invalidateTimer()
355-
}
356-
}
357-
}
358-
}
359-
360300
// MARK: Invalidate
361301
private func invalidateTimer() {
362302
fallbackTimer?.invalidate()

LocalPackages/PIALibrary/Sources/PIALibrary/Extensions/NEVPNStatus+Description.swift

Lines changed: 0 additions & 36 deletions
This file was deleted.

0 commit comments

Comments
 (0)