@rovq/react-native 0.0.0-stage → 0.1.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/LICENSE +21 -0
- package/README.md +76 -2
- package/Rovq.podspec +19 -0
- package/android/build.gradle +86 -0
- package/android/src/main/AndroidManifest.xml +4 -0
- package/android/src/main/java/com/rovq/engine/EngineState.kt +176 -0
- package/android/src/main/java/com/rovq/engine/ManifestVerifier.kt +173 -0
- package/android/src/main/java/com/rovq/engine/PackageDownloader.kt +188 -0
- package/android/src/main/java/com/rovq/engine/PackageRules.kt +39 -0
- package/android/src/main/java/com/rovq/engine/ProgressThrottle.kt +17 -0
- package/android/src/main/java/com/rovq/engine/RovqEngine.kt +543 -0
- package/android/src/main/java/com/rovq/engine/RovqException.kt +31 -0
- package/android/src/main/java/com/rovq/engine/SafePackageExtractor.kt +121 -0
- package/android/src/main/java/com/rovq/engine/StrictBase64.kt +37 -0
- package/android/src/main/java/com/rovq/engine/UpdateInstaller.kt +94 -0
- package/android/src/main/java/com/rovq/engine/UpdateService.kt +139 -0
- package/android/src/main/java/com/rovq/engine/ZipStructureCheck.kt +194 -0
- package/android/src/react/java/com/rovq/engine/react/Rovq.kt +101 -0
- package/android/src/react/java/com/rovq/engine/react/RovqModule.kt +164 -0
- package/android/src/react/java/com/rovq/engine/react/RovqPackage.kt +26 -0
- package/android/src/react/java/com/rovq/engine/react/RovqReactHost.kt +74 -0
- package/ios/Engine/EngineState.swift +119 -0
- package/ios/Engine/ManifestVerifier.swift +197 -0
- package/ios/Engine/PackageDownloader.swift +231 -0
- package/ios/Engine/PackageRules.swift +38 -0
- package/ios/Engine/ProgressThrottle.swift +20 -0
- package/ios/Engine/RovqEngine.swift +633 -0
- package/ios/Engine/RovqError.swift +35 -0
- package/ios/Engine/SafePackageExtractor.swift +107 -0
- package/ios/Engine/UpdateInstaller.swift +126 -0
- package/ios/Engine/UpdateService.swift +158 -0
- package/ios/Engine/ZipReader.swift +378 -0
- package/ios/React/RovqHost.swift +253 -0
- package/ios/React/RovqModule.mm +89 -0
- package/package.json +74 -3
- package/react-native.config.js +11 -0
- package/src/NativeRovq.ts +72 -0
- package/src/errors.ts +44 -0
- package/src/index.ts +228 -0
|
@@ -0,0 +1,164 @@
|
|
|
1
|
+
package com.rovq.engine.react
|
|
2
|
+
|
|
3
|
+
import android.util.Log
|
|
4
|
+
import com.facebook.react.ReactApplication
|
|
5
|
+
import com.facebook.react.bridge.Arguments
|
|
6
|
+
import com.facebook.react.bridge.Promise
|
|
7
|
+
import com.facebook.react.bridge.ReactApplicationContext
|
|
8
|
+
import com.facebook.react.bridge.UiThreadUtil
|
|
9
|
+
import com.facebook.react.bridge.WritableMap
|
|
10
|
+
import com.facebook.react.module.annotations.ReactModule
|
|
11
|
+
import com.rovq.engine.CheckResult
|
|
12
|
+
import com.rovq.engine.DownloadResult
|
|
13
|
+
import com.rovq.engine.InstallResult
|
|
14
|
+
import com.rovq.engine.RovqEngine
|
|
15
|
+
import com.rovq.engine.RovqErrorCode
|
|
16
|
+
import com.rovq.engine.ProgressThrottle
|
|
17
|
+
import com.rovq.engine.SyncResult
|
|
18
|
+
import com.rovq.engine.UpdateInfo
|
|
19
|
+
import java.util.concurrent.Executors
|
|
20
|
+
|
|
21
|
+
@ReactModule(name = RovqModule.NAME)
|
|
22
|
+
class RovqModule(context: ReactApplicationContext) : NativeRovqSpec(context) {
|
|
23
|
+
// Network and disk work must stay off the JavaScript thread.
|
|
24
|
+
private val worker = Executors.newSingleThreadExecutor()
|
|
25
|
+
|
|
26
|
+
// A download keeps running through a reload, when this module's JavaScript has gone.
|
|
27
|
+
@Volatile
|
|
28
|
+
private var invalidated = false
|
|
29
|
+
|
|
30
|
+
override fun getName(): String = NAME
|
|
31
|
+
|
|
32
|
+
override fun checkForUpdate(promise: Promise) = run(promise) { engine ->
|
|
33
|
+
when (val result = engine.checkForUpdate()) {
|
|
34
|
+
CheckResult.UpToDate -> outcome("UP_TO_DATE")
|
|
35
|
+
CheckResult.RolledBack -> outcome("ROLLED_BACK")
|
|
36
|
+
is CheckResult.UpdateAvailable -> outcome("UPDATE_AVAILABLE").apply {
|
|
37
|
+
putMap("update", toMap(result.update))
|
|
38
|
+
putBoolean("alreadyDownloaded", result.alreadyDownloaded)
|
|
39
|
+
}
|
|
40
|
+
is CheckResult.Failed -> failure(result.code, result.message)
|
|
41
|
+
}
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
override fun downloadUpdate(promise: Promise) = run(promise) { engine ->
|
|
45
|
+
when (val result = engine.downloadUpdate(progressReporter())) {
|
|
46
|
+
is DownloadResult.Downloaded -> outcome("DOWNLOADED").apply { putMap("update", toMap(result.update)) }
|
|
47
|
+
is DownloadResult.Failed -> failure(result.code, result.message)
|
|
48
|
+
}
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
override fun installUpdate(promise: Promise) = run(promise) { engine ->
|
|
52
|
+
when (val result = engine.installUpdate()) {
|
|
53
|
+
is InstallResult.Installed -> outcome("INSTALLED").apply { putString("releaseId", result.releaseId) }
|
|
54
|
+
is InstallResult.Failed -> failure(result.code, result.message)
|
|
55
|
+
}
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
override fun sync(promise: Promise) = run(promise) { engine ->
|
|
59
|
+
when (val result = engine.sync(progressReporter())) {
|
|
60
|
+
SyncResult.UpToDate -> outcome("UP_TO_DATE")
|
|
61
|
+
SyncResult.RolledBack -> outcome("ROLLED_BACK")
|
|
62
|
+
is SyncResult.UpdateReady -> outcome("UPDATE_READY").apply {
|
|
63
|
+
putString("releaseId", result.releaseId)
|
|
64
|
+
putBoolean("mandatory", result.mandatory)
|
|
65
|
+
}
|
|
66
|
+
is SyncResult.Failed -> failure(result.code, result.message)
|
|
67
|
+
}
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
override fun notifyApplicationReady() {
|
|
71
|
+
Rovq.engineOrNull?.notifyReady()
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
override fun restartApp() {
|
|
75
|
+
UiThreadUtil.runOnUiThread {
|
|
76
|
+
val application = reactApplicationContext.applicationContext as ReactApplication
|
|
77
|
+
application.reactHost?.reload("ROVQ restart")
|
|
78
|
+
}
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
override fun rollback(promise: Promise) {
|
|
82
|
+
worker.execute {
|
|
83
|
+
try {
|
|
84
|
+
promise.resolve(Rovq.engineOrNull?.rollback() ?: false)
|
|
85
|
+
} catch (error: Exception) {
|
|
86
|
+
promise.reject("UNKNOWN", error)
|
|
87
|
+
}
|
|
88
|
+
}
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
override fun getStatus(promise: Promise) {
|
|
92
|
+
val status = Rovq.engineOrNull?.status()
|
|
93
|
+
promise.resolve(
|
|
94
|
+
Arguments.createMap().apply {
|
|
95
|
+
if (status != null) {
|
|
96
|
+
putString("deviceId", status.deviceId)
|
|
97
|
+
putString("currentReleaseId", status.currentReleaseId)
|
|
98
|
+
putString("downloadedReleaseId", status.downloadedReleaseId)
|
|
99
|
+
putString("pendingReleaseId", status.pendingReleaseId)
|
|
100
|
+
putString("tryingReleaseId", status.tryingReleaseId)
|
|
101
|
+
putDouble("highestSequence", status.highestSequence.toDouble())
|
|
102
|
+
}
|
|
103
|
+
},
|
|
104
|
+
)
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
private fun run(promise: Promise, work: (RovqEngine) -> WritableMap) {
|
|
108
|
+
worker.execute {
|
|
109
|
+
try {
|
|
110
|
+
val engine = Rovq.engineOrNull
|
|
111
|
+
promise.resolve(
|
|
112
|
+
if (engine != null) {
|
|
113
|
+
work(engine)
|
|
114
|
+
} else {
|
|
115
|
+
failure(RovqErrorCode.ENGINE_UNAVAILABLE, "Rovq.initialize was not called")
|
|
116
|
+
},
|
|
117
|
+
)
|
|
118
|
+
} catch (error: Exception) {
|
|
119
|
+
promise.reject("UNKNOWN", error)
|
|
120
|
+
}
|
|
121
|
+
}
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
private fun progressReporter(): (Long, Long) -> Unit {
|
|
125
|
+
val throttle = ProgressThrottle { received, total ->
|
|
126
|
+
if (!invalidated) {
|
|
127
|
+
emitOnDownloadProgress(
|
|
128
|
+
Arguments.createMap().apply {
|
|
129
|
+
putDouble("receivedBytes", received.toDouble())
|
|
130
|
+
putDouble("totalBytes", total.toDouble())
|
|
131
|
+
},
|
|
132
|
+
)
|
|
133
|
+
}
|
|
134
|
+
}
|
|
135
|
+
return throttle::report
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
private fun outcome(name: String): WritableMap = Arguments.createMap().apply { putString("result", name) }
|
|
139
|
+
|
|
140
|
+
private fun failure(code: RovqErrorCode, message: String): WritableMap {
|
|
141
|
+
Log.w(NAME, "Failed with $code: $message")
|
|
142
|
+
return outcome("FAILED").apply {
|
|
143
|
+
putString("code", code.name)
|
|
144
|
+
putString("message", message)
|
|
145
|
+
}
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
private fun toMap(update: UpdateInfo): WritableMap = Arguments.createMap().apply {
|
|
149
|
+
putString("releaseId", update.releaseId)
|
|
150
|
+
putDouble("sequence", update.sequence.toDouble())
|
|
151
|
+
putBoolean("mandatory", update.mandatory)
|
|
152
|
+
putDouble("packageSize", update.packageSize.toDouble())
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
override fun invalidate() {
|
|
156
|
+
invalidated = true
|
|
157
|
+
worker.shutdown()
|
|
158
|
+
super.invalidate()
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
companion object {
|
|
162
|
+
const val NAME = "Rovq"
|
|
163
|
+
}
|
|
164
|
+
}
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
package com.rovq.engine.react
|
|
2
|
+
|
|
3
|
+
import com.facebook.react.BaseReactPackage
|
|
4
|
+
import com.facebook.react.bridge.NativeModule
|
|
5
|
+
import com.facebook.react.bridge.ReactApplicationContext
|
|
6
|
+
import com.facebook.react.module.model.ReactModuleInfo
|
|
7
|
+
import com.facebook.react.module.model.ReactModuleInfoProvider
|
|
8
|
+
|
|
9
|
+
/** Found by React Native autolinking. */
|
|
10
|
+
class RovqPackage : BaseReactPackage() {
|
|
11
|
+
override fun getModule(name: String, reactContext: ReactApplicationContext): NativeModule? =
|
|
12
|
+
if (name == RovqModule.NAME) RovqModule(reactContext) else null
|
|
13
|
+
|
|
14
|
+
override fun getReactModuleInfoProvider(): ReactModuleInfoProvider = ReactModuleInfoProvider {
|
|
15
|
+
mapOf(
|
|
16
|
+
RovqModule.NAME to ReactModuleInfo(
|
|
17
|
+
RovqModule.NAME,
|
|
18
|
+
RovqModule::class.java.name,
|
|
19
|
+
false,
|
|
20
|
+
false,
|
|
21
|
+
false,
|
|
22
|
+
true,
|
|
23
|
+
),
|
|
24
|
+
)
|
|
25
|
+
}
|
|
26
|
+
}
|
|
@@ -0,0 +1,74 @@
|
|
|
1
|
+
package com.rovq.engine.react
|
|
2
|
+
|
|
3
|
+
import android.app.Application
|
|
4
|
+
import android.util.Log
|
|
5
|
+
import com.facebook.react.ReactHost
|
|
6
|
+
import com.facebook.react.ReactPackage
|
|
7
|
+
import com.facebook.react.bridge.JSBundleLoader
|
|
8
|
+
import com.facebook.react.bridge.JSBundleLoaderDelegate
|
|
9
|
+
import com.facebook.react.common.annotations.UnstableReactNativeAPI
|
|
10
|
+
import com.facebook.react.common.build.ReactBuildConfig
|
|
11
|
+
import com.facebook.react.defaults.DefaultComponentsRegistry
|
|
12
|
+
import com.facebook.react.defaults.DefaultReactHostDelegate
|
|
13
|
+
import com.facebook.react.defaults.DefaultTurboModuleManagerDelegate
|
|
14
|
+
import com.facebook.react.fabric.ComponentFactory
|
|
15
|
+
import com.facebook.react.runtime.ReactHostImpl
|
|
16
|
+
import com.facebook.react.runtime.hermes.HermesInstance
|
|
17
|
+
|
|
18
|
+
private const val TAG = "Rovq"
|
|
19
|
+
private const val EMBEDDED_BUNDLE = "assets://index.android.bundle"
|
|
20
|
+
|
|
21
|
+
/**
|
|
22
|
+
* Chooses the bundle each time JavaScript starts, not once when the host is created.
|
|
23
|
+
* This is what lets reload apply an update without killing the process.
|
|
24
|
+
*/
|
|
25
|
+
private class RovqBundleLoader(private val application: Application) : JSBundleLoader() {
|
|
26
|
+
override fun loadScript(delegate: JSBundleLoaderDelegate): String {
|
|
27
|
+
val file = try {
|
|
28
|
+
Rovq.engine.resolveBundle()
|
|
29
|
+
} catch (error: Throwable) {
|
|
30
|
+
// Whatever goes wrong in the engine, the app must still start.
|
|
31
|
+
Log.e(TAG, "Falling back to the bundle inside the app", error)
|
|
32
|
+
null
|
|
33
|
+
}
|
|
34
|
+
if (file == null) {
|
|
35
|
+
Log.i(TAG, "Loading the bundle inside the app")
|
|
36
|
+
delegate.loadScriptFromAssets(application.assets, EMBEDDED_BUNDLE, true)
|
|
37
|
+
return EMBEDDED_BUNDLE
|
|
38
|
+
}
|
|
39
|
+
Log.i(TAG, "Loading ${file.absolutePath}")
|
|
40
|
+
delegate.loadScriptFromFile(file.absolutePath, file.absolutePath, false)
|
|
41
|
+
return file.absolutePath
|
|
42
|
+
}
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
/**
|
|
46
|
+
* The only file that touches React Native's unstable host API. It does what
|
|
47
|
+
* getDefaultReactHost does, with a different loader, so check it against that function
|
|
48
|
+
* when upgrading React Native. See docs/android-bundle-loading.md.
|
|
49
|
+
*/
|
|
50
|
+
@OptIn(UnstableReactNativeAPI::class)
|
|
51
|
+
object RovqReactHost {
|
|
52
|
+
/** Use in place of getDefaultReactHost in MainApplication. */
|
|
53
|
+
fun create(application: Application, packages: List<ReactPackage>): ReactHost {
|
|
54
|
+
val componentFactory = ComponentFactory()
|
|
55
|
+
DefaultComponentsRegistry.register(componentFactory)
|
|
56
|
+
val delegate = DefaultReactHostDelegate(
|
|
57
|
+
jsMainModulePath = "index",
|
|
58
|
+
jsBundleLoader = RovqBundleLoader(application),
|
|
59
|
+
reactPackages = packages,
|
|
60
|
+
jsRuntimeFactory = HermesInstance(),
|
|
61
|
+
turboModuleManagerDelegateBuilder = DefaultTurboModuleManagerDelegate.Builder(),
|
|
62
|
+
// Letting a fatal JavaScript error kill the process is what makes rollback work:
|
|
63
|
+
// the release stays unconfirmed, so the next start abandons it.
|
|
64
|
+
exceptionHandler = { error -> throw error },
|
|
65
|
+
)
|
|
66
|
+
return ReactHostImpl(
|
|
67
|
+
application.applicationContext,
|
|
68
|
+
delegate,
|
|
69
|
+
componentFactory,
|
|
70
|
+
true,
|
|
71
|
+
ReactBuildConfig.DEBUG,
|
|
72
|
+
)
|
|
73
|
+
}
|
|
74
|
+
}
|
|
@@ -0,0 +1,119 @@
|
|
|
1
|
+
import Foundation
|
|
2
|
+
|
|
3
|
+
/// A release on the device, identified by its folder name and its signed sequence.
|
|
4
|
+
public struct ReleaseRef: Codable, Equatable {
|
|
5
|
+
public let id: String
|
|
6
|
+
public let sequence: Int64
|
|
7
|
+
}
|
|
8
|
+
|
|
9
|
+
/// Waiting to be sent to the server. Device events are retried, so each has its own ID.
|
|
10
|
+
public struct QueuedEvent: Codable, Equatable {
|
|
11
|
+
public let clientEventId: String
|
|
12
|
+
public let type: String
|
|
13
|
+
public let releaseId: String?
|
|
14
|
+
public let errorCode: String?
|
|
15
|
+
public let occurredAt: String
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
/// A release that failed its checks after downloading, and when it may be tried again.
|
|
19
|
+
struct PostponedDownload: Codable, Equatable {
|
|
20
|
+
let releaseId: String
|
|
21
|
+
let attempts: Int
|
|
22
|
+
let until: TimeInterval
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
/// Everything the engine must remember across process deaths. It holds IDs and never full
|
|
26
|
+
/// paths, because iOS moves an app's container whenever the app is updated.
|
|
27
|
+
///
|
|
28
|
+
/// A release moves from `downloaded` to `pending` to `trying` to `current`:
|
|
29
|
+
/// - `downloaded`: verified and on disk, but nothing will load it until the app installs it.
|
|
30
|
+
/// - `pending`: installed, and loads on the next JavaScript start.
|
|
31
|
+
/// - `trying`: being loaded right now. If the next start finds it still here, it never
|
|
32
|
+
/// confirmed that it works, so it is abandoned.
|
|
33
|
+
/// - `current`: confirmed to work.
|
|
34
|
+
/// - `previous`: the confirmed release before `current`, kept to go back to.
|
|
35
|
+
struct EngineState: Codable, Equatable {
|
|
36
|
+
var deviceId: String
|
|
37
|
+
var binaryId: String
|
|
38
|
+
var runtimeVersion: String
|
|
39
|
+
var highestSequence: Int64 = 0
|
|
40
|
+
var current: ReleaseRef?
|
|
41
|
+
var previous: ReleaseRef?
|
|
42
|
+
var downloaded: ReleaseRef?
|
|
43
|
+
var pending: ReleaseRef?
|
|
44
|
+
var trying: ReleaseRef?
|
|
45
|
+
/// True if the app went to the background while `trying`, so a person probably closed it.
|
|
46
|
+
var backgroundedWhileTrying = false
|
|
47
|
+
/// How many more starts `trying` was given because it was closed before confirming.
|
|
48
|
+
var tryingRetries = 0
|
|
49
|
+
var postponed: PostponedDownload?
|
|
50
|
+
var revokedPublicKeys: Set<String> = []
|
|
51
|
+
var events: [QueuedEvent] = []
|
|
52
|
+
|
|
53
|
+
enum CodingKeys: String, CodingKey {
|
|
54
|
+
case deviceId, binaryId, runtimeVersion, highestSequence, current, previous, downloaded
|
|
55
|
+
case pending, trying, backgroundedWhileTrying, tryingRetries, postponed, revokedPublicKeys
|
|
56
|
+
case events
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
init(deviceId: String, binaryId: String, runtimeVersion: String) {
|
|
60
|
+
self.deviceId = deviceId
|
|
61
|
+
self.binaryId = binaryId
|
|
62
|
+
self.runtimeVersion = runtimeVersion
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
/// Fields added after the first version are optional, so an older saved state still loads.
|
|
66
|
+
init(from decoder: Decoder) throws {
|
|
67
|
+
let values = try decoder.container(keyedBy: CodingKeys.self)
|
|
68
|
+
deviceId = try values.decode(String.self, forKey: .deviceId)
|
|
69
|
+
binaryId = try values.decode(String.self, forKey: .binaryId)
|
|
70
|
+
runtimeVersion = try values.decode(String.self, forKey: .runtimeVersion)
|
|
71
|
+
highestSequence = try values.decode(Int64.self, forKey: .highestSequence)
|
|
72
|
+
current = try values.decodeIfPresent(ReleaseRef.self, forKey: .current)
|
|
73
|
+
previous = try values.decodeIfPresent(ReleaseRef.self, forKey: .previous)
|
|
74
|
+
downloaded = try values.decodeIfPresent(ReleaseRef.self, forKey: .downloaded)
|
|
75
|
+
pending = try values.decodeIfPresent(ReleaseRef.self, forKey: .pending)
|
|
76
|
+
trying = try values.decodeIfPresent(ReleaseRef.self, forKey: .trying)
|
|
77
|
+
backgroundedWhileTrying =
|
|
78
|
+
try values.decodeIfPresent(Bool.self, forKey: .backgroundedWhileTrying) ?? false
|
|
79
|
+
tryingRetries = try values.decodeIfPresent(Int.self, forKey: .tryingRetries) ?? 0
|
|
80
|
+
postponed = try values.decodeIfPresent(PostponedDownload.self, forKey: .postponed)
|
|
81
|
+
revokedPublicKeys =
|
|
82
|
+
try values.decodeIfPresent(Set<String>.self, forKey: .revokedPublicKeys) ?? []
|
|
83
|
+
events = try values.decode([QueuedEvent].self, forKey: .events)
|
|
84
|
+
}
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
/// Saves the state so that a crash at any moment leaves either the old state or the new one,
|
|
88
|
+
/// never a mixture. The app's first screen depends on this file being readable.
|
|
89
|
+
struct EngineStateStore {
|
|
90
|
+
private let file: URL
|
|
91
|
+
private let temporary: URL
|
|
92
|
+
|
|
93
|
+
init(rootFolder: URL) {
|
|
94
|
+
file = rootFolder.appendingPathComponent("state.json")
|
|
95
|
+
temporary = rootFolder.appendingPathComponent("state.json.tmp")
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
/// Returns nil if there is no state, or if it is unreadable.
|
|
99
|
+
func read() -> EngineState? {
|
|
100
|
+
guard let data = try? Data(contentsOf: file) else { return nil }
|
|
101
|
+
return try? JSONDecoder().decode(EngineState.self, from: data)
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
func write(_ state: EngineState) throws {
|
|
105
|
+
// Writing to another file first means a crash cannot leave a half-written state.
|
|
106
|
+
let data = try JSONEncoder().encode(state)
|
|
107
|
+
guard FileManager.default.createFile(atPath: temporary.path, contents: nil) else {
|
|
108
|
+
throw RovqError(.storageFailed, "Could not save the engine state")
|
|
109
|
+
}
|
|
110
|
+
let handle = try FileHandle(forWritingTo: temporary)
|
|
111
|
+
defer { try? handle.close() }
|
|
112
|
+
try handle.write(contentsOf: data)
|
|
113
|
+
try handle.synchronize()
|
|
114
|
+
// POSIX rename replaces the old file atomically.
|
|
115
|
+
guard rename(temporary.path, file.path) == 0 else {
|
|
116
|
+
throw RovqError(.storageFailed, "Could not save the engine state")
|
|
117
|
+
}
|
|
118
|
+
}
|
|
119
|
+
}
|
|
@@ -0,0 +1,197 @@
|
|
|
1
|
+
import CryptoKit
|
|
2
|
+
import Foundation
|
|
3
|
+
|
|
4
|
+
private let publicKeySize = 32
|
|
5
|
+
private let signatureSize = 64
|
|
6
|
+
private let manifestVersion: Int64 = 1
|
|
7
|
+
private let platform = "IOS"
|
|
8
|
+
private let manifestFields: Set<String> = [
|
|
9
|
+
"version", "releaseId", "deploymentKey", "platform", "runtimeVersion", "binaryVersion",
|
|
10
|
+
"sequence", "mandatory", "package",
|
|
11
|
+
]
|
|
12
|
+
private let packageFields: Set<String> = ["hash", "size"]
|
|
13
|
+
private let uuidPattern = "^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$"
|
|
14
|
+
private let hashPattern = "^[0-9a-f]{64}$"
|
|
15
|
+
|
|
16
|
+
/// Facts from a manifest whose signature has been verified. Nothing else should be trusted.
|
|
17
|
+
public struct VerifiedManifest: Equatable {
|
|
18
|
+
public let releaseId: String
|
|
19
|
+
public let sequence: Int64
|
|
20
|
+
public let mandatory: Bool
|
|
21
|
+
public let packageHash: String
|
|
22
|
+
public let packageSize: Int64
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
/// Decodes standard Base64, accepting only one spelling of each value, as the server does.
|
|
26
|
+
func decodeStrictBase64(_ value: String, expectedSize: Int) -> Data? {
|
|
27
|
+
guard let data = Data(base64Encoded: value), data.count == expectedSize,
|
|
28
|
+
data.base64EncodedString() == value
|
|
29
|
+
else { return nil }
|
|
30
|
+
return data
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
/// The device's own security check. It trusts only the public keys built into the app, so
|
|
34
|
+
/// a compromised server cannot get code onto the device.
|
|
35
|
+
public final class ManifestVerifier {
|
|
36
|
+
private let keys: [String: Curve25519.Signing.PublicKey]
|
|
37
|
+
private let trustedKeys: [String: String]
|
|
38
|
+
private let revokedLock = NSLock()
|
|
39
|
+
private var revoked: Set<String> = []
|
|
40
|
+
private let deploymentKey: String
|
|
41
|
+
private let runtimeVersion: String
|
|
42
|
+
|
|
43
|
+
/// - Parameter trustedKeys: key IDs and their Base64 Ed25519 public keys, built into the app.
|
|
44
|
+
/// - Throws: if a built-in key is malformed, which is a mistake in the app itself.
|
|
45
|
+
public init(trustedKeys: [String: String], deploymentKey: String, runtimeVersion: String) throws {
|
|
46
|
+
keys = try trustedKeys.mapValues { publicKey in
|
|
47
|
+
guard let bytes = decodeStrictBase64(publicKey, expectedSize: publicKeySize) else {
|
|
48
|
+
throw RovqError(.invalidManifest, "A built-in public key is not a Base64 Ed25519 key")
|
|
49
|
+
}
|
|
50
|
+
return try Curve25519.Signing.PublicKey(rawRepresentation: bytes)
|
|
51
|
+
}
|
|
52
|
+
self.trustedKeys = trustedKeys
|
|
53
|
+
self.deploymentKey = deploymentKey
|
|
54
|
+
self.runtimeVersion = runtimeVersion
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
/// Public keys, not IDs, so a later version of the app can reuse an ID with a new key.
|
|
58
|
+
public var revokedPublicKeys: Set<String> {
|
|
59
|
+
get {
|
|
60
|
+
revokedLock.lock()
|
|
61
|
+
defer { revokedLock.unlock() }
|
|
62
|
+
return revoked
|
|
63
|
+
}
|
|
64
|
+
set {
|
|
65
|
+
revokedLock.lock()
|
|
66
|
+
defer { revokedLock.unlock() }
|
|
67
|
+
revoked = newValue
|
|
68
|
+
}
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
/// Checks a key's statement that it must no longer be trusted, and returns its public key.
|
|
72
|
+
/// Only the key's own signature counts, so a stolen key can revoke only itself. Comparing the
|
|
73
|
+
/// exact text means nothing unsigned reaches a parser.
|
|
74
|
+
///
|
|
75
|
+
/// - Returns: nil if the statement is not valid, in which case it must be ignored.
|
|
76
|
+
public func verifyRevocation(keyId: String, statement: String, signature: String) -> String? {
|
|
77
|
+
guard let publicKey = trustedKeys[keyId],
|
|
78
|
+
statement == "{\"version\":1,\"type\":\"key-revocation\",\"publicKey\":\"\(publicKey)\"}",
|
|
79
|
+
(try? verifySignature(manifest: statement, signature: signature, keyId: keyId)) != nil
|
|
80
|
+
else { return nil }
|
|
81
|
+
return publicKey
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
/// - Parameter highestSequence: the highest sequence this device has accepted in this stream.
|
|
85
|
+
/// - Throws: `RovqError` if anything fails. The device must then ignore the update.
|
|
86
|
+
public func verify(
|
|
87
|
+
manifest: String,
|
|
88
|
+
signature: String,
|
|
89
|
+
keyId: String,
|
|
90
|
+
highestSequence: Int64
|
|
91
|
+
) throws -> VerifiedManifest {
|
|
92
|
+
if let publicKey = trustedKeys[keyId], revokedPublicKeys.contains(publicKey) {
|
|
93
|
+
throw RovqError(.revokedSigningKey, "Signing key \(keyId) was revoked")
|
|
94
|
+
}
|
|
95
|
+
try verifySignature(manifest: manifest, signature: signature, keyId: keyId)
|
|
96
|
+
// Only signed text reaches the JSON parser.
|
|
97
|
+
let verified = try parseAndCheck(manifest)
|
|
98
|
+
if verified.sequence <= highestSequence {
|
|
99
|
+
throw RovqError(.staleSequence, "Sequence \(verified.sequence) is not above \(highestSequence)")
|
|
100
|
+
}
|
|
101
|
+
return verified
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
private func verifySignature(manifest: String, signature: String, keyId: String) throws {
|
|
105
|
+
guard let key = keys[keyId] else {
|
|
106
|
+
throw RovqError(.unknownSigningKey, "Unknown signing key \(keyId)")
|
|
107
|
+
}
|
|
108
|
+
guard let bytes = decodeStrictBase64(signature, expectedSize: signatureSize) else {
|
|
109
|
+
throw RovqError(.invalidSignature, "Malformed signature")
|
|
110
|
+
}
|
|
111
|
+
guard key.isValidSignature(bytes, for: Data(manifest.utf8)) else {
|
|
112
|
+
throw RovqError(.invalidSignature, "Signature does not match")
|
|
113
|
+
}
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
private func parseAndCheck(_ manifest: String) throws -> VerifiedManifest {
|
|
117
|
+
let parsed = try? JSONSerialization.jsonObject(with: Data(manifest.utf8))
|
|
118
|
+
guard let root = parsed as? [String: Any] else {
|
|
119
|
+
throw invalid("Manifest is not a JSON object")
|
|
120
|
+
}
|
|
121
|
+
try requireOnlyFields(root, allowed: manifestFields)
|
|
122
|
+
guard try integerField(root, "version") == manifestVersion else {
|
|
123
|
+
throw invalid("Unknown manifest version")
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
let releaseId = try stringField(root, "releaseId")
|
|
127
|
+
// Later used as a folder name, so it must be exactly a UUID.
|
|
128
|
+
guard matches(releaseId, uuidPattern) else { throw invalid("releaseId is not a UUID") }
|
|
129
|
+
|
|
130
|
+
let sequence = try integerField(root, "sequence")
|
|
131
|
+
guard sequence > 0 else { throw invalid("sequence must be positive") }
|
|
132
|
+
|
|
133
|
+
guard let mandatoryNumber = root["mandatory"] as? NSNumber, isBoolean(mandatoryNumber) else {
|
|
134
|
+
throw invalid("mandatory must be a boolean")
|
|
135
|
+
}
|
|
136
|
+
_ = try stringField(root, "binaryVersion")
|
|
137
|
+
|
|
138
|
+
guard let package = root["package"] as? [String: Any] else {
|
|
139
|
+
throw invalid("package must be an object")
|
|
140
|
+
}
|
|
141
|
+
try requireOnlyFields(package, allowed: packageFields)
|
|
142
|
+
let hash = try stringField(package, "hash")
|
|
143
|
+
guard matches(hash, hashPattern) else { throw invalid("package hash is malformed") }
|
|
144
|
+
let size = try integerField(package, "size")
|
|
145
|
+
guard size > 0 else { throw invalid("package size must be positive") }
|
|
146
|
+
|
|
147
|
+
// A valid signature for another deployment, platform or runtime must not install here.
|
|
148
|
+
try requireMatch("deploymentKey", try stringField(root, "deploymentKey"), deploymentKey)
|
|
149
|
+
try requireMatch("platform", try stringField(root, "platform"), platform)
|
|
150
|
+
try requireMatch("runtimeVersion", try stringField(root, "runtimeVersion"), runtimeVersion)
|
|
151
|
+
|
|
152
|
+
return VerifiedManifest(
|
|
153
|
+
releaseId: releaseId,
|
|
154
|
+
sequence: sequence,
|
|
155
|
+
mandatory: mandatoryNumber.boolValue,
|
|
156
|
+
packageHash: hash,
|
|
157
|
+
packageSize: size
|
|
158
|
+
)
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
private func matches(_ value: String, _ pattern: String) -> Bool {
|
|
162
|
+
value.range(of: pattern, options: .regularExpression) != nil
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
private func requireOnlyFields(_ value: [String: Any], allowed: Set<String>) throws {
|
|
166
|
+
if let unknown = value.keys.first(where: { !allowed.contains($0) }) {
|
|
167
|
+
throw invalid("Unknown field \(unknown)")
|
|
168
|
+
}
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
private func stringField(_ value: [String: Any], _ name: String) throws -> String {
|
|
172
|
+
guard let text = value[name] as? String else { throw invalid("\(name) must be a string") }
|
|
173
|
+
return text
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
// JSON numbers and booleans both arrive as NSNumber, so they must be told apart.
|
|
177
|
+
private func integerField(_ value: [String: Any], _ name: String) throws -> Int64 {
|
|
178
|
+
guard let number = value[name] as? NSNumber, !isBoolean(number),
|
|
179
|
+
number.doubleValue == Double(number.int64Value)
|
|
180
|
+
else { throw invalid("\(name) must be a whole number") }
|
|
181
|
+
return number.int64Value
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
private func isBoolean(_ number: NSNumber) -> Bool {
|
|
185
|
+
CFGetTypeID(number) == CFBooleanGetTypeID()
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
private func requireMatch(_ name: String, _ actual: String, _ expected: String) throws {
|
|
189
|
+
if actual != expected {
|
|
190
|
+
throw RovqError(.manifestMismatch, "\(name) does not match this app")
|
|
191
|
+
}
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
private func invalid(_ message: String) -> RovqError {
|
|
195
|
+
RovqError(.invalidManifest, message)
|
|
196
|
+
}
|
|
197
|
+
}
|