diff --git a/CHANGELOG.md b/CHANGELOG.md
index f6ed3f6ea..3bf858971 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -12,6 +12,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
- The table structure view has a Triggers tab for MySQL, MariaDB, PostgreSQL, SQLite, SQL Server, Oracle, libSQL, and Cloudflare D1. It lists each trigger with its timing and event (plus enabled state where the engine reports it), with a filter field and sortable columns. Selecting a trigger shows its full definition in a read-only syntax-highlighted viewer. (#1695)
- Traditional Chinese (繁體中文) language in Settings > General with full UI translation
- An Add button in the table status bar inserts a new row at the end of the grid and starts editing it.
+- DuckDB connections can now reach a remote server over the Quack protocol (experimental). Pick Remote (Quack) in the connection form, enter the host, port, and token, then run SQL against the remote. Listing remote tables in the sidebar is not available yet because the Quack beta cannot enumerate a remote catalog. Needs DuckDB 1.5.3 or later. (#1716)
### Changed
diff --git a/Plugins/DuckDBDriverPlugin/DuckDBPlugin.swift b/Plugins/DuckDBDriverPlugin/DuckDBPlugin.swift
index d00210de1..574e04c53 100644
--- a/Plugins/DuckDBDriverPlugin/DuckDBPlugin.swift
+++ b/Plugins/DuckDBDriverPlugin/DuckDBPlugin.swift
@@ -17,15 +17,70 @@ final class DuckDBPlugin: NSObject, TableProPlugin, DriverPlugin {
static let databaseTypeId = "DuckDB"
static let databaseDisplayName = "DuckDB"
static let iconName = "duckdb-icon"
- static let defaultPort = 0
+ static let defaultPort = 9_494
// MARK: - UI/Capability Metadata
static let isDownloadable = true
- static let pathFieldRole: PathFieldRole = .filePath
+ static let pathFieldRole: PathFieldRole = .database
static let requiresAuthentication = false
- static let connectionMode: ConnectionMode = .fileBased
- static let urlSchemes: [String] = ["duckdb"]
+ static let connectionMode: ConnectionMode = .apiOnly
+ static let urlSchemes: [String] = ["duckdb", "quack"]
+
+ static let additionalConnectionFields: [ConnectionField] = [
+ ConnectionField(
+ id: "duckdbMode",
+ label: String(localized: "Connection Type"),
+ defaultValue: "local",
+ fieldType: .dropdown(options: [
+ ConnectionField.DropdownOption(value: "local", label: String(localized: "Local File")),
+ ConnectionField.DropdownOption(value: "remote", label: String(localized: "Remote (Quack, experimental)"))
+ ]),
+ section: .authentication
+ ),
+ ConnectionField(
+ id: "duckdbFilePath",
+ label: String(localized: "Database File"),
+ placeholder: "/path/to/database.duckdb",
+ required: true,
+ section: .authentication,
+ visibleWhen: FieldVisibilityRule(fieldId: "duckdbMode", values: ["local"])
+ ),
+ ConnectionField(
+ id: "duckdbHost",
+ label: String(localized: "Host"),
+ placeholder: "localhost",
+ required: true,
+ section: .authentication,
+ visibleWhen: FieldVisibilityRule(fieldId: "duckdbMode", values: ["remote"])
+ ),
+ ConnectionField(
+ id: "duckdbPort",
+ label: String(localized: "Port"),
+ placeholder: "9494",
+ defaultValue: "9494",
+ fieldType: .number,
+ section: .authentication,
+ visibleWhen: FieldVisibilityRule(fieldId: "duckdbMode", values: ["remote"])
+ ),
+ ConnectionField(
+ id: "duckdbToken",
+ label: String(localized: "Token"),
+ fieldType: .secure,
+ section: .authentication,
+ hidesPassword: true,
+ visibleWhen: FieldVisibilityRule(fieldId: "duckdbMode", values: ["remote"])
+ ),
+ ConnectionField(
+ id: "duckdbAlias",
+ label: String(localized: "Database Alias"),
+ placeholder: "remotedb",
+ required: true,
+ defaultValue: "remotedb",
+ section: .authentication,
+ visibleWhen: FieldVisibilityRule(fieldId: "duckdbMode", values: ["remote"])
+ )
+ ]
static let fileExtensions: [String] = ["duckdb", "ddb"]
static let brandColorHex = "#FFD900"
static let supportsDatabaseSwitching = false
@@ -147,8 +202,27 @@ final class DuckDBPluginDriver: PluginDatabaseDriver, @unchecked Sendable {
// MARK: - Connection
+ private var isRemoteMode: Bool {
+ config.additionalFields["duckdbMode"] == "remote"
+ }
+
+ private var remoteAlias: String? {
+ guard isRemoteMode else { return nil }
+ let alias = (config.additionalFields["duckdbAlias"] ?? "").trimmingCharacters(in: .whitespaces)
+ return alias.isEmpty ? "remotedb" : alias
+ }
+
func connect() async throws {
- let path = expandPath(config.database)
+ if isRemoteMode {
+ try await connectRemote()
+ } else {
+ try await connectLocal()
+ }
+ }
+
+ private func connectLocal() async throws {
+ let rawPath = config.additionalFields["duckdbFilePath"].flatMap { $0.isEmpty ? nil : $0 } ?? config.database
+ let path = expandPath(rawPath)
if !FileManager.default.fileExists(atPath: path) {
let directory = (path as NSString).deletingLastPathComponent
@@ -161,15 +235,66 @@ final class DuckDBPluginDriver: PluginDatabaseDriver, @unchecked Sendable {
}
try await connectionActor.open(path: path)
+ await enableExtensionAutoloading()
+ await captureInterruptHandle()
+ }
+
+ private func connectRemote() async throws {
+ let host = (config.additionalFields["duckdbHost"] ?? "").trimmingCharacters(in: .whitespaces)
+ let aliasInput = (config.additionalFields["duckdbAlias"] ?? "").trimmingCharacters(in: .whitespaces)
+ let portInput = config.additionalFields["duckdbPort"] ?? ""
+ let token = config.additionalFields["duckdbToken"] ?? ""
- // Enable auto-install and auto-load of extensions (e.g. core_functions)
+ guard QuackConnectBuilder.isValidHost(host) else {
+ throw DuckDBPluginError.connectionFailed(
+ String(localized: "Host is required for a remote DuckDB connection")
+ )
+ }
+ guard let port = QuackConnectBuilder.normalizedPort(portInput) else {
+ throw DuckDBPluginError.connectionFailed(
+ String(localized: "Port must be a number between 1 and 65535")
+ )
+ }
+ let alias = aliasInput.isEmpty ? "remotedb" : aliasInput
+
+ try await connectionActor.open(path: ":memory:")
+ await enableExtensionAutoloading()
+ await loadQuackExtension()
+
+ if !token.isEmpty {
+ try await connectionActor.executeQuery(QuackConnectBuilder.secretSQL(token: token))
+ }
+
+ try await connectionActor.executeQuery(QuackConnectBuilder.attachSQL(host: host, port: port, alias: alias))
+ try await connectionActor.executeQuery(QuackConnectBuilder.useSQL(alias: alias))
+
+ stateLock.lock()
+ _currentSchema = "main"
+ stateLock.unlock()
+
+ await captureInterruptHandle()
+ }
+
+ private func enableExtensionAutoloading() async {
do {
try await connectionActor.executeQuery("SET autoinstall_known_extensions=1")
try await connectionActor.executeQuery("SET autoload_known_extensions=1")
} catch {
Self.logger.warning("Failed to enable DuckDB extension autoloading: \(error.localizedDescription)")
}
+ }
+ private func loadQuackExtension() async {
+ for statement in ["INSTALL quack", "LOAD quack"] {
+ do {
+ try await connectionActor.executeQuery(statement)
+ } catch {
+ Self.logger.warning("DuckDB '\(statement)' failed: \(error.localizedDescription)")
+ }
+ }
+ }
+
+ private func captureInterruptHandle() async {
if let conn = await connectionActor.connectionHandleForInterrupt {
setInterruptHandle(conn)
}
@@ -592,6 +717,10 @@ final class DuckDBPluginDriver: PluginDatabaseDriver, @unchecked Sendable {
func fetchSchemas() async throws -> [String] {
let query = "SELECT schema_name FROM information_schema.schemata ORDER BY schema_name"
+ if let remoteAlias {
+ let schemas = (try? await execute(query: query))?.rows.compactMap { $0[safe: 0]?.asText } ?? []
+ return schemas.isEmpty ? ["main"] : schemas
+ }
let result = try await execute(query: query)
return result.rows.compactMap { $0[safe: 0]?.asText }
}
@@ -607,6 +736,9 @@ final class DuckDBPluginDriver: PluginDatabaseDriver, @unchecked Sendable {
// MARK: - Database Operations
func fetchDatabases() async throws -> [String] {
+ if let remoteAlias {
+ return [remoteAlias]
+ }
let query = "SELECT database_name FROM duckdb_databases() ORDER BY database_name"
let result = try await execute(query: query)
return result.rows.compactMap { row in
@@ -876,4 +1008,3 @@ final class DuckDBPluginDriver: PluginDatabaseDriver, @unchecked Sendable {
}
}
}
-
diff --git a/Plugins/DuckDBDriverPlugin/QuackConnectBuilder.swift b/Plugins/DuckDBDriverPlugin/QuackConnectBuilder.swift
new file mode 100644
index 000000000..5837a877f
--- /dev/null
+++ b/Plugins/DuckDBDriverPlugin/QuackConnectBuilder.swift
@@ -0,0 +1,47 @@
+//
+// QuackConnectBuilder.swift
+// DuckDBDriverPlugin
+//
+
+import Foundation
+
+enum QuackConnectBuilder {
+ static let defaultPort = 9_494
+
+ static func isValidHost(_ host: String) -> Bool {
+ !host.trimmingCharacters(in: .whitespaces).isEmpty
+ }
+
+ static func normalizedPort(_ raw: String) -> Int? {
+ let trimmed = raw.trimmingCharacters(in: .whitespaces)
+ if trimmed.isEmpty { return defaultPort }
+ guard let port = Int(trimmed), (1...65_535).contains(port) else { return nil }
+ return port
+ }
+
+ static func secretSQL(token: String) -> String {
+ "CREATE OR REPLACE SECRET (TYPE quack, TOKEN '\(escapeLiteral(token))')"
+ }
+
+ static func attachSQL(host: String, port: Int, alias: String) -> String {
+ "ATTACH '\(quackTarget(host: host, port: port))' AS \(quoteIdentifier(alias))"
+ }
+
+ static func useSQL(alias: String) -> String {
+ "USE \(quoteIdentifier(alias))"
+ }
+
+ private static func quackTarget(host: String, port: Int) -> String {
+ "quack:\(escapeLiteral(host)):\(port)"
+ }
+
+ private static func escapeLiteral(_ value: String) -> String {
+ value
+ .replacingOccurrences(of: "\0", with: "")
+ .replacingOccurrences(of: "'", with: "''")
+ }
+
+ private static func quoteIdentifier(_ name: String) -> String {
+ "\"\(name.replacingOccurrences(of: "\"", with: "\"\""))\""
+ }
+}
diff --git a/TablePro.xcodeproj/project.pbxproj b/TablePro.xcodeproj/project.pbxproj
index 392683a33..4d5872c3d 100644
--- a/TablePro.xcodeproj/project.pbxproj
+++ b/TablePro.xcodeproj/project.pbxproj
@@ -65,6 +65,7 @@
5ACE00012F4F000000000005 /* CodeEditLanguages in Frameworks */ = {isa = PBXBuildFile; productRef = 5ACE00012F4F000000000003 /* CodeEditLanguages */; };
5ACE00012F4F000000000006 /* CodeEditTextView in Frameworks */ = {isa = PBXBuildFile; productRef = 5ACE00012F4F000000000007 /* CodeEditTextView */; };
5ACE00012F4F00000000000A /* Sparkle in Frameworks */ = {isa = PBXBuildFile; productRef = 5ACE00012F4F000000000009 /* Sparkle */; };
+ 5ACE252A2FE4508500357377 /* DuckDBDriver.tableplugin in Copy Plug-Ins (12 items) */ = {isa = PBXBuildFile; fileRef = 5A869000100000000 /* DuckDBDriver.tableplugin */; settings = {ATTRIBUTES = (CodeSignOnCopy, RemoveHeadersOnCopy, ); }; };
5AD1D8C12FB5000000000001 /* TableProMSSQLCore in Frameworks */ = {isa = PBXBuildFile; productRef = 5AD1D8C12FB5000000000002 /* TableProMSSQLCore */; };
5ADDB00100000000000000A1 /* DynamoDBConnection.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5ADDB00200000000000000A1 /* DynamoDBConnection.swift */; };
5ADDB00100000000000000A2 /* DynamoDBItemFlattener.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5ADDB00200000000000000A2 /* DynamoDBItemFlattener.swift */; };
@@ -287,6 +288,7 @@
dstPath = "";
dstSubfolderSpec = 13;
files = (
+ 5ACE252A2FE4508500357377 /* DuckDBDriver.tableplugin in Copy Plug-Ins (12 items) */,
5A865000D00000000 /* MySQLDriver.tableplugin in Copy Plug-Ins (12 items) */,
5A868000D00000000 /* PostgreSQLDriver.tableplugin in Copy Plug-Ins (12 items) */,
5A862000D00000000 /* SQLiteDriver.tableplugin in Copy Plug-Ins (12 items) */,
diff --git a/TablePro/Core/Plugins/PluginMetadataRegistry+DuckDBConnectionFields.swift b/TablePro/Core/Plugins/PluginMetadataRegistry+DuckDBConnectionFields.swift
new file mode 100644
index 000000000..723b50aa3
--- /dev/null
+++ b/TablePro/Core/Plugins/PluginMetadataRegistry+DuckDBConnectionFields.swift
@@ -0,0 +1,66 @@
+//
+// PluginMetadataRegistry+DuckDBConnectionFields.swift
+// TablePro
+//
+
+import Foundation
+import TableProPluginKit
+
+extension PluginMetadataRegistry {
+ static var duckdbConnectionFields: [ConnectionField] {
+ [
+ ConnectionField(
+ id: "duckdbMode",
+ label: String(localized: "Connection Type"),
+ defaultValue: "local",
+ fieldType: .dropdown(options: [
+ ConnectionField.DropdownOption(value: "local", label: String(localized: "Local File")),
+ ConnectionField.DropdownOption(value: "remote", label: String(localized: "Remote (Quack, experimental)"))
+ ]),
+ section: .authentication
+ ),
+ ConnectionField(
+ id: "duckdbFilePath",
+ label: String(localized: "Database File"),
+ placeholder: "/path/to/database.duckdb",
+ required: true,
+ section: .authentication,
+ visibleWhen: FieldVisibilityRule(fieldId: "duckdbMode", values: ["local"])
+ ),
+ ConnectionField(
+ id: "duckdbHost",
+ label: String(localized: "Host"),
+ placeholder: "localhost",
+ required: true,
+ section: .authentication,
+ visibleWhen: FieldVisibilityRule(fieldId: "duckdbMode", values: ["remote"])
+ ),
+ ConnectionField(
+ id: "duckdbPort",
+ label: String(localized: "Port"),
+ placeholder: "9494",
+ defaultValue: "9494",
+ fieldType: .number,
+ section: .authentication,
+ visibleWhen: FieldVisibilityRule(fieldId: "duckdbMode", values: ["remote"])
+ ),
+ ConnectionField(
+ id: "duckdbToken",
+ label: String(localized: "Token"),
+ fieldType: .secure,
+ section: .authentication,
+ hidesPassword: true,
+ visibleWhen: FieldVisibilityRule(fieldId: "duckdbMode", values: ["remote"])
+ ),
+ ConnectionField(
+ id: "duckdbAlias",
+ label: String(localized: "Database Alias"),
+ placeholder: "remotedb",
+ required: true,
+ defaultValue: "remotedb",
+ section: .authentication,
+ visibleWhen: FieldVisibilityRule(fieldId: "duckdbMode", values: ["remote"])
+ )
+ ]
+ }
+}
diff --git a/TablePro/Core/Plugins/PluginMetadataRegistry+RegistryDefaults.swift b/TablePro/Core/Plugins/PluginMetadataRegistry+RegistryDefaults.swift
index b73937a97..3023af336 100644
--- a/TablePro/Core/Plugins/PluginMetadataRegistry+RegistryDefaults.swift
+++ b/TablePro/Core/Plugins/PluginMetadataRegistry+RegistryDefaults.swift
@@ -285,6 +285,8 @@ extension PluginMetadataRegistry {
"Enum": ["ENUM"]
]
+ let duckdbConnectionFields = Self.duckdbConnectionFields
+
let cassandraDialect = SQLDialectDescriptor(
identifierQuote: "\"",
keywords: [
@@ -811,18 +813,18 @@ extension PluginMetadataRegistry {
)
)),
("DuckDB", PluginMetadataSnapshot(
- displayName: "DuckDB", iconName: "duckdb-icon", defaultPort: 0,
+ displayName: "DuckDB", iconName: "duckdb-icon", defaultPort: 9_494,
requiresAuthentication: false, supportsForeignKeys: true, supportsSchemaEditing: true,
isDownloadable: true, primaryUrlScheme: "duckdb", parameterStyle: .dollar,
navigationModel: .standard,
explainVariants: [
ExplainVariant(id: "explain", label: "EXPLAIN", sqlPrefix: "EXPLAIN"),
],
- pathFieldRole: .filePath,
- supportsHealthMonitor: false, urlSchemes: ["duckdb"], postConnectActions: [],
+ pathFieldRole: .database,
+ supportsHealthMonitor: false, urlSchemes: ["duckdb", "quack"], postConnectActions: [],
brandColorHex: "#FFD900",
queryLanguageName: "SQL", editorLanguage: .sql,
- connectionMode: .fileBased, supportsDatabaseSwitching: false,
+ connectionMode: .apiOnly, supportsDatabaseSwitching: false,
supportsColumnReorder: false,
capabilities: PluginMetadataSnapshot.CapabilityFlags(
supportsSchemaSwitching: false,
@@ -857,8 +859,9 @@ extension PluginMetadataRegistry {
columnTypesByCategory: duckdbColumnTypes
),
connection: PluginMetadataSnapshot.ConnectionConfig(
+ additionalConnectionFields: duckdbConnectionFields,
category: .analytical,
- tagline: String(localized: "Embedded analytical SQL")
+ tagline: String(localized: "Embedded and remote analytical SQL")
)
)),
("Cassandra", PluginMetadataSnapshot(
diff --git a/TablePro/Core/Utilities/Connection/ConnectionURLFormatter.swift b/TablePro/Core/Utilities/Connection/ConnectionURLFormatter.swift
index 7c4d1a574..be9501b29 100644
--- a/TablePro/Core/Utilities/Connection/ConnectionURLFormatter.swift
+++ b/TablePro/Core/Utilities/Connection/ConnectionURLFormatter.swift
@@ -21,7 +21,7 @@ struct ConnectionURLFormatter {
}
if connection.type == .duckdb {
- return formatDuckDB(connection.database)
+ return formatDuckDB(connection)
}
let ssh = connection.resolvedSSHConfig
@@ -46,13 +46,32 @@ struct ConnectionURLFormatter {
return "sqlite://\(database)"
}
- private static func formatDuckDB(_ database: String) -> String {
+ private static func formatDuckDB(_ connection: DatabaseConnection) -> String {
+ if connection.additionalFields["duckdbMode"] == "remote" {
+ return formatDuckDBRemote(connection)
+ }
+ let database = connection.additionalFields["duckdbFilePath"].flatMap { $0.isEmpty ? nil : $0 }
+ ?? connection.database
if database.hasPrefix("/") {
return "duckdb:///\(database.dropFirst())"
}
return "duckdb://\(database)"
}
+ private static func formatDuckDBRemote(_ connection: DatabaseConnection) -> String {
+ let host = connection.additionalFields["duckdbHost"] ?? ""
+ var url = "quack://\(host)"
+ if let portString = connection.additionalFields["duckdbPort"],
+ let port = Int(portString.trimmingCharacters(in: .whitespaces)) {
+ url += ":\(port)"
+ }
+ let alias = connection.additionalFields["duckdbAlias"] ?? ""
+ if !alias.isEmpty {
+ url += "/\(alias)"
+ }
+ return url
+ }
+
private static func formatSSH(
_ connection: DatabaseConnection,
sshConfig ssh: SSHConfiguration,
diff --git a/TablePro/Core/Utilities/Connection/ConnectionURLParser.swift b/TablePro/Core/Utilities/Connection/ConnectionURLParser.swift
index cd849a88f..0772d38ca 100644
--- a/TablePro/Core/Utilities/Connection/ConnectionURLParser.swift
+++ b/TablePro/Core/Utilities/Connection/ConnectionURLParser.swift
@@ -104,7 +104,7 @@ struct ConnectionURLParser {
let isSrv = scheme == "mongodb+srv"
- let isFileBased = dbType == .sqlite || dbType == .duckdb
+ let isFileBased = dbType == .sqlite || (dbType == .duckdb && scheme == "duckdb")
|| PluginMetadataRegistry.shared.snapshot(forTypeId: dbType.pluginTypeId)?.connectionMode == .fileBased
if isFileBased {
let path = String(trimmed[schemeEnd.upperBound...])
@@ -276,6 +276,8 @@ struct ConnectionURLParser {
return .cassandra
case "scylladb", "scylla":
return .scylladb
+ case "quack":
+ return .duckdb
default:
return PluginMetadataRegistry.shared.databaseType(forUrlScheme: scheme)
}
diff --git a/TablePro/Resources/Localizable.xcstrings b/TablePro/Resources/Localizable.xcstrings
index 74bc67758..0df956b6d 100644
--- a/TablePro/Resources/Localizable.xcstrings
+++ b/TablePro/Resources/Localizable.xcstrings
@@ -18703,6 +18703,9 @@
}
}
}
+ },
+ "Connection Type" : {
+
},
"Connection URL" : {
"localizations" : {
@@ -22873,6 +22876,9 @@
}
}
}
+ },
+ "Database Alias" : {
+
},
"Database Driver" : {
"localizations" : {
@@ -27340,6 +27346,9 @@
}
}
}
+ },
+ "DuckLake Data Path" : {
+
},
"Dump operations are only supported for PostgreSQL and Redshift connections." : {
"localizations" : {
@@ -28617,6 +28626,9 @@
}
}
}
+ },
+ "Embedded and remote analytical SQL" : {
+
},
"Embedded zero-config SQL database" : {
"localizations" : {
@@ -28988,7 +29000,6 @@
}
},
"Enabled" : {
- "extractionState" : "stale",
"localizations" : {
"tr" : {
"stringUnit" : {
@@ -31448,6 +31459,7 @@
}
},
"Export Data (⌘⇧E)" : {
+ "extractionState" : "stale",
"localizations" : {
"tr" : {
"stringUnit" : {
@@ -38243,7 +38255,6 @@
}
},
"Import Data" : {
- "extractionState" : "stale",
"localizations" : {
"tr" : {
"stringUnit" : {
@@ -38272,6 +38283,7 @@
}
},
"Import Data (⌘⇧I)" : {
+ "extractionState" : "stale",
"localizations" : {
"tr" : {
"stringUnit" : {
@@ -48242,7 +48254,6 @@
}
},
"New Query Tab" : {
- "extractionState" : "stale",
"localizations" : {
"tr" : {
"stringUnit" : {
@@ -48271,6 +48282,7 @@
}
},
"New Query Tab (⌘T)" : {
+ "extractionState" : "stale",
"localizations" : {
"tr" : {
"stringUnit" : {
@@ -52479,9 +52491,6 @@
}
}
}
- },
- "Open %@ (⌘K)" : {
-
},
"Open %@ Editor" : {
"localizations" : {
@@ -57247,6 +57256,7 @@
}
},
"Preview %@ (⌘⇧P)" : {
+ "extractionState" : "stale",
"localizations" : {
"tr" : {
"stringUnit" : {
@@ -59491,6 +59501,7 @@
}
},
"Quick Switcher (⇧⌘O)" : {
+ "extractionState" : "stale",
"localizations" : {
"tr" : {
"stringUnit" : {
@@ -61021,6 +61032,7 @@
}
},
"Refresh (⌘R)" : {
+ "extractionState" : "stale",
"localizations" : {
"tr" : {
"stringUnit" : {
@@ -61391,6 +61403,9 @@
}
}
}
+ },
+ "Remote (Quack)" : {
+
},
"Remote (Turso)" : {
@@ -65163,6 +65178,7 @@
}
},
"Save Changes (⌘S)" : {
+ "extractionState" : "stale",
"localizations" : {
"tr" : {
"stringUnit" : {
@@ -73515,6 +73531,7 @@
}
},
"Switch Connection (⌘⌥C)" : {
+ "extractionState" : "stale",
"localizations" : {
"tr" : {
"stringUnit" : {
@@ -79254,6 +79271,9 @@
}
}
}
+ },
+ "Toggle Query History" : {
+
},
"Toggle Query History (⌘⇧H)" : {
"extractionState" : "stale",
@@ -79285,6 +79305,7 @@
}
},
"Toggle Query History (⌘Y)" : {
+ "extractionState" : "stale",
"localizations" : {
"tr" : {
"stringUnit" : {
@@ -79341,6 +79362,7 @@
}
},
"Toggle Results (⌘⌥R)" : {
+ "extractionState" : "stale",
"localizations" : {
"tr" : {
"stringUnit" : {
@@ -82161,6 +82183,9 @@
}
}
}
+ },
+ "Use DuckLake" : {
+
},
"Use environment variables in connection fields." : {
"localizations" : {
diff --git a/TablePro/Views/ConnectionForm/ConnectionFormCoordinator.swift b/TablePro/Views/ConnectionForm/ConnectionFormCoordinator.swift
index 30e15fbbb..416d39b30 100644
--- a/TablePro/Views/ConnectionForm/ConnectionFormCoordinator.swift
+++ b/TablePro/Views/ConnectionForm/ConnectionFormCoordinator.swift
@@ -803,6 +803,21 @@ final class ConnectionFormCoordinator {
if parsed.type.pluginTypeId == "Cloudflare D1", !parsed.host.isEmpty {
writeFieldByRegistry("cfAccountId", value: parsed.host)
}
+ if parsed.type.pluginTypeId == "DuckDB" {
+ if parsed.host.isEmpty {
+ writeFieldByRegistry("duckdbMode", value: "local")
+ writeFieldByRegistry("duckdbFilePath", value: parsed.database)
+ } else {
+ writeFieldByRegistry("duckdbMode", value: "remote")
+ writeFieldByRegistry("duckdbHost", value: parsed.host)
+ if let port = parsed.port {
+ writeFieldByRegistry("duckdbPort", value: String(port))
+ }
+ if !parsed.database.isEmpty {
+ writeFieldByRegistry("duckdbAlias", value: parsed.database)
+ }
+ }
+ }
if let connectionName = parsed.connectionName, !connectionName.isEmpty {
network.name = connectionName
} else if network.name.isEmpty {
diff --git a/TablePro/Views/ConnectionForm/ViewModels/AuthPaneViewModel.swift b/TablePro/Views/ConnectionForm/ViewModels/AuthPaneViewModel.swift
index c90d41790..3c600391d 100644
--- a/TablePro/Views/ConnectionForm/ViewModels/AuthPaneViewModel.swift
+++ b/TablePro/Views/ConnectionForm/ViewModels/AuthPaneViewModel.swift
@@ -105,6 +105,12 @@ final class AuthPaneViewModel {
values[field.id] = secureValue
}
}
+ if connection.type.pluginTypeId == "DuckDB",
+ (values["duckdbFilePath"] ?? "").isEmpty,
+ !connection.database.isEmpty {
+ values["duckdbFilePath"] = connection.database
+ }
+
additionalFieldValues = values
if let savedPassword = storage.loadPassword(for: connection.id) {
diff --git a/TableProTests/Core/Utilities/ConnectionURLFormatterTests.swift b/TableProTests/Core/Utilities/ConnectionURLFormatterTests.swift
index 3f31a1207..330470e6b 100644
--- a/TableProTests/Core/Utilities/ConnectionURLFormatterTests.swift
+++ b/TableProTests/Core/Utilities/ConnectionURLFormatterTests.swift
@@ -457,4 +457,48 @@ struct ConnectionURLFormatterTests {
let url = ConnectionURLFormatter.format(conn, password: "pass", sshPassword: nil)
#expect(url.contains("host1:27017,host2:27018,host3:27019"))
}
+
+ // MARK: - DuckDB
+
+ @Test("DuckDB local mode formats a file URL from the file path field")
+ func testDuckDBLocalURL() {
+ let conn = DatabaseConnection(
+ name: "", database: "", type: .duckdb,
+ additionalFields: ["duckdbMode": "local", "duckdbFilePath": "/Users/me/analytics.duckdb"]
+ )
+ let url = ConnectionURLFormatter.format(conn, password: nil, sshPassword: nil)
+ #expect(url == "duckdb:///Users/me/analytics.duckdb")
+ }
+
+ @Test("DuckDB local mode falls back to the database path for legacy connections")
+ func testDuckDBLocalLegacyURL() {
+ let conn = DatabaseConnection(
+ name: "", database: "/Users/me/legacy.duckdb", type: .duckdb
+ )
+ let url = ConnectionURLFormatter.format(conn, password: nil, sshPassword: nil)
+ #expect(url == "duckdb:///Users/me/legacy.duckdb")
+ }
+
+ @Test("DuckDB remote mode formats a quack URL that round-trips")
+ func testDuckDBRemoteURL() {
+ let conn = DatabaseConnection(
+ name: "", database: "", type: .duckdb,
+ additionalFields: [
+ "duckdbMode": "remote",
+ "duckdbHost": "myhost",
+ "duckdbPort": "9495",
+ "duckdbAlias": "remotedb"
+ ]
+ )
+ let url = ConnectionURLFormatter.format(conn, password: nil, sshPassword: nil)
+ #expect(url == "quack://myhost:9495/remotedb")
+
+ guard case .success(let parsed) = ConnectionURLParser.parse(url) else {
+ Issue.record("Expected the formatted URL to parse"); return
+ }
+ #expect(parsed.type == .duckdb)
+ #expect(parsed.host == "myhost")
+ #expect(parsed.port == 9_495)
+ #expect(parsed.database == "remotedb")
+ }
}
diff --git a/TableProTests/Core/Utilities/ConnectionURLParserTests.swift b/TableProTests/Core/Utilities/ConnectionURLParserTests.swift
index 64f16b384..bf0015f08 100644
--- a/TableProTests/Core/Utilities/ConnectionURLParserTests.swift
+++ b/TableProTests/Core/Utilities/ConnectionURLParserTests.swift
@@ -943,7 +943,7 @@ struct ConnectionURLParserTests {
#expect(parsed.sshPort == 2222)
}
- // MARK: - DuckDB (file-based)
+ // MARK: - DuckDB
@Test("DuckDB absolute file path")
func testDuckDBAbsolutePath() {
@@ -966,6 +966,28 @@ struct ConnectionURLParserTests {
#expect(parsed.database == "data.duckdb")
}
+ @Test("Quack scheme parses as a remote DuckDB connection")
+ func testQuackRemoteURL() {
+ let result = ConnectionURLParser.parse("quack://myhost:9495/remotedb")
+ guard case .success(let parsed) = result else {
+ Issue.record("Expected success"); return
+ }
+ #expect(parsed.type == .duckdb)
+ #expect(parsed.host == "myhost")
+ #expect(parsed.port == 9_495)
+ #expect(parsed.database == "remotedb")
+ }
+
+ @Test("Quack default port is normalized away")
+ func testQuackDefaultPort() {
+ let result = ConnectionURLParser.parse("quack://myhost:9494/remotedb")
+ guard case .success(let parsed) = result else {
+ Issue.record("Expected success"); return
+ }
+ #expect(parsed.host == "myhost")
+ #expect(parsed.port == nil)
+ }
+
// MARK: - etcds TLS
@Test("etcds scheme enables SSL")
diff --git a/TableProTests/PluginTestSources/QuackConnectBuilder.swift b/TableProTests/PluginTestSources/QuackConnectBuilder.swift
new file mode 120000
index 000000000..6c7bddfab
--- /dev/null
+++ b/TableProTests/PluginTestSources/QuackConnectBuilder.swift
@@ -0,0 +1 @@
+../../Plugins/DuckDBDriverPlugin/QuackConnectBuilder.swift
\ No newline at end of file
diff --git a/TableProTests/Plugins/DuckDBConnectionFieldsTests.swift b/TableProTests/Plugins/DuckDBConnectionFieldsTests.swift
new file mode 100644
index 000000000..34fa6fec3
--- /dev/null
+++ b/TableProTests/Plugins/DuckDBConnectionFieldsTests.swift
@@ -0,0 +1,103 @@
+//
+// DuckDBConnectionFieldsTests.swift
+// TableProTests
+//
+
+import Foundation
+@testable import TablePro
+import TableProPluginKit
+import Testing
+
+@Suite("DuckDB connection fields")
+struct DuckDBConnectionFieldsTests {
+ private func duckdbFields() throws -> [ConnectionField] {
+ let defaults = PluginMetadataRegistry.shared.registryPluginDefaults()
+ let entry = try #require(defaults.first { $0.typeId == "DuckDB" })
+ return entry.snapshot.connection.additionalConnectionFields
+ }
+
+ @Test("Registry declares mode, local, and remote fields in order")
+ func registryDeclaresAllFields() throws {
+ let fields = try duckdbFields()
+ #expect(fields.map(\.id) == [
+ "duckdbMode",
+ "duckdbFilePath",
+ "duckdbHost",
+ "duckdbPort",
+ "duckdbToken",
+ "duckdbAlias"
+ ])
+ }
+
+ @Test("Mode dropdown defaults to local and offers a remote option")
+ func modeDropdownDefaultsToLocal() throws {
+ let fields = try duckdbFields()
+ let mode = try #require(fields.first { $0.id == "duckdbMode" })
+ #expect(mode.defaultValue == "local")
+ guard case .dropdown(let options) = mode.fieldType else {
+ Issue.record("Expected a dropdown field type")
+ return
+ }
+ #expect(options.map(\.value) == ["local", "remote"])
+ }
+
+ @Test("File path is required, visible only for local, and carries a Browse button")
+ func filePathVisibleOnlyForLocal() throws {
+ let fields = try duckdbFields()
+ let path = try #require(fields.first { $0.id == "duckdbFilePath" })
+ #expect(path.isRequired)
+ #expect(path.fieldType == .text)
+ #expect(path.visibleWhen == FieldVisibilityRule(fieldId: "duckdbMode", values: ["local"]))
+ #expect(path.id.hasSuffix("FilePath"))
+ }
+
+ @Test("Host, port, token, and alias are visible only for remote")
+ func remoteFieldsVisibleOnlyForRemote() throws {
+ let fields = try duckdbFields()
+ let remoteRule = FieldVisibilityRule(fieldId: "duckdbMode", values: ["remote"])
+ for id in ["duckdbHost", "duckdbPort", "duckdbToken", "duckdbAlias"] {
+ let field = try #require(fields.first { $0.id == id })
+ #expect(field.visibleWhen == remoteRule)
+ }
+ }
+
+ @Test("Token is a secure field that hides the main password row")
+ func tokenIsSecureAndHidesPassword() throws {
+ let fields = try duckdbFields()
+ let token = try #require(fields.first { $0.id == "duckdbToken" })
+ #expect(token.isSecure)
+ #expect(token.hidesPassword)
+ }
+
+ @Test("Main password row stays hidden in both local and remote modes")
+ func passwordAlwaysHidden() throws {
+ let fields = try duckdbFields()
+ #expect(fields.hidesPassword(forValues: [:]))
+ #expect(fields.hidesPassword(forValues: ["duckdbMode": "local"]))
+ #expect(fields.hidesPassword(forValues: ["duckdbMode": "remote"]))
+ }
+
+ @Test("Port defaults to 9494")
+ func portDefaultsTo9494() throws {
+ let fields = try duckdbFields()
+ let port = try #require(fields.first { $0.id == "duckdbPort" })
+ #expect(port.defaultValue == "9494")
+ }
+
+ @Test("Field visibility swaps between local and remote modes")
+ @MainActor
+ func visibilitySwapsByMode() throws {
+ let type = DatabaseType(rawValue: "DuckDB")
+ let fields = try duckdbFields()
+ let path = try #require(fields.first { $0.id == "duckdbFilePath" })
+ let host = try #require(fields.first { $0.id == "duckdbHost" })
+
+ let local = ["duckdbMode": "local"]
+ #expect(PluginFieldRendering.isFieldVisible(path, type: type, values: local))
+ #expect(!PluginFieldRendering.isFieldVisible(host, type: type, values: local))
+
+ let remote = ["duckdbMode": "remote"]
+ #expect(!PluginFieldRendering.isFieldVisible(path, type: type, values: remote))
+ #expect(PluginFieldRendering.isFieldVisible(host, type: type, values: remote))
+ }
+}
diff --git a/TableProTests/Plugins/DuckDBQuackConnectTests.swift b/TableProTests/Plugins/DuckDBQuackConnectTests.swift
new file mode 100644
index 000000000..80e37f350
--- /dev/null
+++ b/TableProTests/Plugins/DuckDBQuackConnectTests.swift
@@ -0,0 +1,71 @@
+//
+// DuckDBQuackConnectTests.swift
+// TableProTests
+//
+// Tests for QuackConnectBuilder (compiled via symlink from DuckDBDriverPlugin).
+// Remote Quack connections need a live server, so these cover the SQL the
+// driver sends rather than the network round trip.
+//
+
+import Foundation
+import Testing
+
+@Suite("DuckDB Quack connect builder")
+struct DuckDBQuackConnectTests {
+ @Test("Secret statement escapes single quotes in the token")
+ func secretEscapesQuotes() {
+ let sql = QuackConnectBuilder.secretSQL(token: "my'token")
+ #expect(sql == "CREATE OR REPLACE SECRET (TYPE quack, TOKEN 'my''token')")
+ }
+
+ @Test("Secret statement cannot be broken out of with injection payloads")
+ func secretResistsInjection() {
+ let sql = QuackConnectBuilder.secretSQL(token: "x'); DROP TABLE users; --")
+ #expect(sql == "CREATE OR REPLACE SECRET (TYPE quack, TOKEN 'x''); DROP TABLE users; --')")
+ }
+
+ @Test("Attach statement builds a quack target and quotes the alias")
+ func attachBuildsTarget() {
+ let sql = QuackConnectBuilder.attachSQL(host: "localhost", port: 9_494, alias: "remotedb")
+ #expect(sql == "ATTACH 'quack:localhost:9494' AS \"remotedb\"")
+ }
+
+ @Test("Attach quotes aliases that contain spaces and double quotes")
+ func attachQuotesAlias() {
+ let sql = QuackConnectBuilder.attachSQL(host: "h", port: 1, alias: "my \"db\"")
+ #expect(sql == "ATTACH 'quack:h:1' AS \"my \"\"db\"\"\"")
+ }
+
+ @Test("Attach escapes a single quote in the host")
+ func attachEscapesHost() {
+ let sql = QuackConnectBuilder.attachSQL(host: "h'x", port: 9_494, alias: "a")
+ #expect(sql == "ATTACH 'quack:h''x:9494' AS \"a\"")
+ }
+
+ @Test("USE statement quotes the alias")
+ func useStatementQuotesAlias() {
+ #expect(QuackConnectBuilder.useSQL(alias: "remotedb") == "USE \"remotedb\"")
+ }
+
+ @Test("Empty port falls back to the default")
+ func emptyPortUsesDefault() {
+ #expect(QuackConnectBuilder.normalizedPort("") == 9_494)
+ #expect(QuackConnectBuilder.normalizedPort(" ") == 9_494)
+ }
+
+ @Test("Valid ports are accepted, invalid ones rejected")
+ func portValidation() {
+ #expect(QuackConnectBuilder.normalizedPort("5432") == 5_432)
+ #expect(QuackConnectBuilder.normalizedPort("not-a-port") == nil)
+ #expect(QuackConnectBuilder.normalizedPort("0") == nil)
+ #expect(QuackConnectBuilder.normalizedPort("65536") == nil)
+ #expect(QuackConnectBuilder.normalizedPort("-1") == nil)
+ }
+
+ @Test("Host validation rejects empty and whitespace-only values")
+ func hostValidation() {
+ #expect(!QuackConnectBuilder.isValidHost(""))
+ #expect(!QuackConnectBuilder.isValidHost(" "))
+ #expect(QuackConnectBuilder.isValidHost("localhost"))
+ }
+}
diff --git a/docs/databases/duckdb.mdx b/docs/databases/duckdb.mdx
index 94f355346..0be84fb50 100644
--- a/docs/databases/duckdb.mdx
+++ b/docs/databases/duckdb.mdx
@@ -5,11 +5,11 @@ description: Connect to DuckDB databases with TablePro
# DuckDB
-DuckDB is an embedded analytical database, optimized for OLAP workloads. TablePro supports connecting to DuckDB database files for browsing tables, running queries, and managing schemas.
+DuckDB is an embedded analytical database, optimized for OLAP workloads. TablePro connects to a local DuckDB file or to a remote DuckDB server over the Quack protocol, for browsing tables, running queries, and managing schemas.
-The DuckDB driver plugin (1.0.19) bundles DuckDB 1.5.2, with DuckLake 1.0 support.
+The DuckDB driver plugin bundles DuckDB 1.5.3, with DuckLake 1.0 and Quack remote support.
-## Connecting to a DuckDB database
+## Connecting to a local DuckDB file
@@ -18,7 +18,7 @@ The DuckDB driver plugin (1.0.19) bundles DuckDB 1.5.2, with DuckLake 1.0 suppor
Choose **DuckDB** from the database type list.
-
+
Click **Browse** to select an existing `.duckdb` file, or enter the path to create a new database.
@@ -26,10 +26,34 @@ The DuckDB driver plugin (1.0.19) bundles DuckDB 1.5.2, with DuckLake 1.0 suppor
+## Connecting to a remote DuckDB server (Quack)
+
+Quack is DuckDB's client-server protocol. A DuckDB server exposes itself with `quack_serve`, and TablePro attaches to it as a remote database.
+
+
+
+ In the connection form, set **Connection Type** to **Remote (Quack, experimental)**.
+
+
+ Fill in the **Host** and **Port** (9494 by default), the **Token** the server was started with, and a **Database Alias** for the attached remote.
+
+
+ Click **Connect**. TablePro opens an in-memory DuckDB, registers the token as a secret, and attaches the remote server.
+
+
+
+
+Remote (Quack) is experimental and matches the Quack beta. You can connect and run SQL against the remote (for example `SELECT * FROM alias.main.your_table`), but the sidebar does not list remote tables: the Quack beta cannot enumerate a remote catalog (`SHOW TABLES` and `information_schema` queries are rejected with a "multiple streaming scans" error). DuckLake over Quack is not supported yet for the same reason. Browse and DuckLake support are expected once Quack stabilizes in DuckDB 2.0. On macOS the Quack extension downloads from the DuckDB extension registry on first use, so the first remote connection needs network access.
+
+
## Connection URL
```text
+# Local file
duckdb:///path/to/database.duckdb
+
+# Remote (Quack)
+quack://host:9494/alias
```
See [Connection URL Reference](/databases/connection-urls) for all parameters.
@@ -42,7 +66,7 @@ Double-click any `.duckdb` file in Finder to open it directly in TablePro.
The iOS app supports DuckDB too. In the connection form, pick DuckDB and either turn on **In-Memory Database** or open a `.duckdb`/`.ddb` file through the Files app. Opened files keep working across launches through a security-scoped bookmark, so edits write back to the original file.
-The iOS build links the `core_functions`, `json`, `parquet`, and `icu` extensions. Runtime extension autoloading is off, so extensions that download on demand (such as `httpfs`) are not available on iOS. Large in-memory databases are bounded by the app's memory budget.
+The iOS build statically links the `core_functions`, `json`, `parquet`, `icu`, `httpfs`, and `quack` extensions, so remote Quack connections work on iOS without a download. Runtime extension autoloading stays off, so other on-demand extensions are not available on iOS. Large in-memory databases are bounded by the app's memory budget.
## Querying files directly
diff --git a/scripts/build-duckdb-ios.sh b/scripts/build-duckdb-ios.sh
index 6d065727f..8577f3301 100755
--- a/scripts/build-duckdb-ios.sh
+++ b/scripts/build-duckdb-ios.sh
@@ -8,27 +8,40 @@ set -euo pipefail
# - ios-arm64_x86_64-simulator (Apple Silicon + Intel simulators)
#
# DuckDB ships no official iOS binary, so we compile it from source with the
-# leetal/ios-cmake toolchain. Extensions (json, parquet, icu) are linked
-# statically. Remote extension autoloading/autoinstall is disabled: iOS apps
-# may not download executable code (App Store Review Guideline 2.5.2), and the
-# sandbox blocks it anyway.
+# leetal/ios-cmake toolchain. Extensions are linked statically through
+# scripts/duckdb-ios-extensions.cmake (core_functions, json, parquet, icu,
+# autocomplete, httpfs, quack). Remote extension autoloading/autoinstall is
+# disabled: iOS apps may not download executable code (App Store Review
+# Guideline 2.5.2), and the sandbox blocks it anyway. Static linking is what
+# lets remote Quack connections work on iOS without a download.
#
-# Requirements: macOS, Xcode command line tools, cmake, git, libtool, lipo.
+# quack needs httpfs, and httpfs needs OpenSSL for TLS. We link the OpenSSL that
+# already ships in Libs/ios (OpenSSL-SSL/OpenSSL-Crypto.xcframework). Those slices
+# are ios-arm64 (device) and ios-arm64-simulator (arm64 simulator). There is no
+# x86_64 simulator OpenSSL slice, so the x86_64 simulator DuckDB slice cannot
+# link httpfs/quack. Build OpenSSL for x86_64-simulator first, or drop the
+# SIMULATOR64 slice if Intel-simulator support is not needed.
+#
+# Requirements: macOS, Xcode command line tools, cmake, git, libtool, lipo, and
+# Libs/ios populated (scripts/download-libs.sh).
#
# IMPORTANT: DUCKDB_VERSION must match the bundled macOS libduckdb.a so both
# platforms behave identically. Confirm with the version shown in the app
-# (duckdb_library_version) on macOS, then pin the same tag here.
+# (duckdb_library_version) on macOS, then pin the same tag here. When you bump
+# DuckDB, also update the httpfs/quack pins in duckdb-ios-extensions.cmake.
#
# Usage:
# scripts/build-duckdb-ios.sh [duckdb-version]
-# DUCKDB_VERSION=v1.3.2 scripts/build-duckdb-ios.sh
+# DUCKDB_VERSION=v1.5.3 scripts/build-duckdb-ios.sh
-DUCKDB_VERSION="${1:-${DUCKDB_VERSION:-v1.5.2}}"
-CORE_EXTENSIONS="${CORE_EXTENSIONS:-core_functions;json;parquet;icu}"
+DUCKDB_VERSION="${1:-${DUCKDB_VERSION:-v1.5.3}}"
DEPLOYMENT_TARGET="${DEPLOYMENT_TARGET:-15.0}"
REPO_ROOT="$(cd "$(dirname "$0")/.." && pwd)"
OUT_DIR="$REPO_ROOT/Libs/ios"
+EXTENSION_CONFIG="$REPO_ROOT/scripts/duckdb-ios-extensions.cmake"
+OPENSSL_SSL_XCFRAMEWORK="$OUT_DIR/OpenSSL-SSL.xcframework"
+OPENSSL_CRYPTO_XCFRAMEWORK="$OUT_DIR/OpenSSL-Crypto.xcframework"
WORK_DIR="$(mktemp -d /tmp/duckdb-ios.XXXXXX)"
TOOLCHAIN="$WORK_DIR/ios.toolchain.cmake"
TOOLCHAIN_URL="https://raw.githubusercontent.com/leetal/ios-cmake/master/ios.toolchain.cmake"
@@ -37,9 +50,13 @@ for tool in cmake git libtool lipo xcodebuild; do
command -v "$tool" >/dev/null 2>&1 || { echo "error: '$tool' is required" >&2; exit 1; }
done
-echo "Building DuckDB $DUCKDB_VERSION for iOS (extensions: $CORE_EXTENSIONS)"
+echo "Building DuckDB $DUCKDB_VERSION for iOS (extensions: $EXTENSION_CONFIG)"
echo "Work dir: $WORK_DIR"
+for xcframework in "$OPENSSL_SSL_XCFRAMEWORK" "$OPENSSL_CRYPTO_XCFRAMEWORK"; do
+ [ -d "$xcframework" ] || { echo "error: missing $xcframework; run scripts/download-libs.sh" >&2; exit 1; }
+done
+
echo "Fetching ios-cmake toolchain..."
curl -fSL -o "$TOOLCHAIN" "$TOOLCHAIN_URL"
@@ -54,9 +71,16 @@ git clone --depth 1 --branch "$DUCKDB_VERSION" https://github.com/duckdb/duckdb.
# detection fails. The value only labels the build (extension autoloading is
# off), so a descriptive string is enough.
build_platform() {
- local platform="$1" archs="$2" out_lib="$3" duckdb_platform="$4"
+ local platform="$1" archs="$2" out_lib="$3" duckdb_platform="$4" openssl_slice="$5"
local build_dir="$WORK_DIR/build-$platform"
+ local openssl_include="$OPENSSL_SSL_XCFRAMEWORK/$openssl_slice/Headers"
+ local openssl_ssl_lib="$OPENSSL_SSL_XCFRAMEWORK/$openssl_slice/libssl.a"
+ local openssl_crypto_lib="$OPENSSL_CRYPTO_XCFRAMEWORK/$openssl_slice/libcrypto.a"
+ for path in "$openssl_include" "$openssl_ssl_lib" "$openssl_crypto_lib"; do
+ [ -e "$path" ] || { echo "error: OpenSSL slice missing $path (need an OpenSSL build for $openssl_slice)" >&2; exit 1; }
+ done
+
# The linked-extension registration in DuckDB core (extension_helper.cpp) is
# gated on GENERATED_EXTENSION_HEADERS plus a DUCKDB_EXTENSION__LINKED
# define per extension. CMake enables those only inside the extension/ subdir
@@ -73,11 +97,16 @@ build_platform() {
linked_defines+=" -DDUCKDB_EXTENSION_JSON_LINKED=1"
linked_defines+=" -DDUCKDB_EXTENSION_PARQUET_LINKED=1"
linked_defines+=" -DDUCKDB_EXTENSION_ICU_LINKED=1"
+ linked_defines+=" -DDUCKDB_EXTENSION_AUTOCOMPLETE_LINKED=1"
+ linked_defines+=" -DDUCKDB_EXTENSION_HTTPFS_LINKED=1"
+ linked_defines+=" -DDUCKDB_EXTENSION_QUACK_LINKED=1"
linked_defines+=" -I$build_dir/codegen/include"
linked_defines+=" -I$ext_root/core_functions/include"
linked_defines+=" -I$ext_root/json/include"
linked_defines+=" -I$ext_root/parquet/include"
linked_defines+=" -I$ext_root/icu/include"
+ linked_defines+=" -I$ext_root/autocomplete/include"
+ linked_defines+=" -I$openssl_include"
echo "Building DuckDB for PLATFORM=$platform (archs: $archs)..."
cmake -S "$WORK_DIR/duckdb" -B "$build_dir" -G "Unix Makefiles" \
@@ -95,7 +124,11 @@ build_platform() {
-DBUILD_BENCHMARKS=0 \
-DENABLE_EXTENSION_AUTOLOADING=0 \
-DENABLE_EXTENSION_AUTOINSTALL=0 \
- -DCORE_EXTENSIONS="$CORE_EXTENSIONS"
+ -DOPENSSL_ROOT_DIR="$OPENSSL_SSL_XCFRAMEWORK/$openssl_slice" \
+ -DOPENSSL_INCLUDE_DIR="$openssl_include" \
+ -DOPENSSL_SSL_LIBRARY="$openssl_ssl_lib" \
+ -DOPENSSL_CRYPTO_LIBRARY="$openssl_crypto_lib" \
+ -DEXTENSION_CONFIGS="$EXTENSION_CONFIG"
cmake --build "$build_dir" --config Release -j "$(sysctl -n hw.ncpu)"
@@ -133,12 +166,20 @@ SIM_ARM64_LIB="$WORK_DIR/libduckdb-sim-arm64.a"
SIM_X86_LIB="$WORK_DIR/libduckdb-sim-x86_64.a"
SIM_LIB="$WORK_DIR/libduckdb-sim.a"
-build_platform "OS64" "arm64" "$DEVICE_LIB" "ios_arm64"
-build_platform "SIMULATORARM64" "arm64" "$SIM_ARM64_LIB" "iossimulator_arm64"
-build_platform "SIMULATOR64" "x86_64" "$SIM_X86_LIB" "iossimulator_amd64"
-
-echo "Combining simulator slices..."
-lipo -create "$SIM_ARM64_LIB" "$SIM_X86_LIB" -output "$SIM_LIB"
+build_platform "OS64" "arm64" "$DEVICE_LIB" "ios_arm64" "ios-arm64"
+build_platform "SIMULATORARM64" "arm64" "$SIM_ARM64_LIB" "iossimulator_arm64" "ios-arm64-simulator"
+
+# The x86_64 simulator slice needs an x86_64-simulator OpenSSL, which the bundled
+# OpenSSL xcframework does not ship. Build it opt-in once that OpenSSL slice
+# exists; otherwise the simulator slice is arm64 only (fine on Apple Silicon).
+if [ "${BUILD_X86_SIMULATOR:-0}" = "1" ]; then
+ build_platform "SIMULATOR64" "x86_64" "$SIM_X86_LIB" "iossimulator_amd64" "ios-x86_64-simulator"
+ echo "Combining simulator slices..."
+ lipo -create "$SIM_ARM64_LIB" "$SIM_X86_LIB" -output "$SIM_LIB"
+else
+ echo "Skipping x86_64 simulator slice (set BUILD_X86_SIMULATOR=1 with an x86_64 OpenSSL slice to include it)"
+ cp "$SIM_ARM64_LIB" "$SIM_LIB"
+fi
# The xcframework exposes the public C API header to consumers.
HEADERS_DIR="$WORK_DIR/include"
diff --git a/scripts/build-duckdb.sh b/scripts/build-duckdb.sh
index 128565e14..bfca72a7e 100755
--- a/scripts/build-duckdb.sh
+++ b/scripts/build-duckdb.sh
@@ -4,8 +4,11 @@ set -euo pipefail
# Build DuckDB static library for TablePro
# Usage: ./scripts/build-duckdb.sh [arm64|x86_64|both]
-DUCKDB_VERSION="v1.5.2"
-DUCKDB_SHA256="36388f54d4e73c7148895f9b075c063189d47df8687db237f765f74a7ff5d8f6"
+# Quack remote protocol ships as a core extension from DuckDB 1.5.3 onward.
+# After bumping the version, set DUCKDB_SHA256 to the checksum of the new
+# libduckdb-src.zip: shasum -a 256 /tmp/duckdb-build/libduckdb-src.zip
+DUCKDB_VERSION="v1.5.3"
+DUCKDB_SHA256="REPLACE_WITH_libduckdb-src.zip_SHA256_FOR_v1.5.3"
BUILD_DIR="/tmp/duckdb-build"
LIBS_DIR="$(cd "$(dirname "$0")/.." && pwd)/Libs"
ARCH="${1:-both}"
diff --git a/scripts/duckdb-ios-extensions.cmake b/scripts/duckdb-ios-extensions.cmake
new file mode 100644
index 000000000..cc0e4edf9
--- /dev/null
+++ b/scripts/duckdb-ios-extensions.cmake
@@ -0,0 +1,29 @@
+# Extensions statically linked into the iOS DuckDB build.
+#
+# iOS cannot autoload/autoinstall extensions (App Store Review Guideline 2.5.2
+# plus the sandbox), so every extension a feature needs must be built from
+# source and linked in here. DuckDB reads this file through EXTENSION_CONFIGS and
+# generates the static loader that registers each extension at startup.
+#
+# In-tree extensions ship inside the duckdb checkout. Out-of-tree extensions
+# (httpfs, quack) are fetched from their own repos. quack is what powers remote
+# DuckDB connections; it requires httpfs (TLS over OpenSSL) at runtime.
+#
+# Pin httpfs and quack to commits whose duckdb submodule matches DUCKDB_VERSION
+# in build-duckdb-ios.sh. quack tracks duckdb main, so when you bump DuckDB,
+# update QUACK_GIT_TAG to a commit built against the same tag.
+
+duckdb_extension_load(core_functions)
+duckdb_extension_load(json)
+duckdb_extension_load(parquet)
+duckdb_extension_load(icu)
+duckdb_extension_load(autocomplete)
+
+duckdb_extension_load(httpfs
+ GIT_URL https://github.com/duckdb/duckdb-httpfs
+ GIT_TAG 53c5b032f6c368cfcc1a1ac3819118e86d3286a6
+ APPLY_PATCHES)
+
+duckdb_extension_load(quack
+ GIT_URL https://github.com/duckdb/duckdb-quack
+ GIT_TAG main)