Compare commits

...

4 Commits

Author SHA1 Message Date
Syngnat
96de46cf1e 🐛 fix(postgres-connection): 修复无postgres库时连接失败并支持默认连接库配置
- PostgreSQL 空 database 时按 postgres、template1、用户名同名库回退连接
- 移除后端对 database=postgres 的硬编码写死逻辑
- 连接弹窗新增 PostgreSQL 默认连接数据库(可选)配置项
- refs #120
2026-02-27 09:49:47 +08:00
Syngnat
7d5592d8d9 feat(db): 数据库连接新增 SOCKS5/HTTP 代理能力并兼容 SRV/SSH 场景
- 后端 ConnectionConfig 增加代理配置并完成规范化处理
- 普通 TCP 数据源通过本地转发接入代理
- MongoDB 使用 Dialer 支持代理连接(含 SRV)
- 前端连接配置新增代理 UI、字段清洗与数据回填
- refs #122
2026-02-27 09:31:24 +08:00
Syngnat
d0ba8822f3 feat(driver-manager): 完善驱动多版本安装与版本级包大小动态展示
- 新增驱动版本列表能力,支持按版本选择安装
- 新增按版本查询安装包大小接口,前端切换版本后动态刷新
- 增加版本大小查询回退策略(tag 未命中时回退 latest)
- 优化版本下拉加载链路并增加后台预热,降低首次展开等待
2026-02-27 08:37:35 +08:00
Syngnat
140db73ef4 🐛 fix(startup-release): 修复 Win/mac 发布包白屏与无响应问题
- 移除 v0.4.7 引入的高风险 chunk 拆分配置
- 恢复 main.tsx 的 Monaco 稳定初始化方式
- 调整 release workflow 的 macOS codesign 参数避免双击无反应
2026-02-26 15:21:36 +08:00
22 changed files with 2260 additions and 241 deletions

View File

@@ -203,7 +203,9 @@ jobs:
APP_NAME=$(basename "$APP_PATH")
echo "🔏 正在进行 Ad-hoc 签名..."
codesign --force --options runtime --deep --sign - "$APP_NAME"
# 注意Ad-hoc + hardened runtime--options runtime在未配置 entitlements 时,
# 可能导致部分 macOS 机型上应用双击无响应。这里保持 Ad-hoc 深签名但禁用 runtime hardened。
codesign --force --deep --sign - "$APP_NAME"
DMG_NAME="${{ matrix.build_name }}.dmg"
FINAL_NAME="GoNavi-$VERSION-${{ matrix.os_name }}-${{ matrix.arch_name }}${{ matrix.artifact_suffix }}.dmg"

View File

