feat: sftp (#51)

* feat: sftp
test: add tests for ftp and sftp

* chore: ci fixes

* chore: potential race fix

* fix: simplify existence checks

* fix: split path differently for ftp

* fix: 🤷

* chore: add debug print

* chore: lint

* chore: idk dude

* chore: ?

* chore: more logs

* chore: wipe mods before tests

* chore: logs

* chore: wat

* chore: wait?

* chore: no errors

* chore: gh actions are 💩

* fix: always sync after copy

* chore: remove some test logs

* chore: remove test progress watcher

* refactor: change progress to writer

* chore: logs

* chore: different logs

* chore: whoops

* chore: moar logs

* chore: even moar logs

* chore: what is life

* chore: why are we here

* chore: we are just bags of water floating through space

* chore: are you real?

* chore: ?

* chore: if you get a single update now I call bs

* chore: ok what if we just do one?

* chore: ok what if we do two?

* chore: this should not work

* chore: wait no, this one

* chore: fml

* chore: remove logs

* chore: what if we just wait a little

* chore: retry

* chore: move error

* chore: verbose log

* chore: remove explicit sleep

* chore: remove debug

* fix: linux pathing on windows

* fix: clean paths properly

* fix: fuck ftp

* fix: send update on vanilla

* feat: parallel ftp

* fix: remove potential credential leak
This commit is contained in:
Vilsol 2023-12-27 16:13:09 -08:00 committed by GitHub
parent baacde400e
commit 2672b17f44
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
16 changed files with 676 additions and 197 deletions

View file

@ -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"

1
.gitignore vendored
View file

@ -129,3 +129,4 @@ dist/
schema.graphql
*.log
.direnv
/SatisfactoryDedicatedServer

View file

@ -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,
})))
}

36
cli/cache/download.go vendored
View file

@ -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}
}

View file

@ -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 {

View file

@ -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
pool *puddle.Pool[*ftp.ServerConn]
path string
stepLock sync.Mutex
}
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, fmt.Errorf("failed to dial host %s: %w", u.Host, err)
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 {
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")
return &ftpDisk{
path: u.Path,
client: c,
}, nil
_, err = c.List("/")
if err != nil {
return nil, true, fmt.Errorf("failed listing dir: %w", err)
}
func (l *ftpDisk) Exists(path string) error {
l.stepLock.Lock()
defer l.stepLock.Unlock()
return c, false, nil
}
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)
func (l *ftpDisk) Exists(path string) (bool, error) {
res, err := l.acquire()
if err != nil {
return false, 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 {
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("/")
if err != nil {
return fmt.Errorf("failed to change directory: %w", err)
}
split := strings.Split(path[1:], "/")
for _, s := range split {
dir, err := l.ReadDirLock("", false)
res, err := l.acquire()
if err != nil {
return err
}
defer res.Release()
split := strings.Split(clean(path)[1:], "/")
for _, s := range split {
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)
res, err := l.acquire()
if err != nil {
return nil, err
}
func (l *ftpDisk) ReadDirLock(path string, lock bool) ([]Entry, error) {
if lock {
l.stepLock.Lock()
defer l.stepLock.Unlock()
defer res.Release()
return l.readDirLock(res, path)
}
slog.Debug("reading directory", slog.String("path", path), slog.String("schema", "ftp"))
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 := l.client.List(path)
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
}

View file

@ -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
}

View file

@ -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))
}

View file

@ -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 {
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)
}
func (l sftpDisk) Exists(path string) error { //nolint
panic("implement me")
password, ok := u.User.Password()
var auth []ssh.AuthMethod
if ok {
auth = append(auth, ssh.Password(password))
}
func (l sftpDisk) Read(path string) ([]byte, error) { //nolint
panic("implement me")
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)
}
func (l sftpDisk) Write(path string, data []byte) error { //nolint
panic("implement me")
client, err := sftp.NewClient(conn)
if err != nil {
return nil, fmt.Errorf("failed to create sftp client: %w", err)
}
func (l sftpDisk) Remove(path string) error { //nolint
panic("implement me")
slog.Info("logged into sftp")
return sftpDisk{
path: path,
client: client,
}, nil
}
func (l sftpDisk) MkDir(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
}
func (l sftpDisk) ReadDir(path string) ([]Entry, error) { //nolint
panic("implement me")
return false, fmt.Errorf("failed to check if file exists: %w", err)
}
func (l sftpDisk) IsNotExist(err error) bool { //nolint
panic("implement me")
return s != nil, nil
}
func (l sftpDisk) IsExist(err error) bool { //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)
}
func (l sftpDisk) Open(path string, flag int) (io.WriteCloser, error) { //nolint
panic("implement me")
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 {
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 {
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 {
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) {
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) Open(path string, _ int) (io.WriteCloser, error) {
slog.Debug("opening for writing", slog.String("path", clean(path)), slog.String("schema", "sftp"))
f, err := l.client.Create(clean(path))
if err != nil {
slog.Error("failed to open file", slog.Any("err", err))
}
return f, nil
}

View file

@ -203,27 +203,27 @@ func (i *Installation) Validate(ctx *GlobalContext) error {
foundExecutable := false
err = d.Exists(filepath.Join(i.BasePath(), "FactoryGame.exe"))
exists, err := d.Exists(filepath.Join(i.BasePath(), "FactoryGame.exe"))
if !exists {
if err != nil {
if !d.IsNotExist(err) {
return fmt.Errorf("failed reading FactoryGame.exe: %w", err)
}
} else {
foundExecutable = true
}
err = d.Exists(filepath.Join(i.BasePath(), "FactoryServer.sh"))
exists, err = d.Exists(filepath.Join(i.BasePath(), "FactoryServer.sh"))
if !exists {
if err != nil {
if !d.IsNotExist(err) {
return fmt.Errorf("failed reading FactoryServer.sh: %w", err)
}
} else {
foundExecutable = true
}
err = d.Exists(filepath.Join(i.BasePath(), "FactoryServer.exe"))
exists, err = d.Exists(filepath.Join(i.BasePath(), "FactoryServer.exe"))
if !exists {
if err != nil {
if !d.IsNotExist(err) {
return fmt.Errorf("failed reading FactoryServer.exe: %w", err)
}
} else {
@ -269,17 +269,24 @@ 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 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,13 +717,13 @@ func (i *Installation) GetPlatform(ctx *GlobalContext) (*Platform, error) {
for _, platform := range platforms {
fullPath := filepath.Join(i.BasePath(), platform.VersionPath)
err := d.Exists(fullPath)
exists, err := d.Exists(fullPath)
if !exists {
if err != nil {
if d.IsNotExist(err) {
continue
}
return nil, fmt.Errorf("failed detecting version file: %w", err)
}
continue
}
return &platform, nil
}

View file

@ -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)
}

31
docker-compose-test.yml Executable file
View file

@ -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

5
go.mod
View file

@ -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

14
go.sum
View file

@ -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=

View file

@ -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")
exists, err := d.Exists(hashFile)
if err != nil {
return err
}
if exists {
hashBytes, err := d.Read(hashFile)
if err != nil {
if !d.IsNotExist(err) {
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)
}

38
utils/progress.go Normal file
View file

@ -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
}