feat(swift-sdk): add 6 new components — BLBlobClient, BLKillSwitchClient, BLLicenseClient, BLBiometricAuth, BLCrashReporter, BLAuditLogger

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).
This commit is contained in:
saravanakumardb1 2026-02-28 22:38:43 -08:00
parent 78000cdf6a
commit 23d14f33ea
8 changed files with 579 additions and 16 deletions

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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