1 //  Copyright © 2023 650 Industries. All rights reserved.
2 
3 // swiftlint:disable no_grouping_extension
4 // swiftlint:disable type_name
5 
6 import Foundation
7 
8 /**
9  Protocol with a method for sending state change events to JS.
10  In production, this will be implemented by the AppController.sharedInstance.
11  */
12 internal protocol UpdatesStateChangeDelegate: AnyObject {
sendUpdateStateChangeEventToBridgenull13   func sendUpdateStateChangeEventToBridge(_ eventType: UpdatesStateEventType, body: [String: Any?])
14 }
15 
16 // MARK: - Enums
17 
18 /**
19  All the possible states the machine can take.
20  */
21 internal enum UpdatesStateValue: String {
22   case idle
23   case checking
24   case downloading
25   case restarting
26 }
27 
28 /**
29  All the possible types of events that can be sent to the machine. Each event
30  will cause the machine to transition to a new state.
31  */
32 internal enum UpdatesStateEventType: String {
33   case check
34   case checkCompleteUnavailable
35   case checkCompleteAvailable
36   case checkError
37   case download
38   case downloadComplete
39   case downloadError
40   case restart
41 }
42 
43 // MARK: - Data structures
44 
45 /**
46  Protocol representing an event that can be sent to the machine, and
47  structs representing the different event types
48  */
49 internal protocol UpdatesStateEvent {
50   var type: UpdatesStateEventType { get }
51   var manifest: [String: Any]? { get }
52   var message: String? { get }
53   var rollbackCommitTime: Date? { get }
54   var error: [String: String]? { get }
55 }
56 
57 internal struct UpdatesStateEventCheck: UpdatesStateEvent {
58   let type: UpdatesStateEventType = .check
59   let manifest: [String: Any]? = nil
60   let message: String? = nil
61   let rollbackCommitTime: Date? = nil
62   let error: [String: String]? = nil
63 }
64 
65 internal struct UpdatesStateEventDownload: UpdatesStateEvent {
66   let type: UpdatesStateEventType = .download
67   let manifest: [String: Any]? = nil
68   let message: String? = nil
69   let rollbackCommitTime: Date? = nil
70   let error: [String: String]? = nil
71 }
72 
73 internal struct UpdatesStateEventRestart: UpdatesStateEvent {
74   let type: UpdatesStateEventType = .restart
75   let manifest: [String: Any]? = nil
76   let message: String? = nil
77   let rollbackCommitTime: Date? = nil
78   let error: [String: String]? = nil
79 }
80 
81 internal struct UpdatesStateEventCheckError: UpdatesStateEvent {
82   let type: UpdatesStateEventType = .checkError
83   let manifest: [String: Any]? = nil
84   let message: String?
85   let rollbackCommitTime: Date? = nil
86   var error: [String: String]? {
87     return (message != nil) ? ["message": message ?? ""] : nil
88   }
89 }
90 
91 internal struct UpdatesStateEventDownloadError: UpdatesStateEvent {
92   let type: UpdatesStateEventType = .downloadError
93   let manifest: [String: Any]? = nil
94   let message: String?
95   let rollbackCommitTime: Date? = nil
96   var error: [String: String]? {
97     return (message != nil) ? ["message": message ?? ""] : nil
98   }
99 }
100 
101 internal struct UpdatesStateEventCheckCompleteWithUpdate: UpdatesStateEvent {
102   let type: UpdatesStateEventType = .checkCompleteAvailable
103   let manifest: [String: Any]?
104   let message: String? = nil
105   let rollbackCommitTime: Date? = nil
106   let error: [String: String]? = nil
107 }
108 
109 internal struct UpdatesStateEventCheckCompleteWithRollback: UpdatesStateEvent {
110   let type: UpdatesStateEventType = .checkCompleteAvailable
111   let manifest: [String: Any]? = nil
112   let message: String? = nil
113   let rollbackCommitTime: Date?
114   let error: [String: String]? = nil
115 }
116 
117 internal struct UpdatesStateEventCheckComplete: UpdatesStateEvent {
118   let type: UpdatesStateEventType = .checkCompleteUnavailable
119   let manifest: [String: Any]? = nil
120   let message: String? = nil
121   let rollbackCommitTime: Date? = nil
122   let error: [String: String]? = nil
123 }
124 
125 internal struct UpdatesStateEventDownloadCompleteWithUpdate: UpdatesStateEvent {
126   let type: UpdatesStateEventType = .downloadComplete
127   let manifest: [String: Any]?
128   let message: String? = nil
129   let isRollback: Bool = false
130   let rollbackCommitTime: Date? = nil
131   let error: [String: String]? = nil
132 }
133 
134 internal struct UpdatesStateEventDownloadCompleteWithRollback: UpdatesStateEvent {
135   let type: UpdatesStateEventType = .downloadComplete
136   let manifest: [String: Any]? = nil
137   let message: String? = nil
138   // Rollback commit time is captured during check, not during download
139   let rollbackCommitTime: Date? = nil
140   let error: [String: String]? = nil
141 }
142 
143 internal struct UpdatesStateEventDownloadComplete: UpdatesStateEvent {
144   let type: UpdatesStateEventType = .downloadComplete
145   let manifest: [String: Any]? = nil
146   let message: String? = nil
147   let rollbackCommitTime: Date? = nil
148   let error: [String: String]? = nil
149 }
150 
151 /**
152  Date formatter for the last check times sent in JS events
153  */
154 let iso8601DateFormatter = ISO8601DateFormatter()
155 
156 /**
157  Structure for a rollback. Only the commitTime is used for now.
158  */
159 internal struct UpdatesStateContextRollback {
160   let commitTime: Date
161 
162   var json: [String: Any] {
163     return [
164       "commitTime": iso8601DateFormatter.string(from: commitTime)
165     ]
166   }
167 }
168 
169 /**
170  The state machine context, with information that will be readable from JS.
171  */
172 internal struct UpdatesStateContext {
173   let isUpdateAvailable: Bool
174   let isUpdatePending: Bool
175   let isRollback: Bool
176   let isChecking: Bool
177   let isDownloading: Bool
178   let isRestarting: Bool
179   let latestManifest: [String: Any]?
180   let downloadedManifest: [String: Any]?
181   let rollback: UpdatesStateContextRollback?
182   let checkError: [String: String]?
183   let downloadError: [String: String]?
184   let lastCheckForUpdateTime: Date?
185 
186   private var lastCheckForUpdateTimeDateString: String? {
187     guard let lastCheckForUpdateTime = lastCheckForUpdateTime else {
188       return nil
189     }
190     return iso8601DateFormatter.string(from: lastCheckForUpdateTime)
191   }
192 
193   var json: [String: Any?] {
194     return [
195       "isUpdateAvailable": self.isUpdateAvailable,
196       "isUpdatePending": self.isUpdatePending,
197       "isRollback": self.isRollback,
198       "isChecking": self.isChecking,
199       "isDownloading": self.isDownloading,
200       "isRestarting": self.isRestarting,
201       "latestManifest": self.latestManifest,
202       "downloadedManifest": self.downloadedManifest,
203       "checkError": self.checkError,
204       "downloadError": self.downloadError,
205       "lastCheckForUpdateTimeString": lastCheckForUpdateTimeDateString,
206       "rollback": rollback?.json
207     ] as [String: Any?]
208   }
209 }
210 
211 extension UpdatesStateContext {
212   init() {
213     self.isUpdateAvailable = false
214     self.isUpdatePending = false
215     self.isRollback = false
216     self.isChecking = false
217     self.isDownloading = false
218     self.isRestarting = false
219     self.latestManifest = nil
220     self.downloadedManifest = nil
221     self.checkError = nil
222     self.downloadError = nil
223     self.lastCheckForUpdateTime = nil
224     self.rollback = nil
225   }
226 
227   // struct copy, lets you overwrite specific variables retaining the value of the rest
228   // using a closure to set the new values for the copy of the struct
229   func copy(build: (inout Builder) -> Void) -> UpdatesStateContext {
230     var builder = Builder(original: self)
231     build(&builder)
232     return builder.toContext()
233   }
234 
235   struct Builder {
236     var isUpdateAvailable: Bool = false
237     var isUpdatePending: Bool = false
238     var isRollback: Bool = false
239     var isChecking: Bool = false
240     var isDownloading: Bool = false
241     var isRestarting: Bool = false
242     var latestManifest: [String: Any]?
243     var downloadedManifest: [String: Any]?
244     var checkError: [String: String]?
245     var downloadError: [String: String]?
246     var lastCheckForUpdateTime: Date?
247     var rollback: UpdatesStateContextRollback?
248 
249     fileprivate init(original: UpdatesStateContext) {
250       self.isUpdateAvailable = original.isUpdateAvailable
251       self.isUpdatePending = original.isUpdatePending
252       self.isRollback = original.isRollback
253       self.isChecking = original.isChecking
254       self.isDownloading = original.isDownloading
255       self.isRestarting = original.isRestarting
256       self.latestManifest = original.latestManifest
257       self.downloadedManifest = original.downloadedManifest
258       self.checkError = original.checkError
259       self.downloadError = original.downloadError
260       self.lastCheckForUpdateTime = original.lastCheckForUpdateTime
261       self.rollback = original.rollback
262     }
263 
toContextnull264     fileprivate func toContext() -> UpdatesStateContext {
265       return UpdatesStateContext(
266         isUpdateAvailable: isUpdateAvailable,
267         isUpdatePending: isUpdatePending,
268         isRollback: isRollback,
269         isChecking: isChecking,
270         isDownloading: isDownloading,
271         isRestarting: isRestarting,
272         latestManifest: latestManifest,
273         downloadedManifest: downloadedManifest,
274         rollback: rollback,
275         checkError: checkError,
276         downloadError: downloadError,
277         lastCheckForUpdateTime: lastCheckForUpdateTime
278       )
279     }
280   }
281 }
282 
283 // MARK: - State machine class
284 
285 /**
286  The Updates state machine class. There should be only one instance of this class
287  in a production app, instantiated as a property of AppController.
288  */
289 internal class UpdatesStateMachine {
290   private let logger = UpdatesLogger()
291 
292   init(changeEventDelegate: (any UpdatesStateChangeDelegate)) {
293     self.changeEventDelegate = changeEventDelegate
294   }
295 
296   // MARK: - Public methods and properties
297 
298   /**
299    In production, this is the AppController instance.
300    */
301   private weak var changeEventDelegate: (any UpdatesStateChangeDelegate)?
302 
303   /**
304    The current state
305    */
306   internal var state: UpdatesStateValue = .idle
307 
308   /**
309    The context
310    */
311   internal var context: UpdatesStateContext = UpdatesStateContext()
312 
313   /**
314    Called after the app restarts (reloadAsync()) to reset the machine to its
315    starting state.
316    */
resetnull317   internal func reset() {
318     state = .idle
319     context = UpdatesStateContext()
320     logger.info(message: "Updates state is reset, state = \(state), context = \(context)")
321     sendChangeEventToJS()
322   }
323 
324   /**
325    Called by AppLoaderTask delegate methods in AppController during the initial
326    background check for updates, and called by checkForUpdateAsync(), fetchUpdateAsync(), and reloadAsync().
327    */
processEventnull328   internal func processEvent(_ event: UpdatesStateEvent) {
329     // Execute state transition
330     if transition(event) {
331       // Only change context if transition succeeds
332       context = reducedContext(context, event)
333       logger.info(message: "Updates state change: state = \(state), event = \(event.type), context = \(context)")
334       // Send change event
335       sendChangeEventToJS(event)
336     }
337   }
338 
339   // MARK: - Private methods
340 
341   /**
342    Make sure the state transition is allowed, and then update the state.
343    */
transitionnull344   private func transition(_ event: UpdatesStateEvent) -> Bool {
345     let allowedEvents: Set<UpdatesStateEventType> = UpdatesStateMachine.updatesStateAllowedEvents[state] ?? []
346     if !allowedEvents.contains(event.type) {
347       // Uncomment the line below to halt execution on invalid state transitions,
348       // very useful for testing
349       /*
350       assertionFailure("UpdatesState: invalid transition requested: state = \(state), event = \(event.type)")
351        */
352       return false
353     }
354     // Successful transition
355     state = UpdatesStateMachine.updatesStateTransitions[event.type] ?? .idle
356     return true
357   }
358 
359   /**
360    Given an allowed event and a context, return a new context with the changes
361    made by processing the event.
362    */
reducedContextnull363   private func reducedContext(_ context: UpdatesStateContext, _ event: UpdatesStateEvent) -> UpdatesStateContext {
364     let rollback: UpdatesStateContextRollback?
365     if let rollbackCommitTime = event.rollbackCommitTime {
366       rollback = UpdatesStateContextRollback(commitTime: rollbackCommitTime)
367     } else {
368       rollback = nil
369     }
370 
371     switch event.type {
372     case .check:
373       return context.copy {
374         $0.isChecking = true
375       }
376     case .checkCompleteUnavailable:
377       return context.copy {
378         $0.isChecking = false
379         $0.checkError = nil
380         $0.latestManifest = nil
381         $0.isUpdateAvailable = false
382         $0.isRollback = false
383         $0.lastCheckForUpdateTime = Date()
384         $0.rollback = nil
385       }
386     case .checkCompleteAvailable:
387       return context.copy {
388         $0.isChecking = false
389         $0.checkError = nil
390         $0.latestManifest = event.manifest
391         $0.isUpdateAvailable = true
392         $0.lastCheckForUpdateTime = Date()
393         $0.rollback = rollback
394       }
395     case .checkError:
396       return context.copy {
397         $0.isChecking = false
398         $0.checkError = event.error
399         $0.lastCheckForUpdateTime = Date()
400       }
401     case .download:
402       return context.copy {
403         $0.isDownloading = true
404       }
405     case .downloadComplete:
406       return context.copy {
407         $0.isDownloading = false
408         $0.downloadError = nil
409         $0.latestManifest = event.manifest ?? context.latestManifest
410         $0.downloadedManifest = event.manifest ?? context.downloadedManifest
411         $0.isUpdatePending = $0.downloadedManifest != nil
412         $0.isUpdateAvailable = event.manifest != nil || context.isUpdateAvailable
413       }
414     case .downloadError:
415       return context.copy {
416         $0.isDownloading = false
417         $0.downloadError = event.error
418       }
419     case .restart:
420       return context.copy {
421         $0.isRestarting = true
422       }
423     }
424   }
425 
426   /**
427    On each state change, all context properties are sent to JS
428    */
sendChangeEventToJSnull429   private func sendChangeEventToJS(_ event: UpdatesStateEvent? = nil) {
430     changeEventDelegate?.sendUpdateStateChangeEventToBridge(event?.type ?? .restart, body: [
431       "context": context.json
432     ])
433   }
434 
435   // MARK: - Static definitions of the state machine rules
436 
437   /**
438    For a particular machine state, only certain events may be processed.
439    If the machine receives an unexpected event, an assertion failure will occur
440    and the app will crash.
441    */
442   static let updatesStateAllowedEvents: [UpdatesStateValue: Set<UpdatesStateEventType>] = [
443     .idle: [.check, .download, .restart],
444     .checking: [.checkCompleteAvailable, .checkCompleteUnavailable, .checkError],
445     .downloading: [.downloadComplete, .downloadError],
446     .restarting: []
447   ]
448 
449   /**
450    For this state machine, each event has only one destination state that the
451    machine will transition to.
452    */
453   static let updatesStateTransitions: [UpdatesStateEventType: UpdatesStateValue] = [
454     .check: .checking,
455     .checkCompleteAvailable: .idle,
456     .checkCompleteUnavailable: .idle,
457     .checkError: .idle,
458     .download: .downloading,
459     .downloadComplete: .idle,
460     .downloadError: .idle,
461     .restart: .restarting
462   ]
463 }
464 
465 // swiftlint:enable no_grouping_extension
466 // swiftlint:enable type_name
467