From 8743051986620624370558996e0a8019a2bc3897 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ng=C3=B4=20Qu=E1=BB=91c=20=C4=90=E1=BA=A1t?= Date: Fri, 20 Mar 2026 12:03:31 +0700 Subject: [PATCH 01/11] feat: add reusable SSH tunnel profiles (#381) --- CHANGELOG.md | 5 + TablePro.xcodeproj/project.pbxproj | 172 ++++++++++++++++-- TablePro/Core/Database/DatabaseManager.swift | 56 ++++-- TablePro/Core/Storage/ConnectionStorage.swift | 9 +- TablePro/Core/Storage/SSHProfileStorage.swift | 122 +++++++++++++ .../Connection/DatabaseConnection.swift | 4 + TablePro/Models/Connection/SSHProfile.swift | 91 +++++++++ .../Views/Connection/ConnectionFormView.swift | 94 ++++++++-- 8 files changed, 504 insertions(+), 49 deletions(-) create mode 100644 TablePro/Core/Storage/SSHProfileStorage.swift create mode 100644 TablePro/Models/Connection/SSHProfile.swift diff --git a/CHANGELOG.md b/CHANGELOG.md index 121c26fc3..da8da07f0 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,11 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +### Added + +- Reusable SSH tunnel profiles: save SSH configurations once and select them across multiple connections +- Amazon DynamoDB database support with PartiQL queries, AWS IAM/Profile/SSO authentication, GSI/LSI browsing, table scanning, capacity display, and DynamoDB Local support + ### Fixed - etcd connection failing with 404 when gRPC gateway uses a different API prefix (auto-detects `/v3/`, `/v3beta/`, `/v3alpha/`) diff --git a/TablePro.xcodeproj/project.pbxproj b/TablePro.xcodeproj/project.pbxproj index b40f54893..f54d2befe 100644 --- a/TablePro.xcodeproj/project.pbxproj +++ b/TablePro.xcodeproj/project.pbxproj @@ -30,12 +30,20 @@ 5A86E000A00000000 /* TableProPluginKit.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 5A860000100000000 /* TableProPluginKit.framework */; }; 5A86F000A00000000 /* TableProPluginKit.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 5A860000100000000 /* TableProPluginKit.framework */; }; 5A87A000A00000000 /* TableProPluginKit.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 5A860000100000000 /* TableProPluginKit.framework */; }; - 5AE4F4902F6BC0640097AC5B /* TableProPluginKit.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 5A860000100000000 /* TableProPluginKit.framework */; }; 5ACE00012F4F000000000004 /* CodeEditSourceEditor in Frameworks */ = {isa = PBXBuildFile; productRef = 5ACE00012F4F000000000002 /* CodeEditSourceEditor */; }; 5ACE00012F4F000000000005 /* CodeEditLanguages in Frameworks */ = {isa = PBXBuildFile; productRef = 5ACE00012F4F000000000003 /* CodeEditLanguages */; }; 5ACE00012F4F000000000006 /* CodeEditTextView in Frameworks */ = {isa = PBXBuildFile; productRef = 5ACE00012F4F000000000007 /* CodeEditTextView */; }; 5ACE00012F4F00000000000A /* Sparkle in Frameworks */ = {isa = PBXBuildFile; productRef = 5ACE00012F4F000000000009 /* Sparkle */; }; 5ACE00012F4F00000000000D /* MarkdownUI in Frameworks */ = {isa = PBXBuildFile; productRef = 5ACE00012F4F00000000000C /* MarkdownUI */; }; + 5ADDB0010000000000000001 /* DynamoDBConnection.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5ADDB0020000000000000001 /* DynamoDBConnection.swift */; }; + 5ADDB0010000000000000002 /* DynamoDBItemFlattener.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5ADDB0020000000000000002 /* DynamoDBItemFlattener.swift */; }; + 5ADDB0010000000000000003 /* DynamoDBPartiQLParser.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5ADDB0020000000000000003 /* DynamoDBPartiQLParser.swift */; }; + 5ADDB0010000000000000004 /* DynamoDBPlugin.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5ADDB0020000000000000004 /* DynamoDBPlugin.swift */; }; + 5ADDB0010000000000000005 /* DynamoDBPluginDriver.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5ADDB0020000000000000005 /* DynamoDBPluginDriver.swift */; }; + 5ADDB0010000000000000006 /* DynamoDBQueryBuilder.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5ADDB0020000000000000006 /* DynamoDBQueryBuilder.swift */; }; + 5ADDB0010000000000000007 /* DynamoDBStatementGenerator.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5ADDB0020000000000000007 /* DynamoDBStatementGenerator.swift */; }; + 5ADDB0010000000000000008 /* TableProPluginKit.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 5A860000100000000 /* TableProPluginKit.framework */; }; + 5AE4F4902F6BC0640097AC5B /* TableProPluginKit.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 5A860000100000000 /* TableProPluginKit.framework */; }; 5AEA8B422F6808CA0040461A /* EtcdStatementGenerator.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5AEA8B402F6808CA0040461A /* EtcdStatementGenerator.swift */; }; 5AEA8B432F6808CA0040461A /* EtcdPlugin.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5AEA8B3D2F6808CA0040461A /* EtcdPlugin.swift */; }; 5AEA8B442F6808CA0040461A /* EtcdCommandParser.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5AEA8B3B2F6808CA0040461A /* EtcdCommandParser.swift */; }; @@ -176,6 +184,14 @@ 5A86F000100000000 /* SQLImport.tableplugin */ = {isa = PBXFileReference; explicitFileType = wrapper.cfbundle; includeInIndex = 0; path = SQLImport.tableplugin; sourceTree = BUILT_PRODUCTS_DIR; }; 5A87A000100000000 /* CassandraDriver.tableplugin */ = {isa = PBXFileReference; explicitFileType = wrapper.cfbundle; includeInIndex = 0; path = CassandraDriver.tableplugin; sourceTree = BUILT_PRODUCTS_DIR; }; 5ABCC5A72F43856700EAF3FC /* TableProTests.xctest */ = {isa = PBXFileReference; explicitFileType = wrapper.cfbundle; includeInIndex = 0; path = TableProTests.xctest; sourceTree = BUILT_PRODUCTS_DIR; }; + 5ADDB0020000000000000001 /* DynamoDBConnection.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DynamoDBConnection.swift; sourceTree = ""; }; + 5ADDB0020000000000000002 /* DynamoDBItemFlattener.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DynamoDBItemFlattener.swift; sourceTree = ""; }; + 5ADDB0020000000000000003 /* DynamoDBPartiQLParser.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DynamoDBPartiQLParser.swift; sourceTree = ""; }; + 5ADDB0020000000000000004 /* DynamoDBPlugin.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DynamoDBPlugin.swift; sourceTree = ""; }; + 5ADDB0020000000000000005 /* DynamoDBPluginDriver.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DynamoDBPluginDriver.swift; sourceTree = ""; }; + 5ADDB0020000000000000006 /* DynamoDBQueryBuilder.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DynamoDBQueryBuilder.swift; sourceTree = ""; }; + 5ADDB0020000000000000007 /* DynamoDBStatementGenerator.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DynamoDBStatementGenerator.swift; sourceTree = ""; }; + 5AE460EC2F6CEDB70097AC5B /* DynamoDBDriverPlugin.tableplugin */ = {isa = PBXFileReference; explicitFileType = wrapper.cfbundle; includeInIndex = 0; path = DynamoDBDriverPlugin.tableplugin; sourceTree = BUILT_PRODUCTS_DIR; }; 5AE4F4742F6BC0640097AC5B /* CloudflareD1DriverPlugin.tableplugin */ = {isa = PBXFileReference; explicitFileType = wrapper.cfbundle; includeInIndex = 0; path = CloudflareD1DriverPlugin.tableplugin; sourceTree = BUILT_PRODUCTS_DIR; }; 5AEA8B2A2F6808270040461A /* EtcdDriverPlugin.tableplugin */ = {isa = PBXFileReference; explicitFileType = wrapper.cfbundle; includeInIndex = 0; path = EtcdDriverPlugin.tableplugin; sourceTree = BUILT_PRODUCTS_DIR; }; 5AEA8B3B2F6808CA0040461A /* EtcdCommandParser.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = EtcdCommandParser.swift; sourceTree = ""; }; @@ -209,13 +225,6 @@ ); target = 5A862000000000000 /* SQLiteDriver */; }; - 5AE4F4802F6BC0640097AC5B /* Exceptions for "Plugins/CloudflareD1DriverPlugin" folder in "CloudflareD1DriverPlugin" target */ = { - isa = PBXFileSystemSynchronizedBuildFileExceptionSet; - membershipExceptions = ( - Info.plist, - ); - target = 5AE4F4732F6BC0640097AC5B /* CloudflareD1DriverPlugin */; - }; 5A863000900000000 /* Exceptions for "Plugins/ClickHouseDriverPlugin" folder in "ClickHouseDriver" target */ = { isa = PBXFileSystemSynchronizedBuildFileExceptionSet; membershipExceptions = ( @@ -314,6 +323,13 @@ ); target = 5A87A000000000000 /* CassandraDriver */; }; + 5AE4F4802F6BC0640097AC5B /* Exceptions for "Plugins/CloudflareD1DriverPlugin" folder in "CloudflareD1DriverPlugin" target */ = { + isa = PBXFileSystemSynchronizedBuildFileExceptionSet; + membershipExceptions = ( + Info.plist, + ); + target = 5AE4F4732F6BC0640097AC5B /* CloudflareD1DriverPlugin */; + }; 5AF312BE2F36FF7500E86682 /* Exceptions for "TablePro" folder in "TablePro" target */ = { isa = PBXFileSystemSynchronizedBuildFileExceptionSet; membershipExceptions = ( @@ -356,14 +372,6 @@ path = Plugins/SQLiteDriverPlugin; sourceTree = ""; }; - 5AE4F4812F6BC0640097AC5B /* Plugins/CloudflareD1DriverPlugin */ = { - isa = PBXFileSystemSynchronizedRootGroup; - exceptions = ( - 5AE4F4802F6BC0640097AC5B /* Exceptions for "Plugins/CloudflareD1DriverPlugin" folder in "CloudflareD1DriverPlugin" target */, - ); - path = Plugins/CloudflareD1DriverPlugin; - sourceTree = ""; - }; 5A863000500000000 /* Plugins/ClickHouseDriverPlugin */ = { isa = PBXFileSystemSynchronizedRootGroup; exceptions = ( @@ -481,6 +489,14 @@ path = TableProTests; sourceTree = ""; }; + 5AE4F4812F6BC0640097AC5B /* Plugins/CloudflareD1DriverPlugin */ = { + isa = PBXFileSystemSynchronizedRootGroup; + exceptions = ( + 5AE4F4802F6BC0640097AC5B /* Exceptions for "Plugins/CloudflareD1DriverPlugin" folder in "CloudflareD1DriverPlugin" target */, + ); + path = Plugins/CloudflareD1DriverPlugin; + sourceTree = ""; + }; /* End PBXFileSystemSynchronizedRootGroup section */ /* Begin PBXFrameworksBuildPhase section */ @@ -639,6 +655,14 @@ ); runOnlyForDeploymentPostprocessing = 0; }; + 5AE460E92F6CEDB70097AC5B /* Frameworks */ = { + isa = PBXFrameworksBuildPhase; + buildActionMask = 2147483647; + files = ( + 5ADDB0010000000000000008 /* TableProPluginKit.framework in Frameworks */, + ); + runOnlyForDeploymentPostprocessing = 0; + }; 5AE4F4712F6BC0640097AC5B /* Frameworks */ = { isa = PBXFrameworksBuildPhase; buildActionMask = 2147483647; @@ -669,6 +693,7 @@ 5A1091BE2EF17EDC0055EA7C = { isa = PBXGroup; children = ( + 5ADDB0050000000000000000 /* Plugins/DynamoDBDriverPlugin */, 5AEA8B412F6808CA0040461A /* Plugins/EtcdDriverPlugin */, 5AE4F4812F6BC0640097AC5B /* Plugins/CloudflareD1DriverPlugin */, 5A1091C92EF17EDC0055EA7C /* TablePro */, @@ -720,10 +745,25 @@ 5ABCC5A72F43856700EAF3FC /* TableProTests.xctest */, 5AEA8B2A2F6808270040461A /* EtcdDriverPlugin.tableplugin */, 5AE4F4742F6BC0640097AC5B /* CloudflareD1DriverPlugin.tableplugin */, + 5AE460EC2F6CEDB70097AC5B /* DynamoDBDriverPlugin.tableplugin */, ); name = Products; sourceTree = ""; }; + 5ADDB0050000000000000000 /* Plugins/DynamoDBDriverPlugin */ = { + isa = PBXGroup; + children = ( + 5ADDB0020000000000000001 /* DynamoDBConnection.swift */, + 5ADDB0020000000000000002 /* DynamoDBItemFlattener.swift */, + 5ADDB0020000000000000003 /* DynamoDBPartiQLParser.swift */, + 5ADDB0020000000000000004 /* DynamoDBPlugin.swift */, + 5ADDB0020000000000000005 /* DynamoDBPluginDriver.swift */, + 5ADDB0020000000000000006 /* DynamoDBQueryBuilder.swift */, + 5ADDB0020000000000000007 /* DynamoDBStatementGenerator.swift */, + ); + path = Plugins/DynamoDBDriverPlugin; + sourceTree = ""; + }; 5AEA8B412F6808CA0040461A /* Plugins/EtcdDriverPlugin */ = { isa = PBXGroup; children = ( @@ -1161,6 +1201,25 @@ productReference = 5ABCC5A72F43856700EAF3FC /* TableProTests.xctest */; productType = "com.apple.product-type.bundle.unit-test"; }; + 5AE460EB2F6CEDB70097AC5B /* DynamoDBDriverPlugin */ = { + isa = PBXNativeTarget; + buildConfigurationList = 5AE460EF2F6CEDB80097AC5B /* Build configuration list for PBXNativeTarget "DynamoDBDriverPlugin" */; + buildPhases = ( + 5AE460E82F6CEDB70097AC5B /* Sources */, + 5AE460E92F6CEDB70097AC5B /* Frameworks */, + 5AE460EA2F6CEDB70097AC5B /* Resources */, + ); + buildRules = ( + ); + dependencies = ( + ); + name = DynamoDBDriverPlugin; + packageProductDependencies = ( + ); + productName = DynamoDBDriverPlugin; + productReference = 5AE460EC2F6CEDB70097AC5B /* DynamoDBDriverPlugin.tableplugin */; + productType = "com.apple.product-type.bundle"; + }; 5AE4F4732F6BC0640097AC5B /* CloudflareD1DriverPlugin */ = { isa = PBXNativeTarget; buildConfigurationList = 5AE4F4752F6BC0640097AC5B /* Build configuration list for PBXNativeTarget "CloudflareD1DriverPlugin" */; @@ -1264,6 +1323,9 @@ CreatedOnToolsVersion = 26.2; TestTargetID = 5A1091C62EF17EDC0055EA7C; }; + 5AE460EB2F6CEDB70097AC5B = { + CreatedOnToolsVersion = 26.3; + }; 5AE4F4732F6BC0640097AC5B = { CreatedOnToolsVersion = 26.3; LastSwiftMigration = 2630; @@ -1319,6 +1381,7 @@ 5ABCC5A62F43856700EAF3FC /* TableProTests */, 5AEA8B292F6808270040461A /* EtcdDriverPlugin */, 5AE4F4732F6BC0640097AC5B /* CloudflareD1DriverPlugin */, + 5AE460EB2F6CEDB70097AC5B /* DynamoDBDriverPlugin */, ); }; /* End PBXProject section */ @@ -1457,6 +1520,13 @@ ); runOnlyForDeploymentPostprocessing = 0; }; + 5AE460EA2F6CEDB70097AC5B /* Resources */ = { + isa = PBXResourcesBuildPhase; + buildActionMask = 2147483647; + files = ( + ); + runOnlyForDeploymentPostprocessing = 0; + }; 5AE4F4722F6BC0640097AC5B /* Resources */ = { isa = PBXResourcesBuildPhase; buildActionMask = 2147483647; @@ -1607,6 +1677,20 @@ ); runOnlyForDeploymentPostprocessing = 0; }; + 5AE460E82F6CEDB70097AC5B /* Sources */ = { + isa = PBXSourcesBuildPhase; + buildActionMask = 2147483647; + files = ( + 5ADDB0010000000000000001 /* DynamoDBConnection.swift in Sources */, + 5ADDB0010000000000000002 /* DynamoDBItemFlattener.swift in Sources */, + 5ADDB0010000000000000003 /* DynamoDBPartiQLParser.swift in Sources */, + 5ADDB0010000000000000004 /* DynamoDBPlugin.swift in Sources */, + 5ADDB0010000000000000005 /* DynamoDBPluginDriver.swift in Sources */, + 5ADDB0010000000000000006 /* DynamoDBQueryBuilder.swift in Sources */, + 5ADDB0010000000000000007 /* DynamoDBStatementGenerator.swift in Sources */, + ); + runOnlyForDeploymentPostprocessing = 0; + }; 5AE4F4702F6BC0640097AC5B /* Sources */ = { isa = PBXSourcesBuildPhase; buildActionMask = 2147483647; @@ -2952,6 +3036,53 @@ }; name = Release; }; + 5AE460ED2F6CEDB80097AC5B /* Debug */ = { + isa = XCBuildConfiguration; + buildSettings = { + CLANG_ENABLE_MODULES = YES; + CODE_SIGN_STYLE = Automatic; + COMBINE_HIDPI_IMAGES = YES; + CURRENT_PROJECT_VERSION = 1; + GENERATE_INFOPLIST_FILE = YES; + INFOPLIST_FILE = Plugins/DynamoDBDriverPlugin/Info.plist; + INFOPLIST_KEY_NSHumanReadableCopyright = ""; + INFOPLIST_KEY_NSPrincipalClass = "$(PRODUCT_MODULE_NAME).DynamoDBPlugin"; + INSTALL_PATH = "$(LOCAL_LIBRARY_DIR)/Bundles"; + MACOSX_DEPLOYMENT_TARGET = 14.0; + MARKETING_VERSION = 1.0; + PRODUCT_BUNDLE_IDENTIFIER = com.TablePro.DynamoDBDriverPlugin; + PRODUCT_NAME = "$(TARGET_NAME)"; + SDKROOT = macosx; + SKIP_INSTALL = YES; + SWIFT_OPTIMIZATION_LEVEL = "-Onone"; + SWIFT_VERSION = 5.9; + WRAPPER_EXTENSION = tableplugin; + }; + name = Debug; + }; + 5AE460EE2F6CEDB80097AC5B /* Release */ = { + isa = XCBuildConfiguration; + buildSettings = { + CLANG_ENABLE_MODULES = YES; + CODE_SIGN_STYLE = Automatic; + COMBINE_HIDPI_IMAGES = YES; + CURRENT_PROJECT_VERSION = 1; + GENERATE_INFOPLIST_FILE = YES; + INFOPLIST_FILE = Plugins/DynamoDBDriverPlugin/Info.plist; + INFOPLIST_KEY_NSHumanReadableCopyright = ""; + INFOPLIST_KEY_NSPrincipalClass = "$(PRODUCT_MODULE_NAME).DynamoDBPlugin"; + INSTALL_PATH = "$(LOCAL_LIBRARY_DIR)/Bundles"; + MACOSX_DEPLOYMENT_TARGET = 14.0; + MARKETING_VERSION = 1.0; + PRODUCT_BUNDLE_IDENTIFIER = com.TablePro.DynamoDBDriverPlugin; + PRODUCT_NAME = "$(TARGET_NAME)"; + SDKROOT = macosx; + SKIP_INSTALL = YES; + SWIFT_VERSION = 5.9; + WRAPPER_EXTENSION = tableplugin; + }; + name = Release; + }; 5AE4F4762F6BC0640097AC5B /* Debug */ = { isa = XCBuildConfiguration; buildSettings = { @@ -3230,6 +3361,15 @@ defaultConfigurationIsVisible = 0; defaultConfigurationName = Release; }; + 5AE460EF2F6CEDB80097AC5B /* Build configuration list for PBXNativeTarget "DynamoDBDriverPlugin" */ = { + isa = XCConfigurationList; + buildConfigurations = ( + 5AE460ED2F6CEDB80097AC5B /* Debug */, + 5AE460EE2F6CEDB80097AC5B /* Release */, + ); + defaultConfigurationIsVisible = 0; + defaultConfigurationName = Release; + }; 5AE4F4752F6BC0640097AC5B /* Build configuration list for PBXNativeTarget "CloudflareD1DriverPlugin" */ = { isa = XCConfigurationList; buildConfigurations = ( diff --git a/TablePro/Core/Database/DatabaseManager.swift b/TablePro/Core/Database/DatabaseManager.swift index e851c8538..f2c133571 100644 --- a/TablePro/Core/Database/DatabaseManager.swift +++ b/TablePro/Core/Database/DatabaseManager.swift @@ -426,39 +426,61 @@ final class DatabaseManager { for connection: DatabaseConnection, sshPasswordOverride: String? = nil ) async throws -> DatabaseConnection { - guard connection.sshConfig.enabled else { + // Resolve SSH configuration: profile takes priority over inline + let sshConfig: SSHConfiguration + let isProfile: Bool + let secretOwnerId: UUID + + if let profileId = connection.sshProfileId, + let profile = SSHProfileStorage.shared.profile(for: profileId) { + sshConfig = profile.toSSHConfiguration() + secretOwnerId = profileId + isProfile = true + } else { + sshConfig = connection.sshConfig + secretOwnerId = connection.id + isProfile = false + } + + guard sshConfig.enabled else { return connection } // Load Keychain credentials off the main thread to avoid blocking UI - let connectionId = connection.id let (storedSshPassword, keyPassphrase, totpSecret) = await Task.detached { - let pwd = ConnectionStorage.shared.loadSSHPassword(for: connectionId) - let phrase = ConnectionStorage.shared.loadKeyPassphrase(for: connectionId) - let totp = ConnectionStorage.shared.loadTOTPSecret(for: connectionId) - return (pwd, phrase, totp) + if isProfile { + let pwd = SSHProfileStorage.shared.loadSSHPassword(for: secretOwnerId) + let phrase = SSHProfileStorage.shared.loadKeyPassphrase(for: secretOwnerId) + let totp = SSHProfileStorage.shared.loadTOTPSecret(for: secretOwnerId) + return (pwd, phrase, totp) + } else { + let pwd = ConnectionStorage.shared.loadSSHPassword(for: secretOwnerId) + let phrase = ConnectionStorage.shared.loadKeyPassphrase(for: secretOwnerId) + let totp = ConnectionStorage.shared.loadTOTPSecret(for: secretOwnerId) + return (pwd, phrase, totp) + } }.value let sshPassword = sshPasswordOverride ?? storedSshPassword let tunnelPort = try await SSHTunnelManager.shared.createTunnel( connectionId: connection.id, - sshHost: connection.sshConfig.host, - sshPort: connection.sshConfig.port, - sshUsername: connection.sshConfig.username, - authMethod: connection.sshConfig.authMethod, - privateKeyPath: connection.sshConfig.privateKeyPath, + sshHost: sshConfig.host, + sshPort: sshConfig.port, + sshUsername: sshConfig.username, + authMethod: sshConfig.authMethod, + privateKeyPath: sshConfig.privateKeyPath, keyPassphrase: keyPassphrase, sshPassword: sshPassword, - agentSocketPath: connection.sshConfig.agentSocketPath, + agentSocketPath: sshConfig.agentSocketPath, remoteHost: connection.host, remotePort: connection.port, - jumpHosts: connection.sshConfig.jumpHosts, - totpMode: connection.sshConfig.totpMode, + jumpHosts: sshConfig.jumpHosts, + totpMode: sshConfig.totpMode, totpSecret: totpSecret, - totpAlgorithm: connection.sshConfig.totpAlgorithm, - totpDigits: connection.sshConfig.totpDigits, - totpPeriod: connection.sshConfig.totpPeriod + totpAlgorithm: sshConfig.totpAlgorithm, + totpDigits: sshConfig.totpDigits, + totpPeriod: sshConfig.totpPeriod ) // Adapt SSL config for tunnel: SSH already authenticates the server, diff --git a/TablePro/Core/Storage/ConnectionStorage.swift b/TablePro/Core/Storage/ConnectionStorage.swift index b4001e05d..af31dfd71 100644 --- a/TablePro/Core/Storage/ConnectionStorage.swift +++ b/TablePro/Core/Storage/ConnectionStorage.swift @@ -122,6 +122,7 @@ final class ConnectionStorage { color: connection.color, tagId: connection.tagId, groupId: connection.groupId, + sshProfileId: connection.sshProfileId, safeModeLevel: connection.safeModeLevel, aiPolicy: connection.aiPolicy, redisDatabase: connection.redisDatabase, @@ -259,6 +260,7 @@ private struct StoredConnection: Codable { let color: String let tagId: String? let groupId: String? + let sshProfileId: String? // Safe mode level let safeModeLevel: String @@ -327,6 +329,7 @@ private struct StoredConnection: Codable { self.color = connection.color.rawValue self.tagId = connection.tagId?.uuidString self.groupId = connection.groupId?.uuidString + self.sshProfileId = connection.sshProfileId?.uuidString // Safe mode level self.safeModeLevel = connection.safeModeLevel.rawValue @@ -361,7 +364,7 @@ private struct StoredConnection: Codable { case sshUseSSHConfig, sshAgentSocketPath case totpMode, totpAlgorithm, totpDigits, totpPeriod case sslMode, sslCaCertificatePath, sslClientCertificatePath, sslClientKeyPath - case color, tagId, groupId + case color, tagId, groupId, sshProfileId case safeModeLevel case isReadOnly // Legacy key for migration reading only case aiPolicy @@ -398,6 +401,7 @@ private struct StoredConnection: Codable { try container.encode(color, forKey: .color) try container.encodeIfPresent(tagId, forKey: .tagId) try container.encodeIfPresent(groupId, forKey: .groupId) + try container.encodeIfPresent(sshProfileId, forKey: .sshProfileId) try container.encode(safeModeLevel, forKey: .safeModeLevel) try container.encodeIfPresent(aiPolicy, forKey: .aiPolicy) try container.encodeIfPresent(redisDatabase, forKey: .redisDatabase) @@ -448,6 +452,7 @@ private struct StoredConnection: Codable { color = try container.decodeIfPresent(String.self, forKey: .color) ?? ConnectionColor.none.rawValue tagId = try container.decodeIfPresent(String.self, forKey: .tagId) groupId = try container.decodeIfPresent(String.self, forKey: .groupId) + sshProfileId = try container.decodeIfPresent(String.self, forKey: .sshProfileId) // Migration: read new safeModeLevel first, fall back to old isReadOnly boolean if let levelString = try container.decodeIfPresent(String.self, forKey: .safeModeLevel) { safeModeLevel = levelString @@ -492,6 +497,7 @@ private struct StoredConnection: Codable { let parsedColor = ConnectionColor(rawValue: color) ?? .none let parsedTagId = tagId.flatMap { UUID(uuidString: $0) } let parsedGroupId = groupId.flatMap { UUID(uuidString: $0) } + let parsedSSHProfileId = sshProfileId.flatMap { UUID(uuidString: $0) } let parsedAIPolicy = aiPolicy.flatMap { AIConnectionPolicy(rawValue: $0) } // Merge legacy named keys into additionalFields as fallback @@ -524,6 +530,7 @@ private struct StoredConnection: Codable { color: parsedColor, tagId: parsedTagId, groupId: parsedGroupId, + sshProfileId: parsedSSHProfileId, safeModeLevel: SafeModeLevel(rawValue: safeModeLevel) ?? .silent, aiPolicy: parsedAIPolicy, redisDatabase: redisDatabase, diff --git a/TablePro/Core/Storage/SSHProfileStorage.swift b/TablePro/Core/Storage/SSHProfileStorage.swift new file mode 100644 index 000000000..accc64cd7 --- /dev/null +++ b/TablePro/Core/Storage/SSHProfileStorage.swift @@ -0,0 +1,122 @@ +// +// SSHProfileStorage.swift +// TablePro +// + +import Foundation +import os + +final class SSHProfileStorage { + static let shared = SSHProfileStorage() + private static let logger = Logger(subsystem: "com.TablePro", category: "SSHProfileStorage") + + private let profilesKey = "com.TablePro.sshProfiles" + private let defaults = UserDefaults.standard + private let encoder = JSONEncoder() + private let decoder = JSONDecoder() + + private init() {} + + // MARK: - Profile CRUD + + func loadProfiles() -> [SSHProfile] { + guard let data = defaults.data(forKey: profilesKey) else { + return [] + } + + do { + return try decoder.decode([SSHProfile].self, from: data) + } catch { + Self.logger.error("Failed to load SSH profiles: \(error)") + return [] + } + } + + func saveProfiles(_ profiles: [SSHProfile]) { + do { + let data = try encoder.encode(profiles) + defaults.set(data, forKey: profilesKey) + } catch { + Self.logger.error("Failed to save SSH profiles: \(error)") + } + } + + func addProfile(_ profile: SSHProfile) { + var profiles = loadProfiles() + profiles.append(profile) + saveProfiles(profiles) + } + + func updateProfile(_ profile: SSHProfile) { + var profiles = loadProfiles() + if let index = profiles.firstIndex(where: { $0.id == profile.id }) { + profiles[index] = profile + saveProfiles(profiles) + } + } + + func deleteProfile(_ profile: SSHProfile) { + var profiles = loadProfiles() + profiles.removeAll { $0.id == profile.id } + saveProfiles(profiles) + + deleteSSHPassword(for: profile.id) + deleteKeyPassphrase(for: profile.id) + deleteTOTPSecret(for: profile.id) + } + + func profile(for id: UUID) -> SSHProfile? { + loadProfiles().first { $0.id == id } + } + + // MARK: - SSH Password Storage + + func saveSSHPassword(_ password: String, for profileId: UUID) { + let key = "com.TablePro.sshprofile.password.\(profileId.uuidString)" + KeychainHelper.shared.saveString(password, forKey: key) + } + + func loadSSHPassword(for profileId: UUID) -> String? { + let key = "com.TablePro.sshprofile.password.\(profileId.uuidString)" + return KeychainHelper.shared.loadString(forKey: key) + } + + func deleteSSHPassword(for profileId: UUID) { + let key = "com.TablePro.sshprofile.password.\(profileId.uuidString)" + KeychainHelper.shared.delete(key: key) + } + + // MARK: - Key Passphrase Storage + + func saveKeyPassphrase(_ passphrase: String, for profileId: UUID) { + let key = "com.TablePro.sshprofile.keypassphrase.\(profileId.uuidString)" + KeychainHelper.shared.saveString(passphrase, forKey: key) + } + + func loadKeyPassphrase(for profileId: UUID) -> String? { + let key = "com.TablePro.sshprofile.keypassphrase.\(profileId.uuidString)" + return KeychainHelper.shared.loadString(forKey: key) + } + + func deleteKeyPassphrase(for profileId: UUID) { + let key = "com.TablePro.sshprofile.keypassphrase.\(profileId.uuidString)" + KeychainHelper.shared.delete(key: key) + } + + // MARK: - TOTP Secret Storage + + func saveTOTPSecret(_ secret: String, for profileId: UUID) { + let key = "com.TablePro.sshprofile.totpsecret.\(profileId.uuidString)" + KeychainHelper.shared.saveString(secret, forKey: key) + } + + func loadTOTPSecret(for profileId: UUID) -> String? { + let key = "com.TablePro.sshprofile.totpsecret.\(profileId.uuidString)" + return KeychainHelper.shared.loadString(forKey: key) + } + + func deleteTOTPSecret(for profileId: UUID) { + let key = "com.TablePro.sshprofile.totpsecret.\(profileId.uuidString)" + KeychainHelper.shared.delete(key: key) + } +} diff --git a/TablePro/Models/Connection/DatabaseConnection.swift b/TablePro/Models/Connection/DatabaseConnection.swift index 9da5d1654..89aaf1356 100644 --- a/TablePro/Models/Connection/DatabaseConnection.swift +++ b/TablePro/Models/Connection/DatabaseConnection.swift @@ -237,6 +237,7 @@ extension DatabaseType { static let scylladb = DatabaseType(rawValue: "ScyllaDB") static let etcd = DatabaseType(rawValue: "etcd") static let cloudflareD1 = DatabaseType(rawValue: "Cloudflare D1") + static let dynamodb = DatabaseType(rawValue: "DynamoDB") } extension DatabaseType: Codable { @@ -375,6 +376,7 @@ struct DatabaseConnection: Identifiable, Hashable { var color: ConnectionColor var tagId: UUID? var groupId: UUID? + var sshProfileId: UUID? var safeModeLevel: SafeModeLevel var aiPolicy: AIConnectionPolicy? var additionalFields: [String: String] = [:] @@ -429,6 +431,7 @@ struct DatabaseConnection: Identifiable, Hashable { color: ConnectionColor = .none, tagId: UUID? = nil, groupId: UUID? = nil, + sshProfileId: UUID? = nil, safeModeLevel: SafeModeLevel = .silent, aiPolicy: AIConnectionPolicy? = nil, mongoAuthSource: String? = nil, @@ -452,6 +455,7 @@ struct DatabaseConnection: Identifiable, Hashable { self.color = color self.tagId = tagId self.groupId = groupId + self.sshProfileId = sshProfileId self.safeModeLevel = safeModeLevel self.aiPolicy = aiPolicy self.redisDatabase = redisDatabase diff --git a/TablePro/Models/Connection/SSHProfile.swift b/TablePro/Models/Connection/SSHProfile.swift new file mode 100644 index 000000000..db5ff29de --- /dev/null +++ b/TablePro/Models/Connection/SSHProfile.swift @@ -0,0 +1,91 @@ +// +// SSHProfile.swift +// TablePro +// + +import Foundation + +struct SSHProfile: Identifiable, Hashable, Codable, Sendable { + let id: UUID + var name: String + var host: String + var port: Int + var username: String + var authMethod: SSHAuthMethod + var privateKeyPath: String + var useSSHConfig: Bool + var agentSocketPath: String + var jumpHosts: [SSHJumpHost] + var totpMode: TOTPMode + var totpAlgorithm: TOTPAlgorithm + var totpDigits: Int + var totpPeriod: Int + + init( + id: UUID = UUID(), + name: String, + host: String = "", + port: Int = 22, + username: String = "", + authMethod: SSHAuthMethod = .password, + privateKeyPath: String = "", + useSSHConfig: Bool = true, + agentSocketPath: String = "", + jumpHosts: [SSHJumpHost] = [], + totpMode: TOTPMode = .none, + totpAlgorithm: TOTPAlgorithm = .sha1, + totpDigits: Int = 6, + totpPeriod: Int = 30 + ) { + self.id = id + self.name = name + self.host = host + self.port = port + self.username = username + self.authMethod = authMethod + self.privateKeyPath = privateKeyPath + self.useSSHConfig = useSSHConfig + self.agentSocketPath = agentSocketPath + self.jumpHosts = jumpHosts + self.totpMode = totpMode + self.totpAlgorithm = totpAlgorithm + self.totpDigits = totpDigits + self.totpPeriod = totpPeriod + } + + func toSSHConfiguration() -> SSHConfiguration { + var config = SSHConfiguration() + config.enabled = true + config.host = host + config.port = port + config.username = username + config.authMethod = authMethod + config.privateKeyPath = privateKeyPath + config.useSSHConfig = useSSHConfig + config.agentSocketPath = agentSocketPath + config.jumpHosts = jumpHosts + config.totpMode = totpMode + config.totpAlgorithm = totpAlgorithm + config.totpDigits = totpDigits + config.totpPeriod = totpPeriod + return config + } + + static func fromSSHConfiguration(_ config: SSHConfiguration, name: String) -> SSHProfile { + SSHProfile( + name: name, + host: config.host, + port: config.port, + username: config.username, + authMethod: config.authMethod, + privateKeyPath: config.privateKeyPath, + useSSHConfig: config.useSSHConfig, + agentSocketPath: config.agentSocketPath, + jumpHosts: config.jumpHosts, + totpMode: config.totpMode, + totpAlgorithm: config.totpAlgorithm, + totpDigits: config.totpDigits, + totpPeriod: config.totpPeriod + ) + } +} diff --git a/TablePro/Views/Connection/ConnectionFormView.swift b/TablePro/Views/Connection/ConnectionFormView.swift index 3375fb5e9..b17833dbc 100644 --- a/TablePro/Views/Connection/ConnectionFormView.swift +++ b/TablePro/Views/Connection/ConnectionFormView.swift @@ -56,6 +56,7 @@ struct ConnectionFormView: View { // swiftlint:disable:this type_body_length @State private var hasLoadedData = false // SSH Configuration + @State private var sshProfileId: UUID? @State private var sshEnabled: Bool = false @State private var sshHost: String = "" @State private var sshPort: String = "22" @@ -447,6 +448,59 @@ struct ConnectionFormView: View { // swiftlint:disable:this type_body_length } if sshEnabled { + sshProfileSection + + if let profileId = sshProfileId, + let profile = SSHProfileStorage.shared.profile(for: profileId) { + sshProfileSummarySection(profile) + } else if sshProfileId != nil { + Section { + HStack { + Image(systemName: "exclamationmark.triangle.fill") + .foregroundStyle(.yellow) + Text("Selected SSH profile no longer exists.") + } + Button("Switch to Inline Configuration") { + sshProfileId = nil + } + } + } else { + sshInlineFields + } + } + } + .formStyle(.grouped) + .scrollContentBackground(.hidden) + } + + private var sshProfileSection: some View { + Section(String(localized: "SSH Profile")) { + Picker(String(localized: "Profile"), selection: $sshProfileId) { + Text("Inline Configuration").tag(UUID?.none) + ForEach(SSHProfileStorage.shared.loadProfiles()) { profile in + Text("\(profile.name) (\(profile.username)@\(profile.host))").tag(UUID?.some(profile.id)) + } + } + } + } + + private func sshProfileSummarySection(_ profile: SSHProfile) -> some View { + Section(String(localized: "Profile Settings")) { + LabeledContent("Host", value: profile.host) + LabeledContent("Port", value: String(profile.port)) + LabeledContent("Username", value: profile.username) + LabeledContent("Auth Method", value: profile.authMethod.rawValue) + if !profile.privateKeyPath.isEmpty { + LabeledContent("Key File", value: profile.privateKeyPath) + } + if !profile.jumpHosts.isEmpty { + LabeledContent("Jump Hosts", value: "\(profile.jumpHosts.count)") + } + } + } + + private var sshInlineFields: some View { + Group { Section(String(localized: "Server")) { if !sshConfigEntries.isEmpty { Picker(String(localized: "Config Host"), selection: $selectedSSHConfigHost) @@ -644,10 +698,7 @@ struct ConnectionFormView: View { // swiftlint:disable:this type_body_length .foregroundStyle(.secondary) } } - } } - .formStyle(.grouped) - .scrollContentBackground(.hidden) } // MARK: - SSL/TLS Tab @@ -920,7 +971,15 @@ struct ConnectionFormView: View { // swiftlint:disable:this type_body_length type = existing.type // Load SSH configuration + sshProfileId = existing.sshProfileId sshEnabled = existing.sshConfig.enabled + + // When using a profile, also set sshEnabled based on profile existence + if let profileId = existing.sshProfileId, + SSHProfileStorage.shared.profile(for: profileId) != nil { + sshEnabled = true + } + sshHost = existing.sshConfig.host sshPort = String(existing.sshConfig.port) sshUsername = existing.sshConfig.username @@ -1036,6 +1095,7 @@ struct ConnectionFormView: View { // swiftlint:disable:this type_body_length color: connectionColor, tagId: selectedTagId, groupId: selectedGroupId, + sshProfileId: sshProfileId, safeModeLevel: safeModeLevel, aiPolicy: aiPolicy, redisDatabase: additionalFieldValues["redisDatabase"].map { Int($0) ?? 0 }, @@ -1048,18 +1108,21 @@ struct ConnectionFormView: View { // swiftlint:disable:this type_body_length if !password.isEmpty { storage.savePassword(password, for: connectionToSave.id) } - if sshEnabled && (sshAuthMethod == .password || sshAuthMethod == .keyboardInteractive) - && !sshPassword.isEmpty - { - storage.saveSSHPassword(sshPassword, for: connectionToSave.id) - } - if sshEnabled && sshAuthMethod == .privateKey && !keyPassphrase.isEmpty { - storage.saveKeyPassphrase(keyPassphrase, for: connectionToSave.id) - } - if sshEnabled && totpMode == .autoGenerate && !totpSecret.isEmpty { - storage.saveTOTPSecret(totpSecret, for: connectionToSave.id) - } else { - storage.deleteTOTPSecret(for: connectionToSave.id) + // Only save SSH secrets per-connection when using inline config (not a profile) + if sshEnabled && sshProfileId == nil { + if (sshAuthMethod == .password || sshAuthMethod == .keyboardInteractive) + && !sshPassword.isEmpty + { + storage.saveSSHPassword(sshPassword, for: connectionToSave.id) + } + if sshAuthMethod == .privateKey && !keyPassphrase.isEmpty { + storage.saveKeyPassphrase(keyPassphrase, for: connectionToSave.id) + } + if totpMode == .autoGenerate && !totpSecret.isEmpty { + storage.saveTOTPSecret(totpSecret, for: connectionToSave.id) + } else { + storage.deleteTOTPSecret(for: connectionToSave.id) + } } // Save to storage @@ -1199,6 +1262,7 @@ struct ConnectionFormView: View { // swiftlint:disable:this type_body_length color: connectionColor, tagId: selectedTagId, groupId: selectedGroupId, + sshProfileId: sshProfileId, redisDatabase: additionalFieldValues["redisDatabase"].map { Int($0) ?? 0 }, startupCommands: startupCommands.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty ? nil : startupCommands, From 3425d9975151d3da789dcc1f9adba3b3ff0c3951 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ng=C3=B4=20Qu=E1=BB=91c=20=C4=90=E1=BA=A1t?= Date: Fri, 20 Mar 2026 12:30:20 +0700 Subject: [PATCH 02/11] fix: prevent SSHProfileStorage from overwriting data on decode failure --- TablePro/Core/Storage/SSHProfileStorage.swift | 14 +++++++++++++- 1 file changed, 13 insertions(+), 1 deletion(-) diff --git a/TablePro/Core/Storage/SSHProfileStorage.swift b/TablePro/Core/Storage/SSHProfileStorage.swift index accc64cd7..df468dfaf 100644 --- a/TablePro/Core/Storage/SSHProfileStorage.swift +++ b/TablePro/Core/Storage/SSHProfileStorage.swift @@ -14,6 +14,7 @@ final class SSHProfileStorage { private let defaults = UserDefaults.standard private let encoder = JSONEncoder() private let decoder = JSONDecoder() + private var lastLoadFailed = false private init() {} @@ -21,18 +22,26 @@ final class SSHProfileStorage { func loadProfiles() -> [SSHProfile] { guard let data = defaults.data(forKey: profilesKey) else { + lastLoadFailed = false return [] } do { - return try decoder.decode([SSHProfile].self, from: data) + let profiles = try decoder.decode([SSHProfile].self, from: data) + lastLoadFailed = false + return profiles } catch { Self.logger.error("Failed to load SSH profiles: \(error)") + lastLoadFailed = true return [] } } func saveProfiles(_ profiles: [SSHProfile]) { + guard !lastLoadFailed else { + Self.logger.warning("Refusing to save SSH profiles: previous load failed (would overwrite existing data)") + return + } do { let data = try encoder.encode(profiles) defaults.set(data, forKey: profilesKey) @@ -43,12 +52,14 @@ final class SSHProfileStorage { func addProfile(_ profile: SSHProfile) { var profiles = loadProfiles() + guard !lastLoadFailed else { return } profiles.append(profile) saveProfiles(profiles) } func updateProfile(_ profile: SSHProfile) { var profiles = loadProfiles() + guard !lastLoadFailed else { return } if let index = profiles.firstIndex(where: { $0.id == profile.id }) { profiles[index] = profile saveProfiles(profiles) @@ -57,6 +68,7 @@ final class SSHProfileStorage { func deleteProfile(_ profile: SSHProfile) { var profiles = loadProfiles() + guard !lastLoadFailed else { return } profiles.removeAll { $0.id == profile.id } saveProfiles(profiles) From c462a74cde5658c155859831ea5135c9dac4a821 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ng=C3=B4=20Qu=E1=BB=91c=20=C4=90=E1=BA=A1t?= Date: Fri, 20 Mar 2026 12:34:22 +0700 Subject: [PATCH 03/11] fix: remove unrelated DynamoDB target from pbxproj --- TablePro.xcodeproj/project.pbxproj | 172 +++-------------------------- 1 file changed, 16 insertions(+), 156 deletions(-) diff --git a/TablePro.xcodeproj/project.pbxproj b/TablePro.xcodeproj/project.pbxproj index f54d2befe..b40f54893 100644 --- a/TablePro.xcodeproj/project.pbxproj +++ b/TablePro.xcodeproj/project.pbxproj @@ -30,20 +30,12 @@ 5A86E000A00000000 /* TableProPluginKit.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 5A860000100000000 /* TableProPluginKit.framework */; }; 5A86F000A00000000 /* TableProPluginKit.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 5A860000100000000 /* TableProPluginKit.framework */; }; 5A87A000A00000000 /* TableProPluginKit.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 5A860000100000000 /* TableProPluginKit.framework */; }; + 5AE4F4902F6BC0640097AC5B /* TableProPluginKit.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 5A860000100000000 /* TableProPluginKit.framework */; }; 5ACE00012F4F000000000004 /* CodeEditSourceEditor in Frameworks */ = {isa = PBXBuildFile; productRef = 5ACE00012F4F000000000002 /* CodeEditSourceEditor */; }; 5ACE00012F4F000000000005 /* CodeEditLanguages in Frameworks */ = {isa = PBXBuildFile; productRef = 5ACE00012F4F000000000003 /* CodeEditLanguages */; }; 5ACE00012F4F000000000006 /* CodeEditTextView in Frameworks */ = {isa = PBXBuildFile; productRef = 5ACE00012F4F000000000007 /* CodeEditTextView */; }; 5ACE00012F4F00000000000A /* Sparkle in Frameworks */ = {isa = PBXBuildFile; productRef = 5ACE00012F4F000000000009 /* Sparkle */; }; 5ACE00012F4F00000000000D /* MarkdownUI in Frameworks */ = {isa = PBXBuildFile; productRef = 5ACE00012F4F00000000000C /* MarkdownUI */; }; - 5ADDB0010000000000000001 /* DynamoDBConnection.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5ADDB0020000000000000001 /* DynamoDBConnection.swift */; }; - 5ADDB0010000000000000002 /* DynamoDBItemFlattener.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5ADDB0020000000000000002 /* DynamoDBItemFlattener.swift */; }; - 5ADDB0010000000000000003 /* DynamoDBPartiQLParser.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5ADDB0020000000000000003 /* DynamoDBPartiQLParser.swift */; }; - 5ADDB0010000000000000004 /* DynamoDBPlugin.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5ADDB0020000000000000004 /* DynamoDBPlugin.swift */; }; - 5ADDB0010000000000000005 /* DynamoDBPluginDriver.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5ADDB0020000000000000005 /* DynamoDBPluginDriver.swift */; }; - 5ADDB0010000000000000006 /* DynamoDBQueryBuilder.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5ADDB0020000000000000006 /* DynamoDBQueryBuilder.swift */; }; - 5ADDB0010000000000000007 /* DynamoDBStatementGenerator.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5ADDB0020000000000000007 /* DynamoDBStatementGenerator.swift */; }; - 5ADDB0010000000000000008 /* TableProPluginKit.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 5A860000100000000 /* TableProPluginKit.framework */; }; - 5AE4F4902F6BC0640097AC5B /* TableProPluginKit.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 5A860000100000000 /* TableProPluginKit.framework */; }; 5AEA8B422F6808CA0040461A /* EtcdStatementGenerator.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5AEA8B402F6808CA0040461A /* EtcdStatementGenerator.swift */; }; 5AEA8B432F6808CA0040461A /* EtcdPlugin.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5AEA8B3D2F6808CA0040461A /* EtcdPlugin.swift */; }; 5AEA8B442F6808CA0040461A /* EtcdCommandParser.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5AEA8B3B2F6808CA0040461A /* EtcdCommandParser.swift */; }; @@ -184,14 +176,6 @@ 5A86F000100000000 /* SQLImport.tableplugin */ = {isa = PBXFileReference; explicitFileType = wrapper.cfbundle; includeInIndex = 0; path = SQLImport.tableplugin; sourceTree = BUILT_PRODUCTS_DIR; }; 5A87A000100000000 /* CassandraDriver.tableplugin */ = {isa = PBXFileReference; explicitFileType = wrapper.cfbundle; includeInIndex = 0; path = CassandraDriver.tableplugin; sourceTree = BUILT_PRODUCTS_DIR; }; 5ABCC5A72F43856700EAF3FC /* TableProTests.xctest */ = {isa = PBXFileReference; explicitFileType = wrapper.cfbundle; includeInIndex = 0; path = TableProTests.xctest; sourceTree = BUILT_PRODUCTS_DIR; }; - 5ADDB0020000000000000001 /* DynamoDBConnection.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DynamoDBConnection.swift; sourceTree = ""; }; - 5ADDB0020000000000000002 /* DynamoDBItemFlattener.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DynamoDBItemFlattener.swift; sourceTree = ""; }; - 5ADDB0020000000000000003 /* DynamoDBPartiQLParser.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DynamoDBPartiQLParser.swift; sourceTree = ""; }; - 5ADDB0020000000000000004 /* DynamoDBPlugin.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DynamoDBPlugin.swift; sourceTree = ""; }; - 5ADDB0020000000000000005 /* DynamoDBPluginDriver.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DynamoDBPluginDriver.swift; sourceTree = ""; }; - 5ADDB0020000000000000006 /* DynamoDBQueryBuilder.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DynamoDBQueryBuilder.swift; sourceTree = ""; }; - 5ADDB0020000000000000007 /* DynamoDBStatementGenerator.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DynamoDBStatementGenerator.swift; sourceTree = ""; }; - 5AE460EC2F6CEDB70097AC5B /* DynamoDBDriverPlugin.tableplugin */ = {isa = PBXFileReference; explicitFileType = wrapper.cfbundle; includeInIndex = 0; path = DynamoDBDriverPlugin.tableplugin; sourceTree = BUILT_PRODUCTS_DIR; }; 5AE4F4742F6BC0640097AC5B /* CloudflareD1DriverPlugin.tableplugin */ = {isa = PBXFileReference; explicitFileType = wrapper.cfbundle; includeInIndex = 0; path = CloudflareD1DriverPlugin.tableplugin; sourceTree = BUILT_PRODUCTS_DIR; }; 5AEA8B2A2F6808270040461A /* EtcdDriverPlugin.tableplugin */ = {isa = PBXFileReference; explicitFileType = wrapper.cfbundle; includeInIndex = 0; path = EtcdDriverPlugin.tableplugin; sourceTree = BUILT_PRODUCTS_DIR; }; 5AEA8B3B2F6808CA0040461A /* EtcdCommandParser.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = EtcdCommandParser.swift; sourceTree = ""; }; @@ -225,6 +209,13 @@ ); target = 5A862000000000000 /* SQLiteDriver */; }; + 5AE4F4802F6BC0640097AC5B /* Exceptions for "Plugins/CloudflareD1DriverPlugin" folder in "CloudflareD1DriverPlugin" target */ = { + isa = PBXFileSystemSynchronizedBuildFileExceptionSet; + membershipExceptions = ( + Info.plist, + ); + target = 5AE4F4732F6BC0640097AC5B /* CloudflareD1DriverPlugin */; + }; 5A863000900000000 /* Exceptions for "Plugins/ClickHouseDriverPlugin" folder in "ClickHouseDriver" target */ = { isa = PBXFileSystemSynchronizedBuildFileExceptionSet; membershipExceptions = ( @@ -323,13 +314,6 @@ ); target = 5A87A000000000000 /* CassandraDriver */; }; - 5AE4F4802F6BC0640097AC5B /* Exceptions for "Plugins/CloudflareD1DriverPlugin" folder in "CloudflareD1DriverPlugin" target */ = { - isa = PBXFileSystemSynchronizedBuildFileExceptionSet; - membershipExceptions = ( - Info.plist, - ); - target = 5AE4F4732F6BC0640097AC5B /* CloudflareD1DriverPlugin */; - }; 5AF312BE2F36FF7500E86682 /* Exceptions for "TablePro" folder in "TablePro" target */ = { isa = PBXFileSystemSynchronizedBuildFileExceptionSet; membershipExceptions = ( @@ -372,6 +356,14 @@ path = Plugins/SQLiteDriverPlugin; sourceTree = ""; }; + 5AE4F4812F6BC0640097AC5B /* Plugins/CloudflareD1DriverPlugin */ = { + isa = PBXFileSystemSynchronizedRootGroup; + exceptions = ( + 5AE4F4802F6BC0640097AC5B /* Exceptions for "Plugins/CloudflareD1DriverPlugin" folder in "CloudflareD1DriverPlugin" target */, + ); + path = Plugins/CloudflareD1DriverPlugin; + sourceTree = ""; + }; 5A863000500000000 /* Plugins/ClickHouseDriverPlugin */ = { isa = PBXFileSystemSynchronizedRootGroup; exceptions = ( @@ -489,14 +481,6 @@ path = TableProTests; sourceTree = ""; }; - 5AE4F4812F6BC0640097AC5B /* Plugins/CloudflareD1DriverPlugin */ = { - isa = PBXFileSystemSynchronizedRootGroup; - exceptions = ( - 5AE4F4802F6BC0640097AC5B /* Exceptions for "Plugins/CloudflareD1DriverPlugin" folder in "CloudflareD1DriverPlugin" target */, - ); - path = Plugins/CloudflareD1DriverPlugin; - sourceTree = ""; - }; /* End PBXFileSystemSynchronizedRootGroup section */ /* Begin PBXFrameworksBuildPhase section */ @@ -655,14 +639,6 @@ ); runOnlyForDeploymentPostprocessing = 0; }; - 5AE460E92F6CEDB70097AC5B /* Frameworks */ = { - isa = PBXFrameworksBuildPhase; - buildActionMask = 2147483647; - files = ( - 5ADDB0010000000000000008 /* TableProPluginKit.framework in Frameworks */, - ); - runOnlyForDeploymentPostprocessing = 0; - }; 5AE4F4712F6BC0640097AC5B /* Frameworks */ = { isa = PBXFrameworksBuildPhase; buildActionMask = 2147483647; @@ -693,7 +669,6 @@ 5A1091BE2EF17EDC0055EA7C = { isa = PBXGroup; children = ( - 5ADDB0050000000000000000 /* Plugins/DynamoDBDriverPlugin */, 5AEA8B412F6808CA0040461A /* Plugins/EtcdDriverPlugin */, 5AE4F4812F6BC0640097AC5B /* Plugins/CloudflareD1DriverPlugin */, 5A1091C92EF17EDC0055EA7C /* TablePro */, @@ -745,25 +720,10 @@ 5ABCC5A72F43856700EAF3FC /* TableProTests.xctest */, 5AEA8B2A2F6808270040461A /* EtcdDriverPlugin.tableplugin */, 5AE4F4742F6BC0640097AC5B /* CloudflareD1DriverPlugin.tableplugin */, - 5AE460EC2F6CEDB70097AC5B /* DynamoDBDriverPlugin.tableplugin */, ); name = Products; sourceTree = ""; }; - 5ADDB0050000000000000000 /* Plugins/DynamoDBDriverPlugin */ = { - isa = PBXGroup; - children = ( - 5ADDB0020000000000000001 /* DynamoDBConnection.swift */, - 5ADDB0020000000000000002 /* DynamoDBItemFlattener.swift */, - 5ADDB0020000000000000003 /* DynamoDBPartiQLParser.swift */, - 5ADDB0020000000000000004 /* DynamoDBPlugin.swift */, - 5ADDB0020000000000000005 /* DynamoDBPluginDriver.swift */, - 5ADDB0020000000000000006 /* DynamoDBQueryBuilder.swift */, - 5ADDB0020000000000000007 /* DynamoDBStatementGenerator.swift */, - ); - path = Plugins/DynamoDBDriverPlugin; - sourceTree = ""; - }; 5AEA8B412F6808CA0040461A /* Plugins/EtcdDriverPlugin */ = { isa = PBXGroup; children = ( @@ -1201,25 +1161,6 @@ productReference = 5ABCC5A72F43856700EAF3FC /* TableProTests.xctest */; productType = "com.apple.product-type.bundle.unit-test"; }; - 5AE460EB2F6CEDB70097AC5B /* DynamoDBDriverPlugin */ = { - isa = PBXNativeTarget; - buildConfigurationList = 5AE460EF2F6CEDB80097AC5B /* Build configuration list for PBXNativeTarget "DynamoDBDriverPlugin" */; - buildPhases = ( - 5AE460E82F6CEDB70097AC5B /* Sources */, - 5AE460E92F6CEDB70097AC5B /* Frameworks */, - 5AE460EA2F6CEDB70097AC5B /* Resources */, - ); - buildRules = ( - ); - dependencies = ( - ); - name = DynamoDBDriverPlugin; - packageProductDependencies = ( - ); - productName = DynamoDBDriverPlugin; - productReference = 5AE460EC2F6CEDB70097AC5B /* DynamoDBDriverPlugin.tableplugin */; - productType = "com.apple.product-type.bundle"; - }; 5AE4F4732F6BC0640097AC5B /* CloudflareD1DriverPlugin */ = { isa = PBXNativeTarget; buildConfigurationList = 5AE4F4752F6BC0640097AC5B /* Build configuration list for PBXNativeTarget "CloudflareD1DriverPlugin" */; @@ -1323,9 +1264,6 @@ CreatedOnToolsVersion = 26.2; TestTargetID = 5A1091C62EF17EDC0055EA7C; }; - 5AE460EB2F6CEDB70097AC5B = { - CreatedOnToolsVersion = 26.3; - }; 5AE4F4732F6BC0640097AC5B = { CreatedOnToolsVersion = 26.3; LastSwiftMigration = 2630; @@ -1381,7 +1319,6 @@ 5ABCC5A62F43856700EAF3FC /* TableProTests */, 5AEA8B292F6808270040461A /* EtcdDriverPlugin */, 5AE4F4732F6BC0640097AC5B /* CloudflareD1DriverPlugin */, - 5AE460EB2F6CEDB70097AC5B /* DynamoDBDriverPlugin */, ); }; /* End PBXProject section */ @@ -1520,13 +1457,6 @@ ); runOnlyForDeploymentPostprocessing = 0; }; - 5AE460EA2F6CEDB70097AC5B /* Resources */ = { - isa = PBXResourcesBuildPhase; - buildActionMask = 2147483647; - files = ( - ); - runOnlyForDeploymentPostprocessing = 0; - }; 5AE4F4722F6BC0640097AC5B /* Resources */ = { isa = PBXResourcesBuildPhase; buildActionMask = 2147483647; @@ -1677,20 +1607,6 @@ ); runOnlyForDeploymentPostprocessing = 0; }; - 5AE460E82F6CEDB70097AC5B /* Sources */ = { - isa = PBXSourcesBuildPhase; - buildActionMask = 2147483647; - files = ( - 5ADDB0010000000000000001 /* DynamoDBConnection.swift in Sources */, - 5ADDB0010000000000000002 /* DynamoDBItemFlattener.swift in Sources */, - 5ADDB0010000000000000003 /* DynamoDBPartiQLParser.swift in Sources */, - 5ADDB0010000000000000004 /* DynamoDBPlugin.swift in Sources */, - 5ADDB0010000000000000005 /* DynamoDBPluginDriver.swift in Sources */, - 5ADDB0010000000000000006 /* DynamoDBQueryBuilder.swift in Sources */, - 5ADDB0010000000000000007 /* DynamoDBStatementGenerator.swift in Sources */, - ); - runOnlyForDeploymentPostprocessing = 0; - }; 5AE4F4702F6BC0640097AC5B /* Sources */ = { isa = PBXSourcesBuildPhase; buildActionMask = 2147483647; @@ -3036,53 +2952,6 @@ }; name = Release; }; - 5AE460ED2F6CEDB80097AC5B /* Debug */ = { - isa = XCBuildConfiguration; - buildSettings = { - CLANG_ENABLE_MODULES = YES; - CODE_SIGN_STYLE = Automatic; - COMBINE_HIDPI_IMAGES = YES; - CURRENT_PROJECT_VERSION = 1; - GENERATE_INFOPLIST_FILE = YES; - INFOPLIST_FILE = Plugins/DynamoDBDriverPlugin/Info.plist; - INFOPLIST_KEY_NSHumanReadableCopyright = ""; - INFOPLIST_KEY_NSPrincipalClass = "$(PRODUCT_MODULE_NAME).DynamoDBPlugin"; - INSTALL_PATH = "$(LOCAL_LIBRARY_DIR)/Bundles"; - MACOSX_DEPLOYMENT_TARGET = 14.0; - MARKETING_VERSION = 1.0; - PRODUCT_BUNDLE_IDENTIFIER = com.TablePro.DynamoDBDriverPlugin; - PRODUCT_NAME = "$(TARGET_NAME)"; - SDKROOT = macosx; - SKIP_INSTALL = YES; - SWIFT_OPTIMIZATION_LEVEL = "-Onone"; - SWIFT_VERSION = 5.9; - WRAPPER_EXTENSION = tableplugin; - }; - name = Debug; - }; - 5AE460EE2F6CEDB80097AC5B /* Release */ = { - isa = XCBuildConfiguration; - buildSettings = { - CLANG_ENABLE_MODULES = YES; - CODE_SIGN_STYLE = Automatic; - COMBINE_HIDPI_IMAGES = YES; - CURRENT_PROJECT_VERSION = 1; - GENERATE_INFOPLIST_FILE = YES; - INFOPLIST_FILE = Plugins/DynamoDBDriverPlugin/Info.plist; - INFOPLIST_KEY_NSHumanReadableCopyright = ""; - INFOPLIST_KEY_NSPrincipalClass = "$(PRODUCT_MODULE_NAME).DynamoDBPlugin"; - INSTALL_PATH = "$(LOCAL_LIBRARY_DIR)/Bundles"; - MACOSX_DEPLOYMENT_TARGET = 14.0; - MARKETING_VERSION = 1.0; - PRODUCT_BUNDLE_IDENTIFIER = com.TablePro.DynamoDBDriverPlugin; - PRODUCT_NAME = "$(TARGET_NAME)"; - SDKROOT = macosx; - SKIP_INSTALL = YES; - SWIFT_VERSION = 5.9; - WRAPPER_EXTENSION = tableplugin; - }; - name = Release; - }; 5AE4F4762F6BC0640097AC5B /* Debug */ = { isa = XCBuildConfiguration; buildSettings = { @@ -3361,15 +3230,6 @@ defaultConfigurationIsVisible = 0; defaultConfigurationName = Release; }; - 5AE460EF2F6CEDB80097AC5B /* Build configuration list for PBXNativeTarget "DynamoDBDriverPlugin" */ = { - isa = XCConfigurationList; - buildConfigurations = ( - 5AE460ED2F6CEDB80097AC5B /* Debug */, - 5AE460EE2F6CEDB80097AC5B /* Release */, - ); - defaultConfigurationIsVisible = 0; - defaultConfigurationName = Release; - }; 5AE4F4752F6BC0640097AC5B /* Build configuration list for PBXNativeTarget "CloudflareD1DriverPlugin" */ = { isa = XCConfigurationList; buildConfigurations = ( From b7d424b0f9f6395387c6cd73df62b397bc8927a8 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ng=C3=B4=20Qu=E1=BB=91c=20=C4=90=E1=BA=A1t?= Date: Fri, 20 Mar 2026 12:36:46 +0700 Subject: [PATCH 04/11] fix: remove unrelated DynamoDB type from PR --- TablePro/Models/Connection/DatabaseConnection.swift | 1 - 1 file changed, 1 deletion(-) diff --git a/TablePro/Models/Connection/DatabaseConnection.swift b/TablePro/Models/Connection/DatabaseConnection.swift index 89aaf1356..f53f77498 100644 --- a/TablePro/Models/Connection/DatabaseConnection.swift +++ b/TablePro/Models/Connection/DatabaseConnection.swift @@ -237,7 +237,6 @@ extension DatabaseType { static let scylladb = DatabaseType(rawValue: "ScyllaDB") static let etcd = DatabaseType(rawValue: "etcd") static let cloudflareD1 = DatabaseType(rawValue: "Cloudflare D1") - static let dynamodb = DatabaseType(rawValue: "DynamoDB") } extension DatabaseType: Codable { From 61b1ab3912bcf7cdaab2d6bdc165c71266434e42 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ng=C3=B4=20Qu=E1=BB=91c=20=C4=90=E1=BA=A1t?= Date: Fri, 20 Mar 2026 12:41:23 +0700 Subject: [PATCH 05/11] fix: remove DynamoDB from changelog, localize labels, cache profile list, fix indentation --- CHANGELOG.md | 1 - .../Views/Connection/ConnectionFormView.swift | 336 +++++++++--------- 2 files changed, 170 insertions(+), 167 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index da8da07f0..719082e08 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -10,7 +10,6 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Added - Reusable SSH tunnel profiles: save SSH configurations once and select them across multiple connections -- Amazon DynamoDB database support with PartiQL queries, AWS IAM/Profile/SSO authentication, GSI/LSI browsing, table scanning, capacity display, and DynamoDB Local support ### Fixed diff --git a/TablePro/Views/Connection/ConnectionFormView.swift b/TablePro/Views/Connection/ConnectionFormView.swift index b17833dbc..8a4e4f2a4 100644 --- a/TablePro/Views/Connection/ConnectionFormView.swift +++ b/TablePro/Views/Connection/ConnectionFormView.swift @@ -57,6 +57,7 @@ struct ConnectionFormView: View { // swiftlint:disable:this type_body_length // SSH Configuration @State private var sshProfileId: UUID? + @State private var sshProfiles: [SSHProfile] = [] @State private var sshEnabled: Bool = false @State private var sshHost: String = "" @State private var sshPort: String = "22" @@ -477,226 +478,229 @@ struct ConnectionFormView: View { // swiftlint:disable:this type_body_length Section(String(localized: "SSH Profile")) { Picker(String(localized: "Profile"), selection: $sshProfileId) { Text("Inline Configuration").tag(UUID?.none) - ForEach(SSHProfileStorage.shared.loadProfiles()) { profile in + ForEach(sshProfiles) { profile in Text("\(profile.name) (\(profile.username)@\(profile.host))").tag(UUID?.some(profile.id)) } } } + .onAppear { + sshProfiles = SSHProfileStorage.shared.loadProfiles() + } } private func sshProfileSummarySection(_ profile: SSHProfile) -> some View { Section(String(localized: "Profile Settings")) { - LabeledContent("Host", value: profile.host) - LabeledContent("Port", value: String(profile.port)) - LabeledContent("Username", value: profile.username) - LabeledContent("Auth Method", value: profile.authMethod.rawValue) + LabeledContent(String(localized: "Host"), value: profile.host) + LabeledContent(String(localized: "Port"), value: String(profile.port)) + LabeledContent(String(localized: "Username"), value: profile.username) + LabeledContent(String(localized: "Auth Method"), value: profile.authMethod.rawValue) if !profile.privateKeyPath.isEmpty { - LabeledContent("Key File", value: profile.privateKeyPath) + LabeledContent(String(localized: "Key File"), value: profile.privateKeyPath) } if !profile.jumpHosts.isEmpty { - LabeledContent("Jump Hosts", value: "\(profile.jumpHosts.count)") + LabeledContent(String(localized: "Jump Hosts"), value: "\(profile.jumpHosts.count)") } } } private var sshInlineFields: some View { Group { - Section(String(localized: "Server")) { - if !sshConfigEntries.isEmpty { - Picker(String(localized: "Config Host"), selection: $selectedSSHConfigHost) - { - Text(String(localized: "Manual")).tag("") - ForEach(sshConfigEntries) { entry in - Text(entry.displayName).tag(entry.host) - } - } - .onChange(of: selectedSSHConfigHost) { - applySSHConfigEntry(selectedSSHConfigHost) + Section(String(localized: "Server")) { + if !sshConfigEntries.isEmpty { + Picker(String(localized: "Config Host"), selection: $selectedSSHConfigHost) + { + Text(String(localized: "Manual")).tag("") + ForEach(sshConfigEntries) { entry in + Text(entry.displayName).tag(entry.host) } } - if selectedSSHConfigHost.isEmpty || sshConfigEntries.isEmpty { - TextField( - String(localized: "SSH Host"), - text: $sshHost, - prompt: Text("ssh.example.com") - ) + .onChange(of: selectedSSHConfigHost) { + applySSHConfigEntry(selectedSSHConfigHost) } + } + if selectedSSHConfigHost.isEmpty || sshConfigEntries.isEmpty { TextField( - String(localized: "SSH Port"), - text: $sshPort, - prompt: Text("22") - ) - TextField( - String(localized: "SSH User"), - text: $sshUsername, - prompt: Text("username") + String(localized: "SSH Host"), + text: $sshHost, + prompt: Text("ssh.example.com") ) } - Section(String(localized: "Authentication")) { - Picker(String(localized: "Method"), selection: $sshAuthMethod) { - ForEach(SSHAuthMethod.allCases) { method in - Text(method.rawValue).tag(method) - } + TextField( + String(localized: "SSH Port"), + text: $sshPort, + prompt: Text("22") + ) + TextField( + String(localized: "SSH User"), + text: $sshUsername, + prompt: Text("username") + ) + } + Section(String(localized: "Authentication")) { + Picker(String(localized: "Method"), selection: $sshAuthMethod) { + ForEach(SSHAuthMethod.allCases) { method in + Text(method.rawValue).tag(method) } - if sshAuthMethod == .password { - SecureField(String(localized: "Password"), text: $sshPassword) - } else if sshAuthMethod == .sshAgent { - Picker("Agent Socket", selection: $sshAgentSocketOption) { - ForEach(SSHAgentSocketOption.allCases) { option in - Text(option.displayName).tag(option) - } - } - if sshAgentSocketOption == .custom { - TextField( - "Custom Path", - text: $customSSHAgentSocketPath, - prompt: Text("/path/to/agent.sock") - ) + } + if sshAuthMethod == .password { + SecureField(String(localized: "Password"), text: $sshPassword) + } else if sshAuthMethod == .sshAgent { + Picker("Agent Socket", selection: $sshAgentSocketOption) { + ForEach(SSHAgentSocketOption.allCases) { option in + Text(option.displayName).tag(option) } - Text("Keys are provided by the SSH agent (e.g. 1Password, ssh-agent).") - .font(.caption) - .foregroundStyle(.secondary) - } else if sshAuthMethod == .keyboardInteractive { - SecureField(String(localized: "Password"), text: $sshPassword) - Text( - String(localized: "Password is sent via keyboard-interactive challenge-response.") + } + if sshAgentSocketOption == .custom { + TextField( + "Custom Path", + text: $customSSHAgentSocketPath, + prompt: Text("/path/to/agent.sock") ) + } + Text("Keys are provided by the SSH agent (e.g. 1Password, ssh-agent).") .font(.caption) .foregroundStyle(.secondary) - } else { - LabeledContent(String(localized: "Key File")) { - HStack { - TextField( - "", text: $sshPrivateKeyPath, prompt: Text("~/.ssh/id_rsa")) - Button(String(localized: "Browse")) { browseForPrivateKey() } - .controlSize(.small) - } + } else if sshAuthMethod == .keyboardInteractive { + SecureField(String(localized: "Password"), text: $sshPassword) + Text( + String(localized: "Password is sent via keyboard-interactive challenge-response.") + ) + .font(.caption) + .foregroundStyle(.secondary) + } else { + LabeledContent(String(localized: "Key File")) { + HStack { + TextField( + "", text: $sshPrivateKeyPath, prompt: Text("~/.ssh/id_rsa")) + Button(String(localized: "Browse")) { browseForPrivateKey() } + .controlSize(.small) } - SecureField(String(localized: "Passphrase"), text: $keyPassphrase) } + SecureField(String(localized: "Passphrase"), text: $keyPassphrase) + } } - if sshAuthMethod == .keyboardInteractive || sshAuthMethod == .password { - Section(String(localized: "Two-Factor Authentication")) { - Picker(String(localized: "TOTP"), selection: $totpMode) { - ForEach(TOTPMode.allCases) { mode in - Text(mode.displayName).tag(mode) - } + if sshAuthMethod == .keyboardInteractive || sshAuthMethod == .password { + Section(String(localized: "Two-Factor Authentication")) { + Picker(String(localized: "TOTP"), selection: $totpMode) { + ForEach(TOTPMode.allCases) { mode in + Text(mode.displayName).tag(mode) } + } - if totpMode == .autoGenerate { - SecureField(String(localized: "TOTP Secret"), text: $totpSecret) - .help(String(localized: "Base32-encoded secret from your authenticator setup")) + if totpMode == .autoGenerate { + SecureField(String(localized: "TOTP Secret"), text: $totpSecret) + .help(String(localized: "Base32-encoded secret from your authenticator setup")) - Picker(String(localized: "Algorithm"), selection: $totpAlgorithm) { - ForEach(TOTPAlgorithm.allCases) { algo in - Text(algo.rawValue).tag(algo) - } + Picker(String(localized: "Algorithm"), selection: $totpAlgorithm) { + ForEach(TOTPAlgorithm.allCases) { algo in + Text(algo.rawValue).tag(algo) } + } - Picker(String(localized: "Digits"), selection: $totpDigits) { - Text("6").tag(6) - Text("8").tag(8) - } + Picker(String(localized: "Digits"), selection: $totpDigits) { + Text("6").tag(6) + Text("8").tag(8) + } - Picker(String(localized: "Period"), selection: $totpPeriod) { - Text("30s").tag(30) - Text("60s").tag(60) - } - } else if totpMode == .promptAtConnect { - Text( - String( - localized: - "You will be prompted for a verification code each time you connect." - ) - ) - .font(.caption) - .foregroundStyle(.secondary) + Picker(String(localized: "Period"), selection: $totpPeriod) { + Text("30s").tag(30) + Text("60s").tag(60) } + } else if totpMode == .promptAtConnect { + Text( + String( + localized: + "You will be prompted for a verification code each time you connect." + ) + ) + .font(.caption) + .foregroundStyle(.secondary) } } + } - Section { - DisclosureGroup(String(localized: "Jump Hosts")) { - ForEach($jumpHosts) { $jumpHost in - DisclosureGroup { + Section { + DisclosureGroup(String(localized: "Jump Hosts")) { + ForEach($jumpHosts) { $jumpHost in + DisclosureGroup { + TextField( + String(localized: "Host"), + text: $jumpHost.host, + prompt: Text("bastion.example.com") + ) + HStack { TextField( - String(localized: "Host"), - text: $jumpHost.host, - prompt: Text("bastion.example.com") + String(localized: "Port"), + text: Binding( + get: { String(jumpHost.port) }, + set: { jumpHost.port = Int($0) ?? 22 } + ), + prompt: Text("22") ) - HStack { - TextField( - String(localized: "Port"), - text: Binding( - get: { String(jumpHost.port) }, - set: { jumpHost.port = Int($0) ?? 22 } - ), - prompt: Text("22") - ) - .frame(width: 80) - TextField( - String(localized: "Username"), - text: $jumpHost.username, - prompt: Text("admin") - ) - } - Picker(String(localized: "Auth"), selection: $jumpHost.authMethod) { - ForEach(SSHJumpAuthMethod.allCases) { method in - Text(method.rawValue).tag(method) - } + .frame(width: 80) + TextField( + String(localized: "Username"), + text: $jumpHost.username, + prompt: Text("admin") + ) + } + Picker(String(localized: "Auth"), selection: $jumpHost.authMethod) { + ForEach(SSHJumpAuthMethod.allCases) { method in + Text(method.rawValue).tag(method) } - if jumpHost.authMethod == .privateKey { - LabeledContent(String(localized: "Key File")) { - HStack { - TextField( - "", text: $jumpHost.privateKeyPath, - prompt: Text("~/.ssh/id_rsa")) - Button(String(localized: "Browse")) { - browseForJumpHostKey(jumpHost: $jumpHost) - } - .controlSize(.small) + } + if jumpHost.authMethod == .privateKey { + LabeledContent(String(localized: "Key File")) { + HStack { + TextField( + "", text: $jumpHost.privateKeyPath, + prompt: Text("~/.ssh/id_rsa")) + Button(String(localized: "Browse")) { + browseForJumpHostKey(jumpHost: $jumpHost) } + .controlSize(.small) } } - } label: { - HStack { - Text( - jumpHost.host.isEmpty - ? String(localized: "New Jump Host") - : "\(jumpHost.username)@\(jumpHost.host)" - ) - .foregroundStyle(jumpHost.host.isEmpty ? .secondary : .primary) - Spacer() - Button { - let idToRemove = jumpHost.id - withAnimation { - jumpHosts.removeAll { $0.id == idToRemove } - } - } label: { - Image(systemName: "minus.circle.fill") - .foregroundStyle(.red) + } + } label: { + HStack { + Text( + jumpHost.host.isEmpty + ? String(localized: "New Jump Host") + : "\(jumpHost.username)@\(jumpHost.host)" + ) + .foregroundStyle(jumpHost.host.isEmpty ? .secondary : .primary) + Spacer() + Button { + let idToRemove = jumpHost.id + withAnimation { + jumpHosts.removeAll { $0.id == idToRemove } } - .buttonStyle(.plain) + } label: { + Image(systemName: "minus.circle.fill") + .foregroundStyle(.red) } + .buttonStyle(.plain) } } - .onMove { indices, destination in - jumpHosts.move(fromOffsets: indices, toOffset: destination) - } - - Button { - jumpHosts.append(SSHJumpHost()) - } label: { - Label(String(localized: "Add Jump Host"), systemImage: "plus") - } + } + .onMove { indices, destination in + jumpHosts.move(fromOffsets: indices, toOffset: destination) + } - Text( - "Jump hosts are connected in order before reaching the SSH server above. Only key and agent auth are supported for jumps." - ) - .font(.caption) - .foregroundStyle(.secondary) + Button { + jumpHosts.append(SSHJumpHost()) + } label: { + Label(String(localized: "Add Jump Host"), systemImage: "plus") } + + Text( + "Jump hosts are connected in order before reaching the SSH server above. Only key and agent auth are supported for jumps." + ) + .font(.caption) + .foregroundStyle(.secondary) + } } } } From 021cc7e1ae2b70d7427e3806a840db2152547794 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ng=C3=B4=20Qu=E1=BB=91c=20=C4=90=E1=BA=A1t?= Date: Fri, 20 Mar 2026 12:48:39 +0700 Subject: [PATCH 06/11] feat: add SSH profile editor with create, edit, save-as, and delete --- .../Views/Connection/ConnectionFormView.swift | 66 +++ .../Connection/SSHProfileEditorView.swift | 443 ++++++++++++++++++ 2 files changed, 509 insertions(+) create mode 100644 TablePro/Views/Connection/SSHProfileEditorView.swift diff --git a/TablePro/Views/Connection/ConnectionFormView.swift b/TablePro/Views/Connection/ConnectionFormView.swift index 8a4e4f2a4..f4a367022 100644 --- a/TablePro/Views/Connection/ConnectionFormView.swift +++ b/TablePro/Views/Connection/ConnectionFormView.swift @@ -58,6 +58,9 @@ struct ConnectionFormView: View { // swiftlint:disable:this type_body_length // SSH Configuration @State private var sshProfileId: UUID? @State private var sshProfiles: [SSHProfile] = [] + @State private var showingCreateProfile = false + @State private var editingProfile: SSHProfile? + @State private var showingSaveAsProfile = false @State private var sshEnabled: Bool = false @State private var sshHost: String = "" @State private var sshPort: String = "22" @@ -482,10 +485,73 @@ struct ConnectionFormView: View { // swiftlint:disable:this type_body_length Text("\(profile.name) (\(profile.username)@\(profile.host))").tag(UUID?.some(profile.id)) } } + + HStack(spacing: 12) { + Button("Create New Profile...") { + showingCreateProfile = true + } + + if sshProfileId != nil { + Button("Edit Profile...") { + if let profileId = sshProfileId { + editingProfile = SSHProfileStorage.shared.profile(for: profileId) + } + } + } + + if sshProfileId == nil && sshEnabled && !sshHost.isEmpty { + Button("Save Current as Profile...") { + showingSaveAsProfile = true + } + } + } + .controlSize(.small) } .onAppear { sshProfiles = SSHProfileStorage.shared.loadProfiles() } + .sheet(isPresented: $showingCreateProfile) { + SSHProfileEditorView(existingProfile: nil) { _ in + reloadProfiles() + } + } + .sheet(item: $editingProfile) { profile in + SSHProfileEditorView(existingProfile: profile) { _ in + reloadProfiles() + } + } + .sheet(isPresented: $showingSaveAsProfile) { + SSHProfileEditorView(existingProfile: buildProfileFromInlineConfig()) { savedProfile in + sshProfileId = savedProfile.id + reloadProfiles() + } + } + } + + private func reloadProfiles() { + sshProfiles = SSHProfileStorage.shared.loadProfiles() + // If the edited/deleted profile no longer exists, clear the selection + if let id = sshProfileId, !sshProfiles.contains(where: { $0.id == id }) { + sshProfileId = nil + } + } + + private func buildProfileFromInlineConfig() -> SSHProfile { + SSHProfile( + name: "", + host: sshHost, + port: Int(sshPort) ?? 22, + username: sshUsername, + authMethod: sshAuthMethod, + privateKeyPath: sshPrivateKeyPath, + useSSHConfig: !selectedSSHConfigHost.isEmpty, + agentSocketPath: resolvedSSHAgentSocketPath, + jumpHosts: jumpHosts, + totpMode: totpMode, + totpAlgorithm: totpAlgorithm, + totpDigits: totpDigits, + totpPeriod: totpPeriod + ) } private func sshProfileSummarySection(_ profile: SSHProfile) -> some View { diff --git a/TablePro/Views/Connection/SSHProfileEditorView.swift b/TablePro/Views/Connection/SSHProfileEditorView.swift new file mode 100644 index 000000000..049e7e55a --- /dev/null +++ b/TablePro/Views/Connection/SSHProfileEditorView.swift @@ -0,0 +1,443 @@ +// +// SSHProfileEditorView.swift +// TablePro +// + +import SwiftUI + +struct SSHProfileEditorView: View { + @Environment(\.dismiss) private var dismiss + + let existingProfile: SSHProfile? + var onSave: ((SSHProfile) -> Void)? + + // Profile identity + @State private var profileName: String = "" + + // Server + @State private var host: String = "" + @State private var port: String = "22" + @State private var username: String = "" + + // Authentication + @State private var authMethod: SSHAuthMethod = .password + @State private var sshPassword: String = "" + @State private var privateKeyPath: String = "" + @State private var keyPassphrase: String = "" + @State private var agentSocketOption: SSHAgentSocketOption = .systemDefault + @State private var customAgentSocketPath: String = "" + + // TOTP + @State private var totpMode: TOTPMode = .none + @State private var totpSecret: String = "" + @State private var totpAlgorithm: TOTPAlgorithm = .sha1 + @State private var totpDigits: Int = 6 + @State private var totpPeriod: Int = 30 + + // Jump hosts + @State private var jumpHosts: [SSHJumpHost] = [] + + // SSH config auto-fill + @State private var sshConfigEntries: [SSHConfigEntry] = [] + @State private var selectedSSHConfigHost: String = "" + + // Deletion + @State private var showingDeleteConfirmation = false + @State private var connectionsUsingProfile = 0 + + private var isEditing: Bool { existingProfile != nil } + + private var isValid: Bool { + !profileName.trimmingCharacters(in: .whitespaces).isEmpty + && !host.trimmingCharacters(in: .whitespaces).isEmpty + } + + private var resolvedAgentSocketPath: String { + agentSocketOption.resolvedPath(customPath: customAgentSocketPath) + } + + var body: some View { + VStack(spacing: 0) { + Form { + Section(String(localized: "Profile")) { + TextField(String(localized: "Name"), text: $profileName, prompt: Text("My Server")) + } + + serverSection + authenticationSection + + if authMethod == .keyboardInteractive || authMethod == .password { + totpSection + } + + jumpHostsSection + } + .formStyle(.grouped) + .scrollContentBackground(.hidden) + + Divider() + bottomBar + } + .frame(minWidth: 480, minHeight: 500) + .onAppear { + sshConfigEntries = SSHConfigParser.parse() + loadExistingProfile() + } + } + + // MARK: - Server Section + + private var serverSection: some View { + Section(String(localized: "Server")) { + if !sshConfigEntries.isEmpty { + Picker(String(localized: "Config Host"), selection: $selectedSSHConfigHost) { + Text(String(localized: "Manual")).tag("") + ForEach(sshConfigEntries) { entry in + Text(entry.displayName).tag(entry.host) + } + } + .onChange(of: selectedSSHConfigHost) { + applySSHConfigEntry(selectedSSHConfigHost) + } + } + if selectedSSHConfigHost.isEmpty || sshConfigEntries.isEmpty { + TextField(String(localized: "SSH Host"), text: $host, prompt: Text("ssh.example.com")) + } + TextField(String(localized: "SSH Port"), text: $port, prompt: Text("22")) + TextField(String(localized: "SSH User"), text: $username, prompt: Text("username")) + } + } + + // MARK: - Authentication Section + + private var authenticationSection: some View { + Section(String(localized: "Authentication")) { + Picker(String(localized: "Method"), selection: $authMethod) { + ForEach(SSHAuthMethod.allCases) { method in + Text(method.rawValue).tag(method) + } + } + if authMethod == .password { + SecureField(String(localized: "Password"), text: $sshPassword) + } else if authMethod == .sshAgent { + Picker("Agent Socket", selection: $agentSocketOption) { + ForEach(SSHAgentSocketOption.allCases) { option in + Text(option.displayName).tag(option) + } + } + if agentSocketOption == .custom { + TextField("Custom Path", text: $customAgentSocketPath, prompt: Text("/path/to/agent.sock")) + } + Text("Keys are provided by the SSH agent (e.g. 1Password, ssh-agent).") + .font(.caption) + .foregroundStyle(.secondary) + } else if authMethod == .keyboardInteractive { + SecureField(String(localized: "Password"), text: $sshPassword) + Text(String(localized: "Password is sent via keyboard-interactive challenge-response.")) + .font(.caption) + .foregroundStyle(.secondary) + } else { + LabeledContent(String(localized: "Key File")) { + HStack { + TextField("", text: $privateKeyPath, prompt: Text("~/.ssh/id_rsa")) + Button(String(localized: "Browse")) { browseForPrivateKey() } + .controlSize(.small) + } + } + SecureField(String(localized: "Passphrase"), text: $keyPassphrase) + } + } + } + + // MARK: - TOTP Section + + private var totpSection: some View { + Section(String(localized: "Two-Factor Authentication")) { + Picker(String(localized: "TOTP"), selection: $totpMode) { + ForEach(TOTPMode.allCases) { mode in + Text(mode.displayName).tag(mode) + } + } + + if totpMode == .autoGenerate { + SecureField(String(localized: "TOTP Secret"), text: $totpSecret) + .help(String(localized: "Base32-encoded secret from your authenticator setup")) + + Picker(String(localized: "Algorithm"), selection: $totpAlgorithm) { + ForEach(TOTPAlgorithm.allCases) { algo in + Text(algo.rawValue).tag(algo) + } + } + + Picker(String(localized: "Digits"), selection: $totpDigits) { + Text("6").tag(6) + Text("8").tag(8) + } + + Picker(String(localized: "Period"), selection: $totpPeriod) { + Text("30s").tag(30) + Text("60s").tag(60) + } + } else if totpMode == .promptAtConnect { + Text(String(localized: "You will be prompted for a verification code each time you connect.")) + .font(.caption) + .foregroundStyle(.secondary) + } + } + } + + // MARK: - Jump Hosts Section + + private var jumpHostsSection: some View { + Section { + DisclosureGroup(String(localized: "Jump Hosts")) { + ForEach($jumpHosts) { $jumpHost in + DisclosureGroup { + TextField(String(localized: "Host"), text: $jumpHost.host, prompt: Text("bastion.example.com")) + HStack { + TextField( + String(localized: "Port"), + text: Binding( + get: { String(jumpHost.port) }, + set: { jumpHost.port = Int($0) ?? 22 } + ), + prompt: Text("22") + ) + .frame(width: 80) + TextField(String(localized: "Username"), text: $jumpHost.username, prompt: Text("admin")) + } + Picker(String(localized: "Auth"), selection: $jumpHost.authMethod) { + ForEach(SSHJumpAuthMethod.allCases) { method in + Text(method.rawValue).tag(method) + } + } + if jumpHost.authMethod == .privateKey { + LabeledContent(String(localized: "Key File")) { + HStack { + TextField("", text: $jumpHost.privateKeyPath, prompt: Text("~/.ssh/id_rsa")) + Button(String(localized: "Browse")) { + browseForJumpHostKey(jumpHost: $jumpHost) + } + .controlSize(.small) + } + } + } + } label: { + HStack { + Text( + jumpHost.host.isEmpty + ? String(localized: "New Jump Host") + : "\(jumpHost.username)@\(jumpHost.host)" + ) + .foregroundStyle(jumpHost.host.isEmpty ? .secondary : .primary) + Spacer() + Button { + let idToRemove = jumpHost.id + withAnimation { jumpHosts.removeAll { $0.id == idToRemove } } + } label: { + Image(systemName: "minus.circle.fill").foregroundStyle(.red) + } + .buttonStyle(.plain) + } + } + } + .onMove { indices, destination in + jumpHosts.move(fromOffsets: indices, toOffset: destination) + } + + Button { + jumpHosts.append(SSHJumpHost()) + } label: { + Label(String(localized: "Add Jump Host"), systemImage: "plus") + } + + Text("Jump hosts are connected in order before reaching the SSH server above. Only key and agent auth are supported for jumps.") + .font(.caption) + .foregroundStyle(.secondary) + } + } + } + + // MARK: - Bottom Bar + + private var bottomBar: some View { + HStack { + if isEditing { + Button(role: .destructive) { + connectionsUsingProfile = ConnectionStorage.shared.loadConnections() + .filter { $0.sshProfileId == existingProfile?.id }.count + showingDeleteConfirmation = true + } label: { + Text("Delete Profile") + } + .confirmationDialog( + "Delete SSH Profile?", + isPresented: $showingDeleteConfirmation, + titleVisibility: .visible + ) { + Button("Delete", role: .destructive) { deleteProfile() } + } message: { + if connectionsUsingProfile > 0 { + Text("\(connectionsUsingProfile) connection(s) use this profile. They will fall back to no SSH tunnel.") + } else { + Text("This profile will be permanently deleted.") + } + } + } + + Spacer() + + Button("Cancel") { dismiss() } + .keyboardShortcut(.cancelAction) + + Button(isEditing ? "Save" : "Create") { saveProfile() } + .keyboardShortcut(.defaultAction) + .disabled(!isValid) + } + .padding() + } + + // MARK: - Actions + + private func loadExistingProfile() { + guard let profile = existingProfile else { return } + profileName = profile.name + host = profile.host + port = String(profile.port) + username = profile.username + authMethod = profile.authMethod + privateKeyPath = profile.privateKeyPath + jumpHosts = profile.jumpHosts + totpMode = profile.totpMode + totpAlgorithm = profile.totpAlgorithm + totpDigits = profile.totpDigits + totpPeriod = profile.totpPeriod + + let option = SSHAgentSocketOption(socketPath: profile.agentSocketPath) + agentSocketOption = option + if option == .custom { + customAgentSocketPath = profile.agentSocketPath + } + + // Load secrets from Keychain + if let pwd = SSHProfileStorage.shared.loadSSHPassword(for: profile.id) { + sshPassword = pwd + } + if let phrase = SSHProfileStorage.shared.loadKeyPassphrase(for: profile.id) { + keyPassphrase = phrase + } + if let secret = SSHProfileStorage.shared.loadTOTPSecret(for: profile.id) { + totpSecret = secret + } + } + + private func saveProfile() { + let profileId = existingProfile?.id ?? UUID() + + let profile = SSHProfile( + id: profileId, + name: profileName.trimmingCharacters(in: .whitespaces), + host: host, + port: Int(port) ?? 22, + username: username, + authMethod: authMethod, + privateKeyPath: privateKeyPath, + useSSHConfig: !selectedSSHConfigHost.isEmpty, + agentSocketPath: resolvedAgentSocketPath, + jumpHosts: jumpHosts, + totpMode: totpMode, + totpAlgorithm: totpAlgorithm, + totpDigits: totpDigits, + totpPeriod: totpPeriod + ) + + if isEditing { + SSHProfileStorage.shared.updateProfile(profile) + } else { + SSHProfileStorage.shared.addProfile(profile) + } + + // Save secrets to Keychain + if (authMethod == .password || authMethod == .keyboardInteractive) && !sshPassword.isEmpty { + SSHProfileStorage.shared.saveSSHPassword(sshPassword, for: profileId) + } else { + SSHProfileStorage.shared.deleteSSHPassword(for: profileId) + } + + if authMethod == .privateKey && !keyPassphrase.isEmpty { + SSHProfileStorage.shared.saveKeyPassphrase(keyPassphrase, for: profileId) + } else { + SSHProfileStorage.shared.deleteKeyPassphrase(for: profileId) + } + + if totpMode == .autoGenerate && !totpSecret.isEmpty { + SSHProfileStorage.shared.saveTOTPSecret(totpSecret, for: profileId) + } else { + SSHProfileStorage.shared.deleteTOTPSecret(for: profileId) + } + + onSave?(profile) + dismiss() + } + + private func deleteProfile() { + guard let profile = existingProfile else { return } + SSHProfileStorage.shared.deleteProfile(profile) + onSave?(profile) + dismiss() + } + + // MARK: - SSH Config Helpers + + private func applySSHConfigEntry(_ configHost: String) { + guard let entry = sshConfigEntries.first(where: { $0.host == configHost }) else { return } + + host = entry.hostname ?? entry.host + if let entryPort = entry.port { + port = String(entryPort) + } + if let user = entry.user { + username = user + } + if let agentPath = entry.identityAgent { + let option = SSHAgentSocketOption(socketPath: agentPath) + agentSocketOption = option + if option == .custom { + customAgentSocketPath = agentPath.trimmingCharacters(in: .whitespacesAndNewlines) + } + authMethod = .sshAgent + } else if let keyPath = entry.identityFile { + privateKeyPath = keyPath + authMethod = .privateKey + } + if let proxyJump = entry.proxyJump { + jumpHosts = SSHConfigParser.parseProxyJump(proxyJump) + } + } + + private func browseForPrivateKey() { + let panel = NSOpenPanel() + panel.allowsMultipleSelection = false + panel.canChooseDirectories = false + panel.directoryURL = FileManager.default.homeDirectoryForCurrentUser.appendingPathComponent(".ssh") + panel.showsHiddenFiles = true + panel.begin { response in + if response == .OK, let url = panel.url { + privateKeyPath = url.path(percentEncoded: false) + } + } + } + + private func browseForJumpHostKey(jumpHost: Binding) { + let panel = NSOpenPanel() + panel.allowsMultipleSelection = false + panel.canChooseDirectories = false + panel.directoryURL = FileManager.default.homeDirectoryForCurrentUser.appendingPathComponent(".ssh") + panel.showsHiddenFiles = true + panel.begin { response in + if response == .OK, let url = panel.url { + jumpHost.wrappedValue.privateKeyPath = url.path(percentEncoded: false) + } + } + } +} From 07ff07540139a38655e7b9f2f03747cedfbcee4f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ng=C3=B4=20Qu=E1=BB=91c=20=C4=90=E1=BA=A1t?= Date: Fri, 20 Mar 2026 12:56:17 +0700 Subject: [PATCH 07/11] fix: address review issues in SSH profile integration - Fix testConnection leaking Keychain secrets and overriding profile passwords - Clean up all temporary Keychain entries (password, passphrase, TOTP) after test - Skip inline SSH validation when a profile is selected (isValid) - Move profile list loading from section onAppear to loadConnectionData - Separate onSave/onDelete callbacks in SSHProfileEditorView - Pass inline secrets to Save as Profile flow so TOTP secret is preserved --- .../Views/Connection/ConnectionFormView.swift | 68 ++++++++++++------- .../Connection/SSHProfileEditorView.swift | 20 +++--- 2 files changed, 51 insertions(+), 37 deletions(-) diff --git a/TablePro/Views/Connection/ConnectionFormView.swift b/TablePro/Views/Connection/ConnectionFormView.swift index f4a367022..170d46e14 100644 --- a/TablePro/Views/Connection/ConnectionFormView.swift +++ b/TablePro/Views/Connection/ConnectionFormView.swift @@ -507,24 +507,29 @@ struct ConnectionFormView: View { // swiftlint:disable:this type_body_length } .controlSize(.small) } - .onAppear { - sshProfiles = SSHProfileStorage.shared.loadProfiles() - } .sheet(isPresented: $showingCreateProfile) { - SSHProfileEditorView(existingProfile: nil) { _ in + SSHProfileEditorView(existingProfile: nil, onSave: { _ in reloadProfiles() - } + }) } .sheet(item: $editingProfile) { profile in - SSHProfileEditorView(existingProfile: profile) { _ in + SSHProfileEditorView(existingProfile: profile, onSave: { _ in reloadProfiles() - } + }, onDelete: { + reloadProfiles() + }) } .sheet(isPresented: $showingSaveAsProfile) { - SSHProfileEditorView(existingProfile: buildProfileFromInlineConfig()) { savedProfile in - sshProfileId = savedProfile.id - reloadProfiles() - } + SSHProfileEditorView( + existingProfile: buildProfileFromInlineConfig(), + initialPassword: sshPassword, + initialKeyPassphrase: keyPassphrase, + initialTOTPSecret: totpSecret, + onSave: { savedProfile in + sshProfileId = savedProfile.id + reloadProfiles() + } + ) } } @@ -1002,7 +1007,7 @@ struct ConnectionFormView: View { // swiftlint:disable:this type_body_length .allSatisfy { !(additionalFieldValues[$0.id] ?? "").isEmpty } basicValid = basicValid && hasRequiredFields && !password.isEmpty } - if sshEnabled { + if sshEnabled && sshProfileId == nil { let sshPortValid = sshPort.isEmpty || (Int(sshPort).map { (1...65_535).contains($0) } ?? false) let sshValid = !sshHost.isEmpty && !sshUsername.isEmpty && sshPortValid let authValid = @@ -1028,6 +1033,7 @@ struct ConnectionFormView: View { // swiftlint:disable:this type_body_length } private func loadConnectionData() { + sshProfiles = SSHProfileStorage.shared.loadProfiles() // If editing, load from storage if let id = connectionId, let existing = storage.loadConnections().first(where: { $0.id == id }) @@ -1345,22 +1351,25 @@ struct ConnectionFormView: View { // swiftlint:disable:this type_body_length if !password.isEmpty { ConnectionStorage.shared.savePassword(password, for: testConn.id) } - if sshEnabled - && (sshAuthMethod == .password || sshAuthMethod == .keyboardInteractive) - && !sshPassword.isEmpty - { - ConnectionStorage.shared.saveSSHPassword(sshPassword, for: testConn.id) - } - if sshEnabled && sshAuthMethod == .privateKey && !keyPassphrase.isEmpty { - ConnectionStorage.shared.saveKeyPassphrase(keyPassphrase, for: testConn.id) - } - if sshEnabled && totpMode == .autoGenerate && !totpSecret.isEmpty { - ConnectionStorage.shared.saveTOTPSecret(totpSecret, for: testConn.id) + // Only write inline SSH secrets when not using a profile + if sshEnabled && sshProfileId == nil { + if (sshAuthMethod == .password || sshAuthMethod == .keyboardInteractive) + && !sshPassword.isEmpty + { + ConnectionStorage.shared.saveSSHPassword(sshPassword, for: testConn.id) + } + if sshAuthMethod == .privateKey && !keyPassphrase.isEmpty { + ConnectionStorage.shared.saveKeyPassphrase(keyPassphrase, for: testConn.id) + } + if totpMode == .autoGenerate && !totpSecret.isEmpty { + ConnectionStorage.shared.saveTOTPSecret(totpSecret, for: testConn.id) + } } + let sshPasswordForTest = sshProfileId == nil ? sshPassword : nil let success = try await DatabaseManager.shared.testConnection( - testConn, sshPassword: sshPassword) - ConnectionStorage.shared.deleteTOTPSecret(for: testConn.id) + testConn, sshPassword: sshPasswordForTest) + cleanupTestSecrets(for: testConn.id) await MainActor.run { isTesting = false if success { @@ -1374,7 +1383,7 @@ struct ConnectionFormView: View { // swiftlint:disable:this type_body_length } } } catch { - ConnectionStorage.shared.deleteTOTPSecret(for: testConn.id) + cleanupTestSecrets(for: testConn.id) await MainActor.run { isTesting = false testSucceeded = false @@ -1405,6 +1414,13 @@ struct ConnectionFormView: View { // swiftlint:disable:this type_body_length } } + private func cleanupTestSecrets(for testId: UUID) { + ConnectionStorage.shared.deletePassword(for: testId) + ConnectionStorage.shared.deleteSSHPassword(for: testId) + ConnectionStorage.shared.deleteKeyPassphrase(for: testId) + ConnectionStorage.shared.deleteTOTPSecret(for: testId) + } + private func browseForPrivateKey() { let panel = NSOpenPanel() panel.allowsMultipleSelection = false diff --git a/TablePro/Views/Connection/SSHProfileEditorView.swift b/TablePro/Views/Connection/SSHProfileEditorView.swift index 049e7e55a..2faaf5d4e 100644 --- a/TablePro/Views/Connection/SSHProfileEditorView.swift +++ b/TablePro/Views/Connection/SSHProfileEditorView.swift @@ -9,7 +9,11 @@ struct SSHProfileEditorView: View { @Environment(\.dismiss) private var dismiss let existingProfile: SSHProfile? + var initialPassword: String? + var initialKeyPassphrase: String? + var initialTOTPSecret: String? var onSave: ((SSHProfile) -> Void)? + var onDelete: (() -> Void)? // Profile identity @State private var profileName: String = "" @@ -319,16 +323,10 @@ struct SSHProfileEditorView: View { customAgentSocketPath = profile.agentSocketPath } - // Load secrets from Keychain - if let pwd = SSHProfileStorage.shared.loadSSHPassword(for: profile.id) { - sshPassword = pwd - } - if let phrase = SSHProfileStorage.shared.loadKeyPassphrase(for: profile.id) { - keyPassphrase = phrase - } - if let secret = SSHProfileStorage.shared.loadTOTPSecret(for: profile.id) { - totpSecret = secret - } + // Load secrets from Keychain, falling back to initial values (e.g. from "Save as Profile") + sshPassword = SSHProfileStorage.shared.loadSSHPassword(for: profile.id) ?? initialPassword ?? "" + keyPassphrase = SSHProfileStorage.shared.loadKeyPassphrase(for: profile.id) ?? initialKeyPassphrase ?? "" + totpSecret = SSHProfileStorage.shared.loadTOTPSecret(for: profile.id) ?? initialTOTPSecret ?? "" } private func saveProfile() { @@ -383,7 +381,7 @@ struct SSHProfileEditorView: View { private func deleteProfile() { guard let profile = existingProfile else { return } SSHProfileStorage.shared.deleteProfile(profile) - onSave?(profile) + onDelete?() dismiss() } From 098dfecd827040eae5b8a06086eaaa212298a963 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ng=C3=B4=20Qu=E1=BB=91c=20=C4=90=E1=BA=A1t?= Date: Fri, 20 Mar 2026 13:07:40 +0700 Subject: [PATCH 08/11] feat: add iCloud sync for SSH profiles and documentation - Add SyncRecordType.sshProfile with CKRecord mapping - Add syncSSHProfiles toggle to SyncSettings - Add sync tracking (markDirty/markDeleted) in SSHProfileStorage - Add saveProfilesWithoutSync for applying remote changes - Update SyncCoordinator: push, pull, delete, conflict handling - Handle .sshProfile in ConflictResolutionView - Add SSH Profiles feature docs (EN/VI/ZH) - Update SSH Tunneling docs with link to profiles - Update docs.json navigation --- TablePro/Core/Storage/SSHProfileStorage.swift | 12 ++ TablePro/Core/Sync/SyncCoordinator.swift | 66 ++++++++++- TablePro/Core/Sync/SyncRecordMapper.swift | 80 +++++++++++++ TablePro/Models/Settings/SyncSettings.swift | 9 +- .../Components/ConflictResolutionView.swift | 7 ++ .../Views/Settings/SyncSettingsView.swift | 3 + docs/databases/ssh-tunneling.mdx | 4 + docs/docs.json | 9 +- docs/features/ssh-profiles.mdx | 107 ++++++++++++++++++ docs/vi/databases/ssh-tunneling.mdx | 4 + docs/vi/features/ssh-profiles.mdx | 107 ++++++++++++++++++ docs/zh/databases/ssh-tunneling.mdx | 4 + docs/zh/features/ssh-profiles.mdx | 107 ++++++++++++++++++ 13 files changed, 513 insertions(+), 6 deletions(-) create mode 100644 docs/features/ssh-profiles.mdx create mode 100644 docs/vi/features/ssh-profiles.mdx create mode 100644 docs/zh/features/ssh-profiles.mdx diff --git a/TablePro/Core/Storage/SSHProfileStorage.swift b/TablePro/Core/Storage/SSHProfileStorage.swift index df468dfaf..73a1ff1c2 100644 --- a/TablePro/Core/Storage/SSHProfileStorage.swift +++ b/TablePro/Core/Storage/SSHProfileStorage.swift @@ -42,6 +42,17 @@ final class SSHProfileStorage { Self.logger.warning("Refusing to save SSH profiles: previous load failed (would overwrite existing data)") return } + do { + let data = try encoder.encode(profiles) + defaults.set(data, forKey: profilesKey) + SyncChangeTracker.shared.markDirty(.sshProfile, ids: profiles.map { $0.id.uuidString }) + } catch { + Self.logger.error("Failed to save SSH profiles: \(error)") + } + } + + func saveProfilesWithoutSync(_ profiles: [SSHProfile]) { + guard !lastLoadFailed else { return } do { let data = try encoder.encode(profiles) defaults.set(data, forKey: profilesKey) @@ -67,6 +78,7 @@ final class SSHProfileStorage { } func deleteProfile(_ profile: SSHProfile) { + SyncChangeTracker.shared.markDeleted(.sshProfile, id: profile.id.uuidString) var profiles = loadProfiles() guard !lastLoadFailed else { return } profiles.removeAll { $0.id == profile.id } diff --git a/TablePro/Core/Sync/SyncCoordinator.swift b/TablePro/Core/Sync/SyncCoordinator.swift index 94d57d48c..3d64c2121 100644 --- a/TablePro/Core/Sync/SyncCoordinator.swift +++ b/TablePro/Core/Sync/SyncCoordinator.swift @@ -146,12 +146,17 @@ final class SyncCoordinator { changeTracker.markDirty(.tag, id: tag.id.uuidString) } + let sshProfiles = SSHProfileStorage.shared.loadProfiles() + for profile in sshProfiles { + changeTracker.markDirty(.sshProfile, id: profile.id.uuidString) + } + // Mark all settings categories as dirty for category in ["general", "appearance", "editor", "dataGrid", "history", "tabs", "keyboard", "ai"] { changeTracker.markDirty(.settings, id: category) } - Self.logger.info("Marked all local data dirty: \(connections.count) connections, \(groups.count) groups, \(tags.count) tags, 8 settings categories") + Self.logger.info("Marked all local data dirty: \(connections.count) connections, \(groups.count) groups, \(tags.count) tags, \(sshProfiles.count) SSH profiles, 8 settings categories") } /// Called when user disables sync in settings @@ -254,6 +259,11 @@ final class SyncCoordinator { collectDirtyTags(into: &recordsToSave, deletions: &recordIDsToDelete, zoneID: zoneID) } + // Collect dirty SSH profiles + if settings.syncSSHProfiles { + collectDirtySSHProfiles(into: &recordsToSave, deletions: &recordIDsToDelete, zoneID: zoneID) + } + // Collect unsynced query history if settings.syncQueryHistory { let limit = settings.historySyncLimit.limit ?? Int.max @@ -301,6 +311,9 @@ final class SyncCoordinator { changeTracker.clearAllDirty(.group) changeTracker.clearAllDirty(.tag) } + if settings.syncSSHProfiles { + changeTracker.clearAllDirty(.sshProfile) + } if settings.syncSettings { changeTracker.clearAllDirty(.settings) } @@ -322,6 +335,11 @@ final class SyncCoordinator { metadataStorage.removeTombstone(type: .tag, id: tombstone.id) } } + if settings.syncSSHProfiles { + for tombstone in metadataStorage.tombstones(for: .sshProfile) { + metadataStorage.removeTombstone(type: .sshProfile, id: tombstone.id) + } + } if settings.syncSettings { for tombstone in metadataStorage.tombstones(for: .settings) { metadataStorage.removeTombstone(type: .settings, id: tombstone.id) @@ -416,6 +434,8 @@ final class SyncCoordinator { case SyncRecordType.tag.rawValue where settings.syncGroupsAndTags: applyRemoteTag(record) groupsOrTagsChanged = true + case SyncRecordType.sshProfile.rawValue where settings.syncSSHProfiles: + applyRemoteSSHProfile(record) case SyncRecordType.settings.rawValue where settings.syncSettings: applyRemoteSettings(record) case SyncRecordType.queryHistory.rawValue where settings.syncQueryHistory: @@ -494,6 +514,18 @@ final class SyncCoordinator { TagStorage.shared.saveTags(tags) } + private func applyRemoteSSHProfile(_ record: CKRecord) { + guard let remoteProfile = SyncRecordMapper.toSSHProfile(record) else { return } + + var profiles = SSHProfileStorage.shared.loadProfiles() + if let index = profiles.firstIndex(where: { $0.id == remoteProfile.id }) { + profiles[index] = remoteProfile + } else { + profiles.append(remoteProfile) + } + SSHProfileStorage.shared.saveProfilesWithoutSync(profiles) + } + private func applyRemoteSettings(_ record: CKRecord) { guard let category = SyncRecordMapper.settingsCategory(from: record), let data = SyncRecordMapper.settingsData(from: record) @@ -561,6 +593,15 @@ final class SyncCoordinator { TagStorage.shared.saveTags(tags) } } + + if recordName.hasPrefix("SSHProfile_") { + let uuidString = String(recordName.dropFirst("SSHProfile_".count)) + if let uuid = UUID(uuidString: uuidString) { + var profiles = SSHProfileStorage.shared.loadProfiles() + profiles.removeAll { $0.id == uuid } + SSHProfileStorage.shared.saveProfilesWithoutSync(profiles) + } + } } // MARK: - Observers @@ -654,6 +695,7 @@ final class SyncCoordinator { case SyncRecordType.tag.rawValue: syncRecordType = .tag case SyncRecordType.settings.rawValue: syncRecordType = .settings case SyncRecordType.queryHistory.rawValue: syncRecordType = .queryHistory + case SyncRecordType.sshProfile.rawValue: syncRecordType = .sshProfile default: continue } @@ -786,4 +828,26 @@ final class SyncCoordinator { ) } } + + private func collectDirtySSHProfiles( + into records: inout [CKRecord], + deletions: inout [CKRecord.ID], + zoneID: CKRecordZone.ID + ) { + let dirtyProfileIds = changeTracker.dirtyRecords(for: .sshProfile) + if !dirtyProfileIds.isEmpty { + let profiles = SSHProfileStorage.shared.loadProfiles() + for id in dirtyProfileIds { + if let profile = profiles.first(where: { $0.id.uuidString == id }) { + records.append(SyncRecordMapper.toCKRecord(profile, in: zoneID)) + } + } + } + + for tombstone in metadataStorage.tombstones(for: .sshProfile) { + deletions.append( + SyncRecordMapper.recordID(type: .sshProfile, id: tombstone.id, in: zoneID) + ) + } + } } diff --git a/TablePro/Core/Sync/SyncRecordMapper.swift b/TablePro/Core/Sync/SyncRecordMapper.swift index f72f7df4e..f3e554d17 100644 --- a/TablePro/Core/Sync/SyncRecordMapper.swift +++ b/TablePro/Core/Sync/SyncRecordMapper.swift @@ -18,6 +18,7 @@ enum SyncRecordType: String, CaseIterable { case queryHistory = "QueryHistory" case favorite = "SQLFavorite" case favoriteFolder = "SQLFavoriteFolder" + case sshProfile = "SSHProfile" } /// Pure-function mapper between local models and CKRecord @@ -44,6 +45,7 @@ struct SyncRecordMapper { case .queryHistory: recordName = "History_\(id)" case .favorite: recordName = "Favorite_\(id)" case .favoriteFolder: recordName = "FavoriteFolder_\(id)" + case .sshProfile: recordName = "SSHProfile_\(id)" } return CKRecord.ID(recordName: recordName, zoneID: zone) } @@ -311,4 +313,82 @@ struct SyncRecordMapper { return record } + + // MARK: - SSH Profile + + static func toCKRecord(_ profile: SSHProfile, in zone: CKRecordZone.ID) -> CKRecord { + let recordID = recordID(type: .sshProfile, id: profile.id.uuidString, in: zone) + let record = CKRecord(recordType: SyncRecordType.sshProfile.rawValue, recordID: recordID) + + record["profileId"] = profile.id.uuidString as CKRecordValue + record["name"] = profile.name as CKRecordValue + record["host"] = profile.host as CKRecordValue + record["port"] = Int64(profile.port) as CKRecordValue + record["username"] = profile.username as CKRecordValue + record["authMethod"] = profile.authMethod.rawValue as CKRecordValue + record["privateKeyPath"] = profile.privateKeyPath as CKRecordValue + record["useSSHConfig"] = Int64(profile.useSSHConfig ? 1 : 0) as CKRecordValue + record["agentSocketPath"] = profile.agentSocketPath as CKRecordValue + record["totpMode"] = profile.totpMode.rawValue as CKRecordValue + record["totpAlgorithm"] = profile.totpAlgorithm.rawValue as CKRecordValue + record["totpDigits"] = Int64(profile.totpDigits) as CKRecordValue + record["totpPeriod"] = Int64(profile.totpPeriod) as CKRecordValue + record["modifiedAtLocal"] = Date() as CKRecordValue + record["schemaVersion"] = schemaVersion as CKRecordValue + + if !profile.jumpHosts.isEmpty { + do { + let jumpHostsData = try encoder.encode(profile.jumpHosts) + record["jumpHostsJson"] = jumpHostsData as CKRecordValue + } catch { + logger.warning("Failed to encode jump hosts for sync: \(error.localizedDescription)") + } + } + + return record + } + + static func toSSHProfile(_ record: CKRecord) -> SSHProfile? { + guard let profileIdString = record["profileId"] as? String, + let profileId = UUID(uuidString: profileIdString), + let name = record["name"] as? String + else { + logger.warning("Failed to decode SSH profile from CKRecord: missing required fields") + return nil + } + + let host = record["host"] as? String ?? "" + let port = (record["port"] as? Int64).map { Int($0) } ?? 22 + let username = record["username"] as? String ?? "" + let authMethodRaw = record["authMethod"] as? String ?? SSHAuthMethod.password.rawValue + let privateKeyPath = record["privateKeyPath"] as? String ?? "" + let useSSHConfig = (record["useSSHConfig"] as? Int64 ?? 1) != 0 + let agentSocketPath = record["agentSocketPath"] as? String ?? "" + let totpModeRaw = record["totpMode"] as? String ?? TOTPMode.none.rawValue + let totpAlgorithmRaw = record["totpAlgorithm"] as? String ?? TOTPAlgorithm.sha1.rawValue + let totpDigits = (record["totpDigits"] as? Int64).map { Int($0) } ?? 6 + let totpPeriod = (record["totpPeriod"] as? Int64).map { Int($0) } ?? 30 + + var jumpHosts: [SSHJumpHost] = [] + if let jumpHostsData = record["jumpHostsJson"] as? Data { + jumpHosts = (try? decoder.decode([SSHJumpHost].self, from: jumpHostsData)) ?? [] + } + + return SSHProfile( + id: profileId, + name: name, + host: host, + port: port, + username: username, + authMethod: SSHAuthMethod(rawValue: authMethodRaw) ?? .password, + privateKeyPath: privateKeyPath, + useSSHConfig: useSSHConfig, + agentSocketPath: agentSocketPath, + jumpHosts: jumpHosts, + totpMode: TOTPMode(rawValue: totpModeRaw) ?? .none, + totpAlgorithm: TOTPAlgorithm(rawValue: totpAlgorithmRaw) ?? .sha1, + totpDigits: totpDigits, + totpPeriod: totpPeriod + ) + } } diff --git a/TablePro/Models/Settings/SyncSettings.swift b/TablePro/Models/Settings/SyncSettings.swift index 02fc6ebd6..b1e742015 100644 --- a/TablePro/Models/Settings/SyncSettings.swift +++ b/TablePro/Models/Settings/SyncSettings.swift @@ -16,6 +16,7 @@ struct SyncSettings: Codable, Equatable { var syncQueryHistory: Bool var historySyncLimit: HistorySyncLimit var syncPasswords: Bool + var syncSSHProfiles: Bool init( enabled: Bool, @@ -24,7 +25,8 @@ struct SyncSettings: Codable, Equatable { syncSettings: Bool, syncQueryHistory: Bool, historySyncLimit: HistorySyncLimit, - syncPasswords: Bool = false + syncPasswords: Bool = false, + syncSSHProfiles: Bool = true ) { self.enabled = enabled self.syncConnections = syncConnections @@ -33,6 +35,7 @@ struct SyncSettings: Codable, Equatable { self.syncQueryHistory = syncQueryHistory self.historySyncLimit = historySyncLimit self.syncPasswords = syncPasswords + self.syncSSHProfiles = syncSSHProfiles } init(from decoder: Decoder) throws { @@ -44,6 +47,7 @@ struct SyncSettings: Codable, Equatable { syncQueryHistory = try container.decode(Bool.self, forKey: .syncQueryHistory) historySyncLimit = try container.decode(HistorySyncLimit.self, forKey: .historySyncLimit) syncPasswords = try container.decodeIfPresent(Bool.self, forKey: .syncPasswords) ?? false + syncSSHProfiles = try container.decodeIfPresent(Bool.self, forKey: .syncSSHProfiles) ?? true } static let `default` = SyncSettings( @@ -53,7 +57,8 @@ struct SyncSettings: Codable, Equatable { syncSettings: true, syncQueryHistory: true, historySyncLimit: .entries500, - syncPasswords: false + syncPasswords: false, + syncSSHProfiles: true ) } diff --git a/TablePro/Views/Components/ConflictResolutionView.swift b/TablePro/Views/Components/ConflictResolutionView.swift index 867edf007..09704871e 100644 --- a/TablePro/Views/Components/ConflictResolutionView.swift +++ b/TablePro/Views/Components/ConflictResolutionView.swift @@ -142,6 +142,13 @@ struct ConflictResolutionView: View { if let name = record["name"] as? String { fieldRow(label: String(localized: "Name"), value: name) } + case .sshProfile: + if let name = record["name"] as? String { + fieldRow(label: String(localized: "Name"), value: name) + } + if let host = record["host"] as? String { + fieldRow(label: "Host", value: host) + } } } diff --git a/TablePro/Views/Settings/SyncSettingsView.swift b/TablePro/Views/Settings/SyncSettingsView.swift index 7e0d38f45..008cbfab6 100644 --- a/TablePro/Views/Settings/SyncSettingsView.swift +++ b/TablePro/Views/Settings/SyncSettingsView.swift @@ -131,6 +131,9 @@ struct SyncSettingsView: View { Toggle("Groups & Tags:", isOn: $syncSettings.syncGroupsAndTags) .onChange(of: syncSettings.syncGroupsAndTags) { _, _ in persistSettings() } + Toggle("SSH Profiles:", isOn: $syncSettings.syncSSHProfiles) + .onChange(of: syncSettings.syncSSHProfiles) { _, _ in persistSettings() } + Toggle("Settings:", isOn: $syncSettings.syncSettings) .onChange(of: syncSettings.syncSettings) { _, _ in persistSettings() } diff --git a/docs/databases/ssh-tunneling.mdx b/docs/databases/ssh-tunneling.mdx index 84d49e150..08e3f0629 100644 --- a/docs/databases/ssh-tunneling.mdx +++ b/docs/databases/ssh-tunneling.mdx @@ -7,6 +7,10 @@ description: Route database connections through an encrypted SSH tunnel to reach SSH tunneling routes your database connection through an encrypted tunnel to reach servers that aren't directly accessible from your Mac. TablePro manages the tunnel lifecycle, including keep-alive and auto-reconnect. + +If you connect to multiple databases through the same SSH server, you can save your SSH configuration as a reusable profile. See [SSH Profiles](/features/ssh-profiles). + + ## How SSH Tunneling Works ```mermaid diff --git a/docs/docs.json b/docs/docs.json index 74a901a2b..77464b8d8 100644 --- a/docs/docs.json +++ b/docs/docs.json @@ -65,7 +65,8 @@ "features/keyboard-shortcuts", "features/deep-links", "features/safe-mode", - "features/icloud-sync" + "features/icloud-sync", + "features/ssh-profiles" ] }, { @@ -161,7 +162,8 @@ "vi/features/keyboard-shortcuts", "vi/features/deep-links", "vi/features/safe-mode", - "vi/features/icloud-sync" + "vi/features/icloud-sync", + "vi/features/ssh-profiles" ] }, { @@ -262,7 +264,8 @@ "zh/features/keyboard-shortcuts", "zh/features/deep-links", "zh/features/safe-mode", - "zh/features/icloud-sync" + "zh/features/icloud-sync", + "zh/features/ssh-profiles" ] }, { diff --git a/docs/features/ssh-profiles.mdx b/docs/features/ssh-profiles.mdx new file mode 100644 index 000000000..4304faa96 --- /dev/null +++ b/docs/features/ssh-profiles.mdx @@ -0,0 +1,107 @@ +--- +title: SSH Profiles +description: Create reusable SSH tunnel configurations that can be shared across multiple database connections +--- + +# SSH Profiles + +SSH profiles let you define an SSH tunnel configuration once and reuse it across multiple database connections. Instead of entering the same SSH host, port, username, and auth settings for every connection that tunnels through the same server, create a profile and select it from a dropdown. + +## Creating an SSH Profile + + + + Open a new or existing connection and switch to the **SSH Tunnel** tab. + + + Toggle the **SSH Tunnel** switch to ON. + + + Click **Use SSH Profile** to switch from inline configuration to profile mode, then click **Create New Profile...** from the profile dropdown. + + + Enter a profile name and configure the SSH settings (host, port, username, authentication method). These are the same fields as inline SSH configuration. + + + Click **Save** to store the profile. It is now available in the profile dropdown for all connections. + + + +### Profile Fields + +| Field | Description | +|-------|-------------| +| **Profile Name** | A label to identify this profile (e.g., "Production Bastion", "Staging Jump") | +| **SSH Host** | SSH server hostname or IP | +| **SSH Port** | SSH server port (default `22`) | +| **SSH User** | SSH username | +| **Auth Method** | Password, Private Key, or SSH Agent | +| **Jump Hosts** | Optional multi-hop configuration | + +Authentication fields (password, key file, passphrase, agent socket) depend on the selected auth method. These work identically to [inline SSH tunnel settings](/databases/ssh-tunneling#authentication-methods). + +## Using SSH Profiles + +1. Open a connection's **SSH Tunnel** tab and enable SSH +2. Click **Use SSH Profile** to switch to profile mode +3. Select a profile from the dropdown +4. The SSH settings display as a read-only summary showing the profile's host, port, user, and auth method + +The connection stores a reference to the profile, not a copy of the settings. If you update the profile later, all connections using it pick up the changes automatically. + +To switch back to per-connection SSH settings, click **Use Inline Config**. This detaches the connection from the profile and lets you edit SSH fields directly. + +## Editing and Deleting Profiles + +### Editing + +1. Open any connection that uses the profile (or create a new one and select the profile) +2. Click the **Edit** button next to the profile dropdown +3. Modify the settings and click **Save** + +Changes apply to all connections that reference this profile. + +### Deleting + +1. Open the profile editor +2. Click **Delete Profile** +3. A confirmation dialog shows how many connections currently use this profile +4. Confirm to delete. Affected connections revert to inline SSH configuration with the profile's settings copied in, so no connection loses its SSH config. + +## Save Current Config as Profile + +If you already have a connection with inline SSH settings and want to reuse that configuration: + +1. Open the connection's **SSH Tunnel** tab +2. Click **Save Current as Profile...** +3. Enter a profile name +4. The current SSH settings are saved as a new profile, and the connection switches to using that profile + +This is useful when you realize multiple connections share the same SSH tunnel and you want to consolidate them. + +## iCloud Sync + +SSH profiles sync across your Macs when iCloud Sync is enabled with the **Connections** category turned on. Profiles are synced alongside your connections. + + +SSH passwords and key passphrases are **not** synced with the profile by default. These credentials are stored in the local macOS Keychain. Enable **Password sync** in **Settings** > **Sync** to sync credentials via iCloud Keychain (end-to-end encrypted by Apple). + + +See [iCloud Sync](/features/icloud-sync) for full sync configuration details. + +## Related pages + + + + Full SSH tunnel setup and troubleshooting + + + Sync settings across Macs + + + Managing all your connections + + + Speed up your workflow + + diff --git a/docs/vi/databases/ssh-tunneling.mdx b/docs/vi/databases/ssh-tunneling.mdx index aab1d587d..52ee5a0ce 100644 --- a/docs/vi/databases/ssh-tunneling.mdx +++ b/docs/vi/databases/ssh-tunneling.mdx @@ -7,6 +7,10 @@ description: Định tuyến kết nối database qua tunnel SSH mã hóa để SSH tunneling định tuyến kết nối database qua tunnel mã hóa để truy cập server không truy cập trực tiếp từ Mac. TablePro quản lý vòng đời tunnel, bao gồm keep-alive và tự kết nối lại. + +Nếu bạn kết nối nhiều database qua cùng SSH server, bạn có thể lưu cấu hình SSH thành profile tái sử dụng. Xem [SSH Profiles](/vi/features/ssh-profiles). + + ## Cách SSH Tunneling Hoạt động ```mermaid diff --git a/docs/vi/features/ssh-profiles.mdx b/docs/vi/features/ssh-profiles.mdx new file mode 100644 index 000000000..488dd6198 --- /dev/null +++ b/docs/vi/features/ssh-profiles.mdx @@ -0,0 +1,107 @@ +--- +title: SSH Profiles +description: Tạo cấu hình SSH tunnel tái sử dụng để chia sẻ giữa nhiều kết nối database +--- + +# SSH Profiles + +SSH profiles cho phép bạn định nghĩa cấu hình SSH tunnel một lần và tái sử dụng cho nhiều kết nối database. Thay vì nhập cùng SSH host, port, username và cài đặt xác thực cho mỗi kết nối đi qua cùng server, tạo một profile và chọn từ dropdown. + +## Tạo SSH Profile + + + + Mở kết nối mới hoặc sẵn có và chuyển sang tab **SSH Tunnel**. + + + Bật công tắc **SSH Tunnel**. + + + Nhấp **Use SSH Profile** để chuyển từ cấu hình inline sang chế độ profile, sau đó chọn **Create New Profile...** từ dropdown profile. + + + Nhập tên profile và cấu hình SSH (host, port, username, phương thức xác thực). Các trường giống cấu hình SSH inline. + + + Nhấp **Save** để lưu profile. Profile giờ có sẵn trong dropdown cho tất cả kết nối. + + + +### Các Trường Profile + +| Trường | Mô tả | +|-------|-------------| +| **Profile Name** | Tên để nhận dạng profile (ví dụ: "Production Bastion", "Staging Jump") | +| **SSH Host** | Hostname hoặc IP của SSH server | +| **SSH Port** | Cổng SSH server (mặc định `22`) | +| **SSH User** | Tên người dùng SSH | +| **Auth Method** | Password, Private Key, hoặc SSH Agent | +| **Jump Hosts** | Cấu hình multi-hop tùy chọn | + +Các trường xác thực (password, key file, passphrase, agent socket) phụ thuộc vào phương thức xác thực đã chọn. Hoạt động giống [cài đặt SSH tunnel inline](/vi/databases/ssh-tunneling#phương-thức-xác-thực). + +## Sử dụng SSH Profiles + +1. Mở tab **SSH Tunnel** của kết nối và bật SSH +2. Nhấp **Use SSH Profile** để chuyển sang chế độ profile +3. Chọn profile từ dropdown +4. Cài đặt SSH hiển thị dạng tóm tắt chỉ đọc gồm host, port, user và phương thức xác thực + +Kết nối lưu tham chiếu đến profile, không phải bản sao cài đặt. Nếu bạn cập nhật profile sau, tất cả kết nối dùng profile đó tự động nhận thay đổi. + +Để chuyển về cài đặt SSH riêng cho từng kết nối, nhấp **Use Inline Config**. Thao tác này tách kết nối khỏi profile và cho phép chỉnh sửa trực tiếp các trường SSH. + +## Sửa và Xóa Profile + +### Sửa + +1. Mở kết nối đang dùng profile (hoặc tạo mới và chọn profile) +2. Nhấp nút **Edit** bên cạnh dropdown profile +3. Sửa cài đặt và nhấp **Save** + +Thay đổi áp dụng cho tất cả kết nối tham chiếu đến profile này. + +### Xóa + +1. Mở trình sửa profile +2. Nhấp **Delete Profile** +3. Hộp thoại xác nhận hiển thị số kết nối đang dùng profile +4. Xác nhận xóa. Các kết nối bị ảnh hưởng chuyển về cấu hình SSH inline với cài đặt của profile được sao chép vào, không kết nối nào mất cấu hình SSH. + +## Lưu Cấu hình Hiện tại thành Profile + +Nếu bạn đã có kết nối với cài đặt SSH inline và muốn tái sử dụng cấu hình đó: + +1. Mở tab **SSH Tunnel** của kết nối +2. Nhấp **Save Current as Profile...** +3. Nhập tên profile +4. Cài đặt SSH hiện tại được lưu thành profile mới, và kết nối chuyển sang dùng profile đó + +Hữu ích khi bạn nhận ra nhiều kết nối dùng chung SSH tunnel và muốn hợp nhất. + +## Đồng bộ iCloud + +SSH profiles đồng bộ giữa các Mac khi bật iCloud Sync với danh mục **Connections**. Profile được đồng bộ cùng với kết nối. + + +Mật khẩu SSH và passphrase key **không** đồng bộ cùng profile theo mặc định. Thông tin xác thực lưu trong macOS Keychain local. Bật **Password sync** trong **Settings** > **Sync** để đồng bộ qua iCloud Keychain (mã hóa đầu cuối bởi Apple). + + +Xem [Đồng bộ iCloud](/vi/features/icloud-sync) để biết chi tiết cấu hình đồng bộ. + +## Trang liên quan + + + + Thiết lập và khắc phục sự cố SSH tunnel + + + Đồng bộ cài đặt giữa các Mac + + + Quản lý tất cả kết nối + + + Tăng tốc quy trình làm việc + + diff --git a/docs/zh/databases/ssh-tunneling.mdx b/docs/zh/databases/ssh-tunneling.mdx index 9d188832f..ce7ea9f1d 100644 --- a/docs/zh/databases/ssh-tunneling.mdx +++ b/docs/zh/databases/ssh-tunneling.mdx @@ -7,6 +7,10 @@ description: 通过加密的 SSH 隧道路由数据库连接,访问私有网 SSH tunneling 将你的数据库连接通过加密隧道路由,以访问无法从 Mac 直接连接的服务器。TablePro 管理 tunnel 的生命周期,包括保活和自动重连。 + +如果你通过同一台 SSH 服务器连接多个数据库,可以将 SSH 配置保存为可复用的 profile。参阅 [SSH Profiles](/zh/features/ssh-profiles)。 + + ## SSH Tunneling 的工作原理 ```mermaid diff --git a/docs/zh/features/ssh-profiles.mdx b/docs/zh/features/ssh-profiles.mdx new file mode 100644 index 000000000..008a45cfe --- /dev/null +++ b/docs/zh/features/ssh-profiles.mdx @@ -0,0 +1,107 @@ +--- +title: SSH Profiles +description: 创建可复用的 SSH 隧道配置,在多个数据库连接之间共享 +--- + +# SSH Profiles + +SSH profiles 允许你定义一次 SSH tunnel 配置,在多个数据库连接中复用。无需为每个通过同一服务器的连接重复输入相同的 SSH host、port、用户名和认证设置,只需创建一个 profile 并从下拉菜单中选择。 + +## 创建 SSH Profile + + + + 打开新建或已有的连接,切换到 **SSH Tunnel** 标签页。 + + + 将 **SSH Tunnel** 开关切换为 ON。 + + + 点击 **Use SSH Profile** 从内联配置切换到 profile 模式,然后从 profile 下拉菜单中选择 **Create New Profile...**。 + + + 输入 profile 名称并配置 SSH 设置(host、port、用户名、认证方式)。这些字段与内联 SSH 配置相同。 + + + 点击 **Save** 保存 profile。该 profile 现在可在所有连接的 profile 下拉菜单中使用。 + + + +### Profile 字段 + +| 字段 | 描述 | +|-------|-------------| +| **Profile Name** | 用于识别此 profile 的标签(如 "Production Bastion"、"Staging Jump") | +| **SSH Host** | SSH 服务器主机名或 IP | +| **SSH Port** | SSH 服务器端口(默认 `22`) | +| **SSH User** | SSH 用户名 | +| **Auth Method** | Password、Private Key 或 SSH Agent | +| **Jump Hosts** | 可选的多跳配置 | + +认证字段(密码、密钥文件、口令、agent socket)取决于所选认证方式。与[内联 SSH tunnel 设置](/zh/databases/ssh-tunneling#认证方式)完全相同。 + +## 使用 SSH Profiles + +1. 打开连接的 **SSH Tunnel** 标签页并启用 SSH +2. 点击 **Use SSH Profile** 切换到 profile 模式 +3. 从下拉菜单中选择一个 profile +4. SSH 设置以只读摘要形式显示 profile 的 host、port、用户名和认证方式 + +连接存储的是对 profile 的引用,而不是设置的副本。如果之后更新 profile,所有使用该 profile 的连接会自动获取更改。 + +要切换回每个连接独立的 SSH 设置,点击 **Use Inline Config**。这会将连接与 profile 分离,允许你直接编辑 SSH 字段。 + +## 编辑和删除 Profile + +### 编辑 + +1. 打开使用该 profile 的任意连接(或创建新连接并选择该 profile) +2. 点击 profile 下拉菜单旁的 **Edit** 按钮 +3. 修改设置并点击 **Save** + +更改会应用到所有引用此 profile 的连接。 + +### 删除 + +1. 打开 profile 编辑器 +2. 点击 **Delete Profile** +3. 确认对话框会显示当前有多少连接正在使用此 profile +4. 确认删除。受影响的连接会恢复为内联 SSH 配置,profile 的设置会被复制过去,不会有连接丢失 SSH 配置。 + +## 将当前配置保存为 Profile + +如果你已有使用内联 SSH 设置的连接,想要复用该配置: + +1. 打开连接的 **SSH Tunnel** 标签页 +2. 点击 **Save Current as Profile...** +3. 输入 profile 名称 +4. 当前 SSH 设置保存为新 profile,连接切换为使用该 profile + +当你发现多个连接共享同一个 SSH tunnel 并想要统一管理时,这个功能很有用。 + +## iCloud 同步 + +启用 iCloud Sync 并开启 **Connections** 类别后,SSH profiles 会在你的 Mac 之间同步。Profile 与连接一起同步。 + + +SSH 密码和密钥口令默认**不会**与 profile 一起同步。这些凭据存储在本地 macOS Keychain 中。在 **Settings** > **Sync** 中启用 **Password sync** 可通过 iCloud Keychain 同步凭据(Apple 端到端加密)。 + + +参阅 [iCloud 同步](/zh/features/icloud-sync) 了解完整的同步配置详情。 + +## 相关页面 + + + + SSH tunnel 完整设置和故障排除 + + + 跨 Mac 同步设置 + + + 管理所有连接 + + + 加速你的工作流程 + + From 88deb90a07007b335dcf1161ca13ad8f37cbfb3d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ng=C3=B4=20Qu=E1=BB=91c=20=C4=90=E1=BA=A1t?= Date: Fri, 20 Mar 2026 13:10:50 +0700 Subject: [PATCH 09/11] docs: rewrite SSH profiles docs for accuracy and clarity --- docs/features/ssh-profiles.mdx | 106 ++++++++++++++---------------- docs/vi/features/ssh-profiles.mdx | 104 +++++++++++++---------------- docs/zh/features/ssh-profiles.mdx | 102 +++++++++++++--------------- 3 files changed, 141 insertions(+), 171 deletions(-) diff --git a/docs/features/ssh-profiles.mdx b/docs/features/ssh-profiles.mdx index 4304faa96..6e2697fc6 100644 --- a/docs/features/ssh-profiles.mdx +++ b/docs/features/ssh-profiles.mdx @@ -1,107 +1,97 @@ --- title: SSH Profiles -description: Create reusable SSH tunnel configurations that can be shared across multiple database connections +description: Save SSH tunnel configurations as reusable profiles shared across connections --- # SSH Profiles -SSH profiles let you define an SSH tunnel configuration once and reuse it across multiple database connections. Instead of entering the same SSH host, port, username, and auth settings for every connection that tunnels through the same server, create a profile and select it from a dropdown. +When you connect to MySQL, Redis, and PostgreSQL on the same remote server, you'd normally enter the same SSH host, port, and credentials three times. SSH profiles fix that: define the tunnel config once, then pick it from a dropdown on any connection. -## Creating an SSH Profile +## Creating a Profile - - Open a new or existing connection and switch to the **SSH Tunnel** tab. + + Open a new or existing connection and go to the **SSH Tunnel** tab. - - Toggle the **SSH Tunnel** switch to ON. + + Turn on the **Enable SSH Tunnel** toggle. - - Click **Use SSH Profile** to switch from inline configuration to profile mode, then click **Create New Profile...** from the profile dropdown. + + In the **SSH Profile** section, click **Create New Profile...**. A sheet opens with all the SSH fields. - - Enter a profile name and configure the SSH settings (host, port, username, authentication method). These are the same fields as inline SSH configuration. - - - Click **Save** to store the profile. It is now available in the profile dropdown for all connections. + + Give the profile a name (e.g. "prod-bastion"), fill in the SSH server details, and click **Create**. -### Profile Fields +### Profile fields | Field | Description | |-------|-------------| -| **Profile Name** | A label to identify this profile (e.g., "Production Bastion", "Staging Jump") | -| **SSH Host** | SSH server hostname or IP | -| **SSH Port** | SSH server port (default `22`) | -| **SSH User** | SSH username | -| **Auth Method** | Password, Private Key, or SSH Agent | -| **Jump Hosts** | Optional multi-hop configuration | - -Authentication fields (password, key file, passphrase, agent socket) depend on the selected auth method. These work identically to [inline SSH tunnel settings](/databases/ssh-tunneling#authentication-methods). +| **Name** | Label for this profile — shows in the dropdown | +| **SSH Host** | Hostname or IP of the SSH server | +| **SSH Port** | Default `22` | +| **Username** | SSH login user | +| **Auth Method** | Password, Private Key, SSH Agent, or Keyboard-Interactive | +| **Jump Hosts** | Optional multi-hop bastion chain | +| **TOTP** | Optional two-factor (auto-generate or prompt) | -## Using SSH Profiles +Auth-specific fields (password, key file, passphrase, agent socket) match the [inline SSH tunnel settings](/databases/ssh-tunneling#authentication-methods). -1. Open a connection's **SSH Tunnel** tab and enable SSH -2. Click **Use SSH Profile** to switch to profile mode -3. Select a profile from the dropdown -4. The SSH settings display as a read-only summary showing the profile's host, port, user, and auth method +## Selecting a Profile -The connection stores a reference to the profile, not a copy of the settings. If you update the profile later, all connections using it pick up the changes automatically. +1. Go to a connection's **SSH Tunnel** tab and enable SSH +2. Open the **Profile** picker and select a profile +3. The connection shows a read-only summary of the profile's settings -To switch back to per-connection SSH settings, click **Use Inline Config**. This detaches the connection from the profile and lets you edit SSH fields directly. +The connection stores a reference to the profile — not a copy. Updating the profile later affects every connection that uses it. -## Editing and Deleting Profiles +To go back to per-connection SSH settings, switch the picker back to **Inline Configuration**. -### Editing +## Editing a Profile -1. Open any connection that uses the profile (or create a new one and select the profile) -2. Click the **Edit** button next to the profile dropdown -3. Modify the settings and click **Save** +1. Select the profile in any connection's SSH tab +2. Click **Edit Profile...** +3. Change what you need and click **Save** -Changes apply to all connections that reference this profile. +All connections referencing the profile get the updated config on next connect. -### Deleting +## Deleting a Profile -1. Open the profile editor +1. Open the profile editor via **Edit Profile...** 2. Click **Delete Profile** -3. A confirmation dialog shows how many connections currently use this profile -4. Confirm to delete. Affected connections revert to inline SSH configuration with the profile's settings copied in, so no connection loses its SSH config. +3. A confirmation dialog shows how many connections use this profile +4. Confirm deletion + +After deletion, affected connections show a "profile no longer exists" warning in the SSH tab. SSH tunneling is disabled for those connections until you select a different profile or switch to inline configuration. -## Save Current Config as Profile +## Saving Inline Config as a Profile -If you already have a connection with inline SSH settings and want to reuse that configuration: +If you already configured SSH inline on a connection and want to reuse it: -1. Open the connection's **SSH Tunnel** tab +1. Go to the connection's **SSH Tunnel** tab (with inline config active) 2. Click **Save Current as Profile...** -3. Enter a profile name -4. The current SSH settings are saved as a new profile, and the connection switches to using that profile +3. Name the profile and click **Create** -This is useful when you realize multiple connections share the same SSH tunnel and you want to consolidate them. +The connection switches to using the new profile. Your SSH password, key passphrase, and TOTP secret carry over. ## iCloud Sync -SSH profiles sync across your Macs when iCloud Sync is enabled with the **Connections** category turned on. Profiles are synced alongside your connections. +SSH profiles sync across Macs when iCloud Sync is enabled with the **SSH Profiles** toggle on in **Settings > Sync**. -SSH passwords and key passphrases are **not** synced with the profile by default. These credentials are stored in the local macOS Keychain. Enable **Password sync** in **Settings** > **Sync** to sync credentials via iCloud Keychain (end-to-end encrypted by Apple). +SSH passwords and key passphrases stay in the local macOS Keychain by default. Turn on **Password sync** in **Settings > Sync** to sync credentials via iCloud Keychain. -See [iCloud Sync](/features/icloud-sync) for full sync configuration details. +See [iCloud Sync](/features/icloud-sync) for setup details. -## Related pages +## Related - Full SSH tunnel setup and troubleshooting + SSH tunnel setup and troubleshooting - Sync settings across Macs - - - Managing all your connections - - - Speed up your workflow + Sync across Macs diff --git a/docs/vi/features/ssh-profiles.mdx b/docs/vi/features/ssh-profiles.mdx index 488dd6198..4296f78eb 100644 --- a/docs/vi/features/ssh-profiles.mdx +++ b/docs/vi/features/ssh-profiles.mdx @@ -1,107 +1,97 @@ --- title: SSH Profiles -description: Tạo cấu hình SSH tunnel tái sử dụng để chia sẻ giữa nhiều kết nối database +description: Lưu cấu hình SSH tunnel thành profile tái sử dụng cho nhiều kết nối --- # SSH Profiles -SSH profiles cho phép bạn định nghĩa cấu hình SSH tunnel một lần và tái sử dụng cho nhiều kết nối database. Thay vì nhập cùng SSH host, port, username và cài đặt xác thực cho mỗi kết nối đi qua cùng server, tạo một profile và chọn từ dropdown. +Khi bạn kết nối MySQL, Redis và PostgreSQL trên cùng server, bạn phải nhập cùng SSH host, port và credentials ba lần. SSH profiles giải quyết vấn đề này: cấu hình tunnel một lần, rồi chọn từ dropdown trên bất kỳ kết nối nào. -## Tạo SSH Profile +## Tạo Profile - - Mở kết nối mới hoặc sẵn có và chuyển sang tab **SSH Tunnel**. + + Mở kết nối mới hoặc có sẵn, chuyển sang tab **SSH Tunnel**. - - Bật công tắc **SSH Tunnel**. + + Bật toggle **Enable SSH Tunnel**. - - Nhấp **Use SSH Profile** để chuyển từ cấu hình inline sang chế độ profile, sau đó chọn **Create New Profile...** từ dropdown profile. + + Trong phần **SSH Profile**, nhấn **Create New Profile...**. Một sheet mở ra với đầy đủ các trường SSH. - - Nhập tên profile và cấu hình SSH (host, port, username, phương thức xác thực). Các trường giống cấu hình SSH inline. - - - Nhấp **Save** để lưu profile. Profile giờ có sẵn trong dropdown cho tất cả kết nối. + + Đặt tên profile (ví dụ "prod-bastion"), điền thông tin SSH server, rồi nhấn **Create**. -### Các Trường Profile +### Các trường profile | Trường | Mô tả | |-------|-------------| -| **Profile Name** | Tên để nhận dạng profile (ví dụ: "Production Bastion", "Staging Jump") | +| **Name** | Tên hiển thị trong dropdown | | **SSH Host** | Hostname hoặc IP của SSH server | -| **SSH Port** | Cổng SSH server (mặc định `22`) | -| **SSH User** | Tên người dùng SSH | -| **Auth Method** | Password, Private Key, hoặc SSH Agent | -| **Jump Hosts** | Cấu hình multi-hop tùy chọn | - -Các trường xác thực (password, key file, passphrase, agent socket) phụ thuộc vào phương thức xác thực đã chọn. Hoạt động giống [cài đặt SSH tunnel inline](/vi/databases/ssh-tunneling#phương-thức-xác-thực). +| **SSH Port** | Mặc định `22` | +| **Username** | Tên đăng nhập SSH | +| **Auth Method** | Password, Private Key, SSH Agent, hoặc Keyboard-Interactive | +| **Jump Hosts** | Chuỗi bastion multi-hop (tùy chọn) | +| **TOTP** | Xác thực hai yếu tố (tùy chọn) | -## Sử dụng SSH Profiles +Các trường xác thực (password, key file, passphrase, agent socket) giống với [cài đặt SSH tunnel inline](/vi/databases/ssh-tunneling#phương-thức-xác-thực). -1. Mở tab **SSH Tunnel** của kết nối và bật SSH -2. Nhấp **Use SSH Profile** để chuyển sang chế độ profile -3. Chọn profile từ dropdown -4. Cài đặt SSH hiển thị dạng tóm tắt chỉ đọc gồm host, port, user và phương thức xác thực +## Chọn Profile -Kết nối lưu tham chiếu đến profile, không phải bản sao cài đặt. Nếu bạn cập nhật profile sau, tất cả kết nối dùng profile đó tự động nhận thay đổi. +1. Vào tab **SSH Tunnel** của kết nối và bật SSH +2. Mở picker **Profile** và chọn một profile +3. Kết nối hiển thị tóm tắt chỉ đọc cài đặt của profile -Để chuyển về cài đặt SSH riêng cho từng kết nối, nhấp **Use Inline Config**. Thao tác này tách kết nối khỏi profile và cho phép chỉnh sửa trực tiếp các trường SSH. +Kết nối lưu tham chiếu đến profile, không phải bản sao. Cập nhật profile sẽ ảnh hưởng đến mọi kết nối dùng nó. -## Sửa và Xóa Profile +Để quay lại cài đặt SSH riêng cho từng kết nối, chuyển picker về **Inline Configuration**. -### Sửa +## Sửa Profile -1. Mở kết nối đang dùng profile (hoặc tạo mới và chọn profile) -2. Nhấp nút **Edit** bên cạnh dropdown profile -3. Sửa cài đặt và nhấp **Save** +1. Chọn profile trong tab SSH của bất kỳ kết nối nào +2. Nhấn **Edit Profile...** +3. Thay đổi và nhấn **Save** -Thay đổi áp dụng cho tất cả kết nối tham chiếu đến profile này. +Tất cả kết nối tham chiếu profile sẽ nhận cấu hình mới khi kết nối lại. -### Xóa +## Xóa Profile -1. Mở trình sửa profile -2. Nhấp **Delete Profile** +1. Mở trình sửa profile qua **Edit Profile...** +2. Nhấn **Delete Profile** 3. Hộp thoại xác nhận hiển thị số kết nối đang dùng profile -4. Xác nhận xóa. Các kết nối bị ảnh hưởng chuyển về cấu hình SSH inline với cài đặt của profile được sao chép vào, không kết nối nào mất cấu hình SSH. +4. Xác nhận xóa + +Sau khi xóa, các kết nối bị ảnh hưởng hiển thị cảnh báo "profile không còn tồn tại" trong tab SSH. SSH tunneling bị vô hiệu hóa cho những kết nối đó cho đến khi bạn chọn profile khác hoặc chuyển sang cấu hình inline. -## Lưu Cấu hình Hiện tại thành Profile +## Lưu cấu hình inline thành Profile -Nếu bạn đã có kết nối với cài đặt SSH inline và muốn tái sử dụng cấu hình đó: +Nếu bạn đã cấu hình SSH inline trên một kết nối và muốn tái sử dụng: -1. Mở tab **SSH Tunnel** của kết nối -2. Nhấp **Save Current as Profile...** -3. Nhập tên profile -4. Cài đặt SSH hiện tại được lưu thành profile mới, và kết nối chuyển sang dùng profile đó +1. Vào tab **SSH Tunnel** của kết nối (đang dùng cấu hình inline) +2. Nhấn **Save Current as Profile...** +3. Đặt tên profile và nhấn **Create** -Hữu ích khi bạn nhận ra nhiều kết nối dùng chung SSH tunnel và muốn hợp nhất. +Kết nối chuyển sang dùng profile mới. Mật khẩu SSH, key passphrase và TOTP secret được chuyển theo. ## Đồng bộ iCloud -SSH profiles đồng bộ giữa các Mac khi bật iCloud Sync với danh mục **Connections**. Profile được đồng bộ cùng với kết nối. +SSH profiles đồng bộ giữa các Mac khi bật iCloud Sync với toggle **SSH Profiles** trong **Settings > Sync**. -Mật khẩu SSH và passphrase key **không** đồng bộ cùng profile theo mặc định. Thông tin xác thực lưu trong macOS Keychain local. Bật **Password sync** trong **Settings** > **Sync** để đồng bộ qua iCloud Keychain (mã hóa đầu cuối bởi Apple). +Mật khẩu SSH và key passphrase mặc định lưu trong macOS Keychain local. Bật **Password sync** trong **Settings > Sync** để đồng bộ qua iCloud Keychain. -Xem [Đồng bộ iCloud](/vi/features/icloud-sync) để biết chi tiết cấu hình đồng bộ. +Xem [Đồng bộ iCloud](/vi/features/icloud-sync) để biết chi tiết. -## Trang liên quan +## Liên quan Thiết lập và khắc phục sự cố SSH tunnel - Đồng bộ cài đặt giữa các Mac - - - Quản lý tất cả kết nối - - - Tăng tốc quy trình làm việc + Đồng bộ giữa các Mac diff --git a/docs/zh/features/ssh-profiles.mdx b/docs/zh/features/ssh-profiles.mdx index 008a45cfe..7e74a8d38 100644 --- a/docs/zh/features/ssh-profiles.mdx +++ b/docs/zh/features/ssh-profiles.mdx @@ -1,107 +1,97 @@ --- title: SSH Profiles -description: 创建可复用的 SSH 隧道配置,在多个数据库连接之间共享 +description: 将 SSH 隧道配置保存为可复用的 profile,在多个连接间共享 --- # SSH Profiles -SSH profiles 允许你定义一次 SSH tunnel 配置,在多个数据库连接中复用。无需为每个通过同一服务器的连接重复输入相同的 SSH host、port、用户名和认证设置,只需创建一个 profile 并从下拉菜单中选择。 +当你需要连接同一台远程服务器上的 MySQL、Redis 和 PostgreSQL 时,通常要输入三次相同的 SSH host、port 和凭据。SSH profiles 解决了这个问题:配置一次隧道,然后在任何连接的下拉菜单中选择即可。 -## 创建 SSH Profile +## 创建 Profile - + 打开新建或已有的连接,切换到 **SSH Tunnel** 标签页。 - - 将 **SSH Tunnel** 开关切换为 ON。 + + 开启 **Enable SSH Tunnel** 开关。 - - 点击 **Use SSH Profile** 从内联配置切换到 profile 模式,然后从 profile 下拉菜单中选择 **Create New Profile...**。 + + 在 **SSH Profile** 区域,点击 **Create New Profile...**。弹出的 sheet 包含所有 SSH 字段。 - - 输入 profile 名称并配置 SSH 设置(host、port、用户名、认证方式)。这些字段与内联 SSH 配置相同。 - - - 点击 **Save** 保存 profile。该 profile 现在可在所有连接的 profile 下拉菜单中使用。 + + 为 profile 命名(如 "prod-bastion"),填写 SSH 服务器信息,点击 **Create**。 ### Profile 字段 -| 字段 | 描述 | +| 字段 | 说明 | |-------|-------------| -| **Profile Name** | 用于识别此 profile 的标签(如 "Production Bastion"、"Staging Jump") | +| **Name** | 在下拉菜单中显示的名称 | | **SSH Host** | SSH 服务器主机名或 IP | -| **SSH Port** | SSH 服务器端口(默认 `22`) | -| **SSH User** | SSH 用户名 | -| **Auth Method** | Password、Private Key 或 SSH Agent | -| **Jump Hosts** | 可选的多跳配置 | - -认证字段(密码、密钥文件、口令、agent socket)取决于所选认证方式。与[内联 SSH tunnel 设置](/zh/databases/ssh-tunneling#认证方式)完全相同。 +| **SSH Port** | 默认 `22` | +| **Username** | SSH 登录用户名 | +| **Auth Method** | Password、Private Key、SSH Agent 或 Keyboard-Interactive | +| **Jump Hosts** | 可选的多跳 bastion 链 | +| **TOTP** | 可选的双因素认证 | -## 使用 SSH Profiles +认证相关字段(密码、密钥文件、口令、agent socket)与[内联 SSH tunnel 设置](/zh/databases/ssh-tunneling#认证方式)相同。 -1. 打开连接的 **SSH Tunnel** 标签页并启用 SSH -2. 点击 **Use SSH Profile** 切换到 profile 模式 -3. 从下拉菜单中选择一个 profile -4. SSH 设置以只读摘要形式显示 profile 的 host、port、用户名和认证方式 +## 选择 Profile -连接存储的是对 profile 的引用,而不是设置的副本。如果之后更新 profile,所有使用该 profile 的连接会自动获取更改。 +1. 进入连接的 **SSH Tunnel** 标签页并启用 SSH +2. 打开 **Profile** 选择器,选择一个 profile +3. 连接以只读摘要形式显示 profile 的设置 -要切换回每个连接独立的 SSH 设置,点击 **Use Inline Config**。这会将连接与 profile 分离,允许你直接编辑 SSH 字段。 +连接存储的是对 profile 的引用,不是副本。之后更新 profile 会影响所有使用它的连接。 -## 编辑和删除 Profile +要恢复为每个连接独立的 SSH 设置,将选择器切换回 **Inline Configuration**。 -### 编辑 +## 编辑 Profile -1. 打开使用该 profile 的任意连接(或创建新连接并选择该 profile) -2. 点击 profile 下拉菜单旁的 **Edit** 按钮 -3. 修改设置并点击 **Save** +1. 在任意连接的 SSH 标签页中选择该 profile +2. 点击 **Edit Profile...** +3. 修改后点击 **Save** -更改会应用到所有引用此 profile 的连接。 +所有引用该 profile 的连接在下次连接时使用更新后的配置。 -### 删除 +## 删除 Profile -1. 打开 profile 编辑器 +1. 通过 **Edit Profile...** 打开 profile 编辑器 2. 点击 **Delete Profile** -3. 确认对话框会显示当前有多少连接正在使用此 profile -4. 确认删除。受影响的连接会恢复为内联 SSH 配置,profile 的设置会被复制过去,不会有连接丢失 SSH 配置。 +3. 确认对话框显示当前有多少连接正在使用此 profile +4. 确认删除 + +删除后,受影响的连接在 SSH 标签页中显示"profile 不再存在"的警告。这些连接的 SSH 隧道将被禁用,直到你选择其他 profile 或切换到内联配置。 -## 将当前配置保存为 Profile +## 将内联配置保存为 Profile -如果你已有使用内联 SSH 设置的连接,想要复用该配置: +如果你已在某个连接上配置了内联 SSH 设置并想复用: -1. 打开连接的 **SSH Tunnel** 标签页 +1. 进入连接的 **SSH Tunnel** 标签页(当前使用内联配置) 2. 点击 **Save Current as Profile...** -3. 输入 profile 名称 -4. 当前 SSH 设置保存为新 profile,连接切换为使用该 profile +3. 命名 profile 并点击 **Create** -当你发现多个连接共享同一个 SSH tunnel 并想要统一管理时,这个功能很有用。 +连接切换为使用新 profile。SSH 密码、密钥口令和 TOTP secret 会一并迁移。 ## iCloud 同步 -启用 iCloud Sync 并开启 **Connections** 类别后,SSH profiles 会在你的 Mac 之间同步。Profile 与连接一起同步。 +启用 iCloud Sync 并在 **Settings > Sync** 中开启 **SSH Profiles** 开关后,SSH profiles 会在 Mac 之间同步。 -SSH 密码和密钥口令默认**不会**与 profile 一起同步。这些凭据存储在本地 macOS Keychain 中。在 **Settings** > **Sync** 中启用 **Password sync** 可通过 iCloud Keychain 同步凭据(Apple 端到端加密)。 +SSH 密码和密钥口令默认存储在本地 macOS Keychain 中。在 **Settings > Sync** 中开启 **Password sync** 可通过 iCloud Keychain 同步凭据。 -参阅 [iCloud 同步](/zh/features/icloud-sync) 了解完整的同步配置详情。 +参阅 [iCloud 同步](/zh/features/icloud-sync) 了解详情。 -## 相关页面 +## 相关 - SSH tunnel 完整设置和故障排除 + SSH tunnel 设置和故障排除 - 跨 Mac 同步设置 - - - 管理所有连接 - - - 加速你的工作流程 + 跨 Mac 同步 From d3721561caba85809f0944fc0fb3cc0e03c76dcb Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ng=C3=B4=20Qu=E1=BB=91c=20=C4=90=E1=BA=A1t?= Date: Fri, 20 Mar 2026 13:13:04 +0700 Subject: [PATCH 10/11] fix: address PR review - profile validation, secret cleanup, edit vs prefill --- .../Views/Connection/ConnectionFormView.swift | 11 +++++----- .../Connection/SSHProfileEditorView.swift | 20 +++++++++++++------ 2 files changed, 19 insertions(+), 12 deletions(-) diff --git a/TablePro/Views/Connection/ConnectionFormView.swift b/TablePro/Views/Connection/ConnectionFormView.swift index 170d46e14..12938827b 100644 --- a/TablePro/Views/Connection/ConnectionFormView.swift +++ b/TablePro/Views/Connection/ConnectionFormView.swift @@ -1050,12 +1050,6 @@ struct ConnectionFormView: View { // swiftlint:disable:this type_body_length sshProfileId = existing.sshProfileId sshEnabled = existing.sshConfig.enabled - // When using a profile, also set sshEnabled based on profile existence - if let profileId = existing.sshProfileId, - SSHProfileStorage.shared.profile(for: profileId) != nil { - sshEnabled = true - } - sshHost = existing.sshConfig.host sshPort = String(existing.sshConfig.port) sshUsername = existing.sshConfig.username @@ -1199,6 +1193,11 @@ struct ConnectionFormView: View { // swiftlint:disable:this type_body_length } else { storage.deleteTOTPSecret(for: connectionToSave.id) } + } else { + // Clean up stale per-connection SSH secrets when using a profile or SSH disabled + storage.deleteSSHPassword(for: connectionToSave.id) + storage.deleteKeyPassphrase(for: connectionToSave.id) + storage.deleteTOTPSecret(for: connectionToSave.id) } // Save to storage diff --git a/TablePro/Views/Connection/SSHProfileEditorView.swift b/TablePro/Views/Connection/SSHProfileEditorView.swift index 2faaf5d4e..80069f2c4 100644 --- a/TablePro/Views/Connection/SSHProfileEditorView.swift +++ b/TablePro/Views/Connection/SSHProfileEditorView.swift @@ -49,11 +49,19 @@ struct SSHProfileEditorView: View { @State private var showingDeleteConfirmation = false @State private var connectionsUsingProfile = 0 - private var isEditing: Bool { existingProfile != nil } + private var isStoredProfile: Bool { + guard let profile = existingProfile else { return false } + return SSHProfileStorage.shared.profile(for: profile.id) != nil + } private var isValid: Bool { - !profileName.trimmingCharacters(in: .whitespaces).isEmpty - && !host.trimmingCharacters(in: .whitespaces).isEmpty + let nameValid = !profileName.trimmingCharacters(in: .whitespaces).isEmpty + let hostValid = !host.trimmingCharacters(in: .whitespaces).isEmpty + let portValid = port.isEmpty || (Int(port).map { (1...65_535).contains($0) } ?? false) + let authValid = authMethod == .password || authMethod == .sshAgent + || authMethod == .keyboardInteractive || !privateKeyPath.isEmpty + let jumpValid = jumpHosts.allSatisfy(\.isValid) + return nameValid && hostValid && portValid && authValid && jumpValid } private var resolvedAgentSocketPath: String { @@ -266,7 +274,7 @@ struct SSHProfileEditorView: View { private var bottomBar: some View { HStack { - if isEditing { + if isStoredProfile { Button(role: .destructive) { connectionsUsingProfile = ConnectionStorage.shared.loadConnections() .filter { $0.sshProfileId == existingProfile?.id }.count @@ -294,7 +302,7 @@ struct SSHProfileEditorView: View { Button("Cancel") { dismiss() } .keyboardShortcut(.cancelAction) - Button(isEditing ? "Save" : "Create") { saveProfile() } + Button(isStoredProfile ? "Save" : "Create") { saveProfile() } .keyboardShortcut(.defaultAction) .disabled(!isValid) } @@ -349,7 +357,7 @@ struct SSHProfileEditorView: View { totpPeriod: totpPeriod ) - if isEditing { + if isStoredProfile { SSHProfileStorage.shared.updateProfile(profile) } else { SSHProfileStorage.shared.addProfile(profile) From cf56490fc20260a92a99960dd6c96de4d7aa3423 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ng=C3=B4=20Qu=E1=BB=91c=20=C4=90=E1=BA=A1t?= Date: Fri, 20 Mar 2026 13:18:06 +0700 Subject: [PATCH 11/11] fix: cache selected profile lookup, fix sshInlineFields indentation --- .../Views/Connection/ConnectionFormView.swift | 104 ++++++------------ 1 file changed, 33 insertions(+), 71 deletions(-) diff --git a/TablePro/Views/Connection/ConnectionFormView.swift b/TablePro/Views/Connection/ConnectionFormView.swift index 12938827b..8e8d47912 100644 --- a/TablePro/Views/Connection/ConnectionFormView.swift +++ b/TablePro/Views/Connection/ConnectionFormView.swift @@ -454,8 +454,7 @@ struct ConnectionFormView: View { // swiftlint:disable:this type_body_length if sshEnabled { sshProfileSection - if let profileId = sshProfileId, - let profile = SSHProfileStorage.shared.profile(for: profileId) { + if let profile = selectedSSHProfile { sshProfileSummarySection(profile) } else if sshProfileId != nil { Section { @@ -533,6 +532,11 @@ struct ConnectionFormView: View { // swiftlint:disable:this type_body_length } } + private var selectedSSHProfile: SSHProfile? { + guard let id = sshProfileId else { return nil } + return sshProfiles.first { $0.id == id } + } + private func reloadProfiles() { sshProfiles = SSHProfileStorage.shared.loadProfiles() // If the edited/deleted profile no longer exists, clear the selection @@ -578,8 +582,7 @@ struct ConnectionFormView: View { // swiftlint:disable:this type_body_length Group { Section(String(localized: "Server")) { if !sshConfigEntries.isEmpty { - Picker(String(localized: "Config Host"), selection: $selectedSSHConfigHost) - { + Picker(String(localized: "Config Host"), selection: $selectedSSHConfigHost) { Text(String(localized: "Manual")).tag("") ForEach(sshConfigEntries) { entry in Text(entry.displayName).tag(entry.host) @@ -590,30 +593,19 @@ struct ConnectionFormView: View { // swiftlint:disable:this type_body_length } } if selectedSSHConfigHost.isEmpty || sshConfigEntries.isEmpty { - TextField( - String(localized: "SSH Host"), - text: $sshHost, - prompt: Text("ssh.example.com") - ) - } - TextField( - String(localized: "SSH Port"), - text: $sshPort, - prompt: Text("22") - ) - TextField( - String(localized: "SSH User"), - text: $sshUsername, - prompt: Text("username") - ) + TextField(String(localized: "SSH Host"), text: $sshHost, prompt: Text("ssh.example.com")) } + TextField(String(localized: "SSH Port"), text: $sshPort, prompt: Text("22")) + TextField(String(localized: "SSH User"), text: $sshUsername, prompt: Text("username")) + } + Section(String(localized: "Authentication")) { Picker(String(localized: "Method"), selection: $sshAuthMethod) { ForEach(SSHAuthMethod.allCases) { method in Text(method.rawValue).tag(method) } } - if sshAuthMethod == .password { + if sshAuthMethod == .password { SecureField(String(localized: "Password"), text: $sshPassword) } else if sshAuthMethod == .sshAgent { Picker("Agent Socket", selection: $sshAgentSocketOption) { @@ -622,37 +614,30 @@ struct ConnectionFormView: View { // swiftlint:disable:this type_body_length } } if sshAgentSocketOption == .custom { - TextField( - "Custom Path", - text: $customSSHAgentSocketPath, - prompt: Text("/path/to/agent.sock") - ) + TextField("Custom Path", text: $customSSHAgentSocketPath, prompt: Text("/path/to/agent.sock")) } Text("Keys are provided by the SSH agent (e.g. 1Password, ssh-agent).") .font(.caption) .foregroundStyle(.secondary) } else if sshAuthMethod == .keyboardInteractive { SecureField(String(localized: "Password"), text: $sshPassword) - Text( - String(localized: "Password is sent via keyboard-interactive challenge-response.") - ) - .font(.caption) - .foregroundStyle(.secondary) + Text(String(localized: "Password is sent via keyboard-interactive challenge-response.")) + .font(.caption) + .foregroundStyle(.secondary) } else { LabeledContent(String(localized: "Key File")) { HStack { - TextField( - "", text: $sshPrivateKeyPath, prompt: Text("~/.ssh/id_rsa")) + TextField("", text: $sshPrivateKeyPath, prompt: Text("~/.ssh/id_rsa")) Button(String(localized: "Browse")) { browseForPrivateKey() } .controlSize(.small) } } SecureField(String(localized: "Passphrase"), text: $keyPassphrase) } - } + } if sshAuthMethod == .keyboardInteractive || sshAuthMethod == .password { - Section(String(localized: "Two-Factor Authentication")) { + Section(String(localized: "Two-Factor Authentication")) { Picker(String(localized: "TOTP"), selection: $totpMode) { ForEach(TOTPMode.allCases) { mode in Text(mode.displayName).tag(mode) @@ -662,44 +647,32 @@ struct ConnectionFormView: View { // swiftlint:disable:this type_body_length if totpMode == .autoGenerate { SecureField(String(localized: "TOTP Secret"), text: $totpSecret) .help(String(localized: "Base32-encoded secret from your authenticator setup")) - Picker(String(localized: "Algorithm"), selection: $totpAlgorithm) { ForEach(TOTPAlgorithm.allCases) { algo in Text(algo.rawValue).tag(algo) } } - Picker(String(localized: "Digits"), selection: $totpDigits) { Text("6").tag(6) Text("8").tag(8) } - Picker(String(localized: "Period"), selection: $totpPeriod) { Text("30s").tag(30) Text("60s").tag(60) } } else if totpMode == .promptAtConnect { - Text( - String( - localized: - "You will be prompted for a verification code each time you connect." - ) - ) - .font(.caption) - .foregroundStyle(.secondary) + Text(String(localized: "You will be prompted for a verification code each time you connect.")) + .font(.caption) + .foregroundStyle(.secondary) } } - } + } Section { DisclosureGroup(String(localized: "Jump Hosts")) { ForEach($jumpHosts) { $jumpHost in DisclosureGroup { - TextField( - String(localized: "Host"), - text: $jumpHost.host, - prompt: Text("bastion.example.com") - ) + TextField(String(localized: "Host"), text: $jumpHost.host, prompt: Text("bastion.example.com")) HStack { TextField( String(localized: "Port"), @@ -710,11 +683,7 @@ struct ConnectionFormView: View { // swiftlint:disable:this type_body_length prompt: Text("22") ) .frame(width: 80) - TextField( - String(localized: "Username"), - text: $jumpHost.username, - prompt: Text("admin") - ) + TextField(String(localized: "Username"), text: $jumpHost.username, prompt: Text("admin")) } Picker(String(localized: "Auth"), selection: $jumpHost.authMethod) { ForEach(SSHJumpAuthMethod.allCases) { method in @@ -724,9 +693,7 @@ struct ConnectionFormView: View { // swiftlint:disable:this type_body_length if jumpHost.authMethod == .privateKey { LabeledContent(String(localized: "Key File")) { HStack { - TextField( - "", text: $jumpHost.privateKeyPath, - prompt: Text("~/.ssh/id_rsa")) + TextField("", text: $jumpHost.privateKeyPath, prompt: Text("~/.ssh/id_rsa")) Button(String(localized: "Browse")) { browseForJumpHostKey(jumpHost: $jumpHost) } @@ -745,12 +712,9 @@ struct ConnectionFormView: View { // swiftlint:disable:this type_body_length Spacer() Button { let idToRemove = jumpHost.id - withAnimation { - jumpHosts.removeAll { $0.id == idToRemove } - } + withAnimation { jumpHosts.removeAll { $0.id == idToRemove } } } label: { - Image(systemName: "minus.circle.fill") - .foregroundStyle(.red) + Image(systemName: "minus.circle.fill").foregroundStyle(.red) } .buttonStyle(.plain) } @@ -766,13 +730,11 @@ struct ConnectionFormView: View { // swiftlint:disable:this type_body_length Label(String(localized: "Add Jump Host"), systemImage: "plus") } - Text( - "Jump hosts are connected in order before reaching the SSH server above. Only key and agent auth are supported for jumps." - ) - .font(.caption) - .foregroundStyle(.secondary) - } + Text("Jump hosts are connected in order before reaching the SSH server above. Only key and agent auth are supported for jumps.") + .font(.caption) + .foregroundStyle(.secondary) } + } } }