feat(storage): 支持自定义数据目录与显式迁移

Fixes #242
This commit is contained in:
Syngnat
2026-04-11 21:53:50 +08:00
parent c810d999bd
commit 1f617f9d53
14 changed files with 656 additions and 19 deletions

View File

@@ -17,6 +17,7 @@ import (
aicontext "GoNavi-Wails/internal/ai/context"
"GoNavi-Wails/internal/ai/provider"
"GoNavi-Wails/internal/ai/safety"
"GoNavi-Wails/internal/appdata"
"GoNavi-Wails/internal/logger"
"GoNavi-Wails/internal/secretstore"
@@ -1143,11 +1144,7 @@ func (s *Service) AIDeleteSession(sessionID string) error {
// --- 工具函数 ---
func resolveConfigDir() string {
homeDir, err := os.UserHomeDir()
if err != nil {
homeDir = "."
}
return filepath.Join(homeDir, ".gonavi")
return appdata.MustResolveActiveRoot()
}
func maskAPIKey(apiKey string) string {

View File

@@ -10,10 +10,12 @@ import (
"net"
"net/url"
"os"
"path/filepath"
"strings"
"sync"
"time"
"GoNavi-Wails/internal/appdata"
"GoNavi-Wails/internal/connection"
"GoNavi-Wails/internal/db"
"GoNavi-Wails/internal/logger"
@@ -89,6 +91,7 @@ func (a *App) startup(ctx context.Context) {
if strings.TrimSpace(a.configDir) == "" {
a.configDir = resolveAppConfigDir()
}
db.SetExternalDriverDownloadDirectory(appdata.DriverRoot(a.configDir))
logger.Init()
a.loadPersistedGlobalProxy()
installMacNativeWindowDiagnostics(logger.Path())
@@ -136,6 +139,21 @@ func (a *App) Shutdown(ctx context.Context) {
logger.Close()
}
func dataRootInfoPayload(activeRoot string) map[string]interface{} {
defaultRoot := appdata.DefaultRoot()
currentRoot := strings.TrimSpace(activeRoot)
if currentRoot == "" {
currentRoot = appdata.MustResolveActiveRoot()
}
return map[string]interface{}{
"path": currentRoot,
"defaultPath": defaultRoot,
"driverPath": appdata.DriverRoot(currentRoot),
"isDefaultPath": filepath.Clean(currentRoot) == filepath.Clean(defaultRoot),
"bootstrapPath": appdata.BootstrapPath(),
}
}
func normalizeCacheKeyConfig(config connection.ConnectionConfig) connection.ConnectionConfig {
normalized := config
normalized.ID = ""

View File

@@ -0,0 +1,199 @@
package app
import (
"fmt"
"io"
"os"
"os/exec"
"path/filepath"
stdRuntime "runtime"
"strings"
"GoNavi-Wails/internal/appdata"
"GoNavi-Wails/internal/connection"
"GoNavi-Wails/internal/db"
"GoNavi-Wails/internal/logger"
"github.com/wailsapp/wails/v2/pkg/runtime"
)
var migratableDataRootEntries = []string{
"connections.json",
"global_proxy.json",
"ai_config.json",
"sessions",
"drivers",
}
func (a *App) GetDataRootDirectoryInfo() connection.QueryResult {
return connection.QueryResult{Success: true, Message: "OK", Data: dataRootInfoPayload(a.configDir)}
}
func (a *App) SelectDataRootDirectory(currentDir string) connection.QueryResult {
defaultDir := strings.TrimSpace(currentDir)
if defaultDir == "" {
defaultDir = appdata.MustResolveActiveRoot()
}
if !filepath.IsAbs(defaultDir) {
if abs, err := filepath.Abs(defaultDir); err == nil {
defaultDir = abs
}
}
selection, err := runtime.OpenDirectoryDialog(a.ctx, runtime.OpenDialogOptions{
Title: "选择 GoNavi 数据目录",
DefaultDirectory: defaultDir,
CanCreateDirectories: true,
})
if err != nil {
return connection.QueryResult{Success: false, Message: err.Error()}
}
if strings.TrimSpace(selection) == "" {
return connection.QueryResult{Success: false, Message: "已取消"}
}
resolved, err := appdata.ResolveRoot(selection)
if err != nil {
return connection.QueryResult{Success: false, Message: err.Error()}
}
return connection.QueryResult{Success: true, Data: dataRootInfoPayload(resolved)}
}
func (a *App) ApplyDataRootDirectory(directory string, migrate bool) connection.QueryResult {
currentRoot := appdata.MustResolveActiveRoot()
targetRoot, err := appdata.ResolveRoot(directory)
if err != nil {
return connection.QueryResult{Success: false, Message: err.Error()}
}
if filepath.Clean(currentRoot) == filepath.Clean(targetRoot) {
a.configDir = targetRoot
db.SetExternalDriverDownloadDirectory(appdata.DriverRoot(targetRoot))
return connection.QueryResult{
Success: true,
Message: "数据目录未发生变化",
Data: dataRootInfoPayload(targetRoot),
}
}
if migrate {
if err := migrateDataRootContents(currentRoot, targetRoot); err != nil {
return connection.QueryResult{Success: false, Message: err.Error()}
}
}
appliedRoot, err := appdata.SetActiveRoot(targetRoot)
if err != nil {
return connection.QueryResult{Success: false, Message: err.Error()}
}
a.configDir = appliedRoot
db.SetExternalDriverDownloadDirectory(appdata.DriverRoot(appliedRoot))
message := "数据目录已更新,建议重启应用以让 AI 与其他运行态模块完全切换到新目录"
if migrate {
message = "数据已迁移并切换到新目录,建议重启应用以完成全部模块切换"
}
return connection.QueryResult{Success: true, Message: message, Data: dataRootInfoPayload(appliedRoot)}
}
func (a *App) OpenDataRootDirectory() connection.QueryResult {
root := appdata.MustResolveActiveRoot()
if stat, err := os.Stat(root); err != nil || !stat.IsDir() {
return connection.QueryResult{Success: false, Message: "数据目录不存在或不可访问"}
}
var cmd *exec.Cmd
switch stdRuntime.GOOS {
case "darwin":
cmd = exec.Command("open", root)
case "windows":
cmd = exec.Command("explorer", root)
case "linux":
cmd = exec.Command("xdg-open", root)
default:
return connection.QueryResult{Success: false, Message: fmt.Sprintf("当前平台暂不支持打开目录:%s", stdRuntime.GOOS)}
}
if err := cmd.Start(); err != nil {
logger.Error(err, "打开数据目录失败")
return connection.QueryResult{Success: false, Message: fmt.Sprintf("打开数据目录失败:%v", err)}
}
return connection.QueryResult{Success: true, Message: "已打开数据目录", Data: dataRootInfoPayload(root)}
}
func migrateDataRootContents(sourceRoot string, targetRoot string) error {
sourceRoot = strings.TrimSpace(sourceRoot)
targetRoot = strings.TrimSpace(targetRoot)
if sourceRoot == "" || targetRoot == "" {
return fmt.Errorf("数据目录不能为空")
}
if filepath.Clean(sourceRoot) == filepath.Clean(targetRoot) {
return nil
}
if err := os.MkdirAll(targetRoot, 0o755); err != nil {
return fmt.Errorf("创建目标数据目录失败:%w", err)
}
for _, name := range migratableDataRootEntries {
sourcePath := filepath.Join(sourceRoot, name)
info, err := os.Stat(sourcePath)
if err != nil {
if os.IsNotExist(err) {
continue
}
return fmt.Errorf("读取源数据失败(%s%w", name, err)
}
targetPath := filepath.Join(targetRoot, name)
if info.IsDir() {
if err := copyDir(sourcePath, targetPath); err != nil {
return fmt.Errorf("迁移目录失败(%s%w", name, err)
}
continue
}
if err := copyFile(sourcePath, targetPath, info.Mode()); err != nil {
return fmt.Errorf("迁移文件失败(%s%w", name, err)
}
}
return nil
}
func copyDir(sourceDir string, targetDir string) error {
return filepath.WalkDir(sourceDir, func(path string, entry os.DirEntry, walkErr error) error {
if walkErr != nil {
return walkErr
}
relativePath, err := filepath.Rel(sourceDir, path)
if err != nil {
return err
}
targetPath := filepath.Join(targetDir, relativePath)
if entry.IsDir() {
info, err := entry.Info()
if err != nil {
return err
}
return os.MkdirAll(targetPath, info.Mode())
}
info, err := entry.Info()
if err != nil {
return err
}
return copyFile(path, targetPath, info.Mode())
})
}
func copyFile(sourcePath string, targetPath string, mode os.FileMode) error {
if err := os.MkdirAll(filepath.Dir(targetPath), 0o755); err != nil {
return err
}
sourceFile, err := os.Open(sourcePath)
if err != nil {
return err
}
defer sourceFile.Close()
targetFile, err := os.Create(targetPath)
if err != nil {
return err
}
defer targetFile.Close()
if _, err := io.Copy(targetFile, sourceFile); err != nil {
return err
}
return os.Chmod(targetPath, mode)
}

View File

@@ -0,0 +1,32 @@
package app
import (
"os"
"path/filepath"
"testing"
)
func TestMigrateDataRootContentsCopiesKnownFilesAndDirectories(t *testing.T) {
sourceRoot := t.TempDir()
targetRoot := filepath.Join(t.TempDir(), "gonavi-data")
if err := os.WriteFile(filepath.Join(sourceRoot, "connections.json"), []byte(`{"connections":[]}`), 0o644); err != nil {
t.Fatalf("write connections.json failed: %v", err)
}
if err := os.MkdirAll(filepath.Join(sourceRoot, "sessions"), 0o755); err != nil {
t.Fatalf("mkdir sessions failed: %v", err)
}
if err := os.WriteFile(filepath.Join(sourceRoot, "sessions", "s1.json"), []byte(`{}`), 0o644); err != nil {
t.Fatalf("write session file failed: %v", err)
}
if err := migrateDataRootContents(sourceRoot, targetRoot); err != nil {
t.Fatalf("migrateDataRootContents returned error: %v", err)
}
if _, err := os.Stat(filepath.Join(targetRoot, "connections.json")); err != nil {
t.Fatalf("expected connections.json in target root: %v", err)
}
if _, err := os.Stat(filepath.Join(targetRoot, "sessions", "s1.json")); err != nil {
t.Fatalf("expected session file in target root: %v", err)
}
}

View File

@@ -7,6 +7,7 @@ import (
"path/filepath"
"strings"
"GoNavi-Wails/internal/appdata"
"GoNavi-Wails/internal/connection"
"GoNavi-Wails/internal/secretstore"
"github.com/google/uuid"
@@ -38,11 +39,7 @@ type savedConnectionRepository struct {
}
func resolveAppConfigDir() string {
homeDir, err := os.UserHomeDir()
if err != nil || strings.TrimSpace(homeDir) == "" {
return "."
}
return filepath.Join(homeDir, ".gonavi")
return appdata.MustResolveActiveRoot()
}
func newSavedConnectionRepository(configDir string, store secretstore.SecretStore) *savedConnectionRepository {

View File

@@ -2,4 +2,6 @@
package app
func installMacNativeWindowDiagnostics(logPath string) {}
func setMacNativeWindowControls(enabled bool) {}

View File

@@ -0,0 +1,10 @@
//go:build !darwin
package app
import "testing"
func TestInstallMacNativeWindowDiagnosticsNoopOnNonDarwin(t *testing.T) {
installMacNativeWindowDiagnostics("")
installMacNativeWindowDiagnostics("ignored.log")
}

112
internal/appdata/root.go Normal file
View File

@@ -0,0 +1,112 @@
package appdata
import (
"encoding/json"
"fmt"
"os"
"path/filepath"
"strings"
)
const bootstrapFileName = "storage_root.json"
type bootstrapConfig struct {
DataRoot string `json:"dataRoot"`
}
func DefaultRoot() string {
homeDir, err := os.UserHomeDir()
if err != nil || strings.TrimSpace(homeDir) == "" {
return "."
}
return filepath.Join(homeDir, ".gonavi")
}
func BootstrapPath() string {
return filepath.Join(DefaultRoot(), bootstrapFileName)
}
func normalizeRoot(root string) (string, error) {
trimmed := strings.TrimSpace(root)
if trimmed == "" {
trimmed = DefaultRoot()
}
abs, err := filepath.Abs(trimmed)
if err != nil {
return "", err
}
return abs, nil
}
func ResolveRoot(root string) (string, error) {
return normalizeRoot(root)
}
func ResolveActiveRoot() (string, error) {
defaultRoot, err := normalizeRoot(DefaultRoot())
if err != nil {
return "", err
}
data, err := os.ReadFile(BootstrapPath())
if err != nil {
if os.IsNotExist(err) {
return defaultRoot, nil
}
return "", err
}
var cfg bootstrapConfig
if err := json.Unmarshal(data, &cfg); err != nil {
return "", err
}
if strings.TrimSpace(cfg.DataRoot) == "" {
return defaultRoot, nil
}
return normalizeRoot(cfg.DataRoot)
}
func MustResolveActiveRoot() string {
root, err := ResolveActiveRoot()
if err != nil {
return DefaultRoot()
}
return root
}
func DriverRoot(activeRoot string) string {
root := strings.TrimSpace(activeRoot)
if root == "" {
root = MustResolveActiveRoot()
}
return filepath.Join(root, "drivers")
}
func SetActiveRoot(root string) (string, error) {
targetRoot, err := normalizeRoot(root)
if err != nil {
return "", err
}
defaultRoot, err := normalizeRoot(DefaultRoot())
if err != nil {
return "", err
}
if err := os.MkdirAll(targetRoot, 0o755); err != nil {
return "", fmt.Errorf("创建数据目录失败:%w", err)
}
if targetRoot == defaultRoot {
if err := os.Remove(BootstrapPath()); err != nil && !os.IsNotExist(err) {
return "", err
}
return defaultRoot, nil
}
if err := os.MkdirAll(defaultRoot, 0o755); err != nil {
return "", fmt.Errorf("创建默认引导目录失败:%w", err)
}
payload, err := json.MarshalIndent(bootstrapConfig{DataRoot: targetRoot}, "", " ")
if err != nil {
return "", err
}
if err := os.WriteFile(BootstrapPath(), payload, 0o644); err != nil {
return "", err
}
return targetRoot, nil
}

View File

@@ -0,0 +1,69 @@
package appdata
import (
"os"
"path/filepath"
"testing"
)
func TestResolveActiveRootDefaultsToLegacyGonaviDir(t *testing.T) {
homeDir := t.TempDir()
t.Setenv("HOME", homeDir)
t.Setenv("USERPROFILE", homeDir)
root, err := ResolveActiveRoot()
if err != nil {
t.Fatalf("ResolveActiveRoot returned error: %v", err)
}
expected := filepath.Join(homeDir, ".gonavi")
if root != expected {
t.Fatalf("expected default root %q, got %q", expected, root)
}
}
func TestSetActiveRootWritesBootstrapAndResolveUsesIt(t *testing.T) {
homeDir := t.TempDir()
t.Setenv("HOME", homeDir)
t.Setenv("USERPROFILE", homeDir)
customRoot := filepath.Join(t.TempDir(), "gonavi-data")
savedRoot, err := SetActiveRoot(customRoot)
if err != nil {
t.Fatalf("SetActiveRoot returned error: %v", err)
}
if savedRoot != customRoot {
t.Fatalf("expected saved root %q, got %q", customRoot, savedRoot)
}
if _, err := os.Stat(BootstrapPath()); err != nil {
t.Fatalf("expected bootstrap file to exist: %v", err)
}
resolvedRoot, err := ResolveActiveRoot()
if err != nil {
t.Fatalf("ResolveActiveRoot returned error: %v", err)
}
if resolvedRoot != customRoot {
t.Fatalf("expected custom root %q, got %q", customRoot, resolvedRoot)
}
}
func TestSetActiveRootResetToDefaultRemovesBootstrap(t *testing.T) {
homeDir := t.TempDir()
t.Setenv("HOME", homeDir)
t.Setenv("USERPROFILE", homeDir)
customRoot := filepath.Join(t.TempDir(), "gonavi-data")
if _, err := SetActiveRoot(customRoot); err != nil {
t.Fatalf("SetActiveRoot custom returned error: %v", err)
}
defaultRoot, err := SetActiveRoot("")
if err != nil {
t.Fatalf("SetActiveRoot default returned error: %v", err)
}
expectedDefault := filepath.Join(homeDir, ".gonavi")
if defaultRoot != expectedDefault {
t.Fatalf("expected default root %q, got %q", expectedDefault, defaultRoot)
}
if _, err := os.Stat(BootstrapPath()); !os.IsNotExist(err) {
t.Fatalf("expected bootstrap file to be removed, got err=%v", err)
}
}

View File

@@ -6,6 +6,8 @@ import (
"path/filepath"
"strings"
"sync"
"GoNavi-Wails/internal/appdata"
)
// coreBuiltinDrivers 是始终内置可用的核心驱动,无需额外安装即可使用。
@@ -110,13 +112,7 @@ func IsBuiltinDriver(driverType string) bool {
}
func defaultExternalDriverDownloadDirectory() string {
if home, err := os.UserHomeDir(); err == nil && strings.TrimSpace(home) != "" {
return filepath.Join(home, ".gonavi", "drivers")
}
if wd, err := os.Getwd(); err == nil && strings.TrimSpace(wd) != "" {
return filepath.Join(wd, ".gonavi-drivers")
}
return ".gonavi-drivers"
return appdata.DriverRoot("")
}
func resolveExternalDriverRoot(downloadDir string) (string, error) {