From 2fb6248c88949f7a73f1cdb21a94ab07825af7c3 Mon Sep 17 00:00:00 2001 From: PJ Date: Thu, 23 Apr 2026 15:06:14 +0700 Subject: [PATCH] feat(sdk-ios): add Kotlin Native iOS SDK (TCP agent + POSIX socket + dispatch pauser) --- .../app/folio/sanderling/AccountSnapshots.kt | 18 ++++ .../app/folio/sanderling/AuthSnapshots.kt | 10 +++ .../kotlin/app/folio/sanderling/IosAgent.kt | 90 +++++++++++++++++++ .../kotlin/app/folio/sanderling/IosPauser.kt | 25 ++++++ .../app/folio/sanderling/LedgerSnapshots.kt | 41 +++++++++ .../folio/sanderling/NavigationSnapshots.kt | 16 ++++ .../app/folio/sanderling/SanderlingIos.kt | 36 ++++++++ .../app/folio/sanderling/TcpConnection.kt | 70 +++++++++++++++ 8 files changed, 306 insertions(+) create mode 100644 examples/folio/composeApp/src/iosMain/kotlin/app/folio/sanderling/AccountSnapshots.kt create mode 100644 examples/folio/composeApp/src/iosMain/kotlin/app/folio/sanderling/AuthSnapshots.kt create mode 100644 examples/folio/composeApp/src/iosMain/kotlin/app/folio/sanderling/IosAgent.kt create mode 100644 examples/folio/composeApp/src/iosMain/kotlin/app/folio/sanderling/IosPauser.kt create mode 100644 examples/folio/composeApp/src/iosMain/kotlin/app/folio/sanderling/LedgerSnapshots.kt create mode 100644 examples/folio/composeApp/src/iosMain/kotlin/app/folio/sanderling/NavigationSnapshots.kt create mode 100644 examples/folio/composeApp/src/iosMain/kotlin/app/folio/sanderling/SanderlingIos.kt create mode 100644 examples/folio/composeApp/src/iosMain/kotlin/app/folio/sanderling/TcpConnection.kt 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 new file mode 100644 index 0000000..87ca22b --- /dev/null +++ b/examples/folio/composeApp/src/iosMain/kotlin/app/folio/sanderling/AccountSnapshots.kt @@ -0,0 +1,18 @@ +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 new file mode 100644 index 0000000..a59b3a2 --- /dev/null +++ b/examples/folio/composeApp/src/iosMain/kotlin/app/folio/sanderling/AuthSnapshots.kt @@ -0,0 +1,10 @@ +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 new file mode 100644 index 0000000..40c9699 --- /dev/null +++ b/examples/folio/composeApp/src/iosMain/kotlin/app/folio/sanderling/IosAgent.kt @@ -0,0 +1,90 @@ +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 = synchronized(SanderlingIos.extractors) { SanderlingIos.extractors.toMap() } + buildMap { for ((name, fn) in snap) put(name, runCatching { fn() }.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 as Map<*, *>).entries.joinToString(",") { (k, v) -> "${jsonString(k.toString())}:${jsonValue(v)}" }}}" + is List<*> -> "[${(value as List<*>).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 new file mode 100644 index 0000000..ed5fa92 --- /dev/null +++ b/examples/folio/composeApp/src/iosMain/kotlin/app/folio/sanderling/IosPauser.kt @@ -0,0 +1,25 @@ +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) + @Volatile 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)) + } + dispatch_semaphore_wait(snapshotReady, dispatch_time(DISPATCH_TIME_NOW, 5_000_000_000L)) + 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 new file mode 100644 index 0000000..551fd31 --- /dev/null +++ b/examples/folio/composeApp/src/iosMain/kotlin/app/folio/sanderling/LedgerSnapshots.kt @@ -0,0 +1,41 @@ +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 new file mode 100644 index 0000000..41c570e --- /dev/null +++ b/examples/folio/composeApp/src/iosMain/kotlin/app/folio/sanderling/NavigationSnapshots.kt @@ -0,0 +1,16 @@ +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 new file mode 100644 index 0000000..c17fe57 --- /dev/null +++ b/examples/folio/composeApp/src/iosMain/kotlin/app/folio/sanderling/SanderlingIos.kt @@ -0,0 +1,36 @@ +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 env = NSProcessInfo.processInfo.environment + val portStr = env["SANDERLING_PORT"] as? String ?: return + val port = portStr.toIntOrNull() ?: return + IosAgent.start("127.0.0.1", port) + } + + 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 new file mode 100644 index 0000000..ee5891b --- /dev/null +++ b/examples/folio/composeApp/src/iosMain/kotlin/app/folio/sanderling/TcpConnection.kt @@ -0,0 +1,70 @@ +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 = htons(port.convert()) + inet_pton(AF_INET, host, addr.sin_addr.ptr) + val result = platform.posix.connect(sock, addr.ptr.reinterpret(), sizeOf().convert()) + if (result < 0) { + close(sock) + error("connect() failed: errno=$errno") + } + } + return TcpConnection(sock) + } + } + + 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) + } +}