diff --git a/.github/workflows/publish.yml b/.github/workflows/publish.yml index 57a47ed..ee4d26a 100644 --- a/.github/workflows/publish.yml +++ b/.github/workflows/publish.yml @@ -35,7 +35,7 @@ jobs: echo "TINYUI_QUICKJS_HOST_JNI=$RUNNER_TEMP/quickjs-kmp/library/build/native/host-jni/lib" >> "$GITHUB_ENV" - run: ./gradlew :compose:testAndroidHostTest :compose:apiCheck --no-daemon - name: Publish to Maven Central - run: ./gradlew :compose:publishAndReleaseToMavenCentral --no-configuration-cache --no-daemon + run: ./gradlew :compose:publishAndReleaseToMavenCentral :updates:publishAndReleaseToMavenCentral --no-configuration-cache --no-daemon env: ORG_GRADLE_PROJECT_VERSION_NAME: ${{ github.ref_name }} ORG_GRADLE_PROJECT_mavenCentralUsername: ${{ secrets.SONATYPE_NEXUS_USERNAME }} diff --git a/README.md b/README.md index 3f60e8e..f653ce9 100644 --- a/README.md +++ b/README.md @@ -14,8 +14,9 @@ JS 写声明式组件、Compose Multiplatform 渲染的 UI 框架,引擎为 Qu docs/ 设计文档、ADR、roadmap packages/ npm 包:tinyui-core · tinyui-native · tinyui-cli compose/ KMP 库 app.tinyui:tinyui(Compose Multiplatform 侧) +updates/ KMP 库 app.tinyui:tinyui-updates(热下发:校验、落盘、选择、回退,见 docs/updates.md) schema/ 内置组件 schema(TS DSL),两侧契约的唯一真值,`pnpm schema` 生成 -sample/ 示例 App(sample/js 是页面源码,Gradle 构建时经 CLI 编成字节码打进资源) +sample/ 示例 App(sample/js 与 sample/js-extra 是两个包的页面源码,Gradle 构建时经 CLI 编成字节码打进资源) bench/ 响应式模型与引擎 benchmark build-logic/ Gradle convention plugins ``` @@ -24,7 +25,7 @@ build-logic/ Gradle convention plugins ## 安装 -- Kotlin:`implementation("app.tinyui:tinyui:")`(Maven Central;自带 `wang.harlon:quickjs-kmp`) +- Kotlin:`implementation("app.tinyui:tinyui:")`(Maven Central;自带 `wang.harlon:quickjs-kmp`);热下发另加 `app.tinyui:tinyui-updates`,同版本号 - JS:`pnpm add tinyui-core tinyui-native` 与 `pnpm add -D tinyui-cli`,三包同版本号;`tinyui build` 另需 quickjs-kmp 的宿主工具 `qjsc-kmp`(见下) 版本号与 git tag 一致,不带 `v` 前缀,Maven 与 npm 三包同号同 tag 发:推 tag 触发 `publish.yml`,Maven 走 Sonatype,npm 走 trusted publishing(OIDC,无 token)。 diff --git a/compose/src/androidHostTest/kotlin/app/tinyui/BundleSmokeTest.kt b/compose/src/androidHostTest/kotlin/app/tinyui/BundleSmokeTest.kt index 6b8eb24..0fcfa86 100644 --- a/compose/src/androidHostTest/kotlin/app/tinyui/BundleSmokeTest.kt +++ b/compose/src/androidHostTest/kotlin/app/tinyui/BundleSmokeTest.kt @@ -17,7 +17,7 @@ import kotlin.test.assertTrue /** Loads what `tinyui build` produced for the sample through the real engine; skips when the sample was not built. */ class BundleSmokeTest { - private val out = File("../sample/shared/build/tinyui-cli") + private val out = File("../sample/shared/build/tinyui-cli/sample") @Test fun builtBundlesLoadAndTheRuntimeComesUp() { diff --git a/docs/README.md b/docs/README.md index d31860c..308802c 100644 --- a/docs/README.md +++ b/docs/README.md @@ -74,9 +74,11 @@ tinyui/ │ ├── native/ tinyui-native 业务调用的宿主能力 API(http / storage / toast / navigation / i18n …,即 ADR-002 的 J2 / J3) │ └── cli/ tinyui-cli 构建工具:TSX → h()(ES2025)→ 每页一个 ESM 模块字节码,`tinyui build`;`tinyui bundle` 产签名后的热下发目录,`tinyui keys generate` 产签名密钥对 ├── compose/ app.tinyui:tinyui KMP 库(Compose Multiplatform 侧):节点表、注册表、桥、内置组件;依赖 quickjs-kmp +├── updates/ app.tinyui:tinyui-updates KMP 库:热下发的校验、落盘、选择、回退(ADR-006 §2.4),依赖 compose/ ├── schema/ 内置组件 schema 的唯一真值(TS DSL)→ `pnpm schema` 生成 packages/core 的类型与 compose/ 的注册 schema ├── sample/ 示例 App,M1 Counter / M2 列表页在这里跑 -│ ├── js/ 页面源码(pnpm workspace 成员),shared 的 Gradle 任务调 CLI 编成字节码打进 Compose 资源 +│ ├── js/ 包 `sample` 的页面源码(pnpm workspace 成员),shared 的 Gradle 任务调 CLI 编成字节码打进 Compose 资源 +│ ├── js-extra/ 包 `sample-extra`:第二个包,演示多包热下发(docs/updates.md §0) │ ├── shared/ KMP 模块:App() 与 iOS 入口,出静态 framework(AGP 9 不允许 application 与 KMP 插件同模块) │ ├── androidApp/ Android 壳(纯 com.android.application) │ └── iosApp/ Xcode 壳 @@ -94,7 +96,7 @@ tinyui/ 本地联调 quickjs-kmp:`local.properties` 写 `quickjs-kmp.dir=<仓路径>` 即 composite build 从源码构建,坐标映射由该仓 `gradle/composite-substitutions` 声明;CI 无 `local.properties`,解析 Maven 版本(catalog `quickjsKmp`)。 -约定:顶层只放这八个目录;新 npm 包进 `packages/`,新 Kotlin 模块进 `compose/` 作为子模块,不在顶层增生。ADR 文本中的"宿主"仍指 Kotlin 侧这一运行时角色,与目录名 `compose/` 不冲突。 +约定:顶层只放这八个目录;新 npm 包进 `packages/`;Kotlin 侧只有独立发 artifact 的库才占顶层目录(`updates/`,ADR-006 §2.4),其他 Kotlin 模块进 `compose/` 作为子模块,不在顶层增生。ADR 文本中的"宿主"仍指 Kotlin 侧这一运行时角色,与目录名 `compose/` 不冲突。 ## 实现期文档 diff --git a/docs/updates-plan.md b/docs/updates-plan.md index 170e265..d8e87ae 100644 --- a/docs/updates-plan.md +++ b/docs/updates-plan.md @@ -98,7 +98,7 @@ M1 之后 M2 → M3 与 M4 并行;M6 要等 M3 与 M5。 |---|---| | M1 CLI 产包与签名(含配置文件与模块名改名) | 已完成(PR #13,2026-09-22):`tinyui.config.json`、模块名 `/…`、manifest 扩展、`keys generate`、`bundle` 签原始字节写 `current.json`;sample 包名 `sample` | | M2 core `Bundle` | 已完成(2026-09-22):`BuildManifest` 解析 §1.1 全部字段(缺 `name` / `publicKey` 即失败),`BundleFiles` / `Bundle` / `LoadedPage`,`SourceMaps.isEmpty`;sample 资源目录改 `files/tinyui/sample/`,Android host 冒烟测试改走 `Bundle` | -| M3 `updates/` 模块 | 待开 | +| M3 `updates/` 模块 | 已完成(2026-09-22):`Updates` 状态机与启动选择(含 sha256 重算、原子 state.json)、两端 ECDSA 验签(Android `java.security`、iOS `Security.framework`)、`UpdatesPage` E2 / E6 回退;sample 加第二个包 `sample-extra`,Android 模拟器从本机 `http.server` 装包、重启生效、坏包回退并 `RolledBack` 全部验过 | | M4 服务 MVP | 待开 | | M5 CLI 发布与管理 | 待开 | | M6 TrendingAI 接入 | 待开 | diff --git a/docs/updates.md b/docs/updates.md index 9056e0d..dc25dc5 100644 --- a/docs/updates.md +++ b/docs/updates.md @@ -138,10 +138,10 @@ class Updates( | 成员 | 语义 | |---|---| -| `fun current(pkg: String): Bundle` | 构造时按包定死(§4.4),进程内不变 | +| `fun current(pkg: String): Bundle` | 构造时按包定死(§4.4);进程内只在 §4.5 回退后换回该包的内置 | | `suspend fun check(): Map` | 全部包各跑一遍 §4.3,彼此独立并行,一包失败不影响其他;同一实例串行,重入直接返回进行中的结果 | | `suspend fun check(pkg: String): CheckResult` | 只查一个包 | -| `@Composable fun UpdatesPage(name, registry, sink, services, propsJson, modifier, error, onHost)` | 与 `TinyUIPage` 同参;`name` 是含包名的模块名,第一个 `/` 之前即包,`current(pkg).page(name)` → `TinyUIPage`;`error` 前先走 §4.5 的回退 | +| 顶层 `@Composable fun UpdatesPage(updates, name, registry, sink, 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`**。宿主的分析口径会依赖它,所以发布后字段同样只增不改: diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index 18fcd03..eaacffc 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -11,6 +11,7 @@ kotlinxSerialization = "1.11.0" quickjsKmp = "0.1.1" vanniktechMavenPublish = "0.37.0" binaryCompatibilityValidator = "0.18.2" +okio = "3.18.2" [libraries] kotlin-test = { module = "org.jetbrains.kotlin:kotlin-test", version.ref = "kotlin" } @@ -18,6 +19,7 @@ kotlinx-coroutines-core = { module = "org.jetbrains.kotlinx:kotlinx-coroutines-c kotlinx-coroutines-test = { module = "org.jetbrains.kotlinx:kotlinx-coroutines-test", version.ref = "kotlinxCoroutines" } 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" } 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/pnpm-lock.yaml b/pnpm-lock.yaml index 89bdf22..4440e70 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -59,6 +59,19 @@ importers: specifier: workspace:* version: link:../../packages/cli + sample/js-extra: + dependencies: + tinyui-core: + specifier: workspace:* + version: link:../../packages/core + tinyui-native: + specifier: workspace:* + version: link:../../packages/native + devDependencies: + tinyui-cli: + specifier: workspace:* + version: link:../../packages/cli + schema: devDependencies: tinyui-cli: diff --git a/pnpm-workspace.yaml b/pnpm-workspace.yaml index 2775820..f460e6d 100644 --- a/pnpm-workspace.yaml +++ b/pnpm-workspace.yaml @@ -1,6 +1,7 @@ packages: - packages/* - sample/js + - sample/js-extra - schema onlyBuiltDependencies: diff --git a/sample/androidApp/build.gradle.kts b/sample/androidApp/build.gradle.kts index ba27787..23467e5 100644 --- a/sample/androidApp/build.gradle.kts +++ b/sample/androidApp/build.gradle.kts @@ -22,4 +22,5 @@ android { dependencies { implementation(project(":sample:shared")) implementation(libs.androidx.activity.compose) + implementation(libs.okio) } diff --git a/sample/androidApp/src/main/AndroidManifest.xml b/sample/androidApp/src/main/AndroidManifest.xml index c239f48..c48a11d 100644 --- a/sample/androidApp/src/main/AndroidManifest.xml +++ b/sample/androidApp/src/main/AndroidManifest.xml @@ -1,8 +1,11 @@ + + CADisableMinimumFrameDurationOnPhone + NSAppTransportSecurity + + NSAllowsLocalNetworking + + diff --git a/sample/js-extra/keys/demo-signing-key.pem b/sample/js-extra/keys/demo-signing-key.pem new file mode 100644 index 0000000..d8fe005 --- /dev/null +++ b/sample/js-extra/keys/demo-signing-key.pem @@ -0,0 +1,5 @@ +-----BEGIN PRIVATE KEY----- +MIGHAgEAMBMGByqGSM49AgEGCCqGSM49AwEHBG0wawIBAQQgUXTohh2O/Y1TwH+z +QU2EqFd+uEx+yy4oRQbgWkt8nYShRANCAAQa/5vaIPOPDNBH3FMdNv4c14fQyxmC +EOlVMUeKbl7kO01YseBegenZ6LD1l9ci/7NVjcxraYXL2Yu81ov2G2IT +-----END PRIVATE KEY----- diff --git a/sample/js-extra/package.json b/sample/js-extra/package.json new file mode 100644 index 0000000..24f304a --- /dev/null +++ b/sample/js-extra/package.json @@ -0,0 +1,15 @@ +{ + "name": "tinyui-sample-extra", + "private": true, + "type": "module", + "scripts": { + "typecheck": "tsc -p tsconfig.json --noEmit" + }, + "dependencies": { + "tinyui-core": "workspace:*", + "tinyui-native": "workspace:*" + }, + "devDependencies": { + "tinyui-cli": "workspace:*" + } +} diff --git a/sample/js-extra/src/pages/hello.tsx b/sample/js-extra/src/pages/hello.tsx new file mode 100644 index 0000000..20a62a1 --- /dev/null +++ b/sample/js-extra/src/pages/hello.tsx @@ -0,0 +1,11 @@ +import { Column, Text } from "tinyui-core"; + +/** The second package of the sample App: one page, its own key, delivered on its own (docs/updates.md §0). */ +export default function Hello() { + return ( + + + + + ); +} diff --git a/sample/js-extra/tinyui.config.json b/sample/js-extra/tinyui.config.json new file mode 100644 index 0000000..4a02b63 --- /dev/null +++ b/sample/js-extra/tinyui.config.json @@ -0,0 +1,4 @@ +{ + "name": "sample-extra", + "publicKey": "BBr/m9og848M0EfcUx02/hzXh9DLGYIQ6VUxR4puXuQ7TVix4F6B6dnosPWX1yL/s1WNzGtphcvZi7zWi/YbYhM=" +} diff --git a/sample/js-extra/tsconfig.json b/sample/js-extra/tsconfig.json new file mode 100644 index 0000000..cf35637 --- /dev/null +++ b/sample/js-extra/tsconfig.json @@ -0,0 +1,9 @@ +{ + "extends": "../../tsconfig.base.json", + "compilerOptions": { + "noEmit": true, + "jsx": "react-jsx", + "jsxImportSource": "tinyui-core" + }, + "include": ["src"] +} diff --git a/sample/shared/build.gradle.kts b/sample/shared/build.gradle.kts index 5fb836d..b8c2282 100644 --- a/sample/shared/build.gradle.kts +++ b/sample/shared/build.gradle.kts @@ -21,6 +21,8 @@ kotlin { sourceSets { commonMain.dependencies { implementation(project(":compose")) + implementation(project(":updates")) + implementation(libs.okio) implementation(compose.runtime) implementation(compose.foundation) implementation(compose.material3) @@ -31,8 +33,9 @@ kotlin { } } -// JS 侧:pnpm 编三个包 → tinyui build 把 sample/js 的页面与运行时模块编成字节码 → 只把 .bin 与清单打成 Compose 资源 -val jsRoot = rootDir.resolve("sample/js") +// 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")) val cliOut = layout.buildDirectory.dir("tinyui-cli") val quickjsKmpDir = (gradle as ExtensionAware).extra.properties["quickjs-kmp.dir"] as File? val qjsc: Provider = providers.gradleProperty("tinyui.qjsc") @@ -49,29 +52,40 @@ val buildJsPackages by tasks.registering(Exec::class) { outputs.dirs(rootDir.resolve("packages/core/dist"), rootDir.resolve("packages/native/dist"), rootDir.resolve("packages/cli/dist")) } -val buildTinyUIPages by tasks.registering(Exec::class) { +val buildTinyUIPages by tasks.registering { group = "build" - description = "tinyui build for sample/js" - dependsOn(buildJsPackages) - if (quickjsKmpDir != null) dependsOn(gradle.includedBuild("quickjs-kmp").task(":library:buildHostTools")) - workingDir = jsRoot - inputs.dir(jsRoot.resolve("src")) - inputs.files(buildJsPackages.map { it.outputs.files }) - inputs.property("qjsc", qjsc.orElse("")) - outputs.dir(cliOut) - val args = mutableListOf("node", rootDir.resolve("packages/cli/dist/bin.js").path, "build", "--root", jsRoot.path, "--out", cliOut.get().asFile.path) - qjsc.orNull?.let { args += listOf("--qjsc", it) } - commandLine(args) + description = "tinyui build for every sample package" +} +val packageBuilds = jsPackages.map { (pkg, jsRoot) -> + val name = "buildTinyUIPages" + pkg.split('-').joinToString("") { it.replaceFirstChar(Char::uppercase) } + tasks.register(name) { + group = "build" + description = "tinyui build for ${jsRoot.relativeTo(rootDir)}" + dependsOn(buildJsPackages) + if (quickjsKmpDir != null) dependsOn(gradle.includedBuild("quickjs-kmp").task(":library:buildHostTools")) + workingDir = jsRoot + inputs.dir(jsRoot.resolve("src")) + inputs.file(jsRoot.resolve("tinyui.config.json")) + inputs.files(buildJsPackages.map { it.outputs.files }) + inputs.property("qjsc", qjsc.orElse("")) + val out = cliOut.map { it.dir(pkg) } + outputs.dir(out) + val args = mutableListOf("node", rootDir.resolve("packages/cli/dist/bin.js").path, "build", "--root", jsRoot.path, "--out", out.get().asFile.path) + qjsc.orNull?.let { args += listOf("--qjsc", it) } + commandLine(args) + }.also { buildTinyUIPages.configure { dependsOn(it) } } } // one resource subdirectory per package (docs/updates.md §3): files/tinyui// val tinyUIResourcesRoot = layout.buildDirectory.dir("tinyui-resources") val collectTinyUIResources by tasks.registering(Sync::class) { // maps ride along for the debug-only failure screen (docs/build-chain.md); -Ptinyui.maps=false leaves them out - from(buildTinyUIPages) { - into("files/tinyui/sample") - include("**/*.bin", "manifest.json") - if (providers.gradleProperty("tinyui.maps").orNull != "false") include("**/*.js.map") + jsPackages.keys.forEachIndexed { i, pkg -> + from(packageBuilds[i]) { + into("files/tinyui/$pkg") + include("**/*.bin", "manifest.json") + if (providers.gradleProperty("tinyui.maps").orNull != "false") include("**/*.js.map") + } } // the whole root is synced, so a package directory from an earlier layout does not linger into(tinyUIResourcesRoot) diff --git a/sample/shared/src/androidMain/kotlin/app/tinyui/sample/Platform.android.kt b/sample/shared/src/androidMain/kotlin/app/tinyui/sample/Platform.android.kt new file mode 100644 index 0000000..d75acdf --- /dev/null +++ b/sample/shared/src/androidMain/kotlin/app/tinyui/sample/Platform.android.kt @@ -0,0 +1,23 @@ +package app.tinyui.sample + +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.withContext +import java.io.IOException +import java.net.HttpURLConnection +import java.net.Proxy +import java.net.URL + +actual fun otaBaseUrl(): String = "http://10.0.2.2:8000/ota/" + +actual suspend fun httpGet(url: String): ByteArray = withContext(Dispatchers.IO) { + // the emulator inherits the host's HTTP proxy, which has no route to the emulator-only 10.0.2.2 + val connection = URL(url).openConnection(Proxy.NO_PROXY) as HttpURLConnection + try { + connection.connectTimeout = 5_000 + connection.readTimeout = 10_000 + if (connection.responseCode != 200) throw IOException("HTTP ${connection.responseCode} $url") + connection.inputStream.use { it.readBytes() } + } finally { + connection.disconnect() + } +} 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 e69a073..9ec9ec6 100644 --- a/sample/shared/src/commonMain/kotlin/app/tinyui/sample/App.kt +++ b/sample/shared/src/commonMain/kotlin/app/tinyui/sample/App.kt @@ -1,39 +1,47 @@ package app.tinyui.sample import androidx.compose.foundation.isSystemInDarkTheme -import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.safeDrawingPadding import androidx.compose.material3.MaterialTheme import androidx.compose.material3.Surface import androidx.compose.material3.Text +import androidx.compose.material3.TextButton import androidx.compose.material3.darkColorScheme import androidx.compose.material3.lightColorScheme import androidx.compose.runtime.Composable import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.produceState import androidx.compose.runtime.remember import androidx.compose.runtime.setValue -import androidx.compose.ui.Alignment 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.LoadedPage 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.TinyUIPage 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() @@ -57,25 +65,44 @@ private object FakeTodos : HttpClient { 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") + @OptIn(ExperimentalResourceApi::class) +private suspend fun embedded(pkg: String): Bundle = Bundle.load { path -> + try { Res.readBytes("files/tinyui/$pkg/$path") } catch (e: MissingResourceException) { null } +} + +/** + * [updatesDir] is where installed packages live (docs/updates.md §4.1). Hot updates come from the development + * machine: `tinyui bundle` each package, then `python3 -m http.server 8000` where `dist/ota` is, and restart the App. + */ @Composable -fun App() { - var page by remember { mutableStateOf(null) } - LaunchedEffect(Unit) { - val bundle = Bundle.load { path -> - try { Res.readBytes("files/tinyui/sample/$path") } catch (e: MissingResourceException) { null } +fun App(updatesDir: Path) { + val updates by produceState(null) { + value = withContext(Dispatchers.Default) { + 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, runtimeVersion = "1", dir = updatesDir, installId = "sample-install", fetch = { path -> httpGet(otaBaseUrl() + path) }) { + println("TinyUI updates $it") + } } - val loaded = bundle.page("sample/todos") - // debug builds ship the maps (-Ptinyui.maps); stacks on the failure screen only when they came along - TinyUI.debug = !loaded.sourceMaps.isEmpty - page = loaded } + // 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()) } // 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()) { - Box(Modifier.fillMaxSize().safeDrawingPadding(), contentAlignment = Alignment.Center) { - val p = page - if (p == null) Text("loading…") else TinyUIPage(p.runtime, p.module, registry, sink, services, sourceMaps = p.sourceMaps) + Column(Modifier.fillMaxSize().safeDrawingPadding()) { + Row(Modifier.fillMaxWidth().padding(horizontal = 8.dp)) { + 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()) } } } diff --git a/sample/shared/src/commonMain/kotlin/app/tinyui/sample/Platform.kt b/sample/shared/src/commonMain/kotlin/app/tinyui/sample/Platform.kt new file mode 100644 index 0000000..4c4bd2c --- /dev/null +++ b/sample/shared/src/commonMain/kotlin/app/tinyui/sample/Platform.kt @@ -0,0 +1,6 @@ +package app.tinyui.sample + +/** `python3 -m http.server 8000` in `dist/ota`'s parent on the development machine, as the emulator or simulator reaches it. */ +expect fun otaBaseUrl(): String + +expect suspend fun httpGet(url: String): ByteArray diff --git a/sample/shared/src/iosMain/kotlin/app/tinyui/sample/MainViewController.kt b/sample/shared/src/iosMain/kotlin/app/tinyui/sample/MainViewController.kt index 1d6ec6c..29fb674 100644 --- a/sample/shared/src/iosMain/kotlin/app/tinyui/sample/MainViewController.kt +++ b/sample/shared/src/iosMain/kotlin/app/tinyui/sample/MainViewController.kt @@ -1,6 +1,15 @@ package app.tinyui.sample import androidx.compose.ui.window.ComposeUIViewController +import okio.Path.Companion.toPath +import platform.Foundation.NSApplicationSupportDirectory +import platform.Foundation.NSFileManager +import platform.Foundation.NSURL +import platform.Foundation.NSUserDomainMask import platform.UIKit.UIViewController -fun MainViewController(): UIViewController = ComposeUIViewController { App() } +fun MainViewController(): UIViewController { + val support = NSFileManager.defaultManager.URLsForDirectory(NSApplicationSupportDirectory, NSUserDomainMask).first() as NSURL + val dir = support.path!!.toPath() / "tinyui" + return ComposeUIViewController { App(updatesDir = dir) } +} diff --git a/sample/shared/src/iosMain/kotlin/app/tinyui/sample/Platform.ios.kt b/sample/shared/src/iosMain/kotlin/app/tinyui/sample/Platform.ios.kt new file mode 100644 index 0000000..aa0fdc2 --- /dev/null +++ b/sample/shared/src/iosMain/kotlin/app/tinyui/sample/Platform.ios.kt @@ -0,0 +1,37 @@ +package app.tinyui.sample + +import kotlinx.cinterop.ExperimentalForeignApi +import kotlinx.cinterop.addressOf +import kotlinx.cinterop.usePinned +import kotlinx.coroutines.suspendCancellableCoroutine +import platform.Foundation.NSData +import platform.Foundation.NSHTTPURLResponse +import platform.Foundation.NSURL +import platform.Foundation.NSURLSession +import platform.Foundation.dataTaskWithURL +import platform.posix.memcpy +import kotlin.coroutines.resume +import kotlin.coroutines.resumeWithException + +actual fun otaBaseUrl(): String = "http://localhost:8000/ota/" + +@OptIn(ExperimentalForeignApi::class) +actual suspend fun httpGet(url: String): ByteArray = suspendCancellableCoroutine { continuation -> + val task = NSURLSession.sharedSession.dataTaskWithURL(NSURL(string = url)) { data, response, error -> + val status = (response as? NSHTTPURLResponse)?.statusCode?.toInt() + when { + error != null -> continuation.resumeWithException(Exception(error.localizedDescription)) + status != 200 || data == null -> continuation.resumeWithException(Exception("HTTP $status $url")) + else -> continuation.resume(data.toByteArray()) + } + } + continuation.invokeOnCancellation { task.cancel() } + task.resume() +} + +@OptIn(ExperimentalForeignApi::class) +private fun NSData.toByteArray(): ByteArray { + val size = length.toInt() + if (size == 0) return ByteArray(0) + return ByteArray(size).also { bytes -> bytes.usePinned { memcpy(it.addressOf(0), this.bytes, length) } } +} diff --git a/settings.gradle.kts b/settings.gradle.kts index 363155e..078fcff 100644 --- a/settings.gradle.kts +++ b/settings.gradle.kts @@ -19,6 +19,7 @@ dependencyResolutionManagement { rootProject.name = "tinyui" include(":compose") +include(":updates") // as an included build of a host App only the library is wanted; the sample would drag its Node toolchain and app shell into that composite if (gradle.parent == null) { include(":sample:shared") diff --git a/updates/api/updates.klib.api b/updates/api/updates.klib.api new file mode 100644 index 0000000..81d1dae --- /dev/null +++ b/updates/api/updates.klib.api @@ -0,0 +1,296 @@ +// Klib ABI Dump +// Targets: [iosArm64, iosSimulatorArm64] +// Rendering settings: +// - Signature version: 2 +// - Show manifest properties: true +// - Show declarations: true + +// Library unique name: +final enum class app.tinyui.updates/FailStage : kotlin/Enum { // app.tinyui.updates/FailStage|null[0] + enum entry DOWNLOAD // app.tinyui.updates/FailStage.DOWNLOAD|null[0] + enum entry INTEGRITY // app.tinyui.updates/FailStage.INTEGRITY|null[0] + enum entry MANIFEST // app.tinyui.updates/FailStage.MANIFEST|null[0] + enum entry POINTER // app.tinyui.updates/FailStage.POINTER|null[0] + enum entry SIGNATURE // app.tinyui.updates/FailStage.SIGNATURE|null[0] + enum entry STORAGE // app.tinyui.updates/FailStage.STORAGE|null[0] + + final val entries // app.tinyui.updates/FailStage.entries|#static{}entries[0] + final fun (): kotlin.enums/EnumEntries // app.tinyui.updates/FailStage.entries.|#static(){}[0] + + final fun valueOf(kotlin/String): app.tinyui.updates/FailStage // app.tinyui.updates/FailStage.valueOf|valueOf#static(kotlin.String){}[0] + final fun values(): kotlin/Array // app.tinyui.updates/FailStage.values|values#static(){}[0] +} + +final enum class app.tinyui.updates/Mismatch : kotlin/Enum { // app.tinyui.updates/Mismatch|null[0] + enum entry ENGINE // app.tinyui.updates/Mismatch.ENGINE|null[0] + enum entry NAME // app.tinyui.updates/Mismatch.NAME|null[0] + enum entry PROTOCOL // app.tinyui.updates/Mismatch.PROTOCOL|null[0] + enum entry RUNTIME_VERSION // app.tinyui.updates/Mismatch.RUNTIME_VERSION|null[0] + enum entry VERSION // app.tinyui.updates/Mismatch.VERSION|null[0] + + final val entries // app.tinyui.updates/Mismatch.entries|#static{}entries[0] + final fun (): kotlin.enums/EnumEntries // app.tinyui.updates/Mismatch.entries.|#static(){}[0] + + final fun valueOf(kotlin/String): app.tinyui.updates/Mismatch // app.tinyui.updates/Mismatch.valueOf|valueOf#static(kotlin.String){}[0] + final fun values(): kotlin/Array // app.tinyui.updates/Mismatch.values|values#static(){}[0] +} + +final enum class app.tinyui.updates/SkipReason : kotlin/Enum { // app.tinyui.updates/SkipReason|null[0] + enum entry FAILED_BEFORE // app.tinyui.updates/SkipReason.FAILED_BEFORE|null[0] + enum entry INCOMPATIBLE // app.tinyui.updates/SkipReason.INCOMPATIBLE|null[0] + enum entry OLDER_THAN_EMBEDDED // app.tinyui.updates/SkipReason.OLDER_THAN_EMBEDDED|null[0] + enum entry ROLLOUT // app.tinyui.updates/SkipReason.ROLLOUT|null[0] + + final val entries // app.tinyui.updates/SkipReason.entries|#static{}entries[0] + final fun (): kotlin.enums/EnumEntries // app.tinyui.updates/SkipReason.entries.|#static(){}[0] + + final fun valueOf(kotlin/String): app.tinyui.updates/SkipReason // app.tinyui.updates/SkipReason.valueOf|valueOf#static(kotlin.String){}[0] + final fun values(): kotlin/Array // app.tinyui.updates/SkipReason.values|values#static(){}[0] +} + +final enum class app.tinyui.updates/Source : kotlin/Enum { // app.tinyui.updates/Source|null[0] + enum entry EMBEDDED // app.tinyui.updates/Source.EMBEDDED|null[0] + enum entry INSTALLED // app.tinyui.updates/Source.INSTALLED|null[0] + + final val entries // app.tinyui.updates/Source.entries|#static{}entries[0] + final fun (): kotlin.enums/EnumEntries // app.tinyui.updates/Source.entries.|#static(){}[0] + + final fun valueOf(kotlin/String): app.tinyui.updates/Source // app.tinyui.updates/Source.valueOf|valueOf#static(kotlin.String){}[0] + final fun values(): kotlin/Array // app.tinyui.updates/Source.values|values#static(){}[0] +} + +final class app.tinyui.updates/Updates { // app.tinyui.updates/Updates|null[0] + constructor (kotlin.collections/List, kotlin/String, okio/Path, kotlin/String, kotlin.coroutines/SuspendFunction1, kotlin/Function1 = ...) // app.tinyui.updates/Updates.|(kotlin.collections.List;kotlin.String;okio.Path;kotlin.String;kotlin.coroutines.SuspendFunction1;kotlin.Function1){}[0] + + final val runtimeVersion // app.tinyui.updates/Updates.runtimeVersion|{}runtimeVersion[0] + final fun (): kotlin/String // app.tinyui.updates/Updates.runtimeVersion.|(){}[0] + + final fun current(kotlin/String): app.tinyui/Bundle // app.tinyui.updates/Updates.current|current(kotlin.String){}[0] + final suspend fun check(): kotlin.collections/Map // app.tinyui.updates/Updates.check|check(){}[0] + final suspend fun check(kotlin/String): app.tinyui.updates/CheckResult // app.tinyui.updates/Updates.check|check(kotlin.String){}[0] +} + +sealed class app.tinyui.updates/CheckResult { // app.tinyui.updates/CheckResult|null[0] + final class Failed : app.tinyui.updates/CheckResult { // app.tinyui.updates/CheckResult.Failed|null[0] + constructor (kotlin/String?, app.tinyui.updates/FailStage, kotlin/String) // app.tinyui.updates/CheckResult.Failed.|(kotlin.String?;app.tinyui.updates.FailStage;kotlin.String){}[0] + + final val message // app.tinyui.updates/CheckResult.Failed.message|{}message[0] + final fun (): kotlin/String // app.tinyui.updates/CheckResult.Failed.message.|(){}[0] + final val stage // app.tinyui.updates/CheckResult.Failed.stage|{}stage[0] + final fun (): app.tinyui.updates/FailStage // app.tinyui.updates/CheckResult.Failed.stage.|(){}[0] + final val version // app.tinyui.updates/CheckResult.Failed.version|{}version[0] + final fun (): kotlin/String? // app.tinyui.updates/CheckResult.Failed.version.|(){}[0] + + final fun component1(): kotlin/String? // app.tinyui.updates/CheckResult.Failed.component1|component1(){}[0] + final fun component2(): app.tinyui.updates/FailStage // app.tinyui.updates/CheckResult.Failed.component2|component2(){}[0] + final fun component3(): kotlin/String // app.tinyui.updates/CheckResult.Failed.component3|component3(){}[0] + final fun copy(kotlin/String? = ..., app.tinyui.updates/FailStage = ..., kotlin/String = ...): app.tinyui.updates/CheckResult.Failed // app.tinyui.updates/CheckResult.Failed.copy|copy(kotlin.String?;app.tinyui.updates.FailStage;kotlin.String){}[0] + final fun equals(kotlin/Any?): kotlin/Boolean // app.tinyui.updates/CheckResult.Failed.equals|equals(kotlin.Any?){}[0] + final fun hashCode(): kotlin/Int // app.tinyui.updates/CheckResult.Failed.hashCode|hashCode(){}[0] + final fun toString(): kotlin/String // app.tinyui.updates/CheckResult.Failed.toString|toString(){}[0] + } + + final class Installed : app.tinyui.updates/CheckResult { // app.tinyui.updates/CheckResult.Installed|null[0] + constructor (kotlin/String) // app.tinyui.updates/CheckResult.Installed.|(kotlin.String){}[0] + + final val version // app.tinyui.updates/CheckResult.Installed.version|{}version[0] + final fun (): kotlin/String // app.tinyui.updates/CheckResult.Installed.version.|(){}[0] + + final fun component1(): kotlin/String // app.tinyui.updates/CheckResult.Installed.component1|component1(){}[0] + final fun copy(kotlin/String = ...): app.tinyui.updates/CheckResult.Installed // app.tinyui.updates/CheckResult.Installed.copy|copy(kotlin.String){}[0] + final fun equals(kotlin/Any?): kotlin/Boolean // app.tinyui.updates/CheckResult.Installed.equals|equals(kotlin.Any?){}[0] + final fun hashCode(): kotlin/Int // app.tinyui.updates/CheckResult.Installed.hashCode|hashCode(){}[0] + final fun toString(): kotlin/String // app.tinyui.updates/CheckResult.Installed.toString|toString(){}[0] + } + + final class Skipped : app.tinyui.updates/CheckResult { // app.tinyui.updates/CheckResult.Skipped|null[0] + constructor (kotlin/String, app.tinyui.updates/SkipReason, kotlin.collections/Set = ...) // app.tinyui.updates/CheckResult.Skipped.|(kotlin.String;app.tinyui.updates.SkipReason;kotlin.collections.Set){}[0] + + final val mismatch // app.tinyui.updates/CheckResult.Skipped.mismatch|{}mismatch[0] + final fun (): kotlin.collections/Set // app.tinyui.updates/CheckResult.Skipped.mismatch.|(){}[0] + final val reason // app.tinyui.updates/CheckResult.Skipped.reason|{}reason[0] + final fun (): app.tinyui.updates/SkipReason // app.tinyui.updates/CheckResult.Skipped.reason.|(){}[0] + final val version // app.tinyui.updates/CheckResult.Skipped.version|{}version[0] + final fun (): kotlin/String // app.tinyui.updates/CheckResult.Skipped.version.|(){}[0] + + final fun component1(): kotlin/String // app.tinyui.updates/CheckResult.Skipped.component1|component1(){}[0] + final fun component2(): app.tinyui.updates/SkipReason // app.tinyui.updates/CheckResult.Skipped.component2|component2(){}[0] + final fun component3(): kotlin.collections/Set // app.tinyui.updates/CheckResult.Skipped.component3|component3(){}[0] + final fun copy(kotlin/String = ..., app.tinyui.updates/SkipReason = ..., kotlin.collections/Set = ...): app.tinyui.updates/CheckResult.Skipped // app.tinyui.updates/CheckResult.Skipped.copy|copy(kotlin.String;app.tinyui.updates.SkipReason;kotlin.collections.Set){}[0] + final fun equals(kotlin/Any?): kotlin/Boolean // app.tinyui.updates/CheckResult.Skipped.equals|equals(kotlin.Any?){}[0] + final fun hashCode(): kotlin/Int // app.tinyui.updates/CheckResult.Skipped.hashCode|hashCode(){}[0] + final fun toString(): kotlin/String // app.tinyui.updates/CheckResult.Skipped.toString|toString(){}[0] + } + + final class UpToDate : app.tinyui.updates/CheckResult { // app.tinyui.updates/CheckResult.UpToDate|null[0] + constructor (kotlin/String) // app.tinyui.updates/CheckResult.UpToDate.|(kotlin.String){}[0] + + final val version // app.tinyui.updates/CheckResult.UpToDate.version|{}version[0] + final fun (): kotlin/String // app.tinyui.updates/CheckResult.UpToDate.version.|(){}[0] + + final fun component1(): kotlin/String // app.tinyui.updates/CheckResult.UpToDate.component1|component1(){}[0] + final fun copy(kotlin/String = ...): app.tinyui.updates/CheckResult.UpToDate // app.tinyui.updates/CheckResult.UpToDate.copy|copy(kotlin.String){}[0] + final fun equals(kotlin/Any?): kotlin/Boolean // app.tinyui.updates/CheckResult.UpToDate.equals|equals(kotlin.Any?){}[0] + final fun hashCode(): kotlin/Int // app.tinyui.updates/CheckResult.UpToDate.hashCode|hashCode(){}[0] + final fun toString(): kotlin/String // app.tinyui.updates/CheckResult.UpToDate.toString|toString(){}[0] + } +} + +sealed class app.tinyui.updates/UpdateEvent { // app.tinyui.updates/UpdateEvent|null[0] + abstract val pkg // app.tinyui.updates/UpdateEvent.pkg|{}pkg[0] + abstract fun (): kotlin/String // app.tinyui.updates/UpdateEvent.pkg.|(){}[0] + + final class Failed : app.tinyui.updates/UpdateEvent { // app.tinyui.updates/UpdateEvent.Failed|null[0] + constructor (kotlin/String, kotlin/String?, app.tinyui.updates/FailStage, kotlin/String) // app.tinyui.updates/UpdateEvent.Failed.|(kotlin.String;kotlin.String?;app.tinyui.updates.FailStage;kotlin.String){}[0] + + final val message // app.tinyui.updates/UpdateEvent.Failed.message|{}message[0] + final fun (): kotlin/String // app.tinyui.updates/UpdateEvent.Failed.message.|(){}[0] + final val pkg // app.tinyui.updates/UpdateEvent.Failed.pkg|{}pkg[0] + final fun (): kotlin/String // app.tinyui.updates/UpdateEvent.Failed.pkg.|(){}[0] + final val stage // app.tinyui.updates/UpdateEvent.Failed.stage|{}stage[0] + final fun (): app.tinyui.updates/FailStage // app.tinyui.updates/UpdateEvent.Failed.stage.|(){}[0] + final val version // app.tinyui.updates/UpdateEvent.Failed.version|{}version[0] + final fun (): kotlin/String? // app.tinyui.updates/UpdateEvent.Failed.version.|(){}[0] + + final fun component1(): kotlin/String // app.tinyui.updates/UpdateEvent.Failed.component1|component1(){}[0] + final fun component2(): kotlin/String? // app.tinyui.updates/UpdateEvent.Failed.component2|component2(){}[0] + final fun component3(): app.tinyui.updates/FailStage // app.tinyui.updates/UpdateEvent.Failed.component3|component3(){}[0] + final fun component4(): kotlin/String // app.tinyui.updates/UpdateEvent.Failed.component4|component4(){}[0] + final fun copy(kotlin/String = ..., kotlin/String? = ..., app.tinyui.updates/FailStage = ..., kotlin/String = ...): app.tinyui.updates/UpdateEvent.Failed // app.tinyui.updates/UpdateEvent.Failed.copy|copy(kotlin.String;kotlin.String?;app.tinyui.updates.FailStage;kotlin.String){}[0] + final fun equals(kotlin/Any?): kotlin/Boolean // app.tinyui.updates/UpdateEvent.Failed.equals|equals(kotlin.Any?){}[0] + final fun hashCode(): kotlin/Int // app.tinyui.updates/UpdateEvent.Failed.hashCode|hashCode(){}[0] + final fun toString(): kotlin/String // app.tinyui.updates/UpdateEvent.Failed.toString|toString(){}[0] + } + + final class Installed : app.tinyui.updates/UpdateEvent { // app.tinyui.updates/UpdateEvent.Installed|null[0] + constructor (kotlin/String, kotlin/String) // app.tinyui.updates/UpdateEvent.Installed.|(kotlin.String;kotlin.String){}[0] + + final val pkg // app.tinyui.updates/UpdateEvent.Installed.pkg|{}pkg[0] + final fun (): kotlin/String // app.tinyui.updates/UpdateEvent.Installed.pkg.|(){}[0] + final val version // app.tinyui.updates/UpdateEvent.Installed.version|{}version[0] + final fun (): kotlin/String // app.tinyui.updates/UpdateEvent.Installed.version.|(){}[0] + + final fun component1(): kotlin/String // app.tinyui.updates/UpdateEvent.Installed.component1|component1(){}[0] + final fun component2(): kotlin/String // app.tinyui.updates/UpdateEvent.Installed.component2|component2(){}[0] + final fun copy(kotlin/String = ..., kotlin/String = ...): app.tinyui.updates/UpdateEvent.Installed // app.tinyui.updates/UpdateEvent.Installed.copy|copy(kotlin.String;kotlin.String){}[0] + final fun equals(kotlin/Any?): kotlin/Boolean // app.tinyui.updates/UpdateEvent.Installed.equals|equals(kotlin.Any?){}[0] + final fun hashCode(): kotlin/Int // app.tinyui.updates/UpdateEvent.Installed.hashCode|hashCode(){}[0] + final fun toString(): kotlin/String // app.tinyui.updates/UpdateEvent.Installed.toString|toString(){}[0] + } + + final class RolledBack : app.tinyui.updates/UpdateEvent { // app.tinyui.updates/UpdateEvent.RolledBack|null[0] + constructor (kotlin/String, kotlin/String, kotlin/String, kotlin/String, kotlin/String, kotlin/String) // app.tinyui.updates/UpdateEvent.RolledBack.|(kotlin.String;kotlin.String;kotlin.String;kotlin.String;kotlin.String;kotlin.String){}[0] + + final val buildId // app.tinyui.updates/UpdateEvent.RolledBack.buildId|{}buildId[0] + final fun (): kotlin/String // app.tinyui.updates/UpdateEvent.RolledBack.buildId.|(){}[0] + final val kind // app.tinyui.updates/UpdateEvent.RolledBack.kind|{}kind[0] + final fun (): kotlin/String // app.tinyui.updates/UpdateEvent.RolledBack.kind.|(){}[0] + final val message // app.tinyui.updates/UpdateEvent.RolledBack.message|{}message[0] + final fun (): kotlin/String // app.tinyui.updates/UpdateEvent.RolledBack.message.|(){}[0] + final val page // app.tinyui.updates/UpdateEvent.RolledBack.page|{}page[0] + final fun (): kotlin/String // app.tinyui.updates/UpdateEvent.RolledBack.page.|(){}[0] + final val pkg // app.tinyui.updates/UpdateEvent.RolledBack.pkg|{}pkg[0] + final fun (): kotlin/String // app.tinyui.updates/UpdateEvent.RolledBack.pkg.|(){}[0] + final val version // app.tinyui.updates/UpdateEvent.RolledBack.version|{}version[0] + final fun (): kotlin/String // app.tinyui.updates/UpdateEvent.RolledBack.version.|(){}[0] + + final fun component1(): kotlin/String // app.tinyui.updates/UpdateEvent.RolledBack.component1|component1(){}[0] + final fun component2(): kotlin/String // app.tinyui.updates/UpdateEvent.RolledBack.component2|component2(){}[0] + final fun component3(): kotlin/String // app.tinyui.updates/UpdateEvent.RolledBack.component3|component3(){}[0] + final fun component4(): kotlin/String // app.tinyui.updates/UpdateEvent.RolledBack.component4|component4(){}[0] + final fun component5(): kotlin/String // app.tinyui.updates/UpdateEvent.RolledBack.component5|component5(){}[0] + final fun component6(): kotlin/String // app.tinyui.updates/UpdateEvent.RolledBack.component6|component6(){}[0] + final fun copy(kotlin/String = ..., kotlin/String = ..., kotlin/String = ..., kotlin/String = ..., kotlin/String = ..., kotlin/String = ...): app.tinyui.updates/UpdateEvent.RolledBack // app.tinyui.updates/UpdateEvent.RolledBack.copy|copy(kotlin.String;kotlin.String;kotlin.String;kotlin.String;kotlin.String;kotlin.String){}[0] + final fun equals(kotlin/Any?): kotlin/Boolean // app.tinyui.updates/UpdateEvent.RolledBack.equals|equals(kotlin.Any?){}[0] + final fun hashCode(): kotlin/Int // app.tinyui.updates/UpdateEvent.RolledBack.hashCode|hashCode(){}[0] + final fun toString(): kotlin/String // app.tinyui.updates/UpdateEvent.RolledBack.toString|toString(){}[0] + } + + final class Running : app.tinyui.updates/UpdateEvent { // app.tinyui.updates/UpdateEvent.Running|null[0] + constructor (kotlin/String, kotlin/String, app.tinyui.updates/Source) // app.tinyui.updates/UpdateEvent.Running.|(kotlin.String;kotlin.String;app.tinyui.updates.Source){}[0] + + final val pkg // app.tinyui.updates/UpdateEvent.Running.pkg|{}pkg[0] + final fun (): kotlin/String // app.tinyui.updates/UpdateEvent.Running.pkg.|(){}[0] + final val source // app.tinyui.updates/UpdateEvent.Running.source|{}source[0] + final fun (): app.tinyui.updates/Source // app.tinyui.updates/UpdateEvent.Running.source.|(){}[0] + final val version // app.tinyui.updates/UpdateEvent.Running.version|{}version[0] + final fun (): kotlin/String // app.tinyui.updates/UpdateEvent.Running.version.|(){}[0] + + final fun component1(): kotlin/String // app.tinyui.updates/UpdateEvent.Running.component1|component1(){}[0] + final fun component2(): kotlin/String // app.tinyui.updates/UpdateEvent.Running.component2|component2(){}[0] + final fun component3(): app.tinyui.updates/Source // app.tinyui.updates/UpdateEvent.Running.component3|component3(){}[0] + final fun copy(kotlin/String = ..., kotlin/String = ..., app.tinyui.updates/Source = ...): app.tinyui.updates/UpdateEvent.Running // app.tinyui.updates/UpdateEvent.Running.copy|copy(kotlin.String;kotlin.String;app.tinyui.updates.Source){}[0] + final fun equals(kotlin/Any?): kotlin/Boolean // app.tinyui.updates/UpdateEvent.Running.equals|equals(kotlin.Any?){}[0] + final fun hashCode(): kotlin/Int // app.tinyui.updates/UpdateEvent.Running.hashCode|hashCode(){}[0] + final fun toString(): kotlin/String // app.tinyui.updates/UpdateEvent.Running.toString|toString(){}[0] + } + + final class Skipped : app.tinyui.updates/UpdateEvent { // app.tinyui.updates/UpdateEvent.Skipped|null[0] + constructor (kotlin/String, kotlin/String, app.tinyui.updates/SkipReason, kotlin.collections/Set = ...) // app.tinyui.updates/UpdateEvent.Skipped.|(kotlin.String;kotlin.String;app.tinyui.updates.SkipReason;kotlin.collections.Set){}[0] + + final val mismatch // app.tinyui.updates/UpdateEvent.Skipped.mismatch|{}mismatch[0] + final fun (): kotlin.collections/Set // app.tinyui.updates/UpdateEvent.Skipped.mismatch.|(){}[0] + final val pkg // app.tinyui.updates/UpdateEvent.Skipped.pkg|{}pkg[0] + final fun (): kotlin/String // app.tinyui.updates/UpdateEvent.Skipped.pkg.|(){}[0] + final val reason // app.tinyui.updates/UpdateEvent.Skipped.reason|{}reason[0] + final fun (): app.tinyui.updates/SkipReason // app.tinyui.updates/UpdateEvent.Skipped.reason.|(){}[0] + final val version // app.tinyui.updates/UpdateEvent.Skipped.version|{}version[0] + final fun (): kotlin/String // app.tinyui.updates/UpdateEvent.Skipped.version.|(){}[0] + + final fun component1(): kotlin/String // app.tinyui.updates/UpdateEvent.Skipped.component1|component1(){}[0] + final fun component2(): kotlin/String // app.tinyui.updates/UpdateEvent.Skipped.component2|component2(){}[0] + final fun component3(): app.tinyui.updates/SkipReason // app.tinyui.updates/UpdateEvent.Skipped.component3|component3(){}[0] + final fun component4(): kotlin.collections/Set // app.tinyui.updates/UpdateEvent.Skipped.component4|component4(){}[0] + final fun copy(kotlin/String = ..., kotlin/String = ..., app.tinyui.updates/SkipReason = ..., kotlin.collections/Set = ...): app.tinyui.updates/UpdateEvent.Skipped // app.tinyui.updates/UpdateEvent.Skipped.copy|copy(kotlin.String;kotlin.String;app.tinyui.updates.SkipReason;kotlin.collections.Set){}[0] + final fun equals(kotlin/Any?): kotlin/Boolean // app.tinyui.updates/UpdateEvent.Skipped.equals|equals(kotlin.Any?){}[0] + final fun hashCode(): kotlin/Int // app.tinyui.updates/UpdateEvent.Skipped.hashCode|hashCode(){}[0] + final fun toString(): kotlin/String // app.tinyui.updates/UpdateEvent.Skipped.toString|toString(){}[0] + } + + final class UpToDate : app.tinyui.updates/UpdateEvent { // app.tinyui.updates/UpdateEvent.UpToDate|null[0] + constructor (kotlin/String, kotlin/String) // app.tinyui.updates/UpdateEvent.UpToDate.|(kotlin.String;kotlin.String){}[0] + + final val pkg // app.tinyui.updates/UpdateEvent.UpToDate.pkg|{}pkg[0] + final fun (): kotlin/String // app.tinyui.updates/UpdateEvent.UpToDate.pkg.|(){}[0] + final val version // app.tinyui.updates/UpdateEvent.UpToDate.version|{}version[0] + final fun (): kotlin/String // app.tinyui.updates/UpdateEvent.UpToDate.version.|(){}[0] + + final fun component1(): kotlin/String // app.tinyui.updates/UpdateEvent.UpToDate.component1|component1(){}[0] + final fun component2(): kotlin/String // app.tinyui.updates/UpdateEvent.UpToDate.component2|component2(){}[0] + final fun copy(kotlin/String = ..., kotlin/String = ...): app.tinyui.updates/UpdateEvent.UpToDate // app.tinyui.updates/UpdateEvent.UpToDate.copy|copy(kotlin.String;kotlin.String){}[0] + final fun equals(kotlin/Any?): kotlin/Boolean // app.tinyui.updates/UpdateEvent.UpToDate.equals|equals(kotlin.Any?){}[0] + final fun hashCode(): kotlin/Int // app.tinyui.updates/UpdateEvent.UpToDate.hashCode|hashCode(){}[0] + final fun toString(): kotlin/String // app.tinyui.updates/UpdateEvent.UpToDate.toString|toString(){}[0] + } +} + +final val app.tinyui.updates/app_tinyui_updates_CheckResult$stableprop // app.tinyui.updates/app_tinyui_updates_CheckResult$stableprop|#static{}app_tinyui_updates_CheckResult$stableprop[0] +final val app.tinyui.updates/app_tinyui_updates_CheckResult_Failed$stableprop // app.tinyui.updates/app_tinyui_updates_CheckResult_Failed$stableprop|#static{}app_tinyui_updates_CheckResult_Failed$stableprop[0] +final val app.tinyui.updates/app_tinyui_updates_CheckResult_Installed$stableprop // app.tinyui.updates/app_tinyui_updates_CheckResult_Installed$stableprop|#static{}app_tinyui_updates_CheckResult_Installed$stableprop[0] +final val app.tinyui.updates/app_tinyui_updates_CheckResult_Skipped$stableprop // app.tinyui.updates/app_tinyui_updates_CheckResult_Skipped$stableprop|#static{}app_tinyui_updates_CheckResult_Skipped$stableprop[0] +final val app.tinyui.updates/app_tinyui_updates_CheckResult_UpToDate$stableprop // app.tinyui.updates/app_tinyui_updates_CheckResult_UpToDate$stableprop|#static{}app_tinyui_updates_CheckResult_UpToDate$stableprop[0] +final val app.tinyui.updates/app_tinyui_updates_UpdateEvent$stableprop // app.tinyui.updates/app_tinyui_updates_UpdateEvent$stableprop|#static{}app_tinyui_updates_UpdateEvent$stableprop[0] +final val app.tinyui.updates/app_tinyui_updates_UpdateEvent_Failed$stableprop // app.tinyui.updates/app_tinyui_updates_UpdateEvent_Failed$stableprop|#static{}app_tinyui_updates_UpdateEvent_Failed$stableprop[0] +final val app.tinyui.updates/app_tinyui_updates_UpdateEvent_Installed$stableprop // app.tinyui.updates/app_tinyui_updates_UpdateEvent_Installed$stableprop|#static{}app_tinyui_updates_UpdateEvent_Installed$stableprop[0] +final val app.tinyui.updates/app_tinyui_updates_UpdateEvent_RolledBack$stableprop // app.tinyui.updates/app_tinyui_updates_UpdateEvent_RolledBack$stableprop|#static{}app_tinyui_updates_UpdateEvent_RolledBack$stableprop[0] +final val app.tinyui.updates/app_tinyui_updates_UpdateEvent_Running$stableprop // app.tinyui.updates/app_tinyui_updates_UpdateEvent_Running$stableprop|#static{}app_tinyui_updates_UpdateEvent_Running$stableprop[0] +final val app.tinyui.updates/app_tinyui_updates_UpdateEvent_Skipped$stableprop // app.tinyui.updates/app_tinyui_updates_UpdateEvent_Skipped$stableprop|#static{}app_tinyui_updates_UpdateEvent_Skipped$stableprop[0] +final val app.tinyui.updates/app_tinyui_updates_UpdateEvent_UpToDate$stableprop // app.tinyui.updates/app_tinyui_updates_UpdateEvent_UpToDate$stableprop|#static{}app_tinyui_updates_UpdateEvent_UpToDate$stableprop[0] +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/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] +final fun app.tinyui.updates/app_tinyui_updates_CheckResult_Skipped$stableprop_getter(): kotlin/Int // app.tinyui.updates/app_tinyui_updates_CheckResult_Skipped$stableprop_getter|app_tinyui_updates_CheckResult_Skipped$stableprop_getter(){}[0] +final fun app.tinyui.updates/app_tinyui_updates_CheckResult_UpToDate$stableprop_getter(): kotlin/Int // app.tinyui.updates/app_tinyui_updates_CheckResult_UpToDate$stableprop_getter|app_tinyui_updates_CheckResult_UpToDate$stableprop_getter(){}[0] +final fun app.tinyui.updates/app_tinyui_updates_UpdateEvent$stableprop_getter(): kotlin/Int // app.tinyui.updates/app_tinyui_updates_UpdateEvent$stableprop_getter|app_tinyui_updates_UpdateEvent$stableprop_getter(){}[0] +final fun app.tinyui.updates/app_tinyui_updates_UpdateEvent_Failed$stableprop_getter(): kotlin/Int // app.tinyui.updates/app_tinyui_updates_UpdateEvent_Failed$stableprop_getter|app_tinyui_updates_UpdateEvent_Failed$stableprop_getter(){}[0] +final fun app.tinyui.updates/app_tinyui_updates_UpdateEvent_Installed$stableprop_getter(): kotlin/Int // app.tinyui.updates/app_tinyui_updates_UpdateEvent_Installed$stableprop_getter|app_tinyui_updates_UpdateEvent_Installed$stableprop_getter(){}[0] +final fun app.tinyui.updates/app_tinyui_updates_UpdateEvent_RolledBack$stableprop_getter(): kotlin/Int // app.tinyui.updates/app_tinyui_updates_UpdateEvent_RolledBack$stableprop_getter|app_tinyui_updates_UpdateEvent_RolledBack$stableprop_getter(){}[0] +final fun app.tinyui.updates/app_tinyui_updates_UpdateEvent_Running$stableprop_getter(): kotlin/Int // app.tinyui.updates/app_tinyui_updates_UpdateEvent_Running$stableprop_getter|app_tinyui_updates_UpdateEvent_Running$stableprop_getter(){}[0] +final fun app.tinyui.updates/app_tinyui_updates_UpdateEvent_Skipped$stableprop_getter(): kotlin/Int // app.tinyui.updates/app_tinyui_updates_UpdateEvent_Skipped$stableprop_getter|app_tinyui_updates_UpdateEvent_Skipped$stableprop_getter(){}[0] +final fun app.tinyui.updates/app_tinyui_updates_UpdateEvent_UpToDate$stableprop_getter(): kotlin/Int // app.tinyui.updates/app_tinyui_updates_UpdateEvent_UpToDate$stableprop_getter|app_tinyui_updates_UpdateEvent_UpToDate$stableprop_getter(){}[0] +final fun app.tinyui.updates/app_tinyui_updates_Updates$stableprop_getter(): kotlin/Int // app.tinyui.updates/app_tinyui_updates_Updates$stableprop_getter|app_tinyui_updates_Updates$stableprop_getter(){}[0] +final fun app.tinyui.updates/app_tinyui_updates_Updates_State_$serializer$stableprop_getter(): kotlin/Int // app.tinyui.updates/app_tinyui_updates_Updates_State_$serializer$stableprop_getter|app_tinyui_updates_Updates_State_$serializer$stableprop_getter(){}[0] diff --git a/updates/build.gradle.kts b/updates/build.gradle.kts new file mode 100644 index 0000000..c04a736 --- /dev/null +++ b/updates/build.gradle.kts @@ -0,0 +1,65 @@ +plugins { + id("tinyui.kmp.library") + alias(libs.plugins.composeMultiplatform) + alias(libs.plugins.composeCompiler) + alias(libs.plugins.kotlinSerialization) + alias(libs.plugins.vanniktech.mavenPublish) + alias(libs.plugins.binaryCompatibilityValidator) +} + +kotlin { + android { + namespace = "app.tinyui.updates" + } + + sourceSets { + commonMain.dependencies { + api(project(":compose")) + implementation(libs.quickjs.kmp) + api(libs.okio) + implementation(compose.runtime) + implementation(compose.foundation) + implementation(libs.kotlinx.coroutines.core) + implementation(libs.kotlinx.serialization.json) + } + commonTest.dependencies { + implementation(libs.kotlin.test) + implementation(libs.kotlinx.coroutines.test) + } + } +} + +mavenPublishing { + publishToMavenCentral() + if (providers.gradleProperty("signingInMemoryKey").isPresent) { + signAllPublications() + } + + coordinates(artifactId = "tinyui-updates") + + pom { + name.set("TinyUI Updates") + description.set("Hot updates for TinyUI packages: verification, storage, selection and rollback (docs/updates.md).") + url.set("https://github.com/tiny-ui/tinyui") + licenses { + license { + name.set("MIT") + url.set("https://opensource.org/licenses/MIT") + } + } + developers { + developer { + id.set("HarlonWang") + name.set("Harlon Wang") + } + } + scm { + url.set("https://github.com/tiny-ui/tinyui") + connection.set("scm:git:https://github.com/tiny-ui/tinyui.git") + } + } +} + +apiValidation { + klib { enabled = true } +} diff --git a/updates/src/androidHostTest/kotlin/app/tinyui/updates/TestPaths.android.kt b/updates/src/androidHostTest/kotlin/app/tinyui/updates/TestPaths.android.kt new file mode 100644 index 0000000..6fbf4ec --- /dev/null +++ b/updates/src/androidHostTest/kotlin/app/tinyui/updates/TestPaths.android.kt @@ -0,0 +1,6 @@ +package app.tinyui.updates + +import okio.FileSystem +import okio.Path + +internal actual fun temporaryDirectory(): Path = FileSystem.SYSTEM_TEMPORARY_DIRECTORY diff --git a/updates/src/androidMain/kotlin/app/tinyui/updates/FileSystem.android.kt b/updates/src/androidMain/kotlin/app/tinyui/updates/FileSystem.android.kt new file mode 100644 index 0000000..811b7be --- /dev/null +++ b/updates/src/androidMain/kotlin/app/tinyui/updates/FileSystem.android.kt @@ -0,0 +1,5 @@ +package app.tinyui.updates + +import okio.FileSystem + +internal actual val platformFileSystem: FileSystem = FileSystem.SYSTEM diff --git a/updates/src/androidMain/kotlin/app/tinyui/updates/Signature.android.kt b/updates/src/androidMain/kotlin/app/tinyui/updates/Signature.android.kt new file mode 100644 index 0000000..b4355d8 --- /dev/null +++ b/updates/src/androidMain/kotlin/app/tinyui/updates/Signature.android.kt @@ -0,0 +1,20 @@ +package app.tinyui.updates + +import java.security.KeyFactory +import java.security.Signature +import java.security.spec.X509EncodedKeySpec + +/** SubjectPublicKeyInfo header for an uncompressed P-256 point: the raw point becomes X.509 by prefixing it. */ +private val P256_SPKI_PREFIX = byteArrayOf( + 0x30, 0x59, 0x30, 0x13, 0x06, 0x07, 0x2a, 0x86.toByte(), 0x48, 0xce.toByte(), 0x3d, 0x02, 0x01, + 0x06, 0x08, 0x2a, 0x86.toByte(), 0x48, 0xce.toByte(), 0x3d, 0x03, 0x01, 0x07, 0x03, 0x42, 0x00, +) + +internal actual fun verifyEcdsaP256(publicKey: ByteArray, data: ByteArray, signature: ByteArray): Boolean { + val key = KeyFactory.getInstance("EC").generatePublic(X509EncodedKeySpec(P256_SPKI_PREFIX + publicKey)) + return Signature.getInstance("SHA256withECDSA").run { + initVerify(key) + update(data) + verify(signature) + } +} diff --git a/updates/src/commonMain/kotlin/app/tinyui/updates/Events.kt b/updates/src/commonMain/kotlin/app/tinyui/updates/Events.kt new file mode 100644 index 0000000..949c7c3 --- /dev/null +++ b/updates/src/commonMain/kotlin/app/tinyui/updates/Events.kt @@ -0,0 +1,38 @@ +package app.tinyui.updates + +/** Where the package a process runs came from. */ +enum class Source { EMBEDDED, INSTALLED } + +enum class SkipReason { INCOMPATIBLE, FAILED_BEFORE, OLDER_THAN_EMBEDDED, ROLLOUT } + +/** Which signed manifest field disagreed with this host (docs/updates.md §4.3). */ +enum class Mismatch { NAME, VERSION, ENGINE, PROTOCOL, RUNTIME_VERSION } + +enum class FailStage { POINTER, MANIFEST, SIGNATURE, DOWNLOAD, INTEGRITY, STORAGE } + +/** Outcome of `check()` for one package (docs/updates.md §4.2). */ +sealed class CheckResult { + data class UpToDate(val version: String) : CheckResult() + data class Installed(val version: String) : CheckResult() + data class Skipped(val version: String, val reason: SkipReason, val mismatch: Set = emptySet()) : CheckResult() + data class Failed(val version: String?, val stage: FailStage, val message: String) : CheckResult() +} + +/** What the host logs and reports; every event names its package (docs/updates.md §4.2). */ +sealed class UpdateEvent { + abstract val pkg: String + + data class Running(override val pkg: String, val version: String, val source: Source) : UpdateEvent() + data class UpToDate(override val pkg: String, val version: String) : UpdateEvent() + data class Skipped(override val pkg: String, val version: String, val reason: SkipReason, val mismatch: Set = emptySet()) : UpdateEvent() + data class Installed(override val pkg: String, val version: String) : UpdateEvent() + data class Failed(override val pkg: String, val version: String?, val stage: FailStage, val message: String) : UpdateEvent() + data class RolledBack(override val pkg: String, val version: String, val page: String, val kind: String, val buildId: String, val message: String) : UpdateEvent() +} + +internal fun CheckResult.toEvent(pkg: String): UpdateEvent = when (this) { + is CheckResult.UpToDate -> UpdateEvent.UpToDate(pkg, version) + is CheckResult.Installed -> UpdateEvent.Installed(pkg, version) + is CheckResult.Skipped -> UpdateEvent.Skipped(pkg, version, reason, mismatch) + is CheckResult.Failed -> UpdateEvent.Failed(pkg, version, stage, message) +} diff --git a/updates/src/commonMain/kotlin/app/tinyui/updates/Signature.kt b/updates/src/commonMain/kotlin/app/tinyui/updates/Signature.kt new file mode 100644 index 0000000..deee298 --- /dev/null +++ b/updates/src/commonMain/kotlin/app/tinyui/updates/Signature.kt @@ -0,0 +1,19 @@ +package app.tinyui.updates + +import okio.ByteString.Companion.decodeBase64 + +/** ECDSA P-256 / SHA-256 over raw bytes; key is an X9.63 point, signature DER, both base64 (docs/updates.md §7). */ +internal fun interface SignatureVerifier { + fun verify(publicKey: String, data: ByteArray, signature: String): Boolean +} + +internal object PlatformSignatureVerifier : SignatureVerifier { + override fun verify(publicKey: String, data: ByteArray, signature: String): Boolean { + val key = publicKey.decodeBase64()?.toByteArray() ?: return false + val sig = signature.decodeBase64()?.toByteArray() ?: return false + if (key.size != 65 || key[0] != 0x04.toByte() || sig.isEmpty()) return false + return runCatching { verifyEcdsaP256(key, data, sig) }.getOrDefault(false) + } +} + +internal expect fun verifyEcdsaP256(publicKey: ByteArray, data: ByteArray, signature: ByteArray): Boolean diff --git a/updates/src/commonMain/kotlin/app/tinyui/updates/Updates.kt b/updates/src/commonMain/kotlin/app/tinyui/updates/Updates.kt new file mode 100644 index 0000000..a814b71 --- /dev/null +++ b/updates/src/commonMain/kotlin/app/tinyui/updates/Updates.kt @@ -0,0 +1,314 @@ +package app.tinyui.updates + +import app.tinyui.BuildManifest +import app.tinyui.Bundle +import app.tinyui.PageFailure +import app.tinyui.PageHost +import kotlinx.coroutines.CancellationException +import kotlinx.coroutines.CompletableDeferred +import kotlinx.coroutines.async +import kotlinx.coroutines.coroutineScope +import kotlinx.coroutines.currentCoroutineContext +import kotlinx.coroutines.ensureActive +import kotlinx.coroutines.sync.Mutex +import kotlinx.coroutines.sync.withLock +import kotlinx.serialization.Serializable +import kotlinx.serialization.json.Json +import kotlinx.serialization.json.intOrNull +import kotlinx.serialization.json.jsonObject +import kotlinx.serialization.json.jsonPrimitive +import okio.ByteString.Companion.encodeUtf8 +import okio.ByteString.Companion.toByteString +import okio.FileSystem +import okio.IOException +import okio.Path +import wang.harlon.quickjs.QuickJs + +/** + * Hot updates for a set of packages (docs/updates.md §4): verifies, stores, selects and rolls back, never + * fetches on its own. The host provides its embedded packages, a `fetch` for the relative paths of §2, a + * directory, a stable install id and the `runtimeVersion` it declares. + */ +class Updates internal constructor( + packages: List, + val runtimeVersion: String, + private val dir: Path, + private val installId: String, + private val fetch: suspend (path: String) -> ByteArray, + private val onEvent: (UpdateEvent) -> Unit, + private val fs: FileSystem, + private val engine: String, + private val protocol: Int, + private val verifier: SignatureVerifier, +) { + constructor( + packages: List, + runtimeVersion: String, + dir: Path, + installId: String, + fetch: suspend (path: String) -> ByteArray, + onEvent: (UpdateEvent) -> Unit = {}, + ) : this(packages, runtimeVersion, dir, installId, fetch, onEvent, platformFileSystem, QuickJs.upstreamCommit, PageHost.PROTOCOL, PlatformSignatureVerifier) + + private val packages: Map + private val checkLock = Mutex() + private var inFlight: CompletableDeferred>? = null + + init { + require(packages.isNotEmpty()) { "Updates needs at least one embedded package" } + require(isPathSegment(runtimeVersion)) { "runtimeVersion must be a single path segment, got \"$runtimeVersion\"" } + val byName = LinkedHashMap() + for (bundle in packages) { + require(bundle.name !in byName) { "two embedded packages are named ${bundle.name}" } + require(bundle.manifest.engine == engine) { "package ${bundle.name} was built for engine ${bundle.manifest.engine}, this host embeds $engine" } + byName[bundle.name] = Package(bundle) + } + this.packages = byName + for (p in byName.values) p.selectAtStartup() + // App upgrades drop packages: their directories go too + fs.listOrNull(dir)?.filter { it.name !in byName }?.forEach { fs.deleteRecursively(it) } + for (p in byName.values) { + onEvent(UpdateEvent.Running(p.name, p.current.manifest.version, if (p.current === p.embedded) Source.EMBEDDED else Source.INSTALLED)) + } + } + + /** The bundle every page of [pkg] loads from in this process: chosen at construction, embedded again after a rollback. */ + fun current(pkg: String): Bundle = pkg(pkg).current + + /** Runs the check for every package at once; a second call while one is running joins it. */ + suspend fun check(): Map { + val (run, owner) = checkLock.withLock { + inFlight?.let { it to false } ?: CompletableDeferred>().also { inFlight = it }.let { it to true } + } + if (!owner) return run.await() + try { + val results = coroutineScope { packages.keys.map { pkg -> async { pkg to check(pkg) } }.map { it.await() } }.toMap() + run.complete(results) + return results + } catch (e: Throwable) { + run.completeExceptionally(e) + throw e + } finally { + checkLock.withLock { inFlight = null } + } + } + + /** One package; two calls for the same package at once run one after the other. */ + suspend fun check(pkg: String): CheckResult { + val p = pkg(pkg) + return p.lock.withLock { p.check() }.also { onEvent(it.toEvent(pkg)) } + } + + internal fun embedded(pkg: String): Bundle = pkg(pkg).embedded + + /** + * A page of [bundle], an installed package of [pkg], failed (docs/updates.md §4.5): blacklist that version and go + * back to embedded. A newer version `check()` installed meanwhile is left alone, only the failed one is dropped. + */ + internal suspend fun rollBack(pkg: String, bundle: Bundle, page: String, failure: PageFailure) { + val p = pkg(pkg) + val version = bundle.manifest.version + p.lock.withLock { + if (version !in p.failed) p.failed += version + p.current = p.embedded + if (p.installed == version) { + p.installed = null + runCatching { fs.deleteRecursively(p.root / INSTALLED / version) } + } + p.saveState() + } + onEvent(UpdateEvent.RolledBack(pkg, version, page, failure.kind, failure.error.buildId, failure.message)) + } + + private fun pkg(name: String): Package = packages[name] ?: throw IllegalArgumentException("no embedded package named $name; have ${packages.keys}") + + private fun inRollout(pkg: String, version: String, rollout: Int): Boolean { + val hash = "$installId:$pkg:$version".encodeUtf8().sha256() + val bucket = ((hash[0].toLong() and 0xff) shl 24 or ((hash[1].toLong() and 0xff) shl 16) or ((hash[2].toLong() and 0xff) shl 8) or (hash[3].toLong() and 0xff)) % 100 + return bucket < rollout + } + + @Serializable + private class State(val installed: String? = null, val failed: List = emptyList()) + + private class Pointer(val version: String, val rollout: Int, val signature: String) + + private inner class Package(val embedded: Bundle) { + val name: String = embedded.name + val root: Path = dir / name + val lock = Mutex() + var installed: String? = null + val failed = ArrayList() + var current: Bundle = embedded + + fun selectAtStartup() { + readState() + runCatching { fs.deleteRecursively(root / STAGING) } + val installedDir = root / INSTALLED + val keep = installed + fs.listOrNull(installedDir)?.filter { it.name != keep }?.forEach { fs.deleteRecursively(it) } + if (keep == null) return + val loaded = loadInstalled(installedDir / keep) + if (loaded != null) { + current = loaded + return + } + installed = null + saveState() + fs.deleteRecursively(installedDir / keep) + } + + /** The installed package when it is still the one to run: newer than embedded, this runtime version, intact. */ + private fun loadInstalled(dir: Path): Bundle? { + val manifest = runCatching { BuildManifest.parse(fs.read(dir / MANIFEST) { readUtf8() }) }.getOrNull() ?: return null + if (manifest.name != name || manifest.runtimeVersion != runtimeVersion || manifest.version in failed) return null + if (manifest.createdAt <= embedded.manifest.createdAt) return null + val files = HashMap() + for (module in manifest.runtime + manifest.pages) { + val path = manifest.file(module) + ".bin" + val bytes = runCatching { fs.read(dir / path) { readByteArray() } }.getOrNull() + if (bytes == null || bytes.toByteString().sha256().hex() != manifest.hashes[module]) { + failed += manifest.version + onEvent(UpdateEvent.Failed(name, manifest.version, FailStage.INTEGRITY, "$path is missing or does not match manifest.hashes")) + return null + } + files[path] = bytes + } + return Bundle(manifest) { path -> files[path] } + } + + suspend fun check(): CheckResult { + val pointer = try { + parsePointer(fetch("$name/$runtimeVersion/$POINTER").decodeToString()) + } catch (e: CancellationException) { + throw e + } catch (e: Exception) { + currentCoroutineContext().ensureActive() + return CheckResult.Failed(null, FailStage.POINTER, e.message ?: e.toString()) + } + val version = pointer.version + if (!isPathSegment(version)) return CheckResult.Failed(version, FailStage.POINTER, "version \"$version\" is not a path segment") + if (version == installed) return CheckResult.UpToDate(version) + if (version in failed) return CheckResult.Skipped(version, SkipReason.FAILED_BEFORE) + if (!inRollout(name, version, pointer.rollout)) return CheckResult.Skipped(version, SkipReason.ROLLOUT) + + val manifestBytes = try { + fetch("$name/$runtimeVersion/$version/$MANIFEST") + } catch (e: CancellationException) { + throw e + } catch (e: Exception) { + currentCoroutineContext().ensureActive() + return CheckResult.Failed(version, FailStage.MANIFEST, e.message ?: e.toString()) + } + val trusted = embedded.manifest.publicKey + if (!verifier.verify(trusted, manifestBytes, pointer.signature)) { + val theirs = runCatching { BuildManifest.parse(manifestBytes.decodeToString()).publicKey }.getOrNull() + val hint = if (theirs == trusted) "the manifest names the embedded publicKey" else "the manifest names another publicKey (rotated?)" + return CheckResult.Failed(version, FailStage.SIGNATURE, "signature does not verify; $hint") + } + val manifest = try { + BuildManifest.parse(manifestBytes.decodeToString()) + } catch (e: Exception) { + return CheckResult.Failed(version, FailStage.MANIFEST, e.message ?: e.toString()) + } + val mismatch = buildSet { + if (manifest.name != name) add(Mismatch.NAME) + if (manifest.version != version) add(Mismatch.VERSION) + if (manifest.runtimeVersion != runtimeVersion) add(Mismatch.RUNTIME_VERSION) + if (manifest.engine != engine) add(Mismatch.ENGINE) + if (manifest.protocol != protocol) add(Mismatch.PROTOCOL) + } + if (mismatch.isNotEmpty()) return CheckResult.Skipped(version, SkipReason.INCOMPATIBLE, mismatch) + if (manifest.createdAt <= embedded.manifest.createdAt) return CheckResult.Skipped(version, SkipReason.OLDER_THAN_EMBEDDED) + + val stagingRoot = root / STAGING + val staging = stagingRoot / version + fun discard() = runCatching { fs.deleteRecursively(stagingRoot) } + try { + fs.deleteRecursively(stagingRoot) + fs.createDirectories(staging) + val failure = downloadInto(staging, manifest, manifestBytes) + if (failure != null) { + discard() + return failure + } + val target = root / INSTALLED / version + fs.createDirectories(root / INSTALLED) + fs.deleteRecursively(target) + fs.atomicMove(staging, target) + discard() + installed = version + saveState() + fs.list(root / INSTALLED).filter { it.name != version }.forEach { fs.deleteRecursively(it) } + } catch (e: CancellationException) { + discard() + throw e + } catch (e: IOException) { + discard() + return CheckResult.Failed(version, FailStage.STORAGE, e.message ?: e.toString()) + } + return CheckResult.Installed(version) + } + + /** 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.runtime + manifest.pages) { + val path = manifest.file(module) + ".bin" + val bytes = try { + fetch("$name/$runtimeVersion/$version/$path") + } catch (e: CancellationException) { + throw e + } catch (e: Exception) { + currentCoroutineContext().ensureActive() + return CheckResult.Failed(version, FailStage.DOWNLOAD, "$path: ${e.message ?: e}") + } + if (bytes.toByteString().sha256().hex() != manifest.hashes[module]) { + return CheckResult.Failed(version, FailStage.INTEGRITY, "$path does not match manifest.hashes") + } + val file = staging / path + file.parent?.let { fs.createDirectories(it) } + fs.write(file) { write(bytes) } + } + fs.write(staging / MANIFEST) { write(manifestBytes) } + return null + } + + private fun readState() { + val state = runCatching { json.decodeFromString(State.serializer(), fs.read(root / STATE) { readUtf8() }) }.getOrNull() ?: return + installed = state.installed + failed += state.failed + } + + fun saveState() { + // a temporary file and a rename: the state is whole at every instant + fs.createDirectories(root) + val tmp = root / "$STATE.tmp" + fs.write(tmp) { writeUtf8(json.encodeToString(State.serializer(), State(installed, failed.takeLast(MAX_FAILED)))) } + fs.atomicMove(tmp, root / STATE) + } + } + + private fun parsePointer(text: String): Pointer { + val root = Json.parseToJsonElement(text).jsonObject + val version = root["version"]?.jsonPrimitive?.content ?: throw IllegalArgumentException("current.json has no version") + val signature = root["signature"]?.jsonPrimitive?.content ?: throw IllegalArgumentException("current.json has no signature") + val rollout = root["rollout"]?.let { it.jsonPrimitive.intOrNull ?: throw IllegalArgumentException("current.json rollout is not an integer: $it") } ?: 100 + return Pointer(version, rollout.coerceIn(0, 100), signature) + } + + private companion object { + const val POINTER = "current.json" + const val MANIFEST = "manifest.json" + const val STATE = "state.json" + const val STAGING = "staging" + const val INSTALLED = "installed" + const val MAX_FAILED = 10 + val json = Json { ignoreUnknownKeys = true } + + fun isPathSegment(value: String): Boolean = value.isNotEmpty() && value != "." && value != ".." && value.all { it.isLetterOrDigit() || it == '.' || it == '_' || it == '-' } + } +} + +/** okio declares `FileSystem.SYSTEM` per platform, not in common code. */ +internal expect val platformFileSystem: FileSystem diff --git a/updates/src/commonMain/kotlin/app/tinyui/updates/UpdatesPage.kt b/updates/src/commonMain/kotlin/app/tinyui/updates/UpdatesPage.kt new file mode 100644 index 0000000..a0b1e86 --- /dev/null +++ b/updates/src/commonMain/kotlin/app/tinyui/updates/UpdatesPage.kt @@ -0,0 +1,56 @@ +package app.tinyui.updates + +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +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.LoadedPage +import app.tinyui.PageFailure +import app.tinyui.PageFailureScreen +import app.tinyui.PageHost +import app.tinyui.PageSink +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 + * `/` names the package. A page from an installed package that fails with E2 / E6 is remounted from the + * embedded package, which the whole package falls back to (docs/updates.md §4.5). + */ +@Composable +fun UpdatesPage( + updates: Updates, + name: String, + registry: ComponentRegistry, + sink: PageSink, + services: HostServices = HostServices.Default, + propsJson: String = "{}", + modifier: Modifier = Modifier, + error: @Composable (PageFailure) -> Unit = { PageFailureScreen(it) }, + onHost: (PageHost) -> Unit = {}, +) { + val pkg = name.substringBefore('/') + var bundle by remember(updates, name) { mutableStateOf(updates.current(pkg)) } + 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, + 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)) { + LaunchedEffect(failure) { + updates.rollBack(pkg, page.bundle, page.module.name, failure) + bundle = updates.embedded(pkg) + } + } else { + error(failure) + } + }, + onHost = onHost, + ) +} diff --git a/updates/src/commonTest/kotlin/app/tinyui/updates/Fixture.kt b/updates/src/commonTest/kotlin/app/tinyui/updates/Fixture.kt new file mode 100644 index 0000000..2824ac5 --- /dev/null +++ b/updates/src/commonTest/kotlin/app/tinyui/updates/Fixture.kt @@ -0,0 +1,46 @@ +package app.tinyui.updates + +/** A `/manifest.json` as `tinyui bundle` wrote it, with the `current.json` signature Node's crypto produced for these exact bytes. */ +object Fixture { + const val PUBLIC_KEY = "BC5lpOHvNqDvlsPmE+3KI2Lbr8fEh1U06S+2op5TSG5jjHvRdzk0DeXhMwsidC+i3V7YyVpwu9+qK/wUqs3hMnE=" + const val OTHER_PUBLIC_KEY = "BMxPERlI+YlzaKiP61LhtPrGll7Qv3U0jzrb9OlKgXO3GxfxPdV7G+QQxJ0Lr5GWpxfPewUwyWn3URiJ/+/Eh3k=" + const val SIGNATURE = "MEQCIHyi+3Gh1Ocj6vFfGCXLIqB+uHBW9MUGpl9j3ME58IigAiA/G/y10Jb6ytsjG4aDb1clS4mdJBBeyv/f0fzrZ4FVbw==" + const val ENGINE = "eeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeee" + const val VERSION = "20260922T100000Z-abcdef1" + val MANIFEST = """ + { + "runtime": [ + "tinyui-core", + "tinyui-native" + ], + "pages": [ + "shop/home" + ], + "files": { + "tinyui-core": "runtime/core", + "tinyui-native": "runtime/native", + "shop/home": "pages/home" + }, + "buildIds": { + "tinyui-core": "aaaaaaaa", + "tinyui-native": "bbbbbbbb", + "shop/home": "cccccccc" + }, + "name": "shop", + "publicKey": "BC5lpOHvNqDvlsPmE+3KI2Lbr8fEh1U06S+2op5TSG5jjHvRdzk0DeXhMwsidC+i3V7YyVpwu9+qK/wUqs3hMnE=", + "version": "20260922T100000Z-abcdef1", + "createdAt": "2026-09-22T10:00:00Z", + "engine": "eeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeee", + "protocol": 1, + "hashes": { + "tinyui-core": "d63180d8a590da5a74a6a62a700b4f1c1ccf8eb1286e0509e6b30fc72d92697b", + "tinyui-native": "8a65da82b504e482deb6e49382cd8b2abca80b6c6ad67ff82aa642f76c8fcc4d", + "shop/home": "6ca9202ab8e55afbd5f0e68113ef733655c190b1531cc7e1fc17ea3bb8d32230" + }, + "runtimeVersion": "1" + } + """.trimIndent() + "\n" + + /** The bytes whose sha256 the fixture manifest lists. */ + val FILES = mapOf("runtime/core.bin" to "CORE2", "runtime/native.bin" to "NATIVE2", "pages/home.bin" to "HOME2") +} diff --git a/updates/src/commonTest/kotlin/app/tinyui/updates/SignatureTest.kt b/updates/src/commonTest/kotlin/app/tinyui/updates/SignatureTest.kt new file mode 100644 index 0000000..6341240 --- /dev/null +++ b/updates/src/commonTest/kotlin/app/tinyui/updates/SignatureTest.kt @@ -0,0 +1,25 @@ +package app.tinyui.updates + +import kotlin.test.Test +import kotlin.test.assertFalse +import kotlin.test.assertTrue + +/** Real ECDSA on this platform against a manifest `tinyui bundle` signed (fixture from packages/cli, Node's crypto). */ +class SignatureTest { + @Test + fun verifiesTheBytesTinyuiBundleSigned() { + assertTrue(PlatformSignatureVerifier.verify(Fixture.PUBLIC_KEY, Fixture.MANIFEST.encodeToByteArray(), Fixture.SIGNATURE)) + } + + @Test + fun rejectsAnyChangeToTheBytesTheKeyOrTheSignature() { + val bytes = Fixture.MANIFEST.encodeToByteArray() + assertFalse(PlatformSignatureVerifier.verify(Fixture.PUBLIC_KEY, bytes + 0x20, Fixture.SIGNATURE), "one byte appended") + assertFalse(PlatformSignatureVerifier.verify(Fixture.PUBLIC_KEY, bytes.copyOf(bytes.size - 1), Fixture.SIGNATURE), "one byte removed") + assertFalse(PlatformSignatureVerifier.verify(Fixture.OTHER_PUBLIC_KEY, bytes, Fixture.SIGNATURE), "another key") + val flipped = Fixture.SIGNATURE.toCharArray().also { it[20] = if (it[20] == 'A') 'B' else 'A' }.concatToString() + assertFalse(PlatformSignatureVerifier.verify(Fixture.PUBLIC_KEY, bytes, flipped), "signature altered") + assertFalse(PlatformSignatureVerifier.verify("not base64!", bytes, Fixture.SIGNATURE)) + assertFalse(PlatformSignatureVerifier.verify(Fixture.PUBLIC_KEY, bytes, "")) + } +} diff --git a/updates/src/commonTest/kotlin/app/tinyui/updates/TestPaths.kt b/updates/src/commonTest/kotlin/app/tinyui/updates/TestPaths.kt new file mode 100644 index 0000000..dce3cc8 --- /dev/null +++ b/updates/src/commonTest/kotlin/app/tinyui/updates/TestPaths.kt @@ -0,0 +1,6 @@ +package app.tinyui.updates + +import okio.Path + +/** okio declares `SYSTEM_TEMPORARY_DIRECTORY` per platform, not in common code. */ +internal expect fun temporaryDirectory(): Path diff --git a/updates/src/commonTest/kotlin/app/tinyui/updates/UpdatesTest.kt b/updates/src/commonTest/kotlin/app/tinyui/updates/UpdatesTest.kt new file mode 100644 index 0000000..76d4a22 --- /dev/null +++ b/updates/src/commonTest/kotlin/app/tinyui/updates/UpdatesTest.kt @@ -0,0 +1,268 @@ +package app.tinyui.updates + +import app.tinyui.BuildManifest +import app.tinyui.Bundle +import app.tinyui.PageError +import app.tinyui.PageFailure +import kotlinx.coroutines.test.runTest +import okio.ByteString.Companion.encodeUtf8 +import okio.IOException +import okio.Path +import kotlin.random.Random +import kotlin.test.AfterTest +import kotlin.test.Test +import kotlin.test.assertContains +import kotlin.test.assertEquals +import kotlin.test.assertFailsWith +import kotlin.test.assertFalse +import kotlin.test.assertIs +import kotlin.test.assertSame +import kotlin.test.assertTrue + +class UpdatesTest { + // the real file system on both platforms (okio's fake one does not link on iOS with this Kotlin), a fresh directory per test + private val fs = platformFileSystem + private val root: Path = temporaryDirectory() / "tinyui-updates-test-${Random.nextLong().toULong()}" + private val dir: Path = root / "tinyui" + private val server = HashMap() + private val events = ArrayList() + private val fetch: suspend (String) -> ByteArray = { path -> server[path] ?: throw IOException("404 $path") } + + /** Anything but the literal "bad" verifies: the real algorithm has its own test. */ + private val lenient = SignatureVerifier { _, _, signature -> signature != "bad" } + + @AfterTest + fun cleanUp() { + fs.deleteRecursively(root) + } + + private fun manifest( + name: String = "shop", + version: String = "v1", + createdAt: String = "2026-09-22T10:00:00Z", + engine: String = Fixture.ENGINE, + protocol: Int = 1, + publicKey: String = Fixture.PUBLIC_KEY, + files: Map = mapOf("runtime/core.bin" to "CORE-$version", "runtime/native.bin" to "NATIVE-$version", "pages/home.bin" to "HOME-$version"), + runtimeVersion: String? = "1", + ): String { + val modules = mapOf("tinyui-core" to "runtime/core", "tinyui-native" to "runtime/native", "$name/home" to "pages/home") + val hashes = modules.entries.joinToString(",") { (m, p) -> "\"$m\":\"${files.getValue("$p.bin").encodeUtf8().sha256().hex()}\"" } + val rv = if (runtimeVersion == null) "" else ",\"runtimeVersion\":\"$runtimeVersion\"" + return """{"runtime":["tinyui-core","tinyui-native"],"pages":["$name/home"],"files":{${modules.entries.joinToString(",") { "\"${it.key}\":\"${it.value}\"" }}},"buildIds":{},"name":"$name","publicKey":"$publicKey","version":"$version","createdAt":"$createdAt","engine":"$engine","protocol":$protocol,"hashes":{$hashes}$rv}""" + } + + private fun embedded(name: String = "shop", version: String = "v0", createdAt: String = "2026-09-22T09:00:00Z"): Bundle { + val files = mapOf("runtime/core.bin" to "CORE-$version", "runtime/native.bin" to "NATIVE-$version", "pages/home.bin" to "HOME-$version") + val json = manifest(name, version, createdAt, files = files, runtimeVersion = null) + return Bundle(BuildManifest.parse(json)) { path -> files[path]?.encodeToByteArray() } + } + + /** Publishes [manifestJson] as `//` with its files and a pointer to it. */ + private fun publish(manifestJson: String, pkg: String = "shop", rv: String = "1", rollout: Int = 100, signature: String = "ok", pointerVersion: String? = null, files: Map? = null) { + val m = BuildManifest.parse(manifestJson) + val version = pointerVersion ?: m.version + server["$pkg/$rv/current.json"] = """{"version":"$version","rollout":$rollout,"signature":"$signature"}""".encodeToByteArray() + server["$pkg/$rv/$version/manifest.json"] = manifestJson.encodeToByteArray() + val content = files ?: mapOf("runtime/core.bin" to "CORE-${m.version}", "runtime/native.bin" to "NATIVE-${m.version}", "pages/home.bin" to "HOME-${m.version}") + for ((path, bytes) in content) server["$pkg/$rv/$version/$path"] = bytes.encodeToByteArray() + } + + private fun updates(vararg bundles: Bundle, installId: String = "install-1", rv: String = "1", verifier: SignatureVerifier = lenient, engine: String = Fixture.ENGINE) = + Updates(bundles.toList(), rv, dir, installId, fetch, { events += it }, fs, engine, 1, verifier) + + private suspend fun pageBytes(bundle: Bundle) = bundle.page("shop/home").module.bytecode.decodeToString() + + @Test + fun installsAValidUpdateAndRunsItFromTheNextStart() = runTest { + publish(manifest()) + val first = updates(embedded()) + assertEquals(listOf(UpdateEvent.Running("shop", "v0", Source.EMBEDDED)), events) + assertEquals(CheckResult.Installed("v1"), first.check("shop")) + assertEquals(UpdateEvent.Installed("shop", "v1"), events.last()) + assertEquals("HOME-v0", pageBytes(first.current("shop")), "the process keeps what it started with") + assertTrue(fs.exists(dir / "shop/installed/v1/manifest.json")) + assertTrue(fs.exists(dir / "shop/installed/v1/pages/home.bin")) + assertFalse(fs.exists(dir / "shop/staging")) + assertContains(fs.read(dir / "shop/state.json") { readUtf8() }, "\"installed\":\"v1\"") + + events.clear() + val second = updates(embedded()) + assertEquals(listOf(UpdateEvent.Running("shop", "v1", Source.INSTALLED)), events) + assertEquals("HOME-v1", pageBytes(second.current("shop"))) + assertEquals(CheckResult.UpToDate("v1"), second.check("shop")) + } + + @Test + fun aBrokenOrEscapingPointerFails() = runTest { + val u = updates(embedded()) + assertEquals(FailStage.POINTER, (u.check("shop") as CheckResult.Failed).stage, "404") + server["shop/1/current.json"] = "not json".encodeToByteArray() + assertEquals(FailStage.POINTER, (u.check("shop") as CheckResult.Failed).stage) + server["shop/1/current.json"] = """{"version":"v1","rollout":"all","signature":"ok"}""".encodeToByteArray() + assertEquals(FailStage.POINTER, (u.check("shop") as CheckResult.Failed).stage, "a rollout that is not an integer is not a default") + publish(manifest(), pointerVersion = "../escape") + val escaped = assertIs(u.check("shop")) + assertEquals(FailStage.POINTER, escaped.stage) + assertFalse(fs.exists(dir / "shop/staging")) + } + + @Test + fun aBadSignatureFailsBeforeAnythingElseIsLookedAt() = runTest { + publish(manifest(name = "other", engine = "x"), signature = "bad") + val same = assertIs(updates(embedded()).check("shop")) + assertEquals(FailStage.SIGNATURE, same.stage) + assertContains(same.message, "embedded publicKey") + publish(manifest(publicKey = Fixture.OTHER_PUBLIC_KEY), signature = "bad") + val rotated = assertIs(updates(embedded()).check("shop")) + assertContains(rotated.message, "another publicKey") + assertFalse(fs.exists(dir / "shop/installed")) + } + + @Test + fun aManifestForAnotherPackageHostOrEngineIsSkippedAsIncompatible() = runTest { + val u = updates(embedded()) + publish(manifest(name = "other")) + assertEquals(CheckResult.Skipped("v1", SkipReason.INCOMPATIBLE, setOf(Mismatch.NAME)), u.check("shop")) + publish(manifest(engine = "f".repeat(40), protocol = 2, runtimeVersion = "2")) + assertEquals(setOf(Mismatch.ENGINE, Mismatch.PROTOCOL, Mismatch.RUNTIME_VERSION), (u.check("shop") as CheckResult.Skipped).mismatch) + publish(manifest(version = "v9"), pointerVersion = "v1") + assertEquals(setOf(Mismatch.VERSION), (u.check("shop") as CheckResult.Skipped).mismatch) + publish(manifest(createdAt = "2026-09-22T09:00:00Z")) + assertEquals(CheckResult.Skipped("v1", SkipReason.OLDER_THAN_EMBEDDED), u.check("shop")) + } + + @Test + fun rolloutIsARepeatableDiceRollPerInstallPackageAndVersion() = runTest { + publish(manifest(), rollout = 0) + assertEquals(CheckResult.Skipped("v1", SkipReason.ROLLOUT), updates(embedded()).check("shop")) + publish(manifest(), rollout = 50) + val hits = (1..200).count { i -> + Updates(listOf(embedded()), "1", root / "roll-$i", "install-$i", fetch, {}, fs, Fixture.ENGINE, 1, lenient).check("shop") is CheckResult.Installed + } + assertTrue(hits in 70..130, "about half of 200 installs are in a 50% rollout, got $hits") + val once = Updates(listOf(embedded()), "1", root / "once", "install-7", fetch, {}, fs, Fixture.ENGINE, 1, lenient).check("shop") + val again = Updates(listOf(embedded()), "1", root / "again", "install-7", fetch, {}, fs, Fixture.ENGINE, 1, lenient).check("shop") + assertEquals(once::class, again::class, "the same install rolls the same") + } + + @Test + fun aFileThatDoesNotMatchItsHashOrCannotBeFetchedLeavesNothingBehind() = runTest { + publish(manifest(), files = mapOf("runtime/core.bin" to "CORE-v1", "runtime/native.bin" to "NATIVE-v1", "pages/home.bin" to "tampered")) + val integrity = assertIs(updates(embedded()).check("shop")) + assertEquals(FailStage.INTEGRITY, integrity.stage) + assertContains(integrity.message, "pages/home.bin") + server.remove("shop/1/v1/runtime/native.bin") + publish(manifest(), files = mapOf("runtime/core.bin" to "CORE-v1", "pages/home.bin" to "HOME-v1")) + val download = assertIs(updates(embedded()).check("shop")) + assertEquals(FailStage.DOWNLOAD, download.stage) + assertFalse(fs.exists(dir / "shop/staging")) + assertFalse(fs.exists(dir / "shop/installed")) + } + + @Test + fun packagesAreCheckedIndependently() = runTest { + publish(manifest()) + publish(manifest(name = "orders"), pkg = "orders", signature = "bad") + val results = updates(embedded(), embedded(name = "orders")).check() + assertEquals(CheckResult.Installed("v1"), results["shop"]) + assertEquals(FailStage.SIGNATURE, (results["orders"] as CheckResult.Failed).stage) + assertTrue(fs.exists(dir / "shop/installed/v1/manifest.json")) + assertFalse(fs.exists(dir / "orders/installed")) + } + + @Test + fun startupDropsWhatNoLongerApplies() = runTest { + publish(manifest()) + updates(embedded()).check("shop") + fs.createDirectories(dir / "shop/staging/v2") + fs.createDirectories(dir / "gone/installed/v1") + fs.write(dir / "gone/state.json") { writeUtf8("{}") } + + // the App now embeds something newer than what was installed + events.clear() + val upgraded = updates(embedded(version = "v5", createdAt = "2026-09-23T00:00:00Z")) + assertEquals("HOME-v5", pageBytes(upgraded.current("shop"))) + assertEquals(Source.EMBEDDED, (events.single() as UpdateEvent.Running).source) + assertFalse(fs.exists(dir / "shop/installed/v1")) + assertFalse(fs.exists(dir / "shop/staging")) + assertFalse(fs.exists(dir / "gone"), "a package the App no longer embeds") + + // installed for another runtime version + publish(manifest()) + updates(embedded()).check("shop") + assertTrue(fs.exists(dir / "shop/installed/v1")) + val other = updates(embedded(), rv = "2") + assertEquals("HOME-v0", pageBytes(other.current("shop"))) + assertFalse(fs.exists(dir / "shop/installed/v1")) + } + + @Test + fun startupChecksTheInstalledBytesAndBlacklistsACorruptedPackage() = runTest { + publish(manifest()) + updates(embedded()).check("shop") + fs.write(dir / "shop/installed/v1/pages/home.bin") { writeUtf8("HOME-v1 ") } + events.clear() + val u = updates(embedded()) + assertEquals("HOME-v0", pageBytes(u.current("shop"))) + val failed = assertIs(events.first()) + assertEquals(FailStage.INTEGRITY, failed.stage) + assertEquals(UpdateEvent.Running("shop", "v0", Source.EMBEDDED), events.last()) + assertFalse(fs.exists(dir / "shop/installed/v1")) + assertEquals(CheckResult.Skipped("v1", SkipReason.FAILED_BEFORE), u.check("shop")) + assertContains(fs.read(dir / "shop/state.json") { readUtf8() }, "\"failed\":[\"v1\"]") + } + + @Test + fun aFailedPageRollsThePackageBackToEmbedded() = runTest { + publish(manifest()) + updates(embedded()).check("shop") + events.clear() + val u = updates(embedded()) + assertEquals("HOME-v1", pageBytes(u.current("shop"))) + u.rollBack("shop", u.current("shop"), "shop/home", PageFailure(PageError("E2", "shop/home", "cccccccc", "boom"))) + assertSame(u.embedded("shop"), u.current("shop")) + assertEquals(UpdateEvent.RolledBack("shop", "v1", "shop/home", "E2", "cccccccc", "boom"), events.last()) + assertFalse(fs.exists(dir / "shop/installed/v1")) + assertEquals(CheckResult.Skipped("v1", SkipReason.FAILED_BEFORE), u.check("shop")) + assertEquals("HOME-v0", pageBytes(updates(embedded()).current("shop")), "and stays embedded after a restart") + } + + @Test + fun aRollbackOnlyDropsTheVersionThatFailedNotOneInstalledMeanwhile() = runTest { + publish(manifest()) + updates(embedded()).check("shop") + val u = updates(embedded()) + val running = u.current("shop") + assertEquals("v1", running.manifest.version) + publish(manifest(version = "v2", createdAt = "2026-09-22T11:00:00Z")) + assertEquals(CheckResult.Installed("v2"), u.check("shop")) + u.rollBack("shop", running, "shop/home", PageFailure(PageError("E6", "shop/home", "cccccccc", "boom"))) + assertEquals("v1", (events.last() as UpdateEvent.RolledBack).version) + assertSame(u.embedded("shop"), u.current("shop"), "no more pages from the failed bundle in this process") + assertTrue(fs.exists(dir / "shop/installed/v2/manifest.json"), "the newer install stays") + assertContains(fs.read(dir / "shop/state.json") { readUtf8() }, "\"installed\":\"v2\"") + assertEquals("HOME-v2", pageBytes(updates(embedded()).current("shop")), "and runs from the next start") + } + + @Test + fun refusesPackagesThatCannotRunOnThisHost() { + assertFailsWith { updates(embedded(), embedded()) } + assertFailsWith { updates(embedded(), engine = "0".repeat(40)) } + assertFailsWith { updates(embedded(), rv = "../x") } + assertFailsWith { updates(embedded()).current("nowhere") } + } + + @Test + fun installsAPackageTinyuiBundleSignedWithTheRealVerifier() = runTest { + server["shop/1/current.json"] = """{"version":"${Fixture.VERSION}","rollout":100,"signature":"${Fixture.SIGNATURE}"}""".encodeToByteArray() + server["shop/1/${Fixture.VERSION}/manifest.json"] = Fixture.MANIFEST.encodeToByteArray() + for ((path, bytes) in Fixture.FILES) server["shop/1/${Fixture.VERSION}/$path"] = bytes.encodeToByteArray() + val u = updates(embedded(), verifier = PlatformSignatureVerifier) + assertEquals(CheckResult.Installed(Fixture.VERSION), u.check("shop")) + assertEquals("HOME2", pageBytes(updates(embedded(), verifier = PlatformSignatureVerifier).current("shop"))) + server["shop/1/${Fixture.VERSION}/manifest.json"] = Fixture.MANIFEST.replace("\"protocol\": 1", "\"protocol\": 2").encodeToByteArray() + fs.deleteRecursively(dir) + assertEquals(FailStage.SIGNATURE, (updates(embedded(), verifier = PlatformSignatureVerifier).check("shop") as CheckResult.Failed).stage) + } +} diff --git a/updates/src/iosMain/kotlin/app/tinyui/updates/FileSystem.ios.kt b/updates/src/iosMain/kotlin/app/tinyui/updates/FileSystem.ios.kt new file mode 100644 index 0000000..811b7be --- /dev/null +++ b/updates/src/iosMain/kotlin/app/tinyui/updates/FileSystem.ios.kt @@ -0,0 +1,5 @@ +package app.tinyui.updates + +import okio.FileSystem + +internal actual val platformFileSystem: FileSystem = FileSystem.SYSTEM diff --git a/updates/src/iosMain/kotlin/app/tinyui/updates/Signature.ios.kt b/updates/src/iosMain/kotlin/app/tinyui/updates/Signature.ios.kt new file mode 100644 index 0000000..e594d35 --- /dev/null +++ b/updates/src/iosMain/kotlin/app/tinyui/updates/Signature.ios.kt @@ -0,0 +1,45 @@ +package app.tinyui.updates + +import kotlinx.cinterop.ExperimentalForeignApi +import kotlinx.cinterop.addressOf +import kotlinx.cinterop.ptr +import kotlinx.cinterop.reinterpret +import kotlinx.cinterop.usePinned +import platform.CoreFoundation.CFDataCreate +import platform.CoreFoundation.CFDataRef +import platform.CoreFoundation.CFDictionaryAddValue +import platform.CoreFoundation.CFDictionaryCreateMutable +import platform.CoreFoundation.CFRelease +import platform.CoreFoundation.kCFTypeDictionaryKeyCallBacks +import platform.CoreFoundation.kCFTypeDictionaryValueCallBacks +import platform.Security.SecKeyCreateWithData +import platform.Security.SecKeyVerifySignature +import platform.Security.kSecAttrKeyClass +import platform.Security.kSecAttrKeyClassPublic +import platform.Security.kSecAttrKeyType +import platform.Security.kSecAttrKeyTypeECSECPrimeRandom +import platform.Security.kSecKeyAlgorithmECDSASignatureMessageX962SHA256 + +@OptIn(ExperimentalForeignApi::class) +internal actual fun verifyEcdsaP256(publicKey: ByteArray, data: ByteArray, signature: ByteArray): Boolean { + if (data.isEmpty()) return false + val keyData = publicKey.toCFData() ?: return false + val attributes = CFDictionaryCreateMutable(null, 2, kCFTypeDictionaryKeyCallBacks.ptr, kCFTypeDictionaryValueCallBacks.ptr) + CFDictionaryAddValue(attributes, kSecAttrKeyType, kSecAttrKeyTypeECSECPrimeRandom) + CFDictionaryAddValue(attributes, kSecAttrKeyClass, kSecAttrKeyClassPublic) + val key = SecKeyCreateWithData(keyData, attributes, null) + CFRelease(attributes) + CFRelease(keyData) + if (key == null) return false + val message = data.toCFData() + val sig = signature.toCFData() + val ok = SecKeyVerifySignature(key, kSecKeyAlgorithmECDSASignatureMessageX962SHA256, message, sig, null) + if (message != null) CFRelease(message) + if (sig != null) CFRelease(sig) + CFRelease(key) + return ok +} + +@OptIn(ExperimentalForeignApi::class) +private fun ByteArray.toCFData(): CFDataRef? = + if (isEmpty()) null else usePinned { CFDataCreate(null, it.addressOf(0).reinterpret(), size.toLong()) } diff --git a/updates/src/iosTest/kotlin/app/tinyui/updates/TestPaths.ios.kt b/updates/src/iosTest/kotlin/app/tinyui/updates/TestPaths.ios.kt new file mode 100644 index 0000000..6fbf4ec --- /dev/null +++ b/updates/src/iosTest/kotlin/app/tinyui/updates/TestPaths.ios.kt @@ -0,0 +1,6 @@ +package app.tinyui.updates + +import okio.FileSystem +import okio.Path + +internal actual fun temporaryDirectory(): Path = FileSystem.SYSTEM_TEMPORARY_DIRECTORY