create-cmp-cli 0.8.0 → 0.9.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 +67 -9
- package/llms.txt +5 -1
- package/package.json +1 -1
- package/src/lib/adr-seed.mjs +178 -0
- package/src/lib/tabs.mjs +91 -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 +196 -48
- package/template/README.md +23 -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/InspectorHttpServer.kt +69 -2
- package/template/composeApp/src/androidDebug/kotlin/com/example/app/inspector/InspectorInit.kt +8 -4
- package/template/composeApp/src/androidDebug/kotlin/com/example/app/inspector/NavInspector.kt +31 -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/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 +54 -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 +13 -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 +21 -0
- package/template/composeApp/src/commonMain/kotlin/com/example/app/presentation/profile/ProfileScreen.kt +4 -27
- 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 +269 -0
- package/template/composeApp/src/desktopMain/kotlin/com/example/app/inspector/PreviewRegistry.kt +37 -1
- package/template/composeApp/src/desktopTest/kotlin/com/example/app/conformance/ArchitectureConformanceTest.kt +207 -15
- 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 +317 -34
- package/template/docs/TESTING.md +6 -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/golden/home.json +3 -3
- package/template/qa/lib/approvals.mjs +806 -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 +118 -9
- package/template/specs/app-base.spec.md +44 -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/InspectorHttpServer.kt
CHANGED
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
package __PACKAGE__.inspector
|
|
2
2
|
|
|
3
|
+
import android.content.Context
|
|
3
4
|
import android.graphics.Bitmap
|
|
4
5
|
import android.graphics.Canvas
|
|
5
6
|
import android.os.Handler
|
|
@@ -14,6 +15,7 @@ import java.io.InputStreamReader
|
|
|
14
15
|
import java.net.InetAddress
|
|
15
16
|
import java.net.ServerSocket
|
|
16
17
|
import java.net.Socket
|
|
18
|
+
import java.net.URLDecoder
|
|
17
19
|
import java.nio.charset.StandardCharsets
|
|
18
20
|
import java.util.concurrent.CountDownLatch
|
|
19
21
|
import java.util.concurrent.TimeUnit
|
|
@@ -43,6 +45,15 @@ import kotlin.math.roundToInt
|
|
|
43
45
|
* pair to the root view → {"tapped":true,"x":…,"y":…}.
|
|
44
46
|
* GET /inspect/remote → the self-contained remote-control HTML page (same-origin,
|
|
45
47
|
* zero CORS): live screenshot + click-to-tap in a browser.
|
|
48
|
+
* GET /inspect/nav → { currentRoute, backStack } — best-effort, reported by the
|
|
49
|
+
* common `NavInspectionHook` seam; empty snapshot before the
|
|
50
|
+
* first navigation event (see [NavInspector]).
|
|
51
|
+
* GET /inspect/crashes → { crashes: [...] } — persisted crash JSON (current boot +
|
|
52
|
+
* previous ones), newest first (see [CrashRecorder]).
|
|
53
|
+
* GET /inspect/db → schema: { tables:[{name,sql}] } via `sqlite_master`.
|
|
54
|
+
* GET /inspect/db?table=<n>&limit=<n> → rows for one table (read-only, bounded; see
|
|
55
|
+
* [DbInspector]). 404 (or empty schema) when the project's
|
|
56
|
+
* `room` feature is off.
|
|
46
57
|
*
|
|
47
58
|
* Single-threaded accept loop on a daemon thread = one client at a time = bounded by design.
|
|
48
59
|
* Failure to bind logs a warning and gives up — the inspector must never crash or block
|
|
@@ -67,9 +78,14 @@ object InspectorHttpServer {
|
|
|
67
78
|
|
|
68
79
|
@Volatile private var started = false
|
|
69
80
|
|
|
70
|
-
|
|
81
|
+
// Set once in [start]; read from the HTTP thread only (crashes/db routes). applicationContext
|
|
82
|
+
// is safe to hold — it never leaks an Activity.
|
|
83
|
+
@Volatile private var appContext: Context? = null
|
|
84
|
+
|
|
85
|
+
fun start(appId: String, context: Context) {
|
|
71
86
|
if (started) return
|
|
72
87
|
started = true
|
|
88
|
+
appContext = context.applicationContext
|
|
73
89
|
val thread = Thread({ serve(appId) }, "cmp-inspector-http")
|
|
74
90
|
thread.isDaemon = true
|
|
75
91
|
thread.start()
|
|
@@ -114,7 +130,9 @@ object InspectorHttpServer {
|
|
|
114
130
|
}
|
|
115
131
|
val parts = requestLine.split(" ")
|
|
116
132
|
val method = parts.getOrNull(0) ?: ""
|
|
117
|
-
val
|
|
133
|
+
val rawTarget = parts.getOrNull(1) ?: ""
|
|
134
|
+
val path = rawTarget.substringBefore('?')
|
|
135
|
+
val query = rawTarget.substringAfter('?', "")
|
|
118
136
|
|
|
119
137
|
when {
|
|
120
138
|
method == "GET" && path == "/inspect/health" ->
|
|
@@ -129,6 +147,12 @@ object InspectorHttpServer {
|
|
|
129
147
|
writeResponse(client, 200, RemoteControlPage.html(appId).toByteArray(StandardCharsets.UTF_8), HTML_TYPE)
|
|
130
148
|
method == "POST" && path == "/inspect/tap" ->
|
|
131
149
|
tapResponse(readBody(reader, contentLength)).let { (s, b) -> writeJson(client, s, b) }
|
|
150
|
+
method == "GET" && path == "/inspect/nav" ->
|
|
151
|
+
writeJson(client, 200, navJson())
|
|
152
|
+
method == "GET" && path == "/inspect/crashes" ->
|
|
153
|
+
writeJson(client, 200, crashesJson())
|
|
154
|
+
method == "GET" && path == "/inspect/db" ->
|
|
155
|
+
dbResponse(query).let { (s, b) -> writeJson(client, s, b) }
|
|
132
156
|
method != "GET" && method != "POST" ->
|
|
133
157
|
writeJson(client, 405, errorJson("method not allowed"))
|
|
134
158
|
else ->
|
|
@@ -183,6 +207,49 @@ object InspectorHttpServer {
|
|
|
183
207
|
}
|
|
184
208
|
}
|
|
185
209
|
|
|
210
|
+
/** { currentRoute, backStack } from [NavInspector] — best-effort, never blocks. */
|
|
211
|
+
private fun navJson(): String {
|
|
212
|
+
val snapshot = NavInspector.current()
|
|
213
|
+
val currentRouteJson = snapshot.currentRoute?.let { JsonPrimitive(it).toString() } ?: "null"
|
|
214
|
+
val backStackJson = snapshot.backStack.joinToString(",") { JsonPrimitive(it).toString() }
|
|
215
|
+
return """{"currentRoute":$currentRouteJson,"backStack":[$backStackJson]}"""
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
/** { crashes:[...] } — each element is a persisted crash JSON document, verbatim. */
|
|
219
|
+
private fun crashesJson(): String {
|
|
220
|
+
val ctx = appContext ?: return """{"crashes":[]}"""
|
|
221
|
+
val crashes = CrashRecorder.readAll(ctx)
|
|
222
|
+
return """{"crashes":[${crashes.joinToString(",")}]}"""
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
/** GET /inspect/db dispatch: no `table` → schema, else → rows for that table. */
|
|
226
|
+
private fun dbResponse(query: String): Pair<Int, String> {
|
|
227
|
+
val params = parseQuery(query)
|
|
228
|
+
val table = params["table"]
|
|
229
|
+
return if (table == null) DbInspector.schema() else DbInspector.rows(table, params["limit"])
|
|
230
|
+
}
|
|
231
|
+
|
|
232
|
+
/** Minimal `a=b&c=d` query-string parser (URL-decoded values). Last value wins on repeats. */
|
|
233
|
+
private fun parseQuery(query: String): Map<String, String> {
|
|
234
|
+
if (query.isEmpty()) return emptyMap()
|
|
235
|
+
val out = mutableMapOf<String, String>()
|
|
236
|
+
for (pair in query.split("&")) {
|
|
237
|
+
if (pair.isEmpty()) continue
|
|
238
|
+
val eq = pair.indexOf('=')
|
|
239
|
+
val key = if (eq >= 0) pair.substring(0, eq) else pair
|
|
240
|
+
val value = if (eq >= 0) pair.substring(eq + 1) else ""
|
|
241
|
+
out[urlDecode(key)] = urlDecode(value)
|
|
242
|
+
}
|
|
243
|
+
return out
|
|
244
|
+
}
|
|
245
|
+
|
|
246
|
+
private fun urlDecode(s: String): String =
|
|
247
|
+
try {
|
|
248
|
+
URLDecoder.decode(s, "UTF-8")
|
|
249
|
+
} catch (t: Throwable) {
|
|
250
|
+
s
|
|
251
|
+
}
|
|
252
|
+
|
|
186
253
|
/**
|
|
187
254
|
* PNG of the current Compose root. The Bitmap is rendered on the MAIN thread (views are
|
|
188
255
|
* not thread-safe); PNG compression — tens of ms for a full screen — happens back on 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
|
}
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
package __PACKAGE__.inspector
|
|
2
|
+
|
|
3
|
+
import __PACKAGE__.presentation.navigation.NavInspectionHook
|
|
4
|
+
import java.util.concurrent.atomic.AtomicReference
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* Debug-only sink for [NavInspectionHook]: [install] registers a listener that stores the
|
|
8
|
+
* latest route/back-stack snapshot the common `AppNavHost` reports, so `GET /inspect/nav` can
|
|
9
|
+
* read it synchronously from the HTTP thread without touching Compose state directly.
|
|
10
|
+
*
|
|
11
|
+
* Best-effort by design: until the first navigation event fires (cold start, before
|
|
12
|
+
* `AppNavHost` has composed at least once), [current] reports an empty snapshot rather than
|
|
13
|
+
* blocking or erroring — mirrors the tree/screenshot routes' "not ready yet, retry" posture,
|
|
14
|
+
* just without the 503 (an empty nav snapshot is a valid, if uninteresting, answer).
|
|
15
|
+
*/
|
|
16
|
+
object NavInspector {
|
|
17
|
+
|
|
18
|
+
data class Snapshot(val currentRoute: String?, val backStack: List<String>)
|
|
19
|
+
|
|
20
|
+
private val EMPTY = Snapshot(currentRoute = null, backStack = emptyList())
|
|
21
|
+
private val state = AtomicReference(EMPTY)
|
|
22
|
+
|
|
23
|
+
/** Must run before the first Activity's setContent — same timing rule as [ComposeRootRegistry]. */
|
|
24
|
+
fun install() {
|
|
25
|
+
NavInspectionHook.listener = { current, backStack ->
|
|
26
|
+
state.set(Snapshot(current, backStack))
|
|
27
|
+
}
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
fun current(): Snapshot = state.get()
|
|
31
|
+
}
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
package __PACKAGE__.data
|
|
2
|
+
|
|
3
|
+
import __PACKAGE__.domain.model.DomainError
|
|
4
|
+
import __PACKAGE__.domain.result.AppResult
|
|
5
|
+
import kotlin.coroutines.cancellation.CancellationException
|
|
6
|
+
|
|
7
|
+
/**
|
|
8
|
+
* The data layer's ONLY exception-catching mechanism (`specs/app-base.spec.md` ARCH-08).
|
|
9
|
+
* Repository implementations wrap their I/O in this instead of writing `try`/`catch` —
|
|
10
|
+
* it is the single translation point where infrastructure exceptions become typed
|
|
11
|
+
* [DomainError] values, and it enforces the one non-negotiable rule of coroutine error
|
|
12
|
+
* handling:
|
|
13
|
+
*
|
|
14
|
+
* **`CancellationException` is ALWAYS rethrown, never mapped.** Swallowing it breaks
|
|
15
|
+
* structured concurrency — a cancelled screen would render an error state instead of
|
|
16
|
+
* simply stopping. The conformance gate scans for exactly this guard.
|
|
17
|
+
*
|
|
18
|
+
* [mapError] classifies everything else into your [DomainError] vocabulary; the default
|
|
19
|
+
* files anything unclassified under [DomainError.Unexpected] with the cause preserved
|
|
20
|
+
* for logging (never for display).
|
|
21
|
+
*/
|
|
22
|
+
suspend fun <T> suspendRunCatching(
|
|
23
|
+
mapError: (Throwable) -> DomainError = { DomainError.Unexpected(it) },
|
|
24
|
+
block: suspend () -> T,
|
|
25
|
+
): AppResult<T> =
|
|
26
|
+
try {
|
|
27
|
+
AppResult.Success(block())
|
|
28
|
+
} catch (e: CancellationException) {
|
|
29
|
+
throw e // never mapped: cancellation is not a failure state
|
|
30
|
+
} catch (e: Throwable) {
|
|
31
|
+
AppResult.Failure(mapError(e))
|
|
32
|
+
}
|
package/template/composeApp/src/commonMain/kotlin/com/example/app/data/remote/ItemRepositoryImpl.kt
CHANGED
|
@@ -1,7 +1,9 @@
|
|
|
1
1
|
package __PACKAGE__.data.remote
|
|
2
2
|
|
|
3
|
+
import __PACKAGE__.data.suspendRunCatching
|
|
3
4
|
import __PACKAGE__.domain.model.Item
|
|
4
5
|
import __PACKAGE__.domain.repository.ItemRepository
|
|
6
|
+
import __PACKAGE__.domain.result.AppResult
|
|
5
7
|
import kotlinx.coroutines.delay
|
|
6
8
|
|
|
7
9
|
// Example data source for the `home` feature. This is intentionally dependency-light
|
|
@@ -9,10 +11,15 @@ import kotlinx.coroutines.delay
|
|
|
9
11
|
//
|
|
10
12
|
// Real apps swap this for a Firestore/Ktor source and add a Room cache (see data/local).
|
|
11
13
|
// The Clean Architecture seam is the ItemRepository interface in the domain layer.
|
|
14
|
+
//
|
|
15
|
+
// The repository is the ONLY exception-translation point: I/O runs inside
|
|
16
|
+
// suspendRunCatching (data/AppResultCatching.kt), which maps infrastructure exceptions
|
|
17
|
+
// to typed DomainError values and ALWAYS rethrows CancellationException. Pass a mapError
|
|
18
|
+
// lambda to classify your real source's exceptions (IOException -> Network, etc).
|
|
12
19
|
class ItemRepositoryImpl : ItemRepository {
|
|
13
|
-
override suspend fun getItems(): List<Item
|
|
20
|
+
override suspend fun getItems(): AppResult<List<Item>> = suspendRunCatching {
|
|
14
21
|
delay(300) // simulate I/O
|
|
15
|
-
|
|
22
|
+
listOf(
|
|
16
23
|
Item("1", "Welcome to __APP_NAME__", "Your Compose Multiplatform app is wired end-to-end."),
|
|
17
24
|
Item("2", "Clean Architecture", "presentation → domain → data, with Koin DI."),
|
|
18
25
|
Item("3", "Edge-to-edge, pre-solved", "BaseScreen owns the window insets for you."),
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
package __PACKAGE__.domain.model
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* The typed failure vocabulary of the domain layer — every failure a repository can report
|
|
5
|
+
* is one of these KINDS. No user-facing message strings live here: mapping a kind to copy
|
|
6
|
+
* is the presentation layer's job (see the exemplar ViewModel's `toUserMessage()`), so the
|
|
7
|
+
* domain stays translatable and UI-copy changes never touch this layer.
|
|
8
|
+
*
|
|
9
|
+
* Extend with the kinds YOUR sources can actually produce (e.g. `Unauthorized`, `Conflict`)
|
|
10
|
+
* — the data layer's `suspendRunCatching` mapper is the single place they are assigned.
|
|
11
|
+
*/
|
|
12
|
+
sealed interface DomainError {
|
|
13
|
+
/** The source was unreachable — connectivity, DNS, timeouts. */
|
|
14
|
+
data object Network : DomainError
|
|
15
|
+
|
|
16
|
+
/** The requested entity does not exist at the source. */
|
|
17
|
+
data object NotFound : DomainError
|
|
18
|
+
|
|
19
|
+
/** Anything not yet classified. Carries the cause for logging — never for display. */
|
|
20
|
+
data class Unexpected(val cause: Throwable? = null) : DomainError
|
|
21
|
+
}
|
|
@@ -1,8 +1,11 @@
|
|
|
1
1
|
package __PACKAGE__.domain.repository
|
|
2
2
|
|
|
3
3
|
import __PACKAGE__.domain.model.Item
|
|
4
|
+
import __PACKAGE__.domain.result.AppResult
|
|
4
5
|
|
|
5
6
|
// Domain-facing contract. Presentation depends on THIS, never on a concrete data source.
|
|
7
|
+
// One-shot operations return AppResult — they never throw (ARCH-06): failures cross the
|
|
8
|
+
// boundary as typed DomainError values, translated inside the data implementation.
|
|
6
9
|
interface ItemRepository {
|
|
7
|
-
suspend fun getItems(): List<Item
|
|
10
|
+
suspend fun getItems(): AppResult<List<Item>>
|
|
8
11
|
}
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
package __PACKAGE__.domain.result
|
|
2
|
+
|
|
3
|
+
import __PACKAGE__.domain.model.DomainError
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* The typed result that crosses the data → domain → presentation boundary. One-shot
|
|
7
|
+
* repository operations return `AppResult<T>`, never throw (`specs/app-base.spec.md`
|
|
8
|
+
* ARCH-06) — so a ViewModel exhaustively `when`s over Success/Failure instead of
|
|
9
|
+
* catching exceptions (ARCH-07).
|
|
10
|
+
*
|
|
11
|
+
* Deliberately our own type rather than `kotlin.Result`: the stdlib Result carries an
|
|
12
|
+
* untyped Throwable, which would put raw exceptions right back on the boundary this
|
|
13
|
+
* type exists to keep them off. A [Failure] carries a typed [DomainError] kind instead
|
|
14
|
+
* (the same call Now in Android makes with its own Result).
|
|
15
|
+
*
|
|
16
|
+
* Cancellation is NOT a result: `CancellationException` propagates (structured
|
|
17
|
+
* concurrency), enforced at the single translation point — `suspendRunCatching` in
|
|
18
|
+
* `data/AppResultCatching.kt` (ARCH-08).
|
|
19
|
+
*/
|
|
20
|
+
sealed interface AppResult<out T> {
|
|
21
|
+
data class Success<out T>(val value: T) : AppResult<T>
|
|
22
|
+
data class Failure(val error: DomainError) : AppResult<Nothing>
|
|
23
|
+
}
|
package/template/composeApp/src/commonMain/kotlin/com/example/app/domain/usecase/GetItemsUseCase.kt
CHANGED
|
@@ -2,11 +2,14 @@ package __PACKAGE__.domain.usecase
|
|
|
2
2
|
|
|
3
3
|
import __PACKAGE__.domain.model.Item
|
|
4
4
|
import __PACKAGE__.domain.repository.ItemRepository
|
|
5
|
+
import __PACKAGE__.domain.result.AppResult
|
|
5
6
|
|
|
6
7
|
// A use case is a single business action. ViewModels depend on use cases, not repositories
|
|
7
8
|
// directly, so business rules stay testable and out of the presentation layer.
|
|
9
|
+
// The typed result passes through untouched — a use case may combine or transform results,
|
|
10
|
+
// but it never unwraps them into exceptions.
|
|
8
11
|
class GetItemsUseCase(
|
|
9
12
|
private val repository: ItemRepository,
|
|
10
13
|
) {
|
|
11
|
-
suspend operator fun invoke(): List<Item
|
|
14
|
+
suspend operator fun invoke(): AppResult<List<Item>> = repository.getItems()
|
|
12
15
|
}
|