Skip to content

Commit 2bce72d

Browse files
committed
Merge branch 'main' into feat/json-viewer
2 parents 6835bd2 + 9dd0eb4 commit 2bce72d

62 files changed

Lines changed: 4916 additions & 729 deletions

File tree

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

CHANGELOG.md

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,8 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
1010
### Added
1111

1212
- JSON viewer with Text/Tree toggle for query results — tree view with expand/collapse, search, copy key path
13+
- MCP server: built-in Model Context Protocol server lets AI tools (Claude Desktop, Claude Code, Cursor) browse schemas, run queries, and export data through TablePro's connections
14+
- MCP server: connected clients list in Settings and status menu item showing server state
1315
- Import connections from TablePlus, Sequel Ace, and DBeaver with one-click migration
1416
- Embedded database CLI terminal (View > Open Terminal or Ctrl+Cmd+`) auto-launches mysql, psql, redis-cli, etc. for the active connection
1517
- Structure tab: modify existing tables (add, modify, drop columns, indexes, foreign keys, primary keys)

Packages/TableProCore/Package.swift

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -13,7 +13,8 @@ let package = Package(
1313
.library(name: "TableProModels", targets: ["TableProModels"]),
1414
.library(name: "TableProDatabase", targets: ["TableProDatabase"]),
1515
.library(name: "TableProQuery", targets: ["TableProQuery"]),
16-
.library(name: "TableProSync", targets: ["TableProSync"])
16+
.library(name: "TableProSync", targets: ["TableProSync"]),
17+
.library(name: "TableProAnalytics", targets: ["TableProAnalytics"])
1718
],
1819
targets: [
1920
.target(
@@ -41,6 +42,11 @@ let package = Package(
4142
dependencies: ["TableProModels"],
4243
path: "Sources/TableProSync"
4344
),
45+
.target(
46+
name: "TableProAnalytics",
47+
dependencies: [],
48+
path: "Sources/TableProAnalytics"
49+
),
4450
.testTarget(
4551
name: "TableProModelsTests",
4652
dependencies: ["TableProModels", "TableProPluginKit"],
Lines changed: 47 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,47 @@
1+
//
2+
// AnalyticsEnvironmentProvider.swift
3+
// TableProAnalytics
4+
//
5+
6+
import Foundation
7+
8+
/// Protocol that platform-specific apps conform to, providing all environment data for analytics heartbeats.
9+
///
10+
/// macOS and iOS each implement this with platform-specific data sources (IOKit vs UIDevice,
11+
/// DatabaseManager vs AppState, etc.). The heartbeat service reads these properties at send time
12+
/// to build a fresh payload.
13+
@MainActor
14+
public protocol AnalyticsEnvironmentProvider: AnyObject {
15+
/// SHA256-hashed machine/device identifier (64 hex chars)
16+
var machineId: String { get }
17+
18+
/// App version string (e.g. "1.2.0") from CFBundleShortVersionString
19+
var appVersion: String? { get }
20+
21+
/// OS version string (e.g. "macOS 15.1.0" or "iOS 18.2.0")
22+
var osVersion: String { get }
23+
24+
/// CPU architecture (e.g. "arm64", "x86_64")
25+
var architecture: String { get }
26+
27+
/// Platform identifier sent to backend ("macos" or "ios")
28+
var platform: String { get }
29+
30+
/// User locale preference (e.g. "en", "vi", "system")
31+
var locale: String { get }
32+
33+
/// Whether the user has opted in to analytics
34+
var isAnalyticsEnabled: Bool { get }
35+
36+
/// Whether the user has a valid license
37+
var hasLicense: Bool { get }
38+
39+
/// Database type identifiers for active connections (e.g. ["mysql", "postgresql"])
40+
var activeDatabaseTypes: [String] { get }
41+
42+
/// Number of active database connections
43+
var activeConnectionCount: Int { get }
44+
45+
/// HMAC-SHA256 shared secret for request signing (from Info.plist build setting)
46+
var hmacSecret: String? { get }
47+
}
Lines changed: 144 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,144 @@
1+
//
2+
// AnalyticsHeartbeatService.swift
3+
// TableProAnalytics
4+
//
5+
6+
import CryptoKit
7+
import Foundation
8+
import os
9+
10+
/// Shared heartbeat service for macOS and iOS. Sends anonymous usage data to the analytics API.
11+
///
12+
/// Platform-specific data is injected via `AnalyticsEnvironmentProvider`. The service handles:
13+
/// encoding, HMAC-SHA256 signing, HTTP transport, heartbeat scheduling, and cooldown persistence.
14+
@MainActor
15+
public final class AnalyticsHeartbeatService {
16+
private static let logger = Logger(subsystem: "com.TablePro", category: "AnalyticsHeartbeat")
17+
18+
private let provider: AnalyticsEnvironmentProvider
19+
20+
// swiftlint:disable:next force_unwrapping
21+
private let analyticsUrl: URL
22+
23+
private let heartbeatInterval: TimeInterval
24+
private let initialDelay: TimeInterval
25+
26+
/// Minimum elapsed time before sending another heartbeat.
27+
/// Prevents duplicate sends on iOS when the app cycles between foreground/background.
28+
private let cooldownInterval: TimeInterval
29+
30+
private static let lastHeartbeatKey = "com.TablePro.analytics.lastHeartbeatDate"
31+
32+
private let session: URLSession = {
33+
let config = URLSessionConfiguration.default
34+
config.timeoutIntervalForRequest = 15
35+
config.timeoutIntervalForResource = 30
36+
config.waitsForConnectivity = true
37+
return URLSession(configuration: config)
38+
}()
39+
40+
private let encoder: JSONEncoder = {
41+
let encoder = JSONEncoder()
42+
encoder.keyEncodingStrategy = .convertToSnakeCase
43+
return encoder
44+
}()
45+
46+
public init(
47+
provider: AnalyticsEnvironmentProvider,
48+
analyticsUrl: URL = URL(string: "https://api.tablepro.app/v1/analytics")!, // swiftlint:disable:this force_unwrapping
49+
heartbeatInterval: TimeInterval = 24 * 60 * 60,
50+
initialDelay: TimeInterval = 10,
51+
cooldownInterval: TimeInterval = 20 * 60 * 60
52+
) {
53+
self.provider = provider
54+
self.analyticsUrl = analyticsUrl
55+
self.heartbeatInterval = heartbeatInterval
56+
self.initialDelay = initialDelay
57+
self.cooldownInterval = cooldownInterval
58+
}
59+
60+
// MARK: - Public API
61+
62+
/// Start the periodic heartbeat loop. Returns a cancellable Task.
63+
/// The caller owns the Task lifecycle (cancel on deinit or background).
64+
public func startPeriodicHeartbeat() -> Task<Void, Never> {
65+
Task { [weak self] in
66+
guard let delay = self?.initialDelay else { return }
67+
try? await Task.sleep(for: .seconds(delay))
68+
69+
while !Task.isCancelled {
70+
guard let target = self else { return }
71+
await target.sendHeartbeat()
72+
try? await Task.sleep(for: .seconds(target.heartbeatInterval))
73+
}
74+
}
75+
}
76+
77+
/// Send a single heartbeat. Respects opt-out and cooldown.
78+
public func sendHeartbeat() async {
79+
guard provider.isAnalyticsEnabled else {
80+
Self.logger.trace("Analytics disabled by user, skipping heartbeat")
81+
return
82+
}
83+
84+
guard isCooldownElapsed() else {
85+
Self.logger.trace("Analytics cooldown not elapsed, skipping heartbeat")
86+
return
87+
}
88+
89+
let payload = buildPayload()
90+
91+
do {
92+
var request = URLRequest(url: analyticsUrl)
93+
request.httpMethod = "POST"
94+
request.setValue("application/json", forHTTPHeaderField: "Content-Type")
95+
request.httpBody = try encoder.encode(payload)
96+
97+
if let body = request.httpBody,
98+
let secret = provider.hmacSecret, !secret.isEmpty {
99+
let key = SymmetricKey(data: Data(secret.utf8))
100+
let signature = HMAC<SHA256>.authenticationCode(for: body, using: key)
101+
let signatureHex = signature.map { String(format: "%02x", $0) }.joined()
102+
request.setValue(signatureHex, forHTTPHeaderField: "X-Signature")
103+
}
104+
105+
let (_, response) = try await session.data(for: request)
106+
107+
if let httpResponse = response as? HTTPURLResponse {
108+
Self.logger.trace("Analytics heartbeat sent, status: \(httpResponse.statusCode)")
109+
}
110+
111+
recordHeartbeatTimestamp()
112+
} catch {
113+
Self.logger.trace("Analytics heartbeat failed: \(error.localizedDescription)")
114+
}
115+
}
116+
117+
// MARK: - Private
118+
119+
private func buildPayload() -> AnalyticsPayload {
120+
let types = provider.activeDatabaseTypes
121+
return AnalyticsPayload(
122+
machineId: provider.machineId,
123+
platform: provider.platform,
124+
appVersion: provider.appVersion,
125+
osVersion: provider.osVersion,
126+
architecture: provider.architecture,
127+
locale: provider.locale,
128+
databaseTypes: types.isEmpty ? nil : types,
129+
connectionCount: provider.activeConnectionCount,
130+
hasLicense: provider.hasLicense
131+
)
132+
}
133+
134+
private func isCooldownElapsed() -> Bool {
135+
guard let last = UserDefaults.standard.object(forKey: Self.lastHeartbeatKey) as? Date else {
136+
return true
137+
}
138+
return Date().timeIntervalSince(last) >= cooldownInterval
139+
}
140+
141+
private func recordHeartbeatTimestamp() {
142+
UserDefaults.standard.set(Date(), forKey: Self.lastHeartbeatKey)
143+
}
144+
}
Lines changed: 42 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,42 @@
1+
//
2+
// AnalyticsPayload.swift
3+
// TableProAnalytics
4+
//
5+
6+
import Foundation
7+
8+
/// Anonymous heartbeat payload sent to the analytics API every 24 hours.
9+
/// Encoded with snake_case keys to match backend expectations.
10+
public struct AnalyticsPayload: Encodable, Sendable {
11+
public let machineId: String
12+
public let platform: String
13+
public let appVersion: String?
14+
public let osVersion: String
15+
public let architecture: String
16+
public let locale: String
17+
public let databaseTypes: [String]?
18+
public let connectionCount: Int
19+
public let hasLicense: Bool
20+
21+
public init(
22+
machineId: String,
23+
platform: String,
24+
appVersion: String?,
25+
osVersion: String,
26+
architecture: String,
27+
locale: String,
28+
databaseTypes: [String]?,
29+
connectionCount: Int,
30+
hasLicense: Bool
31+
) {
32+
self.machineId = machineId
33+
self.platform = platform
34+
self.appVersion = appVersion
35+
self.osVersion = osVersion
36+
self.architecture = architecture
37+
self.locale = locale
38+
self.databaseTypes = databaseTypes
39+
self.connectionCount = connectionCount
40+
self.hasLicense = hasLicense
41+
}
42+
}

TablePro.xcodeproj/project.pbxproj

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@
99
/* Begin PBXBuildFile section */
1010
5A3A69B82F976F38000AC5B2 /* GhosttyTerminal in Frameworks */ = {isa = PBXBuildFile; productRef = 5A3A69B72F976F38000AC5B2 /* GhosttyTerminal */; };
1111
5A3A69BA2F976F38000AC5B2 /* GhosttyTheme in Frameworks */ = {isa = PBXBuildFile; productRef = 5A3A69B92F976F38000AC5B2 /* GhosttyTheme */; };
12+
5A7E78A02F95F02A00EEF236 /* TableProAnalytics in Frameworks */ = {isa = PBXBuildFile; productRef = 5ACE00012F4F000000000010 /* TableProAnalytics */; };
1213
5A860000A00000000 /* TableProPluginKit.framework in Embed Frameworks */ = {isa = PBXBuildFile; fileRef = 5A860000100000000 /* TableProPluginKit.framework */; settings = {ATTRIBUTES = (CodeSignOnCopy, RemoveHeadersOnCopy, ); }; };
1314
5A861000A00000000 /* TableProPluginKit.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 5A860000100000000 /* TableProPluginKit.framework */; };
1415
5A862000A00000000 /* TableProPluginKit.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 5A860000100000000 /* TableProPluginKit.framework */; };
@@ -584,6 +585,7 @@
584585
buildActionMask = 2147483647;
585586
files = (
586587
5A3A69BA2F976F38000AC5B2 /* GhosttyTheme in Frameworks */,
588+
5A7E78A02F95F02A00EEF236 /* TableProAnalytics in Frameworks */,
587589
5ACE00012F4F000000000004 /* CodeEditSourceEditor in Frameworks */,
588590
5A3A69B82F976F38000AC5B2 /* GhosttyTerminal in Frameworks */,
589591
5ACE00012F4F000000000005 /* CodeEditLanguages in Frameworks */,
@@ -946,6 +948,7 @@
946948
5ACE00012F4F00000000000C /* MarkdownUI */,
947949
5A3A69B72F976F38000AC5B2 /* GhosttyTerminal */,
948950
5A3A69B92F976F38000AC5B2 /* GhosttyTheme */,
951+
5ACE00012F4F000000000010 /* TableProAnalytics */,
949952
);
950953
productName = TablePro;
951954
productReference = 5A1091C72EF17EDC0055EA7C /* TablePro.app */;
@@ -1486,6 +1489,7 @@
14861489
5A0000012F4F000000000100 /* XCLocalSwiftPackageReference "LocalPackages/CodeEditLanguages" */,
14871490
5ACE00012F4F00000000000E /* XCRemoteSwiftPackageReference "oracle-nio" */,
14881491
5A3A69B62F976F38000AC5B2 /* XCRemoteSwiftPackageReference "libghostty-spm" */,
1492+
5A0000012F4F000000000102 /* XCLocalSwiftPackageReference "Packages/TableProCore" */,
14891493
);
14901494
preferredProjectObjectVersion = 77;
14911495
productRefGroup = 5A1091C82EF17EDC0055EA7C /* Products */;
@@ -3657,6 +3661,10 @@
36573661
isa = XCLocalSwiftPackageReference;
36583662
relativePath = LocalPackages/CodeEditSourceEditor;
36593663
};
3664+
5A0000012F4F000000000102 /* XCLocalSwiftPackageReference "Packages/TableProCore" */ = {
3665+
isa = XCLocalSwiftPackageReference;
3666+
relativePath = Packages/TableProCore;
3667+
};
36603668
/* End XCLocalSwiftPackageReference section */
36613669

36623670
/* Begin XCRemoteSwiftPackageReference section */
@@ -3732,6 +3740,10 @@
37323740
package = 5ACE00012F4F00000000000E /* XCRemoteSwiftPackageReference "oracle-nio" */;
37333741
productName = OracleNIO;
37343742
};
3743+
5ACE00012F4F000000000010 /* TableProAnalytics */ = {
3744+
isa = XCSwiftPackageProductDependency;
3745+
productName = TableProAnalytics;
3746+
};
37353747
/* End XCSwiftPackageProductDependency section */
37363748
};
37373749
rootObject = 5A1091BF2EF17EDC0055EA7C /* Project object */;

TablePro/AppDelegate.swift

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -108,6 +108,12 @@ class AppDelegate: NSObject, NSApplicationDelegate {
108108
SyncCoordinator.shared.start()
109109
LinkedFolderWatcher.shared.start()
110110

111+
if AppSettingsManager.shared.mcp.enabled {
112+
Task {
113+
await MCPServerManager.shared.start(port: UInt16(clamping: AppSettingsManager.shared.mcp.port))
114+
}
115+
}
116+
111117
Task.detached(priority: .background) {
112118
_ = QueryHistoryStorage.shared
113119
}
@@ -205,6 +211,9 @@ class AppDelegate: NSObject, NSApplicationDelegate {
205211
}
206212

207213
func applicationWillTerminate(_ notification: Notification) {
214+
Task {
215+
await MCPServerManager.shared.stop()
216+
}
208217
LinkedFolderWatcher.shared.stop()
209218
SSHTunnelManager.shared.terminateAllProcessesSync()
210219
}

0 commit comments

Comments
 (0)