@lynxship/expo 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/README.md +125 -0
- package/android/build.gradle +40 -0
- package/android/consumer-rules.pro +34 -0
- package/android/src/main/AndroidManifest.xml +1 -0
- package/android/src/main/java/com/lynxship/expo/LynxShipExpoModule.kt +30 -0
- package/android/src/main/java/com/lynxship/expo/LynxShipExpoView.kt +196 -0
- package/app.plugin.cjs +204 -0
- package/app.plugin.js +4 -0
- package/dist/config.d.ts +12 -0
- package/dist/config.d.ts.map +1 -0
- package/dist/config.js +35 -0
- package/dist/index.d.ts +21 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +27 -0
- package/expo-module.config.json +9 -0
- package/ios/LynxShipExpoModule.swift +22 -0
- package/ios/LynxShipExpoView.swift +150 -0
- package/lynxship-expo.podspec +25 -0
- package/package.json +56 -0
- package/src/config.ts +55 -0
- package/src/externals.d.ts +23 -0
- package/src/index.ts +43 -0
package/README.md
ADDED
|
@@ -0,0 +1,125 @@
|
|
|
1
|
+
# @lynxship/expo
|
|
2
|
+
|
|
3
|
+
Expo Modules API integration for embedding an official Lynx `LynxView` inside
|
|
4
|
+
an Expo/React Native application. The native view uses the LynxShip Android or
|
|
5
|
+
iOS OTA client when an OTA endpoint is configured and otherwise renders the
|
|
6
|
+
embedded `main.lynx.bundle` fallback.
|
|
7
|
+
|
|
8
|
+
## Install
|
|
9
|
+
|
|
10
|
+
```bash
|
|
11
|
+
npx expo install @lynxship/expo
|
|
12
|
+
npx expo prebuild
|
|
13
|
+
npx pod-install
|
|
14
|
+
```
|
|
15
|
+
|
|
16
|
+
When the project uses a static `app.json` or `app.config.json`, the Expo CLI
|
|
17
|
+
automatically adds `@lynxship/expo` to `expo.plugins` during `npx expo install`.
|
|
18
|
+
The plugin's defaults are safe for the first build, so no manual config edit
|
|
19
|
+
is required for the basic integration. `npx expo prebuild` then applies the
|
|
20
|
+
Android and iOS native changes before the native build.
|
|
21
|
+
|
|
22
|
+
If the project uses a dynamic `app.config.js`/`app.config.ts`, or if custom OTA
|
|
23
|
+
options are needed, add the config plugin manually:
|
|
24
|
+
|
|
25
|
+
```json
|
|
26
|
+
{
|
|
27
|
+
"expo": {
|
|
28
|
+
"plugins": [
|
|
29
|
+
[
|
|
30
|
+
"@lynxship/expo",
|
|
31
|
+
{
|
|
32
|
+
"endpoint": "https://api.example.com",
|
|
33
|
+
"projectId": "00000000-0000-4000-8000-000000000000",
|
|
34
|
+
"channel": "production",
|
|
35
|
+
"runtimeVersion": "lynx-runtime-2026-01",
|
|
36
|
+
"publicKeys": {
|
|
37
|
+
"release-key-1": "-----BEGIN PUBLIC KEY-----\n...\n-----END PUBLIC KEY-----"
|
|
38
|
+
},
|
|
39
|
+
"embeddedBundle": "main.lynx.bundle",
|
|
40
|
+
"lynxVersion": "auto"
|
|
41
|
+
}
|
|
42
|
+
]
|
|
43
|
+
]
|
|
44
|
+
}
|
|
45
|
+
}
|
|
46
|
+
```
|
|
47
|
+
|
|
48
|
+
For a dynamic config, keep the same `plugins` entry in the returned Expo
|
|
49
|
+
configuration. Expo cannot safely rewrite JavaScript or TypeScript config
|
|
50
|
+
files automatically.
|
|
51
|
+
|
|
52
|
+
The endpoint must be HTTPS outside localhost. The public verification key is
|
|
53
|
+
safe to ship in the application; private signing keys must remain in the
|
|
54
|
+
LynxShip CLI or CI secret store.
|
|
55
|
+
|
|
56
|
+
`lynxVersion` defaults to `auto`. Android resolves the current Lynx release
|
|
57
|
+
through Gradle and iOS resolves the current CocoaPods release. Gradle and
|
|
58
|
+
CocoaPods lockfiles retain the concrete versions selected by the first native
|
|
59
|
+
install, so normal rebuilds remain reproducible. Set an exact semver only when
|
|
60
|
+
you intentionally operate a pinned native compatibility lane; do not put
|
|
61
|
+
`latest` in a production lockfile without reviewing the resulting native
|
|
62
|
+
build.
|
|
63
|
+
|
|
64
|
+
## Use the view
|
|
65
|
+
|
|
66
|
+
```tsx
|
|
67
|
+
import { LynxView } from "@lynxship/expo";
|
|
68
|
+
|
|
69
|
+
export function LynxScreen() {
|
|
70
|
+
return (
|
|
71
|
+
<LynxView
|
|
72
|
+
style={{ flex: 1 }}
|
|
73
|
+
bundle="main.lynx.bundle"
|
|
74
|
+
initialData="{}"
|
|
75
|
+
reloadOnUpdate
|
|
76
|
+
/>
|
|
77
|
+
);
|
|
78
|
+
}
|
|
79
|
+
```
|
|
80
|
+
|
|
81
|
+
The native module initializes Lynx, creates the official `LynxView`, and
|
|
82
|
+
provides a template provider. The provider first reads the verified active OTA
|
|
83
|
+
asset and falls back to the embedded bundle. An OTA release is accepted only
|
|
84
|
+
after its runtime, manifest signature, asset paths, sizes and SHA-256 hashes
|
|
85
|
+
pass validation. `onReady` is emitted after Lynx reports its first screen, not
|
|
86
|
+
merely after the render request is queued. Native code, permissions, native
|
|
87
|
+
modules and Lynx runtime changes still require a new binary.
|
|
88
|
+
|
|
89
|
+
## Build workflow
|
|
90
|
+
|
|
91
|
+
From the Expo project:
|
|
92
|
+
|
|
93
|
+
```bash
|
|
94
|
+
lynxship init
|
|
95
|
+
lynxship doctor --platform android
|
|
96
|
+
lynxship doctor --platform ios
|
|
97
|
+
lynxship build --platform android --profile production
|
|
98
|
+
lynxship build --platform ios --profile production
|
|
99
|
+
lynxship ota doctor --platform android
|
|
100
|
+
lynxship ota doctor --platform ios
|
|
101
|
+
```
|
|
102
|
+
|
|
103
|
+
The package does not silently replace an existing native host. `expo prebuild`
|
|
104
|
+
and the official Lynx dependencies remain the source of truth for native
|
|
105
|
+
project generation. iOS builds still require macOS/Xcode; Android builds use
|
|
106
|
+
the Android SDK and Gradle toolchain.
|
|
107
|
+
|
|
108
|
+
## Official compatibility boundary
|
|
109
|
+
|
|
110
|
+
This package follows the official brownfield Lynx integration: `LynxView` is a
|
|
111
|
+
native Android/iOS view and the host supplies the template provider. Lynx's
|
|
112
|
+
engine does not itself implement application-specific network downloads, so
|
|
113
|
+
the provider is the integration point for the LynxShip OTA cache.
|
|
114
|
+
|
|
115
|
+
References:
|
|
116
|
+
|
|
117
|
+
- [Lynx integration with existing apps](https://lynxjs.org/3.8/guide/start/integrate-with-existing-apps.html)
|
|
118
|
+
- [Expo native view modules](https://docs.expo.dev/modules/native-view-tutorial/)
|
|
119
|
+
- [Expo module configuration](https://docs.expo.dev/modules/module-config/)
|
|
120
|
+
- [LynxShip OTA security and compatibility](../../docs/compatibility.md)
|
|
121
|
+
|
|
122
|
+
The native targets must be built on their real platform. The repository's
|
|
123
|
+
Windows checks validate the JavaScript API, package metadata and generated
|
|
124
|
+
configuration; they cannot replace a real Android Gradle or macOS Xcode
|
|
125
|
+
runtime build.
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
plugins {
|
|
2
|
+
id "com.android.library"
|
|
3
|
+
}
|
|
4
|
+
|
|
5
|
+
// `latest.release` is used only when the app does not provide an exact
|
|
6
|
+
// version. Gradle dependency locking then records the resolved version for
|
|
7
|
+
// reproducible subsequent builds.
|
|
8
|
+
def lynxVersion = project.findProperty("LYNXSHIP_LYNX_VERSION") ?: "latest.release"
|
|
9
|
+
|
|
10
|
+
android {
|
|
11
|
+
namespace "com.lynxship.expo"
|
|
12
|
+
compileSdk 35
|
|
13
|
+
|
|
14
|
+
defaultConfig {
|
|
15
|
+
minSdk 24
|
|
16
|
+
consumerProguardFiles "consumer-rules.pro"
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
compileOptions {
|
|
20
|
+
sourceCompatibility JavaVersion.VERSION_17
|
|
21
|
+
targetCompatibility JavaVersion.VERSION_17
|
|
22
|
+
}
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
dependencies {
|
|
26
|
+
implementation "org.lynxsdk.lynx:lynx:${lynxVersion}"
|
|
27
|
+
implementation "org.lynxsdk.lynx:lynx-jssdk:${lynxVersion}"
|
|
28
|
+
implementation "org.lynxsdk.lynx:lynx-trace:${lynxVersion}"
|
|
29
|
+
implementation "org.lynxsdk.lynx:primjs:${lynxVersion}"
|
|
30
|
+
implementation "org.lynxsdk.lynx:lynx-service-image:${lynxVersion}"
|
|
31
|
+
implementation "org.lynxsdk.lynx:lynx-service-log:${lynxVersion}"
|
|
32
|
+
implementation "org.lynxsdk.lynx:lynx-service-http:${lynxVersion}"
|
|
33
|
+
implementation "com.facebook.fresco:fresco:2.3.0"
|
|
34
|
+
implementation "com.facebook.fresco:animated-gif:2.3.0"
|
|
35
|
+
implementation "com.facebook.fresco:animated-webp:2.3.0"
|
|
36
|
+
implementation "com.facebook.fresco:webpsupport:2.3.0"
|
|
37
|
+
implementation "com.facebook.fresco:animated-base:2.3.0"
|
|
38
|
+
implementation "com.squareup.okhttp3:okhttp:4.9.0"
|
|
39
|
+
implementation project(":lynxship-sdk-android")
|
|
40
|
+
}
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
# These rules are the public keep rules required by the Lynx Android runtime
|
|
2
|
+
# when an application enables R8/minification. Keep this file in the Expo
|
|
3
|
+
# module so consuming apps do not need to copy framework rules manually.
|
|
4
|
+
-dontwarn android.support.annotation.Keep
|
|
5
|
+
-keep @android.support.annotation.Keep class **
|
|
6
|
+
-keep @android.support.annotation.Keep class ** {
|
|
7
|
+
@android.support.annotation.Keep <fields>;
|
|
8
|
+
@android.support.annotation.Keep <methods>;
|
|
9
|
+
}
|
|
10
|
+
-dontwarn androidx.annotation.Keep
|
|
11
|
+
-keep @androidx.annotation.Keep class **
|
|
12
|
+
-keep @androidx.annotation.Keep class ** {
|
|
13
|
+
@androidx.annotation.Keep <fields>;
|
|
14
|
+
@androidx.annotation.Keep <methods>;
|
|
15
|
+
}
|
|
16
|
+
-keepclasseswithmembers,includedescriptorclasses class * {
|
|
17
|
+
native <methods>;
|
|
18
|
+
}
|
|
19
|
+
-keepclasseswithmembers class * {
|
|
20
|
+
@com.lynx.tasm.base.CalledByNative <methods>;
|
|
21
|
+
}
|
|
22
|
+
-keepclasseswithmembers class * {
|
|
23
|
+
@com.lynx.jsbridge.LynxMethod <methods>;
|
|
24
|
+
}
|
|
25
|
+
-keepclassmembers class * {
|
|
26
|
+
@com.lynx.tasm.behavior.LynxProp <methods>;
|
|
27
|
+
@com.lynx.tasm.behavior.LynxPropGroup <methods>;
|
|
28
|
+
@com.lynx.tasm.behavior.LynxUIMethod <methods>;
|
|
29
|
+
}
|
|
30
|
+
-keep class com.lynx.jsbridge.LynxModule { *; }
|
|
31
|
+
-keep class * extends com.lynx.tasm.behavior.ui.LynxBaseUI
|
|
32
|
+
-keep class * extends com.lynx.tasm.behavior.shadow.ShadowNode
|
|
33
|
+
-keep class * extends com.lynx.jsbridge.LynxModule { *; }
|
|
34
|
+
-keep class * extends com.lynx.jsbridge.LynxContextModule
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
<manifest xmlns:android="http://schemas.android.com/apk/res/android" />
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
package com.lynxship.expo
|
|
2
|
+
|
|
3
|
+
import expo.modules.kotlin.modules.Module
|
|
4
|
+
import expo.modules.kotlin.modules.ModuleDefinition
|
|
5
|
+
|
|
6
|
+
class LynxShipExpoModule : Module() {
|
|
7
|
+
override fun definition() = ModuleDefinition {
|
|
8
|
+
Name("LynxShip")
|
|
9
|
+
|
|
10
|
+
View(LynxShipExpoView::class) {
|
|
11
|
+
Events("onReady", "onError", "onUpdate")
|
|
12
|
+
|
|
13
|
+
Prop("bundle") { view: LynxShipExpoView, value: String? ->
|
|
14
|
+
view.bundleName = value ?: "main.lynx.bundle"
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
Prop("initialData") { view: LynxShipExpoView, value: String? ->
|
|
18
|
+
view.initialData = value ?: ""
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
Prop("reloadOnUpdate") { view: LynxShipExpoView, value: Boolean? ->
|
|
22
|
+
view.reloadOnUpdate = value ?: true
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
AsyncFunction("reload") { view: LynxShipExpoView ->
|
|
26
|
+
view.reload()
|
|
27
|
+
}
|
|
28
|
+
}
|
|
29
|
+
}
|
|
30
|
+
}
|
|
@@ -0,0 +1,196 @@
|
|
|
1
|
+
package com.lynxship.expo
|
|
2
|
+
|
|
3
|
+
import android.content.Context
|
|
4
|
+
import android.content.SharedPreferences
|
|
5
|
+
import android.os.Bundle
|
|
6
|
+
import android.provider.Settings
|
|
7
|
+
import com.facebook.drawee.backends.pipeline.Fresco
|
|
8
|
+
import com.facebook.imagepipeline.core.ImagePipelineConfig
|
|
9
|
+
import com.facebook.imagepipeline.memory.PoolConfig
|
|
10
|
+
import com.facebook.imagepipeline.memory.PoolFactory
|
|
11
|
+
import com.lynx.service.http.LynxHttpService
|
|
12
|
+
import com.lynx.service.image.LynxImageService
|
|
13
|
+
import com.lynx.service.log.LynxLogService
|
|
14
|
+
import com.lynx.tasm.LynxEnv
|
|
15
|
+
import com.lynx.tasm.LynxView
|
|
16
|
+
import com.lynx.tasm.LynxViewClient
|
|
17
|
+
import com.lynx.tasm.LynxViewBuilder
|
|
18
|
+
import com.lynx.tasm.provider.AbsTemplateProvider
|
|
19
|
+
import com.lynx.tasm.service.LynxServiceCenter
|
|
20
|
+
import com.lynxship.sdk.android.LynxShipOtaClient
|
|
21
|
+
import expo.modules.kotlin.AppContext
|
|
22
|
+
import expo.modules.kotlin.events.EventDispatcher
|
|
23
|
+
import expo.modules.kotlin.views.ExpoView
|
|
24
|
+
import java.io.ByteArrayOutputStream
|
|
25
|
+
import java.io.File
|
|
26
|
+
import java.io.IOException
|
|
27
|
+
import java.util.UUID
|
|
28
|
+
|
|
29
|
+
class LynxShipExpoView(context: Context, appContext: AppContext) : ExpoView(context, appContext) {
|
|
30
|
+
private val applicationContext = context.applicationContext
|
|
31
|
+
private val onReady by EventDispatcher()
|
|
32
|
+
private val onError by EventDispatcher()
|
|
33
|
+
private val onUpdate by EventDispatcher()
|
|
34
|
+
private val preferences: SharedPreferences = applicationContext.getSharedPreferences("lynxship-expo", Context.MODE_PRIVATE)
|
|
35
|
+
private val metadata: Bundle = applicationContext.applicationInfo.metaData ?: Bundle()
|
|
36
|
+
private val otaClient = createOtaClient(applicationContext)
|
|
37
|
+
private val templateProvider = object : AbsTemplateProvider() {
|
|
38
|
+
override fun loadTemplate(uri: String, callback: Callback) {
|
|
39
|
+
Thread {
|
|
40
|
+
try {
|
|
41
|
+
val bytes = otaClient?.openActiveAsset(uri) ?: readEmbeddedAsset(uri)
|
|
42
|
+
callback.onSuccess(bytes)
|
|
43
|
+
otaClient?.markLaunchSuccess()
|
|
44
|
+
} catch (error: Exception) {
|
|
45
|
+
callback.onFailed(error.message ?: "Unable to load Lynx bundle")
|
|
46
|
+
}
|
|
47
|
+
}.start()
|
|
48
|
+
}
|
|
49
|
+
}
|
|
50
|
+
private val lynxView: LynxView
|
|
51
|
+
var bundleName: String = metadata.getString("com.lynxship.expo.embeddedBundle", "main.lynx.bundle")
|
|
52
|
+
var initialData: String = ""
|
|
53
|
+
var reloadOnUpdate: Boolean = true
|
|
54
|
+
|
|
55
|
+
init {
|
|
56
|
+
initializeLynx(context)
|
|
57
|
+
lynxView = LynxViewBuilder().setTemplateProvider(templateProvider).build(applicationContext)
|
|
58
|
+
lynxView.addLynxViewClient(object : LynxViewClient() {
|
|
59
|
+
override fun onFirstScreen() {
|
|
60
|
+
try {
|
|
61
|
+
otaClient?.markLaunchSuccess()
|
|
62
|
+
} catch (error: IOException) {
|
|
63
|
+
onError(mapOf("message" to (error.message ?: "Could not record Lynx launch"), "recoverable" to true))
|
|
64
|
+
}
|
|
65
|
+
onReady(mapOf("bundle" to bundleName, "sequence" to (otaClient?.activeSequence ?: 0)))
|
|
66
|
+
}
|
|
67
|
+
})
|
|
68
|
+
addView(lynxView, LayoutParams(LayoutParams.MATCH_PARENT, LayoutParams.MATCH_PARENT))
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
override fun onAttachedToWindow() {
|
|
72
|
+
super.onAttachedToWindow()
|
|
73
|
+
try {
|
|
74
|
+
otaClient?.beginLaunch()
|
|
75
|
+
installUpdateInBackground()
|
|
76
|
+
render()
|
|
77
|
+
} catch (error: Exception) {
|
|
78
|
+
onError(mapOf("message" to (error.message ?: "Lynx startup failed")))
|
|
79
|
+
}
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
fun reload() {
|
|
83
|
+
render()
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
private fun render() {
|
|
87
|
+
try {
|
|
88
|
+
lynxView.renderTemplateUrl(bundleName, initialData)
|
|
89
|
+
} catch (error: Exception) {
|
|
90
|
+
onError(mapOf("message" to (error.message ?: "Lynx render failed")))
|
|
91
|
+
}
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
private fun installUpdateInBackground() {
|
|
95
|
+
otaClient?.checkAndInstallAsync(object : LynxShipOtaClient.Listener {
|
|
96
|
+
override fun onSuccess(updateAvailable: Boolean) {
|
|
97
|
+
if (!updateAvailable) return
|
|
98
|
+
try {
|
|
99
|
+
otaClient.activateCandidate()
|
|
100
|
+
post {
|
|
101
|
+
onUpdate(mapOf("sequence" to otaClient.activeSequence))
|
|
102
|
+
if (reloadOnUpdate) render()
|
|
103
|
+
}
|
|
104
|
+
} catch (error: Exception) {
|
|
105
|
+
emitError(error, "OTA activation failed", recoverable = true)
|
|
106
|
+
}
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
override fun onFailure(error: Exception) {
|
|
110
|
+
emitError(error, "OTA check failed", recoverable = true)
|
|
111
|
+
}
|
|
112
|
+
})
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
private fun emitError(error: Exception, fallback: String, recoverable: Boolean = false) {
|
|
116
|
+
post {
|
|
117
|
+
onError(
|
|
118
|
+
mapOf(
|
|
119
|
+
"message" to (error.message ?: fallback),
|
|
120
|
+
"recoverable" to recoverable,
|
|
121
|
+
),
|
|
122
|
+
)
|
|
123
|
+
}
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
private fun readEmbeddedAsset(path: String): ByteArray {
|
|
127
|
+
if (path.contains("..") || path.startsWith("/") || path.contains("\\")) throw IOException("Unsafe Lynx asset path")
|
|
128
|
+
applicationContext.assets.open(path).use { input ->
|
|
129
|
+
ByteArrayOutputStream().use { output ->
|
|
130
|
+
input.copyTo(output)
|
|
131
|
+
return output.toByteArray()
|
|
132
|
+
}
|
|
133
|
+
}
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
private fun createOtaClient(context: Context): LynxShipOtaClient? {
|
|
137
|
+
val endpoint = metadata.getString("com.lynxship.expo.endpoint", "")
|
|
138
|
+
val projectId = metadata.getString("com.lynxship.expo.projectId", "")
|
|
139
|
+
val runtimeVersion = metadata.getString("com.lynxship.expo.runtimeVersion", "")
|
|
140
|
+
val keyJson = metadata.getString("com.lynxship.expo.publicKeys", "{}")
|
|
141
|
+
if (endpoint.isBlank() || projectId.isBlank() || runtimeVersion.isBlank()) return null
|
|
142
|
+
return try {
|
|
143
|
+
val keys = org.json.JSONObject(keyJson).let { json ->
|
|
144
|
+
buildMap {
|
|
145
|
+
json.keys().forEach { key -> put(key, json.getString(key)) }
|
|
146
|
+
}
|
|
147
|
+
}
|
|
148
|
+
val installationId = preferences.getString("installationId", null) ?: Settings.Secure.getString(context.contentResolver, Settings.Secure.ANDROID_ID) ?: UUID.randomUUID().toString()
|
|
149
|
+
preferences.edit().putString("installationId", installationId).apply()
|
|
150
|
+
LynxShipOtaClient(
|
|
151
|
+
LynxShipOtaClient.Config(
|
|
152
|
+
File(context.filesDir, "lynxship-ota"), endpoint, projectId,
|
|
153
|
+
metadata.getString("com.lynxship.expo.channel", "production"), "android",
|
|
154
|
+
runtimeVersion, installationId, keys, { path -> readEmbeddedAsset(path) },
|
|
155
|
+
3,
|
|
156
|
+
metadata.getString("com.lynxship.expo.maxReleaseBytes", "104857600").toLong(),
|
|
157
|
+
10_000,
|
|
158
|
+
30_000,
|
|
159
|
+
),
|
|
160
|
+
)
|
|
161
|
+
} catch (error: Exception) {
|
|
162
|
+
onError(mapOf("message" to (error.message ?: "Invalid LynxShip OTA configuration")))
|
|
163
|
+
null
|
|
164
|
+
}
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
private fun initializeLynx(context: Context) {
|
|
168
|
+
synchronized(LynxShipExpoView::class.java) {
|
|
169
|
+
if (!initialized) {
|
|
170
|
+
val applicationContext = context.applicationContext
|
|
171
|
+
// Lynx's official Android integration requires these services
|
|
172
|
+
// before a LynxView is created. Expo does not give this package
|
|
173
|
+
// an application subclass, so initialize them once at the
|
|
174
|
+
// first view creation. If the host already initialized Fresco,
|
|
175
|
+
// keep that host-owned instance and continue registering Lynx's
|
|
176
|
+
// services against it.
|
|
177
|
+
val factory = PoolFactory(PoolConfig.newBuilder().build())
|
|
178
|
+
val builder = ImagePipelineConfig.newBuilder(applicationContext).setPoolFactory(factory)
|
|
179
|
+
try {
|
|
180
|
+
Fresco.initialize(applicationContext, builder.build())
|
|
181
|
+
} catch (_: IllegalStateException) {
|
|
182
|
+
// Fresco was initialized by the host application.
|
|
183
|
+
}
|
|
184
|
+
LynxServiceCenter.inst().registerService(LynxImageService.getInstance())
|
|
185
|
+
LynxServiceCenter.inst().registerService(LynxLogService)
|
|
186
|
+
LynxServiceCenter.inst().registerService(LynxHttpService)
|
|
187
|
+
LynxEnv.inst().init(applicationContext, null, null, null)
|
|
188
|
+
initialized = true
|
|
189
|
+
}
|
|
190
|
+
}
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
companion object {
|
|
194
|
+
@Volatile private var initialized = false
|
|
195
|
+
}
|
|
196
|
+
}
|
package/app.plugin.cjs
ADDED
|
@@ -0,0 +1,204 @@
|
|
|
1
|
+
const {
|
|
2
|
+
withAndroidManifest,
|
|
3
|
+
withGradleProperties,
|
|
4
|
+
withInfoPlist,
|
|
5
|
+
withPodfile,
|
|
6
|
+
withSettingsGradle,
|
|
7
|
+
} = require("@expo/config-plugins");
|
|
8
|
+
|
|
9
|
+
const MARKER = "# @lynxship/expo managed";
|
|
10
|
+
const DEFAULT_LYNX_VERSION = "auto";
|
|
11
|
+
|
|
12
|
+
function optionsFromPluginEntry(entry) {
|
|
13
|
+
if (Array.isArray(entry) && entry[0] === "@lynxship/expo")
|
|
14
|
+
return entry[1] || {};
|
|
15
|
+
return {};
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
function getOptions(config) {
|
|
19
|
+
return (
|
|
20
|
+
(config.plugins || [])
|
|
21
|
+
.map(optionsFromPluginEntry)
|
|
22
|
+
.find((value) => Object.keys(value).length > 0) || {}
|
|
23
|
+
);
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
function assertOptions(options) {
|
|
27
|
+
if (options.endpoint) {
|
|
28
|
+
const url = new URL(options.endpoint);
|
|
29
|
+
if (
|
|
30
|
+
url.protocol !== "https:" &&
|
|
31
|
+
!["localhost", "127.0.0.1"].includes(url.hostname)
|
|
32
|
+
) {
|
|
33
|
+
throw new Error(
|
|
34
|
+
"@lynxship/expo endpoint must use HTTPS outside localhost",
|
|
35
|
+
);
|
|
36
|
+
}
|
|
37
|
+
}
|
|
38
|
+
if (options.publicKeys) {
|
|
39
|
+
for (const [key, value] of Object.entries(options.publicKeys)) {
|
|
40
|
+
if (
|
|
41
|
+
!key ||
|
|
42
|
+
typeof value !== "string" ||
|
|
43
|
+
!value.includes("BEGIN PUBLIC KEY")
|
|
44
|
+
) {
|
|
45
|
+
throw new Error(
|
|
46
|
+
"@lynxship/expo publicKeys must contain PEM public keys",
|
|
47
|
+
);
|
|
48
|
+
}
|
|
49
|
+
}
|
|
50
|
+
}
|
|
51
|
+
if (
|
|
52
|
+
options.lynxVersion !== undefined &&
|
|
53
|
+
options.lynxVersion !== "auto" &&
|
|
54
|
+
options.lynxVersion !== "latest" &&
|
|
55
|
+
!/^\d+\.\d+\.\d+(?:[-+][0-9A-Za-z.-]+)?$/.test(options.lynxVersion)
|
|
56
|
+
) {
|
|
57
|
+
throw new Error(
|
|
58
|
+
"@lynxship/expo lynxVersion must be auto, latest, or an exact semver",
|
|
59
|
+
);
|
|
60
|
+
}
|
|
61
|
+
return options;
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
function lynxVersion(options) {
|
|
65
|
+
return options.lynxVersion || DEFAULT_LYNX_VERSION;
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
function gradleLynxVersion(options) {
|
|
69
|
+
const value = lynxVersion(options);
|
|
70
|
+
return value === "auto" || value === "latest" ? "latest.release" : value;
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
function addAndroidMetaData(application, name, value) {
|
|
74
|
+
application["meta-data"] = application["meta-data"] || [];
|
|
75
|
+
const existing = application["meta-data"].find(
|
|
76
|
+
(item) => item.$ && item.$["android:name"] === name,
|
|
77
|
+
);
|
|
78
|
+
if (existing) existing.$["android:value"] = value;
|
|
79
|
+
else
|
|
80
|
+
application["meta-data"].push({
|
|
81
|
+
$: { "android:name": name, "android:value": value },
|
|
82
|
+
});
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
function withLynxShipAndroidManifest(config, options) {
|
|
86
|
+
return withAndroidManifest(config, (value) => {
|
|
87
|
+
const manifest = value.modResults.manifest;
|
|
88
|
+
manifest["uses-permission"] = manifest["uses-permission"] || [];
|
|
89
|
+
if (
|
|
90
|
+
!manifest["uses-permission"].some(
|
|
91
|
+
(item) =>
|
|
92
|
+
item.$ && item.$["android:name"] === "android.permission.INTERNET",
|
|
93
|
+
)
|
|
94
|
+
) {
|
|
95
|
+
manifest["uses-permission"].push({
|
|
96
|
+
$: { "android:name": "android.permission.INTERNET" },
|
|
97
|
+
});
|
|
98
|
+
}
|
|
99
|
+
const application = manifest.application && manifest.application[0];
|
|
100
|
+
if (!application)
|
|
101
|
+
throw new Error("@lynxship/expo requires an Android application node");
|
|
102
|
+
const metadata = {
|
|
103
|
+
endpoint: options.endpoint || "",
|
|
104
|
+
projectId: options.projectId || "",
|
|
105
|
+
channel: options.channel || "production",
|
|
106
|
+
runtimeVersion: options.runtimeVersion || "",
|
|
107
|
+
embeddedBundle: options.embeddedBundle || "main.lynx.bundle",
|
|
108
|
+
publicKeys: JSON.stringify(options.publicKeys || {}),
|
|
109
|
+
maxReleaseBytes: String(options.maxReleaseBytes || 104857600),
|
|
110
|
+
};
|
|
111
|
+
for (const [key, item] of Object.entries(metadata))
|
|
112
|
+
addAndroidMetaData(application, `com.lynxship.expo.${key}`, item);
|
|
113
|
+
return value;
|
|
114
|
+
});
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
function withLynxShipInfoPlist(config, options) {
|
|
118
|
+
return withInfoPlist(config, (value) => {
|
|
119
|
+
value.modResults.LynxShipExpo = {
|
|
120
|
+
endpoint: options.endpoint || "",
|
|
121
|
+
projectId: options.projectId || "",
|
|
122
|
+
channel: options.channel || "production",
|
|
123
|
+
runtimeVersion: options.runtimeVersion || "",
|
|
124
|
+
embeddedBundle: options.embeddedBundle || "main.lynx.bundle",
|
|
125
|
+
publicKeys: options.publicKeys || {},
|
|
126
|
+
maxReleaseBytes: options.maxReleaseBytes || 104857600,
|
|
127
|
+
};
|
|
128
|
+
return value;
|
|
129
|
+
});
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
function withLynxShipGradle(config, options) {
|
|
133
|
+
return withGradleProperties(config, (value) => {
|
|
134
|
+
const properties = value.modResults;
|
|
135
|
+
const key = "LYNXSHIP_LYNX_VERSION";
|
|
136
|
+
const current = properties.find((item) => item.key === key);
|
|
137
|
+
if (current) current.value = gradleLynxVersion(options);
|
|
138
|
+
else
|
|
139
|
+
properties.push({
|
|
140
|
+
type: "property",
|
|
141
|
+
key,
|
|
142
|
+
value: gradleLynxVersion(options),
|
|
143
|
+
});
|
|
144
|
+
const androidX = properties.find(
|
|
145
|
+
(item) => item.key === "android.useAndroidX",
|
|
146
|
+
);
|
|
147
|
+
if (androidX) androidX.value = "true";
|
|
148
|
+
else
|
|
149
|
+
properties.push({
|
|
150
|
+
type: "property",
|
|
151
|
+
key: "android.useAndroidX",
|
|
152
|
+
value: "true",
|
|
153
|
+
});
|
|
154
|
+
return value;
|
|
155
|
+
});
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
function withLynxShipAndroidSdk(config) {
|
|
159
|
+
return withSettingsGradle(config, (value) => {
|
|
160
|
+
const settings = value.modResults.contents;
|
|
161
|
+
if (settings.includes("@lynxship/expo managed sdk")) return value;
|
|
162
|
+
value.modResults.contents = `${settings.trimEnd()}\n\n// @lynxship/expo managed sdk\ninclude ':lynxship-sdk-android'\nproject(':lynxship-sdk-android').projectDir = new File(rootDir, '../node_modules/@lynxship/sdk-android')\n`;
|
|
163
|
+
return value;
|
|
164
|
+
});
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
function withLynxShipPodfile(config, options) {
|
|
168
|
+
return withPodfile(config, (value) => {
|
|
169
|
+
const podfile = value.modResults.contents;
|
|
170
|
+
if (podfile.includes(MARKER)) return value;
|
|
171
|
+
const additions = [
|
|
172
|
+
` ${MARKER}`,
|
|
173
|
+
...(lynxVersion(options) === "auto" || lynxVersion(options) === "latest"
|
|
174
|
+
? []
|
|
175
|
+
: [` ENV['LYNXSHIP_LYNX_VERSION'] = '${lynxVersion(options)}'`]),
|
|
176
|
+
" pod 'LynxShipOta', :path => '../node_modules/@lynxship/sdk-ios'",
|
|
177
|
+
` ${MARKER} end`,
|
|
178
|
+
].join("\n");
|
|
179
|
+
const target = podfile.search(/^target\s+['\"][^'\"]+['\"]\s+do\s*$/m);
|
|
180
|
+
if (target < 0)
|
|
181
|
+
throw new Error("@lynxship/expo could not find an iOS Podfile target");
|
|
182
|
+
const lineEnd = podfile.indexOf("\n", target);
|
|
183
|
+
value.modResults.contents = `${podfile.slice(0, lineEnd + 1)}${additions}\n${podfile.slice(lineEnd + 1)}`;
|
|
184
|
+
return value;
|
|
185
|
+
});
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
function withLynxShipExpo(config, props = {}) {
|
|
189
|
+
const options = assertOptions({ ...getOptions(config), ...props });
|
|
190
|
+
return withLynxShipPodfile(
|
|
191
|
+
withLynxShipAndroidSdk(
|
|
192
|
+
withLynxShipGradle(
|
|
193
|
+
withLynxShipInfoPlist(
|
|
194
|
+
withLynxShipAndroidManifest(config, options),
|
|
195
|
+
options,
|
|
196
|
+
),
|
|
197
|
+
options,
|
|
198
|
+
),
|
|
199
|
+
),
|
|
200
|
+
options,
|
|
201
|
+
);
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
module.exports = withLynxShipExpo;
|
package/app.plugin.js
ADDED
|
@@ -0,0 +1,4 @@
|
|
|
1
|
+
// Expo resolves package config plugins through this conventional entry point.
|
|
2
|
+
// Keep the implementation CommonJS so it can be evaluated by Expo's Node.js
|
|
3
|
+
// config phase regardless of the consuming app's module configuration.
|
|
4
|
+
module.exports = require("./app.plugin.cjs");
|
package/dist/config.d.ts
ADDED
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
export interface LynxShipExpoConfig {
|
|
2
|
+
endpoint?: string;
|
|
3
|
+
projectId?: string;
|
|
4
|
+
channel?: string;
|
|
5
|
+
runtimeVersion?: string;
|
|
6
|
+
publicKeys?: Record<string, string>;
|
|
7
|
+
embeddedBundle?: string;
|
|
8
|
+
lynxVersion?: string;
|
|
9
|
+
maxReleaseBytes?: number;
|
|
10
|
+
}
|
|
11
|
+
export declare function validateLynxShipExpoConfig(value?: LynxShipExpoConfig): LynxShipExpoConfig;
|
|
12
|
+
//# sourceMappingURL=config.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"config.d.ts","sourceRoot":"","sources":["../src/config.ts"],"names":[],"mappings":"AAAA,MAAM,WAAW,kBAAkB;IACjC,QAAQ,CAAC,EAAE,MAAM,CAAC;IAClB,SAAS,CAAC,EAAE,MAAM,CAAC;IACnB,OAAO,CAAC,EAAE,MAAM,CAAC;IACjB,cAAc,CAAC,EAAE,MAAM,CAAC;IACxB,UAAU,CAAC,EAAE,MAAM,CAAC,MAAM,EAAE,MAAM,CAAC,CAAC;IACpC,cAAc,CAAC,EAAE,MAAM,CAAC;IACxB,WAAW,CAAC,EAAE,MAAM,CAAC;IACrB,eAAe,CAAC,EAAE,MAAM,CAAC;CAC1B;AAKD,wBAAgB,0BAA0B,CACxC,KAAK,GAAE,kBAAuB,GAC7B,kBAAkB,CAkCpB"}
|
package/dist/config.js
ADDED
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
+
exports.validateLynxShipExpoConfig = validateLynxShipExpoConfig;
|
|
4
|
+
/** Let the native package manager resolve the current Lynx SDK by default. */
|
|
5
|
+
const DEFAULT_LYNX_VERSION = "auto";
|
|
6
|
+
function validateLynxShipExpoConfig(value = {}) {
|
|
7
|
+
if (value.endpoint !== undefined) {
|
|
8
|
+
const url = new URL(value.endpoint);
|
|
9
|
+
if (url.protocol !== "https:" && !isLocalDevelopment(url.hostname))
|
|
10
|
+
throw new Error("LynxShip Expo endpoint must use HTTPS outside localhost");
|
|
11
|
+
}
|
|
12
|
+
for (const [key, publicKey] of Object.entries(value.publicKeys ?? {})) {
|
|
13
|
+
if (!key.trim() || !publicKey.includes("BEGIN PUBLIC KEY"))
|
|
14
|
+
throw new Error("LynxShip Expo publicKeys must contain PEM public keys");
|
|
15
|
+
}
|
|
16
|
+
if (value.maxReleaseBytes !== undefined) {
|
|
17
|
+
if (!Number.isInteger(value.maxReleaseBytes) || value.maxReleaseBytes < 1)
|
|
18
|
+
throw new Error("LynxShip Expo maxReleaseBytes must be a positive integer");
|
|
19
|
+
}
|
|
20
|
+
if (value.lynxVersion !== undefined &&
|
|
21
|
+
value.lynxVersion !== "auto" &&
|
|
22
|
+
value.lynxVersion !== "latest" &&
|
|
23
|
+
!/^\d+\.\d+\.\d+(?:[-+][0-9A-Za-z.-]+)?$/.test(value.lynxVersion)) {
|
|
24
|
+
throw new Error("LynxShip Expo lynxVersion must be auto, latest, or an exact semver");
|
|
25
|
+
}
|
|
26
|
+
return {
|
|
27
|
+
...value,
|
|
28
|
+
channel: value.channel ?? "production",
|
|
29
|
+
embeddedBundle: value.embeddedBundle ?? "main.lynx.bundle",
|
|
30
|
+
lynxVersion: value.lynxVersion ?? DEFAULT_LYNX_VERSION,
|
|
31
|
+
};
|
|
32
|
+
}
|
|
33
|
+
function isLocalDevelopment(hostname) {
|
|
34
|
+
return hostname === "localhost" || hostname === "127.0.0.1";
|
|
35
|
+
}
|
package/dist/index.d.ts
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
import { type ComponentType } from "react";
|
|
2
|
+
import type { ViewProps } from "react-native";
|
|
3
|
+
import { type LynxShipExpoConfig } from "./config.js";
|
|
4
|
+
export type { LynxShipExpoConfig } from "./config.js";
|
|
5
|
+
export { validateLynxShipExpoConfig } from "./config.js";
|
|
6
|
+
export interface LynxViewProps extends ViewProps {
|
|
7
|
+
/** Bundle name resolved by the native Lynx template provider. */
|
|
8
|
+
bundle?: string;
|
|
9
|
+
/** Optional initial data passed to Lynx when the view is rendered. */
|
|
10
|
+
initialData?: string;
|
|
11
|
+
/** Requests a fresh render after an OTA candidate has been activated. */
|
|
12
|
+
reloadOnUpdate?: boolean;
|
|
13
|
+
}
|
|
14
|
+
/**
|
|
15
|
+
* A LynxView that can be placed anywhere in an Expo/React Native view tree.
|
|
16
|
+
* Native OTA configuration is supplied by the LynxShip config plugin.
|
|
17
|
+
*/
|
|
18
|
+
export declare function LynxView(props: LynxViewProps): unknown;
|
|
19
|
+
export declare const LynxShipView: ComponentType<LynxViewProps>;
|
|
20
|
+
export declare function defineLynxShipExpoConfig(config: LynxShipExpoConfig): LynxShipExpoConfig;
|
|
21
|
+
//# sourceMappingURL=index.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":"AAAA,OAAO,EAAiB,KAAK,aAAa,EAAE,MAAM,OAAO,CAAC;AAE1D,OAAO,KAAK,EAAE,SAAS,EAAE,MAAM,cAAc,CAAC;AAC9C,OAAO,EAEL,KAAK,kBAAkB,EACxB,MAAM,aAAa,CAAC;AAErB,YAAY,EAAE,kBAAkB,EAAE,MAAM,aAAa,CAAC;AAEtD,OAAO,EAAE,0BAA0B,EAAE,MAAM,aAAa,CAAC;AAEzD,MAAM,WAAW,aAAc,SAAQ,SAAS;IAC9C,iEAAiE;IACjE,MAAM,CAAC,EAAE,MAAM,CAAC;IAChB,sEAAsE;IACtE,WAAW,CAAC,EAAE,MAAM,CAAC;IACrB,yEAAyE;IACzE,cAAc,CAAC,EAAE,OAAO,CAAC;CAC1B;AAID;;;GAGG;AACH,wBAAgB,QAAQ,CAAC,KAAK,EAAE,aAAa,GAAG,OAAO,CAOtD;AAED,eAAO,MAAM,YAAY,EAAE,aAAa,CAAC,aAAa,CAAY,CAAC;AAEnE,wBAAgB,wBAAwB,CACtC,MAAM,EAAE,kBAAkB,GACzB,kBAAkB,CAEpB"}
|
package/dist/index.js
ADDED
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
+
exports.LynxShipView = exports.validateLynxShipExpoConfig = void 0;
|
|
4
|
+
exports.LynxView = LynxView;
|
|
5
|
+
exports.defineLynxShipExpoConfig = defineLynxShipExpoConfig;
|
|
6
|
+
const react_1 = require("react");
|
|
7
|
+
const expo_modules_core_1 = require("expo-modules-core");
|
|
8
|
+
const config_js_1 = require("./config.js");
|
|
9
|
+
var config_js_2 = require("./config.js");
|
|
10
|
+
Object.defineProperty(exports, "validateLynxShipExpoConfig", { enumerable: true, get: function () { return config_js_2.validateLynxShipExpoConfig; } });
|
|
11
|
+
const NativeLynxView = (0, expo_modules_core_1.requireNativeViewManager)("LynxShip");
|
|
12
|
+
/**
|
|
13
|
+
* A LynxView that can be placed anywhere in an Expo/React Native view tree.
|
|
14
|
+
* Native OTA configuration is supplied by the LynxShip config plugin.
|
|
15
|
+
*/
|
|
16
|
+
function LynxView(props) {
|
|
17
|
+
return (0, react_1.createElement)(NativeLynxView, {
|
|
18
|
+
bundle: props.bundle ?? "main.lynx.bundle",
|
|
19
|
+
initialData: props.initialData ?? "",
|
|
20
|
+
reloadOnUpdate: props.reloadOnUpdate ?? true,
|
|
21
|
+
...props,
|
|
22
|
+
});
|
|
23
|
+
}
|
|
24
|
+
exports.LynxShipView = LynxView;
|
|
25
|
+
function defineLynxShipExpoConfig(config) {
|
|
26
|
+
return (0, config_js_1.validateLynxShipExpoConfig)(config);
|
|
27
|
+
}
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
import ExpoModulesCore
|
|
2
|
+
|
|
3
|
+
public class LynxShipExpoModule: Module {
|
|
4
|
+
public func definition() -> ModuleDefinition {
|
|
5
|
+
Name("LynxShip")
|
|
6
|
+
View(LynxShipExpoView.self) {
|
|
7
|
+
Events("onReady", "onError", "onUpdate")
|
|
8
|
+
Prop("bundle") { (view: LynxShipExpoView, value: String?) in
|
|
9
|
+
view.bundleName = value ?? "main.lynx.bundle"
|
|
10
|
+
}
|
|
11
|
+
Prop("initialData") { (view: LynxShipExpoView, value: String?) in
|
|
12
|
+
view.initialData = value ?? ""
|
|
13
|
+
}
|
|
14
|
+
Prop("reloadOnUpdate") { (view: LynxShipExpoView, value: Bool?) in
|
|
15
|
+
view.reloadOnUpdate = value ?? true
|
|
16
|
+
}
|
|
17
|
+
AsyncFunction("reload") { (view: LynxShipExpoView) in
|
|
18
|
+
try view.reload()
|
|
19
|
+
}
|
|
20
|
+
}
|
|
21
|
+
}
|
|
22
|
+
}
|
|
@@ -0,0 +1,150 @@
|
|
|
1
|
+
import ExpoModulesCore
|
|
2
|
+
import Foundation
|
|
3
|
+
import Lynx
|
|
4
|
+
import LynxShipOta
|
|
5
|
+
import UIKit
|
|
6
|
+
|
|
7
|
+
private final class LynxShipTemplateProvider: NSObject, LynxTemplateProvider {
|
|
8
|
+
private let load: (String) throws -> Data
|
|
9
|
+
private let onLoaded: () -> Void
|
|
10
|
+
|
|
11
|
+
init(load: @escaping (String) throws -> Data, onLoaded: @escaping () -> Void) {
|
|
12
|
+
self.load = load
|
|
13
|
+
self.onLoaded = onLoaded
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
func loadTemplate(withUrl url: String!, onComplete callback: LynxTemplateLoadBlock!) {
|
|
17
|
+
do {
|
|
18
|
+
callback(load(url), nil)
|
|
19
|
+
onLoaded()
|
|
20
|
+
} catch {
|
|
21
|
+
callback(nil, error)
|
|
22
|
+
}
|
|
23
|
+
}
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
public final class LynxShipExpoView: ExpoView {
|
|
27
|
+
let onReady = EventDispatcher()
|
|
28
|
+
let onError = EventDispatcher()
|
|
29
|
+
let onUpdate = EventDispatcher()
|
|
30
|
+
|
|
31
|
+
private lazy var templateProvider = LynxShipTemplateProvider(
|
|
32
|
+
load: { [weak self] path in
|
|
33
|
+
guard let self else { throw NSError(domain: "LynxShip", code: 1, userInfo: [NSLocalizedDescriptionKey: "Lynx view was released"]) }
|
|
34
|
+
return try self.otaClient?.openActiveAsset(path) ?? self.readEmbeddedAsset(path)
|
|
35
|
+
},
|
|
36
|
+
onLoaded: { [weak self] in
|
|
37
|
+
guard let self else { return }
|
|
38
|
+
try? self.otaClient?.markLaunchSuccess()
|
|
39
|
+
DispatchQueue.main.async {
|
|
40
|
+
self.onReady(["bundle": self.bundleName, "sequence": self.otaClient?.activeSequence ?? 0])
|
|
41
|
+
}
|
|
42
|
+
}
|
|
43
|
+
)
|
|
44
|
+
|
|
45
|
+
private lazy var lynxView: LynxView = {
|
|
46
|
+
LynxView { builder in
|
|
47
|
+
builder.config = LynxConfig(provider: self.templateProvider)
|
|
48
|
+
builder.fontScale = 1.0
|
|
49
|
+
}
|
|
50
|
+
}()
|
|
51
|
+
|
|
52
|
+
private var otaClient: LynxShipOtaClient?
|
|
53
|
+
private var hasRendered = false
|
|
54
|
+
var bundleName = "main.lynx.bundle"
|
|
55
|
+
var initialData = ""
|
|
56
|
+
var reloadOnUpdate = true
|
|
57
|
+
|
|
58
|
+
public required init(appContext: AppContext? = nil) {
|
|
59
|
+
super.init(appContext: appContext)
|
|
60
|
+
LynxEnv.sharedInstance()
|
|
61
|
+
otaClient = makeOtaClient()
|
|
62
|
+
addSubview(lynxView)
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
public override func layoutSubviews() {
|
|
66
|
+
super.layoutSubviews()
|
|
67
|
+
lynxView.frame = bounds
|
|
68
|
+
lynxView.preferredLayoutWidth = bounds.width
|
|
69
|
+
lynxView.preferredLayoutHeight = bounds.height
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
public override func didMoveToWindow() {
|
|
73
|
+
super.didMoveToWindow()
|
|
74
|
+
guard window != nil, !hasRendered else { return }
|
|
75
|
+
do {
|
|
76
|
+
try otaClient?.beginLaunch()
|
|
77
|
+
render()
|
|
78
|
+
checkForUpdate()
|
|
79
|
+
} catch {
|
|
80
|
+
onError(["message": error.localizedDescription])
|
|
81
|
+
}
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
func reload() throws {
|
|
85
|
+
render()
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
private func render() {
|
|
89
|
+
lynxView.loadTemplate(fromURL: bundleName, initData: initialData)
|
|
90
|
+
hasRendered = true
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
private func checkForUpdate() {
|
|
94
|
+
Task { [weak self] in
|
|
95
|
+
guard let self else { return }
|
|
96
|
+
do {
|
|
97
|
+
guard let otaClient else { return }
|
|
98
|
+
guard try await otaClient.checkAndInstall() else { return }
|
|
99
|
+
try otaClient.activateCandidate()
|
|
100
|
+
await MainActor.run {
|
|
101
|
+
self.onUpdate(["sequence": otaClient.activeSequence])
|
|
102
|
+
if self.reloadOnUpdate { self.render() }
|
|
103
|
+
}
|
|
104
|
+
} catch {
|
|
105
|
+
await MainActor.run {
|
|
106
|
+
self.onError(["message": error.localizedDescription, "recoverable": true])
|
|
107
|
+
}
|
|
108
|
+
}
|
|
109
|
+
}
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
private func readEmbeddedAsset(_ path: String) throws -> Data {
|
|
113
|
+
guard !path.isEmpty, !path.hasPrefix("/"), !path.contains(".."), !path.contains("\\") else {
|
|
114
|
+
throw NSError(domain: "LynxShip", code: 2, userInfo: [NSLocalizedDescriptionKey: "Unsafe Lynx asset path"])
|
|
115
|
+
}
|
|
116
|
+
guard let url = Bundle.main.url(forResource: path, withExtension: nil) ?? Bundle.main.url(forResource: path, withExtension: "bundle") else {
|
|
117
|
+
throw NSError(domain: "LynxShip", code: 3, userInfo: [NSLocalizedDescriptionKey: "Embedded Lynx bundle not found: \(path)"])
|
|
118
|
+
}
|
|
119
|
+
return try Data(contentsOf: url)
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
private func makeOtaClient() -> LynxShipOtaClient? {
|
|
123
|
+
guard let value = Bundle.main.object(forInfoDictionaryKey: "LynxShipExpo") as? [String: Any],
|
|
124
|
+
let endpointString = value["endpoint"] as? String, !endpointString.isEmpty,
|
|
125
|
+
let endpoint = URL(string: endpointString),
|
|
126
|
+
let projectId = value["projectId"] as? String, !projectId.isEmpty,
|
|
127
|
+
let runtimeVersion = value["runtimeVersion"] as? String, !runtimeVersion.isEmpty else { return nil }
|
|
128
|
+
do {
|
|
129
|
+
let keys = value["publicKeys"] as? [String: String] ?? [:]
|
|
130
|
+
let channel = value["channel"] as? String ?? "production"
|
|
131
|
+
let storage = FileManager.default.urls(for: .applicationSupportDirectory, in: .userDomainMask)[0].appendingPathComponent("lynxship-ota", isDirectory: true)
|
|
132
|
+
let installationId = UIDevice.current.identifierForVendor?.uuidString ?? UUID().uuidString
|
|
133
|
+
let configuration = try LynxShipOtaClient.Configuration(
|
|
134
|
+
storageDirectory: storage,
|
|
135
|
+
endpoint: endpoint,
|
|
136
|
+
projectID: projectId,
|
|
137
|
+
channel: channel,
|
|
138
|
+
runtimeVersion: runtimeVersion,
|
|
139
|
+
installationID: installationId,
|
|
140
|
+
publicKeys: keys,
|
|
141
|
+
embeddedAssets: readEmbeddedAsset,
|
|
142
|
+
maxReleaseBytes: value["maxReleaseBytes"] as? Int ?? 100 * 1024 * 1024
|
|
143
|
+
)
|
|
144
|
+
return try LynxShipOtaClient(configuration: configuration)
|
|
145
|
+
} catch {
|
|
146
|
+
onError(["message": error.localizedDescription])
|
|
147
|
+
return nil
|
|
148
|
+
}
|
|
149
|
+
}
|
|
150
|
+
}
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
Pod::Spec.new do |s|
|
|
2
|
+
s.name = 'LynxShipExpo'
|
|
3
|
+
s.version = '0.1.0'
|
|
4
|
+
s.summary = 'Expo native LynxView integration for LynxShip.'
|
|
5
|
+
s.license = { :type => 'MIT' }
|
|
6
|
+
s.author = { 'LynxShip' => 'opensource@lynxship.dev' }
|
|
7
|
+
s.source = { :path => '.' }
|
|
8
|
+
s.source_files = 'ios/**/*.{h,m,mm,swift}'
|
|
9
|
+
s.platform = :ios, '15.0'
|
|
10
|
+
s.swift_version = '5.9'
|
|
11
|
+
s.dependency 'ExpoModulesCore'
|
|
12
|
+
s.dependency 'LynxShipOta'
|
|
13
|
+
lynx_version = ENV['LYNXSHIP_LYNX_VERSION']
|
|
14
|
+
if lynx_version && !lynx_version.empty? && lynx_version != 'auto' && lynx_version != 'latest'
|
|
15
|
+
s.dependency 'Lynx', lynx_version, :subspecs => ['Framework']
|
|
16
|
+
s.dependency 'PrimJS', lynx_version, :subspecs => ['quickjs', 'napi']
|
|
17
|
+
s.dependency 'LynxService', lynx_version, :subspecs => ['Image', 'Log', 'Http']
|
|
18
|
+
else
|
|
19
|
+
s.dependency 'Lynx', :subspecs => ['Framework']
|
|
20
|
+
s.dependency 'PrimJS', :subspecs => ['quickjs', 'napi']
|
|
21
|
+
s.dependency 'LynxService', :subspecs => ['Image', 'Log', 'Http']
|
|
22
|
+
end
|
|
23
|
+
s.dependency 'SDWebImage', '5.15.5'
|
|
24
|
+
s.dependency 'SDWebImageWebPCoder', '0.11.0'
|
|
25
|
+
end
|
package/package.json
ADDED
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@lynxship/expo",
|
|
3
|
+
"version": "0.1.0",
|
|
4
|
+
"description": "Expo native LynxView with LynxShip OTA delivery and cache support.",
|
|
5
|
+
"license": "MIT",
|
|
6
|
+
"main": "dist/index.js",
|
|
7
|
+
"types": "dist/index.d.ts",
|
|
8
|
+
"files": [
|
|
9
|
+
"dist",
|
|
10
|
+
"android",
|
|
11
|
+
"ios",
|
|
12
|
+
"src",
|
|
13
|
+
"app.plugin.js",
|
|
14
|
+
"app.plugin.cjs",
|
|
15
|
+
"expo-module.config.json",
|
|
16
|
+
"lynxship-expo.podspec",
|
|
17
|
+
"README.md"
|
|
18
|
+
],
|
|
19
|
+
"repository": {
|
|
20
|
+
"type": "git",
|
|
21
|
+
"url": "git+https://github.com/p0ulsh3n/lynx-ship.git",
|
|
22
|
+
"directory": "packages/expo"
|
|
23
|
+
},
|
|
24
|
+
"bugs": {
|
|
25
|
+
"url": "https://github.com/p0ulsh3n/lynx-ship/issues"
|
|
26
|
+
},
|
|
27
|
+
"homepage": "https://github.com/p0ulsh3n/lynx-ship/tree/main/packages/expo#readme",
|
|
28
|
+
"keywords": [
|
|
29
|
+
"lynxjs",
|
|
30
|
+
"lynx",
|
|
31
|
+
"expo",
|
|
32
|
+
"react-native",
|
|
33
|
+
"ota"
|
|
34
|
+
],
|
|
35
|
+
"engines": {
|
|
36
|
+
"node": ">=20.19.0"
|
|
37
|
+
},
|
|
38
|
+
"publishConfig": {
|
|
39
|
+
"access": "public"
|
|
40
|
+
},
|
|
41
|
+
"peerDependencies": {
|
|
42
|
+
"@expo/config-plugins": ">=9",
|
|
43
|
+
"expo": ">=52",
|
|
44
|
+
"expo-modules-core": ">=1",
|
|
45
|
+
"react": ">=18",
|
|
46
|
+
"react-native": ">=0.76"
|
|
47
|
+
},
|
|
48
|
+
"dependencies": {
|
|
49
|
+
"@lynxship/sdk-android": "0.1.0",
|
|
50
|
+
"@lynxship/sdk-ios": "0.1.0"
|
|
51
|
+
},
|
|
52
|
+
"scripts": {
|
|
53
|
+
"build": "tsc -b tsconfig.json",
|
|
54
|
+
"typecheck": "tsc -b tsconfig.json --pretty false"
|
|
55
|
+
}
|
|
56
|
+
}
|
package/src/config.ts
ADDED
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
export interface LynxShipExpoConfig {
|
|
2
|
+
endpoint?: string;
|
|
3
|
+
projectId?: string;
|
|
4
|
+
channel?: string;
|
|
5
|
+
runtimeVersion?: string;
|
|
6
|
+
publicKeys?: Record<string, string>;
|
|
7
|
+
embeddedBundle?: string;
|
|
8
|
+
lynxVersion?: string;
|
|
9
|
+
maxReleaseBytes?: number;
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
/** Let the native package manager resolve the current Lynx SDK by default. */
|
|
13
|
+
const DEFAULT_LYNX_VERSION = "auto";
|
|
14
|
+
|
|
15
|
+
export function validateLynxShipExpoConfig(
|
|
16
|
+
value: LynxShipExpoConfig = {},
|
|
17
|
+
): LynxShipExpoConfig {
|
|
18
|
+
if (value.endpoint !== undefined) {
|
|
19
|
+
const url = new URL(value.endpoint);
|
|
20
|
+
if (url.protocol !== "https:" && !isLocalDevelopment(url.hostname))
|
|
21
|
+
throw new Error(
|
|
22
|
+
"LynxShip Expo endpoint must use HTTPS outside localhost",
|
|
23
|
+
);
|
|
24
|
+
}
|
|
25
|
+
for (const [key, publicKey] of Object.entries(value.publicKeys ?? {})) {
|
|
26
|
+
if (!key.trim() || !publicKey.includes("BEGIN PUBLIC KEY"))
|
|
27
|
+
throw new Error("LynxShip Expo publicKeys must contain PEM public keys");
|
|
28
|
+
}
|
|
29
|
+
if (value.maxReleaseBytes !== undefined) {
|
|
30
|
+
if (!Number.isInteger(value.maxReleaseBytes) || value.maxReleaseBytes < 1)
|
|
31
|
+
throw new Error(
|
|
32
|
+
"LynxShip Expo maxReleaseBytes must be a positive integer",
|
|
33
|
+
);
|
|
34
|
+
}
|
|
35
|
+
if (
|
|
36
|
+
value.lynxVersion !== undefined &&
|
|
37
|
+
value.lynxVersion !== "auto" &&
|
|
38
|
+
value.lynxVersion !== "latest" &&
|
|
39
|
+
!/^\d+\.\d+\.\d+(?:[-+][0-9A-Za-z.-]+)?$/.test(value.lynxVersion)
|
|
40
|
+
) {
|
|
41
|
+
throw new Error(
|
|
42
|
+
"LynxShip Expo lynxVersion must be auto, latest, or an exact semver",
|
|
43
|
+
);
|
|
44
|
+
}
|
|
45
|
+
return {
|
|
46
|
+
...value,
|
|
47
|
+
channel: value.channel ?? "production",
|
|
48
|
+
embeddedBundle: value.embeddedBundle ?? "main.lynx.bundle",
|
|
49
|
+
lynxVersion: value.lynxVersion ?? DEFAULT_LYNX_VERSION,
|
|
50
|
+
};
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
function isLocalDevelopment(hostname: string): boolean {
|
|
54
|
+
return hostname === "localhost" || hostname === "127.0.0.1";
|
|
55
|
+
}
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
declare module "react" {
|
|
2
|
+
export type ComponentType<Props = Record<string, unknown>> = (
|
|
3
|
+
props: Props,
|
|
4
|
+
) => unknown;
|
|
5
|
+
export function createElement(
|
|
6
|
+
type: unknown,
|
|
7
|
+
props: Record<string, unknown> | null,
|
|
8
|
+
): unknown;
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
declare module "react-native" {
|
|
12
|
+
export interface ViewProps {
|
|
13
|
+
accessibilityLabel?: string;
|
|
14
|
+
style?: unknown;
|
|
15
|
+
testID?: string;
|
|
16
|
+
}
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
declare module "expo-modules-core" {
|
|
20
|
+
export function requireNativeViewManager<Props = Record<string, unknown>>(
|
|
21
|
+
moduleName: string,
|
|
22
|
+
): (props: Props) => unknown;
|
|
23
|
+
}
|
package/src/index.ts
ADDED
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
import { createElement, type ComponentType } from "react";
|
|
2
|
+
import { requireNativeViewManager } from "expo-modules-core";
|
|
3
|
+
import type { ViewProps } from "react-native";
|
|
4
|
+
import {
|
|
5
|
+
validateLynxShipExpoConfig,
|
|
6
|
+
type LynxShipExpoConfig,
|
|
7
|
+
} from "./config.js";
|
|
8
|
+
|
|
9
|
+
export type { LynxShipExpoConfig } from "./config.js";
|
|
10
|
+
|
|
11
|
+
export { validateLynxShipExpoConfig } from "./config.js";
|
|
12
|
+
|
|
13
|
+
export interface LynxViewProps extends ViewProps {
|
|
14
|
+
/** Bundle name resolved by the native Lynx template provider. */
|
|
15
|
+
bundle?: string;
|
|
16
|
+
/** Optional initial data passed to Lynx when the view is rendered. */
|
|
17
|
+
initialData?: string;
|
|
18
|
+
/** Requests a fresh render after an OTA candidate has been activated. */
|
|
19
|
+
reloadOnUpdate?: boolean;
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
const NativeLynxView = requireNativeViewManager<LynxViewProps>("LynxShip");
|
|
23
|
+
|
|
24
|
+
/**
|
|
25
|
+
* A LynxView that can be placed anywhere in an Expo/React Native view tree.
|
|
26
|
+
* Native OTA configuration is supplied by the LynxShip config plugin.
|
|
27
|
+
*/
|
|
28
|
+
export function LynxView(props: LynxViewProps): unknown {
|
|
29
|
+
return createElement(NativeLynxView, {
|
|
30
|
+
bundle: props.bundle ?? "main.lynx.bundle",
|
|
31
|
+
initialData: props.initialData ?? "",
|
|
32
|
+
reloadOnUpdate: props.reloadOnUpdate ?? true,
|
|
33
|
+
...props,
|
|
34
|
+
});
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
export const LynxShipView: ComponentType<LynxViewProps> = LynxView;
|
|
38
|
+
|
|
39
|
+
export function defineLynxShipExpoConfig(
|
|
40
|
+
config: LynxShipExpoConfig,
|
|
41
|
+
): LynxShipExpoConfig {
|
|
42
|
+
return validateLynxShipExpoConfig(config);
|
|
43
|
+
}
|