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
2 changes: 2 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,8 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0

### Added

- Activation telemetry: the daily heartbeat now reports three write-once timestamps per device (first connection attempt, first successful connection, first executed query), so we can see where new users drop off during activation. The values are stored locally in UserDefaults, set once and never overwritten, and the server also refuses to overwrite them once received. Both Mac and iOS send the same fields.
- Newsletter prompt on Mac: after the third successful database connection, a one-time native NSAlert offers to subscribe to release notes. "Subscribe in Browser" opens `https://tablepro.app/?subscribe=true&source=mac`, "Maybe later" dismisses. The prompt never reappears once shown.
- MCP: support for protocol versions `2025-06-18` and `2025-11-25` in addition to `2025-03-26`. Clients on the latest spec no longer downgrade. The server advertises the latest version it supports (`2025-11-25`) and falls back when a client requests an unknown version.
- MCP: structured tool output (`structuredContent`) on every tool. The serialized JSON still appears in `content[].text` for backward compatibility, while 2025-11-25 clients can read the parsed object directly.
- MCP: tool annotations (`title`, `readOnlyHint`, `destructiveHint`, `idempotentHint`, `openWorldHint`) on every tool, plus `serverInfo.title` in `initialize` responses. Read tools advertise `readOnlyHint=true`; `confirm_destructive_operation` advertises `destructiveHint=true`.
Expand Down
5 changes: 5 additions & 0 deletions Packages/TableProCore/Package.swift
Original file line number Diff line number Diff line change
Expand Up @@ -61,6 +61,11 @@ let package = Package(
name: "TableProQueryTests",
dependencies: ["TableProQuery", "TableProModels", "TableProPluginKit"],
path: "Tests/TableProQueryTests"
),
.testTarget(
name: "TableProAnalyticsTests",
dependencies: ["TableProAnalytics"],
path: "Tests/TableProAnalyticsTests"
)
]
)
Original file line number Diff line number Diff line change
Expand Up @@ -44,4 +44,22 @@ public protocol AnalyticsEnvironmentProvider: AnyObject {

/// HMAC-SHA256 shared secret for request signing (from Info.plist build setting)
var hmacSecret: String? { get }

/// Timestamp of the first connection attempt the user made on this device, or nil if never attempted.
/// Set once and never overwritten, the heartbeat sends the original value forever.
var connectionAttemptedAt: Date? { get }

/// Timestamp of the first successful connection on this device, or nil if no connection ever succeeded.
/// Set once and never overwritten.
var connectionSucceededAt: Date? { get }

/// Timestamp of the first query the user successfully executed on this device, or nil if no query has run.
/// Set once and never overwritten.
var firstQueryExecutedAt: Date? { get }
}

public extension AnalyticsEnvironmentProvider {
var connectionAttemptedAt: Date? { nil }
var connectionSucceededAt: Date? { nil }
var firstQueryExecutedAt: Date? { nil }
}
Original file line number Diff line number Diff line change
Expand Up @@ -40,6 +40,7 @@ public final class AnalyticsHeartbeatService {
private let encoder: JSONEncoder = {
let encoder = JSONEncoder()
encoder.keyEncodingStrategy = .convertToSnakeCase
encoder.dateEncodingStrategy = .iso8601
return encoder
}()

Expand Down Expand Up @@ -127,10 +128,18 @@ public final class AnalyticsHeartbeatService {
locale: provider.locale,
databaseTypes: types.isEmpty ? nil : types,
connectionCount: provider.activeConnectionCount,
hasLicense: provider.hasLicense
hasLicense: provider.hasLicense,
connectionAttemptedAt: provider.connectionAttemptedAt,
connectionSucceededAt: provider.connectionSucceededAt,
firstQueryExecutedAt: provider.firstQueryExecutedAt
)
}

/// Exposed for tests so they can verify the encoded body without touching `sendHeartbeat()`.
public func makeEncodedBodyForTesting(payload: AnalyticsPayload) throws -> Data {
try encoder.encode(payload)
}

