2021-11-05 21:42:49 +00:00
|
|
|
package cli
|
|
|
|
|
2021-12-02 04:00:33 +00:00
|
|
|
import (
|
|
|
|
"encoding/json"
|
2023-12-16 14:19:53 +00:00
|
|
|
"errors"
|
2021-12-02 04:00:33 +00:00
|
|
|
"fmt"
|
2023-12-16 14:19:53 +00:00
|
|
|
"log/slog"
|
2022-06-22 22:24:35 +00:00
|
|
|
"net/url"
|
2021-12-02 04:00:33 +00:00
|
|
|
"os"
|
2022-05-02 20:07:15 +00:00
|
|
|
"path/filepath"
|
2022-06-03 22:17:02 +00:00
|
|
|
"regexp"
|
|
|
|
"strings"
|
2022-06-18 16:09:09 +00:00
|
|
|
"sync"
|
2022-05-02 20:07:15 +00:00
|
|
|
|
2023-12-16 11:59:58 +00:00
|
|
|
resolver "github.com/satisfactorymodding/ficsit-resolver"
|
2021-12-02 04:00:33 +00:00
|
|
|
"github.com/spf13/viper"
|
2023-12-06 23:39:34 +00:00
|
|
|
"golang.org/x/sync/errgroup"
|
2022-10-14 16:11:16 +00:00
|
|
|
|
2023-12-06 04:47:41 +00:00
|
|
|
"github.com/satisfactorymodding/ficsit-cli/cli/cache"
|
2022-10-14 16:11:16 +00:00
|
|
|
"github.com/satisfactorymodding/ficsit-cli/cli/disk"
|
|
|
|
"github.com/satisfactorymodding/ficsit-cli/utils"
|
2021-12-02 04:00:33 +00:00
|
|
|
)
|
|
|
|
|
|
|
|
type InstallationsVersion int
|
|
|
|
|
|
|
|
const (
|
|
|
|
InitialInstallationsVersion = InstallationsVersion(iota)
|
|
|
|
|
|
|
|
// Always last
|
|
|
|
nextInstallationsVersion
|
|
|
|
)
|
|
|
|
|
|
|
|
type Installations struct {
|
|
|
|
SelectedInstallation string `json:"selected_installation"`
|
2022-10-14 16:11:16 +00:00
|
|
|
Installations []*Installation `json:"installations"`
|
|
|
|
Version InstallationsVersion `json:"version"`
|
2021-12-02 04:00:33 +00:00
|
|
|
}
|
|
|
|
|
2021-11-05 21:42:49 +00:00
|
|
|
type Installation struct {
|
2022-10-14 16:11:16 +00:00
|
|
|
DiskInstance disk.Disk `json:"-"`
|
2022-06-22 22:24:35 +00:00
|
|
|
Path string `json:"path"`
|
|
|
|
Profile string `json:"profile"`
|
2023-12-06 04:02:06 +00:00
|
|
|
Vanilla bool `json:"vanilla"`
|
2021-12-02 04:00:33 +00:00
|
|
|
}
|
|
|
|
|
|
|
|
func InitInstallations() (*Installations, error) {
|
2022-04-14 01:27:39 +00:00
|
|
|
localDir := viper.GetString("local-dir")
|
2021-12-02 04:00:33 +00:00
|
|
|
|
2022-06-05 01:56:46 +00:00
|
|
|
installationsFile := filepath.Join(localDir, viper.GetString("installations-file"))
|
2021-12-02 04:00:33 +00:00
|
|
|
_, err := os.Stat(installationsFile)
|
|
|
|
if err != nil {
|
|
|
|
if !os.IsNotExist(err) {
|
2023-12-16 14:19:53 +00:00
|
|
|
return nil, fmt.Errorf("failed to stat installations file: %w", err)
|
2021-12-02 04:00:33 +00:00
|
|
|
}
|
|
|
|
|
2022-04-14 01:27:39 +00:00
|
|
|
_, err := os.Stat(localDir)
|
2021-12-02 04:00:33 +00:00
|
|
|
if err != nil {
|
|
|
|
if !os.IsNotExist(err) {
|
2023-12-16 14:19:53 +00:00
|
|
|
return nil, fmt.Errorf("failed to read cache directory: %w", err)
|
2021-12-02 04:00:33 +00:00
|
|
|
}
|
|
|
|
|
2022-10-14 16:11:16 +00:00
|
|
|
err = os.MkdirAll(localDir, 0o755)
|
2021-12-02 04:00:33 +00:00
|
|
|
if err != nil {
|
2023-12-16 14:19:53 +00:00
|
|
|
return nil, fmt.Errorf("failed to create cache directory: %w", err)
|
2021-12-02 04:00:33 +00:00
|
|
|
}
|
|
|
|
}
|
|
|
|
|
|
|
|
emptyInstallations := Installations{
|
|
|
|
Version: nextInstallationsVersion - 1,
|
|
|
|
}
|
|
|
|
|
|
|
|
if err := emptyInstallations.Save(); err != nil {
|
2023-12-16 14:19:53 +00:00
|
|
|
return nil, fmt.Errorf("failed to save empty installations: %w", err)
|
2021-12-02 04:00:33 +00:00
|
|
|
}
|
|
|
|
}
|
|
|
|
|
|
|
|
installationsData, err := os.ReadFile(installationsFile)
|
|
|
|
if err != nil {
|
2023-12-16 14:19:53 +00:00
|
|
|
return nil, fmt.Errorf("failed to read installations: %w", err)
|
2021-12-02 04:00:33 +00:00
|
|
|
}
|
|
|
|
|
|
|
|
var installations Installations
|
|
|
|
if err := json.Unmarshal(installationsData, &installations); err != nil {
|
2023-12-16 14:19:53 +00:00
|
|
|
return nil, fmt.Errorf("failed to unmarshal installations: %w", err)
|
2021-12-02 04:00:33 +00:00
|
|
|
}
|
|
|
|
|
|
|
|
if installations.Version >= nextInstallationsVersion {
|
|
|
|
return nil, fmt.Errorf("unknown installations version: %d", installations.Version)
|
|
|
|
}
|
|
|
|
|
|
|
|
return &installations, nil
|
|
|
|
}
|
|
|
|
|
|
|
|
func (i *Installations) Save() error {
|
|
|
|
if viper.GetBool("dry-run") {
|
2023-12-16 14:19:53 +00:00
|
|
|
slog.Info("dry-run: skipping installation saving")
|
2021-12-02 04:00:33 +00:00
|
|
|
return nil
|
|
|
|
}
|
|
|
|
|
2022-06-05 01:56:46 +00:00
|
|
|
installationsFile := filepath.Join(viper.GetString("local-dir"), viper.GetString("installations-file"))
|
2021-12-02 04:00:33 +00:00
|
|
|
|
2023-12-16 14:19:53 +00:00
|
|
|
slog.Info("saving installations", slog.String("path", installationsFile))
|
2021-12-02 04:00:33 +00:00
|
|
|
|
|
|
|
installationsJSON, err := json.MarshalIndent(i, "", " ")
|
|
|
|
if err != nil {
|
2023-12-16 14:19:53 +00:00
|
|
|
return fmt.Errorf("failed to marshal installations: %w", err)
|
2021-12-02 04:00:33 +00:00
|
|
|
}
|
|
|
|
|
2022-10-14 16:11:16 +00:00
|
|
|
if err := os.WriteFile(installationsFile, installationsJSON, 0o755); err != nil {
|
2023-12-16 14:19:53 +00:00
|
|
|
return fmt.Errorf("failed to write installations: %w", err)
|
2021-12-02 04:00:33 +00:00
|
|
|
}
|
|
|
|
|
|
|
|
return nil
|
|
|
|
}
|
|
|
|
|
|
|
|
func (i *Installations) AddInstallation(ctx *GlobalContext, installPath string, profile string) (*Installation, error) {
|
2022-06-22 22:24:35 +00:00
|
|
|
parsed, err := url.Parse(installPath)
|
2022-05-02 20:07:15 +00:00
|
|
|
if err != nil {
|
2023-12-16 14:19:53 +00:00
|
|
|
return nil, fmt.Errorf("failed to parse path: %w", err)
|
2022-06-22 22:24:35 +00:00
|
|
|
}
|
|
|
|
|
2022-10-14 16:11:16 +00:00
|
|
|
absolutePath := installPath
|
2022-06-22 22:37:36 +00:00
|
|
|
if parsed.Scheme != "ftp" && parsed.Scheme != "sftp" {
|
2022-06-22 22:24:35 +00:00
|
|
|
absolutePath, err = filepath.Abs(installPath)
|
|
|
|
|
|
|
|
if err != nil {
|
2023-12-16 14:19:53 +00:00
|
|
|
return nil, fmt.Errorf("could not resolve absolute path of: %s: %w", installPath, err)
|
2022-06-22 22:24:35 +00:00
|
|
|
}
|
2022-05-02 20:07:15 +00:00
|
|
|
}
|
|
|
|
|
2021-12-02 04:00:33 +00:00
|
|
|
installation := &Installation{
|
2022-05-02 20:07:15 +00:00
|
|
|
Path: absolutePath,
|
2021-12-02 04:00:33 +00:00
|
|
|
Profile: profile,
|
2023-12-06 04:02:06 +00:00
|
|
|
Vanilla: false,
|
2021-12-02 04:00:33 +00:00
|
|
|
}
|
|
|
|
|
|
|
|
if err := installation.Validate(ctx); err != nil {
|
2023-12-16 14:19:53 +00:00
|
|
|
return nil, fmt.Errorf("failed to validate installation: %w", err)
|
2021-12-02 04:00:33 +00:00
|
|
|
}
|
|
|
|
|
|
|
|
found := false
|
2022-04-14 01:27:39 +00:00
|
|
|
for _, install := range i.Installations {
|
2022-06-22 22:24:35 +00:00
|
|
|
if filepath.Clean(installation.Path) == filepath.Clean(install.Path) {
|
|
|
|
found = true
|
2021-12-02 04:00:33 +00:00
|
|
|
break
|
|
|
|
}
|
|
|
|
}
|
|
|
|
|
|
|
|
if found {
|
|
|
|
return nil, errors.New("installation already present")
|
|
|
|
}
|
|
|
|
|
|
|
|
i.Installations = append(i.Installations, installation)
|
|
|
|
|
|
|
|
return installation, nil
|
|
|
|
}
|
|
|
|
|
|
|
|
func (i *Installations) GetInstallation(installPath string) *Installation {
|
|
|
|
for _, install := range i.Installations {
|
|
|
|
if install.Path == installPath {
|
|
|
|
return install
|
|
|
|
}
|
|
|
|
}
|
|
|
|
|
|
|
|
return nil
|
|
|
|
}
|
|
|
|
|
2022-04-14 01:27:39 +00:00
|
|
|
func (i *Installations) DeleteInstallation(installPath string) error {
|
|
|
|
found := -1
|
|
|
|
for j, install := range i.Installations {
|
|
|
|
if install.Path == installPath {
|
|
|
|
found = j
|
|
|
|
break
|
|
|
|
}
|
|
|
|
}
|
|
|
|
|
|
|
|
if found == -1 {
|
|
|
|
return errors.New("installation not found")
|
|
|
|
}
|
|
|
|
|
|
|
|
i.Installations = append(i.Installations[:found], i.Installations[found+1:]...)
|
|
|
|
|
|
|
|
return nil
|
|
|
|
}
|
|
|
|
|
2024-05-05 21:01:17 +00:00
|
|
|
var rootExecutables = []string{"FactoryGame.exe", "FactoryServer.sh", "FactoryServer.exe"}
|
|
|
|
|
2021-12-02 04:00:33 +00:00
|
|
|
func (i *Installation) Validate(ctx *GlobalContext) error {
|
|
|
|
found := false
|
|
|
|
for _, p := range ctx.Profiles.Profiles {
|
|
|
|
if p.Name == i.Profile {
|
|
|
|
found = true
|
|
|
|
break
|
|
|
|
}
|
|
|
|
}
|
|
|
|
|
|
|
|
if !found {
|
|
|
|
return errors.New("profile not found")
|
|
|
|
}
|
|
|
|
|
2022-06-22 22:24:35 +00:00
|
|
|
d, err := i.GetDisk()
|
|
|
|
if err != nil {
|
|
|
|
return err
|
|
|
|
}
|
|
|
|
|
2022-04-14 01:27:39 +00:00
|
|
|
foundExecutable := false
|
|
|
|
|
2024-05-05 21:01:17 +00:00
|
|
|
var checkWait errgroup.Group
|
2022-04-14 01:27:39 +00:00
|
|
|
|
2024-05-05 21:01:17 +00:00
|
|
|
for _, executable := range rootExecutables {
|
|
|
|
e := executable
|
|
|
|
checkWait.Go(func() error {
|
|
|
|
exists, err := d.Exists(filepath.Join(i.BasePath(), e))
|
|
|
|
if !exists {
|
|
|
|
if err != nil {
|
|
|
|
return fmt.Errorf("failed reading %s: %w", e, err)
|
|
|
|
}
|
|
|
|
} else {
|
|
|
|
foundExecutable = true
|
|
|
|
}
|
|
|
|
return nil
|
|
|
|
})
|
2022-04-14 01:27:39 +00:00
|
|
|
}
|
|
|
|
|
2024-05-05 21:01:17 +00:00
|
|
|
if err = checkWait.Wait(); err != nil {
|
|
|
|
return err //nolint:wrapcheck
|
2022-04-14 01:27:39 +00:00
|
|
|
}
|
|
|
|
|
|
|
|
if !foundExecutable {
|
2022-06-22 22:24:35 +00:00
|
|
|
return errors.New("did not find game executable in " + i.BasePath())
|
2022-04-14 01:27:39 +00:00
|
|
|
}
|
2021-12-02 04:00:33 +00:00
|
|
|
|
|
|
|
return nil
|
|
|
|
}
|
|
|
|
|
2022-06-03 22:17:02 +00:00
|
|
|
var (
|
|
|
|
lockFileCleaner = regexp.MustCompile(`[^a-zA-Z\d]]`)
|
|
|
|
matchFirstCap = regexp.MustCompile(`(.)([A-Z][a-z]+)`)
|
|
|
|
matchAllCap = regexp.MustCompile(`([a-z\d])([A-Z])`)
|
|
|
|
)
|
2021-12-02 04:00:33 +00:00
|
|
|
|
2024-05-05 21:01:17 +00:00
|
|
|
func (i *Installation) lockFilePath(ctx *GlobalContext, platform *Platform) string {
|
2022-06-03 22:17:02 +00:00
|
|
|
lockFileName := ctx.Profiles.Profiles[i.Profile].Name
|
|
|
|
lockFileName = matchFirstCap.ReplaceAllString(lockFileName, "${1}_${2}")
|
|
|
|
lockFileName = matchAllCap.ReplaceAllString(lockFileName, "${1}_${2}")
|
|
|
|
lockFileName = lockFileCleaner.ReplaceAllLiteralString(lockFileName, "-")
|
|
|
|
lockFileName = strings.ToLower(lockFileName) + "-lock.json"
|
|
|
|
|
2024-05-05 21:01:17 +00:00
|
|
|
return filepath.Join(i.BasePath(), platform.LockfilePath, lockFileName)
|
2022-06-03 22:17:02 +00:00
|
|
|
}
|
|
|
|
|
2024-05-05 21:01:17 +00:00
|
|
|
func (i *Installation) lockfile(ctx *GlobalContext, platform *Platform) (*resolver.LockFile, error) {
|
|
|
|
lockfilePath := i.lockFilePath(ctx, platform)
|
2022-05-02 20:07:15 +00:00
|
|
|
|
2022-06-22 22:24:35 +00:00
|
|
|
d, err := i.GetDisk()
|
|
|
|
if err != nil {
|
|
|
|
return nil, err
|
|
|
|
}
|
|
|
|
|
2023-12-28 00:13:09 +00:00
|
|
|
exists, err := d.Exists(lockfilePath)
|
|
|
|
if err != nil {
|
|
|
|
return nil, err
|
|
|
|
}
|
|
|
|
|
|
|
|
if !exists {
|
|
|
|
return nil, nil
|
|
|
|
}
|
|
|
|
|
2023-12-16 11:59:58 +00:00
|
|
|
var lockFile *resolver.LockFile
|
2022-06-22 22:24:35 +00:00
|
|
|
lockFileJSON, err := d.Read(lockfilePath)
|
2022-05-02 20:07:15 +00:00
|
|
|
if err != nil {
|
2023-12-28 00:13:09 +00:00
|
|
|
return nil, fmt.Errorf("failed reading lockfile: %w", err)
|
|
|
|
}
|
|
|
|
|
|
|
|
if err := json.Unmarshal(lockFileJSON, &lockFile); err != nil {
|
|
|
|
return nil, fmt.Errorf("failed parsing lockfile: %w", err)
|
2022-05-02 20:07:15 +00:00
|
|
|
}
|
|
|
|
|
2022-06-03 22:17:02 +00:00
|
|
|
return lockFile, nil
|
|
|
|
}
|
|
|
|
|
2024-05-05 21:01:17 +00:00
|
|
|
func (i *Installation) writeLockFile(ctx *GlobalContext, platform *Platform, lockfile *resolver.LockFile) error {
|
|
|
|
lockfilePath := i.lockFilePath(ctx, platform)
|
2022-06-03 22:17:02 +00:00
|
|
|
|
2023-12-06 04:02:06 +00:00
|
|
|
d, err := i.GetDisk()
|
2022-06-03 22:17:02 +00:00
|
|
|
if err != nil {
|
2023-12-06 04:02:06 +00:00
|
|
|
return err
|
2022-06-03 22:17:02 +00:00
|
|
|
}
|
|
|
|
|
2023-12-06 04:02:06 +00:00
|
|
|
lockfileDir := filepath.Dir(lockfilePath)
|
2023-12-28 00:13:09 +00:00
|
|
|
if exists, err := d.Exists(lockfileDir); !exists {
|
|
|
|
if err != nil {
|
|
|
|
return err
|
|
|
|
}
|
|
|
|
|
2023-12-06 04:02:06 +00:00
|
|
|
if err := d.MkDir(lockfileDir); err != nil {
|
2023-12-16 14:19:53 +00:00
|
|
|
return fmt.Errorf("failed creating lockfile directory: %w", err)
|
2023-12-06 04:02:06 +00:00
|
|
|
}
|
|
|
|
}
|
|
|
|
|
|
|
|
marshaledLockfile, err := json.MarshalIndent(lockfile, "", " ")
|
2022-06-22 22:24:35 +00:00
|
|
|
if err != nil {
|
2023-12-16 14:19:53 +00:00
|
|
|
return fmt.Errorf("failed to serialize lockfile json: %w", err)
|
2022-06-22 22:24:35 +00:00
|
|
|
}
|
|
|
|
|
|
|
|
if err := d.Write(lockfilePath, marshaledLockfile); err != nil {
|
2023-12-16 14:19:53 +00:00
|
|
|
return fmt.Errorf("failed writing lockfile: %w", err)
|
2022-06-03 22:17:02 +00:00
|
|
|
}
|
|
|
|
|
|
|
|
return nil
|
|
|
|
}
|
|
|
|
|
2023-12-06 19:37:33 +00:00
|
|
|
func (i *Installation) Wipe() error {
|
2023-12-28 00:13:09 +00:00
|
|
|
slog.Info("wiping installation", slog.String("path", i.Path))
|
|
|
|
|
2023-12-06 19:37:33 +00:00
|
|
|
d, err := i.GetDisk()
|
|
|
|
if err != nil {
|
|
|
|
return err
|
|
|
|
}
|
|
|
|
|
|
|
|
modsDirectory := filepath.Join(i.BasePath(), "FactoryGame", "Mods")
|
|
|
|
if err := d.Remove(modsDirectory); err != nil {
|
2023-12-16 14:19:53 +00:00
|
|
|
return fmt.Errorf("failed removing Mods directory: %w", err)
|
2023-12-06 19:37:33 +00:00
|
|
|
}
|
|
|
|
|
|
|
|
return nil
|
|
|
|
}
|
|
|
|
|
2024-05-05 21:01:17 +00:00
|
|
|
func (i *Installation) resolveProfile(ctx *GlobalContext, platform *Platform) (*resolver.LockFile, error) {
|
|
|
|
lockFile, err := i.lockfile(ctx, platform)
|
2023-12-06 04:02:06 +00:00
|
|
|
if err != nil {
|
|
|
|
return nil, err
|
|
|
|
}
|
|
|
|
|
2023-12-16 11:59:58 +00:00
|
|
|
depResolver := resolver.NewDependencyResolver(ctx.Provider, viper.GetString("api-base"))
|
2023-12-06 04:02:06 +00:00
|
|
|
|
2024-05-05 21:01:17 +00:00
|
|
|
gameVersion, err := i.getGameVersion(platform)
|
2023-12-06 04:02:06 +00:00
|
|
|
if err != nil {
|
2023-12-16 14:19:53 +00:00
|
|
|
return nil, fmt.Errorf("failed to detect game version: %w", err)
|
2023-12-06 04:02:06 +00:00
|
|
|
}
|
|
|
|
|
2023-12-16 11:59:58 +00:00
|
|
|
lockfile, err := ctx.Profiles.Profiles[i.Profile].Resolve(depResolver, lockFile, gameVersion)
|
2023-12-06 04:02:06 +00:00
|
|
|
if err != nil {
|
2023-12-16 14:19:53 +00:00
|
|
|
return nil, fmt.Errorf("could not resolve mods: %w", err)
|
2023-12-06 04:02:06 +00:00
|
|
|
}
|
|
|
|
|
2024-05-05 21:01:17 +00:00
|
|
|
if err := i.writeLockFile(ctx, platform, lockfile); err != nil {
|
2023-12-16 14:19:53 +00:00
|
|
|
return nil, fmt.Errorf("failed to write lockfile: %w", err)
|
2023-12-06 04:02:06 +00:00
|
|
|
}
|
|
|
|
|
|
|
|
return lockfile, nil
|
|
|
|
}
|
|
|
|
|
2024-05-05 21:01:17 +00:00
|
|
|
func (i *Installation) GetGameVersion(ctx *GlobalContext) (int, error) {
|
|
|
|
platform, err := i.GetPlatform(ctx)
|
|
|
|
if err != nil {
|
|
|
|
return 0, err
|
|
|
|
}
|
|
|
|
return i.getGameVersion(platform)
|
|
|
|
}
|
|
|
|
|
|
|
|
func (i *Installation) LockFile(ctx *GlobalContext) (*resolver.LockFile, error) {
|
|
|
|
platform, err := i.GetPlatform(ctx)
|
|
|
|
if err != nil {
|
|
|
|
return nil, err
|
|
|
|
}
|
|
|
|
return i.lockfile(ctx, platform)
|
|
|
|
}
|
|
|
|
|
|
|
|
func (i *Installation) WriteLockFile(ctx *GlobalContext, lockfile *resolver.LockFile) error {
|
|
|
|
platform, err := i.GetPlatform(ctx)
|
|
|
|
if err != nil {
|
|
|
|
return err
|
|
|
|
}
|
|
|
|
return i.writeLockFile(ctx, platform, lockfile)
|
|
|
|
}
|
|
|
|
|
2023-12-06 23:39:34 +00:00
|
|
|
type InstallUpdateType string
|
|
|
|
|
|
|
|
var (
|
|
|
|
InstallUpdateTypeOverall InstallUpdateType = "overall"
|
|
|
|
InstallUpdateTypeModDownload InstallUpdateType = "download"
|
|
|
|
InstallUpdateTypeModExtract InstallUpdateType = "extract"
|
|
|
|
InstallUpdateTypeModComplete InstallUpdateType = "complete"
|
|
|
|
)
|
|
|
|
|
2022-06-03 22:17:02 +00:00
|
|
|
type InstallUpdate struct {
|
2023-12-06 23:39:34 +00:00
|
|
|
Type InstallUpdateType
|
|
|
|
Item InstallUpdateItem
|
|
|
|
Progress utils.GenericProgress
|
2022-06-03 22:17:02 +00:00
|
|
|
}
|
|
|
|
|
2023-12-06 23:39:34 +00:00
|
|
|
type InstallUpdateItem struct {
|
|
|
|
Mod string
|
|
|
|
Version string
|
|
|
|
}
|
|
|
|
|
|
|
|
func (i *Installation) Install(ctx *GlobalContext, updates chan<- InstallUpdate) error {
|
2023-12-07 16:57:31 +00:00
|
|
|
platform, err := i.GetPlatform(ctx)
|
|
|
|
if err != nil {
|
2023-12-16 14:19:53 +00:00
|
|
|
return fmt.Errorf("failed to detect platform: %w", err)
|
2023-12-07 16:57:31 +00:00
|
|
|
}
|
|
|
|
|
2023-12-16 11:59:58 +00:00
|
|
|
lockfile := resolver.NewLockfile()
|
2022-05-02 20:07:15 +00:00
|
|
|
|
2023-12-06 04:02:06 +00:00
|
|
|
if !i.Vanilla {
|
|
|
|
var err error
|
2024-05-05 21:01:17 +00:00
|
|
|
lockfile, err = i.resolveProfile(ctx, platform)
|
2023-12-06 04:02:06 +00:00
|
|
|
if err != nil {
|
2023-12-16 14:19:53 +00:00
|
|
|
return fmt.Errorf("failed to resolve lockfile: %w", err)
|
2023-12-06 04:02:06 +00:00
|
|
|
}
|
2022-05-02 20:07:15 +00:00
|
|
|
}
|
|
|
|
|
2022-06-22 22:24:35 +00:00
|
|
|
d, err := i.GetDisk()
|
|
|
|
if err != nil {
|
|
|
|
return err
|
|
|
|
}
|
|
|
|
|
|
|
|
modsDirectory := filepath.Join(i.BasePath(), "FactoryGame", "Mods")
|
|
|
|
if err := d.MkDir(modsDirectory); err != nil {
|
2023-12-16 14:19:53 +00:00
|
|
|
return fmt.Errorf("failed creating Mods directory: %w", err)
|
2022-05-02 20:07:15 +00:00
|
|
|
}
|
|
|
|
|
2022-06-22 22:24:35 +00:00
|
|
|
dir, err := d.ReadDir(modsDirectory)
|
2022-06-03 22:17:02 +00:00
|
|
|
if err != nil {
|
2023-12-16 14:19:53 +00:00
|
|
|
return fmt.Errorf("failed to read mods directory: %w", err)
|
2022-06-03 22:17:02 +00:00
|
|
|
}
|
|
|
|
|
2024-05-05 21:01:17 +00:00
|
|
|
var deleteWait errgroup.Group
|
2022-06-03 22:17:02 +00:00
|
|
|
for _, entry := range dir {
|
|
|
|
if entry.IsDir() {
|
2023-12-07 16:57:31 +00:00
|
|
|
if _, ok := lockfile.Mods[entry.Name()]; !ok {
|
2024-05-05 21:01:17 +00:00
|
|
|
modName := entry.Name()
|
|
|
|
modDir := filepath.Join(modsDirectory, modName)
|
|
|
|
deleteWait.Go(func() error {
|
|
|
|
exists, err := d.Exists(filepath.Join(modDir, ".smm"))
|
|
|
|
if err != nil {
|
|
|
|
return err
|
|
|
|
}
|
2023-12-28 00:13:09 +00:00
|
|
|
|
2024-05-05 21:01:17 +00:00
|
|
|
if exists {
|
|
|
|
slog.Info("deleting mod", slog.String("mod_reference", modName))
|
|
|
|
if err := d.Remove(modDir); err != nil {
|
|
|
|
return fmt.Errorf("failed to delete mod directory: %w", err)
|
|
|
|
}
|
2022-06-07 23:36:28 +00:00
|
|
|
}
|
2024-05-05 21:01:17 +00:00
|
|
|
|
|
|
|
return nil
|
|
|
|
})
|
2022-06-03 22:17:02 +00:00
|
|
|
}
|
|
|
|
}
|
|
|
|
}
|
|
|
|
|
2024-05-05 21:01:17 +00:00
|
|
|
if err := deleteWait.Wait(); err != nil {
|
|
|
|
return fmt.Errorf("failed to remove old mods: %w", err)
|
|
|
|
}
|
|
|
|
|
2023-12-16 14:19:53 +00:00
|
|
|
slog.Info("starting installation", slog.Int("concurrency", viper.GetInt("concurrent-downloads")), slog.String("path", i.Path))
|
2022-06-18 16:09:09 +00:00
|
|
|
|
2023-12-06 23:39:34 +00:00
|
|
|
errg := errgroup.Group{}
|
|
|
|
channelUsers := sync.WaitGroup{}
|
|
|
|
downloadSemaphore := make(chan int, viper.GetInt("concurrent-downloads"))
|
|
|
|
defer close(downloadSemaphore)
|
2022-06-18 16:09:09 +00:00
|
|
|
|
2023-12-06 23:39:34 +00:00
|
|
|
var modComplete chan int
|
|
|
|
if updates != nil {
|
|
|
|
channelUsers.Add(1)
|
|
|
|
modComplete = make(chan int)
|
|
|
|
defer close(modComplete)
|
2022-06-18 16:09:09 +00:00
|
|
|
go func() {
|
2023-12-06 23:39:34 +00:00
|
|
|
defer channelUsers.Done()
|
|
|
|
completed := 0
|
|
|
|
for range modComplete {
|
|
|
|
completed++
|
|
|
|
overallUpdate := InstallUpdate{
|
|
|
|
Type: InstallUpdateTypeOverall,
|
|
|
|
Progress: utils.GenericProgress{
|
|
|
|
Completed: int64(completed),
|
2023-12-07 16:57:31 +00:00
|
|
|
Total: int64(len(lockfile.Mods)),
|
2023-12-06 23:39:34 +00:00
|
|
|
},
|
2022-06-18 16:09:09 +00:00
|
|
|
}
|
2023-12-06 23:39:34 +00:00
|
|
|
updates <- overallUpdate
|
2022-06-18 16:09:09 +00:00
|
|
|
}
|
|
|
|
}()
|
|
|
|
}
|
|
|
|
|
2023-12-07 16:57:31 +00:00
|
|
|
for modReference, version := range lockfile.Mods {
|
2023-12-06 23:39:34 +00:00
|
|
|
channelUsers.Add(1)
|
|
|
|
modReference := modReference
|
|
|
|
version := version
|
|
|
|
errg.Go(func() error {
|
|
|
|
defer channelUsers.Done()
|
2023-12-07 16:57:31 +00:00
|
|
|
|
|
|
|
target, ok := version.Targets[platform.TargetName]
|
|
|
|
if !ok {
|
2023-12-16 14:19:53 +00:00
|
|
|
return fmt.Errorf("%s@%s not available for %s", modReference, version.Version, platform.TargetName)
|
2023-12-07 16:57:31 +00:00
|
|
|
}
|
|
|
|
|
2023-12-06 23:39:34 +00:00
|
|
|
// Only install if a link is provided, otherwise assume mod is already installed
|
2023-12-07 16:57:31 +00:00
|
|
|
if target.Link != "" {
|
2023-12-29 19:27:49 +00:00
|
|
|
err := downloadAndExtractMod(modReference, version.Version, target.Link, target.Hash, platform.TargetName, modsDirectory, updates, downloadSemaphore, d)
|
2023-12-06 23:39:34 +00:00
|
|
|
if err != nil {
|
2023-12-16 14:19:53 +00:00
|
|
|
return fmt.Errorf("failed to install %s@%s: %w", modReference, version.Version, err)
|
2023-12-06 23:39:34 +00:00
|
|
|
}
|
2022-06-03 22:17:02 +00:00
|
|
|
}
|
|
|
|
|
2023-12-06 23:39:34 +00:00
|
|
|
if modComplete != nil {
|
|
|
|
modComplete <- 1
|
2022-05-02 20:07:15 +00:00
|
|
|
}
|
2023-12-06 23:39:34 +00:00
|
|
|
return nil
|
|
|
|
})
|
|
|
|
}
|
2022-05-02 20:07:15 +00:00
|
|
|
|
2023-12-28 00:13:09 +00:00
|
|
|
if err := errg.Wait(); err != nil {
|
|
|
|
return fmt.Errorf("failed to install mods: %w", err)
|
|
|
|
}
|
|
|
|
|
2023-12-06 23:39:34 +00:00
|
|
|
if updates != nil {
|
2023-12-28 00:13:09 +00:00
|
|
|
if i.Vanilla {
|
|
|
|
updates <- InstallUpdate{
|
|
|
|
Type: InstallUpdateTypeOverall,
|
|
|
|
Progress: utils.GenericProgress{
|
|
|
|
Completed: 1,
|
|
|
|
Total: 1,
|
|
|
|
},
|
|
|
|
}
|
|
|
|
}
|
|
|
|
|
2023-12-06 23:39:34 +00:00
|
|
|
go func() {
|
|
|
|
channelUsers.Wait()
|
|
|
|
close(updates)
|
|
|
|
}()
|
|
|
|
}
|
2022-05-02 20:07:15 +00:00
|
|
|
|
2023-12-28 00:13:09 +00:00
|
|
|
slog.Info("installation completed", slog.String("path", i.Path))
|
2022-05-02 20:07:15 +00:00
|
|
|
|
2023-12-06 04:02:06 +00:00
|
|
|
return nil
|
2022-04-14 01:27:39 +00:00
|
|
|
}
|
|
|
|
|
2023-12-06 19:37:33 +00:00
|
|
|
func (i *Installation) UpdateMods(ctx *GlobalContext, mods []string) error {
|
2024-05-05 21:01:17 +00:00
|
|
|
platform, err := i.GetPlatform(ctx)
|
|
|
|
if err != nil {
|
|
|
|
return err
|
2023-12-06 19:37:33 +00:00
|
|
|
}
|
|
|
|
|
2024-05-05 21:01:17 +00:00
|
|
|
lockFile, err := i.lockfile(ctx, platform)
|
2023-12-06 19:37:33 +00:00
|
|
|
if err != nil {
|
2023-12-16 14:19:53 +00:00
|
|
|
return fmt.Errorf("failed to read lock file: %w", err)
|
2023-12-06 19:37:33 +00:00
|
|
|
}
|
|
|
|
|
2023-12-16 11:59:58 +00:00
|
|
|
resolver := resolver.NewDependencyResolver(ctx.Provider, viper.GetString("api-base"))
|
2023-12-06 19:37:33 +00:00
|
|
|
|
2024-05-05 21:01:17 +00:00
|
|
|
gameVersion, err := i.getGameVersion(platform)
|
2023-12-06 19:37:33 +00:00
|
|
|
if err != nil {
|
2023-12-16 14:19:53 +00:00
|
|
|
return fmt.Errorf("failed to detect game version: %w", err)
|
2023-12-06 19:37:33 +00:00
|
|
|
}
|
|
|
|
|
|
|
|
profile := ctx.Profiles.GetProfile(i.Profile)
|
|
|
|
if profile == nil {
|
|
|
|
return errors.New("could not find profile " + i.Profile)
|
|
|
|
}
|
|
|
|
|
|
|
|
for _, modReference := range mods {
|
|
|
|
lockFile = lockFile.Remove(modReference)
|
|
|
|
}
|
|
|
|
|
|
|
|
newLockFile, err := profile.Resolve(resolver, lockFile, gameVersion)
|
|
|
|
if err != nil {
|
2023-12-16 14:19:53 +00:00
|
|
|
return fmt.Errorf("failed to resolve dependencies: %w", err)
|
2023-12-06 19:37:33 +00:00
|
|
|
}
|
|
|
|
|
2024-05-05 21:01:17 +00:00
|
|
|
if err := i.writeLockFile(ctx, platform, newLockFile); err != nil {
|
2023-12-16 14:19:53 +00:00
|
|
|
return fmt.Errorf("failed to write lock file: %w", err)
|
2023-12-06 19:37:33 +00:00
|
|
|
}
|
|
|
|
|
|
|
|
return nil
|
|
|
|
}
|
|
|
|
|
2023-12-29 19:27:49 +00:00
|
|
|
func downloadAndExtractMod(modReference string, version string, link string, hash string, target string, modsDirectory string, updates chan<- InstallUpdate, downloadSemaphore chan int, d disk.Disk) error {
|
2023-12-06 23:39:34 +00:00
|
|
|
var downloadUpdates chan utils.GenericProgress
|
|
|
|
|
2023-12-28 00:13:09 +00:00
|
|
|
var wg sync.WaitGroup
|
2023-12-06 23:39:34 +00:00
|
|
|
if updates != nil {
|
|
|
|
// Forward the inner updates as InstallUpdates
|
|
|
|
downloadUpdates = make(chan utils.GenericProgress)
|
|
|
|
|
2023-12-28 00:13:09 +00:00
|
|
|
wg.Add(1)
|
2023-12-06 23:39:34 +00:00
|
|
|
go func() {
|
2023-12-28 00:13:09 +00:00
|
|
|
defer wg.Done()
|
2023-12-06 23:39:34 +00:00
|
|
|
for up := range downloadUpdates {
|
|
|
|
updates <- InstallUpdate{
|
|
|
|
Item: InstallUpdateItem{
|
|
|
|
Mod: modReference,
|
|
|
|
Version: version,
|
|
|
|
},
|
|
|
|
Type: InstallUpdateTypeModDownload,
|
|
|
|
Progress: up,
|
|
|
|
}
|
|
|
|
}
|
|
|
|
}()
|
|
|
|
}
|
|
|
|
|
2023-12-16 14:19:53 +00:00
|
|
|
slog.Info("downloading mod", slog.String("mod_reference", modReference), slog.String("version", version), slog.String("link", link))
|
2023-12-29 19:27:49 +00:00
|
|
|
reader, size, err := cache.DownloadOrCache(modReference+"_"+version+"_"+target+".zip", hash, link, downloadUpdates, downloadSemaphore)
|
2023-12-06 23:39:34 +00:00
|
|
|
if err != nil {
|
2023-12-16 14:19:53 +00:00
|
|
|
return fmt.Errorf("failed to download %s from: %s: %w", modReference, link, err)
|
2023-12-06 23:39:34 +00:00
|
|
|
}
|
|
|
|
|
2023-12-07 16:57:31 +00:00
|
|
|
defer reader.Close()
|
|
|
|
|
2023-12-06 23:39:34 +00:00
|
|
|
var extractUpdates chan utils.GenericProgress
|
|
|
|
|
|
|
|
if updates != nil {
|
|
|
|
// Forward the inner updates as InstallUpdates
|
|
|
|
extractUpdates = make(chan utils.GenericProgress)
|
|
|
|
|
|
|
|
wg.Add(1)
|
|
|
|
go func() {
|
|
|
|
defer wg.Done()
|
|
|
|
for up := range extractUpdates {
|
2023-12-07 16:57:31 +00:00
|
|
|
updates <- InstallUpdate{
|
2023-12-06 23:39:34 +00:00
|
|
|
Item: InstallUpdateItem{
|
|
|
|
Mod: modReference,
|
|
|
|
Version: version,
|
|
|
|
},
|
|
|
|
Type: InstallUpdateTypeModExtract,
|
|
|
|
Progress: up,
|
|
|
|
}
|
|
|
|
}
|
|
|
|
}()
|
|
|
|
}
|
|
|
|
|
2023-12-16 14:19:53 +00:00
|
|
|
slog.Info("extracting mod", slog.String("mod_reference", modReference), slog.String("version", version), slog.String("link", link))
|
2023-12-06 23:39:34 +00:00
|
|
|
if err := utils.ExtractMod(reader, size, filepath.Join(modsDirectory, modReference), hash, extractUpdates, d); err != nil {
|
2023-12-16 14:19:53 +00:00
|
|
|
return fmt.Errorf("could not extract %s: %w", modReference, err)
|
2023-12-06 23:39:34 +00:00
|
|
|
}
|
|
|
|
|
|
|
|
if updates != nil {
|
2023-12-13 23:34:01 +00:00
|
|
|
close(downloadUpdates)
|
|
|
|
close(extractUpdates)
|
|
|
|
|
2023-12-07 16:57:31 +00:00
|
|
|
updates <- InstallUpdate{
|
2023-12-06 23:39:34 +00:00
|
|
|
Type: InstallUpdateTypeModComplete,
|
|
|
|
Item: InstallUpdateItem{
|
|
|
|
Mod: modReference,
|
|
|
|
Version: version,
|
|
|
|
},
|
|
|
|
}
|
|
|
|
}
|
|
|
|
|
|
|
|
wg.Wait()
|
|
|
|
|
|
|
|
return nil
|
|
|
|
}
|
|
|
|
|
2022-04-14 01:27:39 +00:00
|
|
|
func (i *Installation) SetProfile(ctx *GlobalContext, profile string) error {
|
|
|
|
found := false
|
|
|
|
for _, p := range ctx.Profiles.Profiles {
|
|
|
|
if p.Name == profile {
|
|
|
|
found = true
|
|
|
|
break
|
|
|
|
}
|
|
|
|
}
|
|
|
|
|
|
|
|
if !found {
|
|
|
|
return errors.New("could not find profile: " + profile)
|
|
|
|
}
|
|
|
|
|
|
|
|
i.Profile = profile
|
|
|
|
|
2021-12-02 04:00:33 +00:00
|
|
|
return nil
|
2021-11-05 21:42:49 +00:00
|
|
|
}
|
2022-05-02 20:07:15 +00:00
|
|
|
|
|
|
|
type gameVersionFile struct {
|
2022-10-14 16:11:16 +00:00
|
|
|
BranchName string `json:"BranchName"`
|
|
|
|
BuildID string `json:"BuildId"`
|
2022-05-02 20:07:15 +00:00
|
|
|
MajorVersion int `json:"MajorVersion"`
|
|
|
|
MinorVersion int `json:"MinorVersion"`
|
|
|
|
PatchVersion int `json:"PatchVersion"`
|
|
|
|
Changelist int `json:"Changelist"`
|
|
|
|
CompatibleChangelist int `json:"CompatibleChangelist"`
|
|
|
|
IsLicenseeVersion int `json:"IsLicenseeVersion"`
|
|
|
|
IsPromotedBuild int `json:"IsPromotedBuild"`
|
|
|
|
}
|
|
|
|
|
2024-05-05 21:01:17 +00:00
|
|
|
func (i *Installation) getGameVersion(platform *Platform) (int, error) {
|
2022-06-22 22:24:35 +00:00
|
|
|
d, err := i.GetDisk()
|
2022-05-02 20:07:15 +00:00
|
|
|
if err != nil {
|
2022-06-22 22:24:35 +00:00
|
|
|
return 0, err
|
|
|
|
}
|
|
|
|
|
|
|
|
fullPath := filepath.Join(i.BasePath(), platform.VersionPath)
|
2023-12-28 00:13:09 +00:00
|
|
|
|
2022-06-22 22:24:35 +00:00
|
|
|
file, err := d.Read(fullPath)
|
|
|
|
if err != nil {
|
2023-12-16 14:19:53 +00:00
|
|
|
return 0, fmt.Errorf("failed reading version file: %w", err)
|
2022-05-02 20:07:15 +00:00
|
|
|
}
|
|
|
|
|
|
|
|
var versionData gameVersionFile
|
|
|
|
if err := json.Unmarshal(file, &versionData); err != nil {
|
2023-12-16 14:19:53 +00:00
|
|
|
return 0, fmt.Errorf("failed to parse version file json: %w", err)
|
2022-05-02 20:07:15 +00:00
|
|
|
}
|
|
|
|
|
|
|
|
return versionData.Changelist, nil
|
|
|
|
}
|
|
|
|
|
|
|
|
func (i *Installation) GetPlatform(ctx *GlobalContext) (*Platform, error) {
|
|
|
|
if err := i.Validate(ctx); err != nil {
|
2023-12-16 14:19:53 +00:00
|
|
|
return nil, fmt.Errorf("failed to validate installation: %w", err)
|
2022-05-02 20:07:15 +00:00
|
|
|
}
|
|
|
|
|
2022-06-22 22:24:35 +00:00
|
|
|
d, err := i.GetDisk()
|
|
|
|
if err != nil {
|
|
|
|
return nil, err
|
|
|
|
}
|
|
|
|
|
2022-05-02 20:07:15 +00:00
|
|
|
for _, platform := range platforms {
|
2022-06-22 22:24:35 +00:00
|
|
|
fullPath := filepath.Join(i.BasePath(), platform.VersionPath)
|
2023-12-28 00:13:09 +00:00
|
|
|
exists, err := d.Exists(fullPath)
|
|
|
|
if !exists {
|
|
|
|
if err != nil {
|
|
|
|
return nil, fmt.Errorf("failed detecting version file: %w", err)
|
2022-05-02 20:07:15 +00:00
|
|
|
}
|
2023-12-28 00:13:09 +00:00
|
|
|
continue
|
2022-05-02 20:07:15 +00:00
|
|
|
}
|
|
|
|
return &platform, nil
|
|
|
|
}
|
|
|
|
|
|
|
|
return nil, errors.New("no platform detected")
|
|
|
|
}
|
2022-06-22 22:24:35 +00:00
|
|
|
|
|
|
|
func (i *Installation) GetDisk() (disk.Disk, error) {
|
|
|
|
if i.DiskInstance != nil {
|
|
|
|
return i.DiskInstance, nil
|
|
|
|
}
|
|
|
|
|
|
|
|
var err error
|
|
|
|
i.DiskInstance, err = disk.FromPath(i.Path)
|
|
|
|
return i.DiskInstance, err
|
|
|
|
}
|
|
|
|
|
|
|
|
func (i *Installation) BasePath() string {
|
|
|
|
parsed, err := url.Parse(i.Path)
|
|
|
|
if err != nil {
|
|
|
|
return i.Path
|
|
|
|
}
|
2022-06-22 23:05:35 +00:00
|
|
|
|
|
|
|
if parsed.Scheme != "ftp" && parsed.Scheme != "sftp" {
|
|
|
|
return i.Path
|
|
|
|
}
|
|
|
|
|
2022-06-22 22:24:35 +00:00
|
|
|
return parsed.Path
|
|
|
|
}
|