diff --git a/Cargo.lock b/Cargo.lock index 93709403b7..c6298237e2 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -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" diff --git a/apps/desktop/src/App.vue b/apps/desktop/src/App.vue index 572d2db0ee..ddae633f8d 100644 --- a/apps/desktop/src/App.vue +++ b/apps/desktop/src/App.vue @@ -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(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(() => { { (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)" diff --git a/apps/desktop/src/components/connection/ConnectionDialog.vue b/apps/desktop/src/components/connection/ConnectionDialog.vue index 0c4f26ea7d..8eb3e8d071 100644 --- a/apps/desktop/src/components/connection/ConnectionDialog.vue +++ b/apps/desktop/src/components/connection/ConnectionDialog.vue @@ -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(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; diff --git a/apps/desktop/src/components/layout/AppDialogs.vue b/apps/desktop/src/components/layout/AppDialogs.vue index adfa2ff8a5..dd9af6ae36 100644 --- a/apps/desktop/src/components/layout/AppDialogs.vue +++ b/apps/desktop/src/components/layout/AppDialogs.vue @@ -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)" diff --git a/apps/desktop/src/composables/useTauriEvents.ts b/apps/desktop/src/composables/useTauriEvents.ts index b8639460de..925d821303 100644 --- a/apps/desktop/src/composables/useTauriEvents.ts +++ b/apps/desktop/src/composables/useTauriEvents.ts @@ -5,11 +5,20 @@ import type { NavigationTarget } from "@/composables/useNavigationTargets"; export function useTauriEvents(deps: { openTableTarget: (target: NavigationTarget) => Promise; openSqlFilePath: (path: string) => Promise; + openConnectionDeepLink: (url: string) => Promise; }) { 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("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(() => {}); } diff --git a/apps/desktop/src/lib/api.ts b/apps/desktop/src/lib/api.ts index 0d9269d801..c061a86a90 100644 --- a/apps/desktop/src/lib/api.ts +++ b/apps/desktop/src/lib/api.ts @@ -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 diff --git a/apps/desktop/src/lib/connectionDeepLink.ts b/apps/desktop/src/lib/connectionDeepLink.ts new file mode 100644 index 0000000000..89c5bc8440 --- /dev/null +++ b/apps/desktop/src/lib/connectionDeepLink.ts @@ -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 } : {}), + }; +} diff --git a/apps/desktop/src/lib/connectionUrl.ts b/apps/desktop/src/lib/connectionUrl.ts index 7d64c81eb8..8fc0fc354a 100644 --- a/apps/desktop/src/lib/connectionUrl.ts +++ b/apps/desktop/src/lib/connectionUrl.ts @@ -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}`); } diff --git a/apps/desktop/src/lib/http.ts b/apps/desktop/src/lib/http.ts index 90b23fe11f..5a39fe1a04 100644 --- a/apps/desktop/src/lib/http.ts +++ b/apps/desktop/src/lib/http.ts @@ -499,6 +499,10 @@ export async function pendingOpenSqlFiles(): Promise { return []; } +export async function pendingOpenConnectionLinks(): Promise { + return []; +} + export async function readExternalSqlFile(_path: string): Promise { throw new Error("Opening external SQL file paths is only available in the desktop app"); } diff --git a/apps/desktop/src/lib/tauri.ts b/apps/desktop/src/lib/tauri.ts index 59b962609d..e7d88ee3d0 100644 --- a/apps/desktop/src/lib/tauri.ts +++ b/apps/desktop/src/lib/tauri.ts @@ -112,6 +112,10 @@ export async function pendingOpenSqlFiles(): Promise { return invoke("pending_open_sql_files"); } +export async function pendingOpenConnectionLinks(): Promise { + return invoke("pending_open_connection_links"); +} + export async function readExternalSqlFile(path: string): Promise { return invoke("read_external_sql_file", { path }); } diff --git a/apps/desktop/src/stores/connectionStore.ts b/apps/desktop/src/stores/connectionStore.ts index 7af9938d78..abde81fa0c 100644 --- a/apps/desktop/src/stores/connectionStore.ts +++ b/apps/desktop/src/stores/connectionStore.ts @@ -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) { diff --git a/apps/desktop/src/types/database.ts b/apps/desktop/src/types/database.ts index 71d2ad9692..e5ab6693fb 100644 --- a/apps/desktop/src/types/database.ts +++ b/apps/desktop/src/types/database.ts @@ -79,6 +79,7 @@ export interface ConnectionConfig { connection_string?: string; jdbc_driver_class?: string; jdbc_driver_paths?: string[]; + one_time?: boolean; } export interface AttachedDatabaseConfig { diff --git a/crates/dbx-core/src/connection.rs b/crates/dbx-core/src/connection.rs index d45a1956f8..5bee345dda 100644 --- a/crates/dbx-core/src/connection.rs +++ b/crates/dbx-core/src/connection.rs @@ -689,6 +689,7 @@ mod tests { external_config: None, jdbc_driver_class: None, jdbc_driver_paths: Vec::new(), + one_time: false, } } diff --git a/crates/dbx-core/src/connection_secrets.rs b/crates/dbx-core/src/connection_secrets.rs index fb44bea609..b56c6831ed 100644 --- a/crates/dbx-core/src/connection_secrets.rs +++ b/crates/dbx-core/src/connection_secrets.rs @@ -313,6 +313,7 @@ mod tests { external_config: None, jdbc_driver_class: None, jdbc_driver_paths: Vec::new(), + one_time: false, } } diff --git a/crates/dbx-core/src/models/connection.rs b/crates/dbx-core/src/models/connection.rs index db97d8cca8..7db171055e 100644 --- a/crates/dbx-core/src/models/connection.rs +++ b/crates/dbx-core/src/models/connection.rs @@ -68,6 +68,8 @@ pub struct ConnectionConfig { pub jdbc_driver_class: Option, #[serde(default)] pub jdbc_driver_paths: Vec, + #[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, } } diff --git a/crates/dbx-core/src/query.rs b/crates/dbx-core/src/query.rs index b66a74d395..3356c86467 100644 --- a/crates/dbx-core/src/query.rs +++ b/crates/dbx-core/src/query.rs @@ -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")); diff --git a/crates/dbx-web/src/routes/connection.rs b/crates/dbx-web/src/routes/connection.rs index 9cd4a31a54..46ee0b754d 100644 --- a/crates/dbx-web/src/routes/connection.rs +++ b/crates/dbx-web/src/routes/connection.rs @@ -166,6 +166,7 @@ mod tests { external_config: None, jdbc_driver_class: None, jdbc_driver_paths: Vec::new(), + one_time: false, } } diff --git a/docs/content/docs/cli.cn.mdx b/docs/content/docs/cli.cn.mdx index 4dd93014c7..289be6999e 100644 --- a/docs/content/docs/cli.cn.mdx +++ b/docs/content/docs/cli.cn.mdx @@ -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' +``` + + +测试系统级 `dbx://` 唤起前,请先安装并启动一次 DBX 桌面端,让系统注册 URL 协议。macOS 可使用 `open 'dbx://connection/new?...'` 测试。 + + ## Codex Codex 可以直接通过 shell 调用 CLI: diff --git a/docs/content/docs/cli.mdx b/docs/content/docs/cli.mdx index e7288c3264..0833919a1d 100644 --- a/docs/content/docs/cli.mdx +++ b/docs/content/docs/cli.mdx @@ -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' +``` + + +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. + + ## Codex Codex can call the CLI directly from shell tools: diff --git a/packages/app-tests/connectionDeepLink.test.ts b/packages/app-tests/connectionDeepLink.test.ts new file mode 100644 index 0000000000..c530141500 --- /dev/null +++ b/packages/app-tests/connectionDeepLink.test.ts @@ -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); +}); diff --git a/src-tauri/Cargo.toml b/src-tauri/Cargo.toml index e44e82628b..b379774be8 100644 --- a/src-tauri/Cargo.toml +++ b/src-tauri/Cargo.toml @@ -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" diff --git a/src-tauri/src/commands/connection.rs b/src-tauri/src/commands/connection.rs index 5f4d7cb14a..8bc3e01b69 100644 --- a/src-tauri/src/commands/connection.rs +++ b/src-tauri/src/commands/connection.rs @@ -62,6 +62,7 @@ mod tests { external_config: None, jdbc_driver_class: None, jdbc_driver_paths: Vec::new(), + one_time: false, } } diff --git a/src-tauri/src/commands/deep_link.rs b/src-tauri/src/commands/deep_link.rs new file mode 100644 index 0000000000..a42cfa6488 --- /dev/null +++ b/src-tauri/src/commands/deep_link.rs @@ -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 { + dedupe_links(state.drain()) +} + +#[derive(Default)] +pub struct DeepLinkOpenState { + pending: Mutex>, +} + +impl DeepLinkOpenState { + pub fn push(&self, links: Vec) { + if links.is_empty() { + return; + } + if let Ok(mut pending) = self.pending.lock() { + pending.extend(links); + } + } + + fn drain(&self) -> Vec { + self.pending.lock().map(|mut pending| pending.drain(..).collect()).unwrap_or_default() + } +} + +pub fn connection_deep_links_from_args(args: I) -> Vec +where + I: IntoIterator, + S: AsRef, +{ + 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 { + 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) -> Vec { + 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"] + ); + } +} diff --git a/src-tauri/src/commands/mod.rs b/src-tauri/src/commands/mod.rs index 2e4953d201..75ab12f1fc 100644 --- a/src-tauri/src/commands/mod.rs +++ b/src-tauri/src/commands/mod.rs @@ -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; diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index 9042ead73a..ebdcc63f45 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -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) { + if links.is_empty() { + return; + } + if let Some(state) = app.try_state::() { + 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::() { @@ -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 = 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 = urls .iter() .filter_map(|url| url.to_file_path().ok()) diff --git a/src-tauri/tauri.conf.json b/src-tauri/tauri.conf.json index 9e5bf14b8e..9d5870dc90 100644 --- a/src-tauri/tauri.conf.json +++ b/src-tauri/tauri.conf.json @@ -55,6 +55,11 @@ ] }, "plugins": { + "deep-link": { + "desktop": { + "schemes": ["dbx"] + } + }, "updater": { "pubkey": "dW50cnVzdGVkIGNvbW1lbnQ6IG1pbmlzaWduIHB1YmxpYyBrZXk6IDkxNEYyMzE3QkJCNkM3REYKUldUZng3YTdGeU5Qa1NnTUZCRVhJVkxsOWZKQWFPVCtacUgySlNlcjRDU2RmZElCQnphd25pYUMK", "endpoints": [