98 lines
4.2 KiB
Plaintext
98 lines
4.2 KiB
Plaintext
import Foundation
|
|
import Security
|
|
|
|
struct ConnectionProfile: Identifiable, Codable, Hashable {
|
|
var id = UUID()
|
|
var name = "Local MySQL"
|
|
var host = "127.0.0.1"
|
|
var port = 3306
|
|
var username = "root"
|
|
var database = ""
|
|
}
|
|
struct QueryResult {
|
|
var columns: [String] = []
|
|
var rows: [[String]] = []
|
|
var message = ""
|
|
}
|
|
|
|
enum PasswordStore {
|
|
static func read(_ id: UUID) -> String {
|
|
let query: [String: Any] = [kSecClass as String: kSecClassGenericPassword,
|
|
kSecAttrService as String: "WorkbenchReplacement.MySQL",
|
|
kSecAttrAccount as String: id.uuidString,
|
|
kSecReturnData as String: true,
|
|
kSecMatchLimit as String: kSecMatchLimitOne]
|
|
var value: CFTypeRef?
|
|
guard SecItemCopyMatching(query as CFDictionary, &value) == errSecSuccess,
|
|
let data = value as? Data else { return "" }
|
|
return String(data: data, encoding: .utf8) ?? ""
|
|
}
|
|
static func save(_ password: String, id: UUID) {
|
|
let query: [String: Any] = [kSecClass as String: kSecClassGenericPassword,
|
|
kSecAttrService as String: "WorkbenchReplacement.MySQL",
|
|
kSecAttrAccount as String: id.uuidString]
|
|
SecItemDelete(query as CFDictionary)
|
|
guard !password.isEmpty else { return }
|
|
var attributes = query
|
|
attributes[kSecValueData as String] = Data(password.utf8)
|
|
SecItemAdd(attributes as CFDictionary, nil)
|
|
}
|
|
}
|
|
|
|
@MainActor
|
|
final class DatabaseModel: ObservableObject {
|
|
@Published var profiles: [ConnectionProfile] = []
|
|
@Published var selectedID: UUID?
|
|
@Published var sql = "SELECT VERSION() AS server_version;"
|
|
@Published var result = QueryResult()
|
|
@Published var databases: [String] = []
|
|
@Published var selectedDatabase = ""
|
|
@Published var isRunning = false
|
|
@Published var status = "Not connected"
|
|
private let storageKey = "WorkbenchReplacement.connectionProfiles.native.v1"
|
|
|
|
init() {
|
|
if let data = UserDefaults.standard.data(forKey: storageKey),
|
|
let decoded = try? JSONDecoder().decode([ConnectionProfile].self, from: data) { profiles = decoded }
|
|
if profiles.isEmpty { profiles = [ConnectionProfile()] }
|
|
selectedID = profiles.first?.id
|
|
}
|
|
var selected: ConnectionProfile? { profiles.first { $0.id == selectedID } }
|
|
func save(_ profile: ConnectionProfile, password: String) {
|
|
if let i = profiles.firstIndex(where: { $0.id == profile.id }) { profiles[i] = profile }
|
|
else { profiles.append(profile) }
|
|
PasswordStore.save(password, id: profile.id)
|
|
selectedID = profile.id
|
|
persist()
|
|
}
|
|
private func persist() {
|
|
if let data = try? JSONEncoder().encode(profiles) { UserDefaults.standard.set(data, forKey: storageKey) }
|
|
}
|
|
func loadDatabases() { execute("SHOW DATABASES;", listingDatabases: true) }
|
|
func runSQL() { execute(sql, listingDatabases: false) }
|
|
private func execute(_ statement: String, listingDatabases: Bool) {
|
|
guard let profile = selected else { status = "Select a connection"; return }
|
|
guard !statement.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty else { return }
|
|
isRunning = true
|
|
status = "Connecting…"
|
|
let activeDB = listingDatabases ? "" : (selectedDatabase.isEmpty ? profile.database : selectedDatabase)
|
|
let password = PasswordStore.read(profile.id)
|
|
Task {
|
|
do {
|
|
let parsed = try await NativeMySQLRunner.run(profile: profile, database: activeDB, password: password, sql: statement)
|
|
if listingDatabases {
|
|
databases = parsed.rows.compactMap { $0.first }
|
|
status = "Connected to \(profile.name)"
|
|
} else {
|
|
result = parsed
|
|
status = "Query completed (\(parsed.rows.count) rows)"
|
|
}
|
|
} catch {
|
|
status = "Error"
|
|
result = QueryResult(message: error.localizedDescription)
|
|
}
|
|
isRunning = false
|
|
}
|
|
}
|
|
}
|