@sigx/lynx-dev-client 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.
Files changed (24) hide show
  1. package/LICENSE +21 -0
  2. package/android/src/main/kotlin/com/sigx/devclient/DevGenericResourceFetcher.kt +73 -0
  3. package/android/src/main/kotlin/com/sigx/devclient/DevHomeScreen.kt +163 -0
  4. package/android/src/main/kotlin/com/sigx/devclient/DevLynxScreen.kt +208 -0
  5. package/android/src/main/kotlin/com/sigx/devclient/DevMenu.kt +278 -0
  6. package/android/src/main/kotlin/com/sigx/devclient/DevQRScanner.kt +174 -0
  7. package/android/src/main/kotlin/com/sigx/devclient/DevSettings.kt +61 -0
  8. package/android/src/main/kotlin/com/sigx/devclient/DevTemplateProvider.kt +76 -0
  9. package/android/src/main/kotlin/com/sigx/devclient/DevTemplateResourceFetcher.kt +113 -0
  10. package/android/src/main/kotlin/com/sigx/devclient/ErrorOverlay.kt +86 -0
  11. package/android/src/main/kotlin/com/sigx/devclient/PerfHud.kt +100 -0
  12. package/android/src/main/kotlin/com/sigx/devclient/ShakeDetector.kt +97 -0
  13. package/android/src/main/kotlin/com/sigx/devclient/SigxDevClient.kt +91 -0
  14. package/dist/index.d.ts +11 -0
  15. package/dist/index.js +11 -0
  16. package/ios/Sources/SigxDevClient/DevGenericResourceFetcher.swift +47 -0
  17. package/ios/Sources/SigxDevClient/DevHomeScreen.swift +128 -0
  18. package/ios/Sources/SigxDevClient/DevMenuView.swift +133 -0
  19. package/ios/Sources/SigxDevClient/DevQRScanner.swift +165 -0
  20. package/ios/Sources/SigxDevClient/DevTemplateProvider.swift +68 -0
  21. package/ios/Sources/SigxDevClient/DevTemplateResourceFetcher.swift +73 -0
  22. package/ios/Sources/SigxDevClient/ShakeDetector.swift +60 -0
  23. package/ios/Sources/SigxDevClient/SigxDevClient.swift +106 -0
  24. package/package.json +30 -0
