Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
16 commits
Select commit Hold shift + click to select a range
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -7,5 +7,5 @@

package enum AstrolabeHostMetadata {
/// Host release version advertised during Runtime negotiation.
package static let version = "2.2.1"
package static let version = "2.2.2"
}
224 changes: 224 additions & 0 deletions Sources/AstrolabeCLI/Interaction/Core/InteractionModels.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,224 @@
//
// InteractionModels.swift
// astrolabe
//
// Created by 轩辕十四 on 2026/8/17.
//

import Foundation

package enum InteractionActionKind: String, Equatable, Hashable {
case tap
case longPress
case inputText
case swipe
case pressKey
}

package struct InteractionPoint: Equatable {
/// Horizontal position in the logical viewport coordinate space.
package let x: Double

/// Vertical position in the logical viewport coordinate space.
package let y: Double

package init(x: Double, y: Double) throws {
guard x.isFinite, y.isFinite, x >= 0, y >= 0 else {
throw InteractionError.invalidInput("Interaction point must be finite and nonnegative")
}
self.x = x
self.y = y
}
}

package struct InteractionViewport: Equatable {
/// Logical viewport width.
package let width: Double

/// Logical viewport height.
package let height: Double

package init(width: Double, height: Double) throws {
guard width.isFinite, height.isFinite, width > 0, height > 0 else {
throw InteractionError.invalidInput("Interaction viewport dimensions must be finite and positive")
}
self.width = width
self.height = height
}

func contains(_ point: InteractionPoint) -> Bool {
point.x <= width && point.y <= height
}
}

package enum InteractionOrientation: Equatable {
case portrait
case landscape
}

package enum InteractionDeviceKind: Equatable, Hashable {
case virtual
case physical
}

package enum InteractionLocator: Equatable {
case stableIdentifier(String)
case accessibilityLabel(String)
case alias(String)
case point(InteractionPoint)
}

package enum InteractionAction: Equatable {
case tap
case longPress(duration: TimeInterval)
case inputText(String)
case swipe(to: InteractionPoint, duration: TimeInterval)
case pressKey(String)

package var kind: InteractionActionKind {
switch self {
case .tap:
.tap
case .longPress:
.longPress
case .inputText:
.inputText
case .swipe:
.swipe
case .pressKey:
.pressKey
}
}

package init(validating action: InteractionAction) throws {
try action.validate()
self = action
}

func validate() throws {
switch self {
case .longPress(let duration), .swipe(_, let duration):
guard duration.isFinite, duration > 0 else {
throw InteractionError.invalidInput("Interaction duration must be finite and positive")
}
case .pressKey(let key):
guard !key.isEmpty else {
throw InteractionError.invalidInput("Interaction key must not be empty")
}
case .tap, .inputText:
break
}
}
}

package struct InteractionTarget: Equatable {
/// Runtime app identifier resolved by the platform resolver.
package let appId: String

/// Physical or virtual device identifier where the interaction occurs.
package let deviceIdentifier: String

/// Device category used for Provider routing.
package let deviceKind: InteractionDeviceKind

/// Foreground app process identifier observed before interaction.
package let processIdentifier: String

/// Unix timestamp when the target state was observed.
package let observedAtUnixTime: TimeInterval

/// Orientation observed with the target state.
package let orientation: InteractionOrientation

/// Logical viewport observed with the target state.
package let viewport: InteractionViewport

/// Semantic or coordinate locator for the interaction target.
package let locator: InteractionLocator

package init(
appId: String,
deviceIdentifier: String,
deviceKind: InteractionDeviceKind,
processIdentifier: String,
observedAtUnixTime: TimeInterval,
orientation: InteractionOrientation,
viewport: InteractionViewport,
locator: InteractionLocator
) throws {
guard !appId.isEmpty, !deviceIdentifier.isEmpty, !processIdentifier.isEmpty else {
throw InteractionError.invalidInput("Interaction identifiers must not be empty")
}
guard observedAtUnixTime.isFinite, observedAtUnixTime >= 0 else {
throw InteractionError.invalidInput("Interaction observation time must be finite and nonnegative")
}
if case .stableIdentifier(let identifier) = locator, identifier.isEmpty {
throw InteractionError.invalidInput("Interaction stable identifier must not be empty")
}
if case .accessibilityLabel(let label) = locator, label.isEmpty {
throw InteractionError.invalidInput("Interaction accessibility label must not be empty")
}
if case .alias(let alias) = locator, alias.isEmpty {
throw InteractionError.invalidInput("Interaction alias must not be empty")
}
if case .point(let point) = locator, !viewport.contains(point) {
throw InteractionError.invalidInput("Interaction point must be inside the viewport")
}
self.appId = appId
self.deviceIdentifier = deviceIdentifier
self.deviceKind = deviceKind
self.processIdentifier = processIdentifier
self.observedAtUnixTime = observedAtUnixTime
self.orientation = orientation
self.viewport = viewport
self.locator = locator
}
}

