@hot-updater/react-native 1.0.0-rc.4 → 1.0.0-rc.41

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (317) hide show
  1. package/android/build.gradle +13 -2
  2. package/android/proguard-rules.pro +8 -2
  3. package/android/src/bridge/BridgeReactInstance.kt +38 -0
  4. package/android/src/bridgeless/BridgeReactInstance.kt +24 -0
  5. package/android/src/main/java/com/hotupdater/ActivityStartGate.kt +94 -0
  6. package/android/src/main/java/com/hotupdater/BuiltInAssetResolver.kt +286 -0
  7. package/android/src/main/java/com/hotupdater/BundleFileStorageService.kt +1064 -844
  8. package/android/src/main/java/com/hotupdater/BundleMetadata.kt +50 -11
  9. package/android/src/main/java/com/hotupdater/FileManagerService.kt +45 -0
  10. package/android/src/main/java/com/hotupdater/HotUpdaterException.kt +287 -22
  11. package/android/src/main/java/com/hotupdater/HotUpdaterImpl.kt +46 -30
  12. package/android/src/main/java/com/hotupdater/HotUpdaterRecoveryManager.kt +75 -192
  13. package/android/src/main/java/com/hotupdater/HotUpdaterRecoveryState.kt +64 -0
  14. package/android/src/main/java/com/hotupdater/InstallIdentityService.kt +37 -0
  15. package/android/src/main/java/com/hotupdater/KeyValueStorageService.kt +86 -0
  16. package/android/src/main/java/com/hotupdater/NetworkState.kt +31 -0
  17. package/android/src/main/java/com/hotupdater/NoBackupStorage.kt +17 -0
  18. package/android/src/main/java/com/hotupdater/OkHttpDownloadService.kt +127 -50
  19. package/android/src/main/java/com/hotupdater/ReactNativeValueConverters.kt +34 -0
  20. package/android/src/main/java/com/hotupdater/TarArchiveInputStream.kt +214 -375
  21. package/android/src/main/java/com/hotupdater/TarBrArchiveExtractor.kt +119 -0
  22. package/android/src/newarch/HotUpdater.kt +7 -9
  23. package/android/src/newarch/HotUpdaterModule.kt +109 -84
  24. package/android/src/newarch/JavaScriptExceptionHooks.kt +48 -0
  25. package/android/src/newarch/ReactIntegrationManager.kt +6 -32
  26. package/android/src/oldarch/HotUpdater.kt +7 -9
  27. package/android/src/oldarch/HotUpdaterModule.kt +98 -74
  28. package/android/src/oldarch/HotUpdaterSpec.kt +4 -6
  29. package/android/src/oldarch/JavaScriptExceptionHooks.kt +71 -0
  30. package/ios/HotUpdater/Internal/BrotliFileDecompressor.swift +167 -0
  31. package/ios/HotUpdater/Internal/BuiltInAssetResolver.swift +196 -0
  32. package/ios/HotUpdater/Internal/BundleFileStorageService.swift +1032 -1116
  33. package/ios/HotUpdater/Internal/BundleMetadata.swift +61 -54
  34. package/ios/HotUpdater/Internal/FileManagerService.swift +48 -0
  35. package/ios/HotUpdater/Internal/FileUtilities.swift +64 -0
  36. package/ios/HotUpdater/Internal/HotUpdater-Bridging-Header.h +0 -1
  37. package/ios/HotUpdater/Internal/HotUpdater.mm +32 -67
  38. package/ios/HotUpdater/Internal/HotUpdaterImpl.swift +229 -73
  39. package/ios/HotUpdater/Internal/KeyValueStorageService.swift +71 -0
  40. package/ios/HotUpdater/Internal/NotificationExtension.swift +0 -2
  41. package/ios/HotUpdater/Internal/TarArchiveExtractor.swift +182 -311
  42. package/ios/HotUpdater/Internal/URLSessionDownloadService.swift +119 -71
  43. package/ios/HotUpdater/Internal/UpdateFailure.swift +390 -0
  44. package/ios/HotUpdater/Recovery/HotUpdaterRecovery.h +16 -0
  45. package/ios/HotUpdater/Recovery/HotUpdaterRecovery.mm +83 -0
  46. package/ios/HotUpdater/Recovery/HotUpdaterRecoveryHooks.mm +49 -0
  47. package/lib/commonjs/appReady.js +96 -0
  48. package/lib/commonjs/appReady.js.map +1 -0
  49. package/lib/commonjs/checkForUpdate.js +121 -69
  50. package/lib/commonjs/checkForUpdate.js.map +1 -1
  51. package/lib/commonjs/clientPlugin.js +13 -0
  52. package/lib/commonjs/clientPlugin.js.map +1 -0
  53. package/lib/commonjs/error.js +5 -14
  54. package/lib/commonjs/error.js.map +1 -1
  55. package/lib/commonjs/fetchJSON.js +16 -27
  56. package/lib/commonjs/fetchJSON.js.map +1 -1
  57. package/lib/commonjs/fetchUpdateResponse.js +60 -0
  58. package/lib/commonjs/fetchUpdateResponse.js.map +1 -0
  59. package/lib/commonjs/httpClient.js +64 -95
  60. package/lib/commonjs/httpClient.js.map +1 -1
  61. package/lib/commonjs/index.js +355 -324
  62. package/lib/commonjs/index.js.map +1 -1
  63. package/lib/commonjs/init.types.js +6 -0
  64. package/lib/commonjs/init.types.js.map +1 -0
  65. package/lib/commonjs/native.js +77 -63
  66. package/lib/commonjs/native.js.map +1 -1
  67. package/lib/commonjs/pluginHost.js +41 -0
  68. package/lib/commonjs/pluginHost.js.map +1 -0
  69. package/lib/commonjs/releaseCatalogCache.js +36 -87
  70. package/lib/commonjs/releaseCatalogCache.js.map +1 -1
  71. package/lib/commonjs/sdkVersion.js +1 -1
  72. package/lib/commonjs/sdkVersion.js.map +1 -1
  73. package/lib/commonjs/specs/NativeHotUpdater.js.map +1 -1
  74. package/lib/commonjs/store.js +3 -10
  75. package/lib/commonjs/store.js.map +1 -1
  76. package/lib/commonjs/updateError.js +145 -0
  77. package/lib/commonjs/updateError.js.map +1 -0
  78. package/lib/commonjs/wrap.js +46 -38
  79. package/lib/commonjs/wrap.js.map +1 -1
  80. package/lib/module/appReady.js +90 -0
  81. package/lib/module/appReady.js.map +1 -0
  82. package/lib/module/checkForUpdate.js +114 -64
  83. package/lib/module/checkForUpdate.js.map +1 -1
  84. package/lib/module/clientPlugin.js +4 -0
  85. package/lib/module/clientPlugin.js.map +1 -0
  86. package/lib/module/error.js +5 -14
  87. package/lib/module/error.js.map +1 -1
  88. package/lib/module/fetchJSON.js +14 -26
  89. package/lib/module/fetchJSON.js.map +1 -1
  90. package/lib/module/fetchUpdateResponse.js +55 -0
  91. package/lib/module/fetchUpdateResponse.js.map +1 -0
  92. package/lib/module/httpClient.js +63 -94
  93. package/lib/module/httpClient.js.map +1 -1
  94. package/lib/module/index.js +335 -327
  95. package/lib/module/index.js.map +1 -1
  96. package/lib/module/init.types.js +4 -0
  97. package/lib/module/init.types.js.map +1 -0
  98. package/lib/module/native.js +70 -56
  99. package/lib/module/native.js.map +1 -1
  100. package/lib/module/pluginHost.js +36 -0
  101. package/lib/module/pluginHost.js.map +1 -0
  102. package/lib/module/releaseCatalogCache.js +32 -82
  103. package/lib/module/releaseCatalogCache.js.map +1 -1
  104. package/lib/module/sdkVersion.js +1 -1
  105. package/lib/module/sdkVersion.js.map +1 -1
  106. package/lib/module/specs/NativeHotUpdater.js.map +1 -1
  107. package/lib/module/store.js +3 -10
  108. package/lib/module/store.js.map +1 -1
  109. package/lib/module/updateError.js +139 -0
  110. package/lib/module/updateError.js.map +1 -0
  111. package/lib/module/wrap.js +47 -38
  112. package/lib/module/wrap.js.map +1 -1
  113. package/lib/typescript/commonjs/appReady.d.ts +30 -0
  114. package/lib/typescript/commonjs/appReady.d.ts.map +1 -0
  115. package/lib/typescript/commonjs/checkForUpdate.d.ts +20 -6
  116. package/lib/typescript/commonjs/checkForUpdate.d.ts.map +1 -1
  117. package/lib/typescript/commonjs/clientPlugin.d.ts +2 -0
  118. package/lib/typescript/commonjs/clientPlugin.d.ts.map +1 -0
  119. package/lib/typescript/commonjs/error.d.ts +5 -14
  120. package/lib/typescript/commonjs/error.d.ts.map +1 -1
  121. package/lib/typescript/commonjs/fetchJSON.d.ts +5 -5
  122. package/lib/typescript/commonjs/fetchJSON.d.ts.map +1 -1
  123. package/lib/typescript/commonjs/fetchUpdateResponse.d.ts +14 -0
  124. package/lib/typescript/commonjs/fetchUpdateResponse.d.ts.map +1 -0
  125. package/lib/typescript/commonjs/httpClient.d.ts +5 -34
  126. package/lib/typescript/commonjs/httpClient.d.ts.map +1 -1
  127. package/lib/typescript/commonjs/index.d.ts +124 -116
  128. package/lib/typescript/commonjs/index.d.ts.map +1 -1
  129. package/lib/typescript/commonjs/init.types.d.ts +21 -0
  130. package/lib/typescript/commonjs/init.types.d.ts.map +1 -0
  131. package/lib/typescript/commonjs/native.d.ts +32 -25
  132. package/lib/typescript/commonjs/native.d.ts.map +1 -1
  133. package/lib/typescript/commonjs/pluginHost.d.ts +8 -0
  134. package/lib/typescript/commonjs/pluginHost.d.ts.map +1 -0
  135. package/lib/typescript/commonjs/releaseCatalogCache.d.ts +8 -14
  136. package/lib/typescript/commonjs/releaseCatalogCache.d.ts.map +1 -1
  137. package/lib/typescript/commonjs/sdkVersion.d.ts +1 -1
  138. package/lib/typescript/commonjs/sdkVersion.d.ts.map +1 -1
  139. package/lib/typescript/commonjs/specs/NativeHotUpdater.d.ts +35 -47
  140. package/lib/typescript/commonjs/specs/NativeHotUpdater.d.ts.map +1 -1
  141. package/lib/typescript/commonjs/store.d.ts +1 -3
  142. package/lib/typescript/commonjs/store.d.ts.map +1 -1
  143. package/lib/typescript/commonjs/types.d.ts +1 -1
  144. package/lib/typescript/commonjs/types.d.ts.map +1 -1
  145. package/lib/typescript/commonjs/updateError.d.ts +26 -0
  146. package/lib/typescript/commonjs/updateError.d.ts.map +1 -0
  147. package/lib/typescript/commonjs/wrap.d.ts +2 -3
  148. package/lib/typescript/commonjs/wrap.d.ts.map +1 -1
  149. package/lib/typescript/commonjs/wrap.types.d.ts +13 -41
  150. package/lib/typescript/commonjs/wrap.types.d.ts.map +1 -1
  151. package/lib/typescript/module/appReady.d.ts +30 -0
  152. package/lib/typescript/module/appReady.d.ts.map +1 -0
  153. package/lib/typescript/module/checkForUpdate.d.ts +20 -6
  154. package/lib/typescript/module/checkForUpdate.d.ts.map +1 -1
  155. package/lib/typescript/module/clientPlugin.d.ts +2 -0
  156. package/lib/typescript/module/clientPlugin.d.ts.map +1 -0
  157. package/lib/typescript/module/error.d.ts +5 -14
  158. package/lib/typescript/module/error.d.ts.map +1 -1
  159. package/lib/typescript/module/fetchJSON.d.ts +5 -5
  160. package/lib/typescript/module/fetchJSON.d.ts.map +1 -1
  161. package/lib/typescript/module/fetchUpdateResponse.d.ts +14 -0
  162. package/lib/typescript/module/fetchUpdateResponse.d.ts.map +1 -0
  163. package/lib/typescript/module/httpClient.d.ts +5 -34
  164. package/lib/typescript/module/httpClient.d.ts.map +1 -1
  165. package/lib/typescript/module/index.d.ts +124 -116
  166. package/lib/typescript/module/index.d.ts.map +1 -1
  167. package/lib/typescript/module/init.types.d.ts +21 -0
  168. package/lib/typescript/module/init.types.d.ts.map +1 -0
  169. package/lib/typescript/module/native.d.ts +32 -25
  170. package/lib/typescript/module/native.d.ts.map +1 -1
  171. package/lib/typescript/module/pluginHost.d.ts +8 -0
  172. package/lib/typescript/module/pluginHost.d.ts.map +1 -0
  173. package/lib/typescript/module/releaseCatalogCache.d.ts +8 -14
  174. package/lib/typescript/module/releaseCatalogCache.d.ts.map +1 -1
  175. package/lib/typescript/module/sdkVersion.d.ts +1 -1
  176. package/lib/typescript/module/sdkVersion.d.ts.map +1 -1
  177. package/lib/typescript/module/specs/NativeHotUpdater.d.ts +35 -47
  178. package/lib/typescript/module/specs/NativeHotUpdater.d.ts.map +1 -1
  179. package/lib/typescript/module/store.d.ts +1 -3
  180. package/lib/typescript/module/store.d.ts.map +1 -1
  181. package/lib/typescript/module/types.d.ts +1 -1
  182. package/lib/typescript/module/types.d.ts.map +1 -1
  183. package/lib/typescript/module/updateError.d.ts +26 -0
  184. package/lib/typescript/module/updateError.d.ts.map +1 -0
  185. package/lib/typescript/module/wrap.d.ts +2 -3
  186. package/lib/typescript/module/wrap.d.ts.map +1 -1
  187. package/lib/typescript/module/wrap.types.d.ts +13 -41
  188. package/lib/typescript/module/wrap.types.d.ts.map +1 -1
  189. package/package.json +8 -4
  190. package/src/appReady.ts +147 -0
  191. package/src/checkForUpdate.ts +156 -89
  192. package/src/clientPlugin.ts +21 -0
  193. package/src/error.ts +5 -15
  194. package/src/fetchJSON.ts +14 -30
  195. package/src/fetchUpdateResponse.ts +60 -0
  196. package/src/httpClient.ts +89 -160
  197. package/src/index.ts +413 -425
  198. package/src/init.types.ts +24 -0
  199. package/src/native.ts +101 -105
  200. package/src/pluginHost.ts +52 -0
  201. package/src/releaseCatalogCache.ts +43 -172
  202. package/src/sdkVersion.ts +1 -1
  203. package/src/specs/NativeHotUpdater.ts +34 -48
  204. package/src/store.ts +2 -23
  205. package/src/types.ts +1 -1
  206. package/src/updateError.ts +167 -0
  207. package/src/wrap.tsx +60 -43
  208. package/src/wrap.types.ts +18 -46
  209. package/android/src/main/java/com/hotupdater/DecompressService.kt +0 -89
  210. package/android/src/main/java/com/hotupdater/DecompressionStrategy.kt +0 -26
  211. package/android/src/main/java/com/hotupdater/TarBrDecompressionStrategy.kt +0 -117
  212. package/android/src/main/java/com/hotupdater/TarGzDecompressionStrategy.kt +0 -117
  213. package/android/src/main/java/com/hotupdater/ZipDecompressionStrategy.kt +0 -192
  214. package/android/src/test/java/com/hotupdater/BsdiffPatchTest.kt +0 -46
  215. package/android/src/test/java/com/hotupdater/BundleFileStorageServiceTest.kt +0 -799
  216. package/android/src/test/java/com/hotupdater/HotUpdaterImplTest.kt +0 -273
  217. package/android/src/test/java/com/hotupdater/NativeConfigUtilsTest.kt +0 -20
  218. package/android/src/test/java/com/hotupdater/OkHttpDownloadServiceTest.kt +0 -111
  219. package/android/src/test/java/com/hotupdater/RelativePathResolverTest.kt +0 -41
  220. package/android/src/test/java/com/hotupdater/ReleaseCatalogCacheServiceTest.kt +0 -118
  221. package/android/src/test/java/com/hotupdater/TarArchiveInputStreamTest.kt +0 -68
  222. package/ios/HotUpdater/Internal/ArchiveExtractionUtilities.swift +0 -178
  223. package/ios/HotUpdater/Internal/DecompressService.swift +0 -112
  224. package/ios/HotUpdater/Internal/DecompressionStrategy.swift +0 -22
  225. package/ios/HotUpdater/Internal/StreamingTarArchiveExtractor.swift +0 -370
  226. package/ios/HotUpdater/Internal/TarBrDecompressionStrategy.swift +0 -39
  227. package/ios/HotUpdater/Internal/TarGzDecompressionStrategy.swift +0 -59
  228. package/ios/HotUpdater/Internal/ZipArchiveExtractor.swift +0 -482
  229. package/ios/HotUpdater/Internal/ZipDecompressionStrategy.swift +0 -56
  230. package/lib/commonjs/catalogCacheNative.spec.js +0 -31
  231. package/lib/commonjs/catalogCacheNative.spec.js.map +0 -1
  232. package/lib/commonjs/checkForUpdate.releaseCatalog.spec.js +0 -616
  233. package/lib/commonjs/checkForUpdate.releaseCatalog.spec.js.map +0 -1
  234. package/lib/commonjs/httpClient.spec.js +0 -102
  235. package/lib/commonjs/httpClient.spec.js.map +0 -1
  236. package/lib/commonjs/index.spec.js +0 -302
  237. package/lib/commonjs/index.spec.js.map +0 -1
  238. package/lib/commonjs/iosInterop.spec.js +0 -18
  239. package/lib/commonjs/iosInterop.spec.js.map +0 -1
  240. package/lib/commonjs/native.spec.js +0 -883
  241. package/lib/commonjs/native.spec.js.map +0 -1
  242. package/lib/commonjs/notifyAppReadyInsights.boundary.spec.js +0 -218
  243. package/lib/commonjs/notifyAppReadyInsights.boundary.spec.js.map +0 -1
  244. package/lib/commonjs/notifyAppReadyInsights.js +0 -133
  245. package/lib/commonjs/notifyAppReadyInsights.js.map +0 -1
  246. package/lib/commonjs/notifyAppReadyInsights.spec.js +0 -246
  247. package/lib/commonjs/notifyAppReadyInsights.spec.js.map +0 -1
  248. package/lib/commonjs/notifyAppReadyInsights.test-utils.js +0 -24
  249. package/lib/commonjs/notifyAppReadyInsights.test-utils.js.map +0 -1
  250. package/lib/commonjs/notifyAppReadyInsights.transport.spec.js +0 -117
  251. package/lib/commonjs/notifyAppReadyInsights.transport.spec.js.map +0 -1
  252. package/lib/commonjs/releaseCatalogCache.spec.js +0 -195
  253. package/lib/commonjs/releaseCatalogCache.spec.js.map +0 -1
  254. package/lib/commonjs/releaseCatalogCacheNativeContract.spec.js +0 -64
  255. package/lib/commonjs/releaseCatalogCacheNativeContract.spec.js.map +0 -1
  256. package/lib/commonjs/store.notification.spec.js +0 -76
  257. package/lib/commonjs/store.notification.spec.js.map +0 -1
  258. package/lib/commonjs/store.spec.js +0 -316
  259. package/lib/commonjs/store.spec.js.map +0 -1
  260. package/lib/commonjs/wrap.spec.js +0 -329
  261. package/lib/commonjs/wrap.spec.js.map +0 -1
  262. package/lib/module/catalogCacheNative.spec.js +0 -31
  263. package/lib/module/catalogCacheNative.spec.js.map +0 -1
  264. package/lib/module/checkForUpdate.releaseCatalog.spec.js +0 -615
  265. package/lib/module/checkForUpdate.releaseCatalog.spec.js.map +0 -1
  266. package/lib/module/httpClient.spec.js +0 -102
  267. package/lib/module/httpClient.spec.js.map +0 -1
  268. package/lib/module/index.spec.js +0 -301
  269. package/lib/module/index.spec.js.map +0 -1
  270. package/lib/module/iosInterop.spec.js +0 -18
  271. package/lib/module/iosInterop.spec.js.map +0 -1
  272. package/lib/module/native.spec.js +0 -882
  273. package/lib/module/native.spec.js.map +0 -1
  274. package/lib/module/notifyAppReadyInsights.boundary.spec.js +0 -217
  275. package/lib/module/notifyAppReadyInsights.boundary.spec.js.map +0 -1
  276. package/lib/module/notifyAppReadyInsights.js +0 -128
  277. package/lib/module/notifyAppReadyInsights.js.map +0 -1
  278. package/lib/module/notifyAppReadyInsights.spec.js +0 -245
  279. package/lib/module/notifyAppReadyInsights.spec.js.map +0 -1
  280. package/lib/module/notifyAppReadyInsights.test-utils.js +0 -18
  281. package/lib/module/notifyAppReadyInsights.test-utils.js.map +0 -1
  282. package/lib/module/notifyAppReadyInsights.transport.spec.js +0 -116
  283. package/lib/module/notifyAppReadyInsights.transport.spec.js.map +0 -1
  284. package/lib/module/releaseCatalogCache.spec.js +0 -195
  285. package/lib/module/releaseCatalogCache.spec.js.map +0 -1
  286. package/lib/module/releaseCatalogCacheNativeContract.spec.js +0 -64
  287. package/lib/module/releaseCatalogCacheNativeContract.spec.js.map +0 -1
  288. package/lib/module/store.notification.spec.js +0 -77
  289. package/lib/module/store.notification.spec.js.map +0 -1
  290. package/lib/module/store.spec.js +0 -315
  291. package/lib/module/store.spec.js.map +0 -1
  292. package/lib/module/wrap.spec.js +0 -330
  293. package/lib/module/wrap.spec.js.map +0 -1
  294. package/lib/typescript/commonjs/notifyAppReadyInsights.d.ts +0 -13
  295. package/lib/typescript/commonjs/notifyAppReadyInsights.d.ts.map +0 -1
  296. package/lib/typescript/commonjs/notifyAppReadyInsights.test-utils.d.ts +0 -8
  297. package/lib/typescript/commonjs/notifyAppReadyInsights.test-utils.d.ts.map +0 -1
  298. package/lib/typescript/module/notifyAppReadyInsights.d.ts +0 -13
  299. package/lib/typescript/module/notifyAppReadyInsights.d.ts.map +0 -1
  300. package/lib/typescript/module/notifyAppReadyInsights.test-utils.d.ts +0 -8
  301. package/lib/typescript/module/notifyAppReadyInsights.test-utils.d.ts.map +0 -1
  302. package/src/catalogCacheNative.spec.ts +0 -63
  303. package/src/checkForUpdate.releaseCatalog.spec.ts +0 -612
  304. package/src/httpClient.spec.ts +0 -132
  305. package/src/index.spec.ts +0 -337
  306. package/src/iosInterop.spec.ts +0 -30
  307. package/src/native.spec.ts +0 -945
  308. package/src/notifyAppReadyInsights.boundary.spec.ts +0 -234
  309. package/src/notifyAppReadyInsights.spec.ts +0 -243
  310. package/src/notifyAppReadyInsights.test-utils.ts +0 -31
  311. package/src/notifyAppReadyInsights.transport.spec.ts +0 -130
  312. package/src/notifyAppReadyInsights.ts +0 -194
  313. package/src/releaseCatalogCache.spec.ts +0 -282
  314. package/src/releaseCatalogCacheNativeContract.spec.ts +0 -80
  315. package/src/store.notification.spec.ts +0 -92
  316. package/src/store.spec.ts +0 -372
  317. package/src/wrap.spec.ts +0 -406
