From a192a63c82da6e3dbf266834e8c65338f70f02d6 Mon Sep 17 00:00:00 2001 From: mircearoata Date: Wed, 6 Dec 2023 20:37:33 +0100 Subject: [PATCH] feat: add mod updating (#42) * feat: add mod updating * fix: refactor for previous changes * test: add mod update tests --------- Co-authored-by: Vilsol --- README.md | 102 ++++----- cli/context.go | 23 ++ cli/disk/main.go | 21 ++ cli/installations.go | 52 +++++ cli/lockfile.go | 8 + cli/resolving_test.go | 61 +++++ tea/scenes/main_menu.go | 7 + tea/scenes/mods/update_mods.go | 393 +++++++++++++++++++++++++++++++++ 8 files changed, 616 insertions(+), 51 deletions(-) create mode 100644 tea/scenes/mods/update_mods.go diff --git a/README.md b/README.md index 70b62f9..eb3ca0f 100644 --- a/README.md +++ b/README.md @@ -4,58 +4,58 @@ A CLI tool for managing mods for the game Satisfactory ## Installation -### Windows + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
amd64386arm64armv7ppc64le
Windowsamd64386arm64armv7N/A
Archyay -S ficsit-cli-bin
Debianamd64386arm64armv7ppc64le
Fedoraamd64386arm64armv7ppc64le
Alpineamd64386arm64armv7ppc64le
macOSdarwin_allN/A
-Download the appropriate `.exe` for your CPU architecture. - -* [AMD64 (64-bit)](https://github.com/Vilsol/ficsit-cli/releases/latest/download/ficsit_windows_amd64.exe) -* [386 (32-bit)](https://github.com/Vilsol/ficsit-cli/releases/latest/download/ficsit_windows_386.exe) -* [ARM64 (64-bit ARM)](https://github.com/Vilsol/ficsit-cli/releases/latest/download/ficsit_windows_arm64.exe) -* [ARMv7 (32-bit ARM)](https://github.com/Vilsol/ficsit-cli/releases/latest/download/ficsit_windows_armv7.exe) - -### Linux - -#### Arch - -A package is published to AUR under the name [`ficsit-cli-bin`](https://aur.archlinux.org/packages/ficsit-cli-bin) - -```shell -yay -S ficsit-cli-bin -``` - -#### Debian (inc. Ubuntu, Mint, PopOS!, etc) - -Download the appropriate `.deb` for your CPU architecture. - -* [AMD64 (64-bit)](https://github.com/Vilsol/ficsit-cli/releases/latest/download/ficsit_linux_amd64.deb) -* [386 (32-bit)](https://github.com/Vilsol/ficsit-cli/releases/latest/download/ficsit_linux_386.deb) -* [ARM64 (64-bit ARM)](https://github.com/Vilsol/ficsit-cli/releases/latest/download/ficsit_linux_arm64.deb) -* [ARMv7 (32-bit ARM)](https://github.com/Vilsol/ficsit-cli/releases/latest/download/ficsit_linux_armv7.deb) -* [PowerPC64](https://github.com/Vilsol/ficsit-cli/releases/latest/download/ficsit_linux_ppc64le.deb) - -#### Fedora - -Download the appropriate `.rpm` for your CPU architecture. - -* [AMD64 (64-bit)](https://github.com/Vilsol/ficsit-cli/releases/latest/download/ficsit_linux_amd64.rpm) -* [386 (32-bit)](https://github.com/Vilsol/ficsit-cli/releases/latest/download/ficsit_linux_386.rpm) -* [ARM64 (64-bit ARM)](https://github.com/Vilsol/ficsit-cli/releases/latest/download/ficsit_linux_arm64.rpm) -* [ARMv7 (32-bit ARM)](https://github.com/Vilsol/ficsit-cli/releases/latest/download/ficsit_linux_armv7.rpm) -* [PowerPC64](https://github.com/Vilsol/ficsit-cli/releases/latest/download/ficsit_linux_ppc64le.rpm) - -#### Alpine - -Download the appropriate `.apk` for your CPU architecture. - -* [AMD64 (64-bit)](https://github.com/Vilsol/ficsit-cli/releases/latest/download/ficsit_linux_amd64.apk) -* [386 (32-bit)](https://github.com/Vilsol/ficsit-cli/releases/latest/download/ficsit_linux_386.apk) -* [ARM64 (64-bit ARM)](https://github.com/Vilsol/ficsit-cli/releases/latest/download/ficsit_linux_arm64.apk) -* [ARMv7 (32-bit ARM)](https://github.com/Vilsol/ficsit-cli/releases/latest/download/ficsit_linux_armv7.apk) -* [PowerPC64](https://github.com/Vilsol/ficsit-cli/releases/latest/download/ficsit_linux_ppc64le.apk) - -### macOS - -Download the "all" build [here](https://github.com/Vilsol/ficsit-cli/releases/latest/download/ficsit_darwin_all). ## Usage diff --git a/cli/context.go b/cli/context.go index 7352f8c..0bf1ed7 100644 --- a/cli/context.go +++ b/cli/context.go @@ -64,6 +64,29 @@ func InitCLI(apiOnly bool) (*GlobalContext, error) { return globalContext, nil } +// Wipe will remove any trace of ficsit anywhere +func (g *GlobalContext) Wipe() error { + // Wipe all installations + for _, installation := range g.Installations.Installations { + if err := installation.Wipe(); err != nil { + return errors.Wrap(err, "failed wiping installation") + } + + if err := g.Installations.DeleteInstallation(installation.Path); err != nil { + return errors.Wrap(err, "failed deleting installation") + } + } + + // Wipe all profiles + for _, profile := range g.Profiles.Profiles { + if err := g.Profiles.DeleteProfile(profile.Name); err != nil { + return errors.Wrap(err, "failed deleting profile") + } + } + + return g.Save() +} + func (g *GlobalContext) Save() error { if err := g.Installations.Save(); err != nil { return errors.Wrap(err, "failed to save installations") diff --git a/cli/disk/main.go b/cli/disk/main.go index c438352..018a2ae 100644 --- a/cli/disk/main.go +++ b/cli/disk/main.go @@ -9,14 +9,35 @@ import ( ) type Disk interface { + // Exists checks if the provided file or directory exists Exists(path string) error + + // Read returns the entire file as a byte buffer + // + // Returns error if provided path is not a file Read(path string) ([]byte, error) + + // Write writes provided byte buffer to the path Write(path string, data []byte) error + + // Remove deletes the provided file or directory recursively Remove(path string) error + + // MkDir creates the provided directory recursively MkDir(path string) error + + // ReadDir returns all entries within the directory + // + // Returns error if provided path is not a directory ReadDir(path string) ([]Entry, error) + + // IsNotExist returns true if provided error is a not-exist type error IsNotExist(err error) bool + + // IsExist returns true if provided error is a does-exist type error IsExist(err error) bool + + // Open opens provided path for writing Open(path string, flag int) (io.WriteCloser, error) } diff --git a/cli/installations.go b/cli/installations.go index 235167e..403beb3 100644 --- a/cli/installations.go +++ b/cli/installations.go @@ -312,6 +312,20 @@ func (i *Installation) WriteLockFile(ctx *GlobalContext, lockfile LockFile) erro return nil } +func (i *Installation) Wipe() error { + d, err := i.GetDisk() + if err != nil { + return err + } + + modsDirectory := filepath.Join(i.BasePath(), "FactoryGame", "Mods") + if err := d.Remove(modsDirectory); err != nil { + return errors.Wrap(err, "failed removing Mods directory") + } + + return nil +} + func (i *Installation) ResolveProfile(ctx *GlobalContext) (LockFile, error) { lockFile, err := i.LockFile(ctx) if err != nil { @@ -466,6 +480,44 @@ func (i *Installation) Install(ctx *GlobalContext, updates chan InstallUpdate) e return nil } +func (i *Installation) UpdateMods(ctx *GlobalContext, mods []string) error { + if err := i.Validate(ctx); err != nil { + return errors.Wrap(err, "failed to validate installation") + } + + lockFile, err := i.LockFile(ctx) + if err != nil { + return errors.Wrap(err, "failed to read lock file") + } + + resolver := NewDependencyResolver(ctx.Provider) + + gameVersion, err := i.GetGameVersion(ctx) + if err != nil { + return errors.Wrap(err, "failed to detect game version") + } + + profile := ctx.Profiles.GetProfile(i.Profile) + if profile == nil { + return errors.New("could not find profile " + i.Profile) + } + + for _, modReference := range mods { + lockFile = lockFile.Remove(modReference) + } + + newLockFile, err := profile.Resolve(resolver, lockFile, gameVersion) + if err != nil { + return errors.Wrap(err, "failed to resolve dependencies") + } + + if err := i.WriteLockFile(ctx, newLockFile); err != nil { + return errors.Wrap(err, "failed to write lock file") + } + + return nil +} + func (i *Installation) SetProfile(ctx *GlobalContext, profile string) error { found := false for _, p := range ctx.Profiles.Profiles { diff --git a/cli/lockfile.go b/cli/lockfile.go index 9e90e40..f68e2d7 100644 --- a/cli/lockfile.go +++ b/cli/lockfile.go @@ -16,3 +16,11 @@ func (l LockFile) Clone() LockFile { } return lockFile } + +func (l *LockFile) Remove(modID ...string) *LockFile { + out := *l + for _, s := range modID { + delete(out, s) + } + return &out +} diff --git a/cli/resolving_test.go b/cli/resolving_test.go index 8060621..ad586a9 100644 --- a/cli/resolving_test.go +++ b/cli/resolving_test.go @@ -2,6 +2,7 @@ package cli import ( "math" + "os" "testing" "github.com/MarvinJWendt/testza" @@ -69,3 +70,63 @@ func TestResolutionNonExistentMod(t *testing.T) { testza.AssertEqual(t, "failed resolving profile dependencies: failed to solve dependencies: failed to make decision: failed to get package versions: mod ThisModDoesNotExist$$$ not found", err.Error()) } + +func TestUpdateMods(t *testing.T) { + ctx, err := InitCLI(false) + testza.AssertNoError(t, err) + + err = ctx.Wipe() + testza.AssertNoError(t, err) + + resolver := NewDependencyResolver(ctx.Provider) + + oldLockfile, err := (&Profile{ + Name: DefaultProfileName, + Mods: map[string]ProfileMod{ + "AreaActions": { + Version: "1.6.5", + Enabled: true, + }, + }, + }).Resolve(resolver, nil, math.MaxInt) + + testza.AssertNoError(t, err) + testza.AssertNotNil(t, oldLockfile) + testza.AssertLen(t, oldLockfile, 2) + + profileName := "UpdateTest" + profile, err := ctx.Profiles.AddProfile(profileName) + testza.AssertNoError(t, err) + testza.AssertNoError(t, profile.AddMod("AreaActions", "<=1.6.6")) + + serverLocation := os.Getenv("SF_DEDICATED_SERVER") + if serverLocation != "" { + installation, err := ctx.Installations.AddInstallation(ctx, serverLocation, profileName) + testza.AssertNoError(t, err) + testza.AssertNotNil(t, installation) + + err = installation.WriteLockFile(ctx, oldLockfile) + testza.AssertNoError(t, err) + + err = installation.Install(ctx, nil) + testza.AssertNoError(t, err) + + lockFile, err := installation.LockFile(ctx) + testza.AssertNoError(t, err) + + testza.AssertEqual(t, 2, len(*lockFile)) + testza.AssertEqual(t, "1.6.5", (*lockFile)["AreaActions"].Version) + + err = installation.UpdateMods(ctx, []string{"AreaActions"}) + testza.AssertNoError(t, err) + + lockFile, err = installation.LockFile(ctx) + testza.AssertNoError(t, err) + + testza.AssertEqual(t, 2, len(*lockFile)) + testza.AssertEqual(t, "1.6.6", (*lockFile)["AreaActions"].Version) + + err = installation.Install(ctx, nil) + testza.AssertNoError(t, err) + } +} diff --git a/tea/scenes/main_menu.go b/tea/scenes/main_menu.go index 1d017da..5d9c0cb 100644 --- a/tea/scenes/main_menu.go +++ b/tea/scenes/main_menu.go @@ -101,6 +101,13 @@ func NewMainMenu(root components.RootModel) tea.Model { return newModel, newModel.Init() }, }, + utils.SimpleItem[mainMenu]{ + ItemTitle: "Update Mods", + Activate: func(msg tea.Msg, currentModel mainMenu) (tea.Model, tea.Cmd) { + newModel := mods.NewUpdateMods(root, currentModel) + return newModel, newModel.Init() + }, + }, utils.SimpleItem[mainMenu]{ ItemTitle: "Apply Changes", Activate: func(msg tea.Msg, currentModel mainMenu) (tea.Model, tea.Cmd) { diff --git a/tea/scenes/mods/update_mods.go b/tea/scenes/mods/update_mods.go new file mode 100644 index 0000000..d691a0b --- /dev/null +++ b/tea/scenes/mods/update_mods.go @@ -0,0 +1,393 @@ +package mods + +import ( + "context" + "fmt" + "io" + "sort" + "time" + + "github.com/charmbracelet/bubbles/key" + "github.com/charmbracelet/bubbles/list" + "github.com/charmbracelet/bubbles/spinner" + tea "github.com/charmbracelet/bubbletea" + "github.com/charmbracelet/lipgloss" + "github.com/muesli/reflow/truncate" + + "github.com/satisfactorymodding/ficsit-cli/cli" + "github.com/satisfactorymodding/ficsit-cli/ficsit" + "github.com/satisfactorymodding/ficsit-cli/tea/components" + "github.com/satisfactorymodding/ficsit-cli/tea/scenes/keys" + "github.com/satisfactorymodding/ficsit-cli/tea/utils" +) + +var _ tea.Model = (*updateModsList)(nil) + +type updateModsList struct { + root components.RootModel + list list.Model + parent tea.Model + items chan listUpdate + + err chan string + error *components.ErrorComponent + + selectedMods []string +} + +func NewUpdateMods(root components.RootModel, parent tea.Model) tea.Model { + if root.GetCurrentProfile() == nil { + return parent + } + if root.GetCurrentInstallation() == nil { + return parent + } + + l := list.New([]list.Item{}, updateModsListDelegate{ItemDelegate: utils.NewItemDelegate(), selectedMods: []string{}}, root.Size().Width, root.Size().Height-root.Height()) + l.SetShowStatusBar(true) + l.SetShowFilter(true) + l.SetFilteringEnabled(true) + l.SetSpinner(spinner.MiniDot) + l.Title = "Update Mods" + l.Styles = utils.ListStyles + l.SetSize(l.Width(), l.Height()) + l.KeyMap.Quit.SetHelp("q", "back") + l.DisableQuitKeybindings() + + l.AdditionalShortHelpKeys = func() []key.Binding { + return []key.Binding{ + key.NewBinding(key.WithHelp("q", "back")), + key.NewBinding(key.WithHelp("space", "select")), + key.NewBinding(key.WithHelp("enter", "confirm")), + } + } + + l.AdditionalFullHelpKeys = func() []key.Binding { + return []key.Binding{ + key.NewBinding(key.WithHelp("q", "back")), + key.NewBinding(key.WithHelp("space", "select")), + key.NewBinding(key.WithHelp("enter", "confirm")), + } + } + + return &updateModsList{ + root: root, + list: l, + parent: parent, + items: make(chan listUpdate), + err: make(chan string), + } +} + +func (m updateModsList) Init() tea.Cmd { + go m.LoadModData() + return utils.Ticker() +} + +type modUpdate struct { + Reference string + From string + To string +} + +type modToggleMsg struct { + reference string +} + +func (m updateModsList) LoadModData() { + currentInstallation := m.root.GetCurrentInstallation() + currentProfile := m.root.GetCurrentProfile() + + currentLockfile, err := m.root.GetCurrentInstallation().LockFile(m.root.GetGlobal()) + if err != nil { + return + } + if currentLockfile == nil { + return + } + + gameVersion, err := currentInstallation.GetGameVersion(m.root.GetGlobal()) + if err != nil { + return + } + + resolver := cli.NewDependencyResolver(m.root.GetProvider()) + + updatedLockfile, err := currentProfile.Resolve(resolver, nil, gameVersion) + if err != nil { + return + } + + items := make([]list.Item, 0) + i := 0 + for reference, currentLockedMod := range *currentLockfile { + r := reference + updatedLockedMod, ok := updatedLockfile[reference] + if !ok { + continue + } + if updatedLockedMod.Version == currentLockedMod.Version { + continue + } + items = append(items, utils.SimpleItemExtra[updateModsList, modUpdate]{ + SimpleItem: utils.SimpleItem[updateModsList]{ + ItemTitle: fmt.Sprintf("%s - %s -> %s", reference, currentLockedMod.Version, updatedLockedMod.Version), + Activate: func(msg tea.Msg, currentModel updateModsList) (tea.Model, tea.Cmd) { + return currentModel, func() tea.Msg { + return modToggleMsg{reference: r} + } + }, + }, + Extra: modUpdate{ + Reference: r, + From: currentLockedMod.Version, + To: updatedLockedMod.Version, + }, + }) + i++ + } + + sort.Slice(items, func(i, j int) bool { + a := items[i].(utils.SimpleItemExtra[updateModsList, modUpdate]) + b := items[j].(utils.SimpleItemExtra[updateModsList, modUpdate]) + return ascDesc(sortOrderDesc, a.ItemTitle < b.ItemTitle) + }) + + m.items <- listUpdate{ + Items: items, + Done: false, + } + + m.loadModNames(items) +} + +func (m updateModsList) loadModNames(items []list.Item) { + if len(items) == 0 { + m.items <- listUpdate{ + Items: items, + Done: true, + } + return + } + + references := make([]string, len(items)) + i := 0 + for _, item := range items { + references[i] = item.(utils.SimpleItemExtra[updateModsList, modUpdate]).Extra.Reference + i++ + } + + mods, err := ficsit.Mods(context.TODO(), m.root.GetAPIClient(), ficsit.ModFilter{ + References: references, + }) + if err != nil { + m.err <- err.Error() + return + } + + if len(mods.Mods.Mods) == 0 { + return + } + + newItems := make([]list.Item, len(mods.Mods.Mods)) + for i, mod := range mods.Mods.Mods { + // Re-reference struct + mod := mod + var currentModUpdate modUpdate + for _, item := range items { + currentModUpdate = item.(utils.SimpleItemExtra[updateModsList, modUpdate]).Extra + if currentModUpdate.Reference == mod.Mod_reference { + break + } + } + newItems[i] = utils.SimpleItemExtra[updateModsList, modUpdate]{ + SimpleItem: utils.SimpleItem[updateModsList]{ + ItemTitle: fmt.Sprintf("%s - %s -> %s", mod.Name, currentModUpdate.From, currentModUpdate.To), + Activate: func(msg tea.Msg, currentModel updateModsList) (tea.Model, tea.Cmd) { + return currentModel, func() tea.Msg { + return modToggleMsg{reference: mod.Mod_reference} + } + }, + }, + Extra: currentModUpdate, + } + } + + sort.Slice(newItems, func(i, j int) bool { + a := newItems[i].(utils.SimpleItemExtra[updateModsList, modUpdate]) + b := newItems[j].(utils.SimpleItemExtra[updateModsList, modUpdate]) + return ascDesc(sortOrderDesc, a.Extra.Reference < b.Extra.Reference) + }) + + m.items <- listUpdate{ + Items: newItems, + Done: true, + } +} + +func (m updateModsList) Update(msg tea.Msg) (tea.Model, tea.Cmd) { + // List enables its own keybindings when they were previously disabled + m.list.DisableQuitKeybindings() + + cmds := make([]tea.Cmd, 0) + + switch msg := msg.(type) { + case tea.KeyMsg: + if m.list.SettingFilter() { + var cmd tea.Cmd + m.list, cmd = m.list.Update(msg) + return m, cmd + } + + switch keypress := msg.String(); keypress { + case keys.KeyControlC: + return m, tea.Quit + case "q": + if m.parent != nil { + m.parent.Update(m.root.Size()) + return m.parent, nil + } + return m, tea.Quit + case " ": + i, ok := m.list.SelectedItem().(utils.SimpleItem[updateModsList]) + if ok { + return m.processActivation(i, msg) + } + i2, ok := m.list.SelectedItem().(utils.SimpleItemExtra[updateModsList, modUpdate]) + if ok { + return m.processActivation(i2.SimpleItem, msg) + } + return m, nil + case keys.KeyEnter: + if len(m.selectedMods) > 0 { + err := m.root.GetCurrentInstallation().UpdateMods(m.root.GetGlobal(), m.selectedMods) + if err != nil { + m.err <- err.Error() + return m, nil + } + } + if m.parent != nil { + m.parent.Update(m.root.Size()) + return m.parent, nil + } + return m, tea.Quit + } + case tea.WindowSizeMsg: + top, right, bottom, left := lipgloss.NewStyle().Margin(m.root.Height(), 2, 0).GetMargin() + m.list.SetSize(msg.Width-left-right, msg.Height-top-bottom) + m.root.SetSize(msg) + case utils.TickMsg: + select { + case items := <-m.items: + cmd := m.list.SetItems(items.Items) + if items.Done { + m.list.StopSpinner() + return m, cmd + } + return m, tea.Batch(utils.Ticker(), cmd) + case err := <-m.err: + errorComponent, cmd := components.NewErrorComponent(err, time.Second*5) + m.error = errorComponent + return m, cmd + default: + start := m.list.StartSpinner() + return m, tea.Batch(utils.Ticker(), start) + } + case modToggleMsg: + idx := -1 + for i, mod := range m.selectedMods { + if mod == msg.reference { + idx = i + break + } + } + if idx != -1 { + m.selectedMods = append(m.selectedMods[:idx], m.selectedMods[idx+1:]...) + } else { + m.selectedMods = append(m.selectedMods, msg.reference) + } + cmds = append(cmds, func() tea.Msg { return selectedModsUpdateMsg{selectedMods: m.selectedMods} }) + } + + newList, listCmd := m.list.Update(msg) + m.list = newList + cmds = append(cmds, listCmd) + + return m, tea.Batch(cmds...) +} + +func (m updateModsList) View() string { + m.list.SetSize(m.list.Width(), m.root.Size().Height-m.root.Height()) + return lipgloss.JoinVertical(lipgloss.Left, m.root.View(), m.list.View()) +} + +func (m updateModsList) processActivation(item utils.SimpleItem[updateModsList], msg tea.Msg) (tea.Model, tea.Cmd) { + if item.Activate != nil { + newModel, cmd := item.Activate(msg, m) + if newModel != nil || cmd != nil { + if newModel == nil { + newModel = m + } + return newModel, cmd + } + return m, nil + } + return m, nil +} + +type updateModsListDelegate struct { + list.ItemDelegate + selectedMods []string +} + +type selectedModsUpdateMsg struct { + selectedMods []string +} + +func (c updateModsListDelegate) Update(msg tea.Msg, m *list.Model) tea.Cmd { + if msg, ok := msg.(selectedModsUpdateMsg); ok { + c.selectedMods = msg.selectedMods + m.SetDelegate(c) + } + return nil +} + +func (c updateModsListDelegate) Render(w io.Writer, m list.Model, index int, item list.Item) { + realItem := item.(utils.SimpleItemExtra[updateModsList, modUpdate]) + realDelegate := c.ItemDelegate.(list.DefaultDelegate) + + title := realItem.Title() + + s := &realDelegate.Styles + + if m.Width() <= 0 { + return + } + + textwidth := uint(m.Width() - s.NormalTitle.GetPaddingLeft() - s.NormalTitle.GetPaddingRight()) + title = truncate.StringWithTail(title, textwidth, "…") + + isSelected := index == m.Index() + + isUpdating := false + for _, mod := range c.selectedMods { + if mod == realItem.Extra.Reference { + isUpdating = true + } + } + + var checkbox string + if isUpdating { + checkbox = lipgloss.NewStyle().Foreground(lipgloss.Color("40")).Render("[✓]") + } else { + checkbox = lipgloss.NewStyle().Foreground(lipgloss.Color("40")).Render("[ ]") + } + + if isSelected { + title = s.SelectedTitle.UnsetBorderLeft().UnsetPaddingLeft().Render(title) + } else { + title = s.NormalTitle.UnsetPaddingLeft().Render(title) + } + + fmt.Fprintf(w, "%s %s", checkbox, title) +}