new tests
This commit is contained in:
+63
-12
@@ -17,27 +17,54 @@ struct QueryResult {
|
||||
}
|
||||
|
||||
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]
|
||||
private static let service = "DBSyn.MySQL"
|
||||
private static let legacyService = "WorkbenchReplacement.MySQL"
|
||||
|
||||
private static func lookup(_ id: UUID, service: String) -> String? {
|
||||
let query: [String: Any] = [
|
||||
kSecClass as String: kSecClassGenericPassword,
|
||||
kSecAttrService as String: service,
|
||||
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) ?? ""
|
||||
let data = value as? Data else { return nil }
|
||||
return String(data: data, encoding: .utf8)
|
||||
}
|
||||
|
||||
static func read(_ id: UUID) -> String {
|
||||
if let password = lookup(id, service: service) { return password }
|
||||
// One-time compatibility read. Never delete the legacy item.
|
||||
if let password = lookup(id, service: legacyService) {
|
||||
save(password, id: id)
|
||||
return password
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
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]
|
||||
let query: [String: Any] = [
|
||||
kSecClass as String: kSecClassGenericPassword,
|
||||
kSecAttrService as String: service,
|
||||
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)
|
||||
}
|
||||
|
||||
static func delete(_ id: UUID) {
|
||||
let query: [String: Any] = [
|
||||
kSecClass as String: kSecClassGenericPassword,
|
||||
kSecAttrService as String: service,
|
||||
kSecAttrAccount as String: id.uuidString
|
||||
]
|
||||
SecItemDelete(query as CFDictionary)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -507,7 +534,8 @@ final class DatabaseModel: ObservableObject {
|
||||
let cached = try? JSONDecoder().decode([String: SchemaIntrospection].self, from: data) {
|
||||
introspections = cached.filter { entry in profiles.contains(where: { $0.id == entry.value.connectionID }) }
|
||||
}
|
||||
selectedID = profiles.first?.id
|
||||
let rememberedID = UserDefaults.standard.string(forKey: "DBSyn.selectedConnectionID")
|
||||
selectedID = profiles.first(where: { $0.id.uuidString == rememberedID })?.id ?? profiles.first?.id
|
||||
if let id = selectedID {
|
||||
for snapshot in introspections.values where snapshot.connectionID == id {
|
||||
applyIntrospection(snapshot)
|
||||
@@ -620,11 +648,34 @@ final class DatabaseModel: ObservableObject {
|
||||
}
|
||||
|
||||
var selected: ConnectionProfile? { profiles.first { $0.id == selectedID } }
|
||||
|
||||
func selectConnection(_ id: UUID) {
|
||||
guard let profile = profiles.first(where: { $0.id == id }) else { return }
|
||||
saveActiveQuery()
|
||||
selectedID = id
|
||||
UserDefaults.standard.set(id.uuidString, forKey: "DBSyn.selectedConnectionID")
|
||||
selectedDatabase = profile.database
|
||||
databases = []
|
||||
status = "Selected connection: \(profile.name)"
|
||||
loadDatabases()
|
||||
}
|
||||
|
||||
func deleteConnection(_ id: UUID) {
|
||||
guard profiles.count > 1 else { return }
|
||||
NativeMySQLRunner.disconnect(profileID: id)
|
||||
PasswordStore.delete(id)
|
||||
profiles.removeAll { $0.id == id }
|
||||
if selectedID == id, let first = profiles.first { selectConnection(first.id) }
|
||||
persist()
|
||||
}
|
||||
|
||||
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
|
||||
selectedDatabase = profile.database
|
||||
UserDefaults.standard.set(profile.id.uuidString, forKey: "DBSyn.selectedConnectionID")
|
||||
persist()
|
||||
}
|
||||
private func persist() {
|
||||
|
||||
+137
-14
@@ -29,6 +29,7 @@ struct MainView: View {
|
||||
@State private var editResultRow: [String]? = nil
|
||||
private let resultPageSize = 1_000
|
||||
@State private var showingEditor = false
|
||||
@State private var showingConnectionManager = false
|
||||
@State private var showingDatabaseTransfer = false
|
||||
@State private var tableSearchTarget: WorkbenchTableTarget? = nil
|
||||
@State private var tableImportTarget: WorkbenchTableTarget? = nil
|
||||
@@ -169,6 +170,17 @@ struct MainView: View {
|
||||
.sheet(item: $schemaManagement, onDismiss: { model.loadDatabases() }) { target in
|
||||
WorkbenchSchemaManagementSheet(model: model, target: target)
|
||||
}
|
||||
.sheet(isPresented: $showingConnectionManager) {
|
||||
DBSynConnectionManager(model: model) { profile in
|
||||
showingConnectionManager = false
|
||||
edit(profile)
|
||||
} onAdd: {
|
||||
showingConnectionManager = false
|
||||
editing = ConnectionProfile(name: "New Connection")
|
||||
editingPassword = ""
|
||||
showingEditor = true
|
||||
}
|
||||
}
|
||||
.sheet(isPresented: $showingEditor) {
|
||||
ConnectionEditor(
|
||||
profile: $editing,
|
||||
@@ -258,8 +270,29 @@ struct MainView: View {
|
||||
|
||||
Spacer()
|
||||
|
||||
Menu {
|
||||
ForEach(model.profiles) { profile in
|
||||
Button {
|
||||
model.selectConnection(profile.id)
|
||||
} label: {
|
||||
if model.selectedID == profile.id {
|
||||
Label(profile.name, systemImage: "checkmark.circle.fill")
|
||||
} else {
|
||||
Label(profile.name, systemImage: "server.rack")
|
||||
}
|
||||
}
|
||||
}
|
||||
Divider()
|
||||
Button("Manage Connections…") { showingConnectionManager = true }
|
||||
} label: {
|
||||
Label(model.selected?.name ?? "Select Connection", systemImage: "server.rack")
|
||||
.lineLimit(1)
|
||||
.frame(maxWidth: 185)
|
||||
}
|
||||
.help("Switch active MySQL connection")
|
||||
|
||||
VStack(alignment: .trailing, spacing: 2) {
|
||||
Text(model.selected?.name ?? "No Connection")
|
||||
Text(model.selected?.host ?? "Not connected")
|
||||
.font(.system(size: 12, weight: .semibold))
|
||||
|
||||
Text(model.selectedDatabase.isEmpty
|
||||
@@ -366,8 +399,7 @@ struct MainView: View {
|
||||
VStack(alignment: .leading, spacing: 4) {
|
||||
ForEach(model.profiles) { profile in
|
||||
Button {
|
||||
model.selectedID = profile.id
|
||||
model.loadDatabases()
|
||||
model.selectConnection(profile.id)
|
||||
} label: {
|
||||
Label(
|
||||
profile.name,
|
||||
@@ -2455,11 +2487,17 @@ struct ConnectionEditor: View {
|
||||
let save: () -> Void
|
||||
|
||||
@Environment(\.dismiss) private var dismiss
|
||||
@State private var testMessage = ""
|
||||
|
||||
var body: some View {
|
||||
VStack(alignment: .leading, spacing: 14) {
|
||||
Text("Connection Settings")
|
||||
.font(.title2.bold())
|
||||
HStack {
|
||||
Text("Connection Settings").font(.title2.bold())
|
||||
Spacer()
|
||||
Button { dismiss() } label: { Image(systemName: "xmark.circle.fill") }
|
||||
.buttonStyle(.borderless)
|
||||
.help("Close")
|
||||
}
|
||||
|
||||
Form {
|
||||
TextField(
|
||||
@@ -2472,11 +2510,7 @@ struct ConnectionEditor: View {
|
||||
text: $profile.host
|
||||
)
|
||||
|
||||
TextField(
|
||||
"Port",
|
||||
value: $profile.port,
|
||||
format: .number
|
||||
)
|
||||
TextField("Port", value: $profile.port, format: .number.grouping(.never))
|
||||
|
||||
TextField(
|
||||
"Username",
|
||||
@@ -2488,13 +2522,28 @@ struct ConnectionEditor: View {
|
||||
text: $password
|
||||
)
|
||||
|
||||
TextField(
|
||||
"Default Schema",
|
||||
text: $profile.database
|
||||
)
|
||||
TextField("Default Schema", text: $profile.database)
|
||||
Text("Remote MySQL requires a trusted SSH tunnel until native TLS support is available.")
|
||||
.font(.caption).foregroundStyle(.secondary)
|
||||
}
|
||||
|
||||
if !testMessage.isEmpty {
|
||||
Text(testMessage).font(.caption).foregroundStyle(.secondary)
|
||||
.textSelection(.enabled)
|
||||
}
|
||||
HStack {
|
||||
Button("Test Connection") {
|
||||
let candidate = profile
|
||||
let secret = password
|
||||
Task {
|
||||
do {
|
||||
let result = try await NativeMySQLRunner.run(
|
||||
profile: candidate, database: candidate.database,
|
||||
password: secret, sql: "SELECT VERSION()")
|
||||
testMessage = "Connection successful: " + (result.rows.first?.first ?? "MySQL")
|
||||
} catch { testMessage = error.localizedDescription }
|
||||
}
|
||||
}
|
||||
Spacer()
|
||||
|
||||
Button("Cancel") {
|
||||
@@ -7102,3 +7151,77 @@ private struct DBSynInlineRowEditor: View {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
// MARK: - Connection Manager
|
||||
private struct DBSynConnectionManager: View {
|
||||
@ObservedObject var model: DatabaseModel
|
||||
let onEdit: (ConnectionProfile) -> Void
|
||||
let onAdd: () -> Void
|
||||
@Environment(\.dismiss) private var dismiss
|
||||
@State private var search = ""
|
||||
@State private var confirmDelete: UUID?
|
||||
|
||||
private var filtered: [ConnectionProfile] {
|
||||
model.profiles.filter { search.isEmpty || $0.name.localizedCaseInsensitiveContains(search)
|
||||
|| $0.host.localizedCaseInsensitiveContains(search) }
|
||||
}
|
||||
|
||||
var body: some View {
|
||||
VStack(alignment: .leading, spacing: 16) {
|
||||
HStack {
|
||||
Label("Connections", systemImage: "server.rack").font(.title2.bold())
|
||||
Spacer()
|
||||
Button { dismiss() } label: { Image(systemName: "xmark.circle.fill") }
|
||||
.buttonStyle(.borderless)
|
||||
}
|
||||
TextField("Search connections", text: $search)
|
||||
.textFieldStyle(.roundedBorder)
|
||||
List(filtered) { profile in
|
||||
HStack(spacing: 12) {
|
||||
Image(systemName: model.selectedID == profile.id ? "checkmark.circle.fill" : "server.rack")
|
||||
.foregroundStyle(model.selectedID == profile.id ? Color.blue : Color.secondary)
|
||||
VStack(alignment: .leading) {
|
||||
Text(profile.name).font(.headline)
|
||||
Text("\(profile.username)@\(profile.host):\(profile.port)")
|
||||
.font(.caption).foregroundStyle(.secondary)
|
||||
}
|
||||
Spacer()
|
||||
Button("Connect") { model.selectConnection(profile.id); dismiss() }
|
||||
.disabled(model.selectedID == profile.id)
|
||||
Button("Edit") { onEdit(profile) }
|
||||
Button {
|
||||
var copy = profile
|
||||
copy.id = UUID()
|
||||
copy.name += " Copy"
|
||||
model.save(copy, password: PasswordStore.read(profile.id))
|
||||
} label: { Image(systemName: "plus.square.on.square") }
|
||||
.help("Duplicate")
|
||||
Button(role: .destructive) { confirmDelete = profile.id } label: {
|
||||
Image(systemName: "trash")
|
||||
}
|
||||
.disabled(model.profiles.count <= 1)
|
||||
}
|
||||
.padding(.vertical, 5)
|
||||
}
|
||||
HStack {
|
||||
Button("Add Connection") { onAdd() }
|
||||
Spacer()
|
||||
Button("Done") { dismiss() }.buttonStyle(.borderedProminent)
|
||||
}
|
||||
}
|
||||
.padding(24)
|
||||
.frame(minWidth: 750, minHeight: 430)
|
||||
.alert("Delete connection?", isPresented: Binding(
|
||||
get: { confirmDelete != nil }, set: { if !$0 { confirmDelete = nil } }
|
||||
)) {
|
||||
Button("Delete", role: .destructive) {
|
||||
if let id = confirmDelete { model.deleteConnection(id) }
|
||||
confirmDelete = nil
|
||||
}
|
||||
Button("Cancel", role: .cancel) { confirmDelete = nil }
|
||||
} message: {
|
||||
Text("This removes the saved connection and its DBSyn Keychain credential.")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user