mirror of
https://github.com/Syngnat/GoNavi.git
synced 2026-06-24 15:33:50 +08:00
367 lines
12 KiB
Go
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)
|
|
}
|