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:
parent
d15f19441f
commit
78000cdf6a
31
packages/swift-platform-sdk/Package.swift
Normal file
31
packages/swift-platform-sdk/Package.swift
Normal 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"
|
||||
),
|
||||
]
|
||||
)
|
||||
156
packages/swift-platform-sdk/README.md
Normal file
156
packages/swift-platform-sdk/README.md
Normal 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+
|
||||
258
packages/swift-platform-sdk/Sources/BLAuthClient.swift
Normal file
258
packages/swift-platform-sdk/Sources/BLAuthClient.swift
Normal 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
|
||||
}
|
||||
}
|
||||
@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
54
packages/swift-platform-sdk/Sources/BLKeychain.swift
Normal file
54
packages/swift-platform-sdk/Sources/BLKeychain.swift
Normal 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
|
||||
}
|
||||
}
|
||||
140
packages/swift-platform-sdk/Sources/BLPlatformClient.swift
Normal file
140
packages/swift-platform-sdk/Sources/BLPlatformClient.swift
Normal 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
|
||||
}
|
||||
}
|
||||
64
packages/swift-platform-sdk/Sources/BLPlatformConfig.swift
Normal file
64
packages/swift-platform-sdk/Sources/BLPlatformConfig.swift
Normal 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
|
||||
)
|
||||
}
|
||||
}
|
||||
240
packages/swift-platform-sdk/Sources/BLSyncEngine.swift
Normal file
240
packages/swift-platform-sdk/Sources/BLSyncEngine.swift
Normal 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")
|
||||
}
|
||||
}
|
||||
}
|
||||
214
packages/swift-platform-sdk/Sources/BLTelemetryClient.swift
Normal file
214
packages/swift-platform-sdk/Sources/BLTelemetryClient.swift
Normal 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)
|
||||
}
|
||||
}
|
||||
@ -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.
|
||||
33
packages/swift-platform-sdk/Tests/BLKeychainTests.swift
Normal file
33
packages/swift-platform-sdk/Tests/BLKeychainTests.swift
Normal 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")
|
||||
}
|
||||
}
|
||||
Loading…
Reference in New Issue
Block a user