diff --git a/examples/folio/composeApp/src/androidMain/kotlin/app/folio/sanderling/AccountSnapshots.kt b/examples/folio/composeApp/src/androidMain/kotlin/app/folio/sanderling/AccountSnapshots.kt deleted file mode 100644 index 1f4be1c..0000000 --- a/examples/folio/composeApp/src/androidMain/kotlin/app/folio/sanderling/AccountSnapshots.kt +++ /dev/null @@ -1,19 +0,0 @@ -package app.folio.sanderling - -import app.folio.data.Repository -import app.folio.feature.account.AddAccountUiState -import app.folio.platform.balanceOf -import dev.sanderling.sdk.Sanderling - -object AccountSnapshots { - val accountCount by Sanderling.snapshot { Repository.accounts.value.size } - val totalBalance by Sanderling.snapshot { balanceOf(Repository.transactions.value) } - val accounts by Sanderling.snapshot { - val txns = Repository.transactions.value - Repository.accounts.value.map { a -> - val rows = txns.filter { it.accountId == a.id } - mapOf("id" to a.id, "name" to a.name, "balance" to balanceOf(rows), "txnCount" to rows.size) - } - } - val addAccountError by Sanderling.snapshot { AddAccountUiState.addAccountError.value } -} diff --git a/examples/folio/composeApp/src/androidMain/kotlin/app/folio/sanderling/AuthSnapshots.kt b/examples/folio/composeApp/src/androidMain/kotlin/app/folio/sanderling/AuthSnapshots.kt deleted file mode 100644 index d8be6ab..0000000 --- a/examples/folio/composeApp/src/androidMain/kotlin/app/folio/sanderling/AuthSnapshots.kt +++ /dev/null @@ -1,11 +0,0 @@ -package app.folio.sanderling - -import app.folio.data.Repository -import app.folio.feature.auth.LoginUiState -import dev.sanderling.sdk.Sanderling - -object AuthSnapshots { - val loggedIn by Sanderling.snapshot { Repository.session.value != null } - val authStatus by Sanderling.snapshot { if (Repository.session.value != null) "logged-in" else "logged-out" } - val loginError by Sanderling.snapshot { LoginUiState.loginError.value } -} diff --git a/examples/folio/composeApp/src/androidMain/kotlin/app/folio/sanderling/LedgerSnapshots.kt b/examples/folio/composeApp/src/androidMain/kotlin/app/folio/sanderling/LedgerSnapshots.kt deleted file mode 100644 index 778eaf0..0000000 --- a/examples/folio/composeApp/src/androidMain/kotlin/app/folio/sanderling/LedgerSnapshots.kt +++ /dev/null @@ -1,42 +0,0 @@ -package app.folio.sanderling - -import app.folio.FocusTracker -import app.folio.data.Repository -import app.folio.data.TxnType -import app.folio.feature.ledger.AddTransactionUiState -import app.folio.navigation.Navigator -import app.folio.navigation.Route -import app.folio.platform.balanceOf -import app.folio.platform.signedAmount -import dev.sanderling.sdk.Sanderling - -object LedgerSnapshots { - private val activeId - get() = when (val r = Navigator.current.value) { - is Route.Ledger -> r.accountId - is Route.AddTransaction -> r.accountId - else -> null - } - - val activeAccountId by Sanderling.snapshot { activeId } - val ledgerRows by Sanderling.snapshot { - val active = activeId ?: return@snapshot emptyList() - Repository.transactions.value.filter { it.accountId == active }.map { - mapOf( - "id" to it.id, - "accountId" to it.accountId, - "type" to if (it.type == TxnType.credit) "credit" else "debit", - "amount" to it.amount, - "signed" to signedAmount(it), - ) - } - } - val ledgerBalance by Sanderling.snapshot { - val active = activeId ?: return@snapshot 0L - balanceOf(Repository.transactions.value.filter { it.accountId == active }) - } - val focusedInput by Sanderling.snapshot { FocusTracker.current.value } - val txnFormType by Sanderling.snapshot { AddTransactionUiState.txnFormType.value } - val txnFormAccountId by Sanderling.snapshot { (Navigator.current.value as? Route.AddTransaction)?.accountId } - val txnError by Sanderling.snapshot { AddTransactionUiState.txnError.value } -} diff --git a/examples/folio/composeApp/src/androidMain/kotlin/app/folio/sanderling/NavigationSnapshots.kt b/examples/folio/composeApp/src/androidMain/kotlin/app/folio/sanderling/NavigationSnapshots.kt deleted file mode 100644 index 6da97ac..0000000 --- a/examples/folio/composeApp/src/androidMain/kotlin/app/folio/sanderling/NavigationSnapshots.kt +++ /dev/null @@ -1,17 +0,0 @@ -package app.folio.sanderling - -import app.folio.navigation.Navigator -import app.folio.navigation.Route -import dev.sanderling.sdk.Sanderling - -object NavigationSnapshots { - val screen by Sanderling.snapshot { - when (Navigator.current.value) { - Route.Login -> "login" - Route.Home -> "home" - Route.AddAccount -> "add-account" - is Route.Ledger -> "ledger" - is Route.AddTransaction -> "add-transaction" - } - } -} diff --git a/examples/folio/composeApp/src/iosMain/kotlin/app/folio/sanderling/AccountSnapshots.kt b/examples/folio/composeApp/src/iosMain/kotlin/app/folio/sanderling/AccountSnapshots.kt deleted file mode 100644 index 87ca22b..0000000 --- a/examples/folio/composeApp/src/iosMain/kotlin/app/folio/sanderling/AccountSnapshots.kt +++ /dev/null @@ -1,18 +0,0 @@ -package app.folio.sanderling - -import app.folio.data.Repository -import app.folio.feature.account.AddAccountUiState -import app.folio.platform.balanceOf - -object AccountSnapshots { - val accountCount by SanderlingIos.snapshot { Repository.accounts.value.size } - val totalBalance by SanderlingIos.snapshot { balanceOf(Repository.transactions.value) } - val accounts by SanderlingIos.snapshot { - val txns = Repository.transactions.value - Repository.accounts.value.map { a -> - val rows = txns.filter { it.accountId == a.id } - mapOf("id" to a.id, "name" to a.name, "balance" to balanceOf(rows), "txnCount" to rows.size) - } - } - val addAccountError by SanderlingIos.snapshot { AddAccountUiState.addAccountError.value } -} diff --git a/examples/folio/composeApp/src/iosMain/kotlin/app/folio/sanderling/AuthSnapshots.kt b/examples/folio/composeApp/src/iosMain/kotlin/app/folio/sanderling/AuthSnapshots.kt deleted file mode 100644 index a59b3a2..0000000 --- a/examples/folio/composeApp/src/iosMain/kotlin/app/folio/sanderling/AuthSnapshots.kt +++ /dev/null @@ -1,10 +0,0 @@ -package app.folio.sanderling - -import app.folio.data.Repository -import app.folio.feature.auth.LoginUiState - -object AuthSnapshots { - val loggedIn by SanderlingIos.snapshot { Repository.session.value != null } - val authStatus by SanderlingIos.snapshot { if (Repository.session.value != null) "logged-in" else "logged-out" } - val loginError by SanderlingIos.snapshot { LoginUiState.loginError.value } -} diff --git a/examples/folio/composeApp/src/iosMain/kotlin/app/folio/sanderling/IosAgent.kt b/examples/folio/composeApp/src/iosMain/kotlin/app/folio/sanderling/IosAgent.kt deleted file mode 100644 index 6b271cc..0000000 --- a/examples/folio/composeApp/src/iosMain/kotlin/app/folio/sanderling/IosAgent.kt +++ /dev/null @@ -1,90 +0,0 @@ -package app.folio.sanderling - -import kotlinx.cinterop.ExperimentalForeignApi -import platform.Foundation.NSBundle -import platform.Foundation.NSThread - -@OptIn(ExperimentalForeignApi::class) -internal object IosAgent { - private const val VERSION = "0.0.1" - private const val PROTOCOL_VERSION = 1 - - fun start(host: String, port: Int) { - val thread = NSThread { runLoop(host, port) } - thread.name = "sanderling-agent" - thread.start() - } - - private fun runLoop(host: String, port: Int) { - var delayMs = 0L - while (true) { - if (delayMs > 0) NSThread.sleepForTimeInterval(delayMs / 1000.0) - try { - val conn = TcpConnection.connect(host, port) - delayMs = 0L - try { serve(conn) } finally { conn.close() } - } catch (_: Exception) { - delayMs = if (delayMs <= 0L) 500L else minOf(delayMs * 2, 10_000L) - } - } - } - - private fun serve(conn: TcpConnection) { - val appPackage = (NSBundle.mainBundle.infoDictionary?.get("CFBundleIdentifier") as? String) ?: "unknown" - conn.writeFrame( - """{"type":"HELLO","protocol_version":$PROTOCOL_VERSION,"version":${jsonString(VERSION)},"platform":"ios","app_package":${jsonString(appPackage)}}""".encodeToByteArray() - ) - while (true) { - val frame = conn.readFrame() - val text = frame.decodeToString() - val type = extractJsonField(text, "type") ?: break - val id = extractJsonLong(text, "id") ?: 0L - when (type) { - "PAUSE" -> handlePause(conn, id) - "RESUME" -> IosPauser.release() - "GOODBYE" -> return - } - } - } - - private fun handlePause(conn: TcpConnection, id: Long) { - val snapshots = IosPauser.pauseAndSnapshot { - val snap = SanderlingIos.extractors.toMap() - buildMap { for ((name, extractor) in snap) put(name, runCatching { extractor() }.getOrNull()) } - } - val snapshotsJson = snapshots.entries.joinToString(",") { (k, v) -> "${jsonString(k)}:${jsonValue(v)}" } - conn.writeFrame("""{"type":"STATE","id":$id,"snapshots":{$snapshotsJson}}""".encodeToByteArray()) - } -} - -internal fun extractJsonField(json: String, key: String): String? = - Regex("\"${Regex.escape(key)}\"\\s*:\\s*\"([^\"\\\\]*(?:\\\\.[^\"\\\\]*)*)\"").find(json)?.groupValues?.get(1) - -internal fun extractJsonLong(json: String, key: String): Long? = - Regex("\"${Regex.escape(key)}\"\\s*:\\s*(-?\\d+)").find(json)?.groupValues?.get(1)?.toLongOrNull() - -internal fun jsonString(s: String): String = buildString { - append('"') - for (c in s) when (c) { - '"' -> append("\\\"") - '\\' -> append("\\\\") - '\n' -> append("\\n") - '\r' -> append("\\r") - '\t' -> append("\\t") - else -> if (c.code < 0x20) append("\\u${c.code.toString(16).padStart(4, '0')}") else append(c) - } - append('"') -} - -internal fun jsonValue(value: Any?): String = when (value) { - null -> "null" - is Boolean -> if (value) "true" else "false" - is Int -> value.toString() - is Long -> value.toString() - is Float -> value.toString() - is Double -> value.toString() - is String -> jsonString(value) - is Map<*, *> -> "{${value.entries.joinToString(",") { (k, v) -> "${jsonString(k.toString())}:${jsonValue(v)}" }}}" - is List<*> -> "[${value.joinToString(",") { jsonValue(it) }}]" - else -> jsonString(value.toString()) -} diff --git a/examples/folio/composeApp/src/iosMain/kotlin/app/folio/sanderling/IosPauser.kt b/examples/folio/composeApp/src/iosMain/kotlin/app/folio/sanderling/IosPauser.kt deleted file mode 100644 index e9220cd..0000000 --- a/examples/folio/composeApp/src/iosMain/kotlin/app/folio/sanderling/IosPauser.kt +++ /dev/null @@ -1,26 +0,0 @@ -package app.folio.sanderling - -import kotlinx.cinterop.ExperimentalForeignApi -import platform.darwin.* - -@OptIn(ExperimentalForeignApi::class) -internal object IosPauser { - private val snapshotReady = dispatch_semaphore_create(0) - private val resumeGate = dispatch_semaphore_create(0) - private var capturedSnapshot: Map = emptyMap() - - fun pauseAndSnapshot(extractors: () -> Map): Map { - dispatch_async(dispatch_get_main_queue()) { - capturedSnapshot = runCatching { extractors() }.getOrElse { emptyMap() } - dispatch_semaphore_signal(snapshotReady) - dispatch_semaphore_wait(resumeGate, dispatch_time(DISPATCH_TIME_NOW, 5_000_000_000L)) - } - val waited = dispatch_semaphore_wait(snapshotReady, dispatch_time(DISPATCH_TIME_NOW, 5_000_000_000L)) - check(waited == 0L) { "snapshot timed out: main thread did not signal within 5s" } - return capturedSnapshot - } - - fun release() { - dispatch_semaphore_signal(resumeGate) - } -} diff --git a/examples/folio/composeApp/src/iosMain/kotlin/app/folio/sanderling/LedgerSnapshots.kt b/examples/folio/composeApp/src/iosMain/kotlin/app/folio/sanderling/LedgerSnapshots.kt deleted file mode 100644 index 551fd31..0000000 --- a/examples/folio/composeApp/src/iosMain/kotlin/app/folio/sanderling/LedgerSnapshots.kt +++ /dev/null @@ -1,41 +0,0 @@ -package app.folio.sanderling - -import app.folio.FocusTracker -import app.folio.data.Repository -import app.folio.data.TxnType -import app.folio.feature.ledger.AddTransactionUiState -import app.folio.navigation.Navigator -import app.folio.navigation.Route -import app.folio.platform.balanceOf -import app.folio.platform.signedAmount - -object LedgerSnapshots { - private val activeId - get() = when (val r = Navigator.current.value) { - is Route.Ledger -> r.accountId - is Route.AddTransaction -> r.accountId - else -> null - } - - val activeAccountId by SanderlingIos.snapshot { activeId } - val ledgerRows by SanderlingIos.snapshot { - val active = activeId ?: return@snapshot emptyList() - Repository.transactions.value.filter { it.accountId == active }.map { - mapOf( - "id" to it.id, - "accountId" to it.accountId, - "type" to if (it.type == TxnType.credit) "credit" else "debit", - "amount" to it.amount, - "signed" to signedAmount(it), - ) - } - } - val ledgerBalance by SanderlingIos.snapshot { - val active = activeId ?: return@snapshot 0L - balanceOf(Repository.transactions.value.filter { it.accountId == active }) - } - val focusedInput by SanderlingIos.snapshot { FocusTracker.current.value } - val txnFormType by SanderlingIos.snapshot { AddTransactionUiState.txnFormType.value } - val txnFormAccountId by SanderlingIos.snapshot { (Navigator.current.value as? Route.AddTransaction)?.accountId } - val txnError by SanderlingIos.snapshot { AddTransactionUiState.txnError.value } -} diff --git a/examples/folio/composeApp/src/iosMain/kotlin/app/folio/sanderling/NavigationSnapshots.kt b/examples/folio/composeApp/src/iosMain/kotlin/app/folio/sanderling/NavigationSnapshots.kt deleted file mode 100644 index 41c570e..0000000 --- a/examples/folio/composeApp/src/iosMain/kotlin/app/folio/sanderling/NavigationSnapshots.kt +++ /dev/null @@ -1,16 +0,0 @@ -package app.folio.sanderling - -import app.folio.navigation.Navigator -import app.folio.navigation.Route - -object NavigationSnapshots { - val screen by SanderlingIos.snapshot { - when (Navigator.current.value) { - Route.Login -> "login" - Route.Home -> "home" - Route.AddAccount -> "add-account" - is Route.Ledger -> "ledger" - is Route.AddTransaction -> "add-transaction" - } - } -} diff --git a/examples/folio/composeApp/src/iosMain/kotlin/app/folio/sanderling/SanderlingIos.kt b/examples/folio/composeApp/src/iosMain/kotlin/app/folio/sanderling/SanderlingIos.kt deleted file mode 100644 index f14dd0f..0000000 --- a/examples/folio/composeApp/src/iosMain/kotlin/app/folio/sanderling/SanderlingIos.kt +++ /dev/null @@ -1,48 +0,0 @@ -package app.folio.sanderling - -import kotlin.properties.ReadOnlyProperty -import kotlin.reflect.KProperty -import platform.Foundation.NSProcessInfo - -private fun String.camelToSnakeCase(): String = buildString { - for ((i, c) in this@camelToSnakeCase.withIndex()) { - if (c.isUpperCase() && i > 0) append('_') - append(c.lowercaseChar()) - } -} - -object SanderlingIos { - internal val extractors = mutableMapOf Any?>() - - fun start() { - val port = resolvePort() ?: return - IosAgent.start("127.0.0.1", port) - } - - private fun resolvePort(): Int? { - // Env var set via SIMCTL_CHILD_SANDERLING_PORT (simctl direct launch). - (NSProcessInfo.processInfo.environment["SANDERLING_PORT"] as? String) - ?.toIntOrNull()?.let { return it } - // Launch argument -SANDERLING_PORT (Maestro simctl launch). - @Suppress("UNCHECKED_CAST") - val args = NSProcessInfo.processInfo.arguments as? List ?: return null - val idx = args.indexOfFirst { it == "-SANDERLING_PORT" } - if (idx >= 0 && idx + 1 < args.size) { - return args[idx + 1].toIntOrNull() - } - return null - } - - fun extract(name: String, block: () -> Any?) { - extractors[name] = block - } - - fun snapshot(block: () -> T): SnapshotDelegate = SnapshotDelegate(block) -} - -class SnapshotDelegate(private val block: () -> T) { - operator fun provideDelegate(thisRef: Any?, prop: KProperty<*>): ReadOnlyProperty { - SanderlingIos.extract(prop.name.camelToSnakeCase(), block as () -> Any?) - return ReadOnlyProperty { _, _ -> block() } - } -} diff --git a/examples/folio/composeApp/src/iosMain/kotlin/app/folio/sanderling/TcpConnection.kt b/examples/folio/composeApp/src/iosMain/kotlin/app/folio/sanderling/TcpConnection.kt deleted file mode 100644 index d821388..0000000 --- a/examples/folio/composeApp/src/iosMain/kotlin/app/folio/sanderling/TcpConnection.kt +++ /dev/null @@ -1,81 +0,0 @@ -package app.folio.sanderling - -import kotlinx.cinterop.* -import platform.posix.* - -@OptIn(ExperimentalForeignApi::class) -internal class TcpConnection private constructor(private val fd: Int) { - companion object { - fun connect(host: String, port: Int): TcpConnection { - val sock = socket(AF_INET, SOCK_STREAM, 0) - check(sock >= 0) { "socket() failed" } - memScoped { - val addr = alloc() - addr.sin_family = AF_INET.convert() - addr.sin_port = networkShort(port) - addr.sin_addr.s_addr = networkAddress(host) - val result = platform.posix.connect(sock, addr.ptr.reinterpret(), sizeOf().convert()) - if (result < 0) { - close(sock) - error("connect() to $host:$port failed: errno=$errno") - } - } - return TcpConnection(sock) - } - - // Convert port to network byte order (big-endian bytes in memory on little-endian iOS). - private fun networkShort(value: Int): UShort = - (((value ushr 8) and 0xFF) or ((value and 0xFF) shl 8)).toUShort() - - // Parse "a.b.c.d" → UInt with bytes [a,b,c,d] in memory (little-endian storage). - private fun networkAddress(host: String): UInt { - val parts = host.split(".").map { it.toInt() } - check(parts.size == 4) { "expected IPv4 address, got: $host" } - return (parts[0] or (parts[1] shl 8) or (parts[2] shl 16) or (parts[3] shl 24)).toUInt() - } - } - - fun writeFrame(data: ByteArray) { - val len = data.size - writeAll(byteArrayOf((len ushr 24).toByte(), (len ushr 16).toByte(), (len ushr 8).toByte(), len.toByte())) - writeAll(data) - } - - fun readFrame(): ByteArray { - val header = readAll(4) - val len = ((header[0].toInt() and 0xFF) shl 24) or - ((header[1].toInt() and 0xFF) shl 16) or - ((header[2].toInt() and 0xFF) shl 8) or - (header[3].toInt() and 0xFF) - check(len in 0..16_777_216) { "bad frame length: $len" } - return readAll(len) - } - - private fun writeAll(data: ByteArray) { - data.usePinned { pinned -> - var offset = 0 - while (offset < data.size) { - val n = send(fd, pinned.addressOf(offset), (data.size - offset).convert(), 0).toInt() - check(n > 0) { "send() failed: errno=$errno" } - offset += n - } - } - } - - private fun readAll(count: Int): ByteArray { - val buf = ByteArray(count) - buf.usePinned { pinned -> - var offset = 0 - while (offset < count) { - val n = recv(fd, pinned.addressOf(offset), (count - offset).convert(), 0).toInt() - check(n > 0) { "recv() returned $n" } - offset += n - } - } - return buf - } - - fun close() { - platform.posix.close(fd) - } -}