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
2 changes: 2 additions & 0 deletions .gitattributes
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
# host snapshots are uploaded as raw bytes (docs/updates.md §4.1)
**/tinyui-host/*.txt text eol=lf
2 changes: 1 addition & 1 deletion .github/workflows/build.yml
Original file line number Diff line number Diff line change
Expand Up @@ -28,7 +28,7 @@ jobs:
git clone --depth 1 --branch "$version" https://github.com/HarlonWang/quickjs-kmp.git "$RUNNER_TEMP/quickjs-kmp"
(cd "$RUNNER_TEMP/quickjs-kmp" && ./gradlew :library:buildNativeHostJni --no-daemon)
echo "TINYUI_QUICKJS_HOST_JNI=$RUNNER_TEMP/quickjs-kmp/library/build/native/host-jni/lib" >> "$GITHUB_ENV"
- run: ./gradlew :compose:testAndroidHostTest :compose:apiCheck :sample:androidApp:assembleDebug --no-daemon
- run: ./gradlew :compose:testAndroidHostTest :compose:apiCheck :sample:shared:testAndroidHostTest :sample:androidApp:assembleDebug --no-daemon

pnpm:
runs-on: ubuntu-latest
Expand Down
74 changes: 66 additions & 8 deletions compose/api/compose.klib.api

Large diffs are not rendered by default.

15 changes: 15 additions & 0 deletions compose/build.gradle.kts
Original file line number Diff line number Diff line change
Expand Up @@ -7,12 +7,27 @@ plugins {
alias(libs.plugins.binaryCompatibilityValidator)
}

// TinyUI.version: the published version, so a host snapshot names the tinyui it was built against
val generateVersion by tasks.registering {
val version = providers.gradleProperty("VERSION_NAME")
val out = layout.buildDirectory.dir("generated/tinyui-version")
inputs.property("version", version)
outputs.dir(out)
doLast {
out.get().file("app/tinyui/Version.kt").asFile.apply { parentFile.mkdirs() }
.writeText("package app.tinyui\n\ninternal const val VERSION = \"${version.get()}\"\n")
}
}

kotlin {
android {
namespace = "app.tinyui"
}

sourceSets {
commonMain {
kotlin.srcDir(generateVersion)
}
commonMain.dependencies {
api(libs.quickjs.kmp)
implementation(compose.runtime)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -49,7 +49,7 @@ class BundleSmokeTest {
if (request.url.endsWith("page=1")) HttpResponse(200, """{"items":[{"id":1,"title":"a","done":false}],"next":2}""")
else throw HostException("E_HTTP", "500")
}
val host = PageHost(loaded.runtime, loaded.module, ComponentRegistry().registerBuiltins(), sink, HostServices(http = http), sourceMaps = loaded.sourceMaps)
val host = PageHost(loaded.runtime, loaded.module, TinyUIHost(ComponentRegistry().registerBuiltins(), sink), HostServices(http = http), sourceMaps = loaded.sourceMaps)
host.start()
try {
// the list only exists once the first page has loaded; node ids are dense, so scan them
Expand Down
16 changes: 3 additions & 13 deletions compose/src/commonMain/kotlin/app/tinyui/HostServices.kt
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@ import kotlin.concurrent.atomics.AtomicReference
import kotlin.concurrent.atomics.ExperimentalAtomicApi
import kotlin.concurrent.atomics.update

/** What the host App provides to pages (docs/native-api.md); every part has a default that does nothing. */
/** What the host provides to one mount (docs/native-api.md); every part has a default that does nothing. App-wide parts live in [TinyUIHost]. */
class HostServices(
val navigator: Navigator = Navigator.None,
val store: Store = InMemoryStore(),
Expand All @@ -14,14 +14,9 @@ class HostServices(
val events: EventBus = EventBus(),
/** Extra values merged into `device.info` (docs/native-api.md §1). */
val deviceInfo: Map<String, String> = emptyMap(),
/** J3 `host.call` by name (docs/native-api.md §7); names the framework already uses are rejected. */
val capabilities: Map<String, HostCapability> = emptyMap(),
/** This mount's objects that capabilities read through [PageContext] (docs/native-api.md §7). */
val locals: List<PageLocalValue<*>> = emptyList(),
) {
init {
val taken = capabilities.keys.filter { it in FrameworkCapabilities }
require(taken.isEmpty()) { "capabilities $taken are framework names" }
}

companion object {
/** One shared instance, so a page whose host passes nothing keeps a stable `remember` key. */
val Default = HostServices()
Expand Down Expand Up @@ -83,11 +78,6 @@ class HttpResponse(val status: Int, val bodyJson: String)
/** An E3 failure with one of the codes in docs/native-api.md §6. */
class HostException(val code: String, message: String) : Exception(message)

/** One host-defined J3 capability: [argsJson] is the object JS passed; returns JSON text, or null for `undefined`. Throw [HostException] for E3 codes. */
fun interface HostCapability {
suspend fun call(argsJson: String): String?
}

/** Names the framework dispatches itself (J2 / J3 / J4), listed in the mount manifest ahead of the host's. */
internal val FrameworkCapabilities = listOf("device.info", "i18n.t", "config.get", "store.get", "store.set", "navigation.push", "navigation.pop", "events.emit", "http.request", "timer.schedule")

Expand Down
64 changes: 64 additions & 0 deletions compose/src/commonMain/kotlin/app/tinyui/HostSnapshot.kt
Original file line number Diff line number Diff line change
@@ -0,0 +1,64 @@
package app.tinyui

import app.tinyui.schema.ComponentSchema
import app.tinyui.schema.FieldSpec
import app.tinyui.schema.PropSpec
import kotlinx.serialization.json.JsonPrimitive

/** The host snapshot `tinyui-host/<hostVersion>.txt` (docs/updates.md §4.1, format §6.4). */
object HostSnapshot {
fun render(host: TinyUIHost, hostVersion: String): String {
require(hostVersion.matches(Regex("[1-9][0-9]*"))) { "hostVersion must be a positive integer, got \"$hostVersion\"" }
val components = host.components.schemas.filter { '.' in it.type }.associate { it.type to describe(it) }
return buildString {
append("hostVersion ").append(hostVersion).append('\n')
append("tinyui ").append(TinyUI.version).append("\n\n")
append("components\n")
section(components)
append("\ncapabilities\n")
section(host.capabilities.names.associateWith { "" })
}
}

private fun StringBuilder.section(items: Map<String, String>) {
val width = items.keys.maxOfOrNull { it.length } ?: 0
for ((name, rest) in items.entries.sortedBy { it.key }) {
append(" ").append(if (rest.isEmpty()) name else name.padEnd(width + 2) + rest).append('\n')
}
}

private fun describe(schema: ComponentSchema): String = listOfNotNull(
schema.props.entries.sortedBy { it.key }.joinToString(", ") { (name, spec) -> prop(name, spec) }.ifEmpty { null },
calls("events", schema.events),
calls("commands", schema.commands),
"children".takeIf { schema.children },
"layout".takeIf { schema.layout },
).joinToString("; ")

private fun prop(name: String, spec: PropSpec): String = buildString {
append(name)
if (!spec.required) append('?')
append(": ").append(if (spec is PropSpec.Enum) spec.values.sorted().joinToString("|", "enum(", ")") else spec.kind)
spec.declared?.let { append(" = ").append(if (spec is PropSpec.Str) JsonPrimitive(it as String).toString() else literal(it)) }
if (spec.initial) append(" (initial)")
}

private fun calls(label: String, calls: Map<String, Map<String, FieldSpec>>): String? {
if (calls.isEmpty()) return null
return calls.entries.sortedBy { it.key }.joinToString(", ", "$label ") { (name, fields) ->
fields.entries.sortedBy { it.key }.joinToString(", ", "$name(", ")") { (field, type) -> "$field: ${type.typeName}" }
}
}

private val FieldSpec.typeName: String
get() = when (this) {
FieldSpec.Str -> "string"
FieldSpec.Num -> "number"
FieldSpec.Bool -> "boolean"
}

private fun literal(value: Any): String = when (value) {
Comment thread
HarlonWang marked this conversation as resolved.
Resolved
is Double -> if (value % 1.0 == 0.0 && value > -1e15 && value < 1e15) value.toLong().toString() else value.toString()
else -> value.toString()
}
}
12 changes: 7 additions & 5 deletions compose/src/commonMain/kotlin/app/tinyui/PageHost.kt
Original file line number Diff line number Diff line change
Expand Up @@ -82,14 +82,16 @@ class PageFailure(val error: PageError) {
class PageHost(
private val runtimeBundle: RuntimeBundle,
private val page: PageModule,
val registry: ComponentRegistry,
private val sink: PageSink,
val host: TinyUIHost,
private val services: HostServices = HostServices.Default,
private val propsJson: String = "{}",
/** K entries longer than this are interrupted and fail the page (docs/native-api.md §6). */
private val entryTimeoutMs: Long = 5_000,
private val sourceMaps: SourceMaps = SourceMaps.EMPTY,
) {
private val registry: ComponentRegistry get() = host.components
private val sink: PageSink get() = host.sink
private val context by lazy { PageContext(page.name, services.locals.associate { it.local to it.value }) }
val tree = NodeTree(registry) { report("E5", it.reason, op = it.op) }
var failure: PageFailure? by mutableStateOf(null)
private set
Expand Down Expand Up @@ -129,7 +131,7 @@ class PageHost(
check(protocol == PROTOCOL) { "protocol $protocol from the runtime module, host implements $PROTOCOL" }
namespace.use { ns ->
(ns.get("default", ObjectTransport.REF) as JsRef).use { default ->
e.call("mount", default, JsValue.Str(propsJson), JsValue.Str(registry.manifest(FrameworkCapabilities + services.capabilities.keys)))
e.call("mount", default, JsValue.Str(propsJson), JsValue.Str(registry.manifest(FrameworkCapabilities + host.capabilities.names)))
}
}
e.call("flush")
Expand Down Expand Up @@ -264,9 +266,9 @@ class PageHost(
val response = services.http.request(request)
buildJsonObject { put("status", response.status); put("body", Json.parseToJsonElement(response.bodyJson)) }.toString()
}
else -> when (val capability = services.capabilities[name]) {
else -> when (val capability = host.capabilities[name]) {
null -> scope.launch { reject(cbId, "E_UNSUPPORTED", "$name is not available") }
else -> settle(cbId) { capability.call(argsJson) ?: "" }
else -> settle(cbId) { capability.call(argsJson, context) ?: "" }
}
}
}
Expand Down
3 changes: 3 additions & 0 deletions compose/src/commonMain/kotlin/app/tinyui/TinyUI.kt
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,9 @@ package app.tinyui
import wang.harlon.quickjs.QuickJs

object TinyUI {
/** This library's version, as published (`0.0.0-local` in a source build). */
val version: String get() = VERSION

/** Version of the quickjs-kmp SDK this build links against. */
val engineVersion: String get() = QuickJs.sdkVersion

Expand Down
56 changes: 56 additions & 0 deletions compose/src/commonMain/kotlin/app/tinyui/TinyUIHost.kt
Original file line number Diff line number Diff line change
@@ -0,0 +1,56 @@
package app.tinyui

import app.tinyui.schema.ComponentRegistry

/**
* What one App gives every TinyUI page, built once at startup and not changed after (docs/native-api.md §7).
* Its components and capabilities are the host contract [HostSnapshot] renders.
*/
class TinyUIHost(
val components: ComponentRegistry,
Comment thread
HarlonWang marked this conversation as resolved.
Resolved
val sink: PageSink,
val capabilities: CapabilityRegistry = CapabilityRegistry(),
) {
init {
components.freeze()
capabilities.freeze()
}
}

/** App-level J3 `host.call` capabilities by name (docs/native-api.md §7). */
class CapabilityRegistry {
private val entries = LinkedHashMap<String, HostCapability>()
private var frozen = false

val names: Set<String> get() = entries.keys

fun register(name: String, capability: HostCapability) {
require(name.isNotEmpty() && name.none(Char::isWhitespace)) { "capability name \"$name\" is empty or has whitespace" }
require(name !in FrameworkCapabilities) { "capability $name is a framework name" }
check(!frozen) { "capability $name registered after the registry went into a TinyUIHost" }
require(name !in entries) { "capability $name already registered" }
entries[name] = capability
}
Comment thread
sourcery-ai[bot] marked this conversation as resolved.
Resolved

internal operator fun get(name: String): HostCapability? = entries[name]

internal fun freeze() { frozen = true }
}

/** One host-defined J3 capability: [argsJson] is the object JS passed; returns JSON text, or null for `undefined`. Throw [HostException] for E3 codes. */
fun interface HostCapability {
suspend fun call(argsJson: String, page: PageContext): String?
}

/** The mount a capability call came from: the page's module name and the objects its [HostServices.locals] provide. */
class PageContext internal constructor(val name: String, private val locals: Map<PageLocal<*>, Any>) {
@Suppress("UNCHECKED_CAST")
operator fun <T : Any> get(local: PageLocal<T>): T? = locals[local] as T?
}

/** A typed key for an object one mount hands to capabilities, such as its screen's snackbar. */
class PageLocal<T : Any> {
infix fun provides(value: T): PageLocalValue<T> = PageLocalValue(this, value)
}

class PageLocalValue<T : Any> internal constructor(val local: PageLocal<T>, val value: T)
20 changes: 9 additions & 11 deletions compose/src/commonMain/kotlin/app/tinyui/TinyUIPage.kt
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,6 @@ import androidx.compose.runtime.remember
import androidx.compose.ui.Modifier
import androidx.compose.ui.text.font.FontFamily
import androidx.compose.ui.unit.dp
import app.tinyui.schema.ComponentRegistry

/**
* Renders one TinyUI page; the host owns navigation and passes the page's bytecode (docs/app-model.md).
Expand All @@ -24,29 +23,28 @@ import app.tinyui.schema.ComponentRegistry
fun TinyUIPage(
runtime: RuntimeBundle,
page: PageModule,
registry: ComponentRegistry,
sink: PageSink,
host: TinyUIHost,
services: HostServices = HostServices.Default,
propsJson: String = "{}",
modifier: Modifier = Modifier,
sourceMaps: SourceMaps = SourceMaps.EMPTY,
error: @Composable (PageFailure) -> Unit = { PageFailureScreen(it) },
onHost: (PageHost) -> Unit = {},
) {
val host = remember(runtime, page, registry, sink, services, propsJson, sourceMaps) {
PageHost(runtime, page, registry, sink, services, propsJson, sourceMaps = sourceMaps)
val pageHost = remember(runtime, page, host, services, propsJson, sourceMaps) {
PageHost(runtime, page, host, services, propsJson, sourceMaps = sourceMaps)
}
DisposableEffect(host) {
onHost(host)
host.start()
onDispose { host.close() }
DisposableEffect(pageHost) {
onHost(pageHost)
pageHost.start()
onDispose { pageHost.close() }
}
Box(modifier) {
val failure = host.failure
val failure = pageHost.failure
if (failure != null) {
error(failure)
} else {
for (child in host.tree.root.children) key(child.id) { host.Render(child) }
for (child in pageHost.tree.root.children) key(child.id) { pageHost.Render(child) }
}
}
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -16,18 +16,24 @@ class ComponentRegistry {
private class Entry(val schema: ComponentSchema, val component: Component)

private val entries = LinkedHashMap<String, Entry>()
private var frozen = false

init {
register(ComponentSchema(PLACEHOLDER, emptyMap(), emptyMap(), emptyMap(), children = false, layout = true)) { Placeholder(it) }
}

fun register(schema: ComponentSchema, component: Component) {
check(!frozen) { "component ${schema.type} registered after the registry went into a TinyUIHost" }
require(schema.type !in entries) { "component ${schema.type} already registered" }
entries[schema.type] = Entry(schema, component)
}

fun schema(type: String): ComponentSchema? = entries[type]?.schema

internal fun freeze() { frozen = true }

internal val schemas: List<ComponentSchema> get() = entries.values.map { it.schema }.filter { it.type != PLACEHOLDER }

@Composable
fun Render(type: String, scope: NodeScope) {
(entries[type] ?: entries.getValue(PLACEHOLDER)).component.Render(scope)
Expand Down
10 changes: 10 additions & 0 deletions compose/src/commonMain/kotlin/app/tinyui/schema/ComponentSchema.kt
Original file line number Diff line number Diff line change
Expand Up @@ -24,47 +24,57 @@ sealed interface SizeValue {
sealed class PropSpec(val kind: String, val required: Boolean, val initial: Boolean) {
/** Typed default; `null` when the schema has none. */
abstract val default: Any?
/** The default as schema/ declared it, for the host snapshot. */
internal abstract val declared: Any?
abstract fun convert(value: JsonPrimitive): Any?

class Str(default: String?, required: Boolean = false, initial: Boolean = false) : PropSpec("string", required, initial) {
override val default: String? = default
override val declared: Any? = default
override fun convert(value: JsonPrimitive): Any? = value.takeIf { it.isString }?.content
}

class Num(default: Double?, required: Boolean = false, initial: Boolean = false) : PropSpec("number", required, initial) {
override val default: Double? = default
override val declared: Any? = default
override fun convert(value: JsonPrimitive): Any? = value.takeUnless { it.isString }?.doubleOrNull
}

class Bool(default: Boolean?, required: Boolean = false, initial: Boolean = false) : PropSpec("boolean", required, initial) {
override val default: Boolean? = default
override val declared: Any? = default
override fun convert(value: JsonPrimitive): Any? = value.takeUnless { it.isString }?.booleanOrNull
}

class Dp(default: Double?, required: Boolean = false, initial: Boolean = false) : PropSpec("dp", required, initial) {
override val default: androidx.compose.ui.unit.Dp? = default?.dp
override val declared: Any? = default
override fun convert(value: JsonPrimitive): Any? = value.takeUnless { it.isString }?.doubleOrNull?.dp
}

class Sp(default: Double?, required: Boolean = false, initial: Boolean = false) : PropSpec("sp", required, initial) {
override val default: TextUnit? = default?.sp
override val declared: Any? = default
override fun convert(value: JsonPrimitive): Any? = value.takeUnless { it.isString }?.doubleOrNull?.sp
}

/** `#RRGGBB` / `#AARRGGBB`, or a theme token name (Theme.kt). */
class ColorSpec(default: String?, required: Boolean = false, initial: Boolean = false) : PropSpec("color", required, initial) {
override val default: ColorValue? = default?.let(::parseColorValue)
override val declared: Any? = default
override fun convert(value: JsonPrimitive): Any? = value.takeIf { it.isString }?.content?.let(::parseColorValue)
}

/** Stored as the enum name; the composable maps it. */
class Enum(val values: Set<String>, default: String?, required: Boolean = false, initial: Boolean = false) : PropSpec("enum", required, initial) {
override val default: String? = default
override val declared: Any? = default
override fun convert(value: JsonPrimitive): Any? = value.takeIf { it.isString }?.content?.takeIf { it in values }
}

class Size(default: String?, required: Boolean = false, initial: Boolean = false) : PropSpec("size", required, initial) {
override val default: SizeValue? = default?.let(::parseSize)
override val declared: Any? = default
override fun convert(value: JsonPrimitive): Any? =
if (value.isString) parseSize(value.content) else value.doubleOrNull?.let { SizeValue.Fixed(it.dp) }

Expand Down
Loading
Loading