diff --git a/.github/workflows/ios.yml b/.github/workflows/ios.yml new file mode 100644 index 00000000..9e26415a --- /dev/null +++ b/.github/workflows/ios.yml @@ -0,0 +1,40 @@ +name: ios + +on: + push: + branches: + - main + paths: + - 'ios/**' + - 'shared/fixtures/**' + - '.github/workflows/ios.yml' + pull_request: + paths: + - 'ios/**' + - 'shared/fixtures/**' + - '.github/workflows/ios.yml' + +concurrency: + group: ios-${{ github.ref }} + cancel-in-progress: true + +jobs: + package-tests: + runs-on: macos-15 + steps: + - uses: actions/checkout@v4 + - name: Show toolchain versions + run: | + xcodebuild -version + swift --version + - name: Run HapiKit package tests + run: swift test --package-path ios/Packages/HapiKit + + app-build: + runs-on: macos-15 + steps: + - uses: actions/checkout@v4 + - name: Show toolchain versions + run: xcodebuild -version + - name: Build Hapi app for iOS Simulator + run: xcodebuild build -project ios/Hapi.xcodeproj -scheme Hapi -destination 'generic/platform=iOS Simulator' CODE_SIGNING_ALLOWED=NO diff --git a/.gitignore b/.gitignore index 7c12eb73..b9c798c8 100644 --- a/.gitignore +++ b/.gitignore @@ -50,3 +50,10 @@ e2e-output/ .xyz-harness .agents/ .pi/ + +# Xcode / iOS build artifacts +ios/**/xcuserdata/ +ios/**/.build/ +ios/**/.swiftpm/ +**/*.xcresult +**/DerivedData/ diff --git a/ios/Hapi.xcodeproj/project.pbxproj b/ios/Hapi.xcodeproj/project.pbxproj new file mode 100644 index 00000000..3b8655ec --- /dev/null +++ b/ios/Hapi.xcodeproj/project.pbxproj @@ -0,0 +1,316 @@ +// !$*UTF8*$! +{ + archiveVersion = 1; + classes = { + }; + objectVersion = 77; + objects = { + +/* Begin PBXBuildFile section */ + A100000000000000000000C4 /* HapiProtocol in Frameworks */ = {isa = PBXBuildFile; productRef = A100000000000000000000C2 /* HapiProtocol */; }; + A100000000000000000000C5 /* HapiClient in Frameworks */ = {isa = PBXBuildFile; productRef = A100000000000000000000C3 /* HapiClient */; }; +/* End PBXBuildFile section */ + +/* Begin PBXFileReference section */ + A100000000000000000000A4 /* Hapi.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = Hapi.app; sourceTree = BUILT_PRODUCTS_DIR; }; +/* End PBXFileReference section */ + +/* Begin PBXFileSystemSynchronizedBuildFileExceptionSet section */ + A100000000000000000000C6 /* Exceptions for "Hapi" folder in "Hapi" target */ = { + isa = PBXFileSystemSynchronizedBuildFileExceptionSet; + membershipExceptions = ( + Info.plist, + ); + target = A100000000000000000000A5 /* Hapi */; + }; +/* End PBXFileSystemSynchronizedBuildFileExceptionSet section */ + +/* Begin PBXFileSystemSynchronizedRootGroup section */ + A100000000000000000000A2 /* Hapi */ = { + isa = PBXFileSystemSynchronizedRootGroup; + exceptions = ( + A100000000000000000000C6 /* Exceptions for "Hapi" folder in "Hapi" target */, + ); + explicitFileTypes = { + }; + explicitFolders = ( + ); + path = Hapi; + sourceTree = ""; + }; +/* End PBXFileSystemSynchronizedRootGroup section */ + +/* Begin PBXFrameworksBuildPhase section */ + A100000000000000000000A8 /* Frameworks */ = { + isa = PBXFrameworksBuildPhase; + buildActionMask = 2147483647; + files = ( + A100000000000000000000C4 /* HapiProtocol in Frameworks */, + A100000000000000000000C5 /* HapiClient in Frameworks */, + ); + runOnlyForDeploymentPostprocessing = 0; + }; +/* End PBXFrameworksBuildPhase section */ + +/* Begin PBXGroup section */ + A100000000000000000000A1 = { + isa = PBXGroup; + children = ( + A100000000000000000000A2 /* Hapi */, + A100000000000000000000A3 /* Products */, + ); + sourceTree = ""; + }; + A100000000000000000000A3 /* Products */ = { + isa = PBXGroup; + children = ( + A100000000000000000000A4 /* Hapi.app */, + ); + name = Products; + sourceTree = ""; + }; +/* End PBXGroup section */ + +/* Begin PBXNativeTarget section */ + A100000000000000000000A5 /* Hapi */ = { + isa = PBXNativeTarget; + buildConfigurationList = A100000000000000000000B4 /* Build configuration list for PBXNativeTarget "Hapi" */; + buildPhases = ( + A100000000000000000000A7 /* Sources */, + A100000000000000000000A8 /* Frameworks */, + A100000000000000000000A9 /* Resources */, + ); + buildRules = ( + ); + dependencies = ( + ); + fileSystemSynchronizedGroups = ( + A100000000000000000000A2 /* Hapi */, + ); + name = Hapi; + packageProductDependencies = ( + A100000000000000000000C2 /* HapiProtocol */, + A100000000000000000000C3 /* HapiClient */, + ); + productName = Hapi; + productReference = A100000000000000000000A4 /* Hapi.app */; + productType = "com.apple.product-type.application"; + }; +/* End PBXNativeTarget section */ + +/* Begin PBXProject section */ + A100000000000000000000A6 /* Project object */ = { + isa = PBXProject; + attributes = { + BuildIndependentTargetsInParallel = 1; + LastSwiftUpdateCheck = 1600; + LastUpgradeCheck = 1600; + TargetAttributes = { + A100000000000000000000A5 = { + CreatedOnToolsVersion = 16.0; + }; + }; + }; + buildConfigurationList = A100000000000000000000B1 /* Build configuration list for PBXProject "Hapi" */; + developmentRegion = en; + hasScannedForEncodings = 0; + knownRegions = ( + en, + Base, + ); + mainGroup = A100000000000000000000A1; + minimizedProjectReferenceProxies = 1; + packageReferences = ( + A100000000000000000000C1 /* XCLocalSwiftPackageReference "Packages/HapiKit" */, + ); + preferredProjectObjectVersion = 77; + productRefGroup = A100000000000000000000A3 /* Products */; + projectDirPath = ""; + projectRoot = ""; + targets = ( + A100000000000000000000A5 /* Hapi */, + ); + }; +/* End PBXProject section */ + +/* Begin PBXResourcesBuildPhase section */ + A100000000000000000000A9 /* Resources */ = { + isa = PBXResourcesBuildPhase; + buildActionMask = 2147483647; + files = ( + ); + runOnlyForDeploymentPostprocessing = 0; + }; +/* End PBXResourcesBuildPhase section */ + +/* Begin PBXSourcesBuildPhase section */ + A100000000000000000000A7 /* Sources */ = { + isa = PBXSourcesBuildPhase; + buildActionMask = 2147483647; + files = ( + ); + runOnlyForDeploymentPostprocessing = 0; + }; +/* End PBXSourcesBuildPhase section */ + +/* Begin XCBuildConfiguration section */ + A100000000000000000000B2 /* Debug */ = { + isa = XCBuildConfiguration; + buildSettings = { + ALWAYS_SEARCH_USER_PATHS = NO; + CLANG_ANALYZER_NONNULL = YES; + CLANG_ENABLE_MODULES = YES; + CLANG_ENABLE_OBJC_ARC = YES; + COPY_PHASE_STRIP = NO; + DEBUG_INFORMATION_FORMAT = dwarf; + ENABLE_STRICT_OBJC_MSGSEND = YES; + ENABLE_TESTABILITY = YES; + ENABLE_USER_SCRIPT_SANDBOXING = YES; + GCC_C_LANGUAGE_STANDARD = gnu17; + GCC_DYNAMIC_NO_PIC = NO; + GCC_NO_COMMON_BLOCKS = YES; + GCC_OPTIMIZATION_LEVEL = 0; + GCC_PREPROCESSOR_DEFINITIONS = ( + "DEBUG=1", + "$(inherited)", + ); + IPHONEOS_DEPLOYMENT_TARGET = 17.0; + LOCALIZATION_PREFERS_STRING_CATALOGS = YES; + MTL_ENABLE_DEBUG_INFO = INCLUDE_SOURCE; + MTL_FAST_MATH = YES; + ONLY_ACTIVE_ARCH = YES; + SDKROOT = iphoneos; + SWIFT_ACTIVE_COMPILATION_CONDITIONS = "DEBUG $(inherited)"; + SWIFT_OPTIMIZATION_LEVEL = "-Onone"; + SWIFT_VERSION = 5.0; + }; + name = Debug; + }; + A100000000000000000000B3 /* Release */ = { + isa = XCBuildConfiguration; + buildSettings = { + ALWAYS_SEARCH_USER_PATHS = NO; + CLANG_ANALYZER_NONNULL = YES; + CLANG_ENABLE_MODULES = YES; + CLANG_ENABLE_OBJC_ARC = YES; + COPY_PHASE_STRIP = NO; + DEBUG_INFORMATION_FORMAT = "dwarf-with-dsym"; + ENABLE_NS_ASSERTIONS = NO; + ENABLE_STRICT_OBJC_MSGSEND = YES; + ENABLE_USER_SCRIPT_SANDBOXING = YES; + GCC_C_LANGUAGE_STANDARD = gnu17; + GCC_NO_COMMON_BLOCKS = YES; + IPHONEOS_DEPLOYMENT_TARGET = 17.0; + LOCALIZATION_PREFERS_STRING_CATALOGS = YES; + MTL_ENABLE_DEBUG_INFO = NO; + MTL_FAST_MATH = YES; + SDKROOT = iphoneos; + SWIFT_COMPILATION_MODE = wholemodule; + SWIFT_VERSION = 5.0; + VALIDATE_PRODUCT = YES; + }; + name = Release; + }; + A100000000000000000000B5 /* Debug */ = { + isa = XCBuildConfiguration; + buildSettings = { + ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; + ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor; + CODE_SIGN_STYLE = Automatic; + CURRENT_PROJECT_VERSION = 1; + ENABLE_PREVIEWS = YES; + GENERATE_INFOPLIST_FILE = YES; + INFOPLIST_FILE = Hapi/Info.plist; + INFOPLIST_KEY_CFBundleDisplayName = HAPI; + INFOPLIST_KEY_UIApplicationSceneManifest_Generation = YES; + INFOPLIST_KEY_UIApplicationSupportsIndirectInputEvents = YES; + INFOPLIST_KEY_UILaunchScreen_Generation = YES; + INFOPLIST_KEY_UISupportedInterfaceOrientations_iPad = "UIInterfaceOrientationPortrait UIInterfaceOrientationPortraitUpsideDown UIInterfaceOrientationLandscapeLeft UIInterfaceOrientationLandscapeRight"; + INFOPLIST_KEY_UISupportedInterfaceOrientations_iPhone = "UIInterfaceOrientationPortrait UIInterfaceOrientationLandscapeLeft UIInterfaceOrientationLandscapeRight"; + IPHONEOS_DEPLOYMENT_TARGET = 17.0; + LD_RUNPATH_SEARCH_PATHS = ( + "$(inherited)", + "@executable_path/Frameworks", + ); + MARKETING_VERSION = 1.0; + PRODUCT_BUNDLE_IDENTIFIER = run.hapi.companion; + PRODUCT_NAME = "$(TARGET_NAME)"; + SWIFT_EMIT_LOC_STRINGS = YES; + SWIFT_VERSION = 5.0; + TARGETED_DEVICE_FAMILY = "1,2"; + }; + name = Debug; + }; + A100000000000000000000B6 /* Release */ = { + isa = XCBuildConfiguration; + buildSettings = { + ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; + ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor; + CODE_SIGN_STYLE = Automatic; + CURRENT_PROJECT_VERSION = 1; + ENABLE_PREVIEWS = YES; + GENERATE_INFOPLIST_FILE = YES; + INFOPLIST_FILE = Hapi/Info.plist; + INFOPLIST_KEY_CFBundleDisplayName = HAPI; + INFOPLIST_KEY_UIApplicationSceneManifest_Generation = YES; + INFOPLIST_KEY_UIApplicationSupportsIndirectInputEvents = YES; + INFOPLIST_KEY_UILaunchScreen_Generation = YES; + INFOPLIST_KEY_UISupportedInterfaceOrientations_iPad = "UIInterfaceOrientationPortrait UIInterfaceOrientationPortraitUpsideDown UIInterfaceOrientationLandscapeLeft UIInterfaceOrientationLandscapeRight"; + INFOPLIST_KEY_UISupportedInterfaceOrientations_iPhone = "UIInterfaceOrientationPortrait UIInterfaceOrientationLandscapeLeft UIInterfaceOrientationLandscapeRight"; + IPHONEOS_DEPLOYMENT_TARGET = 17.0; + LD_RUNPATH_SEARCH_PATHS = ( + "$(inherited)", + "@executable_path/Frameworks", + ); + MARKETING_VERSION = 1.0; + PRODUCT_BUNDLE_IDENTIFIER = run.hapi.companion; + PRODUCT_NAME = "$(TARGET_NAME)"; + SWIFT_EMIT_LOC_STRINGS = YES; + SWIFT_VERSION = 5.0; + TARGETED_DEVICE_FAMILY = "1,2"; + }; + name = Release; + }; +/* End XCBuildConfiguration section */ + +/* Begin XCConfigurationList section */ + A100000000000000000000B1 /* Build configuration list for PBXProject "Hapi" */ = { + isa = XCConfigurationList; + buildConfigurations = ( + A100000000000000000000B2 /* Debug */, + A100000000000000000000B3 /* Release */, + ); + defaultConfigurationIsVisible = 0; + defaultConfigurationName = Release; + }; + A100000000000000000000B4 /* Build configuration list for PBXNativeTarget "Hapi" */ = { + isa = XCConfigurationList; + buildConfigurations = ( + A100000000000000000000B5 /* Debug */, + A100000000000000000000B6 /* Release */, + ); + defaultConfigurationIsVisible = 0; + defaultConfigurationName = Release; + }; +/* End XCConfigurationList section */ + +/* Begin XCLocalSwiftPackageReference section */ + A100000000000000000000C1 /* XCLocalSwiftPackageReference "Packages/HapiKit" */ = { + isa = XCLocalSwiftPackageReference; + relativePath = Packages/HapiKit; + }; +/* End XCLocalSwiftPackageReference section */ + +/* Begin XCSwiftPackageProductDependency section */ + A100000000000000000000C2 /* HapiProtocol */ = { + isa = XCSwiftPackageProductDependency; + productName = HapiProtocol; + }; + A100000000000000000000C3 /* HapiClient */ = { + isa = XCSwiftPackageProductDependency; + productName = HapiClient; + }; +/* End XCSwiftPackageProductDependency section */ + }; + rootObject = A100000000000000000000A6 /* Project object */; +} diff --git a/ios/Hapi.xcodeproj/xcshareddata/xcschemes/Hapi.xcscheme b/ios/Hapi.xcodeproj/xcshareddata/xcschemes/Hapi.xcscheme new file mode 100644 index 00000000..265f76fb --- /dev/null +++ b/ios/Hapi.xcodeproj/xcshareddata/xcschemes/Hapi.xcscheme @@ -0,0 +1,78 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/ios/Hapi/Assets.xcassets/AccentColor.colorset/Contents.json b/ios/Hapi/Assets.xcassets/AccentColor.colorset/Contents.json new file mode 100644 index 00000000..eb878970 --- /dev/null +++ b/ios/Hapi/Assets.xcassets/AccentColor.colorset/Contents.json @@ -0,0 +1,11 @@ +{ + "colors" : [ + { + "idiom" : "universal" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/ios/Hapi/Assets.xcassets/AppIcon.appiconset/Contents.json b/ios/Hapi/Assets.xcassets/AppIcon.appiconset/Contents.json new file mode 100644 index 00000000..13613e3e --- /dev/null +++ b/ios/Hapi/Assets.xcassets/AppIcon.appiconset/Contents.json @@ -0,0 +1,13 @@ +{ + "images" : [ + { + "idiom" : "universal", + "platform" : "ios", + "size" : "1024x1024" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/ios/Hapi/Assets.xcassets/Contents.json b/ios/Hapi/Assets.xcassets/Contents.json new file mode 100644 index 00000000..73c00596 --- /dev/null +++ b/ios/Hapi/Assets.xcassets/Contents.json @@ -0,0 +1,6 @@ +{ + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/ios/Hapi/HapiApp.swift b/ios/Hapi/HapiApp.swift new file mode 100644 index 00000000..d65b705b --- /dev/null +++ b/ios/Hapi/HapiApp.swift @@ -0,0 +1,19 @@ +import OSLog +import SwiftUI + +@main +struct HapiApp: App { + private let log = Logger(subsystem: "run.hapi.companion", category: "app") + + var body: some Scene { + WindowGroup { + RootView() + .onOpenURL { url in + // M1d wires this to the pairing flow + // (hapicompanion://bind?hub=&code=). + // For M0 the scheme is registered but intentionally ignored. + log.info("Ignoring URL until pairing lands: \(url.absoluteString, privacy: .public)") + } + } + } +} diff --git a/ios/Hapi/Info.plist b/ios/Hapi/Info.plist new file mode 100644 index 00000000..259f304f --- /dev/null +++ b/ios/Hapi/Info.plist @@ -0,0 +1,19 @@ + + + + + CFBundleURLTypes + + + CFBundleTypeRole + Editor + CFBundleURLName + run.hapi.companion.pairing + CFBundleURLSchemes + + hapicompanion + + + + + diff --git a/ios/Hapi/RootView.swift b/ios/Hapi/RootView.swift new file mode 100644 index 00000000..98365033 --- /dev/null +++ b/ios/Hapi/RootView.swift @@ -0,0 +1,26 @@ +import HapiClient +import HapiProtocol +import SwiftUI + +struct RootView: View { + var body: some View { + NavigationStack { + ContentUnavailableView { + Label("HAPI", systemImage: "antenna.radiowaves.left.and.right") + } description: { + Text("Native companion scaffold (M0). Pairing and sessions arrive in M1.") + } + .navigationTitle("HAPI") + .safeAreaInset(edge: .bottom) { + Text("HapiKit \(HapiClientVersion.current) · protocol v\(ProtocolVersion.supported)") + .font(.footnote) + .foregroundStyle(.secondary) + .padding(.bottom, 8) + } + } + } +} + +#Preview { + RootView() +} diff --git a/ios/Packages/HapiKit/Package.swift b/ios/Packages/HapiKit/Package.swift new file mode 100644 index 00000000..781247fc --- /dev/null +++ b/ios/Packages/HapiKit/Package.swift @@ -0,0 +1,26 @@ +// swift-tools-version: 6.0 +import PackageDescription + +let package = Package( + name: "HapiKit", + platforms: [ + // The app targets iOS 17+. macOS is declared so `swift test` can run + // the package on macOS CI runners (the code is pure Foundation). + .iOS(.v17), + .macOS(.v14), + ], + products: [ + .library(name: "HapiProtocol", targets: ["HapiProtocol"]), + .library(name: "HapiClient", targets: ["HapiClient"]), + ], + targets: [ + // Pure protocol layer: wire models, chat pipeline, window logic. + // Must stay Foundation-only so it can be verified against + // shared/fixtures/** without any UI or transport concerns. + .target(name: "HapiProtocol"), + // Transport layer: APIClient, auth, SSE, stores (lands in M1). + .target(name: "HapiClient", dependencies: ["HapiProtocol"]), + .testTarget(name: "HapiProtocolTests", dependencies: ["HapiProtocol"]), + .testTarget(name: "HapiClientTests", dependencies: ["HapiClient"]), + ] +) diff --git a/ios/Packages/HapiKit/Sources/HapiClient/HapiClientVersion.swift b/ios/Packages/HapiKit/Sources/HapiClient/HapiClientVersion.swift new file mode 100644 index 00000000..6368927c --- /dev/null +++ b/ios/Packages/HapiKit/Sources/HapiClient/HapiClientVersion.swift @@ -0,0 +1,15 @@ +import Foundation +import HapiProtocol + +/// Scaffold marker for the transport layer. +/// +/// APIClient, AuthManager (single-flight refresh), and SSEClient arrive in +/// M1b/M1c. Until then this constant proves the target graph and the +/// HapiClient -> HapiProtocol dependency compile and link. +public enum HapiClientVersion { + /// Version of the HapiKit client scaffold. + public static let current = "0.1.0" + + /// Highest hub protocol generation this client speaks. + public static let protocolVersion = ProtocolVersion.supported +} diff --git a/ios/Packages/HapiKit/Sources/HapiProtocol/JSONValue.swift b/ios/Packages/HapiKit/Sources/HapiProtocol/JSONValue.swift new file mode 100644 index 00000000..e07a260a --- /dev/null +++ b/ios/Packages/HapiKit/Sources/HapiProtocol/JSONValue.swift @@ -0,0 +1,106 @@ +import Foundation + +/// A JSON value exactly as it appears on the wire. +/// +/// The hub protocol carries several free-form JSON payloads (tool inputs, +/// agent event data, metadata blobs). `JSONValue` preserves them losslessly +/// without committing to a schema. Numbers are stored as `Double`, matching +/// JavaScript semantics on the hub side. +public enum JSONValue: Equatable, Hashable, Sendable { + case null + case bool(Bool) + case number(Double) + case string(String) + case array([JSONValue]) + case object([String: JSONValue]) +} + +// MARK: - Codable + +extension JSONValue: Codable { + public init(from decoder: Decoder) throws { + let container = try decoder.singleValueContainer() + if container.decodeNil() { + self = .null + } else if let value = try? container.decode(Bool.self) { + // Foundation's JSON decoder does not coerce 0/1 to Bool (nor + // true/false to Double), so the ordering here is not lossy. + self = .bool(value) + } else if let value = try? container.decode(Double.self) { + self = .number(value) + } else if let value = try? container.decode(String.self) { + self = .string(value) + } else if let value = try? container.decode([JSONValue].self) { + self = .array(value) + } else if let value = try? container.decode([String: JSONValue].self) { + self = .object(value) + } else { + throw DecodingError.dataCorruptedError( + in: container, + debugDescription: "Value is not a representable JSON type" + ) + } + } + + public func encode(to encoder: Encoder) throws { + var container = encoder.singleValueContainer() + switch self { + case .null: + try container.encodeNil() + case .bool(let value): + try container.encode(value) + case .number(let value): + try container.encode(value) + case .string(let value): + try container.encode(value) + case .array(let value): + try container.encode(value) + case .object(let value): + try container.encode(value) + } + } +} + +// MARK: - Literal conveniences (used heavily by tests and fixtures) + +extension JSONValue: ExpressibleByNilLiteral { + public init(nilLiteral: ()) { + self = .null + } +} + +extension JSONValue: ExpressibleByBooleanLiteral { + public init(booleanLiteral value: Bool) { + self = .bool(value) + } +} + +extension JSONValue: ExpressibleByIntegerLiteral { + public init(integerLiteral value: Int) { + self = .number(Double(value)) + } +} + +extension JSONValue: ExpressibleByFloatLiteral { + public init(floatLiteral value: Double) { + self = .number(value) + } +} + +extension JSONValue: ExpressibleByStringLiteral { + public init(stringLiteral value: String) { + self = .string(value) + } +} + +extension JSONValue: ExpressibleByArrayLiteral { + public init(arrayLiteral elements: JSONValue...) { + self = .array(elements) + } +} + +extension JSONValue: ExpressibleByDictionaryLiteral { + public init(dictionaryLiteral elements: (String, JSONValue)...) { + self = .object(Dictionary(uniqueKeysWithValues: elements)) + } +} diff --git a/ios/Packages/HapiKit/Sources/HapiProtocol/Models/Placeholder.swift b/ios/Packages/HapiKit/Sources/HapiProtocol/Models/Placeholder.swift new file mode 100644 index 00000000..1a1a17ed --- /dev/null +++ b/ios/Packages/HapiKit/Sources/HapiProtocol/Models/Placeholder.swift @@ -0,0 +1,11 @@ +import Foundation + +/// The hub protocol generation this client understands. +/// +/// Real wire models (sessions, messages, patches, catalogs) land in M1a. +/// This type exists from M0 so the target, its tests, and the app's package +/// linkage have a stable anchor. +public struct ProtocolVersion: Sendable { + /// Highest protocol generation supported by this build. + public static let supported = 1 +} diff --git a/ios/Packages/HapiKit/Tests/HapiClientTests/SmokeTests.swift b/ios/Packages/HapiKit/Tests/HapiClientTests/SmokeTests.swift new file mode 100644 index 00000000..3842fcb2 --- /dev/null +++ b/ios/Packages/HapiKit/Tests/HapiClientTests/SmokeTests.swift @@ -0,0 +1,10 @@ +import HapiClient +import Testing + +@Suite("HapiClient scaffold") +struct SmokeTests { + @Test func exposesVersionMetadata() { + #expect(!HapiClientVersion.current.isEmpty) + #expect(HapiClientVersion.protocolVersion == 1) + } +} diff --git a/ios/Packages/HapiKit/Tests/HapiProtocolTests/JSONValueTests.swift b/ios/Packages/HapiKit/Tests/HapiProtocolTests/JSONValueTests.swift new file mode 100644 index 00000000..0856edb7 --- /dev/null +++ b/ios/Packages/HapiKit/Tests/HapiProtocolTests/JSONValueTests.swift @@ -0,0 +1,122 @@ +import Foundation +import HapiProtocol +import Testing + +@Suite("JSONValue wire coding") +struct JSONValueTests { + private func decode(_ json: String) throws -> JSONValue { + try JSONDecoder().decode(JSONValue.self, from: Data(json.utf8)) + } + + private func encode(_ value: JSONValue) throws -> String { + let encoder = JSONEncoder() + encoder.outputFormatting = [.sortedKeys] + return String(decoding: try encoder.encode(value), as: UTF8.self) + } + + @Test func decodesEveryScalarKind() throws { + #expect(try decode("null") == .null) + #expect(try decode("true") == .bool(true)) + #expect(try decode("false") == .bool(false)) + #expect(try decode("42") == .number(42)) + #expect(try decode("-7.5") == .number(-7.5)) + #expect(try decode("\"hapi\"") == .string("hapi")) + } + + @Test func decodesEmptyContainers() throws { + #expect(try decode("{}") == .object([:])) + #expect(try decode("[]") == .array([])) + } + + @Test func distinguishesBoolsNumbersAndStrings() throws { + #expect(try decode("1") == .number(1)) + #expect(try decode("1") != .bool(true)) + #expect(try decode("true") == .bool(true)) + #expect(try decode("true") != .number(1)) + #expect(try decode("\"true\"") == .string("true")) + #expect(try decode("\"null\"") == .string("null")) + } + + @Test func decodesNestedDocument() throws { + let json = """ + { + "id": "sess_1", + "seq": 7, + "ratio": 0.5, + "active": true, + "archived": false, + "parent": null, + "tags": ["ios", "m0"], + "meta": {"nested": {"deep": [1, {"leaf": null}]}} + } + """ + let expected: JSONValue = [ + "id": "sess_1", + "seq": 7, + "ratio": 0.5, + "active": true, + "archived": false, + "parent": nil, + "tags": ["ios", "m0"], + "meta": ["nested": ["deep": [1, ["leaf": nil]]]], + ] + #expect(try decode(json) == expected) + } + + @Test func roundTripsNestedComposite() throws { + let original: JSONValue = [ + "role": "agent", + "content": [ + "type": "output", + "data": [ + "blocks": [ + ["text": "hello", "tokens": 12.5, "done": false], + ["text": "world", "tokens": 3, "done": true], + ], + "meta": nil, + "tags": ["a", "b", "c"], + ], + ], + "seq": 42, + ] + let data = try JSONEncoder().encode(original) + let decoded = try JSONDecoder().decode(JSONValue.self, from: data) + #expect(decoded == original) + } + + @Test func roundTripsScalarsAtTopLevel() throws { + for value in [JSONValue.null, .bool(false), .number(0.25), .string("x")] { + let data = try JSONEncoder().encode(value) + let decoded = try JSONDecoder().decode(JSONValue.self, from: data) + #expect(decoded == value) + } + } + + @Test func encodesDeterministicTextForNonNumericValues() throws { + // Numbers are excluded here on purpose: their textual form is an + // implementation detail of JSONEncoder; they are covered by the + // round-trip tests above. + let value: JSONValue = [ + "b": true, + "a": [nil, "x"], + ] + #expect(try encode(value) == #"{"a":[null,"x"],"b":true}"#) + } + + @Test func literalsBuildTheExpectedCases() { + let object: JSONValue = ["k": 1] + #expect(object == .object(["k": .number(1)])) + + let null: JSONValue = nil + #expect(null == .null) + + let array: JSONValue = [true, 2.5, "s"] + #expect(array == .array([.bool(true), .number(2.5), .string("s")])) + } + + @Test func rejectsMalformedJSON() { + #expect(throws: (any Error).self) { + try JSONDecoder().decode(JSONValue.self, from: Data("{".utf8)) + } + } +} diff --git a/ios/README.md b/ios/README.md new file mode 100644 index 00000000..e3370a0a --- /dev/null +++ b/ios/README.md @@ -0,0 +1,81 @@ +# HAPI iOS (native companion) + +Native SwiftUI client for HAPI. Fully independent of `web/` at the code level; +it shares only the protocol contract (`docs/api/`) and the golden fixtures +(`shared/fixtures/`, produced by the consistency track). + +## Requirements + +- Xcode 16 or newer (iOS 17 SDK). No third-party dependencies in M0. +- Deployment target: iOS 17.0. + +## Build + +Open `ios/Hapi.xcodeproj` in Xcode and run the shared `Hapi` scheme, or from +the command line: + +```sh +# App (simulator, no signing) +xcodebuild build -project ios/Hapi.xcodeproj -scheme Hapi \ + -destination 'generic/platform=iOS Simulator' CODE_SIGNING_ALLOWED=NO + +# Package tests (also runs on macOS, the package is pure Foundation) +swift test --package-path ios/Packages/HapiKit +``` + +CI runs both on `macos-15` via `.github/workflows/ios.yml` (triggered by +changes under `ios/**` and `shared/fixtures/**`). + +## Layout + +``` +ios/ + Hapi.xcodeproj/ Hand-rolled minimal project (objectVersion 77). + Hapi/ App target sources. This is an Xcode 16 "synchronized + folder": add files here and they join the target + without touching project.pbxproj. + Packages/HapiKit/ Local SPM package with the real logic: + HapiProtocol Pure-Foundation protocol layer: wire models, JSONValue, + chat pipeline + message window logic (ported from + web/src/chat/**). Validated against shared/fixtures/**. + HapiClient Transport layer: APIClient, AuthManager (single-flight + refresh), SSEClient, @Observable stores, snapshots. +``` + +The app target stays thin; features live in `HapiKit` so they are testable +with `swift test` and free of UI concerns. + +### Fixtures + +`HapiProtocolTests` will run the golden fixtures from the repo-root +`shared/fixtures/` directory, resolved relative to the package directory +(`ios/Packages/HapiKit` -> `../../../shared/fixtures`). The wiring lands in +M2 once the fixture generator (track K, K5) has produced them; until then the +tests are self-contained. + +## Milestones (track A of the native-clients plan) + +- **M0** — this scaffold: project, HapiKit package, CI, one passing test. +- **M1** — foundations: HapiProtocol wire models + catalogs; APIClient + auth + (Keychain, single-flight 401 refresh); SSEClient + reconnect state machine + + versioned patch application (incl. gzip streaming check); pairing flow + (VisionKit scan + `hapicompanion://bind` deep link + multi-hub). +- **M2** — read-only chat: session list; chat pipeline port + (normalize/reducer/toolGroups, fixtures green is the gate); message window + store; Markdown/code/diff renderers; read-only ChatView with paging. +- **M3** — interaction: composer (optimistic send, queue/steer, drafts, + reopen migration, slash commands); permission UX; new session; attachments. +- **M4** — secondary features: files/git; Scratchlist; dictation; + usage/storage (Swift Charts); settings. +- **M5** — polish: zh-CN localization, Dynamic Type/VoiceOver, long-session + memory profiling, App Store material. + +## Notes + +- The `hapicompanion://` URL scheme is registered via `Hapi/Info.plist` + (only `CFBundleURLTypes` lives there; everything else is generated through + `GENERATE_INFOPLIST_FILE` + `INFOPLIST_KEY_*` build settings). +- `run.hapi.companion` is the bundle id; signing is `Automatic` and CI builds + with `CODE_SIGNING_ALLOWED=NO`. +- CI uses the runner's default Xcode; each job prints `xcodebuild -version` + first so failures are attributable to a toolchain bump.