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