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 { 13 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 isRollback: Bool { 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 isRollback: Bool = false 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 isRollback: Bool = false 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 isRollback: Bool = false 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 isRollback: Bool = false 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 isRollback: Bool = false 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 isRollback: Bool = false 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 isRollback: Bool = true 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 isRollback: Bool = false 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 error: [String: String]? = nil 131 } 132 133 internal struct UpdatesStateEventDownloadCompleteWithRollback: UpdatesStateEvent { 134 let type: UpdatesStateEventType = .downloadComplete 135 let manifest: [String: Any]? = nil 136 let message: String? = nil 137 let isRollback: Bool = true 138 let error: [String: String]? = nil 139 } 140 141 internal struct UpdatesStateEventDownloadComplete: UpdatesStateEvent { 142 let type: UpdatesStateEventType = .downloadComplete 143 let manifest: [String: Any]? = nil 144 let message: String? = nil 145 let isRollback: Bool = false 146 let error: [String: String]? = nil 147 } 148 149 /** 150 Date formatter for the last check times sent in JS events 151 */ 152 let iso8601DateFormatter = ISO8601DateFormatter() 153 154 /** 155 The state machine context, with information that will be readable from JS. 156 */ 157 internal struct UpdatesStateContext { 158 let isUpdateAvailable: Bool 159 let isUpdatePending: Bool 160 let isRollback: Bool 161 let isChecking: Bool 162 let isDownloading: Bool 163 let isRestarting: Bool 164 let latestManifest: [String: Any]? 165 let downloadedManifest: [String: Any]? 166 let checkError: [String: String]? 167 let downloadError: [String: String]? 168 let lastCheckForUpdateTime: Date? 169 170 private var lastCheckForUpdateTimeDateString: String? { 171 guard let lastCheckForUpdateTime = lastCheckForUpdateTime else { 172 return nil 173 } 174 return iso8601DateFormatter.string(from: lastCheckForUpdateTime) 175 } 176 177 var json: [String: Any?] { 178 return [ 179 "isUpdateAvailable": self.isUpdateAvailable, 180 "isUpdatePending": self.isUpdatePending, 181 "isRollback": self.isRollback, 182 "isChecking": self.isChecking, 183 "isDownloading": self.isDownloading, 184 "isRestarting": self.isRestarting, 185 "latestManifest": self.latestManifest, 186 "downloadedManifest": self.downloadedManifest, 187 "checkError": self.checkError, 188 "downloadError": self.downloadError, 189 "lastCheckForUpdateTimeString": lastCheckForUpdateTimeDateString 190 ] as [String: Any?] 191 } 192 } 193 194 extension UpdatesStateContext { 195 init() { 196 self.isUpdateAvailable = false 197 self.isUpdatePending = false 198 self.isRollback = false 199 self.isChecking = false 200 self.isDownloading = false 201 self.isRestarting = false 202 self.latestManifest = nil 203 self.downloadedManifest = nil 204 self.checkError = nil 205 self.downloadError = nil 206 self.lastCheckForUpdateTime = nil 207 } 208 209 // struct copy, lets you overwrite specific variables retaining the value of the rest 210 // using a closure to set the new values for the copy of the struct 211 func copy(build: (inout Builder) -> Void) -> UpdatesStateContext { 212 var builder = Builder(original: self) 213 build(&builder) 214 return builder.toContext() 215 } 216 217 struct Builder { 218 var isUpdateAvailable: Bool = false 219 var isUpdatePending: Bool = false 220 var isRollback: Bool = false 221 var isChecking: Bool = false 222 var isDownloading: Bool = false 223 var isRestarting: Bool = false 224 var latestManifest: [String: Any]? 225 var downloadedManifest: [String: Any]? 226 var checkError: [String: String]? 227 var downloadError: [String: String]? 228 var lastCheckForUpdateTime: Date? 229 230 fileprivate init(original: UpdatesStateContext) { 231 self.isUpdateAvailable = original.isUpdateAvailable 232 self.isUpdatePending = original.isUpdatePending 233 self.isRollback = original.isRollback 234 self.isChecking = original.isChecking 235 self.isDownloading = original.isDownloading 236 self.isRestarting = original.isRestarting 237 self.latestManifest = original.latestManifest 238 self.downloadedManifest = original.downloadedManifest 239 self.checkError = original.checkError 240 self.downloadError = original.downloadError 241 self.lastCheckForUpdateTime = original.lastCheckForUpdateTime 242 } 243 244 fileprivate func toContext() -> UpdatesStateContext { 245 return UpdatesStateContext( 246 isUpdateAvailable: isUpdateAvailable, 247 isUpdatePending: isUpdatePending, 248 isRollback: isRollback, 249 isChecking: isChecking, 250 isDownloading: isDownloading, 251 isRestarting: isRestarting, 252 latestManifest: latestManifest, 253 downloadedManifest: downloadedManifest, 254 checkError: checkError, 255 downloadError: downloadError, 256 lastCheckForUpdateTime: lastCheckForUpdateTime 257 ) 258 } 259 } 260 } 261 262 // MARK: - State machine class 263 264 /** 265 The Updates state machine class. There should be only one instance of this class 266 in a production app, instantiated as a property of AppController. 267 */ 268 internal class UpdatesStateMachine { 269 private let logger = UpdatesLogger() 270 271 init(changeEventDelegate: (any UpdatesStateChangeDelegate)) { 272 self.changeEventDelegate = changeEventDelegate 273 } 274 275 // MARK: - Public methods and properties 276 277 /** 278 In production, this is the AppController instance. 279 */ 280 private weak var changeEventDelegate: (any UpdatesStateChangeDelegate)? 281 282 /** 283 The current state 284 */ 285 internal var state: UpdatesStateValue = .idle 286 287 /** 288 The context 289 */ 290 internal var context: UpdatesStateContext = UpdatesStateContext() 291 292 /** 293 Called after the app restarts (reloadAsync()) to reset the machine to its 294 starting state. 295 */ 296 internal func reset() { 297 state = .idle 298 context = UpdatesStateContext() 299 logger.info(message: "Updates state is reset, state = \(state), context = \(context)") 300 sendChangeEventToJS() 301 } 302 303 /** 304 Called by AppLoaderTask delegate methods in AppController during the initial 305 background check for updates, and called by checkForUpdateAsync(), fetchUpdateAsync(), and reloadAsync(). 306 */ 307 internal func processEvent(_ event: UpdatesStateEvent) { 308 // Execute state transition 309 if transition(event) { 310 // Only change context if transition succeeds 311 context = reducedContext(context, event) 312 logger.info(message: "Updates state change: state = \(state), event = \(event.type), context = \(context)") 313 // Send change event 314 sendChangeEventToJS(event) 315 } 316 } 317 318 // MARK: - Private methods 319 320 /** 321 Make sure the state transition is allowed, and then update the state. 322 */ 323 private func transition(_ event: UpdatesStateEvent) -> Bool { 324 let allowedEvents: Set<UpdatesStateEventType> = UpdatesStateMachine.updatesStateAllowedEvents[state] ?? [] 325 if !allowedEvents.contains(event.type) { 326 // Uncomment the line below to halt execution on invalid state transitions, 327 // very useful for testing 328 /* 329 assertionFailure("UpdatesState: invalid transition requested: state = \(state), event = \(event.type)") 330 */ 331 return false 332 } 333 // Successful transition 334 state = UpdatesStateMachine.updatesStateTransitions[event.type] ?? .idle 335 return true 336 } 337 338 /** 339 Given an allowed event and a context, return a new context with the changes 340 made by processing the event. 341 */ 342 private func reducedContext(_ context: UpdatesStateContext, _ event: UpdatesStateEvent) -> UpdatesStateContext { 343 switch event.type { 344 case .check: 345 return context.copy { 346 $0.isChecking = true 347 } 348 case .checkCompleteUnavailable: 349 return context.copy { 350 $0.isChecking = false 351 $0.checkError = nil 352 $0.latestManifest = nil 353 $0.isUpdateAvailable = false 354 $0.isRollback = false 355 $0.lastCheckForUpdateTime = Date() 356 } 357 case .checkCompleteAvailable: 358 return context.copy { 359 $0.isChecking = false 360 $0.checkError = nil 361 $0.latestManifest = event.manifest 362 $0.isRollback = event.isRollback 363 $0.isUpdateAvailable = true 364 $0.lastCheckForUpdateTime = Date() 365 } 366 case .checkError: 367 return context.copy { 368 $0.isChecking = false 369 $0.checkError = event.error 370 $0.lastCheckForUpdateTime = Date() 371 } 372 case .download: 373 return context.copy { 374 $0.isDownloading = true 375 } 376 case .downloadComplete: 377 return context.copy { 378 $0.isDownloading = false 379 $0.downloadError = nil 380 $0.latestManifest = event.manifest ?? context.latestManifest 381 $0.downloadedManifest = event.manifest ?? context.downloadedManifest 382 $0.isUpdatePending = $0.downloadedManifest != nil 383 $0.isUpdateAvailable = event.manifest != nil || context.isUpdateAvailable 384 } 385 case .downloadError: 386 return context.copy { 387 $0.isDownloading = false 388 $0.downloadError = event.error 389 } 390 case .restart: 391 return context.copy { 392 $0.isRestarting = true 393 } 394 } 395 } 396 397 /** 398 On each state change, all context properties are sent to JS 399 */ 400 private func sendChangeEventToJS(_ event: UpdatesStateEvent? = nil) { 401 changeEventDelegate?.sendUpdateStateChangeEventToBridge(event?.type ?? .restart, body: [ 402 "context": context.json 403 ]) 404 } 405 406 // MARK: - Static definitions of the state machine rules 407 408 /** 409 For a particular machine state, only certain events may be processed. 410 If the machine receives an unexpected event, an assertion failure will occur 411 and the app will crash. 412 */ 413 static let updatesStateAllowedEvents: [UpdatesStateValue: Set<UpdatesStateEventType>] = [ 414 .idle: [.check, .download, .restart], 415 .checking: [.checkCompleteAvailable, .checkCompleteUnavailable, .checkError], 416 .downloading: [.downloadComplete, .downloadError], 417 .restarting: [] 418 ] 419 420 /** 421 For this state machine, each event has only one destination state that the 422 machine will transition to. 423 */ 424 static let updatesStateTransitions: [UpdatesStateEventType: UpdatesStateValue] = [ 425 .check: .checking, 426 .checkCompleteAvailable: .idle, 427 .checkCompleteUnavailable: .idle, 428 .checkError: .idle, 429 .download: .downloading, 430 .downloadComplete: .idle, 431 .downloadError: .idle, 432 .restart: .restarting 433 ] 434 } 435 436 // swiftlint:enable no_grouping_extension 437 // swiftlint:enable type_name 438