diff --git a/apps/desktop/src/components/layout/AppSidebar.vue b/apps/desktop/src/components/layout/AppSidebar.vue index bcdac51daf..5041a80f5c 100644 --- a/apps/desktop/src/components/layout/AppSidebar.vue +++ b/apps/desktop/src/components/layout/AppSidebar.vue @@ -16,7 +16,7 @@ defineProps<{ }>(); const emit = defineEmits<{ - import: [source: "dbx" | "navicat" | "dbeaver"]; + import: [source: "dbx" | "navicat" | "dbeaver" | "datagrip"]; export: []; startResize: [event: MouseEvent]; collapse: []; @@ -30,6 +30,7 @@ const importSourceItems = computed(() => [ { value: "dbx", label: t("sidebar.importDbx") }, { value: "navicat", label: t("sidebar.importNavicat") }, { value: "dbeaver", label: t("sidebar.importDbeaver") }, + { value: "datagrip", label: t("sidebar.importDatagrip") }, ]); async function refreshTree() { @@ -82,7 +83,7 @@ defineExpose({ focusSearch }); :highlight-selected="false" check-position="none" align="end" - @update:model-value="(source) => emit('import', source as 'dbx' | 'navicat' | 'dbeaver')" + @update:model-value="(source) => emit('import', source as 'dbx' | 'navicat' | 'dbeaver' | 'datagrip')" /> diff --git a/apps/desktop/src/composables/useDialogSources.ts b/apps/desktop/src/composables/useDialogSources.ts index 5f84701a60..383c272a8b 100644 --- a/apps/desktop/src/composables/useDialogSources.ts +++ b/apps/desktop/src/composables/useDialogSources.ts @@ -205,7 +205,7 @@ export function useDialogSources() { } } - async function onImportClick(source: "dbx" | "navicat" | "dbeaver" = "dbx") { + async function onImportClick(source: "dbx" | "navicat" | "dbeaver" | "datagrip" = "dbx") { try { const result = await connectionStore.readImportFile(source); if (!result) return; @@ -216,13 +216,20 @@ export function useDialogSources() { showConfigPassphraseDialog.value = true; } else { const { count, layout } = await connectionStore.importConnectionsFromFile(result.content, null); + // For DataGrip imports, read Keychain passwords + let keychainFilled = 0; + if (source === "datagrip" && count > 0) { + keychainFilled = await connectionStore.applyDataGripKeychainPasswords(); + } toast( count > 0 ? source === "navicat" ? t("configExport.importNavicatSuccess", { count }) : source === "dbeaver" ? t("configExport.importDbeaverSuccess", { count }) - : t("configExport.importSuccess", { count }) + : source === "datagrip" + ? t("configExport.importDatagripSuccess", { count: count, filled: keychainFilled }) + : t("configExport.importSuccess", { count }) : t("configExport.importNone"), 4000, ); diff --git a/apps/desktop/src/i18n/locales/en.ts b/apps/desktop/src/i18n/locales/en.ts index d83e0a8d96..65c8c60295 100644 --- a/apps/desktop/src/i18n/locales/en.ts +++ b/apps/desktop/src/i18n/locales/en.ts @@ -73,6 +73,7 @@ export default { importDbx: "Import DBX Config", importNavicat: "Import Navicat NCX", importDbeaver: "Import DBeaver", + importDatagrip: "Import DataGrip", export: "Export Connections", collapse: "Collapse sidebar", expand: "Expand sidebar", @@ -867,6 +868,7 @@ export default { importSuccess: "Imported {count} connection(s)", importNavicatSuccess: "Imported {count} Navicat connection(s). Fill in any connection whose password is still empty before testing.", + importDatagripSuccess: "Imported {count} DataGrip connection(s), filled {filled} password(s) from macOS Keychain.", importDbeaverSuccess: "Imported {count} DBeaver connection(s). Fill in any connection whose password is still empty before testing.", importNone: "No new connections to import", diff --git a/apps/desktop/src/i18n/locales/es.ts b/apps/desktop/src/i18n/locales/es.ts index d6602ba709..9accb831c7 100644 --- a/apps/desktop/src/i18n/locales/es.ts +++ b/apps/desktop/src/i18n/locales/es.ts @@ -73,6 +73,7 @@ export default { importDbx: "Importar configuración DBX", importNavicat: "Importar Navicat NCX", importDbeaver: "Importar DBeaver", + importDatagrip: "Importar DataGrip", export: "Exportar conexiones", collapse: "Contraer barra lateral", expand: "Expandir barra lateral", @@ -774,6 +775,8 @@ export default { importSuccess: "Se importaron {count} conexión(es)", importNavicatSuccess: "Se importaron {count} conexión(es) de Navicat. Completa la contraseña de las conexiones que aún la tengan vacía antes de probarlas.", + importDatagripSuccess: + "Se importaron {count} conexión(es) de DataGrip, se llenaron {filled} contraseña(s) del Keychain.", importDbeaverSuccess: "Se importaron {count} conexión(es) de DBeaver. Completa la contraseña de las conexiones que aún la tengan vacía antes de probarlas.", importNone: "No hay nuevas conexiones para importar", diff --git a/apps/desktop/src/i18n/locales/it.ts b/apps/desktop/src/i18n/locales/it.ts index abc7a01f96..36d2d27dc8 100644 --- a/apps/desktop/src/i18n/locales/it.ts +++ b/apps/desktop/src/i18n/locales/it.ts @@ -73,6 +73,7 @@ export default { importDbx: "Importa Configurazione DBX", importNavicat: "Importa Navicat NCX", importDbeaver: "Importa DBeaver", + importDatagrip: "Importa DataGrip", export: "Esporta Connessioni", collapse: "Riduci barra laterale", expand: "Espandi barra laterale", @@ -854,6 +855,7 @@ export default { importSuccess: "Importata/e {count} connessione/i", importNavicatSuccess: "Importata/e {count} connessione/i Navicat. Inserisci le password mancanti prima di testare la connessione.", + importDatagripSuccess: "Importate {count} connessione/i DataGrip, riempite {filled} password dalla Keychain.", importDbeaverSuccess: "Importata/e {count} connessione/i DBeaver. Inserisci le password mancanti prima di testare la connessione.", importNone: "Nessuna nuova connessione da importare", diff --git a/apps/desktop/src/i18n/locales/pt-BR.ts b/apps/desktop/src/i18n/locales/pt-BR.ts index d179952083..518acb0184 100644 --- a/apps/desktop/src/i18n/locales/pt-BR.ts +++ b/apps/desktop/src/i18n/locales/pt-BR.ts @@ -73,6 +73,7 @@ export default { importDbx: "Importar Configuração DBX", importNavicat: "Importar Navicat NCX", importDbeaver: "Importar DBeaver", + importDatagrip: "Importar DataGrip", export: "Exportar Conexões", collapse: "Recolher barra lateral", expand: "Expandir barra lateral", @@ -850,6 +851,7 @@ export default { importSuccess: "{count} conexão(ões) importada(s)", importNavicatSuccess: "{count} conexão(ões) do Navicat importada(s). Preencha qualquer conexão cuja senha ainda esteja vazia antes de testar.", + importDatagripSuccess: "{count} conexão(ões) DataGrip importada(s), {filled} senha(s) preenchida(s) do Keychain.", importDbeaverSuccess: "{count} conexão(ões) do DBeaver importada(s). Preencha qualquer conexão cuja senha ainda esteja vazia antes de testar.", importNone: "Nenhuma conexão nova para importar", diff --git a/apps/desktop/src/i18n/locales/zh-CN.ts b/apps/desktop/src/i18n/locales/zh-CN.ts index 9fc7f8c65b..213ccd27c1 100644 --- a/apps/desktop/src/i18n/locales/zh-CN.ts +++ b/apps/desktop/src/i18n/locales/zh-CN.ts @@ -72,6 +72,7 @@ export default { importDbx: "导入 DBX 配置", importNavicat: "导入 Navicat NCX", importDbeaver: "导入 DBeaver", + importDatagrip: "导入 DataGrip", export: "导出连接", collapse: "收起侧边栏", expand: "展开侧边栏", @@ -848,6 +849,7 @@ export default { exportSuccess: "连接配置导出成功", importSuccess: "已导入 {count} 个连接", importNavicatSuccess: "已导入 {count} 个 Navicat 连接,若个别连接密码为空请补填后测试连接", + importDatagripSuccess: "已导入 {count} 个 DataGrip 连接,已自动读取 {filled} 个密码(来自 macOS 钥匙串)", importDbeaverSuccess: "已导入 {count} 个 DBeaver 连接,若个别连接密码为空请补填后测试连接", importNone: "没有新的连接需要导入", importLayoutConfirm: "导入文件包含连接分组信息,是否一并应用?", diff --git a/apps/desktop/src/i18n/locales/zh-TW.ts b/apps/desktop/src/i18n/locales/zh-TW.ts index 07384b03d6..1c187c035d 100644 --- a/apps/desktop/src/i18n/locales/zh-TW.ts +++ b/apps/desktop/src/i18n/locales/zh-TW.ts @@ -72,6 +72,7 @@ export default { importDbx: "匯入 DBX 設定", importNavicat: "匯入 Navicat NCX", importDbeaver: "匯入 DBeaver", + importDatagrip: "匯入 DataGrip", export: "匯出連線", collapse: "收合側邊欄", expand: "展開側邊欄", @@ -797,6 +798,7 @@ export default { exportSuccess: "連線設定匯出成功", importSuccess: "已匯入 {count} 個連線", importNavicatSuccess: "已匯入 {count} 個 Navicat 連線,若個別連線密碼為空請補填後測試連線", + importDatagripSuccess: "已匯入 {count} 個 DataGrip 連線,已自動讀取 {filled} 個密碼(來自 macOS 鑰匙圈)", importDbeaverSuccess: "已匯入 {count} 個 DBeaver 連線,若個別連線密碼為空請補填後測試連線", importNone: "沒有新的連線需要匯入", importLayoutConfirm: "匯入檔案包含連線群組資訊,是否一併套用?", diff --git a/apps/desktop/src/lib/api.ts b/apps/desktop/src/lib/api.ts index 1bd9dc3784..61de609bd6 100644 --- a/apps/desktop/src/lib/api.ts +++ b/apps/desktop/src/lib/api.ts @@ -57,6 +57,8 @@ export const closeDatabaseConnection = forward("closeDatabaseConnection"); export const refreshConnections = forward("refreshConnections"); export const saveConnections = forward("saveConnections"); export const loadConnections = forward("loadConnections"); +export const readKeychainPassword = forward("readKeychainPassword"); +export const readKeychainPasswords = forward("readKeychainPasswords"); export const decryptConfig = forward("decryptConfig"); export const listPlugins = forward("listPlugins"); export const listJdbcDrivers = forward("listJdbcDrivers"); diff --git a/apps/desktop/src/lib/datagripImport.ts b/apps/desktop/src/lib/datagripImport.ts new file mode 100644 index 0000000000..6c5a980340 --- /dev/null +++ b/apps/desktop/src/lib/datagripImport.ts @@ -0,0 +1,472 @@ +import type { ConnectionConfig, DatabaseType } from "@/types/database"; +import { uuid } from "@/lib/utils"; + +type PartialConnection = Omit; + +export type DataGripImportPayload = { + format: "datagrip-import"; + dataSources: string; + dataSourcesLocal?: string; +}; + +type DataSourceFragment = { + uuid: string; + name: string; + driverRef: string; + jdbcUrl: string; + driverClass: string; + username: string; + product: string; + groupName?: string; +}; + +type DriverProfile = { + dbType: DatabaseType; + profile: string; + label: string; + port: number; + user: string; +}; + +// driver-ref prefix → dbx profile +const driverRefMap: Record = { + mysql: { dbType: "mysql", profile: "mysql", label: "MySQL", port: 3306, user: "root" }, + mariadb: { dbType: "mysql", profile: "mariadb", label: "MariaDB", port: 3306, user: "root" }, + postgresql: { dbType: "postgres", profile: "postgres", label: "PostgreSQL", port: 5432, user: "postgres" }, + postgres: { dbType: "postgres", profile: "postgres", label: "PostgreSQL", port: 5432, user: "postgres" }, + sqlite: { dbType: "sqlite", profile: "sqlite", label: "SQLite", port: 0, user: "" }, + sqlserver: { dbType: "sqlserver", profile: "sqlserver", label: "SQL Server", port: 1433, user: "sa" }, + mssql: { dbType: "sqlserver", profile: "sqlserver", label: "SQL Server", port: 1433, user: "sa" }, + jtds: { dbType: "sqlserver", profile: "sqlserver", label: "SQL Server", port: 1433, user: "sa" }, + oracle: { dbType: "oracle", profile: "oracle", label: "Oracle", port: 1521, user: "system" }, + mongo: { dbType: "mongodb", profile: "mongodb", label: "MongoDB", port: 27017, user: "" }, + mongodb: { dbType: "mongodb", profile: "mongodb", label: "MongoDB", port: 27017, user: "" }, + redis: { dbType: "redis", profile: "redis", label: "Redis", port: 6379, user: "" }, + clickhouse: { dbType: "clickhouse", profile: "clickhouse", label: "ClickHouse", port: 8123, user: "default" }, + cassandra: { dbType: "cassandra", profile: "cassandra", label: "Cassandra", port: 9042, user: "" }, + duckdb: { dbType: "duckdb", profile: "duckdb", label: "DuckDB", port: 0, user: "" }, + bigquery: { dbType: "bigquery", profile: "bigquery", label: "BigQuery", port: 443, user: "" }, + cockroach: { dbType: "postgres", profile: "cockroachdb", label: "CockroachDB", port: 26257, user: "root" }, + cockroachdb: { dbType: "postgres", profile: "cockroachdb", label: "CockroachDB", port: 26257, user: "root" }, + redshift: { dbType: "redshift", profile: "redshift", label: "Redshift", port: 5439, user: "awsuser" }, + elasticsearch: { dbType: "elasticsearch", profile: "elasticsearch", label: "Elasticsearch", port: 9200, user: "" }, + h2: { dbType: "h2", profile: "h2", label: "H2", port: 9092, user: "sa" }, + snowflake: { dbType: "snowflake", profile: "snowflake", label: "Snowflake", port: 443, user: "" }, +}; + +// product name from → dbx profile +const productMap: Record = { + mysql: { dbType: "mysql", profile: "mysql", label: "MySQL", port: 3306, user: "root" }, + mariadb: { dbType: "mysql", profile: "mariadb", label: "MariaDB", port: 3306, user: "root" }, + postgresql: { dbType: "postgres", profile: "postgres", label: "PostgreSQL", port: 5432, user: "postgres" }, + postgres: { dbType: "postgres", profile: "postgres", label: "PostgreSQL", port: 5432, user: "postgres" }, + sqlite: { dbType: "sqlite", profile: "sqlite", label: "SQLite", port: 0, user: "" }, + oracle: { dbType: "oracle", profile: "oracle", label: "Oracle", port: 1521, user: "system" }, + "sql server": { dbType: "sqlserver", profile: "sqlserver", label: "SQL Server", port: 1433, user: "sa" }, + mongodb: { dbType: "mongodb", profile: "mongodb", label: "MongoDB", port: 27017, user: "" }, + redis: { dbType: "redis", profile: "redis", label: "Redis", port: 6379, user: "" }, + clickhouse: { dbType: "clickhouse", profile: "clickhouse", label: "ClickHouse", port: 8123, user: "default" }, + cassandra: { dbType: "cassandra", profile: "cassandra", label: "Cassandra", port: 9042, user: "" }, + duckdb: { dbType: "duckdb", profile: "duckdb", label: "DuckDB", port: 0, user: "" }, + bigquery: { dbType: "bigquery", profile: "bigquery", label: "BigQuery", port: 443, user: "" }, + redshift: { dbType: "redshift", profile: "redshift", label: "Redshift", port: 5439, user: "awsuser" }, + elasticsearch: { dbType: "elasticsearch", profile: "elasticsearch", label: "Elasticsearch", port: 9200, user: "" }, + snowflake: { dbType: "snowflake", profile: "snowflake", label: "Snowflake", port: 443, user: "" }, +}; + +// JDBC subprotocol → dbx profile (fallback when driver-ref and product are unknown) +const subprotocolMap: Record = { + mysql: { dbType: "mysql", profile: "mysql", label: "MySQL", port: 3306, user: "root" }, + mariadb: { dbType: "mysql", profile: "mariadb", label: "MariaDB", port: 3306, user: "root" }, + postgresql: { dbType: "postgres", profile: "postgres", label: "PostgreSQL", port: 5432, user: "postgres" }, + sqlite: { dbType: "sqlite", profile: "sqlite", label: "SQLite", port: 0, user: "" }, + sqlserver: { dbType: "sqlserver", profile: "sqlserver", label: "SQL Server", port: 1433, user: "sa" }, + jtds: { dbType: "sqlserver", profile: "sqlserver", label: "SQL Server", port: 1433, user: "sa" }, + oracle: { dbType: "oracle", profile: "oracle", label: "Oracle", port: 1521, user: "system" }, + mongodb: { dbType: "mongodb", profile: "mongodb", label: "MongoDB", port: 27017, user: "" }, + redis: { dbType: "redis", profile: "redis", label: "Redis", port: 6379, user: "" }, + clickhouse: { dbType: "clickhouse", profile: "clickhouse", label: "ClickHouse", port: 8123, user: "default" }, + cassandra: { dbType: "cassandra", profile: "cassandra", label: "Cassandra", port: 9042, user: "" }, + duckdb: { dbType: "duckdb", profile: "duckdb", label: "DuckDB", port: 0, user: "" }, + bigquery: { dbType: "bigquery", profile: "bigquery", label: "BigQuery", port: 443, user: "" }, + redshift: { dbType: "redshift", profile: "redshift", label: "Redshift", port: 5439, user: "awsuser" }, +}; + +function getNumber(value: string | undefined): number { + if (!value) return 0; + const parsed = Number(value); + return Number.isFinite(parsed) && parsed > 0 ? parsed : 0; +} + +function getText(element: Element, tagName: string): string { + const child = element.getElementsByTagName(tagName)[0]; + return child?.textContent?.trim() ?? ""; +} + +function expandPathMacros(value: string): string { + if (typeof window !== "undefined") { + const home = + (typeof process !== "undefined" && process.env?.HOME) || + (typeof process !== "undefined" && process.env?.USERPROFILE) || + ""; + if (home) return value.replace(/\$USER_HOME\$/g, home); + } + return value.replace(/\$USER_HOME\$/g, "~"); +} + +// --- JDBC URL parser --- + +function parseJdbcUrl(jdbcUrl: string): { + host: string; + port: number; + database: string; + oracleConnectionType?: "service_name" | "sid"; +} { + const url = jdbcUrl.replace(/^jdbc:/i, "").trim(); + const result = { + host: "", + port: 0, + database: "", + oracleConnectionType: undefined as "service_name" | "sid" | undefined, + }; + + // SQL Server: jdbc:sqlserver://host[:port][;key=value] + const sqlServerMatch = url.match(/^sqlserver:\/\/([^;:/]+)(?::(\d+))?(?:;(.*))?/i); + if (sqlServerMatch) { + result.host = sqlServerMatch[1]; + result.port = getNumber(sqlServerMatch[2]); + for (const part of (sqlServerMatch[3] || "").split(";")) { + const [key, ...rest] = part.split("="); + if (/^(databasename|database)$/i.test(key)) result.database = rest.join("="); + } + return result; + } + + // Oracle thin with service_name: jdbc:oracle:thin:@//host:port/service + const oracleService = url.match(/^oracle:thin:@\/\/([^:/]+)(?::(\d+))?\/([^?]+)/i); + if (oracleService) { + result.host = oracleService[1]; + result.port = getNumber(oracleService[2]); + result.database = oracleService[3]; + result.oracleConnectionType = "service_name"; + return result; + } + + // Oracle thin with SID: jdbc:oracle:thin:@host:port:sid + const oracleSid = url.match(/^oracle:thin:@([^:/]+)(?::(\d+))?:([^?]+)/i); + if (oracleSid) { + result.host = oracleSid[1]; + result.port = getNumber(oracleSid[2]); + result.database = oracleSid[3]; + result.oracleConnectionType = "sid"; + return result; + } + + // SQLite / DuckDB file path: jdbc:sqlite:path/to/file.db + const fileMatch = url.match(/^(sqlite|duckdb):(.+)$/i); + if (fileMatch) { + result.host = expandPathMacros(fileMatch[2].split("?")[0]); + result.database = result.host; + return result; + } + + // BigQuery: jdbc:bigquery://host;ProjectId=xxx + const bigqueryMatch = url.match(/^bigquery:\/\/([^;]+)(?:;(.+))?/i); + if (bigqueryMatch) { + result.host = bigqueryMatch[1]; + for (const part of (bigqueryMatch[2] || "").split(";")) { + const [key, ...rest] = part.split("="); + if (/^(projectid|project)$/i.test(key)) result.database = rest.join("="); + } + return result; + } + + // Generic authority form: jdbc:://[user[:pass]@]host[:port][/database][?params] + const schemeEnd = url.indexOf("://"); + if (schemeEnd === -1) return result; + + let remainder = url.slice(schemeEnd + 3); + remainder = remainder.split("?")[0]; + + const slashIndex = remainder.indexOf("/"); + const authority = (slashIndex >= 0 ? remainder.slice(0, slashIndex) : remainder).split("@").pop() || ""; + const database = slashIndex >= 0 ? remainder.slice(slashIndex + 1) : ""; + + const firstHost = authority.split(",")[0] || authority; + if (firstHost.startsWith("[")) { + const closing = firstHost.indexOf("]"); + if (closing > 0) { + result.host = firstHost.slice(1, closing); + if (firstHost[closing + 1] === ":") result.port = getNumber(firstHost.slice(closing + 2)); + } + } else { + const lastColon = firstHost.lastIndexOf(":"); + if (lastColon > 0) { + result.host = firstHost.slice(0, lastColon); + result.port = getNumber(firstHost.slice(lastColon + 1)); + } else { + result.host = firstHost; + } + } + + result.database = database; + return result; +} + +function extractSubprotocol(jdbcUrl: string): string { + const url = jdbcUrl.trim(); + if (!url.toLowerCase().startsWith("jdbc:")) return ""; + let subprotocol = ""; + for (const char of url.slice(5)) { + if (char === ":" || char === "/") break; + subprotocol += char; + } + return subprotocol; +} + +function inferProfile(driverRef: string, subprotocol: string, driverClass: string, product: string): DriverProfile { + // 1. Try driver-ref prefix (most specific) + const refKey = driverRef.split(".")[0].toLowerCase(); + if (driverRefMap[refKey]) return driverRefMap[refKey]; + + // 2. Try product name from + const productKey = product.toLowerCase(); + for (const [needle, profile] of Object.entries(productMap)) { + if (productKey.includes(needle)) return profile; + } + + // 3. Try JDBC subprotocol + const subKey = subprotocol.toLowerCase(); + if (subprotocolMap[subKey]) return subprotocolMap[subKey]; + + // 4. Try driver class name + const classLower = driverClass.toLowerCase(); + if (classLower.includes("mysql")) return driverRefMap.mysql; + if (classLower.includes("postgres")) return driverRefMap.postgresql; + if (classLower.includes("sqlite")) return driverRefMap.sqlite; + if (classLower.includes("oracle")) return driverRefMap.oracle; + if (classLower.includes("sqlserver") || classLower.includes("mssql")) return driverRefMap.sqlserver; + if (classLower.includes("mongo")) return driverRefMap.mongodb; + if (classLower.includes("redis")) return driverRefMap.redis; + if (classLower.includes("clickhouse")) return driverRefMap.clickhouse; + + // 5. Fallback to JDBC + return { dbType: "jdbc", profile: "jdbc", label: driverClass || "JDBC", port: 0, user: "" }; +} + +// --- XML parsing --- + +function parseDataSourcesXml(xml: string): Map> { + const doc = new DOMParser().parseFromString(xml, "application/xml"); + if (doc.querySelector("parsererror")) return new Map(); + + const result = new Map>(); + const elements = doc.getElementsByTagName("data-source"); + + for (const element of Array.from(elements)) { + const uuidVal = element.getAttribute("uuid"); + if (!uuidVal) continue; + + const fragment: Partial = { + uuid: uuidVal, + name: element.getAttribute("name") || undefined, + }; + + const driverRef = getText(element, "driver-ref"); + if (driverRef) fragment.driverRef = driverRef; + + const jdbcUrl = getText(element, "jdbc-url"); + if (jdbcUrl) fragment.jdbcUrl = jdbcUrl; + + const driverClass = getText(element, "jdbc-driver"); + if (driverClass) fragment.driverClass = driverClass; + + const userName = getText(element, "user-name"); + if (userName) fragment.username = userName; + + const groupName = element.getAttribute("group-name") || undefined; + if (groupName) fragment.groupName = groupName; + + result.set(uuidVal, fragment); + } + + return result; +} + +function parseDataSourcesLocalXml(xml: string): Map> { + const doc = new DOMParser().parseFromString(xml, "application/xml"); + if (doc.querySelector("parsererror")) return new Map(); + + const result = new Map>(); + const elements = doc.getElementsByTagName("data-source"); + + for (const element of Array.from(elements)) { + const uuidVal = element.getAttribute("uuid"); + if (!uuidVal) continue; + + const fragment: Partial = { + uuid: uuidVal, + name: element.getAttribute("name") || undefined, + }; + + const userName = getText(element, "user-name"); + if (userName) fragment.username = userName; + + // Extract product from + const dbInfo = element.getElementsByTagName("database-info")[0]; + const product = dbInfo?.getAttribute("product") || ""; + if (product) fragment.product = product; + + result.set(uuidVal, fragment); + } + + return result; +} + +function mergeFragments( + shared: Map>, + local: Map>, +): DataSourceFragment[] { + const merged = new Map>(); + + // Shared first (has driver-ref, jdbc-url, group-name) + for (const [uuidVal, frag] of shared) { + merged.set(uuidVal, { ...frag }); + } + + // Merge local (has user-name, product — overrides name if present) + for (const [uuidVal, localFrag] of local) { + const existing = merged.get(uuidVal) || { uuid: uuidVal }; + merged.set(uuidVal, { + ...existing, + ...localFrag, + // Keep shared name if local has it too (they're usually the same) + name: existing.name || localFrag.name, + // Keep shared driver-ref and jdbc-url (local doesn't have these) + driverRef: existing.driverRef || localFrag.driverRef, + jdbcUrl: existing.jdbcUrl || localFrag.jdbcUrl, + driverClass: existing.driverClass || localFrag.driverClass, + groupName: existing.groupName || localFrag.groupName, + }); + } + + // Resolve and filter + const resolved: DataSourceFragment[] = []; + for (const frag of merged.values()) { + if (!frag.uuid || !frag.driverRef || !frag.jdbcUrl) continue; + resolved.push({ + uuid: frag.uuid, + name: frag.name || frag.uuid, + driverRef: frag.driverRef, + jdbcUrl: frag.jdbcUrl, + driverClass: frag.driverClass || "", + username: frag.username || "", + product: frag.product || "", + groupName: frag.groupName, + }); + } + + return resolved; +} + +function buildConnection(fragment: DataSourceFragment): ConnectionConfig { + const subprotocol = extractSubprotocol(fragment.jdbcUrl); + const profile = inferProfile(fragment.driverRef, subprotocol, fragment.driverClass, fragment.product); + const parsed = parseJdbcUrl(fragment.jdbcUrl); + + const host = parsed.host || (profile.dbType === "sqlite" ? "" : "127.0.0.1"); + const port = parsed.port || profile.port; + const database = parsed.database || undefined; + const username = fragment.username || profile.user; + const name = fragment.name || database || host || profile.label; + + const partial: PartialConnection = { + name, + db_type: profile.dbType, + driver_profile: profile.profile, + driver_label: profile.label, + url_params: "", + host, + port, + username, + password: "", + database, + color: "", + transport_layers: [], + connect_timeout_secs: 5, + query_timeout_secs: 30, + ssl: false, + oracle_connection_type: profile.dbType === "oracle" ? parsed.oracleConnectionType || "service_name" : undefined, + connection_string: + profile.dbType === "jdbc" || profile.dbType === "mongodb" ? fragment.jdbcUrl.replace(/^jdbc:/i, "") : undefined, + jdbc_driver_class: profile.dbType === "jdbc" ? fragment.driverClass || undefined : undefined, + jdbc_driver_paths: [], + }; + + return { ...partial, id: uuid() }; +} + +// --- Public API --- + +export function isDataGripImportPayload(content: string): boolean { + try { + const parsed = JSON.parse(content); + return parsed?.format === "datagrip-import"; + } catch { + return false; + } +} + +export function parseDataGripConnections(payload: DataGripImportPayload): ConnectionConfig[] { + const shared = parseDataSourcesXml(payload.dataSources); + const local = payload.dataSourcesLocal ? parseDataSourcesLocalXml(payload.dataSourcesLocal) : new Map(); + const fragments = mergeFragments(shared, local); + + const configs: ConnectionConfig[] = []; + const seen = new Set(); + + for (const fragment of fragments) { + const config = buildConnection(fragment); + const key = [config.name, config.db_type, config.host, config.port, config.database || ""].join("\u0000"); + if (seen.has(key)) continue; + seen.add(key); + configs.push(config); + } + + return configs; +} + +/** Returns a map of dedup key (name\0host\0port\0db) → DataGrip UUID for Keychain lookup. */ +export function getDataGripUuidMap(payload: DataGripImportPayload): Map { + const shared = parseDataSourcesXml(payload.dataSources); + const local = payload.dataSourcesLocal ? parseDataSourcesLocalXml(payload.dataSourcesLocal) : new Map(); + const fragments = mergeFragments(shared, local); + + const uuidMap = new Map(); + const seen = new Set(); + + for (const fragment of fragments) { + const profile = inferProfile( + fragment.driverRef, + extractSubprotocol(fragment.jdbcUrl), + fragment.driverClass, + fragment.product, + ); + const parsed = parseJdbcUrl(fragment.jdbcUrl); + const host = parsed.host || (profile.dbType === "sqlite" ? "" : "127.0.0.1"); + const port = parsed.port || profile.port; + const database = parsed.database || ""; + const name = fragment.name || database || host || profile.label; + const dedupKey = [name, host, port, database].join("\u0000"); + if (seen.has(dedupKey)) continue; + seen.add(dedupKey); + uuidMap.set(dedupKey, fragment.uuid); + } + + return uuidMap; +} + +/** Build the macOS Keychain service name for a DataGrip data source UUID. */ +export function datagripKeychainService(uuid: string): string { + return `IntelliJ Platform DB — ${uuid}`; +} diff --git a/apps/desktop/src/lib/http.ts b/apps/desktop/src/lib/http.ts index 9e5af3d7b8..2347f5cf07 100644 --- a/apps/desktop/src/lib/http.ts +++ b/apps/desktop/src/lib/http.ts @@ -177,6 +177,14 @@ export async function loadConnections(): Promise { return get("/api/connection/list"); } +export async function readKeychainPassword(_service: string): Promise { + return ""; // Not available in web backend +} + +export async function readKeychainPasswords(services: string[]): Promise<[string, string][]> { + return services.map((s) => [s, ""]); // Not available in web backend +} + export async function decryptConfig(payload: unknown, passphrase: string): Promise { return post("/api/app-settings/config/decrypt", { payload, passphrase }); } diff --git a/apps/desktop/src/lib/tauri.ts b/apps/desktop/src/lib/tauri.ts index 9f53305bf6..1ad62411d9 100644 --- a/apps/desktop/src/lib/tauri.ts +++ b/apps/desktop/src/lib/tauri.ts @@ -870,6 +870,14 @@ export async function loadConnections(): Promise { return invoke("load_connections"); } +export async function readKeychainPassword(service: string): Promise { + return invoke("read_keychain_password", { service, account: null }); +} + +export async function readKeychainPasswords(services: string[]): Promise<[string, string][]> { + return invoke("read_keychain_passwords", { services }); +} + export async function decryptConfig(payload: unknown, passphrase: string): Promise { const { decryptConfig: decryptConfigPayload } = await import("@/lib/configCrypto"); return decryptConfigPayload(payload as any, passphrase); diff --git a/apps/desktop/src/stores/connectionStore.ts b/apps/desktop/src/stores/connectionStore.ts index 52bde92071..83bb5bdcb5 100644 --- a/apps/desktop/src/stores/connectionStore.ts +++ b/apps/desktop/src/stores/connectionStore.ts @@ -73,7 +73,10 @@ import { useSettingsStore } from "@/stores/settingsStore"; const PINNED_TREE_NODES_STORAGE_KEY = "dbx-pinned-tree-nodes"; const ACTIVE_CONNECTION_STORAGE_KEY = "dbx-active-connection"; -type ImportSource = "dbx" | "navicat" | "dbeaver"; +type ImportSource = "dbx" | "navicat" | "dbeaver" | "datagrip"; + +// Temporary storage for DataGrip import payload (used to read Keychain passwords after import) +let pendingDataGripPayload: { format: "datagrip-import"; dataSources: string; dataSourcesLocal?: string } | null = null; interface TreeClipboardTableStructure { kind: "table-structure"; @@ -2373,8 +2376,60 @@ export const useConnectionStore = defineStore("connection", () => { }; } + async function readDataGripImportFile(): Promise<{ content: string; encrypted: boolean } | null> { + let dataSources: string; + let dataSourcesLocal = ""; + + if (isTauriRuntime()) { + const { open } = await import("@tauri-apps/plugin-dialog"); + const { readTextFile } = await import("@tauri-apps/plugin-fs"); + const path = await open({ + filters: [{ name: "DataGrip dataSources.xml", extensions: ["xml"] }], + multiple: false, + }); + if (!path) return null; + dataSources = await readTextFile(path as string); + // Auto-load dataSources.local.xml from the same directory + const dir = (path as string).replace(/[^/\\]*$/, ""); + try { + dataSourcesLocal = await readTextFile(dir + "dataSources.local.xml"); + } catch { + dataSourcesLocal = ""; + } + } else { + const files = await new Promise((resolve, reject) => { + const input = document.createElement("input"); + input.type = "file"; + input.accept = ".xml"; + input.multiple = true; + input.onchange = () => { + if (!input.files?.length) { + reject(new Error("No file selected")); + return; + } + resolve(input.files); + }; + input.click(); + }); + const fileList = Array.from(files); + const dsFile = fileList.find((f) => /^dataSources\.xml$/i.test(f.name)) || fileList[0]; + const localFile = fileList.find((f) => /^dataSources\.local\.xml$/i.test(f.name)); + if (!dsFile) throw new Error("Select dataSources.xml"); + dataSources = await dsFile.text(); + if (localFile) { + dataSourcesLocal = await localFile.text(); + } + } + + return { + content: JSON.stringify({ format: "datagrip-import", dataSources, dataSourcesLocal }), + encrypted: false, + }; + } + async function readImportFile(source: ImportSource = "dbx"): Promise<{ content: string; encrypted: boolean } | null> { if (source === "dbeaver") return readDbeaverImportFile(); + if (source === "datagrip") return readDataGripImportFile(); let content: string; @@ -2431,7 +2486,16 @@ export const useConnectionStore = defineStore("connection", () => { imported = await parseNavicatConnections(content); } else if (!passphrase) { const { isDbeaverImportPayload, parseDbeaverConnections } = await import("@/lib/dbeaverImport"); - if (isDbeaverImportPayload(content)) { + const { isDataGripImportPayload, parseDataGripConnections } = await import("@/lib/datagripImport"); + if (isDataGripImportPayload(content)) { + const payload = JSON.parse(content) as { + format: "datagrip-import"; + dataSources: string; + dataSourcesLocal?: string; + }; + pendingDataGripPayload = payload; + imported = parseDataGripConnections(payload); + } else if (isDbeaverImportPayload(content)) { imported = await parseDbeaverConnections(content); } else { const parsed = JSON.parse(content); @@ -2484,6 +2548,61 @@ export const useConnectionStore = defineStore("connection", () => { return { count, layout: importedLayout }; } + /** Read macOS Keychain passwords for DataGrip connections and update them. */ + async function applyDataGripKeychainPasswords(): Promise { + const payload = pendingDataGripPayload; + pendingDataGripPayload = null; + if (!payload) return 0; + + try { + const { getDataGripUuidMap, datagripKeychainService } = await import("@/lib/datagripImport"); + // dedupKey → DataGrip UUID + const uuidMap = getDataGripUuidMap(payload); + if (uuidMap.size === 0) return 0; + + // Build service names for batch Keychain read + const dedupKeyToService = new Map(); + const services: string[] = []; + for (const [dedupKey, dgUuid] of uuidMap) { + const service = datagripKeychainService(dgUuid); + dedupKeyToService.set(dedupKey, service); + services.push(service); + } + + // Call Tauri command to read Keychain + const results: [string, string][] = await api.readKeychainPasswords(services); + + // Build service → password map + const passwordByService = new Map(); + for (const [service, password] of results) { + if (password) passwordByService.set(service, password); + } + + // Update connections that have passwords (match by name/host/port) + let filled = 0; + const updated = connections.value.map((conn) => { + const dedupKey = [conn.name, conn.host, conn.port, conn.database || ""].join("\u0000"); + const service = dedupKeyToService.get(dedupKey); + if (!service) return conn; + const password = passwordByService.get(service); + if (password) { + filled++; + return { ...conn, password }; + } + return conn; + }); + + if (filled > 0) { + connections.value = updated; + await persistConnections(); + } + return filled; + } catch (e) { + console.warn("[DataGrip Import] Keychain read failed:", e); + return 0; + } + } + function applySidebarLayout(layout: SidebarLayout) { const reconciledLayout = reconcileLayout( connections.value.map((c) => c.id), @@ -2597,6 +2716,7 @@ export const useConnectionStore = defineStore("connection", () => { exportConnectionsToFile, readImportFile, importConnectionsFromFile, + applyDataGripKeychainPasswords, applySidebarLayout, transferSource, schemaDiffSource, diff --git a/src-tauri/src/commands/keychain.rs b/src-tauri/src/commands/keychain.rs new file mode 100644 index 0000000000..d196d5f920 --- /dev/null +++ b/src-tauri/src/commands/keychain.rs @@ -0,0 +1,51 @@ +use std::process::Command; + +/// Read a macOS Keychain generic password by service name. +/// Triggers a system authorization dialog (Touch ID / password) for each unique service. +#[tauri::command] +pub async fn read_keychain_password(service: String, account: Option) -> Result { + #[cfg(not(target_os = "macos"))] + { + let _ = (service, account); + return Err("Keychain access is only available on macOS".to_string()); + } + + #[cfg(target_os = "macos")] + { + let mut cmd = Command::new("security"); + cmd.args(["find-generic-password", "-s", &service, "-w"]); + if let Some(ref acct) = account { + cmd.args(["-a", acct]); + } + + let output = cmd.output().map_err(|e| format!("Failed to run security command: {e}"))?; + + if output.status.success() { + let password = String::from_utf8_lossy(&output.stdout).trim().to_string(); + Ok(password) + } else { + // Exit code 44 = user cancelled the authorization dialog + let stderr = String::from_utf8_lossy(&output.stderr).to_string(); + if output.status.code() == Some(44) || stderr.contains("User canceled") { + Ok(String::new()) // Return empty instead of error for cancelled + } else if stderr.contains("could not be found") || stderr.contains("The specified item could not be found") + { + Ok(String::new()) // No entry found — not an error + } else { + Err(format!("Keychain read failed: {}", stderr.trim())) + } + } + } +} + +/// Read multiple Keychain passwords in one call. Returns a map of service -> password. +/// Services that fail or are cancelled get an empty string. +#[tauri::command] +pub async fn read_keychain_passwords(services: Vec) -> Result, String> { + let mut results = Vec::with_capacity(services.len()); + for service in services { + let password = read_keychain_password(service.clone(), None).await.unwrap_or_default(); + results.push((service, password)); + } + Ok(results) +} diff --git a/src-tauri/src/commands/mod.rs b/src-tauri/src/commands/mod.rs index 3b42a09fa4..11ba965487 100644 --- a/src-tauri/src/commands/mod.rs +++ b/src-tauri/src/commands/mod.rs @@ -13,6 +13,7 @@ pub mod etcd_cmd; pub mod external_db; pub mod external_sql; pub mod history; +pub mod keychain; pub mod mcp; pub mod mcp_bridge; pub mod mongo_cmd; diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index d6b8c291ac..6056de536f 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -461,6 +461,8 @@ pub fn run() { commands::external_sql::pending_open_sql_files, commands::external_sql::read_external_sql_file, commands::external_db::pending_open_db_files, + commands::keychain::read_keychain_password, + commands::keychain::read_keychain_passwords, commands::deep_link::pending_open_connection_links, commands::table_import::preview_table_import_file, commands::table_import::import_table_file,