From 018f9e97672d5c4453aceab90bf096e2a3141cf1 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E8=BD=A9=E8=BE=95=E5=8D=81=E5=9B=9B?= Date: Mon, 20 Jul 2026 18:46:05 +0800 Subject: [PATCH 1/7] docs: add Simplified Chinese README --- README.md | 2 ++ README.zh-CN.md | 59 +++++++++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 61 insertions(+) create mode 100644 README.zh-CN.md diff --git a/README.md b/README.md index 4cae929..361ca97 100644 --- a/README.md +++ b/README.md @@ -1,5 +1,7 @@ # Astrolabe Protocol +English | [简体中文](README.zh-CN.md) + Astrolabe Protocol defines the platform-neutral wire contract shared by the Astrolabe Host and platform Runtime SDKs. diff --git a/README.zh-CN.md b/README.zh-CN.md new file mode 100644 index 0000000..b7ef892 --- /dev/null +++ b/README.zh-CN.md @@ -0,0 +1,59 @@ +# Astrolabe Protocol + +[English](README.md) | 简体中文 + +Astrolabe Protocol 定义 Astrolabe Host 与各平台 Runtime SDK 共享的平台无关 Wire Contract。 + +## 内容 + +- `AstrolabeProtocol` Product 中的 Swift DTO 和强类型协议模型。 +- 请求与响应封装、错误、版本协商和帧编解码器。 +- `Schemas/` 下带版本的 JSON Schema。 +- `Fixtures/` 下用于跨语言验证的有效和无效示例。 +- [PROTOCOL-2.0.md](PROTOCOL-2.0.md) 中的 Wire Protocol 2.0 规范。 +- [PROTOCOL.md](PROTOCOL.md) 中归档的 Wire Protocol 1.0 文档。 + +UIKit、Android View、Transport Listener、设备发现、截图、CLI 命令和 MCP Tools 均不属于本仓库。 + +## 安装 + +通过 Swift Package Manager 添加 Package: + +```swift +.package( + url: "https://github.com/regulusleow/astrolabe-protocol.git", + exact: "1.0.0" +) +``` + +依赖 `AstrolabeProtocol` Product,并通过以下方式导入: + +```swift +import AstrolabeProtocol +``` + +## Wire 格式 + +每条消息由一个使用网络字节序的四字节无符号 Payload 长度和随后的 UTF-8 JSON 对象组成。当前 +Wire Protocol 版本为 `2.0`。 + +Swift 类型只是该协议的一种实现。其他语言的实现应以规范、Schema、Fixture 和文档约定的 Wire +行为作为兼容性事实源。 + +## 开发 + +安装协议校验器并运行全部检查: + +```bash +npm ci +npm test +swift test --parallel +swift build -c release +``` + +校验流程会编译全部 Draft 2020-12 Schema,检查有效和无效 Fixture,执行 JSON Schema 无法表达的 +语义规则,并验证 Swift DTO 对相同 Payload 的接受和拒绝行为保持一致。 + +## 许可证 + +Astrolabe Protocol 使用 [Apache License 2.0](LICENSE) 许可。 From a328945082078f36f46902c96f2626f86f453b27 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E8=BD=A9=E8=BE=95=E5=8D=81=E5=9B=9B?= Date: Mon, 20 Jul 2026 20:10:08 +0800 Subject: [PATCH 2/7] feat: add Kotlin protocol foundation --- .github/workflows/ci.yml | 24 ++ .gitignore | 2 + AstrolabeProtocolKotlin/build.gradle.kts | 46 +++ .../protocol/core/RuntimeGeometry.kt | 86 +++++ .../protocol/core/RuntimeIdentifier.kt | 61 ++++ .../protocol/framing/RuntimeFrameCodec.kt | 124 +++++++ .../protocol/inspection/RuntimeApplication.kt | 104 ++++++ .../protocol/messaging/RuntimeCancellation.kt | 53 +++ .../messaging/RuntimeJsonDocumentValidator.kt | 308 ++++++++++++++++++ .../protocol/messaging/RuntimeMessage.kt | 142 ++++++++ .../protocol/messaging/RuntimeMessageCodec.kt | 220 +++++++++++++ .../protocol/negotiation/RuntimeCapability.kt | 41 +++ .../protocol/negotiation/RuntimeHandshake.kt | 74 +++++ .../protocol/negotiation/RuntimeMethod.kt | 58 ++++ .../negotiation/RuntimeProtocolVersion.kt | 70 ++++ .../protocol/framing/RuntimeFrameCodecTest.kt | 67 ++++ .../inspection/RuntimeApplicationModelTest.kt | 43 +++ .../messaging/RuntimeCancellationModelTest.kt | 31 ++ .../messaging/RuntimeMessageCodecTest.kt | 119 +++++++ .../negotiation/RuntimeSessionModelTest.kt | 100 ++++++ build.gradle.kts | 4 + gradle.properties | 4 + gradle/libs.versions.toml | 10 + gradle/wrapper/gradle-wrapper.jar | Bin 0 -> 45457 bytes gradle/wrapper/gradle-wrapper.properties | 9 + gradlew | 251 ++++++++++++++ scripts/release-prepare.mjs | 1 + scripts/versioning.mjs | 15 + settings.gradle.kts | 16 + test/release-prepare.test.mjs | 10 + 30 files changed, 2093 insertions(+) create mode 100644 AstrolabeProtocolKotlin/build.gradle.kts create mode 100644 AstrolabeProtocolKotlin/src/main/kotlin/dev/astrolabe/protocol/core/RuntimeGeometry.kt create mode 100644 AstrolabeProtocolKotlin/src/main/kotlin/dev/astrolabe/protocol/core/RuntimeIdentifier.kt create mode 100644 AstrolabeProtocolKotlin/src/main/kotlin/dev/astrolabe/protocol/framing/RuntimeFrameCodec.kt create mode 100644 AstrolabeProtocolKotlin/src/main/kotlin/dev/astrolabe/protocol/inspection/RuntimeApplication.kt create mode 100644 AstrolabeProtocolKotlin/src/main/kotlin/dev/astrolabe/protocol/messaging/RuntimeCancellation.kt create mode 100644 AstrolabeProtocolKotlin/src/main/kotlin/dev/astrolabe/protocol/messaging/RuntimeJsonDocumentValidator.kt create mode 100644 AstrolabeProtocolKotlin/src/main/kotlin/dev/astrolabe/protocol/messaging/RuntimeMessage.kt create mode 100644 AstrolabeProtocolKotlin/src/main/kotlin/dev/astrolabe/protocol/messaging/RuntimeMessageCodec.kt create mode 100644 AstrolabeProtocolKotlin/src/main/kotlin/dev/astrolabe/protocol/negotiation/RuntimeCapability.kt create mode 100644 AstrolabeProtocolKotlin/src/main/kotlin/dev/astrolabe/protocol/negotiation/RuntimeHandshake.kt create mode 100644 AstrolabeProtocolKotlin/src/main/kotlin/dev/astrolabe/protocol/negotiation/RuntimeMethod.kt create mode 100644 AstrolabeProtocolKotlin/src/main/kotlin/dev/astrolabe/protocol/negotiation/RuntimeProtocolVersion.kt create mode 100644 AstrolabeProtocolKotlin/src/test/kotlin/dev/astrolabe/protocol/framing/RuntimeFrameCodecTest.kt create mode 100644 AstrolabeProtocolKotlin/src/test/kotlin/dev/astrolabe/protocol/inspection/RuntimeApplicationModelTest.kt create mode 100644 AstrolabeProtocolKotlin/src/test/kotlin/dev/astrolabe/protocol/messaging/RuntimeCancellationModelTest.kt create mode 100644 AstrolabeProtocolKotlin/src/test/kotlin/dev/astrolabe/protocol/messaging/RuntimeMessageCodecTest.kt create mode 100644 AstrolabeProtocolKotlin/src/test/kotlin/dev/astrolabe/protocol/negotiation/RuntimeSessionModelTest.kt create mode 100644 build.gradle.kts create mode 100644 gradle.properties create mode 100644 gradle/libs.versions.toml create mode 100644 gradle/wrapper/gradle-wrapper.jar create mode 100644 gradle/wrapper/gradle-wrapper.properties create mode 100755 gradlew create mode 100644 settings.gradle.kts diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 3c24504..7335d89 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -55,3 +55,27 @@ jobs: - name: Build release product run: swift build -c release + + kotlin: + name: Kotlin tests and publication build + runs-on: ubuntu-24.04 + timeout-minutes: 15 + + steps: + - name: Check out repository + uses: actions/checkout@v7 + + - name: Set up Java + uses: actions/setup-java@v5 + with: + distribution: temurin + java-version: "17" + + - name: Set up Gradle + uses: gradle/actions/setup-gradle@v5 + + - name: Run Kotlin tests + run: ./gradlew :AstrolabeProtocolKotlin:test + + - name: Build Kotlin publication + run: ./gradlew :AstrolabeProtocolKotlin:publishToMavenLocal diff --git a/.gitignore b/.gitignore index 305f0df..4e4c1bd 100644 --- a/.gitignore +++ b/.gitignore @@ -1,8 +1,10 @@ .build/ +.gradle/ .codegraph/ .omo/ .swiftpm/ DerivedData/ node_modules/ +**/build/ *.xcuserstate .DS_Store diff --git a/AstrolabeProtocolKotlin/build.gradle.kts b/AstrolabeProtocolKotlin/build.gradle.kts new file mode 100644 index 0000000..123ad8b --- /dev/null +++ b/AstrolabeProtocolKotlin/build.gradle.kts @@ -0,0 +1,46 @@ +import org.jetbrains.kotlin.gradle.dsl.JvmTarget + +plugins { + alias(libs.plugins.kotlin.jvm) + alias(libs.plugins.kotlin.serialization) + `maven-publish` +} + +group = "dev.astrolabe" +version = providers.gradleProperty("astrolabeVersion").get() + +java { + sourceCompatibility = JavaVersion.VERSION_11 + targetCompatibility = JavaVersion.VERSION_11 +} + +kotlin { + jvmToolchain(17) + compilerOptions { + jvmTarget.set(JvmTarget.JVM_11) + } +} + +dependencies { + api(libs.kotlinx.serialization.json) + testImplementation(kotlin("test")) +} + +sourceSets { + test { + resources.srcDir(rootProject.layout.projectDirectory.dir("Fixtures")) + } +} + +tasks.test { + useJUnitPlatform() +} + +publishing { + publications { + create("kotlin") { + artifactId = "astrolabe-protocol-kotlin" + from(components["java"]) + } + } +} diff --git a/AstrolabeProtocolKotlin/src/main/kotlin/dev/astrolabe/protocol/core/RuntimeGeometry.kt b/AstrolabeProtocolKotlin/src/main/kotlin/dev/astrolabe/protocol/core/RuntimeGeometry.kt new file mode 100644 index 0000000..e2f4479 --- /dev/null +++ b/AstrolabeProtocolKotlin/src/main/kotlin/dev/astrolabe/protocol/core/RuntimeGeometry.kt @@ -0,0 +1,86 @@ +// +// RuntimeGeometry.kt +// astrolabe-protocol +// +// Created by 轩辕十四 on 2026/7/20. +// + +package dev.astrolabe.protocol + +import kotlinx.serialization.Serializable + +/** Unit used by runtime measurements. */ +@Serializable +public enum class RuntimeMeasurementUnit { + logical, + scaledLogical, + pixel +} + +/** Coordinate space containing a runtime geometry value. */ +@Serializable +public enum class RuntimeCoordinateSpace { + local, + parent, + screen, + viewport +} + +/** Scalar measurement with an explicit unit. */ +@Serializable +public data class RuntimeMeasurement( + /** Numeric magnitude. */ + public val value: Double, + /** Unit used by the magnitude. */ + public val unit: RuntimeMeasurementUnit +) + +/** Point in an explicitly declared coordinate space. */ +@Serializable +public data class RuntimeCoordinatePoint( + /** Horizontal coordinate. */ + public val x: Double, + /** Vertical coordinate. */ + public val y: Double, + /** Coordinate space containing the point. */ + public val coordinateSpace: RuntimeCoordinateSpace, + /** Unit used by both coordinates. */ + public val unit: RuntimeMeasurementUnit +) + +/** Two-dimensional measured extent. */ +@Serializable +public data class RuntimeMeasuredSize( + /** Non-negative horizontal extent. */ + public val width: Double, + /** Non-negative vertical extent. */ + public val height: Double, + /** Unit used by both extents. */ + public val unit: RuntimeMeasurementUnit +) { + init { + require(width >= 0.0 && height >= 0.0) { "Measured size dimensions cannot be negative" } + } +} + +/** Per-axis logical-to-pixel conversion. */ +@Serializable +public data class RuntimeScale( + /** Horizontal scale. */ + public val x: Double, + /** Vertical scale. */ + public val y: Double +) + +/** Display facts required for coordinate conversion. */ +@Serializable +public data class RuntimeDisplayInfo( + /** Display dimensions in logical layout units. */ + public val logicalSize: RuntimeMeasuredSize, + /** Display dimensions in physical pixels. */ + public val pixelSize: RuntimeMeasuredSize, + /** Per-axis conversion from logical units to pixels. */ + public val logicalToPixelScale: RuntimeScale, + /** Maximum refresh rate when reported by the platform. */ + public val maximumRefreshRate: Double? +) diff --git a/AstrolabeProtocolKotlin/src/main/kotlin/dev/astrolabe/protocol/core/RuntimeIdentifier.kt b/AstrolabeProtocolKotlin/src/main/kotlin/dev/astrolabe/protocol/core/RuntimeIdentifier.kt new file mode 100644 index 0000000..136c11a --- /dev/null +++ b/AstrolabeProtocolKotlin/src/main/kotlin/dev/astrolabe/protocol/core/RuntimeIdentifier.kt @@ -0,0 +1,61 @@ +// +// RuntimeIdentifier.kt +// astrolabe-protocol +// +// Created by 轩辕十四 on 2026/7/20. +// + +package dev.astrolabe.protocol + +import kotlinx.serialization.Serializable +import kotlinx.serialization.json.JsonElement + +/** Non-empty wire identifier whose internal format is owned by its producer. */ +@JvmInline +@Serializable +public value class RuntimeOpaqueIdentifier( + /** Raw identifier value. */ + public val rawValue: String +) { + init { + require(rawValue.isNotEmpty() && rawValue.length <= MAXIMUM_IDENTIFIER_LENGTH) { + "Opaque identifier must contain between 1 and 256 characters" + } + } +} + +/** Dot-separated identifier that declares its owning namespace. */ +@JvmInline +@Serializable +public value class RuntimeNamespacedIdentifier( + /** Raw namespaced identifier value. */ + public val rawValue: String +) { + init { + require(isValid(rawValue)) { "Namespaced identifier has an invalid format" } + } + + public companion object { + /** Returns whether a value satisfies the shared namespaced identifier contract. */ + public fun isValid(value: String): Boolean = + value.length <= MAXIMUM_IDENTIFIER_LENGTH && NAMESPACED_IDENTIFIER_PATTERN.matches(value) + } +} + +/** Namespaced extension members preserved without platform interpretation. */ +@JvmInline +@Serializable +public value class RuntimeExtensionMap( + /** String-keyed extension values. */ + public val values: Map +) { + init { + require(values.keys.all(RuntimeNamespacedIdentifier::isValid)) { + "Runtime extension keys must be namespaced" + } + } +} + +private const val MAXIMUM_IDENTIFIER_LENGTH: Int = 256 +private val NAMESPACED_IDENTIFIER_PATTERN: Regex = + Regex("^[a-z][a-z0-9-]*(\\.[A-Za-z][A-Za-z0-9_-]*)+$") diff --git a/AstrolabeProtocolKotlin/src/main/kotlin/dev/astrolabe/protocol/framing/RuntimeFrameCodec.kt b/AstrolabeProtocolKotlin/src/main/kotlin/dev/astrolabe/protocol/framing/RuntimeFrameCodec.kt new file mode 100644 index 0000000..169f741 --- /dev/null +++ b/AstrolabeProtocolKotlin/src/main/kotlin/dev/astrolabe/protocol/framing/RuntimeFrameCodec.kt @@ -0,0 +1,124 @@ +// +// RuntimeFrameCodec.kt +// astrolabe-protocol +// +// Created by 轩辕十四 on 2026/7/20. +// + +package dev.astrolabe.protocol + +/** Encodes and decodes length-prefixed Astrolabe wire frames. */ +public class RuntimeFrameCodec( + /** Maximum number of payload bytes accepted in one frame. */ + public val maximumPayloadSize: Int = DEFAULT_MAXIMUM_PAYLOAD_SIZE +) { + init { + require(maximumPayloadSize > 0) { + "maximumPayloadSize must be greater than zero" + } + } + + /** Creates one network-byte-order frame for [payload]. */ + public fun encode(payload: ByteArray): ByteArray { + if (payload.isEmpty()) { + throw RuntimeFrameException.EmptyPayload + } + if (payload.size > maximumPayloadSize) { + throw RuntimeFrameException.PayloadTooLarge( + actual = payload.size.toLong(), + maximum = maximumPayloadSize + ) + } + + val length = payload.size + return ByteArray(FRAME_HEADER_SIZE + length).also { frame -> + frame[0] = (length ushr 24).toByte() + frame[1] = (length ushr 16).toByte() + frame[2] = (length ushr 8).toByte() + frame[3] = length.toByte() + payload.copyInto(frame, destinationOffset = FRAME_HEADER_SIZE) + } + } + + /** Creates a stateful decoder for fragmented byte streams. */ + public fun makeStreamDecoder(): RuntimeFrameStreamDecoder = + RuntimeFrameStreamDecoder(maximumPayloadSize) + + public companion object { + /** Default upper bound for one framed JSON payload. */ + public const val DEFAULT_MAXIMUM_PAYLOAD_SIZE: Int = 16 * 1024 * 1024 + + internal const val FRAME_HEADER_SIZE: Int = 4 + } +} + +/** Decodes zero or more complete frames from fragmented input. */ +public class RuntimeFrameStreamDecoder internal constructor( + private val maximumPayloadSize: Int +) { + private var buffer: ByteArray = byteArrayOf() + + /** Number of bytes retained while waiting for a complete frame. */ + public val pendingByteCount: Int + get() = buffer.size + + /** Appends bytes and returns every complete payload now available. */ + public fun append(data: ByteArray): List { + if (data.isNotEmpty()) { + buffer += data + } + + val payloads = mutableListOf() + var offset = 0 + while (buffer.size - offset >= RuntimeFrameCodec.FRAME_HEADER_SIZE) { + val payloadLength = readPayloadLength(offset) + if (payloadLength == 0L) { + throw RuntimeFrameException.EmptyPayload + } + if (payloadLength > maximumPayloadSize.toLong()) { + throw RuntimeFrameException.PayloadTooLarge( + actual = payloadLength, + maximum = maximumPayloadSize + ) + } + + val frameLength = RuntimeFrameCodec.FRAME_HEADER_SIZE + payloadLength.toInt() + if (buffer.size - offset < frameLength) { + break + } + + val payloadStart = offset + RuntimeFrameCodec.FRAME_HEADER_SIZE + payloads += buffer.copyOfRange(payloadStart, offset + frameLength) + offset += frameLength + } + + if (offset > 0) { + buffer = buffer.copyOfRange(offset, buffer.size) + } + return payloads + } + + /** Discards any incomplete frame bytes. */ + public fun reset() { + buffer = byteArrayOf() + } + + private fun readPayloadLength(offset: Int): Long = + (0 until RuntimeFrameCodec.FRAME_HEADER_SIZE).fold(0L) { length, index -> + (length shl 8) or (buffer[offset + index].toLong() and 0xFF) + } +} + +/** Failures produced while reading or writing a wire frame. */ +public sealed class RuntimeFrameException(message: String) : Exception(message) { + /** The protocol does not permit an empty frame payload. */ + public data object EmptyPayload : RuntimeFrameException("Frame payload cannot be empty") + + /** A declared or encoded payload exceeds the configured limit. */ + public class PayloadTooLarge( + /** Number of payload bytes declared or supplied. */ + public val actual: Long, + /** Maximum number of payload bytes accepted. */ + public val maximum: Int + ) : RuntimeFrameException("Frame payload size $actual exceeds maximum $maximum") +} diff --git a/AstrolabeProtocolKotlin/src/main/kotlin/dev/astrolabe/protocol/inspection/RuntimeApplication.kt b/AstrolabeProtocolKotlin/src/main/kotlin/dev/astrolabe/protocol/inspection/RuntimeApplication.kt new file mode 100644 index 0000000..49cd5cd --- /dev/null +++ b/AstrolabeProtocolKotlin/src/main/kotlin/dev/astrolabe/protocol/inspection/RuntimeApplication.kt @@ -0,0 +1,104 @@ +// +// RuntimeApplication.kt +// astrolabe-protocol +// +// Created by 轩辕十四 on 2026/7/20. +// + +package dev.astrolabe.protocol + +import kotlinx.serialization.Serializable + +/** Platform-neutral application identity. */ +@Serializable +public data class RuntimeApplication( + /** Platform-neutral application identifier. */ + public val identifier: String, + /** User-visible application name. */ + public val displayName: String, + /** Application release version when available. */ + public val version: String?, + /** Application build version when available. */ + public val buildVersion: String? +) + +/** Inspected process instance. */ +@Serializable +public data class RuntimeTarget( + /** Opaque process-instance identifier. */ + public val identifier: RuntimeOpaqueIdentifier, + /** Platform process identifier represented as an opaque string. */ + public val processIdentifier: String?, + /** Open target-kind identifier. */ + public val kind: String, + /** Whether this is the Runtime's primary inspection target. */ + public val primary: Boolean +) + +/** Effective interface layout direction. */ +@Serializable +public enum class RuntimeLayoutDirection { + leftToRight, + rightToLeft, + unknown +} + +/** Platform, device, locale, and display facts. */ +@Serializable +public data class RuntimeEnvironment( + /** Platform family reported by the Runtime. */ + public val platform: String, + /** Operating-system release version. */ + public val operatingSystemVersion: String, + /** Open device-category identifier. */ + public val deviceCategory: String, + /** User-visible device name when available. */ + public val deviceName: String?, + /** Hardware or virtual-device model when available. */ + public val deviceModel: String?, + /** Whether the target runs on a virtual device. */ + public val virtualDevice: Boolean, + /** Active locale identifier when available. */ + public val locale: String?, + /** Effective interface layout direction. */ + public val layoutDirection: RuntimeLayoutDirection, + /** Display facts required for coordinate conversion. */ + public val display: RuntimeDisplayInfo, + /** Optional namespaced platform facts. */ + public val extensions: RuntimeExtensionMap? = null +) + +/** Successful application-info response payload. */ +@Serializable +public data class RuntimeApplicationInfoPayload( + /** Application identity and release metadata. */ + public val application: RuntimeApplication, + /** Inspected process instance. */ + public val target: RuntimeTarget, + /** Platform, device, locale, and display facts. */ + public val environment: RuntimeEnvironment, + /** Optional namespaced application facts. */ + public val extensions: RuntimeExtensionMap? = null +) { + public companion object { + /** Typed success-payload contract for the application-info method. */ + public val contract: RuntimeMethodContract by lazy { + RuntimeMethodContract(RuntimeMethod.applicationInfo, serializer()) + } + } +} + +/** Empty parameters for the application-info method. */ +@Serializable +public class RuntimeApplicationInfoParameters { + public companion object { + /** Typed request contract for the application-info method. */ + public val contract: RuntimeMethodContract by lazy { + RuntimeMethodContract(RuntimeMethod.applicationInfo, serializer()) + } + } + + override fun equals(other: Any?): Boolean = other is RuntimeApplicationInfoParameters + + override fun hashCode(): Int = javaClass.hashCode() +} diff --git a/AstrolabeProtocolKotlin/src/main/kotlin/dev/astrolabe/protocol/messaging/RuntimeCancellation.kt b/AstrolabeProtocolKotlin/src/main/kotlin/dev/astrolabe/protocol/messaging/RuntimeCancellation.kt new file mode 100644 index 0000000..9e91b00 --- /dev/null +++ b/AstrolabeProtocolKotlin/src/main/kotlin/dev/astrolabe/protocol/messaging/RuntimeCancellation.kt @@ -0,0 +1,53 @@ +// +// RuntimeCancellation.kt +// astrolabe-protocol +// +// Created by 轩辕十四 on 2026/7/20. +// + +package dev.astrolabe.protocol + +import java.util.UUID +import kotlinx.serialization.Serializable + +/** Parameters for cancelling one in-flight request. */ +@Serializable +public data class RuntimeCancelRequestParameters( + /** Request identifier whose operation should be cancelled. */ + public val targetRequestID: String +) { + init { + requireValidRequestIdentifier(targetRequestID) + } + + public companion object { + /** Typed request contract for the cancellation method. */ + public val contract: RuntimeMethodContract by lazy { + RuntimeMethodContract(RuntimeMethod.cancelRequest, serializer()) + } + } +} + +/** Successful cancellation response payload. */ +@Serializable +public data class RuntimeCancelRequestPayload( + /** Request identifier supplied by the cancellation request. */ + public val targetRequestID: String, + /** Whether an active operation accepted cancellation. */ + public val cancellationAccepted: Boolean +) { + init { + requireValidRequestIdentifier(targetRequestID) + } + + public companion object { + /** Typed success-payload contract for the cancellation method. */ + public val contract: RuntimeMethodContract by lazy { + RuntimeMethodContract(RuntimeMethod.cancelRequest, serializer()) + } + } +} + +private fun requireValidRequestIdentifier(value: String) { + require(runCatching { UUID.fromString(value) }.isSuccess) { "Request identifier must be a UUID" } +} diff --git a/AstrolabeProtocolKotlin/src/main/kotlin/dev/astrolabe/protocol/messaging/RuntimeJsonDocumentValidator.kt b/AstrolabeProtocolKotlin/src/main/kotlin/dev/astrolabe/protocol/messaging/RuntimeJsonDocumentValidator.kt new file mode 100644 index 0000000..7a04d2c --- /dev/null +++ b/AstrolabeProtocolKotlin/src/main/kotlin/dev/astrolabe/protocol/messaging/RuntimeJsonDocumentValidator.kt @@ -0,0 +1,308 @@ +// +// RuntimeJsonDocumentValidator.kt +// astrolabe-protocol +// +// Created by 轩辕十四 on 2026/7/20. +// + +package dev.astrolabe.protocol + +import kotlinx.serialization.SerializationException +import kotlinx.serialization.decodeFromString +import kotlinx.serialization.json.Json + +/** Validates wire-level JSON constraints before model decoding. */ +internal class RuntimeJsonDocumentValidator( + private val json: Json +) { + fun validate(data: ByteArray) { + if (data.isEmpty()) { + invalid("JSON document cannot be empty") + } + if (data.size >= BYTE_ORDER_MARK.size && + data.copyOfRange(0, BYTE_ORDER_MARK.size).contentEquals(BYTE_ORDER_MARK) + ) { + invalid("JSON document cannot contain a byte-order mark") + } + try { + data.decodeToString(throwOnInvalidSequence = true) + } catch (error: CharacterCodingException) { + throw RuntimeMessageException.InvalidDocument("JSON document is not valid UTF-8", error) + } + + RuntimeJsonParser(data, json).parseRootObject() + } + + private companion object { + private val BYTE_ORDER_MARK = byteArrayOf(0xEF.toByte(), 0xBB.toByte(), 0xBF.toByte()) + } +} + +private class RuntimeJsonParser( + private val bytes: ByteArray, + private val json: Json +) { + private var index: Int = 0 + private val stack = mutableListOf() + + fun parseRootObject() { + skipWhitespace() + if (!consumeIfPresent(CharacterByte.LEFT_BRACE)) { + invalid("Wire message root must be an object") + } + stack += ContainerFrame.objectFrame() + + while (stack.isNotEmpty()) { + when (stack.last().state) { + ContainerState.OBJECT_KEY_OR_END -> parseObjectKey(allowEnd = true) + ContainerState.OBJECT_KEY -> parseObjectKey(allowEnd = false) + ContainerState.OBJECT_COLON -> parseObjectColon() + ContainerState.OBJECT_VALUE -> parseValue() + ContainerState.OBJECT_COMMA_OR_END -> parseObjectSeparator() + ContainerState.ARRAY_VALUE_OR_END -> parseArrayValue(allowEnd = true) + ContainerState.ARRAY_VALUE -> parseArrayValue(allowEnd = false) + ContainerState.ARRAY_COMMA_OR_END -> parseArraySeparator() + } + } + + skipWhitespace() + if (index != bytes.size) { + invalid("JSON document contains trailing data") + } + } + + private fun parseObjectKey(allowEnd: Boolean) { + skipWhitespace() + if (allowEnd && consumeIfPresent(CharacterByte.RIGHT_BRACE)) { + stack.removeLast() + return + } + if (peek() != CharacterByte.QUOTE) { + invalid("JSON object key must be a string") + } + + val key = parseString() + if (!stack.last().keys.add(key)) { + invalid("JSON object contains duplicate key: $key") + } + stack.last().state = ContainerState.OBJECT_COLON + } + + private fun parseObjectColon() { + skipWhitespace() + consume(CharacterByte.COLON) + stack.last().state = ContainerState.OBJECT_VALUE + } + + private fun parseObjectSeparator() { + skipWhitespace() + if (consumeIfPresent(CharacterByte.RIGHT_BRACE)) { + stack.removeLast() + } else { + consume(CharacterByte.COMMA) + stack.last().state = ContainerState.OBJECT_KEY + } + } + + private fun parseArrayValue(allowEnd: Boolean) { + skipWhitespace() + if (allowEnd && consumeIfPresent(CharacterByte.RIGHT_BRACKET)) { + stack.removeLast() + return + } + parseValue() + } + + private fun parseArraySeparator() { + skipWhitespace() + if (consumeIfPresent(CharacterByte.RIGHT_BRACKET)) { + stack.removeLast() + } else { + consume(CharacterByte.COMMA) + stack.last().state = ContainerState.ARRAY_VALUE + } + } + + private fun parseValue() { + skipWhitespace() + val byte = peek() ?: invalid("JSON value is incomplete") + markCurrentValueConsumed() + + when (byte) { + CharacterByte.LEFT_BRACE -> { + index += 1 + stack += ContainerFrame.objectFrame() + } + CharacterByte.LEFT_BRACKET -> { + index += 1 + stack += ContainerFrame.arrayFrame() + } + CharacterByte.QUOTE -> parseString() + CharacterByte.MINUS, in CharacterByte.ZERO..CharacterByte.NINE -> parseNumber() + CharacterByte.T -> consumeLiteral("true") + CharacterByte.F -> consumeLiteral("false") + CharacterByte.N -> consumeLiteral("null") + else -> invalid("JSON value is malformed") + } + } + + private fun markCurrentValueConsumed() { + val frame = stack.last() + frame.state = when (frame.state) { + ContainerState.OBJECT_VALUE -> ContainerState.OBJECT_COMMA_OR_END + ContainerState.ARRAY_VALUE_OR_END, + ContainerState.ARRAY_VALUE -> ContainerState.ARRAY_COMMA_OR_END + else -> frame.state + } + } + + private fun parseString(): String { + val start = index + consume(CharacterByte.QUOTE) + var escaped = false + + while (index < bytes.size) { + val byte = bytes[index] + index += 1 + if (escaped) { + escaped = false + continue + } + when { + byte == CharacterByte.BACKSLASH -> escaped = true + byte == CharacterByte.QUOTE -> { + val encoded = bytes.copyOfRange(start, index).decodeToString() + return try { + json.decodeFromString(encoded) + } catch (error: SerializationException) { + throw RuntimeMessageException.InvalidDocument( + "JSON string is malformed", + error + ) + } + } + byte < CharacterByte.SPACE -> invalid("JSON string contains a control byte") + } + } + invalid("JSON string is unterminated") + } + + private fun parseNumber() { + val start = index + while (peek()?.let(::isNumberByte) == true) { + index += 1 + } + val token = bytes.copyOfRange(start, index).decodeToString() + if (!NUMBER_PATTERN.matches(token)) { + invalid("JSON number is malformed") + } + + if (token.contains('.') || token.contains('e') || token.contains('E')) { + val value = token.toDoubleOrNull() + if (value == null || !value.isFinite()) { + invalid("JSON number must be finite") + } + } else { + val value = token.toLongOrNull() + if (value == null || value !in MINIMUM_SAFE_INTEGER..MAXIMUM_SAFE_INTEGER) { + invalid("JSON integer exceeds the safe range") + } + } + } + + private fun consumeLiteral(literal: String) { + val expected = literal.encodeToByteArray() + if (index + expected.size > bytes.size || + !bytes.copyOfRange(index, index + expected.size).contentEquals(expected) + ) { + invalid("JSON literal is malformed") + } + index += expected.size + } + + private fun consume(expected: Byte) { + if (!consumeIfPresent(expected)) { + invalid("JSON document is malformed") + } + } + + private fun consumeIfPresent(expected: Byte): Boolean { + if (peek() != expected) { + return false + } + index += 1 + return true + } + + private fun skipWhitespace() { + while (peek() in CharacterByte.WHITESPACE) { + index += 1 + } + } + + private fun peek(): Byte? = bytes.getOrNull(index) + + private fun isNumberByte(byte: Byte): Boolean = + byte == CharacterByte.MINUS || + byte == CharacterByte.PLUS || + byte == CharacterByte.PERIOD || + byte == CharacterByte.E || + byte == CharacterByte.UPPERCASE_E || + byte in CharacterByte.ZERO..CharacterByte.NINE + + private companion object { + private const val MAXIMUM_SAFE_INTEGER: Long = 9_007_199_254_740_991 + private const val MINIMUM_SAFE_INTEGER: Long = -9_007_199_254_740_991 + private val NUMBER_PATTERN = Regex("^-?(?:0|[1-9][0-9]*)(?:\\.[0-9]+)?(?:[eE][+-]?[0-9]+)?$") + } +} + +private class ContainerFrame( + var state: ContainerState, + val keys: MutableSet +) { + companion object { + fun objectFrame(): ContainerFrame = + ContainerFrame(ContainerState.OBJECT_KEY_OR_END, mutableSetOf()) + + fun arrayFrame(): ContainerFrame = + ContainerFrame(ContainerState.ARRAY_VALUE_OR_END, mutableSetOf()) + } +} + +private enum class ContainerState { + OBJECT_KEY_OR_END, + OBJECT_KEY, + OBJECT_COLON, + OBJECT_VALUE, + OBJECT_COMMA_OR_END, + ARRAY_VALUE_OR_END, + ARRAY_VALUE, + ARRAY_COMMA_OR_END +} + +private object CharacterByte { + const val QUOTE: Byte = 0x22 + const val PLUS: Byte = 0x2B + const val COMMA: Byte = 0x2C + const val MINUS: Byte = 0x2D + const val PERIOD: Byte = 0x2E + const val ZERO: Byte = 0x30 + const val NINE: Byte = 0x39 + const val COLON: Byte = 0x3A + const val UPPERCASE_E: Byte = 0x45 + const val LEFT_BRACKET: Byte = 0x5B + const val BACKSLASH: Byte = 0x5C + const val RIGHT_BRACKET: Byte = 0x5D + const val E: Byte = 0x65 + const val F: Byte = 0x66 + const val N: Byte = 0x6E + const val T: Byte = 0x74 + const val LEFT_BRACE: Byte = 0x7B + const val RIGHT_BRACE: Byte = 0x7D + const val SPACE: Byte = 0x20 + val WHITESPACE: Set = setOf(0x20, 0x09, 0x0A, 0x0D) +} + +private fun invalid(message: String): Nothing = + throw RuntimeMessageException.InvalidDocument(message) diff --git a/AstrolabeProtocolKotlin/src/main/kotlin/dev/astrolabe/protocol/messaging/RuntimeMessage.kt b/AstrolabeProtocolKotlin/src/main/kotlin/dev/astrolabe/protocol/messaging/RuntimeMessage.kt new file mode 100644 index 0000000..240923a --- /dev/null +++ b/AstrolabeProtocolKotlin/src/main/kotlin/dev/astrolabe/protocol/messaging/RuntimeMessage.kt @@ -0,0 +1,142 @@ +// +// RuntimeMessage.kt +// astrolabe-protocol +// +// Created by 轩辕十四 on 2026/7/20. +// + +package dev.astrolabe.protocol + +import kotlinx.serialization.Serializable +import kotlinx.serialization.json.JsonElement +import kotlinx.serialization.json.JsonObject + +/** Open runtime error code. */ +@JvmInline +@Serializable +public value class RuntimeErrorCode( + /** Raw error code sent on the wire. */ + public val rawValue: String +) { + init { + require(isCommonRuntimeErrorCode(rawValue) || RuntimeNamespacedIdentifier.isValid(rawValue)) { + "Runtime error code must be built-in or namespaced" + } + } + + public companion object { + /** The frame header or payload is malformed. */ + public val malformedFrame: RuntimeErrorCode = RuntimeErrorCode("malformedFrame") + /** The declared frame payload exceeds the accepted limit. */ + public val frameTooLarge: RuntimeErrorCode = RuntimeErrorCode("frameTooLarge") + /** The decoded message does not satisfy the wire contract. */ + public val malformedMessage: RuntimeErrorCode = RuntimeErrorCode("malformedMessage") + /** The peer requested an unsupported protocol version. */ + public val unsupportedProtocolVersion: RuntimeErrorCode = RuntimeErrorCode("unsupportedProtocolVersion") + /** The peer requested an unsupported method. */ + public val unsupportedMethod: RuntimeErrorCode = RuntimeErrorCode("unsupportedMethod") + /** The request requires a completed handshake. */ + public val handshakeRequired: RuntimeErrorCode = RuntimeErrorCode("handshakeRequired") + /** The request requires an unavailable capability. */ + public val capabilityUnavailable: RuntimeErrorCode = RuntimeErrorCode("capabilityUnavailable") + /** Method parameters are invalid. */ + public val invalidParameters: RuntimeErrorCode = RuntimeErrorCode("invalidParameters") + /** The requested node no longer exists. */ + public val nodeNotFound: RuntimeErrorCode = RuntimeErrorCode("nodeNotFound") + /** The requested attribute cannot be patched. */ + public val unsupportedAttribute: RuntimeErrorCode = RuntimeErrorCode("unsupportedAttribute") + /** The supplied attribute value is invalid. */ + public val invalidAttributeValue: RuntimeErrorCode = RuntimeErrorCode("invalidAttributeValue") + /** The requested patch does not exist. */ + public val patchNotFound: RuntimeErrorCode = RuntimeErrorCode("patchNotFound") + /** The patch conflicts with current runtime state. */ + public val patchConflict: RuntimeErrorCode = RuntimeErrorCode("patchConflict") + /** The original value could not be restored. */ + public val patchRestorationFailed: RuntimeErrorCode = RuntimeErrorCode("patchRestorationFailed") + /** The runtime cannot accept more concurrent work. */ + public val tooManyRequests: RuntimeErrorCode = RuntimeErrorCode("tooManyRequests") + /** The request was cancelled. */ + public val requestCancelled: RuntimeErrorCode = RuntimeErrorCode("requestCancelled") + /** The request exceeded its execution deadline. */ + public val requestTimedOut: RuntimeErrorCode = RuntimeErrorCode("requestTimedOut") + /** The runtime failed for an implementation-specific reason. */ + public val internalFailure: RuntimeErrorCode = RuntimeErrorCode("internalFailure") + } +} + +private fun isCommonRuntimeErrorCode(value: String): Boolean = when (value) { + "malformedFrame", + "frameTooLarge", + "malformedMessage", + "unsupportedProtocolVersion", + "unsupportedMethod", + "handshakeRequired", + "capabilityUnavailable", + "invalidParameters", + "nodeNotFound", + "unsupportedAttribute", + "invalidAttributeValue", + "patchNotFound", + "patchConflict", + "patchRestorationFailed", + "tooManyRequests", + "requestCancelled", + "requestTimedOut", + "internalFailure" -> true + else -> false +} + +/** Structured protocol failure. */ +@Serializable +public data class RuntimeError( + /** Machine-readable error code. */ + public val code: RuntimeErrorCode, + /** Human-readable failure description. */ + public val message: String, + /** Optional action that can resolve the failure. */ + public val recoverySuggestion: String?, + /** Optional structured diagnostic values. */ + public val details: JsonObject? = null, + /** Optional namespaced error extensions. */ + public val extensions: RuntimeExtensionMap? = null +) + +/** Platform-neutral request envelope. */ +@Serializable +public data class RuntimeRequestEnvelope( + /** UUID correlating this request with its response. */ + public val requestID: String, + /** Protocol version used by this message. */ + public val protocolVersion: RuntimeProtocolVersion, + /** Operation requested from the Runtime. */ + public val method: RuntimeMethod, + /** Method-specific parameters preserved as a JSON object. */ + public val parameters: JsonObject +) + +/** Platform-neutral response envelope. */ +public data class RuntimeResponseEnvelope( + /** UUID copied from the request. */ + public val requestID: String, + /** Protocol version used by this message. */ + public val protocolVersion: RuntimeProtocolVersion, + /** Method copied from the request. */ + public val method: RuntimeMethod, + /** Successful payload or structured failure. */ + public val outcome: RuntimeResponseOutcome +) + +/** Mutually exclusive response result. */ +public sealed interface RuntimeResponseOutcome { + /** Successful response payload. */ + public data class Success( + /** Method-specific payload. */ + public val payload: JsonElement + ) : RuntimeResponseOutcome + + /** Failed response error. */ + public data class Failure( + /** Structured protocol error. */ + public val error: RuntimeError + ) : RuntimeResponseOutcome +} diff --git a/AstrolabeProtocolKotlin/src/main/kotlin/dev/astrolabe/protocol/messaging/RuntimeMessageCodec.kt b/AstrolabeProtocolKotlin/src/main/kotlin/dev/astrolabe/protocol/messaging/RuntimeMessageCodec.kt new file mode 100644 index 0000000..463ef0f --- /dev/null +++ b/AstrolabeProtocolKotlin/src/main/kotlin/dev/astrolabe/protocol/messaging/RuntimeMessageCodec.kt @@ -0,0 +1,220 @@ +// +// RuntimeMessageCodec.kt +// astrolabe-protocol +// +// Created by 轩辕十四 on 2026/7/20. +// + +package dev.astrolabe.protocol + +import java.util.UUID +import kotlinx.serialization.KSerializer +import kotlinx.serialization.SerializationException +import kotlinx.serialization.encodeToString +import kotlinx.serialization.json.Json +import kotlinx.serialization.json.JsonElement +import kotlinx.serialization.json.JsonObject +import kotlinx.serialization.json.buildJsonObject +import kotlinx.serialization.json.decodeFromJsonElement +import kotlinx.serialization.json.encodeToJsonElement +import kotlinx.serialization.json.jsonObject +import kotlinx.serialization.json.jsonPrimitive +import kotlinx.serialization.json.put + +/** Encodes and decodes platform-neutral Astrolabe wire envelopes. */ +public class RuntimeMessageCodec { + private val json = Json { + encodeDefaults = false + explicitNulls = true + ignoreUnknownKeys = true + } + private val documentValidator = RuntimeJsonDocumentValidator(json) + + /** Decodes any valid JSON document without applying method-specific typing. */ + public fun decodeDocument(data: ByteArray): JsonElement = parseDocument(data) + + /** Decodes a standalone JSON value through an explicit wire serializer. */ + public fun decodeValue(data: ByteArray, serializer: KSerializer): T = wrapDecode { + json.decodeFromJsonElement(serializer, parseDocument(data)) + } + + /** Decodes and validates a request envelope. */ + public fun decodeRequest(data: ByteArray): RuntimeRequestEnvelope = wrapDecode { + val document = requireObject(parseDocument(data)) + val request = json.decodeFromJsonElement(document) + validateRequest(request) + request + } + + /** Encodes a request envelope. */ + public fun encodeRequest(request: RuntimeRequestEnvelope): ByteArray { + validateRequest(request) + return json.encodeToString(request).encodeToByteArray() + } + + /** Decodes method-specific request parameters from an already validated envelope. */ + public fun decodeRequestParameters( + request: RuntimeRequestEnvelope, + contract: RuntimeMethodContract + ): T = wrapDecode { + validateMethod(request.method, contract.method) + json.decodeFromJsonElement(contract.serializer, request.parameters) + } + + /** Decodes and validates a response envelope. */ + public fun decodeResponse(data: ByteArray): RuntimeResponseEnvelope = wrapDecode { + val document = requireObject(parseDocument(data)) + val requestID = document.requireString("requestID") + validateRequestID(requestID) + val protocolVersion = json.decodeFromJsonElement( + document.requireMember("protocolVersion") + ) + validateProtocolVersion(protocolVersion) + val method = RuntimeMethod(document.requireString("method")) + + val outcome = when (document.requireString("status")) { + "success" -> { + if ("error" in document || "payload" !in document) { + throw RuntimeMessageException.InvalidEnvelope( + "Successful response must contain payload and cannot contain error" + ) + } + RuntimeResponseOutcome.Success(document.getValue("payload")) + } + "failure" -> { + if ("payload" in document || "error" !in document) { + throw RuntimeMessageException.InvalidEnvelope( + "Failed response must contain error and cannot contain payload" + ) + } + RuntimeResponseOutcome.Failure( + json.decodeFromJsonElement(document.getValue("error")) + ) + } + else -> throw RuntimeMessageException.InvalidEnvelope("Unknown response status") + } + + RuntimeResponseEnvelope( + requestID = requestID, + protocolVersion = protocolVersion, + method = method, + outcome = outcome + ) + } + + /** Encodes a response envelope. */ + public fun encodeResponse(response: RuntimeResponseEnvelope): ByteArray { + validateRequestID(response.requestID) + validateProtocolVersion(response.protocolVersion) + + val document = buildJsonObject { + put("requestID", response.requestID) + put("protocolVersion", json.encodeToJsonElement(response.protocolVersion)) + put("method", response.method.rawValue) + when (val outcome = response.outcome) { + is RuntimeResponseOutcome.Success -> { + put("status", "success") + put("payload", outcome.payload) + } + is RuntimeResponseOutcome.Failure -> { + put("status", "failure") + put("error", json.encodeToJsonElement(outcome.error)) + } + } + } + return json.encodeToString(document).encodeToByteArray() + } + + /** Decodes the successful payload from an already validated response envelope. */ + public fun decodeSuccessPayload( + response: RuntimeResponseEnvelope, + contract: RuntimeMethodContract + ): T { + validateMethod(response.method, contract.method) + val outcome = response.outcome as? RuntimeResponseOutcome.Success + ?: throw RuntimeMessageException.InvalidEnvelope("Response does not contain a success payload") + return wrapDecode { + json.decodeFromJsonElement(contract.serializer, outcome.payload) + } + } + + private fun parseDocument(data: ByteArray): JsonElement = wrapDecode { + documentValidator.validate(data) + json.parseToJsonElement(data.decodeToString()) + } + + private fun requireObject(document: JsonElement): JsonObject = + runCatching { document.jsonObject }.getOrElse { error -> + throw RuntimeMessageException.InvalidDocument("Wire message root must be an object", error) + } + + private fun validateRequest(request: RuntimeRequestEnvelope) { + validateRequestID(request.requestID) + validateProtocolVersion(request.protocolVersion) + } + + private fun validateRequestID(requestID: String) { + runCatching { UUID.fromString(requestID) }.getOrElse { error -> + throw RuntimeMessageException.InvalidEnvelope("requestID must be a UUID", error) + } + } + + private fun validateProtocolVersion(version: RuntimeProtocolVersion) { + if (!version.isSupported) { + throw RuntimeMessageException.UnsupportedProtocolVersion(version) + } + } + + private fun validateMethod(actual: RuntimeMethod, expected: RuntimeMethod) { + if (actual != expected) { + throw RuntimeMessageException.MethodMismatch(expected = expected, actual = actual) + } + } + + private fun JsonObject.requireMember(name: String): JsonElement = + this[name] ?: throw RuntimeMessageException.InvalidEnvelope("Missing required member: $name") + + private fun JsonObject.requireString(name: String): String = try { + requireMember(name).jsonPrimitive.content + } catch (error: IllegalArgumentException) { + throw RuntimeMessageException.InvalidEnvelope("Member $name must be a string", error) + } + + private inline fun wrapDecode(operation: () -> T): T = try { + operation() + } catch (error: RuntimeMessageException) { + throw error + } catch (error: SerializationException) { + throw RuntimeMessageException.InvalidDocument("Unable to decode JSON document", error) + } catch (error: IllegalArgumentException) { + throw RuntimeMessageException.InvalidDocument("Unable to decode JSON document", error) + } +} + +/** Failures produced while reading or writing a wire message. */ +public sealed class RuntimeMessageException(message: String, cause: Throwable? = null) : + Exception(message, cause) { + /** The bytes do not contain an accepted JSON document. */ + public class InvalidDocument(message: String, cause: Throwable? = null) : + RuntimeMessageException(message, cause) + + /** Required envelope members are missing, malformed, or inconsistent. */ + public class InvalidEnvelope(message: String, cause: Throwable? = null) : + RuntimeMessageException(message, cause) + + /** The message uses a protocol version this package cannot decode. */ + public class UnsupportedProtocolVersion( + /** Unsupported version read from the message. */ + public val version: RuntimeProtocolVersion + ) : RuntimeMessageException("Unsupported protocol version: ${version.major}.${version.minor}") + + /** A typed contract was used with a different wire method. */ + public class MethodMismatch( + /** Method required by the typed contract. */ + public val expected: RuntimeMethod, + /** Method present in the decoded envelope. */ + public val actual: RuntimeMethod + ) : RuntimeMessageException( + "Method mismatch: expected ${expected.rawValue}, received ${actual.rawValue}" + ) +} diff --git a/AstrolabeProtocolKotlin/src/main/kotlin/dev/astrolabe/protocol/negotiation/RuntimeCapability.kt b/AstrolabeProtocolKotlin/src/main/kotlin/dev/astrolabe/protocol/negotiation/RuntimeCapability.kt new file mode 100644 index 0000000..8c1523e --- /dev/null +++ b/AstrolabeProtocolKotlin/src/main/kotlin/dev/astrolabe/protocol/negotiation/RuntimeCapability.kt @@ -0,0 +1,41 @@ +// +// RuntimeCapability.kt +// astrolabe-protocol +// +// Created by 轩辕十四 on 2026/7/20. +// + +package dev.astrolabe.protocol + +import kotlinx.serialization.Serializable + +/** Open capability identifier advertised during handshake. */ +@JvmInline +@Serializable +public value class RuntimeCapability( + /** Raw capability value. */ + public val rawValue: String +) { + init { + require(rawValue.isNotEmpty() && rawValue.length <= MAXIMUM_CAPABILITY_LENGTH) { + "Runtime capability must contain between 1 and 128 characters" + } + } + + public companion object { + /** Runtime can describe the inspected application. */ + public val applicationInfo: RuntimeCapability = RuntimeCapability("applicationInfo") + /** Runtime can capture hierarchy snapshots. */ + public val hierarchySnapshot: RuntimeCapability = RuntimeCapability("hierarchySnapshot") + /** Runtime can return node details. */ + public val nodeDetail: RuntimeCapability = RuntimeCapability("nodeDetail") + /** Runtime can describe patchable attributes. */ + public val attributePatchDiscovery: RuntimeCapability = RuntimeCapability("attributePatchDiscovery") + /** Runtime can apply temporary attribute patches. */ + public val attributePatching: RuntimeCapability = RuntimeCapability("attributePatching") + /** Runtime supports request cancellation. */ + public val requestCancellation: RuntimeCapability = RuntimeCapability("requestCancellation") + } +} + +private const val MAXIMUM_CAPABILITY_LENGTH: Int = 128 diff --git a/AstrolabeProtocolKotlin/src/main/kotlin/dev/astrolabe/protocol/negotiation/RuntimeHandshake.kt b/AstrolabeProtocolKotlin/src/main/kotlin/dev/astrolabe/protocol/negotiation/RuntimeHandshake.kt new file mode 100644 index 0000000..a7d6cf6 --- /dev/null +++ b/AstrolabeProtocolKotlin/src/main/kotlin/dev/astrolabe/protocol/negotiation/RuntimeHandshake.kt @@ -0,0 +1,74 @@ +// +// RuntimeHandshake.kt +// astrolabe-protocol +// +// Created by 轩辕十四 on 2026/7/20. +// + +package dev.astrolabe.protocol + +import kotlinx.serialization.Serializable + +/** Host product information sent when opening a protocol session. */ +@Serializable +public data class RuntimeClientDescriptor( + /** Client product name. */ + public val name: String, + /** Client release version. */ + public val version: String +) + +/** Parameters for the handshake request. */ +@Serializable +public data class RuntimeHandshakeParameters( + /** Host initiating the protocol session. */ + public val client: RuntimeClientDescriptor, + /** Protocol versions the Host can decode. */ + public val supportedProtocolRange: RuntimeProtocolRange +) { + public companion object { + /** Typed request contract for the handshake method. */ + public val contract: RuntimeMethodContract by lazy { + RuntimeMethodContract(RuntimeMethod.handshake, serializer()) + } + } +} + +/** Runtime implementation information returned by handshake. */ +@Serializable +public data class RuntimeDescriptor( + /** Namespaced runtime implementation identifier. */ + public val identifier: RuntimeNamespacedIdentifier, + /** Runtime SDK release version. */ + public val version: String, + /** Opaque identifier for this runtime process instance. */ + public val instanceID: RuntimeOpaqueIdentifier +) + +/** Successful handshake response payload. */ +@Serializable +public data class RuntimeHandshakePayload( + /** Runtime implementation participating in the session. */ + public val runtime: RuntimeDescriptor, + /** Platform identifier reported by the runtime. */ + public val platform: String, + /** Protocol version selected for this session. */ + public val negotiatedProtocolVersion: RuntimeProtocolVersion, + /** Runtime operations available to the Host. */ + public val capabilities: List, + /** Optional namespaced session facts. */ + public val extensions: RuntimeExtensionMap? = null +) { + init { + require(negotiatedProtocolVersion == RuntimeProtocolVersion.V2) { + "Handshake selected an unsupported protocol version" + } + } + + public companion object { + /** Typed success-payload contract for the handshake method. */ + public val contract: RuntimeMethodContract by lazy { + RuntimeMethodContract(RuntimeMethod.handshake, serializer()) + } + } +} diff --git a/AstrolabeProtocolKotlin/src/main/kotlin/dev/astrolabe/protocol/negotiation/RuntimeMethod.kt b/AstrolabeProtocolKotlin/src/main/kotlin/dev/astrolabe/protocol/negotiation/RuntimeMethod.kt new file mode 100644 index 0000000..9e02be1 --- /dev/null +++ b/AstrolabeProtocolKotlin/src/main/kotlin/dev/astrolabe/protocol/negotiation/RuntimeMethod.kt @@ -0,0 +1,58 @@ +// +// RuntimeMethod.kt +// astrolabe-protocol +// +// Created by 轩辕十四 on 2026/7/20. +// + +package dev.astrolabe.protocol + +import kotlinx.serialization.KSerializer +import kotlinx.serialization.Serializable + +/** Open wire method identifier. */ +@JvmInline +@Serializable +public value class RuntimeMethod( + /** Raw method value sent on the wire. */ + public val rawValue: String +) { + init { + require(rawValue.isNotEmpty() && rawValue.length <= MAXIMUM_METHOD_LENGTH) { + "Runtime method must contain between 1 and 256 characters" + } + } + + public companion object { + /** Opens a protocol session and negotiates capabilities. */ + public val handshake: RuntimeMethod = RuntimeMethod("handshake") + /** Returns application, target, environment, and display facts. */ + public val applicationInfo: RuntimeMethod = RuntimeMethod("applicationInfo") + /** Captures the runtime hierarchy. */ + public val hierarchySnapshot: RuntimeMethod = RuntimeMethod("hierarchySnapshot") + /** Returns detailed facts for one node. */ + public val nodeDetail: RuntimeMethod = RuntimeMethod("nodeDetail") + /** Describes attributes accepted by the patch runtime. */ + public val patchableAttributes: RuntimeMethod = RuntimeMethod("patchableAttributes") + /** Applies one temporary attribute patch. */ + public val applyAttributePatch: RuntimeMethod = RuntimeMethod("applyAttributePatch") + /** Lists currently active attribute patches. */ + public val listAttributePatches: RuntimeMethod = RuntimeMethod("listAttributePatches") + /** Reverts one active attribute patch. */ + public val revertAttributePatch: RuntimeMethod = RuntimeMethod("revertAttributePatch") + /** Reverts every active attribute patch. */ + public val clearAttributePatches: RuntimeMethod = RuntimeMethod("clearAttributePatches") + /** Attempts to cancel one in-flight request. */ + public val cancelRequest: RuntimeMethod = RuntimeMethod("cancelRequest") + } +} + +/** Binds one method identifier to the serializer for its parameters or payload. */ +public data class RuntimeMethodContract( + /** Method accepted by this contract. */ + public val method: RuntimeMethod, + /** Serializer for the method-specific value. */ + public val serializer: KSerializer +) + +private const val MAXIMUM_METHOD_LENGTH: Int = 256 diff --git a/AstrolabeProtocolKotlin/src/main/kotlin/dev/astrolabe/protocol/negotiation/RuntimeProtocolVersion.kt b/AstrolabeProtocolKotlin/src/main/kotlin/dev/astrolabe/protocol/negotiation/RuntimeProtocolVersion.kt new file mode 100644 index 0000000..37881be --- /dev/null +++ b/AstrolabeProtocolKotlin/src/main/kotlin/dev/astrolabe/protocol/negotiation/RuntimeProtocolVersion.kt @@ -0,0 +1,70 @@ +// +// RuntimeProtocolVersion.kt +// astrolabe-protocol +// +// Created by 轩辕十四 on 2026/7/20. +// + +package dev.astrolabe.protocol + +import kotlinx.serialization.Serializable + +/** Wire protocol version carried by each request and response. */ +@Serializable +public data class RuntimeProtocolVersion( + /** Protocol major version. */ + public val major: Int, + /** Protocol minor version. */ + public val minor: Int +) : Comparable { + init { + require(major in 0..MAXIMUM_VERSION_COMPONENT) { "Protocol major version is outside UInt16 range" } + require(minor in 0..MAXIMUM_VERSION_COMPONENT) { "Protocol minor version is outside UInt16 range" } + } + + /** Whether this value is the only protocol version implemented by this package. */ + public val isSupported: Boolean + get() = this == V2 + + override fun compareTo(other: RuntimeProtocolVersion): Int = + compareValuesBy(this, other, RuntimeProtocolVersion::major, RuntimeProtocolVersion::minor) + + public companion object { + /** Astrolabe Wire Protocol 2.0. */ + public val V2: RuntimeProtocolVersion = RuntimeProtocolVersion(major = 2, minor = 0) + } +} + +/** Inclusive protocol version interval supported by one peer. */ +@Serializable +public data class RuntimeProtocolRange( + /** Oldest protocol version accepted by the peer. */ + public val minimum: RuntimeProtocolVersion, + /** Newest protocol version accepted by the peer. */ + public val maximum: RuntimeProtocolVersion +) { + init { + require(minimum.major == maximum.major) { "Protocol range cannot span major versions" } + require(minimum <= maximum) { "Protocol range minimum cannot exceed maximum" } + } + + /** Returns whether this range includes the supplied version. */ + public operator fun contains(version: RuntimeProtocolVersion): Boolean = version in minimum..maximum + + /** Returns the newest version accepted by both ranges, or null when they do not overlap. */ + public fun highestCommonVersion(other: RuntimeProtocolRange): RuntimeProtocolVersion? { + val lowerBound = maxOf(minimum, other.minimum) + val upperBound = minOf(maximum, other.maximum) + return upperBound.takeIf { lowerBound <= it } + } + + public companion object { + /** Range containing Astrolabe Wire Protocol 2.0 only. */ + public val V2: RuntimeProtocolRange = RuntimeProtocolRange( + minimum = RuntimeProtocolVersion.V2, + maximum = RuntimeProtocolVersion.V2 + ) + } +} + +private const val MAXIMUM_VERSION_COMPONENT: Int = 65_535 diff --git a/AstrolabeProtocolKotlin/src/test/kotlin/dev/astrolabe/protocol/framing/RuntimeFrameCodecTest.kt b/AstrolabeProtocolKotlin/src/test/kotlin/dev/astrolabe/protocol/framing/RuntimeFrameCodecTest.kt new file mode 100644 index 0000000..a734590 --- /dev/null +++ b/AstrolabeProtocolKotlin/src/test/kotlin/dev/astrolabe/protocol/framing/RuntimeFrameCodecTest.kt @@ -0,0 +1,67 @@ +// +// RuntimeFrameCodecTest.kt +// astrolabe-protocol +// +// Created by 轩辕十四 on 2026/7/20. +// + +package dev.astrolabe.protocol + +import kotlin.test.Test +import kotlin.test.assertContentEquals +import kotlin.test.assertEquals +import kotlin.test.assertFailsWith + +class RuntimeFrameCodecTest { + @Test + fun `encode writes payload length in network byte order`() { + val payload = byteArrayOf(0xAA.toByte(), 0xBB.toByte(), 0xCC.toByte()) + + val frame = RuntimeFrameCodec().encode(payload) + + assertContentEquals(byteArrayOf(0, 0, 0, 3), frame.copyOfRange(0, 4)) + assertContentEquals(payload, frame.copyOfRange(4, frame.size)) + } + + @Test + fun `stream decoder handles fragmented and consecutive frames`() { + val codec = RuntimeFrameCodec() + val first = codec.encode("first".encodeToByteArray()) + val second = codec.encode("second".encodeToByteArray()) + val decoder = codec.makeStreamDecoder() + + assertEquals(emptyList(), decoder.append(first.copyOfRange(0, 3))) + assertEquals( + listOf("first", "second"), + decoder.append(first.copyOfRange(3, first.size) + second) + .map { it.decodeToString() } + ) + assertEquals(0, decoder.pendingByteCount) + } + + @Test + fun `codec rejects empty and oversized payloads`() { + val codec = RuntimeFrameCodec(maximumPayloadSize = 2) + + assertFailsWith { + codec.encode(byteArrayOf()) + } + assertFailsWith { + codec.encode(byteArrayOf(1, 2, 3)) + } + } + + @Test + fun `stream decoder rejects invalid declared payload lengths`() { + val decoder = RuntimeFrameCodec(maximumPayloadSize = 2).makeStreamDecoder() + + assertFailsWith { + decoder.append(byteArrayOf(0, 0, 0, 0)) + } + + decoder.reset() + assertFailsWith { + decoder.append(byteArrayOf(0, 0, 0, 3)) + } + } +} diff --git a/AstrolabeProtocolKotlin/src/test/kotlin/dev/astrolabe/protocol/inspection/RuntimeApplicationModelTest.kt b/AstrolabeProtocolKotlin/src/test/kotlin/dev/astrolabe/protocol/inspection/RuntimeApplicationModelTest.kt new file mode 100644 index 0000000..c4306e6 --- /dev/null +++ b/AstrolabeProtocolKotlin/src/test/kotlin/dev/astrolabe/protocol/inspection/RuntimeApplicationModelTest.kt @@ -0,0 +1,43 @@ +// +// RuntimeApplicationModelTest.kt +// astrolabe-protocol +// +// Created by 轩辕十四 on 2026/7/20. +// + +package dev.astrolabe.protocol + +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertFailsWith + +class RuntimeApplicationModelTest { + private val codec = RuntimeMessageCodec() + + @Test + fun `application info fixtures decode through typed contracts`() { + val request = codec.decodeRequest(fixture("v2/valid/application-info-request.json")) + val response = codec.decodeResponse(fixture("v2/valid/application-info-response.json")) + + codec.decodeRequestParameters(request, RuntimeApplicationInfoParameters.contract) + val payload = codec.decodeSuccessPayload(response, RuntimeApplicationInfoPayload.contract) + + assertEquals("com.example.demo", payload.application.identifier) + assertEquals(3.0, payload.environment.display.logicalToPixelScale.x) + assertEquals("phone", payload.environment.extensions?.values?.get("ios.uikit.interfaceIdiom")?.toString()?.trim('"')) + } + + @Test + fun `display facts require units`() { + assertFailsWith { + codec.decodeValue( + fixture("v2/invalid/display-size-missing-unit.json"), + RuntimeDisplayInfo.serializer() + ) + } + } + + private fun fixture(path: String): ByteArray = checkNotNull( + javaClass.classLoader.getResource(path) + ).readBytes() +} diff --git a/AstrolabeProtocolKotlin/src/test/kotlin/dev/astrolabe/protocol/messaging/RuntimeCancellationModelTest.kt b/AstrolabeProtocolKotlin/src/test/kotlin/dev/astrolabe/protocol/messaging/RuntimeCancellationModelTest.kt new file mode 100644 index 0000000..08c5bf5 --- /dev/null +++ b/AstrolabeProtocolKotlin/src/test/kotlin/dev/astrolabe/protocol/messaging/RuntimeCancellationModelTest.kt @@ -0,0 +1,31 @@ +// +// RuntimeCancellationModelTest.kt +// astrolabe-protocol +// +// Created by 轩辕十四 on 2026/7/20. +// + +package dev.astrolabe.protocol + +import kotlin.test.Test +import kotlin.test.assertEquals + +class RuntimeCancellationModelTest { + private val codec = RuntimeMessageCodec() + + @Test + fun `cancellation fixtures decode through typed contracts`() { + val request = codec.decodeRequest(fixture("v2/valid/cancel-request.json")) + val response = codec.decodeResponse(fixture("v2/valid/cancel-response.json")) + + val parameters = codec.decodeRequestParameters(request, RuntimeCancelRequestParameters.contract) + val payload = codec.decodeSuccessPayload(response, RuntimeCancelRequestPayload.contract) + + assertEquals(parameters.targetRequestID, payload.targetRequestID) + assertEquals(true, payload.cancellationAccepted) + } + + private fun fixture(path: String): ByteArray = checkNotNull( + javaClass.classLoader.getResource(path) + ).readBytes() +} diff --git a/AstrolabeProtocolKotlin/src/test/kotlin/dev/astrolabe/protocol/messaging/RuntimeMessageCodecTest.kt b/AstrolabeProtocolKotlin/src/test/kotlin/dev/astrolabe/protocol/messaging/RuntimeMessageCodecTest.kt new file mode 100644 index 0000000..44e633a --- /dev/null +++ b/AstrolabeProtocolKotlin/src/test/kotlin/dev/astrolabe/protocol/messaging/RuntimeMessageCodecTest.kt @@ -0,0 +1,119 @@ +// +// RuntimeMessageCodecTest.kt +// astrolabe-protocol +// +// Created by 轩辕十四 on 2026/7/20. +// + +package dev.astrolabe.protocol + +import java.io.File +import kotlinx.serialization.json.JsonObject +import kotlinx.serialization.json.JsonPrimitive +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertFailsWith +import kotlin.test.assertIs + +class RuntimeMessageCodecTest { + private val codec = RuntimeMessageCodec() + + @Test + fun `request envelope preserves method parameters and extensions`() { + val source = fixture("v2/valid/unknown-method-request.json") + + val request = codec.decodeRequest(source) + val encoded = codec.encodeRequest(request) + val decoded = codec.decodeRequest(encoded) + + assertEquals("vendor.experimentalInspection", decoded.method.rawValue) + assertEquals(JsonPrimitive("compact"), decoded.parameters["mode"]) + assertEquals(request, decoded) + } + + @Test + fun `response envelope enforces status member exclusivity`() { + val success = codec.decodeResponse(fixture("v2/valid/handshake-response.json")) + val failure = codec.decodeResponse(fixture("v2/valid/node-detail-failure-response.json")) + + assertIs(success.outcome) + assertIs(failure.outcome) + assertFailsWith { + codec.decodeResponse(fixture("v2/invalid/success-response-contains-error.json")) + } + assertFailsWith { + codec.decodeResponse(fixture("v2/invalid/failure-response-contains-payload.json")) + } + } + + @Test + fun `codec rejects unsupported protocol versions and scalar roots`() { + assertFailsWith { + codec.decodeRequest(fixture("v2/invalid/request-uses-v1.json")) + } + assertFailsWith { + codec.decodeRequest("[]".encodeToByteArray()) + } + } + + @Test + fun `codec rejects duplicate keys trailing data and unsafe integers`() { + val invalidDocuments = listOf( + """{"method":"handshake","method":"nodeDetail"}""", + """{"method":"handshake","\u006dethod":"nodeDetail"}""", + """{"method":"handshake"} true""", + """{"value":9007199254740992}""" + ) + + invalidDocuments.forEach { source -> + assertFailsWith { + codec.decodeDocument(source.encodeToByteArray()) + } + } + } + + @Test + fun `codec rejects byte order marks and invalid UTF-8`() { + assertFailsWith { + codec.decodeDocument(byteArrayOf(0xEF.toByte(), 0xBB.toByte(), 0xBF.toByte()) + "{}".encodeToByteArray()) + } + assertFailsWith { + codec.decodeDocument(byteArrayOf(0xC3.toByte(), 0x28)) + } + } + + @Test + fun `codec decodes every valid request and response fixture`() { + validFixtureNames() + .filterNot { it == "vector-attribute-value.json" } + .forEach { name -> + val source = fixture("v2/valid/$name") + val document = codec.decodeDocument(source) + val objectValue = assertIs(document) + when { + "status" in objectValue -> codec.decodeResponse(source) + "method" in objectValue -> codec.decodeRequest(source) + else -> error("Unexpected protocol fixture: $name") + } + } + } + + @Test + fun `codec decodes standalone valid attribute values`() { + val document = codec.decodeDocument(fixture("v2/valid/vector-attribute-value.json")) + + assertIs(document) + } + + private fun validFixtureNames(): List = checkNotNull( + javaClass.classLoader.getResource("v2/valid") + ).toURI().let(::File).listFiles() + ?.filter { it.extension == "json" } + ?.map { it.name } + ?.sorted() + ?: error("Protocol fixtures are unavailable") + + private fun fixture(path: String): ByteArray = checkNotNull( + javaClass.classLoader.getResource(path) + ).readBytes() +} diff --git a/AstrolabeProtocolKotlin/src/test/kotlin/dev/astrolabe/protocol/negotiation/RuntimeSessionModelTest.kt b/AstrolabeProtocolKotlin/src/test/kotlin/dev/astrolabe/protocol/negotiation/RuntimeSessionModelTest.kt new file mode 100644 index 0000000..21fcd15 --- /dev/null +++ b/AstrolabeProtocolKotlin/src/test/kotlin/dev/astrolabe/protocol/negotiation/RuntimeSessionModelTest.kt @@ -0,0 +1,100 @@ +// +// RuntimeSessionModelTest.kt +// astrolabe-protocol +// +// Created by 轩辕十四 on 2026/7/20. +// + +package dev.astrolabe.protocol + +import java.io.File +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertFailsWith + +class RuntimeSessionModelTest { + private val codec = RuntimeMessageCodec() + + @Test + fun `handshake request decodes typed parameters`() { + val request = codec.decodeRequest(fixture("v2/valid/handshake-request.json")) + + val parameters = codec.decodeRequestParameters( + request, + RuntimeHandshakeParameters.contract + ) + + assertEquals("astrolabe-host", parameters.client.name) + assertEquals(RuntimeProtocolRange.V2, parameters.supportedProtocolRange) + } + + @Test + fun `handshake response decodes typed payload`() { + val response = codec.decodeResponse(fixture("v2/valid/handshake-response.json")) + + val payload = codec.decodeSuccessPayload( + response, + RuntimeHandshakePayload.contract + ) + + assertEquals("ios", payload.platform) + assertEquals(RuntimeProtocolVersion.V2, payload.negotiatedProtocolVersion) + assertEquals("astrolabe.runtime.ios", payload.runtime.identifier.rawValue) + } + + @Test + fun `protocol range rejects descending and cross-major values`() { + assertFailsWith { + RuntimeProtocolRange( + minimum = RuntimeProtocolVersion(2, 1), + maximum = RuntimeProtocolVersion(2, 0) + ) + } + assertFailsWith { + RuntimeProtocolRange( + minimum = RuntimeProtocolVersion(1, 0), + maximum = RuntimeProtocolVersion(2, 0) + ) + } + assertFailsWith { + codec.decodeRequest(fixture("v2/invalid/descending-protocol-range.json")).let { request -> + codec.decodeRequestParameters(request, RuntimeHandshakeParameters.contract) + } + } + } + + @Test + fun `typed decoding rejects a mismatched method before reading payload`() { + val request = codec.decodeRequest(fixture("v2/valid/application-info-request.json")) + val response = codec.decodeResponse(fixture("v2/valid/application-info-response.json")) + + assertFailsWith { + codec.decodeRequestParameters(request, RuntimeHandshakeParameters.contract) + } + assertFailsWith { + codec.decodeSuccessPayload(response, RuntimeHandshakePayload.contract) + } + } + + @Test + fun `open identifiers reject empty or unnamespaced values`() { + assertFailsWith { RuntimeMethod("") } + assertFailsWith { RuntimeCapability("") } + assertFailsWith { RuntimeOpaqueIdentifier("") } + assertFailsWith { RuntimeNamespacedIdentifier("missingNamespace") } + } + + @Test + fun `error codes and extension keys follow shared identifier rules`() { + assertEquals("nodeNotFound", RuntimeErrorCode.nodeNotFound.rawValue) + assertEquals("vendor.customFailure", RuntimeErrorCode("vendor.customFailure").rawValue) + assertFailsWith { RuntimeErrorCode("customFailure") } + assertFailsWith { + RuntimeExtensionMap(mapOf("invalid" to kotlinx.serialization.json.JsonNull)) + } + } + + private fun fixture(path: String): ByteArray = File( + checkNotNull(javaClass.classLoader.getResource(path)).toURI() + ).readBytes() +} diff --git a/build.gradle.kts b/build.gradle.kts new file mode 100644 index 0000000..0b832e9 --- /dev/null +++ b/build.gradle.kts @@ -0,0 +1,4 @@ +plugins { + alias(libs.plugins.kotlin.jvm) apply false + alias(libs.plugins.kotlin.serialization) apply false +} diff --git a/gradle.properties b/gradle.properties new file mode 100644 index 0000000..fd76d1e --- /dev/null +++ b/gradle.properties @@ -0,0 +1,4 @@ +astrolabeVersion=1.0.0 +org.gradle.configuration-cache=true +org.gradle.jvmargs=-Xmx2g -Dfile.encoding=UTF-8 +kotlin.code.style=official diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml new file mode 100644 index 0000000..e209155 --- /dev/null +++ b/gradle/libs.versions.toml @@ -0,0 +1,10 @@ +[versions] +kotlin = "2.2.10" +kotlinx-serialization = "1.9.0" + +[libraries] +kotlinx-serialization-json = { module = "org.jetbrains.kotlinx:kotlinx-serialization-json", version.ref = "kotlinx-serialization" } + +[plugins] +kotlin-jvm = { id = "org.jetbrains.kotlin.jvm", version.ref = "kotlin" } +kotlin-serialization = { id = "org.jetbrains.kotlin.plugin.serialization", version.ref = "kotlin" } diff --git a/gradle/wrapper/gradle-wrapper.jar b/gradle/wrapper/gradle-wrapper.jar new file mode 100644 index 0000000000000000000000000000000000000000..8bdaf60c75ab801e22807dde59e12a8735a34077 GIT binary patch literal 45457 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 c82=K%QXLKFx%@O{wJjEi6Y56o#$)Bpeg literal 0 HcmV?d00001 diff --git a/gradle/wrapper/gradle-wrapper.properties b/gradle/wrapper/gradle-wrapper.properties new file mode 100644 index 0000000..b579835 --- /dev/null +++ b/gradle/wrapper/gradle-wrapper.properties @@ -0,0 +1,9 @@ +#Wed Jul 15 20:08:15 CST 2026 +distributionBase=GRADLE_USER_HOME +distributionPath=wrapper/dists +distributionSha256Sum=553c78f50dafcd54d65b9a444649057857469edf836431389695608536d6b746 +distributionUrl=https\://services.gradle.org/distributions/gradle-9.5.0-bin.zip +networkTimeout=10000 +validateDistributionUrl=true +zipStoreBase=GRADLE_USER_HOME +zipStorePath=wrapper/dists diff --git a/gradlew b/gradlew new file mode 100755 index 0000000..ef07e01 --- /dev/null +++ b/gradlew @@ -0,0 +1,251 @@ +#!/bin/sh + +# +# Copyright © 2015 the original authors. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# https://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +# +# SPDX-License-Identifier: Apache-2.0 +# + +############################################################################## +# +# Gradle start up script for POSIX generated by Gradle. +# +# Important for running: +# +# (1) You need a POSIX-compliant shell to run this script. If your /bin/sh is +# noncompliant, but you have some other compliant shell such as ksh or +# bash, then to run this script, type that shell name before the whole +# command line, like: +# +# ksh Gradle +# +# Busybox and similar reduced shells will NOT work, because this script +# requires all of these POSIX shell features: +# * functions; +# * expansions «$var», «${var}», «${var:-default}», «${var+SET}», +# «${var#prefix}», «${var%suffix}», and «$( cmd )»; +# * compound commands having a testable exit status, especially «case»; +# * various built-in commands including «command», «set», and «ulimit». +# +# Important for patching: +# +# (2) This script targets any POSIX shell, so it avoids extensions provided +# by Bash, Ksh, etc; in particular arrays are avoided. +# +# The "traditional" practice of packing multiple parameters into a +# space-separated string is a well documented source of bugs and security +# problems, so this is (mostly) avoided, by progressively accumulating +# options in "$@", and eventually passing that to Java. +# +# Where the inherited environment variables (DEFAULT_JVM_OPTS, JAVA_OPTS, +# and GRADLE_OPTS) rely on word-splitting, this is performed explicitly; +# see the in-line comments for details. +# +# There are tweaks for specific operating systems such as AIX, CygWin, +# Darwin, MinGW, and NonStop. +# +# (3) This script is generated from the Groovy template +# https://github.com/gradle/gradle/blob/HEAD/platforms/jvm/plugins-application/src/main/resources/org/gradle/api/internal/plugins/unixStartScript.txt +# within the Gradle project. +# +# You can find Gradle at https://github.com/gradle/gradle/. +# +############################################################################## + +# Attempt to set APP_HOME + +# Resolve links: $0 may be a link +app_path=$0 + +# Need this for daisy-chained symlinks. +while + APP_HOME=${app_path%"${app_path##*/}"} # leaves a trailing /; empty if no leading path + [ -h "$app_path" ] +do + ls=$( ls -ld "$app_path" ) + link=${ls#*' -> '} + case $link in #( + /*) app_path=$link ;; #( + *) app_path=$APP_HOME$link ;; + esac +done + +# This is normally unused +# shellcheck disable=SC2034 +APP_BASE_NAME=${0##*/} +# Discard cd standard output in case $CDPATH is set (https://github.com/gradle/gradle/issues/25036) +APP_HOME=$( cd -P "${APP_HOME:-./}" > /dev/null && printf '%s\n' "$PWD" ) || exit + +# Use the maximum available, or set MAX_FD != -1 to use that value. +MAX_FD=maximum + +warn () { + echo "$*" +} >&2 + +die () { + echo + echo "$*" + echo + exit 1 +} >&2 + +# OS specific support (must be 'true' or 'false'). +cygwin=false +msys=false +darwin=false +nonstop=false +case "$( uname )" in #( + CYGWIN* ) cygwin=true ;; #( + Darwin* ) darwin=true ;; #( + MSYS* | MINGW* ) msys=true ;; #( + NONSTOP* ) nonstop=true ;; +esac + +CLASSPATH="\\\"\\\"" + + +# Determine the Java command to use to start the JVM. +if [ -n "$JAVA_HOME" ] ; then + if [ -x "$JAVA_HOME/jre/sh/java" ] ; then + # IBM's JDK on AIX uses strange locations for the executables + JAVACMD=$JAVA_HOME/jre/sh/java + else + JAVACMD=$JAVA_HOME/bin/java + fi + if [ ! -x "$JAVACMD" ] ; then + die "ERROR: JAVA_HOME is set to an invalid directory: $JAVA_HOME + +Please set the JAVA_HOME variable in your environment to match the +location of your Java installation." + fi +else + JAVACMD=java + if ! command -v java >/dev/null 2>&1 + then + die "ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH. + +Please set the JAVA_HOME variable in your environment to match the +location of your Java installation." + fi +fi + +# Increase the maximum file descriptors if we can. +if ! "$cygwin" && ! "$darwin" && ! "$nonstop" ; then + case $MAX_FD in #( + max*) + # In POSIX sh, ulimit -H is undefined. That's why the result is checked to see if it worked. + # shellcheck disable=SC2039,SC3045 + MAX_FD=$( ulimit -H -n ) || + warn "Could not query maximum file descriptor limit" + esac + case $MAX_FD in #( + '' | soft) :;; #( + *) + # In POSIX sh, ulimit -n is undefined. That's why the result is checked to see if it worked. + # shellcheck disable=SC2039,SC3045 + ulimit -n "$MAX_FD" || + warn "Could not set maximum file descriptor limit to $MAX_FD" + esac +fi + +# Collect all arguments for the java command, stacking in reverse order: +# * args from the command line +# * the main class name +# * -classpath +# * -D...appname settings +# * --module-path (only if needed) +# * DEFAULT_JVM_OPTS, JAVA_OPTS, and GRADLE_OPTS environment variables. + +# For Cygwin or MSYS, switch paths to Windows format before running java +if "$cygwin" || "$msys" ; then + APP_HOME=$( cygpath --path --mixed "$APP_HOME" ) + CLASSPATH=$( cygpath --path --mixed "$CLASSPATH" ) + + JAVACMD=$( cygpath --unix "$JAVACMD" ) + + # Now convert the arguments - kludge to limit ourselves to /bin/sh + for arg do + if + case $arg in #( + -*) false ;; # don't mess with options #( + /?*) t=${arg#/} t=/${t%%/*} # looks like a POSIX filepath + [ -e "$t" ] ;; #( + *) false ;; + esac + then + arg=$( cygpath --path --ignore --mixed "$arg" ) + fi + # Roll the args list around exactly as many times as the number of + # args, so each arg winds up back in the position where it started, but + # possibly modified. + # + # NB: a `for` loop captures its iteration list before it begins, so + # changing the positional parameters here affects neither the number of + # iterations, nor the values presented in `arg`. + shift # remove old arg + set -- "$@" "$arg" # push replacement arg + done +fi + + +# Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script. +DEFAULT_JVM_OPTS='"-Xmx64m" "-Xms64m"' + +# Collect all arguments for the java command: +# * DEFAULT_JVM_OPTS, JAVA_OPTS, and optsEnvironmentVar are not allowed to contain shell fragments, +# and any embedded shellness will be escaped. +# * For example: A user cannot expect ${Hostname} to be expanded, as it is an environment variable and will be +# treated as '${Hostname}' itself on the command line. + +set -- \ + "-Dorg.gradle.appname=$APP_BASE_NAME" \ + -classpath "$CLASSPATH" \ + -jar "$APP_HOME/gradle/wrapper/gradle-wrapper.jar" \ + "$@" + +# Stop when "xargs" is not available. +if ! command -v xargs >/dev/null 2>&1 +then + die "xargs is not available" +fi + +# Use "xargs" to parse quoted args. +# +# With -n1 it outputs one arg per line, with the quotes and backslashes removed. +# +# In Bash we could simply go: +# +# readarray ARGS < <( xargs -n1 <<<"$var" ) && +# set -- "${ARGS[@]}" "$@" +# +# but POSIX shell has neither arrays nor command substitution, so instead we +# post-process each arg (as a line of input to sed) to backslash-escape any +# character that might be a shell metacharacter, then use eval to reverse +# that process (while maintaining the separation between arguments), and wrap +# the whole thing up as a single "set" statement. +# +# This will of course break if any of these variables contains a newline or +# an unmatched quote. +# + +eval "set -- $( + printf '%s\n' "$DEFAULT_JVM_OPTS $JAVA_OPTS $GRADLE_OPTS" | + xargs -n1 | + sed ' s~[^-[:alnum:]+,./:=@_]~\\&~g; ' | + tr '\n' ' ' + )" '"$@"' + +exec "$JAVACMD" "$@" diff --git a/scripts/release-prepare.mjs b/scripts/release-prepare.mjs index 64c9680..2fb114e 100644 --- a/scripts/release-prepare.mjs +++ b/scripts/release-prepare.mjs @@ -39,6 +39,7 @@ export function prepareRelease({ runRequired(commandRunner, "npm", ["test"]); runRequired(commandRunner, "swift", ["test", "--parallel"]); runRequired(commandRunner, "swift", ["build", "-c", "release"]); + runRequired(commandRunner, "./gradlew", [":AstrolabeProtocolKotlin:build"]); requireOnlyVersionChanges(commandRunner, updatedPaths); runRequired(commandRunner, "git", ["diff", "--check"]); runRequired(commandRunner, "git", ["add", "--", ...updatedPaths]); diff --git a/scripts/versioning.mjs b/scripts/versioning.mjs index ea609f6..d4a02e0 100644 --- a/scripts/versioning.mjs +++ b/scripts/versioning.mjs @@ -2,8 +2,10 @@ import { readFileSync, writeFileSync } from "node:fs"; import { join } from "node:path"; const metadataPath = "Sources/AstrolabeProtocol/Core/RuntimeProtocolMetadata.swift"; +const gradlePropertiesPath = "gradle.properties"; export const versionedPaths = Object.freeze([ + gradlePropertiesPath, metadataPath, "README.md", "package-lock.json", @@ -16,6 +18,7 @@ const releaseVersionPattern = new RegExp( ); const swiftVersionPattern = /static let packageVersion = "([^"]+)"/g; const documentationVersionPattern = /exact: "([^"]+)"/g; +const gradleVersionPattern = /^astrolabeVersion=(.+)$/gm; export function assertReleaseVersion(version) { if (!releaseVersionPattern.test(version)) { @@ -53,6 +56,11 @@ export function synchronizeRepositoryVersion(projectRoot, version) { join(projectRoot, "README.md"), documentationVersionPattern, `exact: "${version}"` + ), + textVersionUpdate( + join(projectRoot, gradlePropertiesPath), + gradleVersionPattern, + `astrolabeVersion=${version}` ) ]; updates.forEach(({ path, content }) => writeFileSync(path, content)); @@ -87,6 +95,13 @@ export function versionConsistencyIssues(projectRoot) { expectedVersion, issues ); + inspectTextVersion( + join(projectRoot, gradlePropertiesPath), + gradlePropertiesPath, + gradleVersionPattern, + expectedVersion, + issues + ); return issues; } diff --git a/settings.gradle.kts b/settings.gradle.kts new file mode 100644 index 0000000..d6f5cd0 --- /dev/null +++ b/settings.gradle.kts @@ -0,0 +1,16 @@ +pluginManagement { + repositories { + mavenCentral() + gradlePluginPortal() + } +} + +dependencyResolutionManagement { + repositoriesMode.set(RepositoriesMode.FAIL_ON_PROJECT_REPOS) + repositories { + mavenCentral() + } +} + +rootProject.name = "astrolabe-protocol" +include(":AstrolabeProtocolKotlin") diff --git a/test/release-prepare.test.mjs b/test/release-prepare.test.mjs index b5fdc3d..005fcf1 100644 --- a/test/release-prepare.test.mjs +++ b/test/release-prepare.test.mjs @@ -32,6 +32,7 @@ test("release preparation synchronizes, verifies, commits, and tags a prerelease join(projectRoot, "README.md"), '.package(url: "example", exact: "2.0.0-rc.1")\n' ); + writeFileSync(join(projectRoot, "gradle.properties"), "astrolabeVersion=2.0.0-rc.1\n"); const sourceDirectory = join(projectRoot, "Sources/AstrolabeProtocol/Core"); mkdirSync(sourceDirectory, { recursive: true }); writeFileSync( @@ -59,7 +60,16 @@ test("release preparation synchronizes, verifies, commits, and tags a prerelease assert.equal(result.version, "2.0.0-rc.2"); assert.equal(JSON.parse(readFileSync(join(projectRoot, "package.json"))).version, "2.0.0-rc.2"); + assert.match( + readFileSync(join(projectRoot, "gradle.properties"), "utf8"), + /^astrolabeVersion=2\.0\.0-rc\.2$/m + ); assert.ok(commands.some((command) => command.join(" ") === "swift build -c release")); + assert.ok( + commands.some( + (command) => command.join(" ") === "./gradlew :AstrolabeProtocolKotlin:build" + ) + ); assert.ok(commands.some((command) => command.join(" ") === "git tag -a 2.0.0-rc.2 -m Astrolabe Protocol 2.0.0-rc.2")); } finally { rmSync(projectRoot, { recursive: true, force: true }); From 14f4f7f4f41e08a07689599ec4266660620cb673 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E8=BD=A9=E8=BE=95=E5=8D=81=E5=9B=9B?= Date: Mon, 20 Jul 2026 21:49:08 +0800 Subject: [PATCH 3/7] feat: complete Kotlin protocol contracts --- .../astrolabe/protocol/core/RuntimeColor.kt | 32 +++ .../protocol/core/RuntimeGeometry.kt | 93 +++++- .../protocol/inspection/RuntimeApplication.kt | 55 +++- .../inspection/RuntimeAttributeValue.kt | 271 ++++++++++++++++++ .../inspection/RuntimeAttributedTextRun.kt | 47 +++ .../protocol/inspection/RuntimeHierarchy.kt | 214 ++++++++++++++ .../inspection/RuntimeLayoutRelation.kt | 67 +++++ .../protocol/inspection/RuntimeNodeDetail.kt | 84 ++++++ .../protocol/messaging/RuntimeMessageCodec.kt | 86 +++++- .../protocol/negotiation/RuntimeMethod.kt | 2 +- .../patching/RuntimeAttributePatch.kt | 149 ++++++++++ .../patching/RuntimePatchableAttribute.kt | 128 +++++++++ .../ProtocolFixtureConformanceTest.kt | 124 ++++++++ .../inspection/RuntimeHierarchyModelTest.kt | 48 ++++ .../inspection/RuntimeNodeDetailModelTest.kt | 135 +++++++++ .../messaging/RuntimeMessageCodecTest.kt | 11 + .../negotiation/RuntimeSessionModelTest.kt | 39 +++ .../patching/RuntimePatchingModelTest.kt | 90 ++++++ 18 files changed, 1651 insertions(+), 24 deletions(-) create mode 100644 AstrolabeProtocolKotlin/src/main/kotlin/dev/astrolabe/protocol/core/RuntimeColor.kt create mode 100644 AstrolabeProtocolKotlin/src/main/kotlin/dev/astrolabe/protocol/inspection/RuntimeAttributeValue.kt create mode 100644 AstrolabeProtocolKotlin/src/main/kotlin/dev/astrolabe/protocol/inspection/RuntimeAttributedTextRun.kt create mode 100644 AstrolabeProtocolKotlin/src/main/kotlin/dev/astrolabe/protocol/inspection/RuntimeHierarchy.kt create mode 100644 AstrolabeProtocolKotlin/src/main/kotlin/dev/astrolabe/protocol/inspection/RuntimeLayoutRelation.kt create mode 100644 AstrolabeProtocolKotlin/src/main/kotlin/dev/astrolabe/protocol/inspection/RuntimeNodeDetail.kt create mode 100644 AstrolabeProtocolKotlin/src/main/kotlin/dev/astrolabe/protocol/patching/RuntimeAttributePatch.kt create mode 100644 AstrolabeProtocolKotlin/src/main/kotlin/dev/astrolabe/protocol/patching/RuntimePatchableAttribute.kt create mode 100644 AstrolabeProtocolKotlin/src/test/kotlin/dev/astrolabe/protocol/contract/ProtocolFixtureConformanceTest.kt create mode 100644 AstrolabeProtocolKotlin/src/test/kotlin/dev/astrolabe/protocol/inspection/RuntimeHierarchyModelTest.kt create mode 100644 AstrolabeProtocolKotlin/src/test/kotlin/dev/astrolabe/protocol/inspection/RuntimeNodeDetailModelTest.kt create mode 100644 AstrolabeProtocolKotlin/src/test/kotlin/dev/astrolabe/protocol/patching/RuntimePatchingModelTest.kt diff --git a/AstrolabeProtocolKotlin/src/main/kotlin/dev/astrolabe/protocol/core/RuntimeColor.kt b/AstrolabeProtocolKotlin/src/main/kotlin/dev/astrolabe/protocol/core/RuntimeColor.kt new file mode 100644 index 0000000..e74549b --- /dev/null +++ b/AstrolabeProtocolKotlin/src/main/kotlin/dev/astrolabe/protocol/core/RuntimeColor.kt @@ -0,0 +1,32 @@ +// +// RuntimeColor.kt +// astrolabe-protocol +// +// Created by 轩辕十四 on 2026/7/20. +// + +package dev.astrolabe.protocol + +import kotlinx.serialization.Serializable + +/** Color components expressed in a declared color space. */ +@Serializable +public data class RuntimeColor( + /** Color-space identifier used by the components. */ + public val colorSpace: String, + /** Red component in the inclusive range from zero to one. */ + public val red: Double, + /** Green component in the inclusive range from zero to one. */ + public val green: Double, + /** Blue component in the inclusive range from zero to one. */ + public val blue: Double, + /** Alpha component in the inclusive range from zero to one. */ + public val alpha: Double +) { + init { + require(colorSpace.isNotEmpty()) { "Color space cannot be empty" } + require(listOf(red, green, blue, alpha).all { it in 0.0..1.0 }) { + "Color components must be between zero and one" + } + } +} diff --git a/AstrolabeProtocolKotlin/src/main/kotlin/dev/astrolabe/protocol/core/RuntimeGeometry.kt b/AstrolabeProtocolKotlin/src/main/kotlin/dev/astrolabe/protocol/core/RuntimeGeometry.kt index e2f4479..585ba6c 100644 --- a/AstrolabeProtocolKotlin/src/main/kotlin/dev/astrolabe/protocol/core/RuntimeGeometry.kt +++ b/AstrolabeProtocolKotlin/src/main/kotlin/dev/astrolabe/protocol/core/RuntimeGeometry.kt @@ -46,7 +46,13 @@ public data class RuntimeCoordinatePoint( public val coordinateSpace: RuntimeCoordinateSpace, /** Unit used by both coordinates. */ public val unit: RuntimeMeasurementUnit -) +) { + init { + require(unit != RuntimeMeasurementUnit.scaledLogical) { + "Coordinate point unit must be logical or pixel" + } + } +} /** Two-dimensional measured extent. */ @Serializable @@ -60,6 +66,71 @@ public data class RuntimeMeasuredSize( ) { init { require(width >= 0.0 && height >= 0.0) { "Measured size dimensions cannot be negative" } + require(unit != RuntimeMeasurementUnit.scaledLogical) { + "Measured size unit must be logical or pixel" + } + } +} + +/** Two-dimensional signed delta. */ +@Serializable +public data class RuntimeVector( + /** Horizontal delta. */ + public val dx: Double, + /** Vertical delta. */ + public val dy: Double, + /** Unit used by both deltas. */ + public val unit: RuntimeMeasurementUnit +) { + init { + require(unit != RuntimeMeasurementUnit.scaledLogical) { + "Vector unit must be logical or pixel" + } + } +} + +/** Insets measured from four edges. */ +@Serializable +public data class RuntimeInsets( + /** Top inset. */ + public val top: Double, + /** Left inset. */ + public val left: Double, + /** Bottom inset. */ + public val bottom: Double, + /** Right inset. */ + public val right: Double, + /** Unit used by every edge. */ + public val unit: RuntimeMeasurementUnit +) { + init { + require(unit != RuntimeMeasurementUnit.scaledLogical) { + "Insets unit must be logical or pixel" + } + } +} + +/** Rectangle in an explicitly declared coordinate space. */ +@Serializable +public data class RuntimeCoordinateRect( + /** Horizontal origin. */ + public val x: Double, + /** Vertical origin. */ + public val y: Double, + /** Non-negative horizontal extent. */ + public val width: Double, + /** Non-negative vertical extent. */ + public val height: Double, + /** Coordinate space containing the rectangle. */ + public val coordinateSpace: RuntimeCoordinateSpace, + /** Unit used by all rectangle components. */ + public val unit: RuntimeMeasurementUnit +) { + init { + require(width >= 0.0 && height >= 0.0) { "Rectangle dimensions cannot be negative" } + require(unit != RuntimeMeasurementUnit.scaledLogical) { + "Rectangle unit must be logical or pixel" + } } } @@ -70,7 +141,11 @@ public data class RuntimeScale( public val x: Double, /** Vertical scale. */ public val y: Double -) +) { + init { + require(x > 0.0 && y > 0.0) { "Scale components must be greater than zero" } + } +} /** Display facts required for coordinate conversion. */ @Serializable @@ -83,4 +158,16 @@ public data class RuntimeDisplayInfo( public val logicalToPixelScale: RuntimeScale, /** Maximum refresh rate when reported by the platform. */ public val maximumRefreshRate: Double? -) +) { + init { + require(logicalSize.unit == RuntimeMeasurementUnit.logical) { + "Logical display size must use logical units" + } + require(pixelSize.unit == RuntimeMeasurementUnit.pixel) { + "Pixel display size must use pixel units" + } + require(maximumRefreshRate == null || maximumRefreshRate > 0.0) { + "Maximum refresh rate must be greater than zero" + } + } +} diff --git a/AstrolabeProtocolKotlin/src/main/kotlin/dev/astrolabe/protocol/inspection/RuntimeApplication.kt b/AstrolabeProtocolKotlin/src/main/kotlin/dev/astrolabe/protocol/inspection/RuntimeApplication.kt index 49cd5cd..5f37faf 100644 --- a/AstrolabeProtocolKotlin/src/main/kotlin/dev/astrolabe/protocol/inspection/RuntimeApplication.kt +++ b/AstrolabeProtocolKotlin/src/main/kotlin/dev/astrolabe/protocol/inspection/RuntimeApplication.kt @@ -20,7 +20,18 @@ public data class RuntimeApplication( public val version: String?, /** Application build version when available. */ public val buildVersion: String? -) +) { + init { + require(identifier.isNotEmpty() && identifier.length <= 512) { + "Application identifier must contain between 1 and 512 characters" + } + require(displayName.isNotEmpty() && displayName.length <= 256) { + "Application display name must contain between 1 and 256 characters" + } + require(version == null || version.length <= 64) { "Application version is too long" } + require(buildVersion == null || buildVersion.length <= 64) { "Application build version is too long" } + } +} /** Inspected process instance. */ @Serializable @@ -33,7 +44,16 @@ public data class RuntimeTarget( public val kind: String, /** Whether this is the Runtime's primary inspection target. */ public val primary: Boolean -) +) { + init { + require(processIdentifier == null || processIdentifier.length <= 128) { + "Process identifier is too long" + } + require(kind.isNotEmpty() && kind.length <= 64) { + "Target kind must contain between 1 and 64 characters" + } + } +} /** Effective interface layout direction. */ @Serializable @@ -66,7 +86,22 @@ public data class RuntimeEnvironment( public val display: RuntimeDisplayInfo, /** Optional namespaced platform facts. */ public val extensions: RuntimeExtensionMap? = null -) +) { + init { + require(platform.isNotEmpty() && platform.length <= 64) { + "Platform must contain between 1 and 64 characters" + } + require(operatingSystemVersion.isNotEmpty() && operatingSystemVersion.length <= 64) { + "Operating-system version must contain between 1 and 64 characters" + } + require(deviceCategory.isNotEmpty() && deviceCategory.length <= 64) { + "Device category must contain between 1 and 64 characters" + } + require(deviceName == null || deviceName.length <= 256) { "Device name is too long" } + require(deviceModel == null || deviceModel.length <= 256) { "Device model is too long" } + require(locale == null || locale.length <= 64) { "Locale identifier is too long" } + } +} /** Successful application-info response payload. */ @Serializable @@ -90,15 +125,9 @@ public data class RuntimeApplicationInfoPayload( /** Empty parameters for the application-info method. */ @Serializable -public class RuntimeApplicationInfoParameters { - public companion object { - /** Typed request contract for the application-info method. */ - public val contract: RuntimeMethodContract by lazy { - RuntimeMethodContract(RuntimeMethod.applicationInfo, serializer()) - } +public data object RuntimeApplicationInfoParameters { + /** Typed request contract for the application-info method. */ + public val contract: RuntimeMethodContract by lazy { + RuntimeMethodContract(RuntimeMethod.applicationInfo, serializer()) } - - override fun equals(other: Any?): Boolean = other is RuntimeApplicationInfoParameters - - override fun hashCode(): Int = javaClass.hashCode() } diff --git a/AstrolabeProtocolKotlin/src/main/kotlin/dev/astrolabe/protocol/inspection/RuntimeAttributeValue.kt b/AstrolabeProtocolKotlin/src/main/kotlin/dev/astrolabe/protocol/inspection/RuntimeAttributeValue.kt new file mode 100644 index 0000000..05bfbf5 --- /dev/null +++ b/AstrolabeProtocolKotlin/src/main/kotlin/dev/astrolabe/protocol/inspection/RuntimeAttributeValue.kt @@ -0,0 +1,271 @@ +// +// RuntimeAttributeValue.kt +// astrolabe-protocol +// +// Created by 轩辕十四 on 2026/7/20. +// + +package dev.astrolabe.protocol + +import java.math.BigDecimal +import kotlinx.serialization.KSerializer +import kotlinx.serialization.Serializable +import kotlinx.serialization.SerializationException +import kotlinx.serialization.descriptors.SerialDescriptor +import kotlinx.serialization.descriptors.buildClassSerialDescriptor +import kotlinx.serialization.descriptors.element +import kotlinx.serialization.encoding.Decoder +import kotlinx.serialization.encoding.Encoder +import kotlinx.serialization.json.JsonArray +import kotlinx.serialization.json.JsonDecoder +import kotlinx.serialization.json.JsonElement +import kotlinx.serialization.json.JsonEncoder +import kotlinx.serialization.json.JsonNull +import kotlinx.serialization.json.JsonObject +import kotlinx.serialization.json.JsonPrimitive +import kotlinx.serialization.json.buildJsonObject +import kotlinx.serialization.json.decodeFromJsonElement +import kotlinx.serialization.json.encodeToJsonElement +import kotlinx.serialization.json.put + +/** Closed common attribute values plus namespaced extension values. */ +@Serializable(with = RuntimeAttributeValueSerializer::class) +public sealed interface RuntimeAttributeValue { + /** Explicit JSON null. */ + public data object Null : RuntimeAttributeValue + + /** Boolean attribute value. */ + public data class BooleanValue( + /** Wrapped Boolean. */ + public val value: Boolean + ) : RuntimeAttributeValue + + /** JSON-safe signed integer attribute value. */ + public data class Integer( + /** Wrapped integer. */ + public val value: Long + ) : RuntimeAttributeValue { + init { + require(value in MINIMUM_SAFE_INTEGER..MAXIMUM_SAFE_INTEGER) { + "Integer attribute value exceeds the JSON safe range" + } + } + } + + /** Finite floating-point attribute value. */ + public data class Number( + /** Wrapped number. */ + public val value: Double + ) : RuntimeAttributeValue { + init { + require(value.isFinite()) { "Number attribute value must be finite" } + } + } + + /** String attribute value. */ + public data class StringValue( + /** Wrapped string. */ + public val value: String + ) : RuntimeAttributeValue + + /** String-list attribute value. */ + public data class StringList( + /** Wrapped strings. */ + public val value: List + ) : RuntimeAttributeValue + + /** Scalar measurement attribute value. */ + public data class Measurement( + /** Wrapped measurement. */ + public val value: RuntimeMeasurement + ) : RuntimeAttributeValue + + /** Coordinate point attribute value. */ + public data class Point( + /** Wrapped point. */ + public val value: RuntimeCoordinatePoint + ) : RuntimeAttributeValue + + /** Measured-size attribute value. */ + public data class Size( + /** Wrapped size. */ + public val value: RuntimeMeasuredSize + ) : RuntimeAttributeValue + + /** Signed vector attribute value. */ + public data class Vector( + /** Wrapped vector. */ + public val value: RuntimeVector + ) : RuntimeAttributeValue + + /** Coordinate rectangle attribute value. */ + public data class Rect( + /** Wrapped rectangle. */ + public val value: RuntimeCoordinateRect + ) : RuntimeAttributeValue + + /** Insets attribute value. */ + public data class Insets( + /** Wrapped insets. */ + public val value: RuntimeInsets + ) : RuntimeAttributeValue + + /** Color attribute value. */ + public data class Color( + /** Wrapped color. */ + public val value: RuntimeColor + ) : RuntimeAttributeValue + + /** Attributed text-run value. */ + public data class TextRuns( + /** Wrapped text runs. */ + public val value: List + ) : RuntimeAttributeValue + + /** Layout-relation value. */ + public data class LayoutRelations( + /** Wrapped layout relations. */ + public val value: List + ) : RuntimeAttributeValue + + /** Arbitrary JSON array value. */ + public data class ArrayValue( + /** Wrapped JSON values. */ + public val value: JsonArray + ) : RuntimeAttributeValue + + /** Arbitrary JSON object value. */ + public data class ObjectValue( + /** Wrapped JSON members. */ + public val value: JsonObject + ) : RuntimeAttributeValue + + /** Namespaced producer-defined attribute value. */ + public data class Extension( + /** Namespaced value type. */ + public val type: RuntimeNamespacedIdentifier, + /** Opaque JSON value interpreted by the producer. */ + public val value: JsonElement + ) : RuntimeAttributeValue +} + +/** Serializer for the discriminator-based attribute-value wire shape. */ +public object RuntimeAttributeValueSerializer : KSerializer { + override val descriptor: SerialDescriptor = buildClassSerialDescriptor("RuntimeAttributeValue") { + element("type") + element("value") + } + + override fun deserialize(decoder: Decoder): RuntimeAttributeValue { + val jsonDecoder = decoder as? JsonDecoder + ?: throw SerializationException("RuntimeAttributeValue requires JSON") + val document = jsonDecoder.decodeJsonElement() as? JsonObject + ?: throw SerializationException("RuntimeAttributeValue must be an object") + if (document.keys != EXPECTED_KEYS) { + throw SerializationException("RuntimeAttributeValue must contain only type and value") + } + val typeElement = document.getValue("type") as? JsonPrimitive + ?: throw SerializationException("RuntimeAttributeValue type must be a string") + if (!typeElement.isString) { + throw SerializationException("RuntimeAttributeValue type must be a string") + } + val type = typeElement.content + val value = document.getValue("value") + return decodeValue(type, value, jsonDecoder) + } + + override fun serialize(encoder: Encoder, value: RuntimeAttributeValue) { + val jsonEncoder = encoder as? JsonEncoder + ?: throw SerializationException("RuntimeAttributeValue requires JSON") + val pair = encodeValue(value, jsonEncoder) + jsonEncoder.encodeJsonElement(buildJsonObject { + put("type", pair.first) + put("value", pair.second) + }) + } + + private fun decodeValue( + type: String, + value: JsonElement, + decoder: JsonDecoder + ): RuntimeAttributeValue = when (type) { + "null" -> { + if (value !is JsonNull) throw SerializationException("Null attribute value must contain JSON null") + RuntimeAttributeValue.Null + } + "boolean" -> RuntimeAttributeValue.BooleanValue(decoder.json.decodeFromJsonElement(value)) + "integer" -> decodeInteger(value) + "number" -> RuntimeAttributeValue.Number(decoder.json.decodeFromJsonElement(value)) + "string" -> RuntimeAttributeValue.StringValue(decoder.json.decodeFromJsonElement(value)) + "stringList" -> RuntimeAttributeValue.StringList(decoder.json.decodeFromJsonElement(value)) + "measurement" -> RuntimeAttributeValue.Measurement(decoder.json.decodeFromJsonElement(value)) + "point" -> RuntimeAttributeValue.Point(decoder.json.decodeFromJsonElement(value)) + "size" -> RuntimeAttributeValue.Size(decoder.json.decodeFromJsonElement(value)) + "vector" -> RuntimeAttributeValue.Vector(decoder.json.decodeFromJsonElement(value)) + "rect" -> RuntimeAttributeValue.Rect(decoder.json.decodeFromJsonElement(value)) + "insets" -> RuntimeAttributeValue.Insets(decoder.json.decodeFromJsonElement(value)) + "color" -> RuntimeAttributeValue.Color(decoder.json.decodeFromJsonElement(value)) + "textRuns" -> RuntimeAttributeValue.TextRuns(decoder.json.decodeFromJsonElement(value)) + "layoutRelations" -> RuntimeAttributeValue.LayoutRelations(decoder.json.decodeFromJsonElement(value)) + "array" -> RuntimeAttributeValue.ArrayValue(value as? JsonArray + ?: throw SerializationException("Array attribute value must contain a JSON array")) + "object" -> RuntimeAttributeValue.ObjectValue(value as? JsonObject + ?: throw SerializationException("Object attribute value must contain a JSON object")) + else -> RuntimeAttributeValue.Extension(RuntimeNamespacedIdentifier(type), value) + } + + private fun decodeInteger(value: JsonElement): RuntimeAttributeValue.Integer { + val primitive = value as? JsonPrimitive + ?: throw SerializationException("Integer attribute value must contain a JSON number") + if (primitive.isString) { + throw SerializationException("Integer attribute value must contain a JSON number") + } + val number = try { + BigDecimal(primitive.content) + } catch (error: NumberFormatException) { + throw SerializationException("Integer attribute value must contain an integer", error) + } + if (number.stripTrailingZeros().scale() > 0) { + throw SerializationException("Integer attribute value must contain an integer") + } + val integer = try { + number.longValueExact() + } catch (error: ArithmeticException) { + throw SerializationException("Integer attribute value exceeds the signed 64-bit range", error) + } + return RuntimeAttributeValue.Integer(integer) + } + + private fun encodeValue( + value: RuntimeAttributeValue, + encoder: JsonEncoder + ): Pair = when (value) { + RuntimeAttributeValue.Null -> "null" to JsonNull + is RuntimeAttributeValue.BooleanValue -> "boolean" to JsonPrimitive(value.value) + is RuntimeAttributeValue.Integer -> "integer" to JsonPrimitive(value.value) + is RuntimeAttributeValue.Number -> "number" to JsonPrimitive(value.value) + is RuntimeAttributeValue.StringValue -> "string" to JsonPrimitive(value.value) + is RuntimeAttributeValue.StringList -> + "stringList" to encoder.json.encodeToJsonElement(value.value) + is RuntimeAttributeValue.Measurement -> + "measurement" to encoder.json.encodeToJsonElement(value.value) + is RuntimeAttributeValue.Point -> "point" to encoder.json.encodeToJsonElement(value.value) + is RuntimeAttributeValue.Size -> "size" to encoder.json.encodeToJsonElement(value.value) + is RuntimeAttributeValue.Vector -> "vector" to encoder.json.encodeToJsonElement(value.value) + is RuntimeAttributeValue.Rect -> "rect" to encoder.json.encodeToJsonElement(value.value) + is RuntimeAttributeValue.Insets -> "insets" to encoder.json.encodeToJsonElement(value.value) + is RuntimeAttributeValue.Color -> "color" to encoder.json.encodeToJsonElement(value.value) + is RuntimeAttributeValue.TextRuns -> + "textRuns" to encoder.json.encodeToJsonElement(value.value) + is RuntimeAttributeValue.LayoutRelations -> + "layoutRelations" to encoder.json.encodeToJsonElement(value.value) + is RuntimeAttributeValue.ArrayValue -> "array" to value.value + is RuntimeAttributeValue.ObjectValue -> "object" to value.value + is RuntimeAttributeValue.Extension -> value.type.rawValue to value.value + } + + private val EXPECTED_KEYS: Set = setOf("type", "value") +} + +private const val MAXIMUM_SAFE_INTEGER: Long = 9_007_199_254_740_991 +private const val MINIMUM_SAFE_INTEGER: Long = -MAXIMUM_SAFE_INTEGER diff --git a/AstrolabeProtocolKotlin/src/main/kotlin/dev/astrolabe/protocol/inspection/RuntimeAttributedTextRun.kt b/AstrolabeProtocolKotlin/src/main/kotlin/dev/astrolabe/protocol/inspection/RuntimeAttributedTextRun.kt new file mode 100644 index 0000000..a28ec6d --- /dev/null +++ b/AstrolabeProtocolKotlin/src/main/kotlin/dev/astrolabe/protocol/inspection/RuntimeAttributedTextRun.kt @@ -0,0 +1,47 @@ +// +// RuntimeAttributedTextRun.kt +// astrolabe-protocol +// +// Created by 轩辕十四 on 2026/7/20. +// + +package dev.astrolabe.protocol + +import kotlinx.serialization.Serializable + +/** Zero-based text range. */ +@Serializable +public data class RuntimeTextRange( + /** Zero-based text location. */ + public val location: Long, + /** Number of text units covered by the run. */ + public val length: Long +) { + init { + require(location >= 0 && length >= 0) { "Text range values cannot be negative" } + require(location <= MAXIMUM_SAFE_TEXT_INDEX && length <= MAXIMUM_SAFE_TEXT_INDEX) { + "Text range values exceed the JSON safe integer range" + } + } +} + +/** Styled text segment captured by the Runtime. */ +@Serializable +public data class RuntimeTextRun( + /** Range covered by this run. */ + public val range: RuntimeTextRange, + /** Text covered by this run. */ + public val text: String, + /** Platform font name when available. */ + public val fontName: String?, + /** Platform font-family name when available. */ + public val fontFamilyName: String?, + /** Font size and scaling unit when available. */ + public val fontSize: RuntimeMeasurement?, + /** Foreground color when available. */ + public val color: RuntimeColor?, + /** Namespaced platform-specific text-run facts. */ + public val extensions: RuntimeExtensionMap +) + +private const val MAXIMUM_SAFE_TEXT_INDEX: Long = 9_007_199_254_740_991 diff --git a/AstrolabeProtocolKotlin/src/main/kotlin/dev/astrolabe/protocol/inspection/RuntimeHierarchy.kt b/AstrolabeProtocolKotlin/src/main/kotlin/dev/astrolabe/protocol/inspection/RuntimeHierarchy.kt new file mode 100644 index 0000000..7bbec1c --- /dev/null +++ b/AstrolabeProtocolKotlin/src/main/kotlin/dev/astrolabe/protocol/inspection/RuntimeHierarchy.kt @@ -0,0 +1,214 @@ +// +// RuntimeHierarchy.kt +// astrolabe-protocol +// +// Created by 轩辕十四 on 2026/7/20. +// + +package dev.astrolabe.protocol + +import kotlinx.serialization.Serializable + +/** Platform runtime type and its ancestor chain. */ +@Serializable +public data class RuntimeType( + /** Most-specific platform runtime type name. */ + public val name: String, + /** Runtime ancestor names ordered from direct parent to root type. */ + public val ancestors: List +) { + init { + require(name.isNotEmpty() && name.length <= MAXIMUM_RUNTIME_TYPE_LENGTH) { + "Runtime type name must contain between 1 and 512 characters" + } + require(ancestors.all { it.isNotEmpty() && it.length <= MAXIMUM_RUNTIME_TYPE_LENGTH }) { + "Runtime type ancestors must contain between 1 and 512 characters" + } + } +} + +/** Node geometry in local, parent, and screen coordinate spaces. */ +@Serializable +public data class RuntimeNodeGeometry( + /** Node bounds in local logical coordinates. */ + public val bounds: RuntimeCoordinateRect, + /** Frame in direct-parent logical coordinates, or null for a root. */ + public val frameInParent: RuntimeCoordinateRect?, + /** Frame in screen logical coordinates. */ + public val frameInScreen: RuntimeCoordinateRect +) { + init { + require( + bounds.coordinateSpace == RuntimeCoordinateSpace.local && + bounds.unit == RuntimeMeasurementUnit.logical + ) { "Node bounds must use local logical coordinates" } + require( + frameInParent == null || + frameInParent.coordinateSpace == RuntimeCoordinateSpace.parent && + frameInParent.unit == RuntimeMeasurementUnit.logical + ) { "Parent frame must use parent logical coordinates" } + require( + frameInScreen.coordinateSpace == RuntimeCoordinateSpace.screen && + frameInScreen.unit == RuntimeMeasurementUnit.logical + ) { "Screen frame must use screen logical coordinates" } + } +} + +/** Explicit visibility causes and the derived final result. */ +@Serializable +public data class RuntimeNodeVisibility( + /** Whether the node explicitly hides itself. */ + public val hidden: Boolean, + /** Whether an ancestor explicitly hides the node. */ + public val hiddenByAncestor: Boolean, + /** Node-local opacity in the inclusive range from zero to one. */ + public val opacity: Double, + /** Opacity after ancestor effects in the inclusive range from zero to one. */ + public val effectiveOpacity: Double, + /** Whether the screen frame intersects the application viewport. */ + public val intersectsViewport: Boolean, + /** Whether ancestor clipping fully removes the node. */ + public val fullyClippedByAncestor: Boolean, + /** Final visibility result derived from all causes. */ + public val onscreen: Boolean +) { + init { + require(opacity in 0.0..1.0 && effectiveOpacity in 0.0..1.0) { + "Visibility opacity must be between zero and one" + } + val derivedOnscreen = !hidden && + !hiddenByAncestor && + effectiveOpacity > VISIBILITY_THRESHOLD && + intersectsViewport && + !fullyClippedByAncestor + require(onscreen == derivedOnscreen) { "Onscreen does not match the derived visibility state" } + } +} + +/** Accessibility facts exposed by one node. */ +@Serializable +public data class RuntimeAccessibility( + /** Whether the node is an accessibility element. */ + public val element: Boolean, + /** Developer-provided accessibility identifier. */ + public val identifier: String?, + /** Accessibility label. */ + public val label: String?, + /** Accessibility value. */ + public val value: String?, + /** Accessibility hint. */ + public val hint: String?, + /** Open normalized accessibility traits. */ + public val traits: List +) + +/** Normalized interaction state for one node. */ +@Serializable +public data class RuntimeInteraction( + /** Whether the node accepts direct interaction. */ + public val interactive: Boolean, + /** Enabled state when the role exposes one. */ + public val enabled: Boolean?, + /** Selected state when the role exposes one. */ + public val selected: Boolean?, + /** Focus state when the platform reports one. */ + public val focused: Boolean? +) + +/** One platform-neutral node captured in a runtime hierarchy. */ +@Serializable +public data class RuntimeNode( + /** Opaque node identifier scoped to the Runtime process. */ + public val nodeID: RuntimeOpaqueIdentifier, + /** Parent node identifier, or null for a hierarchy root. */ + public val parentID: RuntimeOpaqueIdentifier?, + /** Position in the parent's ordered child collection. */ + public val siblingIndex: Int, + /** Open platform-neutral semantic role. */ + public val role: String, + /** Platform runtime type facts. */ + public val runtimeType: RuntimeType, + /** Geometry in local, parent, and screen coordinate spaces. */ + public val geometry: RuntimeNodeGeometry, + /** Explicit visibility causes and final result. */ + public val visibility: RuntimeNodeVisibility, + /** Whether this node clips descendant content. */ + public val clipsContent: Boolean, + /** Resolved background color when available. */ + public val backgroundColor: RuntimeColor?, + /** Short text preview when available. */ + public val text: String?, + /** Accessibility facts when available. */ + public val accessibility: RuntimeAccessibility?, + /** Normalized interaction state. */ + public val interaction: RuntimeInteraction, + /** Namespaced detail categories available for this node. */ + public val availableDetailCategories: List, + /** Namespaced platform-specific node facts. */ + public val extensions: RuntimeExtensionMap, + /** Ordered child nodes captured in the same snapshot. */ + public val children: List +) { + init { + require(siblingIndex >= 0) { "Sibling index cannot be negative" } + require(role.isNotEmpty() && role.length <= MAXIMUM_NODE_ROLE_LENGTH) { + "Node role must contain between 1 and 128 characters" + } + require(availableDetailCategories.distinct().size == availableDetailCategories.size) { + "Available detail categories cannot contain duplicates" + } + } +} + +/** Empty parameters for capturing a hierarchy snapshot. */ +@Serializable +public data object RuntimeHierarchySnapshotParameters { + /** Typed request contract for the hierarchy-snapshot method. */ + public val contract: RuntimeMethodContract by lazy { + RuntimeMethodContract(RuntimeMethod.hierarchySnapshot, serializer()) + } +} + +/** Successful hierarchy-snapshot response payload. */ +@Serializable +public data class RuntimeHierarchySnapshotPayload( + /** Opaque identifier for this immutable hierarchy capture. */ + public val snapshotID: RuntimeOpaqueIdentifier, + /** Capture time in seconds since the Unix epoch. */ + public val capturedAtUnixTime: Double, + /** Process-instance identifier represented by this snapshot. */ + public val targetIdentifier: RuntimeOpaqueIdentifier, + /** Open interface-orientation identifier. */ + public val orientation: String, + /** Display facts used by the capture. */ + public val display: RuntimeDisplayInfo, + /** Application viewport in screen logical coordinates. */ + public val viewport: RuntimeCoordinateRect, + /** Ordered hierarchy roots. */ + public val roots: List, + /** Optional namespaced snapshot facts. */ + public val extensions: RuntimeExtensionMap? = null +) { + init { + require(capturedAtUnixTime >= 0.0) { "Hierarchy capture time cannot be negative" } + require(orientation.isNotEmpty() && orientation.length <= MAXIMUM_ORIENTATION_LENGTH) { + "Orientation must contain between 1 and 64 characters" + } + require( + viewport.coordinateSpace == RuntimeCoordinateSpace.screen && + viewport.unit == RuntimeMeasurementUnit.logical + ) { "Hierarchy viewport must use screen logical coordinates" } + } + + public companion object { + /** Typed success-payload contract for the hierarchy-snapshot method. */ + public val contract: RuntimeMethodContract by lazy { + RuntimeMethodContract(RuntimeMethod.hierarchySnapshot, serializer()) + } + } +} + +private const val VISIBILITY_THRESHOLD: Double = 0.01 +private const val MAXIMUM_RUNTIME_TYPE_LENGTH: Int = 512 +private const val MAXIMUM_NODE_ROLE_LENGTH: Int = 128 +private const val MAXIMUM_ORIENTATION_LENGTH: Int = 64 diff --git a/AstrolabeProtocolKotlin/src/main/kotlin/dev/astrolabe/protocol/inspection/RuntimeLayoutRelation.kt b/AstrolabeProtocolKotlin/src/main/kotlin/dev/astrolabe/protocol/inspection/RuntimeLayoutRelation.kt new file mode 100644 index 0000000..771844f --- /dev/null +++ b/AstrolabeProtocolKotlin/src/main/kotlin/dev/astrolabe/protocol/inspection/RuntimeLayoutRelation.kt @@ -0,0 +1,67 @@ +// +// RuntimeLayoutRelation.kt +// astrolabe-protocol +// +// Created by 轩辕十四 on 2026/7/20. +// + +package dev.astrolabe.protocol + +import kotlinx.serialization.Serializable + +/** Node-owned normalized layout anchor. */ +@Serializable +public data class RuntimeLayoutAnchor( + /** Node owning this layout anchor. */ + public val nodeID: RuntimeOpaqueIdentifier, + /** Open normalized anchor name. */ + public val anchor: String +) { + init { + require(anchor.isNotEmpty() && anchor.length <= MAXIMUM_LAYOUT_ANCHOR_LENGTH) { + "Layout anchor must contain between 1 and 128 characters" + } + } +} + +/** Closed comparison relation used by a layout constraint. */ +@Serializable +public enum class RuntimeLayoutRelationKind { + lessThanOrEqual, + equal, + greaterThanOrEqual +} + +/** Platform-neutral layout relation. */ +@Serializable +public data class RuntimeLayoutRelation( + /** Producer-defined relation identifier when available. */ + public val identifier: String?, + /** Source node anchor constrained by the relation. */ + public val source: RuntimeLayoutAnchor, + /** Closed comparison relation. */ + public val relation: RuntimeLayoutRelationKind, + /** Target node anchor, or null for a constant relation. */ + public val target: RuntimeLayoutAnchor?, + /** Target coefficient applied before offset. */ + public val multiplier: Double, + /** Logical-unit constant offset. */ + public val offset: RuntimeMeasurement, + /** Normalized relation strength when available. */ + public val strength: Double?, + /** Active state when reported by the platform. */ + public val active: Boolean?, + /** Namespaced platform-specific relation facts. */ + public val extensions: RuntimeExtensionMap +) { + init { + require(strength == null || strength in 0.0..1.0) { + "Layout relation strength must be between zero and one" + } + require(offset.unit == RuntimeMeasurementUnit.logical) { + "Layout relation offset must use logical units" + } + } +} + +private const val MAXIMUM_LAYOUT_ANCHOR_LENGTH: Int = 128 diff --git a/AstrolabeProtocolKotlin/src/main/kotlin/dev/astrolabe/protocol/inspection/RuntimeNodeDetail.kt b/AstrolabeProtocolKotlin/src/main/kotlin/dev/astrolabe/protocol/inspection/RuntimeNodeDetail.kt new file mode 100644 index 0000000..0898120 --- /dev/null +++ b/AstrolabeProtocolKotlin/src/main/kotlin/dev/astrolabe/protocol/inspection/RuntimeNodeDetail.kt @@ -0,0 +1,84 @@ +// +// RuntimeNodeDetail.kt +// astrolabe-protocol +// +// Created by 轩辕十四 on 2026/7/20. +// + +package dev.astrolabe.protocol + +import kotlinx.serialization.Serializable + +/** Namespaced semantic or platform attribute identifier. */ +@JvmInline +@Serializable +public value class RuntimeAttributeIdentifier( + /** Raw namespaced attribute identifier. */ + public val rawValue: String +) { + init { + require(RuntimeNamespacedIdentifier.isValid(rawValue)) { "Attribute identifier must be namespaced" } + } +} + +/** Namespaced category grouping related detail attributes. */ +@JvmInline +@Serializable +public value class RuntimeAttributeCategory( + /** Raw namespaced category identifier. */ + public val rawValue: String +) { + init { + require(RuntimeNamespacedIdentifier.isValid(rawValue)) { "Attribute category must be namespaced" } + } +} + +/** Parameters for reading one node's detail sections. */ +@Serializable +public data class RuntimeNodeDetailParameters( + /** Node whose detail sections are requested. */ + public val nodeID: RuntimeOpaqueIdentifier +) { + public companion object { + /** Typed request contract for the node-detail method. */ + public val contract: RuntimeMethodContract by lazy { + RuntimeMethodContract(RuntimeMethod.nodeDetail, serializer()) + } + } +} + +/** One typed runtime attribute. */ +@Serializable +public data class RuntimeAttribute( + /** Namespaced semantic or platform attribute identifier. */ + public val identifier: RuntimeAttributeIdentifier, + /** Typed attribute value. */ + public val value: RuntimeAttributeValue +) + +/** Ordered attributes belonging to one detail category. */ +@Serializable +public data class RuntimeAttributeSection( + /** Namespaced category grouping related attributes. */ + public val category: RuntimeAttributeCategory, + /** Attributes collected for this category. */ + public val attributes: List +) + +/** Successful node-detail response payload. */ +@Serializable +public data class RuntimeNodeDetailPayload( + /** Node represented by these detail sections. */ + public val nodeID: RuntimeOpaqueIdentifier, + /** Ordered semantic and platform detail sections. */ + public val sections: List, + /** Optional namespaced detail metadata. */ + public val extensions: RuntimeExtensionMap? = null +) { + public companion object { + /** Typed success-payload contract for the node-detail method. */ + public val contract: RuntimeMethodContract by lazy { + RuntimeMethodContract(RuntimeMethod.nodeDetail, serializer()) + } + } +} diff --git a/AstrolabeProtocolKotlin/src/main/kotlin/dev/astrolabe/protocol/messaging/RuntimeMessageCodec.kt b/AstrolabeProtocolKotlin/src/main/kotlin/dev/astrolabe/protocol/messaging/RuntimeMessageCodec.kt index 463ef0f..14bd26a 100644 --- a/AstrolabeProtocolKotlin/src/main/kotlin/dev/astrolabe/protocol/messaging/RuntimeMessageCodec.kt +++ b/AstrolabeProtocolKotlin/src/main/kotlin/dev/astrolabe/protocol/messaging/RuntimeMessageCodec.kt @@ -14,11 +14,11 @@ import kotlinx.serialization.encodeToString import kotlinx.serialization.json.Json import kotlinx.serialization.json.JsonElement import kotlinx.serialization.json.JsonObject +import kotlinx.serialization.json.JsonPrimitive import kotlinx.serialization.json.buildJsonObject import kotlinx.serialization.json.decodeFromJsonElement import kotlinx.serialization.json.encodeToJsonElement import kotlinx.serialization.json.jsonObject -import kotlinx.serialization.json.jsonPrimitive import kotlinx.serialization.json.put /** Encodes and decodes platform-neutral Astrolabe wire envelopes. */ @@ -38,6 +38,10 @@ public class RuntimeMessageCodec { json.decodeFromJsonElement(serializer, parseDocument(data)) } + /** Encodes a standalone value through an explicit wire serializer. */ + public fun encodeValue(value: T, serializer: KSerializer): ByteArray = + encodeDocument(json.encodeToJsonElement(serializer, value)) + /** Decodes and validates a request envelope. */ public fun decodeRequest(data: ByteArray): RuntimeRequestEnvelope = wrapDecode { val document = requireObject(parseDocument(data)) @@ -49,7 +53,27 @@ public class RuntimeMessageCodec { /** Encodes a request envelope. */ public fun encodeRequest(request: RuntimeRequestEnvelope): ByteArray { validateRequest(request) - return json.encodeToString(request).encodeToByteArray() + return encodeDocument(json.encodeToJsonElement(request)) + } + + /** Encodes a typed request without exposing JSON assembly to the caller. */ + public fun encodeRequest( + requestID: String, + contract: RuntimeMethodContract, + parameters: T + ): ByteArray { + val document = json.encodeToJsonElement(contract.serializer, parameters) + val parameterObject = runCatching { document.jsonObject }.getOrElse { error -> + throw RuntimeMessageException.InvalidEnvelope("Request parameters must encode as an object", error) + } + return encodeRequest( + RuntimeRequestEnvelope( + requestID = requestID, + protocolVersion = RuntimeProtocolVersion.V2, + method = contract.method, + parameters = parameterObject + ) + ) } /** Decodes method-specific request parameters from an already validated envelope. */ @@ -122,9 +146,39 @@ public class RuntimeMessageCodec { } } } - return json.encodeToString(document).encodeToByteArray() + return encodeDocument(document) } + /** Encodes a typed successful response without exposing JSON assembly to the caller. */ + public fun encodeSuccessResponse( + requestID: String, + contract: RuntimeMethodContract, + payload: T + ): ByteArray = encodeResponse( + RuntimeResponseEnvelope( + requestID = requestID, + protocolVersion = RuntimeProtocolVersion.V2, + method = contract.method, + outcome = RuntimeResponseOutcome.Success( + json.encodeToJsonElement(contract.serializer, payload) + ) + ) + ) + + /** Encodes a structured failure for one method. */ + public fun encodeFailureResponse( + requestID: String, + method: RuntimeMethod, + error: RuntimeError + ): ByteArray = encodeResponse( + RuntimeResponseEnvelope( + requestID = requestID, + protocolVersion = RuntimeProtocolVersion.V2, + method = method, + outcome = RuntimeResponseOutcome.Failure(error) + ) + ) + /** Decodes the successful payload from an already validated response envelope. */ public fun decodeSuccessPayload( response: RuntimeResponseEnvelope, @@ -143,6 +197,12 @@ public class RuntimeMessageCodec { json.parseToJsonElement(data.decodeToString()) } + private fun encodeDocument(document: JsonElement): ByteArray = wrapEncode { + val data = json.encodeToString(document).encodeToByteArray() + documentValidator.validate(data) + data + } + private fun requireObject(document: JsonElement): JsonObject = runCatching { document.jsonObject }.getOrElse { error -> throw RuntimeMessageException.InvalidDocument("Wire message root must be an object", error) @@ -174,10 +234,12 @@ public class RuntimeMessageCodec { private fun JsonObject.requireMember(name: String): JsonElement = this[name] ?: throw RuntimeMessageException.InvalidEnvelope("Missing required member: $name") - private fun JsonObject.requireString(name: String): String = try { - requireMember(name).jsonPrimitive.content - } catch (error: IllegalArgumentException) { - throw RuntimeMessageException.InvalidEnvelope("Member $name must be a string", error) + private fun JsonObject.requireString(name: String): String { + val primitive = requireMember(name) as? JsonPrimitive + if (primitive == null || !primitive.isString) { + throw RuntimeMessageException.InvalidEnvelope("Member $name must be a string") + } + return primitive.content } private inline fun wrapDecode(operation: () -> T): T = try { @@ -189,6 +251,16 @@ public class RuntimeMessageCodec { } catch (error: IllegalArgumentException) { throw RuntimeMessageException.InvalidDocument("Unable to decode JSON document", error) } + + private inline fun wrapEncode(operation: () -> T): T = try { + operation() + } catch (error: RuntimeMessageException) { + throw error + } catch (error: SerializationException) { + throw RuntimeMessageException.InvalidDocument("Unable to encode JSON document", error) + } catch (error: IllegalArgumentException) { + throw RuntimeMessageException.InvalidDocument("Unable to encode JSON document", error) + } } /** Failures produced while reading or writing a wire message. */ diff --git a/AstrolabeProtocolKotlin/src/main/kotlin/dev/astrolabe/protocol/negotiation/RuntimeMethod.kt b/AstrolabeProtocolKotlin/src/main/kotlin/dev/astrolabe/protocol/negotiation/RuntimeMethod.kt index 9e02be1..61f6a55 100644 --- a/AstrolabeProtocolKotlin/src/main/kotlin/dev/astrolabe/protocol/negotiation/RuntimeMethod.kt +++ b/AstrolabeProtocolKotlin/src/main/kotlin/dev/astrolabe/protocol/negotiation/RuntimeMethod.kt @@ -48,7 +48,7 @@ public value class RuntimeMethod( } /** Binds one method identifier to the serializer for its parameters or payload. */ -public data class RuntimeMethodContract( +public class RuntimeMethodContract( /** Method accepted by this contract. */ public val method: RuntimeMethod, /** Serializer for the method-specific value. */ diff --git a/AstrolabeProtocolKotlin/src/main/kotlin/dev/astrolabe/protocol/patching/RuntimeAttributePatch.kt b/AstrolabeProtocolKotlin/src/main/kotlin/dev/astrolabe/protocol/patching/RuntimeAttributePatch.kt new file mode 100644 index 0000000..351f918 --- /dev/null +++ b/AstrolabeProtocolKotlin/src/main/kotlin/dev/astrolabe/protocol/patching/RuntimeAttributePatch.kt @@ -0,0 +1,149 @@ +// +// RuntimeAttributePatch.kt +// astrolabe-protocol +// +// Created by 轩辕十四 on 2026/7/20. +// + +package dev.astrolabe.protocol + +import kotlinx.serialization.Serializable + +/** Parameters for applying one temporary attribute patch. */ +@Serializable +public data class RuntimeApplyAttributePatchParameters( + /** Node receiving the temporary patch. */ + public val nodeID: RuntimeOpaqueIdentifier, + /** Namespaced attribute to patch. */ + public val attributeIdentifier: RuntimeAttributeIdentifier, + /** Requested typed value. */ + public val value: RuntimeAttributeValue +) { + public companion object { + /** Typed request contract for applying an attribute patch. */ + public val contract: RuntimeMethodContract by lazy { + RuntimeMethodContract(RuntimeMethod.applyAttributePatch, serializer()) + } + } +} + +/** Active temporary attribute patch. */ +@Serializable +public data class RuntimeAttributePatch( + /** Opaque identifier for this active patch. */ + public val patchID: RuntimeOpaqueIdentifier, + /** Patched node identifier. */ + public val nodeID: RuntimeOpaqueIdentifier, + /** Patched namespaced attribute. */ + public val attributeIdentifier: RuntimeAttributeIdentifier, + /** Value captured before the first patch when available. */ + public val originalValue: RuntimeAttributeValue?, + /** Value requested by the Host. */ + public val requestedValue: RuntimeAttributeValue, + /** Value observed after applying the patch when available. */ + public val actualValue: RuntimeAttributeValue?, + /** Apply time in seconds since the Unix epoch. */ + public val appliedAtUnixTime: Double +) { + init { + require(appliedAtUnixTime >= 0.0) { "Patch apply time cannot be negative" } + } + + public companion object { + /** Typed success-payload contract for applying an attribute patch. */ + public val applyContract: RuntimeMethodContract by lazy { + RuntimeMethodContract(RuntimeMethod.applyAttributePatch, serializer()) + } + } +} + +/** Empty parameters for listing active attribute patches. */ +@Serializable +public data object RuntimeListAttributePatchesParameters { + /** Typed request contract for listing active attribute patches. */ + public val contract: RuntimeMethodContract by lazy { + RuntimeMethodContract(RuntimeMethod.listAttributePatches, serializer()) + } +} + +/** Successful list-attribute-patches response payload. */ +@Serializable +public data class RuntimeAttributePatchListPayload( + /** Active temporary patches in Runtime-defined order. */ + public val patches: List +) { + public companion object { + /** Typed success-payload contract for listing attribute patches. */ + public val contract: RuntimeMethodContract by lazy { + RuntimeMethodContract(RuntimeMethod.listAttributePatches, serializer()) + } + } +} + +/** Parameters for reverting one active attribute patch. */ +@Serializable +public data class RuntimeRevertAttributePatchParameters( + /** Active patch to revert. */ + public val patchID: RuntimeOpaqueIdentifier +) { + public companion object { + /** Typed request contract for reverting an attribute patch. */ + public val contract: RuntimeMethodContract by lazy { + RuntimeMethodContract(RuntimeMethod.revertAttributePatch, serializer()) + } + } +} + +/** Successful revert-attribute-patch response payload. */ +@Serializable +public data class RuntimeRevertAttributePatchPayload( + /** Identifier of the reverted patch. */ + public val revertedPatchID: RuntimeOpaqueIdentifier, + /** Value restored by the Runtime when available. */ + public val restoredValue: RuntimeAttributeValue?, + /** Number of patches remaining after the operation. */ + public val remainingPatchCount: Int +) { + init { + require(remainingPatchCount >= 0) { "Remaining patch count cannot be negative" } + } + + public companion object { + /** Typed success-payload contract for reverting an attribute patch. */ + public val contract: RuntimeMethodContract by lazy { + RuntimeMethodContract(RuntimeMethod.revertAttributePatch, serializer()) + } + } +} + +/** Empty parameters for clearing all active attribute patches. */ +@Serializable +public data object RuntimeClearAttributePatchesParameters { + /** Typed request contract for clearing attribute patches. */ + public val contract: RuntimeMethodContract by lazy { + RuntimeMethodContract(RuntimeMethod.clearAttributePatches, serializer()) + } +} + +/** Successful clear-attribute-patches response payload. */ +@Serializable +public data class RuntimeClearAttributePatchesPayload( + /** Patch identifiers successfully reverted by the clear operation. */ + public val revertedPatchIDs: List, + /** Number of patches remaining after all revert attempts. */ + public val remainingPatchCount: Int +) { + init { + require(remainingPatchCount >= 0) { "Remaining patch count cannot be negative" } + require(revertedPatchIDs.distinct().size == revertedPatchIDs.size) { + "Reverted patch identifiers cannot contain duplicates" + } + } + + public companion object { + /** Typed success-payload contract for clearing attribute patches. */ + public val contract: RuntimeMethodContract by lazy { + RuntimeMethodContract(RuntimeMethod.clearAttributePatches, serializer()) + } + } +} diff --git a/AstrolabeProtocolKotlin/src/main/kotlin/dev/astrolabe/protocol/patching/RuntimePatchableAttribute.kt b/AstrolabeProtocolKotlin/src/main/kotlin/dev/astrolabe/protocol/patching/RuntimePatchableAttribute.kt new file mode 100644 index 0000000..f099921 --- /dev/null +++ b/AstrolabeProtocolKotlin/src/main/kotlin/dev/astrolabe/protocol/patching/RuntimePatchableAttribute.kt @@ -0,0 +1,128 @@ +// +// RuntimePatchableAttribute.kt +// astrolabe-protocol +// +// Created by 轩辕十四 on 2026/7/20. +// + +package dev.astrolabe.protocol + +import kotlinx.serialization.Serializable + +/** Common or namespaced attribute-value type accepted by a patch. */ +@JvmInline +@Serializable +public value class RuntimePatchValueType( + /** Raw value-type identifier. */ + public val rawValue: String +) { + init { + require(rawValue in COMMON_PATCH_VALUE_TYPES || RuntimeNamespacedIdentifier.isValid(rawValue)) { + "Patch value type must be common or namespaced" + } + } +} + +/** Optional value-level constraints for one patchable attribute. */ +@Serializable +public data class RuntimePatchValueConstraints( + /** Inclusive or exclusive numeric lower bound when present. */ + public val minimum: Double?, + /** Inclusive or exclusive numeric upper bound when present. */ + public val maximum: Double?, + /** Whether the minimum bound is exclusive. */ + public val minimumExclusive: Boolean, + /** Whether the maximum bound is exclusive. */ + public val maximumExclusive: Boolean, + /** Accepted producer-defined string formats. */ + public val acceptedFormats: List, + /** Explicit values accepted by the Runtime. */ + public val allowedValues: List +) { + init { + require(minimum == null || maximum == null || minimum <= maximum) { + "Patch value minimum cannot exceed maximum" + } + require(acceptedFormats.all(String::isNotEmpty)) { "Accepted patch formats cannot be empty" } + require(acceptedFormats.distinct().size == acceptedFormats.size) { + "Accepted patch formats cannot contain duplicates" + } + } +} + +/** Runtime-owned description of one patchable attribute family. */ +@Serializable +public data class RuntimePatchableAttribute( + /** Namespaced attribute path or placeholder pattern. */ + public val attributePattern: String, + /** Value type accepted by the attribute. */ + public val valueType: RuntimePatchValueType, + /** Semantic node roles eligible for the patch. */ + public val targetRoles: List, + /** Optional value-level constraints. */ + public val valueConstraints: RuntimePatchValueConstraints?, + /** Namespaced platform-specific applicability facts. */ + public val extensions: RuntimeExtensionMap +) { + init { + require( + attributePattern.length <= MAXIMUM_PATCH_ATTRIBUTE_PATTERN_LENGTH && + PATCH_ATTRIBUTE_PATTERN.matches(attributePattern) + ) { + "Patch attribute pattern must be namespaced" + } + require(targetRoles.all { it.isNotEmpty() && it.length <= MAXIMUM_PATCH_ROLE_LENGTH }) { + "Patch target roles must contain between 1 and 128 characters" + } + require(targetRoles.distinct().size == targetRoles.size) { + "Patch target roles cannot contain duplicates" + } + } +} + +/** Empty parameters for discovering patchable attributes. */ +@Serializable +public data object RuntimePatchableAttributesParameters { + /** Typed request contract for the patchable-attributes method. */ + public val contract: RuntimeMethodContract by lazy { + RuntimeMethodContract(RuntimeMethod.patchableAttributes, serializer()) + } +} + +/** Successful patchable-attributes response payload. */ +@Serializable +public data class RuntimePatchableAttributesPayload( + /** Runtime-owned catalog of patchable attributes. */ + public val attributes: List +) { + public companion object { + /** Typed success-payload contract for the patchable-attributes method. */ + public val contract: RuntimeMethodContract by lazy { + RuntimeMethodContract(RuntimeMethod.patchableAttributes, serializer()) + } + } +} + +private val COMMON_PATCH_VALUE_TYPES: Set = setOf( + "null", + "boolean", + "integer", + "number", + "string", + "stringList", + "measurement", + "point", + "size", + "vector", + "rect", + "insets", + "color", + "textRuns", + "layoutRelations", + "array", + "object" +) +private val PATCH_ATTRIBUTE_PATTERN: Regex = + Regex("^[a-z][a-z0-9-]*(\\.(?:[A-Za-z][A-Za-z0-9_-]*|<[A-Za-z][A-Za-z0-9_-]*>))+$") +private const val MAXIMUM_PATCH_ATTRIBUTE_PATTERN_LENGTH: Int = 256 +private const val MAXIMUM_PATCH_ROLE_LENGTH: Int = 128 diff --git a/AstrolabeProtocolKotlin/src/test/kotlin/dev/astrolabe/protocol/contract/ProtocolFixtureConformanceTest.kt b/AstrolabeProtocolKotlin/src/test/kotlin/dev/astrolabe/protocol/contract/ProtocolFixtureConformanceTest.kt new file mode 100644 index 0000000..e45160c --- /dev/null +++ b/AstrolabeProtocolKotlin/src/test/kotlin/dev/astrolabe/protocol/contract/ProtocolFixtureConformanceTest.kt @@ -0,0 +1,124 @@ +// +// ProtocolFixtureConformanceTest.kt +// astrolabe-protocol +// +// Created by 轩辕十四 on 2026/7/20. +// + +package dev.astrolabe.protocol + +import java.io.File +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertFailsWith + +class ProtocolFixtureConformanceTest { + private val codec = RuntimeMessageCodec() + + @Test + fun `every valid fixture has an explicit Kotlin conformance path`() { + val cases = mapOf Unit>( + "application-info-request.json" to { decodeRequest(RuntimeApplicationInfoParameters.contract, it) }, + "application-info-response.json" to { decodeResponse(RuntimeApplicationInfoPayload.contract, it) }, + "apply-attribute-patch-request.json" to { decodeRequest(RuntimeApplyAttributePatchParameters.contract, it) }, + "apply-attribute-patch-response.json" to { decodeResponse(RuntimeAttributePatch.applyContract, it) }, + "cancel-request.json" to { decodeRequest(RuntimeCancelRequestParameters.contract, it) }, + "cancel-response.json" to { decodeResponse(RuntimeCancelRequestPayload.contract, it) }, + "clear-attribute-patches-request.json" to { decodeRequest(RuntimeClearAttributePatchesParameters.contract, it) }, + "clear-attribute-patches-response.json" to { decodeResponse(RuntimeClearAttributePatchesPayload.contract, it) }, + "handshake-request.json" to { decodeRequest(RuntimeHandshakeParameters.contract, it) }, + "handshake-response.json" to { decodeResponse(RuntimeHandshakePayload.contract, it) }, + "hierarchy-snapshot-request.json" to { decodeRequest(RuntimeHierarchySnapshotParameters.contract, it) }, + "hierarchy-snapshot-response.json" to { decodeResponse(RuntimeHierarchySnapshotPayload.contract, it) }, + "list-attribute-patches-request.json" to { decodeRequest(RuntimeListAttributePatchesParameters.contract, it) }, + "list-attribute-patches-response.json" to { decodeResponse(RuntimeAttributePatchListPayload.contract, it) }, + "node-detail-failure-response.json" to { codec.decodeResponse(it) }, + "node-detail-request.json" to { decodeRequest(RuntimeNodeDetailParameters.contract, it) }, + "node-detail-response.json" to { decodeResponse(RuntimeNodeDetailPayload.contract, it) }, + "patchable-attributes-request.json" to { decodeRequest(RuntimePatchableAttributesParameters.contract, it) }, + "patchable-attributes-response.json" to { decodeResponse(RuntimePatchableAttributesPayload.contract, it) }, + "revert-attribute-patch-request.json" to { decodeRequest(RuntimeRevertAttributePatchParameters.contract, it) }, + "revert-attribute-patch-response.json" to { decodeResponse(RuntimeRevertAttributePatchPayload.contract, it) }, + "unknown-method-failure-response.json" to { codec.decodeResponse(it) }, + "unknown-method-request.json" to { codec.decodeRequest(it) }, + "vector-attribute-value.json" to { codec.decodeValue(it, RuntimeAttributeValue.serializer()) } + ) + + assertEquals(fixtureNames("v2/valid"), cases.keys) + cases.forEach { (name, operation) -> operation(fixture("v2/valid/$name")) } + } + + @Test + fun `every invalid fixture has an explicit Kotlin rejection path`() { + val cases = mapOf Unit>( + "attribute-identifier-not-namespaced.json" to { source -> + decodeRequest(RuntimeApplyAttributePatchParameters.contract, source) + }, + "coordinate-rect-missing-unit.json" to { source -> + codec.decodeValue(source, RuntimeCoordinateRect.serializer()) + }, + "descending-protocol-range.json" to { source -> + decodeRequest(RuntimeHandshakeParameters.contract, source) + }, + "display-size-missing-unit.json" to { source -> + codec.decodeValue(source, RuntimeDisplayInfo.serializer()) + }, + "extension-key-not-namespaced.json" to { source -> + codec.decodeValue(source, RuntimeExtensionMap.serializer()) + }, + "failure-response-contains-payload.json" to { source -> codec.decodeResponse(source) }, + "inconsistent-visibility.json" to { source -> + codec.decodeValue(source, RuntimeNodeVisibility.serializer()) + }, + "node-id-is-number.json" to { source -> + decodeRequest(RuntimeNodeDetailParameters.contract, source) + }, + "patch-pattern-not-namespaced.json" to { source -> + codec.decodeValue(source, RuntimePatchableAttribute.serializer()) + }, + "request-missing-request-id.json" to { source -> codec.decodeRequest(source) }, + "request-uses-v1.json" to { source -> codec.decodeRequest(source) }, + "success-response-contains-error.json" to { source -> codec.decodeResponse(source) }, + "unknown-response-status.json" to { source -> codec.decodeResponse(source) }, + "unsafe-integer-attribute-value.json" to { source -> + decodeRequest(RuntimeApplyAttributePatchParameters.contract, source) + } + ) + + assertEquals(fixtureNames("v2/invalid"), cases.keys) + cases.forEach { (name, operation) -> + assertFailsWith { + operation(fixture("v2/invalid/$name")) + } + } + } + + private fun decodeRequest(contract: RuntimeMethodContract, source: ByteArray) { + val request = codec.decodeRequest(source) + val parameters = codec.decodeRequestParameters(request, contract) + val encoded = codec.encodeRequest(request.requestID, contract, parameters) + codec.decodeRequestParameters(codec.decodeRequest(encoded), contract) + } + + private fun decodeResponse(contract: RuntimeMethodContract, source: ByteArray) { + val response = codec.decodeResponse(source) + val payload = codec.decodeSuccessPayload(response, contract) + val encoded = codec.encodeSuccessResponse(response.requestID, contract, payload) + codec.decodeSuccessPayload(codec.decodeResponse(encoded), contract) + } + + private fun fixtureNames(path: String): Set = fixtureDirectory(path) + .listFiles() + .orEmpty() + .filter { it.extension == "json" } + .map { it.name } + .toSet() + + private fun fixture(path: String): ByteArray = checkNotNull( + javaClass.classLoader.getResource(path) + ).readBytes() + + private fun fixtureDirectory(path: String): File = File( + checkNotNull(javaClass.classLoader.getResource(path)).toURI() + ) +} diff --git a/AstrolabeProtocolKotlin/src/test/kotlin/dev/astrolabe/protocol/inspection/RuntimeHierarchyModelTest.kt b/AstrolabeProtocolKotlin/src/test/kotlin/dev/astrolabe/protocol/inspection/RuntimeHierarchyModelTest.kt new file mode 100644 index 0000000..eafa4ff --- /dev/null +++ b/AstrolabeProtocolKotlin/src/test/kotlin/dev/astrolabe/protocol/inspection/RuntimeHierarchyModelTest.kt @@ -0,0 +1,48 @@ +// +// RuntimeHierarchyModelTest.kt +// astrolabe-protocol +// +// Created by 轩辕十四 on 2026/7/20. +// + +package dev.astrolabe.protocol + +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertFailsWith + +class RuntimeHierarchyModelTest { + private val codec = RuntimeMessageCodec() + + @Test + fun `hierarchy fixtures decode through typed contracts`() { + val request = codec.decodeRequest(fixture("v2/valid/hierarchy-snapshot-request.json")) + val response = codec.decodeResponse(fixture("v2/valid/hierarchy-snapshot-response.json")) + + codec.decodeRequestParameters(request, RuntimeHierarchySnapshotParameters.contract) + val payload = codec.decodeSuccessPayload(response, RuntimeHierarchySnapshotPayload.contract) + + assertEquals("node:window:0", payload.roots.single().nodeID.rawValue) + assertEquals("node:label:1", payload.roots.single().children.single().nodeID.rawValue) + } + + @Test + fun `hierarchy values reject missing units and inconsistent visibility`() { + assertFailsWith { + codec.decodeValue( + fixture("v2/invalid/coordinate-rect-missing-unit.json"), + RuntimeCoordinateRect.serializer() + ) + } + assertFailsWith { + codec.decodeValue( + fixture("v2/invalid/inconsistent-visibility.json"), + RuntimeNodeVisibility.serializer() + ) + } + } + + private fun fixture(path: String): ByteArray = checkNotNull( + javaClass.classLoader.getResource(path) + ).readBytes() +} diff --git a/AstrolabeProtocolKotlin/src/test/kotlin/dev/astrolabe/protocol/inspection/RuntimeNodeDetailModelTest.kt b/AstrolabeProtocolKotlin/src/test/kotlin/dev/astrolabe/protocol/inspection/RuntimeNodeDetailModelTest.kt new file mode 100644 index 0000000..76f45fe --- /dev/null +++ b/AstrolabeProtocolKotlin/src/test/kotlin/dev/astrolabe/protocol/inspection/RuntimeNodeDetailModelTest.kt @@ -0,0 +1,135 @@ +// +// RuntimeNodeDetailModelTest.kt +// astrolabe-protocol +// +// Created by 轩辕十四 on 2026/7/20. +// + +package dev.astrolabe.protocol + +import kotlinx.serialization.json.JsonArray +import kotlinx.serialization.json.JsonObject +import kotlinx.serialization.json.JsonPrimitive +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertFailsWith +import kotlin.test.assertIs + +class RuntimeNodeDetailModelTest { + private val codec = RuntimeMessageCodec() + + @Test + fun `node detail fixtures decode through typed contracts`() { + val request = codec.decodeRequest(fixture("v2/valid/node-detail-request.json")) + val response = codec.decodeResponse(fixture("v2/valid/node-detail-response.json")) + + val parameters = codec.decodeRequestParameters(request, RuntimeNodeDetailParameters.contract) + val payload = codec.decodeSuccessPayload(response, RuntimeNodeDetailPayload.contract) + + assertEquals(parameters.nodeID, payload.nodeID) + assertEquals("common.text", payload.sections.first().category.rawValue) + } + + @Test + fun `node identifiers and attribute identifiers retain wire constraints`() { + assertFailsWith { + val request = codec.decodeRequest(fixture("v2/invalid/node-id-is-number.json")) + codec.decodeRequestParameters(request, RuntimeNodeDetailParameters.contract) + } + assertFailsWith { + RuntimeAttributeIdentifier("content") + } + } + + @Test + fun `standalone vector attribute values decode with signed components`() { + val value = codec.decodeValue( + fixture("v2/valid/vector-attribute-value.json"), + RuntimeAttributeValue.serializer() + ) + + assertEquals(RuntimeAttributeValue.Vector(RuntimeVector(-2.0, 4.0, RuntimeMeasurementUnit.logical)), value) + } + + @Test + fun `attribute value discriminator must be a string`() { + val error = assertFailsWith { + codec.decodeValue( + """{"type":1,"value":"opaque"}""".encodeToByteArray(), + RuntimeAttributeValue.serializer() + ) + } + + assertIs(error.cause) + } + + @Test + fun `integer attribute value must contain a JSON number`() { + assertEquals( + RuntimeAttributeValue.Integer(42), + codec.decodeValue( + """{"type":"integer","value":42.0}""".encodeToByteArray(), + RuntimeAttributeValue.serializer() + ) + ) + assertFailsWith { + codec.decodeValue( + """{"type":"integer","value":"42"}""".encodeToByteArray(), + RuntimeAttributeValue.serializer() + ) + } + assertFailsWith { + codec.decodeValue( + """{"type":"integer","value":42.000000000000001}""".encodeToByteArray(), + RuntimeAttributeValue.serializer() + ) + } + } + + @Test + fun `common and extension attribute values round trip symmetrically`() { + val values = listOf( + RuntimeAttributeValue.Null, + RuntimeAttributeValue.BooleanValue(true), + RuntimeAttributeValue.Integer(42), + RuntimeAttributeValue.Number(1.5), + RuntimeAttributeValue.StringValue("value"), + RuntimeAttributeValue.StringList(listOf("a", "b")), + RuntimeAttributeValue.Measurement(RuntimeMeasurement(12.0, RuntimeMeasurementUnit.logical)), + RuntimeAttributeValue.Point( + RuntimeCoordinatePoint(1.0, 2.0, RuntimeCoordinateSpace.screen, RuntimeMeasurementUnit.logical) + ), + RuntimeAttributeValue.Size(RuntimeMeasuredSize(3.0, 4.0, RuntimeMeasurementUnit.pixel)), + RuntimeAttributeValue.Vector(RuntimeVector(-2.0, 4.0, RuntimeMeasurementUnit.logical)), + RuntimeAttributeValue.Rect( + RuntimeCoordinateRect( + 0.0, + 0.0, + 10.0, + 20.0, + RuntimeCoordinateSpace.local, + RuntimeMeasurementUnit.logical + ) + ), + RuntimeAttributeValue.Insets( + RuntimeInsets(1.0, 2.0, 3.0, 4.0, RuntimeMeasurementUnit.logical) + ), + RuntimeAttributeValue.Color(RuntimeColor("srgb", 0.1, 0.2, 0.3, 1.0)), + RuntimeAttributeValue.ArrayValue(JsonArray(listOf(JsonPrimitive("item")))), + RuntimeAttributeValue.ObjectValue(JsonObject(mapOf("key" to JsonPrimitive(true)))), + RuntimeAttributeValue.Extension( + RuntimeNamespacedIdentifier("vendor.custom.value"), + JsonPrimitive("opaque") + ) + ) + + values.forEach { value -> + val encoded = codec.encodeValue(value, RuntimeAttributeValue.serializer()) + assertEquals(value, codec.decodeValue(encoded, RuntimeAttributeValue.serializer())) + } + } + + private fun fixture(path: String): ByteArray = checkNotNull( + javaClass.classLoader.getResource(path) + ).readBytes() +} diff --git a/AstrolabeProtocolKotlin/src/test/kotlin/dev/astrolabe/protocol/messaging/RuntimeMessageCodecTest.kt b/AstrolabeProtocolKotlin/src/test/kotlin/dev/astrolabe/protocol/messaging/RuntimeMessageCodecTest.kt index 44e633a..d411e31 100644 --- a/AstrolabeProtocolKotlin/src/test/kotlin/dev/astrolabe/protocol/messaging/RuntimeMessageCodecTest.kt +++ b/AstrolabeProtocolKotlin/src/test/kotlin/dev/astrolabe/protocol/messaging/RuntimeMessageCodecTest.kt @@ -54,6 +54,17 @@ class RuntimeMessageCodecTest { assertFailsWith { codec.decodeRequest("[]".encodeToByteArray()) } + assertFailsWith { + codec.decodeResponse( + """{ + "requestID":"00000000-0000-4000-8000-000000000001", + "protocolVersion":{"major":2,"minor":0}, + "method":1, + "status":"success", + "payload":{} + }""".encodeToByteArray() + ) + } } @Test diff --git a/AstrolabeProtocolKotlin/src/test/kotlin/dev/astrolabe/protocol/negotiation/RuntimeSessionModelTest.kt b/AstrolabeProtocolKotlin/src/test/kotlin/dev/astrolabe/protocol/negotiation/RuntimeSessionModelTest.kt index 21fcd15..a610be5 100644 --- a/AstrolabeProtocolKotlin/src/test/kotlin/dev/astrolabe/protocol/negotiation/RuntimeSessionModelTest.kt +++ b/AstrolabeProtocolKotlin/src/test/kotlin/dev/astrolabe/protocol/negotiation/RuntimeSessionModelTest.kt @@ -8,6 +8,7 @@ package dev.astrolabe.protocol import java.io.File +import kotlinx.serialization.json.JsonPrimitive import kotlin.test.Test import kotlin.test.assertEquals import kotlin.test.assertFailsWith @@ -42,6 +43,30 @@ class RuntimeSessionModelTest { assertEquals("astrolabe.runtime.ios", payload.runtime.identifier.rawValue) } + @Test + fun `typed handshake values round trip without exposing JSON assembly`() { + val sourceRequest = codec.decodeRequest(fixture("v2/valid/handshake-request.json")) + val parameters = codec.decodeRequestParameters(sourceRequest, RuntimeHandshakeParameters.contract) + val encodedRequest = codec.encodeRequest( + requestID = sourceRequest.requestID, + contract = RuntimeHandshakeParameters.contract, + parameters = parameters + ) + + val sourceResponse = codec.decodeResponse(fixture("v2/valid/handshake-response.json")) + val payload = codec.decodeSuccessPayload(sourceResponse, RuntimeHandshakePayload.contract) + val encodedResponse = codec.encodeSuccessResponse( + requestID = sourceResponse.requestID, + contract = RuntimeHandshakePayload.contract, + payload = payload + ) + + val decodedRequest = codec.decodeRequest(encodedRequest) + val decodedResponse = codec.decodeResponse(encodedResponse) + assertEquals(parameters, codec.decodeRequestParameters(decodedRequest, RuntimeHandshakeParameters.contract)) + assertEquals(payload, codec.decodeSuccessPayload(decodedResponse, RuntimeHandshakePayload.contract)) + } + @Test fun `protocol range rejects descending and cross-major values`() { assertFailsWith { @@ -92,6 +117,20 @@ class RuntimeSessionModelTest { assertFailsWith { RuntimeExtensionMap(mapOf("invalid" to kotlinx.serialization.json.JsonNull)) } + assertFailsWith { + codec.decodeValue( + fixture("v2/invalid/extension-key-not-namespaced.json"), + RuntimeExtensionMap.serializer() + ) + } + assertFailsWith { + codec.encodeValue( + RuntimeExtensionMap( + mapOf("vendor.unsafeInteger" to JsonPrimitive(9_007_199_254_740_992L)) + ), + RuntimeExtensionMap.serializer() + ) + } } private fun fixture(path: String): ByteArray = File( diff --git a/AstrolabeProtocolKotlin/src/test/kotlin/dev/astrolabe/protocol/patching/RuntimePatchingModelTest.kt b/AstrolabeProtocolKotlin/src/test/kotlin/dev/astrolabe/protocol/patching/RuntimePatchingModelTest.kt new file mode 100644 index 0000000..de47e2b --- /dev/null +++ b/AstrolabeProtocolKotlin/src/test/kotlin/dev/astrolabe/protocol/patching/RuntimePatchingModelTest.kt @@ -0,0 +1,90 @@ +// +// RuntimePatchingModelTest.kt +// astrolabe-protocol +// +// Created by 轩辕十四 on 2026/7/20. +// + +package dev.astrolabe.protocol + +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertFailsWith + +class RuntimePatchingModelTest { + private val codec = RuntimeMessageCodec() + + @Test + fun `every patch request and response fixture decodes through its typed contract`() { + decodeRoundTrip( + "apply-attribute-patch", + RuntimeApplyAttributePatchParameters.contract, + RuntimeAttributePatch.applyContract + ) + decodeRoundTrip( + "list-attribute-patches", + RuntimeListAttributePatchesParameters.contract, + RuntimeAttributePatchListPayload.contract + ) + decodeRoundTrip( + "revert-attribute-patch", + RuntimeRevertAttributePatchParameters.contract, + RuntimeRevertAttributePatchPayload.contract + ) + decodeRoundTrip( + "clear-attribute-patches", + RuntimeClearAttributePatchesParameters.contract, + RuntimeClearAttributePatchesPayload.contract + ) + decodeRoundTrip( + "patchable-attributes", + RuntimePatchableAttributesParameters.contract, + RuntimePatchableAttributesPayload.contract + ) + } + + @Test + fun `patch identifiers and patterns reject ambiguous values`() { + val request = codec.decodeRequest(fixture("v2/invalid/attribute-identifier-not-namespaced.json")) + + assertFailsWith { + codec.decodeRequestParameters(request, RuntimeApplyAttributePatchParameters.contract) + } + assertFailsWith { + codec.decodeValue( + fixture("v2/invalid/patch-pattern-not-namespaced.json"), + RuntimePatchableAttribute.serializer() + ) + } + assertFailsWith { + val unsafeRequest = codec.decodeRequest( + fixture("v2/invalid/unsafe-integer-attribute-value.json") + ) + codec.decodeRequestParameters(unsafeRequest, RuntimeApplyAttributePatchParameters.contract) + } + } + + @Test + fun `patch count cannot be negative`() { + assertFailsWith { + RuntimeClearAttributePatchesPayload(emptyList(), -1) + } + } + + private fun decodeRoundTrip( + fixturePrefix: String, + requestContract: RuntimeMethodContract, + payloadContract: RuntimeMethodContract + ) { + val request = codec.decodeRequest(fixture("v2/valid/$fixturePrefix-request.json")) + val response = codec.decodeResponse(fixture("v2/valid/$fixturePrefix-response.json")) + + codec.decodeRequestParameters(request, requestContract) + codec.decodeSuccessPayload(response, payloadContract) + assertEquals(request.method, response.method) + } + + private fun fixture(path: String): ByteArray = checkNotNull( + javaClass.classLoader.getResource(path) + ).readBytes() +} From 3db4006843b859b4c0162f832404095c110ff7e0 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E8=BD=A9=E8=BE=95=E5=8D=81=E5=9B=9B?= Date: Mon, 20 Jul 2026 22:26:09 +0800 Subject: [PATCH 4/7] build: add Kotlin Maven publishing --- .github/workflows/ci.yml | 43 ++++++++++++++++ AstrolabeProtocolKotlin/build.gradle.kts | 65 +++++++++++++++++++++--- build.gradle.kts | 1 + gradle/libs.versions.toml | 2 + scripts/release-prepare.mjs | 4 +- test/release-prepare.test.mjs | 3 +- 6 files changed, 109 insertions(+), 9 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 7335d89..74a6f6c 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -79,3 +79,46 @@ jobs: - name: Build Kotlin publication run: ./gradlew :AstrolabeProtocolKotlin:publishToMavenLocal + + publish-kotlin: + name: Publish Kotlin artifact + if: startsWith(github.ref, 'refs/tags/') + needs: + - contract + - swift + - kotlin + runs-on: ubuntu-24.04 + timeout-minutes: 20 + + steps: + - name: Check out repository + uses: actions/checkout@v7 + + - name: Set up Node.js + uses: actions/setup-node@v7 + with: + node-version: "22" + + - name: Verify release tag + run: >- + node -e 'const version = require("./package.json").version; + if (process.env.GITHUB_REF_NAME !== version) { + throw new Error(`Tag ${process.env.GITHUB_REF_NAME} does not match version ${version}`); + }' + + - name: Set up Java + uses: actions/setup-java@v5 + with: + distribution: temurin + java-version: "17" + + - name: Set up Gradle + uses: gradle/actions/setup-gradle@v5 + + - name: Publish and release Kotlin artifact + run: ./gradlew :AstrolabeProtocolKotlin:publishAndReleaseToMavenCentral + env: + ORG_GRADLE_PROJECT_mavenCentralUsername: ${{ secrets.MAVEN_CENTRAL_USERNAME }} + ORG_GRADLE_PROJECT_mavenCentralPassword: ${{ secrets.MAVEN_CENTRAL_PASSWORD }} + ORG_GRADLE_PROJECT_signingInMemoryKey: ${{ secrets.MAVEN_SIGNING_KEY }} + ORG_GRADLE_PROJECT_signingInMemoryKeyPassword: ${{ secrets.MAVEN_SIGNING_PASSWORD }} diff --git a/AstrolabeProtocolKotlin/build.gradle.kts b/AstrolabeProtocolKotlin/build.gradle.kts index 123ad8b..6b752de 100644 --- a/AstrolabeProtocolKotlin/build.gradle.kts +++ b/AstrolabeProtocolKotlin/build.gradle.kts @@ -3,10 +3,10 @@ import org.jetbrains.kotlin.gradle.dsl.JvmTarget plugins { alias(libs.plugins.kotlin.jvm) alias(libs.plugins.kotlin.serialization) - `maven-publish` + alias(libs.plugins.maven.publish) } -group = "dev.astrolabe" +group = "io.github.regulusleow" version = providers.gradleProperty("astrolabeVersion").get() java { @@ -36,11 +36,62 @@ tasks.test { useJUnitPlatform() } -publishing { - publications { - create("kotlin") { - artifactId = "astrolabe-protocol-kotlin" - from(components["java"]) +mavenPublishing { + publishToMavenCentral() + if (!providers.gradleProperty("signingInMemoryKey").orNull.isNullOrBlank()) { + signAllPublications() + } + coordinates( + groupId = project.group.toString(), + artifactId = "astrolabe-protocol-kotlin", + version = project.version.toString() + ) + + pom { + name.set("Astrolabe Protocol Kotlin") + description.set("Platform-neutral Kotlin implementation of the Astrolabe Wire Protocol.") + inceptionYear.set("2026") + url.set("https://github.com/regulusleow/astrolabe-protocol") + + licenses { + license { + name.set("The Apache License, Version 2.0") + url.set("https://www.apache.org/licenses/LICENSE-2.0.txt") + distribution.set("repo") + } + } + + developers { + developer { + id.set("regulusleow") + name.set("Regulus Leow") + url.set("https://github.com/regulusleow") + } + } + + scm { + url.set("https://github.com/regulusleow/astrolabe-protocol") + connection.set("scm:git:https://github.com/regulusleow/astrolabe-protocol.git") + developerConnection.set("scm:git:ssh://git@github.com/regulusleow/astrolabe-protocol.git") + } + } +} + +val requiredCentralPublishingProperties = listOf( + "mavenCentralUsername", + "mavenCentralPassword", + "signingInMemoryKey" +) + +tasks.matching { it.name.contains("MavenCentral") }.configureEach { + doFirst { + val missingProperties = requiredCentralPublishingProperties.filterNot { + !providers.gradleProperty(it).orNull.isNullOrBlank() + } + if (missingProperties.isNotEmpty()) { + throw GradleException( + "Missing Maven Central publishing properties: ${missingProperties.joinToString()}" + ) } } } diff --git a/build.gradle.kts b/build.gradle.kts index 0b832e9..650461b 100644 --- a/build.gradle.kts +++ b/build.gradle.kts @@ -1,4 +1,5 @@ plugins { alias(libs.plugins.kotlin.jvm) apply false alias(libs.plugins.kotlin.serialization) apply false + alias(libs.plugins.maven.publish) apply false } diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index e209155..015ad8d 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -1,6 +1,7 @@ [versions] kotlin = "2.2.10" kotlinx-serialization = "1.9.0" +maven-publish = "0.37.0" [libraries] kotlinx-serialization-json = { module = "org.jetbrains.kotlinx:kotlinx-serialization-json", version.ref = "kotlinx-serialization" } @@ -8,3 +9,4 @@ kotlinx-serialization-json = { module = "org.jetbrains.kotlinx:kotlinx-serializa [plugins] kotlin-jvm = { id = "org.jetbrains.kotlin.jvm", version.ref = "kotlin" } kotlin-serialization = { id = "org.jetbrains.kotlin.plugin.serialization", version.ref = "kotlin" } +maven-publish = { id = "com.vanniktech.maven.publish", version.ref = "maven-publish" } diff --git a/scripts/release-prepare.mjs b/scripts/release-prepare.mjs index 2fb114e..3b0a300 100644 --- a/scripts/release-prepare.mjs +++ b/scripts/release-prepare.mjs @@ -39,7 +39,9 @@ export function prepareRelease({ runRequired(commandRunner, "npm", ["test"]); runRequired(commandRunner, "swift", ["test", "--parallel"]); runRequired(commandRunner, "swift", ["build", "-c", "release"]); - runRequired(commandRunner, "./gradlew", [":AstrolabeProtocolKotlin:build"]); + runRequired(commandRunner, "./gradlew", [ + ":AstrolabeProtocolKotlin:publishToMavenLocal" + ]); requireOnlyVersionChanges(commandRunner, updatedPaths); runRequired(commandRunner, "git", ["diff", "--check"]); runRequired(commandRunner, "git", ["add", "--", ...updatedPaths]); diff --git a/test/release-prepare.test.mjs b/test/release-prepare.test.mjs index 005fcf1..58fb831 100644 --- a/test/release-prepare.test.mjs +++ b/test/release-prepare.test.mjs @@ -67,7 +67,8 @@ test("release preparation synchronizes, verifies, commits, and tags a prerelease assert.ok(commands.some((command) => command.join(" ") === "swift build -c release")); assert.ok( commands.some( - (command) => command.join(" ") === "./gradlew :AstrolabeProtocolKotlin:build" + (command) => command.join(" ") === + "./gradlew :AstrolabeProtocolKotlin:publishToMavenLocal" ) ); assert.ok(commands.some((command) => command.join(" ") === "git tag -a 2.0.0-rc.2 -m Astrolabe Protocol 2.0.0-rc.2")); From b932f2752cf02567febfb420b1682d3a2e978478 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E8=BD=A9=E8=BE=95=E5=8D=81=E5=9B=9B?= Date: Mon, 20 Jul 2026 23:04:29 +0800 Subject: [PATCH 5/7] perf: decode fragmented frames incrementally --- .../protocol/framing/RuntimeFrameCodec.kt | 90 +++++++++++++------ .../protocol/framing/RuntimeFrameCodecTest.kt | 13 +++ 2 files changed, 74 insertions(+), 29 deletions(-) diff --git a/AstrolabeProtocolKotlin/src/main/kotlin/dev/astrolabe/protocol/framing/RuntimeFrameCodec.kt b/AstrolabeProtocolKotlin/src/main/kotlin/dev/astrolabe/protocol/framing/RuntimeFrameCodec.kt index 169f741..9a53118 100644 --- a/AstrolabeProtocolKotlin/src/main/kotlin/dev/astrolabe/protocol/framing/RuntimeFrameCodec.kt +++ b/AstrolabeProtocolKotlin/src/main/kotlin/dev/astrolabe/protocol/framing/RuntimeFrameCodec.kt @@ -56,56 +56,88 @@ public class RuntimeFrameCodec( public class RuntimeFrameStreamDecoder internal constructor( private val maximumPayloadSize: Int ) { - private var buffer: ByteArray = byteArrayOf() + private val header = ByteArray(RuntimeFrameCodec.FRAME_HEADER_SIZE) + private var headerByteCount: Int = 0 + private var payload: ByteArray? = null + private var payloadByteCount: Int = 0 /** Number of bytes retained while waiting for a complete frame. */ public val pendingByteCount: Int - get() = buffer.size + get() = headerByteCount + payloadByteCount /** Appends bytes and returns every complete payload now available. */ public fun append(data: ByteArray): List { - if (data.isNotEmpty()) { - buffer += data - } - val payloads = mutableListOf() var offset = 0 - while (buffer.size - offset >= RuntimeFrameCodec.FRAME_HEADER_SIZE) { - val payloadLength = readPayloadLength(offset) - if (payloadLength == 0L) { - throw RuntimeFrameException.EmptyPayload - } - if (payloadLength > maximumPayloadSize.toLong()) { - throw RuntimeFrameException.PayloadTooLarge( - actual = payloadLength, - maximum = maximumPayloadSize + while (offset < data.size) { + if (payload == null) { + val headerBytesToCopy = minOf( + RuntimeFrameCodec.FRAME_HEADER_SIZE - headerByteCount, + data.size - offset ) + data.copyInto( + destination = header, + destinationOffset = headerByteCount, + startIndex = offset, + endIndex = offset + headerBytesToCopy + ) + headerByteCount += headerBytesToCopy + offset += headerBytesToCopy + if (headerByteCount < RuntimeFrameCodec.FRAME_HEADER_SIZE) { + continue + } + payload = ByteArray(validatedPayloadLength()) } - val frameLength = RuntimeFrameCodec.FRAME_HEADER_SIZE + payloadLength.toInt() - if (buffer.size - offset < frameLength) { - break + val currentPayload = payload ?: continue + val payloadBytesToCopy = minOf( + currentPayload.size - payloadByteCount, + data.size - offset + ) + data.copyInto( + destination = currentPayload, + destinationOffset = payloadByteCount, + startIndex = offset, + endIndex = offset + payloadBytesToCopy + ) + payloadByteCount += payloadBytesToCopy + offset += payloadBytesToCopy + if (payloadByteCount == currentPayload.size) { + payloads += currentPayload + resetFrameState() } - - val payloadStart = offset + RuntimeFrameCodec.FRAME_HEADER_SIZE - payloads += buffer.copyOfRange(payloadStart, offset + frameLength) - offset += frameLength - } - - if (offset > 0) { - buffer = buffer.copyOfRange(offset, buffer.size) } return payloads } /** Discards any incomplete frame bytes. */ public fun reset() { - buffer = byteArrayOf() + resetFrameState() + } + + private fun validatedPayloadLength(): Int { + val payloadLength = readPayloadLength() + if (payloadLength == 0L) { + throw RuntimeFrameException.EmptyPayload + } + if (payloadLength > maximumPayloadSize.toLong()) { + throw RuntimeFrameException.PayloadTooLarge( + actual = payloadLength, + maximum = maximumPayloadSize + ) + } + return payloadLength.toInt() + } + + private fun resetFrameState() { + headerByteCount = 0 + payload = null + payloadByteCount = 0 } - private fun readPayloadLength(offset: Int): Long = + private fun readPayloadLength(): Long = (0 until RuntimeFrameCodec.FRAME_HEADER_SIZE).fold(0L) { length, index -> - (length shl 8) or (buffer[offset + index].toLong() and 0xFF) + (length shl 8) or (header[index].toLong() and 0xFF) } } diff --git a/AstrolabeProtocolKotlin/src/test/kotlin/dev/astrolabe/protocol/framing/RuntimeFrameCodecTest.kt b/AstrolabeProtocolKotlin/src/test/kotlin/dev/astrolabe/protocol/framing/RuntimeFrameCodecTest.kt index a734590..6896997 100644 --- a/AstrolabeProtocolKotlin/src/test/kotlin/dev/astrolabe/protocol/framing/RuntimeFrameCodecTest.kt +++ b/AstrolabeProtocolKotlin/src/test/kotlin/dev/astrolabe/protocol/framing/RuntimeFrameCodecTest.kt @@ -39,6 +39,19 @@ class RuntimeFrameCodecTest { assertEquals(0, decoder.pendingByteCount) } + @Test + fun `stream decoder handles payloads delivered one byte at a time`() { + val codec = RuntimeFrameCodec() + val payload = ByteArray(8 * 1024) { index -> index.toByte() } + val frame = codec.encode(payload) + val decoder = codec.makeStreamDecoder() + val decodedPayloads = frame.flatMap { byte -> decoder.append(byteArrayOf(byte)) } + + assertEquals(1, decodedPayloads.size) + assertContentEquals(payload, decodedPayloads.single()) + assertEquals(0, decoder.pendingByteCount) + } + @Test fun `codec rejects empty and oversized payloads`() { val codec = RuntimeFrameCodec(maximumPayloadSize = 2) From 74b89c002dc844243f480092c1b3b5a871980139 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E8=BD=A9=E8=BE=95=E5=8D=81=E5=9B=9B?= Date: Wed, 22 Jul 2026 15:33:30 +0800 Subject: [PATCH 6/7] fix: accept non-ASCII wire JSON --- .../protocol/messaging/RuntimeJsonDocumentValidator.kt | 6 +++++- .../protocol/messaging/RuntimeMessageCodecTest.kt | 7 +++++++ 2 files changed, 12 insertions(+), 1 deletion(-) diff --git a/AstrolabeProtocolKotlin/src/main/kotlin/dev/astrolabe/protocol/messaging/RuntimeJsonDocumentValidator.kt b/AstrolabeProtocolKotlin/src/main/kotlin/dev/astrolabe/protocol/messaging/RuntimeJsonDocumentValidator.kt index 7a04d2c..986ecd0 100644 --- a/AstrolabeProtocolKotlin/src/main/kotlin/dev/astrolabe/protocol/messaging/RuntimeJsonDocumentValidator.kt +++ b/AstrolabeProtocolKotlin/src/main/kotlin/dev/astrolabe/protocol/messaging/RuntimeJsonDocumentValidator.kt @@ -181,7 +181,8 @@ private class RuntimeJsonParser( ) } } - byte < CharacterByte.SPACE -> invalid("JSON string contains a control byte") + byte.toInt() in 0 until CharacterByte.SPACE.toInt() -> + invalid("JSON string contains a control byte") } } invalid("JSON string is unterminated") @@ -250,6 +251,9 @@ private class RuntimeJsonParser( byte == CharacterByte.UPPERCASE_E || byte in CharacterByte.ZERO..CharacterByte.NINE + private fun invalid(message: String): Nothing = + throw RuntimeMessageException.InvalidDocument("$message at byte offset $index") + private companion object { private const val MAXIMUM_SAFE_INTEGER: Long = 9_007_199_254_740_991 private const val MINIMUM_SAFE_INTEGER: Long = -9_007_199_254_740_991 diff --git a/AstrolabeProtocolKotlin/src/test/kotlin/dev/astrolabe/protocol/messaging/RuntimeMessageCodecTest.kt b/AstrolabeProtocolKotlin/src/test/kotlin/dev/astrolabe/protocol/messaging/RuntimeMessageCodecTest.kt index d411e31..f2b3f1f 100644 --- a/AstrolabeProtocolKotlin/src/test/kotlin/dev/astrolabe/protocol/messaging/RuntimeMessageCodecTest.kt +++ b/AstrolabeProtocolKotlin/src/test/kotlin/dev/astrolabe/protocol/messaging/RuntimeMessageCodecTest.kt @@ -93,6 +93,13 @@ class RuntimeMessageCodecTest { } } + @Test + fun `codec accepts non ASCII UTF-8 strings`() { + val document = codec.decodeDocument("""{"value":"café"}""".encodeToByteArray()) + + assertEquals(JsonPrimitive("café"), assertIs(document)["value"]) + } + @Test fun `codec decodes every valid request and response fixture`() { validFixtureNames() From 210e6b0e7d0d88c0f245415af13c011a73e5a4a8 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E8=BD=A9=E8=BE=95=E5=8D=81=E5=9B=9B?= Date: Mon, 27 Jul 2026 23:12:30 +0800 Subject: [PATCH 7/7] chore: prepare 2.0.0 release --- README.md | 2 +- README.zh-CN.md | 2 +- .../Core/RuntimeProtocolMetadata.swift | 2 +- gradle.properties | 2 +- package-lock.json | 10 +++++----- package.json | 2 +- scripts/versioning.mjs | 13 +++++++++++++ test/release-prepare.test.mjs | 8 ++++++++ 8 files changed, 31 insertions(+), 10 deletions(-) diff --git a/README.md b/README.md index 361ca97..edf6c5f 100644 --- a/README.md +++ b/README.md @@ -26,7 +26,7 @@ Add the package through Swift Package Manager: ```swift .package( url: "https://github.com/regulusleow/astrolabe-protocol.git", - exact: "1.0.0" + exact: "2.0.0" ) ``` diff --git a/README.zh-CN.md b/README.zh-CN.md index b7ef892..e3f9c23 100644 --- a/README.zh-CN.md +++ b/README.zh-CN.md @@ -22,7 +22,7 @@ UIKit、Android View、Transport Listener、设备发现、截图、CLI 命令 ```swift .package( url: "https://github.com/regulusleow/astrolabe-protocol.git", - exact: "1.0.0" + exact: "2.0.0" ) ``` diff --git a/Sources/AstrolabeProtocol/Core/RuntimeProtocolMetadata.swift b/Sources/AstrolabeProtocol/Core/RuntimeProtocolMetadata.swift index b9d35f8..42bfb57 100644 --- a/Sources/AstrolabeProtocol/Core/RuntimeProtocolMetadata.swift +++ b/Sources/AstrolabeProtocol/Core/RuntimeProtocolMetadata.swift @@ -7,7 +7,7 @@ public enum RuntimeProtocolMetadata { /// AstrolabeProtocol package release version. - public static let packageVersion = "1.0.0" + public static let packageVersion = "2.0.0" /// Wire protocol version implemented by this package. public static let wireVersion = RuntimeProtocolVersion.v2 diff --git a/gradle.properties b/gradle.properties index fd76d1e..2de6cde 100644 --- a/gradle.properties +++ b/gradle.properties @@ -1,4 +1,4 @@ -astrolabeVersion=1.0.0 +astrolabeVersion=2.0.0 org.gradle.configuration-cache=true org.gradle.jvmargs=-Xmx2g -Dfile.encoding=UTF-8 kotlin.code.style=official diff --git a/package-lock.json b/package-lock.json index fe3eda2..c20b648 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "astrolabe-protocol-contract", - "version": "1.0.0", + "version": "2.0.0", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "astrolabe-protocol-contract", - "version": "1.0.0", + "version": "2.0.0", "devDependencies": { "ajv": "8.20.0", "ajv-formats": "3.0.1" @@ -55,9 +55,9 @@ "license": "MIT" }, "node_modules/fast-uri": { - "version": "3.1.3", - "resolved": "https://registry.npmjs.org/fast-uri/-/fast-uri-3.1.3.tgz", - "integrity": "sha512-i70LwGWUduXqzicKXWshooq+sWL1K3WUU5rKZNG/0i3a1OSoX3HqhH5WbWwTmqWfor4urUakGPiRQcleRZTwOg==", + "version": "3.1.4", + "resolved": "https://registry.npmjs.org/fast-uri/-/fast-uri-3.1.4.tgz", + "integrity": "sha512-8JnbkQ4juDyvYs4mgFGQqg4yCYtFDtUtmp2QIQq11ZZe5CFQ5wcqm1rqDgAh/QdMySuBnPzMUiJUNZG5N/AiQw==", "dev": true, "funding": [ { diff --git a/package.json b/package.json index 0d76d32..3594f09 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "astrolabe-protocol-contract", - "version": "1.0.0", + "version": "2.0.0", "private": true, "type": "module", "scripts": { diff --git a/scripts/versioning.mjs b/scripts/versioning.mjs index d4a02e0..2cf8031 100644 --- a/scripts/versioning.mjs +++ b/scripts/versioning.mjs @@ -8,6 +8,7 @@ export const versionedPaths = Object.freeze([ gradlePropertiesPath, metadataPath, "README.md", + "README.zh-CN.md", "package-lock.json", "package.json" ]); @@ -57,6 +58,11 @@ export function synchronizeRepositoryVersion(projectRoot, version) { documentationVersionPattern, `exact: "${version}"` ), + textVersionUpdate( + join(projectRoot, "README.zh-CN.md"), + documentationVersionPattern, + `exact: "${version}"` + ), textVersionUpdate( join(projectRoot, gradlePropertiesPath), gradleVersionPattern, @@ -95,6 +101,13 @@ export function versionConsistencyIssues(projectRoot) { expectedVersion, issues ); + inspectTextVersion( + join(projectRoot, "README.zh-CN.md"), + "README.zh-CN.md", + documentationVersionPattern, + expectedVersion, + issues + ); inspectTextVersion( join(projectRoot, gradlePropertiesPath), gradlePropertiesPath, diff --git a/test/release-prepare.test.mjs b/test/release-prepare.test.mjs index 58fb831..759a356 100644 --- a/test/release-prepare.test.mjs +++ b/test/release-prepare.test.mjs @@ -32,6 +32,10 @@ test("release preparation synchronizes, verifies, commits, and tags a prerelease join(projectRoot, "README.md"), '.package(url: "example", exact: "2.0.0-rc.1")\n' ); + writeFileSync( + join(projectRoot, "README.zh-CN.md"), + '.package(url: "example", exact: "2.0.0-rc.1")\n' + ); writeFileSync(join(projectRoot, "gradle.properties"), "astrolabeVersion=2.0.0-rc.1\n"); const sourceDirectory = join(projectRoot, "Sources/AstrolabeProtocol/Core"); mkdirSync(sourceDirectory, { recursive: true }); @@ -64,6 +68,10 @@ test("release preparation synchronizes, verifies, commits, and tags a prerelease readFileSync(join(projectRoot, "gradle.properties"), "utf8"), /^astrolabeVersion=2\.0\.0-rc\.2$/m ); + assert.match( + readFileSync(join(projectRoot, "README.zh-CN.md"), "utf8"), + /exact: "2\.0\.0-rc\.2"/ + ); assert.ok(commands.some((command) => command.join(" ") === "swift build -c release")); assert.ok( commands.some(