Support DBX connection deep links (#359)

* fix(mysql): avoid metadata collation conflicts

Split MySQL column and primary key metadata lookups so MariaDB and OceanBase do not join information_schema tables with incompatible collations.

* feat(grid): add invert column visibility action

* feat(grid): improve cell detail editing

* feat(grid): add keyboard cell navigation

* feat(grid): add column name copy in header tooltip

* feat(grid): improve transpose navigation

* feat(connection): support dbx connection deep links

* docs: document connection deep links

* fix(connection): harden deep link boolean handling
This commit is contained in:
yavon007
2026-05-20 16:10:14 +08:00
committed by GitHub
parent c41c2ae1af
commit 13424588b9
26 changed files with 635 additions and 25 deletions
Generated
+70 -2
View File
@@ -1713,6 +1713,7 @@ dependencies = [
"sqlx",
"tauri",
"tauri-build",
"tauri-plugin-deep-link",
"tauri-plugin-dialog",
"tauri-plugin-fs",
"tauri-plugin-log",
@@ -1985,6 +1986,15 @@ dependencies = [
"syn 2.0.117",
]
[[package]]
name = "dlv-list"
version = "0.5.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "442039f5147480ba31067cb00ada1adae6892028e40e45fc5de7b7df6dcc1b5f"
dependencies = [
"const-random",
]
[[package]]
name = "dom_query"
version = "0.27.0"
@@ -2955,6 +2965,12 @@ dependencies = [
"ahash 0.7.8",
]
[[package]]
name = "hashbrown"
version = "0.14.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e5274423e17b7c9fc20b6e7e208532f9b19825d82dfd615708b70edd83df41f1"
[[package]]
name = "hashbrown"
version = "0.15.5"
@@ -3251,7 +3267,7 @@ dependencies = [
"tokio",
"tower-service",
"tracing",
"windows-registry",
"windows-registry 0.6.1",
]
[[package]]
@@ -3504,7 +3520,7 @@ checksum = "4d40460c0ce33d6ce4b0630ad68ff63d6661961c48b6dba35e5a4d81cfb48222"
dependencies = [
"socket2",
"widestring",
"windows-registry",
"windows-registry 0.6.1",
"windows-result 0.4.1",
"windows-sys 0.61.2",
]
@@ -4712,6 +4728,16 @@ version = "0.2.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "04744f49eae99ab78e0d5c0b603ab218f515ea8cfe5a456d7629ad883a3b6e7d"
[[package]]
name = "ordered-multimap"
version = "0.7.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "49203cdcae0030493bad186b28da2fa25645fa276a51b6fec8010d281e02ef79"
dependencies = [
"dlv-list",
"hashbrown 0.14.5",
]
[[package]]
name = "ordered-stream"
version = "0.2.0"
@@ -5932,6 +5958,16 @@ dependencies = [
"wasm-bindgen-futures",
]
[[package]]
name = "rust-ini"
version = "0.21.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "796e8d2b6696392a43bea58116b667fb4c29727dc5abd27d6acf338bb4f688c7"
dependencies = [
"cfg-if",
"ordered-multimap",
]
[[package]]
name = "rust_decimal"
version = "1.42.0"
@@ -7420,6 +7456,27 @@ dependencies = [
"walkdir",
]
[[package]]
name = "tauri-plugin-deep-link"
version = "2.4.9"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "70ee75bc5627f77bfdf40c913255ebc258117b10ebe2b2239a1a1cf40b0b58aa"
dependencies = [
"dunce",
"plist",
"rust-ini",
"serde",
"serde_json",
"tauri",
"tauri-plugin",
"tauri-utils",
"thiserror 2.0.18",
"tracing",
"url",
"windows-registry 0.5.3",
"windows-result 0.3.4",
]
[[package]]
name = "tauri-plugin-dialog"
version = "2.7.1"
@@ -8979,6 +9036,17 @@ dependencies = [
"windows-link 0.2.1",
]
[[package]]
name = "windows-registry"
version = "0.5.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "5b8a9ed28765efc97bbc954883f4e6796c33a06546ebafacbabee9696967499e"
dependencies = [
"windows-link 0.1.3",
"windows-result 0.3.4",
"windows-strings 0.4.2",
]
[[package]]
name = "windows-registry"
version = "0.6.1"
+41 -3
View File
@@ -33,6 +33,7 @@ import { buildExecutableObjectSourceStatements, objectSourceSaveExecutionMode }
import { resolveExecutableSql } from "@/lib/sqlExecutionTarget";
import { isTauriRuntime } from "@/lib/tauriRuntime";
import { sqlFileTitleFromPath } from "@/lib/sqlFileOpen";
import { parseConnectionDeepLink, type ConnectionDeepLinkDraft } from "@/lib/connectionDeepLink";
import {
isCloseTabShortcut,
isExecuteSqlShortcut,
@@ -95,6 +96,7 @@ const authenticated = ref(isDesktop);
const setupRequired = ref(false);
const showConnectionDialog = ref(false);
const connectionDialogPrefill = ref<ConnectionDeepLinkDraft | null>(null);
const showSettingsDialog = ref(false);
const showDriverStore = ref(false);
const agentDriverUpdateCount = ref(0);
@@ -178,7 +180,11 @@ const { getDatabaseOptions } = useDatabaseOptions();
const { openLineageTarget, openDatabaseSearchTarget, onStructureEditorSaved, openTableTarget } =
useNavigationTargets(dialogs);
const { onExecuteSql, onReloadData, onPaginate, onSort } = useDataGridActions(activeTab);
const { setupTauriListeners, cleanupTauriListeners } = useTauriEvents({ openTableTarget, openSqlFilePath });
const { setupTauriListeners, cleanupTauriListeners } = useTauriEvents({
openTableTarget,
openSqlFilePath,
openConnectionDeepLink,
});
const appVersion = ref("");
const isClassicLayout = computed(() => settingsStore.editorSettings.appLayout === "classic");
@@ -396,6 +402,36 @@ async function openPendingSqlFiles() {
}
}
async function openConnectionDeepLink(url: string) {
try {
const draft = parseConnectionDeepLink(url);
if (!draft) return;
connectionStore.stopEditing();
connectionStore.stopCreatingConnectionInGroup();
connectionDialogPrefill.value = draft;
showConnectionDialog.value = true;
} catch (e: any) {
toast(t("connection.parseConnectionUrlFailed", { message: e?.message || String(e) }), 5000);
}
}
async function openPendingConnectionLinks() {
if (!isTauriRuntime()) return;
try {
const links = await api.pendingOpenConnectionLinks();
for (const link of links) {
await openConnectionDeepLink(link);
}
} catch {
/* ignore startup deep-link probing errors */
}
}
function setConnectionDialogOpen(value: boolean) {
showConnectionDialog.value = value;
if (!value) connectionDialogPrefill.value = null;
}
async function newQuery() {
const connId = connectionStore.activeConnectionId || connectionStore.connections[0]?.id;
if (!connId) return;
@@ -686,6 +722,7 @@ onMounted(async () => {
.catch(() => {});
setupTauriListeners();
void openPendingSqlFiles();
void openPendingConnectionLinks();
console.log(`[STARTUP] onMounted sync done: ${(performance.now() - mountStart).toFixed(0)}ms`);
});
@@ -899,11 +936,12 @@ onUnmounted(() => {
<AppDialogs
:show-connection-dialog="showConnectionDialog"
:connection-prefill="connectionDialogPrefill"
:show-settings-dialog="showSettingsDialog"
:app-version="appVersion"
:show-danger-dialog="showDangerDialog"
:danger-sql="dangerSql"
@update:show-connection-dialog="showConnectionDialog = $event"
@update:show-connection-dialog="setConnectionDialogOpen"
@update:show-settings-dialog="showSettingsDialog = $event"
@update:show-danger-dialog="showDangerDialog = $event"
@danger-confirm="onDangerConfirm"
@@ -913,7 +951,7 @@ onUnmounted(() => {
(msg: string) => toast(t('connection.connectFailed', { message: translateBackendError(t, msg) }), 5000)
"
@open-driver-store="
showConnectionDialog = false;
setConnectionDialogOpen(false);
showDriverStore = true;
"
@structure-editor-saved="onStructureEditorSaved(onReloadData, toast)"
@@ -16,6 +16,7 @@ import DatabaseIcon from "@/components/icons/DatabaseIcon.vue";
import * as api from "@/lib/api";
import { isTauriRuntime } from "@/lib/tauriRuntime";
import { applyParsedConnectionUrl, parseConnectionUrl } from "@/lib/connectionUrl";
import type { ConnectionDeepLinkDraft } from "@/lib/connectionDeepLink";
import { connectionUrlPlaceholder as getUrlPlaceholder } from "@/lib/connectionPresentation";
import { mongodbAuthFailureHint, mongoUrlParam, setMongoUrlParam } from "@/lib/mongoConnectionOptions";
import { showAgentDriverInstallHint, type AgentDriverInstallState } from "@/lib/agentDriverInstallHint";
@@ -45,6 +46,7 @@ const isDesktop = isTauriRuntime();
const props = defineProps<{
editConfig?: ConnectionConfig;
prefillConfig?: ConnectionDeepLinkDraft | null;
}>();
const emit = defineEmits<{
@@ -612,6 +614,7 @@ function connectionConfigForSubmit(id: string): ConnectionConfig {
config.ssh_connect_timeout_secs = Number.isFinite(sshTimeout) && sshTimeout > 0 ? sshTimeout : 5;
const proxyPort = Number(config.proxy_port);
config.proxy_port = Number.isFinite(proxyPort) && proxyPort > 0 ? proxyPort : 1080;
if (!config.one_time) config.one_time = undefined;
if (config.db_type === "mongodb" && !mongoUseUrl.value) {
config.connection_string = undefined;
}
@@ -678,22 +681,95 @@ function resetForm() {
resetTestState();
}
const submittedOneTimePrefillKey = ref<string | null>(null);
function oneTimePrefillKey(draft: ConnectionDeepLinkDraft) {
return JSON.stringify([
draft.name,
draft.dbType,
draft.driverProfile,
draft.driverLabel,
draft.host,
draft.port,
draft.username,
draft.password,
draft.database,
draft.urlParams,
draft.ssl,
draft.connectionString,
draft.oracleConnectionType,
draft.useMongoUrl,
]);
}
function submitOneTimePrefill(draft: ConnectionDeepLinkDraft) {
if (!draft.oneTime) return;
const key = oneTimePrefillKey(draft);
if (submittedOneTimePrefillKey.value === key) return;
submittedOneTimePrefillKey.value = key;
void nextTick(() => save());
}
function applyConnectionPrefill(draft: ConnectionDeepLinkDraft) {
resetForm();
applyProfile(draft.driverProfile);
form.value = {
...form.value,
db_type: draft.dbType,
driver_profile: draft.driverProfile,
driver_label: draft.driverLabel,
host: draft.host ?? form.value.host,
port: draft.port ?? form.value.port,
username: draft.username ?? form.value.username,
password: draft.password ?? form.value.password,
database: draft.database ?? form.value.database,
url_params: draft.urlParams ?? form.value.url_params,
ssl: draft.ssl ?? form.value.ssl,
connection_string: draft.connectionString ?? form.value.connection_string,
oracle_connection_type: draft.oracleConnectionType ?? form.value.oracle_connection_type,
one_time: draft.oneTime || undefined,
};
selectedType.value = draft.driverProfile;
customDriverName.value = isCustomCompatibleProfile() ? draft.driverLabel : "";
mongoUseUrl.value = !!draft.useMongoUrl;
if (draft.name?.trim()) {
form.value.name = draft.name.trim();
} else if (!form.value.name.trim()) {
form.value.name = draft.database || draft.host || draft.driverLabel;
}
dialogStep.value = "config";
configTab.value = "connection";
resetTestState();
submitOneTimePrefill(draft);
}
watch(
open,
(value) => {
if (!value) {
submittedOneTimePrefillKey.value = null;
resetForm();
return;
}
if (!props.editConfig) {
resetForm();
if (props.prefillConfig) applyConnectionPrefill(props.prefillConfig);
}
if (!props.prefillConfig?.oneTime) {
void loadJdbcDrivers();
void loadAgentDrivers();
}
void loadJdbcDrivers();
void loadAgentDrivers();
},
{ immediate: true },
);
watch(
() => props.prefillConfig,
(draft) => {
if (open.value && draft && !props.editConfig) applyConnectionPrefill(draft);
},
);
watch(canUseSsh, (value) => {
if (!value && configTab.value === "ssh") {
configTab.value = "connection";
@@ -731,6 +807,7 @@ async function save() {
emit("connectSucceeded", config.name);
})
.catch((e: any) => {
if (config.one_time) void store.removeConnection(config.id);
emit("connectFailed", mongodbAuthFailureHint(String(e?.message || e)));
});
return;
@@ -21,9 +21,11 @@ const DatabaseSearchDialog = defineAsyncComponent(() => import("@/components/sea
const DatabaseExportDialog = defineAsyncComponent(() => import("@/components/export/DatabaseExportDialog.vue"));
import { useConnectionStore } from "@/stores/connectionStore";
import { useDialogSources } from "@/composables/useDialogSources";
import type { ConnectionDeepLinkDraft } from "@/lib/connectionDeepLink";
const props = defineProps<{
showConnectionDialog: boolean;
connectionPrefill?: ConnectionDeepLinkDraft | null;
showSettingsDialog: boolean;
settingsInitialTab?: string;
appVersion?: string;
@@ -99,6 +101,7 @@ watch(
v-if="shouldShowConnectionDialog"
:open="shouldShowConnectionDialog"
:edit-config="editConfig"
:prefill-config="connectionPrefill"
@update:open="emit('update:showConnectionDialog', $event)"
@connect-started="emit('connectStarted', $event)"
@connect-succeeded="emit('connectSucceeded', $event)"
+23 -15
View File
@@ -5,11 +5,20 @@ import type { NavigationTarget } from "@/composables/useNavigationTargets";
export function useTauriEvents(deps: {
openTableTarget: (target: NavigationTarget) => Promise<void>;
openSqlFilePath: (path: string) => Promise<void>;
openConnectionDeepLink: (url: string) => Promise<void>;
}) {
const connectionStore = useConnectionStore();
const queryStore = useQueryStore();
const unlistenHandles: Array<() => void> = [];
function focusCurrentWindow() {
void import("@tauri-apps/api/window").then(({ getCurrentWindow }) =>
getCurrentWindow()
.setFocus()
.catch(() => {}),
);
}
function setupTauriListeners() {
import("@tauri-apps/api/event")
.then(({ listen }) => {
@@ -30,11 +39,7 @@ export function useTauriEvents(deps: {
} else {
deps.openTableTarget({ connectionId: connection_id, database, schema, tableName: table });
}
import("@tauri-apps/api/window").then(({ getCurrentWindow }) =>
getCurrentWindow()
.setFocus()
.catch(() => {}),
);
focusCurrentWindow();
} catch (e) {
console.error("[DBX] mcp-open-table error:", e);
}
@@ -60,11 +65,7 @@ export function useTauriEvents(deps: {
const tabId = queryStore.createTab(connection_id, database, undefined, "query");
queryStore.updateSql(tabId, sql);
await queryStore.executeTabSql(tabId, sql);
import("@tauri-apps/api/window").then(({ getCurrentWindow }) =>
getCurrentWindow()
.setFocus()
.catch(() => {}),
);
focusCurrentWindow();
} catch (e) {
console.error("[DBX] mcp-execute-query error:", e);
}
@@ -75,15 +76,22 @@ export function useTauriEvents(deps: {
for (const path of event.payload) {
await deps.openSqlFilePath(path);
}
import("@tauri-apps/api/window").then(({ getCurrentWindow }) =>
getCurrentWindow()
.setFocus()
.catch(() => {}),
);
focusCurrentWindow();
} catch (e) {
console.error("[DBX] dbx-open-sql-files error:", e);
}
}).then((unlisten) => unlistenHandles.push(unlisten));
listen<string[]>("dbx-open-connection-links", async (event) => {
try {
for (const url of event.payload) {
await deps.openConnectionDeepLink(url);
}
focusCurrentWindow();
} catch (e) {
console.error("[DBX] dbx-open-connection-links error:", e);
}
}).then((unlisten) => unlistenHandles.push(unlisten));
})
.catch(() => {});
}
+1
View File
@@ -104,6 +104,7 @@ export const executeSqlFile = forward("executeSqlFile");
export const cancelSqlFileExecution = forward("cancelSqlFileExecution");
export const listenSqlFileProgress = forward("listenSqlFileProgress");
export const pendingOpenSqlFiles = forward("pendingOpenSqlFiles");
export const pendingOpenConnectionLinks = forward("pendingOpenConnectionLinks");
export const readExternalSqlFile = forward("readExternalSqlFile");
// Data Transfer
+109
View File
@@ -0,0 +1,109 @@
import type { DatabaseType } from "@/types/database";
import { connectionProfileForScheme, parseConnectionUrl } from "@/lib/connectionUrl";
export interface ConnectionDeepLinkDraft {
name?: string;
dbType: DatabaseType;
driverProfile: string;
driverLabel: string;
host?: string;
port?: number;
username?: string;
password?: string;
database?: string;
urlParams?: string;
ssl?: boolean;
connectionString?: string;
oracleConnectionType?: "service_name" | "sid";
useMongoUrl?: boolean;
oneTime?: boolean;
}
const CONNECTION_DEEP_LINK_TARGET = "connection/new";
function normalizePath(url: URL): string {
return [url.hostname, url.pathname.replace(/^\/+/, "")].filter(Boolean).join("/").replace(/\/+$/, "");
}
function optionalParam(params: URLSearchParams, ...keys: string[]): string | undefined {
for (const key of keys) {
const value = params.get(key)?.trim();
if (value) return value;
}
return undefined;
}
function optionalNumberParam(params: URLSearchParams, ...keys: string[]): number | undefined {
const value = optionalParam(params, ...keys);
if (!value) return undefined;
const numberValue = Number(value);
return Number.isFinite(numberValue) && numberValue > 0 ? numberValue : undefined;
}
function optionalBooleanParam(params: URLSearchParams, ...keys: string[]): boolean {
const value = optionalParam(params, ...keys)?.toLowerCase();
return value === "true" || value === "1" || value === "yes" || value === "on";
}
function draftFromConnectionUrl(value: string, preferredProfile?: string): ConnectionDeepLinkDraft {
const parsed = parseConnectionUrl(value, preferredProfile);
return {
dbType: parsed.dbType,
driverProfile: parsed.driverProfile,
driverLabel: parsed.driverLabel,
host: parsed.host,
port: parsed.port,
username: parsed.username,
password: parsed.password,
database: parsed.database,
urlParams: parsed.urlParams,
ssl: parsed.ssl,
connectionString: parsed.connectionString,
oracleConnectionType: parsed.oracleConnectionType,
useMongoUrl: parsed.useMongoUrl,
};
}
export function parseConnectionDeepLink(value: string): ConnectionDeepLinkDraft | null {
let url: URL;
try {
url = new URL(value);
} catch {
return null;
}
if (url.protocol !== "dbx:") return null;
if (normalizePath(url) !== CONNECTION_DEEP_LINK_TARGET) return null;
const params = url.searchParams;
const preferredProfile = optionalParam(params, "type");
const rawConnectionUrl = optionalParam(params, "url");
const draft: ConnectionDeepLinkDraft = rawConnectionUrl
? draftFromConnectionUrl(rawConnectionUrl, preferredProfile)
: (() => {
const profile = connectionProfileForScheme(preferredProfile || "mysql");
if (!profile) throw new Error(`Unsupported connection type: ${preferredProfile}`);
return {
dbType: profile.type,
driverProfile: profile.profile,
driverLabel: profile.label,
port: profile.defaultPort,
ssl: false,
};
})();
const oneTime = optionalBooleanParam(params, "one_time");
return {
...draft,
name: optionalParam(params, "name") ?? draft.name,
host: optionalParam(params, "host") ?? draft.host,
port: optionalNumberParam(params, "port") ?? draft.port,
username: optionalParam(params, "user") ?? draft.username,
password: optionalParam(params, "password") ?? draft.password,
database: optionalParam(params, "database") ?? draft.database,
urlParams: optionalParam(params, "url_params") ?? draft.urlParams,
ssl: optionalBooleanParam(params, "ssl") ? true : draft.ssl,
...(oneTime ? { oneTime: true } : {}),
};
}
+3 -3
View File
@@ -16,7 +16,7 @@ export interface ParsedConnectionUrl {
useMongoUrl?: boolean;
}
type ConnectionProfile = {
export type ConnectionProfile = {
type: DatabaseType;
profile: string;
label: string;
@@ -66,7 +66,7 @@ function databaseFromPath(pathname: string): string | undefined {
return decodeUrlPart(value.split("/")[0]);
}
function profileForScheme(scheme: string, preferredProfile?: string): ConnectionProfile | undefined {
export function connectionProfileForScheme(scheme: string, preferredProfile?: string): ConnectionProfile | undefined {
if ((scheme === "http" || scheme === "https") && preferredProfile) {
return HTTP_SELECTED_PROFILES[preferredProfile];
}
@@ -193,7 +193,7 @@ export function parseConnectionUrl(value: string, preferredProfile?: string): Pa
}
const scheme = parsed.protocol.replace(/:$/, "").toLowerCase();
const profile = profileForScheme(scheme, preferredProfile);
const profile = connectionProfileForScheme(scheme, preferredProfile);
if (!profile) {
throw new Error(`Unsupported connection URL scheme: ${scheme}`);
}
+4
View File
@@ -499,6 +499,10 @@ export async function pendingOpenSqlFiles(): Promise<string[]> {
return [];
}
export async function pendingOpenConnectionLinks(): Promise<string[]> {
return [];
}
export async function readExternalSqlFile(_path: string): Promise<string> {
throw new Error("Opening external SQL file paths is only available in the desktop app");
}
+4
View File
@@ -112,6 +112,10 @@ export async function pendingOpenSqlFiles(): Promise<string[]> {
return invoke("pending_open_sql_files");
}
export async function pendingOpenConnectionLinks(): Promise<string[]> {
return invoke("pending_open_connection_links");
}
export async function readExternalSqlFile(path: string): Promise<string> {
return invoke("read_external_sql_file", { path });
}
@@ -601,6 +601,7 @@ export const useConnectionStore = defineStore("connection", () => {
}
async function disconnect(connectionId: string) {
const shouldRemoveOneTimeConnection = getConfig(connectionId)?.one_time === true;
await api.disconnectDb(connectionId);
connectedIds.value.delete(connectionId);
const node = treeNodes.value.find((n) => n.connectionId === connectionId);
@@ -613,6 +614,9 @@ export const useConnectionStore = defineStore("connection", () => {
activeConnectionId.value = null;
}
invalidateCompletionCache(connectionId);
if (shouldRemoveOneTimeConnection) {
await removeConnection(connectionId);
}
}
async function ensureConnected(connectionId: string) {
+1
View File
@@ -79,6 +79,7 @@ export interface ConnectionConfig {
connection_string?: string;
jdbc_driver_class?: string;
jdbc_driver_paths?: string[];
one_time?: boolean;
}
export interface AttachedDatabaseConfig {
+1
View File
@@ -689,6 +689,7 @@ mod tests {
external_config: None,
jdbc_driver_class: None,
jdbc_driver_paths: Vec::new(),
one_time: false,
}
}
@@ -313,6 +313,7 @@ mod tests {
external_config: None,
jdbc_driver_class: None,
jdbc_driver_paths: Vec::new(),
one_time: false,
}
}
+7
View File
@@ -68,6 +68,8 @@ pub struct ConnectionConfig {
pub jdbc_driver_class: Option<String>,
#[serde(default)]
pub jdbc_driver_paths: Vec<String>,
#[serde(default, skip_serializing_if = "is_false")]
pub one_time: bool,
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
@@ -88,6 +90,10 @@ fn default_proxy_port() -> u16 {
1080
}
fn is_false(value: &bool) -> bool {
!*value
}
#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq)]
#[serde(rename_all = "lowercase")]
pub enum ProxyType {
@@ -671,6 +677,7 @@ mod tests {
external_config: None,
jdbc_driver_class: None,
jdbc_driver_paths: Vec::new(),
one_time: false,
}
}
+1
View File
@@ -1276,6 +1276,7 @@ mod tests {
external_config: None,
jdbc_driver_class: None,
jdbc_driver_paths: Vec::new(),
one_time: false,
};
let params = external_driver_query_params(&config, "SELECT * FROM events", "analytics", Some("app"));
+1
View File
@@ -166,6 +166,7 @@ mod tests {
external_config: None,
jdbc_driver_class: None,
jdbc_driver_paths: Vec::new(),
one_time: false,
}
}
+42
View File
@@ -130,6 +130,48 @@ CLI JSON 错误使用稳定错误码:
| `DBX_NOT_RUNNING` | DBX 桌面端 bridge 不可用 |
| `ERROR` | 未预期的运行时错误 |
## 桌面端 Deep Link
DBX 桌面端支持通过 `dbx://connection/new` 从浏览器、堡垒机或脚本打开新建连接窗口并预填连接信息。
DSN 模式:
```bash
open 'dbx://connection/new?url=postgres%3A%2F%2Fapp%3Asecret%40db.internal%3A5432%2Forders'
```
字段模式:
```bash
open 'dbx://connection/new?type=mysql&host=127.0.0.1&port=3306&user=root&password=secret&database=test'
```
支持的字段:
| 字段 | 含义 |
|---|---|
| `type` | 数据库类型,例如 `mysql`、`postgres`、`redis`、`mongodb` |
| `url` | 数据库 DSN,建议 URL encode |
| `name` | DBX 中显示的连接名;未传时优先使用 `database`,其次使用 `host` |
| `host` | 主机地址 |
| `port` | 端口 |
| `user` | 用户名 |
| `password` | 密码 |
| `database` | 数据库名;Redis 可传 DB index,例如 `0` |
| `url_params` | 额外连接参数,例如 `sslmode=require` |
| `ssl` | 是否启用 SSL,`true` 表示启用 |
| `one_time` | `true` 时自动连接,断开后自动删除连接信息 |
一次性连接示例:
```bash
open 'dbx://connection/new?type=redis&host=127.0.0.1&port=6379&user=default&password=secret&database=0&one_time=true'
```
<Callout type="info">
测试系统级 `dbx://` 唤起前,请先安装并启动一次 DBX 桌面端,让系统注册 URL 协议。macOS 可使用 `open 'dbx://connection/new?...'` 测试。
</Callout>
## Codex
Codex 可以直接通过 shell 调用 CLI:
+42
View File
@@ -130,6 +130,48 @@ CLI JSON errors use stable codes:
| `DBX_NOT_RUNNING` | DBX Desktop bridge is unavailable |
| `ERROR` | Unexpected runtime failure |
## Desktop Deep Links
DBX Desktop supports `dbx://connection/new` links for opening the new connection dialog from browsers, bastion hosts, or scripts with connection fields prefilled.
DSN mode:
```bash
open 'dbx://connection/new?url=postgres%3A%2F%2Fapp%3Asecret%40db.internal%3A5432%2Forders'
```
Field mode:
```bash
open 'dbx://connection/new?type=mysql&host=127.0.0.1&port=3306&user=root&password=secret&database=test'
```
Supported fields:
| Field | Meaning |
|---|---|
| `type` | Database type, such as `mysql`, `postgres`, `redis`, or `mongodb` |
| `url` | Database DSN; URL encoding is recommended |
| `name` | Display name in DBX; when omitted, DBX uses `database`, then `host` |
| `host` | Hostname or IP address |
| `port` | Port |
| `user` | Username |
| `password` | Password |
| `database` | Database name; for Redis this can be a DB index such as `0` |
| `url_params` | Extra connection parameters, such as `sslmode=require` |
| `ssl` | Enable SSL when set to `true` |
| `one_time` | Automatically connect when set to `true`, then delete the connection after disconnecting |
One-time connection example:
```bash
open 'dbx://connection/new?type=redis&host=127.0.0.1&port=6379&user=default&password=secret&database=0&one_time=true'
```
<Callout type="info">
Before testing system-level `dbx://` launches, install and open DBX Desktop once so the operating system can register the URL scheme. On macOS, use `open 'dbx://connection/new?...'` to test it.
</Callout>
## Codex
Codex can call the CLI directly from shell tools:
@@ -0,0 +1,58 @@
import { strict as assert } from "node:assert";
import test from "node:test";
import { parseConnectionDeepLink } from "../../apps/desktop/src/lib/connectionDeepLink.ts";
test("parses dbx connection deep link query fields", () => {
const draft = parseConnectionDeepLink(
"dbx://connection/new?type=postgres&host=db.internal&port=15432&user=app&database=orders&name=Orders",
);
assert.deepEqual(draft, {
name: "Orders",
dbType: "postgres",
driverProfile: "postgres",
driverLabel: "PostgreSQL",
host: "db.internal",
port: 15432,
username: "app",
password: undefined,
database: "orders",
urlParams: undefined,
ssl: false,
});
});
test("parses encoded database URL with password", () => {
const draft = parseConnectionDeepLink(
"dbx://connection/new?url=postgres%3A%2F%2Fapp%3Asecret%40db.internal%3A5432%2Forders%3Fsslmode%3Drequire",
);
assert.equal(draft?.dbType, "postgres");
assert.equal(draft?.driverProfile, "postgres");
assert.equal(draft?.host, "db.internal");
assert.equal(draft?.port, 5432);
assert.equal(draft?.username, "app");
assert.equal(draft?.password, "secret");
assert.equal(draft?.database, "orders");
assert.equal(draft?.urlParams, "sslmode=require");
});
test("allows password query field to override database URL password", () => {
const draft = parseConnectionDeepLink(
"dbx://connection/new?url=postgres%3A%2F%2Fapp%3Asecret%40db.internal%3A5432%2Forders&password=override",
);
assert.equal(draft?.password, "override");
});
test("parses boolean control parameters consistently", () => {
const draft = parseConnectionDeepLink("dbx://connection/new?type=mysql&ssl=1&one_time=yes");
assert.equal(draft?.ssl, true);
assert.equal(draft?.oneTime, true);
});
test("ignores unsupported dbx deep link targets", () => {
assert.equal(parseConnectionDeepLink("dbx://query/open?sql=select%201"), null);
assert.equal(parseConnectionDeepLink("dbx://connections/new?type=postgres"), null);
});
+1
View File
@@ -27,6 +27,7 @@ tokio = { version = "1", features = ["full"] }
uuid = { version = "1", features = ["v4", "serde"] }
anyhow = "1"
chrono = { version = "0.4", features = ["serde"] }
tauri-plugin-deep-link = "2"
tauri-plugin-dialog = "2.7.0"
tauri-plugin-fs = "2.5.0"
tauri-plugin-single-instance = "2.4.2"
+1
View File
@@ -62,6 +62,7 @@ mod tests {
external_config: None,
jdbc_driver_class: None,
jdbc_driver_paths: Vec::new(),
one_time: false,
}
}
+102
View File
@@ -0,0 +1,102 @@
use std::collections::HashSet;
use std::sync::Mutex;
const CONNECTION_DEEP_LINK_PREFIX: &str = "dbx://connection/new";
#[tauri::command]
pub fn pending_open_connection_links(state: tauri::State<'_, DeepLinkOpenState>) -> Vec<String> {
dedupe_links(state.drain())
}
#[derive(Default)]
pub struct DeepLinkOpenState {
pending: Mutex<Vec<String>>,
}
impl DeepLinkOpenState {
pub fn push(&self, links: Vec<String>) {
if links.is_empty() {
return;
}
if let Ok(mut pending) = self.pending.lock() {
pending.extend(links);
}
}
fn drain(&self) -> Vec<String> {
self.pending.lock().map(|mut pending| pending.drain(..).collect()).unwrap_or_default()
}
}
pub fn connection_deep_links_from_args<I, S>(args: I) -> Vec<String>
where
I: IntoIterator<Item = S>,
S: AsRef<str>,
{
args.into_iter().filter_map(|arg| connection_deep_link_from_arg(arg.as_ref())).collect()
}
pub fn connection_deep_link_from_arg(arg: &str) -> Option<String> {
let trimmed = arg.trim();
let suffix = trimmed.strip_prefix(CONNECTION_DEEP_LINK_PREFIX)?;
if !(suffix.is_empty()
|| suffix.starts_with('?')
|| suffix.starts_with('#')
|| suffix == "/"
|| suffix.starts_with("/?")
|| suffix.starts_with("/#"))
{
return None;
}
Some(trimmed.to_string())
}
fn dedupe_links(links: Vec<String>) -> Vec<String> {
let mut seen = HashSet::new();
let mut unique = Vec::new();
for link in links {
if seen.insert(link.clone()) {
unique.push(link);
}
}
unique
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn filters_connection_deep_links() {
let links = connection_deep_links_from_args([
"dbx://connection/new?type=mysql&host=127.0.0.1",
"--flag",
"dbx://open?x=1",
"dbx://connections/new?type=postgres",
"dbx://connection/newer?type=mysql",
]);
assert_eq!(links, vec!["dbx://connection/new?type=mysql&host=127.0.0.1".to_string()]);
}
#[test]
fn drains_pending_links_once() {
let state = DeepLinkOpenState::default();
state.push(vec!["dbx://connection/new?type=mysql".to_string()]);
assert_eq!(state.drain(), vec!["dbx://connection/new?type=mysql"]);
assert!(state.drain().is_empty());
}
#[test]
fn dedupes_links_while_preserving_order() {
assert_eq!(
dedupe_links(vec![
"dbx://connection/new?type=mysql".to_string(),
"dbx://connection/new?type=postgres".to_string(),
"dbx://connection/new?type=mysql".to_string(),
]),
vec!["dbx://connection/new?type=mysql", "dbx://connection/new?type=postgres"]
);
}
}
+1
View File
@@ -4,6 +4,7 @@ pub mod connection;
#[allow(dead_code, unused_imports)]
mod connection_secrets;
pub mod database_export;
pub mod deep_link;
pub mod external_sql;
pub mod history;
pub mod mcp_bridge;
+30
View File
@@ -13,6 +13,8 @@ use tauri::{
tray::{MouseButton, MouseButtonState, TrayIconBuilder, TrayIconEvent},
};
use tauri::{Emitter, Manager, RunEvent};
#[cfg(any(windows, target_os = "linux"))]
use tauri_plugin_deep_link::DeepLinkExt;
fn should_hide_window_on_close(target_os: &str) -> bool {
matches!(target_os, "macos" | "windows")
@@ -26,6 +28,17 @@ fn show_main_window(app: &tauri::AppHandle) {
}
}
fn open_connection_deep_links(app: &tauri::AppHandle, links: Vec<String>) {
if links.is_empty() {
return;
}
if let Some(state) = app.try_state::<commands::deep_link::DeepLinkOpenState>() {
state.push(links.clone());
}
let _ = app.emit("dbx-open-connection-links", links);
show_main_window(app);
}
#[cfg_attr(not(target_os = "windows"), allow(dead_code))]
fn setup_windows_tray(app: &mut tauri::App) -> tauri::Result<()> {
let menu = MenuBuilder::new(app).text("show", "Show DBX").separator().text("quit", "Quit DBX").build()?;
@@ -75,9 +88,13 @@ pub fn run() {
let startup_begin = Instant::now();
tauri::Builder::default()
.plugin(tauri_plugin_deep_link::init())
.plugin(tauri_plugin_dialog::init())
.plugin(tauri_plugin_fs::init())
.plugin(tauri_plugin_single_instance::init(|app, args, cwd| {
let links = commands::deep_link::connection_deep_links_from_args(args.clone());
open_connection_deep_links(app, links);
let paths = commands::external_sql::sql_file_paths_from_args(args, std::path::Path::new(&cwd));
if !paths.is_empty() {
if let Some(state) = app.try_state::<commands::external_sql::ExternalSqlOpenState>() {
@@ -123,6 +140,9 @@ pub fn run() {
));
app.manage(state.clone());
app.manage(commands::external_sql::ExternalSqlOpenState::default());
app.manage(commands::deep_link::DeepLinkOpenState::default());
let startup_links = commands::deep_link::connection_deep_links_from_args(std::env::args().skip(1));
open_connection_deep_links(app.handle(), startup_links);
let app_handle = app.handle().clone();
commands::mcp_bridge::start(app_handle, state);
@@ -137,6 +157,8 @@ pub fn run() {
#[cfg(target_os = "windows")]
setup_windows_tray(app)?;
window_state_guard::enforce_main_window_bounds(app.handle());
#[cfg(any(windows, target_os = "linux"))]
let _ = app.deep_link().register_all();
Ok(())
})
@@ -198,6 +220,7 @@ pub fn run() {
commands::sql_file::cancel_sql_file_execution,
commands::external_sql::pending_open_sql_files,
commands::external_sql::read_external_sql_file,
commands::deep_link::pending_open_connection_links,
commands::table_import::preview_table_import_file,
commands::table_import::import_table_file,
commands::table_import::cancel_table_import,
@@ -260,6 +283,13 @@ pub fn run() {
.run(|app_handle, event| {
#[cfg(target_os = "macos")]
if let RunEvent::Opened { urls } = &event {
let links: Vec<String> = urls
.iter()
.map(|url| url.to_string())
.filter_map(|url| commands::deep_link::connection_deep_link_from_arg(&url))
.collect();
open_connection_deep_links(app_handle, links);
let paths: Vec<String> = urls
.iter()
.filter_map(|url| url.to_file_path().ok())
+5
View File
@@ -55,6 +55,11 @@
]
},
"plugins": {
"deep-link": {
"desktop": {
"schemes": ["dbx"]
}
},
"updater": {
"pubkey": "dW50cnVzdGVkIGNvbW1lbnQ6IG1pbmlzaWduIHB1YmxpYyBrZXk6IDkxNEYyMzE3QkJCNkM3REYKUldUZng3YTdGeU5Qa1NnTUZCRVhJVkxsOWZKQWFPVCtacUgySlNlcjRDU2RmZElCQnphd25pYUMK",
"endpoints": [