@hot-updater/react-native 1.0.0-rc.3 → 1.0.0-rc.30

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 (300) hide show
  1. package/android/proguard-rules.pro +17 -2
  2. package/android/src/main/java/com/hotupdater/BuiltInAssetResolver.kt +286 -0
  3. package/android/src/main/java/com/hotupdater/BundleFileStorageService.kt +954 -837
  4. package/android/src/main/java/com/hotupdater/BundleMetadata.kt +15 -11
  5. package/android/src/main/java/com/hotupdater/FileManagerService.kt +45 -0
  6. package/android/src/main/java/com/hotupdater/HotUpdaterException.kt +287 -22
  7. package/android/src/main/java/com/hotupdater/HotUpdaterImpl.kt +43 -31
  8. package/android/src/main/java/com/hotupdater/HotUpdaterRecoveryManager.kt +37 -54
  9. package/android/src/main/java/com/hotupdater/HotUpdaterRecoveryState.kt +64 -0
  10. package/android/src/main/java/com/hotupdater/InstallIdentityService.kt +37 -0
  11. package/android/src/main/java/com/hotupdater/KeyValueStorageService.kt +86 -0
  12. package/android/src/main/java/com/hotupdater/NetworkState.kt +31 -0
  13. package/android/src/main/java/com/hotupdater/NoBackupStorage.kt +17 -0
  14. package/android/src/main/java/com/hotupdater/OkHttpDownloadService.kt +127 -50
  15. package/android/src/main/java/com/hotupdater/PreviousProcessExit.kt +89 -0
  16. package/android/src/main/java/com/hotupdater/ReactNativeValueConverters.kt +34 -0
  17. package/android/src/main/java/com/hotupdater/TarArchiveInputStream.kt +214 -375
  18. package/android/src/main/java/com/hotupdater/TarBrArchiveExtractor.kt +119 -0
  19. package/android/src/newarch/HotUpdater.kt +7 -9
  20. package/android/src/newarch/HotUpdaterModule.kt +109 -84
  21. package/android/src/oldarch/HotUpdater.kt +7 -9
  22. package/android/src/oldarch/HotUpdaterModule.kt +98 -74
  23. package/android/src/oldarch/HotUpdaterSpec.kt +4 -6
  24. package/ios/HotUpdater/Internal/BrotliFileDecompressor.swift +167 -0
  25. package/ios/HotUpdater/Internal/BuiltInAssetResolver.swift +196 -0
  26. package/ios/HotUpdater/Internal/BundleFileStorageService.swift +950 -1112
  27. package/ios/HotUpdater/Internal/BundleMetadata.swift +8 -53
  28. package/ios/HotUpdater/Internal/FileManagerService.swift +48 -0
  29. package/ios/HotUpdater/Internal/FileUtilities.swift +64 -0
  30. package/ios/HotUpdater/Internal/HotUpdater-Bridging-Header.h +0 -1
  31. package/ios/HotUpdater/Internal/HotUpdater.mm +13 -45
  32. package/ios/HotUpdater/Internal/HotUpdaterImpl.swift +190 -73
  33. package/ios/HotUpdater/Internal/KeyValueStorageService.swift +71 -0
  34. package/ios/HotUpdater/Internal/NotificationExtension.swift +0 -2
  35. package/ios/HotUpdater/Internal/TarArchiveExtractor.swift +182 -311
  36. package/ios/HotUpdater/Internal/URLSessionDownloadService.swift +119 -71
  37. package/ios/HotUpdater/Internal/UpdateFailure.swift +390 -0
  38. package/ios/HotUpdater/Recovery/HotUpdaterRecovery.h +16 -0
  39. package/ios/HotUpdater/Recovery/HotUpdaterRecovery.mm +83 -0
  40. package/ios/HotUpdater/Recovery/HotUpdaterRecoveryHooks.mm +49 -0
  41. package/lib/commonjs/appReady.js +111 -0
  42. package/lib/commonjs/appReady.js.map +1 -0
  43. package/lib/commonjs/checkForUpdate.js +124 -69
  44. package/lib/commonjs/checkForUpdate.js.map +1 -1
  45. package/lib/commonjs/clientPlugin.js +13 -0
  46. package/lib/commonjs/clientPlugin.js.map +1 -0
  47. package/lib/commonjs/error.js +4 -13
  48. package/lib/commonjs/error.js.map +1 -1
  49. package/lib/commonjs/fetchJSON.js +16 -27
  50. package/lib/commonjs/fetchJSON.js.map +1 -1
  51. package/lib/commonjs/fetchUpdateResponse.js +54 -0
  52. package/lib/commonjs/fetchUpdateResponse.js.map +1 -0
  53. package/lib/commonjs/httpClient.js +63 -94
  54. package/lib/commonjs/httpClient.js.map +1 -1
  55. package/lib/commonjs/index.js +71 -18
  56. package/lib/commonjs/index.js.map +1 -1
  57. package/lib/commonjs/native.js +78 -60
  58. package/lib/commonjs/native.js.map +1 -1
  59. package/lib/commonjs/pluginHost.js +44 -0
  60. package/lib/commonjs/pluginHost.js.map +1 -0
  61. package/lib/commonjs/releaseCatalogCache.js +36 -87
  62. package/lib/commonjs/releaseCatalogCache.js.map +1 -1
  63. package/lib/commonjs/sdkVersion.js +1 -1
  64. package/lib/commonjs/sdkVersion.js.map +1 -1
  65. package/lib/commonjs/specs/NativeHotUpdater.js.map +1 -1
  66. package/lib/commonjs/store.js +3 -10
  67. package/lib/commonjs/store.js.map +1 -1
  68. package/lib/commonjs/updateError.js +132 -0
  69. package/lib/commonjs/updateError.js.map +1 -0
  70. package/lib/commonjs/wrap.js +43 -26
  71. package/lib/commonjs/wrap.js.map +1 -1
  72. package/lib/module/appReady.js +103 -0
  73. package/lib/module/appReady.js.map +1 -0
  74. package/lib/module/checkForUpdate.js +117 -64
  75. package/lib/module/checkForUpdate.js.map +1 -1
  76. package/lib/module/clientPlugin.js +4 -0
  77. package/lib/module/clientPlugin.js.map +1 -0
  78. package/lib/module/error.js +4 -13
  79. package/lib/module/error.js.map +1 -1
  80. package/lib/module/fetchJSON.js +14 -26
  81. package/lib/module/fetchJSON.js.map +1 -1
  82. package/lib/module/fetchUpdateResponse.js +49 -0
  83. package/lib/module/fetchUpdateResponse.js.map +1 -0
  84. package/lib/module/httpClient.js +62 -93
  85. package/lib/module/httpClient.js.map +1 -1
  86. package/lib/module/index.js +58 -20
  87. package/lib/module/index.js.map +1 -1
  88. package/lib/module/native.js +71 -53
  89. package/lib/module/native.js.map +1 -1
  90. package/lib/module/pluginHost.js +35 -0
  91. package/lib/module/pluginHost.js.map +1 -0
  92. package/lib/module/releaseCatalogCache.js +32 -82
  93. package/lib/module/releaseCatalogCache.js.map +1 -1
  94. package/lib/module/sdkVersion.js +1 -1
  95. package/lib/module/sdkVersion.js.map +1 -1
  96. package/lib/module/specs/NativeHotUpdater.js.map +1 -1
  97. package/lib/module/store.js +3 -10
  98. package/lib/module/store.js.map +1 -1
  99. package/lib/module/updateError.js +126 -0
  100. package/lib/module/updateError.js.map +1 -0
  101. package/lib/module/wrap.js +43 -26
  102. package/lib/module/wrap.js.map +1 -1
  103. package/lib/typescript/commonjs/appReady.d.ts +23 -0
  104. package/lib/typescript/commonjs/appReady.d.ts.map +1 -0
  105. package/lib/typescript/commonjs/checkForUpdate.d.ts +16 -4
  106. package/lib/typescript/commonjs/checkForUpdate.d.ts.map +1 -1
  107. package/lib/typescript/commonjs/clientPlugin.d.ts +2 -0
  108. package/lib/typescript/commonjs/clientPlugin.d.ts.map +1 -0
  109. package/lib/typescript/commonjs/error.d.ts +4 -13
  110. package/lib/typescript/commonjs/error.d.ts.map +1 -1
  111. package/lib/typescript/commonjs/fetchJSON.d.ts +5 -5
  112. package/lib/typescript/commonjs/fetchJSON.d.ts.map +1 -1
  113. package/lib/typescript/commonjs/fetchUpdateResponse.d.ts +14 -0
  114. package/lib/typescript/commonjs/fetchUpdateResponse.d.ts.map +1 -0
  115. package/lib/typescript/commonjs/httpClient.d.ts +4 -33
  116. package/lib/typescript/commonjs/httpClient.d.ts.map +1 -1
  117. package/lib/typescript/commonjs/index.d.ts +4 -8
  118. package/lib/typescript/commonjs/index.d.ts.map +1 -1
  119. package/lib/typescript/commonjs/native.d.ts +31 -22
  120. package/lib/typescript/commonjs/native.d.ts.map +1 -1
  121. package/lib/typescript/commonjs/pluginHost.d.ts +4 -0
  122. package/lib/typescript/commonjs/pluginHost.d.ts.map +1 -0
  123. package/lib/typescript/commonjs/releaseCatalogCache.d.ts +8 -14
  124. package/lib/typescript/commonjs/releaseCatalogCache.d.ts.map +1 -1
  125. package/lib/typescript/commonjs/sdkVersion.d.ts +1 -1
  126. package/lib/typescript/commonjs/sdkVersion.d.ts.map +1 -1
  127. package/lib/typescript/commonjs/specs/NativeHotUpdater.d.ts +42 -47
  128. package/lib/typescript/commonjs/specs/NativeHotUpdater.d.ts.map +1 -1
  129. package/lib/typescript/commonjs/store.d.ts +1 -3
  130. package/lib/typescript/commonjs/store.d.ts.map +1 -1
  131. package/lib/typescript/commonjs/types.d.ts +1 -1
  132. package/lib/typescript/commonjs/types.d.ts.map +1 -1
  133. package/lib/typescript/commonjs/updateError.d.ts +26 -0
  134. package/lib/typescript/commonjs/updateError.d.ts.map +1 -0
  135. package/lib/typescript/commonjs/wrap.d.ts.map +1 -1
  136. package/lib/typescript/commonjs/wrap.types.d.ts +6 -7
  137. package/lib/typescript/commonjs/wrap.types.d.ts.map +1 -1
  138. package/lib/typescript/module/appReady.d.ts +23 -0
  139. package/lib/typescript/module/appReady.d.ts.map +1 -0
  140. package/lib/typescript/module/checkForUpdate.d.ts +16 -4
  141. package/lib/typescript/module/checkForUpdate.d.ts.map +1 -1
  142. package/lib/typescript/module/clientPlugin.d.ts +2 -0
  143. package/lib/typescript/module/clientPlugin.d.ts.map +1 -0
  144. package/lib/typescript/module/error.d.ts +4 -13
  145. package/lib/typescript/module/error.d.ts.map +1 -1
  146. package/lib/typescript/module/fetchJSON.d.ts +5 -5
  147. package/lib/typescript/module/fetchJSON.d.ts.map +1 -1
  148. package/lib/typescript/module/fetchUpdateResponse.d.ts +14 -0
  149. package/lib/typescript/module/fetchUpdateResponse.d.ts.map +1 -0
  150. package/lib/typescript/module/httpClient.d.ts +4 -33
  151. package/lib/typescript/module/httpClient.d.ts.map +1 -1
  152. package/lib/typescript/module/index.d.ts +4 -8
  153. package/lib/typescript/module/index.d.ts.map +1 -1
  154. package/lib/typescript/module/native.d.ts +31 -22
  155. package/lib/typescript/module/native.d.ts.map +1 -1
  156. package/lib/typescript/module/pluginHost.d.ts +4 -0
  157. package/lib/typescript/module/pluginHost.d.ts.map +1 -0
  158. package/lib/typescript/module/releaseCatalogCache.d.ts +8 -14
  159. package/lib/typescript/module/releaseCatalogCache.d.ts.map +1 -1
  160. package/lib/typescript/module/sdkVersion.d.ts +1 -1
  161. package/lib/typescript/module/sdkVersion.d.ts.map +1 -1
  162. package/lib/typescript/module/specs/NativeHotUpdater.d.ts +42 -47
  163. package/lib/typescript/module/specs/NativeHotUpdater.d.ts.map +1 -1
  164. package/lib/typescript/module/store.d.ts +1 -3
  165. package/lib/typescript/module/store.d.ts.map +1 -1
  166. package/lib/typescript/module/types.d.ts +1 -1
  167. package/lib/typescript/module/types.d.ts.map +1 -1
  168. package/lib/typescript/module/updateError.d.ts +26 -0
  169. package/lib/typescript/module/updateError.d.ts.map +1 -0
  170. package/lib/typescript/module/wrap.d.ts.map +1 -1
  171. package/lib/typescript/module/wrap.types.d.ts +6 -7
  172. package/lib/typescript/module/wrap.types.d.ts.map +1 -1
  173. package/package.json +7 -4
  174. package/src/appReady.ts +149 -0
  175. package/src/checkForUpdate.ts +151 -88
  176. package/src/clientPlugin.ts +18 -0
  177. package/src/error.ts +4 -14
  178. package/src/fetchJSON.ts +14 -30
  179. package/src/fetchUpdateResponse.ts +54 -0
  180. package/src/httpClient.ts +88 -159
  181. package/src/index.ts +90 -24
  182. package/src/native.ts +106 -102
  183. package/src/pluginHost.ts +45 -0
  184. package/src/releaseCatalogCache.ts +43 -172
  185. package/src/sdkVersion.ts +1 -1
  186. package/src/specs/NativeHotUpdater.ts +41 -48
  187. package/src/store.ts +2 -23
  188. package/src/types.ts +1 -1
  189. package/src/updateError.ts +153 -0
  190. package/src/wrap.tsx +57 -26
  191. package/src/wrap.types.ts +6 -7
  192. package/android/src/main/java/com/hotupdater/DecompressService.kt +0 -89
  193. package/android/src/main/java/com/hotupdater/DecompressionStrategy.kt +0 -26
  194. package/android/src/main/java/com/hotupdater/TarBrDecompressionStrategy.kt +0 -117
  195. package/android/src/main/java/com/hotupdater/TarGzDecompressionStrategy.kt +0 -117
  196. package/android/src/main/java/com/hotupdater/ZipDecompressionStrategy.kt +0 -192
  197. package/android/src/test/java/com/hotupdater/BsdiffPatchTest.kt +0 -46
  198. package/android/src/test/java/com/hotupdater/BundleFileStorageServiceTest.kt +0 -799
  199. package/android/src/test/java/com/hotupdater/HotUpdaterImplTest.kt +0 -273
  200. package/android/src/test/java/com/hotupdater/NativeConfigUtilsTest.kt +0 -20
  201. package/android/src/test/java/com/hotupdater/OkHttpDownloadServiceTest.kt +0 -111
  202. package/android/src/test/java/com/hotupdater/RelativePathResolverTest.kt +0 -41
  203. package/android/src/test/java/com/hotupdater/ReleaseCatalogCacheServiceTest.kt +0 -118
  204. package/android/src/test/java/com/hotupdater/TarArchiveInputStreamTest.kt +0 -68
  205. package/ios/HotUpdater/Internal/ArchiveExtractionUtilities.swift +0 -178
  206. package/ios/HotUpdater/Internal/DecompressService.swift +0 -112
  207. package/ios/HotUpdater/Internal/DecompressionStrategy.swift +0 -22
  208. package/ios/HotUpdater/Internal/StreamingTarArchiveExtractor.swift +0 -370
  209. package/ios/HotUpdater/Internal/TarBrDecompressionStrategy.swift +0 -39
  210. package/ios/HotUpdater/Internal/TarGzDecompressionStrategy.swift +0 -59
  211. package/ios/HotUpdater/Internal/ZipArchiveExtractor.swift +0 -482
  212. package/ios/HotUpdater/Internal/ZipDecompressionStrategy.swift +0 -56
  213. package/lib/commonjs/catalogCacheNative.spec.js +0 -31
  214. package/lib/commonjs/catalogCacheNative.spec.js.map +0 -1
  215. package/lib/commonjs/checkForUpdate.releaseCatalog.spec.js +0 -615
  216. package/lib/commonjs/checkForUpdate.releaseCatalog.spec.js.map +0 -1
  217. package/lib/commonjs/httpClient.spec.js +0 -102
  218. package/lib/commonjs/httpClient.spec.js.map +0 -1
  219. package/lib/commonjs/index.spec.js +0 -302
  220. package/lib/commonjs/index.spec.js.map +0 -1
  221. package/lib/commonjs/iosInterop.spec.js +0 -18
  222. package/lib/commonjs/iosInterop.spec.js.map +0 -1
  223. package/lib/commonjs/native.spec.js +0 -883
  224. package/lib/commonjs/native.spec.js.map +0 -1
  225. package/lib/commonjs/notifyAppReadyInsights.boundary.spec.js +0 -218
  226. package/lib/commonjs/notifyAppReadyInsights.boundary.spec.js.map +0 -1
  227. package/lib/commonjs/notifyAppReadyInsights.js +0 -133
  228. package/lib/commonjs/notifyAppReadyInsights.js.map +0 -1
  229. package/lib/commonjs/notifyAppReadyInsights.spec.js +0 -246
  230. package/lib/commonjs/notifyAppReadyInsights.spec.js.map +0 -1
  231. package/lib/commonjs/notifyAppReadyInsights.test-utils.js +0 -24
  232. package/lib/commonjs/notifyAppReadyInsights.test-utils.js.map +0 -1
  233. package/lib/commonjs/notifyAppReadyInsights.transport.spec.js +0 -117
  234. package/lib/commonjs/notifyAppReadyInsights.transport.spec.js.map +0 -1
  235. package/lib/commonjs/releaseCatalogCache.spec.js +0 -195
  236. package/lib/commonjs/releaseCatalogCache.spec.js.map +0 -1
  237. package/lib/commonjs/releaseCatalogCacheNativeContract.spec.js +0 -64
  238. package/lib/commonjs/releaseCatalogCacheNativeContract.spec.js.map +0 -1
  239. package/lib/commonjs/store.notification.spec.js +0 -76
  240. package/lib/commonjs/store.notification.spec.js.map +0 -1
  241. package/lib/commonjs/store.spec.js +0 -316
  242. package/lib/commonjs/store.spec.js.map +0 -1
  243. package/lib/commonjs/wrap.spec.js +0 -329
  244. package/lib/commonjs/wrap.spec.js.map +0 -1
  245. package/lib/module/catalogCacheNative.spec.js +0 -31
  246. package/lib/module/catalogCacheNative.spec.js.map +0 -1
  247. package/lib/module/checkForUpdate.releaseCatalog.spec.js +0 -614
  248. package/lib/module/checkForUpdate.releaseCatalog.spec.js.map +0 -1
  249. package/lib/module/httpClient.spec.js +0 -102
  250. package/lib/module/httpClient.spec.js.map +0 -1
  251. package/lib/module/index.spec.js +0 -301
  252. package/lib/module/index.spec.js.map +0 -1
  253. package/lib/module/iosInterop.spec.js +0 -18
  254. package/lib/module/iosInterop.spec.js.map +0 -1
  255. package/lib/module/native.spec.js +0 -882
  256. package/lib/module/native.spec.js.map +0 -1
  257. package/lib/module/notifyAppReadyInsights.boundary.spec.js +0 -217
  258. package/lib/module/notifyAppReadyInsights.boundary.spec.js.map +0 -1
  259. package/lib/module/notifyAppReadyInsights.js +0 -128
  260. package/lib/module/notifyAppReadyInsights.js.map +0 -1
  261. package/lib/module/notifyAppReadyInsights.spec.js +0 -245
  262. package/lib/module/notifyAppReadyInsights.spec.js.map +0 -1
  263. package/lib/module/notifyAppReadyInsights.test-utils.js +0 -18
  264. package/lib/module/notifyAppReadyInsights.test-utils.js.map +0 -1
  265. package/lib/module/notifyAppReadyInsights.transport.spec.js +0 -116
  266. package/lib/module/notifyAppReadyInsights.transport.spec.js.map +0 -1
  267. package/lib/module/releaseCatalogCache.spec.js +0 -195
  268. package/lib/module/releaseCatalogCache.spec.js.map +0 -1
  269. package/lib/module/releaseCatalogCacheNativeContract.spec.js +0 -64
  270. package/lib/module/releaseCatalogCacheNativeContract.spec.js.map +0 -1
  271. package/lib/module/store.notification.spec.js +0 -77
  272. package/lib/module/store.notification.spec.js.map +0 -1
  273. package/lib/module/store.spec.js +0 -315
  274. package/lib/module/store.spec.js.map +0 -1
  275. package/lib/module/wrap.spec.js +0 -330
  276. package/lib/module/wrap.spec.js.map +0 -1
  277. package/lib/typescript/commonjs/notifyAppReadyInsights.d.ts +0 -13
  278. package/lib/typescript/commonjs/notifyAppReadyInsights.d.ts.map +0 -1
  279. package/lib/typescript/commonjs/notifyAppReadyInsights.test-utils.d.ts +0 -8
  280. package/lib/typescript/commonjs/notifyAppReadyInsights.test-utils.d.ts.map +0 -1
  281. package/lib/typescript/module/notifyAppReadyInsights.d.ts +0 -13
  282. package/lib/typescript/module/notifyAppReadyInsights.d.ts.map +0 -1
  283. package/lib/typescript/module/notifyAppReadyInsights.test-utils.d.ts +0 -8
  284. package/lib/typescript/module/notifyAppReadyInsights.test-utils.d.ts.map +0 -1
  285. package/src/catalogCacheNative.spec.ts +0 -63
  286. package/src/checkForUpdate.releaseCatalog.spec.ts +0 -611
  287. package/src/httpClient.spec.ts +0 -132
  288. package/src/index.spec.ts +0 -337
  289. package/src/iosInterop.spec.ts +0 -30
  290. package/src/native.spec.ts +0 -945
  291. package/src/notifyAppReadyInsights.boundary.spec.ts +0 -234
  292. package/src/notifyAppReadyInsights.spec.ts +0 -243
  293. package/src/notifyAppReadyInsights.test-utils.ts +0 -31
  294. package/src/notifyAppReadyInsights.transport.spec.ts +0 -130
  295. package/src/notifyAppReadyInsights.ts +0 -194
  296. package/src/releaseCatalogCache.spec.ts +0 -282
  297. package/src/releaseCatalogCacheNativeContract.spec.ts +0 -80
  298. package/src/store.notification.spec.ts +0 -92
  299. package/src/store.spec.ts +0 -372
  300. 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]
