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

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion .github/workflows/publish.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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 }}
Expand Down
5 changes: 3 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
```
Expand All @@ -24,7 +25,7 @@ build-logic/ Gradle convention plugins

## 安装

- Kotlin:`implementation("app.tinyui:tinyui:<version>")`(Maven Central;自带 `wang.harlon:quickjs-kmp`)
- Kotlin:`implementation("app.tinyui:tinyui:<version>")`(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)。
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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() {
Expand Down
6 changes: 4 additions & 2 deletions docs/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 壳
Expand All @@ -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/` 不冲突。

## 实现期文档

Expand Down
2 changes: 1 addition & 1 deletion docs/updates-plan.md
Original file line number Diff line number Diff line change
Expand Up @@ -98,7 +98,7 @@ M1 之后 M2 → M3 与 M4 并行;M6 要等 M3 与 M5。
|---|---|
| M1 CLI 产包与签名(含配置文件与模块名改名) | 已完成(PR #13,2026-09-22):`tinyui.config.json`、模块名 `<pkg>/…`、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 接入 | 待开 |
Expand Down
4 changes: 2 additions & 2 deletions docs/updates.md
Original file line number Diff line number Diff line change
Expand Up @@ -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<String, CheckResult>` | 全部包各跑一遍 §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`**。宿主的分析口径会依赖它,所以发布后字段同样只增不改:

Expand Down
2 changes: 2 additions & 0 deletions gradle/libs.versions.toml
Original file line number Diff line number Diff line change
Expand Up @@ -11,13 +11,15 @@ 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" }
kotlinx-coroutines-core = { module = "org.jetbrains.kotlinx:kotlinx-coroutines-core", version.ref = "kotlinxCoroutines" }
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" }
Expand Down
13 changes: 13 additions & 0 deletions pnpm-lock.yaml

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

1 change: 1 addition & 0 deletions pnpm-workspace.yaml
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
packages:
- packages/*
- sample/js
- sample/js-extra
- schema

onlyBuiltDependencies:
Expand Down
1 change: 1 addition & 0 deletions sample/androidApp/build.gradle.kts
Original file line number Diff line number Diff line change
Expand Up @@ -22,4 +22,5 @@ android {
dependencies {
implementation(project(":sample:shared"))
implementation(libs.androidx.activity.compose)
implementation(libs.okio)
}
3 changes: 3 additions & 0 deletions sample/androidApp/src/main/AndroidManifest.xml
Original file line number Diff line number Diff line change
@@ -1,8 +1,11 @@
<?xml version="1.0" encoding="utf-8"?>
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
<!-- the sample pulls hot updates from the development machine (docs/updates.md) -->
<uses-permission android:name="android.permission.INTERNET" />
<application
android:label="@string/app_name"
android:supportsRtl="true"
android:usesCleartextTraffic="true"
android:theme="@android:style/Theme.Material.Light.NoActionBar">
<activity
android:name=".MainActivity"
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -4,11 +4,12 @@ import android.os.Bundle
import androidx.activity.ComponentActivity
import androidx.activity.compose.setContent
import androidx.activity.enableEdgeToEdge
import okio.Path.Companion.toOkioPath

class MainActivity : ComponentActivity() {
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
enableEdgeToEdge()
setContent { App() }
setContent { App(updatesDir = filesDir.toOkioPath() / "tinyui") }
}
}
5 changes: 5 additions & 0 deletions sample/iosApp/iosApp/Info.plist
Original file line number Diff line number Diff line change
Expand Up @@ -4,5 +4,10 @@
<dict>
<key>CADisableMinimumFrameDurationOnPhone</key>
<true/>
<key>NSAppTransportSecurity</key>
<dict>
<key>NSAllowsLocalNetworking</key>
<true/>
</dict>
</dict>
</plist>
5 changes: 5 additions & 0 deletions sample/js-extra/keys/demo-signing-key.pem
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
-----BEGIN PRIVATE KEY-----
MIGHAgEAMBMGByqGSM49AgEGCCqGSM49AwEHBG0wawIBAQQgUXTohh2O/Y1TwH+z
QU2EqFd+uEx+yy4oRQbgWkt8nYShRANCAAQa/5vaIPOPDNBH3FMdNv4c14fQyxmC
EOlVMUeKbl7kO01YseBegenZ6LD1l9ci/7NVjcxraYXL2Yu81ov2G2IT
-----END PRIVATE KEY-----
Comment thread
sourcery-ai[bot] marked this conversation as resolved.
15 changes: 15 additions & 0 deletions sample/js-extra/package.json
Original file line number Diff line number Diff line change
@@ -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:*"
}
}
11 changes: 11 additions & 0 deletions sample/js-extra/src/pages/hello.tsx
Original file line number Diff line number Diff line change
@@ -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 (
<Column padding={16}>
<Text text="Hello from sample-extra" fontSize={24} />
<Text text={`page ${import.meta.url}`} color="#888888" />
</Column>
);
}
4 changes: 4 additions & 0 deletions sample/js-extra/tinyui.config.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,4 @@
{
"name": "sample-extra",
"publicKey": "BBr/m9og848M0EfcUx02/hzXh9DLGYIQ6VUxR4puXuQ7TVix4F6B6dnosPWX1yL/s1WNzGtphcvZi7zWi/YbYhM="
}
9 changes: 9 additions & 0 deletions sample/js-extra/tsconfig.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
{
"extends": "../../tsconfig.base.json",
"compilerOptions": {
"noEmit": true,
"jsx": "react-jsx",
"jsxImportSource": "tinyui-core"
},
"include": ["src"]
}
50 changes: 32 additions & 18 deletions sample/shared/build.gradle.kts
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand All @@ -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<String> = providers.gradleProperty("tinyui.qjsc")
Expand All @@ -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<Exec>(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/<pkg>/
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)
Expand Down
Original file line number Diff line number Diff line change
@@ -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()
}
}
Loading
Loading