create-cmp-cli 0.8.0 → 0.10.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 +83 -9
- package/llms.txt +5 -1
- package/package.json +1 -1
- package/src/lib/adr-seed.mjs +178 -0
- package/src/lib/tabs.mjs +97 -4
- package/src/scaffold.mjs +52 -1
- package/template/.claude/skills/add-feature/SKILL.md +35 -10
- package/template/.claude/skills/add-repository/SKILL.md +1 -1
- package/template/.claude/skills/add-screen/SKILL.md +13 -7
- package/template/.githooks/pre-push +24 -0
- package/template/CLAUDE.md +213 -47
- package/template/README.md +32 -27
- package/template/composeApp/src/androidDebug/kotlin/com/example/app/inspector/CrashRecorder.kt +99 -0
- package/template/composeApp/src/androidDebug/kotlin/com/example/app/inspector/DbInspector.kt +144 -0
- package/template/composeApp/src/androidDebug/kotlin/com/example/app/inspector/InspectorCatalog.kt +19 -0
- package/template/composeApp/src/androidDebug/kotlin/com/example/app/inspector/InspectorHttpServer.kt +177 -21
- package/template/composeApp/src/androidDebug/kotlin/com/example/app/inspector/InspectorInit.kt +8 -4
- package/template/composeApp/src/androidDebug/kotlin/com/example/app/inspector/LiveSemanticsJson.kt +10 -0
- package/template/composeApp/src/androidDebug/kotlin/com/example/app/inspector/NavInspector.kt +62 -0
- package/template/composeApp/src/commonMain/kotlin/com/example/app/data/AppResultCatching.kt +32 -0
- package/template/composeApp/src/commonMain/kotlin/com/example/app/data/remote/ItemRepositoryImpl.kt +9 -2
- package/template/composeApp/src/commonMain/kotlin/com/example/app/domain/model/DomainError.kt +21 -0
- package/template/composeApp/src/commonMain/kotlin/com/example/app/domain/repository/ItemRepository.kt +4 -1
- package/template/composeApp/src/commonMain/kotlin/com/example/app/domain/result/AppResult.kt +23 -0
- package/template/composeApp/src/commonMain/kotlin/com/example/app/domain/usecase/GetItemsUseCase.kt +4 -1
- package/template/composeApp/src/commonMain/kotlin/com/example/app/presentation/brand/BrandMark.kt +75 -0
- package/template/composeApp/src/commonMain/kotlin/com/example/app/presentation/components/AppBottomBar.kt +138 -0
- package/template/composeApp/src/commonMain/kotlin/com/example/app/presentation/components/AppButton.kt +56 -0
- package/template/composeApp/src/commonMain/kotlin/com/example/app/presentation/components/AppHeader.kt +63 -0
- package/template/composeApp/src/commonMain/kotlin/com/example/app/presentation/components/AppIconButton.kt +48 -0
- package/template/composeApp/src/commonMain/kotlin/com/example/app/presentation/components/BaseScreen.kt +16 -8
- package/template/composeApp/src/commonMain/kotlin/com/example/app/presentation/components/ContentStateContainer.kt +105 -0
- package/template/composeApp/src/commonMain/kotlin/com/example/app/presentation/components/ContentUiState.kt +18 -0
- package/template/composeApp/src/commonMain/kotlin/com/example/app/presentation/components/EmptyState.kt +58 -0
- package/template/composeApp/src/commonMain/kotlin/com/example/app/presentation/components/ErrorState.kt +52 -0
- package/template/composeApp/src/commonMain/kotlin/com/example/app/presentation/components/ListItemCard.kt +77 -0
- package/template/composeApp/src/commonMain/kotlin/com/example/app/presentation/components/ScreenColumn.kt +47 -0
- package/template/composeApp/src/commonMain/kotlin/com/example/app/presentation/components/Shimmer.kt +90 -0
- package/template/composeApp/src/commonMain/kotlin/com/example/app/presentation/components/TestTagAutomation.kt +9 -9
- package/template/composeApp/src/commonMain/kotlin/com/example/app/presentation/home/DetailScreen.kt +5 -27
- package/template/composeApp/src/commonMain/kotlin/com/example/app/presentation/home/HomeScreen.kt +14 -70
- package/template/composeApp/src/commonMain/kotlin/com/example/app/presentation/home/HomeViewModel.kt +33 -13
- package/template/composeApp/src/commonMain/kotlin/com/example/app/presentation/navigation/AppNavHost.kt +22 -0
- package/template/composeApp/src/commonMain/kotlin/com/example/app/presentation/navigation/AppShell.kt +7 -109
- package/template/composeApp/src/commonMain/kotlin/com/example/app/presentation/navigation/NavInspectionHook.kt +31 -0
- package/template/composeApp/src/commonMain/kotlin/com/example/app/presentation/profile/ProfileScreen.kt +4 -27
- package/template/composeApp/src/commonMain/kotlin/com/example/app/presentation/theme/Typography.kt +70 -6
- package/template/composeApp/src/commonTest/kotlin/com/example/app/data/AppResultCatchingTest.kt +52 -0
- package/template/composeApp/src/commonTest/kotlin/com/example/app/data/remote/ItemRepositoryImplTest.kt +29 -4
- package/template/composeApp/src/commonTest/kotlin/com/example/app/domain/usecase/GetItemsUseCaseTest.kt +8 -6
- package/template/composeApp/src/commonTest/kotlin/com/example/app/presentation/home/HomeViewModelTest.kt +39 -27
- package/template/composeApp/src/commonTest/kotlin/com/example/app/testing/fakes/FakeItemRepository.kt +10 -6
- package/template/composeApp/src/desktopMain/kotlin/com/example/app/inspector/ComponentStories.kt +300 -0
- package/template/composeApp/src/desktopMain/kotlin/com/example/app/inspector/PreviewDaemon.kt +5 -0
- package/template/composeApp/src/desktopMain/kotlin/com/example/app/inspector/PreviewHarness.kt +91 -1
- package/template/composeApp/src/desktopMain/kotlin/com/example/app/inspector/PreviewRegistry.kt +37 -1
- package/template/composeApp/src/desktopMain/kotlin/com/example/app/inspector/PreviewSemanticsJson.kt +14 -1
- package/template/composeApp/src/desktopTest/kotlin/com/example/app/conformance/ArchitectureConformanceTest.kt +250 -16
- package/template/composeApp/src/desktopTest/kotlin/com/example/app/conformance/ComponentConformanceTest.kt +84 -0
- package/template/composeApp/src/desktopTest/kotlin/com/example/app/presentation/home/HomeScreenTest.kt +36 -4
- package/template/docs/ARCHITECTURE.md +372 -34
- package/template/docs/TESTING.md +13 -5
- package/template/docs/adr/0002-maestro-over-appium-for-e2e.md +39 -0
- package/template/docs/adr/0003-jvm-desktop-target-is-harness-infrastructure.md +39 -0
- package/template/docs/adr/0004-fakes-not-mocks-for-unit-tests.md +48 -0
- package/template/qa/approvals.json +42 -0
- package/template/qa/approve.mjs +139 -0
- package/template/qa/arch-doc.mjs +69 -0
- package/template/qa/comment.mjs +76 -0
- package/template/qa/comments.json +4 -0
- package/template/qa/e2e/smoke.yaml +6 -0
- package/template/qa/golden/home.json +3 -3
- package/template/qa/lib/a11y.mjs +17 -8
- package/template/qa/lib/approvals.mjs +822 -0
- package/template/qa/lib/arch-doc.mjs +451 -0
- package/template/qa/lib/comments.mjs +252 -0
- package/template/qa/lib/component-stories.mjs +183 -0
- package/template/qa/lib/inputs-hash.mjs +5 -1
- package/template/qa/scaffold-feature.mjs +184 -67
- package/template/qa/setup-hooks.mjs +33 -0
- package/template/qa/verify.mjs +181 -15
- package/template/qa/walkthrough.mjs +499 -0
- package/template/specs/app-base.spec.md +49 -7
- package/template/specs/home.spec.md +7 -4
- package/template/specs/intent.md +50 -0
package/template/composeApp/src/androidDebug/kotlin/com/example/app/inspector/CrashRecorder.kt
ADDED
|
@@ -0,0 +1,99 @@
|
|
|
1
|
+
package __PACKAGE__.inspector
|
|
2
|
+
|
|
3
|
+
import android.content.Context
|
|
4
|
+
import android.util.Log
|
|
5
|
+
import java.io.File
|
|
6
|
+
import java.text.SimpleDateFormat
|
|
7
|
+
import java.util.Date
|
|
8
|
+
import java.util.Locale
|
|
9
|
+
import java.util.TimeZone
|
|
10
|
+
import kotlinx.serialization.json.Json
|
|
11
|
+
import kotlinx.serialization.json.JsonElement
|
|
12
|
+
import kotlinx.serialization.json.JsonNull
|
|
13
|
+
import kotlinx.serialization.json.JsonPrimitive
|
|
14
|
+
import kotlinx.serialization.json.buildJsonArray
|
|
15
|
+
import kotlinx.serialization.json.buildJsonObject
|
|
16
|
+
|
|
17
|
+
/**
|
|
18
|
+
* Debug-only crash capture: [install] sets a process-wide
|
|
19
|
+
* [Thread.UncaughtExceptionHandler] that persists crash JSON to
|
|
20
|
+
* `filesDir/inspector/crashes/` (bounded to the last [MAX_CRASHES]) so a crash survives the
|
|
21
|
+
* process death that follows it — an in-memory ring buffer would not.
|
|
22
|
+
*
|
|
23
|
+
* MUST NEVER SWALLOW THE CRASH: after persisting, it always hands off to whatever handler was
|
|
24
|
+
* installed before it (chained, not replaced) so system crash dialogs, `System.exit`, and any
|
|
25
|
+
* other crash-reporting tool still behave exactly as if this class did not exist.
|
|
26
|
+
*/
|
|
27
|
+
object CrashRecorder {
|
|
28
|
+
|
|
29
|
+
private const val TAG = "CmpInspector"
|
|
30
|
+
private const val MAX_CRASHES = 20
|
|
31
|
+
|
|
32
|
+
fun install(context: Context) {
|
|
33
|
+
val crashDir = File(context.filesDir, "inspector/crashes").apply { mkdirs() }
|
|
34
|
+
val previous = Thread.getDefaultUncaughtExceptionHandler()
|
|
35
|
+
Thread.setDefaultUncaughtExceptionHandler { thread, throwable ->
|
|
36
|
+
try {
|
|
37
|
+
persist(crashDir, throwable)
|
|
38
|
+
} catch (t: Throwable) {
|
|
39
|
+
// Persisting the crash record must never itself crash the crash handler.
|
|
40
|
+
Log.w(TAG, "failed to persist crash record", t)
|
|
41
|
+
} finally {
|
|
42
|
+
if (previous != null) {
|
|
43
|
+
previous.uncaughtException(thread, throwable)
|
|
44
|
+
} else {
|
|
45
|
+
// No previous handler installed: fall back to the JVM's own default so the
|
|
46
|
+
// process still dies the normal way instead of hanging.
|
|
47
|
+
Runtime.getRuntime().exit(10)
|
|
48
|
+
}
|
|
49
|
+
}
|
|
50
|
+
}
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
private fun persist(crashDir: File, throwable: Throwable) {
|
|
54
|
+
val doc = buildJsonObject {
|
|
55
|
+
put("timestamp", JsonPrimitive(isoNow()))
|
|
56
|
+
put("exception", JsonPrimitive(throwable::class.qualifiedName ?: throwable.javaClass.name))
|
|
57
|
+
put("message", throwable.message?.let { JsonPrimitive(it) } ?: JsonNull)
|
|
58
|
+
put("frames", buildJsonArray {
|
|
59
|
+
throwable.stackTrace.forEach { el ->
|
|
60
|
+
add(buildJsonObject {
|
|
61
|
+
put("className", JsonPrimitive(el.className))
|
|
62
|
+
put("methodName", JsonPrimitive(el.methodName))
|
|
63
|
+
put("fileName", el.fileName?.let { JsonPrimitive(it) } ?: JsonNull)
|
|
64
|
+
put("lineNumber", JsonPrimitive(el.lineNumber))
|
|
65
|
+
})
|
|
66
|
+
}
|
|
67
|
+
})
|
|
68
|
+
}
|
|
69
|
+
File(crashDir, "crash-${System.currentTimeMillis()}.json")
|
|
70
|
+
.writeText(Json.encodeToString(JsonElement.serializer(), doc))
|
|
71
|
+
prune(crashDir)
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
/** Keep only the most recent [MAX_CRASHES] crash files (current boot + previous ones). */
|
|
75
|
+
private fun prune(crashDir: File) {
|
|
76
|
+
val files = crashDir.listFiles { f -> f.isFile && f.name.endsWith(".json") } ?: return
|
|
77
|
+
if (files.size <= MAX_CRASHES) return
|
|
78
|
+
files.sortedBy { it.lastModified() }
|
|
79
|
+
.take(files.size - MAX_CRASHES)
|
|
80
|
+
.forEach { it.delete() }
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
/**
|
|
84
|
+
* Every persisted crash's raw JSON text, newest first — spans the current boot AND any
|
|
85
|
+
* previous ones ([install] never clears the directory, only [prune] bounds it).
|
|
86
|
+
*/
|
|
87
|
+
fun readAll(context: Context): List<String> {
|
|
88
|
+
val crashDir = File(context.filesDir, "inspector/crashes")
|
|
89
|
+
val files = crashDir.listFiles { f -> f.isFile && f.name.endsWith(".json") } ?: return emptyList()
|
|
90
|
+
return files.sortedByDescending { it.lastModified() }
|
|
91
|
+
.mapNotNull { runCatching { it.readText() }.getOrNull() }
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
private fun isoNow(): String {
|
|
95
|
+
val fmt = SimpleDateFormat("yyyy-MM-dd'T'HH:mm:ss.SSS'Z'", Locale.US)
|
|
96
|
+
fmt.timeZone = TimeZone.getTimeZone("UTC")
|
|
97
|
+
return fmt.format(Date())
|
|
98
|
+
}
|
|
99
|
+
}
|
|
@@ -0,0 +1,144 @@
|
|
|
1
|
+
package __PACKAGE__.inspector
|
|
2
|
+
|
|
3
|
+
// >>> cmp:feature room
|
|
4
|
+
import __PACKAGE__.data.local.AppDatabase
|
|
5
|
+
import androidx.room.useReaderConnection
|
|
6
|
+
import kotlinx.coroutines.runBlocking
|
|
7
|
+
import kotlinx.serialization.json.Json
|
|
8
|
+
import kotlinx.serialization.json.JsonElement
|
|
9
|
+
import kotlinx.serialization.json.JsonNull
|
|
10
|
+
import kotlinx.serialization.json.JsonPrimitive
|
|
11
|
+
import kotlinx.serialization.json.buildJsonArray
|
|
12
|
+
import kotlinx.serialization.json.buildJsonObject
|
|
13
|
+
import org.koin.core.context.GlobalContext
|
|
14
|
+
// <<< cmp:feature room
|
|
15
|
+
|
|
16
|
+
/**
|
|
17
|
+
* `GET /inspect/db` (schema: tables via `sqlite_master`) and `GET /inspect/db?table=<name>&limit=<n>`
|
|
18
|
+
* (rows). Read-only, off the main thread. 404 when this project's local-database feature is off
|
|
19
|
+
* (there is nothing to query) — see the `room`-gated implementation below for the real path.
|
|
20
|
+
*/
|
|
21
|
+
object DbInspector {
|
|
22
|
+
|
|
23
|
+
// >>> cmp:feature room
|
|
24
|
+
// Reads go through the project's Room database (a Koin single). Injection-safe by
|
|
25
|
+
// construction: a requested `table` is only ever used in a query after it is proven to be
|
|
26
|
+
// a real name returned by `sqlite_master` in THIS call — the raw wire value never reaches
|
|
27
|
+
// SQL beyond that validated identifier.
|
|
28
|
+
//
|
|
29
|
+
// This project's Room config uses the KMP driver architecture (BundledSQLiteDriver, see
|
|
30
|
+
// data/local/DatabaseBuilder.kt), not the legacy Android-only SupportSQLiteDatabase — so
|
|
31
|
+
// reads go through Room 2.8's public `useReaderConnection { transactor -> ... }` whose
|
|
32
|
+
// receiver is a pooled connection exposing `usePrepared(sql) { stmt -> ... }` (the
|
|
33
|
+
// statement is created and closed by Room; binds are 1-based, column reads 0-based).
|
|
34
|
+
private const val DEFAULT_ROW_LIMIT = 50
|
|
35
|
+
private const val MAX_ROW_LIMIT = 500
|
|
36
|
+
private val VALID_IDENTIFIER = Regex("^[A-Za-z_][A-Za-z0-9_]*$")
|
|
37
|
+
private val prettyJson = Json { prettyPrint = true }
|
|
38
|
+
|
|
39
|
+
private fun appDatabaseOrNull(): AppDatabase? =
|
|
40
|
+
try {
|
|
41
|
+
// GlobalContext.getOrNull() IS the Koin instance (or null before startKoin).
|
|
42
|
+
GlobalContext.getOrNull()?.getOrNull<AppDatabase>()
|
|
43
|
+
} catch (t: Throwable) {
|
|
44
|
+
null
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
fun schema(): Pair<Int, String> {
|
|
48
|
+
val db = appDatabaseOrNull()
|
|
49
|
+
?: return 503 to errorJson("database not available yet (Room not initialised — is Koin started?).")
|
|
50
|
+
return try {
|
|
51
|
+
val tables = runBlocking {
|
|
52
|
+
db.useReaderConnection { connection ->
|
|
53
|
+
connection.usePrepared(
|
|
54
|
+
"SELECT name, sql FROM sqlite_master WHERE type = 'table' " +
|
|
55
|
+
"AND name NOT LIKE 'sqlite_%' AND name NOT LIKE 'room_%' ORDER BY name"
|
|
56
|
+
) { stmt ->
|
|
57
|
+
val out = mutableListOf<Pair<String, String?>>()
|
|
58
|
+
while (stmt.step()) {
|
|
59
|
+
out += stmt.getText(0) to (if (stmt.isNull(1)) null else stmt.getText(1))
|
|
60
|
+
}
|
|
61
|
+
out
|
|
62
|
+
}
|
|
63
|
+
}
|
|
64
|
+
}
|
|
65
|
+
200 to prettyJson.encodeToString(JsonElement.serializer(), buildJsonObject {
|
|
66
|
+
put("tables", buildJsonArray {
|
|
67
|
+
tables.forEach { (name, sql) ->
|
|
68
|
+
add(buildJsonObject {
|
|
69
|
+
put("name", JsonPrimitive(name))
|
|
70
|
+
put("sql", sql?.let { JsonPrimitive(it) } ?: JsonNull)
|
|
71
|
+
})
|
|
72
|
+
}
|
|
73
|
+
})
|
|
74
|
+
})
|
|
75
|
+
} catch (t: Throwable) {
|
|
76
|
+
500 to errorJson("failed to read schema: ${t.message}")
|
|
77
|
+
}
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
/** Row page for one table: columns + stringified values, capped. */
|
|
81
|
+
private class TableRows(val columns: List<String>, val rows: List<JsonElement>)
|
|
82
|
+
|
|
83
|
+
fun rows(table: String, limitParam: String?): Pair<Int, String> {
|
|
84
|
+
if (!VALID_IDENTIFIER.matches(table)) {
|
|
85
|
+
return 400 to errorJson("invalid table name '$table' — expected a plain SQL identifier.")
|
|
86
|
+
}
|
|
87
|
+
val db = appDatabaseOrNull()
|
|
88
|
+
?: return 503 to errorJson("database not available yet (Room not initialised — is Koin started?).")
|
|
89
|
+
val limit = (limitParam?.toIntOrNull() ?: DEFAULT_ROW_LIMIT).coerceIn(1, MAX_ROW_LIMIT)
|
|
90
|
+
return try {
|
|
91
|
+
val result: TableRows? = runBlocking {
|
|
92
|
+
db.useReaderConnection { connection ->
|
|
93
|
+
// STRICT validation: `table` is only used in the row query below once THIS
|
|
94
|
+
// check proves it is a real sqlite_master identifier — never the raw wire value.
|
|
95
|
+
val exists = connection.usePrepared(
|
|
96
|
+
"SELECT name FROM sqlite_master WHERE type = 'table' AND name = ?"
|
|
97
|
+
) { stmt ->
|
|
98
|
+
stmt.bindText(1, table)
|
|
99
|
+
stmt.step()
|
|
100
|
+
}
|
|
101
|
+
if (!exists) return@useReaderConnection null
|
|
102
|
+
|
|
103
|
+
connection.usePrepared("SELECT * FROM \"$table\" LIMIT ?") { stmt ->
|
|
104
|
+
stmt.bindLong(1, limit.toLong())
|
|
105
|
+
val colCount = stmt.getColumnCount()
|
|
106
|
+
val columns = (0 until colCount).map { stmt.getColumnName(it) }
|
|
107
|
+
val rows = mutableListOf<JsonElement>()
|
|
108
|
+
while (stmt.step()) {
|
|
109
|
+
rows += buildJsonObject {
|
|
110
|
+
for (i in 0 until colCount) {
|
|
111
|
+
put(columns[i], if (stmt.isNull(i)) JsonNull else JsonPrimitive(stmt.getText(i)))
|
|
112
|
+
}
|
|
113
|
+
}
|
|
114
|
+
}
|
|
115
|
+
TableRows(columns, rows)
|
|
116
|
+
}
|
|
117
|
+
}
|
|
118
|
+
}
|
|
119
|
+
if (result == null) {
|
|
120
|
+
return 404 to errorJson("unknown table '$table' — not present in sqlite_master.")
|
|
121
|
+
}
|
|
122
|
+
200 to prettyJson.encodeToString(JsonElement.serializer(), buildJsonObject {
|
|
123
|
+
put("table", JsonPrimitive(table))
|
|
124
|
+
put("columns", buildJsonArray { result.columns.forEach { add(JsonPrimitive(it)) } })
|
|
125
|
+
put("rows", buildJsonArray { result.rows.forEach { add(it) } })
|
|
126
|
+
put("rowCount", JsonPrimitive(result.rows.size))
|
|
127
|
+
})
|
|
128
|
+
} catch (t: Throwable) {
|
|
129
|
+
500 to errorJson("failed to read table '$table': ${t.message}")
|
|
130
|
+
}
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
private fun errorJson(message: String): String =
|
|
134
|
+
"""{"error":${JsonPrimitive(message)}}"""
|
|
135
|
+
// <<< cmp:feature room
|
|
136
|
+
// >>> cmp:feature !room
|
|
137
|
+
private const val DISABLED_MESSAGE =
|
|
138
|
+
"the 'room' feature is disabled in this project — /inspect/db is unavailable."
|
|
139
|
+
|
|
140
|
+
fun schema(): Pair<Int, String> = 404 to """{"error":"$DISABLED_MESSAGE"}"""
|
|
141
|
+
|
|
142
|
+
fun rows(table: String, limitParam: String?): Pair<Int, String> = schema()
|
|
143
|
+
// <<< cmp:feature !room
|
|
144
|
+
}
|
package/template/composeApp/src/androidDebug/kotlin/com/example/app/inspector/InspectorCatalog.kt
CHANGED
|
@@ -5,9 +5,12 @@ import androidx.compose.ui.graphics.toArgb
|
|
|
5
5
|
import androidx.compose.ui.unit.Dp
|
|
6
6
|
import __PACKAGE__.presentation.theme.__THEME_PREFIX__Colors
|
|
7
7
|
import __PACKAGE__.presentation.theme.__THEME_PREFIX__Tokens
|
|
8
|
+
import __PACKAGE__.presentation.theme.__THEME_PREFIX__TypeRamp
|
|
8
9
|
import kotlinx.serialization.json.Json
|
|
9
10
|
import kotlinx.serialization.json.JsonElement
|
|
11
|
+
import kotlinx.serialization.json.JsonNull
|
|
10
12
|
import kotlinx.serialization.json.JsonPrimitive
|
|
13
|
+
import kotlinx.serialization.json.buildJsonArray
|
|
11
14
|
import kotlinx.serialization.json.buildJsonObject
|
|
12
15
|
import kotlinx.serialization.json.put
|
|
13
16
|
|
|
@@ -57,6 +60,22 @@ object InspectorCatalog {
|
|
|
57
60
|
put("RadiusModal", __THEME_PREFIX__Tokens.RadiusModal.token())
|
|
58
61
|
put("RadiusInput", __THEME_PREFIX__Tokens.RadiusInput.token())
|
|
59
62
|
})
|
|
63
|
+
// The type ramp, from the same ramp data the Typography factory builds
|
|
64
|
+
// its styles from — so the LIVE tier answers with the same block the
|
|
65
|
+
// headless preview catalog writes, and the console renders one ramp
|
|
66
|
+
// whichever tier it read.
|
|
67
|
+
put("typography", buildJsonArray {
|
|
68
|
+
__THEME_PREFIX__TypeRamp.forEach { spec ->
|
|
69
|
+
add(buildJsonObject {
|
|
70
|
+
put("name", spec.name)
|
|
71
|
+
put("weight", spec.weight)
|
|
72
|
+
put("size", "${spec.sizeSp}sp")
|
|
73
|
+
put("lineHeight", "${spec.lineHeightSp}sp")
|
|
74
|
+
val tracking = spec.tracking
|
|
75
|
+
if (tracking == null) put("tracking", JsonNull) else put("tracking", "${tracking}sp")
|
|
76
|
+
})
|
|
77
|
+
}
|
|
78
|
+
})
|
|
60
79
|
}
|
|
61
80
|
return prettyJson.encodeToString(JsonElement.serializer(), doc)
|
|
62
81
|
}
|
package/template/composeApp/src/androidDebug/kotlin/com/example/app/inspector/InspectorHttpServer.kt
CHANGED
|
@@ -1,12 +1,20 @@
|
|
|
1
1
|
package __PACKAGE__.inspector
|
|
2
2
|
|
|
3
|
+
import android.app.Activity
|
|
4
|
+
import android.content.Context
|
|
5
|
+
import android.content.ContextWrapper
|
|
3
6
|
import android.graphics.Bitmap
|
|
4
7
|
import android.graphics.Canvas
|
|
8
|
+
import android.graphics.Rect
|
|
9
|
+
import android.os.Build
|
|
5
10
|
import android.os.Handler
|
|
6
11
|
import android.os.Looper
|
|
7
12
|
import android.os.SystemClock
|
|
8
13
|
import android.util.Log
|
|
9
14
|
import android.view.MotionEvent
|
|
15
|
+
import android.view.PixelCopy
|
|
16
|
+
import android.view.View
|
|
17
|
+
import android.view.Window
|
|
10
18
|
import androidx.core.view.drawToBitmap
|
|
11
19
|
import java.io.BufferedReader
|
|
12
20
|
import java.io.ByteArrayOutputStream
|
|
@@ -14,6 +22,7 @@ import java.io.InputStreamReader
|
|
|
14
22
|
import java.net.InetAddress
|
|
15
23
|
import java.net.ServerSocket
|
|
16
24
|
import java.net.Socket
|
|
25
|
+
import java.net.URLDecoder
|
|
17
26
|
import java.nio.charset.StandardCharsets
|
|
18
27
|
import java.util.concurrent.CountDownLatch
|
|
19
28
|
import java.util.concurrent.TimeUnit
|
|
@@ -43,6 +52,15 @@ import kotlin.math.roundToInt
|
|
|
43
52
|
* pair to the root view → {"tapped":true,"x":…,"y":…}.
|
|
44
53
|
* GET /inspect/remote → the self-contained remote-control HTML page (same-origin,
|
|
45
54
|
* zero CORS): live screenshot + click-to-tap in a browser.
|
|
55
|
+
* GET /inspect/nav → { currentRoute, backStack } — best-effort, reported by the
|
|
56
|
+
* common `NavInspectionHook` seam; empty snapshot before the
|
|
57
|
+
* first navigation event (see [NavInspector]).
|
|
58
|
+
* GET /inspect/crashes → { crashes: [...] } — persisted crash JSON (current boot +
|
|
59
|
+
* previous ones), newest first (see [CrashRecorder]).
|
|
60
|
+
* GET /inspect/db → schema: { tables:[{name,sql}] } via `sqlite_master`.
|
|
61
|
+
* GET /inspect/db?table=<n>&limit=<n> → rows for one table (read-only, bounded; see
|
|
62
|
+
* [DbInspector]). 404 (or empty schema) when the project's
|
|
63
|
+
* `room` feature is off.
|
|
46
64
|
*
|
|
47
65
|
* Single-threaded accept loop on a daemon thread = one client at a time = bounded by design.
|
|
48
66
|
* Failure to bind logs a warning and gives up — the inspector must never crash or block
|
|
@@ -67,9 +85,14 @@ object InspectorHttpServer {
|
|
|
67
85
|
|
|
68
86
|
@Volatile private var started = false
|
|
69
87
|
|
|
70
|
-
|
|
88
|
+
// Set once in [start]; read from the HTTP thread only (crashes/db routes). applicationContext
|
|
89
|
+
// is safe to hold — it never leaks an Activity.
|
|
90
|
+
@Volatile private var appContext: Context? = null
|
|
91
|
+
|
|
92
|
+
fun start(appId: String, context: Context) {
|
|
71
93
|
if (started) return
|
|
72
94
|
started = true
|
|
95
|
+
appContext = context.applicationContext
|
|
73
96
|
val thread = Thread({ serve(appId) }, "cmp-inspector-http")
|
|
74
97
|
thread.isDaemon = true
|
|
75
98
|
thread.start()
|
|
@@ -114,7 +137,9 @@ object InspectorHttpServer {
|
|
|
114
137
|
}
|
|
115
138
|
val parts = requestLine.split(" ")
|
|
116
139
|
val method = parts.getOrNull(0) ?: ""
|
|
117
|
-
val
|
|
140
|
+
val rawTarget = parts.getOrNull(1) ?: ""
|
|
141
|
+
val path = rawTarget.substringBefore('?')
|
|
142
|
+
val query = rawTarget.substringAfter('?', "")
|
|
118
143
|
|
|
119
144
|
when {
|
|
120
145
|
method == "GET" && path == "/inspect/health" ->
|
|
@@ -129,6 +154,17 @@ object InspectorHttpServer {
|
|
|
129
154
|
writeResponse(client, 200, RemoteControlPage.html(appId).toByteArray(StandardCharsets.UTF_8), HTML_TYPE)
|
|
130
155
|
method == "POST" && path == "/inspect/tap" ->
|
|
131
156
|
tapResponse(readBody(reader, contentLength)).let { (s, b) -> writeJson(client, s, b) }
|
|
157
|
+
method == "GET" && path == "/inspect/nav" ->
|
|
158
|
+
writeJson(client, 200, navJson())
|
|
159
|
+
// The JUMP half of the nav seam (the read half is /inspect/nav above): coverage
|
|
160
|
+
// by route, not by synthesized taps. GET (not POST) deliberately — it is
|
|
161
|
+
// idempotent-ish debug tooling meant to be curl-able, like everything else here.
|
|
162
|
+
method == "GET" && path == "/inspect/navigate" ->
|
|
163
|
+
navigateResponse(query).let { (s, b) -> writeJson(client, s, b) }
|
|
164
|
+
method == "GET" && path == "/inspect/crashes" ->
|
|
165
|
+
writeJson(client, 200, crashesJson())
|
|
166
|
+
method == "GET" && path == "/inspect/db" ->
|
|
167
|
+
dbResponse(query).let { (s, b) -> writeJson(client, s, b) }
|
|
132
168
|
method != "GET" && method != "POST" ->
|
|
133
169
|
writeJson(client, 405, errorJson("method not allowed"))
|
|
134
170
|
else ->
|
|
@@ -153,8 +189,35 @@ object InspectorHttpServer {
|
|
|
153
189
|
return String(buf, 0, read)
|
|
154
190
|
}
|
|
155
191
|
|
|
156
|
-
private fun healthJson(appId: String): String
|
|
157
|
-
|
|
192
|
+
private fun healthJson(appId: String): String {
|
|
193
|
+
// Process start as epoch millis: elapsedRealtime anchors the boot clock to the wall
|
|
194
|
+
// clock. This is the DETERMINISM primitive — an external relaunch (force-stop +
|
|
195
|
+
// launch) is VERIFIED by this value moving forward, so a walk can prove it started
|
|
196
|
+
// from a fresh process instead of trusting that a retained ViewModel isn't lurking.
|
|
197
|
+
val startedAtMs = System.currentTimeMillis() - android.os.SystemClock.elapsedRealtime() +
|
|
198
|
+
android.os.Process.getStartElapsedRealtime()
|
|
199
|
+
return """{"status":"ok","schemaVersion":1,"source":"live-android","appId":${JsonPrimitive(appId)},"buildType":"debug",""" +
|
|
200
|
+
""""processStartedAtMs":$startedAtMs,"processUptimeMs":${android.os.SystemClock.elapsedRealtime() - android.os.Process.getStartElapsedRealtime()}}"""
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
private fun navigateResponse(query: String): Pair<Int, String> {
|
|
204
|
+
val route = query.split('&')
|
|
205
|
+
.firstOrNull { it.startsWith("route=") }
|
|
206
|
+
?.substringAfter('=')
|
|
207
|
+
?.let { java.net.URLDecoder.decode(it, "UTF-8") }
|
|
208
|
+
?.takeIf { it.isNotBlank() }
|
|
209
|
+
?: return 400 to errorJson("missing route parameter — /inspect/navigate?route=<route>")
|
|
210
|
+
val error = NavInspector.navigate(route)
|
|
211
|
+
return if (error == null) {
|
|
212
|
+
200 to """{"ok":true,"route":${JsonPrimitive(route)}}"""
|
|
213
|
+
} else if (error.startsWith("nav host not composed")) {
|
|
214
|
+
503 to errorJson(error)
|
|
215
|
+
} else if (error.startsWith("unknown route")) {
|
|
216
|
+
404 to errorJson(error)
|
|
217
|
+
} else {
|
|
218
|
+
500 to errorJson(error)
|
|
219
|
+
}
|
|
220
|
+
}
|
|
158
221
|
|
|
159
222
|
private fun treeResponse(): Pair<Int, String> {
|
|
160
223
|
val root = ComposeRootRegistry.current()
|
|
@@ -183,10 +246,62 @@ object InspectorHttpServer {
|
|
|
183
246
|
}
|
|
184
247
|
}
|
|
185
248
|
|
|
249
|
+
/** { currentRoute, backStack } from [NavInspector] — best-effort, never blocks. */
|
|
250
|
+
private fun navJson(): String {
|
|
251
|
+
val snapshot = NavInspector.current()
|
|
252
|
+
val currentRouteJson = snapshot.currentRoute?.let { JsonPrimitive(it).toString() } ?: "null"
|
|
253
|
+
val backStackJson = snapshot.backStack.joinToString(",") { JsonPrimitive(it).toString() }
|
|
254
|
+
return """{"currentRoute":$currentRouteJson,"backStack":[$backStackJson]}"""
|
|
255
|
+
}
|
|
256
|
+
|
|
257
|
+
/** { crashes:[...] } — each element is a persisted crash JSON document, verbatim. */
|
|
258
|
+
private fun crashesJson(): String {
|
|
259
|
+
val ctx = appContext ?: return """{"crashes":[]}"""
|
|
260
|
+
val crashes = CrashRecorder.readAll(ctx)
|
|
261
|
+
return """{"crashes":[${crashes.joinToString(",")}]}"""
|
|
262
|
+
}
|
|
263
|
+
|
|
264
|
+
/** GET /inspect/db dispatch: no `table` → schema, else → rows for that table. */
|
|
265
|
+
private fun dbResponse(query: String): Pair<Int, String> {
|
|
266
|
+
val params = parseQuery(query)
|
|
267
|
+
val table = params["table"]
|
|
268
|
+
return if (table == null) DbInspector.schema() else DbInspector.rows(table, params["limit"])
|
|
269
|
+
}
|
|
270
|
+
|
|
271
|
+
/** Minimal `a=b&c=d` query-string parser (URL-decoded values). Last value wins on repeats. */
|
|
272
|
+
private fun parseQuery(query: String): Map<String, String> {
|
|
273
|
+
if (query.isEmpty()) return emptyMap()
|
|
274
|
+
val out = mutableMapOf<String, String>()
|
|
275
|
+
for (pair in query.split("&")) {
|
|
276
|
+
if (pair.isEmpty()) continue
|
|
277
|
+
val eq = pair.indexOf('=')
|
|
278
|
+
val key = if (eq >= 0) pair.substring(0, eq) else pair
|
|
279
|
+
val value = if (eq >= 0) pair.substring(eq + 1) else ""
|
|
280
|
+
out[urlDecode(key)] = urlDecode(value)
|
|
281
|
+
}
|
|
282
|
+
return out
|
|
283
|
+
}
|
|
284
|
+
|
|
285
|
+
private fun urlDecode(s: String): String =
|
|
286
|
+
try {
|
|
287
|
+
URLDecoder.decode(s, "UTF-8")
|
|
288
|
+
} catch (t: Throwable) {
|
|
289
|
+
s
|
|
290
|
+
}
|
|
291
|
+
|
|
186
292
|
/**
|
|
187
|
-
* PNG of the current Compose root.
|
|
188
|
-
*
|
|
189
|
-
*
|
|
293
|
+
* PNG of the current Compose root. Pixels are read with [PixelCopy] (API 26+) from the
|
|
294
|
+
* COMPOSITED window surface — the same source `adb screencap` reads — so a capture can
|
|
295
|
+
* never replay a stale Compose layer recording. The software fallback (`View.draw` into a
|
|
296
|
+
* bitmap canvas) replays recorded display lists, and a nav-transition `graphicsLayer`
|
|
297
|
+
* whose recording predates the current frame replays the PREVIOUS screen: byte-identical
|
|
298
|
+
* "screenshots" of two different screens, detectable only by hash-compare. PixelCopy is
|
|
299
|
+
* therefore the primary path; the draw fallback remains for pre-26, windowless roots, and
|
|
300
|
+
* dialog/popup roots (their content lives in a different window than the Activity's).
|
|
301
|
+
*
|
|
302
|
+
* The capture runs on the MAIN thread (views are not thread-safe; PixelCopy's listener is
|
|
303
|
+
* delivered there too); PNG compression — tens of ms for a full screen — happens back on
|
|
304
|
+
* the server thread so the UI never pays for it.
|
|
190
305
|
*/
|
|
191
306
|
private fun screenshotResponse(client: Socket) {
|
|
192
307
|
val root = ComposeRootRegistry.current()
|
|
@@ -200,22 +315,35 @@ object InspectorHttpServer {
|
|
|
200
315
|
val bitmapRef = AtomicReference<Bitmap?>()
|
|
201
316
|
val errorRef = AtomicReference<String?>()
|
|
202
317
|
val latch = CountDownLatch(1)
|
|
203
|
-
Handler(Looper.getMainLooper())
|
|
318
|
+
val main = Handler(Looper.getMainLooper())
|
|
319
|
+
main.post {
|
|
204
320
|
try {
|
|
205
321
|
val view = root.view
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
322
|
+
// PixelCopy only when this root actually lives in the Activity's window —
|
|
323
|
+
// a dialog/popup root has its own window, and copying the Activity's would
|
|
324
|
+
// capture the screen BENEATH it. Those fall back to the software draw.
|
|
325
|
+
val window = windowOf(view)?.takeIf { it.decorView === view.rootView }
|
|
326
|
+
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O && window != null) {
|
|
327
|
+
val loc = IntArray(2).also(view::getLocationInWindow)
|
|
328
|
+
val bmp = Bitmap.createBitmap(
|
|
329
|
+
view.width.coerceAtLeast(1),
|
|
330
|
+
view.height.coerceAtLeast(1),
|
|
331
|
+
Bitmap.Config.ARGB_8888,
|
|
332
|
+
)
|
|
333
|
+
val src = Rect(loc[0], loc[1], loc[0] + bmp.width, loc[1] + bmp.height)
|
|
334
|
+
PixelCopy.request(window, src, bmp, { result ->
|
|
335
|
+
try {
|
|
336
|
+
// No composited frame to copy (mid-transition, surface torn down):
|
|
337
|
+
// fall back rather than fail — a fallback frame beats no frame.
|
|
338
|
+
bitmapRef.set(if (result == PixelCopy.SUCCESS) bmp else softwareDraw(view))
|
|
339
|
+
} catch (t: Throwable) {
|
|
340
|
+
errorRef.set("failed to render screenshot: ${t.message}")
|
|
341
|
+
}
|
|
342
|
+
latch.countDown()
|
|
343
|
+
}, main)
|
|
344
|
+
return@post // countDown happens in the PixelCopy listener above
|
|
345
|
+
}
|
|
346
|
+
bitmapRef.set(softwareDraw(view))
|
|
219
347
|
} catch (t: Throwable) {
|
|
220
348
|
errorRef.set("failed to render screenshot: ${t.message}")
|
|
221
349
|
}
|
|
@@ -242,6 +370,34 @@ object InspectorHttpServer {
|
|
|
242
370
|
writeResponse(client, 200, bytes, PNG_TYPE)
|
|
243
371
|
}
|
|
244
372
|
|
|
373
|
+
/** The view's host [Window], unwrapped through the ContextWrapper chain. MAIN thread. */
|
|
374
|
+
private fun windowOf(view: View): Window? {
|
|
375
|
+
var ctx = view.context
|
|
376
|
+
while (ctx is ContextWrapper) {
|
|
377
|
+
if (ctx is Activity) return ctx.window
|
|
378
|
+
ctx = ctx.baseContext
|
|
379
|
+
}
|
|
380
|
+
return null
|
|
381
|
+
}
|
|
382
|
+
|
|
383
|
+
/**
|
|
384
|
+
* Software-canvas capture — re-issues the view's draw. Correct for laid-out static content,
|
|
385
|
+
* but can replay a stale `graphicsLayer` recording mid-transition (see [screenshotResponse]);
|
|
386
|
+
* used only where PixelCopy can't be (pre-API-26, windowless, or dialog roots). MAIN thread.
|
|
387
|
+
*/
|
|
388
|
+
private fun softwareDraw(view: View): Bitmap =
|
|
389
|
+
try {
|
|
390
|
+
// androidx.core.view.drawToBitmap (core-ktx — already an androidMain dep).
|
|
391
|
+
view.drawToBitmap()
|
|
392
|
+
} catch (t: Throwable) {
|
|
393
|
+
// Not laid out yet / hardware path refused — plain Canvas draw fallback.
|
|
394
|
+
Bitmap.createBitmap(
|
|
395
|
+
view.width.coerceAtLeast(1),
|
|
396
|
+
view.height.coerceAtLeast(1),
|
|
397
|
+
Bitmap.Config.ARGB_8888,
|
|
398
|
+
).also { view.draw(Canvas(it)) }
|
|
399
|
+
}
|
|
400
|
+
|
|
245
401
|
/**
|
|
246
402
|
* Dispatch a synthetic tap (ACTION_DOWN, then ACTION_UP ~50ms later) to the topmost
|
|
247
403
|
* Compose root, on the MAIN thread. Coordinates are root-relative px — exactly the
|
package/template/composeApp/src/androidDebug/kotlin/com/example/app/inspector/InspectorInit.kt
CHANGED
|
@@ -3,14 +3,18 @@ package __PACKAGE__.inspector
|
|
|
3
3
|
import android.app.Application
|
|
4
4
|
|
|
5
5
|
/**
|
|
6
|
-
* DEBUG variant: install the Compose root registry
|
|
7
|
-
* `onViewCreatedCallback`
|
|
8
|
-
*
|
|
6
|
+
* DEBUG variant: install the Compose root registry and the nav-inspection listener (both must
|
|
7
|
+
* happen BEFORE any Activity — the registry so `onViewCreatedCallback` catches every root, the
|
|
8
|
+
* nav listener so the first `AppNavHost` composition is observed), chain in the crash recorder,
|
|
9
|
+
* then start the loopback-only inspection server on 127.0.0.1:9500. Reach it from the host via
|
|
10
|
+
* `adb forward tcp:9500 tcp:9500`.
|
|
9
11
|
*
|
|
10
12
|
* The release source set carries a same-signature no-op twin — the compiler picks the variant
|
|
11
13
|
* body, so release builds contain no inspector code at all (structural absence, not a flag).
|
|
12
14
|
*/
|
|
13
15
|
fun Application.startInspector() {
|
|
14
16
|
ComposeRootRegistry.install()
|
|
15
|
-
|
|
17
|
+
NavInspector.install()
|
|
18
|
+
CrashRecorder.install(this)
|
|
19
|
+
InspectorHttpServer.start(appId = packageName, context = this)
|
|
16
20
|
}
|
package/template/composeApp/src/androidDebug/kotlin/com/example/app/inspector/LiveSemanticsJson.kt
CHANGED
|
@@ -26,6 +26,9 @@ import kotlin.math.roundToInt
|
|
|
26
26
|
* - `role` string|null — [SemanticsProperties.Role] (e.g. "Button").
|
|
27
27
|
* - `clickable` boolean — presence of [SemanticsActions.OnClick].
|
|
28
28
|
* - `disabled` boolean — presence of [SemanticsProperties.Disabled].
|
|
29
|
+
* - `size` {width,height} — the FULL composed (unclipped) size; `bounds` is the visible
|
|
30
|
+
* slice after ancestor clipping (a scroll fold truncates it). The a11y audit
|
|
31
|
+
* judges touch targets on `size` so fold-clipped rows never false-flag.
|
|
29
32
|
*/
|
|
30
33
|
object LiveSemanticsJson {
|
|
31
34
|
|
|
@@ -49,6 +52,7 @@ object LiveSemanticsJson {
|
|
|
49
52
|
put("clickable", JsonPrimitive(node.isClickable()))
|
|
50
53
|
put("disabled", JsonPrimitive(node.isDisabled()))
|
|
51
54
|
put("bounds", node.boundsJson())
|
|
55
|
+
put("size", node.sizeJson())
|
|
52
56
|
put("designToken", node.designTokenJson())
|
|
53
57
|
put("children", buildJsonArray {
|
|
54
58
|
node.children.forEach { add(nodeToJson(it)) }
|
|
@@ -87,6 +91,12 @@ object LiveSemanticsJson {
|
|
|
87
91
|
}
|
|
88
92
|
}
|
|
89
93
|
|
|
94
|
+
/** Full composed (unclipped) size — see the class doc's `size` contract note. */
|
|
95
|
+
private fun SemanticsNode.sizeJson(): JsonObject = buildJsonObject {
|
|
96
|
+
put("width", JsonPrimitive(size.width))
|
|
97
|
+
put("height", JsonPrimitive(size.height))
|
|
98
|
+
}
|
|
99
|
+
|
|
90
100
|
private fun SemanticsNode.designTokenJson(): JsonElement {
|
|
91
101
|
val info = config.getOrNull(DesignTokenKey) ?: return JsonNull
|
|
92
102
|
return buildJsonObject {
|