From 30dbcae62c53e72196a379fa96a6db3939b4f79d Mon Sep 17 00:00:00 2001 From: Ngo Quoc Dat Date: Fri, 13 Feb 2026 10:47:15 +0700 Subject: [PATCH] Add anonymous usage analytics with opt-out and HMAC signing - Add AnalyticsService that sends a lightweight heartbeat every 24 hours (app version, OS, architecture, locale, database types, connection count) - Add opt-out toggle in Settings > General > Privacy - Sign requests with HMAC-SHA256 to prevent unauthorized API calls - Add Vietnamese translations for all new strings --- CHANGELOG.md | 1 + TablePro/AppDelegate.swift | 3 + TablePro/Core/Services/AnalyticsService.swift | 155 ++++++++++++++++++ TablePro/Models/AppSettings.swift | 11 +- TablePro/Resources/Localizable.xcstrings | 30 ++++ .../Views/Settings/GeneralSettingsView.swift | 8 + 6 files changed, 206 insertions(+), 2 deletions(-) create mode 100644 TablePro/Core/Services/AnalyticsService.swift diff --git a/CHANGELOG.md b/CHANGELOG.md index 2df1a8847..099b94fc5 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -9,6 +9,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Added +- Anonymous usage analytics with opt-out toggle in Settings > General > Privacy — sends lightweight heartbeat (OS version, architecture, locale, database types) every 24 hours to help improve TablePro; no personal data or queries are collected - ENUM/SET column editor: double-click ENUM columns to select from a searchable dropdown popover, SET columns show a multi-select checkbox popover with OK/Cancel buttons - PostgreSQL user-defined enum type support via `pg_enum` catalog lookup - SQLite CHECK constraint pseudo-enum detection (e.g., `CHECK(col IN ('a','b','c'))`) diff --git a/TablePro/AppDelegate.swift b/TablePro/AppDelegate.swift index 0266924df..4b31a1eda 100644 --- a/TablePro/AppDelegate.swift +++ b/TablePro/AppDelegate.swift @@ -163,6 +163,9 @@ class AppDelegate: NSObject, NSApplicationDelegate { LicenseManager.shared.startPeriodicValidation() } + // Start anonymous usage analytics heartbeat + AnalyticsService.shared.startPeriodicHeartbeat() + // Configure windows after app launch configureWelcomeWindow() diff --git a/TablePro/Core/Services/AnalyticsService.swift b/TablePro/Core/Services/AnalyticsService.swift new file mode 100644 index 000000000..d1f8c96bd --- /dev/null +++ b/TablePro/Core/Services/AnalyticsService.swift @@ -0,0 +1,155 @@ +// +// AnalyticsService.swift +// TablePro +// +// Lightweight heartbeat analytics — sends anonymous usage data to help improve TablePro +// + +import CryptoKit +import Foundation +import os + +/// Sends periodic anonymous usage heartbeats to the TablePro analytics API +@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 + private let hmacSecret = "tp_analytics_8f3k2m9x4v7b1n6j5h0w" + + private var heartbeatTimer: Timer? + + private let session: URLSession = { + let config = URLSessionConfiguration.default + config.timeoutIntervalForRequest = 15 + config.timeoutIntervalForResource = 30 + return URLSession(configuration: config) + }() + + private let encoder: JSONEncoder = { + let encoder = JSONEncoder() + encoder.keyEncodingStrategy = .convertToSnakeCase + return encoder + }() + + private init() {} + + // MARK: - Public API + + /// Start periodic heartbeat. Call from AppDelegate.applicationDidFinishLaunching. + func startPeriodicHeartbeat() { + // Send first heartbeat after initial delay + DispatchQueue.main.asyncAfter(deadline: .now() + initialDelay) { [weak self] in + Task { @MainActor in + await self?.sendHeartbeat() + } + } + + // Schedule periodic timer + heartbeatTimer?.invalidate() + heartbeatTimer = Timer.scheduledTimer( + withTimeInterval: heartbeatInterval, + repeats: true + ) { [weak self] _ in + Task { @MainActor [weak self] in + await self?.sendHeartbeat() + } + } + } + + // MARK: - Private + + private func sendHeartbeat() async { + // Check opt-out setting + guard AppSettingsStorage.shared.loadGeneral().shareAnalytics else { + Self.logger.debug("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 + if let body = request.httpBody { + let key = SymmetricKey(data: Data(hmacSecret.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.debug("Analytics heartbeat sent, status: \(httpResponse.statusCode)") + } + } catch { + Self.logger.debug("Analytics heartbeat failed: \(error.localizedDescription)") + } + } + + private func buildPayload() -> AnalyticsPayload { + let appVersion = Bundle.main.infoDictionary?["CFBundleShortVersionString"] as? String + + 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 licenseKey = LicenseStorage.shared.loadLicenseKey() + + return AnalyticsPayload( + machineId: LicenseStorage.shared.machineId, + appVersion: appVersion, + osVersion: osVersion, + architecture: architecture, + locale: locale, + databaseTypes: databaseTypes.isEmpty ? nil : databaseTypes, + connectionCount: connectionCount, + licenseKey: licenseKey + ) + } +} + +// 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 licenseKey: String? +} diff --git a/TablePro/Models/AppSettings.swift b/TablePro/Models/AppSettings.swift index 1e39eb556..938e2c857 100644 --- a/TablePro/Models/AppSettings.swift +++ b/TablePro/Models/AppSettings.swift @@ -60,23 +60,29 @@ struct GeneralSettings: Codable, Equatable { /// Query execution timeout in seconds (0 = no limit) var queryTimeoutSeconds: Int + /// Whether to share anonymous usage analytics + var shareAnalytics: Bool + static let `default` = GeneralSettings( startupBehavior: .showWelcome, language: .system, automaticallyCheckForUpdates: true, - queryTimeoutSeconds: 60 + queryTimeoutSeconds: 60, + shareAnalytics: true ) init( startupBehavior: StartupBehavior = .showWelcome, language: AppLanguage = .system, automaticallyCheckForUpdates: Bool = true, - queryTimeoutSeconds: Int = 60 + queryTimeoutSeconds: Int = 60, + shareAnalytics: Bool = true ) { self.startupBehavior = startupBehavior self.language = language self.automaticallyCheckForUpdates = automaticallyCheckForUpdates self.queryTimeoutSeconds = queryTimeoutSeconds + self.shareAnalytics = shareAnalytics } init(from decoder: Decoder) throws { @@ -85,6 +91,7 @@ struct GeneralSettings: Codable, Equatable { language = try container.decodeIfPresent(AppLanguage.self, forKey: .language) ?? .system automaticallyCheckForUpdates = try container.decodeIfPresent(Bool.self, forKey: .automaticallyCheckForUpdates) ?? true queryTimeoutSeconds = try container.decodeIfPresent(Int.self, forKey: .queryTimeoutSeconds) ?? 60 + shareAnalytics = try container.decodeIfPresent(Bool.self, forKey: .shareAnalytics) ?? true } } diff --git a/TablePro/Resources/Localizable.xcstrings b/TablePro/Resources/Localizable.xcstrings index ea1e6018b..9ee59e8cf 100644 --- a/TablePro/Resources/Localizable.xcstrings +++ b/TablePro/Resources/Localizable.xcstrings @@ -3259,6 +3259,16 @@ } } }, + "Help improve TablePro by sharing anonymous usage statistics (no personal data or queries)." : { + "localizations" : { + "vi" : { + "stringUnit" : { + "state" : "translated", + "value" : "Giúp cải thiện TablePro bằng cách chia sẻ thống kê sử dụng ẩn danh (không có dữ liệu cá nhân hay truy vấn nào)." + } + } + } + }, "Higher values create fewer INSERT statements, resulting in smaller files and faster imports" : { "localizations" : { "vi" : { @@ -4721,6 +4731,16 @@ } } }, + "Privacy" : { + "localizations" : { + "vi" : { + "stringUnit" : { + "state" : "translated", + "value" : "Quyền riêng tư" + } + } + } + }, "Preview" : { "localizations" : { "vi" : { @@ -5451,6 +5471,16 @@ } } }, + "Share anonymous usage data" : { + "localizations" : { + "vi" : { + "stringUnit" : { + "state" : "translated", + "value" : "Chia sẻ dữ liệu sử dụng ẩn danh" + } + } + } + }, "Show alternate row backgrounds" : { "localizations" : { "vi" : { diff --git a/TablePro/Views/Settings/GeneralSettingsView.swift b/TablePro/Views/Settings/GeneralSettingsView.swift index abda853f1..bc186f348 100644 --- a/TablePro/Views/Settings/GeneralSettingsView.swift +++ b/TablePro/Views/Settings/GeneralSettingsView.swift @@ -65,6 +65,14 @@ struct GeneralSettingsView: View { } .disabled(!updaterBridge.canCheckForUpdates) } + + Section("Privacy") { + Toggle("Share anonymous usage data", isOn: $settings.shareAnalytics) + + Text("Help improve TablePro by sharing anonymous usage statistics (no personal data or queries).") + .font(.caption) + .foregroundStyle(.secondary) + } } .formStyle(.grouped) .scrollContentBackground(.hidden)