feat(swift-sdk): add ByteLystPlatformSDK — shared Swift package for all iOS/watchOS/macOS apps

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+
This commit is contained in:
saravanakumardb1 2026-02-28 22:12:20 -08:00
parent d15f19441f
commit 78000cdf6a
11 changed files with 1290 additions and 0 deletions

View File

@ -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"
),
]
)

View File

@ -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+

View File

@ -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
}
}

View File

@ -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
}
}
}

View File

@ -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
}
}

View File

@ -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<T: Decodable>(
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
}
}

View File

@ -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
)
}
}

View File

@ -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<T> {
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<Adapter: BLSyncAdapter> {
private let config: BLPlatformConfig
private let client: BLPlatformClient
private let adapter: Adapter
private let storagePrefix: String
private var syncTask: Task<Void, Never>?
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<Adapter.SyncItem> {
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")
}
}
}

View File

@ -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..<min($0 + batchSize, events.count)])
}
for chunk in chunks {
sendBatch(chunk)
}
}
// MARK: - Accessors
public func getInstallId() -> 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)
}
}

View File

@ -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.

View File

@ -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")
}
}