11//
2- // AppStoreMetaDataService .swift
2+ // AppVersionService .swift
33// MullvadVPN
44//
55// Created by Jon Petersson on 2026-01-09.
99import MullvadLogging
1010import MullvadSettings
1111import MullvadTypes
12- import UserNotifications
1312
14- public final class AppStoreMetaDataService : @unchecked Sendable {
13+ public final class AppVersionService : @unchecked Sendable {
14+ public enum Deadline {
15+ case now, nextCheck
16+ }
17+
1518 private var timer : DispatchSourceTimer ?
1619 private let checkInterval : TimeInterval = Duration . days ( 1 ) . timeInterval
17- private let logger = Logger ( label: " AppStoreMetaDataService " )
20+ private let logger = Logger ( label: " AppVersionService " )
1821
19- private let tunnelSettings : LatestTunnelSettings
2022 private let urlSession : URLSessionProtocol
2123 private let appPreferences : AppPreferences
2224 private let mainAppBundleIdentifier : String
23- private let appStoreLink : URL
25+ private let iTunesLink : URL
26+
27+ public var onNewAppVersion : ( ( ) -> Void ) ?
2428
2529 public init (
26- tunnelSettings: LatestTunnelSettings ,
2730 urlSession: URLSessionProtocol ,
2831 appPreferences: AppPreferences ,
29- mainAppBundleIdentifier: String
32+ mainAppBundleIdentifier: String ,
3033 ) {
31- self . tunnelSettings = tunnelSettings
3234 self . urlSession = urlSession
3335 self . appPreferences = appPreferences
3436 self . mainAppBundleIdentifier = mainAppBundleIdentifier
@@ -38,28 +40,31 @@ public final class AppStoreMetaDataService: @unchecked Sendable {
3840 resolvingAgainstBaseURL: true
3941 ) !
4042 urlComponents. queryItems = [ URLQueryItem ( name: " bundleId " , value: mainAppBundleIdentifier) ]
41- appStoreLink = urlComponents. url!
43+ iTunesLink = urlComponents. url!
4244 }
4345
44- public func scheduleTimer( ) {
46+ public func scheduleTimer( deadline : Deadline ) {
4547 let newTimer = DispatchSource . makeTimerSource ( )
4648
4749 newTimer. setEventHandler {
4850 Task { [ weak self] in
49- guard let self, tunnelSettings. includeAllNetworks. includeAllNetworksIsEnabled else {
50- return
51- }
51+ guard let self else { return }
5252
5353 let newVersionExists = ( try ? await performVersionCheck ( ) ) ?? false
5454 if newVersionExists {
55- sendNotification ( )
55+ onNewAppVersion ? ( )
5656 }
5757 }
5858 }
5959
60- // Resume deadline if there's time left from previous check. Otherwise, fire away.
61- let elapsed = Date . now. timeIntervalSince ( appPreferences. lastVersionCheck. date)
62- let deadline = max ( checkInterval - elapsed, 0 )
60+ let deadline =
61+ switch deadline {
62+ case . now:
63+ 0.0
64+ case . nextCheck:
65+ // Resume deadline if there's time left from previous check. Otherwise, fire away.
66+ max ( checkInterval - Date. now. timeIntervalSince ( appPreferences. lastVersionCheck. date) , 0 )
67+ }
6368
6469 newTimer. schedule ( deadline: . now( ) + deadline, repeating: . seconds( Int ( checkInterval) ) )
6570 newTimer. activate ( )
@@ -69,11 +74,11 @@ public final class AppStoreMetaDataService: @unchecked Sendable {
6974 }
7075
7176 func performVersionCheck( ) async throws -> Bool {
72- appPreferences. lastVersionCheck. date = . now
73-
7477 let appStoreMetaData = try await fetchAppStoreMetaData ( )
7578 let appStoreVersion = appStoreMetaData? . version ?? " "
7679
80+ appPreferences. lastVersionCheck. date = . now
81+
7782 if appStoreVersion. isNewerThan ( Bundle . main. shortVersion) {
7883 appPreferences. lastVersionCheck. version = appStoreVersion
7984 return true
@@ -85,7 +90,7 @@ public final class AppStoreMetaDataService: @unchecked Sendable {
8590 private func fetchAppStoreMetaData( ) async throws -> AppStoreMetaData ? {
8691 do {
8792 let data = try await urlSession. data (
88- for: URLRequest ( url: appStoreLink , timeoutInterval: REST . defaultAPINetworkTimeout. timeInterval)
93+ for: URLRequest ( url: iTunesLink , timeoutInterval: REST . defaultAPINetworkTimeout. timeInterval)
8994 )
9095 let response = try JSONDecoder ( ) . decode ( AppStoreMetaDataResponse . self, from: data. 0 )
9196 return response. results. first { $0. bundleId == mainAppBundleIdentifier }
@@ -95,42 +100,6 @@ public final class AppStoreMetaDataService: @unchecked Sendable {
95100
96101 return nil
97102 }
98-
99- private func sendNotification( ) {
100- let content = UNMutableNotificationContent ( )
101- content. title = NSLocalizedString ( " Update available " , comment: " " )
102- content. body = String (
103- format: NSLocalizedString (
104- " Disable “%@” or disconnect before updating in order not to lose network connectivity. " ,
105- comment: " "
106- ) ,
107- " Force all apps "
108- )
109-
110- // When scheduling a user notification we need to make sure that the date has not passed
111- // when it's actually added to the system. Giving it a few seconds leeway lets us be sure
112- // that this is the case.
113- let dateComponents = Calendar . current. dateComponents (
114- [ . second, . minute, . hour, . day, . month, . year] ,
115- from: Date ( timeIntervalSinceNow: 5 )
116- )
117- let trigger = UNCalendarNotificationTrigger ( dateMatching: dateComponents, repeats: false )
118-
119- let request = UNNotificationRequest (
120- identifier: NotificationProviderIdentifier . newAppVersionSystemNotification. domainIdentifier,
121- content: content,
122- trigger: trigger
123- )
124-
125- let identifier = request. identifier
126- UNUserNotificationCenter . current ( ) . add ( request) { [ weak self, identifier] error in
127- if let error {
128- self ? . logger. error (
129- " Failed to add notification request with identifier \( identifier) . Error: \( error. description) "
130- )
131- }
132- }
133- }
134103}
135104
136105extension String {
0 commit comments