private func isCooldownElapsed() -> Bool {
guard let last = UserDefaults.standard.object(forKey: Self.lastHeartbeatKey) as? Date else {
return true
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,9 @@ public struct AnalyticsPayload: Encodable, Sendable {
public let databaseTypes: [String]?
public let connectionCount: Int
public let hasLicense: Bool
public let connectionAttemptedAt: Date?
public let connectionSucceededAt: Date?
public let firstQueryExecutedAt: Date?

public init(
machineId: String,
Expand All @@ -27,7 +30,10 @@ public struct AnalyticsPayload: Encodable, Sendable {
locale: String,
databaseTypes: [String]?,
connectionCount: Int,
hasLicense: Bool
hasLicense: Bool,
connectionAttemptedAt: Date? = nil,
connectionSucceededAt: Date? = nil,
firstQueryExecutedAt: Date? = nil
) {
self.machineId = machineId
self.platform = platform
Expand All @@ -38,5 +44,8 @@ public struct AnalyticsPayload: Encodable, Sendable {
self.databaseTypes = databaseTypes
self.connectionCount = connectionCount
self.hasLicense = hasLicense
self.connectionAttemptedAt = connectionAttemptedAt
self.connectionSucceededAt = connectionSucceededAt
self.firstQueryExecutedAt = firstQueryExecutedAt
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,131 @@
//
// AnalyticsHeartbeatPayloadTests.swift
// TableProAnalyticsTests
//

import CryptoKit
import Foundation
import Testing

@testable import TableProAnalytics

@MainActor
@Suite("AnalyticsHeartbeatService payload encoding")
struct AnalyticsHeartbeatPayloadTests {
private final class StubProvider: AnalyticsEnvironmentProvider {
var machineId = "machine-1"
var appVersion: String? = "1.0.0"
var osVersion = "macOS 15.1.0"
var architecture = "arm64"
var platform = "macos"
var locale = "en"
var isAnalyticsEnabled = true
var hasLicense = false
var activeDatabaseTypes: [String] = []
var activeConnectionCount = 0
var hmacSecret: String?
var connectionAttemptedAt: Date?
var connectionSucceededAt: Date?
var firstQueryExecutedAt: Date?
}

private func makeService(provider: StubProvider) -> AnalyticsHeartbeatService {
AnalyticsHeartbeatService(
provider: provider,
heartbeatInterval: 60,
initialDelay: 60,
cooldownInterval: 0
)
}

private func makePayload(provider: StubProvider) -> AnalyticsPayload {
AnalyticsPayload(
machineId: provider.machineId,
platform: provider.platform,
appVersion: provider.appVersion,
osVersion: provider.osVersion,
architecture: provider.architecture,
locale: provider.locale,
databaseTypes: provider.activeDatabaseTypes.isEmpty ? nil : provider.activeDatabaseTypes,
connectionCount: provider.activeConnectionCount,
hasLicense: provider.hasLicense,
connectionAttemptedAt: provider.connectionAttemptedAt,
connectionSucceededAt: provider.connectionSucceededAt,
firstQueryExecutedAt: provider.firstQueryExecutedAt
)
}

@Test("Encodes new timestamp fields as ISO 8601 strings in snake_case keys")
func encodesTimestampsIso8601() throws {
let provider = StubProvider()
let attempted = Date(timeIntervalSince1970: 1_700_000_000)
let succeeded = Date(timeIntervalSince1970: 1_700_000_300)
let queried = Date(timeIntervalSince1970: 1_700_001_000)
provider.connectionAttemptedAt = attempted
provider.connectionSucceededAt = succeeded
provider.firstQueryExecutedAt = queried

let service = makeService(provider: provider)
let body = try service.makeEncodedBodyForTesting(payload: makePayload(provider: provider))
let json = try #require(try JSONSerialization.jsonObject(with: body) as? [String: Any])

#expect(json["connection_attempted_at"] as? String == ISO8601DateFormatter().string(from: attempted))
#expect(json["connection_succeeded_at"] as? String == ISO8601DateFormatter().string(from: succeeded))
#expect(json["first_query_executed_at"] as? String == ISO8601DateFormatter().string(from: queried))
}

@Test("Omits timestamp fields when provider returns nil")
func omitsNilTimestampFields() throws {
let provider = StubProvider()
let service = makeService(provider: provider)
let body = try service.makeEncodedBodyForTesting(payload: makePayload(provider: provider))
let json = try #require(try JSONSerialization.jsonObject(with: body) as? [String: Any])

#expect(json["connection_attempted_at"] == nil)
#expect(json["connection_succeeded_at"] == nil)
#expect(json["first_query_executed_at"] == nil)
}

@Test("Includes existing payload fields with snake_case keys")
func encodesExistingFields() throws {
let provider = StubProvider()
provider.activeDatabaseTypes = ["mysql", "postgresql"]
provider.activeConnectionCount = 2
provider.hasLicense = true

let service = makeService(provider: provider)
let body = try service.makeEncodedBodyForTesting(payload: makePayload(provider: provider))
let json = try #require(try JSONSerialization.jsonObject(with: body) as? [String: Any])

#expect(json["machine_id"] as? String == "machine-1")
#expect(json["app_version"] as? String == "1.0.0")
#expect(json["os_version"] as? String == "macOS 15.1.0")
#expect(json["connection_count"] as? Int == 2)
#expect(json["has_license"] as? Bool == true)
}

@Test("HMAC signature covers the encoded body including new fields")
func hmacCoversNewFields() throws {
let provider = StubProvider()
provider.hmacSecret = "test-secret"

let service = makeService(provider: provider)
let basePayload = makePayload(provider: provider)
let baseBody = try service.makeEncodedBodyForTesting(payload: basePayload)

provider.firstQueryExecutedAt = Date(timeIntervalSince1970: 1_700_001_000)
let withTimestampPayload = makePayload(provider: provider)
let withTimestampBody = try service.makeEncodedBodyForTesting(payload: withTimestampPayload)

let key = SymmetricKey(data: Data("test-secret".utf8))
let baseSig = HMAC<SHA256>.authenticationCode(for: baseBody, using: key)
.map { String(format: "%02x", $0) }
.joined()
let withSig = HMAC<SHA256>.authenticationCode(for: withTimestampBody, using: key)
.map { String(format: "%02x", $0) }
.joined()

#expect(baseSig != withSig, "Signature must change when payload contents change")
#expect(baseBody != withTimestampBody, "Body must differ when a new timestamp is included")
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,7 @@ private final class MockDatabaseDriver: DatabaseDriver, @unchecked Sendable {
func switchDatabase(to name: String) async throws {}
var supportsSchemas: Bool { false }
func switchSchema(to name: String) async throws {}
func fetchSchemas() async throws -> [String] { [] }
var currentSchema: String? { nil }
var supportsTransactions: Bool { false }
func beginTransaction() async throws {}
Expand Down
1 change: 1 addition & 0 deletions TablePro/AppDelegate.swift
Original file line number Diff line number Diff line change
Expand Up @@ -58,6 +58,7 @@ class AppDelegate: NSObject, NSApplicationDelegate {
}

AnalyticsService.shared.startPeriodicHeartbeat()
NewsletterPromptCoordinator.shared.start()

SyncCoordinator.shared.start()
LinkedFolderWatcher.shared.start()
Expand Down
4 changes: 3 additions & 1 deletion TablePro/Core/Database/DatabaseManager+Queries.swift
Original file line number Diff line number Diff line change
Expand Up @@ -39,9 +39,11 @@ extension DatabaseManager {
throw DatabaseError.notConnected
}

return try await trackOperation(sessionId: sessionId) {
let result = try await trackOperation(sessionId: sessionId) {
try await driver.execute(query: query)
}
MacAnalyticsProvider.shared.markFirstQueryExecuted()
return result
}

/// Fetch tables from the current session
Expand Down
4 changes: 4 additions & 0 deletions TablePro/Core/Database/DatabaseManager+Sessions.swift
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,8 @@ extension DatabaseManager {
return
}

MacAnalyticsProvider.shared.markConnectionAttempted()

let resolvedConnection: DatabaseConnection
if LicenseManager.shared.isFeatureAvailable(.envVarReferences) {
resolvedConnection = EnvVarResolver.resolveConnection(connection)
Expand Down Expand Up @@ -136,7 +138,9 @@ extension DatabaseManager {

appSettingsStorage.saveLastConnectionId(connection.id)

MacAnalyticsProvider.shared.markConnectionSucceeded()
NotificationCenter.default.post(name: .databaseDidConnect, object: nil)
NotificationCenter.default.post(name: .successfulConnectionRecorded, object: nil)

let supportsHealth = PluginMetadataRegistry.shared.snapshot(
forTypeId: connection.type.pluginTypeId
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -15,7 +15,7 @@ final class AnalyticsService {
private let service: AnalyticsHeartbeatService

private init() {
service = AnalyticsHeartbeatService(provider: MacAnalyticsProvider())
service = AnalyticsHeartbeatService(provider: MacAnalyticsProvider.shared)
}

deinit {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,7 @@ extension Notification.Name {
static let connectionUpdated = Notification.Name("connectionUpdated")
static let connectionStatusDidChange = Notification.Name("connectionStatusDidChange")
static let databaseDidConnect = Notification.Name("databaseDidConnect")
static let successfulConnectionRecorded = Notification.Name("successfulConnectionRecorded")
static let exportConnections = Notification.Name("exportConnections")
static let importConnections = Notification.Name("importConnections")
static let importConnectionsFromApp = Notification.Name("importConnectionsFromApp")
Expand Down
63 changes: 63 additions & 0 deletions TablePro/Core/Services/Infrastructure/MacAnalyticsProvider.swift
Original file line number Diff line number Diff line change
Expand Up @@ -4,10 +4,29 @@
//

import Foundation
import os
import TableProAnalytics

@MainActor
final class MacAnalyticsProvider: AnalyticsEnvironmentProvider {
static let shared = MacAnalyticsProvider()

private static let logger = Logger(subsystem: "com.TablePro", category: "MacAnalyticsProvider")

private let defaults: UserDefaults

enum Keys {
static let connectionAttemptedAt = "com.TablePro.analytics.connectionAttemptedAt"
static let connectionSucceededAt = "com.TablePro.analytics.connectionSucceededAt"
static let firstQueryExecutedAt = "com.TablePro.analytics.firstQueryExecutedAt"
static let successfulConnectionCount = "com.TablePro.analytics.successfulConnectionCount"
static let newsletterPromptShown = "com.TablePro.newsletter.promptShown"
}

init(defaults: UserDefaults = .standard) {
self.defaults = defaults
}

var machineId: String {
LicenseStorage.shared.machineId
}
Expand Down Expand Up @@ -59,4 +78,48 @@ final class MacAnalyticsProvider: AnalyticsEnvironmentProvider {
}
return value
}

var connectionAttemptedAt: Date? {
defaults.object(forKey: Keys.connectionAttemptedAt) as? Date
}

var connectionSucceededAt: Date? {
defaults.object(forKey: Keys.connectionSucceededAt) as? Date
}

var firstQueryExecutedAt: Date? {
defaults.object(forKey: Keys.firstQueryExecutedAt) as? Date
}

var successfulConnectionCount: Int {
defaults.integer(forKey: Keys.successfulConnectionCount)
}

var newsletterPromptShown: Bool {
defaults.bool(forKey: Keys.newsletterPromptShown)
}

func markConnectionAttempted() {
writeOnceDate(Keys.connectionAttemptedAt, label: "connectionAttemptedAt")
}

func markConnectionSucceeded() {
writeOnceDate(Keys.connectionSucceededAt, label: "connectionSucceededAt")
let next = defaults.integer(forKey: Keys.successfulConnectionCount) + 1
defaults.set(next, forKey: Keys.successfulConnectionCount)
}

func markFirstQueryExecuted() {
writeOnceDate(Keys.firstQueryExecutedAt, label: "firstQueryExecutedAt")
}

func markNewsletterPromptShown() {
defaults.set(true, forKey: Keys.newsletterPromptShown)
}

private func writeOnceDate(_ key: String, label: String) {
guard defaults.object(forKey: key) == nil else { return }
defaults.set(Date(), forKey: key)
Self.logger.info("Recorded \(label, privacy: .public) for first time")
}
}
Loading
Loading