diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 3c24504..74a6f6c 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -55,3 +55,70 @@ 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 + + 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/.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..6b752de --- /dev/null +++ b/AstrolabeProtocolKotlin/build.gradle.kts @@ -0,0 +1,97 @@ +import org.jetbrains.kotlin.gradle.dsl.JvmTarget + +plugins { + alias(libs.plugins.kotlin.jvm) + alias(libs.plugins.kotlin.serialization) + alias(libs.plugins.maven.publish) +} + +group = "io.github.regulusleow" +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() +} + +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/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 new file mode 100644 index 0000000..585ba6c --- /dev/null +++ b/AstrolabeProtocolKotlin/src/main/kotlin/dev/astrolabe/protocol/core/RuntimeGeometry.kt @@ -0,0 +1,173 @@ +// +// 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 +) { + init { + require(unit != RuntimeMeasurementUnit.scaledLogical) { + "Coordinate point unit must be logical or pixel" + } + } +} + +/** 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" } + 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" + } + } +} + +/** Per-axis logical-to-pixel conversion. */ +@Serializable +public data class RuntimeScale( + /** Horizontal scale. */ + 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 +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? +) { + 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/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..9a53118 --- /dev/null +++ b/AstrolabeProtocolKotlin/src/main/kotlin/dev/astrolabe/protocol/framing/RuntimeFrameCodec.kt @@ -0,0 +1,156 @@ +// +// 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 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() = headerByteCount + payloadByteCount + + /** Appends bytes and returns every complete payload now available. */ + public fun append(data: ByteArray): List { + val payloads = mutableListOf() + var offset = 0 + 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 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() + } + } + return payloads + } + + /** Discards any incomplete frame bytes. */ + public fun reset() { + 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(): Long = + (0 until RuntimeFrameCodec.FRAME_HEADER_SIZE).fold(0L) { length, index -> + (length shl 8) or (header[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..5f37faf --- /dev/null +++ b/AstrolabeProtocolKotlin/src/main/kotlin/dev/astrolabe/protocol/inspection/RuntimeApplication.kt @@ -0,0 +1,133 @@ +// +// 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? +) { + 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 +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 +) { + 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 +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 +) { + 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 +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 data object RuntimeApplicationInfoParameters { + /** Typed request contract for the application-info method. */ + public val contract: RuntimeMethodContract by lazy { + RuntimeMethodContract(RuntimeMethod.applicationInfo, serializer()) + } +} 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/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..986ecd0 --- /dev/null +++ b/AstrolabeProtocolKotlin/src/main/kotlin/dev/astrolabe/protocol/messaging/RuntimeJsonDocumentValidator.kt @@ -0,0 +1,312 @@ +// +// 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.toInt() in 0 until CharacterByte.SPACE.toInt() -> + 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 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 + 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..14bd26a --- /dev/null +++ b/AstrolabeProtocolKotlin/src/main/kotlin/dev/astrolabe/protocol/messaging/RuntimeMessageCodec.kt @@ -0,0 +1,292 @@ +// +// 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.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.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)) + } + + /** 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)) + val request = json.decodeFromJsonElement(document) + validateRequest(request) + request + } + + /** Encodes a request envelope. */ + public fun encodeRequest(request: RuntimeRequestEnvelope): ByteArray { + validateRequest(request) + 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. */ + 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 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, + 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 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) + } + + 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 { + 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 { + 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) + } + + 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. */ +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..61f6a55 --- /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 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/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/framing/RuntimeFrameCodecTest.kt b/AstrolabeProtocolKotlin/src/test/kotlin/dev/astrolabe/protocol/framing/RuntimeFrameCodecTest.kt new file mode 100644 index 0000000..6896997 --- /dev/null +++ b/AstrolabeProtocolKotlin/src/test/kotlin/dev/astrolabe/protocol/framing/RuntimeFrameCodecTest.kt @@ -0,0 +1,80 @@ +// +// 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 `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) + + 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/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/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..f2b3f1f --- /dev/null +++ b/AstrolabeProtocolKotlin/src/test/kotlin/dev/astrolabe/protocol/messaging/RuntimeMessageCodecTest.kt @@ -0,0 +1,137 @@ +// +// 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()) + } + assertFailsWith { + codec.decodeResponse( + """{ + "requestID":"00000000-0000-4000-8000-000000000001", + "protocolVersion":{"major":2,"minor":0}, + "method":1, + "status":"success", + "payload":{} + }""".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 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() + .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..a610be5 --- /dev/null +++ b/AstrolabeProtocolKotlin/src/test/kotlin/dev/astrolabe/protocol/negotiation/RuntimeSessionModelTest.kt @@ -0,0 +1,139 @@ +// +// RuntimeSessionModelTest.kt +// astrolabe-protocol +// +// Created by 轩辕十四 on 2026/7/20. +// + +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 + +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 `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 { + 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)) + } + 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( + checkNotNull(javaClass.classLoader.getResource(path)).toURI() + ).readBytes() +} 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() +} diff --git a/README.md b/README.md index 4cae929..edf6c5f 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. @@ -24,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 new file mode 100644 index 0000000..e3f9c23 --- /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: "2.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) 许可。 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/build.gradle.kts b/build.gradle.kts new file mode 100644 index 0000000..650461b --- /dev/null +++ b/build.gradle.kts @@ -0,0 +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.properties b/gradle.properties new file mode 100644 index 0000000..2de6cde --- /dev/null +++ b/gradle.properties @@ -0,0 +1,4 @@ +astrolabeVersion=2.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..015ad8d --- /dev/null +++ b/gradle/libs.versions.toml @@ -0,0 +1,12 @@ +[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" } + +[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/gradle/wrapper/gradle-wrapper.jar b/gradle/wrapper/gradle-wrapper.jar new file mode 100644 index 0000000..8bdaf60 Binary files /dev/null and b/gradle/wrapper/gradle-wrapper.jar differ 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/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/release-prepare.mjs b/scripts/release-prepare.mjs index 64c9680..3b0a300 100644 --- a/scripts/release-prepare.mjs +++ b/scripts/release-prepare.mjs @@ -39,6 +39,9 @@ export function prepareRelease({ runRequired(commandRunner, "npm", ["test"]); runRequired(commandRunner, "swift", ["test", "--parallel"]); runRequired(commandRunner, "swift", ["build", "-c", "release"]); + runRequired(commandRunner, "./gradlew", [ + ":AstrolabeProtocolKotlin:publishToMavenLocal" + ]); 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..2cf8031 100644 --- a/scripts/versioning.mjs +++ b/scripts/versioning.mjs @@ -2,10 +2,13 @@ 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", + "README.zh-CN.md", "package-lock.json", "package.json" ]); @@ -16,6 +19,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 +57,16 @@ export function synchronizeRepositoryVersion(projectRoot, version) { join(projectRoot, "README.md"), documentationVersionPattern, `exact: "${version}"` + ), + textVersionUpdate( + join(projectRoot, "README.zh-CN.md"), + documentationVersionPattern, + `exact: "${version}"` + ), + textVersionUpdate( + join(projectRoot, gradlePropertiesPath), + gradleVersionPattern, + `astrolabeVersion=${version}` ) ]; updates.forEach(({ path, content }) => writeFileSync(path, content)); @@ -87,6 +101,20 @@ 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, + 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..759a356 100644 --- a/test/release-prepare.test.mjs +++ b/test/release-prepare.test.mjs @@ -32,6 +32,11 @@ 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 }); writeFileSync( @@ -59,7 +64,21 @@ 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.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( + (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")); } finally { rmSync(projectRoot, { recursive: true, force: true });