@hot-updater/react-native 0.24.0 → 0.24.2
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/android/src/main/java/com/hotupdater/BundleFileStorageService.kt +24 -2
- package/android/src/main/java/com/hotupdater/BundleMetadata.kt +12 -2
- package/android/src/main/java/com/hotupdater/ZipDecompressionStrategy.kt +39 -17
- package/android/src/newarch/HotUpdaterModule.kt +33 -21
- package/android/src/oldarch/HotUpdaterModule.kt +31 -19
- package/ios/HotUpdater/Internal/URLSessionDownloadService.swift +93 -5
- package/lib/commonjs/DefaultResolver.js +38 -0
- package/lib/commonjs/DefaultResolver.js.map +1 -0
- package/lib/commonjs/checkForUpdate.js +35 -50
- package/lib/commonjs/checkForUpdate.js.map +1 -1
- package/lib/commonjs/index.js +26 -14
- package/lib/commonjs/index.js.map +1 -1
- package/lib/commonjs/types.js +12 -0
- package/lib/commonjs/types.js.map +1 -1
- package/lib/commonjs/wrap.js +44 -13
- package/lib/commonjs/wrap.js.map +1 -1
- package/lib/module/DefaultResolver.js +34 -0
- package/lib/module/DefaultResolver.js.map +1 -0
- package/lib/module/checkForUpdate.js +35 -50
- package/lib/module/checkForUpdate.js.map +1 -1
- package/lib/module/index.js +26 -14
- package/lib/module/index.js.map +1 -1
- package/lib/module/types.js +12 -0
- package/lib/module/types.js.map +1 -1
- package/lib/module/wrap.js +44 -13
- package/lib/module/wrap.js.map +1 -1
- package/lib/typescript/commonjs/DefaultResolver.d.ts +10 -0
- package/lib/typescript/commonjs/DefaultResolver.d.ts.map +1 -0
- package/lib/typescript/commonjs/checkForUpdate.d.ts +2 -1
- package/lib/typescript/commonjs/checkForUpdate.d.ts.map +1 -1
- package/lib/typescript/commonjs/index.d.ts +3 -3
- package/lib/typescript/commonjs/index.d.ts.map +1 -1
- package/lib/typescript/commonjs/types.d.ts +115 -0
- package/lib/typescript/commonjs/types.d.ts.map +1 -1
- package/lib/typescript/commonjs/wrap.d.ts +64 -10
- package/lib/typescript/commonjs/wrap.d.ts.map +1 -1
- package/lib/typescript/module/DefaultResolver.d.ts +10 -0
- package/lib/typescript/module/DefaultResolver.d.ts.map +1 -0
- package/lib/typescript/module/checkForUpdate.d.ts +2 -1
- package/lib/typescript/module/checkForUpdate.d.ts.map +1 -1
- package/lib/typescript/module/index.d.ts +3 -3
- package/lib/typescript/module/index.d.ts.map +1 -1
- package/lib/typescript/module/types.d.ts +115 -0
- package/lib/typescript/module/types.d.ts.map +1 -1
- package/lib/typescript/module/wrap.d.ts +64 -10
- package/lib/typescript/module/wrap.d.ts.map +1 -1
- package/package.json +7 -7
- package/src/DefaultResolver.ts +36 -0
- package/src/checkForUpdate.ts +43 -59
- package/src/index.ts +51 -19
- package/src/types.ts +135 -0
- package/src/wrap.tsx +161 -72
|
@@ -382,9 +382,31 @@ class BundleFileStorageService(
|
|
|
382
382
|
"updateBundle bundleId $bundleId fileUrl $fileUrl fileHash $fileHash",
|
|
383
383
|
)
|
|
384
384
|
|
|
385
|
-
// If no URL is provided, reset to fallback
|
|
385
|
+
// If no URL is provided, reset to fallback and clean up all bundles
|
|
386
386
|
if (fileUrl.isNullOrEmpty()) {
|
|
387
|
-
|
|
387
|
+
Log.d(TAG, "fileUrl is null or empty, resetting to fallback bundle")
|
|
388
|
+
|
|
389
|
+
withContext(Dispatchers.IO) {
|
|
390
|
+
// 1. Set bundle URL to null (reset preference)
|
|
391
|
+
val setResult = setBundleURL(null)
|
|
392
|
+
if (!setResult) {
|
|
393
|
+
Log.w(TAG, "Failed to reset bundle URL")
|
|
394
|
+
}
|
|
395
|
+
|
|
396
|
+
// 2. Reset metadata to initial state (clear all bundle references)
|
|
397
|
+
val metadata = createInitialMetadata()
|
|
398
|
+
val saveResult = saveMetadata(metadata)
|
|
399
|
+
if (!saveResult) {
|
|
400
|
+
Log.w(TAG, "Failed to reset metadata")
|
|
401
|
+
}
|
|
402
|
+
|
|
403
|
+
// 3. Clean up all downloaded bundles
|
|
404
|
+
// Pass null for currentBundleId to remove all bundles except the new bundleId
|
|
405
|
+
val bundleStoreDir = getBundleStoreDir()
|
|
406
|
+
cleanupOldBundles(bundleStoreDir, null, bundleId)
|
|
407
|
+
|
|
408
|
+
Log.d(TAG, "Successfully reset to fallback bundle and cleaned up downloads")
|
|
409
|
+
}
|
|
388
410
|
return
|
|
389
411
|
}
|
|
390
412
|
|
|
@@ -25,8 +25,18 @@ data class BundleMetadata(
|
|
|
25
25
|
fun fromJson(json: JSONObject): BundleMetadata =
|
|
26
26
|
BundleMetadata(
|
|
27
27
|
schema = json.optString("schema", SCHEMA_VERSION),
|
|
28
|
-
stableBundleId =
|
|
29
|
-
|
|
28
|
+
stableBundleId =
|
|
29
|
+
if (json.has("stableBundleId") && !json.isNull("stableBundleId")) {
|
|
30
|
+
json.getString("stableBundleId").takeIf { it.isNotEmpty() }
|
|
31
|
+
} else {
|
|
32
|
+
null
|
|
33
|
+
},
|
|
34
|
+
stagingBundleId =
|
|
35
|
+
if (json.has("stagingBundleId") && !json.isNull("stagingBundleId")) {
|
|
36
|
+
json.getString("stagingBundleId").takeIf { it.isNotEmpty() }
|
|
37
|
+
} else {
|
|
38
|
+
null
|
|
39
|
+
},
|
|
30
40
|
verificationPending = json.optBoolean("verificationPending", false),
|
|
31
41
|
verificationAttemptedAt =
|
|
32
42
|
if (json.has("verificationAttemptedAt") && !json.isNull("verificationAttemptedAt")) {
|
|
@@ -5,6 +5,7 @@ import java.io.BufferedInputStream
|
|
|
5
5
|
import java.io.File
|
|
6
6
|
import java.io.FileInputStream
|
|
7
7
|
import java.io.FileOutputStream
|
|
8
|
+
import java.io.IOException
|
|
8
9
|
import java.util.zip.CRC32
|
|
9
10
|
import java.util.zip.ZipEntry
|
|
10
11
|
import java.util.zip.ZipException
|
|
@@ -85,25 +86,31 @@ class ZipDecompressionStrategy : DecompressionStrategy {
|
|
|
85
86
|
destinationDir.mkdirs()
|
|
86
87
|
}
|
|
87
88
|
|
|
88
|
-
|
|
89
|
+
// Calculate total bytes to extract for accurate progress reporting
|
|
90
|
+
val totalBytes =
|
|
89
91
|
try {
|
|
90
92
|
ZipFile(File(filePath)).use { zipFile ->
|
|
91
|
-
zipFile
|
|
93
|
+
zipFile
|
|
94
|
+
.entries()
|
|
95
|
+
.asSequence()
|
|
96
|
+
.filter { !it.isDirectory }
|
|
97
|
+
.sumOf { it.size }
|
|
92
98
|
}
|
|
93
99
|
} catch (e: Exception) {
|
|
94
|
-
Log.d(TAG, "Failed to
|
|
95
|
-
|
|
100
|
+
Log.d(TAG, "Failed to calculate total bytes: ${e.message}")
|
|
101
|
+
0L
|
|
96
102
|
}
|
|
97
103
|
|
|
98
|
-
if (
|
|
99
|
-
Log.d(TAG, "No
|
|
104
|
+
if (totalBytes == 0L) {
|
|
105
|
+
Log.d(TAG, "No content found in ZIP")
|
|
100
106
|
return false
|
|
101
107
|
}
|
|
102
108
|
|
|
103
|
-
Log.d(TAG, "Extracting $
|
|
109
|
+
Log.d(TAG, "Extracting $totalBytes bytes from ZIP")
|
|
104
110
|
|
|
105
111
|
var extractedFileCount = 0
|
|
106
|
-
var
|
|
112
|
+
var extractedBytes = 0L
|
|
113
|
+
var lastReportedProgress = 0.0
|
|
107
114
|
|
|
108
115
|
FileInputStream(filePath).use { fileInputStream ->
|
|
109
116
|
BufferedInputStream(fileInputStream).use { bufferedInputStream ->
|
|
@@ -112,10 +119,20 @@ class ZipDecompressionStrategy : DecompressionStrategy {
|
|
|
112
119
|
while (entry != null) {
|
|
113
120
|
val file = File(destinationPath, entry.name)
|
|
114
121
|
|
|
115
|
-
|
|
116
|
-
|
|
122
|
+
// Zip Slip vulnerability check - verify entry path is within destination
|
|
123
|
+
try {
|
|
124
|
+
val canonicalDestPath = destinationDir.canonicalPath
|
|
125
|
+
val canonicalFilePath = file.canonicalPath
|
|
126
|
+
|
|
127
|
+
if (!canonicalFilePath.startsWith(canonicalDestPath)) {
|
|
128
|
+
Log.w(TAG, "Skipping potentially malicious zip entry: ${entry.name}")
|
|
129
|
+
entry = zipInputStream.nextEntry
|
|
130
|
+
continue
|
|
131
|
+
}
|
|
132
|
+
} catch (e: IOException) {
|
|
133
|
+
// If we can't resolve canonical paths, treat as potentially malicious
|
|
134
|
+
Log.w(TAG, "Failed to resolve canonical path for zip entry: ${entry.name}", e)
|
|
117
135
|
entry = zipInputStream.nextEntry
|
|
118
|
-
processedEntries++
|
|
119
136
|
continue
|
|
120
137
|
}
|
|
121
138
|
|
|
@@ -132,6 +149,16 @@ class ZipDecompressionStrategy : DecompressionStrategy {
|
|
|
132
149
|
while (zipInputStream.read(buffer).also { bytesRead = it } != -1) {
|
|
133
150
|
output.write(buffer, 0, bytesRead)
|
|
134
151
|
crc.update(buffer, 0, bytesRead)
|
|
152
|
+
|
|
153
|
+
// Track bytes written for byte-based progress
|
|
154
|
+
extractedBytes += bytesRead
|
|
155
|
+
|
|
156
|
+
// Report progress more frequently (every 1%)
|
|
157
|
+
val currentProgress = extractedBytes.toDouble() / totalBytes
|
|
158
|
+
if (currentProgress - lastReportedProgress >= 0.01) {
|
|
159
|
+
progressCallback.invoke(currentProgress)
|
|
160
|
+
lastReportedProgress = currentProgress
|
|
161
|
+
}
|
|
135
162
|
}
|
|
136
163
|
}
|
|
137
164
|
|
|
@@ -145,11 +172,6 @@ class ZipDecompressionStrategy : DecompressionStrategy {
|
|
|
145
172
|
}
|
|
146
173
|
|
|
147
174
|
zipInputStream.closeEntry()
|
|
148
|
-
processedEntries++
|
|
149
|
-
|
|
150
|
-
val progress = processedEntries.toDouble() / totalEntries
|
|
151
|
-
progressCallback.invoke(progress)
|
|
152
|
-
|
|
153
175
|
entry = zipInputStream.nextEntry
|
|
154
176
|
}
|
|
155
177
|
}
|
|
@@ -161,7 +183,7 @@ class ZipDecompressionStrategy : DecompressionStrategy {
|
|
|
161
183
|
return false
|
|
162
184
|
}
|
|
163
185
|
|
|
164
|
-
Log.d(TAG, "Successfully extracted $extractedFileCount files")
|
|
186
|
+
Log.d(TAG, "Successfully extracted $extractedFileCount files ($extractedBytes bytes)")
|
|
165
187
|
progressCallback.invoke(1.0)
|
|
166
188
|
true
|
|
167
189
|
} catch (e: ZipException) {
|
|
@@ -1,8 +1,8 @@
|
|
|
1
1
|
package com.hotupdater
|
|
2
2
|
|
|
3
|
+
import android.os.Handler
|
|
4
|
+
import android.os.Looper
|
|
3
5
|
import android.util.Log
|
|
4
|
-
import androidx.fragment.app.FragmentActivity
|
|
5
|
-
import androidx.lifecycle.lifecycleScope
|
|
6
6
|
import com.facebook.react.bridge.Promise
|
|
7
7
|
import com.facebook.react.bridge.ReactApplicationContext
|
|
8
8
|
import com.facebook.react.bridge.ReadableMap
|
|
@@ -11,6 +11,8 @@ import com.facebook.react.bridge.WritableNativeMap
|
|
|
11
11
|
import com.facebook.react.modules.core.DeviceEventManagerModule
|
|
12
12
|
import kotlinx.coroutines.CoroutineScope
|
|
13
13
|
import kotlinx.coroutines.Dispatchers
|
|
14
|
+
import kotlinx.coroutines.SupervisorJob
|
|
15
|
+
import kotlinx.coroutines.cancel
|
|
14
16
|
import kotlinx.coroutines.launch
|
|
15
17
|
|
|
16
18
|
class HotUpdaterModule internal constructor(
|
|
@@ -18,15 +20,24 @@ class HotUpdaterModule internal constructor(
|
|
|
18
20
|
) : HotUpdaterSpec(reactContext) {
|
|
19
21
|
private val mReactApplicationContext: ReactApplicationContext = reactContext
|
|
20
22
|
|
|
23
|
+
// Managed coroutine scope for the module lifecycle
|
|
24
|
+
private val moduleScope = CoroutineScope(SupervisorJob() + Dispatchers.Main)
|
|
25
|
+
|
|
21
26
|
override fun getName(): String = NAME
|
|
22
27
|
|
|
28
|
+
override fun invalidate() {
|
|
29
|
+
super.invalidate()
|
|
30
|
+
// Cancel all ongoing coroutines when module is destroyed
|
|
31
|
+
moduleScope.cancel()
|
|
32
|
+
}
|
|
33
|
+
|
|
23
34
|
/**
|
|
24
35
|
* Gets the singleton HotUpdaterImpl instance
|
|
25
36
|
*/
|
|
26
37
|
private fun getInstance(): HotUpdaterImpl = HotUpdater.getInstance(mReactApplicationContext)
|
|
27
38
|
|
|
28
39
|
override fun reload(promise: Promise) {
|
|
29
|
-
|
|
40
|
+
moduleScope.launch {
|
|
30
41
|
try {
|
|
31
42
|
val impl = getInstance()
|
|
32
43
|
val currentActivity = mReactApplicationContext.currentActivity
|
|
@@ -40,17 +51,11 @@ class HotUpdaterModule internal constructor(
|
|
|
40
51
|
}
|
|
41
52
|
|
|
42
53
|
override fun updateBundle(
|
|
43
|
-
params: ReadableMap
|
|
54
|
+
params: ReadableMap,
|
|
44
55
|
promise: Promise,
|
|
45
56
|
) {
|
|
46
|
-
|
|
57
|
+
moduleScope.launch {
|
|
47
58
|
try {
|
|
48
|
-
// Parameter validation
|
|
49
|
-
if (params == null) {
|
|
50
|
-
promise.reject("UNKNOWN_ERROR", "Missing or invalid parameters for updateBundle")
|
|
51
|
-
return@launch
|
|
52
|
-
}
|
|
53
|
-
|
|
54
59
|
val bundleId = params.getString("bundleId")
|
|
55
60
|
if (bundleId == null || bundleId.isEmpty()) {
|
|
56
61
|
promise.reject("MISSING_BUNDLE_ID", "Missing or empty 'bundleId'")
|
|
@@ -78,15 +83,22 @@ class HotUpdaterModule internal constructor(
|
|
|
78
83
|
fileUrl,
|
|
79
84
|
fileHash,
|
|
80
85
|
) { progress ->
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
86
|
+
// Post to Main thread for React Native event emission
|
|
87
|
+
Handler(Looper.getMainLooper()).post {
|
|
88
|
+
try {
|
|
89
|
+
val progressParams =
|
|
90
|
+
WritableNativeMap().apply {
|
|
91
|
+
putDouble("progress", progress)
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
this@HotUpdaterModule
|
|
95
|
+
.mReactApplicationContext
|
|
96
|
+
.getJSModule(DeviceEventManagerModule.RCTDeviceEventEmitter::class.java)
|
|
97
|
+
?.emit("onProgress", progressParams)
|
|
98
|
+
} catch (e: Exception) {
|
|
99
|
+
Log.w("HotUpdater", "Failed to emit progress (bridge may be unavailable): ${e.message}")
|
|
84
100
|
}
|
|
85
|
-
|
|
86
|
-
this@HotUpdaterModule
|
|
87
|
-
.mReactApplicationContext
|
|
88
|
-
.getJSModule(DeviceEventManagerModule.RCTDeviceEventEmitter::class.java)
|
|
89
|
-
.emit("onProgress", progressParams)
|
|
101
|
+
}
|
|
90
102
|
}
|
|
91
103
|
promise.resolve(true)
|
|
92
104
|
} catch (e: HotUpdaterException) {
|
|
@@ -118,9 +130,9 @@ class HotUpdaterModule internal constructor(
|
|
|
118
130
|
// No-op
|
|
119
131
|
}
|
|
120
132
|
|
|
121
|
-
override fun notifyAppReady(params: ReadableMap
|
|
133
|
+
override fun notifyAppReady(params: ReadableMap): WritableNativeMap {
|
|
122
134
|
val result = WritableNativeMap()
|
|
123
|
-
val bundleId = params
|
|
135
|
+
val bundleId = params.getString("bundleId")
|
|
124
136
|
if (bundleId == null) {
|
|
125
137
|
result.putString("status", "STABLE")
|
|
126
138
|
return result
|
|
@@ -1,8 +1,8 @@
|
|
|
1
1
|
package com.hotupdater
|
|
2
2
|
|
|
3
|
+
import android.os.Handler
|
|
4
|
+
import android.os.Looper
|
|
3
5
|
import android.util.Log
|
|
4
|
-
import androidx.fragment.app.FragmentActivity
|
|
5
|
-
import androidx.lifecycle.lifecycleScope
|
|
6
6
|
import com.facebook.react.bridge.Promise
|
|
7
7
|
import com.facebook.react.bridge.ReactApplicationContext
|
|
8
8
|
import com.facebook.react.bridge.ReactMethod
|
|
@@ -11,6 +11,8 @@ import com.facebook.react.bridge.WritableNativeMap
|
|
|
11
11
|
import com.facebook.react.modules.core.DeviceEventManagerModule
|
|
12
12
|
import kotlinx.coroutines.CoroutineScope
|
|
13
13
|
import kotlinx.coroutines.Dispatchers
|
|
14
|
+
import kotlinx.coroutines.SupervisorJob
|
|
15
|
+
import kotlinx.coroutines.cancel
|
|
14
16
|
import kotlinx.coroutines.launch
|
|
15
17
|
import org.json.JSONArray
|
|
16
18
|
import org.json.JSONObject
|
|
@@ -20,8 +22,17 @@ class HotUpdaterModule internal constructor(
|
|
|
20
22
|
) : HotUpdaterSpec(context) {
|
|
21
23
|
private val mReactApplicationContext: ReactApplicationContext = context
|
|
22
24
|
|
|
25
|
+
// Managed coroutine scope for the module lifecycle
|
|
26
|
+
private val moduleScope = CoroutineScope(SupervisorJob() + Dispatchers.Main)
|
|
27
|
+
|
|
23
28
|
override fun getName(): String = NAME
|
|
24
29
|
|
|
30
|
+
override fun onCatalystInstanceDestroy() {
|
|
31
|
+
super.onCatalystInstanceDestroy()
|
|
32
|
+
// Cancel all ongoing coroutines when module is destroyed
|
|
33
|
+
moduleScope.cancel()
|
|
34
|
+
}
|
|
35
|
+
|
|
25
36
|
/**
|
|
26
37
|
* Gets the singleton HotUpdaterImpl instance
|
|
27
38
|
*/
|
|
@@ -29,7 +40,7 @@ class HotUpdaterModule internal constructor(
|
|
|
29
40
|
|
|
30
41
|
@ReactMethod
|
|
31
42
|
override fun reload(promise: Promise) {
|
|
32
|
-
|
|
43
|
+
moduleScope.launch {
|
|
33
44
|
try {
|
|
34
45
|
val impl = getInstance()
|
|
35
46
|
val currentActivity = mReactApplicationContext.currentActivity
|
|
@@ -47,14 +58,8 @@ class HotUpdaterModule internal constructor(
|
|
|
47
58
|
params: ReadableMap,
|
|
48
59
|
promise: Promise,
|
|
49
60
|
) {
|
|
50
|
-
|
|
61
|
+
moduleScope.launch {
|
|
51
62
|
try {
|
|
52
|
-
// Parameter validation
|
|
53
|
-
if (params == null) {
|
|
54
|
-
promise.reject("UNKNOWN_ERROR", "Missing or invalid parameters for updateBundle")
|
|
55
|
-
return@launch
|
|
56
|
-
}
|
|
57
|
-
|
|
58
63
|
val bundleId = params.getString("bundleId")
|
|
59
64
|
if (bundleId == null || bundleId.isEmpty()) {
|
|
60
65
|
promise.reject("MISSING_BUNDLE_ID", "Missing or empty 'bundleId'")
|
|
@@ -82,15 +87,22 @@ class HotUpdaterModule internal constructor(
|
|
|
82
87
|
fileUrl,
|
|
83
88
|
fileHash,
|
|
84
89
|
) { progress ->
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
90
|
+
// Post to Main thread for React Native event emission
|
|
91
|
+
Handler(Looper.getMainLooper()).post {
|
|
92
|
+
try {
|
|
93
|
+
val progressParams =
|
|
94
|
+
WritableNativeMap().apply {
|
|
95
|
+
putDouble("progress", progress)
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
this@HotUpdaterModule
|
|
99
|
+
.mReactApplicationContext
|
|
100
|
+
.getJSModule(DeviceEventManagerModule.RCTDeviceEventEmitter::class.java)
|
|
101
|
+
?.emit("onProgress", progressParams)
|
|
102
|
+
} catch (e: Exception) {
|
|
103
|
+
Log.w("HotUpdater", "Failed to emit progress (bridge may be unavailable): ${e.message}")
|
|
88
104
|
}
|
|
89
|
-
|
|
90
|
-
this@HotUpdaterModule
|
|
91
|
-
.mReactApplicationContext
|
|
92
|
-
.getJSModule(DeviceEventManagerModule.RCTDeviceEventEmitter::class.java)
|
|
93
|
-
.emit("onProgress", progressParams)
|
|
105
|
+
}
|
|
94
106
|
}
|
|
95
107
|
promise.resolve(true)
|
|
96
108
|
} catch (e: HotUpdaterException) {
|
|
@@ -126,7 +138,7 @@ class HotUpdaterModule internal constructor(
|
|
|
126
138
|
|
|
127
139
|
@ReactMethod(isBlockingSynchronousMethod = true)
|
|
128
140
|
override fun notifyAppReady(params: ReadableMap): String {
|
|
129
|
-
val bundleId = params
|
|
141
|
+
val bundleId = params.getString("bundleId")
|
|
130
142
|
val result = JSONObject()
|
|
131
143
|
|
|
132
144
|
if (bundleId == null) {
|
|
@@ -1,4 +1,7 @@
|
|
|
1
1
|
import Foundation
|
|
2
|
+
#if !os(macOS)
|
|
3
|
+
import UIKit
|
|
4
|
+
#endif
|
|
2
5
|
|
|
3
6
|
protocol DownloadService {
|
|
4
7
|
/**
|
|
@@ -25,16 +28,70 @@ enum DownloadError: Error {
|
|
|
25
28
|
case invalidContentLength
|
|
26
29
|
}
|
|
27
30
|
|
|
31
|
+
// Task state for persistence and recovery
|
|
32
|
+
struct TaskState: Codable {
|
|
33
|
+
let taskIdentifier: Int
|
|
34
|
+
let destination: String
|
|
35
|
+
let bundleId: String
|
|
36
|
+
let startedAt: TimeInterval
|
|
37
|
+
}
|
|
38
|
+
|
|
28
39
|
class URLSessionDownloadService: NSObject, DownloadService {
|
|
29
40
|
private var session: URLSession!
|
|
41
|
+
private var backgroundSession: URLSession!
|
|
30
42
|
private var progressHandlers: [URLSessionTask: (Double) -> Void] = [:]
|
|
31
43
|
private var completionHandlers: [URLSessionTask: (Result<URL, Error>) -> Void] = [:]
|
|
32
44
|
private var destinations: [URLSessionTask: String] = [:]
|
|
45
|
+
private var taskStates: [Int: TaskState] = [:]
|
|
33
46
|
|
|
34
47
|
override init() {
|
|
35
48
|
super.init()
|
|
36
|
-
|
|
37
|
-
session
|
|
49
|
+
|
|
50
|
+
// Foreground session (existing behavior)
|
|
51
|
+
let defaultConfig = URLSessionConfiguration.default
|
|
52
|
+
session = URLSession(configuration: defaultConfig, delegate: self, delegateQueue: nil)
|
|
53
|
+
|
|
54
|
+
// Background session for persistent downloads
|
|
55
|
+
let backgroundConfig = URLSessionConfiguration.background(
|
|
56
|
+
withIdentifier: "com.hotupdater.background.download"
|
|
57
|
+
)
|
|
58
|
+
backgroundConfig.isDiscretionary = false
|
|
59
|
+
backgroundConfig.sessionSendsLaunchEvents = true
|
|
60
|
+
backgroundSession = URLSession(configuration: backgroundConfig, delegate: self, delegateQueue: nil)
|
|
61
|
+
|
|
62
|
+
// Load persisted task states
|
|
63
|
+
taskStates = loadTaskStates()
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
// MARK: - State Persistence
|
|
67
|
+
|
|
68
|
+
private var stateFileURL: URL {
|
|
69
|
+
let documentsPath = FileManager.default.urls(for: .documentDirectory, in: .userDomainMask)[0]
|
|
70
|
+
return documentsPath.appendingPathComponent("download-state.json")
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
private func saveTaskState(_ state: TaskState) {
|
|
74
|
+
taskStates[state.taskIdentifier] = state
|
|
75
|
+
|
|
76
|
+
if let data = try? JSONEncoder().encode(taskStates) {
|
|
77
|
+
try? data.write(to: stateFileURL)
|
|
78
|
+
}
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
private func loadTaskStates() -> [Int: TaskState] {
|
|
82
|
+
guard let data = try? Data(contentsOf: stateFileURL),
|
|
83
|
+
let states = try? JSONDecoder().decode([Int: TaskState].self, from: data) else {
|
|
84
|
+
return [:]
|
|
85
|
+
}
|
|
86
|
+
return states
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
private func removeTaskState(_ taskIdentifier: Int) {
|
|
90
|
+
taskStates.removeValue(forKey: taskIdentifier)
|
|
91
|
+
|
|
92
|
+
if let data = try? JSONEncoder().encode(taskStates) {
|
|
93
|
+
try? data.write(to: stateFileURL)
|
|
94
|
+
}
|
|
38
95
|
}
|
|
39
96
|
|
|
40
97
|
func getFileSize(from url: URL, completion: @escaping (Result<Int64, Error>) -> Void) {
|
|
@@ -66,10 +123,39 @@ class URLSessionDownloadService: NSObject, DownloadService {
|
|
|
66
123
|
}
|
|
67
124
|
|
|
68
125
|
func downloadFile(from url: URL, to destination: String, progressHandler: @escaping (Double) -> Void, completion: @escaping (Result<URL, Error>) -> Void) -> URLSessionDownloadTask? {
|
|
69
|
-
|
|
126
|
+
// Determine if we should use background session
|
|
127
|
+
#if !os(macOS)
|
|
128
|
+
let appState = UIApplication.shared.applicationState
|
|
129
|
+
let useBackgroundSession = (appState == .background || appState == .inactive)
|
|
130
|
+
#else
|
|
131
|
+
let useBackgroundSession = false
|
|
132
|
+
#endif
|
|
133
|
+
|
|
134
|
+
let selectedSession = useBackgroundSession ? backgroundSession : session
|
|
135
|
+
let task = selectedSession?.downloadTask(with: url)
|
|
136
|
+
|
|
137
|
+
guard let task = task else {
|
|
138
|
+
return nil
|
|
139
|
+
}
|
|
140
|
+
|
|
70
141
|
progressHandlers[task] = progressHandler
|
|
71
142
|
completionHandlers[task] = completion
|
|
72
143
|
destinations[task] = destination
|
|
144
|
+
|
|
145
|
+
// Extract bundleId from destination path (e.g., "bundle-store/{bundleId}/bundle.zip")
|
|
146
|
+
let bundleId = (destination as NSString).pathComponents
|
|
147
|
+
.dropFirst()
|
|
148
|
+
.first(where: { $0 != "bundle-store" }) ?? "unknown"
|
|
149
|
+
|
|
150
|
+
// Save task metadata for background recovery
|
|
151
|
+
let taskState = TaskState(
|
|
152
|
+
taskIdentifier: task.taskIdentifier,
|
|
153
|
+
destination: destination,
|
|
154
|
+
bundleId: bundleId,
|
|
155
|
+
startedAt: Date().timeIntervalSince1970
|
|
156
|
+
)
|
|
157
|
+
saveTaskState(taskState)
|
|
158
|
+
|
|
73
159
|
task.resume()
|
|
74
160
|
return task
|
|
75
161
|
}
|
|
@@ -84,6 +170,7 @@ extension URLSessionDownloadService: URLSessionDownloadDelegate {
|
|
|
84
170
|
progressHandlers.removeValue(forKey: downloadTask)
|
|
85
171
|
completionHandlers.removeValue(forKey: downloadTask)
|
|
86
172
|
destinations.removeValue(forKey: downloadTask)
|
|
173
|
+
removeTaskState(downloadTask.taskIdentifier)
|
|
87
174
|
|
|
88
175
|
// 다운로드 완료 알림
|
|
89
176
|
NotificationCenter.default.post(name: .downloadDidFinish, object: downloadTask)
|
|
@@ -136,10 +223,11 @@ extension URLSessionDownloadService: URLSessionDownloadDelegate {
|
|
|
136
223
|
progressHandlers.removeValue(forKey: task)
|
|
137
224
|
completionHandlers.removeValue(forKey: task)
|
|
138
225
|
destinations.removeValue(forKey: task)
|
|
139
|
-
|
|
226
|
+
removeTaskState(task.taskIdentifier)
|
|
227
|
+
|
|
140
228
|
NotificationCenter.default.post(name: .downloadDidFinish, object: task)
|
|
141
229
|
}
|
|
142
|
-
|
|
230
|
+
|
|
143
231
|
if let error = error {
|
|
144
232
|
completion?(.failure(error))
|
|
145
233
|
}
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
|
|
3
|
+
Object.defineProperty(exports, "__esModule", {
|
|
4
|
+
value: true
|
|
5
|
+
});
|
|
6
|
+
exports.createDefaultResolver = createDefaultResolver;
|
|
7
|
+
var _fetchUpdateInfo = require("./fetchUpdateInfo.js");
|
|
8
|
+
/**
|
|
9
|
+
* Creates a default resolver that uses baseURL for network operations.
|
|
10
|
+
* This encapsulates the existing baseURL logic into a resolver.
|
|
11
|
+
*
|
|
12
|
+
* @param baseURL - The base URL for the update server
|
|
13
|
+
* @returns A HotUpdaterResolver that uses the baseURL
|
|
14
|
+
*/
|
|
15
|
+
function createDefaultResolver(baseURL) {
|
|
16
|
+
return {
|
|
17
|
+
checkUpdate: async params => {
|
|
18
|
+
// Build URL based on strategy (existing buildUpdateUrl logic)
|
|
19
|
+
let url;
|
|
20
|
+
if (params.updateStrategy === "fingerprint") {
|
|
21
|
+
if (!params.fingerprintHash) {
|
|
22
|
+
throw new Error("Fingerprint hash is required");
|
|
23
|
+
}
|
|
24
|
+
url = `${baseURL}/fingerprint/${params.platform}/${params.fingerprintHash}/${params.channel}/${params.minBundleId}/${params.bundleId}`;
|
|
25
|
+
} else {
|
|
26
|
+
url = `${baseURL}/app-version/${params.platform}/${params.appVersion}/${params.channel}/${params.minBundleId}/${params.bundleId}`;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
// Use existing fetchUpdateInfo
|
|
30
|
+
return (0, _fetchUpdateInfo.fetchUpdateInfo)({
|
|
31
|
+
url,
|
|
32
|
+
requestHeaders: params.requestHeaders,
|
|
33
|
+
requestTimeout: params.requestTimeout
|
|
34
|
+
});
|
|
35
|
+
}
|
|
36
|
+
};
|
|
37
|
+
}
|
|
38
|
+
//# sourceMappingURL=DefaultResolver.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"names":["_fetchUpdateInfo","require","createDefaultResolver","baseURL","checkUpdate","params","url","updateStrategy","fingerprintHash","Error","platform","channel","minBundleId","bundleId","appVersion","fetchUpdateInfo","requestHeaders","requestTimeout"],"sourceRoot":"../../src","sources":["DefaultResolver.ts"],"mappings":";;;;;;AACA,IAAAA,gBAAA,GAAAC,OAAA;AAGA;AACA;AACA;AACA;AACA;AACA;AACA;AACO,SAASC,qBAAqBA,CAACC,OAAe,EAAsB;EACzE,OAAO;IACLC,WAAW,EAAE,MACXC,MAAiC,IACC;MAClC;MACA,IAAIC,GAAW;MACf,IAAID,MAAM,CAACE,cAAc,KAAK,aAAa,EAAE;QAC3C,IAAI,CAACF,MAAM,CAACG,eAAe,EAAE;UAC3B,MAAM,IAAIC,KAAK,CAAC,8BAA8B,CAAC;QACjD;QACAH,GAAG,GAAG,GAAGH,OAAO,gBAAgBE,MAAM,CAACK,QAAQ,IAAIL,MAAM,CAACG,eAAe,IAAIH,MAAM,CAACM,OAAO,IAAIN,MAAM,CAACO,WAAW,IAAIP,MAAM,CAACQ,QAAQ,EAAE;MACxI,CAAC,MAAM;QACLP,GAAG,GAAG,GAAGH,OAAO,gBAAgBE,MAAM,CAACK,QAAQ,IAAIL,MAAM,CAACS,UAAU,IAAIT,MAAM,CAACM,OAAO,IAAIN,MAAM,CAACO,WAAW,IAAIP,MAAM,CAACQ,QAAQ,EAAE;MACnI;;MAEA;MACA,OAAO,IAAAE,gCAAe,EAAC;QACrBT,GAAG;QACHU,cAAc,EAAEX,MAAM,CAACW,cAAc;QACrCC,cAAc,EAAEZ,MAAM,CAACY;MACzB,CAAC,CAAC;IACJ;EACF,CAAC;AACH","ignoreList":[]}
|
|
@@ -6,26 +6,9 @@ Object.defineProperty(exports, "__esModule", {
|
|
|
6
6
|
exports.checkForUpdate = checkForUpdate;
|
|
7
7
|
var _reactNative = require("react-native");
|
|
8
8
|
var _error = require("./error.js");
|
|
9
|
-
var _fetchUpdateInfo = require("./fetchUpdateInfo.js");
|
|
10
9
|
var _native = require("./native.js");
|
|
11
|
-
// Internal type that includes
|
|
10
|
+
// Internal type that includes resolver for use within index.ts
|
|
12
11
|
|
|
13
|
-
// Internal function to build update URL (not exported)
|
|
14
|
-
function buildUpdateUrl(baseURL, updateStrategy, params) {
|
|
15
|
-
switch (updateStrategy) {
|
|
16
|
-
case "fingerprint":
|
|
17
|
-
{
|
|
18
|
-
if (!params.fingerprintHash) {
|
|
19
|
-
throw new _error.HotUpdaterError("Fingerprint hash is required");
|
|
20
|
-
}
|
|
21
|
-
return `${baseURL}/fingerprint/${params.platform}/${params.fingerprintHash}/${params.channel}/${params.minBundleId}/${params.bundleId}`;
|
|
22
|
-
}
|
|
23
|
-
case "appVersion":
|
|
24
|
-
{
|
|
25
|
-
return `${baseURL}/app-version/${params.platform}/${params.appVersion}/${params.channel}/${params.minBundleId}/${params.bundleId}`;
|
|
26
|
-
}
|
|
27
|
-
}
|
|
28
|
-
}
|
|
29
12
|
async function checkForUpdate(options) {
|
|
30
13
|
if (__DEV__) {
|
|
31
14
|
return null;
|
|
@@ -34,10 +17,6 @@ async function checkForUpdate(options) {
|
|
|
34
17
|
options.onError?.(new _error.HotUpdaterError("HotUpdater is only supported on iOS and Android"));
|
|
35
18
|
return null;
|
|
36
19
|
}
|
|
37
|
-
if (!options.baseURL || !options.updateStrategy) {
|
|
38
|
-
options.onError?.(new _error.HotUpdaterError("'baseURL' and 'updateStrategy' are required"));
|
|
39
|
-
return null;
|
|
40
|
-
}
|
|
41
20
|
const currentAppVersion = (0, _native.getAppVersion)();
|
|
42
21
|
const platform = _reactNative.Platform.OS;
|
|
43
22
|
const currentBundleId = (0, _native.getBundleId)();
|
|
@@ -48,34 +27,40 @@ async function checkForUpdate(options) {
|
|
|
48
27
|
return null;
|
|
49
28
|
}
|
|
50
29
|
const fingerprintHash = (0, _native.getFingerprintHash)();
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
30
|
+
if (!options.resolver?.checkUpdate) {
|
|
31
|
+
options.onError?.(new _error.HotUpdaterError("Resolver is required but not configured"));
|
|
32
|
+
return null;
|
|
33
|
+
}
|
|
34
|
+
let updateInfo = null;
|
|
35
|
+
try {
|
|
36
|
+
updateInfo = await options.resolver.checkUpdate({
|
|
37
|
+
platform,
|
|
38
|
+
appVersion: currentAppVersion,
|
|
39
|
+
bundleId: currentBundleId,
|
|
40
|
+
minBundleId,
|
|
41
|
+
channel,
|
|
42
|
+
updateStrategy: options.updateStrategy,
|
|
43
|
+
fingerprintHash,
|
|
44
|
+
requestHeaders: options.requestHeaders,
|
|
45
|
+
requestTimeout: options.requestTimeout
|
|
46
|
+
});
|
|
47
|
+
} catch (error) {
|
|
48
|
+
options.onError?.(error);
|
|
49
|
+
return null;
|
|
50
|
+
}
|
|
51
|
+
if (!updateInfo) {
|
|
52
|
+
return null;
|
|
53
|
+
}
|
|
54
|
+
return {
|
|
55
|
+
...updateInfo,
|
|
56
|
+
updateBundle: async () => {
|
|
57
|
+
return (0, _native.updateBundle)({
|
|
58
|
+
bundleId: updateInfo.id,
|
|
59
|
+
fileUrl: updateInfo.fileUrl,
|
|
60
|
+
fileHash: updateInfo.fileHash,
|
|
61
|
+
status: updateInfo.status
|
|
62
|
+
});
|
|
67
63
|
}
|
|
68
|
-
|
|
69
|
-
...updateInfo,
|
|
70
|
-
updateBundle: async () => {
|
|
71
|
-
return (0, _native.updateBundle)({
|
|
72
|
-
bundleId: updateInfo.id,
|
|
73
|
-
fileUrl: updateInfo.fileUrl,
|
|
74
|
-
fileHash: updateInfo.fileHash,
|
|
75
|
-
status: updateInfo.status
|
|
76
|
-
});
|
|
77
|
-
}
|
|
78
|
-
};
|
|
79
|
-
});
|
|
64
|
+
};
|
|
80
65
|
}
|
|
81
66
|
//# sourceMappingURL=checkForUpdate.js.map
|