From 78000cdf6a82a87ceeb30e58e1ad7656cbcd8adf Mon Sep 17 00:00:00 2001 From: saravanakumardb1 Date: Sat, 28 Feb 2026 22:12:20 -0800 Subject: [PATCH] =?UTF-8?q?feat(swift-sdk):=20add=20ByteLystPlatformSDK=20?= =?UTF-8?q?=E2=80=94=20shared=20Swift=20package=20for=20all=20iOS/watchOS/?= =?UTF-8?q?macOS=20apps?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Extracts duplicated platform integration code from ChronoMind + LysnrAI into a single Swift Package. Eliminates ~1,100+ lines of copied code per product app. Components: - BLPlatformConfig — product-specific configuration (productId, baseURL, bundleId) - BLPlatformClient — generic HTTP client with auth injection, x-request-id, timeout - BLKeychain — Keychain CRUD for secure token storage - BLTelemetryClient — telemetry queue + batch flush (matches @bytelyst/telemetry-client) - BLAuthClient — full auth operations (matches @bytelyst/auth-client) - BLFeatureFlagClient — feature flag polling from platform-service /flags/poll - BLSyncEngine — generic offline-first sync with delta pull + batch push Platforms: iOS 17+, watchOS 10+, macOS 14+ --- packages/swift-platform-sdk/Package.swift | 31 +++ packages/swift-platform-sdk/README.md | 156 +++++++++++ .../Sources/BLAuthClient.swift | 258 ++++++++++++++++++ .../Sources/BLFeatureFlagClient.swift | 86 ++++++ .../Sources/BLKeychain.swift | 54 ++++ .../Sources/BLPlatformClient.swift | 140 ++++++++++ .../Sources/BLPlatformConfig.swift | 64 +++++ .../Sources/BLSyncEngine.swift | 240 ++++++++++++++++ .../Sources/BLTelemetryClient.swift | 214 +++++++++++++++ .../Sources/ByteLystPlatformSDK.swift | 14 + .../Tests/BLKeychainTests.swift | 33 +++ 11 files changed, 1290 insertions(+) create mode 100644 packages/swift-platform-sdk/Package.swift create mode 100644 packages/swift-platform-sdk/README.md create mode 100644 packages/swift-platform-sdk/Sources/BLAuthClient.swift create mode 100644 packages/swift-platform-sdk/Sources/BLFeatureFlagClient.swift create mode 100644 packages/swift-platform-sdk/Sources/BLKeychain.swift create mode 100644 packages/swift-platform-sdk/Sources/BLPlatformClient.swift create mode 100644 packages/swift-platform-sdk/Sources/BLPlatformConfig.swift create mode 100644 packages/swift-platform-sdk/Sources/BLSyncEngine.swift create mode 100644 packages/swift-platform-sdk/Sources/BLTelemetryClient.swift create mode 100644 packages/swift-platform-sdk/Sources/ByteLystPlatformSDK.swift create mode 100644 packages/swift-platform-sdk/Tests/BLKeychainTests.swift diff --git a/packages/swift-platform-sdk/Package.swift b/packages/swift-platform-sdk/Package.swift new file mode 100644 index 00000000..210cceb6 --- /dev/null +++ b/packages/swift-platform-sdk/Package.swift @@ -0,0 +1,31 @@ +// swift-tools-version: 5.9 +// ByteLystPlatformSDK — Shared Swift platform client for all ByteLyst iOS/watchOS/macOS apps. +// Lives in learning_ai_common_plat so every product app references ONE source of truth. + +import PackageDescription + +let package = Package( + name: "ByteLystPlatformSDK", + platforms: [ + .iOS(.v17), + .watchOS(.v10), + .macOS(.v14), + ], + products: [ + .library( + name: "ByteLystPlatformSDK", + targets: ["ByteLystPlatformSDK"] + ), + ], + targets: [ + .target( + name: "ByteLystPlatformSDK", + path: "Sources" + ), + .testTarget( + name: "ByteLystPlatformSDKTests", + dependencies: ["ByteLystPlatformSDK"], + path: "Tests" + ), + ] +) diff --git a/packages/swift-platform-sdk/README.md b/packages/swift-platform-sdk/README.md new file mode 100644 index 00000000..5035a18e --- /dev/null +++ b/packages/swift-platform-sdk/README.md @@ -0,0 +1,156 @@ +# ByteLystPlatformSDK + +Shared Swift platform client for all ByteLyst iOS/watchOS/macOS apps. Eliminates code duplication across products by providing a single source of truth for platform-service integration. + +## What's Inside + +| File | What It Does | +| --------------------- | ----------------------------------------------------------------------------------------------------------------------- | +| `BLPlatformConfig` | Product-specific configuration (productId, baseURL, bundleId, appGroupId) | +| `BLPlatformClient` | Generic HTTP client with auth injection, x-request-id, timeout | +| `BLKeychain` | Keychain CRUD for secure token storage | +| `BLTelemetryClient` | Telemetry event queue + batch flush (matches `@bytelyst/telemetry-client`) | +| `BLAuthClient` | Auth operations: login, register, refresh, password ops, email verify, delete account (matches `@bytelyst/auth-client`) | +| `BLFeatureFlagClient` | Feature flag polling from platform-service `/flags/poll` | +| `BLSyncEngine` | Generic offline-first sync engine with delta pull + batch push | + +## Usage + +### 1. Add to Xcode Project + +In Xcode: **File → Add Package Dependencies → Add Local...** → select this directory: + +``` +../learning_ai_common_plat/packages/swift-platform-sdk/ +``` + +Or in `Package.swift`: + +```swift +.package(path: "../learning_ai_common_plat/packages/swift-platform-sdk") +``` + +### 2. Configure at App Launch + +```swift +import ByteLystPlatformSDK + +// Create config — one per app +let config = BLPlatformConfig.fromInfoPlist( + productId: "peakpulse", + defaultBaseURL: "https://api.peakpulse.app", + bundleId: "com.saravana.peakpulse" +) + +// Create shared HTTP client +let client = BLPlatformClient(config: config) + +// Create services +let telemetry = BLTelemetryClient(config: config, client: client) +let auth = BLAuthClient(config: config, client: client) +let flags = BLFeatureFlagClient(config: config, client: client) +``` + +### 3. Telemetry + +```swift +telemetry.start() +telemetry.trackEvent("info", module: "session", name: "session_started", metrics: ["elevation": 2450.0]) +telemetry.trackScreen("live_tracking") +// On app background: +telemetry.stop() +``` + +### 4. Auth + +```swift +// Login +let user = try await auth.login(email: "user@example.com", password: "secret") + +// Restore on launch +await auth.restoreSession() + +// Listen for state changes +auth.onAuthStateChanged = { state in + switch state { + case .loggedIn(let user): print("Hello, \(user.displayName)") + case .loggedOut: print("Signed out") + case .error(let msg): print("Auth error: \(msg)") + case .loading: print("Loading...") + } +} +``` + +### 5. Feature Flags + +```swift +flags.start(userId: auth.accessToken != nil ? "user-id" : nil) +if flags.isEnabled("peakpulse.pro_charts") { + // Show Pro charts +} +``` + +### 6. Sync Engine + +```swift +// Implement your product-specific adapter +struct PeakSessionSyncAdapter: BLSyncAdapter { + typealias SyncItem = PeakSessionDTO + + func pullDelta(since: Date?, client: BLPlatformClient) async throws -> [PeakSessionDTO] { + var path = "/api/peak-sessions/sync" + if let since { path += "?since=\(ISO8601DateFormatter().string(from: since))" } + return try await client.request(path: path, responseType: [PeakSessionDTO].self) + } + + func pushBatch(_ items: [BLOfflineQueueItem], client: BLPlatformClient) async throws -> BLBatchResult { + let body = try JSONEncoder().encode(["items": items.compactMap(\.payload)]) + let (data, _) = try await client.rawRequest(path: "/api/peak-sessions/batch", method: "POST", body: body) + return try JSONDecoder().decode(BLBatchResult.self, from: data) + } +} + +let syncEngine = BLSyncEngine( + config: config, + client: client, + adapter: PeakSessionSyncAdapter() +) +``` + +## Product Apps Using This SDK + +| Product | Repo | Status | +| ---------- | ----------------------------------- | ---------------------------------------------------- | +| ChronoMind | `learning_ai_clock` | Migration from local Cloud/ files | +| LysnrAI | `learning_voice_ai_agent` | Migration from local Util/ + Auth/ files | +| PeakPulse | `learning_ai_peakpulse` | New — will use SDK from day one | +| NomGap | `learning_ai_fastgap` | Future — React Native, will use TS packages directly | +| MindLyst | `learning_multimodal_memory_agents` | Future — KMP, may need Kotlin equivalent | + +## What This Replaces + +Before this SDK, each iOS app had its own copy of platform integration code: + +| ChronoMind (old) | LysnrAI (old) | SDK (new) | +| --------------------------------- | ------------------------------ | -------------------------------- | +| `CMTelemetryService` (139 lines) | `TelemetryService` (288 lines) | `BLTelemetryClient` | +| `CMAuthService` (359 lines) | `AuthService` (exists) | `BLAuthClient` | +| `KeychainHelper` (53 lines) | `KeychainHelper` (exists) | `BLKeychain` | +| `FeatureFlagService` (72 lines) | `FeatureFlagService` (exists) | `BLFeatureFlagClient` | +| `PlatformSyncManager` (450 lines) | Various sync files | `BLSyncEngine` + product adapter | + +Total duplicated code eliminated: **~1,100+ lines per product app**. + +## Design Decisions + +1. **No `@MainActor`** — the SDK is thread-safe via NSLock. Product apps can wrap in `@MainActor` at the view model layer. +2. **No singletons** — product apps own the lifecycle. Create instances at app launch, inject where needed. +3. **No SwiftUI dependency** — pure Foundation. Works in watchOS, macOS, widgets, extensions. +4. **Protocol-based sync** — `BLSyncAdapter` lets each product define its own DTOs and endpoints while reusing the queue/timer/conflict plumbing. +5. **Fire-and-forget telemetry** — errors never surface to the user. Matches the TypeScript package behavior. + +## Platforms + +- iOS 17+ +- watchOS 10+ +- macOS 14+ diff --git a/packages/swift-platform-sdk/Sources/BLAuthClient.swift b/packages/swift-platform-sdk/Sources/BLAuthClient.swift new file mode 100644 index 00000000..316f0b1b --- /dev/null +++ b/packages/swift-platform-sdk/Sources/BLAuthClient.swift @@ -0,0 +1,258 @@ +// ── Auth Client ────────────────────────────────────────────── +// Generic auth client matching @bytelyst/auth-client TypeScript interface. +// Login, register, refresh, forgot/reset/change password, verify email, delete account. +// Token storage via BLKeychain. Product apps configure via BLPlatformConfig. + +import Foundation + +// MARK: - Public Types + +public struct BLAuthUser: Codable, Sendable { + public let id: String + public let email: String + public let displayName: String + public let plan: String + public let role: String + + enum CodingKeys: String, CodingKey { + case id, email, displayName, plan, role + } + + public init(id: String, email: String, displayName: String, plan: String = "free", role: String = "user") { + self.id = id + self.email = email + self.displayName = displayName + self.plan = plan + self.role = role + } + + public init(from decoder: Decoder) throws { + let c = try decoder.container(keyedBy: CodingKeys.self) + id = try c.decode(String.self, forKey: .id) + email = try c.decode(String.self, forKey: .email) + displayName = try c.decode(String.self, forKey: .displayName) + plan = try c.decodeIfPresent(String.self, forKey: .plan) ?? "free" + role = try c.decodeIfPresent(String.self, forKey: .role) ?? "user" + } +} + +public enum BLAuthState: Sendable { + case loading + case loggedOut + case loggedIn(BLAuthUser) + case error(String) +} + +// MARK: - Auth Client + +/// Generic auth client for all ByteLyst iOS apps. +/// Handles login, register, token refresh, password operations, and account management. +/// Stores tokens in Keychain. Notifies via `onAuthStateChanged` callback. +public final class BLAuthClient { + + private let config: BLPlatformConfig + private let client: BLPlatformClient + private let keychainService: String + + /// Called whenever auth state changes. Set by the product app's view model / observable. + public var onAuthStateChanged: ((BLAuthState) -> Void)? + + /// Called when tokens are updated (for wiring into sync managers). + public var onTokensUpdated: ((String?) -> Void)? + + private var refreshTimer: Timer? + + private struct TokenResponse: Codable { + let accessToken: String + let refreshToken: String + let user: BLAuthUser + } + + private struct RefreshResponse: Codable { + let accessToken: String + let refreshToken: String + } + + private struct MessageResponse: Codable { + let message: String + } + + public init(config: BLPlatformConfig, client: BLPlatformClient) { + self.config = config + self.client = client + self.keychainService = config.bundleId + } + + // MARK: - Token Access + + public var accessToken: String? { + BLKeychain.read(service: keychainService, key: "\(config.productId)_access_token") + } + + public var refreshTokenValue: String? { + BLKeychain.read(service: keychainService, key: "\(config.productId)_refresh_token") + } + + public var isAuthenticated: Bool { + accessToken != nil && !(accessToken?.isEmpty ?? true) + } + + // MARK: - Auth Operations + + /// Login with email and password. + public func login(email: String, password: String) async throws -> BLAuthUser { + let body: [String: String] = [ + "email": email, + "password": password, + "productId": config.productId, + ] + let (data, _) = try await client.rawRequest(path: "/auth/login", method: "POST", body: body) + let result = try JSONDecoder().decode(TokenResponse.self, from: data) + saveTokens(access: result.accessToken, refresh: result.refreshToken) + startRefreshTimer() + return result.user + } + + /// Register a new account. + public func register(displayName: String, email: String, password: String) async throws -> BLAuthUser { + let body: [String: String] = [ + "displayName": displayName, + "email": email, + "password": password, + "productId": config.productId, + ] + let (data, _) = try await client.rawRequest(path: "/auth/register", method: "POST", body: body) + let result = try JSONDecoder().decode(TokenResponse.self, from: data) + saveTokens(access: result.accessToken, refresh: result.refreshToken) + startRefreshTimer() + return result.user + } + + /// Fetch current user profile. + public func getMe() async throws -> BLAuthUser { + return try await client.request(path: "/auth/me", responseType: BLAuthUser.self) + } + + /// Refresh the access token using the stored refresh token. + @discardableResult + public func refreshAccessToken() async -> Bool { + guard let rt = refreshTokenValue, !rt.isEmpty else { return false } + let body = ["refreshToken": rt] + + do { + let (data, _) = try await client.rawRequest(path: "/auth/refresh", method: "POST", body: body) + let result = try JSONDecoder().decode(RefreshResponse.self, from: data) + saveTokens(access: result.accessToken, refresh: result.refreshToken) + return true + } catch { + if let netErr = error as? BLNetworkError, netErr.statusCode == 401 { + logout() + } + return false + } + } + + /// Request password reset email. + public func forgotPassword(email: String) async throws { + let body = ["email": email, "productId": config.productId] + _ = try await client.rawRequest(path: "/auth/forgot-password", method: "POST", body: body) + } + + /// Reset password with token. + public func resetPassword(token: String, newPassword: String) async throws { + let body = ["token": token, "newPassword": newPassword] + _ = try await client.rawRequest(path: "/auth/reset-password", method: "POST", body: body) + } + + /// Change password (authenticated). + public func changePassword(currentPassword: String, newPassword: String) async throws { + let body = ["currentPassword": currentPassword, "newPassword": newPassword] + _ = try await client.rawRequest(path: "/auth/change-password", method: "POST", body: body) + } + + /// Verify email with token. + public func verifyEmail(token: String) async throws { + let body = ["token": token] + _ = try await client.rawRequest(path: "/auth/verify-email", method: "POST", body: body) + } + + /// Resend verification email. + public func resendVerification(email: String) async throws { + let body = ["email": email, "productId": config.productId] + _ = try await client.rawRequest(path: "/auth/resend-verification", method: "POST", body: body) + } + + /// Delete account (requires password confirmation). + public func deleteAccount(password: String) async throws { + let body = ["password": password] + _ = try await client.rawRequest(path: "/auth/account", method: "DELETE", body: body) + logout() + } + + /// Logout — clear tokens and notify. + public func logout() { + stopRefreshTimer() + clearTokens() + onAuthStateChanged?(.loggedOut) + } + + /// Restore session from stored tokens. Call on app launch. + public func restoreSession() async { + guard isAuthenticated else { + onAuthStateChanged?(.loggedOut) + return + } + + onAuthStateChanged?(.loading) + + do { + let user = try await getMe() + onAuthStateChanged?(.loggedIn(user)) + startRefreshTimer() + } catch { + // Try refresh + let ok = await refreshAccessToken() + if ok { + do { + let user = try await getMe() + onAuthStateChanged?(.loggedIn(user)) + startRefreshTimer() + } catch { + onAuthStateChanged?(.loggedOut) + } + } else { + onAuthStateChanged?(.loggedOut) + } + } + } + + // MARK: - Private + + private func saveTokens(access: String, refresh: String) { + BLKeychain.save(service: keychainService, key: "\(config.productId)_access_token", value: access) + BLKeychain.save(service: keychainService, key: "\(config.productId)_refresh_token", value: refresh) + client.authToken = access + onTokensUpdated?(access) + } + + private func clearTokens() { + BLKeychain.delete(service: keychainService, key: "\(config.productId)_access_token") + BLKeychain.delete(service: keychainService, key: "\(config.productId)_refresh_token") + client.authToken = nil + onTokensUpdated?(nil) + } + + private func startRefreshTimer() { + stopRefreshTimer() + // Refresh every 45 minutes (tokens typically expire in 1 hour) + refreshTimer = Timer.scheduledTimer(withTimeInterval: 45 * 60, repeats: true) { [weak self] _ in + guard let self else { return } + Task { await self.refreshAccessToken() } + } + } + + private func stopRefreshTimer() { + refreshTimer?.invalidate() + refreshTimer = nil + } +} diff --git a/packages/swift-platform-sdk/Sources/BLFeatureFlagClient.swift b/packages/swift-platform-sdk/Sources/BLFeatureFlagClient.swift new file mode 100644 index 00000000..62260ad0 --- /dev/null +++ b/packages/swift-platform-sdk/Sources/BLFeatureFlagClient.swift @@ -0,0 +1,86 @@ +// ── Feature Flag Client ────────────────────────────────────── +// Generic feature flag polling from platform-service /flags/poll. +// Flags cached in memory, re-polled at configurable interval. +// Matches the platform-service flags module API. + +import Foundation + +/// Generic feature flag client for all ByteLyst iOS apps. +/// Polls platform-service and caches flag values in memory. +public final class BLFeatureFlagClient { + + private let config: BLPlatformConfig + private let client: BLPlatformClient + private let pollIntervalSec: TimeInterval + + private var flags: [String: Bool] = [:] + private let flagsLock = NSLock() + private var pollTimer: Timer? + + public init( + config: BLPlatformConfig, + client: BLPlatformClient, + pollIntervalSec: TimeInterval = 5 * 60 + ) { + self.config = config + self.client = client + self.pollIntervalSec = pollIntervalSec + } + + // MARK: - Lifecycle + + /// Start polling for feature flags. + public func start(userId: String? = nil) { + Task { await fetchFlags(userId: userId) } + pollTimer?.invalidate() + pollTimer = Timer.scheduledTimer(withTimeInterval: pollIntervalSec, repeats: true) { [weak self] _ in + guard let self else { return } + Task { await self.fetchFlags(userId: userId) } + } + } + + /// Stop polling. + public func stop() { + pollTimer?.invalidate() + pollTimer = nil + } + + // MARK: - Query + + /// Check if a feature flag is enabled. + public func isEnabled(_ key: String) -> Bool { + flagsLock.lock() + defer { flagsLock.unlock() } + return flags[key] == true + } + + /// Get all current flag values. + public func allFlags() -> [String: Bool] { + flagsLock.lock() + defer { flagsLock.unlock() } + return flags + } + + // MARK: - Fetch + + private struct FlagsResponse: Codable { + let flags: [String: Bool] + } + + private func fetchFlags(userId: String? = nil) async { + var path = "/api/flags/poll?platform=\(config.platform)" + if let userId { path += "&userId=\(userId)" } + + do { + let result = try await client.request( + path: path, + responseType: FlagsResponse.self + ) + flagsLock.lock() + flags = result.flags + flagsLock.unlock() + } catch { + // Keep existing flags on error — silent failure + } + } +} diff --git a/packages/swift-platform-sdk/Sources/BLKeychain.swift b/packages/swift-platform-sdk/Sources/BLKeychain.swift new file mode 100644 index 00000000..eec56c9b --- /dev/null +++ b/packages/swift-platform-sdk/Sources/BLKeychain.swift @@ -0,0 +1,54 @@ +// ── Keychain Helper ─────────────────────────────────────────── +// Generic Keychain CRUD for storing auth tokens securely. +// Service identifier is configurable per product via BLPlatformConfig.bundleId. + +import Foundation +import Security + +public enum BLKeychain { + + /// Save a string value to the Keychain. + @discardableResult + public static func save(service: String, key: String, value: String) -> Bool { + guard let data = value.data(using: .utf8) else { return false } + delete(service: service, key: key) + + let query: [String: Any] = [ + kSecClass as String: kSecClassGenericPassword, + kSecAttrService as String: service, + kSecAttrAccount as String: key, + kSecValueData as String: data, + kSecAttrAccessible as String: kSecAttrAccessibleAfterFirstUnlock, + ] + + return SecItemAdd(query as CFDictionary, nil) == errSecSuccess + } + + /// Read a string value from the Keychain. + public static func read(service: String, key: String) -> String? { + let query: [String: Any] = [ + kSecClass as String: kSecClassGenericPassword, + kSecAttrService as String: service, + kSecAttrAccount as String: key, + kSecReturnData as String: true, + kSecMatchLimit as String: kSecMatchLimitOne, + ] + + var result: AnyObject? + let status = SecItemCopyMatching(query as CFDictionary, &result) + guard status == errSecSuccess, let data = result as? Data else { return nil } + return String(data: data, encoding: .utf8) + } + + /// Delete a value from the Keychain. + @discardableResult + public static func delete(service: String, key: String) -> Bool { + let query: [String: Any] = [ + kSecClass as String: kSecClassGenericPassword, + kSecAttrService as String: service, + kSecAttrAccount as String: key, + ] + let status = SecItemDelete(query as CFDictionary) + return status == errSecSuccess || status == errSecItemNotFound + } +} diff --git a/packages/swift-platform-sdk/Sources/BLPlatformClient.swift b/packages/swift-platform-sdk/Sources/BLPlatformClient.swift new file mode 100644 index 00000000..c25c1979 --- /dev/null +++ b/packages/swift-platform-sdk/Sources/BLPlatformClient.swift @@ -0,0 +1,140 @@ +// ── Platform HTTP Client ───────────────────────────────────── +// Generic URLSession wrapper with auth header injection, x-request-id, +// timeout, and product ID header. Used by all BL* services. + +import Foundation + +public final class BLPlatformClient: @unchecked Sendable { + + public let config: BLPlatformConfig + private let session: URLSession + private let encoder: JSONEncoder + private let decoder: JSONDecoder + + /// Auth token injected by BLAuthClient after login/refresh. + private var _authToken: String? + private let tokenLock = NSLock() + + public var authToken: String? { + get { tokenLock.lock(); defer { tokenLock.unlock() }; return _authToken } + set { tokenLock.lock(); defer { tokenLock.unlock() }; _authToken = newValue } + } + + public init(config: BLPlatformConfig, timeoutSeconds: TimeInterval = 15) { + self.config = config + + let urlConfig = URLSessionConfiguration.default + urlConfig.timeoutIntervalForRequest = timeoutSeconds + urlConfig.waitsForConnectivity = true + session = URLSession(configuration: urlConfig) + + encoder = JSONEncoder() + encoder.dateEncodingStrategy = .iso8601 + + decoder = JSONDecoder() + decoder.dateDecodingStrategy = .iso8601 + } + + // MARK: - Public Request Methods + + /// Perform an authenticated request and decode the response. + public func request( + path: String, + method: String = "GET", + body: (any Encodable)? = nil, + responseType: T.Type + ) async throws -> T { + let (data, _) = try await rawRequest(path: path, method: method, body: body) + return try decoder.decode(T.self, from: data) + } + + /// Perform an authenticated request, returning raw (Data, HTTPURLResponse). + public func rawRequest( + path: String, + method: String = "GET", + body: (any Encodable)? = nil + ) async throws -> (Data, HTTPURLResponse) { + guard let url = URL(string: "\(config.baseURL)\(path)") else { + throw BLNetworkError.invalidURL(path) + } + + var request = URLRequest(url: url) + request.httpMethod = method + request.setValue("application/json", forHTTPHeaderField: "Content-Type") + request.setValue(config.productId, forHTTPHeaderField: "X-Product-Id") + request.setValue(UUID().uuidString, forHTTPHeaderField: "X-Request-Id") + + if let token = authToken { + request.setValue("Bearer \(token)", forHTTPHeaderField: "Authorization") + } + + if let body { + request.httpBody = try encoder.encode(body) + } + + let (data, response) = try await session.data(for: request) + + guard let http = response as? HTTPURLResponse else { + throw BLNetworkError.invalidResponse + } + + guard (200...299).contains(http.statusCode) else { + // Try to extract server error message + let message: String? = { + if let json = try? JSONSerialization.jsonObject(with: data) as? [String: Any], + let msg = json["message"] as? String { return msg } + return nil + }() + throw BLNetworkError.httpError(statusCode: http.statusCode, message: message) + } + + return (data, http) + } + + /// Fire-and-forget POST (used by telemetry — errors silently ignored). + public func fireAndForget(path: String, body: Data) { + guard let url = URL(string: "\(config.baseURL)\(path)") else { return } + + var request = URLRequest(url: url) + request.httpMethod = "POST" + request.httpBody = body + request.setValue("application/json", forHTTPHeaderField: "Content-Type") + request.setValue(config.productId, forHTTPHeaderField: "X-Product-Id") + request.setValue(UUID().uuidString, forHTTPHeaderField: "X-Request-Id") + request.timeoutInterval = 10 + + if let token = authToken { + request.setValue("Bearer \(token)", forHTTPHeaderField: "Authorization") + } + + session.dataTask(with: request) { _, _, _ in }.resume() + } + + // MARK: - Encoder/Decoder Access + + public var jsonEncoder: JSONEncoder { encoder } + public var jsonDecoder: JSONDecoder { decoder } +} + +// MARK: - Errors + +public enum BLNetworkError: LocalizedError { + case invalidURL(String) + case invalidResponse + case httpError(statusCode: Int, message: String?) + case notAuthenticated + + public var errorDescription: String? { + switch self { + case .invalidURL(let path): return "Invalid URL: \(path)" + case .invalidResponse: return "Invalid server response" + case .httpError(let code, let msg): return msg ?? "Server error (\(code))" + case .notAuthenticated: return "Not signed in" + } + } + + public var statusCode: Int? { + if case .httpError(let code, _) = self { return code } + return nil + } +} diff --git a/packages/swift-platform-sdk/Sources/BLPlatformConfig.swift b/packages/swift-platform-sdk/Sources/BLPlatformConfig.swift new file mode 100644 index 00000000..c0c7c615 --- /dev/null +++ b/packages/swift-platform-sdk/Sources/BLPlatformConfig.swift @@ -0,0 +1,64 @@ +// ── Platform Configuration ─────────────────────────────────── +// Product-specific config that every BL* service reads from. +// Each app creates ONE config at launch and passes it to all services. + +import Foundation + +/// Configuration for all ByteLyst platform services. +/// Create one instance at app launch and inject into BLTelemetryClient, BLAuthClient, etc. +public struct BLPlatformConfig { + /// Product identifier (e.g. "chronomind", "lysnrai", "peakpulse", "nomgap", "mindlyst"). + public let productId: String + + /// Platform-service base URL (e.g. "https://api.chronomind.app" or "http://localhost:4003/api"). + public let baseURL: String + + /// Platform string sent in telemetry (e.g. "ios", "watchos", "macos"). + public let platform: String + + /// Channel string sent in telemetry (e.g. "native", "mobile_app"). + public let channel: String + + /// Bundle ID used as Keychain service identifier. + public let bundleId: String + + /// App Group ID for sharing data between app and extensions (optional). + public let appGroupId: String? + + public init( + productId: String, + baseURL: String, + platform: String = "ios", + channel: String = "native", + bundleId: String, + appGroupId: String? = nil + ) { + self.productId = productId + self.baseURL = baseURL + self.platform = platform + self.channel = channel + self.bundleId = bundleId + self.appGroupId = appGroupId + } + + /// Convenience: read PLATFORM_SERVICE_URL from Info.plist, fall back to provided default. + public static func fromInfoPlist( + productId: String, + defaultBaseURL: String, + platform: String = "ios", + channel: String = "native", + bundleId: String, + appGroupId: String? = nil + ) -> BLPlatformConfig { + let url = Bundle.main.object(forInfoDictionaryKey: "PLATFORM_SERVICE_URL") as? String + ?? defaultBaseURL + return BLPlatformConfig( + productId: productId, + baseURL: url, + platform: platform, + channel: channel, + bundleId: bundleId, + appGroupId: appGroupId + ) + } +} diff --git a/packages/swift-platform-sdk/Sources/BLSyncEngine.swift b/packages/swift-platform-sdk/Sources/BLSyncEngine.swift new file mode 100644 index 00000000..3bc6a8bc --- /dev/null +++ b/packages/swift-platform-sdk/Sources/BLSyncEngine.swift @@ -0,0 +1,240 @@ +// ── Sync Engine ────────────────────────────────────────────── +// Generic offline-first sync engine for ByteLyst iOS apps. +// Provides: offline queue, periodic sync, delta pull, batch push. +// Product apps supply a SyncAdapter with their DTO types and endpoints. +// +// This extracts the generic parts of ChronoMind's PlatformSyncManager +// so every product app gets offline sync without reimplementing the +// queue, timer, error handling, and conflict resolution plumbing. + +import Foundation + +// MARK: - Sync Adapter Protocol + +/// Product apps implement this protocol to define their sync endpoints and DTO types. +public protocol BLSyncAdapter { + associatedtype SyncItem: Codable + + /// Pull remote changes since the given date. Return empty array if no changes. + func pullDelta(since: Date?, client: BLPlatformClient) async throws -> [SyncItem] + + /// Push a batch of offline queue items. Return IDs of successfully synced items. + func pushBatch(_ items: [BLOfflineQueueItem], client: BLPlatformClient) async throws -> BLBatchResult +} + +// MARK: - Offline Queue Item + +/// A queued change waiting to be synced. +public struct BLOfflineQueueItem: Codable { + public let id: String + public let action: BLSyncAction + public let payload: Data? // JSON-encoded product-specific DTO + public let enqueuedAt: Date + + public init(id: String, action: BLSyncAction, payload: Data?, enqueuedAt: Date = Date()) { + self.id = id + self.action = action + self.payload = payload + self.enqueuedAt = enqueuedAt + } +} + +public enum BLSyncAction: String, Codable { + case create + case update + case delete +} + +// MARK: - Sync Results + +public struct BLSyncResult { + public let pulled: [T] + public let syncedIds: [String] + public let conflicts: [BLSyncConflict] + public let error: String? + + public init(pulled: [T], syncedIds: [String] = [], conflicts: [BLSyncConflict] = [], error: String? = nil) { + self.pulled = pulled + self.syncedIds = syncedIds + self.conflicts = conflicts + self.error = error + } +} + +public struct BLSyncConflict: Codable { + public let id: String + public let serverVersion: Int + + public init(id: String, serverVersion: Int) { + self.id = id + self.serverVersion = serverVersion + } +} + +public struct BLBatchResult: Codable { + public let synced: [String] + public let conflicts: [BLSyncConflict] + public let errors: [String] + + public init(synced: [String] = [], conflicts: [BLSyncConflict] = [], errors: [String] = []) { + self.synced = synced + self.conflicts = conflicts + self.errors = errors + } +} + +// MARK: - Sync Engine + +/// Generic sync engine. Product apps create one instance with their SyncAdapter. +public final class BLSyncEngine { + + private let config: BLPlatformConfig + private let client: BLPlatformClient + private let adapter: Adapter + + private let storagePrefix: String + private var syncTask: Task? + private let syncIntervalSec: TimeInterval + + // MARK: - State + + public private(set) var isSyncing = false + public private(set) var lastSyncDate: Date? + public private(set) var pendingChanges: Int = 0 + public private(set) var lastError: String? + + public var syncEnabled: Bool { + didSet { + UserDefaults.standard.set(syncEnabled, forKey: "\(storagePrefix)-sync-enabled") + if syncEnabled { schedulePeriodicSync() } else { cancelPeriodicSync() } + } + } + + /// Called when sync state changes (for UI binding). + public var onStateChanged: (() -> Void)? + + public init( + config: BLPlatformConfig, + client: BLPlatformClient, + adapter: Adapter, + syncIntervalSec: TimeInterval = 60 + ) { + self.config = config + self.client = client + self.adapter = adapter + self.syncIntervalSec = syncIntervalSec + self.storagePrefix = config.productId + + syncEnabled = UserDefaults.standard.bool(forKey: "\(storagePrefix)-sync-enabled") + + if let date = UserDefaults.standard.object(forKey: "\(storagePrefix)-last-sync") as? Date { + lastSyncDate = date + } + + pendingChanges = loadQueueItems().count + + if syncEnabled { + schedulePeriodicSync() + } + } + + // MARK: - Sync + + /// Full delta sync: pull remote changes, push offline queue. + public func sync() async -> BLSyncResult { + guard syncEnabled, client.authToken != nil else { + return BLSyncResult(pulled: [], error: "Not authenticated or sync disabled") + } + + isSyncing = true + lastError = nil + onStateChanged?() + defer { + isSyncing = false + onStateChanged?() + } + + do { + let pulled = try await adapter.pullDelta(since: lastSyncDate, client: client) + let batchResult = try await pushOfflineQueue() + + lastSyncDate = Date() + UserDefaults.standard.set(lastSyncDate, forKey: "\(storagePrefix)-last-sync") + + return BLSyncResult( + pulled: pulled, + syncedIds: batchResult.synced, + conflicts: batchResult.conflicts, + error: nil + ) + } catch { + lastError = error.localizedDescription + return BLSyncResult(pulled: [], error: error.localizedDescription) + } + } + + // MARK: - Offline Queue + + /// Enqueue a change for later sync. + public func enqueueChange(id: String, action: BLSyncAction, payload: Data?) { + var queue = loadQueueItems() + queue.removeAll { $0.id == id } + queue.append(BLOfflineQueueItem(id: id, action: action, payload: payload)) + saveQueue(queue) + pendingChanges = queue.count + onStateChanged?() + } + + /// Enqueue a delete. + public func enqueueDelete(id: String) { + enqueueChange(id: id, action: .delete, payload: nil) + } + + // MARK: - Private + + private func pushOfflineQueue() async throws -> BLBatchResult { + let queue = loadQueueItems() + guard !queue.isEmpty else { + return BLBatchResult() + } + + let result = try await adapter.pushBatch(queue, client: client) + + // Clear synced items + var remaining = loadQueueItems() + remaining.removeAll { result.synced.contains($0.id) } + saveQueue(remaining) + pendingChanges = remaining.count + + return result + } + + private func schedulePeriodicSync() { + cancelPeriodicSync() + syncTask = Task { [weak self] in + while !Task.isCancelled { + try? await Task.sleep(nanoseconds: UInt64((self?.syncIntervalSec ?? 60) * 1_000_000_000)) + guard let self, self.syncEnabled else { break } + _ = await self.sync() + } + } + } + + private func cancelPeriodicSync() { + syncTask?.cancel() + syncTask = nil + } + + // MARK: - Queue Persistence + + private func loadQueueItems() -> [BLOfflineQueueItem] { + guard let data = UserDefaults.standard.data(forKey: "\(storagePrefix)-offline-queue") else { return [] } + return (try? JSONDecoder().decode([BLOfflineQueueItem].self, from: data)) ?? [] + } + + private func saveQueue(_ queue: [BLOfflineQueueItem]) { + if let data = try? JSONEncoder().encode(queue) { + UserDefaults.standard.set(data, forKey: "\(storagePrefix)-offline-queue") + } + } +} diff --git a/packages/swift-platform-sdk/Sources/BLTelemetryClient.swift b/packages/swift-platform-sdk/Sources/BLTelemetryClient.swift new file mode 100644 index 00000000..c27b242d --- /dev/null +++ b/packages/swift-platform-sdk/Sources/BLTelemetryClient.swift @@ -0,0 +1,214 @@ +// ── Telemetry Client ───────────────────────────────────────── +// Generic telemetry event queue + batch flush to platform-service. +// Matches the @bytelyst/telemetry-client TypeScript package interface. +// Product apps configure with BLPlatformConfig; no hardcoded product IDs. + +import Foundation + +/// Telemetry event matching the platform-service /telemetry/events schema. +public struct BLTelemetryEvent: Codable, Sendable { + public let id: String + public let productId: String + public let anonymousInstallId: String + public let sessionId: String + public let platform: String + public let channel: String + public let osFamily: String + public let osVersion: String + public let appVersion: String + public let buildNumber: String + public let releaseChannel: String + public let eventType: String + public let module: String + public let eventName: String + public var feature: String? + public var message: String? + public var tags: [String: String]? + public var metrics: [String: Double]? + public let occurredAt: String +} + +/// Generic telemetry client. Queues events in memory and flushes periodically. +/// Thread-safe via NSLock. Fire-and-forget — errors never surface to the user. +public final class BLTelemetryClient { + + private let config: BLPlatformConfig + private let client: BLPlatformClient + + private var queue: [[String: Any]] = [] + private let queueLock = NSLock() + private var flushTimer: Timer? + + private let maxQueue: Int + private let batchSize: Int + private let flushIntervalSec: TimeInterval + + private let installId: String + private var sessionId: String + + private let appVersion: String + private let buildNumber: String + private let releaseChannel: String + private let osVersion: String + + /// Optional extra fields added to every event (e.g. deviceModel, locale, timezone). + public var extraFields: [String: String] = [:] + + public init( + config: BLPlatformConfig, + client: BLPlatformClient, + maxQueue: Int = 200, + batchSize: Int = 50, + flushIntervalSec: TimeInterval = 30, + releaseChannel: String = "beta" + ) { + self.config = config + self.client = client + self.maxQueue = maxQueue + self.batchSize = batchSize + self.flushIntervalSec = flushIntervalSec + self.releaseChannel = releaseChannel + + let bundle = Bundle.main + appVersion = bundle.infoDictionary?["CFBundleShortVersionString"] as? String ?? "0.0.0" + buildNumber = bundle.infoDictionary?["CFBundleVersion"] as? String ?? "0" + osVersion = ProcessInfo.processInfo.operatingSystemVersionString + + // Install ID — persisted in UserDefaults (or App Group if configured) + let storageKey = "\(config.productId)-telemetry-install-id" + let defaults: UserDefaults + if let groupId = config.appGroupId, let groupDefaults = UserDefaults(suiteName: groupId) { + defaults = groupDefaults + } else { + defaults = .standard + } + if let existing = defaults.string(forKey: storageKey), !existing.isEmpty { + installId = existing + } else { + let newId = UUID().uuidString + defaults.set(newId, forKey: storageKey) + installId = newId + } + + sessionId = UUID().uuidString + } + + // MARK: - Lifecycle + + /// Start the periodic flush timer. Call on app launch / foreground. + public func start() { + sessionId = UUID().uuidString + guard flushTimer == nil else { return } + flushTimer = Timer.scheduledTimer(withTimeInterval: flushIntervalSec, repeats: true) { [weak self] _ in + self?.flush() + } + } + + /// Stop the flush timer and flush remaining events. Call on app background. + public func stop() { + flush() + flushTimer?.invalidate() + flushTimer = nil + } + + // MARK: - Track + + /// Track a telemetry event. Thread-safe. + public func trackEvent( + _ eventType: String, + module: String, + name: String, + feature: String? = nil, + message: String? = nil, + tags: [String: String]? = nil, + metrics: [String: Double]? = nil, + userId: String? = nil + ) { + var event: [String: Any] = [ + "id": UUID().uuidString, + "productId": config.productId, + "anonymousInstallId": installId, + "sessionId": sessionId, + "platform": config.platform, + "channel": config.channel, + "osFamily": "ios", + "osVersion": osVersion, + "appVersion": appVersion, + "buildNumber": buildNumber, + "releaseChannel": releaseChannel, + "eventType": eventType, + "module": module, + "eventName": name, + "occurredAt": ISO8601DateFormatter().string(from: Date()), + ] + + if let feature { event["feature"] = feature } + if let message { event["message"] = String(message.prefix(512)) } + if let tags { event["tags"] = tags } + if let metrics { event["metrics"] = metrics } + if let userId { event["userId"] = userId } + + for (key, value) in extraFields { + event[key] = value + } + + enqueue(event) + } + + /// Convenience: track a screen view. + public func trackScreen(_ screen: String) { + trackEvent("info", module: "navigation", name: "screen_view", tags: ["screen": screen]) + } + + // MARK: - Flush + + /// Flush all queued events to the server. Thread-safe. + public func flush() { + queueLock.lock() + let events = queue + queue.removeAll() + queueLock.unlock() + + guard !events.isEmpty else { return } + + // Batch into chunks + let chunks = stride(from: 0, to: events.count, by: batchSize).map { + Array(events[$0.. String { installId } + public func getSessionId() -> String { sessionId } + + // MARK: - Private + + private func enqueue(_ event: [String: Any]) { + queueLock.lock() + queue.append(event) + if queue.count > maxQueue { + queue.removeFirst(queue.count - maxQueue) + } + let count = queue.count + queueLock.unlock() + + if count >= batchSize { + flush() + } + } + + private func sendBatch(_ events: [[String: Any]]) { + let body: [String: Any] = [ + "productId": config.productId, + "events": events, + ] + + guard let jsonData = try? JSONSerialization.data(withJSONObject: body) else { return } + client.fireAndForget(path: "/telemetry/events", body: jsonData) + } +} diff --git a/packages/swift-platform-sdk/Sources/ByteLystPlatformSDK.swift b/packages/swift-platform-sdk/Sources/ByteLystPlatformSDK.swift new file mode 100644 index 00000000..f06feda6 --- /dev/null +++ b/packages/swift-platform-sdk/Sources/ByteLystPlatformSDK.swift @@ -0,0 +1,14 @@ +// ── ByteLystPlatformSDK ────────────────────────────────────── +// Re-exports all public types for convenience. +// +// Usage in product apps: +// import ByteLystPlatformSDK +// +// let config = BLPlatformConfig(productId: "peakpulse", baseURL: "...", bundleId: "com.saravana.peakpulse") +// let client = BLPlatformClient(config: config) +// let telemetry = BLTelemetryClient(config: config, client: client) +// let auth = BLAuthClient(config: config, client: client) +// let flags = BLFeatureFlagClient(config: config, client: client) + +// All types are exported via their respective files. +// This file exists for module-level documentation only. diff --git a/packages/swift-platform-sdk/Tests/BLKeychainTests.swift b/packages/swift-platform-sdk/Tests/BLKeychainTests.swift new file mode 100644 index 00000000..046f09a9 --- /dev/null +++ b/packages/swift-platform-sdk/Tests/BLKeychainTests.swift @@ -0,0 +1,33 @@ +import XCTest +@testable import ByteLystPlatformSDK + +final class BLKeychainTests: XCTestCase { + + private let service = "com.bytelyst.test" + + override func tearDown() { + BLKeychain.delete(service: service, key: "test_key") + super.tearDown() + } + + func testSaveAndRead() { + BLKeychain.save(service: service, key: "test_key", value: "hello") + XCTAssertEqual(BLKeychain.read(service: service, key: "test_key"), "hello") + } + + func testReadNonExistent() { + XCTAssertNil(BLKeychain.read(service: service, key: "nonexistent")) + } + + func testDelete() { + BLKeychain.save(service: service, key: "test_key", value: "hello") + BLKeychain.delete(service: service, key: "test_key") + XCTAssertNil(BLKeychain.read(service: service, key: "test_key")) + } + + func testOverwrite() { + BLKeychain.save(service: service, key: "test_key", value: "first") + BLKeychain.save(service: service, key: "test_key", value: "second") + XCTAssertEqual(BLKeychain.read(service: service, key: "test_key"), "second") + } +}