diff --git a/.gitignore b/.gitignore index 62a44966..752eeee0 100644 --- a/.gitignore +++ b/.gitignore @@ -21,6 +21,8 @@ __LOCAL_LLMs/models/ __LOCAL_LLMs/.venv-*/ __LOCAL_LLMs/*.wav packages/swift-platform-sdk/build/ +packages/swift-platform-sdk/.build/ +packages/swift-platform-sdk/.swiftpm/ packages/kotlin-platform-sdk/build/ packages/kotlin-platform-sdk/.gradle/ packages/kotlin-platform-sdk/gradle/ diff --git a/packages/swift-platform-sdk/Sources/ByteLystPlatform.swift b/packages/swift-platform-sdk/Sources/ByteLystPlatform.swift new file mode 100644 index 00000000..0d0343c0 --- /dev/null +++ b/packages/swift-platform-sdk/Sources/ByteLystPlatform.swift @@ -0,0 +1,121 @@ +// ── ByteLystPlatform ──────────────────────────────────────── +// Unified entry point for the ByteLyst platform SDK. +// Creates and wires all platform services from a single config. +// +// Usage: +// let platform = ByteLystPlatform(config: .init( +// productId: "peakpulse", +// baseURL: "https://api.peakpulse.app", +// bundleId: "com.saravana.peakpulse" +// )) +// +// platform.start() // Start telemetry + flags + kill switch +// platform.telemetry.trackScreen("home") // Track events +// let isNew = platform.flags.isEnabled("new_feature") +// platform.stop() // Flush + stop timers + +import Foundation + +/// Unified entry point that wires all ByteLyst platform services together. +/// Create one instance at app launch and access services via properties. +public final class ByteLystPlatform { + + /// Platform configuration. + public let config: BLPlatformConfig + + /// HTTP client shared by all services. + public let client: BLPlatformClient + + /// Telemetry event tracking. + public let telemetry: BLTelemetryClient + + /// Feature flag polling. + public let flags: BLFeatureFlagClient + + /// Kill switch checker. + public let killSwitch: BLKillSwitchClient + + /// Crash reporter (MetricKit). + public let crashReporter: BLCrashReporter + + /// Keychain access (via bundleId as service). + public let keychain: BLKeychainAccessor + + /// Audit logger. + public let auditLog: BLAuditLogger + + /// Auth client. + public let auth: BLAuthClient + + /// Whether `start()` has been called. + public private(set) var isStarted = false + + public init(config: BLPlatformConfig) { + self.config = config + self.client = BLPlatformClient(config: config) + self.telemetry = BLTelemetryClient(config: config, client: client) + self.flags = BLFeatureFlagClient(config: config, client: client) + self.killSwitch = BLKillSwitchClient(config: config) + self.crashReporter = BLCrashReporter(productId: config.productId) + self.keychain = BLKeychainAccessor(service: config.bundleId) + self.auditLog = BLAuditLogger(productId: config.productId) + self.auth = BLAuthClient(config: config, client: client) + } + + /// Test-only initializer that accepts a custom URLSessionConfiguration. + public init(config: BLPlatformConfig, sessionConfiguration: URLSessionConfiguration) { + self.config = config + self.client = BLPlatformClient(config: config, sessionConfiguration: sessionConfiguration) + self.telemetry = BLTelemetryClient(config: config, client: client) + self.flags = BLFeatureFlagClient(config: config, client: client) + self.killSwitch = BLKillSwitchClient(config: config) + self.crashReporter = BLCrashReporter(productId: config.productId) + self.keychain = BLKeychainAccessor(service: config.bundleId) + self.auditLog = BLAuditLogger(productId: config.productId) + self.auth = BLAuthClient(config: config, client: client) + } + + // MARK: - Lifecycle + + /// Start all services: telemetry flush timer, feature flag polling, kill switch check. + public func start(userId: String? = nil) { + guard !isStarted else { return } + isStarted = true + telemetry.start() + flags.start(userId: userId) + Task { await killSwitch.check() } + } + + /// Stop all services: flush telemetry, stop flag polling. + public func stop() { + guard isStarted else { return } + isStarted = false + telemetry.stop() + flags.stop() + } +} + +// MARK: - Keychain Accessor + +/// Convenience wrapper around BLKeychain that binds to a specific service (bundleId). +public struct BLKeychainAccessor { + private let service: String + + public init(service: String) { + self.service = service + } + + @discardableResult + public func save(key: String, value: String) -> Bool { + BLKeychain.save(service: service, key: key, value: value) + } + + public func read(key: String) -> String? { + BLKeychain.read(service: service, key: key) + } + + @discardableResult + public func delete(key: String) -> Bool { + BLKeychain.delete(service: service, key: key) + } +} diff --git a/packages/swift-platform-sdk/Tests/BLFeatureFlagClientTests.swift b/packages/swift-platform-sdk/Tests/BLFeatureFlagClientTests.swift new file mode 100644 index 00000000..4f63401a --- /dev/null +++ b/packages/swift-platform-sdk/Tests/BLFeatureFlagClientTests.swift @@ -0,0 +1,31 @@ +import XCTest +@testable import ByteLystPlatformSDK + +final class BLFeatureFlagClientTests: XCTestCase { + + private func makeClient() -> BLFeatureFlagClient { + let config = BLPlatformConfig( + productId: "testapp", + baseURL: "http://localhost:4003", + bundleId: "com.bytelyst.test" + ) + let platformClient = BLPlatformClient(config: config) + return BLFeatureFlagClient(config: config, client: platformClient) + } + + func testDefaultFlagsEmpty() { + let client = makeClient() + XCTAssertEqual(client.allFlags().count, 0) + } + + func testIsEnabledReturnsFalseForUnknownKey() { + let client = makeClient() + XCTAssertFalse(client.isEnabled("nonexistent_flag")) + } + + func testStopDoesNotCrash() { + let client = makeClient() + client.stop() + client.stop() // Double stop + } +} diff --git a/packages/swift-platform-sdk/Tests/BLKillSwitchClientTests.swift b/packages/swift-platform-sdk/Tests/BLKillSwitchClientTests.swift new file mode 100644 index 00000000..e018b0c2 --- /dev/null +++ b/packages/swift-platform-sdk/Tests/BLKillSwitchClientTests.swift @@ -0,0 +1,27 @@ +import XCTest +@testable import ByteLystPlatformSDK + +final class BLKillSwitchClientTests: XCTestCase { + + private func makeConfig() -> BLPlatformConfig { + BLPlatformConfig( + productId: "testapp", + baseURL: "http://localhost:4003", + bundleId: "com.bytelyst.test" + ) + } + + func testDefaultState() { + let client = BLKillSwitchClient(config: makeConfig()) + XCTAssertFalse(client.isDisabled) + XCTAssertEqual(client.maintenanceMessage, "") + } + + func testReset() { + let client = BLKillSwitchClient(config: makeConfig()) + // Manually test reset clears any hypothetical state + client.reset() + XCTAssertFalse(client.isDisabled) + XCTAssertEqual(client.maintenanceMessage, "") + } +} diff --git a/packages/swift-platform-sdk/Tests/BLPlatformConfigTests.swift b/packages/swift-platform-sdk/Tests/BLPlatformConfigTests.swift new file mode 100644 index 00000000..f199d7c4 --- /dev/null +++ b/packages/swift-platform-sdk/Tests/BLPlatformConfigTests.swift @@ -0,0 +1,34 @@ +import XCTest +@testable import ByteLystPlatformSDK + +final class BLPlatformConfigTests: XCTestCase { + + func testInitWithDefaults() { + let config = BLPlatformConfig( + productId: "testapp", + baseURL: "http://localhost:4003", + bundleId: "com.bytelyst.testapp" + ) + XCTAssertEqual(config.productId, "testapp") + XCTAssertEqual(config.baseURL, "http://localhost:4003") + XCTAssertEqual(config.platform, "ios") + XCTAssertEqual(config.channel, "native") + XCTAssertEqual(config.bundleId, "com.bytelyst.testapp") + XCTAssertNil(config.appGroupId) + } + + func testInitWithCustomValues() { + let config = BLPlatformConfig( + productId: "peakpulse", + baseURL: "https://api.peakpulse.app", + platform: "watchos", + channel: "companion", + bundleId: "com.saravana.peakpulse", + appGroupId: "group.com.saravana.peakpulse" + ) + XCTAssertEqual(config.productId, "peakpulse") + XCTAssertEqual(config.platform, "watchos") + XCTAssertEqual(config.channel, "companion") + XCTAssertEqual(config.appGroupId, "group.com.saravana.peakpulse") + } +} diff --git a/packages/swift-platform-sdk/Tests/BLTelemetryClientTests.swift b/packages/swift-platform-sdk/Tests/BLTelemetryClientTests.swift new file mode 100644 index 00000000..dc2d534a --- /dev/null +++ b/packages/swift-platform-sdk/Tests/BLTelemetryClientTests.swift @@ -0,0 +1,65 @@ +import XCTest +@testable import ByteLystPlatformSDK + +final class BLTelemetryClientTests: XCTestCase { + + private func makeClient() -> BLTelemetryClient { + let config = BLPlatformConfig( + productId: "testapp", + baseURL: "http://localhost:4003", + bundleId: "com.bytelyst.test" + ) + let platformClient = BLPlatformClient(config: config) + return BLTelemetryClient(config: config, client: platformClient) + } + + func testInstallIdIsStable() { + let client = makeClient() + let id1 = client.getInstallId() + let id2 = client.getInstallId() + XCTAssertEqual(id1, id2) + XCTAssertFalse(id1.isEmpty) + } + + func testSessionIdIsNotEmpty() { + let client = makeClient() + XCTAssertFalse(client.getSessionId().isEmpty) + } + + func testStartGeneratesNewSessionId() { + let client = makeClient() + let sessionBefore = client.getSessionId() + client.start() + let sessionAfter = client.getSessionId() + // start() rotates session ID + XCTAssertNotEqual(sessionBefore, sessionAfter) + client.stop() + } + + func testStopDoesNotCrash() { + let client = makeClient() + client.stop() // Stop without start + client.start() + client.stop() + client.stop() // Double stop + } + + func testTrackEventDoesNotCrash() { + let client = makeClient() + client.trackEvent("info", module: "test", name: "unit_test") + client.trackEvent("error", module: "test", name: "fail", message: "test error") + client.trackEvent("info", module: "test", name: "tagged", tags: ["key": "value"]) + client.trackEvent("info", module: "test", name: "measured", metrics: ["duration": 1.5]) + } + + func testTrackScreenDoesNotCrash() { + let client = makeClient() + client.trackScreen("home") + client.trackScreen("settings") + } + + func testFlushDoesNotCrashWhenEmpty() { + let client = makeClient() + client.flush() // Empty queue + } +} diff --git a/packages/swift-platform-sdk/Tests/ByteLystPlatformTests.swift b/packages/swift-platform-sdk/Tests/ByteLystPlatformTests.swift new file mode 100644 index 00000000..065c6fd7 --- /dev/null +++ b/packages/swift-platform-sdk/Tests/ByteLystPlatformTests.swift @@ -0,0 +1,67 @@ +import XCTest +@testable import ByteLystPlatformSDK + +final class ByteLystPlatformTests: XCTestCase { + + private func makeConfig() -> BLPlatformConfig { + BLPlatformConfig( + productId: "testapp", + baseURL: "http://localhost:4003/api", + platform: "ios", + channel: "native", + bundleId: "com.bytelyst.test" + ) + } + + func testInitCreatesAllServices() { + let platform = ByteLystPlatform(config: makeConfig()) + XCTAssertEqual(platform.config.productId, "testapp") + XCTAssertNotNil(platform.client) + XCTAssertNotNil(platform.telemetry) + XCTAssertNotNil(platform.flags) + XCTAssertNotNil(platform.killSwitch) + XCTAssertNotNil(platform.crashReporter) + XCTAssertNotNil(platform.keychain) + XCTAssertNotNil(platform.auditLog) + XCTAssertNotNil(platform.auth) + } + + func testStartSetsIsStarted() { + let platform = ByteLystPlatform(config: makeConfig()) + XCTAssertFalse(platform.isStarted) + platform.start() + XCTAssertTrue(platform.isStarted) + } + + func testStopClearsIsStarted() { + let platform = ByteLystPlatform(config: makeConfig()) + platform.start() + XCTAssertTrue(platform.isStarted) + platform.stop() + XCTAssertFalse(platform.isStarted) + } + + func testDoubleStartIsIdempotent() { + let platform = ByteLystPlatform(config: makeConfig()) + platform.start() + platform.start() // Should not crash or double-start + XCTAssertTrue(platform.isStarted) + platform.stop() + } + + func testDoubleStopIsIdempotent() { + let platform = ByteLystPlatform(config: makeConfig()) + platform.start() + platform.stop() + platform.stop() // Should not crash + XCTAssertFalse(platform.isStarted) + } + + func testKeychainAccessor() { + let accessor = BLKeychainAccessor(service: "com.bytelyst.test.accessor") + accessor.save(key: "test_key", value: "hello") + XCTAssertEqual(accessor.read(key: "test_key"), "hello") + accessor.delete(key: "test_key") + XCTAssertNil(accessor.read(key: "test_key")) + } +}