aboutsummaryrefslogtreecommitdiff
path: root/steam
diff options
context:
space:
mode:
Diffstat (limited to 'steam')
-rw-r--r--steam/acf.go43
-rw-r--r--steam/archive.go2
-rw-r--r--steam/extract.go66
-rw-r--r--steam/game.go32
-rw-r--r--steam/http.go61
-rw-r--r--steam/package.go148
-rw-r--r--steam/status.go200
-rw-r--r--steam/steam.go102
8 files changed, 560 insertions, 94 deletions
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