From 742938b00222c7ad57ad11eb24850d9202c2503d Mon Sep 17 00:00:00 2001 From: Mitchell Riedstra Date: Wed, 4 Aug 2021 20:06:07 -0400 Subject: Pretty large structural changes. Non-building development snapshot --- steam/acf.go | 43 ++++++++++++ steam/archive.go | 2 - steam/extract.go | 66 ++++++++++++++++++ steam/game.go | 32 +++++++++ steam/http.go | 61 +++++++++++++++++ steam/package.go | 148 ++++++++++++++++++++++++++++------------ steam/status.go | 200 +++++++++++++++++++++++++++++++++++++++++++++++++++++++ steam/steam.go | 102 ++++++++++++++-------------- 8 files changed, 560 insertions(+), 94 deletions(-) create mode 100644 steam/acf.go create mode 100644 steam/extract.go create mode 100644 steam/game.go create mode 100644 steam/http.go create mode 100644 steam/status.go (limited to 'steam') diff --git a/steam/acf.go b/steam/acf.go new file mode 100644 index 0000000..7c034e1 --- /dev/null +++ b/steam/acf.go @@ -0,0 +1,43 @@ +package steam + +import ( + "bufio" + "fmt" + "os" + "path/filepath" + "strings" +) + +// FindACF will return the filename of the ACF file for a given `game` +func FindACF(libraryPath, game string) (string, error) { + files, err := filepath.Glob(filepath.Join(libraryPath, "*.acf")) + if err != nil { + return "", err + } + for _, fn := range files { + info, err := os.Lstat(fn) + if err != nil { + return "", err + } + // We don't want it if it's a directory + if info.IsDir() { + continue + } + + // Open up the file + f, err := os.Open(fn) + if err != nil { + return "", err + } + defer f.Close() + + scanner := bufio.NewScanner(f) + for scanner.Scan() { + if strings.Contains(scanner.Text(), game) { + return fn, nil + } + } + + } + return "", fmt.Errorf("Couldn't find ACF file related to Game: %s", game) +} diff --git a/steam/archive.go b/steam/archive.go index 296defa..72bab10 100644 --- a/steam/archive.go +++ b/steam/archive.go @@ -31,8 +31,6 @@ func tarWalkfn(writer *tar.Writer, prefix string) filepath.WalkFunc { tarPth = strings.ReplaceAll(tarPth, "\\", "/") tarPth = strings.TrimPrefix(tarPth, "/") - // TODO; See if tar.FileInfoheader() could be used instead - // without the pathing issues I encountered h := &tar.Header{ Name: tarPth, Size: info.Size(), diff --git a/steam/extract.go b/steam/extract.go new file mode 100644 index 0000000..f73d5f7 --- /dev/null +++ b/steam/extract.go @@ -0,0 +1,66 @@ +package steam + +import ( + "errors" + "fmt" + "io" + "time" +) + +// how often are we going to be updating our status information? +const updateEveryNBytes = 10 * 1024 * 1024 // 10mb + +// extractUpdate takes care of updating the job as it goes along at updateEveryNBytes +// it will be reported back to the Job's status. +func (l *Library) extractUpdate(j *Job, g *Game, rdr io.ReadCloser) (*Game, error) { + rdr, wrtr := io.Pipe() + + go func() { + var err error + g, err = l.extractPrimitive(j, g, rdr) + if err != nil { + j.addError(fmt.Errorf("Installer: extracting %s", err)) + } + // resp.Body.Close() + rdr.Close() + }() + + var total int64 + var err error + + for { + var n int64 + n, err = io.CopyN(wrtr, rdr, updateEveryNBytes) + if err == io.EOF { + break + } else if err != nil { + j.addError(fmt.Errorf( + "Error encountered read error: %w", err)) + break + } + + total += n + j.setTransferred(total) + + // rate in bytes/sec + rate := total / int64(time.Since(*j.StartTime()).Seconds()) + + estSize := j.GetSize() + + if estSize == nil { + j.addError(errors.New("Expected an estimated size, got nil")) + continue + } + + remaining := *estSize - total + + j.setETA(time.Duration((remaining / rate) / 1000 / 1000 / 1000)) + } + + if err == io.EOF { + return g, nil + } + + return g, err + +} diff --git a/steam/game.go b/steam/game.go new file mode 100644 index 0000000..0028257 --- /dev/null +++ b/steam/game.go @@ -0,0 +1,32 @@ +package steam + +import ( + "os" + "path/filepath" +) + +// GetSize returns the size of a game in a pretty format. If size is 0 +// it will call SetSizeInfo before returning +func (g *Game) GetSize() string { + if g.Size == 0 { + _ = g.SetSizeInfo() + } + return formatBytes(g.Size) +} + +// SetSizeInfo reads the size information for the game off of the disk +// and stores it in the Size struct element +func (g *Game) SetSizeInfo() error { + pth := filepath.Join(g.LibraryPath, "common", g.Name) + return filepath.Walk(pth, func(path string, info os.FileInfo, err error) error { + if err != nil { + return err + } + + if info.Mode().IsRegular() { + g.Size += info.Size() + } + + return nil + }) +} diff --git a/steam/http.go b/steam/http.go new file mode 100644 index 0000000..df756e4 --- /dev/null +++ b/steam/http.go @@ -0,0 +1,61 @@ +package steam + +import ( + "fmt" + "net/http" + "os" + "strconv" +) + +// ExtractFile is a wrapper around Extract that handles an HTTP endpoint. +// this spawns an "extractFile" on the library. Status will be updated there +// as this goes along. Non fatal and fatal errors will be populated there +func (l *Library) ExtractFile(fn string) (*Game, error) { + g := &Game{} + j := newJob("extractFile", g) + defer j.done() + + l.status.addJob(j) + + fi, err := os.Stat(fn) + if err != nil { + j.addError(err) + return g, err + } + j.setSize(fi.Size()) + + fh, err := os.Open(fn) + if err != nil { + j.addError(err) + return g, err + } + + return l.extractUpdate(j, g, fh) +} + +// ExtractHTTP is a wrapper around Extract that handles an HTTP endpoint. +// this spawns an "extractHTTP" on the library. Status will be updated there +// as this goes along. Non fatal and fatal errors will be populated there +func (l *Library) ExtractHTTP(url string) (*Game, error) { + g := &Game{} + j := newJob("extractHTTP", g) + defer j.done() + + l.status.addJob(j) + + resp, err := http.Get(url) + if err != nil { + j.addError(err) + return g, err + } + + estSize, err := strconv.ParseInt(resp.Header.Get("Estimated-size"), 10, 64) + if err != nil { + j.addError(err) + return g, fmt.Errorf("Failed to convert estimated size header: %w", err) + } + + j.setSize(estSize) + + return l.extractUpdate(j, g, resp.Body) +} diff --git a/steam/package.go b/steam/package.go index bd5bfb5..df6f1e8 100644 --- a/steam/package.go +++ b/steam/package.go @@ -8,39 +8,79 @@ import ( "strings" ) -// Package writes the package, returning bytes written and an error if any -func (g *Game) Package(wr io.WriteCloser) error { - acf, err := FindACF(g.LibraryPath, g.Name) +const ( + defaultDirectoryMode = 0775 + defaultFileMode = 0644 +) + +// Package writes the package to wr, returning errors if any +func (l *Library) Package(game string, wr io.WriteCloser) error { + g, ok := l.games[game] + if !ok { + return E_GameDoesNotExist + } + + j := newJob("package", g) + defer j.done() + + l.status.addJob(j) + + acf, err := FindACF(l.folder, g.Name) if err != nil { + j.addError(err) return err } twriter := tar.NewWriter(wr) paths := []string{ - filepath.Join(g.LibraryPath, "common", g.Name), + filepath.Join(l.folder, "common", g.Name), acf, } for _, pth := range paths { - err := filepath.Walk(pth, tarWalkfn(twriter, g.LibraryPath)) + err := filepath.Walk(pth, tarWalkfn(twriter, l.folder)) if err != nil { + j.addError(err) return err } } err = twriter.Flush() if err != nil { + j.addError(err) return err } err = twriter.Close() + if err != nil { + j.addError(err) + return err + } - return wr.Close() + err = wr.Close() + if err != nil { + j.addError(err) + } + return err } // Extract will read a tarball from the io.Reader and install the game into -// the current library path -func (l *Library) Extract(r io.Reader) error { +// the current library path. This offers no visibility into the progress, +// as it does not update the job status on the progress, though it will +// populate errors. +// +// Most callers will want to use ExtractHTTP or ExtractFile instead +func (l *Library) Extract(r io.Reader) (*Game, error) { + g := &Game{LibraryPath: l.folder} + j := newJob("extract", g) + defer j.done() + + l.status.addJob(j) + + return l.extractPrimitive(j, g, r) +} + +func (l *Library) extractPrimitive(j *Job, g *Game, r io.Reader) (*Game, error) { treader := tar.NewReader(r) for { @@ -50,78 +90,100 @@ func (l *Library) Extract(r io.Reader) error { break } if err != nil { - return err + j.addError(err) + return nil, err } - // Fix windows slashes... - fileName := strings.Replace(hdr.Name, "\\", "/", -1) + fileName := filepath.ToSlash(hdr.Name) + + if g.Name == "" { + s := strings.Split(fileName, "/") + if len(s) >= 2 { + g.Name = s[1] + } + } - fileName = filepath.Join(l.Folder, fileName) + fileName = filepath.Join(l.folder, fileName) info := hdr.FileInfo() if info.IsDir() { // I don't like hard-coded permissions but it // it helps with overall platform compatibility - if err = os.MkdirAll(fileName, 0775); err != nil { - return err + err = os.MkdirAll(fileName, defaultDirectoryMode) + if err != nil { + j.addError(err) + return nil, err } + continue } - if err = os.MkdirAll(filepath.Dir(fileName), 0775); err != nil { - return err + err = os.MkdirAll(filepath.Dir(fileName), defaultDirectoryMode) + if err != nil { + j.addError(err) + return nil, err } // Create a file handle to work with - f, err := os.OpenFile(fileName, os.O_CREATE|os.O_TRUNC|os.O_WRONLY, 0664) + f, err := os.OpenFile(fileName, os.O_CREATE|os.O_TRUNC|os.O_WRONLY, + defaultFileMode) if err != nil { - return err + j.addError(err) + return nil, err } if _, err := io.Copy(f, treader); err != nil { + j.addError(err) f.Close() - return err + return nil, err } f.Close() } - return nil + err := g.SetSizeInfo() + if err != nil { + j.addError(err) + return nil, err + } + + l.m.Lock() + l.games[g.Name] = g + l.m.Unlock() + + return g, nil } // Delete removes all of the game files and the ACF -func (g *Game) Delete() error { - acf, err := FindACF(g.LibraryPath, g.Name) +func (l *Library) Delete(game string) error { + g, ok := l.games[game] + if !ok { + return E_GameDoesNotExist + } + + j := newJob("delete", g) + defer j.done() + + l.status.addJob(j) + + acf, err := FindACF(l.folder, game) if err != nil { + j.addError(err) return err } if err := os.Remove(acf); err != nil { + j.addError(err) return err } - err = os.RemoveAll(filepath.Join(g.LibraryPath, "common", g.Name)) + err = os.RemoveAll(filepath.Join(l.folder, "common", g.Name)) if err != nil { + j.addError(err) return err } - return nil -} - -// GetSize returns the size of a game in a pretty format -func (g Game) GetSize() string { - return formatBytes(g.Size) -} - -func (g *Game) setSizeInfo() error { - pth := filepath.Join(g.LibraryPath, "common", g.Name) - return filepath.Walk(pth, func(path string, info os.FileInfo, err error) error { - if err != nil { - return err - } + l.m.Lock() + delete(l.games, game) + l.m.Unlock() - if info.Mode().IsRegular() { - g.Size += info.Size() - } - - return nil - }) + return nil } diff --git a/steam/status.go b/steam/status.go new file mode 100644 index 0000000..12f5e3b --- /dev/null +++ b/steam/status.go @@ -0,0 +1,200 @@ +package steam + +import ( + "sync" + "time" +) + +// JobStatus provides specific information about an individual job +type Job struct { + action string + target *Game + running bool + start *time.Time + errors []error + + // If applicablle + size *int64 + transferred *int64 + eta *time.Duration + + m sync.Mutex +} + +// Action is a short string describing the action, i.e. "packaging", "deleting" +func (j *Job) Action() string { + j.m.Lock() + defer j.m.Unlock() + return j.action +} + +// Target returns the game that is the target of the action +func (j *Job) Target() *Game { + j.m.Lock() + defer j.m.Unlock() + return j.target +} + +// IsRunning returns true if the job is currently running, otherwise false +func (j *Job) IsRunning() bool { + j.m.Lock() + defer j.m.Unlock() + return j.running +} + +// StartTime returns the time in which the job started +func (j *Job) StartTime() *time.Time { + j.m.Lock() + defer j.m.Unlock() + return j.start +} + +func (j *Job) Errors() []error { + j.m.Lock() + defer j.m.Unlock() + return j.errors +} + +// newJob sets up a job of action for the target Game +func newJob(action string, target *Game) *Job { + t := time.Now() + return &Job{ + action: action, + target: target, + running: true, + start: &t, + } +} + +func (j *Job) setSize(size int64) { + j.m.Lock() + defer j.m.Unlock() + j.size = &size +} + +// GetSize returns the size set if applicable for the operation +func (j *Job) GetSize() *int64 { + j.m.Lock() + defer j.m.Unlock() + return j.size +} + +func (j *Job) setTransferred(transferred int64) { + j.m.Lock() + defer j.m.Unlock() + j.transferred = &transferred +} + +// GetTransferred returns the transferred set if applicable for the operation +func (j *Job) GetTransferred() *int64 { + j.m.Lock() + defer j.m.Unlock() + return j.transferred +} + +// setETA sets the eta to the speicifed duration +func (j *Job) setETA(d time.Duration) { + j.m.Lock() + defer j.m.Unlock() + j.eta = &d +} + +// GetETA returns the ETA to completion as a *time.Duration. nil represents +// when +func (j *Job) GetETA() *time.Duration { + j.m.Lock() + defer j.m.Unlock() + return j.eta +} + +// done sets running to false +func (j *Job) done() { + j.m.Lock() + defer j.m.Unlock() + j.running = false +} + +// addError appends an error to the internal slice of errors +func (j *Job) addError(err error) { + j.m.Lock() + defer j.m.Unlock() + j.errors = append(j.errors, err) +} + +// Jobs is by the Library to determine whether or not we currently have any +// jobs running on this library, as well as to give some history as to +// what jobs have been run previously +type Jobs struct { + running []*Job + previous []*Job + + m sync.Mutex +} + +func (jobs *Jobs) scan() { + jobs.m.Lock() + defer jobs.m.Unlock() + running := []*Job{} + notrunning := []*Job{} + + for _, job := range jobs.running { + if job == nil { + continue + } + + if job.IsRunning() == true { + running = append(running, job) + } else { + notrunning = append(notrunning, job) + } + } + + if len(notrunning) > 0 { + jobs.previous = append(jobs.previous, notrunning...) + } + + jobs.running = running +} + +// Running returns true if any job is currently running, otherwise false +func (jobs *Jobs) Running() bool { + jobs.scan() + jobs.m.Lock() + defer jobs.m.Unlock() + if len(jobs.running) == 0 { + return false + } + return true +} + +// GetJobs returns all of the jobs regardless of their state +func (jobs *Jobs) GetJobs() []*Job { + jobs.scan() + jobs.m.Lock() + defer jobs.m.Unlock() + return append(jobs.running, jobs.previous...) +} + +// GetRunningJobs returns all of the running jobs +func (jobs *Jobs) GetRunningJobs() []*Job { + jobs.scan() + jobs.m.Lock() + defer jobs.m.Unlock() + return jobs.running +} + +// GetStoppedJobs returns all of the stopped jobs +func (jobs *Jobs) GetStoppedJobs() []*Job { + jobs.scan() + jobs.m.Lock() + defer jobs.m.Unlock() + return jobs.previous +} + +// addJob adds a job to the internal slice +func (jobs *Jobs) addJob(j *Job) { + jobs.m.Lock() + jobs.running = append(jobs.running, j) + jobs.m.Unlock() + jobs.scan() +} diff --git a/steam/steam.go b/steam/steam.go index ff0ce5a..66ef7f4 100644 --- a/steam/steam.go +++ b/steam/steam.go @@ -4,24 +4,31 @@ package steam import ( - "bufio" + "errors" "fmt" "io/ioutil" - "os" - "path/filepath" "regexp" - "strings" + "sync" ) +var E_GameDoesNotExist = errors.New("Game does not exist") + // Library is used to represent the steam library, the Games map is populated // by NewLibrary when called or when ProcessLibrary is called directly +// The key in the map is the same as the Game's `Name` property. +// +// Status contains various bits of information about previous jobs +// and the status of any currently running jobs type Library struct { - Folder string - Games map[string]Game + folder string + games map[string]*Game + status *Jobs + + m sync.Mutex } -// Game represents an actual game in the steam Library, allowing you to perform -// a delete, package, and such. +// Game represents an actual game in the steam Library. The purpose is only +// to provide info on a game. type Game struct { Name string LibraryPath string @@ -39,7 +46,12 @@ func (g Game) Slug() string { // NewLibrary returns a pointer to a processed library and an error // if any func NewLibrary(path string) (*Library, error) { - l := &Library{} + l := &Library{ + status: &Jobs{ + running: make([]*Job, 0), + previous: make([]*Job, 0), + }, + } err := l.ProcessLibrary(path) if err != nil { return nil, err @@ -57,73 +69,65 @@ func NewLibraryMust(path string) *Library { return l } +// Games returns a slice of *Game for the current library +func (l *Library) Games() []*Game { + l.m.Lock() + out := []*Game{} + for _, g := range l.games { + out = append(out, g) + } + l.m.Unlock() + + return out +} + +// Jobs returns the current *Jobs struct which can be used to keep track +// of any long running operations on the library as well as any errors +// encountered along the way +func (l *Library) Status() *Jobs { + return l.status +} + // ProcessLibrary Populates the "Folder" and "Games" fields based on the // provided directory. +// func (s *Library) ProcessLibrary(r string) error { + if s.status.Running() { + return errors.New("Cannot process library with actions running") + } + if !hasCommon(r) { return fmt.Errorf("No common directory in: %s", r) } - s.Games = make(map[string]Game) + s.m.Lock() + defer s.m.Unlock() + s.games = make(map[string]*Game) dirs, err := ioutil.ReadDir(r + "/common") if err != nil { return err } - s.Folder = r + s.folder = r for _, f := range dirs { if f.IsDir() { g := &Game{ Name: f.Name(), LibraryPath: r, } - g.setSizeInfo() + g.SetSizeInfo() - s.Games[f.Name()] = *g + s.games[f.Name()] = g } } return nil } -// FindACF will return the filename of the ACF file for a given `game` -func FindACF(libraryPath, game string) (string, error) { - files, err := filepath.Glob(filepath.Join(libraryPath, "*.acf")) - if err != nil { - return "", err - } - for _, fn := range files { - info, err := os.Lstat(fn) - if err != nil { - return "", err - } - // We don't want it if it's a directory - if info.IsDir() { - continue - } - - // Open up the file - f, err := os.Open(fn) - if err != nil { - return "", err - } - defer f.Close() - - scanner := bufio.NewScanner(f) - for scanner.Scan() { - if strings.Contains(scanner.Text(), game) { - return fn, nil - } - } - - } - return "", fmt.Errorf("Couldn't find ACF file related to Game: %s", game) -} - func (s *Library) String() (str string) { - str = fmt.Sprintf("Library: %s\n", s.Folder) + str = fmt.Sprintf("Library: %s\n", s.folder) str = str + "----\n" - for _, v := range s.Games { + for _, v := range s.games { str = str + fmt.Sprintf("%s\n", v.Name) } return -- cgit v1.2.3