feat(import): 添加 DataGrip 连接导入支持 (#936)

- 新增 datagripImport 模块解析 XML 配置
- 支持从 macOS Keychain 自动填充密码
- 添加 Keychain 读取的 Tauri 命令
- 更新多语言翻译文件
- 集成到侧边栏导入流程中
This commit is contained in:
cl1107 2026-06-09 16:45:59 +08:00 committed by GitHub
parent 354d21d086
commit 3995524e5d
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
16 changed files with 691 additions and 6 deletions

View File

@ -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')"
/>
</span>
</TooltipTrigger>

View File

@ -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,
);

View File

@ -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",

View File

@ -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",

View File

@ -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",

View File

@ -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",

View File

@ -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: "导入文件包含连接分组信息,是否一并应用?",

View File

@ -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: "匯入檔案包含連線群組資訊,是否一併套用?",

View File

@ -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");

View File

@ -0,0 +1,472 @@
import type { ConnectionConfig, DatabaseType } from "@/types/database";
import { uuid } from "@/lib/utils";
type PartialConnection = Omit<ConnectionConfig, "id">;
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<string, DriverProfile> = {
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 <database-info product="..."> → dbx profile
const productMap: Record<string, DriverProfile> = {
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<string, DriverProfile> = {
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:<sub>://[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 <database-info>
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<string, Partial<DataSourceFragment>> {
const doc = new DOMParser().parseFromString(xml, "application/xml");
if (doc.querySelector("parsererror")) return new Map();
const result = new Map<string, Partial<DataSourceFragment>>();
const elements = doc.getElementsByTagName("data-source");
for (const element of Array.from(elements)) {
const uuidVal = element.getAttribute("uuid");
if (!uuidVal) continue;
const fragment: Partial<DataSourceFragment> = {
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<string, Partial<DataSourceFragment>> {
const doc = new DOMParser().parseFromString(xml, "application/xml");
if (doc.querySelector("parsererror")) return new Map();
const result = new Map<string, Partial<DataSourceFragment>>();
const elements = doc.getElementsByTagName("data-source");
for (const element of Array.from(elements)) {
const uuidVal = element.getAttribute("uuid");
if (!uuidVal) continue;
const fragment: Partial<DataSourceFragment> = {
uuid: uuidVal,
name: element.getAttribute("name") || undefined,
};
const userName = getText(element, "user-name");
if (userName) fragment.username = userName;
// Extract product from <database-info product="...">
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<string, Partial<DataSourceFragment>>,
local: Map<string, Partial<DataSourceFragment>>,
): DataSourceFragment[] {
const merged = new Map<string, Partial<DataSourceFragment>>();
// 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<string>();
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<string, string> {
const shared = parseDataSourcesXml(payload.dataSources);
const local = payload.dataSourcesLocal ? parseDataSourcesLocalXml(payload.dataSourcesLocal) : new Map();
const fragments = mergeFragments(shared, local);
const uuidMap = new Map<string, string>();
const seen = new Set<string>();
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}`;
}

View File

@ -177,6 +177,14 @@ export async function loadConnections(): Promise<ConnectionConfig[]> {
return get("/api/connection/list");
}
export async function readKeychainPassword(_service: string): Promise<string> {
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<string> {
return post("/api/app-settings/config/decrypt", { payload, passphrase });
}

View File

@ -870,6 +870,14 @@ export async function loadConnections(): Promise<ConnectionConfig[]> {
return invoke("load_connections");
}
export async function readKeychainPassword(service: string): Promise<string> {
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<string> {
const { decryptConfig: decryptConfigPayload } = await import("@/lib/configCrypto");
return decryptConfigPayload(payload as any, passphrase);

View File

@ -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<FileList>((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<number> {
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<string, string>();
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<string, string>();
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,

View File

@ -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<String>) -> Result<String, String> {
#[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<String>) -> Result<Vec<(String, String)>, 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)
}

View File

@ -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;

View File

@ -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,