diff --git a/Packages/TableProCore/Package.swift b/Packages/TableProCore/Package.swift index 28fda7c5..f98dc718 100644 --- a/Packages/TableProCore/Package.swift +++ b/Packages/TableProCore/Package.swift @@ -13,7 +13,8 @@ let package = Package( .library(name: "TableProModels", targets: ["TableProModels"]), .library(name: "TableProDatabase", targets: ["TableProDatabase"]), .library(name: "TableProQuery", targets: ["TableProQuery"]), - .library(name: "TableProSync", targets: ["TableProSync"]) + .library(name: "TableProSync", targets: ["TableProSync"]), + .library(name: "TableProAnalytics", targets: ["TableProAnalytics"]) ], targets: [ .target( @@ -41,6 +42,11 @@ let package = Package( dependencies: ["TableProModels"], path: "Sources/TableProSync" ), + .target( + name: "TableProAnalytics", + dependencies: [], + path: "Sources/TableProAnalytics" + ), .testTarget( name: "TableProModelsTests", dependencies: ["TableProModels", "TableProPluginKit"], diff --git a/Packages/TableProCore/Sources/TableProAnalytics/AnalyticsEnvironmentProvider.swift b/Packages/TableProCore/Sources/TableProAnalytics/AnalyticsEnvironmentProvider.swift new file mode 100644 index 00000000..ed3fa54f --- /dev/null +++ b/Packages/TableProCore/Sources/TableProAnalytics/AnalyticsEnvironmentProvider.swift @@ -0,0 +1,47 @@ +// +// AnalyticsEnvironmentProvider.swift +// TableProAnalytics +// + +import Foundation + +/// Protocol that platform-specific apps conform to, providing all environment data for analytics heartbeats. +/// +/// macOS and iOS each implement this with platform-specific data sources (IOKit vs UIDevice, +/// DatabaseManager vs AppState, etc.). The heartbeat service reads these properties at send time +/// to build a fresh payload. +@MainActor +public protocol AnalyticsEnvironmentProvider: AnyObject { + /// SHA256-hashed machine/device identifier (64 hex chars) + var machineId: String { get } + + /// App version string (e.g. "1.2.0") from CFBundleShortVersionString + var appVersion: String? { get } + + /// OS version string (e.g. "macOS 15.1.0" or "iOS 18.2.0") + var osVersion: String { get } + + /// CPU architecture (e.g. "arm64", "x86_64") + var architecture: String { get } + + /// Platform identifier sent to backend ("macos" or "ios") + var platform: String { get } + + /// User locale preference (e.g. "en", "vi", "system") + var locale: String { get } + + /// Whether the user has opted in to analytics + var isAnalyticsEnabled: Bool { get } + + /// Whether the user has a valid license + var hasLicense: Bool { get } + + /// Database type identifiers for active connections (e.g. ["mysql", "postgresql"]) + var activeDatabaseTypes: [String] { get } + + /// Number of active database connections + var activeConnectionCount: Int { get } + + /// HMAC-SHA256 shared secret for request signing (from Info.plist build setting) + var hmacSecret: String? { get } +} diff --git a/Packages/TableProCore/Sources/TableProAnalytics/AnalyticsHeartbeatService.swift b/Packages/TableProCore/Sources/TableProAnalytics/AnalyticsHeartbeatService.swift new file mode 100644 index 00000000..645d32a6 --- /dev/null +++ b/Packages/TableProCore/Sources/TableProAnalytics/AnalyticsHeartbeatService.swift @@ -0,0 +1,144 @@ +// +// AnalyticsHeartbeatService.swift +// TableProAnalytics +// + +import CryptoKit +import Foundation +import os + +/// Shared heartbeat service for macOS and iOS. Sends anonymous usage data to the analytics API. +/// +/// Platform-specific data is injected via `AnalyticsEnvironmentProvider`. The service handles: +/// encoding, HMAC-SHA256 signing, HTTP transport, heartbeat scheduling, and cooldown persistence. +@MainActor +public final class AnalyticsHeartbeatService { + private static let logger = Logger(subsystem: "com.TablePro", category: "AnalyticsHeartbeat") + + private let provider: AnalyticsEnvironmentProvider + + // swiftlint:disable:next force_unwrapping + private let analyticsUrl: URL + + private let heartbeatInterval: TimeInterval + private let initialDelay: TimeInterval + + /// Minimum elapsed time before sending another heartbeat. + /// Prevents duplicate sends on iOS when the app cycles between foreground/background. + private let cooldownInterval: TimeInterval + + private static let lastHeartbeatKey = "com.TablePro.analytics.lastHeartbeatDate" + + private let session: URLSession = { + let config = URLSessionConfiguration.default + config.timeoutIntervalForRequest = 15 + config.timeoutIntervalForResource = 30 + config.waitsForConnectivity = true + return URLSession(configuration: config) + }() + + private let encoder: JSONEncoder = { + let encoder = JSONEncoder() + encoder.keyEncodingStrategy = .convertToSnakeCase + return encoder + }() + + public init( + provider: AnalyticsEnvironmentProvider, + analyticsUrl: URL = URL(string: "https://api.tablepro.app/v1/analytics")!, // swiftlint:disable:this force_unwrapping + heartbeatInterval: TimeInterval = 24 * 60 * 60, + initialDelay: TimeInterval = 10, + cooldownInterval: TimeInterval = 20 * 60 * 60 + ) { + self.provider = provider + self.analyticsUrl = analyticsUrl + self.heartbeatInterval = heartbeatInterval + self.initialDelay = initialDelay + self.cooldownInterval = cooldownInterval + } + + // MARK: - Public API + + /// Start the periodic heartbeat loop. Returns a cancellable Task. + /// The caller owns the Task lifecycle (cancel on deinit or background). + public func startPeriodicHeartbeat() -> Task { + Task { [weak self] in + guard let delay = self?.initialDelay else { return } + try? await Task.sleep(for: .seconds(delay)) + + while !Task.isCancelled { + guard let target = self else { return } + await target.sendHeartbeat() + try? await Task.sleep(for: .seconds(target.heartbeatInterval)) + } + } + } + + /// Send a single heartbeat. Respects opt-out and cooldown. + public func sendHeartbeat() async { + guard provider.isAnalyticsEnabled else { + Self.logger.trace("Analytics disabled by user, skipping heartbeat") + return + } + + guard isCooldownElapsed() else { + Self.logger.trace("Analytics cooldown not elapsed, skipping heartbeat") + return + } + + let payload = buildPayload() + + do { + var request = URLRequest(url: analyticsUrl) + request.httpMethod = "POST" + request.setValue("application/json", forHTTPHeaderField: "Content-Type") + request.httpBody = try encoder.encode(payload) + + if let body = request.httpBody, + let secret = provider.hmacSecret, !secret.isEmpty { + let key = SymmetricKey(data: Data(secret.utf8)) + let signature = HMAC.authenticationCode(for: body, using: key) + let signatureHex = signature.map { String(format: "%02x", $0) }.joined() + request.setValue(signatureHex, forHTTPHeaderField: "X-Signature") + } + + let (_, response) = try await session.data(for: request) + + if let httpResponse = response as? HTTPURLResponse { + Self.logger.trace("Analytics heartbeat sent, status: \(httpResponse.statusCode)") + } + + recordHeartbeatTimestamp() + } catch { + Self.logger.trace("Analytics heartbeat failed: \(error.localizedDescription)") + } + } + + // MARK: - Private + + private func buildPayload() -> AnalyticsPayload { + let types = provider.activeDatabaseTypes + return AnalyticsPayload( + machineId: provider.machineId, + platform: provider.platform, + appVersion: provider.appVersion, + osVersion: provider.osVersion, + architecture: provider.architecture, + locale: provider.locale, + databaseTypes: types.isEmpty ? nil : types, + connectionCount: provider.activeConnectionCount, + hasLicense: provider.hasLicense + ) + } + + private func isCooldownElapsed() -> Bool { + guard let last = UserDefaults.standard.object(forKey: Self.lastHeartbeatKey) as? Date else { + return true + } + return Date().timeIntervalSince(last) >= cooldownInterval + } + + private func recordHeartbeatTimestamp() { + UserDefaults.standard.set(Date(), forKey: Self.lastHeartbeatKey) + } +} diff --git a/Packages/TableProCore/Sources/TableProAnalytics/AnalyticsPayload.swift b/Packages/TableProCore/Sources/TableProAnalytics/AnalyticsPayload.swift new file mode 100644 index 00000000..919b1cf5 --- /dev/null +++ b/Packages/TableProCore/Sources/TableProAnalytics/AnalyticsPayload.swift @@ -0,0 +1,42 @@ +// +// AnalyticsPayload.swift +// TableProAnalytics +// + +import Foundation + +/// Anonymous heartbeat payload sent to the analytics API every 24 hours. +/// Encoded with snake_case keys to match backend expectations. +public struct AnalyticsPayload: Encodable, Sendable { + public let machineId: String + public let platform: String + public let appVersion: String? + public let osVersion: String + public let architecture: String + public let locale: String + public let databaseTypes: [String]? + public let connectionCount: Int + public let hasLicense: Bool + + public init( + machineId: String, + platform: String, + appVersion: String?, + osVersion: String, + architecture: String, + locale: String, + databaseTypes: [String]?, + connectionCount: Int, + hasLicense: Bool + ) { + self.machineId = machineId + self.platform = platform + self.appVersion = appVersion + self.osVersion = osVersion + self.architecture = architecture + self.locale = locale + self.databaseTypes = databaseTypes + self.connectionCount = connectionCount + self.hasLicense = hasLicense + } +} diff --git a/TablePro.xcodeproj/project.pbxproj b/TablePro.xcodeproj/project.pbxproj index 9b5f3513..f1686088 100644 --- a/TablePro.xcodeproj/project.pbxproj +++ b/TablePro.xcodeproj/project.pbxproj @@ -7,6 +7,7 @@ objects = { /* Begin PBXBuildFile section */ + 5A7E78A02F95F02A00EEF236 /* TableProAnalytics in Frameworks */ = {isa = PBXBuildFile; productRef = 5ACE00012F4F000000000010 /* TableProAnalytics */; }; 5A860000A00000000 /* TableProPluginKit.framework in Embed Frameworks */ = {isa = PBXBuildFile; fileRef = 5A860000100000000 /* TableProPluginKit.framework */; settings = {ATTRIBUTES = (CodeSignOnCopy, RemoveHeadersOnCopy, ); }; }; 5A861000A00000000 /* TableProPluginKit.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 5A860000100000000 /* TableProPluginKit.framework */; }; 5A862000A00000000 /* TableProPluginKit.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 5A860000100000000 /* TableProPluginKit.framework */; }; @@ -581,6 +582,7 @@ isa = PBXFrameworksBuildPhase; buildActionMask = 2147483647; files = ( + 5A7E78A02F95F02A00EEF236 /* TableProAnalytics in Frameworks */, 5ACE00012F4F000000000004 /* CodeEditSourceEditor in Frameworks */, 5ACE00012F4F000000000005 /* CodeEditLanguages in Frameworks */, 5ACE00012F4F000000000006 /* CodeEditTextView in Frameworks */, @@ -940,6 +942,7 @@ 5ACE00012F4F000000000007 /* CodeEditTextView */, 5ACE00012F4F000000000009 /* Sparkle */, 5ACE00012F4F00000000000C /* MarkdownUI */, + 5ACE00012F4F000000000010 /* TableProAnalytics */, ); productName = TablePro; productReference = 5A1091C72EF17EDC0055EA7C /* TablePro.app */; @@ -1479,6 +1482,7 @@ 5ACE00012F4F00000000000B /* XCRemoteSwiftPackageReference "swift-markdown-ui" */, 5A0000012F4F000000000100 /* XCLocalSwiftPackageReference "LocalPackages/CodeEditLanguages" */, 5ACE00012F4F00000000000E /* XCRemoteSwiftPackageReference "oracle-nio" */, + 5A0000012F4F000000000102 /* XCLocalSwiftPackageReference "Packages/TableProCore" */, ); preferredProjectObjectVersion = 77; productRefGroup = 5A1091C82EF17EDC0055EA7C /* Products */; @@ -3650,6 +3654,10 @@ isa = XCLocalSwiftPackageReference; relativePath = LocalPackages/CodeEditSourceEditor; }; + 5A0000012F4F000000000102 /* XCLocalSwiftPackageReference "Packages/TableProCore" */ = { + isa = XCLocalSwiftPackageReference; + relativePath = Packages/TableProCore; + }; /* End XCLocalSwiftPackageReference section */ /* Begin XCRemoteSwiftPackageReference section */ @@ -3707,6 +3715,10 @@ package = 5ACE00012F4F00000000000E /* XCRemoteSwiftPackageReference "oracle-nio" */; productName = OracleNIO; }; + 5ACE00012F4F000000000010 /* TableProAnalytics */ = { + isa = XCSwiftPackageProductDependency; + productName = TableProAnalytics; + }; /* End XCSwiftPackageProductDependency section */ }; rootObject = 5A1091BF2EF17EDC0055EA7C /* Project object */; diff --git a/TablePro/Core/Services/Infrastructure/AnalyticsService.swift b/TablePro/Core/Services/Infrastructure/AnalyticsService.swift index 2cb3a954..11cffb74 100644 --- a/TablePro/Core/Services/Infrastructure/AnalyticsService.swift +++ b/TablePro/Core/Services/Infrastructure/AnalyticsService.swift @@ -2,162 +2,29 @@ // AnalyticsService.swift // TablePro // -// Lightweight heartbeat analytics — sends anonymous usage data to help improve TablePro -// -import CryptoKit import Foundation -import os +import TableProAnalytics -/// Sends periodic anonymous usage heartbeats to the TablePro analytics API +/// macOS analytics entry point. Thin wrapper around the shared AnalyticsHeartbeatService. @MainActor final class AnalyticsService { static let shared = AnalyticsService() - private static let logger = Logger(subsystem: "com.TablePro", category: "AnalyticsService") - - // swiftlint:disable:next force_unwrapping - private let analyticsURL = URL(string: "https://api.tablepro.app/v1/analytics")! - - /// Heartbeat interval: 24 hours - private let heartbeatInterval: TimeInterval = 24 * 60 * 60 - - /// Initial delay before first heartbeat (let connections establish) - private let initialDelay: TimeInterval = 10 - - /// HMAC-SHA256 shared secret for analytics request signing (injected via Info.plist build setting) - private let hmacSecret: String? = { - guard let value = Bundle.main.object(forInfoDictionaryKey: "AnalyticsHMACSecret") as? String, - !value.isEmpty, - !value.hasPrefix("$(") else { - return nil - } - return value - }() - private var heartbeatTask: Task? + private let service: AnalyticsHeartbeatService - private let session: URLSession = { - let config = URLSessionConfiguration.default - config.timeoutIntervalForRequest = 15 - config.timeoutIntervalForResource = 30 - config.waitsForConnectivity = true - return URLSession(configuration: config) - }() - - private let encoder: JSONEncoder = { - let encoder = JSONEncoder() - encoder.keyEncodingStrategy = .convertToSnakeCase - return encoder - }() - - private init() {} + private init() { + service = AnalyticsHeartbeatService(provider: MacAnalyticsProvider()) + } deinit { heartbeatTask?.cancel() } - // MARK: - Public API - /// Start periodic heartbeat. Call from AppDelegate.applicationDidFinishLaunching. func startPeriodicHeartbeat() { heartbeatTask?.cancel() - heartbeatTask = Task { [weak self] in - // Initial delay before first heartbeat (let connections establish) - guard let delay = self?.initialDelay else { return } - try? await Task.sleep(for: .seconds(delay)) - - while !Task.isCancelled { - guard let target = self else { return } - await target.sendHeartbeat() - try? await Task.sleep(for: .seconds(target.heartbeatInterval)) - } - } - } - - // MARK: - Private - - private func sendHeartbeat() async { - // Check opt-out setting - guard AppSettingsStorage.shared.loadGeneral().shareAnalytics else { - Self.logger.trace("Analytics disabled by user, skipping heartbeat") - return - } - - let payload = buildPayload() - - do { - var request = URLRequest(url: analyticsURL) - request.httpMethod = "POST" - request.setValue("application/json", forHTTPHeaderField: "Content-Type") - request.httpBody = try encoder.encode(payload) - - // Sign request body with HMAC-SHA256 (secret injected at build time) - if let body = request.httpBody, - let secret = hmacSecret, !secret.isEmpty { - let key = SymmetricKey(data: Data(secret.utf8)) - let signature = HMAC.authenticationCode(for: body, using: key) - let signatureHex = signature.map { String(format: "%02x", $0) }.joined() - request.setValue(signatureHex, forHTTPHeaderField: "X-Signature") - } - - let (_, response) = try await session.data(for: request) - - if let httpResponse = response as? HTTPURLResponse { - Self.logger.trace("Analytics heartbeat sent, status: \(httpResponse.statusCode)") - } - } catch { - Self.logger.trace("Analytics heartbeat failed: \(error.localizedDescription)") - } + heartbeatTask = service.startPeriodicHeartbeat() } - - private func buildPayload() -> AnalyticsPayload { - let appVersion = Bundle.main.appVersion - - let osVersion: String = { - let version = ProcessInfo.processInfo.operatingSystemVersion - return "macOS \(version.majorVersion).\(version.minorVersion).\(version.patchVersion)" - }() - - let architecture: String = { - #if arch(arm64) - return "arm64" - #else - return "x86_64" - #endif - }() - - let generalSettings = AppSettingsStorage.shared.loadGeneral() - let locale = generalSettings.language.rawValue - - let sessions = DatabaseManager.shared.activeSessions - let databaseTypes = Array(Set(sessions.values.compactMap { $0.connection.type.rawValue })) - let connectionCount = sessions.count - - let hasLicense = LicenseStorage.shared.loadLicenseKey() != nil - - return AnalyticsPayload( - machineId: LicenseStorage.shared.machineId, - appVersion: appVersion, - osVersion: osVersion, - architecture: architecture, - locale: locale, - databaseTypes: databaseTypes.isEmpty ? nil : databaseTypes, - connectionCount: connectionCount, - hasLicense: hasLicense - ) - } -} - -// MARK: - Payload - -private struct AnalyticsPayload: Encodable { - let machineId: String - let appVersion: String? - let osVersion: String - let architecture: String - let locale: String - let databaseTypes: [String]? - let connectionCount: Int - let hasLicense: Bool } diff --git a/TablePro/Core/Services/Infrastructure/MacAnalyticsProvider.swift b/TablePro/Core/Services/Infrastructure/MacAnalyticsProvider.swift new file mode 100644 index 00000000..2bb94264 --- /dev/null +++ b/TablePro/Core/Services/Infrastructure/MacAnalyticsProvider.swift @@ -0,0 +1,62 @@ +// +// MacAnalyticsProvider.swift +// TablePro +// + +import Foundation +import TableProAnalytics + +@MainActor +final class MacAnalyticsProvider: AnalyticsEnvironmentProvider { + var machineId: String { + LicenseStorage.shared.machineId + } + + var appVersion: String? { + Bundle.main.appVersion + } + + var osVersion: String { + let version = ProcessInfo.processInfo.operatingSystemVersion + return "macOS \(version.majorVersion).\(version.minorVersion).\(version.patchVersion)" + } + + var architecture: String { + #if arch(arm64) + return "arm64" + #else + return "x86_64" + #endif + } + + var platform: String { "macos" } + + var locale: String { + AppSettingsStorage.shared.loadGeneral().language.rawValue + } + + var isAnalyticsEnabled: Bool { + AppSettingsStorage.shared.loadGeneral().shareAnalytics + } + + var hasLicense: Bool { + LicenseStorage.shared.loadLicenseKey() != nil + } + + var activeDatabaseTypes: [String] { + Array(Set(DatabaseManager.shared.activeSessions.values.compactMap { $0.connection.type.rawValue })) + } + + var activeConnectionCount: Int { + DatabaseManager.shared.activeSessions.count + } + + var hmacSecret: String? { + guard let value = Bundle.main.object(forInfoDictionaryKey: "AnalyticsHMACSecret") as? String, + !value.isEmpty, + !value.hasPrefix("$(") else { + return nil + } + return value + } +} diff --git a/TableProMobile/TableProMobile.xcodeproj/project.pbxproj b/TableProMobile/TableProMobile.xcodeproj/project.pbxproj index 8208cef8..d0293f5e 100644 --- a/TableProMobile/TableProMobile.xcodeproj/project.pbxproj +++ b/TableProMobile/TableProMobile.xcodeproj/project.pbxproj @@ -7,6 +7,7 @@ objects = { /* Begin PBXBuildFile section */ + 5A7E81B12F95F23600EEF236 /* TableProAnalytics in Frameworks */ = {isa = PBXBuildFile; productRef = 5A87EEED2F7F893000D028D1 /* TableProAnalytics */; }; 5A87EEED2F7F893000D028D0 /* TableProSync in Frameworks */ = {isa = PBXBuildFile; productRef = 5A87EEEC2F7F893000D028D0 /* TableProSync */; }; 5AA136062F82610F00ADCD58 /* WidgetKit.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 5AA136052F82610F00ADCD58 /* WidgetKit.framework */; }; 5AA136082F82610F00ADCD58 /* SwiftUI.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 5AA136072F82610F00ADCD58 /* SwiftUI.framework */; }; @@ -580,6 +581,7 @@ isa = PBXFrameworksBuildPhase; buildActionMask = 2147483647; files = ( + 5A7E81B12F95F23600EEF236 /* TableProAnalytics in Frameworks */, 5AA3133A2F7EA5B4008EBA97 /* LibPQ.xcframework in Frameworks */, 5AB9F3EF2F7C1D03001F3337 /* TableProQuery in Frameworks */, 5AA313402F7EA5B4008EBA97 /* MariaDB.xcframework in Frameworks */, @@ -1695,6 +1697,7 @@ 5AB9F3EC2F7C1D03001F3337 /* TableProPluginKit */, 5AB9F3EE2F7C1D03001F3337 /* TableProQuery */, 5A87EEEC2F7F893000D028D0 /* TableProSync */, + 5A87EEED2F7F893000D028D1 /* TableProAnalytics */, ); productName = TableProMobile; productReference = 5AB9F3D92F7C1C12001F3337 /* TableProMobile.app */; @@ -2102,6 +2105,10 @@ package = 5AB9F3E72F7C1D03001F3337 /* XCLocalSwiftPackageReference "../Packages/TableProCore" */; productName = TableProSync; }; + 5A87EEED2F7F893000D028D1 /* TableProAnalytics */ = { + isa = XCSwiftPackageProductDependency; + productName = TableProAnalytics; + }; 5AB9F3E82F7C1D03001F3337 /* TableProDatabase */ = { isa = XCSwiftPackageProductDependency; productName = TableProDatabase; diff --git a/TableProMobile/TableProMobile/Helpers/String+SHA256.swift b/TableProMobile/TableProMobile/Helpers/String+SHA256.swift new file mode 100644 index 00000000..462dd517 --- /dev/null +++ b/TableProMobile/TableProMobile/Helpers/String+SHA256.swift @@ -0,0 +1,15 @@ +// +// String+SHA256.swift +// TableProMobile +// + +import CryptoKit +import Foundation + +extension String { + var sha256: String { + let data = Data(utf8) + let digest = SHA256.hash(data: data) + return digest.map { String(format: "%02x", $0) }.joined() + } +} diff --git a/TableProMobile/TableProMobile/Info.plist b/TableProMobile/TableProMobile/Info.plist index ae77874e..1d80a5e8 100644 --- a/TableProMobile/TableProMobile/Info.plist +++ b/TableProMobile/TableProMobile/Info.plist @@ -2,6 +2,8 @@ + AnalyticsHMACSecret + $(ANALYTICS_HMAC_SECRET) NSUserActivityTypes com.TablePro.viewConnection diff --git a/TableProMobile/TableProMobile/Platform/IOSAnalyticsProvider.swift b/TableProMobile/TableProMobile/Platform/IOSAnalyticsProvider.swift new file mode 100644 index 00000000..5481df9b --- /dev/null +++ b/TableProMobile/TableProMobile/Platform/IOSAnalyticsProvider.swift @@ -0,0 +1,79 @@ +// +// IOSAnalyticsProvider.swift +// TableProMobile +// + +import Foundation +import TableProAnalytics +import TableProDatabase +import TableProModels +import UIKit + +@MainActor +final class IOSAnalyticsProvider: AnalyticsEnvironmentProvider { + private let appState: AppState + + init(appState: AppState) { + self.appState = appState + } + + var machineId: String { + let stableKey = "com.TablePro.analytics.stableDeviceId" + if let stable = UserDefaults.standard.string(forKey: stableKey) { + return stable + } + let id: String + if let vendorId = UIDevice.current.identifierForVendor?.uuidString { + id = vendorId.sha256 + } else { + id = UUID().uuidString.sha256 + } + UserDefaults.standard.set(id, forKey: stableKey) + return id + } + + var appVersion: String? { + Bundle.main.object(forInfoDictionaryKey: "CFBundleShortVersionString") as? String + } + + var osVersion: String { + let version = ProcessInfo.processInfo.operatingSystemVersion + return "iOS \(version.majorVersion).\(version.minorVersion).\(version.patchVersion)" + } + + var architecture: String { "arm64" } + + var platform: String { "ios" } + + var locale: String { + Locale.current.language.languageCode?.identifier ?? "en" + } + + var isAnalyticsEnabled: Bool { + UserDefaults.standard.object(forKey: "com.TablePro.settings.shareAnalytics") as? Bool ?? true + } + + var hasLicense: Bool { false } + + var activeDatabaseTypes: [String] { + let active = appState.connections.filter { conn in + appState.connectionManager.session(for: conn.id) != nil + } + return Array(Set(active.map { $0.type.rawValue })) + } + + var activeConnectionCount: Int { + appState.connections.filter { conn in + appState.connectionManager.session(for: conn.id) != nil + }.count + } + + var hmacSecret: String? { + guard let value = Bundle.main.object(forInfoDictionaryKey: "AnalyticsHMACSecret") as? String, + !value.isEmpty, + !value.hasPrefix("$(") else { + return nil + } + return value + } +} diff --git a/TableProMobile/TableProMobile/TableProMobileApp.swift b/TableProMobile/TableProMobile/TableProMobileApp.swift index 9589d7b9..85b66502 100644 --- a/TableProMobile/TableProMobile/TableProMobileApp.swift +++ b/TableProMobile/TableProMobile/TableProMobileApp.swift @@ -5,6 +5,7 @@ import CoreSpotlight import SwiftUI +import TableProAnalytics import TableProDatabase import TableProModels @@ -12,6 +13,8 @@ import TableProModels struct TableProMobileApp: App { @State private var appState = AppState() @State private var syncTask: Task? + @State private var heartbeatService: AnalyticsHeartbeatService? + @State private var heartbeatTask: Task? @Environment(\.scenePhase) private var scenePhase var body: some Scene { @@ -59,7 +62,16 @@ struct TableProMobileApp: App { localTags: appState.tags ) } + if heartbeatTask == nil { + let provider = IOSAnalyticsProvider(appState: appState) + let service = AnalyticsHeartbeatService(provider: provider) + heartbeatService = service + heartbeatTask = service.startPeriodicHeartbeat() + } case .background: + heartbeatTask?.cancel() + heartbeatTask = nil + heartbeatService = nil Task { await appState.connectionManager.disconnectAll() } default: break diff --git a/TableProMobile/TableProMobile/Views/ConnectionListView.swift b/TableProMobile/TableProMobile/Views/ConnectionListView.swift index 84587cdf..844e2425 100644 --- a/TableProMobile/TableProMobile/Views/ConnectionListView.swift +++ b/TableProMobile/TableProMobile/Views/ConnectionListView.swift @@ -20,6 +20,7 @@ struct ConnectionListView: View { @AppStorage("groupByGroup") private var groupByGroup = false @State private var editMode: EditMode = .inactive @State private var connectionToDelete: DatabaseConnection? + @State private var showingSettings = false private var showDeleteConfirmation: Binding { Binding( @@ -146,6 +147,18 @@ struct ConnectionListView: View { .sheet(isPresented: $showingTagManagement) { TagManagementView() } + .sheet(isPresented: $showingSettings) { + NavigationStack { + SettingsView() + .toolbar { + ToolbarItem(placement: .confirmationAction) { + Button(String(localized: "Done")) { + showingSettings = false + } + } + } + } + } } @ViewBuilder @@ -275,6 +288,11 @@ struct ConnectionListView: View { } label: { Label("Manage Tags", systemImage: "tag") } + Button { + showingSettings = true + } label: { + Label("Settings", systemImage: "gear") + } } } label: { Image(systemName: "line.3.horizontal.decrease.circle") diff --git a/TableProMobile/TableProMobile/Views/SettingsView.swift b/TableProMobile/TableProMobile/Views/SettingsView.swift new file mode 100644 index 00000000..7f0f211b --- /dev/null +++ b/TableProMobile/TableProMobile/Views/SettingsView.swift @@ -0,0 +1,32 @@ +// +// SettingsView.swift +// TableProMobile +// + +import SwiftUI + +struct SettingsView: View { + @AppStorage("com.TablePro.settings.shareAnalytics") private var shareAnalytics: Bool = true + + var body: some View { + Form { + Section("Privacy") { + Toggle(String(localized: "Share anonymous usage data"), isOn: $shareAnalytics) + + Text("Help improve TablePro by sharing anonymous usage statistics (no personal data or queries).") + .font(.caption) + .foregroundStyle(.secondary) + } + + Section("About") { + LabeledContent(String(localized: "Version")) { + Text(Bundle.main.object(forInfoDictionaryKey: "CFBundleShortVersionString") as? String ?? "—") + } + LabeledContent(String(localized: "Build")) { + Text(Bundle.main.object(forInfoDictionaryKey: "CFBundleVersion") as? String ?? "—") + } + } + } + .navigationTitle(String(localized: "Settings")) + } +}