- 背景与问题 :以前没有支持官方工具mysqlworkbench的xml导入,现在支持了

- 变更点:新增mysqlworkbench的xml文件导入,并当没有密码时,提示用户,而不是直接使用空密码进行直接连接,更友好
  - 影响范围:仅导入受到影响
  - 验证方式:点击导入,用mysqlworkbench的xml进行导入即可
This commit is contained in:
anyanfei
2026-04-14 18:50:40 +08:00
parent f78b132c7c
commit b6121fe1f8
12 changed files with 312 additions and 12 deletions

View File

@@ -1 +1 @@
26a843d5fd071d0c7e9d8022e98eb4e3
571d014306268cf67665967059cda912

View File

@@ -1702,7 +1702,7 @@ function App() {
const importKind = detectConnectionImportKind(raw);
if (importKind === 'invalid') {
void message.error('文件格式错误:仅支持 GoNavi 恢复包历史 JSON 连接数组');
void message.error('文件格式错误:仅支持 GoNavi 恢复包历史 JSON 连接数组或 MySQL Workbench XML');
return;
}

View File

@@ -1,6 +1,6 @@
import type { ConnectionConfig, SavedConnection } from '../types';
export type ConnectionImportKind = 'app-managed-package' | 'encrypted-package' | 'legacy-json' | 'invalid';
export type ConnectionImportKind = 'app-managed-package' | 'encrypted-package' | 'legacy-json' | 'mysql-workbench-xml' | 'invalid';
export type ConnectionPackageDialogSnapshot = {
open: boolean;
mode: 'export' | 'import';
@@ -105,7 +105,15 @@ const parseConnectionImportRaw = (raw: unknown): unknown => {
}
};
const isMySQLWorkbenchXML = (raw: string): boolean => (
raw.includes('<data') && raw.includes('grt_format') && raw.includes('db.mgmt.Connection')
);
export const detectConnectionImportKind = (raw: unknown): ConnectionImportKind => {
if (typeof raw === 'string' && isMySQLWorkbenchXML(raw)) {
return 'mysql-workbench-xml';
}
const parsed = parseConnectionImportRaw(raw);
if (isConnectionPackageV2AppManagedEnvelope(parsed)) {

View File

@@ -25,7 +25,7 @@ describe('connectionModalPresentation', () => {
it('maps missing saved-connection errors to a secret-specific hint', () => {
expect(normalizeConnectionSecretErrorMessage('saved connection not found: conn-1')).toBe(
'未找到当前连接对应的已保存密文,请重新填写密码保存后再试',
'未找到当前连接对应的已保存密文,请编辑当前连接,并输入密码保存',
);
});
@@ -39,7 +39,7 @@ describe('connectionModalPresentation', () => {
reason: 'saved connection not found: conn-1',
fallback: '连接失败',
})).toEqual({
message: '测试失败: 未找到当前连接对应的已保存密文,请重新填写密码保存后再试',
message: '测试失败: 未找到当前连接对应的已保存密文,请编辑当前连接,并输入密码保存',
shouldToast: true,
});
});

View File

@@ -41,7 +41,7 @@ export const normalizeConnectionSecretErrorMessage = (
const lower = text.toLowerCase();
if (lower.includes('saved connection not found:')) {
return '未找到当前连接对应的已保存密文,请重新填写密码保存后再试';
return '未找到当前连接对应的已保存密文,请编辑当前连接,并输入密码保存';
}
if (lower.includes('secret store unavailable')) {
return '系统密文存储当前不可用,请检查系统钥匙串或凭据管理器后再试';

View File

@@ -246,4 +246,85 @@ export function OnFileDropOff() :void
export function CanResolveFilePaths(): boolean;
// Resolves file paths for an array of files
export function ResolveFilePaths(files: File[]): void
export function ResolveFilePaths(files: File[]): void
// Notification types
export interface NotificationOptions {
id: string;
title: string;
subtitle?: string; // macOS and Linux only
body?: string;
categoryId?: string;
data?: { [key: string]: any };
}
export interface NotificationAction {
id?: string;
title?: string;
destructive?: boolean; // macOS-specific
}
export interface NotificationCategory {
id?: string;
actions?: NotificationAction[];
hasReplyField?: boolean;
replyPlaceholder?: string;
replyButtonTitle?: string;
}
// [InitializeNotifications](https://wails.io/docs/reference/runtime/notification#initializenotifications)
// Initializes the notification service for the application.
// This must be called before sending any notifications.
export function InitializeNotifications(): Promise<void>;
// [CleanupNotifications](https://wails.io/docs/reference/runtime/notification#cleanupnotifications)
// Cleans up notification resources and releases any held connections.
export function CleanupNotifications(): Promise<void>;
// [IsNotificationAvailable](https://wails.io/docs/reference/runtime/notification#isnotificationavailable)
// Checks if notifications are available on the current platform.
export function IsNotificationAvailable(): Promise<boolean>;
// [RequestNotificationAuthorization](https://wails.io/docs/reference/runtime/notification#requestnotificationauthorization)
// Requests notification authorization from the user (macOS only).
export function RequestNotificationAuthorization(): Promise<boolean>;
// [CheckNotificationAuthorization](https://wails.io/docs/reference/runtime/notification#checknotificationauthorization)
// Checks the current notification authorization status (macOS only).
export function CheckNotificationAuthorization(): Promise<boolean>;
// [SendNotification](https://wails.io/docs/reference/runtime/notification#sendnotification)
// Sends a basic notification with the given options.
export function SendNotification(options: NotificationOptions): Promise<void>;
// [SendNotificationWithActions](https://wails.io/docs/reference/runtime/notification#sendnotificationwithactions)
// Sends a notification with action buttons. Requires a registered category.
export function SendNotificationWithActions(options: NotificationOptions): Promise<void>;
// [RegisterNotificationCategory](https://wails.io/docs/reference/runtime/notification#registernotificationcategory)
// Registers a notification category that can be used with SendNotificationWithActions.
export function RegisterNotificationCategory(category: NotificationCategory): Promise<void>;
// [RemoveNotificationCategory](https://wails.io/docs/reference/runtime/notification#removenotificationcategory)
// Removes a previously registered notification category.
export function RemoveNotificationCategory(categoryId: string): Promise<void>;
// [RemoveAllPendingNotifications](https://wails.io/docs/reference/runtime/notification#removeallpendingnotifications)
// Removes all pending notifications from the notification center.
export function RemoveAllPendingNotifications(): Promise<void>;
// [RemovePendingNotification](https://wails.io/docs/reference/runtime/notification#removependingnotification)
// Removes a specific pending notification by its identifier.
export function RemovePendingNotification(identifier: string): Promise<void>;
// [RemoveAllDeliveredNotifications](https://wails.io/docs/reference/runtime/notification#removealldeliverednotifications)
// Removes all delivered notifications from the notification center.
export function RemoveAllDeliveredNotifications(): Promise<void>;
// [RemoveDeliveredNotification](https://wails.io/docs/reference/runtime/notification#removedeliverednotification)
// Removes a specific delivered notification by its identifier.
export function RemoveDeliveredNotification(identifier: string): Promise<void>;
// [RemoveNotification](https://wails.io/docs/reference/runtime/notification#removenotification)
// Removes a notification by its identifier (cross-platform convenience function).
export function RemoveNotification(identifier: string): Promise<void>;

View File

@@ -239,4 +239,60 @@ export function CanResolveFilePaths() {
export function ResolveFilePaths(files) {
return window.runtime.ResolveFilePaths(files);
}
export function InitializeNotifications() {
return window.runtime.InitializeNotifications();
}
export function CleanupNotifications() {
return window.runtime.CleanupNotifications();
}
export function IsNotificationAvailable() {
return window.runtime.IsNotificationAvailable();
}
export function RequestNotificationAuthorization() {
return window.runtime.RequestNotificationAuthorization();
}
export function CheckNotificationAuthorization() {
return window.runtime.CheckNotificationAuthorization();
}
export function SendNotification(options) {
return window.runtime.SendNotification(options);
}
export function SendNotificationWithActions(options) {
return window.runtime.SendNotificationWithActions(options);
}
export function RegisterNotificationCategory(category) {
return window.runtime.RegisterNotificationCategory(category);
}
export function RemoveNotificationCategory(categoryId) {
return window.runtime.RemoveNotificationCategory(categoryId);
}
export function RemoveAllPendingNotifications() {
return window.runtime.RemoveAllPendingNotifications();
}
export function RemovePendingNotification(identifier) {
return window.runtime.RemovePendingNotification(identifier);
}
export function RemoveAllDeliveredNotifications() {
return window.runtime.RemoveAllDeliveredNotifications();
}
export function RemoveDeliveredNotification(identifier) {
return window.runtime.RemoveDeliveredNotification(identifier);
}
export function RemoveNotification(identifier) {
return window.runtime.RemoveNotification(identifier);
}

3
go.mod
View File

@@ -15,7 +15,7 @@ require (
github.com/redis/go-redis/v9 v9.17.3
github.com/sijms/go-ora/v2 v2.9.0
github.com/taosdata/driver-go/v3 v3.7.8
github.com/wailsapp/wails/v2 v2.11.0
github.com/wailsapp/wails/v2 v2.12.0
github.com/xuri/excelize/v2 v2.10.0
go.mongodb.org/mongo-driver v1.17.9
go.mongodb.org/mongo-driver/v2 v2.5.0
@@ -27,6 +27,7 @@ require (
)
require (
git.sr.ht/~jackmordaunt/go-toast/v2 v2.0.3 // indirect
github.com/kr/pretty v0.3.1 // indirect
github.com/rogpeppe/go-internal v1.12.0 // indirect
gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c // indirect

6
go.sum
View File

@@ -1,5 +1,7 @@
filippo.io/edwards25519 v1.1.0 h1:FNf4tywRC1HmFuKW5xopWpigGjJKiJSV0Cqo0cJWDaA=
filippo.io/edwards25519 v1.1.0/go.mod h1:BxyFTGdWcka3PhytdK4V28tE5sGfRvvvRV7EaN4VDT4=
git.sr.ht/~jackmordaunt/go-toast/v2 v2.0.3 h1:N3IGoHHp9pb6mj1cbXbuaSXV/UMKwmbKLf53nQmtqMA=
git.sr.ht/~jackmordaunt/go-toast/v2 v2.0.3/go.mod h1:QtOLZGz8olr4qH2vWK0QH0w0O4T9fEIjMuWpKUsH7nc=
gitea.com/kingbase/gokb v0.0.0-20201021123113-29bd62a876c3 h1:QjslQNaH5Nuap5i4nijS0OYV6GMk5kqrAmgU90zBKd4=
gitea.com/kingbase/gokb v0.0.0-20201021123113-29bd62a876c3/go.mod h1:7lH5A1jzCXD9Nl16DzaBUOfDAT8NPrDmZwKu1p5wf94=
gitee.com/chunanyong/dm v1.8.22 h1:H7fsrnUIvEA0jlDWew7vwELry1ff+tLMIu2Fk2cIBSg=
@@ -243,8 +245,8 @@ github.com/wailsapp/go-webview2 v1.0.22 h1:YT61F5lj+GGaat5OB96Aa3b4QA+mybD0Ggq6N
github.com/wailsapp/go-webview2 v1.0.22/go.mod h1:qJmWAmAmaniuKGZPWwne+uor3AHMB5PFhqiK0Bbj8kc=
github.com/wailsapp/mimetype v1.4.1 h1:pQN9ycO7uo4vsUUuPeHEYoUkLVkaRntMnHJxVwYhwHs=
github.com/wailsapp/mimetype v1.4.1/go.mod h1:9aV5k31bBOv5z6u+QP8TltzvNGJPmNJD4XlAL3U+j3o=
github.com/wailsapp/wails/v2 v2.11.0 h1:seLacV8pqupq32IjS4Y7V8ucab0WZwtK6VvUVxSBtqQ=
github.com/wailsapp/wails/v2 v2.11.0/go.mod h1:jrf0ZaM6+GBc1wRmXsM8cIvzlg0karYin3erahI4+0k=
github.com/wailsapp/wails/v2 v2.12.0 h1:BHO/kLNWFHYjCzucxbzAYZWUjub1Tvb4cSguQozHn5c=
github.com/wailsapp/wails/v2 v2.12.0/go.mod h1:mo1bzK1DEJrobt7YrBjgxvb5Sihb1mhAY09hppbibQg=
github.com/xdg-go/pbkdf2 v1.0.0 h1:Su7DPu48wXMwC3bs7MCNG+z4FhcyEuz5dlvchbq0B0c=
github.com/xdg-go/pbkdf2 v1.0.0/go.mod h1:jrpuAogTd400dnrH08LKmI/xc1MbPOebTwRqcT5RDeI=
github.com/xdg-go/scram v1.1.1/go.mod h1:RaEWvsqvNKKvBPvcKeFjrG2cJqOkHTiyTpzz23ni57g=

View File

@@ -2,8 +2,10 @@ package app
import (
"encoding/json"
"encoding/xml"
"errors"
"fmt"
"strconv"
"strings"
"time"
@@ -268,6 +270,21 @@ func (a *App) ImportConnectionsPayload(raw string, password string) ([]connectio
return sanitizeSavedConnectionViews(views), nil
}
if isMySQLWorkbenchXML(trimmed) {
inputs, err := parseMySQLWorkbenchXML(trimmed)
if err != nil {
return nil, fmt.Errorf("解析 MySQL Workbench XML 失败: %w", err)
}
if len(inputs) == 0 {
return nil, fmt.Errorf("未在 XML 中找到有效的连接配置")
}
views, err := a.importSavedConnectionsAtomically(inputs)
if err != nil {
return nil, err
}
return sanitizeSavedConnectionViews(views), nil
}
var legacy []connection.LegacySavedConnection
if err := json.Unmarshal([]byte(trimmed), &legacy); err != nil {
return nil, errConnectionPackageUnsupported
@@ -372,3 +389,126 @@ func (s connectionPackageImportRollbackSnapshot) restore(a *App) error {
}
return nil
}
// --- MySQL Workbench XML import ---
func isMySQLWorkbenchXML(content string) bool {
return strings.Contains(content, "<data") && strings.Contains(content, "grt_format") && strings.Contains(content, "db.mgmt.Connection")
}
// mysqlWorkbenchData is the root XML element.
type mysqlWorkbenchData struct {
XMLName xml.Name `xml:"data"`
Value mysqlWorkbenchTopValue `xml:"value"`
}
type mysqlWorkbenchTopValue struct {
Values []mysqlWorkbenchConnection `xml:"value"`
}
type mysqlWorkbenchConnection struct {
StructName string `xml:"struct-name,attr"`
Values []mysqlWorkbenchValue `xml:"value"`
Links []mysqlWorkbenchLinkValue `xml:"link"`
}
type mysqlWorkbenchValue struct {
Type string `xml:"type,attr"`
Key string `xml:"key,attr"`
StructName string `xml:"struct-name,attr"`
Content string `xml:",chardata"`
Children []mysqlWorkbenchValue `xml:"value"`
}
type mysqlWorkbenchLinkValue struct {
Key string `xml:"key,attr"`
Content string `xml:",chardata"`
}
func parseMySQLWorkbenchXML(content string) ([]connection.SavedConnectionInput, error) {
var data mysqlWorkbenchData
if err := xml.Unmarshal([]byte(content), &data); err != nil {
return nil, err
}
var inputs []connection.SavedConnectionInput
for _, conn := range data.Value.Values {
if conn.StructName != "db.mgmt.Connection" {
continue
}
input := parseMySQLWorkbenchConnection(conn)
inputs = append(inputs, input)
}
return inputs, nil
}
func parseMySQLWorkbenchConnection(conn mysqlWorkbenchConnection) connection.SavedConnectionInput {
params := make(map[string]string)
connName := ""
driverKey := ""
for _, v := range conn.Values {
key := strings.TrimSpace(v.Key)
switch {
case key == "name" && v.Type == "string":
connName = strings.TrimSpace(v.Content)
case key == "parameterValues" && v.Type == "dict":
for _, child := range v.Children {
childKey := strings.TrimSpace(child.Key)
if childKey == "" {
continue
}
params[childKey] = strings.TrimSpace(child.Content)
}
}
}
for _, link := range conn.Links {
if strings.TrimSpace(link.Key) == "driver" {
driverKey = strings.TrimSpace(link.Content)
}
}
host := params["hostName"]
port := 3306
if p, err := strconv.Atoi(params["port"]); err == nil && p > 0 {
port = p
}
user := params["userName"]
schema := params["schema"]
password := params["password"]
useSSL := false
if v, err := strconv.Atoi(params["useSSL"]); err == nil && v > 0 {
useSSL = true
}
dbType := "mysql"
if strings.Contains(driverKey, "mariadb") {
dbType = "mariadb"
}
connID := "conn-" + uuid.New().String()[:8]
config := connection.ConnectionConfig{
ID: connID,
Type: dbType,
Host: host,
Port: port,
User: user,
Password: password,
Database: schema,
UseSSL: useSSL,
}
if connName == "" {
connName = fmt.Sprintf("%s@%s:%d", user, host, port)
}
return connection.SavedConnectionInput{
ID: connID,
Name: connName,
Config: config,
}
}

View File

@@ -37,6 +37,14 @@ func (a *App) resolveConnectionSecrets(config connection.ConnectionConfig) (conn
}
resolved := mergeConnectionSecretBundleIntoConfig(base, bundle)
resolved.ID = view.ID
if !connectionConfigCarriesInlineSecrets(config) && !bundle.hasAny() {
_, dailyExists, _ := repo.dailySecrets().GetConnection(view.ID)
if !dailyExists {
return resolved, fmt.Errorf("未找到当前连接对应的已保存密文,请编辑当前连接,并输入密码后保存")
}
}
return resolved, nil
}
@@ -102,9 +110,9 @@ func normalizeConnectionSecretResolutionError(config connection.ConnectionConfig
if connectionMetadataLooksEmpty(config) {
return fmt.Errorf("未找到已保存连接,可能已被删除,请刷新后重试")
}
return fmt.Errorf("未找到当前连接对应的已保存密文,请重新填写密码保存后再试")
return fmt.Errorf("未找到当前连接对应的已保存密文,请编辑当前连接,并输入密码保存")
case errors.Is(err, os.ErrNotExist):
return fmt.Errorf("未找到当前连接对应的已保存密文,请重新填写密码保存后再试")
return fmt.Errorf("未找到当前连接对应的已保存密文,请编辑当前连接,并输入密码保存")
case strings.Contains(lower, "secret store unavailable"):
return fmt.Errorf("系统密文存储当前不可用,请检查系统钥匙串或凭据管理器后再试")
default:

View File

@@ -290,6 +290,10 @@ func (a *App) ImportConfigFile() connection.QueryResult {
DisplayName: "JSON Files (*.json)",
Pattern: "*.json",
},
{
DisplayName: "MySQL Workbench Connections (*.xml)",
Pattern: "*.xml",
},
},
})