diff --git a/.gitattributes b/.gitattributes new file mode 100644 index 0000000..abfa596 --- /dev/null +++ b/.gitattributes @@ -0,0 +1,2 @@ +# host snapshots are uploaded as raw bytes (docs/updates.md §4.1) +**/tinyui-host/*.txt text eol=lf diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index 98a632f..2009bdf 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -28,7 +28,7 @@ jobs: git clone --depth 1 --branch "$version" https://github.com/HarlonWang/quickjs-kmp.git "$RUNNER_TEMP/quickjs-kmp" (cd "$RUNNER_TEMP/quickjs-kmp" && ./gradlew :library:buildNativeHostJni --no-daemon) echo "TINYUI_QUICKJS_HOST_JNI=$RUNNER_TEMP/quickjs-kmp/library/build/native/host-jni/lib" >> "$GITHUB_ENV" - - run: ./gradlew :compose:testAndroidHostTest :compose:apiCheck :sample:androidApp:assembleDebug --no-daemon + - run: ./gradlew :compose:testAndroidHostTest :compose:apiCheck :sample:shared:testAndroidHostTest :sample:androidApp:assembleDebug --no-daemon pnpm: runs-on: ubuntu-latest diff --git a/compose/api/compose.klib.api b/compose/api/compose.klib.api index 1af914e..fbc7e84 100644 --- a/compose/api/compose.klib.api +++ b/compose/api/compose.klib.api @@ -37,7 +37,7 @@ abstract fun interface app.tinyui/Config { // app.tinyui/Config|null[0] } abstract fun interface app.tinyui/HostCapability { // app.tinyui/HostCapability|null[0] - abstract suspend fun call(kotlin/String): kotlin/String? // app.tinyui/HostCapability.call|call(kotlin.String){}[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] @@ -153,6 +153,19 @@ sealed interface app.tinyui.schema/SizeValue { // app.tinyui.schema/SizeValue|nu } } +final class <#A: kotlin/Any> app.tinyui/PageLocal { // app.tinyui/PageLocal|null[0] + constructor () // app.tinyui/PageLocal.|(){}[0] + + final fun provides(#A): app.tinyui/PageLocalValue<#A> // app.tinyui/PageLocal.provides|provides(1:0){}[0] +} + +final class <#A: kotlin/Any> app.tinyui/PageLocalValue { // app.tinyui/PageLocalValue|null[0] + final val local // app.tinyui/PageLocalValue.local|{}local[0] + final fun (): app.tinyui/PageLocal<#A> // app.tinyui/PageLocalValue.local.|(){}[0] + final val value // app.tinyui/PageLocalValue.value|{}value[0] + final fun (): #A // app.tinyui/PageLocalValue.value.|(){}[0] +} + final class app.tinyui.node/Command { // app.tinyui.node/Command|null[0] constructor (kotlin/String, kotlin.collections/Map) // app.tinyui.node/Command.|(kotlin.String;kotlin.collections.Map){}[0] @@ -285,6 +298,15 @@ final class app.tinyui/Bundle { // app.tinyui/Bundle|null[0] } } +final class app.tinyui/CapabilityRegistry { // app.tinyui/CapabilityRegistry|null[0] + constructor () // app.tinyui/CapabilityRegistry.|(){}[0] + + final val names // app.tinyui/CapabilityRegistry.names|{}names[0] + final fun (): kotlin.collections/Set // app.tinyui/CapabilityRegistry.names.|(){}[0] + + final fun register(kotlin/String, app.tinyui/HostCapability) // app.tinyui/CapabilityRegistry.register|register(kotlin.String;app.tinyui.HostCapability){}[0] +} + final class app.tinyui/EventBus { // app.tinyui/EventBus|null[0] constructor () // app.tinyui/EventBus.|(){}[0] @@ -300,10 +322,8 @@ final class app.tinyui/HostException : kotlin/Exception { // app.tinyui/HostExce } 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/Map = ...) // 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.Map){}[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 capabilities // app.tinyui/HostServices.capabilities|{}capabilities[0] - final fun (): kotlin.collections/Map // app.tinyui/HostServices.capabilities.|(){}[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] @@ -314,6 +334,8 @@ final class app.tinyui/HostServices { // app.tinyui/HostServices|null[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] @@ -370,6 +392,13 @@ final class app.tinyui/LoadedPage { // app.tinyui/LoadedPage|null[0] final fun (): app.tinyui/SourceMaps // app.tinyui/LoadedPage.sourceMaps.|(){}[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] + + final fun <#A1: kotlin/Any> get(app.tinyui/PageLocal<#A1>): #A1? // app.tinyui/PageContext.get|get(app.tinyui.PageLocal<0:0>){0§}[0] +} + final class app.tinyui/PageError { // app.tinyui/PageError|null[0] constructor (kotlin/String, kotlin/String, kotlin/String, kotlin/String, kotlin/String? = ..., kotlin/String? = ..., kotlin.collections/List = ..., kotlin/String? = ...) // app.tinyui/PageError.|(kotlin.String;kotlin.String;kotlin.String;kotlin.String;kotlin.String?;kotlin.String?;kotlin.collections.List;kotlin.String?){}[0] @@ -405,10 +434,10 @@ final class app.tinyui/PageFailure { // app.tinyui/PageFailure|null[0] } final class app.tinyui/PageHost { // app.tinyui/PageHost|null[0] - constructor (app.tinyui/RuntimeBundle, app.tinyui/PageModule, app.tinyui.schema/ComponentRegistry, app.tinyui/PageSink, app.tinyui/HostServices = ..., kotlin/String = ..., kotlin/Long = ..., app.tinyui/SourceMaps = ...) // app.tinyui/PageHost.|(app.tinyui.RuntimeBundle;app.tinyui.PageModule;app.tinyui.schema.ComponentRegistry;app.tinyui.PageSink;app.tinyui.HostServices;kotlin.String;kotlin.Long;app.tinyui.SourceMaps){}[0] + constructor (app.tinyui/RuntimeBundle, app.tinyui/PageModule, app.tinyui/TinyUIHost, app.tinyui/HostServices = ..., kotlin/String = ..., kotlin/Long = ..., app.tinyui/SourceMaps = ...) // app.tinyui/PageHost.|(app.tinyui.RuntimeBundle;app.tinyui.PageModule;app.tinyui.TinyUIHost;app.tinyui.HostServices;kotlin.String;kotlin.Long;app.tinyui.SourceMaps){}[0] - final val registry // app.tinyui/PageHost.registry|{}registry[0] - final fun (): app.tinyui.schema/ComponentRegistry // app.tinyui/PageHost.registry.|(){}[0] + final val host // app.tinyui/PageHost.host|{}host[0] + final fun (): app.tinyui/TinyUIHost // app.tinyui/PageHost.host.|(){}[0] final val tree // app.tinyui/PageHost.tree|{}tree[0] final fun (): app.tinyui.node/NodeTree // app.tinyui/PageHost.tree.|(){}[0] @@ -491,6 +520,17 @@ final class app.tinyui/StoreValue { // app.tinyui/StoreValue|null[0] final fun (): kotlin/Long // app.tinyui/StoreValue.version.|(){}[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] + + final val capabilities // app.tinyui/TinyUIHost.capabilities|{}capabilities[0] + final fun (): app.tinyui/CapabilityRegistry // app.tinyui/TinyUIHost.capabilities.|(){}[0] + final val components // app.tinyui/TinyUIHost.components|{}components[0] + final fun (): app.tinyui.schema/ComponentRegistry // app.tinyui/TinyUIHost.components.|(){}[0] + final val sink // app.tinyui/TinyUIHost.sink|{}sink[0] + final fun (): app.tinyui/PageSink // app.tinyui/TinyUIHost.sink.|(){}[0] +} + sealed class app.tinyui.schema/PropSpec { // app.tinyui.schema/PropSpec|null[0] abstract val default // app.tinyui.schema/PropSpec.default|{}default[0] abstract fun (): kotlin/Any? // app.tinyui.schema/PropSpec.default.|(){}[0] @@ -616,9 +656,15 @@ final object app.tinyui.schema/Theme { // app.tinyui.schema/Theme|null[0] final fun textStyle(androidx.compose.material3/Typography, kotlin/String): androidx.compose.ui.text/TextStyle? // app.tinyui.schema/Theme.textStyle|textStyle(androidx.compose.material3.Typography;kotlin.String){}[0] } +final object app.tinyui/HostSnapshot { // app.tinyui/HostSnapshot|null[0] + final fun render(app.tinyui/TinyUIHost, kotlin/String): kotlin/String // app.tinyui/HostSnapshot.render|render(app.tinyui.TinyUIHost;kotlin.String){}[0] +} + final object app.tinyui/TinyUI { // app.tinyui/TinyUI|null[0] final val engineVersion // app.tinyui/TinyUI.engineVersion|{}engineVersion[0] final fun (): kotlin/String // app.tinyui/TinyUI.engineVersion.|(){}[0] + final val version // app.tinyui/TinyUI.version|{}version[0] + final fun (): kotlin/String // app.tinyui/TinyUI.version.|(){}[0] final var debug // app.tinyui/TinyUI.debug|{}debug[0] final fun (): kotlin/Boolean // app.tinyui/TinyUI.debug.|(){}[0] @@ -653,18 +699,23 @@ final val app.tinyui.schema/app_tinyui_schema_SizeValue_Wrap$stableprop // app.t final val app.tinyui.schema/app_tinyui_schema_Theme$stableprop // app.tinyui.schema/app_tinyui_schema_Theme$stableprop|#static{}app_tinyui_schema_Theme$stableprop[0] final val app.tinyui/app_tinyui_BuildManifest$stableprop // app.tinyui/app_tinyui_BuildManifest$stableprop|#static{}app_tinyui_BuildManifest$stableprop[0] final val app.tinyui/app_tinyui_Bundle$stableprop // app.tinyui/app_tinyui_Bundle$stableprop|#static{}app_tinyui_Bundle$stableprop[0] +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_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_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] final val app.tinyui/app_tinyui_PageHost$stableprop // app.tinyui/app_tinyui_PageHost$stableprop|#static{}app_tinyui_PageHost$stableprop[0] +final val app.tinyui/app_tinyui_PageLocal$stableprop // app.tinyui/app_tinyui_PageLocal$stableprop|#static{}app_tinyui_PageLocal$stableprop[0] +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_RuntimeBundle$stableprop // app.tinyui/app_tinyui_RuntimeBundle$stableprop|#static{}app_tinyui_RuntimeBundle$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] @@ -672,6 +723,7 @@ final val app.tinyui/app_tinyui_SourceMaps$stableprop // app.tinyui/app_tinyui_S final val app.tinyui/app_tinyui_StackFrame$stableprop // app.tinyui/app_tinyui_StackFrame$stableprop|#static{}app_tinyui_StackFrame$stableprop[0] final val app.tinyui/app_tinyui_StoreValue$stableprop // app.tinyui/app_tinyui_StoreValue$stableprop|#static{}app_tinyui_StoreValue$stableprop[0] final val app.tinyui/app_tinyui_TinyUI$stableprop // app.tinyui/app_tinyui_TinyUI$stableprop|#static{}app_tinyui_TinyUI$stableprop[0] +final val app.tinyui/app_tinyui_TinyUIHost$stableprop // app.tinyui/app_tinyui_TinyUIHost$stableprop|#static{}app_tinyui_TinyUIHost$stableprop[0] 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] @@ -699,21 +751,26 @@ 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/RuntimeBundle, app.tinyui/PageModule, app.tinyui.schema/ComponentRegistry, app.tinyui/PageSink, 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.RuntimeBundle;app.tinyui.PageModule;app.tinyui.schema.ComponentRegistry;app.tinyui.PageSink;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/RuntimeBundle, 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.RuntimeBundle;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/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_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_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] final fun app.tinyui/app_tinyui_PageHost$stableprop_getter(): kotlin/Int // app.tinyui/app_tinyui_PageHost$stableprop_getter|app_tinyui_PageHost$stableprop_getter(){}[0] +final fun app.tinyui/app_tinyui_PageLocal$stableprop_getter(): kotlin/Int // app.tinyui/app_tinyui_PageLocal$stableprop_getter|app_tinyui_PageLocal$stableprop_getter(){}[0] +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_RuntimeBundle$stableprop_getter(): kotlin/Int // app.tinyui/app_tinyui_RuntimeBundle$stableprop_getter|app_tinyui_RuntimeBundle$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] @@ -721,3 +778,4 @@ final fun app.tinyui/app_tinyui_SourceMaps$stableprop_getter(): kotlin/Int // ap 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] diff --git a/compose/build.gradle.kts b/compose/build.gradle.kts index c890f66..310ff5f 100644 --- a/compose/build.gradle.kts +++ b/compose/build.gradle.kts @@ -7,12 +7,27 @@ plugins { alias(libs.plugins.binaryCompatibilityValidator) } +// TinyUI.version: the published version, so a host snapshot names the tinyui it was built against +val generateVersion by tasks.registering { + val version = providers.gradleProperty("VERSION_NAME") + val out = layout.buildDirectory.dir("generated/tinyui-version") + inputs.property("version", version) + outputs.dir(out) + doLast { + out.get().file("app/tinyui/Version.kt").asFile.apply { parentFile.mkdirs() } + .writeText("package app.tinyui\n\ninternal const val VERSION = \"${version.get()}\"\n") + } +} + kotlin { android { namespace = "app.tinyui" } sourceSets { + commonMain { + kotlin.srcDir(generateVersion) + } commonMain.dependencies { api(libs.quickjs.kmp) implementation(compose.runtime) diff --git a/compose/src/androidHostTest/kotlin/app/tinyui/BundleSmokeTest.kt b/compose/src/androidHostTest/kotlin/app/tinyui/BundleSmokeTest.kt index 0fcfa86..423712c 100644 --- a/compose/src/androidHostTest/kotlin/app/tinyui/BundleSmokeTest.kt +++ b/compose/src/androidHostTest/kotlin/app/tinyui/BundleSmokeTest.kt @@ -49,7 +49,7 @@ class BundleSmokeTest { if (request.url.endsWith("page=1")) HttpResponse(200, """{"items":[{"id":1,"title":"a","done":false}],"next":2}""") else throw HostException("E_HTTP", "500") } - val host = PageHost(loaded.runtime, loaded.module, ComponentRegistry().registerBuiltins(), sink, HostServices(http = http), sourceMaps = loaded.sourceMaps) + val host = PageHost(loaded.runtime, loaded.module, TinyUIHost(ComponentRegistry().registerBuiltins(), sink), HostServices(http = http), sourceMaps = loaded.sourceMaps) host.start() try { // the list only exists once the first page has loaded; node ids are dense, so scan them diff --git a/compose/src/commonMain/kotlin/app/tinyui/HostServices.kt b/compose/src/commonMain/kotlin/app/tinyui/HostServices.kt index 511b3cc..1c9110a 100644 --- a/compose/src/commonMain/kotlin/app/tinyui/HostServices.kt +++ b/compose/src/commonMain/kotlin/app/tinyui/HostServices.kt @@ -4,7 +4,7 @@ import kotlin.concurrent.atomics.AtomicReference import kotlin.concurrent.atomics.ExperimentalAtomicApi import kotlin.concurrent.atomics.update -/** What the host App provides to pages (docs/native-api.md); every part has a default that does nothing. */ +/** What the host provides to one mount (docs/native-api.md); every part has a default that does nothing. App-wide parts live in [TinyUIHost]. */ class HostServices( val navigator: Navigator = Navigator.None, val store: Store = InMemoryStore(), @@ -14,14 +14,9 @@ class HostServices( val events: EventBus = EventBus(), /** Extra values merged into `device.info` (docs/native-api.md §1). */ val deviceInfo: Map = emptyMap(), - /** J3 `host.call` by name (docs/native-api.md §7); names the framework already uses are rejected. */ - val capabilities: Map = emptyMap(), + /** This mount's objects that capabilities read through [PageContext] (docs/native-api.md §7). */ + val locals: List> = emptyList(), ) { - init { - val taken = capabilities.keys.filter { it in FrameworkCapabilities } - require(taken.isEmpty()) { "capabilities $taken are framework names" } - } - companion object { /** One shared instance, so a page whose host passes nothing keeps a stable `remember` key. */ val Default = HostServices() @@ -83,11 +78,6 @@ class HttpResponse(val status: Int, val bodyJson: String) /** An E3 failure with one of the codes in docs/native-api.md §6. */ class HostException(val code: String, message: String) : Exception(message) -/** One host-defined J3 capability: [argsJson] is the object JS passed; returns JSON text, or null for `undefined`. Throw [HostException] for E3 codes. */ -fun interface HostCapability { - suspend fun call(argsJson: String): String? -} - /** Names the framework dispatches itself (J2 / J3 / J4), listed in the mount manifest ahead of the host's. */ internal val FrameworkCapabilities = listOf("device.info", "i18n.t", "config.get", "store.get", "store.set", "navigation.push", "navigation.pop", "events.emit", "http.request", "timer.schedule") diff --git a/compose/src/commonMain/kotlin/app/tinyui/HostSnapshot.kt b/compose/src/commonMain/kotlin/app/tinyui/HostSnapshot.kt new file mode 100644 index 0000000..de9553b --- /dev/null +++ b/compose/src/commonMain/kotlin/app/tinyui/HostSnapshot.kt @@ -0,0 +1,64 @@ +package app.tinyui + +import app.tinyui.schema.ComponentSchema +import app.tinyui.schema.FieldSpec +import app.tinyui.schema.PropSpec +import kotlinx.serialization.json.JsonPrimitive + +/** The host snapshot `tinyui-host/.txt` (docs/updates.md §4.1, format §6.4). */ +object HostSnapshot { + fun render(host: TinyUIHost, hostVersion: String): String { + require(hostVersion.matches(Regex("[1-9][0-9]*"))) { "hostVersion must be a positive integer, got \"$hostVersion\"" } + val components = host.components.schemas.filter { '.' in it.type }.associate { it.type to describe(it) } + return buildString { + append("hostVersion ").append(hostVersion).append('\n') + append("tinyui ").append(TinyUI.version).append("\n\n") + append("components\n") + section(components) + append("\ncapabilities\n") + section(host.capabilities.names.associateWith { "" }) + } + } + + private fun StringBuilder.section(items: Map) { + val width = items.keys.maxOfOrNull { it.length } ?: 0 + for ((name, rest) in items.entries.sortedBy { it.key }) { + append(" ").append(if (rest.isEmpty()) name else name.padEnd(width + 2) + rest).append('\n') + } + } + + private fun describe(schema: ComponentSchema): String = listOfNotNull( + schema.props.entries.sortedBy { it.key }.joinToString(", ") { (name, spec) -> prop(name, spec) }.ifEmpty { null }, + calls("events", schema.events), + calls("commands", schema.commands), + "children".takeIf { schema.children }, + "layout".takeIf { schema.layout }, + ).joinToString("; ") + + private fun prop(name: String, spec: PropSpec): String = buildString { + append(name) + if (!spec.required) append('?') + append(": ").append(if (spec is PropSpec.Enum) spec.values.sorted().joinToString("|", "enum(", ")") else spec.kind) + spec.declared?.let { append(" = ").append(if (spec is PropSpec.Str) JsonPrimitive(it as String).toString() else literal(it)) } + if (spec.initial) append(" (initial)") + } + + private fun calls(label: String, calls: Map>): String? { + if (calls.isEmpty()) return null + return calls.entries.sortedBy { it.key }.joinToString(", ", "$label ") { (name, fields) -> + fields.entries.sortedBy { it.key }.joinToString(", ", "$name(", ")") { (field, type) -> "$field: ${type.typeName}" } + } + } + + private val FieldSpec.typeName: String + get() = when (this) { + FieldSpec.Str -> "string" + FieldSpec.Num -> "number" + FieldSpec.Bool -> "boolean" + } + + private fun literal(value: Any): String = when (value) { + is Double -> if (value % 1.0 == 0.0 && value > -1e15 && value < 1e15) value.toLong().toString() else value.toString() + else -> value.toString() + } +} diff --git a/compose/src/commonMain/kotlin/app/tinyui/PageHost.kt b/compose/src/commonMain/kotlin/app/tinyui/PageHost.kt index 599b363..55ed1bd 100644 --- a/compose/src/commonMain/kotlin/app/tinyui/PageHost.kt +++ b/compose/src/commonMain/kotlin/app/tinyui/PageHost.kt @@ -82,14 +82,16 @@ class PageFailure(val error: PageError) { class PageHost( private val runtimeBundle: RuntimeBundle, private val page: PageModule, - val registry: ComponentRegistry, - private val sink: PageSink, + val host: TinyUIHost, private val services: HostServices = HostServices.Default, private val propsJson: String = "{}", /** K entries longer than this are interrupted and fail the page (docs/native-api.md §6). */ private val entryTimeoutMs: Long = 5_000, private val sourceMaps: SourceMaps = SourceMaps.EMPTY, ) { + private val registry: ComponentRegistry get() = host.components + private val sink: PageSink get() = host.sink + private val context by lazy { PageContext(page.name, services.locals.associate { it.local to it.value }) } val tree = NodeTree(registry) { report("E5", it.reason, op = it.op) } var failure: PageFailure? by mutableStateOf(null) private set @@ -129,7 +131,7 @@ class PageHost( check(protocol == PROTOCOL) { "protocol $protocol from the runtime module, host implements $PROTOCOL" } namespace.use { ns -> (ns.get("default", ObjectTransport.REF) as JsRef).use { default -> - e.call("mount", default, JsValue.Str(propsJson), JsValue.Str(registry.manifest(FrameworkCapabilities + services.capabilities.keys))) + e.call("mount", default, JsValue.Str(propsJson), JsValue.Str(registry.manifest(FrameworkCapabilities + host.capabilities.names))) } } e.call("flush") @@ -264,9 +266,9 @@ class PageHost( val response = services.http.request(request) buildJsonObject { put("status", response.status); put("body", Json.parseToJsonElement(response.bodyJson)) }.toString() } - else -> when (val capability = services.capabilities[name]) { + else -> when (val capability = host.capabilities[name]) { null -> scope.launch { reject(cbId, "E_UNSUPPORTED", "$name is not available") } - else -> settle(cbId) { capability.call(argsJson) ?: "" } + else -> settle(cbId) { capability.call(argsJson, context) ?: "" } } } } diff --git a/compose/src/commonMain/kotlin/app/tinyui/TinyUI.kt b/compose/src/commonMain/kotlin/app/tinyui/TinyUI.kt index c7d341a..2a64599 100644 --- a/compose/src/commonMain/kotlin/app/tinyui/TinyUI.kt +++ b/compose/src/commonMain/kotlin/app/tinyui/TinyUI.kt @@ -3,6 +3,9 @@ package app.tinyui import wang.harlon.quickjs.QuickJs object TinyUI { + /** This library's version, as published (`0.0.0-local` in a source build). */ + val version: String get() = VERSION + /** Version of the quickjs-kmp SDK this build links against. */ val engineVersion: String get() = QuickJs.sdkVersion diff --git a/compose/src/commonMain/kotlin/app/tinyui/TinyUIHost.kt b/compose/src/commonMain/kotlin/app/tinyui/TinyUIHost.kt new file mode 100644 index 0000000..503aca8 --- /dev/null +++ b/compose/src/commonMain/kotlin/app/tinyui/TinyUIHost.kt @@ -0,0 +1,56 @@ +package app.tinyui + +import app.tinyui.schema.ComponentRegistry + +/** + * What one App gives every TinyUI page, built once at startup and not changed after (docs/native-api.md §7). + * Its components and capabilities are the host contract [HostSnapshot] renders. + */ +class TinyUIHost( + val components: ComponentRegistry, + val sink: PageSink, + val capabilities: CapabilityRegistry = CapabilityRegistry(), +) { + init { + components.freeze() + capabilities.freeze() + } +} + +/** App-level J3 `host.call` capabilities by name (docs/native-api.md §7). */ +class CapabilityRegistry { + private val entries = LinkedHashMap() + private var frozen = false + + val names: Set get() = entries.keys + + fun register(name: String, capability: HostCapability) { + require(name.isNotEmpty() && name.none(Char::isWhitespace)) { "capability name \"$name\" is empty or has whitespace" } + require(name !in FrameworkCapabilities) { "capability $name is a framework name" } + check(!frozen) { "capability $name registered after the registry went into a TinyUIHost" } + require(name !in entries) { "capability $name already registered" } + entries[name] = capability + } + + internal operator fun get(name: String): HostCapability? = entries[name] + + internal fun freeze() { frozen = true } +} + +/** One host-defined J3 capability: [argsJson] is the object JS passed; returns JSON text, or null for `undefined`. Throw [HostException] for E3 codes. */ +fun interface HostCapability { + suspend fun call(argsJson: String, page: PageContext): String? +} + +/** The mount a capability call came from: the page's module name and the objects its [HostServices.locals] provide. */ +class PageContext internal constructor(val name: String, private val locals: Map, Any>) { + @Suppress("UNCHECKED_CAST") + operator fun get(local: PageLocal): T? = locals[local] as T? +} + +/** A typed key for an object one mount hands to capabilities, such as its screen's snackbar. */ +class PageLocal { + infix fun provides(value: T): PageLocalValue = PageLocalValue(this, value) +} + +class PageLocalValue internal constructor(val local: PageLocal, val value: T) diff --git a/compose/src/commonMain/kotlin/app/tinyui/TinyUIPage.kt b/compose/src/commonMain/kotlin/app/tinyui/TinyUIPage.kt index 5883725..c9964db 100644 --- a/compose/src/commonMain/kotlin/app/tinyui/TinyUIPage.kt +++ b/compose/src/commonMain/kotlin/app/tinyui/TinyUIPage.kt @@ -14,7 +14,6 @@ import androidx.compose.runtime.remember import androidx.compose.ui.Modifier import androidx.compose.ui.text.font.FontFamily import androidx.compose.ui.unit.dp -import app.tinyui.schema.ComponentRegistry /** * Renders one TinyUI page; the host owns navigation and passes the page's bytecode (docs/app-model.md). @@ -24,8 +23,7 @@ import app.tinyui.schema.ComponentRegistry fun TinyUIPage( runtime: RuntimeBundle, page: PageModule, - registry: ComponentRegistry, - sink: PageSink, + host: TinyUIHost, services: HostServices = HostServices.Default, propsJson: String = "{}", modifier: Modifier = Modifier, @@ -33,20 +31,20 @@ fun TinyUIPage( error: @Composable (PageFailure) -> Unit = { PageFailureScreen(it) }, onHost: (PageHost) -> Unit = {}, ) { - val host = remember(runtime, page, registry, sink, services, propsJson, sourceMaps) { - PageHost(runtime, page, registry, sink, services, propsJson, sourceMaps = sourceMaps) + val pageHost = remember(runtime, page, host, services, propsJson, sourceMaps) { + PageHost(runtime, page, host, services, propsJson, sourceMaps = sourceMaps) } - DisposableEffect(host) { - onHost(host) - host.start() - onDispose { host.close() } + DisposableEffect(pageHost) { + onHost(pageHost) + pageHost.start() + onDispose { pageHost.close() } } Box(modifier) { - val failure = host.failure + val failure = pageHost.failure if (failure != null) { error(failure) } else { - for (child in host.tree.root.children) key(child.id) { host.Render(child) } + for (child in pageHost.tree.root.children) key(child.id) { pageHost.Render(child) } } } } diff --git a/compose/src/commonMain/kotlin/app/tinyui/schema/ComponentRegistry.kt b/compose/src/commonMain/kotlin/app/tinyui/schema/ComponentRegistry.kt index 0f63671..49b1eeb 100644 --- a/compose/src/commonMain/kotlin/app/tinyui/schema/ComponentRegistry.kt +++ b/compose/src/commonMain/kotlin/app/tinyui/schema/ComponentRegistry.kt @@ -16,18 +16,24 @@ class ComponentRegistry { private class Entry(val schema: ComponentSchema, val component: Component) private val entries = LinkedHashMap() + private var frozen = false init { register(ComponentSchema(PLACEHOLDER, emptyMap(), emptyMap(), emptyMap(), children = false, layout = true)) { Placeholder(it) } } fun register(schema: ComponentSchema, component: Component) { + check(!frozen) { "component ${schema.type} registered after the registry went into a TinyUIHost" } require(schema.type !in entries) { "component ${schema.type} already registered" } entries[schema.type] = Entry(schema, component) } fun schema(type: String): ComponentSchema? = entries[type]?.schema + internal fun freeze() { frozen = true } + + internal val schemas: List get() = entries.values.map { it.schema }.filter { it.type != PLACEHOLDER } + @Composable fun Render(type: String, scope: NodeScope) { (entries[type] ?: entries.getValue(PLACEHOLDER)).component.Render(scope) diff --git a/compose/src/commonMain/kotlin/app/tinyui/schema/ComponentSchema.kt b/compose/src/commonMain/kotlin/app/tinyui/schema/ComponentSchema.kt index 63eeaa6..bd1af6f 100644 --- a/compose/src/commonMain/kotlin/app/tinyui/schema/ComponentSchema.kt +++ b/compose/src/commonMain/kotlin/app/tinyui/schema/ComponentSchema.kt @@ -24,47 +24,57 @@ sealed interface SizeValue { sealed class PropSpec(val kind: String, val required: Boolean, val initial: Boolean) { /** Typed default; `null` when the schema has none. */ abstract val default: Any? + /** The default as schema/ declared it, for the host snapshot. */ + internal abstract val declared: Any? abstract fun convert(value: JsonPrimitive): Any? class Str(default: String?, required: Boolean = false, initial: Boolean = false) : PropSpec("string", required, initial) { override val default: String? = default + override val declared: Any? = default override fun convert(value: JsonPrimitive): Any? = value.takeIf { it.isString }?.content } class Num(default: Double?, required: Boolean = false, initial: Boolean = false) : PropSpec("number", required, initial) { override val default: Double? = default + override val declared: Any? = default override fun convert(value: JsonPrimitive): Any? = value.takeUnless { it.isString }?.doubleOrNull } class Bool(default: Boolean?, required: Boolean = false, initial: Boolean = false) : PropSpec("boolean", required, initial) { override val default: Boolean? = default + override val declared: Any? = default override fun convert(value: JsonPrimitive): Any? = value.takeUnless { it.isString }?.booleanOrNull } class Dp(default: Double?, required: Boolean = false, initial: Boolean = false) : PropSpec("dp", required, initial) { override val default: androidx.compose.ui.unit.Dp? = default?.dp + override val declared: Any? = default override fun convert(value: JsonPrimitive): Any? = value.takeUnless { it.isString }?.doubleOrNull?.dp } class Sp(default: Double?, required: Boolean = false, initial: Boolean = false) : PropSpec("sp", required, initial) { override val default: TextUnit? = default?.sp + override val declared: Any? = default override fun convert(value: JsonPrimitive): Any? = value.takeUnless { it.isString }?.doubleOrNull?.sp } /** `#RRGGBB` / `#AARRGGBB`, or a theme token name (Theme.kt). */ class ColorSpec(default: String?, required: Boolean = false, initial: Boolean = false) : PropSpec("color", required, initial) { override val default: ColorValue? = default?.let(::parseColorValue) + override val declared: Any? = default override fun convert(value: JsonPrimitive): Any? = value.takeIf { it.isString }?.content?.let(::parseColorValue) } /** Stored as the enum name; the composable maps it. */ class Enum(val values: Set, default: String?, required: Boolean = false, initial: Boolean = false) : PropSpec("enum", required, initial) { override val default: String? = default + override val declared: Any? = default override fun convert(value: JsonPrimitive): Any? = value.takeIf { it.isString }?.content?.takeIf { it in values } } class Size(default: String?, required: Boolean = false, initial: Boolean = false) : PropSpec("size", required, initial) { override val default: SizeValue? = default?.let(::parseSize) + override val declared: Any? = default override fun convert(value: JsonPrimitive): Any? = if (value.isString) parseSize(value.content) else value.doubleOrNull?.let { SizeValue.Fixed(it.dp) } diff --git a/compose/src/commonTest/kotlin/app/tinyui/HostSnapshotTest.kt b/compose/src/commonTest/kotlin/app/tinyui/HostSnapshotTest.kt new file mode 100644 index 0000000..e8f60fe --- /dev/null +++ b/compose/src/commonTest/kotlin/app/tinyui/HostSnapshotTest.kt @@ -0,0 +1,99 @@ +package app.tinyui + +import app.tinyui.components.registerBuiltins +import app.tinyui.schema.ComponentRegistry +import app.tinyui.schema.ComponentSchema +import app.tinyui.schema.FieldSpec +import app.tinyui.schema.PropSpec +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertFailsWith + +class HostSnapshotTest { + private val sink = object : PageSink { + override fun error(error: PageError) {} + override fun log(line: String) {} + } + + private val icon = ComponentSchema( + type = "ta.Icon", + props = mapOf( + "name" to PropSpec.Str(default = null, required = true), + "tint" to PropSpec.ColorSpec(default = "primary"), + "size" to PropSpec.Dp(default = 24.0), + "label" to PropSpec.Str(default = "a; b\ncapabilities"), + ), + events = emptyMap(), commands = emptyMap(), children = false, layout = true, + ) + + private val rating = ComponentSchema( + type = "ta.Rating", + props = mapOf( + "value" to PropSpec.Num(default = null, required = true), + "style" to PropSpec.Enum(setOf("stars", "hearts"), default = "stars", initial = true), + "step" to PropSpec.Num(default = 0.5), + ), + events = mapOf("onChange" to mapOf("value" to FieldSpec.Num, "fromUser" to FieldSpec.Bool)), + commands = mapOf("reset" to emptyMap()), + children = true, layout = false, + ) + + private fun host(capabilities: CapabilityRegistry = CapabilityRegistry()) = TinyUIHost( + ComponentRegistry().registerBuiltins().apply { + register(rating) {} + register(icon) {} + }, + sink, + capabilities, + ) + + @Test + fun listsDottedComponentsWithTheirSchemaAndCapabilityNamesSorted() { + val capabilities = CapabilityRegistry().apply { + register("coupon.apply") { _, _ -> null } + register("checkout.start") { _, _ -> null } + } + assertEquals( + """ + hostVersion 3 + tinyui ${TinyUI.version} + + components + ta.Icon label?: string = "a; b\ncapabilities", name: string, size?: dp = 24, tint?: color = primary; layout + ta.Rating step?: number = 0.5, style?: enum(hearts|stars) = stars (initial), value: number; events onChange(fromUser: boolean, value: number); commands reset(); children + + capabilities + checkout.start + coupon.apply + + """.trimIndent(), + HostSnapshot.render(host(capabilities), "3"), + ) + } + + @Test + fun writesBothSectionsEvenWhenEmpty() { + val bare = TinyUIHost(ComponentRegistry().registerBuiltins(), sink) + assertEquals("hostVersion 1\ntinyui ${TinyUI.version}\n\ncomponents\n\ncapabilities\n", HostSnapshot.render(bare, "1")) + } + + @Test + fun registriesRejectRegistrationOnceInAHost() { + val host = host() + assertFailsWith { host.components.register(ComponentSchema("ta.Other", emptyMap(), emptyMap(), emptyMap(), children = false, layout = true)) {} } + assertFailsWith { host.capabilities.register("late.one") { _, _ -> null } } + } + + @Test + fun rejectsAHostVersionThatIsNotAPositiveInteger() { + for (bad in listOf("0", "1.2.0", "", "01")) assertFailsWith(bad) { HostSnapshot.render(host(), bad) } + } + + @Test + fun registryRejectsFrameworkDuplicateAndBlankNames() { + val registry = CapabilityRegistry().apply { register("a.b") { _, _ -> null } } + for (bad in listOf("a.b", "store.get", "", "a b")) { + assertFailsWith(bad) { registry.register(bad) { _, _ -> null } } + } + } +} diff --git a/compose/src/commonTest/kotlin/app/tinyui/PageHostTest.kt b/compose/src/commonTest/kotlin/app/tinyui/PageHostTest.kt index 78399bf..d18a5a6 100644 --- a/compose/src/commonTest/kotlin/app/tinyui/PageHostTest.kt +++ b/compose/src/commonTest/kotlin/app/tinyui/PageHostTest.kt @@ -64,15 +64,16 @@ class PageHostTest { override suspend fun request(request: HttpRequest): HttpResponse = if (request.url == "/todos") HttpResponse(200, """[{"id":1}]""") else throw HostException("E_HTTP", "404") }, - capabilities = mapOf( - "checkout.start" to HostCapability { args -> - if (args.contains("annual")) """{"url":"https://pay"}""" else throw HostException("E_INVALID", "no such plan") - }, - ), + locals = listOf(Merchant provides "acme"), ) + 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 fun host(props: String = "{}", maps: SourceMaps = SourceMaps.EMPTY) = - PageHost(RuntimeBundle(core, native), page, ComponentRegistry().registerBuiltins(), sink, services, props, sourceMaps = maps) + PageHost(RuntimeBundle(core, native), page, tinyui, services, props, sourceMaps = maps) // runTest's virtual time never advances the real engine thread: wait on a real dispatcher private suspend fun PageHost.await(check: () -> Boolean) = @@ -178,7 +179,7 @@ class PageHostTest { host.dispatch(2, "onCapability", "{}") host.await { text()?.count { it == ';' } == 2 } assertEquals( - """ok 11 {"url":"https://pay"};rejected 12 {"code":"E_INVALID","message":"no such plan"};rejected 13 {"code":"E_UNSUPPORTED","message":"nobody.home is not available"}""", + """ok 11 {"url":"https://pay/acme/pages/counter"};rejected 12 {"code":"E_INVALID","message":"no such plan"};rejected 13 {"code":"E_UNSUPPORTED","message":"nobody.home is not available"}""", text(), ) host.dispatch(2, "onSubscribe", "{}") @@ -211,7 +212,7 @@ class PageHostTest { @Test fun protocolMismatchIsE6() = runTest { val badCore = module("tinyui-core", "globalThis.__tinyui = { protocol: 99 }; export const VERSION = 'x';") - val host = PageHost(RuntimeBundle(badCore, native), page, ComponentRegistry().registerBuiltins(), sink) + val host = PageHost(RuntimeBundle(badCore, native), page, tinyui) host.start() host.await { host.failure != null } assertEquals("E6", host.failure!!.kind) @@ -222,3 +223,5 @@ class PageHostTest { private fun module(name: String, source: String): ByteArray = JsBytecode.compile(source.trimIndent(), name, module = true, strip = JsBytecode.Strip.SOURCE) } + +private val Merchant = PageLocal() diff --git a/docs/native-api.md b/docs/native-api.md index 1b07ed6..e9e5fd0 100644 --- a/docs/native-api.md +++ b/docs/native-api.md @@ -2,7 +2,7 @@ - 状态:已定(2026-09-16);M2 的实现依据 - 来源:ADR-002 §3.1(J2 白名单、J3 / J4)、[app-model.md](./app-model.md)(路由、store、事件)、ADR-004 §3.1(连续事件阈值) -- 两侧:JS 的 `tinyui-native` 是 `__host_*` 的类型化封装;Kotlin 的 `HostServices` 是宿主 App 实现的接口组,交给 `TinyUIPage` +- 两侧:JS 的 `tinyui-native` 是 `__host_*` 的类型化封装;Kotlin 侧宿主给页面的东西分两层:App 级的 `TinyUIHost`(组件注册表、能力注册表、`PageSink`,启动时建一次)与每次挂载的 `HostServices`(本节以下各接口),都交给 `TinyUIPage` ## 1. J2 白名单 @@ -87,7 +87,26 @@ J3 `http.request`,参数 `{ method, url, headers?, body?, timeout? }`;宿主 const { url } = await host.call<{ url: string }>("checkout.start", { plan: "annual" }); ``` -J3,一个名字一个能力,Kotlin 侧 `HostServices(capabilities = mapOf("checkout.start" to HostCapability { argsJson -> … }))`:suspend,收 JS 传的对象(JSON 文本),返回 JSON 文本(`null` 即 `undefined`);抛 `HostException` 走 E3 码,其他异常按 `E_NET`。没注册的名字拒绝 `E_UNSUPPORTED`。名字不强制前缀,但不能与框架自己的十个名字(§1–§5)重合,重合在构造 `HostServices` 时就报错。挂载清单的 `capabilities` 列出框架名 + 宿主名,页面可据此判断宿主是否提供某能力。 +J3,一个名字一个能力。能力在 App 级注册一次,与组件同属宿主契约(2026-09-23 定,M6:契约要能在不跑 App 的情况下枚举出来生成宿主快照,updates.md §4.1): + +```kotlin +val Snackbar = PageLocal() + +val host = TinyUIHost(components, sink, CapabilityRegistry().apply { + register("checkout.start") { argsJson, page -> … } + register("ui.snackbar") { argsJson, page -> + val snackbar = page[Snackbar] ?: throw HostException("E_UNSUPPORTED", "no snackbar on ${page.name}") + … + } +}) + +TinyUIPage(runtime, module, host, HostServices(locals = listOf(Snackbar provides snackbarHostState))) +``` + +- `HostCapability.call(argsJson, page)`:suspend,收 JS 传的对象(JSON 文本),返回 JSON 文本(`null` 即 `undefined`);抛 `HostException` 走 E3 码,其他异常按 `E_NET`。没注册的名字拒绝 `E_UNSUPPORTED` +- 能力只依赖 App 级对象;依赖某一屏的东西(snackbar、当前屏的 ViewModel)由挂载方经 `HostServices.locals` 以 `PageLocal` 为键传入,能力从 `page[local]` 取,没有就自己决定报什么码。同一名字按屏给不同行为,也在实现里看 `page` 分派 +- 注册表启动时建好即不可变,放进 `TinyUIHost` 后再 `register`(组件同样)直接报错:不能在运行中增删能力(登录后才开放之类,在实现里判断后拒绝)。名字不强制前缀,但不能与框架自己的十个名字(§1–§5)重合,不能含空白,也不能重复注册,都在 `register` 时报错 +- 挂载清单的 `capabilities` 列出框架名 + 注册表里的全部名字,页面可据此判断宿主是否提供某能力 不做 J4(火后不理)变体:不等 Promise 就是火后不理,省不下什么。能力的类型声明由宿主 JS 工程自己写一层封装(`ta.checkout.start(plan)`),框架不生成。 diff --git a/docs/updates-plan.md b/docs/updates-plan.md index 78a3a71..528bdd3 100644 --- a/docs/updates-plan.md +++ b/docs/updates-plan.md @@ -112,14 +112,15 @@ M1 之后 M2 → M3 与 M4 并行;M5.1、M5.2 与 tinyui 0.3.0(npm 三包 + | 7 | 命名 | 包名 `trendingai`(页面 `trendingai/subscription`),线上 app id `trendingai`;宿主层面标识页面(深链、埋点页面名、日志)用 `tinyui:trendingai/subscription`,`navigation.push` 仍用不带前缀的 | | 8 | JS 侧填错目标宿主版本 | 发布前核对(updates.md §1.3):`tinyui build` 把每页用到的能力名与宿主组件名写进 manifest 的 `requires`;`publish` 上传前从热下发服务读目标版本的宿主快照比对,不通过即拒绝发布(`bundle` 保持离线)。快照由宿主 CI 在发 App 版本时上传,每版本只写一次。铁律:`host.call` 的名字必须是字面量、宿主组件必须能静态解析(build-chain.md §5.1)。设备侧核对暂不做 | | 9 | 服务端凭据分层 | 在实例 `ADMIN_TOKEN` 与包的发布 token 之间补 app token:一个 app 的完整管理权(注册包、公钥、签发发布 token、上传宿主快照),暂不细分权限范围;宿主 CI 用它上传快照;本 app 的发布 token 可读快照。权限与 URL 挂在 app(一个宿主 App)上,不挂在租户(`org`,可改可转让)上(updates.md §6) | +| 10 | 宿主契约怎么枚举 | 能力名的真值在宿主 Kotlin;能力像组件一样 App 级注册(`CapabilityRegistry`),`HostCapability.call(argsJson, page)` 经 `PageLocal` 拿某一屏的对象;组件注册表、能力注册表、`PageSink` 合成 App 级 `TinyUIHost` 交给 `TinyUIPage` / `UpdatesPage`(native-api.md §7)。快照由库的 `HostSnapshot.render` 生成,检查是宿主自己的 host 侧单元测试,`-Ptinyui.updateHostSnapshot` 写入;已发版本的冻结只靠服务端 409(updates.md §4.1) | 交付: -- **tinyui**:`tinyui build` 静态找出每页用到的能力与宿主组件写进 manifest 的 `requires`,违反 build-chain.md §5.1 即失败;`tinyui publish` 上传前读目标宿主版本的快照核对(updates.md §1.3,快照格式 §6.4);宿主契约快照——导出宿主组件 schema、能力名、tinyui 版本为稳定排序的文本,存 `tinyui-host/.txt`;检查任务(当前宿主与快照不符即失败)与生成任务(已随发版带出的版本拒绝覆盖),任务名做时定。前置:宿主能力能集中枚举(TrendingAI 现在散在各 Screen 里注册)。`tinyui:` 前缀约定写进 app-model.md -- **trendingai-tinyui**:先恢复可构建——依赖从 `link:` 换成 npm 的 0.3.0,加 `tinyui.config.json`(`name: "trendingai"`,公钥),模块名 `pages/subscription` → `trendingai/subscription`;`keys generate`,私钥写进 GitHub secret 与 `HarlonWang/secrets` 的 `tinyui-updates/signing/trendingai/trendingai.pem`;CI:合 main → build → `bundle --host-version <值>` → `publish --channel staging`,`hostVersion` 取 workflow 里的一个变量(抄宿主仓当前值;抄错时由 `publish` 对照服务端的宿主快照拒绝发布,见拍板第 8 点);`production` environment 设人工批准,手动 workflow 跑 `releases promote / rollback / rollout` +- **tinyui**:`tinyui build` 静态找出每页用到的能力与宿主组件写进 manifest 的 `requires`,违反 build-chain.md §5.1 即失败;`tinyui publish` 上传前读目标宿主版本的快照核对(updates.md §1.3,快照格式 §6.4);宿主契约快照——`TinyUIHost` 与 `HostSnapshot.render`(拍板第 10 点),sample 带一份 `HostSnapshotTest` 作宿主的写法样板。`tinyui:` 前缀约定写进 app-model.md +- **trendingai-tinyui**:先恢复可构建——依赖从 `link:` 换成 npm 上与 TrendingAI 同版本的 tinyui 三包(0.4.0),加 `tinyui.config.json`(`name: "trendingai"`,公钥),模块名 `pages/subscription` → `trendingai/subscription`;`keys generate`,私钥写进 GitHub secret 与 `HarlonWang/secrets` 的 `tinyui-updates/signing/trendingai/trendingai.pem`;CI:合 main → build → `bundle --host-version <值>` → `publish --channel staging`,`hostVersion` 取 workflow 里的一个变量(抄宿主仓当前值;抄错时由 `publish` 对照服务端的宿主快照拒绝发布,见拍板第 8 点);`production` environment 设人工批准,手动 workflow 跑 `releases promote / rollback / rollout` - **服务端代码**:app token 的签发与吊销、各管理端点改为认 app token(updates.md §6.3);宿主快照的上传与读取端点(§6.4,每个 (app, hostVersion) 只写一次);CLI 配套 `apps tokens`、`hosts upload` - **服务端(线上,不可逆,执行前列命令确认)**:`apps create trendingai --name TrendingAI`(`org` 留空);签一枚 app token 给 TrendingAI 宿主 CI 上传快照;`packages create trendingai --app trendingai`(登记公钥);两枚发布 token,CI 用的限 `staging`,production environment 用的限 `production` -- **TrendingAI**:依赖升到 tinyui 0.3.0 并加 `tinyui-updates`;`TinyUIHost` 改用 `Updates(listOf(embedded), hostVersion = "1", dir, installId = getOrCreateInstallId(), fetch = ktor)`,base URL `https://updates.tinyui.app/trendingai/`;设置页隐藏的 channel 开关(持久化,切回即恢复 `production`);订阅页改用 `UpdatesPage`,`PAGE = "trendingai/subscription"`;App 启动即 `check()`(先于任何 TinyUI 页挂载);`onEvent` 接埋点;宿主能力集中注册,`shared/tinyui-host/1.txt` 与检查任务进 CI,发版时 CI 把快照上传到热下发服务;内置包改为发 App 时从服务端拉当时 `production` 的那一版进 `composeResources/files/tinyui/trendingai/`(`pnpm sync` 只再生成 `HostSchemas.kt`),脚本还是 CLI 子命令做时定 +- **TrendingAI**:依赖升到带 `TinyUIHost` 的 tinyui(0.4.0)并加 `tinyui-updates`;宿主的 TinyUI 入口改用 `Updates(listOf(embedded), hostVersion = "1", dir, installId = getOrCreateInstallId(), fetch = ktor)`,base URL `https://updates.tinyui.app/trendingai/`;设置页隐藏的 channel 开关(持久化,切回即恢复 `production`);订阅页改用 `UpdatesPage`,`PAGE = "trendingai/subscription"`;App 启动即 `check()`(先于任何 TinyUI 页挂载);`onEvent` 接埋点;五个能力搬进 App 级 `CapabilityRegistry`(`ui.snackbar` 经 `PageLocal` 取当前屏的 snackbar),`shared/tinyui-host/1.txt` 与快照测试进 CI,发版时 CI 把快照上传到热下发服务;内置包改为发 App 时从服务端拉当时 `production` 的那一版进 `composeResources/files/tinyui/trendingai/`(`pnpm sync` 只再生成 `HostSchemas.kt`),脚本还是 CLI 子命令做时定 验收:合 main → CI 发 `staging` → 商店版 App 切到 staging 看到新文案 → 批准 workflow 晋级 `production` → 切回、重启看到;往 staging 发坏包 → 回退到内置并在埋点里看到 `RolledBack`;production workflow `rollback` → 重启回到上一版;宿主加一个能力而不加 `hostVersion` → 宿主 CI 失败。 @@ -139,5 +140,5 @@ M1 之后 M2 → M3 与 M4 并行;M5.1、M5.2 与 tinyui 0.3.0(npm 三包 + | M5.1 改名 `hostVersion` | 已完成(2026-09-23,tinyui PR #17、tinyui-updates-server PR #2,服务端已部署):manifest 字段、Kotlin API、CLI `--host-version`、服务端路由与校验、文档全部改名;CLI、`Updates` 构造、服务端路径与 manifest 五处限定正整数;共用签名夹具换钥重签,Android host 与 iOS 模拟器两端验签通过 | | M5.2 `qjsc-kmp` 平台包 | 已完成(2026-09-23,quickjs-kmp PR #13 与 0.1.2、tinyui PR #18):入口包 `qjsc-kmp` + `@qjsc-kmp/{darwin-arm64,darwin-x64,linux-x64,linux-arm64}`,Linux 为 musl 全静态;首版本地发布,之后随 quickjs-kmp 的 tag 经 OIDC 发布;`tinyui-cli` 依赖它并由测试钉住与 `quickjsKmp` 同版本,CI 不再现编 `qjsc-kmp` | | tinyui 0.3.0 | 已发(2026-09-23):npm `tinyui-core` / `tinyui-native` / `tinyui-cli`,Maven `app.tinyui:tinyui` 与首发的 `app.tinyui:tinyui-updates`;`tinyui-cli` 带 `qjsc-kmp@0.1.2` | -| M6 TrendingAI 接入 | 进行中:拍板八点(2026-09-23);`tinyui build` 写 `requires` 已完成(PR #19,TrendingAI 订阅页实测 5 个能力、2 个宿主组件);服务端 app token 与快照端点已上线(tinyui-updates-server PR #3),CLI 的 `apps tokens` / `hosts upload` 与 `publish` 核对已完成(PR #20,本地 `wrangler dev` 端到端验过);下一步宿主侧快照生成工具(前置:宿主能力集中枚举),然后 TrendingAI / trendingai-tinyui 接入 | +| M6 TrendingAI 接入 | 进行中:拍板八点(2026-09-23);`tinyui build` 写 `requires` 已完成(PR #19,TrendingAI 订阅页实测 5 个能力、2 个宿主组件);服务端 app token 与快照端点已上线(tinyui-updates-server PR #3),CLI 的 `apps tokens` / `hosts upload` 与 `publish` 核对已完成(PR #20,本地 `wrangler dev` 端到端验过);宿主契约收进 App 级 `TinyUIHost`(能力 `CapabilityRegistry` + `PageLocal`)与 `HostSnapshot.render` 已完成(拍板第 10 点,PR #21,sample 带 `HostSnapshotTest` 样板);下一步发 tinyui 0.4.0,然后 TrendingAI / trendingai-tinyui 接入 | | M7 第二个 App | 待开 | diff --git a/docs/updates.md b/docs/updates.md index fe91ad9..c84c197 100644 --- a/docs/updates.md +++ b/docs/updates.md @@ -151,6 +151,15 @@ class Updates( 漏加的后果是旧宿主收到跑不了的页面(不崩溃的错误不会触发 §4.5 回退),多加的后果是更早的宿主从此收不到更新。漏加只可能发生在宿主仓,由宿主构建拦:宿主快照 `tinyui-host/.txt`(宿主组件 schema、能力名、tinyui 版本)每个版本一份入库,当前宿主与当前版本的快照不符即构建失败;已随发版带出去的版本,其快照冻结不可改。快照覆盖不到已有能力的参数与行为变化,这部分按上面的规则由人判断。为什么是精确匹配而不是 `>=` 范围,见 ADR-006 §2.2。 +快照由 `HostSnapshot.render(host, hostVersion)` 从宿主的 `TinyUIHost` 生成(所以能力必须在 App 级注册,native-api.md §7),检查写成宿主自己的 host 侧单元测试,照 sample 的 `HostSnapshotTest`: + +- 当前 `hostVersion` 的快照文件不存在,或与 `render` 结果不同 → 失败,提示加 `hostVersion` +- 加 `-Ptinyui.updateHostSnapshot` 跑同一个测试 → 写入当前结果。这等于人声明"这个版本还没随发版带出去";本地不判断版本是否已发,冻结由服务端兜底:`hosts upload` 同版本不同字节得 409(§6.4),发版 CI 在那里失败 +- 快照文件在 `.gitattributes` 里固定 `eol=lf`:上传的是原始字节,CRLF 的副本会被当成另一份快照而得 409 +- `hostVersion` 在宿主里只写一处常量,`Updates(hostVersion = …)` 与这个测试都读它 + +测试只构造 `TinyUIHost`、不执行任何能力与组件,所以能在 JVM 上跑;能力实现需要的仓库类对象在测试里传假的即可。 + **接入约定:`check()` 在任何 TinyUI 页面挂载之前调用**(App 启动即调)。指针回滚对已装上的用户生效靠的是下一次 `check()` 装上回退版本;页面若先于它崩溃,回滚永远送不到。进程级崩溃(native 段错误)不在 §4.5 的回退范围内,出口就是灰度 + 指针回滚 + 这条约定;挂载标记见 ADR-006 §4.3 推迟项。 文件与 sha256 用 okio(`FileSystem` + `ByteString.sha256`),是本 artifact 独有的依赖,core 库不引入。ECDSA 验签走平台 API(§7)。 @@ -162,7 +171,7 @@ class Updates( | `fun current(pkg: String): Bundle` | 构造时按包定死(§4.4);进程内只在 §4.5 回退后换回该包的内置 | | `suspend fun check(): Map` | 全部包各跑一遍 §4.3,彼此独立并行,一包失败不影响其他;同一实例串行,重入直接返回进行中的结果 | | `suspend fun check(pkg: String): CheckResult` | 只查一个包 | -| 顶层 `@Composable fun UpdatesPage(updates, name, registry, sink, services, propsJson, modifier, error, onHost)` | 与 `TinyUIPage` 同参外加 `updates`;`name` 是含包名的模块名,第一个 `/` 之前即包,`current(pkg).page(name)` → `TinyUIPage`;`error` 前先走 §4.5 的回退 | +| 顶层 `@Composable fun UpdatesPage(updates, name, host, services, propsJson, modifier, error, onHost)` | 与 `TinyUIPage` 同参外加 `updates`;`name` 是含包名的模块名,第一个 `/` 之前即包,`current(pkg).page(name)` → `TinyUIPage`;`error` 前先走 §4.5 的回退 | `CheckResult`:`UpToDate` / `Installed(version)` / `Skipped(reason)` / `Failed(stage, cause)`。`UpdateEvent` 是同一组事实加 `Running` 与 `RolledBack`,给宿主打日志与埋点;**所有事件都带 `pkg`**。宿主的分析口径会依赖它,所以发布后字段同样只增不改: @@ -247,7 +256,7 @@ manifest 一到手先验签再解析:签名不对的 manifest 里任何字段 ## 5. 与既有契约的关系 -- `PageHost` / `TinyUIPage` / patch 协议 / schema / `PageSink` 不变;`Updates` 全部搭在 `Bundle` 与 `TinyUIPage` 之上 +- `PageHost` / `TinyUIPage` / patch 协议 / schema / `PageSink` 不因热下发而变(M6 把组件、能力、sink 收进 App 级 `TinyUIHost` 是为了宿主快照,见 native-api.md §7);`Updates` 全部搭在 `Bundle` 与 `TinyUIPage` 之上 - 引擎的 `JsEngineConfig.moduleLoader` 不接入:它服务页内 `import()`,不是页面级分发(ADR-006 §4.3) - `PageError` 不加字段:`buildId` 已能定位到具体 build,`RolledBack` 事件带 `pkg` 与 `version` - 跨包的 params / store / events 约定在 app-model.md §7,热下发只保证每个包内部一致 @@ -320,15 +329,21 @@ hostVersion 2 tinyui 0.3.0 components - ta.Icon name: string, tint?: color, size?: dp - ta.Loading color?: color, size?: dp + ta.Icon name: string, size?: dp = 24, tint?: color = primary; layout + ta.Rating value: number; events onChange(value: number); commands reset(); layout capabilities checkout.start coupon.apply ``` -前两行固定;`components` 与 `capabilities` 两段按此顺序各出现一次,没有条目也要写段名(缺段会被读成"什么都不提供",所以一律拒绝);每项缩进两格、一行一项、按名排序。`tinyui hosts upload` 先解析并核对首行的 `hostVersion` 与 `--host-version` 相符再上传——服务端只收第一份。**每行第一个词是名字**,其后供人阅读、供宿主侧检查判断 schema 是否变了,CLI 核对只看名字。宿主组件只列带点的,内置组件随 `tinyui` 版本。 +前两行固定;`components` 与 `capabilities` 两段按此顺序各出现一次,没有条目也要写段名(缺段会被读成"什么都不提供",所以一律拒绝);每项缩进两格、一行一项、按名排序,名字补空格对齐。`tinyui hosts upload` 先解析并核对首行的 `hostVersion` 与 `--host-version` 相符再上传——服务端只收第一份。**每行第一个词是名字**,CLI 核对只看名字;其后是给人看、也给宿主侧检查判断 schema 变没变的完整 schema,改哪一处都会让快照不同: + +- 各段以 `; ` 分隔,依次为 props、`events …`、`commands …`、`children`、`layout`,没有的段省略 +- prop 按名排序,`名字: 类型`;可选的名字后加 `?`,有默认值加 ` = 默认值`(`string` 的默认值写成 JSON 字符串,带引号与转义,免得换行或 `;` 弄乱一行一项),只在创建时读的加 ` (initial)`;枚举写 `enum(a|b)`,取值排序 +- 事件与命令按名排序,参数写在括号里,参数按名排序,类型为 `string` / `number` / `boolean` + +宿主组件只列带点的,内置组件随 `tinyui` 版本。 | 请求 | 凭据 | 语义 | |---|---|---| diff --git a/sample/shared/build.gradle.kts b/sample/shared/build.gradle.kts index b8c2282..d5cc484 100644 --- a/sample/shared/build.gradle.kts +++ b/sample/shared/build.gradle.kts @@ -30,9 +30,19 @@ kotlin { implementation(compose.components.resources) implementation(libs.kotlinx.coroutines.core) } + getByName("androidHostTest").dependencies { + implementation(libs.kotlin.test) + } } } +// HostSnapshotTest compares against tinyui-host/ here; -Ptinyui.updateHostSnapshot makes it write instead +val updateHostSnapshot = providers.gradleProperty("tinyui.updateHostSnapshot") +tasks.withType().configureEach { + inputs.files(layout.projectDirectory.dir("tinyui-host")).withPropertyName("hostSnapshots") + updateHostSnapshot.orNull?.let { systemProperty("tinyui.updateHostSnapshot", "true") } +} + // JS 侧:pnpm 编三个包 → tinyui build 把每个示例包的页面与运行时模块编成字节码 → 只把 .bin 与清单打成 Compose 资源 // 两个包演示多包模型(docs/updates.md §0):各自的 JS 工程、密钥、资源子目录 val jsPackages = mapOf("sample" to rootDir.resolve("sample/js"), "sample-extra" to rootDir.resolve("sample/js-extra")) diff --git a/sample/shared/src/androidHostTest/kotlin/app/tinyui/sample/HostSnapshotTest.kt b/sample/shared/src/androidHostTest/kotlin/app/tinyui/sample/HostSnapshotTest.kt new file mode 100644 index 0000000..6288c75 --- /dev/null +++ b/sample/shared/src/androidHostTest/kotlin/app/tinyui/sample/HostSnapshotTest.kt @@ -0,0 +1,24 @@ +package app.tinyui.sample + +import app.tinyui.HostSnapshot +import java.io.File +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertTrue + +/** The host-side check of docs/updates.md §4.1; `-Ptinyui.updateHostSnapshot` writes the file instead. */ +class HostSnapshotTest { + @Test + fun hostMatchesTheSnapshotOfItsHostVersion() { + val file = File("tinyui-host/$HOST_VERSION.txt") + val current = HostSnapshot.render(tinyUIHost, HOST_VERSION) + if (System.getProperty("tinyui.updateHostSnapshot") != null) { + file.parentFile.mkdirs() + file.writeText(current) + return + } + val hint = "if $HOST_VERSION has not shipped 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(file.readText(), current, "the host no longer matches ${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 eececec..3130cb0 100644 --- a/sample/shared/src/commonMain/kotlin/app/tinyui/sample/App.kt +++ b/sample/shared/src/commonMain/kotlin/app/tinyui/sample/App.kt @@ -34,23 +34,11 @@ import app.tinyui.HostServices import app.tinyui.HttpClient import app.tinyui.HttpRequest import app.tinyui.HttpResponse -import app.tinyui.PageError -import app.tinyui.PageSink import app.tinyui.TinyUI -import app.tinyui.components.registerBuiltins import app.tinyui.sample.res.Res -import app.tinyui.schema.ComponentRegistry import app.tinyui.updates.Updates import app.tinyui.updates.UpdatesPage -/** App-level, built once (docs/adr-003 §3.3). */ -private val registry = ComponentRegistry().registerBuiltins() - -private val sink = object : PageSink { - override fun error(error: PageError) = println("TinyUI $error") - override fun log(line: String) = println("TinyUI $line") -} - /** Stands in for a backend: three pages of todos, 300 ms each. */ private object FakeTodos : HttpClient { override suspend fun request(request: HttpRequest): HttpResponse { @@ -86,7 +74,7 @@ fun App(updatesDir: Path) { val bundles = packages.map { embedded(it) } // debug builds ship the maps (-Ptinyui.maps); stacks on the failure screen only when they came along TinyUI.debug = !bundles.first().page("sample/todos").sourceMaps.isEmpty - Updates(bundles, hostVersion = "1", dir = updatesDir, installId = "sample-install", fetch = { path -> httpGet(otaBaseUrl() + path) }) { + Updates(bundles, hostVersion = HOST_VERSION, dir = updatesDir, installId = "sample-install", fetch = { path -> httpGet(otaBaseUrl() + path) }) { println("TinyUI updates $it") } } @@ -102,7 +90,7 @@ fun App(updatesDir: Path) { for (p in pages) TextButton(onClick = { page = p }) { Text(p.substringBefore('/')) } } val u = updates - if (u == null) Text("loading…", Modifier.padding(16.dp)) else UpdatesPage(u, page, registry, sink, services, modifier = Modifier.fillMaxSize()) + if (u == null) Text("loading…", Modifier.padding(16.dp)) else UpdatesPage(u, page, tinyUIHost, services, 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 new file mode 100644 index 0000000..4acd8b9 --- /dev/null +++ b/sample/shared/src/commonMain/kotlin/app/tinyui/sample/Host.kt @@ -0,0 +1,19 @@ +package app.tinyui.sample + +import app.tinyui.PageError +import app.tinyui.PageSink +import app.tinyui.TinyUIHost +import app.tinyui.components.registerBuiltins +import app.tinyui.schema.ComponentRegistry + +/** Raise when [tinyUIHost] changes what pages can use; `tinyui-host/.txt` records each value (docs/updates.md §4.1). */ +internal const val HOST_VERSION = "1" + +/** App-level, built once (docs/native-api.md §7). */ +internal val tinyUIHost = TinyUIHost( + components = ComponentRegistry().registerBuiltins(), + sink = object : PageSink { + override fun error(error: PageError) = println("TinyUI $error") + override fun log(line: String) = println("TinyUI $line") + }, +) diff --git a/sample/shared/tinyui-host/1.txt b/sample/shared/tinyui-host/1.txt new file mode 100644 index 0000000..98a7759 --- /dev/null +++ b/sample/shared/tinyui-host/1.txt @@ -0,0 +1,6 @@ +hostVersion 1 +tinyui 0.0.0-local + +components + +capabilities diff --git a/updates/api/updates.klib.api b/updates/api/updates.klib.api index 9eac680..9d05fd9 100644 --- a/updates/api/updates.klib.api +++ b/updates/api/updates.klib.api @@ -279,7 +279,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.schema/ComponentRegistry, app.tinyui/PageSink, 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.schema.ComponentRegistry;app.tinyui.PageSink;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, 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/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/UpdatesPage.kt b/updates/src/commonMain/kotlin/app/tinyui/updates/UpdatesPage.kt index a0b1e86..2cce939 100644 --- a/updates/src/commonMain/kotlin/app/tinyui/updates/UpdatesPage.kt +++ b/updates/src/commonMain/kotlin/app/tinyui/updates/UpdatesPage.kt @@ -13,9 +13,8 @@ import app.tinyui.LoadedPage import app.tinyui.PageFailure import app.tinyui.PageFailureScreen import app.tinyui.PageHost -import app.tinyui.PageSink +import app.tinyui.TinyUIHost import app.tinyui.TinyUIPage -import app.tinyui.schema.ComponentRegistry /** * [TinyUIPage] for a page of one of [updates]' packages: [name] is the module name, the part before the first @@ -26,8 +25,7 @@ import app.tinyui.schema.ComponentRegistry fun UpdatesPage( updates: Updates, name: String, - registry: ComponentRegistry, - sink: PageSink, + host: TinyUIHost, services: HostServices = HostServices.Default, propsJson: String = "{}", modifier: Modifier = Modifier, @@ -39,7 +37,7 @@ fun UpdatesPage( val loaded by produceState(null, bundle, name) { value = bundle.page(name) } val page = loaded ?: return TinyUIPage( - page.runtime, page.module, registry, sink, services, propsJson, modifier, page.sourceMaps, + page.runtime, page.module, host, services, propsJson, 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)) {