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