diff --git a/CHANGELOG.md b/CHANGELOG.md index 64f6307c3..2371f1000 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -9,6 +9,13 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Added +- External API for Raycast, Cursor, Claude Desktop, and other MCP clients: URL scheme, stdio MCP transport, pairing flow, activity log +- UUID-keyed deep links: `tablepro://connect/`, `.../table/`, `.../query?sql=...`, `tablepro://integrations/pair?...`, `tablepro://integrations/start-mcp` +- stdio MCP transport via bundled `tablepro-mcp` CLI at `Contents/MacOS/tablepro-mcp`. Reads handshake file, no token needed +- Per-connection `External Access` setting (`blocked`, `readOnly`, `readWrite`). Defaults to `readOnly`. Bounds token reach via `MIN(token.scope, connection.externalAccess)` +- Pairing flow with PKCE code exchange. One-click token issuance for Raycast and other extensions +- Activity log at `~/Library/Application Support/TablePro/mcp-audit.db`. Viewable in Settings > Integrations > Activity Log. 90-day retention +- New MCP tools: `list_recent_tabs`, `search_query_history`, `open_connection_window`, `open_table_tab`, `focus_query_tab` - PostgreSQL ICU collation provider in Create Database (PG 15+). Provider picker is added when the server reports PG 15 or newer. ICU locale list comes from `pg_collation`. SQL emission is version-aware: PG 16+ uses unified `LOCALE`, PG 15 uses `ICU_LOCALE` with `LC_COLLATE 'C' LC_CTYPE 'C'`. - Connection URL parsing: SSH `user:password@host` split, `safeModeLevel` from TablePlus URLs, case-insensitive query params - Connection URL export: SSH password, Redis database index, MongoDB auth params (`authSource`, `authMechanism`, `replicaSet`), and multi-host @@ -25,6 +32,11 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Changed +- MCP server lazy-starts on first external request. Manual enable in Settings is no longer required +- Settings tab renamed from "MCP" to "Integrations" with new sections for connected clients, activity log, and pairing +- Integrations settings: rename MCP Server section to Integrations, restructure with searchable activity log, native list with keyboard navigation, accessibility labels, color-blind-safe status icons. +- Activity log gained an Export… button that writes the current filtered list to CSV. +- Connection Advanced settings: AI Policy and External Clients now share a single External Access section. The External Clients picker uses a segmented control. - Storage and sync singletons accept dependencies via init for test isolation, matching Apple's URLSession and UserDefaults convention. Production callers using `.shared` are unchanged. `SQLFavoriteStorage` is now an actor so its first access no longer blocks the main thread on SQLite setup. - Create Database dialog is now driver-driven. Each driver discovers its own valid options (PostgreSQL queries `pg_collation` and `pg_database`, MySQL/MariaDB query `information_schema.character_sets`/`collations`). The hardcoded macOS-flavored locale list is gone. Engines that don't support creation hide the Create button instead of failing on click. - Introduced TableRows, Row, and Delta value types in TablePro/Models/Query/ as the foundation for the data grid row model rewrite. No callers migrated yet (Phase C.1 of the DataGrid refactor). @@ -64,9 +76,17 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - Data grid cell focus ring redraws when the user toggles Light or Dark mode mid-session, picking up the system's appearance-aware focus indicator color - Data grid keeps sortedIDs and cachedRowCount paired by calling updateCache() immediately after the SwiftUI bridge writes new sortedIDs to the coordinator, removing a window where the cached count and the sort permutation could disagree - Display formats memoized per tab on MainContentCoordinator keyed by schema version, smart-detection setting, and format-overrides version, so ValueDisplayDetector.detect runs once per result schema instead of on every SwiftUI body evaluation +- MCP HTTP router replaced with a route registry. `MCPRouter` now matches paths and methods against a list of `MCPRouteHandler` values; `/mcp` traffic and `/v1/integrations/exchange` traffic each live in their own handler file under `Core/MCP/Routes/`. OPTIONS preflight is handled once at the router level for every path +- `MCPAuthGuard` and `MCPConnectionBridge` route concurrent dedup through a shared `OnceTask` actor (`Core/Concurrency/OnceTask.swift`). Cleanup of in-flight slots happens in `defer` inside the actor, so a cancelled or thrown caller no longer leaves a stale entry behind. + +### Removed (BREAKING) + +- `tablepro://connect//...` deep links. Replace with UUID-keyed paths from "Copy Connection Deep Link" in the sidebar context menu. User-saved bookmarks must be regenerated +- MCP server data directory moved from `~/Library/Application Support/com.TablePro/` to `~/Library/Application Support/TablePro/`. Existing tokens, audit log, and handshake files are not migrated. Re-pair Raycast, Cursor, Claude Desktop, and any other external clients after upgrading. Delete the old directory with `rm -rf ~/Library/Application Support/com.TablePro` ### Fixed +- File associations for `.sql`, `.sqlite`, `.duckdb`, and related extensions disabled in Finder's Open With menu. The custom UTIs (`com.tablepro.sql`, `com.tablepro.sqlite-db`, `com.tablepro.duckdb`) were declared under `UTImportedTypeDeclarations` instead of `UTExportedTypeDeclarations`, so Launch Services treated them as "imported" claims and ranked them below other apps. SQL is now `LSHandlerRank: Owner`, SQLite is `Default`, DuckDB is `Owner`/`Editor`, and `com.tablepro.sqlite-db` conforms to `com.apple.sqlite3`. - Crash on macOS 26 when opening SQL Preview (NSColor.cgColor calls deprecated colorSpaceName) - Connection form: `usePrivateKey=true` from URL no longer disables Test/Create buttons - Transient connections from URL clean up keychain entries on connection failure @@ -77,6 +97,22 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - Persist group deletions before firing the sync notification, fixing a race that could re-upload deleted groups via iCloud. - Persist connection deletions before firing the sync notification, fixing the same race for deleted connections. - Refuse to generate SQL when the database dialect cannot be resolved, instead of silently emitting unquoted identifiers. +- MCP `execute_query`: strip trailing semicolons before appending `LIMIT/OFFSET`, fixing `syntax error at or near LIMIT` for queries like `select * from t;`. +- MCP `export_data`: tightened table name validation to reject double-dot, leading-dot, and trailing-dot identifiers (e.g. `schema..table`). `quoteQualifiedIdentifier` now rejects empty segments instead of producing `"schema".""."table"`. +- MCP `focus_query_tab`: re-validate that the resolved tab still belongs to the authorized connection between the auth check and the window raise, closing a TOCTOU window where a tab could be re-bound to a different connection. +- MCP pairing: cap pending exchange codes at 50 to prevent unbounded memory growth from repeated pairing attempts. +- Pairing approval no longer grants access on Return key; Approve must be clicked. Deny remains the cancel action and Escape still dismisses. +- Pairing approval shows a live countdown for the 5-minute exchange code window and disables Approve when it runs out. +- Pairing approval connection list is searchable with Select All / Deselect All controls and bounded height for many connections. +- Token deletion now requires confirmation in a destructive alert, with the token name in the message. Backspace on a selected token in the list shows the same alert. +- Disconnect on a connected client now requires confirmation before tearing down the session. +- Token list switched to a native macOS list with keyboard navigation, multi-select, and a context menu (Revoke, Copy ID, Delete…). "Deactivate" was renamed to "Revoke" so the UI matches the documented language. +- Activity log layout fixed: the inner list no longer nests inside the settings Form, so vertical scrolling has a single owner. Connection column shows the connection name instead of the UUID prefix and falls back to "Deleted connection (…)" when the connection is gone. +- Activity log gained search across action, token, connection, and details, plus a 90-day retention notice. +- Token reveal warning banner uses thin material with an orange border so it stays visible in Dark Mode. +- Token, audit, and pairing sheets use a flexible minimum height so they no longer clip with larger Dynamic Type sizes. +- Token list "Last used" now uses RelativeDateTimeFormatter so localized strings read correctly (e.g. "5 minutes ago" in en) instead of the broken duplicated " ago" suffix. +- Token reveal, audit refresh, and copy buttons now expose VoiceOver accessibility labels in addition to tooltips. ## [0.36.0] - 2026-04-27 diff --git a/TablePro.xcodeproj/project.pbxproj b/TablePro.xcodeproj/project.pbxproj index 8ac2acb00..9c65d7b14 100644 --- a/TablePro.xcodeproj/project.pbxproj +++ b/TablePro.xcodeproj/project.pbxproj @@ -8,7 +8,7 @@ /* Begin PBXBuildFile section */ 5A32BBFB2F9D5EAB00BAEB5F /* X509 in Frameworks */ = {isa = PBXBuildFile; productRef = 5A32BBFA2F9D5EAB00BAEB5F /* X509 */; }; - 5A32BC0B2F9D659100BAEB5F /* mcp-server in CopyFiles */ = {isa = PBXBuildFile; fileRef = 5A32BC002F9D5F1300BAEB5F /* mcp-server */; settings = {ATTRIBUTES = (CodeSignOnCopy, ); }; }; + 5A32BC0B2F9D659100BAEB5F /* tablepro-mcp in CopyFiles */ = {isa = PBXBuildFile; fileRef = 5A32BC002F9D5F1300BAEB5F /* tablepro-mcp */; settings = {ATTRIBUTES = (CodeSignOnCopy, ); }; }; 5A3A69B82F976F38000AC5B2 /* GhosttyTerminal in Frameworks */ = {isa = PBXBuildFile; productRef = 5A3A69B72F976F38000AC5B2 /* GhosttyTerminal */; }; 5A3A69BA2F976F38000AC5B2 /* GhosttyTheme in Frameworks */ = {isa = PBXBuildFile; productRef = 5A3A69B92F976F38000AC5B2 /* GhosttyTheme */; }; 5A3BE6FC2F97DB0000611C1F /* TableProPluginKit.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 5A860000100000000 /* TableProPluginKit.framework */; }; @@ -75,6 +75,13 @@ /* End PBXBuildFile section */ /* Begin PBXContainerItemProxy section */ + 5A32BC0C2F9D659200BAEB5F /* PBXContainerItemProxy */ = { + isa = PBXContainerItemProxy; + containerPortal = 5A1091BF2EF17EDC0055EA7C /* Project object */; + proxyType = 1; + remoteGlobalIDString = 5A32BBFF2F9D5F1300BAEB5F; + remoteInfo = "tablepro-mcp"; + }; 5A860000B00000000 /* PBXContainerItemProxy */ = { isa = PBXContainerItemProxy; containerPortal = 5A1091BF2EF17EDC0055EA7C /* Project object */; @@ -219,7 +226,7 @@ dstPath = ""; dstSubfolderSpec = 6; files = ( - 5A32BC0B2F9D659100BAEB5F /* mcp-server in CopyFiles */, + 5A32BC0B2F9D659100BAEB5F /* tablepro-mcp in CopyFiles */, ); runOnlyForDeploymentPostprocessing = 0; }; @@ -259,7 +266,7 @@ /* Begin PBXFileReference section */ 5A1091C72EF17EDC0055EA7C /* TablePro.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = TablePro.app; sourceTree = BUILT_PRODUCTS_DIR; }; - 5A32BC002F9D5F1300BAEB5F /* mcp-server */ = {isa = PBXFileReference; explicitFileType = "compiled.mach-o.executable"; includeInIndex = 0; path = "mcp-server"; sourceTree = BUILT_PRODUCTS_DIR; }; + 5A32BC002F9D5F1300BAEB5F /* tablepro-mcp */ = {isa = PBXFileReference; explicitFileType = "compiled.mach-o.executable"; includeInIndex = 0; path = "tablepro-mcp"; sourceTree = BUILT_PRODUCTS_DIR; }; 5A3BE6F82F97DA8100611C1F /* LibSQLDriverPlugin.tableplugin */ = {isa = PBXFileReference; explicitFileType = wrapper.cfbundle; includeInIndex = 0; path = LibSQLDriverPlugin.tableplugin; sourceTree = BUILT_PRODUCTS_DIR; }; 5A860000100000000 /* TableProPluginKit.framework */ = {isa = PBXFileReference; explicitFileType = wrapper.framework; includeInIndex = 0; path = TableProPluginKit.framework; sourceTree = BUILT_PRODUCTS_DIR; }; 5A861000100000000 /* OracleDriver.tableplugin */ = {isa = PBXFileReference; explicitFileType = wrapper.cfbundle; includeInIndex = 0; path = OracleDriver.tableplugin; sourceTree = BUILT_PRODUCTS_DIR; }; @@ -311,8 +318,8 @@ 5A32BC082F9D5FC900BAEB5F /* Exceptions for "TablePro" folder in "mcp-server" target */ = { isa = PBXFileSystemSynchronizedBuildFileExceptionSet; membershipExceptions = ( - MCPBridge/main.swift, - MCPBridge/MCPBridgeProxy.swift, + CLI/main.swift, + CLI/MCPBridgeProxy.swift, ); target = 5A32BBFF2F9D5F1300BAEB5F /* mcp-server */; }; @@ -452,9 +459,9 @@ 5AF312BE2F36FF7500E86682 /* Exceptions for "TablePro" folder in "TablePro" target */ = { isa = PBXFileSystemSynchronizedBuildFileExceptionSet; membershipExceptions = ( + CLI/main.swift, + CLI/MCPBridgeProxy.swift, Info.plist, - MCPBridge/main.swift, - MCPBridge/MCPBridgeProxy.swift, ); target = 5A1091C62EF17EDC0055EA7C /* TablePro */; }; @@ -913,7 +920,7 @@ 5ABQR00300000000000000A0 /* BigQueryDriverPlugin.tableplugin */, 5AE4F4742F6BC0640097AC5B /* CloudflareD1DriverPlugin.tableplugin */, 5A3BE6F82F97DA8100611C1F /* LibSQLDriverPlugin.tableplugin */, - 5A32BC002F9D5F1300BAEB5F /* mcp-server */, + 5A32BC002F9D5F1300BAEB5F /* tablepro-mcp */, ); name = Products; sourceTree = ""; @@ -994,6 +1001,7 @@ buildRules = ( ); dependencies = ( + 5A32BC0D2F9D659200BAEB5F /* PBXTargetDependency */, 5A860000C00000000 /* PBXTargetDependency */, 5A861000C00000000 /* PBXTargetDependency */, 5A862000C00000000 /* PBXTargetDependency */, @@ -1050,7 +1058,7 @@ packageProductDependencies = ( ); productName = "mcp-server"; - productReference = 5A32BC002F9D5F1300BAEB5F /* mcp-server */; + productReference = 5A32BC002F9D5F1300BAEB5F /* tablepro-mcp */; productType = "com.apple.product-type.tool"; }; 5A3BE6F72F97DA8100611C1F /* LibSQLDriverPlugin */ = { @@ -2024,6 +2032,11 @@ /* End PBXSourcesBuildPhase section */ /* Begin PBXTargetDependency section */ + 5A32BC0D2F9D659200BAEB5F /* PBXTargetDependency */ = { + isa = PBXTargetDependency; + target = 5A32BBFF2F9D5F1300BAEB5F /* mcp-server */; + targetProxy = 5A32BC0C2F9D659200BAEB5F /* PBXContainerItemProxy */; + }; 5A860000C00000000 /* PBXTargetDependency */ = { isa = PBXTargetDependency; target = 5A860000000000000 /* TableProPluginKit */; @@ -2402,7 +2415,8 @@ DEVELOPMENT_TEAM = D7HJ5TFYCU; ENABLE_HARDENED_RUNTIME = YES; MACOSX_DEPLOYMENT_TARGET = 14.0; - PRODUCT_NAME = "$(TARGET_NAME)"; + PRODUCT_BUNDLE_IDENTIFIER = "com.TablePro.tablepro-mcp"; + PRODUCT_NAME = "tablepro-mcp"; SDKROOT = macosx; SWIFT_APPROACHABLE_CONCURRENCY = YES; SWIFT_UPCOMING_FEATURE_MEMBER_IMPORT_VISIBILITY = YES; @@ -2417,7 +2431,8 @@ DEVELOPMENT_TEAM = D7HJ5TFYCU; ENABLE_HARDENED_RUNTIME = YES; MACOSX_DEPLOYMENT_TARGET = 14.0; - PRODUCT_NAME = "$(TARGET_NAME)"; + PRODUCT_BUNDLE_IDENTIFIER = "com.TablePro.tablepro-mcp"; + PRODUCT_NAME = "tablepro-mcp"; SDKROOT = macosx; SWIFT_APPROACHABLE_CONCURRENCY = YES; SWIFT_UPCOMING_FEATURE_MEMBER_IMPORT_VISIBILITY = YES; diff --git a/TablePro.xcodeproj/xcshareddata/xcschemes/TablePro.xcscheme b/TablePro.xcodeproj/xcshareddata/xcschemes/TablePro.xcscheme index d2a10cf3d..f99c67cbd 100644 --- a/TablePro.xcodeproj/xcshareddata/xcschemes/TablePro.xcscheme +++ b/TablePro.xcodeproj/xcshareddata/xcschemes/TablePro.xcscheme @@ -25,7 +25,6 @@ 300 - ? String(condition.prefix(300)) + "…" : condition - filterDescription = preview - } else { - filterDescription = [parsed.filterColumn, parsed.filterOperation, parsed.filterValue] - .compactMap { $0 }.joined(separator: " ") - } - if !filterDescription.isEmpty { - let confirmed = await AlertHelper.confirmDestructive( - title: String(localized: "Apply Filter from Link"), - message: String( - format: String(localized: "An external link wants to apply a filter:\n\n%@"), - filterDescription - ), - confirmButton: String(localized: "Apply Filter"), - cancelButton: String(localized: "Cancel"), - window: NSApp.keyWindow - ) - guard confirmed else { return } - } - - NotificationCenter.default.post( - name: .applyURLFilter, - object: nil, - userInfo: [ - "connectionId": connectionId, - "column": parsed.filterColumn as Any, - "operation": parsed.filterOperation as Any, - "value": parsed.filterValue as Any, - "condition": parsed.filterCondition as Any - ] - ) - } - } - } - } - - private func waitForConnection(timeout: Duration) async { - await withCheckedContinuation { (continuation: CheckedContinuation) in - var didResume = false - var observer: NSObjectProtocol? - - func resumeOnce() { - guard !didResume else { return } - didResume = true - if let obs = observer { - NotificationCenter.default.removeObserver(obs) - } - continuation.resume() - } - - let timeoutTask = Task { - try? await Task.sleep(for: timeout) - resumeOnce() - } - observer = NotificationCenter.default.addObserver( - forName: .databaseDidConnect, - object: nil, - queue: .main - ) { _ in - timeoutTask.cancel() - resumeOnce() - } - } - } - - private func waitForNotification(_ name: Notification.Name, timeout: Duration) async { - await withCheckedContinuation { (continuation: CheckedContinuation) in - var didResume = false - var observer: NSObjectProtocol? - - func resumeOnce() { - guard !didResume else { return } - didResume = true - if let obs = observer { - NotificationCenter.default.removeObserver(obs) - } - continuation.resume() - } - - let timeoutTask = Task { - try? await Task.sleep(for: timeout) - resumeOnce() - } - observer = NotificationCenter.default.addObserver( - forName: name, object: nil, queue: .main - ) { _ in - timeoutTask.cancel() - resumeOnce() - } - } - } - - // MARK: - Session Lookup - - /// Finds any session (connected or still connecting) matching the parsed URL params. - private func findSessionByParams(_ parsed: ParsedConnectionURL) -> UUID? { - for (id, session) in DatabaseManager.shared.activeSessions { - let conn = session.connection - if conn.type == parsed.type - && conn.host == parsed.host - && conn.database == parsed.database - && (parsed.port == nil || conn.port == parsed.port || conn.port == parsed.type.defaultPort) - && (parsed.username.isEmpty || conn.username == parsed.username) - && (parsed.redisDatabase == nil || conn.redisDatabase == parsed.redisDatabase) { - return id - } - } - return nil - } - - /// Normalized key for deduplicating connection attempts by URL params. - static func paramKey(for parsed: ParsedConnectionURL) -> String { - let rdb = parsed.redisDatabase.map { "/redis:\($0)" } ?? "" - return "\(parsed.type.rawValue):\(parsed.username)@\(parsed.host):\(parsed.port ?? 0)/\(parsed.database)\(rdb)" - } - - func bringConnectionWindowToFront(_ connectionId: UUID) { - let windows = WindowLifecycleMonitor.shared.windows(for: connectionId) - if let window = windows.first { - window.makeKeyAndOrderFront(nil) - } else { - NSApp.windows.first { isMainWindow($0) && $0.isVisible }?.makeKeyAndOrderFront(nil) - } - } - - // MARK: - Connection Failure - - func handleConnectionFailure(_ error: Error) async { - closeOrphanedMainWindows() - - // User cancelled password prompt — no error dialog needed - if error is CancellationError { return } - - await Task.yield() - AlertHelper.showErrorSheet( - title: String(localized: "Connection Failed"), - message: error.localizedDescription, - window: NSApp.keyWindow - ) - } - - /// Closes main windows that have no active database session, then opens the welcome window if none remain. - private func closeOrphanedMainWindows() { - for window in NSApp.windows where isMainWindow(window) { - let hasActiveSession = DatabaseManager.shared.activeSessions.values.contains { - window.subtitle == $0.connection.name - || window.subtitle == "\($0.connection.name) — Preview" - } - if !hasActiveSession { window.close() } - } - if !NSApp.windows.contains(where: { isMainWindow($0) && $0.isVisible }) { - openWelcomeWindow() - } - } - - // MARK: - Transient Connection Builder - - private func buildTransientConnection(from parsed: ParsedConnectionURL) -> DatabaseConnection { - var sshConfig = SSHConfiguration() - if let sshHost = parsed.sshHost { - sshConfig.enabled = true - sshConfig.host = sshHost - sshConfig.port = parsed.sshPort ?? 22 - sshConfig.username = parsed.sshUsername ?? "" - if parsed.usePrivateKey == true { - sshConfig.authMethod = .privateKey - } - if parsed.useSSHAgent == true { - sshConfig.authMethod = .sshAgent - sshConfig.agentSocketPath = parsed.agentSocket ?? "" - } - } - - var sslConfig = SSLConfiguration() - if let sslMode = parsed.sslMode { - sslConfig.mode = sslMode - } - - var color: ConnectionColor = .none - if let hex = parsed.statusColor { - color = ConnectionURLParser.connectionColor(fromHex: hex) - } - - var tagId: UUID? - if let envName = parsed.envTag { - tagId = ConnectionURLParser.tagId(fromEnvName: envName) - } - - let resolvedSafeMode = parsed.safeModeLevel.flatMap(SafeModeLevel.from(urlInteger:)) ?? .silent - - var connection = DatabaseConnection( - name: parsed.connectionName ?? parsed.suggestedName, - host: parsed.host, - port: parsed.port ?? parsed.type.defaultPort, - database: parsed.database, - username: parsed.username, - type: parsed.type, - sshConfig: sshConfig, - sslConfig: sslConfig, - color: color, - tagId: tagId, - safeModeLevel: resolvedSafeMode, - mongoAuthSource: parsed.authSource, - mongoUseSrv: parsed.useSrv, - mongoAuthMechanism: parsed.mongoQueryParams["authMechanism"], - mongoReplicaSet: parsed.mongoQueryParams["replicaSet"], - redisDatabase: parsed.redisDatabase, - oracleServiceName: parsed.oracleServiceName - ) - - for (key, value) in parsed.mongoQueryParams where !value.isEmpty { - if key != "authMechanism" && key != "replicaSet" { - connection.additionalFields["mongoParam_\(key)"] = value - } - } - - return connection - } -} diff --git a/TablePro/AppDelegate+FileOpen.swift b/TablePro/AppDelegate+FileOpen.swift deleted file mode 100644 index a80c733c8..000000000 --- a/TablePro/AppDelegate+FileOpen.swift +++ /dev/null @@ -1,305 +0,0 @@ -// -// AppDelegate+FileOpen.swift -// TablePro -// - -import AppKit -import os -import SwiftUI - -private let fileOpenLogger = Logger(subsystem: "com.TablePro", category: "FileOpen") - -extension AppDelegate { - // MARK: - Handoff - - func application(_ application: NSApplication, continue userActivity: NSUserActivity, - restorationHandler: @escaping ([any NSUserActivityRestoring]) -> Void) -> Bool { - handleHandoffActivity(userActivity) - return true - } - - private func handleHandoffActivity(_ activity: NSUserActivity) { - guard let connectionIdString = activity.userInfo?["connectionId"] as? String, - let connectionId = UUID(uuidString: connectionIdString) else { return } - - let connections = ConnectionStorage.shared.loadConnections() - guard let connection = connections.first(where: { $0.id == connectionId }) else { - fileOpenLogger.error("Handoff: no connection with ID '\(connectionIdString, privacy: .public)'") - return - } - - let tableName = activity.userInfo?["tableName"] as? String - - if DatabaseManager.shared.activeSessions[connectionId]?.driver != nil { - if let tableName { - let payload = EditorTabPayload(connectionId: connectionId, tabType: .table, tableName: tableName) - WindowManager.shared.openTab(payload: payload) - } else { - for window in NSApp.windows where isMainWindow(window) { - window.makeKeyAndOrderFront(nil) - return - } - } - return - } - - let initialPayload = EditorTabPayload(connectionId: connectionId) - WindowManager.shared.openTab(payload: initialPayload) - - Task { - do { - try await DatabaseManager.shared.connectToSession(connection) - for window in NSApp.windows where self.isWelcomeWindow(window) { - window.close() - } - if let tableName { - let payload = EditorTabPayload(connectionId: connectionId, tabType: .table, tableName: tableName) - WindowManager.shared.openTab(payload: payload) - } - } catch { - fileOpenLogger.error("Handoff connect failed: \(error.localizedDescription)") - } - } - } - - // MARK: - URL Classification - - private func isDatabaseURL(_ url: URL) -> Bool { - guard let scheme = url.scheme?.lowercased() else { return false } - let base = scheme - .replacingOccurrences(of: "+ssh", with: "") - .replacingOccurrences(of: "+srv", with: "") - let registeredSchemes = PluginManager.shared.allRegisteredURLSchemes - return registeredSchemes.contains(base) || registeredSchemes.contains(scheme) - } - - private func isDatabaseFile(_ url: URL) -> Bool { - PluginManager.shared.allRegisteredFileExtensions[url.pathExtension.lowercased()] != nil - } - - private func databaseTypeForFile(_ url: URL) -> DatabaseType? { - PluginManager.shared.allRegisteredFileExtensions[url.pathExtension.lowercased()] - } - - // MARK: - Main Dispatch - - func handleOpenURLs(_ urls: [URL]) { - let deeplinks = urls.filter { $0.scheme == "tablepro" } - if !deeplinks.isEmpty { - Task { - for url in deeplinks { await self.handleDeeplink(url) } - } - } - - let plugins = urls.filter { $0.pathExtension == "tableplugin" } - if !plugins.isEmpty { - Task { - for url in plugins { await self.handlePluginInstall(url) } - } - } - - let databaseURLs = urls.filter { isDatabaseURL($0) } - if !databaseURLs.isEmpty { - suppressWelcomeWindow() - Task { - for url in databaseURLs { self.handleDatabaseURL(url) } - // endFileOpenSuppression is called here to match suppressWelcomeWindow above. - // Individual handlers no longer manage this flag. - self.endFileOpenSuppression() - } - } - - let databaseFiles = urls.filter { isDatabaseFile($0) } - if !databaseFiles.isEmpty { - suppressWelcomeWindow() - Task { - for url in databaseFiles { - guard let dbType = self.databaseTypeForFile(url) else { continue } - switch dbType { - case .sqlite: - self.handleSQLiteFile(url) - case .duckdb: - self.handleDuckDBFile(url) - default: - self.handleGenericDatabaseFile(url, type: dbType) - } - } - self.endFileOpenSuppression() - } - } - - // Connection share files - let connectionShareFiles = urls.filter { $0.pathExtension.lowercased() == "tablepro" } - for url in connectionShareFiles { - handleConnectionShareFile(url) - } - - let sqlFiles = urls.filter { $0.pathExtension.lowercased() == "sql" } - if !sqlFiles.isEmpty { - if DatabaseManager.shared.currentSession != nil { - suppressWelcomeWindow() - for window in NSApp.windows where isMainWindow(window) { - window.makeKeyAndOrderFront(nil) - } - for window in NSApp.windows where isWelcomeWindow(window) { - window.close() - } - NotificationCenter.default.post(name: .openSQLFiles, object: sqlFiles) - endFileOpenSuppression() - } else { - queuedFileURLs.append(contentsOf: sqlFiles) - openWelcomeWindow() - } - } - } - - // MARK: - Welcome Window Suppression - - func suppressWelcomeWindow() { - isHandlingFileOpen = true - fileOpenSuppressionCount += 1 - for window in NSApp.windows where isWelcomeWindow(window) { - window.orderOut(nil) - } - } - - // MARK: - Deeplink Handling - - private func handleDeeplink(_ url: URL) async { - guard let action = DeeplinkHandler.parse(url) else { return } - - switch action { - case .connect(let name): - connectViaDeeplink(connectionName: name) - - case .openTable(let name, let table, let database): - connectViaDeeplink(connectionName: name) { connectionId in - EditorTabPayload(connectionId: connectionId, tabType: .table, - tableName: table, databaseName: database) - } - - case .openQuery(let name, let sql): - let maxDeeplinkSQLLength = 51_200 - let sqlLength = (sql as NSString).length - guard sqlLength <= maxDeeplinkSQLLength else { return } - let preview: String - if sqlLength > 300 { - let hiddenCount = sqlLength - 300 - preview = String(sql.prefix(300)) - + String(format: String(localized: "\n\n… (%d more characters not shown)"), hiddenCount) - } else { - preview = sql - } - let confirmed = await AlertHelper.confirmDestructive( - title: String(localized: "Open Query from Link"), - message: String(format: String(localized: "An external link wants to open a query on connection \"%@\":\n\n%@"), name, preview), - confirmButton: String(localized: "Open Query"), - cancelButton: String(localized: "Cancel"), - window: NSApp.keyWindow - ) - guard confirmed else { return } - connectViaDeeplink(connectionName: name) { connectionId in - EditorTabPayload(connectionId: connectionId, tabType: .query, - initialQuery: sql) - } - - case .importConnection(let exportable): - openWelcomeWindow() - PendingActionStore.shared.deeplinkImport = exportable - NotificationCenter.default.post(name: .deeplinkImportRequested, object: exportable) - } - } - - private func connectViaDeeplink( - connectionName: String, - makePayload: (@Sendable (UUID) -> EditorTabPayload)? = nil - ) { - guard let connection = DeeplinkHandler.resolveConnection(named: connectionName) else { - fileOpenLogger.error("Deep link: no connection named '\(connectionName, privacy: .public)'") - AlertHelper.showErrorSheet( - title: String(localized: "Connection Not Found"), - message: String(format: String(localized: "No saved connection named \"%@\"."), connectionName), - window: NSApp.keyWindow - ) - return - } - - if DatabaseManager.shared.activeSessions[connection.id]?.driver != nil { - if let payload = makePayload?(connection.id) { - WindowManager.shared.openTab(payload: payload) - } else { - for window in NSApp.windows where isMainWindow(window) { - window.makeKeyAndOrderFront(nil) - return - } - } - return - } - - let hadExistingMain = NSApp.windows.contains { isMainWindow($0) && $0.isVisible } - let savedTabbing = NSWindow.allowsAutomaticWindowTabbing - if hadExistingMain && !AppSettingsManager.shared.tabs.groupAllConnectionTabs { - NSWindow.allowsAutomaticWindowTabbing = false - } - - let deeplinkPayload = EditorTabPayload(connectionId: connection.id) - WindowManager.shared.openTab(payload: deeplinkPayload) - NSWindow.allowsAutomaticWindowTabbing = savedTabbing - - Task { - do { - // Confirm pre-connect script if present (deep links are external, so always confirm) - if let script = connection.preConnectScript, - !script.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty - { - let confirmed = await AlertHelper.confirmDestructive( - title: String(localized: "Pre-Connect Script"), - message: String(format: String(localized: "Connection \"%@\" has a script that will run before connecting:\n\n%@"), connection.name, script), - confirmButton: String(localized: "Run Script"), - cancelButton: String(localized: "Cancel"), - window: NSApp.keyWindow - ) - guard confirmed else { return } - } - - try await DatabaseManager.shared.connectToSession(connection) - for window in NSApp.windows where self.isWelcomeWindow(window) { - window.close() - } - if let payload = makePayload?(connection.id) { - WindowManager.shared.openTab(payload: payload) - } - } catch { - fileOpenLogger.error("Deep link connect failed: \(error.localizedDescription)") - await self.handleConnectionFailure(error) - } - } - } - - // MARK: - Connection Share Import - - private func handleConnectionShareFile(_ url: URL) { - openWelcomeWindow() - PendingActionStore.shared.connectionShareURL = url - NotificationCenter.default.post(name: .connectionShareFileOpened, object: url) - } - - // MARK: - Plugin Install - - private func handlePluginInstall(_ url: URL) async { - do { - let entry = try await PluginManager.shared.installPlugin(from: url) - fileOpenLogger.info("Installed plugin '\(entry.name)' from Finder") - - UserDefaults.standard.set(SettingsTab.plugins.rawValue, forKey: "selectedSettingsTab") - NotificationCenter.default.post(name: .openSettingsWindow, object: nil) - } catch { - fileOpenLogger.error("Plugin install failed: \(error.localizedDescription)") - AlertHelper.showErrorSheet( - title: String(localized: "Plugin Installation Failed"), - message: error.localizedDescription, - window: NSApp.keyWindow - ) - } - } -} diff --git a/TablePro/AppDelegate+WindowConfig.swift b/TablePro/AppDelegate+WindowConfig.swift deleted file mode 100644 index 3e9f8dde2..000000000 --- a/TablePro/AppDelegate+WindowConfig.swift +++ /dev/null @@ -1,406 +0,0 @@ -// -// AppDelegate+WindowConfig.swift -// TablePro -// - -import AppKit -import os -import SwiftUI - -private let windowLogger = Logger(subsystem: "com.TablePro", category: "WindowConfig") - -extension AppDelegate { - // MARK: - Dock Menu - - func applicationDockMenu(_ sender: NSApplication) -> NSMenu? { - let menu = NSMenu() - - let welcomeItem = NSMenuItem( - title: String(localized: "Show Welcome Window"), - action: #selector(showWelcomeFromDock), - keyEquivalent: "" - ) - welcomeItem.target = self - menu.addItem(welcomeItem) - - let connections = ConnectionStorage.shared.loadConnections() - if !connections.isEmpty { - let connectionsItem = NSMenuItem(title: String(localized: "Open Connection"), action: nil, keyEquivalent: "") - let submenu = NSMenu() - - for connection in connections { - let item = NSMenuItem( - title: connection.name, - action: #selector(connectFromDock(_:)), - keyEquivalent: "" - ) - item.target = self - item.representedObject = connection.id - let iconName = connection.type.iconName - let original = NSImage(systemSymbolName: iconName, accessibilityDescription: nil) - ?? NSImage(named: iconName) - if let original { - let resized = NSImage(size: NSSize(width: 16, height: 16), flipped: false) { rect in - original.draw(in: rect) - return true - } - item.image = resized - } - submenu.addItem(item) - } - - connectionsItem.submenu = submenu - menu.addItem(connectionsItem) - } - - return menu - } - - @objc func showWelcomeFromDock() { - openWelcomeWindow() - } - - @objc func newWindowForTab(_ sender: Any?) { - guard let keyWindow = NSApp.keyWindow, - let connectionId = MainActor.assumeIsolated({ - WindowLifecycleMonitor.shared.connectionId(forWindow: keyWindow) - }) - else { return } - - let payload = EditorTabPayload( - connectionId: connectionId, - intent: .newEmptyTab - ) - MainActor.assumeIsolated { - WindowManager.shared.openTab(payload: payload) - } - } - - @objc func connectFromDock(_ sender: NSMenuItem) { - guard let connectionId = sender.representedObject as? UUID else { return } - let connections = ConnectionStorage.shared.loadConnections() - guard let connection = connections.first(where: { $0.id == connectionId }) else { return } - - let payload = EditorTabPayload(connectionId: connection.id, intent: .restoreOrDefault) - WindowManager.shared.openTab(payload: payload) - - Task { - do { - try await DatabaseManager.shared.connectToSession(connection) - - for window in NSApp.windows where self.isWelcomeWindow(window) { - window.close() - } - } catch { - windowLogger.error("Dock connection failed for '\(connection.name)': \(error.localizedDescription)") - - for window in WindowLifecycleMonitor.shared.windows(for: connection.id) { - window.close() - } - if !NSApp.windows.contains(where: { self.isMainWindow($0) && $0.isVisible }) { - self.openWelcomeWindow() - } - } - } - } - - // MARK: - Reopen Handling - - func applicationShouldHandleReopen(_ sender: NSApplication, hasVisibleWindows flag: Bool) -> Bool { - if flag { - return true - } - - openWelcomeWindow() - return false - } - - // MARK: - Window Identification - - private enum WindowId { - static let main = "main" - static let welcome = "welcome" - static let connectionForm = "connection-form" - } - - func isMainWindow(_ window: NSWindow) -> Bool { - guard let rawValue = window.identifier?.rawValue else { return false } - return rawValue == WindowId.main || rawValue.hasPrefix("\(WindowId.main)-") - } - - func isWelcomeWindow(_ window: NSWindow) -> Bool { - guard let rawValue = window.identifier?.rawValue else { return false } - return rawValue == WindowId.welcome || rawValue.hasPrefix("\(WindowId.welcome)-") - } - - private func isConnectionFormWindow(_ window: NSWindow) -> Bool { - guard let rawValue = window.identifier?.rawValue else { return false } - return rawValue == WindowId.connectionForm || rawValue.hasPrefix("\(WindowId.connectionForm)-") - } - - @objc func handleFocusConnectionForm() { - if let window = NSApp.windows.first(where: { isConnectionFormWindow($0) }) { - window.makeKeyAndOrderFront(nil) - } - } - - // MARK: - Welcome Window - - /// Hide the Welcome window immediately when we know we're going to - /// auto-reconnect. Prevents a visible flash of the Welcome screen - /// before the main editor window appears. - func closeWelcomeWindowEagerly() { - for window in NSApp.windows where isWelcomeWindow(window) { - window.orderOut(nil) - } - } - - func openWelcomeWindow() { - for window in NSApp.windows where isWelcomeWindow(window) { - window.makeKeyAndOrderFront(nil) - return - } - - NotificationCenter.default.post(name: .openWelcomeWindow, object: nil) - } - - private func configureWelcomeWindowStyle(_ window: NSWindow) { - window.standardWindowButton(.miniaturizeButton)?.isHidden = true - window.standardWindowButton(.zoomButton)?.isHidden = true - window.styleMask.remove(.miniaturizable) - - window.collectionBehavior.remove(.fullScreenPrimary) - window.collectionBehavior.insert(.fullScreenNone) - - if window.styleMask.contains(.resizable) { - window.styleMask.remove(.resizable) - } - - let welcomeSize = NSSize(width: 700, height: 450) - if window.frame.size != welcomeSize { - window.setContentSize(welcomeSize) - window.center() - } - - window.isOpaque = false - window.backgroundColor = .clear - window.titlebarAppearsTransparent = true - - window.makeKeyAndOrderFront(nil) - - if let textField = window.contentView?.firstEditableTextField() { - window.makeFirstResponder(textField) - } - } - - private func configureConnectionFormWindowStyle(_ window: NSWindow) { - window.standardWindowButton(.miniaturizeButton)?.isEnabled = false - window.standardWindowButton(.zoomButton)?.isEnabled = false - window.styleMask.remove(.miniaturizable) - - window.collectionBehavior.remove(.fullScreenPrimary) - window.collectionBehavior.insert(.fullScreenNone) - } - - // MARK: - Welcome Window Suppression - - /// Called by connection handlers when the file-open connection attempt finishes - /// (success or failure). Decrements the suppression counter and resets the flag - /// when all outstanding file opens have completed. - func endFileOpenSuppression() { - fileOpenSuppressionCount = max(0, fileOpenSuppressionCount - 1) - if fileOpenSuppressionCount == 0 { - isHandlingFileOpen = false - } - } - - @discardableResult - private func closeWelcomeWindowIfMainExists() -> Bool { - let hasMainWindow = NSApp.windows.contains { isMainWindow($0) && $0.isVisible } - guard hasMainWindow else { return false } - for window in NSApp.windows where isWelcomeWindow(window) { - window.close() - } - return true - } - - // MARK: - Window Notifications - - @objc func windowDidBecomeKey(_ notification: Notification) { - guard let window = notification.object as? NSWindow else { return } - let windowId = ObjectIdentifier(window) - - if isWelcomeWindow(window) && isHandlingFileOpen { - // Only close welcome if a main window exists to take its place; - // otherwise just hide it so the user doesn't see a flash. - if let mainWin = NSApp.windows.first(where: { isMainWindow($0) }) { - window.close() - mainWin.makeKeyAndOrderFront(nil) - } else { - window.orderOut(nil) - } - return - } - - if isWelcomeWindow(window) && !configuredWindows.contains(windowId) { - configureWelcomeWindowStyle(window) - configuredWindows.insert(windowId) - } - - if isConnectionFormWindow(window) && !configuredWindows.contains(windowId) { - configureConnectionFormWindowStyle(window) - configuredWindows.insert(windowId) - } - - if isMainWindow(window) && isHandlingFileOpen { - closeWelcomeWindowIfMainExists() - } - - // Phase 5: removed legacy main-window tabbing block. `WindowManager.openTab` - // now performs the tab-group merge at creation time with the correct - // ordering, and pre-marks `configuredWindows` so this method is a no-op - // for main windows. The old block consumed `WindowOpener.pendingPayloads` - // and called `addTabbedWindow` mid-`windowDidBecomeKey`, which produced - // the 200–7000 ms grace-period delay we removed in Phase 2. - } - - @objc func windowWillClose(_ notification: Notification) { - let seq = MainContentCoordinator.nextSwitchSeq() - let t0 = Date() - guard let window = notification.object as? NSWindow else { return } - let isMain = isMainWindow(window) - - configuredWindows.remove(ObjectIdentifier(window)) - - if isMain { - let remainingMainWindows = NSApp.windows.filter { - $0 !== window && isMainWindow($0) && $0.isVisible - }.count - windowLogger.info("[close] AppDelegate.windowWillClose seq=\(seq) isMain=true remaining=\(remainingMainWindows)") - - if remainingMainWindows == 0 { - NotificationCenter.default.post(name: .mainWindowWillClose, object: nil) - openWelcomeWindow() - } - } - windowLogger.info("[close] AppDelegate.windowWillClose seq=\(seq) total ms=\(Int(Date().timeIntervalSince(t0) * 1_000))") - } - - @objc func windowDidChangeOcclusionState(_ notification: Notification) { - guard let window = notification.object as? NSWindow, - isHandlingFileOpen else { return } - - if isWelcomeWindow(window), - window.occlusionState.contains(.visible), - NSApp.windows.contains(where: { isMainWindow($0) && $0.isVisible }), - window.isVisible { - window.close() - } - } - - // MARK: - Auto-Reconnect - - func attemptAutoReconnectAll(connectionIds: [UUID]) { - let connections = ConnectionStorage.shared.loadConnections() - let validConnections = connectionIds.compactMap { id in - connections.first { $0.id == id } - } - - guard !validConnections.isEmpty else { - AppSettingsStorage.shared.saveLastOpenConnectionIds([]) - AppSettingsStorage.shared.saveLastConnectionId(nil) - closeRestoredMainWindows() - openWelcomeWindow() - return - } - - isAutoReconnecting = true - - Task { @MainActor [weak self] in - guard let self else { return } - defer { self.isAutoReconnecting = false } - - for connection in validConnections { - let payload = EditorTabPayload(connectionId: connection.id, intent: .restoreOrDefault) - WindowManager.shared.openTab(payload: payload) - - do { - try await DatabaseManager.shared.connectToSession(connection) - } catch is CancellationError { - for window in WindowLifecycleMonitor.shared.windows(for: connection.id) { - window.close() - } - continue - } catch { - windowLogger.error( - "Auto-reconnect failed for '\(connection.name)': \(error.localizedDescription)" - ) - for window in WindowLifecycleMonitor.shared.windows(for: connection.id) { - window.close() - } - continue - } - } - - for window in NSApp.windows where self.isWelcomeWindow(window) { - window.close() - } - - // If all connections failed, show the welcome window - if !NSApp.windows.contains(where: { self.isMainWindow($0) && $0.isVisible }) { - self.openWelcomeWindow() - } - } - } - - func attemptAutoReconnect(connectionId: UUID) { - let connections = ConnectionStorage.shared.loadConnections() - guard let connection = connections.first(where: { $0.id == connectionId }) else { - AppSettingsStorage.shared.saveLastConnectionId(nil) - closeRestoredMainWindows() - openWelcomeWindow() - return - } - - isAutoReconnecting = true - - Task { @MainActor [weak self] in - guard let self else { return } - let payload = EditorTabPayload(connectionId: connection.id, intent: .restoreOrDefault) - WindowManager.shared.openTab(payload: payload) - - defer { self.isAutoReconnecting = false } - do { - try await DatabaseManager.shared.connectToSession(connection) - - for window in NSApp.windows where self.isWelcomeWindow(window) { - window.close() - } - } catch is CancellationError { - for window in WindowLifecycleMonitor.shared.windows(for: connection.id) { - window.close() - } - if !NSApp.windows.contains(where: { self.isMainWindow($0) && $0.isVisible }) { - self.openWelcomeWindow() - } - } catch { - windowLogger.error("Auto-reconnect failed for '\(connection.name)': \(error.localizedDescription)") - - for window in WindowLifecycleMonitor.shared.windows(for: connection.id) { - window.close() - } - if !NSApp.windows.contains(where: { self.isMainWindow($0) && $0.isVisible }) { - self.openWelcomeWindow() - } - } - } - } - - func closeRestoredMainWindows() { - Task { @MainActor [weak self] in - for window in NSApp.windows where self?.isMainWindow(window) == true { - window.close() - } - } - } -} diff --git a/TablePro/AppDelegate.swift b/TablePro/AppDelegate.swift index 50129031c..bf3f1c360 100644 --- a/TablePro/AppDelegate.swift +++ b/TablePro/AppDelegate.swift @@ -7,44 +7,30 @@ import AppKit import os import SwiftUI -internal extension URL { - /// Returns the URL string with the password component replaced by `***` for safe logging. - var sanitizedForLogging: String { - guard var components = URLComponents(url: self, resolvingAgainstBaseURL: false), - components.password != nil else { - return absoluteString - } - components.password = "***" - return components.string ?? absoluteString - } -} - @MainActor class AppDelegate: NSObject, NSApplicationDelegate { private static let logger = Logger(subsystem: "com.TablePro", category: "AppDelegate") static let lifecycleLogger = Logger(subsystem: "com.TablePro", category: "NativeTabLifecycle") - var configuredWindows = Set() - var queuedFileURLs: [URL] = [] - var queuedURLEntries: [QueuedURLEntry] = [] - var isHandlingFileOpen = false - var fileOpenSuppressionCount = 0 - var isProcessingQueuedURLs = false - var isAutoReconnecting = false - var connectingURLConnectionIds = Set() - var connectingURLParamKeys = Set() - var connectingFilePaths = Set() - - // MARK: - NSApplicationDelegate + // MARK: - URL & File Open func application(_ application: NSApplication, open urls: [URL]) { - handleOpenURLs(urls) + AppLaunchCoordinator.shared.handleOpenURLs(urls) + } + + func application(_ application: NSApplication, continue userActivity: NSUserActivity, + restorationHandler: @escaping ([any NSUserActivityRestoring]) -> Void) -> Bool { + AppLaunchCoordinator.shared.handleHandoff(userActivity) + return true } + func applicationShouldHandleReopen(_ sender: NSApplication, hasVisibleWindows flag: Bool) -> Bool { + AppLaunchCoordinator.shared.handleReopen(hasVisibleWindows: flag) + } + + // MARK: - Lifecycle + func applicationDidFinishLaunching(_ notification: Notification) { - // Re-apply appearance now that NSApp exists. - // AppSettingsManager.shared may already be initialized (by @State in TableProApp), - // but NSApp was nil at that point so NSApp?.appearance was a no-op. let appearanceSettings = AppSettingsManager.shared.appearance ThemeEngine.shared.updateAppearanceAndTheme( mode: appearanceSettings.appearanceMode, @@ -90,54 +76,12 @@ class AppDelegate: NSObject, NSApplicationDelegate { _ = QueryHistoryStorage.shared } - let settings = AppSettingsStorage.shared.loadGeneral() - if settings.startupBehavior == .reopenLast { - let connectionIds = AppSettingsStorage.shared.loadLastOpenConnectionIds() - if !connectionIds.isEmpty { - closeWelcomeWindowEagerly() - attemptAutoReconnectAll(connectionIds: connectionIds) - } else if let lastConnectionId = AppSettingsStorage.shared.loadLastConnectionId() { - // Backward compat: fall back to single lastConnectionId for upgrades - closeWelcomeWindowEagerly() - attemptAutoReconnect(connectionId: lastConnectionId) - } else { - // Crash recovery: if the app crashed before applicationWillTerminate - // could save the list, scan the TabState directory for connections - // that still have saved tab state on disk. - Task { @MainActor [weak self] in - let diskIds = await TabDiskActor.shared.connectionIdsWithSavedState() - if !diskIds.isEmpty { - self?.closeWelcomeWindowEagerly() - self?.attemptAutoReconnectAll(connectionIds: diskIds) - } else { - self?.closeRestoredMainWindows() - } - } - } - } else { - closeRestoredMainWindows() - } - - // NOTE: These observers are not explicitly removed because AppDelegate - // lives for the entire app lifetime. NotificationCenter uses weak - // references for selector-based observers on macOS 10.11+. + AppLaunchCoordinator.shared.didFinishLaunching() - NotificationCenter.default.addObserver( - self, selector: #selector(windowDidBecomeKey(_:)), - name: NSWindow.didBecomeKeyNotification, object: nil - ) NotificationCenter.default.addObserver( self, selector: #selector(windowWillClose(_:)), name: NSWindow.willCloseNotification, object: nil ) - NotificationCenter.default.addObserver( - self, selector: #selector(windowDidChangeOcclusionState(_:)), - name: NSWindow.didChangeOcclusionStateNotification, object: nil - ) - NotificationCenter.default.addObserver( - self, selector: #selector(handleDatabaseDidConnect), - name: .databaseDidConnect, object: nil - ) NotificationCenter.default.addObserver( self, selector: #selector(handlePluginsRejected(_:)), name: .pluginsRejected, object: nil @@ -148,6 +92,45 @@ class AppDelegate: NSObject, NSApplicationDelegate { ) } + func applicationDidBecomeActive(_ notification: Notification) { + SyncCoordinator.shared.syncIfNeeded() + } + + func applicationShouldTerminate(_ sender: NSApplication) -> NSApplication.TerminateReply { + let hasUnsaved = MainContentCoordinator.hasAnyUnsavedChanges() + if hasUnsaved { + let alert = NSAlert() + alert.messageText = String(localized: "You have unsaved changes") + alert.informativeText = String(localized: "Some tabs have unsaved edits. Quitting will discard these changes.") + alert.alertStyle = .warning + alert.addButton(withTitle: String(localized: "Cancel")) + alert.addButton(withTitle: String(localized: "Quit Anyway")) + alert.buttons[1].hasDestructiveAction = true + let response = alert.runModal() + guard response == .alertSecondButtonReturn else { return .terminateCancel } + } + + Task { + await MCPServerManager.shared.stop() + NSApp.reply(toApplicationShouldTerminate: true) + } + return .terminateLater + } + + func applicationWillTerminate(_ notification: Notification) { + LinkedFolderWatcher.shared.stop() + TerminalProcessManager.registry.terminateAllSync() + SSHTunnelManager.shared.terminateAllProcessesSync() + } + + @objc func showHelp(_ sender: Any?) { + if let url = URL(string: "https://docs.tablepro.app") { + NSWorkspace.shared.open(url) + } + } + + // MARK: - Plugin Rejection Alert + @objc private func handlePluginsRejected(_ notification: Notification) { guard let rejected = notification.object as? [RejectedPlugin], !rejected.isEmpty else { return } @@ -184,40 +167,101 @@ class AppDelegate: NSObject, NSApplicationDelegate { } } - func applicationDidBecomeActive(_ notification: Notification) { - SyncCoordinator.shared.syncIfNeeded() + // MARK: - Window Notifications + + @objc func windowWillClose(_ notification: Notification) { + guard let window = notification.object as? NSWindow else { return } + + if AppLaunchCoordinator.isMainWindow(window) { + let remaining = NSApp.windows.filter { + $0 !== window && AppLaunchCoordinator.isMainWindow($0) && $0.isVisible + }.count + if remaining == 0 { + NotificationCenter.default.post(name: .mainWindowWillClose, object: nil) + WelcomeWindowFactory.openOrFront() + } + } } - func applicationShouldTerminate(_ sender: NSApplication) -> NSApplication.TerminateReply { - let hasUnsaved = MainContentCoordinator.hasAnyUnsavedChanges() - if hasUnsaved { - let alert = NSAlert() - alert.messageText = String(localized: "You have unsaved changes") - alert.informativeText = String(localized: "Some tabs have unsaved edits. Quitting will discard these changes.") - alert.alertStyle = .warning - alert.addButton(withTitle: String(localized: "Cancel")) - alert.addButton(withTitle: String(localized: "Quit Anyway")) - alert.buttons[1].hasDestructiveAction = true - let response = alert.runModal() - guard response == .alertSecondButtonReturn else { return .terminateCancel } + @objc func handleFocusConnectionForm() { + if let window = NSApp.windows.first(where: { AppLaunchCoordinator.isConnectionFormWindow($0) }) { + window.makeKeyAndOrderFront(nil) } + } - Task { - await MCPServerManager.shared.stop() - NSApp.reply(toApplicationShouldTerminate: true) + // MARK: - Dock Menu + + func applicationDockMenu(_ sender: NSApplication) -> NSMenu? { + let menu = NSMenu() + + let welcomeItem = NSMenuItem( + title: String(localized: "Show Welcome Window"), + action: #selector(showWelcomeFromDock), + keyEquivalent: "" + ) + welcomeItem.target = self + menu.addItem(welcomeItem) + + let connections = ConnectionStorage.shared.loadConnections() + if !connections.isEmpty { + let connectionsItem = NSMenuItem(title: String(localized: "Open Connection"), action: nil, keyEquivalent: "") + let submenu = NSMenu() + + for connection in connections { + let item = NSMenuItem( + title: connection.name, + action: #selector(connectFromDock(_:)), + keyEquivalent: "" + ) + item.target = self + item.representedObject = connection.id + let iconName = connection.type.iconName + let original = NSImage(systemSymbolName: iconName, accessibilityDescription: nil) + ?? NSImage(named: iconName) + if let original { + let resized = NSImage(size: NSSize(width: 16, height: 16), flipped: false) { rect in + original.draw(in: rect) + return true + } + item.image = resized + } + submenu.addItem(item) + } + + connectionsItem.submenu = submenu + menu.addItem(connectionsItem) } - return .terminateLater + + return menu } - func applicationWillTerminate(_ notification: Notification) { - LinkedFolderWatcher.shared.stop() - TerminalProcessManager.registry.terminateAllSync() - SSHTunnelManager.shared.terminateAllProcessesSync() + @objc func showWelcomeFromDock() { + WelcomeWindowFactory.openOrFront() } - @objc func showHelp(_ sender: Any?) { - if let url = URL(string: "https://docs.tablepro.app") { - NSWorkspace.shared.open(url) + @objc func newWindowForTab(_ sender: Any?) { + guard let keyWindow = NSApp.keyWindow, + let connectionId = MainActor.assumeIsolated({ + WindowLifecycleMonitor.shared.connectionId(forWindow: keyWindow) + }) + else { return } + + MainActor.assumeIsolated { + if let actions = MainContentCoordinator.allActiveCoordinators() + .first(where: { $0.connectionId == connectionId })?.commandActions { + actions.newTab() + } else { + WindowManager.shared.openTab( + payload: EditorTabPayload(connectionId: connectionId, intent: .newEmptyTab) + ) + } + } + } + + @objc func connectFromDock(_ sender: NSMenuItem) { + guard let connectionId = sender.representedObject as? UUID else { return } + Task { + await LaunchIntentRouter.shared.route(.openConnection(connectionId)) } } diff --git a/TablePro/MCPBridge/MCPBridgeProxy.swift b/TablePro/CLI/MCPBridgeProxy.swift similarity index 63% rename from TablePro/MCPBridge/MCPBridgeProxy.swift rename to TablePro/CLI/MCPBridgeProxy.swift index a74bde5ad..f52bae633 100644 --- a/TablePro/MCPBridge/MCPBridgeProxy.swift +++ b/TablePro/CLI/MCPBridgeProxy.swift @@ -49,19 +49,22 @@ private final class CertificatePinningDelegate: NSObject, URLSessionDelegate { } final class MCPBridgeProxy { + private static let pollInterval: TimeInterval = 0.2 + private static let pollTimeout: TimeInterval = 10.0 + private static let launchURL = "tablepro://integrations/start-mcp" + private let handshakePath: String private var sessionId: String? init() { let home = FileManager.default.homeDirectoryForCurrentUser.path - self.handshakePath = "\(home)/Library/Application Support/com.TablePro/mcp-handshake.json" + self.handshakePath = "\(home)/Library/Application Support/TablePro/mcp-handshake.json" } func run() async { let handshake: MCPHandshake - do { - handshake = try loadHandshake() + handshake = try await acquireHandshake() } catch { writeStderr("Error: \(error.localizedDescription)\n") writeJsonRpcError( @@ -72,33 +75,24 @@ final class MCPBridgeProxy { exit(1) } - guard isProcessRunning(pid: handshake.pid) else { - writeStderr("Error: TablePro process \(handshake.pid) is not running\n") - writeJsonRpcError( - id: .null, - code: -32_000, - message: "TablePro is not running. Launch the app and enable the MCP server." - ) - exit(1) - } - - let config = URLSessionConfiguration.ephemeral - config.timeoutIntervalForRequest = 60 - config.timeoutIntervalForResource = 60 + let urlSession = makeSession(handshake: handshake) + let scheme = (handshake.tls ?? false) ? "https" : "http" + let baseUrl = "\(scheme)://127.0.0.1:\(handshake.port)/mcp" + await readLoop(baseUrl: baseUrl, bearerToken: handshake.token, urlSession: urlSession) + } - let delegate: URLSessionDelegate? - if handshake.tls ?? false, let fingerprint = handshake.tlsCertFingerprint { - delegate = CertificatePinningDelegate(expectedFingerprint: fingerprint) - } else { - delegate = nil + private func acquireHandshake() async throws -> MCPHandshake { + if let handshake = try? loadHandshake(), isProcessRunning(pid: handshake.pid) { + return handshake } - let urlSession = URLSession(configuration: config, delegate: delegate, delegateQueue: nil) - let scheme = (handshake.tls ?? false) ? "https" : "http" - let baseUrl = "\(scheme)://127.0.0.1:\(handshake.port)/mcp" - let bearerToken = handshake.token + if (try? loadHandshake()) != nil { + writeStderr("Stale handshake detected; relaunching TablePro\n") + removeHandshake() + } - await readLoop(baseUrl: baseUrl, bearerToken: bearerToken, urlSession: urlSession) + try launchHostApp() + return try await pollForHandshake() } private func loadHandshake() throws -> MCPHandshake { @@ -106,10 +100,51 @@ final class MCPBridgeProxy { return try JSONDecoder().decode(MCPHandshake.self, from: data) } + private func removeHandshake() { + try? FileManager.default.removeItem(atPath: handshakePath) + } + private func isProcessRunning(pid: Int32) -> Bool { kill(pid, 0) == 0 } + private func launchHostApp() throws { + writeStderr("TablePro not running; launching via \(Self.launchURL)\n") + let process = Process() + process.executableURL = URL(fileURLWithPath: "/usr/bin/open") + process.arguments = ["-g", Self.launchURL] + try process.run() + process.waitUntilExit() + if process.terminationStatus != 0 { + throw BridgeError.launchFailed(status: process.terminationStatus) + } + } + + private func pollForHandshake() async throws -> MCPHandshake { + let deadline = Date().addingTimeInterval(Self.pollTimeout) + while Date() < deadline { + if let handshake = try? loadHandshake(), isProcessRunning(pid: handshake.pid) { + return handshake + } + try? await Task.sleep(nanoseconds: UInt64(Self.pollInterval * 1_000_000_000)) + } + throw BridgeError.handshakeTimeout + } + + private func makeSession(handshake: MCPHandshake) -> URLSession { + let config = URLSessionConfiguration.ephemeral + config.timeoutIntervalForRequest = 60 + config.timeoutIntervalForResource = 60 + + let delegate: URLSessionDelegate? + if handshake.tls ?? false, let fingerprint = handshake.tlsCertFingerprint { + delegate = CertificatePinningDelegate(expectedFingerprint: fingerprint) + } else { + delegate = nil + } + return URLSession(configuration: config, delegate: delegate, delegateQueue: nil) + } + private func readLoop(baseUrl: String, bearerToken: String, urlSession: URLSession) async { let stdin = FileHandle.standardInput var buffer = Data() @@ -132,14 +167,12 @@ final class MCPBridgeProxy { let requestId = extractRequestId(from: lineDataCopy) do { - let responseData = try await forwardRequest( + try await forwardAndEmit( lineDataCopy, baseUrl: baseUrl, bearerToken: bearerToken, urlSession: urlSession ) - writeStdout(responseData) - writeStdout(Data([0x0A])) } catch { writeStderr("Request failed: \(error.localizedDescription)\n") writeJsonRpcError( @@ -152,12 +185,12 @@ final class MCPBridgeProxy { } } - private func forwardRequest( + private func forwardAndEmit( _ body: Data, baseUrl: String, bearerToken: String, urlSession: URLSession - ) async throws -> Data { + ) async throws { guard let url = URL(string: baseUrl) else { throw BridgeError.invalidUrl } @@ -166,9 +199,8 @@ final class MCPBridgeProxy { request.httpMethod = "POST" request.httpBody = body request.setValue("application/json", forHTTPHeaderField: "Content-Type") - request.setValue("application/json", forHTTPHeaderField: "Accept") + request.setValue("application/json, text/event-stream", forHTTPHeaderField: "Accept") request.setValue("Bearer \(bearerToken)", forHTTPHeaderField: "Authorization") - if let sessionId { request.setValue(sessionId, forHTTPHeaderField: "Mcp-Session-Id") } @@ -177,22 +209,47 @@ final class MCPBridgeProxy { if let httpResponse = response as? HTTPURLResponse { captureSessionId(from: httpResponse) + let contentType = headerValue(httpResponse, forKey: "content-type")?.lowercased() ?? "" + if contentType.contains("text/event-stream") { + emitSSE(data) + return + } + } + + writeStdout(data) + writeStdout(Data([0x0A])) + } + + private func emitSSE(_ data: Data) { + guard let raw = String(data: data, encoding: .utf8) else { return } + for line in raw.split(omittingEmptySubsequences: false, whereSeparator: { $0 == "\n" }) { + guard line.hasPrefix("data:") else { continue } + let payload = line.dropFirst("data:".count) + let trimmed = payload.drop(while: { $0 == " " }) + guard !trimmed.isEmpty else { continue } + if let payloadData = String(trimmed).data(using: .utf8) { + writeStdout(payloadData) + writeStdout(Data([0x0A])) + } } + } - return data + private func headerValue(_ response: HTTPURLResponse, forKey key: String) -> String? { + for (rawKey, rawValue) in response.allHeaderFields { + guard let keyString = rawKey as? String, + keyString.lowercased() == key.lowercased(), + let valueString = rawValue as? String else { continue } + return valueString + } + return nil } private func captureSessionId(from response: HTTPURLResponse) { - let headerFields = response.allHeaderFields - for (key, value) in headerFields { - guard let keyString = key as? String else { continue } - guard keyString.lowercased() == "mcp-session-id" else { continue } - guard let valueString = value as? String else { continue } - - sessionId = valueString - writeStderr("Session established: \(valueString)\n") - return + guard let value = headerValue(response, forKey: "mcp-session-id") else { return } + if sessionId == nil { + writeStderr("Session established: \(value)\n") } + sessionId = value } private func extractRequestId(from data: Data) -> JsonRpcId { @@ -255,11 +312,17 @@ private enum JsonRpcId { private enum BridgeError: LocalizedError { case invalidUrl + case launchFailed(status: Int32) + case handshakeTimeout var errorDescription: String? { switch self { case .invalidUrl: "Invalid MCP server URL" + case .launchFailed(let status): + "Failed to launch TablePro (open exit \(status))" + case .handshakeTimeout: + "Timed out waiting for TablePro MCP server to start" } } } diff --git a/TablePro/MCPBridge/main.swift b/TablePro/CLI/main.swift similarity index 100% rename from TablePro/MCPBridge/main.swift rename to TablePro/CLI/main.swift diff --git a/TablePro/Core/Concurrency/OnceTask.swift b/TablePro/Core/Concurrency/OnceTask.swift new file mode 100644 index 000000000..69b0c2ed9 --- /dev/null +++ b/TablePro/Core/Concurrency/OnceTask.swift @@ -0,0 +1,52 @@ +// +// OnceTask.swift +// TablePro +// + +import Foundation + +actor OnceTask { + private struct Entry { + let task: Task + let generation: Int + } + + private var inFlight: [Key: Entry] = [:] + private var nextGeneration: Int = 0 + + init() {} + + func execute( + key: Key, + work: @Sendable @escaping () async throws -> Value + ) async throws -> Value { + if let existing = inFlight[key] { + return try await existing.task.value + } + + nextGeneration += 1 + let generation = nextGeneration + let task = Task { + try await work() + } + inFlight[key] = Entry(task: task, generation: generation) + defer { + if inFlight[key]?.generation == generation { + inFlight.removeValue(forKey: key) + } + } + return try await task.value + } + + func cancel(key: Key) { + inFlight[key]?.task.cancel() + inFlight.removeValue(forKey: key) + } + + func cancelAll() { + for entry in inFlight.values { + entry.task.cancel() + } + inFlight.removeAll() + } +} diff --git a/TablePro/Core/Database/DatabaseManager+ConnectionState.swift b/TablePro/Core/Database/DatabaseManager+ConnectionState.swift new file mode 100644 index 000000000..765a3490f --- /dev/null +++ b/TablePro/Core/Database/DatabaseManager+ConnectionState.swift @@ -0,0 +1,20 @@ +import Foundation + +enum ConnectionState { + case live(DatabaseDriver, ConnectionSession) + case stored(DatabaseConnection) + case unknown +} + +extension DatabaseManager { + @MainActor + func connectionState(_ id: UUID) -> ConnectionState { + if let session = activeSessions[id], let driver = session.driver { + return .live(driver, session) + } + if let connection = ConnectionStorage.shared.loadConnections().first(where: { $0.id == id }) { + return .stored(connection) + } + return .unknown + } +} diff --git a/TablePro/Core/Database/DatabaseManager+EnsureConnected.swift b/TablePro/Core/Database/DatabaseManager+EnsureConnected.swift new file mode 100644 index 000000000..4abe669e2 --- /dev/null +++ b/TablePro/Core/Database/DatabaseManager+EnsureConnected.swift @@ -0,0 +1,15 @@ +// +// DatabaseManager+EnsureConnected.swift +// TablePro +// + +import Foundation + +extension DatabaseManager { + func ensureConnected(_ connection: DatabaseConnection) async throws { + if activeSessions[connection.id]?.driver != nil { return } + try await ensureConnectedDedup.execute(key: connection.id) { + try await self.connectToSession(connection) + } + } +} diff --git a/TablePro/Core/Database/DatabaseManager+Health.swift b/TablePro/Core/Database/DatabaseManager+Health.swift index 1cb7b128c..a6c6e7c8e 100644 --- a/TablePro/Core/Database/DatabaseManager+Health.swift +++ b/TablePro/Core/Database/DatabaseManager+Health.swift @@ -51,6 +51,7 @@ extension DatabaseManager { reconnectHandler: { [weak self] in guard let self else { return false } guard let session = await self.activeSessions[connectionId] else { return false } + await SchemaService.shared.invalidate(connectionId: connectionId) do { let result = try await self.trackOperation(sessionId: connectionId) { try await self.reconnectDriver(for: session) @@ -207,6 +208,8 @@ extension DatabaseManager { session.status = .connecting } + await SchemaService.shared.invalidate(connectionId: sessionId) + // Stop existing health monitor await stopHealthMonitor(for: sessionId) diff --git a/TablePro/Core/Database/DatabaseManager+Sessions.swift b/TablePro/Core/Database/DatabaseManager+Sessions.swift index 59f99a4ec..ecb77f56e 100644 --- a/TablePro/Core/Database/DatabaseManager+Sessions.swift +++ b/TablePro/Core/Database/DatabaseManager+Sessions.swift @@ -13,17 +13,12 @@ import TableProPluginKit // MARK: - Session Management extension DatabaseManager { - /// Connect to a database and create/switch to its session - /// If connection already has a session, switches to it instead func connectToSession(_ connection: DatabaseConnection) async throws { - // Check if session already exists and is connected if let existing = activeSessions[connection.id], existing.driver != nil { - // Session is fully connected, just switch to it switchToSession(connection.id) return } - // Resolve environment variable references in connection fields (Pro feature) let resolvedConnection: DatabaseConnection if LicenseManager.shared.isFeatureAvailable(.envVarReferences) { resolvedConnection = EnvVarResolver.resolveConnection(connection) @@ -31,7 +26,6 @@ extension DatabaseManager { resolvedConnection = connection } - // Create new session (or reuse a prepared one) if activeSessions[connection.id] == nil { var session = ConnectionSession(connection: connection) session.status = .connecting @@ -39,18 +33,15 @@ extension DatabaseManager { } currentSessionId = connection.id - // Create SSH tunnel if needed and build effective connection let effectiveConnection: DatabaseConnection do { effectiveConnection = try await buildEffectiveConnection(for: resolvedConnection) } catch { - // Remove failed session removeSessionEntry(for: connection.id) currentSessionId = nil throw error } - // Run pre-connect hook if configured (only on explicit connect, not auto-reconnect) if let script = resolvedConnection.preConnectScript, !script.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty { @@ -63,7 +54,6 @@ extension DatabaseManager { } } - // Resolve password override for prompt-for-password connections var passwordOverride: String? if connection.promptForPassword { if let cached = activeSessions[connection.id]?.cachedPassword { @@ -83,7 +73,6 @@ extension DatabaseManager { } } - // Create appropriate driver with effective connection let driver: DatabaseDriver do { driver = try await DatabaseDriverFactory.createDriver( @@ -92,7 +81,6 @@ extension DatabaseManager { awaitPlugins: true ) } catch { - // Close tunnel if SSH was established if connection.resolvedSSHConfig.enabled { Task { do { @@ -110,35 +98,32 @@ extension DatabaseManager { do { try await driver.connect() - // Apply query timeout from settings (best-effort — some PostgreSQL-compatible - // databases like Aurora DSQL don't support SET statement_timeout) let timeoutSeconds = AppSettingsManager.shared.general.queryTimeoutSeconds if timeoutSeconds > 0 { do { try await driver.applyQueryTimeout(timeoutSeconds) } catch { + // Best-effort: some PostgreSQL-compatible databases like Aurora DSQL + // don't support SET statement_timeout. Self.logger.warning( "Query timeout not supported for \(connection.name): \(error.localizedDescription)" ) } } - // Run startup commands before schema init await executeStartupCommands( resolvedConnection.startupCommands, on: driver, connectionName: connection.name ) - // Initialize schema for drivers that support schema switching if let schemaDriver = driver as? SchemaSwitchable { activeSessions[connection.id]?.currentSchema = schemaDriver.currentSchema } - // Run post-connect actions declared by the plugin await executePostConnectActions( for: connection, resolvedConnection: resolvedConnection, driver: driver ) - // Batch all session mutations into a single write to fire objectWillChange once + // Batch all session mutations into a single write to fire objectWillChange once. if var session = activeSessions[connection.id] { session.driver = driver session.status = driver.status @@ -149,13 +134,10 @@ extension DatabaseManager { setSession(session, for: connection.id) } - // Save as last connection for "Reopen Last Session" feature appSettingsStorage.saveLastConnectionId(connection.id) - // Post notification for reliable delivery NotificationCenter.default.post(name: .databaseDidConnect, object: nil) - // Start health monitoring if the plugin supports it let supportsHealth = PluginMetadataRegistry.shared.snapshot( forTypeId: connection.type.pluginTypeId )?.supportsHealthMonitor ?? true @@ -164,7 +146,6 @@ extension DatabaseManager { await startHealthMonitor(for: connection.id) } } catch { - // Close tunnel if connection failed if connection.resolvedSSHConfig.enabled { Task { do { @@ -175,12 +156,10 @@ extension DatabaseManager { } } - // Remove failed session completely so UI returns to Welcome window + // Remove failed session completely so UI returns to Welcome window. removeSessionEntry(for: connection.id) - // Clear current session if this was it if currentSessionId == connection.id { - // Switch to another session if available, otherwise clear if let nextSessionId = activeSessions.keys.first { currentSessionId = nextSessionId } else { @@ -268,6 +247,7 @@ extension DatabaseManager { session.currentSchema = nil } appSettingsStorage.saveLastSchema(nil, for: connectionId) + await SchemaService.shared.invalidate(connectionId: connectionId) await reconnectSession(connectionId) } else if pm?.capabilities.supportsSchemaSwitching == true, let schemaDriver = driver as? SchemaSwitchable { @@ -304,7 +284,6 @@ extension DatabaseManager { appSettingsStorage.saveLastSchema(schema, for: connectionId) } - /// Switch to an existing session func switchToSession(_ sessionId: UUID) { guard activeSessions[sessionId] != nil else { return } currentSessionId = sessionId @@ -313,7 +292,6 @@ extension DatabaseManager { } } - /// Disconnect a specific session func disconnectSession(_ sessionId: UUID) async { let lifecycleLogger = Logger(subsystem: "com.TablePro", category: "NativeTabLifecycle") guard let session = activeSessions[sessionId] else { @@ -327,7 +305,6 @@ extension DatabaseManager { "[close] disconnectSession start connId=\(sessionId, privacy: .public) name=\(session.connection.name, privacy: .public) hasSSH=\(session.connection.resolvedSSHConfig.enabled)" ) - // Close SSH tunnel if exists if session.connection.resolvedSSHConfig.enabled { let sshStart = Date() do { @@ -340,7 +317,6 @@ extension DatabaseManager { ) } - // Stop health monitoring let hmStart = Date() await stopHealthMonitor(for: sessionId) lifecycleLogger.info( @@ -354,18 +330,16 @@ extension DatabaseManager { ) removeSessionEntry(for: sessionId) - // Clean up shared schema cache for this connection + await SchemaService.shared.invalidate(connectionId: sessionId) + SchemaProviderRegistry.shared.clear(for: sessionId) - // Clean up shared sidebar state for this connection SharedSidebarState.removeConnection(sessionId) - // If this was the current session, switch to another or clear if currentSessionId == sessionId { if let nextSessionId = activeSessions.keys.first { switchToSession(nextSessionId) } else { - // No more sessions - clear current session and last connection ID currentSessionId = nil appSettingsStorage.saveLastConnectionId(nil) } @@ -375,7 +349,6 @@ extension DatabaseManager { ) } - /// Disconnect all sessions func disconnectAll() async { let monitorIds = Array(healthMonitors.keys) for sessionId in monitorIds { @@ -388,8 +361,7 @@ extension DatabaseManager { } } - /// Update session state (for preserving UI state). - /// Skips the write-back when no observable fields changed, avoiding spurious connectionStatusVersion bumps. + // Skips the write-back when no observable fields changed, avoiding spurious connectionStatusVersion bumps. func updateSession(_ sessionId: UUID, update: (inout ConnectionSession) -> Void) { guard var session = activeSessions[sessionId] else { return } let before = session @@ -400,14 +372,12 @@ extension DatabaseManager { setSession(session, for: sessionId) } - /// Write a session and bump its per-connection version counter. internal func setSession(_ session: ConnectionSession, for connectionId: UUID) { activeSessions[connectionId] = session connectionStatusVersions[connectionId, default: 0] &+= 1 NotificationCenter.default.post(name: .connectionStatusDidChange, object: connectionId) } - /// Remove a session and clean up its per-connection version counter. internal func removeSessionEntry(for connectionId: UUID) { activeSessions.removeValue(forKey: connectionId) connectionStatusVersions.removeValue(forKey: connectionId) @@ -415,12 +385,10 @@ extension DatabaseManager { } #if DEBUG - /// Test-only: inject a session for unit testing without real database connections internal func injectSession(_ session: ConnectionSession, for connectionId: UUID) { setSession(session, for: connectionId) } - /// Test-only: remove an injected session internal func removeSession(for connectionId: UUID) { removeSessionEntry(for: connectionId) } diff --git a/TablePro/Core/Database/DatabaseManager.swift b/TablePro/Core/Database/DatabaseManager.swift index 83bded437..d70f35448 100644 --- a/TablePro/Core/Database/DatabaseManager.swift +++ b/TablePro/Core/Database/DatabaseManager.swift @@ -60,6 +60,8 @@ final class DatabaseManager { /// and the wake-from-sleep handler fire for the same connection. @ObservationIgnored internal var recoveringConnectionIds = Set() + @ObservationIgnored internal let ensureConnectedDedup = OnceTask() + /// Current session (computed from currentSessionId) var currentSession: ConnectionSession? { guard let sessionId = currentSessionId else { return nil } diff --git a/TablePro/Core/MCP/MCPAuditLogStorage.swift b/TablePro/Core/MCP/MCPAuditLogStorage.swift new file mode 100644 index 000000000..c42e4e7c0 --- /dev/null +++ b/TablePro/Core/MCP/MCPAuditLogStorage.swift @@ -0,0 +1,286 @@ +// +// MCPAuditLogStorage.swift +// TablePro +// + +import Foundation +import os +import SQLite3 + +actor MCPAuditLogStorage { + static let shared = MCPAuditLogStorage() + private static let logger = Logger(subsystem: "com.TablePro", category: "MCPAuditLogStorage") + + private static let retentionDays: Int = 90 + + private static let SQLITE_TRANSIENT = unsafeBitCast(-1, to: sqlite3_destructor_type.self) + + private static var isRunningTests: Bool { + NSClassFromString("XCTestCase") != nil + } + + private var db: OpaquePointer? + private var dbPath: String? + private let testDatabaseSuffix: String? + + enum TimeRange: Equatable { + case lastHours(Int) + case lastDays(Int) + case all + } + + init() { + self.testDatabaseSuffix = nil + setupDatabase() + prune(olderThan: Self.retentionDays) + } + + #if DEBUG + init(isolatedForTesting: Bool) { + self.testDatabaseSuffix = isolatedForTesting ? "_\(UUID().uuidString)" : nil + setupDatabase() + prune(olderThan: Self.retentionDays) + } + #endif + + deinit { + if let db { + sqlite3_close(db) + } + if Self.isRunningTests, let dbPath { + try? FileManager.default.removeItem(atPath: dbPath) + for suffix in ["-wal", "-shm"] { + try? FileManager.default.removeItem(atPath: dbPath + suffix) + } + } + } + + private func setupDatabase() { + let fileManager = FileManager.default + guard + let appSupport = fileManager.urls( + for: .applicationSupportDirectory, in: .userDomainMask + ).first + else { + Self.logger.error("Unable to access application support directory") + return + } + let directory = appSupport.appendingPathComponent("TablePro") + try? fileManager.createDirectory(at: directory, withIntermediateDirectories: true) + + let suffix = testDatabaseSuffix ?? "" + let fileName = Self.isRunningTests + ? "mcp-audit-test_\(ProcessInfo.processInfo.processIdentifier)\(suffix).db" + : "mcp-audit.db" + let path = directory.appendingPathComponent(fileName).path(percentEncoded: false) + self.dbPath = path + + if sqlite3_open(path, &db) != SQLITE_OK { + Self.logger.error("Error opening MCP audit database") + return + } + + execute("PRAGMA journal_mode=WAL;") + execute("PRAGMA synchronous=NORMAL;") + + createTables() + } + + private func createTables() { + execute(""" + CREATE TABLE IF NOT EXISTS audit_entries ( + id TEXT PRIMARY KEY, + timestamp REAL NOT NULL, + category TEXT NOT NULL, + token_id TEXT, + token_name TEXT, + connection_id TEXT, + action TEXT NOT NULL, + outcome TEXT NOT NULL, + details TEXT + ); + """) + execute("CREATE INDEX IF NOT EXISTS idx_audit_timestamp ON audit_entries(timestamp DESC);") + execute("CREATE INDEX IF NOT EXISTS idx_audit_token ON audit_entries(token_id, timestamp DESC);") + } + + private func execute(_ sql: String) { + var statement: OpaquePointer? + if sqlite3_prepare_v2(db, sql, -1, &statement, nil) == SQLITE_OK { + sqlite3_step(statement) + } + sqlite3_finalize(statement) + } + + @discardableResult + func addEntry(_ entry: AuditEntry) -> Bool { + let sql = """ + INSERT OR REPLACE INTO audit_entries + (id, timestamp, category, token_id, token_name, connection_id, action, outcome, details) + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?); + """ + + var statement: OpaquePointer? + guard sqlite3_prepare_v2(db, sql, -1, &statement, nil) == SQLITE_OK else { + Self.logger.warning("Failed to prepare audit insert statement") + return false + } + defer { sqlite3_finalize(statement) } + + sqlite3_bind_text(statement, 1, entry.id.uuidString, -1, Self.SQLITE_TRANSIENT) + sqlite3_bind_double(statement, 2, entry.timestamp.timeIntervalSince1970) + sqlite3_bind_text(statement, 3, entry.category.rawValue, -1, Self.SQLITE_TRANSIENT) + + if let tokenId = entry.tokenId?.uuidString { + sqlite3_bind_text(statement, 4, tokenId, -1, Self.SQLITE_TRANSIENT) + } else { + sqlite3_bind_null(statement, 4) + } + + if let tokenName = entry.tokenName { + sqlite3_bind_text(statement, 5, tokenName, -1, Self.SQLITE_TRANSIENT) + } else { + sqlite3_bind_null(statement, 5) + } + + if let connectionId = entry.connectionId?.uuidString { + sqlite3_bind_text(statement, 6, connectionId, -1, Self.SQLITE_TRANSIENT) + } else { + sqlite3_bind_null(statement, 6) + } + + sqlite3_bind_text(statement, 7, entry.action, -1, Self.SQLITE_TRANSIENT) + sqlite3_bind_text(statement, 8, entry.outcome, -1, Self.SQLITE_TRANSIENT) + + if let details = entry.details { + sqlite3_bind_text(statement, 9, details, -1, Self.SQLITE_TRANSIENT) + } else { + sqlite3_bind_null(statement, 9) + } + + return sqlite3_step(statement) == SQLITE_DONE + } + + func query( + category: AuditCategory? = nil, + tokenId: UUID? = nil, + since: Date? = nil, + limit: Int = 500 + ) -> [AuditEntry] { + var conditions: [String] = [] + if category != nil { conditions.append("category = ?") } + if tokenId != nil { conditions.append("token_id = ?") } + if since != nil { conditions.append("timestamp >= ?") } + + var sql = """ + SELECT id, timestamp, category, token_id, token_name, connection_id, action, outcome, details + FROM audit_entries + """ + if !conditions.isEmpty { + sql += " WHERE " + conditions.joined(separator: " AND ") + } + sql += " ORDER BY timestamp DESC LIMIT ?;" + + var statement: OpaquePointer? + guard sqlite3_prepare_v2(db, sql, -1, &statement, nil) == SQLITE_OK else { + Self.logger.warning("Failed to prepare audit query statement") + return [] + } + defer { sqlite3_finalize(statement) } + + var bindIndex: Int32 = 1 + if let category { + sqlite3_bind_text(statement, bindIndex, category.rawValue, -1, Self.SQLITE_TRANSIENT) + bindIndex += 1 + } + if let tokenId { + sqlite3_bind_text(statement, bindIndex, tokenId.uuidString, -1, Self.SQLITE_TRANSIENT) + bindIndex += 1 + } + if let since { + sqlite3_bind_double(statement, bindIndex, since.timeIntervalSince1970) + bindIndex += 1 + } + sqlite3_bind_int(statement, bindIndex, Int32(limit)) + + var entries: [AuditEntry] = [] + while sqlite3_step(statement) == SQLITE_ROW { + if let entry = parseEntry(statement) { + entries.append(entry) + } + } + return entries + } + + func count() -> Int { + var statement: OpaquePointer? + guard sqlite3_prepare_v2(db, "SELECT COUNT(*) FROM audit_entries;", -1, &statement, nil) == SQLITE_OK else { + return 0 + } + defer { sqlite3_finalize(statement) } + if sqlite3_step(statement) == SQLITE_ROW { + return Int(sqlite3_column_int(statement, 0)) + } + return 0 + } + + @discardableResult + func prune(olderThan days: Int) -> Int { + guard days > 0 else { return 0 } + let cutoff = Date().addingTimeInterval(-Double(days) * 86_400) + let sql = "DELETE FROM audit_entries WHERE timestamp < ?;" + + var statement: OpaquePointer? + guard sqlite3_prepare_v2(db, sql, -1, &statement, nil) == SQLITE_OK else { + return 0 + } + defer { sqlite3_finalize(statement) } + + sqlite3_bind_double(statement, 1, cutoff.timeIntervalSince1970) + guard sqlite3_step(statement) == SQLITE_DONE else { return 0 } + return Int(sqlite3_changes(db)) + } + + @discardableResult + func deleteAll() -> Bool { + var statement: OpaquePointer? + guard sqlite3_prepare_v2(db, "DELETE FROM audit_entries;", -1, &statement, nil) == SQLITE_OK else { + return false + } + defer { sqlite3_finalize(statement) } + return sqlite3_step(statement) == SQLITE_DONE + } + + private func parseEntry(_ statement: OpaquePointer?) -> AuditEntry? { + guard let statement, + let idCString = sqlite3_column_text(statement, 0), + let id = UUID(uuidString: String(cString: idCString)), + let categoryCString = sqlite3_column_text(statement, 2), + let category = AuditCategory(rawValue: String(cString: categoryCString)), + let actionCString = sqlite3_column_text(statement, 6), + let outcomeCString = sqlite3_column_text(statement, 7) + else { + return nil + } + + let timestamp = Date(timeIntervalSince1970: sqlite3_column_double(statement, 1)) + let tokenId = sqlite3_column_text(statement, 3).flatMap { UUID(uuidString: String(cString: $0)) } + let tokenName = sqlite3_column_text(statement, 4).map { String(cString: $0) } + let connectionId = sqlite3_column_text(statement, 5).flatMap { UUID(uuidString: String(cString: $0)) } + let action = String(cString: actionCString) + let outcome = String(cString: outcomeCString) + let details = sqlite3_column_text(statement, 8).map { String(cString: $0) } + + return AuditEntry( + id: id, + timestamp: timestamp, + category: category, + tokenId: tokenId, + tokenName: tokenName, + connectionId: connectionId, + action: action, + outcome: outcome, + details: details + ) + } +} diff --git a/TablePro/Core/MCP/MCPAuditLogger.swift b/TablePro/Core/MCP/MCPAuditLogger.swift index 250c2999e..f5f53a95f 100644 --- a/TablePro/Core/MCP/MCPAuditLogger.swift +++ b/TablePro/Core/MCP/MCPAuditLogger.swift @@ -5,36 +5,217 @@ enum MCPAuditLogger { private static let serverAuth = Logger(subsystem: "com.TablePro", category: "MCPAuth") private static let serverAccess = Logger(subsystem: "com.TablePro", category: "MCPAccess") private static let serverAdmin = Logger(subsystem: "com.TablePro", category: "MCPAdmin") + private static let serverQuery = Logger(subsystem: "com.TablePro", category: "MCPQuery") + private static let serverTool = Logger(subsystem: "com.TablePro", category: "MCPTool") + private static let serverResource = Logger(subsystem: "com.TablePro", category: "MCPResource") + + private static let sqlExcerptLimit = 256 static func logAuthSuccess(tokenName: String, ip: String) { serverAuth.info("Auth success: token=\(tokenName, privacy: .public) ip=\(ip, privacy: .public)") + record( + category: .auth, + tokenName: tokenName, + action: "auth.success", + outcome: .success, + details: "ip=\(ip)" + ) } static func logAuthFailure(reason: String, ip: String) { serverAuth.warning("Auth failure: reason=\(reason, privacy: .public) ip=\(ip, privacy: .public)") + record( + category: .auth, + action: "auth.failure", + outcome: .denied, + details: "ip=\(ip) reason=\(reason)" + ) } static func logRateLimited(ip: String, retryAfterSeconds: Int) { serverAuth.warning( "Rate limited: ip=\(ip, privacy: .public) retryAfter=\(retryAfterSeconds, privacy: .public)s" ) + record( + category: .auth, + action: "auth.rateLimited", + outcome: .rateLimited, + details: "ip=\(ip) retryAfter=\(retryAfterSeconds)s" + ) } static func logTokenCreated(tokenName: String) { serverAdmin.info("Token created: \(tokenName, privacy: .public)") + record( + category: .admin, + tokenName: tokenName, + action: "token.created", + outcome: .success + ) } static func logTokenRevoked(tokenName: String) { serverAdmin.info("Token revoked: \(tokenName, privacy: .public)") + record( + category: .admin, + tokenName: tokenName, + action: "token.revoked", + outcome: .success + ) } static func logServerStarted(port: UInt16, remoteAccess: Bool, tlsEnabled: Bool) { serverAdmin.info( "MCP server started: port=\(port, privacy: .public) remote=\(remoteAccess, privacy: .public) tls=\(tlsEnabled, privacy: .public)" ) + record( + category: .admin, + action: "server.started", + outcome: .success, + details: "port=\(port) remote=\(remoteAccess) tls=\(tlsEnabled)" + ) } static func logServerStopped() { serverAdmin.info("MCP server stopped") + record( + category: .admin, + action: "server.stopped", + outcome: .success + ) + } + + static func logQueryExecuted( + tokenId: UUID?, + tokenName: String?, + connectionId: UUID, + sql: String, + durationMs: Int, + rowCount: Int, + outcome: AuditOutcome, + errorMessage: String? = nil + ) { + serverQuery.info( + """ + Query: token=\(tokenName ?? "-", privacy: .public) \ + connection=\(connectionId, privacy: .public) \ + duration=\(durationMs, privacy: .public)ms \ + rows=\(rowCount, privacy: .public) \ + outcome=\(outcome.rawValue, privacy: .public) \ + sql=\(sql, privacy: .private) + """ + ) + + var detailParts: [String] = [ + "duration=\(durationMs)ms", + "rows=\(rowCount)", + "sql=\(truncate(sql, to: sqlExcerptLimit))" + ] + if let errorMessage { + detailParts.append("error=\(truncate(errorMessage, to: 256))") + } + + record( + category: .query, + tokenId: tokenId, + tokenName: tokenName, + connectionId: connectionId, + action: "query.executed", + outcome: outcome, + details: detailParts.joined(separator: " ") + ) + } + + static func logToolCalled( + tokenId: UUID?, + tokenName: String?, + toolName: String, + connectionId: UUID? = nil, + outcome: AuditOutcome, + errorMessage: String? = nil + ) { + serverTool.info( + """ + Tool: token=\(tokenName ?? "-", privacy: .public) \ + tool=\(toolName, privacy: .public) \ + connection=\(connectionId?.uuidString ?? "-", privacy: .public) \ + outcome=\(outcome.rawValue, privacy: .public) + """ + ) + + var detailParts: [String] = ["tool=\(toolName)"] + if let errorMessage { + detailParts.append("error=\(truncate(errorMessage, to: 256))") + } + + record( + category: .tool, + tokenId: tokenId, + tokenName: tokenName, + connectionId: connectionId, + action: "tool.\(toolName)", + outcome: outcome, + details: detailParts.joined(separator: " ") + ) + } + + static func logResourceRead( + tokenId: UUID?, + tokenName: String?, + uri: String, + outcome: AuditOutcome, + errorMessage: String? = nil + ) { + serverResource.info( + """ + Resource: token=\(tokenName ?? "-", privacy: .public) \ + uri=\(uri, privacy: .public) \ + outcome=\(outcome.rawValue, privacy: .public) + """ + ) + + var detailParts: [String] = ["uri=\(uri)"] + if let errorMessage { + detailParts.append("error=\(truncate(errorMessage, to: 256))") + } + + record( + category: .resource, + tokenId: tokenId, + tokenName: tokenName, + action: "resource.read", + outcome: outcome, + details: detailParts.joined(separator: " ") + ) + } + + private static func record( + category: AuditCategory, + tokenId: UUID? = nil, + tokenName: String? = nil, + connectionId: UUID? = nil, + action: String, + outcome: AuditOutcome, + details: String? = nil + ) { + let entry = AuditEntry( + category: category, + tokenId: tokenId, + tokenName: tokenName, + connectionId: connectionId, + action: action, + outcome: outcome, + details: details + ) + Task { + await MCPAuditLogStorage.shared.addEntry(entry) + } + } + + private static func truncate(_ text: String, to limit: Int) -> String { + let nsText = text as NSString + guard nsText.length > limit else { return text } + let prefix = nsText.substring(to: limit) + return prefix + "..." } } diff --git a/TablePro/Core/MCP/MCPAuthGuard.swift b/TablePro/Core/MCP/MCPAuthGuard.swift deleted file mode 100644 index 049c9d2b1..000000000 --- a/TablePro/Core/MCP/MCPAuthGuard.swift +++ /dev/null @@ -1,175 +0,0 @@ -// -// MCPAuthGuard.swift -// TablePro -// -// Enforces AIConnectionPolicy and SafeModeLevel for MCP requests. -// - -import AppKit -import Foundation -import os - -actor MCPAuthGuard { - private static let logger = Logger(subsystem: "com.TablePro", category: "MCPAuthGuard") - - /// Per-session approved connections (for askEachTime policy) - private var sessionApprovals: [String: Set] = [:] - - // MARK: - Connection Access Check - - func checkConnectionAccess(connectionId: UUID, sessionId: String) async throws { - let (policy, connectionName, databaseType) = await MainActor.run { - let conns = ConnectionStorage.shared.loadConnections() - guard let conn = conns.first(where: { $0.id == connectionId }) else { - return (AIConnectionPolicy.never, "", "") - } - let effective = conn.aiPolicy ?? AppSettingsManager.shared.ai.defaultConnectionPolicy - return (effective, conn.name, conn.type.rawValue) - } - - switch policy { - case .alwaysAllow: - return - - case .never: - throw MCPError.forbidden( - String(localized: "AI access is disabled for this connection") - ) - - case .askEachTime: - if let approved = sessionApprovals[sessionId], approved.contains(connectionId) { - return - } - - let userApproved = try await promptUserApproval( - connectionName: connectionName, - databaseType: databaseType - ) - - if userApproved { - sessionApprovals[sessionId, default: []].insert(connectionId) - } else { - throw MCPError.forbidden( - String(localized: "User denied MCP access to this connection") - ) - } - } - } - - // MARK: - Query Permission Check - - func checkQueryPermission( - sql: String, - connectionId: UUID, - databaseType: DatabaseType, - safeModeLevel: SafeModeLevel - ) async throws { - let isWrite = QueryClassifier.isWriteQuery(sql, databaseType: databaseType) - let needsDialog = safeModeLevel != .silent && (isWrite || safeModeLevel == .alertFull || safeModeLevel == .safeModeFull) - - var window: NSWindow? - if needsDialog { - window = await MainActor.run { - NSApp.activate(ignoringOtherApps: true) - return NSApp.keyWindow ?? NSApp.mainWindow - } - } - - let permission = await SafeModeGuard.checkPermission( - level: safeModeLevel, - isWriteOperation: isWrite, - sql: sql, - operationDescription: String(localized: "MCP query execution"), - window: window, - databaseType: databaseType - ) - - if case .blocked(let reason) = permission { - throw MCPError.forbidden(reason) - } - } - - // MARK: - Query Logging - - func logQuery( - sql: String, - connectionId: UUID, - databaseName: String, - executionTime: TimeInterval, - rowCount: Int, - wasSuccessful: Bool, - errorMessage: String? - ) async { - let shouldLog = await MainActor.run { - AppSettingsManager.shared.mcp.logQueriesInHistory - } - guard shouldLog else { return } - - let entry = QueryHistoryEntry( - query: sql, - connectionId: connectionId, - databaseName: databaseName, - executionTime: executionTime, - rowCount: rowCount, - wasSuccessful: wasSuccessful, - errorMessage: errorMessage - ) - - _ = await QueryHistoryStorage.shared.addHistory(entry) - } - - // MARK: - User Approval (askEachTime) - - private func promptUserApproval(connectionName: String, databaseType: String) async throws -> Bool { - // Use a task group so the actor suspends (freeing it for other requests) - // while the approval dialog is shown on the main thread. - // Race the dialog against a 30-second timeout. - let approvalTask = Task { @MainActor in - NSApp.requestUserAttention(.criticalRequest) - NSApp.activate(ignoringOtherApps: true) - return await AlertHelper.confirmDestructive( - title: String(localized: "MCP Access Request"), - message: String( - format: String(localized: "An MCP client wants to access '%@' (%@). Allow?"), - connectionName, - databaseType - ), - confirmButton: String(localized: "Allow"), - cancelButton: String(localized: "Deny"), - window: nil - ) - } - - let approved = try await withThrowingTaskGroup(of: Bool.self) { group in - group.addTask { - await approvalTask.value - } - group.addTask { - try await Task.sleep(for: .seconds(30)) - approvalTask.cancel() - throw MCPError.timeout( - String(localized: "User approval timed out after 30 seconds") - ) - } - guard let result = try await group.next() else { - throw MCPError.internalError("No result from approval prompt") - } - approvalTask.cancel() - group.cancelAll() - return result - } - - if approved { - return true - } - throw MCPError.forbidden( - String(localized: "User denied MCP access to this connection") - ) - } - - // MARK: - Session Cleanup - - func clearSession(_ sessionId: String) { - sessionApprovals.removeValue(forKey: sessionId) - } -} diff --git a/TablePro/Core/MCP/MCPAuthPolicy.swift b/TablePro/Core/MCP/MCPAuthPolicy.swift new file mode 100644 index 000000000..2fa2eee60 --- /dev/null +++ b/TablePro/Core/MCP/MCPAuthPolicy.swift @@ -0,0 +1,314 @@ +import AppKit +import Foundation +import os + +typealias MCPToolName = String + +extension MCPToolName { + static let stateMutating: Set = [ + "execute_query", "confirm_destructive_operation", + "switch_database", "switch_schema", "export_data" + ] + static let requiresFullAccess: Set = ["confirm_destructive_operation"] + static let requiresReadWrite: Set = ["switch_database", "switch_schema", "export_data"] + static let writeQueryTools: Set = ["execute_query"] +} + +enum AuthDecision: Sendable { + case allowed + case requiresUserApproval(reason: String) + case denied(reason: String) +} + +actor MCPAuthPolicy { + private static let logger = Logger(subsystem: "com.TablePro", category: "MCPAuthPolicy") + + private var sessionApprovals: [String: Set] = [:] + private let approvalDedup = OnceTask() + + private struct ApprovalKey: Hashable, Sendable { + let sessionId: String + let connectionId: UUID + } + + private struct ConnectionSnapshot: Sendable { + let policy: AIConnectionPolicy + let externalAccess: ExternalAccessLevel + let name: String + let databaseType: String + let safeModeLevel: SafeModeLevel + } + + func authorize( + token: MCPAuthToken, + tool: MCPToolName, + connectionId: UUID?, + sql: String? = nil, + sessionId: String + ) async throws -> AuthDecision { + guard let connectionId else { + return decideTokenTier(token: token, tool: tool) + } + + guard let snapshot = await loadConnection(connectionId) else { + return .denied(reason: String(localized: "Connection not found")) + } + + if snapshot.policy == .never { + return .denied(reason: String(localized: "AI access is disabled for this connection")) + } + + if snapshot.externalAccess == .blocked { + return .denied(reason: String(localized: "External access is disabled for this connection")) + } + + if !token.connectionAccess.allows(connectionId) { + return .denied(reason: String(localized: "Token does not have access to this connection")) + } + + if case .denied(let reason) = decideTokenTier(token: token, tool: tool) { + return .denied(reason: reason) + } + + if let writeReason = denialForWriteIntent( + tool: tool, + sql: sql, + externalAccess: snapshot.externalAccess, + databaseType: snapshot.databaseType + ) { + return .denied(reason: writeReason) + } + + if snapshot.policy == .askEachTime, + !(sessionApprovals[sessionId]?.contains(connectionId) ?? false) + { + return .requiresUserApproval( + reason: String( + format: String(localized: "An MCP client wants to access '%@' (%@). Allow?"), + snapshot.name, + snapshot.databaseType + ) + ) + } + + return .allowed + } + + func resolveAndAuthorize( + token: MCPAuthToken, + tool: MCPToolName, + connectionId: UUID?, + sql: String? = nil, + sessionId: String + ) async throws { + let decision = try await authorize( + token: token, + tool: tool, + connectionId: connectionId, + sql: sql, + sessionId: sessionId + ) + + switch decision { + case .allowed: + return + + case .denied(let reason): + throw MCPError.forbidden(reason) + + case .requiresUserApproval(let reason): + guard let connectionId else { + throw MCPError.forbidden(reason) + } + let approved = try await runApprovalDedup( + sessionId: sessionId, + connectionId: connectionId, + reason: reason + ) + if approved { + recordApproval(sessionId: sessionId, connectionId: connectionId) + } else { + throw MCPError.forbidden( + String(localized: "User denied MCP access to this connection") + ) + } + } + } + + func recordApproval(sessionId: String, connectionId: UUID) { + sessionApprovals[sessionId, default: []].insert(connectionId) + } + + func clearSession(_ sessionId: String) { + sessionApprovals.removeValue(forKey: sessionId) + } + + func checkSafeModeDialog( + sql: String, + connectionId: UUID, + databaseType: DatabaseType, + safeModeLevel: SafeModeLevel + ) async throws { + let isWrite = QueryClassifier.isWriteQuery(sql, databaseType: databaseType) + let needsDialog = safeModeLevel != .silent + && (isWrite || safeModeLevel == .alertFull || safeModeLevel == .safeModeFull) + + let window: NSWindow? = needsDialog + ? await MainActor.run { + NSApp.activate(ignoringOtherApps: true) + return WindowLifecycleMonitor.shared.findWindow(for: connectionId) + ?? NSApp.mainWindow + } + : nil + + let permission = await SafeModeGuard.checkPermission( + level: safeModeLevel, + isWriteOperation: isWrite, + sql: sql, + operationDescription: String(localized: "MCP query execution"), + window: window, + databaseType: databaseType + ) + + if case .blocked(let reason) = permission { + throw MCPError.forbidden(reason) + } + } + + func logQuery( + sql: String, + connectionId: UUID, + databaseName: String, + executionTime: TimeInterval, + rowCount: Int, + wasSuccessful: Bool, + errorMessage: String? + ) async { + let shouldLog = await MainActor.run { + AppSettingsManager.shared.mcp.logQueriesInHistory + } + guard shouldLog else { return } + + let entry = QueryHistoryEntry( + query: sql, + connectionId: connectionId, + databaseName: databaseName, + executionTime: executionTime, + rowCount: rowCount, + wasSuccessful: wasSuccessful, + errorMessage: errorMessage + ) + + _ = await QueryHistoryStorage.shared.addHistory(entry) + } + + private func runApprovalDedup( + sessionId: String, + connectionId: UUID, + reason: String + ) async throws -> Bool { + let key = ApprovalKey(sessionId: sessionId, connectionId: connectionId) + return try await approvalDedup.execute(key: key) { + try await Self.promptApproval(reason: reason) + } + } + + private static func promptApproval(reason: String) async throws -> Bool { + try await withThrowingTaskGroup(of: Bool.self) { group in + defer { group.cancelAll() } + group.addTask { + await AlertHelper.runApprovalModal( + title: String(localized: "MCP Access Request"), + message: reason, + confirm: String(localized: "Allow"), + cancel: String(localized: "Deny") + ) + } + group.addTask { + try await Task.sleep(for: .seconds(30)) + throw MCPError.timeout( + String(localized: "User approval timed out after 30 seconds") + ) + } + guard let result = try await group.next() else { + throw MCPError.internalError("No result from approval prompt") + } + return result + } + } + + private func decideTokenTier(token: MCPAuthToken, tool: MCPToolName) -> AuthDecision { + let required = requiredPermission(for: tool) + if token.permissions.satisfies(required) { + return .allowed + } + return .denied( + reason: String( + format: String(localized: "Token '%@' with permission '%@' cannot access '%@'"), + token.name, + token.permissions.displayName, + tool + ) + ) + } + + private func requiredPermission(for tool: MCPToolName) -> TokenPermissions { + if MCPToolName.requiresFullAccess.contains(tool) { return .fullAccess } + if MCPToolName.requiresReadWrite.contains(tool) { return .readWrite } + return .readOnly + } + + private func denialForWriteIntent( + tool: MCPToolName, + sql: String?, + externalAccess: ExternalAccessLevel, + databaseType: String + ) -> String? { + if MCPToolName.requiresReadWrite.contains(tool) || MCPToolName.requiresFullAccess.contains(tool) { + if externalAccess != .readWrite { + return String(localized: "Connection is read-only for external clients") + } + return nil + } + + guard MCPToolName.writeQueryTools.contains(tool), let sql else { + return nil + } + + let dbType = DatabaseType(rawValue: databaseType) + guard QueryClassifier.isWriteQuery(sql, databaseType: dbType) else { + return nil + } + if externalAccess != .readWrite { + return String(localized: "Connection is read-only for external clients") + } + return nil + } + + private func loadConnection(_ connectionId: UUID) async -> ConnectionSnapshot? { + await MainActor.run { + let state = DatabaseManager.shared.connectionState(connectionId) + switch state { + case .live(_, let session): + let conn = session.connection + return ConnectionSnapshot( + policy: conn.aiPolicy ?? AppSettingsManager.shared.ai.defaultConnectionPolicy, + externalAccess: conn.externalAccess, + name: conn.name, + databaseType: conn.type.rawValue, + safeModeLevel: conn.safeModeLevel + ) + case .stored(let conn): + return ConnectionSnapshot( + policy: conn.aiPolicy ?? AppSettingsManager.shared.ai.defaultConnectionPolicy, + externalAccess: conn.externalAccess, + name: conn.name, + databaseType: conn.type.rawValue, + safeModeLevel: conn.safeModeLevel + ) + case .unknown: + return nil + } + } + } +} diff --git a/TablePro/Core/MCP/MCPConnectionBridge.swift b/TablePro/Core/MCP/MCPConnectionBridge.swift index d0dfbfb69..f7958bffe 100644 --- a/TablePro/Core/MCP/MCPConnectionBridge.swift +++ b/TablePro/Core/MCP/MCPConnectionBridge.swift @@ -11,11 +11,10 @@ import os actor MCPConnectionBridge { private static let logger = Logger(subsystem: "com.TablePro", category: "MCPConnectionBridge") - // MARK: - Connection Management - func listConnections() async -> JSONValue { let (connections, activeSessions) = await MainActor.run { let conns = ConnectionStorage.shared.loadConnections() + .filter { $0.externalAccess != .blocked } let sessions = DatabaseManager.shared.activeSessions return (conns, sessions) } @@ -69,9 +68,7 @@ actor MCPConnectionBridge { return .object(result) } - // Not connected yet -- create a new session via DatabaseManager. - // connectToSession is @MainActor; Swift hops automatically for async calls. - try await DatabaseManager.shared.connectToSession(connection) + try await DatabaseManager.shared.ensureConnected(connection) let (serverVersion, currentDatabase, currentSchema) = await MainActor.run { let session = DatabaseManager.shared.activeSessions[connectionId] @@ -161,8 +158,6 @@ actor MCPConnectionBridge { return .object(result) } - // MARK: - Query Execution - func executeQuery( connectionId: UUID, query: String, @@ -170,8 +165,9 @@ actor MCPConnectionBridge { timeoutSeconds: Int ) async throws -> JSONValue { let (driver, databaseType) = try await resolveDriver(connectionId) - let isWrite = QueryClassifier.isWriteQuery(query, databaseType: databaseType) - let hasReturning = query.range(of: #"\bRETURNING\b"#, options: [.regularExpression, .caseInsensitive]) != nil + let normalizedQuery = Self.stripTrailingSemicolons(query) + let isWrite = QueryClassifier.isWriteQuery(normalizedQuery, databaseType: databaseType) + let hasReturning = normalizedQuery.range(of: #"\bRETURNING\b"#, options: [.regularExpression, .caseInsensitive]) != nil let shouldUseFetchRows = !isWrite || hasReturning let effectiveLimit = maxRows + 1 @@ -183,9 +179,9 @@ actor MCPConnectionBridge { try await withThrowingTaskGroup(of: QueryResult.self) { group in group.addTask { if shouldUseFetchRows { - try await driver.fetchRows(query: query, offset: 0, limit: effectiveLimit) + try await driver.fetchRows(query: normalizedQuery, offset: 0, limit: effectiveLimit) } else { - try await driver.execute(query: query) + try await driver.execute(query: normalizedQuery) } } group.addTask { @@ -231,18 +227,9 @@ actor MCPConnectionBridge { return .object(response) } - // MARK: - Schema Operations - func listTables(connectionId: UUID, includeRowCounts: Bool) async throws -> JSONValue { - let provider = await MainActor.run { - SchemaProviderRegistry.shared.provider(for: connectionId) - } - var cachedTables: [TableInfo] = [] - if let provider { - let cached = await provider.getTables() - if !cached.isEmpty { - cachedTables = cached - } + let cachedTables = await MainActor.run { + SchemaService.shared.tables(for: connectionId) } let tables: [TableInfo] @@ -359,8 +346,6 @@ actor MCPConnectionBridge { return .object(["ddl": .string(ddl)]) } - // MARK: - Database/Schema Switching - func switchDatabase(connectionId: UUID, database: String) async throws -> JSONValue { // switchDatabase is @MainActor; Swift hops automatically for async calls. try await DatabaseManager.shared.switchDatabase(to: database, for: connectionId) @@ -379,19 +364,9 @@ actor MCPConnectionBridge { ]) } - // MARK: - Schema Resource (for resources/read) - func fetchSchemaResource(connectionId: UUID) async throws -> JSONValue { - // Check SchemaProviderRegistry cache first - let provider = await MainActor.run { - SchemaProviderRegistry.shared.provider(for: connectionId) - } - var cachedTables: [TableInfo] = [] - if let provider { - let cached = await provider.getTables() - if !cached.isEmpty { - cachedTables = cached - } + let cachedTables = await MainActor.run { + SchemaService.shared.tables(for: connectionId) } let (driver, _) = try await resolveDriver(connectionId) @@ -438,8 +413,6 @@ actor MCPConnectionBridge { return .object(result) } - // MARK: - History Resource - func fetchHistoryResource( connectionId: UUID, limit: Int, @@ -480,18 +453,31 @@ actor MCPConnectionBridge { return .object(["history": .array(jsonEntries)]) } - // MARK: - Private Helpers - private func resolveDriver(_ connectionId: UUID) async throws -> (DatabaseDriver, DatabaseType) { - try await MainActor.run { - guard let session = DatabaseManager.shared.activeSessions[connectionId], - let driver = session.driver else { + let pending: DatabaseConnection? = await MainActor.run { + switch DatabaseManager.shared.connectionState(connectionId) { + case .live: return nil + case .stored(let connection): return connection + case .unknown: return nil + } + } + if let pending { + try await connectIfNeeded(pending) + } + return try await MainActor.run { + switch DatabaseManager.shared.connectionState(connectionId) { + case .live(let driver, let session): + return (driver, session.connection.type) + case .stored, .unknown: throw MCPError.notConnected(connectionId) } - return (driver, session.connection.type) } } + private func connectIfNeeded(_ connection: DatabaseConnection) async throws { + try await DatabaseManager.shared.ensureConnected(connection) + } + private func resolveSession(_ connectionId: UUID) async throws -> ConnectionSession { try await MainActor.run { guard let session = DatabaseManager.shared.activeSessions[connectionId] else { @@ -510,4 +496,13 @@ actor MCPConnectionBridge { return connection } } + + static func stripTrailingSemicolons(_ query: String) -> String { + var result = query.trimmingCharacters(in: .whitespacesAndNewlines) + while result.hasSuffix(";") { + result = String(result.dropLast()) + .trimmingCharacters(in: .whitespacesAndNewlines) + } + return result + } } diff --git a/TablePro/Core/MCP/MCPHTTPParser.swift b/TablePro/Core/MCP/MCPHTTPParser.swift index 6fdc329ae..5662aa73e 100644 --- a/TablePro/Core/MCP/MCPHTTPParser.swift +++ b/TablePro/Core/MCP/MCPHTTPParser.swift @@ -13,6 +13,19 @@ struct HTTPRequest: Sendable { let path: String let headers: [String: String] let body: Data? + var remoteIP: String? + + init(method: Method, path: String, headers: [String: String], body: Data?, remoteIP: String? = nil) { + self.method = method + self.path = path + self.headers = headers + self.body = body + self.remoteIP = remoteIP + } + + func withRemoteIP(_ remoteIP: String?) -> HTTPRequest { + HTTPRequest(method: method, path: path, headers: headers, body: body, remoteIP: remoteIP) + } } enum HTTPParseError: Error, Sendable { diff --git a/TablePro/Core/MCP/MCPMessageTypes.swift b/TablePro/Core/MCP/MCPMessageTypes.swift index 118c3faa7..01ae77a47 100644 --- a/TablePro/Core/MCP/MCPMessageTypes.swift +++ b/TablePro/Core/MCP/MCPMessageTypes.swift @@ -5,8 +5,6 @@ import Foundation -// MARK: - JSONValue - enum JSONValue: Codable, Equatable, Sendable { case null case bool(Bool) @@ -78,8 +76,6 @@ enum JSONValue: Codable, Equatable, Sendable { } } -// MARK: - JSONValue Literals - extension JSONValue: ExpressibleByStringLiteral { init(stringLiteral value: String) { self = .string(value) @@ -116,8 +112,6 @@ extension JSONValue: ExpressibleByDictionaryLiteral { } } -// MARK: - JSONValue Accessors - extension JSONValue { subscript(key: String) -> JSONValue? { guard case .object(let dict) = self else { return nil } @@ -161,8 +155,6 @@ extension JSONValue { } } -// MARK: - JSONRPCId - enum JSONRPCId: Codable, Equatable, Hashable, Sendable { case string(String) case int(Int) @@ -194,8 +186,6 @@ enum JSONRPCId: Codable, Equatable, Hashable, Sendable { } } -// MARK: - JSON-RPC 2.0 Base Types - struct JSONRPCRequest: Codable, Sendable { let jsonrpc: String let id: JSONRPCId? @@ -273,8 +263,6 @@ struct JSONRPCErrorDetail: Codable, Sendable { let data: JSONValue? } -// MARK: - MCPError - enum MCPError: Error, Sendable { case parseError case invalidRequest(String) @@ -286,6 +274,9 @@ enum MCPError: Error, Sendable { case timeout(String, context: [String: String]? = nil) case resultTooLarge case serverDisabled + case notFound(String) + case expired(String) + case userCancelled var code: Int { switch self { @@ -299,6 +290,9 @@ enum MCPError: Error, Sendable { case .timeout: -32_002 case .resultTooLarge: -32_003 case .serverDisabled: -32_004 + case .notFound: -32_005 + case .expired: -32_006 + case .userCancelled: -32_007 } } @@ -324,6 +318,12 @@ enum MCPError: Error, Sendable { "Result too large" case .serverDisabled: "MCP server is disabled" + case .notFound(let detail): + "Not found: \(detail)" + case .expired(let detail): + "Expired: \(detail)" + case .userCancelled: + "User cancelled" } } @@ -349,9 +349,16 @@ enum MCPError: Error, Sendable { error: JSONRPCErrorDetail(code: code, message: message, data: contextData) ) } + + var isUserCancelled: Bool { + if case .userCancelled = self { return true } + return false + } } -// MARK: - MCP Initialize +extension MCPError: LocalizedError { + var errorDescription: String? { message } +} struct MCPClientInfo: Codable, Sendable { let name: String @@ -383,8 +390,6 @@ struct MCPServerInfo: Codable, Sendable { let version: String } -// MARK: - MCP Tools - struct MCPToolDefinition: Codable, Sendable { let name: String let description: String @@ -405,8 +410,6 @@ struct MCPContent: Codable, Sendable { } } -// MARK: - MCP Resources - struct MCPResourceDefinition: Codable, Sendable { let uri: String let name: String diff --git a/TablePro/Core/MCP/MCPPairingService.swift b/TablePro/Core/MCP/MCPPairingService.swift new file mode 100644 index 000000000..3201bf579 --- /dev/null +++ b/TablePro/Core/MCP/MCPPairingService.swift @@ -0,0 +1,196 @@ +// +// MCPPairingService.swift +// TablePro +// + +import AppKit +import CryptoKit +import Foundation +import os + +struct PairingExchangeRecord: Sendable, Equatable { + let plaintextToken: String + let challenge: String + let expiresAt: Date +} + +final class PairingExchangeStore: @unchecked Sendable { + static let exchangeWindow: TimeInterval = 300 + static let maxPendingCodes = 50 + + private let lock = NSLock() + private var pending: [String: PairingExchangeRecord] = [:] + + func insert(code: String, record: PairingExchangeRecord) throws { + lock.lock() + defer { lock.unlock() } + prune(now: Date.now) + guard pending.count < Self.maxPendingCodes else { + throw MCPError.forbidden( + String(localized: "Too many pending pairing codes. Try again later.") + ) + } + pending[code] = record + } + + func consume(code: String, verifier: String, now: Date = .now) throws -> String { + lock.lock() + defer { lock.unlock() } + prune(now: now) + + guard let entry = pending[code] else { + throw MCPError.notFound("pairing code") + } + + guard entry.expiresAt > now else { + pending.removeValue(forKey: code) + throw MCPError.expired("pairing code") + } + + let computed = Self.sha256Base64Url(of: verifier) + guard Self.constantTimeEqual(entry.challenge, computed) else { + throw MCPError.forbidden("challenge mismatch") + } + + let token = entry.plaintextToken + pending.removeValue(forKey: code) + return token + } + + func pruneExpired(now: Date = .now) { + lock.lock() + defer { lock.unlock() } + prune(now: now) + } + + func count() -> Int { + lock.lock() + defer { lock.unlock() } + return pending.count + } + + func contains(code: String) -> Bool { + lock.lock() + defer { lock.unlock() } + return pending[code] != nil + } + + private func prune(now: Date) { + let stale = pending.filter { $0.value.expiresAt <= now }.keys + for key in stale { + pending.removeValue(forKey: key) + } + } + + static func sha256Base64Url(of value: String) -> String { + let digest = SHA256.hash(data: Data(value.utf8)) + let data = Data(digest) + return data.base64EncodedString() + .replacingOccurrences(of: "+", with: "-") + .replacingOccurrences(of: "/", with: "_") + .replacingOccurrences(of: "=", with: "") + } + + static func constantTimeEqual(_ lhs: String, _ rhs: String) -> Bool { + let lhsBytes = Array(lhs.utf8) + let rhsBytes = Array(rhs.utf8) + guard lhsBytes.count == rhsBytes.count else { return false } + var result: UInt8 = 0 + for index in 0..? + + init(store: PairingExchangeStore = PairingExchangeStore()) { + self.store = store + startPruneLoop() + } + + func startPairing(_ request: PairingRequest) async throws { + await MCPServerManager.shared.lazyStart() + + guard let tokenStore = MCPServerManager.shared.tokenStore else { + Self.logger.error("Token store unavailable after lazyStart") + throw MCPError.internalError("Token store unavailable") + } + + let approval = try await AlertHelper.runPairingApproval(request: request) + + let connectionAccess: ConnectionAccess = approval.allowedConnectionIds.map { .limited($0) } ?? .all + let result = await tokenStore.generate( + name: request.clientName, + permissions: approval.grantedPermissions, + connectionAccess: connectionAccess, + expiresAt: approval.expiresAt + ) + + let code = UUID().uuidString + do { + try store.insert( + code: code, + record: PairingExchangeRecord( + plaintextToken: result.plaintext, + challenge: request.challenge, + expiresAt: Date.now.addingTimeInterval(PairingExchangeStore.exchangeWindow) + ) + ) + } catch { + await tokenStore.delete(tokenId: result.token.id) + throw error + } + + guard let redirect = buildRedirectURL(base: request.redirectURL, code: code) else { + Self.logger.error("Failed to build pairing redirect URL") + await tokenStore.delete(tokenId: result.token.id) + throw MCPError.invalidParams("redirect URL") + } + + Self.logger.info("Pairing approved for client '\(request.clientName, privacy: .public)'") + NSWorkspace.shared.open(redirect) + } + + func exchange(_ exchange: PairingExchange) throws -> String { + try store.consume(code: exchange.code, verifier: exchange.verifier) + } + + private func startPruneLoop() { + pruneTask = Task { [store] in + while !Task.isCancelled { + try? await Task.sleep(for: Self.pruneInterval) + guard !Task.isCancelled else { return } + store.pruneExpired() + } + } + } + + private func buildRedirectURL(base: URL, code: String) -> URL? { + guard var components = URLComponents(url: base, resolvingAgainstBaseURL: false) else { + return nil + } + var items = components.queryItems ?? [] + if base.scheme == "raycast" { + let payload = ["code": code] + guard let data = try? JSONSerialization.data(withJSONObject: payload, options: [.sortedKeys]), + let json = String(data: data, encoding: .utf8) else { + return nil + } + items.append(URLQueryItem(name: "context", value: json)) + } else { + items.append(URLQueryItem(name: "code", value: code)) + } + components.queryItems = items + return components.url + } +} diff --git a/TablePro/Core/MCP/MCPPortAllocator.swift b/TablePro/Core/MCP/MCPPortAllocator.swift new file mode 100644 index 000000000..9a354665a --- /dev/null +++ b/TablePro/Core/MCP/MCPPortAllocator.swift @@ -0,0 +1,61 @@ +// +// MCPPortAllocator.swift +// TablePro +// + +import Darwin +import Foundation +import os + +enum MCPPortAllocatorError: Error, LocalizedError { + case rangeExhausted(ClosedRange) + + var errorDescription: String? { + switch self { + case .rangeExhausted(let range): + return String( + format: String(localized: "No free port in range %d-%d"), + Int(range.lowerBound), + Int(range.upperBound) + ) + } + } +} + +enum MCPPortAllocator { + private static let logger = Logger(subsystem: "com.TablePro", category: "MCPPortAllocator") + + static func findFreePort(in range: ClosedRange) throws -> UInt16 { + for port in range where probe(port: port) { + return port + } + logger.error("Port allocator exhausted range \(range.lowerBound)-\(range.upperBound)") + throw MCPPortAllocatorError.rangeExhausted(range) + } + + static func isFree(port: UInt16) -> Bool { + probe(port: port) + } + + private static func probe(port: UInt16) -> Bool { + let fd = socket(AF_INET, SOCK_STREAM, IPPROTO_TCP) + guard fd >= 0 else { return false } + defer { close(fd) } + + var reuse: Int32 = 1 + setsockopt(fd, SOL_SOCKET, SO_REUSEADDR, &reuse, socklen_t(MemoryLayout.size)) + + var addr = sockaddr_in() + addr.sin_family = sa_family_t(AF_INET) + addr.sin_port = port.bigEndian + addr.sin_addr.s_addr = INADDR_LOOPBACK.bigEndian + addr.sin_len = UInt8(MemoryLayout.size) + + let bindResult = withUnsafePointer(to: &addr) { addrPtr -> Int32 in + addrPtr.withMemoryRebound(to: sockaddr.self, capacity: 1) { sockPtr in + bind(fd, sockPtr, socklen_t(MemoryLayout.size)) + } + } + return bindResult == 0 + } +} diff --git a/TablePro/Core/MCP/MCPResourceHandler.swift b/TablePro/Core/MCP/MCPResourceHandler.swift index d2fec5f24..aa4b36226 100644 --- a/TablePro/Core/MCP/MCPResourceHandler.swift +++ b/TablePro/Core/MCP/MCPResourceHandler.swift @@ -5,11 +5,11 @@ final class MCPResourceHandler: Sendable { private static let logger = Logger(subsystem: "com.TablePro", category: "MCPResourceHandler") private let bridge: MCPConnectionBridge - private let authGuard: MCPAuthGuard + private let authPolicy: MCPAuthPolicy - init(bridge: MCPConnectionBridge, authGuard: MCPAuthGuard) { + init(bridge: MCPConnectionBridge, authPolicy: MCPAuthPolicy) { self.bridge = bridge - self.authGuard = authGuard + self.authPolicy = authPolicy } func handleResourceRead(uri: String, sessionId: String) async throws -> MCPResourceReadResult { @@ -65,7 +65,12 @@ final class MCPResourceHandler: Sendable { } private func handleSchemaResource(uri: String, connectionId: UUID, sessionId: String) async throws -> MCPResourceReadResult { - try await authGuard.checkConnectionAccess(connectionId: connectionId, sessionId: sessionId) + try await authPolicy.resolveAndAuthorize( + token: MCPToolHandler.anonymousFullAccessToken, + tool: "describe_table", + connectionId: connectionId, + sessionId: sessionId + ) let result = try await bridge.fetchSchemaResource(connectionId: connectionId) let jsonString = encodeJSON(result) return MCPResourceReadResult(contents: [ @@ -79,7 +84,12 @@ final class MCPResourceHandler: Sendable { queryItems: [URLQueryItem], sessionId: String ) async throws -> MCPResourceReadResult { - try await authGuard.checkConnectionAccess(connectionId: connectionId, sessionId: sessionId) + try await authPolicy.resolveAndAuthorize( + token: MCPToolHandler.anonymousFullAccessToken, + tool: "search_query_history", + connectionId: connectionId, + sessionId: sessionId + ) let limit = queryItems.first(where: { $0.name == "limit" }) .flatMap { $0.value } .flatMap { Int($0) } diff --git a/TablePro/Core/MCP/MCPRouteHandler.swift b/TablePro/Core/MCP/MCPRouteHandler.swift new file mode 100644 index 000000000..3b7a39c12 --- /dev/null +++ b/TablePro/Core/MCP/MCPRouteHandler.swift @@ -0,0 +1,7 @@ +import Foundation + +protocol MCPRouteHandler: Sendable { + var methods: [HTTPRequest.Method] { get } + var path: String { get } + func handle(_ request: HTTPRequest) async -> MCPRouter.RouteResult +} diff --git a/TablePro/Core/MCP/MCPRouter.swift b/TablePro/Core/MCP/MCPRouter.swift index c00e10f63..1561e27dd 100644 --- a/TablePro/Core/MCP/MCPRouter.swift +++ b/TablePro/Core/MCP/MCPRouter.swift @@ -1,12 +1,6 @@ import Foundation -import os final class MCPRouter: Sendable { - private static let logger = Logger(subsystem: "com.TablePro", category: "MCPRouter") - - private let encoder: JSONEncoder - private let decoder: JSONDecoder - enum RouteResult: Sendable { case json(Data, sessionId: String?) case sseStream(sessionId: String) @@ -16,542 +10,46 @@ final class MCPRouter: Sendable { case httpErrorWithHeaders(status: Int, message: String, extraHeaders: [(String, String)]) } - init() { - let enc = JSONEncoder() - enc.outputFormatting = [.sortedKeys] - self.encoder = enc - self.decoder = JSONDecoder() + private let routes: [any MCPRouteHandler] + + init(routes: [any MCPRouteHandler]) { + self.routes = routes } - func route( - _ request: HTTPRequest, - server: MCPServer, - remoteIP: String?, - tokenStore: MCPTokenStore?, - rateLimiter: MCPRateLimiter? - ) async -> RouteResult { + func handle(_ request: HTTPRequest) async -> RouteResult { if request.path.hasPrefix("/.well-known/") { return .httpError(status: 404, message: "Not found") } - guard request.path == "/mcp" || request.path.hasPrefix("/mcp?") else { - return .httpError(status: 404, message: "Not found") - } - - if let rateLimiter, let ip = remoteIP { - let lockoutCheck = await rateLimiter.isLockedOut(ip: ip) - if case .rateLimited(let retryAfter) = lockoutCheck { - let seconds = Int(retryAfter.components.seconds) - MCPAuditLogger.logRateLimited(ip: ip, retryAfterSeconds: seconds) - return .httpErrorWithHeaders( - status: 429, - message: "Too many failed attempts", - extraHeaders: [("Retry-After", "\(seconds)")] - ) - } - } - - let authResult = await authenticateRequest( - request, - remoteIP: remoteIP, - tokenStore: tokenStore, - rateLimiter: rateLimiter - ) - - switch authResult { - case .failure(let result): - return result - case .success(let token): - if token == nil { - if let origin = request.headers["origin"], !isAllowedOrigin(origin) { - return .httpError(status: 403, message: "Forbidden origin") - } - } - - switch request.method { - case .options: - return handleOptions() - case .post: - return await handlePost(request, server: server, authenticatedToken: token) - case .get: - return await handleGet(request, server: server) - case .delete: - return await handleDelete(request, server: server) - } - } - } - - private enum AuthResult { - case success(MCPAuthToken?) - case failure(RouteResult) - } - - private func authenticateRequest( - _ request: HTTPRequest, - remoteIP: String?, - tokenStore: MCPTokenStore?, - rateLimiter: MCPRateLimiter? - ) async -> AuthResult { - let authRequired = await MainActor.run { AppSettingsManager.shared.mcp.requireAuthentication } - - guard let authHeader = request.headers["authorization"] else { - guard !authRequired else { - MCPAuditLogger.logAuthFailure(reason: "Missing authorization header", ip: remoteIP ?? "localhost") - return .failure(.httpErrorWithHeaders( - status: 401, - message: "Authentication required", - extraHeaders: [("WWW-Authenticate", "Bearer realm=\"TablePro MCP\"")] - )) - } - return .success(nil) - } - - guard authHeader.lowercased().hasPrefix("bearer "), let tokenStore else { - let rateLimitResult = await recordAuthFailure(ip: remoteIP, rateLimiter: rateLimiter) - if case .rateLimited(let retryAfter) = rateLimitResult { - let seconds = Int(retryAfter.components.seconds) - MCPAuditLogger.logRateLimited(ip: remoteIP ?? "localhost", retryAfterSeconds: seconds) - return .failure(.httpErrorWithHeaders( - status: 429, - message: "Too many failed attempts", - extraHeaders: [("Retry-After", "\(seconds)")] - )) - } - MCPAuditLogger.logAuthFailure(reason: "Invalid authorization header format", ip: remoteIP ?? "localhost") - return .failure(.httpErrorWithHeaders( - status: 401, - message: "Invalid authorization header", - extraHeaders: [("WWW-Authenticate", "Bearer realm=\"TablePro MCP\"")] - )) - } - - let bearerToken = String(authHeader.dropFirst(7)) - - guard let token = await tokenStore.validate(bearerToken: bearerToken) else { - let rateLimitResult = await recordAuthFailure(ip: remoteIP, rateLimiter: rateLimiter) - if case .rateLimited(let retryAfter) = rateLimitResult { - let seconds = Int(retryAfter.components.seconds) - MCPAuditLogger.logRateLimited(ip: remoteIP ?? "localhost", retryAfterSeconds: seconds) - return .failure(.httpErrorWithHeaders( - status: 429, - message: "Too many failed attempts", - extraHeaders: [("Retry-After", "\(seconds)")] - )) - } - MCPAuditLogger.logAuthFailure(reason: "Invalid token", ip: remoteIP ?? "localhost") - return .failure(.httpErrorWithHeaders( - status: 401, - message: "Invalid or expired token", - extraHeaders: [("WWW-Authenticate", "Bearer realm=\"TablePro MCP\"")] - )) - } - - if let rateLimiter, let ip = remoteIP { - _ = await rateLimiter.checkAndRecord(ip: ip, success: true) - } - MCPAuditLogger.logAuthSuccess(tokenName: token.name, ip: remoteIP ?? "localhost") - return .success(token) - } - - @discardableResult - private func recordAuthFailure( - ip: String?, - rateLimiter: MCPRateLimiter? - ) async -> MCPRateLimiter.AuthRateResult? { - guard let rateLimiter, let ip else { return nil } - return await rateLimiter.checkAndRecord(ip: ip, success: false) - } - - private func isAllowedOrigin(_ origin: String) -> Bool { - guard let components = URLComponents(string: origin), - let host = components.host - else { - return false - } - let allowedHosts: Set = ["localhost", "127.0.0.1", "::1"] - return allowedHosts.contains(host) - } - - private func handleOptions() -> RouteResult { - .noContent - } - - private func handleGet(_ request: HTTPRequest, server: MCPServer) async -> RouteResult { - guard let sessionId = request.headers["mcp-session-id"] else { - return .httpError(status: 400, message: "Missing Mcp-Session-Id header") - } - - guard let session = await server.session(for: sessionId) else { - return .httpError(status: 404, message: "Session not found") - } - - await session.markActive() - return .sseStream(sessionId: session.id) - } - - private func handleDelete(_ request: HTTPRequest, server: MCPServer) async -> RouteResult { - guard let sessionId = request.headers["mcp-session-id"] else { - return .httpError(status: 400, message: "Missing Mcp-Session-Id header") - } - - guard await server.session(for: sessionId) != nil else { - return .httpError(status: 404, message: "Session not found") - } - - await server.removeSession(sessionId) - Self.logger.info("Session terminated via DELETE: \(sessionId)") - return .noContent - } - - private func handlePost( - _ request: HTTPRequest, - server: MCPServer, - authenticatedToken: MCPAuthToken? - ) async -> RouteResult { - if let accept = request.headers["accept"], !accept.contains("application/json") && !accept.contains("*/*") { - return .httpError(status: 406, message: "Accept header must include application/json") - } - - guard let body = request.body else { - return encodeError(MCPError.parseError, id: nil) - } - - let rpcRequest: JSONRPCRequest - do { - rpcRequest = try decoder.decode(JSONRPCRequest.self, from: body) - } catch { - return encodeError(MCPError.parseError, id: nil) - } - - guard rpcRequest.jsonrpc == "2.0" else { - return encodeError(MCPError.invalidRequest("jsonrpc must be \"2.0\""), id: rpcRequest.id) - } - - if let protocolVersion = request.headers["mcp-protocol-version"], - protocolVersion != "2025-03-26" - { - Self.logger.warning("Client mcp-protocol-version mismatch: \(protocolVersion)") - } - - let headerSessionId = request.headers["mcp-session-id"] - return await dispatchMethod( - rpcRequest, - headerSessionId: headerSessionId, - server: server, - authenticatedToken: authenticatedToken - ) - } - - private func dispatchMethod( - _ request: JSONRPCRequest, - headerSessionId: String?, - server: MCPServer, - authenticatedToken: MCPAuthToken? - ) async -> RouteResult { - if request.method == "initialize" { - return await handleInitialize(request, server: server, authenticatedToken: authenticatedToken) - } - - if request.method == "ping" { - return handlePing(request) - } - - guard let sessionId = headerSessionId else { - return .httpError(status: 400, message: "Missing Mcp-Session-Id header") - } - guard let session = await server.session(for: sessionId) else { - return .httpError(status: 404, message: "Session not found") - } - - await session.markActive() - - if request.method == "notifications/initialized" { - await session.setInitialized(true) - return .accepted - } - - if request.method == "notifications/cancelled" { - return await handleCancellation(request, session: session) - } - - guard await session.isInitialized else { - return encodeError( - MCPError.invalidRequest("Session not initialized. Send notifications/initialized first."), - id: request.id - ) - } - - switch request.method { - case "tools/list": - return handleToolsList(request, sessionId: sessionId) - - case "tools/call": - return await handleToolsCall( - request, - sessionId: sessionId, - server: server, - authenticatedToken: authenticatedToken - ) - - case "resources/list": - return handleResourcesList(request, sessionId: sessionId) - - case "resources/read": - return await handleResourcesRead(request, sessionId: sessionId, server: server) - - default: - return encodeError(MCPError.methodNotFound(request.method), id: request.id) - } - } - - private func handleInitialize( - _ request: JSONRPCRequest, - server: MCPServer, - authenticatedToken: MCPAuthToken? - ) async -> RouteResult { - guard let session = await server.createSession() else { - return encodeError(MCPError.internalError("Maximum sessions reached"), id: request.id) - } - - if let params = request.params, - let clientInfo = params["clientInfo"], - let name = clientInfo["name"]?.stringValue - { - let version = clientInfo["version"]?.stringValue - await session.setClientInfo(MCPClientInfo(name: name, version: version)) - } - - if let token = authenticatedToken { - await session.setAuthenticatedTokenId(token.id) - await session.setTokenName(token.name) - } - - let result = MCPInitializeResult( - protocolVersion: "2025-03-26", - capabilities: MCPServerCapabilities( - tools: .init(listChanged: false), - resources: .init(subscribe: false, listChanged: false) - ), - serverInfo: MCPServerInfo(name: "tablepro", version: "1.0.0") - ) - - return encodeResult(result, id: request.id, sessionId: session.id) - } - - private func handlePing(_ request: JSONRPCRequest) -> RouteResult { - guard let id = request.id else { - return .accepted - } - return encodeRawResult(.object([:]), id: id, sessionId: nil) - } - - private func handleCancellation( - _ request: JSONRPCRequest, - session: MCPSession - ) async -> RouteResult { - guard let params = request.params, - let requestIdValue = params["requestId"] - else { - return .accepted - } - - let cancelId: JSONRPCId? - switch requestIdValue { - case .string(let s): - cancelId = .string(s) - case .int(let i): - cancelId = .int(i) - default: - cancelId = nil - } - - if let cancelId, let task = await session.removeRunningTask(cancelId) { - task.cancel() - Self.logger.info("Cancelled request \(String(describing: cancelId)) in session \(session.id)") - } - - return .accepted - } - - private func handleToolsList(_ request: JSONRPCRequest, sessionId: String) -> RouteResult { - guard let id = request.id else { - return .accepted - } - - let tools = Self.toolDefinitions() - let result: JSONValue = .object(["tools": encodeToolDefinitions(tools)]) - return encodeRawResult(result, id: id, sessionId: sessionId) - } - - private func handleToolsCall( - _ request: JSONRPCRequest, - sessionId: String, - server: MCPServer, - authenticatedToken: MCPAuthToken? - ) async -> RouteResult { - guard let id = request.id else { - return encodeError(MCPError.invalidRequest("tools/call requires an id"), id: nil) - } - - guard let params = request.params, - let name = params["name"]?.stringValue - else { - return encodeError(MCPError.invalidParams("Missing tool name"), id: id) - } - - let arguments = params["arguments"] - - guard let handler = await server.toolCallHandler else { - return encodeError(MCPError.internalError("Server not fully initialized"), id: id) - } - - let session = await server.session(for: sessionId) - let toolTask = Task { - try await handler(name, arguments, sessionId, authenticatedToken) - } - if let session { - let cancelForwardingTask = Task { - await withTaskCancellationHandler { - _ = try? await toolTask.value - } onCancel: { - toolTask.cancel() - } - } - await session.addRunningTask(id, task: cancelForwardingTask) - } - - do { - let toolResult = try await toolTask.value - if let session { _ = await session.removeRunningTask(id) } - let resultData = try encoder.encode(toolResult) - guard let resultValue = try? decoder.decode(JSONValue.self, from: resultData) else { - return encodeError(MCPError.internalError("Failed to encode tool result"), id: id) - } - return encodeRawResult(resultValue, id: id, sessionId: sessionId) - } catch is CancellationError { - if let session { _ = await session.removeRunningTask(id) } - return encodeError(MCPError.timeout("Request was cancelled"), id: id) - } catch let mcpError as MCPError { - if let session { _ = await session.removeRunningTask(id) } - return encodeError(mcpError, id: id) - } catch { - if let session { _ = await session.removeRunningTask(id) } - return encodeError(MCPError.internalError(error.localizedDescription), id: id) - } - } - - private func handleResourcesList(_ request: JSONRPCRequest, sessionId: String) -> RouteResult { - guard let id = request.id else { - return .accepted - } - - let resources = Self.resourceDefinitions() - let result: JSONValue = .object(["resources": encodeResourceDefinitions(resources)]) - return encodeRawResult(result, id: id, sessionId: sessionId) - } - - private func handleResourcesRead( - _ request: JSONRPCRequest, - sessionId: String, - server: MCPServer - ) async -> RouteResult { - guard let id = request.id else { - return encodeError(MCPError.invalidRequest("resources/read requires an id"), id: nil) + if request.method == .options { + return .noContent } - guard let params = request.params, - let uri = params["uri"]?.stringValue - else { - return encodeError(MCPError.invalidParams("Missing resource uri"), id: id) - } - - guard let handler = await server.resourceReadHandler else { - return encodeError(MCPError.internalError("Server not fully initialized"), id: id) - } - - do { - let readResult = try await handler(uri, sessionId) - let resultData = try encoder.encode(readResult) - guard let resultValue = try? decoder.decode(JSONValue.self, from: resultData) else { - return encodeError(MCPError.internalError("Failed to encode resource result"), id: id) - } - return encodeRawResult(resultValue, id: id, sessionId: sessionId) - } catch let mcpError as MCPError { - return encodeError(mcpError, id: id) - } catch { - return encodeError(MCPError.internalError(error.localizedDescription), id: id) - } - } - - private func encodeResult(_ result: T, id: JSONRPCId?, sessionId: String?) -> RouteResult { - guard let id else { - return .accepted + guard let route = match(request) else { + return .httpError(status: 404, message: "Not found") } - do { - let resultData = try encoder.encode(result) - let resultValue = try decoder.decode(JSONValue.self, from: resultData) - let response = JSONRPCResponse(id: id, result: resultValue) - let data = try encoder.encode(response) - return .json(data, sessionId: sessionId) - } catch { - Self.logger.error("Failed to encode response: \(error.localizedDescription)") - return encodeError(MCPError.internalError("Encoding failed"), id: id) - } + return await route.handle(request) } - private func encodeRawResult(_ result: JSONValue, id: JSONRPCId, sessionId: String?) -> RouteResult { - do { - let response = JSONRPCResponse(id: id, result: result) - let data = try encoder.encode(response) - return .json(data, sessionId: sessionId) - } catch { - Self.logger.error("Failed to encode response: \(error.localizedDescription)") - return encodeError(MCPError.internalError("Encoding failed"), id: id) + private func match(_ request: HTTPRequest) -> (any MCPRouteHandler)? { + let normalizedPath = Self.canonicalPath(request.path) + return routes.first { route in + route.path == normalizedPath && route.methods.contains(request.method) } } - private func encodeError(_ error: MCPError, id: JSONRPCId?) -> RouteResult { - let errorResponse = error.toJsonRpcError(id: id) - do { - let data = try encoder.encode(errorResponse) - return .json(data, sessionId: nil) - } catch { - Self.logger.error("Failed to encode error response") - return .httpError(status: 500, message: "Internal encoding error") + private static func canonicalPath(_ path: String) -> String { + if let queryIndex = path.firstIndex(of: "?") { + return String(path[.. JSONValue { - .array(tools.map { tool in - .object([ - "name": .string(tool.name), - "description": .string(tool.description), - "inputSchema": tool.inputSchema - ]) - }) - } - - private func encodeResourceDefinitions(_ resources: [MCPResourceDefinition]) -> JSONValue { - .array(resources.map { resource in - var dict: [String: JSONValue] = [ - "uri": .string(resource.uri), - "name": .string(resource.name) - ] - if let description = resource.description { - dict["description"] = .string(description) - } - if let mimeType = resource.mimeType { - dict["mimeType"] = .string(mimeType) - } - return .object(dict) - }) + return path } } extension MCPRouter { static func toolDefinitions() -> [MCPToolDefinition] { - connectionTools() + schemaTools() + queryAndExportTools() + connectionTools() + schemaTools() + queryAndExportTools() + integrationTools() } private static func connectionTools() -> [MCPToolDefinition] { @@ -816,7 +314,7 @@ extension MCPRouter { ]), "output_path": .object([ "type": "string", - "description": "File path to save export (returns inline data if omitted)" + "description": "File path inside the user's Downloads directory (returns inline data if omitted). Paths outside Downloads are rejected." ]), "max_rows": .object([ "type": "integer", @@ -854,6 +352,111 @@ extension MCPRouter { ) ] } + + private static func integrationTools() -> [MCPToolDefinition] { + [ + MCPToolDefinition( + name: "list_recent_tabs", + description: "List currently open tabs across all TablePro windows. " + + "Returns connection, tab type, table name, and titles for each tab.", + inputSchema: .object([ + "type": "object", + "properties": .object([ + "limit": .object([ + "type": "integer", + "description": "Maximum number of tabs to return (default 20, max 500)" + ]) + ]), + "required": .array([]) + ]) + ), + MCPToolDefinition( + name: "search_query_history", + description: "Search saved query history. " + + "Returns matching entries with execution time, row count, and outcome.", + inputSchema: .object([ + "type": "object", + "properties": .object([ + "query": .object([ + "type": "string", + "description": "Search text (full-text matched against the query column)" + ]), + "connection_id": .object([ + "type": "string", + "description": "Restrict to a specific connection (UUID, optional)" + ]), + "limit": .object([ + "type": "integer", + "description": "Maximum number of entries to return (default 50, max 500)" + ]), + "since": .object([ + "type": "number", + "description": "Earliest executed_at to include, Unix epoch seconds (inclusive, optional)" + ]), + "until": .object([ + "type": "number", + "description": "Latest executed_at to include, Unix epoch seconds (inclusive, optional)" + ]) + ]), + "required": .array([.string("query")]) + ]) + ), + MCPToolDefinition( + name: "open_connection_window", + description: "Open a TablePro window for a saved connection (focuses if already open).", + inputSchema: .object([ + "type": "object", + "properties": .object([ + "connection_id": .object([ + "type": "string", + "description": "UUID of the saved connection" + ]) + ]), + "required": .array([.string("connection_id")]) + ]) + ), + MCPToolDefinition( + name: "open_table_tab", + description: "Open a table tab in TablePro for the given connection.", + inputSchema: .object([ + "type": "object", + "properties": .object([ + "connection_id": .object([ + "type": "string", + "description": "UUID of the connection" + ]), + "table_name": .object([ + "type": "string", + "description": "Table name to open" + ]), + "database_name": .object([ + "type": "string", + "description": "Database name (uses connection's current database if omitted)" + ]), + "schema_name": .object([ + "type": "string", + "description": "Schema name (for multi-schema databases)" + ]) + ]), + "required": .array([.string("connection_id"), .string("table_name")]) + ]) + ), + MCPToolDefinition( + name: "focus_query_tab", + description: "Focus an already-open tab by id (returned from list_recent_tabs).", + inputSchema: .object([ + "type": "object", + "properties": .object([ + "tab_id": .object([ + "type": "string", + "description": "UUID of the tab to focus" + ]) + ]), + "required": .array([.string("tab_id")]) + ]) + ) + ] + } } extension MCPRouter { diff --git a/TablePro/Core/MCP/MCPServer.swift b/TablePro/Core/MCP/MCPServer.swift index 5d2a3b1c3..a499d6581 100644 --- a/TablePro/Core/MCP/MCPServer.swift +++ b/TablePro/Core/MCP/MCPServer.swift @@ -27,7 +27,7 @@ actor MCPServer { private var sessions: [String: MCPSession] = [:] private var cleanupTask: Task? private let stateCallback: @Sendable (MCPServerState) -> Void - private var router: MCPRouter! + private var router: MCPRouter? private(set) var tokenStore: MCPTokenStore? private(set) var rateLimiter: MCPRateLimiter? @@ -38,7 +38,10 @@ actor MCPServer { init(stateCallback: @escaping @Sendable (MCPServerState) -> Void) { self.stateCallback = stateCallback - self.router = MCPRouter() + } + + func setRouter(_ router: MCPRouter) { + self.router = router } func setTokenStore(_ store: MCPTokenStore) { @@ -129,10 +132,18 @@ actor MCPServer { cleanupTask?.cancel() cleanupTask = nil + let sessionIds = Array(sessions.keys) for (_, session) in sessions { await session.cancelAllTasks() await session.cancelSSEConnection() } + + if let cleanupHandler = sessionCleanupHandler { + for id in sessionIds { + await cleanupHandler(id) + } + } + sessions.removeAll() if let currentListener = listener { @@ -280,7 +291,7 @@ actor MCPServer { } } - private static let corsHeaders: [(String, String)] = [ + static let corsHeaders: [(String, String)] = [ ("Access-Control-Allow-Origin", "http://localhost"), ("Access-Control-Allow-Methods", "GET, POST, DELETE, OPTIONS"), ("Access-Control-Allow-Headers", "Content-Type, Mcp-Session-Id, mcp-protocol-version, Authorization"), @@ -295,7 +306,13 @@ actor MCPServer { return "\(host)" }() - let result = await router.route(request, server: self, remoteIP: remoteIP, tokenStore: tokenStore, rateLimiter: rateLimiter) + guard let router else { + sendHTTPError(connection: connection, status: 503, message: "Server not configured") + return + } + + let routedRequest = request.withRemoteIP(remoteIP) + let result = await router.handle(routedRequest) switch result { case .json(let data, let sessionId): @@ -342,6 +359,7 @@ actor MCPServer { guard let session = sessions.removeValue(forKey: sessionId) else { return } await session.cancelAllTasks() await session.cancelSSEConnection() + try? await session.transition(to: .terminated(reason: .removed)) if let cleanupHandler = sessionCleanupHandler { await cleanupHandler(sessionId) @@ -371,6 +389,7 @@ actor MCPServer { if idle > .seconds(Self.idleTimeout) { await session.cancelAllTasks() await session.cancelSSEConnection() + try? await session.transition(to: .terminated(reason: .idleTimeout)) sessions.removeValue(forKey: id) if let cleanupHandler = sessionCleanupHandler { diff --git a/TablePro/Core/MCP/MCPServerManager.swift b/TablePro/Core/MCP/MCPServerManager.swift index ddbbc437a..e0616748b 100644 --- a/TablePro/Core/MCP/MCPServerManager.swift +++ b/TablePro/Core/MCP/MCPServerManager.swift @@ -61,9 +61,9 @@ final class MCPServerManager { let rateLimiter = MCPRateLimiter() let bridge = MCPConnectionBridge() - let authGuard = MCPAuthGuard() - let toolHandler = MCPToolHandler(bridge: bridge, authGuard: authGuard) - let resourceHandler = MCPResourceHandler(bridge: bridge, authGuard: authGuard) + let authPolicy = MCPAuthPolicy() + let toolHandler = MCPToolHandler(bridge: bridge, authPolicy: authPolicy) + let resourceHandler = MCPResourceHandler(bridge: bridge, authPolicy: authPolicy) await newServer.setTokenStore(newTokenStore) await newServer.setRateLimiter(rateLimiter) @@ -75,11 +75,20 @@ final class MCPServerManager { try await resourceHandler.handleResourceRead(uri: uri, sessionId: sessionId) } await newServer.setSessionCleanupHandler { sessionId in - await authGuard.clearSession(sessionId) + await authPolicy.clearSession(sessionId) } + let protocolHandler = MCPProtocolHandler( + server: newServer, + tokenStore: newTokenStore, + rateLimiter: rateLimiter + ) + let exchangeHandler = IntegrationsExchangeHandler.live() + let router = MCPRouter(routes: [protocolHandler, exchangeHandler]) + await newServer.setRouter(router) + let bridgeResult = await newTokenStore.generate( - name: "__stdio_bridge__", + name: MCPTokenStore.stdioBridgeTokenName, permissions: .fullAccess ) self.bridgeTokenId = bridgeResult.token.id @@ -150,6 +159,29 @@ final class MCPServerManager { await start(port: port) } + func lazyStart() async { + if case .running = state { return } + if case .starting = state { return } + + let settings = AppSettingsManager.shared.mcp + let preferredPort = UInt16(clamping: settings.port) + + let chosenPort: UInt16 + if preferredPort > 0, MCPPortAllocator.isFree(port: preferredPort) { + chosenPort = preferredPort + } else { + do { + chosenPort = try MCPPortAllocator.findFreePort(in: 51_000...52_000) + } catch { + Self.logger.error("Lazy start failed to allocate port: \(error.localizedDescription)") + state = .failed(error.localizedDescription) + return + } + } + + await start(port: chosenPort) + } + func disconnectClient(_ sessionId: String) async { await server?.removeSession(sessionId) await refreshClients() @@ -180,7 +212,7 @@ final class MCPServerManager { private static let handshakeDirectoryPath: String = { let home = FileManager.default.homeDirectoryForCurrentUser.path - return "\(home)/Library/Application Support/com.TablePro" + return "\(home)/Library/Application Support/TablePro" }() private static let handshakeFilePath: String = { diff --git a/TablePro/Core/MCP/MCPSession.swift b/TablePro/Core/MCP/MCPSession.swift index 66933e08c..a52295cad 100644 --- a/TablePro/Core/MCP/MCPSession.swift +++ b/TablePro/Core/MCP/MCPSession.swift @@ -6,15 +6,23 @@ actor MCPSession { let createdAt: ContinuousClock.Instant var lastActivityAt: ContinuousClock.Instant - var isInitialized: Bool = false + private(set) var phase: MCPSessionPhase = .created var clientInfo: MCPClientInfo? var sseConnection: NWConnection? var runningTasks: [JSONRPCId: Task] = [:] private(set) var eventCounter: Int = 0 - private(set) var authenticatedTokenId: UUID? - private(set) var tokenName: String? private(set) var remoteAddress: String? + var authenticatedTokenId: UUID? { + if case .active(let tokenId, _) = phase { return tokenId } + return nil + } + + var tokenName: String? { + if case .active(_, let tokenName) = phase { return tokenName } + return nil + } + init() { self.id = UUID().uuidString let now = ContinuousClock.now @@ -33,20 +41,31 @@ actor MCPSession { runningTasks.removeAll() } - func setInitialized(_ value: Bool) { - isInitialized = value - } - - func setClientInfo(_ info: MCPClientInfo?) { - clientInfo = info + func transition(to next: MCPSessionPhase) throws { + guard isValidTransition(from: phase, to: next) else { + throw MCPError.invalidRequest( + "Invalid session phase transition from \(phase) to \(next)" + ) + } + phase = next } - func setAuthenticatedTokenId(_ id: UUID?) { - authenticatedTokenId = id + private func isValidTransition(from current: MCPSessionPhase, to next: MCPSessionPhase) -> Bool { + switch (current, next) { + case (.created, .initializing), + (.created, .active), + (.created, .terminated), + (.initializing, .active), + (.initializing, .terminated), + (.active, .terminated): + return true + default: + return false + } } - func setTokenName(_ name: String?) { - tokenName = name + func setClientInfo(_ info: MCPClientInfo?) { + clientInfo = info } func setRemoteAddress(_ address: String?) { diff --git a/TablePro/Core/MCP/MCPSessionPhase.swift b/TablePro/Core/MCP/MCPSessionPhase.swift new file mode 100644 index 000000000..fa502c9f5 --- /dev/null +++ b/TablePro/Core/MCP/MCPSessionPhase.swift @@ -0,0 +1,20 @@ +import Foundation + +enum MCPSessionTerminationReason: Sendable, Equatable { + case removed + case idleTimeout + case serverStopped + case clientDisconnected +} + +enum MCPSessionPhase: Sendable, Equatable { + case created + case initializing + case active(tokenId: UUID?, tokenName: String?) + case terminated(reason: MCPSessionTerminationReason) + + var isActive: Bool { + if case .active = self { return true } + return false + } +} diff --git a/TablePro/Core/MCP/MCPTLSManager.swift b/TablePro/Core/MCP/MCPTLSManager.swift index c4338ed41..f876fca1a 100644 --- a/TablePro/Core/MCP/MCPTLSManager.swift +++ b/TablePro/Core/MCP/MCPTLSManager.swift @@ -20,8 +20,6 @@ actor MCPTLSManager { private(set) var fingerprint: String? private(set) var pemCertificate: String? - // MARK: - Public API - func loadOrGenerate() throws -> SecIdentity { if let existing = try? loadExistingIdentity() { return existing @@ -43,8 +41,6 @@ actor MCPTLSManager { Self.logger.info("Deleted MCP TLS identity from Keychain") } - // MARK: - Identity Loading - private func loadExistingIdentity() throws -> SecIdentity { let identityQuery: [String: Any] = [ kSecClass as String: kSecClassIdentity, @@ -80,8 +76,6 @@ actor MCPTLSManager { return identity } - // MARK: - Certificate Generation - private func generateAndStore() throws -> SecIdentity { let privateKey = P256.Signing.PrivateKey() let derCertData = try generateCertificate(privateKey: privateKey) @@ -132,8 +126,6 @@ actor MCPTLSManager { return Data(serializer.serializedBytes) } - // MARK: - Keychain Import - private func importPrivateKey(_ privateKey: P256.Signing.PrivateKey) throws { deleteKeychainKey() @@ -177,8 +169,6 @@ actor MCPTLSManager { } } - // MARK: - Keychain Retrieval - private func retrieveIdentity() throws -> SecIdentity { let identityQuery: [String: Any] = [ kSecClass as String: kSecClassIdentity, @@ -197,8 +187,6 @@ actor MCPTLSManager { return (ref as! SecIdentity) // swiftlint:disable:this force_cast } - // MARK: - Keychain Cleanup - private func deleteKeychainKey() { let query: [String: Any] = [ kSecClass as String: kSecClassKey, @@ -224,8 +212,6 @@ actor MCPTLSManager { } } - // MARK: - Certificate Validation - private func isCertificateValid(derData: Data) -> Bool { do { let certificate = try Certificate(derEncoded: Array(derData)) @@ -237,8 +223,6 @@ actor MCPTLSManager { } } - // MARK: - Metadata - private func cacheMetadata(derData: Data) { fingerprint = computeFingerprint(derData: derData) pemCertificate = encodePem(derData: derData) @@ -256,8 +240,6 @@ actor MCPTLSManager { } } -// MARK: - Errors - private enum MCPTLSError: LocalizedError { case keyGenerationFailed case certificateGenerationFailed diff --git a/TablePro/Core/MCP/MCPTokenStore.swift b/TablePro/Core/MCP/MCPTokenStore.swift index 2a67d50ca..dc71eaab8 100644 --- a/TablePro/Core/MCP/MCPTokenStore.swift +++ b/TablePro/Core/MCP/MCPTokenStore.swift @@ -3,6 +3,25 @@ import Foundation import os import Security +enum ConnectionAccess: Sendable, Codable, Equatable { + case all + case limited(Set) + + var allowedIds: Set? { + switch self { + case .all: return nil + case .limited(let ids): return ids + } + } + + func allows(_ connectionId: UUID) -> Bool { + switch self { + case .all: return true + case .limited(let ids): return ids.contains(connectionId) + } + } +} + struct MCPAuthToken: Codable, Identifiable, Sendable { let id: UUID let name: String @@ -10,7 +29,7 @@ struct MCPAuthToken: Codable, Identifiable, Sendable { let tokenHash: String let salt: String let permissions: TokenPermissions - let allowedConnectionIds: Set? + let connectionAccess: ConnectionAccess let createdAt: Date var lastUsedAt: Date? let expiresAt: Date? @@ -22,6 +41,84 @@ struct MCPAuthToken: Codable, Identifiable, Sendable { } var isEffectivelyActive: Bool { isActive && !isExpired } + + init( + id: UUID, + name: String, + prefix: String, + tokenHash: String, + salt: String, + permissions: TokenPermissions, + connectionAccess: ConnectionAccess, + createdAt: Date, + lastUsedAt: Date?, + expiresAt: Date?, + isActive: Bool + ) { + self.id = id + self.name = name + self.prefix = prefix + self.tokenHash = tokenHash + self.salt = salt + self.permissions = permissions + self.connectionAccess = connectionAccess + self.createdAt = createdAt + self.lastUsedAt = lastUsedAt + self.expiresAt = expiresAt + self.isActive = isActive + } + + private enum CodingKeys: String, CodingKey { + case id + case name + case prefix + case tokenHash + case salt + case permissions + case connectionAccess + case allowedConnectionIds + case createdAt + case lastUsedAt + case expiresAt + case isActive + } + + init(from decoder: Decoder) throws { + let container = try decoder.container(keyedBy: CodingKeys.self) + self.id = try container.decode(UUID.self, forKey: .id) + self.name = try container.decode(String.self, forKey: .name) + self.prefix = try container.decode(String.self, forKey: .prefix) + self.tokenHash = try container.decode(String.self, forKey: .tokenHash) + self.salt = try container.decode(String.self, forKey: .salt) + self.permissions = try container.decode(TokenPermissions.self, forKey: .permissions) + self.createdAt = try container.decode(Date.self, forKey: .createdAt) + self.lastUsedAt = try container.decodeIfPresent(Date.self, forKey: .lastUsedAt) + self.expiresAt = try container.decodeIfPresent(Date.self, forKey: .expiresAt) + self.isActive = try container.decode(Bool.self, forKey: .isActive) + + if let access = try container.decodeIfPresent(ConnectionAccess.self, forKey: .connectionAccess) { + self.connectionAccess = access + } else if let legacyIds = try container.decodeIfPresent(Set.self, forKey: .allowedConnectionIds) { + self.connectionAccess = .limited(legacyIds) + } else { + self.connectionAccess = .all + } + } + + func encode(to encoder: Encoder) throws { + var container = encoder.container(keyedBy: CodingKeys.self) + try container.encode(id, forKey: .id) + try container.encode(name, forKey: .name) + try container.encode(prefix, forKey: .prefix) + try container.encode(tokenHash, forKey: .tokenHash) + try container.encode(salt, forKey: .salt) + try container.encode(permissions, forKey: .permissions) + try container.encode(connectionAccess, forKey: .connectionAccess) + try container.encode(createdAt, forKey: .createdAt) + try container.encodeIfPresent(lastUsedAt, forKey: .lastUsedAt) + try container.encodeIfPresent(expiresAt, forKey: .expiresAt) + try container.encode(isActive, forKey: .isActive) + } } enum TokenPermissions: String, Codable, Sendable, CaseIterable, Identifiable { @@ -55,6 +152,8 @@ enum TokenPermissions: String, Codable, Sendable, CaseIterable, Identifiable { } actor MCPTokenStore { + static let stdioBridgeTokenName = "__stdio_bridge__" + private static let logger = Logger(subsystem: "com.TablePro", category: "MCPTokenStore") private var tokens: [MCPAuthToken] = [] @@ -65,14 +164,14 @@ actor MCPTokenStore { init() { let appSupportUrl = FileManager.default.urls(for: .applicationSupportDirectory, in: .userDomainMask).first ?? URL(fileURLWithPath: NSHomeDirectory()).appendingPathComponent("Library/Application Support") - let directory = appSupportUrl.appendingPathComponent("com.TablePro") + let directory = appSupportUrl.appendingPathComponent("TablePro") self.storageUrl = directory.appendingPathComponent("mcp-tokens.json") } func generate( name: String, permissions: TokenPermissions, - allowedConnectionIds: Set? = nil, + connectionAccess: ConnectionAccess = .all, expiresAt: Date? = nil ) -> (token: MCPAuthToken, plaintext: String) { let key = SymmetricKey(size: .bits256) @@ -93,7 +192,7 @@ actor MCPTokenStore { tokenHash: hash, salt: saltBase64, permissions: permissions, - allowedConnectionIds: allowedConnectionIds, + connectionAccess: connectionAccess, createdAt: Date.now, lastUsedAt: nil, expiresAt: expiresAt, @@ -180,9 +279,9 @@ actor MCPTokenStore { Self.logger.error("Failed to load MCP tokens: \(error.localizedDescription, privacy: .public)") } - let staleCount = tokens.filter({ $0.name == "__stdio_bridge__" }).count + let staleCount = tokens.filter({ $0.name == Self.stdioBridgeTokenName }).count if staleCount > 0 { - tokens.removeAll { $0.name == "__stdio_bridge__" } + tokens.removeAll { $0.name == Self.stdioBridgeTokenName } save() Self.logger.info("Cleaned up \(staleCount) stale bridge token(s)") } diff --git a/TablePro/Core/MCP/MCPToolHandler+Integrations.swift b/TablePro/Core/MCP/MCPToolHandler+Integrations.swift new file mode 100644 index 000000000..73ebad5e3 --- /dev/null +++ b/TablePro/Core/MCP/MCPToolHandler+Integrations.swift @@ -0,0 +1,335 @@ +// +// MCPToolHandler+Integrations.swift +// TablePro +// + +import AppKit +import Foundation + +extension MCPToolHandler { + func handleListRecentTabs(_ args: JSONValue?, sessionId: String, token: MCPAuthToken?) async throws -> MCPToolResult { + let limit = optionalInt(args, key: "limit", default: 20, clamp: 1...500) + + if let token, !token.permissions.satisfies(.readOnly) { + throw MCPError.forbidden( + "Token '\(token.name)' with permission '\(token.permissions.displayName)' cannot access 'list_recent_tabs'" + ) + } + + let snapshots = await MainActor.run { Self.collectTabSnapshots() } + let blockedConnectionIds = await MainActor.run { Self.blockedExternalConnectionIds() } + let access = token?.connectionAccess ?? .all + let filtered = snapshots.filter { snapshot in + guard !blockedConnectionIds.contains(snapshot.connectionId) else { return false } + return access.allows(snapshot.connectionId) + } + + let trimmed = Array(filtered.prefix(limit)) + let payload = trimmed.map { snapshot -> JSONValue in + var dict: [String: JSONValue] = [ + "connection_id": .string(snapshot.connectionId.uuidString), + "connection_name": .string(snapshot.connectionName), + "tab_id": .string(snapshot.tabId.uuidString), + "tab_type": .string(snapshot.tabType), + "display_title": .string(snapshot.displayTitle), + "is_active": .bool(snapshot.isActive) + ] + if let table = snapshot.tableName { + dict["table_name"] = .string(table) + } + if let database = snapshot.databaseName { + dict["database_name"] = .string(database) + } + if let schema = snapshot.schemaName { + dict["schema_name"] = .string(schema) + } + if let windowId = snapshot.windowId { + dict["window_id"] = .string(windowId.uuidString) + } + return .object(dict) + } + + return MCPToolResult(content: [.text(encodeJSON(.object(["tabs": .array(payload)])))], isError: nil) + } + + func handleSearchQueryHistory(_ args: JSONValue?, sessionId: String, token: MCPAuthToken?) async throws -> MCPToolResult { + let query = try requireString(args, key: "query") + let connectionIdString = optionalString(args, key: "connection_id") + let limit = optionalInt(args, key: "limit", default: 50, clamp: 1...500) + let since = args?["since"]?.doubleValue.map { Date(timeIntervalSince1970: $0) } + let until = args?["until"]?.doubleValue.map { Date(timeIntervalSince1970: $0) } + + if let since, let until, since > until { + throw MCPError.invalidParams("'since' must be less than or equal to 'until'") + } + + if let token, !token.permissions.satisfies(.readOnly) { + throw MCPError.forbidden( + "Token '\(token.name)' with permission '\(token.permissions.displayName)' cannot access 'search_query_history'" + ) + } + + let blockedConnectionIds = await MainActor.run { Self.blockedExternalConnectionIds() } + + let connectionId: UUID? + if let connectionIdString { + guard let parsed = UUID(uuidString: connectionIdString) else { + throw MCPError.invalidParams("Invalid UUID for parameter: connection_id") + } + if let token, !token.connectionAccess.allows(parsed) { + throw MCPError.forbidden("Token does not have access to this connection") + } + if blockedConnectionIds.contains(parsed) { + throw MCPError.forbidden( + String(localized: "External access is disabled for this connection") + ) + } + connectionId = parsed + } else { + connectionId = nil + } + + let tokenScopedAllowlist = await resolveHistoryAllowlist( + token: token, + scopedConnectionId: connectionId, + blockedConnectionIds: blockedConnectionIds + ) + + let entries = await QueryHistoryStorage.shared.fetchHistory( + limit: limit, + offset: 0, + connectionId: connectionId, + searchText: query.isEmpty ? nil : query, + dateFilter: .all, + since: since, + until: until, + allowedConnectionIds: tokenScopedAllowlist + ) + + let payload = entries.map { entry -> JSONValue in + var dict: [String: JSONValue] = [ + "id": .string(entry.id.uuidString), + "query": .string(entry.query), + "connection_id": .string(entry.connectionId.uuidString), + "database_name": .string(entry.databaseName), + "executed_at": .double(entry.executedAt.timeIntervalSince1970), + "execution_time_ms": .double(entry.executionTime * 1_000), + "row_count": .int(entry.rowCount), + "was_successful": .bool(entry.wasSuccessful) + ] + if let error = entry.errorMessage { + dict["error_message"] = .string(error) + } + return .object(dict) + } + + return MCPToolResult(content: [.text(encodeJSON(.object(["entries": .array(payload)])))], isError: nil) + } + + func handleOpenConnectionWindow(_ args: JSONValue?, sessionId: String, token: MCPAuthToken?) async throws -> MCPToolResult { + let connectionId = try requireUUID(args, key: "connection_id") + try await ensureConnectionExists(connectionId) + try await authPolicy.resolveAndAuthorize( + token: token ?? Self.anonymousFullAccessToken, + tool: "open_connection_window", + connectionId: connectionId, + sessionId: sessionId + ) + + let windowId = await MainActor.run { () -> UUID in + let payload = EditorTabPayload( + connectionId: connectionId, + tabType: .query, + intent: .restoreOrDefault + ) + WindowManager.shared.openTab(payload: payload) + NSApp.activate(ignoringOtherApps: true) + return payload.id + } + + let result: JSONValue = .object([ + "status": "opened", + "connection_id": .string(connectionId.uuidString), + "window_id": .string(windowId.uuidString) + ]) + return MCPToolResult(content: [.text(encodeJSON(result))], isError: nil) + } + + func handleOpenTableTab(_ args: JSONValue?, sessionId: String, token: MCPAuthToken?) async throws -> MCPToolResult { + let connectionId = try requireUUID(args, key: "connection_id") + let tableName = try requireString(args, key: "table_name") + let databaseName = optionalString(args, key: "database_name") + let schemaName = optionalString(args, key: "schema_name") + + try await ensureConnectionExists(connectionId) + try await authPolicy.resolveAndAuthorize( + token: token ?? Self.anonymousFullAccessToken, + tool: "open_table_tab", + connectionId: connectionId, + sessionId: sessionId + ) + + let windowId = await MainActor.run { () -> UUID in + let payload = EditorTabPayload( + connectionId: connectionId, + tabType: .table, + tableName: tableName, + databaseName: databaseName, + schemaName: schemaName, + intent: .openContent + ) + WindowManager.shared.openTab(payload: payload) + NSApp.activate(ignoringOtherApps: true) + return payload.id + } + + let result: JSONValue = .object([ + "status": "opened", + "connection_id": .string(connectionId.uuidString), + "table_name": .string(tableName), + "window_id": .string(windowId.uuidString) + ]) + return MCPToolResult(content: [.text(encodeJSON(result))], isError: nil) + } + + func handleFocusQueryTab(_ args: JSONValue?, sessionId: String, token: MCPAuthToken?) async throws -> MCPToolResult { + let tabId = try requireUUID(args, key: "tab_id") + + let resolved = await MainActor.run { () -> (hasWindow: Bool, windowId: UUID?, connectionId: UUID?)? in + for snapshot in Self.collectTabSnapshots() where snapshot.tabId == tabId { + return (snapshot.window != nil, snapshot.windowId, snapshot.connectionId) + } + return nil + } + + guard let resolved, resolved.hasWindow else { + throw MCPError.notFound("tab") + } + + guard let connectionId = resolved.connectionId else { + throw MCPError.notFound("connection") + } + try await authPolicy.resolveAndAuthorize( + token: token ?? Self.anonymousFullAccessToken, + tool: "focus_query_tab", + connectionId: connectionId, + sessionId: sessionId + ) + + let raised = await MainActor.run { () -> Bool in + for snapshot in Self.collectTabSnapshots() where snapshot.tabId == tabId { + guard snapshot.connectionId == connectionId else { return false } + guard let window = snapshot.window else { return false } + NSApp.activate(ignoringOtherApps: true) + window.makeKeyAndOrderFront(nil) + return true + } + return false + } + + guard raised else { + throw MCPError.notFound("tab") + } + + var dict: [String: JSONValue] = [ + "status": "focused", + "tab_id": .string(tabId.uuidString), + "connection_id": .string(connectionId.uuidString) + ] + if let windowId = resolved.windowId { + dict["window_id"] = .string(windowId.uuidString) + } + + return MCPToolResult(content: [.text(encodeJSON(.object(dict)))], isError: nil) + } + + private func resolveHistoryAllowlist( + token: MCPAuthToken?, + scopedConnectionId: UUID?, + blockedConnectionIds: Set + ) async -> Set? { + if scopedConnectionId != nil { + return nil + } + if let access = token?.connectionAccess, case .limited(let allowed) = access { + return allowed.subtracting(blockedConnectionIds) + } + guard !blockedConnectionIds.isEmpty else { return nil } + let allConnectionIds = await MainActor.run { + Set(ConnectionStorage.shared.loadConnections().map(\.id)) + } + return allConnectionIds.subtracting(blockedConnectionIds) + } + + private func ensureConnectionExists(_ connectionId: UUID) async throws { + let exists = await MainActor.run { + ConnectionStorage.shared.loadConnections().contains { $0.id == connectionId } + } + guard exists else { + throw MCPError.notFound("connection") + } + } + + @MainActor + static func collectTabSnapshots() -> [TabSnapshot] { + let connections = ConnectionStorage.shared.loadConnections() + let connectionsById = Dictionary(uniqueKeysWithValues: connections.map { ($0.id, $0) }) + + var snapshots: [TabSnapshot] = [] + for coordinator in MainContentCoordinator.allActiveCoordinators() { + let connectionName = connectionsById[coordinator.connectionId]?.name + ?? coordinator.connection.name + let selectedId = coordinator.tabManager.selectedTabId + for tab in coordinator.tabManager.tabs { + snapshots.append(TabSnapshot( + tabId: tab.id, + connectionId: coordinator.connectionId, + connectionName: connectionName, + tabType: tab.tabType.snapshotName, + tableName: tab.tableContext.tableName, + databaseName: tab.tableContext.databaseName, + schemaName: tab.tableContext.schemaName, + displayTitle: tab.title, + windowId: coordinator.windowId, + isActive: tab.id == selectedId, + window: coordinator.contentWindow + )) + } + } + return snapshots + } + + @MainActor + static func blockedExternalConnectionIds() -> Set { + let connections = ConnectionStorage.shared.loadConnections() + return Set(connections.filter { $0.externalAccess == .blocked }.map(\.id)) + } + +} + +struct TabSnapshot { + let tabId: UUID + let connectionId: UUID + let connectionName: String + let tabType: String + let tableName: String? + let databaseName: String? + let schemaName: String? + let displayTitle: String + let windowId: UUID? + let isActive: Bool + weak var window: NSWindow? +} + +private extension TabType { + var snapshotName: String { + switch self { + case .query: "query" + case .table: "table" + case .createTable: "createTable" + case .erDiagram: "erDiagram" + case .serverDashboard: "serverDashboard" + case .terminal: "terminal" + } + } +} diff --git a/TablePro/Core/MCP/MCPToolHandler.swift b/TablePro/Core/MCP/MCPToolHandler.swift index 3b1e55150..8fa342cae 100644 --- a/TablePro/Core/MCP/MCPToolHandler.swift +++ b/TablePro/Core/MCP/MCPToolHandler.swift @@ -4,12 +4,12 @@ import os final class MCPToolHandler: Sendable { private static let logger = Logger(subsystem: "com.TablePro", category: "MCPToolHandler") - private let bridge: MCPConnectionBridge - private let authGuard: MCPAuthGuard + let bridge: MCPConnectionBridge + let authPolicy: MCPAuthPolicy - init(bridge: MCPConnectionBridge, authGuard: MCPAuthGuard) { + init(bridge: MCPConnectionBridge, authPolicy: MCPAuthPolicy) { self.bridge = bridge - self.authGuard = authGuard + self.authPolicy = authPolicy } func handleToolCall( @@ -18,19 +18,45 @@ final class MCPToolHandler: Sendable { sessionId: String, token: MCPAuthToken? = nil ) async throws -> MCPToolResult { - if let token { - try checkTokenToolPermission(token, toolName: name) + do { + let result = try await dispatchTool( + name: name, + arguments: arguments, + sessionId: sessionId, + token: token + ) + logToolOutcome(name: name, token: token, arguments: arguments, outcome: .success, error: nil) + return result + } catch let error as MCPError { + let outcome: AuditOutcome + if case .forbidden = error { + outcome = .denied + } else { + outcome = .error + } + logToolOutcome(name: name, token: token, arguments: arguments, outcome: outcome, error: error.message) + throw error + } catch { + logToolOutcome(name: name, token: token, arguments: arguments, outcome: .error, error: error.localizedDescription) + throw error } + } + private func dispatchTool( + name: String, + arguments: JSONValue?, + sessionId: String, + token: MCPAuthToken? + ) async throws -> MCPToolResult { switch name { case "list_connections": - return try await handleListConnections() + return try await handleListConnections(token: token) case "connect": return try await handleConnect(arguments, sessionId: sessionId, token: token) case "disconnect": - return try await handleDisconnect(arguments, token: token) + return try await handleDisconnect(arguments, sessionId: sessionId, token: token) case "get_connection_status": - return try await handleGetConnectionStatus(arguments, token: token) + return try await handleGetConnectionStatus(arguments, sessionId: sessionId, token: token) case "execute_query": return try await handleExecuteQuery(arguments, sessionId: sessionId, token: token) case "list_tables": @@ -51,63 +77,114 @@ final class MCPToolHandler: Sendable { return try await handleSwitchDatabase(arguments, sessionId: sessionId, token: token) case "switch_schema": return try await handleSwitchSchema(arguments, sessionId: sessionId, token: token) + case "list_recent_tabs": + return try await handleListRecentTabs(arguments, sessionId: sessionId, token: token) + case "search_query_history": + return try await handleSearchQueryHistory(arguments, sessionId: sessionId, token: token) + case "open_connection_window": + return try await handleOpenConnectionWindow(arguments, sessionId: sessionId, token: token) + case "open_table_tab": + return try await handleOpenTableTab(arguments, sessionId: sessionId, token: token) + case "focus_query_tab": + return try await handleFocusQueryTab(arguments, sessionId: sessionId, token: token) default: throw MCPError.methodNotFound(name) } } - private func checkTokenToolPermission(_ token: MCPAuthToken, toolName: String) throws { - let required = minimumPermission(for: toolName) - guard token.permissions.satisfies(required) else { - throw MCPError.forbidden( - "Token '\(token.name)' with permission '\(token.permissions.displayName)' " - + "cannot access '\(toolName)'" - ) - } + private func logToolOutcome( + name: String, + token: MCPAuthToken?, + arguments: JSONValue?, + outcome: AuditOutcome, + error: String? + ) { + let connectionId = arguments?["connection_id"]?.stringValue.flatMap(UUID.init(uuidString:)) + MCPAuditLogger.logToolCalled( + tokenId: token?.id, + tokenName: token?.name, + toolName: name, + connectionId: connectionId, + outcome: outcome, + errorMessage: error + ) } - private func minimumPermission(for toolName: String) -> TokenPermissions { - switch toolName { - case "confirm_destructive_operation": - return .fullAccess - case "switch_database", "switch_schema", "export_data": - return .readWrite - default: - return .readOnly - } + private func authorize( + token: MCPAuthToken?, + tool: String, + connectionId: UUID?, + sql: String? = nil, + sessionId: String + ) async throws { + try await authPolicy.resolveAndAuthorize( + token: token ?? Self.anonymousFullAccessToken, + tool: tool, + connectionId: connectionId, + sql: sql, + sessionId: sessionId + ) } - private func checkTokenConnectionAccess(_ token: MCPAuthToken, connectionId: UUID) throws { - guard let allowed = token.allowedConnectionIds else { return } - guard allowed.contains(connectionId) else { - throw MCPError.forbidden("Token does not have access to this connection") - } + static let anonymousFullAccessToken: MCPAuthToken = MCPAuthToken( + id: UUID(), + name: "__anonymous__", + prefix: "tp_anon", + tokenHash: "", + salt: "", + permissions: .fullAccess, + connectionAccess: .all, + createdAt: Date.now, + lastUsedAt: nil, + expiresAt: nil, + isActive: true + ) + + private func handleListConnections(token: MCPAuthToken?) async throws -> MCPToolResult { + let result = await bridge.listConnections() + let filtered = filterConnectionsByToken(result, token: token) + return MCPToolResult(content: [.text(encodeJSON(filtered))], isError: nil) } - private func handleListConnections() async throws -> MCPToolResult { - let result = await bridge.listConnections() - return MCPToolResult(content: [.text(encodeJSON(result))], isError: nil) + private func filterConnectionsByToken(_ value: JSONValue, token: MCPAuthToken?) -> JSONValue { + guard let access = token?.connectionAccess, case .limited(let allowed) = access else { + return value + } + guard case .object(var dict) = value, + let entries = dict["connections"]?.arrayValue + else { + return value + } + let filtered = entries.filter { entry in + guard let idString = entry["id"]?.stringValue, + let id = UUID(uuidString: idString) + else { + return false + } + return allowed.contains(id) + } + dict["connections"] = .array(filtered) + return .object(dict) } private func handleConnect(_ args: JSONValue?, sessionId: String, token: MCPAuthToken?) async throws -> MCPToolResult { let connectionId = try requireUUID(args, key: "connection_id") - if let token { try checkTokenConnectionAccess(token, connectionId: connectionId) } - try await authGuard.checkConnectionAccess(connectionId: connectionId, sessionId: sessionId) + try await authorize(token: token, tool: "connect", connectionId: connectionId, sessionId: sessionId) let result = try await bridge.connect(connectionId: connectionId) return MCPToolResult(content: [.text(encodeJSON(result))], isError: nil) } - private func handleDisconnect(_ args: JSONValue?, token: MCPAuthToken?) async throws -> MCPToolResult { + private func handleDisconnect(_ args: JSONValue?, sessionId: String, token: MCPAuthToken?) async throws -> MCPToolResult { let connectionId = try requireUUID(args, key: "connection_id") - if let token { try checkTokenConnectionAccess(token, connectionId: connectionId) } + try await authorize(token: token, tool: "disconnect", connectionId: connectionId, sessionId: sessionId) try await bridge.disconnect(connectionId: connectionId) let result: JSONValue = .object(["status": "disconnected"]) return MCPToolResult(content: [.text(encodeJSON(result))], isError: nil) } - private func handleGetConnectionStatus(_ args: JSONValue?, token: MCPAuthToken?) async throws -> MCPToolResult { + private func handleGetConnectionStatus(_ args: JSONValue?, sessionId: String, token: MCPAuthToken?) async throws -> MCPToolResult { let connectionId = try requireUUID(args, key: "connection_id") - if let token { try checkTokenConnectionAccess(token, connectionId: connectionId) } + try await authorize(token: token, tool: "get_connection_status", connectionId: connectionId, sessionId: sessionId) let result = try await bridge.getConnectionStatus(connectionId: connectionId) return MCPToolResult(content: [.text(encodeJSON(result))], isError: nil) } @@ -129,8 +206,13 @@ final class MCPToolHandler: Sendable { throw MCPError.invalidParams("Multi-statement queries are not supported. Send one statement at a time.") } - if let token { try checkTokenConnectionAccess(token, connectionId: connectionId) } - try await authGuard.checkConnectionAccess(connectionId: connectionId, sessionId: sessionId) + try await authorize( + token: token, + tool: "execute_query", + connectionId: connectionId, + sql: query, + sessionId: sessionId + ) let (databaseType, safeModeLevel, databaseName) = try await resolveConnectionMeta(connectionId) @@ -150,11 +232,21 @@ final class MCPToolHandler: Sendable { + "Use the confirm_destructive_operation tool instead." ) - case .write, .safe: - if let token { - try checkTokenQueryTierPermission(token, tier: tier) + case .write: + if let token, !token.permissions.satisfies(.readWrite) { + throw MCPError.forbidden( + "Token '\(token.name)' with '\(token.permissions.displayName)' permission cannot execute write queries" + ) } - try await authGuard.checkQueryPermission( + try await authPolicy.checkSafeModeDialog( + sql: query, + connectionId: connectionId, + databaseType: databaseType, + safeModeLevel: safeModeLevel + ) + + case .safe: + try await authPolicy.checkSafeModeDialog( sql: query, connectionId: connectionId, databaseType: databaseType, @@ -167,31 +259,13 @@ final class MCPToolHandler: Sendable { connectionId: connectionId, databaseName: databaseName, maxRows: maxRows, - timeoutSeconds: timeoutSeconds + timeoutSeconds: timeoutSeconds, + token: token ) return MCPToolResult(content: [.text(encodeJSON(result))], isError: nil) } - private func checkTokenQueryTierPermission(_ token: MCPAuthToken, tier: QueryTier) throws { - switch tier { - case .safe: - return - case .write: - guard token.permissions.satisfies(.readWrite) else { - throw MCPError.forbidden( - "Token '\(token.name)' with '\(token.permissions.displayName)' permission cannot execute write queries" - ) - } - case .destructive: - guard token.permissions == .fullAccess else { - throw MCPError.forbidden( - "Token '\(token.name)' with '\(token.permissions.displayName)' permission cannot execute destructive queries" - ) - } - } - } - private func handleConfirmDestructiveOperation( _ args: JSONValue?, sessionId: String, @@ -213,8 +287,13 @@ final class MCPToolHandler: Sendable { ) } - if let token { try checkTokenConnectionAccess(token, connectionId: connectionId) } - try await authGuard.checkConnectionAccess(connectionId: connectionId, sessionId: sessionId) + try await authorize( + token: token, + tool: "confirm_destructive_operation", + connectionId: connectionId, + sql: query, + sessionId: sessionId + ) let (databaseType, safeModeLevel, databaseName) = try await resolveConnectionMeta(connectionId) @@ -226,7 +305,7 @@ final class MCPToolHandler: Sendable { ) } - try await authGuard.checkQueryPermission( + try await authPolicy.checkSafeModeDialog( sql: query, connectionId: connectionId, databaseType: databaseType, @@ -241,7 +320,8 @@ final class MCPToolHandler: Sendable { connectionId: connectionId, databaseName: databaseName, maxRows: 0, - timeoutSeconds: timeoutSeconds + timeoutSeconds: timeoutSeconds, + token: token ) return MCPToolResult(content: [.text(encodeJSON(result))], isError: nil) @@ -253,8 +333,7 @@ final class MCPToolHandler: Sendable { let database = optionalString(args, key: "database") let schema = optionalString(args, key: "schema") - if let token { try checkTokenConnectionAccess(token, connectionId: connectionId) } - try await authGuard.checkConnectionAccess(connectionId: connectionId, sessionId: sessionId) + try await authorize(token: token, tool: "list_tables", connectionId: connectionId, sessionId: sessionId) if let database { _ = try await bridge.switchDatabase(connectionId: connectionId, database: database) @@ -272,8 +351,7 @@ final class MCPToolHandler: Sendable { let table = try requireString(args, key: "table") let schema = optionalString(args, key: "schema") - if let token { try checkTokenConnectionAccess(token, connectionId: connectionId) } - try await authGuard.checkConnectionAccess(connectionId: connectionId, sessionId: sessionId) + try await authorize(token: token, tool: "describe_table", connectionId: connectionId, sessionId: sessionId) let result = try await bridge.describeTable(connectionId: connectionId, table: table, schema: schema) return MCPToolResult(content: [.text(encodeJSON(result))], isError: nil) @@ -281,8 +359,7 @@ final class MCPToolHandler: Sendable { private func handleListDatabases(_ args: JSONValue?, sessionId: String, token: MCPAuthToken?) async throws -> MCPToolResult { let connectionId = try requireUUID(args, key: "connection_id") - if let token { try checkTokenConnectionAccess(token, connectionId: connectionId) } - try await authGuard.checkConnectionAccess(connectionId: connectionId, sessionId: sessionId) + try await authorize(token: token, tool: "list_databases", connectionId: connectionId, sessionId: sessionId) let result = try await bridge.listDatabases(connectionId: connectionId) return MCPToolResult(content: [.text(encodeJSON(result))], isError: nil) } @@ -291,8 +368,7 @@ final class MCPToolHandler: Sendable { let connectionId = try requireUUID(args, key: "connection_id") let database = optionalString(args, key: "database") - if let token { try checkTokenConnectionAccess(token, connectionId: connectionId) } - try await authGuard.checkConnectionAccess(connectionId: connectionId, sessionId: sessionId) + try await authorize(token: token, tool: "list_schemas", connectionId: connectionId, sessionId: sessionId) if let database { _ = try await bridge.switchDatabase(connectionId: connectionId, database: database) @@ -307,8 +383,7 @@ final class MCPToolHandler: Sendable { let table = try requireString(args, key: "table") let schema = optionalString(args, key: "schema") - if let token { try checkTokenConnectionAccess(token, connectionId: connectionId) } - try await authGuard.checkConnectionAccess(connectionId: connectionId, sessionId: sessionId) + try await authorize(token: token, tool: "get_table_ddl", connectionId: connectionId, sessionId: sessionId) let result = try await bridge.getTableDDL(connectionId: connectionId, table: table, schema: schema) return MCPToolResult(content: [.text(encodeJSON(result))], isError: nil) @@ -330,14 +405,29 @@ final class MCPToolHandler: Sendable { throw MCPError.invalidParams("Either 'query' or 'tables' must be provided") } - if let token { try checkTokenConnectionAccess(token, connectionId: connectionId) } - try await authGuard.checkConnectionAccess(connectionId: connectionId, sessionId: sessionId) + if let tables { + for table in tables { + try Self.validateExportTableName(table) + } + } + if let outputPath { + _ = try Self.sandboxedDownloadsURL(for: outputPath) + } + + try await authorize( + token: token, + tool: "export_data", + connectionId: connectionId, + sql: query, + sessionId: sessionId + ) + + let (databaseType, safeModeLevel, _) = try await resolveConnectionMeta(connectionId) var queries: [(label: String, sql: String)] = [] if let query { - let (databaseType, safeModeLevel, _) = try await resolveConnectionMeta(connectionId) - try await authGuard.checkQueryPermission( + try await authPolicy.checkSafeModeDialog( sql: query, connectionId: connectionId, databaseType: databaseType, @@ -345,8 +435,17 @@ final class MCPToolHandler: Sendable { ) queries.append((label: "query", sql: query)) } else if let tables { + let quoteIdentifier = Self.identifierQuoter(for: databaseType) for table in tables { - queries.append((label: table, sql: "SELECT * FROM \(table) LIMIT \(maxRows)")) + let quoted = try Self.quoteQualifiedIdentifier(table, quoter: quoteIdentifier) + let sql = "SELECT * FROM \(quoted) LIMIT \(maxRows)" + try await authPolicy.checkSafeModeDialog( + sql: sql, + connectionId: connectionId, + databaseType: databaseType, + safeModeLevel: safeModeLevel + ) + queries.append((label: table, sql: sql)) } } @@ -392,6 +491,8 @@ final class MCPToolHandler: Sendable { } if let outputPath { + let fileURL = try Self.sandboxedDownloadsURL(for: outputPath) + let fullContent: String if exportResults.count == 1, let data = exportResults.first?["data"]?.stringValue @@ -401,11 +502,10 @@ final class MCPToolHandler: Sendable { fullContent = exportResults.compactMap { $0["data"]?.stringValue }.joined(separator: "\n\n") } - let fileURL = URL(fileURLWithPath: outputPath) try fullContent.write(to: fileURL, atomically: true, encoding: .utf8) let response: JSONValue = .object([ - "path": .string(outputPath), + "path": .string(fileURL.path), "rows_exported": .int(totalRowsExported) ]) return MCPToolResult(content: [.text(encodeJSON(response))], isError: nil) @@ -425,8 +525,7 @@ final class MCPToolHandler: Sendable { let connectionId = try requireUUID(args, key: "connection_id") let database = try requireString(args, key: "database") - if let token { try checkTokenConnectionAccess(token, connectionId: connectionId) } - try await authGuard.checkConnectionAccess(connectionId: connectionId, sessionId: sessionId) + try await authorize(token: token, tool: "switch_database", connectionId: connectionId, sessionId: sessionId) let result = try await bridge.switchDatabase(connectionId: connectionId, database: database) return MCPToolResult(content: [.text(encodeJSON(result))], isError: nil) @@ -436,8 +535,7 @@ final class MCPToolHandler: Sendable { let connectionId = try requireUUID(args, key: "connection_id") let schema = try requireString(args, key: "schema") - if let token { try checkTokenConnectionAccess(token, connectionId: connectionId) } - try await authGuard.checkConnectionAccess(connectionId: connectionId, sessionId: sessionId) + try await authorize(token: token, tool: "switch_schema", connectionId: connectionId, sessionId: sessionId) let result = try await bridge.switchSchema(connectionId: connectionId, schema: schema) return MCPToolResult(content: [.text(encodeJSON(result))], isError: nil) @@ -448,7 +546,8 @@ final class MCPToolHandler: Sendable { connectionId: UUID, databaseName: String, maxRows: Int, - timeoutSeconds: Int + timeoutSeconds: Int, + token: MCPAuthToken? = nil ) async throws -> JSONValue { let startTime = Date() do { @@ -459,19 +558,29 @@ final class MCPToolHandler: Sendable { timeoutSeconds: timeoutSeconds ) let elapsed = Date().timeIntervalSince(startTime) - await authGuard.logQuery( + let rowCount = result["row_count"]?.intValue ?? 0 + await authPolicy.logQuery( sql: query, connectionId: connectionId, databaseName: databaseName, executionTime: elapsed, - rowCount: result["row_count"]?.intValue ?? 0, + rowCount: rowCount, wasSuccessful: true, errorMessage: nil ) + MCPAuditLogger.logQueryExecuted( + tokenId: token?.id, + tokenName: token?.name, + connectionId: connectionId, + sql: query, + durationMs: Int(elapsed * 1_000), + rowCount: rowCount, + outcome: .success + ) return result } catch { let elapsed = Date().timeIntervalSince(startTime) - await authGuard.logQuery( + await authPolicy.logQuery( sql: query, connectionId: connectionId, databaseName: databaseName, @@ -480,11 +589,21 @@ final class MCPToolHandler: Sendable { wasSuccessful: false, errorMessage: error.localizedDescription ) + MCPAuditLogger.logQueryExecuted( + tokenId: token?.id, + tokenName: token?.name, + connectionId: connectionId, + sql: query, + durationMs: Int(elapsed * 1_000), + rowCount: 0, + outcome: .error, + errorMessage: error.localizedDescription + ) throw error } } - private func requireUUID(_ args: JSONValue?, key: String) throws -> UUID { + func requireUUID(_ args: JSONValue?, key: String) throws -> UUID { guard let value = args?[key]?.stringValue else { throw MCPError.invalidParams("Missing required parameter: \(key)") } @@ -494,18 +613,18 @@ final class MCPToolHandler: Sendable { return uuid } - private func requireString(_ args: JSONValue?, key: String) throws -> String { + func requireString(_ args: JSONValue?, key: String) throws -> String { guard let value = args?[key]?.stringValue else { throw MCPError.invalidParams("Missing required parameter: \(key)") } return value } - private func optionalString(_ args: JSONValue?, key: String) -> String? { + func optionalString(_ args: JSONValue?, key: String) -> String? { args?[key]?.stringValue } - private func optionalInt(_ args: JSONValue?, key: String, default defaultValue: Int, clamp range: ClosedRange) -> Int { + func optionalInt(_ args: JSONValue?, key: String, default defaultValue: Int, clamp range: ClosedRange) -> Int { guard let value = args?[key]?.intValue else { return defaultValue } return min(max(value, range.lowerBound), range.upperBound) } @@ -522,14 +641,60 @@ final class MCPToolHandler: Sendable { private func resolveConnectionMeta(_ connectionId: UUID) async throws -> (DatabaseType, SafeModeLevel, String) { try await MainActor.run { - guard let session = DatabaseManager.shared.activeSessions[connectionId] else { + switch DatabaseManager.shared.connectionState(connectionId) { + case .live(_, let session): + return (session.connection.type, session.connection.safeModeLevel, session.activeDatabase) + case .stored(let conn): + return (conn.type, conn.safeModeLevel, conn.database) + case .unknown: throw MCPError.notConnected(connectionId) } - return (session.connection.type, session.connection.safeModeLevel, session.activeDatabase) } } - private func encodeJSON(_ value: JSONValue) -> String { + static func validateExportTableName(_ table: String) throws { + let pattern = "^[A-Za-z0-9_]+(\\.[A-Za-z0-9_]+)*$" + guard table.range(of: pattern, options: .regularExpression) != nil else { + throw MCPError.invalidParams( + "Invalid table name: '\(table)'. Allowed characters: letters, digits, underscore, and '.' for schema-qualified names." + ) + } + } + + static func identifierQuoter(for databaseType: DatabaseType) -> (String) -> String { + if let dialect = try? resolveSQLDialect(for: databaseType) { + return quoteIdentifierFromDialect(dialect) + } + return { "\"\($0.replacingOccurrences(of: "\"", with: "\"\""))\"" } + } + + static func quoteQualifiedIdentifier(_ identifier: String, quoter: (String) -> String) throws -> String { + let segments = identifier.split(separator: ".", omittingEmptySubsequences: true) + guard !segments.isEmpty, segments.count == identifier.split(separator: ".", omittingEmptySubsequences: false).count else { + throw MCPError.invalidParams( + "Invalid qualified identifier: '\(identifier)'. Empty components are not allowed." + ) + } + return segments.map { quoter(String($0)) }.joined(separator: ".") + } + + static func sandboxedDownloadsURL(for path: String) throws -> URL { + guard let downloads = FileManager.default.urls(for: .downloadsDirectory, in: .userDomainMask).first else { + throw MCPError.invalidParams("Downloads directory is not available") + } + let downloadsRoot = downloads.standardizedFileURL.resolvingSymlinksInPath().path + let candidate = path.hasPrefix("/") ? URL(fileURLWithPath: path) : downloads.appendingPathComponent(path) + let resolvedPath = candidate.standardizedFileURL.resolvingSymlinksInPath().path + let prefix = downloadsRoot.hasSuffix("/") ? downloadsRoot : downloadsRoot + "/" + guard resolvedPath == downloadsRoot || resolvedPath.hasPrefix(prefix) else { + throw MCPError.invalidParams( + "output_path must be inside the Downloads directory (\(downloadsRoot))" + ) + } + return URL(fileURLWithPath: resolvedPath) + } + + func encodeJSON(_ value: JSONValue) -> String { let encoder = JSONEncoder() encoder.outputFormatting = [.sortedKeys] guard let data = try? encoder.encode(value), diff --git a/TablePro/Core/MCP/PairingTypes.swift b/TablePro/Core/MCP/PairingTypes.swift new file mode 100644 index 000000000..c54345d24 --- /dev/null +++ b/TablePro/Core/MCP/PairingTypes.swift @@ -0,0 +1,14 @@ +import Foundation + +struct PairingRequest: Sendable, Equatable { + let clientName: String + let challenge: String + let redirectURL: URL + let requestedScopes: String? + let requestedConnectionIds: Set? +} + +struct PairingExchange: Sendable, Equatable { + let code: String + let verifier: String +} diff --git a/TablePro/Core/MCP/Routes/IntegrationsExchangeHandler.swift b/TablePro/Core/MCP/Routes/IntegrationsExchangeHandler.swift new file mode 100644 index 000000000..794494a02 --- /dev/null +++ b/TablePro/Core/MCP/Routes/IntegrationsExchangeHandler.swift @@ -0,0 +1,94 @@ +import Foundation +import os + +struct IntegrationsExchangeHandler: MCPRouteHandler { + private static let logger = Logger(subsystem: "com.TablePro", category: "IntegrationsExchangeHandler") + + private let exchange: @Sendable (PairingExchange) async throws -> String + + private let encoder: JSONEncoder + private let decoder: JSONDecoder + + var methods: [HTTPRequest.Method] { [.post] } + var path: String { "/v1/integrations/exchange" } + + init(exchange: @escaping @Sendable (PairingExchange) async throws -> String) { + self.exchange = exchange + let enc = JSONEncoder() + enc.outputFormatting = [.sortedKeys] + self.encoder = enc + self.decoder = JSONDecoder() + } + + static func live() -> IntegrationsExchangeHandler { + IntegrationsExchangeHandler { request in + try await MainActor.run { + try MCPPairingService.shared.exchange(request) + } + } + } + + func handle(_ request: HTTPRequest) async -> MCPRouter.RouteResult { + guard let body = request.body else { + return .httpError(status: 400, message: "Missing request body") + } + + let parsed: ExchangeRequestBody + do { + parsed = try decoder.decode(ExchangeRequestBody.self, from: body) + } catch { + return .httpError(status: 400, message: "Invalid JSON body") + } + + guard !parsed.code.isEmpty, !parsed.codeVerifier.isEmpty else { + return .httpError(status: 400, message: "Missing code or code_verifier") + } + + let token: String + do { + token = try await exchange( + PairingExchange(code: parsed.code, verifier: parsed.codeVerifier) + ) + } catch let mcpError as MCPError { + return Self.mapExchangeError(mcpError) + } catch { + Self.logger.error("Pairing exchange failed: \(error.localizedDescription)") + return .httpError(status: 500, message: "Internal error") + } + + do { + let data = try encoder.encode(ExchangeResponseBody(token: token)) + return .json(data, sessionId: nil) + } catch { + Self.logger.error("Failed to encode exchange response: \(error.localizedDescription)") + return .httpError(status: 500, message: "Internal error") + } + } + + private static func mapExchangeError(_ error: MCPError) -> MCPRouter.RouteResult { + switch error { + case .notFound: + return .httpError(status: 404, message: "Pairing code not found") + case .expired: + return .httpError(status: 410, message: "Pairing code expired") + case .forbidden: + return .httpError(status: 403, message: "Challenge mismatch") + default: + return .httpError(status: 500, message: "Internal error") + } + } + + private struct ExchangeRequestBody: Decodable { + let code: String + let codeVerifier: String + + enum CodingKeys: String, CodingKey { + case code + case codeVerifier = "code_verifier" + } + } + + private struct ExchangeResponseBody: Encodable { + let token: String + } +} diff --git a/TablePro/Core/MCP/Routes/MCPProtocolHandler.swift b/TablePro/Core/MCP/Routes/MCPProtocolHandler.swift new file mode 100644 index 000000000..dac72ede6 --- /dev/null +++ b/TablePro/Core/MCP/Routes/MCPProtocolHandler.swift @@ -0,0 +1,533 @@ +import Foundation +import os + +final class MCPProtocolHandler: MCPRouteHandler, @unchecked Sendable { + private static let logger = Logger(subsystem: "com.TablePro", category: "MCPProtocolHandler") + + private weak var server: MCPServer? + private let tokenStore: MCPTokenStore? + private let rateLimiter: MCPRateLimiter? + + private let encoder: JSONEncoder + private let decoder: JSONDecoder + + var methods: [HTTPRequest.Method] { [.get, .post, .delete] } + var path: String { "/mcp" } + + init(server: MCPServer, tokenStore: MCPTokenStore?, rateLimiter: MCPRateLimiter?) { + self.server = server + self.tokenStore = tokenStore + self.rateLimiter = rateLimiter + let enc = JSONEncoder() + enc.outputFormatting = [.sortedKeys] + self.encoder = enc + self.decoder = JSONDecoder() + } + + func handle(_ request: HTTPRequest) async -> MCPRouter.RouteResult { + guard let server else { + return .httpError(status: 503, message: "Server unavailable") + } + + if let rateLimiter, let ip = request.remoteIP { + let lockoutCheck = await rateLimiter.isLockedOut(ip: ip) + if case .rateLimited(let retryAfter) = lockoutCheck { + let seconds = Int(retryAfter.components.seconds) + MCPAuditLogger.logRateLimited(ip: ip, retryAfterSeconds: seconds) + return .httpErrorWithHeaders( + status: 429, + message: "Too many failed attempts", + extraHeaders: [("Retry-After", "\(seconds)")] + ) + } + } + + let authResult = await authenticateRequest(request) + + switch authResult { + case .failure(let result): + return result + case .success(let token): + if token == nil { + if let origin = request.headers["origin"], !isAllowedOrigin(origin) { + return .httpError(status: 403, message: "Forbidden origin") + } + } + + switch request.method { + case .post: + return await handlePost(request, server: server, authenticatedToken: token) + case .get: + return await handleGet(request, server: server) + case .delete: + return await handleDelete(request, server: server) + case .options: + return .noContent + } + } + } + + private enum AuthResult { + case success(MCPAuthToken?) + case failure(MCPRouter.RouteResult) + } + + private func authenticateRequest(_ request: HTTPRequest) async -> AuthResult { + let remoteIP = request.remoteIP + let authRequired = await MainActor.run { AppSettingsManager.shared.mcp.requireAuthentication } + + guard let authHeader = request.headers["authorization"] else { + guard !authRequired else { + MCPAuditLogger.logAuthFailure(reason: "Missing authorization header", ip: remoteIP ?? "localhost") + return .failure(.httpErrorWithHeaders( + status: 401, + message: "Authentication required", + extraHeaders: [("WWW-Authenticate", "Bearer realm=\"TablePro MCP\"")] + )) + } + return .success(nil) + } + + guard authHeader.lowercased().hasPrefix("bearer "), let tokenStore else { + let rateLimitResult = await recordAuthFailure(ip: remoteIP) + if case .rateLimited(let retryAfter) = rateLimitResult { + let seconds = Int(retryAfter.components.seconds) + MCPAuditLogger.logRateLimited(ip: remoteIP ?? "localhost", retryAfterSeconds: seconds) + return .failure(.httpErrorWithHeaders( + status: 429, + message: "Too many failed attempts", + extraHeaders: [("Retry-After", "\(seconds)")] + )) + } + MCPAuditLogger.logAuthFailure(reason: "Invalid authorization header format", ip: remoteIP ?? "localhost") + return .failure(.httpErrorWithHeaders( + status: 401, + message: "Invalid authorization header", + extraHeaders: [("WWW-Authenticate", "Bearer realm=\"TablePro MCP\"")] + )) + } + + let bearerToken = String(authHeader.dropFirst(7)) + + guard let token = await tokenStore.validate(bearerToken: bearerToken) else { + let rateLimitResult = await recordAuthFailure(ip: remoteIP) + if case .rateLimited(let retryAfter) = rateLimitResult { + let seconds = Int(retryAfter.components.seconds) + MCPAuditLogger.logRateLimited(ip: remoteIP ?? "localhost", retryAfterSeconds: seconds) + return .failure(.httpErrorWithHeaders( + status: 429, + message: "Too many failed attempts", + extraHeaders: [("Retry-After", "\(seconds)")] + )) + } + MCPAuditLogger.logAuthFailure(reason: "Invalid token", ip: remoteIP ?? "localhost") + return .failure(.httpErrorWithHeaders( + status: 401, + message: "Invalid or expired token", + extraHeaders: [("WWW-Authenticate", "Bearer realm=\"TablePro MCP\"")] + )) + } + + if let rateLimiter, let ip = remoteIP { + _ = await rateLimiter.checkAndRecord(ip: ip, success: true) + } + MCPAuditLogger.logAuthSuccess(tokenName: token.name, ip: remoteIP ?? "localhost") + return .success(token) + } + + @discardableResult + private func recordAuthFailure(ip: String?) async -> MCPRateLimiter.AuthRateResult? { + guard let rateLimiter, let ip else { return nil } + return await rateLimiter.checkAndRecord(ip: ip, success: false) + } + + private func isAllowedOrigin(_ origin: String) -> Bool { + guard let components = URLComponents(string: origin), + let host = components.host + else { + return false + } + let allowedHosts: Set = ["localhost", "127.0.0.1", "::1"] + return allowedHosts.contains(host) + } + + private func handleGet(_ request: HTTPRequest, server: MCPServer) async -> MCPRouter.RouteResult { + guard let sessionId = request.headers["mcp-session-id"] else { + return .httpError(status: 400, message: "Missing Mcp-Session-Id header") + } + + guard let session = await server.session(for: sessionId) else { + return .httpError(status: 404, message: "Session not found") + } + + await session.markActive() + return .sseStream(sessionId: session.id) + } + + private func handleDelete(_ request: HTTPRequest, server: MCPServer) async -> MCPRouter.RouteResult { + guard let sessionId = request.headers["mcp-session-id"] else { + return .httpError(status: 400, message: "Missing Mcp-Session-Id header") + } + + guard await server.session(for: sessionId) != nil else { + return .httpError(status: 404, message: "Session not found") + } + + await server.removeSession(sessionId) + Self.logger.info("Session terminated via DELETE: \(sessionId)") + return .noContent + } + + private func handlePost( + _ request: HTTPRequest, + server: MCPServer, + authenticatedToken: MCPAuthToken? + ) async -> MCPRouter.RouteResult { + if let accept = request.headers["accept"], !accept.contains("application/json") && !accept.contains("*/*") { + return .httpError(status: 406, message: "Accept header must include application/json") + } + + guard let body = request.body else { + return encodeError(MCPError.parseError, id: nil) + } + + let rpcRequest: JSONRPCRequest + do { + rpcRequest = try decoder.decode(JSONRPCRequest.self, from: body) + } catch { + return encodeError(MCPError.parseError, id: nil) + } + + guard rpcRequest.jsonrpc == "2.0" else { + return encodeError(MCPError.invalidRequest("jsonrpc must be \"2.0\""), id: rpcRequest.id) + } + + if let protocolVersion = request.headers["mcp-protocol-version"], + protocolVersion != "2025-03-26" + { + Self.logger.warning("Client mcp-protocol-version mismatch: \(protocolVersion)") + } + + let headerSessionId = request.headers["mcp-session-id"] + return await dispatchMethod( + rpcRequest, + headerSessionId: headerSessionId, + server: server, + authenticatedToken: authenticatedToken + ) + } + + private func dispatchMethod( + _ request: JSONRPCRequest, + headerSessionId: String?, + server: MCPServer, + authenticatedToken: MCPAuthToken? + ) async -> MCPRouter.RouteResult { + if request.method == "initialize" { + return await handleInitialize(request, server: server) + } + + if request.method == "ping" { + return handlePing(request) + } + + guard let sessionId = headerSessionId else { + return .httpError(status: 400, message: "Missing Mcp-Session-Id header") + } + guard let session = await server.session(for: sessionId) else { + return .httpError(status: 404, message: "Session not found") + } + + await session.markActive() + + if request.method == "notifications/initialized" { + do { + try await session.transition(to: .active( + tokenId: authenticatedToken?.id, + tokenName: authenticatedToken?.name + )) + } catch { + return encodeError(MCPError.invalidRequest("Cannot initialize session in current phase"), id: request.id) + } + return .accepted + } + + if request.method == "notifications/cancelled" { + return await handleCancellation(request, session: session) + } + + guard await session.phase.isActive else { + return encodeError( + MCPError.invalidRequest("Session not initialized. Send notifications/initialized first."), + id: request.id + ) + } + + switch request.method { + case "tools/list": + return handleToolsList(request, sessionId: sessionId) + + case "tools/call": + return await handleToolsCall( + request, + sessionId: sessionId, + server: server, + authenticatedToken: authenticatedToken + ) + + case "resources/list": + return handleResourcesList(request, sessionId: sessionId) + + case "resources/read": + return await handleResourcesRead(request, sessionId: sessionId, server: server) + + default: + return encodeError(MCPError.methodNotFound(request.method), id: request.id) + } + } + + private func handleInitialize( + _ request: JSONRPCRequest, + server: MCPServer + ) async -> MCPRouter.RouteResult { + guard let session = await server.createSession() else { + return encodeError(MCPError.internalError("Maximum sessions reached"), id: request.id) + } + + if let params = request.params, + let clientInfo = params["clientInfo"], + let name = clientInfo["name"]?.stringValue + { + let version = clientInfo["version"]?.stringValue + await session.setClientInfo(MCPClientInfo(name: name, version: version)) + } + + do { + try await session.transition(to: .initializing) + } catch { + await server.removeSession(session.id) + return encodeError(MCPError.invalidRequest("Cannot initialize session"), id: request.id) + } + + let result = MCPInitializeResult( + protocolVersion: "2025-03-26", + capabilities: MCPServerCapabilities( + tools: .init(listChanged: false), + resources: .init(subscribe: false, listChanged: false) + ), + serverInfo: MCPServerInfo(name: "tablepro", version: "1.0.0") + ) + + return encodeResult(result, id: request.id, sessionId: session.id) + } + + private func handlePing(_ request: JSONRPCRequest) -> MCPRouter.RouteResult { + guard let id = request.id else { + return .accepted + } + return encodeRawResult(.object([:]), id: id, sessionId: nil) + } + + private func handleCancellation( + _ request: JSONRPCRequest, + session: MCPSession + ) async -> MCPRouter.RouteResult { + guard let params = request.params, + let requestIdValue = params["requestId"] + else { + return .accepted + } + + let cancelId: JSONRPCId? + switch requestIdValue { + case .string(let s): + cancelId = .string(s) + case .int(let i): + cancelId = .int(i) + default: + cancelId = nil + } + + if let cancelId, let task = await session.removeRunningTask(cancelId) { + task.cancel() + Self.logger.info("Cancelled request \(String(describing: cancelId)) in session \(session.id)") + } + + return .accepted + } + + private func handleToolsList(_ request: JSONRPCRequest, sessionId: String) -> MCPRouter.RouteResult { + guard let id = request.id else { + return .accepted + } + + let tools = MCPRouter.toolDefinitions() + let result: JSONValue = .object(["tools": encodeToolDefinitions(tools)]) + return encodeRawResult(result, id: id, sessionId: sessionId) + } + + private func handleToolsCall( + _ request: JSONRPCRequest, + sessionId: String, + server: MCPServer, + authenticatedToken: MCPAuthToken? + ) async -> MCPRouter.RouteResult { + guard let id = request.id else { + return encodeError(MCPError.invalidRequest("tools/call requires an id"), id: nil) + } + + guard let params = request.params, + let name = params["name"]?.stringValue + else { + return encodeError(MCPError.invalidParams("Missing tool name"), id: id) + } + + let arguments = params["arguments"] + + guard let handler = await server.toolCallHandler else { + return encodeError(MCPError.internalError("Server not fully initialized"), id: id) + } + + let session = await server.session(for: sessionId) + let toolTask = Task { + try await handler(name, arguments, sessionId, authenticatedToken) + } + if let session { + let cancelForwardingTask = Task { + await withTaskCancellationHandler { + _ = try? await toolTask.value + } onCancel: { + toolTask.cancel() + } + } + await session.addRunningTask(id, task: cancelForwardingTask) + } + + do { + let toolResult = try await toolTask.value + if let session { _ = await session.removeRunningTask(id) } + let resultData = try encoder.encode(toolResult) + guard let resultValue = try? decoder.decode(JSONValue.self, from: resultData) else { + return encodeError(MCPError.internalError("Failed to encode tool result"), id: id) + } + return encodeRawResult(resultValue, id: id, sessionId: sessionId) + } catch is CancellationError { + if let session { _ = await session.removeRunningTask(id) } + return encodeError(MCPError.timeout("Request was cancelled"), id: id) + } catch let mcpError as MCPError { + if let session { _ = await session.removeRunningTask(id) } + return encodeError(mcpError, id: id) + } catch { + if let session { _ = await session.removeRunningTask(id) } + return encodeError(MCPError.internalError(error.localizedDescription), id: id) + } + } + + private func handleResourcesList(_ request: JSONRPCRequest, sessionId: String) -> MCPRouter.RouteResult { + guard let id = request.id else { + return .accepted + } + + let resources = MCPRouter.resourceDefinitions() + let result: JSONValue = .object(["resources": encodeResourceDefinitions(resources)]) + return encodeRawResult(result, id: id, sessionId: sessionId) + } + + private func handleResourcesRead( + _ request: JSONRPCRequest, + sessionId: String, + server: MCPServer + ) async -> MCPRouter.RouteResult { + guard let id = request.id else { + return encodeError(MCPError.invalidRequest("resources/read requires an id"), id: nil) + } + + guard let params = request.params, + let uri = params["uri"]?.stringValue + else { + return encodeError(MCPError.invalidParams("Missing resource uri"), id: id) + } + + guard let handler = await server.resourceReadHandler else { + return encodeError(MCPError.internalError("Server not fully initialized"), id: id) + } + + do { + let readResult = try await handler(uri, sessionId) + let resultData = try encoder.encode(readResult) + guard let resultValue = try? decoder.decode(JSONValue.self, from: resultData) else { + return encodeError(MCPError.internalError("Failed to encode resource result"), id: id) + } + return encodeRawResult(resultValue, id: id, sessionId: sessionId) + } catch let mcpError as MCPError { + return encodeError(mcpError, id: id) + } catch { + return encodeError(MCPError.internalError(error.localizedDescription), id: id) + } + } + + private func encodeResult(_ result: T, id: JSONRPCId?, sessionId: String?) -> MCPRouter.RouteResult { + guard let id else { + return .accepted + } + + do { + let resultData = try encoder.encode(result) + let resultValue = try decoder.decode(JSONValue.self, from: resultData) + let response = JSONRPCResponse(id: id, result: resultValue) + let data = try encoder.encode(response) + return .json(data, sessionId: sessionId) + } catch { + Self.logger.error("Failed to encode response: \(error.localizedDescription)") + return encodeError(MCPError.internalError("Encoding failed"), id: id) + } + } + + private func encodeRawResult(_ result: JSONValue, id: JSONRPCId, sessionId: String?) -> MCPRouter.RouteResult { + do { + let response = JSONRPCResponse(id: id, result: result) + let data = try encoder.encode(response) + return .json(data, sessionId: sessionId) + } catch { + Self.logger.error("Failed to encode response: \(error.localizedDescription)") + return encodeError(MCPError.internalError("Encoding failed"), id: id) + } + } + + private func encodeError(_ error: MCPError, id: JSONRPCId?) -> MCPRouter.RouteResult { + let errorResponse = error.toJsonRpcError(id: id) + do { + let data = try encoder.encode(errorResponse) + return .json(data, sessionId: nil) + } catch { + Self.logger.error("Failed to encode error response") + return .httpError(status: 500, message: "Internal encoding error") + } + } + + private func encodeToolDefinitions(_ tools: [MCPToolDefinition]) -> JSONValue { + .array(tools.map { tool in + .object([ + "name": .string(tool.name), + "description": .string(tool.description), + "inputSchema": tool.inputSchema + ]) + }) + } + + private func encodeResourceDefinitions(_ resources: [MCPResourceDefinition]) -> JSONValue { + .array(resources.map { resource in + var dict: [String: JSONValue] = [ + "uri": .string(resource.uri), + "name": .string(resource.name) + ] + if let description = resource.description { + dict["description"] = .string(description) + } + if let mimeType = resource.mimeType { + dict["mimeType"] = .string(mimeType) + } + return .object(dict) + }) + } +} diff --git a/TablePro/Core/MCP/TokenPermissionFilter.swift b/TablePro/Core/MCP/TokenPermissionFilter.swift new file mode 100644 index 000000000..8c42600e6 --- /dev/null +++ b/TablePro/Core/MCP/TokenPermissionFilter.swift @@ -0,0 +1,47 @@ +import Foundation + +protocol ConnectionIdentifiable { + var connectionId: UUID { get } +} + +enum TokenPermissionFilter { + static let overfetchMultiplier = 3 + private static let maxRoundTrips = 2 + + static func filter(_ items: [T], by access: ConnectionAccess) -> [T] { + switch access { + case .all: + return items + case .limited(let ids): + return items.filter { ids.contains($0.connectionId) } + } + } + + static func fetchFiltered( + access: ConnectionAccess, + limit: Int, + fetch: (Int, Int) async throws -> [T] + ) async throws -> [T] { + if case .all = access { + let items = try await fetch(limit, 0) + return Array(items.prefix(limit)) + } + + guard limit > 0 else { return [] } + + let fetchLimit = limit * overfetchMultiplier + var collected: [T] = [] + var offset = 0 + + for _ in 0..= limit { break } + if raw.count < fetchLimit { break } + offset += fetchLimit + } + + return Array(collected.prefix(limit)) + } +} diff --git a/TablePro/Core/Services/Infrastructure/AppLaunchCoordinator.swift b/TablePro/Core/Services/Infrastructure/AppLaunchCoordinator.swift new file mode 100644 index 000000000..b49745142 --- /dev/null +++ b/TablePro/Core/Services/Infrastructure/AppLaunchCoordinator.swift @@ -0,0 +1,226 @@ +// +// AppLaunchCoordinator.swift +// TablePro +// + +import AppKit +import Foundation +import Observation +import os + +@MainActor +@Observable +internal final class AppLaunchCoordinator { + internal static let shared = AppLaunchCoordinator() + + private static let logger = Logger(subsystem: "com.TablePro", category: "AppLaunchCoordinator") + internal static let collectionWindow: Duration = .milliseconds(150) + + private(set) var phase: LaunchPhase = .launching + + private var pendingIntents: [LaunchIntent] = [] + private var deadlineTask: Task? + private var hasFinishedLaunching = false + + private init() {} + + // MARK: - App Lifecycle Hooks + + internal func didFinishLaunching() { + hasFinishedLaunching = true + let deadline = Date().addingTimeInterval(0.150) + phase = .collectingIntents(deadline: deadline) + deadlineTask = Task { [weak self] in + try? await Task.sleep(for: Self.collectionWindow) + await MainActor.run { + self?.transitionToRouting() + } + } + } + + internal func handleOpenURLs(_ urls: [URL]) { + let intents: [LaunchIntent] = urls.compactMap { url in + switch URLClassifier.classify(url) { + case .none: + Self.logger.warning("Unrecognized URL: \(url.sanitizedForLogging, privacy: .public)") + return nil + case .some(.failure(let error)): + Self.logger.error("URL parse failed: \(error.localizedDescription, privacy: .public) for \(url.sanitizedForLogging, privacy: .public)") + return nil + case .some(.success(let intent)): + return intent + } + } + deliver(intents) + } + + internal func handleHandoff(_ activity: NSUserActivity) { + guard let connectionIdString = activity.userInfo?["connectionId"] as? String, + let connectionId = UUID(uuidString: connectionIdString) else { return } + let table = activity.userInfo?["tableName"] as? String + + if let table { + deliver([.openTable( + connectionId: connectionId, + database: nil, + schema: nil, + table: table, + isView: false + )]) + } else { + deliver([.openConnection(connectionId)]) + } + } + + internal func handleReopen(hasVisibleWindows: Bool) -> Bool { + if hasVisibleWindows { return true } + showWelcomeWindow() + return false + } + + // MARK: - Phase Transitions + + private func deliver(_ intents: [LaunchIntent]) { + guard !intents.isEmpty else { return } + if phase.isAcceptingIntents { + pendingIntents.append(contentsOf: intents) + for window in NSApp.windows where Self.isWelcomeWindow(window) { + window.orderOut(nil) + } + } else { + Task { [weak self] in + guard let self else { return } + for intent in intents { + await LaunchIntentRouter.shared.route(intent) + } + } + } + } + + private func transitionToRouting() { + guard hasFinishedLaunching else { return } + phase = .routing + let intents = pendingIntents + pendingIntents.removeAll() + + Task { [weak self] in + guard let self else { return } + for intent in intents { + await LaunchIntentRouter.shared.route(intent) + } + self.runStartupBehaviorIfNeeded(skipping: intents) + self.phase = .ready + self.finalizeWindowsIfNoVisibleMain(intents: intents) + } + } + + private func runStartupBehaviorIfNeeded(skipping intents: [LaunchIntent]) { + guard intents.isEmpty else { + closeRestoredMainWindowsExcept(intents: intents) + return + } + let general = AppSettingsStorage.shared.loadGeneral() + guard general.startupBehavior == .reopenLast else { + closeRestoredMainWindowsExcept(intents: intents) + return + } + let openIds = AppSettingsStorage.shared.loadLastOpenConnectionIds() + if !openIds.isEmpty { + attemptAutoReconnect(connectionIds: openIds) + return + } + if let lastId = AppSettingsStorage.shared.loadLastConnectionId() { + attemptAutoReconnect(connectionIds: [lastId]) + return + } + Task { [weak self] in + let diskIds = await TabDiskActor.shared.connectionIdsWithSavedState() + if !diskIds.isEmpty { + self?.attemptAutoReconnect(connectionIds: diskIds) + } else { + self?.closeRestoredMainWindowsExcept(intents: []) + } + } + } + + private func finalizeWindowsIfNoVisibleMain(intents: [LaunchIntent]) { + guard intents.isEmpty else { return } + guard !NSApp.windows.contains(where: { Self.isMainWindow($0) && $0.isVisible }) else { return } + showWelcomeWindow() + } + + private func closeRestoredMainWindowsExcept(intents: [LaunchIntent]) { + let preserved = Set(intents.compactMap { $0.targetConnectionId }) + for window in NSApp.windows where Self.isMainWindow(window) { + if let id = WindowLifecycleMonitor.shared.connectionId(forWindow: window), + preserved.contains(id) { + continue + } + window.close() + } + } + + private func attemptAutoReconnect(connectionIds: [UUID]) { + let saved = ConnectionStorage.shared.loadConnections() + let valid = connectionIds.compactMap { id in + saved.first(where: { $0.id == id }) + } + guard !valid.isEmpty else { + AppSettingsStorage.shared.saveLastOpenConnectionIds([]) + AppSettingsStorage.shared.saveLastConnectionId(nil) + closeRestoredMainWindowsExcept(intents: []) + showWelcomeWindow() + return + } + for window in NSApp.windows where Self.isWelcomeWindow(window) { + window.orderOut(nil) + } + Task { [weak self] in + for connection in valid { + let payload = EditorTabPayload( + connectionId: connection.id, intent: .restoreOrDefault + ) + WindowManager.shared.openTab(payload: payload) + do { + try await DatabaseManager.shared.ensureConnected(connection) + } catch is CancellationError { + for window in WindowLifecycleMonitor.shared.windows(for: connection.id) { + window.close() + } + } catch { + Self.logger.error("Auto-reconnect failed for '\(connection.name, privacy: .public)': \(error.localizedDescription, privacy: .public)") + for window in WindowLifecycleMonitor.shared.windows(for: connection.id) { + window.close() + } + } + } + for window in NSApp.windows where Self.isWelcomeWindow(window) { + window.close() + } + if !NSApp.windows.contains(where: { Self.isMainWindow($0) && $0.isVisible }) { + self?.showWelcomeWindow() + } + } + } + + // MARK: - Window Identification + + internal static func isMainWindow(_ window: NSWindow) -> Bool { + guard let raw = window.identifier?.rawValue else { return false } + return raw == "main" || raw.hasPrefix("main-") + } + + internal static func isWelcomeWindow(_ window: NSWindow) -> Bool { + guard let raw = window.identifier?.rawValue else { return false } + return raw == "welcome" || raw.hasPrefix("welcome-") + } + + internal static func isConnectionFormWindow(_ window: NSWindow) -> Bool { + guard let raw = window.identifier?.rawValue else { return false } + return raw == "connection-form" || raw.hasPrefix("connection-form-") + } + + private func showWelcomeWindow() { + WelcomeWindowFactory.openOrFront() + } +} diff --git a/TablePro/Core/Services/Infrastructure/AppNotifications.swift b/TablePro/Core/Services/Infrastructure/AppNotifications.swift index ca04e402a..fa88c5b6a 100644 --- a/TablePro/Core/Services/Infrastructure/AppNotifications.swift +++ b/TablePro/Core/Services/Infrastructure/AppNotifications.swift @@ -19,8 +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 connectionShareFileOpened = Notification.Name("connectionShareFileOpened") - static let deeplinkImportRequested = Notification.Name("deeplinkImportRequested") 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/ConnectionFormWindowFactory.swift b/TablePro/Core/Services/Infrastructure/ConnectionFormWindowFactory.swift new file mode 100644 index 000000000..4c55f775e --- /dev/null +++ b/TablePro/Core/Services/Infrastructure/ConnectionFormWindowFactory.swift @@ -0,0 +1,60 @@ +// +// ConnectionFormWindowFactory.swift +// TablePro +// + +import AppKit +import SwiftUI + +@MainActor +internal enum ConnectionFormWindowFactory { + private static let baseIdentifier = "connection-form" + + internal static func openOrFront(connectionId: UUID? = nil) { + if let existing = existingWindow(for: connectionId) { + existing.makeKeyAndOrderFront(nil) + NSApp.activate(ignoringOtherApps: true) + return + } + let window = makeWindow(connectionId: connectionId) + window.makeKeyAndOrderFront(nil) + NSApp.activate(ignoringOtherApps: true) + } + + internal static func close(connectionId: UUID? = nil) { + existingWindow(for: connectionId)?.close() + } + + internal static func closeAll() { + for window in NSApp.windows where AppLaunchCoordinator.isConnectionFormWindow(window) { + window.close() + } + } + + private static func existingWindow(for connectionId: UUID?) -> NSWindow? { + let target = identifier(for: connectionId) + return NSApp.windows.first { $0.identifier?.rawValue == target } + } + + private static func identifier(for connectionId: UUID?) -> String { + if let connectionId { + return "\(baseIdentifier)-\(connectionId.uuidString)" + } + return baseIdentifier + } + + private static func makeWindow(connectionId: UUID?) -> NSWindow { + let hostingController = NSHostingController(rootView: ConnectionFormView(connectionId: connectionId)) + let window = NSWindow(contentViewController: hostingController) + window.identifier = NSUserInterfaceItemIdentifier(identifier(for: connectionId)) + window.title = String(localized: "New Connection") + window.styleMask = [.titled, .closable, .resizable] + window.standardWindowButton(.miniaturizeButton)?.isEnabled = false + window.standardWindowButton(.zoomButton)?.isEnabled = false + window.styleMask.remove(.miniaturizable) + window.collectionBehavior.insert(.fullScreenNone) + window.center() + window.isReleasedWhenClosed = false + return window + } +} diff --git a/TablePro/Core/Services/Infrastructure/DeeplinkHandler.swift b/TablePro/Core/Services/Infrastructure/DeeplinkHandler.swift deleted file mode 100644 index 6dfecf7b4..000000000 --- a/TablePro/Core/Services/Infrastructure/DeeplinkHandler.swift +++ /dev/null @@ -1,189 +0,0 @@ -// -// DeeplinkHandler.swift -// TablePro -// - -import Foundation -import os - -enum DeeplinkAction { - case connect(connectionName: String) - case openTable(connectionName: String, tableName: String, databaseName: String?) - case openQuery(connectionName: String, sql: String) - case importConnection(ExportableConnection) -} - -@MainActor -enum DeeplinkHandler { - private static let logger = Logger(subsystem: "com.TablePro", category: "DeeplinkHandler") - - static func parse(_ url: URL) -> DeeplinkAction? { - guard url.scheme == "tablepro" else { return nil } - - let host = url.host(percentEncoded: false) - switch host { - case "connect": - return parseConnect(url) - case "import": - return parseImport(url) - default: - logger.warning("Unknown deep link host: \(host ?? "nil", privacy: .public)") - return nil - } - } - - // MARK: - Connect parsing - - private static func parseConnect(_ url: URL) -> DeeplinkAction? { - let components = url.pathComponents.filter { $0 != "/" } - guard let connectionName = components.first?.removingPercentEncoding, - !connectionName.isEmpty else { return nil } - - // /connect/{name}/query?sql=... - if components.count >= 2, components[1] == "query" { - let queryItems = URLComponents(url: url, resolvingAgainstBaseURL: false)?.queryItems - guard let sql = queryItems?.first(where: { $0.name == "sql" })?.value, - !sql.isEmpty else { return nil } - return .openQuery(connectionName: connectionName, sql: sql) - } - - // /connect/{name}/database/{db}/table/{table} - if components.count == 5, - components[1] == "database", - components[3] == "table", - let dbName = components[2].removingPercentEncoding, - let tableName = components[4].removingPercentEncoding { - return .openTable(connectionName: connectionName, tableName: tableName, - databaseName: dbName) - } - - // /connect/{name}/table/{table} - if components.count >= 3, components[1] == "table", - let tableName = components[2].removingPercentEncoding { - return .openTable(connectionName: connectionName, tableName: tableName, - databaseName: nil) - } - - // /connect/{name} - if components.count == 1 { - return .connect(connectionName: connectionName) - } - - logger.warning("Unrecognized connect deep link path: \(url.path, privacy: .public)") - return nil - } - - // MARK: - Import parsing - - private static func parseImport(_ url: URL) -> DeeplinkAction? { - guard let queryItems = URLComponents(url: url, resolvingAgainstBaseURL: false)?.queryItems - else { return nil } - - func value(_ key: String) -> String? { - queryItems.first(where: { $0.name == key })?.value - } - - guard let name = value("name"), !name.isEmpty, - let host = value("host"), !host.isEmpty, - let typeStr = value("type"), - let dbType = DatabaseType(validating: typeStr) - ?? PluginMetadataRegistry.shared.allRegisteredTypeIds() - .first(where: { $0.lowercased() == typeStr.lowercased() }) - .map({ DatabaseType(rawValue: $0) }) - else { - logger.warning("Import deep link missing required params") - return nil - } - - let port = value("port").flatMap(Int.init) ?? dbType.defaultPort - let username = value("username") ?? "" - let database = value("database") ?? "" - - let sshConfig: ExportableSSHConfig? - if value("ssh") == "1" { - let jumpHosts: [ExportableJumpHost]? - if let jumpJson = value("sshJumpHosts"), - let data = jumpJson.data(using: .utf8) { - jumpHosts = try? JSONDecoder().decode([ExportableJumpHost].self, from: data) - } else { - jumpHosts = nil - } - sshConfig = ExportableSSHConfig( - enabled: true, - host: value("sshHost") ?? "", - port: value("sshPort").flatMap(Int.init) ?? 22, - username: value("sshUsername") ?? "", - authMethod: value("sshAuthMethod") ?? "password", - privateKeyPath: value("sshPrivateKeyPath") ?? "", - useSSHConfig: value("sshUseSSHConfig") == "1", - agentSocketPath: value("sshAgentSocketPath") ?? "", - jumpHosts: jumpHosts, - totpMode: value("sshTotpMode"), - totpAlgorithm: value("sshTotpAlgorithm"), - totpDigits: value("sshTotpDigits").flatMap(Int.init), - totpPeriod: value("sshTotpPeriod").flatMap(Int.init) - ) - } else { - sshConfig = nil - } - - let sslConfig: ExportableSSLConfig? - if let sslMode = value("sslMode") { - sslConfig = ExportableSSLConfig( - mode: sslMode, - caCertificatePath: value("sslCaCertPath"), - clientCertificatePath: value("sslClientCertPath"), - clientKeyPath: value("sslClientKeyPath") - ) - } else { - sslConfig = nil - } - - var additionalFields: [String: String]? - let afItems = queryItems.filter { $0.name.hasPrefix("af_") } - if !afItems.isEmpty { - var fields: [String: String] = [:] - for item in afItems { - let fieldKey = String(item.name.dropFirst(3)) - if !fieldKey.isEmpty, let fieldValue = item.value { - fields[fieldKey] = fieldValue - } - } - if !fields.isEmpty { - additionalFields = fields - } - } - - let exportable = ExportableConnection( - name: name, - host: host, - port: port, - database: database, - username: username, - type: dbType.rawValue, - sshConfig: sshConfig, - sslConfig: sslConfig, - color: value("color"), - tagName: value("tagName"), - groupName: value("groupName"), - sshProfileId: nil, - safeModeLevel: value("safeModeLevel"), - aiPolicy: value("aiPolicy"), - additionalFields: additionalFields, - redisDatabase: value("redisDatabase").flatMap(Int.init), - startupCommands: value("startupCommands"), - localOnly: value("localOnly") == "1" ? true : nil - ) - - return .importConnection(exportable) - } - - // MARK: - Resolution - - static func resolveConnection(named name: String) -> DatabaseConnection? { - let connections = ConnectionStorage.shared.loadConnections() - return connections.first { - $0.name.localizedCaseInsensitiveCompare(name) == .orderedSame - } - } -} diff --git a/TablePro/Core/Services/Infrastructure/DeeplinkParser.swift b/TablePro/Core/Services/Infrastructure/DeeplinkParser.swift new file mode 100644 index 000000000..d6bc15890 --- /dev/null +++ b/TablePro/Core/Services/Infrastructure/DeeplinkParser.swift @@ -0,0 +1,378 @@ +// +// DeeplinkParser.swift +// TablePro +// + +import Foundation +import TableProPluginKit + +internal enum DeeplinkError: Error, LocalizedError, Equatable { + case unknownScheme(String) + case unknownHost(String) + case malformedPath(String) + case missingRequiredParam(String) + case invalidUUID(String) + case sqlTooLong(Int, limit: Int) + case unsupportedDatabaseType(String) + + internal var errorDescription: String? { + switch self { + case .unknownScheme(let scheme): + return String(format: String(localized: "Unknown URL scheme: %@"), scheme) + case .unknownHost(let host): + return String(format: String(localized: "Unknown deep link host: %@"), host) + case .malformedPath(let path): + return String(format: String(localized: "Malformed deep link path: %@"), path) + case .missingRequiredParam(let name): + return String(format: String(localized: "Missing required parameter: %@"), name) + case .invalidUUID(let raw): + return String(format: String(localized: "Invalid UUID: %@"), raw) + case .sqlTooLong(let length, let limit): + return String( + format: String(localized: "SQL is too long: %d characters (limit %d)"), + length, limit + ) + case .unsupportedDatabaseType(let raw): + return String(format: String(localized: "Unsupported database type: %@"), raw) + } + } +} + +internal enum DeeplinkParser { + internal static let sqlLengthLimit = 51_200 + + internal static func parse(_ url: URL) -> Result { + guard url.scheme == "tablepro" else { + return .failure(.unknownScheme(url.scheme ?? "")) + } + let host = url.host(percentEncoded: false) ?? "" + switch host { + case "connect": + return parseConnect(url) + case "import": + return parseImport(url) + case "integrations": + return parseIntegrations(url) + default: + return .failure(.unknownHost(host)) + } + } + + private static func parseConnect(_ url: URL) -> Result { + let segments = pathSegments(url) + var cursor = PathCursor(segments: segments) + + guard let firstRaw = cursor.next() else { + return .failure(.malformedPath(url.path)) + } + guard let connectionId = UUID(uuidString: firstRaw) else { + return .failure(.invalidUUID(firstRaw)) + } + + guard let head = cursor.peek() else { + return .success(.openConnection(connectionId)) + } + + switch head { + case "table": + cursor.advance() + guard let table = cursor.next(), !table.isEmpty else { + return .failure(.malformedPath(url.path)) + } + guard cursor.atEnd else { return .failure(.malformedPath(url.path)) } + return .success(.openTable( + connectionId: connectionId, + database: nil, + schema: nil, + table: table, + isView: false + )) + + case "database": + cursor.advance() + guard let database = cursor.next(), !database.isEmpty else { + return .failure(.malformedPath(url.path)) + } + return parseDatabaseTail( + connectionId: connectionId, database: database, cursor: &cursor, fullPath: url.path + ) + + case "query": + cursor.advance() + guard cursor.atEnd else { return .failure(.malformedPath(url.path)) } + return parseQuery(url: url, connectionId: connectionId) + + default: + return .failure(.malformedPath(url.path)) + } + } + + private static func parseDatabaseTail( + connectionId: UUID, + database: String, + cursor: inout PathCursor, + fullPath: String + ) -> Result { + guard let next = cursor.next() else { + return .failure(.malformedPath(fullPath)) + } + switch next { + case "schema": + guard let schema = cursor.next(), !schema.isEmpty else { + return .failure(.malformedPath(fullPath)) + } + guard cursor.next() == "table", + let table = cursor.next(), !table.isEmpty else { + return .failure(.malformedPath(fullPath)) + } + guard cursor.atEnd else { return .failure(.malformedPath(fullPath)) } + return .success(.openTable( + connectionId: connectionId, + database: database, + schema: schema, + table: table, + isView: false + )) + + case "table": + guard let table = cursor.next(), !table.isEmpty else { + return .failure(.malformedPath(fullPath)) + } + guard cursor.atEnd else { return .failure(.malformedPath(fullPath)) } + return .success(.openTable( + connectionId: connectionId, + database: database, + schema: nil, + table: table, + isView: false + )) + + default: + return .failure(.malformedPath(fullPath)) + } + } + + private static func parseQuery(url: URL, connectionId: UUID) -> Result { + guard let queryItems = URLComponents(url: url, resolvingAgainstBaseURL: false)?.queryItems, + let rawSQL = queryItems.first(where: { $0.name == "sql" })?.value, + !rawSQL.isEmpty else { + return .failure(.missingRequiredParam("sql")) + } + let length = (rawSQL as NSString).length + guard length <= sqlLengthLimit else { + return .failure(.sqlTooLong(length, limit: sqlLengthLimit)) + } + return .success(.openQuery(connectionId: connectionId, sql: rawSQL)) + } + + private static func parseIntegrations(_ url: URL) -> Result { + let segments = pathSegments(url) + var cursor = PathCursor(segments: segments) + guard let action = cursor.next() else { + return .failure(.malformedPath(url.path)) + } + switch action { + case "pair": + return parsePair(url) + case "start-mcp": + return .success(.startMCPServer) + default: + return .failure(.malformedPath(url.path)) + } + } + + private static func parsePair(_ url: URL) -> Result { + guard let queryItems = URLComponents(url: url, resolvingAgainstBaseURL: false)?.queryItems + else { + return .failure(.missingRequiredParam("client")) + } + func value(_ key: String) -> String? { + queryItems.first(where: { $0.name == key })?.value + } + + guard let clientName = value("client"), !clientName.isEmpty else { + return .failure(.missingRequiredParam("client")) + } + guard let challenge = value("challenge"), !challenge.isEmpty else { + return .failure(.missingRequiredParam("challenge")) + } + guard let redirectRaw = value("redirect"), !redirectRaw.isEmpty, + let redirectURL = URL(string: redirectRaw) else { + return .failure(.missingRequiredParam("redirect")) + } + + let scopes = value("scopes")?.nilIfEmpty + let connectionIds: Set? + if let csv = value("connection-ids")?.nilIfEmpty { + let parsed = csv.split(separator: ",").compactMap { UUID(uuidString: String($0)) } + connectionIds = parsed.isEmpty ? nil : Set(parsed) + } else { + connectionIds = nil + } + + return .success(.pairIntegration( + PairingRequest( + clientName: clientName, + challenge: challenge, + redirectURL: redirectURL, + requestedScopes: scopes, + requestedConnectionIds: connectionIds + ) + )) + } + + private static func parseImport(_ url: URL) -> Result { + guard let queryItems = URLComponents(url: url, resolvingAgainstBaseURL: false)?.queryItems + else { + return .failure(.missingRequiredParam("name")) + } + func value(_ key: String) -> String? { + queryItems.first(where: { $0.name == key })?.value + } + + guard let name = value("name"), !name.isEmpty else { + return .failure(.missingRequiredParam("name")) + } + guard let host = value("host"), !host.isEmpty else { + return .failure(.missingRequiredParam("host")) + } + guard let typeStr = value("type") else { + return .failure(.missingRequiredParam("type")) + } + + let resolvedType: DatabaseType? + if let direct = DatabaseType(validating: typeStr) { + resolvedType = direct + } else if let pluginMatch = PluginMetadataRegistry.shared.allRegisteredTypeIds() + .first(where: { $0.lowercased() == typeStr.lowercased() }) { + resolvedType = DatabaseType(rawValue: pluginMatch) + } else { + resolvedType = nil + } + guard let dbType = resolvedType else { + return .failure(.unsupportedDatabaseType(typeStr)) + } + + let port = value("port").flatMap(Int.init) ?? dbType.defaultPort + let username = value("username") ?? "" + let database = value("database") ?? "" + + let sshConfig: ExportableSSHConfig? + if value("ssh") == "1" { + let jumpHosts: [ExportableJumpHost]? + if let jumpJson = value("sshJumpHosts"), + let data = jumpJson.data(using: .utf8) { + jumpHosts = try? JSONDecoder().decode([ExportableJumpHost].self, from: data) + } else { + jumpHosts = nil + } + sshConfig = ExportableSSHConfig( + enabled: true, + host: value("sshHost") ?? "", + port: value("sshPort").flatMap(Int.init) ?? 22, + username: value("sshUsername") ?? "", + authMethod: value("sshAuthMethod") ?? "password", + privateKeyPath: value("sshPrivateKeyPath") ?? "", + useSSHConfig: value("sshUseSSHConfig") == "1", + agentSocketPath: value("sshAgentSocketPath") ?? "", + jumpHosts: jumpHosts, + totpMode: value("sshTotpMode"), + totpAlgorithm: value("sshTotpAlgorithm"), + totpDigits: value("sshTotpDigits").flatMap(Int.init), + totpPeriod: value("sshTotpPeriod").flatMap(Int.init) + ) + } else { + sshConfig = nil + } + + let sslConfig: ExportableSSLConfig? + if let sslMode = value("sslMode") { + sslConfig = ExportableSSLConfig( + mode: sslMode, + caCertificatePath: value("sslCaCertPath"), + clientCertificatePath: value("sslClientCertPath"), + clientKeyPath: value("sslClientKeyPath") + ) + } else { + sslConfig = nil + } + + var additionalFields: [String: String]? + let afItems = queryItems.filter { $0.name.hasPrefix("af_") } + if !afItems.isEmpty { + var fields: [String: String] = [:] + for item in afItems { + let fieldKey = String(item.name.dropFirst(3)) + if !fieldKey.isEmpty, let fieldValue = item.value, !fieldValue.isEmpty { + fields[fieldKey] = fieldValue + } + } + if !fields.isEmpty { + additionalFields = fields + } + } + + let exportable = ExportableConnection( + name: name, + host: host, + port: port, + database: database, + username: username, + type: dbType.rawValue, + sshConfig: sshConfig, + sslConfig: sslConfig, + color: value("color"), + tagName: value("tagName"), + groupName: value("groupName"), + sshProfileId: nil, + safeModeLevel: value("safeModeLevel"), + aiPolicy: value("aiPolicy"), + additionalFields: additionalFields, + redisDatabase: value("redisDatabase").flatMap(Int.init), + startupCommands: value("startupCommands"), + localOnly: value("localOnly") == "1" ? true : nil + ) + + return .success(.importConnection(exportable)) + } + + private static func pathSegments(_ url: URL) -> [String] { + url.pathComponents + .filter { $0 != "/" } + .compactMap { $0.removingPercentEncoding } + } +} + +private struct PathCursor { + private let segments: [String] + private var index: Int = 0 + + init(segments: [String]) { + self.segments = segments + } + + var atEnd: Bool { + index >= segments.count + } + + func peek() -> String? { + guard index < segments.count else { return nil } + return segments[index] + } + + mutating func advance() { + index += 1 + } + + mutating func next() -> String? { + guard index < segments.count else { return nil } + defer { index += 1 } + return segments[index] + } +} + +private extension String { + var nilIfEmpty: String? { + isEmpty ? nil : self + } +} diff --git a/TablePro/Core/Services/Infrastructure/LaunchIntent.swift b/TablePro/Core/Services/Infrastructure/LaunchIntent.swift new file mode 100644 index 000000000..aef5d2f68 --- /dev/null +++ b/TablePro/Core/Services/Infrastructure/LaunchIntent.swift @@ -0,0 +1,38 @@ +// +// LaunchIntent.swift +// TablePro +// + +import Foundation + +internal enum LaunchIntent: @unchecked Sendable { + case openConnection(UUID) + case openTable(connectionId: UUID, database: String?, schema: String?, table: String, isView: Bool) + case openQuery(connectionId: UUID, sql: String) + case importConnection(ExportableConnection) + case openSQLFile(URL) + case openDatabaseFile(URL, DatabaseType) + case openConnectionShare(URL) + case pairIntegration(PairingRequest) + case startMCPServer + case openDatabaseURL(URL) + case installPlugin(URL) + + internal var targetConnectionId: UUID? { + switch self { + case .openConnection(let id), + .openTable(let id, _, _, _, _), + .openQuery(let id, _): + return id + case .openDatabaseURL, + .openDatabaseFile, + .openSQLFile, + .importConnection, + .openConnectionShare, + .pairIntegration, + .startMCPServer, + .installPlugin: + return nil + } + } +} diff --git a/TablePro/Core/Services/Infrastructure/LaunchIntentRouter.swift b/TablePro/Core/Services/Infrastructure/LaunchIntentRouter.swift new file mode 100644 index 000000000..7a60f1959 --- /dev/null +++ b/TablePro/Core/Services/Infrastructure/LaunchIntentRouter.swift @@ -0,0 +1,95 @@ +// +// LaunchIntentRouter.swift +// TablePro +// + +import AppKit +import Foundation +import os + +@MainActor +internal final class LaunchIntentRouter { + internal static let shared = LaunchIntentRouter() + + private static let logger = Logger(subsystem: "com.TablePro", category: "LaunchIntentRouter") + + private init() {} + + internal func route(_ intent: LaunchIntent) async { + do { + switch intent { + case .openConnection, + .openTable, + .openQuery, + .openDatabaseURL, + .openDatabaseFile, + .openSQLFile: + try await TabRouter.shared.route(intent) + + case .importConnection(let exportable): + WelcomeRouter.shared.routeImport(exportable) + + case .openConnectionShare(let url): + WelcomeRouter.shared.routeShare(url) + + case .pairIntegration(let request): + try await MCPPairingService.shared.startPairing(request) + + case .startMCPServer: + await MCPServerManager.shared.lazyStart() + + case .installPlugin(let url): + try await installPlugin(url) + } + } catch let error as TabRouterError where error == .userCancelled { + Self.logger.info("Intent cancelled by user") + } catch let error as MCPError where error.isUserCancelled { + Self.logger.info("Pairing cancelled by user") + } catch is CancellationError { + Self.logger.info("Intent cancelled") + } catch { + Self.logger.error("Intent failed: \(error.localizedDescription, privacy: .public)") + await presentError(error, for: intent) + } + } + + private func installPlugin(_ url: URL) async throws { + let entry = try await PluginManager.shared.installPlugin(from: url) + Self.logger.info("Installed plugin '\(entry.name, privacy: .public)' from Finder") + UserDefaults.standard.set(SettingsTab.plugins.rawValue, forKey: "selectedSettingsTab") + NotificationCenter.default.post(name: .openSettingsWindow, object: nil) + } + + private func presentError(_ error: Error, for intent: LaunchIntent) async { + let title: String + switch intent { + case .pairIntegration: + title = String(localized: "Pairing Failed") + case .installPlugin: + title = String(localized: "Plugin Installation Failed") + case .openConnection, .openTable, .openQuery, .openDatabaseURL, .openDatabaseFile: + title = String(localized: "Connection Failed") + case .openSQLFile: + title = String(localized: "Could Not Open File") + case .importConnection, .openConnectionShare, .startMCPServer: + title = String(localized: "Action Failed") + } + AlertHelper.showErrorSheet( + title: title, + message: error.localizedDescription, + window: NSApp.keyWindow + ) + } +} + +extension TabRouterError: Equatable { + internal static func == (lhs: TabRouterError, rhs: TabRouterError) -> Bool { + switch (lhs, rhs) { + case (.userCancelled, .userCancelled): return true + case (.connectionNotFound(let l), .connectionNotFound(let r)): return l == r + case (.malformedDatabaseURL(let l), .malformedDatabaseURL(let r)): return l == r + case (.unsupportedIntent(let l), .unsupportedIntent(let r)): return l == r + default: return false + } + } +} diff --git a/TablePro/Core/Services/Infrastructure/LaunchPhase.swift b/TablePro/Core/Services/Infrastructure/LaunchPhase.swift new file mode 100644 index 000000000..2bbf8e0c5 --- /dev/null +++ b/TablePro/Core/Services/Infrastructure/LaunchPhase.swift @@ -0,0 +1,27 @@ +// +// LaunchPhase.swift +// TablePro +// + +import Foundation + +internal enum LaunchPhase: Equatable, Sendable { + case launching + case collectingIntents(deadline: Date) + case routing + case ready + + internal var isAcceptingIntents: Bool { + switch self { + case .launching, .collectingIntents: + return true + case .routing, .ready: + return false + } + } + + internal var isReady: Bool { + if case .ready = self { return true } + return false + } +} diff --git a/TablePro/Core/Services/Infrastructure/MainSplitViewController.swift b/TablePro/Core/Services/Infrastructure/MainSplitViewController.swift index b0bdc60c7..c200decf4 100644 --- a/TablePro/Core/Services/Infrastructure/MainSplitViewController.swift +++ b/TablePro/Core/Services/Infrastructure/MainSplitViewController.swift @@ -45,7 +45,6 @@ internal final class MainSplitViewController: NSSplitViewController, InspectorVi // MARK: - Observers private var connectionStatusObserver: NSObjectProtocol? - private var newConnectionObserver: NSObjectProtocol? // MARK: - Init @@ -199,15 +198,6 @@ internal final class MainSplitViewController: NSSplitViewController, InspectorVi self?.handleConnectionStatusChange() } } - newConnectionObserver = NotificationCenter.default.addObserver( - forName: .newConnection, - object: nil, - queue: .main - ) { _ in - MainActor.assumeIsolated { - NotificationCenter.default.post(name: .openWelcomeWindow, object: nil) - } - } handleConnectionStatusChange() } @@ -216,10 +206,6 @@ internal final class MainSplitViewController: NSSplitViewController, InspectorVi NotificationCenter.default.removeObserver(observer) connectionStatusObserver = nil } - if let observer = newConnectionObserver { - NotificationCenter.default.removeObserver(observer) - newConnectionObserver = nil - } } // MARK: - Toolbar @@ -320,7 +306,6 @@ internal final class MainSplitViewController: NSSplitViewController, InspectorVi private func buildSidebarView() -> some View { if let currentSession, let sessionState { SidebarView( - tables: sessionTablesBinding, sidebarState: SharedSidebarState.forConnection(currentSession.connection.id), onDoubleClick: { [weak self] table in guard let coordinator = self?.sessionState?.coordinator else { return } @@ -358,7 +343,6 @@ internal final class MainSplitViewController: NSSplitViewController, InspectorVi connection: currentSession.connection, payload: payload, windowTitle: windowTitleBinding, - tables: sessionTablesBinding, sidebarState: SharedSidebarState.forConnection(currentSession.connection.id), pendingTruncates: sessionPendingTruncatesBinding, pendingDeletes: sessionPendingDeletesBinding, @@ -419,10 +403,6 @@ internal final class MainSplitViewController: NSSplitViewController, InspectorVi ) } - private var sessionTablesBinding: Binding<[TableInfo]> { - createSessionBinding(get: { $0.tables }, set: { $0.tables = $1 }, defaultValue: []) - } - private var sessionPendingTruncatesBinding: Binding> { createSessionBinding(get: { $0.pendingTruncates }, set: { $0.pendingTruncates = $1 }, defaultValue: []) } diff --git a/TablePro/Core/Services/Infrastructure/MainWindowToolbar.swift b/TablePro/Core/Services/Infrastructure/MainWindowToolbar.swift index 38efcee31..5b8b08948 100644 --- a/TablePro/Core/Services/Infrastructure/MainWindowToolbar.swift +++ b/TablePro/Core/Services/Infrastructure/MainWindowToolbar.swift @@ -258,23 +258,20 @@ internal final class MainWindowToolbar: NSObject, NSToolbarDelegate { private struct ConnectionToolbarButton: View { let coordinator: MainContentCoordinator - @State private var showSwitcher = false var body: some View { + @Bindable var state = coordinator.toolbarState Button { - showSwitcher.toggle() + state.showConnectionSwitcher.toggle() } label: { Label("Connection", systemImage: "network") } .help(String(localized: "Switch Connection (⌘⌥C)")) - .popover(isPresented: $showSwitcher) { + .popover(isPresented: $state.showConnectionSwitcher) { ConnectionSwitcherPopover { - showSwitcher = false + state.showConnectionSwitcher = false } } - .onReceive(NotificationCenter.default.publisher(for: .openConnectionSwitcher)) { _ in - showSwitcher = true - } } } diff --git a/TablePro/Core/Services/Infrastructure/PendingActionStore.swift b/TablePro/Core/Services/Infrastructure/PendingActionStore.swift deleted file mode 100644 index 707884e9c..000000000 --- a/TablePro/Core/Services/Infrastructure/PendingActionStore.swift +++ /dev/null @@ -1,28 +0,0 @@ -// -// PendingActionStore.swift -// TablePro -// - -import Foundation - -@MainActor @Observable -final class PendingActionStore { - static let shared = PendingActionStore() - - var connectionShareURL: URL? - var deeplinkImport: ExportableConnection? - - private init() {} - - func consumeConnectionShareURL() -> URL? { - let url = connectionShareURL - connectionShareURL = nil - return url - } - - func consumeDeeplinkImport() -> ExportableConnection? { - let value = deeplinkImport - deeplinkImport = nil - return value - } -} diff --git a/TablePro/Core/Services/Infrastructure/SessionStateFactory.swift b/TablePro/Core/Services/Infrastructure/SessionStateFactory.swift index 099912e81..ffabe045a 100644 --- a/TablePro/Core/Services/Infrastructure/SessionStateFactory.swift +++ b/TablePro/Core/Services/Infrastructure/SessionStateFactory.swift @@ -2,9 +2,6 @@ // SessionStateFactory.swift // TablePro // -// Factory for creating session state objects used by MainContentView. -// Extracted from MainContentView.init to enable testability. -// import Foundation import os @@ -22,23 +19,36 @@ enum SessionStateFactory { let coordinator: MainContentCoordinator } - /// Hand-off registry for SessionState created eagerly by `WindowManager.openTab`. - /// `WindowManager` creates the coordinator BEFORE `TabWindowController.init` so the - /// NSToolbar can be installed synchronously in init (eliminating the toolbar flash - /// caused by lazy install via `WindowAccessor → configureWindow` after the window - /// is already on-screen). `ContentView.init` consumes the same SessionState here so - /// only one coordinator exists per window — no duplicate-tab side effects. private static var pendingSessionStates: [UUID: SessionState] = [:] + private static var pendingExpirationTasks: [UUID: Task] = [:] + + private static let pendingEntryTTL: Duration = .seconds(5) static func registerPending(_ state: SessionState, for payloadId: UUID) { pendingSessionStates[payloadId] = state + pendingExpirationTasks[payloadId]?.cancel() + pendingExpirationTasks[payloadId] = Task { [payloadId] in + try? await Task.sleep(for: pendingEntryTTL) + guard !Task.isCancelled else { return } + await MainActor.run { + pendingExpirationTasks.removeValue(forKey: payloadId) + guard let abandoned = pendingSessionStates.removeValue(forKey: payloadId) else { + return + } + MainContentCoordinator.activeCoordinators.removeValue( + forKey: abandoned.coordinator.instanceId + ) + } + } } static func consumePending(for payloadId: UUID) -> SessionState? { - pendingSessionStates.removeValue(forKey: payloadId) + pendingExpirationTasks.removeValue(forKey: payloadId)?.cancel() + return pendingSessionStates.removeValue(forKey: payloadId) } static func removePending(for payloadId: UUID) { + pendingExpirationTasks.removeValue(forKey: payloadId)?.cancel() pendingSessionStates.removeValue(forKey: payloadId) } @@ -46,14 +56,16 @@ enum SessionStateFactory { connection: DatabaseConnection, payload: EditorTabPayload? ) -> SessionState { - let tabMgr = QueryTabManager() + let connectionId = connection.id + let tabMgr = QueryTabManager(globalTabsProvider: { + MainActor.assumeIsolated { MainContentCoordinator.allTabs(for: connectionId) } + }) let changeMgr = DataChangeManager() changeMgr.databaseType = connection.type let filterMgr = FilterStateManager() let colVisMgr = ColumnVisibilityManager() let toolbarSt = ConnectionToolbarState(connection: connection) - // Eagerly populate version + state from existing session to avoid flash if let session = DatabaseManager.shared.session(for: connection.id) { toolbarSt.updateConnectionState(from: session.status) if let driver = session.driver { @@ -65,7 +77,6 @@ enum SessionStateFactory { } toolbarSt.hasCompletedSetup = true - // Redis: set initial database name eagerly to avoid toolbar flash if connection.type.pluginTypeId == "Redis" { let dbIndex = connection.redisDatabase ?? Int(connection.database) ?? 0 toolbarSt.databaseName = String(dbIndex) @@ -136,7 +147,11 @@ enum SessionStateFactory { case .newEmptyTab: let allTabs = MainContentCoordinator.allTabs(for: connection.id) let title = QueryTabManager.nextQueryTitle(existingTabs: allTabs) - tabMgr.addTab(title: title, databaseName: payload.databaseName ?? connection.database) + tabMgr.addTab( + initialQuery: payload.initialQuery, + title: title, + databaseName: payload.databaseName ?? connection.database + ) case .restoreOrDefault: break } @@ -151,6 +166,13 @@ enum SessionStateFactory { toolbarState: toolbarSt ) + // Eagerly publish to the active-coordinator registry so concurrent + // window opens for the same connection both observe each other when + // computing globals like nextQueryTitle. Without this, two windows + // opened back-to-back can both compute "Query 1" before either has + // run onAppear. + coord.registerEagerly() + return SessionState( tabManager: tabMgr, changeManager: changeMgr, diff --git a/TablePro/Core/Services/Infrastructure/TabPersistenceCoordinator+AggregatedSave.swift b/TablePro/Core/Services/Infrastructure/TabPersistenceCoordinator+AggregatedSave.swift new file mode 100644 index 000000000..c4424943e --- /dev/null +++ b/TablePro/Core/Services/Infrastructure/TabPersistenceCoordinator+AggregatedSave.swift @@ -0,0 +1,33 @@ +// +// TabPersistenceCoordinator+AggregatedSave.swift +// TablePro +// + +import Foundation + +extension TabPersistenceCoordinator { + /// Save or clear persisted state based on tabs aggregated across all windows + /// for the connection. Prevents the per-window close path from clobbering + /// state when sibling windows still have open tabs. + func saveOrClearAggregated() { + let aggregatedTabs = MainContentCoordinator.aggregatedTabs(for: connectionId) + if aggregatedTabs.isEmpty { + clearSavedState() + } else { + let selectedId = MainContentCoordinator.aggregatedSelectedTabId(for: connectionId) + saveNow(tabs: aggregatedTabs, selectedTabId: selectedId) + } + } + + /// Synchronous variant for the window-close path, where the run loop may + /// not be available to service Tasks before the window tears down. + func saveOrClearAggregatedSync() { + let aggregatedTabs = MainContentCoordinator.aggregatedTabs(for: connectionId) + if aggregatedTabs.isEmpty { + saveNowSync(tabs: [], selectedTabId: nil) + } else { + let selectedId = MainContentCoordinator.aggregatedSelectedTabId(for: connectionId) + saveNowSync(tabs: aggregatedTabs, selectedTabId: selectedId) + } + } +} diff --git a/TablePro/Core/Services/Infrastructure/TabPersistenceCoordinator.swift b/TablePro/Core/Services/Infrastructure/TabPersistenceCoordinator.swift index 3af4eb7ee..2ce909d20 100644 --- a/TablePro/Core/Services/Infrastructure/TabPersistenceCoordinator.swift +++ b/TablePro/Core/Services/Infrastructure/TabPersistenceCoordinator.swift @@ -2,15 +2,11 @@ // TabPersistenceCoordinator.swift // TablePro // -// Explicit-save coordinator for tab state persistence. -// Replaces debounced/flag-based TabPersistenceService with direct save calls. -// import Foundation import Observation import os -/// Result of tab restoration from disk internal struct RestoreResult { let tabs: [QueryTab] let selectedTabId: UUID? @@ -22,22 +18,19 @@ internal struct RestoreResult { } } -/// Coordinator for persisting and restoring tab state. -/// All saves are explicit: no debounce timers, no onChange-driven saves, -/// no isDismissing/isRestoringTabs flag state machine. @MainActor @Observable internal final class TabPersistenceCoordinator { private static let logger = Logger(subsystem: "com.TablePro", category: "NativeTabLifecycle") let connectionId: UUID + @ObservationIgnored private var saveTask: Task? + init(connectionId: UUID) { self.connectionId = connectionId } // MARK: - Save - /// Save tab state to disk. Called explicitly at named business events - /// (tab switch, window close, quit, etc.). internal func saveNow(tabs: [QueryTab], selectedTabId: UUID?) { let nonPreviewTabs = tabs.filter { !$0.isPreview } guard !nonPreviewTabs.isEmpty else { @@ -45,43 +38,17 @@ internal final class TabPersistenceCoordinator { return } let persisted = nonPreviewTabs.map { convertToPersistedTab($0) } - let connId = connectionId let normalizedSelectedId = nonPreviewTabs.contains(where: { $0.id == selectedTabId }) ? selectedTabId : nonPreviewTabs.first?.id - Self.logger.debug("[persist] saveNow queued tabCount=\(nonPreviewTabs.count) connId=\(connId, privacy: .public)") - - Task { - let t0 = Date() - do { - try await TabDiskActor.shared.save(connectionId: connId, tabs: persisted, selectedTabId: normalizedSelectedId) - Self.logger.debug("[persist] saveNow written tabCount=\(persisted.count) connId=\(connId, privacy: .public) ms=\(Int(Date().timeIntervalSince(t0) * 1_000))") - } catch { - TabDiskActor.logSaveError(connectionId: connId, error: error) - } - } + scheduleSave(tabs: persisted, selectedTabId: normalizedSelectedId) } - /// Save pre-aggregated tabs for the quit path, where the caller has already - /// collected and converted tabs from all windows for this connection. - internal func saveNow(persistedTabs: [PersistedTab], selectedTabId: UUID?) { - let connId = connectionId - let selectedId = selectedTabId - - Task { - do { - try await TabDiskActor.shared.save(connectionId: connId, tabs: persistedTabs, selectedTabId: selectedId) - } catch { - TabDiskActor.logSaveError(connectionId: connId, error: error) - } - } - } - - /// Synchronous save for `applicationWillTerminate` where no run loop - /// remains to service async Tasks. Bypasses the actor and writes directly. internal func saveNowSync(tabs: [QueryTab], selectedTabId: UUID?) { let nonPreviewTabs = tabs.filter { !$0.isPreview } guard !nonPreviewTabs.isEmpty else { - TabDiskActor.saveSync(connectionId: connectionId, tabs: [], selectedTabId: nil) + saveTask?.cancel() + saveTask = nil + TabDiskActor.clearSync(connectionId: connectionId) return } let persisted = nonPreviewTabs.map { convertToPersistedTab($0) } @@ -92,17 +59,40 @@ internal final class TabPersistenceCoordinator { // MARK: - Clear - /// Clear all saved state for this connection (user closed all tabs). internal func clearSavedState() { + saveTask?.cancel() + saveTask = nil let connId = connectionId Task { await TabDiskActor.shared.clear(connectionId: connId) } } + // MARK: - Private save scheduling + + private func scheduleSave(tabs: [PersistedTab], selectedTabId: UUID?) { + saveTask?.cancel() + let connId = connectionId + let tabsCopy = tabs + let selectedId = selectedTabId + Self.logger.debug("[persist] saveNow queued tabCount=\(tabsCopy.count) connId=\(connId, privacy: .public)") + + saveTask = Task { + guard !Task.isCancelled else { return } + let t0 = Date() + do { + try await TabDiskActor.shared.save(connectionId: connId, tabs: tabsCopy, selectedTabId: selectedId) + Self.logger.debug("[persist] saveNow written tabCount=\(tabsCopy.count) connId=\(connId, privacy: .public) ms=\(Int(Date().timeIntervalSince(t0) * 1_000))") + } catch is CancellationError { + return + } catch { + Self.logger.fault("Failed to save tab state for connection \(connId, privacy: .public): \(error.localizedDescription, privacy: .public)") + } + } + } + // MARK: - Restore - /// Restore tabs from disk. Called once at window creation. internal func restoreFromDisk() async -> RestoreResult { guard let state = await TabDiskActor.shared.load(connectionId: connectionId) else { return RestoreResult(tabs: [], selectedTabId: nil, source: .none) diff --git a/TablePro/Core/Services/Infrastructure/TabRouter.swift b/TablePro/Core/Services/Infrastructure/TabRouter.swift new file mode 100644 index 000000000..198b47f8e --- /dev/null +++ b/TablePro/Core/Services/Infrastructure/TabRouter.swift @@ -0,0 +1,426 @@ +// +// TabRouter.swift +// TablePro +// + +import AppKit +import Foundation +import os + +internal enum TabRouterError: Error, LocalizedError { + case connectionNotFound(UUID) + case malformedDatabaseURL(URL) + case userCancelled + case unsupportedIntent(String) + + internal var errorDescription: String? { + switch self { + case .connectionNotFound(let id): + return String( + format: String(localized: "No saved connection with ID \"%@\"."), id.uuidString + ) + case .malformedDatabaseURL(let url): + return String( + format: String(localized: "Could not parse database URL: %@"), url.sanitizedForLogging + ) + case .userCancelled: + return String(localized: "Cancelled by user.") + case .unsupportedIntent(let detail): + return String(format: String(localized: "Unsupported intent: %@"), detail) + } + } +} + +@MainActor +internal final class TabRouter { + internal static let shared = TabRouter() + + private static let logger = Logger(subsystem: "com.TablePro", category: "TabRouter") + + private init() {} + + internal func route(_ intent: LaunchIntent) async throws { + switch intent { + case .openConnection(let id): + try await openConnection(id: id) + + case .openTable(let id, let database, let schema, let table, let isView): + try await openTable( + connectionId: id, transientConnection: nil, + database: database, schema: schema, table: table, isView: isView + ) + + case .openQuery(let id, let sql): + try await openQuery(connectionId: id, sql: sql) + + case .openDatabaseURL(let url): + try await openDatabaseURL(url) + + case .openDatabaseFile(let url, let type): + try await openDatabaseFile(url, type: type) + + case .openSQLFile(let url): + try await openSQLFile(url) + + default: + throw TabRouterError.unsupportedIntent(String(describing: intent)) + } + } + + // MARK: - Connection + + private func openConnection(id: UUID) async throws { + guard let connection = ConnectionStorage.shared.loadConnections().first(where: { $0.id == id }) else { + throw TabRouterError.connectionNotFound(id) + } + if let existing = WindowLifecycleMonitor.shared.findWindow(for: id) { + existing.makeKeyAndOrderFront(nil) + NSApp.activate(ignoringOtherApps: true) + try await DatabaseManager.shared.ensureConnected(connection) + closeWelcomeWindows() + return + } + try await runPreConnectScriptIfNeeded(connection) + let payload = EditorTabPayload(connectionId: connection.id, intent: .restoreOrDefault) + WindowManager.shared.openTab(payload: payload) + NSApp.activate(ignoringOtherApps: true) + try await DatabaseManager.shared.ensureConnected(connection) + closeWelcomeWindows() + } + + // MARK: - Table + + private func openTable( + connectionId: UUID, transientConnection: DatabaseConnection? = nil, + database: String?, schema: String?, table: String, isView: Bool + ) async throws { + let connection: DatabaseConnection + if let transientConnection { + connection = transientConnection + } else if let stored = ConnectionStorage.shared.loadConnections().first(where: { $0.id == connectionId }) { + connection = stored + } else { + throw TabRouterError.connectionNotFound(connectionId) + } + try await runPreConnectScriptIfNeeded(connection) + try await DatabaseManager.shared.ensureConnected(connection) + + if let schema { + await switchSchemaOrDatabase(connectionId: connectionId, target: schema) + } else if let database { + await switchSchemaOrDatabase(connectionId: connectionId, target: database) + } + + if focusExistingTableTab(connectionId: connectionId, database: database, schema: schema, table: table) { + NSApp.activate(ignoringOtherApps: true) + closeWelcomeWindows() + return + } + + let payload = EditorTabPayload( + connectionId: connectionId, + tabType: .table, + tableName: table, + databaseName: database, + schemaName: schema, + isView: isView + ) + WindowManager.shared.openTab(payload: payload) + NSApp.activate(ignoringOtherApps: true) + closeWelcomeWindows() + } + + private func focusExistingTableTab( + connectionId: UUID, database: String?, schema: String?, table: String + ) -> Bool { + for coordinator in MainContentCoordinator.allActiveCoordinators() + where coordinator.connectionId == connectionId { + guard let match = coordinator.tabManager.tabs.first(where: { tab in + guard tab.tabType == .table, + tab.tableContext.tableName == table else { return false } + let databaseMatches = database.map { db in + tab.tableContext.databaseName == db + } ?? true + let schemaMatches = schema.map { sch in + tab.tableContext.schemaName.map { $0 == sch } ?? false + } ?? true + return databaseMatches && schemaMatches + }) else { continue } + coordinator.tabManager.selectedTabId = match.id + if let windowId = coordinator.windowId, + let window = WindowLifecycleMonitor.shared.window(for: windowId) { + window.makeKeyAndOrderFront(nil) + } + return true + } + return false + } + + // MARK: - Query + + private func openQuery(connectionId: UUID, sql: String) async throws { + guard let connection = ConnectionStorage.shared.loadConnections().first(where: { $0.id == connectionId }) else { + throw TabRouterError.connectionNotFound(connectionId) + } + + let preview = previewForSQL(sql) + let confirmed = await AlertHelper.runApprovalModal( + title: String(localized: "Open Query from Link"), + message: String( + format: String(localized: "An external link wants to open a query on \"%@\":\n\n%@"), + connection.name, preview + ), + confirm: String(localized: "Open Query"), + cancel: String(localized: "Cancel") + ) + guard confirmed else { throw TabRouterError.userCancelled } + + try await runPreConnectScriptIfNeeded(connection) + try await DatabaseManager.shared.ensureConnected(connection) + + if focusExistingQueryTab(connectionId: connectionId, sql: sql) { + NSApp.activate(ignoringOtherApps: true) + closeWelcomeWindows() + return + } + + let payload = EditorTabPayload( + connectionId: connectionId, + tabType: .query, + initialQuery: sql + ) + WindowManager.shared.openTab(payload: payload) + NSApp.activate(ignoringOtherApps: true) + closeWelcomeWindows() + } + + private func focusExistingQueryTab(connectionId: UUID, sql: String) -> Bool { + for coordinator in MainContentCoordinator.allActiveCoordinators() + where coordinator.connectionId == connectionId { + let match = coordinator.tabManager.tabs.first { tab in + tab.tabType == .query && tab.content.query == sql + } + guard let match else { continue } + coordinator.tabManager.selectedTabId = match.id + if let windowId = coordinator.windowId, + let window = WindowLifecycleMonitor.shared.window(for: windowId) { + window.makeKeyAndOrderFront(nil) + } + return true + } + return false + } + + private func previewForSQL(_ sql: String) -> String { + let nsSQL = sql as NSString + guard nsSQL.length > 300 else { return sql } + let head = nsSQL.substring(to: 300) + let hidden = nsSQL.length - 300 + return head + String(format: String(localized: "\n\n… (%d more characters not shown)"), hidden) + } + + // MARK: - Database URL + + private func openDatabaseURL(_ url: URL) async throws { + guard case .success(let parsed) = ConnectionURLParser.parse(url.absoluteString) else { + throw TabRouterError.malformedDatabaseURL(url) + } + + let connections = ConnectionStorage.shared.loadConnections() + let matched = connections.first { conn in + conn.type == parsed.type + && conn.host == parsed.host + && (parsed.port == nil || conn.port == parsed.port) + && conn.database == parsed.database + && (parsed.username.isEmpty || conn.username == parsed.username) + } + + let connection: DatabaseConnection + let isTransient: Bool + if let matched { + connection = matched + isTransient = false + } else { + connection = TransientConnectionFactory.build(from: parsed) + isTransient = true + } + + if !parsed.password.isEmpty { + ConnectionStorage.shared.savePassword(parsed.password, for: connection.id) + } + if let sshPass = parsed.sshPassword, !sshPass.isEmpty { + ConnectionStorage.shared.saveSSHPassword(sshPass, for: connection.id) + } + + do { + if let table = parsed.tableName { + try await openTable( + connectionId: connection.id, + transientConnection: isTransient ? connection : nil, + database: parsed.database.isEmpty ? nil : parsed.database, + schema: parsed.schema, + table: table, + isView: parsed.isView + ) + if parsed.filterColumn != nil || parsed.filterCondition != nil { + try await applyFilterFromParsedURL(parsed: parsed, connectionId: connection.id) + } + return + } + + try await runPreConnectScriptIfNeeded(connection) + let payload = EditorTabPayload(connectionId: connection.id, intent: .restoreOrDefault) + WindowManager.shared.openTab(payload: payload) + NSApp.activate(ignoringOtherApps: true) + try await DatabaseManager.shared.ensureConnected(connection) + closeWelcomeWindows() + + if let schema = parsed.schema { + await switchSchemaOrDatabase(connectionId: connection.id, target: schema) + } + } catch { + if isTransient { + ConnectionStorage.shared.deletePassword(for: connection.id) + ConnectionStorage.shared.deleteSSHPassword(for: connection.id) + } + throw error + } + } + + // MARK: - Database File + + private func openDatabaseFile(_ url: URL, type: DatabaseType) async throws { + let filePath = url.path(percentEncoded: false) + let connectionName = url.deletingPathExtension().lastPathComponent + + for (sessionId, session) in DatabaseManager.shared.activeSessions + where session.connection.type == type + && session.connection.database == filePath + && session.driver != nil { + bringConnectionWindowToFront(sessionId) + return + } + + let connection = DatabaseConnection( + name: connectionName, + host: "", + port: 0, + database: filePath, + username: "", + type: type + ) + + let payload = EditorTabPayload(connectionId: connection.id, intent: .restoreOrDefault) + WindowManager.shared.openTab(payload: payload) + NSApp.activate(ignoringOtherApps: true) + try await DatabaseManager.shared.ensureConnected(connection) + closeWelcomeWindows() + } + + // MARK: - SQL File + + private func openSQLFile(_ url: URL) async throws { + if let existing = WindowLifecycleMonitor.shared.window(forSourceFile: url) { + existing.makeKeyAndOrderFront(nil) + NSApp.activate(ignoringOtherApps: true) + return + } + + if let session = DatabaseManager.shared.currentSession { + let content = await Task.detached(priority: .userInitiated) { () -> String? in + try? String(contentsOf: url, encoding: .utf8) + }.value + guard let content else { + Self.logger.error("Failed to read SQL file: \(url.lastPathComponent, privacy: .public)") + return + } + let payload = EditorTabPayload( + connectionId: session.connection.id, + tabType: .query, + initialQuery: content, + sourceFileURL: url + ) + WindowManager.shared.openTab(payload: payload) + NSApp.activate(ignoringOtherApps: true) + } else { + WelcomeRouter.shared.enqueueSQLFile(url) + } + } + + // MARK: - Helpers + + internal func bringConnectionWindowToFront(_ connectionId: UUID) { + let windows = WindowLifecycleMonitor.shared.windows(for: connectionId) + if let window = windows.first { + window.makeKeyAndOrderFront(nil) + } else { + NSApp.windows.first { AppLaunchCoordinator.isMainWindow($0) && $0.isVisible }?.makeKeyAndOrderFront(nil) + } + NSApp.activate(ignoringOtherApps: true) + } + + private func switchSchemaOrDatabase(connectionId: UUID, target: String) async { + guard let coordinator = MainContentCoordinator.allActiveCoordinators() + .first(where: { $0.connectionId == connectionId }) else { return } + if PluginManager.shared.supportsSchemaSwitching(for: coordinator.connection.type) { + await coordinator.switchSchema(to: target) + } else { + await coordinator.switchDatabase(to: target) + } + } + + private func runPreConnectScriptIfNeeded(_ connection: DatabaseConnection) async throws { + guard let script = connection.preConnectScript, + !script.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty else { return } + let confirmed = await AlertHelper.confirmDestructive( + title: String(localized: "Pre-Connect Script"), + message: String( + format: String(localized: "Connection \"%@\" has a script that will run before connecting:\n\n%@"), + connection.name, script + ), + confirmButton: String(localized: "Run Script"), + cancelButton: String(localized: "Cancel"), + window: NSApp.keyWindow + ) + guard confirmed else { throw TabRouterError.userCancelled } + } + + private func applyFilterFromParsedURL(parsed: ParsedConnectionURL, connectionId: UUID) async throws { + let description: String + if let condition = parsed.filterCondition, !condition.isEmpty { + description = (condition as NSString).length > 300 + ? String(condition.prefix(300)) + "…" : condition + } else { + description = [parsed.filterColumn, parsed.filterOperation, parsed.filterValue] + .compactMap { $0 }.joined(separator: " ") + } + if !description.isEmpty { + let confirmed = await AlertHelper.confirmDestructive( + title: String(localized: "Apply Filter from Link"), + message: String( + format: String(localized: "An external link wants to apply a filter:\n\n%@"), + description + ), + confirmButton: String(localized: "Apply Filter"), + cancelButton: String(localized: "Cancel"), + window: NSApp.keyWindow + ) + guard confirmed else { throw TabRouterError.userCancelled } + } + + guard let coordinator = MainContentCoordinator.allActiveCoordinators() + .first(where: { $0.connectionId == connectionId }) else { return } + coordinator.applyURLFilter( + condition: parsed.filterCondition, + column: parsed.filterColumn, + operation: parsed.filterOperation, + value: parsed.filterValue + ) + } + + private func closeWelcomeWindows() { + for window in NSApp.windows where AppLaunchCoordinator.isWelcomeWindow(window) { + window.close() + } + } +} diff --git a/TablePro/Core/Services/Infrastructure/URLClassifier.swift b/TablePro/Core/Services/Infrastructure/URLClassifier.swift new file mode 100644 index 000000000..114cf13c8 --- /dev/null +++ b/TablePro/Core/Services/Infrastructure/URLClassifier.swift @@ -0,0 +1,48 @@ +// +// URLClassifier.swift +// TablePro +// + +import Foundation + +@MainActor +internal enum URLClassifier { + internal static func classify(_ url: URL) -> Result? { + if url.scheme == "tablepro" { + return DeeplinkParser.parse(url) + } + if url.isFileURL { + return classifyFile(url) + } + if isDatabaseURL(url) { + return .success(.openDatabaseURL(url)) + } + return nil + } + + private static func classifyFile(_ url: URL) -> Result? { + let ext = url.pathExtension.lowercased() + if ext == "tableplugin" { + return .success(.installPlugin(url)) + } + if ext == "tablepro" { + return .success(.openConnectionShare(url)) + } + if ext == "sql" { + return .success(.openSQLFile(url)) + } + if let dbType = PluginManager.shared.allRegisteredFileExtensions[ext] { + return .success(.openDatabaseFile(url, dbType)) + } + return nil + } + + private static func isDatabaseURL(_ url: URL) -> Bool { + guard let scheme = url.scheme?.lowercased() else { return false } + let base = scheme + .replacingOccurrences(of: "+ssh", with: "") + .replacingOccurrences(of: "+srv", with: "") + let registered = PluginManager.shared.allRegisteredURLSchemes + return registered.contains(base) || registered.contains(scheme) + } +} diff --git a/TablePro/Core/Services/Infrastructure/WelcomeRouter.swift b/TablePro/Core/Services/Infrastructure/WelcomeRouter.swift new file mode 100644 index 000000000..f0997e8c9 --- /dev/null +++ b/TablePro/Core/Services/Infrastructure/WelcomeRouter.swift @@ -0,0 +1,70 @@ +// +// WelcomeRouter.swift +// TablePro +// + +import AppKit +import Foundation +import Observation + +@MainActor +@Observable +internal final class WelcomeRouter { + internal static let shared = WelcomeRouter() + + private(set) var pendingImport: ExportableConnection? + private(set) var pendingConnectionShare: URL? + private(set) var pendingSQLFiles: [URL] = [] + + private init() { + NotificationCenter.default.addObserver( + forName: .databaseDidConnect, object: nil, queue: .main + ) { _ in + MainActor.assumeIsolated { + WelcomeRouter.shared.drainPendingSQLFiles() + } + } + } + + private func drainPendingSQLFiles() { + let urls = consumePendingSQLFiles() + guard !urls.isEmpty else { return } + NotificationCenter.default.post(name: .openSQLFiles, object: urls) + } + + internal func routeImport(_ exportable: ExportableConnection) { + pendingImport = exportable + showWelcomeWindow() + } + + internal func routeShare(_ url: URL) { + pendingConnectionShare = url + showWelcomeWindow() + } + + internal func enqueueSQLFile(_ url: URL) { + pendingSQLFiles.append(url) + } + + internal func consumePendingImport() -> ExportableConnection? { + let value = pendingImport + pendingImport = nil + return value + } + + internal func consumePendingShare() -> URL? { + let value = pendingConnectionShare + pendingConnectionShare = nil + return value + } + + internal func consumePendingSQLFiles() -> [URL] { + let value = pendingSQLFiles + pendingSQLFiles.removeAll() + return value + } + + private func showWelcomeWindow() { + WelcomeWindowFactory.openOrFront() + } +} diff --git a/TablePro/Core/Services/Infrastructure/WelcomeWindowFactory.swift b/TablePro/Core/Services/Infrastructure/WelcomeWindowFactory.swift new file mode 100644 index 000000000..e64d00315 --- /dev/null +++ b/TablePro/Core/Services/Infrastructure/WelcomeWindowFactory.swift @@ -0,0 +1,55 @@ +// +// WelcomeWindowFactory.swift +// TablePro +// + +import AppKit +import SwiftUI + +@MainActor +internal enum WelcomeWindowFactory { + private static let identifier = NSUserInterfaceItemIdentifier("welcome") + private static let contentSize = NSSize(width: 700, height: 450) + + internal static func openOrFront() { + if let existing = existingWindow() { + existing.makeKeyAndOrderFront(nil) + NSApp.activate(ignoringOtherApps: true) + return + } + let window = makeWindow() + window.makeKeyAndOrderFront(nil) + NSApp.activate(ignoringOtherApps: true) + } + + internal static func close() { + existingWindow()?.close() + } + + internal static func orderOut() { + existingWindow()?.orderOut(nil) + } + + private static func existingWindow() -> NSWindow? { + NSApp.windows.first { AppLaunchCoordinator.isWelcomeWindow($0) } + } + + private static func makeWindow() -> NSWindow { + let hostingController = NSHostingController(rootView: WelcomeWindowView()) + let window = NSWindow(contentViewController: hostingController) + window.identifier = identifier + window.title = String(localized: "Welcome to TablePro") + window.styleMask = [.titled, .closable, .fullSizeContentView] + window.titleVisibility = .hidden + window.titlebarAppearsTransparent = true + window.isOpaque = false + window.backgroundColor = .clear + window.standardWindowButton(.miniaturizeButton)?.isHidden = true + window.standardWindowButton(.zoomButton)?.isHidden = true + window.collectionBehavior.insert(.fullScreenNone) + window.setContentSize(contentSize) + window.center() + window.isReleasedWhenClosed = false + return window + } +} diff --git a/TablePro/Core/Services/Infrastructure/WindowManager.swift b/TablePro/Core/Services/Infrastructure/WindowManager.swift index d4a18eedd..27af3c145 100644 --- a/TablePro/Core/Services/Infrastructure/WindowManager.swift +++ b/TablePro/Core/Services/Infrastructure/WindowManager.swift @@ -2,17 +2,6 @@ // WindowManager.swift // TablePro // -// Imperative AppKit window management for main editor tabs. -// Phase 1 scope: create TabWindowController, install into tab group with -// correct ordering (orderFront before addTabbedWindow — avoids the synchronous -// full-tree layout that slowed the earlier prototype 4–5×), retain strong -// reference, release on willClose. -// -// In later phases WindowManager will also absorb the lookup API currently -// on WindowLifecycleMonitor (windows(for:), previewWindow(for:), etc.). -// In Phase 1, WindowLifecycleMonitor keeps that responsibility — this -// manager only owns window creation + controller lifetime. -// import AppKit import os @@ -24,9 +13,6 @@ internal final class WindowManager { internal static let shared = WindowManager() - /// Strong refs keyed by NSWindow identity. Because - /// `NSWindow.isReleasedWhenClosed = false` on our windows, this is the - /// only owner — dropping the entry deallocates controller + window. private var controllers: [ObjectIdentifier: TabWindowController] = [:] private var closeObservers: [ObjectIdentifier: NSObjectProtocol] = [:] @@ -34,25 +20,12 @@ internal final class WindowManager { // MARK: - Open - /// Creates and shows a new main-editor window hosting ContentView(payload:). - /// If a sibling window with the same tabbingIdentifier is already visible, - /// the new window joins its tab group. internal func openTab(payload: EditorTabPayload) { let t0 = Date() Self.lifecycleLogger.info( "[open] WindowManager.openTab start payloadId=\(payload.id, privacy: .public) connId=\(payload.connectionId, privacy: .public) intent=\(String(describing: payload.intent), privacy: .public) skipAutoExecute=\(payload.skipAutoExecute)" ) - // Eagerly create SessionState (coordinator + tab manager + toolbar state) - // BEFORE constructing the controller. This lets `TabWindowController.init` - // install the NSToolbar synchronously — so the window's first paint - // already has it, eliminating the toolbar-flash that occurs when the - // toolbar is installed later via `configureWindow` (which runs only - // after the window is on-screen). - // - // The same SessionState is handed off to ContentView via - // `SessionStateFactory.consumePending` so only ONE coordinator exists - // per window — no duplicate tabs. let resolvedConnection = DatabaseManager.shared.activeSessions[payload.connectionId]?.connection let preCreatedSessionState: SessionStateFactory.SessionState? if let resolvedConnection { @@ -60,9 +33,6 @@ internal final class WindowManager { SessionStateFactory.registerPending(state, for: payload.id) preCreatedSessionState = state } else { - // Connection not ready yet (welcome → connect race). Fall back to - // lazy SessionState creation inside ContentView.init + lazy toolbar - // install via configureWindow. preCreatedSessionState = nil } @@ -71,31 +41,14 @@ internal final class WindowManager { Self.lifecycleLogger.error( "[open] WindowManager.openTab failed: controller has no window payloadId=\(payload.id, privacy: .public)" ) - // Clean up the pending state we registered above so it doesn't leak. SessionStateFactory.removePending(for: payload.id) return } retain(controller: controller, window: window) - // Pre-mark so AppDelegate.windowDidBecomeKey skips its tabbing-merge - // block (we do the merge here, at creation, with the correct ordering). - if let appDelegate = NSApp.delegate as? AppDelegate { - appDelegate.configuredWindows.insert(ObjectIdentifier(window)) - } - - // --- Tab-group merge, correctly ordered --- - // - // The earlier prototype called `addTabbedWindow(window, …)` before - // the window was visible. AppKit responded by synchronously flushing - // the NSHostingView's SwiftUI layout (NavigationSplitView + editor + - // TreeSitterClient warmup) on the main thread — observed cost - // 800–960 ms per open. - // - // Ordering `orderFront(nil)` first makes the window visible and lets - // SwiftUI render asynchronously via its normal display cycle. Then - // `addTabbedWindow` re-parents an already-visible window into the - // tab group, which is a cheap AppKit-level operation. + // orderFront before addTabbedWindow avoids a synchronous full-tree + // SwiftUI layout pass that adds 700-900ms per open. let tabbingId = window.tabbingIdentifier ?? "" let groupAll = AppSettingsManager.shared.tabs.groupAllConnectionTabs let sibling = findSibling( @@ -103,13 +56,7 @@ internal final class WindowManager { ) if let sibling { - // Tab-merge: `addTabbedWindow(_:ordered:)` both adds the window to - // the group AND orders it — calling orderFront separately beforehand - // triggers a redundant layout pass on NSHostingView (observed cost - // 700-900ms vs. 75ms standalone). Let addTabbedWindow do both at once. if groupAll { - // groupAll mode: retag every visible main window with the unified - // identifier so addTabbedWindow is willing to merge. let otherMains = NSApp.windows.filter { $0 !== window && Self.isMainWindow($0) && $0.isVisible } @@ -119,29 +66,19 @@ internal final class WindowManager { } let target = sibling.tabbedWindows?.last ?? sibling target.addTabbedWindow(window, ordered: .above) - // `addTabbedWindow(_:ordered:)` only inserts — it doesn't select - // the new tab in the group. `makeKeyAndOrderFront` brings this - // window to the front of the group AND makes it key, which is - // what the user expects on Cmd+T. window.makeKeyAndOrderFront(nil) Self.lifecycleLogger.info( "[open] WindowManager joined existing tab group payloadId=\(payload.id, privacy: .public) tabbingId=\(tabbingId, privacy: .public)" ) } else { - // Standalone case: center the frame BEFORE showing so the window - // doesn't flash at the default (0,0) position before jumping. - // `makeKeyAndOrderFront` is the standard AppKit idiom for this. window.center() window.makeKeyAndOrderFront(nil) - // Ensure the app is active when opening from a background context - // (e.g. Welcome window's Connect button races with welcome close). NSApp.activate(ignoringOtherApps: true) Self.lifecycleLogger.info( "[open] WindowManager standalone window payloadId=\(payload.id, privacy: .public) tabbingId=\(tabbingId, privacy: .public)" ) } - Self.lifecycleLogger.info( "[open] WindowManager.openTab done payloadId=\(payload.id, privacy: .public) elapsedMs=\(Int(Date().timeIntervalSince(t0) * 1_000))" ) @@ -177,10 +114,6 @@ internal final class WindowManager { return raw == "main" || raw.hasPrefix("main-") } - /// Tabbing identifier for a connection. Per-connection by default; - /// shared "com.TablePro.main" when the user enables Group All Connection - /// Tabs in Settings → Tabs. Used by `TabWindowController.init` and by - /// AppDelegate's pre-Phase-1 fallback in `windowDidBecomeKey`. internal static func tabbingIdentifier(for connectionId: UUID) -> String { if AppSettingsManager.shared.tabs.groupAllConnectionTabs { return "com.TablePro.main" diff --git a/TablePro/Core/Services/Infrastructure/WindowOpener.swift b/TablePro/Core/Services/Infrastructure/WindowOpener.swift deleted file mode 100644 index e8a746911..000000000 --- a/TablePro/Core/Services/Infrastructure/WindowOpener.swift +++ /dev/null @@ -1,49 +0,0 @@ -// -// WindowOpener.swift -// TablePro -// -// Bridges SwiftUI's `OpenWindowAction` to imperative call sites for the -// remaining SwiftUI scenes (Welcome, Connection Form, Settings). The main -// editor windows no longer use this — they go through `WindowManager.openTab` -// directly. -// - -import os -import SwiftUI - -@MainActor -internal final class WindowOpener { - private static let logger = Logger(subsystem: "com.TablePro", category: "WindowOpener") - - internal static let shared = WindowOpener() - - private var readyContinuations: [CheckedContinuation] = [] - - /// Set on appear by `OpenWindowHandler` (TableProApp). Used to open the - /// welcome window, connection form, and settings from imperative code. - /// Safe to store — `OpenWindowAction` is app-scoped, not view-scoped. - internal var openWindow: OpenWindowAction? { - didSet { - if openWindow != nil { - for continuation in readyContinuations { - continuation.resume() - } - readyContinuations.removeAll() - } - } - } - - /// Suspends until `openWindow` is set. Returns immediately if available. - /// Used by Dock-menu / URL-scheme cold-launch paths that fire before any - /// SwiftUI view has appeared. - internal func waitUntilReady() async { - if openWindow != nil { return } - await withCheckedContinuation { continuation in - if openWindow != nil { - continuation.resume() - } else { - readyContinuations.append(continuation) - } - } - } -} diff --git a/TablePro/Core/Services/Query/SchemaService.swift b/TablePro/Core/Services/Query/SchemaService.swift new file mode 100644 index 000000000..8df9bf0fc --- /dev/null +++ b/TablePro/Core/Services/Query/SchemaService.swift @@ -0,0 +1,87 @@ +// +// SchemaService.swift +// TablePro +// + +import Foundation +import os + +@MainActor +@Observable +final class SchemaService { + static let shared = SchemaService() + + private(set) var states: [UUID: SchemaState] = [:] + + @ObservationIgnored private var lastLoadDates: [UUID: Date] = [:] + @ObservationIgnored private let loadDedup = OnceTask() + @ObservationIgnored private static let logger = Logger(subsystem: "com.TablePro", category: "SchemaService") + + init() {} + + func state(for connectionId: UUID) -> SchemaState { + states[connectionId] ?? .idle + } + + func tables(for connectionId: UUID) -> [TableInfo] { + if case .loaded(let tables) = state(for: connectionId) { + return tables + } + return [] + } + + func load(connectionId: UUID, driver: DatabaseDriver, connection: DatabaseConnection) async { + switch state(for: connectionId) { + case .loaded: + return + case .idle, .loading, .failed: + await runLoad(connectionId: connectionId, driver: driver, connection: connection) + } + } + + func reload(connectionId: UUID, driver: DatabaseDriver, connection: DatabaseConnection) async { + await runLoad(connectionId: connectionId, driver: driver, connection: connection) + } + + func reloadIfStale( + connectionId: UUID, + driver: DatabaseDriver, + connection: DatabaseConnection, + staleness: TimeInterval + ) async { + guard let lastLoad = lastLoadDates[connectionId] else { + await reload(connectionId: connectionId, driver: driver, connection: connection) + return + } + guard Date().timeIntervalSince(lastLoad) > staleness else { return } + await reload(connectionId: connectionId, driver: driver, connection: connection) + } + + func invalidate(connectionId: UUID) async { + await loadDedup.cancel(key: connectionId) + states.removeValue(forKey: connectionId) + lastLoadDates.removeValue(forKey: connectionId) + } + + private func runLoad( + connectionId: UUID, + driver: DatabaseDriver, + connection: DatabaseConnection + ) async { + states[connectionId] = .loading + do { + let tables = try await loadDedup.execute(key: connectionId) { + try await driver.fetchTables() + } + states[connectionId] = .loaded(tables) + lastLoadDates[connectionId] = Date() + } catch is CancellationError { + return + } catch { + Self.logger.warning( + "[schema] load failed connId=\(connectionId, privacy: .public) error=\(error.localizedDescription, privacy: .public)" + ) + states[connectionId] = .failed(error.localizedDescription) + } + } +} diff --git a/TablePro/Core/Services/Query/SchemaState.swift b/TablePro/Core/Services/Query/SchemaState.swift new file mode 100644 index 000000000..c61cbe46d --- /dev/null +++ b/TablePro/Core/Services/Query/SchemaState.swift @@ -0,0 +1,13 @@ +// +// SchemaState.swift +// TablePro +// + +import Foundation + +enum SchemaState: Equatable, Sendable { + case idle + case loading + case loaded([TableInfo]) + case failed(String) +} diff --git a/TablePro/Core/Storage/QueryHistoryStorage.swift b/TablePro/Core/Storage/QueryHistoryStorage.swift index 52cd1855a..a1182a2d3 100644 --- a/TablePro/Core/Storage/QueryHistoryStorage.swift +++ b/TablePro/Core/Storage/QueryHistoryStorage.swift @@ -269,14 +269,32 @@ actor QueryHistoryStorage { offset: Int = 0, connectionId: UUID? = nil, searchText: String? = nil, - dateFilter: DateFilter = .all + dateFilter: DateFilter = .all, + since: Date? = nil, + until: Date? = nil, + allowedConnectionIds: Set? = nil ) -> [QueryHistoryEntry] { var entries: [QueryHistoryEntry] = [] + if let allowedConnectionIds, allowedConnectionIds.isEmpty { + return entries + } + + let effectiveSince = [dateFilter.startDate, since].compactMap { $0 }.max() + + let allowedList: [UUID]? + if let allowedConnectionIds { + allowedList = Array(allowedConnectionIds) + } else { + allowedList = nil + } + var sql: String var bindIndex: Int32 = 1 var hasConnectionFilter = false - var hasDateFilter = false + var hasSinceFilter = false + var hasUntilFilter = false + var hasAllowedFilter = false if let searchText = searchText, !searchText.isEmpty { sql = """ @@ -291,9 +309,20 @@ actor QueryHistoryStorage { hasConnectionFilter = true } - if dateFilter.startDate != nil { + if let allowedList { + let placeholders = Array(repeating: "?", count: allowedList.count).joined(separator: ", ") + sql += " AND h.connection_id IN (\(placeholders))" + hasAllowedFilter = true + } + + if effectiveSince != nil { sql += " AND h.executed_at >= ?" - hasDateFilter = true + hasSinceFilter = true + } + + if until != nil { + sql += " AND h.executed_at <= ?" + hasUntilFilter = true } } else { sql = @@ -306,9 +335,20 @@ actor QueryHistoryStorage { hasConnectionFilter = true } - if dateFilter.startDate != nil { + if let allowedList { + let placeholders = Array(repeating: "?", count: allowedList.count).joined(separator: ", ") + whereClauses.append("connection_id IN (\(placeholders))") + hasAllowedFilter = true + } + + if effectiveSince != nil { whereClauses.append("executed_at >= ?") - hasDateFilter = true + hasSinceFilter = true + } + + if until != nil { + whereClauses.append("executed_at <= ?") + hasUntilFilter = true } if !whereClauses.isEmpty { @@ -338,8 +378,20 @@ actor QueryHistoryStorage { bindIndex += 1 } - if let startDate = dateFilter.startDate, hasDateFilter { - sqlite3_bind_double(statement, bindIndex, startDate.timeIntervalSince1970) + if let allowedList, hasAllowedFilter { + for allowedId in allowedList { + sqlite3_bind_text(statement, bindIndex, allowedId.uuidString, -1, SQLITE_TRANSIENT) + bindIndex += 1 + } + } + + if let effectiveSince, hasSinceFilter { + sqlite3_bind_double(statement, bindIndex, effectiveSince.timeIntervalSince1970) + bindIndex += 1 + } + + if let until, hasUntilFilter { + sqlite3_bind_double(statement, bindIndex, until.timeIntervalSince1970) bindIndex += 1 } diff --git a/TablePro/Core/Storage/TabDiskActor.swift b/TablePro/Core/Storage/TabDiskActor.swift index 8f3382948..25e81f7a1 100644 --- a/TablePro/Core/Storage/TabDiskActor.swift +++ b/TablePro/Core/Storage/TabDiskActor.swift @@ -10,16 +10,11 @@ import Foundation import os -/// Persisted tab state for a connection internal struct TabDiskState: Codable { let tabs: [PersistedTab] let selectedTabId: UUID? } -/// Actor that serializes all tab-state disk I/O. -/// -/// Data is stored as individual JSON files per connection in: -/// `~/Library/Application Support/TablePro/TabState/` internal actor TabDiskActor { internal static let shared = TabDiskActor() @@ -52,7 +47,6 @@ internal actor TabDiskActor { // MARK: - Public API - /// Save tab state for a connection. Throws on encoding or disk write failure. internal func save(connectionId: UUID, tabs: [PersistedTab], selectedTabId: UUID?) throws { let state = TabDiskState(tabs: tabs, selectedTabId: selectedTabId) let data = try encoder.encode(state) @@ -60,12 +54,6 @@ internal actor TabDiskActor { try data.write(to: fileURL, options: .atomic) } - /// Log a save error from callers that handle errors externally. - nonisolated static func logSaveError(connectionId: UUID, error: Error) { - logger.error("Failed to save tab state for \(connectionId): \(error.localizedDescription)") - } - - /// Load tab state for a connection. Returns nil if the file is missing or corrupt. internal func load(connectionId: UUID) -> TabDiskState? { let fileURL = tabStateFileURL(for: connectionId) @@ -82,7 +70,6 @@ internal actor TabDiskActor { } } - /// Delete the tab state file for a connection. internal func clear(connectionId: UUID) { let fileURL = tabStateFileURL(for: connectionId) @@ -95,7 +82,6 @@ internal actor TabDiskActor { } } - /// List all connection IDs that have saved tab state on disk. internal func connectionIdsWithSavedState() -> [UUID] { let fm = FileManager.default guard let files = try? fm.contentsOfDirectory( @@ -104,10 +90,18 @@ internal actor TabDiskActor { ) else { return [] } - return files.compactMap { url -> UUID? in - guard url.pathExtension == "json" else { return nil } - return UUID(uuidString: url.deletingPathExtension().lastPathComponent) + var validIds: [UUID] = [] + for url in files where url.pathExtension == "json" { + guard let id = UUID(uuidString: url.deletingPathExtension().lastPathComponent) else { continue } + if let data = try? Data(contentsOf: url), + let state = try? decoder.decode(TabDiskState.self, from: data), + !state.tabs.isEmpty { + validIds.append(id) + } else { + try? fm.removeItem(at: url) + } } + return validIds } // MARK: - Static Path Helpers @@ -127,9 +121,6 @@ internal actor TabDiskActor { // MARK: - Synchronous Save (quit-time only) - /// Synchronous file write for `applicationWillTerminate`, where no run loop - /// remains to execute an async Task. Safe because the process is single-threaded - /// at termination — no concurrent actor access is possible. nonisolated internal static func saveSync( connectionId: UUID, tabs: [PersistedTab], @@ -145,7 +136,17 @@ internal actor TabDiskActor { let fileURL = tabStateFileURL(for: connectionId) try data.write(to: fileURL, options: .atomic) } catch { - logger.error("saveSync failed for \(connectionId): \(error.localizedDescription)") + logger.fault("saveSync failed for \(connectionId): \(error.localizedDescription)") + } + } + + nonisolated internal static func clearSync(connectionId: UUID) { + let fileURL = tabStateFileURL(for: connectionId) + guard FileManager.default.fileExists(atPath: fileURL.path) else { return } + do { + try FileManager.default.removeItem(at: fileURL) + } catch { + logger.fault("clearSync failed for \(connectionId): \(error.localizedDescription)") } } @@ -157,9 +158,6 @@ internal actor TabDiskActor { // MARK: - Migration from UserDefaults - /// One-time migration: reads existing tab state from UserDefaults, - /// writes it to file storage, then clears the old UserDefaults keys. - /// This is a static method to avoid actor-isolation issues during init. private static func performMigrationIfNeeded(tabStateDirectory: URL) { let defaults = UserDefaults.standard diff --git a/TablePro/Core/Utilities/Connection/TransientConnectionFactory.swift b/TablePro/Core/Utilities/Connection/TransientConnectionFactory.swift new file mode 100644 index 000000000..ea2fa15a2 --- /dev/null +++ b/TablePro/Core/Utilities/Connection/TransientConnectionFactory.swift @@ -0,0 +1,71 @@ +// +// TransientConnectionFactory.swift +// TablePro +// + +import Foundation + +@MainActor +internal enum TransientConnectionFactory { + internal static func build(from parsed: ParsedConnectionURL) -> DatabaseConnection { + var sshConfig = SSHConfiguration() + if let sshHost = parsed.sshHost { + sshConfig.enabled = true + sshConfig.host = sshHost + sshConfig.port = parsed.sshPort ?? 22 + sshConfig.username = parsed.sshUsername ?? "" + if parsed.usePrivateKey == true { + sshConfig.authMethod = .privateKey + } + if parsed.useSSHAgent == true { + sshConfig.authMethod = .sshAgent + sshConfig.agentSocketPath = parsed.agentSocket ?? "" + } + } + + var sslConfig = SSLConfiguration() + if let sslMode = parsed.sslMode { + sslConfig.mode = sslMode + } + + var color: ConnectionColor = .none + if let hex = parsed.statusColor { + color = ConnectionURLParser.connectionColor(fromHex: hex) + } + + var tagId: UUID? + if let envName = parsed.envTag { + tagId = ConnectionURLParser.tagId(fromEnvName: envName) + } + + let resolvedSafeMode = parsed.safeModeLevel.flatMap(SafeModeLevel.from(urlInteger:)) ?? .silent + + var connection = DatabaseConnection( + name: parsed.connectionName ?? parsed.suggestedName, + host: parsed.host, + port: parsed.port ?? parsed.type.defaultPort, + database: parsed.database, + username: parsed.username, + type: parsed.type, + sshConfig: sshConfig, + sslConfig: sslConfig, + color: color, + tagId: tagId, + safeModeLevel: resolvedSafeMode, + mongoAuthSource: parsed.authSource, + mongoUseSrv: parsed.useSrv, + mongoAuthMechanism: parsed.mongoQueryParams["authMechanism"], + mongoReplicaSet: parsed.mongoQueryParams["replicaSet"], + redisDatabase: parsed.redisDatabase, + oracleServiceName: parsed.oracleServiceName + ) + + for (key, value) in parsed.mongoQueryParams where !value.isEmpty { + if key != "authMechanism" && key != "replicaSet" { + connection.additionalFields["mongoParam_\(key)"] = value + } + } + + return connection + } +} diff --git a/TablePro/Core/Utilities/UI/AlertHelper.swift b/TablePro/Core/Utilities/UI/AlertHelper.swift index 07341e5e8..e761369e9 100644 --- a/TablePro/Core/Utilities/UI/AlertHelper.swift +++ b/TablePro/Core/Utilities/UI/AlertHelper.swift @@ -2,31 +2,18 @@ // AlertHelper.swift // TablePro // -// Created by TablePro on 1/19/26. -// import AppKit +import SwiftUI -/// Centralized helper for creating and displaying NSAlert dialogs -/// Provides consistent styling and behavior across the application @MainActor final class AlertHelper { - /// Tries multiple sources to find a presentable window, minimizing runModal() fallback usage. static func resolveWindow(_ window: NSWindow?) -> NSWindow? { window ?? NSApp.keyWindow ?? NSApp.mainWindow ?? NSApp.windows.first { $0.isVisible } } // MARK: - Destructive Confirmations - /// Shows a destructive confirmation dialog (warning style) - /// Uses async sheet presentation when window is available, falls back to modal - /// - Parameters: - /// - title: Alert title - /// - message: Detailed message - /// - confirmButton: Label for destructive action button (default: "OK") - /// - cancelButton: Label for cancel button (default: "Cancel") - /// - window: Parent window to attach sheet to (optional) - /// - Returns: true if user confirmed, false if cancelled static func confirmDestructive( title: String, message: String, @@ -41,32 +28,18 @@ final class AlertHelper { alert.addButton(withTitle: confirmButton) alert.addButton(withTitle: cancelButton) - // Use sheet presentation when window is available (non-blocking, Swift 6 friendly) if let window = resolveWindow(window) { return await withCheckedContinuation { continuation in alert.beginSheetModal(for: window) { response in continuation.resume(returning: response == .alertFirstButtonReturn) } } - } else { - // Fallback to modal when no window available - let response = alert.runModal() - return response == .alertFirstButtonReturn } + return alert.runModal() == .alertFirstButtonReturn } // MARK: - Critical Confirmations - /// Shows a critical confirmation dialog (critical style) - /// Uses async sheet presentation when window is available, falls back to modal - /// Used for dangerous operations like DROP, TRUNCATE, DELETE without WHERE - /// - Parameters: - /// - title: Alert title - /// - message: Detailed message - /// - confirmButton: Label for dangerous action button (default: "Execute") - /// - cancelButton: Label for cancel button (default: "Cancel") - /// - window: Parent window to attach sheet to (optional) - /// - Returns: true if user confirmed, false if cancelled static func confirmCritical( title: String, message: String, @@ -81,33 +54,81 @@ final class AlertHelper { alert.addButton(withTitle: confirmButton) alert.addButton(withTitle: cancelButton) - // Use sheet presentation when window is available (non-blocking, Swift 6 friendly) if let window = resolveWindow(window) { return await withCheckedContinuation { continuation in alert.beginSheetModal(for: window) { response in continuation.resume(returning: response == .alertFirstButtonReturn) } } - } else { - // Fallback to modal when no window available - let response = alert.runModal() - return response == .alertFirstButtonReturn + } + return alert.runModal() == .alertFirstButtonReturn + } + + // MARK: - Cross-Process Approval + + static func runApprovalModal( + title: String, + message: String, + confirm: String, + cancel: String + ) async -> Bool { + NSApp.activate(ignoringOtherApps: true) + let alert = NSAlert() + alert.messageText = title + alert.informativeText = message + alert.alertStyle = .warning + alert.addButton(withTitle: confirm) + alert.addButton(withTitle: cancel) + return alert.runModal() == .alertFirstButtonReturn + } + + static func runPairingApproval(request: PairingRequest) async throws -> PairingApproval { + try await withCheckedThrowingContinuation { continuation in + var deliver: ((Result) -> Void)? + let codeExpiresAt = Date.now.addingTimeInterval(PairingExchangeStore.exchangeWindow) + let host = NSHostingController( + rootView: PairingApprovalSheet( + request: request, + codeExpiresAt: codeExpiresAt, + onComplete: { result in deliver?(result) } + ) + ) + host.view.frame = NSRect(x: 0, y: 0, width: 520, height: 560) + + let parent = resolveWindow(nil) + let sheetWindow = NSWindow(contentViewController: host) + sheetWindow.styleMask = [.titled] + sheetWindow.title = String(localized: "Approve Integration") + sheetWindow.isReleasedWhenClosed = false + + var resolved = false + deliver = { result in + guard !resolved else { return } + resolved = true + if let parent { + parent.endSheet(sheetWindow) + } else { + sheetWindow.close() + } + continuation.resume(with: result) + } + + if let parent { + parent.beginSheet(sheetWindow, completionHandler: nil) + } else { + NSApp.activate(ignoringOtherApps: true) + sheetWindow.center() + sheetWindow.makeKeyAndOrderFront(nil) + } } } // MARK: - Save Changes Confirmation - /// Result of a standard macOS save-changes confirmation dialog enum SaveConfirmationResult { case save, dontSave, cancel } - /// Shows the standard macOS "save changes before closing?" dialog. - /// Button layout matches NSDocument convention: Save (default) | Cancel | Don't Save (Cmd+D). - /// - Parameters: - /// - message: Detailed message explaining what has unsaved changes - /// - window: Parent window to attach sheet to (optional) - /// - Returns: The user's choice static func confirmSaveChanges( message: String, window: NSWindow? = nil @@ -117,17 +138,15 @@ final class AlertHelper { alert.informativeText = message alert.alertStyle = .warning - // Button order follows macOS convention (rightmost → leftmost): - // [Don't Save] [Cancel] [Save] - alert.addButton(withTitle: String(localized: "Save")) // alertFirstButtonReturn (default) - alert.addButton(withTitle: String(localized: "Cancel")) // alertSecondButtonReturn - let dontSaveButton = alert.addButton(withTitle: String(localized: "Don't Save")) // alertThirdButtonReturn + // Button order follows NSDocument convention: Save | Cancel | Don't Save (Cmd+D) + alert.addButton(withTitle: String(localized: "Save")) + alert.addButton(withTitle: String(localized: "Cancel")) + let dontSaveButton = alert.addButton(withTitle: String(localized: "Don't Save")) dontSaveButton.hasDestructiveAction = true dontSaveButton.keyEquivalent = "d" dontSaveButton.keyEquivalentModifierMask = .command let response: NSApplication.ModalResponse - if let window = resolveWindow(window) { response = await withCheckedContinuation { continuation in alert.beginSheetModal(for: window) { resp in @@ -139,27 +158,14 @@ final class AlertHelper { } switch response { - case .alertFirstButtonReturn: - return .save - case .alertThirdButtonReturn: - return .dontSave - default: - return .cancel + case .alertFirstButtonReturn: return .save + case .alertThirdButtonReturn: return .dontSave + default: return .cancel } } // MARK: - Three-Way Confirmations - /// Shows a three-option confirmation dialog - /// Uses async sheet presentation when window is available, falls back to modal - /// - Parameters: - /// - title: Alert title - /// - message: Detailed message - /// - first: Label for first button - /// - second: Label for second button - /// - third: Label for third button - /// - window: Parent window to attach sheet to (optional) - /// - Returns: 0 for first button, 1 for second, 2 for third static func confirmThreeWay( title: String, message: String, @@ -177,8 +183,6 @@ final class AlertHelper { alert.addButton(withTitle: third) let response: NSApplication.ModalResponse - - // Use sheet presentation when window is available (non-blocking, Swift 6 friendly) if let window = resolveWindow(window) { response = await withCheckedContinuation { continuation in alert.beginSheetModal(for: window) { resp in @@ -186,29 +190,19 @@ final class AlertHelper { } } } else { - // Fallback to modal when no window available response = alert.runModal() } switch response { - case .alertFirstButtonReturn: - return 0 - case .alertSecondButtonReturn: - return 1 - case .alertThirdButtonReturn: - return 2 - default: - return 2 // Default to third option (usually cancel) + case .alertFirstButtonReturn: return 0 + case .alertSecondButtonReturn: return 1 + case .alertThirdButtonReturn: return 2 + default: return 2 } } - // MARK: - Error Sheets + // MARK: - Error / Info Sheets - /// Shows an error message as a non-blocking sheet - /// - Parameters: - /// - title: Error title - /// - message: Error details - /// - window: Parent window to attach sheet to (optional, falls back to modal) static func showErrorSheet( title: String, message: String, @@ -221,22 +215,12 @@ final class AlertHelper { alert.addButton(withTitle: String(localized: "OK")) if let window = resolveWindow(window) { - alert.beginSheetModal(for: window) { _ in - // Sheet dismissed, no action needed - } + alert.beginSheetModal(for: window) { _ in } } else { - // Fallback to modal if no window available alert.runModal() } } - // MARK: - Info Sheets - - /// Shows an informational message as a non-blocking sheet - /// - Parameters: - /// - title: Info title - /// - message: Info details - /// - window: Parent window to attach sheet to (optional, falls back to modal) static func showInfoSheet( title: String, message: String, @@ -249,23 +233,14 @@ final class AlertHelper { alert.addButton(withTitle: String(localized: "OK")) if let window = resolveWindow(window) { - alert.beginSheetModal(for: window) { _ in - // Sheet dismissed, no action needed - } + alert.beginSheetModal(for: window) { _ in } } else { - // Fallback to modal if no window available alert.runModal() } } // MARK: - Query Error with AI Option - /// Shows a query error dialog with an option to ask AI to fix it - /// - Parameters: - /// - title: Error title - /// - message: Error details - /// - window: Parent window to attach sheet to (optional) - /// - Returns: true if "Ask AI to Fix" was clicked static func showQueryErrorWithAIOption( title: String, message: String, @@ -284,9 +259,7 @@ final class AlertHelper { continuation.resume(returning: response == .alertSecondButtonReturn) } } - } else { - let response = alert.runModal() - return response == .alertSecondButtonReturn } + return alert.runModal() == .alertSecondButtonReturn } } diff --git a/TablePro/Extensions/URL+SanitizedLogging.swift b/TablePro/Extensions/URL+SanitizedLogging.swift new file mode 100644 index 000000000..18f1fc93c --- /dev/null +++ b/TablePro/Extensions/URL+SanitizedLogging.swift @@ -0,0 +1,17 @@ +// +// URL+SanitizedLogging.swift +// TablePro +// + +import Foundation + +internal extension URL { + var sanitizedForLogging: String { + guard var components = URLComponents(url: self, resolvingAgainstBaseURL: false), + components.password != nil else { + return absoluteString + } + components.password = "***" + return components.string ?? absoluteString + } +} diff --git a/TablePro/Info.plist b/TablePro/Info.plist index 1cd4d004c..d5e98b81d 100644 --- a/TablePro/Info.plist +++ b/TablePro/Info.plist @@ -22,10 +22,11 @@ CFBundleTypeRole Editor LSHandlerRank - Default + Owner LSItemContentTypes com.tablepro.sql + public.sql @@ -61,7 +62,7 @@ CFBundleTypeRole Editor LSHandlerRank - Alternate + Default LSItemContentTypes com.apple.sqlite3 @@ -88,9 +89,9 @@ CFBundleTypeName DuckDB Database CFBundleTypeRole - Viewer + Editor LSHandlerRank - Alternate + Owner CFBundleTypeExtensions duckdb @@ -102,7 +103,7 @@ - UTImportedTypeDeclarations + UTExportedTypeDeclarations UTTypeIdentifier @@ -113,6 +114,7 @@ SQLDocument UTTypeConformsTo + public.sql public.plain-text UTTypeTagSpecification @@ -130,6 +132,7 @@ SQLite Database UTTypeConformsTo + com.apple.sqlite3 public.database public.data @@ -147,14 +150,15 @@ + UTTypeIdentifier + com.tablepro.duckdb + UTTypeDescription + DuckDB Database UTTypeConformsTo + public.database public.data - UTTypeDescription - DuckDB Database - UTTypeIdentifier - com.tablepro.duckdb UTTypeTagSpecification public.filename-extension @@ -164,9 +168,6 @@ - - UTExportedTypeDeclarations - UTTypeIdentifier com.tablepro.connection-share diff --git a/TablePro/Models/AuditEntry.swift b/TablePro/Models/AuditEntry.swift new file mode 100644 index 000000000..8b09a5f2f --- /dev/null +++ b/TablePro/Models/AuditEntry.swift @@ -0,0 +1,112 @@ +// +// AuditEntry.swift +// TablePro +// + +import Foundation + +enum AuditCategory: String, Codable, CaseIterable, Sendable, Identifiable { + case auth + case access + case admin + case query + case tool + case resource + + var id: String { rawValue } + + var displayName: String { + switch self { + case .auth: + String(localized: "Authentication") + case .access: + String(localized: "Access") + case .admin: + String(localized: "Administration") + case .query: + String(localized: "Query") + case .tool: + String(localized: "Tool") + case .resource: + String(localized: "Resource") + } + } +} + +enum AuditOutcome: String, Codable, Sendable { + case success + case denied + case error + case rateLimited + + var displayName: String { + switch self { + case .success: + String(localized: "Success") + case .denied: + String(localized: "Denied") + case .error: + String(localized: "Error") + case .rateLimited: + String(localized: "Rate limited") + } + } +} + +struct AuditEntry: Codable, Identifiable, Sendable, Equatable, Hashable { + let id: UUID + let timestamp: Date + let category: AuditCategory + let tokenId: UUID? + let tokenName: String? + let connectionId: UUID? + let action: String + let outcome: String + let details: String? + + init( + id: UUID = UUID(), + timestamp: Date = Date(), + category: AuditCategory, + tokenId: UUID? = nil, + tokenName: String? = nil, + connectionId: UUID? = nil, + action: String, + outcome: String, + details: String? = nil + ) { + self.id = id + self.timestamp = timestamp + self.category = category + self.tokenId = tokenId + self.tokenName = tokenName + self.connectionId = connectionId + self.action = action + self.outcome = outcome + self.details = details + } + + init( + id: UUID = UUID(), + timestamp: Date = Date(), + category: AuditCategory, + tokenId: UUID? = nil, + tokenName: String? = nil, + connectionId: UUID? = nil, + action: String, + outcome: AuditOutcome, + details: String? = nil + ) { + self.init( + id: id, + timestamp: timestamp, + category: category, + tokenId: tokenId, + tokenName: tokenName, + connectionId: connectionId, + action: action, + outcome: outcome.rawValue, + details: details + ) + } +} diff --git a/TablePro/Models/Connection/ConnectionSession.swift b/TablePro/Models/Connection/ConnectionSession.swift index 806907039..734b2bc95 100644 --- a/TablePro/Models/Connection/ConnectionSession.swift +++ b/TablePro/Models/Connection/ConnectionSession.swift @@ -18,7 +18,6 @@ struct ConnectionSession: Identifiable { var lastError: String? // Per-connection state - var tables: [TableInfo] = [] var selectedTables: Set = [] var pendingTruncates: Set = [] var pendingDeletes: Set = [] @@ -26,6 +25,11 @@ struct ConnectionSession: Identifiable { var currentSchema: String? var currentDatabase: String? + @MainActor + var tables: [TableInfo] { + SchemaService.shared.tables(for: id) + } + /// In-memory password for prompt-for-password connections. Never persisted to disk. var cachedPassword: String? @@ -63,7 +67,6 @@ struct ConnectionSession: Identifiable { /// to release memory held by stale table metadata. /// Note: `cachedPassword` is intentionally NOT cleared — auto-reconnect needs it after disconnect. mutating func clearCachedData() { - tables = [] selectedTables = [] pendingTruncates = [] pendingDeletes = [] @@ -80,12 +83,12 @@ struct ConnectionSession: Identifiable { /// Compares fields used by ContentView's body to avoid unnecessary SwiftUI re-renders. /// Excludes: driver (protocol, non-comparable), - /// lastActiveAt (volatile), lastError, effectiveConnection. + /// lastActiveAt (volatile), lastError, effectiveConnection, + /// tables (owned by SchemaService and observed independently). func isContentViewEquivalent(to other: ConnectionSession) -> Bool { id == other.id && status == other.status && connection == other.connection - && tables == other.tables && pendingTruncates == other.pendingTruncates && pendingDeletes == other.pendingDeletes && tableOperationOptions == other.tableOperationOptions diff --git a/TablePro/Models/Connection/ConnectionToolbarState.swift b/TablePro/Models/Connection/ConnectionToolbarState.swift index 9faaf3b75..484b38b82 100644 --- a/TablePro/Models/Connection/ConnectionToolbarState.swift +++ b/TablePro/Models/Connection/ConnectionToolbarState.swift @@ -195,6 +195,9 @@ final class ConnectionToolbarState { /// Whether the SQL review popover is showing var showSQLReviewPopover: Bool = false + /// Whether the connection switcher popover is showing + var showConnectionSwitcher: Bool = false + /// SQL statements to display in the review popover var previewStatements: [String] = [] diff --git a/TablePro/Models/Connection/DatabaseConnection.swift b/TablePro/Models/Connection/DatabaseConnection.swift index 7b47fb7cc..087dcac5b 100644 --- a/TablePro/Models/Connection/DatabaseConnection.swift +++ b/TablePro/Models/Connection/DatabaseConnection.swift @@ -144,6 +144,36 @@ extension DatabaseType { } } +// MARK: - External Access + +enum ExternalAccessLevel: String, Codable, Sendable, CaseIterable, Identifiable { + case blocked + case readOnly + case readWrite + + var id: String { rawValue } + + var displayName: String { + switch self { + case .blocked: return String(localized: "Blocked") + case .readOnly: return String(localized: "Read-Only") + case .readWrite: return String(localized: "Read-Write") + } + } + + private var rank: Int { + switch self { + case .blocked: 0 + case .readOnly: 1 + case .readWrite: 2 + } + } + + func satisfies(_ required: ExternalAccessLevel) -> Bool { + rank >= required.rank + } +} + // MARK: - Connection Color /// Preset colors for connection status indicators @@ -213,6 +243,7 @@ struct DatabaseConnection: Identifiable, Hashable { var sshTunnelMode: SSHTunnelMode var safeModeLevel: SafeModeLevel var aiPolicy: AIConnectionPolicy? + var externalAccess: ExternalAccessLevel = .readOnly var additionalFields: [String: String] = [:] var redisDatabase: Int? var startupCommands: String? @@ -291,6 +322,7 @@ struct DatabaseConnection: Identifiable, Hashable { sshTunnelMode: SSHTunnelMode = .disabled, safeModeLevel: SafeModeLevel = .silent, aiPolicy: AIConnectionPolicy? = nil, + externalAccess: ExternalAccessLevel = .readOnly, mongoAuthSource: String? = nil, mongoReadPreference: String? = nil, mongoWriteConcern: String? = nil, @@ -335,6 +367,7 @@ struct DatabaseConnection: Identifiable, Hashable { self.sshTunnelMode = sshTunnelMode } self.aiPolicy = aiPolicy + self.externalAccess = externalAccess self.redisDatabase = redisDatabase self.startupCommands = startupCommands self.sortOrder = sortOrder @@ -385,7 +418,7 @@ extension DatabaseConnection: Codable { private enum CodingKeys: String, CodingKey { case id, name, host, port, database, username, type case sshConfig, sslConfig, color, tagId, groupId, sshProfileId - case sshTunnelMode, safeModeLevel, aiPolicy, additionalFields + case sshTunnelMode, safeModeLevel, aiPolicy, externalAccess, additionalFields case redisDatabase, startupCommands, sortOrder, localOnly } @@ -406,6 +439,7 @@ extension DatabaseConnection: Codable { sshProfileId = try container.decodeIfPresent(UUID.self, forKey: .sshProfileId) safeModeLevel = try container.decodeIfPresent(SafeModeLevel.self, forKey: .safeModeLevel) ?? .silent aiPolicy = try container.decodeIfPresent(AIConnectionPolicy.self, forKey: .aiPolicy) + externalAccess = try container.decodeIfPresent(ExternalAccessLevel.self, forKey: .externalAccess) ?? .readOnly additionalFields = try container.decodeIfPresent([String: String].self, forKey: .additionalFields) ?? [:] redisDatabase = try container.decodeIfPresent(Int.self, forKey: .redisDatabase) startupCommands = try container.decodeIfPresent(String.self, forKey: .startupCommands) @@ -446,6 +480,7 @@ extension DatabaseConnection: Codable { try container.encode(sshTunnelMode, forKey: .sshTunnelMode) try container.encode(safeModeLevel, forKey: .safeModeLevel) try container.encodeIfPresent(aiPolicy, forKey: .aiPolicy) + try container.encode(externalAccess, forKey: .externalAccess) try container.encode(additionalFields, forKey: .additionalFields) try container.encodeIfPresent(redisDatabase, forKey: .redisDatabase) try container.encodeIfPresent(startupCommands, forKey: .startupCommands) diff --git a/TablePro/Models/Query/QueryResult.swift b/TablePro/Models/Query/QueryResult.swift index e7fa54063..92434a0fc 100644 --- a/TablePro/Models/Query/QueryResult.swift +++ b/TablePro/Models/Query/QueryResult.swift @@ -72,7 +72,7 @@ enum DatabaseError: Error, LocalizedError { } /// Information about a database table -struct TableInfo: Identifiable, Hashable { +struct TableInfo: Identifiable, Hashable, Sendable { var id: String { "\(name)_\(type.rawValue)" } let name: String let type: TableType diff --git a/TablePro/Models/Query/QueryTabManager.swift b/TablePro/Models/Query/QueryTabManager.swift index 98c1be0f2..0f6d387c5 100644 --- a/TablePro/Models/Query/QueryTabManager.swift +++ b/TablePro/Models/Query/QueryTabManager.swift @@ -26,6 +26,12 @@ final class QueryTabManager { @ObservationIgnored private var _tabIndexMap: [UUID: Int] = [:] @ObservationIgnored private var _tabIndexMapDirty = true + @ObservationIgnored private let globalTabsProvider: () -> [QueryTab] + + init(globalTabsProvider: @escaping () -> [QueryTab] = { [] }) { + self.globalTabsProvider = globalTabsProvider + } + private func rebuildTabIndexMapIfNeeded() { guard _tabIndexMapDirty else { return } _tabIndexMap = Dictionary(uniqueKeysWithValues: tabs.enumerated().map { ($1.id, $0) }) @@ -50,11 +56,6 @@ final class QueryTabManager { return (tabs[index], index) } - init() { - tabs = [] - selectedTabId = nil - } - // MARK: - Tab Naming /// Next "Query N" title based on existing tabs across all windows. @@ -69,6 +70,10 @@ final class QueryTabManager { return "Query \(maxNumber + 1)" } + private func nextTitle() -> String { + Self.nextQueryTitle(existingTabs: globalTabsProvider() + tabs) + } + // MARK: - Tab Management func addTab(initialQuery: String? = nil, title: String? = nil, databaseName: String = "", sourceFileURL: URL? = nil) { @@ -81,7 +86,7 @@ final class QueryTabManager { return } - let tabTitle = title ?? Self.nextQueryTitle(existingTabs: tabs) + let tabTitle = title ?? nextTitle() var newTab = QueryTab(title: tabTitle, tabType: .query) if let query = initialQuery { @@ -181,6 +186,13 @@ final class QueryTabManager { databaseName: String = "", quoteIdentifier: ((String) -> String)? = nil ) throws { + if let existing = tabs.first(where: { + $0.tabType == .table && $0.tableContext.tableName == tableName && $0.tableContext.databaseName == databaseName + }) { + selectedTabId = existing.id + return + } + let pageSize = AppSettingsManager.shared.dataGrid.defaultPageSize let query = try QueryTab.buildBaseTableQuery( tableName: tableName, databaseType: databaseType, quoteIdentifier: quoteIdentifier diff --git a/TablePro/Resources/Localizable.xcstrings b/TablePro/Resources/Localizable.xcstrings index 1dfd89061..bdb760dc2 100644 --- a/TablePro/Resources/Localizable.xcstrings +++ b/TablePro/Resources/Localizable.xcstrings @@ -2668,6 +2668,9 @@ } } } + }, + "1 day" : { + }, "1 of %lld conflicts" : { "localizations" : { @@ -3456,6 +3459,9 @@ } } } + }, + "Access Level" : { + }, "Account" : { "localizations" : { @@ -3801,6 +3807,9 @@ } } } + }, + "Activity Log" : { + }, "Actual" : { "localizations" : { @@ -4301,6 +4310,9 @@ } } } + }, + "Administration" : { + }, "Advanced" : { @@ -4681,6 +4693,9 @@ } } } + }, + "All categories" : { + }, "All columns" : { "localizations" : { @@ -4841,6 +4856,9 @@ } } } + }, + "All time" : { + }, "All Time" : { "localizations" : { @@ -4863,6 +4881,9 @@ } } } + }, + "All tokens" : { + }, "Allow" : { "localizations" : { @@ -4885,6 +4906,9 @@ } } } + }, + "Allow %@ to access TablePro?" : { + }, "Allow AI Access" : { "localizations" : { @@ -4910,6 +4934,9 @@ }, "Allow remote connections" : { + }, + "Allowed Connections" : { + }, "Also handles" : { "localizations" : { @@ -5050,6 +5077,9 @@ }, "Always Show" : { + }, + "An external app is asking for an API token. Review the permissions before approving." : { + }, "An external link wants to add a database connection:\n\nName: %@\n%@" : { "extractionState" : "stale", @@ -5102,7 +5132,18 @@ } } }, + "An external link wants to open a query on \"%@\":\n\n%@" : { + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "new", + "value" : "An external link wants to open a query on \"%1$@\":\n\n%2$@" + } + } + } + }, "An external link wants to open a query on connection \"%@\":\n\n%@" : { + "extractionState" : "stale", "localizations" : { "en" : { "stringUnit" : { @@ -5630,6 +5671,12 @@ } } } + }, + "Approve" : { + + }, + "Approve Integration" : { + }, "Are you sure you want to cancel the running query for this session?" : { "localizations" : { @@ -6676,6 +6723,9 @@ } } } + }, + "Blocked" : { + }, "Blue" : { "localizations" : { @@ -7190,6 +7240,9 @@ } } } + }, + "Cancelled by user." : { + }, "Cannot connect to Ollama at %@. Is Ollama running?" : { "localizations" : { @@ -9675,6 +9728,9 @@ } } } + }, + "Connection is read-only for external clients" : { + }, "Connection lost" : { "localizations" : { @@ -9719,8 +9775,12 @@ } } } + }, + "Connection not found" : { + }, "Connection Not Found" : { + "extractionState" : "stale", "localizations" : { "tr" : { "stringUnit" : { @@ -9922,6 +9982,9 @@ } } } + }, + "Connection: %@" : { + }, "Connections" : { "localizations" : { @@ -10077,6 +10140,9 @@ } } } + }, + "Controls how external clients (Raycast, Cursor, Claude Desktop) access this connection. Tokens cannot exceed this level even with full-access scope." : { + }, "Conversation History" : { "extractionState" : "stale", @@ -10836,6 +10902,12 @@ }, "Could not generate SQL for changes." : { + }, + "Could Not Open File" : { + + }, + "Could not parse database URL: %@" : { + }, "Could not reach the license server. Check your internet connection and try again." : { "localizations" : { @@ -13512,6 +13584,9 @@ } } } + }, + "Denied" : { + }, "Deny" : { "localizations" : { @@ -17213,6 +17288,12 @@ } } } + }, + "External Access" : { + + }, + "External access is disabled for this connection" : { + }, "Extra Large" : { "extractionState" : "stale", @@ -19409,6 +19490,9 @@ }, "Full Access" : { + }, + "Full access including destructive DDL after explicit confirmation." : { + }, "Function" : { "localizations" : { @@ -21825,6 +21909,9 @@ } } } + }, + "Integrations" : { + }, "Interactive Data Grid" : { "localizations" : { @@ -22162,6 +22249,9 @@ } } } + }, + "Invalid UUID: %@" : { + }, "Invisibles" : { "localizations" : { @@ -22847,6 +22937,15 @@ } } } + }, + "Last 7 days" : { + + }, + "Last 24 hours" : { + + }, + "Last 30 days" : { + }, "Last query execution summary" : { "localizations" : { @@ -24080,6 +24179,9 @@ } } } + }, + "Malformed deep link path: %@" : { + }, "Manage Connections" : { "localizations" : { @@ -24481,9 +24583,6 @@ } } } - }, - "MCP" : { - }, "MCP Access Request" : { "localizations" : { @@ -24860,6 +24959,9 @@ } } } + }, + "Missing required parameter: %@" : { + }, "Missing value for parameter: %@" : { @@ -26114,6 +26216,9 @@ } } } + }, + "No activity yet" : { + }, "No AI provider configured. Go to Settings > AI to add one." : { "localizations" : { @@ -26494,6 +26599,16 @@ } } }, + "No free port in range %d-%d" : { + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "new", + "value" : "No free port in range %1$d-%2$d" + } + } + } + }, "No iCloud" : { "localizations" : { "tr" : { @@ -27119,6 +27234,7 @@ } }, "No saved connection named \"%@\"." : { + "extractionState" : "stale", "localizations" : { "tr" : { "stringUnit" : { @@ -27139,6 +27255,9 @@ } } } + }, + "No saved connection with ID \"%@\"." : { + }, "No saved connections" : { @@ -29209,6 +29328,9 @@ } } } + }, + "Pairing Failed" : { + }, "Panel State" : { "localizations" : { @@ -32119,6 +32241,12 @@ } } } + }, + "Range" : { + + }, + "Rate limited" : { + }, "Rate limited. Please try again later." : { "localizations" : { @@ -32280,6 +32408,12 @@ } } } + }, + "Read schema and run any non-destructive query, including INSERT, UPDATE, and DELETE." : { + + }, + "Read schema and run SELECT queries." : { + }, "Read-only" : { "extractionState" : "stale", @@ -32370,6 +32504,9 @@ } } } + }, + "Read-Write" : { + }, "Reading connections..." : { "localizations" : { @@ -33674,6 +33811,9 @@ } } } + }, + "Resource" : { + }, "Restart TablePro for the language change to take full effect." : { "localizations" : { @@ -37710,6 +37850,9 @@ } } } + }, + "SQL dialect for %@ is not available. The plugin may not be installed or loaded." : { + }, "SQL Editor" : { "localizations" : { @@ -37799,6 +37942,16 @@ } } }, + "SQL is too long: %d characters (limit %d)" : { + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "new", + "value" : "SQL is too long: %1$d characters (limit %2$d)" + } + } + } + }, "SQL Preview" : { "localizations" : { "tr" : { @@ -42195,12 +42348,28 @@ }, "Token" : { + }, + "Token '%@' with permission '%@' cannot access '%@'" : { + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "new", + "value" : "Token '%1$@' with permission '%2$@' cannot access '%3$@'" + } + } + } + }, + "Token does not have access to this connection" : { + }, "Token Name" : { }, "Too many submissions. Please try again later." : { + }, + "Tool" : { + }, "Toolbar" : { "localizations" : { @@ -43066,6 +43235,9 @@ } } } + }, + "Unknown deep link host: %@" : { + }, "Unknown error" : { "localizations" : { @@ -43110,6 +43282,9 @@ } } } + }, + "Unknown URL scheme: %@" : { + }, "Unlicensed" : { "localizations" : { @@ -43243,6 +43418,9 @@ } } } + }, + "Unsupported database type: %@" : { + }, "Unsupported encryption version %d" : { "localizations" : { @@ -43310,6 +43488,9 @@ } } } + }, + "Unsupported intent: %@" : { + }, "Unsupported MongoDB method: %@" : { "extractionState" : "stale", diff --git a/TablePro/TableProApp.swift b/TablePro/TableProApp.swift index b5d18fdf0..e56be1bbd 100644 --- a/TablePro/TableProApp.swift +++ b/TablePro/TableProApp.swift @@ -196,7 +196,7 @@ struct AppMenuCommands: Commands { // File menu CommandGroup(replacing: .newItem) { Button("Manage Connections") { - NotificationCenter.default.post(name: .newConnection, object: nil) + WelcomeWindowFactory.openOrFront() } .optionalKeyboardShortcut(shortcut(for: .manageConnections)) } @@ -384,7 +384,7 @@ struct AppMenuCommands: Commands { .disabled(!(actions?.isConnected ?? false)) Button("Switch Connection...") { - NotificationCenter.default.post(name: .openConnectionSwitcher, object: nil) + actions?.openConnectionSwitcher() } .optionalKeyboardShortcut(shortcut(for: .switchConnection)) .disabled(!(actions?.isConnected ?? false)) @@ -626,33 +626,15 @@ struct TableProApp: App { } var body: some Scene { - // Welcome Window - opens on launch (must be first Window scene so SwiftUI - // restores it by default when clicking the dock icon) - Window("Welcome to TablePro", id: "welcome") { - WelcomeWindowView() - .background(OpenWindowHandler()) // Handle window notifications from startup - } - .windowStyle(.hiddenTitleBar) - .windowResizability(.contentSize) - .defaultSize(width: 700, height: 450) - - // Connection Form Window - opens when creating/editing a connection - WindowGroup(id: "connection-form", for: UUID?.self) { $connectionId in - ConnectionFormView(connectionId: connectionId ?? nil) - } - .windowResizability(.contentSize) - - // NOTE (prototype): main windows are now created imperatively via - // MainWindowFactory → NSWindow + NSHostingController. The retired - // `WindowGroup(id:"main", for: EditorTabPayload.self)` caused SwiftUI to - // re-instantiate ContentView for every historical payload on every scene - // phase diff (5-7 phantom inits per open). AppKit-native windows avoid - // that and eliminate the 68-437ms openWindow() latency. - - // Settings Window - opens with Cmd+, + // All app windows are created imperatively via NSWindow + NSHostingController + // factories (MainWindow via WindowManager, Welcome via WelcomeWindowFactory, + // ConnectionForm via ConnectionFormWindowFactory). Declaring them as SwiftUI + // Scenes auto-opens the first Scene on launch and races with cold-launch + // intent routing. Settings { SettingsView() .environment(updaterBridge) + .background(SettingsNotificationBridge()) } .commands { @@ -668,9 +650,6 @@ struct TableProApp: App { // MARK: - Notification Names extension Notification.Name { - // Connection lifecycle - static let newConnection = Notification.Name("newConnection") - static let openConnectionSwitcher = Notification.Name("openConnectionSwitcher") // Multi-listener broadcasts (Sidebar + Coordinator + StructureView) static let refreshData = Notification.Name("refreshData") @@ -687,12 +666,6 @@ extension Notification.Name { // Window lifecycle notifications static let mainWindowWillClose = Notification.Name("mainWindowWillClose") - static let openMainWindow = Notification.Name("openMainWindow") - static let openWelcomeWindow = Notification.Name("openWelcomeWindow") - - // Database URL handling notifications - static let switchSchemaFromURL = Notification.Name("switchSchemaFromURL") - static let applyURLFilter = Notification.Name("applyURLFilter") } // MARK: - Check for Updates @@ -725,45 +698,31 @@ private struct MCPServerMenuItem: View { case .running: let count = manager.connectedClients.count if count == 0 { - return String(localized: "MCP Server: Running") + return String(localized: "Integrations: Running") } - return String(format: String(localized: "MCP Server: Running (%d clients)"), count) + return String(format: String(localized: "Integrations: Running (%d clients)"), count) case .failed: - return String(localized: "MCP Server: Failed") + return String(localized: "Integrations: Failed") case .stopped: - return String(localized: "MCP Server: Stopped") + return String(localized: "Integrations: Stopped") case .starting: - return String(localized: "MCP Server: Starting...") + return String(localized: "Integrations: Starting...") } } } -// MARK: - Open Window Handler +// MARK: - Settings Notification Bridge -/// Helper view that listens for window open notifications -private struct OpenWindowHandler: View { - @Environment(\.openWindow) - private var openWindow +/// Forwards `.openSettingsWindow` notifications to SwiftUI's `openSettings` +/// action. Lives inside the Settings scene because `\.openSettings` is only +/// available there. +private struct SettingsNotificationBridge: View { @Environment(\.openSettings) private var openSettings var body: some View { Color.clear .frame(width: 0, height: 0) - .onAppear { - // Store openWindow action for imperative access (e.g., from MainContentCommandActions) - WindowOpener.shared.openWindow = openWindow - } - .onReceive(NotificationCenter.default.publisher(for: .openWelcomeWindow)) { _ in - openWindow(id: "welcome") - } - .onReceive(NotificationCenter.default.publisher(for: .openMainWindow)) { notification in - if let payload = notification.object as? EditorTabPayload { - WindowManager.shared.openTab(payload: payload) - } else if let connectionId = notification.object as? UUID { - WindowManager.shared.openTab(payload: EditorTabPayload(connectionId: connectionId)) - } - } .onReceive(NotificationCenter.default.publisher(for: .openSettingsWindow)) { _ in openSettings() } diff --git a/TablePro/ViewModels/SidebarViewModel.swift b/TablePro/ViewModels/SidebarViewModel.swift index 4d3e1aee8..268c57d78 100644 --- a/TablePro/ViewModels/SidebarViewModel.swift +++ b/TablePro/ViewModels/SidebarViewModel.swift @@ -41,7 +41,6 @@ final class SidebarViewModel { // MARK: - Binding Storage - private var tablesBinding: Binding<[TableInfo]> private var selectedTablesBinding: Binding> private var pendingTruncatesBinding: Binding> private var pendingDeletesBinding: Binding> @@ -54,11 +53,6 @@ final class SidebarViewModel { // MARK: - Convenience Accessors - var tables: [TableInfo] { - get { tablesBinding.wrappedValue } - set { tablesBinding.wrappedValue = newValue } - } - var selectedTables: Set { get { selectedTablesBinding.wrappedValue } set { selectedTablesBinding.wrappedValue = newValue } @@ -82,7 +76,6 @@ final class SidebarViewModel { // MARK: - Initialization init( - tables: Binding<[TableInfo]>, selectedTables: Binding>, pendingTruncates: Binding>, pendingDeletes: Binding>, @@ -90,7 +83,6 @@ final class SidebarViewModel { databaseType: DatabaseType, connectionId: UUID ) { - self.tablesBinding = tables self.selectedTablesBinding = selectedTables self.pendingTruncatesBinding = pendingTruncates self.pendingDeletesBinding = pendingDeletes diff --git a/TablePro/ViewModels/WelcomeViewModel.swift b/TablePro/ViewModels/WelcomeViewModel.swift index 01d391815..91a25c345 100644 --- a/TablePro/ViewModels/WelcomeViewModel.swift +++ b/TablePro/ViewModels/WelcomeViewModel.swift @@ -33,7 +33,6 @@ final class WelcomeViewModel { private let storage = ConnectionStorage.shared private let groupStorage = GroupStorage.shared - private let dbManager = DatabaseManager.shared // MARK: - State @@ -78,14 +77,12 @@ final class WelcomeViewModel { // MARK: - Notification Observers - @ObservationIgnored private var openWindow: OpenWindowAction? @ObservationIgnored private var connectionUpdatedObserver: NSObjectProtocol? - @ObservationIgnored private var shareFileObserver: NSObjectProtocol? @ObservationIgnored private var exportObserver: NSObjectProtocol? @ObservationIgnored private var importObserver: NSObjectProtocol? @ObservationIgnored private var linkedFoldersObserver: NSObjectProtocol? @ObservationIgnored private var importFromAppObserver: NSObjectProtocol? - @ObservationIgnored private var deeplinkImportObserver: NSObjectProtocol? + @ObservationIgnored private var welcomeRouterTask: Task? // MARK: - Computed Properties @@ -146,8 +143,7 @@ final class WelcomeViewModel { // MARK: - Setup & Teardown - func setUp(openWindow: OpenWindowAction) { - self.openWindow = openWindow + func setUp() { guard connectionUpdatedObserver == nil else { return } if expandedGroupIds.isEmpty { @@ -168,16 +164,6 @@ final class WelcomeViewModel { } } - shareFileObserver = NotificationCenter.default.addObserver( - forName: .connectionShareFileOpened, object: nil, queue: .main - ) { [weak self] notification in - Task { @MainActor [weak self] in - guard let url = notification.object as? URL else { return } - _ = PendingActionStore.shared.consumeConnectionShareURL() - self?.activeSheet = .importFile(url) - } - } - exportObserver = NotificationCenter.default.addObserver( forName: .exportConnections, object: nil, queue: .main ) { [weak self] _ in @@ -211,35 +197,74 @@ final class WelcomeViewModel { } } - deeplinkImportObserver = NotificationCenter.default.addObserver( - forName: .deeplinkImportRequested, object: nil, queue: .main - ) { [weak self] notification in - Task { @MainActor [weak self] in - guard let self else { return } - let exportable = (notification.object as? ExportableConnection) - ?? PendingActionStore.shared.consumeDeeplinkImport() - guard let exportable else { return } - PendingActionStore.shared.deeplinkImport = nil - self.activeSheet = .deeplinkImport(exportable) - } - } - loadConnections() linkedConnections = LinkedFolderWatcher.shared.linkedConnections - if let pendingURL = PendingActionStore.shared.consumeConnectionShareURL() { + consumePendingRouterActions() + startWelcomeRouterObservation() + } + + private func consumePendingRouterActions() { + if let pendingURL = WelcomeRouter.shared.consumePendingShare() { activeSheet = .importFile(pendingURL) + return } - - if let pendingImport = PendingActionStore.shared.consumeDeeplinkImport() { + if let pendingImport = WelcomeRouter.shared.consumePendingImport() { activeSheet = .deeplinkImport(pendingImport) } } + private func startWelcomeRouterObservation() { + welcomeRouterTask?.cancel() + welcomeRouterTask = Task { @MainActor [weak self] in + while !Task.isCancelled { + let didChange = await Self.awaitWelcomeRouterChange() + guard didChange else { return } + self?.consumePendingRouterActions() + } + } + } + + private static func awaitWelcomeRouterChange() async -> Bool { + let box = ContinuationBox() + return await withTaskCancellationHandler { + await withCheckedContinuation { continuation in + box.set(continuation) + withObservationTracking({ + _ = WelcomeRouter.shared.pendingImport + _ = WelcomeRouter.shared.pendingConnectionShare + }, onChange: { + box.resume(with: true) + }) + } + } onCancel: { + box.resume(with: false) + } + } + + private final class ContinuationBox: @unchecked Sendable { + private var continuation: CheckedContinuation? + private let lock = NSLock() + + func set(_ continuation: CheckedContinuation) { + lock.lock() + defer { lock.unlock() } + self.continuation = continuation + } + + func resume(with value: Bool) { + lock.lock() + let pending = continuation + continuation = nil + lock.unlock() + pending?.resume(returning: value) + } + } + deinit { - [connectionUpdatedObserver, shareFileObserver, exportObserver, - importObserver, importFromAppObserver, linkedFoldersObserver, - deeplinkImportObserver].forEach { + welcomeRouterTask?.cancel() + [connectionUpdatedObserver, exportObserver, importObserver, + importFromAppObserver, linkedFoldersObserver].forEach { if let observer = $0 { NotificationCenter.default.removeObserver(observer) } @@ -261,26 +286,13 @@ final class WelcomeViewModel { // MARK: - Connection Actions func connectToDatabase(_ connection: DatabaseConnection) { - guard let openWindow else { return } - if WindowOpener.shared.openWindow == nil { - WindowOpener.shared.openWindow = openWindow - } - // Close welcome BEFORE opening the new editor window. Otherwise the - // welcome window (still key + visible) reasserts itself during the - // new window's `makeKeyAndOrderFront` — the new window briefly - // becomes key, immediately resigns, welcome retakes key, and the - // app is left with no key window after welcome closes → menu - // @FocusedValue nil → Cmd+T/1...9 disabled. - NSApplication.shared.closeWindows(withId: "welcome") - WindowManager.shared.openTab(payload: EditorTabPayload(connectionId: connection.id, intent: .restoreOrDefault)) - + WelcomeWindowFactory.close() Task { do { - try await dbManager.connectToSession(connection) + try await TabRouter.shared.route(.openConnection(connection.id)) } catch is CancellationError { - // User cancelled password prompt — return to welcome closeConnectionWindows(for: connection.id) - self.openWindow?(id: "welcome") + WelcomeWindowFactory.openOrFront() } catch { if case PluginError.pluginNotInstalled = error { Self.logger.info("Plugin not installed for \(connection.type.rawValue), prompting install") @@ -295,21 +307,13 @@ final class WelcomeViewModel { } func connectAfterInstall(_ connection: DatabaseConnection) { - guard let openWindow else { return } - if WindowOpener.shared.openWindow == nil { - WindowOpener.shared.openWindow = openWindow - } - // Close welcome before opening editor — see connectToDatabase above - // for the welcome-reasserts-key race that disabled menu shortcuts. - NSApplication.shared.closeWindows(withId: "welcome") - WindowManager.shared.openTab(payload: EditorTabPayload(connectionId: connection.id, intent: .restoreOrDefault)) - + WelcomeWindowFactory.close() Task { do { - try await dbManager.connectToSession(connection) + try await TabRouter.shared.route(.openConnection(connection.id)) } catch is CancellationError { closeConnectionWindows(for: connection.id) - self.openWindow?(id: "welcome") + WelcomeWindowFactory.openOrFront() } catch { Self.logger.error( "Failed to connect after plugin install: \(error.localizedDescription, privacy: .public)") @@ -340,8 +344,7 @@ final class WelcomeViewModel { func duplicateConnection(_ connection: DatabaseConnection) { let duplicate = storage.duplicateConnection(connection) loadConnections() - openWindow?(id: "connection-form", value: duplicate.id as UUID?) - focusConnectionFormWindow() + ConnectionFormWindowFactory.openOrFront(connectionId: duplicate.id) } // MARK: - Delete @@ -589,17 +592,15 @@ final class WelcomeViewModel { // MARK: - Private Helpers private func handleConnectionFailure(error: Error, connectionId: UUID) { - guard let openWindow else { return } closeConnectionWindows(for: connectionId) connectionError = error.localizedDescription showConnectionError = true - openWindow(id: "welcome") + WelcomeWindowFactory.openOrFront() } private func handleMissingPlugin(connection: DatabaseConnection) { - guard let openWindow else { return } closeConnectionWindows(for: connection.id) - openWindow(id: "welcome") + WelcomeWindowFactory.openOrFront() pluginInstallConnection = connection } diff --git a/TablePro/Views/Connection/ConnectionAdvancedView.swift b/TablePro/Views/Connection/ConnectionAdvancedView.swift index 6e48c6552..ba21e8046 100644 --- a/TablePro/Views/Connection/ConnectionAdvancedView.swift +++ b/TablePro/Views/Connection/ConnectionAdvancedView.swift @@ -13,6 +13,7 @@ struct ConnectionAdvancedView: View { @Binding var startupCommands: String @Binding var preConnectScript: String @Binding var aiPolicy: AIConnectionPolicy? + @Binding var externalAccess: ExternalAccessLevel @Binding var localOnly: Bool let databaseType: DatabaseType @@ -72,8 +73,8 @@ struct ConnectionAdvancedView: View { .foregroundStyle(.secondary) } - if AppSettingsManager.shared.ai.enabled { - Section(String(localized: "AI")) { + Section { + if AppSettingsManager.shared.ai.enabled { Picker(String(localized: "AI Policy"), selection: $aiPolicy) { Text(String(localized: "Use Default")) .tag(AIConnectionPolicy?.none as AIConnectionPolicy?) @@ -83,6 +84,25 @@ struct ConnectionAdvancedView: View { } } } + + Picker(String(localized: "External Clients"), selection: $externalAccess) { + ForEach(ExternalAccessLevel.allCases) { level in + Text(level.displayName).tag(level) + } + } + .pickerStyle(.segmented) + } header: { + Text(String(localized: "External Access")) + } footer: { + VStack(alignment: .leading, spacing: 4) { + if AppSettingsManager.shared.ai.enabled { + Text(String(localized: "AI Policy controls in-app AI agents. External Clients controls Raycast, Cursor, Claude Desktop, and other MCP clients. Effective scope is the minimum of the requesting token's scope and the External Clients level.")) + } else { + Text(String(localized: "Controls how external clients (Raycast, Cursor, Claude Desktop) access this connection. Tokens cannot exceed this level even with full-access scope.")) + } + } + .font(.caption) + .foregroundStyle(.secondary) } if AppSettingsManager.shared.sync.enabled { diff --git a/TablePro/Views/Connection/ConnectionFormView+Footer.swift b/TablePro/Views/Connection/ConnectionFormView+Footer.swift index f21e90e5e..bc8ce94af 100644 --- a/TablePro/Views/Connection/ConnectionFormView+Footer.swift +++ b/TablePro/Views/Connection/ConnectionFormView+Footer.swift @@ -52,7 +52,7 @@ extension ConnectionFormView { // Cancel Button("Cancel") { - NSApplication.shared.closeWindows(withId: "connection-form") + ConnectionFormWindowFactory.closeAll() } if isNew { @@ -74,7 +74,7 @@ extension ConnectionFormView { } .background(Color(nsColor: .windowBackgroundColor)) .onExitCommand { - NSApplication.shared.closeWindows(withId: "connection-form") + ConnectionFormWindowFactory.closeAll() } .onChange(of: host) { _, _ in testSucceeded = false } .onChange(of: port) { _, _ in testSucceeded = false } diff --git a/TablePro/Views/Connection/ConnectionFormView+Helpers.swift b/TablePro/Views/Connection/ConnectionFormView+Helpers.swift index ae0446c7f..40a3054e0 100644 --- a/TablePro/Views/Connection/ConnectionFormView+Helpers.swift +++ b/TablePro/Views/Connection/ConnectionFormView+Helpers.swift @@ -119,6 +119,7 @@ extension ConnectionFormView { selectedGroupId = existing.groupId safeModeLevel = existing.safeModeLevel aiPolicy = existing.aiPolicy + externalAccess = existing.externalAccess localOnly = existing.localOnly // Load additional fields from connection @@ -235,6 +236,7 @@ extension ConnectionFormView { sshTunnelMode: sshTunnelMode, safeModeLevel: safeModeLevel, aiPolicy: aiPolicy, + externalAccess: externalAccess, redisDatabase: additionalFieldValues["redisDatabase"].map { Int($0) ?? 0 }, startupCommands: startupCommands.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty ? nil : startupCommands, @@ -277,7 +279,7 @@ extension ConnectionFormView { if !connectionToSave.localOnly { SyncChangeTracker.shared.markDirty(.connection, id: connectionToSave.id.uuidString) } - NSApplication.shared.closeWindows(withId: "connection-form") + ConnectionFormWindowFactory.closeAll() NotificationCenter.default.post(name: .connectionUpdated, object: nil) if connect { connectToDatabase(connectionToSave) @@ -290,7 +292,7 @@ extension ConnectionFormView { SyncChangeTracker.shared.markDirty(.connection, id: connectionToSave.id.uuidString) } } - NSApplication.shared.closeWindows(withId: "connection-form") + ConnectionFormWindowFactory.closeAll() NotificationCenter.default.post(name: .connectionUpdated, object: nil) } } @@ -299,23 +301,15 @@ extension ConnectionFormView { guard let id = connectionId, let connection = storage.loadConnections().first(where: { $0.id == id }) else { return } storage.deleteConnection(connection) - NSApplication.shared.closeWindows(withId: "connection-form") + ConnectionFormWindowFactory.closeAll() NotificationCenter.default.post(name: .connectionUpdated, object: nil) } func connectToDatabase(_ connection: DatabaseConnection) { - if WindowOpener.shared.openWindow == nil { - WindowOpener.shared.openWindow = openWindow - } - // Close welcome BEFORE opening the editor window so it can't reassert - // key status during the new window's `makeKeyAndOrderFront`. See - // WelcomeViewModel.connectToDatabase for the diagnosed race. - NSApplication.shared.closeWindows(withId: "welcome") - WindowManager.shared.openTab(payload: EditorTabPayload(connectionId: connection.id, intent: .restoreOrDefault)) - + WelcomeWindowFactory.close() Task { do { - try await dbManager.connectToSession(connection) + try await TabRouter.shared.route(.openConnection(connection.id)) } catch { handleConnectError(error, connection: connection) } @@ -328,7 +322,7 @@ extension ConnectionFormView { return } closeConnectionWindows(for: connection.id) - openWindow(id: "welcome") + WelcomeWindowFactory.openOrFront() guard !(error is CancellationError) else { return } Self.logger.error("Failed to connect: \(error.localizedDescription, privacy: .public)") AlertHelper.showErrorSheet( @@ -339,7 +333,7 @@ extension ConnectionFormView { func handleMissingPlugin(connection: DatabaseConnection) { closeConnectionWindows(for: connection.id) - openWindow(id: "welcome") + WelcomeWindowFactory.openOrFront() pluginInstallConnection = connection } @@ -350,17 +344,10 @@ extension ConnectionFormView { } func connectAfterInstall(_ connection: DatabaseConnection) { - if WindowOpener.shared.openWindow == nil { - WindowOpener.shared.openWindow = openWindow - } - // Close welcome before opening editor — see connectToDatabase above - // for the welcome-reasserts-key race that disabled menu shortcuts. - NSApplication.shared.closeWindows(withId: "welcome") - WindowManager.shared.openTab(payload: EditorTabPayload(connectionId: connection.id, intent: .restoreOrDefault)) - + WelcomeWindowFactory.close() Task { do { - try await dbManager.connectToSession(connection) + try await TabRouter.shared.route(.openConnection(connection.id)) } catch { handleConnectError(error, connection: connection) } diff --git a/TablePro/Views/Connection/ConnectionFormView.swift b/TablePro/Views/Connection/ConnectionFormView.swift index 408999e79..75af777c4 100644 --- a/TablePro/Views/Connection/ConnectionFormView.swift +++ b/TablePro/Views/Connection/ConnectionFormView.swift @@ -12,13 +12,11 @@ import UniformTypeIdentifiers struct ConnectionFormView: View { static let logger = Logger(subsystem: "com.TablePro", category: "ConnectionFormView") - @Environment(\.openWindow) var openWindow // Connection ID: nil = new connection, UUID = edit existing let connectionId: UUID? let storage = ConnectionStorage.shared - let dbManager = DatabaseManager.shared var isNew: Bool { connectionId == nil } @@ -102,6 +100,9 @@ struct ConnectionFormView: View { // AI policy @State var aiPolicy: AIConnectionPolicy? + // External access (Raycast / Cursor / Claude Desktop) + @State var externalAccess: ExternalAccessLevel = .readOnly + // Plugin-driven additional connection fields @State var additionalFieldValues: [String: String] = [:] @@ -240,6 +241,7 @@ struct ConnectionFormView: View { startupCommands: $startupCommands, preConnectScript: $preConnectScript, aiPolicy: $aiPolicy, + externalAccess: $externalAccess, localOnly: $localOnly, databaseType: type, additionalConnectionFields: additionalConnectionFields diff --git a/TablePro/Views/Connection/WelcomeContextMenus.swift b/TablePro/Views/Connection/WelcomeContextMenus.swift index 7ee94bced..d826d48d2 100644 --- a/TablePro/Views/Connection/WelcomeContextMenus.swift +++ b/TablePro/Views/Connection/WelcomeContextMenus.swift @@ -91,7 +91,7 @@ extension WelcomeWindowView { Divider() Button { - openWindow(id: "connection-form", value: connection.id as UUID?) + ConnectionFormWindowFactory.openOrFront(connectionId: connection.id) vm.focusConnectionFormWindow() } label: { Label(String(localized: "Edit"), systemImage: "pencil") @@ -228,7 +228,7 @@ extension WelcomeWindowView { @ViewBuilder var newConnectionContextMenu: some View { - Button(action: { openWindow(id: "connection-form") }) { + Button(action: { ConnectionFormWindowFactory.openOrFront() }) { Label("New Connection...", systemImage: "plus") } diff --git a/TablePro/Views/Connection/WelcomeWindowView.swift b/TablePro/Views/Connection/WelcomeWindowView.swift index 9c1ee3560..e52349874 100644 --- a/TablePro/Views/Connection/WelcomeWindowView.swift +++ b/TablePro/Views/Connection/WelcomeWindowView.swift @@ -16,7 +16,6 @@ struct WelcomeWindowView: View { @State var vm = WelcomeViewModel() @FocusState private var focus: FocusField? - @Environment(\.openWindow) var openWindow var body: some View { ZStack { @@ -36,7 +35,7 @@ struct WelcomeWindowView: View { .ignoresSafeArea() .frame(minWidth: 600, idealWidth: 700, minHeight: 400, idealHeight: 450) .onAppear { - vm.setUp(openWindow: openWindow) + vm.setUp() focus = .search } .alert( @@ -171,7 +170,7 @@ struct WelcomeWindowView: View { HStack(spacing: 0) { WelcomeLeftPanel( onActivateLicense: { vm.activeSheet = .activation }, - onCreateConnection: { openWindow(id: "connection-form") } + onCreateConnection: { ConnectionFormWindowFactory.openOrFront() } ) Divider() rightPanel @@ -184,7 +183,7 @@ struct WelcomeWindowView: View { private var rightPanel: some View { VStack(spacing: 0) { HStack(spacing: 8) { - Button(action: { openWindow(id: "connection-form") }) { + Button(action: { ConnectionFormWindowFactory.openOrFront() }) { Image(systemName: "plus") .font(.callout.weight(.medium)) .foregroundStyle(.secondary) @@ -418,7 +417,7 @@ struct WelcomeWindowView: View { .font(.callout) .foregroundStyle(.tertiary) - Button(action: { openWindow(id: "connection-form") }) { + Button(action: { ConnectionFormWindowFactory.openOrFront() }) { Label("New Connection", systemImage: "plus") } .controlSize(.large) diff --git a/TablePro/Views/Main/Child/MainEditorContentView.swift b/TablePro/Views/Main/Child/MainEditorContentView.swift index a1c8a2214..68c63347e 100644 --- a/TablePro/Views/Main/Child/MainEditorContentView.swift +++ b/TablePro/Views/Main/Child/MainEditorContentView.swift @@ -263,7 +263,7 @@ struct MainEditorContentView: View { parameters: parameterBinding(for: tab), isParameterPanelVisible: parameterVisibilityBinding(for: tab), onExecute: { coordinator.runQuery() }, - schemaProvider: coordinator.schemaProvider, + schemaProvider: SchemaProviderRegistry.shared.getOrCreate(for: coordinator.connection.id), databaseType: coordinator.connection.type, connectionId: coordinator.connection.id, connectionAIPolicy: coordinator.connection.aiPolicy ?? AppSettingsManager.shared.ai.defaultConnectionPolicy, diff --git a/TablePro/Views/Main/Extensions/MainContentCoordinator+Navigation.swift b/TablePro/Views/Main/Extensions/MainContentCoordinator+Navigation.swift index 97aa94f0e..d085311ae 100644 --- a/TablePro/Views/Main/Extensions/MainContentCoordinator+Navigation.swift +++ b/TablePro/Views/Main/Extensions/MainContentCoordinator+Navigation.swift @@ -49,7 +49,7 @@ extension MainContentCoordinator { // During database switch, update the existing tab in-place instead of // opening a new native window tab. - if sidebarLoadingState == .loading { + if case .loading = SchemaService.shared.state(for: connectionId) { if tabManager.tabs.isEmpty { do { try tabManager.addTableTab( @@ -65,14 +65,18 @@ extension MainContentCoordinator { } // Check if another native window tab already has this table open — switch to it - if let keyWindow = NSApp.keyWindow { - let ownWindows = Set(WindowLifecycleMonitor.shared.windows(for: connectionId).map { ObjectIdentifier($0) }) - let tabbedWindows = keyWindow.tabbedWindows ?? [keyWindow] - for window in tabbedWindows - where window.title == tableName && ownWindows.contains(ObjectIdentifier(window)) { - window.makeKeyAndOrderFront(nil) - return + for sibling in MainContentCoordinator.allActiveCoordinators() + where sibling !== self && sibling.connectionId == connectionId { + let hasMatch = sibling.tabManager.tabs.contains { tab in + tab.tabType == .table + && tab.tableContext.tableName == tableName + && tab.tableContext.databaseName == currentDatabase } + guard hasMatch, + let windowId = sibling.windowId, + let window = WindowLifecycleMonitor.shared.window(for: windowId) else { continue } + window.makeKeyAndOrderFront(nil) + return } // If no tabs exist (empty state), add a table tab directly. @@ -401,7 +405,6 @@ extension MainContentCoordinator { /// Switch to a different database (called from database switcher) func switchDatabase(to database: String) async { - sidebarLoadingState = .loading filterStateManager.clearAll() let previousDatabase = toolbarState.databaseName toolbarState.databaseName = database @@ -414,14 +417,11 @@ extension MainContentCoordinator { tableRowsStore.tearDown() tabManager.tabs = [] tabManager.selectedTabId = nil - DatabaseManager.shared.updateSession(connectionId) { session in - session.tables = [] - } + await SchemaService.shared.invalidate(connectionId: connectionId) await refreshTables() } catch { toolbarState.databaseName = previousDatabase - sidebarLoadingState = .error(error.localizedDescription) navigationLogger.error("Failed to switch database: \(error.localizedDescription, privacy: .public)") AlertHelper.showErrorSheet( @@ -436,7 +436,6 @@ extension MainContentCoordinator { func switchSchema(to schema: String) async { guard PluginManager.shared.supportsSchemaSwitching(for: connection.type) else { return } - sidebarLoadingState = .loading filterStateManager.clearAll() let previousSchema = toolbarState.databaseName toolbarState.databaseName = schema @@ -449,9 +448,7 @@ extension MainContentCoordinator { tableRowsStore.tearDown() tabManager.tabs = [] tabManager.selectedTabId = nil - DatabaseManager.shared.updateSession(connectionId) { session in - session.tables = [] - } + await SchemaService.shared.invalidate(connectionId: connectionId) await refreshTables() } catch { diff --git a/TablePro/Views/Main/Extensions/MainContentCoordinator+Registry.swift b/TablePro/Views/Main/Extensions/MainContentCoordinator+Registry.swift new file mode 100644 index 000000000..9a766df2c --- /dev/null +++ b/TablePro/Views/Main/Extensions/MainContentCoordinator+Registry.swift @@ -0,0 +1,44 @@ +// +// MainContentCoordinator+Registry.swift +// TablePro +// + +import AppKit +import Foundation + +extension MainContentCoordinator { + static func allActiveCoordinators() -> [MainContentCoordinator] { + Array(activeCoordinators.values) + } + + static func coordinator(for windowId: UUID) -> MainContentCoordinator? { + activeCoordinators.values.first { $0.windowId == windowId } + } + + static func coordinator(forWindow window: NSWindow) -> MainContentCoordinator? { + activeCoordinators.values.first { $0.contentWindow === window } + } + + static func hasAnyUnsavedChanges() -> Bool { + activeCoordinators.values.contains { coordinator in + coordinator.changeManager.hasChanges + || coordinator.tabManager.tabs.contains { $0.pendingChanges.hasChanges } + } + } + + static func allTabs(for connectionId: UUID) -> [QueryTab] { + activeCoordinators.values + .filter { $0.connectionId == connectionId } + .flatMap { $0.tabManager.tabs } + } + + static func coordinator( + forConnection connectionId: UUID, + tabMatching predicate: (QueryTab) -> Bool + ) -> MainContentCoordinator? { + activeCoordinators.values.first { coordinator in + coordinator.connectionId == connectionId + && coordinator.tabManager.tabs.contains(where: predicate) + } + } +} diff --git a/TablePro/Views/Main/Extensions/MainContentCoordinator+URLFilter.swift b/TablePro/Views/Main/Extensions/MainContentCoordinator+URLFilter.swift index dcbb54f4a..bc2f35b55 100644 --- a/TablePro/Views/Main/Extensions/MainContentCoordinator+URLFilter.swift +++ b/TablePro/Views/Main/Extensions/MainContentCoordinator+URLFilter.swift @@ -6,57 +6,7 @@ import Foundation extension MainContentCoordinator { - func setupURLNotificationObservers() -> [NSObjectProtocol] { - let connId = connectionId - let observer1 = NotificationCenter.default.addObserver( - forName: .applyURLFilter, - object: nil, - queue: .main - ) { [weak self] notification in - guard let userInfo = notification.userInfo, - let targetId = userInfo["connectionId"] as? UUID, - targetId == connId else { return } - - let condition = userInfo["condition"] as? String - let column = userInfo["column"] as? String - let operation = userInfo["operation"] as? String - let value = userInfo["value"] as? String - Task { [weak self] in - self?.applyURLFilterValues( - condition: condition, column: column, - operation: operation, value: value - ) - } - } - - let observer2 = NotificationCenter.default.addObserver( - forName: .switchSchemaFromURL, - object: nil, - queue: .main - ) { [weak self] notification in - guard let userInfo = notification.userInfo, - let targetId = userInfo["connectionId"] as? UUID, - targetId == connId, - let schema = userInfo["schema"] as? String else { return } - - Task { [weak self] in - guard let self else { return } - - if PluginManager.shared.supportsSchemaSwitching(for: self.connection.type) { - await self.switchSchema(to: schema) - } else { - await self.switchDatabase(to: schema) - } - } - } - - return [observer1, observer2] - } - - private func applyURLFilterValues( - condition: String?, column: String?, - operation: String?, value: String? - ) { + func applyURLFilter(condition: String?, column: String?, operation: String?, value: String?) { if let condition, !condition.isEmpty { let filter = TableFilter( id: UUID(), @@ -74,7 +24,6 @@ extension MainContentCoordinator { guard let column, !column.isEmpty else { return } let filterOp = mapTablePlusOperation(operation ?? "Equal") - let filter = TableFilter( id: UUID(), columnName: column, diff --git a/TablePro/Views/Main/Extensions/MainContentCoordinator+WindowLifecycle.swift b/TablePro/Views/Main/Extensions/MainContentCoordinator+WindowLifecycle.swift index 07f0244ab..cb11e10ab 100644 --- a/TablePro/Views/Main/Extensions/MainContentCoordinator+WindowLifecycle.swift +++ b/TablePro/Views/Main/Extensions/MainContentCoordinator+WindowLifecycle.swift @@ -102,19 +102,11 @@ extension MainContentCoordinator { "[close] coordinator.handleWindowWillClose connId=\(self.connectionId, privacy: .public) tabs=\(self.tabManager.tabs.count)" ) - // Persist remaining non-preview tabs synchronously. saveNowSync writes - // directly without spawning a Task — required here because the window - // is closing and we cannot rely on async tasks being serviced. - let persistableTabs = tabManager.tabs.filter { !$0.isPreview } - if persistableTabs.isEmpty { - // Empty → clear saved state so next open shows a default empty window. - persistence.saveNowSync(tabs: [], selectedTabId: nil) - } else { - let normalizedSelectedId = - persistableTabs.contains(where: { $0.id == tabManager.selectedTabId }) - ? tabManager.selectedTabId : persistableTabs.first?.id - persistence.saveNowSync(tabs: persistableTabs, selectedTabId: normalizedSelectedId) - } + // Persist tabs aggregated across all windows for this connection. + // Writing this window's tabs in isolation can clobber sibling windows' + // state on disk — for example, closing an empty window would erase the + // saved tabs of an open sibling window. + persistence.saveOrClearAggregatedSync() // Cancel the pending eviction task before teardown drops it. evictionTask?.cancel() diff --git a/TablePro/Views/Main/Extensions/MainContentView+EventHandlers.swift b/TablePro/Views/Main/Extensions/MainContentView+EventHandlers.swift index 0ce5ace93..986262d1b 100644 --- a/TablePro/Views/Main/Extensions/MainContentView+EventHandlers.swift +++ b/TablePro/Views/Main/Extensions/MainContentView+EventHandlers.swift @@ -58,20 +58,9 @@ extension MainContentView { coordinator.promotePreviewTab() } - let persistableTabs = tabManager.tabs.filter { !$0.isPreview } - if persistableTabs.isEmpty { - coordinator.persistence.clearSavedState() - } else { - let normalizedSelectedId = - persistableTabs.contains(where: { $0.id == tabManager.selectedTabId }) - ? tabManager.selectedTabId : persistableTabs.first?.id - coordinator.persistence.saveNow( - tabs: persistableTabs, - selectedTabId: normalizedSelectedId - ) - } + coordinator.persistence.saveOrClearAggregated() MainContentView.lifecycleLogger.debug( - "[switch] handleStructureChange tabCount=\(tabManager.tabs.count) persistableCount=\(persistableTabs.count) ms=\(Int(Date().timeIntervalSince(t0) * 1_000))" + "[switch] handleStructureChange tabCount=\(tabManager.tabs.count) ms=\(Int(Date().timeIntervalSince(t0) * 1_000))" ) } diff --git a/TablePro/Views/Main/Extensions/MainContentView+Modifiers.swift b/TablePro/Views/Main/Extensions/MainContentView+Modifiers.swift index 6dc477e02..1da3d0631 100644 --- a/TablePro/Views/Main/Extensions/MainContentView+Modifiers.swift +++ b/TablePro/Views/Main/Extensions/MainContentView+Modifiers.swift @@ -54,7 +54,6 @@ struct FocusedCommandActionsModifier: ViewModifier { connection: DatabaseConnection.preview, payload: nil, windowTitle: .constant("SQL Query"), - tables: .constant([]), sidebarState: SharedSidebarState(), pendingTruncates: .constant([]), pendingDeletes: .constant([]), diff --git a/TablePro/Views/Main/Extensions/MainContentView+Setup.swift b/TablePro/Views/Main/Extensions/MainContentView+Setup.swift index a884c2c02..5eebb9fdc 100644 --- a/TablePro/Views/Main/Extensions/MainContentView+Setup.swift +++ b/TablePro/Views/Main/Extensions/MainContentView+Setup.swift @@ -95,11 +95,6 @@ extension MainContentView { private func handleRestoreOrDefault() async { if WindowLifecycleMonitor.shared.hasOtherWindows(for: connection.id, excluding: windowId) { - if tabManager.tabs.isEmpty { - let allTabs = MainContentCoordinator.allTabs(for: connection.id) - let title = QueryTabManager.nextQueryTitle(existingTabs: allTabs) - tabManager.addTab(title: title, databaseName: connection.database) - } MainContentView.lifecycleLogger.info( "[open] handleRestoreOrDefault short-circuit (other windows exist) windowId=\(windowId, privacy: .public)" ) @@ -111,7 +106,8 @@ extension MainContentView { MainContentView.lifecycleLogger.info( "[open] restoreFromDisk done windowId=\(windowId, privacy: .public) tabsRestored=\(result.tabs.count) source=\(String(describing: result.source), privacy: .public) elapsedMs=\(Int(Date().timeIntervalSince(restoreStart) * 1_000))" ) - if !result.tabs.isEmpty { + guard !result.tabs.isEmpty else { return } + do { var restoredTabs = result.tabs for i in restoredTabs.indices where restoredTabs[i].tabType == .table { if let tableName = restoredTabs[i].tableContext.tableName { @@ -141,17 +137,13 @@ extension MainContentView { if !remainingTabs.isEmpty { let selectedWasFirst = firstTab.id == selectedId - Task { @MainActor in - for tab in remainingTabs { - let restorePayload = EditorTabPayload( - from: tab, connectionId: connection.id, skipAutoExecute: true) - WindowManager.shared.openTab(payload: restorePayload) - } - // Bring the first window to front only if it had the selected tab. - // Otherwise let the last restored window stay focused. - if selectedWasFirst { - viewWindow?.makeKeyAndOrderFront(nil) - } + for tab in remainingTabs { + let restorePayload = EditorTabPayload( + from: tab, connectionId: connection.id, skipAutoExecute: true) + WindowManager.shared.openTab(payload: restorePayload) + } + if selectedWasFirst { + viewWindow?.makeKeyAndOrderFront(nil) } } diff --git a/TablePro/Views/Main/MainContentCommandActions.swift b/TablePro/Views/Main/MainContentCommandActions.swift index 694bbf9d6..f22f2bcff 100644 --- a/TablePro/Views/Main/MainContentCommandActions.swift +++ b/TablePro/Views/Main/MainContentCommandActions.swift @@ -316,15 +316,8 @@ final class MainContentCommandActions { // MARK: - Tab Operations (Group A — Called Directly) func newTab(initialQuery: String? = nil) { - // If no tabs exist (empty state), add directly to this window - if coordinator?.tabManager.tabs.isEmpty == true { - coordinator?.tabManager.addTab(initialQuery: initialQuery, databaseName: connection.database) - return - } - // Open a new native macOS window tab with a query editor let payload = EditorTabPayload( connectionId: connection.id, - tabType: .query, initialQuery: initialQuery, intent: .newEmptyTab ) @@ -487,11 +480,11 @@ final class MainContentCommandActions { // MARK: - Tab Navigation (Group A — Called Directly) func selectTab(number: Int) { - // Switch to the nth native window tab guard let keyWindow = NSApp.keyWindow, let tabbedWindows = keyWindow.tabbedWindows, - number > 0, number <= tabbedWindows.count else { return } - tabbedWindows[number - 1].makeKeyAndOrderFront(nil) + tabbedWindows.indices.contains(number - 1) else { return } + let target = tabbedWindows[number - 1] + target.makeKeyAndOrderFront(nil) } // MARK: - Filter Operations (Group A — Called Directly) @@ -704,6 +697,10 @@ final class MainContentCommandActions { coordinator?.activeSheet = .quickSwitcher } + func openConnectionSwitcher() { + coordinator?.toolbarState.showConnectionSwitcher = true + } + // MARK: - Undo/Redo (Group A — Called Directly) func undoChange() { @@ -762,9 +759,11 @@ final class MainContentCommandActions { if let driver = DatabaseManager.shared.driver(for: self.connection.id) { coordinator?.toolbarState.databaseVersion = driver.serverVersion } - if coordinator?.sidebarLoadingState != .loading { - await coordinator?.refreshTables() + if case .loading = SchemaService.shared.state(for: self.connection.id) { + coordinator?.initRedisKeyTreeIfNeeded() + return } + await coordinator?.refreshTables() coordinator?.initRedisKeyTreeIfNeeded() } } @@ -791,32 +790,9 @@ final class MainContentCommandActions { private func handleOpenSQLFiles(_ notification: Notification) { guard let urls = notification.object as? [URL] else { return } - Task { for url in urls { - if let existingWindow = WindowLifecycleMonitor.shared.window(forSourceFile: url) { - existingWindow.makeKeyAndOrderFront(nil) - continue - } - - let content = await Task.detached(priority: .userInitiated) { () -> String? in - do { - return try String(contentsOf: url, encoding: .utf8) - } catch { - Self.logger.error("Failed to read \(url.lastPathComponent, privacy: .public): \(error.localizedDescription, privacy: .public)") - return nil - } - }.value - - if let content { - let payload = EditorTabPayload( - connectionId: connection.id, - tabType: .query, - initialQuery: content, - sourceFileURL: url - ) - WindowManager.shared.openTab(payload: payload) - } + try? await TabRouter.shared.route(.openSQLFile(url)) } } } diff --git a/TablePro/Views/Main/MainContentCoordinator.swift b/TablePro/Views/Main/MainContentCoordinator.swift index 8590d2e5d..cf8b99a1f 100644 --- a/TablePro/Views/Main/MainContentCoordinator.swift +++ b/TablePro/Views/Main/MainContentCoordinator.swift @@ -38,14 +38,6 @@ struct DisplayFormatsCacheEntry { let formats: [ValueDisplayFormat?] } -/// Sidebar table loading state — single source of truth for sidebar UI -enum SidebarLoadingState: Equatable { - case idle - case loading - case loaded - case error(String) -} - /// Represents which sheet is currently active in MainContentView. /// Uses a single `.sheet(item:)` modifier instead of multiple `.sheet(isPresented:)`. enum ActiveSheet: Identifiable { @@ -141,15 +133,12 @@ final class MainContentCoordinator { // MARK: - Published State - var schemaProvider: SQLSchemaProvider var cursorPositions: [CursorPosition] = [] var tableMetadata: TableMetadata? - // Removed: showErrorAlert and errorAlertMessage - errors now display inline var activeSheet: ActiveSheet? var importFileURL: URL? var exportPreselectedTableNames: Set? var needsLazyLoad = false - var sidebarLoadingState: SidebarLoadingState = .idle /// Cache for async-sorted query tab rows (large datasets sorted on background thread) @ObservationIgnored var querySortCache: [UUID: QuerySortCacheEntry] = [:] @@ -171,10 +160,8 @@ final class MainContentCoordinator { @ObservationIgnored private var changeManagerUpdateTask: Task? @ObservationIgnored private var activeSortTasks: [UUID: Task] = [:] @ObservationIgnored private var terminationObserver: NSObjectProtocol? - @ObservationIgnored private var urlFilterObservers: [NSObjectProtocol] = [] @ObservationIgnored private var pluginDriverObserver: NSObjectProtocol? @ObservationIgnored private var fileWatcher: DatabaseFileWatcher? - @ObservationIgnored private var lastSchemaRefreshDate = Date.distantPast /// Set during handleTabChange to suppress redundant column-change reconfiguration @ObservationIgnored internal var isHandlingTabSwitch = false @@ -241,61 +228,32 @@ final class MainContentCoordinator { set { _isAppTerminating.withLock { $0 = newValue } } } + /// Stable instance identity. Used to key the registry so a recycled + /// `ObjectIdentifier` from a freshly-allocated coordinator can never + /// remove a different instance's entry from a delayed cleanup Task. + let instanceId = UUID() + /// Registry of active coordinators for aggregated quit-time persistence. - /// Keyed by ObjectIdentifier of each coordinator instance. - private static var activeCoordinators: [ObjectIdentifier: MainContentCoordinator] = [:] + /// Keyed by `instanceId` (UUID) — never by `ObjectIdentifier`, which can + /// be recycled across allocations. + static var activeCoordinators: [UUID: MainContentCoordinator] = [:] /// Register this coordinator so quit-time persistence can aggregate tabs. - private func registerForPersistence() { - Self.activeCoordinators[ObjectIdentifier(self)] = self - } - - /// Unregister this coordinator from quit-time aggregation. - private func unregisterFromPersistence() { - Self.activeCoordinators.removeValue(forKey: ObjectIdentifier(self)) + /// Idempotent — repeated registration is a no-op. + func registerEagerly() { + Self.activeCoordinators[instanceId] = self } - /// Find a coordinator by its window identifier. - static func coordinator(for windowId: UUID) -> MainContentCoordinator? { - activeCoordinators.values.first { $0.windowId == windowId } - } - - /// Find the coordinator whose `contentWindow` matches the given NSWindow. - /// Used by `TabWindowController` to dispatch NSWindowDelegate callbacks - /// to the correct coordinator without needing a shared registry key. - static func coordinator(forWindow window: NSWindow) -> MainContentCoordinator? { - activeCoordinators.values.first { $0.contentWindow === window } - } - - /// Check whether any active coordinator has unsaved edits. - static func hasAnyUnsavedChanges() -> Bool { - activeCoordinators.values.contains { coordinator in - coordinator.changeManager.hasChanges - || coordinator.tabManager.tabs.contains { $0.pendingChanges.hasChanges } - } - } - - /// Collect all tabs from all active coordinators for a given connectionId. - static func allTabs(for connectionId: UUID) -> [QueryTab] { - activeCoordinators.values - .filter { $0.connectionId == connectionId } - .flatMap { $0.tabManager.tabs } + private func registerForPersistence() { + Self.activeCoordinators[instanceId] = self } - /// Find the first coordinator for `connectionId` that owns a tab matching `predicate`. - /// Used to dedup cross-window tabs (Server Dashboard singleton, ER Diagram reuse). - static func coordinator( - forConnection connectionId: UUID, - tabMatching predicate: (QueryTab) -> Bool - ) -> MainContentCoordinator? { - activeCoordinators.values.first { coordinator in - coordinator.connectionId == connectionId - && coordinator.tabManager.tabs.contains(where: predicate) - } + private func unregisterFromPersistence() { + Self.activeCoordinators.removeValue(forKey: instanceId) } /// Collect non-preview tabs for persistence. - private static func aggregatedTabs(for connectionId: UUID) -> [QueryTab] { + static func aggregatedTabs(for connectionId: UUID) -> [QueryTab] { let coordinators = activeCoordinators.values .filter { $0.connectionId == connectionId } @@ -321,7 +279,7 @@ final class MainContentCoordinator { } /// Get selected tab ID from any coordinator for a given connectionId. - private static func aggregatedSelectedTabId(for connectionId: UUID) -> UUID? { + static func aggregatedSelectedTabId(for connectionId: UUID) -> UUID? { activeCoordinators.values .first { $0.connectionId == connectionId && $0.tabManager.selectedTabId != nil }? .tabManager.selectedTabId @@ -394,9 +352,8 @@ final class MainContentCoordinator { ) self.persistence = TabPersistenceCoordinator(connectionId: connection.id) - self.schemaProvider = SchemaProviderRegistry.shared.getOrCreate(for: connection.id) + _ = SchemaProviderRegistry.shared.getOrCreate(for: connection.id) SchemaProviderRegistry.shared.retain(for: connection.id) - urlFilterObservers = setupURLNotificationObservers() changeManager.undoManagerProvider = { [weak self] in self?.contentWindow?.undoManager } changeManager.onUndoApplied = { [weak self] result in self?.handleUndoResult(result) } @@ -453,16 +410,6 @@ final class MainContentCoordinator { ) } - /// Transition sidebar from `.idle` to `.loaded` when tables already exist - /// (e.g. populated by another window's `refreshTables()`). - func healSidebarLoadingStateIfNeeded() { - guard sidebarLoadingState == .idle else { return } - let tables = DatabaseManager.shared.session(for: connectionId)?.tables ?? [] - if !tables.isEmpty { - sidebarLoadingState = .loaded - } - } - /// Start watching the database file for external changes (SQLite, DuckDB). private func startFileWatcherIfNeeded() { guard PluginManager.shared.connectionMode(for: connection.type) == .fileBased else { return } @@ -471,8 +418,9 @@ final class MainContentCoordinator { let watcher = DatabaseFileWatcher() watcher.watch(filePath: filePath, connectionId: connectionId) { [weak self] in - guard let self, self.sidebarLoadingState != .loading else { return } - Task { await self.refreshTablesIfStale() } + guard let self else { return } + if case .loading = SchemaService.shared.state(for: self.connectionId) { return } + Task { await self.refreshTables() } } fileWatcher = watcher } @@ -480,9 +428,14 @@ final class MainContentCoordinator { /// Refresh schema only if not recently refreshed (avoids redundant work /// when both the file watcher and window focus trigger close together). func refreshTablesIfStale() async { - guard Date().timeIntervalSince(lastSchemaRefreshDate) > 2 else { return } - lastSchemaRefreshDate = Date() - await refreshTables() + guard let driver = DatabaseManager.shared.driver(for: connectionId) else { return } + await SchemaService.shared.reloadIfStale( + connectionId: connectionId, + driver: driver, + connection: connection, + staleness: 2 + ) + await reconcilePostSchemaLoad() } func showAIChatPanel() { @@ -512,45 +465,44 @@ final class MainContentCoordinator { } func refreshTables() async { - lastSchemaRefreshDate = Date() - sidebarLoadingState = .loading - guard let driver = DatabaseManager.shared.driver(for: connectionId) else { - sidebarLoadingState = .error(String(localized: "Not connected")) - return - } - do { - let tables = try await driver.fetchTables() - .sorted { $0.name.localizedCaseInsensitiveCompare($1.name) == .orderedAscending } - DatabaseManager.shared.updateSession(connectionId) { $0.tables = tables } + guard let driver = DatabaseManager.shared.driver(for: connectionId) else { return } + await SchemaService.shared.reload( + connectionId: connectionId, + driver: driver, + connection: connection + ) + await reconcilePostSchemaLoad() + } + + /// Push the SchemaService table list into the autocomplete provider and prune sidebar + /// state for tables that no longer exist. + private func reconcilePostSchemaLoad() async { + guard case .loaded(let tables) = SchemaService.shared.state(for: connectionId) else { return } + if let driver = DatabaseManager.shared.driver(for: connectionId), + let provider = SchemaProviderRegistry.shared.provider(for: connectionId) { let currentDb = DatabaseManager.shared.session(for: connectionId)?.activeDatabase - await schemaProvider.resetForDatabase(currentDb, tables: tables, driver: driver) - - // Clean up stale selections and pending operations for tables that no longer exist - if let vm = sidebarViewModel { - let validNames = Set(tables.map(\.name)) - let staleSelections = vm.selectedTables.filter { !validNames.contains($0.name) } - if !staleSelections.isEmpty { - vm.selectedTables.subtract(staleSelections) - } - let stalePendingDeletes = vm.pendingDeletes.subtracting(validNames) - if !stalePendingDeletes.isEmpty { - vm.pendingDeletes.subtract(stalePendingDeletes) - for name in stalePendingDeletes { - vm.tableOperationOptions.removeValue(forKey: name) - } - } - let stalePendingTruncates = vm.pendingTruncates.subtracting(validNames) - if !stalePendingTruncates.isEmpty { - vm.pendingTruncates.subtract(stalePendingTruncates) - for name in stalePendingTruncates { - vm.tableOperationOptions.removeValue(forKey: name) - } - } - } + await provider.resetForDatabase(currentDb, tables: tables, driver: driver) + } - sidebarLoadingState = .loaded - } catch { - sidebarLoadingState = .error(error.localizedDescription) + guard let vm = sidebarViewModel else { return } + let validNames = Set(tables.map(\.name)) + let staleSelections = vm.selectedTables.filter { !validNames.contains($0.name) } + if !staleSelections.isEmpty { + vm.selectedTables.subtract(staleSelections) + } + let stalePendingDeletes = vm.pendingDeletes.subtracting(validNames) + if !stalePendingDeletes.isEmpty { + vm.pendingDeletes.subtract(stalePendingDeletes) + for name in stalePendingDeletes { + vm.tableOperationOptions.removeValue(forKey: name) + } + } + let stalePendingTruncates = vm.pendingTruncates.subtracting(validNames) + if !stalePendingTruncates.isEmpty { + vm.pendingTruncates.subtract(stalePendingTruncates) + for name in stalePendingTruncates { + vm.tableOperationOptions.removeValue(forKey: name) + } } } @@ -564,10 +516,6 @@ final class MainContentCoordinator { _didTeardown.withLock { $0 = true } unregisterFromPersistence() - for observer in urlFilterObservers { - NotificationCenter.default.removeObserver(observer) - } - urlFilterObservers.removeAll() if let observer = terminationObserver { NotificationCenter.default.removeObserver(observer) terminationObserver = nil @@ -631,7 +579,7 @@ final class MainContentCoordinator { // Never-activated coordinators are throwaway instances created by SwiftUI // during body re-evaluation — @State only keeps the first, rest are discarded guard _didActivate.withLock({ $0 }) else { - let id = ObjectIdentifier(self) + let id = instanceId Task { @MainActor in Self.activeCoordinators.removeValue(forKey: id) } @@ -674,12 +622,9 @@ final class MainContentCoordinator { } } - /// Load schema only if the shared provider hasn't loaded yet + /// Load schema if not already loaded by another window for this connection. func loadSchemaIfNeeded() async { - let alreadyLoaded = await schemaProvider.isSchemaLoaded() - if !alreadyLoaded { - await loadSchema() - } + await loadSchema() } /// Initialize view with connection info and load schema (legacy — used by first window) @@ -702,7 +647,12 @@ final class MainContentCoordinator { func loadSchema() async { guard let driver = DatabaseManager.shared.driver(for: connectionId) else { return } - await schemaProvider.loadSchema(using: driver, connection: connection) + await SchemaService.shared.load( + connectionId: connectionId, + driver: driver, + connection: connection + ) + await reconcilePostSchemaLoad() } func loadTableMetadata(tableName: String) async { diff --git a/TablePro/Views/Main/MainContentView.swift b/TablePro/Views/Main/MainContentView.swift index 76ce35776..8467e0aaf 100644 --- a/TablePro/Views/Main/MainContentView.swift +++ b/TablePro/Views/Main/MainContentView.swift @@ -30,13 +30,17 @@ struct MainContentView: View { // Shared state from parent @Binding var windowTitle: String - @Binding var tables: [TableInfo] + @Bindable var schemaService = SchemaService.shared var sidebarState: SharedSidebarState @Binding var pendingTruncates: Set @Binding var pendingDeletes: Set @Binding var tableOperationOptions: [String: TableOperationOptions] var rightPanelState: RightPanelState + private var tables: [TableInfo] { + schemaService.tables(for: connection.id) + } + // MARK: - State Objects let tabManager: QueryTabManager @@ -66,7 +70,6 @@ struct MainContentView: View { connection: DatabaseConnection, payload: EditorTabPayload?, windowTitle: Binding, - tables: Binding<[TableInfo]>, sidebarState: SharedSidebarState, pendingTruncates: Binding>, pendingDeletes: Binding>, @@ -81,7 +84,6 @@ struct MainContentView: View { self.connection = connection self.payload = payload self._windowTitle = windowTitle - self._tables = tables self.sidebarState = sidebarState self._pendingTruncates = pendingTruncates self._pendingDeletes = pendingDeletes @@ -202,7 +204,7 @@ struct MainContentView: View { case .quickSwitcher: QuickSwitcherSheet( isPresented: dismissBinding, - schemaProvider: coordinator.schemaProvider, + schemaProvider: SchemaProviderRegistry.shared.getOrCreate(for: connection.id), connectionId: connection.id, databaseType: connection.type, onSelect: { item in @@ -262,7 +264,7 @@ struct MainContentView: View { setupCommandActions() updateToolbarPendingState() updateInspectorContext() - rightPanelState.aiViewModel.schemaProvider = coordinator.schemaProvider + rightPanelState.aiViewModel.schemaProvider = SchemaProviderRegistry.shared.getOrCreate(for: connection.id) coordinator.aiViewModel = rightPanelState.aiViewModel coordinator.rightPanelState = rightPanelState diff --git a/TablePro/Views/Results/Cells/DataGridBaseCellView.swift b/TablePro/Views/Results/Cells/DataGridBaseCellView.swift index 369e88dee..9fc74090a 100644 --- a/TablePro/Views/Results/Cells/DataGridBaseCellView.swift +++ b/TablePro/Views/Results/Cells/DataGridBaseCellView.swift @@ -65,7 +65,7 @@ class DataGridBaseCellView: NSTableCellView { return view }() - required override init(frame frameRect: NSRect) { + override required init(frame frameRect: NSRect) { cellTextField = Self.makeTextField() super.init(frame: frameRect) commonInit() diff --git a/TablePro/Views/Results/DataGridCellFactory.swift b/TablePro/Views/Results/DataGridCellFactory.swift index ee2d3c6e2..433dd096e 100644 --- a/TablePro/Views/Results/DataGridCellFactory.swift +++ b/TablePro/Views/Results/DataGridCellFactory.swift @@ -13,7 +13,7 @@ final class DataGridCellFactory { private static let sampleRowCount = 30 private static let maxMeasureChars = 50 - private static let headerFont: NSFont = NSFont.systemFont(ofSize: 13, weight: .semibold) + private static let headerFont = NSFont.systemFont(ofSize: 13, weight: .semibold) func calculateColumnWidth(for columnName: String) -> CGFloat { let attributes: [NSAttributedString.Key: Any] = [.font: Self.headerFont] diff --git a/TablePro/Views/Settings/Components/CopyableCodeBlock.swift b/TablePro/Views/Settings/Components/CopyableCodeBlock.swift new file mode 100644 index 000000000..5e54b7deb --- /dev/null +++ b/TablePro/Views/Settings/Components/CopyableCodeBlock.swift @@ -0,0 +1,34 @@ +import AppKit +import SwiftUI + +struct CopyableCodeBlock: View { + let text: String + @State private var copied = false + + var body: some View { + HStack(alignment: .top) { + Text(text) + .font(.system(.caption, design: .monospaced)) + .textSelection(.enabled) + .padding(8) + .frame(maxWidth: .infinity, alignment: .leading) + .background(.quaternary) + .clipShape(RoundedRectangle(cornerRadius: 6)) + + Button { + NSPasteboard.general.clearContents() + NSPasteboard.general.setString(text, forType: .string) + copied = true + Task { @MainActor in + try? await Task.sleep(for: .seconds(1.5)) + copied = false + } + } label: { + Image(systemName: copied ? "checkmark" : "doc.on.doc") + .contentTransition(.symbolEffect(.replace)) + } + .accessibilityLabel(String(localized: "Copy")) + .help(String(localized: "Copy to clipboard")) + } + } +} diff --git a/TablePro/Views/Settings/Components/IntegrationClient.swift b/TablePro/Views/Settings/Components/IntegrationClient.swift new file mode 100644 index 000000000..9c8aeabf4 --- /dev/null +++ b/TablePro/Views/Settings/Components/IntegrationClient.swift @@ -0,0 +1,17 @@ +import Foundation + +enum IntegrationClient: String, CaseIterable, Identifiable, Sendable { + case claudeCode + case claudeDesktop + case cursor + + var id: String { rawValue } + + var displayName: String { + switch self { + case .claudeCode: return String(localized: "Claude Code") + case .claudeDesktop: return String(localized: "Claude Desktop") + case .cursor: return String(localized: "Cursor") + } + } +} diff --git a/TablePro/Views/Settings/Components/IntegrationStatusIndicator.swift b/TablePro/Views/Settings/Components/IntegrationStatusIndicator.swift new file mode 100644 index 000000000..f9b7533c4 --- /dev/null +++ b/TablePro/Views/Settings/Components/IntegrationStatusIndicator.swift @@ -0,0 +1,98 @@ +import AppKit +import SwiftUI + +enum IntegrationStatus: Equatable { + case running + case stopped + case starting + case failed + case success + case error + case warning + case expired + case revoked + case active +} + +struct IntegrationStatusIndicator: View { + let status: IntegrationStatus + var label: String? + + var body: some View { + HStack(spacing: 6) { + Image(systemName: symbolName) + .symbolRenderingMode(.hierarchical) + .foregroundStyle(tint) + .imageScale(.small) + .accessibilityHidden(true) + if let label { + Text(label) + .foregroundStyle(.secondary) + } + } + .accessibilityElement(children: .ignore) + .accessibilityLabel(accessibilityDescription) + } + + private var symbolName: String { + switch status { + case .running, .success, .active: + return "checkmark.circle.fill" + case .stopped: + return "stop.circle.fill" + case .starting: + return "clock.fill" + case .failed, .error, .revoked: + return "xmark.circle.fill" + case .warning: + return "exclamationmark.triangle.fill" + case .expired: + return "clock.badge.exclamationmark.fill" + } + } + + private var tint: Color { + switch status { + case .running, .success, .active: + return Color(nsColor: .systemGreen) + case .stopped: + return Color(nsColor: .secondaryLabelColor) + case .starting: + return Color(nsColor: .systemOrange) + case .failed, .error: + return Color(nsColor: .systemRed) + case .warning, .expired: + return Color(nsColor: .systemOrange) + case .revoked: + return Color(nsColor: .systemRed) + } + } + + var accessibilityDescription: String { + let prefix: String + switch status { + case .running: + prefix = String(localized: "Status: running") + case .stopped: + prefix = String(localized: "Status: stopped") + case .starting: + prefix = String(localized: "Status: starting") + case .failed: + prefix = String(localized: "Status: failed") + case .success: + prefix = String(localized: "Status: success") + case .error: + prefix = String(localized: "Status: error") + case .warning: + prefix = String(localized: "Status: warning") + case .expired: + prefix = String(localized: "Status: expired") + case .revoked: + prefix = String(localized: "Status: revoked") + case .active: + prefix = String(localized: "Status: active") + } + guard let label, !label.isEmpty else { return prefix } + return String(format: String(localized: "%1$@, %2$@"), prefix, label) + } +} diff --git a/TablePro/Views/Settings/MCPSettingsView.swift b/TablePro/Views/Settings/MCPSettingsView.swift index 8454ee834..90f52849f 100644 --- a/TablePro/Views/Settings/MCPSettingsView.swift +++ b/TablePro/Views/Settings/MCPSettingsView.swift @@ -1,23 +1,56 @@ -// -// MCPSettingsView.swift -// TablePro -// - import SwiftUI struct MCPSettingsView: View { @Binding var settings: MCPSettings + @State private var selectedPane: IntegrationsPane = .settings + var body: some View { - Form { - MCPSection(settings: $settings) + VStack(spacing: 0) { + Picker("", selection: $selectedPane) { + ForEach(IntegrationsPane.allCases) { pane in + Text(pane.label).tag(pane) + } + } + .pickerStyle(.segmented) + .labelsHidden() + .padding(.horizontal, 20) + .padding(.top, 16) + .padding(.bottom, 8) + + Divider() + + switch selectedPane { + case .settings: + Form { + MCPSection(settings: $settings) + } + .formStyle(.grouped) + .scrollContentBackground(.hidden) + case .activityLog: + MCPAuditLogView() + } + } + } +} + +private enum IntegrationsPane: String, CaseIterable, Identifiable { + case settings + case activityLog + + var id: String { rawValue } + + var label: String { + switch self { + case .settings: + return String(localized: "Settings") + case .activityLog: + return String(localized: "Activity Log") } - .formStyle(.grouped) - .scrollContentBackground(.hidden) } } #Preview { MCPSettingsView(settings: .constant(.default)) - .frame(width: 450, height: 500) + .frame(width: 520, height: 540) } diff --git a/TablePro/Views/Settings/Sections/MCPAuditLogView.swift b/TablePro/Views/Settings/Sections/MCPAuditLogView.swift new file mode 100644 index 000000000..8ebf5a824 --- /dev/null +++ b/TablePro/Views/Settings/Sections/MCPAuditLogView.swift @@ -0,0 +1,366 @@ +import AppKit +import SwiftUI +import UniformTypeIdentifiers + +struct MCPAuditLogView: View { + @State private var entries: [AuditEntry] = [] + @State private var tokens: [MCPAuthToken] = [] + @State private var connections: [DatabaseConnection] = [] + @State private var selectedTokenId: UUID? + @State private var selectedCategory: AuditCategory? + @State private var selectedRange: TimeRangeOption = .last7Days + @State private var searchText: String = "" + @State private var isLoading = false + + var body: some View { + VStack(alignment: .leading, spacing: 12) { + searchBar + filterBar + + if isLoading { + ProgressView() + .frame(maxWidth: .infinity) + .padding(.vertical, 24) + } else if filteredEntries.isEmpty { + emptyState + } else { + entryList + } + + HStack { + Text(String(localized: "Activity is retained for 90 days.")) + .font(.caption) + .foregroundStyle(.secondary) + Spacer() + } + } + .padding() + .task { await reload() } + } + + private var searchBar: some View { + HStack(spacing: 8) { + Image(systemName: "magnifyingglass") + .foregroundStyle(.secondary) + TextField(String(localized: "Search activity"), text: $searchText) + .textFieldStyle(.plain) + if !searchText.isEmpty { + Button { + searchText = "" + } label: { + Image(systemName: "xmark.circle.fill") + .foregroundStyle(.secondary) + } + .buttonStyle(.plain) + .accessibilityLabel(String(localized: "Clear search")) + } + } + .padding(.horizontal, 8) + .padding(.vertical, 6) + .background(Color(nsColor: .textBackgroundColor)) + .overlay( + RoundedRectangle(cornerRadius: 6) + .strokeBorder(Color(nsColor: .separatorColor), lineWidth: 1) + ) + .clipShape(RoundedRectangle(cornerRadius: 6)) + } + + private var filterBar: some View { + HStack(spacing: 12) { + Picker(selection: $selectedTokenId) { + Text(String(localized: "All tokens")).tag(UUID?.none) + ForEach(tokens) { token in + Text(displayTokenName(token.name)).tag(Optional(token.id)) + } + } label: { + Text(String(localized: "Token")) + } + .frame(minWidth: 180, maxWidth: 240) + + Picker(selection: $selectedCategory) { + Text(String(localized: "All categories")).tag(AuditCategory?.none) + ForEach(AuditCategory.allCases) { category in + Text(category.displayName).tag(Optional(category)) + } + } label: { + Text(String(localized: "Category")) + } + .frame(minWidth: 180, maxWidth: 220) + + Picker(selection: $selectedRange) { + ForEach(TimeRangeOption.allCases) { option in + Text(option.displayName).tag(option) + } + } label: { + Text(String(localized: "Range")) + } + .frame(minWidth: 160, maxWidth: 200) + + Spacer() + + Button { + exportCSV() + } label: { + Label(String(localized: "Export…"), systemImage: "square.and.arrow.up") + } + .accessibilityLabel(String(localized: "Export activity to CSV")) + .help(String(localized: "Export the filtered activity log to CSV")) + .disabled(filteredEntries.isEmpty) + + Button { + Task { await reload() } + } label: { + Image(systemName: "arrow.clockwise") + } + .accessibilityLabel(String(localized: "Refresh")) + .help(String(localized: "Refresh")) + } + .onChange(of: selectedTokenId) { _, _ in Task { await reload() } } + .onChange(of: selectedCategory) { _, _ in Task { await reload() } } + .onChange(of: selectedRange) { _, _ in Task { await reload() } } + } + + private var emptyState: some View { + VStack { + Spacer(minLength: 0) + Group { + if !searchText.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty { + ContentUnavailableView.search(text: searchText) + } else { + ContentUnavailableView( + String(localized: "No activity yet"), + systemImage: "tray", + description: Text(String(localized: "External integrations and MCP client requests will appear here.")) + ) + } + } + Spacer(minLength: 0) + } + .frame(maxWidth: .infinity, maxHeight: .infinity) + } + + private var entryList: some View { + List(filteredEntries) { entry in + MCPAuditLogRow( + entry: entry, + connectionName: connectionName(for: entry.connectionId) + ) + } + .listStyle(.inset) + .frame(minHeight: 240) + } + + private var filteredEntries: [AuditEntry] { + let trimmed = searchText.trimmingCharacters(in: .whitespacesAndNewlines) + guard !trimmed.isEmpty else { return entries } + let needle = trimmed.lowercased() + return entries.filter { entry in + if entry.action.lowercased().contains(needle) { return true } + if let tokenName = entry.tokenName?.lowercased(), tokenName.contains(needle) { return true } + if let connectionName = connectionName(for: entry.connectionId)?.lowercased(), + connectionName.contains(needle) { + return true + } + if let details = entry.details?.lowercased(), details.contains(needle) { return true } + return false + } + } + + private func connectionName(for id: UUID?) -> String? { + guard let id else { return nil } + if let connection = connections.first(where: { $0.id == id }) { + return connection.name + } + let prefix = id.uuidString.prefix(8) + return String(format: String(localized: "Deleted connection (%@)"), String(prefix)) + } + + private func displayTokenName(_ name: String) -> String { + name == MCPTokenStore.stdioBridgeTokenName ? String(localized: "Built-in CLI") : name + } + + private func reload() async { + isLoading = true + defer { isLoading = false } + + let store = MCPServerManager.shared.tokenStore + if let store { + tokens = await store.list().filter { $0.name != MCPTokenStore.stdioBridgeTokenName } + } + connections = ConnectionStorage.shared.loadConnections() + + let since = selectedRange.startDate + let category = selectedCategory + let tokenId = selectedTokenId + let result = await MCPAuditLogStorage.shared.query( + category: category, + tokenId: tokenId, + since: since, + limit: 1_000 + ) + entries = result + } + + private func exportCSV() { + let panel = NSSavePanel() + panel.allowedContentTypes = [.commaSeparatedText] + panel.nameFieldStringValue = "tablepro-activity-\(Self.fileTimestamp()).csv" + panel.canCreateDirectories = true + panel.title = String(localized: "Export Activity Log") + + let response = panel.runModal() + guard response == .OK, let url = panel.url else { return } + + let csv = csvString(for: filteredEntries) + do { + try csv.write(to: url, atomically: true, encoding: .utf8) + } catch { + let alert = NSAlert() + alert.messageText = String(localized: "Could not export activity log") + alert.informativeText = error.localizedDescription + alert.alertStyle = .warning + alert.addButton(withTitle: String(localized: "OK")) + alert.runModal() + } + } + + private func csvString(for entries: [AuditEntry]) -> String { + let header = [ + "Timestamp", + "Category", + "Action", + "Connection", + "Token", + "Outcome", + "Details" + ].joined(separator: ",") + let rows = entries.map { entry -> String in + let timestamp = ISO8601DateFormatter().string(from: entry.timestamp) + let cells = [ + timestamp, + entry.category.rawValue, + entry.action, + connectionName(for: entry.connectionId) ?? "", + entry.tokenName ?? "", + entry.outcome, + entry.details ?? "" + ] + return cells.map(Self.escapeCSV).joined(separator: ",") + } + return ([header] + rows).joined(separator: "\n") + "\n" + } + + private static func escapeCSV(_ value: String) -> String { + let needsQuotes = value.contains(",") || value.contains("\"") || value.contains("\n") || value.contains("\r") + guard needsQuotes else { return value } + let escaped = value.replacingOccurrences(of: "\"", with: "\"\"") + return "\"\(escaped)\"" + } + + private static func fileTimestamp() -> String { + let formatter = DateFormatter() + formatter.dateFormat = "yyyyMMdd-HHmmss" + return formatter.string(from: .now) + } +} + +private struct MCPAuditLogRow: View { + let entry: AuditEntry + let connectionName: String? + + private static let relativeFormatter: RelativeDateTimeFormatter = { + let formatter = RelativeDateTimeFormatter() + formatter.unitsStyle = .full + return formatter + }() + + var body: some View { + HStack(alignment: .top, spacing: 10) { + IntegrationStatusIndicator(status: outcomeStatus) + .padding(.top, 2) + VStack(alignment: .leading, spacing: 4) { + HStack(spacing: 8) { + Text(displayActionName) + .font(.callout.weight(.medium)) + Text(entry.category.displayName) + .font(.caption) + .foregroundStyle(.secondary) + .padding(.horizontal, 6) + .padding(.vertical, 2) + .background( + RoundedRectangle(cornerRadius: 4) + .fill(Color(nsColor: .quaternaryLabelColor)) + ) + } + if let tokenName = entry.tokenName { + Text(displayTokenName(tokenName)) + .font(.caption) + .foregroundStyle(.secondary) + } + if let connectionName { + Text(String(format: String(localized: "Connection: %@"), connectionName)) + .font(.caption) + .foregroundStyle(.secondary) + } + if let details = entry.details { + Text(details) + .font(.system(.caption2, design: .monospaced)) + .foregroundStyle(.tertiary) + .lineLimit(2) + } + } + Spacer() + Text(Self.relativeFormatter.localizedString(for: entry.timestamp, relativeTo: .now)) + .font(.caption) + .foregroundStyle(.secondary) + } + .padding(.vertical, 4) + .help(entry.timestamp.formatted(date: .complete, time: .standard)) + } + + private var displayActionName: String { + let words = entry.action.split(separator: ".").map { $0.capitalized } + return words.joined(separator: " ") + } + + private func displayTokenName(_ name: String) -> String { + name == MCPTokenStore.stdioBridgeTokenName ? String(localized: "Built-in CLI") : name + } + + private var outcomeStatus: IntegrationStatus { + switch entry.outcome { + case AuditOutcome.success.rawValue: return .success + case AuditOutcome.denied.rawValue, AuditOutcome.rateLimited.rawValue: return .warning + case AuditOutcome.error.rawValue: return .error + default: return .stopped + } + } +} + +enum TimeRangeOption: String, CaseIterable, Identifiable { + case last24Hours + case last7Days + case last30Days + case all + + var id: String { rawValue } + + var displayName: String { + switch self { + case .last24Hours: String(localized: "Last 24 hours") + case .last7Days: String(localized: "Last 7 days") + case .last30Days: String(localized: "Last 30 days") + case .all: String(localized: "All time") + } + } + + var startDate: Date? { + let now = Date() + switch self { + case .last24Hours: return now.addingTimeInterval(-86_400) + case .last7Days: return now.addingTimeInterval(-7 * 86_400) + case .last30Days: return now.addingTimeInterval(-30 * 86_400) + case .all: return nil + } + } +} diff --git a/TablePro/Views/Settings/Sections/MCPSection.swift b/TablePro/Views/Settings/Sections/MCPSection.swift index 6e178d0ee..644e59f0d 100644 --- a/TablePro/Views/Settings/Sections/MCPSection.swift +++ b/TablePro/Views/Settings/Sections/MCPSection.swift @@ -1,27 +1,23 @@ -// -// MCPSection.swift -// TablePro -// - import AppKit import SwiftUI struct MCPSection: View { @Binding var settings: MCPSettings @State private var manager = MCPServerManager.shared - @State private var selectedTool: MCPClientTool = .claudeDesktop + @State private var selectedTool: IntegrationClient = .claudeDesktop @State private var tokenList: [MCPAuthToken] = [] @State private var showCreateSheet = false @State private var showRevealSheet = false @State private var revealedToken: MCPAuthToken? @State private var revealedPlaintext: String = "" + @State private var disconnectCandidate: MCPServer.SessionSnapshot? var body: some View { - Section("MCP Server") { - Toggle("Enable MCP Server", isOn: $settings.enabled) + Section(String(localized: "Integrations")) { + Toggle(String(localized: "Enable MCP Server"), isOn: $settings.enabled) if settings.enabled { - LabeledContent("Status") { + LabeledContent(String(localized: "Status")) { MCPStatusIndicator() } } @@ -31,58 +27,54 @@ struct MCPSection: View { configurationSection authenticationSection networkSection - setupSection connectedClientsSection + setupSection Section { - Text("AI access policies are configured per-connection in each connection's settings.") + Text(String(localized: "AI access policies are configured per-connection in each connection's settings.")) .foregroundStyle(.secondary) .font(.callout) } } } - // MARK: - Configuration - private var configurationSection: some View { - Section("MCP Configuration") { - LabeledContent("Port") { + Section(String(localized: "Server Configuration")) { + LabeledContent(String(localized: "Port")) { TextField("", value: $settings.port, format: .number.grouping(.never)) .frame(width: 80) .multilineTextAlignment(.trailing) } - LabeledContent("Default row limit") { + LabeledContent(String(localized: "Default row limit")) { TextField("", value: $settings.defaultRowLimit, format: .number.grouping(.never)) .frame(width: 80) .multilineTextAlignment(.trailing) } - LabeledContent("Maximum row limit") { + LabeledContent(String(localized: "Maximum row limit")) { TextField("", value: $settings.maxRowLimit, format: .number.grouping(.never)) .frame(width: 80) .multilineTextAlignment(.trailing) } - LabeledContent("Query timeout") { + LabeledContent(String(localized: "Query timeout")) { HStack(spacing: 4) { TextField("", value: $settings.queryTimeoutSeconds, format: .number.grouping(.never)) .frame(width: 80) .multilineTextAlignment(.trailing) - Text("seconds") + Text(String(localized: "seconds")) .foregroundStyle(.secondary) } } - Toggle("Log MCP queries in history", isOn: $settings.logQueriesInHistory) + Toggle(String(localized: "Log MCP queries in history"), isOn: $settings.logQueriesInHistory) } } - // MARK: - Authentication - private var authenticationSection: some View { - Section("Authentication") { - Toggle("Require authentication", isOn: $settings.requireAuthentication) + Section(String(localized: "Authentication")) { + Toggle(String(localized: "Require authentication"), isOn: $settings.requireAuthentication) if settings.requireAuthentication { MCPTokenListView( @@ -109,44 +101,43 @@ struct MCPSection: View { } } - // MARK: - Network - private var networkSection: some View { - Section("Network") { - Toggle("Allow remote connections", isOn: $settings.allowRemoteConnections) + Section(String(localized: "Network")) { + Toggle(String(localized: "Allow remote connections"), isOn: $settings.allowRemoteConnections) if settings.allowRemoteConnections { Label { - Text("The server will be accessible from other devices on your network. Authentication and TLS are enabled automatically.") + Text(String(localized: "The server will be accessible from other devices on your network. Authentication and TLS are enabled automatically.")) } icon: { Image(systemName: "exclamationmark.triangle.fill") - .foregroundStyle(.orange) + .foregroundStyle(Color(nsColor: .systemOrange)) } .font(.callout) } } } - // MARK: - Setup - private var setupSection: some View { - Section("MCP Setup") { - Picker("Client:", selection: $selectedTool) { - ForEach(MCPClientTool.allCases) { tool in - Text(tool.displayName).tag(tool) + Section(String(localized: "Connect a Client")) { + DisclosureGroup(String(localized: "Setup Instructions")) { + VStack(alignment: .leading, spacing: 12) { + Picker(String(localized: "Client"), selection: $selectedTool) { + ForEach(IntegrationClient.allCases) { tool in + Text(tool.displayName).tag(tool) + } + } + + MCPSetupInstructions(tool: selectedTool, port: settings.port) } + .padding(.top, 4) } - - MCPSetupInstructions(tool: selectedTool, port: settings.port) } } - // MARK: - Connected Clients - private var connectedClientsSection: some View { - Section("Connected Clients") { + Section(String(localized: "Connected Clients")) { if manager.connectedClients.isEmpty { - Text("No clients connected") + Text(String(localized: "No clients connected")) .foregroundStyle(.secondary) } else { ForEach(manager.connectedClients) { client in @@ -164,25 +155,52 @@ struct MCPSection: View { .foregroundStyle(.secondary) } Spacer() - Button("Disconnect") { - Task { await manager.disconnectClient(client.id) } + Button(role: .destructive) { + disconnectCandidate = client + } label: { + Text(String(localized: "Disconnect")) } .controlSize(.small) } } } } + .alert( + String(localized: "Disconnect client?"), + isPresented: disconnectAlertBinding, + presenting: disconnectCandidate + ) { client in + Button(String(localized: "Cancel"), role: .cancel) { + disconnectCandidate = nil + } + Button(String(localized: "Disconnect"), role: .destructive) { + Task { await manager.disconnectClient(client.id) } + disconnectCandidate = nil + } + } message: { client in + Text(String(format: String(localized: "“%@” will be disconnected and any in-flight requests will be cancelled."), client.clientName)) + } } - // MARK: - Token Management + private var disconnectAlertBinding: Binding { + Binding( + get: { disconnectCandidate != nil }, + set: { isPresented in + if !isPresented { + disconnectCandidate = nil + } + } + ) + } private func handleGenerate(name: String, permissions: TokenPermissions, connectionIds: Set?, expiresAt: Date?) { Task { guard let store = manager.tokenStore else { return } + let access: ConnectionAccess = connectionIds.map { .limited($0) } ?? .all let result = await store.generate( name: name, permissions: permissions, - allowedConnectionIds: connectionIds, + connectionAccess: access, expiresAt: expiresAt ) revealedToken = result.token @@ -195,32 +213,13 @@ struct MCPSection: View { private func refreshTokens() async { guard let store = MCPServerManager.shared.tokenStore else { return } - tokenList = await store.list().filter { $0.name != "__stdio_bridge__" } + tokenList = await store.list().filter { $0.name != MCPTokenStore.stdioBridgeTokenName } } } -// MARK: - MCP Client Tool - -private enum MCPClientTool: String, CaseIterable, Identifiable { - case claudeDesktop, claudeCode, cursor - - var id: String { rawValue } - - var displayName: String { - switch self { - case .claudeDesktop: "Claude Desktop" - case .claudeCode: "Claude Code" - case .cursor: "Cursor" - } - } -} - -// MARK: - Setup Instructions - private struct MCPSetupInstructions: View { - let tool: MCPClientTool + let tool: IntegrationClient let port: Int - @State private var copied = false var body: some View { VStack(alignment: .leading, spacing: 12) { @@ -236,60 +235,33 @@ private struct MCPSetupInstructions: View { } if let snippet = configSnippet { - copyableCodeBlock(snippet) + CopyableCodeBlock(text: snippet) } if let command { - copyableCodeBlock(command) + CopyableCodeBlock(text: command) } } .font(.callout) } - @ViewBuilder - private func copyableCodeBlock(_ text: String) -> some View { - HStack(alignment: .top) { - Text(text) - .font(.system(.caption, design: .monospaced)) - .textSelection(.enabled) - .padding(8) - .frame(maxWidth: .infinity, alignment: .leading) - .background(.quaternary) - .clipShape(RoundedRectangle(cornerRadius: 6)) - - Button { - NSPasteboard.general.clearContents() - NSPasteboard.general.setString(text, forType: .string) - copied = true - Task { @MainActor in - try? await Task.sleep(for: .seconds(1.5)) - copied = false - } - } label: { - Image(systemName: copied ? "checkmark" : "doc.on.doc") - .contentTransition(.symbolEffect(.replace)) - } - .help(String(localized: "Copy to clipboard")) - } - } - private var url: String { "http://127.0.0.1:\(port)/mcp" } private var steps: [String] { switch tool { case .claudeDesktop: - [ - "Open Claude Desktop, go to Settings > Developer", - "Click \"Edit Config\" to open claude_desktop_config.json", - "Add the JSON below inside the file and save", - "Restart Claude Desktop" + return [ + String(localized: "Open Claude Desktop, go to Settings > Developer"), + String(localized: "Click \"Edit Config\" to open claude_desktop_config.json"), + String(localized: "Add the JSON below inside the file and save"), + String(localized: "Restart Claude Desktop") ] case .claudeCode: - ["Run the command below in your terminal"] + return [String(localized: "Run the command below in your terminal")] case .cursor: - [ - "Open Cursor, go to Settings > MCP", - "Click \"+ Add new global MCP server\"", - "Paste the JSON below and save" + return [ + String(localized: "Open Cursor, go to Settings > MCP"), + String(localized: "Click \"+ Add new global MCP server\""), + String(localized: "Paste the JSON below and save") ] } } @@ -297,7 +269,7 @@ private struct MCPSetupInstructions: View { private var configSnippet: String? { switch tool { case .claudeDesktop, .cursor: - """ + return """ { "mcpServers": { "tablepro": { @@ -306,7 +278,8 @@ private struct MCPSetupInstructions: View { } } """ - case .claudeCode: nil + case .claudeCode: + return nil } } @@ -318,27 +291,19 @@ private struct MCPSetupInstructions: View { } } -// MARK: - Status Indicator - private struct MCPStatusIndicator: View { @State private var manager = MCPServerManager.shared var body: some View { - HStack(spacing: 6) { - Circle() - .fill(statusColor) - .frame(width: 8, height: 8) - Text(statusText) - .foregroundStyle(.secondary) - } + IntegrationStatusIndicator(status: status, label: statusText) } - private var statusColor: Color { + private var status: IntegrationStatus { switch manager.state { - case .stopped: .secondary - case .starting: .orange - case .running: .green - case .failed: .red + case .stopped: .stopped + case .starting: .starting + case .running: .running + case .failed: .failed } } diff --git a/TablePro/Views/Settings/Sections/MCPTokenCreateSheet.swift b/TablePro/Views/Settings/Sections/MCPTokenCreateSheet.swift index 80356562b..113f93658 100644 --- a/TablePro/Views/Settings/Sections/MCPTokenCreateSheet.swift +++ b/TablePro/Views/Settings/Sections/MCPTokenCreateSheet.swift @@ -1,8 +1,3 @@ -// -// MCPTokenCreateSheet.swift -// TablePro -// - import SwiftUI struct MCPTokenCreateSheet: View { @@ -33,23 +28,21 @@ struct MCPTokenCreateSheet: View { actionBar .padding() } - .frame(width: 480, height: 520) + .frame(minWidth: 480, minHeight: 520) .task { connections = ConnectionStorage.shared.loadConnections() } } - // MARK: - Sections - private var nameSection: some View { - Section("Token Name") { - TextField("e.g., Claude Code on VPS", text: $tokenName) + Section(String(localized: "Token Name")) { + TextField(String(localized: "e.g., Claude Code on VPS"), text: $tokenName) } } private var permissionsSection: some View { - Section("Permission Level") { - Picker("Permission", selection: $permissions) { + Section(String(localized: "Permission Level")) { + Picker(String(localized: "Permission"), selection: $permissions) { ForEach(TokenPermissions.allCases) { permission in Text(permission.displayName).tag(permission) } @@ -60,10 +53,10 @@ struct MCPTokenCreateSheet: View { } private var connectionAccessSection: some View { - Section("Connection Access") { - Picker("Access", selection: $connectionAccess) { - Text("All Connections").tag(ConnectionAccessMode.all) - Text("Select Connections").tag(ConnectionAccessMode.selected) + Section(String(localized: "Connection Access")) { + Picker(String(localized: "Access"), selection: $connectionAccess) { + Text(String(localized: "All Connections")).tag(ConnectionAccessMode.all) + Text(String(localized: "Select Connections")).tag(ConnectionAccessMode.selected) } .labelsHidden() @@ -76,7 +69,7 @@ struct MCPTokenCreateSheet: View { @ViewBuilder private var connectionList: some View { if connections.isEmpty { - Text("No saved connections") + Text(String(localized: "No saved connections")) .foregroundStyle(.secondary) } else { ForEach(connections) { connection in @@ -94,8 +87,8 @@ struct MCPTokenCreateSheet: View { } private var expirationSection: some View { - Section("Expiration") { - Picker("Expires", selection: $expirationOption) { + Section(String(localized: "Expiration")) { + Picker(String(localized: "Expires"), selection: $expirationOption) { ForEach(ExpirationOption.allCases) { option in Text(option.displayName).tag(option) } @@ -104,7 +97,7 @@ struct MCPTokenCreateSheet: View { if expirationOption == .custom { DatePicker( - "Expiration date", + String(localized: "Expiration date"), selection: $customExpirationDate, in: Date.now..., displayedComponents: .date @@ -115,24 +108,23 @@ struct MCPTokenCreateSheet: View { private var actionBar: some View { HStack { - Button("Cancel", role: .cancel) { + Button(String(localized: "Cancel"), role: .cancel) { dismiss() } .keyboardShortcut(.cancelAction) Spacer() - Button("Generate") { + Button(String(localized: "Generate")) { let connectionIds: Set? = connectionAccess == .selected ? selectedConnectionIds : nil onGenerate(tokenName, permissions, connectionIds, resolvedExpirationDate) } .keyboardShortcut(.defaultAction) - .disabled(tokenName.trimmingCharacters(in: .whitespaces).isEmpty || (connectionAccess == .selected && selectedConnectionIds.isEmpty)) + .disabled(tokenName.trimmingCharacters(in: .whitespaces).isEmpty + || (connectionAccess == .selected && selectedConnectionIds.isEmpty)) } } - // MARK: - Helpers - private func connectionBinding(for id: UUID) -> Binding { Binding( get: { selectedConnectionIds.contains(id) }, @@ -157,8 +149,6 @@ struct MCPTokenCreateSheet: View { } } -// MARK: - Supporting Types - private enum ConnectionAccessMode: String, Identifiable { case all case selected diff --git a/TablePro/Views/Settings/Sections/MCPTokenListView.swift b/TablePro/Views/Settings/Sections/MCPTokenListView.swift index 78112e7ba..246e189bc 100644 --- a/TablePro/Views/Settings/Sections/MCPTokenListView.swift +++ b/TablePro/Views/Settings/Sections/MCPTokenListView.swift @@ -1,8 +1,4 @@ -// -// MCPTokenListView.swift -// TablePro -// - +import AppKit import SwiftUI struct MCPTokenListView: View { @@ -11,30 +7,119 @@ struct MCPTokenListView: View { let onRevoke: (UUID) -> Void let onDelete: (UUID) -> Void + @State private var selection: Set = [] + @State private var deleteCandidate: MCPAuthToken? + var body: some View { - if tokens.isEmpty { - Text("No tokens created") + VStack(alignment: .leading, spacing: 8) { + if tokens.isEmpty { + emptyState + } else { + List(selection: $selection) { + ForEach(tokens) { token in + MCPTokenRow(token: token) + .tag(token.id) + .contextMenu { + contextMenu(for: token) + } + } + } + .frame(minHeight: 160) + .onDeleteCommand(perform: deleteSelectionFromKeyboard) + } + + HStack(spacing: 8) { + Button { + onGenerate() + } label: { + Label(String(localized: "Generate Token"), systemImage: "plus") + } + .buttonStyle(.borderedProminent) + .accessibilityLabel(String(localized: "Generate token")) + + Spacer() + } + } + .alert(deleteAlertTitle, isPresented: deleteAlertBinding, presenting: deleteCandidate) { token in + Button(String(localized: "Cancel"), role: .cancel) { + deleteCandidate = nil + } + Button(String(localized: "Delete"), role: .destructive) { + onDelete(token.id) + selection.remove(token.id) + deleteCandidate = nil + } + } message: { token in + Text(String(format: String(localized: "“%@” will be permanently deleted. External clients using this token will lose access immediately."), token.name)) + } + } + + private var emptyState: some View { + VStack(spacing: 8) { + Image(systemName: "key") + .font(.system(size: 28)) + .foregroundStyle(.tertiary) + Text(String(localized: "No tokens created")) .foregroundStyle(.secondary) - } else { - ForEach(tokens) { token in - MCPTokenRow( - token: token, - onRevoke: { onRevoke(token.id) }, - onDelete: { onDelete(token.id) } - ) + Text(String(localized: "Generate a token so external clients can connect with their own credentials.")) + .font(.caption) + .foregroundStyle(.tertiary) + .multilineTextAlignment(.center) + } + .frame(maxWidth: .infinity) + .padding(.vertical, 24) + } + + @ViewBuilder + private func contextMenu(for token: MCPAuthToken) -> some View { + if token.isActive { + Button(role: .destructive) { + onRevoke(token.id) + } label: { + Label(String(localized: "Revoke"), systemImage: "xmark.circle") } } + Button { + copyTokenId(token.id) + } label: { + Label(String(localized: "Copy ID"), systemImage: "doc.on.doc") + } + Divider() + Button(role: .destructive) { + deleteCandidate = token + } label: { + Label(String(localized: "Delete…"), systemImage: "trash") + } + } - Button("Generate New Token", action: onGenerate) + private func deleteSelectionFromKeyboard() { + guard let id = selection.first, let token = tokens.first(where: { $0.id == id }) else { return } + deleteCandidate = token + } + + private func copyTokenId(_ id: UUID) { + NSPasteboard.general.clearContents() + NSPasteboard.general.setString(id.uuidString, forType: .string) } -} -// MARK: - Token Row + private var deleteAlertTitle: String { + String(localized: "Delete token?") + } + + private var deleteAlertBinding: Binding { + Binding( + get: { deleteCandidate != nil }, + set: { isPresented in + if !isPresented { + deleteCandidate = nil + } + } + ) + } +} private struct MCPTokenRow: View { let token: MCPAuthToken - let onRevoke: () -> Void - let onDelete: () -> Void var body: some View { HStack(spacing: 10) { @@ -56,7 +141,7 @@ private struct MCPTokenRow: View { .font(.system(.caption, design: .monospaced)) .foregroundStyle(.secondary) - lastUsedText + Text(lastUsedText) .font(.caption) .foregroundStyle(.tertiary) } @@ -64,63 +149,35 @@ private struct MCPTokenRow: View { Spacer() - statusIndicator - - contextMenuButton + IntegrationStatusIndicator(status: tokenStatus) + .help(tokenStatus == .active + ? String(localized: "Active") + : tokenStatus == .expired + ? String(localized: "Expired") + : String(localized: "Revoked")) } + .padding(.vertical, 2) } - private var statusIndicator: some View { - Circle() - .fill(token.isEffectivelyActive ? .green : .red) - .frame(width: 8, height: 8) - .help(statusHelpText) - } - - private var statusHelpText: String { - if token.isExpired { - return String(localized: "Expired") - } - return token.isActive ? String(localized: "Active") : String(localized: "Revoked") + private var tokenStatus: IntegrationStatus { + if token.isExpired { return .expired } + return token.isActive ? .active : .revoked } - private var lastUsedText: some View { - Group { - if let lastUsed = token.lastUsedAt { - Text(lastUsed, style: .relative) + Text(" ago") - } else { - Text("Never used") - } + private var lastUsedText: String { + guard let lastUsed = token.lastUsedAt else { + return String(localized: "Never used") } + let formatter = RelativeDateTimeFormatter() + formatter.unitsStyle = .full + return formatter.localizedString(for: lastUsed, relativeTo: .now) } private var permissionColor: Color { switch token.permissions { - case .readOnly: .blue - case .readWrite: .orange - case .fullAccess: .red - } - } - - private var contextMenuButton: some View { - Menu { - if token.isActive { - Button(role: .destructive) { - onRevoke() - } label: { - Label("Deactivate", systemImage: "xmark.circle") - } - } - Button(role: .destructive) { - onDelete() - } label: { - Label("Delete", systemImage: "trash") - } - } label: { - Image(systemName: "ellipsis.circle") - .foregroundStyle(.secondary) + case .readOnly: Color(nsColor: .systemBlue) + case .readWrite: Color(nsColor: .systemOrange) + case .fullAccess: Color(nsColor: .systemRed) } - .menuStyle(.borderlessButton) - .frame(width: 24) } } diff --git a/TablePro/Views/Settings/Sections/MCPTokenRevealSheet.swift b/TablePro/Views/Settings/Sections/MCPTokenRevealSheet.swift index 443212f8a..25715f270 100644 --- a/TablePro/Views/Settings/Sections/MCPTokenRevealSheet.swift +++ b/TablePro/Views/Settings/Sections/MCPTokenRevealSheet.swift @@ -1,8 +1,3 @@ -// -// MCPTokenRevealSheet.swift -// TablePro -// - import AppKit import SwiftUI @@ -15,7 +10,7 @@ struct MCPTokenRevealSheet: View { @State private var isTokenRevealed = false @State private var tokenCopied = false - @State private var selectedClient: MCPSetupClient = .claudeCode + @State private var selectedClient: IntegrationClient = .claudeCode var body: some View { VStack(spacing: 0) { @@ -32,35 +27,35 @@ struct MCPTokenRevealSheet: View { HStack { Spacer() - Button("Done") { dismiss() } + Button(String(localized: "Done")) { dismiss() } .keyboardShortcut(.defaultAction) } .padding() } - .frame(width: 540, height: 520) + .frame(minWidth: 540, minHeight: 520) } - // MARK: - Warning Banner - private var warningBanner: some View { Label { - Text("This token will not be shown again") + Text(String(localized: "This token will not be shown again")) .fontWeight(.medium) } icon: { Image(systemName: "exclamationmark.triangle.fill") + .foregroundStyle(Color(nsColor: .systemOrange)) } - .foregroundStyle(Color(nsColor: .systemOrange)) .padding(12) .frame(maxWidth: .infinity, alignment: .leading) - .background(Color(nsColor: .systemOrange).opacity(0.1)) + .background(.thinMaterial) + .overlay( + RoundedRectangle(cornerRadius: 8) + .strokeBorder(Color(nsColor: .systemOrange), lineWidth: 1) + ) .clipShape(RoundedRectangle(cornerRadius: 8)) } - // MARK: - Token Display - private var tokenDisplay: some View { VStack(alignment: .leading, spacing: 8) { - Text("Token") + Text(String(localized: "Token")) .font(.headline) HStack(spacing: 8) { @@ -77,7 +72,12 @@ struct MCPTokenRevealSheet: View { } label: { Image(systemName: isTokenRevealed ? "eye.slash" : "eye") } - .help(isTokenRevealed ? String(localized: "Hide token") : String(localized: "Reveal token")) + .accessibilityLabel(isTokenRevealed + ? String(localized: "Hide token") + : String(localized: "Reveal token")) + .help(isTokenRevealed + ? String(localized: "Hide token") + : String(localized: "Reveal token")) } .padding(10) .background(.quaternary) @@ -94,10 +94,13 @@ struct MCPTokenRevealSheet: View { HStack(spacing: 4) { Image(systemName: tokenCopied ? "checkmark" : "doc.on.doc") .contentTransition(.symbolEffect(.replace)) - Text(tokenCopied ? "Copied" : "Copy Token") + Text(tokenCopied + ? String(localized: "Copied") + : String(localized: "Copy Token")) } } .buttonStyle(.borderedProminent) + .accessibilityLabel(String(localized: "Copy token")) } } @@ -105,56 +108,33 @@ struct MCPTokenRevealSheet: View { String(plaintext.prefix(8)) + String(repeating: "\u{2022}", count: 24) } - // MARK: - Setup Instructions - private var setupInstructions: some View { VStack(alignment: .leading, spacing: 12) { - Text("Setup Instructions") + Text(String(localized: "Setup Instructions")) .font(.headline) - Picker("Client", selection: $selectedClient) { - ForEach(MCPSetupClient.allCases) { client in + Picker(String(localized: "Client"), selection: $selectedClient) { + ForEach(IntegrationClient.allCases) { client in Text(client.displayName).tag(client) } } .labelsHidden() .pickerStyle(.segmented) - snippetView(for: selectedClient) + CopyableCodeBlock(text: configSnippet(for: selectedClient)) } } - @ViewBuilder - private func snippetView(for client: MCPSetupClient) -> some View { - let snippet = configSnippet(for: client) - MCPCopyableCodeBlock(text: snippet) - } - - // MARK: - Config Snippets - private var baseURL: String { let scheme = allowRemoteConnections ? "https" : "http" return "\(scheme)://127.0.0.1:\(port)/mcp" } - private func configSnippet(for client: MCPSetupClient) -> String { + private func configSnippet(for client: IntegrationClient) -> String { switch client { case .claudeCode: return "claude mcp add tablepro --transport http \(baseURL) --header \"Authorization: Bearer \(plaintext)\"" - case .claudeDesktop: - return """ - { - "mcpServers": { - "tablepro": { - "url": "\(baseURL)", - "headers": { - "Authorization": "Bearer \(plaintext)" - } - } - } - } - """ - case .cursor: + case .claudeDesktop, .cursor: return """ { "mcpServers": { @@ -175,54 +155,3 @@ struct MCPTokenRevealSheet: View { NSPasteboard.general.setString(text, forType: .string) } } - -// MARK: - Setup Client - -private enum MCPSetupClient: String, CaseIterable, Identifiable { - case claudeCode - case claudeDesktop - case cursor - - var id: String { rawValue } - - var displayName: String { - switch self { - case .claudeCode: "Claude Code" - case .claudeDesktop: "Claude Desktop" - case .cursor: "Cursor" - } - } -} - -// MARK: - Copyable Code Block - -private struct MCPCopyableCodeBlock: View { - let text: String - @State private var copied = false - - var body: some View { - HStack(alignment: .top) { - Text(text) - .font(.system(.caption, design: .monospaced)) - .textSelection(.enabled) - .padding(8) - .frame(maxWidth: .infinity, alignment: .leading) - .background(.quaternary) - .clipShape(RoundedRectangle(cornerRadius: 6)) - - Button { - NSPasteboard.general.clearContents() - NSPasteboard.general.setString(text, forType: .string) - copied = true - Task { @MainActor in - try? await Task.sleep(for: .seconds(1.5)) - copied = false - } - } label: { - Image(systemName: copied ? "checkmark" : "doc.on.doc") - .contentTransition(.symbolEffect(.replace)) - } - .help(String(localized: "Copy to clipboard")) - } - } -} diff --git a/TablePro/Views/Settings/Sections/PairingApprovalSheet.swift b/TablePro/Views/Settings/Sections/PairingApprovalSheet.swift new file mode 100644 index 000000000..521eaf3ea --- /dev/null +++ b/TablePro/Views/Settings/Sections/PairingApprovalSheet.swift @@ -0,0 +1,306 @@ +import Combine +import SwiftUI + +struct PairingApproval: Sendable { + let grantedPermissions: TokenPermissions + let allowedConnectionIds: Set? + let expiresAt: Date? +} + +struct PairingApprovalSheet: View { + let request: PairingRequest + let codeExpiresAt: Date + let onComplete: (Result) -> Void + + @State private var permissions: TokenPermissions + @State private var connectionAccess: ConnectionAccessMode = .all + @State private var selectedConnectionIds: Set = [] + @State private var expiry: ExpiryOption = .never + @State private var connections: [DatabaseConnection] = [] + @State private var connectionSearch: String = "" + @State private var now: Date = .now + + private let timer = Timer.publish(every: 1, on: .main, in: .common).autoconnect() + + init( + request: PairingRequest, + codeExpiresAt: Date, + onComplete: @escaping (Result) -> Void + ) { + self.request = request + self.codeExpiresAt = codeExpiresAt + self.onComplete = onComplete + let initialPermissions = Self.initialPermissions(from: request) + _permissions = State(initialValue: initialPermissions) + if let requested = request.requestedConnectionIds, !requested.isEmpty { + _connectionAccess = State(initialValue: .selected) + _selectedConnectionIds = State(initialValue: requested) + } + } + + var body: some View { + VStack(spacing: 0) { + header + Divider() + Form { + permissionsSection + connectionAccessSection + expirySection + } + .formStyle(.grouped) + .scrollContentBackground(.hidden) + + Divider() + actionBar.padding() + } + .frame(minWidth: 520, minHeight: 560) + .task { + connections = ConnectionStorage.shared.loadConnections() + if connectionAccess == .all { + selectedConnectionIds = Set(connections.map(\.id)) + } + } + .onReceive(timer) { value in + now = value + } + } + + private var header: some View { + VStack(alignment: .leading, spacing: 6) { + Text(String(format: String(localized: "Allow %@ to access TablePro?"), request.clientName)) + .font(.headline) + Text(String(localized: "An external app is asking for an API token. Review the permissions before approving.")) + .font(.callout) + .foregroundStyle(.secondary) + countdownLabel + } + .frame(maxWidth: .infinity, alignment: .leading) + .padding() + } + + private var countdownLabel: some View { + HStack(spacing: 6) { + Image(systemName: isExpired ? "clock.badge.exclamationmark.fill" : "clock") + .foregroundStyle(isExpired ? Color(nsColor: .systemRed) : Color(nsColor: .secondaryLabelColor)) + .imageScale(.small) + .accessibilityHidden(true) + Text(countdownText) + .font(.caption) + .monospacedDigit() + .foregroundStyle(isExpired ? Color(nsColor: .systemRed) : .secondary) + .contentTransition(.numericText()) + } + } + + private var remainingSeconds: Int { + let interval = codeExpiresAt.timeIntervalSince(now) + return max(0, Int(interval.rounded(.up))) + } + + private var isExpired: Bool { + remainingSeconds <= 0 + } + + private var countdownText: String { + if isExpired { + return String(localized: "Code expired") + } + return String(format: String(localized: "Code expires in %d seconds"), remainingSeconds) + } + + private var permissionsSection: some View { + Section(String(localized: "Permission Level")) { + Picker(String(localized: "Permission"), selection: $permissions) { + ForEach(TokenPermissions.allCases) { permission in + Text(permission.displayName).tag(permission) + } + } + .pickerStyle(.segmented) + .labelsHidden() + + Text(permissionsDescription) + .font(.caption) + .foregroundStyle(.secondary) + } + } + + private var connectionAccessSection: some View { + Section(String(localized: "Allowed Connections")) { + Picker(String(localized: "Access"), selection: $connectionAccess) { + Text(String(localized: "All Connections")).tag(ConnectionAccessMode.all) + Text(String(localized: "Select Connections")).tag(ConnectionAccessMode.selected) + } + .labelsHidden() + .onChange(of: connectionAccess) { _, newValue in + if newValue == .all { + selectedConnectionIds = Set(connections.map(\.id)) + } else if selectedConnectionIds.isEmpty { + selectedConnectionIds = Set(connections.map(\.id)) + } + } + + if connectionAccess == .selected { + connectionList + } + } + } + + @ViewBuilder + private var connectionList: some View { + if connections.isEmpty { + Text(String(localized: "No saved connections")) + .foregroundStyle(.secondary) + } else { + HStack { + TextField(String(localized: "Search connections"), text: $connectionSearch) + .textFieldStyle(.roundedBorder) + Spacer() + Button(String(localized: "Select All")) { + selectedConnectionIds.formUnion(filteredConnections.map(\.id)) + } + Button(String(localized: "Deselect All")) { + selectedConnectionIds.subtract(filteredConnections.map(\.id)) + } + } + + ScrollView { + VStack(alignment: .leading, spacing: 0) { + ForEach(filteredConnections) { connection in + Toggle(isOn: connectionBinding(for: connection.id)) { + HStack(spacing: 6) { + Text(connection.name) + Text(connection.type.displayName) + .font(.caption) + .foregroundStyle(.secondary) + } + } + .toggleStyle(.checkbox) + .padding(.vertical, 2) + } + } + } + .frame(maxHeight: 200) + } + } + + private var filteredConnections: [DatabaseConnection] { + let trimmed = connectionSearch.trimmingCharacters(in: .whitespacesAndNewlines) + guard !trimmed.isEmpty else { return connections } + let lowercased = trimmed.lowercased() + return connections.filter { connection in + connection.name.lowercased().contains(lowercased) + || connection.type.displayName.lowercased().contains(lowercased) + } + } + + private var expirySection: some View { + Section(String(localized: "Expiration")) { + Picker(String(localized: "Expires"), selection: $expiry) { + ForEach(ExpiryOption.allCases) { option in + Text(option.displayName).tag(option) + } + } + .labelsHidden() + } + } + + private var actionBar: some View { + HStack { + Button(String(localized: "Deny"), role: .cancel) { + onComplete(.failure(MCPError.userCancelled)) + } + .keyboardShortcut(.cancelAction) + + Spacer() + + Button(String(localized: "Approve")) { + let approval = PairingApproval( + grantedPermissions: permissions, + allowedConnectionIds: connectionAccess == .selected ? selectedConnectionIds : nil, + expiresAt: expiry.resolvedDate + ) + onComplete(.success(approval)) + } + .disabled(approveDisabled) + } + } + + private var approveDisabled: Bool { + if isExpired { return true } + if connectionAccess == .selected && selectedConnectionIds.isEmpty { return true } + return false + } + + private func connectionBinding(for id: UUID) -> Binding { + Binding( + get: { selectedConnectionIds.contains(id) }, + set: { isSelected in + if isSelected { + selectedConnectionIds.insert(id) + } else { + selectedConnectionIds.remove(id) + } + } + ) + } + + private var permissionsDescription: String { + switch permissions { + case .readOnly: + String(localized: "Read schema and run SELECT queries.") + case .readWrite: + String(localized: "Read schema and run any non-destructive query, including INSERT, UPDATE, and DELETE.") + case .fullAccess: + String(localized: "Full access including destructive DDL after explicit confirmation.") + } + } + + private static func initialPermissions(from request: PairingRequest) -> TokenPermissions { + guard let raw = request.requestedScopes?.lowercased() else { return .readOnly } + switch raw { + case "readwrite", "read_write", "read-write": + return .readWrite + case "fullaccess", "full_access", "full-access", "full": + return .fullAccess + default: + return .readOnly + } + } +} + +private enum ConnectionAccessMode: String, Identifiable, Sendable { + case all + case selected + + var id: String { rawValue } +} + +private enum ExpiryOption: String, CaseIterable, Identifiable, Sendable { + case never + case oneDay + case sevenDays + case thirtyDays + case ninetyDays + + var id: String { rawValue } + + var displayName: String { + switch self { + case .never: String(localized: "Never") + case .oneDay: String(localized: "1 day") + case .sevenDays: String(localized: "7 days") + case .thirtyDays: String(localized: "30 days") + case .ninetyDays: String(localized: "90 days") + } + } + + var resolvedDate: Date? { + switch self { + case .never: nil + case .oneDay: Calendar.current.date(byAdding: .day, value: 1, to: .now) + case .sevenDays: Calendar.current.date(byAdding: .day, value: 7, to: .now) + case .thirtyDays: Calendar.current.date(byAdding: .day, value: 30, to: .now) + case .ninetyDays: Calendar.current.date(byAdding: .day, value: 90, to: .now) + } + } +} diff --git a/TablePro/Views/Settings/SettingsView.swift b/TablePro/Views/Settings/SettingsView.swift index 9754db52e..30a2ab5a4 100644 --- a/TablePro/Views/Settings/SettingsView.swift +++ b/TablePro/Views/Settings/SettingsView.swift @@ -50,7 +50,7 @@ struct SettingsView: View { .tag(SettingsTab.terminal.rawValue) MCPSettingsView(settings: $settingsManager.mcp) - .tabItem { Label("MCP", systemImage: "network") } + .tabItem { Label("Integrations", systemImage: "network") } .tag(SettingsTab.mcp.rawValue) PluginsSettingsView() diff --git a/TablePro/Views/Sidebar/SidebarView.swift b/TablePro/Views/Sidebar/SidebarView.swift index b1aef4621..1fe9406e1 100644 --- a/TablePro/Views/Sidebar/SidebarView.swift +++ b/TablePro/Views/Sidebar/SidebarView.swift @@ -11,9 +11,10 @@ import SwiftUI /// Sidebar view with segmented tab picker for Tables and Favorites struct SidebarView: View { + @State private var viewModel: SidebarViewModel + @Bindable private var schemaService = SchemaService.shared - @Binding var tables: [TableInfo] var sidebarState: SharedSidebarState @Binding var pendingTruncates: Set @Binding var pendingDeletes: Set @@ -22,6 +23,10 @@ struct SidebarView: View { var connectionId: UUID private weak var coordinator: MainContentCoordinator? + private var tables: [TableInfo] { + schemaService.tables(for: connectionId) + } + private var filteredTables: [TableInfo] { guard !viewModel.searchText.isEmpty else { return tables } return tables.filter { $0.name.localizedCaseInsensitiveContains(viewModel.searchText) } @@ -35,7 +40,6 @@ struct SidebarView: View { } init( - tables: Binding<[TableInfo]>, sidebarState: SharedSidebarState, onDoubleClick: ((TableInfo) -> Void)? = nil, pendingTruncates: Binding>, @@ -45,7 +49,6 @@ struct SidebarView: View { connectionId: UUID, coordinator: MainContentCoordinator? = nil ) { - _tables = tables self.sidebarState = sidebarState self.onDoubleClick = onDoubleClick _pendingTruncates = pendingTruncates @@ -55,7 +58,6 @@ struct SidebarView: View { set: { sidebarState.selectedTables = $0 } ) let vm = SidebarViewModel( - tables: tables, selectedTables: selectedBinding, pendingTruncates: pendingTruncates, pendingDeletes: pendingDeletes, @@ -92,7 +94,6 @@ struct SidebarView: View { } .onAppear { coordinator?.sidebarViewModel = viewModel - coordinator?.healSidebarLoadingStateIfNeeded() // Update toolbar version if driver connected before this window's observer was set up if let driver = DatabaseManager.shared.driver(for: connectionId), coordinator?.toolbarState.databaseVersion == nil { @@ -121,16 +122,16 @@ struct SidebarView: View { @ViewBuilder private var tablesContent: some View { - switch coordinator?.sidebarLoadingState ?? (tables.isEmpty ? .idle : .loaded) { - case .loading: + switch schemaService.state(for: connectionId) { + case .loading where tables.isEmpty: loadingState - case .error(let message): + case .failed(let message): errorState(message: message) - case .loaded where tables.isEmpty: - emptyState case .loaded where !viewModel.searchText.isEmpty && filteredTables.isEmpty: noMatchState - case .loaded: + case .loaded(let allTables) where allTables.isEmpty: + emptyState + case .loaded, .loading: tableList case .idle: emptyState @@ -258,7 +259,6 @@ struct SidebarView: View { #Preview { SidebarView( - tables: .constant([]), sidebarState: SharedSidebarState(), pendingTruncates: .constant([]), pendingDeletes: .constant([]), diff --git a/TablePro/Views/Toolbar/ConnectionSwitcherPopover.swift b/TablePro/Views/Toolbar/ConnectionSwitcherPopover.swift index ea57967af..a9676ce64 100644 --- a/TablePro/Views/Toolbar/ConnectionSwitcherPopover.swift +++ b/TablePro/Views/Toolbar/ConnectionSwitcherPopover.swift @@ -13,11 +13,8 @@ import TableProPluginKit /// Popover content for quick connection switching struct ConnectionSwitcherPopover: View { @State private var savedConnections: [DatabaseConnection] = [] - @State private var isConnecting: UUID? @State private var selectedIndex: Int = 0 - @Environment(\.openWindow) private var openWindow - /// Callback when the popover should dismiss var onDismiss: (() -> Void)? @@ -94,7 +91,6 @@ struct ConnectionSwitcherPopover: View { connection: connection, isActive: false, isConnected: false, - isConnecting: isConnecting == connection.id, isHighlighted: itemIndex == selectedIndex ) } @@ -126,7 +122,7 @@ struct ConnectionSwitcherPopover: View { // Manage connections button Button { onDismiss?() - NotificationCenter.default.post(name: .openWelcomeWindow, object: nil) + WelcomeWindowFactory.openOrFront() } label: { HStack { Image(systemName: "gear") @@ -203,7 +199,6 @@ struct ConnectionSwitcherPopover: View { connection: DatabaseConnection, isActive: Bool, isConnected: Bool, - isConnecting: Bool = false, isHighlighted: Bool = false ) -> some View { HStack(spacing: 8) { @@ -228,10 +223,7 @@ struct ConnectionSwitcherPopover: View { Spacer() // Status indicator - if isConnecting { - ProgressView() - .controlSize(.small) - } else if isActive { + if isActive { Image(systemName: "checkmark.circle.fill") .foregroundStyle(isHighlighted ? Color(nsColor: .alternateSelectedControlTextColor) : Color(nsColor: .systemGreen)) .font(.system(size: 14)) @@ -287,23 +279,18 @@ struct ConnectionSwitcherPopover: View { } private func switchToSession(_ sessionId: UUID) { - onDismiss?() - // Try to bring existing window for this connection to front - if let existingWindow = findWindow(for: sessionId) { - existingWindow.makeKeyAndOrderFront(nil) - } else { - openWindowForDifferentConnection(EditorTabPayload(connectionId: sessionId)) - } + openConnection(sessionId) } private func connectToSaved(_ connection: DatabaseConnection) { - isConnecting = connection.id + openConnection(connection.id) + } + + private func openConnection(_ id: UUID) { onDismiss?() - // Open a new window, then connect — window shows "Connecting..." until ready - openWindowForDifferentConnection(EditorTabPayload(connectionId: connection.id)) Task { do { - try await DatabaseManager.shared.connectToSession(connection) + try await TabRouter.shared.route(.openConnection(id)) } catch { await MainActor.run { AlertHelper.showErrorSheet( @@ -313,32 +300,6 @@ struct ConnectionSwitcherPopover: View { ) } } - await MainActor.run { - isConnecting = nil - } - } - } - - /// Find an existing visible window for the given connection ID - private func findWindow(for connectionId: UUID) -> NSWindow? { - WindowLifecycleMonitor.shared.findWindow(for: connectionId) - } - - /// Open a new window for a different connection, ensuring it doesn't - /// merge as a tab with the current connection's window group - /// (unless the user opted to group all connections in one window). - private func openWindowForDifferentConnection(_ payload: EditorTabPayload) { - if AppSettingsManager.shared.tabs.groupAllConnectionTabs { - WindowManager.shared.openTab(payload: payload) - } else { - // Temporarily disable tab merging so the new window opens independently - let currentWindow = NSApp.keyWindow - let previousMode = currentWindow?.tabbingMode ?? .preferred - currentWindow?.tabbingMode = .disallowed - WindowManager.shared.openTab(payload: payload) - DispatchQueue.main.async { - currentWindow?.tabbingMode = previousMode - } } } } diff --git a/TablePro/Views/Toolbar/TableProToolbarView.swift b/TablePro/Views/Toolbar/TableProToolbarView.swift index df95683d6..1d13af062 100644 --- a/TablePro/Views/Toolbar/TableProToolbarView.swift +++ b/TablePro/Views/Toolbar/TableProToolbarView.swift @@ -59,7 +59,6 @@ struct ToolbarPrincipalContent: View { struct TableProToolbar: ViewModifier { @Bindable var state: ConnectionToolbarState @FocusedValue(\.commandActions) private var actions: MainContentCommandActions? - @State private var showConnectionSwitcher = false func body(content: Content) -> some View { content @@ -68,14 +67,14 @@ struct TableProToolbar: ViewModifier { ToolbarItem(placement: .navigation) { Button { - showConnectionSwitcher.toggle() + state.showConnectionSwitcher.toggle() } label: { Label("Connection", systemImage: "network") } .help(String(localized: "Switch Connection (⌘⌥C)")) - .popover(isPresented: $showConnectionSwitcher) { + .popover(isPresented: $state.showConnectionSwitcher) { ConnectionSwitcherPopover { - showConnectionSwitcher = false + state.showConnectionSwitcher = false } } } @@ -229,9 +228,6 @@ struct TableProToolbar: ViewModifier { } } } - .onReceive(NotificationCenter.default.publisher(for: .openConnectionSwitcher)) { _ in - showConnectionSwitcher = true - } } } diff --git a/TableProTests/Core/Concurrency/OnceTaskTests.swift b/TableProTests/Core/Concurrency/OnceTaskTests.swift new file mode 100644 index 000000000..33576a7ef --- /dev/null +++ b/TableProTests/Core/Concurrency/OnceTaskTests.swift @@ -0,0 +1,186 @@ +// +// OnceTaskTests.swift +// TableProTests +// + +import Foundation +@testable import TablePro +import XCTest + +final class OnceTaskTests: XCTestCase { + actor Counter { + private(set) var value: Int = 0 + + func increment() { + value += 1 + } + } + + private struct TestError: Error, Equatable { + let tag: String + } + + func testConcurrentSameKeyRunsWorkOnce() async throws { + let dedup = OnceTask() + let counter = Counter() + + async let first = dedup.execute(key: "k") { + await counter.increment() + try await Task.sleep(for: .milliseconds(50)) + return 42 + } + async let second = dedup.execute(key: "k") { + await counter.increment() + try await Task.sleep(for: .milliseconds(50)) + return 99 + } + + let results = try await [first, second] + let invocations = await counter.value + + XCTAssertEqual(invocations, 1, "Work block must run exactly once for concurrent same-key callers") + XCTAssertEqual(results[0], results[1], "Concurrent callers must observe the same value") + XCTAssertEqual(results[0], 42, "Both callers must receive the value produced by the first work block") + } + + func testConcurrentDifferentKeysRunWorkSeparately() async throws { + let dedup = OnceTask() + let counter = Counter() + + async let alpha = dedup.execute(key: "alpha") { + await counter.increment() + try await Task.sleep(for: .milliseconds(20)) + return "alpha-value" + } + async let beta = dedup.execute(key: "beta") { + await counter.increment() + try await Task.sleep(for: .milliseconds(20)) + return "beta-value" + } + + let alphaValue = try await alpha + let betaValue = try await beta + let invocations = await counter.value + + XCTAssertEqual(invocations, 2, "Distinct keys must each run their own work block") + XCTAssertEqual(alphaValue, "alpha-value") + XCTAssertEqual(betaValue, "beta-value") + } + + func testThrowingWorkPropagatesAndClearsInFlight() async throws { + let dedup = OnceTask() + let counter = Counter() + + do { + _ = try await dedup.execute(key: "k") { + await counter.increment() + throw TestError(tag: "first") + } + XCTFail("Expected throw from first execute") + } catch let error as TestError { + XCTAssertEqual(error.tag, "first") + } + + let secondValue = try await dedup.execute(key: "k") { + await counter.increment() + return 7 + } + + XCTAssertEqual(secondValue, 7, "After a throw, the next execute must rerun the work") + let invocations = await counter.value + XCTAssertEqual(invocations, 2, "Both work blocks must have run (throw cleared the in-flight slot)") + } + + func testCancelKeyClearsInFlightAndAllowsRerun() async throws { + let dedup = OnceTask() + let counter = Counter() + let started = expectation(description: "work started") + started.assertForOverFulfill = false + + let inFlight = Task { + try await dedup.execute(key: "k") { + await counter.increment() + started.fulfill() + try await Task.sleep(for: .seconds(5)) + return 1 + } + } + + await fulfillment(of: [started], timeout: 2.0) + await dedup.cancel(key: "k") + + do { + _ = try await inFlight.value + XCTFail("Expected CancellationError from cancelled in-flight call") + } catch is CancellationError { + // expected + } catch { + XCTFail("Expected CancellationError, got \(error)") + } + + let rerunValue = try await dedup.execute(key: "k") { + await counter.increment() + return 11 + } + + XCTAssertEqual(rerunValue, 11, "After cancel, a fresh execute must run the work again") + let invocations = await counter.value + XCTAssertEqual(invocations, 2) + } + + func testSequentialSameKeyRunsWorkAgain() async throws { + let dedup = OnceTask() + let counter = Counter() + + let first = try await dedup.execute(key: "k") { + await counter.increment() + return 1 + } + let second = try await dedup.execute(key: "k") { + await counter.increment() + return 2 + } + + XCTAssertEqual(first, 1) + XCTAssertEqual(second, 2) + let invocations = await counter.value + XCTAssertEqual(invocations, 2, "Sequential calls (after first completes) must each run the work") + } + + func testCancelAllCancelsEveryInFlight() async throws { + let dedup = OnceTask() + let firstStarted = expectation(description: "first started") + let secondStarted = expectation(description: "second started") + firstStarted.assertForOverFulfill = false + secondStarted.assertForOverFulfill = false + + let firstTask = Task { + try await dedup.execute(key: "a") { + firstStarted.fulfill() + try await Task.sleep(for: .seconds(5)) + return 1 + } + } + let secondTask = Task { + try await dedup.execute(key: "b") { + secondStarted.fulfill() + try await Task.sleep(for: .seconds(5)) + return 2 + } + } + + await fulfillment(of: [firstStarted, secondStarted], timeout: 2.0) + await dedup.cancelAll() + + for task in [firstTask, secondTask] { + do { + _ = try await task.value + XCTFail("Expected CancellationError from cancelAll") + } catch is CancellationError { + // expected + } catch { + XCTFail("Expected CancellationError, got \(error)") + } + } + } +} diff --git a/TableProTests/Core/Database/DatabaseConnectionExternalAccessTests.swift b/TableProTests/Core/Database/DatabaseConnectionExternalAccessTests.swift new file mode 100644 index 000000000..48d86a8e9 --- /dev/null +++ b/TableProTests/Core/Database/DatabaseConnectionExternalAccessTests.swift @@ -0,0 +1,90 @@ +// +// DatabaseConnectionExternalAccessTests.swift +// TableProTests +// + +import Foundation +import Testing + +@testable import TablePro + +@Suite("DatabaseConnection externalAccess") +struct DatabaseConnectionExternalAccessTests { + @Test("Default value is readOnly") + func defaultValueIsReadOnly() { + let connection = DatabaseConnection(name: "Test") + #expect(connection.externalAccess == .readOnly) + } + + @Test("Decoding legacy JSON without externalAccess defaults to readOnly") + func decodeLegacyJSONDefaultsToReadOnly() throws { + let json = """ + { + "id": "11111111-2222-3333-4444-555555555555", + "name": "Legacy", + "host": "localhost", + "port": 3306, + "database": "test", + "username": "root", + "type": "MySQL", + "sshConfig": { "enabled": false, "host": "", "port": 22, "username": "", "authMethod": "password", "privateKeyPath": "" }, + "sslConfig": { "mode": "preferred" }, + "color": "None", + "sshTunnelMode": { "kind": "disabled" }, + "safeModeLevel": "silent", + "additionalFields": {}, + "sortOrder": 0, + "localOnly": false + } + """ + let data = Data(json.utf8) + let connection = try JSONDecoder().decode(DatabaseConnection.self, from: data) + #expect(connection.externalAccess == .readOnly) + } + + @Test("Decoding JSON with explicit externalAccess preserves value") + func decodeJSONWithExplicitValue() throws { + let json = """ + { + "id": "11111111-2222-3333-4444-555555555555", + "name": "Test", + "host": "localhost", + "port": 3306, + "database": "", + "username": "", + "type": "MySQL", + "sshConfig": { "enabled": false, "host": "", "port": 22, "username": "", "authMethod": "password", "privateKeyPath": "" }, + "sslConfig": { "mode": "preferred" }, + "color": "None", + "sshTunnelMode": { "kind": "disabled" }, + "safeModeLevel": "silent", + "externalAccess": "blocked", + "additionalFields": {}, + "sortOrder": 0, + "localOnly": false + } + """ + let data = Data(json.utf8) + let connection = try JSONDecoder().decode(DatabaseConnection.self, from: data) + #expect(connection.externalAccess == .blocked) + } + + @Test("Encoding round-trips externalAccess") + func encodeRoundTrip() throws { + let original = DatabaseConnection( + name: "Test", + externalAccess: .readWrite + ) + let data = try JSONEncoder().encode(original) + let decoded = try JSONDecoder().decode(DatabaseConnection.self, from: data) + #expect(decoded.externalAccess == .readWrite) + } + + @Test("All cases are CaseIterable") + func allCasesIterable() { + #expect(ExternalAccessLevel.allCases.count == 3) + #expect(ExternalAccessLevel.allCases.contains(.blocked)) + #expect(ExternalAccessLevel.allCases.contains(.readOnly)) + #expect(ExternalAccessLevel.allCases.contains(.readWrite)) + } +} diff --git a/TableProTests/Core/Database/MultiConnectionTests.swift b/TableProTests/Core/Database/MultiConnectionTests.swift index 60a2b39bc..6ad99ad1e 100644 --- a/TableProTests/Core/Database/MultiConnectionTests.swift +++ b/TableProTests/Core/Database/MultiConnectionTests.swift @@ -95,13 +95,12 @@ struct DatabaseManagerMultiSessionTests { DatabaseManager.shared.removeSession(for: id2) } - let table = TestFixtures.makeTableInfo(name: "users") DatabaseManager.shared.updateSession(id1) { session in - session.tables = [table] + session.pendingTruncates = ["users"] } - #expect(DatabaseManager.shared.session(for: id1)?.tables.count == 1) - #expect(DatabaseManager.shared.session(for: id2)?.tables.isEmpty == true) + #expect(DatabaseManager.shared.session(for: id1)?.pendingTruncates == ["users"]) + #expect(DatabaseManager.shared.session(for: id2)?.pendingTruncates.isEmpty == true) } @Test("Removing one session does not affect the other") @@ -133,7 +132,7 @@ struct DatabaseManagerMultiSessionTests { let countBefore = DatabaseManager.shared.activeSessions.count DatabaseManager.shared.updateSession(unknownId) { session in - session.tables = [TestFixtures.makeTableInfo(name: "ghost")] + session.pendingTruncates = ["ghost"] } #expect(DatabaseManager.shared.activeSessions.count == countBefore) @@ -228,35 +227,22 @@ struct CoordinatorConnectionIsolationTests { #expect(coordinator2.connectionId == id2) } - @Test("sidebarLoadingState is per-coordinator and does not bleed across instances") - func sidebarLoadingStateIsPerCoordinator() { - let conn1 = TestFixtures.makeConnection(id: UUID(), name: "Conn1", database: "db_a", type: .mysql) - let conn2 = TestFixtures.makeConnection(id: UUID(), name: "Conn2", database: "db_b", type: .mysql) - - let coordinator1 = MainContentCoordinator( - connection: conn1, - tabManager: QueryTabManager(), - changeManager: DataChangeManager(), - filterStateManager: FilterStateManager(), - columnVisibilityManager: ColumnVisibilityManager(), - toolbarState: ConnectionToolbarState() - ) - defer { coordinator1.teardown() } - - let coordinator2 = MainContentCoordinator( - connection: conn2, - tabManager: QueryTabManager(), - changeManager: DataChangeManager(), - filterStateManager: FilterStateManager(), - columnVisibilityManager: ColumnVisibilityManager(), - toolbarState: ConnectionToolbarState() - ) - defer { coordinator2.teardown() } + @Test("Schema state is per-connection in SchemaService") + func schemaStateIsPerConnection() async { + let id1 = UUID() + let id2 = UUID() - coordinator1.sidebarLoadingState = .loading + await SchemaService.shared.invalidate(connectionId: id1) + await SchemaService.shared.invalidate(connectionId: id2) + defer { + Task { + await SchemaService.shared.invalidate(connectionId: id1) + await SchemaService.shared.invalidate(connectionId: id2) + } + } - #expect(coordinator1.sidebarLoadingState == .loading) - #expect(coordinator2.sidebarLoadingState == .idle) + #expect(SchemaService.shared.state(for: id1) == .idle) + #expect(SchemaService.shared.state(for: id2) == .idle) } @Test("openTableTab uses coordinator's connection database for the added tab") diff --git a/TableProTests/Core/MCP/MCPAuditLogStorageTests.swift b/TableProTests/Core/MCP/MCPAuditLogStorageTests.swift new file mode 100644 index 000000000..2901eedbd --- /dev/null +++ b/TableProTests/Core/MCP/MCPAuditLogStorageTests.swift @@ -0,0 +1,210 @@ +// +// MCPAuditLogStorageTests.swift +// TableProTests +// + +import Foundation +import Testing + +@testable import TablePro + +@Suite("MCP Audit Log Storage") +struct MCPAuditLogStorageTests { + private func makeStorage() -> MCPAuditLogStorage { + MCPAuditLogStorage(isolatedForTesting: true) + } + + private func makeEntry( + category: AuditCategory = .tool, + tokenId: UUID? = nil, + tokenName: String? = nil, + connectionId: UUID? = nil, + timestamp: Date = Date(), + action: String = "tool.test", + outcome: AuditOutcome = .success, + details: String? = nil + ) -> AuditEntry { + AuditEntry( + timestamp: timestamp, + category: category, + tokenId: tokenId, + tokenName: tokenName, + connectionId: connectionId, + action: action, + outcome: outcome, + details: details + ) + } + + @Test("Insert and read single entry") + func insertAndRead() async { + let storage = makeStorage() + let entry = makeEntry(action: "auth.success", outcome: .success) + let inserted = await storage.addEntry(entry) + #expect(inserted == true) + + let entries = await storage.query() + #expect(entries.count == 1) + #expect(entries.first?.action == "auth.success") + #expect(entries.first?.outcome == AuditOutcome.success.rawValue) + } + + @Test("Query filters by category") + func filterByCategory() async { + let storage = makeStorage() + await storage.addEntry(makeEntry(category: .auth, action: "auth.success")) + await storage.addEntry(makeEntry(category: .tool, action: "tool.run")) + await storage.addEntry(makeEntry(category: .query, action: "query.executed")) + + let toolEntries = await storage.query(category: .tool) + #expect(toolEntries.count == 1) + #expect(toolEntries.first?.category == .tool) + + let authEntries = await storage.query(category: .auth) + #expect(authEntries.count == 1) + #expect(authEntries.first?.category == .auth) + } + + @Test("Query filters by token") + func filterByToken() async { + let storage = makeStorage() + let tokenA = UUID() + let tokenB = UUID() + await storage.addEntry(makeEntry(tokenId: tokenA, action: "tool.a")) + await storage.addEntry(makeEntry(tokenId: tokenB, action: "tool.b")) + await storage.addEntry(makeEntry(tokenId: tokenA, action: "tool.a2")) + + let aEntries = await storage.query(tokenId: tokenA) + #expect(aEntries.count == 2) + #expect(aEntries.allSatisfy { $0.tokenId == tokenA }) + + let bEntries = await storage.query(tokenId: tokenB) + #expect(bEntries.count == 1) + } + + @Test("Query filters by since date") + func filterBySince() async { + let storage = makeStorage() + let now = Date() + let oneHourAgo = now.addingTimeInterval(-3_600) + let threeHoursAgo = now.addingTimeInterval(-3 * 3_600) + + await storage.addEntry(makeEntry(timestamp: threeHoursAgo, action: "old")) + await storage.addEntry(makeEntry(timestamp: oneHourAgo, action: "recent")) + await storage.addEntry(makeEntry(timestamp: now, action: "now")) + + let twoHoursAgo = now.addingTimeInterval(-2 * 3_600) + let recent = await storage.query(since: twoHoursAgo) + #expect(recent.count == 2) + #expect(recent.allSatisfy { $0.timestamp >= twoHoursAgo }) + } + + @Test("Results sorted newest first") + func sortedNewestFirst() async { + let storage = makeStorage() + let now = Date() + await storage.addEntry(makeEntry(timestamp: now.addingTimeInterval(-300), action: "older")) + await storage.addEntry(makeEntry(timestamp: now, action: "newer")) + + let entries = await storage.query() + #expect(entries.count == 2) + #expect(entries[0].action == "newer") + #expect(entries[1].action == "older") + } + + @Test("Limit clamps result size") + func limitClampsResultSize() async { + let storage = makeStorage() + for index in 0..<10 { + await storage.addEntry(makeEntry(action: "tool.\(index)")) + } + + let limited = await storage.query(limit: 3) + #expect(limited.count == 3) + } + + @Test("Prune removes entries older than the cutoff") + func pruneRemovesOldEntries() async { + let storage = makeStorage() + let now = Date() + await storage.addEntry(makeEntry(timestamp: now.addingTimeInterval(-100 * 86_400), action: "ancient")) + await storage.addEntry(makeEntry(timestamp: now, action: "fresh")) + + let removed = await storage.prune(olderThan: 90) + #expect(removed == 1) + + let remaining = await storage.query() + #expect(remaining.count == 1) + #expect(remaining.first?.action == "fresh") + } + + @Test("Prune with negative or zero days is a no-op") + func pruneNoOpForZeroDays() async { + let storage = makeStorage() + await storage.addEntry(makeEntry(action: "fresh")) + + let removed = await storage.prune(olderThan: 0) + #expect(removed == 0) + + let entries = await storage.query() + #expect(entries.count == 1) + } + + @Test("Concurrent writes preserve all entries") + func concurrentWrites() async { + let storage = makeStorage() + + await withTaskGroup(of: Void.self) { group in + for index in 0..<50 { + group.addTask { + await storage.addEntry( + AuditEntry( + timestamp: Date(), + category: .tool, + action: "tool.\(index)", + outcome: AuditOutcome.success.rawValue + ) + ) + } + } + } + + let count = await storage.count() + #expect(count == 50) + } + + @Test("Outcome convenience initializer stores raw value") + func outcomeInitializerStoresRawValue() async { + let storage = makeStorage() + await storage.addEntry(makeEntry(outcome: .denied)) + + let entries = await storage.query() + #expect(entries.first?.outcome == AuditOutcome.denied.rawValue) + } + + @Test("Insert with same id replaces previous entry") + func insertOrReplacePreservesUniqueness() async { + let storage = makeStorage() + let id = UUID() + let first = AuditEntry( + id: id, + timestamp: Date(), + category: .tool, + action: "first", + outcome: AuditOutcome.success + ) + let second = AuditEntry( + id: id, + timestamp: Date(), + category: .tool, + action: "second", + outcome: AuditOutcome.success + ) + await storage.addEntry(first) + await storage.addEntry(second) + + let entries = await storage.query() + #expect(entries.count == 1) + #expect(entries.first?.action == "second") + } +} diff --git a/TableProTests/Core/MCP/MCPAuthGuardTests.swift b/TableProTests/Core/MCP/MCPAuthGuardTests.swift new file mode 100644 index 000000000..be9540422 --- /dev/null +++ b/TableProTests/Core/MCP/MCPAuthGuardTests.swift @@ -0,0 +1,128 @@ +// +// MCPAuthGuardTests.swift +// TableProTests +// + +import Foundation +import Testing + +@testable import TablePro + +@Suite("MCP Auth Guard external access", .serialized) +@MainActor +struct MCPAuthGuardTests { + private let storage = ConnectionStorage.shared + + private func withConnection( + externalAccess: ExternalAccessLevel, + aiPolicy: AIConnectionPolicy = .alwaysAllow, + body: (UUID) async throws -> Void + ) async throws { + let original = storage.loadConnections() + defer { storage.saveConnections(original) } + + let connection = DatabaseConnection( + name: "MCP Test", + type: .mysql, + aiPolicy: aiPolicy, + externalAccess: externalAccess + ) + storage.saveConnections([connection]) + try await body(connection.id) + } + + @Test("Read query passes when externalAccess is readOnly") + func readQueryReadOnly() async throws { + try await withConnection(externalAccess: .readOnly) { connectionId in + let guardian = MCPAuthGuard() + try await guardian.checkExternalWritePermission( + connectionId: connectionId, + sql: "SELECT * FROM users", + databaseType: .mysql + ) + } + } + + @Test("Write query is blocked when externalAccess is readOnly") + func writeQueryBlockedReadOnly() async throws { + try await withConnection(externalAccess: .readOnly) { connectionId in + let guardian = MCPAuthGuard() + do { + try await guardian.checkExternalWritePermission( + connectionId: connectionId, + sql: "UPDATE users SET name='x' WHERE id=1", + databaseType: .mysql + ) + Issue.record("Expected MCPError.forbidden for write on read-only connection") + } catch let error as MCPError { + if case .forbidden = error { + return + } + Issue.record("Expected forbidden, got \(error)") + } + } + } + + @Test("Write query passes when externalAccess is readWrite") + func writeQueryAllowedReadWrite() async throws { + try await withConnection(externalAccess: .readWrite) { connectionId in + let guardian = MCPAuthGuard() + try await guardian.checkExternalWritePermission( + connectionId: connectionId, + sql: "INSERT INTO users (id) VALUES (1)", + databaseType: .mysql + ) + } + } + + @Test("Connection access blocked when externalAccess is blocked") + func connectionAccessBlocked() async throws { + try await withConnection(externalAccess: .blocked) { connectionId in + let guardian = MCPAuthGuard() + do { + try await guardian.checkConnectionAccess( + connectionId: connectionId, + sessionId: "session-1" + ) + Issue.record("Expected MCPError.forbidden for blocked connection") + } catch let error as MCPError { + if case .forbidden = error { + return + } + Issue.record("Expected forbidden, got \(error)") + } + } + } + + @Test("Connection access allowed when externalAccess is readOnly") + func connectionAccessAllowedReadOnly() async throws { + try await withConnection(externalAccess: .readOnly) { connectionId in + let guardian = MCPAuthGuard() + try await guardian.checkConnectionAccess( + connectionId: connectionId, + sessionId: "session-1" + ) + } + } + + @Test("Missing connection rejects external write check") + func missingConnectionRejectsExternalWrite() async { + let guardian = MCPAuthGuard() + let unknownId = UUID() + do { + try await guardian.checkExternalWritePermission( + connectionId: unknownId, + sql: "UPDATE foo SET bar=1", + databaseType: .mysql + ) + Issue.record("Expected MCPError.forbidden for missing connection") + } catch let error as MCPError { + if case .forbidden = error { + return + } + Issue.record("Expected forbidden, got \(error)") + } catch { + Issue.record("Unexpected error type: \(error)") + } + } +} diff --git a/TableProTests/Core/MCP/MCPPairingServiceTests.swift b/TableProTests/Core/MCP/MCPPairingServiceTests.swift new file mode 100644 index 000000000..725508f9e --- /dev/null +++ b/TableProTests/Core/MCP/MCPPairingServiceTests.swift @@ -0,0 +1,210 @@ +import CryptoKit +import Foundation +import Testing + +@testable import TablePro + +@Suite("MCP Pairing Exchange Store") +struct MCPPairingServiceTests { + private func base64UrlSha256(of value: String) -> String { + PairingExchangeStore.sha256Base64Url(of: value) + } + + private func makeStore() -> PairingExchangeStore { + PairingExchangeStore() + } + + private func record(plaintext: String, challenge: String, expiresIn: TimeInterval) -> PairingExchangeRecord { + PairingExchangeRecord( + plaintextToken: plaintext, + challenge: challenge, + expiresAt: Date.now.addingTimeInterval(expiresIn) + ) + } + + @Test("consume returns stored token when challenge and verifier match") + func consumeReturnsTokenForValidVerifier() throws { + let verifier = "test-verifier-1" + let challenge = base64UrlSha256(of: verifier) + let store = makeStore() + try store.insert(code: "code-1", record: record(plaintext: "tp_secret", challenge: challenge, expiresIn: 60)) + + let token = try store.consume(code: "code-1", verifier: verifier) + + #expect(token == "tp_secret") + } + + @Test("consume removes the entry after success (single-use)") + func consumeIsSingleUse() throws { + let verifier = "test-verifier-2" + let challenge = base64UrlSha256(of: verifier) + let store = makeStore() + try store.insert(code: "code-2", record: record(plaintext: "tp_secret", challenge: challenge, expiresIn: 60)) + + _ = try store.consume(code: "code-2", verifier: verifier) + + #expect(store.contains(code: "code-2") == false) + } + + @Test("second consume of the same code returns notFound") + func duplicateConsumeReturnsNotFound() throws { + let verifier = "test-verifier-3" + let challenge = base64UrlSha256(of: verifier) + let store = makeStore() + try store.insert(code: "code-3", record: record(plaintext: "tp_secret", challenge: challenge, expiresIn: 60)) + + _ = try store.consume(code: "code-3", verifier: verifier) + + #expect(throws: MCPError.self) { + try store.consume(code: "code-3", verifier: verifier) + } + } + + @Test("consume returns notFound for unknown code") + func consumeUnknownCodeReturnsNotFound() { + let store = makeStore() + + do { + _ = try store.consume(code: "missing", verifier: "any") + Issue.record("Expected notFound error") + } catch let error as MCPError { + guard case .notFound = error else { + Issue.record("Expected notFound, got \(error)") + return + } + } catch { + Issue.record("Unexpected error: \(error)") + } + } + + @Test("consume returns expired when entry has expired") + func consumeExpiredEntryReturnsExpired() throws { + let verifier = "test-verifier-4" + let challenge = base64UrlSha256(of: verifier) + let store = makeStore() + try store.insert(code: "code-4", record: record(plaintext: "tp_secret", challenge: challenge, expiresIn: -1)) + + do { + _ = try store.consume(code: "code-4", verifier: verifier, now: Date.now) + Issue.record("Expected expired error") + } catch let error as MCPError { + guard case .expired = error else { + Issue.record("Expected expired, got \(error)") + return + } + } catch { + Issue.record("Unexpected error: \(error)") + } + } + + @Test("consume returns forbidden when challenge does not match the verifier") + func consumeMismatchedChallengeReturnsForbidden() throws { + let store = makeStore() + let challenge = base64UrlSha256(of: "intended-verifier") + try store.insert(code: "code-5", record: record(plaintext: "tp_secret", challenge: challenge, expiresIn: 60)) + + do { + _ = try store.consume(code: "code-5", verifier: "attacker-verifier") + Issue.record("Expected forbidden error") + } catch let error as MCPError { + guard case .forbidden = error else { + Issue.record("Expected forbidden, got \(error)") + return + } + } catch { + Issue.record("Unexpected error: \(error)") + } + } + + @Test("consume on expired code removes the entry") + func consumeOnExpiredCodeRemovesEntry() throws { + let verifier = "test-verifier-6" + let challenge = base64UrlSha256(of: verifier) + let store = makeStore() + try store.insert(code: "code-6", record: record(plaintext: "tp_secret", challenge: challenge, expiresIn: -1)) + + _ = try? store.consume(code: "code-6", verifier: verifier) + + #expect(store.contains(code: "code-6") == false) + } + + @Test("pruneExpired removes only expired entries") + func pruneRemovesOnlyExpiredEntries() throws { + let store = makeStore() + try store.insert( + code: "alive", + record: record(plaintext: "tp_a", challenge: "challenge", expiresIn: 60) + ) + try store.insert( + code: "stale-1", + record: record(plaintext: "tp_b", challenge: "challenge", expiresIn: -1) + ) + try store.insert( + code: "stale-2", + record: record(plaintext: "tp_c", challenge: "challenge", expiresIn: -10) + ) + + store.pruneExpired() + + #expect(store.count() == 1) + #expect(store.contains(code: "alive")) + #expect(store.contains(code: "stale-1") == false) + #expect(store.contains(code: "stale-2") == false) + } + + @Test("sha256Base64Url matches CryptoKit output without padding") + func sha256Base64UrlMatchesCryptoKit() { + let value = "verifier-string" + let digest = SHA256.hash(data: Data(value.utf8)) + let expected = Data(digest).base64EncodedString() + .replacingOccurrences(of: "+", with: "-") + .replacingOccurrences(of: "/", with: "_") + .replacingOccurrences(of: "=", with: "") + + #expect(PairingExchangeStore.sha256Base64Url(of: value) == expected) + } + + @Test("constantTimeEqual returns true for identical strings") + func constantTimeEqualIdentical() { + #expect(PairingExchangeStore.constantTimeEqual("abc", "abc")) + } + + @Test("constantTimeEqual returns false for different strings") + func constantTimeEqualDifferent() { + #expect(PairingExchangeStore.constantTimeEqual("abc", "abd") == false) + } + + @Test("constantTimeEqual returns false for different lengths") + func constantTimeEqualLengthMismatch() { + #expect(PairingExchangeStore.constantTimeEqual("abc", "abcd") == false) + } + + @Test("insert throws after maxPendingCodes consecutive inserts") + func insertThrowsWhenPendingCapReached() throws { + let store = makeStore() + for index in 0.. MCPRateLimiter { MCPRateLimiter() } @@ -31,8 +29,6 @@ struct MCPRateLimiterTests { return retryAfter } - // MARK: - Basic Behavior - @Test("First request is allowed") func firstRequestAllowed() async { let limiter = makeLimiter() @@ -63,8 +59,6 @@ struct MCPRateLimiterTests { expectAllowed(result) } - // MARK: - Escalating Lockout - @Test("Second failure triggers 1s lockout") func secondFailureLockout() async { let limiter = makeLimiter() @@ -121,8 +115,6 @@ struct MCPRateLimiterTests { #expect(remainingRetry <= initialRetry) } - // MARK: - Lockout Check - @Test("isLockedOut returns rateLimited during lockout") func isLockedOutDuringLockout() async { let limiter = makeLimiter() @@ -140,8 +132,6 @@ struct MCPRateLimiterTests { expectAllowed(result) } - // MARK: - Per-IP Isolation - @Test("Different IPs have independent counters") func independentCounters() async { let limiter = makeLimiter() @@ -168,8 +158,6 @@ struct MCPRateLimiterTests { expectAllowed(resultB, message: "IP-B should not be affected by IP-A lockout") } - // MARK: - Success Resets - @Test("Success after failure resets counter") func successResetsCounter() async { let limiter = makeLimiter() @@ -183,8 +171,6 @@ struct MCPRateLimiterTests { expectRateLimited(secondFail, message: "Second failure after reset should lock out again") } - // MARK: - Edge Cases - @Test("Empty IP string works") func emptyIpString() async { let limiter = makeLimiter() diff --git a/TableProTests/Core/MCP/MCPRouterTests.swift b/TableProTests/Core/MCP/MCPRouterTests.swift new file mode 100644 index 000000000..f2c392eae --- /dev/null +++ b/TableProTests/Core/MCP/MCPRouterTests.swift @@ -0,0 +1,167 @@ +// +// MCPRouterTests.swift +// TableProTests +// + +import Foundation +@testable import TablePro +import Testing + +@Suite("MCP Router") +struct MCPRouterTests { + private final class StubHandler: MCPRouteHandler, @unchecked Sendable { + let methods: [HTTPRequest.Method] + let path: String + private let result: MCPRouter.RouteResult + private(set) var invocationCount: Int = 0 + private(set) var lastRequest: HTTPRequest? + + init(methods: [HTTPRequest.Method], path: String, result: MCPRouter.RouteResult = .accepted) { + self.methods = methods + self.path = path + self.result = result + } + + func handle(_ request: HTTPRequest) async -> MCPRouter.RouteResult { + invocationCount += 1 + lastRequest = request + return result + } + } + + private func makeRequest( + method: HTTPRequest.Method, + path: String, + body: Data? = nil + ) -> HTTPRequest { + HTTPRequest(method: method, path: path, headers: [:], body: body, remoteIP: nil) + } + + @Test("OPTIONS preflight returns noContent regardless of path") + func optionsPreflightAlwaysNoContent() async { + let mcpHandler = StubHandler(methods: [.post], path: "/mcp", result: .accepted) + let router = MCPRouter(routes: [mcpHandler]) + + let optionsAtMcp = makeRequest(method: .options, path: "/mcp") + let result1 = await router.handle(optionsAtMcp) + guard case .noContent = result1 else { + Issue.record("Expected .noContent for OPTIONS /mcp, got \(result1)") + return + } + + let optionsAtUnknown = makeRequest(method: .options, path: "/unknown/path") + let result2 = await router.handle(optionsAtUnknown) + guard case .noContent = result2 else { + Issue.record("Expected .noContent for OPTIONS /unknown, got \(result2)") + return + } + + #expect(mcpHandler.invocationCount == 0) + } + + @Test("POST /mcp dispatches to MCP protocol handler") + func postMcpDispatchesToProtocolHandler() async { + let mcpHandler = StubHandler(methods: [.get, .post, .delete], path: "/mcp", result: .accepted) + let exchangeHandler = StubHandler(methods: [.post], path: "/v1/integrations/exchange", result: .accepted) + let router = MCPRouter(routes: [mcpHandler, exchangeHandler]) + + let request = makeRequest(method: .post, path: "/mcp") + _ = await router.handle(request) + + #expect(mcpHandler.invocationCount == 1) + #expect(exchangeHandler.invocationCount == 0) + } + + @Test("POST /v1/integrations/exchange dispatches to exchange handler") + func postExchangeDispatchesToExchangeHandler() async { + let mcpHandler = StubHandler(methods: [.get, .post, .delete], path: "/mcp", result: .accepted) + let exchangeHandler = StubHandler(methods: [.post], path: "/v1/integrations/exchange", result: .accepted) + let router = MCPRouter(routes: [mcpHandler, exchangeHandler]) + + let request = makeRequest(method: .post, path: "/v1/integrations/exchange") + _ = await router.handle(request) + + #expect(exchangeHandler.invocationCount == 1) + #expect(mcpHandler.invocationCount == 0) + } + + @Test("Path with query string still matches canonical route") + func queryStringMatchesCanonicalPath() async { + let mcpHandler = StubHandler(methods: [.post], path: "/mcp", result: .accepted) + let router = MCPRouter(routes: [mcpHandler]) + + let request = makeRequest(method: .post, path: "/mcp?session=abc") + _ = await router.handle(request) + + #expect(mcpHandler.invocationCount == 1) + } + + @Test("Unknown path returns 404 httpError") + func unknownPathReturnsNotFound() async { + let mcpHandler = StubHandler(methods: [.post], path: "/mcp", result: .accepted) + let router = MCPRouter(routes: [mcpHandler]) + + let request = makeRequest(method: .post, path: "/totally/unknown") + let result = await router.handle(request) + + guard case .httpError(let status, _) = result else { + Issue.record("Expected .httpError, got \(result)") + return + } + #expect(status == 404) + #expect(mcpHandler.invocationCount == 0) + } + + @Test("Method mismatch on registered path returns 404") + func methodMismatchReturnsNotFound() async { + let exchangeHandler = StubHandler(methods: [.post], path: "/v1/integrations/exchange", result: .accepted) + let router = MCPRouter(routes: [exchangeHandler]) + + let request = makeRequest(method: .get, path: "/v1/integrations/exchange") + let result = await router.handle(request) + + guard case .httpError(let status, _) = result else { + Issue.record("Expected .httpError, got \(result)") + return + } + #expect(status == 404) + #expect(exchangeHandler.invocationCount == 0) + } + + @Test(".well-known requests return 404 immediately") + func wellKnownReturnsNotFound() async { + let mcpHandler = StubHandler(methods: [.get], path: "/.well-known/oauth", result: .accepted) + let router = MCPRouter(routes: [mcpHandler]) + + let request = makeRequest(method: .get, path: "/.well-known/oauth") + let result = await router.handle(request) + + guard case .httpError(let status, _) = result else { + Issue.record("Expected .httpError, got \(result)") + return + } + #expect(status == 404) + #expect(mcpHandler.invocationCount == 0) + } + + @Test("Handler receives the original request") + func handlerReceivesOriginalRequest() async { + let mcpHandler = StubHandler(methods: [.post], path: "/mcp", result: .accepted) + let router = MCPRouter(routes: [mcpHandler]) + + let body = Data("{\"hello\":\"world\"}".utf8) + let request = HTTPRequest( + method: .post, + path: "/mcp", + headers: ["content-type": "application/json"], + body: body, + remoteIP: "10.0.0.1" + ) + _ = await router.handle(request) + + #expect(mcpHandler.lastRequest?.path == "/mcp") + #expect(mcpHandler.lastRequest?.method == .post) + #expect(mcpHandler.lastRequest?.body == body) + #expect(mcpHandler.lastRequest?.remoteIP == "10.0.0.1") + } +} diff --git a/TableProTests/Core/MCP/MCPTokenStoreTests.swift b/TableProTests/Core/MCP/MCPTokenStoreTests.swift index 0467ffc5f..757a9791c 100644 --- a/TableProTests/Core/MCP/MCPTokenStoreTests.swift +++ b/TableProTests/Core/MCP/MCPTokenStoreTests.swift @@ -5,8 +5,6 @@ import Testing @Suite("MCP Token Store") struct MCPTokenStoreTests { - // MARK: - Helpers - private func makeStore() -> MCPTokenStore { MCPTokenStore() } @@ -30,8 +28,6 @@ struct MCPTokenStoreTests { ) } - // MARK: - TokenPermissions - @Test("readOnly satisfies readOnly") func readOnlySatisfiesReadOnly() { #expect(TokenPermissions.readOnly.satisfies(.readOnly) == true) @@ -84,8 +80,6 @@ struct MCPTokenStoreTests { #expect(TokenPermissions.fullAccess.id == "fullAccess") } - // MARK: - MCPAuthToken - @Test("isExpired returns false when expiresAt is nil") func isExpiredNilExpiresAt() { let token = makeToken(expiresAt: nil) @@ -122,8 +116,6 @@ struct MCPTokenStoreTests { #expect(token.isEffectivelyActive == false) } - // MARK: - MCPTokenStore - @Test("generate creates token with tp_ prefix") func generateCreatesTokenWithPrefix() async { let store = makeStore() diff --git a/TableProTests/Core/MCP/MCPToolHandlerExportTests.swift b/TableProTests/Core/MCP/MCPToolHandlerExportTests.swift new file mode 100644 index 000000000..b1be3ae00 --- /dev/null +++ b/TableProTests/Core/MCP/MCPToolHandlerExportTests.swift @@ -0,0 +1,183 @@ +// +// MCPToolHandlerExportTests.swift +// TableProTests +// + +import Foundation +import Testing + +@testable import TablePro + +@Suite("MCP Tool Handler — export_data validation", .serialized) +@MainActor +struct MCPToolHandlerExportTests { + private let storage = ConnectionStorage.shared + + private func makeHandler() -> MCPToolHandler { + MCPToolHandler(bridge: MCPConnectionBridge(), authGuard: MCPAuthGuard()) + } + + private func withConnections( + _ connections: [DatabaseConnection], + body: () async throws -> Void + ) async throws { + let original = storage.loadConnections() + defer { storage.saveConnections(original) } + storage.saveConnections(connections) + try await body() + } + + @Test("export_data rejects table name with SQL injection payload") + func exportDataRejectsInjectionInTableName() async throws { + let handler = makeHandler() + let connection = DatabaseConnection( + name: "Target", + type: .mysql, + aiPolicy: .alwaysAllow, + externalAccess: .readWrite + ) + try await withConnections([connection]) { + do { + _ = try await handler.handleToolCall( + name: "export_data", + arguments: .object([ + "connection_id": .string(connection.id.uuidString), + "format": .string("csv"), + "tables": .array([.string("users; DROP TABLE users;--")]) + ]), + sessionId: "test-session", + token: nil + ) + Issue.record("Expected MCPError.invalidParams for malicious table name") + } catch let error as MCPError { + if case .invalidParams = error { return } + Issue.record("Expected invalidParams, got \(error)") + } catch { + Issue.record("Expected MCPError, got \(error)") + } + } + } + + @Test("export_data rejects table name with quote payload") + func exportDataRejectsQuotePayload() async throws { + let handler = makeHandler() + let connection = DatabaseConnection( + name: "Target", + type: .mysql, + aiPolicy: .alwaysAllow, + externalAccess: .readWrite + ) + try await withConnections([connection]) { + do { + _ = try await handler.handleToolCall( + name: "export_data", + arguments: .object([ + "connection_id": .string(connection.id.uuidString), + "format": .string("csv"), + "tables": .array([.string("users`; DROP TABLE x;--")]) + ]), + sessionId: "test-session", + token: nil + ) + Issue.record("Expected MCPError.invalidParams for backtick injection") + } catch let error as MCPError { + if case .invalidParams = error { return } + Issue.record("Expected invalidParams, got \(error)") + } catch { + Issue.record("Expected MCPError, got \(error)") + } + } + } + + @Test("validateExportTableName accepts simple identifiers") + func validateExportTableNameAcceptsSimple() throws { + try MCPToolHandler.validateExportTableName("users") + try MCPToolHandler.validateExportTableName("users_v2") + try MCPToolHandler.validateExportTableName("public.users") + try MCPToolHandler.validateExportTableName("schema.table_name_42") + } + + @Test("validateExportTableName rejects spaces") + func validateExportTableNameRejectsSpaces() { + do { + try MCPToolHandler.validateExportTableName("users x") + Issue.record("Expected throw for table name with space") + } catch let error as MCPError { + if case .invalidParams = error { return } + Issue.record("Expected invalidParams, got \(error)") + } catch { + Issue.record("Expected MCPError, got \(error)") + } + } + + @Test("validateExportTableName rejects semicolon") + func validateExportTableNameRejectsSemicolon() { + do { + try MCPToolHandler.validateExportTableName("users;DROP TABLE x") + Issue.record("Expected throw for table name with semicolon") + } catch let error as MCPError { + if case .invalidParams = error { return } + Issue.record("Expected invalidParams, got \(error)") + } catch { + Issue.record("Expected MCPError, got \(error)") + } + } + + @Test("validateExportTableName rejects empty string") + func validateExportTableNameRejectsEmpty() { + do { + try MCPToolHandler.validateExportTableName("") + Issue.record("Expected throw for empty table name") + } catch let error as MCPError { + if case .invalidParams = error { return } + Issue.record("Expected invalidParams, got \(error)") + } catch { + Issue.record("Expected MCPError, got \(error)") + } + } + + @Test("validateExportTableName rejects leading dot") + func validateExportTableNameRejectsLeadingDot() { + do { + try MCPToolHandler.validateExportTableName(".users") + Issue.record("Expected throw for table name with leading dot") + } catch let error as MCPError { + if case .invalidParams = error { return } + Issue.record("Expected invalidParams, got \(error)") + } catch { + Issue.record("Expected MCPError, got \(error)") + } + } + + @Test("export_data rejects output_path outside Downloads") + func exportDataRejectsPathOutsideDownloads() async throws { + let handler = makeHandler() + let connection = DatabaseConnection( + name: "Target", + type: .mysql, + aiPolicy: .alwaysAllow, + externalAccess: .readWrite + ) + try await withConnections([connection]) { + do { + _ = try await handler.handleToolCall( + name: "export_data", + arguments: .object([ + "connection_id": .string(connection.id.uuidString), + "format": .string("csv"), + "query": .string("SELECT 1"), + "output_path": .string("/tmp/escape.csv") + ]), + sessionId: "test-session", + token: nil + ) + Issue.record("Expected MCPError.invalidParams for path outside Downloads") + } catch let error as MCPError { + if case .invalidParams = error { return } + Issue.record("Expected invalidParams, got \(error)") + } catch { + Issue.record("Expected MCPError, got \(error)") + } + } + } +} diff --git a/TableProTests/Core/MCP/MCPToolHandlerIntegrationTests.swift b/TableProTests/Core/MCP/MCPToolHandlerIntegrationTests.swift new file mode 100644 index 000000000..73eb404be --- /dev/null +++ b/TableProTests/Core/MCP/MCPToolHandlerIntegrationTests.swift @@ -0,0 +1,701 @@ +// +// MCPToolHandlerIntegrationTests.swift +// TableProTests +// + +import Foundation +import Testing + +@testable import TablePro + +@Suite("MCP Tool Handler — integration tools", .serialized) +@MainActor +struct MCPToolHandlerIntegrationTests { + private let storage = ConnectionStorage.shared + + private func makeHandler() -> MCPToolHandler { + MCPToolHandler(bridge: MCPConnectionBridge(), authGuard: MCPAuthGuard()) + } + + private func makeToken( + permissions: TokenPermissions = .readWrite, + allowedConnectionIds: Set? = nil + ) -> MCPAuthToken { + MCPAuthToken( + id: UUID(), + name: "test-token", + prefix: "tp_test1", + tokenHash: "fakehash", + salt: "fakesalt", + permissions: permissions, + allowedConnectionIds: allowedConnectionIds, + createdAt: Date.now, + lastUsedAt: nil, + expiresAt: nil, + isActive: true + ) + } + + private func withConnections( + _ connections: [DatabaseConnection], + body: () async throws -> Void + ) async throws { + let original = storage.loadConnections() + defer { storage.saveConnections(original) } + storage.saveConnections(connections) + try await body() + } + + @Test("list_connections omits connections with externalAccess == .blocked") + func listConnectionsFiltersBlocked() async throws { + let handler = makeHandler() + let blocked = DatabaseConnection(name: "Blocked Prod", type: .mysql, externalAccess: .blocked) + let visible = DatabaseConnection(name: "Visible Staging", type: .mysql, externalAccess: .readOnly) + try await withConnections([blocked, visible]) { + let result = try await handler.handleToolCall( + name: "list_connections", + arguments: nil, + sessionId: "test-session", + token: nil + ) + #expect(result.isError == nil) + let payload = result.content.first?.text ?? "" + #expect(!payload.contains(blocked.id.uuidString)) + #expect(payload.contains(visible.id.uuidString)) + } + } + + @Test("list_recent_tabs returns tabs JSON object") + func listRecentTabsShape() async throws { + let handler = makeHandler() + let result = try await handler.handleToolCall( + name: "list_recent_tabs", + arguments: .object(["limit": .int(5)]), + sessionId: "test-session", + token: nil + ) + #expect(result.isError == nil) + #expect(result.content.first?.type == "text") + let payload = result.content.first?.text ?? "" + #expect(payload.contains("\"tabs\"")) + } + + @Test("blockedExternalConnectionIds returns ids of connections with externalAccess == .blocked") + func blockedExternalConnectionIdsHelper() async throws { + let blocked = DatabaseConnection(name: "Blocked", type: .mysql, externalAccess: .blocked) + let readOnly = DatabaseConnection(name: "ReadOnly", type: .mysql, aiPolicy: .alwaysAllow, externalAccess: .readOnly) + let readWrite = DatabaseConnection(name: "ReadWrite", type: .mysql, externalAccess: .readWrite) + try await withConnections([blocked, readOnly, readWrite]) { + let ids = MCPToolHandler.blockedExternalConnectionIds() + #expect(ids.contains(blocked.id)) + #expect(!ids.contains(readOnly.id)) + #expect(!ids.contains(readWrite.id)) + } + } + + @Test("list_recent_tabs requires read scope only") + func listRecentTabsScope() async throws { + let handler = makeHandler() + let token = makeToken(permissions: .readOnly) + let result = try await handler.handleToolCall( + name: "list_recent_tabs", + arguments: nil, + sessionId: "test-session", + token: token + ) + #expect(result.isError == nil) + } + + @Test("search_query_history rejects missing query parameter") + func searchQueryHistoryRequiresQuery() async { + let handler = makeHandler() + do { + _ = try await handler.handleToolCall( + name: "search_query_history", + arguments: nil, + sessionId: "test-session", + token: nil + ) + Issue.record("Expected MCPError.invalidParams when query is missing") + } catch let error as MCPError { + if case .invalidParams = error { + return + } + Issue.record("Expected invalidParams, got \(error)") + } catch { + Issue.record("Expected MCPError, got \(error)") + } + } + + @Test("search_query_history rejects invalid connection_id UUID") + func searchQueryHistoryRejectsInvalidUUID() async { + let handler = makeHandler() + do { + _ = try await handler.handleToolCall( + name: "search_query_history", + arguments: .object([ + "query": .string("SELECT"), + "connection_id": .string("not-a-uuid") + ]), + sessionId: "test-session", + token: nil + ) + Issue.record("Expected MCPError.invalidParams for malformed UUID") + } catch let error as MCPError { + if case .invalidParams = error { + return + } + Issue.record("Expected invalidParams, got \(error)") + } catch { + Issue.record("Expected MCPError, got \(error)") + } + } + + @Test("search_query_history with empty query returns entries object") + func searchQueryHistoryEmptyQuery() async throws { + let handler = makeHandler() + let result = try await handler.handleToolCall( + name: "search_query_history", + arguments: .object(["query": .string(""), "limit": .int(1)]), + sessionId: "test-session", + token: nil + ) + #expect(result.isError == nil) + let payload = result.content.first?.text ?? "" + #expect(payload.contains("\"entries\"")) + } + + @Test("search_query_history rejects since greater than until") + func searchQueryHistoryRejectsInvertedWindow() async { + let handler = makeHandler() + do { + _ = try await handler.handleToolCall( + name: "search_query_history", + arguments: .object([ + "query": .string(""), + "since": .double(2_000), + "until": .double(1_000) + ]), + sessionId: "test-session", + token: nil + ) + Issue.record("Expected MCPError.invalidParams when since > until") + } catch let error as MCPError { + if case .invalidParams = error { return } + Issue.record("Expected invalidParams, got \(error)") + } catch { + Issue.record("Expected MCPError, got \(error)") + } + } + + @Test("search_query_history rejects connection_id whose externalAccess is .blocked") + func searchQueryHistoryRejectsBlockedConnection() async throws { + let handler = makeHandler() + let blocked = DatabaseConnection(name: "Blocked Prod", type: .mysql, externalAccess: .blocked) + try await withConnections([blocked]) { + do { + _ = try await handler.handleToolCall( + name: "search_query_history", + arguments: .object([ + "query": .string(""), + "connection_id": .string(blocked.id.uuidString) + ]), + sessionId: "test-session", + token: nil + ) + Issue.record("Expected MCPError.forbidden for blocked connection") + } catch let error as MCPError { + if case .forbidden = error { return } + Issue.record("Expected forbidden, got \(error)") + } catch { + Issue.record("Expected MCPError, got \(error)") + } + } + } + + @Test("search_query_history filters out blocked connections when iterating without connection_id") + func searchQueryHistoryFiltersBlockedFromUnscopedQuery() async throws { + let handler = makeHandler() + let blocked = DatabaseConnection(name: "Blocked", type: .mysql, externalAccess: .blocked) + let visible = DatabaseConnection(name: "Visible", type: .mysql, externalAccess: .readOnly) + let marker = UUID().uuidString + + try await withConnections([blocked, visible]) { + let blockedEntry = QueryHistoryEntry( + query: "SELECT blocked_\(marker)", + connectionId: blocked.id, + databaseName: "db", + executionTime: 0.01, + rowCount: 1, + wasSuccessful: true + ) + let visibleEntry = QueryHistoryEntry( + query: "SELECT visible_\(marker)", + connectionId: visible.id, + databaseName: "db", + executionTime: 0.01, + rowCount: 1, + wasSuccessful: true + ) + _ = await QueryHistoryStorage.shared.addHistory(blockedEntry) + _ = await QueryHistoryStorage.shared.addHistory(visibleEntry) + + let result = try await handler.handleToolCall( + name: "search_query_history", + arguments: .object(["query": .string(marker)]), + sessionId: "test-session", + token: nil + ) + #expect(result.isError == nil) + let payload = result.content.first?.text ?? "" + #expect(payload.contains("visible_\(marker)")) + #expect(!payload.contains("blocked_\(marker)")) + } + } + + @Test("search_query_history pushes token allowlist into SQL so older allowed entries surface") + func searchQueryHistoryAllowlistOverFlood() async throws { + let handler = makeHandler() + let allowedConn = DatabaseConnection(name: "Allowed", type: .mysql) + let otherConn = DatabaseConnection(name: "Other", type: .mysql) + let marker = UUID().uuidString + let now = Date() + + try await withConnections([allowedConn, otherConn]) { + let oldAllowed = QueryHistoryEntry( + query: "SELECT old_allowed_\(marker)", + connectionId: allowedConn.id, + databaseName: "db", + executedAt: now.addingTimeInterval(-3_600), + executionTime: 0.01, + rowCount: 1, + wasSuccessful: true + ) + _ = await QueryHistoryStorage.shared.addHistory(oldAllowed) + + for index in 0..<20 { + let recentOther = QueryHistoryEntry( + query: "SELECT recent_other_\(marker)_\(index)", + connectionId: otherConn.id, + databaseName: "db", + executedAt: now.addingTimeInterval(Double(index)), + executionTime: 0.01, + rowCount: 1, + wasSuccessful: true + ) + _ = await QueryHistoryStorage.shared.addHistory(recentOther) + } + + let token = makeToken(allowedConnectionIds: [allowedConn.id]) + let result = try await handler.handleToolCall( + name: "search_query_history", + arguments: .object(["query": .string(marker), "limit": .int(5)]), + sessionId: "test-session", + token: token + ) + #expect(result.isError == nil) + let payload = result.content.first?.text ?? "" + #expect(payload.contains("old_allowed_\(marker)")) + #expect(!payload.contains("recent_other_\(marker)")) + } + } + + @Test("QueryHistoryStorage.fetchHistory restricts results to allowedConnectionIds") + func fetchHistoryAllowlistFilters() async throws { + let allowedId = UUID() + let otherId = UUID() + let marker = UUID().uuidString + + let allowedEntry = QueryHistoryEntry( + query: "SELECT allowed_\(marker)", + connectionId: allowedId, + databaseName: "db", + executionTime: 0.01, + rowCount: 1, + wasSuccessful: true + ) + let otherEntry = QueryHistoryEntry( + query: "SELECT other_\(marker)", + connectionId: otherId, + databaseName: "db", + executionTime: 0.01, + rowCount: 1, + wasSuccessful: true + ) + _ = await QueryHistoryStorage.shared.addHistory(allowedEntry) + _ = await QueryHistoryStorage.shared.addHistory(otherEntry) + + let entries = await QueryHistoryStorage.shared.fetchHistory( + limit: 100, + searchText: marker, + allowedConnectionIds: [allowedId] + ) + + #expect(entries.contains { $0.query.contains("allowed_\(marker)") }) + #expect(!entries.contains { $0.query.contains("other_\(marker)") }) + } + + @Test("QueryHistoryStorage.fetchHistory returns empty when allowedConnectionIds is empty") + func fetchHistoryEmptyAllowlistReturnsEmpty() async throws { + let connectionId = UUID() + let marker = UUID().uuidString + let entry = QueryHistoryEntry( + query: "SELECT empty_allowlist_\(marker)", + connectionId: connectionId, + databaseName: "db", + executionTime: 0.01, + rowCount: 1, + wasSuccessful: true + ) + _ = await QueryHistoryStorage.shared.addHistory(entry) + + let entries = await QueryHistoryStorage.shared.fetchHistory( + limit: 100, + searchText: marker, + allowedConnectionIds: [] + ) + + #expect(entries.isEmpty) + } + + @Test("search_query_history with since/until filters by executed_at window") + func searchQueryHistorySinceUntilFilters() async throws { + let handler = makeHandler() + let connId = UUID() + let now = Date() + let oneHourAgo = now.addingTimeInterval(-3_600) + let twoHoursAgo = now.addingTimeInterval(-7_200) + let marker = UUID().uuidString + + let outside = QueryHistoryEntry( + query: "SELECT outside_\(marker)", + connectionId: connId, + databaseName: "testdb", + executedAt: twoHoursAgo, + executionTime: 0.01, + rowCount: 1, + wasSuccessful: true + ) + let inside = QueryHistoryEntry( + query: "SELECT inside_\(marker)", + connectionId: connId, + databaseName: "testdb", + executedAt: oneHourAgo, + executionTime: 0.01, + rowCount: 1, + wasSuccessful: true + ) + _ = await QueryHistoryStorage.shared.addHistory(outside) + _ = await QueryHistoryStorage.shared.addHistory(inside) + + let result = try await handler.handleToolCall( + name: "search_query_history", + arguments: .object([ + "query": .string(marker), + "connection_id": .string(connId.uuidString), + "since": .double(now.addingTimeInterval(-5_400).timeIntervalSince1970), + "until": .double(now.timeIntervalSince1970) + ]), + sessionId: "test-session", + token: nil + ) + #expect(result.isError == nil) + let payload = result.content.first?.text ?? "" + #expect(payload.contains("inside_\(marker)")) + #expect(!payload.contains("outside_\(marker)")) + } + + @Test("switch_database against a readOnly connection returns forbidden") + func switchDatabaseDeniedByReadOnlyExternalAccess() async throws { + let handler = makeHandler() + let connection = DatabaseConnection(name: "ReadOnly", type: .mysql, aiPolicy: .alwaysAllow, externalAccess: .readOnly) + try await withConnections([connection]) { + do { + _ = try await handler.handleToolCall( + name: "switch_database", + arguments: .object([ + "connection_id": .string(connection.id.uuidString), + "database": .string("postgres") + ]), + sessionId: "test-session", + token: nil + ) + Issue.record("Expected MCPError.forbidden for readOnly externalAccess") + } catch let error as MCPError { + if case .forbidden = error { return } + Issue.record("Expected forbidden, got \(error)") + } catch { + Issue.record("Expected MCPError, got \(error)") + } + } + } + + @Test("switch_schema against a readOnly connection returns forbidden") + func switchSchemaDeniedByReadOnlyExternalAccess() async throws { + let handler = makeHandler() + let connection = DatabaseConnection(name: "ReadOnly", type: .postgresql, aiPolicy: .alwaysAllow, externalAccess: .readOnly) + try await withConnections([connection]) { + do { + _ = try await handler.handleToolCall( + name: "switch_schema", + arguments: .object([ + "connection_id": .string(connection.id.uuidString), + "schema": .string("public") + ]), + sessionId: "test-session", + token: nil + ) + Issue.record("Expected MCPError.forbidden for readOnly externalAccess") + } catch let error as MCPError { + if case .forbidden = error { return } + Issue.record("Expected forbidden, got \(error)") + } catch { + Issue.record("Expected MCPError, got \(error)") + } + } + } + + @Test("export_data against a readOnly connection returns forbidden") + func exportDataDeniedByReadOnlyExternalAccess() async throws { + let handler = makeHandler() + let connection = DatabaseConnection(name: "ReadOnly", type: .mysql, aiPolicy: .alwaysAllow, externalAccess: .readOnly) + try await withConnections([connection]) { + do { + _ = try await handler.handleToolCall( + name: "export_data", + arguments: .object([ + "connection_id": .string(connection.id.uuidString), + "format": .string("csv"), + "tables": .array([.string("users")]) + ]), + sessionId: "test-session", + token: nil + ) + Issue.record("Expected MCPError.forbidden for readOnly externalAccess") + } catch let error as MCPError { + if case .forbidden = error { return } + Issue.record("Expected forbidden, got \(error)") + } catch { + Issue.record("Expected MCPError, got \(error)") + } + } + } + + @Test("open_connection_window against a readOnly connection returns forbidden") + func openConnectionWindowDeniedByReadOnlyExternalAccess() async throws { + let handler = makeHandler() + let connection = DatabaseConnection(name: "ReadOnly", type: .mysql, aiPolicy: .alwaysAllow, externalAccess: .readOnly) + try await withConnections([connection]) { + do { + _ = try await handler.handleToolCall( + name: "open_connection_window", + arguments: .object(["connection_id": .string(connection.id.uuidString)]), + sessionId: "test-session", + token: nil + ) + Issue.record("Expected MCPError.forbidden for readOnly externalAccess") + } catch let error as MCPError { + if case .forbidden = error { return } + Issue.record("Expected forbidden, got \(error)") + } catch { + Issue.record("Expected MCPError, got \(error)") + } + } + } + + @Test("open_table_tab against a readOnly connection returns forbidden") + func openTableTabDeniedByReadOnlyExternalAccess() async throws { + let handler = makeHandler() + let connection = DatabaseConnection(name: "ReadOnly", type: .mysql, aiPolicy: .alwaysAllow, externalAccess: .readOnly) + try await withConnections([connection]) { + do { + _ = try await handler.handleToolCall( + name: "open_table_tab", + arguments: .object([ + "connection_id": .string(connection.id.uuidString), + "table_name": .string("users") + ]), + sessionId: "test-session", + token: nil + ) + Issue.record("Expected MCPError.forbidden for readOnly externalAccess") + } catch let error as MCPError { + if case .forbidden = error { return } + Issue.record("Expected forbidden, got \(error)") + } catch { + Issue.record("Expected MCPError, got \(error)") + } + } + } + + @Test("ExternalAccessLevel.satisfies follows blocked < readOnly < readWrite ordering") + func externalAccessLevelSatisfiesOrdering() { + #expect(ExternalAccessLevel.readWrite.satisfies(.readWrite)) + #expect(ExternalAccessLevel.readWrite.satisfies(.readOnly)) + #expect(ExternalAccessLevel.readOnly.satisfies(.readOnly)) + #expect(!ExternalAccessLevel.readOnly.satisfies(.readWrite)) + #expect(!ExternalAccessLevel.blocked.satisfies(.readOnly)) + #expect(!ExternalAccessLevel.blocked.satisfies(.readWrite)) + } + + @Test("open_connection_window rejects missing connection_id") + func openConnectionWindowRequiresConnectionId() async { + let handler = makeHandler() + do { + _ = try await handler.handleToolCall( + name: "open_connection_window", + arguments: nil, + sessionId: "test-session", + token: nil + ) + Issue.record("Expected MCPError.invalidParams") + } catch let error as MCPError { + if case .invalidParams = error { return } + Issue.record("Expected invalidParams, got \(error)") + } catch { + Issue.record("Expected MCPError, got \(error)") + } + } + + @Test("open_connection_window rejects unknown connection") + func openConnectionWindowRejectsUnknown() async throws { + let handler = makeHandler() + do { + _ = try await handler.handleToolCall( + name: "open_connection_window", + arguments: .object(["connection_id": .string(UUID().uuidString)]), + sessionId: "test-session", + token: nil + ) + Issue.record("Expected MCPError.notFound for unknown connection") + } catch let error as MCPError { + if case .notFound = error { return } + Issue.record("Expected notFound, got \(error)") + } catch { + Issue.record("Expected MCPError, got \(error)") + } + } + + @Test("open_connection_window denies read-only token") + func openConnectionWindowReadOnlyDenied() async throws { + let handler = makeHandler() + let token = makeToken(permissions: .readOnly) + do { + _ = try await handler.handleToolCall( + name: "open_connection_window", + arguments: .object(["connection_id": .string(UUID().uuidString)]), + sessionId: "test-session", + token: token + ) + Issue.record("Expected MCPError.forbidden for read-only token") + } catch let error as MCPError { + if case .forbidden = error { return } + Issue.record("Expected forbidden, got \(error)") + } catch { + Issue.record("Expected MCPError, got \(error)") + } + } + + @Test("open_connection_window respects token connection allowlist") + func openConnectionWindowAllowlist() async throws { + let handler = makeHandler() + let connection = DatabaseConnection(name: "Test", type: .mysql) + try await withConnections([connection]) { + let token = makeToken( + permissions: .readWrite, + allowedConnectionIds: [UUID()] + ) + do { + _ = try await handler.handleToolCall( + name: "open_connection_window", + arguments: .object(["connection_id": .string(connection.id.uuidString)]), + sessionId: "test-session", + token: token + ) + Issue.record("Expected MCPError.forbidden for disallowed connection") + } catch let error as MCPError { + if case .forbidden = error { return } + Issue.record("Expected forbidden, got \(error)") + } catch { + Issue.record("Expected MCPError, got \(error)") + } + } + } + + @Test("open_table_tab requires table_name") + func openTableTabRequiresTableName() async { + let handler = makeHandler() + do { + _ = try await handler.handleToolCall( + name: "open_table_tab", + arguments: .object(["connection_id": .string(UUID().uuidString)]), + sessionId: "test-session", + token: nil + ) + Issue.record("Expected MCPError.invalidParams") + } catch let error as MCPError { + if case .invalidParams = error { return } + Issue.record("Expected invalidParams, got \(error)") + } catch { + Issue.record("Expected MCPError, got \(error)") + } + } + + @Test("focus_query_tab returns notFound when tab is not open") + func focusQueryTabNotFound() async { + let handler = makeHandler() + do { + _ = try await handler.handleToolCall( + name: "focus_query_tab", + arguments: .object(["tab_id": .string(UUID().uuidString)]), + sessionId: "test-session", + token: nil + ) + Issue.record("Expected MCPError.notFound") + } catch let error as MCPError { + if case .notFound = error { return } + Issue.record("Expected notFound, got \(error)") + } catch { + Issue.record("Expected MCPError, got \(error)") + } + } + + @Test("focus_query_tab requires read-write token") + func focusQueryTabRequiresWriteScope() async { + let handler = makeHandler() + let token = makeToken(permissions: .readOnly) + do { + _ = try await handler.handleToolCall( + name: "focus_query_tab", + arguments: .object(["tab_id": .string(UUID().uuidString)]), + sessionId: "test-session", + token: token + ) + Issue.record("Expected MCPError.forbidden for read-only token") + } catch let error as MCPError { + if case .forbidden = error { return } + Issue.record("Expected forbidden, got \(error)") + } catch { + Issue.record("Expected MCPError, got \(error)") + } + } + + @Test("Unknown tool name throws methodNotFound") + func unknownToolThrows() async { + let handler = makeHandler() + do { + _ = try await handler.handleToolCall( + name: "totally_made_up_tool", + arguments: nil, + sessionId: "test-session", + token: nil + ) + Issue.record("Expected methodNotFound") + } catch let error as MCPError { + if case .methodNotFound = error { return } + Issue.record("Expected methodNotFound, got \(error)") + } catch { + Issue.record("Expected MCPError, got \(error)") + } + } +} diff --git a/TableProTests/Core/MCP/MCPToolHandlerSecurityTests.swift b/TableProTests/Core/MCP/MCPToolHandlerSecurityTests.swift new file mode 100644 index 000000000..36c595ec5 --- /dev/null +++ b/TableProTests/Core/MCP/MCPToolHandlerSecurityTests.swift @@ -0,0 +1,87 @@ +import Foundation +import Testing + +@testable import TablePro + +@Suite("MCP Tool Handler — identifier validation hardening") +struct MCPToolHandlerSecurityTests { + @Test("validateExportTableName rejects double-dot") + func rejectsDoubleDot() { + do { + try MCPToolHandler.validateExportTableName("schema..table") + Issue.record("Expected throw for double-dot table name") + } catch let error as MCPError { + if case .invalidParams = error { return } + Issue.record("Expected invalidParams, got \(error)") + } catch { + Issue.record("Expected MCPError, got \(error)") + } + } + + @Test("validateExportTableName rejects trailing dot") + func rejectsTrailingDot() { + do { + try MCPToolHandler.validateExportTableName("schema.") + Issue.record("Expected throw for trailing-dot table name") + } catch let error as MCPError { + if case .invalidParams = error { return } + Issue.record("Expected invalidParams, got \(error)") + } catch { + Issue.record("Expected MCPError, got \(error)") + } + } + + @Test("validateExportTableName rejects only dots") + func rejectsOnlyDots() { + do { + try MCPToolHandler.validateExportTableName("..") + Issue.record("Expected throw for dots-only table name") + } catch let error as MCPError { + if case .invalidParams = error { return } + Issue.record("Expected invalidParams, got \(error)") + } catch { + Issue.record("Expected MCPError, got \(error)") + } + } + + @Test("validateExportTableName accepts schema-qualified identifiers") + func acceptsValidQualified() throws { + try MCPToolHandler.validateExportTableName("public.users") + try MCPToolHandler.validateExportTableName("db.schema.table") + } + + @Test("quoteQualifiedIdentifier throws on empty component") + func quoteThrowsOnEmptyComponent() { + let quoter: (String) -> String = { "\"\($0)\"" } + do { + _ = try MCPToolHandler.quoteQualifiedIdentifier("schema..table", quoter: quoter) + Issue.record("Expected throw for empty component in qualified identifier") + } catch let error as MCPError { + if case .invalidParams = error { return } + Issue.record("Expected invalidParams, got \(error)") + } catch { + Issue.record("Expected MCPError, got \(error)") + } + } + + @Test("quoteQualifiedIdentifier throws on leading dot") + func quoteThrowsOnLeadingDot() { + let quoter: (String) -> String = { "\"\($0)\"" } + do { + _ = try MCPToolHandler.quoteQualifiedIdentifier(".table", quoter: quoter) + Issue.record("Expected throw for leading-dot identifier") + } catch let error as MCPError { + if case .invalidParams = error { return } + Issue.record("Expected invalidParams, got \(error)") + } catch { + Issue.record("Expected MCPError, got \(error)") + } + } + + @Test("quoteQualifiedIdentifier quotes each segment for valid identifiers") + func quoteQuotesValidSegments() throws { + let quoter: (String) -> String = { "\"\($0)\"" } + let result = try MCPToolHandler.quoteQualifiedIdentifier("public.users", quoter: quoter) + #expect(result == "\"public\".\"users\"") + } +} diff --git a/TableProTests/Core/Services/DeeplinkHandlerTests.swift b/TableProTests/Core/Services/DeeplinkHandlerTests.swift index 894e5ae5c..c336353d2 100644 --- a/TableProTests/Core/Services/DeeplinkHandlerTests.swift +++ b/TableProTests/Core/Services/DeeplinkHandlerTests.swift @@ -13,34 +13,48 @@ struct DeeplinkHandlerTests { // MARK: - Connect Actions - @Test("Connect action with simple name") - func testConnectSimpleName() { - let url = URL(string: "tablepro://connect/Production")! + private static let sampleId = UUID(uuidString: "11111111-2222-3333-4444-555555555555")! + + @Test("Connect action with UUID") + func testConnectByUUID() { + let url = URL(string: "tablepro://connect/\(Self.sampleId.uuidString)")! let action = DeeplinkHandler.parse(url) - if case .connect(let name) = action { - #expect(name == "Production") + if case .connect(let connectionId) = action { + #expect(connectionId == Self.sampleId) } else { Issue.record("Expected .connect, got \(String(describing: action))") } } - @Test("Connect action with percent-encoded name") - func testConnectPercentEncodedName() { - let url = URL(string: "tablepro://connect/My%20DB")! - let action = DeeplinkHandler.parse(url) - if case .connect(let name) = action { - #expect(name == "My DB") + @Test("Connect action with non-UUID first segment returns nil") + func testConnectNonUUIDReturnsNil() { + let url = URL(string: "tablepro://connect/Production")! + #expect(DeeplinkHandler.parse(url) == nil) + } + + @Test("Connect action with empty path returns nil") + func testConnectEmptyPathReturnsNil() { + let url = URL(string: "tablepro://connect/")! + #expect(DeeplinkHandler.parse(url) == nil) + } + + @Test("Connect action accepts lowercase UUID") + func testConnectLowercaseUUID() { + let id = UUID() + let url = URL(string: "tablepro://connect/\(id.uuidString.lowercased())")! + if case .connect(let parsed) = DeeplinkHandler.parse(url) { + #expect(parsed == id) } else { - Issue.record("Expected .connect, got \(String(describing: action))") + Issue.record("Expected .connect for lowercase UUID") } } @Test("Open table without database") func testOpenTableWithoutDatabase() { - let url = URL(string: "tablepro://connect/Prod/table/users")! + let url = URL(string: "tablepro://connect/\(Self.sampleId.uuidString)/table/users")! let action = DeeplinkHandler.parse(url) - if case .openTable(let connectionName, let tableName, let databaseName) = action { - #expect(connectionName == "Prod") + if case .openTable(let connectionId, let tableName, let databaseName) = action { + #expect(connectionId == Self.sampleId) #expect(tableName == "users") #expect(databaseName == nil) } else { @@ -50,10 +64,10 @@ struct DeeplinkHandlerTests { @Test("Open table with database") func testOpenTableWithDatabase() { - let url = URL(string: "tablepro://connect/Prod/database/analytics/table/events")! + let url = URL(string: "tablepro://connect/\(Self.sampleId.uuidString)/database/analytics/table/events")! let action = DeeplinkHandler.parse(url) - if case .openTable(let connectionName, let tableName, let databaseName) = action { - #expect(connectionName == "Prod") + if case .openTable(let connectionId, let tableName, let databaseName) = action { + #expect(connectionId == Self.sampleId) #expect(tableName == "events") #expect(databaseName == "analytics") } else { @@ -63,10 +77,10 @@ struct DeeplinkHandlerTests { @Test("Open query with decoded SQL") func testOpenQueryDecodedSQL() { - let url = URL(string: "tablepro://connect/Prod/query?sql=SELECT%20*%20FROM%20users")! + let url = URL(string: "tablepro://connect/\(Self.sampleId.uuidString)/query?sql=SELECT%20*%20FROM%20users")! let action = DeeplinkHandler.parse(url) - if case .openQuery(let connectionName, let sql) = action { - #expect(connectionName == "Prod") + if case .openQuery(let connectionId, let sql) = action { + #expect(connectionId == Self.sampleId) #expect(sql == "SELECT * FROM users") } else { Issue.record("Expected .openQuery, got \(String(describing: action))") @@ -75,14 +89,14 @@ struct DeeplinkHandlerTests { @Test("Open query with empty SQL returns nil") func testOpenQueryEmptySQLReturnsNil() { - let url = URL(string: "tablepro://connect/Prod/query?sql=")! + let url = URL(string: "tablepro://connect/\(Self.sampleId.uuidString)/query?sql=")! let action = DeeplinkHandler.parse(url) #expect(action == nil) } @Test("Unrecognized path returns nil") func testUnrecognizedPathReturnsNil() { - let url = URL(string: "tablepro://connect/Prod/unknown/path")! + let url = URL(string: "tablepro://connect/\(Self.sampleId.uuidString)/unknown/path")! let action = DeeplinkHandler.parse(url) #expect(action == nil) } @@ -101,6 +115,86 @@ struct DeeplinkHandlerTests { #expect(action == nil) } + @Test("Malformed UUID with extra characters returns nil") + func testMalformedUUIDReturnsNil() { + let url = URL(string: "tablepro://connect/not-a-real-uuid-1234")! + #expect(DeeplinkHandler.parse(url) == nil) + } + + // MARK: - Integrations Actions + + @Test("Pair action parses required params") + func testPairAction() { + let url = URL(string: "tablepro://integrations/pair?client=Raycast&challenge=abc123&redirect=raycast://callback&scopes=readOnly")! + if case .pairIntegration(let request) = DeeplinkHandler.parse(url) { + #expect(request.clientName == "Raycast") + #expect(request.challenge == "abc123") + #expect(request.redirectURL.absoluteString == "raycast://callback") + #expect(request.requestedScopes == "readOnly") + #expect(request.requestedConnectionIds == nil) + } else { + Issue.record("Expected .pairIntegration") + } + } + + @Test("Pair action parses connection-ids CSV") + func testPairActionConnectionIds() { + let id1 = UUID() + let id2 = UUID() + let csv = "\(id1.uuidString),\(id2.uuidString)" + let url = URL(string: "tablepro://integrations/pair?client=Raycast&challenge=abc&redirect=raycast://cb&connection-ids=\(csv)")! + if case .pairIntegration(let request) = DeeplinkHandler.parse(url) { + #expect(request.requestedConnectionIds == Set([id1, id2])) + } else { + Issue.record("Expected .pairIntegration with parsed UUIDs") + } + } + + @Test("Pair missing client returns nil") + func testPairMissingClientReturnsNil() { + let url = URL(string: "tablepro://integrations/pair?challenge=abc&redirect=raycast://cb")! + #expect(DeeplinkHandler.parse(url) == nil) + } + + @Test("Pair missing challenge returns nil") + func testPairMissingChallengeReturnsNil() { + let url = URL(string: "tablepro://integrations/pair?client=Raycast&redirect=raycast://cb")! + #expect(DeeplinkHandler.parse(url) == nil) + } + + @Test("Exchange action parses code and verifier") + func testExchangeAction() { + let url = URL(string: "tablepro://integrations/exchange?code=abc-123&verifier=xyz-456")! + if case .exchangePairing(let exchange) = DeeplinkHandler.parse(url) { + #expect(exchange.code == "abc-123") + #expect(exchange.verifier == "xyz-456") + } else { + Issue.record("Expected .exchangePairing") + } + } + + @Test("Exchange missing verifier returns nil") + func testExchangeMissingVerifierReturnsNil() { + let url = URL(string: "tablepro://integrations/exchange?code=abc")! + #expect(DeeplinkHandler.parse(url) == nil) + } + + @Test("Start MCP action parses without params") + func testStartMCPAction() { + let url = URL(string: "tablepro://integrations/start-mcp")! + if case .startMCP = DeeplinkHandler.parse(url) { + // matched + } else { + Issue.record("Expected .startMCP") + } + } + + @Test("Unknown integrations action returns nil") + func testUnknownIntegrationsAction() { + let url = URL(string: "tablepro://integrations/unknown")! + #expect(DeeplinkHandler.parse(url) == nil) + } + // MARK: - Import — Basic Fields @Test("Import with all basic params") diff --git a/TableProTests/Core/Storage/QueryHistoryStorageTests.swift b/TableProTests/Core/Storage/QueryHistoryStorageTests.swift index 4cf28817a..2e9e810f1 100644 --- a/TableProTests/Core/Storage/QueryHistoryStorageTests.swift +++ b/TableProTests/Core/Storage/QueryHistoryStorageTests.swift @@ -204,6 +204,44 @@ struct QueryHistoryStorageTests { #expect(remaining.isEmpty) } + @Test("fetchHistory with since/until window excludes entries outside the range") + func fetchHistorySinceUntilWindow() async { + let connId = UUID() + let now = Date() + let oneHourAgo = now.addingTimeInterval(-3_600) + let twoHoursAgo = now.addingTimeInterval(-7_200) + + let outside = QueryHistoryEntry( + query: "SELECT outside_window", + connectionId: connId, + databaseName: "testdb", + executedAt: twoHoursAgo, + executionTime: 0.01, + rowCount: 1, + wasSuccessful: true + ) + let inside = QueryHistoryEntry( + query: "SELECT inside_window", + connectionId: connId, + databaseName: "testdb", + executedAt: oneHourAgo, + executionTime: 0.01, + rowCount: 1, + wasSuccessful: true + ) + + _ = await storage.addHistory(outside) + _ = await storage.addHistory(inside) + + let windowed = await storage.fetchHistory( + connectionId: connId, + since: now.addingTimeInterval(-5_400), + until: now + ) + #expect(windowed.count == 1) + #expect(windowed.first?.query == "SELECT inside_window") + } + @Test("Combined connectionId + dateFilter works") func combinedConnectionIdAndDateFilter() async { let targetConn = UUID() diff --git a/TableProTests/Models/ConnectionSessionTests.swift b/TableProTests/Models/ConnectionSessionTests.swift index 9e3e50e67..0c843fef4 100644 --- a/TableProTests/Models/ConnectionSessionTests.swift +++ b/TableProTests/Models/ConnectionSessionTests.swift @@ -19,7 +19,6 @@ struct ConnectionSessionEquivalenceTests { id: UUID = UUID(), database: String = "testdb", type: DatabaseType = .mysql, - tables: [TableInfo] = [], status: ConnectionStatus = .connected ) -> ConnectionSession { let connection = DatabaseConnection( @@ -30,7 +29,6 @@ struct ConnectionSessionEquivalenceTests { ) var session = ConnectionSession(connection: connection) session.status = status - session.tables = tables return session } @@ -71,16 +69,15 @@ struct ConnectionSessionEquivalenceTests { #expect(!a.isContentViewEquivalent(to: b)) } - @Test("Returns false when tables change") - func falseWhenTablesChange() { + @Test("Tables are excluded from equivalence (owned by SchemaService)") + @MainActor + func tablesAreExcludedFromEquivalence() async { let id = UUID() - var a = makeSession(id: id) - var b = makeSession(id: id) - - a.tables = [TestFixtures.makeTableInfo(name: "users")] - b.tables = [TestFixtures.makeTableInfo(name: "orders")] + let a = makeSession(id: id) + let b = makeSession(id: id) - #expect(!a.isContentViewEquivalent(to: b)) + await SchemaService.shared.invalidate(connectionId: id) + #expect(a.isContentViewEquivalent(to: b)) } @Test("Returns false when status changes") @@ -162,14 +159,6 @@ struct ConnectionSessionStateTests { #expect(!session.isConnected) } - @Test("clearCachedData clears tables") - func clearCachedDataClearsTables() { - var session = makeSession() - session.tables = [TestFixtures.makeTableInfo(name: "users")] - session.clearCachedData() - #expect(session.tables.isEmpty) - } - @Test("clearCachedData clears selectedTables") func clearCachedDataClearsSelectedTables() { var session = makeSession() @@ -207,7 +196,7 @@ struct ConnectionSessionStateTests { let connection = TestFixtures.makeConnection(name: "Production") var session = ConnectionSession(connection: connection) session.status = .connected - session.tables = [TestFixtures.makeTableInfo(name: "users")] + session.selectedTables = [TestFixtures.makeTableInfo(name: "users")] session.clearCachedData() #expect(session.status == .connected) #expect(session.connection.id == connection.id) diff --git a/TableProTests/Views/Components/IntegrationStatusIndicatorTests.swift b/TableProTests/Views/Components/IntegrationStatusIndicatorTests.swift new file mode 100644 index 000000000..914ad337b --- /dev/null +++ b/TableProTests/Views/Components/IntegrationStatusIndicatorTests.swift @@ -0,0 +1,51 @@ +@testable import TablePro +import Testing + +@Suite("IntegrationStatusIndicator") +struct IntegrationStatusIndicatorTests { + @Test("Running status exposes a localized accessibility label") + func runningLabel() { + let indicator = IntegrationStatusIndicator(status: .running, label: "Running on port 23000") + let description = indicator.accessibilityDescription + #expect(description.contains("running")) + #expect(description.contains("Running on port 23000")) + } + + @Test("Stopped status mentions stopped in accessibility label") + func stoppedLabel() { + let indicator = IntegrationStatusIndicator(status: .stopped, label: nil) + #expect(indicator.accessibilityDescription.contains("stopped")) + } + + @Test("Failed status mentions failed in accessibility label") + func failedLabel() { + let indicator = IntegrationStatusIndicator(status: .failed, label: nil) + #expect(indicator.accessibilityDescription.contains("failed")) + } + + @Test("Expired status mentions expired in accessibility label") + func expiredLabel() { + let indicator = IntegrationStatusIndicator(status: .expired, label: nil) + #expect(indicator.accessibilityDescription.contains("expired")) + } + + @Test("Revoked status mentions revoked in accessibility label") + func revokedLabel() { + let indicator = IntegrationStatusIndicator(status: .revoked, label: nil) + #expect(indicator.accessibilityDescription.contains("revoked")) + } + + @Test("Active status mentions active in accessibility label") + func activeLabel() { + let indicator = IntegrationStatusIndicator(status: .active, label: nil) + #expect(indicator.accessibilityDescription.contains("active")) + } + + @Test("Warning, success, error, starting all expose distinct labels") + func remainingLabels() { + #expect(IntegrationStatusIndicator(status: .warning, label: nil).accessibilityDescription.contains("warning")) + #expect(IntegrationStatusIndicator(status: .success, label: nil).accessibilityDescription.contains("success")) + #expect(IntegrationStatusIndicator(status: .error, label: nil).accessibilityDescription.contains("error")) + #expect(IntegrationStatusIndicator(status: .starting, label: nil).accessibilityDescription.contains("starting")) + } +} diff --git a/docs/customization/settings.mdx b/docs/customization/settings.mdx index 3298bf2be..96339fc5d 100644 --- a/docs/customization/settings.mdx +++ b/docs/customization/settings.mdx @@ -14,7 +14,7 @@ Open with `Cmd+,`. Settings are grouped into tabs. Custom shortcuts. Provider config, inline suggestions, context, privacy. Font, theme, CLI paths. - MCP server config. + Server config, tokens, activity log, connected clients. Manage drivers and exporters. License, iCloud sync, linked folders. @@ -74,6 +74,18 @@ No queries or database content is transmitted. A tab is "clean" when it's a table tab (not query/create), unpinned, no unsaved changes, and no interactions (sort, filter, selection). +## MCP + +The **MCP** tab covers the [External API](/external-api) surface. The server lazy-starts on first use and exposes the following sections: + +- **MCP Server**: enable toggle and live status. The server runs on a free port in the `51000-52000` range. See [MCP Server](/features/mcp). +- **MCP Configuration**: row limit defaults, query timeout, "log MCP queries in history". +- **Authentication**: list, create, and revoke bearer tokens. Issued by the [pairing flow](/external-api/pairing) or generated manually. See [Tokens](/external-api/tokens). +- **Activity Log**: every authentication, tool call, and resource read with token, category, action, connection, and outcome. 90-day retention. Backed by `~/Library/Application Support/TablePro/mcp-audit.db`. +- **Network**: remote access toggle, TLS certificate, fingerprint, and PEM export. +- **MCP Setup**: one-click config snippets for Claude Code, Claude Desktop, and Cursor. +- **Connected Clients**: live list of MCP clients connected to the server. + ## Plugins Manage database driver and exporter plugins from **Settings** > **Plugins**. Split-view: list on the left, details on the right. diff --git a/docs/databases/overview.mdx b/docs/databases/overview.mdx index 586511210..bf0a7a8f0 100644 --- a/docs/databases/overview.mdx +++ b/docs/databases/overview.mdx @@ -243,6 +243,17 @@ SSL/TLS is not available for SQLite connections (file-based, no network involved /> +#### Advanced Section + +Open the **Advanced** tab on the connection form for the following settings: + +| Field | Description | +|-------|-------------| +| **Startup Commands** | SQL statements that run automatically on every connection. See [Startup Commands](#startup-commands). | +| **External Access** | Controls how external clients (Raycast, Cursor, Claude Desktop) reach this connection: `blocked`, `readOnly` (default), or `readWrite`. Tokens cannot exceed this level. See [External API](/external-api). | +| **Local only** | Excludes this connection from iCloud Sync. See [iCloud Sync](/features/icloud-sync). | +| **Plugin fields** | Driver-specific options (for example, MongoDB `replicaSet`, ClickHouse `Secure`). | + ## Organizing Connections Colors tint the toolbar when you connect (red for production, green for development). Tags group connections in the sidebar. diff --git a/docs/development/plugin-registry.mdx b/docs/development/plugin-registry.mdx index cb8cf3a2f..c8d554aa9 100644 --- a/docs/development/plugin-registry.mdx +++ b/docs/development/plugin-registry.mdx @@ -34,7 +34,7 @@ The registry file (`plugins.json`): | `databaseTypeIds` | [string] | No | Maps to `DatabaseType.pluginTypeId`. Used for auto-install. | | `downloadURL` | string | No* | Direct URL to `.zip` | | `sha256` | string | No* | SHA-256 hex of ZIP | -| `binaries` | [object] | No | Per-arch: `[{ "architecture": "arm64"|"x86_64", "downloadURL": "...", "sha256": "..." }]` | +| `binaries` | [object] | No | Per-arch entries with `architecture` (`arm64` or `x86_64`), `downloadURL`, and `sha256`. See example below. | | `minAppVersion` | string | No | Minimum TablePro version | | `minPluginKitVersion` | int | No | Minimum PluginKit version (currently 2) | | `iconName` | string | No | SF Symbol name | diff --git a/docs/docs.json b/docs/docs.json index 2b09be7a1..07261caf5 100644 --- a/docs/docs.json +++ b/docs/docs.json @@ -121,8 +121,7 @@ "features/tabs", "features/query-history", "features/sql-favorites", - "features/keyboard-shortcuts", - "features/deep-links" + "features/keyboard-shortcuts" ] }, { @@ -155,6 +154,20 @@ "customization/appearance", "customization/editor-settings" ] + }, + { + "group": "External API", + "pages": [ + "external-api/index", + "external-api/url-scheme", + "external-api/mcp-tools", + "external-api/mcp-resources", + "external-api/pairing", + "external-api/tokens", + "external-api/raycast", + "external-api/mcp-clients", + "external-api/versioning" + ] } ] }, diff --git a/docs/external-api/index.mdx b/docs/external-api/index.mdx new file mode 100644 index 000000000..22792fa3b --- /dev/null +++ b/docs/external-api/index.mdx @@ -0,0 +1,73 @@ +--- +title: External API +description: URL scheme, MCP server, and pairing flow for Raycast, Cursor, Claude Desktop, and other external clients +--- + +# External API + +The TablePro External API is the public contract that lets other apps drive TablePro. Raycast, Cursor, Claude Desktop, and custom scripts all use the same surface. This page is the entry point. Pick the subpage that matches what you want to do. + +## Three pillars + +The External API has three independent layers. Most clients use a mix of all three. + + + + `tablepro://` deep links open connections, tables, and queries in the GUI. + + + JSON-RPC tools and resources for AI clients. HTTP and stdio transports. + + + One-click flow to issue a scoped token to an extension. + + + +## When to use which + +| Goal | Use | +|------|-----| +| Open a connection from a script or other app | URL scheme | +| Run a query and read rows back | MCP `execute_query` | +| Browse schema for an AI model | MCP `list_tables`, `describe_table` | +| Issue a token to a Raycast or Cursor extension | Pairing | +| Navigate to a tab the user already has open | MCP `list_recent_tabs` + `focus_query_tab` | + +URL scheme drives the GUI. MCP exchanges data. Pairing bootstraps trust. + +## Security model + +The External API is gated by three independent layers. A request must clear all three. + +1. **Per-connection external access.** Each connection has an `externalAccess` setting: `blocked`, `readOnly` (default), or `readWrite`. Set per connection in the connection editor. Tokens cannot exceed this level. +2. **Token scope.** Tokens are issued with `readOnly`, `readWrite`, or `fullAccess` scope and an optional per-connection allowlist. See [Tokens](/external-api/tokens). +3. **AI policy and Safe Mode.** The connection's AI policy (`alwaysAllow` / `askEachTime` / `never`) and Safe Mode rules apply on top. Destructive operations require an explicit confirmation phrase. + +The effective permission for any request is `MIN(token.scope, connection.externalAccess)`. A token with `fullAccess` against a connection with `readOnly` cannot mutate. + +Every request is recorded in the activity log. Open **Settings > Integrations > Activity Log** to inspect. + +## Quick start + +- Install the [Raycast extension](/external-api/raycast) and run `Pair with TablePro`. +- Or wire stdio MCP into your [MCP client](/external-api/mcp-clients) without an extension. +- Or open a deep link from your shell: + +```bash +open "tablepro://connect/9f1f0c3e-2e3d-4b14-9c3a-1d2f4ad1f6f1" +``` + +## Versioning + +The External API follows TablePro's semver. Paths and tools are additive within a major version. See [Versioning](/external-api/versioning) for the deprecation policy. + +## Subpages + +- [URL Scheme](/external-api/url-scheme): every `tablepro://` action and parameter. +- [MCP Tools](/external-api/mcp-tools): JSON-RPC tool catalog with input and output schemas. +- [MCP Resources](/external-api/mcp-resources): resources you can read. +- [Pairing](/external-api/pairing): sequence diagram and PKCE flow. +- [Tokens](/external-api/tokens): scope model, allowlists, revocation. +- [Raycast](/external-api/raycast): extension install and command list. +- [MCP Clients](/external-api/mcp-clients): stdio MCP setup for Claude Desktop, Claude Code, Cursor, Cline, Continue, Zed, Windsurf, Goose, and custom clients. +- [Versioning](/external-api/versioning): stability policy. diff --git a/docs/external-api/mcp-clients.mdx b/docs/external-api/mcp-clients.mdx new file mode 100644 index 000000000..9e6464c73 --- /dev/null +++ b/docs/external-api/mcp-clients.mdx @@ -0,0 +1,216 @@ +--- +title: MCP Clients +description: Connect Claude Desktop, Claude Code, Cursor, Cline, Continue, Zed, Windsurf, Goose, and custom clients to TablePro over stdio +--- + +# MCP Clients + +Any MCP client that supports the stdio transport and lets you point it at a command on disk can connect to TablePro. The pattern is the same across every client: + +```json +{ + "mcpServers": { + "tablepro": { + "command": "/Applications/TablePro.app/Contents/MacOS/tablepro-mcp" + } + } +} +``` + +The `tablepro-mcp` CLI ships inside the app bundle. It reads `~/Library/Application Support/TablePro/mcp-handshake.json` for the local port and a bridge token, then forwards stdio JSON-RPC to the running app's HTTP MCP server. If the handshake file is missing, the CLI fires `tablepro://integrations/start-mcp` to lazy-start the server and waits up to 10 seconds for the handshake to appear. The TablePro app must be running; you can keep it minimized. + +You do not pass a token in the client config. The bridge reuses the in-app handshake, so the token issued during pairing stays inside TablePro. The Raycast extension or any `tablepro://integrations/pair?...` link triggers the one-time pairing flow that puts a token on disk; clients launched via stdio inherit that trust automatically. + +If TablePro is installed somewhere other than `/Applications` (for example, Setapp or a custom path), replace the `command` value with the absolute path to your bundle's `tablepro-mcp` binary. + +## Claude Desktop + +Edit `~/Library/Application Support/Claude/claude_desktop_config.json`: + +```json +{ + "mcpServers": { + "tablepro": { + "command": "/Applications/TablePro.app/Contents/MacOS/tablepro-mcp" + } + } +} +``` + +Restart Claude Desktop. Open a new chat, click the connectors icon below the input, and confirm `tablepro` is listed with its tools enabled. + +## Claude Code + +Use the `claude mcp add` CLI: + +```bash +claude mcp add --transport stdio tablepro -- /Applications/TablePro.app/Contents/MacOS/tablepro-mcp +``` + +The double dash separates Claude Code's flags from the command it runs. Verify with `claude mcp list`. + +## Cursor + +Edit `~/.cursor/mcp.json` for global access, or `.cursor/mcp.json` in the project root for per-project access: + +```json +{ + "mcpServers": { + "tablepro": { + "command": "/Applications/TablePro.app/Contents/MacOS/tablepro-mcp" + } + } +} +``` + +Restart Cursor. The TablePro tools appear under `@mcp` in chat. + +## Cline + +Cline is a VS Code extension. Open the Cline panel, click the MCP Servers icon in its top nav, and choose **Configure MCP Servers** to open `cline_mcp_settings.json`: + +```json +{ + "mcpServers": { + "tablepro": { + "command": "/Applications/TablePro.app/Contents/MacOS/tablepro-mcp", + "disabled": false, + "alwaysAllow": [] + } + } +} +``` + +Reload the Cline panel. The server status indicator should turn green. + +## Continue + +Continue (`continue.dev`) reads MCP configs from `.continue/mcpServers/` in your workspace. Create `.continue/mcpServers/tablepro.yaml`: + +```yaml +mcpServers: + - name: tablepro + type: stdio + command: /Applications/TablePro.app/Contents/MacOS/tablepro-mcp +``` + +If you prefer JSON, drop the same shape into `.continue/mcpServers/tablepro.json` using the standard `mcpServers` object form. Reload Continue's config from the gear menu. + +## Zed + +Zed keys MCP servers under `context_servers`, not `mcpServers`. Edit `~/.config/zed/settings.json` (or open it via **Zed > Settings**): + +```json +{ + "context_servers": { + "tablepro": { + "command": "/Applications/TablePro.app/Contents/MacOS/tablepro-mcp", + "args": [], + "env": {} + } + } +} +``` + +Open the Agent Panel; the TablePro entry should show a green status dot. + +## Windsurf + +Edit `~/.codeium/windsurf/mcp_config.json`. From inside Windsurf, click the MCP icon in the Cascade panel and choose **Configure** to open this file: + +```json +{ + "mcpServers": { + "tablepro": { + "command": "/Applications/TablePro.app/Contents/MacOS/tablepro-mcp" + } + } +} +``` + +Restart the Cascade panel. TablePro's tools appear in the tool picker. + +## Goose + +Goose is the Block CLI agent (now hosted at the Agentic AI Foundation). Run `goose configure`, choose **Add Extension > Command-line Extension**, and enter: + +- **Name**: `tablepro` +- **Command**: `/Applications/TablePro.app/Contents/MacOS/tablepro-mcp` +- **Timeout**: `300` + +The wizard writes the entry to `~/.config/goose/config.yaml`. To edit by hand, add an entry under `extensions`: + +```yaml +extensions: + tablepro: + type: stdio + cmd: /Applications/TablePro.app/Contents/MacOS/tablepro-mcp + args: [] + enabled: true + timeout: 300 +``` + +Run `goose session` and ask for the tool list to confirm. + +## Generic or custom client + +If you are building a client against the [MCP specification](https://modelcontextprotocol.io), TablePro speaks two transports: + +- **stdio**: spawn `/Applications/TablePro.app/Contents/MacOS/tablepro-mcp` with no arguments. The CLI handles the handshake and forwards JSON-RPC over its stdin and stdout. No token, no environment variables. +- **HTTP**: connect to `http://127.0.0.1:/mcp` using the port from `~/Library/Application Support/TablePro/mcp-handshake.json` and a bearer token issued via [Pairing](/external-api/pairing). See [Tokens](/external-api/tokens) for scope rules. + +For most desktop clients, stdio is the right default. Use HTTP when the client lives on a different machine, when you need a tighter token scope than the bridge token, or when the client cannot spawn a local process. See [MCP Server](/features/mcp#remote-access) for remote setup. + +## HTTP transport + +If your client cannot use stdio, mint a token in **Settings > Integrations > Authentication** and configure HTTP directly. The shape varies by client; here is the Cursor form: + +```json +{ + "mcpServers": { + "tablepro": { + "url": "http://127.0.0.1:23508/mcp", + "headers": { + "Authorization": "Bearer tp_your_token_here" + } + } + } +} +``` + +Replace `23508` with the port shown in **Settings > Integrations > MCP Configuration**. Other clients use the same `url` plus `headers` shape, sometimes under `type: streamable-http`. Check the client's docs. + +## Setup snippets in TablePro + +Open **Settings > Integrations > MCP Setup** and pick a client. TablePro shows the exact JSON or shell command to paste into the client's config, with the correct paths for your install. + +## What the AI sees + +AI clients see the full [tool catalog](/external-api/mcp-tools). For an unfamiliar schema, the AI is expected to call `describe_table` before generating SQL. For mutating SQL, the AI must request user confirmation through the host's tool-confirmation mechanism. Hosts like Cursor, Claude Desktop, Claude Code, Cline, and Windsurf each surface this with their own UI. + +The connection's `externalAccess` setting and the token scope still apply. A read-only connection rejects writes regardless of what the AI tries. + +## Verify the connection + +After configuring a client, the fastest check is to ask it to list TablePro tools or call `list_connections`. Success looks like: + +- The client lists tools such as `list_connections`, `list_tables`, `describe_table`, and `execute_query`. +- A `list_connections` call returns the connections you have saved in TablePro (id, name, type). + +If the call fails, the response code tells you which layer rejected it: + +- **stdio process exits immediately**: TablePro is not running, or you are on a build older than 0.37. Open TablePro and re-launch the client. +- **`401 Unauthorized`**: the bridge token is stale. Quit and reopen TablePro to regenerate the handshake. +- **`403 Forbidden`**: the connection's `externalAccess` is `blocked` or `readOnly`, or the token's allowlist excludes it. Open the connection editor in TablePro and adjust under **External Access**. + +## Troubleshooting + +**Handshake timeout.** TablePro launched but did not respond to `tablepro://integrations/start-mcp` within 10 seconds. Open **Settings > Integrations** and toggle **Enable MCP Server** off and on, then re-launch the client. + +**Stale handshake file.** Delete `~/Library/Application Support/TablePro/mcp-handshake.json` and reopen TablePro. The app rewrites the file on launch. + +**Setapp or non-default install path.** Replace `/Applications/TablePro.app` in the `command` with the absolute path to your install. For Setapp the bundle lives under `~/Applications/Setapp/TablePro.app`. + +**Port conflict.** TablePro picks a different free port from the `51000-52000` range on next launch and rewrites the handshake file. The stdio bridge re-reads it automatically. + +**Tool calls return `403 Connection is read-only for external clients`.** The connection's external access is `readOnly` and the SQL is a write. Either change external access in TablePro, or run the query in TablePro's editor. diff --git a/docs/external-api/mcp-resources.mdx b/docs/external-api/mcp-resources.mdx new file mode 100644 index 000000000..13b28545e --- /dev/null +++ b/docs/external-api/mcp-resources.mdx @@ -0,0 +1,113 @@ +--- +title: MCP Resources +description: Read-only resources exposed by the MCP server +--- + +# MCP Resources + +Resources are read-only views of TablePro state. AI clients use them to discover what is available before calling tools. Every resource is scope-gated the same way as tools and respects the per-connection allowlist. + +URIs use the `tablepro://` scheme inside the MCP transport. Do not confuse them with shell-level [URL scheme deep links](/external-api/url-scheme). + +## `tablepro://connections` + +All saved connections with their current session state. + +**Returns**: + +```json +{ + "connections": [ + { + "id": "9f1f0c3e-2e3d-4b14-9c3a-1d2f4ad1f6f1", + "name": "Production", + "type": "PostgreSQL", + "host": "db.example.com", + "port": 5432, + "database": "app", + "username": "app", + "is_connected": false, + "ai_policy": "askEachTime", + "safe_mode": "silent" + } + ] +} +``` + +`database` reflects the active session database when connected, otherwise the saved default. `type` uses display casing (`MySQL`, `PostgreSQL`, `SQLite`, etc.). `safe_mode` is one of `silent`, `alert`, `alertFull`, `safeMode`, `safeModeFull`, `readOnly`. `ai_policy` is one of `askEachTime`, `alwaysAllow`, `never`. + +Connections with `externalAccess: blocked` are omitted. The envelope matches the `list_connections` tool. + +**Scope**: `readOnly`. + +## `tablepro://connections/{id}/schema` + +Tables and columns visible on the current connection session. + +**Path parameter**: `id` is the connection UUID. + +**Returns**: + +```json +{ + "tables": [ + { + "name": "users", + "type": "table", + "columns": [ + { "name": "id", "data_type": "uuid", "is_nullable": false, "is_primary_key": true }, + { "name": "email", "data_type": "text", "is_nullable": false, "is_primary_key": false } + ] + } + ] +} +``` + +The response is flat: tables for the active database/schema only. To switch context, call the `switch_database` or `switch_schema` tool first. `type` matches the underlying `TableType` raw value (for example `table`, `view`). + +Capped at 100 tables. When more exist, the response also includes `truncated: true` and `total_tables: `. For larger schemas, page through `list_tables` instead. + +**Scope**: `readOnly`. + +## `tablepro://connections/{id}/history` + +Recent query history for a connection. + +**Path parameter**: `id` is the connection UUID. + +**Query parameters**: + +| Parameter | Description | +|-----------|-------------| +| `limit` | 1-500. Default 50. | +| `search` | Full-text query string. | +| `date_filter` | `today`, `thisWeek`, or `thisMonth`. Anything else is treated as no date filter. | + +**Returns**: + +```json +{ + "history": [ + { + "id": "9b2d3c5a-...", + "query": "SELECT * FROM users WHERE active = true", + "database_name": "app", + "executed_at": "2026-04-26T10:14:22Z", + "execution_time_ms": 18.4, + "row_count": 142, + "was_successful": true + } + ] +} +``` + +`executed_at` is an ISO 8601 timestamp. `execution_time_ms` is a double in milliseconds. `error_message` is included when `was_successful` is false. + +**Scope**: `readOnly`. + +## Errors + +| Code | Meaning | +|------|---------| +| `403` | Token allowlist rejects the connection, or `externalAccess` is `blocked`. | +| `404` | Connection not found. | diff --git a/docs/external-api/mcp-tools.mdx b/docs/external-api/mcp-tools.mdx new file mode 100644 index 000000000..988072906 --- /dev/null +++ b/docs/external-api/mcp-tools.mdx @@ -0,0 +1,489 @@ +--- +title: MCP Tools +description: JSON-RPC tool catalog with input schemas, output schemas, scope requirements, and examples +--- + +# MCP Tools + +The MCP server exposes tools and resources over JSON-RPC. The tools are grouped by category below. Every tool is scope-gated: a request must come with a token whose scope and connection allowlist permit the call. + +## Transports + +The same tool catalog is available over two transports: + +- **HTTP**: `http://127.0.0.1:/mcp` (port from the handshake file). Bearer token in `Authorization` header. +- **stdio**: bundled `tablepro-mcp` CLI bridges stdio JSON-RPC to localhost HTTP. No token needed because the bridge reuses the in-app handshake. + +See [MCP Clients](/external-api/mcp-clients) for stdio config snippets. + +## Scope and access matrix + +Every tool requires one of these scopes. The scope is the token's; the connection's `externalAccess` setting can downgrade it further. + +| Scope | Read schema | Run SELECT | Run INSERT/UPDATE/DELETE | Confirm DROP/TRUNCATE | +|-------|:-----------:|:----------:|:------------------------:|:--------------------:| +| `readOnly` | yes | yes | no | no | +| `readWrite` | yes | yes | yes | no | +| `fullAccess` | yes | yes | yes | yes (with phrase) | + +If `connection.externalAccess` is `blocked`, every tool that targets that connection returns `403 forbidden`. If `readOnly`, write tools return `403` even with a `readWrite` token. + +## Connection tools + +### `list_connections` + +List all saved connections. + +**Input**: none. + +**Output**: + +```json +{ + "connections": [ + { + "id": "9f1f0c3e-2e3d-4b14-9c3a-1d2f4ad1f6f1", + "name": "Production", + "type": "PostgreSQL", + "host": "db.example.com", + "port": 5432, + "database": "app", + "username": "app", + "is_connected": false, + "ai_policy": "askEachTime", + "safe_mode": "silent" + } + ] +} +``` + +**Scope**: `readOnly`. + +### `connect` + +Open a database connection. + +**Input**: + +```json +{ "connection_id": "9f1f0c3e-..." } +``` + +**Output**: + +```json +{ + "status": "connected", + "current_database": "app", + "current_schema": "public", + "server_version": "PostgreSQL 16.2" +} +``` + +`current_schema` and `server_version` are present when known. + +**Scope**: `readOnly`. + +### `disconnect` + +Close a connection. + +**Input**: `{ "connection_id": "..." }` + +**Output**: empty object on success. + +**Scope**: `readOnly`. + +### `get_connection_status` + +Return version, uptime, and active database for a connection. + +**Input**: `{ "connection_id": "..." }` + +**Output**: + +```json +{ + "status": "connected", + "current_database": "app", + "current_schema": "public", + "server_version": "PostgreSQL 16.2", + "connected_at": "2026-04-26T10:14:22Z", + "last_active_at": "2026-04-26T10:14:22Z" +} +``` + +`status` is one of `connected`, `connecting`, `disconnected`, `error`. When `error`, an `error` object with a `message` field is included. + +**Scope**: `readOnly`. + +## Schema tools + +### `list_databases` + +**Input**: `{ "connection_id": "..." }` + +**Output**: `{ "databases": ["app", "analytics"] }` (array of database names) + +**Scope**: `readOnly`. + +### `list_schemas` + +**Input**: `{ "connection_id": "...", "database": "app" }` (database optional) + +**Output**: `{ "schemas": ["public", "reporting"] }` (array of schema names) + +**Scope**: `readOnly`. + +### `list_tables` + +**Input**: + +```json +{ + "connection_id": "...", + "database": "app", + "schema": "public", + "include_row_counts": false +} +``` + +**Output**: + +```json +{ + "tables": [ + { "name": "users", "type": "table" }, + { "name": "orders_view", "type": "view" } + ] +} +``` + +When `include_row_counts` is true and the driver supports it, each entry also includes `row_count`. + +**Scope**: `readOnly`. + +### `describe_table` + +Columns, indexes, foreign keys, primary key, DDL. + +**Input**: + +```json +{ + "connection_id": "...", + "table": "users", + "schema": "public" +} +``` + +`schema` is optional. The current database is used unless the connection was first switched with `switch_database`. + +**Output**: + +```json +{ + "columns": [ + { + "name": "id", + "data_type": "uuid", + "is_nullable": false, + "is_primary_key": true + }, + { + "name": "email", + "data_type": "text", + "is_nullable": false + } + ], + "indexes": [ + { + "name": "users_email_idx", + "columns": ["email"], + "is_unique": true, + "is_primary": false, + "type": "btree" + } + ], + "foreign_keys": [], + "ddl": "CREATE TABLE users (...)", + "approximate_row_count": 12345 +} +``` + +`default_value`, `extra`, and `comment` are present on a column when set. `ddl` and `approximate_row_count` are present when the driver supports them. + +**Scope**: `readOnly`. + +### `get_table_ddl` + +Just the `CREATE TABLE` statement. + +**Input**: same as `describe_table` (`connection_id`, `table`, `schema`). + +**Output**: `{ "ddl": "CREATE TABLE ..." }` + +**Scope**: `readOnly`. + +## Query tools + +### `execute_query` + +Run a SQL query. + +**Input**: + +```json +{ + "connection_id": "...", + "query": "SELECT id, email FROM users WHERE active = true LIMIT 100", + "max_rows": 500, + "timeout_seconds": 30, + "database": "app", + "schema": "public" +} +``` + +`max_rows` defaults to 500, max 10,000. `timeout_seconds` defaults to 30, max 300. Single-statement queries only. Query size cap is 100 KB. + +**Output**: + +```json +{ + "columns": ["id", "email"], + "rows": [["9f1f...", "alice@example.com"]], + "row_count": 1, + "rows_affected": 0, + "execution_time_ms": 14, + "is_truncated": false +} +``` + +`columns` is an array of column-name strings. `rows` is an array of rows, where each row is an array of strings (or `null`) aligned to the `columns` order. `status_message` is added when the driver returns one. + +**Scope**: + +- `readOnly` for SELECT, SHOW, EXPLAIN. +- `readWrite` for INSERT, UPDATE, DELETE. +- DROP, TRUNCATE, ALTER...DROP are rejected. Use `confirm_destructive_operation`. + +Safe Mode rules apply on top. A connection in Safe Mode `readOnly` returns `403` for any write SQL. + +### `confirm_destructive_operation` + +Run a DROP, TRUNCATE, or ALTER...DROP after a typed confirmation. + +**Input**: + +```json +{ + "connection_id": "...", + "query": "DROP TABLE legacy_events", + "confirmation_phrase": "I understand this is irreversible" +} +``` + +The confirmation phrase is fixed: `I understand this is irreversible`. Anything else returns `400 invalid confirmation`. + +**Output**: same shape as `execute_query`. + +**Scope**: `fullAccess`. + +### `export_data` + +Export query or table data as CSV, JSON, or SQL. + +**Input**: + +```json +{ + "connection_id": "...", + "format": "csv", + "tables": ["users", "orders"], + "max_rows": 50000 +} +``` + +`format` is one of `csv`, `json`, `sql`. `max_rows` defaults to 50,000, max 100,000. Provide either `tables` or `query`. Pass `output_path` to write to disk instead of returning data inline. + +**Output**: an envelope with one entry per query/table exported. Each entry has the export label and either inline data or the file path. Provide `output_path` in the request to receive a file-path response. + +**Scope**: `readOnly`. + +### `switch_database` / `switch_schema` + +**Input**: `{ "connection_id": "...", "database": "analytics" }` or `{ "connection_id": "...", "schema": "reporting" }` + +**Output**: `{ "status": "switched", "current_database": "analytics" }` or `{ "status": "switched", "current_schema": "reporting" }` + +**Scope**: `readOnly`. + +## Navigation tools + +These mutate UI state in the running TablePro app: opening tabs, focusing windows. They require `readWrite` scope because the user sees the result. + +### `open_connection_window` + +Open a connection in TablePro and bring its window to front. + +**Input**: `{ "connection_id": "..." }` + +**Output**: + +```json +{ + "status": "opened", + "connection_id": "9f1f...", + "window_id": "..." +} +``` + +**Scope**: `readWrite`. + +### `open_table_tab` + +Open a table tab. + +**Input**: + +```json +{ + "connection_id": "...", + "table_name": "users", + "database_name": "app", + "schema_name": "public" +} +``` + +`database_name` and `schema_name` are optional. If omitted, the connection's current database/schema is used. + +**Output**: + +```json +{ + "status": "opened", + "connection_id": "9f1f...", + "table_name": "users", + "window_id": "..." +} +``` + +**Scope**: `readWrite`. + +### `focus_query_tab` + +Bring an existing tab to front. + +**Input**: `{ "tab_id": "..." }` + +**Output**: + +```json +{ + "status": "focused", + "tab_id": "...", + "window_id": "...", + "connection_id": "9f1f..." +} +``` + +**Scope**: `readWrite`. + +### `list_recent_tabs` + +Read the cross-window tab registry. + +**Input**: `{ "limit": 20 }` (optional, 1-500, default 20). + +**Output**: + +```json +{ + "tabs": [ + { + "tab_id": "...", + "connection_id": "9f1f...", + "connection_name": "Production", + "tab_type": "query", + "display_title": "users by signup date", + "is_active": true, + "table_name": "users", + "database_name": "app", + "schema_name": "public", + "window_id": "..." + } + ] +} +``` + +`tab_type` is one of `query`, `table`, `createTable`, `erDiagram`, `serverDashboard`, `terminal`. `table_name`, `database_name`, `schema_name`, and `window_id` are present when known. + +**Scope**: `readOnly`. + +## History tools + +### `search_query_history` + +Full-text search over the query history database. + +**Input**: + +```json +{ + "query": "users active", + "connection_id": "9f1f...", + "limit": 50, + "since": 1745577262, + "until": 1745663662 +} +``` + +`connection_id` is optional. `limit` is 1-500, default 50. `since` and `until` are optional Unix epoch seconds; both bounds are inclusive. Either may be set on its own. Pass an empty `query` ("") to skip the full-text filter and only narrow by date or connection. + +**Output**: + +```json +{ + "entries": [ + { + "id": "...", + "connection_id": "9f1f...", + "database_name": "app", + "query": "SELECT * FROM users WHERE active = true", + "executed_at": 1745663662.0, + "execution_time_ms": 18, + "row_count": 142, + "was_successful": true + } + ] +} +``` + +`executed_at` is a Unix timestamp in seconds. `error_message` is included when `was_successful` is false. + +**Scope**: `readOnly`. + +## Errors + +All tools return JSON-RPC errors with these codes: + +| Code | Meaning | +|------|---------| +| `400` | Invalid input | +| `401` | Missing or invalid bearer token | +| `403` | Token scope or `externalAccess` rejects the request | +| `404` | Connection, table, or tab not found | +| `408` | Query timeout | +| `429` | Rate limit | +| `500` | Server error | + +Error responses include a `message` field. Example: + +```json +{ + "error": { + "code": 403, + "message": "Connection is read-only for external clients" + } +} +``` diff --git a/docs/external-api/pairing.mdx b/docs/external-api/pairing.mdx new file mode 100644 index 000000000..de1181bb0 --- /dev/null +++ b/docs/external-api/pairing.mdx @@ -0,0 +1,167 @@ +--- +title: Pairing +description: One-click flow that issues a scoped MCP token to an extension using a PKCE-flavored code exchange +--- + +# Pairing + +Pairing is how an extension gets a TablePro token without the user copying and pasting one. The user runs a `Pair with TablePro` command in the extension, picks scopes and connections inside TablePro, and the extension receives a token over a Raycast deep link callback. + +The flow is PKCE-flavored: the extension generates a verifier, hashes it into a challenge, and the token is only released after the verifier is presented. This prevents another app on the same machine from intercepting the redirect and stealing the token. + +## Sequence + +```mermaid +sequenceDiagram + participant E as Extension + participant T as TablePro app + participant U as User + participant M as MCP server (HTTP) + + E->>E: verifier = randomBytes(32).base64url + E->>E: challenge = base64url(SHA-256(verifier)) + E->>T: open tablepro://integrations/pair
?client=...&challenge=...&redirect=...&scopes=... + T->>M: lazyStart() + T->>U: Approval sheet (client, scopes, connections, expiry) + U->>T: Approve + T->>T: tokenStore.generate(...) -> { plaintext, prefix } + T->>T: store pending exchange { code, plaintext, challenge }
expires in 5 min + T->>E: open redirect URL with code (context for raycast://, ?code= otherwise) + E->>M: POST /v1/integrations/exchange
{ code, code_verifier: verifier } + M->>M: SHA-256(verifier) == challenge ? + M->>E: 200 { token: "tp_..." } + E->>E: store token in Keychain (Raycast password preference) +``` + +## Step by step + +### 1. Extension generates a verifier and challenge + +```ts +import { randomBytes, createHash } from "node:crypto"; + +function base64url(buffer: Buffer): string { + return buffer.toString("base64").replace(/\+/g, "-").replace(/\//g, "_").replace(/=+$/, ""); +} + +const verifier = base64url(randomBytes(32)); +const challenge = base64url(createHash("sha256").update(verifier).digest()); +``` + +The verifier is 32 random bytes, base64url-encoded. Keep it in memory until the exchange step. Do not log it. + +### 2. Extension opens the pair deep link + +```ts +import { open } from "@raycast/api"; + +const params = new URLSearchParams({ + client: `Raycast on ${require("os").hostname()}`, + challenge, + redirect: "raycast://extensions/ngoquocdat/tablepro/pair-callback", + scopes: "readOnly,readWrite", +}); +await open(`tablepro://integrations/pair?${params}`); +``` + +See the [URL scheme reference](/external-api/url-scheme#start-pairing) for parameters. + +### 3. TablePro shows the approval sheet + +The user sees: + +- The client name from the request. +- A scopes radio (defaults to the requested scope, downgradeable). +- A connections multi-select (defaults to all unless `connection-ids` was provided). +- An expiry picker (defaults to never). + +The user can change any of these before approving. The query parameters are a request, not a grant. + +### 4. TablePro generates a token and a one-time code + +On approval, TablePro calls `MCPTokenStore.generate(...)` to mint a token, then stores a pending exchange: + +```swift +struct PendingExchange { + let plaintextToken: String + let challenge: String + let expiresAt: Date // now + 5 min +} +``` + +The plaintext token is held in memory only. The token store keeps the hashed form on disk (SHA-256 + salt). + +### 5. TablePro redirects with the code + +TablePro opens the `redirect` URL with `NSWorkspace.shared.open(...)`. The encoding depends on the redirect scheme: + +- **`raycast://...`**: TablePro appends `?context={"code":""}` (URL-encoded JSON). Raycast parses `context` and passes it to the receiving command as `LaunchProps.launchContext`. This matches Raycast's documented launch-context convention. +- **Anything else** (`http://127.0.0.1:/callback`, custom schemes): TablePro appends `?code=` as a flat query parameter. Standard OAuth-callback shape. + +### 6. Extension exchanges the code + +The extension reads the MCP port from `~/Library/Application Support/TablePro/mcp-handshake.json`, then: + +```ts +const port = await readHandshakePort(); +const res = await fetch(`http://127.0.0.1:${port}/v1/integrations/exchange`, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ code, code_verifier: verifier }), +}); +const { token } = await res.json(); +``` + +The exchange endpoint requires no bearer auth. The single-use code is the auth. + +### 7. TablePro validates and returns the token + +Server-side check: + +``` +SHA-256(code_verifier) == challenge +``` + +If equal, return the plaintext token and delete the pending exchange. If the code has expired (5 minutes) or the verifier does not match, return `403`. + +### 8. Extension stores the token + +```ts +import { LocalStorage } from "@raycast/api"; +await LocalStorage.setItem("apiToken", token); +``` + +For preferences-backed storage, use `updateCommandMetadata` or write to the password preference. Tokens stored in Raycast preferences live in the macOS Keychain. + +## Security properties + +| Property | How | +|----------|-----| +| Token is never in the URL | The token is fetched over localhost HTTP, not embedded in a deep link. | +| Redirect interception is harmless | A malicious app that intercepts the `code` cannot exchange it without the verifier. | +| Code is single-use | Successful exchange or 5-minute expiry deletes the pending exchange. | +| Plaintext token is not persisted by TablePro | Only the SHA-256 hash plus salt is saved to `mcp-tokens.json`. | +| User sees and approves scopes | The sheet shows what was requested, what is granted, and which connections. | +| User can revoke any time | **Settings > Integrations > Authentication > Revoke**. | + +## Errors + +| Code | Meaning | +|------|---------| +| `403 challenge mismatch` | The verifier does not hash to the stored challenge. | +| `404 pairing code` | The code does not exist or has already been exchanged. | +| `410 expired` | The pending exchange is older than 5 minutes. | + +A failed exchange is recorded in the activity log under the `auth` category with outcome `denied`. + +## Implementing pairing in another extension + +The flow is not Raycast-specific. Cursor, Claude Desktop, or any custom client can use it. Requirements: + +1. Generate a verifier and challenge. +2. Open `tablepro://integrations/pair?...` with a deep link callback URL the OS can route back to the extension. +3. Read the MCP port from the handshake file. +4. POST `{ code, code_verifier }` to `/v1/integrations/exchange`. +5. Store the returned token in OS Keychain. + +If the extension cannot register a custom URL scheme, open a localhost HTTP server on a chosen port and pass `http://127.0.0.1:/callback` as the `redirect`. diff --git a/docs/external-api/raycast.mdx b/docs/external-api/raycast.mdx new file mode 100644 index 000000000..16863a7ea --- /dev/null +++ b/docs/external-api/raycast.mdx @@ -0,0 +1,97 @@ +--- +title: Raycast Extension +description: Install the TablePro Raycast extension, pair it, and use the command and AI tool catalog +--- + +# Raycast Extension + +The TablePro Raycast extension is the reference consumer of the External API. It uses the URL scheme to open the GUI and the MCP server to read schema, run queries, and search history. AI tools let Raycast Pro and Quick AI call TablePro tools directly with `@tablepro` mentions. + +## Install + +1. Open Raycast. +2. Search for **Store**, then search **TablePro**. +3. Click **Install**. + +The extension is open-source under MIT, hosted at [github.com/raycast/extensions/extensions/tablepro](https://github.com/raycast/extensions). Source contributions are welcome via the Raycast extensions monorepo. + +## Pair + +After install, run **Pair with TablePro**. A form appears: + +- **Client name** defaults to `Raycast on `. +- **Scope**: `readOnly`, `readWrite`, or `fullAccess`. Default `readOnly`. +- **Allowed connections**: defaults to all. Pick a subset to restrict. +- **Expiry**: defaults to never. Pick a date for rotating tokens. + +On submit, TablePro opens with an approval sheet. Adjust and approve. The extension stores the returned token in the macOS Keychain via Raycast's password preference. + +The flow is detailed in [Pairing](/external-api/pairing). + +## Commands + +| Command | Mode | Description | +|---------|------|-------------| +| Search Connections | view | Fuzzy search over saved connections, open the selected one. | +| Open Connection | no-view | Argument-driven open, e.g. `Open Connection prod`. | +| TablePro Menu Bar | menu-bar | Menu-bar item showing connection status, refresh interval 10 minutes. | +| Search Schema | view | Browse databases, schemas, and tables for a connection. | +| Search Tables | view | Quick table picker, opens the table in TablePro. | +| Recent Tabs | view | Cross-window tab list, focus or open. | +| Run Query | view | Type SQL, run via MCP, see row count and first rows in a Detail view. | +| Search Query History | view | Full-text search over query history. | +| Pair with TablePro | view | Pairing form. Re-run any time to issue a new token. | + +Every command falls back to a clear empty state when TablePro is not running or paired. See [Troubleshooting](#troubleshooting). + +## AI tools + +If you have Raycast Pro, the extension exposes its tools to Quick AI and Raycast Chat. Mention `@tablepro` in a chat: + +> @tablepro show me users that signed up this week from production + +Raycast picks the right tools, calls the MCP server, and returns the result. The catalog: + +| Tool | Description | +|------|-------------| +| List Connections | Calls `list_connections`. | +| List Databases | Calls `list_databases`. | +| List Schemas | Calls `list_schemas`. | +| List Tables | Calls `list_tables`. | +| Describe Table | Calls `describe_table`. | +| Get Table DDL | Calls `get_table_ddl`. | +| Run Query | Calls `execute_query`. Mutating SQL prompts a `Tool.Confirmation`. | +| Explain Query | Runs `EXPLAIN` (or the dialect equivalent). | +| Open Connection in TablePro | Calls `open_connection_window`. | +| Search Query History | Calls `search_query_history`. | + +The extension's AI instructions tell the model to always `describe-table` before generating queries against an unknown schema, and to use `Tool.Confirmation` for any INSERT, UPDATE, DELETE, DROP, ALTER, or TRUNCATE. + +## Preferences + +| Preference | Type | Description | +|------------|------|-------------| +| TablePro App | App picker | Path to TablePro. Default `/Applications/TablePro.app`. | +| API Token | Password | Bearer token. Filled by the pairing command, editable manually. | + +The token is stored in the Keychain via Raycast's password preference type. + +## Troubleshooting + +**TablePro not installed.** The extension shows an "Install TablePro" link to [tablepro.app](https://tablepro.app). + +**TablePro running but MCP not started.** The extension fires `tablepro://integrations/start-mcp` and retries. If it still fails, the message asks you to update TablePro to the latest version. + +**No token paired.** A "Pair with TablePro" call-to-action runs the `pair` command. + +**Token revoked (`401`).** The extension clears the stored token and shows the pair CTA. + +**Connection rejected (`403`).** The connection's `externalAccess` is `blocked` or the token's allowlist excludes it. Open **Settings > Integrations** in TablePro to inspect. + +**Read-only error (`403 Connection is read-only for external clients`).** The connection's `externalAccess` is `readOnly` and the SQL is a write. Either change the connection's external access in TablePro, or run the query in TablePro's editor. + +## Privacy + +The extension reads connection metadata from `~/Library/Application Support/TablePro/connections.json` to build the connection picker without an MCP roundtrip. Passwords are not in that file (they live in the Keychain). The extension never reads or transmits passwords. + +Query results returned by `Run Query` stay in Raycast's process. Raycast does not send them to a server. AI tool calls go through Raycast's AI provider per Raycast's [privacy policy](https://www.raycast.com/privacy). diff --git a/docs/external-api/tokens.mdx b/docs/external-api/tokens.mdx new file mode 100644 index 000000000..e9014a5e7 --- /dev/null +++ b/docs/external-api/tokens.mdx @@ -0,0 +1,137 @@ +--- +title: Tokens +description: Token model, scopes, per-connection allowlists, expiry, and revocation +--- + +# Tokens + +Every external request needs a bearer token. Tokens carry a scope, an optional connection allowlist, and an optional expiry. Tokens are stored hashed (SHA-256 + salt) at `~/Library/Application Support/TablePro/mcp-tokens.json` with `0600` permissions. The plaintext is shown once at creation and never again. + +## Token shape + +```swift +struct MCPAuthToken { + let id: UUID + var name: String + let prefix: String // First 8 chars of plaintext, e.g. "tp_a1b2c3" + let hashedToken: String // SHA-256 + salt of the plaintext + var permissions: TokenPermissions // readOnly, readWrite, fullAccess + var allowedConnectionIds: Set? // nil means all connections + var expiresAt: Date? // nil means never + var isActive: Bool + let createdAt: Date + var lastUsedAt: Date? +} +``` + +The `prefix` is shown in the token list so the user can identify a token without revealing the secret. + +## Scopes + +| Scope | Read schema | SELECT | INSERT/UPDATE/DELETE | DROP/TRUNCATE | UI mutation | +|-------|:-----------:|:------:|:--------------------:|:-------------:|:-----------:| +| `readOnly` | yes | yes | no | no | no | +| `readWrite` | yes | yes | yes | no | yes | +| `fullAccess` | yes | yes | yes | yes (with phrase) | yes | + +UI mutation covers `open_connection_window`, `open_table_tab`, `focus_query_tab`. These open windows and tabs in the running app. + +DROP and TRUNCATE always require an explicit confirmation phrase via `confirm_destructive_operation`, even with `fullAccess`. There is no token scope that bypasses the phrase. + +## Connection allowlist + +Each token can be limited to a subset of connections. + +- `allowedConnectionIds = nil` means all connections. +- `allowedConnectionIds = { uuid1, uuid2 }` means only those. + +A request that targets a connection outside the allowlist returns `403 forbidden` before any per-connection check runs. + +## External access combination + +The effective permission is `MIN(token.scope, connection.externalAccess)`. + +| Token scope | Connection access | Effective | +|-------------|------------------|-----------| +| `readOnly` | `readWrite` | `readOnly` | +| `readWrite` | `readOnly` | `readOnly` | +| `fullAccess` | `readOnly` | `readOnly` | +| `fullAccess` | `readWrite` | `readWrite` (no destructive) | +| `fullAccess` | `blocked` | denied | +| any | `blocked` | denied | + +A `fullAccess` token cannot mutate data on a `readOnly` connection. A token's reach is bounded by both itself and the connection. + +## Creation + +Tokens are created in three ways: + +1. **Pairing flow** (most common). See [Pairing](/external-api/pairing). +2. **Settings UI**. **Settings > Integrations > Authentication**, then **Generate Token**. Pick name, scope, allowlist, expiry. The plaintext is shown once in a reveal sheet. +3. **AppleScript-style URL** is not supported. Tokens are not exposed as a URL scheme action. + +The plaintext format is `tp_`. The first 8 chars are the prefix. + +## Expiry + +Optional. If set, the token stops authenticating at the expiry time. Expired requests return `401 unauthorized` with `message: "Token expired"`. + +Recommended values: + +- `readWrite` and `fullAccess` for human-driven extensions: 90 days. +- `readOnly` for personal use: never. +- CI or automation: 30 days, rotated. + +## Revocation + +**Settings > Integrations > Authentication** lists all tokens with prefix, name, scope, allowlist, last-used time, and expiry. Each row has: + +- **Revoke**: marks the token inactive. Stays in the list with status `Revoked`. Cannot be reactivated. +- **Delete**: removes the row entirely. + +A revoked token returns `401 unauthorized` immediately. The MCP server invalidates any cached session for the token within one second. + +After revoking a token used by an extension, the extension shows an "unauthorized" state on the next call. The user runs the pairing command again to mint a new token. + +## Audit log + +Every authentication, every tool call, every resource read is recorded in `~/Library/Application Support/TablePro/mcp-audit.db` with the token id (not the plaintext). The activity log view in **Settings > Integrations > Activity Log** shows: + +| Field | Example | +|-------|---------| +| Timestamp | 2026-04-26 10:14:22 | +| Token | Raycast on macbook-pro (`tp_a1b2c3`) | +| Category | `query`, `auth`, `access`, `admin` | +| Action | `execute_query`, `pair`, `revoke` | +| Connection | Production (or `-`) | +| Outcome | `success`, `denied`, `error` | + +Entries are kept for 90 days, auto-pruned on app launch. + +## Rate limits + +Per-IP, on failed auth: + +| Failures | Lockout | +|----------|---------| +| 2 | 1 second | +| 3 | 5 seconds | +| 4 | 30 seconds | +| 5+ | 5 minutes | + +A successful auth resets the counter. During lockout the server returns `429 Too Many Requests`. + +## What tokens cannot do + +| Capability | State | +|-----------|------| +| Read connection passwords | no | +| Read SSH keys | no | +| Read license data | no | +| Read app settings | no | +| Read local files outside `~/Library/Application Support/TablePro/` | no | +| Mutate Safe Mode rules | no | +| Mutate other tokens | no | +| Mutate connection records | no | + +The token surface is the MCP tool catalog and the URL scheme. Anything not on those lists is not reachable. diff --git a/docs/external-api/url-scheme.mdx b/docs/external-api/url-scheme.mdx new file mode 100644 index 000000000..4f9b43ff1 --- /dev/null +++ b/docs/external-api/url-scheme.mdx @@ -0,0 +1,204 @@ +--- +title: URL Scheme +description: Every tablepro:// deep link action with parameters and examples +--- + +# URL Scheme + +The `tablepro://` URL scheme drives the TablePro GUI from outside the app. Use it from the shell with `open`, from another app with `NSWorkspace.shared.open(url:)`, or from a Raycast extension with `open()` from `@raycast/api`. + +The scheme covers two kinds of actions: + +- **Navigate**: open a connection, table, or query tab. +- **Pair**: bootstrap an MCP token for an extension. + +Data exchange is not part of the URL scheme. For that, use [MCP](/external-api/mcp-tools). + +## Connection IDs are UUIDs + +Connection paths use the connection's UUID, not its display name. + +``` +tablepro://connect/9f1f0c3e-2e3d-4b14-9c3a-1d2f4ad1f6f1 +``` + +You can copy the URL for any connection from the sidebar context menu: right-click the connection, **Copy Connection Deep Link**. + + +Pre-0.37 builds accepted `tablepro://connect//...` paths. Those paths were removed in 0.37. Bookmarks built against old TablePro versions must be regenerated. Use **Copy Connection Deep Link** to get the new UUID-keyed URL. + + +## Open a connection + +``` +tablepro://connect/ +``` + +Opens the saved connection. If the connection is already open in a window, that window comes to front. If the UUID does not match a saved connection, an error alert appears. + +```bash +open "tablepro://connect/9f1f0c3e-2e3d-4b14-9c3a-1d2f4ad1f6f1" +``` + +## Open a table + +``` +tablepro://connect//table/ +tablepro://connect//database//table/ +tablepro://connect//database//schema//table/ +``` + +The first form opens the table in the connection's current database and schema. The second selects a database first. The third (Postgres-style) selects both. + +Table and schema names with spaces or special characters must be percent-encoded. + +```bash +# Open a table in the current database +open "tablepro://connect/9f1f0c3e-2e3d-4b14-9c3a-1d2f4ad1f6f1/table/users" + +# Open a table in a specific database +open "tablepro://connect/9f1f0c3e-2e3d-4b14-9c3a-1d2f4ad1f6f1/database/analytics/table/events" + +# Postgres: select database and schema +open "tablepro://connect/9f1f0c3e-2e3d-4b14-9c3a-1d2f4ad1f6f1/database/app/schema/reporting/table/daily_events" +``` + +## Run a query + +``` +tablepro://connect//query?sql= +tablepro://connect//query?sql=&token= +``` + +Opens a new query tab with the SQL pre-filled. Without a `token`, TablePro shows a confirmation dialog with the SQL before opening, so the user can verify the query is safe. + +If a valid `token` is provided and the token has `query.write` scope (i.e. `readWrite` or `fullAccess`), the confirmation is skipped. The token is matched against the active connection's `externalAccess` level. A read-only connection rejects any write SQL regardless of token scope. + +```bash +# With confirmation +open "tablepro://connect/9f1f0c3e-2e3d-4b14-9c3a-1d2f4ad1f6f1/query?sql=SELECT%20*%20FROM%20users%20LIMIT%2010" + +# With token, no confirmation +open "tablepro://connect/9f1f0c3e-2e3d-4b14-9c3a-1d2f4ad1f6f1/query?sql=SELECT%20*%20FROM%20users%20LIMIT%2010&token=tp_abc123..." +``` + +| Parameter | Required | Description | +|-----------|----------|-------------| +| `sql` | yes | Percent-encoded SQL. | +| `token` | no | Bearer token. Skips the confirmation dialog when present and valid. | + +## Start pairing + +``` +tablepro://integrations/pair?client=&challenge=&redirect=&scopes=&connection-ids= +``` + +Starts a pairing flow. TablePro presents an approval sheet, the user picks scopes and connections, and TablePro returns a one-time code via the `redirect` URL. + +| Parameter | Required | Description | +|-----------|----------|-------------| +| `client` | yes | Display name shown in the approval sheet, e.g. `Raycast on macbook-pro`. | +| `challenge` | yes | Base64url-encoded SHA-256 hash of the verifier (PKCE). | +| `redirect` | yes | URL to receive the `code` query parameter on success. | +| `scopes` | no | Comma-separated requested scopes: `readOnly`, `readWrite`, `fullAccess`. Defaults to `readOnly`. | +| `connection-ids` | no | Comma-separated UUIDs to preselect in the allowlist. Defaults to all. | + +The user can change scopes and connections in the approval sheet. The query parameters are a request, not a grant. + +Example invocation from a Raycast extension: + +```ts +import { open } from "@raycast/api"; + +const params = new URLSearchParams({ + client: "Raycast on macbook-pro", + challenge: challengeB64Url, + redirect: "raycast://extensions/ngoquocdat/tablepro/pair-callback", + scopes: "readOnly,readWrite", +}); +await open(`tablepro://integrations/pair?${params}`); +``` + +See [Pairing](/external-api/pairing) for the full sequence and the exchange step. + +## Lazy-start the MCP server + +``` +tablepro://integrations/start-mcp +``` + +Starts the MCP server if it is not already running, then returns. Used by the bundled `tablepro-mcp` CLI to bootstrap on cold launch. + +The user does not need to enable MCP in Settings beforehand. The first call starts the server on a free port in the `51000-52000` range and writes a handshake file at `~/Library/Application Support/TablePro/mcp-handshake.json`. + +```bash +open "tablepro://integrations/start-mcp" +``` + +## Import a connection + +``` +tablepro://import?name=&host=&port=

&type=&username=&database= +``` + +Creates a saved connection from query parameters and opens the connection editor for review. A confirmation dialog shows the connection details before adding, so you can reject unexpected imports. The user adds a password before connecting; passwords are never accepted in the URL. + +Required parameters: `name`, `host`, `type`. + +`type` accepts any registered database type name (case-insensitive). Examples: `MySQL`, `PostgreSQL`, `MongoDB`, `Redis`, `ClickHouse`, `Oracle`, `DuckDB`, `Cassandra`. + +```bash +open "tablepro://import?name=Staging&host=db.example.com&port=5432&type=postgresql&username=admin&database=mydb" +``` + +### Core parameters + +| Parameter | Description | +|-----------|-------------| +| `port` | Server port. Defaults to the database type's standard port. | +| `username` | Database username. | +| `database` | Default database name. | +| `color` | Connection color in the sidebar. | +| `tagName` | Tag to assign. | +| `groupName` | Group to place the connection in. | +| `safeModeLevel` | Safe Mode level: `silent`, `alert`, `alertFull`, `safeMode`, `safeModeFull`, or `readOnly`. | +| `aiPolicy` | AI access policy: `useDefault`, `alwaysAllow`, `askEachTime`, or `never`. | + +### SSH parameters + +Set `ssh=1` to enable SSH tunneling. + +| Parameter | Description | +|-----------|-------------| +| `sshHost` | SSH server hostname. | +| `sshPort` | SSH port. Default `22`. | +| `sshUsername` | SSH username. | +| `sshAuthMethod` | `password`, `privateKey`, `agent`, or `keyboardInteractive`. | +| `sshPrivateKeyPath` | Path to private key file. | +| `sshUseSSHConfig` | Set to `1` to read `~/.ssh/config`. | +| `sshAgentSocketPath` | Custom SSH agent socket path. | +| `sshJumpHosts` | JSON array of jump hosts. | +| `sshTotpMode` | TOTP mode for two-factor SSH auth. | + +### SSL parameters + +| Parameter | Description | +|-----------|-------------| +| `sslMode` | `disabled`, `preferred`, `required`, `verify-ca`, or `verify-full`. | +| `sslCaCertPath` | CA certificate file path. | +| `sslClientCertPath` | Client certificate file path. | +| `sslClientKeyPath` | Client key file path. | + +### Plugin-specific fields + +Use the `af_` prefix to pass driver-specific fields. For example, `af_replicaSet=myrs` passes `replicaSet` to the MongoDB plugin. + +## Errors + +Invalid UUIDs, missing connections, or malformed query parameters surface as error alerts. The error message names the failing field. Examples: + +- `Connection not found: 9f1f0c3e-2e3d-4b14-9c3a-1d2f4ad1f6f1` +- `Invalid connection ID format` +- `Missing required parameter: client` + +URL scheme errors are also written to the activity log under the `admin` category with outcome `error`. diff --git a/docs/external-api/versioning.mdx b/docs/external-api/versioning.mdx new file mode 100644 index 000000000..905fab298 --- /dev/null +++ b/docs/external-api/versioning.mdx @@ -0,0 +1,59 @@ +--- +title: Versioning +description: Stability policy for the URL scheme, MCP tools, and resource catalog +--- + +# Versioning + +The External API follows TablePro's semver. The contract is the URL scheme, the MCP tool catalog, the resource list, and the pairing flow. + +## Stability rules + +Within a major version, the External API is **additive only**: + +- New URL scheme actions can be added. +- New MCP tools can be added. +- New tool input fields can be added if they are optional with a sensible default. +- New tool output fields can be added. +- New resources can be added. +- New error codes can be added. + +Within a major version, none of the following will happen: + +- A URL scheme path will not be removed or change meaning. +- A tool will not be removed or renamed. +- A required input field will not be added to an existing tool. +- An output field will not be removed or change type. +- A resource will not be removed. + +## Breaking changes + +Breaking changes ship only at major TablePro version bumps (e.g. 1.x to 2.x). The 0.x series is pre-1.0; we treat minor versions as the release boundary, but only break with explicit notice. + +When a breaking change ships: + +- The next previous version emits a deprecation warning in the activity log on every use of the affected surface. +- The release notes call it out under the `BREAKING` heading in CHANGELOG. +- The deprecated surface continues to work for at least one minor version after the warning is introduced. + +## Deprecation lifecycle + +1. **Announce.** A `Deprecated` note is added to the docs. CHANGELOG mentions the affected surface. +2. **Warn.** The next version logs a deprecation warning to the activity log when the surface is used. The warning names the replacement. +3. **Remove.** A later version removes the surface. CHANGELOG marks it `BREAKING`. + +The warn-to-remove gap is at least one minor version. + +## What is not under the contract + +The following are not part of the External API contract and can change at any time without notice: + +- Internal MCP message routing details, transport framing, and HTTP path layout under `/v1/internal/*`. +- The handshake file format at `~/Library/Application Support/TablePro/mcp-handshake.json`. Use the bundled `tablepro-mcp` CLI rather than parsing it yourself. +- The tokens file format at `~/Library/Application Support/TablePro/mcp-tokens.json`. Tokens are managed via Settings, not the file. +- Audit log file format at `~/Library/Application Support/TablePro/mcp-audit.db`. Read via the activity log view. +- The connections file format at `~/Library/Application Support/TablePro/connections.json`. Treat as best-effort. Schema can shift between minor versions; integrations should fall back to MCP `list_connections`. + +## Reporting issues + +If you find a behavior that contradicts these rules, file an issue at [github.com/TableProApp/TablePro](https://github.com/TableProApp/TablePro/issues). Include the TablePro version, the URL or MCP call, expected and actual behavior, and a snippet of the activity log row if relevant. diff --git a/docs/features/ai-assistant.mdx b/docs/features/ai-assistant.mdx index b5d43de8b..fb773b2d8 100644 --- a/docs/features/ai-assistant.mdx +++ b/docs/features/ai-assistant.mdx @@ -1,6 +1,6 @@ --- title: AI Assistant -description: Built-in AI for SQL: chat, inline suggestions, explain, optimize, fix-error. Multi-provider. +description: "Built-in AI for SQL: chat, inline suggestions, explain, optimize, fix-error. Multi-provider." --- # AI Assistant @@ -191,3 +191,12 @@ Set a per-connection AI policy in the connection form: **Use Default**, **Always alt="Per-connection AI policy" /> + +### External AI clients + +External clients (Raycast, Cursor, Claude Desktop, and other MCP clients) call the same AI tools through the [External API](/external-api). Two per-connection settings gate them: + +- **AI policy** decides whether the connection is reachable by AI clients at all. `Never` blocks every external AI tool call against this connection. +- **External Access** caps the level: `blocked`, `readOnly` (default), or `readWrite`. A token's effective permission is `MIN(token.scope, connection.externalAccess)`. Set this in the connection form's **Advanced** tab. + +See [Tokens](/external-api/tokens) for the scope model. diff --git a/docs/features/connection-sharing.mdx b/docs/features/connection-sharing.mdx index 65c16aeab..9e017f330 100644 --- a/docs/features/connection-sharing.mdx +++ b/docs/features/connection-sharing.mdx @@ -87,12 +87,26 @@ Groups and folders carry over. ## Share via Link -Right-click > **Copy as Import Link**. Paste in Slack, a wiki, or a README. +Two link forms ship with TablePro. Pick the one that matches what you want to share. + +### Copy as Import Link + +Right-click > **Copy as Import Link**. Produces a `tablepro://import?...` URL with host, port, type, and username (no password). Paste in Slack, a wiki, or a README. The recipient opens the link, reviews the prefilled form, adds their own password, and saves. ``` tablepro://import?name=Staging&host=db.example.com&port=5432&type=PostgreSQL&username=admin ``` +### Copy Connection Deep Link + +Right-click > **Copy Connection Deep Link**. Produces a `tablepro://connect/` URL that opens the connection you already have saved. The link refers to your local connection record by UUID, so it only works on a Mac that already has the same connection saved (for example, your other Mac with iCloud Sync, or a teammate who imported the connection). Use this form for bookmarks, Raycast Quicklinks, or shell aliases. + +``` +tablepro://connect/9f1f0c3e-2e3d-4b14-9c3a-1d2f4ad1f6f1 +``` + +See [URL Scheme](/external-api/url-scheme#open-a-connection) for the full path syntax (table targets, query parameters, percent-encoding). + ## Encrypted Export Pro Include passwords in the export, protected by a passphrase (AES-256-GCM). diff --git a/docs/features/deep-links.mdx b/docs/features/deep-links.mdx deleted file mode 100644 index 2588988f8..000000000 --- a/docs/features/deep-links.mdx +++ /dev/null @@ -1,183 +0,0 @@ ---- -title: Deep Links -description: Open connections, tables, and queries from the terminal or other apps using database URLs or tablepro:// links ---- - -# Deep Links - -TablePro supports two URL-based entry points: standard database URLs (`mysql://`, `postgresql://`, etc.) and the custom `tablepro://` scheme. - -## Database URLs - -Open any database connection directly using its standard URL. Works from the terminal, scripts, Alfred, Raycast, or any app that can open URLs. - -### Supported Schemes - -| Scheme | Database | -|--------|----------| -| `mysql://` | MySQL / MariaDB | -| `postgresql://`, `postgres://` | PostgreSQL | -| `mongodb://`, `mongodb+srv://` | MongoDB | -| `redis://`, `rediss://` | Redis | -| `mssql://`, `sqlserver://` | SQL Server | -| `sqlite://` | SQLite (local file path) | -| `redshift://` | Amazon Redshift | -| `oracle://` | Oracle | -| `clickhouse://` | ClickHouse | -| `cassandra://` | Cassandra | -| `scylladb://` | ScyllaDB | -| `duckdb://` | DuckDB | -| `etcd://` | etcd | -| `d1://` | Cloudflare D1 | -| `libsql://` | libSQL / Turso | - -### URL Format - -``` -scheme://[username[:password]@]host[:port][/database][?param=value] -``` - -### Examples - -```bash -# MySQL -open "mysql://root@localhost/mydb" -a TablePro - -# PostgreSQL with port and database -open "postgresql://admin@db.example.com:5432/analytics" -a TablePro - -# MongoDB -open "mongodb://localhost:27017/mydb" -a TablePro - -# Redis -open "redis://localhost:6379" -a TablePro - -# SQLite (local file) -open "sqlite:///Users/me/data/app.db" -a TablePro -``` - -TablePro checks if a saved connection matches the URL. If found, it reuses that connection. If the connection is already open, the existing window comes to front. Otherwise, a temporary connection is created. - -### Query Parameters - -Pass additional options as query parameters: - -| Parameter | Description | -|-----------|-------------| -| `table` | Open a specific table after connecting | -| `column`, `operation`, `value` | Apply a filter on the opened table | -| `condition` | Apply a raw SQL WHERE condition | -| `schema` | Switch to a specific schema (PostgreSQL) or database (MySQL) after connecting | - -```bash -# Open a table with a filter -open "mysql://root@localhost/mydb?table=users&column=status&operation==&value=active" -a TablePro - -# Open with a raw SQL condition -open "postgresql://admin@localhost/mydb?table=orders&condition=total%20%3E%20100" -a TablePro - -# Switch schema on connect -open "postgresql://admin@localhost/mydb?schema=reporting" -a TablePro -``` - - -Passwords can be included in URLs (`mysql://user:pass@host/db`), but avoid sharing URLs that contain passwords. Prefer saved connections with Keychain-stored passwords. - - -## Custom Deep Links (tablepro://) - -## URL Patterns - -### Open a Connection - -``` -tablepro://connect/{name} -``` - -Opens the saved connection matching `{name}`. If no match is found, an error alert appears. - -```bash -open "tablepro://connect/Production" -``` - -### Open a Table - -``` -tablepro://connect/{name}/table/{table} -tablepro://connect/{name}/database/{db}/table/{table} -``` - -Connects and opens the specified table. The second form targets a specific database on the connection. - -```bash -open "tablepro://connect/Production/table/users" -open "tablepro://connect/Production/database/analytics/table/events" -``` - -### Open a Query - -``` -tablepro://connect/{name}/query?sql={encoded_sql} -``` - -Connects and opens a new query tab with the SQL pre-filled. The SQL must be percent-encoded. A confirmation dialog shows the SQL before opening, so you can verify the query is safe. - -```bash -open "tablepro://connect/Production/query?sql=SELECT%20*%20FROM%20users%20LIMIT%2010" -``` - -### Import a Connection - -``` -tablepro://import?name={n}&host={h}&port={p}&type={t}&username={u}&database={db} -``` - -Creates a new saved connection and opens the connection form for review. A confirmation dialog shows the connection details before adding, so you can reject unexpected imports. You can add a password before connecting. - -```bash -open "tablepro://import?name=Staging&host=db.example.com&port=5432&type=postgresql&username=admin&database=mydb" -``` - -**Required parameters:** `name`, `host`, `type` - -**Core parameters:** - -| Parameter | Description | -|-----------|-------------| -| `port` | Server port (defaults to the database type's standard port) | -| `username` | Database username | -| `database` | Default database name | -| `color` | Connection color in the sidebar | -| `tagName` | Tag to assign | -| `groupName` | Group to place the connection in | -| `safeModeLevel` | Safe mode level (e.g., `silent`, `alert`, `readOnly`) | -| `aiPolicy` | MCP AI access policy | - -**SSH parameters** (set `ssh=1` to enable): - -| Parameter | Description | -|-----------|-------------| -| `sshHost` | SSH server hostname | -| `sshPort` | SSH port (default 22) | -| `sshUsername` | SSH username | -| `sshAuthMethod` | `password`, `privateKey`, `agent`, or `keyboardInteractive` | -| `sshPrivateKeyPath` | Path to private key file | -| `sshUseSSHConfig` | Set to `1` to read `~/.ssh/config` | -| `sshAgentSocketPath` | Custom SSH agent socket path | -| `sshJumpHosts` | JSON array of jump hosts | -| `sshTotpMode` | TOTP mode for two-factor SSH auth | - -**SSL parameters:** - -| Parameter | Description | -|-----------|-------------| -| `sslMode` | SSL mode (e.g., `require`, `verify-ca`, `verify-full`) | -| `sslCaCertPath` | CA certificate file path | -| `sslClientCertPath` | Client certificate file path | -| `sslClientKeyPath` | Client key file path | - -**Plugin-specific fields:** Use the `af_` prefix for additional fields specific to a database plugin. For example, `af_replicaSet=myrs` passes `replicaSet` to the plugin. - -Supported `type` values: any registered database type name (case-insensitive). Examples: `MySQL`, `PostgreSQL`, `MongoDB`, `Redis`, `ClickHouse`, `Oracle`, `DuckDB`, `Cassandra`. - - diff --git a/docs/features/keyboard-shortcuts.mdx b/docs/features/keyboard-shortcuts.mdx index fdddcbeed..b4877fc81 100644 --- a/docs/features/keyboard-shortcuts.mdx +++ b/docs/features/keyboard-shortcuts.mdx @@ -382,3 +382,13 @@ Click **Reset to Defaults** to restore all shortcuts to their original values. Tab switching shortcuts (`Cmd+1` through `Cmd+9`) follow standard macOS convention and cannot be customized. +## Outside The App + +TablePro can also be driven from outside the running app. None of these are macOS keyboard shortcuts in the strict sense, but they cover the same fast-navigation use cases. + +| Entry point | Use | +|-------------|-----| +| [Raycast extension](/external-api/raycast) | Trigger commands from Raycast's own hotkey: search connections, open tables, run queries, search query history, focus tabs. | +| [`tablepro://` URL scheme](/external-api/url-scheme) | `open tablepro://...` from the terminal, browser, or another app to open a connection, table, or query tab. Bind in Raycast Quicklinks, Alfred, or Shortcuts.app. | +| [MCP clients](/external-api/mcp-clients) | Claude Desktop, Cursor, Claude Code, Cline, Continue, Zed, Windsurf, Goose. Each client decides its own hotkey for invoking AI; tools call back into TablePro. | + diff --git a/docs/features/mcp.mdx b/docs/features/mcp.mdx index 8e24e5ed4..16f43f627 100644 --- a/docs/features/mcp.mdx +++ b/docs/features/mcp.mdx @@ -1,15 +1,42 @@ --- title: MCP Server -description: Built-in Model Context Protocol server for AI tool integration +description: Built-in Model Context Protocol server that exposes TablePro to AI clients --- # MCP Server -TablePro includes a built-in [Model Context Protocol](https://modelcontextprotocol.io) (MCP) server that lets AI tools query your databases through TablePro's saved connections. Runs locally by default. Supports remote access with token auth and TLS. +TablePro includes a built-in [Model Context Protocol](https://modelcontextprotocol.io) (MCP) server that lets AI clients query your databases through TablePro's saved connections. The MCP server is one of three pillars of the [External API](/external-api), alongside the URL scheme and the pairing flow. -## Enabling the Server +This page covers the in-app **Settings > Integrations** UI. For protocol details, see the External API section. -Open **Settings > MCP** and toggle **Enable MCP Server**. Default port is `23508`. + + + JSON-RPC tools, input and output schemas. + + + Read-only resources for connection metadata, schema, and history. + + + Scopes, allowlists, expiry, revocation. + + + One-click flow to issue a token to an extension. + + + Stdio MCP setup for Claude Desktop, Claude Code, Cursor, Cline, Continue, Zed, Windsurf, Goose, and custom clients. + + + Reference extension that uses URL scheme, MCP, and pairing. + + + +## Enabling the server + +The MCP server lazy-starts on first use. Pairing an extension or hitting `tablepro://integrations/start-mcp` from the bundled `tablepro-mcp` CLI starts the server on demand. You do not have to flip a toggle in Settings beforehand. + +When the server starts, it picks a free port in the `51000-52000` range and writes a handshake file at `~/Library/Application Support/TablePro/mcp-handshake.json`. The `tablepro-mcp` CLI and the Raycast extension read this file to find the port; you do not need to configure it manually. + +To start it manually or keep it running between launches, open **Settings > Integrations** and toggle **Enable MCP Server**. MCP** and toggle **Enable MCP Server**. Default port is `23508 /> -Settings: - -- **Port** - default 23508 -- **Default row limit** - default 500 -- **Maximum row limit** - default 10,000 -- **Query timeout** - default 30 seconds -- **Log MCP queries in history** - show MCP queries in Query History -- **Authentication** - token auth with permission tiers -- **Remote access** - accept connections from other machines - -## Client Configuration - -### Auto-Setup (recommended) - -In **Settings > MCP**, click the setup button for your client. TablePro writes the config for you: +Settings on the same tab: -- **Setup for Claude Code** - uses the stdio bridge, no token needed -- **Setup for Claude Desktop** - writes to `claude_desktop_config.json` -- **Setup for Cursor** - writes to `~/.cursor/mcp.json` +- **Default row limit** (default 500) +- **Maximum row limit** (default 10,000) +- **Query timeout** (default 30 seconds) +- **Log MCP queries in history** (show MCP queries in Query History) -### Manual Setup +## MCP setup snippets -Add to your client's MCP config: +**Settings > Integrations > MCP Setup** writes the config for popular clients in one click: -```json -{ - "mcpServers": { - "tablepro": { - "url": "http://127.0.0.1:23508/mcp" - } - } -} -``` - -With authentication: - -```json -{ - "mcpServers": { - "tablepro": { - "url": "http://127.0.0.1:23508/mcp", - "headers": { - "Authorization": "Bearer tp_your_token_here" - } - } - } -} -``` - -**Claude Code** (HTTP): +- **Setup for Claude Code** uses the stdio bridge, no token needed. +- **Setup for Claude Desktop** writes to `~/Library/Application Support/Claude/claude_desktop_config.json`. +- **Setup for Cursor** writes to `~/.cursor/mcp.json`. -```bash -claude mcp add tablepro --transport http http://127.0.0.1:23508/mcp -``` - -With auth: - -```bash -claude mcp add tablepro --transport http --header "Authorization: Bearer tp_your_token" https://192.168.1.x:23508/mcp -``` - -## Local Setup (Stdio Bridge) - -TablePro bundles a CLI at `Contents/MacOS/mcp-server` that connects to the running app via a local handshake file. No tokens or network config needed. - -Click **Setup for Claude Code** in Settings, or configure manually: - -```json -{ - "mcpServers": { - "tablepro": { - "command": "/Applications/TablePro.app/Contents/MacOS/mcp-server" - } - } -} -``` - -```bash -claude mcp add tablepro -- /Applications/TablePro.app/Contents/MacOS/mcp-server -``` - -TablePro must be running for the bridge to work. +For manual configuration, see [MCP Clients](/external-api/mcp-clients). The HTTP transport requires a token; see [Tokens](/external-api/tokens). ## Authentication -When enabled, every request needs a `Authorization: Bearer ` header. - -### Generating Tokens - -**Settings > MCP > Authentication > Generate Token**. Each token has: - -- **Name** - label like "Claude Code on VPS" or "CI server" -- **Permission tier** - Read Only, Read & Write, or Full Access -- **Connection access** - all connections or a specific allowlist -- **Expiry** - optional, default never - -You can create multiple tokens with different permissions. +Tokens are managed under **Settings > Integrations > Authentication**. The pairing flow, scopes, allowlists, expiry, and revocation are documented in [Tokens](/external-api/tokens). -### Permission Tiers +The activity log under **Settings > Integrations > Activity Log** shows every authentication, tool call, and resource read with the token id, category, action, connection, and outcome. Entries are kept for 90 days. -| Tier | What it can do | -|------|----------------| -| Read Only | Schema browsing, SELECT, SHOW, EXPLAIN, query history | -| Read & Write | Above + INSERT, UPDATE, DELETE, data export | -| Full Access | Above + DROP, TRUNCATE via `confirm_destructive_operation` | +## Remote access -Wrong tier returns 403. +The MCP server is localhost-only by default. Toggle **Remote access** under **Settings > Integrations > Network** to accept connections from other machines. -### Connection Allowlist +Enabling remote access automatically requires authentication and TLS. -Each token can be restricted to specific connections. Requests to connections outside the allowlist get 403. +### Connection options -### Rate Limiting - -Failed auth triggers escalating lockouts per IP: - -| Failures | Lockout | -|----------|---------| -| 2 | 1 second | -| 3 | 5 seconds | -| 4 | 30 seconds | -| 5+ | 5 minutes | - -Success resets the counter. During lockout: 429 Too Many Requests. - -## Remote Access - -Default: localhost only. Enable remote access in **Settings > MCP > Network** to accept connections from other machines. - -This automatically enables TLS and requires authentication. - -### How to Connect - -**Tailscale** (easiest): install on both machines, connect via Tailscale IP: +**Tailscale** (recommended): install on both machines and connect via the Tailscale IP. ``` -https://100.x.y.z:23508/mcp +https://100.x.y.z:/mcp ``` -**SSH tunnel**: forward the port, no TLS trust needed: +**SSH tunnel**: forward the port. No TLS trust setup needed because the connection stays local on the remote side. ```bash -ssh -R 23508:127.0.0.1:23508 user@remote-server +ssh -R :127.0.0.1: user@remote-server ``` -Remote side connects to `http://127.0.0.1:23508/mcp` through the tunnel. +The remote side connects to `http://127.0.0.1:/mcp` through the tunnel. -**Direct LAN**: connect using the Mac's IP: +**Direct LAN**: connect using the Mac's IP. The client must trust the TLS certificate (see below). ``` -https://192.168.1.x:23508/mcp +https://192.168.1.x:/mcp ``` -Client must trust the TLS certificate (see below). - macOS firewall allows TablePro automatically (Developer ID signed). ## TLS -Auto-generated self-signed certificate when remote access is enabled. Valid 1 year. +When remote access is enabled, TablePro generates a self-signed certificate valid for 1 year. -- SHA-256 fingerprint shown in **Settings > MCP > Network** -- **Copy Certificate (PEM)** exports the cert for client trust stores -- **Regenerate** creates a new cert (invalidates existing trust) +- The SHA-256 fingerprint shows in **Settings > Integrations > Network**. +- **Copy Certificate (PEM)** exports the certificate for client trust stores. +- **Regenerate** creates a new certificate (invalidates existing trust). Connect with PEM: ```bash -curl --cacert tablepro-mcp.pem https://192.168.1.x:23508/mcp +curl --cacert tablepro-mcp.pem https://192.168.1.x:/mcp ``` Connect with fingerprint pinning: ```bash -curl --pinnedpubkey "sha256//FINGERPRINT" https://192.168.1.x:23508/mcp +curl --pinnedpubkey "sha256//FINGERPRINT" https://192.168.1.x:/mcp ``` -## Available Tools - -### Connection tools - -| Tool | Description | -|------|-------------| -| `list_connections` | List all saved connections with status | -| `connect` | Connect to a saved database | -| `disconnect` | Disconnect from a database | -| `get_connection_status` | Connection details (version, uptime) | - -### Schema tools - -| Tool | Parameters | Description | -|------|-----------|-------------| -| `list_databases` | `connection_id` | List databases | -| `list_schemas` | `connection_id`, `database`? | List schemas | -| `list_tables` | `connection_id`, `database`?, `schema`?, `include_row_counts`? | List tables and views | -| `describe_table` | `connection_id`, `table_name`, `schema`? | Columns, indexes, foreign keys, DDL | -| `get_table_ddl` | `connection_id`, `table_name`, `schema`? | CREATE TABLE statement | - -### Query tools - -| Tool | Parameters | Description | -|------|-----------|-------------| -| `execute_query` | `connection_id`, `query`, `max_rows`?, `timeout_seconds`?, `database`?, `schema`? | Run a query (Safe Mode applies) | -| `confirm_destructive_operation` | `connection_id`, `query`, `confirmation_phrase` | Run DROP/TRUNCATE after confirmation | -| `export_data` | `connection_id`, `format` (csv/json/sql), `tables`?, `query`?, `max_rows`? | Export data. Default 50,000 rows, max 100,000. | -| `switch_database` | `connection_id`, `database` | Switch active database | -| `switch_schema` | `connection_id`, `schema` | Switch active schema | - -`?` = optional. - -### Resources - -| Resource | Description | -|----------|-------------| -| `tablepro://connections` | All saved connections with metadata | -| `tablepro://connections/{id}/schema` | Schema for a connected database (max 100 tables) | -| `tablepro://connections/{id}/history` | Recent query history | - -History parameters: `?limit=N` (1-500), `?search=text`, `?date_filter=today|thisWeek|thisMonth`. - -Resources respect token connection allowlists. - -## Security - -All safety controls run server-side. AI clients cannot bypass them. - -### Network - -- Localhost only by default -- Remote access requires both auth and TLS -- Origin validation prevents DNS rebinding (localhost, 127.0.0.1, ::1) - -### Connection Access - -Each connection has an AI Policy (Always Allow / Ask Each Time / Never). Set per connection or change the default in **Settings > AI**. - -Token allowlist is checked before AI Policy. Blocked at token level = rejected before the dialog. - -### Query Safety - -| Tier | Examples | Behavior | -|------|----------|----------| -| Safe | SELECT, SHOW, EXPLAIN | Runs immediately | -| Write | INSERT, UPDATE, DELETE | Subject to [Safe Mode](/features/safe-mode) | -| Destructive | DROP, TRUNCATE, ALTER...DROP | Blocked. Use `confirm_destructive_operation` with phrase "I understand this is irreversible" | - -### Limits +## Origin and DNS rebinding protection -- Rows: 500 default, 10,000 max per query -- Export: 50,000 default, 100,000 max -- Timeout: 30s default, 300s max -- Single statements only -- Query size: 100 KB -- HTTP body: 10 MB -- Sessions: 10 max, 5 min idle timeout +The server validates the `Origin` header against `localhost`, `127.0.0.1`, and `::1`. Browsers and DNS-rebinding tricks cannot reach the API even when remote access is off. -### Data Access +## What the server cannot access -Can access: connection metadata, schemas, query results, history. +| Capability | State | +|-----------|-------| +| Read connection passwords | no | +| Read SSH keys | no | +| Read license data | no | +| Read app settings | no | +| Read local files outside `~/Library/Application Support/TablePro/` | no | +| Mutate Safe Mode rules | no | -Cannot access: passwords, SSH keys, license data, app settings, local files. +The reachable surface is the [tool catalog](/external-api/mcp-tools) and the [URL scheme](/external-api/url-scheme). ## Troubleshooting -**Port conflict**: Change port in **Settings > MCP**. +**Port conflict**: the server picks a different free port from the `51000-52000` range on next launch. The handshake file is rewritten. -**"Server not fully initialized"**: Restart MCP server from Settings. If it persists, relaunch TablePro. +**"Server not fully initialized"**: restart the MCP server from Settings. If it persists, relaunch TablePro. -**App must be running**: MCP server only runs while TablePro is open. +**App must be running**: the MCP server only runs while TablePro is open. The stdio bridge fires `tablepro://integrations/start-mcp` to launch the app on cold start. -**Connection refused**: Check server is running (green indicator). Verify the URL and port match. +**Connection refused**: check the green status indicator in **Settings > Integrations**. Verify the URL and port match the handshake file. -**401 Unauthorized**: Auth is enabled. Add a Bearer token to your config. Generate one in **Settings > MCP > Authentication**. +**`401 Unauthorized`**: the token is missing, expired, or revoked. Generate a new one in **Settings > Integrations > Authentication**, or run the pair command in your extension. -**403 Forbidden**: Token lacks permission for this operation, or connection not in the token's allowlist. +**`403 Forbidden`**: the token's allowlist excludes the connection, the connection's `externalAccess` is `blocked`, or the SQL is a write against a `readOnly` connection. -**Certificate trust error**: Export the PEM from **Settings > MCP > Network** and add it to your client's trust store. Or use fingerprint pinning. +**Certificate trust error**: export the PEM from **Settings > Integrations > Network** and add it to your client's trust store, or use fingerprint pinning. -**429 Too Many Requests**: Too many failed auth attempts. Wait up to 5 minutes. The lockout resets after a successful auth. +**`429 Too Many Requests`**: too many failed auth attempts. The lockout escalates to 5 minutes and resets on the next successful auth. diff --git a/docs/features/overview.mdx b/docs/features/overview.mdx index 78ff446fd..6041c2fef 100644 --- a/docs/features/overview.mdx +++ b/docs/features/overview.mdx @@ -36,6 +36,12 @@ Every feature in TablePro, grouped so you can jump straight to what you need. Expose TablePro to Claude and other MCP clients. + + URL scheme, MCP, and pairing for Raycast, Cursor, Claude Desktop. + + + Search connections, run queries, focus tabs from Raycast. + ## Views @@ -90,8 +96,8 @@ Every feature in TablePro, grouped so you can jump straight to what you need. Full shortcut reference. - - `tablepro://` URLs to open connections and tables. + + `tablepro://` URLs to open connections, tables, and queries. diff --git a/docs/features/query-history.mdx b/docs/features/query-history.mdx index 98897a0ee..3da9e7a8a 100644 --- a/docs/features/query-history.mdx +++ b/docs/features/query-history.mdx @@ -143,3 +143,9 @@ Configure retention in **Settings** > **General**: | **Auto Cleanup** | On | Automatically remove old entries | To clear all history, open **Settings** > **General** and click **Clear All History**. For a specific connection, right-click it in the sidebar and select **Clear History**. + +## Search From External Clients + +History is searchable from MCP clients. The `search_query_history` tool returns matching entries with timestamp, connection, query text, and outcome. The Raycast extension wraps this in a **Search Query History** command. + +See [`search_query_history`](/external-api/mcp-tools) and [Raycast commands](/external-api/raycast#commands). diff --git a/docs/features/safe-mode.mdx b/docs/features/safe-mode.mdx index 31591bb8e..28db4e788 100644 --- a/docs/features/safe-mode.mdx +++ b/docs/features/safe-mode.mdx @@ -71,3 +71,15 @@ The current Safe Mode level appears as a badge in the toolbar (orange for Alert Safe Mode gates apply to query execution, saving cell edits, table operations, and sidebar changes. +## External Clients + +Safe Mode runs inside the app on every query you execute. External clients (Raycast, Cursor, Claude Desktop, and other MCP clients) hit a separate gate first. + +A write request from an external client clears three locks in this order: + +1. **External Access** (per-connection, `blocked` / `readOnly` / `readWrite`). Set in the connection form's **Advanced** tab. A `readOnly` connection rejects any write before the request reaches the database. +2. **Token scope** (per-integration, `readOnly` / `readWrite` / `fullAccess`). Issued by the [pairing flow](/external-api/pairing) and bounded by External Access: effective permission is `MIN(token.scope, connection.externalAccess)`. +3. **Safe Mode** (per-query). The same rules on this page apply once the request has been routed to the connection. Touch ID prompts and confirmation dialogs still appear, even for queries originating from an external client. + +DROP and TRUNCATE always need an explicit confirmation phrase via the `confirm_destructive_operation` tool, regardless of token scope. See [External API security model](/external-api#security-model). + diff --git a/docs/features/tabs.mdx b/docs/features/tabs.mdx index 242aa576f..8e89b349d 100644 --- a/docs/features/tabs.mdx +++ b/docs/features/tabs.mdx @@ -211,3 +211,14 @@ Right-click any tab: alt="Tab context menu" /> + +## From External Clients + +Raycast, Cursor, Claude Desktop, and other MCP clients can list and focus tabs across windows. Four MCP tools cover the surface: + +- `list_recent_tabs` enumerates open tabs across every window. +- `focus_query_tab` brings an existing tab to the front by id. +- `open_connection_window` opens a saved connection. +- `open_table_tab` opens a specific table. + +The Raycast extension's [Recent Tabs command](/external-api/raycast#commands) wraps these tools. See [MCP Tools](/external-api/mcp-tools) for input and output schemas. diff --git a/docs/index.mdx b/docs/index.mdx index de7130abc..4d851a42d 100644 --- a/docs/index.mdx +++ b/docs/index.mdx @@ -49,6 +49,7 @@ Native macOS client for every database. Built on SwiftUI and AppKit. Ships under **AI Assistant**: Chat, inline suggestions, and Explain/Optimize via GitHub Copilot, Claude, OpenAI, or Ollama. **Terminal**: Built-in database CLI (mysql, psql, redis-cli, mongosh, etc.) with SSH and Docker support. **MCP Server**: Expose your connections to AI tools via the Model Context Protocol. +**External API**: Drive TablePro from Raycast, Cursor, Claude Desktop, and other MCP clients. URL scheme, MCP, and one-click pairing. **Plugin System**: 8 built-in drivers, 10 more via the plugin registry. Third-party plugins supported. **iCloud Sync**: Sync connections, groups, tags, settings, and SSH profiles across Macs. **Safe Mode**: 6 per-connection protection levels from silent alerts to Touch ID and read-only. diff --git a/scripts/build-release.sh b/scripts/build-release.sh index 7fe23e013..c1b4a6a94 100755 --- a/scripts/build-release.sh +++ b/scripts/build-release.sh @@ -598,6 +598,14 @@ build_for_arch() { exit 1 fi + # Verify embedded MCP stdio bridge made it into the bundle + MCP_CLI_PATH="$BUILD_DIR/$OUTPUT_NAME/Contents/MacOS/tablepro-mcp" + if [ ! -x "$MCP_CLI_PATH" ]; then + echo "❌ FATAL: tablepro-mcp helper missing from $MCP_CLI_PATH" + echo "Check the mcp-server target's Copy Files build phase on the TablePro target." + exit 1 + fi + # Get size SIZE=$(ls -lh "$BINARY_PATH" 2>/dev/null | awk '{print $5}') if [ -z "$SIZE" ]; then