Skip to content

Commit 649eca3

Browse files
authored
refactor(ios): extract connection form logic into ConnectionFormViewModel (#1165)
1 parent d98e58b commit 649eca3

3 files changed

Lines changed: 582 additions & 493 deletions

File tree

CHANGELOG.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
1616

1717
### Changed
1818

19+
- Internal: iOS connection form (test connection, save, file picker handlers, default port resolution, credential hydration) moves out of the View into `ConnectionFormViewModel`. The View drops from 53 to 5 `@State` properties; behavior is unchanged
1920
- Internal: iOS data browser business logic (page load, pagination, sort, filter, search, delete, foreign-key fetch, memory pressure) moves out of the View into `DataBrowserViewModel`. The View drops 30 of its 33 `@State` properties and a dozen private functions; behavior is unchanged
2021
- iOS: metadata badges (column types, primary key markers, row counts) cap at the first accessibility size so they stay readable without breaking layouts at the largest Dynamic Type sizes
2122
- iOS: SQL editor keyboard accessory uses the system keyboard input view, dropping the deprecated screen-width measurement
Lines changed: 347 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,347 @@
1+
//
2+
// ConnectionFormViewModel.swift
3+
// TableProMobile
4+
//
5+
6+
import Foundation
7+
import os
8+
import TableProDatabase
9+
import TableProModels
10+
11+
@MainActor
12+
@Observable
13+
final class ConnectionFormViewModel {
14+
enum KeyInputMode: String, CaseIterable {
15+
case file = "Import File"
16+
case paste = "Paste Key"
17+
}
18+
19+
struct TestResult: Sendable {
20+
let success: Bool
21+
let message: String
22+
let recovery: String?
23+
}
24+
25+
private static let logger = Logger(subsystem: "com.TablePro", category: "ConnectionFormViewModel")
26+
27+
// Form fields
28+
var name = ""
29+
var type: DatabaseType = .mysql {
30+
didSet { onTypeChange(from: oldValue) }
31+
}
32+
var host = "127.0.0.1"
33+
var port = "3306"
34+
var username = ""
35+
var password = ""
36+
var database = ""
37+
var sslEnabled = false
38+
39+
// Organization
40+
var groupId: UUID?
41+
var tagId: UUID?
42+
var safeModeLevel: SafeModeLevel = .off
43+
44+
// SSH
45+
var sshEnabled = false
46+
var sshHost = ""
47+
var sshPort = "22"
48+
var sshUsername = ""
49+
var sshPassword = ""
50+
var sshAuthMethod: SSHConfiguration.SSHAuthMethod = .password
51+
var sshKeyPath = ""
52+
var sshKeyContent = ""
53+
var sshKeyPassphrase = ""
54+
var sshKeyInputMode: KeyInputMode = .file
55+
56+
// File picker output
57+
var selectedFileURL: URL?
58+
var newDatabaseName = ""
59+
60+
// Async state
61+
private(set) var isTesting = false
62+
private(set) var testResult: TestResult?
63+
private(set) var credentialError: String?
64+
65+
@ObservationIgnored let existingConnection: DatabaseConnection?
66+
67+
init(editing: DatabaseConnection? = nil) {
68+
self.existingConnection = editing
69+
guard let conn = editing else { return }
70+
name = conn.name
71+
type = conn.type
72+
host = conn.host
73+
port = String(conn.port)
74+
username = conn.username
75+
database = conn.database
76+
sslEnabled = conn.sslEnabled
77+
sshEnabled = conn.sshEnabled
78+
groupId = conn.groupId
79+
tagId = conn.tagId
80+
safeModeLevel = conn.safeModeLevel
81+
if let ssh = conn.sshConfiguration {
82+
sshHost = ssh.host
83+
sshPort = String(ssh.port)
84+
sshUsername = ssh.username
85+
sshAuthMethod = ssh.authMethod
86+
sshKeyPath = ssh.privateKeyPath ?? ""
87+
sshKeyContent = ssh.privateKeyData ?? ""
88+
if let keyData = ssh.privateKeyData, !keyData.isEmpty {
89+
sshKeyInputMode = .paste
90+
}
91+
}
92+
if conn.type == .sqlite {
93+
selectedFileURL = URL(fileURLWithPath: conn.database)
94+
}
95+
}
96+
97+
// MARK: - Computed
98+
99+
var canSave: Bool {
100+
if type == .sqlite {
101+
return !database.isEmpty
102+
}
103+
return !host.isEmpty
104+
}
105+
106+
var isEditing: Bool { existingConnection != nil }
107+
108+
// MARK: - Credential Hydration
109+
110+
func loadStoredCredentials(secureStore: KeychainSecureStore) async {
111+
guard let conn = existingConnection else { return }
112+
let connKey = "com.TablePro.password.\(conn.id.uuidString)"
113+
if let stored = try? secureStore.retrieve(forKey: connKey), !stored.isEmpty {
114+
password = stored
115+
}
116+
if let sshPwd = try? secureStore.retrieve(forKey: "com.TablePro.sshpassword.\(conn.id.uuidString)"), !sshPwd.isEmpty {
117+
sshPassword = sshPwd
118+
}
119+
if let passphrase = try? secureStore.retrieve(forKey: "com.TablePro.keypassphrase.\(conn.id.uuidString)"), !passphrase.isEmpty {
120+
sshKeyPassphrase = passphrase
121+
}
122+
}
123+
124+
// MARK: - Type Change
125+
126+
private func onTypeChange(from oldType: DatabaseType) {
127+
guard oldType != type else { return }
128+
updateDefaultPort()
129+
selectedFileURL = nil
130+
database = ""
131+
}
132+
133+
private func updateDefaultPort() {
134+
switch type {
135+
case .mysql, .mariadb: port = "3306"
136+
case .postgresql: port = "5432"
137+
case .redshift: port = "5439"
138+
case .redis: port = "6379"
139+
case .sqlite: port = ""
140+
default: port = "3306"
141+
}
142+
}
143+
144+
// MARK: - File Picker
145+
146+
func handleSQLiteFilePicker(_ result: Result<[URL], Error>) {
147+
guard case .success(let urls) = result, let url = urls.first else { return }
148+
guard url.startAccessingSecurityScopedResource() else { return }
149+
defer { url.stopAccessingSecurityScopedResource() }
150+
151+
let destURL = copyToDocuments(url)
152+
selectedFileURL = destURL
153+
database = destURL.path
154+
if name.isEmpty {
155+
name = destURL.deletingPathExtension().lastPathComponent
156+
}
157+
}
158+
159+
func handleSSHKeyFilePicker(_ result: Result<[URL], Error>) {
160+
guard case .success(let urls) = result, let url = urls.first else { return }
161+
guard url.startAccessingSecurityScopedResource() else { return }
162+
defer { url.stopAccessingSecurityScopedResource() }
163+
164+
if let content = try? String(contentsOf: url, encoding: .utf8) {
165+
sshKeyContent = content
166+
sshKeyInputMode = .paste
167+
} else {
168+
guard let docsDir = FileManager.default.urls(for: .documentDirectory, in: .userDomainMask).first else { return }
169+
let dest = docsDir.appendingPathComponent("ssh_" + url.lastPathComponent)
170+
try? FileManager.default.removeItem(at: dest)
171+
try? FileManager.default.copyItem(at: url, to: dest)
172+
sshKeyPath = dest.path
173+
}
174+
}
175+
176+
private func copyToDocuments(_ sourceURL: URL) -> URL {
177+
guard let documentsDir = FileManager.default.urls(for: .documentDirectory, in: .userDomainMask).first else {
178+
return sourceURL
179+
}
180+
var destURL = documentsDir.appendingPathComponent(sourceURL.lastPathComponent)
181+
182+
if FileManager.default.fileExists(atPath: destURL.path) {
183+
let baseName = sourceURL.deletingPathExtension().lastPathComponent
184+
let ext = sourceURL.pathExtension
185+
let suffix = UUID().uuidString.prefix(8)
186+
destURL = documentsDir.appendingPathComponent("\(baseName)_\(suffix).\(ext)")
187+
}
188+
189+
try? FileManager.default.copyItem(at: sourceURL, to: destURL)
190+
return destURL
191+
}
192+
193+
func clearSelectedFile() {
194+
selectedFileURL = nil
195+
database = ""
196+
}
197+
198+
func createNewDatabase() {
199+
guard !newDatabaseName.isEmpty else { return }
200+
201+
let safeName = newDatabaseName.hasSuffix(".db") ? newDatabaseName : "\(newDatabaseName).db"
202+
guard let documentsDir = FileManager.default.urls(for: .documentDirectory, in: .userDomainMask).first else { return }
203+
let fileURL = documentsDir.appendingPathComponent(safeName)
204+
205+
selectedFileURL = fileURL
206+
database = fileURL.path
207+
if name.isEmpty {
208+
name = newDatabaseName
209+
}
210+
newDatabaseName = ""
211+
}
212+
213+
// MARK: - Test Connection
214+
215+
func testConnection(appState: AppState, secureStore: KeychainSecureStore) async {
216+
isTesting = true
217+
testResult = nil
218+
defer { isTesting = false }
219+
220+
let tempId = UUID()
221+
var testConn = buildConnection()
222+
testConn.id = tempId
223+
224+
if !password.isEmpty {
225+
try? appState.connectionManager.storePassword(password, for: tempId)
226+
}
227+
if sshEnabled && !sshPassword.isEmpty {
228+
try? secureStore.store(sshPassword, forKey: "com.TablePro.sshpassword.\(tempId.uuidString)")
229+
}
230+
if sshEnabled && !sshKeyPassphrase.isEmpty {
231+
try? secureStore.store(sshKeyPassphrase, forKey: "com.TablePro.keypassphrase.\(tempId.uuidString)")
232+
}
233+
if sshEnabled && !sshKeyContent.isEmpty {
234+
try? secureStore.store(sshKeyContent, forKey: "com.TablePro.sshkeydata.\(tempId.uuidString)")
235+
}
236+
237+
defer {
238+
try? appState.connectionManager.deletePassword(for: tempId)
239+
try? secureStore.delete(forKey: "com.TablePro.sshpassword.\(tempId.uuidString)")
240+
try? secureStore.delete(forKey: "com.TablePro.keypassphrase.\(tempId.uuidString)")
241+
try? secureStore.delete(forKey: "com.TablePro.sshkeydata.\(tempId.uuidString)")
242+
}
243+
244+
await appState.sshProvider.setPendingConnectionId(tempId)
245+
246+
do {
247+
_ = try await appState.connectionManager.connect(testConn)
248+
await appState.connectionManager.disconnect(tempId)
249+
testResult = TestResult(
250+
success: true,
251+
message: String(localized: "Connection successful"),
252+
recovery: nil
253+
)
254+
} catch {
255+
let context = ErrorContext(
256+
operation: "testConnection",
257+
databaseType: type,
258+
host: host,
259+
sshEnabled: sshEnabled
260+
)
261+
let classified = ErrorClassifier.classify(error, context: context)
262+
testResult = TestResult(success: false, message: classified.message, recovery: classified.recovery)
263+
}
264+
}
265+
266+
// MARK: - Save
267+
268+
func save(appState: AppState, secureStore: KeychainSecureStore) -> DatabaseConnection? {
269+
let connection = buildConnection()
270+
var storageFailed = false
271+
272+
if !password.isEmpty {
273+
do {
274+
try appState.connectionManager.storePassword(password, for: connection.id)
275+
} catch {
276+
Self.logger.error("Failed to store password: \(error.localizedDescription, privacy: .public)")
277+
storageFailed = true
278+
}
279+
}
280+
281+
if sshEnabled {
282+
if !sshPassword.isEmpty {
283+
do {
284+
try secureStore.store(sshPassword, forKey: "com.TablePro.sshpassword.\(connection.id.uuidString)")
285+
} catch {
286+
Self.logger.error("Failed to store SSH password: \(error.localizedDescription, privacy: .public)")
287+
storageFailed = true
288+
}
289+
}
290+
if !sshKeyPassphrase.isEmpty {
291+
do {
292+
try secureStore.store(sshKeyPassphrase, forKey: "com.TablePro.keypassphrase.\(connection.id.uuidString)")
293+
} catch {
294+
Self.logger.error("Failed to store SSH key passphrase: \(error.localizedDescription, privacy: .public)")
295+
storageFailed = true
296+
}
297+
}
298+
if !sshKeyContent.isEmpty {
299+
do {
300+
try secureStore.store(sshKeyContent, forKey: "com.TablePro.sshkeydata.\(connection.id.uuidString)")
301+
} catch {
302+
Self.logger.error("Failed to store SSH key data: \(error.localizedDescription, privacy: .public)")
303+
storageFailed = true
304+
}
305+
}
306+
}
307+
308+
if storageFailed {
309+
credentialError = String(localized: "Some credentials could not be saved to the keychain. You may need to re-enter them later.")
310+
return nil
311+
}
312+
313+
return connection
314+
}
315+
316+
func dismissCredentialError() {
317+
credentialError = nil
318+
}
319+
320+
private func buildConnection() -> DatabaseConnection {
321+
var conn = DatabaseConnection(
322+
id: existingConnection?.id ?? UUID(),
323+
name: name.isEmpty ? (selectedFileURL?.lastPathComponent ?? host) : name,
324+
type: type,
325+
host: host,
326+
port: Int(port) ?? 3306,
327+
username: username,
328+
database: database,
329+
sshEnabled: sshEnabled,
330+
sslEnabled: sslEnabled,
331+
groupId: groupId,
332+
tagId: tagId
333+
)
334+
conn.safeModeLevel = safeModeLevel
335+
if sshEnabled {
336+
conn.sshConfiguration = SSHConfiguration(
337+
host: sshHost,
338+
port: Int(sshPort) ?? 22,
339+
username: sshUsername,
340+
authMethod: sshAuthMethod,
341+
privateKeyPath: sshKeyPath.isEmpty ? nil : sshKeyPath,
342+
privateKeyData: sshKeyContent.isEmpty ? nil : sshKeyContent
343+
)
344+
}
345+
return conn
346+
}
347+
}

0 commit comments

Comments
 (0)