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:
parent
78000cdf6a
commit
23d14f33ea
@ -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
|
||||
|
||||
|
||||
83
packages/swift-platform-sdk/Sources/BLAuditLogger.swift
Normal file
83
packages/swift-platform-sdk/Sources/BLAuditLogger.swift
Normal 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)
|
||||
}
|
||||
}
|
||||
72
packages/swift-platform-sdk/Sources/BLBiometricAuth.swift
Normal file
72
packages/swift-platform-sdk/Sources/BLBiometricAuth.swift
Normal 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
|
||||
}
|
||||
}
|
||||
}
|
||||
86
packages/swift-platform-sdk/Sources/BLBlobClient.swift
Normal file
86
packages/swift-platform-sdk/Sources/BLBlobClient.swift
Normal 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)
|
||||
}
|
||||
}
|
||||
129
packages/swift-platform-sdk/Sources/BLCrashReporter.swift
Normal file
129
packages/swift-platform-sdk/Sources/BLCrashReporter.swift
Normal 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
|
||||
}
|
||||
}
|
||||
58
packages/swift-platform-sdk/Sources/BLKillSwitchClient.swift
Normal file
58
packages/swift-platform-sdk/Sources/BLKillSwitchClient.swift
Normal 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 = ""
|
||||
}
|
||||
}
|
||||
103
packages/swift-platform-sdk/Sources/BLLicenseClient.swift
Normal file
103
packages/swift-platform-sdk/Sources/BLLicenseClient.swift
Normal 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
|
||||
)
|
||||
}
|
||||
}
|
||||
@ -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.
|
||||
|
||||
Loading…
Reference in New Issue
Block a user