package struct InteractionRequest: Equatable {
/// Requested user interaction.
package let action: InteractionAction

/// Current target context for the interaction.
package let target: InteractionTarget

package init(action: InteractionAction, target: InteractionTarget) throws {
try action.validate()
if case .swipe(let destination, _) = action, !target.viewport.contains(destination) {
throw InteractionError.invalidInput("Interaction swipe destination must be inside the viewport")
}
self.action = action
self.target = target
}
}

package enum InteractionExecutionStatus: Equatable {
case sent
case confirmed
case uncertain
}

package struct InteractionResult: Equatable {
/// Exact request accepted by the selected Provider.
package let request: InteractionRequest

/// Execution certainty reported by the selected Provider.
package let status: InteractionExecutionStatus

package init(request: InteractionRequest, status: InteractionExecutionStatus) {
self.request = request
self.status = status
}
}

package enum InteractionError: Error, Equatable {
case invalidInput(String)
case duplicateProviderIdentifier(String)
case providerUnavailable(platform: RuntimeUIPlatform, deviceKind: InteractionDeviceKind)
case unsupportedAction(InteractionActionKind)
case ambiguousProviders([String])
case staleTarget
case foregroundAppChanged
case coordinateContextChanged
case executionFailed(String)
}
64 changes: 64 additions & 0 deletions Sources/AstrolabeCLI/Interaction/Core/InteractionRegistry.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,64 @@
//
// InteractionRegistry.swift
// astrolabe
//
// Created by 轩辕十四 on 2026/8/17.
//

import Foundation

package final class InteractionRegistry {
/// Resolver used to derive Runtime platform from the current app identifier.
private let platformResolver: any RuntimeUIPlatformResolving

/// Registered interaction Provider strategies.
private let providers: [any PlatformInteractionPerforming]

package init(
platformResolver: any RuntimeUIPlatformResolving,
providers: [any PlatformInteractionPerforming]
) throws {
let identifiers = providers.map(\.descriptor.identifier)
if let duplicate = Self.firstDuplicate(in: identifiers) {
throw InteractionError.duplicateProviderIdentifier(duplicate)
}
self.platformResolver = platformResolver
self.providers = providers
}

package func perform(_ request: InteractionRequest) throws -> InteractionResult {
let platform = try platformResolver.platform(for: request.target.appId)
let contextualProviders = providers.filter {
$0.descriptor.platform == platform
&& $0.descriptor.supportedDeviceKinds.contains(request.target.deviceKind)
}
guard !contextualProviders.isEmpty else {
throw InteractionError.providerUnavailable(
platform: platform,
deviceKind: request.target.deviceKind
)
}
let actionProviders = contextualProviders.filter {
$0.descriptor.supportedActionKinds.contains(request.action.kind)
}
guard !actionProviders.isEmpty else {
throw InteractionError.unsupportedAction(request.action.kind)
}
let capableProviders = actionProviders.filter { $0.canHandle(target: request.target) }
guard !capableProviders.isEmpty else {
throw InteractionError.providerUnavailable(
platform: platform,
deviceKind: request.target.deviceKind
)
}
guard capableProviders.count == 1 else {
throw InteractionError.ambiguousProviders(capableProviders.map(\.descriptor.identifier))
}
return try capableProviders[0].perform(request)
}

private static func firstDuplicate<Value: Hashable>(in values: [Value]) -> Value? {
var seen = Set<Value>()
return values.first { !seen.insert($0).inserted }
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,47 @@
//
// PlatformInteractionPerforming.swift
// astrolabe
//
// Created by 轩辕十四 on 2026/8/17.
//

package struct InteractionProviderDescriptor: Equatable {
/// Stable identifier for this interaction Provider.
package let identifier: String

/// Runtime platform supported by this Provider.
package let platform: RuntimeUIPlatform

/// Device categories supported by this Provider.
package let supportedDeviceKinds: Set<InteractionDeviceKind>

/// Action kinds supported by this Provider.
package let supportedActionKinds: Set<InteractionActionKind>

package init(
identifier: String,
platform: RuntimeUIPlatform,
supportedDeviceKinds: Set<InteractionDeviceKind>,
supportedActionKinds: Set<InteractionActionKind>
) throws {
guard !identifier.isEmpty else {
throw InteractionError.invalidInput("Interaction Provider identifier must not be empty")
}
guard !supportedDeviceKinds.isEmpty else {
throw InteractionError.invalidInput("Interaction Provider device kinds must not be empty")
}
guard !supportedActionKinds.isEmpty else {
throw InteractionError.invalidInput("Interaction Provider action kinds must not be empty")
}
self.identifier = identifier
self.platform = platform
self.supportedDeviceKinds = supportedDeviceKinds
self.supportedActionKinds = supportedActionKinds
}
}

package protocol PlatformInteractionPerforming {
var descriptor: InteractionProviderDescriptor { get }
func canHandle(target: InteractionTarget) -> Bool
func perform(_ request: InteractionRequest) throws -> InteractionResult
}
Loading