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:
+467
@@ -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])
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -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 |
Reference in New Issue
Block a user