├── Icon.png ├── winres ├── icon.png ├── icon16.png └── winres.json ├── resources ├── bof3.png ├── bof4.png ├── mmm.png ├── cc │ ├── disc.png │ └── find.png ├── icon16.png ├── logos.zip └── chronocross.png ├── ui ├── state │ ├── gui │ │ └── gui.go │ ├── ui │ │ └── windows.go │ └── state.go ├── confirm │ ├── bypassConfirmer.go │ ├── enableRequiredMod.go │ ├── confirmer.go │ ├── hostedDownload.go │ └── nexusDownload.go ├── util │ ├── working │ │ └── working.go │ ├── rows.go │ ├── errLong.go │ ├── updateChecker.go │ ├── resources │ │ └── resources.go │ └── sandbox.go ├── custom-widgets │ ├── util.go │ ├── buttonWithPopup.go │ ├── openFileDialogEntry.go │ └── dynamicList.go ├── local │ ├── updateButton.go │ └── enableBinding.go ├── mod-author │ ├── previewDef.go │ ├── entry │ │ ├── bool.go │ │ ├── string.go │ │ ├── multiLine.go │ │ ├── select.go │ │ └── manager.go │ ├── richTextEditor.go │ ├── compatibility.go │ ├── donations.go │ ├── previewsDef.go │ ├── downloadFiles.go │ ├── alwaysDownloadDef.go │ ├── downloads.go │ ├── files.go │ ├── downloadsGitHub.go │ ├── dirs.go │ ├── configurations.go │ ├── downloadsRemote.go │ ├── games.go │ ├── modCompats.go │ ├── downloadsGoogleDrive.go │ ├── downloadsAt.go │ └── choices.go ├── game-select │ └── gameSelect.go ├── secret │ └── secrets.go ├── conflicts │ └── conflicts.go ├── configure │ └── configure.go ├── discover │ └── filterButton.go └── mod-preview │ └── preivew.go ├── config ├── game_linux.go ├── game_windows.go └── secrets │ └── secrets.go ├── makefile ├── .gitignore ├── mods ├── compatability.go ├── modCompat.go ├── downloadFiles.go ├── override.go ├── modStateChanger.go ├── managed │ ├── authored │ │ └── authored.go │ ├── gameModLookup.go │ ├── updates.go │ └── modTracker.go ├── modKind.go ├── downloadable.go ├── lookup.go ├── toInstall.go ├── trackedMod.go └── preview.go ├── collections └── set.go ├── cache └── imgCache.go ├── README.md ├── discover ├── remote │ ├── client.go │ ├── discover.go │ ├── github │ │ └── getter.go │ ├── util │ │ └── modCompiler.go │ ├── nexus │ │ └── mod.go │ └── curseforge │ │ └── mod.go ├── repo │ └── repoDef.go └── discoverer.go ├── LICENSE ├── .github └── workflows │ └── linux.yml ├── files ├── conflicts.go └── tracker.go ├── browser ├── download.go └── update.go ├── util └── files.go ├── actions └── steps │ └── extracted.go ├── downloads └── manager.go ├── main.go ├── go.mod └── archive └── decompress.go /Icon.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/KiameV/moogle-mod-manager/HEAD/Icon.png -------------------------------------------------------------------------------- /winres/icon.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/KiameV/moogle-mod-manager/HEAD/winres/icon.png -------------------------------------------------------------------------------- /resources/bof3.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/KiameV/moogle-mod-manager/HEAD/resources/bof3.png -------------------------------------------------------------------------------- /resources/bof4.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/KiameV/moogle-mod-manager/HEAD/resources/bof4.png -------------------------------------------------------------------------------- /resources/mmm.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/KiameV/moogle-mod-manager/HEAD/resources/mmm.png -------------------------------------------------------------------------------- /winres/icon16.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/KiameV/moogle-mod-manager/HEAD/winres/icon16.png -------------------------------------------------------------------------------- /resources/cc/disc.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/KiameV/moogle-mod-manager/HEAD/resources/cc/disc.png -------------------------------------------------------------------------------- /resources/cc/find.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/KiameV/moogle-mod-manager/HEAD/resources/cc/find.png -------------------------------------------------------------------------------- /resources/icon16.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/KiameV/moogle-mod-manager/HEAD/resources/icon16.png -------------------------------------------------------------------------------- /resources/logos.zip: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/KiameV/moogle-mod-manager/HEAD/resources/logos.zip -------------------------------------------------------------------------------- /resources/chronocross.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/KiameV/moogle-mod-manager/HEAD/resources/chronocross.png -------------------------------------------------------------------------------- /ui/state/gui/gui.go: -------------------------------------------------------------------------------- 1 | package gui 2 | 3 | import ( 4 | "fyne.io/fyne/v2/data/binding" 5 | ) 6 | 7 | var Current = binding.NewInt() 8 | -------------------------------------------------------------------------------- /config/game_linux.go: -------------------------------------------------------------------------------- 1 | //go:build !windows 2 | 3 | package config 4 | 5 | func (g *gameDef) SteamDirFromRegistry() string { 6 | return "" 7 | } 8 | -------------------------------------------------------------------------------- /makefile: -------------------------------------------------------------------------------- 1 | .DEFAULT_GOAL := build 2 | build: 3 | go-winres make 4 | go build -ldflags="-s -H=windowsgui" -o moogle-mod-manager.exe 5 | upx -9 -k moogle-mod-manager.exe 6 | rm moogle-mod-manager.ex~ 7 | mv moogle-mod-manager.exe ./bin/moogle-mod-manager.exe 8 | #7z a -tzip moogle-mod-manager.zip moogle-mod-manager.exe -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | */bin 2 | bin/* 3 | */obj 4 | *.vs 5 | *.json 6 | *.syso 7 | .idea 8 | temp 9 | */moogle-mod-manager.exe 10 | */moogle-mod-manager.zip 11 | */moogle-mod-manager.upx 12 | !example/* 13 | !winres/* 14 | scripts/* 15 | !scripts/findSupportedMods.go 16 | moogle-mod-manager.ex* 17 | moogle-mod-manager.upx 18 | rm.exe.stackdump -------------------------------------------------------------------------------- /ui/confirm/bypassConfirmer.go: -------------------------------------------------------------------------------- 1 | package confirm 2 | 3 | import ( 4 | "github.com/kiamev/moogle-mod-manager/mods" 5 | ) 6 | 7 | type bypassConfirmer struct { 8 | Params 9 | } 10 | 11 | func newBypassConfirmer(params Params) Confirmer { 12 | return &bypassConfirmer{Params: params} 13 | } 14 | 15 | func (_ *bypassConfirmer) Downloads(done func(mods.Result)) (err error) { 16 | done(mods.Ok) 17 | return nil 18 | } 19 | -------------------------------------------------------------------------------- /ui/state/ui/windows.go: -------------------------------------------------------------------------------- 1 | package ui 2 | 3 | import "fyne.io/fyne/v2" 4 | 5 | var ( 6 | App fyne.App 7 | Window fyne.Window 8 | PopupWindow fyne.Window 9 | ShowingPopup bool 10 | ) 11 | 12 | func ActiveWindow() fyne.Window { 13 | if ShowingPopup { 14 | if PopupWindow == nil { 15 | ShowingPopup = false 16 | } else { 17 | return PopupWindow 18 | } 19 | } 20 | return Window 21 | } 22 | -------------------------------------------------------------------------------- /mods/compatability.go: -------------------------------------------------------------------------------- 1 | package mods 2 | 3 | type ModCompatibility struct { 4 | Requires []*ModCompat `json:"Require,omitempty" xml:"Requires,omitempty"` 5 | Forbids []*ModCompat `json:"Forbid,omitempty" xml:"Forbids,omitempty"` 6 | // OrderConstraints []ModCompat `json:"OrderConstraint"` 7 | } 8 | 9 | func (c *ModCompatibility) HasItems() bool { 10 | return c != nil && (len(c.Requires) > 0 || len(c.Forbids) > 0) 11 | } 12 | -------------------------------------------------------------------------------- /ui/util/working/working.go: -------------------------------------------------------------------------------- 1 | package working 2 | 3 | import ( 4 | "fyne.io/fyne/v2/dialog" 5 | "github.com/kiamev/moogle-mod-manager/ui/state/ui" 6 | ) 7 | 8 | var workingDialog dialog.Dialog 9 | 10 | func ShowDialog() { 11 | if workingDialog == nil { 12 | if w := ui.ActiveWindow(); w != nil { 13 | workingDialog = dialog.NewInformation("Working", "Working...", w) 14 | workingDialog.Show() 15 | } 16 | } 17 | } 18 | 19 | func HideDialog() { 20 | if workingDialog != nil { 21 | workingDialog.Hide() 22 | workingDialog = nil 23 | } 24 | } 25 | -------------------------------------------------------------------------------- /mods/modCompat.go: -------------------------------------------------------------------------------- 1 | package mods 2 | 3 | type ( 4 | //ModCompatOrder string 5 | ModCompat struct { 6 | Versions []string `json:"Version,omitempty" xml:"Versions,omitempty"` 7 | ID ModID `json:"ModID,omitempty" xml:"ModID,omitempty"` 8 | //displayName string `json:"-" xml:"-"` 9 | } 10 | ) 11 | 12 | //const ( 13 | // None ModCompatOrder = "" 14 | // Before ModCompatOrder = "Before" 15 | // After ModCompatOrder = "After" 16 | //) 17 | //var ModCompatOrders = []string{string(None), string(Before), string(After)} 18 | 19 | func (c *ModCompat) ModID() ModID { 20 | return c.ID 21 | } 22 | -------------------------------------------------------------------------------- /ui/util/rows.go: -------------------------------------------------------------------------------- 1 | package util 2 | 3 | import ( 4 | "fyne.io/fyne/v2" 5 | "fyne.io/fyne/v2/container" 6 | "fyne.io/fyne/v2/theme" 7 | "fyne.io/fyne/v2/widget" 8 | "github.com/atotto/clipboard" 9 | "net/url" 10 | ) 11 | 12 | func CreateUrlRow(value string) *fyne.Container { 13 | t := widget.NewToolbarAction(theme.ContentCopyIcon(), func() { 14 | _ = clipboard.WriteAll(value) 15 | }) 16 | if u, err := url.Parse(value); err == nil { 17 | return container.NewBorder(nil, nil, nil, widget.NewToolbar(t), widget.NewHyperlink(value, u)) 18 | } 19 | return container.NewBorder(nil, nil, nil, widget.NewToolbar(t), widget.NewLabel(value)) 20 | } 21 | -------------------------------------------------------------------------------- /ui/custom-widgets/util.go: -------------------------------------------------------------------------------- 1 | package custom_widgets 2 | 3 | import ( 4 | "fyne.io/fyne/v2/data/binding" 5 | "github.com/kiamev/moogle-mod-manager/mods" 6 | ) 7 | 8 | func GetValueFromDataItem(di binding.DataItem) (result interface{}, ok bool) { 9 | switch u := di.(type) { 10 | case binding.Untyped: 11 | if i, err := u.Get(); err == nil { 12 | switch v := i.(type) { 13 | case binding.Untyped: 14 | result, err = v.Get() 15 | ok = err == nil 16 | return 17 | case *mods.Mod: 18 | result = v 19 | ok = true 20 | return 21 | case interface{}: 22 | result = v 23 | ok = true 24 | return 25 | } 26 | } 27 | } 28 | return nil, false 29 | } 30 | -------------------------------------------------------------------------------- /ui/util/errLong.go: -------------------------------------------------------------------------------- 1 | package util 2 | 3 | import ( 4 | "fyne.io/fyne/v2" 5 | "fyne.io/fyne/v2/container" 6 | "fyne.io/fyne/v2/dialog" 7 | "fyne.io/fyne/v2/widget" 8 | "github.com/atotto/clipboard" 9 | "github.com/kiamev/moogle-mod-manager/ui/state/ui" 10 | ) 11 | 12 | func ShowErrorLong(err error) { 13 | var text = widget.NewRichTextWithText(err.Error()) 14 | text.Wrapping = fyne.TextWrapBreak 15 | 16 | button := widget.NewButton("Copy To Clipboard", func() { 17 | _ = clipboard.WriteAll(err.Error()) 18 | }) 19 | 20 | errDialog := dialog.NewCustom("Error", "OK", container.NewBorder(button, nil, nil, nil, container.NewVScroll(text)), ui.ActiveWindow()) 21 | errDialog.Resize(fyne.NewSize(500, 400)) 22 | errDialog.Show() 23 | } 24 | -------------------------------------------------------------------------------- /config/game_windows.go: -------------------------------------------------------------------------------- 1 | //go:build windows 2 | 3 | package config 4 | 5 | import ( 6 | "fmt" 7 | "runtime" 8 | 9 | "golang.org/x/sys/windows/registry" 10 | ) 11 | 12 | const ( 13 | windowsRegLookup = "Software\\Microsoft\\Windows\\CurrentVersion\\Uninstall\\Steam App " 14 | ) 15 | 16 | func (g *gameDef) SteamDirFromRegistry() (dir string) { 17 | // only poke into registry for Windows, there's probably a similar method for Mac/Linux 18 | if runtime.GOOS == "windows" { 19 | key, err := registry.OpenKey(registry.LOCAL_MACHINE, fmt.Sprintf("%s%s", windowsRegLookup, g.SteamID_), registry.QUERY_VALUE) 20 | if err != nil { 21 | return 22 | } 23 | if dir, _, err = key.GetStringValue("InstallLocation"); err != nil { 24 | dir = "" 25 | } 26 | } 27 | return 28 | } 29 | -------------------------------------------------------------------------------- /mods/downloadFiles.go: -------------------------------------------------------------------------------- 1 | package mods 2 | 3 | type DownloadFiles struct { 4 | DownloadName string `json:"DownloadName" xml:"DownloadName"` 5 | // IsInstallAll is used by nexus mods when a mod.xml is not used 6 | Files []*ModFile `json:"File,omitempty" xml:"Files,omitempty"` 7 | Dirs []*ModDir `json:"Dir,omitempty" xml:"Dirs,omitempty"` 8 | } 9 | 10 | func (f *DownloadFiles) IsEmpty() bool { 11 | return len(f.Files) == 0 && len(f.Dirs) == 0 12 | } 13 | 14 | func (f *DownloadFiles) HasArchive() []string { 15 | var s []string 16 | for _, file := range f.Files { 17 | if file.ToArchive == nil { 18 | s = append(s, file.From) 19 | } 20 | } 21 | for _, dir := range f.Dirs { 22 | if dir.ToArchive == nil { 23 | s = append(s, dir.From) 24 | } 25 | } 26 | return s 27 | } 28 | -------------------------------------------------------------------------------- /ui/local/updateButton.go: -------------------------------------------------------------------------------- 1 | package local 2 | 3 | import ( 4 | "fyne.io/fyne/v2/widget" 5 | "github.com/kiamev/moogle-mod-manager/mods" 6 | ) 7 | 8 | type UpdateButton struct { 9 | *widget.Button 10 | tm mods.TrackedMod 11 | update func(tm mods.TrackedMod) 12 | } 13 | 14 | func NewUpdateButton(update func(tm mods.TrackedMod)) *UpdateButton { 15 | b := &UpdateButton{update: update} 16 | b.Button = widget.NewButton("Update", func() { 17 | if b.tm != nil && b.tm.UpdatedMod() != nil { 18 | b.update(b.tm) 19 | } 20 | }) 21 | return b 22 | } 23 | 24 | func (b *UpdateButton) Refresh() { 25 | if b.tm != nil { 26 | b.Hidden = b.tm.UpdatedMod() == nil 27 | } else { 28 | b.Hidden = true 29 | } 30 | } 31 | 32 | func (b *UpdateButton) SetTrackedMod(tm mods.TrackedMod) { 33 | b.tm = tm 34 | b.Refresh() 35 | } 36 | -------------------------------------------------------------------------------- /ui/confirm/enableRequiredMod.go: -------------------------------------------------------------------------------- 1 | package confirm 2 | 3 | import ( 4 | "fmt" 5 | "fyne.io/fyne/v2" 6 | "fyne.io/fyne/v2/container" 7 | "fyne.io/fyne/v2/dialog" 8 | "fyne.io/fyne/v2/widget" 9 | "github.com/kiamev/moogle-mod-manager/mods" 10 | "github.com/kiamev/moogle-mod-manager/ui/state/ui" 11 | ) 12 | 13 | func ShowEnableModConfirmDialog(baseModName mods.ModName, neededMod *mods.Mod, done func(mods.Result)) { 14 | msg := fmt.Sprintf("[%s] requires [%s], would you like to enable it first?", baseModName, neededMod.Name) 15 | d := dialog.NewCustomConfirm("Enable Required Mod?", "Yes", "Cancel", 16 | container.NewVScroll(widget.NewRichTextFromMarkdown(msg)), func(ok bool) { 17 | result := mods.Ok 18 | if !ok { 19 | result = mods.Cancel 20 | } 21 | done(result) 22 | }, ui.Window) 23 | d.Resize(fyne.NewSize(500, 400)) 24 | d.Show() 25 | return 26 | } 27 | -------------------------------------------------------------------------------- /ui/mod-author/previewDef.go: -------------------------------------------------------------------------------- 1 | package mod_author 2 | 3 | import ( 4 | "fyne.io/fyne/v2/widget" 5 | "github.com/kiamev/moogle-mod-manager/mods" 6 | "github.com/kiamev/moogle-mod-manager/ui/mod-author/entry" 7 | ) 8 | 9 | type previewDef struct { 10 | e entry.Entry[string] 11 | } 12 | 13 | func newPreviewDef() *previewDef { 14 | return &previewDef{ 15 | e: entry.NewStringFormEntry("Preview Url", ""), 16 | } 17 | } 18 | 19 | func (d *previewDef) set(p *mods.Preview) { 20 | if p == nil || p.Url == nil { 21 | d.e.Set("") 22 | } else { 23 | d.e.Set(*p.Url) 24 | } 25 | } 26 | 27 | func (d *previewDef) compile() *mods.Preview { 28 | var p mods.Preview 29 | if url := d.e.Value(); url != "" { 30 | p.Url = &url 31 | } 32 | return &p 33 | } 34 | 35 | func (d *previewDef) getFormItems() []*widget.FormItem { 36 | return []*widget.FormItem{ 37 | d.e.FormItem(), 38 | } 39 | } 40 | -------------------------------------------------------------------------------- /collections/set.go: -------------------------------------------------------------------------------- 1 | package collections 2 | 3 | type Set[T comparable] struct { 4 | Map map[T]bool 5 | } 6 | 7 | func NewSet[T comparable]() Set[T] { 8 | return Set[T]{Map: make(map[T]bool)} 9 | } 10 | 11 | func (s *Set[T]) Contains(k T) bool { 12 | if s.Map == nil { 13 | s.Map = make(map[T]bool) 14 | } 15 | _, ok := s.Map[k] 16 | return ok 17 | } 18 | 19 | func (s *Set[T]) Remove(k T) { 20 | if s.Map == nil { 21 | s.Map = make(map[T]bool) 22 | } 23 | delete(s.Map, k) 24 | } 25 | 26 | func (s *Set[T]) Set(k T) { 27 | if s.Map == nil { 28 | s.Map = make(map[T]bool) 29 | } 30 | s.Map[k] = true 31 | } 32 | 33 | func (s *Set[T]) Keys() []T { 34 | if s.Map == nil { 35 | s.Map = make(map[T]bool) 36 | } 37 | keys := make([]T, 0, len(s.Map)) 38 | for k := range s.Map { 39 | keys = append(keys, k) 40 | } 41 | return keys 42 | } 43 | 44 | func (s *Set[T]) Len() int { 45 | return len(s.Map) 46 | } 47 | -------------------------------------------------------------------------------- /cache/imgCache.go: -------------------------------------------------------------------------------- 1 | package cache 2 | 3 | import ( 4 | "fyne.io/fyne/v2" 5 | "github.com/kiamev/moogle-mod-manager/browser" 6 | "github.com/kiamev/moogle-mod-manager/config" 7 | "github.com/kiamev/moogle-mod-manager/util" 8 | "os" 9 | "path/filepath" 10 | ) 11 | 12 | func GetImage(url string, imgDirOverride ...string) (r fyne.Resource, err error) { 13 | var ( 14 | key = util.CreateFileName(url) 15 | imgDir = getImgDir(imgDirOverride...) 16 | fp = filepath.Join(imgDir, key) 17 | _ = os.MkdirAll(fp, 0777) 18 | file string 19 | ) 20 | if file, err = browser.Download(url, fp); err != nil { 21 | return 22 | } 23 | return fyne.LoadResourceFromPath(file) 24 | } 25 | 26 | func getImgDir(imgDirOverride ...string) string { 27 | var imgDir = config.Get().ImgCacheDir 28 | if len(imgDirOverride) > 0 && imgDirOverride[0] != "" { 29 | imgDir = imgDirOverride[0] 30 | } 31 | return imgDir 32 | } 33 | -------------------------------------------------------------------------------- /ui/util/updateChecker.go: -------------------------------------------------------------------------------- 1 | package util 2 | 3 | import ( 4 | "fmt" 5 | "time" 6 | 7 | "fyne.io/fyne/v2/dialog" 8 | "github.com/kiamev/moogle-mod-manager/browser" 9 | "github.com/kiamev/moogle-mod-manager/ui/state/ui" 10 | ) 11 | 12 | func PromptForUpdateAsNeeded(ignoreNoUpdate bool) { 13 | time.Sleep(time.Second) 14 | if newer, newerVersion, err := browser.CheckForUpdate(); err != nil { 15 | if !ignoreNoUpdate { 16 | dialog.ShowError(err, ui.ActiveWindow()) 17 | } 18 | } else if newer { 19 | dialog.ShowConfirm( 20 | "Update Available", 21 | fmt.Sprintf("Version %s is available.\nWould you like to update?", newerVersion), 22 | func(ok bool) { 23 | if ok { 24 | _ = browser.Update(newerVersion) 25 | } 26 | }, ui.ActiveWindow()) 27 | } else if !ignoreNoUpdate { 28 | dialog.ShowInformation("No Updates Available", "You are running the latest version.", ui.ActiveWindow()) 29 | } 30 | } 31 | -------------------------------------------------------------------------------- /ui/custom-widgets/buttonWithPopup.go: -------------------------------------------------------------------------------- 1 | package custom_widgets 2 | 3 | import ( 4 | "fyne.io/fyne/v2" 5 | "fyne.io/fyne/v2/theme" 6 | "fyne.io/fyne/v2/widget" 7 | ) 8 | 9 | type ButtonWithPopups struct { 10 | *widget.Button 11 | items []*fyne.MenuItem 12 | } 13 | 14 | func NewButtonWithPopups(label string, items ...*fyne.MenuItem) *ButtonWithPopups { 15 | b := &ButtonWithPopups{items: items} 16 | b.Button = widget.NewButton(label, func() { 17 | b.popUp() 18 | }) 19 | return b 20 | } 21 | 22 | func (b *ButtonWithPopups) popUp() { 23 | c := fyne.CurrentApp().Driver().CanvasForObject(b) 24 | p := widget.NewPopUpMenu(fyne.NewMenu("", b.items...), c) 25 | p.ShowAtPosition(b.popUpPos()) 26 | } 27 | 28 | func (b *ButtonWithPopups) popUpPos() fyne.Position { 29 | buttonPos := fyne.CurrentApp().Driver().AbsolutePositionForObject(b) 30 | return buttonPos.Add(fyne.NewPos(0, b.Size().Height-theme.InputBorderSize())) 31 | } 32 | -------------------------------------------------------------------------------- /ui/confirm/confirmer.go: -------------------------------------------------------------------------------- 1 | package confirm 2 | 3 | import ( 4 | "github.com/kiamev/moogle-mod-manager/config" 5 | "github.com/kiamev/moogle-mod-manager/mods" 6 | ) 7 | 8 | type ( 9 | Params struct { 10 | Game config.GameDef 11 | Mod mods.TrackedMod 12 | ToInstall []*mods.ToInstall 13 | } 14 | Confirmer interface { 15 | Downloads(done func(mods.Result)) error 16 | } 17 | ) 18 | 19 | func NewParams(game config.GameDef, mod mods.TrackedMod, toInstall []*mods.ToInstall) Params { 20 | return Params{ 21 | Game: game, 22 | Mod: mod, 23 | ToInstall: toInstall, 24 | } 25 | } 26 | 27 | func NewConfirmer(params Params) Confirmer { 28 | k := params.Mod.Kinds() 29 | if k.Is(mods.Nexus) { 30 | return newManualDownloadConfirmer(params) 31 | } 32 | if k.IsHosted() { 33 | return newHostedConfirmer(params) 34 | } 35 | if k.Is(mods.GoogleDrive) { 36 | return newManualDownloadConfirmer(params) 37 | } 38 | return newBypassConfirmer(params) 39 | } 40 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Final Fantasy PR Mod Manager 2 | Manages mods for Final Fantasy Pixel Remastered 3 | 4 | ![Alt text](https://github.com/KiameV/moogle-mod-manager/blob/main/resources/mmm.png?raw=true) 5 | 6 | WIP with a task board available here: https://github.com/users/KiameV/projects/1/views/1 7 | 8 | WIP mod.xml template - https://github.com/KiameV/moogle-mod-manager/wiki/%22mod.xml%22-explained 9 | 10 | Discussions is open https://github.com/KiameV/moogle-mod-manager/discussions 11 | 12 | In association with `Moogles & Mods` discord channel - https://discord.gg/uEfSJDdQ 13 | 14 | ______________________________________________________________________________________ 15 | 16 | If you'd like to support me in this effort, consider buying me a coffee 17 | https://ko-fi.com/kiamev 18 | 19 | ______________________________________________________________________________________ 20 | 21 | Logo copyrights are owned by their respective owners and are being used in this project 22 | in accordance with the [Fair Use Doctrine](https://en.wikipedia.org/wiki/Fair_use). 23 | -------------------------------------------------------------------------------- /discover/remote/client.go: -------------------------------------------------------------------------------- 1 | package remote 2 | 3 | import ( 4 | "github.com/kiamev/moogle-mod-manager/config" 5 | "github.com/kiamev/moogle-mod-manager/discover/remote/curseforge" 6 | "github.com/kiamev/moogle-mod-manager/discover/remote/nexus" 7 | "github.com/kiamev/moogle-mod-manager/discover/remote/util" 8 | "github.com/kiamev/moogle-mod-manager/mods" 9 | ) 10 | 11 | type Client interface { 12 | GetFromMod(in *mods.Mod) (found bool, mod *mods.Mod, err error) 13 | GetFromID(game config.GameDef, id int) (found bool, mod *mods.Mod, err error) 14 | GetFromUrl(url string) (found bool, mod *mods.Mod, err error) 15 | GetNewestMods(game config.GameDef, lastID int) (result []*mods.Mod, err error) 16 | GetMods(game config.GameDef, rebuildCache bool) (result []*mods.Mod, err error) 17 | Folder(game config.GameDef) string 18 | } 19 | 20 | func NewCurseForgeClient() Client { 21 | return curseforge.NewClient(util.NewModCompiler(mods.CurseForge)) 22 | } 23 | 24 | func NewNexusClient() Client { 25 | return nexus.NewClient(util.NewModCompiler(mods.Nexus)) 26 | } 27 | -------------------------------------------------------------------------------- /config/secrets/secrets.go: -------------------------------------------------------------------------------- 1 | package secrets 2 | 3 | import ( 4 | "github.com/kiamev/moogle-mod-manager/config" 5 | "github.com/kiamev/moogle-mod-manager/util" 6 | "path/filepath" 7 | ) 8 | 9 | const secretsFile = "secrets.json" 10 | 11 | var secret = &sec{} 12 | 13 | type ( 14 | Key byte 15 | sec struct { 16 | NexusApiKey string `json:"nexusApiKey"` 17 | CfApiKey string `json:"cfApiKey"` 18 | } 19 | ) 20 | 21 | const ( 22 | _ Key = iota 23 | NexusApiKey 24 | CfApiKey 25 | ) 26 | 27 | func Initialize() { 28 | _ = util.LoadFromFile(filepath.Join(config.PWD, secretsFile), secret) 29 | } 30 | 31 | func Save() error { 32 | return util.SaveToFile(filepath.Join(config.PWD, secretsFile), secret) 33 | } 34 | 35 | func Get(k Key) (v string) { 36 | if k == NexusApiKey { 37 | v = secret.NexusApiKey 38 | } else if k == CfApiKey { 39 | v = secret.CfApiKey 40 | } 41 | return 42 | } 43 | 44 | func Set(k Key, v string) { 45 | if k == NexusApiKey { 46 | secret.NexusApiKey = v 47 | } else if k == CfApiKey { 48 | secret.CfApiKey = v 49 | } 50 | } 51 | -------------------------------------------------------------------------------- /ui/mod-author/entry/bool.go: -------------------------------------------------------------------------------- 1 | package entry 2 | 3 | import ( 4 | "fyne.io/fyne/v2/data/binding" 5 | "fyne.io/fyne/v2/widget" 6 | ) 7 | 8 | type boolFormEntry struct { 9 | entry *widget.Check 10 | fi *widget.FormItem 11 | bind binding.Bool 12 | checked bool 13 | } 14 | 15 | func newBoolFormEntry(key string, value any) Entry[bool] { 16 | e := &boolFormEntry{ 17 | checked: value.(bool), 18 | } 19 | e.bind = binding.BindBool(&e.checked) 20 | e.entry = widget.NewCheckWithData("", e.bind) 21 | e.fi = widget.NewFormItem(key, e.entry) 22 | return e 23 | } 24 | 25 | func (e *boolFormEntry) Binding() binding.DataItem { 26 | return e.bind 27 | } 28 | 29 | func (e *boolFormEntry) Enable(enable bool) { 30 | if enable { 31 | e.entry.Enable() 32 | } else { 33 | e.entry.Disable() 34 | } 35 | } 36 | 37 | func (e *boolFormEntry) Set(value bool) { 38 | _ = e.bind.Set(value) 39 | } 40 | 41 | func (e *boolFormEntry) Value() bool { 42 | return e.checked 43 | } 44 | 45 | func (e *boolFormEntry) FormItem() *widget.FormItem { 46 | return e.fi 47 | } 48 | -------------------------------------------------------------------------------- /ui/mod-author/entry/string.go: -------------------------------------------------------------------------------- 1 | package entry 2 | 3 | import ( 4 | "fyne.io/fyne/v2/data/binding" 5 | "fyne.io/fyne/v2/widget" 6 | ) 7 | 8 | type stringFormEntry struct { 9 | entry *widget.Entry 10 | fi *widget.FormItem 11 | bind binding.String 12 | text string 13 | } 14 | 15 | func NewStringFormEntry(key string, value any) Entry[string] { 16 | e := &stringFormEntry{ 17 | text: value.(string), 18 | } 19 | e.bind = binding.BindString(&e.text) 20 | e.entry = widget.NewEntryWithData(e.bind) 21 | e.fi = widget.NewFormItem(key, e.entry) 22 | return e 23 | } 24 | 25 | func (e *stringFormEntry) Binding() binding.DataItem { 26 | return e.bind 27 | } 28 | 29 | func (e *stringFormEntry) Enable(enable bool) { 30 | if enable { 31 | e.entry.Enable() 32 | } else { 33 | e.entry.Disable() 34 | } 35 | } 36 | 37 | func (e *stringFormEntry) Set(value string) { 38 | _ = e.bind.Set(value) 39 | } 40 | 41 | func (e *stringFormEntry) Value() string { 42 | return e.text 43 | } 44 | 45 | func (e *stringFormEntry) FormItem() *widget.FormItem { 46 | return e.fi 47 | } 48 | -------------------------------------------------------------------------------- /ui/mod-author/entry/multiLine.go: -------------------------------------------------------------------------------- 1 | package entry 2 | 3 | import ( 4 | "fyne.io/fyne/v2/data/binding" 5 | "fyne.io/fyne/v2/widget" 6 | ) 7 | 8 | type multiLineFormEntry struct { 9 | entry *widget.Entry 10 | fi *widget.FormItem 11 | bind binding.String 12 | text string 13 | } 14 | 15 | func newMultiLineFormEntry(key string, value any) Entry[string] { 16 | e := &multiLineFormEntry{ 17 | text: value.(string), 18 | } 19 | e.bind = binding.BindString(&e.text) 20 | e.entry = widget.NewMultiLineEntry() 21 | e.entry.Bind(e.bind) 22 | e.fi = widget.NewFormItem(key, e.entry) 23 | return e 24 | } 25 | 26 | func (e *multiLineFormEntry) Enable(enable bool) { 27 | if enable { 28 | e.entry.Enable() 29 | } else { 30 | e.entry.Disable() 31 | } 32 | } 33 | 34 | func (e *multiLineFormEntry) Binding() binding.DataItem { 35 | return e.bind 36 | } 37 | 38 | func (e *multiLineFormEntry) Set(value string) { 39 | _ = e.bind.Set(value) 40 | } 41 | 42 | func (e *multiLineFormEntry) Value() string { 43 | return e.text 44 | } 45 | 46 | func (e *multiLineFormEntry) FormItem() *widget.FormItem { 47 | return e.fi 48 | } 49 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2022 TO 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /mods/override.go: -------------------------------------------------------------------------------- 1 | package mods 2 | 3 | /* 4 | type Override struct { 5 | NexusModID string `json:"id" xml:"id"` 6 | NexusGameID string `json:"game" xml:"game"` 7 | Name string `json:"name" xml:"name"` 8 | Preview *Preview `json:"preview" xml:"preview"` 9 | ConfigSelectionType SelectType `json:"ConfigSelectionType" xml:"ConfigSelectionType"` 10 | Description *string `json:"description,omitempty" xml:"description,omitempty"` 11 | ModCompatibility *ModCompatibility `json:"Compatibility,omitempty" xml:"ModCompatibility,omitempty"` 12 | Downloadables []*Download `json:"Downloadable" xml:"Downloadables"` 13 | DonationLinks []*DonationLink `json:"DonationLink" xml:"DonationLinks"` 14 | AlwaysDownload []*DownloadFiles `json:"AlwaysDownload,omitempty" xml:"AlwaysDownload,omitempty"` 15 | Configurations *[]*Configuration `json:"configurations,omitempty" xml:"configurations,omitempty"` 16 | } 17 | 18 | func (o Override) Override(m *Mod) { 19 | if o.Preview != nil { 20 | m.Preview = o.Preview 21 | } 22 | if o.Description != nil { 23 | m.Description = *o.Description 24 | } 25 | if o.Configurations != nil { 26 | m.Configurations = *o.Configurations 27 | } 28 | } 29 | */ 30 | -------------------------------------------------------------------------------- /ui/mod-author/richTextEditor.go: -------------------------------------------------------------------------------- 1 | package mod_author 2 | 3 | import ( 4 | "fyne.io/fyne/v2" 5 | "fyne.io/fyne/v2/container" 6 | "fyne.io/fyne/v2/data/binding" 7 | "fyne.io/fyne/v2/widget" 8 | "strings" 9 | ) 10 | 11 | type richTextEditor struct { 12 | input binding.String 13 | preview *widget.RichText 14 | } 15 | 16 | func newRichTextEditor() *richTextEditor { 17 | e := &richTextEditor{ 18 | input: binding.NewString(), 19 | preview: widget.NewRichTextWithText(""), 20 | } 21 | e.input.AddListener(e) 22 | return e 23 | } 24 | 25 | func (e *richTextEditor) Draw() fyne.CanvasObject { 26 | entry := widget.NewMultiLineEntry() 27 | entry.Bind(e.input) 28 | entry.Wrapping = fyne.TextWrapWord 29 | e.preview.Wrapping = fyne.TextWrapWord 30 | return container.NewVSplit( 31 | container.NewScroll(entry), 32 | container.NewVScroll(e.preview)) 33 | } 34 | 35 | func (e *richTextEditor) DataChanged() { 36 | text := e.String() 37 | text2 := strings.ReplaceAll(text, "\r", "") 38 | if text2 != text { 39 | e.SetText(text2) 40 | return 41 | } 42 | e.preview.ParseMarkdown(text2) 43 | } 44 | 45 | func (e *richTextEditor) SetText(s string) { 46 | _ = e.input.Set(strings.ReplaceAll(s, "\r", "")) 47 | } 48 | 49 | func (e *richTextEditor) String() string { 50 | s, _ := e.input.Get() 51 | return s 52 | } 53 | -------------------------------------------------------------------------------- /.github/workflows/linux.yml: -------------------------------------------------------------------------------- 1 | # This workflow will build a golang project 2 | # For more information see: https://docs.github.com/en/actions/automating-builds-and-tests/building-and-testing-go 3 | 4 | name: Linux 5 | 6 | on: 7 | push: 8 | branches: [ "main" ] 9 | pull_request: 10 | branches: [ "main" ] 11 | 12 | jobs: 13 | 14 | build: 15 | runs-on: ubuntu-latest 16 | steps: 17 | - uses: actions/checkout@v3 18 | 19 | - name: Set up Go 20 | uses: actions/setup-go@v4 21 | with: 22 | go-version: '1.19' 23 | 24 | - name: Install gcc and graphics libraries 25 | run: sudo apt-get install -y gcc libgl1-mesa-dev xorg-dev 26 | - name: Install fyne 27 | run: go install fyne.io/fyne/v2/cmd/fyne@latest 28 | - name: Install upx 29 | uses: crazy-max/ghaction-upx@v2 30 | with: 31 | install-only: true 32 | 33 | - name: Build 34 | run: go build -o moogle-mod-manager 35 | 36 | - name: upx 37 | run: upx -9 -k moogle-mod-manager 38 | 39 | - name: upload to release 40 | uses: svenstaro/upload-release-action@v2 41 | with: 42 | repo_token: ${{ secrets.GITHUB_TOKEN }} 43 | file: moogle-mod-manager 44 | asset_name: moogle-mod-manager 45 | tag: latest 46 | overwrite: true 47 | -------------------------------------------------------------------------------- /ui/mod-author/compatibility.go: -------------------------------------------------------------------------------- 1 | package mod_author 2 | 3 | import ( 4 | "fyne.io/fyne/v2" 5 | "fyne.io/fyne/v2/container" 6 | "github.com/kiamev/moogle-mod-manager/mods" 7 | ) 8 | 9 | type modCompatabilityDef struct { 10 | requires *modCompatsDef 11 | forbids *modCompatsDef 12 | } 13 | 14 | func newModCompatibilityDef(gamesDef *gamesDef) *modCompatabilityDef { 15 | return &modCompatabilityDef{ 16 | requires: newModCompatsDef("Requires", gamesDef), 17 | forbids: newModCompatsDef("Forbids", gamesDef), 18 | } 19 | } 20 | 21 | func (d *modCompatabilityDef) draw() fyne.CanvasObject { 22 | return container.NewVScroll(container.NewVBox( 23 | d.requires.draw(), 24 | d.forbids.draw(), 25 | )) 26 | } 27 | 28 | func (d *modCompatabilityDef) compile() *mods.ModCompatibility { 29 | if d.requires == nil && d.forbids == nil { 30 | return nil 31 | } 32 | return &mods.ModCompatibility{ 33 | Requires: d.requires.compile(), 34 | Forbids: d.forbids.compile(), 35 | } 36 | } 37 | 38 | func (d *modCompatabilityDef) set(compatibility *mods.ModCompatibility) { 39 | d.requires.clear() 40 | d.forbids.clear() 41 | if compatibility != nil { 42 | for _, i := range compatibility.Requires { 43 | d.requires.list.AddItem(i) 44 | } 45 | for _, i := range compatibility.Forbids { 46 | d.forbids.list.AddItem(i) 47 | } 48 | } 49 | } 50 | -------------------------------------------------------------------------------- /ui/game-select/gameSelect.go: -------------------------------------------------------------------------------- 1 | package game_select 2 | 3 | import ( 4 | "net/url" 5 | 6 | "fyne.io/fyne/v2" 7 | "fyne.io/fyne/v2/container" 8 | "fyne.io/fyne/v2/layout" 9 | "fyne.io/fyne/v2/widget" 10 | "github.com/kiamev/moogle-mod-manager/config" 11 | "github.com/kiamev/moogle-mod-manager/ui/state" 12 | ) 13 | 14 | func New() state.Screen { 15 | return &GameSelect{} 16 | } 17 | 18 | type GameSelect struct{} 19 | 20 | func (s *GameSelect) PreDraw(fyne.Window, ...interface{}) error { return nil } 21 | 22 | func (s *GameSelect) OnClose() {} 23 | 24 | func (s *GameSelect) DrawAsDialog(fyne.Window) {} 25 | 26 | func (s *GameSelect) Draw(w fyne.Window) { 27 | var ( 28 | games = config.GameDefs() 29 | inputs = make([]fyne.CanvasObject, 0, len(games)*2-1) 30 | ) 31 | for _, g := range games { 32 | inputs = append(inputs, s.createInput(g)) 33 | } 34 | u, _ := url.Parse("https://discord.gg/KMehVn7GwM") 35 | w.SetContent( 36 | container.NewBorder( 37 | container.NewCenter(widget.NewHyperlinkWithStyle("Join us in the Moogles & Mods Discord", u, fyne.TextAlignCenter, fyne.TextStyle{Bold: true})), 38 | nil, nil, nil, 39 | container.New(layout.NewGridLayout(3), inputs...))) 40 | } 41 | 42 | func (s *GameSelect) createInput(g config.GameDef) *fyne.Container { 43 | return container.NewMax(widget.NewButton("", func() { 44 | state.CurrentGame = g 45 | state.ShowScreen(state.LocalMods) 46 | }), g.Logo()) 47 | } 48 | -------------------------------------------------------------------------------- /files/conflicts.go: -------------------------------------------------------------------------------- 1 | package files 2 | 3 | import ( 4 | "github.com/kiamev/moogle-mod-manager/config" 5 | "github.com/kiamev/moogle-mod-manager/mods" 6 | "github.com/kiamev/moogle-mod-manager/mods/managed" 7 | "path/filepath" 8 | ) 9 | 10 | type ( 11 | Conflict struct { 12 | Owner *mods.Mod 13 | Name string 14 | Path string 15 | Selection *mods.Mod 16 | } 17 | ) 18 | 19 | func FindConflicts(game config.GameDef, files []string) (conflicts []*Conflict) { 20 | var ( 21 | owner mods.ModID 22 | tm mods.TrackedMod 23 | found bool 24 | ) 25 | for _, f := range files { 26 | if owner, found = HasFile(game, f); found { 27 | if tm, found = managed.TryGetMod(game, owner); found { 28 | conflicts = append(conflicts, &Conflict{ 29 | Owner: tm.Mod(), 30 | Name: filepath.Base(f), 31 | Path: f, 32 | }) 33 | } 34 | } 35 | } 36 | return 37 | } 38 | 39 | func FindConflictsWithArchive(game config.GameDef, archive string, files []string) (conflicts []*Conflict) { 40 | var ( 41 | owner mods.ModID 42 | tm mods.TrackedMod 43 | found bool 44 | ) 45 | for _, f := range files { 46 | if owner, found = HasArchiveFile(game, archive, f); found { 47 | if tm, found = managed.TryGetMod(game, owner); found { 48 | conflicts = append(conflicts, &Conflict{ 49 | Owner: tm.Mod(), 50 | Name: filepath.Base(f), 51 | Path: f, 52 | }) 53 | } 54 | } 55 | } 56 | return 57 | } 58 | -------------------------------------------------------------------------------- /mods/modStateChanger.go: -------------------------------------------------------------------------------- 1 | package mods 2 | 3 | import ( 4 | "github.com/kiamev/moogle-mod-manager/config" 5 | ) 6 | 7 | type Result byte 8 | 9 | const ( 10 | _ Result = iota 11 | Ok 12 | Cancel 13 | Error 14 | Working 15 | Repeat 16 | ) 17 | 18 | type ( 19 | DoneCallback func(result Result, err ...error) 20 | ConflictChoiceCallback func(result Result, choices []*FileConflict, err ...error) 21 | OnConflict func(conflicts []*FileConflict, choiceCallback ConflictChoiceCallback) 22 | ) 23 | 24 | type FileConflict struct { 25 | File string 26 | CurrentModID ModID 27 | NewModID ModID 28 | ChoiceName string 29 | } 30 | 31 | func (c *FileConflict) OnChange(selected string) { 32 | c.ChoiceName = selected 33 | } 34 | 35 | type ModEnabler struct { 36 | Game config.GameDef 37 | TrackedMod TrackedMod 38 | ToInstall []*ToInstall 39 | OnConflict OnConflict 40 | ShowWorking func() 41 | DoneCallback DoneCallback 42 | } 43 | 44 | func (e *ModEnabler) Kinds() Kinds { 45 | return e.TrackedMod.Kinds() 46 | } 47 | 48 | func NewModEnabler(game config.GameDef, tm TrackedMod, toInstall []*ToInstall, onConflict OnConflict, showWorking func(), doneCallback DoneCallback) *ModEnabler { 49 | return &ModEnabler{ 50 | Game: game, 51 | TrackedMod: tm, 52 | ToInstall: toInstall, 53 | OnConflict: onConflict, 54 | ShowWorking: showWorking, 55 | DoneCallback: doneCallback, 56 | } 57 | } 58 | -------------------------------------------------------------------------------- /mods/managed/authored/authored.go: -------------------------------------------------------------------------------- 1 | package authored 2 | 3 | import ( 4 | "encoding/json" 5 | "fmt" 6 | "github.com/kiamev/moogle-mod-manager/config" 7 | "github.com/kiamev/moogle-mod-manager/mods" 8 | "os" 9 | "path" 10 | ) 11 | 12 | const file = "authored.json" 13 | 14 | var lookup = make(map[mods.ModID]string) 15 | 16 | func Initialize() (err error) { 17 | f := path.Join(config.PWD, file) 18 | if _, err = os.Stat(f); err != nil { 19 | return nil 20 | } 21 | var b []byte 22 | if b, err = os.ReadFile(f); err != nil { 23 | return fmt.Errorf("failed to read %s: %v", file, err) 24 | } 25 | if err = json.Unmarshal(b, &lookup); err != nil { 26 | return fmt.Errorf("failed to read %s: %v", file, err) 27 | } 28 | return nil 29 | } 30 | 31 | func GetDir(modID mods.ModID) (dir string, found bool) { 32 | if modID != "" { 33 | dir, found = lookup[modID] 34 | } 35 | return 36 | } 37 | 38 | func SetDir(modID mods.ModID, dir string) (err error) { 39 | lookup[modID] = dir 40 | var ( 41 | b []byte 42 | f *os.File 43 | ) 44 | 45 | if b, err = json.MarshalIndent(&lookup, "", "\t"); err != nil { 46 | return fmt.Errorf("failed to prepare %s: %v", file, err) 47 | } 48 | 49 | if f, err = os.Create(path.Join(config.PWD, file)); err != nil { 50 | return fmt.Errorf("failed to create %s: %v", file, err) 51 | } 52 | defer func() { _ = f.Close() }() 53 | 54 | if _, err = f.Write(b); err != nil { 55 | return fmt.Errorf("failed to write %s: %v", file, err) 56 | } 57 | return nil 58 | } 59 | -------------------------------------------------------------------------------- /ui/util/resources/resources.go: -------------------------------------------------------------------------------- 1 | package resources 2 | 3 | import ( 4 | "fyne.io/fyne/v2" 5 | "fyne.io/fyne/v2/canvas" 6 | "fyne.io/fyne/v2/widget" 7 | "github.com/kiamev/moogle-mod-manager/cache" 8 | "github.com/kiamev/moogle-mod-manager/config" 9 | "github.com/kiamev/moogle-mod-manager/util" 10 | "path/filepath" 11 | ) 12 | 13 | const ( 14 | mmmRepoResources = "https://raw.githubusercontent.com/kiamev/moogle-mod-manager/master/resources/" 15 | resourcesDir = "resources" 16 | ) 17 | 18 | var ( 19 | Icon fyne.Resource 20 | ) 21 | 22 | func Initialize(games []config.GameDef) { 23 | for _, g := range games { 24 | if g.LogoPath() != "" { 25 | g.SetLogo(loadLogo(g)) 26 | } 27 | } 28 | Icon, _ = loadImage("icon16.png") 29 | } 30 | 31 | func loadLogo(game config.GameDef) fyne.CanvasObject { 32 | var ( 33 | r, err = loadImage(game.LogoPath()) 34 | img *canvas.Image 35 | ) 36 | if err != nil { 37 | return createTextLogo(game) 38 | } 39 | 40 | img = canvas.NewImageFromResource(r) 41 | size := fyne.Size{Width: 444 * .75, Height: 176 * .75} 42 | img.SetMinSize(size) 43 | img.Resize(size) 44 | img.FillMode = canvas.ImageFillContain 45 | return img 46 | } 47 | 48 | func loadImage(f string) (fyne.Resource, error) { 49 | if util.FileExists(f) { 50 | return fyne.LoadResourceFromPath(f) 51 | } 52 | return cache.GetImage(mmmRepoResources+f, filepath.Join(config.PWD, resourcesDir)) 53 | } 54 | 55 | func createTextLogo(game config.GameDef) fyne.CanvasObject { 56 | return widget.NewLabel(string(game.Name())) 57 | } 58 | -------------------------------------------------------------------------------- /ui/secret/secrets.go: -------------------------------------------------------------------------------- 1 | package secret 2 | 3 | import ( 4 | "fyne.io/fyne/v2" 5 | "fyne.io/fyne/v2/container" 6 | "fyne.io/fyne/v2/data/binding" 7 | "fyne.io/fyne/v2/dialog" 8 | "fyne.io/fyne/v2/widget" 9 | "github.com/kiamev/moogle-mod-manager/config/secrets" 10 | "github.com/kiamev/moogle-mod-manager/ui/util" 11 | ) 12 | 13 | const ( 14 | nexusVortexApiAccessUrl = "https://www.nexusmods.com/users/myaccount?tab=api+access" 15 | cfApiKeyAccessUrl = "https://console.curseforge.com/#/api-keys" 16 | ) 17 | 18 | func Show(w fyne.Window) { 19 | var ( 20 | nwe = widget.NewPasswordEntry() 21 | cfwe = widget.NewPasswordEntry() 22 | n = secrets.Get(secrets.NexusApiKey) 23 | cf = secrets.Get(secrets.CfApiKey) 24 | ) 25 | nwe.Bind(binding.BindString(&n)) 26 | cfwe.Bind(binding.BindString(&cf)) 27 | d := dialog.NewCustomConfirm("Secrets", "Save", "Cancel", container.NewVBox( 28 | widget.NewForm(widget.NewFormItem("Nexus Vortex Api Key", nwe)), 29 | widget.NewLabel("To get a key, follow this link and select [REQUEST AN API KEY] for Vortex. Copy what's generated."), 30 | util.CreateUrlRow(nexusVortexApiAccessUrl), 31 | widget.NewForm(widget.NewFormItem("CurseForge Api Key", cfwe)), 32 | widget.NewLabel("To get a key, follow this link to generate one."), 33 | util.CreateUrlRow(cfApiKeyAccessUrl)), 34 | func(ok bool) { 35 | if ok { 36 | secrets.Set(secrets.NexusApiKey, n) 37 | secrets.Set(secrets.CfApiKey, cf) 38 | _ = secrets.Save() 39 | } 40 | }, w) 41 | d.Resize(fyne.NewSize(800, 400)) 42 | d.Show() 43 | } 44 | -------------------------------------------------------------------------------- /ui/mod-author/entry/select.go: -------------------------------------------------------------------------------- 1 | package entry 2 | 3 | import ( 4 | "fyne.io/fyne/v2/data/binding" 5 | "fyne.io/fyne/v2/widget" 6 | ) 7 | 8 | type SelectFormEntry struct { 9 | Entry *widget.Select 10 | fi *widget.FormItem 11 | selected string 12 | bind binding.String 13 | } 14 | 15 | func NewSelectFormEntry(key string, value any, possible []string) Entry[string] { 16 | e := &SelectFormEntry{ 17 | selected: value.(string), 18 | } 19 | e.bind = binding.BindString(&e.selected) 20 | e.bind.AddListener(e) 21 | e.Entry = widget.NewSelect(possible, func(s string) { 22 | _ = e.bind.Set(s) 23 | }) 24 | e.fi = widget.NewFormItem(key, e.Entry) 25 | return e 26 | } 27 | 28 | func (e *SelectFormEntry) Enable(enable bool) { 29 | if enable { 30 | e.Entry.Enable() 31 | } else { 32 | e.Entry.Disable() 33 | } 34 | } 35 | 36 | func (e *SelectFormEntry) Binding() binding.DataItem { 37 | return e.bind 38 | } 39 | 40 | func (e *SelectFormEntry) Set(value string) { 41 | _ = e.bind.Set(value) 42 | } 43 | 44 | func (e *SelectFormEntry) Value() string { 45 | return e.selected 46 | } 47 | 48 | func (e *SelectFormEntry) DataChanged() { 49 | if e != nil && e.bind != nil { 50 | if v, err := e.bind.Get(); err == nil { 51 | if e.Entry != nil && e.Entry.Selected != v { 52 | e.Entry.Selected = v 53 | } 54 | } 55 | } 56 | } 57 | 58 | func (e *SelectFormEntry) FormItem() *widget.FormItem { 59 | return e.fi 60 | } 61 | 62 | func (e *SelectFormEntry) Clear() { 63 | var s []string 64 | e.Entry.Options = s 65 | e.Entry.Selected = "" 66 | } 67 | -------------------------------------------------------------------------------- /winres/winres.json: -------------------------------------------------------------------------------- 1 | { 2 | "RT_GROUP_ICON": { 3 | "APP": { 4 | "0000": [ 5 | "icon.png", 6 | "icon16.png" 7 | ] 8 | } 9 | }, 10 | "RT_MANIFEST": { 11 | "#1": { 12 | "0409": { 13 | "identity": { 14 | "name": "", 15 | "version": "" 16 | }, 17 | "description": "", 18 | "minimum-os": "win7", 19 | "execution-level": "as invoker", 20 | "ui-access": false, 21 | "auto-elevate": false, 22 | "dpi-awareness": "system", 23 | "disable-theming": false, 24 | "disable-window-filtering": false, 25 | "high-resolution-scrolling-aware": false, 26 | "ultra-high-resolution-scrolling-aware": false, 27 | "long-path-aware": false, 28 | "printer-driver-isolation": false, 29 | "gdi-scaling": false, 30 | "segment-heap": false, 31 | "use-common-controls-v6": false 32 | } 33 | } 34 | }, 35 | "RT_VERSION": { 36 | "#1": { 37 | "0000": { 38 | "fixed": { 39 | "file_version": "0.0.0.0", 40 | "product_version": "0.0.0.0" 41 | }, 42 | "info": { 43 | "0409": { 44 | "Comments": "", 45 | "CompanyName": "", 46 | "FileDescription": "", 47 | "FileVersion": "", 48 | "InternalName": "", 49 | "LegalCopyright": "", 50 | "LegalTrademarks": "", 51 | "OriginalFilename": "", 52 | "PrivateBuild": "", 53 | "ProductName": "", 54 | "ProductVersion": "", 55 | "SpecialBuild": "" 56 | } 57 | } 58 | } 59 | } 60 | } 61 | } -------------------------------------------------------------------------------- /ui/util/sandbox.go: -------------------------------------------------------------------------------- 1 | package util 2 | 3 | import ( 4 | "fmt" 5 | "fyne.io/fyne/v2" 6 | "fyne.io/fyne/v2/container" 7 | "fyne.io/fyne/v2/dialog" 8 | "fyne.io/fyne/v2/widget" 9 | "github.com/kiamev/moogle-mod-manager/mods" 10 | "github.com/kiamev/moogle-mod-manager/ui/state/ui" 11 | "strings" 12 | ) 13 | 14 | func DisplayDownloadsAndFiles(toInstall []*mods.ToInstall) { 15 | defer func() { 16 | if err := recover(); err != nil { 17 | ShowErrorLong(fmt.Errorf("%v", err)) 18 | } 19 | }() 20 | sb := strings.Builder{} 21 | for _, ti := range toInstall { 22 | if ti.Download == nil { 23 | continue 24 | } 25 | sb.WriteString(fmt.Sprintf("## %s\n\n", ti.Download.Name)) 26 | sb.WriteString("### Sources:\n\n") 27 | if ti.Download.Hosted != nil { 28 | for _, s := range ti.Download.Hosted.Sources { 29 | sb.WriteString(fmt.Sprintf(" - %s\n\n", s)) 30 | } 31 | } else if ti.Download.Nexus != nil { 32 | sb.WriteString(fmt.Sprintf(" - %s\n\n", ti.Download.Nexus.FileName)) 33 | } else if ti.Download.CurseForge != nil { 34 | sb.WriteString(" - ") 35 | } 36 | sb.WriteString("### Files:\n\n") 37 | for _, dl := range ti.DownloadFiles { 38 | for _, f := range dl.Files { 39 | sb.WriteString(fmt.Sprintf(" - %s -> %s\n\n", f.From, f.To)) 40 | } 41 | sb.WriteString("### Dirs:\n\n") 42 | for _, dir := range dl.Dirs { 43 | sb.WriteString(fmt.Sprintf(" - %s -> %s | Recursive %v\n\n", dir.From, dir.To, dir.Recursive)) 44 | } 45 | } 46 | sb.WriteString("_____________________\n\n") 47 | } 48 | d := dialog.NewCustom("Downloads and File/Dir Copies", "ok", container.NewVScroll(widget.NewRichTextFromMarkdown(sb.String())), ui.Window) 49 | d.Resize(fyne.NewSize(600, 600)) 50 | d.Show() 51 | } 52 | -------------------------------------------------------------------------------- /mods/modKind.go: -------------------------------------------------------------------------------- 1 | package mods 2 | 3 | import "strings" 4 | 5 | type ( 6 | Kind string 7 | NexusModID int 8 | CfModID int 9 | ) 10 | 11 | const ( 12 | Nexus Kind = "Nexus" 13 | CurseForge Kind = "CurseForge" 14 | HostedAt Kind = "HostedAt" 15 | HostedGitHub Kind = "GitHub" 16 | GoogleDrive Kind = "GoogleDrive" 17 | ) 18 | 19 | type ( 20 | GitHub struct { 21 | Owner string `json:"Owner"` 22 | Repo string `json:"Repo"` 23 | Version string `json:"Version"` 24 | } 25 | Kinds []Kind 26 | ModKind struct { 27 | Kinds Kinds `json:"Kinds" xml:"Kinds"` 28 | NexusID *NexusModID `json:"NexusID,omitempty" xml:"NexusID,omitempty"` 29 | CurseForgeID *CfModID `json:"CurseForgeID,omitempty" xml:"CurseForgeID,omitempty"` 30 | GitHub *GitHub `json:"Github,omitempty" xml:"Github,omitempty"` 31 | } 32 | ) 33 | 34 | var SubKinds = []string{ 35 | string(HostedAt), 36 | string(HostedGitHub), 37 | } 38 | 39 | func (k Kind) Is(kind Kind) bool { 40 | return k == kind 41 | } 42 | 43 | func (k *Kinds) Is(kind Kind) bool { 44 | for _, i := range *k { 45 | if i == kind { 46 | return true 47 | } 48 | } 49 | return false 50 | } 51 | 52 | func (k *Kinds) Add(kind Kind) { 53 | if !k.Is(kind) { 54 | *k = append(*k, kind) 55 | } 56 | } 57 | 58 | func (k *Kinds) Remove(kind Kind) { 59 | for i, v := range *k { 60 | if v == kind { 61 | *k = append((*k)[:i], (*k)[i+1:]...) 62 | break 63 | } 64 | } 65 | } 66 | 67 | func (k *Kinds) IsHosted() bool { 68 | return k.Is(HostedAt) || k.Is(HostedGitHub) 69 | } 70 | 71 | func (k *Kinds) String() string { 72 | sb := strings.Builder{} 73 | for i, v := range *k { 74 | if i > 0 { 75 | sb.WriteByte(',') 76 | } 77 | sb.WriteString(string(v)) 78 | } 79 | return sb.String() 80 | } 81 | -------------------------------------------------------------------------------- /ui/confirm/hostedDownload.go: -------------------------------------------------------------------------------- 1 | package confirm 2 | 3 | import ( 4 | "fmt" 5 | "fyne.io/fyne/v2" 6 | "fyne.io/fyne/v2/container" 7 | "fyne.io/fyne/v2/dialog" 8 | "fyne.io/fyne/v2/widget" 9 | "github.com/kiamev/moogle-mod-manager/mods" 10 | "github.com/kiamev/moogle-mod-manager/ui/state/ui" 11 | "os" 12 | "path/filepath" 13 | "strings" 14 | ) 15 | 16 | type hostedConfirmer struct { 17 | Params 18 | } 19 | 20 | func newHostedConfirmer(params Params) Confirmer { 21 | return &hostedConfirmer{Params: params} 22 | } 23 | 24 | func (c *hostedConfirmer) Downloads(done func(mods.Result)) (err error) { 25 | var sb = strings.Builder{} 26 | for i, ti := range c.ToInstall { 27 | if c.alreadyDownloaded(ti) { 28 | continue 29 | } 30 | sb.WriteString(fmt.Sprintf("## Download %d\n\n", i+1)) 31 | if len(ti.Download.Hosted.Sources) == 1 { 32 | sb.WriteString(ti.Download.Hosted.Sources[0] + "\n\n") 33 | } else { 34 | sb.WriteString("### Sources:\n\n") 35 | for j, s := range ti.Download.Hosted.Sources { 36 | sb.WriteString(fmt.Sprintf(" - %d. %s\n\n", j+1, s)) 37 | } 38 | } 39 | } 40 | if sb.Len() == 0 { 41 | done(mods.Ok) 42 | return 43 | } 44 | 45 | d := dialog.NewCustomConfirm("Download Files?", "Yes", "Cancel", container.NewVScroll(widget.NewRichTextFromMarkdown(sb.String())), func(ok bool) { 46 | result := mods.Ok 47 | if !ok { 48 | result = mods.Cancel 49 | } 50 | done(result) 51 | }, ui.Window) 52 | d.Resize(fyne.NewSize(500, 400)) 53 | d.Show() 54 | return 55 | } 56 | 57 | func (c *hostedConfirmer) alreadyDownloaded(ti *mods.ToInstall) bool { 58 | file := strings.Split(ti.Download.Hosted.Sources[0], "/") 59 | file = strings.Split(file[len(file)-1], "?") 60 | dir, _ := ti.GetDownloadLocation(c.Game, c.Mod) 61 | _, err := os.Stat(filepath.Join(dir, file[0])) 62 | return err == nil 63 | } 64 | -------------------------------------------------------------------------------- /mods/downloadable.go: -------------------------------------------------------------------------------- 1 | package mods 2 | 3 | import ( 4 | "fmt" 5 | "github.com/kiamev/moogle-mod-manager/config" 6 | "path/filepath" 7 | ) 8 | 9 | type ( 10 | ArchiveLocation string 11 | Download struct { 12 | Name string `json:"Name" xml:"Name"` 13 | Version string `json:"Version" xml:"Version"` 14 | 15 | Hosted *HostedDownloadable `json:"Hosted,omitempty" xml:"Hosted,omitempty"` 16 | Nexus *NexusDownloadable `json:"Nexus,omitempty" xml:"Nexus,omitempty"` 17 | CurseForge *CurseForgeDownloadable `json:"CurseForge,omitempty" xml:"CurseForge,omitempty"` 18 | GoogleDrive *GoogleDriveDownloadable `json:"GoogleDrive,omitempty" xml:"GoogleDrive,omitempty"` 19 | 20 | DownloadedArchiveLocation *ArchiveLocation `json:"DownloadedLoc,omitempty" xml:"DownloadedLoc,omitempty"` 21 | //InstallType InstallType `json:"InstallType" xml:"InstallType"` 22 | } 23 | HostedDownloadable struct { 24 | Sources []string `json:"Source" xml:"Sources"` 25 | } 26 | NexusDownloadable struct { 27 | FileID int `json:"FileID"` 28 | FileName string `json:"FileName"` 29 | } 30 | CurseForgeDownloadable struct { 31 | FileID int `json:"FileID"` 32 | FileName string `json:"FileName"` 33 | Url string `json:"Url"` 34 | } 35 | GoogleDriveDownloadable struct { 36 | Name string `json:"Name" xml:"Name"` 37 | Url string `json:"Url" xml:"Url"` 38 | } 39 | ) 40 | 41 | func (d Download) FileName() (string, error) { 42 | if d.Nexus != nil { 43 | return d.Nexus.FileName, nil 44 | } else if d.CurseForge != nil { 45 | return d.CurseForge.FileName, nil 46 | } else if d.GoogleDrive != nil { 47 | return d.GoogleDrive.Name, nil 48 | } 49 | return "", fmt.Errorf("no file name specified for %s", d.Name) 50 | } 51 | 52 | func (l *ArchiveLocation) ExtractDir(fileName string) string { 53 | s := config.PWD 54 | if l != nil { 55 | s = filepath.Dir(string(*l)) 56 | } 57 | return filepath.Join(s, "extracted", fileName) 58 | } 59 | -------------------------------------------------------------------------------- /browser/download.go: -------------------------------------------------------------------------------- 1 | package browser 2 | 3 | import ( 4 | "bytes" 5 | "fmt" 6 | "io" 7 | "net/http" 8 | "os" 9 | "path" 10 | "strings" 11 | ) 12 | 13 | func Download(url, toDir string) (string, error) { 14 | var ( 15 | name, err = getName(url) 16 | buf *bytes.Buffer 17 | out *os.File 18 | file = path.Join(toDir, name) 19 | ) 20 | if err != nil { 21 | return "", err 22 | } 23 | 24 | if _, err = os.Stat(file); err == nil { 25 | // Already downloaded 26 | return file, nil 27 | } 28 | 29 | if buf, err = download(url); err != nil { 30 | return "", err 31 | } 32 | 33 | if err = os.MkdirAll(toDir, 0777); err != nil { 34 | return "", err 35 | } 36 | 37 | // Create the file 38 | if out, err = os.Create(file); err != nil { 39 | return "", err 40 | } 41 | defer func() { _ = out.Close() }() 42 | 43 | // Write the body to file 44 | _, err = io.Copy(out, buf) 45 | return file, err 46 | } 47 | 48 | func DownloadAsString(url string) (string, error) { 49 | buf, err := download(url) 50 | if err != nil { 51 | return "", err 52 | } 53 | return buf.String(), nil 54 | } 55 | 56 | func DownloadAsBytes(url string) ([]byte, error) { 57 | buf, err := download(url) 58 | if err != nil { 59 | return nil, err 60 | } 61 | return buf.Bytes(), nil 62 | } 63 | 64 | func download(url string) (buf *bytes.Buffer, err error) { 65 | var resp *http.Response 66 | if resp, err = http.Get(url); err != nil { 67 | return 68 | } 69 | defer func() { resp.Body.Close() }() 70 | 71 | if resp.StatusCode != 200 { 72 | err = fmt.Errorf("failed to download the mod's source at %s", url) 73 | return 74 | } 75 | defer func() { _ = resp.Body.Close() }() 76 | 77 | buf = new(bytes.Buffer) 78 | _, err = buf.ReadFrom(resp.Body) 79 | 80 | return 81 | } 82 | 83 | func getName(url string) (name string, err error) { 84 | sp := strings.Split(url, "/") 85 | if len(sp) == 0 { 86 | err = fmt.Errorf("invalid url: %s", url) 87 | return 88 | } 89 | name = sp[len(sp)-1] 90 | if i := strings.Index(name, "?"); i >= 0 { 91 | name = name[:i] 92 | } 93 | return 94 | } 95 | -------------------------------------------------------------------------------- /mods/lookup.go: -------------------------------------------------------------------------------- 1 | package mods 2 | 3 | type ( 4 | lookupID string 5 | mod interface { 6 | ID() ModID 7 | Kinds() Kinds 8 | Mod() *Mod 9 | } 10 | ModLookup[T mod] interface { 11 | All() []T 12 | Clear() 13 | Get(m T) (found T, ok bool) 14 | GetByID(modID ModID) (found T, ok bool) 15 | Has(m T) bool 16 | Len() int 17 | Remove(m T) 18 | RemoveConditionally(f func(t T) bool) 19 | Set(m T) 20 | } 21 | // ModLookupConc is a public for serialization purposes. 22 | ModLookupConc[T mod] struct { 23 | Lookup map[lookupID]T `json:"Lookup"` 24 | } 25 | ) 26 | 27 | func NewModLookup[T mod]() ModLookup[T] { 28 | return &ModLookupConc[T]{ 29 | Lookup: make(map[lookupID]T), 30 | } 31 | } 32 | 33 | func (l *ModLookupConc[T]) All() []T { 34 | s := make([]T, 0, len(l.Lookup)) 35 | for _, m := range l.Lookup { 36 | s = append(s, m) 37 | } 38 | return s 39 | } 40 | 41 | func (l *ModLookupConc[T]) Clear() { 42 | l.Lookup = make(map[lookupID]T) 43 | } 44 | 45 | func (l *ModLookupConc[T]) Set(m T) { 46 | l.Lookup[l.newLookupID(m)] = m 47 | } 48 | 49 | func (l *ModLookupConc[T]) Has(m T) bool { 50 | _, ok := l.Lookup[l.newLookupID(m)] 51 | return ok 52 | } 53 | 54 | func (l *ModLookupConc[T]) Get(m T) (found T, ok bool) { 55 | found, ok = l.Lookup[l.newLookupID(m)] 56 | return 57 | } 58 | 59 | func (l *ModLookupConc[T]) GetByID(modID ModID) (found T, ok bool) { 60 | for _, m := range l.Lookup { 61 | if m.ID() == modID { 62 | found = m 63 | ok = true 64 | break 65 | } 66 | } 67 | return 68 | } 69 | 70 | func (l *ModLookupConc[T]) Remove(m T) { 71 | delete(l.Lookup, l.newLookupID(m)) 72 | } 73 | func (l *ModLookupConc[T]) RemoveConditionally(f func(m T) bool) { 74 | var toRemove []lookupID 75 | for k, m := range l.Lookup { 76 | if f(m) { 77 | toRemove = append(toRemove, k) 78 | } 79 | } 80 | for _, k := range toRemove { 81 | delete(l.Lookup, k) 82 | } 83 | } 84 | 85 | func (l *ModLookupConc[T]) Len() int { 86 | return len(l.Lookup) 87 | } 88 | 89 | func (l *ModLookupConc[T]) newLookupID(m T) lookupID { 90 | return lookupID(m.ID()) 91 | } 92 | -------------------------------------------------------------------------------- /ui/local/enableBinding.go: -------------------------------------------------------------------------------- 1 | package local 2 | 3 | import ( 4 | "fyne.io/fyne/v2/data/binding" 5 | "github.com/kiamev/moogle-mod-manager/actions" 6 | "github.com/kiamev/moogle-mod-manager/mods" 7 | "github.com/kiamev/moogle-mod-manager/ui/state" 8 | "github.com/kiamev/moogle-mod-manager/ui/util" 9 | ) 10 | 11 | type ( 12 | enableBind struct { 13 | binding.Bool 14 | parent *localUI 15 | tm mods.TrackedMod 16 | start func() bool 17 | modEnabledCallback func(result actions.Result) 18 | } 19 | ) 20 | 21 | func newEnableBind(parent *localUI, tm mods.TrackedMod, start func() bool, modEnabledCallback func(r actions.Result)) *enableBind { 22 | var ( 23 | b = &enableBind{ 24 | parent: parent, 25 | Bool: binding.NewBool(), 26 | tm: tm, 27 | start: start, 28 | modEnabledCallback: modEnabledCallback, 29 | } 30 | ) 31 | _ = b.Set(tm.Enabled()) 32 | b.AddListener(b) 33 | return b 34 | } 35 | 36 | func (b *enableBind) DataChanged() { 37 | var ( 38 | isChecked, _ = b.Get() 39 | tmEnabled = b.tm.Enabled() 40 | action actions.Action 41 | err error 42 | ) 43 | if isChecked != tmEnabled { 44 | if !b.start() { 45 | return 46 | } 47 | if isChecked { 48 | // Enable 49 | if action, err = actions.New(actions.Install, state.CurrentGame, b.tm, b.ActionDone); err != nil { 50 | util.ShowErrorLong(err) 51 | _ = b.Set(false) 52 | } else if err = action.Run(); err != nil { 53 | util.ShowErrorLong(err) 54 | _ = b.Set(false) 55 | } 56 | } else { 57 | // Disable 58 | if action, err = actions.New(actions.Uninstall, state.CurrentGame, b.tm, b.ActionDone); err != nil { 59 | util.ShowErrorLong(err) 60 | _ = b.Set(true) 61 | } else if err = action.Run(); err != nil { 62 | util.ShowErrorLong(err) 63 | _ = b.Set(true) 64 | } 65 | } 66 | } 67 | } 68 | 69 | func (b *enableBind) ActionDone(r actions.Result) { 70 | if r.Err != nil { 71 | util.ShowErrorLong(r.Err) 72 | } 73 | b.modEnabledCallback(r) 74 | b.parent.ModList.Refresh() 75 | } 76 | -------------------------------------------------------------------------------- /discover/remote/discover.go: -------------------------------------------------------------------------------- 1 | package remote 2 | 3 | import ( 4 | "encoding/json" 5 | "fmt" 6 | "github.com/kiamev/moogle-mod-manager/config" 7 | "github.com/kiamev/moogle-mod-manager/config/secrets" 8 | "github.com/kiamev/moogle-mod-manager/mods" 9 | "golang.org/x/sync/errgroup" 10 | "sync" 11 | ) 12 | 13 | func GetMod(game config.GameDef, id mods.ModID, rebuildCache bool) (found bool, mod *mods.Mod, err error) { 14 | var result []*mods.Mod 15 | if result, err = GetMods(game, rebuildCache); err != nil { 16 | return 17 | } 18 | for _, mod = range result { 19 | if found = mod.ID() == id; found { 20 | return 21 | } 22 | } 23 | return 24 | } 25 | 26 | func GetFromUrl(kind mods.Kind, url string) (bool, *mods.Mod, error) { 27 | var c Client 28 | switch kind { 29 | case mods.CurseForge: 30 | c = NewCurseForgeClient() 31 | case mods.Nexus: 32 | c = NewNexusClient() 33 | default: 34 | return false, nil, fmt.Errorf("invalid kind to GetFromUrl %v", kind) 35 | } 36 | return c.GetFromUrl(url) 37 | } 38 | 39 | func GetMods(game config.GameDef, rebuildCache bool) (result []*mods.Mod, err error) { 40 | var ( 41 | eg = errgroup.Group{} 42 | m = sync.Mutex{} 43 | ) 44 | 45 | // Get the mods from the remote sources 46 | for _, cl := range GetClients() { 47 | getMods(game, cl, &eg, &m, &result, rebuildCache) 48 | } 49 | if err = eg.Wait(); err != nil { 50 | return 51 | } 52 | return 53 | } 54 | 55 | func GetClients() []Client { 56 | var c []Client 57 | if secrets.Get(secrets.NexusApiKey) != "" { 58 | c = append(c, NewNexusClient()) 59 | } 60 | if secrets.Get(secrets.CfApiKey) != "" { 61 | NewCurseForgeClient() 62 | } 63 | return c 64 | } 65 | 66 | func getMods(game config.GameDef, c Client, eg *errgroup.Group, m *sync.Mutex, result *[]*mods.Mod, rebuildCache bool) { 67 | eg.Go(func() error { 68 | r, e := c.GetMods(game, rebuildCache) 69 | if e != nil { 70 | switch e.(type) { 71 | case *json.SyntaxError: 72 | return nil 73 | default: 74 | return e 75 | } 76 | } 77 | m.Lock() 78 | *result = append(*result, r...) 79 | m.Unlock() 80 | return nil 81 | }) 82 | } 83 | -------------------------------------------------------------------------------- /ui/custom-widgets/openFileDialogEntry.go: -------------------------------------------------------------------------------- 1 | package custom_widgets 2 | 3 | import ( 4 | "fyne.io/fyne/v2" 5 | "fyne.io/fyne/v2/data/binding" 6 | "fyne.io/fyne/v2/widget" 7 | "github.com/kiamev/moogle-mod-manager/config" 8 | "github.com/ncruces/zenity" 9 | "strings" 10 | ) 11 | 12 | type OpenFileDialogHandler interface { 13 | widget.ToolbarItem 14 | Handle() 15 | Get() string 16 | SetAction(a *widget.ToolbarAction) 17 | } 18 | 19 | type OpenFileDialogContainer struct { 20 | *fyne.Container 21 | OpenFileDialogHandler OpenFileDialogHandler 22 | } 23 | 24 | type OpenDirDialog struct { 25 | *widget.ToolbarAction 26 | BaseDir binding.String 27 | Value binding.String 28 | IsRelative bool 29 | } 30 | 31 | func (o *OpenDirDialog) Get() string { 32 | s, _ := o.Value.Get() 33 | return s 34 | } 35 | 36 | func (o *OpenDirDialog) SetAction(a *widget.ToolbarAction) { o.ToolbarAction = a } 37 | 38 | func (o *OpenDirDialog) Handle() { 39 | dir := config.PWD 40 | if o.BaseDir != nil { 41 | dir, _ = o.BaseDir.Get() 42 | } 43 | s, err := zenity.SelectFile( 44 | zenity.Title("Select file"), 45 | zenity.Filename(dir), 46 | zenity.Directory()) 47 | if err == nil { 48 | if o.IsRelative { 49 | s = strings.ReplaceAll(s, dir, "") 50 | s = strings.ReplaceAll(s, "\\", "/") 51 | if len(s) == 0 || (len(s) > 0 && s[0] == '/') { 52 | s = "." + s 53 | } 54 | } 55 | _ = o.Value.Set(s) 56 | } 57 | } 58 | 59 | type OpenFileDialog struct { 60 | *widget.ToolbarAction 61 | BaseDir binding.String 62 | Value binding.String 63 | IsRelative bool 64 | } 65 | 66 | func (o *OpenFileDialog) Get() string { 67 | s, _ := o.Value.Get() 68 | return s 69 | } 70 | 71 | func (o *OpenFileDialog) SetAction(a *widget.ToolbarAction) { o.ToolbarAction = a } 72 | 73 | func (o *OpenFileDialog) Handle() { 74 | dir := config.PWD 75 | if o.BaseDir != nil { 76 | dir, _ = o.BaseDir.Get() 77 | } 78 | s, err := zenity.SelectFile( 79 | zenity.Title("Select file"), 80 | zenity.Filename(dir), 81 | zenity.FileFilter{ 82 | Name: "All files", 83 | Patterns: []string{"*"}, 84 | }) 85 | if err == nil { 86 | if o.IsRelative { 87 | s = strings.ReplaceAll(s, dir, "") 88 | s = strings.ReplaceAll(s, "\\", "/") 89 | if len(s) > 0 && s[0] == '/' { 90 | s = "." + s 91 | } 92 | } 93 | _ = o.Value.Set(s) 94 | } 95 | } 96 | -------------------------------------------------------------------------------- /ui/conflicts/conflicts.go: -------------------------------------------------------------------------------- 1 | package conflicts 2 | 3 | import ( 4 | "path/filepath" 5 | 6 | "fyne.io/fyne/v2" 7 | "fyne.io/fyne/v2/container" 8 | "fyne.io/fyne/v2/dialog" 9 | "fyne.io/fyne/v2/widget" 10 | "github.com/kiamev/moogle-mod-manager/files" 11 | "github.com/kiamev/moogle-mod-manager/mods" 12 | "github.com/kiamev/moogle-mod-manager/ui/state/ui" 13 | ) 14 | 15 | type item struct { 16 | *widget.Select 17 | Conflict *files.Conflict 18 | } 19 | 20 | func ShowConflicts(mod *mods.Mod, conflicts []*files.Conflict, done func(mods.Result)) { 21 | var ( 22 | d dialog.Dialog 23 | f = widget.NewForm() 24 | items = createItems(f, mod, conflicts) 25 | c = container.NewBorder( 26 | container.NewHBox( 27 | widget.NewButton("Skip All", func() { 28 | for _, i := range items { 29 | i.skip() 30 | } 31 | }), 32 | widget.NewButton("Overwrite All", func() { 33 | for _, i := range items { 34 | i.overwrite(mod) 35 | } 36 | })), nil, nil, nil, 37 | container.NewVScroll(f)) 38 | ) 39 | d = dialog.NewCustomConfirm("Conflicts", "ok", "cancel", c, func(ok bool) { 40 | for _, i := range items { 41 | if i.Conflict.Selection == nil { 42 | ShowConflicts(mod, conflicts, done) 43 | dialog.ShowInformation("Error", "Please select an option for all conflicts", ui.ActiveWindow()) 44 | return 45 | } 46 | } 47 | r := mods.Ok 48 | if !ok { 49 | r = mods.Cancel 50 | } 51 | done(r) 52 | }, ui.Window) 53 | d.Resize(fyne.NewSize(400, 400)) 54 | d.Show() 55 | } 56 | 57 | func createItems(form *widget.Form, mod *mods.Mod, conflicts []*files.Conflict) (items []*item) { 58 | items = make([]*item, len(conflicts)) 59 | for i, c := range conflicts { 60 | j := &item{ 61 | Select: widget.NewSelect([]string{string(mod.Name), string(c.Owner.Name)}, func(s string) { 62 | if s == string(mod.Name) { 63 | c.Selection = mod 64 | } else { 65 | c.Selection = c.Owner 66 | } 67 | }), 68 | Conflict: c, 69 | } 70 | items[i] = j 71 | form.AppendItem(widget.NewFormItem(filepath.Base(c.Path), j.Select)) 72 | } 73 | return 74 | } 75 | 76 | func (i item) skip() { 77 | i.Conflict.Selection = i.Conflict.Owner 78 | i.Select.SetSelected(string(i.Conflict.Owner.Name)) 79 | } 80 | 81 | func (i item) overwrite(mod *mods.Mod) { 82 | i.Conflict.Selection = mod 83 | i.Select.SetSelected(string(mod.Name)) 84 | } 85 | -------------------------------------------------------------------------------- /util/files.go: -------------------------------------------------------------------------------- 1 | package util 2 | 3 | import ( 4 | "encoding/base64" 5 | "encoding/json" 6 | "encoding/xml" 7 | "fmt" 8 | "os" 9 | "path/filepath" 10 | "regexp" 11 | "strings" 12 | ) 13 | 14 | func FileExists(file string) bool { 15 | _, err := os.Stat(file) 16 | return err == nil 17 | } 18 | 19 | func CreateFileName(s string) string { 20 | if reg, err := regexp.Compile("[^a-zA-Z0-9_]+"); err == nil { 21 | s = strings.TrimSpace(reg.ReplaceAllString(s, "")) 22 | if len(s) > 0 { 23 | return s 24 | } 25 | } 26 | return strings.Trim(base64.StdEncoding.EncodeToString([]byte(s)), "=") 27 | } 28 | 29 | func LoadFromFile(file string, i interface{}) (err error) { 30 | var ( 31 | b []byte 32 | ext = filepath.Ext(file) 33 | ) 34 | if _, err = os.Stat(file); err != nil { 35 | return err 36 | } 37 | if b, err = os.ReadFile(file); err != nil { 38 | return fmt.Errorf("failed to read %s: %v", file, err) 39 | } 40 | switch ext { 41 | case ".json", ".moogle": 42 | err = json.Unmarshal(b, i) 43 | case ".xml": 44 | err = xml.Unmarshal(b, i) 45 | default: 46 | return fmt.Errorf("unknown file extension: %s", file) 47 | } 48 | return 49 | } 50 | 51 | func SaveToFile(file string, i interface{}, endFileChar ...byte) (err error) { 52 | var ( 53 | b []byte 54 | f *os.File 55 | ) 56 | if err = os.MkdirAll(filepath.Dir(file), 0777); err != nil { 57 | return fmt.Errorf("failed to create directory %s: %v", filepath.Dir(file), err) 58 | } 59 | if b, err = json.MarshalIndent(i, "", "\t"); err != nil { 60 | return fmt.Errorf("failed to marshal %s: %v", file, err) 61 | } 62 | if len(endFileChar) > 0 { 63 | b = append(b, endFileChar...) 64 | } 65 | if f, err = os.Create(file); err != nil { 66 | return fmt.Errorf("failed to create %s: %v", file, err) 67 | } 68 | if _, err = f.Write(b); err != nil { 69 | return fmt.Errorf("failed to write %s: %v", file, err) 70 | } 71 | return 72 | } 73 | 74 | func MoveFile(from, to string) (err error) { 75 | var ( 76 | b []byte 77 | dir = filepath.Dir(to) 78 | ) 79 | if err = os.MkdirAll(dir, 0755); err != nil { 80 | return err 81 | } 82 | if err = os.Rename(from, to); err != nil { 83 | if b, err = os.ReadFile(from); err != nil { 84 | return 85 | } 86 | if err = os.WriteFile(to, b, 0755); err != nil { 87 | _ = os.Remove(to) 88 | return 89 | } else { 90 | _ = os.Remove(from) 91 | } 92 | } 93 | return 94 | } 95 | -------------------------------------------------------------------------------- /discover/remote/github/getter.go: -------------------------------------------------------------------------------- 1 | package github 2 | 3 | import ( 4 | "encoding/json" 5 | "errors" 6 | "fmt" 7 | "github.com/kiamev/moogle-mod-manager/mods" 8 | "io" 9 | "net/http" 10 | ) 11 | 12 | const ( 13 | getTagUrl = "https://api.github.com/repos/%s/%s/releases/latest" 14 | listDownloadsUrl = "https://api.github.com/repos/%s/%s/releases/tags/%s" 15 | ) 16 | 17 | type Release struct { 18 | TagName string `json:"tag_name"` 19 | } 20 | 21 | func LatestReleaseFromMod(mod *mods.Mod) (tag string, err error) { 22 | if mod == nil || mod.ModKind.GitHub == nil { 23 | return "", errors.New("mod is nil or not a github mod") 24 | } 25 | return LatestRelease(mod.ModKind.GitHub.Owner, mod.ModKind.GitHub.Repo) 26 | } 27 | 28 | func LatestRelease(owner, repo string) (tag string, err error) { 29 | var ( 30 | resp *http.Response 31 | body []byte 32 | release Release 33 | // Replace with the owner and repository of the desired GitHub repository 34 | ) 35 | 36 | // Send a GET request to the GitHub REST API to retrieve the latest release 37 | resp, err = http.Get(fmt.Sprintf(getTagUrl, owner, repo)) 38 | if err != nil { 39 | // Handle error 40 | return 41 | } 42 | defer func() { _ = resp.Body.Close() }() 43 | 44 | // Read the response body and unmarshal it into a Release struct 45 | if body, err = io.ReadAll(resp.Body); err != nil { 46 | // Handle error 47 | return 48 | } 49 | 50 | if err = json.Unmarshal(body, &release); err != nil { 51 | // Handle error 52 | return 53 | } 54 | 55 | return release.TagName, nil 56 | } 57 | 58 | type ( 59 | Download struct { 60 | Name string `json:"name"` 61 | URL string `json:"browser_download_url"` 62 | } 63 | DlRelease struct { 64 | Assets []Download `json:"assets"` 65 | } 66 | ) 67 | 68 | func ListDownloads(owner, repo, tag string) (downloads []Download, err error) { 69 | // Send a GET request to the GitHub REST API to retrieve the specified release 70 | var ( 71 | resp *http.Response 72 | release DlRelease 73 | body []byte 74 | ) 75 | 76 | if resp, err = http.Get(fmt.Sprintf(listDownloadsUrl, owner, repo, tag)); err != nil { 77 | return nil, err 78 | } 79 | defer func() { _ = resp.Body.Close() }() 80 | 81 | // Read the response body and unmarshal it into a Release struct 82 | if body, err = io.ReadAll(resp.Body); err != nil { 83 | return nil, err 84 | } 85 | if err = json.Unmarshal(body, &release); err != nil { 86 | return nil, err 87 | } 88 | 89 | // Return the list of assets 90 | return release.Assets, nil 91 | } 92 | -------------------------------------------------------------------------------- /ui/mod-author/donations.go: -------------------------------------------------------------------------------- 1 | package mod_author 2 | 3 | import ( 4 | "fyne.io/fyne/v2" 5 | "fyne.io/fyne/v2/container" 6 | "fyne.io/fyne/v2/dialog" 7 | "fyne.io/fyne/v2/widget" 8 | "github.com/kiamev/moogle-mod-manager/mods" 9 | cw "github.com/kiamev/moogle-mod-manager/ui/custom-widgets" 10 | "github.com/kiamev/moogle-mod-manager/ui/mod-author/entry" 11 | "github.com/kiamev/moogle-mod-manager/ui/state/ui" 12 | ) 13 | 14 | type donationsDef struct { 15 | entry.Manager 16 | list *cw.DynamicList 17 | } 18 | 19 | func newDonationsDef() *donationsDef { 20 | d := &donationsDef{ 21 | Manager: entry.NewManager(), 22 | } 23 | d.list = cw.NewDynamicList(cw.Callbacks{ 24 | GetItemKey: d.getItemKey, 25 | GetItemFields: d.getItemFields, 26 | OnEditItem: d.onEditItem, 27 | }, true) 28 | return d 29 | } 30 | 31 | func (d *donationsDef) compile() []*mods.DonationLink { 32 | dls := make([]*mods.DonationLink, len(d.list.Items)) 33 | for i, item := range d.list.Items { 34 | dls[i] = item.(*mods.DonationLink) 35 | } 36 | return dls 37 | } 38 | 39 | func (d *donationsDef) getItemKey(item interface{}) string { 40 | return item.(*mods.DonationLink).Name 41 | } 42 | 43 | func (d *donationsDef) getItemFields(item interface{}) []string { 44 | m := item.(*mods.DonationLink) 45 | return []string{ 46 | m.Name, 47 | m.Link, 48 | } 49 | } 50 | 51 | func (d *donationsDef) onEditItem(item interface{}) { 52 | d.createItem(item) 53 | } 54 | 55 | func (d *donationsDef) createItem(item interface{}, done ...func(interface{})) { 56 | m := item.(*mods.DonationLink) 57 | entry.NewEntry[string](d, entry.KindString, "Name", m.Name) 58 | entry.NewEntry[string](d, entry.KindString, "Link", m.Link) 59 | 60 | fd := dialog.NewForm("Edit Donation", "Save", "Cancel", []*widget.FormItem{ 61 | entry.FormItem[string](d, "Name"), 62 | entry.FormItem[string](d, "Link"), 63 | }, func(ok bool) { 64 | if ok { 65 | m.Name = entry.Value[string](d, "Name") 66 | m.Link = entry.Value[string](d, "Link") 67 | if len(done) > 0 { 68 | done[0](m) 69 | } 70 | d.list.Refresh() 71 | } 72 | }, ui.Window) 73 | fd.Resize(fyne.NewSize(400, 400)) 74 | fd.Show() 75 | } 76 | 77 | func (d *donationsDef) draw() fyne.CanvasObject { 78 | return container.NewVBox(container.NewHBox( 79 | widget.NewLabelWithStyle("Donation Links", fyne.TextAlignCenter, fyne.TextStyle{Bold: true}), 80 | widget.NewButton("Add", func() { 81 | d.createItem(&mods.DonationLink{}, func(result interface{}) { 82 | d.list.AddItem(result) 83 | }) 84 | })), 85 | d.list.Draw()) 86 | } 87 | 88 | func (d *donationsDef) set(links []*mods.DonationLink) { 89 | d.list.Clear() 90 | for _, i := range links { 91 | d.list.AddItem(i) 92 | } 93 | } 94 | -------------------------------------------------------------------------------- /ui/mod-author/previewsDef.go: -------------------------------------------------------------------------------- 1 | package mod_author 2 | 3 | import ( 4 | "fyne.io/fyne/v2" 5 | "fyne.io/fyne/v2/container" 6 | "fyne.io/fyne/v2/dialog" 7 | "fyne.io/fyne/v2/widget" 8 | "github.com/kiamev/moogle-mod-manager/mods" 9 | cw "github.com/kiamev/moogle-mod-manager/ui/custom-widgets" 10 | "github.com/kiamev/moogle-mod-manager/ui/mod-author/entry" 11 | "github.com/kiamev/moogle-mod-manager/ui/state/ui" 12 | ) 13 | 14 | type previewsDef struct { 15 | entry.Manager 16 | list *cw.DynamicList 17 | } 18 | 19 | func newpPreviewsDef() *previewsDef { 20 | d := &previewsDef{ 21 | Manager: entry.NewManager(), 22 | } 23 | d.list = cw.NewDynamicList(cw.Callbacks{ 24 | GetItemKey: d.getItemKey, 25 | GetItemFields: d.getItemFields, 26 | OnEditItem: d.onEditItem, 27 | }, true) 28 | return d 29 | } 30 | 31 | func (d *previewsDef) compile() []*mods.Preview { 32 | p := make([]*mods.Preview, len(d.list.Items)) 33 | for i, item := range d.list.Items { 34 | p[i] = item.(*mods.Preview) 35 | } 36 | return p 37 | } 38 | 39 | func (d *previewsDef) getItemKey(item interface{}) string { 40 | u := item.(*mods.Preview).Url 41 | if u == nil { 42 | return "" 43 | } 44 | return *u 45 | } 46 | 47 | func (d *previewsDef) getItemFields(item interface{}) []string { 48 | var ( 49 | m = item.(*mods.Preview) 50 | u string 51 | ) 52 | if m.Url != nil { 53 | u = *m.Url 54 | } 55 | return []string{u} 56 | } 57 | 58 | func (d *previewsDef) onEditItem(item interface{}) { 59 | d.createItem(item) 60 | } 61 | 62 | func (d *previewsDef) createItem(item interface{}, done ...func(interface{})) { 63 | var ( 64 | m = item.(*mods.Preview) 65 | u string 66 | ) 67 | if m.Url != nil { 68 | u = *m.Url 69 | } 70 | entry.NewEntry[string](d, entry.KindString, "Image URL", u) 71 | 72 | fd := dialog.NewForm("Preview Images", "Save", "Cancel", []*widget.FormItem{ 73 | entry.FormItem[string](d, "Image URL"), 74 | }, func(ok bool) { 75 | if ok { 76 | if m.Url == nil { 77 | m.Url = new(string) 78 | } 79 | *m.Url = entry.Value[string](d, "Image URL") 80 | if len(done) > 0 { 81 | done[0](m) 82 | } 83 | d.list.Refresh() 84 | } 85 | }, ui.Window) 86 | fd.Resize(fyne.NewSize(400, 400)) 87 | fd.Show() 88 | } 89 | 90 | func (d *previewsDef) draw() fyne.CanvasObject { 91 | return container.NewVBox(container.NewHBox( 92 | widget.NewButton("Add", func() { 93 | d.createItem(&mods.Preview{}, func(result interface{}) { 94 | d.list.AddItem(result) 95 | }) 96 | })), 97 | d.list.Draw()) 98 | } 99 | 100 | func (d *previewsDef) set(links []*mods.Preview) { 101 | d.list.Clear() 102 | for _, i := range links { 103 | d.list.AddItem(i) 104 | } 105 | } 106 | -------------------------------------------------------------------------------- /ui/mod-author/downloadFiles.go: -------------------------------------------------------------------------------- 1 | package mod_author 2 | 3 | import ( 4 | "fyne.io/fyne/v2/widget" 5 | "github.com/kiamev/moogle-mod-manager/config" 6 | "github.com/kiamev/moogle-mod-manager/mods" 7 | "github.com/kiamev/moogle-mod-manager/ui/mod-author/entry" 8 | ) 9 | 10 | type downloadFilesDef struct { 11 | entry.Manager 12 | downloads *downloads 13 | selectEntry *entry.SelectFormEntry 14 | files *filesDef 15 | dirs *dirsDef 16 | } 17 | 18 | func newDownloadFilesDef(downloads *downloads, installType *config.InstallType, gamesDef *gamesDef) *downloadFilesDef { 19 | d := &downloadFilesDef{ 20 | Manager: entry.NewManager(), 21 | downloads: downloads, 22 | files: newFilesDef(installType, gamesDef), 23 | dirs: newDirsDef(installType, gamesDef), 24 | } 25 | var i any = entry.NewSelectEntry(d, "Download Name", "", nil) 26 | d.selectEntry = i.(*entry.SelectFormEntry) 27 | return d 28 | } 29 | 30 | func (d *downloadFilesDef) compile() *mods.DownloadFiles { 31 | return &mods.DownloadFiles{ 32 | DownloadName: entry.Value[string](d, "Download Name"), 33 | Files: d.files.compile(), 34 | Dirs: d.dirs.compile(), 35 | } 36 | } 37 | 38 | /*func (d *downloadFilesDef) draw() fyne.CanvasObject { 39 | var possible []string 40 | for _, dl := range d.downloads.compileDownloads() { 41 | possible = append(possible, dl.Name) 42 | } 43 | 44 | entry.NewEntry[string](d, entry.KindSelect, "Download Name", possible, d.dlName) 45 | 46 | return container.NewVBox( 47 | widget.NewForm(entry.FormItem[string](d, "Download Name")), 48 | d.files.draw(true), 49 | d.dirs.draw(true), 50 | ) 51 | }*/ 52 | 53 | func (d *downloadFilesDef) getFormItems() ([]*widget.FormItem, error) { 54 | var ( 55 | possible = []string{""} 56 | dls, err = d.downloads.compileDownloads() 57 | ) 58 | if err != nil { 59 | return nil, err 60 | } 61 | for _, dl := range dls { 62 | possible = append(possible, dl.Name) 63 | } 64 | d.selectEntry.Entry.Options = possible 65 | 66 | return []*widget.FormItem{ 67 | entry.FormItem[string](d, "Download Name"), 68 | widget.NewFormItem("Files", d.files.draw(false)), 69 | widget.NewFormItem("Dirs", d.dirs.draw(false)), 70 | }, nil 71 | } 72 | 73 | func (d *downloadFilesDef) clear() { 74 | d.selectEntry.Set("") 75 | d.files.clear() 76 | d.dirs.clear() 77 | } 78 | 79 | func (d *downloadFilesDef) populate(dlf *mods.DownloadFiles) { 80 | if dlf == nil { 81 | d.clear() 82 | } else { 83 | d.selectEntry.Set(dlf.DownloadName) 84 | d.files.populate(dlf.Files) 85 | d.dirs.populate(dlf.Dirs) 86 | } 87 | } 88 | 89 | /*func (d *downloadFilesDef) set(df *mods.DownloadFiles) { 90 | d.dlName = "" 91 | d.files.clear() 92 | d.dirs.clear() 93 | if df != nil { 94 | d.dlName = df.DownloadName 95 | d.files.populate(df.Files) 96 | d.dirs.populate(df.Dirs) 97 | } 98 | }*/ 99 | -------------------------------------------------------------------------------- /mods/toInstall.go: -------------------------------------------------------------------------------- 1 | package mods 2 | 3 | import ( 4 | "fmt" 5 | "github.com/kiamev/moogle-mod-manager/config" 6 | "github.com/kiamev/moogle-mod-manager/util" 7 | "os" 8 | "path/filepath" 9 | ) 10 | 11 | type ToInstall struct { 12 | kinds Kinds 13 | Download *Download 14 | DownloadFiles []*DownloadFiles 15 | downloadDir string 16 | } 17 | 18 | func NewToInstall(kinds Kinds, download *Download, downloadFiles *DownloadFiles) *ToInstall { 19 | return &ToInstall{ 20 | kinds: kinds, 21 | Download: download, 22 | DownloadFiles: []*DownloadFiles{downloadFiles}, 23 | } 24 | } 25 | 26 | func NewToInstallForMod(mod *Mod, downloadFiles []*DownloadFiles) (result []*ToInstall, err error) { 27 | mLookup := make(map[string]*Download) 28 | dfLookup := make(map[string]*DownloadFiles) 29 | for _, dl := range mod.Downloadables { 30 | mLookup[dl.Name] = dl 31 | } 32 | for _, df := range downloadFiles { 33 | i, ok := dfLookup[df.DownloadName] 34 | if !ok { 35 | i = &DownloadFiles{ 36 | DownloadName: df.DownloadName, 37 | } 38 | dfLookup[df.DownloadName] = i 39 | } 40 | i.Files = append(i.Files, df.Files...) 41 | i.Dirs = append(i.Dirs, df.Dirs...) 42 | } 43 | for n, df := range dfLookup { 44 | dl := mLookup[n] 45 | result = append(result, NewToInstall(mod.Kinds(), dl, df)) 46 | } 47 | return 48 | } 49 | 50 | func (ti *ToInstall) GetDownloadLocation(game config.GameDef, tm TrackedMod) (string, error) { 51 | if ti.downloadDir != "" { 52 | return ti.downloadDir, nil 53 | } 54 | if ti.kinds.IsHosted() { 55 | return ti.getHostedDownloadLocation(game, tm, tm.Mod().Version) 56 | } 57 | return ti.getRemoteDownloadLocation(game, tm) 58 | } 59 | 60 | func (ti *ToInstall) getHostedDownloadLocation(game config.GameDef, tm TrackedMod, v string) (string, error) { 61 | var m = tm.Mod() 62 | if v == "" { 63 | v = "nv" 64 | } 65 | if len(m.Games) > 0 && m.Category == config.Utility { 66 | ti.downloadDir = config.Get().GetDownloadFullPathForUtility() 67 | } else { 68 | ti.downloadDir = config.Get().GetDownloadFullPathForGame(game) 69 | } 70 | ti.downloadDir = filepath.Join(ti.downloadDir, tm.ID().AsDir(), util.CreateFileName(v)) 71 | if err := createPath(ti.downloadDir); err != nil { 72 | return "", err 73 | } 74 | return ti.downloadDir, nil 75 | } 76 | 77 | func (ti *ToInstall) getRemoteDownloadLocation(game config.GameDef, tm TrackedMod) (string, error) { 78 | ti.downloadDir = filepath.Join(config.Get().GetDownloadFullPathForGame(game), tm.ID().AsDir(), util.CreateFileName(ti.Download.Version)) 79 | if err := createPath(ti.downloadDir); err != nil { 80 | return "", err 81 | } 82 | return ti.downloadDir, nil 83 | } 84 | 85 | func createPath(path string) error { 86 | if err := os.MkdirAll(path, os.ModePerm); err != nil { 87 | err = fmt.Errorf("failed to create mod directory: %v", err) 88 | return err 89 | } 90 | return nil 91 | } 92 | -------------------------------------------------------------------------------- /discover/repo/repoDef.go: -------------------------------------------------------------------------------- 1 | package repo 2 | 3 | import ( 4 | "fmt" 5 | "os" 6 | "path/filepath" 7 | "strings" 8 | 9 | "github.com/kiamev/moogle-mod-manager/config" 10 | "github.com/kiamev/moogle-mod-manager/mods" 11 | "github.com/kiamev/moogle-mod-manager/util" 12 | ) 13 | 14 | const ( 15 | authorDir = "author" 16 | repoDir = "repo" 17 | defaultRepoName = "mmmm" 18 | defaultRepoUrl = "https://github.com/KiameV/moogle-mod-manager-mods" 19 | ) 20 | 21 | var repoDefs []repoDef 22 | 23 | type ( 24 | UseKind byte 25 | repoDef struct { 26 | Name string `json:"name"` 27 | Url string `json:"url"` 28 | } 29 | ) 30 | 31 | const ( 32 | _ UseKind = iota 33 | Author 34 | Read 35 | ) 36 | 37 | func (d repoDef) Source() string { 38 | sp := strings.Split(d.Url, "/") 39 | return sp[len(sp)-1] 40 | } 41 | 42 | func (d repoDef) repoDir(k UseKind) string { 43 | dir := repoDir 44 | if k == Author { 45 | dir = authorDir 46 | } 47 | return filepath.Join(config.PWD, dir, d.Name) 48 | } 49 | 50 | func (d repoDef) repoUtilDir(k UseKind) string { 51 | return filepath.Join(d.repoDir(k), "utilities") 52 | } 53 | 54 | func (d repoDef) repoGameDir(k UseKind, game config.GameDef) string { 55 | if game == nil { 56 | return "" 57 | } 58 | return filepath.Join(d.repoDir(k), string(game.ID())) 59 | } 60 | 61 | func (d repoDef) repoGameModDir(k UseKind, game config.GameDef, mod *mods.Mod) string { 62 | return filepath.Join(d.repoGameDir(k, game), strings.ToLower(string(mod.Kinds()[0])), d.removeFilePrefixes(strings.ToLower(mod.ID().AsDir()))) 63 | } 64 | 65 | func (d repoDef) removeFilePrefixes(s string) string { 66 | s = strings.TrimPrefix(s, hostedPrefix) 67 | s = strings.TrimPrefix(s, nexusPrefix) 68 | s = strings.TrimPrefix(s, curseforgePrefix) 69 | return s 70 | } 71 | 72 | func Initialize() (err error) { 73 | f := filepath.Join(config.PWD, "repo.json") 74 | if len(repoDefs) == 0 { 75 | if _, err = os.Stat(f); err != nil { 76 | repoDefs = []repoDef{{ 77 | Name: defaultRepoName, 78 | Url: defaultRepoUrl, 79 | }} 80 | return saveDefaultRepo(f) 81 | } 82 | if err = util.LoadFromFile(f, &repoDefs); err != nil { 83 | return 84 | } 85 | } 86 | if len(repoDefs) == 0 { 87 | err = fmt.Errorf("no repositories found in %s, using default repository", f) 88 | _ = saveDefaultRepo(f) 89 | } 90 | return 91 | } 92 | 93 | func ClearCache() { 94 | _ = os.RemoveAll(filepath.Join(config.PWD, repoDir)) 95 | } 96 | 97 | func Dirs(k UseKind) (dirs []string) { 98 | dirs = make([]string, len(repoDefs)) 99 | for i, rd := range repoDefs { 100 | dirs[i] = rd.repoDir(k) 101 | } 102 | return 103 | } 104 | 105 | func saveDefaultRepo(f string) error { 106 | repoDefs = []repoDef{{ 107 | Name: defaultRepoName, 108 | Url: defaultRepoUrl, 109 | }} 110 | return util.SaveToFile(f, &repoDefs) 111 | } 112 | -------------------------------------------------------------------------------- /ui/configure/configure.go: -------------------------------------------------------------------------------- 1 | package configure 2 | 3 | import ( 4 | "os" 5 | 6 | "fyne.io/fyne/v2" 7 | "fyne.io/fyne/v2/container" 8 | "fyne.io/fyne/v2/data/binding" 9 | "fyne.io/fyne/v2/dialog" 10 | "fyne.io/fyne/v2/theme" 11 | "fyne.io/fyne/v2/widget" 12 | "github.com/kiamev/moogle-mod-manager/config" 13 | cw "github.com/kiamev/moogle-mod-manager/ui/custom-widgets" 14 | ) 15 | 16 | func Show(w fyne.Window, done func()) { 17 | configs := *config.Get() 18 | items := []*widget.FormItem{ 19 | createSelectRow("Default GameDef", &configs.DefaultGame, config.GameIDs()...), 20 | createCheckboxRow("Check For M3 Updates on Start", configs.CheckForM3UpdateOnStart), 21 | createCheckboxRow("Delete Downloads After Install", &configs.DeleteDownloadAfterInstall), 22 | } 23 | for _, g := range config.GameDefs() { 24 | var ( 25 | gd *config.GameDir 26 | ok bool 27 | ) 28 | if gd, ok = configs.GameDirs[string(g.ID())]; !ok { 29 | gd = &config.GameDir{} 30 | configs.GameDirs[string(g.ID())] = gd 31 | } 32 | items = append(items, createDirRow(string(g.ID()+" Dir"), &gd.Dir)) 33 | } 34 | items = append(items, createDirRow("Download Dir", &configs.DownloadDir)) 35 | items = append(items, createDirRow("Backup Dir", &configs.BackupDir)) 36 | items = append(items, createDirRow("Image Cache Dir", &configs.ImgCacheDir)) 37 | 38 | d := dialog.NewForm("Configure", "Save", "Cancel", items, func(ok bool) { 39 | if ok { 40 | configs.FirstTime = false 41 | _ = os.MkdirAll(configs.ModsDir, 0777) 42 | _ = os.MkdirAll(configs.BackupDir, 0777) 43 | _ = os.MkdirAll(configs.DownloadDir, 0777) 44 | _ = os.MkdirAll(configs.ImgCacheDir, 0777) 45 | if err := configs.Save(); err != nil { 46 | dialog.ShowError(err, w) 47 | return 48 | } 49 | config.Set(configs) 50 | } 51 | if done != nil { 52 | done() 53 | } 54 | }, w) 55 | d.Resize(fyne.NewSize(800, 400)) 56 | d.Show() 57 | } 58 | 59 | func createSelectRow(label string, value *string, options ...string) *widget.FormItem { 60 | if *value == "" { 61 | *value = options[0] 62 | } 63 | sel := widget.NewSelect(options, func(s string) { 64 | *value = s 65 | }) 66 | sel.SetSelected(*value) 67 | return widget.NewFormItem(label, sel) 68 | } 69 | 70 | func createCheckboxRow(label string, value *bool, options ...string) *widget.FormItem { 71 | return widget.NewFormItem(label, widget.NewCheckWithData("", binding.BindBool(value))) 72 | } 73 | 74 | func createDirRow(label string, value *string) *widget.FormItem { 75 | b := binding.BindString(value) 76 | o := &cw.OpenDirDialog{ 77 | IsRelative: false, 78 | Value: b, 79 | } 80 | o.SetAction(widget.NewToolbarAction(theme.FolderOpenIcon(), o.Handle)) 81 | c := &cw.OpenFileDialogContainer{ 82 | Container: container.NewBorder(nil, nil, nil, widget.NewToolbar(o), widget.NewEntryWithData(b)), 83 | OpenFileDialogHandler: o, 84 | } 85 | return widget.NewFormItem(label, c.Container) 86 | } 87 | -------------------------------------------------------------------------------- /ui/custom-widgets/dynamicList.go: -------------------------------------------------------------------------------- 1 | package custom_widgets 2 | 3 | import ( 4 | "fyne.io/fyne/v2" 5 | "fyne.io/fyne/v2/container" 6 | "fyne.io/fyne/v2/dialog" 7 | "fyne.io/fyne/v2/theme" 8 | "fyne.io/fyne/v2/widget" 9 | "github.com/kiamev/moogle-mod-manager/ui/state/ui" 10 | ) 11 | 12 | type Callbacks struct { 13 | GetItemKey func(item interface{}) string 14 | GetItemFields func(item interface{}) []string 15 | OnEditItem func(item interface{}) 16 | } 17 | 18 | type DynamicList struct { 19 | list *fyne.Container 20 | Items []interface{} 21 | callbacks Callbacks 22 | confirmDelete bool 23 | } 24 | 25 | func NewDynamicList(callbacks Callbacks, confirmDelete bool) *DynamicList { 26 | return &DynamicList{ 27 | list: container.NewVBox(), 28 | callbacks: callbacks, 29 | confirmDelete: confirmDelete, 30 | } 31 | } 32 | 33 | func (l *DynamicList) AddItem(item interface{}) { 34 | l.Items = append(l.Items, item) 35 | l.list.Objects = append(l.list.Objects, l.createRow(item)) 36 | } 37 | 38 | func (l *DynamicList) createRow(item interface{}) fyne.CanvasObject { 39 | return container.NewHBox( 40 | widget.NewLabel(l.callbacks.GetItemKey(item)), 41 | widget.NewToolbar( 42 | // Edit 43 | newAction(item, theme.DocumentCreateIcon(), func(item interface{}) { 44 | l.callbacks.OnEditItem(item) 45 | }), 46 | // Remove 47 | newAction(item, theme.ContentRemoveIcon(), func(item interface{}) { 48 | l.removeItem(item) 49 | })), 50 | ) 51 | } 52 | 53 | func (l *DynamicList) Draw() fyne.CanvasObject { 54 | return l.list 55 | } 56 | 57 | func (l *DynamicList) Refresh() { 58 | for i, item := range l.Items { 59 | l.list.Objects[i] = l.createRow(item) 60 | } 61 | } 62 | 63 | func (l *DynamicList) Reset() { 64 | l.Items = make([]interface{}, 0) 65 | l.list.Objects = make([]fyne.CanvasObject, 0) 66 | } 67 | 68 | type Action struct { 69 | *widget.ToolbarAction 70 | } 71 | 72 | func newAction(item interface{}, icon fyne.Resource, onActivated func(item interface{})) *Action { 73 | return &Action{ 74 | ToolbarAction: widget.NewToolbarAction(icon, func() { onActivated(item) }), 75 | } 76 | } 77 | 78 | func (l *DynamicList) removeItem(item interface{}) { 79 | if l.confirmDelete { 80 | dialog.NewConfirm("Delete Item?", "Are you sure you want to delete this item?", func(ok bool) { 81 | if ok { 82 | l.removeFromList(item) 83 | } 84 | }, ui.Window).Show() 85 | } else { 86 | l.removeFromList(item) 87 | } 88 | } 89 | 90 | func (l *DynamicList) removeFromList(item interface{}) { 91 | for i, v := range l.Items { 92 | if item == v { 93 | l.Items = append(l.Items[:i], l.Items[i+1:]...) 94 | l.list.Objects = append(l.list.Objects[:i], l.list.Objects[i+1:]...) 95 | break 96 | } 97 | } 98 | } 99 | 100 | func (l *DynamicList) Clear() { 101 | l.Items = nil 102 | l.list.Objects = nil 103 | } 104 | -------------------------------------------------------------------------------- /ui/discover/filterButton.go: -------------------------------------------------------------------------------- 1 | package discover 2 | 3 | import ( 4 | "fyne.io/fyne/v2" 5 | "fyne.io/fyne/v2/widget" 6 | "github.com/kiamev/moogle-mod-manager/config" 7 | "github.com/kiamev/moogle-mod-manager/ui/state" 8 | ) 9 | 10 | type supportedKind string 11 | 12 | const ( 13 | unsupported supportedKind = "Unsupported" 14 | supported supportedKind = "Supported" 15 | all supportedKind = "All" 16 | ) 17 | 18 | var supportedKinds = []string{string(unsupported), string(supported), string(all)} 19 | 20 | var filters = findFilter{ 21 | supportedKind: supported, 22 | category: nil, 23 | } 24 | 25 | type findFilter struct { 26 | supportedKind supportedKind 27 | category *config.Category 28 | } 29 | 30 | /* 31 | func NewFilterButton(callback func(bool), window fyne.Window) *filterButton { 32 | b := &filterButton{ 33 | callback: callback, 34 | window: window, 35 | } 36 | b.Text = "Filters" 37 | return b 38 | } 39 | 40 | type filterButton struct { 41 | widget.Button 42 | callback func(bool) 43 | window fyne.Window 44 | } 45 | 46 | func (b *filterButton) Tapped(e *fyne.PointEvent) { 47 | options := make([]string, len(mods.Categories)+1) 48 | options[0] = "" 49 | for i, c := range mods.Categories { 50 | options[i+1] = c 51 | } 52 | category := widget.NewSelect(options, func(s string) { 53 | if s == "" { 54 | filters.category = nil 55 | } else { 56 | c := mods.Category(s) 57 | filters.category = &c 58 | } 59 | }) 60 | if filters.category != nil { 61 | category.SetSelected(string(*filters.category)) 62 | } else { 63 | category.SetSelected("") 64 | } 65 | 66 | include := widget.NewSelect(supportedKinds, func(s string) { 67 | filters.supportedKind = supportedKind(s) 68 | }) 69 | include.SetSelected(string(filters.supportedKind)) 70 | 71 | d := dialog.NewForm("Filters", "Apply", "Cancel", []*widget.FormItem{ 72 | widget.NewFormItem("Category", category), 73 | widget.NewFormItem("Show", include), 74 | }, b.callback, b.window) 75 | d.Resize(fyne.NewSize(300, 200)) 76 | d.Show() 77 | } 78 | */ 79 | 80 | func newCategoryFilter(onChange func()) fyne.CanvasObject { 81 | options := state.CurrentGame.CategoriesForSelect() 82 | category := widget.NewSelect(options, func(s string) { 83 | if s == "" { 84 | filters.category = nil 85 | } else { 86 | c := config.Category(s) 87 | filters.category = &c 88 | } 89 | onChange() 90 | }) 91 | if filters.category != nil { 92 | category.SetSelected(string(*filters.category)) 93 | } else { 94 | category.SetSelected("") 95 | } 96 | return category 97 | } 98 | 99 | func newIncludeFilter(onChange func()) fyne.CanvasObject { 100 | include := widget.NewSelect(supportedKinds, func(s string) { 101 | filters.supportedKind = supportedKind(s) 102 | onChange() 103 | }) 104 | include.SetSelected(string(filters.supportedKind)) 105 | return include 106 | } 107 | -------------------------------------------------------------------------------- /ui/mod-author/alwaysDownloadDef.go: -------------------------------------------------------------------------------- 1 | package mod_author 2 | 3 | import ( 4 | "fyne.io/fyne/v2" 5 | "fyne.io/fyne/v2/container" 6 | "fyne.io/fyne/v2/dialog" 7 | "fyne.io/fyne/v2/widget" 8 | "github.com/kiamev/moogle-mod-manager/config" 9 | "github.com/kiamev/moogle-mod-manager/mods" 10 | cw "github.com/kiamev/moogle-mod-manager/ui/custom-widgets" 11 | "github.com/kiamev/moogle-mod-manager/ui/mod-author/entry" 12 | "github.com/kiamev/moogle-mod-manager/ui/state/ui" 13 | ) 14 | 15 | type alwaysDownloadDef struct { 16 | entry.Manager 17 | list *cw.DynamicList 18 | downloadFilesDef *downloadFilesDef 19 | } 20 | 21 | func newAlwaysDownloadDef(downloads *downloads, installType *config.InstallType, gamesDef *gamesDef) *alwaysDownloadDef { 22 | d := &alwaysDownloadDef{ 23 | Manager: entry.NewManager(), 24 | downloadFilesDef: newDownloadFilesDef(downloads, installType, gamesDef), 25 | } 26 | d.list = cw.NewDynamicList(cw.Callbacks{ 27 | GetItemKey: d.getItemKey, 28 | GetItemFields: d.getItemFields, 29 | OnEditItem: d.onEditItem, 30 | }, true) 31 | return d 32 | } 33 | 34 | func (d *alwaysDownloadDef) compile() []*mods.DownloadFiles { 35 | dls := make([]*mods.DownloadFiles, len(d.list.Items)) 36 | for i, item := range d.list.Items { 37 | dls[i] = item.(*mods.DownloadFiles) 38 | } 39 | return dls 40 | } 41 | 42 | func (d *alwaysDownloadDef) getItemKey(item interface{}) string { 43 | dlf := item.(*mods.DownloadFiles) 44 | return dlf.DownloadName 45 | } 46 | 47 | func (d *alwaysDownloadDef) getItemFields(item interface{}) []string { 48 | return []string{} 49 | } 50 | 51 | func (d *alwaysDownloadDef) onEditItem(item interface{}) { 52 | d.createItem(item) 53 | } 54 | 55 | func (d *alwaysDownloadDef) createItem(item interface{}, done ...func(interface{})) { 56 | dlf := item.(*mods.DownloadFiles) 57 | d.downloadFilesDef.populate(dlf) 58 | 59 | fi, err := d.downloadFilesDef.getFormItems() 60 | if err != nil { 61 | dialog.ShowError(err, ui.Window) 62 | return 63 | } 64 | 65 | fd := dialog.NewForm("Edit Download Files", "Save", "Cancel", fi, 66 | func(ok bool) { 67 | if ok { 68 | result := d.downloadFilesDef.compile() 69 | *dlf = *result 70 | if len(done) > 0 { 71 | done[0](dlf) 72 | } 73 | d.list.Refresh() 74 | } 75 | }, ui.Window) 76 | fd.Resize(fyne.NewSize(400, 400)) 77 | fd.Show() 78 | } 79 | 80 | func (d *alwaysDownloadDef) draw() fyne.CanvasObject { 81 | return container.NewVBox( 82 | container.NewHBox( 83 | widget.NewLabelWithStyle("Always Download", fyne.TextAlignCenter, fyne.TextStyle{Bold: true}), 84 | widget.NewButton("Add", func() { 85 | d.createItem(&mods.DownloadFiles{}, func(result interface{}) { 86 | d.list.AddItem(result) 87 | }) 88 | })), 89 | d.list.Draw()) 90 | } 91 | 92 | func (d *alwaysDownloadDef) set(alwaysDownload []*mods.DownloadFiles) { 93 | d.list.Clear() 94 | for _, f := range alwaysDownload { 95 | d.list.AddItem(f) 96 | } 97 | } 98 | -------------------------------------------------------------------------------- /ui/mod-author/downloads.go: -------------------------------------------------------------------------------- 1 | package mod_author 2 | 3 | import ( 4 | "errors" 5 | "fyne.io/fyne/v2" 6 | "fyne.io/fyne/v2/container" 7 | "github.com/kiamev/moogle-mod-manager/mods" 8 | ) 9 | 10 | type ( 11 | dlHoster interface { 12 | clear() 13 | compile(mod *mods.Mod) error 14 | compileDownloads() ([]*mods.Download, error) 15 | draw() *container.TabItem 16 | set(*mods.Mod) 17 | } 18 | downloads struct { 19 | kinds *mods.Kinds 20 | dlHosters []dlHoster 21 | } 22 | ) 23 | 24 | func newDownloads(games *gamesDef, kinds *mods.Kinds) *downloads { 25 | return &downloads{ 26 | kinds: kinds, 27 | dlHosters: []dlHoster{ 28 | newDownloadsDef(kinds), 29 | newGithubDownloadsDef(kinds), 30 | newGoogleDriveDownloadsDef(kinds), 31 | newDownloadsRemoteDef(games, mods.Nexus), 32 | newDownloadsRemoteDef(games, mods.CurseForge), 33 | }, 34 | } 35 | } 36 | 37 | func (d *downloads) compileDownloads() (result []*mods.Download, err error) { 38 | var ( 39 | l = make(map[string]*mods.Download) 40 | dls []*mods.Download 41 | ) 42 | for _, h := range d.dlHosters { 43 | if dls, err = h.compileDownloads(); err != nil { 44 | return 45 | } 46 | if len(dls) > 0 && len(l) > 0 { 47 | if len(dls) != len(l) { 48 | err = errors.New("number of downloads must be equal") 49 | return 50 | } 51 | for _, dl := range dls { 52 | if _, ok := l[dl.Name]; !ok { 53 | err = errors.New("download names must be equal") 54 | return 55 | } 56 | } 57 | for k, _ := range l { 58 | for _, dl := range dls { 59 | found := false 60 | if dl.Name == k { 61 | found = true 62 | } 63 | if !found { 64 | err = errors.New("download names must be equal") 65 | return 66 | } 67 | } 68 | } 69 | } 70 | d.addDlToMap(&l, dls) 71 | } 72 | result = make([]*mods.Download, 0, len(l)) 73 | for _, dl := range l { 74 | result = append(result, dl) 75 | } 76 | return 77 | } 78 | 79 | func (d *downloads) addDlToMap(l *map[string]*mods.Download, dls []*mods.Download) { 80 | for _, dl := range dls { 81 | (*l)[dl.Name] = dl 82 | } 83 | } 84 | 85 | func (d *downloads) compile(mod *mods.Mod) (err error) { 86 | var dls []*mods.Download 87 | for _, h := range d.dlHosters { 88 | if err = h.compile(mod); err != nil { 89 | return 90 | } 91 | } 92 | if dls, err = d.compileDownloads(); err != nil { 93 | return 94 | } 95 | mod.Downloadables = dls 96 | return 97 | } 98 | 99 | func (d *downloads) set(mod *mods.Mod) { 100 | for _, h := range d.dlHosters { 101 | h.set(mod) 102 | } 103 | } 104 | 105 | func (d *downloads) clear() { 106 | for _, h := range d.dlHosters { 107 | h.clear() 108 | } 109 | } 110 | 111 | func (d *downloads) draw() fyne.CanvasObject { 112 | t := container.NewAppTabs() 113 | for _, h := range d.dlHosters { 114 | t.Append(h.draw()) 115 | } 116 | return t 117 | } 118 | -------------------------------------------------------------------------------- /browser/update.go: -------------------------------------------------------------------------------- 1 | package browser 2 | 3 | import ( 4 | "encoding/json" 5 | "fmt" 6 | "io" 7 | "net/http" 8 | "os/exec" 9 | "runtime" 10 | "strconv" 11 | "strings" 12 | ) 13 | 14 | const ( 15 | Version = "v1.2.1" 16 | 17 | tagUrl = `https://api.github.com/repos/KiameV/ffprModManager/tags` 18 | relUrl = `https://github.com/KiameV/ffprModManager/releases/%s` 19 | ) 20 | 21 | type ( 22 | comparer struct { 23 | major, minor, patch uint64 24 | pre int64 25 | } 26 | tag struct { 27 | Name string `json:"name"` 28 | } 29 | ) 30 | 31 | func newComparer(v string) *comparer { 32 | v = strings.TrimPrefix(v, "v") 33 | var ( 34 | c = &comparer{} 35 | sp = strings.Split(v, "_pre") 36 | err error 37 | ) 38 | if len(sp) > 1 { 39 | if c.pre, err = strconv.ParseInt(sp[1], 10, 64); err != nil { 40 | c.pre = -1 41 | } 42 | v = sp[0] 43 | } 44 | sp = strings.Split(v, ".") 45 | if c.major, err = strconv.ParseUint(sp[0], 10, 64); err != nil { 46 | return nil 47 | } 48 | if c.minor, err = strconv.ParseUint(sp[1], 10, 64); err != nil { 49 | return nil 50 | } 51 | if c.patch, err = strconv.ParseUint(sp[2], 10, 64); err != nil { 52 | return nil 53 | } 54 | return c 55 | } 56 | 57 | func (c comparer) isGreaterThan(cv *comparer) bool { 58 | if cv == nil { 59 | return true 60 | } 61 | if cv.pre != 0 && c.pre == 0 { 62 | return true 63 | } 64 | if c.pre != 0 { 65 | if c.major == cv.major && c.minor == cv.minor && c.patch == cv.patch { 66 | if cv.pre == 0 { 67 | return false 68 | } 69 | return c.pre > cv.pre 70 | } 71 | } 72 | return c.major > cv.major || 73 | c.minor > cv.minor || 74 | c.patch > cv.patch 75 | } 76 | 77 | func CheckForUpdate() (hasNewer bool, version string, err error) { 78 | var ( 79 | r *http.Response 80 | b []byte 81 | tags []tag 82 | highestVersion = newComparer(Version) 83 | ) 84 | if r, err = http.Get(tagUrl); err != nil { 85 | return 86 | } 87 | if b, err = io.ReadAll(r.Body); err != nil { 88 | return 89 | } 90 | if err = json.Unmarshal(b, &tags); err != nil { 91 | return 92 | } 93 | 94 | for _, t := range tags { 95 | if t.Name != Version && strings.Contains(t.Name, ".") { 96 | i := newComparer(t.Name) 97 | if !highestVersion.isGreaterThan(i) { 98 | hasNewer = true 99 | version = t.Name 100 | highestVersion = i 101 | } 102 | } 103 | } 104 | return 105 | } 106 | 107 | func Update(tag string) (err error) { 108 | url := fmt.Sprintf(relUrl, tag) 109 | switch runtime.GOOS { 110 | case "linux": 111 | err = exec.Command("xdg-open", url).Start() 112 | case "windows": 113 | err = exec.Command("rundll32", "url.dll,FileProtocolHandler", url).Start() 114 | case "darwin": 115 | err = exec.Command("open", url).Start() 116 | default: 117 | err = fmt.Errorf("unsupported platform") 118 | } 119 | return 120 | } 121 | -------------------------------------------------------------------------------- /mods/managed/gameModLookup.go: -------------------------------------------------------------------------------- 1 | package managed 2 | 3 | import ( 4 | "github.com/kiamev/moogle-mod-manager/config" 5 | "github.com/kiamev/moogle-mod-manager/mods" 6 | ) 7 | 8 | type ( 9 | gameModLookup interface { 10 | Has(game config.GameDef) bool 11 | HasMod(game config.GameDef, tm mods.TrackedMod) bool 12 | Len() int 13 | GetMod(game config.GameDef, tm mods.TrackedMod) (found mods.TrackedMod, ok bool) 14 | GetMods(game config.GameDef) (found []mods.TrackedMod) 15 | GetModByID(game config.GameDef, id mods.ModID) (mods.TrackedMod, bool) 16 | ModCount(game config.GameDef) int 17 | RemoveMod(game config.GameDef, tm mods.TrackedMod) 18 | Set(game config.GameDef) 19 | SetMod(game config.GameDef, tm mods.TrackedMod) 20 | } 21 | gameMods struct { 22 | GameMods map[string]*mods.ModLookupConc[*mods.TrackedModConc] `json:"Mods"` 23 | } 24 | ) 25 | 26 | func newGameModLookup() gameModLookup { 27 | return &gameMods{GameMods: make(map[string]*mods.ModLookupConc[*mods.TrackedModConc])} 28 | } 29 | 30 | func (gm *gameMods) Has(game config.GameDef) bool { 31 | _, ok := gm.GameMods[string(game.ID())] 32 | return ok 33 | } 34 | 35 | func (gm *gameMods) HasMod(game config.GameDef, tm mods.TrackedMod) bool { 36 | if l, ok := gm.GameMods[string(game.ID())]; ok { 37 | return l.Has(tm.(*mods.TrackedModConc)) 38 | } 39 | return false 40 | } 41 | 42 | func (gm *gameMods) Len() int { 43 | return len(gm.GameMods) 44 | } 45 | 46 | func (gm *gameMods) GetMod(game config.GameDef, tm mods.TrackedMod) (mods.TrackedMod, bool) { 47 | if l, found := gm.GameMods[string(game.ID())]; found { 48 | return l.Get(tm.(*mods.TrackedModConc)) 49 | } 50 | return nil, false 51 | } 52 | 53 | func (gm *gameMods) GetMods(game config.GameDef) (tms []mods.TrackedMod) { 54 | if l, found := gm.GameMods[string(game.ID())]; found { 55 | all := l.All() 56 | tms = make([]mods.TrackedMod, len(all)) 57 | for i, tm := range all { 58 | tms[i] = tm 59 | } 60 | } 61 | return 62 | } 63 | 64 | func (gm *gameMods) GetModByID(game config.GameDef, id mods.ModID) (tm mods.TrackedMod, ok bool) { 65 | if l, found := gm.GameMods[string(game.ID())]; found { 66 | return l.GetByID(id) 67 | } 68 | return 69 | } 70 | 71 | func (gm *gameMods) ModCount(game config.GameDef) int { 72 | if l, found := gm.GameMods[string(game.ID())]; found { 73 | return l.Len() 74 | } 75 | return 0 76 | } 77 | 78 | func (gm *gameMods) RemoveMod(game config.GameDef, tm mods.TrackedMod) { 79 | if l, found := gm.GameMods[string(game.ID())]; found { 80 | l.Remove(tm.(*mods.TrackedModConc)) 81 | } 82 | } 83 | 84 | func (gm *gameMods) Set(game config.GameDef) { 85 | if _, found := gm.GameMods[string(game.ID())]; !found { 86 | c := mods.NewModLookup[*mods.TrackedModConc]() 87 | gm.GameMods[string(game.ID())] = c.(*mods.ModLookupConc[*mods.TrackedModConc]) 88 | } 89 | } 90 | 91 | func (gm *gameMods) SetMod(game config.GameDef, tm mods.TrackedMod) { 92 | if l, found := gm.GameMods[string(game.ID())]; found { 93 | l.Set(tm.(*mods.TrackedModConc)) 94 | } 95 | } 96 | -------------------------------------------------------------------------------- /discover/discoverer.go: -------------------------------------------------------------------------------- 1 | package discover 2 | 3 | import ( 4 | "fmt" 5 | "github.com/kiamev/moogle-mod-manager/config" 6 | "github.com/kiamev/moogle-mod-manager/discover/remote" 7 | "github.com/kiamev/moogle-mod-manager/discover/repo" 8 | "github.com/kiamev/moogle-mod-manager/mods" 9 | "golang.org/x/sync/errgroup" 10 | ) 11 | 12 | type ( 13 | gameMods struct { 14 | lookup map[config.GameID]mods.ModLookup[*mods.Mod] 15 | } 16 | ) 17 | 18 | func (m gameMods) Get(game config.GameDef) (l mods.ModLookup[*mods.Mod], found bool) { 19 | l, found = m.lookup[game.ID()] 20 | return 21 | } 22 | 23 | func (m gameMods) Set(game config.GameDef, lookup mods.ModLookup[*mods.Mod]) { 24 | m.lookup[game.ID()] = lookup 25 | } 26 | 27 | var ( 28 | utilLookup = mods.NewModLookup[*mods.Mod]() 29 | gameModLookup = &gameMods{lookup: make(map[config.GameID]mods.ModLookup[*mods.Mod])} 30 | ) 31 | 32 | /* TODO REMOVE func GetMods(game config.GameDef) (found []*mods.Mod, lookup mods.ModLookup, err error) { 33 | if lookup, err = GetModsAsLookup(game); err != nil { 34 | return 35 | } 36 | 37 | found = make([]*mods.Mod, 0, len(lookup)) 38 | for _, m := range lookup { 39 | found = append(found, m) 40 | } 41 | return 42 | }*/ 43 | 44 | func GetModsAsLookup(game config.GameDef) (lookup mods.ModLookup[*mods.Mod], err error) { 45 | var ( 46 | remoteMods []*mods.Mod 47 | repoMods []*mods.Mod 48 | //found *mods.Mod 49 | eg errgroup.Group 50 | ok bool 51 | ) 52 | 53 | /* TODO is this cache needed? 54 | if game == nil { 55 | lookup = utilLookup 56 | ok = true 57 | } else { 58 | lookup, ok = gameModLookup.Get(game) 59 | } 60 | if lookup != nil && lookup.Len() > 0 && ok { 61 | return 62 | }*/ 63 | 64 | if game != nil { 65 | eg.Go(func() (e error) { 66 | remoteMods, e = remote.GetMods(game, false) 67 | return 68 | }) 69 | eg.Go(func() (e error) { 70 | repoMods, e = repo.NewGetter(repo.Read).GetMods(game, false) 71 | return 72 | }) 73 | if err = eg.Wait(); err != nil { 74 | return 75 | } 76 | } else { // utilities 77 | if repoMods, err = repo.NewGetter(repo.Read).GetUtilities(); err != nil { 78 | return 79 | } 80 | } 81 | 82 | lookup = mods.NewModLookup[*mods.Mod]() 83 | for _, m := range repoMods { 84 | if !lookup.Has(m) { 85 | lookup.Set(m) 86 | } 87 | } 88 | for _, m := range remoteMods { 89 | if _, ok = lookup.Get(m); !ok { 90 | lookup.Set(m) 91 | } /* else { 92 | found.Mod().Merge(*m) 93 | }*/ 94 | } 95 | lookup.RemoveConditionally(func(m *mods.Mod) bool { 96 | return m.Mod().Hide 97 | }) 98 | if game == nil { 99 | utilLookup = lookup 100 | } else { 101 | gameModLookup.Set(game, lookup) 102 | } 103 | return 104 | } 105 | 106 | func GetDisplayName(game config.GameDef, modID mods.ModID) (string, error) { 107 | lookup, err := GetModsAsLookup(game) 108 | if err != nil { 109 | return "", err 110 | } 111 | if mod, ok := lookup.GetByID(modID); ok { 112 | return string(mod.Mod().Name), nil 113 | } 114 | return "", fmt.Errorf("mod [%s] not found", modID) 115 | } 116 | -------------------------------------------------------------------------------- /discover/remote/util/modCompiler.go: -------------------------------------------------------------------------------- 1 | package util 2 | 3 | import ( 4 | "fmt" 5 | "github.com/kiamev/moogle-mod-manager/config" 6 | "github.com/kiamev/moogle-mod-manager/mods" 7 | "golang.org/x/sync/errgroup" 8 | "os" 9 | "path/filepath" 10 | "strings" 11 | "sync" 12 | "time" 13 | ) 14 | 15 | type Finder interface { 16 | GetNewestMods(game config.GameDef, lastID int) ([]*mods.Mod, error) 17 | GetFromID(game config.GameDef, id int) (found bool, mod *mods.Mod, err error) 18 | } 19 | 20 | type ModCompiler interface { 21 | AppendNewMods(folder string, game config.GameDef, ms []*mods.Mod) (result []*mods.Mod, err error) 22 | SetFinder(finder Finder) 23 | } 24 | 25 | type modCompiler struct { 26 | finder Finder 27 | kind mods.Kind 28 | } 29 | 30 | func NewModCompiler(kind mods.Kind) ModCompiler { 31 | return &modCompiler{kind: kind} 32 | } 33 | 34 | func (c *modCompiler) SetFinder(finder Finder) { 35 | c.finder = finder 36 | } 37 | 38 | func (c *modCompiler) AppendNewMods(folder string, game config.GameDef, ms []*mods.Mod) (result []*mods.Mod, err error) { 39 | var ( 40 | lastID = c.getLastModID(ms) 41 | nm []*mods.Mod 42 | file string 43 | wg = errgroup.Group{} 44 | mutex = &sync.Mutex{} 45 | count = 0 46 | ) 47 | if nm, err = c.finder.GetNewestMods(game, lastID); err != nil { 48 | return 49 | } 50 | if c.kind == mods.Nexus { 51 | newModsLastID := c.getLastModID(nm) 52 | result = ms 53 | for id := lastID; id < newModsLastID; id++ { 54 | file = filepath.Join(folder, fmt.Sprintf("%d", id), "mod.json") 55 | if _, err = os.Stat(file); err != nil { 56 | c.loadMod(file, game, id, &result, mutex, &wg) 57 | if count%10 == 0 { 58 | time.Sleep(10 * time.Millisecond) 59 | } else { 60 | time.Sleep(100 * time.Millisecond) 61 | } 62 | count++ 63 | } 64 | } 65 | err = wg.Wait() 66 | } else if c.kind == mods.CurseForge { 67 | for _, mod := range nm { 68 | id := strings.Split(string(mod.ModID), ".")[1] 69 | file = filepath.Join(folder, id, "mod.json") 70 | if _, err = os.Stat(file); err != nil { 71 | if err = mod.Save(file); err != nil { 72 | return 73 | } 74 | result = append(result, mod) 75 | } 76 | } 77 | } else { 78 | err = fmt.Errorf("invalid kind %v", c.kind) 79 | } 80 | return 81 | } 82 | 83 | func (c *modCompiler) getLastModID(ms []*mods.Mod) (lastID int) { 84 | var id int 85 | for _, m := range ms { 86 | if m.ModKind.Kinds.Is(c.kind) { 87 | if c.kind == mods.Nexus { 88 | id = int(*m.ModKind.NexusID) 89 | } else if c.kind == mods.CurseForge { 90 | id = int(*m.ModKind.CurseForgeID) 91 | } else { 92 | panic(fmt.Errorf("invalid kind %v", c.kind)) 93 | } 94 | 95 | if id > lastID { 96 | lastID = id 97 | } 98 | } 99 | } 100 | return 101 | } 102 | 103 | func (c *modCompiler) loadMod(file string, game config.GameDef, id int, result *[]*mods.Mod, mutex *sync.Mutex, wg *errgroup.Group) { 104 | wg.Go(func() error { 105 | found, mod, e := c.finder.GetFromID(game, id) 106 | if found && e == nil { 107 | if e = mod.Save(file); e != nil { 108 | return e 109 | } 110 | mutex.Lock() 111 | *result = append(*result, mod) 112 | mutex.Unlock() 113 | } 114 | return nil 115 | }) 116 | } 117 | -------------------------------------------------------------------------------- /mods/trackedMod.go: -------------------------------------------------------------------------------- 1 | package mods 2 | 3 | import ( 4 | "github.com/kiamev/moogle-mod-manager/config" 5 | "path/filepath" 6 | "strings" 7 | ) 8 | 9 | const moogleModName = "mod.moogle" 10 | 11 | type ( 12 | TrackedMod interface { 13 | ID() ModID 14 | Kinds() Kinds 15 | Mod() *Mod 16 | SetMod(m *Mod) 17 | Enable() 18 | Enabled() bool 19 | EnabledPtr() *bool 20 | Disable() 21 | Save() error 22 | DisplayName() string 23 | DisplayNamePtr() *string 24 | SetDisplayName(name string) 25 | UpdatedMod() *Mod 26 | UpdateModDef(m *Mod) 27 | SetUpdatedMod(m *Mod) 28 | MoogleModFile() string 29 | InstallType(game config.GameDef) config.InstallType 30 | } 31 | // TrackedModConc is public for serialization purposes 32 | TrackedModConc struct { 33 | IsEnabled bool `json:"Enabled"` 34 | MoogleModFile_ string `json:"MoogleModFile"` 35 | //Installed []*InstalledDownload `json:"Installed"` 36 | Mod_ *Mod `json:"-"` 37 | UpdatedMod_ *Mod `json:"-"` 38 | DisplayName_ string `json:"-"` 39 | } 40 | ) 41 | 42 | func (m *TrackedModConc) InstallType(game config.GameDef) config.InstallType { 43 | return m.Mod().InstallType(game) 44 | } 45 | 46 | func (m *TrackedModConc) DisplayNamePtr() *string { 47 | return &m.DisplayName_ 48 | } 49 | 50 | func (m *TrackedModConc) SetDisplayName(name string) { 51 | m.DisplayName_ = name 52 | } 53 | 54 | func (m *TrackedModConc) SetUpdatedMod(updatedMod *Mod) { 55 | m.UpdatedMod_ = updatedMod 56 | } 57 | 58 | func (m *TrackedModConc) MoogleModFile() string { 59 | return m.MoogleModFile_ 60 | } 61 | 62 | func (m *TrackedModConc) UpdatedMod() *Mod { 63 | return m.UpdatedMod_ 64 | } 65 | 66 | func (m *TrackedModConc) UpdateModDef(mod *Mod) { 67 | m.Mod_.ModDef = mod.ModDef 68 | _ = m.Save() 69 | } 70 | 71 | func (m *TrackedModConc) DisplayName() string { 72 | return m.DisplayName_ 73 | } 74 | 75 | func (m *TrackedModConc) Enable() { 76 | m.IsEnabled = true 77 | } 78 | 79 | func (m *TrackedModConc) EnabledPtr() *bool { 80 | return &m.IsEnabled 81 | } 82 | 83 | func (m *TrackedModConc) Disable() { 84 | m.IsEnabled = false 85 | } 86 | 87 | func NewTrackerMod(mod *Mod, game config.GameDef) TrackedMod { 88 | tm := &TrackedModConc{ 89 | IsEnabled: false, 90 | Mod_: mod, 91 | } 92 | tm.MoogleModFile_ = filepath.Join(config.Get().GetModsFullPath(game), tm.ID().AsDir(), moogleModName) 93 | return tm 94 | } 95 | 96 | func (id ModID) AsDir() string { 97 | return strings.ReplaceAll(string(id), ".", "_") 98 | } 99 | 100 | func (m *TrackedModConc) ID() ModID { 101 | return m.Mod_.ID() 102 | } 103 | 104 | func (m *TrackedModConc) Kinds() Kinds { 105 | return m.Mod_.Kinds() 106 | } 107 | 108 | func (m *TrackedModConc) Mod() *Mod { 109 | return m.Mod_ 110 | } 111 | 112 | func (m *TrackedModConc) SetMod(mod *Mod) { 113 | m.Mod_ = mod 114 | } 115 | 116 | func (m *TrackedModConc) Enabled() bool { 117 | return m.IsEnabled 118 | } 119 | 120 | func (m *TrackedModConc) Toggle() bool { 121 | m.IsEnabled = !m.IsEnabled 122 | return m.IsEnabled 123 | } 124 | 125 | func (m *TrackedModConc) Save() error { 126 | return m.Mod_.Save(m.MoogleModFile_) 127 | } 128 | 129 | type InstalledDownload struct { 130 | Name string `json:"Name"` 131 | Version string `json:"Version"` 132 | } 133 | 134 | func NewInstalledDownload(name, version string) *InstalledDownload { 135 | return &InstalledDownload{ 136 | Name: name, 137 | Version: version, 138 | } 139 | } 140 | -------------------------------------------------------------------------------- /ui/state/state.go: -------------------------------------------------------------------------------- 1 | package state 2 | 3 | import ( 4 | "fyne.io/fyne/v2" 5 | "fyne.io/fyne/v2/data/binding" 6 | "fyne.io/fyne/v2/dialog" 7 | "github.com/kiamev/moogle-mod-manager/config" 8 | "github.com/kiamev/moogle-mod-manager/ui/state/gui" 9 | "github.com/kiamev/moogle-mod-manager/ui/state/ui" 10 | "github.com/kiamev/moogle-mod-manager/ui/util/working" 11 | ) 12 | 13 | type GUI byte 14 | 15 | const ( 16 | None GUI = iota 17 | LocalMods 18 | DiscoverMods 19 | ModAuthor 20 | ConfigInstaller 21 | ) 22 | 23 | var ( 24 | CurrentGame config.GameDef 25 | guiHistories []*guiHistory 26 | mainMenu Screen 27 | screens = make(map[GUI]Screen) 28 | baseDir = binding.NewString() 29 | ) 30 | 31 | type guiHistory struct { 32 | gui GUI 33 | baseDir string 34 | } 35 | 36 | func appendGuiHistory(gui GUI) { 37 | guiHistories = append(guiHistories, &guiHistory{ 38 | gui: gui, 39 | baseDir: GetBaseDir(), 40 | }) 41 | SetBaseDir("") 42 | } 43 | 44 | type Screen interface { 45 | PreDraw(w fyne.Window, args ...interface{}) error 46 | Draw(w fyne.Window) 47 | DrawAsDialog(window fyne.Window) 48 | OnClose() 49 | } 50 | 51 | func GetCurrentGUI() GUI { 52 | if len(guiHistories) > 0 { 53 | return guiHistories[len(guiHistories)-1].gui 54 | } 55 | return None 56 | } 57 | 58 | func GetScreen(gui GUI) Screen { 59 | return screens[gui] 60 | } 61 | 62 | func ShowScreen(g GUI, args ...interface{}) { 63 | defer working.HideDialog() 64 | working.ShowDialog() 65 | 66 | if g == DiscoverMods { 67 | if ui.PopupWindow == nil { 68 | ui.PopupWindow = ui.App.NewWindow("Finder") 69 | ui.PopupWindow.Resize(config.Get().Size()) 70 | ui.PopupWindow.SetOnClosed(func() { ui.PopupWindow = nil }) 71 | if err := screens[g].PreDraw(ui.PopupWindow, args); err != nil { 72 | dialog.ShowError(err, ui.Window) 73 | return 74 | } 75 | ui.PopupWindow.Show() 76 | ui.ShowingPopup = true 77 | screens[g].DrawAsDialog(ui.PopupWindow) 78 | } 79 | return 80 | } else { 81 | if err := screens[g].PreDraw(ui.Window, args); err != nil { 82 | dialog.ShowError(err, ui.Window) 83 | return 84 | } 85 | } 86 | appendGuiHistory(g) 87 | mainMenu.Draw(ui.Window) 88 | screens[g].Draw(ui.Window) 89 | gui.Current.Set(int(g)) 90 | } 91 | 92 | func ClosePopupWindow() { 93 | ui.PopupWindow.Close() 94 | ui.ShowingPopup = false 95 | } 96 | 97 | func ShowPreviousScreen() { 98 | var s Screen 99 | if len(guiHistories) > 1 { 100 | guiHistories = guiHistories[:len(guiHistories)-1] 101 | h := guiHistories[len(guiHistories)-1] 102 | s = screens[h.gui] 103 | SetBaseDir(h.baseDir) 104 | gui.Current.Set(int(h.gui)) 105 | } else { 106 | s = screens[None] 107 | SetBaseDir("") 108 | gui.Current.Set(int(None)) 109 | } 110 | ui.Window.MainMenu().Refresh() 111 | mainMenu.Draw(ui.Window) 112 | s.Draw(ui.Window) 113 | } 114 | 115 | func UpdateCurrentScreen() { 116 | s := screens[GetCurrentGUI()] 117 | s.Draw(ui.Window) 118 | gui.Current.Set(int(GetCurrentGUI())) 119 | } 120 | 121 | func RegisterScreen(g GUI, screen Screen) { 122 | screens[g] = screen 123 | gui.Current.Set(int(g)) 124 | } 125 | 126 | func RegisterMainMenu(m Screen) { 127 | mainMenu = m 128 | } 129 | 130 | func RefreshMenu() { 131 | ui.Window.MainMenu().Refresh() 132 | } 133 | 134 | func GetBaseDir() string { 135 | s, _ := baseDir.Get() 136 | return s 137 | } 138 | 139 | func GetBaseDirBinding() binding.String { 140 | return baseDir 141 | } 142 | 143 | func SetBaseDir(dir string) { 144 | _ = baseDir.Set(dir) 145 | } 146 | -------------------------------------------------------------------------------- /mods/managed/updates.go: -------------------------------------------------------------------------------- 1 | package managed 2 | 3 | import ( 4 | "errors" 5 | "fmt" 6 | "github.com/carwale/golibraries/workerpool" 7 | "github.com/kiamev/moogle-mod-manager/config" 8 | "github.com/kiamev/moogle-mod-manager/discover/remote" 9 | "github.com/kiamev/moogle-mod-manager/discover/repo" 10 | "github.com/kiamev/moogle-mod-manager/mods" 11 | "github.com/kiamev/moogle-mod-manager/ui/util" 12 | "strings" 13 | "sync" 14 | ) 15 | 16 | func CheckForUpdates(game config.GameDef, result func(err error)) { 17 | var ( 18 | dispatcher = workerpool.NewDispatcher( 19 | fmt.Sprintf("Checker%d", game), 20 | workerpool.SetMaxWorkers(4)) 21 | wg = sync.WaitGroup{} 22 | ucs []updateChecker 23 | ) 24 | 25 | if err := repo.NewGetter(repo.Read).Pull(); err != nil { 26 | result(err) 27 | return 28 | } 29 | 30 | for _, tm := range lookup.GetMods(game) { 31 | k := tm.Kinds() 32 | if k.IsHosted() { 33 | wg.Add(1) 34 | h := &hostedUpdateChecker{tm: tm, wg: &wg} 35 | ucs = append(ucs, h) 36 | dispatcher.JobQueue <- h 37 | } 38 | if k.Is(mods.Nexus) { 39 | wg.Add(1) 40 | n := &remoteUpdateChecker{tm: tm, wg: &wg, client: remote.NewNexusClient()} 41 | ucs = append(ucs, n) 42 | dispatcher.JobQueue <- n 43 | } 44 | if k.Is(mods.CurseForge) { 45 | wg.Add(1) 46 | n := &remoteUpdateChecker{tm: tm, wg: &wg, client: remote.NewCurseForgeClient()} 47 | ucs = append(ucs, n) 48 | dispatcher.JobQueue <- n 49 | } 50 | } 51 | wg.Wait() 52 | for _, uc := range ucs { 53 | if uc.getError() != nil { 54 | result(uc.getError()) 55 | return 56 | } 57 | } 58 | result(nil) 59 | } 60 | 61 | type updateChecker interface { 62 | getError() error 63 | } 64 | 65 | type hostedUpdateChecker struct { 66 | tm mods.TrackedMod 67 | wg *sync.WaitGroup 68 | err error 69 | } 70 | 71 | func (c *hostedUpdateChecker) Process() error { 72 | defer c.wg.Done() 73 | 74 | remoteMod, err := repo.NewGetter(repo.Read).GetMod(c.tm.Mod()) 75 | if err != nil { 76 | util.ShowErrorLong(err) 77 | return nil 78 | } 79 | 80 | if remoteMod.ID() != c.tm.ID() { 81 | util.ShowErrorLong(errors.New("Could not download remote version for " + c.tm.DisplayName())) 82 | return nil 83 | } 84 | if isVersionNewer(c.tm.Mod().Version, remoteMod.Version) { 85 | markForUpdate(c.tm, remoteMod) 86 | } 87 | return nil 88 | } 89 | 90 | func (c *hostedUpdateChecker) getError() error { 91 | return c.err 92 | } 93 | 94 | type remoteUpdateChecker struct { 95 | tm mods.TrackedMod 96 | wg *sync.WaitGroup 97 | client remote.Client 98 | err error 99 | } 100 | 101 | func (c *remoteUpdateChecker) Process() error { 102 | defer c.wg.Done() 103 | found, mod, err := c.client.GetFromMod(c.tm.Mod()) 104 | if err != nil { 105 | c.err = err 106 | return nil 107 | } 108 | if found && mod != nil { 109 | if isVersionNewer(mod.Version, c.tm.Mod().Version) { 110 | markForUpdate(c.tm, mod) 111 | } 112 | } 113 | return nil 114 | } 115 | 116 | func (c *remoteUpdateChecker) getError() error { 117 | return c.err 118 | } 119 | 120 | func isVersionNewer(new string, old string) bool { 121 | if new == old { 122 | return false 123 | } 124 | newSl := strings.Split(new, ".") 125 | oldSl := strings.Split(old, ".") 126 | for i := 0; i < len(newSl) && i < len(oldSl); i++ { 127 | if newSl[i] > oldSl[i] { 128 | return true 129 | } 130 | } 131 | return len(newSl) > len(oldSl) 132 | } 133 | 134 | func markForUpdate(tm mods.TrackedMod, mod *mods.Mod) { 135 | tm.SetUpdatedMod(mods.NewModForVersion(tm.Mod(), mod)) 136 | } 137 | -------------------------------------------------------------------------------- /ui/mod-author/files.go: -------------------------------------------------------------------------------- 1 | package mod_author 2 | 3 | import ( 4 | "fmt" 5 | "fyne.io/fyne/v2" 6 | "fyne.io/fyne/v2/container" 7 | "fyne.io/fyne/v2/dialog" 8 | "fyne.io/fyne/v2/widget" 9 | "github.com/kiamev/moogle-mod-manager/config" 10 | "github.com/kiamev/moogle-mod-manager/mods" 11 | cw "github.com/kiamev/moogle-mod-manager/ui/custom-widgets" 12 | "github.com/kiamev/moogle-mod-manager/ui/mod-author/entry" 13 | "github.com/kiamev/moogle-mod-manager/ui/state" 14 | "github.com/kiamev/moogle-mod-manager/ui/state/ui" 15 | ) 16 | 17 | type filesDef struct { 18 | entry.Manager 19 | list *cw.DynamicList 20 | installType *config.InstallType 21 | gamesDef *gamesDef 22 | } 23 | 24 | func newFilesDef(installType *config.InstallType, gamesDef *gamesDef) *filesDef { 25 | d := &filesDef{ 26 | Manager: entry.NewManager(), 27 | installType: installType, 28 | gamesDef: gamesDef, 29 | } 30 | d.list = cw.NewDynamicList(cw.Callbacks{ 31 | GetItemKey: d.getItemKey, 32 | GetItemFields: d.getItemFields, 33 | OnEditItem: d.onEditItem, 34 | }, false) 35 | return d 36 | } 37 | 38 | func (d *filesDef) compile() []*mods.ModFile { 39 | dl := make([]*mods.ModFile, len(d.list.Items)) 40 | for i, item := range d.list.Items { 41 | dl[i] = item.(*mods.ModFile) 42 | } 43 | return dl 44 | } 45 | 46 | func (d *filesDef) getItemKey(item interface{}) string { 47 | f := item.(*mods.ModFile) 48 | return fmt.Sprintf("%s -> %s", f.From, f.To) 49 | } 50 | 51 | func (d *filesDef) getItemFields(item interface{}) []string { 52 | f := item.(*mods.ModFile) 53 | return []string{ 54 | f.From, 55 | f.To, 56 | } 57 | } 58 | 59 | func (d *filesDef) onEditItem(item interface{}) { 60 | d.createItem(item) 61 | } 62 | 63 | func (d *filesDef) createItem(item interface{}, done ...func(interface{})) { 64 | f := item.(*mods.ModFile) 65 | entry.CreateFileDialog(d, "From", f.From, state.GetBaseDirBinding(), false, true) 66 | entry.NewEntry[string](d, entry.KindString, d.gamesDef.AuthorHintDir(), f.To) 67 | s := "" 68 | if f.ToArchive != nil { 69 | s = *f.ToArchive 70 | } 71 | entry.NewEntry[string](d, entry.KindString, "To Archive", s) 72 | 73 | items := []*widget.FormItem{ 74 | entry.GetFileDialog(d, "From"), 75 | entry.FormItem[string](d, d.gamesDef.AuthorHintDir()), 76 | } 77 | 78 | if d.installType.Is(config.MoveToArchive) { 79 | items = append(items, entry.FormItem[string](d, "To Archive")) 80 | } 81 | 82 | fd := dialog.NewForm("Edit File Copy", "Save", "Cancel", items, 83 | func(ok bool) { 84 | if ok { 85 | f.From = cleanPath(entry.DialogValue(d, "From")) 86 | f.To = cleanPath(entry.Value[string](d, d.gamesDef.AuthorHintDir())) 87 | if s = entry.Value[string](d, "To Archive"); s == "" { 88 | f.ToArchive = nil 89 | } else { 90 | f.ToArchive = &s 91 | } 92 | if len(done) > 0 { 93 | done[0](f) 94 | } 95 | d.list.Refresh() 96 | } 97 | }, ui.Window) 98 | fd.Resize(fyne.NewSize(600, 400)) 99 | fd.Show() 100 | } 101 | 102 | func (d *filesDef) draw(label bool) fyne.CanvasObject { 103 | c := container.NewHBox() 104 | if label { 105 | c.Add(widget.NewLabelWithStyle("Files", fyne.TextAlignCenter, fyne.TextStyle{Bold: true})) 106 | } 107 | c.Add(widget.NewButton("Add", func() { 108 | d.createItem(&mods.ModFile{}, func(result interface{}) { 109 | d.list.AddItem(result) 110 | }) 111 | })) 112 | return container.NewVBox( 113 | c, 114 | d.list.Draw()) 115 | } 116 | 117 | func (d *filesDef) clear() { 118 | d.list.Clear() 119 | } 120 | 121 | func (d *filesDef) populate(files []*mods.ModFile) { 122 | d.list.Clear() 123 | for _, f := range files { 124 | d.list.AddItem(f) 125 | } 126 | } 127 | -------------------------------------------------------------------------------- /discover/remote/nexus/mod.go: -------------------------------------------------------------------------------- 1 | package nexus 2 | 3 | import ( 4 | "github.com/kiamev/moogle-mod-manager/config" 5 | "github.com/kiamev/moogle-mod-manager/mods" 6 | "time" 7 | ) 8 | 9 | type ( 10 | nexusMod struct { 11 | ModID int `json:"mod_id"` 12 | Name string `json:"name"` 13 | Summary string `json:"summary"` 14 | Description string `json:"description"` 15 | PictureUrl string `json:"picture_url"` 16 | CreatedTime time.Time `json:"created_time"` 17 | UpdatedTime time.Time `json:"updated_time"` 18 | Version string `json:"version"` 19 | GamePath config.NexusPath `json:"domain_name"` 20 | CategoryID int `json:"category_id"` 21 | Author string `json:"author"` 22 | AuthorLink string `json:"author_link"` 23 | HasAdultContent bool `json:"contains_adult_content"` 24 | Available bool `json:"available"` 25 | Link string `json:"-"` 26 | } 27 | NexusFile struct { 28 | FileID int `json:"file_id"` 29 | Name string `json:"name"` 30 | Version string `json:"version"` 31 | IsPrimary bool `json:"is_primary"` 32 | FileName string `json:"file_name"` 33 | ModVersion string `json:"mod_version"` 34 | Description string `json:"description"` 35 | } 36 | fileParent struct { 37 | Files []NexusFile `json:"files"` 38 | } 39 | ) 40 | 41 | func (f NexusFile) ToDownload() *mods.Download { 42 | return &mods.Download{ 43 | Name: f.Name, 44 | Version: f.Version, 45 | Nexus: &mods.NexusDownloadable{ 46 | FileID: f.FileID, 47 | FileName: f.FileName, 48 | }, 49 | } 50 | } 51 | 52 | func (p fileParent) ToDownloads() []*mods.Download { 53 | result := make([]*mods.Download, len(p.Files)) 54 | for i, f := range p.Files { 55 | result[i] = f.ToDownload() 56 | } 57 | return result 58 | } 59 | 60 | /* 61 | { 62 | "files":[ 63 | { 64 | "id":[ 65 | 232, 66 | 4335 67 | ], 68 | "uid":18618683228392, 69 | "file_id":232, 70 | "name":"FFVIPR_SNES_Battle_Backgrounds_metalliguy", 71 | "version":"1.1", 72 | "category_id":1, 73 | "category_name":"MAIN", 74 | "is_primary":false, 75 | "size":22333, 76 | "file_name":"FFVIPR_SNES_Battle_Backgrounds_metalliguy-48-1-1-1657022362.rar", 77 | "uploaded_timestamp":1657022362, 78 | "uploaded_time":"2022-07-05T11:59:22.000+00:00", 79 | "mod_version":"1.1", 80 | "external_virus_scan_url":"https://www.virustotal.com/gui/file/b396a748611f317853f0b89da7ad4398216d943f73ff19739206e72430d1a02f/detection/f-b396a748611f317853f0b89da7ad4398216d943f73ff19739206e72430d1a02f-1657022438", 81 | "description":"Updated to support patch 1.0.6", 82 | "size_kb":22333, 83 | "size_in_bytes":22868776, 84 | "changelog_html":"Updated to support patch 1.0.6", 85 | "content_preview_link":"https://file-metadata.nexusmods.com/file/nexus-files-s3-meta/4335/48/FFVIPR_SNES_Battle_Backgrounds_metalliguy-48-1-1-1657022362.rar.json" 86 | } 87 | ], 88 | "file_updates":[ 89 | { 90 | "old_file_id":168, 91 | "new_file_id":232, 92 | "old_file_name":"FFVIPR_SNES_Battle_Backgrounds_metalliguy-48-1-0-1651437383.rar", 93 | "new_file_name":"FFVIPR_SNES_Battle_Backgrounds_metalliguy-48-1-1-1657022362.rar", 94 | "uploaded_timestamp":1657022362, 95 | "uploaded_time":"2022-07-05T11:59:22.000+00:00" 96 | } 97 | ] 98 | } ], 99 | } 100 | */ 101 | -------------------------------------------------------------------------------- /actions/steps/extracted.go: -------------------------------------------------------------------------------- 1 | package steps 2 | 3 | import ( 4 | "fmt" 5 | "io/fs" 6 | "path/filepath" 7 | "strings" 8 | 9 | "github.com/kiamev/moogle-mod-manager/archive" 10 | "github.com/kiamev/moogle-mod-manager/config" 11 | "github.com/kiamev/moogle-mod-manager/mods" 12 | ) 13 | 14 | type ( 15 | Extracted struct { 16 | ToInstall *mods.ToInstall 17 | Files []archive.ExtractedFile 18 | filesToInstall []*FileToInstall 19 | } 20 | FileToInstall struct { 21 | Relative string 22 | AbsoluteFrom string 23 | AbsoluteTo string 24 | Skip bool 25 | archive *string 26 | } 27 | ) 28 | 29 | func newFileToInstallFromFile(relToExtracted map[string]archive.ExtractedFile, f *mods.ModFile, installDir string, archive *string) (*FileToInstall, error) { 30 | af, found := relToExtracted[f.From] 31 | if !found { 32 | return nil, fmt.Errorf("file %v not found in extracted files", f) 33 | } 34 | return &FileToInstall{ 35 | Relative: f.To, 36 | AbsoluteFrom: af.From, 37 | AbsoluteTo: filepath.Join(installDir, f.To), 38 | Skip: false, 39 | archive: archive, 40 | }, nil 41 | } 42 | 43 | func newFileToInstallFromDir(relToExtracted map[string]archive.ExtractedFile, rel string, d *mods.ModDir, installDir string, archive *string) (*FileToInstall, error) { 44 | var ( 45 | af, found = relToExtracted[rel] 46 | toRel = rel 47 | ) 48 | if !found { 49 | return nil, fmt.Errorf("dir %v not found in extracted files", d.From) 50 | } 51 | if d.From != "." { 52 | toRel = strings.TrimPrefix(rel, d.From) 53 | } 54 | return &FileToInstall{ 55 | Relative: af.Relative, 56 | AbsoluteFrom: af.From, 57 | AbsoluteTo: filepath.Join(installDir, d.To, toRel), 58 | Skip: false, 59 | archive: archive, 60 | }, nil 61 | } 62 | 63 | func (e *Extracted) FilesToInstall() []*FileToInstall { 64 | return e.filesToInstall 65 | } 66 | 67 | func (e *Extracted) Compile(game config.GameDef, extractedDir string) (err error) { 68 | if len(e.filesToInstall) > 0 { 69 | return 70 | } 71 | 72 | var ( 73 | fromToExtracted = make(map[string]archive.ExtractedFile) 74 | installDir string 75 | fti *FileToInstall 76 | rel string 77 | ) 78 | if installDir, err = config.Get().GetDir(game, config.GameDirKind); err != nil { 79 | return 80 | } 81 | 82 | for _, f := range e.Files { 83 | fromToExtracted[strings.ReplaceAll(f.Relative, "\\", "/")] = f 84 | } 85 | for _, df := range e.ToInstall.DownloadFiles { 86 | for _, f := range df.Files { 87 | if fti, err = newFileToInstallFromFile(fromToExtracted, f, installDir, f.ToArchive); err != nil { 88 | return 89 | } 90 | e.filesToInstall = append(e.filesToInstall, fti) 91 | /*ex, found := fromToExtracted[f.From] 92 | if !found { 93 | return nil, fmt.Errorf("file %v not found", f.From) 94 | } 95 | if f.From == f.To { 96 | result = append(result, ex.Relative) 97 | } else { 98 | // Add root directory 99 | filepath.Base(ex.File) 100 | result = append(result, filepath.Join(f.To, filepath.Base(ex.File))) 101 | }*/ 102 | } 103 | for _, d := range df.Dirs { 104 | if err = filepath.WalkDir(filepath.Join(extractedDir, d.From), func(path string, de fs.DirEntry, err error) error { 105 | if err != nil { 106 | return err 107 | } 108 | if de.IsDir() { 109 | return nil 110 | } 111 | path = filepath.ToSlash(path) 112 | if rel, err = filepath.Rel(extractedDir, path); err != nil { 113 | return err 114 | } 115 | rel = strings.ReplaceAll(rel, "\\", "/") 116 | if fti, err = newFileToInstallFromDir(fromToExtracted, rel, d, installDir, d.ToArchive); err != nil { 117 | return err 118 | } 119 | e.filesToInstall = append(e.filesToInstall, fti) 120 | return nil 121 | }); err != nil { 122 | return 123 | } 124 | } 125 | } 126 | return 127 | } 128 | -------------------------------------------------------------------------------- /ui/mod-author/downloadsGitHub.go: -------------------------------------------------------------------------------- 1 | package mod_author 2 | 3 | import ( 4 | "fyne.io/fyne/v2" 5 | "fyne.io/fyne/v2/container" 6 | "fyne.io/fyne/v2/widget" 7 | "github.com/kiamev/moogle-mod-manager/discover/remote/github" 8 | "github.com/kiamev/moogle-mod-manager/mods" 9 | "github.com/kiamev/moogle-mod-manager/ui/mod-author/entry" 10 | "github.com/kiamev/moogle-mod-manager/ui/util" 11 | "strings" 12 | ) 13 | 14 | type githubDownloadsDef struct { 15 | entry.Manager 16 | dlList *fyne.Container 17 | kinds *mods.Kinds 18 | } 19 | 20 | func newGithubDownloadsDef(kinds *mods.Kinds) dlHoster { 21 | return &githubDownloadsDef{ 22 | Manager: entry.NewManager(), 23 | kinds: kinds, 24 | dlList: container.NewVBox(), 25 | } 26 | } 27 | 28 | func (d *githubDownloadsDef) version() (string, error) { 29 | return github.LatestRelease(entry.Value[string](d, "owner"), entry.Value[string](d, "repo")) 30 | } 31 | 32 | func (d *githubDownloadsDef) compile(mod *mods.Mod) error { 33 | gh, err := d.compileGH() 34 | if err != nil { 35 | return err 36 | } 37 | if gh.Repo != "" && gh.Owner != "" && gh.Version != "" { 38 | mod.ModKind.Kinds.Add(mods.HostedGitHub) 39 | mod.ModKind.GitHub = gh 40 | } 41 | return nil 42 | } 43 | 44 | func (d *githubDownloadsDef) compileGH() (*mods.GitHub, error) { 45 | var ( 46 | v, err = d.version() 47 | gh = &mods.GitHub{ 48 | Owner: entry.Value[string](d, "owner"), 49 | Repo: entry.Value[string](d, "repo"), 50 | Version: v, 51 | } 52 | ) 53 | if err != nil { 54 | return nil, err 55 | } 56 | return gh, nil 57 | } 58 | 59 | func (d *githubDownloadsDef) compileDownloads() (result []*mods.Download, err error) { 60 | var ( 61 | dls []github.Download 62 | version string 63 | ) 64 | if dls, err = github.ListDownloads(entry.Value[string](d, "owner"), entry.Value[string](d, "repo"), version); err != nil { 65 | return 66 | } 67 | if version, err = d.version(); err != nil { 68 | return 69 | } 70 | result = make([]*mods.Download, len(dls)) 71 | for i, dl := range dls { 72 | name := dl.Name 73 | if j := strings.LastIndex(name, "."); j != -1 { 74 | name = name[:j] 75 | } 76 | result[i] = &mods.Download{ 77 | Name: name, 78 | Version: version, 79 | Hosted: &mods.HostedDownloadable{ 80 | Sources: []string{dl.URL}, 81 | }, 82 | } 83 | } 84 | return 85 | } 86 | 87 | func (d *githubDownloadsDef) draw() *container.TabItem { 88 | return container.NewTabItem( 89 | "GitHub", 90 | container.NewVBox( 91 | widget.NewForm( 92 | entry.FormItem[string](d, "owner"), 93 | entry.FormItem[string](d, "repo")), 94 | container.NewHBox(widget.NewButton("Load Downloadables", func() { 95 | if gh, err := d.compileGH(); err != nil { 96 | util.ShowErrorLong(err) 97 | return 98 | } else { 99 | d.setGH(gh) 100 | } 101 | })), 102 | )) 103 | } 104 | 105 | func (d *githubDownloadsDef) set(mod *mods.Mod) { 106 | d.setGH(mod.ModKind.GitHub) 107 | } 108 | 109 | func (d *githubDownloadsDef) setGH(gh *mods.GitHub) { 110 | if gh == nil { 111 | d.clear() 112 | } else { 113 | entry.NewEntry[string](d, entry.KindString, "owner", gh.Owner) 114 | entry.NewEntry[string](d, entry.KindString, "repo", gh.Repo) 115 | dls, err := d.compileDownloads() 116 | if err != nil { 117 | return 118 | } 119 | d.dlList.Objects = nil 120 | if len(dls) > 0 { 121 | d.kinds.Add(mods.HostedGitHub) 122 | for _, dl := range dls { 123 | d.dlList.Add(widget.NewLabel("- " + dl.Name)) 124 | } 125 | } 126 | } 127 | } 128 | 129 | func (d *githubDownloadsDef) getFormItems() []*widget.FormItem { 130 | return []*widget.FormItem{ 131 | entry.FormItem[string](d, "owner"), 132 | entry.FormItem[string](d, "repo"), 133 | } 134 | } 135 | 136 | func (d *githubDownloadsDef) clear() { 137 | entry.NewEntry[string](d, entry.KindString, "owner", "") 138 | entry.NewEntry[string](d, entry.KindString, "repo", "") 139 | d.kinds.Remove(mods.HostedGitHub) 140 | } 141 | -------------------------------------------------------------------------------- /ui/mod-author/dirs.go: -------------------------------------------------------------------------------- 1 | package mod_author 2 | 3 | import ( 4 | "fmt" 5 | "strings" 6 | 7 | "fyne.io/fyne/v2" 8 | "fyne.io/fyne/v2/container" 9 | "fyne.io/fyne/v2/dialog" 10 | "fyne.io/fyne/v2/widget" 11 | "github.com/kiamev/moogle-mod-manager/config" 12 | "github.com/kiamev/moogle-mod-manager/mods" 13 | cw "github.com/kiamev/moogle-mod-manager/ui/custom-widgets" 14 | "github.com/kiamev/moogle-mod-manager/ui/mod-author/entry" 15 | "github.com/kiamev/moogle-mod-manager/ui/state" 16 | "github.com/kiamev/moogle-mod-manager/ui/state/ui" 17 | ) 18 | 19 | type dirsDef struct { 20 | entry.Manager 21 | list *cw.DynamicList 22 | installType *config.InstallType 23 | gamesDef *gamesDef 24 | } 25 | 26 | func newDirsDef(installType *config.InstallType, gamesDef *gamesDef) *dirsDef { 27 | d := &dirsDef{ 28 | Manager: entry.NewManager(), 29 | installType: installType, 30 | gamesDef: gamesDef, 31 | } 32 | d.list = cw.NewDynamicList(cw.Callbacks{ 33 | GetItemKey: d.getItemKey, 34 | GetItemFields: d.getItemFields, 35 | OnEditItem: d.onEditItem, 36 | }, false) 37 | return d 38 | } 39 | 40 | func (d *dirsDef) compile() []*mods.ModDir { 41 | dl := make([]*mods.ModDir, len(d.list.Items)) 42 | for i, item := range d.list.Items { 43 | dl[i] = item.(*mods.ModDir) 44 | } 45 | return dl 46 | } 47 | 48 | func (d *dirsDef) getItemKey(item interface{}) string { 49 | f := item.(*mods.ModDir) 50 | return fmt.Sprintf("%s -> %s", f.From, f.To) 51 | } 52 | 53 | func (d *dirsDef) getItemFields(item interface{}) []string { 54 | f := item.(*mods.ModDir) 55 | return []string{ 56 | f.From, 57 | f.To, 58 | } 59 | } 60 | 61 | func (d *dirsDef) onEditItem(item interface{}) { 62 | d.createItem(item) 63 | } 64 | 65 | func (d *dirsDef) createItem(item interface{}, done ...func(interface{})) { 66 | f := item.(*mods.ModDir) 67 | entry.CreateFileDialog(d, "From", f.From, state.GetBaseDirBinding(), true, true) 68 | entry.NewEntry[string](d, entry.KindString, d.gamesDef.AuthorHintDir(), f.To) 69 | entry.NewEntry[bool](d, entry.KindBool, "Recursive", f.Recursive) 70 | s := "" 71 | if f.ToArchive != nil { 72 | s = *f.ToArchive 73 | } 74 | entry.NewEntry[string](d, entry.KindString, "To Archive", s) 75 | 76 | items := []*widget.FormItem{ 77 | entry.GetFileDialog(d, "From"), 78 | entry.FormItem[string](d, d.gamesDef.AuthorHintDir()), 79 | entry.FormItem[bool](d, "Recursive"), 80 | } 81 | if d.installType.Is(config.MoveToArchive) { 82 | items = append(items, entry.FormItem[string](d, "To Archive")) 83 | } 84 | 85 | fd := dialog.NewForm("Edit Directory Copy", "Save", "Cancel", items, 86 | func(ok bool) { 87 | if ok { 88 | f.From = cleanPath(entry.DialogValue(d, "From")) 89 | f.To = cleanPath(entry.Value[string](d, d.gamesDef.AuthorHintDir())) 90 | f.Recursive = entry.Value[bool](d, "Recursive") 91 | if s = entry.Value[string](d, "To Archive"); s == "" { 92 | f.ToArchive = nil 93 | } else { 94 | f.ToArchive = &s 95 | } 96 | if len(done) > 0 { 97 | done[0](f) 98 | } 99 | d.list.Refresh() 100 | } 101 | }, ui.Window) 102 | fd.Resize(fyne.NewSize(600, 400)) 103 | fd.Show() 104 | } 105 | 106 | func (d *dirsDef) draw(label bool) fyne.CanvasObject { 107 | c := container.NewHBox() 108 | if label { 109 | c.Add(widget.NewLabelWithStyle("Dirs", fyne.TextAlignCenter, fyne.TextStyle{Bold: true})) 110 | } 111 | c.Add(widget.NewButton("Add", func() { 112 | d.createItem(&mods.ModDir{}, func(result interface{}) { 113 | d.list.AddItem(result) 114 | }) 115 | })) 116 | return container.NewVBox( 117 | c, 118 | d.list.Draw()) 119 | } 120 | 121 | func (d *dirsDef) clear() { 122 | d.list.Clear() 123 | } 124 | 125 | func (d *dirsDef) populate(dirs []*mods.ModDir) { 126 | d.list.Clear() 127 | for _, dir := range dirs { 128 | d.list.AddItem(dir) 129 | } 130 | } 131 | 132 | func cleanPath(s string) string { 133 | s = strings.ReplaceAll(s, "\\", "/") 134 | s = strings.ReplaceAll(s, "//", "/") 135 | return strings.Trim(s, "/") 136 | } 137 | -------------------------------------------------------------------------------- /ui/mod-author/configurations.go: -------------------------------------------------------------------------------- 1 | package mod_author 2 | 3 | import ( 4 | "fyne.io/fyne/v2" 5 | "fyne.io/fyne/v2/container" 6 | "fyne.io/fyne/v2/dialog" 7 | "fyne.io/fyne/v2/widget" 8 | "github.com/kiamev/moogle-mod-manager/config" 9 | "github.com/kiamev/moogle-mod-manager/mods" 10 | cw "github.com/kiamev/moogle-mod-manager/ui/custom-widgets" 11 | "github.com/kiamev/moogle-mod-manager/ui/mod-author/entry" 12 | "github.com/kiamev/moogle-mod-manager/ui/state/ui" 13 | ) 14 | 15 | type configurationsDef struct { 16 | entry.Manager 17 | list *cw.DynamicList 18 | choicesDef *choicesDef 19 | previewDef *previewDef 20 | selectType entry.Entry[string] 21 | } 22 | 23 | func newConfigurationsDef(dlDef *downloads, installType *config.InstallType, gamesDef *gamesDef) *configurationsDef { 24 | d := &configurationsDef{ 25 | Manager: entry.NewManager(), 26 | previewDef: newPreviewDef(), 27 | } 28 | d.selectType = entry.NewSelectEntry(d, "Selection Type", string(mods.Auto), mods.SelectTypes) 29 | d.choicesDef = newChoicesDef(dlDef, d, d.selectType, installType, gamesDef) 30 | d.list = cw.NewDynamicList(cw.Callbacks{ 31 | GetItemKey: d.getItemKey, 32 | GetItemFields: d.getItemFields, 33 | OnEditItem: d.onEditItem, 34 | }, true) 35 | return d 36 | } 37 | 38 | func (d *configurationsDef) compile() []*mods.Configuration { 39 | cfgs := make([]*mods.Configuration, len(d.list.Items)) 40 | for i, item := range d.list.Items { 41 | cfgs[i] = item.(*mods.Configuration) 42 | } 43 | return cfgs 44 | } 45 | 46 | func (d *configurationsDef) getItemKey(item interface{}) string { 47 | c := item.(*mods.Configuration) 48 | if c.Root { 49 | return c.Name + " (root)" 50 | } 51 | return c.Name 52 | } 53 | 54 | func (d *configurationsDef) getItemFields(item interface{}) []string { 55 | c := item.(*mods.Configuration) 56 | return []string{ 57 | c.Name, 58 | c.Description, 59 | } 60 | } 61 | 62 | func (d *configurationsDef) onEditItem(item interface{}) { 63 | d.createItem(item) 64 | } 65 | 66 | func (d *configurationsDef) createItem(item interface{}, done ...func(interface{})) { 67 | c := item.(*mods.Configuration) 68 | entry.NewEntry[string](d, entry.KindString, "Name", c.Name) 69 | entry.NewEntry[string](d, entry.KindMultiLine, "Description", c.Description) 70 | entry.NewEntry[bool](d, entry.KindBool, "Root", c.Root) 71 | if d.selectType.Value() == "" { 72 | d.selectType.Set(string(mods.Auto)) 73 | } 74 | d.previewDef.set(c.Preview) 75 | d.choicesDef.populate(c.Choices) 76 | 77 | items := []*widget.FormItem{ 78 | entry.FormItem[string](d, "Name"), 79 | entry.FormItem[string](d, "Description"), 80 | entry.FormItem[bool](d, "Root"), 81 | d.selectType.FormItem(), 82 | } 83 | items = append(items, d.previewDef.getFormItems()...) 84 | items = append(items, widget.NewFormItem("Choices", d.choicesDef.draw(false))) 85 | 86 | fd := dialog.NewForm("Edit Configuration", "Save", "Cancel", items, func(ok bool) { 87 | if ok { 88 | c.Name = entry.Value[string](d, "Name") 89 | c.Description = entry.Value[string](d, "Description") 90 | c.Root = entry.Value[bool](d, "Root") 91 | c.Preview = d.previewDef.compile() 92 | c.Choices = d.choicesDef.compile() 93 | c.SelectionType = mods.SelectType(d.selectType.Value()) 94 | if len(done) > 0 { 95 | done[0](c) 96 | } 97 | d.list.Refresh() 98 | } 99 | }, ui.Window) 100 | fd.Resize(fyne.NewSize(400, 400)) 101 | fd.Show() 102 | } 103 | 104 | func (d *configurationsDef) draw() fyne.CanvasObject { 105 | return container.NewVBox(container.NewHBox( 106 | widget.NewLabelWithStyle("Configurations", fyne.TextAlignCenter, fyne.TextStyle{Bold: true}), 107 | widget.NewButton("Add", func() { 108 | d.createItem(&mods.Configuration{}, func(result interface{}) { 109 | d.list.AddItem(result) 110 | }) 111 | })), 112 | d.list.Draw()) 113 | } 114 | 115 | func (d *configurationsDef) set(configurations []*mods.Configuration) { 116 | d.list.Clear() 117 | for _, c := range configurations { 118 | d.list.AddItem(c) 119 | } 120 | } 121 | -------------------------------------------------------------------------------- /ui/mod-author/downloadsRemote.go: -------------------------------------------------------------------------------- 1 | package mod_author 2 | 3 | import ( 4 | "errors" 5 | "fmt" 6 | "fyne.io/fyne/v2" 7 | "fyne.io/fyne/v2/container" 8 | "fyne.io/fyne/v2/widget" 9 | "github.com/kiamev/moogle-mod-manager/config" 10 | "github.com/kiamev/moogle-mod-manager/discover/remote/curseforge" 11 | "github.com/kiamev/moogle-mod-manager/discover/remote/nexus" 12 | "github.com/kiamev/moogle-mod-manager/mods" 13 | "github.com/kiamev/moogle-mod-manager/ui/mod-author/entry" 14 | "github.com/kiamev/moogle-mod-manager/ui/util" 15 | "strconv" 16 | ) 17 | 18 | type downloadsRemoteDef struct { 19 | games *gamesDef 20 | kind mods.Kind 21 | parent *fyne.Container 22 | dlList *fyne.Container 23 | listItems []*mods.Download 24 | idEntry entry.Entry[string] 25 | } 26 | 27 | func newDownloadsRemoteDef(games *gamesDef, kind mods.Kind) dlHoster { 28 | d := &downloadsRemoteDef{ 29 | games: games, 30 | kind: kind, 31 | dlList: container.NewVBox(), 32 | idEntry: entry.NewStringFormEntry(string(kind)+" Mod ID", ""), 33 | } 34 | return d 35 | } 36 | 37 | func (d *downloadsRemoteDef) compile(mod *mods.Mod) (err error) { 38 | var id int64 39 | if d.idEntry.Value() != "" { 40 | if id, err = strconv.ParseInt(d.idEntry.Value(), 10, 64); err != nil { 41 | return 42 | } 43 | i := int(id) 44 | if d.kind.Is(mods.Nexus) { 45 | mod.ModKind.Kinds.Add(mods.Nexus) 46 | mod.ModKind.NexusID = (*mods.NexusModID)(&i) 47 | } else if d.kind.Is(mods.CurseForge) { 48 | mod.ModKind.Kinds.Add(mods.CurseForge) 49 | mod.ModKind.CurseForgeID = (*mods.CfModID)(&i) 50 | } 51 | } 52 | return 53 | } 54 | 55 | func (d *downloadsRemoteDef) compileDownloads() (dls []*mods.Download, err error) { 56 | if d.idEntry.Value() != "" { 57 | dls = d.listItems 58 | } 59 | return 60 | } 61 | 62 | func (d *downloadsRemoteDef) loadDownloads() (err error) { 63 | var ( 64 | g []config.GameDef 65 | dls []*mods.Download 66 | ) 67 | if g, err = d.games.gameDefs(); err != nil { 68 | return 69 | } 70 | if len(g) == 1 { 71 | if d.kind == mods.Nexus { 72 | dls, err = nexus.GetDownloads(g[0], d.idEntry.Value()) 73 | } else if d.kind == mods.CurseForge { 74 | dls, err = curseforge.GetDownloads(d.idEntry.Value()) 75 | } 76 | } else { 77 | err = errors.New("select a game this mod will work with") 78 | return 79 | } 80 | d.setDownloadables(dls) 81 | return 82 | } 83 | 84 | func (d *downloadsRemoteDef) draw() *container.TabItem { 85 | d.parent = container.NewVBox( 86 | widget.NewForm(d.idEntry.FormItem()), 87 | container.NewHBox(widget.NewButton("Load Downloadables", func() { 88 | if err := d.loadDownloads(); err != nil { 89 | util.ShowErrorLong(err) 90 | return 91 | } 92 | })), 93 | widget.NewLabel("Downloads:"), 94 | d.dlList, 95 | ) 96 | return container.NewTabItem(string(d.kind), d.parent) 97 | } 98 | 99 | func (d *downloadsRemoteDef) set(mod *mods.Mod) { 100 | d.clear() 101 | if d.kind.Is(mods.Nexus) && mod.ModKind.NexusID != nil { 102 | d.idEntry.Set(fmt.Sprintf("%d", *mod.ModKind.NexusID)) 103 | } else if d.kind.Is(mods.CurseForge) && mod.ModKind.CurseForgeID != nil { 104 | d.idEntry.Set(fmt.Sprintf("%d", *mod.ModKind.CurseForgeID)) 105 | } 106 | d.setDownloadables(mod.Downloadables) 107 | } 108 | 109 | func (d *downloadsRemoteDef) setDownloadables(dls []*mods.Download) { 110 | var ( 111 | isNexus = d.kind.Is(mods.Nexus) 112 | isCf = d.kind.Is(mods.CurseForge) 113 | ) 114 | d.listItems = nil 115 | d.dlList.Objects = nil 116 | for _, dl := range dls { 117 | if (isNexus && dl.Nexus != nil) || 118 | (isCf && dl.CurseForge != nil) { 119 | d.listItems = append(d.listItems, dl) 120 | d.dlList.Add(widget.NewLabel("- " + dl.Name)) 121 | } 122 | } 123 | d.dlList.Refresh() 124 | if d.parent != nil { 125 | d.parent.Refresh() 126 | } 127 | } 128 | 129 | func (d *downloadsRemoteDef) clear() { 130 | d.listItems = nil 131 | d.dlList.Objects = nil 132 | d.idEntry.Set("") 133 | d.dlList.Objects = nil 134 | if d.parent != nil { 135 | d.parent.Refresh() 136 | } 137 | } 138 | -------------------------------------------------------------------------------- /ui/mod-author/games.go: -------------------------------------------------------------------------------- 1 | package mod_author 2 | 3 | import ( 4 | "fmt" 5 | "fyne.io/fyne/v2" 6 | "fyne.io/fyne/v2/container" 7 | "fyne.io/fyne/v2/dialog" 8 | "fyne.io/fyne/v2/widget" 9 | "github.com/kiamev/moogle-mod-manager/config" 10 | "github.com/kiamev/moogle-mod-manager/mods" 11 | cw "github.com/kiamev/moogle-mod-manager/ui/custom-widgets" 12 | "github.com/kiamev/moogle-mod-manager/ui/mod-author/entry" 13 | "github.com/kiamev/moogle-mod-manager/ui/state/ui" 14 | "strings" 15 | ) 16 | 17 | type gamesDef struct { 18 | entry.Manager 19 | list *cw.DynamicList 20 | gameAdded func(config.GameID) 21 | } 22 | 23 | func newGamesDef(gameAdded func(config.GameID)) *gamesDef { 24 | d := &gamesDef{ 25 | Manager: entry.NewManager(), 26 | gameAdded: gameAdded, 27 | } 28 | d.list = cw.NewDynamicList(cw.Callbacks{ 29 | GetItemKey: d.getItemKey, 30 | GetItemFields: d.getItemFields, 31 | OnEditItem: d.editItem, 32 | }, false) 33 | return d 34 | } 35 | 36 | func (d *gamesDef) compile() (games []*mods.Game) { 37 | games = make([]*mods.Game, len(d.list.Items)) 38 | for i, item := range d.list.Items { 39 | games[i] = item.(*mods.Game) 40 | } 41 | return games 42 | } 43 | 44 | func (d *gamesDef) gameDefs() (games []config.GameDef, err error) { 45 | games = make([]config.GameDef, len(d.list.Items)) 46 | for i, item := range d.list.Items { 47 | if games[i], err = config.GameDefFromID(item.(*mods.Game).ID); err != nil { 48 | return 49 | } 50 | } 51 | return 52 | } 53 | 54 | func (d *gamesDef) getItemKey(item interface{}) string { 55 | return string(item.(*mods.Game).ID) 56 | } 57 | 58 | func (d *gamesDef) getItemFields(item interface{}) []string { 59 | versions := item.(*mods.Game).Versions 60 | if len(versions) == 0 { 61 | return nil 62 | } 63 | result := make([]string, len(versions)) 64 | for i, v := range versions { 65 | result[i] = string(v.Version) 66 | } 67 | return []string{strings.Join(result, ", ")} 68 | } 69 | 70 | func (d *gamesDef) editItem(item interface{}) { 71 | d.createItem(item) 72 | } 73 | 74 | func (d *gamesDef) createItem(item interface{}, done ...func(interface{})) { 75 | g := item.(*mods.Game) 76 | entry.NewSelectEntry(d, "Games", string(g.ID), config.GameIDs()) 77 | versions := g.Versions 78 | var v string 79 | if versions != nil { 80 | s := make([]string, len(versions)) 81 | for i, ver := range versions { 82 | s[i] = string(ver.Version) 83 | } 84 | v = strings.Join(s, ", ") 85 | } 86 | entry.NewEntry[string](d, entry.KindString, "Versions", v) 87 | 88 | fd := dialog.NewForm("Edit Games", "Save", "Cancel", []*widget.FormItem{ 89 | entry.FormItem[string](d, "Games"), 90 | entry.FormItem[string](d, "Versions"), 91 | }, func(ok bool) { 92 | if ok { 93 | g.ID = config.GameID(entry.Value[string](d, "Games")) 94 | selected := strings.Split(entry.Value[string](d, "Versions"), ",") 95 | g.Versions = make([]config.Version, len(selected)) 96 | for i, s := range selected { 97 | g.Versions[i] = config.Version{Version: config.VersionID(s)} 98 | } 99 | if len(done) > 0 { 100 | done[0](g) 101 | } 102 | d.list.Refresh() 103 | if d.gameAdded != nil { 104 | d.gameAdded(g.ID) 105 | } 106 | } 107 | }, ui.Window) 108 | fd.Resize(fyne.NewSize(400, 400)) 109 | fd.Show() 110 | } 111 | 112 | func (d *gamesDef) draw() fyne.CanvasObject { 113 | return container.NewVBox(container.NewHBox( 114 | widget.NewLabelWithStyle("Games", fyne.TextAlignCenter, fyne.TextStyle{Bold: true}), 115 | widget.NewButton("Add", func() { 116 | d.createItem(&mods.Game{}, func(result interface{}) { 117 | d.list.AddItem(result) 118 | }) 119 | })), 120 | d.list.Draw()) 121 | } 122 | 123 | func (d *gamesDef) set(games []*mods.Game) { 124 | d.list.Clear() 125 | for _, g := range games { 126 | d.list.AddItem(g) 127 | } 128 | } 129 | 130 | func (d *gamesDef) AuthorHintDir() string { 131 | for _, g := range d.compile() { 132 | if gd, err := config.GameDefFromID(g.ID); err == nil { 133 | return fmt.Sprintf("To %s/", gd.AuthorHintDir()) 134 | } 135 | } 136 | return "To" 137 | } 138 | -------------------------------------------------------------------------------- /downloads/manager.go: -------------------------------------------------------------------------------- 1 | package downloads 2 | 3 | import ( 4 | "fmt" 5 | "github.com/kiamev/moogle-mod-manager/browser" 6 | "github.com/kiamev/moogle-mod-manager/config" 7 | "github.com/kiamev/moogle-mod-manager/mods" 8 | "os" 9 | "path/filepath" 10 | "strings" 11 | ) 12 | 13 | func Download(game config.GameDef, mod mods.TrackedMod, toInstall []*mods.ToInstall) (err error) { 14 | k := mod.Kinds() 15 | if k.IsHosted() { 16 | if err = hosted(game, mod, toInstall); err == nil { 17 | // Success 18 | return 19 | } 20 | } else if k.Is(mods.CurseForge) { 21 | if err = curseForge(game, mod, toInstall); err == nil { 22 | // Success 23 | return 24 | } 25 | } else if k.Is(mods.Nexus) || k.Is(mods.GoogleDrive) { 26 | if err = manualDownload(game, mod, toInstall); err == nil { 27 | // Success 28 | return 29 | } 30 | } else { 31 | return fmt.Errorf("unknown kind %s", k.String()) 32 | } 33 | 34 | if err != nil { 35 | err = fmt.Errorf("failed to install %s: %w", mod.DisplayName(), err) 36 | } 37 | return 38 | } 39 | 40 | func hosted(game config.GameDef, mod mods.TrackedMod, toInstall []*mods.ToInstall) error { 41 | var ( 42 | f string 43 | err error 44 | ) 45 | for _, ti := range toInstall { 46 | if len(ti.Download.Hosted.Sources) == 0 { 47 | return fmt.Errorf("%s has no download sources", ti.Download.Name) 48 | } 49 | for _, source := range ti.Download.Hosted.Sources { 50 | if f, err = ti.GetDownloadLocation(game, mod); err != nil { 51 | return err 52 | } 53 | if f, err = browser.Download(source, f); err == nil { 54 | // success 55 | ti.Download.DownloadedArchiveLocation = (*mods.ArchiveLocation)(&f) 56 | break 57 | } 58 | } 59 | if ti.Download.DownloadedArchiveLocation == nil || *ti.Download.DownloadedArchiveLocation == "" { 60 | return fmt.Errorf("failed to download %s", ti.Download.Hosted.Sources[0]) 61 | } 62 | } 63 | 64 | for _, ti := range toInstall { 65 | if ti.Download.DownloadedArchiveLocation == nil || *ti.Download.DownloadedArchiveLocation == "" { 66 | return fmt.Errorf("failed to download %s", ti.Download.Hosted.Sources[0]) 67 | } 68 | } 69 | return nil 70 | } 71 | 72 | func manualDownload(game config.GameDef, mod mods.TrackedMod, toInstall []*mods.ToInstall) error { 73 | var ( 74 | dir []os.DirEntry 75 | path string 76 | name string 77 | err error 78 | ) 79 | for _, ti := range toInstall { 80 | if path, err = ti.GetDownloadLocation(game, mod); err != nil { 81 | return err 82 | } 83 | if dir, err = os.ReadDir(path); err != nil { 84 | return err 85 | } 86 | if name, err = ti.Download.FileName(); err != nil { 87 | return err 88 | } 89 | 90 | ti.Download.DownloadedArchiveLocation = nil 91 | for _, f := range dir { 92 | if strings.HasPrefix(name, f.Name()) { 93 | s := filepath.Join(path, f.Name()) 94 | ti.Download.DownloadedArchiveLocation = (*mods.ArchiveLocation)(&s) 95 | break 96 | } 97 | } 98 | if ti.Download.DownloadedArchiveLocation == nil || *ti.Download.DownloadedArchiveLocation == "" { 99 | return fmt.Errorf("failed to find %s in %s", name, path) 100 | } 101 | } 102 | return nil 103 | } 104 | 105 | func curseForge(game config.GameDef, mod mods.TrackedMod, toInstall []*mods.ToInstall) error { 106 | var ( 107 | f string 108 | err error 109 | ) 110 | for _, ti := range toInstall { 111 | for _, i := range toInstall { 112 | if f, err = ti.GetDownloadLocation(game, mod); err != nil { 113 | return err 114 | } 115 | if f, err = browser.Download(i.Download.CurseForge.Url, f); err == nil { 116 | // success 117 | ti.Download.DownloadedArchiveLocation = (*mods.ArchiveLocation)(&f) 118 | break 119 | } 120 | } 121 | if ti.Download.DownloadedArchiveLocation == nil || *ti.Download.DownloadedArchiveLocation == "" { 122 | return fmt.Errorf("failed to download %s", ti.Download.Hosted.Sources[0]) 123 | } 124 | } 125 | 126 | for _, ti := range toInstall { 127 | if ti.Download.DownloadedArchiveLocation == nil || *ti.Download.DownloadedArchiveLocation == "" { 128 | return fmt.Errorf("failed to download %s", ti.Download.Hosted.Sources[0]) 129 | } 130 | } 131 | return nil 132 | } 133 | -------------------------------------------------------------------------------- /ui/mod-author/modCompats.go: -------------------------------------------------------------------------------- 1 | package mod_author 2 | 3 | import ( 4 | "errors" 5 | "fyne.io/fyne/v2" 6 | "fyne.io/fyne/v2/container" 7 | "fyne.io/fyne/v2/dialog" 8 | "fyne.io/fyne/v2/widget" 9 | xw "fyne.io/x/fyne/widget" 10 | "github.com/kiamev/moogle-mod-manager/config" 11 | "github.com/kiamev/moogle-mod-manager/discover" 12 | "github.com/kiamev/moogle-mod-manager/mods" 13 | cw "github.com/kiamev/moogle-mod-manager/ui/custom-widgets" 14 | "github.com/kiamev/moogle-mod-manager/ui/mod-author/entry" 15 | "github.com/kiamev/moogle-mod-manager/ui/state" 16 | "github.com/kiamev/moogle-mod-manager/ui/state/ui" 17 | "github.com/kiamev/moogle-mod-manager/ui/util" 18 | "strings" 19 | ) 20 | 21 | type modCompatsDef struct { 22 | entry.Manager 23 | list *cw.DynamicList 24 | name string 25 | gd *gamesDef 26 | } 27 | 28 | func newModCompatsDef(name string, gd *gamesDef) *modCompatsDef { 29 | d := &modCompatsDef{ 30 | Manager: entry.NewManager(), 31 | name: name, 32 | gd: gd, 33 | } 34 | d.list = cw.NewDynamicList(cw.Callbacks{ 35 | GetItemKey: d.getItemKey, 36 | GetItemFields: d.getItemFields, 37 | OnEditItem: d.onEditItem, 38 | }, true) 39 | return d 40 | } 41 | 42 | func (d *modCompatsDef) compile() []*mods.ModCompat { 43 | downloads := make([]*mods.ModCompat, len(d.list.Items)) 44 | for i, item := range d.list.Items { 45 | downloads[i] = item.(*mods.ModCompat) 46 | } 47 | return downloads 48 | } 49 | 50 | func (d *modCompatsDef) getItemKey(item interface{}) string { 51 | name, err := discover.GetDisplayName(state.CurrentGame, item.(*mods.ModCompat).ModID()) 52 | if err != nil { 53 | name = err.Error() 54 | } 55 | return name 56 | } 57 | 58 | func (d *modCompatsDef) getItemFields(item interface{}) []string { 59 | return nil 60 | } 61 | 62 | func (d *modCompatsDef) onEditItem(item interface{}) { 63 | d.createItem(item) 64 | } 65 | 66 | func (d *modCompatsDef) createItem(item interface{}, done ...func(interface{})) { 67 | var m = item.(*mods.ModCompat) 68 | 69 | var ( 70 | game config.GameDef 71 | err error 72 | ) 73 | if d.gd != nil && len(d.gd.list.Items) == 1 { 74 | game, err = config.GameDefFromID(d.gd.compile()[0].ID) 75 | } 76 | if err != nil { 77 | util.ShowErrorLong(errors.New("please specify a supported Games first (from the Games tab)")) 78 | return 79 | } 80 | 81 | modLookup, err := discover.GetModsAsLookup(game) 82 | if err != nil { 83 | util.ShowErrorLong(err) 84 | return 85 | } 86 | 87 | search := xw.NewCompletionEntry(nil) 88 | search.SetText(string(m.ModID())) 89 | search.OnChanged = func(s string) { 90 | if len(s) < 3 { 91 | search.HideCompletion() 92 | } 93 | s = strings.ToLower(s) 94 | var results []string 95 | for _, mod := range modLookup.All() { 96 | if strings.Contains(strings.ToLower(string(mod.ID())), s) || strings.Contains(strings.ToLower(string(mod.Name)), s) { 97 | results = append(results, string(mod.Name)) 98 | } 99 | } 100 | search.SetOptions(results) 101 | search.ShowCompletion() 102 | } 103 | 104 | fd := dialog.NewForm("Edit Mod Compatibility", "Save", "Cancel", []*widget.FormItem{ 105 | widget.NewFormItem("Mod", search), 106 | }, func(ok bool) { 107 | if ok { 108 | var selected *mods.Mod 109 | m.ID = "" 110 | if search.Text != "" { 111 | for _, mod := range modLookup.All() { 112 | if mod.Name.Contains(search.Text) { 113 | selected = mod 114 | break 115 | } 116 | } 117 | if selected == nil { 118 | // TODO 119 | return 120 | } 121 | m.ID = selected.ID() 122 | } 123 | if len(done) > 0 { 124 | done[0](m) 125 | } 126 | } 127 | }, ui.Window) 128 | fd.Resize(fyne.NewSize(400, 400)) 129 | fd.Show() 130 | } 131 | 132 | func (d *modCompatsDef) draw() fyne.CanvasObject { 133 | return container.NewVBox(container.NewHBox( 134 | widget.NewLabelWithStyle(d.name, fyne.TextAlignCenter, fyne.TextStyle{Bold: true}), 135 | widget.NewButton("Add", func() { 136 | d.createItem(&mods.ModCompat{}, func(result interface{}) { 137 | d.list.AddItem(result) 138 | }) 139 | })), 140 | d.list.Draw()) 141 | } 142 | 143 | func (d *modCompatsDef) clear() { 144 | d.list.Clear() 145 | } 146 | -------------------------------------------------------------------------------- /main.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "log" 5 | "os" 6 | "path/filepath" 7 | "runtime/pprof" 8 | "strconv" 9 | "time" 10 | 11 | "fyne.io/fyne/v2/app" 12 | "github.com/kiamev/moogle-mod-manager/browser" 13 | "github.com/kiamev/moogle-mod-manager/config" 14 | "github.com/kiamev/moogle-mod-manager/config/secrets" 15 | "github.com/kiamev/moogle-mod-manager/discover/repo" 16 | "github.com/kiamev/moogle-mod-manager/files" 17 | "github.com/kiamev/moogle-mod-manager/mods/managed" 18 | "github.com/kiamev/moogle-mod-manager/mods/managed/authored" 19 | config_installer "github.com/kiamev/moogle-mod-manager/ui/config-installer" 20 | "github.com/kiamev/moogle-mod-manager/ui/configure" 21 | "github.com/kiamev/moogle-mod-manager/ui/discover" 22 | "github.com/kiamev/moogle-mod-manager/ui/game-select" 23 | "github.com/kiamev/moogle-mod-manager/ui/local" 24 | "github.com/kiamev/moogle-mod-manager/ui/menu" 25 | mod_author "github.com/kiamev/moogle-mod-manager/ui/mod-author" 26 | "github.com/kiamev/moogle-mod-manager/ui/secret" 27 | "github.com/kiamev/moogle-mod-manager/ui/state" 28 | "github.com/kiamev/moogle-mod-manager/ui/state/ui" 29 | "github.com/kiamev/moogle-mod-manager/ui/util" 30 | "github.com/kiamev/moogle-mod-manager/ui/util/resources" 31 | ) 32 | 33 | func main() { 34 | defer func() { 35 | if err := recover(); err != nil { 36 | var msg string 37 | switch e := err.(type) { 38 | case string: 39 | msg = e 40 | case error: 41 | msg = e.Error() 42 | } 43 | if msg != "" { 44 | _ = os.WriteFile("log.txt", []byte(msg), 0644) 45 | } 46 | } 47 | }() 48 | 49 | readScaleFile() 50 | 51 | if os.Getenv("profile") == "true" { 52 | f, err := os.Create(filepath.Join(config.PWD, "cpuprofile")) 53 | if err != nil { 54 | log.Fatal(err) 55 | } 56 | _ = pprof.StartCPUProfile(f) 57 | defer pprof.StopCPUProfile() 58 | } 59 | 60 | ui.App = app.New() 61 | ui.Window = ui.App.NewWindow("Moogle Mod Manager " + browser.Version) 62 | initialize() 63 | 64 | state.RegisterMainMenu(menu.New()) 65 | state.RegisterScreen(state.None, game_select.New()) 66 | state.RegisterScreen(state.ModAuthor, mod_author.New()) 67 | state.RegisterScreen(state.LocalMods, local.New()) 68 | state.RegisterScreen(state.DiscoverMods, discover.New()) 69 | state.RegisterScreen(state.ConfigInstaller, config_installer.New()) 70 | 71 | state.ShowScreen(state.None) 72 | if config.Get().FirstTime { 73 | configure.Show(ui.Window, func() { 74 | secret.Show(ui.Window) 75 | }) 76 | } 77 | 78 | if game, err := config.GameDefFromID(config.GameID(config.Get().DefaultGame)); err == nil { 79 | state.CurrentGame = game 80 | state.ShowScreen(state.LocalMods) 81 | } 82 | 83 | if *config.Get().CheckForM3UpdateOnStart { 84 | go func() { 85 | time.Sleep(time.Second) 86 | util.PromptForUpdateAsNeeded(true) 87 | }() 88 | } 89 | 90 | ui.Window.ShowAndRun() 91 | } 92 | 93 | func readScaleFile() { 94 | if b, err := os.ReadFile("scale.txt"); err == nil { 95 | if _, err = strconv.ParseFloat(string(b), 64); err == nil { 96 | _ = os.Setenv("FYNE_SCALE", string(b)) 97 | } 98 | } 99 | } 100 | 101 | func initialize() { 102 | var err error 103 | secrets.Initialize() 104 | 105 | if err = repo.Initialize(); err != nil { 106 | util.ShowErrorLong(err) 107 | } 108 | 109 | configs := config.Get() 110 | if err = configs.Initialize(); err != nil { 111 | util.ShowErrorLong(err) 112 | } 113 | if configs.CheckForM3UpdateOnStart == nil { 114 | b := true 115 | configs.CheckForM3UpdateOnStart = &b 116 | } 117 | 118 | ui.Window.Resize(config.Get().Size()) 119 | ui.Window.SetMaster() 120 | 121 | if err = repo.NewGetter(repo.Read).Pull(); err != nil { 122 | util.ShowErrorLong(err) 123 | return 124 | } 125 | 126 | if err = config.Initialize(repo.Dirs(repo.Read)); err != nil { 127 | util.ShowErrorLong(err) 128 | } 129 | 130 | if err = files.Initialize(); err != nil { 131 | util.ShowErrorLong(err) 132 | } 133 | 134 | if err = managed.Initialize(config.GameDefs()); err != nil { 135 | util.ShowErrorLong(err) 136 | } 137 | if err = authored.Initialize(); err != nil { 138 | util.ShowErrorLong(err) 139 | } 140 | 141 | configs.InitializeGames(config.GameDefs()) 142 | resources.Initialize(config.GameDefs()) 143 | if resources.Icon != nil { 144 | ui.Window.SetIcon(resources.Icon) 145 | } 146 | } 147 | -------------------------------------------------------------------------------- /mods/preview.go: -------------------------------------------------------------------------------- 1 | package mods 2 | 3 | import ( 4 | "image" 5 | "io" 6 | "os" 7 | "path/filepath" 8 | 9 | "fyne.io/fyne/v2" 10 | "fyne.io/fyne/v2/canvas" 11 | "fyne.io/fyne/v2/container" 12 | "fyne.io/fyne/v2/dialog" 13 | "fyne.io/fyne/v2/theme" 14 | "fyne.io/fyne/v2/widget" 15 | "github.com/kiamev/moogle-mod-manager/cache" 16 | "github.com/kiamev/moogle-mod-manager/config" 17 | "github.com/kiamev/moogle-mod-manager/ui/state" 18 | "github.com/kiamev/moogle-mod-manager/ui/state/ui" 19 | "golang.org/x/image/webp" 20 | ) 21 | 22 | type Preview struct { 23 | Url *string `json:"Url,omitempty" xml:"Url,omitempty"` 24 | Local *string `json:"Local,omitempty" xml:"Local,omitempty"` 25 | img *canvas.Image `json:"-" xml:"-"` 26 | } 27 | 28 | func (p *Preview) Get() *canvas.Image { 29 | if p == nil { 30 | return nil 31 | } 32 | if p.img == nil { 33 | p.img = p.GetUncachedImage() 34 | } 35 | return p.img 36 | } 37 | 38 | func (p *Preview) GetUncachedImage() (img *canvas.Image) { 39 | var ( 40 | r fyne.Resource 41 | err error 42 | ) 43 | if p.Local != nil { 44 | f := filepath.Join(state.GetBaseDir(), *p.Local) 45 | if _, err = os.Stat(f); err == nil { 46 | r, err = p.loadResourceFromPath(f) 47 | } 48 | } 49 | if r == nil && p.Url != nil { 50 | if r, err = cache.GetImage(*p.Url); err != nil { 51 | r, err = fyne.LoadResourceFromURLString(*p.Url) 52 | } 53 | } 54 | if r == nil || err != nil { 55 | return nil 56 | } 57 | img = canvas.NewImageFromResource(r) 58 | img.SetMinSize(fyne.Size{Width: float32(300), Height: float32(300)}) 59 | img.FillMode = canvas.ImageFillContain 60 | return 61 | } 62 | 63 | func (p *Preview) loadResourceFromPath(f string) (r fyne.Resource, err error) { 64 | var ( 65 | i image.Image 66 | reader io.Reader 67 | ) 68 | if r, err = fyne.LoadResourceFromPath(f); err != nil { 69 | if reader, err = os.Open(f); err != nil { 70 | return 71 | } 72 | if i, err = webp.Decode(reader); err != nil { 73 | return 74 | } 75 | canvas.NewImageFromImage(i) 76 | 77 | } 78 | return 79 | } 80 | 81 | func (p *Preview) GetAsButton(onClick func()) *fyne.Container { 82 | i := p.Get() 83 | if i == nil { 84 | return nil 85 | } 86 | return container.NewMax(i, widget.NewButton("", onClick)) 87 | } 88 | 89 | func (p *Preview) GetAsEnlargeOnClick() *fyne.Container { 90 | i := p.Get() 91 | if i == nil { 92 | return nil 93 | } 94 | return container.NewBorder(nil, container.NewCenter(widget.NewButton("Enlarge", func() { 95 | d := dialog.NewCustom("", "Close", p.GetUncachedImage(), ui.ActiveWindow()) 96 | d.Resize(config.Get().Size()) 97 | d.Show() 98 | })), nil, nil, i) 99 | } 100 | 101 | func (p *Preview) GetAsImageGallery(index int, previews []*Preview, enlarge bool) *fyne.Container { 102 | var ( 103 | c = container.NewMax() 104 | left = widget.NewButtonWithIcon("", theme.NavigateBackIcon(), func() { 105 | index = p.decrementIndex(index, len(previews)) 106 | if img := previews[index].GetUncachedImage(); img != nil { 107 | c.Objects = nil 108 | c.Add(img) 109 | } else { 110 | index = p.incrementIndex(index, len(previews)) 111 | } 112 | }) 113 | right = widget.NewButtonWithIcon("", theme.NavigateNextIcon(), func() { 114 | index = p.incrementIndex(index, len(previews)) 115 | if img := previews[index].GetUncachedImage(); img != nil { 116 | c.Objects = nil 117 | c.Add(img) 118 | } else { 119 | index = p.decrementIndex(index, len(previews)) 120 | } 121 | }) 122 | ) 123 | 124 | if img := previews[index].GetUncachedImage(); img != nil { 125 | c.Objects = nil 126 | c.Add(img) 127 | } 128 | 129 | if enlarge { 130 | bottom := container.NewCenter(widget.NewButton("Enlarge", func() { 131 | d := dialog.NewCustom("", "Close", previews[index].GetAsImageGallery(index, previews, false), ui.ActiveWindow()) 132 | d.Resize(config.Get().Size()) 133 | d.Show() 134 | })) 135 | return container.NewBorder(nil, bottom, left, right, c) 136 | } 137 | return container.NewBorder(nil, nil, left, right, c) 138 | } 139 | 140 | func (p *Preview) incrementIndex(i int, size int) int { 141 | i++ 142 | if i == size { 143 | i = 0 144 | } 145 | return i 146 | } 147 | 148 | func (p *Preview) decrementIndex(i int, size int) int { 149 | i-- 150 | if i < 0 { 151 | i = size - 1 152 | } 153 | return i 154 | } 155 | -------------------------------------------------------------------------------- /ui/mod-author/downloadsGoogleDrive.go: -------------------------------------------------------------------------------- 1 | package mod_author 2 | 3 | import ( 4 | "fmt" 5 | "fyne.io/fyne/v2" 6 | "fyne.io/fyne/v2/container" 7 | "fyne.io/fyne/v2/dialog" 8 | "fyne.io/fyne/v2/widget" 9 | "github.com/kiamev/moogle-mod-manager/mods" 10 | cw "github.com/kiamev/moogle-mod-manager/ui/custom-widgets" 11 | "github.com/kiamev/moogle-mod-manager/ui/mod-author/entry" 12 | "github.com/kiamev/moogle-mod-manager/ui/state/ui" 13 | "path/filepath" 14 | "strings" 15 | ) 16 | 17 | type googleDriveDownloadsDef struct { 18 | entry.Manager 19 | list *cw.DynamicList 20 | kinds *mods.Kinds 21 | } 22 | 23 | func newGoogleDriveDownloadsDef(kinds *mods.Kinds) dlHoster { 24 | d := &googleDriveDownloadsDef{ 25 | Manager: entry.NewManager(), 26 | kinds: kinds, 27 | } 28 | d.list = cw.NewDynamicList(cw.Callbacks{ 29 | GetItemKey: d.getItemKey, 30 | GetItemFields: d.getItemFields, 31 | OnEditItem: d.onEditItem, 32 | }, true) 33 | return d 34 | } 35 | 36 | func (d *googleDriveDownloadsDef) compile(mod *mods.Mod) error { 37 | if len(d.list.Items) > 0 { 38 | mod.ModKind.Kinds.Add(mods.GoogleDrive) 39 | } 40 | return nil 41 | } 42 | 43 | func (d *googleDriveDownloadsDef) compileDownloads() ([]*mods.Download, error) { 44 | dls := make([]*mods.Download, len(d.list.Items)) 45 | for i, item := range d.list.Items { 46 | di := item.(*mods.Download) 47 | if di.GoogleDrive != nil { 48 | di.Name = di.GoogleDrive.Name 49 | if j := strings.LastIndex(di.Name, "."); j != -1 { 50 | di.Name = di.Name[:j] 51 | } 52 | } 53 | dls[i] = di 54 | } 55 | return dls, nil 56 | } 57 | 58 | func (d *googleDriveDownloadsDef) getItemKey(item interface{}) string { 59 | dl := item.(*mods.Download) 60 | return fmt.Sprintf(dl.Name) 61 | } 62 | 63 | func (d *googleDriveDownloadsDef) getItemFields(item interface{}) []string { 64 | return []string{ 65 | item.(*mods.Download).Name, 66 | } 67 | } 68 | 69 | func (d *googleDriveDownloadsDef) onEditItem(item interface{}) { 70 | d.createItem(item) 71 | } 72 | 73 | func (d *googleDriveDownloadsDef) createItem(item interface{}, done ...func(interface{})) { 74 | var ( 75 | items []*widget.FormItem 76 | m = item.(*mods.Download) 77 | fileName string 78 | url string 79 | ) 80 | if gd := m.GoogleDrive; gd != nil { 81 | fileName = gd.Name 82 | url = gd.Url 83 | } 84 | entry.NewEntry[string](d, entry.KindString, "File Name", fileName) 85 | entry.NewEntry[string](d, entry.KindString, "Version", m.Version) 86 | entry.NewEntry[string](d, entry.KindString, "URL", url) 87 | 88 | items = []*widget.FormItem{ 89 | entry.FormItem[string](d, "File Name"), 90 | entry.FormItem[string](d, "Version"), 91 | entry.FormItem[string](d, "URL"), 92 | } 93 | 94 | fd := dialog.NewForm("Edit Downloadable", "Save", "Cancel", items, func(ok bool) { 95 | if ok { 96 | m.Version = entry.Value[string](d, "Version") 97 | if m.GoogleDrive == nil { 98 | m.GoogleDrive = &mods.GoogleDriveDownloadable{} 99 | } 100 | fileName = strings.TrimSpace(entry.Value[string](d, "File Name")) 101 | m.GoogleDrive.Name = fileName 102 | m.GoogleDrive.Url = entry.Value[string](d, "URL") 103 | m.Name = strings.TrimSuffix(fileName, filepath.Ext(fileName)) 104 | for _, dn := range done { 105 | dn(m) 106 | } 107 | d.list.Refresh() 108 | } 109 | }, ui.Window) 110 | fd.Resize(fyne.NewSize(600, 400)) 111 | fd.Show() 112 | } 113 | 114 | func (d *googleDriveDownloadsDef) draw() *container.TabItem { 115 | return container.NewTabItem("Google Drive", 116 | container.NewVScroll(container.NewVBox(container.NewHBox( 117 | widget.NewLabelWithStyle("Downloadables", fyne.TextAlignCenter, fyne.TextStyle{Bold: true}), 118 | widget.NewButton("Add", func() { 119 | d.createItem(&mods.Download{}, func(result interface{}) { 120 | d.list.AddItem(result) 121 | d.kinds.Add(mods.GoogleDrive) 122 | }) 123 | })), 124 | d.list.Draw()))) 125 | } 126 | 127 | func (d *googleDriveDownloadsDef) set(mod *mods.Mod) { 128 | d.clear() 129 | if mod.ModKind.Kinds.Is(mods.GoogleDrive) { 130 | for _, i := range mod.Downloadables { 131 | if i.GoogleDrive != nil { 132 | d.list.AddItem(i) 133 | d.kinds.Add(mods.GoogleDrive) 134 | } 135 | } 136 | } 137 | } 138 | 139 | func (d *googleDriveDownloadsDef) clear() { 140 | d.list.Clear() 141 | d.kinds.Remove(mods.GoogleDrive) 142 | } 143 | -------------------------------------------------------------------------------- /ui/mod-author/downloadsAt.go: -------------------------------------------------------------------------------- 1 | package mod_author 2 | 3 | import ( 4 | "fmt" 5 | "fyne.io/fyne/v2" 6 | "fyne.io/fyne/v2/container" 7 | "fyne.io/fyne/v2/dialog" 8 | "fyne.io/fyne/v2/widget" 9 | "github.com/kiamev/moogle-mod-manager/mods" 10 | cw "github.com/kiamev/moogle-mod-manager/ui/custom-widgets" 11 | "github.com/kiamev/moogle-mod-manager/ui/mod-author/entry" 12 | "github.com/kiamev/moogle-mod-manager/ui/state/ui" 13 | "path/filepath" 14 | "strings" 15 | ) 16 | 17 | type downloadsDef struct { 18 | entry.Manager 19 | list *cw.DynamicList 20 | kinds *mods.Kinds 21 | } 22 | 23 | func newDownloadsDef(kinds *mods.Kinds) dlHoster { 24 | d := &downloadsDef{ 25 | Manager: entry.NewManager(), 26 | kinds: kinds, 27 | } 28 | d.list = cw.NewDynamicList(cw.Callbacks{ 29 | GetItemKey: d.getItemKey, 30 | GetItemFields: d.getItemFields, 31 | OnEditItem: d.onEditItem, 32 | }, true) 33 | return d 34 | } 35 | 36 | func (d *downloadsDef) compile(mod *mods.Mod) error { 37 | if len(d.list.Items) > 0 { 38 | mod.ModKind.Kinds.Add(mods.HostedAt) 39 | } 40 | return nil 41 | } 42 | 43 | func (d *downloadsDef) compileDownloads() ([]*mods.Download, error) { 44 | dls := make([]*mods.Download, len(d.list.Items)) 45 | for i, item := range d.list.Items { 46 | di := item.(*mods.Download) 47 | if di.Hosted != nil && len(di.Hosted.Sources) > 0 { 48 | di.Name = filepath.Base(di.Hosted.Sources[0]) 49 | if j := strings.LastIndex(di.Name, "."); j != -1 { 50 | di.Name = di.Name[:j] 51 | } 52 | } 53 | dls[i] = di 54 | } 55 | return dls, nil 56 | } 57 | 58 | func (d *downloadsDef) getItemKey(item interface{}) string { 59 | dl := item.(*mods.Download) 60 | if dl.Version == "" { 61 | return dl.Name 62 | } 63 | return fmt.Sprintf("%s - %s", dl.Name, dl.Version) 64 | } 65 | 66 | func (d *downloadsDef) getItemFields(item interface{}) []string { 67 | return []string{ 68 | item.(*mods.Download).Name, 69 | //strings.Join(item.(*mods.Download).Sources, ", "), 70 | //string(item.(*mods.Download).InstallType), 71 | } 72 | } 73 | 74 | func (d *downloadsDef) onEditItem(item interface{}) { 75 | d.createItem(item) 76 | } 77 | 78 | func (d *downloadsDef) createItem(item interface{}, done ...func(interface{})) { 79 | var ( 80 | items []*widget.FormItem 81 | m = item.(*mods.Download) 82 | ) 83 | var sources []string 84 | if m.Hosted != nil { 85 | sources = m.Hosted.Sources 86 | } 87 | entry.NewEntry[string](d, entry.KindMultiLine, "Sources", strings.Join(sources, "\n")) 88 | entry.NewEntry[string](d, entry.KindString, "Version", m.Version) 89 | 90 | items = []*widget.FormItem{ 91 | entry.FormItem[string](d, "Version"), 92 | entry.FormItem[string](d, "Sources"), 93 | } 94 | 95 | fd := dialog.NewForm("Edit Downloadable", "Save", "Cancel", items, func(ok bool) { 96 | if ok { 97 | m.Version = entry.Value[string](d, "Version") 98 | if m.Hosted == nil { 99 | m.Hosted = &mods.HostedDownloadable{} 100 | } 101 | m.Hosted.Sources = strings.Split(entry.Value[string](d, "Sources"), "\n") 102 | if len(m.Hosted.Sources) > 0 { 103 | m.Name = filepath.Base(m.Hosted.Sources[0]) 104 | } 105 | if m.Name != "" { 106 | m.Name = strings.TrimSuffix(m.Name, filepath.Ext(m.Name)) 107 | } 108 | //m.InstallType = mods.InstallType(entry.Value[string](d, "Install Type")) 109 | for _, dn := range done { 110 | dn(m) 111 | } 112 | d.list.Refresh() 113 | } 114 | }, ui.Window) 115 | fd.Resize(fyne.NewSize(600, 400)) 116 | fd.Show() 117 | } 118 | 119 | func (d *downloadsDef) draw() *container.TabItem { 120 | return container.NewTabItem("Direct Download", 121 | container.NewVScroll(container.NewVBox(container.NewHBox( 122 | widget.NewLabelWithStyle("Downloadables", fyne.TextAlignCenter, fyne.TextStyle{Bold: true}), 123 | widget.NewButton("Add", func() { 124 | d.createItem(&mods.Download{}, func(result interface{}) { 125 | d.list.AddItem(result) 126 | d.kinds.Add(mods.HostedAt) 127 | }) 128 | })), 129 | d.list.Draw()))) 130 | } 131 | 132 | func (d *downloadsDef) set(mod *mods.Mod) { 133 | d.clear() 134 | if mod.ModKind.Kinds.Is(mods.HostedAt) { 135 | for _, i := range mod.Downloadables { 136 | if i.Hosted != nil && len(i.Hosted.Sources) > 0 { 137 | d.list.AddItem(i) 138 | d.kinds.Add(mods.HostedAt) 139 | } 140 | } 141 | } 142 | } 143 | 144 | func (d *downloadsDef) clear() { 145 | d.list.Clear() 146 | d.kinds.Remove(mods.HostedAt) 147 | } 148 | -------------------------------------------------------------------------------- /go.mod: -------------------------------------------------------------------------------- 1 | module github.com/kiamev/moogle-mod-manager 2 | 3 | go 1.19 4 | 5 | require ( 6 | fyne.io/fyne/v2 v2.3.5 7 | fyne.io/x/fyne v0.0.0-20230712143731-ae1d5b63c4f2 8 | github.com/JohannesKaufmann/html-to-markdown v1.3.6 9 | github.com/atotto/clipboard v0.1.4 10 | github.com/carwale/golibraries v1.5.0 11 | github.com/gen2brain/go-unarr v0.1.6 12 | github.com/go-git/go-git/v5 v5.5.2 13 | github.com/google/go-github/v45 v45.2.0 14 | github.com/mholt/archiver/v4 v4.0.0-alpha.7 15 | github.com/ncruces/zenity v0.10.5 16 | golang.org/x/image v0.3.0 17 | golang.org/x/oauth2 v0.4.0 18 | golang.org/x/sync v0.1.0 19 | golang.org/x/sys v0.4.0 20 | ) 21 | 22 | require ( 23 | fyne.io/systray v1.10.1-0.20230602210930-b6a2d6ca2a7b // indirect 24 | github.com/Microsoft/go-winio v0.6.0 // indirect 25 | github.com/ProtonMail/go-crypto v0.0.0-20221026131551-cf6655e29de4 // indirect 26 | github.com/PuerkitoBio/goquery v1.8.0 // indirect 27 | github.com/acomagu/bufpipe v1.0.3 // indirect 28 | github.com/akavel/rsrc v0.10.2 // indirect 29 | github.com/andybalholm/brotli v1.0.4 // indirect 30 | github.com/andybalholm/cascadia v1.3.1 // indirect 31 | github.com/beorn7/perks v1.0.1 // indirect 32 | github.com/cespare/xxhash/v2 v2.2.0 // indirect 33 | github.com/cloudflare/circl v1.3.1 // indirect 34 | github.com/davecgh/go-spew v1.1.1 // indirect 35 | github.com/dchest/jsmin v0.0.0-20220218165748-59f39799265f // indirect 36 | github.com/dsnet/compress v0.0.1 // indirect 37 | github.com/emirpasic/gods v1.18.1 // indirect 38 | github.com/fredbi/uri v1.0.0 // indirect 39 | github.com/fsnotify/fsnotify v1.6.0 // indirect 40 | github.com/fyne-io/gl-js v0.0.0-20220802150000-8e339395f381 // indirect 41 | github.com/fyne-io/glfw-js v0.0.0-20220517201726-bebc2019cd33 // indirect 42 | github.com/fyne-io/image v0.0.0-20221020213044-f609c6a24345 // indirect 43 | github.com/go-git/gcfg v1.5.0 // indirect 44 | github.com/go-git/go-billy/v5 v5.4.0 // indirect 45 | github.com/go-gl/gl v0.0.0-20211210172815-726fda9656d6 // indirect 46 | github.com/go-gl/glfw/v3.3/glfw v0.0.0-20221017161538-93cebf72946b // indirect 47 | github.com/go-text/typesetting v0.0.0-20230405155246-bf9c697c6e16 // indirect 48 | github.com/godbus/dbus/v5 v5.1.0 // indirect 49 | github.com/goki/freetype v0.0.0-20220119013949-7a161fd3728c // indirect 50 | github.com/golang/protobuf v1.5.2 // indirect 51 | github.com/golang/snappy v0.0.4 // indirect 52 | github.com/google/go-querystring v1.1.0 // indirect 53 | github.com/gopherjs/gopherjs v1.17.2 // indirect 54 | github.com/imdario/mergo v0.3.13 // indirect 55 | github.com/jbenet/go-context v0.0.0-20150711004518-d14ea06fba99 // indirect 56 | github.com/josephspurrier/goversioninfo v1.4.0 // indirect 57 | github.com/jsummers/gobmp v0.0.0-20151104160322-e2ba15ffa76e // indirect 58 | github.com/kevinburke/ssh_config v1.2.0 // indirect 59 | github.com/klauspost/compress v1.15.14 // indirect 60 | github.com/klauspost/pgzip v1.2.5 // indirect 61 | github.com/matttproud/golang_protobuf_extensions v1.0.4 // indirect 62 | github.com/nfnt/resize v0.0.0-20180221191011-83c6a9932646 // indirect 63 | github.com/nwaples/rardecode/v2 v2.0.0-beta.2 // indirect 64 | github.com/pierrec/lz4/v4 v4.1.17 // indirect 65 | github.com/pjbgf/sha1cd v0.2.3 // indirect 66 | github.com/pmezard/go-difflib v1.0.0 // indirect 67 | github.com/prometheus/client_golang v1.14.0 // indirect 68 | github.com/prometheus/client_model v0.3.0 // indirect 69 | github.com/prometheus/common v0.39.0 // indirect 70 | github.com/prometheus/procfs v0.9.0 // indirect 71 | github.com/randall77/makefat v0.0.0-20210315173500-7ddd0e42c844 // indirect 72 | github.com/sergi/go-diff v1.2.0 // indirect 73 | github.com/skeema/knownhosts v1.1.0 // indirect 74 | github.com/srwiley/oksvg v0.0.0-20221011165216-be6e8873101c // indirect 75 | github.com/srwiley/rasterx v0.0.0-20220730225603-2ab79fcdd4ef // indirect 76 | github.com/stretchr/testify v1.8.1 // indirect 77 | github.com/tevino/abool v1.2.0 // indirect 78 | github.com/therootcompany/xz v1.0.1 // indirect 79 | github.com/ulikunitz/xz v0.5.11 // indirect 80 | github.com/xanzy/ssh-agent v0.3.3 // indirect 81 | github.com/yuin/goldmark v1.5.3 // indirect 82 | golang.org/x/crypto v0.5.0 // indirect 83 | golang.org/x/mobile v0.0.0-20221110043201-43a038452099 // indirect 84 | golang.org/x/mod v0.7.0 // indirect 85 | golang.org/x/net v0.5.0 // indirect 86 | golang.org/x/text v0.6.0 // indirect 87 | golang.org/x/tools v0.5.0 // indirect 88 | google.golang.org/appengine v1.6.7 // indirect 89 | google.golang.org/protobuf v1.28.1 // indirect 90 | gopkg.in/Graylog2/go-gelf.v2 v2.0.0-20191017102106-1550ee647df0 // indirect 91 | gopkg.in/warnings.v0 v0.1.2 // indirect 92 | gopkg.in/yaml.v3 v3.0.1 // indirect 93 | honnef.co/go/js/dom v0.0.0-20221001195520-26252dedbe70 // indirect 94 | ) 95 | -------------------------------------------------------------------------------- /ui/mod-author/choices.go: -------------------------------------------------------------------------------- 1 | package mod_author 2 | 3 | import ( 4 | "fyne.io/fyne/v2" 5 | "fyne.io/fyne/v2/container" 6 | "fyne.io/fyne/v2/dialog" 7 | "fyne.io/fyne/v2/widget" 8 | "github.com/kiamev/moogle-mod-manager/config" 9 | "github.com/kiamev/moogle-mod-manager/mods" 10 | cw "github.com/kiamev/moogle-mod-manager/ui/custom-widgets" 11 | "github.com/kiamev/moogle-mod-manager/ui/mod-author/entry" 12 | "github.com/kiamev/moogle-mod-manager/ui/state/ui" 13 | ) 14 | 15 | type choicesDef struct { 16 | entry.Manager 17 | list *cw.DynamicList 18 | dlfDef *downloadFilesDef 19 | configDef *configurationsDef 20 | previewDef *previewDef 21 | parentSelect entry.Entry[string] 22 | } 23 | 24 | func newChoicesDef(dlDef *downloads, configDef *configurationsDef, parentSelect entry.Entry[string], installType *config.InstallType, gamesDef *gamesDef) *choicesDef { 25 | d := &choicesDef{ 26 | Manager: entry.NewManager(), 27 | dlfDef: newDownloadFilesDef(dlDef, installType, gamesDef), 28 | configDef: configDef, 29 | previewDef: newPreviewDef(), 30 | parentSelect: parentSelect, 31 | } 32 | d.list = cw.NewDynamicList(cw.Callbacks{ 33 | GetItemKey: d.getItemKey, 34 | GetItemFields: d.getItemFields, 35 | OnEditItem: d.onEditItem, 36 | }, true) 37 | return d 38 | } 39 | 40 | func (d *choicesDef) compile() []*mods.Choice { 41 | choices := make([]*mods.Choice, len(d.list.Items)) 42 | for i, item := range d.list.Items { 43 | choices[i] = item.(*mods.Choice) 44 | } 45 | return choices 46 | } 47 | 48 | func (d *choicesDef) getItemKey(item interface{}) string { 49 | return item.(*mods.Choice).Name 50 | } 51 | 52 | func (d *choicesDef) getItemFields(item interface{}) []string { 53 | c := item.(*mods.Choice) 54 | sl := []string{ 55 | c.Name, 56 | c.Description, 57 | } 58 | if c.NextConfigurationName != nil { 59 | sl = append(sl, *c.NextConfigurationName) 60 | } 61 | if c.DownloadFiles != nil { 62 | sl = append(sl, c.DownloadFiles.DownloadName) 63 | } 64 | return sl 65 | } 66 | 67 | func (d *choicesDef) onEditItem(item interface{}) { 68 | d.createItem(item) 69 | } 70 | 71 | func (d *choicesDef) createItem(item interface{}, done ...func(interface{})) { 72 | var ( 73 | c = item.(*mods.Choice) 74 | configs = d.configDef.compile() 75 | possible = d.getPossibleConfigs(configs) 76 | nextConfig = "" 77 | ) 78 | d.dlfDef.populate(c.DownloadFiles) 79 | 80 | if c.NextConfigurationName != nil { 81 | nextConfig = *c.NextConfigurationName 82 | } 83 | 84 | entry.NewEntry[string](d, entry.KindString, "Name", c.Name) 85 | entry.NewEntry[string](d, entry.KindString, "Description", c.Description) 86 | entry.NewSelectEntry(d, "Next Configuration", nextConfig, possible) 87 | d.previewDef.set(c.Preview) 88 | if c.DownloadFiles != nil { 89 | d.dlfDef.populate(c.DownloadFiles) 90 | } 91 | 92 | form := []*widget.FormItem{ 93 | entry.FormItem[string](d, "Name"), 94 | entry.FormItem[string](d, "Description"), 95 | } 96 | if d.parentSelect.Value() != string(mods.Multi) { 97 | form = append(form, entry.FormItem[string](d, "Next Configuration")) 98 | } 99 | form = append(form, d.previewDef.getFormItems()...) 100 | 101 | dls, err := d.dlfDef.getFormItems() 102 | if err != nil { 103 | dialog.ShowError(err, ui.Window) 104 | } else { 105 | form = append(form, dls...) 106 | } 107 | 108 | fd := dialog.NewForm("Edit Choice", "Save", "Cancel", form, func(ok bool) { 109 | if ok { 110 | c.Name = entry.Value[string](d, "Name") 111 | c.Description = entry.Value[string](d, "Description") 112 | c.Preview = d.previewDef.compile() 113 | c.DownloadFiles = d.dlfDef.compile() 114 | if entry.Value[string](d, "Next Configuration") != "" { 115 | s := entry.Value[string](d, "Next Configuration") 116 | c.NextConfigurationName = &s 117 | } else { 118 | c.NextConfigurationName = nil 119 | } 120 | if len(done) > 0 { 121 | done[0](c) 122 | } 123 | d.list.Refresh() 124 | } 125 | }, ui.Window) 126 | fd.Resize(fyne.NewSize(400, 400)) 127 | fd.Show() 128 | } 129 | 130 | func (d *choicesDef) draw(includeLabel bool) fyne.CanvasObject { 131 | c := container.NewVBox() 132 | if includeLabel { 133 | c.Add(widget.NewLabelWithStyle("Choices", fyne.TextAlignCenter, fyne.TextStyle{Bold: true})) 134 | } 135 | c.Add(widget.NewButton("Add", func() { 136 | d.createItem(&mods.Choice{}, func(result interface{}) { 137 | d.list.AddItem(result) 138 | }) 139 | })) 140 | c.Add(d.list.Draw()) 141 | return c 142 | } 143 | 144 | func (d *choicesDef) getPossibleConfigs(configs []*mods.Configuration) (possible []string) { 145 | if len(configs) > 0 { 146 | possible = make([]string, len(configs)+1) 147 | possible[0] = "" 148 | for i, cfg := range d.configDef.compile() { 149 | possible[i+1] = cfg.Name 150 | } 151 | } 152 | return 153 | } 154 | 155 | func (d *choicesDef) populate(choices []*mods.Choice) { 156 | d.list.Clear() 157 | for _, c := range choices { 158 | d.list.AddItem(c) 159 | } 160 | } 161 | -------------------------------------------------------------------------------- /ui/mod-preview/preivew.go: -------------------------------------------------------------------------------- 1 | package mod_preview 2 | 3 | import ( 4 | "fyne.io/fyne/v2" 5 | "fyne.io/fyne/v2/container" 6 | "fyne.io/fyne/v2/widget" 7 | "github.com/kiamev/moogle-mod-manager/config" 8 | "github.com/kiamev/moogle-mod-manager/discover" 9 | "github.com/kiamev/moogle-mod-manager/mods" 10 | "github.com/kiamev/moogle-mod-manager/ui/util" 11 | "github.com/kiamev/moogle-mod-manager/ui/util/working" 12 | "net/url" 13 | "strings" 14 | ) 15 | 16 | type ModPreviewOptions struct { 17 | UpdateCallback func(mod mods.TrackedMod) 18 | TrackedMod mods.TrackedMod 19 | } 20 | 21 | func CreatePreview(mod *mods.Mod, options ...ModPreviewOptions) fyne.CanvasObject { 22 | defer working.HideDialog() 23 | working.ShowDialog() 24 | 25 | c := container.NewVBox() 26 | if len(options) > 0 && options[0].UpdateCallback != nil && options[0].TrackedMod != nil && options[0].TrackedMod.UpdatedMod() != nil { 27 | c.Add(widget.NewButton("Update", func() { 28 | options[0].UpdateCallback(options[0].TrackedMod) 29 | })) 30 | } 31 | c.Add(createField("Name", string(mod.Name))) 32 | c.Add(createLink("Link", mod.Link)) 33 | c.Add(createField("Author", mod.Author)) 34 | c.Add(createField("Version", mod.Version)) 35 | if mod.Category != "" { 36 | c.Add(createField("Category", string(mod.Category))) 37 | } 38 | c.Add(createField("Release Date", mod.ReleaseDate)) 39 | 40 | text := widget.NewRichTextFromMarkdown(strings.ReplaceAll(mod.Description, "\r", "")) 41 | text.Wrapping = fyne.TextWrapWord 42 | tabItems := []*container.TabItem{ 43 | container.NewTabItem("Description", text), 44 | } 45 | if mod.ReleaseNotes != "" { 46 | text = widget.NewRichTextFromMarkdown(strings.ReplaceAll(mod.ReleaseNotes, "\r", "")) 47 | text.Wrapping = fyne.TextWrapWord 48 | container.NewTabItem("Release Notes", text) 49 | } 50 | if mod.ModCompatibility != nil && mod.ModCompatibility.HasItems() { 51 | if len(mod.Games) > 0 { 52 | if game, err := config.GameDefFromID(mod.Games[0].ID); err == nil { 53 | tabItems = append(tabItems, container.NewTabItem("Compatibility", createCompatibility(game, mod.ModCompatibility))) 54 | } 55 | } 56 | } 57 | if mod.DonationLinks != nil && len(mod.DonationLinks) > 0 { 58 | tabItems = append(tabItems, container.NewTabItem("Donations", createDonationLinks(mod.DonationLinks))) 59 | } 60 | 61 | tabs := container.NewAppTabs(tabItems...) 62 | c = container.NewBorder(c, nil, nil, nil, tabs) 63 | 64 | var img *fyne.Container 65 | if len(mod.Previews) == 1 { 66 | img = mod.Previews[0].GetAsEnlargeOnClick() 67 | } else if len(mod.Previews) > 1 { 68 | img = mod.Previews[0].GetAsImageGallery(0, mod.Previews, true) 69 | } 70 | if img != nil { 71 | c = container.NewBorder(img, nil, nil, nil, c) 72 | } 73 | 74 | return container.NewScroll(c) 75 | } 76 | 77 | func createField(name, value string) *fyne.Container { 78 | return container.NewHBox( 79 | widget.NewLabelWithStyle(name, fyne.TextAlignLeading, fyne.TextStyle{Bold: true}), 80 | widget.NewLabel(value), 81 | ) 82 | } 83 | 84 | func createLink(name, value string) *fyne.Container { 85 | u, err := url.ParseRequestURI(value) 86 | if err != nil { 87 | return createField(name, value) 88 | } 89 | return container.NewHBox( 90 | widget.NewLabelWithStyle(name, fyne.TextAlignLeading, fyne.TextStyle{Bold: true}), 91 | widget.NewHyperlink(value, u), 92 | ) 93 | } 94 | 95 | func createCompatibility(game config.GameDef, compatibility *mods.ModCompatibility) fyne.CanvasObject { 96 | var ( 97 | c = container.NewVBox( 98 | widget.NewLabelWithStyle("Compatibility", fyne.TextAlignLeading, fyne.TextStyle{Bold: true}), 99 | ) 100 | name string 101 | err error 102 | ) 103 | 104 | // Requires 105 | if len(compatibility.Requires) > 0 { 106 | c.Add(widget.NewLabelWithStyle(" Requires", fyne.TextAlignLeading, fyne.TextStyle{Bold: true})) 107 | for _, r := range compatibility.Requires { 108 | name, err = discover.GetDisplayName(game, r.ModID()) 109 | if err != nil { 110 | util.ShowErrorLong(err) 111 | } 112 | c.Add(widget.NewLabel(" - " + name)) 113 | } 114 | } 115 | 116 | // Forbids 117 | if len(compatibility.Forbids) > 0 { 118 | c.Add(widget.NewLabelWithStyle(" Forbids", fyne.TextAlignLeading, fyne.TextStyle{Bold: true})) 119 | for _, r := range compatibility.Forbids { 120 | name, err = discover.GetDisplayName(game, r.ModID()) 121 | if err != nil { 122 | util.ShowErrorLong(err) 123 | } 124 | c.Add(widget.NewLabel(" - " + name)) 125 | } 126 | } 127 | return c 128 | } 129 | 130 | func createDonationLinks(links []*mods.DonationLink) fyne.CanvasObject { 131 | c := container.NewVBox( 132 | widget.NewLabelWithStyle("Support Project", fyne.TextAlignLeading, fyne.TextStyle{Bold: true}), 133 | ) 134 | for _, r := range links { 135 | if u, err := url.Parse(r.Link); err != nil { 136 | c.Add(widget.NewLabel(" - " + r.Name + ": " + r.Link)) 137 | } else { 138 | c.Add(container.NewHBox(widget.NewLabel(" - "+r.Name), widget.NewHyperlink(r.Link, u))) 139 | } 140 | } 141 | return c 142 | } 143 | -------------------------------------------------------------------------------- /discover/remote/curseforge/mod.go: -------------------------------------------------------------------------------- 1 | package curseforge 2 | 3 | import ( 4 | "github.com/kiamev/moogle-mod-manager/config" 5 | "github.com/kiamev/moogle-mod-manager/mods" 6 | "time" 7 | ) 8 | 9 | type cfModResponse struct { 10 | Data cfMod `json:"data"` 11 | } 12 | 13 | type cfMod struct { 14 | ModID int `json:"id"` 15 | Name string `json:"name"` 16 | Summary string `json:"summary"` 17 | Category []category `json:"categories"` 18 | Links links `json:"links"` 19 | Author []author `json:"authors"` 20 | Logo logo `json:"logo"` 21 | Screenshots []screenshot `json:"screenshots"` 22 | CreatedTime time.Time `json:"dateCreated"` 23 | UpdatedTime time.Time `json:"dateModified"` 24 | GameVersions []string `json:"gameVersions"` 25 | Game config.CfGameID `json:"gameId"` 26 | } 27 | 28 | func (m *cfMod) Version() string { 29 | return m.UpdatedTime.Format("2006.01.02.15.04.05") 30 | } 31 | 32 | type CfFile struct { 33 | FileID int `json:"id"` 34 | Name string `json:"displayName"` 35 | DownloadUrl string `json:"downloadUrl"` 36 | FileDate time.Time `json:"fileDate"` 37 | } 38 | 39 | func (f CfFile) Version() string { 40 | return f.FileDate.Format("2006.01.02.15.04.05") 41 | } 42 | 43 | type category struct { 44 | Name string `json:"name"` 45 | } 46 | 47 | /*func (c *category) toCategory() (r mods.Category, err error) { 48 | switch c.Name { 49 | case "Script/Text": 50 | r = mods.ScriptText 51 | case "Enemy Sprite": 52 | r = mods.EnemySprite 53 | case "Window Frames": 54 | r = mods.UiWindowFrames 55 | case "General": 56 | r = mods.General 57 | case "Tile Set": 58 | r = mods.TileSet 59 | case "Textbox Portraits": 60 | r = mods.UiTextBoxPortraits 61 | case "Game Overhauls": 62 | r = mods.GameOverhauls 63 | case "Player/NPC Sprite": 64 | r = mods.PlayerNpcSprites 65 | case "Title Screen": 66 | r = mods.TitleScreen 67 | case "Menu Portraits": 68 | r = mods.UiMenuPortraits 69 | case "Soundtrack": 70 | r = mods.Soundtrack 71 | case "Utility": 72 | r = mods.Utility 73 | case "Gameplay": 74 | r = mods.Gameplay 75 | case "Battle Scene": 76 | r = mods.BattleScene 77 | case "Fonts": 78 | r = mods.Fonts 79 | case "UI": 80 | r = mods.UIGeneral 81 | default: 82 | err = fmt.Errorf("unknown category: " + c.Name) 83 | } 84 | return 85 | }*/ 86 | 87 | type links struct { 88 | WebsiteUrl string `json:"websiteUrl"` 89 | } 90 | 91 | type screenshot struct { 92 | ThumbnailUrl string `json:"thumbnailUrl"` 93 | Url string `json:"url"` 94 | } 95 | 96 | type logo struct { 97 | ThumbnailUrl string `json:"thumbnailUrl"` 98 | Url string `json:"url"` 99 | } 100 | 101 | type author struct { 102 | Name string `json:"name"` 103 | Url string `json:"url"` 104 | } 105 | 106 | type fileParent struct { 107 | Files []CfFile `json:"data"` 108 | } 109 | 110 | type description struct { 111 | Data string `json:"data"` 112 | } 113 | 114 | func (f CfFile) toDownload() *mods.Download { 115 | return &mods.Download{ 116 | Name: f.Name, 117 | Version: f.Version(), 118 | CurseForge: &mods.CurseForgeDownloadable{ 119 | FileID: f.FileID, 120 | FileName: f.Name, 121 | Url: f.DownloadUrl, 122 | }, 123 | } 124 | } 125 | 126 | /* 127 | { 128 | "files":[ 129 | { 130 | "id":[ 131 | 232, 132 | 4335 133 | ], 134 | "uid":18618683228392, 135 | "file_id":232, 136 | "name":"FFVIPR_SNES_Battle_Backgrounds_metalliguy", 137 | "version":"1.1", 138 | "category_id":1, 139 | "category_name":"MAIN", 140 | "is_primary":false, 141 | "size":22333, 142 | "file_name":"FFVIPR_SNES_Battle_Backgrounds_metalliguy-48-1-1-1657022362.rar", 143 | "uploaded_timestamp":1657022362, 144 | "uploaded_time":"2022-07-05T11:59:22.000+00:00", 145 | "mod_version":"1.1", 146 | "external_virus_scan_url":"https://www.virustotal.com/gui/file/b396a748611f317853f0b89da7ad4398216d943f73ff19739206e72430d1a02f/detection/f-b396a748611f317853f0b89da7ad4398216d943f73ff19739206e72430d1a02f-1657022438", 147 | "description":"Updated to support patch 1.0.6", 148 | "size_kb":22333, 149 | "size_in_bytes":22868776, 150 | "changelog_html":"Updated to support patch 1.0.6", 151 | "content_preview_link":"https://file-metadata.nexusmods.com/file/nexus-files-s3-meta/4335/48/FFVIPR_SNES_Battle_Backgrounds_metalliguy-48-1-1-1657022362.rar.json" 152 | } 153 | ], 154 | "file_updates":[ 155 | { 156 | "old_file_id":168, 157 | "new_file_id":232, 158 | "old_file_name":"FFVIPR_SNES_Battle_Backgrounds_metalliguy-48-1-0-1651437383.rar", 159 | "new_file_name":"FFVIPR_SNES_Battle_Backgrounds_metalliguy-48-1-1-1657022362.rar", 160 | "uploaded_timestamp":1657022362, 161 | "uploaded_time":"2022-07-05T11:59:22.000+00:00" 162 | } 163 | ] 164 | } ], 165 | } 166 | */ 167 | -------------------------------------------------------------------------------- /ui/confirm/nexusDownload.go: -------------------------------------------------------------------------------- 1 | package confirm 2 | 3 | import ( 4 | "errors" 5 | "fmt" 6 | "fyne.io/fyne/v2" 7 | "fyne.io/fyne/v2/container" 8 | "fyne.io/fyne/v2/dialog" 9 | "fyne.io/fyne/v2/widget" 10 | "github.com/atotto/clipboard" 11 | "github.com/kiamev/moogle-mod-manager/config" 12 | "github.com/kiamev/moogle-mod-manager/discover/remote/nexus" 13 | "github.com/kiamev/moogle-mod-manager/mods" 14 | "github.com/kiamev/moogle-mod-manager/ui/state/ui" 15 | "github.com/kiamev/moogle-mod-manager/ui/util" 16 | "os" 17 | "path/filepath" 18 | "time" 19 | ) 20 | 21 | type toDownload struct { 22 | uri string 23 | dir string 24 | fileName string 25 | } 26 | 27 | type manualDownloadConfirmer struct { 28 | Params 29 | } 30 | 31 | func newManualDownloadConfirmer(params Params) Confirmer { 32 | return &manualDownloadConfirmer{Params: params} 33 | } 34 | 35 | func (c *manualDownloadConfirmer) Downloads(done func(mods.Result)) (err error) { 36 | var ( 37 | toDl []toDownload 38 | fileName string 39 | ) 40 | for _, ti := range c.ToInstall { 41 | fileName, _ = ti.Download.FileName() 42 | if ti.Download != nil { 43 | dl := toDownload{fileName: fileName} 44 | if dl.uri, err = downloadLink(c.Game, ti.Download); err != nil { 45 | return 46 | } 47 | if dl.dir, err = ti.GetDownloadLocation(c.Game, c.Mod); err != nil { 48 | return 49 | } 50 | if _, err = os.Stat(filepath.Join(dl.dir, fileName)); err == nil { 51 | continue 52 | } 53 | toDl = append(toDl, dl) 54 | } 55 | } 56 | 57 | if len(toDl) == 0 { 58 | done(mods.Ok) 59 | return nil 60 | } 61 | 62 | return c.showDialog(toDl, done) 63 | } 64 | 65 | func (c *manualDownloadConfirmer) showDialog(toDl []toDownload, done func(mods.Result)) (err error) { 66 | var ( 67 | fi []*widget.FormItem 68 | rows []*downloadRow 69 | ) 70 | 71 | for i, td := range toDl { 72 | r := newDownloadRow(&td) 73 | rows = append(rows, r) 74 | text := "Place download in:" 75 | if len(toDl) == 1 && clipboard.WriteAll(td.dir) == nil { 76 | text += " (copied to clipboard)" 77 | } 78 | 79 | fi = append(fi, widget.NewFormItem(fmt.Sprintf("%d:", i+1), r)) 80 | fi = append(fi, widget.NewFormItem("", 81 | widget.NewLabelWithStyle("Download the following file/s:", fyne.TextAlignLeading, fyne.TextStyle{Bold: true}))) 82 | fi = append(fi, widget.NewFormItem("", 83 | util.CreateUrlRow(td.uri))) 84 | fi = append(fi, widget.NewFormItem("", 85 | widget.NewLabelWithStyle(text, fyne.TextAlignLeading, fyne.TextStyle{Bold: true}))) 86 | fi = append(fi, widget.NewFormItem("", 87 | util.CreateUrlRow(td.dir))) 88 | } 89 | 90 | fi = append(fi, widget.NewFormItem("", container.NewCenter(widget.NewButton("Check", func() { 91 | for _, r := range rows { 92 | if e := r.Validate(); e != nil { 93 | util.ShowErrorLong(e) 94 | return 95 | } 96 | } 97 | })))) 98 | d := dialog.NewForm("Download Files", "Done", "Cancel", fi, func(ok bool) { 99 | result := mods.Ok 100 | if !ok { 101 | result = mods.Cancel 102 | } 103 | done(result) 104 | }, ui.Window) 105 | d.SetOnClosed(func() { 106 | for _, r := range rows { 107 | r.stop = true 108 | } 109 | for _, r := range rows { 110 | r.SetOnValidationChanged(nil) 111 | } 112 | }) 113 | d.Resize(fyne.NewSize(500, 450)) 114 | d.Show() 115 | return 116 | } 117 | 118 | type downloadRow struct { 119 | fyne.Validatable 120 | fyne.Widget 121 | fileNeeded string 122 | stop bool 123 | validatedCallback func(error) 124 | } 125 | 126 | func newDownloadRow(td *toDownload) *downloadRow { 127 | r := &downloadRow{ 128 | fileNeeded: filepath.Join(td.dir, td.fileName), 129 | Widget: widget.NewLabel("Found"), 130 | } 131 | if r.Found() { 132 | r.Show() 133 | } else { 134 | r.Hide() 135 | } 136 | r.start() 137 | return r 138 | } 139 | 140 | func (r *downloadRow) start() { 141 | go func() { 142 | var err error 143 | for !r.stop { 144 | err = r.Validate() 145 | if err == nil { 146 | r.Show() 147 | } else { 148 | r.Hide() 149 | } 150 | if r.validatedCallback != nil { 151 | r.validatedCallback(err) 152 | } 153 | time.Sleep(500 * time.Millisecond) 154 | } 155 | }() 156 | } 157 | 158 | func (r *downloadRow) Stop() { 159 | r.stop = true 160 | } 161 | 162 | func (r *downloadRow) Found() bool { 163 | _, err := os.Stat(r.fileNeeded) 164 | return err == nil 165 | } 166 | 167 | func (r *downloadRow) Validate() error { 168 | if !r.Found() { 169 | return fmt.Errorf("The following file was not found:\n%s", r.fileNeeded) 170 | } 171 | return nil 172 | } 173 | 174 | func (r *downloadRow) SetOnValidationChanged(validatedCallback func(error)) { 175 | r.validatedCallback = validatedCallback 176 | } 177 | 178 | func downloadLink(game config.GameDef, d *mods.Download) (string, error) { 179 | if d.Nexus != nil { 180 | return fmt.Sprintf(nexus.NexusFileDownload, d.Nexus.FileID, game.Remote().Nexus.ID), nil 181 | } 182 | if d.GoogleDrive != nil { 183 | return d.GoogleDrive.Url, nil 184 | } 185 | return "", errors.New("DownloadLink only works with Nexus and Google Drive") 186 | } 187 | -------------------------------------------------------------------------------- /ui/mod-author/entry/manager.go: -------------------------------------------------------------------------------- 1 | package entry 2 | 3 | import ( 4 | "fmt" 5 | "fyne.io/fyne/v2" 6 | "fyne.io/fyne/v2/container" 7 | "fyne.io/fyne/v2/data/binding" 8 | "fyne.io/fyne/v2/theme" 9 | "fyne.io/fyne/v2/widget" 10 | cw "github.com/kiamev/moogle-mod-manager/ui/custom-widgets" 11 | ) 12 | 13 | const baseDirKey = "__base_dir" 14 | 15 | type ( 16 | Kind byte 17 | em map[string]any 18 | Manager interface { 19 | get(key string) (any, bool) 20 | set(key string, value any) 21 | } 22 | manager struct { 23 | entries em 24 | } 25 | Entry[T any] interface { 26 | Enable(bool) 27 | Binding() binding.DataItem 28 | Set(t T) 29 | Value() T 30 | FormItem() *widget.FormItem 31 | } 32 | valuer[T any] interface { 33 | Value() T 34 | } 35 | ) 36 | 37 | const ( 38 | _ Kind = iota 39 | KindBool 40 | KindString 41 | KindMultiLine 42 | ) 43 | 44 | func NewManager() Manager { 45 | return &manager{entries: make(map[string]any)} 46 | } 47 | 48 | func (m *manager) get(key string) (any, bool) { 49 | e, ok := m.entries[key] 50 | return e, ok 51 | } 52 | 53 | func (m *manager) set(key string, value any) { 54 | m.entries[key] = value 55 | } 56 | 57 | func Value[T any](m Manager, key string) (t T) { 58 | e, ok := m.get(key) 59 | if ok { 60 | switch en := e.(type) { 61 | case Entry[T]: 62 | t = en.Value() 63 | default: 64 | panic("unknown type") 65 | } 66 | } 67 | return 68 | } 69 | 70 | func DialogValue(m Manager, key string) string { 71 | e, ok := m.get(key) 72 | if ok { 73 | switch en := e.(type) { 74 | case *cw.OpenFileDialogContainer: 75 | return en.OpenFileDialogHandler.Get() 76 | default: 77 | panic("unknown type") 78 | } 79 | } 80 | return "" 81 | } 82 | 83 | func NewEntry[T any](m Manager, kind Kind, key string, value T) Entry[T] { 84 | e, found := m.get(key) 85 | if !found { 86 | switch kind { 87 | case KindBool: 88 | e = newBoolFormEntry(key, value) 89 | case KindString: 90 | e = NewStringFormEntry(key, value) 91 | case KindMultiLine: 92 | e = newMultiLineFormEntry(key, value) 93 | default: 94 | panic(fmt.Sprintf("unknown entry kind %d", kind)) 95 | } 96 | m.set(key, e) 97 | } else { 98 | e.(Entry[T]).Set(value) 99 | } 100 | return e.(Entry[T]) 101 | } 102 | 103 | func NewSelectEntry(m Manager, key string, value string, possible []string) Entry[string] { 104 | e, found := m.get(key) 105 | if !found { 106 | e = NewSelectFormEntry(key, value, possible) 107 | m.set(key, e) 108 | } else { 109 | e.(*SelectFormEntry).Entry.Options = possible 110 | e.(*SelectFormEntry).Set(value) 111 | } 112 | return e.(Entry[string]) 113 | } 114 | 115 | func GetEntry[T any](m Manager, key string) Entry[T] { 116 | e, ok := m.get(key) 117 | if !ok { 118 | return nil 119 | } 120 | return e.(Entry[T]) 121 | } 122 | 123 | func FormItem[T any](m Manager, key string, values ...T) *widget.FormItem { 124 | e := GetEntry[T](m, key) 125 | if len(values) > 0 { 126 | e.Set(values[0]) 127 | } 128 | return e.FormItem() 129 | } 130 | 131 | func GetBaseDirFormItem(m Manager, name string) *widget.FormItem { 132 | e, _ := m.get(baseDirKey) 133 | return widget.NewFormItem(name, e.(*cw.OpenFileDialogContainer).Container) 134 | } 135 | 136 | func GetFileDialog(m Manager, name string) *widget.FormItem { 137 | e, _ := m.get(name) 138 | return widget.NewFormItem(name, e.(*cw.OpenFileDialogContainer).Container) 139 | } 140 | 141 | func CreateBaseDir(m Manager, baseDir binding.String) { 142 | if _, ok := m.get(baseDirKey); !ok { 143 | o := &cw.OpenDirDialog{BaseDir: baseDir, Value: baseDir} 144 | o.ToolbarAction = widget.NewToolbarAction(theme.FolderOpenIcon(), o.Handle) 145 | m.set(baseDirKey, &cw.OpenFileDialogContainer{ 146 | Container: container.NewBorder(nil, nil, nil, widget.NewToolbar(o), widget.NewEntryWithData(baseDir)), 147 | OpenFileDialogHandler: o, 148 | }) 149 | } 150 | } 151 | 152 | func CreateFileDialog(m Manager, key string, value string, baseDir binding.String, isDir bool, isRelative bool) { 153 | e, ok := m.get(key) 154 | if !ok { 155 | b := binding.NewString() 156 | var o cw.OpenFileDialogHandler 157 | if isDir { 158 | o = &cw.OpenDirDialog{ 159 | IsRelative: isRelative, 160 | Value: b, 161 | BaseDir: baseDir, 162 | } 163 | } else { 164 | o = &cw.OpenFileDialog{ 165 | IsRelative: isRelative, 166 | Value: b, 167 | BaseDir: baseDir, 168 | } 169 | } 170 | o.SetAction(widget.NewToolbarAction(theme.FolderOpenIcon(), o.Handle)) 171 | e = &cw.OpenFileDialogContainer{ 172 | Container: container.NewBorder(nil, nil, nil, widget.NewToolbar(o), widget.NewEntryWithData(b)), 173 | OpenFileDialogHandler: o, 174 | } 175 | m.set(key, e) 176 | } 177 | switch t := e.(*cw.OpenFileDialogContainer).OpenFileDialogHandler.(type) { 178 | case *cw.OpenDirDialog: 179 | _ = t.Value.Set(value) 180 | case *cw.OpenFileDialog: 181 | _ = t.Value.Set(value) 182 | } 183 | } 184 | 185 | func FormItemFileDialog(m Manager, key string) *widget.FormItem { 186 | e, _ := m.get(key) 187 | return widget.NewFormItem(key, e.(fyne.CanvasObject)) 188 | } 189 | -------------------------------------------------------------------------------- /mods/managed/modTracker.go: -------------------------------------------------------------------------------- 1 | package managed 2 | 3 | import ( 4 | "encoding/json" 5 | "encoding/xml" 6 | "errors" 7 | "fmt" 8 | "os" 9 | "path/filepath" 10 | "strings" 11 | 12 | "github.com/kiamev/moogle-mod-manager/browser" 13 | "github.com/kiamev/moogle-mod-manager/config" 14 | "github.com/kiamev/moogle-mod-manager/discover/remote" 15 | "github.com/kiamev/moogle-mod-manager/discover/remote/curseforge" 16 | "github.com/kiamev/moogle-mod-manager/discover/remote/nexus" 17 | "github.com/kiamev/moogle-mod-manager/mods" 18 | "github.com/kiamev/moogle-mod-manager/ui/state" 19 | "github.com/kiamev/moogle-mod-manager/util" 20 | ) 21 | 22 | const ( 23 | modTrackerName = "tracker.json" 24 | ) 25 | 26 | var ( 27 | lookup = newGameModLookup() 28 | ) 29 | 30 | func Initialize(games []config.GameDef) (err error) { 31 | if err = util.LoadFromFile(filepath.Join(config.PWD, modTrackerName), &lookup); err != nil { 32 | // first run 33 | for _, game := range games { 34 | lookup.Set(game) 35 | } 36 | return save() 37 | } 38 | 39 | if len(games) != lookup.Len() { 40 | for _, game := range games { 41 | if !lookup.Has(game) { 42 | lookup.Set(game) 43 | } 44 | } 45 | } 46 | 47 | for _, game := range games { 48 | for _, tm := range lookup.GetMods(game) { 49 | var mod mods.Mod 50 | if err = mod.LoadFromFile(tm.MoogleModFile()); err != nil { 51 | return 52 | } 53 | tm.SetMod(&mod) 54 | } 55 | } 56 | return 57 | } 58 | 59 | func AddModFromFile(game config.GameDef, file string) (mods.TrackedMod, error) { 60 | mod := &mods.Mod{} 61 | if err := mod.LoadFromFile(file); err != nil { 62 | return nil, err 63 | } 64 | if s := mod.Validate(); s != "" { 65 | return nil, fmt.Errorf("failed to load mod:\n%s", s) 66 | } 67 | return AddMod(game, mod) 68 | } 69 | 70 | func AddModFromUrl(game config.GameDef, url string) (mods.TrackedMod, error) { 71 | var ( 72 | mod *mods.Mod 73 | b []byte 74 | err error 75 | ) 76 | if i := strings.Index(url, "?"); i != -1 { 77 | url = url[:i] 78 | } 79 | if nexus.IsNexus(url) { 80 | if _, mod, err = remote.GetFromUrl(mods.Nexus, url); err != nil { 81 | return nil, err 82 | } 83 | } else if curseforge.IsCurseforge(url) { 84 | if _, mod, err = remote.GetFromUrl(mods.CurseForge, url); err != nil { 85 | return nil, err 86 | } 87 | } else { 88 | if b, err = browser.DownloadAsBytes(url); err != nil { 89 | return nil, err 90 | } 91 | if b[0] == '<' { 92 | err = xml.Unmarshal(b, &mod) 93 | } else { 94 | err = json.Unmarshal(b, &mod) 95 | } 96 | if err != nil { 97 | return nil, fmt.Errorf("failed to load mod: %v", err) 98 | } 99 | } 100 | 101 | return AddMod(game, mod) 102 | } 103 | 104 | func AddMod(game config.GameDef, mod *mods.Mod) (tm mods.TrackedMod, err error) { 105 | var found bool 106 | if tm, found = lookup.GetModByID(game, mod.ID()); found { 107 | return 108 | } 109 | tm = mods.NewTrackerMod(mod, state.CurrentGame) 110 | err = addMod(game, tm) 111 | return 112 | } 113 | 114 | func addMod(game config.GameDef, tm mods.TrackedMod) (err error) { 115 | if err = tm.Mod().Supports(game); err != nil { 116 | return 117 | } 118 | 119 | // tm.Disable() 120 | if lookup.HasMod(game, tm) { 121 | return errors.New("mod already added") 122 | } 123 | 124 | if err = saveMoogle(tm); err != nil { 125 | return 126 | } 127 | 128 | lookup.SetMod(game, tm) 129 | return save() 130 | } 131 | 132 | func GetMods(game config.GameDef) []mods.TrackedMod { 133 | return lookup.GetMods(game) 134 | } 135 | 136 | func DisableMod(tm mods.TrackedMod) error { 137 | tm.Disable() 138 | return save() 139 | } 140 | 141 | func EnableMod(tm mods.TrackedMod) error { 142 | tm.Enable() 143 | return save() 144 | } 145 | 146 | func GetEnabledMods(game config.GameDef) (result []mods.TrackedMod) { 147 | for _, tm := range lookup.GetMods(game) { 148 | if tm.Enabled() { 149 | result = append(result, tm) 150 | } 151 | } 152 | return 153 | } 154 | 155 | func IsModEnabled(game config.GameDef, id mods.ModID) (mod mods.TrackedMod, found bool, enabled bool) { 156 | if mod, found = TryGetMod(game, id); found { 157 | enabled = mod.Enabled() 158 | } else { 159 | mod = nil 160 | } 161 | return 162 | } 163 | 164 | func TryGetMod(game config.GameDef, id mods.ModID) (m mods.TrackedMod, found bool) { 165 | m, found = lookup.GetModByID(game, id) 166 | return 167 | } 168 | 169 | func RemoveMod(game config.GameDef, tm mods.TrackedMod) error { 170 | lookup.RemoveMod(game, tm) 171 | _ = os.RemoveAll(filepath.Dir(tm.MoogleModFile())) 172 | for _, ti := range tm.Mod().Downloadables { 173 | if ti.DownloadedArchiveLocation != nil && *ti.DownloadedArchiveLocation != "" { 174 | dir := filepath.Dir(string(*ti.DownloadedArchiveLocation)) 175 | dir = filepath.Dir(dir) 176 | if strings.Contains(dir, config.Get().DownloadDir) { 177 | _ = os.RemoveAll(dir) 178 | } 179 | } 180 | } 181 | return save() 182 | } 183 | 184 | func save() error { 185 | return util.SaveToFile(filepath.Join(config.PWD, modTrackerName), &lookup) 186 | } 187 | 188 | func saveMoogle(tm mods.TrackedMod) (err error) { 189 | return tm.Save() 190 | } 191 | 192 | func ForceDisableAll(game config.GameDef) { 193 | for _, tm := range lookup.GetMods(game) { 194 | tm.Disable() 195 | } 196 | _ = save() 197 | } 198 | 199 | func ForceDisable(tm mods.TrackedMod) { 200 | tm.Disable() 201 | _ = save() 202 | } 203 | -------------------------------------------------------------------------------- /files/tracker.go: -------------------------------------------------------------------------------- 1 | package files 2 | 3 | import ( 4 | "fmt" 5 | "os" 6 | "path/filepath" 7 | 8 | "github.com/kiamev/moogle-mod-manager/collections" 9 | "github.com/kiamev/moogle-mod-manager/config" 10 | "github.com/kiamev/moogle-mod-manager/mods" 11 | uu "github.com/kiamev/moogle-mod-manager/ui/util" 12 | "github.com/kiamev/moogle-mod-manager/util" 13 | ) 14 | 15 | const file = "filetracker.json" 16 | 17 | type ( 18 | gameTracker struct { 19 | Games map[config.GameID]*modTracker `json:"games"` 20 | } 21 | modTracker struct { 22 | Mods map[mods.ModID]*fileTracker `json:"mods"` 23 | // Backups collections.Set[string] `json:"backup"` 24 | } 25 | fileTracker struct { 26 | Files collections.Set[string] `json:"files,omitempty"` 27 | ArchiveFiles map[string]collections.Set[string] `json:"archive_files,omitempty"` 28 | } 29 | ) 30 | 31 | var tracker = &gameTracker{Games: make(map[config.GameID]*modTracker)} 32 | 33 | func Initialize() error { 34 | if _, err := os.Stat(filepath.Join(config.PWD, file)); err == nil { 35 | return util.LoadFromFile(filepath.Join(config.PWD, file), tracker) 36 | } 37 | return nil 38 | } 39 | 40 | func ModTracker(game config.GameDef) *modTracker { 41 | mt, ok := tracker.Games[game.ID()] 42 | if !ok { 43 | mt = &modTracker{ 44 | Mods: make(map[mods.ModID]*fileTracker), 45 | // Backups: collections.NewSet[string](), 46 | } 47 | tracker.Games[game.ID()] = mt 48 | } 49 | return mt 50 | } 51 | 52 | func modFiles(game config.GameDef, modID mods.ModID) *fileTracker { 53 | var ( 54 | mt = ModTracker(game) 55 | ft, ok = mt.Mods[modID] 56 | ) 57 | if !ok { 58 | ft = &fileTracker{ 59 | Files: collections.NewSet[string](), 60 | } 61 | mt.Mods[modID] = ft 62 | } 63 | return ft 64 | } 65 | 66 | func Files(game config.GameDef, modID mods.ModID) collections.Set[string] { 67 | return modFiles(game, modID).Files 68 | } 69 | 70 | func Archives(game config.GameDef, modID mods.ModID) map[string]collections.Set[string] { 71 | return modFiles(game, modID).ArchiveFiles 72 | } 73 | 74 | func EmptyMods(game config.GameDef) (result []mods.ModID) { 75 | for id, ft := range ModTracker(game).Mods { 76 | if ft.Files.Len() == 0 && len(ft.ArchiveFiles) == 0 { 77 | result = append(result, id) 78 | } 79 | } 80 | return 81 | } 82 | 83 | // func Backups(game config.GameDef) collections.Set[string] { 84 | // return ModTracker(game).Backups 85 | // } 86 | 87 | func HasFile(game config.GameDef, file string) (modID mods.ModID, found bool) { 88 | var ft *fileTracker 89 | for modID, ft = range ModTracker(game).Mods { 90 | if ft.Files.Contains(file) { 91 | return modID, true 92 | } 93 | } 94 | return 95 | } 96 | 97 | func HasArchiveFile(game config.GameDef, archive string, file string) (modID mods.ModID, found bool) { 98 | var ft *fileTracker 99 | for modID, ft = range ModTracker(game).Mods { 100 | if m := ft.ArchiveFiles; m != nil { 101 | if files, ok := m[archive]; ok && files.Contains(file) { 102 | return modID, true 103 | } 104 | } 105 | } 106 | return 107 | } 108 | 109 | // func HasBackup(game config.GameDef, file string) bool { 110 | // return ModTracker(game).Backups.Contains(file) 111 | // } 112 | 113 | func SetFiles(game config.GameDef, modID mods.ModID, files ...string) { 114 | var ( 115 | ft = modFiles(game, modID) 116 | ) 117 | for _, f := range files { 118 | ft.Files.Set(f) 119 | } 120 | tracker.save() 121 | } 122 | 123 | func AppendArchiveFiles(game config.GameDef, modID mods.ModID, archive string, files ...string) { 124 | var ( 125 | ft = modFiles(game, modID) 126 | m = ft.ArchiveFiles 127 | ) 128 | if m == nil { 129 | m = make(map[string]collections.Set[string]) 130 | } 131 | for _, f := range files { 132 | s, found := m[archive] 133 | if !found { 134 | s = collections.NewSet[string]() 135 | } 136 | s.Set(f) 137 | m[archive] = s 138 | } 139 | ft.ArchiveFiles = m 140 | tracker.save() 141 | } 142 | 143 | /*func SetBackups(game config.GameDef, backups ...string) { 144 | mt := ModTracker(game) 145 | for _, f := range backups { 146 | mt.Backups.Set(f) 147 | } 148 | save() 149 | }*/ 150 | 151 | /*func RemoveBackups(game config.GameDef, backups ...string) { 152 | mt := ModTracker(game) 153 | for _, bu := range backups { 154 | mt.Backups.Remove(bu) 155 | } 156 | save() 157 | }*/ 158 | 159 | func RemoveFiles(game config.GameDef, modID mods.ModID, files ...string) { 160 | for _, f := range files { 161 | modFiles(game, modID).Files.Remove(f) 162 | } 163 | tracker.save() 164 | } 165 | 166 | func RemoveAllFilesForGame(game config.GameDef) { 167 | delete(tracker.Games, game.ID()) 168 | tracker.save() 169 | } 170 | 171 | func RemoveAllFilesForMod(game config.GameDef, modID mods.ModID) { 172 | delete(ModTracker(game).Mods, modID) 173 | tracker.save() 174 | } 175 | 176 | func RemoveArchiveFiles(game config.GameDef, modID mods.ModID, archive string, files ...string) { 177 | var ( 178 | ft = modFiles(game, modID) 179 | m = ft.ArchiveFiles 180 | ) 181 | if m == nil { 182 | m = make(map[string]collections.Set[string]) 183 | } 184 | for _, f := range files { 185 | if s, found := m[archive]; found { 186 | s.Remove(f) 187 | m[archive] = s 188 | } 189 | } 190 | ft.ArchiveFiles = m 191 | tracker.save() 192 | } 193 | 194 | func (t *gameTracker) save() { 195 | if err := util.SaveToFile(filepath.Join(config.PWD, file), t); err != nil { 196 | uu.ShowErrorLong(fmt.Errorf("failed to save file tracker: %v", err)) 197 | } 198 | } 199 | -------------------------------------------------------------------------------- /archive/decompress.go: -------------------------------------------------------------------------------- 1 | package archive 2 | 3 | import ( 4 | "context" 5 | "github.com/gen2brain/go-unarr" 6 | "github.com/kiamev/moogle-mod-manager/mods" 7 | "github.com/mholt/archiver/v4" 8 | "io" 9 | "os" 10 | "path/filepath" 11 | "strings" 12 | ) 13 | 14 | type ( 15 | ExtractedFile struct { 16 | Name string 17 | From string 18 | Relative string 19 | } 20 | extractor struct { 21 | extracted []ExtractedFile 22 | to string 23 | files map[string]bool 24 | dirsRecursive []string 25 | includeBaseDirRecursive bool 26 | dirs map[string]bool 27 | includeBaseDir bool 28 | } 29 | ) 30 | 31 | func Decompress(from string, to string, continueIfExists bool, ti *mods.ToInstall) (extracted []ExtractedFile, err error) { 32 | var ( 33 | f *os.File 34 | fi os.FileInfo 35 | a *unarr.Archive 36 | e = newExtractor(to, ti) 37 | ) 38 | if fi, err = os.Stat(to); err == nil && fi.IsDir() { 39 | var fis []os.DirEntry 40 | if fis, err = os.ReadDir(to); err == nil && len(fis) > 0 { 41 | if !continueIfExists { 42 | return nil, nil 43 | } 44 | } 45 | } 46 | 47 | if filepath.Ext(from) == ".rar" { 48 | if f, err = os.Open(from); err != nil { 49 | return 50 | } 51 | err = archiver.Rar{}.Extract(context.Background(), f, nil, e.extractRar) 52 | } else { // zip/7z 53 | if a, err = unarr.NewArchive(from); err != nil { 54 | return 55 | } 56 | defer func() { _ = a.Close() }() 57 | 58 | if err = os.MkdirAll(to, 0777); err != nil { 59 | return 60 | } 61 | err = e.extractArchive(a) 62 | } 63 | extracted = e.extracted 64 | return 65 | } 66 | 67 | func newExtractor(to string, ti *mods.ToInstall) *extractor { 68 | e := &extractor{ 69 | to: to, 70 | files: make(map[string]bool), 71 | dirs: make(map[string]bool), 72 | } 73 | for _, df := range ti.DownloadFiles { 74 | for _, f := range df.Files { 75 | e.files[strings.ReplaceAll(f.From, "\\", "/")] = true 76 | } 77 | for _, d := range df.Dirs { 78 | if d.From == "." { 79 | if d.Recursive { 80 | e.includeBaseDirRecursive = true 81 | break 82 | } else { 83 | e.includeBaseDir = true 84 | } 85 | } else { 86 | from := strings.ReplaceAll(d.From, "\\", "/") 87 | from = strings.Trim(from, "/") 88 | if d.Recursive { 89 | e.dirsRecursive = append(e.dirsRecursive, from) 90 | } else { 91 | e.dirs[from] = true 92 | } 93 | } 94 | } 95 | } 96 | return e 97 | } 98 | 99 | func (e *extractor) extractRar(_ context.Context, f archiver.File) (err error) { 100 | if !f.IsDir() { 101 | var r io.ReadCloser 102 | if r, err = f.Open(); err != nil { 103 | return 104 | } 105 | defer func() { _ = r.Close() }() 106 | 107 | if e.shouldSkip(f.NameInArchive) { 108 | return nil 109 | } 110 | 111 | fp := filepath.Join(e.to, f.NameInArchive) 112 | if err = os.MkdirAll(filepath.Dir(fp), 0755); err != nil { 113 | return 114 | } 115 | buf := new(strings.Builder) 116 | if _, err = io.Copy(buf, r); err != nil { 117 | return 118 | } 119 | var file *os.File 120 | if file, err = os.Create(fp); err != nil { 121 | return 122 | } 123 | defer func() { _ = file.Close() }() 124 | 125 | _, err = file.WriteString(buf.String()) 126 | 127 | e.extracted = append(e.extracted, ExtractedFile{ 128 | Name: strings.ReplaceAll(filepath.Base(fp), "\\", "/"), 129 | From: strings.ReplaceAll(fp, "\\", "/"), 130 | Relative: strings.ReplaceAll(f.NameInArchive, "\\", "/"), 131 | }) 132 | } 133 | return 134 | } 135 | 136 | func (e *extractor) extractArchive(a *unarr.Archive) (err error) { 137 | var ( 138 | files []string 139 | rel string 140 | ) 141 | if files, err = a.Extract(e.to); err == nil { 142 | e.extracted = make([]ExtractedFile, 0, len(files)) 143 | err = filepath.WalkDir(e.to, 144 | func(path string, d os.DirEntry, err error) error { 145 | if err != nil { 146 | return nil 147 | } 148 | if d.IsDir() { 149 | return nil 150 | } 151 | rel, _ = filepath.Rel(e.to, path) 152 | if e.shouldSkip(rel) { 153 | _ = os.Remove(path) 154 | return nil 155 | } 156 | if rel, err = filepath.Rel(e.to, path); err != nil { 157 | return err 158 | } 159 | e.extracted = append(e.extracted, ExtractedFile{ 160 | Name: d.Name(), 161 | From: path, 162 | Relative: rel, 163 | }) 164 | return nil 165 | }) 166 | } 167 | return 168 | } 169 | 170 | func (e *extractor) shouldSkip(path string) bool { 171 | var ( 172 | lowerName = strings.ToLower(filepath.Base(path)) 173 | found bool 174 | ) 175 | if strings.HasPrefix(lowerName, "readme") || 176 | strings.HasPrefix(lowerName, "license") || 177 | strings.HasPrefix(lowerName, ".git") || 178 | strings.HasPrefix(lowerName, "__macosx") || 179 | strings.HasPrefix(lowerName, ".ds_store") { 180 | return true 181 | } 182 | path = strings.ReplaceAll(path, "\\", "/") 183 | 184 | if e.includeBaseDirRecursive { 185 | return false 186 | } 187 | 188 | if _, found = e.files[path]; found { 189 | return false 190 | } 191 | 192 | dir := filepath.Dir(path) 193 | if e.includeBaseDir && dir == filepath.Dir(dir) { 194 | return false 195 | } 196 | 197 | dir = strings.ReplaceAll(dir, "\\", "/") 198 | if _, found = e.dirs[dir]; found { 199 | return false 200 | } 201 | for _, d := range e.dirsRecursive { 202 | if strings.HasPrefix(dir, d) { 203 | return false 204 | } 205 | } 206 | return true 207 | } 208 | --------------------------------------------------------------------------------