@tamer4lynx/tamer-dev-client 0.0.1
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 +31 -0
- package/android/build.gradle.kts +34 -0
- package/android/src/main/AndroidManifest.xml +3 -0
- package/android/src/main/kotlin/com/nanofuxion/tamerdevclient/DevClientModule.kt +311 -0
- package/android/src/main/kotlin/com/nanofuxion/tamerdevclient/TamerRelogLogService.kt +141 -0
- package/android/src/main/kotlin/com/nanofuxion/tamerdevclient/nsd/NsdDiscovery.kt +138 -0
- package/android/src/main/kotlin/com/nanofuxion/tamerdevclient/nsd/NsdServiceInfoExtensions.kt +26 -0
- package/android/src/main/kotlin/com/nanofuxion/tamerdevclient/nsd/ResolveService.kt +28 -0
- package/android/templates/DevClientManager.kt +49 -0
- package/android/templates/DevServerPrefs.kt +44 -0
- package/android/templates/PortraitCaptureActivity.kt +5 -0
- package/android/templates/ProjectActivity.kt +112 -0
- package/dist/dev-client.lynx.bundle +0 -0
- package/ios/tamerdevclient/tamerdevclient/Classes/DevClientModule.swift +298 -0
- package/ios/tamerdevclient/tamerdevclient/Classes/TamerRelogLogService.swift +198 -0
- package/ios/tamerdevclient/tamerdevclient.podspec +16 -0
- package/ios/templates/DevClientManager.swift +51 -0
- package/ios/templates/DevLauncherViewController.swift +102 -0
- package/ios/templates/DevTemplateProvider.swift +66 -0
- package/ios/templates/LynxInitProcessor.swift +37 -0
- package/ios/templates/ProjectViewController.swift +105 -0
- package/ios/templates/QRScannerViewController.swift +83 -0
- package/lynx.config.ts +19 -0
- package/package.json +39 -0
package/README.md
ADDED
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
# tamer-dev-client
|
|
2
|
+
|
|
3
|
+
Native dev client module for Tamer4Lynx — QR scan, discovery, URL persistence, reload bridge, embedded dev launcher UI.
|
|
4
|
+
|
|
5
|
+
## Installation
|
|
6
|
+
|
|
7
|
+
```bash
|
|
8
|
+
npm install @tamer4lynx/tamer-dev-client
|
|
9
|
+
```
|
|
10
|
+
|
|
11
|
+
Add to your app's dependencies and run `t4l link`. Used by **tamer-dev-app** for the dev launcher experience.
|
|
12
|
+
|
|
13
|
+
## Usage
|
|
14
|
+
|
|
15
|
+
The dev client provides:
|
|
16
|
+
|
|
17
|
+
- **Discovery** — Find dev servers on the local network
|
|
18
|
+
- **Connect** — Enter URL or scan QR code to connect
|
|
19
|
+
- **Recent** — List of recently used dev server URLs
|
|
20
|
+
- **Reload** — Reload the Lynx bundle
|
|
21
|
+
- **Compatibility check** — Validates native modules between app and project
|
|
22
|
+
|
|
23
|
+
When building the dev app (`t4l build-dev-app`), the dev client UI is embedded and the Lynx bundle is loaded from the connected dev server.
|
|
24
|
+
|
|
25
|
+
## Dependencies
|
|
26
|
+
|
|
27
|
+
Requires: `tamer-app-shell`, `tamer-insets`, `tamer-system-ui`, `tamer-plugin`, `tamer-router`, `react-router`, `@lynx-js/react`.
|
|
28
|
+
|
|
29
|
+
## Platform
|
|
30
|
+
|
|
31
|
+
Uses **lynx.ext.json**. Run `t4l link` after adding to your app.
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
plugins {
|
|
2
|
+
id("com.android.library")
|
|
3
|
+
id("org.jetbrains.kotlin.android")
|
|
4
|
+
}
|
|
5
|
+
|
|
6
|
+
android {
|
|
7
|
+
namespace = "com.nanofuxion.tamerdevclient"
|
|
8
|
+
compileSdk = 35
|
|
9
|
+
|
|
10
|
+
defaultConfig {
|
|
11
|
+
minSdk = 28
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
compileOptions {
|
|
15
|
+
sourceCompatibility = JavaVersion.VERSION_17
|
|
16
|
+
targetCompatibility = JavaVersion.VERSION_17
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
kotlinOptions {
|
|
20
|
+
jvmTarget = "17"
|
|
21
|
+
}
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
dependencies {
|
|
25
|
+
implementation("org.lynxsdk.lynx:lynx-service-log:3.3.1")
|
|
26
|
+
implementation("androidx.activity:activity-ktx:1.8.2")
|
|
27
|
+
implementation("org.jetbrains.kotlinx:kotlinx-coroutines-android:1.7.3")
|
|
28
|
+
implementation("org.lynxsdk.lynx:lynx:3.3.1")
|
|
29
|
+
implementation("org.lynxsdk.lynx:lynx-jssdk:3.3.1")
|
|
30
|
+
implementation("org.lynxsdk.lynx:lynx-trace:3.3.1")
|
|
31
|
+
implementation("com.squareup.okhttp3:okhttp:4.9.0")
|
|
32
|
+
implementation("com.journeyapps:zxing-android-embedded:4.3.0")
|
|
33
|
+
implementation("androidx.appcompat:appcompat:1.6.1")
|
|
34
|
+
}
|
|
@@ -0,0 +1,311 @@
|
|
|
1
|
+
package com.nanofuxion.tamerdevclient
|
|
2
|
+
|
|
3
|
+
import android.app.Activity
|
|
4
|
+
import android.content.Context
|
|
5
|
+
import android.content.Intent
|
|
6
|
+
import android.util.Log
|
|
7
|
+
import androidx.core.content.ContextCompat
|
|
8
|
+
import com.google.zxing.integration.android.IntentIntegrator
|
|
9
|
+
import com.lynx.jsbridge.LynxMethod
|
|
10
|
+
import com.lynx.jsbridge.LynxModule
|
|
11
|
+
import com.lynx.react.bridge.Callback
|
|
12
|
+
import com.lynx.react.bridge.JavaOnlyArray
|
|
13
|
+
import com.lynx.react.bridge.JavaOnlyMap
|
|
14
|
+
import com.lynx.tasm.LynxView
|
|
15
|
+
import com.lynx.tasm.behavior.LynxContext
|
|
16
|
+
import com.nanofuxion.tamerdevclient.nsd.DiscoveredServer
|
|
17
|
+
import com.nanofuxion.tamerdevclient.nsd.NsdDiscovery
|
|
18
|
+
import org.json.JSONObject
|
|
19
|
+
|
|
20
|
+
class DevClientModule(context: Context) : LynxModule(context) {
|
|
21
|
+
|
|
22
|
+
companion object {
|
|
23
|
+
private const val TAG = "DevClientModule"
|
|
24
|
+
const val ACTION_RELOAD_PROJECT = "com.tamer4lynx.RELOAD_PROJECT"
|
|
25
|
+
private const val PREFS = "tamer_dev_server"
|
|
26
|
+
private const val KEY_URL = "dev_server_url"
|
|
27
|
+
private const val KEY_RECENT = "dev_server_recent"
|
|
28
|
+
|
|
29
|
+
@Volatile
|
|
30
|
+
var instance: DevClientModule? = null
|
|
31
|
+
|
|
32
|
+
@Volatile
|
|
33
|
+
private var hostActivity: Activity? = null
|
|
34
|
+
|
|
35
|
+
@Volatile
|
|
36
|
+
private var lynxViewRef: LynxView? = null
|
|
37
|
+
|
|
38
|
+
private var requestCameraPermission: ((Runnable) -> Unit)? = null
|
|
39
|
+
private var launchScan: Runnable? = null
|
|
40
|
+
private var reloadProjectLauncher: Runnable? = null
|
|
41
|
+
|
|
42
|
+
fun attachHostActivity(activity: Activity?) {
|
|
43
|
+
hostActivity = activity
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
fun attachLynxView(view: LynxView?) {
|
|
47
|
+
lynxViewRef = view
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
fun attachCameraPermissionRequester(requester: (Runnable) -> Unit) {
|
|
51
|
+
requestCameraPermission = requester
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
fun attachScanLauncher(launcher: Runnable) {
|
|
55
|
+
launchScan = launcher
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
fun attachReloadProjectLauncher(launcher: Runnable?) {
|
|
59
|
+
reloadProjectLauncher = launcher
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
@Volatile
|
|
63
|
+
private var supportedModuleClassNames: Set<String> = emptySet()
|
|
64
|
+
|
|
65
|
+
fun attachSupportedModuleClassNames(names: List<String>) {
|
|
66
|
+
supportedModuleClassNames = names.filter { it.isNotBlank() }.toSet()
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
fun getSupportedModuleClassNames(): Set<String> = supportedModuleClassNames
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
private var nsdDiscovery: NsdDiscovery? = null
|
|
73
|
+
private var lastDiscovered: List<DiscoveredServer> = emptyList()
|
|
74
|
+
|
|
75
|
+
data class CompatibilityResult(val compatible: Boolean, val requiredModules: List<Pair<String, String>>)
|
|
76
|
+
|
|
77
|
+
private fun fetchMetaAndCheckCompatibility(baseUrl: String): CompatibilityResult? {
|
|
78
|
+
val supported = DevClientModule.getSupportedModuleClassNames()
|
|
79
|
+
if (supported.isEmpty()) return CompatibilityResult(true, emptyList())
|
|
80
|
+
return try {
|
|
81
|
+
val metaUrl = baseUrl.trimEnd('/') + "/meta.json"
|
|
82
|
+
val request = okhttp3.Request.Builder().url(metaUrl).build()
|
|
83
|
+
val client = okhttp3.OkHttpClient.Builder()
|
|
84
|
+
.connectTimeout(5, java.util.concurrent.TimeUnit.SECONDS)
|
|
85
|
+
.readTimeout(5, java.util.concurrent.TimeUnit.SECONDS)
|
|
86
|
+
.build()
|
|
87
|
+
val response = client.newCall(request).execute()
|
|
88
|
+
if (!response.isSuccessful || response.body == null) return null
|
|
89
|
+
val body = response.body!!.string()
|
|
90
|
+
val json = org.json.JSONObject(body)
|
|
91
|
+
val arr = json.optJSONArray("nativeModules") ?: return CompatibilityResult(true, emptyList())
|
|
92
|
+
val required = mutableListOf<Pair<String, String>>()
|
|
93
|
+
for (i in 0 until arr.length()) {
|
|
94
|
+
val obj = arr.getJSONObject(i)
|
|
95
|
+
val pkg = obj.optString("packageName", "")
|
|
96
|
+
val cls = obj.optString("moduleClassName", "")
|
|
97
|
+
if (cls.isNotBlank() && cls !in supported) {
|
|
98
|
+
required.add(pkg to cls)
|
|
99
|
+
}
|
|
100
|
+
}
|
|
101
|
+
CompatibilityResult(required.isEmpty(), required)
|
|
102
|
+
} catch (_: Exception) {
|
|
103
|
+
null
|
|
104
|
+
}
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
init {
|
|
108
|
+
instance = this
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
fun setActivity(activity: Activity?) {
|
|
112
|
+
attachHostActivity(activity)
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
fun setCameraPermissionRequester(requester: (Runnable) -> Unit) {
|
|
116
|
+
attachCameraPermissionRequester(requester)
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
private fun getLynxContext(): LynxContext? = mContext as? LynxContext
|
|
120
|
+
|
|
121
|
+
private fun emitDiscoveredServers(servers: List<DiscoveredServer>) {
|
|
122
|
+
lastDiscovered = servers
|
|
123
|
+
val arr = org.json.JSONArray()
|
|
124
|
+
for (s in servers) {
|
|
125
|
+
val obj = org.json.JSONObject()
|
|
126
|
+
obj.put("url", s.url)
|
|
127
|
+
obj.put("name", s.name)
|
|
128
|
+
arr.put(obj)
|
|
129
|
+
}
|
|
130
|
+
val payload = org.json.JSONObject().put("servers", arr).toString()
|
|
131
|
+
val eventDetails = JavaOnlyMap()
|
|
132
|
+
eventDetails.putString("payload", payload)
|
|
133
|
+
val params = JavaOnlyArray()
|
|
134
|
+
params.pushMap(eventDetails)
|
|
135
|
+
val lynxContext = getLynxContext()
|
|
136
|
+
if (lynxContext != null) {
|
|
137
|
+
lynxContext.sendGlobalEvent("devclient:discoveredServers", params)
|
|
138
|
+
} else {
|
|
139
|
+
lynxViewRef?.sendGlobalEvent("devclient:discoveredServers", params)
|
|
140
|
+
}
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
private fun prefs() = mContext.getSharedPreferences(PREFS, Context.MODE_PRIVATE)
|
|
144
|
+
|
|
145
|
+
@LynxMethod
|
|
146
|
+
fun getDevServerUrl(callback: Callback) {
|
|
147
|
+
callback.invoke(prefs().getString(KEY_URL, null) ?: "")
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
@LynxMethod
|
|
151
|
+
fun setDevServerUrl(url: String) {
|
|
152
|
+
val normalized = url.trim().removeSuffix("/").let { u ->
|
|
153
|
+
when {
|
|
154
|
+
u.endsWith("/main.lynx.bundle") -> u.removeSuffix("/main.lynx.bundle")
|
|
155
|
+
u.endsWith("main.lynx.bundle") -> u.removeSuffix("main.lynx.bundle").trimEnd('/')
|
|
156
|
+
else -> u
|
|
157
|
+
}
|
|
158
|
+
}
|
|
159
|
+
prefs().edit().putString(KEY_URL, normalized).commit()
|
|
160
|
+
addRecent(normalized)
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
private fun addRecent(url: String) {
|
|
164
|
+
val current = parseRecentJson().filter { it != url }
|
|
165
|
+
val updated = listOf(url) + current
|
|
166
|
+
prefs().edit()
|
|
167
|
+
.putString(KEY_RECENT, org.json.JSONArray(updated.take(10)).toString())
|
|
168
|
+
.apply()
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
private fun parseRecentJson(): List<String> {
|
|
172
|
+
val json = prefs().getString(KEY_RECENT, "[]") ?: "[]"
|
|
173
|
+
return try {
|
|
174
|
+
val ja = org.json.JSONArray(json)
|
|
175
|
+
(0 until ja.length()).map { ja.getString(it) }
|
|
176
|
+
} catch (_: Exception) { emptyList() }
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
@LynxMethod
|
|
180
|
+
fun getRecentUrls(callback: Callback) {
|
|
181
|
+
val arr = JavaOnlyArray()
|
|
182
|
+
for (s in parseRecentJson()) arr.pushString(s)
|
|
183
|
+
callback.invoke(arr)
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
@LynxMethod
|
|
187
|
+
fun clearDevServerUrl() {
|
|
188
|
+
prefs().edit().clear().apply()
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
@LynxMethod
|
|
192
|
+
fun scanQR() {
|
|
193
|
+
val activity = hostActivity ?: (mContext as? LynxContext)?.activity as? Activity
|
|
194
|
+
if (activity == null) {
|
|
195
|
+
Log.e(TAG, "No activity for QR scan")
|
|
196
|
+
emitScanResult(null)
|
|
197
|
+
return
|
|
198
|
+
}
|
|
199
|
+
activity.runOnUiThread {
|
|
200
|
+
if (ContextCompat.checkSelfPermission(mContext, android.Manifest.permission.CAMERA) == android.content.pm.PackageManager.PERMISSION_GRANTED) {
|
|
201
|
+
launchScanner()
|
|
202
|
+
return@runOnUiThread
|
|
203
|
+
}
|
|
204
|
+
val requester = requestCameraPermission
|
|
205
|
+
if (requester != null) {
|
|
206
|
+
requester(Runnable { launchScanner() })
|
|
207
|
+
} else {
|
|
208
|
+
Log.e(TAG, "No camera permission requester set; call setCameraPermissionRequester from Activity")
|
|
209
|
+
emitScanResult(null)
|
|
210
|
+
}
|
|
211
|
+
}
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
private fun launchScanner() {
|
|
215
|
+
launchScan?.run() ?: run {
|
|
216
|
+
val activity = hostActivity
|
|
217
|
+
if (activity != null) {
|
|
218
|
+
IntentIntegrator(activity).setPrompt("Scan dev server QR").initiateScan()
|
|
219
|
+
} else {
|
|
220
|
+
Log.e(TAG, "No scan launcher or activity for QR scan")
|
|
221
|
+
emitScanResult(null)
|
|
222
|
+
}
|
|
223
|
+
}
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
fun deliverScanResult(contents: String?) {
|
|
227
|
+
emitScanResult(contents)
|
|
228
|
+
}
|
|
229
|
+
|
|
230
|
+
private fun emitScanResult(url: String?) {
|
|
231
|
+
val json = JSONObject().apply { put("url", url ?: "") }.toString()
|
|
232
|
+
val eventDetails = JavaOnlyMap()
|
|
233
|
+
eventDetails.putString("payload", json)
|
|
234
|
+
val params = JavaOnlyArray()
|
|
235
|
+
params.pushMap(eventDetails)
|
|
236
|
+
val lynxContext = getLynxContext()
|
|
237
|
+
if (lynxContext != null) {
|
|
238
|
+
lynxContext.sendGlobalEvent("devclient:scanResult", params)
|
|
239
|
+
} else {
|
|
240
|
+
lynxViewRef?.sendGlobalEvent("devclient:scanResult", params)
|
|
241
|
+
}
|
|
242
|
+
}
|
|
243
|
+
|
|
244
|
+
@LynxMethod
|
|
245
|
+
fun reloadWithProjectBundle() {
|
|
246
|
+
val launcher = reloadProjectLauncher
|
|
247
|
+
val activity = hostActivity
|
|
248
|
+
if (launcher != null && activity != null) {
|
|
249
|
+
activity.runOnUiThread { launcher.run() }
|
|
250
|
+
} else {
|
|
251
|
+
mContext.sendBroadcast(Intent(ACTION_RELOAD_PROJECT).setPackage(mContext.packageName))
|
|
252
|
+
}
|
|
253
|
+
}
|
|
254
|
+
|
|
255
|
+
@LynxMethod
|
|
256
|
+
fun startDiscovery() {
|
|
257
|
+
val app = mContext.applicationContext
|
|
258
|
+
if (nsdDiscovery != null) return
|
|
259
|
+
nsdDiscovery = NsdDiscovery(
|
|
260
|
+
app as android.app.Application,
|
|
261
|
+
{ url -> fetchMetaAndCheckCompatibility(url)?.let { it.compatible } ?: true }
|
|
262
|
+
) { servers ->
|
|
263
|
+
emitDiscoveredServers(servers)
|
|
264
|
+
}
|
|
265
|
+
nsdDiscovery?.start()
|
|
266
|
+
}
|
|
267
|
+
|
|
268
|
+
@LynxMethod
|
|
269
|
+
fun stopDiscovery() {
|
|
270
|
+
nsdDiscovery?.stop()
|
|
271
|
+
nsdDiscovery = null
|
|
272
|
+
}
|
|
273
|
+
|
|
274
|
+
@LynxMethod
|
|
275
|
+
fun getDiscoveredServers(callback: Callback) {
|
|
276
|
+
val arr = JavaOnlyArray()
|
|
277
|
+
for (s in lastDiscovered) {
|
|
278
|
+
val map = JavaOnlyMap()
|
|
279
|
+
map.putString("url", s.url)
|
|
280
|
+
map.putString("name", s.name)
|
|
281
|
+
arr.pushMap(map)
|
|
282
|
+
}
|
|
283
|
+
callback.invoke(arr)
|
|
284
|
+
}
|
|
285
|
+
|
|
286
|
+
@LynxMethod
|
|
287
|
+
fun checkServerCompatibility(baseUrl: String, callback: Callback) {
|
|
288
|
+
Thread {
|
|
289
|
+
val result = fetchMetaAndCheckCompatibility(baseUrl)
|
|
290
|
+
val activity = hostActivity
|
|
291
|
+
if (activity != null) {
|
|
292
|
+
activity.runOnUiThread {
|
|
293
|
+
if (result == null) {
|
|
294
|
+
callback.invoke(true, JavaOnlyArray())
|
|
295
|
+
} else {
|
|
296
|
+
val requiredArr = JavaOnlyArray()
|
|
297
|
+
for ((pkg, cls) in result.requiredModules) {
|
|
298
|
+
val map = JavaOnlyMap()
|
|
299
|
+
map.putString("packageName", pkg)
|
|
300
|
+
map.putString("moduleClassName", cls)
|
|
301
|
+
requiredArr.pushMap(map)
|
|
302
|
+
}
|
|
303
|
+
callback.invoke(result.compatible, requiredArr)
|
|
304
|
+
}
|
|
305
|
+
}
|
|
306
|
+
} else {
|
|
307
|
+
callback.invoke(true, JavaOnlyArray())
|
|
308
|
+
}
|
|
309
|
+
}.start()
|
|
310
|
+
}
|
|
311
|
+
}
|
|
@@ -0,0 +1,141 @@
|
|
|
1
|
+
package com.nanofuxion.tamerdevclient
|
|
2
|
+
|
|
3
|
+
import android.content.Context
|
|
4
|
+
import android.net.Uri
|
|
5
|
+
import android.os.Handler
|
|
6
|
+
import android.os.Looper
|
|
7
|
+
import androidx.annotation.Keep
|
|
8
|
+
import com.lynx.service.log.LynxLogService
|
|
9
|
+
import com.lynx.tasm.service.ILynxLogService
|
|
10
|
+
import okhttp3.OkHttpClient
|
|
11
|
+
import okhttp3.Request
|
|
12
|
+
import okhttp3.WebSocket
|
|
13
|
+
import okhttp3.WebSocketListener
|
|
14
|
+
import org.json.JSONArray
|
|
15
|
+
import org.json.JSONObject
|
|
16
|
+
import java.util.concurrent.TimeUnit
|
|
17
|
+
import java.util.regex.Pattern
|
|
18
|
+
|
|
19
|
+
@Keep
|
|
20
|
+
object TamerRelogLogService : ILynxLogService {
|
|
21
|
+
private const val PREFS = "tamer_dev_server"
|
|
22
|
+
private const val KEY_URL = "dev_server_url"
|
|
23
|
+
private const val MAX_QUEUE = 100
|
|
24
|
+
private const val RECONNECT_DELAY_MS = 3000L
|
|
25
|
+
|
|
26
|
+
@Volatile private var appContext: Context? = null
|
|
27
|
+
@Volatile private var ws: WebSocket? = null
|
|
28
|
+
@Volatile private var connecting = false
|
|
29
|
+
@Volatile private var shouldReconnect = false
|
|
30
|
+
private val pendingQueue = ArrayDeque<String>()
|
|
31
|
+
private val handler = Handler(Looper.getMainLooper())
|
|
32
|
+
|
|
33
|
+
private val client = OkHttpClient.Builder()
|
|
34
|
+
.connectTimeout(5, TimeUnit.SECONDS)
|
|
35
|
+
.readTimeout(0, TimeUnit.SECONDS)
|
|
36
|
+
.build()
|
|
37
|
+
|
|
38
|
+
private val consoleLogPattern = Pattern.compile(
|
|
39
|
+
"""\[.*?:(?:INFO|ERROR|WARN(?:ING)?|DEBUG|VERBOSE|FATAL):lynx_console\.cc\(\d+\)] (.+)""",
|
|
40
|
+
Pattern.DOTALL
|
|
41
|
+
)
|
|
42
|
+
|
|
43
|
+
fun init(context: Context) {
|
|
44
|
+
appContext = context.applicationContext
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
fun connect() {
|
|
48
|
+
shouldReconnect = true
|
|
49
|
+
if (connecting || ws != null) return
|
|
50
|
+
val ctx = appContext ?: return
|
|
51
|
+
val devUrl = ctx.getSharedPreferences(PREFS, Context.MODE_PRIVATE)
|
|
52
|
+
.getString(KEY_URL, null) ?: return
|
|
53
|
+
val wsUrl = buildWsUrl(devUrl) ?: return
|
|
54
|
+
connecting = true
|
|
55
|
+
val request = Request.Builder().url(wsUrl).build()
|
|
56
|
+
client.newWebSocket(request, object : WebSocketListener() {
|
|
57
|
+
override fun onOpen(socket: WebSocket, response: okhttp3.Response) {
|
|
58
|
+
ws = socket
|
|
59
|
+
connecting = false
|
|
60
|
+
val ping = JSONObject().apply {
|
|
61
|
+
put("type", "console_log")
|
|
62
|
+
put("tag", "lynx-console")
|
|
63
|
+
put("message", JSONArray().put("[TamerRelog] connected"))
|
|
64
|
+
}.toString()
|
|
65
|
+
socket.send(ping)
|
|
66
|
+
synchronized(pendingQueue) {
|
|
67
|
+
while (pendingQueue.isNotEmpty()) {
|
|
68
|
+
socket.send(pendingQueue.removeFirst())
|
|
69
|
+
}
|
|
70
|
+
}
|
|
71
|
+
}
|
|
72
|
+
override fun onFailure(socket: WebSocket, t: Throwable, response: okhttp3.Response?) {
|
|
73
|
+
ws = null
|
|
74
|
+
connecting = false
|
|
75
|
+
synchronized(pendingQueue) { pendingQueue.clear() }
|
|
76
|
+
scheduleReconnect()
|
|
77
|
+
}
|
|
78
|
+
override fun onClosed(socket: WebSocket, code: Int, reason: String) {
|
|
79
|
+
ws = null
|
|
80
|
+
connecting = false
|
|
81
|
+
scheduleReconnect()
|
|
82
|
+
}
|
|
83
|
+
})
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
private fun scheduleReconnect() {
|
|
87
|
+
if (!shouldReconnect) return
|
|
88
|
+
handler.postDelayed({ connect() }, RECONNECT_DELAY_MS)
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
fun disconnect() {
|
|
92
|
+
shouldReconnect = false
|
|
93
|
+
handler.removeCallbacksAndMessages(null)
|
|
94
|
+
ws?.close(1000, null)
|
|
95
|
+
ws = null
|
|
96
|
+
connecting = false
|
|
97
|
+
synchronized(pendingQueue) { pendingQueue.clear() }
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
override fun logByPlatform(level: Int, tag: String, msg: String) {
|
|
101
|
+
LynxLogService.logByPlatform(level, tag, msg)
|
|
102
|
+
|
|
103
|
+
val (forwardTag, forwardMsg) = if (tag == "lynx") {
|
|
104
|
+
val m = consoleLogPattern.matcher(msg)
|
|
105
|
+
if (m.find()) "lynx-console" to m.group(1)!! else tag to msg
|
|
106
|
+
} else {
|
|
107
|
+
tag to msg
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
val payload = try {
|
|
111
|
+
JSONObject().apply {
|
|
112
|
+
put("type", "console_log")
|
|
113
|
+
put("tag", forwardTag)
|
|
114
|
+
put("message", JSONArray().put(forwardMsg))
|
|
115
|
+
}.toString()
|
|
116
|
+
} catch (_: Exception) { return }
|
|
117
|
+
|
|
118
|
+
val socket = ws
|
|
119
|
+
if (socket != null) {
|
|
120
|
+
socket.send(payload)
|
|
121
|
+
} else if (connecting) {
|
|
122
|
+
synchronized(pendingQueue) {
|
|
123
|
+
if (pendingQueue.size < MAX_QUEUE) pendingQueue.addLast(payload)
|
|
124
|
+
}
|
|
125
|
+
}
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
private fun buildWsUrl(devUrl: String): String? {
|
|
129
|
+
val uri = Uri.parse(devUrl)
|
|
130
|
+
val scheme = if (uri.scheme == "https") "wss" else "ws"
|
|
131
|
+
val host = uri.host ?: return null
|
|
132
|
+
val port = if (uri.port > 0) ":${uri.port}" else ""
|
|
133
|
+
val path = (uri.path ?: "").let { p -> (if (p.endsWith("/")) p else p + "/") + "__hmr" }
|
|
134
|
+
return "$scheme://$host$port$path"
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
override fun isLogOutputByPlatform(): Boolean = true
|
|
138
|
+
override fun getDefaultWriteFunction(): Long = 0
|
|
139
|
+
override fun switchLogToSystem(enableSystemLog: Boolean) {}
|
|
140
|
+
override fun getLogToSystemStatus(): Boolean = false
|
|
141
|
+
}
|
|
@@ -0,0 +1,138 @@
|
|
|
1
|
+
package com.nanofuxion.tamerdevclient.nsd
|
|
2
|
+
|
|
3
|
+
import android.app.Application
|
|
4
|
+
import android.content.Context
|
|
5
|
+
import android.net.nsd.NsdManager
|
|
6
|
+
import android.net.nsd.NsdServiceInfo
|
|
7
|
+
import android.util.Log
|
|
8
|
+
import kotlinx.coroutines.CoroutineScope
|
|
9
|
+
import kotlinx.coroutines.Dispatchers
|
|
10
|
+
import kotlinx.coroutines.Job
|
|
11
|
+
import kotlinx.coroutines.SupervisorJob
|
|
12
|
+
import kotlinx.coroutines.async
|
|
13
|
+
import kotlinx.coroutines.awaitAll
|
|
14
|
+
import kotlinx.coroutines.delay
|
|
15
|
+
import kotlinx.coroutines.ensureActive
|
|
16
|
+
import kotlinx.coroutines.isActive
|
|
17
|
+
import kotlinx.coroutines.launch
|
|
18
|
+
import okhttp3.OkHttpClient
|
|
19
|
+
import okhttp3.Request
|
|
20
|
+
import java.util.concurrent.TimeUnit
|
|
21
|
+
|
|
22
|
+
private const val SERVICE_TYPE = "_tamer._tcp."
|
|
23
|
+
private const val TAG = "NsdDiscovery"
|
|
24
|
+
|
|
25
|
+
data class DiscoveredServer(val url: String, val name: String)
|
|
26
|
+
|
|
27
|
+
class NsdDiscovery(
|
|
28
|
+
application: Application,
|
|
29
|
+
private val isCompatible: (String) -> Boolean = { true },
|
|
30
|
+
private val onServersChanged: (List<DiscoveredServer>) -> Unit
|
|
31
|
+
) {
|
|
32
|
+
private val scope = CoroutineScope(Dispatchers.Default + SupervisorJob())
|
|
33
|
+
private val nsdManager = application.getSystemService(Context.NSD_SERVICE) as? NsdManager
|
|
34
|
+
private val httpClient = OkHttpClient.Builder()
|
|
35
|
+
.connectTimeout(5, TimeUnit.SECONDS)
|
|
36
|
+
.readTimeout(5, TimeUnit.SECONDS)
|
|
37
|
+
.build()
|
|
38
|
+
|
|
39
|
+
private var isDiscovering = false
|
|
40
|
+
private var healthCheckJob: Job? = null
|
|
41
|
+
private val potentialServers = mutableSetOf<PotentialServer>()
|
|
42
|
+
|
|
43
|
+
private data class PotentialServer(val serviceName: String, val url: String, val name: String)
|
|
44
|
+
|
|
45
|
+
private val discoveryListener = object : NsdManager.DiscoveryListener {
|
|
46
|
+
override fun onDiscoveryStarted(serviceType: String) {}
|
|
47
|
+
override fun onServiceFound(service: NsdServiceInfo) {
|
|
48
|
+
scope.launch { resolveAndAdd(service) }
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
override fun onServiceLost(service: NsdServiceInfo) {
|
|
52
|
+
val name = service.serviceName ?: return
|
|
53
|
+
synchronized(potentialServers) {
|
|
54
|
+
potentialServers.removeAll { it.serviceName == name }
|
|
55
|
+
}
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
override fun onDiscoveryStopped(serviceType: String) {
|
|
59
|
+
synchronized(potentialServers) { potentialServers.clear() }
|
|
60
|
+
onServersChanged(emptyList())
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
override fun onStartDiscoveryFailed(serviceType: String, errorCode: Int) {
|
|
64
|
+
Log.e(TAG, "Discovery start failed: $errorCode")
|
|
65
|
+
isDiscovering = false
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
override fun onStopDiscoveryFailed(serviceType: String, errorCode: Int) {
|
|
69
|
+
Log.e(TAG, "Discovery stop failed: $errorCode")
|
|
70
|
+
}
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
fun start() {
|
|
74
|
+
if (nsdManager == null) {
|
|
75
|
+
Log.e(TAG, "NsdManager not available")
|
|
76
|
+
return
|
|
77
|
+
}
|
|
78
|
+
if (isDiscovering) return
|
|
79
|
+
nsdManager.discoverServices(SERVICE_TYPE, NsdManager.PROTOCOL_DNS_SD, discoveryListener)
|
|
80
|
+
isDiscovering = true
|
|
81
|
+
startHealthCheck()
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
fun stop() {
|
|
85
|
+
if (!isDiscovering) return
|
|
86
|
+
nsdManager?.stopServiceDiscovery(discoveryListener)
|
|
87
|
+
isDiscovering = false
|
|
88
|
+
healthCheckJob?.cancel()
|
|
89
|
+
healthCheckJob = null
|
|
90
|
+
synchronized(potentialServers) { potentialServers.clear() }
|
|
91
|
+
onServersChanged(emptyList())
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
private suspend fun resolveAndAdd(serviceInfo: NsdServiceInfo) {
|
|
95
|
+
if (nsdManager == null) return
|
|
96
|
+
try {
|
|
97
|
+
val resolved = nsdManager.resolveServiceCoroutine(serviceInfo)
|
|
98
|
+
scope.ensureActive()
|
|
99
|
+
val baseUrl = resolved.getUrl() ?: return
|
|
100
|
+
val path = resolved.getAttribute("path") ?: ""
|
|
101
|
+
val url = baseUrl.trimEnd('/') + path
|
|
102
|
+
val name = resolved.getAttribute("name") ?: resolved.serviceName ?: url
|
|
103
|
+
synchronized(potentialServers) {
|
|
104
|
+
potentialServers.add(PotentialServer(resolved.serviceName ?: "", url, name))
|
|
105
|
+
}
|
|
106
|
+
} catch (e: Exception) {
|
|
107
|
+
Log.w(TAG, "Resolve failed: ${e.message}")
|
|
108
|
+
}
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
private fun startHealthCheck() {
|
|
112
|
+
healthCheckJob?.cancel()
|
|
113
|
+
healthCheckJob = scope.launch {
|
|
114
|
+
while (isActive && isDiscovering) {
|
|
115
|
+
val current = synchronized(potentialServers) { potentialServers.toList() }
|
|
116
|
+
val alive = current
|
|
117
|
+
.map { s -> async { s to (checkStatus(s.url) && isCompatible(s.url)) } }
|
|
118
|
+
.awaitAll()
|
|
119
|
+
.filter { (_, ok) -> ok }
|
|
120
|
+
.map { (s, _) -> DiscoveredServer(s.url, s.name) }
|
|
121
|
+
ensureActive()
|
|
122
|
+
onServersChanged(alive)
|
|
123
|
+
delay(3000)
|
|
124
|
+
}
|
|
125
|
+
}
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
private fun checkStatus(url: String): Boolean {
|
|
129
|
+
return try {
|
|
130
|
+
val statusUrl = url.trimEnd('/') + "/status"
|
|
131
|
+
val request = Request.Builder().url(statusUrl).build()
|
|
132
|
+
val response = httpClient.newCall(request).execute()
|
|
133
|
+
response.isSuccessful && (response.body?.string()?.contains("packager-status:running") == true)
|
|
134
|
+
} catch (_: Exception) {
|
|
135
|
+
false
|
|
136
|
+
}
|
|
137
|
+
}
|
|
138
|
+
}
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
package com.nanofuxion.tamerdevclient.nsd
|
|
2
|
+
|
|
3
|
+
import android.net.nsd.NsdServiceInfo
|
|
4
|
+
import android.os.Build
|
|
5
|
+
import java.net.Inet4Address
|
|
6
|
+
|
|
7
|
+
private fun NsdServiceInfo.getHostIpv4(): Inet4Address? {
|
|
8
|
+
return if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.UPSIDE_DOWN_CAKE) {
|
|
9
|
+
hostAddresses.filterIsInstance<Inet4Address>().firstOrNull()
|
|
10
|
+
} else {
|
|
11
|
+
@Suppress("DEPRECATION")
|
|
12
|
+
host as? Inet4Address
|
|
13
|
+
}
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
fun NsdServiceInfo.getUrl(): String? {
|
|
17
|
+
val hostIp = getHostIpv4() ?: return null
|
|
18
|
+
val port = port
|
|
19
|
+
return "http://${hostIp.hostAddress}:$port"
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
fun NsdServiceInfo.getAttribute(attributeName: String): String? {
|
|
23
|
+
val attributes = attributes ?: return null
|
|
24
|
+
val attributeValue = attributes[attributeName] ?: return null
|
|
25
|
+
return String(attributeValue, Charsets.UTF_8)
|
|
26
|
+
}
|