new tests
This commit is contained in:
+63
-12
@@ -17,27 +17,54 @@ struct QueryResult {
|
|||||||
}
|
}
|
||||||
|
|
||||||
enum PasswordStore {
|
enum PasswordStore {
|
||||||
static func read(_ id: UUID) -> String {
|
private static let service = "DBSyn.MySQL"
|
||||||
let query: [String: Any] = [kSecClass as String: kSecClassGenericPassword,
|
private static let legacyService = "WorkbenchReplacement.MySQL"
|
||||||
kSecAttrService as String: "WorkbenchReplacement.MySQL",
|
|
||||||
kSecAttrAccount as String: id.uuidString,
|
private static func lookup(_ id: UUID, service: String) -> String? {
|
||||||
kSecReturnData as String: true,
|
let query: [String: Any] = [
|
||||||
kSecMatchLimit as String: kSecMatchLimitOne]
|
kSecClass as String: kSecClassGenericPassword,
|
||||||
|
kSecAttrService as String: service,
|
||||||
|
kSecAttrAccount as String: id.uuidString,
|
||||||
|
kSecReturnData as String: true,
|
||||||
|
kSecMatchLimit as String: kSecMatchLimitOne
|
||||||
|
]
|
||||||
var value: CFTypeRef?
|
var value: CFTypeRef?
|
||||||
guard SecItemCopyMatching(query as CFDictionary, &value) == errSecSuccess,
|
guard SecItemCopyMatching(query as CFDictionary, &value) == errSecSuccess,
|
||||||
let data = value as? Data else { return "" }
|
let data = value as? Data else { return nil }
|
||||||
return String(data: data, encoding: .utf8) ?? ""
|
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) {
|
static func save(_ password: String, id: UUID) {
|
||||||
let query: [String: Any] = [kSecClass as String: kSecClassGenericPassword,
|
let query: [String: Any] = [
|
||||||
kSecAttrService as String: "WorkbenchReplacement.MySQL",
|
kSecClass as String: kSecClassGenericPassword,
|
||||||
kSecAttrAccount as String: id.uuidString]
|
kSecAttrService as String: service,
|
||||||
|
kSecAttrAccount as String: id.uuidString
|
||||||
|
]
|
||||||
SecItemDelete(query as CFDictionary)
|
SecItemDelete(query as CFDictionary)
|
||||||
guard !password.isEmpty else { return }
|
guard !password.isEmpty else { return }
|
||||||
var attributes = query
|
var attributes = query
|
||||||
attributes[kSecValueData as String] = Data(password.utf8)
|
attributes[kSecValueData as String] = Data(password.utf8)
|
||||||
SecItemAdd(attributes as CFDictionary, nil)
|
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) {
|
let cached = try? JSONDecoder().decode([String: SchemaIntrospection].self, from: data) {
|
||||||
introspections = cached.filter { entry in profiles.contains(where: { $0.id == entry.value.connectionID }) }
|
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 {
|
if let id = selectedID {
|
||||||
for snapshot in introspections.values where snapshot.connectionID == id {
|
for snapshot in introspections.values where snapshot.connectionID == id {
|
||||||
applyIntrospection(snapshot)
|
applyIntrospection(snapshot)
|
||||||
@@ -620,11 +648,34 @@ final class DatabaseModel: ObservableObject {
|
|||||||
}
|
}
|
||||||
|
|
||||||
var selected: ConnectionProfile? { profiles.first { $0.id == selectedID } }
|
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) {
|
func save(_ profile: ConnectionProfile, password: String) {
|
||||||
if let i = profiles.firstIndex(where: { $0.id == profile.id }) { profiles[i] = profile }
|
if let i = profiles.firstIndex(where: { $0.id == profile.id }) { profiles[i] = profile }
|
||||||
else { profiles.append(profile) }
|
else { profiles.append(profile) }
|
||||||
PasswordStore.save(password, id: profile.id)
|
PasswordStore.save(password, id: profile.id)
|
||||||
selectedID = profile.id
|
selectedID = profile.id
|
||||||
|
selectedDatabase = profile.database
|
||||||
|
UserDefaults.standard.set(profile.id.uuidString, forKey: "DBSyn.selectedConnectionID")
|
||||||
persist()
|
persist()
|
||||||
}
|
}
|
||||||
private func persist() {
|
private func persist() {
|
||||||
|
|||||||
+137
-14
@@ -29,6 +29,7 @@ struct MainView: View {
|
|||||||
@State private var editResultRow: [String]? = nil
|
@State private var editResultRow: [String]? = nil
|
||||||
private let resultPageSize = 1_000
|
private let resultPageSize = 1_000
|
||||||
@State private var showingEditor = false
|
@State private var showingEditor = false
|
||||||
|
@State private var showingConnectionManager = false
|
||||||
@State private var showingDatabaseTransfer = false
|
@State private var showingDatabaseTransfer = false
|
||||||
@State private var tableSearchTarget: WorkbenchTableTarget? = nil
|
@State private var tableSearchTarget: WorkbenchTableTarget? = nil
|
||||||
@State private var tableImportTarget: WorkbenchTableTarget? = nil
|
@State private var tableImportTarget: WorkbenchTableTarget? = nil
|
||||||
@@ -169,6 +170,17 @@ struct MainView: View {
|
|||||||
.sheet(item: $schemaManagement, onDismiss: { model.loadDatabases() }) { target in
|
.sheet(item: $schemaManagement, onDismiss: { model.loadDatabases() }) { target in
|
||||||
WorkbenchSchemaManagementSheet(model: model, target: target)
|
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) {
|
.sheet(isPresented: $showingEditor) {
|
||||||
ConnectionEditor(
|
ConnectionEditor(
|
||||||
profile: $editing,
|
profile: $editing,
|
||||||
@@ -258,8 +270,29 @@ struct MainView: View {
|
|||||||
|
|
||||||
Spacer()
|
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) {
|
VStack(alignment: .trailing, spacing: 2) {
|
||||||
Text(model.selected?.name ?? "No Connection")
|
Text(model.selected?.host ?? "Not connected")
|
||||||
.font(.system(size: 12, weight: .semibold))
|
.font(.system(size: 12, weight: .semibold))
|
||||||
|
|
||||||
Text(model.selectedDatabase.isEmpty
|
Text(model.selectedDatabase.isEmpty
|
||||||
@@ -366,8 +399,7 @@ struct MainView: View {
|
|||||||
VStack(alignment: .leading, spacing: 4) {
|
VStack(alignment: .leading, spacing: 4) {
|
||||||
ForEach(model.profiles) { profile in
|
ForEach(model.profiles) { profile in
|
||||||
Button {
|
Button {
|
||||||
model.selectedID = profile.id
|
model.selectConnection(profile.id)
|
||||||
model.loadDatabases()
|
|
||||||
} label: {
|
} label: {
|
||||||
Label(
|
Label(
|
||||||
profile.name,
|
profile.name,
|
||||||
@@ -2455,11 +2487,17 @@ struct ConnectionEditor: View {
|
|||||||
let save: () -> Void
|
let save: () -> Void
|
||||||
|
|
||||||
@Environment(\.dismiss) private var dismiss
|
@Environment(\.dismiss) private var dismiss
|
||||||
|
@State private var testMessage = ""
|
||||||
|
|
||||||
var body: some View {
|
var body: some View {
|
||||||
VStack(alignment: .leading, spacing: 14) {
|
VStack(alignment: .leading, spacing: 14) {
|
||||||
Text("Connection Settings")
|
HStack {
|
||||||
.font(.title2.bold())
|
Text("Connection Settings").font(.title2.bold())
|
||||||
|
Spacer()
|
||||||
|
Button { dismiss() } label: { Image(systemName: "xmark.circle.fill") }
|
||||||
|
.buttonStyle(.borderless)
|
||||||
|
.help("Close")
|
||||||
|
}
|
||||||
|
|
||||||
Form {
|
Form {
|
||||||
TextField(
|
TextField(
|
||||||
@@ -2472,11 +2510,7 @@ struct ConnectionEditor: View {
|
|||||||
text: $profile.host
|
text: $profile.host
|
||||||
)
|
)
|
||||||
|
|
||||||
TextField(
|
TextField("Port", value: $profile.port, format: .number.grouping(.never))
|
||||||
"Port",
|
|
||||||
value: $profile.port,
|
|
||||||
format: .number
|
|
||||||
)
|
|
||||||
|
|
||||||
TextField(
|
TextField(
|
||||||
"Username",
|
"Username",
|
||||||
@@ -2488,13 +2522,28 @@ struct ConnectionEditor: View {
|
|||||||
text: $password
|
text: $password
|
||||||
)
|
)
|
||||||
|
|
||||||
TextField(
|
TextField("Default Schema", text: $profile.database)
|
||||||
"Default Schema",
|
Text("Remote MySQL requires a trusted SSH tunnel until native TLS support is available.")
|
||||||
text: $profile.database
|
.font(.caption).foregroundStyle(.secondary)
|
||||||
)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if !testMessage.isEmpty {
|
||||||
|
Text(testMessage).font(.caption).foregroundStyle(.secondary)
|
||||||
|
.textSelection(.enabled)
|
||||||
|
}
|
||||||
HStack {
|
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()
|
Spacer()
|
||||||
|
|
||||||
Button("Cancel") {
|
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