Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
223 changes: 151 additions & 72 deletions compose/api/compose.klib.api

Large diffs are not rendered by default.

12 changes: 11 additions & 1 deletion compose/build.gradle.kts
Original file line number Diff line number Diff line change
Expand Up @@ -34,13 +34,23 @@ kotlin {
api(libs.quickjs.kmp)
implementation(compose.runtime)
implementation(compose.foundation)
implementation(compose.material3)
implementation(libs.compose.material3)
implementation(libs.kotlinx.coroutines.core)
implementation(libs.kotlinx.serialization.json)
api(libs.okio)
api(libs.ktor.client.core)
}
androidMain.dependencies {
implementation(libs.ktor.client.okhttp)
}
iosMain.dependencies {
implementation(libs.ktor.client.darwin)
}
commonTest.dependencies {
implementation(libs.kotlin.test)
implementation(libs.kotlinx.coroutines.test)
implementation(libs.ktor.client.mock)
implementation(libs.okio.fakefilesystem)
}
}
}
Expand Down
11 changes: 5 additions & 6 deletions compose/src/androidHostTest/kotlin/app/tinyui/BundleSmokeTest.kt
Original file line number Diff line number Diff line change
Expand Up @@ -44,12 +44,11 @@ class BundleSmokeTest {
override fun error(error: PageError) { errors += error }
override fun log(line: String) {}
}
val http = object : HttpClient {
override suspend fun request(request: HttpRequest): HttpResponse =
if (request.url.endsWith("page=1")) HttpResponse(200, """{"items":[{"id":1,"title":"a","done":false}],"next":2}""")
else throw HostException("E_HTTP", "500")
val api = HttpChannel { request ->
if (request.url.endsWith("page=1")) HttpResponse(200, emptyMap(), """{"items":[{"id":1,"title":"a","done":false}],"next":2}""")
else HttpResponse(500, emptyMap(), "")
}
val host = PageHost(loaded.module, TinyUIHost(ComponentRegistry().registerBuiltins(), sink), HostServices(http = http), sourceMaps = loaded.sourceMaps)
val host = PageHost(loaded.module, TinyUIHost(ComponentRegistry().registerBuiltins(), sink, channels = mapOf("todos" to api)), sourceMaps = loaded.sourceMaps)
host.start()
try {
// the list only exists once the first page has loaded; node ids are dense, so scan them
Expand All @@ -63,7 +62,7 @@ class BundleSmokeTest {
assertEquals(bundle.manifest.buildId("sample/todos"), e.buildId)
val own = e.frames.firstOrNull { it.mapped && it.file == "src/pages/todos.tsx" } ?: error("no mapped frame in ${e.frames}; stack=${e.jsStack}")
val source = File("../sample/js/src/pages/todos.tsx").readLines()
assertTrue("await http.get" in source[own.line - 1], "frame $own points at the await; stack=${e.jsStack}")
assertTrue("await todos.get" in source[own.line - 1], "frame $own points at the await; stack=${e.jsStack}")
} finally {
host.close()
}
Expand Down
10 changes: 10 additions & 0 deletions compose/src/androidMain/kotlin/app/tinyui/Platform.android.kt
Original file line number Diff line number Diff line change
@@ -1,9 +1,19 @@
package app.tinyui

import android.os.Build
import io.ktor.client.HttpClient
import io.ktor.client.engine.okhttp.OkHttp
import java.util.Locale
import okio.FileSystem

internal actual fun platformInfo(): Map<String, String> = mapOf(
"os" to "android",
"osVersion" to Build.VERSION.RELEASE.orEmpty(),
"model" to Build.MODEL.orEmpty(),
)

internal actual fun systemLocale(): String = Locale.getDefault().toLanguageTag()

internal actual val platformFileSystem: FileSystem = FileSystem.SYSTEM

internal actual fun defaultHttpClient(): HttpClient = HttpClient(OkHttp)
20 changes: 18 additions & 2 deletions compose/src/commonMain/kotlin/app/tinyui/BuildManifest.kt
Original file line number Diff line number Diff line change
Expand Up @@ -23,7 +23,13 @@ class BuildManifest(
val requires: Map<String, PageRequires> = emptyMap(),
/** Present only in a manifest `tinyui bundle` wrote: the embedded package has none. */
val hostVersion: String? = null,
/** The package's strings (docs/updates.md §1.1); null when it has none. */
val i18n: I18nFiles? = null,
) {
/** Every file of the package besides the manifest, as (path, key into [hashes]). */
val payload: List<Pair<String, String>>
get() = pages.map { file(it) + ".bin" to it } + (i18n?.files?.values?.map { it to it } ?: emptyList())

fun buildId(module: String): String = buildIds[module] ?: ""

/** Output path of [module] without extension (`pages/home`): append `.bin` or `.js.map`. */
Expand Down Expand Up @@ -56,13 +62,23 @@ class BuildManifest(
PageRequires(
components = page["components"]?.jsonArray?.map { it.jsonPrimitive.content } ?: emptyList(),
capabilities = page["capabilities"]?.jsonArray?.map { it.jsonPrimitive.content } ?: emptyList(),
channels = page["channels"]?.jsonArray?.map { it.jsonPrimitive.content } ?: emptyList(),
)
} ?: emptyMap(),
hostVersion = root["hostVersion"]?.jsonPrimitive?.contentOrNull,
i18n = root["i18n"]?.jsonObject?.let { i ->
I18nFiles(
defaultLocale = i["default"]?.jsonPrimitive?.contentOrNull ?: "",
files = i["files"]?.jsonObject?.mapValues { it.value.jsonPrimitive.content } ?: emptyMap(),
)
},
)
}
}
}

