create-cmp-cli 0.2.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 (139) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +232 -0
  3. package/bin/create-cmp.mjs +91 -0
  4. package/options.schema.json +110 -0
  5. package/package.json +52 -0
  6. package/src/bootstrap/checks.mjs +283 -0
  7. package/src/bootstrap/exec.mjs +90 -0
  8. package/src/commands/clean.mjs +195 -0
  9. package/src/commands/create.mjs +223 -0
  10. package/src/commands/doctor.mjs +256 -0
  11. package/src/commands/upgrade.mjs +196 -0
  12. package/src/commands/verify.mjs +94 -0
  13. package/src/doctor.mjs +109 -0
  14. package/src/lib/args.mjs +38 -0
  15. package/src/lib/clean.mjs +72 -0
  16. package/src/lib/fsutil.mjs +90 -0
  17. package/src/lib/log.mjs +40 -0
  18. package/src/lib/project-doctor.mjs +329 -0
  19. package/src/lib/registry.mjs +102 -0
  20. package/src/lib/rename.mjs +116 -0
  21. package/src/lib/schema.mjs +123 -0
  22. package/src/lib/toggle.mjs +130 -0
  23. package/src/lib/tokens.mjs +96 -0
  24. package/src/lib/toml.mjs +137 -0
  25. package/src/lib/upgrade.mjs +159 -0
  26. package/src/lib/verify.mjs +97 -0
  27. package/src/scaffold.mjs +347 -0
  28. package/src/verify.mjs +4 -0
  29. package/src/versions/registry.json +42 -0
  30. package/template/.github/workflows/verify.yml +70 -0
  31. package/template/build.gradle.kts +17 -0
  32. package/template/composeApp/build.gradle.kts +257 -0
  33. package/template/composeApp/google-services.json +29 -0
  34. package/template/composeApp/proguard-rules.pro +34 -0
  35. package/template/composeApp/src/androidDebug/AndroidManifest.xml +13 -0
  36. package/template/composeApp/src/androidDebug/kotlin/com/example/app/inspector/ComposeRootRegistry.kt +40 -0
  37. package/template/composeApp/src/androidDebug/kotlin/com/example/app/inspector/InspectorCatalog.kt +80 -0
  38. package/template/composeApp/src/androidDebug/kotlin/com/example/app/inspector/InspectorHttpServer.kt +322 -0
  39. package/template/composeApp/src/androidDebug/kotlin/com/example/app/inspector/InspectorInit.kt +16 -0
  40. package/template/composeApp/src/androidDebug/kotlin/com/example/app/inspector/LiveSemanticsJson.kt +102 -0
  41. package/template/composeApp/src/androidDebug/kotlin/com/example/app/inspector/RemoteControlPage.kt +106 -0
  42. package/template/composeApp/src/androidDebug/res/xml/debug_network_security_config.xml +9 -0
  43. package/template/composeApp/src/androidMain/AndroidManifest.xml +28 -0
  44. package/template/composeApp/src/androidMain/kotlin/com/example/app/AppApplication.kt +72 -0
  45. package/template/composeApp/src/androidMain/kotlin/com/example/app/MainActivity.kt +24 -0
  46. package/template/composeApp/src/androidMain/kotlin/com/example/app/core/connectivity/NetworkMonitor.kt +42 -0
  47. package/template/composeApp/src/androidMain/kotlin/com/example/app/data/local/DatabaseBuilder.android.kt +13 -0
  48. package/template/composeApp/src/androidMain/kotlin/com/example/app/di/AndroidModule.kt +9 -0
  49. package/template/composeApp/src/androidMain/kotlin/com/example/app/presentation/components/TestTagAutomation.android.kt +11 -0
  50. package/template/composeApp/src/androidMain/res/drawable/ic_launcher_background.xml +9 -0
  51. package/template/composeApp/src/androidMain/res/mipmap-anydpi-v26/ic_launcher.xml +6 -0
  52. package/template/composeApp/src/androidMain/res/mipmap-anydpi-v26/ic_launcher_round.xml +6 -0
  53. package/template/composeApp/src/androidMain/res/mipmap-hdpi/ic_launcher.png +0 -0
  54. package/template/composeApp/src/androidMain/res/mipmap-hdpi/ic_launcher_foreground.png +0 -0
  55. package/template/composeApp/src/androidMain/res/mipmap-hdpi/ic_launcher_round.png +0 -0
  56. package/template/composeApp/src/androidMain/res/mipmap-mdpi/ic_launcher.png +0 -0
  57. package/template/composeApp/src/androidMain/res/mipmap-mdpi/ic_launcher_foreground.png +0 -0
  58. package/template/composeApp/src/androidMain/res/mipmap-mdpi/ic_launcher_round.png +0 -0
  59. package/template/composeApp/src/androidMain/res/mipmap-xhdpi/ic_launcher.png +0 -0
  60. package/template/composeApp/src/androidMain/res/mipmap-xhdpi/ic_launcher_foreground.png +0 -0
  61. package/template/composeApp/src/androidMain/res/mipmap-xhdpi/ic_launcher_round.png +0 -0
  62. package/template/composeApp/src/androidMain/res/mipmap-xxhdpi/ic_launcher.png +0 -0
  63. package/template/composeApp/src/androidMain/res/mipmap-xxhdpi/ic_launcher_foreground.png +0 -0
  64. package/template/composeApp/src/androidMain/res/mipmap-xxhdpi/ic_launcher_round.png +0 -0
  65. package/template/composeApp/src/androidMain/res/mipmap-xxxhdpi/ic_launcher.png +0 -0
  66. package/template/composeApp/src/androidMain/res/mipmap-xxxhdpi/ic_launcher_foreground.png +0 -0
  67. package/template/composeApp/src/androidMain/res/mipmap-xxxhdpi/ic_launcher_round.png +0 -0
  68. package/template/composeApp/src/androidMain/res/values/colors.xml +6 -0
  69. package/template/composeApp/src/androidMain/res/values/themes.xml +9 -0
  70. package/template/composeApp/src/androidMain/res/values-v31/themes.xml +8 -0
  71. package/template/composeApp/src/androidRelease/kotlin/com/example/app/inspector/InspectorInit.kt +13 -0
  72. package/template/composeApp/src/commonMain/composeResources/font/DMSans_Bold.ttf +0 -0
  73. package/template/composeApp/src/commonMain/composeResources/font/DMSans_Medium.ttf +0 -0
  74. package/template/composeApp/src/commonMain/composeResources/font/DMSans_Regular.ttf +0 -0
  75. package/template/composeApp/src/commonMain/composeResources/font/DMSans_SemiBold.ttf +0 -0
  76. package/template/composeApp/src/commonMain/kotlin/com/example/app/App.kt +7 -0
  77. package/template/composeApp/src/commonMain/kotlin/com/example/app/core/connectivity/NetworkMonitor.kt +7 -0
  78. package/template/composeApp/src/commonMain/kotlin/com/example/app/data/local/AppDatabase.kt +28 -0
  79. package/template/composeApp/src/commonMain/kotlin/com/example/app/data/local/DatabaseBuilder.kt +14 -0
  80. package/template/composeApp/src/commonMain/kotlin/com/example/app/data/local/ItemDao.kt +18 -0
  81. package/template/composeApp/src/commonMain/kotlin/com/example/app/data/remote/FirebaseConfig.kt +5 -0
  82. package/template/composeApp/src/commonMain/kotlin/com/example/app/data/remote/ItemRepositoryImpl.kt +22 -0
  83. package/template/composeApp/src/commonMain/kotlin/com/example/app/di/AppModule.kt +23 -0
  84. package/template/composeApp/src/commonMain/kotlin/com/example/app/domain/model/Item.kt +8 -0
  85. package/template/composeApp/src/commonMain/kotlin/com/example/app/domain/repository/ItemRepository.kt +8 -0
  86. package/template/composeApp/src/commonMain/kotlin/com/example/app/domain/usecase/GetItemsUseCase.kt +12 -0
  87. package/template/composeApp/src/commonMain/kotlin/com/example/app/presentation/App.kt +13 -0
  88. package/template/composeApp/src/commonMain/kotlin/com/example/app/presentation/components/BaseScreen.kt +69 -0
  89. package/template/composeApp/src/commonMain/kotlin/com/example/app/presentation/components/TestTagAutomation.kt +18 -0
  90. package/template/composeApp/src/commonMain/kotlin/com/example/app/presentation/home/DetailScreen.kt +39 -0
  91. package/template/composeApp/src/commonMain/kotlin/com/example/app/presentation/home/HomeScreen.kt +87 -0
  92. package/template/composeApp/src/commonMain/kotlin/com/example/app/presentation/home/HomeViewModel.kt +35 -0
  93. package/template/composeApp/src/commonMain/kotlin/com/example/app/presentation/navigation/AppNavHost.kt +43 -0
  94. package/template/composeApp/src/commonMain/kotlin/com/example/app/presentation/navigation/AppShell.kt +144 -0
  95. package/template/composeApp/src/commonMain/kotlin/com/example/app/presentation/navigation/AppTab.kt +25 -0
  96. package/template/composeApp/src/commonMain/kotlin/com/example/app/presentation/navigation/Screen.kt +15 -0
  97. package/template/composeApp/src/commonMain/kotlin/com/example/app/presentation/profile/ProfileScreen.kt +43 -0
  98. package/template/composeApp/src/commonMain/kotlin/com/example/app/presentation/theme/DesignToken.kt +25 -0
  99. package/template/composeApp/src/commonMain/kotlin/com/example/app/presentation/theme/Shape.kt +12 -0
  100. package/template/composeApp/src/commonMain/kotlin/com/example/app/presentation/theme/Theme.kt +64 -0
  101. package/template/composeApp/src/commonMain/kotlin/com/example/app/presentation/theme/Tokens.kt +19 -0
  102. package/template/composeApp/src/commonMain/kotlin/com/example/app/presentation/theme/Typography.kt +36 -0
  103. package/template/composeApp/src/desktopMain/kotlin/com/example/app/core/connectivity/NetworkMonitor.desktop.kt +9 -0
  104. package/template/composeApp/src/desktopMain/kotlin/com/example/app/data/local/DatabaseBuilder.desktop.kt +13 -0
  105. package/template/composeApp/src/desktopMain/kotlin/com/example/app/di/DesktopModule.kt +32 -0
  106. package/template/composeApp/src/desktopMain/kotlin/com/example/app/main.kt +29 -0
  107. package/template/composeApp/src/desktopMain/kotlin/com/example/app/presentation/components/TestTagAutomation.desktop.kt +6 -0
  108. package/template/composeApp/src/iosMain/kotlin/com/example/app/KoinHelper.kt +50 -0
  109. package/template/composeApp/src/iosMain/kotlin/com/example/app/MainViewController.kt +10 -0
  110. package/template/composeApp/src/iosMain/kotlin/com/example/app/core/connectivity/NetworkMonitor.kt +32 -0
  111. package/template/composeApp/src/iosMain/kotlin/com/example/app/data/local/DatabaseBuilder.ios.kt +10 -0
  112. package/template/composeApp/src/iosMain/kotlin/com/example/app/presentation/components/TestTagAutomation.ios.kt +11 -0
  113. package/template/docs/dev-client.md +52 -0
  114. package/template/gitignore +21 -0
  115. package/template/gradle/libs.versions.toml +100 -0
  116. package/template/gradle/wrapper/gradle-wrapper.jar +0 -0
  117. package/template/gradle/wrapper/gradle-wrapper.properties +7 -0
  118. package/template/gradle.properties +9 -0
  119. package/template/gradlew +152 -0
  120. package/template/gradlew.bat +94 -0
  121. package/template/iosApp/Podfile +27 -0
  122. package/template/iosApp/iosApp/Assets.xcassets/AppIcon.appiconset/AppIcon-1024.png +0 -0
  123. package/template/iosApp/iosApp/Assets.xcassets/AppIcon.appiconset/Contents.json +14 -0
  124. package/template/iosApp/iosApp/Assets.xcassets/Contents.json +6 -0
  125. package/template/iosApp/iosApp/ContentView.swift +17 -0
  126. package/template/iosApp/iosApp/GoogleService-Info.plist +34 -0
  127. package/template/iosApp/iosApp/Info.plist +43 -0
  128. package/template/iosApp/iosApp/PrivacyInfo.xcprivacy +42 -0
  129. package/template/iosApp/iosApp/iOSApp.swift +33 -0
  130. package/template/iosApp/project.yml +56 -0
  131. package/template/local.properties.example +2 -0
  132. package/template/manifest.json +125 -0
  133. package/template/qa/appium/README.md +23 -0
  134. package/template/qa/appium/lib/appium-client.mjs +225 -0
  135. package/template/qa/appium/package.json +8 -0
  136. package/template/qa/appium/run-android-smoke.mjs +39 -0
  137. package/template/settings.gradle.kts +34 -0
  138. package/template/tests/appium/cmp/conftest.py +96 -0
  139. package/template/tests/appium/cmp/test_smoke.py +17 -0
