Files
MyGoNavi/internal/app/methods_data_root.go
2026-06-22 15:12:42 +08:00

367 lines
12 KiB
Go

package app
import (
"encoding/json"
"errors"
"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 dataRootMigrationExcludedEntries = map[string]struct{}{
"storage_root.json": {},
}
type dataRootTextFunc func(string, map[string]any) string
type dataRootLocalizedError struct {
message string
cause error
}
func (e dataRootLocalizedError) Error() string {
return e.message
}
func (e dataRootLocalizedError) Unwrap() error {
return e.cause
}
func dataRootText(text dataRootTextFunc, key string, params map[string]any) string {
if text != nil {
return text(key, params)
}
return defaultAppText(key, params)
}
func dataRootError(text dataRootTextFunc, key string, params map[string]any) error {
return errors.New(dataRootText(text, key, params))
}
func dataRootErrorWithDetail(text dataRootTextFunc, key string, detail string, cause error, params map[string]any) error {
resolved := make(map[string]any, len(params)+1)
for name, value := range params {
resolved[name] = value
}
resolved["detail"] = detail
message := dataRootText(text, key, resolved)
if cause == nil {
return errors.New(message)
}
return dataRootLocalizedError{message: message, cause: cause}
}
func dataRootWrapError(text dataRootTextFunc, key string, cause error, params map[string]any) error {
if cause == nil {
return dataRootError(text, key, params)
}
return dataRootErrorWithDetail(text, key, cause.Error(), cause, params)
}
func dataRootLocalizeSetActiveRootError(text dataRootTextFunc, err error) error {
if err == nil {
return nil
}
switch {
case errors.Is(err, appdata.ErrSetActiveRootCreateDataDirectory):
detail := appdata.SetActiveRootErrorDetail(err)
if detail == nil {
detail = err
}
return dataRootErrorWithDetail(text, "app.data_root.backend.error.create_data_directory_failed", detail.Error(), err, nil)
case errors.Is(err, appdata.ErrSetActiveRootCreateBootstrapDirectory):
detail := appdata.SetActiveRootErrorDetail(err)
if detail == nil {
detail = err
}
return dataRootErrorWithDetail(text, "app.data_root.backend.error.create_bootstrap_directory_failed", detail.Error(), err, nil)
default:
return err
}
}
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: a.appText("app.data_root.backend.dialog.select_directory", nil),
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: a.appText("app.data_root.backend.message.unchanged", nil),
Data: dataRootInfoPayload(targetRoot),
}
}
if migrate {
if err := migrateDataRootContentsWithText(currentRoot, targetRoot, a.appText); err != nil {
return connection.QueryResult{Success: false, Message: err.Error()}
}
}
appliedRoot, err := appdata.SetActiveRoot(targetRoot)
if err != nil {
return connection.QueryResult{Success: false, Message: dataRootLocalizeSetActiveRootError(a.appText, err).Error()}
}
a.configDir = appliedRoot
db.SetExternalDriverDownloadDirectory(appdata.DriverRoot(appliedRoot))
message := a.appText("app.data_root.backend.message.updated_restart", nil)
if migrate {
message = a.appText("app.data_root.backend.message.migrated_restart", nil)
}
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: a.appText("app.data_root.backend.error.directory_unavailable", nil)}
}
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: a.appText("app.data_root.backend.error.open_directory_unsupported", map[string]any{"platform": stdRuntime.GOOS})}
}
if err := cmd.Start(); err != nil {
logger.Error(err, "打开数据目录失败")
return connection.QueryResult{Success: false, Message: a.appText("app.data_root.backend.error.open_directory_failed", map[string]any{"detail": err.Error()})}
}
return connection.QueryResult{Success: true, Message: a.appText("app.data_root.backend.message.opened", nil), Data: dataRootInfoPayload(root)}
}
func migrateDataRootContents(sourceRoot string, targetRoot string) error {
return migrateDataRootContentsWithText(sourceRoot, targetRoot, nil)
}
func migrateDataRootContentsWithText(sourceRoot string, targetRoot string, text dataRootTextFunc) error {
sourceRoot = strings.TrimSpace(sourceRoot)
targetRoot = strings.TrimSpace(targetRoot)
if sourceRoot == "" || targetRoot == "" {
return dataRootError(text, "app.data_root.backend.error.directory_empty", nil)
}
sourceAbs, err := filepath.Abs(sourceRoot)
if err != nil {
return dataRootWrapError(text, "app.data_root.backend.error.resolve_source_failed", err, nil)
}
targetAbs, err := filepath.Abs(targetRoot)
if err != nil {
return dataRootWrapError(text, "app.data_root.backend.error.resolve_target_failed", err, nil)
}
if filepath.Clean(sourceAbs) == filepath.Clean(targetAbs) {
return nil
}
if rel, err := filepath.Rel(sourceAbs, targetAbs); err == nil && rel != "." && rel != "" && !strings.HasPrefix(rel, "..") && !filepath.IsAbs(rel) {
return dataRootError(text, "app.data_root.backend.error.target_inside_source", nil)
}
sourceRoot = sourceAbs
targetRoot = targetAbs
if err := os.MkdirAll(targetRoot, 0o755); err != nil {
return dataRootWrapError(text, "app.data_root.backend.error.create_target_failed", err, nil)
}
entries, err := os.ReadDir(sourceRoot)
if err != nil {
return dataRootWrapError(text, "app.data_root.backend.error.read_source_root_failed", err, nil)
}
for _, entry := range entries {
name := strings.TrimSpace(entry.Name())
if name == "" {
continue
}
if _, excluded := dataRootMigrationExcludedEntries[name]; excluded {
continue
}
sourcePath := filepath.Join(sourceRoot, name)
targetPath := filepath.Join(targetRoot, name)
info, err := entry.Info()
if err != nil {
return dataRootWrapError(text, "app.data_root.backend.error.read_source_failed", err, map[string]any{"entry": name})
}
if info.IsDir() {
if err := copyDir(sourcePath, targetPath); err != nil {
return dataRootWrapError(text, "app.data_root.backend.error.migrate_directory_failed", err, map[string]any{"entry": name})
}
continue
}
if err := copyFile(sourcePath, targetPath, info.Mode()); err != nil {
return dataRootWrapError(text, "app.data_root.backend.error.migrate_file_failed", err, map[string]any{"entry": name})
}
}
if err := rewriteMigratedDataRootStateWithText(targetRoot, text); err != nil {
return err
}
return nil
}
func rewriteMigratedDataRootState(targetRoot string) error {
return rewriteMigratedDataRootStateWithText(targetRoot, nil)
}
func rewriteMigratedDataRootStateWithText(targetRoot string, text dataRootTextFunc) error {
if err := rewriteSecurityUpdateBackupPathsWithText(targetRoot, text); err != nil {
return err
}
return nil
}
func rewriteSecurityUpdateBackupPaths(targetRoot string) error {
return rewriteSecurityUpdateBackupPathsWithText(targetRoot, nil)
}
func rewriteSecurityUpdateBackupPathsWithText(targetRoot string, text dataRootTextFunc) error {
repo := newSecurityUpdateStateRepository(targetRoot)
marker, err := repo.readMarker()
if err != nil {
if os.IsNotExist(err) {
return nil
}
return dataRootWrapError(text, "app.data_root.backend.error.read_migrated_security_update_state_failed", err, nil)
}
migrationID := strings.TrimSpace(marker.MigrationID)
if migrationID == "" {
return nil
}
targetBackupPath := repo.backupPath(migrationID)
marker.BackupPath = targetBackupPath
if err := repo.writeMarker(marker); err != nil {
return dataRootWrapError(text, "app.data_root.backend.error.write_migrated_security_update_state_failed", err, nil)
}
manifestPath := repo.manifestPath(migrationID)
manifestData, err := os.ReadFile(manifestPath)
if err != nil {
if !os.IsNotExist(err) {
return dataRootWrapError(text, "app.data_root.backend.error.read_migrated_security_update_manifest_failed", err, nil)
}
} else {
var manifest securityUpdateBackupManifest
if err := json.Unmarshal(manifestData, &manifest); err != nil {
return dataRootWrapError(text, "app.data_root.backend.error.parse_migrated_security_update_manifest_failed", err, nil)
}
manifest.BackupPath = targetBackupPath
if err := securityUpdateWriteJSONFile(manifestPath, manifest); err != nil {
return dataRootWrapError(text, "app.data_root.backend.error.write_migrated_security_update_manifest_failed", err, nil)
}
}
resultPath := repo.resultPath(migrationID)
resultData, err := os.ReadFile(resultPath)
if err != nil {
if !os.IsNotExist(err) {
return dataRootWrapError(text, "app.data_root.backend.error.read_migrated_security_update_result_failed", err, nil)
}
} else {
var result SecurityUpdateStatus
if err := json.Unmarshal(resultData, &result); err != nil {
return dataRootWrapError(text, "app.data_root.backend.error.parse_migrated_security_update_result_failed", err, nil)
}
result.BackupPath = targetBackupPath
result.BackupAvailable = strings.TrimSpace(targetBackupPath) != ""
if err := securityUpdateWriteJSONFile(resultPath, result); err != nil {
return dataRootWrapError(text, "app.data_root.backend.error.write_migrated_security_update_result_failed", err, nil)
}
}
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)
}