@@ -3,79 +3,79 @@
"drivers": {
"mariadb": {
"engine": "go",
"version": "go-embedded",
"version": "1.9.3",
"checksumPolicy": "off",
"downloadUrl": "builtin://activate/mariadb"
},
"diros": {
"engine": "go",
"version": "go-embedded",
"version": "1.9.3",
"checksumPolicy": "off",
"downloadUrl": "builtin://activate/diros"
},
"sphinx": {
"engine": "go",
"version": "go-embedded",
"version": "1.9.3",
"checksumPolicy": "off",
"downloadUrl": "builtin://activate/sphinx"
},
"sqlserver": {
"engine": "go",
"version": "go-embedded",
"version": "1.9.6",
"checksumPolicy": "off",
"downloadUrl": "builtin://activate/sqlserver"
},
"sqlite": {
"engine": "go",
"version": "go-embedded",
"version": "1.44.3",
"checksumPolicy": "off",
"downloadUrl": "builtin://activate/sqlite"
},
"duckdb": {
"engine": "go",
"version": "go-embedded",
"version": "2.5.5",
"checksumPolicy": "off",
"downloadUrl": "builtin://activate/duckdb"
},
"dameng": {
"engine": "go",
"version": "go-embedded",
"version": "1.8.22",
"checksumPolicy": "off",
"downloadUrl": "builtin://activate/dameng"
},
"kingbase": {
"engine": "go",
"version": "go-embedded",
"version": "0.0.0-20201021123113-29bd62a876c3",
"checksumPolicy": "off",
"downloadUrl": "builtin://activate/kingbase"
},
"highgo": {
"engine": "go",
"version": "go-embedded",
"version": "0.0.0-local",
"checksumPolicy": "off",
"downloadUrl": "builtin://activate/highgo"
},
"vastbase": {
"engine": "go",
"version": "go-embedded",
"version": "1.11.1",
"checksumPolicy": "off",
"downloadUrl": "builtin://activate/vastbase"
},
"mongodb": {
"engine": "go",
"version": "go-embedded",
"version": "2.5.0",
"checksumPolicy": "off",
"downloadUrl": "builtin://activate/mongodb"
},
"tdengine": {
"engine": "go",
"version": "go-embedded",
"version": "3.7.8",
"checksumPolicy": "off",
"downloadUrl": "builtin://activate/tdengine"
},
"postgres": {
"engine": "go",
"version": "go-embedded",
"version": "1.11.1",
"checksumPolicy": "off",
"downloadUrl": "builtin://activate/postgres"
}

View File

@@ -3,7 +3,7 @@ import { Modal, Form, Input, InputNumber, Button, message, Checkbox, Divider, Se
import { DatabaseOutlined, ConsoleSqlOutlined, FileTextOutlined, CloudServerOutlined, AppstoreAddOutlined, CloudOutlined, CheckCircleFilled, CloseCircleFilled } from '@ant-design/icons';
import { useStore } from '../store';
import { DBGetDatabases, GetDriverStatusList, MongoDiscoverMembers, TestConnection, RedisConnect, SelectSSHKeyFile } from '../../wailsjs/go/app/App';
import { MongoMemberInfo, SavedConnection } from '../types';
import { ConnectionConfig, MongoMemberInfo, SavedConnection } from '../types';
const { Meta } = Card;
const { Text } = Typography;
@@ -58,6 +58,7 @@ const ConnectionModal: React.FC<{
const [form] = Form.useForm();
const [loading, setLoading] = useState(false);
const [useSSH, setUseSSH] = useState(false);
const [useProxy, setUseProxy] = useState(false);
const [dbType, setDbType] = useState('mysql');
const [step, setStep] = useState(1); // 1: Select Type, 2: Configure
const [activeGroup, setActiveGroup] = useState(0); // Active category index in step 1
@@ -655,6 +656,12 @@ const ConnectionModal: React.FC<{
sshUser: config.ssh?.user,
sshPassword: config.ssh?.password,
sshKeyPath: config.ssh?.keyPath,
useProxy: config.useProxy,
proxyType: config.proxy?.type || 'socks5',
proxyHost: config.proxy?.host,
proxyPort: config.proxy?.port,
proxyUser: config.proxy?.user,
proxyPassword: config.proxy?.password,
driver: config.driver,
dsn: config.dsn,
timeout: config.timeout || 30,
@@ -674,6 +681,7 @@ const ConnectionModal: React.FC<{
mongoReplicaPassword: config.mongoReplicaPassword || ''
});
setUseSSH(config.useSSH || false);
setUseProxy(config.useProxy || false);
setDbType(configType);
// 如果是 Redis 编辑模式,设置已保存的 Redis 数据库列表
if (configType === 'redis') {
@@ -684,6 +692,7 @@ const ConnectionModal: React.FC<{
setStep(1);
form.resetFields();
setUseSSH(false);
setUseProxy(false);
setDbType('mysql');
setActiveGroup(0);
}
@@ -733,6 +742,7 @@ const ConnectionModal: React.FC<{
setLoading(false);
form.resetFields();
setUseSSH(false);
setUseProxy(false);
setDbType('mysql');
setStep(1);
onClose();
@@ -852,7 +862,7 @@ const ConnectionModal: React.FC<{
}
};
const buildConfig = async (values: any, forPersist: boolean) => {
const buildConfig = async (values: any, forPersist: boolean): Promise<ConnectionConfig> => {
const mergedValues = { ...values };
const parsedUriValues = parseUriToValues(mergedValues.uri, mergedValues.type);
const isEmptyField = (value: unknown) => (
@@ -951,6 +961,22 @@ const ConnectionModal: React.FC<{
password: mergedValues.sshPassword || "",
keyPath: mergedValues.sshKeyPath || ""
} : { host: "", port: 22, user: "", password: "", keyPath: "" };
const effectiveUseProxy = !isFileDbType && !!mergedValues.useProxy;
const proxyTypeRaw = String(mergedValues.proxyType || 'socks5').toLowerCase();
const proxyType: 'socks5' | 'http' = proxyTypeRaw === 'http' ? 'http' : 'socks5';
const proxyConfig: NonNullable<ConnectionConfig['proxy']> = effectiveUseProxy ? {
type: proxyType,
host: String(mergedValues.proxyHost || '').trim(),
port: Number(mergedValues.proxyPort || (proxyTypeRaw === 'http' ? 8080 : 1080)),
user: String(mergedValues.proxyUser || '').trim(),
password: mergedValues.proxyPassword || "",
} : {
type: 'socks5',
host: '',
port: 1080,
user: '',
password: '',
};
const keepPassword = !forPersist || savePassword;
@@ -964,6 +990,8 @@ const ConnectionModal: React.FC<{
database: mergedValues.database || "",
useSSH: !!mergedValues.useSSH,
ssh: sshConfig,
useProxy: effectiveUseProxy,
proxy: proxyConfig,
driver: mergedValues.driver,
dsn: mergedValues.dsn,
timeout: Number(mergedValues.timeout || 30),
@@ -997,6 +1025,7 @@ const ConnectionModal: React.FC<{
const defaultPort = getDefaultPortByType(type);
if (isFileDatabaseType(type)) {
setUseSSH(false);
setUseProxy(false);
form.setFieldsValue({
host: '',
port: 0,
@@ -1009,6 +1038,12 @@ const ConnectionModal: React.FC<{
sshUser: '',
sshPassword: '',
sshKeyPath: '',
useProxy: false,
proxyType: 'socks5',
proxyHost: '',
proxyPort: 1080,
proxyUser: '',
proxyPassword: '',
mysqlTopology: 'single',
mongoTopology: 'single',
mongoSrv: false,
@@ -1026,6 +1061,7 @@ const ConnectionModal: React.FC<{
});
} else if (type !== 'custom') {
form.setFieldsValue({
database: '',
port: defaultPort,
mysqlTopology: 'single',
mongoTopology: 'single',
@@ -1164,9 +1200,13 @@ const ConnectionModal: React.FC<{
type: 'mysql',
host: 'localhost',
port: 3306,
database: '',
user: 'root',
useSSH: false,
sshPort: 22,
useProxy: false,
proxyType: 'socks5',
proxyPort: 1080,
timeout: 30,
uri: '',
mysqlTopology: 'single',
@@ -1191,6 +1231,21 @@ const ConnectionModal: React.FC<{
setUriFeedback(null);
}
if (changed.useSSH !== undefined) setUseSSH(changed.useSSH);
if (changed.useProxy !== undefined) setUseProxy(changed.useProxy);
if (changed.proxyType !== undefined) {
const nextType = String(changed.proxyType || 'socks5').toLowerCase();
if (nextType === 'http') {
const currentPort = Number(form.getFieldValue('proxyPort') || 0);
if (!currentPort || currentPort === 1080) {
form.setFieldValue('proxyPort', 8080);
}
} else {
const currentPort = Number(form.getFieldValue('proxyPort') || 0);
if (!currentPort || currentPort === 8080) {
form.setFieldValue('proxyPort', 1080);
}
}
}
// Type change handled by step 1, but keep sync if select changes (hidden now)
if (changed.type !== undefined) setDbType(changed.type);
if (
@@ -1285,6 +1340,16 @@ const ConnectionModal: React.FC<{
)}
</div>
{(dbType === 'postgres' || dbType === 'kingbase' || dbType === 'highgo' || dbType === 'vastbase') && (
<Form.Item
name="database"
label="默认连接数据库(可选)"
help="留空会自动尝试 postgres、template1、与当前用户名同名数据库"
>
<Input placeholder="例如appdb" />
</Form.Item>
)}
{(dbType === 'mysql' || dbType === 'mariadb' || dbType === 'diros' || dbType === 'sphinx') && (
<>
<Form.Item name="mysqlTopology" label="连接模式">
@@ -1531,6 +1596,38 @@ const ConnectionModal: React.FC<{
</div>
)}
<Divider style={{ margin: '12px 0' }} />
<Form.Item name="useProxy" valuePropName="checked" style={{ marginBottom: 0 }}>
<Checkbox>使 (SOCKS5 / HTTP CONNECT)</Checkbox>
</Form.Item>
{useProxy && (
<div style={{ padding: '12px', background: '#f5f5f5', borderRadius: 6, marginTop: 12 }}>
<div style={{ display: 'flex', gap: 16 }}>
<Form.Item name="proxyType" label="代理类型" rules={[{ required: useProxy, message: '请选择代理类型' }]} style={{ width: 180 }}>
<Select options={[
{ value: 'socks5', label: 'SOCKS5' },
{ value: 'http', label: 'HTTP CONNECT' },
]} />
</Form.Item>
<Form.Item name="proxyHost" label="代理主机" rules={[{ required: useProxy, message: '请输入代理主机' }]} style={{ flex: 1 }}>
<Input placeholder="例如: 127.0.0.1 或 proxy.company.com" />
</Form.Item>
<Form.Item name="proxyPort" label="端口" rules={[{ required: useProxy, message: '请输入代理端口' }]} style={{ width: 120 }}>
<InputNumber style={{ width: '100%' }} min={1} max={65535} />
</Form.Item>
</div>
<div style={{ display: 'flex', gap: 16 }}>
<Form.Item name="proxyUser" label="代理用户名(可选)" style={{ flex: 1 }}>
<Input placeholder="留空表示无认证" />
</Form.Item>
<Form.Item name="proxyPassword" label="代理密码(可选)" style={{ flex: 1 }}>
<Input.Password placeholder="留空表示无认证" />
</Form.Item>
</div>
</div>
)}
<Divider style={{ margin: '12px 0' }} />
<Collapse

View File

@@ -1,9 +1,11 @@
import React, { useCallback, useEffect, useMemo, useState } from 'react';
import { Button, Modal, Progress, Space, Table, Tag, Typography, message } from 'antd';
import { Button, Modal, Progress, Select, Space, Table, Tag, Typography, message } from 'antd';
import { DeleteOutlined, DownloadOutlined, ReloadOutlined } from '@ant-design/icons';
import { EventsOn } from '../../wailsjs/runtime/runtime';
import {
DownloadDriverPackage,
GetDriverVersionList,
GetDriverVersionPackageSize,
GetDriverStatusList,
RemoveDriverPackage,
} from '../../wailsjs/go/app/App';
@@ -14,6 +16,8 @@ type DriverStatusRow = {
type: string;
name: string;
builtIn: boolean;
pinnedVersion?: string;
installedVersion?: string;
packageSizeText?: string;
runtimeAvailable: boolean;
packageInstalled: boolean;
@@ -35,12 +39,75 @@ type ProgressState = {
percent: number;
};
type DriverVersionOption = {
version: string;
downloadUrl: string;
packageSizeText?: string;
recommended?: boolean;
source?: string;
year?: string;
displayLabel?: string;
};
const buildVersionOptionKey = (option: DriverVersionOption) => `${option.version}@@${option.downloadUrl}`;
const buildVersionSizeLoadingKey = (driverType: string, optionKey: string) => `${driverType}@@${optionKey}`;
const buildVersionSelectOptions = (options: DriverVersionOption[]) => {
type SelectOption = { value: string; label: string };
type SelectGroup = { label: string; options: SelectOption[] };
if (options.length === 0) {
return [] as Array<SelectOption | SelectGroup>;
}
const yearGroups = new Map<string, SelectOption[]>();
const others: SelectOption[] = [];
options.forEach((option) => {
const selectOption: SelectOption = {
value: buildVersionOptionKey(option),
label: option.displayLabel || option.version || '默认版本',
};
const year = String(option.year || '').trim();
if (!year) {
others.push(selectOption);
return;
}
const group = yearGroups.get(year) || [];
group.push(selectOption);
yearGroups.set(year, group);
});
const sortedYears = Array.from(yearGroups.keys()).sort((a, b) => {
const left = Number.parseInt(a, 10);
const right = Number.parseInt(b, 10);
const leftValid = Number.isFinite(left);
const rightValid = Number.isFinite(right);
if (leftValid && rightValid) {
return right - left;
}
return b.localeCompare(a);
});
const grouped: SelectGroup[] = sortedYears.map((year) => ({
label: `${year}`,
options: yearGroups.get(year) || [],
}));
if (others.length > 0) {
grouped.push({ label: '其他', options: others });
}
return grouped;
};
const DriverManagerModal: React.FC<{ open: boolean; onClose: () => void }> = ({ open, onClose }) => {
const [loading, setLoading] = useState(false);
const [downloadDir, setDownloadDir] = useState('');
const [rows, setRows] = useState<DriverStatusRow[]>([]);
const [actionDriver, setActionDriver] = useState('');
const [progressMap, setProgressMap] = useState<Record<string, ProgressState>>({});
const [versionMap, setVersionMap] = useState<Record<string, DriverVersionOption[]>>({});
const [selectedVersionMap, setSelectedVersionMap] = useState<Record<string, string>>({});
const [versionLoadingMap, setVersionLoadingMap] = useState<Record<string, boolean>>({});
const [versionSizeLoadingMap, setVersionSizeLoadingMap] = useState<Record<string, boolean>>({});
const refreshStatus = useCallback(async (toastOnError = true) => {
setLoading(true);
@@ -65,6 +132,8 @@ const DriverManagerModal: React.FC<{ open: boolean; onClose: () => void }> = ({
type: String(item.type || '').trim(),
name: String(item.name || item.type || '').trim(),
builtIn: !!item.builtIn,
pinnedVersion: String(item.pinnedVersion || '').trim() || undefined,
installedVersion: String(item.installedVersion || '').trim() || undefined,
packageSizeText: String(item.packageSizeText || '').trim() || undefined,
runtimeAvailable: !!item.runtimeAvailable,
packageInstalled: !!item.packageInstalled,
@@ -82,6 +151,155 @@ const DriverManagerModal: React.FC<{ open: boolean; onClose: () => void }> = ({
}
}, [downloadDir]);
const loadVersionOptions = useCallback(async (row: DriverStatusRow, toastOnError = false) => {
if (row.builtIn) {
return [] as DriverVersionOption[];
}
const driverType = String(row.type || '').trim();
if (!driverType) {
return [] as DriverVersionOption[];
}
setVersionLoadingMap((prev) => ({ ...prev, [driverType]: true }));
try {
const res = await GetDriverVersionList(driverType, '');
if (!res?.success) {
if (toastOnError) {
message.error(res?.message || `${row.name} 版本列表加载失败`);
}
return [] as DriverVersionOption[];
}
const data = (res?.data || {}) as any;
const rawVersions = Array.isArray(data.versions) ? data.versions : [];
const options: DriverVersionOption[] = rawVersions
.map((item: any) => {
const version = String(item.version || '').trim();
const downloadUrl = String(item.downloadUrl || '').trim();
if (!version && !downloadUrl) {
return null;
}
return {
version,
downloadUrl,
packageSizeText: String(item.packageSizeText || '').trim() || undefined,
recommended: !!item.recommended,
source: String(item.source || '').trim() || undefined,
year: String(item.year || '').trim() || undefined,
displayLabel: String(item.displayLabel || '').trim() || undefined,
} as DriverVersionOption;
})
.filter((item: DriverVersionOption | null): item is DriverVersionOption => !!item);
if (options.length === 0) {
const fallbackVersion = String(row.pinnedVersion || '').trim();
const fallbackURL = String(row.defaultDownloadUrl || '').trim();
if (fallbackVersion || fallbackURL) {
options.push({
version: fallbackVersion,
downloadUrl: fallbackURL,
recommended: true,
source: 'fallback',
displayLabel: fallbackVersion || '默认版本',
});
}
}
setVersionMap((prev) => ({ ...prev, [driverType]: options }));
setSelectedVersionMap((prev) => {
const currentKey = prev[driverType];
if (currentKey && options.some((option) => buildVersionOptionKey(option) === currentKey)) {
return prev;
}
const preferred =
options.find((option) => option.version === row.installedVersion) ||
options.find((option) => option.version === row.pinnedVersion) ||
options.find((option) => option.recommended) ||
options[0];
if (!preferred) {
return prev;
}
return { ...prev, [driverType]: buildVersionOptionKey(preferred) };
});
return options;
} catch (err: any) {
if (toastOnError) {
message.error(`加载 ${row.name} 版本列表失败:${err?.message || String(err)}`);
}
return [] as DriverVersionOption[];
} finally {
setVersionLoadingMap((prev) => ({ ...prev, [driverType]: false }));
}
}, []);
const loadVersionPackageSize = useCallback(async (row: DriverStatusRow, optionKey: string) => {
if (row.builtIn) {
return;
}
const driverType = String(row.type || '').trim();
if (!driverType || !optionKey) {
return;
}
const options = versionMap[driverType] || [];
const selectedOption = options.find((item) => buildVersionOptionKey(item) === optionKey);
if (!selectedOption) {
return;
}
if (String(selectedOption.packageSizeText || '').trim()) {
return;
}
const versionText = String(selectedOption.version || '').trim();
if (!versionText) {
return;
}
const loadingKey = buildVersionSizeLoadingKey(driverType, optionKey);
if (versionSizeLoadingMap[loadingKey]) {
return;
}
setVersionSizeLoadingMap((prev) => ({ ...prev, [loadingKey]: true }));
try {
const res = await GetDriverVersionPackageSize(driverType, versionText);
if (!res?.success) {
return;
}
const data = (res?.data || {}) as any;
const sizeText = String(data.packageSizeText || '').trim();
if (!sizeText) {
return;
}
setVersionMap((prev) => {
const current = prev[driverType] || [];
let changed = false;
const next = current.map((item) => {
if (buildVersionOptionKey(item) !== optionKey) {
return item;
}
if (String(item.packageSizeText || '').trim() === sizeText) {
return item;
}
changed = true;
return { ...item, packageSizeText: sizeText };
});
if (!changed) {
return prev;
}
return { ...prev, [driverType]: next };
});
} finally {
setVersionSizeLoadingMap((prev) => {
if (!prev[loadingKey]) {
return prev;
}
const next = { ...prev };
delete next[loadingKey];
return next;
});
}
}, [versionMap, versionSizeLoadingMap]);
useEffect(() => {
if (!open) {
return;
@@ -129,17 +347,30 @@ const DriverManagerModal: React.FC<{ open: boolean; onClose: () => void }> = ({
},
}));
try {
const result = await DownloadDriverPackage(row.type, '', downloadDir);
let options = versionMap[row.type] || [];
if (options.length === 0) {
options = await loadVersionOptions(row, true);
}
const selectedKey = selectedVersionMap[row.type];
const selectedOption =
options.find((item) => buildVersionOptionKey(item) === selectedKey) ||
options.find((item) => item.recommended) ||
options[0];
const selectedVersion = selectedOption?.version || row.pinnedVersion || '';
const selectedDownloadURL = selectedOption?.downloadUrl || row.defaultDownloadUrl || '';
const result = await DownloadDriverPackage(row.type, selectedVersion, selectedDownloadURL, downloadDir);
if (!result?.success) {
message.error(result?.message || `安装 ${row.name} 失败`);
return;
}
message.success(`${row.name} 已安装启用`);
const versionTip = selectedVersion ? `${selectedVersion}` : '';
message.success(`${row.name}${versionTip} 已安装启用`);
refreshStatus(false);
} finally {
setActionDriver('');
}
}, [downloadDir, refreshStatus]);
}, [downloadDir, loadVersionOptions, refreshStatus, selectedVersionMap, versionMap]);
const removeDriver = useCallback(async (row: DriverStatusRow) => {
setActionDriver(row.type);
@@ -174,7 +405,23 @@ const DriverManagerModal: React.FC<{ open: boolean; onClose: () => void }> = ({
dataIndex: 'packageSizeText',
key: 'packageSizeText',
width: 120,
render: (_: string | undefined, row: DriverStatusRow) => row.packageSizeText || '-',
render: (_: string | undefined, row: DriverStatusRow) => {
if (row.builtIn) {
return row.packageSizeText || '-';
}
const options = versionMap[row.type] || [];
const selectedKey = selectedVersionMap[row.type];
const loadingKey = buildVersionSizeLoadingKey(row.type, selectedKey || '');
const selectedOption =
options.find((item) => buildVersionOptionKey(item) === selectedKey) ||
options.find((item) => item.recommended) ||
options[0];
const anyKnownSize = options.find((item) => String(item.packageSizeText || '').trim())?.packageSizeText;
if (selectedKey && versionSizeLoadingMap[loadingKey]) {
return '计算中...';
}
return selectedOption?.packageSizeText || anyKnownSize || row.packageSizeText || '-';
},
},
{
title: '状态',
@@ -224,6 +471,43 @@ const DriverManagerModal: React.FC<{ open: boolean; onClose: () => void }> = ({
return <Progress percent={percent} status={status} size="small" />;
},
},
{
title: '驱动版本',
key: 'driverVersion',
width: 230,
render: (_: string, row: DriverStatusRow) => {
if (row.builtIn) {
return <Text type="secondary">-</Text>;
}
const options = versionMap[row.type] || [];
const selectedKey = selectedVersionMap[row.type];
const selectOptions = buildVersionSelectOptions(options);
return (
<Select
size="small"
style={{ width: '100%' }}
loading={!!versionLoadingMap[row.type]}
disabled={actionDriver === row.type}
placeholder={options.length > 0 ? '选择驱动版本' : '点击展开加载版本'}
value={selectedKey}
options={selectOptions as any}
onOpenChange={(open) => {
if (open && options.length === 0 && !versionLoadingMap[row.type]) {
void loadVersionOptions(row, true);
return;
}
if (open && selectedKey) {
void loadVersionPackageSize(row, selectedKey);
}
}}
onChange={(value) => {
setSelectedVersionMap((prev) => ({ ...prev, [row.type]: value }));
void loadVersionPackageSize(row, value);
}}
/>
);
},
},
{
title: '操作',
key: 'actions',
@@ -262,7 +546,7 @@ const DriverManagerModal: React.FC<{ open: boolean; onClose: () => void }> = ({
},
},
];
}, [actionDriver, installDriver, progressMap, removeDriver]);
}, [actionDriver, installDriver, loadVersionOptions, loadVersionPackageSize, progressMap, removeDriver, selectedVersionMap, versionLoadingMap, versionMap, versionSizeLoadingMap]);
return (
<Modal

View File

@@ -6,21 +6,7 @@ import App from './App'
// 全局配置 Monaco Editor 使用本地打包的文件,避免从 CDN (jsdelivr) 加载。
// Windows WebView2 环境下访问外部 CDN 可能失败,导致编辑器一直显示 Loading。
import { loader } from '@monaco-editor/react'
import * as monaco from 'monaco-editor/esm/vs/editor/editor.api.js'
import EditorWorker from 'monaco-editor/esm/vs/editor/editor.worker.js?worker'
import JsonWorker from 'monaco-editor/esm/vs/language/json/json.worker.js?worker'
import 'monaco-editor/esm/vs/basic-languages/sql/sql.contribution.js'
import 'monaco-editor/esm/vs/language/json/monaco.contribution.js'
(self as any).MonacoEnvironment = {
getWorker(_: unknown, label: string) {
if (label === 'json') {
return new JsonWorker()
}
return new EditorWorker()
},
}
import * as monaco from 'monaco-editor'
loader.config({ monaco })
// 全局注册透明主题,避免每个 Editor 组件 beforeMount 中重复定义

View File

@@ -155,6 +155,16 @@ const sanitizeConnectionConfig = (value: unknown): ConnectionConfig => {
password: toTrimmedString(sshRaw.password),
keyPath: toTrimmedString(sshRaw.keyPath),
};
const proxyRaw = (raw.proxy && typeof raw.proxy === 'object') ? raw.proxy as Record<string, unknown> : {};
const proxyTypeRaw = toTrimmedString(proxyRaw.type, 'socks5').toLowerCase();
const proxyType: 'socks5' | 'http' = proxyTypeRaw === 'http' ? 'http' : 'socks5';
const proxy = {
type: proxyType,
host: toTrimmedString(proxyRaw.host),
port: normalizePort(proxyRaw.port, proxyTypeRaw === 'http' ? 8080 : 1080),
user: toTrimmedString(proxyRaw.user),
password: toTrimmedString(proxyRaw.password),
};
const safeConfig: ConnectionConfig & Record<string, unknown> = {
...raw,
@@ -167,6 +177,8 @@ const sanitizeConnectionConfig = (value: unknown): ConnectionConfig => {
database: toTrimmedString(raw.database),
useSSH: !!raw.useSSH,
ssh,
useProxy: !!raw.useProxy,
proxy,
uri: toTrimmedString(raw.uri).slice(0, MAX_URI_LENGTH),
hosts: sanitizeAddressList(raw.hosts),
topology: raw.topology === 'replica' ? 'replica' : 'single',

View File

@@ -6,6 +6,14 @@ export interface SSHConfig {
keyPath?: string;
}
export interface ProxyConfig {
type: 'socks5' | 'http';
host: string;
port: number;
user?: string;
password?: string;
}
export interface ConnectionConfig {
type: string;
host: string;
@@ -16,6 +24,8 @@ export interface ConnectionConfig {
database?: string;
useSSH?: boolean;
ssh?: SSHConfig;
useProxy?: boolean;
proxy?: ProxyConfig;
driver?: string;
dsn?: string;
timeout?: number;

View File

@@ -1,54 +1,6 @@
import { defineConfig } from 'vite'
import react from '@vitejs/plugin-react'
const normalizeModuleId = (id: string): string => id.replace(/\\/g, '/')
const sanitizeChunkToken = (raw: string): string =>
String(raw || '')
.trim()
.replace(/[^a-zA-Z0-9_-]/g, '-')
.replace(/-+/g, '-')
.replace(/^-|-$/g, '') || 'misc'
const firstSegmentAfter = (id: string, marker: string): string => {
const idx = id.indexOf(marker)
if (idx < 0) return ''
const rest = id.substring(idx + marker.length)
const [segment] = rest.split('/')
return sanitizeChunkToken(segment)
}
const resolveMonacoChunk = (id: string, prefix: string): string | undefined => {
if (!id.includes('/node_modules/monaco-editor/')) return undefined
if (id.includes('/esm/vs/language/typescript/')) {
if (id.includes('typescriptServices')) return `${prefix}-ts-services`
return `${prefix}-typescript`
}
if (id.includes('/esm/vs/language/json/')) return `${prefix}-json`
if (id.includes('/esm/vs/language/css/')) return `${prefix}-css`
if (id.includes('/esm/vs/language/html/')) return `${prefix}-html`
if (id.includes('/esm/vs/editor/contrib/')) {
return `${prefix}-editor-contrib-${firstSegmentAfter(id, '/esm/vs/editor/contrib/')}`
}
if (id.includes('/esm/vs/editor/browser/')) {
return `${prefix}-editor-browser-${firstSegmentAfter(id, '/esm/vs/editor/browser/')}`
}
if (id.includes('/esm/vs/editor/common/')) {
return `${prefix}-editor-common-${firstSegmentAfter(id, '/esm/vs/editor/common/')}`
}
if (id.includes('/esm/vs/editor/')) return `${prefix}-editor`
if (id.includes('/esm/vs/base/browser/')) return `${prefix}-base-browser`
if (id.includes('/esm/vs/base/common/')) return `${prefix}-base-common`
if (id.includes('/esm/vs/base/')) return `${prefix}-base`
if (id.includes('/esm/vs/platform/')) return `${prefix}-platform`
return `${prefix}-misc`
}
// https://vitejs.dev/config/
export default defineConfig({
plugins: [react()],
@@ -59,61 +11,5 @@ export default defineConfig({
build: {
outDir: 'dist', // Standard Wails output directory
emptyOutDir: true,
rollupOptions: {
output: {
manualChunks(id) {
const moduleId = normalizeModuleId(id)
if (!moduleId.includes('node_modules')) return undefined
const monacoChunk = resolveMonacoChunk(moduleId, 'vendor-monaco')
if (monacoChunk) {
return monacoChunk
}
if (moduleId.includes('/node_modules/@monaco-editor/react/')) return 'vendor-monaco-react'
if (moduleId.includes('/node_modules/antd/es/')) {
return `vendor-antd-${firstSegmentAfter(moduleId, '/node_modules/antd/es/')}`
}
if (moduleId.includes('/node_modules/antd/')) return 'vendor-antd'
if (moduleId.includes('/node_modules/@ant-design/icons/')) return 'vendor-antd-icons'
if (moduleId.includes('/node_modules/@ant-design/cssinjs/')) return 'vendor-antd-css'
if (moduleId.includes('/node_modules/rc-')) return 'vendor-antd-rc'
if (moduleId.includes('/node_modules/@dnd-kit/')) return 'vendor-dnd-kit'
if (moduleId.includes('/node_modules/sql-formatter/')) return 'vendor-sql-formatter'
if (
moduleId.includes('/node_modules/react/')
|| moduleId.includes('/node_modules/react-dom/')
|| moduleId.includes('/node_modules/scheduler/')
) {
return 'vendor-react'
}
if (
moduleId.includes('/node_modules/zustand/')
|| moduleId.includes('/node_modules/uuid/')
|| moduleId.includes('/node_modules/clsx/')
|| moduleId.includes('/node_modules/react-resizable/')
) {
return 'vendor-utils'
}
return 'vendor-misc'
},
},
},
},
worker: {
format: 'es',
rollupOptions: {
output: {
manualChunks(id) {
const moduleId = normalizeModuleId(id)
if (!moduleId.includes('node_modules')) return undefined
return resolveMonacoChunk(moduleId, 'worker-monaco')
},
},
},
},
}
})

View File

@@ -38,7 +38,7 @@ export function DataSyncAnalyze(arg1:sync.SyncConfig):Promise<connection.QueryRe
export function DataSyncPreview(arg1:sync.SyncConfig,arg2:string,arg3:number):Promise<connection.QueryResult>;
export function DownloadDriverPackage(arg1:string,arg2:string,arg3:string):Promise<connection.QueryResult>;
export function DownloadDriverPackage(arg1:string,arg2:string,arg3:string,arg4:string):Promise<connection.QueryResult>;
export function DownloadUpdate():Promise<connection.QueryResult>;
@@ -66,6 +66,10 @@ export function GetAppInfo():Promise<connection.QueryResult>;
export function GetDriverStatusList(arg1:string,arg2:string):Promise<connection.QueryResult>;
export function GetDriverVersionList(arg1:string,arg2:string):Promise<connection.QueryResult>;
export function GetDriverVersionPackageSize(arg1:string,arg2:string):Promise<connection.QueryResult>;
export function ImportConfigFile():Promise<connection.QueryResult>;
export function ImportData(arg1:connection.ConnectionConfig,arg2:string,arg3:string):Promise<connection.QueryResult>;

View File

@@ -70,8 +70,8 @@ export function DataSyncPreview(arg1, arg2, arg3) {
return window['go']['app']['App']['DataSyncPreview'](arg1, arg2, arg3);
}
export function DownloadDriverPackage(arg1, arg2, arg3) {
return window['go']['app']['App']['DownloadDriverPackage'](arg1, arg2, arg3);
export function DownloadDriverPackage(arg1, arg2, arg3, arg4) {
return window['go']['app']['App']['DownloadDriverPackage'](arg1, arg2, arg3, arg4);
}
export function DownloadUpdate() {
@@ -126,6 +126,14 @@ export function GetDriverStatusList(arg1, arg2) {
return window['go']['app']['App']['GetDriverStatusList'](arg1, arg2);
}
export function GetDriverVersionList(arg1, arg2) {
return window['go']['app']['App']['GetDriverVersionList'](arg1, arg2);
}
export function GetDriverVersionPackageSize(arg1, arg2) {
return window['go']['app']['App']['GetDriverVersionPackageSize'](arg1, arg2);
}
export function ImportConfigFile() {
return window['go']['app']['App']['ImportConfigFile']();
}

View File

@@ -48,6 +48,26 @@ export namespace connection {
return a;
}
}
export class ProxyConfig {
type: string;
host: string;
port: number;
user?: string;
password?: string;
static createFrom(source: any = {}) {
return new ProxyConfig(source);
}
constructor(source: any = {}) {
if ('string' === typeof source) source = JSON.parse(source);
this.type = source["type"];
this.host = source["host"];
this.port = source["port"];
this.user = source["user"];
this.password = source["password"];
}
}
export class SSHConfig {
host: string;
port: number;
@@ -78,6 +98,8 @@ export namespace connection {
database: string;
useSSH: boolean;
ssh: SSHConfig;
useProxy?: boolean;
proxy?: ProxyConfig;
driver?: string;
dsn?: string;
timeout?: number;
@@ -110,6 +132,8 @@ export namespace connection {
this.database = source["database"];
this.useSSH = source["useSSH"];
this.ssh = this.convertValues(source["ssh"], SSHConfig);
this.useProxy = source["useProxy"];
this.proxy = this.convertValues(source["proxy"], ProxyConfig);
this.driver = source["driver"];
this.dsn = source["dsn"];
this.timeout = source["timeout"];
@@ -146,6 +170,7 @@ export namespace connection {
return a;
}
}
export class QueryResult {
success: boolean;
message: string;

4
go.mod
View File

@@ -17,6 +17,8 @@ require (
github.com/xuri/excelize/v2 v2.10.0
go.mongodb.org/mongo-driver/v2 v2.5.0
golang.org/x/crypto v0.47.0
golang.org/x/mod v0.32.0
golang.org/x/net v0.49.0
golang.org/x/text v0.33.0
modernc.org/sqlite v1.44.3
)
@@ -83,8 +85,6 @@ require (
github.com/youmark/pkcs8 v0.0.0-20240726163527-a2c0da244d78 // indirect
github.com/zeebo/xxh3 v1.1.0 // indirect
golang.org/x/exp v0.0.0-20260112195511-716be5621a96 // indirect
golang.org/x/mod v0.32.0 // indirect
golang.org/x/net v0.49.0 // indirect
golang.org/x/sync v0.19.0 // indirect
golang.org/x/sys v0.40.0 // indirect
golang.org/x/telemetry v0.0.0-20260116145544-c6413dc483f5 // indirect

View File

@@ -15,6 +15,7 @@ import (
"GoNavi-Wails/internal/connection"
"GoNavi-Wails/internal/db"
"GoNavi-Wails/internal/logger"
proxytunnel "GoNavi-Wails/internal/proxy"
)
const dbCachePingInterval = 30 * time.Second
@@ -66,6 +67,7 @@ func (a *App) Shutdown(ctx context.Context) {
logger.Error(err, "关闭数据库连接失败")
}
}
proxytunnel.CloseAllForwarders()
// Close all Redis connections
CloseAllRedisClients()
logger.Infof("资源释放完成,应用已关闭")
@@ -77,9 +79,8 @@ func getCacheKey(config connection.ConnectionConfig) string {
if !config.UseSSH {
config.SSH = connection.SSHConfig{}
}
// 保持与驱动默认一致,避免同一连接被重复缓存
if config.Type == "postgres" && config.Database == "" {
config.Database = "postgres"
if !config.UseProxy {
config.Proxy = connection.ProxyConfig{}
}
b, _ := json.Marshal(config)
@@ -175,6 +176,12 @@ func formatConnSummary(config connection.ConnectionConfig) string {
if config.UseSSH {
b.WriteString(fmt.Sprintf(" SSH=%s:%d 用户=%s", config.SSH.Host, config.SSH.Port, config.SSH.User))
}
if config.UseProxy {
b.WriteString(fmt.Sprintf(" 代理=%s://%s:%d", strings.ToLower(strings.TrimSpace(config.Proxy.Type)), config.Proxy.Host, config.Proxy.Port))
if strings.TrimSpace(config.Proxy.User) != "" {
b.WriteString(" 代理认证=已配置")
}
}
if config.Type == "custom" {
driver := strings.TrimSpace(config.Driver)
@@ -269,7 +276,14 @@ func (a *App) getDatabaseWithPing(config connection.ConnectionConfig, forcePing
return nil, err
}
if err := dbInst.Connect(config); err != nil {
connectConfig, proxyErr := resolveDialConfigWithProxy(config)
if proxyErr != nil {
wrapped := wrapConnectError(config, proxyErr)
logger.Error(wrapped, "连接代理准备失败:%s 缓存Key=%s", formatConnSummary(config), shortKey)
return nil, wrapped
}
if err := dbInst.Connect(connectConfig); err != nil {
wrapped := wrapConnectError(config, err)
logger.Error(wrapped, "建立数据库连接失败:%s 缓存Key=%s", formatConnSummary(config), shortKey)
return nil, wrapped

202
internal/app/db_proxy.go Normal file
View File

@@ -0,0 +1,202 @@
package app
import (
"fmt"
"net"
"strconv"
"strings"
"GoNavi-Wails/internal/connection"
proxytunnel "GoNavi-Wails/internal/proxy"
)
func resolveDialConfigWithProxy(raw connection.ConnectionConfig) (connection.ConnectionConfig, error) {
config := raw
if !config.UseProxy {
config.Proxy = connection.ProxyConfig{}
return config, nil
}
normalizedProxy, err := proxytunnel.NormalizeConfig(config.Proxy)
if err != nil {
return connection.ConnectionConfig{}, err
}
config.Proxy = normalizedProxy
if config.UseSSH {
sshPort := config.SSH.Port
if sshPort <= 0 {
sshPort = 22
}
forwardedSSH, err := buildProxyForwardAddress(normalizedProxy, strings.TrimSpace(config.SSH.Host), sshPort)
if err != nil {
return connection.ConnectionConfig{}, fmt.Errorf("代理连接 SSH 网关失败:%w", err)
}
config.SSH.Host = forwardedSSH.host
config.SSH.Port = forwardedSSH.port
config.UseProxy = false
config.Proxy = connection.ProxyConfig{}
return config, nil
}
normalizedType := strings.ToLower(strings.TrimSpace(config.Type))
if normalizedType == "sqlite" || normalizedType == "duckdb" || normalizedType == "custom" {
// 文件型/自定义 DSN 类型不走标准 host:port不在此层改写。
return config, nil
}
if normalizedType == "mongodb" && config.MongoSRV {
// Mongo SRV 由驱动侧 Dialer 处理代理,避免破坏 DNS SRV 拓扑发现。
return config, nil
}
targetPort := config.Port
if targetPort <= 0 {
targetPort = defaultPortByType(normalizedType)
}
forwardedPrimary, err := buildProxyForwardAddress(normalizedProxy, strings.TrimSpace(config.Host), targetPort)
if err != nil {
return connection.ConnectionConfig{}, err
}
config.Host = forwardedPrimary.host
config.Port = forwardedPrimary.port
if len(config.Hosts) > 0 {
rewritten := make([]string, 0, len(config.Hosts))
seen := make(map[string]struct{}, len(config.Hosts))
for _, rawEntry := range config.Hosts {
targetHost, targetPort, ok := parseAddressWithDefaultPort(rawEntry, defaultPortByType(normalizedType))
if !ok {
continue
}
forwarded, forwardErr := buildProxyForwardAddress(normalizedProxy, targetHost, targetPort)
if forwardErr != nil {
return connection.ConnectionConfig{}, forwardErr
}
rewrittenAddress := formatHostPort(forwarded.host, forwarded.port)
if _, exists := seen[rewrittenAddress]; exists {
continue
}
seen[rewrittenAddress] = struct{}{}
rewritten = append(rewritten, rewrittenAddress)
}
config.Hosts = rewritten
}
config.UseProxy = false
config.Proxy = connection.ProxyConfig{}
return config, nil
}
type hostPort struct {
host string
port int
}
func buildProxyForwardAddress(proxyConfig connection.ProxyConfig, targetHost string, targetPort int) (hostPort, error) {
host := strings.TrimSpace(targetHost)
if host == "" {
host = "localhost"
}
port := targetPort
if port <= 0 {
return hostPort{}, fmt.Errorf("目标端口无效:%d", targetPort)
}
forwarder, err := proxytunnel.GetOrCreateLocalForwarder(proxyConfig, host, port)
if err != nil {
return hostPort{}, err
}
localHost, localPort, splitOK := parseAddressWithDefaultPort(forwarder.LocalAddr, 0)
if !splitOK || localPort <= 0 {
return hostPort{}, fmt.Errorf("解析代理本地转发地址失败:%s", forwarder.LocalAddr)
}
return hostPort{host: localHost, port: localPort}, nil
}
func parseAddressWithDefaultPort(raw string, defaultPort int) (string, int, bool) {
text := strings.TrimSpace(raw)
if text == "" {
return "", 0, false
}
if strings.HasPrefix(text, "[") {
if host, portText, err := net.SplitHostPort(text); err == nil {
if port, convErr := strconv.Atoi(portText); convErr == nil && port > 0 && port <= 65535 {
return strings.TrimSpace(host), port, true
}
return "", 0, false
}
trimmed := strings.Trim(strings.TrimPrefix(text, "["), "]")
if trimmed != "" && defaultPort > 0 {
return trimmed, defaultPort, true
}
return "", 0, false
}
if strings.Count(text, ":") == 0 {
if defaultPort <= 0 {
return "", 0, false
}
return text, defaultPort, true
}
if strings.Count(text, ":") == 1 {
host, portText, err := net.SplitHostPort(text)
if err == nil {
port, convErr := strconv.Atoi(portText)
if convErr == nil && port > 0 && port <= 65535 {
return strings.TrimSpace(host), port, true
}
return "", 0, false
}
if defaultPort > 0 {
return strings.TrimSpace(text), defaultPort, true
}
return "", 0, false
}
// IPv6 地址未带端口,使用默认端口。
if defaultPort > 0 {
return text, defaultPort, true
}
return "", 0, false
}
func formatHostPort(host string, port int) string {
h := strings.TrimSpace(host)
if strings.Contains(h, ":") && !strings.HasPrefix(h, "[") {
return fmt.Sprintf("[%s]:%d", h, port)
}
return fmt.Sprintf("%s:%d", h, port)
}
func defaultPortByType(driverType string) int {
switch strings.ToLower(strings.TrimSpace(driverType)) {
case "mysql", "mariadb":
return 3306
case "diros":
return 9030
case "sphinx":
return 9306
case "postgres", "vastbase":
return 5432
case "redis":
return 6379
case "tdengine":
return 6041
case "oracle":
return 1521
case "dameng":
return 5236
case "kingbase":
return 54321
case "sqlserver":
return 1433
case "mongodb":
return 27017
case "highgo":
return 5866
default:
return 0
}
}

View File

@@ -190,9 +190,6 @@ func (a *App) RenameDatabase(config connection.ConnectionConfig, oldName string,
return connection.QueryResult{Success: false, Message: "当前连接正在使用目标数据库,请先连接到其他数据库后再重命名"}
}
runConfig := config
if strings.TrimSpace(runConfig.Database) == "" {
runConfig.Database = "postgres"
}
dbInst, err := a.getDatabase(runConfig)
if err != nil {
return connection.QueryResult{Success: false, Message: err.Error()}
@@ -228,9 +225,6 @@ func (a *App) DropDatabase(config connection.ConnectionConfig, dbName string) co
return connection.QueryResult{Success: false, Message: "当前连接正在使用目标数据库,请先连接到其他数据库后再删除"}
}
runConfig = config
if strings.TrimSpace(runConfig.Database) == "" {
runConfig.Database = "postgres"
}
sql = fmt.Sprintf("DROP DATABASE %s", quoteIdentByType(dbType, dbName))
default:
return connection.QueryResult{Success: false, Message: fmt.Sprintf("当前数据源(%s)暂不支持删除数据库", dbType)}

File diff suppressed because it is too large Load Diff

View File

@@ -9,33 +9,44 @@ type SSHConfig struct {
KeyPath string `json:"keyPath"`
}
// ProxyConfig holds proxy connection details
type ProxyConfig struct {
Type string `json:"type"` // socks5 | http
Host string `json:"host"`
Port int `json:"port"`
User string `json:"user,omitempty"`
Password string `json:"password,omitempty"`
}
// ConnectionConfig holds database connection details including SSH
type ConnectionConfig struct {
Type string `json:"type"`
Host string `json:"host"`
Port int `json:"port"`
User string `json:"user"`
Password string `json:"password"`
SavePassword bool `json:"savePassword,omitempty"` // Persist password in saved connection
Database string `json:"database"`
UseSSH bool `json:"useSSH"`
SSH SSHConfig `json:"ssh"`
Driver string `json:"driver,omitempty"` // For custom connection
DSN string `json:"dsn,omitempty"` // For custom connection
Timeout int `json:"timeout,omitempty"` // Connection timeout in seconds (default: 30)
RedisDB int `json:"redisDB,omitempty"` // Redis database index (0-15)
URI string `json:"uri,omitempty"` // Connection URI for copy/paste
Hosts []string `json:"hosts,omitempty"` // Multi-host addresses: host:port
Topology string `json:"topology,omitempty"` // single | replica
MySQLReplicaUser string `json:"mysqlReplicaUser,omitempty"` // MySQL replica auth user
MySQLReplicaPassword string `json:"mysqlReplicaPassword,omitempty"` // MySQL replica auth password
ReplicaSet string `json:"replicaSet,omitempty"` // MongoDB replica set name
AuthSource string `json:"authSource,omitempty"` // MongoDB authSource
ReadPreference string `json:"readPreference,omitempty"` // MongoDB readPreference
MongoSRV bool `json:"mongoSrv,omitempty"` // MongoDB use mongodb+srv URI scheme
MongoAuthMechanism string `json:"mongoAuthMechanism,omitempty"` // MongoDB authMechanism
MongoReplicaUser string `json:"mongoReplicaUser,omitempty"` // MongoDB replica auth user
MongoReplicaPassword string `json:"mongoReplicaPassword,omitempty"` // MongoDB replica auth password
Type string `json:"type"`
Host string `json:"host"`
Port int `json:"port"`
User string `json:"user"`
Password string `json:"password"`
SavePassword bool `json:"savePassword,omitempty"` // Persist password in saved connection
Database string `json:"database"`
UseSSH bool `json:"useSSH"`
SSH SSHConfig `json:"ssh"`
UseProxy bool `json:"useProxy,omitempty"`
Proxy ProxyConfig `json:"proxy,omitempty"`
Driver string `json:"driver,omitempty"` // For custom connection
DSN string `json:"dsn,omitempty"` // For custom connection
Timeout int `json:"timeout,omitempty"` // Connection timeout in seconds (default: 30)
RedisDB int `json:"redisDB,omitempty"` // Redis database index (0-15)
URI string `json:"uri,omitempty"` // Connection URI for copy/paste
Hosts []string `json:"hosts,omitempty"` // Multi-host addresses: host:port
Topology string `json:"topology,omitempty"` // single | replica
MySQLReplicaUser string `json:"mysqlReplicaUser,omitempty"` // MySQL replica auth user
MySQLReplicaPassword string `json:"mysqlReplicaPassword,omitempty"` // MySQL replica auth password
ReplicaSet string `json:"replicaSet,omitempty"` // MongoDB replica set name
AuthSource string `json:"authSource,omitempty"` // MongoDB authSource
ReadPreference string `json:"readPreference,omitempty"` // MongoDB readPreference
MongoSRV bool `json:"mongoSrv,omitempty"` // MongoDB use mongodb+srv URI scheme
MongoAuthMechanism string `json:"mongoAuthMechanism,omitempty"` // MongoDB authMechanism
MongoReplicaUser string `json:"mongoReplicaUser,omitempty"` // MongoDB replica auth user
MongoReplicaPassword string `json:"mongoReplicaPassword,omitempty"` // MongoDB replica auth password
}
// QueryResult is the standard response format for Wails methods

View File

@@ -14,6 +14,7 @@ import (
"GoNavi-Wails/internal/connection"
"GoNavi-Wails/internal/logger"
proxytunnel "GoNavi-Wails/internal/proxy"
"GoNavi-Wails/internal/ssh"
"go.mongodb.org/mongo-driver/v2/bson"
@@ -29,6 +30,14 @@ type MongoDB struct {
forwarder *ssh.LocalForwarder
}
type mongoProxyDialer struct {
proxyConfig connection.ProxyConfig
}
func (d *mongoProxyDialer) DialContext(ctx context.Context, network, address string) (net.Conn, error) {
return proxytunnel.DialContext(ctx, d.proxyConfig, network, address)
}
const defaultMongoPort = 27017
func normalizeMongoAddress(host string, port int) string {
@@ -328,6 +337,9 @@ func (m *MongoDB) Connect(config connection.ConnectionConfig) error {
uri := m.getURI(attemptConfig)
clientOpts := options.Client().ApplyURI(uri)
if attemptConfig.UseProxy {
clientOpts.SetDialer(&mongoProxyDialer{proxyConfig: attemptConfig.Proxy})
}
client, err := mongo.Connect(clientOpts)
if err != nil {
errorDetails = append(errorDetails, fmt.Sprintf("%s连接失败: %v", authLabel, err))

View File

@@ -0,0 +1,48 @@
package db
import (
"reflect"
"testing"
"GoNavi-Wails/internal/connection"
)
func TestResolvePostgresConnectDatabases_ExplicitDatabase(t *testing.T) {
cfg := connection.ConnectionConfig{
Type: "postgres",
Database: "analytics",
User: "app_user",
}
got := resolvePostgresConnectDatabases(cfg)
want := []string{"analytics"}
if !reflect.DeepEqual(got, want) {
t.Fatalf("unexpected databases, got=%v want=%v", got, want)
}
}
func TestResolvePostgresConnectDatabases_FallbackOrder(t *testing.T) {
cfg := connection.ConnectionConfig{
Type: "postgres",
User: "app_user",
}
got := resolvePostgresConnectDatabases(cfg)
want := []string{"postgres", "template1", "app_user"}
if !reflect.DeepEqual(got, want) {
t.Fatalf("unexpected databases, got=%v want=%v", got, want)
}
}
func TestResolvePostgresConnectDatabases_DeduplicateUserDefault(t *testing.T) {
cfg := connection.ConnectionConfig{
Type: "postgres",
User: "postgres",
}
got := resolvePostgresConnectDatabases(cfg)
want := []string{"postgres", "template1"}
if !reflect.DeepEqual(got, want) {
t.Fatalf("unexpected databases, got=%v want=%v", got, want)
}
}

View File

@@ -24,6 +24,30 @@ type PostgresDB struct {
forwarder *ssh.LocalForwarder // Store SSH tunnel forwarder
}
func resolvePostgresConnectDatabases(config connection.ConnectionConfig) []string {
explicit := strings.TrimSpace(config.Database)
if explicit != "" {
return []string{explicit}
}
candidates := []string{"postgres", "template1", strings.TrimSpace(config.User)}
seen := make(map[string]struct{}, len(candidates))
result := make([]string, 0, len(candidates))
for _, name := range candidates {
trimmed := strings.TrimSpace(name)
if trimmed == "" {
continue
}
normalized := strings.ToLower(trimmed)
if _, exists := seen[normalized]; exists {
continue
}
seen[normalized] = struct{}{}
result = append(result, trimmed)
}
return result
}
func (p *PostgresDB) getDSN(config connection.ConnectionConfig) string {
// postgres://user:password@host:port/dbname?sslmode=disable
dbname := config.Database
@@ -53,8 +77,23 @@ func (p *PostgresDB) Connect(config connection.ConnectionConfig) error {
return fmt.Errorf("%s", reason)
}
var dsn string
var err error
runConfig := config
p.pingTimeout = getConnectTimeout(config)
cleanupOnFailure := true
defer func() {
if !cleanupOnFailure {
return
}
if p.conn != nil {
_ = p.conn.Close()
p.conn = nil
}
if p.forwarder != nil {
_ = p.forwarder.Close()
p.forwarder = nil
}
}()
if config.UseSSH {
// Create SSH tunnel with local port forwarding
@@ -83,24 +122,44 @@ func (p *PostgresDB) Connect(config connection.ConnectionConfig) error {
localConfig.Port = port
localConfig.UseSSH = false // Disable SSH flag for DSN generation
dsn = p.getDSN(localConfig)
runConfig = localConfig
logger.Infof("PostgreSQL 通过本地端口转发连接:%s -> %s:%d", forwarder.LocalAddr, config.Host, config.Port)
} else {
dsn = p.getDSN(config)
}
db, err := sql.Open("postgres", dsn)
if err != nil {
return fmt.Errorf("打开数据库连接失败:%w", err)
}
p.conn = db
p.pingTimeout = getConnectTimeout(config)
attemptDBs := resolvePostgresConnectDatabases(runConfig)
var failures []string
for _, dbName := range attemptDBs {
attemptConfig := runConfig
attemptConfig.Database = dbName
dsn := p.getDSN(attemptConfig)
// Force verification
if err := p.Ping(); err != nil {
return fmt.Errorf("连接建立后验证失败:%w", err)
dbConn, err := sql.Open("postgres", dsn)
if err != nil {
failures = append(failures, fmt.Sprintf("数据库=%s 打开连接失败: %v", dbName, err))
continue
}
p.conn = dbConn
// Force verification
if err := p.Ping(); err != nil {
failures = append(failures, fmt.Sprintf("数据库=%s 验证失败: %v", dbName, err))
_ = dbConn.Close()
p.conn = nil
continue
}
if strings.TrimSpace(config.Database) == "" && !strings.EqualFold(dbName, "postgres") {
logger.Infof("PostgreSQL 自动选择连接数据库:%s", dbName)
}
cleanupOnFailure = false
return nil
}
return nil
if len(failures) == 0 {
return fmt.Errorf("连接建立后验证失败:未找到可用的连接数据库")
}
return fmt.Errorf("连接建立后验证失败:%s", strings.Join(failures, ""))
}
func (p *PostgresDB) Close() error {

344
internal/proxy/proxy.go Normal file
View File

@@ -0,0 +1,344 @@
package proxy
import (
"bufio"
"context"
"crypto/sha256"
"encoding/base64"
"encoding/hex"
"fmt"
"io"
"net"
"net/http"
"net/url"
"strings"
"sync"
"time"
"GoNavi-Wails/internal/connection"
"GoNavi-Wails/internal/logger"
xproxy "golang.org/x/net/proxy"
)
const (
defaultDialTimeout = 8 * time.Second
)
type LocalForwarder struct {
LocalAddr string
RemoteAddr string
ProxyAddr string
ProxyType string
cfg connection.ProxyConfig
listener net.Listener
closeChan chan struct{}
closeOnce sync.Once
closed bool
closedMu sync.RWMutex
}
var (
forwarderMu sync.RWMutex
localForwarders = make(map[string]*LocalForwarder)
)
func NormalizeConfig(config connection.ProxyConfig) (connection.ProxyConfig, error) {
result := connection.ProxyConfig{
Type: strings.ToLower(strings.TrimSpace(config.Type)),
Host: strings.TrimSpace(config.Host),
Port: config.Port,
User: strings.TrimSpace(config.User),
Password: config.Password,
}
switch result.Type {
case "socks5", "socks5h", "http":
default:
return result, fmt.Errorf("不支持的代理类型:%s", config.Type)
}
if result.Type == "socks5h" {
result.Type = "socks5"
}
if result.Host == "" {
return result, fmt.Errorf("代理主机为空")
}
if result.Port <= 0 || result.Port > 65535 {
return result, fmt.Errorf("代理端口无效:%d", result.Port)
}
return result, nil
}
func GetOrCreateLocalForwarder(proxyConfig connection.ProxyConfig, remoteHost string, remotePort int) (*LocalForwarder, error) {
cfg, err := NormalizeConfig(proxyConfig)
if err != nil {
return nil, err
}
if strings.TrimSpace(remoteHost) == "" || remotePort <= 0 {
return nil, fmt.Errorf("无效的远端地址:%s:%d", remoteHost, remotePort)
}
key := forwarderCacheKey(cfg, remoteHost, remotePort)
forwarderMu.RLock()
forwarder, exists := localForwarders[key]
forwarderMu.RUnlock()
if exists && forwarder != nil && !forwarder.IsClosed() {
return forwarder, nil
}
if exists {
forwarderMu.Lock()
delete(localForwarders, key)
forwarderMu.Unlock()
}
next, err := NewLocalForwarder(cfg, remoteHost, remotePort)
if err != nil {
return nil, err
}
forwarderMu.Lock()
localForwarders[key] = next
forwarderMu.Unlock()
return next, nil
}
func forwarderCacheKey(cfg connection.ProxyConfig, remoteHost string, remotePort int) string {
trimmedHost := strings.TrimSpace(remoteHost)
credential := cfg.User + "\x00" + cfg.Password
credentialHash := sha256.Sum256([]byte(credential))
// 仅保留短指纹用于区分不同认证信息,避免在 key 日志中泄露明文口令。
fingerprint := hex.EncodeToString(credentialHash[:8])
return fmt.Sprintf("%s://%s:%d@%s:%d#%s", cfg.Type, cfg.Host, cfg.Port, trimmedHost, remotePort, fingerprint)
}
func NewLocalForwarder(proxyConfig connection.ProxyConfig, remoteHost string, remotePort int) (*LocalForwarder, error) {
cfg, err := NormalizeConfig(proxyConfig)
if err != nil {
return nil, err
}
listener, err := net.Listen("tcp", "127.0.0.1:0")
if err != nil {
return nil, fmt.Errorf("创建本地代理监听失败:%w", err)
}
localAddr := listener.Addr().String()
remoteAddr := net.JoinHostPort(strings.TrimSpace(remoteHost), fmt.Sprintf("%d", remotePort))
proxyAddr := net.JoinHostPort(cfg.Host, fmt.Sprintf("%d", cfg.Port))
forwarder := &LocalForwarder{
LocalAddr: localAddr,
RemoteAddr: remoteAddr,
ProxyAddr: proxyAddr,
ProxyType: cfg.Type,
cfg: cfg,
listener: listener,
closeChan: make(chan struct{}),
}
go forwarder.forward()
logger.Infof("已创建代理端口转发:本地 %s -> 远端 %s代理 %s://%s", localAddr, remoteAddr, cfg.Type, proxyAddr)
return forwarder, nil
}
func (f *LocalForwarder) forward() {
for {
localConn, err := f.listener.Accept()
if err != nil {
select {
case <-f.closeChan:
return
default:
logger.Warnf("接受本地代理连接失败:%v", err)
return
}
}
go f.handleConnection(localConn)
}
}
func (f *LocalForwarder) handleConnection(localConn net.Conn) {
defer localConn.Close()
ctx, cancel := context.WithTimeout(context.Background(), defaultDialTimeout)
remoteConn, err := dialThroughProxy(ctx, f.cfg, "tcp", f.RemoteAddr)
cancel()
if err != nil {
logger.Warnf("通过代理连接远端失败:远端=%s 代理=%s://%s 错误=%v", f.RemoteAddr, f.ProxyType, f.ProxyAddr, err)
return
}
defer remoteConn.Close()
errc := make(chan error, 2)
var closeOnce sync.Once
closeBoth := func() {
_ = localConn.Close()
_ = remoteConn.Close()
}
go func() {
_, copyErr := io.Copy(remoteConn, localConn)
closeOnce.Do(closeBoth)
errc <- copyErr
}()
go func() {
_, copyErr := io.Copy(localConn, remoteConn)
closeOnce.Do(closeBoth)
errc <- copyErr
}()
<-errc
<-errc
}
func (f *LocalForwarder) Close() error {
var err error
f.closeOnce.Do(func() {
f.closedMu.Lock()
f.closed = true
f.closedMu.Unlock()
close(f.closeChan)
err = f.listener.Close()
if err != nil {
logger.Warnf("关闭代理端口转发失败:%v", err)
}
})
return err
}
func (f *LocalForwarder) IsClosed() bool {
f.closedMu.RLock()
defer f.closedMu.RUnlock()
return f.closed
}
func CloseAllForwarders() {
forwarderMu.Lock()
defer forwarderMu.Unlock()
for key, forwarder := range localForwarders {
if forwarder == nil {
continue
}
_ = forwarder.Close()
logger.Infof("已关闭代理端口转发:%s", key)
}
localForwarders = make(map[string]*LocalForwarder)
}
func DialContext(ctx context.Context, proxyConfig connection.ProxyConfig, network, address string) (net.Conn, error) {
cfg, err := NormalizeConfig(proxyConfig)
if err != nil {
return nil, err
}
return dialThroughProxy(ctx, cfg, network, address)
}
func dialThroughProxy(ctx context.Context, cfg connection.ProxyConfig, network, address string) (net.Conn, error) {
switch cfg.Type {
case "socks5":
return dialSOCKS5(ctx, cfg, network, address)
case "http":
return dialHTTPConnect(ctx, cfg, address)
default:
return nil, fmt.Errorf("不支持的代理类型:%s", cfg.Type)
}
}
func dialSOCKS5(ctx context.Context, cfg connection.ProxyConfig, network, address string) (net.Conn, error) {
proxyAddr := net.JoinHostPort(cfg.Host, fmt.Sprintf("%d", cfg.Port))
var auth *xproxy.Auth
if cfg.User != "" || cfg.Password != "" {
auth = &xproxy.Auth{
User: cfg.User,
Password: cfg.Password,
}
}
dialer, err := xproxy.SOCKS5("tcp", proxyAddr, auth, &net.Dialer{Timeout: defaultDialTimeout})
if err != nil {
return nil, fmt.Errorf("创建 SOCKS5 代理拨号器失败:%w", err)
}
type result struct {
conn net.Conn
err error
}
ch := make(chan result, 1)
go func() {
conn, dialErr := dialer.Dial(network, address)
ch <- result{conn: conn, err: dialErr}
}()
select {
case <-ctx.Done():
go func() {
r := <-ch
if r.conn != nil {
_ = r.conn.Close()
}
}()
return nil, ctx.Err()
case r := <-ch:
if r.err != nil {
return nil, fmt.Errorf("SOCKS5 代理连接失败:%w", r.err)
}
return r.conn, nil
}
}
func dialHTTPConnect(ctx context.Context, cfg connection.ProxyConfig, address string) (net.Conn, error) {
proxyAddr := net.JoinHostPort(cfg.Host, fmt.Sprintf("%d", cfg.Port))
dialer := &net.Dialer{Timeout: defaultDialTimeout}
conn, err := dialer.DialContext(ctx, "tcp", proxyAddr)
if err != nil {
return nil, fmt.Errorf("连接 HTTP 代理失败:%w", err)
}
connectReq := &http.Request{
Method: http.MethodConnect,
URL: &url.URL{Opaque: address},
Host: address,
Header: make(http.Header),
}
if cfg.User != "" || cfg.Password != "" {
raw := cfg.User + ":" + cfg.Password
connectReq.Header.Set("Proxy-Authorization", "Basic "+base64.StdEncoding.EncodeToString([]byte(raw)))
}
if err := connectReq.Write(conn); err != nil {
_ = conn.Close()
return nil, fmt.Errorf("发送 HTTP CONNECT 请求失败:%w", err)
}
reader := bufio.NewReader(conn)
resp, err := http.ReadResponse(reader, connectReq)
if err != nil {
_ = conn.Close()
return nil, fmt.Errorf("读取 HTTP CONNECT 响应失败:%w", err)
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
_ = conn.Close()
return nil, fmt.Errorf("HTTP 代理 CONNECT 失败:%s", strings.TrimSpace(resp.Status))
}
if reader.Buffered() == 0 {
return conn, nil
}
return &bufferedConn{Conn: conn, reader: reader}, nil
}
type bufferedConn struct {
net.Conn
reader *bufio.Reader
}
func (c *bufferedConn) Read(p []byte) (int, error) {
if c.reader == nil {
return c.Conn.Read(p)
}
if c.reader.Buffered() == 0 {
c.reader = nil
return c.Conn.Read(p)
}
return c.reader.Read(p)
}

View File

@@ -0,0 +1,44 @@
package proxy
import (
"strings"
"testing"
"GoNavi-Wails/internal/connection"
)
func TestNormalizeConfigSupportsSocks5hAlias(t *testing.T) {
cfg, err := NormalizeConfig(connection.ProxyConfig{
Type: "SOCKS5H",
Host: "127.0.0.1",
Port: 1080,
})
if err != nil {
t.Fatalf("NormalizeConfig returned error: %v", err)
}
if cfg.Type != "socks5" {
t.Fatalf("expected normalized proxy type socks5, got %s", cfg.Type)
}
}
func TestForwarderCacheKeyIncludesCredentialFingerprint(t *testing.T) {
base := connection.ProxyConfig{
Type: "socks5",
Host: "127.0.0.1",
Port: 1080,
User: "tester",
Password: "first-password",
}
other := base
other.Password = "second-password"
keyA := forwarderCacheKey(base, "db.internal", 3306)
keyB := forwarderCacheKey(other, "db.internal", 3306)
if keyA == keyB {
t.Fatalf("expected different cache key for different credentials")
}
if strings.Contains(keyA, base.Password) || strings.Contains(keyB, other.Password) {
t.Fatalf("cache key should not contain raw password")
}
}