diff --git a/backend/bindings/app.go b/backend/bindings/app.go index 9a5f2746..38e4fe50 100644 --- a/backend/bindings/app.go +++ b/backend/bindings/app.go @@ -152,6 +152,31 @@ func (a *App) OpenFileDialog(options OpenDialogOptions) (string, error) { return file, nil } +func (a *App) OpenDirectoryDialog(options OpenDialogOptions) (string, error) { + wailsFilters := make([]wailsRuntime.FileFilter, len(options.Filters)) + for i, filter := range options.Filters { + wailsFilters[i] = wailsRuntime.FileFilter{ + DisplayName: filter.DisplayName, + Pattern: filter.Pattern, + } + } + wailsOptions := wailsRuntime.OpenDialogOptions{ + DefaultDirectory: options.DefaultDirectory, + DefaultFilename: options.DefaultFilename, + Title: options.Title, + Filters: wailsFilters, + ShowHiddenFiles: options.ShowHiddenFiles, + CanCreateDirectories: options.CanCreateDirectories, + ResolvesAliases: options.ResolvesAliases, + TreatPackagesAsDirectories: options.TreatPackagesAsDirectories, + } + file, err := wailsRuntime.OpenDirectoryDialog(a.ctx, wailsOptions) + if err != nil { + return "", errors.Wrap(err, "failed to open directory dialog") + } + return file, nil +} + func (a *App) ExternalInstallMod(modID, version string) { wailsRuntime.EventsEmit(a.ctx, "externalInstallMod", modID, version) } diff --git a/backend/bindings/ficsitcli/wrapper.go b/backend/bindings/ficsitcli/wrapper.go index de8600b3..d292ff27 100644 --- a/backend/bindings/ficsitcli/wrapper.go +++ b/backend/bindings/ficsitcli/wrapper.go @@ -3,15 +3,18 @@ package ficsitcli import ( "context" "log/slog" + "os" "time" "github.com/mitchellh/go-ps" "github.com/pkg/errors" "github.com/satisfactorymodding/ficsit-cli/cli" "github.com/satisfactorymodding/ficsit-cli/cli/provider" + "github.com/spf13/viper" wailsRuntime "github.com/wailsapp/wails/v2/pkg/runtime" "github.com/satisfactorymodding/SatisfactoryModManager/backend/settings" + "github.com/satisfactorymodding/SatisfactoryModManager/backend/utils" ) type FicsitCLI struct { @@ -76,3 +79,80 @@ func (f *FicsitCLI) setProgress(p *Progress) { f.progress = p wailsRuntime.EventsEmit(f.ctx, "progress", p) } + +func ValidateCacheDir(dir string) error { + stat, err := os.Stat(dir) + if err != nil { + if !os.IsNotExist(err) { + return errors.Wrapf(err, "failed to stat %s", dir) + } + } else { + if !stat.IsDir() { + return errors.Errorf("%s is not a directory", dir) + } + } + return nil +} + +func MoveCacheDir(newDir string) error { + if newDir == viper.GetString("cache-dir") { + return nil + } + + err := ValidateCacheDir(newDir) + if err != nil { + return err + } + + err = os.MkdirAll(newDir, 0o755) + if err != nil { + if !os.IsExist(err) { + return errors.Wrapf(err, "failed to create %s", newDir) + } + } + + items, err := os.ReadDir(newDir) + if err != nil { + return errors.Wrapf(err, "failed to check if directory %s is empty", newDir) + } + if len(items) > 0 { + return errors.Errorf("directory %s is not empty", newDir) + } + + oldCacheDir := viper.GetString("cache-dir") + // Move contents of oldCacheDir to dir + if oldCacheDir != "" && oldCacheDir != newDir { + err := moveCacheData(oldCacheDir, newDir) + if err != nil { + return err + } + } + + viper.Set("cache-dir", newDir) + return nil +} + +func moveCacheData(oldCacheDir, newDir string) error { + oldStat, err := os.Stat(oldCacheDir) + if err != nil { + if os.IsNotExist(err) { + // Nothing to move + return nil + } + return errors.Wrapf(err, "failed to stat %s", oldCacheDir) + } + if !oldStat.IsDir() { + return errors.Errorf("%s is not a directory", oldCacheDir) + } + + // Perform the move atomically + copySuccess, err := utils.MoveRecursive(oldCacheDir, newDir) + if err != nil { + if !copySuccess { + return err + } + slog.Error("failed to move cache dir", slog.Any("error", err)) + } + + return nil +} diff --git a/backend/bindings/settings.go b/backend/bindings/settings.go index 9a89cf05..28a922de 100644 --- a/backend/bindings/settings.go +++ b/backend/bindings/settings.go @@ -2,9 +2,12 @@ package bindings import ( "context" + "log/slog" + "github.com/spf13/viper" wailsRuntime "github.com/wailsapp/wails/v2/pkg/runtime" + "github.com/satisfactorymodding/SatisfactoryModManager/backend/bindings/ficsitcli" "github.com/satisfactorymodding/SatisfactoryModManager/backend/settings" ) @@ -175,3 +178,22 @@ func (s *Settings) SetAnnouncementViewed(announcement string) { _ = settings.SaveSettings() wailsRuntime.EventsEmit(s.ctx, "viewedAnnouncements", settings.Settings.ViewedAnnouncements) } + +func (s *Settings) SetCacheDir(dir string) error { + if dir == "" { + dir = viper.GetString("default-cache-dir") + } + err := ficsitcli.MoveCacheDir(dir) + if err != nil { + slog.Error("failed to set cache dir", slog.Any("error", err)) + return err + } + settings.Settings.CacheDir = dir + _ = settings.SaveSettings() + wailsRuntime.EventsEmit(s.ctx, "cacheDir", dir) + return nil +} + +func (s *Settings) GetCacheDir() string { + return viper.GetString("cache-dir") +} diff --git a/backend/settings/settings.go b/backend/settings/settings.go index 0c2fd1f9..96dc708c 100644 --- a/backend/settings/settings.go +++ b/backend/settings/settings.go @@ -52,6 +52,8 @@ type settings struct { Konami bool `json:"konami"` LaunchButton string `json:"launchButton"` + + CacheDir string `json:"cacheDir,omitempty"` } var Settings = settings{ diff --git a/backend/utils/os.go b/backend/utils/os.go new file mode 100644 index 00000000..4d367e0e --- /dev/null +++ b/backend/utils/os.go @@ -0,0 +1,86 @@ +package utils + +import ( + "io" + "os" + "path/filepath" + + "github.com/pkg/errors" +) + +func IsIn(dir, path string) bool { + rel, err := filepath.Rel(dir, path) + if err != nil { + return false + } + return filepath.IsLocal(rel) +} + +func CopyRecursive(from, to string) error { + return filepath.Walk(from, func(path string, info os.FileInfo, err error) error { //nolint:wrapcheck + if err != nil { + return err + } + if IsIn(to, path) { + return nil + } + relPath, err := filepath.Rel(from, path) + if err != nil { + return err //nolint:wrapcheck + } + newPath := filepath.Join(to, relPath) + if info.IsDir() { + err := os.Mkdir(newPath, 0o755) + if err != nil && !os.IsExist(err) { + return err //nolint:wrapcheck + } + return nil + } + f, err := os.Open(path) + if err != nil { + return err //nolint:wrapcheck + } + defer f.Close() + f2, err := os.Create(newPath) + if err != nil { + return err //nolint:wrapcheck + } + defer f2.Close() + _, err = io.Copy(f2, f) + return err //nolint:wrapcheck + }) +} + +func MoveRecursive(from, to string) (bool, error) { + err := CopyRecursive(from, to) + if err != nil { + return false, errors.Wrapf(err, "failed to copy %s to %s", from, to) + } + err = filepath.Walk(from, func(path string, info os.FileInfo, err error) error { + if err != nil { + if !os.IsNotExist(err) { + return err + } + return nil + } + if IsIn(path, to) { + // Skip parent directories of destination + return nil + } + if IsIn(to, path) { + // Skip contents of destination + return nil + } + err = os.RemoveAll(path) + if err != nil { + if !os.IsNotExist(err) { + return err //nolint:wrapcheck + } + } + return nil + }) + if err != nil { + return true, errors.Wrapf(err, "failed to remove %s", from) + } + return true, nil +} diff --git a/frontend/src/lib/components/left-bar/Settings.svelte b/frontend/src/lib/components/left-bar/Settings.svelte index 918513d6..34f6f10c 100644 --- a/frontend/src/lib/components/left-bar/Settings.svelte +++ b/frontend/src/lib/components/left-bar/Settings.svelte @@ -2,16 +2,20 @@ import Button, { Label } from '@smui/button'; import Menu, { SelectionGroup, SelectionGroupIcon } from '@smui/menu'; import List, { Item, PrimaryText, Text, Separator } from '@smui/list'; - import { mdiBug, mdiCheck, mdiChevronRight, mdiClipboard, mdiCog, mdiDownload, mdiTune } from '@mdi/js'; + import { mdiBug, mdiCheck, mdiChevronRight, mdiClipboard, mdiCog, mdiDownload, mdiFolderEdit, mdiTune } from '@mdi/js'; import { getContextClient } from '@urql/svelte'; + import Dialog, { Actions, Content, Title } from '@smui/dialog'; + import Textfield from '@smui/textfield'; + import HelperText from '@smui/textfield/helper-text'; import SvgIcon from '$lib/components/SVGIcon.svelte'; import { GenerateDebugInfo } from '$wailsjs/go/bindings/DebugInfo'; - import { startView, konami, launchButton, queueAutoStart, offline, updateCheckMode } from '$lib/store/settingsStore'; + import { startView, konami, launchButton, queueAutoStart, offline, updateCheckMode, cacheDir } from '$lib/store/settingsStore'; import { manifestMods, lockfileMods } from '$lib/store/ficsitCLIStore'; import { GetModNameDocument } from '$lib/generated'; import type { LaunchButtonType, ViewType } from '$lib/wailsTypesExtensions'; import { OfflineGetMod } from '$wailsjs/go/ficsitcli/FicsitCLI'; + import { OpenDirectoryDialog } from '$lib/generated/wailsjs/go/bindings/App'; let settingsMenu: Menu; let startViewMenu: Menu; @@ -116,6 +120,75 @@ }); navigator.clipboard.writeText(modListString.trim()); } + + let cacheDialog = false; + let cacheError: string | null = null; + let newCacheLocation = $cacheDir; + + let fileDialogOpen = false; + async function pickCacheLocation() { + if(fileDialogOpen) { + return; + } + fileDialogOpen = true; + try { + let result = await OpenDirectoryDialog({ + defaultDirectory: newCacheLocation ?? undefined, + }); + if (result) { + newCacheLocation = result; + } + } catch (e) { + if(e instanceof Error) { + cacheError = e.message; + } else if (typeof e === 'string') { + cacheError = e; + } else { + cacheError = 'Unknown error'; + } + } finally { + fileDialogOpen = false; + } + } + + let cacheMoveInProgress = false; + + async function setCacheLocation() { + try { + cacheMoveInProgress = true; + await cacheDir.asyncSet(newCacheLocation ?? ''); + cacheError = null; + } catch(e) { + if (e instanceof Error) { + cacheError = e.message; + } else if (typeof e === 'string') { + cacheError = e; + } else { + cacheError = 'Unknown error'; + } + } finally { + cacheMoveInProgress = false; + } + } + + async function resetCacheLocation() { + try { + cacheMoveInProgress = true; + await cacheDir.asyncSet(''); + newCacheLocation = $cacheDir; + cacheError = null; + } catch(e) { + if (e instanceof Error) { + cacheError = e.message; + } else if (typeof e === 'string') { + cacheError = e; + } else { + cacheError = 'Unknown error'; + } + } finally { + cacheMoveInProgress = false; + } + }