diff --git a/CHANGELOG.md b/CHANGELOG.md index db2c0ccb3..d5884cece 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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`. diff --git a/Packages/TableProCore/Package.swift b/Packages/TableProCore/Package.swift index f98dc7181..302781d3b 100644 --- a/Packages/TableProCore/Package.swift +++ b/Packages/TableProCore/Package.swift @@ -61,6 +61,11 @@ let package = Package( name: "TableProQueryTests", dependencies: ["TableProQuery", "TableProModels", "TableProPluginKit"], path: "Tests/TableProQueryTests" + ), + .testTarget( + name: "TableProAnalyticsTests", + dependencies: ["TableProAnalytics"], + path: "Tests/TableProAnalyticsTests" ) ] ) diff --git a/Packages/TableProCore/Sources/TableProAnalytics/AnalyticsEnvironmentProvider.swift b/Packages/TableProCore/Sources/TableProAnalytics/AnalyticsEnvironmentProvider.swift index ed3fa54f4..012eb8203 100644 --- a/Packages/TableProCore/Sources/TableProAnalytics/AnalyticsEnvironmentProvider.swift +++ b/Packages/TableProCore/Sources/TableProAnalytics/AnalyticsEnvironmentProvider.swift @@ -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 } } diff --git a/Packages/TableProCore/Sources/TableProAnalytics/AnalyticsHeartbeatService.swift b/Packages/TableProCore/Sources/TableProAnalytics/AnalyticsHeartbeatService.swift index 645d32a67..96c4d60c7 100644 --- a/Packages/TableProCore/Sources/TableProAnalytics/AnalyticsHeartbeatService.swift +++ b/Packages/TableProCore/Sources/TableProAnalytics/AnalyticsHeartbeatService.swift @@ -40,6 +40,7 @@ public final class AnalyticsHeartbeatService { private let encoder: JSONEncoder = { let encoder = JSONEncoder() encoder.keyEncodingStrategy = .convertToSnakeCase + encoder.dateEncodingStrategy = .iso8601 return encoder }() @@ -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 diff --git a/Packages/TableProCore/Sources/TableProAnalytics/AnalyticsPayload.swift b/Packages/TableProCore/Sources/TableProAnalytics/AnalyticsPayload.swift index 919b1cf5a..2305cb7d7 100644 --- a/Packages/TableProCore/Sources/TableProAnalytics/AnalyticsPayload.swift +++ b/Packages/TableProCore/Sources/TableProAnalytics/AnalyticsPayload.swift @@ -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, @@ -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 @@ -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 } } diff --git a/Packages/TableProCore/Tests/TableProAnalyticsTests/AnalyticsHeartbeatPayloadTests.swift b/Packages/TableProCore/Tests/TableProAnalyticsTests/AnalyticsHeartbeatPayloadTests.swift new file mode 100644 index 000000000..51dbf9e40 --- /dev/null +++ b/Packages/TableProCore/Tests/TableProAnalyticsTests/AnalyticsHeartbeatPayloadTests.swift @@ -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.authenticationCode(for: baseBody, using: key) + .map { String(format: "%02x", $0) } + .joined() + let withSig = HMAC.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") + } +} diff --git a/Packages/TableProCore/Tests/TableProDatabaseTests/ConnectionManagerTests.swift b/Packages/TableProCore/Tests/TableProDatabaseTests/ConnectionManagerTests.swift index c8909d51e..52d1363c8 100644 --- a/Packages/TableProCore/Tests/TableProDatabaseTests/ConnectionManagerTests.swift +++ b/Packages/TableProCore/Tests/TableProDatabaseTests/ConnectionManagerTests.swift @@ -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 {} diff --git a/TablePro/AppDelegate.swift b/TablePro/AppDelegate.swift index 5ca87cde4..179c897ef 100644 --- a/TablePro/AppDelegate.swift +++ b/TablePro/AppDelegate.swift @@ -58,6 +58,7 @@ class AppDelegate: NSObject, NSApplicationDelegate { } AnalyticsService.shared.startPeriodicHeartbeat() + NewsletterPromptCoordinator.shared.start() SyncCoordinator.shared.start() LinkedFolderWatcher.shared.start() diff --git a/TablePro/Core/Database/DatabaseManager+Queries.swift b/TablePro/Core/Database/DatabaseManager+Queries.swift index 2add4d5e2..a53d99605 100644 --- a/TablePro/Core/Database/DatabaseManager+Queries.swift +++ b/TablePro/Core/Database/DatabaseManager+Queries.swift @@ -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 diff --git a/TablePro/Core/Database/DatabaseManager+Sessions.swift b/TablePro/Core/Database/DatabaseManager+Sessions.swift index ecb77f56e..e94d034e9 100644 --- a/TablePro/Core/Database/DatabaseManager+Sessions.swift +++ b/TablePro/Core/Database/DatabaseManager+Sessions.swift @@ -19,6 +19,8 @@ extension DatabaseManager { return } + MacAnalyticsProvider.shared.markConnectionAttempted() + let resolvedConnection: DatabaseConnection if LicenseManager.shared.isFeatureAvailable(.envVarReferences) { resolvedConnection = EnvVarResolver.resolveConnection(connection) @@ -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 diff --git a/TablePro/Core/Services/Infrastructure/AnalyticsService.swift b/TablePro/Core/Services/Infrastructure/AnalyticsService.swift index 11cffb742..cbd2f176b 100644 --- a/TablePro/Core/Services/Infrastructure/AnalyticsService.swift +++ b/TablePro/Core/Services/Infrastructure/AnalyticsService.swift @@ -15,7 +15,7 @@ final class AnalyticsService { private let service: AnalyticsHeartbeatService private init() { - service = AnalyticsHeartbeatService(provider: MacAnalyticsProvider()) + service = AnalyticsHeartbeatService(provider: MacAnalyticsProvider.shared) } deinit { diff --git a/TablePro/Core/Services/Infrastructure/AppNotifications.swift b/TablePro/Core/Services/Infrastructure/AppNotifications.swift index fa88c5b6a..0d5f60a62 100644 --- a/TablePro/Core/Services/Infrastructure/AppNotifications.swift +++ b/TablePro/Core/Services/Infrastructure/AppNotifications.swift @@ -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") diff --git a/TablePro/Core/Services/Infrastructure/MacAnalyticsProvider.swift b/TablePro/Core/Services/Infrastructure/MacAnalyticsProvider.swift index 2bb94264f..0ee39ba71 100644 --- a/TablePro/Core/Services/Infrastructure/MacAnalyticsProvider.swift +++ b/TablePro/Core/Services/Infrastructure/MacAnalyticsProvider.swift @@ -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 } @@ -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") + } } diff --git a/TablePro/Core/Services/Infrastructure/NewsletterPromptCoordinator.swift b/TablePro/Core/Services/Infrastructure/NewsletterPromptCoordinator.swift new file mode 100644 index 000000000..89d287d6f --- /dev/null +++ b/TablePro/Core/Services/Infrastructure/NewsletterPromptCoordinator.swift @@ -0,0 +1,67 @@ +// +// NewsletterPromptCoordinator.swift +// TablePro +// + +import AppKit +import Foundation +import os + +@MainActor +final class NewsletterPromptCoordinator { + static let shared = NewsletterPromptCoordinator() + + static let promptThreshold = 3 + static let subscribeURL = URL(string: "https://tablepro.app/?subscribe=true&source=mac") + + private static let logger = Logger(subsystem: "com.TablePro", category: "NewsletterPrompt") + + private var observer: NSObjectProtocol? + private let provider: MacAnalyticsProvider + private let workspace: NSWorkspace + + private init(provider: MacAnalyticsProvider = .shared, workspace: NSWorkspace = .shared) { + self.provider = provider + self.workspace = workspace + } + + func start() { + guard observer == nil else { return } + observer = NotificationCenter.default.addObserver( + forName: .successfulConnectionRecorded, + object: nil, + queue: .main + ) { [weak self] _ in + Task { @MainActor [weak self] in + self?.evaluateAndPresent() + } + } + } + + func evaluateAndPresent() { + guard !provider.newsletterPromptShown, + provider.successfulConnectionCount >= Self.promptThreshold else { + return + } + present() + } + + private func present() { + provider.markNewsletterPromptShown() + + let alert = NSAlert() + alert.messageText = String(localized: "Stay updated on TablePro releases") + alert.informativeText = String(localized: "Get release notes and database tips by email. No spam, unsubscribe anytime.") + alert.alertStyle = .informational + alert.addButton(withTitle: String(localized: "Subscribe in Browser")) + alert.addButton(withTitle: String(localized: "Maybe later")) + + let response = alert.runModal() + guard response == .alertFirstButtonReturn else { return } + guard let url = Self.subscribeURL else { + Self.logger.error("Newsletter subscribe URL is invalid") + return + } + workspace.open(url) + } +} diff --git a/TableProMobile/TableProMobile/Coordinators/ConnectionCoordinator.swift b/TableProMobile/TableProMobile/Coordinators/ConnectionCoordinator.swift index dd449027c..d4b4df8a5 100644 --- a/TableProMobile/TableProMobile/Coordinators/ConnectionCoordinator.swift +++ b/TableProMobile/TableProMobile/Coordinators/ConnectionCoordinator.swift @@ -3,9 +3,9 @@ // TableProMobile // -import os import Foundation import Observation +import os import SwiftUI import TableProDatabase import TableProModels @@ -112,6 +112,8 @@ final class ConnectionCoordinator { private func connectFresh() async { await appState.sshProvider.setPendingConnectionId(connection.id) + IOSAnalyticsProvider.shared.markConnectionAttempted() + do { let newSession = try await appState.connectionManager.connect(connection) self.session = newSession @@ -119,6 +121,7 @@ final class ConnectionCoordinator { await loadDatabases() await loadSchemas() phase = .connected + IOSAnalyticsProvider.shared.markConnectionSucceeded() navigateToPendingTable() } catch { let context = ErrorContext( diff --git a/TableProMobile/TableProMobile/Platform/IOSAnalyticsProvider.swift b/TableProMobile/TableProMobile/Platform/IOSAnalyticsProvider.swift index 5481df9b9..19560a410 100644 --- a/TableProMobile/TableProMobile/Platform/IOSAnalyticsProvider.swift +++ b/TableProMobile/TableProMobile/Platform/IOSAnalyticsProvider.swift @@ -4,6 +4,7 @@ // import Foundation +import os import TableProAnalytics import TableProDatabase import TableProModels @@ -11,15 +12,32 @@ import UIKit @MainActor final class IOSAnalyticsProvider: AnalyticsEnvironmentProvider { - private let appState: AppState + static let shared = IOSAnalyticsProvider() - init(appState: AppState) { + private static let logger = Logger(subsystem: "com.TablePro", category: "IOSAnalyticsProvider") + + private weak var appState: AppState? + + 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" + } + + init(defaults: UserDefaults = .standard) { + self.defaults = defaults + } + + func attach(appState: AppState) { self.appState = appState } var machineId: String { let stableKey = "com.TablePro.analytics.stableDeviceId" - if let stable = UserDefaults.standard.string(forKey: stableKey) { + if let stable = defaults.string(forKey: stableKey) { return stable } let id: String @@ -28,7 +46,7 @@ final class IOSAnalyticsProvider: AnalyticsEnvironmentProvider { } else { id = UUID().uuidString.sha256 } - UserDefaults.standard.set(id, forKey: stableKey) + defaults.set(id, forKey: stableKey) return id } @@ -50,12 +68,13 @@ final class IOSAnalyticsProvider: AnalyticsEnvironmentProvider { } var isAnalyticsEnabled: Bool { - UserDefaults.standard.object(forKey: "com.TablePro.settings.shareAnalytics") as? Bool ?? true + defaults.object(forKey: "com.TablePro.settings.shareAnalytics") as? Bool ?? true } var hasLicense: Bool { false } var activeDatabaseTypes: [String] { + guard let appState else { return [] } let active = appState.connections.filter { conn in appState.connectionManager.session(for: conn.id) != nil } @@ -63,7 +82,8 @@ final class IOSAnalyticsProvider: AnalyticsEnvironmentProvider { } var activeConnectionCount: Int { - appState.connections.filter { conn in + guard let appState else { return 0 } + return appState.connections.filter { conn in appState.connectionManager.session(for: conn.id) != nil }.count } @@ -76,4 +96,36 @@ final class IOSAnalyticsProvider: 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 + } + + 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") + } + + 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") + } } diff --git a/TableProMobile/TableProMobile/TableProMobileApp.swift b/TableProMobile/TableProMobile/TableProMobileApp.swift index a825f3694..288e9cb11 100644 --- a/TableProMobile/TableProMobile/TableProMobileApp.swift +++ b/TableProMobile/TableProMobile/TableProMobileApp.swift @@ -64,7 +64,8 @@ struct TableProMobileApp: App { ) } if heartbeatTask == nil { - let provider = IOSAnalyticsProvider(appState: appState) + let provider = IOSAnalyticsProvider.shared + provider.attach(appState: appState) let service = AnalyticsHeartbeatService(provider: provider) heartbeatService = service heartbeatTask = service.startPeriodicHeartbeat() diff --git a/TableProMobile/TableProMobile/Views/QueryEditorView.swift b/TableProMobile/TableProMobile/Views/QueryEditorView.swift index a06475732..1647d8a38 100644 --- a/TableProMobile/TableProMobile/Views/QueryEditorView.swift +++ b/TableProMobile/TableProMobile/Views/QueryEditorView.swift @@ -143,7 +143,7 @@ struct QueryEditorView: View { .foregroundStyle(.secondary) } } else if let time = executionTime { - Text(String(format: "%.1fms", time * 1000)) + Text(String(format: "%.1fms", time * 1_000)) .font(.caption2) .foregroundStyle(.secondary) } @@ -397,6 +397,8 @@ struct QueryEditorView: View { self.executionTime = queryResult.executionTime hapticSuccess.toggle() + IOSAnalyticsProvider.shared.markFirstQueryExecuted() + let item = QueryHistoryItem(query: trimmed, connectionId: connectionId) coordinator.addHistoryItem(item) } catch { diff --git a/TableProTests/Services/MacAnalyticsProviderTests.swift b/TableProTests/Services/MacAnalyticsProviderTests.swift new file mode 100644 index 000000000..42ddc4e19 --- /dev/null +++ b/TableProTests/Services/MacAnalyticsProviderTests.swift @@ -0,0 +1,117 @@ +// +// MacAnalyticsProviderTests.swift +// TableProTests +// + +import Foundation +import Testing + +@testable import TablePro + +@MainActor +@Suite("MacAnalyticsProvider write-once timestamp semantics") +struct MacAnalyticsProviderTests { + private static let suiteCounter = SuiteCounter() + + private final class SuiteCounter: @unchecked Sendable { + private var value: Int = 0 + private let lock = NSLock() + func next() -> Int { + lock.lock() + defer { lock.unlock() } + value += 1 + return value + } + } + + private func makeProvider(test: String = #function) throws -> (MacAnalyticsProvider, UserDefaults) { + let id = "test.MacAnalyticsProviderTests.\(test).\(Self.suiteCounter.next()).\(UUID().uuidString)" + let defaults = try #require(UserDefaults(suiteName: id)) + defaults.removePersistentDomain(forName: id) + return (MacAnalyticsProvider(defaults: defaults), defaults) + } + + @Test("Getter returns nil when no timestamp recorded") + func gettersAreNilByDefault() throws { + let (provider, _) = try makeProvider() + #expect(provider.connectionAttemptedAt == nil) + #expect(provider.connectionSucceededAt == nil) + #expect(provider.firstQueryExecutedAt == nil) + } + + @Test("markConnectionAttempted writes once and never overwrites") + func attemptedIsWriteOnce() throws { + let (provider, _) = try makeProvider() + provider.markConnectionAttempted() + let first = provider.connectionAttemptedAt + #expect(first != nil) + + Thread.sleep(forTimeInterval: 0.01) + provider.markConnectionAttempted() + let second = provider.connectionAttemptedAt + + #expect(first == second, "Second mark must not overwrite the first timestamp") + } + + @Test("markConnectionSucceeded writes once and never overwrites") + func succeededIsWriteOnce() throws { + let (provider, _) = try makeProvider() + provider.markConnectionSucceeded() + let first = provider.connectionSucceededAt + #expect(first != nil) + + Thread.sleep(forTimeInterval: 0.01) + provider.markConnectionSucceeded() + let second = provider.connectionSucceededAt + + #expect(first == second) + } + + @Test("markFirstQueryExecuted writes once and never overwrites") + func firstQueryIsWriteOnce() throws { + let (provider, _) = try makeProvider() + provider.markFirstQueryExecuted() + let first = provider.firstQueryExecutedAt + #expect(first != nil) + + Thread.sleep(forTimeInterval: 0.01) + provider.markFirstQueryExecuted() + let second = provider.firstQueryExecutedAt + + #expect(first == second) + } + + @Test("markConnectionAttempted does not affect connectionSucceededAt") + func attemptedDoesNotAffectSucceeded() throws { + let (provider, _) = try makeProvider() + provider.markConnectionAttempted() + + #expect(provider.connectionAttemptedAt != nil) + #expect(provider.connectionSucceededAt == nil) + #expect(provider.firstQueryExecutedAt == nil) + } + + @Test("Each successful connection increments the counter, regardless of write-once timestamp") + func successfulCounterIncrementsEachCall() throws { + let (provider, _) = try makeProvider() + #expect(provider.successfulConnectionCount == 0) + + provider.markConnectionSucceeded() + #expect(provider.successfulConnectionCount == 1) + let firstSucceededAt = provider.connectionSucceededAt + + provider.markConnectionSucceeded() + provider.markConnectionSucceeded() + + #expect(provider.successfulConnectionCount == 3) + #expect(provider.connectionSucceededAt == firstSucceededAt, "Timestamp stays write-once even as counter advances") + } + + @Test("Newsletter prompt-shown flag flips once and stays true") + func newsletterPromptShownIsLatched() throws { + let (provider, _) = try makeProvider() + #expect(provider.newsletterPromptShown == false) + provider.markNewsletterPromptShown() + #expect(provider.newsletterPromptShown == true) + } +}