merge: A-M0 iOS scaffold

This commit is contained in:
weishu
2026-08-17 11:25:53 +08:00
17 changed files with 906 additions and 0 deletions
+40
View File
@@ -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
+7
View File
@@ -50,3 +50,10 @@ e2e-output/
.xyz-harness
.agents/
.pi/
# Xcode / iOS build artifacts
ios/**/xcuserdata/
ios/**/.build/
ios/**/.swiftpm/
**/*.xcresult
**/DerivedData/
+316
View File
@@ -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 = "<group>";
};
/* 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 = "<group>";
};
A100000000000000000000A3 /* Products */ = {
isa = PBXGroup;
children = (
A100000000000000000000A4 /* Hapi.app */,
);
name = Products;
sourceTree = "<group>";
};
/* 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 */;
}
@@ -0,0 +1,78 @@
<?xml version="1.0" encoding="UTF-8"?>
<Scheme
LastUpgradeVersion = "1600"
version = "1.7">
<BuildAction
parallelizeBuildables = "YES"
buildImplicitDependencies = "YES"
buildArchitectures = "Automatic">
<BuildActionEntries>
<BuildActionEntry
buildForTesting = "YES"
buildForRunning = "YES"
buildForProfiling = "YES"
buildForArchiving = "YES"
buildForAnalyzing = "YES">
<BuildableReference
BuildableIdentifier = "primary"
BlueprintIdentifier = "A100000000000000000000A5"
BuildableName = "Hapi.app"
BlueprintName = "Hapi"
ReferencedContainer = "container:Hapi.xcodeproj">
</BuildableReference>
</BuildActionEntry>
</BuildActionEntries>
</BuildAction>
<TestAction
buildConfiguration = "Debug"
selectedDebuggerIdentifier = "Xcode.DebuggerFoundation.Debugger.LLDB"
selectedLauncherIdentifier = "Xcode.DebuggerFoundation.Launcher.LLDB"
shouldUseLaunchSchemeArgsEnv = "YES"
shouldAutocreateTestPlan = "YES">
</TestAction>
<LaunchAction
buildConfiguration = "Debug"
selectedDebuggerIdentifier = "Xcode.DebuggerFoundation.Debugger.LLDB"
selectedLauncherIdentifier = "Xcode.DebuggerFoundation.Launcher.LLDB"
launchStyle = "0"
useCustomWorkingDirectory = "NO"
ignoresPersistentStateOnLaunch = "NO"
debugDocumentVersioning = "YES"
debugServiceExtension = "internal"
allowLocationSimulation = "YES">
<BuildableProductRunnable
runnableDebuggingMode = "0">
<BuildableReference
BuildableIdentifier = "primary"
BlueprintIdentifier = "A100000000000000000000A5"
BuildableName = "Hapi.app"
BlueprintName = "Hapi"
ReferencedContainer = "container:Hapi.xcodeproj">
</BuildableReference>
</BuildableProductRunnable>
</LaunchAction>
<ProfileAction
buildConfiguration = "Release"
shouldUseLaunchSchemeArgsEnv = "YES"
savedToolIdentifier = ""
useCustomWorkingDirectory = "NO"
debugDocumentVersioning = "YES">
<BuildableProductRunnable
runnableDebuggingMode = "0">
<BuildableReference
BuildableIdentifier = "primary"
BlueprintIdentifier = "A100000000000000000000A5"
BuildableName = "Hapi.app"
BlueprintName = "Hapi"
ReferencedContainer = "container:Hapi.xcodeproj">
</BuildableReference>
</BuildableProductRunnable>
</ProfileAction>
<AnalyzeAction
buildConfiguration = "Debug">
</AnalyzeAction>
<ArchiveAction
buildConfiguration = "Release"
revealArchiveInOrganizer = "YES">
</ArchiveAction>
</Scheme>
@@ -0,0 +1,11 @@
{
"colors" : [
{
"idiom" : "universal"
}
],
"info" : {
"author" : "xcode",
"version" : 1
}
}
@@ -0,0 +1,13 @@
{
"images" : [
{
"idiom" : "universal",
"platform" : "ios",
"size" : "1024x1024"
}
],
"info" : {
"author" : "xcode",
"version" : 1
}
}
+6
View File
@@ -0,0 +1,6 @@
{
"info" : {
"author" : "xcode",
"version" : 1
}
}
+19
View File
@@ -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=<url>&code=<accessToken>).
// For M0 the scheme is registered but intentionally ignored.
log.info("Ignoring URL until pairing lands: \(url.absoluteString, privacy: .public)")
}
}
}
}
+19
View File
@@ -0,0 +1,19 @@
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<key>CFBundleURLTypes</key>
<array>
<dict>
<key>CFBundleTypeRole</key>
<string>Editor</string>
<key>CFBundleURLName</key>
<string>run.hapi.companion.pairing</string>
<key>CFBundleURLSchemes</key>
<array>
<string>hapicompanion</string>
</array>
</dict>
</array>
</dict>
</plist>
+26
View File
@@ -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()
}
+26
View File
@@ -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"]),
]
)
@@ -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
}
@@ -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))
}
}
@@ -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
}
@@ -0,0 +1,10 @@
import HapiClient
import Testing
@Suite("HapiClient scaffold")
struct SmokeTests {
@Test func exposesVersionMetadata() {
#expect(!HapiClientVersion.current.isEmpty)
#expect(HapiClientVersion.protocolVersion == 1)
}
}
@@ -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))
}
}
}
+81
View File
@@ -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.