@hot-updater/react-native 0.24.1 → 0.24.3

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,7 +11,7 @@ Pod::Spec.new do |s|
11
11
  s.license = package["license"]
12
12
  s.authors = package["author"]
13
13
 
14
- s.platforms = { :ios => "13.4" }
14
+ s.platforms = { :ios => "13.4", :tvos => "13.4" }
15
15
  s.source = { :git => "https://github.com/gronxb/hot-updater.git", :tag => "#{s.version}" }
16
16
  s.source_files = "ios/**/*.{h,m,mm,swift}"
17
17
  s.public_header_files = "ios/HotUpdater/Public/*.h"
@@ -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
- setBundleURL(null)
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 = json.optString("stableBundleId", null)?.takeIf { it.isNotEmpty() },
29
- stagingBundleId = json.optString("stagingBundleId", null)?.takeIf { it.isNotEmpty() },
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
- val totalEntries =
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.entries().asSequence().count()
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 count entries: ${e.message}")
95
- 0
100
+ Log.d(TAG, "Failed to calculate total bytes: ${e.message}")
101
+ 0L
96
102
  }
97
103
 
98
- if (totalEntries == 0) {
99
- Log.d(TAG, "No entries found in ZIP")
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 $totalEntries entries from ZIP")
109
+ Log.d(TAG, "Extracting $totalBytes bytes from ZIP")
104
110
 
105
111
  var extractedFileCount = 0
106
- var processedEntries = 0
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
- if (!file.canonicalPath.startsWith(destinationDir.canonicalPath)) {
116
- Log.w(TAG, "Skipping potentially malicious zip entry: ${entry.name}")
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
- CoroutineScope(Dispatchers.Main.immediate).launch {
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
- (mReactApplicationContext.currentActivity as FragmentActivity?)?.lifecycleScope?.launch {
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
- val progressParams =
82
- WritableNativeMap().apply {
83
- putDouble("progress", progress)
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?): WritableNativeMap {
133
+ override fun notifyAppReady(params: ReadableMap): WritableNativeMap {
122
134
  val result = WritableNativeMap()
123
- val bundleId = params?.getString("bundleId")
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
- CoroutineScope(Dispatchers.Main.immediate).launch {
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
- (mReactApplicationContext.currentActivity as FragmentActivity?)?.lifecycleScope?.launch {
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
- val progressParams =
86
- WritableNativeMap().apply {
87
- putDouble("progress", progress)
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?.getString("bundleId")
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
- let configuration = URLSessionConfiguration.default
37
- session = URLSession(configuration: configuration, delegate: self, delegateQueue: nil)
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
- let task = session.downloadTask(with: url)
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
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@hot-updater/react-native",
3
- "version": "0.24.1",
3
+ "version": "0.24.3",
4
4
  "description": "React Native OTA solution for self-hosted",
5
5
  "main": "lib/commonjs/index",
6
6
  "module": "lib/module/index",
@@ -120,14 +120,14 @@
120
120
  "react-native": "0.79.1",
121
121
  "react-native-builder-bob": "^0.40.10",
122
122
  "typescript": "^5.8.3",
123
- "hot-updater": "0.24.1"
123
+ "hot-updater": "0.24.3"
124
124
  },
125
125
  "dependencies": {
126
126
  "use-sync-external-store": "1.5.0",
127
- "@hot-updater/cli-tools": "0.24.1",
128
- "@hot-updater/core": "0.24.1",
129
- "@hot-updater/plugin-core": "0.24.1",
130
- "@hot-updater/js": "0.24.1"
127
+ "@hot-updater/js": "0.24.3",
128
+ "@hot-updater/plugin-core": "0.24.3",
129
+ "@hot-updater/cli-tools": "0.24.3",
130
+ "@hot-updater/core": "0.24.3"
131
131
  },
132
132
  "scripts": {
133
133
  "build": "bob build && tsc -p plugin/tsconfig.build.json",