package/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2025 Andreas Ekdahl
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
@@ -0,0 +1,73 @@
1
+ package com.sigx.devclient
2
+
3
+ import com.lynx.tasm.resourceprovider.LynxResourceCallback
4
+ import com.lynx.tasm.resourceprovider.LynxResourceRequest
5
+ import com.lynx.tasm.resourceprovider.LynxResourceResponse
6
+ import com.lynx.tasm.resourceprovider.generic.LynxGenericResourceFetcher
7
+ import com.lynx.tasm.resourceprovider.generic.StreamDelegate
8
+ import okhttp3.Call
9
+ import okhttp3.Callback
10
+ import okhttp3.OkHttpClient
11
+ import okhttp3.Request
12
+ import okhttp3.Response
13
+ import java.io.IOException
14
+ import java.util.concurrent.TimeUnit
15
+
16
+ /**
17
+ * Handles generic resource fetching (JS chunks, CSS, JSON, etc.) for LynxView.
18
+ * Required for HMR hot-update JSON/JS fetches.
19
+ */
20
+ @Suppress("UNCHECKED_CAST")
21
+ class DevGenericResourceFetcher : LynxGenericResourceFetcher() {
22
+
23
+ private val httpClient = OkHttpClient.Builder()
24
+ .connectTimeout(10, TimeUnit.SECONDS)
25
+ .readTimeout(30, TimeUnit.SECONDS)
26
+ .build()
27
+
28
+ override fun fetchResource(
29
+ request: LynxResourceRequest,
30
+ callback: LynxResourceCallback<ByteArray>
31
+ ) {
32
+ if (request == null) {
33
+ callback.onResponse(failed("request is null"))
34
+ return
35
+ }
36
+
37
+ val httpRequest = Request.Builder().url(request.url).build()
38
+ httpClient.newCall(httpRequest).enqueue(object : Callback {
39
+ override fun onResponse(call: Call, response: Response) {
40
+ response.use {
41
+ if (it.isSuccessful && it.body != null) {
42
+ callback.onResponse(LynxResourceResponse.onSuccess(it.body!!.bytes()))
43
+ } else {
44
+ callback.onResponse(failed("HTTP ${it.code}: ${it.message}"))
45
+ }
46
+ }
47
+ }
48
+
49
+ override fun onFailure(call: Call, e: IOException) {
50
+ callback.onResponse(failed(e))
51
+ }
52
+ })
53
+ }
54
+
55
+ override fun fetchResourcePath(
56
+ request: LynxResourceRequest,
57
+ callback: LynxResourceCallback<String>
58
+ ) {
59
+ callback.onResponse(failed("fetchResourcePath not supported"))
60
+ }
61
+
62
+ override fun fetchStream(request: LynxResourceRequest, delegate: StreamDelegate) {
63
+ delegate.onError("fetchStream not supported")
64
+ }
65
+
66
+ override fun cancel(request: LynxResourceRequest) {}
67
+
68
+ private fun <T> failed(message: String): LynxResourceResponse<T> =
69
+ LynxResourceResponse.onFailed(Throwable(message)) as LynxResourceResponse<T>
70
+
71
+ private fun <T> failed(e: Throwable): LynxResourceResponse<T> =
72
+ LynxResourceResponse.onFailed(e) as LynxResourceResponse<T>
73
+ }
@@ -0,0 +1,163 @@
1
+ package com.sigx.devclient
2
+
3
+ import androidx.compose.foundation.clickable
4
+ import androidx.compose.foundation.layout.*
5
+ import androidx.compose.foundation.lazy.LazyColumn
6
+ import androidx.compose.foundation.lazy.items
7
+ import androidx.compose.foundation.text.KeyboardActions
8
+ import androidx.compose.foundation.text.KeyboardOptions
9
+ import androidx.compose.material.icons.Icons
10
+ import androidx.compose.material.icons.filled.Delete
11
+ import androidx.compose.material.icons.filled.QrCodeScanner
12
+ import androidx.compose.material3.*
13
+ import androidx.compose.runtime.*
14
+ import androidx.compose.ui.Alignment
15
+ import androidx.compose.ui.Modifier
16
+ import androidx.compose.ui.platform.LocalContext
17
+ import androidx.compose.ui.text.input.ImeAction
18
+ import androidx.compose.ui.text.input.KeyboardType
19
+ import androidx.compose.ui.unit.dp
20
+
21
+ /**
22
+ * Dev-mode landing screen for sigx-lynx apps that ship without a bundled
23
+ * `main.lynx.bundle`. Lets the user enter a dev-server URL by hand, scan a
24
+ * QR code, or pick from recent URLs. The app template renders this when it
25
+ * has no `EXTRA_DEV_URL` intent extra AND no `main.lynx.bundle` asset.
26
+ *
27
+ * `onSelectUrl` fires when the user picks a URL — the parent (template's
28
+ * MainActivity) holds that in state and re-composes to render `DevLynxScreen`.
29
+ */
30
+ @OptIn(ExperimentalMaterial3Api::class)
31
+ @Composable
32
+ fun DevHomeScreen(
33
+ onSelectUrl: (String) -> Unit,
34
+ ) {
35
+ val context = LocalContext.current
36
+ val devSettings = remember { DevSettings(context) }
37
+ var urlText by remember { mutableStateOf("") }
38
+ var recentUrls by remember { mutableStateOf(devSettings.recentUrls) }
39
+ var showQRScanner by remember { mutableStateOf(false) }
40
+
41
+ fun connectToUrl(url: String) {
42
+ val trimmed = url.trim()
43
+ if (trimmed.isBlank()) return
44
+ devSettings.addRecentUrl(trimmed)
45
+ recentUrls = devSettings.recentUrls
46
+ onSelectUrl(trimmed)
47
+ }
48
+
49
+ if (showQRScanner) {
50
+ DevQRScanner(
51
+ onCodeScanned = { code ->
52
+ showQRScanner = false
53
+ urlText = code
54
+ connectToUrl(code)
55
+ },
56
+ onBack = { showQRScanner = false },
57
+ )
58
+ return
59
+ }
60
+
61
+ Scaffold(
62
+ topBar = {
63
+ TopAppBar(
64
+ title = { Text("sigx-lynx dev") },
65
+ actions = {
66
+ IconButton(onClick = { showQRScanner = true }) {
67
+ Icon(Icons.Default.QrCodeScanner, contentDescription = "Scan QR")
68
+ }
69
+ },
70
+ )
71
+ },
72
+ ) { padding ->
73
+ Column(
74
+ modifier = Modifier
75
+ .fillMaxSize()
76
+ .padding(padding)
77
+ .padding(16.dp),
78
+ ) {
79
+ OutlinedTextField(
80
+ value = urlText,
81
+ onValueChange = { urlText = it },
82
+ label = { Text("Dev server URL") },
83
+ placeholder = { Text("http://192.168.1.100:3000/main.lynx.bundle") },
84
+ modifier = Modifier.fillMaxWidth(),
85
+ singleLine = true,
86
+ keyboardOptions = KeyboardOptions(
87
+ keyboardType = KeyboardType.Uri,
88
+ imeAction = ImeAction.Go,
89
+ ),
90
+ keyboardActions = KeyboardActions(
91
+ onGo = { connectToUrl(urlText) },
92
+ ),
93
+ )
94
+
95
+ Spacer(modifier = Modifier.height(12.dp))
96
+
97
+ Button(
98
+ onClick = { connectToUrl(urlText) },
99
+ modifier = Modifier.fillMaxWidth(),
100
+ enabled = urlText.isNotBlank(),
101
+ ) {
102
+ Text("Connect")
103
+ }
104
+
105
+ Spacer(modifier = Modifier.height(24.dp))
106
+
107
+ if (recentUrls.isNotEmpty()) {
108
+ Row(
109
+ modifier = Modifier.fillMaxWidth(),
110
+ horizontalArrangement = Arrangement.SpaceBetween,
111
+ verticalAlignment = Alignment.CenterVertically,
112
+ ) {
113
+ Text(
114
+ "Recent",
115
+ style = MaterialTheme.typography.titleMedium,
116
+ )
117
+ TextButton(onClick = {
118
+ devSettings.clearRecentUrls()
119
+ recentUrls = emptyList()
120
+ }) {
121
+ Text("Clear")
122
+ }
123
+ }
124
+
125
+ Spacer(modifier = Modifier.height(8.dp))
126
+
127
+ LazyColumn {
128
+ items(recentUrls) { url ->
129
+ ListItem(
130
+ headlineContent = { Text(url, maxLines = 1) },
131
+ modifier = Modifier.clickable {
132
+ urlText = url
133
+ connectToUrl(url)
134
+ },
135
+ trailingContent = {
136
+ IconButton(onClick = {
137
+ devSettings.removeRecentUrl(url)
138
+ recentUrls = devSettings.recentUrls
139
+ }) {
140
+ Icon(Icons.Default.Delete, contentDescription = "Remove")
141
+ }
142
+ },
143
+ )
144
+ HorizontalDivider()
145
+ }
146
+ }
147
+ } else {
148
+ Spacer(modifier = Modifier.weight(1f))
149
+ Column(
150
+ modifier = Modifier.fillMaxWidth(),
151
+ horizontalAlignment = Alignment.CenterHorizontally,
152
+ ) {
153
+ Text(
154
+ "Enter a dev server URL or scan a QR code",
155
+ style = MaterialTheme.typography.bodyMedium,
156
+ color = MaterialTheme.colorScheme.onSurfaceVariant,
157
+ )
158
+ }
159
+ Spacer(modifier = Modifier.weight(1f))
160
+ }
161
+ }
162
+ }
163
+ }
@@ -0,0 +1,208 @@
1
+ package com.sigx.devclient
2
+
3
+ import android.view.ViewGroup
4
+ import androidx.activity.compose.BackHandler
5
+ import androidx.compose.foundation.layout.*
6
+ import androidx.compose.material3.*
7
+ import androidx.compose.runtime.*
8
+ import androidx.compose.ui.Alignment
9
+ import androidx.compose.ui.Modifier
10
+ import androidx.compose.ui.platform.LocalContext
11
+ import androidx.compose.ui.viewinterop.AndroidView
12
+ import com.lynx.tasm.LynxView
13
+ import com.lynx.tasm.LynxViewBuilder
14
+ import com.lynx.tasm.TemplateData
15
+ import com.lynx.xelement.XElementBehaviors
16
+
17
+ /**
18
+ * Full-featured dev screen that wraps a LynxView with the complete sigx dev experience:
19
+ * - HMR via dev-client resource fetchers
20
+ * - Shake-to-open dev menu
21
+ * - Performance HUD overlay
22
+ * - Error overlay (red screen)
23
+ * - Dev settings persistence
24
+ *
25
+ * Use this as a drop-in replacement for manual LynxView setup in dev mode.
26
+ */
27
+ @Composable
28
+ fun DevLynxScreen(
29
+ url: String,
30
+ onBack: (() -> Unit)? = null,
31
+ /** Optional list of native module names to show in dev menu. */
32
+ nativeModules: List<String> = emptyList(),
33
+ /**
34
+ * Called once per LynxView after construction. Use to attach lifecycle
35
+ * publishers (e.g. `GeneratedLifecyclePublishers.attachAll(lynxView)`).
36
+ * Called BEFORE renderTemplateUrl so per-view initial state (safe-area
37
+ * insets, etc.) lands before the first MT paint.
38
+ */
39
+ onLynxViewCreated: ((LynxView) -> Unit)? = null,
40
+ ) {
41
+ var loading by remember { mutableStateOf(true) }
42
+ var error by remember { mutableStateOf<String?>(null) }
43
+ var showDevMenu by remember { mutableStateOf(false) }
44
+ var currentUrl by remember { mutableStateOf(url) }
45
+ val context = LocalContext.current
46
+ val devSettings = remember { DevSettings(context) }
47
+
48
+ var lynxViewRef by remember { mutableStateOf<LynxView?>(null) }
49
+ var perfHudEnabled by remember { mutableStateOf(devSettings.perfHudEnabled) }
50
+ var logBoxEnabled by remember { mutableStateOf(devSettings.logBoxEnabled) }
51
+ var inspectorEnabled by remember { mutableStateOf(false) }
52
+
53
+ // Persist last connected URL
54
+ LaunchedEffect(currentUrl) {
55
+ devSettings.lastConnectedUrl = currentUrl
56
+ }
57
+
58
+ // Shake detector
59
+ DisposableEffect(Unit) {
60
+ val shakeDetector = ShakeDetector(context) {
61
+ showDevMenu = true
62
+ }
63
+ shakeDetector.start()
64
+ onDispose { shakeDetector.stop() }
65
+ }
66
+
67
+ // Wire the system back gesture/button to onBack. Without this, system
68
+ // back falls through to the activity's default behavior (typically
69
+ // finish()), which kills the app instead of returning to a sandbox
70
+ // host's DevHomeScreen. When onBack is null (caller wants no back
71
+ // affordance) we leave the default behavior alone.
72
+ if (onBack != null) {
73
+ BackHandler { onBack() }
74
+ }
75
+
76
+ Box(modifier = Modifier.fillMaxSize()) {
77
+ AndroidView(
78
+ modifier = Modifier.fillMaxSize(),
79
+ factory = { ctx ->
80
+ try {
81
+ val viewBuilder = LynxViewBuilder()
82
+ viewBuilder.addBehaviors(XElementBehaviors().create())
83
+ SigxDevClient.configureForDev(viewBuilder, ctx)
84
+
85
+ val lynxView = viewBuilder.build(ctx)
86
+ lynxView.layoutParams = ViewGroup.LayoutParams(
87
+ ViewGroup.LayoutParams.MATCH_PARENT,
88
+ ViewGroup.LayoutParams.MATCH_PARENT
89
+ )
90
+
91
+ // Lifecycle publishers (safe-area, future device
92
+ // observers) attach BEFORE renderTemplate so each
93
+ // publisher's initial updateGlobalProps lands before
94
+ // the first MT paint.
95
+ onLynxViewCreated?.invoke(lynxView)
96
+
97
+ lynxView.renderTemplateUrl(currentUrl, TemplateData.empty())
98
+ lynxViewRef = lynxView
99
+ loading = false
100
+ lynxView
101
+ } catch (e: Exception) {
102
+ error = e.message ?: "Failed to create LynxView"
103
+ loading = false
104
+ android.view.View(ctx)
105
+ }
106
+ }
107
+ )
108
+
109
+ if (loading) {
110
+ CircularProgressIndicator(
111
+ modifier = Modifier.align(Alignment.Center)
112
+ )
113
+ }
114
+
115
+ // Performance HUD overlay
116
+ PerfHud(
117
+ visible = perfHudEnabled,
118
+ lynxView = lynxViewRef,
119
+ modifier = Modifier.align(Alignment.TopEnd)
120
+ )
121
+
122
+ // Error overlay
123
+ ErrorOverlay(
124
+ error = error,
125
+ onDismiss = { error = null },
126
+ onReload = {
127
+ error = null
128
+ lynxViewRef?.let { view ->
129
+ loading = true
130
+ try {
131
+ view.reloadAndInit()
132
+ view.renderTemplateUrl(currentUrl, TemplateData.empty())
133
+ } catch (e: Exception) {
134
+ error = e.message ?: "Reload failed"
135
+ }
136
+ loading = false
137
+ }
138
+ }
139
+ )
140
+ }
141
+
142
+ // Dev Menu
143
+ DevMenu(
144
+ visible = showDevMenu,
145
+ onDismiss = { showDevMenu = false },
146
+ actions = DevMenuActions(
147
+ onReload = {
148
+ lynxViewRef?.let { view ->
149
+ loading = true
150
+ error = null
151
+ try {
152
+ view.reloadAndInit()
153
+ view.renderTemplateUrl(currentUrl, TemplateData.empty())
154
+ } catch (e: Exception) {
155
+ error = e.message ?: "Reload failed"
156
+ }
157
+ loading = false
158
+ }
159
+ },
160
+ onChangeUrl = { newUrl ->
161
+ currentUrl = newUrl
162
+ lynxViewRef?.let { view ->
163
+ loading = true
164
+ error = null
165
+ try {
166
+ view.renderTemplateUrl(newUrl, TemplateData.empty())
167
+ } catch (e: Exception) {
168
+ error = e.message ?: "Failed to load URL"
169
+ }
170
+ loading = false
171
+ }
172
+ },
173
+ onGoHome = onBack,
174
+ onTogglePerfHud = {
175
+ perfHudEnabled = !perfHudEnabled
176
+ devSettings.perfHudEnabled = perfHudEnabled
177
+ },
178
+ onToggleLogBox = {
179
+ logBoxEnabled = !logBoxEnabled
180
+ devSettings.logBoxEnabled = logBoxEnabled
181
+ try {
182
+ val cls = Class.forName("com.lynx.service.devtool.LynxDevToolService")
183
+ val instance = cls.getMethod("getINSTANCE").invoke(null)
184
+ cls.getMethod("setLogBoxPresetValue", Boolean::class.java)
185
+ .invoke(instance, logBoxEnabled)
186
+ } catch (_: Exception) {}
187
+ },
188
+ onToggleInspector = {
189
+ inspectorEnabled = !inspectorEnabled
190
+ if (inspectorEnabled) {
191
+ lynxViewRef?.let { view ->
192
+ try {
193
+ val cls = Class.forName("com.lynx.service.devtool.LynxDevToolService")
194
+ val instance = cls.getMethod("getINSTANCE").invoke(null)
195
+ cls.getMethod("createInspectorOwner", com.lynx.tasm.LynxView::class.java, Boolean::class.java)
196
+ .invoke(instance, view, true)
197
+ } catch (_: Exception) {}
198
+ }
199
+ }
200
+ },
201
+ currentUrl = currentUrl,
202
+ perfHudEnabled = perfHudEnabled,
203
+ logBoxEnabled = logBoxEnabled,
204
+ inspectorEnabled = inspectorEnabled,
205
+ nativeModules = nativeModules
206
+ )
207
+ )
208
+ }