diff --git a/CHANGELOG.md b/CHANGELOG.md index a5a69bed4..095f561a9 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -12,7 +12,6 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - Sample database (Chinook) bundled — open from welcome screen with one click; reset via File menu - Connection string detection — paste a `postgres://`, `mysql://`, `redis://`, or `mongodb://` URL, then click Use to auto-fill the connection form - 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/TablePro/AppDelegate.swift b/TablePro/AppDelegate.swift index 28fb49efa..12418fbc6 100644 --- a/TablePro/AppDelegate.swift +++ b/TablePro/AppDelegate.swift @@ -58,7 +58,6 @@ class AppDelegate: NSObject, NSApplicationDelegate { } AnalyticsService.shared.startPeriodicHeartbeat() - NewsletterPromptCoordinator.shared.start() SyncCoordinator.shared.start() LinkedFolderWatcher.shared.start() diff --git a/TablePro/Core/Database/DatabaseManager+Sessions.swift b/TablePro/Core/Database/DatabaseManager+Sessions.swift index e94d034e9..1a9ef4acd 100644 --- a/TablePro/Core/Database/DatabaseManager+Sessions.swift +++ b/TablePro/Core/Database/DatabaseManager+Sessions.swift @@ -140,7 +140,6 @@ extension DatabaseManager { 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/AppNotifications.swift b/TablePro/Core/Services/Infrastructure/AppNotifications.swift index e468c521c..e1c614138 100644 --- a/TablePro/Core/Services/Infrastructure/AppNotifications.swift +++ b/TablePro/Core/Services/Infrastructure/AppNotifications.swift @@ -19,7 +19,6 @@ 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 0ee39ba71..d64e133b9 100644 --- a/TablePro/Core/Services/Infrastructure/MacAnalyticsProvider.swift +++ b/TablePro/Core/Services/Infrastructure/MacAnalyticsProvider.swift @@ -19,8 +19,6 @@ final class MacAnalyticsProvider: AnalyticsEnvironmentProvider { 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) { @@ -91,32 +89,18 @@ final class MacAnalyticsProvider: AnalyticsEnvironmentProvider { 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) diff --git a/TablePro/Core/Services/Infrastructure/NewsletterPromptCoordinator.swift b/TablePro/Core/Services/Infrastructure/NewsletterPromptCoordinator.swift deleted file mode 100644 index 89d287d6f..000000000 --- a/TablePro/Core/Services/Infrastructure/NewsletterPromptCoordinator.swift +++ /dev/null @@ -1,67 +0,0 @@ -// -// 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/TablePro/Resources/Localizable.xcstrings b/TablePro/Resources/Localizable.xcstrings index 8ce411ed4..f2f984b64 100644 --- a/TablePro/Resources/Localizable.xcstrings +++ b/TablePro/Resources/Localizable.xcstrings @@ -7836,6 +7836,9 @@ } } } + }, + "Bundled sample database" : { + }, "Bytes Received" : { "localizations" : { @@ -8707,6 +8710,9 @@ } } } + }, + "Chinook (Sample)" : { + }, "Choose a certificate or key file" : { "localizations" : { @@ -9751,6 +9757,9 @@ } } } + }, + "Close the open Sample connection before resetting it." : { + }, "Closing this tab will discard all unsaved changes." : { "extractionState" : "stale", @@ -12466,6 +12475,9 @@ } } } + }, + "Could not install the sample database: %@" : { + }, "Could Not Open File" : { "localizations" : { @@ -12488,6 +12500,9 @@ } } } + }, + "Could Not Open Sample" : { + }, "Could not parse database URL: %@" : { "localizations" : { @@ -12554,6 +12569,9 @@ } } } + }, + "Could Not Reset Sample" : { + }, "Count all rows" : { "localizations" : { @@ -12644,6 +12662,7 @@ } }, "Create a connection to get started" : { + "extractionState" : "stale", "localizations" : { "tr" : { "stringUnit" : { @@ -22161,9 +22180,6 @@ } } } - }, - "Get release notes and database tips by email. No spam, unsubscribe anytime." : { - }, "Get Started" : { "localizations" : { @@ -27669,9 +27685,6 @@ } } } - }, - "Maybe later" : { - }, "MCP Access Request" : { "localizations" : { @@ -32264,6 +32277,9 @@ } } } + }, + "Open Sample Database" : { + }, "Open Schema" : { "localizations" : { @@ -37806,6 +37822,15 @@ } } } + }, + "Reset Sample" : { + + }, + "Reset Sample Database?" : { + + }, + "Reset Sample Database..." : { + }, "Reset to Defaults" : { "localizations" : { @@ -38893,6 +38918,9 @@ } } } + }, + "Sample" : { + }, "Sanitize formula-like values" : { "extractionState" : "stale", @@ -43954,9 +43982,6 @@ } } } - }, - "Stay updated on TablePro releases" : { - }, "Steps to Reproduce" : { "localizations" : { @@ -44266,9 +44291,6 @@ } } } - }, - "Subscribe in Browser" : { - }, "Success" : { "localizations" : { @@ -45809,6 +45831,9 @@ } } } + }, + "The bundled sample database is missing from the app." : { + }, "The code expires in 15 minutes." : { "localizations" : { @@ -46156,6 +46181,9 @@ } } } + }, + "The text doesn't look like a connection URL." : { + }, "The text is not valid JSON. Save anyway?" : { "localizations" : { @@ -46333,6 +46361,9 @@ } } } + }, + "This discards your edits to the Chinook sample and restores the original copy." : { + }, "This DROP query will permanently remove database objects. This action cannot be undone." : { "localizations" : { @@ -48449,6 +48480,7 @@ } }, "Try a different search term" : { + "extractionState" : "stale", "localizations" : { "tr" : { "stringUnit" : { @@ -48469,6 +48501,9 @@ } } } + }, + "Try a different search term." : { + }, "Try adjusting your search terms\nor date filter." : { "localizations" : { @@ -48513,6 +48548,12 @@ } } } + }, + "Try Sample Database" : { + + }, + "Try the sample database, or click + above to add your own." : { + }, "Two-Factor Authentication" : { "localizations" : { @@ -49110,6 +49151,9 @@ } } } + }, + "Unsupported connection scheme: %@" : { + }, "Unsupported database scheme: %@" : { "localizations" : { @@ -49508,6 +49552,9 @@ } } } + }, + "Use" : { + }, "Use ~/.pgpass" : { "localizations" : { @@ -49530,6 +49577,9 @@ } } } + }, + "Use clipboard URL" : { + }, "Use Default" : { "localizations" : { diff --git a/TableProTests/Services/MacAnalyticsProviderTests.swift b/TableProTests/Services/MacAnalyticsProviderTests.swift index 42ddc4e19..fb23326bc 100644 --- a/TableProTests/Services/MacAnalyticsProviderTests.swift +++ b/TableProTests/Services/MacAnalyticsProviderTests.swift @@ -91,27 +91,17 @@ struct MacAnalyticsProviderTests { #expect(provider.firstQueryExecutedAt == nil) } - @Test("Each successful connection increments the counter, regardless of write-once timestamp") - func successfulCounterIncrementsEachCall() throws { + @Test("Repeated successful connections keep the timestamp write-once") + func succeededTimestampStaysWriteOnce() throws { let (provider, _) = try makeProvider() - #expect(provider.successfulConnectionCount == 0) provider.markConnectionSucceeded() - #expect(provider.successfulConnectionCount == 1) let firstSucceededAt = provider.connectionSucceededAt + #expect(firstSucceededAt != nil) 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) + #expect(provider.connectionSucceededAt == firstSucceededAt) } }