Skip to content
Open
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
8 changes: 7 additions & 1 deletion Packages/TableProCore/Package.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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(
Expand Down Expand Up @@ -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"],
Expand Down
Original file line number Diff line number Diff line change
@@ -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 }
}
Original file line number Diff line number Diff line change
@@ -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<Void, Never> {
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<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.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)
}
}
Original file line number Diff line number Diff line change
@@ -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
}
}
12 changes: 12 additions & 0 deletions TablePro.xcodeproj/project.pbxproj
Original file line number Diff line number Diff line change
Expand Up @@ -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 */; };
Expand Down Expand Up @@ -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 */,
Expand Down Expand Up @@ -940,6 +942,7 @@
5ACE00012F4F000000000007 /* CodeEditTextView */,
5ACE00012F4F000000000009 /* Sparkle */,
5ACE00012F4F00000000000C /* MarkdownUI */,
5ACE00012F4F000000000010 /* TableProAnalytics */,
);
productName = TablePro;
productReference = 5A1091C72EF17EDC0055EA7C /* TablePro.app */;
Expand Down Expand Up @@ -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 */;
Expand Down Expand Up @@ -3650,6 +3654,10 @@
isa = XCLocalSwiftPackageReference;
relativePath = LocalPackages/CodeEditSourceEditor;
};
5A0000012F4F000000000102 /* XCLocalSwiftPackageReference "Packages/TableProCore" */ = {
isa = XCLocalSwiftPackageReference;
relativePath = Packages/TableProCore;
};
/* End XCLocalSwiftPackageReference section */

/* Begin XCRemoteSwiftPackageReference section */
Expand Down Expand Up @@ -3707,6 +3715,10 @@
package = 5ACE00012F4F00000000000E /* XCRemoteSwiftPackageReference "oracle-nio" */;
productName = OracleNIO;
};
5ACE00012F4F000000000010 /* TableProAnalytics */ = {
isa = XCSwiftPackageProductDependency;
productName = TableProAnalytics;
};
/* End XCSwiftPackageProductDependency section */
};
rootObject = 5A1091BF2EF17EDC0055EA7C /* Project object */;
Expand Down
Loading
Loading