From 23d14f33ea43dd209132a380c4d52bf808048433 Mon Sep 17 00:00:00 2001 From: saravanakumardb1 Date: Sat, 28 Feb 2026 22:38:43 -0800 Subject: [PATCH] =?UTF-8?q?feat(swift-sdk):=20add=206=20new=20components?= =?UTF-8?q?=20=E2=80=94=20BLBlobClient,=20BLKillSwitchClient,=20BLLicenseC?= =?UTF-8?q?lient,=20BLBiometricAuth,=20BLCrashReporter,=20BLAuditLogger?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit New SDK components extracted from product apps: - BLBlobClient — Azure Blob Storage upload via SAS tokens (from LysnrAI BlobService) - BLKillSwitchClient — Kill switch check from platform-service (from LysnrAI KillSwitchService) - BLLicenseClient — License key activation + status (from LysnrAI LicenseService) - BLBiometricAuth — Face ID / Touch ID wrapper (from LysnrAI BiometricAuth) - BLCrashReporter — MetricKit crash reporting (from ChronoMind CrashReporter) - BLAuditLogger — Local rotating JSON audit log (from LysnrAI AuditLogger) SDK now has 13 source files. Updated README with full component table and migration status (3 apps fully migrated, 18 wrappers total). --- packages/swift-platform-sdk/README.md | 42 ++++-- .../Sources/BLAuditLogger.swift | 83 +++++++++++ .../Sources/BLBiometricAuth.swift | 72 ++++++++++ .../Sources/BLBlobClient.swift | 86 ++++++++++++ .../Sources/BLCrashReporter.swift | 129 ++++++++++++++++++ .../Sources/BLKillSwitchClient.swift | 58 ++++++++ .../Sources/BLLicenseClient.swift | 103 ++++++++++++++ .../Sources/ByteLystPlatformSDK.swift | 22 ++- 8 files changed, 579 insertions(+), 16 deletions(-) create mode 100644 packages/swift-platform-sdk/Sources/BLAuditLogger.swift create mode 100644 packages/swift-platform-sdk/Sources/BLBiometricAuth.swift create mode 100644 packages/swift-platform-sdk/Sources/BLBlobClient.swift create mode 100644 packages/swift-platform-sdk/Sources/BLCrashReporter.swift create mode 100644 packages/swift-platform-sdk/Sources/BLKillSwitchClient.swift create mode 100644 packages/swift-platform-sdk/Sources/BLLicenseClient.swift diff --git a/packages/swift-platform-sdk/README.md b/packages/swift-platform-sdk/README.md index 5035a18e..2312d960 100644 --- a/packages/swift-platform-sdk/README.md +++ b/packages/swift-platform-sdk/README.md @@ -13,6 +13,12 @@ Shared Swift platform client for all ByteLyst iOS/watchOS/macOS apps. Eliminates | `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 | +| `BLBlobClient` | Azure Blob Storage upload via SAS tokens from platform-service | +| `BLKillSwitchClient` | Kill switch check from platform-service (fail-open) | +| `BLLicenseClient` | License key activation + status via platform-service | +| `BLBiometricAuth` | Face ID / Touch ID wrapper (LocalAuthentication) | +| `BLCrashReporter` | MetricKit crash and hang reporting with local storage | +| `BLAuditLogger` | Local rotating JSON audit log for debugging | ## Usage @@ -119,27 +125,33 @@ let syncEngine = BLSyncEngine( ## 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 | +| Product | Repo | Wrappers | Status | +| ---------- | ----------------------------------- | -------- | ----------------------------------- | +| ChronoMind | `learning_ai_clock` | 5 files | ✅ Migrated (Cloud/ + Diagnostics/) | +| LysnrAI | `learning_voice_ai_agent` | 9 files | ✅ Migrated (Auth/ + Util/) | +| MindLyst | `learning_multimodal_memory_agents` | 4 files | ✅ Migrated (Services/) | +| PeakPulse | `learning_ai_peakpulse` | — | New — will use SDK from day one | +| NomGap | `learning_ai_fastgap` | — | React Native — uses TS packages | ## 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 | +| ChronoMind (old) | LysnrAI (old) | MindLyst (old) | SDK (new) | +| --------------------------------- | ------------------------------- | ------------------------------- | --------------------- | +| `KeychainHelper` (53 lines) | `KeychainHelper` (60 lines) | `KeychainHelper` (60 lines) | `BLKeychain` | +| `CMTelemetryService` (139 lines) | `TelemetryService` (288 lines) | `TelemetryService` (139 lines) | `BLTelemetryClient` | +| `CMAuthService` (359 lines) | `AuthService` (421 lines) | `AuthService` (389 lines) | `BLAuthClient` | +| `FeatureFlagService` (72 lines) | `FeatureFlagService` (71 lines) | `FeatureFlagService` (72 lines) | `BLFeatureFlagClient` | +| `CrashReporter` (153 lines) | — | — | `BLCrashReporter` | +| — | `BlobService` (118 lines) | — | `BLBlobClient` | +| — | `KillSwitchService` (48 lines) | — | `BLKillSwitchClient` | +| — | `LicenseService` (135 lines) | — | `BLLicenseClient` | +| — | `BiometricAuth` (65 lines) | — | `BLBiometricAuth` | +| — | `AuditLogger` (70 lines) | — | `BLAuditLogger` | +| `PlatformSyncManager` (450 lines) | Various sync files | — | `BLSyncEngine` | -Total duplicated code eliminated: **~1,100+ lines per product app**. +Total duplicated code eliminated: **~2,600+ lines across 3 product apps**. ## Design Decisions diff --git a/packages/swift-platform-sdk/Sources/BLAuditLogger.swift b/packages/swift-platform-sdk/Sources/BLAuditLogger.swift new file mode 100644 index 00000000..ec8e9d26 --- /dev/null +++ b/packages/swift-platform-sdk/Sources/BLAuditLogger.swift @@ -0,0 +1,83 @@ +// ── Audit Logger ──────────────────────────────────────────── +// Generic local audit logger that tracks user actions for debugging. +// Stores events in a rotating JSON file (configurable max entries). +// Product apps configure with a product-specific file name. + +import Foundation + +/// Audit event stored locally. +public struct BLAuditEvent: Codable, Sendable { + public let id: String + public let action: String + public let details: String? + public let timestamp: Date + + public init(action: String, details: String? = nil) { + self.id = UUID().uuidString + self.action = action + self.details = details + self.timestamp = Date() + } +} + +/// Generic local audit logger for all ByteLyst iOS apps. +/// Stores events in a rotating JSON file in the Documents directory. +public enum BLAuditLogger { + + private static var maxEvents = 1000 + private static var fileName = "audit_log.json" + + /// Configure the logger with a product-specific file name and max events. + public static func configure(fileName: String = "audit_log.json", maxEvents: Int = 1000) { + self.fileName = fileName + self.maxEvents = maxEvents + } + + private static var fileURL: URL { + let docs = FileManager.default.urls(for: .documentDirectory, in: .userDomainMask).first! + return docs.appendingPathComponent(fileName) + } + + /// Log a user action. + public static func log(_ action: String, details: String? = nil) { + let event = BLAuditEvent(action: action, details: details) + + var events = loadEvents() + events.append(event) + + // Rotate: keep only the most recent maxEvents + if events.count > maxEvents { + events = Array(events.suffix(maxEvents)) + } + + saveEvents(events) + } + + /// Get all logged events (newest first). + public static func getEvents(limit: Int = 100) -> [BLAuditEvent] { + let events = loadEvents() + return Array(events.suffix(limit).reversed()) + } + + /// Clear all audit logs. + public static func clear() { + try? FileManager.default.removeItem(at: fileURL) + } + + // MARK: - Persistence + + private static func loadEvents() -> [BLAuditEvent] { + guard let data = try? Data(contentsOf: fileURL), + let events = try? JSONDecoder().decode([BLAuditEvent].self, from: data) else { + return [] + } + return events + } + + private static func saveEvents(_ events: [BLAuditEvent]) { + let encoder = JSONEncoder() + encoder.dateEncodingStrategy = .iso8601 + guard let data = try? encoder.encode(events) else { return } + try? data.write(to: fileURL, options: .atomic) + } +} diff --git a/packages/swift-platform-sdk/Sources/BLBiometricAuth.swift b/packages/swift-platform-sdk/Sources/BLBiometricAuth.swift new file mode 100644 index 00000000..0c55faed --- /dev/null +++ b/packages/swift-platform-sdk/Sources/BLBiometricAuth.swift @@ -0,0 +1,72 @@ +// ── Biometric Authentication ──────────────────────────────── +// Generic Face ID / Touch ID wrapper using LocalAuthentication. +// Product apps pass a custom reason string for the biometric prompt. + +import Foundation +import LocalAuthentication + +/// Generic biometric authentication for all ByteLyst iOS apps. +public enum BLBiometricAuth { + + public enum BiometricType { + case faceID, touchID, none + } + + /// Check what biometric type is available on this device. + public static var availableType: BiometricType { + let context = LAContext() + var error: NSError? + guard context.canEvaluatePolicy(.deviceOwnerAuthenticationWithBiometrics, error: &error) else { + return .none + } + switch context.biometryType { + case .faceID: return .faceID + case .touchID: return .touchID + default: return .none + } + } + + /// Whether biometric auth is available on this device. + public static var isAvailable: Bool { + availableType != .none + } + + /// Whether the user has enabled biometric lock in settings. + /// Uses a configurable UserDefaults key. + public static func isEnabled(key: String = "biometric_lock_enabled") -> Bool { + UserDefaults.standard.bool(forKey: key) + } + + /// Set biometric lock enabled state. + public static func setEnabled(_ enabled: Bool, key: String = "biometric_lock_enabled") { + UserDefaults.standard.set(enabled, forKey: key) + } + + /// Authenticate with biometrics only. Returns true on success. + public static func authenticate(reason: String = "Unlock app") async -> Bool { + let context = LAContext() + context.localizedCancelTitle = "Use Password" + + do { + return try await context.evaluatePolicy( + .deviceOwnerAuthenticationWithBiometrics, + localizedReason: reason + ) + } catch { + return false + } + } + + /// Authenticate with biometrics or device passcode fallback. + public static func authenticateWithPasscode(reason: String = "Unlock app") async -> Bool { + let context = LAContext() + do { + return try await context.evaluatePolicy( + .deviceOwnerAuthentication, + localizedReason: reason + ) + } catch { + return false + } + } +} diff --git a/packages/swift-platform-sdk/Sources/BLBlobClient.swift b/packages/swift-platform-sdk/Sources/BLBlobClient.swift new file mode 100644 index 00000000..a211d32f --- /dev/null +++ b/packages/swift-platform-sdk/Sources/BLBlobClient.swift @@ -0,0 +1,86 @@ +// ── Blob Storage Client ───────────────────────────────────── +// Generic Azure Blob Storage client via platform-service SAS tokens. +// Upload files to Azure Blob using SAS URL from POST /api/blob/sas. +// Product apps configure with BLPlatformConfig. + +import Foundation + +/// Response from platform-service SAS token endpoint. +public struct BLSASResponse: Codable, Sendable { + public let sasUrl: String + public let blobUrl: String + public let container: String + public let blobName: String +} + +/// Generic blob storage client for all ByteLyst iOS apps. +/// Handles SAS token acquisition + direct Azure Blob upload. +public final class BLBlobClient { + + private let config: BLPlatformConfig + private let client: BLPlatformClient + + public init(config: BLPlatformConfig, client: BLPlatformClient) { + self.config = config + self.client = client + } + + // MARK: - Upload + + /// Upload data to Azure Blob Storage. + /// 1. Acquires SAS token from platform-service + /// 2. Uploads directly to Azure Blob using the SAS URL + /// Returns the permanent blob URL on success. + public func upload( + data: Data, + container: String, + fileName: String, + contentType: String + ) async throws -> String { + // Step 1: Get SAS token + let sas = try await getSASToken(container: container, blobName: fileName, permissions: "w") + + // Step 2: Upload to blob storage using SAS URL + guard let url = URL(string: sas.sasUrl) else { + throw BLNetworkError.invalidURL(sas.sasUrl) + } + + var request = URLRequest(url: url) + request.httpMethod = "PUT" + request.setValue(contentType, forHTTPHeaderField: "Content-Type") + request.setValue("BlockBlob", forHTTPHeaderField: "x-ms-blob-type") + request.httpBody = data + request.timeoutInterval = 120 + + let (_, response) = try await URLSession.shared.data(for: request) + + guard let http = response as? HTTPURLResponse, + (200...299).contains(http.statusCode) else { + throw BLNetworkError.httpError(statusCode: (response as? HTTPURLResponse)?.statusCode ?? 0, message: "Blob upload failed") + } + + return sas.blobUrl + } + + /// Convenience: upload audio data. + public func uploadAudio(data: Data, fileName: String) async throws -> String { + try await upload(data: data, container: "audio", fileName: fileName, contentType: "audio/wav") + } + + /// Convenience: upload an attachment (image, document, etc.). + public func uploadAttachment(data: Data, fileName: String, contentType: String) async throws -> String { + try await upload(data: data, container: "attachments", fileName: fileName, contentType: contentType) + } + + // MARK: - SAS Token + + /// Get a SAS token from platform-service. + public func getSASToken(container: String, blobName: String, permissions: String = "r") async throws -> BLSASResponse { + let body = [ + "container": container, + "blobName": blobName, + "permissions": permissions, + ] + return try await client.request(path: "/api/blob/sas", method: "POST", body: body, responseType: BLSASResponse.self) + } +} diff --git a/packages/swift-platform-sdk/Sources/BLCrashReporter.swift b/packages/swift-platform-sdk/Sources/BLCrashReporter.swift new file mode 100644 index 00000000..d1d24428 --- /dev/null +++ b/packages/swift-platform-sdk/Sources/BLCrashReporter.swift @@ -0,0 +1,129 @@ +// ── Crash Reporter ────────────────────────────────────────── +// Generic MetricKit-based crash and performance reporting. +// Stores crash diagnostics locally for debugging and feedback forms. +// Product apps configure with a product-specific persistence key. + +import Foundation +import MetricKit + +/// Crash report model stored locally. +public struct BLCrashReport: Codable, Identifiable, Sendable { + public let id: String + public let date: Date + public let exceptionType: String? + public let signal: String? + public let terminationReason: String? + public let callStackData: Data? + + public init(date: Date, exceptionType: String?, signal: String?, terminationReason: String?, callStackTree: Data) { + self.id = UUID().uuidString + self.date = date + self.exceptionType = exceptionType + self.signal = signal + self.terminationReason = terminationReason + self.callStackData = callStackTree + } +} + +/// Generic MetricKit crash reporter for all ByteLyst iOS apps. +/// Subscribes to MetricKit, stores crash reports in UserDefaults. +@MainActor +public final class BLCrashReporter: NSObject, ObservableObject, MXMetricManagerSubscriber { + + private let persistenceKey: String + private let maxReports: Int + + @Published public var lastCrashReport: Date? + @Published public var diagnosticCount: Int = 0 + + public init(productId: String, maxReports: Int = 50) { + self.persistenceKey = "\(productId)-crash-reports" + self.maxReports = maxReports + super.init() + MXMetricManager.shared.add(self) + loadStats() + } + + deinit { + MXMetricManager.shared.remove(self) + } + + // MARK: - MXMetricManagerSubscriber + + nonisolated public func didReceive(_ payloads: [MXMetricPayload]) { + // MetricKit delivers daily aggregated metrics — no action needed by default + } + + nonisolated public func didReceive(_ payloads: [MXDiagnosticPayload]) { + Task { @MainActor [weak self] in + guard let self else { return } + for payload in payloads { + self.processDiagnosticPayload(payload) + } + self.diagnosticCount += payloads.count + self.lastCrashReport = Date() + } + } + + // MARK: - Public API + + /// Get all stored crash reports. + public func loadCrashReports() -> [BLCrashReport] { + guard let data = UserDefaults.standard.data(forKey: persistenceKey), + let reports = try? JSONDecoder().decode([BLCrashReport].self, from: data) else { + return [] + } + return reports + } + + /// Clear all stored crash reports. + public func clearReports() { + UserDefaults.standard.removeObject(forKey: persistenceKey) + diagnosticCount = 0 + } + + // MARK: - Private + + private func processDiagnosticPayload(_ payload: MXDiagnosticPayload) { + if let crashDiagnostics = payload.crashDiagnostics { + for crash in crashDiagnostics { + let report = BLCrashReport( + date: Date(), + exceptionType: crash.exceptionType?.description, + signal: crash.signal?.description, + terminationReason: crash.terminationReason?.description, + callStackTree: crash.callStackTree.jsonRepresentation() + ) + storeCrashReport(report) + } + } + + if let hangDiagnostics = payload.hangDiagnostics { + for hang in hangDiagnostics { + let report = BLCrashReport( + date: Date(), + exceptionType: nil, + signal: nil, + terminationReason: "Hang: \(hang.hangDuration.description)", + callStackTree: hang.callStackTree.jsonRepresentation() + ) + storeCrashReport(report) + } + } + } + + private func storeCrashReport(_ report: BLCrashReport) { + var reports = loadCrashReports() + reports.append(report) + if reports.count > maxReports { + reports = Array(reports.suffix(maxReports)) + } + if let data = try? JSONEncoder().encode(reports) { + UserDefaults.standard.set(data, forKey: persistenceKey) + } + } + + private func loadStats() { + diagnosticCount = loadCrashReports().count + } +} diff --git a/packages/swift-platform-sdk/Sources/BLKillSwitchClient.swift b/packages/swift-platform-sdk/Sources/BLKillSwitchClient.swift new file mode 100644 index 00000000..964c434f --- /dev/null +++ b/packages/swift-platform-sdk/Sources/BLKillSwitchClient.swift @@ -0,0 +1,58 @@ +// ── Kill Switch Client ────────────────────────────────────── +// Checks platform-service kill switch at app launch. +// If the app is disabled server-side, surfaces a maintenance message. +// Fails open — network errors allow the app to run. + +import Foundation + +/// Generic kill switch client for all ByteLyst iOS apps. +/// Checks GET /api/settings/kill-switch?productId=X at launch. +public final class BLKillSwitchClient { + + private let config: BLPlatformConfig + + /// Whether the app is disabled by the server. + public private(set) var isDisabled = false + + /// Maintenance message from the server (empty if not disabled). + public private(set) var maintenanceMessage = "" + + public init(config: BLPlatformConfig) { + self.config = config + } + + /// Check kill switch status. Non-blocking — defaults to enabled on failure (fail open). + public func check() async { + guard let url = URL(string: "\(config.baseURL)/api/settings/kill-switch?productId=\(config.productId)") else { return } + + var request = URLRequest(url: url) + request.timeoutInterval = 5 + + do { + let (data, response) = try await URLSession.shared.data(for: request) + guard let http = response as? HTTPURLResponse, http.statusCode == 200 else { return } + + struct KillSwitchResponse: Codable { + let enabled: Bool? + let disabled: Bool? + let message: String? + } + + let result = try JSONDecoder().decode(KillSwitchResponse.self, from: data) + + // Support both `enabled: false` and `disabled: true` patterns + if result.disabled == true || result.enabled == false { + isDisabled = true + maintenanceMessage = result.message ?? "\(config.productId) is temporarily unavailable for maintenance." + } + } catch { + // Network error — allow the app to run (fail open) + } + } + + /// Reset the kill switch state (e.g. for retry). + public func reset() { + isDisabled = false + maintenanceMessage = "" + } +} diff --git a/packages/swift-platform-sdk/Sources/BLLicenseClient.swift b/packages/swift-platform-sdk/Sources/BLLicenseClient.swift new file mode 100644 index 00000000..6a3320ee --- /dev/null +++ b/packages/swift-platform-sdk/Sources/BLLicenseClient.swift @@ -0,0 +1,103 @@ +// ── License Client ────────────────────────────────────────── +// Generic license key activation via platform-service. +// Flow: enter key → POST /api/licenses/activate → receive tokens. +// Product apps configure with BLPlatformConfig. + +import Foundation + +/// License information returned from platform-service. +public struct BLLicenseInfo: Codable, Sendable { + public let key: String + public let plan: String + public let status: String + public let devicesUsed: Int + public let maxDevices: Int + public let expiresAt: String? + + public init(key: String, plan: String, status: String, devicesUsed: Int, maxDevices: Int, expiresAt: String?) { + self.key = key + self.plan = plan + self.status = status + self.devicesUsed = devicesUsed + self.maxDevices = maxDevices + self.expiresAt = expiresAt + } +} + +/// Activation result containing tokens + license info. +public struct BLActivationResult: Sendable { + public let accessToken: String + public let refreshToken: String + public let license: BLLicenseInfo +} + +/// Generic license client for all ByteLyst iOS apps. +/// Handles license key activation and status checking via platform-service. +public final class BLLicenseClient { + + private let config: BLPlatformConfig + private let client: BLPlatformClient + + public init(config: BLPlatformConfig, client: BLPlatformClient) { + self.config = config + self.client = client + } + + // MARK: - Activate + + /// Activate a license key on this device. + /// Returns activation result with tokens and license info. + public func activate(key: String, deviceId: String, deviceName: String) async throws -> BLActivationResult { + let body: [String: String] = [ + "key": key.uppercased().trimmingCharacters(in: .whitespaces), + "deviceId": deviceId, + "deviceName": deviceName, + "platform": config.platform, + ] + + let (data, _) = try await client.rawRequest(path: "/api/licenses/activate", method: "POST", body: body) + + struct ActivateResponse: Codable { + let accessToken: String + let refreshToken: String + let license: LicenseDoc + } + struct LicenseDoc: Codable { + let key: String + let plan: String + let status: String + let deviceIds: [String] + let maxDevices: Int + let expiresAt: String? + let userId: String + } + + let result = try JSONDecoder().decode(ActivateResponse.self, from: data) + + let info = BLLicenseInfo( + key: result.license.key, + plan: result.license.plan, + status: result.license.status, + devicesUsed: result.license.deviceIds.count, + maxDevices: result.license.maxDevices, + expiresAt: result.license.expiresAt + ) + + return BLActivationResult( + accessToken: result.accessToken, + refreshToken: result.refreshToken, + license: info + ) + } + + // MARK: - Status + + /// Check license status without activating. + public func checkStatus(key: String) async throws -> BLLicenseInfo { + let encodedKey = key.uppercased().trimmingCharacters(in: .whitespaces) + return try await client.request( + path: "/api/licenses/status/\(encodedKey)", + responseType: BLLicenseInfo.self + ) + } +} diff --git a/packages/swift-platform-sdk/Sources/ByteLystPlatformSDK.swift b/packages/swift-platform-sdk/Sources/ByteLystPlatformSDK.swift index f06feda6..fb981c02 100644 --- a/packages/swift-platform-sdk/Sources/ByteLystPlatformSDK.swift +++ b/packages/swift-platform-sdk/Sources/ByteLystPlatformSDK.swift @@ -1,5 +1,6 @@ // ── ByteLystPlatformSDK ────────────────────────────────────── -// Re-exports all public types for convenience. +// Shared Swift platform client for all ByteLyst iOS/watchOS/macOS apps. +// Re-exports all public types via their respective source files. // // Usage in product apps: // import ByteLystPlatformSDK @@ -9,6 +10,25 @@ // let telemetry = BLTelemetryClient(config: config, client: client) // let auth = BLAuthClient(config: config, client: client) // let flags = BLFeatureFlagClient(config: config, client: client) +// let blob = BLBlobClient(config: config, client: client) +// let license = BLLicenseClient(config: config, client: client) +// let killSwitch = BLKillSwitchClient(config: config) +// let crashReporter = BLCrashReporter(productId: config.productId) +// +// Components (13 source files): +// - BLPlatformConfig — Product-specific config (productId, baseURL, bundleId, appGroupId) +// - BLPlatformClient — Generic HTTP client (auth injection, x-request-id, fire-and-forget) +// - BLKeychain — Keychain CRUD (configurable service string) +// - BLTelemetryClient — Telemetry event queue + batch flush +// - BLAuthClient — Full auth operations (login, register, refresh, password ops) +// - BLFeatureFlagClient — Feature flag polling from /flags/poll +// - BLSyncEngine — Generic offline-first sync with BLSyncAdapter protocol +// - BLBlobClient — Azure Blob Storage upload via SAS tokens +// - BLKillSwitchClient — Kill switch check from platform-service +// - BLLicenseClient — License key activation via platform-service +// - BLBiometricAuth — Face ID / Touch ID wrapper +// - BLCrashReporter — MetricKit crash and performance reporting +// - BLAuditLogger — Local rotating JSON audit log // All types are exported via their respective files. // This file exists for module-level documentation only.