@otakit/capacitor-updater 2.2.0 → 2.3.1

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.
@@ -11,9 +11,18 @@ struct ManifestEncryption {
11
11
  let nonce: String
12
12
  }
13
13
 
14
+ struct ManifestFileEntry {
15
+ let path: String
16
+ let sha256: String
17
+ let size: Int?
18
+ let url: String
19
+ }
20
+
14
21
  struct LatestManifest {
15
22
  let version: String
16
- let url: String
23
+ /// Bundle zip URL. Present for the zip strategy; nil for deltas.
24
+ let url: String?
25
+ /// Zip hash for the zip strategy; canonical filesHash for deltas.
17
26
  let sha256: String
18
27
  let size: Int
19
28
  let runtimeVersion: String?
@@ -21,6 +30,8 @@ struct LatestManifest {
21
30
  let strategy: String
22
31
  let forceImmediate: Bool
23
32
  let encryption: ManifestEncryption?
33
+ /// Per-file entries for the deltas strategy; nil for zip.
34
+ let files: [ManifestFileEntry]?
24
35
  }
25
36
 
26
37
  struct ManifestSignature {
@@ -105,7 +116,6 @@ enum ManifestClient {
105
116
  guard
106
117
  let object = try JSONSerialization.jsonObject(with: data) as? [String: Any],
107
118
  let version = object["version"] as? String,
108
- let downloadUrl = object["url"] as? String,
109
119
  let sha256 = object["sha256"] as? String,
110
120
  let size = object["size"] as? Int
111
121
  else {
@@ -129,10 +139,17 @@ enum ManifestClient {
129
139
  let forceImmediate = object["forceImmediate"] as? Bool ?? false
130
140
  let encryption = try parseEncryption(object["encryption"])
131
141
 
132
- guard let dlURL = URL(string: downloadUrl) else {
133
- throw ManifestClientError.invalidURL
142
+ let downloadUrl = (object["url"] as? String)?.nilIfEmpty
143
+ var files: [ManifestFileEntry]?
144
+
145
+ if strategy == "deltas" {
146
+ files = try parseFiles(object["files"], allowInsecureUrls: allowInsecureUrls)
147
+ } else {
148
+ guard let downloadUrl, let dlURL = URL(string: downloadUrl) else {
149
+ throw ManifestClientError.invalidResponse
150
+ }
151
+ try requireHTTPS(url: dlURL, allowInsecure: allowInsecureUrls)
134
152
  }
135
- try requireHTTPS(url: dlURL, allowInsecure: allowInsecureUrls)
136
153
 
137
154
  if manifestKeys.isEmpty {
138
155
  print("[OtaKit] WARNING: No manifest signing keys configured — signature verification is disabled for this request.")
@@ -167,10 +184,41 @@ enum ManifestClient {
167
184
  releaseId: releaseId,
168
185
  strategy: strategy,
169
186
  forceImmediate: forceImmediate,
170
- encryption: encryption
187
+ encryption: encryption,
188
+ files: files
171
189
  )
172
190
  }
173
191
 
192
+ private static func parseFiles(
193
+ _ rawValue: Any?,
194
+ allowInsecureUrls: Bool
195
+ ) throws -> [ManifestFileEntry] {
196
+ guard let rawFiles = rawValue as? [[String: Any]], !rawFiles.isEmpty else {
197
+ throw ManifestClientError.invalidResponse
198
+ }
199
+
200
+ var entries: [ManifestFileEntry] = []
201
+ entries.reserveCapacity(rawFiles.count)
202
+ for rawFile in rawFiles {
203
+ guard let path = rawFile["path"] as? String,
204
+ let sha256 = rawFile["sha256"] as? String,
205
+ let fileUrl = rawFile["url"] as? String,
206
+ let parsedUrl = URL(string: fileUrl) else {
207
+ throw ManifestClientError.invalidResponse
208
+ }
209
+ try requireHTTPS(url: parsedUrl, allowInsecure: allowInsecureUrls)
210
+ entries.append(
211
+ ManifestFileEntry(
212
+ path: path,
213
+ sha256: sha256,
214
+ size: rawFile["size"] as? Int,
215
+ url: fileUrl
216
+ )
217
+ )
218
+ }
219
+ return entries
220
+ }
221
+
174
222
  private static func parseEncryption(_ rawValue: Any?) throws -> ManifestEncryption? {
175
223
  guard let rawValue, !(rawValue is NSNull) else {
176
224
  return nil
@@ -1040,10 +1040,13 @@ public class UpdaterPlugin: CAPPlugin, CAPBridgedPlugin {
1040
1040
  ) -> [String: Any] {
1041
1041
  var payload: [String: Any] = [
1042
1042
  "version": latest.version,
1043
- "url": latest.url,
1044
1043
  "sha256": latest.sha256,
1045
1044
  "size": latest.size,
1045
+ "strategy": latest.strategy,
1046
1046
  ]
1047
+ if let url = latest.url {
1048
+ payload["url"] = url
1049
+ }
1047
1050
  if let runtimeVersion = latest.runtimeVersion {
1048
1051
  payload["runtimeVersion"] = runtimeVersion
1049
1052
  }
@@ -1056,7 +1059,11 @@ public class UpdaterPlugin: CAPPlugin, CAPBridgedPlugin {
1056
1059
  _ manifest: LatestManifest,
1057
1060
  targetChannel: String?
1058
1061
  ) async throws -> BundleInfo {
1059
- guard let url = URL(string: manifest.url) else {
1062
+ if manifest.strategy == "deltas" {
1063
+ return try await assembleAndStage(manifest: manifest, targetChannel: targetChannel)
1064
+ }
1065
+
1066
+ guard let urlString = manifest.url, let url = URL(string: urlString) else {
1060
1067
  throw NSError(
1061
1068
  domain: "OtaKit",
1062
1069
  code: 1,
@@ -1076,6 +1083,163 @@ public class UpdaterPlugin: CAPPlugin, CAPBridgedPlugin {
1076
1083
  )
1077
1084
  }
1078
1085
 
1086
+ private static let bundleFileListName = "otakit_files.json"
1087
+
1088
+ /// Deltas strategy: fill content-cache misses and assemble the bundle from
1089
+ /// the cache, then hand it to the same staging path the zip flow uses.
1090
+ private func assembleAndStage(
1091
+ manifest: LatestManifest,
1092
+ targetChannel: String?
1093
+ ) async throws -> BundleInfo {
1094
+ // Same conservative disk-space guard as the zip path.
1095
+ let requiredSpace = Int64(Double(manifest.size) * 2.5)
1096
+ if getFreeDiskSpace() < requiredSpace {
1097
+ sendDeviceEvent(
1098
+ action: .downloadError,
1099
+ bundleVersion: manifest.version,
1100
+ runtimeVersion: manifest.runtimeVersion,
1101
+ channel: targetChannel,
1102
+ releaseId: manifest.releaseId,
1103
+ detail: "insufficient_disk_space"
1104
+ )
1105
+ emitEvent(
1106
+ "downloadFailed",
1107
+ failureEventData(
1108
+ version: manifest.version,
1109
+ runtimeVersion: manifest.runtimeVersion,
1110
+ channel: targetChannel,
1111
+ releaseId: manifest.releaseId,
1112
+ reason: "insufficient_disk_space"
1113
+ )
1114
+ )
1115
+ throw NSError(
1116
+ domain: "OtaKit",
1117
+ code: 1,
1118
+ userInfo: [NSLocalizedDescriptionKey: "Insufficient disk space"]
1119
+ )
1120
+ }
1121
+
1122
+ let assembleDirectory = fileManager.temporaryDirectory
1123
+ .appendingPathComponent("otakit-assemble-\(UUID().uuidString)", isDirectory: true)
1124
+ defer {
1125
+ try? fileManager.removeItem(at: assembleDirectory)
1126
+ }
1127
+
1128
+ do {
1129
+ guard let files = manifest.files, !files.isEmpty else {
1130
+ throw NSError(
1131
+ domain: "OtaKit",
1132
+ code: 1,
1133
+ userInfo: [NSLocalizedDescriptionKey: "Delta manifest is missing its file list"]
1134
+ )
1135
+ }
1136
+
1137
+ let assembler = DeltaAssembler(
1138
+ cacheDirectory: store.filesCacheDirectory,
1139
+ downloader: downloader
1140
+ )
1141
+ try assembler.validate(files, expectedFilesHash: manifest.sha256)
1142
+
1143
+ if let builtinDirectory = Bundle.main.resourceURL?
1144
+ .appendingPathComponent("public", isDirectory: true) {
1145
+ assembler.seedFromBuiltinIfNeeded(
1146
+ builtinDirectory: builtinDirectory,
1147
+ nativeBuild: coordinator.nativeBuild
1148
+ )
1149
+ }
1150
+
1151
+ try await assembler.assemble(entries: files, into: assembleDirectory)
1152
+
1153
+ let bundleId = buildBundleId(
1154
+ version: manifest.version,
1155
+ releaseId: manifest.releaseId,
1156
+ sha256: manifest.sha256
1157
+ )
1158
+ let destination = coordinator.bundleDirectory(for: bundleId)
1159
+ if fileManager.fileExists(atPath: destination.path) {
1160
+ try fileManager.removeItem(at: destination)
1161
+ }
1162
+ try fileManager.moveItem(at: assembleDirectory, to: destination)
1163
+
1164
+ // Record this bundle's content hashes for cache pruning.
1165
+ let hashes = files.map { $0.sha256.lowercased() }
1166
+ if let data = try? JSONSerialization.data(withJSONObject: hashes) {
1167
+ try? data.write(
1168
+ to: destination.appendingPathComponent(UpdaterPlugin.bundleFileListName),
1169
+ options: .atomic
1170
+ )
1171
+ }
1172
+
1173
+ let info = BundleInfo(
1174
+ id: bundleId,
1175
+ version: manifest.version,
1176
+ runtimeVersion: manifest.runtimeVersion,
1177
+ status: .pending,
1178
+ downloadedAt: Date(),
1179
+ sha256: manifest.sha256,
1180
+ path: destination.path,
1181
+ channel: targetChannel,
1182
+ releaseId: manifest.releaseId
1183
+ )
1184
+
1185
+ let cleanupBundleIds = try coordinator.stageDownloadedBundle(info)
1186
+ coordinator.cleanupBundles(cleanupBundleIds)
1187
+
1188
+ pruneDeltaCache(assembler: assembler)
1189
+
1190
+ sendDeviceEvent(
1191
+ action: .downloaded,
1192
+ bundleVersion: manifest.version,
1193
+ runtimeVersion: manifest.runtimeVersion,
1194
+ channel: targetChannel,
1195
+ releaseId: manifest.releaseId
1196
+ )
1197
+ emitEvent("updateStaged", ["bundle": info.toDictionary()])
1198
+ return info
1199
+ } catch {
1200
+ sendDeviceEvent(
1201
+ action: .downloadError,
1202
+ bundleVersion: manifest.version,
1203
+ runtimeVersion: manifest.runtimeVersion,
1204
+ channel: targetChannel,
1205
+ releaseId: manifest.releaseId,
1206
+ detail: error.localizedDescription
1207
+ )
1208
+ emitEvent(
1209
+ "downloadFailed",
1210
+ failureEventData(
1211
+ version: manifest.version,
1212
+ runtimeVersion: manifest.runtimeVersion,
1213
+ channel: targetChannel,
1214
+ releaseId: manifest.releaseId,
1215
+ reason: failureReason(from: error)
1216
+ )
1217
+ )
1218
+ throw error
1219
+ }
1220
+ }
1221
+
1222
+ /// Keep only cache entries referenced by live bundles (plus the builtin seed).
1223
+ private func pruneDeltaCache(assembler: DeltaAssembler) {
1224
+ var referenced = Set<String>()
1225
+ let liveBundleIds = [
1226
+ store.getCurrentBundleId(),
1227
+ store.getFallbackBundleId(),
1228
+ store.getStagedBundleId(),
1229
+ ]
1230
+ for bundleId in liveBundleIds {
1231
+ guard let bundleId else { continue }
1232
+ let listURL = store.bundleDirectory(for: bundleId)
1233
+ .appendingPathComponent(UpdaterPlugin.bundleFileListName)
1234
+ guard let data = try? Data(contentsOf: listURL),
1235
+ let hashes = try? JSONSerialization.jsonObject(with: data) as? [String] else {
1236
+ continue
1237
+ }
1238
+ referenced.formUnion(hashes.map { $0.lowercased() })
1239
+ }
1240
+ assembler.pruneCache(referencedHashes: referenced)
1241
+ }
1242
+
1079
1243
  private func pruneIncompatibleBundles() {
1080
1244
  let cleanupBundleIds = coordinator.pruneIncompatibleBundles(
1081
1245
  isCompatibleRuntime: isCompatibleRuntime
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@otakit/capacitor-updater",
3
- "version": "2.2.0",
3
+ "version": "2.3.1",
4
4
  "description": "Capacitor plugin for OTA updates",
5
5
  "main": "dist/plugin.cjs.js",
6
6
  "module": "dist/esm/index.js",