/** Dotted host components and `host.call` capability names one page uses. */
class PageRequires(val components: List<String>, val capabilities: List<String>)
/** Dotted host components, capability names (`host.call`, `session.signIn`) and http channels one page uses. */
class PageRequires(val components: List<String>, val capabilities: List<String>, val channels: List<String> = emptyList())

/** `manifest.i18n`: the default language and each language's file inside the package. */
class I18nFiles(val defaultLocale: String, val files: Map<String, String>)
11 changes: 10 additions & 1 deletion compose/src/commonMain/kotlin/app/tinyui/Bundle.kt
Original file line number Diff line number Diff line change
Expand Up @@ -20,14 +20,23 @@ class Bundle(val manifest: BuildManifest, private val files: BundleFiles) {

private val lock = Mutex()
private val maps = HashMap<String, String?>()
private var i18n: PackageI18n? = null

suspend fun page(name: String): LoadedPage {
require(name in manifest.pages) { "page $name is not in package ${manifest.name}: ${manifest.pages}" }
val bytecode = read(manifest.file(name) + ".bin")
val module = PageModule(name, bytecode, manifest.buildId(name))
val module = PageModule(name, bytecode, manifest.buildId(name), i18n())
return LoadedPage(module, sourceMaps(name), this)
}

/** The package's strings, every language read once (docs/native-api.md §8). */
private suspend fun i18n(): PackageI18n = lock.withLock {
i18n ?: run {
val spec = manifest.i18n ?: return@run PackageI18n.EMPTY
PackageI18n.parse(spec.defaultLocale, spec.files.mapValues { (_, path) -> read(path).decodeToString() })
}.also { i18n = it }
}

private suspend fun sourceMaps(module: String): SourceMaps {
val map = lock.withLock {
if (module in maps) maps[module] else files.read(manifest.file(module) + ".js.map")?.decodeToString().also { maps[module] = it }
Expand Down
138 changes: 138 additions & 0 deletions compose/src/commonMain/kotlin/app/tinyui/HostInterfaces.kt
Original file line number Diff line number Diff line change
@@ -0,0 +1,138 @@
package app.tinyui

import kotlin.concurrent.atomics.AtomicReference
import kotlin.concurrent.atomics.ExperimentalAtomicApi
import kotlin.concurrent.atomics.update
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Job
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.launch
import kotlinx.serialization.KSerializer
import kotlinx.serialization.json.Json
import kotlinx.serialization.serializer

/** J4 `navigation.push` / `navigation.pop`; results travel back as K5 `navigation.result` (docs/app-model.md). */
interface Navigator {
fun push(page: String, paramsJson: String)
fun pop(resultJson: String?)

object None : Navigator {
override fun push(page: String, paramsJson: String) {}
override fun pop(resultJson: String?) {}
}
}

/** A store value with the monotonic version of its write; listeners drop what arrives out of order. */
class StoreValue(val json: String, val version: Long)

/** App-wide key → JSON text, in memory; the truth for cross-page state (docs/native-api.md §4). */
interface Store {
fun getJson(key: String): String?
fun setJson(key: String, json: String)
/** [listener] receives every write to [key], possibly out of order across threads; the returned handle removes it. */
fun observe(key: String, listener: (StoreValue) -> Unit): AutoCloseable
}

inline fun <reified T> Store.set(key: String, value: T) = setJson(key, Json.encodeToString(serializer<T>(), value))

/** Writes every value of [flow] to [key] until [scope] ends. */
fun <T> Store.bind(key: String, flow: Flow<T>, scope: CoroutineScope, serializer: KSerializer<T>): Job =
scope.launch { flow.collect { setJson(key, Json.encodeToString(serializer, it)) } }

inline fun <reified T> Store.bind(key: String, flow: Flow<T>, scope: CoroutineScope): Job = bind(key, flow, scope, serializer<T>())

/** Copy-on-write: pages subscribe on their engine threads while the host writes from anywhere. */
@OptIn(ExperimentalAtomicApi::class)
internal class InMemoryStore : Store {
private val values = AtomicReference<Map<String, StoreValue>>(emptyMap())
private val listeners = Listeners<StoreValue>()

override fun getJson(key: String): String? = values.load()[key]?.json

override fun setJson(key: String, json: String) {
var written: StoreValue? = null
values.update { map -> StoreValue(json, (map[key]?.version ?: 0) + 1).also { written = it }.let { map + (key to it) } }
listeners.notify(key, written!!)
}

override fun observe(key: String, listener: (StoreValue) -> Unit): AutoCloseable = listeners.add(key, listener)
}

/** One request of J3 `http.request`; [body] is the text to send, its `Content-Type` among [headers]. */
class HttpRequest(val method: String, val url: String, val headers: Map<String, String>, val body: String?, val timeoutMs: Long?)

class HttpResponse(val status: Int, val headers: Map<String, String>, val body: String)

/** Where a page's requests go out (docs/native-api.md §6): the built-in `default`, or one the host registers with its identity. */
fun interface HttpChannel {
/** Any status is a response; throw [HostException] for E3 codes, anything else counts as E_NET. */
suspend fun request(request: HttpRequest): HttpResponse
}

/** An E3 failure with one of the codes in docs/native-api.md §14. */
class HostException(val code: String, message: String) : Exception(message)

/** The login session a host provides (docs/native-api.md §9); [userId] is a stable, non-secret id, never a token; null when logged out or not yet known. */
data class Session(val loggedIn: Boolean, val userId: String?) {
companion object {
val LoggedOut = Session(false, null)
}
}

/** [signIn] runs the host's login flow and returns when it ends, whatever the outcome. */
class SessionSource(val state: StateFlow<Session>, val signIn: suspend (source: String) -> Unit)

/** Where `analytics.track` goes (docs/native-api.md §12); [props] hold String, Long, Double, Boolean or null. */
fun interface AnalyticsSink {
fun track(name: String, props: Map<String, Any?>, page: PageContext)
}

/** Opens a link for `linking.openUrl` (docs/native-api.md §11); throw [HostException] `E_UNSUPPORTED` when nothing can. */
fun interface LinkOpener {
suspend fun open(url: String, page: PageContext)

companion object {
/** The platform's own handling: Android `ACTION_VIEW`, iOS `UIApplication.open`. */
val System: LinkOpener = LinkOpener { url, page -> page.openWithSystem(url) }
}
}

/** One bus for host events and business events; pages subscribe per topic (docs/app-model.md §3). */
class EventBus {
private val listeners = Listeners<String>()

fun emit(topic: String, payloadJson: String) = listeners.notify(topic, payloadJson)

fun subscribe(topic: String, listener: (String) -> Unit): AutoCloseable = listeners.add(topic, listener)
}

/** Names the framework dispatches itself (J2 / J3 / J4); a host capability may not take one. */
internal val FrameworkNames = setOf(
"device.info", "store.get", "store.set", "store.subscribe", "storage.get", "storage.set", "storage.remove", "storage.clear",
"i18n.t", "i18n.locale", "i18n.subscribe", "session.get", "session.subscribe", "session.signIn",
"navigation.push", "navigation.pop", "events.emit", "events.subscribe", "http.request", "timer.schedule", "timer.cancel",
"ui.toast", "ui.alert", "ui.confirm", "linking.openUrl", "analytics.track",
)

/** What every host offers, listed in the mount manifest ahead of the host's own; `session.signIn` only comes with a session. */
internal val FrameworkCapabilities: Set<String> = FrameworkNames - "session.signIn"

@OptIn(ExperimentalAtomicApi::class)
internal class Listeners<T> {
private val byKey = AtomicReference<Map<String, List<(T) -> Unit>>>(emptyMap())

fun add(key: String, listener: (T) -> Unit): AutoCloseable {
byKey.update { it + (key to (it[key].orEmpty() + listener)) }
return AutoCloseable { byKey.update { it + (key to (it[key].orEmpty() - listener)) } }
}

fun notify(key: String, value: T) {
byKey.load()[key]?.forEach { it(value) }
}
}

internal expect fun platformInfo(): Map<String, String>

/** BCP 47, e.g. `zh-Hans-CN`. */
internal expect fun systemLocale(): String
Loading
Loading