refactor(ios): migrate Cloud/ files to ByteLystPlatformSDK — eliminate duplicated platform code
Replaced standalone implementations with thin wrappers over shared SDK: - KeychainHelper → delegates to BLKeychain (53→27 lines) - TelemetryService → delegates to BLTelemetryClient (139→55 lines) - FeatureFlagService → delegates to BLFeatureFlagClient (72→39 lines) - AuthService → delegates to BLAuthClient (359→171 lines) - project.yml → added ByteLystPlatformSDK local package dependency Total: 623 lines of duplicated code → 292 lines of thin wrappers. PlatformSyncManager kept as-is (product-specific timer DTOs). All existing call-site APIs preserved — zero breaking changes.
This commit is contained in:
parent
3596a8f350
commit
d1b4534b22
@ -1,50 +1,13 @@
|
|||||||
// ── Auth Service ──────────────────────────────────────────────
|
// ── Auth Service ──────────────────────────────────────────────
|
||||||
// Login, register, refresh, logout via platform-service /auth/* endpoints.
|
// Thin wrapper over ByteLystPlatformSDK's BLAuthClient.
|
||||||
// Stores tokens in Keychain; wires into PlatformSyncManager.
|
// Keeps existing call-site API + ChronoMind-specific sync wiring.
|
||||||
|
// AuthUser = BLAuthUser (re-exported from SDK).
|
||||||
|
|
||||||
import Foundation
|
import Foundation
|
||||||
import SwiftUI
|
import SwiftUI
|
||||||
|
import ByteLystPlatformSDK
|
||||||
|
|
||||||
struct AuthUser: Codable {
|
typealias AuthUser = BLAuthUser
|
||||||
let id: String
|
|
||||||
let email: String
|
|
||||||
let name: String
|
|
||||||
let plan: String
|
|
||||||
let role: String
|
|
||||||
|
|
||||||
enum CodingKeys: String, CodingKey {
|
|
||||||
case id, email, plan, role
|
|
||||||
case name = "displayName"
|
|
||||||
}
|
|
||||||
|
|
||||||
init(id: String, email: String, name: String, plan: String, role: String = "user") {
|
|
||||||
self.id = id
|
|
||||||
self.email = email
|
|
||||||
self.name = name
|
|
||||||
self.plan = plan
|
|
||||||
self.role = role
|
|
||||||
}
|
|
||||||
|
|
||||||
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)
|
|
||||||
name = try c.decode(String.self, forKey: .name)
|
|
||||||
plan = try c.decodeIfPresent(String.self, forKey: .plan) ?? "free"
|
|
||||||
role = try c.decodeIfPresent(String.self, forKey: .role) ?? "user"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private struct TokenResponse: Codable {
|
|
||||||
let accessToken: String
|
|
||||||
let refreshToken: String
|
|
||||||
let user: AuthUser
|
|
||||||
}
|
|
||||||
|
|
||||||
private struct RefreshResponse: Codable {
|
|
||||||
let accessToken: String
|
|
||||||
let refreshToken: String
|
|
||||||
}
|
|
||||||
|
|
||||||
enum CMAuthState {
|
enum CMAuthState {
|
||||||
case loading
|
case loading
|
||||||
@ -62,42 +25,35 @@ final class CMAuthService: ObservableObject {
|
|||||||
@AppStorage("cm_user_name") private var userName = ""
|
@AppStorage("cm_user_name") private var userName = ""
|
||||||
@AppStorage("cm_user_plan") private var userPlan = "free"
|
@AppStorage("cm_user_plan") private var userPlan = "free"
|
||||||
|
|
||||||
private var accessToken: String {
|
private let authClient: BLAuthClient
|
||||||
get { KeychainHelper.read(key: "cm_access_token") ?? "" }
|
private let platformClient: BLPlatformClient
|
||||||
set {
|
|
||||||
if newValue.isEmpty { KeychainHelper.delete(key: "cm_access_token") }
|
|
||||||
else { KeychainHelper.save(key: "cm_access_token", value: newValue) }
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private var refreshToken: String {
|
|
||||||
get { KeychainHelper.read(key: "cm_refresh_token") ?? "" }
|
|
||||||
set {
|
|
||||||
if newValue.isEmpty { KeychainHelper.delete(key: "cm_refresh_token") }
|
|
||||||
else { KeychainHelper.save(key: "cm_refresh_token", value: newValue) }
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private var refreshTimer: Timer?
|
|
||||||
|
|
||||||
private var baseURL: String {
|
|
||||||
Bundle.main.object(forInfoDictionaryKey: "PLATFORM_SERVICE_URL") as? String
|
|
||||||
?? "https://api.chronomind.app"
|
|
||||||
}
|
|
||||||
|
|
||||||
private let productId = "chronomind"
|
|
||||||
|
|
||||||
private init() {
|
private init() {
|
||||||
|
let config = BLPlatformConfig.fromInfoPlist(
|
||||||
|
productId: "chronomind",
|
||||||
|
defaultBaseURL: "https://api.chronomind.app",
|
||||||
|
bundleId: "com.saravana.chronomind",
|
||||||
|
appGroupId: "group.com.chronomind.shared"
|
||||||
|
)
|
||||||
|
platformClient = BLPlatformClient(config: config)
|
||||||
|
authClient = BLAuthClient(config: config, client: platformClient)
|
||||||
|
|
||||||
|
// Wire token updates → sync manager
|
||||||
|
authClient.onTokensUpdated = { [weak self] token in
|
||||||
|
Task { @MainActor in
|
||||||
|
self?.wireSyncToken(token)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
checkExistingSession()
|
checkExistingSession()
|
||||||
}
|
}
|
||||||
|
|
||||||
private func checkExistingSession() {
|
private func checkExistingSession() {
|
||||||
if !accessToken.isEmpty, !userEmail.isEmpty {
|
if authClient.isAuthenticated, !userEmail.isEmpty {
|
||||||
state = .loggedIn(AuthUser(id: "", email: userEmail, name: userName, plan: userPlan))
|
state = .loggedIn(AuthUser(id: "", email: userEmail, displayName: userName, plan: userPlan))
|
||||||
wireSyncToken()
|
wireSyncToken(authClient.accessToken)
|
||||||
startRefreshTimer()
|
|
||||||
Task { await fetchCurrentUser() }
|
Task { await fetchCurrentUser() }
|
||||||
} else if !accessToken.isEmpty {
|
} else if authClient.isAuthenticated {
|
||||||
Task { await fetchCurrentUser() }
|
Task { await fetchCurrentUser() }
|
||||||
} else {
|
} else {
|
||||||
state = .loggedOut
|
state = .loggedOut
|
||||||
@ -108,62 +64,36 @@ final class CMAuthService: ObservableObject {
|
|||||||
|
|
||||||
func login(email: String, password: String) async {
|
func login(email: String, password: String) async {
|
||||||
state = .loading
|
state = .loading
|
||||||
let body: [String: String] = [
|
do {
|
||||||
"email": email,
|
let user = try await authClient.login(email: email, password: password)
|
||||||
"password": password,
|
saveUserInfo(user)
|
||||||
"productId": productId,
|
state = .loggedIn(user)
|
||||||
]
|
} catch {
|
||||||
guard let result = await postAuth(path: "/auth/login", body: body) else {
|
|
||||||
state = .error("Invalid email or password")
|
state = .error("Invalid email or password")
|
||||||
return
|
|
||||||
}
|
}
|
||||||
saveSession(result)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
func register(name: String, email: String, password: String) async {
|
func register(name: String, email: String, password: String) async {
|
||||||
state = .loading
|
state = .loading
|
||||||
let body: [String: String] = [
|
do {
|
||||||
"email": email,
|
let user = try await authClient.register(displayName: name, email: email, password: password)
|
||||||
"displayName": name,
|
saveUserInfo(user)
|
||||||
"password": password,
|
state = .loggedIn(user)
|
||||||
"productId": productId,
|
} catch {
|
||||||
]
|
|
||||||
guard let result = await postAuth(path: "/auth/register", body: body) else {
|
|
||||||
state = .error("Registration failed")
|
state = .error("Registration failed")
|
||||||
return
|
|
||||||
}
|
}
|
||||||
saveSession(result)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
func logout() {
|
func logout() {
|
||||||
stopRefreshTimer()
|
authClient.logout()
|
||||||
accessToken = ""
|
|
||||||
refreshToken = ""
|
|
||||||
userEmail = ""
|
userEmail = ""
|
||||||
userName = ""
|
userName = ""
|
||||||
state = .loggedOut
|
state = .loggedOut
|
||||||
PlatformSyncManager.shared.setAuthToken(nil)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
func forgotPassword(email: String) async -> String? {
|
func forgotPassword(email: String) async -> String? {
|
||||||
guard let url = URL(string: "\(baseURL)/auth/forgot-password"),
|
|
||||||
let jsonData = try? JSONSerialization.data(withJSONObject: [
|
|
||||||
"email": email, "productId": productId
|
|
||||||
]) else { return "Invalid request" }
|
|
||||||
|
|
||||||
var request = URLRequest(url: url)
|
|
||||||
request.httpMethod = "POST"
|
|
||||||
request.setValue("application/json", forHTTPHeaderField: "Content-Type")
|
|
||||||
request.setValue(productId, forHTTPHeaderField: "X-Product-Id")
|
|
||||||
request.setValue(UUID().uuidString, forHTTPHeaderField: "X-Request-Id")
|
|
||||||
request.httpBody = jsonData
|
|
||||||
request.timeoutInterval = 15
|
|
||||||
|
|
||||||
do {
|
do {
|
||||||
let (_, response) = try await URLSession.shared.data(for: request)
|
try await authClient.forgotPassword(email: email)
|
||||||
guard let http = response as? HTTPURLResponse, http.statusCode == 200 else {
|
|
||||||
return "Failed to send reset email"
|
|
||||||
}
|
|
||||||
return nil
|
return nil
|
||||||
} catch {
|
} catch {
|
||||||
return error.localizedDescription
|
return error.localizedDescription
|
||||||
@ -171,28 +101,8 @@ final class CMAuthService: ObservableObject {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func changePassword(currentPassword: String, newPassword: String) async -> String? {
|
func changePassword(currentPassword: String, newPassword: String) async -> String? {
|
||||||
guard !accessToken.isEmpty,
|
|
||||||
let url = URL(string: "\(baseURL)/auth/change-password"),
|
|
||||||
let jsonData = try? JSONSerialization.data(withJSONObject: [
|
|
||||||
"currentPassword": currentPassword, "newPassword": newPassword
|
|
||||||
]) else { return "Not authenticated" }
|
|
||||||
|
|
||||||
var request = URLRequest(url: url)
|
|
||||||
request.httpMethod = "POST"
|
|
||||||
request.setValue("application/json", forHTTPHeaderField: "Content-Type")
|
|
||||||
request.setValue("Bearer \(accessToken)", forHTTPHeaderField: "Authorization")
|
|
||||||
request.setValue(productId, forHTTPHeaderField: "X-Product-Id")
|
|
||||||
request.setValue(UUID().uuidString, forHTTPHeaderField: "X-Request-Id")
|
|
||||||
request.httpBody = jsonData
|
|
||||||
request.timeoutInterval = 15
|
|
||||||
|
|
||||||
do {
|
do {
|
||||||
let (data, response) = try await URLSession.shared.data(for: request)
|
try await authClient.changePassword(currentPassword: currentPassword, newPassword: newPassword)
|
||||||
guard let http = response as? HTTPURLResponse, http.statusCode == 200 else {
|
|
||||||
if let body = try? JSONSerialization.jsonObject(with: data) as? [String: Any],
|
|
||||||
let msg = body["message"] as? String { return msg }
|
|
||||||
return "Failed to change password"
|
|
||||||
}
|
|
||||||
return nil
|
return nil
|
||||||
} catch {
|
} catch {
|
||||||
return error.localizedDescription
|
return error.localizedDescription
|
||||||
@ -200,29 +110,11 @@ final class CMAuthService: ObservableObject {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func deleteAccount(password: String) async -> String? {
|
func deleteAccount(password: String) async -> String? {
|
||||||
guard !accessToken.isEmpty,
|
|
||||||
let url = URL(string: "\(baseURL)/auth/account"),
|
|
||||||
let jsonData = try? JSONSerialization.data(withJSONObject: [
|
|
||||||
"password": password
|
|
||||||
]) else { return "Not authenticated" }
|
|
||||||
|
|
||||||
var request = URLRequest(url: url)
|
|
||||||
request.httpMethod = "DELETE"
|
|
||||||
request.setValue("application/json", forHTTPHeaderField: "Content-Type")
|
|
||||||
request.setValue("Bearer \(accessToken)", forHTTPHeaderField: "Authorization")
|
|
||||||
request.setValue(productId, forHTTPHeaderField: "X-Product-Id")
|
|
||||||
request.setValue(UUID().uuidString, forHTTPHeaderField: "X-Request-Id")
|
|
||||||
request.httpBody = jsonData
|
|
||||||
request.timeoutInterval = 15
|
|
||||||
|
|
||||||
do {
|
do {
|
||||||
let (data, response) = try await URLSession.shared.data(for: request)
|
try await authClient.deleteAccount(password: password)
|
||||||
guard let http = response as? HTTPURLResponse, http.statusCode == 200 else {
|
userEmail = ""
|
||||||
if let body = try? JSONSerialization.jsonObject(with: data) as? [String: Any],
|
userName = ""
|
||||||
let msg = body["message"] as? String { return msg }
|
state = .loggedOut
|
||||||
return "Failed to delete account"
|
|
||||||
}
|
|
||||||
logout()
|
|
||||||
return nil
|
return nil
|
||||||
} catch {
|
} catch {
|
||||||
return error.localizedDescription
|
return error.localizedDescription
|
||||||
@ -240,119 +132,39 @@ final class CMAuthService: ObservableObject {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func getAccessToken() -> String? {
|
func getAccessToken() -> String? {
|
||||||
let t = accessToken
|
authClient.accessToken
|
||||||
return t.isEmpty ? nil : t
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// MARK: - Token Refresh
|
// MARK: - Token Refresh
|
||||||
|
|
||||||
func refreshAccessToken() async -> Bool {
|
func refreshAccessToken() async -> Bool {
|
||||||
guard !refreshToken.isEmpty else { return false }
|
let ok = await authClient.refreshAccessToken()
|
||||||
let body: [String: String] = ["refreshToken": refreshToken]
|
if !ok { logout() }
|
||||||
guard let url = URL(string: "\(baseURL)/auth/refresh"),
|
return ok
|
||||||
let jsonData = try? JSONSerialization.data(withJSONObject: body) else { return false }
|
}
|
||||||
|
|
||||||
var request = URLRequest(url: url)
|
|
||||||
request.httpMethod = "POST"
|
|
||||||
request.setValue("application/json", forHTTPHeaderField: "Content-Type")
|
|
||||||
request.setValue(productId, forHTTPHeaderField: "X-Product-Id")
|
|
||||||
request.httpBody = jsonData
|
|
||||||
request.timeoutInterval = 10
|
|
||||||
|
|
||||||
|
func fetchCurrentUser() async {
|
||||||
do {
|
do {
|
||||||
let (data, response) = try await URLSession.shared.data(for: request)
|
let user = try await authClient.getMe()
|
||||||
guard let http = response as? HTTPURLResponse, http.statusCode == 200 else {
|
saveUserInfo(user)
|
||||||
if let http = response as? HTTPURLResponse, http.statusCode == 401 { logout() }
|
state = .loggedIn(user)
|
||||||
return false
|
|
||||||
}
|
|
||||||
let r = try JSONDecoder().decode(RefreshResponse.self, from: data)
|
|
||||||
accessToken = r.accessToken
|
|
||||||
refreshToken = r.refreshToken
|
|
||||||
wireSyncToken()
|
|
||||||
return true
|
|
||||||
} catch {
|
} catch {
|
||||||
return false
|
if let netErr = error as? BLNetworkError, netErr.statusCode == 401 {
|
||||||
|
let ok = await refreshAccessToken()
|
||||||
|
if ok { await fetchCurrentUser() } else { state = .loggedOut }
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private func startRefreshTimer() {
|
|
||||||
stopRefreshTimer()
|
|
||||||
refreshTimer = Timer.scheduledTimer(withTimeInterval: 45 * 60, repeats: true) { [weak self] _ in
|
|
||||||
guard let self else { return }
|
|
||||||
Task { @MainActor in _ = await self.refreshAccessToken() }
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private func stopRefreshTimer() {
|
|
||||||
refreshTimer?.invalidate()
|
|
||||||
refreshTimer = nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// MARK: - Helpers
|
// MARK: - Helpers
|
||||||
|
|
||||||
private func wireSyncToken() {
|
private func wireSyncToken(_ token: String?) {
|
||||||
PlatformSyncManager.shared.setAuthToken(accessToken.isEmpty ? nil : accessToken)
|
PlatformSyncManager.shared.setAuthToken(token)
|
||||||
}
|
}
|
||||||
|
|
||||||
private func postAuth(path: String, body: [String: String]) async -> TokenResponse? {
|
private func saveUserInfo(_ user: AuthUser) {
|
||||||
guard let url = URL(string: "\(baseURL)\(path)"),
|
userEmail = user.email
|
||||||
let jsonData = try? JSONSerialization.data(withJSONObject: body) else { return nil }
|
userName = user.displayName
|
||||||
|
userPlan = user.plan
|
||||||
var request = URLRequest(url: url)
|
|
||||||
request.httpMethod = "POST"
|
|
||||||
request.setValue("application/json", forHTTPHeaderField: "Content-Type")
|
|
||||||
request.setValue(productId, forHTTPHeaderField: "X-Product-Id")
|
|
||||||
request.setValue(UUID().uuidString, forHTTPHeaderField: "X-Request-Id")
|
|
||||||
request.httpBody = jsonData
|
|
||||||
request.timeoutInterval = 15
|
|
||||||
|
|
||||||
do {
|
|
||||||
let (data, response) = try await URLSession.shared.data(for: request)
|
|
||||||
guard let http = response as? HTTPURLResponse, (200...201).contains(http.statusCode) else { return nil }
|
|
||||||
return try JSONDecoder().decode(TokenResponse.self, from: data)
|
|
||||||
} catch {
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private func saveSession(_ resp: TokenResponse) {
|
|
||||||
accessToken = resp.accessToken
|
|
||||||
refreshToken = resp.refreshToken
|
|
||||||
userEmail = resp.user.email
|
|
||||||
userName = resp.user.name
|
|
||||||
userPlan = resp.user.plan
|
|
||||||
state = .loggedIn(resp.user)
|
|
||||||
wireSyncToken()
|
|
||||||
startRefreshTimer()
|
|
||||||
}
|
|
||||||
|
|
||||||
func fetchCurrentUser() async {
|
|
||||||
guard !accessToken.isEmpty, let url = URL(string: "\(baseURL)/auth/me") else { return }
|
|
||||||
var request = URLRequest(url: url)
|
|
||||||
request.setValue("Bearer \(accessToken)", forHTTPHeaderField: "Authorization")
|
|
||||||
request.setValue(productId, forHTTPHeaderField: "X-Product-Id")
|
|
||||||
request.timeoutInterval = 10
|
|
||||||
|
|
||||||
do {
|
|
||||||
let (data, response) = try await URLSession.shared.data(for: request)
|
|
||||||
guard let http = response as? HTTPURLResponse, http.statusCode == 200 else {
|
|
||||||
if let http = response as? HTTPURLResponse, http.statusCode == 401 {
|
|
||||||
let ok = await refreshAccessToken()
|
|
||||||
if ok { await fetchCurrentUser() } else { state = .loggedOut }
|
|
||||||
}
|
|
||||||
return
|
|
||||||
}
|
|
||||||
struct MeResponse: Codable {
|
|
||||||
let id: String; let email: String; let displayName: String
|
|
||||||
let plan: String?; let role: String?
|
|
||||||
}
|
|
||||||
let info = try JSONDecoder().decode(MeResponse.self, from: data)
|
|
||||||
let plan = info.plan ?? "free"
|
|
||||||
userPlan = plan; userEmail = info.email; userName = info.displayName
|
|
||||||
state = .loggedIn(AuthUser(id: info.id, email: info.email, name: info.displayName, plan: plan, role: info.role ?? "user"))
|
|
||||||
wireSyncToken()
|
|
||||||
} catch {
|
|
||||||
// Keep existing state
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -1,71 +1,38 @@
|
|||||||
// ── Feature Flag Client ───────────────────────────────────────
|
// ── Feature Flag Client ───────────────────────────────────────
|
||||||
// Polls platform-service /flags/poll for feature flags.
|
// Thin wrapper over ByteLystPlatformSDK's BLFeatureFlagClient.
|
||||||
// Flags cached in memory, re-polled every 5 minutes.
|
// Keeps existing call-site API (FeatureFlagService.shared.isEnabled).
|
||||||
// Consumers call FeatureFlagService.shared.isEnabled("flag_key").
|
|
||||||
|
|
||||||
import Foundation
|
import Foundation
|
||||||
|
import ByteLystPlatformSDK
|
||||||
|
|
||||||
@MainActor
|
@MainActor
|
||||||
final class FeatureFlagService: ObservableObject {
|
final class FeatureFlagService: ObservableObject {
|
||||||
static let shared = FeatureFlagService()
|
static let shared = FeatureFlagService()
|
||||||
|
|
||||||
@Published private(set) var flags: [String: Bool] = [:]
|
@Published private(set) var flags: [String: Bool] = [:]
|
||||||
private var pollTimer: Timer?
|
private let flagClient: BLFeatureFlagClient
|
||||||
private let pollIntervalSec: TimeInterval = 5 * 60 // 5 minutes
|
|
||||||
private let productId = "chronomind"
|
|
||||||
|
|
||||||
private var baseURL: String {
|
private init() {
|
||||||
Bundle.main.object(forInfoDictionaryKey: "PLATFORM_SERVICE_URL") as? String
|
let config = BLPlatformConfig.fromInfoPlist(
|
||||||
?? "https://api.chronomind.app"
|
productId: "chronomind",
|
||||||
|
defaultBaseURL: "https://api.chronomind.app",
|
||||||
|
bundleId: "com.saravana.chronomind"
|
||||||
|
)
|
||||||
|
let client = BLPlatformClient(config: config)
|
||||||
|
flagClient = BLFeatureFlagClient(config: config, client: client)
|
||||||
}
|
}
|
||||||
|
|
||||||
private init() {}
|
|
||||||
|
|
||||||
// MARK: - Public API
|
// MARK: - Public API
|
||||||
|
|
||||||
func start(userId: String? = nil) {
|
func start(userId: String? = nil) {
|
||||||
Task { await fetchFlags(userId: userId) }
|
flagClient.start(userId: userId)
|
||||||
pollTimer?.invalidate()
|
|
||||||
pollTimer = Timer.scheduledTimer(withTimeInterval: pollIntervalSec, repeats: true) { [weak self] _ in
|
|
||||||
guard let self else { return }
|
|
||||||
Task { @MainActor in await self.fetchFlags(userId: userId) }
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
func stop() {
|
func stop() {
|
||||||
pollTimer?.invalidate()
|
flagClient.stop()
|
||||||
pollTimer = nil
|
|
||||||
}
|
}
|
||||||
|
|
||||||
func isEnabled(_ key: String) -> Bool {
|
func isEnabled(_ key: String) -> Bool {
|
||||||
flags[key] == true
|
flagClient.isEnabled(key)
|
||||||
}
|
|
||||||
|
|
||||||
// MARK: - Fetch
|
|
||||||
|
|
||||||
private func fetchFlags(userId: String? = nil) async {
|
|
||||||
var components = URLComponents(string: "\(baseURL)/api/flags/poll")
|
|
||||||
var queryItems: [URLQueryItem] = [.init(name: "platform", value: "ios")]
|
|
||||||
if let userId { queryItems.append(.init(name: "userId", value: userId)) }
|
|
||||||
components?.queryItems = queryItems
|
|
||||||
|
|
||||||
guard let url = components?.url else { return }
|
|
||||||
|
|
||||||
var request = URLRequest(url: url)
|
|
||||||
request.setValue(productId, forHTTPHeaderField: "X-Product-Id")
|
|
||||||
request.timeoutInterval = 10
|
|
||||||
|
|
||||||
do {
|
|
||||||
let (data, response) = try await URLSession.shared.data(for: request)
|
|
||||||
guard let http = response as? HTTPURLResponse, http.statusCode == 200 else { return }
|
|
||||||
|
|
||||||
struct FlagsResponse: Codable {
|
|
||||||
let flags: [String: Bool]
|
|
||||||
}
|
|
||||||
let parsed = try JSONDecoder().decode(FlagsResponse.self, from: data)
|
|
||||||
flags = parsed.flags
|
|
||||||
} catch {
|
|
||||||
// Keep existing flags on error
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -1,8 +1,10 @@
|
|||||||
// ── Keychain Helper ───────────────────────────────────────────
|
// ── Keychain Helper ───────────────────────────────────────────
|
||||||
// Lightweight wrapper for storing auth tokens securely in iOS Keychain.
|
// Thin wrapper over ByteLystPlatformSDK's BLKeychain.
|
||||||
|
// Keeps the existing call-site API (KeychainHelper.save/read/delete)
|
||||||
|
// while delegating to the shared implementation.
|
||||||
|
|
||||||
import Foundation
|
import Foundation
|
||||||
import Security
|
import ByteLystPlatformSDK
|
||||||
|
|
||||||
enum KeychainHelper {
|
enum KeychainHelper {
|
||||||
|
|
||||||
@ -10,43 +12,15 @@ enum KeychainHelper {
|
|||||||
|
|
||||||
@discardableResult
|
@discardableResult
|
||||||
static func save(key: String, value: String) -> Bool {
|
static func save(key: String, value: String) -> Bool {
|
||||||
guard let data = value.data(using: .utf8) else { return false }
|
BLKeychain.save(service: service, key: key, value: value)
|
||||||
delete(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
|
|
||||||
}
|
}
|
||||||
|
|
||||||
static func read(key: String) -> String? {
|
static func read(key: String) -> String? {
|
||||||
let query: [String: Any] = [
|
BLKeychain.read(service: service, key: key)
|
||||||
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)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
@discardableResult
|
@discardableResult
|
||||||
static func delete(key: String) -> Bool {
|
static func delete(key: String) -> Bool {
|
||||||
let query: [String: Any] = [
|
BLKeychain.delete(service: service, key: key)
|
||||||
kSecClass as String: kSecClassGenericPassword,
|
|
||||||
kSecAttrService as String: service,
|
|
||||||
kSecAttrAccount as String: key,
|
|
||||||
]
|
|
||||||
let status = SecItemDelete(query as CFDictionary)
|
|
||||||
return status == errSecSuccess || status == errSecItemNotFound
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -1,76 +1,32 @@
|
|||||||
// ── Platform Telemetry Client ─────────────────────────────────
|
// ── Platform Telemetry Client ─────────────────────────────────
|
||||||
// Sends events to platform-service /telemetry/events endpoint.
|
// Thin wrapper over ByteLystPlatformSDK's BLTelemetryClient.
|
||||||
|
// Keeps existing call-site API (CMTelemetryService.shared.trackEvent/trackTimer/trackScreen).
|
||||||
// Privacy: no PII, only action names + timing metrics.
|
// Privacy: no PII, only action names + timing metrics.
|
||||||
// Fire-and-forget — errors never surface to the user.
|
|
||||||
|
|
||||||
import Foundation
|
import Foundation
|
||||||
|
import ByteLystPlatformSDK
|
||||||
|
|
||||||
@MainActor
|
@MainActor
|
||||||
final class CMTelemetryService {
|
final class CMTelemetryService {
|
||||||
static let shared = CMTelemetryService()
|
static let shared = CMTelemetryService()
|
||||||
|
|
||||||
private let productId = "chronomind"
|
private let telemetry: BLTelemetryClient
|
||||||
private let platform = "ios"
|
|
||||||
private let channel = "native"
|
|
||||||
private var queue: [TelemetryPayload] = []
|
|
||||||
private var flushTimer: Timer?
|
|
||||||
private let maxQueue = 50
|
|
||||||
private let flushIntervalSec: TimeInterval = 30
|
|
||||||
|
|
||||||
private var installId: String {
|
private init() {
|
||||||
let key = "chronomind-telemetry-install-id"
|
let config = BLPlatformConfig.fromInfoPlist(
|
||||||
if let id = UserDefaults.standard.string(forKey: key) { return id }
|
productId: "chronomind",
|
||||||
let id = UUID().uuidString
|
defaultBaseURL: "https://api.chronomind.app",
|
||||||
UserDefaults.standard.set(id, forKey: key)
|
bundleId: "com.saravana.chronomind",
|
||||||
return id
|
appGroupId: "group.com.chronomind.shared"
|
||||||
|
)
|
||||||
|
let client = BLPlatformClient(config: config)
|
||||||
|
telemetry = BLTelemetryClient(config: config, client: client)
|
||||||
}
|
}
|
||||||
|
|
||||||
private let sessionId = UUID().uuidString
|
|
||||||
|
|
||||||
private var baseURL: String {
|
|
||||||
Bundle.main.object(forInfoDictionaryKey: "PLATFORM_SERVICE_URL") as? String
|
|
||||||
?? "https://api.chronomind.app"
|
|
||||||
}
|
|
||||||
|
|
||||||
private struct TelemetryPayload: Codable {
|
|
||||||
let id: String
|
|
||||||
let productId: String
|
|
||||||
let anonymousInstallId: String
|
|
||||||
let sessionId: String
|
|
||||||
let platform: String
|
|
||||||
let channel: String
|
|
||||||
let osFamily: String
|
|
||||||
let osVersion: String
|
|
||||||
let appVersion: String
|
|
||||||
let buildNumber: String
|
|
||||||
let releaseChannel: String
|
|
||||||
let eventType: String
|
|
||||||
let module: String
|
|
||||||
let eventName: String
|
|
||||||
let feature: String?
|
|
||||||
let message: String?
|
|
||||||
let tags: [String: String]?
|
|
||||||
let metrics: [String: Double]?
|
|
||||||
let occurredAt: String
|
|
||||||
}
|
|
||||||
|
|
||||||
private init() {}
|
|
||||||
|
|
||||||
// MARK: - Public API
|
// MARK: - Public API
|
||||||
|
|
||||||
func start() {
|
func start() { telemetry.start() }
|
||||||
guard flushTimer == nil else { return }
|
func stop() { telemetry.stop() }
|
||||||
flushTimer = Timer.scheduledTimer(withTimeInterval: flushIntervalSec, repeats: true) { [weak self] _ in
|
|
||||||
guard let self else { return }
|
|
||||||
Task { @MainActor in self.flush() }
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func stop() {
|
|
||||||
flush()
|
|
||||||
flushTimer?.invalidate()
|
|
||||||
flushTimer = nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func trackEvent(
|
func trackEvent(
|
||||||
_ eventType: String,
|
_ eventType: String,
|
||||||
@ -81,32 +37,9 @@ final class CMTelemetryService {
|
|||||||
tags: [String: String]? = nil,
|
tags: [String: String]? = nil,
|
||||||
metrics: [String: Double]? = nil
|
metrics: [String: Double]? = nil
|
||||||
) {
|
) {
|
||||||
let event = TelemetryPayload(
|
telemetry.trackEvent(eventType, module: module, name: name,
|
||||||
id: UUID().uuidString,
|
feature: feature, message: message,
|
||||||
productId: productId,
|
tags: tags, metrics: metrics)
|
||||||
anonymousInstallId: installId,
|
|
||||||
sessionId: sessionId,
|
|
||||||
platform: platform,
|
|
||||||
channel: channel,
|
|
||||||
osFamily: "ios",
|
|
||||||
osVersion: ProcessInfo.processInfo.operatingSystemVersionString,
|
|
||||||
appVersion: Bundle.main.infoDictionary?["CFBundleShortVersionString"] as? String ?? "0.1.0",
|
|
||||||
buildNumber: Bundle.main.infoDictionary?["CFBundleVersion"] as? String ?? "1",
|
|
||||||
releaseChannel: "beta",
|
|
||||||
eventType: eventType,
|
|
||||||
module: module,
|
|
||||||
eventName: name,
|
|
||||||
feature: feature,
|
|
||||||
message: message,
|
|
||||||
tags: tags,
|
|
||||||
metrics: metrics,
|
|
||||||
occurredAt: ISO8601DateFormatter().string(from: Date())
|
|
||||||
)
|
|
||||||
queue.append(event)
|
|
||||||
|
|
||||||
if queue.count >= maxQueue {
|
|
||||||
flush()
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
func trackTimer(_ name: String, tags: [String: String]? = nil, metrics: [String: Double]? = nil) {
|
func trackTimer(_ name: String, tags: [String: String]? = nil, metrics: [String: Double]? = nil) {
|
||||||
@ -114,25 +47,8 @@ final class CMTelemetryService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func trackScreen(_ screen: String) {
|
func trackScreen(_ screen: String) {
|
||||||
trackEvent("info", module: "navigation", name: "screen_view", tags: ["screen": screen])
|
telemetry.trackScreen(screen)
|
||||||
}
|
}
|
||||||
|
|
||||||
func flush() {
|
func flush() { telemetry.flush() }
|
||||||
guard !queue.isEmpty else { return }
|
|
||||||
let batch = queue
|
|
||||||
queue.removeAll()
|
|
||||||
|
|
||||||
Task.detached { [baseURL, productId] in
|
|
||||||
guard let url = URL(string: "\(baseURL)/telemetry/events") else { return }
|
|
||||||
var request = URLRequest(url: url)
|
|
||||||
request.httpMethod = "POST"
|
|
||||||
request.setValue("application/json", forHTTPHeaderField: "Content-Type")
|
|
||||||
request.setValue(productId, forHTTPHeaderField: "X-Product-Id")
|
|
||||||
request.setValue(UUID().uuidString, forHTTPHeaderField: "X-Request-Id")
|
|
||||||
request.httpBody = try? JSONEncoder().encode(["events": batch])
|
|
||||||
request.timeoutInterval = 10
|
|
||||||
|
|
||||||
_ = try? await URLSession.shared.data(for: request)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|||||||
@ -10,6 +10,10 @@ options:
|
|||||||
generateEmptyDirectories: true
|
generateEmptyDirectories: true
|
||||||
groupSortPosition: top
|
groupSortPosition: top
|
||||||
|
|
||||||
|
packages:
|
||||||
|
ByteLystPlatformSDK:
|
||||||
|
path: ../../learning_ai_common_plat/packages/swift-platform-sdk
|
||||||
|
|
||||||
settings:
|
settings:
|
||||||
base:
|
base:
|
||||||
SWIFT_VERSION: "5.9"
|
SWIFT_VERSION: "5.9"
|
||||||
@ -45,6 +49,7 @@ targets:
|
|||||||
dependencies:
|
dependencies:
|
||||||
- target: ChronoMindWidgets
|
- target: ChronoMindWidgets
|
||||||
- target: ChronoMindWatch
|
- target: ChronoMindWatch
|
||||||
|
- package: ByteLystPlatformSDK
|
||||||
entitlements:
|
entitlements:
|
||||||
path: ChronoMind/ChronoMind.entitlements
|
path: ChronoMind/ChronoMind.entitlements
|
||||||
properties:
|
properties:
|
||||||
@ -159,6 +164,8 @@ targets:
|
|||||||
- path: ChronoMind/Shared/Reschedule
|
- path: ChronoMind/Shared/Reschedule
|
||||||
- path: ChronoMind/Shared/Gamification
|
- path: ChronoMind/Shared/Gamification
|
||||||
- path: ChronoMind/Shared/Cloud
|
- path: ChronoMind/Shared/Cloud
|
||||||
|
dependencies:
|
||||||
|
- package: ByteLystPlatformSDK
|
||||||
settings:
|
settings:
|
||||||
base:
|
base:
|
||||||
PRODUCT_BUNDLE_IDENTIFIER: com.chronomind.mac
|
PRODUCT_BUNDLE_IDENTIFIER: com.chronomind.mac
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user