@@ -0,0 +1,322 @@
1
+ package __PACKAGE__.inspector
2
+
3
+ import android.graphics.Bitmap
4
+ import android.graphics.Canvas
5
+ import android.os.Handler
6
+ import android.os.Looper
7
+ import android.os.SystemClock
8
+ import android.util.Log
9
+ import android.view.MotionEvent
10
+ import androidx.core.view.drawToBitmap
11
+ import java.io.BufferedReader
12
+ import java.io.ByteArrayOutputStream
13
+ import java.io.InputStreamReader
14
+ import java.net.InetAddress
15
+ import java.net.ServerSocket
16
+ import java.net.Socket
17
+ import java.nio.charset.StandardCharsets
18
+ import java.util.concurrent.CountDownLatch
19
+ import java.util.concurrent.TimeUnit
20
+ import java.util.concurrent.atomic.AtomicReference
21
+ import kotlinx.serialization.json.Json
22
+ import kotlinx.serialization.json.JsonPrimitive
23
+ import kotlinx.serialization.json.floatOrNull
24
+ import kotlinx.serialization.json.jsonObject
25
+ import kotlinx.serialization.json.jsonPrimitive
26
+ import kotlin.math.roundToInt
27
+
28
+ /**
29
+ * Debug-only, zero-dependency inspection server: a hand-rolled HTTP/1.1 responder over a
30
+ * plain [ServerSocket]. Binds LOOPBACK ONLY (never on the LAN); the host reaches it via
31
+ * `adb forward tcp:9500 tcp:9500`.
32
+ *
33
+ * Routes (JSON unless noted, `Connection: close`):
34
+ * GET /inspect/health → { status, schemaVersion, source, appId, buildType }
35
+ * GET /inspect/tree → the semantics-tree contract document (source "live-android"),
36
+ * read from the topmost Compose root ON THE MAIN THREAD.
37
+ * 503 while no Compose root is attached yet (cold start).
38
+ * GET /inspect/design-system → the declared token catalog { colors, dimens }.
39
+ * GET /inspect/screenshot → PNG bytes of the current Compose root (`image/png`) —
40
+ * pixels for the HUMAN's live view, never for model context.
41
+ * POST /inspect/tap → body {"x":<px>,"y":<px>} (root-relative px, exactly as the
42
+ * tree's bounds report them) → dispatches a down+up MotionEvent
43
+ * pair to the root view → {"tapped":true,"x":…,"y":…}.
44
+ * GET /inspect/remote → the self-contained remote-control HTML page (same-origin,
45
+ * zero CORS): live screenshot + click-to-tap in a browser.
46
+ *
47
+ * Single-threaded accept loop on a daemon thread = one client at a time = bounded by design.
48
+ * Failure to bind logs a warning and gives up — the inspector must never crash or block
49
+ * app startup. This class exists only in the androidDebug source set; release builds do
50
+ * not compile it at all.
51
+ */
52
+ object InspectorHttpServer {
53
+
54
+ const val PORT = 9500
55
+ private const val TAG = "CmpInspector"
56
+
57
+ // The main thread is busy during cold start (first setContent/layout) — be generous.
58
+ private const val MAIN_THREAD_TIMEOUT_MS = 5_000L
59
+
60
+ // Gap between the synthetic ACTION_DOWN and ACTION_UP — a natural, unambiguous tap
61
+ // (well under the long-press timeout).
62
+ private const val TAP_UP_DELAY_MS = 50L
63
+
64
+ private const val JSON_TYPE = "application/json; charset=utf-8"
65
+ private const val HTML_TYPE = "text/html; charset=utf-8"
66
+ private const val PNG_TYPE = "image/png"
67
+
68
+ @Volatile private var started = false
69
+
70
+ fun start(appId: String) {
71
+ if (started) return
72
+ started = true
73
+ val thread = Thread({ serve(appId) }, "cmp-inspector-http")
74
+ thread.isDaemon = true
75
+ thread.start()
76
+ }
77
+
78
+ private fun serve(appId: String) {
79
+ val socket = try {
80
+ ServerSocket(PORT, 1, InetAddress.getLoopbackAddress())
81
+ } catch (t: Throwable) {
82
+ Log.w(TAG, "inspector server failed to bind 127.0.0.1:$PORT — giving up", t)
83
+ return
84
+ }
85
+ Log.i(TAG, "inspector server listening on 127.0.0.1:$PORT (debug build only)")
86
+ while (true) {
87
+ val client = try {
88
+ socket.accept()
89
+ } catch (t: Throwable) {
90
+ Log.w(TAG, "inspector accept failed — stopping", t)
91
+ return
92
+ }
93
+ try {
94
+ client.use { handle(it, appId) }
95
+ } catch (t: Throwable) {
96
+ // Never let a bad request take the app (or the accept loop) down.
97
+ Log.w(TAG, "inspector request failed", t)
98
+ }
99
+ }
100
+ }
101
+
102
+ private fun handle(client: Socket, appId: String) {
103
+ client.soTimeout = 5_000
104
+ val reader = BufferedReader(InputStreamReader(client.getInputStream(), StandardCharsets.UTF_8))
105
+ val requestLine = reader.readLine() ?: return
106
+ // Drain headers; the only one we ever need is Content-Length (POST /inspect/tap).
107
+ var contentLength = 0
108
+ while (true) {
109
+ val line = reader.readLine() ?: break
110
+ if (line.isEmpty()) break
111
+ if (line.startsWith("Content-Length:", ignoreCase = true)) {
112
+ contentLength = line.substringAfter(':').trim().toIntOrNull() ?: 0
113
+ }
114
+ }
115
+ val parts = requestLine.split(" ")
116
+ val method = parts.getOrNull(0) ?: ""
117
+ val path = (parts.getOrNull(1) ?: "").substringBefore('?')
118
+
119
+ when {
120
+ method == "GET" && path == "/inspect/health" ->
121
+ writeJson(client, 200, healthJson(appId))
122
+ method == "GET" && path == "/inspect/tree" ->
123
+ treeResponse().let { (s, b) -> writeJson(client, s, b) }
124
+ method == "GET" && path == "/inspect/design-system" ->
125
+ writeJson(client, 200, InspectorCatalog.json())
126
+ method == "GET" && path == "/inspect/screenshot" ->
127
+ screenshotResponse(client)
128
+ method == "GET" && path == "/inspect/remote" ->
129
+ writeResponse(client, 200, RemoteControlPage.html(appId).toByteArray(StandardCharsets.UTF_8), HTML_TYPE)
130
+ method == "POST" && path == "/inspect/tap" ->
131
+ tapResponse(readBody(reader, contentLength)).let { (s, b) -> writeJson(client, s, b) }
132
+ method != "GET" && method != "POST" ->
133
+ writeJson(client, 405, errorJson("method not allowed"))
134
+ else ->
135
+ writeJson(client, 404, errorJson("unknown path"))
136
+ }
137
+ }
138
+
139
+ /**
140
+ * Read the request body. The stream is already wrapped in a UTF-8 reader, so we read
141
+ * [contentLength] CHARS — exact for the ASCII JSON `{"x":…,"y":…}` this route accepts
142
+ * (and never under-reads it), which is all this debug server needs.
143
+ */
144
+ private fun readBody(reader: BufferedReader, contentLength: Int): String {
145
+ if (contentLength <= 0) return ""
146
+ val buf = CharArray(contentLength.coerceAtMost(8_192))
147
+ var read = 0
148
+ while (read < buf.size) {
149
+ val n = reader.read(buf, read, buf.size - read)
150
+ if (n < 0) break
151
+ read += n
152
+ }
153
+ return String(buf, 0, read)
154
+ }
155
+
156
+ private fun healthJson(appId: String): String =
157
+ """{"status":"ok","schemaVersion":1,"source":"live-android","appId":${JsonPrimitive(appId)},"buildType":"debug"}"""
158
+
159
+ private fun treeResponse(): Pair<Int, String> {
160
+ val root = ComposeRootRegistry.current()
161
+ ?: return 503 to errorJson(
162
+ "compose root not ready yet — no Compose root attached (cold start?). Retry shortly."
163
+ )
164
+ // Semantics/layout nodes are not thread-safe: read the tree on the main thread and
165
+ // await with a generous timeout (the main thread is busy during first composition).
166
+ val result = AtomicReference<Pair<Int, String>>()
167
+ val latch = CountDownLatch(1)
168
+ Handler(Looper.getMainLooper()).post {
169
+ result.set(
170
+ try {
171
+ // Merged tree — matches the Phase 0 harness (onRoot() default).
172
+ 200 to LiveSemanticsJson.dumpTree(root.semanticsOwner.rootSemanticsNode)
173
+ } catch (t: Throwable) {
174
+ 500 to errorJson("failed to walk semantics tree: ${t.message}")
175
+ }
176
+ )
177
+ latch.countDown()
178
+ }
179
+ return if (latch.await(MAIN_THREAD_TIMEOUT_MS, TimeUnit.MILLISECONDS)) {
180
+ result.get()
181
+ } else {
182
+ 503 to errorJson("main thread did not respond within ${MAIN_THREAD_TIMEOUT_MS}ms — app busy (cold start?). Retry.")
183
+ }
184
+ }
185
+
186
+ /**
187
+ * PNG of the current Compose root. The Bitmap is rendered on the MAIN thread (views are
188
+ * not thread-safe); PNG compression — tens of ms for a full screen — happens back on the
189
+ * server thread so the UI never pays for it.
190
+ */
191
+ private fun screenshotResponse(client: Socket) {
192
+ val root = ComposeRootRegistry.current()
193
+ if (root == null) {
194
+ writeJson(
195
+ client, 503,
196
+ errorJson("compose root not ready yet — no Compose root attached (cold start?). Retry shortly.")
197
+ )
198
+ return
199
+ }
200
+ val bitmapRef = AtomicReference<Bitmap?>()
201
+ val errorRef = AtomicReference<String?>()
202
+ val latch = CountDownLatch(1)
203
+ Handler(Looper.getMainLooper()).post {
204
+ try {
205
+ val view = root.view
206
+ bitmapRef.set(
207
+ try {
208
+ // androidx.core.view.drawToBitmap (core-ktx — already an androidMain dep).
209
+ view.drawToBitmap()
210
+ } catch (t: Throwable) {
211
+ // Not laid out yet / hardware path refused — plain Canvas draw fallback.
212
+ Bitmap.createBitmap(
213
+ view.width.coerceAtLeast(1),
214
+ view.height.coerceAtLeast(1),
215
+ Bitmap.Config.ARGB_8888,
216
+ ).also { view.draw(Canvas(it)) }
217
+ }
218
+ )
219
+ } catch (t: Throwable) {
220
+ errorRef.set("failed to render screenshot: ${t.message}")
221
+ }
222
+ latch.countDown()
223
+ }
224
+ if (!latch.await(MAIN_THREAD_TIMEOUT_MS, TimeUnit.MILLISECONDS)) {
225
+ writeJson(client, 503, errorJson("main thread did not respond within ${MAIN_THREAD_TIMEOUT_MS}ms — app busy (cold start?). Retry."))
226
+ return
227
+ }
228
+ errorRef.get()?.let {
229
+ writeJson(client, 500, errorJson(it))
230
+ return
231
+ }
232
+ val bitmap = bitmapRef.get()
233
+ if (bitmap == null) {
234
+ writeJson(client, 500, errorJson("failed to render screenshot: no bitmap produced"))
235
+ return
236
+ }
237
+ val bytes = ByteArrayOutputStream().use { out ->
238
+ bitmap.compress(Bitmap.CompressFormat.PNG, 100, out)
239
+ out.toByteArray()
240
+ }
241
+ bitmap.recycle()
242
+ writeResponse(client, 200, bytes, PNG_TYPE)
243
+ }
244
+
245
+ /**
246
+ * Dispatch a synthetic tap (ACTION_DOWN, then ACTION_UP ~50ms later) to the topmost
247
+ * Compose root, on the MAIN thread. Coordinates are root-relative px — exactly the
248
+ * space the tree's `bounds` report, so callers can tap what they just inspected.
249
+ */
250
+ private fun tapResponse(body: String): Pair<Int, String> {
251
+ val (x, y) = try {
252
+ val obj = Json.parseToJsonElement(body).jsonObject
253
+ val px = obj["x"]?.jsonPrimitive?.floatOrNull
254
+ val py = obj["y"]?.jsonPrimitive?.floatOrNull
255
+ if (px == null || py == null) {
256
+ return 400 to errorJson("""tap body must be {"x":<px>,"y":<px>} (root-relative px).""")
257
+ }
258
+ px to py
259
+ } catch (t: Throwable) {
260
+ return 400 to errorJson("""tap body must be {"x":<px>,"y":<px>} (root-relative px).""")
261
+ }
262
+ val root = ComposeRootRegistry.current()
263
+ ?: return 503 to errorJson(
264
+ "compose root not ready yet — no Compose root attached (cold start?). Retry shortly."
265
+ )
266
+ val latch = CountDownLatch(1)
267
+ val handler = Handler(Looper.getMainLooper())
268
+ handler.post {
269
+ try {
270
+ val view = root.view
271
+ val downTime = SystemClock.uptimeMillis()
272
+ val down = MotionEvent.obtain(downTime, downTime, MotionEvent.ACTION_DOWN, x, y, 0)
273
+ view.dispatchTouchEvent(down)
274
+ down.recycle()
275
+ handler.postDelayed({
276
+ try {
277
+ val up = MotionEvent.obtain(downTime, SystemClock.uptimeMillis(), MotionEvent.ACTION_UP, x, y, 0)
278
+ view.dispatchTouchEvent(up)
279
+ up.recycle()
280
+ } finally {
281
+ latch.countDown()
282
+ }
283
+ }, TAP_UP_DELAY_MS)
284
+ } catch (t: Throwable) {
285
+ latch.countDown()
286
+ }
287
+ }
288
+ return if (latch.await(MAIN_THREAD_TIMEOUT_MS, TimeUnit.MILLISECONDS)) {
289
+ 200 to """{"tapped":true,"x":${x.roundToInt()},"y":${y.roundToInt()}}"""
290
+ } else {
291
+ 503 to errorJson("main thread did not respond within ${MAIN_THREAD_TIMEOUT_MS}ms — app busy (cold start?). Retry.")
292
+ }
293
+ }
294
+
295
+ private fun errorJson(message: String): String =
296
+ """{"error":${JsonPrimitive(message)}}"""
297
+
298
+ private fun writeJson(client: Socket, status: Int, body: String) =
299
+ writeResponse(client, status, body.toByteArray(StandardCharsets.UTF_8), JSON_TYPE)
300
+
301
+ private fun writeResponse(client: Socket, status: Int, bytes: ByteArray, contentType: String) {
302
+ val reason = when (status) {
303
+ 200 -> "OK"
304
+ 400 -> "Bad Request"
305
+ 404 -> "Not Found"
306
+ 405 -> "Method Not Allowed"
307
+ 503 -> "Service Unavailable"
308
+ else -> "Internal Server Error"
309
+ }
310
+ val head = buildString {
311
+ append("HTTP/1.1 ").append(status).append(' ').append(reason).append("\r\n")
312
+ append("Content-Type: ").append(contentType).append("\r\n")
313
+ append("Content-Length: ").append(bytes.size).append("\r\n")
314
+ append("Connection: close\r\n")
315
+ append("\r\n")
316
+ }
317
+ val out = client.getOutputStream()
318
+ out.write(head.toByteArray(StandardCharsets.UTF_8))
319
+ out.write(bytes)
320
+ out.flush()
321
+ }
322
+ }
@@ -0,0 +1,16 @@
1
+ package __PACKAGE__.inspector
2
+
3
+ import android.app.Application
4
+
5
+ /**
6
+ * DEBUG variant: install the Compose root registry (must happen BEFORE any Activity so the
7
+ * `onViewCreatedCallback` hook catches every root), then start the loopback-only inspection
8
+ * server on 127.0.0.1:9500. Reach it from the host via `adb forward tcp:9500 tcp:9500`.
9
+ *
10
+ * The release source set carries a same-signature no-op twin — the compiler picks the variant
11
+ * body, so release builds contain no inspector code at all (structural absence, not a flag).
12
+ */
13
+ fun Application.startInspector() {
14
+ ComposeRootRegistry.install()
15
+ InspectorHttpServer.start(appId = packageName)
16
+ }
@@ -0,0 +1,102 @@
1
+ package __PACKAGE__.inspector
2
+
3
+ import androidx.compose.ui.semantics.SemanticsActions
4
+ import androidx.compose.ui.semantics.SemanticsNode
5
+ import androidx.compose.ui.semantics.SemanticsProperties
6
+ import androidx.compose.ui.semantics.getOrNull
7
+ import __PACKAGE__.presentation.theme.DesignTokenKey
8
+ import kotlinx.serialization.json.Json
9
+ import kotlinx.serialization.json.JsonElement
10
+ import kotlinx.serialization.json.JsonNull
11
+ import kotlinx.serialization.json.JsonObject
12
+ import kotlinx.serialization.json.JsonPrimitive
13
+ import kotlinx.serialization.json.buildJsonArray
14
+ import kotlinx.serialization.json.buildJsonObject
15
+ import kotlin.math.roundToInt
16
+
17
+ /**
18
+ * Walks a Compose [SemanticsNode] tree and serialises it to JSON matching the create-cmp
19
+ * inspector contract (schemaVersion 1, source "live-android"). This mirrors the Phase 0
20
+ * harness serializer (`inspector/harness/.../SemanticsJson.kt`) node-for-node — the live
21
+ * path yields the SAME SemanticsNode type, so tier-0 and tier-1 output are structurally
22
+ * identical by construction. Every node carries pixel, root-relative bounds and a (possibly
23
+ * empty) `children` array; testTag/text/contentDescription/designToken are nullable.
24
+ *
25
+ * Optional contract fields (still schemaVersion 1):
26
+ * - `role` string|null — [SemanticsProperties.Role] (e.g. "Button").
27
+ * - `clickable` boolean — presence of [SemanticsActions.OnClick].
28
+ * - `disabled` boolean — presence of [SemanticsProperties.Disabled].
29
+ */
30
+ object LiveSemanticsJson {
31
+
32
+ private val prettyJson = Json { prettyPrint = true }
33
+
34
+ /** Serialises [root] as the top-level contract document string. */
35
+ fun dumpTree(root: SemanticsNode): String {
36
+ val doc = buildJsonObject {
37
+ put("schemaVersion", JsonPrimitive(1))
38
+ put("source", JsonPrimitive("live-android"))
39
+ put("root", nodeToJson(root))
40
+ }
41
+ return prettyJson.encodeToString(JsonElement.serializer(), doc)
42
+ }
43
+
44
+ private fun nodeToJson(node: SemanticsNode): JsonObject = buildJsonObject {
45
+ put("testTag", node.testTag().toJson())
46
+ put("text", node.text().toJson())
47
+ put("contentDescription", node.contentDescription().toJson())
48
+ put("role", node.roleName().toJson())
49
+ put("clickable", JsonPrimitive(node.isClickable()))
50
+ put("disabled", JsonPrimitive(node.isDisabled()))
51
+ put("bounds", node.boundsJson())
52
+ put("designToken", node.designTokenJson())
53
+ put("children", buildJsonArray {
54
+ node.children.forEach { add(nodeToJson(it)) }
55
+ })
56
+ }
57
+
58
+ private fun SemanticsNode.testTag(): String? =
59
+ config.getOrNull(SemanticsProperties.TestTag)
60
+
61
+ private fun SemanticsNode.text(): String? =
62
+ config.getOrNull(SemanticsProperties.Text)
63
+ ?.joinToString(separator = " ") { it.text }
64
+ ?.takeIf { it.isNotEmpty() }
65
+
66
+ private fun SemanticsNode.contentDescription(): String? =
67
+ config.getOrNull(SemanticsProperties.ContentDescription)
68
+ ?.joinToString(separator = " ")
69
+ ?.takeIf { it.isNotEmpty() }
70
+
71
+ private fun SemanticsNode.roleName(): String? =
72
+ config.getOrNull(SemanticsProperties.Role)?.toString()
73
+
74
+ private fun SemanticsNode.isClickable(): Boolean =
75
+ config.contains(SemanticsActions.OnClick)
76
+
77
+ private fun SemanticsNode.isDisabled(): Boolean =
78
+ config.contains(SemanticsProperties.Disabled)
79
+
80
+ private fun SemanticsNode.boundsJson(): JsonObject {
81
+ val rect = boundsInRoot
82
+ return buildJsonObject {
83
+ put("x", JsonPrimitive(rect.left.roundToInt()))
84
+ put("y", JsonPrimitive(rect.top.roundToInt()))
85
+ put("width", JsonPrimitive(rect.width.roundToInt()))
86
+ put("height", JsonPrimitive(rect.height.roundToInt()))
87
+ }
88
+ }
89
+
90
+ private fun SemanticsNode.designTokenJson(): JsonElement {
91
+ val info = config.getOrNull(DesignTokenKey) ?: return JsonNull
92
+ return buildJsonObject {
93
+ put("tokens", buildJsonArray { info.tokens.forEach { add(JsonPrimitive(it)) } })
94
+ put("resolved", buildJsonObject {
95
+ info.resolved.forEach { (k, v) -> put(k, JsonPrimitive(v)) }
96
+ })
97
+ }
98
+ }
99
+
100
+ private fun String?.toJson(): JsonElement =
101
+ if (this == null) JsonNull else JsonPrimitive(this)
102
+ }
@@ -0,0 +1,106 @@
1
+ package __PACKAGE__.inspector
2
+
3
+ /**
4
+ * The `/inspect/remote` page: a self-contained, same-origin (zero CORS) remote-control view
5
+ * of the running app — the "Running Devices window" for a browser. The HUMAN watches the live
6
+ * screenshot (re-fetched ~every 700ms with a cache-buster) and clicks it; the click is scaled
7
+ * from displayed-image space to device px and delivered via `POST /inspect/tap`, and the next
8
+ * poll shows the result. A light `/inspect/tree` poll (~every 3s) feeds the header stats.
9
+ *
10
+ * No external resources, dark-friendly, phone-aspect. Pixels here flow to the HUMAN's browser
11
+ * only — the agent keeps asserting on the tree. Debug builds only, loopback only, like every
12
+ * other route on this server.
13
+ */
14
+ object RemoteControlPage {
15
+
16
+ fun html(appId: String): String = """<!doctype html>
17
+ <html>
18
+ <head>
19
+ <meta charset="utf-8">
20
+ <meta name="viewport" content="width=device-width,initial-scale=1">
21
+ <title>$appId — live device view</title>
22
+ <style>
23
+ body { margin: 0; background: #101216; color: #e8eaed; display: flex; flex-direction: column;
24
+ min-height: 100vh; font: 13px/1.4 -apple-system, BlinkMacSystemFont, sans-serif; }
25
+ header { display: flex; justify-content: space-between; align-items: baseline; gap: 12px;
26
+ padding: 10px 16px; background: #1a1d23; position: sticky; top: 0;
27
+ border-bottom: 1px solid #2a2e36; }
28
+ header .app { font-weight: 600; }
29
+ #stats { color: #7ee2a8; font-variant-numeric: tabular-nums; }
30
+ main { flex: 1; display: flex; justify-content: center; align-items: flex-start; padding: 14px; }
31
+ #screen { max-height: calc(100vh - 92px); max-width: 94vw; border: 1px solid #2a2e36;
32
+ border-radius: 14px; background: #000; cursor: crosshair; }
33
+ #tapdot { position: fixed; width: 16px; height: 16px; border-radius: 50%; pointer-events: none;
34
+ background: rgba(126, 226, 168, .9); transform: translate(-50%, -50%); opacity: 0;
35
+ transition: opacity .4s; }
36
+ footer { padding: 6px 16px 10px; color: #7a8090; font-size: 11px; text-align: center; }
37
+ </style>
38
+ </head>
39
+ <body>
40
+ <header>
41
+ <span class="app">$appId · live device view</span>
42
+ <span id="stats">connecting…</span>
43
+ </header>
44
+ <main><img id="screen" alt="live device screen (click to tap)"></main>
45
+ <div id="tapdot"></div>
46
+ <footer>click the screen to tap the real device · create-cmp debug inspector · loopback only, debug builds only</footer>
47
+ <script>
48
+ "use strict";
49
+ var img = document.getElementById("screen");
50
+ var stats = document.getElementById("stats");
51
+ var dot = document.getElementById("tapdot");
52
+ var busy = false;
53
+
54
+ // Live screenshot: re-fetch ~every 700ms (cache-busted); swap only once loaded (no flicker).
55
+ function poll() {
56
+ if (busy) return;
57
+ busy = true;
58
+ var next = new Image();
59
+ next.onload = function () { img.src = next.src; busy = false; };
60
+ next.onerror = function () { busy = false; };
61
+ next.src = "/inspect/screenshot?t=" + Date.now();
62
+ }
63
+ setInterval(poll, 700);
64
+ poll();
65
+
66
+ // Click → scale displayed coords to device px (natural vs displayed size) → POST /inspect/tap.
67
+ img.addEventListener("click", function (e) {
68
+ if (!img.naturalWidth) return;
69
+ var r = img.getBoundingClientRect();
70
+ var x = Math.round((e.clientX - r.left) * (img.naturalWidth / r.width));
71
+ var y = Math.round((e.clientY - r.top) * (img.naturalHeight / r.height));
72
+ dot.style.left = e.clientX + "px";
73
+ dot.style.top = e.clientY + "px";
74
+ dot.style.opacity = "1";
75
+ setTimeout(function () { dot.style.opacity = "0"; }, 350);
76
+ fetch("/inspect/tap", {
77
+ method: "POST",
78
+ headers: { "Content-Type": "application/json" },
79
+ body: JSON.stringify({ x: x, y: y })
80
+ }).catch(function () {});
81
+ });
82
+
83
+ // Header stats: a light tree poll — node + tagged counts, ~every 3s.
84
+ function countNodes(node) {
85
+ var acc = { nodes: 1, tags: node.testTag ? 1 : 0 };
86
+ (node.children || []).forEach(function (c) {
87
+ var m = countNodes(c);
88
+ acc.nodes += m.nodes;
89
+ acc.tags += m.tags;
90
+ });
91
+ return acc;
92
+ }
93
+ function pollTree() {
94
+ fetch("/inspect/tree").then(function (res) { return res.json(); }).then(function (tree) {
95
+ if (!tree.root) { stats.textContent = "tree: not ready"; return; }
96
+ var n = countNodes(tree.root);
97
+ stats.textContent = n.nodes + " nodes · " + n.tags + " tagged";
98
+ }).catch(function () { stats.textContent = "tree: unreachable"; });
99
+ }
100
+ setInterval(pollTree, 3000);
101
+ pollTree();
102
+ </script>
103
+ </body>
104
+ </html>
105
+ """
106
+ }
@@ -0,0 +1,9 @@
1
+ <?xml version="1.0" encoding="utf-8"?>
2
+ <network-security-config>
3
+ <!-- Debug-only cleartext to the local Firebase emulators / dev backends. -->
4
+ <domain-config cleartextTrafficPermitted="true">
5
+ <domain includeSubdomains="true">127.0.0.1</domain>
6
+ <domain includeSubdomains="true">10.0.2.2</domain>
7
+ <domain includeSubdomains="true">localhost</domain>
8
+ </domain-config>
9
+ </network-security-config>
@@ -0,0 +1,28 @@
1
+ <?xml version="1.0" encoding="utf-8"?>
2
+ <manifest xmlns:android="http://schemas.android.com/apk/res/android">
3
+
4
+ <uses-permission android:name="android.permission.INTERNET" />
5
+ <uses-permission android:name="android.permission.ACCESS_NETWORK_STATE" />
6
+ <uses-permission android:name="android.permission.POST_NOTIFICATIONS" />
7
+
8
+ <application
9
+ android:name=".AppApplication"
10
+ android:label="__APP_NAME__"
11
+ android:icon="@mipmap/ic_launcher"
12
+ android:roundIcon="@mipmap/ic_launcher_round"
13
+ android:supportsRtl="true"
14
+ android:usesCleartextTraffic="${usesCleartextTraffic}"
15
+ android:theme="@style/Theme.App">
16
+
17
+ <activity
18
+ android:name=".MainActivity"
19
+ android:exported="true"
20
+ android:windowSoftInputMode="adjustResize">
21
+ <intent-filter>
22
+ <action android:name="android.intent.action.MAIN" />
23
+ <category android:name="android.intent.category.LAUNCHER" />
24
+ </intent-filter>
25
+ </activity>
26
+
27
+ </application>
28
+ </manifest>
@@ -0,0 +1,72 @@
1
+ package __PACKAGE__
2
+
3
+ import android.app.Application
4
+ // >>> cmp:feature firebase
5
+ import __PACKAGE__.data.remote.FIREBASE_FUNCTIONS_REGION
6
+ import dev.gitlive.firebase.Firebase
7
+ import dev.gitlive.firebase.auth.auth
8
+ import dev.gitlive.firebase.firestore.firestore
9
+ import dev.gitlive.firebase.functions.functions
10
+ import dev.gitlive.firebase.storage.storage
11
+ // <<< cmp:feature firebase
12
+ // >>> cmp:feature room
13
+ import __PACKAGE__.data.local.AppDatabase
14
+ import __PACKAGE__.data.local.appContext
15
+ import __PACKAGE__.data.local.buildDatabase
16
+ // <<< cmp:feature room
17
+ import __PACKAGE__.core.connectivity.NetworkMonitor
18
+ import __PACKAGE__.di.androidModule
19
+ // >>> cmp:feature inspector
20
+ import __PACKAGE__.inspector.startInspector
21
+ // <<< cmp:feature inspector
22
+ import __PACKAGE__.di.appModules
23
+ import org.koin.android.ext.koin.androidContext
24
+ import org.koin.android.ext.koin.androidLogger
25
+ import org.koin.core.context.startKoin
26
+ import org.koin.dsl.module
27
+
28
+ class AppApplication : Application() {
29
+ override fun onCreate() {
30
+ super.onCreate()
31
+ // >>> cmp:feature inspector
32
+ // Debug builds only: the androidRelease twin is a no-op (see inspector/InspectorInit.kt).
33
+ // Must run before any Activity so the Compose root registry catches every root.
34
+ startInspector()
35
+ // <<< cmp:feature inspector
36
+ // >>> cmp:feature room
37
+ appContext = this
38
+ // <<< cmp:feature room
39
+ // >>> cmp:feature firebase
40
+ configureFirebaseEmulators()
41
+ // <<< cmp:feature firebase
42
+ startKoin {
43
+ androidLogger()
44
+ androidContext(this@AppApplication)
45
+ modules(
46
+ module {
47
+ // >>> cmp:feature room
48
+ single<AppDatabase> { buildDatabase() }
49
+ // <<< cmp:feature room
50
+ single { NetworkMonitor(androidContext()) }
51
+ },
52
+ androidModule,
53
+ *appModules.toTypedArray()
54
+ )
55
+ }
56
+ }
57
+
58
+ // >>> cmp:feature firebase
59
+ // Debug builds talk to the local Firebase emulators (BuildConfig flags set in build.gradle.kts).
60
+ private fun configureFirebaseEmulators() {
61
+ if (!BuildConfig.USE_FIREBASE_EMULATORS) return
62
+ val host = BuildConfig.FIREBASE_EMULATOR_HOST
63
+ runCatching {
64
+ Firebase.auth.useEmulator(host, BuildConfig.FIREBASE_AUTH_PORT)
65
+ Firebase.firestore.useEmulator(host, BuildConfig.FIREBASE_FIRESTORE_PORT)
66
+ Firebase.functions(FIREBASE_FUNCTIONS_REGION)
67
+ .useEmulator(host, BuildConfig.FIREBASE_FUNCTIONS_PORT)
68
+ Firebase.storage.useEmulator(host, BuildConfig.FIREBASE_STORAGE_PORT)
69
+ }
70
+ }
71
+ // <<< cmp:feature firebase
72
+ }