diff --git a/compose/api/compose.klib.api b/compose/api/compose.klib.api index 0955343..91a23eb 100644 --- a/compose/api/compose.klib.api +++ b/compose/api/compose.klib.api @@ -23,29 +23,28 @@ abstract fun interface app.tinyui.schema/Component { // app.tinyui.schema/Compon abstract fun Render(app.tinyui.schema/NodeScope, androidx.compose.runtime/Composer?, kotlin/Int) // app.tinyui.schema/Component.Render|Render(app.tinyui.schema.NodeScope;androidx.compose.runtime.Composer?;kotlin.Int){}[0] } -abstract fun interface app.tinyui/BundleFiles { // app.tinyui/BundleFiles|null[0] - abstract suspend fun read(kotlin/String): kotlin/ByteArray? // app.tinyui/BundleFiles.read|read(kotlin.String){}[0] +abstract fun interface app.tinyui/AnalyticsSink { // app.tinyui/AnalyticsSink|null[0] + abstract fun track(kotlin/String, kotlin.collections/Map, app.tinyui/PageContext) // app.tinyui/AnalyticsSink.track|track(kotlin.String;kotlin.collections.Map;app.tinyui.PageContext){}[0] } -abstract fun interface app.tinyui/Config { // app.tinyui/Config|null[0] - abstract fun get(kotlin/String): kotlin/String? // app.tinyui/Config.get|get(kotlin.String){}[0] - - final object Companion { // app.tinyui/Config.Companion|null[0] - final val None // app.tinyui/Config.Companion.None|{}None[0] - final fun (): app.tinyui/Config // app.tinyui/Config.Companion.None.|(){}[0] - } +abstract fun interface app.tinyui/BundleFiles { // app.tinyui/BundleFiles|null[0] + abstract suspend fun read(kotlin/String): kotlin/ByteArray? // app.tinyui/BundleFiles.read|read(kotlin.String){}[0] } abstract fun interface app.tinyui/HostCapability { // app.tinyui/HostCapability|null[0] abstract suspend fun call(kotlin/String, app.tinyui/PageContext): kotlin/String? // app.tinyui/HostCapability.call|call(kotlin.String;app.tinyui.PageContext){}[0] } -abstract fun interface app.tinyui/I18n { // app.tinyui/I18n|null[0] - abstract fun translate(kotlin/String, kotlin/String): kotlin/String? // app.tinyui/I18n.translate|translate(kotlin.String;kotlin.String){}[0] +abstract fun interface app.tinyui/HttpChannel { // app.tinyui/HttpChannel|null[0] + abstract suspend fun request(app.tinyui/HttpRequest): app.tinyui/HttpResponse // app.tinyui/HttpChannel.request|request(app.tinyui.HttpRequest){}[0] +} + +abstract fun interface app.tinyui/LinkOpener { // app.tinyui/LinkOpener|null[0] + abstract suspend fun open(kotlin/String, app.tinyui/PageContext) // app.tinyui/LinkOpener.open|open(kotlin.String;app.tinyui.PageContext){}[0] - final object Companion { // app.tinyui/I18n.Companion|null[0] - final val None // app.tinyui/I18n.Companion.None|{}None[0] - final fun (): app.tinyui/I18n // app.tinyui/I18n.Companion.None.|(){}[0] + final object Companion { // app.tinyui/LinkOpener.Companion|null[0] + final val System // app.tinyui/LinkOpener.Companion.System|{}System[0] + final fun (): app.tinyui/LinkOpener // app.tinyui/LinkOpener.Companion.System.|(){}[0] } } @@ -67,14 +66,6 @@ abstract interface app.tinyui.schema/NodeScope { // app.tinyui.schema/NodeScope| } } -abstract interface app.tinyui/HttpClient { // app.tinyui/HttpClient|null[0] - abstract suspend fun request(app.tinyui/HttpRequest): app.tinyui/HttpResponse // app.tinyui/HttpClient.request|request(app.tinyui.HttpRequest){}[0] - - final object None : app.tinyui/HttpClient { // app.tinyui/HttpClient.None|null[0] - final suspend fun request(app.tinyui/HttpRequest): app.tinyui/HttpResponse // app.tinyui/HttpClient.None.request|request(app.tinyui.HttpRequest){}[0] - } -} - abstract interface app.tinyui/Navigator { // app.tinyui/Navigator|null[0] abstract fun pop(kotlin/String?) // app.tinyui/Navigator.pop|pop(kotlin.String?){}[0] abstract fun push(kotlin/String, kotlin/String) // app.tinyui/Navigator.push|push(kotlin.String;kotlin.String){}[0] @@ -91,9 +82,9 @@ abstract interface app.tinyui/PageSink { // app.tinyui/PageSink|null[0] } abstract interface app.tinyui/Store { // app.tinyui/Store|null[0] - abstract fun get(kotlin/String): kotlin/String? // app.tinyui/Store.get|get(kotlin.String){}[0] + abstract fun getJson(kotlin/String): kotlin/String? // app.tinyui/Store.getJson|getJson(kotlin.String){}[0] abstract fun observe(kotlin/String, kotlin/Function1): kotlin/AutoCloseable // app.tinyui/Store.observe|observe(kotlin.String;kotlin.Function1){}[0] - abstract fun set(kotlin/String, kotlin/String) // app.tinyui/Store.set|set(kotlin.String;kotlin.String){}[0] + abstract fun setJson(kotlin/String, kotlin/String) // app.tinyui/Store.setJson|setJson(kotlin.String;kotlin.String){}[0] } sealed interface app.tinyui.schema/ColorValue { // app.tinyui.schema/ColorValue|null[0] @@ -247,8 +238,13 @@ final class app.tinyui.schema/ComponentSchema { // app.tinyui.schema/ComponentSc final fun prop(kotlin/String): app.tinyui.schema/PropSpec? // app.tinyui.schema/ComponentSchema.prop|prop(kotlin.String){}[0] } +final class app.tinyui.schema/IconValue { // app.tinyui.schema/IconValue|null[0] + final val vector // app.tinyui.schema/IconValue.vector|{}vector[0] + final fun (): androidx.compose.ui.graphics.vector/ImageVector // app.tinyui.schema/IconValue.vector.|(){}[0] +} + final class app.tinyui/BuildManifest { // app.tinyui/BuildManifest|null[0] - constructor (kotlin.collections/List, kotlin.collections/Map, kotlin.collections/Map, kotlin/String, kotlin/String, kotlin/String, kotlin/String, kotlin/String, kotlin.collections/Map, kotlin/String = ..., kotlin.collections/Map = ..., kotlin/String? = ...) // app.tinyui/BuildManifest.|(kotlin.collections.List;kotlin.collections.Map;kotlin.collections.Map;kotlin.String;kotlin.String;kotlin.String;kotlin.String;kotlin.String;kotlin.collections.Map;kotlin.String;kotlin.collections.Map;kotlin.String?){}[0] + constructor (kotlin.collections/List, kotlin.collections/Map, kotlin.collections/Map, kotlin/String, kotlin/String, kotlin/String, kotlin/String, kotlin/String, kotlin.collections/Map, kotlin/String = ..., kotlin.collections/Map = ..., kotlin/String? = ..., app.tinyui/I18nFiles? = ...) // app.tinyui/BuildManifest.|(kotlin.collections.List;kotlin.collections.Map;kotlin.collections.Map;kotlin.String;kotlin.String;kotlin.String;kotlin.String;kotlin.String;kotlin.collections.Map;kotlin.String;kotlin.collections.Map;kotlin.String?;app.tinyui.I18nFiles?){}[0] final val buildIds // app.tinyui/BuildManifest.buildIds|{}buildIds[0] final fun (): kotlin.collections/Map // app.tinyui/BuildManifest.buildIds.|(){}[0] @@ -262,10 +258,14 @@ final class app.tinyui/BuildManifest { // app.tinyui/BuildManifest|null[0] final fun (): kotlin.collections/Map // app.tinyui/BuildManifest.hashes.|(){}[0] final val hostVersion // app.tinyui/BuildManifest.hostVersion|{}hostVersion[0] final fun (): kotlin/String? // app.tinyui/BuildManifest.hostVersion.|(){}[0] + final val i18n // app.tinyui/BuildManifest.i18n|{}i18n[0] + final fun (): app.tinyui/I18nFiles? // app.tinyui/BuildManifest.i18n.|(){}[0] final val name // app.tinyui/BuildManifest.name|{}name[0] final fun (): kotlin/String // app.tinyui/BuildManifest.name.|(){}[0] final val pages // app.tinyui/BuildManifest.pages|{}pages[0] final fun (): kotlin.collections/List // app.tinyui/BuildManifest.pages.|(){}[0] + final val payload // app.tinyui/BuildManifest.payload|{}payload[0] + final fun (): kotlin.collections/List> // app.tinyui/BuildManifest.payload.|(){}[0] final val publicKey // app.tinyui/BuildManifest.publicKey|{}publicKey[0] final fun (): kotlin/String // app.tinyui/BuildManifest.publicKey.|(){}[0] final val requires // app.tinyui/BuildManifest.requires|{}requires[0] @@ -321,37 +321,11 @@ final class app.tinyui/HostException : kotlin/Exception { // app.tinyui/HostExce final fun (): kotlin/String // app.tinyui/HostException.code.|(){}[0] } -final class app.tinyui/HostServices { // app.tinyui/HostServices|null[0] - constructor (app.tinyui/Navigator = ..., app.tinyui/Store = ..., app.tinyui/HttpClient = ..., app.tinyui/I18n = ..., app.tinyui/Config = ..., app.tinyui/EventBus = ..., kotlin.collections/Map = ..., kotlin.collections/List> = ...) // app.tinyui/HostServices.|(app.tinyui.Navigator;app.tinyui.Store;app.tinyui.HttpClient;app.tinyui.I18n;app.tinyui.Config;app.tinyui.EventBus;kotlin.collections.Map;kotlin.collections.List>){}[0] - - final val config // app.tinyui/HostServices.config|{}config[0] - final fun (): app.tinyui/Config // app.tinyui/HostServices.config.|(){}[0] - final val deviceInfo // app.tinyui/HostServices.deviceInfo|{}deviceInfo[0] - final fun (): kotlin.collections/Map // app.tinyui/HostServices.deviceInfo.|(){}[0] - final val events // app.tinyui/HostServices.events|{}events[0] - final fun (): app.tinyui/EventBus // app.tinyui/HostServices.events.|(){}[0] - final val http // app.tinyui/HostServices.http|{}http[0] - final fun (): app.tinyui/HttpClient // app.tinyui/HostServices.http.|(){}[0] - final val i18n // app.tinyui/HostServices.i18n|{}i18n[0] - final fun (): app.tinyui/I18n // app.tinyui/HostServices.i18n.|(){}[0] - final val locals // app.tinyui/HostServices.locals|{}locals[0] - final fun (): kotlin.collections/List> // app.tinyui/HostServices.locals.|(){}[0] - final val navigator // app.tinyui/HostServices.navigator|{}navigator[0] - final fun (): app.tinyui/Navigator // app.tinyui/HostServices.navigator.|(){}[0] - final val store // app.tinyui/HostServices.store|{}store[0] - final fun (): app.tinyui/Store // app.tinyui/HostServices.store.|(){}[0] - - final object Companion { // app.tinyui/HostServices.Companion|null[0] - final val Default // app.tinyui/HostServices.Companion.Default|{}Default[0] - final fun (): app.tinyui/HostServices // app.tinyui/HostServices.Companion.Default.|(){}[0] - } -} - final class app.tinyui/HttpRequest { // app.tinyui/HttpRequest|null[0] constructor (kotlin/String, kotlin/String, kotlin.collections/Map, kotlin/String?, kotlin/Long?) // app.tinyui/HttpRequest.|(kotlin.String;kotlin.String;kotlin.collections.Map;kotlin.String?;kotlin.Long?){}[0] - final val bodyJson // app.tinyui/HttpRequest.bodyJson|{}bodyJson[0] - final fun (): kotlin/String? // app.tinyui/HttpRequest.bodyJson.|(){}[0] + final val body // app.tinyui/HttpRequest.body|{}body[0] + final fun (): kotlin/String? // app.tinyui/HttpRequest.body.|(){}[0] final val headers // app.tinyui/HttpRequest.headers|{}headers[0] final fun (): kotlin.collections/Map // app.tinyui/HttpRequest.headers.|(){}[0] final val method // app.tinyui/HttpRequest.method|{}method[0] @@ -363,20 +337,29 @@ final class app.tinyui/HttpRequest { // app.tinyui/HttpRequest|null[0] } final class app.tinyui/HttpResponse { // app.tinyui/HttpResponse|null[0] - constructor (kotlin/Int, kotlin/String) // app.tinyui/HttpResponse.|(kotlin.Int;kotlin.String){}[0] + constructor (kotlin/Int, kotlin.collections/Map, kotlin/String) // app.tinyui/HttpResponse.|(kotlin.Int;kotlin.collections.Map;kotlin.String){}[0] - final val bodyJson // app.tinyui/HttpResponse.bodyJson|{}bodyJson[0] - final fun (): kotlin/String // app.tinyui/HttpResponse.bodyJson.|(){}[0] + final val body // app.tinyui/HttpResponse.body|{}body[0] + final fun (): kotlin/String // app.tinyui/HttpResponse.body.|(){}[0] + final val headers // app.tinyui/HttpResponse.headers|{}headers[0] + final fun (): kotlin.collections/Map // app.tinyui/HttpResponse.headers.|(){}[0] final val status // app.tinyui/HttpResponse.status|{}status[0] final fun (): kotlin/Int // app.tinyui/HttpResponse.status.|(){}[0] } -final class app.tinyui/InMemoryStore : app.tinyui/Store { // app.tinyui/InMemoryStore|null[0] - constructor () // app.tinyui/InMemoryStore.|(){}[0] +final class app.tinyui/I18nFiles { // app.tinyui/I18nFiles|null[0] + constructor (kotlin/String, kotlin.collections/Map) // app.tinyui/I18nFiles.|(kotlin.String;kotlin.collections.Map){}[0] + + final val defaultLocale // app.tinyui/I18nFiles.defaultLocale|{}defaultLocale[0] + final fun (): kotlin/String // app.tinyui/I18nFiles.defaultLocale.|(){}[0] + final val files // app.tinyui/I18nFiles.files|{}files[0] + final fun (): kotlin.collections/Map // app.tinyui/I18nFiles.files.|(){}[0] +} + +final class app.tinyui/KtorChannel : app.tinyui/HttpChannel { // app.tinyui/KtorChannel|null[0] + constructor (io.ktor.client/HttpClient) // app.tinyui/KtorChannel.|(io.ktor.client.HttpClient){}[0] - final fun get(kotlin/String): kotlin/String? // app.tinyui/InMemoryStore.get|get(kotlin.String){}[0] - final fun observe(kotlin/String, kotlin/Function1): kotlin/AutoCloseable // app.tinyui/InMemoryStore.observe|observe(kotlin.String;kotlin.Function1){}[0] - final fun set(kotlin/String, kotlin/String) // app.tinyui/InMemoryStore.set|set(kotlin.String;kotlin.String){}[0] + final suspend fun request(app.tinyui/HttpRequest): app.tinyui/HttpResponse // app.tinyui/KtorChannel.request|request(app.tinyui.HttpRequest){}[0] } final class app.tinyui/LoadedPage { // app.tinyui/LoadedPage|null[0] @@ -390,6 +373,22 @@ final class app.tinyui/LoadedPage { // app.tinyui/LoadedPage|null[0] final fun (): app.tinyui/SourceMaps // app.tinyui/LoadedPage.sourceMaps.|(){}[0] } +final class app.tinyui/PackageI18n { // app.tinyui/PackageI18n|null[0] + constructor (kotlin/String, kotlin.collections/Map>) // app.tinyui/PackageI18n.|(kotlin.String;kotlin.collections.Map>){}[0] + + final val defaultLocale // app.tinyui/PackageI18n.defaultLocale|{}defaultLocale[0] + final fun (): kotlin/String // app.tinyui/PackageI18n.defaultLocale.|(){}[0] + + final fun lookup(kotlin/String, kotlin/String): kotlin/String? // app.tinyui/PackageI18n.lookup|lookup(kotlin.String;kotlin.String){}[0] + + final object Companion { // app.tinyui/PackageI18n.Companion|null[0] + final val EMPTY // app.tinyui/PackageI18n.Companion.EMPTY|{}EMPTY[0] + final fun (): app.tinyui/PackageI18n // app.tinyui/PackageI18n.Companion.EMPTY.|(){}[0] + + final fun parse(kotlin/String, kotlin.collections/Map): app.tinyui/PackageI18n // app.tinyui/PackageI18n.Companion.parse|parse(kotlin.String;kotlin.collections.Map){}[0] + } +} + final class app.tinyui/PageContext { // app.tinyui/PageContext|null[0] final val name // app.tinyui/PageContext.name|{}name[0] final fun (): kotlin/String // app.tinyui/PageContext.name.|(){}[0] @@ -432,7 +431,7 @@ final class app.tinyui/PageFailure { // app.tinyui/PageFailure|null[0] } final class app.tinyui/PageHost { // app.tinyui/PageHost|null[0] - constructor (app.tinyui/PageModule, app.tinyui/TinyUIHost, app.tinyui/HostServices = ..., kotlin/String = ..., kotlin/Long = ..., app.tinyui/SourceMaps = ...) // app.tinyui/PageHost.|(app.tinyui.PageModule;app.tinyui.TinyUIHost;app.tinyui.HostServices;kotlin.String;kotlin.Long;app.tinyui.SourceMaps){}[0] + constructor (app.tinyui/PageModule, app.tinyui/TinyUIHost, kotlin/String = ..., kotlin.collections/List> = ..., kotlin/Long = ..., app.tinyui/SourceMaps = ...) // app.tinyui/PageHost.|(app.tinyui.PageModule;app.tinyui.TinyUIHost;kotlin.String;kotlin.collections.List>;kotlin.Long;app.tinyui.SourceMaps){}[0] final val host // app.tinyui/PageHost.host|{}host[0] final fun (): app.tinyui/TinyUIHost // app.tinyui/PageHost.host.|(){}[0] @@ -457,25 +456,61 @@ final class app.tinyui/PageHost { // app.tinyui/PageHost|null[0] } final class app.tinyui/PageModule { // app.tinyui/PageModule|null[0] - constructor (kotlin/String, kotlin/ByteArray, kotlin/String = ...) // app.tinyui/PageModule.|(kotlin.String;kotlin.ByteArray;kotlin.String){}[0] + constructor (kotlin/String, kotlin/ByteArray, kotlin/String = ..., app.tinyui/PackageI18n = ...) // app.tinyui/PageModule.|(kotlin.String;kotlin.ByteArray;kotlin.String;app.tinyui.PackageI18n){}[0] final val buildId // app.tinyui/PageModule.buildId|{}buildId[0] final fun (): kotlin/String // app.tinyui/PageModule.buildId.|(){}[0] final val bytecode // app.tinyui/PageModule.bytecode|{}bytecode[0] final fun (): kotlin/ByteArray // app.tinyui/PageModule.bytecode.|(){}[0] + final val i18n // app.tinyui/PageModule.i18n|{}i18n[0] + final fun (): app.tinyui/PackageI18n // app.tinyui/PageModule.i18n.|(){}[0] final val name // app.tinyui/PageModule.name|{}name[0] final fun (): kotlin/String // app.tinyui/PageModule.name.|(){}[0] + final val pkg // app.tinyui/PageModule.pkg|{}pkg[0] + final fun (): kotlin/String // app.tinyui/PageModule.pkg.|(){}[0] } final class app.tinyui/PageRequires { // app.tinyui/PageRequires|null[0] - constructor (kotlin.collections/List, kotlin.collections/List) // app.tinyui/PageRequires.|(kotlin.collections.List;kotlin.collections.List){}[0] + constructor (kotlin.collections/List, kotlin.collections/List, kotlin.collections/List = ...) // app.tinyui/PageRequires.|(kotlin.collections.List;kotlin.collections.List;kotlin.collections.List){}[0] final val capabilities // app.tinyui/PageRequires.capabilities|{}capabilities[0] final fun (): kotlin.collections/List // app.tinyui/PageRequires.capabilities.|(){}[0] + final val channels // app.tinyui/PageRequires.channels|{}channels[0] + final fun (): kotlin.collections/List // app.tinyui/PageRequires.channels.|(){}[0] final val components // app.tinyui/PageRequires.components|{}components[0] final fun (): kotlin.collections/List // app.tinyui/PageRequires.components.|(){}[0] } +final class app.tinyui/Session { // app.tinyui/Session|null[0] + constructor (kotlin/Boolean, kotlin/String?) // app.tinyui/Session.|(kotlin.Boolean;kotlin.String?){}[0] + + final val loggedIn // app.tinyui/Session.loggedIn|{}loggedIn[0] + final fun (): kotlin/Boolean // app.tinyui/Session.loggedIn.|(){}[0] + final val userId // app.tinyui/Session.userId|{}userId[0] + final fun (): kotlin/String? // app.tinyui/Session.userId.|(){}[0] + + final fun component1(): kotlin/Boolean // app.tinyui/Session.component1|component1(){}[0] + final fun component2(): kotlin/String? // app.tinyui/Session.component2|component2(){}[0] + final fun copy(kotlin/Boolean = ..., kotlin/String? = ...): app.tinyui/Session // app.tinyui/Session.copy|copy(kotlin.Boolean;kotlin.String?){}[0] + final fun equals(kotlin/Any?): kotlin/Boolean // app.tinyui/Session.equals|equals(kotlin.Any?){}[0] + final fun hashCode(): kotlin/Int // app.tinyui/Session.hashCode|hashCode(){}[0] + final fun toString(): kotlin/String // app.tinyui/Session.toString|toString(){}[0] + + final object Companion { // app.tinyui/Session.Companion|null[0] + final val LoggedOut // app.tinyui/Session.Companion.LoggedOut|{}LoggedOut[0] + final fun (): app.tinyui/Session // app.tinyui/Session.Companion.LoggedOut.|(){}[0] + } +} + +final class app.tinyui/SessionSource { // app.tinyui/SessionSource|null[0] + constructor (kotlinx.coroutines.flow/StateFlow, kotlin.coroutines/SuspendFunction1) // app.tinyui/SessionSource.|(kotlinx.coroutines.flow.StateFlow;kotlin.coroutines.SuspendFunction1){}[0] + + final val signIn // app.tinyui/SessionSource.signIn|{}signIn[0] + final fun (): kotlin.coroutines/SuspendFunction1 // app.tinyui/SessionSource.signIn.|(){}[0] + final val state // app.tinyui/SessionSource.state|{}state[0] + final fun (): kotlinx.coroutines.flow/StateFlow // app.tinyui/SessionSource.state.|(){}[0] +} + final class app.tinyui/SourceMaps { // app.tinyui/SourceMaps|null[0] constructor (kotlin.collections/Map) // app.tinyui/SourceMaps.|(kotlin.collections.Map){}[0] @@ -517,14 +552,34 @@ final class app.tinyui/StoreValue { // app.tinyui/StoreValue|null[0] } final class app.tinyui/TinyUIHost { // app.tinyui/TinyUIHost|null[0] - constructor (app.tinyui.schema/ComponentRegistry, app.tinyui/PageSink, app.tinyui/CapabilityRegistry = ...) // app.tinyui/TinyUIHost.|(app.tinyui.schema.ComponentRegistry;app.tinyui.PageSink;app.tinyui.CapabilityRegistry){}[0] + constructor (app.tinyui.schema/ComponentRegistry, app.tinyui/PageSink, app.tinyui/CapabilityRegistry = ..., kotlin.collections/Map = ..., kotlinx.coroutines.flow/StateFlow? = ..., app.tinyui/SessionSource? = ..., app.tinyui/AnalyticsSink? = ..., app.tinyui/LinkOpener? = ..., app.tinyui/Navigator = ..., okio/Path? = ...) // app.tinyui/TinyUIHost.|(app.tinyui.schema.ComponentRegistry;app.tinyui.PageSink;app.tinyui.CapabilityRegistry;kotlin.collections.Map;kotlinx.coroutines.flow.StateFlow?;app.tinyui.SessionSource?;app.tinyui.AnalyticsSink?;app.tinyui.LinkOpener?;app.tinyui.Navigator;okio.Path?){}[0] + final val analytics // app.tinyui/TinyUIHost.analytics|{}analytics[0] + final fun (): app.tinyui/AnalyticsSink? // app.tinyui/TinyUIHost.analytics.|(){}[0] final val capabilities // app.tinyui/TinyUIHost.capabilities|{}capabilities[0] final fun (): app.tinyui/CapabilityRegistry // app.tinyui/TinyUIHost.capabilities.|(){}[0] + final val capabilityNames // app.tinyui/TinyUIHost.capabilityNames|{}capabilityNames[0] + final fun (): kotlin.collections/Set // app.tinyui/TinyUIHost.capabilityNames.|(){}[0] + final val channels // app.tinyui/TinyUIHost.channels|{}channels[0] + final fun (): kotlin.collections/Map // app.tinyui/TinyUIHost.channels.|(){}[0] final val components // app.tinyui/TinyUIHost.components|{}components[0] final fun (): app.tinyui.schema/ComponentRegistry // app.tinyui/TinyUIHost.components.|(){}[0] + final val dataDir // app.tinyui/TinyUIHost.dataDir|{}dataDir[0] + final fun (): okio/Path? // app.tinyui/TinyUIHost.dataDir.|(){}[0] + final val events // app.tinyui/TinyUIHost.events|{}events[0] + final fun (): app.tinyui/EventBus // app.tinyui/TinyUIHost.events.|(){}[0] + final val links // app.tinyui/TinyUIHost.links|{}links[0] + final fun (): app.tinyui/LinkOpener? // app.tinyui/TinyUIHost.links.|(){}[0] + final val locale // app.tinyui/TinyUIHost.locale|{}locale[0] + final fun (): kotlinx.coroutines.flow/StateFlow? // app.tinyui/TinyUIHost.locale.|(){}[0] + final val navigator // app.tinyui/TinyUIHost.navigator|{}navigator[0] + final fun (): app.tinyui/Navigator // app.tinyui/TinyUIHost.navigator.|(){}[0] + final val session // app.tinyui/TinyUIHost.session|{}session[0] + final fun (): app.tinyui/SessionSource? // app.tinyui/TinyUIHost.session.|(){}[0] final val sink // app.tinyui/TinyUIHost.sink|{}sink[0] final fun (): app.tinyui/PageSink // app.tinyui/TinyUIHost.sink.|(){}[0] + final val store // app.tinyui/TinyUIHost.store|{}store[0] + final fun (): app.tinyui/Store // app.tinyui/TinyUIHost.store.|(){}[0] } sealed class app.tinyui.schema/PropSpec { // app.tinyui.schema/PropSpec|null[0] @@ -577,6 +632,15 @@ sealed class app.tinyui.schema/PropSpec { // app.tinyui.schema/PropSpec|null[0] final fun convert(kotlinx.serialization.json/JsonPrimitive): kotlin/Any? // app.tinyui.schema/PropSpec.Enum.convert|convert(kotlinx.serialization.json.JsonPrimitive){}[0] } + final class IconSpec : app.tinyui.schema/PropSpec { // app.tinyui.schema/PropSpec.IconSpec|null[0] + constructor (kotlin/Boolean = ..., kotlin/Boolean = ...) // app.tinyui.schema/PropSpec.IconSpec.|(kotlin.Boolean;kotlin.Boolean){}[0] + + final val default // app.tinyui.schema/PropSpec.IconSpec.default|{}default[0] + final fun (): app.tinyui.schema/IconValue? // app.tinyui.schema/PropSpec.IconSpec.default.|(){}[0] + + final fun convert(kotlinx.serialization.json/JsonPrimitive): kotlin/Any? // app.tinyui.schema/PropSpec.IconSpec.convert|convert(kotlinx.serialization.json.JsonPrimitive){}[0] + } + final class Num : app.tinyui.schema/PropSpec { // app.tinyui.schema/PropSpec.Num|null[0] constructor (kotlin/Double?, kotlin/Boolean = ..., kotlin/Boolean = ...) // app.tinyui.schema/PropSpec.Num.|(kotlin.Double?;kotlin.Boolean;kotlin.Boolean){}[0] @@ -621,8 +685,12 @@ final object app.tinyui.components.generated/BuiltinSchemas { // app.tinyui.comp final fun (): app.tinyui.schema/ComponentSchema // app.tinyui.components.generated/BuiltinSchemas.Button.|(){}[0] final val Column // app.tinyui.components.generated/BuiltinSchemas.Column|{}Column[0] final fun (): app.tinyui.schema/ComponentSchema // app.tinyui.components.generated/BuiltinSchemas.Column.|(){}[0] + final val Icon // app.tinyui.components.generated/BuiltinSchemas.Icon|{}Icon[0] + final fun (): app.tinyui.schema/ComponentSchema // app.tinyui.components.generated/BuiltinSchemas.Icon.|(){}[0] final val LazyColumn // app.tinyui.components.generated/BuiltinSchemas.LazyColumn|{}LazyColumn[0] final fun (): app.tinyui.schema/ComponentSchema // app.tinyui.components.generated/BuiltinSchemas.LazyColumn.|(){}[0] + final val Loading // app.tinyui.components.generated/BuiltinSchemas.Loading|{}Loading[0] + final fun (): app.tinyui.schema/ComponentSchema // app.tinyui.components.generated/BuiltinSchemas.Loading.|(){}[0] final val RadioButton // app.tinyui.components.generated/BuiltinSchemas.RadioButton|{}RadioButton[0] final fun (): app.tinyui.schema/ComponentSchema // app.tinyui.components.generated/BuiltinSchemas.RadioButton.|(){}[0] final val Row // app.tinyui.components.generated/BuiltinSchemas.Row|{}Row[0] @@ -686,12 +754,14 @@ final val app.tinyui.schema/app_tinyui_schema_ColorValue_Literal$stableprop // a final val app.tinyui.schema/app_tinyui_schema_ColorValue_Token$stableprop // app.tinyui.schema/app_tinyui_schema_ColorValue_Token$stableprop|#static{}app_tinyui_schema_ColorValue_Token$stableprop[0] final val app.tinyui.schema/app_tinyui_schema_ComponentRegistry$stableprop // app.tinyui.schema/app_tinyui_schema_ComponentRegistry$stableprop|#static{}app_tinyui_schema_ComponentRegistry$stableprop[0] final val app.tinyui.schema/app_tinyui_schema_ComponentSchema$stableprop // app.tinyui.schema/app_tinyui_schema_ComponentSchema$stableprop|#static{}app_tinyui_schema_ComponentSchema$stableprop[0] +final val app.tinyui.schema/app_tinyui_schema_IconValue$stableprop // app.tinyui.schema/app_tinyui_schema_IconValue$stableprop|#static{}app_tinyui_schema_IconValue$stableprop[0] final val app.tinyui.schema/app_tinyui_schema_LayoutProps$stableprop // app.tinyui.schema/app_tinyui_schema_LayoutProps$stableprop|#static{}app_tinyui_schema_LayoutProps$stableprop[0] final val app.tinyui.schema/app_tinyui_schema_PropSpec$stableprop // app.tinyui.schema/app_tinyui_schema_PropSpec$stableprop|#static{}app_tinyui_schema_PropSpec$stableprop[0] final val app.tinyui.schema/app_tinyui_schema_PropSpec_Bool$stableprop // app.tinyui.schema/app_tinyui_schema_PropSpec_Bool$stableprop|#static{}app_tinyui_schema_PropSpec_Bool$stableprop[0] final val app.tinyui.schema/app_tinyui_schema_PropSpec_ColorSpec$stableprop // app.tinyui.schema/app_tinyui_schema_PropSpec_ColorSpec$stableprop|#static{}app_tinyui_schema_PropSpec_ColorSpec$stableprop[0] final val app.tinyui.schema/app_tinyui_schema_PropSpec_Dp$stableprop // app.tinyui.schema/app_tinyui_schema_PropSpec_Dp$stableprop|#static{}app_tinyui_schema_PropSpec_Dp$stableprop[0] final val app.tinyui.schema/app_tinyui_schema_PropSpec_Enum$stableprop // app.tinyui.schema/app_tinyui_schema_PropSpec_Enum$stableprop|#static{}app_tinyui_schema_PropSpec_Enum$stableprop[0] +final val app.tinyui.schema/app_tinyui_schema_PropSpec_IconSpec$stableprop // app.tinyui.schema/app_tinyui_schema_PropSpec_IconSpec$stableprop|#static{}app_tinyui_schema_PropSpec_IconSpec$stableprop[0] final val app.tinyui.schema/app_tinyui_schema_PropSpec_Num$stableprop // app.tinyui.schema/app_tinyui_schema_PropSpec_Num$stableprop|#static{}app_tinyui_schema_PropSpec_Num$stableprop[0] final val app.tinyui.schema/app_tinyui_schema_PropSpec_Size$stableprop // app.tinyui.schema/app_tinyui_schema_PropSpec_Size$stableprop|#static{}app_tinyui_schema_PropSpec_Size$stableprop[0] final val app.tinyui.schema/app_tinyui_schema_PropSpec_Sp$stableprop // app.tinyui.schema/app_tinyui_schema_PropSpec_Sp$stableprop|#static{}app_tinyui_schema_PropSpec_Sp$stableprop[0] @@ -705,15 +775,15 @@ final val app.tinyui/app_tinyui_Bundle$stableprop // app.tinyui/app_tinyui_Bundl final val app.tinyui/app_tinyui_CapabilityRegistry$stableprop // app.tinyui/app_tinyui_CapabilityRegistry$stableprop|#static{}app_tinyui_CapabilityRegistry$stableprop[0] final val app.tinyui/app_tinyui_EventBus$stableprop // app.tinyui/app_tinyui_EventBus$stableprop|#static{}app_tinyui_EventBus$stableprop[0] final val app.tinyui/app_tinyui_HostException$stableprop // app.tinyui/app_tinyui_HostException$stableprop|#static{}app_tinyui_HostException$stableprop[0] -final val app.tinyui/app_tinyui_HostServices$stableprop // app.tinyui/app_tinyui_HostServices$stableprop|#static{}app_tinyui_HostServices$stableprop[0] final val app.tinyui/app_tinyui_HostSnapshot$stableprop // app.tinyui/app_tinyui_HostSnapshot$stableprop|#static{}app_tinyui_HostSnapshot$stableprop[0] -final val app.tinyui/app_tinyui_HttpClient_None$stableprop // app.tinyui/app_tinyui_HttpClient_None$stableprop|#static{}app_tinyui_HttpClient_None$stableprop[0] final val app.tinyui/app_tinyui_HttpRequest$stableprop // app.tinyui/app_tinyui_HttpRequest$stableprop|#static{}app_tinyui_HttpRequest$stableprop[0] final val app.tinyui/app_tinyui_HttpResponse$stableprop // app.tinyui/app_tinyui_HttpResponse$stableprop|#static{}app_tinyui_HttpResponse$stableprop[0] -final val app.tinyui/app_tinyui_InMemoryStore$stableprop // app.tinyui/app_tinyui_InMemoryStore$stableprop|#static{}app_tinyui_InMemoryStore$stableprop[0] +final val app.tinyui/app_tinyui_I18nFiles$stableprop // app.tinyui/app_tinyui_I18nFiles$stableprop|#static{}app_tinyui_I18nFiles$stableprop[0] +final val app.tinyui/app_tinyui_KtorChannel$stableprop // app.tinyui/app_tinyui_KtorChannel$stableprop|#static{}app_tinyui_KtorChannel$stableprop[0] final val app.tinyui/app_tinyui_LoadedPage$stableprop // app.tinyui/app_tinyui_LoadedPage$stableprop|#static{}app_tinyui_LoadedPage$stableprop[0] final val app.tinyui/app_tinyui_Navigator_None$stableprop // app.tinyui/app_tinyui_Navigator_None$stableprop|#static{}app_tinyui_Navigator_None$stableprop[0] final val app.tinyui/app_tinyui_PackageCheck$stableprop // app.tinyui/app_tinyui_PackageCheck$stableprop|#static{}app_tinyui_PackageCheck$stableprop[0] +final val app.tinyui/app_tinyui_PackageI18n$stableprop // app.tinyui/app_tinyui_PackageI18n$stableprop|#static{}app_tinyui_PackageI18n$stableprop[0] final val app.tinyui/app_tinyui_PageContext$stableprop // app.tinyui/app_tinyui_PageContext$stableprop|#static{}app_tinyui_PageContext$stableprop[0] final val app.tinyui/app_tinyui_PageError$stableprop // app.tinyui/app_tinyui_PageError$stableprop|#static{}app_tinyui_PageError$stableprop[0] final val app.tinyui/app_tinyui_PageFailure$stableprop // app.tinyui/app_tinyui_PageFailure$stableprop|#static{}app_tinyui_PageFailure$stableprop[0] @@ -722,6 +792,8 @@ final val app.tinyui/app_tinyui_PageLocal$stableprop // app.tinyui/app_tinyui_Pa final val app.tinyui/app_tinyui_PageLocalValue$stableprop // app.tinyui/app_tinyui_PageLocalValue$stableprop|#static{}app_tinyui_PageLocalValue$stableprop[0] final val app.tinyui/app_tinyui_PageModule$stableprop // app.tinyui/app_tinyui_PageModule$stableprop|#static{}app_tinyui_PageModule$stableprop[0] final val app.tinyui/app_tinyui_PageRequires$stableprop // app.tinyui/app_tinyui_PageRequires$stableprop|#static{}app_tinyui_PageRequires$stableprop[0] +final val app.tinyui/app_tinyui_Session$stableprop // app.tinyui/app_tinyui_Session$stableprop|#static{}app_tinyui_Session$stableprop[0] +final val app.tinyui/app_tinyui_SessionSource$stableprop // app.tinyui/app_tinyui_SessionSource$stableprop|#static{}app_tinyui_SessionSource$stableprop[0] final val app.tinyui/app_tinyui_SourceMap_Position$stableprop // app.tinyui/app_tinyui_SourceMap_Position$stableprop|#static{}app_tinyui_SourceMap_Position$stableprop[0] final val app.tinyui/app_tinyui_SourceMaps$stableprop // app.tinyui/app_tinyui_SourceMaps$stableprop|#static{}app_tinyui_SourceMaps$stableprop[0] final val app.tinyui/app_tinyui_StackFrame$stableprop // app.tinyui/app_tinyui_StackFrame$stableprop|#static{}app_tinyui_StackFrame$stableprop[0] @@ -731,6 +803,7 @@ final val app.tinyui/app_tinyui_TinyUIHost$stableprop // app.tinyui/app_tinyui_T final fun (app.tinyui.schema/ComponentRegistry).app.tinyui.components/registerBuiltins(): app.tinyui.schema/ComponentRegistry // app.tinyui.components/registerBuiltins|registerBuiltins@app.tinyui.schema.ComponentRegistry(){}[0] final fun (app.tinyui.schema/NodeScope).app.tinyui.schema/Commands(kotlin.coroutines/SuspendFunction1, androidx.compose.runtime/Composer?, kotlin/Int) // app.tinyui.schema/Commands|Commands@app.tinyui.schema.NodeScope(kotlin.coroutines.SuspendFunction1;androidx.compose.runtime.Composer?;kotlin.Int){}[0] +final fun <#A: kotlin/Any?> (app.tinyui/Store).app.tinyui/bind(kotlin/String, kotlinx.coroutines.flow/Flow<#A>, kotlinx.coroutines/CoroutineScope, kotlinx.serialization/KSerializer<#A>): kotlinx.coroutines/Job // app.tinyui/bind|bind@app.tinyui.Store(kotlin.String;kotlinx.coroutines.flow.Flow<0:0>;kotlinx.coroutines.CoroutineScope;kotlinx.serialization.KSerializer<0:0>){0§}[0] final fun app.tinyui.components.generated/app_tinyui_components_generated_BuiltinSchemas$stableprop_getter(): kotlin/Int // app.tinyui.components.generated/app_tinyui_components_generated_BuiltinSchemas$stableprop_getter|app_tinyui_components_generated_BuiltinSchemas$stableprop_getter(){}[0] final fun app.tinyui.node/app_tinyui_node_Command$stableprop_getter(): kotlin/Int // app.tinyui.node/app_tinyui_node_Command$stableprop_getter|app_tinyui_node_Command$stableprop_getter(){}[0] final fun app.tinyui.node/app_tinyui_node_NodeTree$stableprop_getter(): kotlin/Int // app.tinyui.node/app_tinyui_node_NodeTree$stableprop_getter|app_tinyui_node_NodeTree$stableprop_getter(){}[0] @@ -740,12 +813,14 @@ final fun app.tinyui.schema/app_tinyui_schema_ColorValue_Literal$stableprop_gett final fun app.tinyui.schema/app_tinyui_schema_ColorValue_Token$stableprop_getter(): kotlin/Int // app.tinyui.schema/app_tinyui_schema_ColorValue_Token$stableprop_getter|app_tinyui_schema_ColorValue_Token$stableprop_getter(){}[0] final fun app.tinyui.schema/app_tinyui_schema_ComponentRegistry$stableprop_getter(): kotlin/Int // app.tinyui.schema/app_tinyui_schema_ComponentRegistry$stableprop_getter|app_tinyui_schema_ComponentRegistry$stableprop_getter(){}[0] final fun app.tinyui.schema/app_tinyui_schema_ComponentSchema$stableprop_getter(): kotlin/Int // app.tinyui.schema/app_tinyui_schema_ComponentSchema$stableprop_getter|app_tinyui_schema_ComponentSchema$stableprop_getter(){}[0] +final fun app.tinyui.schema/app_tinyui_schema_IconValue$stableprop_getter(): kotlin/Int // app.tinyui.schema/app_tinyui_schema_IconValue$stableprop_getter|app_tinyui_schema_IconValue$stableprop_getter(){}[0] final fun app.tinyui.schema/app_tinyui_schema_LayoutProps$stableprop_getter(): kotlin/Int // app.tinyui.schema/app_tinyui_schema_LayoutProps$stableprop_getter|app_tinyui_schema_LayoutProps$stableprop_getter(){}[0] final fun app.tinyui.schema/app_tinyui_schema_PropSpec$stableprop_getter(): kotlin/Int // app.tinyui.schema/app_tinyui_schema_PropSpec$stableprop_getter|app_tinyui_schema_PropSpec$stableprop_getter(){}[0] final fun app.tinyui.schema/app_tinyui_schema_PropSpec_Bool$stableprop_getter(): kotlin/Int // app.tinyui.schema/app_tinyui_schema_PropSpec_Bool$stableprop_getter|app_tinyui_schema_PropSpec_Bool$stableprop_getter(){}[0] final fun app.tinyui.schema/app_tinyui_schema_PropSpec_ColorSpec$stableprop_getter(): kotlin/Int // app.tinyui.schema/app_tinyui_schema_PropSpec_ColorSpec$stableprop_getter|app_tinyui_schema_PropSpec_ColorSpec$stableprop_getter(){}[0] final fun app.tinyui.schema/app_tinyui_schema_PropSpec_Dp$stableprop_getter(): kotlin/Int // app.tinyui.schema/app_tinyui_schema_PropSpec_Dp$stableprop_getter|app_tinyui_schema_PropSpec_Dp$stableprop_getter(){}[0] final fun app.tinyui.schema/app_tinyui_schema_PropSpec_Enum$stableprop_getter(): kotlin/Int // app.tinyui.schema/app_tinyui_schema_PropSpec_Enum$stableprop_getter|app_tinyui_schema_PropSpec_Enum$stableprop_getter(){}[0] +final fun app.tinyui.schema/app_tinyui_schema_PropSpec_IconSpec$stableprop_getter(): kotlin/Int // app.tinyui.schema/app_tinyui_schema_PropSpec_IconSpec$stableprop_getter|app_tinyui_schema_PropSpec_IconSpec$stableprop_getter(){}[0] final fun app.tinyui.schema/app_tinyui_schema_PropSpec_Num$stableprop_getter(): kotlin/Int // app.tinyui.schema/app_tinyui_schema_PropSpec_Num$stableprop_getter|app_tinyui_schema_PropSpec_Num$stableprop_getter(){}[0] final fun app.tinyui.schema/app_tinyui_schema_PropSpec_Size$stableprop_getter(): kotlin/Int // app.tinyui.schema/app_tinyui_schema_PropSpec_Size$stableprop_getter|app_tinyui_schema_PropSpec_Size$stableprop_getter(){}[0] final fun app.tinyui.schema/app_tinyui_schema_PropSpec_Sp$stableprop_getter(): kotlin/Int // app.tinyui.schema/app_tinyui_schema_PropSpec_Sp$stableprop_getter|app_tinyui_schema_PropSpec_Sp$stableprop_getter(){}[0] @@ -755,21 +830,21 @@ final fun app.tinyui.schema/app_tinyui_schema_SizeValue_Fixed$stableprop_getter( final fun app.tinyui.schema/app_tinyui_schema_SizeValue_Wrap$stableprop_getter(): kotlin/Int // app.tinyui.schema/app_tinyui_schema_SizeValue_Wrap$stableprop_getter|app_tinyui_schema_SizeValue_Wrap$stableprop_getter(){}[0] final fun app.tinyui.schema/app_tinyui_schema_Theme$stableprop_getter(): kotlin/Int // app.tinyui.schema/app_tinyui_schema_Theme$stableprop_getter|app_tinyui_schema_Theme$stableprop_getter(){}[0] final fun app.tinyui/PageFailureScreen(app.tinyui/PageFailure, androidx.compose.runtime/Composer?, kotlin/Int) // app.tinyui/PageFailureScreen|PageFailureScreen(app.tinyui.PageFailure;androidx.compose.runtime.Composer?;kotlin.Int){}[0] -final fun app.tinyui/TinyUIPage(app.tinyui/PageModule, app.tinyui/TinyUIHost, app.tinyui/HostServices?, kotlin/String?, androidx.compose.ui/Modifier?, app.tinyui/SourceMaps?, kotlin/Function3?, kotlin/Function1?, androidx.compose.runtime/Composer?, kotlin/Int, kotlin/Int) // app.tinyui/TinyUIPage|TinyUIPage(app.tinyui.PageModule;app.tinyui.TinyUIHost;app.tinyui.HostServices?;kotlin.String?;androidx.compose.ui.Modifier?;app.tinyui.SourceMaps?;kotlin.Function3?;kotlin.Function1?;androidx.compose.runtime.Composer?;kotlin.Int;kotlin.Int){}[0] +final fun app.tinyui/TinyUIPage(app.tinyui/PageModule, app.tinyui/TinyUIHost, kotlin/String?, kotlin.collections/List>?, androidx.compose.ui/Modifier?, app.tinyui/SourceMaps?, kotlin/Function3?, kotlin/Function1?, androidx.compose.runtime/Composer?, kotlin/Int, kotlin/Int) // app.tinyui/TinyUIPage|TinyUIPage(app.tinyui.PageModule;app.tinyui.TinyUIHost;kotlin.String?;kotlin.collections.List>?;androidx.compose.ui.Modifier?;app.tinyui.SourceMaps?;kotlin.Function3?;kotlin.Function1?;androidx.compose.runtime.Composer?;kotlin.Int;kotlin.Int){}[0] final fun app.tinyui/app_tinyui_BuildManifest$stableprop_getter(): kotlin/Int // app.tinyui/app_tinyui_BuildManifest$stableprop_getter|app_tinyui_BuildManifest$stableprop_getter(){}[0] final fun app.tinyui/app_tinyui_Bundle$stableprop_getter(): kotlin/Int // app.tinyui/app_tinyui_Bundle$stableprop_getter|app_tinyui_Bundle$stableprop_getter(){}[0] final fun app.tinyui/app_tinyui_CapabilityRegistry$stableprop_getter(): kotlin/Int // app.tinyui/app_tinyui_CapabilityRegistry$stableprop_getter|app_tinyui_CapabilityRegistry$stableprop_getter(){}[0] final fun app.tinyui/app_tinyui_EventBus$stableprop_getter(): kotlin/Int // app.tinyui/app_tinyui_EventBus$stableprop_getter|app_tinyui_EventBus$stableprop_getter(){}[0] final fun app.tinyui/app_tinyui_HostException$stableprop_getter(): kotlin/Int // app.tinyui/app_tinyui_HostException$stableprop_getter|app_tinyui_HostException$stableprop_getter(){}[0] -final fun app.tinyui/app_tinyui_HostServices$stableprop_getter(): kotlin/Int // app.tinyui/app_tinyui_HostServices$stableprop_getter|app_tinyui_HostServices$stableprop_getter(){}[0] final fun app.tinyui/app_tinyui_HostSnapshot$stableprop_getter(): kotlin/Int // app.tinyui/app_tinyui_HostSnapshot$stableprop_getter|app_tinyui_HostSnapshot$stableprop_getter(){}[0] -final fun app.tinyui/app_tinyui_HttpClient_None$stableprop_getter(): kotlin/Int // app.tinyui/app_tinyui_HttpClient_None$stableprop_getter|app_tinyui_HttpClient_None$stableprop_getter(){}[0] final fun app.tinyui/app_tinyui_HttpRequest$stableprop_getter(): kotlin/Int // app.tinyui/app_tinyui_HttpRequest$stableprop_getter|app_tinyui_HttpRequest$stableprop_getter(){}[0] final fun app.tinyui/app_tinyui_HttpResponse$stableprop_getter(): kotlin/Int // app.tinyui/app_tinyui_HttpResponse$stableprop_getter|app_tinyui_HttpResponse$stableprop_getter(){}[0] -final fun app.tinyui/app_tinyui_InMemoryStore$stableprop_getter(): kotlin/Int // app.tinyui/app_tinyui_InMemoryStore$stableprop_getter|app_tinyui_InMemoryStore$stableprop_getter(){}[0] +final fun app.tinyui/app_tinyui_I18nFiles$stableprop_getter(): kotlin/Int // app.tinyui/app_tinyui_I18nFiles$stableprop_getter|app_tinyui_I18nFiles$stableprop_getter(){}[0] +final fun app.tinyui/app_tinyui_KtorChannel$stableprop_getter(): kotlin/Int // app.tinyui/app_tinyui_KtorChannel$stableprop_getter|app_tinyui_KtorChannel$stableprop_getter(){}[0] final fun app.tinyui/app_tinyui_LoadedPage$stableprop_getter(): kotlin/Int // app.tinyui/app_tinyui_LoadedPage$stableprop_getter|app_tinyui_LoadedPage$stableprop_getter(){}[0] final fun app.tinyui/app_tinyui_Navigator_None$stableprop_getter(): kotlin/Int // app.tinyui/app_tinyui_Navigator_None$stableprop_getter|app_tinyui_Navigator_None$stableprop_getter(){}[0] final fun app.tinyui/app_tinyui_PackageCheck$stableprop_getter(): kotlin/Int // app.tinyui/app_tinyui_PackageCheck$stableprop_getter|app_tinyui_PackageCheck$stableprop_getter(){}[0] +final fun app.tinyui/app_tinyui_PackageI18n$stableprop_getter(): kotlin/Int // app.tinyui/app_tinyui_PackageI18n$stableprop_getter|app_tinyui_PackageI18n$stableprop_getter(){}[0] final fun app.tinyui/app_tinyui_PageContext$stableprop_getter(): kotlin/Int // app.tinyui/app_tinyui_PageContext$stableprop_getter|app_tinyui_PageContext$stableprop_getter(){}[0] final fun app.tinyui/app_tinyui_PageError$stableprop_getter(): kotlin/Int // app.tinyui/app_tinyui_PageError$stableprop_getter|app_tinyui_PageError$stableprop_getter(){}[0] final fun app.tinyui/app_tinyui_PageFailure$stableprop_getter(): kotlin/Int // app.tinyui/app_tinyui_PageFailure$stableprop_getter|app_tinyui_PageFailure$stableprop_getter(){}[0] @@ -778,9 +853,13 @@ final fun app.tinyui/app_tinyui_PageLocal$stableprop_getter(): kotlin/Int // app final fun app.tinyui/app_tinyui_PageLocalValue$stableprop_getter(): kotlin/Int // app.tinyui/app_tinyui_PageLocalValue$stableprop_getter|app_tinyui_PageLocalValue$stableprop_getter(){}[0] final fun app.tinyui/app_tinyui_PageModule$stableprop_getter(): kotlin/Int // app.tinyui/app_tinyui_PageModule$stableprop_getter|app_tinyui_PageModule$stableprop_getter(){}[0] final fun app.tinyui/app_tinyui_PageRequires$stableprop_getter(): kotlin/Int // app.tinyui/app_tinyui_PageRequires$stableprop_getter|app_tinyui_PageRequires$stableprop_getter(){}[0] +final fun app.tinyui/app_tinyui_Session$stableprop_getter(): kotlin/Int // app.tinyui/app_tinyui_Session$stableprop_getter|app_tinyui_Session$stableprop_getter(){}[0] +final fun app.tinyui/app_tinyui_SessionSource$stableprop_getter(): kotlin/Int // app.tinyui/app_tinyui_SessionSource$stableprop_getter|app_tinyui_SessionSource$stableprop_getter(){}[0] final fun app.tinyui/app_tinyui_SourceMap_Position$stableprop_getter(): kotlin/Int // app.tinyui/app_tinyui_SourceMap_Position$stableprop_getter|app_tinyui_SourceMap_Position$stableprop_getter(){}[0] final fun app.tinyui/app_tinyui_SourceMaps$stableprop_getter(): kotlin/Int // app.tinyui/app_tinyui_SourceMaps$stableprop_getter|app_tinyui_SourceMaps$stableprop_getter(){}[0] final fun app.tinyui/app_tinyui_StackFrame$stableprop_getter(): kotlin/Int // app.tinyui/app_tinyui_StackFrame$stableprop_getter|app_tinyui_StackFrame$stableprop_getter(){}[0] final fun app.tinyui/app_tinyui_StoreValue$stableprop_getter(): kotlin/Int // app.tinyui/app_tinyui_StoreValue$stableprop_getter|app_tinyui_StoreValue$stableprop_getter(){}[0] final fun app.tinyui/app_tinyui_TinyUI$stableprop_getter(): kotlin/Int // app.tinyui/app_tinyui_TinyUI$stableprop_getter|app_tinyui_TinyUI$stableprop_getter(){}[0] final fun app.tinyui/app_tinyui_TinyUIHost$stableprop_getter(): kotlin/Int // app.tinyui/app_tinyui_TinyUIHost$stableprop_getter|app_tinyui_TinyUIHost$stableprop_getter(){}[0] +final inline fun <#A: reified kotlin/Any?> (app.tinyui/Store).app.tinyui/bind(kotlin/String, kotlinx.coroutines.flow/Flow<#A>, kotlinx.coroutines/CoroutineScope): kotlinx.coroutines/Job // app.tinyui/bind|bind@app.tinyui.Store(kotlin.String;kotlinx.coroutines.flow.Flow<0:0>;kotlinx.coroutines.CoroutineScope){0§}[0] +final inline fun <#A: reified kotlin/Any?> (app.tinyui/Store).app.tinyui/set(kotlin/String, #A) // app.tinyui/set|set@app.tinyui.Store(kotlin.String;0:0){0§}[0] diff --git a/compose/build.gradle.kts b/compose/build.gradle.kts index 1d48aa6..2893343 100644 --- a/compose/build.gradle.kts +++ b/compose/build.gradle.kts @@ -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) } } } diff --git a/compose/src/androidHostTest/kotlin/app/tinyui/BundleSmokeTest.kt b/compose/src/androidHostTest/kotlin/app/tinyui/BundleSmokeTest.kt index b09231c..0d4f0cc 100644 --- a/compose/src/androidHostTest/kotlin/app/tinyui/BundleSmokeTest.kt +++ b/compose/src/androidHostTest/kotlin/app/tinyui/BundleSmokeTest.kt @@ -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 @@ -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() } diff --git a/compose/src/androidMain/kotlin/app/tinyui/Platform.android.kt b/compose/src/androidMain/kotlin/app/tinyui/Platform.android.kt index e281942..4e05639 100644 --- a/compose/src/androidMain/kotlin/app/tinyui/Platform.android.kt +++ b/compose/src/androidMain/kotlin/app/tinyui/Platform.android.kt @@ -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 = 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) diff --git a/compose/src/commonMain/kotlin/app/tinyui/BuildManifest.kt b/compose/src/commonMain/kotlin/app/tinyui/BuildManifest.kt index 427c5fd..eee50a0 100644 --- a/compose/src/commonMain/kotlin/app/tinyui/BuildManifest.kt +++ b/compose/src/commonMain/kotlin/app/tinyui/BuildManifest.kt @@ -23,7 +23,13 @@ class BuildManifest( val requires: Map = 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> + 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`. */ @@ -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, val capabilities: List) +/** Dotted host components, capability names (`host.call`, `session.signIn`) and http channels one page uses. */ +class PageRequires(val components: List, val capabilities: List, val channels: List = emptyList()) + +/** `manifest.i18n`: the default language and each language's file inside the package. */ +class I18nFiles(val defaultLocale: String, val files: Map) diff --git a/compose/src/commonMain/kotlin/app/tinyui/Bundle.kt b/compose/src/commonMain/kotlin/app/tinyui/Bundle.kt index 5f2d748..fe0aec4 100644 --- a/compose/src/commonMain/kotlin/app/tinyui/Bundle.kt +++ b/compose/src/commonMain/kotlin/app/tinyui/Bundle.kt @@ -20,14 +20,23 @@ class Bundle(val manifest: BuildManifest, private val files: BundleFiles) { private val lock = Mutex() private val maps = HashMap() + 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 } diff --git a/compose/src/commonMain/kotlin/app/tinyui/HostInterfaces.kt b/compose/src/commonMain/kotlin/app/tinyui/HostInterfaces.kt new file mode 100644 index 0000000..8e0d256 --- /dev/null +++ b/compose/src/commonMain/kotlin/app/tinyui/HostInterfaces.kt @@ -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 Store.set(key: String, value: T) = setJson(key, Json.encodeToString(serializer(), value)) + +/** Writes every value of [flow] to [key] until [scope] ends. */ +fun Store.bind(key: String, flow: Flow, scope: CoroutineScope, serializer: KSerializer): Job = + scope.launch { flow.collect { setJson(key, Json.encodeToString(serializer, it)) } } + +inline fun Store.bind(key: String, flow: Flow, scope: CoroutineScope): Job = bind(key, flow, scope, serializer()) + +/** 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>(emptyMap()) + private val listeners = Listeners() + + 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, val body: String?, val timeoutMs: Long?) + +class HttpResponse(val status: Int, val headers: Map, 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, 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, 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() + + 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 = FrameworkNames - "session.signIn" + +@OptIn(ExperimentalAtomicApi::class) +internal class Listeners { + private val byKey = AtomicReference 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 + +/** BCP 47, e.g. `zh-Hans-CN`. */ +internal expect fun systemLocale(): String diff --git a/compose/src/commonMain/kotlin/app/tinyui/HostServices.kt b/compose/src/commonMain/kotlin/app/tinyui/HostServices.kt deleted file mode 100644 index 1c9110a..0000000 --- a/compose/src/commonMain/kotlin/app/tinyui/HostServices.kt +++ /dev/null @@ -1,125 +0,0 @@ -package app.tinyui - -import kotlin.concurrent.atomics.AtomicReference -import kotlin.concurrent.atomics.ExperimentalAtomicApi -import kotlin.concurrent.atomics.update - -/** 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(), - val http: HttpClient = HttpClient.None, - val i18n: I18n = I18n.None, - val config: Config = Config.None, - val events: EventBus = EventBus(), - /** Extra values merged into `device.info` (docs/native-api.md §1). */ - val deviceInfo: Map = emptyMap(), - /** This mount's objects that capabilities read through [PageContext] (docs/native-api.md §7). */ - val locals: List> = emptyList(), -) { - companion object { - /** One shared instance, so a page whose host passes nothing keeps a stable `remember` key. */ - val Default = HostServices() - } -} - -/** 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; the truth for cross-page state (docs/app-model.md §3). */ -interface Store { - fun get(key: String): String? - fun set(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 -} - -/** Copy-on-write: pages subscribe on their engine threads while the host writes from anywhere. */ -@OptIn(ExperimentalAtomicApi::class) -class InMemoryStore : Store { - private val values = AtomicReference>(emptyMap()) - private val listeners = Listeners() - - override fun get(key: String): String? = values.load()[key]?.json - - override fun set(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) -} - -class HttpRequest(val method: String, val url: String, val headers: Map, val bodyJson: String?, val timeoutMs: Long?) - -/** J3 `http.request`; [bodyJson] is JSON text or null. Throw [HostException] for E3 codes. */ -interface HttpClient { - suspend fun request(request: HttpRequest): HttpResponse - - object None : HttpClient { - override suspend fun request(request: HttpRequest): HttpResponse = throw HostException("E_UNSUPPORTED", "http is not available") - } -} - -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) - -/** 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") - -/** J2 `i18n.t`. */ -fun interface I18n { - fun translate(key: String, argsJson: String): String? - - companion object { - val None = I18n { _, _ -> null } - } -} - -/** J2 `config.get`, JSON text. */ -fun interface Config { - fun get(key: String): String? - - companion object { - val None = Config { null } - } -} - -/** One bus for host events and business events; pages subscribe per topic (docs/app-model.md §3). */ -class EventBus { - private val listeners = Listeners() - - fun emit(topic: String, payloadJson: String) = listeners.notify(topic, payloadJson) - - fun subscribe(topic: String, listener: (String) -> Unit): AutoCloseable = listeners.add(topic, listener) -} - -@OptIn(ExperimentalAtomicApi::class) -internal class Listeners { - private val byKey = AtomicReference 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 diff --git a/compose/src/commonMain/kotlin/app/tinyui/HostSnapshot.kt b/compose/src/commonMain/kotlin/app/tinyui/HostSnapshot.kt index 7088976..85b6c20 100644 --- a/compose/src/commonMain/kotlin/app/tinyui/HostSnapshot.kt +++ b/compose/src/commonMain/kotlin/app/tinyui/HostSnapshot.kt @@ -9,7 +9,7 @@ import kotlinx.serialization.json.JsonPrimitive object HostSnapshot { /** * The host test's check (docs/updates.md §4.1): what keeps [host] from being a host of [snapshot], the committed - * `tinyui-host/.txt`; empty when it is one. Components and capabilities must match exactly; the + * `tinyui-host/.txt`; empty when it is one. Components, capabilities and channels must match exactly; the * `tinyui` line is the lower bound of this host version, so this host's tinyui only has to be compatible with it. */ fun problems(snapshot: String, host: TinyUIHost, hostVersion: String): List = buildList { @@ -20,7 +20,9 @@ object HostSnapshot { if (floor == null || !TinyUI.isCompatible(TinyUI.version, floor)) { add("tinyui ${TinyUI.version} is not compatible with this host version's lower bound $floor (same major, not older)") } - if (lines.drop(2) != current.drop(2)) add("components or capabilities differ from the snapshot") + // snapshots frozen before channels existed have no such section: they provide none + val recorded = lines.drop(2).let { if ("channels" in it) it else it.dropLastWhile(String::isEmpty) + listOf("", "channels", "") } + if (recorded != current.drop(2)) add("components, capabilities or channels differ from the snapshot") } /** The snapshot of [host] at [hostVersion]; its `tinyui` line is this library's version, the lower bound when first written. */ @@ -33,7 +35,9 @@ object HostSnapshot { append("components\n") section(components) append("\ncapabilities\n") - section(host.capabilities.names.associateWith { "" }) + section(host.capabilityNames.associateWith { "" }) + append("\nchannels\n") + section(host.channels.keys.associateWith { "" }) } } diff --git a/compose/src/commonMain/kotlin/app/tinyui/Http.kt b/compose/src/commonMain/kotlin/app/tinyui/Http.kt new file mode 100644 index 0000000..5378d47 --- /dev/null +++ b/compose/src/commonMain/kotlin/app/tinyui/Http.kt @@ -0,0 +1,40 @@ +package app.tinyui + +import io.ktor.client.HttpClient +import io.ktor.client.plugins.ResponseException +import io.ktor.client.request.headers +import io.ktor.client.request.request +import io.ktor.client.request.setBody +import io.ktor.client.statement.HttpResponse as KtorResponse +import io.ktor.client.statement.bodyAsText +import io.ktor.http.ContentType +import io.ktor.http.HttpHeaders +import io.ktor.http.HttpMethod +import io.ktor.http.content.TextContent + +/** Wraps a host's ktor client as a channel: its plugins (auth, refresh, common headers) apply to the page's requests. */ +class KtorChannel(private val client: HttpClient) : HttpChannel { + override suspend fun request(request: HttpRequest): HttpResponse { + val response = try { + client.request(request.url) { + method = HttpMethod.parse(request.method.uppercase()) + val contentType = request.headers.entries.firstOrNull { it.key.equals(HttpHeaders.ContentType, ignoreCase = true) }?.value + headers { request.headers.filterKeys { !it.equals(HttpHeaders.ContentType, ignoreCase = true) }.forEach { (k, v) -> append(k, v) } } + // ktor owns Content-Type: it travels on the body, not as a header + request.body?.let { setBody(TextContent(it, contentType?.let(ContentType::parse) ?: ContentType.Text.Plain)) } + } + } catch (e: ResponseException) { + e.response + } + return response.toTinyUI() + } + + private suspend fun KtorResponse.toTinyUI() = HttpResponse( + status = status.value, + headers = headers.entries().associate { (k, v) -> k.lowercase() to v.joinToString(", ") }, + body = bodyAsText(), + ) +} + +/** The engine each platform ships; the `default` channel adds nothing to it. */ +internal expect fun defaultHttpClient(): HttpClient diff --git a/compose/src/commonMain/kotlin/app/tinyui/I18n.kt b/compose/src/commonMain/kotlin/app/tinyui/I18n.kt new file mode 100644 index 0000000..7532e2b --- /dev/null +++ b/compose/src/commonMain/kotlin/app/tinyui/I18n.kt @@ -0,0 +1,45 @@ +package app.tinyui + +import kotlin.concurrent.atomics.AtomicReference +import kotlin.concurrent.atomics.ExperimentalAtomicApi +import kotlin.concurrent.atomics.update +import kotlinx.serialization.json.Json +import kotlinx.serialization.json.jsonObject +import kotlinx.serialization.json.jsonPrimitive + +/** A package's `i18n/.json` files (docs/native-api.md §8, build-chain.md §8). */ +class PackageI18n(val defaultLocale: String, private val dictionaries: Map>) { + /** + * The string for [key] in [locale], falling back by dropping subtags (`zh-Hant-TW` → `zh-Hant` → `zh`) and then to + * [defaultLocale]; null when no dictionary has it. + */ + fun lookup(key: String, locale: String): String? { + for (candidate in chain(locale)) dictionaries[candidate]?.get(key)?.let { return it } + return null + } + + private fun chain(locale: String): List { + val parts = locale.replace('_', '-').split('-').filter { it.isNotEmpty() } + return (parts.size downTo 1).map { parts.take(it).joinToString("-") } + defaultLocale + } + + companion object { + val EMPTY = PackageI18n("", emptyMap()) + + /** [files] maps each locale to its JSON text; flat string values only. */ + fun parse(defaultLocale: String, files: Map): PackageI18n = + PackageI18n(defaultLocale, files.mapValues { (_, json) -> Json.parseToJsonElement(json).jsonObject.mapValues { it.value.jsonPrimitive.content } }) + } +} + +/** Each missing key is reported once per host. */ +@OptIn(ExperimentalAtomicApi::class) +internal class MissingKeys { + private val seen = AtomicReference>(emptySet()) + + fun firstTime(key: String): Boolean { + var added = false + seen.update { if (key in it) it else { added = true; it + key } } + return added + } +} diff --git a/compose/src/commonMain/kotlin/app/tinyui/PackageCheck.kt b/compose/src/commonMain/kotlin/app/tinyui/PackageCheck.kt index e67b03a..8324bb0 100644 --- a/compose/src/commonMain/kotlin/app/tinyui/PackageCheck.kt +++ b/compose/src/commonMain/kotlin/app/tinyui/PackageCheck.kt @@ -8,10 +8,11 @@ object PackageCheck { fun problems(manifest: BuildManifest, host: TinyUIHost): List = buildList { if (manifest.engine != QuickJs.upstreamCommit) add("built for engine ${manifest.engine}, the host embeds ${QuickJs.upstreamCommit}") if (!TinyUI.isCompatible(TinyUI.version, manifest.tinyui)) add("built with tinyui ${manifest.tinyui.ifEmpty { "(unknown)" }}, which does not run on this host's ${TinyUI.version} (same major, not newer)") - val capabilities = FrameworkCapabilities.toSet() + host.capabilities.names + val capabilities = FrameworkCapabilities + host.capabilityNames for ((page, uses) in manifest.requires.entries.sortedBy { it.key }) { uses.components.filter { host.components.schema(it) == null }.forEach { add("$page uses component $it, which the host does not register") } uses.capabilities.filter { it !in capabilities }.forEach { add("$page calls $it, which the host does not provide") } + uses.channels.filter { it != TinyUIHost.DEFAULT_CHANNEL && it !in host.channels }.forEach { add("$page sends through http channel $it, which the host does not register") } } } } diff --git a/compose/src/commonMain/kotlin/app/tinyui/PageHost.kt b/compose/src/commonMain/kotlin/app/tinyui/PageHost.kt index ca21063..3977c51 100644 --- a/compose/src/commonMain/kotlin/app/tinyui/PageHost.kt +++ b/compose/src/commonMain/kotlin/app/tinyui/PageHost.kt @@ -3,6 +3,10 @@ package app.tinyui import androidx.compose.foundation.background import androidx.compose.foundation.border import androidx.compose.foundation.clickable +import androidx.compose.foundation.selection.selectable +import androidx.compose.material3.SnackbarDuration +import androidx.compose.material3.SnackbarResult +import androidx.compose.ui.semantics.Role import androidx.compose.foundation.layout.fillMaxHeight import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.height @@ -22,6 +26,7 @@ import androidx.compose.ui.Modifier import androidx.compose.ui.draw.clip import androidx.compose.ui.unit.Dp import androidx.compose.ui.unit.dp +import kotlin.concurrent.Volatile import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.DelicateCoroutinesApi import kotlinx.coroutines.Dispatchers @@ -33,9 +38,14 @@ import kotlinx.coroutines.cancel import kotlinx.coroutines.delay import kotlinx.coroutines.launch import kotlinx.coroutines.newSingleThreadContext +import kotlinx.coroutines.withContext import kotlinx.coroutines.withTimeout +import kotlinx.coroutines.withTimeoutOrNull +import kotlinx.coroutines.flow.StateFlow import kotlinx.serialization.json.Json +import kotlinx.serialization.json.JsonNull import kotlinx.serialization.json.JsonObject +import kotlinx.serialization.json.booleanOrNull import kotlinx.serialization.json.JsonPrimitive import kotlinx.serialization.json.buildJsonObject import kotlinx.serialization.json.contentOrNull @@ -63,8 +73,14 @@ import app.tinyui.schema.SizeValue /** Bytecode of the runtime modules every page imports; [TinyUI.runtime] outside of tests. */ internal class RuntimeBundle(val core: ByteArray, val native: ByteArray) -/** One page's bytecode; [name] is the module name (`sample/todos`) and [buildId] comes from `manifest.json`. */ -class PageModule(val name: String, val bytecode: ByteArray, val buildId: String = "") +/** + * One page's bytecode; [name] is the module name (`sample/todos`) and [buildId] comes from `manifest.json`. + * [i18n] is its package's strings (docs/native-api.md §8). + */ +class PageModule(val name: String, val bytecode: ByteArray, val buildId: String = "", val i18n: PackageI18n = PackageI18n.EMPTY) { + /** The package the page belongs to: the part of [name] before the first `/`. */ + val pkg: String get() = name.substringBefore('/') +} /** Everything the host learns about a page's health, in one place (docs/adr-002 §3.5). */ /** The page's failure (E2 / E6): engine gone, error page to be rendered by the host. */ @@ -83,24 +99,27 @@ class PageHost internal constructor( private val runtimeBundle: RuntimeBundle, private val page: PageModule, 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). */ + /** This mount's objects for `host.call` capabilities (docs/native-api.md §13). */ + private val locals: List> = emptyList(), + /** K entries longer than this are interrupted and fail the page (docs/native-api.md §14). */ private val entryTimeoutMs: Long = 5_000, private val sourceMaps: SourceMaps = SourceMaps.EMPTY, ) { constructor( page: PageModule, host: TinyUIHost, - services: HostServices = HostServices.Default, propsJson: String = "{}", + locals: List> = emptyList(), entryTimeoutMs: Long = 5_000, sourceMaps: SourceMaps = SourceMaps.EMPTY, - ) : this(TinyUI.runtime, page, host, services, propsJson, entryTimeoutMs, sourceMaps) + ) : this(TinyUI.runtime, page, host, propsJson, locals, entryTimeoutMs, sourceMaps) 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 }) } + internal val context = PageContext(page.name, locals.associate { it.local to it.value }) + private val storage by lazy { host.storages.of(page.pkg) } + internal val ui = PageUi() val tree = NodeTree(registry) { report("E5", it.reason, op = it.op) } var failure: PageFailure? by mutableStateOf(null) private set @@ -115,6 +134,8 @@ class PageHost internal constructor( jsThread, ) private var entries: Entries? = null + /** The last [visible]; one that arrives before the mount is applied right after it. */ + @Volatile private var wantVisible = true private class Entries(val self: JsRef, val fns: Map) { fun call(name: String, vararg args: JsValue) = fns.getValue(name).invoke(self, args.toList()) @@ -125,6 +146,7 @@ class PageHost internal constructor( scope.launch { try { // K0 + K1 mount under the same timeout as every other entry: a page that spins on load fails as E6 + storage // the package's file is read before the first J2 can ask for it withTimeout(entryTimeoutMs) { runtime.withEngine { registerHost(this) step("registering tinyui-core") { registerModule(runtimeBundle.core) } @@ -138,9 +160,10 @@ class PageHost internal constructor( val e = Entries(self, fns).also { entries = it } namespace.use { ns -> (ns.get("default", ObjectTransport.REF) as JsRef).use { default -> - e.call("mount", default, JsValue.Str(propsJson), JsValue.Str(registry.manifest(FrameworkCapabilities + host.capabilities.names))) + e.call("mount", default, JsValue.Str(propsJson), JsValue.Str(registry.manifest((FrameworkCapabilities + host.capabilityNames).toList()))) } } + if (!wantVisible) e.call("visible", JsValue.Bool(false)) e.call("flush") } } } catch (t: TimeoutCancellationException) { @@ -152,10 +175,15 @@ class PageHost internal constructor( } fun dispatch(nodeId: Int, event: String, payload: String) = entry("dispatch", JsValue.Num(nodeId), JsValue.Str(event), JsValue.Str(payload)) - fun visible(visible: Boolean) = entry("visible", JsValue.Bool(visible)) + fun visible(visible: Boolean) { + wantVisible = visible + entry("visible", JsValue.Bool(visible)) + } fun resolve(cbId: Int, resultJson: String) = entry("resolve", JsValue.Num(cbId), JsValue.Str(resultJson)) - fun reject(cbId: Int, code: String, message: String) = - entry("reject", JsValue.Num(cbId), JsValue.Str(buildJsonObject { put("code", code); put("message", message) }.toString())) + fun reject(cbId: Int, code: String, message: String) = reject(cbId, code, message, null) + + private fun reject(cbId: Int, code: String, message: String, details: JsonObject?) = + entry("reject", JsValue.Num(cbId), JsValue.Str(JsonObject((details ?: JsonObject(emptyMap())) + mapOf("code" to JsonPrimitive(code), "message" to JsonPrimitive(message))).toString())) /** K5. The host calls this for `navigation.result`; bus topics arrive through the page's own subscriptions. */ fun emit(topic: String, payloadJson: String) = entry("emit", JsValue.Str(topic), JsValue.Str(payloadJson)) @@ -245,33 +273,65 @@ class PageHost internal constructor( } } - /** J2 whitelist (docs/native-api.md §1); anything else answers null. */ + /** J2 whitelist (docs/native-api.md §2); anything else answers null. */ private fun query(name: String, argsJson: String): String { val a = args(argsJson) return when (name) { - "device.info" -> JsonObject((platformInfo() + services.deviceInfo).mapValues { JsonPrimitive(it.value) }).toString() - "i18n.t" -> services.i18n.translate(a.str("key") ?: return "null", a["args"]?.toString() ?: "{}")?.let { JsonPrimitive(it).toString() } ?: "null" - "config.get" -> a.str("key")?.let(services.config::get) ?: "null" - "store.get" -> a.str("key")?.let(services.store::get) ?: "null" + "device.info" -> JsonObject(platformInfo().mapValues { JsonPrimitive(it.value) }).toString() + "store.get" -> a.str("key")?.let(host.store::getJson) ?: "null" + "storage.get" -> a.str("key")?.let { storage?.get(it) } ?: "null" + "storage.set" -> { + val key = a.str("key") ?: return JsonPrimitive("E_INVALID").toString() + val target = storage ?: return JsonPrimitive("E_UNSUPPORTED").toString() + target.set(key, a.str("value") ?: "null")?.let { JsonPrimitive(it).toString() } ?: "null" + } + "i18n.t" -> JsonPrimitive(translate(a.str("key") ?: "")).toString() + "i18n.locale" -> JsonPrimitive(host.currentLocale()).toString() + "session.get" -> sessionJson(host.session?.state?.value ?: Session.LoggedOut) else -> "null" } } - /** J3 (docs/native-api.md §5); results come back through [resolve] / [reject] as one transaction each. */ + private fun translate(key: String): String = + page.i18n.lookup(key, host.currentLocale()) ?: key.also { if (host.missingKeys.firstTime("${page.pkg}:$it")) sink.log("i18n: ${page.pkg} has no \"$it\" in any language") } + + private fun sessionJson(s: Session): String = buildJsonObject { put("loggedIn", s.loggedIn); put("userId", s.userId) }.toString() + + /** J3 (docs/native-api.md §6–§13); results come back through [resolve] / [reject] as one transaction each. */ private fun call(name: String, cbId: Int, argsJson: String) { val a = args(argsJson) when (name) { "timer.schedule" -> schedule(cbId, a) - "http.request" -> settle(cbId) { - val request = HttpRequest( - method = a.str("method") ?: "GET", - url = a.str("url") ?: throw HostException("E_INVALID", "http.request needs url"), - headers = (a["headers"] as? JsonObject)?.mapValues { it.value.jsonPrimitive.content } ?: emptyMap(), - bodyJson = a["body"]?.toString(), - timeoutMs = a["timeout"]?.jsonPrimitive?.longOrNull, + "http.request" -> settle(cbId) { http(a) } + "session.signIn" -> settle(cbId) { + val session = host.session ?: throw HostException("E_UNSUPPORTED", "the host provides no session") + withContext(Dispatchers.Main) { session.signIn(a.str("source") ?: "") } + "" + } + "ui.toast" -> settle(cbId) { + val message = a.str("message") ?: throw HostException("E_INVALID", "ui.toast needs a message") + val long = a.str("duration") == "long" + val result = withContext(Dispatchers.Main) { + ui.snackbar.showSnackbar(message, a.str("action"), duration = if (long) SnackbarDuration.Long else SnackbarDuration.Short) + } + JsonPrimitive(if (result == SnackbarResult.ActionPerformed) "action" else "dismissed").toString() + } + "ui.alert", "ui.confirm" -> settle(cbId) { + val texts = DialogTexts.of(host.currentLocale()) + val confirmed = ui.ask( + DialogRequest( + title = a.str("title"), + message = a.str("message") ?: throw HostException("E_INVALID", "$name needs a message"), + confirm = a.str("confirm") ?: texts.ok, + cancel = if (name == "ui.confirm") a.str("cancel") ?: texts.cancel else null, + ), ) - val response = services.http.request(request) - buildJsonObject { put("status", response.status); put("body", Json.parseToJsonElement(response.bodyJson)) }.toString() + if (name == "ui.confirm") confirmed.toString() else "" + } + "linking.openUrl" -> settle(cbId) { + val url = a.str("url")?.takeIf { URL_SCHEME.containsMatchIn(it) } ?: throw HostException("E_INVALID", "linking.openUrl needs an absolute URL") + withContext(Dispatchers.Main) { (host.links ?: LinkOpener.System).open(url, context) } + "" } else -> when (val capability = host.capabilities[name]) { null -> scope.launch { reject(cbId, "E_UNSUPPORTED", "$name is not available") } @@ -280,10 +340,46 @@ class PageHost internal constructor( } } + /** docs/native-api.md §6: any response comes back from the channel; non-2xx rejects E_HTTP with status, headers and body. */ + private suspend fun http(a: JsonObject): String { + val channelName = a.str("channel") ?: TinyUIHost.DEFAULT_CHANNEL + val channel = host.channel(channelName) ?: throw HostException("E_UNSUPPORTED", "the host has no http channel \"$channelName\"") + val headers = (a["headers"] as? JsonObject)?.mapValues { it.value.jsonPrimitive.content }.orEmpty().toMutableMap() + val body = when (val b = a["body"]) { + null, JsonNull -> null + is JsonPrimitive if b.isString -> b.content + else -> { + if (headers.keys.none { it.equals("content-type", ignoreCase = true) }) headers["Content-Type"] = "application/json" + b.toString() + } + } + val request = HttpRequest( + method = a.str("method") ?: "GET", + url = a.str("url") ?: throw HostException("E_INVALID", "http.request needs url"), + headers = headers, + body = body, + timeoutMs = a["timeout"]?.jsonPrimitive?.longOrNull, + ) + val response = request.timeoutMs?.let { ms -> + withTimeoutOrNull(ms) { channel.request(request) } ?: throw HostException("E_TIMEOUT", "${request.method} ${request.url} took longer than $ms ms") + } ?: channel.request(request) + val fields = mapOf( + "status" to JsonPrimitive(response.status), + "headers" to JsonObject(response.headers.mapValues { JsonPrimitive(it.value) }), + "body" to (runCatching { Json.parseToJsonElement(response.body) }.getOrNull() ?: JsonPrimitive(response.body)), + ) + if (response.status !in 200..299) throw HttpStatusException(response.status, JsonObject(fields)) + return JsonObject(fields).toString() + } + + private class HttpStatusException(val status: Int, val details: JsonObject) : Exception("HTTP $status") + /** Runs one J3 on the page scope and answers with K3: the JSON [block] returns, or its E3 code. */ private fun settle(cbId: Int, block: suspend () -> String) = scope.launch { try { resolve(cbId, block()) + } catch (e: HttpStatusException) { + reject(cbId, "E_HTTP", e.message ?: "", e.details) } catch (e: HostException) { reject(cbId, e.code, e.message ?: "") } catch (e: Exception) { @@ -291,22 +387,51 @@ class PageHost internal constructor( } } - /** J4 (docs/native-api.md §2–§4). */ + /** J4 (docs/native-api.md §3–§12). */ private fun send(name: String, argsJson: String) { val a = args(argsJson) when (name) { "timer.cancel" -> a["cbId"]?.jsonPrimitive?.intOrNull?.let { timers.remove(it)?.cancel() } - "navigation.push" -> services.navigator.push(a.str("page") ?: return, a["params"]?.toString() ?: "{}") - "navigation.pop" -> services.navigator.pop(a["result"]?.toString()) - "store.set" -> { val key = a.str("key") ?: return; services.store.set(key, a.str("value") ?: "null") } + "navigation.push" -> host.navigator.push(a.str("page") ?: return, a["params"]?.toString() ?: "{}") + "navigation.pop" -> host.navigator.pop(a["result"]?.toString()) + "store.set" -> { val key = a.str("key") ?: return; host.store.setJson(key, a.str("value") ?: "null") } // one host listener per key / topic; JS fans out to its own handlers - "store.subscribe" -> { val key = a.str("key") ?: return; subscriptions.getOrPut("store:$key") { services.store.observe(key) { v -> storeChanged(key, v) } } } - "events.emit" -> services.events.emit(a.str("topic") ?: return, a["payload"]?.toString() ?: "{}") - "events.subscribe" -> { val topic = a.str("topic") ?: return; subscriptions.getOrPut("events:$topic") { services.events.subscribe(topic) { json -> emit(topic, json) } } } + "store.subscribe" -> { val key = a.str("key") ?: return; subscriptions.getOrPut("store:$key") { host.store.observe(key) { v -> storeChanged(key, v) } } } + "storage.remove" -> storage?.remove(a.str("key") ?: return) + "storage.clear" -> storage?.clear() + "events.emit" -> host.events.emit(a.str("topic") ?: return, a["payload"]?.toString() ?: "{}") + "events.subscribe" -> { val topic = a.str("topic") ?: return; subscriptions.getOrPut("events:$topic") { host.events.subscribe(topic) { json -> emit(topic, json) } } } + "i18n.subscribe" -> host.locale?.let { flow -> + subscriptions.getOrPut("i18n") { follow(flow) { emit("i18n.locale", buildJsonObject { put("locale", it) }.toString()) } } + } + "session.subscribe" -> host.session?.let { source -> + subscriptions.getOrPut("session") { follow(source.state) { emit("session", sessionJson(it)) } } + } + "analytics.track" -> track(a) else -> sink.log("unknown J4 $name") } } + /** K5 for every value of [flow], the current one included; stops with the page. */ + private fun follow(flow: StateFlow, push: (T) -> Unit): AutoCloseable { + val job = scope.launch { flow.collect(push) } + return AutoCloseable { job.cancel() } + } + + private fun track(a: JsonObject) { + val name = a.str("name") ?: return + val props = (a["props"] as? JsonObject).orEmpty().mapValues { (_, v) -> + val p = v as? JsonPrimitive + when { + p == null || p is JsonNull -> null + p.isString -> p.content + else -> p.booleanOrNull ?: p.longOrNull ?: p.doubleOrNull + } + } + val sink = host.analytics + if (sink == null) { if (TinyUI.debug) this.sink.log("analytics: no sink, dropped $name") } else sink.track(name, props, context) + } + /** Notifications may arrive out of order across threads; the version decides, applied inside the entry (under the lock). */ private fun storeChanged(key: String, value: StoreValue) { var stale = false @@ -350,7 +475,11 @@ class PageHost internal constructor( color("background")?.let { m = m.background(it) } // 0.dp is Compose's hairline (one pixel), not "no border" get("borderWidth")?.takeIf { it > 0.dp }?.let { m = m.border(it, color("borderColor") ?: MaterialTheme.colorScheme.outline, shape) } - if (clickable && has("onClick")) m = m.clickable { dispatch("onClick") } + if (clickable && has("onClick")) { + val role = roleOf(get("role")) + val selected = get("selected") + m = if (selected != null) m.selectable(selected, role = role) { dispatch("onClick") } else m.clickable(role = role) { dispatch("onClick") } + } val all = get("padding") val h = get("paddingHorizontal") ?: all val v = get("paddingVertical") ?: all @@ -380,6 +509,16 @@ class PageHost internal constructor( companion object { /** `import.meta.url` of a page module is `tinyui:` (docs/adr-005-engine.md). */ const val MODULE_SCHEME = "tinyui" + private val URL_SCHEME = Regex("^[A-Za-z][A-Za-z0-9+.-]*:") + + private fun roleOf(name: String?): Role? = when (name) { + "button" -> Role.Button + "checkbox" -> Role.Checkbox + "switch" -> Role.Switch + "radio" -> Role.RadioButton + "tab" -> Role.Tab + else -> null + } private val ENTRY_NAMES = listOf("mount", "unmount", "visible", "dispatch", "resolve", "reject", "emit", "flush") } } diff --git a/compose/src/commonMain/kotlin/app/tinyui/PageUi.kt b/compose/src/commonMain/kotlin/app/tinyui/PageUi.kt new file mode 100644 index 0000000..38871d5 --- /dev/null +++ b/compose/src/commonMain/kotlin/app/tinyui/PageUi.kt @@ -0,0 +1,60 @@ +package app.tinyui + +import androidx.compose.material3.AlertDialog +import androidx.compose.material3.SnackbarHost +import androidx.compose.material3.SnackbarHostState +import androidx.compose.material3.Text +import androidx.compose.material3.TextButton +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.setValue +import androidx.compose.ui.Modifier +import kotlinx.coroutines.CompletableDeferred +import kotlinx.coroutines.sync.Mutex +import kotlinx.coroutines.sync.withLock + +/** `ui.alert` / `ui.confirm`; [cancel] is null for an alert. */ +internal class DialogRequest(val title: String?, val message: String, val confirm: String, val cancel: String?) { + val result = CompletableDeferred() +} + +/** The page's own snackbar and dialog (docs/native-api.md §10), drawn by [TinyUIPage] over the page. */ +internal class PageUi { + val snackbar = SnackbarHostState() + var dialog: DialogRequest? by mutableStateOf(null) + private set + + private val queue = Mutex() + + /** Shows [request] after any dialog already up; true when confirmed. */ + suspend fun ask(request: DialogRequest): Boolean = queue.withLock { + dialog = request + try { + request.result.await() + } finally { + dialog = null + } + } + + @Composable + fun Overlay(modifier: Modifier) { + SnackbarHost(snackbar, modifier) + dialog?.let { d -> + AlertDialog( + onDismissRequest = { d.result.complete(false) }, + title = d.title?.let { { Text(it) } }, + text = { Text(d.message) }, + confirmButton = { TextButton(onClick = { d.result.complete(true) }) { Text(d.confirm) } }, + dismissButton = d.cancel?.let { { TextButton(onClick = { d.result.complete(false) }) { Text(it) } } }, + ) + } + } +} + +/** The framework's own button labels when a page gives none. */ +internal class DialogTexts(val ok: String, val cancel: String) { + companion object { + fun of(locale: String): DialogTexts = if (locale.lowercase().startsWith("zh")) DialogTexts("确定", "取消") else DialogTexts("OK", "Cancel") + } +} diff --git a/compose/src/commonMain/kotlin/app/tinyui/Storage.kt b/compose/src/commonMain/kotlin/app/tinyui/Storage.kt new file mode 100644 index 0000000..4a5703a --- /dev/null +++ b/compose/src/commonMain/kotlin/app/tinyui/Storage.kt @@ -0,0 +1,109 @@ +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.CoroutineStart +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.Job +import kotlinx.coroutines.SupervisorJob +import kotlinx.coroutines.delay +import kotlinx.coroutines.launch +import kotlinx.serialization.json.Json +import kotlinx.serialization.json.JsonElement +import kotlinx.serialization.json.JsonObject +import kotlinx.serialization.json.jsonObject +import okio.FileSystem +import okio.Path + +/** Each package's `storage` (docs/native-api.md §7), loaded on first use and kept for the life of the App. */ +@OptIn(ExperimentalAtomicApi::class) +internal class Storages(private val dir: Path?, private val sink: PageSink, private val fs: FileSystem = platformFileSystem) { + private val byPackage = AtomicReference>(emptyMap()) + private val scope = CoroutineScope(SupervisorJob() + Dispatchers.Default) + + /** Null when the host gave no data directory. */ + fun of(pkg: String): PackageStorage? { + val root = dir ?: return null + byPackage.load()[pkg]?.let { return it } + val loaded = PackageStorage(fs, root / "storage" / "$pkg.json", scope, sink) + var result = loaded + byPackage.update { map -> map[pkg]?.let { result = it; map } ?: (map + (pkg to loaded)) } + return result + } +} + +/** + * One package's key → JSON text, read whole into memory; writes land in memory at once and reach the file together + * after [FLUSH_DELAY_MS], through a temporary file renamed over it. + */ +@OptIn(ExperimentalAtomicApi::class) +internal class PackageStorage(private val fs: FileSystem, private val file: Path, private val scope: CoroutineScope, private val sink: PageSink) { + private val values = AtomicReference(read()) + private val flush = AtomicReference(null) + + fun get(key: String): String? = values.load()[key] + + /** Null on success, or the E3 code. */ + fun set(key: String, json: String): String? { + var over = false + values.update { map -> + val next = map + (key to json) + if (sizeOf(next) > LIMIT_CHARS) { over = true; map } else { over = false; next } + } + if (over) return "E_QUOTA" + scheduleFlush() + return null + } + + fun remove(key: String) { + values.update { it - key } + scheduleFlush() + } + + fun clear() { + values.store(emptyMap()) + scheduleFlush() + } + + private fun scheduleFlush() { + val job = scope.launch(start = CoroutineStart.LAZY) { + delay(FLUSH_DELAY_MS) + write(values.load()) + } + flush.exchange(job)?.cancel() + job.start() + } + + private fun read(): Map = try { + if (!fs.exists(file)) emptyMap() + else Json.parseToJsonElement(fs.read(file) { readUtf8() }).jsonObject.mapValues { it.value.toString() } + } catch (e: Exception) { + sink.log("storage: ${file.name} unreadable, starting empty: ${e.message}") + emptyMap() + } + + private fun write(map: Map) { + try { + file.parent?.let { fs.createDirectories(it) } + val tmp = file.parent!! / "${file.name}.tmp" + val json = JsonObject(map.mapValues { Json.parseToJsonElement(it.value) as JsonElement }).toString() + fs.write(tmp) { writeUtf8(json) } + fs.atomicMove(tmp, file) + } catch (e: Exception) { + sink.log("storage: writing ${file.name} failed: ${e.message}") + } + } + + private fun sizeOf(map: Map): Int = map.entries.sumOf { it.key.length + it.value.length } + + companion object { + /** The whole package, keys plus JSON values, in characters (docs/native-api.md §14). */ + const val LIMIT_CHARS = 1_000_000 + const val FLUSH_DELAY_MS = 200L + } +} + +/** okio declares `FileSystem.SYSTEM` per platform, not in common code. */ +internal expect val platformFileSystem: FileSystem diff --git a/compose/src/commonMain/kotlin/app/tinyui/TinyUIHost.kt b/compose/src/commonMain/kotlin/app/tinyui/TinyUIHost.kt index 503aca8..5ceedd0 100644 --- a/compose/src/commonMain/kotlin/app/tinyui/TinyUIHost.kt +++ b/compose/src/commonMain/kotlin/app/tinyui/TinyUIHost.kt @@ -1,23 +1,68 @@ package app.tinyui +import androidx.compose.ui.platform.UriHandler import app.tinyui.schema.ComponentRegistry +import kotlin.concurrent.Volatile +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.withContext +import okio.Path /** - * 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. + * What one App gives every TinyUI page, built once at startup and not changed after (docs/native-api.md §1). + * Its host components, capability names, channel names and whether it has a [session] are the host contract + * [HostSnapshot] renders; the other standard interfaces fall back to defaults when absent. + * + * @param channels HTTP channels pages pick by name (`http.client("app")`); `default` is the framework's own and cannot be registered. + * @param locale the user's language (BCP 47); the system language when null. + * @param dataDir where `storage` keeps each package's file; `storage` answers E_UNSUPPORTED when null. Not the `Updates` directory. */ class TinyUIHost( val components: ComponentRegistry, val sink: PageSink, val capabilities: CapabilityRegistry = CapabilityRegistry(), + val channels: Map = emptyMap(), + val locale: StateFlow? = null, + val session: SessionSource? = null, + val analytics: AnalyticsSink? = null, + val links: LinkOpener? = null, + val navigator: Navigator = Navigator.None, + val dataDir: Path? = null, ) { + /** App-level key-value shared by every page and the host's own code (docs/native-api.md §4). */ + val store: Store = InMemoryStore() + + /** App-level bus: host events to pages, business events from pages (docs/native-api.md §5). */ + val events: EventBus = EventBus() + + /** Capability names pages may rely on beyond the framework's: the registered ones, and `session.signIn` with a [session]. */ + val capabilityNames: Set get() = capabilities.names + if (session != null) setOf(SESSION_SIGN_IN) else emptySet() + + internal val storages = Storages(dataDir, sink) + internal val missingKeys = MissingKeys() + internal val defaultChannel: HttpChannel by lazy { KtorChannel(defaultHttpClient()) } + init { + for (name in channels.keys) { + require(name.matches(CHANNEL_NAME)) { "channel name \"$name\" must match ${CHANNEL_NAME.pattern}" } + require(name != DEFAULT_CHANNEL) { "channel \"$DEFAULT_CHANNEL\" is the framework's own" } + } components.freeze() capabilities.freeze() } + + internal fun channel(name: String): HttpChannel? = if (name == DEFAULT_CHANNEL) defaultChannel else channels[name] + + internal fun currentLocale(): String = locale?.value ?: systemLocale() + + internal companion object { + const val DEFAULT_CHANNEL = "default" + const val SESSION_SIGN_IN = "session.signIn" + val CHANNEL_NAME = Regex("[a-z][a-z0-9-]*") + } } -/** App-level J3 `host.call` capabilities by name (docs/native-api.md §7). */ +/** App-level J3 `host.call` capabilities by name (docs/native-api.md §13). */ class CapabilityRegistry { private val entries = LinkedHashMap() private var frozen = false @@ -26,7 +71,7 @@ class CapabilityRegistry { 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" } + require(name !in FrameworkNames) { "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 @@ -42,13 +87,26 @@ 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, Any>) { +/** The mount a call came from: the page's module name and the objects its `locals` provide. */ +class PageContext internal constructor(val name: String, @Volatile internal var locals: Map, Any>) { + internal var uriHandler: UriHandler? = null + @Suppress("UNCHECKED_CAST") operator fun get(local: PageLocal): T? = locals[local] as T? + + internal suspend fun openWithSystem(url: String) { + val handler = uriHandler ?: throw HostException("E_UNSUPPORTED", "the page is not on screen") + withContext(Dispatchers.Main) { + try { + handler.openUri(url) + } catch (e: IllegalArgumentException) { + throw HostException("E_UNSUPPORTED", e.message ?: "nothing can open $url") + } + } + } } -/** A typed key for an object one mount hands to capabilities, such as its screen's snackbar. */ +/** A typed key for an object one mount hands to capabilities, such as its screen's view model. */ class PageLocal { infix fun provides(value: T): PageLocalValue = PageLocalValue(this, value) } diff --git a/compose/src/commonMain/kotlin/app/tinyui/TinyUIPage.kt b/compose/src/commonMain/kotlin/app/tinyui/TinyUIPage.kt index 3e7aff8..1e047bc 100644 --- a/compose/src/commonMain/kotlin/app/tinyui/TinyUIPage.kt +++ b/compose/src/commonMain/kotlin/app/tinyui/TinyUIPage.kt @@ -9,35 +9,64 @@ import androidx.compose.material3.MaterialTheme import androidx.compose.material3.Text import androidx.compose.runtime.Composable import androidx.compose.runtime.DisposableEffect +import androidx.compose.runtime.SideEffect import androidx.compose.runtime.key import androidx.compose.runtime.remember +import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier +import androidx.compose.ui.platform.LocalUriHandler +import androidx.lifecycle.Lifecycle +import androidx.lifecycle.LifecycleEventObserver +import androidx.lifecycle.compose.LocalLifecycleOwner import androidx.compose.ui.text.font.FontFamily import androidx.compose.ui.unit.dp /** * Renders one TinyUI page; the host owns navigation and passes the page's bytecode (docs/app-model.md). * To deliver a navigation result to this page, keep the [PageHost] (see [onHost]) and call [PageHost.emit]. + * The page is visible (`pageVisible()`) while the lifecycle is at least STARTED; its toasts and dialogs draw over it. + * + * @param locals this mount's objects for `host.call` capabilities (docs/native-api.md §13). */ @Composable fun TinyUIPage( page: PageModule, host: TinyUIHost, - services: HostServices = HostServices.Default, propsJson: String = "{}", + locals: List> = emptyList(), modifier: Modifier = Modifier, sourceMaps: SourceMaps = SourceMaps.EMPTY, error: @Composable (PageFailure) -> Unit = { PageFailureScreen(it) }, onHost: (PageHost) -> Unit = {}, ) { - val pageHost = remember(page, host, services, propsJson, sourceMaps) { - PageHost(page, host, services, propsJson, sourceMaps = sourceMaps) + val pageHost = remember(page, host, propsJson, sourceMaps) { + PageHost(page, host, propsJson, locals, sourceMaps = sourceMaps) } + val uriHandler = LocalUriHandler.current + // a new list of the same objects each recomposition must not remount the page: the context just follows it + SideEffect { + pageHost.context.uriHandler = uriHandler + pageHost.context.locals = locals.associate { it.local to it.value } + } + val lifecycle = LocalLifecycleOwner.current.lifecycle DisposableEffect(pageHost) { onHost(pageHost) pageHost.start() onDispose { pageHost.close() } } + DisposableEffect(pageHost, lifecycle) { + val observer = LifecycleEventObserver { _, event -> + when (event) { + Lifecycle.Event.ON_START -> pageHost.visible(true) + Lifecycle.Event.ON_STOP -> pageHost.visible(false) + else -> {} + } + } + // addObserver replays ON_START only when already started; below that the page starts hidden + if (!lifecycle.currentState.isAtLeast(Lifecycle.State.STARTED)) pageHost.visible(false) + lifecycle.addObserver(observer) + onDispose { lifecycle.removeObserver(observer) } + } Box(modifier) { val failure = pageHost.failure if (failure != null) { @@ -45,6 +74,7 @@ fun TinyUIPage( } else { for (child in pageHost.tree.root.children) key(child.id) { pageHost.Render(child) } } + pageHost.ui.Overlay(Modifier.align(Alignment.BottomCenter)) } } diff --git a/compose/src/commonMain/kotlin/app/tinyui/components/Builtins.kt b/compose/src/commonMain/kotlin/app/tinyui/components/Builtins.kt index 1d8205c..4ef02fc 100644 --- a/compose/src/commonMain/kotlin/app/tinyui/components/Builtins.kt +++ b/compose/src/commonMain/kotlin/app/tinyui/components/Builtins.kt @@ -6,6 +6,7 @@ import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.size import androidx.compose.foundation.lazy.LazyColumn import androidx.compose.foundation.lazy.items import androidx.compose.foundation.lazy.rememberLazyListState @@ -14,6 +15,11 @@ import androidx.compose.foundation.verticalScroll import androidx.compose.foundation.text.KeyboardActions import androidx.compose.foundation.text.KeyboardOptions import androidx.compose.material3.Button +import androidx.compose.material3.ExperimentalMaterial3ExpressiveApi +import androidx.compose.material3.Icon +import androidx.compose.material3.LoadingIndicator +import androidx.compose.material3.LoadingIndicatorDefaults +import androidx.compose.material3.LocalContentColor import androidx.compose.material3.LocalTextStyle import androidx.compose.material3.MaterialTheme import androidx.compose.material3.OutlinedButton @@ -52,6 +58,7 @@ import kotlinx.serialization.json.put import app.tinyui.components.generated.BuiltinSchemas import app.tinyui.node.UINode import app.tinyui.schema.Commands +import app.tinyui.schema.IconValue import app.tinyui.schema.ComponentRegistry import app.tinyui.schema.NodeScope import app.tinyui.schema.Theme @@ -113,6 +120,11 @@ fun ComponentRegistry.registerBuiltins(): ComponentRegistry = apply { register(BuiltinSchemas.TextField) { scope -> TextFieldComponent(scope) } register(BuiltinSchemas.LazyColumn) { scope -> LazyColumnComponent(scope) } register(BuiltinSchemas.Spacer) { scope -> Spacer(scope.modifier()) } + register(BuiltinSchemas.Icon) { scope -> + val icon = scope.get("icon") ?: return@register + Icon(icon.vector, contentDescription = scope.get("label"), tint = scope.color("tint") ?: LocalContentColor.current, modifier = scope.modifier().size(scope.get("size") ?: 24.dp)) + } + register(BuiltinSchemas.Loading) { scope -> LoadingComponent(scope) } } // a scrolling axis is unbounded, so there is no remaining space to share: weight is ignored there rather than collapsing the child to 0 @@ -212,3 +224,12 @@ private fun LazyColumnComponent(scope: NodeScope) { } private fun payload(key: String, value: String) = buildJsonObject { put(key, value) }.toString() + +@OptIn(ExperimentalMaterial3ExpressiveApi::class) +@Composable +private fun LoadingComponent(scope: NodeScope) { + LoadingIndicator( + modifier = scope.modifier().size(scope.get("size") ?: 24.dp), + color = scope.color("color") ?: LoadingIndicatorDefaults.indicatorColor, + ) +} diff --git a/compose/src/commonMain/kotlin/app/tinyui/components/generated/BuiltinSchemas.kt b/compose/src/commonMain/kotlin/app/tinyui/components/generated/BuiltinSchemas.kt index 4141058..0aeddb3 100644 --- a/compose/src/commonMain/kotlin/app/tinyui/components/generated/BuiltinSchemas.kt +++ b/compose/src/commonMain/kotlin/app/tinyui/components/generated/BuiltinSchemas.kt @@ -154,7 +154,38 @@ object BuiltinSchemas { layout = true, ) - val all: List = listOf(Column, Row, Box, Text, Button, RadioButton, TextField, LazyColumn, Spacer) + val Icon = ComponentSchema( + type = "Icon", + props = mapOf( + "icon" to PropSpec.IconSpec(required = true, initial = false), + "size" to PropSpec.Dp(default = 24.0, required = false, initial = false), + "tint" to PropSpec.ColorSpec(default = null, required = false, initial = false), + "label" to PropSpec.Str(default = null, required = false, initial = false), + ), + events = mapOf( + "onClick" to emptyMap(), + ), + commands = mapOf( + ), + children = false, + layout = true, + ) + + val Loading = ComponentSchema( + type = "Loading", + props = mapOf( + "size" to PropSpec.Dp(default = 24.0, required = false, initial = false), + "color" to PropSpec.ColorSpec(default = "primary", required = false, initial = false), + ), + events = mapOf( + ), + commands = mapOf( + ), + children = false, + layout = true, + ) + + val all: List = listOf(Column, Row, Box, Text, Button, RadioButton, TextField, LazyColumn, Spacer, Icon, Loading) /** Theme token names the schema DSL knows; Theme.kt must resolve each (checked by test). */ val colorTokens: Set = setOf("primary", "onPrimary", "primaryContainer", "onPrimaryContainer", "inversePrimary", "secondary", "onSecondary", "secondaryContainer", "onSecondaryContainer", "tertiary", "onTertiary", "tertiaryContainer", "onTertiaryContainer", "background", "onBackground", "surface", "onSurface", "surfaceVariant", "onSurfaceVariant", "surfaceTint", "inverseSurface", "inverseOnSurface", "error", "onError", "errorContainer", "onErrorContainer", "outline", "outlineVariant", "scrim", "surfaceBright", "surfaceDim", "surfaceContainer", "surfaceContainerHigh", "surfaceContainerHighest", "surfaceContainerLow", "surfaceContainerLowest", "primaryFixed", "primaryFixedDim", "onPrimaryFixed", "onPrimaryFixedVariant", "secondaryFixed", "secondaryFixedDim", "onSecondaryFixed", "onSecondaryFixedVariant", "tertiaryFixed", "tertiaryFixedDim", "onTertiaryFixed", "onTertiaryFixedVariant") diff --git a/compose/src/commonMain/kotlin/app/tinyui/generated/RuntimeBytecode.kt b/compose/src/commonMain/kotlin/app/tinyui/generated/RuntimeBytecode.kt index 7702d08..b413d8d 100644 --- a/compose/src/commonMain/kotlin/app/tinyui/generated/RuntimeBytecode.kt +++ b/compose/src/commonMain/kotlin/app/tinyui/generated/RuntimeBytecode.kt @@ -4,10 +4,10 @@ package app.tinyui.generated /** Base64 of the runtime modules' bytecode, split to fit JVM string constants. */ internal object RuntimeBytecode { val core: Array = arrayOf( - "UUpLQjQAAAABAAAAMDRiZTI0NjAwMTU5OWY1OTk1ZmEyZjJkOGM5MWEwZjE5OGQzZjM0YwWVAhZ0aW55dWktY29yZQZCb3gMQnV0dG9uDENvbHVtbgZGb3IQRnJhZ21lbnQSSG9zdEVycm9yFExhenlDb2x1bW4WUmFkaW9CdXR0b24GUm93CFNob3cMU3BhY2VyCFRleHQSVGV4dEZpZWxkDlZFUlNJT04MZWZmZWN0AmgQaW50ZXJuYWwQbWFuaWZlc3QIbWVtbxRvYnNlcnZhYmxlEm9uQ2xlYW51cBZwYWdlVmlzaWJsZQZyZWYQcmVzb3VyY2UMc2lnbmFsCnRodW5rDnVudHJhY2sMdW53cmFwEl9fZGVmUHJvcBBfX2V4cG9ydBpjdXJyZW50RWZmZWN0GGN1cnJlbnRPd25lcg5wZW5kaW5nBnJhbiRNQVhfUlVOU19QRVJfRkxVU0gWY3JlYXRlT3duZXIYcnVuV2l0aE93bmVyGGRpc3Bvc2VPd25lchhpbnNpZGVSZW5kZXIQdHJhY2tpbmcUcnVuUGVuZGluZyRjcmVhdGVFZmZlY3RSZWNvcmQScnVuRWZmZWN0GmRpc3Bvc2VFZmZlY3QWdW5zdWJzY3JpYmUWcnVuQ2xlYW51cHMKdHJhY2sMbm90aWZ5DkNPTlRST0wKVGh1bmsOcGF0Y2hlcxBoYW5kbGVycwxuZXh0SWQQcmVzZXRJZHMIU2xvdBpjYWxsQ29tcG9uZW50GmNyZWF0ZUVsZW1lbnQQYmluZFByb3AMc2NhbGFyDmZsYXR0ZW4acmVuZGVySW5Pd25lchBwZW5kaW5nMhBuZXh0Q2JJZApxdWVyeQhjYWxsCHNlbmQMcmVwb3J0HHJlc29sdmVQZW5kaW5nGnJlamVjdFBlbmRpbmcaY2FuY2VsUGVuZGluZxhhbGxvY2F0ZUNiSWQecmVnaXN0ZXJQZW5kaW5nFHNldFRpbWVvdXQYY2xlYXJUaW1lb3V0HGluc3RhbGxHbG9iYWxzCHJvb3QOY3VycmVudA52aXNpYmxlFHNldFZpc2libGUMdG9waWNzDG9uRW1pdA5ndWFyZGVkDmVudHJpZXMOaW5zdGFsbAZSQVcIS0VZUw5wcm94aWVzFnN1YnNjcmliZXJzFHVud3JhcERlZXAadW53cmFwU2hhbGxvdxZpc1dyYXBwYWJsZQh3cmFwCHN1YnMOb2JzZXJ2ZQ5jaGFuZ2VkDmlzSW5kZXgSaGFuZGxlcnMyIGludGVybmFsX2V4cG9ydHMYX19ob3N0X2FwcGx5GF9faG9zdF9xdWVyeRZfX2hvc3RfY2FsbBZfX2hvc3Rfc2VuZBpfX2hvc3RfcmVwb3J0DlJlZmxlY3QKMC43LjAcdGlueXVpLmNvbnRyb2wKbW91bnQMaW5zZXJ0CG1vdmUMcmVtb3ZlDG9mZnNldAxhc3NpZ24UY29tcG9uZW50cxhjYXBhYmlsaXRpZXMOdW5tb3VudBBkaXNwYXRjaAhlbWl0CmZsdXNoFHRpbnl1aS5yYXcWdGlueXVpLmtleXMGYWxsDmVmZmVjdHMQY2xlYW51cHMQZGlzcG9zZWQKb3duZXIEZm4ScHJldk93bmVyFHByZXZFZmZlY3QCZQJjDHNwbGljZQ5yZXZlcnNlCGluaXQKc3ViczICdg5lZmZlY3QyAm0KZGlydHkIcHVzaGxvbkNsZWFudXAoKSBjYWxsZWQgb3V0c2lkZSBhIHN5bmNocm9ub3VzIHJlbmRlciBwZXJpb2QIcHJldgJpDHF1ZXVlZAhydW5zfHVwZGF0ZSBsb29wOiBhbiBlZmZlY3QgcmUtcmFuIG1vcmUgdGhhbiAxMDAgdGltZXMgaW4gb25lIGZsdXNoZmVmZmVjdCgpIGNhbGxlZCBvdXRzaWRlIGEgc3luY2hyb25vdXMgcmVuZGVyIHBlcmlvZAhkZXBzDHBhcmVudBBlbnRyaWVzMhBwb3NpdGlvbgpub2Rlcwhub2RlBGF0BHRvDmluZGV4T2YsbW92ZTogbm9kZSBub3QgaW4gc2xvdDByZW1vdmU6IG5vZGUgbm90IGluIHNsb3QCcgJuBGlkBmNtZAhhcmdzXnJlZi5jbWQoKSBiZWZvcmUgdGhlIHJlZiB3YXMgYXR0YWNoZWQgdG8gYSBub2RlAngSRnJhZ21lbnQyCnByb3BzEGNoaWxkcmVuCHR5cGVcaCgpIGNhbGxlZCBvdXRzaWRlIGEgc3luY2hyb25vdXMgcmVuZGVyIHBlcmlvZAJwBmtleQxyZXN1bHQId2hhdHxhIFByb21pc2UgKGFzeW5jIGNvbXBvbmVudHMgYXJlIG5vdCBzdXBwb3J0ZWQ7IHVzZSByZXNvdXJjZSgpKQ5pc0FycmF5VHNldmVyYWwgbm9kZXMgKHdyYXAgdGhlbSBpbiBvbmUgY29udGFpbmVyKU5hIEZvciAvIFNob3cgKHdyYXAgaXQgaW4gb25lIGNvbnRhaW5lcikUY29tcG9uZW50IBY8YW5vbnltb3VzPkYgbXVzdCByZXR1cm4gZXhhY3RseSBvbmUgbm9kZSwgZ290IAJrCmNoaWxkCHRlc3QCPAIgKD4gbXVzdCBiZSBhIGZ1bmN0aW9uAjoIbGFzdApmaXJzdAJ0Vj4gaXMgYSBmdW5jdGlvbjsgZGlkIHlvdSBmb3JnZXQgdG8gY2FsbCBpdD+qAT4gbXVzdCBiZSBhIHN0cmluZywgbnVtYmVyLCBib29sZWFuIG9yIG51bGw7IHByb3BzIGRvIG5vdCBjcm9zcyB0aGUgYnJpZGdlIGFzIG9iamVjdHMGb3V0DHJlbmRlcjogbXVzdCByZXR1cm4gZXhhY3RseSBvbmUgbm9kZQhjb2RlEnBhdGNoSnNvbgpwYXJzZRJzdHJpbmdpZnkIY2JJZAhzaXRlCGtpbmQMZGV0YWlsFHJlc3VsdEpzb24URV9CQURfSlNPTjJob3N0IHJlc3VsdCBpcyBub3QgSlNPTjogEmVycm9ySnNvbgplcnJvciBob3N0IGNhbGwgZmFpbGVkBG1zHHRpbWVyLnNjaGVkdWxlGHRpbWVyLmNhbmNlbAJnCnRvcGljZm9uRW1pdCgpIGNhbGxlZCBvdXRzaWRlIGEgc3luY2hyb25vdXMgcmVuZGVyIHBlcmlvZAplbnRyeQZlcnIERTEIcGFnZRJwcm9wc0pzb24QaG9zdEpzb24obW91bnQoKSBjYWxsZWQgdHdpY2VOcGFnZSBtdXN0IHJldHVybiBleGFjdGx5IG9uZSBub2RlLCBnb3QgEmEgUHJvbWlzZQpjbGVhcgxub2RlSWQKZXZlbnQWcGF5bG9hZEpzb24OaGFuZGxlcg5wYXlsb2FkCGpzb24QX190aW55dWlab2JzZXJ2YWJsZSgpIHRha2VzIGEgcGxhaW4gb2JqZWN0IG9yIGFuIGFycmF5CHNlZW4CdQpwcm90bxBpc0Zyb3plbgxjcmVhdGUCcxByZWNlaXZlcgJkBmhhZAZvbGQGbm93HGhhc093blByb3BlcnR5ClNob3cyCHNs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AQAA7Q5+AOjxDoIp5gMR9gUMEhQRCBsOJjUHGhEGGwcADEMGAZYEAQgBBQUEAqcBCf4HAQRAgAgAALCCCAEB8IQIAgCwhggDAvCGBwQAsIgIBQPw3gUGAOCKCAcAsLQEC0IBzgJjAwCYBA1CAZAEEEIBXgcAXgYAXgUAXgQAXgMAXgIAXgEAXgAA2+yU6A44AQARBAYCAAAhAQAwBhHw6Q17fgAOx34ADsiC6gcO3Qbt6u8GEfDpDXt+AA7JfgAOyoLqBw7dCe3q7wYR8OkPe34ADsEEfgAOwQWC6gcO3Qbt6u0JwQbevgDtDr4BSgUCAADBB18HAOwOXwAAC18CAEkCAgAAXwQAScMBAABfBwBJBwIAACYCACjmAy3+BQB7DgcYFwkvChEdYiwMDGwADAx2EwwMFyUNGRESMgIAAh4TEQYNCmZCOVEADEIGAQAAAAACAAEABgDeBQYQAAoRZQAAKeYDBYUGDAMPAAxCBgEAAAAABAAFAh0A3gUGEACCCAEYAIYIAxgAiAgFGAD+BwABAGQCAArtDmQDAAbtDlsEAOw+hAAAAL4AvgEkAgAp5gMTiAYOAxMWFA0TFhANDxEOBwQvCgAMQgYBAAEAAQIBAwETAfQFAQBA3gUAEgCCCAEaAIYIAhoAZAAA6AIpZAEAvgDtDmQCAAntKeYDD4sGEwMRHAMIAxsODQ0WFAAMQgYBAAAAAAEAAQACAPQFAAEA2yjmAwWOBg4CDAAMQgYBAAEAAQIAAwASAegFAQAA3gUAEgCICAMaAIYIAhoAZAAA6AIpZAEAz+0OZAIACe0p5gMRkAYHAwYcAwgDERIHAQ0PFhQADAIGAQAAAAABAAEAAgDoBDBCAdso5gMFngYIAgwADAIGAQAAAAABAAEAAgCIBUJCAdso5gMFnwYKAgwADAIGAQAAAAABAAEAAgDmBC9CAdso5gMFoAYJAgwADAIGAQAAAAABAAEAAgDqBDFCAdso5gMFoQYIAgwA", + "UUpLQjQAAAABAAAAMDRiZTI0NjAwMTU5OWY1OTk1ZmEyZjJkOGM5MWEwZjE5OGQzZjM0YwWbAhZ0aW55dWktY29yZQZCb3gMQnV0dG9uDENvbHVtbgZGb3IQRnJhZ21lbnQSSG9zdEVycm9yCEljb24UTGF6eUNvbHVtbg5Mb2FkaW5nFlJhZGlvQnV0dG9uBlJvdwhTaG93DFNwYWNlcghUZXh0ElRleHRGaWVsZA5WRVJTSU9ODGVmZmVjdAJoEGludGVybmFsEG1hbmlmZXN0CG1lbW8Ub2JzZXJ2YWJsZRJvbkNsZWFudXAWcGFnZVZpc2libGUGcmVmEHJlc291cmNlDHNpZ25hbAp0aHVuaw51bnRyYWNrDHVud3JhcBJfX2RlZlByb3AQX19leHBvcnQaY3VycmVudEVmZmVjdBhjdXJyZW50T3duZXIOcGVuZGluZwZyYW4kTUFYX1JVTlNfUEVSX0ZMVVNIFmNyZWF0ZU93bmVyGHJ1bldpdGhPd25lchhkaXNwb3NlT3duZXIYaW5zaWRlUmVuZGVyEHRyYWNraW5nFHJ1blBlbmRpbmckY3JlYXRlRWZmZWN0UmVjb3JkEnJ1bkVmZmVjdBpkaXNwb3NlRWZmZWN0FnVuc3Vic2NyaWJlFnJ1bkNsZWFudXBzCnRyYWNrDG5vdGlmeQ5DT05UUk9MClRodW5rDnBhdGNoZXMQaGFuZGxlcnMMbmV4dElkEHJlc2V0SWRzCFNsb3QaY2FsbENvbXBvbmVudBpjcmVhdGVFbGVtZW50EGJpbmRQcm9wDHNjYWxhcg5mbGF0dGVuGnJlbmRlckluT3duZXIQcGVuZGluZzIQbmV4dENiSWQKcXVlcnkIY2FsbAhzZW5kDHJlcG9ydBxyZXNvbHZlUGVuZGluZxpyZWplY3RQZW5kaW5nGmNhbmNlbFBlbmRpbmcYYWxsb2NhdGVDYklkHnJlZ2lzdGVyUGVuZGluZxRzZXRUaW1lb3V0GGNsZWFyVGltZW91dBxpbnN0YWxsR2xvYmFscwhyb290DmN1cnJlbnQOdmlzaWJsZRRzZXRWaXNpYmxlDHRvcGljcwxvbkVtaXQMbGlzdGVuDmd1YXJkZWQOZW50cmllcw5pbnN0YWxsBlJBVwhLRVlTDnByb3hpZXMWc3Vic2NyaWJlcnMUdW53cmFwRGVlcBp1bndyYXBTaGFsbG93FmlzV3JhcHBhYmxlCHdyYXAIc3Vicw5vYnNlcnZlDmNoYW5nZWQOaXNJbmRleBJoYW5kbGVyczIgaW50ZXJuYWxfZXhwb3J0cxhfX2hvc3RfYXBwbHkYX19ob3N0X3F1ZXJ5Fl9faG9zdF9jYWxsFl9faG9zdF9zZW5kGl9faG9zdF9yZXBvcnQOUmVmbGVjdAowLjcuMBx0aW55dWkuY29udHJvbAptb3VudAxpbnNlcnQIbW92ZQxyZW1vdmUMb2Zmc2V0DGFzc2lnbhRjb21wb25lbnRzGGNhcGFiaWxpdGllcw51bm1vdW50EGRpc3BhdGNoCGVtaXQKZmx1c2gUdGlueXVpLnJhdxZ0aW55dWkua2V5cwZhbGwOZWZmZWN0cxBjbGVhbnVwcxBkaXNwb3NlZApvd25lcgRmbhJwcmV2T3duZXIUcHJldkVmZmVjdAJlAmMMc3BsaWNlDnJldmVyc2UIaW5pdApzdWJzMgJ2DmVmZmVjdDICbQpkaXJ0eQhwdXNobG9uQ2xlYW51cCgpIGNhbGxlZCBvdXRzaWRlIGEgc3luY2hyb25vdXMgcmVuZGVyIHBlcmlvZAhwcmV2AmkMcXVldWVkCHJ1bnN8dXBkYXRlIGxvb3A6IGFuIGVmZmVjdCByZS1yYW4gbW9yZSB0aGFuIDEwMCB0aW1lcyBpbiBvbmUgZmx1c2hmZWZmZWN0KCkgY2FsbGVkIG91dHNpZGUgYSBzeW5jaHJvbm91cyByZW5kZXIgcGVyaW9kCGRlcHMMcGFyZW50EGVudHJpZXMyEHBvc2l0aW9uCm5vZGVzCG5vZGUEYXQEdG8OaW5kZXhPZixtb3ZlOiBub2RlIG5vdCBpbiBzbG90MHJlbW92ZTogbm9kZSBub3QgaW4gc2xvdAJyAm4EaWQGY21kCGFyZ3NecmVmLmNtZCgpIGJlZm9yZSB0aGUgcmVmIHdhcyBhdHRhY2hlZCB0byBhIG5vZGUCeBJGcmFnbWVudDIKcHJvcHMQY2hpbGRyZW4IdHlwZVxoKCkgY2FsbGVkIG91dHNpZGUgYSBzeW5jaHJvbm91cyByZW5kZXIgcGVyaW9kAnAGa2V5DHJlc3VsdAh3aGF0fGEgUHJvbWlzZSAoYXN5bmMgY29tcG9uZW50cyBhcmUgbm90IHN1cHBvcnRlZDsgdXNlIHJlc291cmNlKCkpDmlzQXJyYXlUc2V2ZXJhbCBub2RlcyAod3JhcCB0aGVtIGluIG9uZSBjb250YWluZXIpTmEgRm9yIC8gU2hvdyAod3JhcCBpdCBpbiBvbmUgY29udGFpbmVyKRRjb21wb25lbnQgFjxhbm9ueW1vdXM+RiBtdXN0IHJldHVybiBleGFjdGx5IG9uZSBub2RlLCBnb3QgAmsKY2hpbGQIdGVzdAI8AiAoPiBtdXN0IGJlIGEgZnVuY3Rpb24COghsYXN0CmZpcnN0AnRWPiBpcyBhIGZ1bmN0aW9uOyBkaWQgeW91IGZvcmdldCB0byBjYWxsIGl0P6oBPiBtdXN0IGJlIGEgc3RyaW5nLCBudW1iZXIsIGJvb2xlYW4gb3IgbnVsbDsgcHJvcHMgZG8gbm90IGNyb3NzIHRoZSBicmlkZ2UgYXMgb2JqZWN0cwZvdXQMcmVuZGVyOiBtdXN0IHJldHVybiBleGFjdGx5IG9uZSBub2RlCGNvZGUOZGV0YWlscw5oZWFkZXJzCGJvZHkScGF0Y2hKc29uCnBhcnNlEnN0cmluZ2lmeQhjYklkCHNpdGUIa2luZAxkZXRhaWwUcmVzdWx0SnNvbhRFX0JBRF9KU09OMmhvc3QgcmVzdWx0IGlzIG5vdCBKU09OOiASZXJyb3JKc29uCmVycm9yIGhvc3QgY2FsbCBmYWlsZWQEbXMcdGltZXIuc2NoZWR1bGUYdGltZXIuY2FuY2VsAmcKdG9waWNmb25FbWl0KCkgY2FsbGVkIG91dHNpZGUgYSBzeW5jaHJvbm91cyByZW5kZXIgcGVyaW9kCmVudHJ5BmVycgRFMQhwYWdlEnByb3BzSnNvbhBob3N0SnNvbihtb3VudCgpIGNhbGxlZCB0d2ljZU5wYWdlIG11c3QgcmV0dXJuIGV4YWN0bHkgb25lIG5vZGUsIGdvdCASYSBQcm9taXNlCmNsZWFyDG5vZGVJZApldmVudBZwYXlsb2FkSnNvbg5oYW5kbGVyDnBheWxvYWQIanNvbhBfX3Rpbnl1aVpvYnNlcnZhYmxlKCkgdGFrZXMgYSBwbGFpbiBvYmplY3Qgb3IgYW4gYXJyYXkIc2VlbgJ1CnByb3RvEGlzRnJvemVuDGNyZWF0ZQJzEHJlY2Vp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", ) val native: Array = arrayOf( - "UUpLQjQAAAABAAAAMDRiZTI0NjAwMTU5OWY1OTk1ZmEyZjJkOGM5MWEwZjE5OGQzZjM0YwVCGnRpbnl1aS1uYXRpdmUWdGlueXVpLWNvcmUSSG9zdEVycm9yDlZFUlNJT04MZXZlbnRzCGhvc3QIaHR0cBRuYXZpZ2F0aW9uCnN0b3JlEGludGVybmFsDHNpZ25hbBJpbnRlcm5hbDISaW50ZXJuYWwzEmludGVybmFsNBJpbnRlcm5hbDUSX19kZWZQcm9wEF9fZXhwb3J0JG5hdmlnYXRpb25fZXhwb3J0cwhwdXNoBnBvcBBvblJlc3VsdBpzdG9yZV9leHBvcnRzCndhdGNoHGV2ZW50c19leHBvcnRzCGVtaXQEb24YaHR0cF9leHBvcnRzDnJlcXVlc3QIZ2V0Mghwb3N0GGhvc3RfZXhwb3J0cwhjYWxsCjAuNy4wBmFsbAhwYWdlDHBhcmFtcwhzZW5kHm5hdmlnYXRpb24ucHVzaAxyZXN1bHQcbmF2aWdhdGlvbi5wb3AEZm4Mb25FbWl0Im5hdmlnYXRpb24ucmVzdWx0AnACcgZrZXkCdgpxdWVyeRJzdG9yZS5nZXQSc3RvcmUuc2V0EnN0cmluZ2lmeRBzZXRWYWx1ZR5zdG9yZS5zdWJzY3JpYmUMc3RvcmU6CnRvcGljDnBheWxvYWQWZXZlbnRzLmVtaXQgZXZlbnRzLnN1YnNjcmliZQxtZXRob2QGdXJsCGJvZHkOb3B0aW9ucxhodHRwLnJlcXVlc3QGR0VUCFBPU1QIYXJncw3mAwboAwLoAwLoAwLoAwLoAwLoAwIHAAbqAwAa7AMAEe4DABjwAwAU8gMACfQDAA32AwAHAAD4AwABAPgDAQIA+gMBAwD4AwIEAPgDAwUA+AMEBgDqAwUADCAGAagBAAAABAAdGboCAPgDAB8A/AMBHwD6AwIfAP4DAx8AgAQEHwCCBAUfAOoDBh8AhAQABgCGBAEGAIgEAgYAigQDRgGMBARGAY4EBUYBkAQGBgCKAQdGAYwBCEYBkgQJRgGUBAoGAJYEC0YBmAQMRgGaBA0GAJwEDkYBngQPBgCgBBAGAKIEEQYApAQSRgHsAxMGANwCAAUAygIABQAI6DS+BFwKAL4FXAsAvgZcDAC+ClwOAL4LXA8AvgxcEAC+D1wSAL4QXBMAvhRcFQC+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AADQSS4BAADRBqoR6AkOC9FJLwEAAAdQBg4O0gdQBg4OJQIA5gMPSACKEhESdVAMClc2IK8BAAwCBgEAAgACBQABAAwC3AQBAADgBAEAAJwEFUIB2wQyAQAAzwbQIwQA5gMKSwsCJCAeDBoHKQAMAgYBAAMAAwUAAQAMA9wEAQAA3gQBAADgBAEAAJwEFUIB2wQzAQAAz9DRIwQA5gMMTAsCMCAgBwoHDAcnAAwCBgEAAAAAAQABAAIApAQZQgHbKOYDBFEIAgwADEEGAaQEAgIBBAABAB4EdAEAAOgEAQAAdP////8PAKDoBAEAoIIEBRoAXgEAXgAAz8fQEfDoBA4L2MhkAAA+EgEAAM/QJQIA5gMMVABYEhESGwwHDAc3AA==", + "UUpLQjQAAAABAAAAMDRiZTI0NjAwMTU5OWY1OTk1ZmEyZjJkOGM5MWEwZjE5OGQzZjM0YwWmARp0aW55dWktbmF0aXZlFnRpbnl1aS1jb3JlEkhvc3RFcnJvcg5WRVJTSU9OEmFuYWx5dGljcwxjYWNoZWQMZXZlbnRzCGhvc3QIaHR0cAhpMThuDmxpbmtpbmcUbmF2aWdhdGlvbg5zZXNzaW9uDnN0b3JhZ2UKc3RvcmUEdWkQaW50ZXJuYWwMc2lnbmFsEm9uQ2xlYW51cBJpbnRlcm5hbDISaW50ZXJuYWwzEmludGVybmFsNBJpbnRlcm5hbDUSaW50ZXJuYWw2DnNpZ25hbDISaW50ZXJuYWw3DnNpZ25hbDMSaW50ZXJuYWw4DnNpZ25hbDQSaW50ZXJuYWw5FGludGVybmFsMTAUSG9zdEVycm9yMhRpbnRlcm5hbDExFEhvc3RFcnJvcjMSX19kZWZQcm9wEF9fZXhwb3J0JG5hdmlnYXRpb25fZXhwb3J0cwhwdXNoBnBvcBBvblJlc3VsdBpzdG9yZV9leHBvcnRzCndhdGNoHGV2ZW50c19leHBvcnRzCGVtaXQEb24YaHR0cF9leHBvcnRzDmNoYW5uZWwQZmFsbGJhY2sMY2xpZW50DnJlcXVlc3QIZ2V0Mghwb3N0BnB1dAZkZWwYaG9zdF9leHBvcnRzCGNhbGwec3RvcmFnZV9leHBvcnRzCGdldDMIc2V0MgxyZW1vdmUKY2xlYXIYaTE4bl9leHBvcnRzDmN1cnJlbnQUc2V0Q3VycmVudBRzdWJzY3JpYmVkDGZvbGxvdwxsb2NhbGUCdB5zZXNzaW9uX2V4cG9ydHMQY3VycmVudDIWc2V0Q3VycmVudDIWc3Vic2NyaWJlZDIKc3RhdGUMc2lnbkluFHVpX2V4cG9ydHMKdG9hc3QKYWxlcnQOY29uZmlybR5saW5raW5nX2V4cG9ydHMOb3BlblVybCJhbmFseXRpY3NfZXhwb3J0cwp0cmFjaxBsb2dnZWRJbgx1c2VySWQKMC43LjAGYWxsCHBhZ2UMcGFyYW1zCHNlbmQebmF2aWdhdGlvbi5wdXNoDHJlc3VsdBxuYXZpZ2F0aW9uLnBvcARmbgxvbkVtaXQibmF2aWdhdGlvbi5yZXN1bHQCcAJyBmtleQJ2CnF1ZXJ5EnN0b3JlLmdldBJzdG9yZS5zZXQSc3RyaW5naWZ5EHNldFZhbHVlHnN0b3JlLnN1YnNjcmliZQxzdG9yZToKdG9waWMOcGF5bG9hZBZldmVudHMuZW1pdCBldmVudHMuc3Vic2NyaWJlEHJlcXVlc3QyDG1ldGhvZAZ1cmwIYm9keQ5vcHRpb25zGGh0dHAucmVxdWVzdAZHRVQIUE9TVAZQVVQMREVMRVRFCGFyZ3MWc3RvcmFnZS5nZXQIY29kZRZzdG9yYWdlLnNldBpzdG9yYWdlLnNldCgiFiIpIGZhaWxlZDogHHN0b3JhZ2UucmVtb3ZlGnN0b3JhZ2UuY2xlYXIOZmV0Y2hlcghkYXRhDnNldERhdGEObG9hZGluZxRzZXRMb2FkaW5nCmVycm9yEHNldEVycm9yEGRpc3Bvc2VkDGxhdGVzdAZydW4OcmVmZXRjaAhydW4yEGN1cnJlbnQzDnBlbmRpbmcCZRZpMThuLmxvY2FsZRxpMThuLnN1YnNjcmliZQxsaXN0ZW4IcmVzdAh0ZXh0DGkxOG4udA5yZXBsYWNlAm0Wc2Vzc2lvbi5nZXQic2Vzc2lvbi5zdWJzY3JpYmUcc2Vzc2lvbi5zaWduSW4QdWkudG9hc3QQdWkuYWxlcnQUdWkuY29uZmlybR5saW5raW5nLm9wZW5VcmwKcHJvcHMCaw5lbnRyaWVzEkVfSU5WQUxJRCJhbmFseXRpY3MudHJhY2soIggiKTogUiBpcyBub3QgYSBzdHJpbmcsIG51bWJlciwgYm9vbGVhbiBvciBudWxsHmFuYWx5dGljcy50cmFjaw3mAwzoAwLoAwLoAwLoAwLoAwLoAwLoAwLoAwLoAwLoAwLoAwLoAwIOABLqAwBG7AMARO4DADDwAwAd8gMAKfQDACD2AwAx+AMAQvoDABX8AwA4/gMAK4AEABmCBAA+hAQAEwAAhgQAAQCGBAECAIgEAQMAhgQCBACGBAMFAIYEBAYA6gMFBwCGBAUIAIoEBQkAiAQFCgCGBAYLAIgEBgwAhgQHDQCIBAcOAIYECA8AhgQJEADqAwoRAIYEChIA6gMLAAwgBgGoAQAAAAUASzihBgCGBAAfAIwEAR8AiAQCHwCOBAMfAJAEBB8AkgQFHwDqAwYfAJQEBx8AigQIHwCWBAkfAJgECh8AmgQLHwCcBAwf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", ) } diff --git a/compose/src/commonMain/kotlin/app/tinyui/schema/ComponentSchema.kt b/compose/src/commonMain/kotlin/app/tinyui/schema/ComponentSchema.kt index bd1af6f..9712b23 100644 --- a/compose/src/commonMain/kotlin/app/tinyui/schema/ComponentSchema.kt +++ b/compose/src/commonMain/kotlin/app/tinyui/schema/ComponentSchema.kt @@ -2,6 +2,14 @@ package app.tinyui.schema import androidx.compose.runtime.Composable import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.SolidColor +import androidx.compose.ui.graphics.StrokeCap +import androidx.compose.ui.graphics.StrokeJoin +import androidx.compose.ui.graphics.vector.ImageVector +import androidx.compose.ui.graphics.vector.PathParser +import kotlin.concurrent.atomics.AtomicReference +import kotlin.concurrent.atomics.ExperimentalAtomicApi +import kotlin.concurrent.atomics.update import androidx.compose.ui.unit.Dp import androidx.compose.ui.unit.TextUnit import androidx.compose.ui.unit.dp @@ -84,8 +92,46 @@ sealed class PropSpec(val kind: String, val required: Boolean, val initial: Bool else -> text.toDoubleOrNull()?.let { SizeValue.Fixed(it.dp) } } } + + /** `"|"`, or `"||"` for an outlined icon (docs/components.md §3), parsed into an [ImageVector] once per distinct string. */ + class IconSpec(required: Boolean = false, initial: Boolean = false) : PropSpec("icon", required, initial) { + override val default: IconValue? = null + override val declared: Any? = null + override fun convert(value: JsonPrimitive): Any? = value.takeIf { it.isString }?.content?.let(::parseIcon) + } +} + +/** A converted `icon` prop. */ +class IconValue internal constructor(val vector: ImageVector) + +@OptIn(ExperimentalAtomicApi::class) +private val icons = AtomicReference>(emptyMap()) + +@OptIn(ExperimentalAtomicApi::class) +internal fun parseIcon(text: String): IconValue? { + icons.load()[text]?.let { return it } + val parts = text.split('|') + if (parts.size !in 2..3) return null + val box = parts[0].trim().split(Regex("[\\s,]+")).mapNotNull { it.toFloatOrNull() } + if (box.size != 4 || box[2] <= 0f || box[3] <= 0f) return null + val stroke = parts.getOrNull(2)?.let { it.toFloatOrNull() ?: return null } + val nodes = runCatching { PathParser().parsePathString(parts[1]).toNodes() }.getOrNull() ?: return null + val paint = SolidColor(Color.Black) + val vector = ImageVector.Builder(defaultWidth = 24.dp, defaultHeight = 24.dp, viewportWidth = box[2], viewportHeight = box[3]) + .addGroup(translationX = -box[0], translationY = -box[1]) + .apply { + if (stroke == null) addPath(nodes, fill = paint) + else addPath(nodes, fill = null, stroke = paint, strokeLineWidth = stroke, strokeLineCap = StrokeCap.Round, strokeLineJoin = StrokeJoin.Round) + } + .clearGroup() + .build() + val value = IconValue(vector) + icons.update { if (it.size >= ICON_CACHE) mapOf(text to value) else it + (text to value) } + return value } +private const val ICON_CACHE = 512 + /** A field of an event payload or command args (flat, scalar). */ enum class FieldSpec { Str, Num, Bool; @@ -138,6 +184,8 @@ object LayoutProps { "padding" to PropSpec.Dp(default = null), "paddingHorizontal" to PropSpec.Dp(default = null), "paddingVertical" to PropSpec.Dp(default = null), + "role" to PropSpec.Enum(values = setOf("button", "checkbox", "switch", "radio", "tab"), default = null), + "selected" to PropSpec.Bool(default = null), ) } diff --git a/compose/src/commonTest/kotlin/app/tinyui/HostBoundaryTest.kt b/compose/src/commonTest/kotlin/app/tinyui/HostBoundaryTest.kt new file mode 100644 index 0000000..689a07e --- /dev/null +++ b/compose/src/commonTest/kotlin/app/tinyui/HostBoundaryTest.kt @@ -0,0 +1,81 @@ +package app.tinyui + +import app.tinyui.schema.parseIcon +import io.ktor.client.HttpClient +import io.ktor.client.engine.mock.MockEngine +import io.ktor.client.engine.mock.respond +import io.ktor.client.request.HttpRequestData +import io.ktor.http.HttpStatusCode +import io.ktor.http.content.TextContent +import io.ktor.http.headersOf +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.delay +import kotlinx.coroutines.test.runTest +import kotlinx.coroutines.withContext +import okio.Path.Companion.toPath +import okio.fakefilesystem.FakeFileSystem +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertNotNull +import kotlin.test.assertNull +import kotlin.test.assertTrue + +/** The framework-owned pieces of ADR-007 that do not need an engine. */ +class HostBoundaryTest { + private val sink = object : PageSink { + override fun error(error: PageError) {} + override fun log(line: String) {} + } + + @Test + fun i18nFallsBackBySubtagThenToTheDefault() { + val i18n = PackageI18n("en", mapOf("en" to mapOf("a" to "A", "b" to "B"), "zh" to mapOf("a" to "甲"), "zh-Hant" to mapOf("a" to "甲T"))) + assertEquals("甲T", i18n.lookup("a", "zh-Hant-TW")) + assertEquals("甲", i18n.lookup("a", "zh_CN")) + assertEquals("B", i18n.lookup("b", "zh-Hant-TW"), "a key the language lacks comes from the default") + assertNull(i18n.lookup("c", "zh")) + } + + @Test + fun storageKeepsWritesAcrossALoadAndRefusesPastItsLimit() = runTest { + val fs = FakeFileSystem() + val file = "/data/storage/shop.json".toPath() + val scope = CoroutineScope(Dispatchers.Default) + val storage = PackageStorage(fs, file, scope, sink) + assertNull(storage.set("count", "3")) + assertEquals("3", storage.get("count")) + assertEquals("E_QUOTA", storage.set("big", "\"${"x".repeat(PackageStorage.LIMIT_CHARS)}\"")) + assertNull(storage.get("big")) + withContext(Dispatchers.Default) { while (!fs.exists(file)) delay(20) } + assertEquals("3", PackageStorage(fs, file, scope, sink).get("count"), "a fresh load reads what was written") + } + + @Test + fun iconsParseFilledAndOutlinedAndRefuseAnythingElse() { + assertNotNull(parseIcon("0 -960 960 960|M480-80L80-480Z")) + assertNotNull(parseIcon("0 0 24 24|M2 12L22 12|2")) + assertNull(parseIcon("M2 12L22 12")) + assertNull(parseIcon("0 0 24|M2 12Z")) + assertNull(parseIcon("0 0 24 24|M2 12Z|thick")) + } + + @Test + fun ktorChannelSendsTheBodyWithItsTypeAndReturnsAnyStatus() = runTest { + var seen: HttpRequestData? = null + val client = HttpClient(MockEngine { request -> + seen = request + respond("""{"error":"dup"}""", HttpStatusCode.Conflict, headersOf("Content-Type", "application/json")) + }) + val response = KtorChannel(client).request( + HttpRequest("POST", "https://api/x", mapOf("Content-Type" to "application/json", "X-Trace" to "1"), """{"plan":"annual"}""", null), + ) + assertEquals(409, response.status) + assertEquals("""{"error":"dup"}""", response.body) + assertEquals("application/json", response.headers["content-type"]) + val body = seen!!.body as TextContent + assertEquals("""{"plan":"annual"}""", body.text) + assertTrue(body.contentType.toString().startsWith("application/json")) + assertEquals("1", seen!!.headers["X-Trace"]) + } +} diff --git a/compose/src/commonTest/kotlin/app/tinyui/HostSnapshotTest.kt b/compose/src/commonTest/kotlin/app/tinyui/HostSnapshotTest.kt index 1b41139..41ea9be 100644 --- a/compose/src/commonTest/kotlin/app/tinyui/HostSnapshotTest.kt +++ b/compose/src/commonTest/kotlin/app/tinyui/HostSnapshotTest.kt @@ -6,6 +6,7 @@ import app.tinyui.schema.ComponentSchema import app.tinyui.schema.FieldSpec import app.tinyui.schema.PropSpec import kotlin.test.Test +import kotlinx.coroutines.flow.MutableStateFlow import kotlin.test.assertEquals import kotlin.test.assertFailsWith @@ -38,13 +39,15 @@ class HostSnapshotTest { children = true, layout = false, ) - private fun host(capabilities: CapabilityRegistry = CapabilityRegistry()) = TinyUIHost( + private fun host(capabilities: CapabilityRegistry = CapabilityRegistry(), channels: Map = emptyMap(), session: SessionSource? = null) = TinyUIHost( ComponentRegistry().registerBuiltins().apply { register(rating) {} register(icon) {} }, sink, capabilities, + channels = channels, + session = session, ) @Test @@ -65,9 +68,21 @@ class HostSnapshotTest { capabilities checkout.start coupon.apply + session.signIn + + channels + app + pay """.trimIndent(), - HostSnapshot.render(host(capabilities), "3"), + HostSnapshot.render( + host( + capabilities, + channels = mapOf("pay" to HttpChannel { error("unused") }, "app" to HttpChannel { error("unused") }), + session = SessionSource(MutableStateFlow(Session.LoggedOut)) {}, + ), + "3", + ), ) } @@ -87,13 +102,22 @@ class HostSnapshotTest { assertEquals(1, HostSnapshot.problems(snapshot, host, "4").size) val newer = HostSnapshot.problems(snapshot.replace("tinyui ${TinyUI.version}", "tinyui 99.0.0"), host, "3") assertEquals(listOf("tinyui ${TinyUI.version} is not compatible with this host version's lower bound 99.0.0 (same major, not older)"), newer) - assertEquals(listOf("components or capabilities differ from the snapshot"), HostSnapshot.problems(snapshot.replace(" ta.Icon", " ta.Icon2"), host, "3")) + assertEquals(listOf("components, capabilities or channels differ from the snapshot"), HostSnapshot.problems(snapshot.replace(" ta.Icon", " ta.Icon2"), host, "3")) + } + + @Test + fun writesEverySectionEvenWhenEmpty() { + val bare = TinyUIHost(ComponentRegistry().registerBuiltins(), sink) + assertEquals("hostVersion 1\ntinyui ${TinyUI.version}\n\ncomponents\n\ncapabilities\n\nchannels\n", HostSnapshot.render(bare, "1")) } @Test - fun writesBothSectionsEvenWhenEmpty() { + fun aSnapshotFrozenBeforeChannelsMatchesAHostWithout() { val bare = TinyUIHost(ComponentRegistry().registerBuiltins(), sink) - assertEquals("hostVersion 1\ntinyui ${TinyUI.version}\n\ncomponents\n\ncapabilities\n", HostSnapshot.render(bare, "1")) + val old = "hostVersion 1\ntinyui ${TinyUI.version}\n\ncomponents\n\ncapabilities\n" + assertEquals(emptyList(), HostSnapshot.problems(old, bare, "1")) + val withChannel = TinyUIHost(ComponentRegistry().registerBuiltins(), sink, channels = mapOf("app" to HttpChannel { error("unused") })) + assertEquals(1, HostSnapshot.problems(old, withChannel, "1").size) } @Test diff --git a/compose/src/commonTest/kotlin/app/tinyui/PageHostTest.kt b/compose/src/commonTest/kotlin/app/tinyui/PageHostTest.kt index 93b1a18..f4b785f 100644 --- a/compose/src/commonTest/kotlin/app/tinyui/PageHostTest.kt +++ b/compose/src/commonTest/kotlin/app/tinyui/PageHostTest.kt @@ -13,6 +13,8 @@ import kotlin.test.Test import kotlin.test.assertEquals import kotlin.test.assertNotNull import kotlin.test.assertNull +import kotlin.test.assertTrue +import kotlinx.coroutines.flow.MutableStateFlow /** Drives PageHost with a hand-written stand-in for `tinyui-core`: the K/J entry shapes, not the real runtime. */ class PageHostTest { @@ -41,10 +43,17 @@ class PageHostTest { if (event === "onBoom") { try { throw new Error("boom"); } catch (e) { __host_report("E1", JSON.stringify({ entry: "dispatch", message: e.message, stack: e.stack })); } } if (event === "onReject") Promise.reject(new Error("nobody catches")); if (event === "onQuery") patches.push(["p",1,"text", __host_query("store.get", JSON.stringify({ key: "cart" })) + "|" + __host_query("i18n.t", JSON.stringify({ key: "hi" })) + "|" + JSON.parse(__host_query("device.info", "{}")).os]); - if (event === "onHttp") __host_call("http.request", 9, JSON.stringify({ method: "GET", url: "/todos" })); + if (event === "onHttp") __host_call("http.request", 9, JSON.stringify({ channel: "app", method: "GET", url: "/todos" })); if (event === "onCapability") { __host_call("checkout.start", 11, JSON.stringify({ plan: "annual" })); __host_call("checkout.start", 12, JSON.stringify({ plan: "weekly" })); __host_call("nobody.home", 13, "{}"); } if (event === "onSubscribe") { __host_send("store.subscribe", JSON.stringify({ key: "cart" })); __host_send("store.subscribe", JSON.stringify({ key: "cart" })); __host_send("events.subscribe", JSON.stringify({ topic: "net" })); __host_send("events.subscribe", JSON.stringify({ topic: "net" })); patches.push(["p",1,"text","subscribed"]); } if (event === "onSet") __host_send("store.set", JSON.stringify({ key: "cart", value: payloadJson })); + if (event === "onQueries") patches.push(["p",1,"text", [__host_query("i18n.locale", "{}"), __host_query("i18n.t", JSON.stringify({ key: "missing" })), __host_query("session.get", "{}"), __host_query("storage.set", JSON.stringify({ key: "k", value: "1" }))].join("|")]); + if (event === "onFramework") { + __host_send("analytics.track", JSON.stringify({ name: "tap", props: { n: 2, ok: true, s: "x" } })); + __host_call("http.request", 14, JSON.stringify({ channel: "app", method: "GET", url: "/gone" })); + __host_call("http.request", 15, JSON.stringify({ channel: "nope", method: "GET", url: "/x" })); + __host_call("session.signIn", 16, "{}"); + } }, emit(topic, json) { emits++; patches.push(["p",1,"text", topic + " " + json + " #" + emits]); }, flush() { if (count > 2) throw new Error("render exploded"); if (patches.length) { __host_apply(JSON.stringify(patches)); patches = []; } }, @@ -52,27 +61,29 @@ class PageHostTest { export const VERSION = "stub"; """) private val native = module("tinyui-native", "export const NATIVE = 1;") - private val page = PageModule("pages/counter", module("pages/counter", "export default function Counter() { return 'page'; }"), "abcd1234") - - private val store = InMemoryStore().apply { set("cart", """{"n":2}""") } - private val services = HostServices( - store = store, - i18n = I18n { key, _ -> "hello-$key" }, - deviceInfo = mapOf("app" to "test"), - http = object : HttpClient { - override suspend fun request(request: HttpRequest): HttpResponse = - if (request.url == "/todos") HttpResponse(200, """[{"id":1}]""") else throw HostException("E_HTTP", "404") - }, - locals = listOf(Merchant provides "acme"), + private val page = PageModule( + "pages/counter", module("pages/counter", "export default function Counter() { return 'page'; }"), "abcd1234", + PackageI18n("en", mapOf("en" to mapOf("hi" to "hello-hi"))), ) - private val tinyui = TinyUIHost(ComponentRegistry().registerBuiltins(), sink, CapabilityRegistry().apply { - register("checkout.start") { args, page -> - if (args.contains("annual")) """{"url":"https://pay/${page[Merchant]}/${page.name}"}""" else throw HostException("E_INVALID", "no such plan") - } - }) + + private val tinyui = TinyUIHost( + ComponentRegistry().registerBuiltins(), sink, + CapabilityRegistry().apply { + register("checkout.start") { args, page -> + if (args.contains("annual")) """{"url":"https://pay/${page[Merchant]}/${page.name}"}""" else throw HostException("E_INVALID", "no such plan") + } + }, + channels = mapOf("app" to HttpChannel { request -> + if (request.url == "/todos") HttpResponse(200, mapOf("content-type" to "application/json"), """[{"id":1}]""") else HttpResponse(404, emptyMap(), """{"gone":true}""") + }), + locale = MutableStateFlow("en"), + analytics = AnalyticsSink { name, props, page -> tracked += "${page.name} $name $props" }, + ).apply { store.setJson("cart", """{"n":2}""") } + private val tracked = mutableListOf() + private val store = tinyui.store private fun host(props: String = "{}", maps: SourceMaps = SourceMaps.EMPTY) = - PageHost(RuntimeBundle(core, native), page, tinyui, services, props, sourceMaps = maps) + PageHost(RuntimeBundle(core, native), page, tinyui, props, listOf(Merchant provides "acme"), sourceMaps = maps) // runTest's virtual time never advances the real engine thread: wait on a real dispatcher private suspend fun PageHost.await(check: () -> Boolean) = @@ -94,7 +105,7 @@ class PageHostTest { val host = host("""{"name":"tinyui"}""") host.start() host.await { host.tree.root.children.isNotEmpty() } - assertEquals("hello tinyui / 9", host.tree.node(1)!!.props["text"]) + assertEquals("hello tinyui / 11", host.tree.node(1)!!.props["text"]) assertNull(host.failure) host.close() } @@ -174,7 +185,7 @@ class PageHostTest { host.dispatch(2, "onQuery", "{}") host.await { text() == """{"n":2}|"hello-hi"|${platformInfo()["os"]}""" } host.dispatch(2, "onHttp", "{}") - host.await { text() == """http {"status":200,"body":[{"id":1}]}""" } + host.await { text() == """http {"status":200,"headers":{"content-type":"application/json"},"body":[{"id":1}]}""" } host.dispatch(2, "onCapability", "{}") host.await { text()?.count { it == ';' } == 2 } assertEquals( @@ -183,16 +194,46 @@ class PageHostTest { ) host.dispatch(2, "onSubscribe", "{}") host.await { text() == "subscribed" } - services.events.emit("net", """{"online":false}""") + tinyui.events.emit("net", """{"online":false}""") host.await { text() == """net {"online":false} #1""" } host.dispatch(2, "onSet", """{"n":3}""") host.await { text() == """store:cart {"value":{"n":3}} #2""" } - assertEquals("""{"n":3}""", store.get("cart")) + assertEquals("""{"n":3}""", store.getJson("cart")) delay(100) assertEquals("""store:cart {"value":{"n":3}} #2""", text(), "two subscribe calls still deliver each change once") host.close() } + @Test + fun frameworkInterfacesWorkWithoutTheHostImplementingThem() = runTest { + val host = host() + host.start() + host.await { host.tree.root.children.isNotEmpty() } + val text = { host.tree.node(1)!!.props["text"] as? String } + host.dispatch(2, "onFramework", "{}") + host.await { text()?.count { it == ';' } == 2 } + assertEquals( + """rejected 14 {"status":404,"headers":{},"body":{"gone":true},"code":"E_HTTP","message":"HTTP 404"};""" + + """rejected 15 {"code":"E_UNSUPPORTED","message":"the host has no http channel \"nope\""};""" + + """rejected 16 {"code":"E_UNSUPPORTED","message":"the host provides no session"}""", + text(), + ) + assertEquals(listOf("pages/counter tap {n=2, ok=true, s=x}"), tracked) + host.close() + } + + @Test + fun frameworkQueriesAnswerWithDefaultsWhereTheHostGaveNothing() = runTest { + val host = host() + host.start() + host.await { host.tree.root.children.isNotEmpty() } + val text = { host.tree.node(1)!!.props["text"] as? String } + host.dispatch(2, "onQueries", "{}") + host.await { text()?.startsWith("\"en\"") == true } + assertEquals(""""en"|"missing"|{"loggedIn":false,"userId":null}|"E_UNSUPPORTED"""", text(), "no session, no data directory") + host.close() + } + @Test fun renderErrorsFailThePage() = runTest { val host = host() diff --git a/compose/src/iosMain/kotlin/app/tinyui/Platform.ios.kt b/compose/src/iosMain/kotlin/app/tinyui/Platform.ios.kt index 32ca363..b607080 100644 --- a/compose/src/iosMain/kotlin/app/tinyui/Platform.ios.kt +++ b/compose/src/iosMain/kotlin/app/tinyui/Platform.ios.kt @@ -1,5 +1,12 @@ package app.tinyui +import io.ktor.client.HttpClient +import io.ktor.client.engine.darwin.Darwin +import okio.FileSystem +import platform.Foundation.NSLocale +import platform.Foundation.currentLocale +import platform.Foundation.localeIdentifier +import platform.Foundation.preferredLanguages import platform.UIKit.UIDevice internal actual fun platformInfo(): Map = mapOf( @@ -7,3 +14,10 @@ internal actual fun platformInfo(): Map = mapOf( "osVersion" to UIDevice.currentDevice.systemVersion, "model" to UIDevice.currentDevice.model, ) + +internal actual fun systemLocale(): String = + (NSLocale.preferredLanguages.firstOrNull() as? String) ?: NSLocale.currentLocale.localeIdentifier.replace('_', '-') + +internal actual val platformFileSystem: FileSystem = FileSystem.SYSTEM + +internal actual fun defaultHttpClient(): HttpClient = HttpClient(Darwin) diff --git a/docs/adr-007-host-boundary.md b/docs/adr-007-host-boundary.md index 386201e..06fb1fb 100644 --- a/docs/adr-007-host-boundary.md +++ b/docs/adr-007-host-boundary.md @@ -64,7 +64,7 @@ TrendingAI 订阅页(roadmap 第 6 条)是第一个真实页面。它对宿 | B. 框架只给语言 | 页面自选 i18n 库 | RN(i18next)、小程序 | 每包重复打库;缺键检查、key 类型各自做 | | C. 字典进包、框架加载 | 包级 `i18n/.json`,框架读当前语言进内存,`i18n.t` 仍走 J2 | Flutter ARB、Android 资源 | 框架多一件事 | -选 C。字典经 `Bundle` 的加载器读取(与页面字节码同一来源、同一签名覆盖),只把当前语言放进 Kotlin 内存,J2 查询是纯内存操作(ADR-002 对 J2 的要求)。语言优先取宿主推来的用户语言(App 内语言开关只有宿主知道),缺省用系统语言;宿主推新语言时框架换字典、经 K5 通知活页面,`t()` 绑定重算,页面不重建。回退链 `zh-Hant-TW → zh-Hant → zh → 包的默认语言`,全部缺失显示 key 并经 `PageSink` 上报一次。只支持 `{name}` 插值;`tinyui build` 以默认语言为准校验各语言 key 齐全,并生成 key 的 TS 类型。 +选 C。字典经 `Bundle` 的加载器读取(与页面字节码同一来源、同一签名覆盖),各语言的字典在包第一次挂页面时全部读进 Kotlin 内存(几 KB,切语言不再读文件),J2 查询是纯内存操作(ADR-002 对 J2 的要求)。语言优先取宿主推来的用户语言(App 内语言开关只有宿主知道),缺省用系统语言;宿主推新语言时框架换字典、经 K5 通知活页面,`t()` 绑定重算,页面不重建。回退链 `zh-Hant-TW → zh-Hant → zh → 包的默认语言`,全部缺失显示 key 并经 `PageSink` 上报一次。只支持 `{name}` 插值;`tinyui build` 以默认语言为准校验各语言 key 齐全,并生成 key 的 TS 类型。 QuickJS 没有 `Intl`:复数、本地化数字与日期第一批不做,触发时加 J2 `i18n.format`(原生侧实现)。 @@ -139,9 +139,9 @@ QuickJS 没有 `Intl`:复数、本地化数字与日期第一批不做,触 | B. 图标字体 | Material Symbols 字体 | 字体数 MB,裁剪又回到固定集合 | | C. 图标数据随包,框架只渲染 | 页面 import 图标数据,esbuild 只打进用到的;`Icon` 把 path 渲染成 ImageVector | 只支持单色 path | -选 C。新 npm 包 `tinyui-icons` 从 Material Symbols(Apache 2.0)生成,一个图标一个具名导出;图标是字符串 `"|"`(prop 不过桥对象,patch-protocol.md §4),页面可手写品牌图标。Kotlin 按字符串缓存解析结果。服务端 key 到图标的映射搬进页面,新 key 发包即可。多色与位图归 Image 管线。 +选 C,且不另建图标包:`tinyui build` 给 esbuild 加 `.svg` 加载器,页面直接 import SVG 文件(Material Symbols 用现成的 `@material-symbols/svg-400`,Apache 2.0;设计师给的 `.svg` 同样),构建期转成字符串 `"|"`,描边图标(Lucide 这类 `fill="none"` + `stroke`)为 `"||<线宽>"`(prop 不过桥对象,patch-protocol.md §4)。渐变、位图、多色、`transform` 在构建期报错。少维护一个要跟上游、要发版的包,图标来源也不限一套。Kotlin 按字符串缓存解析结果。服务端 key 到图标的映射搬进页面,新 key 发包即可。多色与位图归 Image 管线。 -`Loading` 用 M3 Expressive 的 `LoadingIndicator`(实验 API,Compose 升级时框架跟进),只做不定进度。 +`Loading` 用 M3 Expressive 的 `LoadingIndicator`(实验 API,Compose 升级时框架跟进),只做不定进度。它只在 CMP material3 的 alpha 线上有(稳定版停在 1.9.x),所以 core 库依赖与 CMP 同代的 `material3` alpha(1.12.0-alpha03),宿主本来也须与 CMP 同代。 同时为可点击容器补无障碍语义:公共 prop `role` 与 `selected`(components.md §2),订阅页的选档卡片由此成为读屏的一个"单选、已选中"节点,与原生版等价。 @@ -181,7 +181,7 @@ QuickJS 没有 `Intl`:复数、本地化数字与日期第一批不做,触 | 存储 | `storage`,按包隔离、预载同步读、合并写盘、约 1 MB 上限、`E_QUOTA` | | 会话与状态 | 框架标准会话 `{ loggedIn, userId }` + `signIn`;业务状态页面自取;store App 级单例;`pageVisible()` 由 `TinyUIPage` 驱动 | | UI 能力 | `ui.toast`、`ui.alert` / `ui.confirm`、`linking.openUrl`(宿主可接管) | -| 组件 | `Icon`(path 数据,`tinyui-icons`)、`Loading`(`LoadingIndicator`)、公共 prop `role` / `selected` | +| 组件 | `Icon`(path 数据,CLI 的 `.svg` 加载器产出)、`Loading`(`LoadingIndicator`)、公共 prop `role` / `selected` | | 埋点 | `analytics.track` + 宿主 `AnalyticsSink` | | 宿主契约 | 标准接口 > `events` > `host.call` 兜底 + 宿主组件;删 `HostServices`;快照加通道段,是否提供会话记作能力 `session.signIn` | | 库的 I/O | core 库放开零 I/O,直接依赖 ktor 与 okio(ADR-006 §2.4 该句作废);`tinyui-updates` 的边界不变(仍不做网络,`fetch` 由宿主给) | diff --git a/docs/build-chain.md b/docs/build-chain.md index bb38146..2331b73 100644 --- a/docs/build-chain.md +++ b/docs/build-chain.md @@ -1,6 +1,6 @@ # 构建链:TSX → ESM 模块字节码 -- 状态:已完成(2026-09-16,PR #2;2026-09-17 加 §7 错误上报与 source map);2026-09-19 修订模块名规则——加包名前缀、去 `pages/` 段,引入 `tinyui.config.json`([ADR-006](./adr-006-hot-updates.md) §2.10),随热下发 M1 实施,sample 与 TrendingAI 的现有模块名一并改;2026-09-24 按 ADR-006 §2.11 修订:运行时模块改由库构建、随库发布,`tinyui build` 只编页面;2026-09-25 按 [ADR-007](./adr-007-host-boundary.md) 加包内 i18n 资源(§8)与网络通道名的静态识别(§5.1) +- 状态:已完成(2026-09-16,PR #2;2026-09-17 加 §7 错误上报与 source map);2026-09-19 修订模块名规则——加包名前缀、去 `pages/` 段,引入 `tinyui.config.json`([ADR-006](./adr-006-hot-updates.md) §2.10),随热下发 M1 实施,sample 与 TrendingAI 的现有模块名一并改;2026-09-24 按 ADR-006 §2.11 修订:运行时模块改由库构建、随库发布,`tinyui build` 只编页面;2026-09-25 按 [ADR-007](./adr-007-host-boundary.md) 加包内 i18n 资源(§8)、SVG 图标(§9)与网络通道名的静态识别(§5.1) - 来源:[ADR-005](./adr-005-engine.md) §4 "语言目标 / 模块" 两行的展开;roadmap A 组"构建链打通" - 范围:`tinyui-cli` 的 `tinyui build`、`compose/` 的 K0 加载序列、sample 的接线与 CI。自动 thunk(roadmap C 组)、页面级配置文件不在本期(`qjsc-kmp` 二进制分发已于 2026-09-23 随热下发 M5.2 做完,见 §5);字节码行号回映射见 §7 @@ -134,3 +134,13 @@ rolldown 是真正的备选(Rollup 同形 API、oxc 转译)。CLI 只包一 - 产物:文件原样复制到输出目录的 `i18n/`,进 manifest 的 `i18n`(`{ "default": "en", "files": { "en": "i18n/en.json", … } }`)与 `hashes`,随包签名(updates.md §1.1) - 类型:`tinyui i18n --ts ` 生成对 `tinyui-native` 的模块扩充(每个 key 及其占位符名),`i18n.t` 的 key 与参数因此在编译期检查;与 `tinyui schema` 一样把生成物提交进仓 - 页面没有用到 i18n 的包可以没有 `i18n/` 目录,此时不写 `defaultLocale` + +## 9. SVG 图标 + +定于 2026-09-25(ADR-007 §3.7,组件契约见 components.md §3)。页面 `import icon from "….svg"` 得到 `Icon` 要的字符串:`tinyui build` 在 esbuild 里给 `.svg` 挂加载器,构建期转换,设备上不解析 SVG。 + +- 填充图标:`` 下的 `path` / `circle` / `ellipse` / `rect`(含圆角)/ `line` / `polyline` / `polygon` 合并成一个 path,得 `"|"`;`fill` 只能缺省或 `currentColor` +- 描边图标:根元素 `fill="none"` 且有 `stroke`(Lucide 的写法),得 `"||"` +- 构建失败:渐变、`image`、`text`、`use`、`mask`、`transform`、`style`、透明度、另一种颜色的 `fill`、没有 `viewBox`(且没有 `width` / `height`) +- 类型:页面工程的 tsconfig 加 `"types": ["tinyui-core/assets"]`,`*.svg` 的默认导出是 `string` + diff --git a/docs/components.md b/docs/components.md index 08b93c3..60a2284 100644 --- a/docs/components.md +++ b/docs/components.md @@ -75,19 +75,21 @@ prop 类型:`string` / `number` / `boolean` / `dp` / `sp` / `color`(`#RRGGBB | `TextField` | `initialText`(initial)、`placeholder`、`singleLine`、`keyboard` | `onChange{text}`、`onCommit{text}`(IME Done 或失焦) | `setText{text}`、`focus`、`blur` | | | `LazyColumn` | `gap` | `onReachEnd`、`onScrollEnd{index}` | `scrollTo{index}` | 是,通常是一个 `` | | `Spacer` | (只有布局 prop) | | | | -| `Icon` | `icon`(必填,`icon` 类型)、`size`(dp,默认 24)、`tint`(color,缺省跟随内容色) | `onClick` | | | +| `Icon` | `icon`(必填,`icon` 类型)、`size`(dp,默认 24)、`tint`(color,缺省跟随内容色)、`label`(读屏念的名字;紧挨文字的装饰图标不设) | `onClick` | | | | `Loading` | `size`(dp,默认 24)、`color`(缺省 `primary`) | | | | `RadioButton` 的选中态是普通 prop(2026-09-17 加):它只报点击,选中哪个由 JS 决定——单选组的真值本来就在页面状态里,不属于 ADR-004 的"高频交互状态"。`TextField` 的文本与光标永远在 Kotlin 侧(ADR-004 §3.3):`setText` 会同时触发 `onChange`。`LazyColumn` 的行就是它的 children,虚拟化只在组合层(ADR-003 §3.6)。 -`Icon` 不内置任何图标,图标数据随包(2026-09-25,ADR-007 §3.7)。一个图标是一个字符串 `"|"`:`viewBox` 是四个数(`0 -960 960 960`),`d` 是 SVG path 数据,单色,多个 path 合并成一个 `d`,按 `tint` 着色;用字符串是因为 prop 不过桥对象(patch-protocol.md §4)。schema 里是新的 prop 类型 `icon()`,Kotlin 写入时解析成 `ImageVector` 并按字符串缓存,格式不对按 E5 跳过。页面从 npm 包 `tinyui-icons` 按名 import(由 Material Symbols 生成,一个图标一个具名导出的字符串常量,esbuild 只打进用到的),或自己写品牌图标的字符串。多色图标与位图归 `Image`。 +`Icon` 不内置任何图标,图标数据随包(2026-09-25,ADR-007 §3.7)。一个图标是一个字符串:填充图标 `"|"`,描边图标 `"||<线宽>"`(圆头圆角)。`viewBox` 是四个数(`0 -960 960 960`),`d` 是 SVG path 数据,多个形状合并成一个 `d`,按 `tint` 着色;用字符串是因为 prop 不过桥对象(patch-protocol.md §4)。schema 里是 prop 类型 `icon()`,Kotlin 写入时解析成 `ImageVector` 并按字符串缓存,格式不对按 E5 跳过。字符串由 `tinyui build` 的 `.svg` 加载器从 SVG 文件生成(build-chain.md §9),页面直接 import SVG:Material Symbols 用 npm 包 `@material-symbols/svg-400`,品牌图标放一个 `.svg` 进工程;类型声明见 `tinyui-core/assets`。多色图标与位图归 `Image`。 ```tsx -import { bolt, checkCircle } from "tinyui-icons/material/outlined"; +import bolt from "@material-symbols/svg-400/outlined/bolt.svg"; +import checkCircle from "@material-symbols/svg-400/outlined/check_circle.svg"; +const ICONS: Record = { quota: bolt }; ``` -`Loading` 是 M3 Expressive 的 `LoadingIndicator`,不定进度;有进度的进度条等需求。 +`Loading` 是 M3 Expressive 的 `LoadingIndicator`,不定进度;有进度的进度条等需求。`LoadingIndicator` 只在 CMP material3 的 alpha 线上有,库因此依赖与 CMP 同代的 material3 alpha(catalog `composeMaterial3`)。 **`Image` 推迟**:牵出图片加载管线的选型(coil3 还是宿主提供 loader),M2 用不到,单独一次定。 diff --git a/docs/native-api.md b/docs/native-api.md index 09e8daf..8e665a4 100644 --- a/docs/native-api.md +++ b/docs/native-api.md @@ -23,7 +23,7 @@ val host = TinyUIHost( TinyUIPage(module, host, propsJson = "{}", locals = emptyList(), modifier = …) ``` -除 `components`、`sink`、`dataDir` 外都可缺省,缺省行为见各节。标准接口(通道之外)的形状由框架定,不进宿主快照;进快照的只有宿主组件、`capabilities` 的名字、`channels` 的名字,以及是否提供会话(记作能力 `session.signIn`,§9)(updates.md §6.4)。`host.store`、`host.events` 是框架建的 App 级实例,宿主经它们与页面交换状态和事件(§4、§5)。 +除 `components`、`sink` 外都可缺省,缺省行为见各节(没有 `dataDir` 时 `storage` 答 `E_UNSUPPORTED`)。标准接口(通道之外)的形状由框架定,不进宿主快照;进快照的只有宿主组件、`capabilities` 的名字、`channels` 的名字,以及是否提供会话(记作能力 `session.signIn`,§9)(updates.md §6.4)。`host.store`、`host.events` 是框架建的 App 级实例,宿主经它们与页面交换状态和事件(§4、§5)。 `locals` 是这一次挂载交给 `host.call` 能力的对象(§13),只有兜底能力用得到。 @@ -64,7 +64,7 @@ const cart = store.watch(key) // 渲染期调用,返回 accessor;K5 topic st App 级内存键值,真值在框架的 `host.store`,全 App 一份、跨包共享、进程结束即失。宿主写入用带类型的方法: ```kotlin -host.store.set("trendingai.flag", true) // 按 kotlinx.serialization 编码 +host.store.set("trendingai.flag", true) // 按 kotlinx.serialization 编码;原始 JSON 用 setJson / getJson host.store.bind("trendingai.flag", flow, scope) // 把一个 Flow 持续写入 ``` @@ -143,7 +143,8 @@ i18n.locale() // accessor,如 "zh" ``` - 字典在包里:`i18n/.json`,扁平的 key → 字符串,包的默认语言写在 `tinyui.config.json`(build-chain.md §8) -- 当前语言:`TinyUIHost.locale` 的值,缺省为系统语言;变化时框架加载新字典,经 K5 topic `i18n.locale` 通知活页面,`t()` 与 `locale()` 的绑定重算,页面不重建 +- 包第一次挂页面时,各语言的字典全部读进内存(manifest 的 `i18n`,与字节码同一来源) +- 当前语言:`TinyUIHost.locale` 的值,缺省为系统语言;变化时经 K5 topic `i18n.locale` 通知活页面,`t()` 与 `locale()` 的绑定重算,页面不重建 - 查找回退:`zh-Hant-TW → zh-Hant → zh → 默认语言`;全部缺失返回 key 本身,并经 `PageSink` 每个 key 上报一次 - 插值只有 `{name}`,值按字符串替换;没有复数与数字 / 日期格式化(引擎无 `Intl`) - key 的 TS 类型由 CLI 生成(build-chain.md §8),写错的 key 编译期报 @@ -161,7 +162,7 @@ class SessionSource(val state: StateFlow, val signIn: suspend (source: ``` - J2 `session.get` 取初值,K5 topic `session` 推送变化;框架订阅 `state`,推给所有活页面,页面不重建 -- `userId` 是稳定、非敏感的用户标识,不是 token +- `userId` 是稳定、非敏感的用户标识,不是 token;宿主登录态里还没有它时可以给 `null` - 宿主没提供 `session` 时:`state()` 恒为 `{ loggedIn: false, userId: null }`,`signIn` 以 `E_UNSUPPORTED` reject - 提供了 `session` 的宿主,快照的能力段多一行 `session.signIn`;调用了 `session.signIn` 的页面把它记进 `requires`,发布前照能力名核对(updates.md §1.3)。提供或撤掉会话都是宿主契约变化,`hostVersion` 加 1。`session.signIn` 是框架名,`host.call` 注册同名能力在 `register` 时报错(§13),所以快照里的这一行只可能来自会话 diff --git a/docs/roadmap.md b/docs/roadmap.md index 3165b98..cd084b4 100644 --- a/docs/roadmap.md +++ b/docs/roadmap.md @@ -81,4 +81,4 @@ 5. ~~**C 剩余项 + 内置组件集,M2:列表页**——`For` / `LazyColumn` / `TextField` / J3 网络,覆盖所有权、命令、流式输入~~ 已完成(PR #4,2026-09-16):schema 生成链、八个内置组件、`HostServices`、`tinyui-native`;todos 页在 Android 与 iOS 模拟器上跑通输入提交、行增删改、命令滚动、分页 6. **实战接入:TrendingAI 订阅页**(2026-09-17 起,代替多页业务样例)——页面源码在 `~/TrendingProjects/trendingai-tinyui`,产物经 `pnpm sync` 入 TrendingAI;框架缺什么以这一页为准补(第一批:主题 token、weight / border / scroll、`host.call`、`Button` children、`RadioButton`、宿主 include)。已在 Android 模拟器跑通拉价、选档、深浅色(TrendingAI 分支 `feat/tinyui-subscription`);合入 TrendingAI main 的前提——tinyui 0.1.0(Maven + npm 三包 `tinyui-core` / `tinyui-native` / `tinyui-cli`)与 quickjs-kmp 0.1.1——已于 2026-09-18 发出(PR #11);iOS 模拟器 smoke 通过,三条状态验证(结账失败 / 未登录 / 已是 Pro)不做 7. **热下发实现**(ADR-006 定稿后;里程碑与进度见 [updates-plan.md](./updates-plan.md)):CLI(`tinyui.config.json`、模块名加包名、manifest 扩展、`bundle` + 签名、`keys`)→ core 库 `Bundle` → `updates/` 模块(多包 `Updates`)→ `tinyui-updates-server` 仓 MVP(投递 + 发布 + 管理端点,部署 `updates.tinyui.app`)→ CLI `publish` / `apps` / `packages` / `tokens` / `releases` → TrendingAI 接入 → 第二个 App 接入。前置:`qjsc-kmp` npm 平台包 -8. **框架职责边界**([ADR-007](./adr-007-host-boundary.md),2026-09-25 定):tinyui 一个 PR——`TinyUIHost` 重组、删 `HostServices`、App 级 store 与 `pageVisible()` 驱动、`http` 通道、`storage` 与 `cached`、包内 i18n(CLI 校验与类型)、`ui` / `linking` / `analytics` / `session`、`Icon` 与 `tinyui-icons`、`Loading`、`role` / `selected`、快照通道段,sample 同步改写 → 发 minor → TrendingAI 订阅页按新形态重写(trendingai-tinyui 删 `schema/` 与宿主封装;TrendingAI 删五个能力、props、宿主组件,`hostVersion` 加 1)。验收见 ADR-007 §5.4 +8. **框架职责边界**([ADR-007](./adr-007-host-boundary.md),2026-09-25 定):tinyui 一个 PR——`TinyUIHost` 重组、删 `HostServices`、App 级 store 与 `pageVisible()` 驱动、`http` 通道、`storage` 与 `cached`、包内 i18n(CLI 校验与类型)、`ui` / `linking` / `analytics` / `session`、`Icon` 与 CLI 的 `.svg` 加载器、`Loading`、`role` / `selected`、快照通道段,sample 同步改写 → 发 minor → TrendingAI 订阅页按新形态重写(trendingai-tinyui 删 `schema/` 与宿主封装;TrendingAI 删五个能力、props、宿主组件,`hostVersion` 加 1)。验收见 ADR-007 §5.4 diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index 3a221ea..b92c551 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -2,6 +2,8 @@ agp = "9.4.0" kotlin = "2.4.20" composeMultiplatform = "1.12.0" +# 独立版本线,只发 alpha;须与 composeMultiplatform 同代,LoadingIndicator 也只在这里有 +composeMaterial3 = "1.12.0-alpha03" android-minSdk = "24" android-compileSdk = "37" android-targetSdk = "37" @@ -12,6 +14,7 @@ quickjsKmp = "0.1.2" vanniktechMavenPublish = "0.37.0" binaryCompatibilityValidator = "0.18.2" okio = "3.18.2" +ktor = "3.5.2" [libraries] kotlin-test = { module = "org.jetbrains.kotlin:kotlin-test", version.ref = "kotlin" } @@ -20,6 +23,12 @@ kotlinx-coroutines-test = { module = "org.jetbrains.kotlinx:kotlinx-coroutines-t kotlinx-serialization-json = { module = "org.jetbrains.kotlinx:kotlinx-serialization-json", version.ref = "kotlinxSerialization" } quickjs-kmp = { module = "wang.harlon:quickjs-kmp", version.ref = "quickjsKmp" } okio = { module = "com.squareup.okio:okio", version.ref = "okio" } +okio-fakefilesystem = { module = "com.squareup.okio:okio-fakefilesystem", version.ref = "okio" } +ktor-client-core = { module = "io.ktor:ktor-client-core", version.ref = "ktor" } +ktor-client-okhttp = { module = "io.ktor:ktor-client-okhttp", version.ref = "ktor" } +ktor-client-darwin = { module = "io.ktor:ktor-client-darwin", version.ref = "ktor" } +ktor-client-mock = { module = "io.ktor:ktor-client-mock", version.ref = "ktor" } +compose-material3 = { module = "org.jetbrains.compose.material3:material3", version.ref = "composeMaterial3" } androidx-activity-compose = { module = "androidx.activity:activity-compose", version.ref = "androidxActivityCompose" } # build-logic 用:把插件当普通依赖引进 convention plugin 的 classpath plugin-android = { module = "com.android.tools.build:gradle", version.ref = "agp" } diff --git a/packages/cli/src/bin.ts b/packages/cli/src/bin.ts index 39155f9..59838b5 100644 --- a/packages/cli/src/bin.ts +++ b/packages/cli/src/bin.ts @@ -7,6 +7,7 @@ import { build } from "./build.ts"; import { bundle, isHostVersion, isPathSegment } from "./bundle.ts"; import { ADMIN_TOKEN_ENV, DEFAULT_URL, resolveUrl, requireToken, TOKEN_ENV, UpdatesClient } from "./client.ts"; import { loadConfig, requireName } from "./config.ts"; +import { i18nTypes, loadI18n } from "./i18n.ts"; import { generateKeyPair } from "./keys.ts"; import { publish } from "./publish.ts"; import { pull } from "./pull.ts"; @@ -16,6 +17,7 @@ import { loadSchema } from "./schema/load.ts"; const USAGE = `usage: tinyui build [--root ] [--out ] [--qjsc ] [--js-only] [--version ] tinyui bundle --host-version --signing-key [--dist ] [--rollout <0-100>] [--out ] + tinyui i18n --ts [--root ] [--check] tinyui keys generate [--out ] tinyui schema --entry [--ts ] [--kt --package [--object ]] [--check] tinyui publish --channel [--app ] [--dir ] [--rollout <0-100>] @@ -38,6 +40,7 @@ build compile the pages of the package described by /tinyui.config.js --qjsc path to qjsc-kmp (default: $TINYUI_QJSC, then the qjsc-kmp npm package, then PATH) --js-only emit ESM sources and source maps only, skip bytecode --version package version to record (default: -) + pages import icons as .svg files: single-colour shapes become the string Icon takes (docs/components.md §3) bundle turn a build into the signed upload directory /// (docs/updates.md) --host-version the positive integer the host declares for what it provides to pages (docs/updates.md §4.1) @@ -46,6 +49,11 @@ bundle turn a build into the signed upload directory ///ota) +i18n type i18n.t from the package's strings (docs/build-chain.md §8); build checks them either way + --root project root (default: cwd) + --ts write the tinyui-native I18nKeys augmentation here + --check exit 1 if --ts is not already up to date, write nothing + keys generate --out where to write the private key (default: ./tinyui-signing-key.pem); the public key is printed @@ -116,6 +124,26 @@ const COMMANDS: Record { + const root = (v["root"] as string | undefined) ?? process.cwd(); + const out = v["ts"] as string | undefined; + if (!out) throw new Error("i18n: --ts is required"); + const strings = await loadI18n(root, await loadConfig(root)); + if (!strings) throw new Error(`i18n: ${root} has no strings (tinyui.config.json "i18n" directory)`); + const text = i18nTypes(strings); + if (v["check"]) { + const current = await readFile(out, "utf8").catch(() => ""); + if (current !== text) throw new Error(`${out} is out of date; run tinyui i18n --ts ${out}`); + return 0; + } + await mkdir(dirname(out), { recursive: true }); + await writeFile(out, text); + process.stdout.write(`${out}\n`); + return 0; + }, + }, keys: { options: { out: { type: "string" } }, run: async (v, positionals) => { diff --git a/packages/cli/src/build.ts b/packages/cli/src/build.ts index 62b4a45..663aa75 100644 --- a/packages/cli/src/build.ts +++ b/packages/cli/src/build.ts @@ -1,14 +1,16 @@ import { build as esbuild, type Plugin } from "esbuild"; import { execFile } from "node:child_process"; import { createHash } from "node:crypto"; -import { mkdir, readdir, readFile, rm, writeFile } from "node:fs/promises"; +import { copyFile, mkdir, readdir, readFile, rm, writeFile } from "node:fs/promises"; import { createRequire } from "node:module"; import { dirname, isAbsolute, join, relative, resolve, sep } from "node:path"; import { promisify } from "node:util"; import { isPathSegment } from "./bundle.ts"; import { loadConfig, type TinyUIConfig } from "./config.ts"; +import { loadI18n } from "./i18n.ts"; import { compileModule, findQjsc } from "./qjsc.ts"; import { analyzePage, RequiresError, type PageRequires } from "./requires.ts"; +import { SvgError, svgToIcon } from "./svg.ts"; import { TransformError, transformJsx } from "./transform.ts"; const execFileAsync = promisify(execFile); @@ -60,8 +62,10 @@ export interface Manifest { tinyui: string; /** Module name → sha256 hex of its `.bin`; empty when built with `jsOnly`. */ hashes: Record; - /** Page module name → the host capabilities and host components it uses (docs/updates.md §1.3). */ + /** Page module name → the host capabilities, http channels and host components it uses (docs/updates.md §1.3). */ requires: Record; + /** The package's strings: the default language and each language's file (docs/build-chain.md §8); absent without strings. */ + i18n?: { default: string; files: Record }; } export async function build(options: BuildOptions): Promise { @@ -72,6 +76,7 @@ export async function build(options: BuildOptions): Promise { const pagesDir = join(root, config.pages); const pageNames = await discoverPages(config.name, pagesDir); if (pageNames.size === 0) throw new Error(`no pages found under ${pagesDir}`); + const i18n = await loadI18n(root, config); const qjsc = options.jsOnly ? undefined : await findQjsc(options.qjsc); if (!options.jsOnly && !qjsc) { @@ -80,11 +85,20 @@ export async function build(options: BuildOptions): Promise { // stale outputs would otherwise be packaged along with the current pages await rm(join(out, "pages"), { recursive: true, force: true }); + await rm(join(out, "i18n"), { recursive: true, force: true }); const pages = await bundlePages(root, out, config, pagesDir, pageNames); const requires = await pageRequires(pages); await finish(root, pages, qjsc); const hashes: Record = {}; for (const m of pages) if (m.bin) hashes[m.name] = createHash("sha256").update(await readFile(m.bin)).digest("hex"); + if (i18n) { + for (const [locale, path] of i18n.paths) { + const target = join(out, ...path.split("/")); + await mkdir(dirname(target), { recursive: true }); + await copyFile(i18n.sources.get(locale)!, target); + hashes[path] = createHash("sha256").update(await readFile(target)).digest("hex"); + } + } const createdAt = new Date().toISOString().replace(/\.\d{3}Z$/, "Z"); const manifest = join(out, "manifest.json"); @@ -100,6 +114,7 @@ export async function build(options: BuildOptions): Promise { tinyui: await tinyuiVersion(root), hashes, requires, + ...(i18n && { i18n: { default: i18n.defaultLocale, files: Object.fromEntries(i18n.paths) } }), }; await writeFile(manifest, JSON.stringify(content, null, 2) + "\n"); return { pages, manifest }; @@ -259,6 +274,15 @@ const pagePlugin: Plugin = { name: "tinyui-pages", setup(api) { api.onResolve({ filter: /^tinyui:jsx-shim$/ }, (args) => ({ path: args.path, namespace: "tinyui" })); + // docs/components.md §3: an SVG file is the icon string Icon takes, checked here rather than on the device + api.onLoad({ filter: /\.svg$/ }, async (args) => { + try { + return { contents: `export default ${JSON.stringify(svgToIcon(await readFile(args.path, "utf8")))};`, loader: "js" }; + } catch (e) { + if (e instanceof SvgError) return { errors: [{ text: `${e.message} (an icon is a single-colour SVG: docs/components.md §3)`, location: { file: args.path } }] }; + throw e; + } + }); // Runtime modules stay bare specifiers and are pure, so a page that never uses JSX keeps no import of h api.onResolve({ filter: /^tinyui-(core|native)$/ }, (args) => ({ path: args.path, external: true, sideEffects: false })); api.onLoad({ filter: /.*/, namespace: "tinyui" }, () => ({ diff --git a/packages/cli/src/bundle.ts b/packages/cli/src/bundle.ts index 4536f8a..514d790 100644 --- a/packages/cli/src/bundle.ts +++ b/packages/cli/src/bundle.ts @@ -3,6 +3,7 @@ import { copyFile, mkdir, readFile, rename, rm, writeFile } from "node:fs/promis import { dirname, isAbsolute, join, relative, resolve, sep } from "node:path"; import type { Manifest } from "./build.ts"; import { isPackageName } from "./config.ts"; +import { payload, requirePackagePath } from "./payload.ts"; import { isPublicKey, publicKeyOf, sign } from "./keys.ts"; export interface BundleOptions { @@ -63,15 +64,15 @@ export async function bundle(options: BundleOptions): Promise { // everything is checked before anything is written const files: { source: string; target: string }[] = []; - for (const [module, path] of Object.entries(manifest.files)) { - const source = resolve(dist, path + ".bin"); - if (!within(dist, source)) throw new Error(`manifest.files["${module}"] points outside ${dist}: ${path}`); - // an output path is a URL path once the package is served; tinyui build refuses these at the source - if (!path.split("/").every(isPathSegment)) throw new Error(`manifest.files["${module}"] = "${path}": every segment must match [A-Za-z0-9._-]+ to survive the delivery URL`); - const expected = manifest.hashes[module]; + // an output path is a URL path once the package is served; payload() refuses the rest, as tinyui build does at the source + for (const { path, hashKey } of payload(manifest)) { + const source = resolve(dist, path); + if (!within(dist, source)) throw new Error(`${path} points outside ${dist}`); + requirePackagePath(path); + const expected = manifest.hashes[hashKey]; const actual = createHash("sha256").update(await readFile(source)).digest("hex"); if (actual !== expected) throw new Error(`${source} does not match manifest.hashes (${actual} vs ${expected}); rerun tinyui build`); - files.push({ source, target: path + ".bin" }); + files.push({ source, target: path }); } // written once, signed as written: every side verifies these exact bytes (docs/updates.md §7) @@ -138,9 +139,11 @@ async function readManifest(dist: string): Promise { }; const modules = names("pages"); if (new Set(modules).size !== modules.length) throw new Error(`${file}: a module is listed twice`); + const i18nFiles = Object.values((raw.i18n as { files?: Record } | undefined)?.files ?? {}); for (const key of ["files", "hashes", "buildIds"] as const) { const keys = Object.keys(table(key)).sort(); - if (keys.join("\n") !== [...modules].sort().join("\n")) throw new Error(`${file}: "${key}" does not cover exactly the modules in "pages"`); + const expected = key === "hashes" ? [...modules, ...i18nFiles] : modules; + if (keys.join("\n") !== [...expected].sort().join("\n")) throw new Error(`${file}: "${key}" does not cover exactly the modules in "pages"${key === "hashes" ? " and the i18n files" : ""}`); } return raw as Manifest; } diff --git a/packages/cli/src/config.ts b/packages/cli/src/config.ts index dd0b96b..1e32647 100644 --- a/packages/cli/src/config.ts +++ b/packages/cli/src/config.ts @@ -12,6 +12,10 @@ export interface TinyUIConfig { publicKey: string; /** Pages directory relative to the root; defaults to `src/pages`. */ pages: string; + /** Directory of `.json` string files relative to the root; defaults to `i18n` (docs/build-chain.md §8). */ + i18n: string; + /** The language the others are checked against and fall back to; required once there are strings. */ + defaultLocale?: string; } /** Package name: module name prefix and a path segment on the server and on disk (docs/updates.md §0). */ @@ -46,7 +50,7 @@ export async function loadConfig(root: string): Promise { throw new Error(`${file}: ${(e as Error).message}`); } if (typeof raw !== "object" || raw === null) throw new Error(`${file}: expected an object`); - const { name, publicKey, pages } = raw as Record; + const { name, publicKey, pages, i18n, defaultLocale } = raw as Record; if (typeof name !== "string" || !isPackageName(name)) throw new Error(`${file}: "name" must match [a-z0-9-]+`); if (typeof publicKey !== "string" || !isPublicKey(publicKey)) { throw new Error(`${file}: "publicKey" must be a P-256 uncompressed point in base64 (65 bytes, 04-prefixed); tinyui keys generate prints one`); @@ -54,5 +58,11 @@ export async function loadConfig(root: string): Promise { if (pages !== undefined && (typeof pages !== "string" || !insideRoot(root, pages))) { throw new Error(`${file}: "pages" must be a directory inside the project root`); } - return { name, publicKey, pages: pages ?? "src/pages" }; + if (i18n !== undefined && (typeof i18n !== "string" || !insideRoot(root, i18n))) { + throw new Error(`${file}: "i18n" must be a directory inside the project root`); + } + if (defaultLocale !== undefined && (typeof defaultLocale !== "string" || !/^[A-Za-z]{2,3}(-[A-Za-z0-9]{2,8})*$/.test(defaultLocale))) { + throw new Error(`${file}: "defaultLocale" must be a BCP 47 tag such as "en"`); + } + return { name, publicKey, pages: pages ?? "src/pages", i18n: i18n ?? "i18n", ...(defaultLocale !== undefined && { defaultLocale }) }; } diff --git a/packages/cli/src/i18n.ts b/packages/cli/src/i18n.ts new file mode 100644 index 0000000..2f549bd --- /dev/null +++ b/packages/cli/src/i18n.ts @@ -0,0 +1,105 @@ +import { readdir, readFile } from "node:fs/promises"; +import { join } from "node:path"; +import { isPathSegment } from "./bundle.ts"; +import type { TinyUIConfig } from "./config.ts"; + +/** A package's strings as `tinyui build` checked them (docs/build-chain.md §8). */ +export interface PackageI18n { + defaultLocale: string; + /** locale → key → string */ + dictionaries: Map>; + /** locale → the file's path inside the package (`i18n/en.json`) */ + paths: Map; + /** locale → the file on disk */ + sources: Map; +} + +const LOCALE = /^[A-Za-z]{2,3}(-[A-Za-z0-9]{2,8})*$/; +const KEY = /^[A-Za-z0-9._-]+$/; +const PLACEHOLDER = /\{([A-Za-z0-9_]+)\}/g; + +export class I18nError extends Error {} + +/** Null when the package has no `i18n/` directory and no `defaultLocale`; otherwise every rule of build-chain.md §8 holds or it throws. */ +export async function loadI18n(root: string, config: TinyUIConfig): Promise { + const dir = join(root, config.i18n); + let names: string[]; + try { + names = (await readdir(dir)).filter((n) => n.endsWith(".json")).sort(); + } catch { + if (config.defaultLocale !== undefined) throw new I18nError(`"defaultLocale" is set but ${dir} does not exist`); + return null; + } + if (names.length === 0 && config.defaultLocale === undefined) return null; + const defaultLocale = config.defaultLocale; + if (defaultLocale === undefined) throw new I18nError(`${dir} has strings but tinyui.config.json has no "defaultLocale"`); + const problems: string[] = []; + const dictionaries = new Map>(); + const sources = new Map(); + for (const name of names) { + const locale = name.slice(0, -".json".length); + if (!LOCALE.test(locale) || !isPathSegment(name)) { problems.push(`${name}: the file name must be a BCP 47 tag, such as en.json or zh-Hant.json`); continue; } + const file = join(dir, name); + let raw: unknown; + try { + raw = JSON.parse(await readFile(file, "utf8")); + } catch (e) { + problems.push(`${name}: ${(e as Error).message}`); + continue; + } + if (typeof raw !== "object" || raw === null || Array.isArray(raw)) { problems.push(`${name}: expected a flat object of strings`); continue; } + let valid = true; + for (const [key, value] of Object.entries(raw)) { + if (!KEY.test(key)) { problems.push(`${name}: key "${key}" must match [A-Za-z0-9._-]+`); valid = false; } + if (typeof value !== "string") { problems.push(`${name}: "${key}" must be a string`); valid = false; } + } + // a broken file is reported as such; comparing it against the others would only add noise or throw + if (!valid) continue; + dictionaries.set(locale, raw as Record); + sources.set(locale, file); + } + const base = dictionaries.get(defaultLocale); + if (!base && !sources.has(defaultLocale) && !problems.length) problems.push(`there is no ${defaultLocale}.json for the default language`); + if (base) { + for (const [locale, dict] of dictionaries) { + if (locale === defaultLocale) continue; + const missing = Object.keys(base).filter((k) => !Object.hasOwn(dict, k)); + const extra = Object.keys(dict).filter((k) => !Object.hasOwn(base, k)); + if (missing.length) problems.push(`${locale}.json lacks ${missing.map((k) => `"${k}"`).join(", ")}`); + if (extra.length) problems.push(`${locale}.json has ${extra.map((k) => `"${k}"`).join(", ")}, which ${defaultLocale}.json does not`); + for (const key of Object.keys(base)) { + if (!Object.hasOwn(dict, key)) continue; + const want = placeholders(base[key]!).join(", "); + const have = placeholders(dict[key]!).join(", "); + if (want !== have) problems.push(`${locale}.json "${key}" has placeholders {${have}}, ${defaultLocale}.json has {${want}}`); + } + } + } + if (problems.length) throw new I18nError(`${dir}:\n${problems.map((p) => ` ${p}`).join("\n")}`); + const paths = new Map([...dictionaries.keys()].map((l) => [l, `i18n/${l}.json`])); + return { defaultLocale, dictionaries, paths, sources }; +} + +export function placeholders(text: string): string[] { + return [...new Set([...text.matchAll(PLACEHOLDER)].map((m) => m[1]!))].sort(); +} + +/** The module augmentation that types `i18n.t`: each key of the default language and its placeholder names. */ +export function i18nTypes(i18n: PackageI18n): string { + const base = i18n.dictionaries.get(i18n.defaultLocale)!; + const lines = Object.keys(base).sort().map((key) => { + const names = placeholders(base[key]!); + return ` ${JSON.stringify(key)}: ${names.length ? names.map((n) => JSON.stringify(n)).join(" | ") : "never"};`; + }); + return [ + "// Generated by `tinyui i18n` from i18n/; do not edit.", + 'import "tinyui-native";', + "", + 'declare module "tinyui-native" {', + " interface I18nKeys {", + ...lines, + " }", + "}", + "", + ].join("\n"); +} diff --git a/packages/cli/src/payload.ts b/packages/cli/src/payload.ts new file mode 100644 index 0000000..ac19ab9 --- /dev/null +++ b/packages/cli/src/payload.ts @@ -0,0 +1,23 @@ +import { isPathSegment } from "./bundle.ts"; + +/** The part of a manifest that says which files a package carries besides `manifest.json` (docs/updates.md §1.1). */ +export interface PayloadManifest { + pages: string[]; + files: Record; + hashes: Record; + i18n?: { default: string; files: Record }; +} + +/** Every file of the package: its path inside the package, and its key into `hashes`. */ +export function payload(manifest: PayloadManifest): { path: string; hashKey: string }[] { + return [ + ...manifest.pages.map((module) => ({ path: `${manifest.files[module]}.bin`, hashKey: module })), + ...Object.values(manifest.i18n?.files ?? {}).map((path) => ({ path, hashKey: path })), + ]; +} + +/** A path inside a package is a URL path once served: plain relative segments only. */ +export function requirePackagePath(path: string): string { + if (!path.split("/").every(isPathSegment)) throw new Error(`"${path}": every segment must match [A-Za-z0-9._-]+ to survive the delivery URL`); + return path; +} diff --git a/packages/cli/src/publish.ts b/packages/cli/src/publish.ts index a5d11f5..27d50bb 100644 --- a/packages/cli/src/publish.ts +++ b/packages/cli/src/publish.ts @@ -5,6 +5,7 @@ import { isHostVersion, isPathSegment, type Pointer } from "./bundle.ts"; import { readHostSnapshot } from "./admin.ts"; import { segments, UpdatesError, type UpdatesClient, type UploadResult } from "./client.ts"; import type { PageRequires } from "./requires.ts"; +import { payload, requirePackagePath } from "./payload.ts"; import { parseHostSnapshot } from "./snapshot.ts"; import { isCompatible } from "./version.ts"; @@ -53,7 +54,7 @@ export async function publish(options: PublishOptions): Promise { await checkHost(client, app, local.manifest); const prefix = segments(app, pkg, hostVersion, version); - const files = [...new Set(Object.values(manifest.files).map((path) => `${path}.bin`)), "manifest.json"]; + const files = [...new Set(payload(manifest).map((f) => requirePackagePath(f.path))), "manifest.json"]; const results: UploadResult[] = []; const versionDir = join(local.dir, version); await inParallel(files, concurrency, async (path) => { @@ -89,7 +90,7 @@ async function checkHost(client: UpdatesClient, app: string, manifest: LocalBund const names = (v: unknown) => Array.isArray(v) && v.every((x) => typeof x === "string"); for (const page of manifest.pages) { const needs = (manifest.requires as Record)[page] as Partial | undefined; - if (!needs || !names(needs.components) || !names(needs.capabilities)) { + if (!needs || !names(needs.components) || !names(needs.capabilities) || (needs.channels !== undefined && !names(needs.channels))) { throw new Error(`${manifest.name} ${manifest.version}: "requires" has no well-formed entry for page ${page}; rebuild it with a current tinyui-cli`); } } @@ -106,8 +107,10 @@ async function checkHost(client: UpdatesClient, app: string, manifest: LocalBund for (const [page, needs] of Object.entries(manifest.requires as Record).sort(([a], [b]) => (a < b ? -1 : 1))) { const components = needs.components.filter((c) => !host.components.has(c)); const capabilities = needs.capabilities.filter((c) => !host.capabilities.has(c)); + const channels = (needs.channels ?? []).filter((c) => !host.channels.has(c)); if (components.length) problems.push(` ${page} uses host component${components.length > 1 ? "s" : ""} ${components.join(", ")}`); if (capabilities.length) problems.push(` ${page} calls ${capabilities.join(", ")}`); + if (channels.length) problems.push(` ${page} sends through http channel${channels.length > 1 ? "s" : ""} ${channels.join(", ")}`); } if (problems.length) throw new Error(`${manifest.name} ${manifest.version} cannot go to ${target}, which does not provide what it uses:\n${problems.join("\n")}`); } diff --git a/packages/cli/src/pull.ts b/packages/cli/src/pull.ts index 2783409..81dda92 100644 --- a/packages/cli/src/pull.ts +++ b/packages/cli/src/pull.ts @@ -3,6 +3,7 @@ import { mkdir, mkdtemp, readFile, rename, rm, writeFile } from "node:fs/promise import { dirname, join, resolve } from "node:path"; import { isPathSegment } from "./bundle.ts"; import { requireSecureUrl, segments } from "./client.ts"; +import { payload, requirePackagePath } from "./payload.ts"; import { verify } from "./keys.ts"; export interface PullOptions { @@ -38,6 +39,7 @@ interface Manifest { pages: string[]; files: Record; hashes: Record; + i18n?: { default: string; files: Record }; } /** @@ -88,11 +90,10 @@ export async function pull(options: PullOptions): Promise { if (current?.hostVersion === options.hostVersion && manifest.createdAt <= current.createdAt) return keep(`embedded ${current.version} is not older than ${version}`); const files = new Map(); - for (const module of manifest.pages) { - const path = `${manifest.files[module]}.bin`; - if (!path.split("/").every(isPathSegment)) throw new Error(`manifest.files[${module}] is not a relative path: ${path}`); + for (const { path, hashKey } of payload(manifest)) { + requirePackagePath(path); const bytes = await get(`${version}/${path}`); - if (createHash("sha256").update(bytes).digest("hex") !== manifest.hashes[module]) throw new Error(`${path} does not match manifest.hashes`); + if (createHash("sha256").update(bytes).digest("hex") !== manifest.hashes[hashKey]) throw new Error(`${path} does not match manifest.hashes`); files.set(path, bytes); } files.set("manifest.json", manifestBytes); diff --git a/packages/cli/src/requires.ts b/packages/cli/src/requires.ts index 3dd8b1e..6a9ff76 100644 --- a/packages/cli/src/requires.ts +++ b/packages/cli/src/requires.ts @@ -4,8 +4,10 @@ import { parseSync } from "oxc-parser"; export interface PageRequires { /** Host component types, always dotted (`ta.Icon`); built-ins follow the TinyUI version and are not listed. */ components: string[]; - /** `host.call` names. */ + /** `host.call` names, and `session.signIn` when the page calls it. */ capabilities: string[]; + /** `http.client` channel names; the built-in `default` is not listed. */ + channels: string[]; } export class RequiresError extends Error {} @@ -34,6 +36,7 @@ export function analyzePage(page: string, code: string): PageRequires { const shadowed = new Set(); const components = new Set(); const capabilities = new Set(); + const channels = new Set(); const problem = (node: AnyNode, message: string) => problems.push(` ${origin(code, node.start)}: ${snippet(code, node)}\n ${message}`); const useHost = (ref: AnyNode) => { @@ -43,6 +46,25 @@ export function analyzePage(page: string, code: string): PageRequires { if (target === undefined) return problem(call, "the first argument of host.call must be a string literal (docs/build-chain.md §5.1)"); capabilities.add(target); }; + const parentOf = (n: AnyNode) => (n as AnyNode & { parentNode?: AnyNode })["parentNode"] ?? null; + /** [ref] is `http` (or `native.http`): only its members, and `client` only with a literal channel name. */ + const useHttp = (ref: AnyNode) => { + const member = memberOf(ref, parentOf(ref)); + if (!member) return problem(ref, "http may only be used as http.(…) or http.client(\"\") (docs/build-chain.md §5.1)"); + if (member.name !== "client") return; + const call = parentOf(member.node); + if (call?.type !== "CallExpression" || call["callee"] !== member.node) return problem(member.node, "http.client may only be called, not passed around (docs/build-chain.md §5.1)"); + const name = literal((call["arguments"] as AnyNode[])[0]); + if (name === undefined) return problem(call, "the argument of http.client must be a string literal (docs/build-chain.md §5.1)"); + // the built-in channel is every host's, nothing to check it against + if (name !== "default") channels.add(name); + }; + /** [ref] is `session` (or `native.session`): only its members; reaching signIn is a use of the session. */ + const useSession = (ref: AnyNode) => { + const member = memberOf(ref, parentOf(ref)); + if (!member) return problem(ref, "session may only be used as session. (docs/build-chain.md §5.1)"); + if (member.name === "signIn") capabilities.add("session.signIn"); + }; const useH = (ref: AnyNode) => { const call = (ref as AnyNode & { parentNode?: AnyNode })["parentNode"]; if (call?.type !== "CallExpression" || call["callee"] !== ref) return problem(ref, "h may only be called, not passed around (docs/build-chain.md §5.1)"); @@ -66,6 +88,8 @@ export function analyzePage(page: string, code: string): PageRequires { } const binding = only(list); if (binding?.kind === "native" && binding.imported === "host") return useHost(node); + if (binding?.kind === "native" && binding.imported === "http") return useHttp(node); + if (binding?.kind === "native" && binding.imported === "session") return useSession(node); if (binding?.kind === "core" && binding.imported === "h") return useH(node); if ((binding?.kind === "native" || binding?.kind === "core") && binding.imported === "*") { // a namespace import is followed through its members, and only there: passed whole it could reach host or h unseen @@ -74,11 +98,13 @@ export function analyzePage(page: string, code: string): PageRequires { } const member = (parent["property"] as AnyNode)["name"]; if (binding.kind === "native" && member === "host") return useHost(parent); + if (binding.kind === "native" && member === "http") return useHttp(parent); + if (binding.kind === "native" && member === "session") return useSession(parent); if (binding.kind === "core" && member === "h") return useH(parent); } }); if (problems.length) throw new RequiresError(`${page} uses its host in a way tinyui build cannot follow:\n${problems.join("\n")}`); - return { components: [...components].sort(), capabilities: [...capabilities].sort() }; + return { components: [...components].sort(), capabilities: [...capabilities].sort(), channels: [...channels].sort() }; } function isRuntimeImport(binding: Binding): boolean { @@ -201,6 +227,14 @@ function only(list: Binding[] | undefined): Binding | undefined { return list?.length === 1 ? list[0] : undefined; } +/** The non-computed member [id] is the object of, e.g. `client` in `http.client`; undefined otherwise. */ +function memberOf(id: AnyNode, parent: AnyNode | null): { node: AnyNode; name: string } | undefined { + if (parent?.type !== "MemberExpression" || parent["object"] !== id) return undefined; + const property = parent["property"] as AnyNode; + const name = parent["computed"] ? literal(property) : (property["name"] as string); + return name === undefined ? undefined : { node: parent, name }; +} + /** `host.call(…)` when [id] is its `host`; otherwise undefined. */ function hostCall(id: AnyNode, parent: AnyNode | null): AnyNode | undefined { if (parent?.type !== "MemberExpression" || parent["object"] !== id) return undefined; diff --git a/packages/cli/src/schema/dsl.ts b/packages/cli/src/schema/dsl.ts index 1dfe718..51cb231 100644 --- a/packages/cli/src/schema/dsl.ts +++ b/packages/cli/src/schema/dsl.ts @@ -1,7 +1,7 @@ // Component schema DSL: the single source of truth for built-in and host components (docs/components.md). // `tinyui schema` turns definitions into JSX prop types (TS) and registration schemas (Kotlin). -export type PropKind = "string" | "number" | "boolean" | "dp" | "sp" | "color" | "enum" | "size"; +export type PropKind = "string" | "number" | "boolean" | "dp" | "sp" | "color" | "enum" | "size" | "icon"; export type FieldKind = "string" | "number" | "boolean"; export interface PropDef { @@ -52,6 +52,8 @@ export const sp = (o: PropOptions & { default?: number } = {}) => prop("sp", o); export const color = (o: PropOptions & { default?: string } = {}) => prop("color", o); /** A number in dp, or "fill" / "wrap". */ export const size = (o: PropOptions & { default?: number | "fill" | "wrap" } = {}) => prop("size", o); +/** `"|"`: a single-colour icon, as the CLI's `.svg` loader produces it (docs/components.md §3). */ +export const icon = (o: PropOptions = {}) => prop("icon", o); export function enumOf(values: T, o: PropOptions & { default?: T[number] } = {}): PropDef { if (values.length === 0) throw new Error("enumOf() needs at least one value"); return { ...prop("enum", o), values }; @@ -111,4 +113,6 @@ export const LAYOUT_PROPS: Record = { padding: dp({ doc: "all four sides" }), paddingHorizontal: dp({ doc: "start and end; overrides padding on that axis" }), paddingVertical: dp({ doc: "top and bottom; overrides padding on that axis" }), + role: enumOf(["button", "checkbox", "switch", "radio", "tab"], { doc: "what a screen reader announces the clickable container as" }), + selected: boolean({ doc: "makes the clickable container one selectable node (announced selected / not selected)" }), }; diff --git a/packages/cli/src/schema/generate.ts b/packages/cli/src/schema/generate.ts index ee611ab..7f4bf6a 100644 --- a/packages/cli/src/schema/generate.ts +++ b/packages/cli/src/schema/generate.ts @@ -100,6 +100,7 @@ function tsPropType(p: PropDef): string { case "number": case "dp": case "sp": return "number"; case "boolean": return "boolean"; case "size": return 'number | "fill" | "wrap"'; + case "icon": return "string"; case "enum": return (p.values ?? []).map((v) => JSON.stringify(v)).join(" | "); } } @@ -115,6 +116,7 @@ function ktPropSpec(p: PropDef): string { case "sp": return `PropSpec.Sp(default = ${ktDouble(d)}, ${flags})`; case "color": return `PropSpec.ColorSpec(default = ${d === undefined ? "null" : JSON.stringify(d)}, ${flags})`; case "size": return `PropSpec.Size(default = ${d === undefined ? "null" : JSON.stringify(String(d))}, ${flags})`; + case "icon": return `PropSpec.IconSpec(${flags})`; case "enum": return `PropSpec.Enum(values = setOf(${(p.values ?? []).map((v) => JSON.stringify(v)).join(", ")}), default = ${d === undefined ? "null" : JSON.stringify(d)}, ${flags})`; } } diff --git a/packages/cli/src/snapshot.ts b/packages/cli/src/snapshot.ts index 56c6efd..6cf302a 100644 --- a/packages/cli/src/snapshot.ts +++ b/packages/cli/src/snapshot.ts @@ -1,3 +1,8 @@ +import { compareVersions } from "./version.ts"; + +/** The tinyui that added the `channels` section; older frozen snapshots lack it. */ +const CHANNELS_SINCE = "0.8.0"; + /** A host snapshot as `tinyui-host/.txt` holds it (docs/updates.md §6.4). */ export interface HostSnapshot { hostVersion: string; @@ -5,10 +10,12 @@ export interface HostSnapshot { tinyui: string; components: Set; capabilities: Set; + channels: Set; } /** - * Two header lines, then `components` and `capabilities` sections with one two-space-indented item per line. + * Two header lines, then `components`, `capabilities` and `channels` sections with one two-space-indented item per line. + * A snapshot frozen before channels existed has no `channels` section and provides none. * The first word of an item is its name; the rest is for people and for the host's own check, not for this parser. */ export function parseHostSnapshot(text: string): HostSnapshot { @@ -18,15 +25,15 @@ export function parseHostSnapshot(text: string): HostSnapshot { if (!match) throw new Error(`host snapshot line ${index + 1} must be "${key} ", got "${lines[index] ?? ""}"`); return match[1]!; }; - const snapshot: HostSnapshot = { hostVersion: header(0, "hostVersion"), tinyui: header(1, "tinyui"), components: new Set(), capabilities: new Set() }; - // both sections, once each, components first: a missing one would read as "provides nothing" and a check would pass on it - const order = ["components", "capabilities"] as const; + const snapshot: HostSnapshot = { hostVersion: header(0, "hostVersion"), tinyui: header(1, "tinyui"), components: new Set(), capabilities: new Set(), channels: new Set() }; + // every section, once each, in order: a missing one would read as "provides nothing" and a check would pass on it + const order = ["components", "capabilities", "channels"] as const; let seen = 0; let section: Set | undefined; lines.slice(2).forEach((line, i) => { if (line.trim() === "") return; if (line === order[seen]) { - section = line === "components" ? snapshot.components : snapshot.capabilities; + section = snapshot[line]; seen++; } else if (line.startsWith(" ") && section) { section.add(line.trim().split(/\s+/)[0]!); @@ -34,6 +41,7 @@ export function parseHostSnapshot(text: string): HostSnapshot { throw new Error(`host snapshot line ${i + 3}: expected ${seen < order.length ? `the "${order[seen]}" section` : "an item"}${section ? " or an indented item" : ""}, got "${line}"`); } }); + if (seen === 2 && compareVersions(snapshot.tinyui, CHANNELS_SINCE) < 0) return snapshot; if (seen < order.length) throw new Error(`host snapshot has no "${order[seen]}" section; write it even when empty`); return snapshot; } diff --git a/packages/cli/src/svg.ts b/packages/cli/src/svg.ts new file mode 100644 index 0000000..57c998e --- /dev/null +++ b/packages/cli/src/svg.ts @@ -0,0 +1,101 @@ +// The `.svg` loader of `tinyui build`: an SVG file becomes the icon string `Icon` takes (docs/components.md §3). + +export class SvgError extends Error {} + +/** `"|"` for a filled icon, `"||"` for an outlined one (fill none, stroke set on the root). */ +export function svgToIcon(source: string): string { + const text = source.replace(//g, "").replace(/<\?xml[\s\S]*?\?>/g, "").replace(//gi, ""); + const tags = [...text.matchAll(/<(\/?)([A-Za-z][\w:-]*)([^>]*?)(\/?)>/g)]; + const root = tags.find((t) => t[2] === "svg" && t[1] === ""); + if (!root) throw new SvgError("no element"); + const rootAttrs = attributes(root[3]!); + const viewBox = rootAttrs["viewBox"] ?? (rootAttrs["width"] && rootAttrs["height"] ? `0 0 ${num(rootAttrs["width"])} ${num(rootAttrs["height"])}` : undefined); + const box = viewBox?.trim().split(/[\s,]+/).map(Number); + if (!box || box.length !== 4 || !box.every(Number.isFinite) || box[2]! <= 0 || box[3]! <= 0) throw new SvgError("the needs a viewBox of four numbers with a positive width and height"); + for (const bad of ["transform", "style", "opacity", "fill-opacity", "clip-path", "mask", "filter"]) { + if (rootAttrs[bad] !== undefined) throw new SvgError(`: only plain single-colour shapes can be an icon`); + } + const stroked = rootAttrs["fill"] === "none" && rootAttrs["stroke"] !== undefined && rootAttrs["stroke"] !== "none"; + if (!stroked && rootAttrs["stroke"] !== undefined && rootAttrs["stroke"] !== "none") throw new SvgError(` on a filled icon: one icon is either filled or outlined (fill="none" and a stroke)`); + if (rootAttrs["fill"] === "none" && !stroked) throw new SvgError(` without a stroke draws nothing; an outlined icon sets both`); + if (rootAttrs["fill"] !== undefined && rootAttrs["fill"] !== "none" && rootAttrs["fill"] !== "currentColor") throw new SvgError(`: an icon takes one colour from its tint; remove the fill`); + const strokeWidth = stroked ? num(rootAttrs["stroke-width"] ?? "1") : undefined; + + const parts: string[] = []; + let depth = 0; + for (const [, closing, name, rest] of tags) { + const tag = name!; + if (tag === "svg" || tag === "title" || tag === "desc") continue; + if (closing) { if (tag === "g") depth--; continue; } + const a = attributes(rest!); + for (const bad of ["transform", "clip-path", "mask", "filter", "style", "opacity", "fill-opacity", "fill-rule"]) { + if (a[bad] !== undefined && !(bad === "fill-rule" && a[bad] === "nonzero")) throw new SvgError(`<${tag} ${bad}="…">: only plain single-colour shapes can be an icon`); + } + if (a["fill"] !== undefined && a["fill"] !== "currentColor" && a["fill"] !== "none" && !stroked) throw new SvgError(`<${tag} fill="${a["fill"]}">: an icon takes one colour from its tint; remove the fill`); + if (!stroked && a["stroke"] !== undefined && a["stroke"] !== "none") throw new SvgError(`<${tag} stroke="…"> in a filled icon; outlined icons set fill="none" and a stroke on the `); + if (stroked && ((a["stroke"] !== undefined && a["stroke"] !== "currentColor") || (a["stroke-width"] !== undefined && num(a["stroke-width"]) !== strokeWidth))) { + throw new SvgError(`<${tag}> overrides the stroke; an outlined icon has one stroke, set on the `); + } + if (!stroked && a["fill"] === "none") throw new SvgError(`<${tag} fill="none"> in a filled icon; outlined icons set fill="none" and a stroke on the `); + switch (tag) { + case "g": if (!(rest ?? "").trimEnd().endsWith("/")) depth++; break; + case "path": parts.push(required(a, "d", tag)); break; + case "circle": parts.push(ellipse(n(a, "cx"), n(a, "cy"), n(a, "r", true), n(a, "r", true))); break; + case "ellipse": parts.push(ellipse(n(a, "cx"), n(a, "cy"), n(a, "rx", true), n(a, "ry", true))); break; + case "rect": parts.push(rect(n(a, "x"), n(a, "y"), n(a, "width", true), n(a, "height", true), a["rx"] ?? a["ry"], a["ry"] ?? a["rx"])); break; + case "line": parts.push(`M${n(a, "x1")} ${n(a, "y1")}L${n(a, "x2")} ${n(a, "y2")}`); break; + case "polyline": + case "polygon": { + const points = required(a, "points", tag).trim().split(/[\s,]+/).map(Number); + if (points.length < 4 || points.length % 2 || points.some(Number.isNaN)) throw new SvgError(`<${tag}> points must be pairs of numbers`); + const pairs = []; + for (let i = 0; i < points.length; i += 2) pairs.push(`${points[i]} ${points[i + 1]}`); + parts.push(`M${pairs.join("L")}${tag === "polygon" ? "Z" : ""}`); + break; + } + default: throw new SvgError(`<${tag}> cannot be part of an icon (gradients, images, text, masks and references are not supported)`); + } + } + if (depth !== 0) throw new SvgError("unbalanced "); + if (!parts.length) throw new SvgError("no shapes"); + const boxText = box.join(" "); + return strokeWidth === undefined ? `${boxText}|${parts.join("")}` : `${boxText}|${parts.join("")}|${strokeWidth}`; +} + +function attributes(text: string): Record { + const out: Record = {}; + for (const m of text.matchAll(/([\w:-]+)\s*=\s*("([^"]*)"|'([^']*)')/g)) out[m[1]!] = m[3] ?? m[4] ?? ""; + return out; +} + +function required(a: Record, key: string, tag: string): string { + const v = a[key]; + if (v === undefined || v.trim() === "") throw new SvgError(`<${tag}> needs ${key}`); + return v.trim(); +} + +function num(v: string): number { + const x = Number.parseFloat(v); + if (Number.isNaN(x)) throw new SvgError(`"${v}" is not a number`); + return x; +} + +function n(a: Record, key: string, needed = false): number { + const v = a[key]; + if (v === undefined) { + if (needed) throw new SvgError(`missing ${key}`); + return 0; + } + return num(v); +} + +function ellipse(cx: number, cy: number, rx: number, ry: number): string { + return `M${cx - rx} ${cy}a${rx} ${ry} 0 1 0 ${2 * rx} 0a${rx} ${ry} 0 1 0 ${-2 * rx} 0Z`; +} + +function rect(x: number, y: number, w: number, h: number, rxText?: string, ryText?: string): string { + const rx = Math.min(rxText === undefined ? 0 : num(rxText), w / 2); + const ry = Math.min(ryText === undefined ? 0 : num(ryText), h / 2); + if (rx === 0 || ry === 0) return `M${x} ${y}h${w}v${h}h${-w}Z`; + return `M${x + rx} ${y}h${w - 2 * rx}a${rx} ${ry} 0 0 1 ${rx} ${ry}v${h - 2 * ry}a${rx} ${ry} 0 0 1 ${-rx} ${ry}h${-(w - 2 * rx)}a${rx} ${ry} 0 0 1 ${-rx} ${-ry}v${-(h - 2 * ry)}a${rx} ${ry} 0 0 1 ${rx} ${-ry}Z`; +} diff --git a/packages/cli/src/version.ts b/packages/cli/src/version.ts index f568c71..f93f822 100644 --- a/packages/cli/src/version.ts +++ b/packages/cli/src/version.ts @@ -46,3 +46,8 @@ function compare(a: Version, b: Version): number { } return a.pre.length - b.pre.length; } + +/** Negative when [a] comes before [b] in semver precedence, positive after, zero when equal. */ +export function compareVersions(a: string, b: string): number { + return compare(parse(a), parse(b)); +} diff --git a/packages/cli/test/i18n.test.ts b/packages/cli/test/i18n.test.ts new file mode 100644 index 0000000..1c08859 --- /dev/null +++ b/packages/cli/test/i18n.test.ts @@ -0,0 +1,50 @@ +import assert from "node:assert/strict"; +import { mkdir, mkdtemp, rm, writeFile } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { after, before, describe, it } from "node:test"; +import { i18nTypes, I18nError, loadI18n } from "../src/i18n.ts"; +import type { TinyUIConfig } from "../src/config.ts"; + +const config = (defaultLocale?: string): TinyUIConfig => ({ name: "shop", publicKey: "", pages: "src/pages", i18n: "i18n", ...(defaultLocale && { defaultLocale }) }); + +describe("package i18n", () => { + let root: string; + before(async () => { root = await mkdtemp(join(tmpdir(), "tinyui-i18n-")); }); + after(() => rm(root, { recursive: true, force: true })); + + async function strings(files: Record) { + await rm(join(root, "i18n"), { recursive: true, force: true }); + await mkdir(join(root, "i18n")); + for (const [name, value] of Object.entries(files)) await writeFile(join(root, "i18n", name), JSON.stringify(value)); + } + + it("loads every language and types the default one's keys and placeholders", async () => { + await strings({ "en.json": { "sub.title": "Pro", "sub.save": "Save {percent}" }, "zh.json": { "sub.title": "专业版", "sub.save": "省 {percent}" } }); + const i18n = (await loadI18n(root, config("en")))!; + assert.deepEqual([...i18n.paths], [["en", "i18n/en.json"], ["zh", "i18n/zh.json"]]); + const types = i18nTypes(i18n); + assert.match(types, /"sub\.save": "percent";/); + assert.match(types, /"sub\.title": never;/); + }); + + it("refuses missing and extra keys, mismatched placeholders and a missing default", async () => { + await strings({ "en.json": { a: "A {x}", b: "B" }, "zh.json": { a: "甲", c: "丙" } }); + await assert.rejects(loadI18n(root, config("en")), (e: unknown) => { + assert.ok(e instanceof I18nError); + assert.match(e.message, /zh\.json lacks "b"/); + assert.match(e.message, /zh\.json has "c"/); + assert.match(e.message, /"a" has placeholders \{\}, en\.json has \{x\}/); + return true; + }); + await assert.rejects(loadI18n(root, config("fr")), /no fr\.json/); + await strings({ "en.json": { a: "A" }, "zh.json": { a: { nested: true } } }); + await assert.rejects(loadI18n(root, config("en")), /zh\.json: "a" must be a string/); + await assert.rejects(loadI18n(root, config()), /no "defaultLocale"/); + }); + + it("is nothing for a package without strings", async () => { + await rm(join(root, "i18n"), { recursive: true, force: true }); + assert.equal(await loadI18n(root, config()), null); + }); +}); diff --git a/packages/cli/test/requires.test.ts b/packages/cli/test/requires.test.ts index 5d50487..170d54a 100644 --- a/packages/cli/test/requires.test.ts +++ b/packages/cli/test/requires.test.ts @@ -30,10 +30,10 @@ describe("host requirements of a page", () => { const core = JSON.parse(await readFile(join(import.meta.dirname, "..", "..", "core", "package.json"), "utf8")) as { version: string }; assert.equal(manifest.tinyui, core.version, "the tinyui-core the runtime modules came from"); assert.deepEqual(manifest.requires, { - "shop/plain": { components: [], capabilities: [] }, + "shop/plain": { components: [], capabilities: [], channels: [] }, // ta.Badge through the local PlanCard, ta.Loading through a hand-written h() choosing between two; // ta.Unused is in the generated object but never rendered - "shop/shop": { components: ["ta.Badge", "ta.Icon", "ta.Loading"], capabilities: ["billing.prices", "checkout.start"] }, + "shop/shop": { components: ["ta.Badge", "ta.Icon", "ta.Loading"], capabilities: ["billing.prices", "checkout.start"], channels: [] }, }); }); @@ -53,6 +53,20 @@ describe("host requirements of a page", () => { }); }); + it("lists http.client channels and a session.signIn, and nothing it cannot follow", () => { + const HTTP = `import { http, session } from "tinyui-native";\n`; + const page = analyzePage("shop/home", `${HTTP}http.get("https://x");\nconst api = http.client("app");\nhttp["client"]("pay").post("/p");\nhttp.client("default").get("/d");\nsession.state();\nsession.signIn("paywall");\n`); + assert.deepEqual(page.channels, ["app", "pay"]); + assert.deepEqual(page.capabilities, ["session.signIn"]); + assert.deepEqual(analyzePage("shop/home", `${HTTP}session.state();\n`).capabilities, [], "reading the session is not a use of signIn"); + const ns = analyzePage("shop/home", `import * as native from "tinyui-native";\nnative.http.client("app");\nnative.session.signIn();\n`); + assert.deepEqual([ns.channels, ns.capabilities], [["app"], ["session.signIn"]]); + rejects(`${HTTP}const name = "app";\nhttp.client(name);\n`, /argument of http\.client must be a string literal/); + rejects(`${HTTP}const c = http.client;\n`, /http\.client may only be called/); + rejects(`${HTTP}const h2 = http;\n`, /http may only be used as/); + rejects(`${HTTP}const s = session;\n`, /session may only be used as/); + }); + it("takes literal capability names, bracketed or backquoted, and only those", () => { const code = `${NATIVE}host.call("a.b");\nhost["call"]("c.d", {});\nhost.call(\`e.f\`);\n`; assert.deepEqual(analyzePage("shop/home", code).capabilities, ["a.b", "c.d", "e.f"]); diff --git a/packages/cli/test/snapshot.test.ts b/packages/cli/test/snapshot.test.ts index 76b0918..846fec4 100644 --- a/packages/cli/test/snapshot.test.ts +++ b/packages/cli/test/snapshot.test.ts @@ -4,13 +4,15 @@ import { parseHostSnapshot } from "../src/snapshot.ts"; describe("host snapshot", () => { it("reads the header and the names, leaving the rest of each line to people", () => { - const snapshot = parseHostSnapshot("hostVersion 2\r\ntinyui 0.3.0\r\n\r\ncomponents\r\n ta.Icon name: string, size?: dp\r\n ta.Loading\r\n\r\ncapabilities\r\n checkout.start\r\n plain\r\n"); + const snapshot = parseHostSnapshot("hostVersion 2\r\ntinyui 0.8.0\r\n\r\ncomponents\r\n ta.Icon name: string, size?: dp\r\n ta.Loading\r\n\r\ncapabilities\r\n checkout.start\r\n plain\r\n\r\nchannels\r\n app\r\n"); assert.equal(snapshot.hostVersion, "2"); - assert.equal(snapshot.tinyui, "0.3.0"); + assert.equal(snapshot.tinyui, "0.8.0"); + assert.deepEqual([...snapshot.channels], ["app"]); assert.deepEqual([...snapshot.components], ["ta.Icon", "ta.Loading"]); assert.deepEqual([...snapshot.capabilities], ["checkout.start", "plain"]); const empty = parseHostSnapshot("hostVersion 1\ntinyui 0.3.0\n\ncomponents\n\ncapabilities\n"); - assert.equal(empty.components.size + empty.capabilities.size, 0); + assert.equal(empty.components.size + empty.capabilities.size + empty.channels.size, 0, "frozen before channels existed: provides none"); + assert.throws(() => parseHostSnapshot("hostVersion 1\ntinyui 0.8.0\n\ncomponents\n\ncapabilities\n"), /no "channels" section/); }); it("refuses anything it would have to guess at", () => { @@ -18,11 +20,12 @@ describe("host snapshot", () => { assert.throws(() => parseHostSnapshot("hostVersion 1\n"), /line 2 must be "tinyui "/); assert.throws(() => parseHostSnapshot("hostVersion 1\ntinyui 0.3.0\n ta.Icon\n"), /line 3: expected the "components" section/); assert.throws(() => parseHostSnapshot("hostVersion 1\ntinyui 0.3.0\nwidgets\n ta.Icon\n"), /line 3: expected the "components" section/); - assert.throws(() => parseHostSnapshot("hostVersion 1\ntinyui 0.3.0\ncomponents\ncapabilities\nplain\n"), /line 5: expected an item/); + assert.throws(() => parseHostSnapshot("hostVersion 1\ntinyui 0.3.0\ncomponents\ncapabilities\nplain\n"), /line 5: expected the "channels" section/); + assert.throws(() => parseHostSnapshot("hostVersion 1\ntinyui 0.8.0\ncomponents\ncapabilities\nchannels\nplain\n"), /line 6: expected an item/); // both sections, once each, in order: a missing one would read as "provides nothing" assert.throws(() => parseHostSnapshot("hostVersion 1\ntinyui 0.3.0\n"), /no "components" section/); assert.throws(() => parseHostSnapshot("hostVersion 1\ntinyui 0.3.0\ncomponents\n ta.Icon\n"), /no "capabilities" section/); assert.throws(() => parseHostSnapshot("hostVersion 1\ntinyui 0.3.0\ncapabilities\ncomponents\n"), /line 3: expected the "components" section/); - assert.throws(() => parseHostSnapshot("hostVersion 1\ntinyui 0.3.0\ncomponents\ncapabilities\ncomponents\n"), /line 5: expected an item/); + assert.throws(() => parseHostSnapshot("hostVersion 1\ntinyui 0.3.0\ncomponents\ncapabilities\ncomponents\n"), /line 5: expected the "channels" section/); }); }); diff --git a/packages/cli/test/svg.test.ts b/packages/cli/test/svg.test.ts new file mode 100644 index 0000000..ffffcfa --- /dev/null +++ b/packages/cli/test/svg.test.ts @@ -0,0 +1,40 @@ +import assert from "node:assert/strict"; +import { describe, it } from "node:test"; +import { SvgError, svgToIcon } from "../src/svg.ts"; + +describe("svg loader", () => { + it("turns a filled Material Symbols file into viewBox|d", () => { + const svg = ``; + assert.equal(svgToIcon(svg), "0 -960 960 960|M480-80 L80-480Z"); + }); + + it("merges shapes into one path and keeps the stroke width of an outlined icon", () => { + const svg = ``; + const icon = svgToIcon(svg); + const [box, d, width] = icon.split("|"); + assert.equal(box, "0 0 24 24"); + assert.equal(width, "2"); + assert.match(d!, /^M2 12a10 10 0 1 0 20 0a10 10 0 1 0 -20 0Z/); + assert.match(d!, /M12 8L12 12/); + assert.match(d!, /M1 1L2 2$/); + }); + + it("refuses what one tint cannot draw", () => { + const wrap = (inner: string) => `${inner}`; + assert.throws(() => svgToIcon(wrap(``)), SvgError); + assert.throws(() => svgToIcon(wrap(``)), /cannot be part of an icon/); + assert.throws(() => svgToIcon(wrap(``)), /only plain single-colour shapes/); + assert.throws(() => svgToIcon(``), /viewBox/); + assert.throws(() => svgToIcon(``), /viewBox/); + assert.throws(() => svgToIcon(``), /positive width/); + assert.throws(() => svgToIcon(wrap("")), /no shapes/); + assert.throws(() => svgToIcon(``), /without a stroke/); + assert.throws(() => svgToIcon(``), /either filled or outlined/); + assert.throws(() => svgToIcon(wrap(``)), /stroke="…"> in a filled icon/); + const outlined = (inner: string) => `${inner}`; + assert.throws(() => svgToIcon(outlined(``)), /overrides the stroke/); + assert.throws(() => svgToIcon(outlined(``)), /overrides the stroke/); + assert.doesNotThrow(() => svgToIcon(outlined(``))); + assert.throws(() => svgToIcon(``), /only plain single-colour shapes/); + }); +}); diff --git a/packages/core/assets.d.ts b/packages/core/assets.d.ts new file mode 100644 index 0000000..b829376 --- /dev/null +++ b/packages/core/assets.d.ts @@ -0,0 +1,7 @@ +// Types for what `tinyui build` loads besides code; reference with "types": ["tinyui-core/assets"] (docs/components.md §3). + +/** An icon: the string `Icon` takes, made by the build from a single-colour SVG. */ +declare module "*.svg" { + const icon: string; + export default icon; +} diff --git a/packages/core/package.json b/packages/core/package.json index 3d3a7cc..0d44ea3 100644 --- a/packages/core/package.json +++ b/packages/core/package.json @@ -16,10 +16,14 @@ "./jsx-dev-runtime": { "types": "./dist/jsx-runtime.d.ts", "default": "./dist/jsx-runtime.js" + }, + "./assets": { + "types": "./assets.d.ts" } }, "files": [ - "dist" + "dist", + "assets.d.ts" ], "scripts": { "build": "tsc -p tsconfig.json", diff --git a/packages/core/src/generated/components.ts b/packages/core/src/generated/components.ts index c7dc0e6..ba8bbeb 100644 --- a/packages/core/src/generated/components.ts +++ b/packages/core/src/generated/components.ts @@ -16,6 +16,8 @@ export interface LayoutProps { padding?: number; paddingHorizontal?: number; paddingVertical?: number; + role?: "button" | "checkbox" | "switch" | "radio" | "tab"; + selected?: boolean; } export interface ColumnClickPayload { @@ -165,6 +167,26 @@ export interface LazyColumnProps extends LayoutProps { export interface SpacerProps extends LayoutProps { } +export interface IconClickPayload { +} + +/** A single-colour icon whose data comes with the package (`import bolt from "….svg"`); tinted, never ships an icon set. */ +export interface IconProps extends LayoutProps { + icon: string; + size?: number; + /** defaults to the content colour */ + tint?: ColorValue; + /** what a screen reader says; leave unset for a decorative icon next to its text */ + label?: string; + onClick?: (e: IconClickPayload) => void; +} + +/** Indeterminate M3 loading indicator. */ +export interface LoadingProps extends LayoutProps { + size?: number; + color?: ColorValue; +} + export interface IntrinsicElements { Column: ColumnProps; Row: RowProps; @@ -175,6 +197,8 @@ export interface IntrinsicElements { TextField: TextFieldProps; LazyColumn: LazyColumnProps; Spacer: SpacerProps; + Icon: IconProps; + Loading: LoadingProps; } export const Column = "Column"; @@ -186,3 +210,5 @@ export const RadioButton = "RadioButton"; export const TextField = "TextField"; export const LazyColumn = "LazyColumn"; export const Spacer = "Spacer"; +export const Icon = "Icon"; +export const Loading = "Loading"; diff --git a/packages/core/src/host.ts b/packages/core/src/host.ts index c6481f6..231b269 100644 --- a/packages/core/src/host.ts +++ b/packages/core/src/host.ts @@ -12,11 +12,24 @@ interface Pending { site?: string; } +/** What an `E_HTTP` rejection carries besides its code (docs/native-api.md §6). */ +export interface HostErrorDetails { + status?: number; + headers?: Record; + body?: unknown; +} + export class HostError extends Error { readonly code: string; - constructor(code: string, message: string) { + readonly status: number | undefined; + readonly headers: Record | undefined; + readonly body: unknown; + constructor(code: string, message: string, details: HostErrorDetails = {}) { super(message); this.code = code; + this.status = details.status; + this.headers = details.headers; + this.body = details.body; } } @@ -69,13 +82,13 @@ export function rejectPending(cbId: number, errorJson: string): void { const p = pending.get(cbId); if (!p) return; pending.delete(cbId); - let e: { code?: string; message?: string } = {}; + let e: { code?: string; message?: string } & HostErrorDetails = {}; try { - e = JSON.parse(errorJson) as { code?: string; message?: string }; + e = JSON.parse(errorJson) as typeof e; } catch { e = { code: "E_BAD_JSON", message: errorJson }; } - const error = new HostError(e.code ?? "unknown", e.message ?? "host call failed"); + const error = new HostError(e.code ?? "unknown", e.message ?? "host call failed", e); if (p.site !== undefined) error.stack = p.site; p.reject(error); } diff --git a/packages/core/src/index.ts b/packages/core/src/index.ts index 43fe1c5..c2ac1ea 100644 --- a/packages/core/src/index.ts +++ b/packages/core/src/index.ts @@ -10,6 +10,7 @@ export type { ForProps, ShowProps } from "./control.ts"; export { resource } from "./resource.ts"; export type { ResourceActions } from "./resource.ts"; export { HostError } from "./host.ts"; +export type { HostErrorDetails } from "./host.ts"; export * as internal from "./internal.ts"; export * from "./components.ts"; @@ -19,4 +20,7 @@ declare global { /** `tinyui:`, e.g. `tinyui:sample/home`. */ readonly url: string; } + /** Runs [fn] once after [ms], in a transaction of its own; gone with the page (docs/runtime-api.md §7). */ + function setTimeout(fn: () => void, ms?: number): number; + function clearTimeout(id: number | undefined): void; } diff --git a/packages/core/src/internal.ts b/packages/core/src/internal.ts index b11d193..e44c1d2 100644 --- a/packages/core/src/internal.ts +++ b/packages/core/src/internal.ts @@ -1,3 +1,3 @@ // The contract between tinyui-core and tinyui-native (docs/runtime-api.md §9); page code has no business here. export { call, query, send } from "./host.ts"; -export { onEmit } from "./page.ts"; +export { onEmit, listen } from "./page.ts"; diff --git a/packages/core/src/page.ts b/packages/core/src/page.ts index 8345845..a879aaa 100644 --- a/packages/core/src/page.ts +++ b/packages/core/src/page.ts @@ -31,6 +31,13 @@ export function onEmit(topic: string, fn: (payload: unknown) => void): void { onCleanup(() => { set.delete(fn); }); } +/** A K5 subscription for the life of the page, for runtime state that is not tied to a component (session, locale). */ +export function listen(topic: string, fn: (payload: unknown) => void): void { + let set = topics.get(topic); + if (!set) topics.set(topic, (set = new Set())); + set.add(fn); +} + function guarded(entry: string, fn: () => void): void { try { fn(); diff --git a/packages/native/src/analytics.ts b/packages/native/src/analytics.ts new file mode 100644 index 0000000..2d8c238 --- /dev/null +++ b/packages/native/src/analytics.ts @@ -0,0 +1,14 @@ +// docs/native-api.md §12: event names and fields belong to the package; the host only forwards them. +import { HostError, internal } from "tinyui-core"; + +export type Props = Record; + +/** J4, no result. [props] is one flat level of primitives. */ +export function track(name: string, props: Props = {}): void { + for (const [k, v] of Object.entries(props)) { + if (v !== null && typeof v !== "string" && typeof v !== "number" && typeof v !== "boolean") { + throw new HostError("E_INVALID", `analytics.track("${name}"): ${k} is not a string, number, boolean or null`); + } + } + internal.send("analytics.track", { name, props }); +} diff --git a/packages/native/src/events.ts b/packages/native/src/events.ts index c681170..80dda0a 100644 --- a/packages/native/src/events.ts +++ b/packages/native/src/events.ts @@ -1,4 +1,4 @@ -// docs/native-api.md §4: one bus for host events and business events. +// docs/native-api.md §5: one bus for host events and business events. import { internal } from "tinyui-core"; /** J4: delivered to every page subscribed to `topic`, including native listeners. */ diff --git a/packages/native/src/host.ts b/packages/native/src/host.ts index e069a56..bf18c1e 100644 --- a/packages/native/src/host.ts +++ b/packages/native/src/host.ts @@ -1,4 +1,4 @@ -// docs/native-api.md §7: J3 for capabilities the host App registers under its own names. +// docs/native-api.md §13: J3 for capabilities the host App registers under its own names. import { internal } from "tinyui-core"; /** Rejects with HostError E_UNSUPPORTED when the host did not register [name]. */ diff --git a/packages/native/src/http.ts b/packages/native/src/http.ts index 1653f1c..11c5a0e 100644 --- a/packages/native/src/http.ts +++ b/packages/native/src/http.ts @@ -1,21 +1,52 @@ -// docs/native-api.md §5: J3 `http.request`, errors are HostError with the E3 codes. +// docs/native-api.md §6: J3 `http.request` through a channel; non-2xx rejects E_HTTP with status, headers and body. import { internal } from "tinyui-core"; export interface HttpOptions { headers?: Record; - /** ms; the host rejects with E_TIMEOUT */ + /** ms; rejects with E_TIMEOUT */ timeout?: number; } export interface HttpResponse { status: number; + headers: Record; /** parsed JSON body, or the text when it is not JSON */ body: T; } -export function request(method: string, url: string, body?: unknown, options: HttpOptions = {}): Promise> { - return internal.call>("http.request", { method, url, ...(body !== undefined && { body }), ...options }); +export interface HttpClient { + request(method: string, url: string, body?: unknown, options?: HttpOptions): Promise>; + get(url: string, options?: HttpOptions): Promise>; + post(url: string, body?: unknown, options?: HttpOptions): Promise>; + put(url: string, body?: unknown, options?: HttpOptions): Promise>; + delete(url: string, options?: HttpOptions): Promise>; } -export const get = (url: string, options?: HttpOptions) => request("GET", url, undefined, options); -export const post = (url: string, body?: unknown, options?: HttpOptions) => request("POST", url, body, options); +function channel(name: string): HttpClient { + const request = (method: string, url: string, body?: unknown, options: HttpOptions = {}) => + internal.call>("http.request", { channel: name, method, url, ...(body !== undefined && { body }), ...options }); + return { + request, + get: (url, options) => request("GET", url, undefined, options), + post: (url, body, options) => request("POST", url, body, options), + put: (url, body, options) => request("PUT", url, body, options), + delete: (url, options) => request("DELETE", url, undefined, options), + }; +} + +const fallback = channel("default"); + +/** + * A channel the host registered (`"app"`): its requests go out with the host's identity. [name] must be a string + * literal so `tinyui build` can list it (docs/build-chain.md §5.1). + */ +export function client(name: string): HttpClient { + return channel(name); +} + +export const request = fallback.request; +export const get = fallback.get; +export const post = fallback.post; +export const put = fallback.put; +export { del as delete }; +const del = fallback.delete; diff --git a/packages/native/src/i18n.ts b/packages/native/src/i18n.ts new file mode 100644 index 0000000..5a92f1f --- /dev/null +++ b/packages/native/src/i18n.ts @@ -0,0 +1,36 @@ +// docs/native-api.md §8: the package's own strings; the framework loads them and follows the host's language. +import { internal, signal } from "tinyui-core"; + +/** Augmented by `tinyui i18n --ts`: each key and the names of its `{placeholders}` (never when it has none). */ +export interface I18nKeys {} + +type Key = [keyof I18nKeys] extends [never] ? string : keyof I18nKeys & string; +type Args = K extends keyof I18nKeys + ? [I18nKeys[K]] extends [never] ? [] : [args: Record] + : [args?: Record]; + +const [current, setCurrent] = signal(""); +let subscribed = false; + +function follow(): void { + if (subscribed) return; + subscribed = true; + setCurrent(internal.query("i18n.locale")); + internal.send("i18n.subscribe", {}); + internal.listen("i18n.locale", (p) => setCurrent((p as { locale: string }).locale)); +} + +/** The language strings resolve against (BCP 47); an accessor, bindings re-run when the host changes it. */ +export function locale(): string { + follow(); + return current(); +} + +/** The string for [key] in the current language, falling back towards the package default; `{name}` filled from args. */ +export function t(key: K, ...rest: Args): string { + follow(); + current(); + const text = internal.query("i18n.t", { key }); + const args = rest[0] as Record | undefined; + return args ? text.replace(/\{([A-Za-z0-9_]+)\}/g, (m, name: string) => (name in args ? String(args[name]) : m)) : text; +} diff --git a/packages/native/src/index.ts b/packages/native/src/index.ts index 66a9c9a..0626c04 100644 --- a/packages/native/src/index.ts +++ b/packages/native/src/index.ts @@ -4,5 +4,15 @@ export * as store from "./store.ts"; export * as events from "./events.ts"; export * as http from "./http.ts"; export * as host from "./host.ts"; -export type { HttpResponse, HttpOptions } from "./http.ts"; +export * as storage from "./storage.ts"; +export * as i18n from "./i18n.ts"; +export * as session from "./session.ts"; +export * as ui from "./ui.ts"; +export * as linking from "./linking.ts"; +export * as analytics from "./analytics.ts"; +export { cached } from "./storage.ts"; +export type { HttpResponse, HttpOptions, HttpClient } from "./http.ts"; +export type { CachedActions } from "./storage.ts"; +export type { I18nKeys } from "./i18n.ts"; +export type { Session } from "./session.ts"; export { HostError } from "tinyui-core"; diff --git a/packages/native/src/linking.ts b/packages/native/src/linking.ts new file mode 100644 index 0000000..0c3b645 --- /dev/null +++ b/packages/native/src/linking.ts @@ -0,0 +1,7 @@ +// docs/native-api.md §11 +import { internal } from "tinyui-core"; + +/** Hands [url] to the host's link opener (or the system); resolves once handed over, not when the user returns. */ +export function openUrl(url: string): Promise { + return internal.call("linking.openUrl", { url }); +} diff --git a/packages/native/src/navigation.ts b/packages/native/src/navigation.ts index c6f4ce6..be37634 100644 --- a/packages/native/src/navigation.ts +++ b/packages/native/src/navigation.ts @@ -1,4 +1,4 @@ -// docs/native-api.md §2; the host implements Navigator (docs/app-model.md). +// docs/native-api.md §3; the host implements Navigator (docs/app-model.md). import { internal } from "tinyui-core"; export type Params = Record; diff --git a/packages/native/src/session.ts b/packages/native/src/session.ts new file mode 100644 index 0000000..16a742d --- /dev/null +++ b/packages/native/src/session.ts @@ -0,0 +1,27 @@ +// docs/native-api.md §9: the login session the host provides; no token ever reaches the page. +import { internal, signal } from "tinyui-core"; + +export interface Session { + loggedIn: boolean; + /** Stable and not secret; null when logged out. */ + userId: string | null; +} + +const [current, setCurrent] = signal({ loggedIn: false, userId: null }); +let subscribed = false; + +/** An accessor kept current by the host; bindings re-run on login and logout. */ +export function state(): Session { + if (!subscribed) { + subscribed = true; + setCurrent(internal.query("session.get")); + internal.send("session.subscribe", {}); + internal.listen("session", (p) => setCurrent(p as Session)); + } + return current(); +} + +/** Starts the host's login flow; settles when the flow ends, read [state] for the outcome. E_UNSUPPORTED without a session. */ +export function signIn(source = ""): Promise { + return internal.call("session.signIn", { source }); +} diff --git a/packages/native/src/storage.ts b/packages/native/src/storage.ts new file mode 100644 index 0000000..1fe4166 --- /dev/null +++ b/packages/native/src/storage.ts @@ -0,0 +1,67 @@ +// docs/native-api.md §7: this package's persistent key-value, read synchronously from memory. +import { HostError, internal, onCleanup, signal } from "tinyui-core"; + +/** J2: the stored value, or undefined. */ +export function get(key: string): T | undefined { + const v = internal.query("storage.get", { key }); + return v === null ? undefined : v; +} + +/** J2: throws HostError E_QUOTA when the package would exceed its limit, E_UNSUPPORTED when the host has no storage. */ +export function set(key: string, value: unknown): void { + const code = internal.query("storage.set", { key, value: JSON.stringify(value) }); + if (code !== null) throw new HostError(code, `storage.set("${key}") failed: ${code}`); +} + +/** J4 */ +export function remove(key: string): void { + internal.send("storage.remove", { key }); +} + +/** J4: every key of this package. */ +export function clear(): void { + internal.send("storage.clear", {}); +} + +export interface CachedActions { + loading: () => boolean; + error: () => unknown; + refetch: () => void; +} + +/** + * Stale-while-revalidate over [storage]: starts with the stored value, then runs [fetcher]; a success replaces it + * and is stored, a failure keeps it and sets `error`. Shaped like `resource`. + */ +export function cached(key: string, fetcher: () => Promise): [data: () => T | undefined, actions: CachedActions] { + const [data, setData] = signal(get(key)); + const [loading, setLoading] = signal(false); + const [error, setError] = signal(undefined); + let disposed = false; + let latest = 0; + onCleanup(() => { disposed = true; }); + const run = () => { + // only the newest run may settle: an older fetch finishing late must not overwrite it + const run = ++latest; + const current = () => !disposed && run === latest; + setLoading(true); + setError(undefined); + let pending: Promise; + try { + pending = fetcher(); + } catch (e) { + pending = Promise.reject(e); + } + pending.then( + (v) => { + if (!current()) return; + setData(() => v); + setLoading(false); + try { set(key, v); } catch (e) { setError(e); } + }, + (e: unknown) => { if (!current()) return; setError(e); setLoading(false); }, + ); + }; + run(); + return [data, { loading, error, refetch: run }]; +} diff --git a/packages/native/src/store.ts b/packages/native/src/store.ts index 42eb569..534ba72 100644 --- a/packages/native/src/store.ts +++ b/packages/native/src/store.ts @@ -1,4 +1,4 @@ -// docs/native-api.md §3: the truth lives in Kotlin; pages read snapshots and subscribe to changes. +// docs/native-api.md §4: the truth lives in Kotlin; pages read snapshots and subscribe to changes. import { internal, signal } from "tinyui-core"; /** J2: the current value, or undefined. */ diff --git a/packages/native/src/ui.ts b/packages/native/src/ui.ts new file mode 100644 index 0000000..2c85157 --- /dev/null +++ b/packages/native/src/ui.ts @@ -0,0 +1,32 @@ +// docs/native-api.md §10: M3 snackbar and text dialogs drawn by the page itself. +import { internal } from "tinyui-core"; + +export interface ToastOptions { + action?: string; + duration?: "short" | "long"; +} + +/** Resolves "action" when the action was tapped, "dismissed" otherwise. */ +export function toast(message: string, options: ToastOptions = {}): Promise<"action" | "dismissed"> { + return internal.call("ui.toast", { message, ...options }); +} + +export interface AlertOptions { + title?: string; + message: string; + /** defaults to the framework's own "OK" in the current language */ + confirm?: string; +} + +export function alert(options: AlertOptions): Promise { + return internal.call("ui.alert", { ...options }); +} + +export interface ConfirmOptions extends AlertOptions { + cancel?: string; +} + +/** True when confirmed; dismissing counts as cancel. */ +export function confirm(options: ConfirmOptions): Promise { + return internal.call("ui.confirm", { ...options }); +} diff --git a/packages/native/test/native.test.ts b/packages/native/test/native.test.ts index 25f9c95..b30ee86 100644 --- a/packages/native/test/native.test.ts +++ b/packages/native/test/native.test.ts @@ -2,7 +2,7 @@ import assert from "node:assert/strict"; import { afterEach, describe, it } from "node:test"; import { bridge } from "./host-stub.ts"; import { Column, h, Text, thunk } from "tinyui-core"; -import { events, host, http, navigation, store } from "../src/index.ts"; +import { analytics, cached, events, HostError, host, http, i18n, linking, navigation, session, storage, store, ui } from "../src/index.ts"; type Entries = { mount(page: unknown, props: string, host: string): void; unmount(): void; flush(): void; resolve(id: number, json: string): void; emit(topic: string, json: string): void }; const tinyui = (): Entries => (globalThis as Record)["__tinyui"] as Entries; @@ -15,10 +15,101 @@ describe("tinyui-native", () => { const p = http.get<{ ok: boolean }>("/me", { timeout: 100 }); const call = bridge.calls.at(-1)!; assert.equal(call.name, "http.request"); - assert.deepEqual(call.args, { method: "GET", url: "/me", timeout: 100 }); - tinyui().resolve(call.cbId, JSON.stringify({ status: 200, body: { ok: true } })); + assert.deepEqual(call.args, { channel: "default", method: "GET", url: "/me", timeout: 100 }); + tinyui().resolve(call.cbId, JSON.stringify({ status: 200, headers: {}, body: { ok: true } })); await drain(); - assert.deepEqual(await p, { status: 200, body: { ok: true } }); + assert.deepEqual(await p, { status: 200, headers: {}, body: { ok: true } }); + }); + + it("http.client names its channel, and E_HTTP carries status and body", async () => { + const p = http.client("app").post("https://api/x", { plan: "annual" }); + const call = bridge.calls.at(-1)!; + assert.deepEqual(call.args, { channel: "app", method: "POST", url: "https://api/x", body: { plan: "annual" } }); + const rejected = assert.rejects(p, (e: HostError) => e.code === "E_HTTP" && e.status === 409 && (e.body as { error: string }).error === "dup"); + ((globalThis as Record)["__tinyui"] as { reject(id: number, json: string): void }) + .reject(call.cbId, JSON.stringify({ code: "E_HTTP", message: "409", status: 409, headers: { a: "b" }, body: { error: "dup" } })); + await rejected; + }); + + it("storage reads and writes through J2; a returned code throws", () => { + bridge.queries["storage.get"] = { v: 1 }; + assert.deepEqual(storage.get("k"), { v: 1 }); + bridge.queries["storage.set"] = null; + storage.set("k", { v: 2 }); + bridge.queries["storage.set"] = "E_QUOTA"; + assert.throws(() => storage.set("k", "x".repeat(10)), (e: HostError) => e.code === "E_QUOTA"); + storage.remove("k"); + assert.deepEqual(bridge.sent.at(-1), { name: "storage.remove", args: { key: "k" } }); + }); + + it("cached starts from storage and replaces it with the fetched value", async () => { + bridge.queries["storage.get"] = "old"; + bridge.queries["storage.set"] = null; + let data: () => string | undefined = () => undefined; + tinyui().mount(() => { [data] = cached("c", () => Promise.resolve("new")); return h(Text, { text: "x" }); }, "{}", HOST); + assert.equal(data(), "old"); + await drain(); + assert.equal(data(), "new"); + }); + + it("cached keeps only the newest run and turns a throwing fetcher into its error", async () => { + bridge.queries["storage.get"] = null; + bridge.queries["storage.set"] = null; + const resolvers: ((v: string) => void)[] = []; + let first = true; + let data: () => string | undefined = () => undefined; + let actions: { error: () => unknown; refetch: () => void } | undefined; + tinyui().mount(() => { + [data, actions] = cached("r", () => { + if (first) { first = false; throw new Error("sync"); } + return new Promise((r) => resolvers.push(r)); + }); + return h(Text, { text: "x" }); + }, "{}", HOST); + await drain(); + assert.equal((actions!.error() as Error).message, "sync"); + actions!.refetch(); + actions!.refetch(); + resolvers[1]!("newer"); + resolvers[0]!("older"); + await drain(); + assert.equal(data(), "newer"); + }); + + it("i18n.t fills placeholders and re-runs bindings when the locale changes", () => { + bridge.queries["i18n.locale"] = "zh"; + bridge.queries["i18n.t"] = "省 {percent}"; + tinyui().mount(() => h(Text, { text: thunk(() => `${i18n.locale()}:${i18n.t("savings", { percent: "43%" })}`) }), "{}", HOST); + tinyui().flush(); + assert.deepEqual(bridge.applied.at(-1)!.filter((o) => o[0] === "p"), [["p", 1, "text", "zh:省 43%"]]); + bridge.queries["i18n.t"] = "Save {percent}"; + tinyui().emit("i18n.locale", JSON.stringify({ locale: "en" })); + tinyui().flush(); + assert.deepEqual(bridge.applied.at(-1), [["p", 1, "text", "en:Save 43%"]]); + }); + + it("session.state follows K5 and signIn is a J3", () => { + bridge.queries["session.get"] = { loggedIn: false, userId: null }; + tinyui().mount(() => h(Text, { text: thunk(() => String(session.state().loggedIn)) }), "{}", HOST); + tinyui().flush(); + assert.deepEqual(bridge.sent.at(-1), { name: "session.subscribe", args: {} }); + tinyui().emit("session", JSON.stringify({ loggedIn: true, userId: "u1" })); + tinyui().flush(); + assert.deepEqual(bridge.applied.at(-1), [["p", 1, "text", "true"]]); + void session.signIn("paywall"); + assert.deepEqual([bridge.calls.at(-1)!.name, bridge.calls.at(-1)!.args], ["session.signIn", { source: "paywall" }]); + }); + + it("ui, linking and analytics map to their bridge names", () => { + void ui.toast("hi", { action: "undo" }); + assert.deepEqual([bridge.calls.at(-1)!.name, bridge.calls.at(-1)!.args], ["ui.toast", { message: "hi", action: "undo" }]); + void ui.confirm({ message: "sure?" }); + assert.equal(bridge.calls.at(-1)!.name, "ui.confirm"); + void linking.openUrl("https://x"); + assert.deepEqual([bridge.calls.at(-1)!.name, bridge.calls.at(-1)!.args], ["linking.openUrl", { url: "https://x" }]); + analytics.track("checkout_step", { step: "plan_selected", plan: "annual" }); + assert.deepEqual(bridge.sent.at(-1), { name: "analytics.track", args: { name: "checkout_step", props: { step: "plan_selected", plan: "annual" } } }); + assert.throws(() => analytics.track("x", { nested: {} as unknown as string }), (e: HostError) => e.code === "E_INVALID"); }); it("host.call is a J3 under the host's own name", async () => { diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 6362622..046b3b1 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -71,6 +71,9 @@ importers: specifier: workspace:* version: link:../../packages/native devDependencies: + '@material-symbols/svg-400': + specifier: 0.47.5 + version: 0.47.5 tinyui-cli: specifier: workspace:* version: link:../../packages/cli @@ -242,6 +245,9 @@ packages: '@jridgewell/sourcemap-codec@1.6.0': resolution: {integrity: sha512-T7jf+5zgsZHwNJ4lvQ7/aezbyk0nNX+zJVWpmHA7VYsEx7a7qr5Rg5IbtJFqkgze5Y2sruq1RUY8Q837Od7iFw==} + '@material-symbols/svg-400@0.47.5': + resolution: {integrity: sha512-5AHuernGbTq1uq0YDb16QabESAaiYdqgDsDqF3Nk5usqP8pEeE5Nrt11jSs4/A/D7kOz5Ci6xyXMS/DsaxY/rw==} + '@oxc-parser/binding-android-arm-eabi@0.150.0': resolution: {integrity: sha512-oQef2Zu4Prz1KLKznz3HqZzU9uVoA5PMoDZuuLmqms7hKmKSAPzlaMnLllJq3t+rgKmfJJ2siPrpZfFrW06btw==} engines: {node: ^20.19.0 || >=22.12.0} @@ -609,6 +615,8 @@ snapshots: '@jridgewell/sourcemap-codec@1.6.0': {} + '@material-symbols/svg-400@0.47.5': {} + '@oxc-parser/binding-android-arm-eabi@0.150.0': optional: true diff --git a/sample/js-extra/i18n/en.json b/sample/js-extra/i18n/en.json new file mode 100644 index 0000000..8cf25c9 --- /dev/null +++ b/sample/js-extra/i18n/en.json @@ -0,0 +1,18 @@ +{ + "features.title": "What the framework gives a page", + "features.locale": "Language: {locale}", + "features.count": "Taps kept across restarts: {count}", + "features.tap": "Tap", + "features.toast": "Show a toast", + "features.toast.message": "Saved", + "features.toast.undo": "Undo", + "features.confirm": "Ask to confirm", + "features.confirm.message": "Clear the counter?", + "features.open": "Open tinyui.app", + "features.plan.annual": "Annual", + "features.plan.monthly": "Monthly", + "features.session.in": "Signed in as {user}", + "features.session.out": "Signed out", + "features.signin": "Sign in", + "features.zen": "GitHub says: {text}" +} diff --git a/sample/js-extra/i18n/zh.json b/sample/js-extra/i18n/zh.json new file mode 100644 index 0000000..e61dcba --- /dev/null +++ b/sample/js-extra/i18n/zh.json @@ -0,0 +1,18 @@ +{ + "features.title": "框架给页面的能力", + "features.locale": "语言:{locale}", + "features.count": "重启后仍保留的点击数:{count}", + "features.tap": "点一下", + "features.toast": "弹一条提示", + "features.toast.message": "已保存", + "features.toast.undo": "撤销", + "features.confirm": "二次确认", + "features.confirm.message": "清空计数?", + "features.open": "打开 tinyui.app", + "features.plan.annual": "年付", + "features.plan.monthly": "月付", + "features.session.in": "已登录:{user}", + "features.session.out": "未登录", + "features.signin": "登录", + "features.zen": "GitHub 说:{text}" +} diff --git a/sample/js-extra/package.json b/sample/js-extra/package.json index 24f304a..8d5279d 100644 --- a/sample/js-extra/package.json +++ b/sample/js-extra/package.json @@ -10,6 +10,7 @@ "tinyui-native": "workspace:*" }, "devDependencies": { + "@material-symbols/svg-400": "0.47.5", "tinyui-cli": "workspace:*" } } diff --git a/sample/js-extra/src/assets/heart.svg b/sample/js-extra/src/assets/heart.svg new file mode 100644 index 0000000..775d123 --- /dev/null +++ b/sample/js-extra/src/assets/heart.svg @@ -0,0 +1 @@ + diff --git a/sample/js-extra/src/generated/i18n.ts b/sample/js-extra/src/generated/i18n.ts new file mode 100644 index 0000000..09b5e87 --- /dev/null +++ b/sample/js-extra/src/generated/i18n.ts @@ -0,0 +1,23 @@ +// Generated by `tinyui i18n` from i18n/; do not edit. +import "tinyui-native"; + +declare module "tinyui-native" { + interface I18nKeys { + "features.confirm": never; + "features.confirm.message": never; + "features.count": "count"; + "features.locale": "locale"; + "features.open": never; + "features.plan.annual": never; + "features.plan.monthly": never; + "features.session.in": "user"; + "features.session.out": never; + "features.signin": never; + "features.tap": never; + "features.title": never; + "features.toast": never; + "features.toast.message": never; + "features.toast.undo": never; + "features.zen": "text"; + } +} diff --git a/sample/js-extra/src/pages/features.tsx b/sample/js-extra/src/pages/features.tsx new file mode 100644 index 0000000..350ae0b --- /dev/null +++ b/sample/js-extra/src/pages/features.tsx @@ -0,0 +1,71 @@ +import { Box, Button, Column, Icon, Loading, observable, RadioButton, Row, Show, signal, Text } from "tinyui-core"; +import { analytics, cached, http, i18n, linking, session, storage, ui } from "tinyui-native"; +import bolt from "@material-symbols/svg-400/outlined/bolt.svg"; +import heart from "../assets/heart.svg"; + +type Plan = "annual" | "monthly"; + +/** One page per framework capability of ADR-007, with no host capability of its own (docs/native-api.md). */ +export default function Features() { + const t = i18n.t; + const [count, setCount] = signal(storage.get("count") ?? 0); + const state = observable({ plan: "annual" as Plan }); + const [zen] = cached("zen", () => http.get("https://api.github.com/zen").then((r) => r.body)); + + function tap() { + setCount(count() + 1); + storage.set("count", count()); + analytics.track("features_tap", { count: count() }); + } + + async function toast() { + if ((await ui.toast(t("features.toast.message"), { action: t("features.toast.undo") })) === "action") setCount(Math.max(0, count() - 1)); + } + + async function clear() { + if (!(await ui.confirm({ message: t("features.confirm.message") }))) return; + setCount(0); + storage.remove("count"); + } + + return ( + + + + + + + + + + + + + + + + (state.plan = "annual")} /> + (state.plan = "monthly")} /> + + + {() => + + }> + {() => } + + + ); +} + +function PlanRow(p: { label: string; selected: boolean; onClick: () => void }) { + return ( + p.onClick()}> + + + + ); +} diff --git a/sample/js-extra/tinyui.config.json b/sample/js-extra/tinyui.config.json index 4a02b63..6c26172 100644 --- a/sample/js-extra/tinyui.config.json +++ b/sample/js-extra/tinyui.config.json @@ -1,4 +1,5 @@ { "name": "sample-extra", - "publicKey": "BBr/m9og848M0EfcUx02/hzXh9DLGYIQ6VUxR4puXuQ7TVix4F6B6dnosPWX1yL/s1WNzGtphcvZi7zWi/YbYhM=" + "publicKey": "BBr/m9og848M0EfcUx02/hzXh9DLGYIQ6VUxR4puXuQ7TVix4F6B6dnosPWX1yL/s1WNzGtphcvZi7zWi/YbYhM=", + "defaultLocale": "en" } diff --git a/sample/js-extra/tsconfig.json b/sample/js-extra/tsconfig.json index cf35637..5afed25 100644 --- a/sample/js-extra/tsconfig.json +++ b/sample/js-extra/tsconfig.json @@ -3,7 +3,8 @@ "compilerOptions": { "noEmit": true, "jsx": "react-jsx", - "jsxImportSource": "tinyui-core" + "jsxImportSource": "tinyui-core", + "types": ["tinyui-core/assets"] }, "include": ["src"] } diff --git a/sample/js/src/pages/todos.tsx b/sample/js/src/pages/todos.tsx index c8e591d..738f7da 100644 --- a/sample/js/src/pages/todos.tsx +++ b/sample/js/src/pages/todos.tsx @@ -4,9 +4,12 @@ import { http } from "tinyui-native"; interface Todo { id: number; title: string; done: boolean } interface Page { items: Todo[]; next: number | null } +/** The host's `todos` channel; the sample's fake backend stands behind it. */ +const todos = http.client("todos"); + export default function Todos() { const state = observable({ todos: [] as Todo[], next: 1 as number | null, loading: false }); - const [first, firstMeta] = resource(() => http.get("/todos?page=1")); + const [first, firstMeta] = resource(() => todos.get("/todos?page=1")); effect(() => { const r = first(); if (r) untrack(() => { state.todos = r.body.items; state.next = r.body.next; }); @@ -19,7 +22,7 @@ export default function Todos() { if (page === null || state.loading) return; state.loading = true; try { - const { body } = await http.get(`/todos?page=${page}`); + const { body } = await todos.get(`/todos?page=${page}`); state.todos.push(...body.items); state.next = body.next; } finally { diff --git a/sample/shared/build.gradle.kts b/sample/shared/build.gradle.kts index 0a0a507..132de0e 100644 --- a/sample/shared/build.gradle.kts +++ b/sample/shared/build.gradle.kts @@ -25,7 +25,7 @@ kotlin { implementation(libs.okio) implementation(compose.runtime) implementation(compose.foundation) - implementation(compose.material3) + implementation(libs.compose.material3) implementation(compose.ui) implementation(compose.components.resources) implementation(libs.kotlinx.coroutines.core) @@ -71,6 +71,7 @@ val packageBuilds = jsPackages.map { (pkg, jsRoot) -> workingDir = jsRoot inputs.dir(jsRoot.resolve("src")) inputs.file(jsRoot.resolve("tinyui.config.json")) + inputs.files(fileTree(jsRoot.resolve("i18n"))).withPropertyName("i18n") inputs.files(buildJsPackages.map { it.outputs.files }) inputs.property("qjsc", qjsc.orElse("")) val out = cliOut.map { it.dir(pkg) } @@ -88,7 +89,7 @@ val collectTinyUIResources by tasks.registering(Sync::class) { jsPackages.keys.forEachIndexed { i, pkg -> from(packageBuilds[i]) { into("files/tinyui/$pkg") - include("pages/**/*.bin", "manifest.json") + include("pages/**/*.bin", "i18n/*.json", "manifest.json") if (providers.gradleProperty("tinyui.maps").orNull != "false") include("pages/**/*.js.map") } } diff --git a/sample/shared/src/androidHostTest/kotlin/app/tinyui/sample/EmbeddedPackageTest.kt b/sample/shared/src/androidHostTest/kotlin/app/tinyui/sample/EmbeddedPackageTest.kt index 4b4f603..335b1d0 100644 --- a/sample/shared/src/androidHostTest/kotlin/app/tinyui/sample/EmbeddedPackageTest.kt +++ b/sample/shared/src/androidHostTest/kotlin/app/tinyui/sample/EmbeddedPackageTest.kt @@ -13,7 +13,7 @@ class EmbeddedPackageTest { val root = File(System.getProperty("tinyui.embedded")) for (pkg in listOf("sample", "sample-extra")) { val manifest = BuildManifest.parse(root.resolve("$pkg/manifest.json").readText()) - assertEquals(emptyList(), PackageCheck.problems(manifest, tinyUIHost), "embedded package $pkg") + assertEquals(emptyList(), PackageCheck.problems(manifest, sampleHost()), "embedded package $pkg") } } } diff --git a/sample/shared/src/androidHostTest/kotlin/app/tinyui/sample/HostSnapshotTest.kt b/sample/shared/src/androidHostTest/kotlin/app/tinyui/sample/HostSnapshotTest.kt index f385149..eda7012 100644 --- a/sample/shared/src/androidHostTest/kotlin/app/tinyui/sample/HostSnapshotTest.kt +++ b/sample/shared/src/androidHostTest/kotlin/app/tinyui/sample/HostSnapshotTest.kt @@ -11,7 +11,7 @@ class HostSnapshotTest { @Test fun hostMatchesTheSnapshotOfItsHostVersion() { val file = File("tinyui-host/$HOST_VERSION.txt") - val current = HostSnapshot.render(tinyUIHost, HOST_VERSION) + val current = HostSnapshot.render(sampleHost(), HOST_VERSION) if (System.getProperty("tinyui.updateHostSnapshot") != null) { file.parentFile.mkdirs() file.writeText(current) @@ -19,6 +19,6 @@ class HostSnapshotTest { } val hint = "if HOST_VERSION $HOST_VERSION is not on main yet, rerun with -Ptinyui.updateHostSnapshot; otherwise raise HOST_VERSION and do that" assertTrue(file.exists(), "no ${file.path} for HOST_VERSION $HOST_VERSION; $hint") - assertEquals(emptyList(), HostSnapshot.problems(file.readText(), tinyUIHost, HOST_VERSION), "the host is no longer one of ${file.path}; $hint") + assertEquals(emptyList(), HostSnapshot.problems(file.readText(), sampleHost(), HOST_VERSION), "the host is no longer one of ${file.path}; $hint") } } diff --git a/sample/shared/src/commonMain/kotlin/app/tinyui/sample/App.kt b/sample/shared/src/commonMain/kotlin/app/tinyui/sample/App.kt index 3130cb0..3b9c630 100644 --- a/sample/shared/src/commonMain/kotlin/app/tinyui/sample/App.kt +++ b/sample/shared/src/commonMain/kotlin/app/tinyui/sample/App.kt @@ -14,6 +14,7 @@ import androidx.compose.material3.TextButton import androidx.compose.material3.darkColorScheme import androidx.compose.material3.lightColorScheme import androidx.compose.runtime.Composable +import androidx.compose.runtime.collectAsState import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateOf @@ -23,40 +24,20 @@ import androidx.compose.runtime.setValue import androidx.compose.ui.Modifier import androidx.compose.ui.unit.dp import kotlinx.coroutines.Dispatchers -import kotlinx.coroutines.delay import kotlinx.coroutines.withContext import okio.Path import org.jetbrains.compose.resources.ExperimentalResourceApi import org.jetbrains.compose.resources.MissingResourceException import app.tinyui.Bundle -import app.tinyui.HostException -import app.tinyui.HostServices -import app.tinyui.HttpClient -import app.tinyui.HttpRequest -import app.tinyui.HttpResponse import app.tinyui.TinyUI import app.tinyui.sample.res.Res import app.tinyui.updates.Updates import app.tinyui.updates.UpdatesPage -/** Stands in for a backend: three pages of todos, 300 ms each. */ -private object FakeTodos : HttpClient { - override suspend fun request(request: HttpRequest): HttpResponse { - delay(300) - val page = Regex("page=(\\d+)").find(request.url)?.groupValues?.get(1)?.toInt() ?: throw HostException("E_HTTP", "404 ${request.url}") - if (page > 3) throw HostException("E_HTTP", "404 page $page") - val items = (1..8).joinToString(",") { i -> val id = (page - 1) * 8 + i; """{"id":$id,"title":"Todo #$id","done":${id % 3 == 0}}""" } - val next = if (page < 3) "${page + 1}" else "null" - return HttpResponse(200, """{"items":[$items],"next":$next}""") - } -} - -private val services = HostServices(http = FakeTodos, deviceInfo = mapOf("app" to "sample")) - /** The two packages the App embeds, each under its own resource directory (docs/updates.md §3). */ private val packages = listOf("sample", "sample-extra") -private val pages = listOf("sample/todos", "sample-extra/hello") +private val pages = listOf("sample/todos", "sample-extra/hello", "sample-extra/features") @OptIn(ExperimentalResourceApi::class) private suspend fun embedded(pkg: String): Bundle = Bundle.load { path -> @@ -82,15 +63,18 @@ fun App(updatesDir: Path) { // docs/updates.md §4.1: check before any page mounts, so a rollback reaches this install on the next start LaunchedEffect(updates) { updates?.check() } var page by remember { mutableStateOf(pages.first()) } + val host = remember(updatesDir) { sampleHost(updatesDir.parent!! / "tinyui-data") } + val locale by sampleLocale.collectAsState() // pages name theme tokens only (docs/components.md §6); flipping the scheme here restyles them with no patch MaterialTheme(colorScheme = if (isSystemInDarkTheme()) darkColorScheme() else lightColorScheme()) { Surface(Modifier.fillMaxSize()) { Column(Modifier.fillMaxSize().safeDrawingPadding()) { Row(Modifier.fillMaxWidth().padding(horizontal = 8.dp)) { - for (p in pages) TextButton(onClick = { page = p }) { Text(p.substringBefore('/')) } + for (p in pages) TextButton(onClick = { page = p }) { Text(p.substringAfter('/')) } + TextButton(onClick = { sampleLocale.value = if (locale == "en") "zh" else "en" }) { Text(if (locale == "en") "中文" else "EN") } } val u = updates - if (u == null) Text("loading…", Modifier.padding(16.dp)) else UpdatesPage(u, page, tinyUIHost, services, modifier = Modifier.fillMaxSize()) + if (u == null) Text("loading…", Modifier.padding(16.dp)) else UpdatesPage(u, page, host, modifier = Modifier.fillMaxSize()) } } } diff --git a/sample/shared/src/commonMain/kotlin/app/tinyui/sample/Host.kt b/sample/shared/src/commonMain/kotlin/app/tinyui/sample/Host.kt index c017d81..00331f8 100644 --- a/sample/shared/src/commonMain/kotlin/app/tinyui/sample/Host.kt +++ b/sample/shared/src/commonMain/kotlin/app/tinyui/sample/Host.kt @@ -1,19 +1,47 @@ package app.tinyui.sample +import app.tinyui.AnalyticsSink +import app.tinyui.HttpChannel +import app.tinyui.HttpResponse import app.tinyui.PageError import app.tinyui.PageSink +import app.tinyui.Session +import app.tinyui.SessionSource import app.tinyui.TinyUIHost import app.tinyui.components.registerBuiltins import app.tinyui.schema.ComponentRegistry +import kotlinx.coroutines.delay +import kotlinx.coroutines.flow.MutableStateFlow +import okio.Path -/** Raise when [tinyUIHost] changes what pages can use; `tinyui-host/.txt` records each value (docs/updates.md §4.1). */ -internal const val HOST_VERSION = "2" +/** Raise when [sampleHost] changes what pages can use; `tinyui-host/.txt` records each value (docs/updates.md §4.1). */ +internal const val HOST_VERSION = "3" -/** App-level, built once (docs/native-api.md §7). */ -internal val tinyUIHost = TinyUIHost( +/** The App's language setting; the language button in [App] flips it. */ +internal val sampleLocale = MutableStateFlow("en") + +private val signedIn = MutableStateFlow(Session.LoggedOut) + +/** Stands in for a backend behind the `todos` channel: three pages of todos, 300 ms each. */ +private val fakeTodos = HttpChannel { request -> + delay(300) + val page = Regex("page=(\\d+)").find(request.url)?.groupValues?.get(1)?.toInt() + if (page == null || page > 3) return@HttpChannel HttpResponse(404, emptyMap(), "") + val items = (1..8).joinToString(",") { i -> val id = (page - 1) * 8 + i; """{"id":$id,"title":"Todo #$id","done":${id % 3 == 0}}""" } + val next = if (page < 3) "${page + 1}" else "null" + HttpResponse(200, mapOf("content-type" to "application/json"), """{"items":[$items],"next":$next}""") +} + +/** App-level, built once (docs/native-api.md §1); [dataDir] backs `storage`. */ +internal fun sampleHost(dataDir: Path? = null) = TinyUIHost( components = ComponentRegistry().registerBuiltins(), sink = object : PageSink { override fun error(error: PageError) = println("TinyUI $error") override fun log(line: String) = println("TinyUI $line") }, + channels = mapOf("todos" to fakeTodos), + locale = sampleLocale, + session = SessionSource(signedIn) { delay(500); signedIn.value = Session(true, "demo") }, + analytics = AnalyticsSink { name, props, page -> println("TinyUI analytics ${page.name} $name $props") }, + dataDir = dataDir, ) diff --git a/sample/shared/tinyui-host/3.txt b/sample/shared/tinyui-host/3.txt new file mode 100644 index 0000000..95a8706 --- /dev/null +++ b/sample/shared/tinyui-host/3.txt @@ -0,0 +1,10 @@ +hostVersion 3 +tinyui 0.7.0 + +components + +capabilities + session.signIn + +channels + todos diff --git a/schema/components/icon.ts b/schema/components/icon.ts new file mode 100644 index 0000000..586488b --- /dev/null +++ b/schema/components/icon.ts @@ -0,0 +1,12 @@ +import { color, defineComponent, dp, icon, string } from "tinyui-cli/schema"; + +export default defineComponent("Icon", { + doc: "A single-colour icon whose data comes with the package (`import bolt from \"….svg\"`); tinted, never ships an icon set.", + props: { + icon: icon({ required: true }), + size: dp({ default: 24 }), + tint: color({ doc: "defaults to the content colour" }), + label: string({ doc: "what a screen reader says; leave unset for a decorative icon next to its text" }), + }, + events: { onClick: {} }, +}); diff --git a/schema/components/loading.ts b/schema/components/loading.ts new file mode 100644 index 0000000..0ce231e --- /dev/null +++ b/schema/components/loading.ts @@ -0,0 +1,9 @@ +import { color, defineComponent, dp } from "tinyui-cli/schema"; + +export default defineComponent("Loading", { + doc: "Indeterminate M3 loading indicator.", + props: { + size: dp({ default: 24 }), + color: color({ default: "primary" }), + }, +}); diff --git a/schema/index.ts b/schema/index.ts index a6f9143..1ab83d8 100644 --- a/schema/index.ts +++ b/schema/index.ts @@ -9,5 +9,7 @@ import RadioButton from "./components/radio-button.ts"; import TextField from "./components/text-field.ts"; import LazyColumn from "./components/lazy-column.ts"; import Spacer from "./components/spacer.ts"; +import Icon from "./components/icon.ts"; +import Loading from "./components/loading.ts"; -export default [Column, Row, Box, Text, Button, RadioButton, TextField, LazyColumn, Spacer]; +export default [Column, Row, Box, Text, Button, RadioButton, TextField, LazyColumn, Spacer, Icon, Loading]; diff --git a/updates/api/updates.klib.api b/updates/api/updates.klib.api index 0bff657..0b5ea70 100644 --- a/updates/api/updates.klib.api +++ b/updates/api/updates.klib.api @@ -299,7 +299,7 @@ final val app.tinyui.updates/app_tinyui_updates_UpdateEvent_UpToDate$stableprop final val app.tinyui.updates/app_tinyui_updates_Updates$stableprop // app.tinyui.updates/app_tinyui_updates_Updates$stableprop|#static{}app_tinyui_updates_Updates$stableprop[0] final val app.tinyui.updates/app_tinyui_updates_Updates_State_$serializer$stableprop // app.tinyui.updates/app_tinyui_updates_Updates_State_$serializer$stableprop|#static{}app_tinyui_updates_Updates_State_$serializer$stableprop[0] -final fun app.tinyui.updates/UpdatesPage(app.tinyui.updates/Updates, kotlin/String, app.tinyui/TinyUIHost, app.tinyui/HostServices?, kotlin/String?, androidx.compose.ui/Modifier?, kotlin/Function3?, kotlin/Function1?, androidx.compose.runtime/Composer?, kotlin/Int, kotlin/Int) // app.tinyui.updates/UpdatesPage|UpdatesPage(app.tinyui.updates.Updates;kotlin.String;app.tinyui.TinyUIHost;app.tinyui.HostServices?;kotlin.String?;androidx.compose.ui.Modifier?;kotlin.Function3?;kotlin.Function1?;androidx.compose.runtime.Composer?;kotlin.Int;kotlin.Int){}[0] +final fun app.tinyui.updates/UpdatesPage(app.tinyui.updates/Updates, kotlin/String, app.tinyui/TinyUIHost, kotlin/String?, kotlin.collections/List>?, androidx.compose.ui/Modifier?, kotlin/Function3?, kotlin/Function1?, androidx.compose.runtime/Composer?, kotlin/Int, kotlin/Int) // app.tinyui.updates/UpdatesPage|UpdatesPage(app.tinyui.updates.Updates;kotlin.String;app.tinyui.TinyUIHost;kotlin.String?;kotlin.collections.List>?;androidx.compose.ui.Modifier?;kotlin.Function3?;kotlin.Function1?;androidx.compose.runtime.Composer?;kotlin.Int;kotlin.Int){}[0] final fun app.tinyui.updates/app_tinyui_updates_CheckResult$stableprop_getter(): kotlin/Int // app.tinyui.updates/app_tinyui_updates_CheckResult$stableprop_getter|app_tinyui_updates_CheckResult$stableprop_getter(){}[0] final fun app.tinyui.updates/app_tinyui_updates_CheckResult_Failed$stableprop_getter(): kotlin/Int // app.tinyui.updates/app_tinyui_updates_CheckResult_Failed$stableprop_getter|app_tinyui_updates_CheckResult_Failed$stableprop_getter(){}[0] final fun app.tinyui.updates/app_tinyui_updates_CheckResult_Installed$stableprop_getter(): kotlin/Int // app.tinyui.updates/app_tinyui_updates_CheckResult_Installed$stableprop_getter|app_tinyui_updates_CheckResult_Installed$stableprop_getter(){}[0] diff --git a/updates/src/commonMain/kotlin/app/tinyui/updates/Updates.kt b/updates/src/commonMain/kotlin/app/tinyui/updates/Updates.kt index 059b265..482cbdf 100644 --- a/updates/src/commonMain/kotlin/app/tinyui/updates/Updates.kt +++ b/updates/src/commonMain/kotlin/app/tinyui/updates/Updates.kt @@ -171,10 +171,9 @@ class Updates internal constructor( if (manifest.engine != engine || !TinyUI.isCompatible(tinyui, manifest.tinyui)) return null if (embeddedCreatedAt?.let { manifest.createdAt <= it } == true) return null val files = HashMap() - for (module in manifest.pages) { - val path = manifest.file(module) + ".bin" + for ((path, hashKey) in manifest.payload) { val bytes = runCatching { fs.read(dir / path) { readByteArray() } }.getOrNull() - if (bytes == null || bytes.toByteString().sha256().hex() != manifest.hashes[module]) { + if (bytes == null || bytes.toByteString().sha256().hex() != manifest.hashes[hashKey]) { failed += manifest.version onEvent(UpdateEvent.Failed(name, manifest.version, FailStage.INTEGRITY, "$path is missing or does not match manifest.hashes")) return null @@ -261,8 +260,7 @@ class Updates internal constructor( /** Every file of [manifest] into [staging], each checked against `hashes`; null once all are there. */ private suspend fun downloadInto(staging: Path, manifest: BuildManifest, manifestBytes: ByteArray): CheckResult.Failed? { val version = manifest.version - for (module in manifest.pages) { - val path = manifest.file(module) + ".bin" + for ((path, hashKey) in manifest.payload) { val bytes = try { fetch("$name/$hostVersion/$version/$path") } catch (e: CancellationException) { @@ -271,7 +269,7 @@ class Updates internal constructor( currentCoroutineContext().ensureActive() return CheckResult.Failed(version, FailStage.DOWNLOAD, "$path: ${e.message ?: e}") } - if (bytes.toByteString().sha256().hex() != manifest.hashes[module]) { + if (bytes.toByteString().sha256().hex() != manifest.hashes[hashKey]) { return CheckResult.Failed(version, FailStage.INTEGRITY, "$path does not match manifest.hashes") } val file = staging / path diff --git a/updates/src/commonMain/kotlin/app/tinyui/updates/UpdatesPage.kt b/updates/src/commonMain/kotlin/app/tinyui/updates/UpdatesPage.kt index b5d0df3..7aef4ae 100644 --- a/updates/src/commonMain/kotlin/app/tinyui/updates/UpdatesPage.kt +++ b/updates/src/commonMain/kotlin/app/tinyui/updates/UpdatesPage.kt @@ -8,7 +8,7 @@ import androidx.compose.runtime.produceState import androidx.compose.runtime.remember import androidx.compose.runtime.setValue import androidx.compose.ui.Modifier -import app.tinyui.HostServices +import app.tinyui.PageLocalValue import app.tinyui.LoadedPage import app.tinyui.PageError import app.tinyui.PageFailure @@ -28,8 +28,8 @@ fun UpdatesPage( updates: Updates, name: String, host: TinyUIHost, - services: HostServices = HostServices.Default, propsJson: String = "{}", + locals: List> = emptyList(), modifier: Modifier = Modifier, error: @Composable (PageFailure) -> Unit = { PageFailureScreen(it) }, onHost: (PageHost) -> Unit = {}, @@ -55,7 +55,7 @@ fun UpdatesPage( return } TinyUIPage( - page.module, host, services, propsJson, modifier, page.sourceMaps, + page.module, host, propsJson, locals, modifier, page.sourceMaps, error = { failure -> // only the failures that mean the package cannot run here (docs/updates.md §4.5) if ((failure.kind == "E2" || failure.kind == "E6") && page.bundle !== updates.embedded(pkg)) {