@@ -293,10 +298,7 @@ public protocol BundleStorageService {
293
298
  func getCrashHistory() -> CrashedHistory
294
299
  func clearCrashHistory() -> Bool
295
300
  func getInstallId() -> String
296
- func getUserId() -> String?
297
- func getUsername() -> String?
298
- func setUser(userId: String?, username: String?)
299
-
301
+
300
302
  /**
301
303
  * Gets the base URL for the current active bundle directory
302
304
  * @return Base URL string (e.g., "file:///data/.../bundle-store/abc123") or empty string
@@ -306,7 +308,7 @@ public protocol BundleStorageService {
306
308
 
307
309
  /**
308
310
  * Gets the current active bundle ID from bundle storage.
309
- * Reads manifest.json first and falls back to older metadata when needed.
311
+ * Reads the required OTA manifest.json.
310
312
  */
311
313
  func getBundleId() -> String?
312
314
 
@@ -332,6 +334,8 @@ public extension BundleStorageService {
332
334
  }
333
335
 
334
336
  class BundleFileStorageService: BundleStorageService {
337
+ private static let maximumSafeByteSize: Int64 = 9_007_199_254_740_991
338
+
335
339
  private struct ActiveBundleMetadataSnapshot {
336
340
  let activeBundleId: String
337
341
  let bundleId: String?
@@ -341,80 +345,52 @@ class BundleFileStorageService: BundleStorageService {
341
345
  private struct ParsedBundleManifest {
342
346
  let bundleId: String
343
347
  let assets: [String: ParsedManifestAsset]
348
+ let archive: ParsedArchive?
344
349
  }
345
350
 
346
351
  private struct ParsedManifestAsset {
347
352
  let fileHash: String
348
353
  let signature: String?
354
+ let byteSize: Int64?
355
+ let downloadByteSize: Int64?
349
356
  }
350
357
 
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
358
+ private struct ParsedArchive {
359
+ let downloadFileHash: String
360
+ let downloadByteSize: Int64
361
+ let tarByteSize: Int64
360
362
  }
361
363
 
362
364
  private let fileSystem: FileSystemService
363
365
  private let downloadService: DownloadService
364
- private let decompressService: DecompressService
366
+ /// The wait before a download's second attempt; it doubles for each later one.
367
+ private let downloadRetryDelay: TimeInterval
365
368
  private let preferences: PreferencesService
366
369
  private let isolationKey: String
367
370
 
368
- private let id = Int.random(in: 1..<100)
369
-
370
371
  // Queue for potentially long-running sequences within updateBundle or for explicit background tasks.
371
372
  private let fileOperationQueue: DispatchQueue
372
373
 
373
- private var activeTasks: [URLSessionTask] = []
374
-
374
+ private var hasPreparedLaunch = false
375
375
  private var currentLaunchReport: LaunchReport?
376
376
  private let activeBundleMetadataLock = NSLock()
377
377
  private var activeBundleMetadataSnapshot: ActiveBundleMetadataSnapshot?
378
378
  private let builtInBundleIdProvider: () -> String
379
+ private let builtInAssetResolver: BuiltInAssetResolver
379
380
  private let updateStrategyProvider: () -> UpdateStrategy
380
381
  private let releaseStateLock = NSRecursiveLock()
381
382
  private var pendingInstallSelections: [String: PersistedSelection] = [:]
382
383
 
384
+ private let installIdentityLock = NSLock()
383
385
  private var currentInstallIdentity: InstallIdentity?
384
- private var currentUserIdentity: UserIdentity?
385
386
 
386
387
  private let protectedBundleStoreEntries: Set<String> = [
387
388
  BundleMetadata.metadataFilename,
388
389
  CrashedHistory.crashedHistoryFilename,
389
390
  LaunchReport.launchReportFilename,
390
- InstallIdentity.installIdentityFilename,
391
- UserIdentity.userIdentityFilename,
391
+ "builtin-index-v1.json",
392
392
  ]
393
393
 
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
394
  private func createDiffProgressFiles(
419
395
  changedAssets: [String: ChangedAssetDescriptor]
420
396
  ) -> [UpdateProgressPayload.DiffProgressFileSnapshot] {
@@ -576,10 +552,7 @@ class BundleFileStorageService: BundleStorageService {
576
552
  return
577
553
  }
578
554
 
579
- try StreamingTarArchiveExtractor.decompressBrotliFile(
580
- from: downloadedPath,
581
- to: destinationPath
582
- )
555
+ try BrotliFileDecompressor.decompress(from: downloadedPath, to: destinationPath)
583
556
  try? fileSystem.removeItem(atPath: downloadedPath)
584
557
  }
585
558
 
@@ -604,7 +577,7 @@ class BundleFileStorageService: BundleStorageService {
604
577
 
605
578
  let sourcePath: String
606
579
  do {
607
- sourcePath = try ArchiveExtractionUtilities.extractionURL(
580
+ sourcePath = try FileUtilities.fileURL(
608
581
  for: assetPath,
609
582
  destinationRoot: currentBundleDir
610
583
  ).path
@@ -722,6 +695,14 @@ class BundleFileStorageService: BundleStorageService {
722
695
  atPath path: String,
723
696
  asset: ParsedManifestAsset
724
697
  ) throws {
698
+ if let expectedByteSize = asset.byteSize {
699
+ let attributes = try fileSystem.attributesOfItem(atPath: path)
700
+ guard let size = attributes[.size] as? NSNumber,
701
+ size.int64Value == expectedByteSize else {
702
+ throw BundleStorageError.signatureVerificationFailed(.fileHashMismatch)
703
+ }
704
+ }
705
+
725
706
  guard let actualHash = HashUtils.calculateSHA256(fileURL: URL(fileURLWithPath: path)) else {
726
707
  throw BundleStorageError.signatureVerificationFailed(.fileReadFailed)
727
708
  }
@@ -751,22 +732,28 @@ class BundleFileStorageService: BundleStorageService {
751
732
 
752
733
  public init(fileSystem: FileSystemService,
753
734
  downloadService: DownloadService,
754
- decompressService: DecompressService,
755
735
  preferences: PreferencesService,
756
736
  isolationKey: String,
757
737
  builtInBundleIdProvider: @escaping () -> String = { "" },
738
+ builtInAssetResolver: BuiltInAssetResolver? = nil,
758
739
  updateStrategyProvider: @escaping () -> UpdateStrategy = {
759
740
  let fingerprintHash = HotUpdaterConfig.shared.fingerprintHash
760
741
  ?? Bundle.main.object(forInfoDictionaryKey: "HOT_UPDATER_FINGERPRINT_HASH") as? String
761
742
  return (fingerprintHash?.isEmpty == false) ? .fingerprint : .appVersion
762
- }) {
743
+ },
744
+ downloadRetryDelay: TimeInterval = 1) {
763
745
 
764
746
  self.fileSystem = fileSystem
747
+ self.downloadRetryDelay = downloadRetryDelay
765
748
  self.downloadService = downloadService
766
- self.decompressService = decompressService
767
749
  self.preferences = preferences
768
750
  self.isolationKey = isolationKey
769
751
  self.builtInBundleIdProvider = builtInBundleIdProvider
752
+ self.builtInAssetResolver = builtInAssetResolver ?? IOSBuiltInAssetResolver(
753
+ cacheURL: URL(fileURLWithPath: fileSystem.documentsPath())
754
+ .appendingPathComponent("bundle-store", isDirectory: true)
755
+ .appendingPathComponent("builtin-index-v1.json")
756
+ )
770
757
  self.updateStrategyProvider = updateStrategyProvider
771
758
 
772
759
  // Create queue for file operations
@@ -779,6 +766,7 @@ class BundleFileStorageService: BundleStorageService {
779
766
 
780
767
  // Clean up old bundles if isolationKey format changed
781
768
  checkAndCleanupIfIsolationKeyChanged()
769
+ recoverInterruptedPromotions()
782
770
  }
783
771
 
784
772
  // MARK: - Metadata File Paths
@@ -804,18 +792,9 @@ class BundleFileStorageService: BundleStorageService {
804
792
  return URL(fileURLWithPath: storeDir).appendingPathComponent(LaunchReport.launchReportFilename)
805
793
  }
806
794
 
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)
795
+ private func installIdentityFileURL() -> URL {
796
+ NoBackupStorage.directory(in: fileSystem)
797
+ .appendingPathComponent(InstallIdentity.installIdentityFilename)
819
798
  }
820
799
 
821
800
  // MARK: - Metadata Operations
@@ -864,54 +843,28 @@ class BundleFileStorageService: BundleStorageService {
864
843
  _ = report.save(to: file)
865
844
  }
866
845
 
867
- private func loadInstallIdentity() -> InstallIdentity? {
846
+ /// Loads the install identity, creating it once when its file is missing.
847
+ @discardableResult
848
+ private func loadOrCreateInstallIdentity() -> InstallIdentity {
849
+ installIdentityLock.lock()
850
+ defer { installIdentityLock.unlock() }
851
+
868
852
  if let currentInstallIdentity {
869
853
  return currentInstallIdentity
870
854
  }
871
- guard let file = installIdentityFileURL(),
872
- let identity = InstallIdentity.load(from: file) else {
873
- return nil
855
+ let file = installIdentityFileURL()
856
+ if let identity = InstallIdentity.load(from: file) {
857
+ currentInstallIdentity = identity
858
+ return identity
874
859
  }
875
- currentInstallIdentity = identity
876
- return identity
877
- }
878
860
 
879
- private func saveInstallIdentity(_ identity: InstallIdentity) -> Bool {
861
+ let identity = InstallIdentity()
862
+ // Keep the new id for this process even when it cannot be saved.
880
863
  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
864
+ _ = identity.save(to: file)
896
865
  return identity
897
866
  }
898
867
 
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
868
  private func resolveBuiltInBundleId() -> String? {
916
869
  let bundleId = builtInBundleIdProvider().trimmingCharacters(in: .whitespacesAndNewlines)
917
870
  return bundleId.isEmpty ? nil : bundleId
@@ -938,11 +891,10 @@ class BundleFileStorageService: BundleStorageService {
938
891
  }
939
892
 
940
893
  private func createInitialMetadata() -> BundleMetadata {
941
- let currentBundleId = getCachedBundleId()
942
894
  return BundleMetadata(
943
895
  isolationKey: isolationKey,
944
896
  stableBundleId: nil,
945
- stagingBundleId: currentBundleId,
897
+ stagingBundleId: nil,
946
898
  verificationPending: false,
947
899
  pendingTransition: nil
948
900
  )
@@ -956,18 +908,18 @@ class BundleFileStorageService: BundleStorageService {
956
908
  }
957
909
 
958
910
  private func getActiveBundleId() -> String? {
959
- if let cachedBundleId = getCachedBundleId() {
911
+ guard let metadata = loadMetadataOrNull() else { return nil }
912
+ if let cachedBundleId = getCachedBundleId(),
913
+ cachedBundleId == metadata.stagingBundleId || cachedBundleId == metadata.stableBundleId {
960
914
  return cachedBundleId
961
915
  }
962
916
 
963
- let metadata = loadMetadataOrNull()
964
-
965
- if let stagingBundleId = metadata?.stagingBundleId,
966
- metadata?.verificationPending == false {
917
+ if let stagingBundleId = metadata.stagingBundleId,
918
+ metadata.verificationPending == false {
967
919
  return stagingBundleId
968
920
  }
969
921
 
970
- if let stableBundleId = metadata?.stableBundleId {
922
+ if let stableBundleId = metadata.stableBundleId {
971
923
  return stableBundleId
972
924
  }
973
925
 
@@ -1017,22 +969,6 @@ class BundleFileStorageService: BundleStorageService {
1017
969
  }
1018
970
  }
1019
971
 
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
972
  private func resolveActiveBundleMetadataSnapshot(
1037
973
  activeBundleId: String,
1038
974
  bundleDirectory: String
@@ -1041,9 +977,7 @@ class BundleFileStorageService: BundleStorageService {
1041
977
  let manifestBundleId =
1042
978
  (manifest["bundleId"] as? String)?
1043
979
  .trimmingCharacters(in: .whitespacesAndNewlines)
1044
- let resolvedBundleId =
1045
- (manifestBundleId?.isEmpty == false ? manifestBundleId : nil) ??
1046
- readCompatibilityBundleId(in: bundleDirectory)
980
+ let resolvedBundleId = manifestBundleId?.isEmpty == false ? manifestBundleId : nil
1047
981
 
1048
982
  return ActiveBundleMetadataSnapshot(
1049
983
  activeBundleId: activeBundleId,
@@ -1070,34 +1004,6 @@ class BundleFileStorageService: BundleStorageService {
1070
1004
  )
1071
1005
  }
1072
1006
 
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
1007
  private func readManifest(in bundleDirectory: String) -> [String: Any]? {
1102
1008
  let manifestPath = (bundleDirectory as NSString).appendingPathComponent("manifest.json")
1103
1009
  guard fileSystem.fileExists(atPath: manifestPath) else {
@@ -1124,7 +1030,7 @@ class BundleFileStorageService: BundleStorageService {
1124
1030
 
1125
1031
  var assets: [String: ParsedManifestAsset] = [:]
1126
1032
  for (assetPath, assetValue) in rawAssets {
1127
- guard ArchiveExtractionUtilities.normalizedRelativePath(from: assetPath) == assetPath else {
1033
+ guard FileUtilities.normalizedRelativePath(from: assetPath) == assetPath else {
1128
1034
  return nil
1129
1035
  }
1130
1036
  guard let asset = assetValue as? [String: Any],
@@ -1137,42 +1043,136 @@ class BundleFileStorageService: BundleStorageService {
1137
1043
  .trimmingCharacters(in: .whitespacesAndNewlines)
1138
1044
  assets[assetPath] = ParsedManifestAsset(
1139
1045
  fileHash: fileHash,
1140
- signature: signature?.isEmpty == false ? signature : nil
1046
+ signature: signature?.isEmpty == false ? signature : nil,
1047
+ byteSize: Self.parseSafeByteSize(asset["byteSize"]),
1048
+ downloadByteSize: Self.parseSafeByteSize(asset["downloadByteSize"])
1049
+ )
1050
+ }
1051
+
1052
+ let archive: ParsedArchive?
1053
+ if let payload = manifest["archive"] as? [String: Any],
1054
+ let downloadFileHash = payload["downloadFileHash"] as? String,
1055
+ Self.isPlainSHA256(downloadFileHash),
1056
+ let downloadByteSize = Self.parseSafeByteSize(payload["downloadByteSize"]),
1057
+ downloadByteSize > 0,
1058
+ let tarByteSize = Self.parseSafeByteSize(payload["tarByteSize"]),
1059
+ tarByteSize >= 1024 {
1060
+ archive = ParsedArchive(
1061
+ downloadFileHash: downloadFileHash,
1062
+ downloadByteSize: downloadByteSize,
1063
+ tarByteSize: tarByteSize
1141
1064
  )
1065
+ } else {
1066
+ archive = nil
1142
1067
  }
1143
1068
 
1144
- return ParsedBundleManifest(bundleId: manifestBundleId, assets: assets)
1069
+ return ParsedBundleManifest(
1070
+ bundleId: manifestBundleId,
1071
+ assets: assets,
1072
+ archive: archive
1073
+ )
1145
1074
  }
1146
1075
 
1147
- private func parseBundleManifest(fromFile manifestPath: String) -> ParsedBundleManifest? {
1148
- guard let manifest = readManifest(in: (manifestPath as NSString).deletingLastPathComponent) else {
1076
+ private static func parseSafeByteSize(_ value: Any?) -> Int64? {
1077
+ guard let number = value as? NSNumber,
1078
+ String(cString: number.objCType) != "c" else {
1079
+ return nil
1080
+ }
1081
+ let doubleValue = number.doubleValue
1082
+ guard doubleValue.isFinite,
1083
+ doubleValue >= 0,
1084
+ doubleValue <= Double(maximumSafeByteSize),
1085
+ doubleValue.rounded(.towardZero) == doubleValue else {
1149
1086
  return nil
1150
1087
  }
1088
+ return Int64(doubleValue)
1089
+ }
1151
1090
 
1152
- return parseBundleManifest(from: manifest)
1091
+ private static func isPlainSHA256(_ value: String) -> Bool {
1092
+ value.count == 64 && value.allSatisfy {
1093
+ ("0"..."9").contains($0) || ("a"..."f").contains($0)
1094
+ }
1153
1095
  }
1154
1096
 
1155
- private func writeManifestFile(_ manifest: ParsedBundleManifest, to destination: String) throws {
1097
+ private func copyVerifiedManifest(from source: String, to destination: String) throws {
1156
1098
  let manifestDirectory = (destination as NSString).deletingLastPathComponent
1157
1099
  guard fileSystem.createDirectory(atPath: manifestDirectory) else {
1158
1100
  throw BundleStorageError.directoryCreationFailed
1159
1101
  }
1102
+ if fileSystem.fileExists(atPath: destination) {
1103
+ try fileSystem.removeItem(atPath: destination)
1104
+ }
1105
+ do {
1106
+ try fileSystem.copyItem(atPath: source, toPath: destination)
1107
+ } catch {
1108
+ throw BundleStorageError.moveOperationFailed(error)
1109
+ }
1110
+ }
1111
+
1112
+ private func shouldUseArchive(
1113
+ archive: ParsedArchive?,
1114
+ archiveUrl: URL?,
1115
+ downloads: [String: ChangedAssetDescriptor],
1116
+ assets: [String: ParsedManifestAsset]
1117
+ ) -> Bool {
1118
+ guard downloads.count >= 2,
1119
+ archive != nil,
1120
+ archiveUrl != nil,
1121
+ assets.values.allSatisfy({ $0.byteSize != nil }) else {
1122
+ return false
1123
+ }
1160
1124
 
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
1125
+ var individualLowerBound: Int64 = 0
1126
+ var hasOfferedPatch = false
1127
+ for (path, descriptor) in downloads {
1128
+ guard let originalByteSize = assets[path]?.downloadByteSize else {
1129
+ return false
1130
+ }
1131
+ let plannedByteSize: Int64
1132
+ if let patch = descriptor.patch {
1133
+ hasOfferedPatch = true
1134
+ guard let patchByteSize = patch.byteSize,
1135
+ patchByteSize >= 0,
1136
+ patchByteSize <= Self.maximumSafeByteSize else {
1137
+ return false
1167
1138
  }
1168
- partialResult[entry.key] = assetPayload
1139
+ plannedByteSize = min(originalByteSize, patchByteSize)
1140
+ } else {
1141
+ plannedByteSize = originalByteSize
1169
1142
  }
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))
1143
+ let (sum, overflowed) = individualLowerBound.addingReportingOverflow(plannedByteSize)
1144
+ guard !overflowed,
1145
+ sum <= Self.maximumSafeByteSize else { return false }
1146
+ individualLowerBound = sum
1147
+ }
1148
+
1149
+ guard let archive else { return false }
1150
+ var logicalByteSize: Int64 = 0
1151
+ for asset in assets.values {
1152
+ guard let byteSize = asset.byteSize else { return false }
1153
+ let (sum, overflowed) = logicalByteSize.addingReportingOverflow(byteSize)
1154
+ guard !overflowed,
1155
+ sum <= Self.maximumSafeByteSize else { return false }
1156
+ logicalByteSize = sum
1157
+ }
1158
+ let (tarOverhead, overheadUnderflowed) = archive.tarByteSize
1159
+ .subtractingReportingOverflow(logicalByteSize)
1160
+ guard !overheadUnderflowed,
1161
+ tarOverhead >= 0 else { return false }
1162
+
1163
+ if archive.downloadByteSize <= individualLowerBound {
1164
+ return true
1165
+ }
1166
+
1167
+ guard downloads.count == assets.count,
1168
+ !hasOfferedPatch else {
1169
+ return false
1170
+ }
1171
+ let (archiveBound, boundOverflowed) = individualLowerBound
1172
+ .addingReportingOverflow(tarOverhead)
1173
+ guard !boundOverflowed,
1174
+ archiveBound <= Self.maximumSafeByteSize else { return false }
1175
+ return archive.downloadByteSize <= archiveBound
1176
1176
  }
1177
1177
 
1178
1178
  /**
@@ -1539,6 +1539,7 @@ class BundleFileStorageService: BundleStorageService {
1539
1539
  metadata.stagingBundleId = nil
1540
1540
  metadata.stagingSelection = selection
1541
1541
  metadata.verificationPending = false
1542
+ metadata.launchInProgress = false
1542
1543
  metadata.pendingTransition = nil
1543
1544
  metadata.pendingSelectionTransition = nil
1544
1545
  }
@@ -1619,7 +1620,42 @@ class BundleFileStorageService: BundleStorageService {
1619
1620
  DispatchQueue.global(qos: .background).async(execute: workItem)
1620
1621
  }
1621
1622
 
1623
+ /**
1624
+ * Downloads `url`, trying again after a retryable failure (see
1625
+ * `DownloadError.isRetryable`) up to `DownloadError.maximumAttempts`
1626
+ * attempts in all, 1 and then 2 seconds apart, unless `retrying` is
1627
+ * false. The last attempt's result is returned, so a failure keeps what
1628
+ * classifies it. Blocks the calling thread.
1629
+ */
1622
1630
  private func downloadFileSynchronously(
1631
+ from url: URL,
1632
+ to destination: String,
1633
+ retrying: Bool = true,
1634
+ progressHandler: @escaping (DownloadProgress) -> Void
1635
+ ) -> Result<URL, Error> {
1636
+ var attempt = 1
1637
+ while true {
1638
+ let result = downloadFileOnce(
1639
+ from: url,
1640
+ to: destination,
1641
+ progressHandler: progressHandler
1642
+ )
1643
+ guard case .failure(let error) = result,
1644
+ retrying,
1645
+ attempt < DownloadError.maximumAttempts,
1646
+ DownloadError.isRetryable(error) else {
1647
+ return result
1648
+ }
1649
+ let delay = downloadRetryDelay * pow(2, Double(attempt - 1))
1650
+ NSLog("[BundleStorage] Download failed (attempt \(attempt)/\(DownloadError.maximumAttempts)): \(error.localizedDescription). Retrying in \(delay)s")
1651
+ if delay > 0 {
1652
+ Thread.sleep(forTimeInterval: delay)
1653
+ }
1654
+ attempt += 1
1655
+ }
1656
+ }
1657
+
1658
+ private func downloadFileOnce(
1623
1659
  from url: URL,
1624
1660
  to destination: String,
1625
1661
  progressHandler: @escaping (DownloadProgress) -> Void
@@ -1666,7 +1702,7 @@ class BundleFileStorageService: BundleStorageService {
1666
1702
  do {
1667
1703
  var bundlePaths: [String] = []
1668
1704
  for assetPath in parsedManifest.assets.keys {
1669
- let assetURL = try ArchiveExtractionUtilities.extractionURL(
1705
+ let assetURL = try FileUtilities.fileURL(
1670
1706
  for: assetPath,
1671
1707
  destinationRoot: directoryPath
1672
1708
  )
@@ -1688,38 +1724,7 @@ class BundleFileStorageService: BundleStorageService {
1688
1724
  }
1689
1725
  }
1690
1726
 
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
- }
1727
+ return .success(nil)
1723
1728
  }
1724
1729
 
1725
1730
  /**
@@ -1752,7 +1757,7 @@ class BundleFileStorageService: BundleStorageService {
1752
1757
  let bundles = contents.compactMap { item -> String? in
1753
1758
  let fullPath = (storeDir as NSString).appendingPathComponent(item)
1754
1759
 
1755
- if protectedBundleStoreEntries.contains(item) {
1760
+ if protectedBundleStoreEntries.contains(item) || item.hasSuffix(".install-backup") {
1756
1761
  return nil
1757
1762
  }
1758
1763
 
@@ -1864,11 +1869,11 @@ class BundleFileStorageService: BundleStorageService {
1864
1869
  }
1865
1870
 
1866
1871
  let relativePath = String(bundleURL.path.dropFirst(sourcePrefix.count))
1867
- guard ArchiveExtractionUtilities.normalizedRelativePath(from: relativePath) == relativePath else {
1872
+ guard FileUtilities.normalizedRelativePath(from: relativePath) == relativePath else {
1868
1873
  throw BundleStorageError.invalidBundle
1869
1874
  }
1870
1875
 
1871
- return try ArchiveExtractionUtilities.extractionURL(
1876
+ return try FileUtilities.fileURL(
1872
1877
  for: relativePath,
1873
1878
  destinationRoot: destinationDirectory
1874
1879
  ).path
@@ -1924,8 +1929,8 @@ class BundleFileStorageService: BundleStorageService {
1924
1929
  private func selectLaunch(bundle: Bundle) -> LaunchSelection {
1925
1930
  guard let metadata = loadMetadataOrNull() else {
1926
1931
  return LaunchSelection(
1927
- bundleURL: getCachedBundleURL() ?? getFallbackBundleURL(bundle: bundle),
1928
- launchedBundleId: getCachedBundleId(),
1932
+ bundleURL: getFallbackBundleURL(bundle: bundle),
1933
+ launchedBundleId: nil,
1929
1934
  shouldRollbackOnCrash: false
1930
1935
  )
1931
1936
  }
@@ -1972,13 +1977,26 @@ class BundleFileStorageService: BundleStorageService {
1972
1977
  }
1973
1978
 
1974
1979
  func prepareLaunch(bundle: Bundle, pendingRecovery: PendingCrashRecovery?) -> LaunchSelection {
1980
+ builtInAssetResolver.use(bundle: bundle)
1975
1981
  saveLaunchReport(nil)
1976
- if loadInstallIdentity() == nil {
1977
- let identity = InstallIdentity()
1978
- _ = saveInstallIdentity(identity)
1979
- }
1982
+ loadOrCreateInstallIdentity()
1980
1983
  applyPendingRecoveryIfNeeded(pendingRecovery)
1981
- return selectLaunch(bundle: bundle)
1984
+ // Only a new storage instance consumes an unfinished launch. Repeated bundle
1985
+ // lookups or bridge reloads in this process must not reject its own launch.
1986
+ if !hasPreparedLaunch,
1987
+ let metadata = loadMetadataOrNull(),
1988
+ metadata.verificationPending,
1989
+ metadata.launchInProgress,
1990
+ let stagingBundleId = metadata.stagingBundleId {
1991
+ rollbackPendingBundle(stagingBundleId)
1992
+ }
1993
+ hasPreparedLaunch = true
1994
+ let selection = selectLaunch(bundle: bundle)
1995
+ if selection.shouldRollbackOnCrash, var metadata = loadMetadataOrNull() {
1996
+ metadata.launchInProgress = true
1997
+ _ = saveMetadata(metadata)
1998
+ }
1999
+ return selection
1982
2000
  }
1983
2001
 
1984
2002
  // MARK: - Bundle Update
@@ -1986,12 +2004,12 @@ class BundleFileStorageService: BundleStorageService {
1986
2004
  /**
1987
2005
  * Updates the bundle from the specified URL. This operation is asynchronous.
1988
2006
  * @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>)
2007
+ * @param manifestUrl URL of the complete target manifest
2008
+ * @param manifestFileHash Hash or signature of the target manifest
1991
2009
  * @param progressHandler Callback for download and extraction progress (0.0 to 1.0)
1992
2010
  * @param completion Callback with result of the operation
1993
2011
  */
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) {
2012
+ 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
2013
  // Check if bundle is in crashed history
1996
2014
  let crashedHistory = loadCrashedHistory()
1997
2015
  if crashedHistory.contains(bundleId) {
@@ -2000,928 +2018,774 @@ class BundleFileStorageService: BundleStorageService {
2000
2018
  return
2001
2019
  }
2002
2020
 
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
-
2021
+ fileOperationQueue.async(flags: .barrier) {
2039
2022
  let storeDirResult = self.bundleStoreDir()
2040
2023
  guard case .success(let storeDir) = storeDirResult else {
2041
- completion(.failure(storeDirResult.failureError ?? BundleStorageError.unknown(nil)))
2024
+ completion(.failure(UpdateFailureError.classifying(
2025
+ storeDirResult.failureError ?? BundleStorageError.unknown(nil),
2026
+ during: .storage
2027
+ )))
2042
2028
  return
2043
2029
  }
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
- }
2030
+ self.updateBundleFromManifest(
2031
+ bundleId: bundleId,
2032
+ manifestUrl: manifestUrl,
2033
+ manifestFileHash: manifestFileHash,
2034
+ archiveUrl: archiveUrl,
2035
+ changedAssets: assets,
2036
+ storeDir: storeDir,
2037
+ progressHandler: progressHandler,
2038
+ completion: completion
2039
+ )
2140
2040
  }
2141
2041
  }
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
2042
 
2170
- try? self.fileSystem.removeItem(atPath: tempDirectory)
2171
- guard self.fileSystem.createDirectory(atPath: tempDirectory) else {
2172
- completion(.failure(BundleStorageError.directoryCreationFailed))
2173
- return
2174
- }
2043
+ /// How the per-file install produced one file.
2044
+ private enum AssetInstallOutcome {
2045
+ case patched
2046
+ /// `patchFallback` is true when a patch for the running bundle was
2047
+ /// offered but the original file was downloaded instead.
2048
+ case downloaded(patchFallback: Bool)
2049
+ }
2175
2050
 
2176
- let currentBundleId = self.getCachedBundleId()
2177
- let currentBundleDir = currentBundleId.map {
2178
- (storeDir as NSString).appendingPathComponent($0)
2051
+ private func downloadManifestAsset(
2052
+ assetPath: String,
2053
+ expectedAsset: ParsedManifestAsset,
2054
+ changedAsset: ChangedAssetDescriptor,
2055
+ currentBundleId: String?,
2056
+ currentBundleDir: String?,
2057
+ destinationPath: String,
2058
+ tempDirectory: String,
2059
+ progressFile: UpdateProgressPayload.DiffProgressFileSnapshot,
2060
+ progressHandler: @escaping (UpdateProgressPayload) -> Void
2061
+ ) throws -> AssetInstallOutcome {
2062
+ let expectedHash = expectedAsset.fileHash
2063
+ var diffFiles = [progressFile]
2064
+ let patched = applyPatchAssetIfPossible(
2065
+ assetPath: assetPath,
2066
+ changedAsset: changedAsset,
2067
+ currentBundleId: currentBundleId,
2068
+ currentBundleDir: currentBundleDir,
2069
+ destinationPath: destinationPath,
2070
+ expectedHash: expectedHash,
2071
+ tempDirectory: tempDirectory,
2072
+ files: &diffFiles,
2073
+ progressHandler: progressHandler
2074
+ )
2075
+ if patched {
2076
+ // The patch produced these bytes, so a mismatch is a patch failure.
2077
+ try UpdateFailureError.during(.patch, resource: .patch) {
2078
+ try verifyManifestAssetFile(atPath: destinationPath, asset: expectedAsset)
2079
+ }
2080
+ updateDiffProgressFile(
2081
+ files: &diffFiles,
2082
+ assetPath: assetPath,
2083
+ status: "downloaded",
2084
+ progress: 1
2085
+ )
2086
+ self.emitDiffProgress(
2087
+ progressHandler: progressHandler,
2088
+ phase: diffFiles.allSatisfy { $0.status == "downloaded" }
2089
+ ? "finalizing"
2090
+ : "downloading",
2091
+ files: diffFiles
2092
+ )
2093
+ return .patched
2179
2094
  }
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
2095
 
2185
- do {
2096
+ guard let changedAssetFileUrl = changedAsset.fileUrl else {
2097
+ updateDiffProgressFile(
2098
+ files: &diffFiles,
2099
+ assetPath: assetPath,
2100
+ status: "failed",
2101
+ progress: 0
2102
+ )
2186
2103
  self.emitDiffProgress(
2187
2104
  progressHandler: progressHandler,
2188
- phase: "manifest",
2189
- files: diffFiles,
2190
- manifestProgress: 0
2105
+ phase: "downloading",
2106
+ files: diffFiles
2191
2107
  )
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
2108
+ // With no original file to fall back to, the patch decides the install.
2109
+ throw UpdateFailureError(
2110
+ underlying: BundleStorageError.downloadFailed(
2111
+ NSError(domain: "HotUpdater", code: 0, userInfo: [
2112
+ NSLocalizedDescriptionKey: "Changed asset fileUrl missing and patch could not be applied: \(assetPath)"
2113
+ ])
2114
+ ),
2115
+ failure: UpdateFailure.patch.with(resource: .patch)
2217
2116
  )
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
- }
2117
+ }
2224
2118
 
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
2119
+ let changedAssetDownloadPath = downloadPath(
2120
+ assetPath: assetPath,
2121
+ changedAsset: changedAsset
2122
+ )
2123
+ let downloadDestinationPath: String
2124
+ if changedAsset.fileCompression == "br" {
2125
+ downloadDestinationPath = compressedTempPath(
2126
+ tempDirectory: tempDirectory,
2127
+ assetPath: assetPath,
2128
+ compression: "br"
2236
2129
  )
2130
+ } else {
2131
+ downloadDestinationPath = destinationPath
2132
+ }
2237
2133
 
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,
2134
+ switch self.downloadFileSynchronously(
2135
+ from: changedAssetFileUrl,
2136
+ to: downloadDestinationPath,
2137
+ progressHandler: { progress in
2138
+ self.updateDiffProgressFile(
2332
2139
  files: &diffFiles,
2333
- progressHandler: progressHandler
2140
+ assetPath: assetPath,
2141
+ status: "downloading",
2142
+ progress: progress.progress,
2143
+ downloadPath: changedAssetDownloadPath,
2144
+ downloadedBytes: progress.downloadedBytes,
2145
+ totalBytes: progress.totalBytes
2334
2146
  )
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
2147
+ self.emitDiffProgress(
2148
+ progressHandler: progressHandler,
2149
+ phase: "downloading",
2150
+ files: diffFiles
2151
+ )
2152
+ }
2153
+ ) {
2154
+ case .success(let downloadedFileURL):
2155
+ do {
2156
+ try UpdateFailureError.during(.extraction, resource: .file) {
2157
+ try materializeDownloadedAsset(
2158
+ downloadedPath: downloadedFileURL.path,
2159
+ destinationPath: destinationPath,
2160
+ changedAsset: changedAsset
2349
2161
  )
2350
- continue
2351
2162
  }
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
- ])
2163
+ // Compressed files are checked after decoding, so their
2164
+ // mismatch is an extraction failure.
2165
+ let verificationPhase: UpdatePhase =
2166
+ changedAsset.fileCompression == "br" ? .extraction : .verification
2167
+ try UpdateFailureError.during(verificationPhase, resource: .file) {
2168
+ try verifyManifestAssetFile(
2169
+ atPath: destinationPath,
2170
+ asset: expectedAsset
2369
2171
  )
2370
2172
  }
2371
-
2372
- let changedAssetDownloadPath = downloadPath(
2173
+ } catch {
2174
+ updateDiffProgressFile(
2175
+ files: &diffFiles,
2373
2176
  assetPath: assetPath,
2374
- changedAsset: changedAsset
2177
+ status: "failed",
2178
+ progress: 1
2375
2179
  )
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
- }
2180
+ self.emitDiffProgress(
2181
+ progressHandler: progressHandler,
2182
+ phase: "downloading",
2183
+ files: diffFiles
2184
+ )
2185
+ throw error
2464
2186
  }
2465
-
2187
+ updateDiffProgressFile(
2188
+ files: &diffFiles,
2189
+ assetPath: assetPath,
2190
+ status: "downloaded",
2191
+ progress: 1
2192
+ )
2466
2193
  self.emitDiffProgress(
2467
2194
  progressHandler: progressHandler,
2468
- phase: "finalizing",
2195
+ phase: diffFiles.allSatisfy { $0.status == "downloaded" }
2196
+ ? "finalizing"
2197
+ : "downloading",
2469
2198
  files: diffFiles
2470
2199
  )
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,
2200
+ // A patch for the running bundle was offered, but the original
2201
+ // file came instead; a patch for another base was never tried.
2202
+ let patchWasTried = changedAsset.patch.map {
2203
+ $0.algorithm == "bsdiff" && $0.baseBundleId == currentBundleId
2204
+ } ?? false
2205
+ return .downloaded(patchFallback: patchWasTried)
2206
+ case .failure(let error):
2207
+ let lastKnownProgress = diffFiles.first(where: { $0.path == assetPath })?.progress ?? 0
2208
+ updateDiffProgressFile(
2209
+ files: &diffFiles,
2210
+ assetPath: assetPath,
2211
+ status: "failed",
2212
+ progress: lastKnownProgress
2213
+ )
2214
+ self.emitDiffProgress(
2542
2215
  progressHandler: progressHandler,
2543
- completion: completion
2216
+ phase: "downloading",
2217
+ files: diffFiles
2544
2218
  )
2219
+ let transferError: Error
2220
+ if let downloadError = error as? DownloadError,
2221
+ case .incompleteDownload(let expected, let actual) = downloadError {
2222
+ transferError = BundleStorageError.incompleteDownload(expected: expected, actual: actual)
2223
+ } else {
2224
+ transferError = BundleStorageError.downloadFailed(error)
2225
+ }
2226
+ throw UpdateFailureError.classifying(transferError, during: .transfer, resource: .file)
2545
2227
  }
2546
2228
  }
2547
2229
 
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
2230
+ // Retain only target files on retry; partial files are rehashed before reuse.
2231
+ private func pruneStagingDirectory(_ directory: String, assets: Set<String>, prefix: String = "") throws {
2232
+ for name in try fileSystem.contentsOfDirectory(atPath: directory) {
2233
+ let relativePath = prefix + name
2234
+ let path = (directory as NSString).appendingPathComponent(name)
2235
+ let type = try fileSystem.attributesOfItem(atPath: path)[.type] as? FileAttributeType
2236
+ if type == .typeDirectory, assets.contains(where: { $0.hasPrefix(relativePath + "/") }) {
2237
+ try pruneStagingDirectory(path, assets: assets, prefix: relativePath + "/")
2238
+ } else if type != .typeRegular || !assets.contains(relativePath) {
2239
+ try fileSystem.removeItem(atPath: path)
2240
+ }
2570
2241
  }
2242
+ }
2571
2243
 
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
2244
+ private func installArchive(
2245
+ archive: ParsedArchive,
2246
+ archiveUrl: URL,
2247
+ targetManifest: ParsedBundleManifest,
2248
+ preparedDirectory: String,
2249
+ tempDirectory: String,
2250
+ progressHandler: @escaping (UpdateProgressPayload) -> Void
2251
+ ) throws -> [UpdateProgressPayload.DiffProgressFileSnapshot] {
2252
+ let archiveScratch = (tempDirectory as NSString).appendingPathComponent("archive")
2253
+ try? fileSystem.removeItem(atPath: archiveScratch)
2254
+ guard fileSystem.createDirectory(atPath: archiveScratch) else {
2255
+ throw BundleStorageError.directoryCreationFailed
2256
+ }
2257
+ defer { try? fileSystem.removeItem(atPath: archiveScratch) }
2593
2258
 
2594
- NSLog("[BundleStorage] Available: \(freeSize) bytes, Required: \(requiredSpace) bytes")
2259
+ let compressedPath = (archiveScratch as NSString).appendingPathComponent("bundle.tar.br")
2260
+ let tarPath = (archiveScratch as NSString).appendingPathComponent("bundle.tar")
2261
+ let extractedDirectory = (archiveScratch as NSString).appendingPathComponent("extracted")
2262
+ var progressFiles = [UpdateProgressPayload.DiffProgressFileSnapshot(
2263
+ path: "bundle.tar.br",
2264
+ downloadPath: "bundle.tar.br",
2265
+ status: "pending",
2266
+ progress: 0,
2267
+ order: 0
2268
+ )]
2269
+ emitDiffProgress(
2270
+ progressHandler: progressHandler,
2271
+ phase: "downloading",
2272
+ files: progressFiles
2273
+ )
2595
2274
 
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(
2275
+ // One attempt: a failed archive falls back to per-file downloads, which retry.
2276
+ switch downloadFileSynchronously(
2277
+ from: archiveUrl,
2278
+ to: compressedPath,
2279
+ retrying: false,
2280
+ progressHandler: { progress in
2281
+ self.updateDiffProgressFile(
2282
+ files: &progressFiles,
2283
+ assetPath: "bundle.tar.br",
2284
+ status: "downloading",
2285
+ progress: progress.progress,
2286
+ downloadPath: "bundle.tar.br",
2287
+ downloadedBytes: progress.downloadedBytes,
2288
+ totalBytes: progress.totalBytes
2289
+ )
2290
+ self.emitDiffProgress(
2608
2291
  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
2292
+ phase: "downloading",
2293
+ files: progressFiles
2616
2294
  )
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
2295
  }
2296
+ ) {
2297
+ case .success:
2298
+ break
2299
+ case .failure(let error):
2300
+ throw BundleStorageError.downloadFailed(error)
2301
+ }
2625
2302
 
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
- }
2303
+ let compressedAttributes = try fileSystem.attributesOfItem(atPath: compressedPath)
2304
+ guard let compressedSize = compressedAttributes[.size] as? NSNumber,
2305
+ compressedSize.int64Value == archive.downloadByteSize,
2306
+ HashUtils.verifyHash(
2307
+ fileURL: URL(fileURLWithPath: compressedPath),
2308
+ expectedHash: archive.downloadFileHash
2309
+ ) else {
2310
+ throw UpdateFailureError(
2311
+ underlying: BundleStorageError.invalidBundle,
2312
+ failure: .hashMismatch
2313
+ )
2314
+ }
2633
2315
 
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
- }
2316
+ try UpdateFailureError.during(.extraction) {
2317
+ try BrotliFileDecompressor.decompress(
2318
+ from: compressedPath,
2319
+ to: tarPath,
2320
+ expectedOutputByteSize: archive.tarByteSize
2321
+ )
2322
+ }
2323
+
2324
+ let expectedFiles = try targetManifest.assets.reduce(
2325
+ into: [String: Int64](),
2326
+ { result, entry in
2327
+ guard let byteSize = entry.value.byteSize else {
2328
+ throw BundleStorageError.invalidBundle
2657
2329
  }
2330
+ result[entry.key] = byteSize
2331
+ }
2332
+ )
2333
+ try UpdateFailureError.during(.extraction) {
2334
+ try TarArchiveExtractor.extract(
2335
+ from: tarPath,
2336
+ to: extractedDirectory,
2337
+ expectedFiles: expectedFiles
2338
+ )
2339
+ for (path, asset) in targetManifest.assets {
2340
+ let extractedPath = try FileUtilities.fileURL(
2341
+ for: path,
2342
+ destinationRoot: extractedDirectory
2343
+ ).path
2344
+ try verifyManifestAssetFile(atPath: extractedPath, asset: asset)
2658
2345
  }
2659
- self.fileOperationQueue.async(execute: workItem)
2660
2346
  }
2347
+
2348
+ try promoteVerifiedArchiveDirectory(
2349
+ preparedDirectory,
2350
+ withArchiveDirectory: extractedDirectory
2351
+ )
2352
+
2353
+ updateDiffProgressFile(
2354
+ files: &progressFiles,
2355
+ assetPath: "bundle.tar.br",
2356
+ status: "downloaded",
2357
+ progress: 1,
2358
+ downloadedBytes: archive.downloadByteSize,
2359
+ totalBytes: archive.downloadByteSize
2360
+ )
2361
+ emitDiffProgress(
2362
+ progressHandler: progressHandler,
2363
+ phase: "finalizing",
2364
+ files: progressFiles
2661
2365
  )
2366
+ return progressFiles
2662
2367
  }
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
2368
 
2672
- // Check if path exists
2673
- let exists = fileManager.fileExists(atPath: path)
2674
- NSLog("[BundleStorage] [\(context)] Path exists: \(exists) - \(path)")
2369
+ private func promoteVerifiedArchiveDirectory(
2370
+ _ preparedDirectory: String,
2371
+ withArchiveDirectory archiveDirectory: String
2372
+ ) throws {
2373
+ let backupDirectory = preparedDirectory + ".archive-backup"
2374
+ try? fileSystem.removeItem(atPath: backupDirectory)
2375
+ try fileSystem.moveItem(atPath: preparedDirectory, toPath: backupDirectory)
2675
2376
 
2676
- if exists {
2377
+ do {
2378
+ try fileSystem.moveItem(
2379
+ atPath: archiveDirectory,
2380
+ toPath: preparedDirectory
2381
+ )
2382
+ } catch {
2383
+ try? fileSystem.removeItem(atPath: preparedDirectory)
2677
2384
  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))")
2385
+ try fileSystem.moveItem(atPath: backupDirectory, toPath: preparedDirectory)
2386
+ } catch let restoreError {
2387
+ throw BundleStorageError.moveOperationFailed(restoreError)
2388
+ }
2389
+ throw error
2390
+ }
2391
+ try? fileSystem.removeItem(atPath: backupDirectory)
2392
+ }
2393
+
2394
+ private func recoverInterruptedPromotions() {
2395
+ guard case .success(let storeDir) = bundleStoreDir(),
2396
+ let entries = try? fileSystem.contentsOfDirectory(atPath: storeDir) else { return }
2397
+ for entry in entries where entry.hasSuffix(".install-backup") {
2398
+ let finalDirectory = (storeDir as NSString).appendingPathComponent(String(entry.dropLast(".install-backup".count)))
2399
+ do {
2400
+ try recoverInterruptedPromotion(finalDirectory: finalDirectory)
2682
2401
  } catch {
2683
- NSLog("[BundleStorage] [\(context)] Failed to get attributes: \(error.localizedDescription)")
2402
+ NSLog("[BundleStorage] Cannot recover interrupted promotion: \(error)")
2684
2403
  }
2685
2404
  }
2405
+ }
2686
2406
 
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)")
2407
+ private func recoverInterruptedPromotion(finalDirectory: String) throws {
2408
+ let backup = finalDirectory + ".install-backup"
2409
+ guard fileSystem.fileExists(atPath: backup) else { return }
2410
+ if fileSystem.fileExists(atPath: finalDirectory) {
2411
+ if isCompleteBundleDirectory(finalDirectory) {
2412
+ try fileSystem.removeItem(atPath: backup)
2413
+ return
2414
+ }
2415
+ try fileSystem.removeItem(atPath: finalDirectory)
2416
+ }
2417
+ try fileSystem.moveItem(atPath: backup, toPath: finalDirectory)
2691
2418
  }
2692
2419
 
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,
2420
+ private func isCompleteBundleDirectory(_ directory: String) -> Bool {
2421
+ let bundleId = (directory as NSString).lastPathComponent
2422
+ guard let manifest = readManifest(in: directory),
2423
+ let parsed = parseBundleManifest(from: manifest),
2424
+ parsed.bundleId == bundleId,
2425
+ case .success(let bundlePath) = findBundleFile(in: directory, expectedBundleId: bundleId),
2426
+ bundlePath != nil else { return false }
2427
+ return parsed.assets.allSatisfy { path, asset in
2428
+ guard let file = try? FileUtilities.fileURL(for: path, destinationRoot: directory) else { return false }
2429
+ return (try? verifyManifestAssetFile(atPath: file.path, asset: asset)) != nil
2430
+ }
2431
+ }
2432
+
2433
+ private func promoteStagedBundle(
2710
2434
  bundleId: String,
2711
- tempDirectory: String,
2435
+ stagingDirectory: String,
2436
+ finalDirectory: String,
2437
+ bundlePathInStaging: String
2438
+ ) throws -> BundleMetadata {
2439
+ releaseStateLock.lock()
2440
+ defer { releaseStateLock.unlock() }
2441
+ let currentMetadata = loadMetadataOrNull() ?? createInitialMetadata()
2442
+ guard let updatedMetadata = prepareMetadataForNewStagingBundle(currentMetadata, bundleId: bundleId) else {
2443
+ throw BundleStorageError.unknown(StaleReleaseSelectionError())
2444
+ }
2445
+ let finalBundlePath = try resolveBundlePathAfterMove(
2446
+ bundlePathInStaging, from: stagingDirectory, to: finalDirectory
2447
+ )
2448
+ let previousPreference = try preferences.getItem(forKey: "HotUpdaterBundleURL")
2449
+ let backup = finalDirectory + ".install-backup"
2450
+ try recoverInterruptedPromotion(finalDirectory: finalDirectory)
2451
+ if fileSystem.fileExists(atPath: finalDirectory) {
2452
+ try fileSystem.moveItem(atPath: finalDirectory, toPath: backup)
2453
+ }
2454
+ var promoted = false
2455
+ do {
2456
+ try fileSystem.moveItem(atPath: stagingDirectory, toPath: finalDirectory)
2457
+ promoted = true
2458
+ try setBundleURL(localPath: finalBundlePath).get()
2459
+ // Metadata is the durable activation point. Launch never uses an
2460
+ // untracked preference if this write fails or the process terminates.
2461
+ guard saveMetadata(updatedMetadata) else {
2462
+ throw UpdateFailureError(
2463
+ underlying: BundleStorageError.unknown(NSError(domain: "HotUpdater", code: 0,
2464
+ userInfo: [NSLocalizedDescriptionKey: "Failed to persist bundle activation metadata"])),
2465
+ failure: .storage
2466
+ )
2467
+ }
2468
+ } catch {
2469
+ let installError = error
2470
+ let preferenceResult = setBundleURL(localPath: previousPreference)
2471
+ if promoted { try fileSystem.removeItem(atPath: finalDirectory) }
2472
+ if fileSystem.fileExists(atPath: backup) {
2473
+ try fileSystem.moveItem(atPath: backup, toPath: finalDirectory)
2474
+ }
2475
+ try preferenceResult.get()
2476
+ throw installError
2477
+ }
2478
+ try? fileSystem.removeItem(atPath: backup)
2479
+ return updatedMetadata
2480
+ }
2481
+
2482
+ private func updateBundleFromManifest(
2483
+ bundleId: String,
2484
+ manifestUrl: URL,
2485
+ manifestFileHash: String,
2486
+ archiveUrl: URL?,
2487
+ changedAssets: [String: ChangedAssetDescriptor],
2488
+ storeDir: String,
2712
2489
  progressHandler: @escaping (UpdateProgressPayload) -> Void,
2713
- completion: @escaping (Result<Bool, Error>) -> Void
2490
+ completion: @escaping (Result<UpdateDelivery, Error>) -> Void
2714
2491
  ) {
2715
- let currentBundleId = self.getCachedBundleId()
2716
- NSLog("[BundleStorage] Processing downloaded file atPath: \(location.path)")
2492
+ let tempDirResult = tempDir()
2493
+ guard case .success(let tempDirectory) = tempDirResult else {
2494
+ completion(.failure(UpdateFailureError.classifying(
2495
+ tempDirResult.failureError ?? BundleStorageError.unknown(nil),
2496
+ during: .storage
2497
+ )))
2498
+ return
2499
+ }
2717
2500
 
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
- ))))
2501
+ try? self.fileSystem.removeItem(atPath: tempDirectory)
2502
+ guard self.fileSystem.createDirectory(atPath: tempDirectory) else {
2503
+ completion(.failure(UpdateFailureError.classifying(
2504
+ BundleStorageError.directoryCreationFailed,
2505
+ during: .storage
2506
+ )))
2727
2507
  return
2728
2508
  }
2729
2509
 
2730
- // 2) Define tmpDir and realDir
2510
+ let currentBundleId = self.getCachedBundleId()
2511
+ let currentBundleDir = currentBundleId.map {
2512
+ (storeDir as NSString).appendingPathComponent($0)
2513
+ }
2514
+ let tempManifestPath = (tempDirectory as NSString).appendingPathComponent("manifest.json")
2731
2515
  let tmpDir = (storeDir as NSString).appendingPathComponent("\(bundleId).tmp")
2732
2516
  let realDir = (storeDir as NSString).appendingPathComponent(bundleId)
2517
+ var diffFiles: [UpdateProgressPayload.DiffProgressFileSnapshot] = []
2518
+ // The running step classifies the errors whose type does not.
2519
+ var failurePhase = UpdatePhase.storage
2520
+ var failureResource: UpdateFailureResource?
2733
2521
 
2734
2522
  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(
2523
+ try recoverInterruptedPromotion(finalDirectory: realDir)
2524
+ failurePhase = .transfer
2525
+ failureResource = .manifest
2526
+ self.emitDiffProgress(
2751
2527
  progressHandler: progressHandler,
2752
- progress: UpdateProgress.verificationStart
2528
+ phase: "manifest",
2529
+ files: diffFiles,
2530
+ manifestProgress: 0
2753
2531
  )
2754
- let tempBundleURL = URL(fileURLWithPath: tempBundleFile)
2755
- let verificationResult = SignatureVerifier.verifyBundle(fileURL: tempBundleURL, fileHash: fileHash)
2756
- switch verificationResult {
2532
+ switch self.downloadFileSynchronously(
2533
+ from: manifestUrl,
2534
+ to: tempManifestPath,
2535
+ progressHandler: { progress in
2536
+ self.emitDiffProgress(
2537
+ progressHandler: progressHandler,
2538
+ phase: "manifest",
2539
+ files: diffFiles,
2540
+ manifestProgress: progress.progress
2541
+ )
2542
+ }
2543
+ ) {
2757
2544
  case .success:
2758
- NSLog("[BundleStorage] Bundle verification completed successfully")
2759
- self.emitArchiveProgress(
2760
- progressHandler: progressHandler,
2761
- progress: UpdateProgress.verificationEnd
2762
- )
2545
+ break
2763
2546
  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
2547
+ if let downloadError = error as? DownloadError,
2548
+ case .incompleteDownload(let expected, let actual) = downloadError {
2549
+ throw BundleStorageError.incompleteDownload(expected: expected, actual: actual)
2550
+ }
2551
+ throw BundleStorageError.downloadFailed(error)
2769
2552
  }
2770
2553
 
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
2554
+ failurePhase = .verification
2555
+ let manifestVerificationResult = SignatureVerifier.verifyBundle(
2556
+ fileURL: URL(fileURLWithPath: tempManifestPath),
2557
+ fileHash: manifestFileHash
2558
+ )
2559
+ guard case .success = manifestVerificationResult else {
2560
+ if case .failure(let error) = manifestVerificationResult {
2561
+ throw BundleStorageError.signatureVerificationFailed(error)
2562
+ }
2563
+ throw BundleStorageError.unknown(nil)
2795
2564
  }
2796
2565
 
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
- }
2566
+ failurePhase = .manifest
2567
+ guard let manifestData = try? Data(contentsOf: URL(fileURLWithPath: tempManifestPath)),
2568
+ let manifestJson = try JSONSerialization.jsonObject(with: manifestData) as? [String: Any],
2569
+ let targetManifest = parseBundleManifest(from: manifestJson),
2570
+ targetManifest.bundleId == bundleId
2571
+ else {
2572
+ throw BundleStorageError.invalidBundle
2573
+ }
2574
+ // Validate the entire wire contract even when every byte can be reused.
2575
+ guard Set(targetManifest.assets.keys) == Set(changedAssets.keys) else {
2576
+ throw BundleStorageError.invalidBundle
2577
+ }
2578
+ for (path, asset) in targetManifest.assets {
2579
+ guard FileUtilities.normalizedRelativePath(from: path) == path,
2580
+ path != "manifest.json",
2581
+ let descriptor = changedAssets[path],
2582
+ descriptor.fileUrl != nil,
2583
+ descriptor.fileHash.caseInsensitiveCompare(asset.fileHash) == .orderedSame else {
2584
+ throw BundleStorageError.invalidBundle
2585
+ }
2586
+ _ = try FileUtilities.fileURL(for: path, destinationRoot: tmpDir)
2587
+ }
2816
2588
 
2817
- // 10) Rename (move) tmpDir → realDir
2589
+ // Downloads, patches, and extraction below classify their own errors.
2590
+ failurePhase = .storage
2591
+ failureResource = nil
2592
+ guard self.fileSystem.createDirectory(atPath: tmpDir) else {
2593
+ throw BundleStorageError.directoryCreationFailed
2594
+ }
2595
+ try pruneStagingDirectory(tmpDir, assets: Set(targetManifest.assets.keys))
2596
+ let sortedAssets = targetManifest.assets.sorted { $0.key < $1.key }
2597
+ let localDirectories = [currentBundleDir, realDir].compactMap { $0 }.filter { fileSystem.fileExists(atPath: $0) }
2598
+ var downloads: [String: ChangedAssetDescriptor] = [:]
2599
+ for (index, asset) in sortedAssets.enumerated() {
2600
+ // Local preparation has its own progress interval, with no download files.
2601
+ progressHandler(UpdateProgressPayload(
2602
+ progress: 0.15 + 0.05 * Double(index) / Double(max(1, sortedAssets.count)),
2603
+ artifactType: "diff",
2604
+ details: .init(totalFilesCount: 0, completedFilesCount: 0)
2605
+ ))
2606
+ let assetPath = asset.key
2607
+ let expectedAsset = asset.value
2608
+ let destinationPath = try FileUtilities.fileURL(for: assetPath, destinationRoot: tmpDir).path
2609
+ guard self.fileSystem.createDirectory(atPath: (destinationPath as NSString).deletingLastPathComponent) else {
2610
+ throw BundleStorageError.directoryCreationFailed
2611
+ }
2612
+ if (try? fileSystem.attributesOfItem(atPath: destinationPath)[.type] as? FileAttributeType) == .typeRegular,
2613
+ (try? verifyManifestAssetFile(atPath: destinationPath, asset: expectedAsset)) != nil {
2614
+ continue
2615
+ }
2616
+ if fileSystem.fileExists(atPath: destinationPath) { try self.fileSystem.removeItem(atPath: destinationPath) }
2617
+
2618
+ var reused = false
2619
+ // The cached target is only a candidate, just like the active OTA.
2620
+ for directory in localDirectories {
2621
+ guard let sourcePath = try? FileUtilities.fileURL(for: assetPath, destinationRoot: directory).path,
2622
+ (try? fileSystem.attributesOfItem(atPath: sourcePath)[.type] as? FileAttributeType) == .typeRegular,
2623
+ (try? verifyManifestAssetFile(atPath: sourcePath, asset: expectedAsset)) != nil else { continue }
2818
2624
  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")
2625
+ try self.fileSystem.copyItem(atPath: sourcePath, toPath: destinationPath)
2626
+ try verifyManifestAssetFile(atPath: destinationPath, asset: expectedAsset)
2627
+ reused = true
2628
+ break
2822
2629
  } 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)
2630
+ try? self.fileSystem.removeItem(atPath: destinationPath)
2828
2631
  }
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
2632
+ }
2633
+ if reused { continue }
2634
+ if builtInAssetResolver.copyIfMatches(assetPath: assetPath, expectedHash: expectedAsset.fileHash, destination: destinationPath) {
2635
+ try UpdateFailureError.during(.verification, resource: .file) {
2636
+ try verifyManifestAssetFile(atPath: destinationPath, asset: expectedAsset)
2637
+ }
2638
+ continue
2639
+ }
2640
+ downloads[assetPath] = changedAssets[assetPath]
2641
+ }
2642
+
2643
+ var archiveInstalled = false
2644
+ var patchedFiles = false
2645
+ // The archive is chosen before any patch is tried, so only the
2646
+ // per-file install can fall back from a patch.
2647
+ var patchFallbacks = false
2648
+ if shouldUseArchive(
2649
+ archive: targetManifest.archive,
2650
+ archiveUrl: archiveUrl,
2651
+ downloads: downloads,
2652
+ assets: targetManifest.assets
2653
+ ), let archive = targetManifest.archive, let archiveUrl {
2654
+ do {
2655
+ diffFiles = try UpdateFailureError.during(.storage, resource: .archive) {
2656
+ try installArchive(
2657
+ archive: archive,
2658
+ archiveUrl: archiveUrl,
2659
+ targetManifest: targetManifest,
2660
+ preparedDirectory: tmpDir,
2661
+ tempDirectory: tempDirectory,
2662
+ progressHandler: progressHandler
2663
+ )
2664
+ }
2665
+ archiveInstalled = true
2666
+ } catch {
2667
+ NSLog("[BundleStorage] tar.br optimization failed; falling back to individual files: \(error.localizedDescription)")
2668
+ guard fileSystem.fileExists(atPath: tmpDir) else {
2669
+ throw error
2847
2670
  }
2848
- let setResult = self.setBundleURL(localPath: finalBundlePath)
2849
- switch setResult {
2850
- case .success:
2851
- NSLog("[BundleStorage] Successfully set bundle URL: \(finalBundlePath)")
2671
+ }
2672
+ }
2852
2673
 
2853
- // 13) Set staging metadata for rollback support
2854
- let _ = self.saveMetadata(updatedMetadata)
2855
- NSLog("[BundleStorage] Set staging bundle: \(bundleId), verificationPending: true")
2674
+ if !archiveInstalled {
2675
+ diffFiles = createDiffProgressFiles(changedAssets: downloads)
2676
+ emitDiffProgress(progressHandler: progressHandler, phase: "downloading", files: diffFiles)
2677
+ let progressLock = NSLock()
2678
+ let workers = OperationQueue()
2679
+ workers.maxConcurrentOperationCount = 4
2680
+ var installError: Error?
2681
+ var installOutcomes: [AssetInstallOutcome] = []
2682
+ for progressFile in diffFiles {
2683
+ let assetPath = progressFile.path
2684
+ let expectedAsset = targetManifest.assets[assetPath]!
2685
+ let descriptor = downloads[assetPath]!
2686
+ let destinationPath = try FileUtilities.fileURL(for: assetPath, destinationRoot: tmpDir).path
2687
+ // Separate temp namespaces also avoid collisions between flattened asset paths.
2688
+ let workerTemp = (tempDirectory as NSString).appendingPathComponent(String(progressFile.order))
2689
+ workers.addOperation {
2690
+ progressLock.lock()
2691
+ let shouldSkip = installError != nil
2692
+ progressLock.unlock()
2693
+ if shouldSkip { return }
2694
+ do {
2695
+ guard self.fileSystem.createDirectory(atPath: workerTemp) else { throw BundleStorageError.directoryCreationFailed }
2696
+ let outcome = try self.downloadManifestAsset(
2697
+ assetPath: assetPath, expectedAsset: expectedAsset, changedAsset: descriptor,
2698
+ currentBundleId: currentBundleId, currentBundleDir: currentBundleDir,
2699
+ destinationPath: destinationPath, tempDirectory: workerTemp, progressFile: progressFile,
2700
+ progressHandler: { payload in
2701
+ progressLock.lock()
2702
+ defer { progressLock.unlock() }
2703
+ if let snapshot = payload.details?.files.first,
2704
+ let index = diffFiles.firstIndex(where: { $0.path == assetPath }) {
2705
+ diffFiles[index] = snapshot
2706
+ }
2707
+ self.emitDiffProgress(progressHandler: progressHandler, phase: "downloading", files: diffFiles)
2708
+ }
2709
+ )
2710
+ progressLock.lock()
2711
+ installOutcomes.append(outcome)
2712
+ progressLock.unlock()
2713
+ } catch {
2714
+ progressLock.lock()
2715
+ if installError == nil { installError = error }
2716
+ progressLock.unlock()
2717
+ }
2718
+ }
2719
+ }
2720
+ // Join all workers before cleaning scratch files, even after a failure.
2721
+ workers.waitUntilAllOperationsAreFinished()
2722
+ if let installError { throw installError }
2723
+ for outcome in installOutcomes {
2724
+ switch outcome {
2725
+ case .patched:
2726
+ patchedFiles = true
2727
+ case .downloaded(let patchFallback):
2728
+ patchFallbacks = patchFallbacks || patchFallback
2729
+ }
2730
+ }
2731
+ }
2856
2732
 
2857
- // 14) Clean up the temporary directory
2858
- self.cleanupTemporaryFiles([tempDirectory])
2733
+ self.emitDiffProgress(
2734
+ progressHandler: progressHandler,
2735
+ phase: "finalizing",
2736
+ files: diffFiles
2737
+ )
2859
2738
 
2860
- self.emitArchiveProgress(
2861
- progressHandler: progressHandler,
2862
- progress: UpdateProgress.activationReady
2863
- )
2864
- self.scheduleCleanupOldBundles(
2865
- bundleIdsToKeep: [currentBundleId, updatedMetadata.stableBundleId, bundleId].compactMap { $0 }
2866
- )
2739
+ let manifestDestination = (tmpDir as NSString).appendingPathComponent("manifest.json")
2740
+ try copyVerifiedManifest(from: tempManifestPath, to: manifestDestination)
2867
2741
 
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))
2742
+ switch self.findBundleFile(in: tmpDir, expectedBundleId: bundleId) {
2743
+ case .success(let maybeBundlePath):
2744
+ guard let bundlePathInTmp = maybeBundlePath else {
2745
+ // The manifest does not name exactly one platform bundle file.
2746
+ throw UpdateFailureError(
2747
+ underlying: BundleStorageError.invalidBundle,
2748
+ failure: UpdateFailure.invalidResponse.with(resource: .manifest)
2749
+ )
2889
2750
  }
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)
2751
+
2752
+ let updatedMetadata = try promoteStagedBundle(
2753
+ bundleId: bundleId,
2754
+ stagingDirectory: tmpDir,
2755
+ finalDirectory: realDir,
2756
+ bundlePathInStaging: bundlePathInTmp
2757
+ )
2895
2758
  self.cleanupTemporaryFiles([tempDirectory])
2896
- completion(.failure(findError))
2759
+ self.scheduleCleanupOldBundles(
2760
+ bundleIdsToKeep: [currentBundleId, updatedMetadata.stableBundleId, bundleId].compactMap { $0 }
2761
+ )
2762
+ progressHandler(UpdateProgressPayload(
2763
+ progress: 1.0,
2764
+ artifactType: "diff",
2765
+ details: .init(
2766
+ totalFilesCount: diffFiles.count,
2767
+ completedFilesCount: diffFiles.filter { $0.status == "downloaded" }.count,
2768
+ files: diffFiles
2769
+ )
2770
+ ))
2771
+ let delivery: UpdateDelivery.Method =
2772
+ archiveInstalled ? .archive : (patchedFiles ? .patch : .manifest)
2773
+ completion(.success(UpdateDelivery(method: delivery, patchFallback: patchFallbacks)))
2774
+ case .failure(let error):
2775
+ throw error
2897
2776
  }
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)
2777
+ } catch {
2778
+ NSLog("[BundleStorage] Manifest-driven install failed: \(error.localizedDescription).")
2779
+ // Preserve staging for a hash-checked retry. Never trust its presence alone.
2904
2780
  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
- }
2781
+ completion(.failure(UpdateFailureError.classifying(
2782
+ error,
2783
+ during: failurePhase,
2784
+ resource: failureResource
2785
+ )))
2912
2786
  }
2913
2787
  }
2914
2788
 
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))
2918
- }
2919
-
2920
- // MARK: - Rollback Support
2921
-
2922
- /**
2923
- * Marks the current launch as successful after the first content appeared.
2924
- */
2925
2789
  func markLaunchCompleted(bundleId: String?) {
2926
2790
  guard let bundleId,
2927
2791
  var metadata = loadMetadataOrNull(),
@@ -2935,6 +2799,7 @@ class BundleFileStorageService: BundleStorageService {
2935
2799
  metadata.stableBundleId = nil
2936
2800
  metadata.stableSelection = nil
2937
2801
  metadata.verificationPending = false
2802
+ metadata.launchInProgress = false
2938
2803
  metadata.pendingTransition = nil
2939
2804
  metadata.pendingSelectionTransition = nil
2940
2805
  metadata.updatedAt = Date().timeIntervalSince1970 * 1000
@@ -2999,34 +2864,7 @@ class BundleFileStorageService: BundleStorageService {
2999
2864
  }
3000
2865
 
3001
2866
  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
3009
- }
3010
- return identity.installId
3011
- }
3012
-
3013
- func getUserId() -> String? {
3014
- loadUserIdentity()?.userId
3015
- }
3016
-
3017
- func getUsername() -> String? {
3018
- loadUserIdentity()?.username
3019
- }
3020
-
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)
3027
- return
3028
- }
3029
- _ = saveUserIdentity(identity)
2867
+ loadOrCreateInstallIdentity().installId
3030
2868
  }
3031
2869
 
3032
2870
  /**