@@ -52,27 +52,28 @@ public struct BsdiffPatchDescriptor {
52
52
  public let baseFileHash: String
53
53
  public let patchFileHash: String
54
54
  public let patchUrl: URL
55
+ public let byteSize: Int64?
55
56
 
56
57
  public init(
57
58
  algorithm: String,
58
59
  baseBundleId: String,
59
60
  baseFileHash: String,
60
61
  patchFileHash: String,
61
- patchUrl: URL
62
+ patchUrl: URL,
63
+ byteSize: Int64? = nil
62
64
  ) {
63
65
  self.algorithm = algorithm
64
66
  self.baseBundleId = baseBundleId
65
67
  self.baseFileHash = baseFileHash
66
68
  self.patchFileHash = patchFileHash
67
69
  self.patchUrl = patchUrl
70
+ self.byteSize = byteSize
68
71
  }
69
72
  }
70
73
 
71
74
  public struct UpdateProgressPayload {
72
75
  public let progress: Double
73
76
  public let artifactType: String
74
- public let downloadedBytes: Int64?
75
- public let totalBytes: Int64?
76
77
  public let details: DiffProgressDetails?
77
78
 
78
79
  public struct DiffProgressFileSnapshot {
@@ -147,30 +148,47 @@ public struct UpdateProgressPayload {
147
148
  public init(
148
149
  progress: Double,
149
150
  artifactType: String,
150
- downloadedBytes: Int64? = nil,
151
- totalBytes: Int64? = nil,
152
151
  details: DiffProgressDetails? = nil
153
152
  ) {
154
153
  self.progress = progress
155
154
  self.artifactType = artifactType
156
- self.downloadedBytes = downloadedBytes
157
- self.totalBytes = totalBytes
158
155
  self.details = details
159
156
  }
160
157
 
161
158
  public var userInfo: [String: Any] {
162
- var info: [String: Any] = [
159
+ [
163
160
  "artifactType": artifactType,
164
161
  "progress": progress,
165
162
  "details": details?.userInfo ?? NSNull()
166
163
  ]
167
- if let downloadedBytes {
168
- info["downloadedBytes"] = downloadedBytes
169
- }
170
- if let totalBytes {
171
- info["totalBytes"] = totalBytes
172
- }
173
- return info
164
+ }
165
+ }
166
+
167
+ /// How a staged bundle arrived. `updateBundle` resolves with `dictionary`.
168
+ public struct UpdateDelivery: Equatable {
169
+ public enum Method: String {
170
+ /// A bsdiff patch produced at least one file.
171
+ case patch
172
+ /// Only changed files were downloaded, possibly none.
173
+ case manifest
174
+ /// The full tar.br archive was downloaded.
175
+ case archive
176
+ }
177
+
178
+ public let method: Method
179
+ /// A file's patch was tried, but the full file was downloaded instead.
180
+ public let patchFallback: Bool
181
+
182
+ public init(method: Method, patchFallback: Bool) {
183
+ self.method = method
184
+ self.patchFallback = patchFallback
185
+ }
186
+
187
+ public var dictionary: [String: Any] {
188
+ [
189
+ "delivery": method.rawValue,
190
+ "patchFallback": patchFallback,
191
+ ]
174
192
  }
175
193
  }
176
194
 
@@ -178,9 +196,7 @@ public enum BundleStorageError: Error, CustomNSError {
178
196
  case directoryCreationFailed
179
197
  case downloadFailed(Error)
180
198
  case incompleteDownload(expected: Int64, actual: Int64)
181
- case extractionFormatError(Error)
182
199
  case invalidBundle
183
- case insufficientDiskSpace
184
200
  case signatureVerificationFailed(SignatureVerificationError)
185
201
  case moveOperationFailed(Error)
186
202
  case bundleInCrashedHistory(String)
@@ -200,9 +216,7 @@ public enum BundleStorageError: Error, CustomNSError {
200
216
  case .directoryCreationFailed: return "DIRECTORY_CREATION_FAILED"
201
217
  case .downloadFailed: return "DOWNLOAD_FAILED"
202
218
  case .incompleteDownload: return "INCOMPLETE_DOWNLOAD"
203
- case .extractionFormatError: return "EXTRACTION_FORMAT_ERROR"
204
219
  case .invalidBundle: return "INVALID_BUNDLE"
205
- case .insufficientDiskSpace: return "INSUFFICIENT_DISK_SPACE"
206
220
  case .signatureVerificationFailed: return "SIGNATURE_VERIFICATION_FAILED"
207
221
  case .moveOperationFailed: return "MOVE_OPERATION_FAILED"
208
222
  case .bundleInCrashedHistory: return "BUNDLE_IN_CRASHED_HISTORY"
@@ -227,19 +241,10 @@ public enum BundleStorageError: Error, CustomNSError {
227
241
  userInfo[NSLocalizedDescriptionKey] = "Download incomplete: received \(actual) bytes, expected \(expected) bytes"
228
242
  userInfo[NSLocalizedRecoverySuggestionErrorKey] = "The download was interrupted. Check network connection and try again"
229
243
 
230
- case .extractionFormatError(let underlyingError):
231
- userInfo[NSLocalizedDescriptionKey] = "The downloaded bundle file is not a valid compressed archive"
232
- userInfo[NSUnderlyingErrorKey] = underlyingError
233
- userInfo[NSLocalizedRecoverySuggestionErrorKey] = "The downloaded file is not a supported bundle archive. Try downloading again"
234
-
235
244
  case .invalidBundle:
236
245
  userInfo[NSLocalizedDescriptionKey] = "Bundle missing required platform files (index.ios.bundle or main.jsbundle)"
237
246
  userInfo[NSLocalizedRecoverySuggestionErrorKey] = "Verify the bundle was built correctly with metro bundler"
238
247
 
239
- case .insufficientDiskSpace:
240
- userInfo[NSLocalizedDescriptionKey] = "Insufficient disk space to download and extract bundle"
241
- userInfo[NSLocalizedRecoverySuggestionErrorKey] = "Free up device storage and try again"
242
-
243
248
  case .signatureVerificationFailed(let underlyingError):
244
249
  userInfo[NSLocalizedDescriptionKey] = "Bundle signature verification failed"
245
250
  userInfo[NSUnderlyingErrorKey] = underlyingError
@@ -280,7 +285,7 @@ public protocol BundleStorageService {
280
285
  func prepareLaunch(bundle: Bundle, pendingRecovery: PendingCrashRecovery?) -> LaunchSelection
281
286
 
282
287
  // Bundle update
283
- func updateBundle(bundleId: String, fileUrl: URL?, fileHash: String?, manifestUrl: URL?, manifestFileHash: String?, changedAssets: [String: ChangedAssetDescriptor]?, progressHandler: @escaping (UpdateProgressPayload) -> Void, completion: @escaping (Result<Bool, Error>) -> Void)
288
+ func updateBundle(bundleId: String, manifestUrl: URL, manifestFileHash: String, archiveUrl: URL?, assets: [String: ChangedAssetDescriptor], progressHandler: @escaping (UpdateProgressPayload) -> Void, completion: @escaping (Result<UpdateDelivery, Error>) -> Void)
284
289
  func stageReleaseSelection(_ selection: PersistedSelection) -> Bool
285
290
  func acceptReleaseCatalog(catalogId: String, scopeKey: String, generation: Int64, catalogHash: String, channel: String, selectionContextHash: String) -> Bool
286
291
  func getActiveUpdateState() -> [String: Any]
@@ -288,15 +293,13 @@ public protocol BundleStorageService {
288
293
  func commitReleaseSelection(_ selection: PersistedSelection) -> Bool
289
294
 
290
295
  // Rollback support
296
+ func markLaunchStarted(bundleId: String?)
291
297
  func markLaunchCompleted(bundleId: String?)
292
298
  func notifyAppReady() -> [String: Any]
293
299
  func getCrashHistory() -> CrashedHistory
294
300
  func clearCrashHistory() -> Bool
295
301
  func getInstallId() -> String
296
- func getUserId() -> String?
297
- func getUsername() -> String?
298
- func setUser(userId: String?, username: String?)
299
-
302
+
300
303
  /**
301
304
  * Gets the base URL for the current active bundle directory
302
305
  * @return Base URL string (e.g., "file:///data/.../bundle-store/abc123") or empty string
@@ -306,7 +309,7 @@ public protocol BundleStorageService {
306
309
 
307
310
  /**
308
311
  * Gets the current active bundle ID from bundle storage.
309
- * Reads manifest.json first and falls back to older metadata when needed.
312
+ * Reads the required OTA manifest.json.
310
313
  */
311
314
  func getBundleId() -> String?
312
315
 
@@ -332,6 +335,8 @@ public extension BundleStorageService {
332
335
  }
333
336
 
334
337
  class BundleFileStorageService: BundleStorageService {
338
+ private static let maximumSafeByteSize: Int64 = 9_007_199_254_740_991
339
+
335
340
  private struct ActiveBundleMetadataSnapshot {
336
341
  let activeBundleId: String
337
342
  let bundleId: String?
@@ -341,80 +346,59 @@ class BundleFileStorageService: BundleStorageService {
341
346
  private struct ParsedBundleManifest {
342
347
  let bundleId: String
343
348
  let assets: [String: ParsedManifestAsset]
349
+ let archive: ParsedArchive?
344
350
  }
345
351
 
346
352
  private struct ParsedManifestAsset {
347
353
  let fileHash: String
348
354
  let signature: String?
355
+ let byteSize: Int64?
356
+ let downloadByteSize: Int64?
349
357
  }
350
358
 
351
- private enum UpdateProgress {
352
- static let downloadEnd = 0.7
353
- static let verificationStart = 0.72
354
- static let verificationEnd = 0.82
355
- static let extractionStart = 0.82
356
- static let extractionEnd = 0.97
357
- static let bundleValidation = 0.98
358
- static let activationReady = 0.99
359
- static let complete = 1.0
359
+ private struct ParsedArchive {
360
+ let downloadFileHash: String
361
+ let downloadByteSize: Int64
362
+ let tarByteSize: Int64
360
363
  }
361
364
 
362
365
  private let fileSystem: FileSystemService
363
366
  private let downloadService: DownloadService
364
- private let decompressService: DecompressService
367
+ /// The wait before a download's second attempt; it doubles for each later one.
368
+ private let downloadRetryDelay: TimeInterval
365
369
  private let preferences: PreferencesService
366
370
  private let isolationKey: String
367
371
 
368
- private let id = Int.random(in: 1..<100)
369
-
370
372
  // Queue for potentially long-running sequences within updateBundle or for explicit background tasks.
371
373
  private let fileOperationQueue: DispatchQueue
372
374
 
373
- private var activeTasks: [URLSessionTask] = []
374
-
375
+ private var hasPreparedLaunch = false
375
376
  private var currentLaunchReport: LaunchReport?
377
+ // The staged bundle this process launched before its first content. Only that
378
+ // launch is pending; a bundle staged later waits for the next launch.
379
+ private var pendingLaunchBundleId: String?
380
+ // Crash history is written at first content on the main thread while JavaScript can read it.
381
+ private let crashedHistoryLock = NSLock()
382
+ // A bundle waiting for its retry that this process keeps refusing, see InterruptedLaunch.
383
+ private var retryHeldBundleId: String?
376
384
  private let activeBundleMetadataLock = NSLock()
377
385
  private var activeBundleMetadataSnapshot: ActiveBundleMetadataSnapshot?
378
386
  private let builtInBundleIdProvider: () -> String
387
+ private let builtInAssetResolver: BuiltInAssetResolver
379
388
  private let updateStrategyProvider: () -> UpdateStrategy
380
389
  private let releaseStateLock = NSRecursiveLock()
381
390
  private var pendingInstallSelections: [String: PersistedSelection] = [:]
382
391
 
392
+ private let installIdentityLock = NSLock()
383
393
  private var currentInstallIdentity: InstallIdentity?
384
- private var currentUserIdentity: UserIdentity?
385
394
 
386
395
  private let protectedBundleStoreEntries: Set<String> = [
387
396
  BundleMetadata.metadataFilename,
388
397
  CrashedHistory.crashedHistoryFilename,
389
398
  LaunchReport.launchReportFilename,
390
- InstallIdentity.installIdentityFilename,
391
- UserIdentity.userIdentityFilename,
399
+ "builtin-index-v1.json",
392
400
  ]
393
401
 
394
- private func normalizeIdentityValue(_ value: String?) -> String? {
395
- guard let trimmed = value?.trimmingCharacters(in: .whitespacesAndNewlines),
396
- !trimmed.isEmpty else {
397
- return nil
398
- }
399
- return trimmed
400
- }
401
-
402
- private func emitArchiveProgress(
403
- progressHandler: @escaping (UpdateProgressPayload) -> Void,
404
- progress: Double,
405
- downloadedBytes: Int64? = nil,
406
- totalBytes: Int64? = nil
407
- ) {
408
- progressHandler(
409
- UpdateProgressPayload(
410
- progress: max(0, min(progress, 1)),
411
- artifactType: "archive",
412
- downloadedBytes: downloadedBytes,
413
- totalBytes: totalBytes
414
- )
415
- )
416
- }
417
-
418
402
  private func createDiffProgressFiles(
419
403
  changedAssets: [String: ChangedAssetDescriptor]
420
404
  ) -> [UpdateProgressPayload.DiffProgressFileSnapshot] {
@@ -576,10 +560,7 @@ class BundleFileStorageService: BundleStorageService {
576
560
  return
577
561
  }
578
562
 
579
- try StreamingTarArchiveExtractor.decompressBrotliFile(
580
- from: downloadedPath,
581
- to: destinationPath
582
- )
563
+ try BrotliFileDecompressor.decompress(from: downloadedPath, to: destinationPath)
583
564
  try? fileSystem.removeItem(atPath: downloadedPath)
584
565
  }
585
566
 
@@ -604,7 +585,7 @@ class BundleFileStorageService: BundleStorageService {
604
585
 
605
586
  let sourcePath: String
606
587
  do {
607
- sourcePath = try ArchiveExtractionUtilities.extractionURL(
588
+ sourcePath = try FileUtilities.fileURL(
608
589
  for: assetPath,
609
590
  destinationRoot: currentBundleDir
610
591
  ).path
@@ -722,6 +703,14 @@ class BundleFileStorageService: BundleStorageService {
722
703
  atPath path: String,
723
704
  asset: ParsedManifestAsset
724
705
  ) throws {
706
+ if let expectedByteSize = asset.byteSize {
707
+ let attributes = try fileSystem.attributesOfItem(atPath: path)
708
+ guard let size = attributes[.size] as? NSNumber,
709
+ size.int64Value == expectedByteSize else {
710
+ throw BundleStorageError.signatureVerificationFailed(.fileHashMismatch)
711
+ }
712
+ }
713
+
725
714
  guard let actualHash = HashUtils.calculateSHA256(fileURL: URL(fileURLWithPath: path)) else {
726
715
  throw BundleStorageError.signatureVerificationFailed(.fileReadFailed)
727
716
  }
@@ -751,22 +740,28 @@ class BundleFileStorageService: BundleStorageService {
751
740
 
752
741
  public init(fileSystem: FileSystemService,
753
742
  downloadService: DownloadService,
754
- decompressService: DecompressService,
755
743
  preferences: PreferencesService,
756
744
  isolationKey: String,
757
745
  builtInBundleIdProvider: @escaping () -> String = { "" },
746
+ builtInAssetResolver: BuiltInAssetResolver? = nil,
758
747
  updateStrategyProvider: @escaping () -> UpdateStrategy = {
759
748
  let fingerprintHash = HotUpdaterConfig.shared.fingerprintHash
760
749
  ?? Bundle.main.object(forInfoDictionaryKey: "HOT_UPDATER_FINGERPRINT_HASH") as? String
761
750
  return (fingerprintHash?.isEmpty == false) ? .fingerprint : .appVersion
762
- }) {
751
+ },
752
+ downloadRetryDelay: TimeInterval = 1) {
763
753
 
764
754
  self.fileSystem = fileSystem
755
+ self.downloadRetryDelay = downloadRetryDelay
765
756
  self.downloadService = downloadService
766
- self.decompressService = decompressService
767
757
  self.preferences = preferences
768
758
  self.isolationKey = isolationKey
769
759
  self.builtInBundleIdProvider = builtInBundleIdProvider
760
+ self.builtInAssetResolver = builtInAssetResolver ?? IOSBuiltInAssetResolver(
761
+ cacheURL: URL(fileURLWithPath: fileSystem.documentsPath())
762
+ .appendingPathComponent("bundle-store", isDirectory: true)
763
+ .appendingPathComponent("builtin-index-v1.json")
764
+ )
770
765
  self.updateStrategyProvider = updateStrategyProvider
771
766
 
772
767
  // Create queue for file operations
@@ -779,6 +774,7 @@ class BundleFileStorageService: BundleStorageService {
779
774
 
780
775
  // Clean up old bundles if isolationKey format changed
781
776
  checkAndCleanupIfIsolationKeyChanged()
777
+ recoverInterruptedPromotions()
782
778
  }
783
779
 
784
780
  // MARK: - Metadata File Paths
@@ -804,18 +800,9 @@ class BundleFileStorageService: BundleStorageService {
804
800
  return URL(fileURLWithPath: storeDir).appendingPathComponent(LaunchReport.launchReportFilename)
805
801
  }
806
802
 
807
- private func installIdentityFileURL() -> URL? {
808
- guard case .success(let storeDir) = bundleStoreDir() else {
809
- return nil
810
- }
811
- return URL(fileURLWithPath: storeDir).appendingPathComponent(InstallIdentity.installIdentityFilename)
812
- }
813
-
814
- private func userIdentityFileURL() -> URL? {
815
- guard case .success(let storeDir) = bundleStoreDir() else {
816
- return nil
817
- }
818
- return URL(fileURLWithPath: storeDir).appendingPathComponent(UserIdentity.userIdentityFilename)
803
+ private func installIdentityFileURL() -> URL {
804
+ NoBackupStorage.directory(in: fileSystem)
805
+ .appendingPathComponent(InstallIdentity.installIdentityFilename)
819
806
  }
820
807
 
821
808
  // MARK: - Metadata Operations
@@ -864,54 +851,28 @@ class BundleFileStorageService: BundleStorageService {
864
851
  _ = report.save(to: file)
865
852
  }
866
853
 
867
- private func loadInstallIdentity() -> InstallIdentity? {
854
+ /// Loads the install identity, creating it once when its file is missing.
855
+ @discardableResult
856
+ private func loadOrCreateInstallIdentity() -> InstallIdentity {
857
+ installIdentityLock.lock()
858
+ defer { installIdentityLock.unlock() }
859
+
868
860
  if let currentInstallIdentity {
869
861
  return currentInstallIdentity
870
862
  }
871
- guard let file = installIdentityFileURL(),
872
- let identity = InstallIdentity.load(from: file) else {
873
- return nil
863
+ let file = installIdentityFileURL()
864
+ if let identity = InstallIdentity.load(from: file) {
865
+ currentInstallIdentity = identity
866
+ return identity
874
867
  }
875
- currentInstallIdentity = identity
876
- return identity
877
- }
878
868
 
879
- private func saveInstallIdentity(_ identity: InstallIdentity) -> Bool {
869
+ let identity = InstallIdentity()
870
+ // Keep the new id for this process even when it cannot be saved.
880
871
  currentInstallIdentity = identity
881
- guard let file = installIdentityFileURL() else {
882
- return false
883
- }
884
- return identity.save(to: file)
885
- }
886
-
887
- private func loadUserIdentity() -> UserIdentity? {
888
- if let currentUserIdentity {
889
- return currentUserIdentity
890
- }
891
- guard let file = userIdentityFileURL(),
892
- let identity = UserIdentity.load(from: file) else {
893
- return nil
894
- }
895
- currentUserIdentity = identity
872
+ _ = identity.save(to: file)
896
873
  return identity
897
874
  }
898
875
 
899
- private func saveUserIdentity(_ identity: UserIdentity?) -> Bool {
900
- currentUserIdentity = identity
901
- guard let file = userIdentityFileURL() else {
902
- return false
903
- }
904
-
905
- guard let identity else {
906
- if FileManager.default.fileExists(atPath: file.path) {
907
- try? FileManager.default.removeItem(at: file)
908
- }
909
- return true
910
- }
911
-
912
- return identity.save(to: file)
913
- }
914
-
915
876
  private func resolveBuiltInBundleId() -> String? {
916
877
  let bundleId = builtInBundleIdProvider().trimmingCharacters(in: .whitespacesAndNewlines)
917
878
  return bundleId.isEmpty ? nil : bundleId
@@ -938,11 +899,10 @@ class BundleFileStorageService: BundleStorageService {
938
899
  }
939
900
 
940
901
  private func createInitialMetadata() -> BundleMetadata {
941
- let currentBundleId = getCachedBundleId()
942
902
  return BundleMetadata(
943
903
  isolationKey: isolationKey,
944
904
  stableBundleId: nil,
945
- stagingBundleId: currentBundleId,
905
+ stagingBundleId: nil,
946
906
  verificationPending: false,
947
907
  pendingTransition: nil
948
908
  )
@@ -956,18 +916,18 @@ class BundleFileStorageService: BundleStorageService {
956
916
  }
957
917
 
958
918
  private func getActiveBundleId() -> String? {
959
- if let cachedBundleId = getCachedBundleId() {
919
+ guard let metadata = loadMetadataOrNull() else { return nil }
920
+ if let cachedBundleId = getCachedBundleId(),
921
+ cachedBundleId == metadata.stagingBundleId || cachedBundleId == metadata.stableBundleId {
960
922
  return cachedBundleId
961
923
  }
962
924
 
963
- let metadata = loadMetadataOrNull()
964
-
965
- if let stagingBundleId = metadata?.stagingBundleId,
966
- metadata?.verificationPending == false {
925
+ if let stagingBundleId = metadata.stagingBundleId,
926
+ metadata.verificationPending == false {
967
927
  return stagingBundleId
968
928
  }
969
929
 
970
- if let stableBundleId = metadata?.stableBundleId {
930
+ if let stableBundleId = metadata.stableBundleId {
971
931
  return stableBundleId
972
932
  }
973
933
 
@@ -1017,22 +977,6 @@ class BundleFileStorageService: BundleStorageService {
1017
977
  }
1018
978
  }
1019
979
 
1020
- func canUseManifestDrivenInstall() -> Bool {
1021
- guard let currentBundleId = getCachedBundleId(),
1022
- case .success(let storeDir) = bundleStoreDir() else {
1023
- return false
1024
- }
1025
-
1026
- let currentBundleDir = (storeDir as NSString).appendingPathComponent(currentBundleId)
1027
- guard fileSystem.fileExists(atPath: currentBundleDir),
1028
- let snapshot = getActiveBundleMetadataSnapshot(),
1029
- let currentManifest = parseBundleManifest(from: snapshot.manifest) else {
1030
- return false
1031
- }
1032
-
1033
- return currentManifest.assets.isEmpty == false
1034
- }
1035
-
1036
980
  private func resolveActiveBundleMetadataSnapshot(
1037
981
  activeBundleId: String,
1038
982
  bundleDirectory: String
@@ -1041,9 +985,7 @@ class BundleFileStorageService: BundleStorageService {
1041
985
  let manifestBundleId =
1042
986
  (manifest["bundleId"] as? String)?
1043
987
  .trimmingCharacters(in: .whitespacesAndNewlines)
1044
- let resolvedBundleId =
1045
- (manifestBundleId?.isEmpty == false ? manifestBundleId : nil) ??
1046
- readCompatibilityBundleId(in: bundleDirectory)
988
+ let resolvedBundleId = manifestBundleId?.isEmpty == false ? manifestBundleId : nil
1047
989
 
1048
990
  return ActiveBundleMetadataSnapshot(
1049
991
  activeBundleId: activeBundleId,
@@ -1070,34 +1012,6 @@ class BundleFileStorageService: BundleStorageService {
1070
1012
  )
1071
1013
  }
1072
1014
 
1073
- private func readCompatibilityBundleId(in bundleDirectory: String) -> String? {
1074
- let compatibilityBundleIdPath = (bundleDirectory as NSString)
1075
- .appendingPathComponent(compatibilityBundleIdFilename())
1076
- if fileSystem.fileExists(atPath: compatibilityBundleIdPath) {
1077
- do {
1078
- let compatibilityBundleId = try String(
1079
- contentsOfFile: compatibilityBundleIdPath,
1080
- encoding: .utf8
1081
- )
1082
- .trimmingCharacters(in: .whitespacesAndNewlines)
1083
-
1084
- if !compatibilityBundleId.isEmpty {
1085
- return compatibilityBundleId
1086
- }
1087
- } catch {
1088
- NSLog(
1089
- "[BundleStorage] Failed to read compatibility bundle metadata at \(compatibilityBundleIdPath): \(error.localizedDescription)"
1090
- )
1091
- }
1092
- }
1093
-
1094
- return nil
1095
- }
1096
-
1097
- private func compatibilityBundleIdFilename() -> String {
1098
- "BUNDLE_ID"
1099
- }
1100
-
1101
1015
  private func readManifest(in bundleDirectory: String) -> [String: Any]? {
1102
1016
  let manifestPath = (bundleDirectory as NSString).appendingPathComponent("manifest.json")
1103
1017
  guard fileSystem.fileExists(atPath: manifestPath) else {
@@ -1124,7 +1038,7 @@ class BundleFileStorageService: BundleStorageService {
1124
1038
 
1125
1039
  var assets: [String: ParsedManifestAsset] = [:]
1126
1040
  for (assetPath, assetValue) in rawAssets {
1127
- guard ArchiveExtractionUtilities.normalizedRelativePath(from: assetPath) == assetPath else {
1041
+ guard FileUtilities.normalizedRelativePath(from: assetPath) == assetPath else {
1128
1042
  return nil
1129
1043
  }
1130
1044
  guard let asset = assetValue as? [String: Any],
@@ -1137,42 +1051,136 @@ class BundleFileStorageService: BundleStorageService {
1137
1051
  .trimmingCharacters(in: .whitespacesAndNewlines)
1138
1052
  assets[assetPath] = ParsedManifestAsset(
1139
1053
  fileHash: fileHash,
1140
- signature: signature?.isEmpty == false ? signature : nil
1054
+ signature: signature?.isEmpty == false ? signature : nil,
1055
+ byteSize: Self.parseSafeByteSize(asset["byteSize"]),
1056
+ downloadByteSize: Self.parseSafeByteSize(asset["downloadByteSize"])
1057
+ )
1058
+ }
1059
+
1060
+ let archive: ParsedArchive?
1061
+ if let payload = manifest["archive"] as? [String: Any],
1062
+ let downloadFileHash = payload["downloadFileHash"] as? String,
1063
+ Self.isPlainSHA256(downloadFileHash),
1064
+ let downloadByteSize = Self.parseSafeByteSize(payload["downloadByteSize"]),
1065
+ downloadByteSize > 0,
1066
+ let tarByteSize = Self.parseSafeByteSize(payload["tarByteSize"]),
1067
+ tarByteSize >= 1024 {
1068
+ archive = ParsedArchive(
1069
+ downloadFileHash: downloadFileHash,
1070
+ downloadByteSize: downloadByteSize,
1071
+ tarByteSize: tarByteSize
1141
1072
  )
1073
+ } else {
1074
+ archive = nil
1142
1075
  }
1143
1076
 
1144
- return ParsedBundleManifest(bundleId: manifestBundleId, assets: assets)
1077
+ return ParsedBundleManifest(
1078
+ bundleId: manifestBundleId,
1079
+ assets: assets,
1080
+ archive: archive
1081
+ )
1145
1082
  }
1146
1083
 
1147
- private func parseBundleManifest(fromFile manifestPath: String) -> ParsedBundleManifest? {
1148
- guard let manifest = readManifest(in: (manifestPath as NSString).deletingLastPathComponent) else {
1084
+ private static func parseSafeByteSize(_ value: Any?) -> Int64? {
1085
+ guard let number = value as? NSNumber,
1086
+ String(cString: number.objCType) != "c" else {
1087
+ return nil
1088
+ }
1089
+ let doubleValue = number.doubleValue
1090
+ guard doubleValue.isFinite,
1091
+ doubleValue >= 0,
1092
+ doubleValue <= Double(maximumSafeByteSize),
1093
+ doubleValue.rounded(.towardZero) == doubleValue else {
1149
1094
  return nil
1150
1095
  }
1096
+ return Int64(doubleValue)
1097
+ }
1151
1098
 
1152
- return parseBundleManifest(from: manifest)
1099
+ private static func isPlainSHA256(_ value: String) -> Bool {
1100
+ value.count == 64 && value.allSatisfy {
1101
+ ("0"..."9").contains($0) || ("a"..."f").contains($0)
1102
+ }
1153
1103
  }
1154
1104
 
1155
- private func writeManifestFile(_ manifest: ParsedBundleManifest, to destination: String) throws {
1105
+ private func copyVerifiedManifest(from source: String, to destination: String) throws {
1156
1106
  let manifestDirectory = (destination as NSString).deletingLastPathComponent
1157
1107
  guard fileSystem.createDirectory(atPath: manifestDirectory) else {
1158
1108
  throw BundleStorageError.directoryCreationFailed
1159
1109
  }
1110
+ if fileSystem.fileExists(atPath: destination) {
1111
+ try fileSystem.removeItem(atPath: destination)
1112
+ }
1113
+ do {
1114
+ try fileSystem.copyItem(atPath: source, toPath: destination)
1115
+ } catch {
1116
+ throw BundleStorageError.moveOperationFailed(error)
1117
+ }
1118
+ }
1119
+
1120
+ private func shouldUseArchive(
1121
+ archive: ParsedArchive?,
1122
+ archiveUrl: URL?,
1123
+ downloads: [String: ChangedAssetDescriptor],
1124
+ assets: [String: ParsedManifestAsset]
1125
+ ) -> Bool {
1126
+ guard downloads.count >= 2,
1127
+ archive != nil,
1128
+ archiveUrl != nil,
1129
+ assets.values.allSatisfy({ $0.byteSize != nil }) else {
1130
+ return false
1131
+ }
1160
1132
 
1161
- let assets = manifest.assets
1162
- .sorted { $0.key < $1.key }
1163
- .reduce(into: [String: [String: String]]()) { partialResult, entry in
1164
- var assetPayload = ["fileHash": entry.value.fileHash]
1165
- if let signature = entry.value.signature, !signature.isEmpty {
1166
- assetPayload["signature"] = signature
1133
+ var individualLowerBound: Int64 = 0
1134
+ var hasOfferedPatch = false
1135
+ for (path, descriptor) in downloads {
1136
+ guard let originalByteSize = assets[path]?.downloadByteSize else {
1137
+ return false
1138
+ }
1139
+ let plannedByteSize: Int64
1140
+ if let patch = descriptor.patch {
1141
+ hasOfferedPatch = true
1142
+ guard let patchByteSize = patch.byteSize,
1143
+ patchByteSize >= 0,
1144
+ patchByteSize <= Self.maximumSafeByteSize else {
1145
+ return false
1167
1146
  }
1168
- partialResult[entry.key] = assetPayload
1147
+ plannedByteSize = min(originalByteSize, patchByteSize)
1148
+ } else {
1149
+ plannedByteSize = originalByteSize
1169
1150
  }
1170
- let payload: [String: Any] = [
1171
- "bundleId": manifest.bundleId,
1172
- "assets": assets,
1173
- ]
1174
- let data = try JSONSerialization.data(withJSONObject: payload, options: [.prettyPrinted])
1175
- try data.write(to: URL(fileURLWithPath: destination))
1151
+ let (sum, overflowed) = individualLowerBound.addingReportingOverflow(plannedByteSize)
1152
+ guard !overflowed,
1153
+ sum <= Self.maximumSafeByteSize else { return false }
1154
+ individualLowerBound = sum
1155
+ }
1156
+
1157
+ guard let archive else { return false }
1158
+ var logicalByteSize: Int64 = 0
1159
+ for asset in assets.values {
1160
+ guard let byteSize = asset.byteSize else { return false }
1161
+ let (sum, overflowed) = logicalByteSize.addingReportingOverflow(byteSize)
1162
+ guard !overflowed,
1163
+ sum <= Self.maximumSafeByteSize else { return false }
1164
+ logicalByteSize = sum
1165
+ }
1166
+ let (tarOverhead, overheadUnderflowed) = archive.tarByteSize
1167
+ .subtractingReportingOverflow(logicalByteSize)
1168
+ guard !overheadUnderflowed,
1169
+ tarOverhead >= 0 else { return false }
1170
+
1171
+ if archive.downloadByteSize <= individualLowerBound {
1172
+ return true
1173
+ }
1174
+
1175
+ guard downloads.count == assets.count,
1176
+ !hasOfferedPatch else {
1177
+ return false
1178
+ }
1179
+ let (archiveBound, boundOverflowed) = individualLowerBound
1180
+ .addingReportingOverflow(tarOverhead)
1181
+ guard !boundOverflowed,
1182
+ archiveBound <= Self.maximumSafeByteSize else { return false }
1183
+ return archive.downloadByteSize <= archiveBound
1176
1184
  }
1177
1185
 
1178
1186
  /**
@@ -1308,8 +1316,10 @@ class BundleFileStorageService: BundleStorageService {
1308
1316
  )
1309
1317
  }
1310
1318
 
1319
+ /// Rolls back the pending staging bundle. `unfinishedLaunch` means its launch
1320
+ /// ended before first content without a crash marker.
1311
1321
  @discardableResult
1312
- private func rollbackPendingBundle(_ stagingId: String) -> Bool {
1322
+ private func rollbackPendingBundle(_ stagingId: String, unfinishedLaunch: Bool = false) -> Bool {
1313
1323
  guard var metadata = loadMetadataOrNull(), metadata.stagingBundleId == stagingId else {
1314
1324
  return false
1315
1325
  }
@@ -1318,9 +1328,7 @@ class BundleFileStorageService: BundleStorageService {
1318
1328
  let pendingSelectionTransition = metadata.pendingSelectionTransition
1319
1329
  let stableSelection = metadata.stableSelection
1320
1330
 
1321
- var crashedHistory = loadCrashedHistory()
1322
- crashedHistory.addEntry(stagingId)
1323
- let _ = saveCrashedHistory(crashedHistory)
1331
+ recordFailedLaunch(stagingId, unfinishedLaunch: unfinishedLaunch)
1324
1332
 
1325
1333
  let fallbackBundleId = metadata.stableBundleId.flatMap { candidate in
1326
1334
  if case .success(let storeDir) = bundleStoreDir() {
@@ -1539,6 +1547,7 @@ class BundleFileStorageService: BundleStorageService {
1539
1547
  metadata.stagingBundleId = nil
1540
1548
  metadata.stagingSelection = selection
1541
1549
  metadata.verificationPending = false
1550
+ metadata.launchInProgress = false
1542
1551
  metadata.pendingTransition = nil
1543
1552
  metadata.pendingSelectionTransition = nil
1544
1553
  }
@@ -1619,7 +1628,42 @@ class BundleFileStorageService: BundleStorageService {
1619
1628
  DispatchQueue.global(qos: .background).async(execute: workItem)
1620
1629
  }
1621
1630
 
1631
+ /**
1632
+ * Downloads `url`, trying again after a retryable failure (see
1633
+ * `DownloadError.isRetryable`) up to `DownloadError.maximumAttempts`
1634
+ * attempts in all, 1 and then 2 seconds apart, unless `retrying` is
1635
+ * false. The last attempt's result is returned, so a failure keeps what
1636
+ * classifies it. Blocks the calling thread.
1637
+ */
1622
1638
  private func downloadFileSynchronously(
1639
+ from url: URL,
1640
+ to destination: String,
1641
+ retrying: Bool = true,
1642
+ progressHandler: @escaping (DownloadProgress) -> Void
1643
+ ) -> Result<URL, Error> {
1644
+ var attempt = 1
1645
+ while true {
1646
+ let result = downloadFileOnce(
1647
+ from: url,
1648
+ to: destination,
1649
+ progressHandler: progressHandler
1650
+ )
1651
+ guard case .failure(let error) = result,
1652
+ retrying,
1653
+ attempt < DownloadError.maximumAttempts,
1654
+ DownloadError.isRetryable(error) else {
1655
+ return result
1656
+ }
1657
+ let delay = downloadRetryDelay * pow(2, Double(attempt - 1))
1658
+ NSLog("[BundleStorage] Download failed (attempt \(attempt)/\(DownloadError.maximumAttempts)): \(error.localizedDescription). Retrying in \(delay)s")
1659
+ if delay > 0 {
1660
+ Thread.sleep(forTimeInterval: delay)
1661
+ }
1662
+ attempt += 1
1663
+ }
1664
+ }
1665
+
1666
+ private func downloadFileOnce(
1623
1667
  from url: URL,
1624
1668
  to destination: String,
1625
1669
  progressHandler: @escaping (DownloadProgress) -> Void
@@ -1666,7 +1710,7 @@ class BundleFileStorageService: BundleStorageService {
1666
1710
  do {
1667
1711
  var bundlePaths: [String] = []
1668
1712
  for assetPath in parsedManifest.assets.keys {
1669
- let assetURL = try ArchiveExtractionUtilities.extractionURL(
1713
+ let assetURL = try FileUtilities.fileURL(
1670
1714
  for: assetPath,
1671
1715
  destinationRoot: directoryPath
1672
1716
  )
@@ -1688,38 +1732,7 @@ class BundleFileStorageService: BundleStorageService {
1688
1732
  }
1689
1733
  }
1690
1734
 
1691
- let iosBundlePath = (directoryPath as NSString).appendingPathComponent("index.ios.bundle")
1692
- if self.fileSystem.fileExists(atPath: iosBundlePath) {
1693
- NSLog("[BundleStorage] Found iOS bundle atPath: \(iosBundlePath)")
1694
- return .success(iosBundlePath)
1695
- }
1696
-
1697
- let mainBundlePath = (directoryPath as NSString).appendingPathComponent("main.jsbundle")
1698
- if self.fileSystem.fileExists(atPath: mainBundlePath) {
1699
- NSLog("[BundleStorage] Found main bundle atPath: \(mainBundlePath)")
1700
- return .success(mainBundlePath)
1701
- }
1702
-
1703
- do {
1704
- let contents = try self.fileSystem.contentsOfDirectory(atPath: directoryPath)
1705
- NSLog("[BundleStorage] Directory contents: \(contents)")
1706
-
1707
- // Additional search: check all .bundle files
1708
- for file in contents {
1709
- if file.hasSuffix(".bundle") {
1710
- let bundlePath = (directoryPath as NSString).appendingPathComponent(file)
1711
- NSLog("[BundleStorage] Found alternative bundle atPath: \(bundlePath)")
1712
- return .success(bundlePath)
1713
- }
1714
- }
1715
-
1716
- NSLog("[BundleStorage] No bundle file found in directory: \(directoryPath)")
1717
- NSLog("[BundleStorage] Available files: \(contents)")
1718
- return .success(nil)
1719
- } catch let error {
1720
- NSLog("[BundleStorage] Error reading directory contents: \(error.localizedDescription)")
1721
- return .failure(error)
1722
- }
1735
+ return .success(nil)
1723
1736
  }
1724
1737
 
1725
1738
  /**
@@ -1752,7 +1765,7 @@ class BundleFileStorageService: BundleStorageService {
1752
1765
  let bundles = contents.compactMap { item -> String? in
1753
1766
  let fullPath = (storeDir as NSString).appendingPathComponent(item)
1754
1767
 
1755
- if protectedBundleStoreEntries.contains(item) {
1768
+ if protectedBundleStoreEntries.contains(item) || item.hasSuffix(".install-backup") {
1756
1769
  return nil
1757
1770
  }
1758
1771
 
@@ -1864,11 +1877,11 @@ class BundleFileStorageService: BundleStorageService {
1864
1877
  }
1865
1878
 
1866
1879
  let relativePath = String(bundleURL.path.dropFirst(sourcePrefix.count))
1867
- guard ArchiveExtractionUtilities.normalizedRelativePath(from: relativePath) == relativePath else {
1880
+ guard FileUtilities.normalizedRelativePath(from: relativePath) == relativePath else {
1868
1881
  throw BundleStorageError.invalidBundle
1869
1882
  }
1870
1883
 
1871
- return try ArchiveExtractionUtilities.extractionURL(
1884
+ return try FileUtilities.fileURL(
1872
1885
  for: relativePath,
1873
1886
  destinationRoot: destinationDirectory
1874
1887
  ).path
@@ -1924,8 +1937,8 @@ class BundleFileStorageService: BundleStorageService {
1924
1937
  private func selectLaunch(bundle: Bundle) -> LaunchSelection {
1925
1938
  guard let metadata = loadMetadataOrNull() else {
1926
1939
  return LaunchSelection(
1927
- bundleURL: getCachedBundleURL() ?? getFallbackBundleURL(bundle: bundle),
1928
- launchedBundleId: getCachedBundleId(),
1940
+ bundleURL: getFallbackBundleURL(bundle: bundle),
1941
+ launchedBundleId: nil,
1929
1942
  shouldRollbackOnCrash: false
1930
1943
  )
1931
1944
  }
@@ -1972,13 +1985,24 @@ class BundleFileStorageService: BundleStorageService {
1972
1985
  }
1973
1986
 
1974
1987
  func prepareLaunch(bundle: Bundle, pendingRecovery: PendingCrashRecovery?) -> LaunchSelection {
1988
+ builtInAssetResolver.use(bundle: bundle)
1975
1989
  saveLaunchReport(nil)
1976
- if loadInstallIdentity() == nil {
1977
- let identity = InstallIdentity()
1978
- _ = saveInstallIdentity(identity)
1979
- }
1990
+ loadOrCreateInstallIdentity()
1980
1991
  applyPendingRecoveryIfNeeded(pendingRecovery)
1981
- return selectLaunch(bundle: bundle)
1992
+ // Only a new storage instance consumes an unfinished launch. Repeated bundle
1993
+ // lookups or bridge reloads in this process must not reject its own launch.
1994
+ if !hasPreparedLaunch,
1995
+ let metadata = loadMetadataOrNull(),
1996
+ metadata.verificationPending,
1997
+ metadata.launchInProgress,
1998
+ let stagingBundleId = metadata.stagingBundleId {
1999
+ rollbackPendingBundle(stagingBundleId, unfinishedLaunch: true)
2000
+ }
2001
+ hasPreparedLaunch = true
2002
+ // A launch is recorded only once it can show UI (markLaunchStarted).
2003
+ let selection = selectLaunch(bundle: bundle)
2004
+ pendingLaunchBundleId = selection.shouldRollbackOnCrash ? selection.launchedBundleId : nil
2005
+ return selection
1982
2006
  }
1983
2007
 
1984
2008
  // MARK: - Bundle Update
@@ -1986,943 +2010,805 @@ class BundleFileStorageService: BundleStorageService {
1986
2010
  /**
1987
2011
  * Updates the bundle from the specified URL. This operation is asynchronous.
1988
2012
  * @param bundleId ID of the bundle to update
1989
- * @param fileUrl URL of the bundle file to download (or nil to reset)
1990
- * @param fileHash Combined hash string for verification (sig:<signature> or <hex_hash>)
2013
+ * @param manifestUrl URL of the complete target manifest
2014
+ * @param manifestFileHash Hash or signature of the target manifest
1991
2015
  * @param progressHandler Callback for download and extraction progress (0.0 to 1.0)
1992
2016
  * @param completion Callback with result of the operation
1993
2017
  */
1994
- func updateBundle(bundleId: String, fileUrl: URL?, fileHash: String?, manifestUrl: URL?, manifestFileHash: String?, changedAssets: [String: ChangedAssetDescriptor]?, progressHandler: @escaping (UpdateProgressPayload) -> Void, completion: @escaping (Result<Bool, Error>) -> Void) {
2018
+ func updateBundle(bundleId: String, manifestUrl: URL, manifestFileHash: String, archiveUrl: URL? = nil, assets: [String: ChangedAssetDescriptor], progressHandler: @escaping (UpdateProgressPayload) -> Void, completion: @escaping (Result<UpdateDelivery, Error>) -> Void) {
1995
2019
  // Check if bundle is in crashed history
1996
- let crashedHistory = loadCrashedHistory()
1997
- if crashedHistory.contains(bundleId) {
2020
+ if getCrashHistory().contains(bundleId) {
1998
2021
  NSLog("[BundleStorage] Bundle '\(bundleId)' is in crashed history, rejecting update")
1999
2022
  completion(.failure(BundleStorageError.bundleInCrashedHistory(bundleId)))
2000
2023
  return
2001
2024
  }
2002
2025
 
2003
- // Get the current bundle ID from the cached bundle URL (exclude fallback bundles)
2004
- let currentBundleId = self.getCachedBundleId()
2005
-
2006
- guard let validFileUrl = fileUrl else {
2007
- NSLog("[BundleStorage] fileUrl is nil, resetting bundle URL.")
2008
- // Dispatch the sequence to the file operation queue to ensure completion is called asynchronously
2009
- // and to keep file operations off the calling thread if it's the main thread.
2010
- fileOperationQueue.async {
2011
- let setResult = self.setBundleURL(localPath: nil)
2012
- switch setResult {
2013
- case .success:
2014
- let previousMetadata = self.loadMetadataOrNull()
2015
- var resetMetadata = self.createInitialMetadata()
2016
- resetMetadata.highestSeenCatalogs = previousMetadata?.highestSeenCatalogs ?? [:]
2017
- resetMetadata.currentSelectionContexts = previousMetadata?.currentSelectionContexts ?? [:]
2018
- let _ = self.saveMetadata(resetMetadata)
2019
- self.saveLaunchReport(nil)
2020
- let cleanupResult = self.cleanupOldBundles(currentBundleId: currentBundleId, bundleId: bundleId)
2021
- switch cleanupResult {
2022
- case .success:
2023
- completion(.success(true))
2024
- case .failure(let error):
2025
- NSLog("[BundleStorage] Error during cleanup after reset: \(error)")
2026
- completion(.failure(error))
2027
- }
2028
- case .failure(let error):
2029
- NSLog("[BundleStorage] Error resetting bundle URL: \(error)")
2030
- completion(.failure(error))
2031
- }
2032
- }
2033
- return
2034
- }
2035
-
2036
- // Start the bundle update process on a background queue
2037
- fileOperationQueue.async {
2038
-
2026
+ fileOperationQueue.async(flags: .barrier) {
2039
2027
  let storeDirResult = self.bundleStoreDir()
2040
2028
  guard case .success(let storeDir) = storeDirResult else {
2041
- completion(.failure(storeDirResult.failureError ?? BundleStorageError.unknown(nil)))
2029
+ completion(.failure(UpdateFailureError.classifying(
2030
+ storeDirResult.failureError ?? BundleStorageError.unknown(nil),
2031
+ during: .storage
2032
+ )))
2042
2033
  return
2043
2034
  }
2044
-
2045
- let finalBundleDir = (storeDir as NSString).appendingPathComponent(bundleId)
2046
-
2047
- if self.fileSystem.fileExists(atPath: finalBundleDir) {
2048
- let findResult = self.findBundleFile(
2049
- in: finalBundleDir,
2050
- expectedBundleId: bundleId
2051
- )
2052
- switch findResult {
2053
- case .success(let existingBundlePath):
2054
- if let bundlePath = existingBundlePath {
2055
- NSLog("[BundleStorage] Using cached bundle at path: \(bundlePath)")
2056
- let currentMetadata = self.loadMetadataOrNull() ?? self.createInitialMetadata()
2057
- guard let updatedMetadata = self.prepareMetadataForNewStagingBundle(currentMetadata, bundleId: bundleId) else {
2058
- completion(.failure(BundleStorageError.unknown(
2059
- NSError(domain: "HotUpdater", code: 0, userInfo: [
2060
- NSLocalizedDescriptionKey: "Release catalog selection is stale"
2061
- ])
2062
- )))
2063
- return
2064
- }
2065
- let setResult = self.setBundleURL(localPath: bundlePath)
2066
- switch setResult {
2067
- case .success:
2068
- let _ = self.saveMetadata(updatedMetadata)
2069
- NSLog("[BundleStorage] Set staging bundle (cached): \(bundleId), verificationPending: true")
2070
-
2071
- self.emitArchiveProgress(
2072
- progressHandler: progressHandler,
2073
- progress: UpdateProgress.complete
2074
- )
2075
- self.scheduleCleanupOldBundles(
2076
- bundleIdsToKeep: [currentBundleId, updatedMetadata.stableBundleId, bundleId].compactMap { $0 }
2077
- )
2078
- completion(.success(true))
2079
- case .failure(let error):
2080
- completion(.failure(error))
2081
- }
2082
- return
2083
- } else {
2084
- NSLog("[BundleStorage] Cached directory exists but invalid, removing: \(finalBundleDir)")
2085
- do {
2086
- try self.fileSystem.removeItem(atPath: finalBundleDir)
2087
- // Continue with download process on success
2088
- if let manifestUrl,
2089
- let manifestFileHash,
2090
- let changedAssets,
2091
- self.canUseManifestDrivenInstall() {
2092
- self.updateBundleFromManifest(
2093
- bundleId: bundleId,
2094
- fileUrl: validFileUrl,
2095
- fileHash: fileHash,
2096
- manifestUrl: manifestUrl,
2097
- manifestFileHash: manifestFileHash,
2098
- changedAssets: changedAssets,
2099
- storeDir: storeDir,
2100
- progressHandler: progressHandler,
2101
- completion: completion
2102
- )
2103
- } else {
2104
- if manifestUrl != nil && manifestFileHash != nil && changedAssets != nil {
2105
- NSLog("[BundleStorage] Skipping manifest-driven install for \(bundleId) because no active OTA manifest is available. Using archive.")
2106
- }
2107
- self.prepareAndDownloadBundle(bundleId: bundleId, fileUrl: validFileUrl, fileHash: fileHash, storeDir: storeDir, progressHandler: progressHandler, completion: completion)
2108
- }
2109
- } catch let error {
2110
- NSLog("[BundleStorage] Failed to remove invalid bundle dir: \(error.localizedDescription)")
2111
- completion(.failure(BundleStorageError.unknown(error)))
2112
- }
2113
- }
2114
- case .failure(let error):
2115
- completion(.failure(error))
2116
- }
2117
- } else {
2118
- if let manifestUrl,
2119
- let manifestFileHash,
2120
- let changedAssets,
2121
- self.canUseManifestDrivenInstall() {
2122
- self.updateBundleFromManifest(
2123
- bundleId: bundleId,
2124
- fileUrl: validFileUrl,
2125
- fileHash: fileHash,
2126
- manifestUrl: manifestUrl,
2127
- manifestFileHash: manifestFileHash,
2128
- changedAssets: changedAssets,
2129
- storeDir: storeDir,
2130
- progressHandler: progressHandler,
2131
- completion: completion
2132
- )
2133
- } else {
2134
- if manifestUrl != nil && manifestFileHash != nil && changedAssets != nil {
2135
- NSLog("[BundleStorage] Skipping manifest-driven install for \(bundleId) because no active OTA manifest is available. Using archive.")
2136
- }
2137
- self.prepareAndDownloadBundle(bundleId: bundleId, fileUrl: validFileUrl, fileHash: fileHash, storeDir: storeDir, progressHandler: progressHandler, completion: completion)
2138
- }
2139
- }
2035
+ self.updateBundleFromManifest(
2036
+ bundleId: bundleId,
2037
+ manifestUrl: manifestUrl,
2038
+ manifestFileHash: manifestFileHash,
2039
+ archiveUrl: archiveUrl,
2040
+ changedAssets: assets,
2041
+ storeDir: storeDir,
2042
+ progressHandler: progressHandler,
2043
+ completion: completion
2044
+ )
2140
2045
  }
2141
2046
  }
2142
-
2143
- /**
2144
- * Prepares directories and starts the download process.
2145
- * This method is part of the asynchronous `updateBundle` flow.
2146
- * @param bundleId ID of the bundle to update
2147
- * @param fileUrl URL of the bundle file to download
2148
- * @param fileHash Combined hash string for verification (sig:<signature> or <hex_hash>)
2149
- * @param storeDir Path to the bundle-store directory
2150
- * @param progressHandler Callback for download and extraction progress
2151
- * @param completion Callback with result of the operation
2152
- */
2153
- private func updateBundleFromManifest(
2154
- bundleId: String,
2155
- fileUrl: URL,
2156
- fileHash: String?,
2157
- manifestUrl: URL,
2158
- manifestFileHash: String,
2159
- changedAssets: [String: ChangedAssetDescriptor],
2160
- storeDir: String,
2161
- progressHandler: @escaping (UpdateProgressPayload) -> Void,
2162
- completion: @escaping (Result<Bool, Error>) -> Void
2163
- ) {
2164
- let tempDirResult = tempDir()
2165
- guard case .success(let tempDirectory) = tempDirResult else {
2166
- completion(.failure(tempDirResult.failureError ?? BundleStorageError.unknown(nil)))
2167
- return
2168
- }
2169
2047
 
2170
- try? self.fileSystem.removeItem(atPath: tempDirectory)
2171
- guard self.fileSystem.createDirectory(atPath: tempDirectory) else {
2172
- completion(.failure(BundleStorageError.directoryCreationFailed))
2173
- return
2174
- }
2048
+ /// How the per-file install produced one file.
2049
+ private enum AssetInstallOutcome {
2050
+ case patched
2051
+ /// `patchFallback` is true when a patch for the running bundle was
2052
+ /// offered but the original file was downloaded instead.
2053
+ case downloaded(patchFallback: Bool)
2054
+ }
2175
2055
 
2176
- let currentBundleId = self.getCachedBundleId()
2177
- let currentBundleDir = currentBundleId.map {
2178
- (storeDir as NSString).appendingPathComponent($0)
2056
+ private func downloadManifestAsset(
2057
+ assetPath: String,
2058
+ expectedAsset: ParsedManifestAsset,
2059
+ changedAsset: ChangedAssetDescriptor,
2060
+ currentBundleId: String?,
2061
+ currentBundleDir: String?,
2062
+ destinationPath: String,
2063
+ tempDirectory: String,
2064
+ progressFile: UpdateProgressPayload.DiffProgressFileSnapshot,
2065
+ progressHandler: @escaping (UpdateProgressPayload) -> Void
2066
+ ) throws -> AssetInstallOutcome {
2067
+ let expectedHash = expectedAsset.fileHash
2068
+ var diffFiles = [progressFile]
2069
+ let patched = applyPatchAssetIfPossible(
2070
+ assetPath: assetPath,
2071
+ changedAsset: changedAsset,
2072
+ currentBundleId: currentBundleId,
2073
+ currentBundleDir: currentBundleDir,
2074
+ destinationPath: destinationPath,
2075
+ expectedHash: expectedHash,
2076
+ tempDirectory: tempDirectory,
2077
+ files: &diffFiles,
2078
+ progressHandler: progressHandler
2079
+ )
2080
+ if patched {
2081
+ // The patch produced these bytes, so a mismatch is a patch failure.
2082
+ try UpdateFailureError.during(.patch, resource: .patch) {
2083
+ try verifyManifestAssetFile(atPath: destinationPath, asset: expectedAsset)
2084
+ }
2085
+ updateDiffProgressFile(
2086
+ files: &diffFiles,
2087
+ assetPath: assetPath,
2088
+ status: "downloaded",
2089
+ progress: 1
2090
+ )
2091
+ self.emitDiffProgress(
2092
+ progressHandler: progressHandler,
2093
+ phase: diffFiles.allSatisfy { $0.status == "downloaded" }
2094
+ ? "finalizing"
2095
+ : "downloading",
2096
+ files: diffFiles
2097
+ )
2098
+ return .patched
2179
2099
  }
2180
- let tempManifestPath = (tempDirectory as NSString).appendingPathComponent("manifest.json")
2181
- let tmpDir = (storeDir as NSString).appendingPathComponent("\(bundleId).tmp")
2182
- let realDir = (storeDir as NSString).appendingPathComponent(bundleId)
2183
- var diffFiles = createDiffProgressFiles(changedAssets: changedAssets)
2184
2100
 
2185
- do {
2101
+ guard let changedAssetFileUrl = changedAsset.fileUrl else {
2102
+ updateDiffProgressFile(
2103
+ files: &diffFiles,
2104
+ assetPath: assetPath,
2105
+ status: "failed",
2106
+ progress: 0
2107
+ )
2186
2108
  self.emitDiffProgress(
2187
2109
  progressHandler: progressHandler,
2188
- phase: "manifest",
2189
- files: diffFiles,
2190
- manifestProgress: 0
2110
+ phase: "downloading",
2111
+ files: diffFiles
2191
2112
  )
2192
- switch self.downloadFileSynchronously(
2193
- from: manifestUrl,
2194
- to: tempManifestPath,
2195
- progressHandler: { progress in
2196
- self.emitDiffProgress(
2197
- progressHandler: progressHandler,
2198
- phase: "manifest",
2199
- files: diffFiles,
2200
- manifestProgress: progress.progress
2201
- )
2202
- }
2203
- ) {
2204
- case .success:
2205
- break
2206
- case .failure(let error):
2207
- if let downloadError = error as? DownloadError,
2208
- case .incompleteDownload(let expected, let actual) = downloadError {
2209
- throw BundleStorageError.incompleteDownload(expected: expected, actual: actual)
2210
- }
2211
- throw BundleStorageError.downloadFailed(error)
2212
- }
2213
-
2214
- let manifestVerificationResult = SignatureVerifier.verifyBundle(
2215
- fileURL: URL(fileURLWithPath: tempManifestPath),
2216
- fileHash: manifestFileHash
2113
+ // With no original file to fall back to, the patch decides the install.
2114
+ throw UpdateFailureError(
2115
+ underlying: BundleStorageError.downloadFailed(
2116
+ NSError(domain: "HotUpdater", code: 0, userInfo: [
2117
+ NSLocalizedDescriptionKey: "Changed asset fileUrl missing and patch could not be applied: \(assetPath)"
2118
+ ])
2119
+ ),
2120
+ failure: UpdateFailure.patch.with(resource: .patch)
2217
2121
  )
2218
- guard case .success = manifestVerificationResult else {
2219
- if case .failure(let error) = manifestVerificationResult {
2220
- throw BundleStorageError.signatureVerificationFailed(error)
2221
- }
2222
- throw BundleStorageError.unknown(nil)
2223
- }
2122
+ }
2224
2123
 
2225
- guard let manifestData = try? Data(contentsOf: URL(fileURLWithPath: tempManifestPath)),
2226
- let manifestJson = try JSONSerialization.jsonObject(with: manifestData) as? [String: Any],
2227
- let targetManifest = parseBundleManifest(from: manifestJson),
2228
- targetManifest.bundleId == bundleId
2229
- else {
2230
- throw BundleStorageError.invalidBundle
2231
- }
2232
- self.emitDiffProgress(
2233
- progressHandler: progressHandler,
2234
- phase: diffFiles.isEmpty ? "finalizing" : "downloading",
2235
- files: diffFiles
2124
+ let changedAssetDownloadPath = downloadPath(
2125
+ assetPath: assetPath,
2126
+ changedAsset: changedAsset
2127
+ )
2128
+ let downloadDestinationPath: String
2129
+ if changedAsset.fileCompression == "br" {
2130
+ downloadDestinationPath = compressedTempPath(
2131
+ tempDirectory: tempDirectory,
2132
+ assetPath: assetPath,
2133
+ compression: "br"
2236
2134
  )
2135
+ } else {
2136
+ downloadDestinationPath = destinationPath
2137
+ }
2237
2138
 
2238
- let currentManifest = getActiveBundleMetadataSnapshot().flatMap { snapshot in
2239
- parseBundleManifest(from: snapshot.manifest)
2240
- }
2241
-
2242
- if self.fileSystem.fileExists(atPath: tmpDir) {
2243
- try self.fileSystem.removeItem(atPath: tmpDir)
2244
- }
2245
- guard self.fileSystem.createDirectory(atPath: tmpDir) else {
2246
- throw BundleStorageError.directoryCreationFailed
2247
- }
2248
-
2249
- let sortedAssets = targetManifest.assets.sorted { $0.key < $1.key }
2250
-
2251
- for asset in sortedAssets {
2252
- let assetPath = asset.key
2253
- let expectedAsset = asset.value
2254
- let expectedHash = expectedAsset.fileHash
2255
- let destinationPath = try ArchiveExtractionUtilities.extractionURL(
2256
- for: assetPath,
2257
- destinationRoot: tmpDir
2258
- ).path
2259
- let destinationDir = (destinationPath as NSString).deletingLastPathComponent
2260
- guard self.fileSystem.createDirectory(atPath: destinationDir) else {
2261
- throw BundleStorageError.directoryCreationFailed
2262
- }
2263
-
2264
- if currentManifest?.assets[assetPath]?.fileHash == expectedHash {
2265
- guard let currentBundleDir,
2266
- self.fileSystem.fileExists(atPath: currentBundleDir)
2267
- else {
2268
- throw BundleStorageError.downloadFailed(
2269
- NSError(domain: "HotUpdater", code: 0, userInfo: [
2270
- NSLocalizedDescriptionKey: "Current bundle directory unavailable for reused asset: \(assetPath)"
2271
- ])
2272
- )
2273
- }
2274
-
2275
- let sourcePath = try ArchiveExtractionUtilities.extractionURL(
2276
- for: assetPath,
2277
- destinationRoot: currentBundleDir
2278
- ).path
2279
- guard self.fileSystem.fileExists(atPath: sourcePath),
2280
- HashUtils.verifyHash(fileURL: URL(fileURLWithPath: sourcePath), expectedHash: expectedHash)
2281
- else {
2282
- throw BundleStorageError.signatureVerificationFailed(.fileHashMismatch)
2283
- }
2284
-
2285
- try self.fileSystem.copyItem(atPath: sourcePath, toPath: destinationPath)
2286
- try verifyManifestAssetFile(atPath: destinationPath, asset: expectedAsset)
2287
- continue
2288
- }
2289
-
2290
- guard let changedAsset = changedAssets[assetPath] else {
2291
- updateDiffProgressFile(
2292
- files: &diffFiles,
2293
- assetPath: assetPath,
2294
- status: "failed",
2295
- progress: 0
2296
- )
2297
- self.emitDiffProgress(
2298
- progressHandler: progressHandler,
2299
- phase: "downloading",
2300
- files: diffFiles
2301
- )
2302
- throw BundleStorageError.downloadFailed(
2303
- NSError(domain: "HotUpdater", code: 0, userInfo: [
2304
- NSLocalizedDescriptionKey: "Changed asset missing from update response: \(assetPath)"
2305
- ])
2306
- )
2307
- }
2308
-
2309
- guard changedAsset.fileHash.caseInsensitiveCompare(expectedHash) == .orderedSame else {
2310
- updateDiffProgressFile(
2311
- files: &diffFiles,
2312
- assetPath: assetPath,
2313
- status: "failed",
2314
- progress: 0
2315
- )
2316
- self.emitDiffProgress(
2317
- progressHandler: progressHandler,
2318
- phase: "downloading",
2319
- files: diffFiles
2320
- )
2321
- throw BundleStorageError.signatureVerificationFailed(.fileHashMismatch)
2322
- }
2323
-
2324
- let patched = applyPatchAssetIfPossible(
2325
- assetPath: assetPath,
2326
- changedAsset: changedAsset,
2327
- currentBundleId: currentBundleId,
2328
- currentBundleDir: currentBundleDir,
2329
- destinationPath: destinationPath,
2330
- expectedHash: expectedHash,
2331
- tempDirectory: tempDirectory,
2139
+ switch self.downloadFileSynchronously(
2140
+ from: changedAssetFileUrl,
2141
+ to: downloadDestinationPath,
2142
+ progressHandler: { progress in
2143
+ self.updateDiffProgressFile(
2332
2144
  files: &diffFiles,
2333
- progressHandler: progressHandler
2145
+ assetPath: assetPath,
2146
+ status: "downloading",
2147
+ progress: progress.progress,
2148
+ downloadPath: changedAssetDownloadPath,
2149
+ downloadedBytes: progress.downloadedBytes,
2150
+ totalBytes: progress.totalBytes
2334
2151
  )
2335
- if patched {
2336
- try verifyManifestAssetFile(atPath: destinationPath, asset: expectedAsset)
2337
- updateDiffProgressFile(
2338
- files: &diffFiles,
2339
- assetPath: assetPath,
2340
- status: "downloaded",
2341
- progress: 1
2342
- )
2343
- self.emitDiffProgress(
2344
- progressHandler: progressHandler,
2345
- phase: diffFiles.allSatisfy { $0.status == "downloaded" }
2346
- ? "finalizing"
2347
- : "downloading",
2348
- files: diffFiles
2152
+ self.emitDiffProgress(
2153
+ progressHandler: progressHandler,
2154
+ phase: "downloading",
2155
+ files: diffFiles
2156
+ )
2157
+ }
2158
+ ) {
2159
+ case .success(let downloadedFileURL):
2160
+ do {
2161
+ try UpdateFailureError.during(.extraction, resource: .file) {
2162
+ try materializeDownloadedAsset(
2163
+ downloadedPath: downloadedFileURL.path,
2164
+ destinationPath: destinationPath,
2165
+ changedAsset: changedAsset
2349
2166
  )
2350
- continue
2351
2167
  }
2352
-
2353
- guard let changedAssetFileUrl = changedAsset.fileUrl else {
2354
- updateDiffProgressFile(
2355
- files: &diffFiles,
2356
- assetPath: assetPath,
2357
- status: "failed",
2358
- progress: 0
2359
- )
2360
- self.emitDiffProgress(
2361
- progressHandler: progressHandler,
2362
- phase: "downloading",
2363
- files: diffFiles
2364
- )
2365
- throw BundleStorageError.downloadFailed(
2366
- NSError(domain: "HotUpdater", code: 0, userInfo: [
2367
- NSLocalizedDescriptionKey: "Changed asset fileUrl missing and patch could not be applied: \(assetPath)"
2368
- ])
2168
+ // Compressed files are checked after decoding, so their
2169
+ // mismatch is an extraction failure.
2170
+ let verificationPhase: UpdatePhase =
2171
+ changedAsset.fileCompression == "br" ? .extraction : .verification
2172
+ try UpdateFailureError.during(verificationPhase, resource: .file) {
2173
+ try verifyManifestAssetFile(
2174
+ atPath: destinationPath,
2175
+ asset: expectedAsset
2369
2176
  )
2370
2177
  }
2371
-
2372
- let changedAssetDownloadPath = downloadPath(
2178
+ } catch {
2179
+ updateDiffProgressFile(
2180
+ files: &diffFiles,
2373
2181
  assetPath: assetPath,
2374
- changedAsset: changedAsset
2182
+ status: "failed",
2183
+ progress: 1
2375
2184
  )
2376
- let downloadDestinationPath: String
2377
- if changedAsset.fileCompression == "br" {
2378
- downloadDestinationPath = compressedTempPath(
2379
- tempDirectory: tempDirectory,
2380
- assetPath: assetPath,
2381
- compression: "br"
2382
- )
2383
- } else {
2384
- downloadDestinationPath = destinationPath
2385
- }
2386
-
2387
- switch self.downloadFileSynchronously(
2388
- from: changedAssetFileUrl,
2389
- to: downloadDestinationPath,
2390
- progressHandler: { progress in
2391
- self.updateDiffProgressFile(
2392
- files: &diffFiles,
2393
- assetPath: assetPath,
2394
- status: "downloading",
2395
- progress: progress.progress,
2396
- downloadPath: changedAssetDownloadPath,
2397
- downloadedBytes: progress.downloadedBytes,
2398
- totalBytes: progress.totalBytes
2399
- )
2400
- self.emitDiffProgress(
2401
- progressHandler: progressHandler,
2402
- phase: "downloading",
2403
- files: diffFiles
2404
- )
2405
- }
2406
- ) {
2407
- case .success(let downloadedFileURL):
2408
- do {
2409
- try materializeDownloadedAsset(
2410
- downloadedPath: downloadedFileURL.path,
2411
- destinationPath: destinationPath,
2412
- changedAsset: changedAsset
2413
- )
2414
- try verifyManifestAssetFile(
2415
- atPath: destinationPath,
2416
- asset: expectedAsset
2417
- )
2418
- } catch {
2419
- updateDiffProgressFile(
2420
- files: &diffFiles,
2421
- assetPath: assetPath,
2422
- status: "failed",
2423
- progress: 1
2424
- )
2425
- self.emitDiffProgress(
2426
- progressHandler: progressHandler,
2427
- phase: "downloading",
2428
- files: diffFiles
2429
- )
2430
- throw error
2431
- }
2432
- updateDiffProgressFile(
2433
- files: &diffFiles,
2434
- assetPath: assetPath,
2435
- status: "downloaded",
2436
- progress: 1
2437
- )
2438
- self.emitDiffProgress(
2439
- progressHandler: progressHandler,
2440
- phase: diffFiles.allSatisfy { $0.status == "downloaded" }
2441
- ? "finalizing"
2442
- : "downloading",
2443
- files: diffFiles
2444
- )
2445
- case .failure(let error):
2446
- let lastKnownProgress = diffFiles.first(where: { $0.path == assetPath })?.progress ?? 0
2447
- updateDiffProgressFile(
2448
- files: &diffFiles,
2449
- assetPath: assetPath,
2450
- status: "failed",
2451
- progress: lastKnownProgress
2452
- )
2453
- self.emitDiffProgress(
2454
- progressHandler: progressHandler,
2455
- phase: "downloading",
2456
- files: diffFiles
2457
- )
2458
- if let downloadError = error as? DownloadError,
2459
- case .incompleteDownload(let expected, let actual) = downloadError {
2460
- throw BundleStorageError.incompleteDownload(expected: expected, actual: actual)
2461
- }
2462
- throw BundleStorageError.downloadFailed(error)
2463
- }
2185
+ self.emitDiffProgress(
2186
+ progressHandler: progressHandler,
2187
+ phase: "downloading",
2188
+ files: diffFiles
2189
+ )
2190
+ throw error
2464
2191
  }
2465
-
2192
+ updateDiffProgressFile(
2193
+ files: &diffFiles,
2194
+ assetPath: assetPath,
2195
+ status: "downloaded",
2196
+ progress: 1
2197
+ )
2466
2198
  self.emitDiffProgress(
2467
2199
  progressHandler: progressHandler,
2468
- phase: "finalizing",
2200
+ phase: diffFiles.allSatisfy { $0.status == "downloaded" }
2201
+ ? "finalizing"
2202
+ : "downloading",
2469
2203
  files: diffFiles
2470
2204
  )
2471
-
2472
- let manifestDestination = (tmpDir as NSString).appendingPathComponent("manifest.json")
2473
- try writeManifestFile(targetManifest, to: manifestDestination)
2474
-
2475
- switch self.findBundleFile(in: tmpDir, expectedBundleId: bundleId) {
2476
- case .success(let maybeBundlePath):
2477
- guard let bundlePathInTmp = maybeBundlePath else {
2478
- throw BundleStorageError.invalidBundle
2479
- }
2480
-
2481
- if self.fileSystem.fileExists(atPath: realDir) {
2482
- try self.fileSystem.removeItem(atPath: realDir)
2483
- }
2484
-
2485
- do {
2486
- try self.fileSystem.moveItem(atPath: tmpDir, toPath: realDir)
2487
- } catch {
2488
- throw BundleStorageError.moveOperationFailed(error)
2489
- }
2490
-
2491
- let finalBundlePath = try resolveBundlePathAfterMove(
2492
- bundlePathInTmp,
2493
- from: tmpDir,
2494
- to: realDir
2495
- )
2496
- let currentMetadata = self.loadMetadataOrNull() ?? self.createInitialMetadata()
2497
- guard let updatedMetadata = self.prepareMetadataForNewStagingBundle(currentMetadata, bundleId: bundleId) else {
2498
- self.cleanupTemporaryFiles([tempDirectory])
2499
- completion(.failure(BundleStorageError.unknown(
2500
- NSError(domain: "HotUpdater", code: 0, userInfo: [
2501
- NSLocalizedDescriptionKey: "Release catalog selection is stale"
2502
- ])
2503
- )))
2504
- return
2505
- }
2506
- switch self.setBundleURL(localPath: finalBundlePath) {
2507
- case .success:
2508
- let _ = self.saveMetadata(updatedMetadata)
2509
- self.cleanupTemporaryFiles([tempDirectory])
2510
- self.scheduleCleanupOldBundles(
2511
- bundleIdsToKeep: [currentBundleId, updatedMetadata.stableBundleId, bundleId].compactMap { $0 }
2512
- )
2513
- progressHandler(
2514
- UpdateProgressPayload(
2515
- progress: UpdateProgress.complete,
2516
- artifactType: "diff",
2517
- details: UpdateProgressPayload.DiffProgressDetails(
2518
- totalFilesCount: diffFiles.count,
2519
- completedFilesCount: diffFiles.filter { $0.status == "downloaded" }.count,
2520
- files: diffFiles
2521
- )
2522
- )
2523
- )
2524
- completion(.success(true))
2525
- case .failure(let error):
2526
- try? self.fileSystem.removeItem(atPath: realDir)
2527
- self.cleanupTemporaryFiles([tempDirectory])
2528
- completion(.failure(error))
2529
- }
2530
- case .failure(let error):
2531
- throw error
2532
- }
2533
- } catch {
2534
- NSLog("[BundleStorage] Manifest-driven install failed: \(error.localizedDescription). Falling back to archive.")
2535
- try? self.fileSystem.removeItem(atPath: tmpDir)
2536
- self.cleanupTemporaryFiles([tempDirectory])
2537
- self.prepareAndDownloadBundle(
2538
- bundleId: bundleId,
2539
- fileUrl: fileUrl,
2540
- fileHash: fileHash,
2541
- storeDir: storeDir,
2205
+ // A patch for the running bundle was offered, but the original
2206
+ // file came instead; a patch for another base was never tried.
2207
+ let patchWasTried = changedAsset.patch.map {
2208
+ $0.algorithm == "bsdiff" && $0.baseBundleId == currentBundleId
2209
+ } ?? false
2210
+ return .downloaded(patchFallback: patchWasTried)
2211
+ case .failure(let error):
2212
+ let lastKnownProgress = diffFiles.first(where: { $0.path == assetPath })?.progress ?? 0
2213
+ updateDiffProgressFile(
2214
+ files: &diffFiles,
2215
+ assetPath: assetPath,
2216
+ status: "failed",
2217
+ progress: lastKnownProgress
2218
+ )
2219
+ self.emitDiffProgress(
2542
2220
  progressHandler: progressHandler,
2543
- completion: completion
2221
+ phase: "downloading",
2222
+ files: diffFiles
2544
2223
  )
2224
+ let transferError: Error
2225
+ if let downloadError = error as? DownloadError,
2226
+ case .incompleteDownload(let expected, let actual) = downloadError {
2227
+ transferError = BundleStorageError.incompleteDownload(expected: expected, actual: actual)
2228
+ } else {
2229
+ transferError = BundleStorageError.downloadFailed(error)
2230
+ }
2231
+ throw UpdateFailureError.classifying(transferError, during: .transfer, resource: .file)
2545
2232
  }
2546
2233
  }
2547
2234
 
2548
- private func prepareAndDownloadBundle(
2549
- bundleId: String,
2550
- fileUrl: URL,
2551
- fileHash: String?,
2552
- storeDir: String,
2553
- progressHandler: @escaping (UpdateProgressPayload) -> Void,
2554
- completion: @escaping (Result<Bool, Error>) -> Void
2555
- ) {
2556
- // 1) Prepare temp directory for download
2557
- let tempDirResult = tempDir()
2558
- guard case .success(let tempDirectory) = tempDirResult else {
2559
- completion(.failure(tempDirResult.failureError ?? BundleStorageError.unknown(nil)))
2560
- return
2561
- }
2562
-
2563
- // 2) Clean up any previous temp dir
2564
- try? self.fileSystem.removeItem(atPath: tempDirectory)
2565
-
2566
- // 3) Create temp dir
2567
- if !self.fileSystem.createDirectory(atPath: tempDirectory) {
2568
- completion(.failure(BundleStorageError.directoryCreationFailed))
2569
- return
2235
+ // Retain only target files on retry; partial files are rehashed before reuse.
2236
+ private func pruneStagingDirectory(_ directory: String, assets: Set<String>, prefix: String = "") throws {
2237
+ for name in try fileSystem.contentsOfDirectory(atPath: directory) {
2238
+ let relativePath = prefix + name
2239
+ let path = (directory as NSString).appendingPathComponent(name)
2240
+ let type = try fileSystem.attributesOfItem(atPath: path)[.type] as? FileAttributeType
2241
+ if type == .typeDirectory, assets.contains(where: { $0.hasPrefix(relativePath + "/") }) {
2242
+ try pruneStagingDirectory(path, assets: assets, prefix: relativePath + "/")
2243
+ } else if type != .typeRegular || !assets.contains(relativePath) {
2244
+ try fileSystem.removeItem(atPath: path)
2245
+ }
2570
2246
  }
2247
+ }
2571
2248
 
2572
- // 4) Determine bundle filename from URL
2573
- let bundleFileName = fileUrl.lastPathComponent.isEmpty ? "bundle.zip" : fileUrl.lastPathComponent
2574
- let tempBundleFile = (tempDirectory as NSString).appendingPathComponent(bundleFileName)
2575
-
2576
- NSLog("[BundleStorage] Starting download from \(fileUrl)")
2577
-
2578
- // Download with integrated disk space check
2579
- var diskSpaceError: BundleStorageError? = nil
2580
-
2581
- _ = self.downloadService.downloadFile(
2582
- from: fileUrl,
2583
- to: tempBundleFile,
2584
- fileSizeHandler: { fileSize in
2585
- // This will be called when Content-Length is received
2586
- NSLog("[BundleStorage] File size received: \(fileSize) bytes")
2587
-
2588
- // Check available disk space
2589
- do {
2590
- let attributes = try FileManager.default.attributesOfFileSystem(forPath: NSHomeDirectory())
2591
- if let freeSize = attributes[.systemFreeSize] as? Int64 {
2592
- let requiredSpace = fileSize * 2 // ZIP + extracted files
2249
+ private func installArchive(
2250
+ archive: ParsedArchive,
2251
+ archiveUrl: URL,
2252
+ targetManifest: ParsedBundleManifest,
2253
+ preparedDirectory: String,
2254
+ tempDirectory: String,
2255
+ progressHandler: @escaping (UpdateProgressPayload) -> Void
2256
+ ) throws -> [UpdateProgressPayload.DiffProgressFileSnapshot] {
2257
+ let archiveScratch = (tempDirectory as NSString).appendingPathComponent("archive")
2258
+ try? fileSystem.removeItem(atPath: archiveScratch)
2259
+ guard fileSystem.createDirectory(atPath: archiveScratch) else {
2260
+ throw BundleStorageError.directoryCreationFailed
2261
+ }
2262
+ defer { try? fileSystem.removeItem(atPath: archiveScratch) }
2593
2263
 
2594
- NSLog("[BundleStorage] Available: \(freeSize) bytes, Required: \(requiredSpace) bytes")
2264
+ let compressedPath = (archiveScratch as NSString).appendingPathComponent("bundle.tar.br")
2265
+ let tarPath = (archiveScratch as NSString).appendingPathComponent("bundle.tar")
2266
+ let extractedDirectory = (archiveScratch as NSString).appendingPathComponent("extracted")
2267
+ var progressFiles = [UpdateProgressPayload.DiffProgressFileSnapshot(
2268
+ path: "bundle.tar.br",
2269
+ downloadPath: "bundle.tar.br",
2270
+ status: "pending",
2271
+ progress: 0,
2272
+ order: 0
2273
+ )]
2274
+ emitDiffProgress(
2275
+ progressHandler: progressHandler,
2276
+ phase: "downloading",
2277
+ files: progressFiles
2278
+ )
2595
2279
 
2596
- if freeSize < requiredSpace {
2597
- NSLog("[BundleStorage] Insufficient disk space detected: need \(requiredSpace) bytes, available \(freeSize) bytes")
2598
- // Store error to be returned in completion handler
2599
- diskSpaceError = .insufficientDiskSpace
2600
- }
2601
- }
2602
- } catch {
2603
- NSLog("[BundleStorage] Failed to check disk space: \(error.localizedDescription)")
2604
- }
2605
- },
2606
- progressHandler: { downloadProgress in
2607
- self.emitArchiveProgress(
2280
+ // One attempt: a failed archive falls back to per-file downloads, which retry.
2281
+ switch downloadFileSynchronously(
2282
+ from: archiveUrl,
2283
+ to: compressedPath,
2284
+ retrying: false,
2285
+ progressHandler: { progress in
2286
+ self.updateDiffProgressFile(
2287
+ files: &progressFiles,
2288
+ assetPath: "bundle.tar.br",
2289
+ status: "downloading",
2290
+ progress: progress.progress,
2291
+ downloadPath: "bundle.tar.br",
2292
+ downloadedBytes: progress.downloadedBytes,
2293
+ totalBytes: progress.totalBytes
2294
+ )
2295
+ self.emitDiffProgress(
2608
2296
  progressHandler: progressHandler,
2609
- progress: Self.mapProgress(
2610
- downloadProgress.progress,
2611
- start: 0,
2612
- end: UpdateProgress.downloadEnd
2613
- ),
2614
- downloadedBytes: downloadProgress.downloadedBytes,
2615
- totalBytes: downloadProgress.totalBytes
2297
+ phase: "downloading",
2298
+ files: progressFiles
2616
2299
  )
2617
- },
2618
- completion: { [weak self] result in
2619
- guard let self = self else {
2620
- let error = NSError(domain: "HotUpdaterError", code: 998,
2621
- userInfo: [NSLocalizedDescriptionKey: "Self deallocated during download"])
2622
- completion(.failure(error))
2623
- return
2624
2300
  }
2301
+ ) {
2302
+ case .success:
2303
+ break
2304
+ case .failure(let error):
2305
+ throw BundleStorageError.downloadFailed(error)
2306
+ }
2625
2307
 
2626
- // Check for disk space error first before processing download result
2627
- if let diskError = diskSpaceError {
2628
- NSLog("[BundleStorage] Throwing disk space error")
2629
- self.cleanupTemporaryFiles([tempDirectory])
2630
- completion(.failure(diskError))
2631
- return
2632
- }
2308
+ let compressedAttributes = try fileSystem.attributesOfItem(atPath: compressedPath)
2309
+ guard let compressedSize = compressedAttributes[.size] as? NSNumber,
2310
+ compressedSize.int64Value == archive.downloadByteSize,
2311
+ HashUtils.verifyHash(
2312
+ fileURL: URL(fileURLWithPath: compressedPath),
2313
+ expectedHash: archive.downloadFileHash
2314
+ ) else {
2315
+ throw UpdateFailureError(
2316
+ underlying: BundleStorageError.invalidBundle,
2317
+ failure: .hashMismatch
2318
+ )
2319
+ }
2633
2320
 
2634
- // Dispatch the processing of the downloaded file to the file operation queue
2635
- let workItem = DispatchWorkItem {
2636
- switch result {
2637
- case .success(let location):
2638
- self.processDownloadedFileWithTmp(location: location,
2639
- tempBundleFile: tempBundleFile,
2640
- fileHash: fileHash,
2641
- storeDir: storeDir,
2642
- bundleId: bundleId,
2643
- tempDirectory: tempDirectory,
2644
- progressHandler: progressHandler,
2645
- completion: completion)
2646
- case .failure(let error):
2647
- NSLog("[BundleStorage] Download failed: \(error.localizedDescription)")
2648
- self.cleanupTemporaryFiles([tempDirectory]) // Sync cleanup
2649
-
2650
- // Map DownloadError.incompleteDownload to BundleStorageError.incompleteDownload
2651
- if let downloadError = error as? DownloadError,
2652
- case .incompleteDownload(let expected, let actual) = downloadError {
2653
- completion(.failure(BundleStorageError.incompleteDownload(expected: expected, actual: actual)))
2654
- } else {
2655
- completion(.failure(BundleStorageError.downloadFailed(error)))
2656
- }
2321
+ try UpdateFailureError.during(.extraction) {
2322
+ try BrotliFileDecompressor.decompress(
2323
+ from: compressedPath,
2324
+ to: tarPath,
2325
+ expectedOutputByteSize: archive.tarByteSize
2326
+ )
2327
+ }
2328
+
2329
+ let expectedFiles = try targetManifest.assets.reduce(
2330
+ into: [String: Int64](),
2331
+ { result, entry in
2332
+ guard let byteSize = entry.value.byteSize else {
2333
+ throw BundleStorageError.invalidBundle
2657
2334
  }
2335
+ result[entry.key] = byteSize
2336
+ }
2337
+ )
2338
+ try UpdateFailureError.during(.extraction) {
2339
+ try TarArchiveExtractor.extract(
2340
+ from: tarPath,
2341
+ to: extractedDirectory,
2342
+ expectedFiles: expectedFiles
2343
+ )
2344
+ for (path, asset) in targetManifest.assets {
2345
+ let extractedPath = try FileUtilities.fileURL(
2346
+ for: path,
2347
+ destinationRoot: extractedDirectory
2348
+ ).path
2349
+ try verifyManifestAssetFile(atPath: extractedPath, asset: asset)
2658
2350
  }
2659
- self.fileOperationQueue.async(execute: workItem)
2660
2351
  }
2352
+
2353
+ try promoteVerifiedArchiveDirectory(
2354
+ preparedDirectory,
2355
+ withArchiveDirectory: extractedDirectory
2661
2356
  )
2357
+
2358
+ updateDiffProgressFile(
2359
+ files: &progressFiles,
2360
+ assetPath: "bundle.tar.br",
2361
+ status: "downloaded",
2362
+ progress: 1,
2363
+ downloadedBytes: archive.downloadByteSize,
2364
+ totalBytes: archive.downloadByteSize
2365
+ )
2366
+ emitDiffProgress(
2367
+ progressHandler: progressHandler,
2368
+ phase: "finalizing",
2369
+ files: progressFiles
2370
+ )
2371
+ return progressFiles
2662
2372
  }
2663
-
2664
- /**
2665
- * Logs detailed diagnostic information about a file system path.
2666
- * @param path The path to diagnose
2667
- * @param context Additional context for logging
2668
- */
2669
- private func logFileSystemDiagnostics(path: String, context: String) {
2670
- let fileManager = FileManager.default
2671
2373
 
2672
- // Check if path exists
2673
- let exists = fileManager.fileExists(atPath: path)
2674
- NSLog("[BundleStorage] [\(context)] Path exists: \(exists) - \(path)")
2374
+ private func promoteVerifiedArchiveDirectory(
2375
+ _ preparedDirectory: String,
2376
+ withArchiveDirectory archiveDirectory: String
2377
+ ) throws {
2378
+ let backupDirectory = preparedDirectory + ".archive-backup"
2379
+ try? fileSystem.removeItem(atPath: backupDirectory)
2380
+ try fileSystem.moveItem(atPath: preparedDirectory, toPath: backupDirectory)
2381
+
2382
+ do {
2383
+ try fileSystem.moveItem(
2384
+ atPath: archiveDirectory,
2385
+ toPath: preparedDirectory
2386
+ )
2387
+ } catch {
2388
+ try? fileSystem.removeItem(atPath: preparedDirectory)
2389
+ do {
2390
+ try fileSystem.moveItem(atPath: backupDirectory, toPath: preparedDirectory)
2391
+ } catch let restoreError {
2392
+ throw BundleStorageError.moveOperationFailed(restoreError)
2393
+ }
2394
+ throw error
2395
+ }
2396
+ try? fileSystem.removeItem(atPath: backupDirectory)
2397
+ }
2675
2398
 
2676
- if exists {
2399
+ private func recoverInterruptedPromotions() {
2400
+ guard case .success(let storeDir) = bundleStoreDir(),
2401
+ let entries = try? fileSystem.contentsOfDirectory(atPath: storeDir) else { return }
2402
+ for entry in entries where entry.hasSuffix(".install-backup") {
2403
+ let finalDirectory = (storeDir as NSString).appendingPathComponent(String(entry.dropLast(".install-backup".count)))
2677
2404
  do {
2678
- let attributes = try fileManager.attributesOfItem(atPath: path)
2679
- let size = attributes[.size] as? Int64 ?? 0
2680
- let permissions = attributes[.posixPermissions] as? Int ?? 0
2681
- NSLog("[BundleStorage] [\(context)] Size: \(size) bytes, Permissions: \(String(permissions, radix: 8))")
2405
+ try recoverInterruptedPromotion(finalDirectory: finalDirectory)
2682
2406
  } catch {
2683
- NSLog("[BundleStorage] [\(context)] Failed to get attributes: \(error.localizedDescription)")
2407
+ NSLog("[BundleStorage] Cannot recover interrupted promotion: \(error)")
2684
2408
  }
2685
2409
  }
2410
+ }
2686
2411
 
2687
- // Check parent directory
2688
- let parentPath = (path as NSString).deletingLastPathComponent
2689
- let parentExists = fileManager.fileExists(atPath: parentPath)
2690
- NSLog("[BundleStorage] [\(context)] Parent directory exists: \(parentExists) - \(parentPath)")
2412
+ private func recoverInterruptedPromotion(finalDirectory: String) throws {
2413
+ let backup = finalDirectory + ".install-backup"
2414
+ guard fileSystem.fileExists(atPath: backup) else { return }
2415
+ if fileSystem.fileExists(atPath: finalDirectory) {
2416
+ if isCompleteBundleDirectory(finalDirectory) {
2417
+ try fileSystem.removeItem(atPath: backup)
2418
+ return
2419
+ }
2420
+ try fileSystem.removeItem(atPath: finalDirectory)
2421
+ }
2422
+ try fileSystem.moveItem(atPath: backup, toPath: finalDirectory)
2691
2423
  }
2692
2424
 
2693
- /**
2694
- * Processes a downloaded bundle file using the "tmp" rename approach.
2695
- * This method is part of the asynchronous `updateBundle` flow and is expected to run on a background thread.
2696
- * @param location URL of the downloaded file
2697
- * @param tempBundleFile Path to store the downloaded bundle file
2698
- * @param fileHash Combined hash string for verification (sig:<signature> or <hex_hash>)
2699
- * @param storeDir Path to the bundle-store directory
2700
- * @param bundleId ID of the bundle being processed
2701
- * @param tempDirectory Temporary directory for processing
2702
- * @param progressHandler Callback for download/apply progress (0.0 to 1.0)
2703
- * @param completion Callback with result of the operation
2704
- */
2705
- private func processDownloadedFileWithTmp(
2706
- location: URL,
2707
- tempBundleFile: String,
2708
- fileHash: String?,
2709
- storeDir: String,
2425
+ private func isCompleteBundleDirectory(_ directory: String) -> Bool {
2426
+ let bundleId = (directory as NSString).lastPathComponent
2427
+ guard let manifest = readManifest(in: directory),
2428
+ let parsed = parseBundleManifest(from: manifest),
2429
+ parsed.bundleId == bundleId,
2430
+ case .success(let bundlePath) = findBundleFile(in: directory, expectedBundleId: bundleId),
2431
+ bundlePath != nil else { return false }
2432
+ return parsed.assets.allSatisfy { path, asset in
2433
+ guard let file = try? FileUtilities.fileURL(for: path, destinationRoot: directory) else { return false }
2434
+ return (try? verifyManifestAssetFile(atPath: file.path, asset: asset)) != nil
2435
+ }
2436
+ }
2437
+
2438
+ private func promoteStagedBundle(
2710
2439
  bundleId: String,
2711
- tempDirectory: String,
2440
+ stagingDirectory: String,
2441
+ finalDirectory: String,
2442
+ bundlePathInStaging: String
2443
+ ) throws -> BundleMetadata {
2444
+ releaseStateLock.lock()
2445
+ defer { releaseStateLock.unlock() }
2446
+ let currentMetadata = loadMetadataOrNull() ?? createInitialMetadata()
2447
+ guard let updatedMetadata = prepareMetadataForNewStagingBundle(currentMetadata, bundleId: bundleId) else {
2448
+ throw BundleStorageError.unknown(StaleReleaseSelectionError())
2449
+ }
2450
+ let finalBundlePath = try resolveBundlePathAfterMove(
2451
+ bundlePathInStaging, from: stagingDirectory, to: finalDirectory
2452
+ )
2453
+ let previousPreference = try preferences.getItem(forKey: "HotUpdaterBundleURL")
2454
+ let backup = finalDirectory + ".install-backup"
2455
+ try recoverInterruptedPromotion(finalDirectory: finalDirectory)
2456
+ if fileSystem.fileExists(atPath: finalDirectory) {
2457
+ try fileSystem.moveItem(atPath: finalDirectory, toPath: backup)
2458
+ }
2459
+ var promoted = false
2460
+ do {
2461
+ try fileSystem.moveItem(atPath: stagingDirectory, toPath: finalDirectory)
2462
+ promoted = true
2463
+ try setBundleURL(localPath: finalBundlePath).get()
2464
+ // Metadata is the durable activation point. Launch never uses an
2465
+ // untracked preference if this write fails or the process terminates.
2466
+ guard saveMetadata(updatedMetadata) else {
2467
+ throw UpdateFailureError(
2468
+ underlying: BundleStorageError.unknown(NSError(domain: "HotUpdater", code: 0,
2469
+ userInfo: [NSLocalizedDescriptionKey: "Failed to persist bundle activation metadata"])),
2470
+ failure: .storage
2471
+ )
2472
+ }
2473
+ } catch {
2474
+ let installError = error
2475
+ let preferenceResult = setBundleURL(localPath: previousPreference)
2476
+ if promoted { try fileSystem.removeItem(atPath: finalDirectory) }
2477
+ if fileSystem.fileExists(atPath: backup) {
2478
+ try fileSystem.moveItem(atPath: backup, toPath: finalDirectory)
2479
+ }
2480
+ try preferenceResult.get()
2481
+ throw installError
2482
+ }
2483
+ try? fileSystem.removeItem(atPath: backup)
2484
+ return updatedMetadata
2485
+ }
2486
+
2487
+ private func updateBundleFromManifest(
2488
+ bundleId: String,
2489
+ manifestUrl: URL,
2490
+ manifestFileHash: String,
2491
+ archiveUrl: URL?,
2492
+ changedAssets: [String: ChangedAssetDescriptor],
2493
+ storeDir: String,
2712
2494
  progressHandler: @escaping (UpdateProgressPayload) -> Void,
2713
- completion: @escaping (Result<Bool, Error>) -> Void
2495
+ completion: @escaping (Result<UpdateDelivery, Error>) -> Void
2714
2496
  ) {
2715
- let currentBundleId = self.getCachedBundleId()
2716
- NSLog("[BundleStorage] Processing downloaded file atPath: \(location.path)")
2497
+ let tempDirResult = tempDir()
2498
+ guard case .success(let tempDirectory) = tempDirResult else {
2499
+ completion(.failure(UpdateFailureError.classifying(
2500
+ tempDirResult.failureError ?? BundleStorageError.unknown(nil),
2501
+ during: .storage
2502
+ )))
2503
+ return
2504
+ }
2717
2505
 
2718
- // 1) Ensure the bundle file exists
2719
- guard self.fileSystem.fileExists(atPath: location.path) else {
2720
- logFileSystemDiagnostics(path: location.path, context: "Download Location Missing")
2721
- self.cleanupTemporaryFiles([tempDirectory])
2722
- completion(.failure(BundleStorageError.downloadFailed(NSError(
2723
- domain: "HotUpdaterError",
2724
- code: 1,
2725
- userInfo: [NSLocalizedDescriptionKey: "Downloaded file does not exist atPath: \(location.path)"]
2726
- ))))
2506
+ try? self.fileSystem.removeItem(atPath: tempDirectory)
2507
+ guard self.fileSystem.createDirectory(atPath: tempDirectory) else {
2508
+ completion(.failure(UpdateFailureError.classifying(
2509
+ BundleStorageError.directoryCreationFailed,
2510
+ during: .storage
2511
+ )))
2727
2512
  return
2728
2513
  }
2729
2514
 
2730
- // 2) Define tmpDir and realDir
2515
+ let currentBundleId = self.getCachedBundleId()
2516
+ let currentBundleDir = currentBundleId.map {
2517
+ (storeDir as NSString).appendingPathComponent($0)
2518
+ }
2519
+ let tempManifestPath = (tempDirectory as NSString).appendingPathComponent("manifest.json")
2731
2520
  let tmpDir = (storeDir as NSString).appendingPathComponent("\(bundleId).tmp")
2732
2521
  let realDir = (storeDir as NSString).appendingPathComponent(bundleId)
2522
+ var diffFiles: [UpdateProgressPayload.DiffProgressFileSnapshot] = []
2523
+ // The running step classifies the errors whose type does not.
2524
+ var failurePhase = UpdatePhase.storage
2525
+ var failureResource: UpdateFailureResource?
2733
2526
 
2734
2527
  do {
2735
- // 3) Remove any existing tmpDir
2736
- if self.fileSystem.fileExists(atPath: tmpDir) {
2737
- try self.fileSystem.removeItem(atPath: tmpDir)
2738
- NSLog("[BundleStorage] Removed existing tmpDir: \(tmpDir)")
2739
- }
2740
-
2741
- // 4) Create tmpDir
2742
- guard self.fileSystem.createDirectory(atPath: tmpDir) else {
2743
- throw BundleStorageError.directoryCreationFailed
2744
- }
2745
- NSLog("[BundleStorage] Created tmpDir: \(tmpDir)")
2746
- logFileSystemDiagnostics(path: tmpDir, context: "TmpDir Created")
2747
-
2748
- // 5) Verify bundle integrity (hash or signature based on fileHash format)
2749
- NSLog("[BundleStorage] Verifying bundle integrity...")
2750
- self.emitArchiveProgress(
2528
+ try recoverInterruptedPromotion(finalDirectory: realDir)
2529
+ failurePhase = .transfer
2530
+ failureResource = .manifest
2531
+ self.emitDiffProgress(
2751
2532
  progressHandler: progressHandler,
2752
- progress: UpdateProgress.verificationStart
2533
+ phase: "manifest",
2534
+ files: diffFiles,
2535
+ manifestProgress: 0
2753
2536
  )
2754
- let tempBundleURL = URL(fileURLWithPath: tempBundleFile)
2755
- let verificationResult = SignatureVerifier.verifyBundle(fileURL: tempBundleURL, fileHash: fileHash)
2756
- switch verificationResult {
2537
+ switch self.downloadFileSynchronously(
2538
+ from: manifestUrl,
2539
+ to: tempManifestPath,
2540
+ progressHandler: { progress in
2541
+ self.emitDiffProgress(
2542
+ progressHandler: progressHandler,
2543
+ phase: "manifest",
2544
+ files: diffFiles,
2545
+ manifestProgress: progress.progress
2546
+ )
2547
+ }
2548
+ ) {
2757
2549
  case .success:
2758
- NSLog("[BundleStorage] Bundle verification completed successfully")
2759
- self.emitArchiveProgress(
2760
- progressHandler: progressHandler,
2761
- progress: UpdateProgress.verificationEnd
2762
- )
2550
+ break
2763
2551
  case .failure(let error):
2764
- NSLog("[BundleStorage] Bundle verification failed: \(error)")
2765
- try? self.fileSystem.removeItem(atPath: tmpDir)
2766
- self.cleanupTemporaryFiles([tempDirectory])
2767
- completion(.failure(BundleStorageError.signatureVerificationFailed(error)))
2768
- return
2552
+ if let downloadError = error as? DownloadError,
2553
+ case .incompleteDownload(let expected, let actual) = downloadError {
2554
+ throw BundleStorageError.incompleteDownload(expected: expected, actual: actual)
2555
+ }
2556
+ throw BundleStorageError.downloadFailed(error)
2769
2557
  }
2770
2558
 
2771
- // 6) Unzip directly into tmpDir with progress tracking (0.8 - 1.0)
2772
- NSLog("[BundleStorage] Extracting \(tempBundleFile) → \(tmpDir)")
2773
- logFileSystemDiagnostics(path: tempBundleFile, context: "Before Extraction")
2774
- do {
2775
- try self.decompressService.unzip(file: tempBundleFile, to: tmpDir, progressHandler: { unzipProgress in
2776
- self.emitArchiveProgress(
2777
- progressHandler: progressHandler,
2778
- progress: Self.mapProgress(
2779
- unzipProgress,
2780
- start: UpdateProgress.extractionStart,
2781
- end: UpdateProgress.extractionEnd
2782
- )
2783
- )
2784
- })
2785
- NSLog("[BundleStorage] Extraction complete at \(tmpDir)")
2786
- logFileSystemDiagnostics(path: tmpDir, context: "After Extraction")
2787
- } catch {
2788
- let nsError = error as NSError
2789
- NSLog("[BundleStorage] Extraction failed - Domain: \(nsError.domain), Code: \(nsError.code), Description: \(nsError.localizedDescription)")
2790
- logFileSystemDiagnostics(path: tmpDir, context: "Extraction Failed")
2791
- try? self.fileSystem.removeItem(atPath: tmpDir)
2792
- self.cleanupTemporaryFiles([tempDirectory])
2793
- completion(.failure(BundleStorageError.extractionFormatError(error)))
2794
- return
2559
+ failurePhase = .verification
2560
+ let manifestVerificationResult = SignatureVerifier.verifyBundle(
2561
+ fileURL: URL(fileURLWithPath: tempManifestPath),
2562
+ fileHash: manifestFileHash
2563
+ )
2564
+ guard case .success = manifestVerificationResult else {
2565
+ if case .failure(let error) = manifestVerificationResult {
2566
+ throw BundleStorageError.signatureVerificationFailed(error)
2567
+ }
2568
+ throw BundleStorageError.unknown(nil)
2795
2569
  }
2796
2570
 
2797
- // 7) Remove the downloaded bundle file
2798
- try? self.fileSystem.removeItem(atPath: tempBundleFile)
2799
-
2800
- // 8) Verify that a valid bundle file exists inside tmpDir
2801
- self.emitArchiveProgress(
2802
- progressHandler: progressHandler,
2803
- progress: UpdateProgress.bundleValidation
2804
- )
2805
- switch self.findBundleFile(in: tmpDir, expectedBundleId: bundleId) {
2806
- case .success(let maybeBundlePath):
2807
- if let bundlePathInTmp = maybeBundlePath {
2808
- NSLog("[BundleStorage] Found valid bundle in tmpDir: \(bundlePathInTmp)")
2809
- logFileSystemDiagnostics(path: bundlePathInTmp, context: "Bundle Found")
2810
-
2811
- // 9) Remove any existing realDir
2812
- if self.fileSystem.fileExists(atPath: realDir) {
2813
- try self.fileSystem.removeItem(atPath: realDir)
2814
- NSLog("[BundleStorage] Removed existing realDir: \(realDir)")
2815
- }
2571
+ failurePhase = .manifest
2572
+ guard let manifestData = try? Data(contentsOf: URL(fileURLWithPath: tempManifestPath)),
2573
+ let manifestJson = try JSONSerialization.jsonObject(with: manifestData) as? [String: Any],
2574
+ let targetManifest = parseBundleManifest(from: manifestJson),
2575
+ targetManifest.bundleId == bundleId
2576
+ else {
2577
+ throw BundleStorageError.invalidBundle
2578
+ }
2579
+ // Validate the entire wire contract even when every byte can be reused.
2580
+ guard Set(targetManifest.assets.keys) == Set(changedAssets.keys) else {
2581
+ throw BundleStorageError.invalidBundle
2582
+ }
2583
+ for (path, asset) in targetManifest.assets {
2584
+ guard FileUtilities.normalizedRelativePath(from: path) == path,
2585
+ path != "manifest.json",
2586
+ let descriptor = changedAssets[path],
2587
+ descriptor.fileUrl != nil,
2588
+ descriptor.fileHash.caseInsensitiveCompare(asset.fileHash) == .orderedSame else {
2589
+ throw BundleStorageError.invalidBundle
2590
+ }
2591
+ _ = try FileUtilities.fileURL(for: path, destinationRoot: tmpDir)
2592
+ }
2816
2593
 
2817
- // 10) Rename (move) tmpDir → realDir
2594
+ // Downloads, patches, and extraction below classify their own errors.
2595
+ failurePhase = .storage
2596
+ failureResource = nil
2597
+ guard self.fileSystem.createDirectory(atPath: tmpDir) else {
2598
+ throw BundleStorageError.directoryCreationFailed
2599
+ }
2600
+ try pruneStagingDirectory(tmpDir, assets: Set(targetManifest.assets.keys))
2601
+ let sortedAssets = targetManifest.assets.sorted { $0.key < $1.key }
2602
+ let localDirectories = [currentBundleDir, realDir].compactMap { $0 }.filter { fileSystem.fileExists(atPath: $0) }
2603
+ var downloads: [String: ChangedAssetDescriptor] = [:]
2604
+ for (index, asset) in sortedAssets.enumerated() {
2605
+ // Local preparation has its own progress interval, with no download files.
2606
+ progressHandler(UpdateProgressPayload(
2607
+ progress: 0.15 + 0.05 * Double(index) / Double(max(1, sortedAssets.count)),
2608
+ artifactType: "diff",
2609
+ details: .init(totalFilesCount: 0, completedFilesCount: 0)
2610
+ ))
2611
+ let assetPath = asset.key
2612
+ let expectedAsset = asset.value
2613
+ let destinationPath = try FileUtilities.fileURL(for: assetPath, destinationRoot: tmpDir).path
2614
+ guard self.fileSystem.createDirectory(atPath: (destinationPath as NSString).deletingLastPathComponent) else {
2615
+ throw BundleStorageError.directoryCreationFailed
2616
+ }
2617
+ if (try? fileSystem.attributesOfItem(atPath: destinationPath)[.type] as? FileAttributeType) == .typeRegular,
2618
+ (try? verifyManifestAssetFile(atPath: destinationPath, asset: expectedAsset)) != nil {
2619
+ continue
2620
+ }
2621
+ if fileSystem.fileExists(atPath: destinationPath) { try self.fileSystem.removeItem(atPath: destinationPath) }
2622
+
2623
+ var reused = false
2624
+ // The cached target is only a candidate, just like the active OTA.
2625
+ for directory in localDirectories {
2626
+ guard let sourcePath = try? FileUtilities.fileURL(for: assetPath, destinationRoot: directory).path,
2627
+ (try? fileSystem.attributesOfItem(atPath: sourcePath)[.type] as? FileAttributeType) == .typeRegular,
2628
+ (try? verifyManifestAssetFile(atPath: sourcePath, asset: expectedAsset)) != nil else { continue }
2818
2629
  do {
2819
- try self.fileSystem.moveItem(atPath: tmpDir, toPath: realDir)
2820
- NSLog("[BundleStorage] Renamed tmpDir to realDir: \(realDir)")
2821
- logFileSystemDiagnostics(path: realDir, context: "After Move")
2630
+ try self.fileSystem.copyItem(atPath: sourcePath, toPath: destinationPath)
2631
+ try verifyManifestAssetFile(atPath: destinationPath, asset: expectedAsset)
2632
+ reused = true
2633
+ break
2822
2634
  } catch {
2823
- let nsError = error as NSError
2824
- NSLog("[BundleStorage] Move operation failed - Domain: \(nsError.domain), Code: \(nsError.code), Description: \(nsError.localizedDescription)")
2825
- logFileSystemDiagnostics(path: tmpDir, context: "Move Failed - Source")
2826
- logFileSystemDiagnostics(path: realDir, context: "Move Failed - Destination")
2827
- throw BundleStorageError.moveOperationFailed(error)
2635
+ try? self.fileSystem.removeItem(atPath: destinationPath)
2828
2636
  }
2829
-
2830
- // 11) Construct final bundlePath for preferences
2831
- let finalBundlePath = try resolveBundlePathAfterMove(
2832
- bundlePathInTmp,
2833
- from: tmpDir,
2834
- to: realDir
2835
- )
2836
-
2837
- // 12) Set the bundle URL in preferences (for backwards compatibility)
2838
- let currentMetadata = self.loadMetadataOrNull() ?? self.createInitialMetadata()
2839
- guard let updatedMetadata = self.prepareMetadataForNewStagingBundle(currentMetadata, bundleId: bundleId) else {
2840
- self.cleanupTemporaryFiles([tempDirectory])
2841
- completion(.failure(BundleStorageError.unknown(
2842
- NSError(domain: "HotUpdater", code: 0, userInfo: [
2843
- NSLocalizedDescriptionKey: "Release catalog selection is stale"
2844
- ])
2845
- )))
2846
- return
2637
+ }
2638
+ if reused { continue }
2639
+ if builtInAssetResolver.copyIfMatches(assetPath: assetPath, expectedHash: expectedAsset.fileHash, destination: destinationPath) {
2640
+ try UpdateFailureError.during(.verification, resource: .file) {
2641
+ try verifyManifestAssetFile(atPath: destinationPath, asset: expectedAsset)
2642
+ }
2643
+ continue
2644
+ }
2645
+ downloads[assetPath] = changedAssets[assetPath]
2646
+ }
2647
+
2648
+ var archiveInstalled = false
2649
+ var patchedFiles = false
2650
+ // The archive is chosen before any patch is tried, so only the
2651
+ // per-file install can fall back from a patch.
2652
+ var patchFallbacks = false
2653
+ if shouldUseArchive(
2654
+ archive: targetManifest.archive,
2655
+ archiveUrl: archiveUrl,
2656
+ downloads: downloads,
2657
+ assets: targetManifest.assets
2658
+ ), let archive = targetManifest.archive, let archiveUrl {
2659
+ do {
2660
+ diffFiles = try UpdateFailureError.during(.storage, resource: .archive) {
2661
+ try installArchive(
2662
+ archive: archive,
2663
+ archiveUrl: archiveUrl,
2664
+ targetManifest: targetManifest,
2665
+ preparedDirectory: tmpDir,
2666
+ tempDirectory: tempDirectory,
2667
+ progressHandler: progressHandler
2668
+ )
2669
+ }
2670
+ archiveInstalled = true
2671
+ } catch {
2672
+ NSLog("[BundleStorage] tar.br optimization failed; falling back to individual files: \(error.localizedDescription)")
2673
+ guard fileSystem.fileExists(atPath: tmpDir) else {
2674
+ throw error
2847
2675
  }
2848
- let setResult = self.setBundleURL(localPath: finalBundlePath)
2849
- switch setResult {
2850
- case .success:
2851
- NSLog("[BundleStorage] Successfully set bundle URL: \(finalBundlePath)")
2676
+ }
2677
+ }
2852
2678
 
2853
- // 13) Set staging metadata for rollback support
2854
- let _ = self.saveMetadata(updatedMetadata)
2855
- NSLog("[BundleStorage] Set staging bundle: \(bundleId), verificationPending: true")
2679
+ if !archiveInstalled {
2680
+ diffFiles = createDiffProgressFiles(changedAssets: downloads)
2681
+ emitDiffProgress(progressHandler: progressHandler, phase: "downloading", files: diffFiles)
2682
+ let progressLock = NSLock()
2683
+ let workers = OperationQueue()
2684
+ workers.maxConcurrentOperationCount = 4
2685
+ var installError: Error?
2686
+ var installOutcomes: [AssetInstallOutcome] = []
2687
+ for progressFile in diffFiles {
2688
+ let assetPath = progressFile.path
2689
+ let expectedAsset = targetManifest.assets[assetPath]!
2690
+ let descriptor = downloads[assetPath]!
2691
+ let destinationPath = try FileUtilities.fileURL(for: assetPath, destinationRoot: tmpDir).path
2692
+ // Separate temp namespaces also avoid collisions between flattened asset paths.
2693
+ let workerTemp = (tempDirectory as NSString).appendingPathComponent(String(progressFile.order))
2694
+ workers.addOperation {
2695
+ progressLock.lock()
2696
+ let shouldSkip = installError != nil
2697
+ progressLock.unlock()
2698
+ if shouldSkip { return }
2699
+ do {
2700
+ guard self.fileSystem.createDirectory(atPath: workerTemp) else { throw BundleStorageError.directoryCreationFailed }
2701
+ let outcome = try self.downloadManifestAsset(
2702
+ assetPath: assetPath, expectedAsset: expectedAsset, changedAsset: descriptor,
2703
+ currentBundleId: currentBundleId, currentBundleDir: currentBundleDir,
2704
+ destinationPath: destinationPath, tempDirectory: workerTemp, progressFile: progressFile,
2705
+ progressHandler: { payload in
2706
+ progressLock.lock()
2707
+ defer { progressLock.unlock() }
2708
+ if let snapshot = payload.details?.files.first,
2709
+ let index = diffFiles.firstIndex(where: { $0.path == assetPath }) {
2710
+ diffFiles[index] = snapshot
2711
+ }
2712
+ self.emitDiffProgress(progressHandler: progressHandler, phase: "downloading", files: diffFiles)
2713
+ }
2714
+ )
2715
+ progressLock.lock()
2716
+ installOutcomes.append(outcome)
2717
+ progressLock.unlock()
2718
+ } catch {
2719
+ progressLock.lock()
2720
+ if installError == nil { installError = error }
2721
+ progressLock.unlock()
2722
+ }
2723
+ }
2724
+ }
2725
+ // Join all workers before cleaning scratch files, even after a failure.
2726
+ workers.waitUntilAllOperationsAreFinished()
2727
+ if let installError { throw installError }
2728
+ for outcome in installOutcomes {
2729
+ switch outcome {
2730
+ case .patched:
2731
+ patchedFiles = true
2732
+ case .downloaded(let patchFallback):
2733
+ patchFallbacks = patchFallbacks || patchFallback
2734
+ }
2735
+ }
2736
+ }
2856
2737
 
2857
- // 14) Clean up the temporary directory
2858
- self.cleanupTemporaryFiles([tempDirectory])
2738
+ self.emitDiffProgress(
2739
+ progressHandler: progressHandler,
2740
+ phase: "finalizing",
2741
+ files: diffFiles
2742
+ )
2859
2743
 
2860
- self.emitArchiveProgress(
2861
- progressHandler: progressHandler,
2862
- progress: UpdateProgress.activationReady
2863
- )
2864
- self.scheduleCleanupOldBundles(
2865
- bundleIdsToKeep: [currentBundleId, updatedMetadata.stableBundleId, bundleId].compactMap { $0 }
2866
- )
2744
+ let manifestDestination = (tmpDir as NSString).appendingPathComponent("manifest.json")
2745
+ try copyVerifiedManifest(from: tempManifestPath, to: manifestDestination)
2867
2746
 
2868
- // 15) Complete with success
2869
- self.emitArchiveProgress(
2870
- progressHandler: progressHandler,
2871
- progress: UpdateProgress.complete
2872
- )
2873
- completion(.success(true))
2874
- case .failure(let err):
2875
- let nsError = err as NSError
2876
- NSLog("[BundleStorage] Failed to set bundle URL - Domain: \(nsError.domain), Code: \(nsError.code), Description: \(nsError.localizedDescription)")
2877
- // Preferences save failed → remove realDir and clean up
2878
- try? self.fileSystem.removeItem(atPath: realDir)
2879
- self.cleanupTemporaryFiles([tempDirectory])
2880
- completion(.failure(err))
2881
- }
2882
- } else {
2883
- // No valid .jsbundle found → delete tmpDir and fail
2884
- NSLog("[BundleStorage] No valid bundle file found in tmpDir")
2885
- logFileSystemDiagnostics(path: tmpDir, context: "Invalid Bundle")
2886
- try? self.fileSystem.removeItem(atPath: tmpDir)
2887
- self.cleanupTemporaryFiles([tempDirectory])
2888
- completion(.failure(BundleStorageError.invalidBundle))
2747
+ switch self.findBundleFile(in: tmpDir, expectedBundleId: bundleId) {
2748
+ case .success(let maybeBundlePath):
2749
+ guard let bundlePathInTmp = maybeBundlePath else {
2750
+ // The manifest does not name exactly one platform bundle file.
2751
+ throw UpdateFailureError(
2752
+ underlying: BundleStorageError.invalidBundle,
2753
+ failure: UpdateFailure.invalidResponse.with(resource: .manifest)
2754
+ )
2889
2755
  }
2890
- case .failure(let findError):
2891
- let nsError = findError as NSError
2892
- NSLog("[BundleStorage] Error finding bundle file - Domain: \(nsError.domain), Code: \(nsError.code), Description: \(nsError.localizedDescription)")
2893
- // Error scanning tmpDir → delete tmpDir and fail
2894
- try? self.fileSystem.removeItem(atPath: tmpDir)
2756
+
2757
+ let updatedMetadata = try promoteStagedBundle(
2758
+ bundleId: bundleId,
2759
+ stagingDirectory: tmpDir,
2760
+ finalDirectory: realDir,
2761
+ bundlePathInStaging: bundlePathInTmp
2762
+ )
2895
2763
  self.cleanupTemporaryFiles([tempDirectory])
2896
- completion(.failure(findError))
2764
+ self.scheduleCleanupOldBundles(
2765
+ bundleIdsToKeep: [currentBundleId, updatedMetadata.stableBundleId, bundleId].compactMap { $0 }
2766
+ )
2767
+ progressHandler(UpdateProgressPayload(
2768
+ progress: 1.0,
2769
+ artifactType: "diff",
2770
+ details: .init(
2771
+ totalFilesCount: diffFiles.count,
2772
+ completedFilesCount: diffFiles.filter { $0.status == "downloaded" }.count,
2773
+ files: diffFiles
2774
+ )
2775
+ ))
2776
+ let delivery: UpdateDelivery.Method =
2777
+ archiveInstalled ? .archive : (patchedFiles ? .patch : .manifest)
2778
+ completion(.success(UpdateDelivery(method: delivery, patchFallback: patchFallbacks)))
2779
+ case .failure(let error):
2780
+ throw error
2897
2781
  }
2898
- } catch let error {
2899
- // Any failure during unzip or rename → clean tmpDir and fail
2900
- let nsError = error as NSError
2901
- NSLog("[BundleStorage] Error during tmpDir processing - Domain: \(nsError.domain), Code: \(nsError.code), Description: \(nsError.localizedDescription)")
2902
- logFileSystemDiagnostics(path: tmpDir, context: "Processing Error")
2903
- try? self.fileSystem.removeItem(atPath: tmpDir)
2782
+ } catch {
2783
+ NSLog("[BundleStorage] Manifest-driven install failed: \(error.localizedDescription).")
2784
+ // Preserve staging for a hash-checked retry. Never trust its presence alone.
2904
2785
  self.cleanupTemporaryFiles([tempDirectory])
2905
-
2906
- // Re-throw specific BundleStorageError if it is one, otherwise wrap as unknown
2907
- if let bundleError = error as? BundleStorageError {
2908
- completion(.failure(bundleError))
2909
- } else {
2910
- completion(.failure(BundleStorageError.unknown(error)))
2911
- }
2786
+ completion(.failure(UpdateFailureError.classifying(
2787
+ error,
2788
+ during: failurePhase,
2789
+ resource: failureResource
2790
+ )))
2912
2791
  }
2913
2792
  }
2914
2793
 
2915
- private static func mapProgress(_ value: Double, start: Double, end: Double) -> Double {
2916
- let clampedValue = min(max(value, 0), 1)
2917
- return start + (clampedValue * (end - start))
2794
+ func markLaunchStarted(bundleId: String?) {
2795
+ // Unlike prepareLaunch, this can run while JavaScript stages a newer bundle.
2796
+ releaseStateLock.lock()
2797
+ defer { releaseStateLock.unlock() }
2798
+ guard let bundleId,
2799
+ var metadata = loadMetadataOrNull(),
2800
+ metadata.verificationPending,
2801
+ !metadata.launchInProgress,
2802
+ metadata.stagingBundleId == bundleId else {
2803
+ return
2804
+ }
2805
+ metadata.launchInProgress = true
2806
+ _ = saveMetadata(metadata)
2918
2807
  }
2919
2808
 
2920
- // MARK: - Rollback Support
2921
-
2922
- /**
2923
- * Marks the current launch as successful after the first content appeared.
2924
- */
2925
2809
  func markLaunchCompleted(bundleId: String?) {
2810
+ // Before the guard: content from the built-in bundle (nil) also counts.
2811
+ readyInterruptedLaunchRetry(bundleId)
2926
2812
  guard let bundleId,
2927
2813
  var metadata = loadMetadataOrNull(),
2928
2814
  metadata.verificationPending,
@@ -2935,6 +2821,7 @@ class BundleFileStorageService: BundleStorageService {
2935
2821
  metadata.stableBundleId = nil
2936
2822
  metadata.stableSelection = nil
2937
2823
  metadata.verificationPending = false
2824
+ metadata.launchInProgress = false
2938
2825
  metadata.pendingTransition = nil
2939
2826
  metadata.pendingSelectionTransition = nil
2940
2827
  metadata.updatedAt = Date().timeIntervalSince1970 * 1000
@@ -2953,9 +2840,10 @@ class BundleFileStorageService: BundleStorageService {
2953
2840
 
2954
2841
  func notifyAppReady() -> [String: Any] {
2955
2842
  guard let report = loadLaunchReport() else {
2956
- if let metadata = loadMetadataOrNull(),
2843
+ if let launchedBundleId = pendingLaunchBundleId,
2844
+ let metadata = loadMetadataOrNull(),
2957
2845
  metadata.verificationPending,
2958
- metadata.stagingBundleId != nil {
2846
+ metadata.stagingBundleId == launchedBundleId {
2959
2847
  return ["status": "PENDING"]
2960
2848
  }
2961
2849
  return ["status": LaunchReportStatus.unchanged.rawValue]
@@ -2981,11 +2869,20 @@ class BundleFileStorageService: BundleStorageService {
2981
2869
  }
2982
2870
 
2983
2871
  /**
2984
- * Returns the crashed bundle history.
2872
+ * Returns the bundles this device refuses to install: crash history, plus a
2873
+ * bundle waiting for its retry until a process that may retry it.
2985
2874
  * @return The crashed history object
2986
2875
  */
2987
2876
  func getCrashHistory() -> CrashedHistory {
2988
- return loadCrashedHistory()
2877
+ crashedHistoryLock.lock()
2878
+ defer { crashedHistoryLock.unlock() }
2879
+ var history = loadCrashedHistory()
2880
+ if let interrupted = history.interruptedLaunch,
2881
+ !interrupted.retryReady || interrupted.bundleId == retryHeldBundleId,
2882
+ !history.contains(interrupted.bundleId) {
2883
+ history.bundles.append(CrashedBundleEntry(bundleId: interrupted.bundleId))
2884
+ }
2885
+ return history
2989
2886
  }
2990
2887
 
2991
2888
  /**
@@ -2993,40 +2890,59 @@ class BundleFileStorageService: BundleStorageService {
2993
2890
  * @return true if clearing was successful
2994
2891
  */
2995
2892
  func clearCrashHistory() -> Bool {
2893
+ crashedHistoryLock.lock()
2894
+ defer { crashedHistoryLock.unlock() }
2996
2895
  var history = loadCrashedHistory()
2997
2896
  history.clear()
2897
+ retryHeldBundleId = nil
2998
2898
  return saveCrashedHistory(history)
2999
2899
  }
3000
2900
 
3001
- func getInstallId() -> String {
3002
- if let installId = loadInstallIdentity()?.installId {
3003
- return installId
3004
- }
3005
-
3006
- let identity = InstallIdentity()
3007
- guard saveInstallIdentity(identity) else {
3008
- return identity.installId
2901
+ /// A crash, or a second unfinished launch of the same bundle, adds the bundle
2902
+ /// to crash history. A first unfinished launch cannot tell a hang from a user
2903
+ /// leaving early, so it leaves the bundle one retry instead.
2904
+ private func recordFailedLaunch(_ bundleId: String, unfinishedLaunch: Bool) {
2905
+ crashedHistoryLock.lock()
2906
+ defer { crashedHistoryLock.unlock() }
2907
+ var history = loadCrashedHistory()
2908
+ let interrupted = history.interruptedLaunch
2909
+ if unfinishedLaunch, interrupted?.bundleId != bundleId {
2910
+ NSLog("[BundleStorage] Launch of \(bundleId) ended before first content; it gets one retry")
2911
+ history.interruptedLaunch = InterruptedLaunch(bundleId: bundleId)
2912
+ retryHeldBundleId = bundleId
2913
+ } else {
2914
+ history.addEntry(bundleId)
2915
+ if interrupted?.bundleId == bundleId {
2916
+ history.interruptedLaunch = nil
2917
+ }
3009
2918
  }
3010
- return identity.installId
3011
- }
3012
-
3013
- func getUserId() -> String? {
3014
- loadUserIdentity()?.userId
3015
- }
3016
-
3017
- func getUsername() -> String? {
3018
- loadUserIdentity()?.username
2919
+ _ = saveCrashedHistory(history)
3019
2920
  }
3020
2921
 
3021
- func setUser(userId: String?, username: String?) {
3022
- let normalizedUserId = normalizeIdentityValue(userId)
3023
- let normalizedUsername = normalizeIdentityValue(username)
3024
- let identity = UserIdentity(userId: normalizedUserId, username: normalizedUsername)
3025
- if identity.isEmpty {
3026
- _ = saveUserIdentity(nil)
2922
+ /// First content appeared, so the app works again. A bundle waiting for its
2923
+ /// retry may be installed from the next process on. This process keeps
2924
+ /// refusing it, so the session that recovered never reloads into it. Content
2925
+ /// from that bundle verifies it.
2926
+ private func readyInterruptedLaunchRetry(_ currentBundleId: String?) {
2927
+ crashedHistoryLock.lock()
2928
+ defer { crashedHistoryLock.unlock() }
2929
+ var history = loadCrashedHistory()
2930
+ guard let interrupted = history.interruptedLaunch else {
2931
+ return
2932
+ }
2933
+ if interrupted.bundleId == currentBundleId {
2934
+ history.interruptedLaunch = nil
2935
+ } else if interrupted.retryReady {
3027
2936
  return
2937
+ } else {
2938
+ history.interruptedLaunch?.retryReady = true
2939
+ retryHeldBundleId = interrupted.bundleId
3028
2940
  }
3029
- _ = saveUserIdentity(identity)
2941
+ _ = saveCrashedHistory(history)
2942
+ }
2943
+
2944
+ func getInstallId() -> String {
2945
+ loadOrCreateInstallIdentity().installId
3030
2946
  }
3031
2947
 
3032
2948
  /**