Improved progress display

Previously, the progress bar updated only after completing an entire table.

For large tables such as LIBARC, this could make the application appear to have stopped responding.

Phase 10 updates the interface while rows are being exported.
This commit is contained in:
Mark Gregory
2026-10-18 14:43:13 +01:00
parent 0891230b39
commit ebd0bd1e29
9 changed files with 76 additions and 38 deletions
+467
View File
@@ -0,0 +1,467 @@
import SwiftUI
import AppKit
extension Notification.Name {
static let workbenchRevealSQLEditor = Notification.Name("DBSyn.RevealSQLEditor")
}
@main
struct DBSynApp: App {
@StateObject private var model = DatabaseModel()
var body: some Scene {
WindowGroup("DBSyn") {
WorkbenchLaunchView()
.onAppear {
if let iconURL = Bundle.main.url(forResource: "DBSyn", withExtension: "icns"),
let icon = NSImage(contentsOf: iconURL) {
NSApplication.shared.applicationIconImage = icon
}
}
.environmentObject(model)
.frame(minWidth: 800, minHeight: 520)
}
.defaultSize(width: 1200, height: 800)
.windowResizability(.contentSize)
.commands {
CommandGroup(replacing: .appInfo) {
Button("About DBSyn") {
let icon = NSImage(contentsOfFile: Bundle.main.path(forResource: "DBSynBrand", ofType: "png") ?? "")
NSApplication.shared.orderFrontStandardAboutPanel(options: [
.applicationName: "DBSyn",
.applicationIcon: icon ?? NSApplication.shared.applicationIconImage,
.version: "Phase 59.2",
.credits: NSAttributedString(string: "Native MySQL client for macOS")
])
}
}
WorkbenchFileCommands(model: model)
}
Settings {
WorkbenchSettingsView()
}
}
}
// MARK: - Application Preferences
@MainActor
final class WorkbenchPreferences: ObservableObject {
static let shared = WorkbenchPreferences()
@AppStorage("preferences.editorFontSize")
var editorFontSize = 13.0
@AppStorage("preferences.uppercaseKeywords")
var uppercaseKeywords = true
@AppStorage("preferences.showLineNumbers")
var showLineNumbers = true
@AppStorage("preferences.tabWidth")
var tabWidth = 4
@AppStorage("preferences.resultRowHeight")
var resultRowHeight = 24.0
@AppStorage("preferences.resultColumnWidth")
var resultColumnWidth = 170.0
@AppStorage("preferences.alternatingRows")
var alternatingRows = true
@AppStorage("preferences.appearance")
var appearance = "light"
@AppStorage("preferences.autoExpandTables")
var autoExpandTables = true
}
// MARK: - Workbench Settings
struct WorkbenchSettingsView: View {
@AppStorage("preferences.editorFontSize")
private var editorFontSize = 13.0
@AppStorage("preferences.uppercaseKeywords")
private var uppercaseKeywords = true
@AppStorage("preferences.showLineNumbers")
private var showLineNumbers = true
@AppStorage("preferences.tabWidth")
private var tabWidth = 4
@AppStorage("preferences.defaultRowLimit")
private var defaultRowLimit = 500
@AppStorage("preferences.resultRowHeight")
private var resultRowHeight = 24.0
@AppStorage("preferences.resultColumnWidth")
private var resultColumnWidth = 170.0
@AppStorage("preferences.alternatingRows")
private var alternatingRows = true
@AppStorage("preferences.autoExpandTables")
private var autoExpandTables = true
var body: some View {
TabView {
editorSettings
.tabItem {
Label(
"SQL Editor",
systemImage: "chevron.left.forwardslash.chevron.right"
)
}
executionSettings
.tabItem {
Label(
"Query Execution",
systemImage: "bolt"
)
}
resultSettings
.tabItem {
Label(
"Result Grid",
systemImage: "tablecells"
)
}
generalSettings
.tabItem {
Label(
"General",
systemImage: "gearshape"
)
}
}
.frame(width: 620, height: 390)
.preferredColorScheme(.light)
}
private var editorSettings: some View {
settingsPage("SQL Editor") {
settingsRow("Font Size") {
Stepper(
"\(Int(editorFontSize)) pt",
value: $editorFontSize,
in: 10...24,
step: 1
)
.frame(width: 130)
}
settingsRow("Tab Width") {
Picker("", selection: $tabWidth) {
Text("2 spaces").tag(2)
Text("4 spaces").tag(4)
Text("8 spaces").tag(8)
}
.labelsHidden()
.frame(width: 150)
}
settingsRow("SQL Formatting") {
Toggle(
"Automatically uppercase SQL keywords",
isOn: $uppercaseKeywords
)
}
settingsRow("Editor Display") {
Toggle(
"Show line numbers",
isOn: $showLineNumbers
)
}
}
}
private var executionSettings: some View {
settingsPage("Query Execution") {
settingsRow("Default Row Limit") {
HStack(spacing: 12) {
TextField(
"",
value: $defaultRowLimit,
format: .number
)
.textFieldStyle(.roundedBorder)
.frame(width: 110)
Stepper(
"",
value: $defaultRowLimit,
in: 50...100000,
step: 50
)
.labelsHidden()
}
}
Text(
"Controls the number of rows retrieved when opening a table from the schema navigator."
)
.font(.system(size: 12))
.foregroundStyle(.secondary)
.frame(maxWidth: .infinity, alignment: .leading)
.padding(.leading, 190)
Divider()
.padding(.vertical, 12)
Text("Examples")
.font(.headline)
Text(
"500 rows: Faster browsing and lower memory usage.\n" +
"5,000 rows: Useful for larger datasets.\n" +
"50,000 rows: May take longer to retrieve."
)
.font(.system(size: 12))
.foregroundStyle(.secondary)
.frame(maxWidth: .infinity, alignment: .leading)
}
}
private var resultSettings: some View {
settingsPage("Result Grid") {
settingsRow("Row Height") {
Stepper(
"\(Int(resultRowHeight)) px",
value: $resultRowHeight,
in: 18...40,
step: 1
)
.frame(width: 130)
}
settingsRow("Column Width") {
Stepper(
"\(Int(resultColumnWidth)) px",
value: $resultColumnWidth,
in: 80...400,
step: 10
)
.frame(width: 140)
}
settingsRow("Appearance") {
Toggle(
"Alternating row colours",
isOn: $alternatingRows
)
}
}
}
private var generalSettings: some View {
settingsPage("General") {
settingsRow("Navigator") {
Toggle(
"Automatically expand table folders",
isOn: $autoExpandTables
)
}
}
}
private func settingsPage<Content: View>(
_ title: String,
@ViewBuilder content: () -> Content
) -> some View {
VStack(alignment: .leading, spacing: 18) {
Text(title)
.font(.system(size: 20, weight: .semibold))
Divider()
content()
Spacer(minLength: 0)
}
.padding(26)
.frame(maxWidth: .infinity, maxHeight: .infinity)
.background(Color(nsColor: .windowBackgroundColor))
}
private func settingsRow<Content: View>(
_ title: String,
@ViewBuilder content: () -> Content
) -> some View {
HStack(alignment: .center, spacing: 16) {
Text(title)
.font(.system(size: 13))
.frame(width: 170, alignment: .trailing)
content()
Spacer(minLength: 0)
}
.frame(height: 30)
}
}
// MARK: - Workbench Splash Screen
struct WorkbenchLaunchView: View {
@State private var showingSplash = true
var body: some View {
ZStack {
MainView()
if showingSplash {
WorkbenchSplashView()
.transition(.opacity)
.zIndex(10)
}
}
.task {
try? await Task.sleep(for: .seconds(3))
withAnimation(.easeOut(duration: 0.4)) {
showingSplash = false
}
}
}
}
struct WorkbenchSplashView: View {
var body: some View {
VStack(spacing: 20) {
Spacer()
if let path = Bundle.main.path(
forResource: "DBSynBrand",
ofType: "png"
), let image = NSImage(contentsOfFile: path) {
Image(nsImage: image)
.resizable()
.scaledToFit()
.frame(width: 160, height: 160)
}
Text("DBSyn")
.font(.system(size: 30, weight: .bold))
Text("Modern MySQL Client for macOS")
.font(.system(size: 15))
.foregroundStyle(.secondary)
HStack(spacing: 30) {
feature("cylinder.split.1x2", "Schemas")
feature("chevron.left.forwardslash.chevron.right", "SQL Editor")
feature("tablecells", "Result Grid")
feature("server.rack", "Connections")
}
.padding(.top, 20)
Spacer()
ProgressView()
.frame(width: 260)
Text("Starting DBSyn…")
.font(.caption)
.foregroundStyle(.secondary)
.padding(.bottom, 30)
}
.frame(maxWidth: .infinity, maxHeight: .infinity)
.background(
LinearGradient(
colors: [
Color(red: 0.96, green: 0.98, blue: 1),
Color(red: 0.88, green: 0.93, blue: 1)
],
startPoint: .topLeading,
endPoint: .bottomTrailing
)
)
.preferredColorScheme(.light)
}
private func feature(_ symbol: String, _ title: String) -> some View {
VStack(spacing: 8) {
Image(systemName: symbol)
.font(.system(size: 24))
.foregroundStyle(.blue)
Text(title)
.font(.system(size: 12))
}
.frame(width: 90)
}
}
// MARK: - Workbench File Menu
struct WorkbenchFileCommands: Commands {
@ObservedObject var model: DatabaseModel
var body: some Commands {
CommandGroup(replacing: .newItem) {
Button("New Query Tab") {
model.newQueryTab()
NotificationCenter.default.post(name: .workbenchRevealSQLEditor, object: nil)
}
.keyboardShortcut("t", modifiers: .command)
}
CommandGroup(after: .newItem) {
Button("Open SQL Script…") {
model.openSQLScript()
}
.keyboardShortcut("o", modifiers: .command)
Menu("Open Recent SQL Script") {
if model.recentSQLScripts.isEmpty {
Text("No Recent Scripts")
} else {
ForEach(model.recentSQLScripts, id: \.self) { path in
Button(URL(fileURLWithPath: path).lastPathComponent) {
model.openRecentSQLScript(at: path)
}
.help(path)
}
Divider()
Button("Clear Recent Scripts") { model.clearRecentSQLScripts() }
}
}
Divider()
Button("Save SQL Script") {
model.saveSQLScript()
}
.keyboardShortcut("s", modifiers: .command)
Button("Save SQL Script As…") {
model.saveSQLScript(asNewFile: true)
}
.keyboardShortcut("s", modifiers: [.command, .shift])
}
}
}
+651
View File
@@ -0,0 +1,651 @@
import Foundation
import AppKit
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)
}
}
struct WorkbenchQueryTab: Identifiable {
let id: UUID
var title: String
var sql: String
var result: QueryResult
var fileURL: URL?
init(
id: UUID = UUID(),
title: String,
sql: String = "",
result: QueryResult = QueryResult(),
fileURL: URL? = nil
) {
self.id = id
self.title = title
self.sql = sql
self.result = result
self.fileURL = fileURL
}
}
struct SQLHistoryEntry: Identifiable, Codable {
var id = UUID()
var executedAt = Date()
var connectionName: String
var database: String
var sql: String
var succeeded: Bool
var detail: String
}
struct SavedSQLQuery: Identifiable, Codable {
var id = UUID()
var name: String
var sql: String
}
@MainActor
final class DatabaseModel: ObservableObject {
@Published var profiles: [ConnectionProfile] = []
@Published private(set) var queryHistory: [SQLHistoryEntry] = []
@Published private(set) var savedQueries: [SavedSQLQuery] = []
private let historyKey = "WorkbenchReplacement.queryHistory.v1"
private let savedQueriesKey = "WorkbenchReplacement.savedQueries.v1"
private let maxHistoryEntries = 200
private let recentScriptsKey = "WorkbenchReplacement.recentSQLScripts.v1"
@Published private(set) var recentSQLScripts: [String] = []
private func recordRecentSQLScript(_ url: URL) {
let path = url.standardizedFileURL.path
recentSQLScripts.removeAll { $0 == path }
recentSQLScripts.insert(path, at: 0)
recentSQLScripts = Array(recentSQLScripts.prefix(12))
UserDefaults.standard.set(recentSQLScripts, forKey: recentScriptsKey)
}
func clearRecentSQLScripts() {
recentSQLScripts = []
UserDefaults.standard.removeObject(forKey: recentScriptsKey)
}
func openRecentSQLScript(at path: String) {
let url = URL(fileURLWithPath: path)
do {
let contents = try String(contentsOf: url, encoding: .utf8)
saveActiveQuery()
let tab = WorkbenchQueryTab(title: url.lastPathComponent, sql: contents, fileURL: url)
queryTabs.append(tab)
activateQuery(tab.id)
recordRecentSQLScript(url)
status = "Opened \(url.lastPathComponent)"
NotificationCenter.default.post(name: .workbenchRevealSQLEditor, object: nil)
} catch {
status = "Could not open \(url.lastPathComponent): \(error.localizedDescription)"
}
}
func saveCurrentQuery(named name: String) {
let clean = name.trimmingCharacters(in: .whitespacesAndNewlines)
guard !clean.isEmpty, !sql.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty else { return }
savedQueries.insert(SavedSQLQuery(name: clean, sql: sql), at: 0)
persistSavedQueries()
}
func openSavedQuery(_ entry: SavedSQLQuery) {
newQueryTab()
sql = entry.sql
if let id = activeQueryID, let index = queryTabs.firstIndex(where: { $0.id == id }) {
queryTabs[index].title = entry.name
queryTabs[index].sql = entry.sql
}
}
func deleteSavedQuery(_ id: UUID) {
savedQueries.removeAll { $0.id == id }
persistSavedQueries()
}
func clearQueryHistory() {
queryHistory.removeAll()
UserDefaults.standard.removeObject(forKey: historyKey)
}
private func persistSavedQueries() {
if let data = try? JSONEncoder().encode(savedQueries) {
UserDefaults.standard.set(data, forKey: savedQueriesKey)
}
}
private func recordQuery(_ statement: String, profile: ConnectionProfile, database: String, succeeded: Bool, detail: String) {
queryHistory.insert(SQLHistoryEntry(connectionName: profile.name, database: database, sql: statement, succeeded: succeeded, detail: detail), at: 0)
if queryHistory.count > maxHistoryEntries {
queryHistory.removeLast(queryHistory.count - maxHistoryEntries)
}
if let data = try? JSONEncoder().encode(queryHistory) {
UserDefaults.standard.set(data, forKey: historyKey)
}
}
@Published var selectedID: UUID?
@Published var queryTabs: [WorkbenchQueryTab] = [
WorkbenchQueryTab(
title: "Query 1",
sql: "SELECT VERSION() AS server_version;"
)
]
@Published var activeQueryID: UUID?
@Published var sql = "SELECT VERSION() AS server_version;"
@Published var result = QueryResult()
@Published var databases: [String] = []
@Published var schemaTables: [String: [String]] = [:]
@Published var schemaViews: [String: [String]] = [:]
@Published var schemaProcedures: [String: [String]] = [:]
@Published var schemaFunctions: [String: [String]] = [:]
@Published var schemaColumns: [String: [String]] = [:]
@Published var selectedDatabase = ""
@Published var queryDurationText = ""
@Published var isRunning = false
@Published var status = "Not connected"
private let storageKey = "WorkbenchReplacement.connectionProfiles.native.v1"
init() {
recentSQLScripts = UserDefaults.standard.stringArray(forKey: recentScriptsKey) ?? []
if let data = UserDefaults.standard.data(forKey: historyKey),
let entries = try? JSONDecoder().decode([SQLHistoryEntry].self, from: data) {
queryHistory = Array(entries.prefix(maxHistoryEntries))
}
if let data = UserDefaults.standard.data(forKey: savedQueriesKey),
let entries = try? JSONDecoder().decode([SavedSQLQuery].self, from: data) {
savedQueries = entries
}
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
activeQueryID = queryTabs.first?.id
}
func newQueryTab() {
saveActiveQuery()
let tab = WorkbenchQueryTab(
title: "Query \(queryTabs.count + 1)"
)
queryTabs.append(tab)
activeQueryID = tab.id
sql = tab.sql
result = tab.result
}
func saveActiveQuery() {
guard let id = activeQueryID,
let index = queryTabs.firstIndex(where: { $0.id == id })
else { return }
queryTabs[index].sql = sql
queryTabs[index].result = result
}
func activateQuery(_ id: UUID) {
saveActiveQuery()
guard let tab = queryTabs.first(where: { $0.id == id })
else { return }
activeQueryID = tab.id
sql = tab.sql
result = tab.result
}
func closeQuery(_ id: UUID) {
saveActiveQuery()
queryTabs.removeAll { $0.id == id }
if queryTabs.isEmpty {
let tab = WorkbenchQueryTab(title: "Query 1")
queryTabs.append(tab)
}
if activeQueryID == id {
activeQueryID = nil
activateQuery(queryTabs[0].id)
}
}
func openSQLScript() {
let panel = NSOpenPanel()
panel.allowedContentTypes = [.plainText]
panel.allowsOtherFileTypes = true
panel.canChooseDirectories = false
guard panel.runModal() == .OK,
let url = panel.url,
let contents = try? String(contentsOf: url, encoding: .utf8)
else { return }
saveActiveQuery()
let tab = WorkbenchQueryTab(
title: url.lastPathComponent,
sql: contents,
fileURL: url
)
queryTabs.append(tab)
activateQuery(tab.id)
recordRecentSQLScript(url)
NotificationCenter.default.post(name: .workbenchRevealSQLEditor, object: nil)
status = "Opened \\(url.lastPathComponent)"
}
func saveSQLScript(asNewFile: Bool = false) {
saveActiveQuery()
guard let id = activeQueryID,
let index = queryTabs.firstIndex(where: { $0.id == id })
else { return }
var destination = asNewFile ? nil : queryTabs[index].fileURL
if destination == nil {
let panel = NSSavePanel()
panel.nameFieldStringValue = "query.sql"
panel.canCreateDirectories = true
guard panel.runModal() == .OK else { return }
destination = panel.url
}
guard let url = destination else { return }
do {
try sql.write(to: url, atomically: true, encoding: .utf8)
queryTabs[index].fileURL = url
queryTabs[index].title = url.lastPathComponent
recordRecentSQLScript(url)
status = "Saved \\(url.lastPathComponent)"
} catch {
status = "Save failed: \\(error.localizedDescription)"
}
}
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 formatSQL() {
sql = SQLFormatter.uppercaseKeywords(sql)
}
func loadTables(database: String) {
guard let profile = selected else {
status = "Select a MySQL connection"
return
}
let password = PasswordStore.read(profile.id)
let escaped = database.replacingOccurrences(
of: "`",
with: "``"
)
status = "Loading tables from \(database)…"
Task {
do {
let parsed = try await NativeMySQLRunner.run(
profile: profile,
database: database,
password: password,
sql: "SHOW FULL TABLES FROM `\(escaped)`"
)
var tables: [String] = []
var views: [String] = []
for row in parsed.rows {
guard let name = row.first else {
continue
}
if row.count > 1 && row[1] == "VIEW" {
views.append(name)
} else {
tables.append(name)
}
}
schemaTables[database] = tables
schemaViews[database] = views
// Routine metadata is separate from SHOW FULL TABLES.
// Failure (e.g. insufficient INFORMATION_SCHEMA privileges)
// must not prevent browsing tables and views.
do {
let quotedSchema = database.replacingOccurrences(of: "'", with: "''")
let routineResult = try await NativeMySQLRunner.run(
profile: profile, database: database, password: password,
sql: "SELECT ROUTINE_NAME, ROUTINE_TYPE FROM information_schema.ROUTINES WHERE ROUTINE_SCHEMA = '\(quotedSchema)' ORDER BY ROUTINE_NAME"
)
schemaProcedures[database] = routineResult.rows.compactMap { row in
row.count >= 2 && row[1].uppercased() == "PROCEDURE" ? row[0] : nil
}
schemaFunctions[database] = routineResult.rows.compactMap { row in
row.count >= 2 && row[1].uppercased() == "FUNCTION" ? row[0] : nil
}
} catch {
schemaProcedures[database] = []
schemaFunctions[database] = []
}
// Cache column names for SQL completion. The lookup is read-only,
// scoped to the selected schema and uses the current connection.
// Failures (e.g. metadata permissions) do not prevent table browsing.
do {
let quotedSchema = database.replacingOccurrences(of: "'", with: "''")
let columnResult = try await NativeMySQLRunner.run(
profile: profile,
database: database,
password: password,
sql: "SELECT TABLE_NAME, COLUMN_NAME FROM information_schema.COLUMNS WHERE TABLE_SCHEMA = '\(quotedSchema)' ORDER BY TABLE_NAME, ORDINAL_POSITION"
)
var names = Set<String>()
for row in columnResult.rows where row.count >= 2 {
names.insert(row[1])
names.insert("\(row[0]).\(row[1])")
}
schemaColumns[database] = names.sorted()
} catch {
schemaColumns[database] = []
}
status = "Loaded \(tables.count) tables and \(views.count) views from \(database)"
} catch {
status = "Unable to load tables: \(error.localizedDescription)"
result = QueryResult(message: error.localizedDescription)
}
}
}
func selectTable(database: String, table: String) {
let db = database.replacingOccurrences(
of: "`",
with: "``"
)
let tbl = table.replacingOccurrences(
of: "`",
with: "``"
)
selectedDatabase = database
let configuredLimit = UserDefaults.standard.integer(
forKey: "preferences.defaultRowLimit"
)
let rowLimit = configuredLimit > 0
? min(configuredLimit, 100000)
: 500
sql = "SELECT * FROM `\(db)`.`\(tbl)` LIMIT \(rowLimit);"
runSQL()
}
func loadDatabases() { execute("SHOW DATABASES;", listingDatabases: true) }
func runSQL() {
if UserDefaults.standard.object(
forKey: "preferences.uppercaseKeywords"
) as? Bool ?? true {
formatSQL()
}
execute(sql, listingDatabases: false)
}
func runSQL(statement: String) {
guard !statement.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty else { return }
execute(statement, 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
if !listingDatabases { queryDurationText = "Running…" }
let queryStarted = Date()
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
if let id = activeQueryID,
let index = queryTabs.firstIndex(where: { $0.id == id }) {
queryTabs[index].sql = sql
queryTabs[index].result = parsed
}
status = "Query completed (\(parsed.rows.count) rows)"
recordQuery(statement, profile: profile, database: activeDB, succeeded: true, detail: status)
}
} catch {
status = "Error"
result = QueryResult(message: error.localizedDescription)
if !listingDatabases {
recordQuery(statement, profile: profile, database: activeDB, succeeded: false, detail: error.localizedDescription)
}
}
if !listingDatabases {
queryDurationText = String(format: "%.3f s", Date().timeIntervalSince(queryStarted))
}
isRunning = false
}
}
}
// MARK: - SQL Keyword Formatter
enum SQLFormatter {
private static let keywords: Set<String> = [
"SELECT", "FROM", "WHERE", "INSERT", "INTO", "VALUES",
"UPDATE", "DELETE", "CREATE", "ALTER", "DROP", "TABLE",
"DATABASE", "SCHEMA", "USE", "SHOW", "DESCRIBE", "EXPLAIN",
"JOIN", "INNER", "LEFT", "RIGHT", "FULL", "OUTER",
"ON", "AS", "AND", "OR", "NOT", "NULL", "IS", "IN",
"BETWEEN", "LIKE", "ORDER", "BY", "GROUP", "HAVING",
"LIMIT", "OFFSET", "ASC", "DESC", "DISTINCT", "ALL",
"PRIMARY", "KEY", "FOREIGN", "REFERENCES", "INDEX",
"UNIQUE", "DEFAULT", "AUTO_INCREMENT", "IF", "EXISTS",
"CASCADE", "RESTRICT", "SET", "BEGIN", "COMMIT",
"ROLLBACK", "TRANSACTION", "UNION", "CASE", "WHEN",
"THEN", "ELSE", "END", "TRUE", "FALSE", "COUNT",
"SUM", "AVG", "MIN", "MAX", "HAVING", "WITH",
"RECURSIVE", "TRUNCATE", "REPLACE", "VIEW",
"PROCEDURE", "FUNCTION", "TRIGGER", "GRANT",
"REVOKE", "DISTINCT", "CURRENT_TIMESTAMP"
]
static func uppercaseKeywords(_ sql: String) -> String {
let chars = Array(sql)
var output = ""
var index = 0
enum State {
case normal
case singleQuote
case doubleQuote
case backtick
case lineComment
case blockComment
}
var state = State.normal
while index < chars.count {
let ch = chars[index]
let next: Character? = index + 1 < chars.count
? chars[index + 1] : nil
switch state {
case .normal:
if ch == "'" {
state = .singleQuote
output.append(ch)
index += 1
} else if ch == "\"" {
state = .doubleQuote
output.append(ch)
index += 1
} else if ch == "`" {
state = .backtick
output.append(ch)
index += 1
} else if ch == "-" && next == "-" &&
(index + 2 == chars.count ||
chars[index + 2].isWhitespace) {
state = .lineComment
output += "--"
index += 2
} else if ch == "#" {
state = .lineComment
output.append(ch)
index += 1
} else if ch == "/" && next == "*" {
state = .blockComment
output += "/*"
index += 2
} else if ch.isLetter || ch == "_" {
let start = index
while index < chars.count &&
(chars[index].isLetter ||
chars[index].isNumber ||
chars[index] == "_" ||
chars[index] == "$") {
index += 1
}
let word = String(chars[start..<index])
let upper = word.uppercased()
output += keywords.contains(upper) ? upper : word
} else {
output.append(ch)
index += 1
}
case .singleQuote:
output.append(ch)
index += 1
if ch == "\\" && index < chars.count {
output.append(chars[index])
index += 1
} else if ch == "'" {
if index < chars.count && chars[index] == "'" {
output.append(chars[index])
index += 1
} else {
state = .normal
}
}
case .doubleQuote:
output.append(ch)
index += 1
if ch == "\\" && index < chars.count {
output.append(chars[index])
index += 1
} else if ch == "\"" {
if index < chars.count && chars[index] == "\"" {
output.append(chars[index])
index += 1
} else {
state = .normal
}
}
case .backtick:
output.append(ch)
index += 1
if ch == "`" {
if index < chars.count && chars[index] == "`" {
output.append(chars[index])
index += 1
} else {
state = .normal
}
}
case .lineComment:
output.append(ch)
index += 1
if ch == "\n" {
state = .normal
}
case .blockComment:
output.append(ch)
index += 1
if ch == "*" && index < chars.count &&
chars[index] == "/" {
output.append("/")
index += 1
state = .normal
}
}
}
return output
}
}
+5842
View File
File diff suppressed because it is too large Load Diff
+448
View File
@@ -0,0 +1,448 @@
import Foundation
import CommonCrypto
import CoreFoundation
import Security
// Native MySQL wire protocol client. No external mysql executable is used.
// Initial scope: TCP, mysql_native_password and caching_sha2_password fast authentication.
struct MySQLWireError: LocalizedError {
let message: String
var errorDescription: String? { message }
}
private struct ByteCursor {
let bytes: [UInt8]
var offset = 0
mutating func take(_ n: Int) throws -> [UInt8] {
guard n >= 0, offset + n <= bytes.count else { throw MySQLWireError(message: "Malformed MySQL packet") }
defer { offset += n }
return Array(bytes[offset..<(offset+n)])
}
mutating func u8() throws -> UInt8 { try take(1)[0] }
mutating func u16() throws -> Int { let b = try take(2); return Int(b[0]) | (Int(b[1]) << 8) }
mutating func u32() throws -> Int { let b = try take(4); return Int(b[0]) | (Int(b[1]) << 8) | (Int(b[2]) << 16) | (Int(b[3]) << 24) }
mutating func nulString() throws -> String {
let start = offset
while offset < bytes.count && bytes[offset] != 0 { offset += 1 }
guard offset < bytes.count else { throw MySQLWireError(message: "Unterminated server string") }
let s = String(decoding: bytes[start..<offset], as: UTF8.self)
offset += 1
return s
}
mutating func lenInt() throws -> Int? {
let first = try u8()
switch first {
case 0xfb: return nil
case 0xfc: return try u16()
case 0xfd:
let b = try take(3); return Int(b[0]) | Int(b[1]) << 8 | Int(b[2]) << 16
case 0xfe:
let b = try take(8)
var v: UInt64 = 0
for (i, byte) in b.enumerated() { v |= UInt64(byte) << (8 * i) }
guard v <= UInt64(Int.max) else { throw MySQLWireError(message: "Field too large") }
return Int(v)
default: return Int(first)
}
}
mutating func lenString() throws -> String? {
guard let n = try lenInt() else { return nil }
return String(decoding: try take(n), as: UTF8.self)
}
}
private func le32(_ v: Int) -> [UInt8] { (0..<4).map { UInt8(truncatingIfNeeded: v >> ($0 * 8)) } }
private func sha1(_ data: [UInt8]) -> [UInt8] {
var result = [UInt8](repeating: 0, count: Int(CC_SHA1_DIGEST_LENGTH))
data.withUnsafeBytes { ptr in _ = CC_SHA1(ptr.baseAddress, CC_LONG(data.count), &result) }
return result
}
private func sha256(_ data: [UInt8]) -> [UInt8] {
var result = [UInt8](repeating: 0, count: Int(CC_SHA256_DIGEST_LENGTH))
data.withUnsafeBytes { ptr in _ = CC_SHA256(ptr.baseAddress, CC_LONG(data.count), &result) }
return result
}
private func authResponse(password: String, salt: [UInt8], plugin: String) throws -> [UInt8] {
if password.isEmpty { return [] }
let pwd = Array(password.utf8)
if plugin == "mysql_native_password" {
let first = sha1(pwd), second = sha1(sha1(pwd))
let last = sha1(salt + second)
return zip(first, last).map { $0 ^ $1 }
}
if plugin == "caching_sha2_password" {
let first = sha256(pwd), second = sha256(sha256(pwd))
let last = sha256(second + salt)
return zip(first, last).map { $0 ^ $1 }
}
throw MySQLWireError(message: "Authentication plugin \(plugin) is not yet supported")
}
// Parse an RSA SubjectPublicKeyInfo PEM supplied by MySQL. Security.framework
// expects the inner PKCS#1 RSAPublicKey DER for SecKeyCreateWithData.
private func mysqlRSAPublicKey(_ packet: [UInt8]) throws -> SecKey {
guard packet.first == 0x01 else {
throw MySQLWireError(message: "Expected caching_sha2_password public-key response")
}
let pem = String(decoding: packet.dropFirst(), as: UTF8.self)
let base64 = pem.components(separatedBy: .newlines)
.filter { !$0.hasPrefix("-----") }.joined()
.trimmingCharacters(in: .whitespacesAndNewlines)
guard let spki = Data(base64Encoded: base64) else {
throw MySQLWireError(message: "Invalid MySQL RSA public key")
}
let der = [UInt8](spki)
func length(_ index: inout Int) throws -> Int {
guard index < der.count else { throw MySQLWireError(message: "Invalid RSA DER length") }
let first = Int(der[index]); index += 1
if first < 128 { return first }
let bytes = first & 0x7f
guard bytes > 0, bytes <= 4, index + bytes <= der.count else {
throw MySQLWireError(message: "Invalid RSA DER length")
}
var value = 0
for _ in 0..<bytes { value = (value << 8) | Int(der[index]); index += 1 }
return value
}
func element(_ index: inout Int, tag: UInt8) throws -> Range<Int> {
guard index < der.count, der[index] == tag else {
throw MySQLWireError(message: "Invalid RSA DER element")
}
index += 1
let count = try length(&index)
guard count <= der.count - index else { throw MySQLWireError(message: "Truncated RSA DER") }
let range = index..<(index + count)
index += count
return range
}
var i = 0
let outer = try element(&i, tag: 0x30)
i = outer.lowerBound
_ = try element(&i, tag: 0x30) // AlgorithmIdentifier
let bits = try element(&i, tag: 0x03)
guard bits.count > 1, der[bits.lowerBound] == 0 else {
throw MySQLWireError(message: "Invalid RSA public key bit string")
}
let pkcs1 = Data(der[(bits.lowerBound + 1)..<bits.upperBound])
var error: Unmanaged<CFError>?
guard let key = SecKeyCreateWithData(pkcs1 as CFData, [
kSecAttrKeyType as String: kSecAttrKeyTypeRSA,
kSecAttrKeyClass as String: kSecAttrKeyClassPublic
] as CFDictionary, &error) else {
throw MySQLWireError(message: "Cannot import MySQL RSA public key: \(error?.takeRetainedValue().localizedDescription ?? "unknown error")")
}
return key
}
final class MySQLWireConnection {
private let input: InputStream
private let output: OutputStream
private var sequence: UInt8 = 0
private let maxPacket = 16_777_215
init(host: String, port: Int) throws {
guard (1...65535).contains(port) else { throw MySQLWireError(message: "Invalid port") }
var read: Unmanaged<CFReadStream>?
var write: Unmanaged<CFWriteStream>?
CFStreamCreatePairWithSocketToHost(nil, host as CFString, UInt32(port), &read, &write)
guard let r = read?.takeRetainedValue(), let w = write?.takeRetainedValue() else {
throw MySQLWireError(message: "Cannot create connection")
}
input = unsafeBitCast(r, to: InputStream.self)
output = unsafeBitCast(w, to: OutputStream.self)
input.open(); output.open()
// A CFStream may still be .opening immediately after open(). The
// first blocking read below waits for the server greeting.
if input.streamStatus == .error || output.streamStatus == .error {
throw MySQLWireError(message: input.streamError?.localizedDescription ?? output.streamError?.localizedDescription ?? "Could not open MySQL connection")
}
}
deinit { input.close(); output.close() }
private func readExact(_ count: Int) throws -> [UInt8] {
var buffer = [UInt8](repeating: 0, count: count)
var done = 0
while done < count {
let n = buffer.withUnsafeMutableBufferPointer { ptr -> Int in
guard let base = ptr.baseAddress else { return -1 }
return input.read(base.advanced(by: done), maxLength: count - done)
}
guard n > 0 else { throw MySQLWireError(message: input.streamError?.localizedDescription ?? "MySQL connection closed") }
done += n
}
return buffer
}
private func readPacket() throws -> [UInt8] {
let header = try readExact(4)
let count = Int(header[0]) | Int(header[1]) << 8 | Int(header[2]) << 16
sequence = header[3] &+ 1
return try readExact(count)
}
private func send(_ payload: [UInt8]) throws {
guard payload.count < maxPacket else { throw MySQLWireError(message: "Query exceeds initial client packet limit") }
let count = payload.count
let packet = [UInt8(count & 255), UInt8((count >> 8) & 255), UInt8((count >> 16) & 255), sequence] + payload
sequence = sequence &+ 1
var written = 0
while written < packet.count {
let n = packet.withUnsafeBufferPointer { ptr -> Int in
guard let base = ptr.baseAddress else { return -1 }
return output.write(base.advanced(by: written), maxLength: packet.count - written)
}
guard n > 0 else { throw MySQLWireError(message: output.streamError?.localizedDescription ?? "MySQL write failed") }
written += n
}
}
private func checkError(_ p: [UInt8]) throws {
guard p.first == 0xff else { return }
let details = String(decoding: p.dropFirst(3), as: UTF8.self)
throw MySQLWireError(message: "MySQL error: \(details)")
}
func authenticate(user: String, password: String, database: String) throws {
let hello = try readPacket()
var c = ByteCursor(bytes: hello)
let version = try c.u8()
guard version == 10 else { throw MySQLWireError(message: "Unsupported MySQL handshake") }
_ = try c.nulString()
_ = try c.u32()
let salt1 = try c.take(8)
_ = try c.u8()
let lower = try c.u16()
_ = try c.u8() // character set
_ = try c.u16() // status
let upper = try c.u16()
let capabilities = lower | (upper << 16)
let authLen = Int(try c.u8())
_ = try c.take(10)
let salt2 = try c.take(max(13, authLen - 8))
let salt = salt1 + salt2.prefix(max(0, authLen - 9))
let plugin = (try? c.nulString()) ?? "mysql_native_password"
// This initial implementation deliberately refuses cleartext remote credentials.
guard hostIsLoopback else { throw MySQLWireError(message: "Direct remote connections require TLS, which is not yet implemented. Use a local SSH tunnel.") }
// CLIENT_LONG_PASSWORD, LONG_FLAG, PROTOCOL_41, TRANSACTIONS,
// SECURE_CONNECTION and PLUGIN_AUTH. Do not set DEPRECATE_EOF.
let clientFlags = 0x00000001 | 0x00000004 | 0x00000200 | 0x00002000 | 0x00008000 | 0x00080000
let response = try authResponse(password: password, salt: Array(salt), plugin: plugin)
var body = le32(clientFlags & capabilities) + le32(16_777_216) + [UInt8(45)] + [UInt8](repeating: 0, count: 23)
body += Array(user.utf8) + [0, UInt8(response.count)] + response
body += Array(plugin.utf8) + [0]
try send(body)
var reply = try readPacket()
try checkError(reply)
var currentPlugin = plugin
var currentSalt = Array(salt)
if reply.first == 0xfe {
var switchCursor = ByteCursor(bytes: Array(reply.dropFirst()))
let newPlugin = try switchCursor.nulString()
let rawSalt = Array(reply.dropFirst(1 + newPlugin.utf8.count + 1))
let newSalt = rawSalt.last == 0 ? Array(rawSalt.dropLast()) : rawSalt
currentPlugin = newPlugin
currentSalt = newSalt
try send(authResponse(password: password, salt: newSalt, plugin: newPlugin))
reply = try readPacket()
try checkError(reply)
}
if reply.first == 0x01 {
guard currentPlugin == "caching_sha2_password", reply.count >= 2 else {
throw MySQLWireError(message: "Unsupported authentication continuation")
}
switch reply[1] {
case 0x03: // Fast authentication accepted; wait for OK.
reply = try readPacket()
case 0x04: // Full authentication required.
guard hostIsLoopback else {
throw MySQLWireError(message: "Remote full authentication requires TLS (not implemented). Use a trusted local SSH tunnel.")
}
if password.isEmpty {
try send([0])
} else {
// Request the server's RSA public key (0x02). This is
// intended ONLY for a trusted local loopback connection.
try send([0x02])
let keyPacket = try readPacket()
try checkError(keyPacket)
let key = try mysqlRSAPublicKey(keyPacket)
guard !currentSalt.isEmpty else {
throw MySQLWireError(message: "Missing MySQL authentication nonce")
}
var scrambled = Array(password.utf8) + [0]
for i in scrambled.indices { scrambled[i] ^= currentSalt[i % currentSalt.count] }
var rsaError: Unmanaged<CFError>?
guard let encrypted = SecKeyCreateEncryptedData(
key, .rsaEncryptionOAEPSHA1, Data(scrambled) as CFData, &rsaError
) as Data? else {
throw MySQLWireError(message: "MySQL RSA encryption failed: \(rsaError?.takeRetainedValue().localizedDescription ?? "unknown error")")
}
try send(Array(encrypted))
}
reply = try readPacket()
default:
throw MySQLWireError(message: "Unsupported caching_sha2_password response: \(reply[1])")
}
}
try checkError(reply)
guard reply.first == 0x00 else { throw MySQLWireError(message: "Authentication did not complete") }
if !database.isEmpty { _ = try query("USE `\(database.replacingOccurrences(of: "`", with: "``"))`") }
}
private var hostIsLoopback = false
func markHost(_ host: String) { hostIsLoopback = ["localhost", "127.0.0.1", "::1"].contains(host.lowercased()) }
// Streams result rows without the query editor's 100,000-row limit.
// The callback is invoked on the session's serial background queue.
func streamFirstColumn(_ sql: String, onRow: (String) throws -> Void) throws -> Int64 {
sequence = 0
try send([0x03] + Array(sql.utf8))
let first = try readPacket()
try checkError(first)
guard first.first != 0x00 else { return 0 }
var cursor = ByteCursor(bytes: first)
guard let columnCount = try cursor.lenInt(), columnCount == 1 else {
throw MySQLWireError(message: "Export query must return exactly one column")
}
_ = try readPacket() // Column metadata
_ = try readPacket() // Column EOF
var count: Int64 = 0
while true {
let packet = try readPacket()
try checkError(packet)
if packet.first == 0xfe && packet.count < 9 { break }
var row = ByteCursor(bytes: packet)
guard let value = try row.lenString() else {
throw MySQLWireError(message: "Unexpected NULL export row")
}
try onRow(value)
count += 1
}
return count
}
func query(_ sql: String) throws -> QueryResult {
sequence = 0
try send([0x03] + Array(sql.utf8))
let first = try readPacket()
try checkError(first)
if first.first == 0x00 { return QueryResult(message: "Statement executed successfully") }
var c = ByteCursor(bytes: first)
guard let columnCount = try c.lenInt(), columnCount <= 4096 else { throw MySQLWireError(message: "Invalid column count") }
var names: [String] = []
for _ in 0..<columnCount {
var field = ByteCursor(bytes: try readPacket())
for _ in 0..<4 { _ = try field.lenString() }
names.append(try field.lenString() ?? "")
}
_ = try readPacket() // column EOF (initial protocol requires EOF)
var rows: [[String]] = []
while true {
let packet = try readPacket()
try checkError(packet)
if packet.first == 0xfe && packet.count < 9 { break }
var row = ByteCursor(bytes: packet)
var values: [String] = []
for _ in 0..<columnCount { values.append(try row.lenString() ?? "NULL") }
rows.append(values)
if rows.count > 100_000 { throw MySQLWireError(message: "Result exceeds 100,000 row safety limit") }
}
return QueryResult(columns: names, rows: rows, message: "\(rows.count) row(s)")
}
}
// A session owns one authenticated TCP connection. Access is serialized by the
// queue below because MySQLWireConnection is not safe for concurrent queries.
private final class PersistentMySQLSessions {
static let shared = PersistentMySQLSessions()
private struct Entry {
let profile: ConnectionProfile
let password: String
let connection: MySQLWireConnection
}
private let queue = DispatchQueue(label: "DBSyn.MySQLSessions")
private var connections: [UUID: Entry] = [:]
func execute(profile: ConnectionProfile, database: String,
password: String, sql: String) throws -> QueryResult {
try queue.sync {
// A changed connection profile or password requires a new session.
if let previous = connections[profile.id],
(previous.profile != profile || previous.password != password) {
connections.removeValue(forKey: profile.id)
}
if connections[profile.id] == nil {
let connection = try MySQLWireConnection(host: profile.host, port: profile.port)
connection.markHost(profile.host)
try connection.authenticate(user: profile.username,
password: password, database: database)
connections[profile.id] = Entry(profile: profile,
password: password,
connection: connection)
}
guard let entry = connections[profile.id] else {
throw MySQLWireError(message: "MySQL session was not created")
}
do {
// Crucially, no new authentication takes place here. USE,
// session variables and transactions survive between queries.
return try entry.connection.query(sql)
} catch {
// The wire protocol can be out of sync after a failed query.
// Discard it rather than silently issuing commands on a bad socket.
connections.removeValue(forKey: profile.id)
throw error
}
}
}
func stream(profile: ConnectionProfile, database: String,
password: String, sql: String,
onRow: (String) throws -> Void) throws -> Int64 {
try queue.sync {
guard let entry = connections[profile.id],
entry.profile == profile, entry.password == password else {
throw MySQLWireError(message: "Export session is no longer connected")
}
do {
return try entry.connection.streamFirstColumn(sql, onRow: onRow)
} catch {
connections.removeValue(forKey: profile.id)
throw error
}
}
}
func disconnect(profileID: UUID) {
queue.sync { _ = connections.removeValue(forKey: profileID) }
}
}
enum NativeMySQLRunner {
static func run(profile: ConnectionProfile, database: String,
password: String, sql: String) async throws -> QueryResult {
try await Task.detached(priority: .userInitiated) {
try PersistentMySQLSessions.shared.execute(
profile: profile, database: database, password: password, sql: sql
)
}.value
}
static func stream(profile: ConnectionProfile, database: String,
password: String, sql: String,
onRow: @escaping (String) throws -> Void) async throws -> Int64 {
try await Task.detached(priority: .userInitiated) {
try PersistentMySQLSessions.shared.stream(
profile: profile, database: database, password: password,
sql: sql, onRow: { row in
try Task<Never, Never>.checkCancellation()
try onRow(row)
}
)
}.value
}
static func disconnect(profileID: UUID) {
PersistentMySQLSessions.shared.disconnect(profileID: profileID)
}
}
Binary file not shown.
Binary file not shown.

After

Width:  |  Height:  |  Size: 460 KiB