diff --git a/.github/workflows/push.yaml b/.github/workflows/push.yaml index 8b64f20..894c3ac 100644 --- a/.github/workflows/push.yaml +++ b/.github/workflows/push.yaml @@ -82,6 +82,10 @@ jobs: - name: Install Satisfactory Dedicated Server run: steamcmd +login anonymous +force_install_dir ${{ github.workspace }}/SatisfactoryDedicatedServer +app_update 1690800 validate +quit + - name: Change directory permissions + if: ${{ matrix.os == 'ubuntu-latest' }} + run: mkdir -p ${{ github.workspace }}/SatisfactoryDedicatedServer/FactoryGame/Mods && chmod -R 777 ${{ github.workspace }}/SatisfactoryDedicatedServer + - name: List directory (linux) if: ${{ matrix.os == 'ubuntu-latest' }} run: ls -lR @@ -90,6 +94,10 @@ jobs: if: ${{ matrix.os == 'windows-latest' }} run: tree /F + - name: Boot ftp and sftp + if: ${{ matrix.os == 'ubuntu-latest' }} + run: docker-compose -f docker-compose-test.yml up -d + - name: Download GQL schema run: "npx graphqurl https://api.ficsit.dev/v2/query --introspect -H 'content-type: application/json' > schema.graphql" diff --git a/.gitignore b/.gitignore index 2544bdd..6961396 100644 --- a/.gitignore +++ b/.gitignore @@ -128,4 +128,5 @@ dist/ /.graphqlconfig schema.graphql *.log -.direnv \ No newline at end of file +.direnv +/SatisfactoryDedicatedServer \ No newline at end of file diff --git a/cfg/test_defaults.go b/cfg/test_defaults.go index d6ac288..c575700 100644 --- a/cfg/test_defaults.go +++ b/cfg/test_defaults.go @@ -1,6 +1,8 @@ package cfg import ( + "log/slog" + "os" "path/filepath" "runtime" @@ -18,4 +20,8 @@ func SetDefaults() { viper.SetDefault("api-base", "https://api.ficsit.dev") viper.SetDefault("graphql-api", "/v2/query") viper.SetDefault("concurrent-downloads", 5) + + slog.SetDefault(slog.New(slog.NewTextHandler(os.Stderr, &slog.HandlerOptions{ + Level: slog.LevelDebug, + }))) } diff --git a/cli/cache/download.go b/cli/cache/download.go index 3f5a717..c9fd46a 100644 --- a/cli/cache/download.go +++ b/cli/cache/download.go @@ -4,11 +4,14 @@ import ( "errors" "fmt" "io" + "log/slog" "net/http" "os" "path/filepath" "sync" + "time" + "github.com/avast/retry-go" "github.com/puzpuzpuz/xsync/v3" "github.com/spf13/viper" @@ -84,7 +87,11 @@ func DownloadOrCache(cacheKey string, hash string, url string, updates chan<- ut outer: for { select { - case update := <-upstreamUpdates: + case update, ok := <-upstreamUpdates: + if !ok { + break outer + } + for _, u := range group.updates { u <- update } @@ -94,11 +101,29 @@ func DownloadOrCache(cacheKey string, hash string, url string, updates chan<- ut } }() - size, err := downloadInternal(cacheKey, location, hash, url, upstreamUpdates, downloadSemaphore) + var size int64 + + err := retry.Do(func() error { + var err error + size, err = downloadInternal(cacheKey, location, hash, url, upstreamUpdates, downloadSemaphore) + if err != nil { + return fmt.Errorf("internal download error: %w", err) + } + return nil + }, + retry.Attempts(5), + retry.Delay(time.Second), + retry.DelayType(retry.FixedDelay), + retry.OnRetry(func(n uint, err error) { + if n > 0 { + slog.Info("retrying download", slog.Uint64("n", uint64(n)), slog.String("cacheKey", cacheKey)) + } + }), + ) if err != nil { group.err = err close(group.wait) - return nil, 0, err + return nil, 0, err // nolint } close(upstreamWaiter) @@ -175,16 +200,17 @@ func downloadInternal(cacheKey string, location string, hash string, url string, } progresser := &utils.Progresser{ - Reader: resp.Body, Total: resp.ContentLength, Updates: updates, } - _, err = io.Copy(out, progresser) + _, err = io.Copy(io.MultiWriter(out, progresser), resp.Body) if err != nil { return 0, fmt.Errorf("failed writing file to disk: %w", err) } + _ = out.Sync() + if updates != nil { updates <- utils.GenericProgress{Completed: resp.ContentLength, Total: resp.ContentLength} } diff --git a/cli/context.go b/cli/context.go index 1e33f5f..35395ee 100644 --- a/cli/context.go +++ b/cli/context.go @@ -2,6 +2,7 @@ package cli import ( "fmt" + "log/slog" "github.com/Khan/genqlient/graphql" "github.com/spf13/viper" @@ -87,6 +88,8 @@ func (g *GlobalContext) ReInit() error { // Wipe will remove any trace of ficsit anywhere func (g *GlobalContext) Wipe() error { + slog.Info("wiping global context") + // Wipe all installations for _, installation := range g.Installations.Installations { if err := installation.Wipe(); err != nil { diff --git a/cli/disk/ftp.go b/cli/disk/ftp.go index c72524d..1900383 100644 --- a/cli/disk/ftp.go +++ b/cli/disk/ftp.go @@ -2,23 +2,28 @@ package disk import ( "bytes" + "context" "fmt" "io" + "log" "log/slog" "net/url" + "path/filepath" "strings" - "sync" "time" + "github.com/jackc/puddle/v2" "github.com/jlaffaye/ftp" ) +// TODO Make configurable +const connectionCount = 5 + var _ Disk = (*ftpDisk)(nil) type ftpDisk struct { - client *ftp.ServerConn - path string - stepLock sync.Mutex + pool *puddle.Pool[*ftp.ServerConn] + path string } type ftpEntry struct { @@ -39,40 +44,117 @@ func newFTP(path string) (Disk, error) { return nil, fmt.Errorf("failed to parse ftp url: %w", err) } - c, err := ftp.Dial(u.Host, ftp.DialWithTimeout(time.Second*5)) + pool, err := puddle.NewPool(&puddle.Config[*ftp.ServerConn]{ + Constructor: func(ctx context.Context) (*ftp.ServerConn, error) { + c, failedHidden, err := testFTP(u, ftp.DialWithTimeout(time.Second*5), ftp.DialWithForceListHidden(true)) + if failedHidden { + c, _, err = testFTP(u, ftp.DialWithTimeout(time.Second*5)) + if err != nil { + return nil, err + } + } else if err != nil { + return nil, err + } + + slog.Info("logged into ftp", slog.Bool("hidden-files", !failedHidden)) + + return c, nil + }, + MaxSize: connectionCount, + }) if err != nil { - return nil, fmt.Errorf("failed to dial host %s: %w", u.Host, err) + log.Fatal(err) + } + + return &ftpDisk{ + path: u.Path, + pool: pool, + }, nil +} + +func testFTP(u *url.URL, options ...ftp.DialOption) (*ftp.ServerConn, bool, error) { + c, err := ftp.Dial(u.Host, options...) + if err != nil { + return nil, false, fmt.Errorf("failed to dial host %s: %w", u.Host, err) } password, _ := u.User.Password() if err := c.Login(u.User.Username(), password); err != nil { - return nil, fmt.Errorf("failed to login: %w", err) + return nil, false, fmt.Errorf("failed to login: %w", err) } - slog.Debug("logged into ftp") + _, err = c.List("/") + if err != nil { + return nil, true, fmt.Errorf("failed listing dir: %w", err) + } - return &ftpDisk{ - path: u.Path, - client: c, - }, nil + return c, false, nil } -func (l *ftpDisk) Exists(path string) error { - l.stepLock.Lock() - defer l.stepLock.Unlock() +func (l *ftpDisk) Exists(path string) (bool, error) { + res, err := l.acquire() + if err != nil { + return false, err + } - slog.Debug("checking if file exists", slog.String("path", path), slog.String("schema", "ftp")) - _, err := l.client.FileSize(path) - return fmt.Errorf("failed to check if file exists: %w", err) + defer res.Release() + + slog.Debug("checking if file exists", slog.String("path", clean(path)), slog.String("schema", "ftp")) + + split := strings.Split(clean(path)[1:], "/") + for _, s := range split[:len(split)-1] { + dir, err := l.readDirLock(res, "") + if err != nil { + return false, err + } + + currentDir, _ := res.Value().CurrentDir() + + foundDir := false + for _, entry := range dir { + if entry.IsDir() && entry.Name() == s { + foundDir = true + break + } + } + + if !foundDir { + return false, nil + } + + slog.Debug("entering directory", slog.String("dir", s), slog.String("cwd", currentDir), slog.String("schema", "ftp")) + if err := res.Value().ChangeDir(s); err != nil { + return false, fmt.Errorf("failed to enter directory: %w", err) + } + } + + dir, err := l.readDirLock(res, "") + if err != nil { + return false, fmt.Errorf("failed listing directory: %w", err) + } + + found := false + for _, entry := range dir { + if entry.Name() == clean(filepath.Base(path)) { + found = true + break + } + } + + return found, nil } func (l *ftpDisk) Read(path string) ([]byte, error) { - l.stepLock.Lock() - defer l.stepLock.Unlock() + res, err := l.acquire() + if err != nil { + return nil, err + } - slog.Debug("reading file", slog.String("path", path), slog.String("schema", "ftp")) + defer res.Release() - f, err := l.client.Retr(path) + slog.Debug("reading file", slog.String("path", clean(path)), slog.String("schema", "ftp")) + + f, err := res.Value().Retr(clean(path)) if err != nil { return nil, fmt.Errorf("failed to retrieve path: %w", err) } @@ -88,11 +170,15 @@ func (l *ftpDisk) Read(path string) ([]byte, error) { } func (l *ftpDisk) Write(path string, data []byte) error { - l.stepLock.Lock() - defer l.stepLock.Unlock() + res, err := l.acquire() + if err != nil { + return err + } - slog.Debug("writing to file", slog.String("path", path), slog.String("schema", "ftp")) - if err := l.client.Stor(path, bytes.NewReader(data)); err != nil { + defer res.Release() + + slog.Debug("writing to file", slog.String("path", clean(path)), slog.String("schema", "ftp")) + if err := res.Value().Stor(clean(path), bytes.NewReader(data)); err != nil { return fmt.Errorf("failed to write file: %w", err) } @@ -100,34 +186,40 @@ func (l *ftpDisk) Write(path string, data []byte) error { } func (l *ftpDisk) Remove(path string) error { - l.stepLock.Lock() - defer l.stepLock.Unlock() + res, err := l.acquire() + if err != nil { + return err + } - slog.Debug("deleting path", slog.String("path", path), slog.String("schema", "ftp")) - if err := l.client.Delete(path); err != nil { - return fmt.Errorf("failed to delete path: %w", err) + defer res.Release() + + slog.Debug("deleting path", slog.String("path", clean(path)), slog.String("schema", "ftp")) + if err := res.Value().Delete(clean(path)); err != nil { + if err := res.Value().RemoveDirRecur(clean(path)); err != nil { + return fmt.Errorf("failed to delete path: %w", err) + } } return nil } func (l *ftpDisk) MkDir(path string) error { - l.stepLock.Lock() - defer l.stepLock.Unlock() - - slog.Debug("going to root directory", slog.String("schema", "ftp")) - err := l.client.ChangeDir("/") + res, err := l.acquire() if err != nil { - return fmt.Errorf("failed to change directory: %w", err) + return err } - split := strings.Split(path[1:], "/") + defer res.Release() + + split := strings.Split(clean(path)[1:], "/") for _, s := range split { - dir, err := l.ReadDirLock("", false) + dir, err := l.readDirLock(res, "") if err != nil { return err } + currentDir, _ := res.Value().CurrentDir() + foundDir := false for _, entry := range dir { if entry.IsDir() && entry.Name() == s { @@ -137,14 +229,14 @@ func (l *ftpDisk) MkDir(path string) error { } if !foundDir { - slog.Debug("making directory", slog.String("dir", s), slog.String("schema", "ftp")) - if err := l.client.MakeDir(s); err != nil { + slog.Debug("making directory", slog.String("dir", s), slog.String("cwd", currentDir), slog.String("schema", "ftp")) + if err := res.Value().MakeDir(s); err != nil { return fmt.Errorf("failed to make directory: %w", err) } } - slog.Debug("entering directory", slog.String("dir", s), slog.String("schema", "ftp")) - if err := l.client.ChangeDir(s); err != nil { + slog.Debug("entering directory", slog.String("dir", s), slog.String("cwd", currentDir), slog.String("schema", "ftp")) + if err := res.Value().ChangeDir(s); err != nil { return fmt.Errorf("failed to enter directory: %w", err) } } @@ -153,18 +245,20 @@ func (l *ftpDisk) MkDir(path string) error { } func (l *ftpDisk) ReadDir(path string) ([]Entry, error) { - return l.ReadDirLock(path, true) -} - -func (l *ftpDisk) ReadDirLock(path string, lock bool) ([]Entry, error) { - if lock { - l.stepLock.Lock() - defer l.stepLock.Unlock() + res, err := l.acquire() + if err != nil { + return nil, err } - slog.Debug("reading directory", slog.String("path", path), slog.String("schema", "ftp")) + defer res.Release() - dir, err := l.client.List(path) + return l.readDirLock(res, path) +} + +func (l *ftpDisk) readDirLock(res *puddle.Resource[*ftp.ServerConn], path string) ([]Entry, error) { + slog.Debug("reading directory", slog.String("path", clean(path)), slog.String("schema", "ftp")) + + dir, err := res.Value().List(clean(path)) if err != nil { return nil, fmt.Errorf("failed to list files in directory: %w", err) } @@ -179,29 +273,49 @@ func (l *ftpDisk) ReadDirLock(path string, lock bool) ([]Entry, error) { return entries, nil } -func (l *ftpDisk) IsNotExist(err error) bool { - return strings.Contains(err.Error(), "Could not get file") || strings.Contains(err.Error(), "Failed to open file") -} - -func (l *ftpDisk) IsExist(err error) bool { - return strings.Contains(err.Error(), "Create directory operation failed") -} - func (l *ftpDisk) Open(path string, _ int) (io.WriteCloser, error) { + res, err := l.acquire() + if err != nil { + return nil, err + } + reader, writer := io.Pipe() - slog.Debug("opening for writing", slog.String("path", path), slog.String("schema", "ftp")) + slog.Debug("opening for writing", slog.String("path", clean(path)), slog.String("schema", "ftp")) go func() { - l.stepLock.Lock() - defer l.stepLock.Unlock() + defer res.Release() - err := l.client.Stor(path, reader) + err := res.Value().Stor(clean(path), reader) if err != nil { slog.Error("failed to store file", slog.Any("err", err)) } - slog.Debug("write success", slog.String("path", path), slog.String("schema", "ftp")) + slog.Debug("write success", slog.String("path", clean(path)), slog.String("schema", "ftp")) }() return writer, nil } + +func (l *ftpDisk) goHome(res *puddle.Resource[*ftp.ServerConn]) error { + slog.Debug("going to root directory", slog.String("schema", "ftp")) + + err := res.Value().ChangeDir("/") + if err != nil { + return fmt.Errorf("failed to change directory: %w", err) + } + + return nil +} + +func (l *ftpDisk) acquire() (*puddle.Resource[*ftp.ServerConn], error) { + res, err := l.pool.Acquire(context.TODO()) + if err != nil { + return nil, fmt.Errorf("failed acquiring connection: %w", err) + } + + if err := l.goHome(res); err != nil { + return nil, err + } + + return res, nil +} diff --git a/cli/disk/local.go b/cli/disk/local.go index af827e0..4f509dc 100644 --- a/cli/disk/local.go +++ b/cli/disk/local.go @@ -1,6 +1,8 @@ package disk import ( + "errors" + "fmt" "io" "os" ) @@ -19,9 +21,18 @@ func newLocal(path string) (Disk, error) { return localDisk{path: path}, nil } -func (l localDisk) Exists(path string) error { +func (l localDisk) Exists(path string) (bool, error) { _, err := os.Stat(path) - return err //nolint + + if errors.Is(err, os.ErrNotExist) { + return false, nil + } + + if err != nil { + return false, fmt.Errorf("failed checking file existence: %w", err) + } + + return true, nil } func (l localDisk) Read(path string) ([]byte, error) { @@ -56,14 +67,6 @@ func (l localDisk) ReadDir(path string) ([]Entry, error) { return entries, nil } -func (l localDisk) IsNotExist(err error) bool { - return os.IsNotExist(err) -} - -func (l localDisk) IsExist(err error) bool { - return os.IsExist(err) -} - func (l localDisk) Open(path string, flag int) (io.WriteCloser, error) { return os.OpenFile(path, flag, 0777) //nolint } diff --git a/cli/disk/main.go b/cli/disk/main.go index cf04510..b88710e 100644 --- a/cli/disk/main.go +++ b/cli/disk/main.go @@ -5,11 +5,12 @@ import ( "io" "log/slog" "net/url" + "path/filepath" ) type Disk interface { // Exists checks if the provided file or directory exists - Exists(path string) error + Exists(path string) (bool, error) // Read returns the entire file as a byte buffer // @@ -30,12 +31,6 @@ type Disk interface { // Returns error if provided path is not a directory ReadDir(path string) ([]Entry, error) - // IsNotExist returns true if provided error is a not-exist type error - IsNotExist(err error) bool - - // IsExist returns true if provided error is a does-exist type error - IsExist(err error) bool - // Open opens provided path for writing Open(path string, flag int) (io.WriteCloser, error) } @@ -53,13 +48,18 @@ func FromPath(path string) (Disk, error) { switch parsed.Scheme { case "ftp": - slog.Info("connecting to ftp", slog.String("path", path)) + slog.Info("connecting to ftp") return newFTP(path) case "sftp": - slog.Info("connecting to sftp", slog.String("path", path)) + slog.Info("connecting to sftp") return newSFTP(path) } slog.Info("using local disk", slog.String("path", path)) return newLocal(path) } + +// clean returns a unix-style path +func clean(path string) string { + return filepath.ToSlash(filepath.Clean(path)) +} diff --git a/cli/disk/sftp.go b/cli/disk/sftp.go index f3d584c..8cfaba8 100644 --- a/cli/disk/sftp.go +++ b/cli/disk/sftp.go @@ -1,51 +1,169 @@ package disk import ( + "bytes" + "errors" + "fmt" "io" + "log/slog" + "net/url" + "os" + + "github.com/pkg/sftp" + "golang.org/x/crypto/ssh" ) var _ Disk = (*sftpDisk)(nil) type sftpDisk struct { - path string + client *sftp.Client + path string +} + +type sftpEntry struct { + os.FileInfo +} + +func (f sftpEntry) IsDir() bool { + return f.FileInfo.IsDir() +} + +func (f sftpEntry) Name() string { + return f.FileInfo.Name() } func newSFTP(path string) (Disk, error) { - return sftpDisk{path: path}, nil + u, err := url.Parse(path) + if err != nil { + return nil, fmt.Errorf("failed to parse sftp url: %w", err) + } + + password, ok := u.User.Password() + var auth []ssh.AuthMethod + if ok { + auth = append(auth, ssh.Password(password)) + } + + conn, err := ssh.Dial("tcp", u.Host, &ssh.ClientConfig{ + User: u.User.Username(), + Auth: auth, + + // TODO Somehow use systems hosts file + HostKeyCallback: ssh.InsecureIgnoreHostKey(), + }) + if err != nil { + return nil, fmt.Errorf("failed to connect to ssh server: %w", err) + } + + client, err := sftp.NewClient(conn) + if err != nil { + return nil, fmt.Errorf("failed to create sftp client: %w", err) + } + + slog.Info("logged into sftp") + + return sftpDisk{ + path: path, + client: client, + }, nil } -func (l sftpDisk) Exists(path string) error { //nolint - panic("implement me") +func (l sftpDisk) Exists(path string) (bool, error) { + slog.Debug("checking if file exists", slog.String("path", clean(path)), slog.String("schema", "sftp")) + + s, err := l.client.Stat(clean(path)) + if err != nil { + if errors.Is(err, os.ErrNotExist) { + return false, nil + } + + return false, fmt.Errorf("failed to check if file exists: %w", err) + } + + return s != nil, nil } -func (l sftpDisk) Read(path string) ([]byte, error) { //nolint - panic("implement me") +func (l sftpDisk) Read(path string) ([]byte, error) { + slog.Debug("reading file", slog.String("path", clean(path)), slog.String("schema", "sftp")) + + f, err := l.client.Open(clean(path)) + if err != nil { + return nil, fmt.Errorf("failed to retrieve path: %w", err) + } + + defer f.Close() + + data, err := io.ReadAll(f) + if err != nil { + return nil, fmt.Errorf("failed to read file: %w", err) + } + + return data, nil } -func (l sftpDisk) Write(path string, data []byte) error { //nolint - panic("implement me") +func (l sftpDisk) Write(path string, data []byte) error { + slog.Debug("writing to file", slog.String("path", clean(path)), slog.String("schema", "sftp")) + + file, err := l.client.Create(clean(path)) + if err != nil { + return fmt.Errorf("failed to create file: %w", err) + } + + defer file.Close() + + if _, err = io.Copy(file, bytes.NewReader(data)); err != nil { + return fmt.Errorf("failed to write file: %w", err) + } + + return nil } -func (l sftpDisk) Remove(path string) error { //nolint - panic("implement me") +func (l sftpDisk) Remove(path string) error { + slog.Debug("deleting path", slog.String("path", clean(path)), slog.String("schema", "sftp")) + if err := l.client.Remove(clean(path)); err != nil { + if err := l.client.RemoveAll(clean(path)); err != nil { + return fmt.Errorf("failed to delete path: %w", err) + } + } + + return nil } -func (l sftpDisk) MkDir(path string) error { //nolint - panic("implement me") +func (l sftpDisk) MkDir(path string) error { + slog.Debug("making directory", slog.String("path", clean(path)), slog.String("schema", "sftp")) + + if err := l.client.MkdirAll(clean(path)); err != nil { + return fmt.Errorf("failed to make directory: %w", err) + } + + return nil } -func (l sftpDisk) ReadDir(path string) ([]Entry, error) { //nolint - panic("implement me") +func (l sftpDisk) ReadDir(path string) ([]Entry, error) { + slog.Debug("reading directory", slog.String("path", clean(path)), slog.String("schema", "sftp")) + + dir, err := l.client.ReadDir(clean(path)) + if err != nil { + return nil, fmt.Errorf("failed to list files in directory: %w", err) + } + + entries := make([]Entry, len(dir)) + for i, entry := range dir { + entries[i] = sftpEntry{ + FileInfo: entry, + } + } + + return entries, nil } -func (l sftpDisk) IsNotExist(err error) bool { //nolint - panic("implement me") -} +func (l sftpDisk) Open(path string, _ int) (io.WriteCloser, error) { + slog.Debug("opening for writing", slog.String("path", clean(path)), slog.String("schema", "sftp")) -func (l sftpDisk) IsExist(err error) bool { //nolint - panic("implement me") -} + f, err := l.client.Create(clean(path)) + if err != nil { + slog.Error("failed to open file", slog.Any("err", err)) + } -func (l sftpDisk) Open(path string, flag int) (io.WriteCloser, error) { //nolint - panic("implement me") + return f, nil } diff --git a/cli/installations.go b/cli/installations.go index f3d5e81..3279994 100644 --- a/cli/installations.go +++ b/cli/installations.go @@ -203,27 +203,27 @@ func (i *Installation) Validate(ctx *GlobalContext) error { foundExecutable := false - err = d.Exists(filepath.Join(i.BasePath(), "FactoryGame.exe")) - if err != nil { - if !d.IsNotExist(err) { + exists, err := d.Exists(filepath.Join(i.BasePath(), "FactoryGame.exe")) + if !exists { + if err != nil { return fmt.Errorf("failed reading FactoryGame.exe: %w", err) } } else { foundExecutable = true } - err = d.Exists(filepath.Join(i.BasePath(), "FactoryServer.sh")) - if err != nil { - if !d.IsNotExist(err) { + exists, err = d.Exists(filepath.Join(i.BasePath(), "FactoryServer.sh")) + if !exists { + if err != nil { return fmt.Errorf("failed reading FactoryServer.sh: %w", err) } } else { foundExecutable = true } - err = d.Exists(filepath.Join(i.BasePath(), "FactoryServer.exe")) - if err != nil { - if !d.IsNotExist(err) { + exists, err = d.Exists(filepath.Join(i.BasePath(), "FactoryServer.exe")) + if !exists { + if err != nil { return fmt.Errorf("failed reading FactoryServer.exe: %w", err) } } else { @@ -269,16 +269,23 @@ func (i *Installation) LockFile(ctx *GlobalContext) (*resolver.LockFile, error) return nil, err } + exists, err := d.Exists(lockfilePath) + if err != nil { + return nil, err + } + + if !exists { + return nil, nil + } + var lockFile *resolver.LockFile lockFileJSON, err := d.Read(lockfilePath) if err != nil { - if !d.IsNotExist(err) { - return nil, fmt.Errorf("failed reading lockfile: %w", err) - } - } else { - if err := json.Unmarshal(lockFileJSON, &lockFile); err != nil { - return nil, fmt.Errorf("failed parsing lockfile: %w", err) - } + 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) } return lockFile, nil @@ -296,7 +303,11 @@ func (i *Installation) WriteLockFile(ctx *GlobalContext, lockfile *resolver.Lock } lockfileDir := filepath.Dir(lockfilePath) - if err := d.Exists(lockfileDir); d.IsNotExist(err) { + if exists, err := d.Exists(lockfileDir); !exists { + if err != nil { + return err + } + if err := d.MkDir(lockfileDir); err != nil { return fmt.Errorf("failed creating lockfile directory: %w", err) } @@ -315,6 +326,8 @@ func (i *Installation) WriteLockFile(ctx *GlobalContext, lockfile *resolver.Lock } func (i *Installation) Wipe() error { + slog.Info("wiping installation", slog.String("path", i.Path)) + d, err := i.GetDisk() if err != nil { return err @@ -412,8 +425,12 @@ func (i *Installation) Install(ctx *GlobalContext, updates chan<- InstallUpdate) if entry.IsDir() { if _, ok := lockfile.Mods[entry.Name()]; !ok { modDir := filepath.Join(modsDirectory, entry.Name()) - err := d.Exists(filepath.Join(modDir, ".smm")) - if err == nil { + exists, err := d.Exists(filepath.Join(modDir, ".smm")) + if err != nil { + return err + } + + if exists { slog.Info("deleting mod", slog.String("mod_reference", entry.Name())) if err := d.Remove(modDir); err != nil { return fmt.Errorf("failed to delete mod directory: %w", err) @@ -479,16 +496,28 @@ func (i *Installation) Install(ctx *GlobalContext, updates chan<- InstallUpdate) }) } + if err := errg.Wait(); err != nil { + return fmt.Errorf("failed to install mods: %w", err) + } + if updates != nil { + if i.Vanilla { + updates <- InstallUpdate{ + Type: InstallUpdateTypeOverall, + Progress: utils.GenericProgress{ + Completed: 1, + Total: 1, + }, + } + } + go func() { channelUsers.Wait() close(updates) }() } - if err := errg.Wait(); err != nil { - return fmt.Errorf("failed to install mods: %w", err) - } + slog.Info("installation completed", slog.String("path", i.Path)) return nil } @@ -534,11 +563,14 @@ func (i *Installation) UpdateMods(ctx *GlobalContext, mods []string) error { func downloadAndExtractMod(modReference string, version string, link string, hash string, modsDirectory string, updates chan<- InstallUpdate, downloadSemaphore chan int, d disk.Disk) error { var downloadUpdates chan utils.GenericProgress + var wg sync.WaitGroup if updates != nil { // Forward the inner updates as InstallUpdates downloadUpdates = make(chan utils.GenericProgress) + wg.Add(1) go func() { + defer wg.Done() for up := range downloadUpdates { updates <- InstallUpdate{ Item: InstallUpdateItem{ @@ -562,7 +594,6 @@ func downloadAndExtractMod(modReference string, version string, link string, has var extractUpdates chan utils.GenericProgress - var wg sync.WaitGroup if updates != nil { // Forward the inner updates as InstallUpdates extractUpdates = make(chan utils.GenericProgress) @@ -652,11 +683,17 @@ func (i *Installation) GetGameVersion(ctx *GlobalContext) (int, error) { } fullPath := filepath.Join(i.BasePath(), platform.VersionPath) + exists, err := d.Exists(fullPath) + if err != nil { + return 0, err + } + + if !exists { + return 0, errors.New("game version file does not exist") + } + file, err := d.Read(fullPath) if err != nil { - if d.IsNotExist(err) { - return 0, fmt.Errorf("could not find game version file: %w", err) - } return 0, fmt.Errorf("failed reading version file: %w", err) } @@ -680,12 +717,12 @@ func (i *Installation) GetPlatform(ctx *GlobalContext) (*Platform, error) { for _, platform := range platforms { fullPath := filepath.Join(i.BasePath(), platform.VersionPath) - err := d.Exists(fullPath) - if err != nil { - if d.IsNotExist(err) { - continue + exists, err := d.Exists(fullPath) + if !exists { + if err != nil { + return nil, fmt.Errorf("failed detecting version file: %w", err) } - return nil, fmt.Errorf("failed detecting version file: %w", err) + continue } return &platform, nil } diff --git a/cli/installations_test.go b/cli/installations_test.go index bc6720e..b9f22ae 100644 --- a/cli/installations_test.go +++ b/cli/installations_test.go @@ -2,13 +2,25 @@ package cli import ( "os" + "path/filepath" + "runtime" "testing" + "time" "github.com/MarvinJWendt/testza" "github.com/satisfactorymodding/ficsit-cli/cfg" ) +// NOTE: +// +// This code contains sleep. +// This is because github actions are special. +// They don't properly sync to disk. +// And Go is faster than their disk. +// So tests are flaky :) +// DO NOT REMOVE THE SLEEP! + func init() { cfg.SetDefaults() } @@ -19,7 +31,7 @@ func TestInstallationsInit(t *testing.T) { testza.AssertNotNil(t, installations) } -func TestAddInstallation(t *testing.T) { +func TestAddLocalInstallation(t *testing.T) { ctx, err := InitCLI(false) testza.AssertNoError(t, err) @@ -39,6 +51,10 @@ func TestAddInstallation(t *testing.T) { serverLocation := os.Getenv("SF_DEDICATED_SERVER") if serverLocation != "" { + time.Sleep(time.Second) + testza.AssertNoError(t, os.RemoveAll(filepath.Join(serverLocation, "FactoryGame", "Mods"))) + time.Sleep(time.Second) + installation, err := ctx.Installations.AddInstallation(ctx, serverLocation, profileName) testza.AssertNoError(t, err) testza.AssertNotNil(t, installation) @@ -49,5 +65,101 @@ func TestAddInstallation(t *testing.T) { installation.Vanilla = true err = installation.Install(ctx, installWatcher()) testza.AssertNoError(t, err) + time.Sleep(time.Second) } + + err = ctx.Wipe() + testza.AssertNoError(t, err) +} + +func TestAddFTPInstallation(t *testing.T) { + if runtime.GOOS == "windows" { + // Not supported + return + } + + ctx, err := InitCLI(false) + testza.AssertNoError(t, err) + + err = ctx.Wipe() + testza.AssertNoError(t, err) + + err = ctx.ReInit() + testza.AssertNoError(t, err) + + ctx.Provider = MockProvider{} + + profileName := "InstallationTest" + profile, err := ctx.Profiles.AddProfile(profileName) + testza.AssertNoError(t, err) + testza.AssertNoError(t, profile.AddMod("AreaActions", "1.6.5")) + testza.AssertNoError(t, profile.AddMod("RefinedPower", "3.2.10")) + + serverLocation := os.Getenv("SF_DEDICATED_SERVER") + if serverLocation != "" { + time.Sleep(time.Second) + testza.AssertNoError(t, os.RemoveAll(filepath.Join(serverLocation, "FactoryGame", "Mods"))) + time.Sleep(time.Second) + + installation, err := ctx.Installations.AddInstallation(ctx, "ftp://user:pass@localhost:2121/server", profileName) + testza.AssertNoError(t, err) + testza.AssertNotNil(t, installation) + + err = installation.Install(ctx, installWatcher()) + testza.AssertNoError(t, err) + + installation.Vanilla = true + err = installation.Install(ctx, installWatcher()) + testza.AssertNoError(t, err) + time.Sleep(time.Second) + } + + err = ctx.Wipe() + testza.AssertNoError(t, err) +} + +func TestAddSFTPInstallation(t *testing.T) { + if runtime.GOOS == "windows" { + // Not supported + return + } + + ctx, err := InitCLI(false) + testza.AssertNoError(t, err) + + err = ctx.Wipe() + testza.AssertNoError(t, err) + + err = ctx.ReInit() + testza.AssertNoError(t, err) + + ctx.Provider = MockProvider{} + + profileName := "InstallationTest" + profile, err := ctx.Profiles.AddProfile(profileName) + testza.AssertNoError(t, err) + testza.AssertNoError(t, profile.AddMod("AreaActions", "1.6.5")) + testza.AssertNoError(t, profile.AddMod("RefinedPower", "3.2.10")) + + serverLocation := os.Getenv("SF_DEDICATED_SERVER") + if serverLocation != "" { + time.Sleep(time.Second) + testza.AssertNoError(t, os.RemoveAll(filepath.Join(serverLocation, "FactoryGame", "Mods"))) + time.Sleep(time.Second) + + installation, err := ctx.Installations.AddInstallation(ctx, "sftp://user:pass@localhost:2222/home/user/server", profileName) + testza.AssertNoError(t, err) + testza.AssertNotNil(t, installation) + + err = installation.Install(ctx, installWatcher()) + testza.AssertNoError(t, err) + + installation.Vanilla = true + err = installation.Install(ctx, installWatcher()) + testza.AssertNoError(t, err) + time.Sleep(time.Second) + } + + err = ctx.Wipe() + testza.AssertNoError(t, err) } diff --git a/docker-compose-test.yml b/docker-compose-test.yml new file mode 100755 index 0000000..b65227b --- /dev/null +++ b/docker-compose-test.yml @@ -0,0 +1,31 @@ +version: '2' + +services: + ftp: + image: fauria/vsftpd:latest + ports: + - "2020:20" + - "2121:21" + - "21100-21110:21100-21110" + volumes: + - ./SatisfactoryDedicatedServer:/home/vsftpd/user/server + environment: + - FTP_USER=user + - FTP_PASS=pass + - PASV_ADDRESS=127.0.0.1 + - PASV_MIN_PORT=21100 + - PASV_MAX_PORT=21110 + - LOG_STDOUT=true + + ssh: + image: lscr.io/linuxserver/openssh-server:latest + ports: + - "2222:2222" + volumes: + - ./SatisfactoryDedicatedServer:/home/user/server + environment: + - PUID=1000 + - PGID=1000 + - PASSWORD_ACCESS=true + - USER_PASSWORD=pass + - USER_NAME=user \ No newline at end of file diff --git a/go.mod b/go.mod index 2b7a958..c2fdce0 100644 --- a/go.mod +++ b/go.mod @@ -9,14 +9,17 @@ require ( github.com/Khan/genqlient v0.6.0 github.com/MarvinJWendt/testza v0.5.2 github.com/PuerkitoBio/goquery v1.8.1 + github.com/avast/retry-go v3.0.0+incompatible github.com/charmbracelet/bubbles v0.17.1 github.com/charmbracelet/bubbletea v0.25.0 github.com/charmbracelet/glamour v0.6.0 github.com/charmbracelet/lipgloss v0.9.1 github.com/charmbracelet/x/exp/teatest v0.0.0-20231215171016-7ba2b450712d + github.com/jackc/puddle/v2 v2.2.1 github.com/jlaffaye/ftp v0.2.0 github.com/lmittmann/tint v1.0.3 github.com/muesli/reflow v0.3.0 + github.com/pkg/sftp v1.13.6 github.com/pterm/pterm v0.12.71 github.com/puzpuzpuz/xsync/v3 v3.0.2 github.com/sahilm/fuzzy v0.1.1-0.20230530133925-c48e322e2a8f @@ -24,6 +27,7 @@ require ( github.com/satisfactorymodding/ficsit-resolver v0.0.2 github.com/spf13/cobra v1.8.0 github.com/spf13/viper v1.18.1 + golang.org/x/crypto v0.16.0 golang.org/x/sync v0.5.0 ) @@ -54,6 +58,7 @@ require ( github.com/hashicorp/hcl v1.0.0 // indirect github.com/inconshreveable/mousetrap v1.1.0 // indirect github.com/klauspost/cpuid/v2 v2.2.6 // indirect + github.com/kr/fs v0.1.0 // indirect github.com/lithammer/fuzzysearch v1.1.8 // indirect github.com/lucasb-eyer/go-colorful v1.2.0 // indirect github.com/magiconair/properties v1.8.7 // indirect diff --git a/go.sum b/go.sum index 2521c59..e587328 100644 --- a/go.sum +++ b/go.sum @@ -40,6 +40,8 @@ github.com/arbovm/levenshtein v0.0.0-20160628152529-48b4e1c0c4d0/go.mod h1:t2tdK github.com/atomicgo/cursor v0.0.1/go.mod h1:cBON2QmmrysudxNBFthvMtN32r3jxVRIvzkUiF/RuIk= github.com/atotto/clipboard v0.1.4 h1:EH0zSVneZPSuFR11BlR9YppQTVDbh5+16AmcJi4g1z4= github.com/atotto/clipboard v0.1.4/go.mod h1:ZY9tmq7sm5xIbd9bOK4onWV4S6X0u6GY7Vn0Yu86PYI= +github.com/avast/retry-go v3.0.0+incompatible h1:4SOWQ7Qs+oroOTQOYnAHqelpCO0biHSxpiH9JdtuBj0= +github.com/avast/retry-go v3.0.0+incompatible/go.mod h1:XtSnn+n/sHqQIpZ10K1qAevBhOOCWBLXXy3hyiqqBrY= github.com/aymanbagabas/go-osc52 v1.0.3/go.mod h1:zT8H+Rk4VSabYN90pWyugflM3ZhpTZNC7cASDfUCdT4= github.com/aymanbagabas/go-osc52/v2 v2.0.1 h1:HwpRHbFMcZLEVr42D4p7XBqjyuxQH5SMiErDT4WkJ2k= github.com/aymanbagabas/go-osc52/v2 v2.0.1/go.mod h1:uYgXzlJ7ZpABp8OJ+exZzJJhRNQ2ASbcXHWsFqH8hp8= @@ -97,6 +99,8 @@ github.com/hashicorp/hcl v1.0.0 h1:0Anlzjpi4vEasTeNFn2mLJgTSwt0+6sfsiTG8qcWGx4= github.com/hashicorp/hcl v1.0.0/go.mod h1:E5yfLk+7swimpb2L/Alb/PJmXilQ/rhwaUYs4T20WEQ= github.com/inconshreveable/mousetrap v1.1.0 h1:wN+x4NVGpMsO7ErUn/mUI3vEoE6Jt13X2s0bqwp9tc8= github.com/inconshreveable/mousetrap v1.1.0/go.mod h1:vpF70FUmC8bwa3OWnCshd2FqLfsEA9PFc4w1p2J65bw= +github.com/jackc/puddle/v2 v2.2.1 h1:RhxXJtFG022u4ibrCSMSiu5aOq1i77R3OHKNJj77OAk= +github.com/jackc/puddle/v2 v2.2.1/go.mod h1:vriiEXHvEE654aYKXXjOvZM39qJ0q+azkZFrfEOc3H4= github.com/jlaffaye/ftp v0.2.0 h1:lXNvW7cBu7R/68bknOX3MrRIIqZ61zELs1P2RAiA3lg= github.com/jlaffaye/ftp v0.2.0/go.mod h1:is2Ds5qkhceAPy2xD6RLI6hmp/qysSoymZ+Z2uTnspI= github.com/klauspost/cpuid/v2 v2.0.9/go.mod h1:FInQzS24/EEf25PyTYn52gqo7WaD8xa0213Md/qVLRg= @@ -104,6 +108,8 @@ github.com/klauspost/cpuid/v2 v2.0.10/go.mod h1:g2LTdtYhdyuGPqyWyv7qRAmj1WBqxuOb github.com/klauspost/cpuid/v2 v2.0.12/go.mod h1:g2LTdtYhdyuGPqyWyv7qRAmj1WBqxuObKfj5c0PQa7c= github.com/klauspost/cpuid/v2 v2.2.6 h1:ndNyv040zDGIDh8thGkXYjnFtiN02M1PVVF+JE/48xc= github.com/klauspost/cpuid/v2 v2.2.6/go.mod h1:Lcz8mBdAVJIBVzewtcLocK12l3Y+JytZYpaMropDUws= +github.com/kr/fs v0.1.0 h1:Jskdu9ieNAYnjxsi0LbQp1ulIKZV1LAFgK1tWhpZgl8= +github.com/kr/fs v0.1.0/go.mod h1:FFnZGqtBN9Gxj7eW1uZ42v5BccTP0vu6NEaFoC2HwRg= github.com/kr/pretty v0.1.0/go.mod h1:dAy3ld7l9f0ibDNOQOHHMYYIIbhfbHSm3C4ZsoJORNo= github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE= github.com/kr/pretty v0.3.1/go.mod h1:hoEshYVHaxMs3cyo3Yncou5ZscifuDolrwPKZanG3xk= @@ -155,6 +161,8 @@ github.com/pelletier/go-toml/v2 v2.1.0/go.mod h1:tJU2Z3ZkXwnxa4DPO899bsyIoywizdU github.com/pkg/errors v0.8.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0= github.com/pkg/errors v0.9.1 h1:FEBLx1zS214owpjy7qsBeixbURkuhQAwrK5UwLGTwt4= github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0= +github.com/pkg/sftp v1.13.6 h1:JFZT4XbOU7l77xGSpOdW+pwIMqP044IyjXX6FGyEKFo= +github.com/pkg/sftp v1.13.6/go.mod h1:tz1ryNURKu77RL+GuCzmoJYxQczL3wLNNpPWagdg4Qk= github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 h1:Jamvg5psRIccs7FGNTlIRMkT8wgtp5eCXdBlqhYGL6U= github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= @@ -239,7 +247,10 @@ go.uber.org/multierr v1.11.0 h1:blXXJkSxSSfBVBlC76pxqeO+LN3aDfLQo+309xJstO0= go.uber.org/multierr v1.11.0/go.mod h1:20+QtiLqy0Nd6FdQB9TLXag12DsQkrbs3htMFfDN80Y= golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w= golang.org/x/crypto v0.0.0-20210921155107-089bfa567519/go.mod h1:GvvjBRRGRdwPK5ydBHafDWAxML/pGHZbMvKqRZ5+Abc= +golang.org/x/crypto v0.1.0/go.mod h1:RecgLatLF4+eUMCP1PoPZQb+cVrJcOPbHkTkbkB9sbw= golang.org/x/crypto v0.15.0/go.mod h1:4ChreQoLWfG3xLDer1WdlH5NdlQ3+mwnQq1YTKY+72g= +golang.org/x/crypto v0.16.0 h1:mMMrFzRSCF0GvB7Ne27XVtVAaXLrPmgPC7/v0tkwHaY= +golang.org/x/crypto v0.16.0/go.mod h1:gCAAfMLgwOJRpTjQ2zCCt2OcSfYMTeZVSRtQlPC7Nq4= golang.org/x/exp v0.0.0-20231206192017-f3f8817b8deb h1:c0vyKkb6yr3KR7jEfJaOSv4lG7xPkbN6r52aJz1d8a8= golang.org/x/exp v0.0.0-20231206192017-f3f8817b8deb/go.mod h1:iRJReGqOEeBhDZGkGbynYwcHlctCvnjTYIamk7uXpHI= golang.org/x/mod v0.6.0-dev.0.20220419223038-86c51ed26bb4/go.mod h1:jJ57K6gSWd91VN4djpZkiMVwK6gcyfeH4XE8wZrZaV4= @@ -251,6 +262,7 @@ golang.org/x/net v0.0.0-20210226172049-e18ecbb05110/go.mod h1:m0MpNAwzfU5UDzcl9v golang.org/x/net v0.0.0-20210916014120-12bc252f5db8/go.mod h1:9nx3DQGgdP8bBQD5qxJ1jj9UTztislL4KSBs9R2vV5Y= golang.org/x/net v0.0.0-20220722155237-a158d28d115b/go.mod h1:XRhObCWvk6IyKnWLug+ECip1KBveYUHfp+8e9klMJ9c= golang.org/x/net v0.0.0-20221002022538-bcab6841153b/go.mod h1:YDH+HFinaLZZlnHAfSS6ZXJJ9M9t4Dl22yv3iI2vPwk= +golang.org/x/net v0.1.0/go.mod h1:Cx3nUiGt4eDBEyega/BKRp+/AlGL8hYe7U9odMt2Cco= golang.org/x/net v0.6.0/go.mod h1:2Tu9+aMcznHK/AK1HMvgo6xiTLG5rD5rZLDS+rp2Bjs= golang.org/x/net v0.7.0/go.mod h1:2Tu9+aMcznHK/AK1HMvgo6xiTLG5rD5rZLDS+rp2Bjs= golang.org/x/net v0.9.0/go.mod h1:d48xBJpPfHeWQsugry2m+kC02ZBRGRgulfHnEXEuWns= @@ -287,6 +299,7 @@ golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9sn golang.org/x/term v0.0.0-20210220032956-6a3ed077a48d/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo= golang.org/x/term v0.0.0-20210615171337-6886f2dfbf5b/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8= golang.org/x/term v0.0.0-20210927222741-03fcf44c2211/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8= +golang.org/x/term v0.1.0/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8= golang.org/x/term v0.5.0/go.mod h1:jMB1sMXY+tzblOD4FWmEbocvup2/aLOaQEp7JmGp78k= golang.org/x/term v0.7.0/go.mod h1:P32HKFT3hSsZrRxla30E9HqToFYAQPCMs/zFMBUFqPY= golang.org/x/term v0.8.0/go.mod h1:xPskH00ivmX89bAKVGSKKtLOWNx2+17Eiy94tnKShWo= @@ -297,6 +310,7 @@ golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ= golang.org/x/text v0.3.6/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ= golang.org/x/text v0.3.7/go.mod h1:u+2+/6zg+i71rQMx5EYifcz6MCKuco9NR6JIITiCfzQ= +golang.org/x/text v0.4.0/go.mod h1:mrYo+phRRbMaCq/xk9113O4dZlRixOauAjOtrjsXDZ8= golang.org/x/text v0.7.0/go.mod h1:mrYo+phRRbMaCq/xk9113O4dZlRixOauAjOtrjsXDZ8= golang.org/x/text v0.9.0/go.mod h1:e1OnstbJyHTd6l/uOt8jFFHp6TRDWZR/bV3emEE/zU8= golang.org/x/text v0.14.0 h1:ScX5w1eTa3QqT8oi6+ziP7dTV1S2+ALU0bI+0zXKWiQ= diff --git a/utils/io.go b/utils/io.go index 2e638a0..31a142f 100644 --- a/utils/io.go +++ b/utils/io.go @@ -14,49 +14,6 @@ import ( "github.com/satisfactorymodding/ficsit-cli/cli/disk" ) -type GenericProgress struct { - Completed int64 - Total int64 -} - -func (gp GenericProgress) Percentage() float64 { - if gp.Total == 0 { - return 0 - } - return float64(gp.Completed) / float64(gp.Total) -} - -type Progresser struct { - io.Reader - Updates chan<- GenericProgress - Total int64 - Running int64 -} - -func (pt *Progresser) Read(p []byte) (int, error) { - n, err := pt.Reader.Read(p) - pt.Running += int64(n) - - if err == nil { - if pt.Updates != nil { - select { - case pt.Updates <- GenericProgress{Completed: pt.Running, Total: pt.Total}: - default: - } - } - } - - if err == io.EOF { - return n, io.EOF - } - - if err != nil { - return 0, fmt.Errorf("failed to read: %w", err) - } - - return n, nil -} - func SHA256Data(f io.Reader) (string, error) { h := sha256.New() if _, err := io.Copy(h, f); err != nil { @@ -68,22 +25,29 @@ func SHA256Data(f io.Reader) (string, error) { func ExtractMod(f io.ReaderAt, size int64, location string, hash string, updates chan<- GenericProgress, d disk.Disk) error { hashFile := filepath.Join(location, ".smm") - hashBytes, err := d.Read(hashFile) + + exists, err := d.Exists(hashFile) if err != nil { - if !d.IsNotExist(err) { + return err + } + + if exists { + hashBytes, err := d.Read(hashFile) + if err != nil { return fmt.Errorf("failed to read .smm mod hash file: %w", err) } - } else { + if hash == string(hashBytes) { return nil } } - if err := d.MkDir(location); err != nil { - if !d.IsExist(err) { - return fmt.Errorf("failed to create mod directory: %s: %w", location, err) - } + exists, err = d.Exists(location) + if err != nil { + return err + } + if exists { if err := d.Remove(location); err != nil { return fmt.Errorf("failed to remove directory: %s: %w", location, err) } @@ -175,13 +139,12 @@ func writeZipFile(outFileLocation string, file *zip.File, d disk.Disk, updates c } defer inFile.Close() - progressInReader := &Progresser{ - Reader: inFile, + progressInWriter := &Progresser{ Total: int64(file.UncompressedSize64), Updates: updates, } - if _, err := io.Copy(outFile, progressInReader); err != nil { + if _, err := io.Copy(io.MultiWriter(outFile, progressInWriter), inFile); err != nil { return fmt.Errorf("failed to write to file: %s: %w", outFileLocation, err) } diff --git a/utils/progress.go b/utils/progress.go new file mode 100644 index 0000000..d2da8e9 --- /dev/null +++ b/utils/progress.go @@ -0,0 +1,38 @@ +package utils + +import ( + "io" +) + +type GenericProgress struct { + Completed int64 + Total int64 +} + +func (gp GenericProgress) Percentage() float64 { + if gp.Total == 0 { + return 0 + } + return float64(gp.Completed) / float64(gp.Total) +} + +var _ io.Writer = (*Progresser)(nil) + +type Progresser struct { + Updates chan<- GenericProgress + Total int64 + Running int64 +} + +func (pt *Progresser) Write(p []byte) (int, error) { + pt.Running += int64(len(p)) + + if pt.Updates != nil { + select { + case pt.Updates <- GenericProgress{Completed: pt.Running, Total: pt.Total}: + default: + } + } + + return len(p), nil +}