@otakit/capacitor-updater 2.3.2 → 3.0.0
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.
- package/README.md +46 -5
- package/android/build.gradle +16 -0
- package/android/src/main/java/com/otakit/updater/BundleCrypto.java +38 -17
- package/android/src/main/java/com/otakit/updater/BundleInfo.java +10 -0
- package/android/src/main/java/com/otakit/updater/BundleStore.java +134 -86
- package/android/src/main/java/com/otakit/updater/CheckFailure.java +53 -0
- package/android/src/main/java/com/otakit/updater/DeltaAssembler.java +46 -41
- package/android/src/main/java/com/otakit/updater/DeviceEventClient.java +125 -26
- package/android/src/main/java/com/otakit/updater/DocumentReadyBridge.java +71 -0
- package/android/src/main/java/com/otakit/updater/DownloadRetry.java +144 -0
- package/android/src/main/java/com/otakit/updater/EventOutbox.java +174 -0
- package/android/src/main/java/com/otakit/updater/FileDownloader.java +80 -0
- package/android/src/main/java/com/otakit/updater/ForegroundDeadline.java +84 -0
- package/android/src/main/java/com/otakit/updater/HashUtils.java +26 -0
- package/android/src/main/java/com/otakit/updater/ManifestClient.java +12 -9
- package/android/src/main/java/com/otakit/updater/ManifestKeyConfig.java +47 -0
- package/android/src/main/java/com/otakit/updater/ManifestVerifier.java +22 -4
- package/android/src/main/java/com/otakit/updater/SDKVersion.java +9 -0
- package/android/src/main/java/com/otakit/updater/UpdateOwner.java +27 -0
- package/android/src/main/java/com/otakit/updater/UpdaterCoordinator.java +154 -59
- package/android/src/main/java/com/otakit/updater/UpdaterPlugin.java +284 -209
- package/android/src/main/java/com/otakit/updater/WebViewActivation.java +26 -0
- package/dist/esm/definitions.d.ts +3 -3
- package/dist/esm/definitions.d.ts.map +1 -1
- package/ios/Sources/UpdaterPlugin/BundleCrypto.swift +35 -3
- package/ios/Sources/UpdaterPlugin/BundleInfo.swift +13 -0
- package/ios/Sources/UpdaterPlugin/BundleStore.swift +103 -77
- package/ios/Sources/UpdaterPlugin/CheckFailure.swift +41 -0
- package/ios/Sources/UpdaterPlugin/DeltaAssembler.swift +29 -17
- package/ios/Sources/UpdaterPlugin/DeviceEventClient.swift +12 -10
- package/ios/Sources/UpdaterPlugin/DocumentReadyBridge.swift +43 -0
- package/ios/Sources/UpdaterPlugin/DownloadRetry.swift +51 -0
- package/ios/Sources/UpdaterPlugin/Downloader.swift +97 -39
- package/ios/Sources/UpdaterPlugin/EventOutbox.swift +183 -0
- package/ios/Sources/UpdaterPlugin/ForegroundDeadline.swift +94 -0
- package/ios/Sources/UpdaterPlugin/HashUtils.swift +16 -0
- package/ios/Sources/UpdaterPlugin/ManifestClient.swift +2 -4
- package/ios/Sources/UpdaterPlugin/ManifestKeyConfig.swift +20 -0
- package/ios/Sources/UpdaterPlugin/ManifestVerifier.swift +7 -1
- package/ios/Sources/UpdaterPlugin/SDKVersion.swift +4 -0
- package/ios/Sources/UpdaterPlugin/UpdateOwner.swift +12 -0
- package/ios/Sources/UpdaterPlugin/UpdaterCoordinator.swift +146 -110
- package/ios/Sources/UpdaterPlugin/UpdaterPlugin.swift +206 -202
- package/ios/Tests/UpdaterPluginTests/BundleCryptoTests.swift +80 -0
- package/ios/Tests/UpdaterPluginTests/BundlePersistenceTests.swift +102 -0
- package/ios/Tests/UpdaterPluginTests/CheckFailureTests.swift +55 -0
- package/ios/Tests/UpdaterPluginTests/DeltaCacheIntegrityTests.swift +69 -0
- package/ios/Tests/UpdaterPluginTests/DocumentReadyBridgeTests.swift +102 -0
- package/ios/Tests/UpdaterPluginTests/DownloadIntegrityTests.swift +40 -0
- package/ios/Tests/UpdaterPluginTests/DownloadRetryTests.swift +189 -0
- package/ios/Tests/UpdaterPluginTests/EventDeliveryTests.swift +56 -0
- package/ios/Tests/UpdaterPluginTests/EventOutboxTests.swift +86 -0
- package/ios/Tests/UpdaterPluginTests/ForegroundDeadlineTests.swift +144 -0
- package/ios/Tests/UpdaterPluginTests/ManifestKeyConfigTests.swift +48 -0
- package/ios/Tests/UpdaterPluginTests/UpdateOwnerTests.swift +34 -0
- package/ios/Tests/UpdaterPluginTests/UpdaterCoordinatorTests.swift +394 -0
- package/package.json +7 -3
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
package com.otakit.updater;
|
|
2
|
+
|
|
3
|
+
final class WebViewActivation {
|
|
4
|
+
|
|
5
|
+
@FunctionalInterface
|
|
6
|
+
interface PathActivator {
|
|
7
|
+
void activate(String path);
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
private WebViewActivation() {}
|
|
11
|
+
|
|
12
|
+
static void activate(
|
|
13
|
+
String path,
|
|
14
|
+
String builtinAssetPath,
|
|
15
|
+
PathActivator setServerBasePath,
|
|
16
|
+
PathActivator setServerAssetPath
|
|
17
|
+
) {
|
|
18
|
+
// Capacitor's Android setters both switch the hosted files and enqueue the
|
|
19
|
+
// WebView navigation. Calling WebView.reload() as well causes two navigations.
|
|
20
|
+
if (path == null || path.isEmpty()) {
|
|
21
|
+
setServerAssetPath.activate(builtinAssetPath);
|
|
22
|
+
} else {
|
|
23
|
+
setServerBasePath.activate(path);
|
|
24
|
+
}
|
|
25
|
+
}
|
|
26
|
+
}
|
|
@@ -15,7 +15,7 @@ export declare enum BundleStatus {
|
|
|
15
15
|
ERROR = "error"
|
|
16
16
|
}
|
|
17
17
|
export interface BundleInfo {
|
|
18
|
-
/**
|
|
18
|
+
/** Opaque local installation identifier. Separate downloads can have different IDs for the same release. */
|
|
19
19
|
id: string;
|
|
20
20
|
/** Semantic version string */
|
|
21
21
|
version: string;
|
|
@@ -154,7 +154,7 @@ export interface OtaKitConfig {
|
|
|
154
154
|
* Set to 0 or a negative value to disable resume throttling.
|
|
155
155
|
*/
|
|
156
156
|
checkInterval?: number;
|
|
157
|
-
/**
|
|
157
|
+
/** Foreground milliseconds to wait for notifyAppReady(). Pauses while inactive/backgrounded. Defaults to 10000. */
|
|
158
158
|
appReadyTimeout?: number;
|
|
159
159
|
/** Custom event ingest base URL. Hosted default: https://ingest.otakit.app/v1 */
|
|
160
160
|
ingestUrl?: string;
|
|
@@ -205,7 +205,7 @@ export interface OtaKitPlugin {
|
|
|
205
205
|
update(): Promise<void>;
|
|
206
206
|
/**
|
|
207
207
|
* **CRITICAL**: Call this when your app has successfully started.
|
|
208
|
-
* Must be called within appReadyTimeout (default 10s) or rollback occurs.
|
|
208
|
+
* Must be called within appReadyTimeout of foreground time (default 10s) or rollback occurs.
|
|
209
209
|
*/
|
|
210
210
|
notifyAppReady(): Promise<void>;
|
|
211
211
|
/**
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"definitions.d.ts","sourceRoot":"","sources":["../../src/definitions.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAE,oBAAoB,EAAE,MAAM,iBAAiB,CAAC;AAE5D;;GAEG;AACH,oBAAY,YAAY;IACtB,+BAA+B;IAC/B,OAAO,YAAY;IACnB,iDAAiD;IACjD,OAAO,YAAY;IACnB,mCAAmC;IACnC,KAAK,UAAU;IACf,wBAAwB;IACxB,OAAO,YAAY;IACnB,yDAAyD;IACzD,KAAK,UAAU;CAChB;AAED,MAAM,WAAW,UAAU;IACzB
|
|
1
|
+
{"version":3,"file":"definitions.d.ts","sourceRoot":"","sources":["../../src/definitions.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAE,oBAAoB,EAAE,MAAM,iBAAiB,CAAC;AAE5D;;GAEG;AACH,oBAAY,YAAY;IACtB,+BAA+B;IAC/B,OAAO,YAAY;IACnB,iDAAiD;IACjD,OAAO,YAAY;IACnB,mCAAmC;IACnC,KAAK,UAAU;IACf,wBAAwB;IACxB,OAAO,YAAY;IACnB,yDAAyD;IACzD,KAAK,UAAU;CAChB;AAED,MAAM,WAAW,UAAU;IACzB,4GAA4G;IAC5G,EAAE,EAAE,MAAM,CAAC;IACX,8BAA8B;IAC9B,OAAO,EAAE,MAAM,CAAC;IAChB,iDAAiD;IACjD,cAAc,CAAC,EAAE,MAAM,CAAC;IACxB,mCAAmC;IACnC,MAAM,EAAE,YAAY,CAAC;IACrB,+CAA+C;IAC/C,YAAY,CAAC,EAAE,MAAM,CAAC;IACtB,iCAAiC;IACjC,MAAM,CAAC,EAAE,MAAM,CAAC;IAChB,qDAAqD;IACrD,OAAO,CAAC,EAAE,MAAM,CAAC;IACjB,gEAAgE;IAChE,SAAS,CAAC,EAAE,MAAM,CAAC;CACpB;AAED,MAAM,WAAW,aAAa;IAC5B,qBAAqB;IACrB,OAAO,EAAE,MAAM,CAAC;IAChB,iDAAiD;IACjD,cAAc,CAAC,EAAE,MAAM,CAAC;IACxB,gFAAgF;IAChF,GAAG,CAAC,EAAE,MAAM,CAAC;IACb,sFAAsF;IACtF,MAAM,EAAE,MAAM,CAAC;IACf,kEAAkE;IAClE,IAAI,EAAE,MAAM,CAAC;IACb,uDAAuD;IACvD,SAAS,EAAE,MAAM,CAAC;IAClB,2EAA2E;IAC3E,QAAQ,CAAC,EAAE,KAAK,GAAG,QAAQ,CAAC;IAC5B;;;;OAIG;IACH,cAAc,CAAC,EAAE,OAAO,CAAC;CAC1B;AAED,MAAM,WAAW,WAAW;IAC1B,OAAO,EAAE,UAAU,CAAC;IACpB,QAAQ,EAAE,UAAU,CAAC;IACrB,MAAM,EAAE,UAAU,GAAG,IAAI,CAAC;IAC1B,cAAc,EAAE,MAAM,CAAC;CACxB;AAED,MAAM,MAAM,YAAY,GAAG,KAAK,GAAG,QAAQ,GAAG,cAAc,GAAG,WAAW,CAAC;AAE3E,MAAM,WAAW,iBAAiB;IAChC,GAAG,EAAE,MAAM,CAAC;IACZ,GAAG,EAAE,MAAM,CAAC;CACb;AAED,MAAM,WAAW,eAAe;IAC9B,8FAA8F;IAC9F,GAAG,EAAE,MAAM,CAAC;IACZ,oFAAoF;IACpF,GAAG,EAAE,MAAM,CAAC;CACb;AAED,MAAM,WAAW,mBAAmB;IAClC,IAAI,EAAE,WAAW,CAAC;CACnB;AAED,MAAM,WAAW,wBAAwB;IACvC,IAAI,EAAE,gBAAgB,CAAC;IACvB,MAAM,EAAE,aAAa,CAAC;CACvB;AAED,MAAM,WAAW,0BAA0B;IACzC,IAAI,EAAE,kBAAkB,CAAC;IACzB,MAAM,EAAE,aAAa,CAAC;CACvB;AAED,MAAM,MAAM,WAAW,GACnB,mBAAmB,GACnB,wBAAwB,GACxB,0BAA0B,CAAC;AAE/B,MAAM,WAAW,sBAAsB;IACrC,IAAI,EAAE,WAAW,CAAC;CACnB;AAED,MAAM,WAAW,oBAAoB;IACnC,IAAI,EAAE,QAAQ,CAAC;IACf,MAAM,EAAE,UAAU,CAAC;CACpB;AAED,MAAM,MAAM,cAAc,GAAG,sBAAsB,GAAG,oBAAoB,CAAC;AAE3E,MAAM,WAAW,WAAW;IAC1B,mEAAmE;IACnE,OAAO,EAAE,MAAM,GAAG,IAAI,CAAC;IACvB,mFAAmF;IACnF,MAAM,EAAE,UAAU,GAAG,QAAQ,CAAC;CAC/B;AAED;;;GAGG;AACH,MAAM,WAAW,iBAAiB;IAChC,MAAM,EAAE,UAAU,CAAC;CACpB;AAED;;;GAGG;AACH,MAAM,WAAW,kBAAkB;IACjC,MAAM,EAAE,UAAU,CAAC;CACpB;AAED;;GAEG;AACH,MAAM,WAAW,iBAAiB;IAChC,OAAO,EAAE,MAAM,CAAC;IAChB,cAAc,CAAC,EAAE,MAAM,CAAC;IACxB,SAAS,CAAC,EAAE,MAAM,CAAC;IACnB,OAAO,CAAC,EAAE,MAAM,CAAC;IACjB,gGAAgG;IAChG,MAAM,EAAE,MAAM,CAAC;CAChB;AAED;;;;;;;GAOG;AACH,MAAM,MAAM,eAAe,GACvB,iBAAiB,GACjB,cAAc,GACd,eAAe,GACf,gBAAgB,GAChB,UAAU,CAAC;AAEf;;GAEG;AACH,MAAM,WAAW,YAAY;IAC3B,gEAAgE;IAChE,KAAK,EAAE,MAAM,CAAC;IACd,kEAAkE;IAClE,OAAO,CAAC,EAAE,MAAM,CAAC;IACjB,uGAAuG;IACvG,cAAc,CAAC,EAAE,MAAM,CAAC;IACxB,2FAA2F;IAC3F,YAAY,CAAC,EAAE,YAAY,CAAC;IAC5B,oDAAoD;IACpD,YAAY,CAAC,EAAE,YAAY,CAAC;IAC5B,2GAA2G;IAC3G,aAAa,CAAC,EAAE,YAAY,CAAC;IAC7B;;;;;OAKG;IACH,aAAa,CAAC,EAAE,MAAM,CAAC;IACvB,mHAAmH;IACnH,eAAe,CAAC,EAAE,MAAM,CAAC;IACzB,iFAAiF;IACjF,SAAS,CAAC,EAAE,MAAM,CAAC;IACnB,yHAAyH;IACzH,SAAS,CAAC,EAAE,MAAM,CAAC;IACnB,4DAA4D;IAC5D,MAAM,CAAC,EAAE,MAAM,CAAC;IAChB,yEAAyE;IACzE,YAAY,CAAC,EAAE,iBAAiB,EAAE,CAAC;IACnC;;;OAGG;IACH,UAAU,CAAC,EAAE,eAAe,EAAE,CAAC;IAC/B,oEAAoE;IACpE,iBAAiB,CAAC,EAAE,OAAO,CAAC;CAC7B;AAED,MAAM,WAAW,YAAY;IAC3B;;OAEG;IACH,QAAQ,IAAI,OAAO,CAAC,WAAW,CAAC,CAAC;IAEjC;;OAEG;IACH,KAAK,IAAI,OAAO,CAAC,WAAW,CAAC,CAAC;IAE9B;;OAEG;IACH,QAAQ,IAAI,OAAO,CAAC,cAAc,CAAC,CAAC;IAEpC;;;;;;OAMG;IACH,KAAK,IAAI,OAAO,CAAC,IAAI,CAAC,CAAC;IAEvB;;;;;;;;OAQG;IACH,MAAM,IAAI,OAAO,CAAC,IAAI,CAAC,CAAC;IAExB;;;OAGG;IACH,cAAc,IAAI,OAAO,CAAC,IAAI,CAAC,CAAC;IAEhC;;;OAGG;IACH,cAAc,IAAI,OAAO,CAAC,UAAU,GAAG,IAAI,CAAC,CAAC;IAE7C;;;;;;;OAOG;IACH,UAAU,CAAC,OAAO,EAAE;QAAE,OAAO,EAAE,MAAM,GAAG,IAAI,CAAA;KAAE,GAAG,OAAO,CAAC,IAAI,CAAC,CAAC;IAE/D;;;OAGG;IACH,UAAU,IAAI,OAAO,CAAC,WAAW,CAAC,CAAC;IAEnC;;;;;;;;OAQG;IACH,WAAW,CACT,SAAS,EAAE,iBAAiB,EAC5B,YAAY,EAAE,CAAC,MAAM,EAAE,aAAa,KAAK,IAAI,GAC5C,OAAO,CAAC,oBAAoB,CAAC,CAAC;IACjC,WAAW,CACT,SAAS,EAAE,cAAc,EACzB,YAAY,EAAE,CAAC,KAAK,EAAE,iBAAiB,KAAK,IAAI,GAC/C,OAAO,CAAC,oBAAoB,CAAC,CAAC;IACjC,WAAW,CACT,SAAS,EAAE,eAAe,EAC1B,YAAY,EAAE,CAAC,KAAK,EAAE,kBAAkB,KAAK,IAAI,GAChD,OAAO,CAAC,oBAAoB,CAAC,CAAC;IACjC,WAAW,CACT,SAAS,EAAE,gBAAgB,EAC3B,YAAY,EAAE,CAAC,KAAK,EAAE,iBAAiB,KAAK,IAAI,GAC/C,OAAO,CAAC,oBAAoB,CAAC,CAAC;IACjC,WAAW,CACT,SAAS,EAAE,UAAU,EACrB,YAAY,EAAE,CAAC,KAAK,EAAE,iBAAiB,KAAK,IAAI,GAC/C,OAAO,CAAC,oBAAoB,CAAC,CAAC;IAEjC;;OAEG;IACH,kBAAkB,IAAI,OAAO,CAAC,IAAI,CAAC,CAAC;CACrC;AAED,MAAM,WAAW,kBAAkB;IACjC,QAAQ,IAAI,OAAO,CAAC,WAAW,CAAC,CAAC;IACjC,KAAK,IAAI,OAAO,CAAC,WAAW,CAAC,CAAC;IAC9B,QAAQ,IAAI,OAAO,CAAC,cAAc,CAAC,CAAC;IACpC,KAAK,IAAI,OAAO,CAAC,IAAI,CAAC,CAAC;IACvB,MAAM,IAAI,OAAO,CAAC,IAAI,CAAC,CAAC;IACxB,cAAc,IAAI,OAAO,CAAC,IAAI,CAAC,CAAC;IAChC,cAAc,IAAI,OAAO,CAAC,UAAU,GAAG,IAAI,CAAC,CAAC;IAC7C,UAAU,CAAC,OAAO,EAAE;QAAE,OAAO,EAAE,MAAM,GAAG,IAAI,CAAA;KAAE,GAAG,OAAO,CAAC,IAAI,CAAC,CAAC;IAC/D,UAAU,IAAI,OAAO,CAAC,WAAW,CAAC,CAAC;IACnC,WAAW,CACT,SAAS,EAAE,eAAe,EAC1B,YAAY,EAAE,CAAC,KAAK,EAAE,OAAO,KAAK,IAAI,GACrC,OAAO,CAAC,oBAAoB,CAAC,CAAC;IACjC,kBAAkB,IAAI,OAAO,CAAC,IAAI,CAAC,CAAC;CACrC"}
|
|
@@ -1,10 +1,12 @@
|
|
|
1
1
|
import CryptoKit
|
|
2
2
|
import Foundation
|
|
3
|
+
import os
|
|
3
4
|
|
|
4
5
|
enum BundleCryptoError: Error, LocalizedError {
|
|
5
6
|
case noMatchingKey(String)
|
|
6
7
|
case invalidParameter(String)
|
|
7
8
|
case decryptionFailed(String)
|
|
9
|
+
case insufficientMemory(encryptedBytes: Int64, budgetBytes: Int64)
|
|
8
10
|
|
|
9
11
|
var errorDescription: String? {
|
|
10
12
|
switch self {
|
|
@@ -14,6 +16,8 @@ enum BundleCryptoError: Error, LocalizedError {
|
|
|
14
16
|
return "invalid bundle encryption parameter: \(name)"
|
|
15
17
|
case let .decryptionFailed(detail):
|
|
16
18
|
return "bundle decryption failed: \(detail)"
|
|
19
|
+
case let .insufficientMemory(encryptedBytes, budgetBytes):
|
|
20
|
+
return "insufficient_memory_for_encrypted_bundle; encryptedBytes=\(encryptedBytes); budgetBytes=\(budgetBytes)"
|
|
17
21
|
}
|
|
18
22
|
}
|
|
19
23
|
}
|
|
@@ -25,6 +29,25 @@ enum BundleCryptoError: Error, LocalizedError {
|
|
|
25
29
|
enum BundleCrypto {
|
|
26
30
|
private static let tagLength = 16
|
|
27
31
|
private static let keyLength = 32
|
|
32
|
+
private static let reserveBytes: Int64 = 32 * 1024 * 1024
|
|
33
|
+
private static let maximumEncryptedBytes: Int64 = 128 * 1024 * 1024
|
|
34
|
+
|
|
35
|
+
static func decryptionMemoryEstimate() -> Int64 {
|
|
36
|
+
#if targetEnvironment(simulator)
|
|
37
|
+
// Simulator apps can report zero from os_proc_available_memory(). Host RAM
|
|
38
|
+
// is only an estimate; keep the same reserve, multiplier, and hard cap.
|
|
39
|
+
return Int64(clamping: ProcessInfo.processInfo.physicalMemory)
|
|
40
|
+
#else
|
|
41
|
+
return Int64(os_proc_available_memory())
|
|
42
|
+
#endif
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
static func requireMemoryBudget(encryptedBytes: Int64, availableBytes: Int64) throws {
|
|
46
|
+
let budget = availableBytes <= reserveBytes ? 0 : min(maximumEncryptedBytes, (availableBytes - reserveBytes) / 4)
|
|
47
|
+
guard encryptedBytes <= budget else {
|
|
48
|
+
throw BundleCryptoError.insufficientMemory(encryptedBytes: encryptedBytes, budgetBytes: budget)
|
|
49
|
+
}
|
|
50
|
+
}
|
|
28
51
|
|
|
29
52
|
static func unwrapDek(
|
|
30
53
|
kek: Data,
|
|
@@ -63,16 +86,25 @@ enum BundleCrypto {
|
|
|
63
86
|
dek: Data,
|
|
64
87
|
nonceB64: String,
|
|
65
88
|
input: URL,
|
|
66
|
-
output: URL
|
|
89
|
+
output: URL,
|
|
90
|
+
availableBytes: Int64 = BundleCrypto.decryptionMemoryEstimate()
|
|
67
91
|
) throws {
|
|
68
92
|
guard let nonce = Data(base64Encoded: nonceB64) else {
|
|
69
93
|
throw BundleCryptoError.invalidParameter("nonce")
|
|
70
94
|
}
|
|
71
95
|
|
|
72
|
-
|
|
73
|
-
|
|
96
|
+
guard dek.count == keyLength, nonce.count == 12 else {
|
|
97
|
+
throw BundleCryptoError.invalidParameter("key or nonce length")
|
|
98
|
+
}
|
|
99
|
+
let attributes = try FileManager.default.attributesOfItem(atPath: input.path)
|
|
100
|
+
guard let size = attributes[.size] as? NSNumber, size.int64Value > tagLength else {
|
|
74
101
|
throw BundleCryptoError.invalidParameter("ciphertext too short")
|
|
75
102
|
}
|
|
103
|
+
try requireMemoryBudget(encryptedBytes: size.int64Value, availableBytes: availableBytes)
|
|
104
|
+
let ciphertextWithTag = try Data(contentsOf: input, options: .mappedIfSafe)
|
|
105
|
+
guard ciphertextWithTag.count == size.intValue else {
|
|
106
|
+
throw BundleCryptoError.invalidParameter("ciphertext changed during read")
|
|
107
|
+
}
|
|
76
108
|
|
|
77
109
|
do {
|
|
78
110
|
let sealedBox = try AES.GCM.SealedBox(
|
|
@@ -15,6 +15,19 @@ struct BundleInfo: Codable {
|
|
|
15
15
|
id == "builtin"
|
|
16
16
|
}
|
|
17
17
|
|
|
18
|
+
var attemptId: String? {
|
|
19
|
+
guard id.hasPrefix("bundle-"), UUID(uuidString: String(id.dropFirst(7))) != nil else { return nil }
|
|
20
|
+
return id
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
func withStatus(_ status: BundleStatus) -> BundleInfo {
|
|
24
|
+
BundleInfo(
|
|
25
|
+
id: id, version: version, runtimeVersion: runtimeVersion, status: status,
|
|
26
|
+
downloadedAt: downloadedAt, sha256: sha256, path: path, channel: channel,
|
|
27
|
+
releaseId: releaseId
|
|
28
|
+
)
|
|
29
|
+
}
|
|
30
|
+
|
|
18
31
|
func toDictionary() -> [String: Any] {
|
|
19
32
|
var result: [String: Any] = [
|
|
20
33
|
"id": id,
|
|
@@ -1,6 +1,9 @@
|
|
|
1
1
|
import Foundation
|
|
2
2
|
|
|
3
3
|
final class BundleStore {
|
|
4
|
+
/// Release identity is metadata; an installation must never reuse a live directory.
|
|
5
|
+
static func newInstallationId() -> String { "bundle-\(UUID().uuidString.lowercased())" }
|
|
6
|
+
|
|
4
7
|
private enum Keys {
|
|
5
8
|
static let currentBundleId = "otakit_current_bundle_id"
|
|
6
9
|
static let fallbackBundleId = "otakit_fallback_bundle_id"
|
|
@@ -10,28 +13,33 @@ final class BundleStore {
|
|
|
10
13
|
static let overrideChannel = "otakit_override_channel"
|
|
11
14
|
}
|
|
12
15
|
|
|
13
|
-
private let defaults
|
|
16
|
+
private let defaults: UserDefaults
|
|
17
|
+
private let rootDirectory: URL
|
|
14
18
|
private let fileManager = FileManager.default
|
|
15
19
|
var appRuntimeVersion: String?
|
|
16
20
|
|
|
17
|
-
|
|
21
|
+
init(defaults: UserDefaults = .standard, rootDirectory: URL? = nil) {
|
|
22
|
+
self.defaults = defaults
|
|
23
|
+
self.rootDirectory = rootDirectory ?? FileManager.default.urls(
|
|
24
|
+
for: .applicationSupportDirectory,
|
|
25
|
+
in: .userDomainMask
|
|
26
|
+
)[0]
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
private var decoder: JSONDecoder {
|
|
18
30
|
let decoder = JSONDecoder()
|
|
19
31
|
decoder.dateDecodingStrategy = .iso8601
|
|
20
32
|
return decoder
|
|
21
|
-
}
|
|
33
|
+
}
|
|
22
34
|
|
|
23
|
-
private
|
|
35
|
+
private var encoder: JSONEncoder {
|
|
24
36
|
let encoder = JSONEncoder()
|
|
25
37
|
encoder.dateEncodingStrategy = .iso8601
|
|
26
38
|
return encoder
|
|
27
|
-
}
|
|
39
|
+
}
|
|
28
40
|
|
|
29
41
|
private(set) lazy var bundlesDirectory: URL = {
|
|
30
|
-
let
|
|
31
|
-
for: .applicationSupportDirectory,
|
|
32
|
-
in: .userDomainMask
|
|
33
|
-
).first!
|
|
34
|
-
let directory = appSupport.appendingPathComponent(
|
|
42
|
+
let directory = rootDirectory.appendingPathComponent(
|
|
35
43
|
"otakit_bundles",
|
|
36
44
|
isDirectory: true
|
|
37
45
|
)
|
|
@@ -44,11 +52,7 @@ final class BundleStore {
|
|
|
44
52
|
|
|
45
53
|
/// Content-addressed file cache for the deltas strategy (`otakit_files/<sha256>`).
|
|
46
54
|
private(set) lazy var filesCacheDirectory: URL = {
|
|
47
|
-
let
|
|
48
|
-
for: .applicationSupportDirectory,
|
|
49
|
-
in: .userDomainMask
|
|
50
|
-
).first!
|
|
51
|
-
let directory = appSupport.appendingPathComponent(
|
|
55
|
+
let directory = rootDirectory.appendingPathComponent(
|
|
52
56
|
"otakit_files",
|
|
53
57
|
isDirectory: true
|
|
54
58
|
)
|
|
@@ -151,79 +155,106 @@ final class BundleStore {
|
|
|
151
155
|
return bundles
|
|
152
156
|
}
|
|
153
157
|
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
158
|
+
private struct CoreState: Codable {
|
|
159
|
+
var version = 1
|
|
160
|
+
var currentId: String?
|
|
161
|
+
var fallbackId: String?
|
|
162
|
+
var stagedId: String?
|
|
163
|
+
var lastFailed: BundleInfo?
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
private var stateURL: URL { rootDirectory.appendingPathComponent("otakit-state.json") }
|
|
167
|
+
private let persistenceLock = NSRecursiveLock()
|
|
168
|
+
|
|
169
|
+
private func readCoreState() throws -> CoreState {
|
|
170
|
+
persistenceLock.lock()
|
|
171
|
+
defer { persistenceLock.unlock() }
|
|
172
|
+
if fileManager.fileExists(atPath: stateURL.path) {
|
|
173
|
+
let state = try decoder.decode(CoreState.self, from: Data(contentsOf: stateURL))
|
|
174
|
+
guard state.version == 1 else {
|
|
175
|
+
throw CocoaError(.coderReadCorrupt)
|
|
176
|
+
}
|
|
177
|
+
return state
|
|
160
178
|
}
|
|
161
|
-
|
|
179
|
+
// Existing installations migrate on their first successful state write.
|
|
180
|
+
let failed = defaults.data(forKey: Keys.lastFailedBundleInfo)
|
|
181
|
+
.flatMap { try? decoder.decode(BundleInfo.self, from: $0) }
|
|
182
|
+
return CoreState(
|
|
183
|
+
currentId: defaults.string(forKey: Keys.currentBundleId),
|
|
184
|
+
fallbackId: defaults.string(forKey: Keys.fallbackBundleId),
|
|
185
|
+
stagedId: defaults.string(forKey: Keys.stagedBundleId),
|
|
186
|
+
lastFailed: failed
|
|
187
|
+
)
|
|
162
188
|
}
|
|
163
189
|
|
|
164
|
-
func
|
|
165
|
-
|
|
190
|
+
private func writeCoreState(_ state: CoreState) throws {
|
|
191
|
+
try fileManager.createDirectory(at: rootDirectory, withIntermediateDirectories: true)
|
|
192
|
+
try encoder.encode(state).write(to: stateURL, options: .atomic)
|
|
166
193
|
}
|
|
167
194
|
|
|
168
|
-
func
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
195
|
+
private func mutateCoreState(_ mutation: (inout CoreState) -> Void) throws {
|
|
196
|
+
persistenceLock.lock()
|
|
197
|
+
defer { persistenceLock.unlock() }
|
|
198
|
+
var state = try readCoreState()
|
|
199
|
+
mutation(&state)
|
|
200
|
+
try writeCoreState(state)
|
|
174
201
|
}
|
|
175
202
|
|
|
176
|
-
func
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
203
|
+
func setCoreState(
|
|
204
|
+
currentId: String?, fallbackId: String?, stagedId: String?, lastFailed: BundleInfo?
|
|
205
|
+
) throws {
|
|
206
|
+
persistenceLock.lock()
|
|
207
|
+
defer { persistenceLock.unlock() }
|
|
208
|
+
// Validate an existing state before replacing it; corruption must fail closed.
|
|
209
|
+
_ = try readCoreState()
|
|
210
|
+
try writeCoreState(CoreState(
|
|
211
|
+
currentId: currentId, fallbackId: fallbackId, stagedId: stagedId, lastFailed: lastFailed
|
|
212
|
+
))
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
func protectedBundleIds() throws -> Set<String> {
|
|
216
|
+
let state = try readCoreState()
|
|
217
|
+
return Set([state.currentId, state.fallbackId, state.stagedId].compactMap { $0 })
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
func getCurrentBundle() -> BundleInfo {
|
|
221
|
+
guard let id = getCurrentBundleId(), let bundle = getBundle(id: id) else {
|
|
181
222
|
return builtinBundle()
|
|
182
223
|
}
|
|
183
224
|
return bundle
|
|
184
225
|
}
|
|
185
226
|
|
|
186
|
-
func
|
|
187
|
-
|
|
227
|
+
func getCurrentBundleId() -> String? { (try? readCoreState())?.currentId }
|
|
228
|
+
|
|
229
|
+
func setCurrentBundleId(_ id: String?) throws {
|
|
230
|
+
try mutateCoreState { $0.currentId = id }
|
|
188
231
|
}
|
|
189
232
|
|
|
190
|
-
func
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
} else {
|
|
194
|
-
defaults.removeObject(forKey: Keys.fallbackBundleId)
|
|
233
|
+
func getFallbackBundle() -> BundleInfo {
|
|
234
|
+
guard let id = getFallbackBundleId(), let bundle = getBundle(id: id) else {
|
|
235
|
+
return builtinBundle()
|
|
195
236
|
}
|
|
237
|
+
return bundle
|
|
196
238
|
}
|
|
197
239
|
|
|
198
|
-
func
|
|
199
|
-
defaults.string(forKey: Keys.stagedBundleId)
|
|
200
|
-
}
|
|
240
|
+
func getFallbackBundleId() -> String? { (try? readCoreState())?.fallbackId }
|
|
201
241
|
|
|
202
|
-
func
|
|
203
|
-
|
|
204
|
-
defaults.set(id, forKey: Keys.stagedBundleId)
|
|
205
|
-
} else {
|
|
206
|
-
defaults.removeObject(forKey: Keys.stagedBundleId)
|
|
207
|
-
}
|
|
242
|
+
func setFallbackBundleId(_ id: String?) throws {
|
|
243
|
+
try mutateCoreState { $0.fallbackId = id }
|
|
208
244
|
}
|
|
209
245
|
|
|
210
|
-
func
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
}
|
|
215
|
-
} else {
|
|
216
|
-
defaults.removeObject(forKey: Keys.lastFailedBundleInfo)
|
|
217
|
-
}
|
|
246
|
+
func getStagedBundleId() -> String? { (try? readCoreState())?.stagedId }
|
|
247
|
+
|
|
248
|
+
func setStagedBundleId(_ id: String?) throws {
|
|
249
|
+
try mutateCoreState { $0.stagedId = id }
|
|
218
250
|
}
|
|
219
251
|
|
|
220
|
-
func
|
|
221
|
-
|
|
222
|
-
return nil
|
|
223
|
-
}
|
|
224
|
-
return try? decoder.decode(BundleInfo.self, from: data)
|
|
252
|
+
func setLastFailedBundle(_ bundle: BundleInfo?) throws {
|
|
253
|
+
try mutateCoreState { $0.lastFailed = bundle }
|
|
225
254
|
}
|
|
226
255
|
|
|
256
|
+
func getLastFailedBundle() -> BundleInfo? { (try? readCoreState())?.lastFailed }
|
|
257
|
+
|
|
227
258
|
func getOverrideChannel() -> String? {
|
|
228
259
|
defaults.string(forKey: Keys.overrideChannel)
|
|
229
260
|
}
|
|
@@ -249,7 +280,7 @@ final class BundleStore {
|
|
|
249
280
|
}
|
|
250
281
|
}
|
|
251
282
|
|
|
252
|
-
func markStatus(bundleId: String, status: BundleStatus) {
|
|
283
|
+
func markStatus(bundleId: String, status: BundleStatus) throws {
|
|
253
284
|
guard var bundle = getBundle(id: bundleId) else {
|
|
254
285
|
return
|
|
255
286
|
}
|
|
@@ -264,7 +295,7 @@ final class BundleStore {
|
|
|
264
295
|
channel: bundle.channel,
|
|
265
296
|
releaseId: bundle.releaseId
|
|
266
297
|
)
|
|
267
|
-
try
|
|
298
|
+
try saveBundle(bundle)
|
|
268
299
|
}
|
|
269
300
|
|
|
270
301
|
func deleteBundle(id: String) throws {
|
|
@@ -272,17 +303,12 @@ final class BundleStore {
|
|
|
272
303
|
return
|
|
273
304
|
}
|
|
274
305
|
|
|
306
|
+
try setCoreState(
|
|
307
|
+
currentId: getCurrentBundleId() == id ? nil : getCurrentBundleId(),
|
|
308
|
+
fallbackId: getFallbackBundleId() == id ? nil : getFallbackBundleId(),
|
|
309
|
+
stagedId: getStagedBundleId() == id ? nil : getStagedBundleId(),
|
|
310
|
+
lastFailed: getLastFailedBundle()
|
|
311
|
+
)
|
|
275
312
|
try fileManager.removeItem(at: bundleDirectory(for: id))
|
|
276
|
-
|
|
277
|
-
if getStagedBundleId() == id {
|
|
278
|
-
setStagedBundleId(nil)
|
|
279
|
-
}
|
|
280
|
-
if defaults.string(forKey: Keys.currentBundleId) == id {
|
|
281
|
-
setCurrentBundleId(nil)
|
|
282
|
-
}
|
|
283
|
-
if defaults.string(forKey: Keys.fallbackBundleId) == id {
|
|
284
|
-
setFallbackBundleId(nil)
|
|
285
|
-
}
|
|
286
313
|
}
|
|
287
|
-
|
|
288
314
|
}
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
import Foundation
|
|
2
|
+
|
|
3
|
+
/// Bounded diagnostics: never include manifest bodies, URLs, signatures, or key IDs.
|
|
4
|
+
struct CheckFailure {
|
|
5
|
+
let phase: String
|
|
6
|
+
let detail: String
|
|
7
|
+
|
|
8
|
+
static func from(_ error: Error) -> CheckFailure? {
|
|
9
|
+
if error is CancellationError { return nil }
|
|
10
|
+
if let verifier = error as? ManifestVerifierError {
|
|
11
|
+
let detail: String
|
|
12
|
+
switch verifier {
|
|
13
|
+
case .expired: detail = "signature_expired"
|
|
14
|
+
case .unknownKid: detail = "signature_unknown_key"
|
|
15
|
+
case .invalidSignature: detail = "signature_invalid"
|
|
16
|
+
case .missingSignature: detail = "signature_missing"
|
|
17
|
+
}
|
|
18
|
+
return CheckFailure(phase: "signature", detail: detail)
|
|
19
|
+
}
|
|
20
|
+
if let manifest = error as? ManifestClientError {
|
|
21
|
+
if case let .httpStatus(status) = manifest {
|
|
22
|
+
return CheckFailure(phase: "check", detail: "manifest_http_\(status)")
|
|
23
|
+
}
|
|
24
|
+
return CheckFailure(phase: "check", detail: "manifest_invalid")
|
|
25
|
+
}
|
|
26
|
+
let failure = error as NSError
|
|
27
|
+
if failure.domain == NSURLErrorDomain {
|
|
28
|
+
if failure.code == NSURLErrorCancelled { return nil }
|
|
29
|
+
return CheckFailure(phase: "check", detail: "manifest_network_\(failure.code)")
|
|
30
|
+
}
|
|
31
|
+
return CheckFailure(phase: "check", detail: "manifest_check_failed")
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
static func observe<T>(_ operation: () async throws -> T, report: (CheckFailure) throws -> Void) async throws -> T {
|
|
35
|
+
do { return try await operation() }
|
|
36
|
+
catch {
|
|
37
|
+
if let failure = from(error) { try report(failure) }
|
|
38
|
+
throw error
|
|
39
|
+
}
|
|
40
|
+
}
|
|
41
|
+
}
|
|
@@ -47,14 +47,14 @@ final class DeltaAssembler {
|
|
|
47
47
|
private let maxTotalSize: UInt64 = 500_000_000 // 500 MB
|
|
48
48
|
|
|
49
49
|
private let cacheDirectory: URL
|
|
50
|
-
private let
|
|
50
|
+
private let download: (URL) async throws -> URL
|
|
51
51
|
private let fileManager = FileManager.default
|
|
52
52
|
|
|
53
53
|
private static let builtinSeedMarkerName = "builtin_seed.json"
|
|
54
54
|
|
|
55
|
-
init(cacheDirectory: URL, downloader: Downloader) {
|
|
55
|
+
init(cacheDirectory: URL, downloader: Downloader, fetch: ((URL) async throws -> URL)? = nil) {
|
|
56
56
|
self.cacheDirectory = cacheDirectory
|
|
57
|
-
self.
|
|
57
|
+
self.download = fetch ?? { try await downloader.download(from: $0) }
|
|
58
58
|
}
|
|
59
59
|
|
|
60
60
|
// MARK: - Canonical file list
|
|
@@ -153,36 +153,43 @@ final class DeltaAssembler {
|
|
|
153
153
|
cacheDirectory.appendingPathComponent(sha256.lowercased(), isDirectory: false)
|
|
154
154
|
}
|
|
155
155
|
|
|
156
|
-
private func
|
|
157
|
-
|
|
156
|
+
private func validCached(_ entry: ManifestFileEntry) -> Bool {
|
|
157
|
+
let file = cachePath(for: entry.sha256)
|
|
158
|
+
guard let attributes = try? fileManager.attributesOfItem(atPath: file.path),
|
|
159
|
+
attributes[.type] as? FileAttributeType == .typeRegular,
|
|
160
|
+
let size = attributes[.size] as? NSNumber,
|
|
161
|
+
entry.size == nil || size.intValue == entry.size else { return false }
|
|
162
|
+
return (try? HashUtils.verify(fileURL: file, expectedSha256: entry.sha256)) == true
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
private func discardDamagedCache(_ file: URL) throws {
|
|
166
|
+
if fileManager.fileExists(atPath: file.path) { try fileManager.removeItem(at: file) }
|
|
158
167
|
}
|
|
159
168
|
|
|
160
169
|
private func ensureCached(_ entry: ManifestFileEntry) async throws {
|
|
161
|
-
if
|
|
162
|
-
|
|
163
|
-
|
|
170
|
+
if validCached(entry) { return }
|
|
171
|
+
let destination = cachePath(for: entry.sha256)
|
|
172
|
+
try discardDamagedCache(destination)
|
|
164
173
|
|
|
165
174
|
guard let url = URL(string: entry.url) else {
|
|
166
175
|
throw DeltaAssemblerError.invalidFileURL(entry.url)
|
|
167
176
|
}
|
|
168
177
|
|
|
169
|
-
let temporary = try await
|
|
178
|
+
let temporary = try await download(url)
|
|
170
179
|
defer { try? fileManager.removeItem(at: temporary) }
|
|
171
180
|
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
}
|
|
181
|
+
try HashUtils.verifyDownload(fileURL: temporary, expectedSha256: entry.sha256,
|
|
182
|
+
expectedBytes: entry.size, kind: "file")
|
|
175
183
|
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
return
|
|
179
|
-
}
|
|
184
|
+
if validCached(entry) { return }
|
|
185
|
+
try discardDamagedCache(destination)
|
|
180
186
|
// Write via temp + rename so a crash mid-copy can never leave a
|
|
181
|
-
// truncated file at a content-addressed path
|
|
187
|
+
// truncated file at a content-addressed path. Existing entries are still verified.
|
|
182
188
|
let staging = cacheDirectory.appendingPathComponent(
|
|
183
189
|
".tmp-\(UUID().uuidString)",
|
|
184
190
|
isDirectory: false
|
|
185
191
|
)
|
|
192
|
+
defer { try? fileManager.removeItem(at: staging) }
|
|
186
193
|
try fileManager.copyItem(at: temporary, to: staging)
|
|
187
194
|
do {
|
|
188
195
|
try fileManager.moveItem(at: staging, to: destination)
|
|
@@ -193,6 +200,8 @@ final class DeltaAssembler {
|
|
|
193
200
|
throw error
|
|
194
201
|
}
|
|
195
202
|
}
|
|
203
|
+
try HashUtils.verifyDownload(fileURL: destination, expectedSha256: entry.sha256,
|
|
204
|
+
expectedBytes: entry.size, kind: "cached file")
|
|
196
205
|
}
|
|
197
206
|
|
|
198
207
|
// MARK: - Assembly
|
|
@@ -228,6 +237,9 @@ final class DeltaAssembler {
|
|
|
228
237
|
let parent = target.deletingLastPathComponent()
|
|
229
238
|
try fileManager.createDirectory(at: parent, withIntermediateDirectories: true)
|
|
230
239
|
try fileManager.copyItem(at: cachePath(for: entry.sha256), to: target)
|
|
240
|
+
// Recheck the installed copy if the cache changed during assembly.
|
|
241
|
+
try HashUtils.verifyDownload(fileURL: target, expectedSha256: entry.sha256,
|
|
242
|
+
expectedBytes: entry.size, kind: "assembled file")
|
|
231
243
|
}
|
|
232
244
|
}
|
|
233
245
|
|
|
@@ -4,10 +4,13 @@ enum DeviceEventAction: String {
|
|
|
4
4
|
case downloaded
|
|
5
5
|
case applied
|
|
6
6
|
case downloadError = "download_error"
|
|
7
|
+
case checkError = "check_error"
|
|
7
8
|
case rollback
|
|
8
9
|
}
|
|
9
10
|
|
|
10
11
|
enum DeviceEventClient {
|
|
12
|
+
private static let delivery = EventDelivery()
|
|
13
|
+
static func resume() { delivery.resume() }
|
|
11
14
|
static func send(
|
|
12
15
|
ingestUrl: String,
|
|
13
16
|
appId: String,
|
|
@@ -18,7 +21,10 @@ enum DeviceEventClient {
|
|
|
18
21
|
runtimeVersion: String?,
|
|
19
22
|
releaseId: String,
|
|
20
23
|
nativeBuild: String,
|
|
21
|
-
detail: String
|
|
24
|
+
detail: String?,
|
|
25
|
+
attemptId: String? = nil,
|
|
26
|
+
phase: String? = nil,
|
|
27
|
+
lifecycle: String = "unknown"
|
|
22
28
|
) {
|
|
23
29
|
let sanitizedBase = ingestUrl.replacingOccurrences(
|
|
24
30
|
of: "/+$",
|
|
@@ -38,7 +44,11 @@ enum DeviceEventClient {
|
|
|
38
44
|
"bundleVersion": bundleVersion,
|
|
39
45
|
"releaseId": releaseId,
|
|
40
46
|
"nativeBuild": nativeBuild,
|
|
47
|
+
"nativeSdkVersion": SDKVersion.value,
|
|
48
|
+
"lifecycle": lifecycle,
|
|
41
49
|
]
|
|
50
|
+
if let attemptId { payload["attemptId"] = attemptId }
|
|
51
|
+
if let phase { payload["phase"] = phase }
|
|
42
52
|
if let channel, !channel.isEmpty {
|
|
43
53
|
payload["channel"] = channel
|
|
44
54
|
}
|
|
@@ -53,14 +63,6 @@ enum DeviceEventClient {
|
|
|
53
63
|
return
|
|
54
64
|
}
|
|
55
65
|
|
|
56
|
-
|
|
57
|
-
request.httpMethod = "POST"
|
|
58
|
-
request.setValue(appId, forHTTPHeaderField: "X-App-Id")
|
|
59
|
-
request.setValue("application/json", forHTTPHeaderField: "Content-Type")
|
|
60
|
-
request.httpBody = body
|
|
61
|
-
request.timeoutInterval = 10
|
|
62
|
-
|
|
63
|
-
// Device events are best-effort and should never block the update flow.
|
|
64
|
-
URLSession.shared.dataTask(with: request).resume()
|
|
66
|
+
delivery.enqueue(url: url, appId: appId, body: body)
|
|
65
67
|
}
|
|
66
68
|
}
|
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
import Foundation
|
|
2
|
+
import WebKit
|
|
3
|
+
|
|
4
|
+
/// Captures the activation in the document's bridge before application JavaScript runs.
|
|
5
|
+
final class DocumentReadyBridge {
|
|
6
|
+
private var script: WKUserScript?
|
|
7
|
+
|
|
8
|
+
static func source(activationId: String) throws -> String {
|
|
9
|
+
let quotedId = String(data: try JSONEncoder().encode(activationId), encoding: .utf8)!
|
|
10
|
+
return """
|
|
11
|
+
(() => {
|
|
12
|
+
if (window !== window.top) return;
|
|
13
|
+
const cap = window.Capacitor;
|
|
14
|
+
if (!cap || typeof cap.toNative !== 'function') return;
|
|
15
|
+
const original = cap.toNative;
|
|
16
|
+
const activationId = \(quotedId);
|
|
17
|
+
cap.toNative = function(plugin, method, options, callback) {
|
|
18
|
+
if (plugin === 'OtaKit' && method === 'notifyAppReady') {
|
|
19
|
+
options = Object.assign({}, options, { _otakitActivationId: activationId });
|
|
20
|
+
}
|
|
21
|
+
return original.call(this, plugin, method, options, callback);
|
|
22
|
+
};
|
|
23
|
+
})();
|
|
24
|
+
"""
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
func install(on webView: WKWebView, activationId: String) throws {
|
|
28
|
+
precondition(Thread.isMainThread)
|
|
29
|
+
let controller = webView.configuration.userContentController
|
|
30
|
+
let replacement = WKUserScript(
|
|
31
|
+
source: try Self.source(activationId: activationId),
|
|
32
|
+
injectionTime: .atDocumentStart, forMainFrameOnly: true
|
|
33
|
+
)
|
|
34
|
+
// WebKit has no individual removal API. Preserve every other plugin's scripts and order.
|
|
35
|
+
if let script {
|
|
36
|
+
let retained = controller.userScripts.filter { $0 !== script }
|
|
37
|
+
controller.removeAllUserScripts()
|
|
38
|
+
for retainedScript in retained { controller.addUserScript(retainedScript) }
|
|
39
|
+
}
|
|
40
|
+
controller.addUserScript(replacement)
|
|
41
|
+
script = replacement
|
|
42
|
+
}
|
|
43
|
+
}
|