Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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'))`)
Expand Down
3 changes: 3 additions & 0 deletions TablePro/AppDelegate.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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()

Expand Down
155 changes: 155 additions & 0 deletions TablePro/Core/Services/AnalyticsService.swift
Original file line number Diff line number Diff line change
@@ -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<SHA256>.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?
}
11 changes: 9 additions & 2 deletions TablePro/Models/AppSettings.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand All @@ -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
}
}

Expand Down
30 changes: 30 additions & 0 deletions TablePro/Resources/Localizable.xcstrings
Original file line number Diff line number Diff line change
Expand Up @@ -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" : {
Expand Down Expand Up @@ -4721,6 +4731,16 @@
}
}
},
"Privacy" : {
"localizations" : {
"vi" : {
"stringUnit" : {
"state" : "translated",
"value" : "Quyền riêng tư"
}
}
}
},
"Preview" : {
"localizations" : {
"vi" : {
Expand Down Expand Up @@ -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" : {
Expand Down
8 changes: 8 additions & 0 deletions TablePro/Views/Settings/GeneralSettingsView.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down