feat: offline mode (#14)

* chore: move mod downloading to cli/cache

* feat: data providers, ficsit and local

* feat: keep cache in memory, load on init

* feat: log invalid cache files instead of returning error

* chore: make linter happy

* feat: fill cached mod Authors field from CreatedBy

* chore: make linter happy again

* feat: add icon and size to cached mods

* feat: cache the cached file hashes

* fix: change to new provider access style

---------

Co-authored-by: Vilsol <me@vil.so>
This commit is contained in:
mircearoata 2023-12-06 05:47:41 +01:00 committed by GitHub
parent ea983cf851
commit e4b02a792d
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
24 changed files with 909 additions and 141 deletions

164
cli/cache/cache.go vendored Normal file
View file

@ -0,0 +1,164 @@
package cache
import (
"archive/zip"
"encoding/base64"
"encoding/json"
"io"
"os"
"path/filepath"
"strings"
"github.com/pkg/errors"
"github.com/rs/zerolog/log"
"github.com/spf13/viper"
)
const IconFilename = "Resources/Icon128.png" // This is the path UE expects for the icon
type File struct {
Icon *string
ModReference string
Hash string
Plugin UPlugin
Size int64
}
var loadedCache map[string][]File
func GetCache() (map[string][]File, error) {
if loadedCache != nil {
return loadedCache, nil
}
return LoadCache()
}
func GetCacheMod(mod string) ([]File, error) {
cache, err := GetCache()
if err != nil {
return nil, err
}
return cache[mod], nil
}
func LoadCache() (map[string][]File, error) {
loadedCache = map[string][]File{}
downloadCache := filepath.Join(viper.GetString("cache-dir"), "downloadCache")
if _, err := os.Stat(downloadCache); os.IsNotExist(err) {
return map[string][]File{}, nil
}
items, err := os.ReadDir(downloadCache)
if err != nil {
return nil, errors.Wrap(err, "failed reading download cache")
}
for _, item := range items {
if item.IsDir() {
continue
}
if item.Name() == integrityFilename {
continue
}
_, err = addFileToCache(item.Name())
if err != nil {
log.Err(err).Str("file", item.Name()).Msg("failed to add file to cache")
}
}
return loadedCache, nil
}
func addFileToCache(filename string) (*File, error) {
cacheFile, err := readCacheFile(filename)
if err != nil {
return nil, errors.Wrap(err, "failed to read cache file")
}
loadedCache[cacheFile.ModReference] = append(loadedCache[cacheFile.ModReference], *cacheFile)
return cacheFile, nil
}
func readCacheFile(filename string) (*File, error) {
downloadCache := filepath.Join(viper.GetString("cache-dir"), "downloadCache")
path := filepath.Join(downloadCache, filename)
stat, err := os.Stat(path)
if err != nil {
return nil, errors.Wrap(err, "failed to stat file")
}
zipFile, err := os.Open(path)
if err != nil {
return nil, errors.Wrap(err, "failed to open file")
}
defer zipFile.Close()
size := stat.Size()
reader, err := zip.NewReader(zipFile, size)
if err != nil {
return nil, errors.Wrap(err, "failed to read zip")
}
var upluginFile *zip.File
for _, file := range reader.File {
if strings.HasSuffix(file.Name, ".uplugin") {
upluginFile = file
break
}
}
if upluginFile == nil {
return nil, errors.New("no uplugin file found in zip")
}
upluginReader, err := upluginFile.Open()
if err != nil {
return nil, errors.Wrap(err, "failed to open uplugin file")
}
var uplugin UPlugin
data, err := io.ReadAll(upluginReader)
if err != nil {
return nil, errors.Wrap(err, "failed to read uplugin file")
}
if err := json.Unmarshal(data, &uplugin); err != nil {
return nil, errors.Wrap(err, "failed to unmarshal uplugin file")
}
modReference := strings.TrimSuffix(upluginFile.Name, ".uplugin")
hash, err := getFileHash(filename)
if err != nil {
return nil, errors.Wrap(err, "failed to get file hash")
}
var iconFile *zip.File
for _, file := range reader.File {
if file.Name == IconFilename {
iconFile = file
break
}
}
var icon *string
if iconFile != nil {
iconReader, err := iconFile.Open()
if err != nil {
return nil, errors.Wrap(err, "failed to open icon file")
}
defer iconReader.Close()
data, err := io.ReadAll(iconReader)
if err != nil {
return nil, errors.Wrap(err, "failed to read icon file")
}
iconData := base64.StdEncoding.EncodeToString(data)
icon = &iconData
}
return &File{
ModReference: modReference,
Hash: hash,
Size: size,
Icon: icon,
Plugin: uplugin,
}, nil
}

130
cli/cache/download.go vendored Normal file
View file

@ -0,0 +1,130 @@
package cache
import (
"fmt"
"io"
"net/http"
"os"
"path/filepath"
"github.com/pkg/errors"
"github.com/spf13/viper"
"github.com/satisfactorymodding/ficsit-cli/utils"
)
type Progresser struct {
io.Reader
updates chan utils.GenericUpdate
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 <- utils.GenericUpdate{Progress: float64(pt.running) / float64(pt.total)}:
default:
}
}
}
if err == io.EOF {
return n, io.EOF
}
return n, errors.Wrap(err, "failed to read")
}
func DownloadOrCache(cacheKey string, hash string, url string, updates chan utils.GenericUpdate) (io.ReaderAt, int64, error) {
downloadCache := filepath.Join(viper.GetString("cache-dir"), "downloadCache")
if err := os.MkdirAll(downloadCache, 0o777); err != nil {
if !os.IsExist(err) {
return nil, 0, errors.Wrap(err, "failed creating download cache")
}
}
location := filepath.Join(downloadCache, cacheKey)
stat, err := os.Stat(location)
if err == nil {
existingHash := ""
if hash != "" {
f, err := os.Open(location)
if err != nil {
return nil, 0, errors.Wrap(err, "failed to open file: "+location)
}
existingHash, err = utils.SHA256Data(f)
if err != nil {
return nil, 0, errors.Wrap(err, "could not compute hash for file: "+location)
}
}
if hash == existingHash {
f, err := os.Open(location)
if err != nil {
return nil, 0, errors.Wrap(err, "failed to open file: "+location)
}
return f, stat.Size(), nil
}
if err := os.Remove(location); err != nil {
return nil, 0, errors.Wrap(err, "failed to delete file: "+location)
}
} else if !os.IsNotExist(err) {
return nil, 0, errors.Wrap(err, "failed to stat file: "+location)
}
out, err := os.Create(location)
if err != nil {
return nil, 0, errors.Wrap(err, "failed creating file at: "+location)
}
defer out.Close()
resp, err := http.Get(url)
if err != nil {
return nil, 0, errors.Wrap(err, "failed to fetch: "+url)
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
return nil, 0, fmt.Errorf("bad status: %s on url: %s", resp.Status, url)
}
progresser := &Progresser{
Reader: resp.Body,
total: resp.ContentLength,
updates: updates,
}
_, err = io.Copy(out, progresser)
if err != nil {
return nil, 0, errors.Wrap(err, "failed writing file to disk")
}
f, err := os.Open(location)
if err != nil {
return nil, 0, errors.Wrap(err, "failed to open file: "+location)
}
if updates != nil {
select {
case updates <- utils.GenericUpdate{Progress: 1}:
default:
}
}
_, err = addFileToCache(cacheKey)
if err != nil {
return nil, 0, errors.Wrap(err, "failed to add file to cache")
}
return f, resp.ContentLength, nil
}

107
cli/cache/integrity.go vendored Normal file
View file

@ -0,0 +1,107 @@
package cache
import (
"encoding/json"
"io"
"os"
"path/filepath"
"time"
"github.com/pkg/errors"
"github.com/rs/zerolog/log"
"github.com/spf13/viper"
"github.com/satisfactorymodding/ficsit-cli/utils"
)
type hashInfo struct {
Modified time.Time
Hash string
Size int64
}
var hashCache map[string]hashInfo
var integrityFilename = ".integrity"
func getFileHash(file string) (string, error) {
if hashCache == nil {
loadHashCache()
}
cachedHash, ok := hashCache[file]
if !ok {
return cacheFileHash(file)
}
downloadCache := filepath.Join(viper.GetString("cache-dir"), "downloadCache")
stat, err := os.Stat(filepath.Join(downloadCache, file))
if err != nil {
return "", errors.Wrap(err, "failed to stat file")
}
if stat.Size() != cachedHash.Size || stat.ModTime() != cachedHash.Modified {
return cacheFileHash(file)
}
return cachedHash.Hash, nil
}
func cacheFileHash(file string) (string, error) {
downloadCache := filepath.Join(viper.GetString("cache-dir"), "downloadCache")
stat, err := os.Stat(filepath.Join(downloadCache, file))
if err != nil {
return "", errors.Wrap(err, "failed to stat file")
}
f, err := os.Open(filepath.Join(downloadCache, file))
if err != nil {
return "", errors.Wrap(err, "failed to open file")
}
defer f.Close()
hash, err := utils.SHA256Data(f)
if err != nil {
return "", errors.Wrap(err, "failed to hash file")
}
hashCache[file] = hashInfo{
Hash: hash,
Size: stat.Size(),
Modified: stat.ModTime(),
}
saveHashCache()
return hash, nil
}
func loadHashCache() {
hashCache = map[string]hashInfo{}
cacheFile := filepath.Join(viper.GetString("cache-dir"), "downloadCache", integrityFilename)
if _, err := os.Stat(cacheFile); os.IsNotExist(err) {
return
}
f, err := os.Open(cacheFile)
if err != nil {
log.Warn().Err(err).Msg("failed to open hash cache, recreating")
return
}
defer f.Close()
hashCacheJSON, err := io.ReadAll(f)
if err != nil {
log.Warn().Err(err).Msg("failed to read hash cache, recreating")
return
}
if err := json.Unmarshal(hashCacheJSON, &hashCache); err != nil {
log.Warn().Err(err).Msg("failed to unmarshal hash cache, recreating")
return
}
}
func saveHashCache() {
cacheFile := filepath.Join(viper.GetString("cache-dir"), "downloadCache", integrityFilename)
hashCacheJSON, err := json.Marshal(hashCache)
if err != nil {
log.Warn().Err(err).Msg("failed to marshal hash cache")
return
}
if err := os.WriteFile(cacheFile, hashCacheJSON, 0o755); err != nil {
log.Warn().Err(err).Msg("failed to write hash cache")
return
}
}

16
cli/cache/uplugin.go vendored Normal file
View file

@ -0,0 +1,16 @@
package cache
type UPlugin struct {
SemVersion string `json:"SemVersion"`
FriendlyName string `json:"FriendlyName"`
Description string `json:"Description"`
CreatedBy string `json:"CreatedBy"`
Plugins []Plugins `json:"Plugins"`
}
type Plugins struct {
Name string `json:"Name"`
SemVersion string `json:"SemVersion"`
Enabled bool `json:"Enabled"`
BasePlugin bool `json:"BasePlugin"`
Optional bool `json:"Optional"`
}

View file

@ -3,7 +3,10 @@ package cli
import ( import (
"github.com/Khan/genqlient/graphql" "github.com/Khan/genqlient/graphql"
"github.com/pkg/errors" "github.com/pkg/errors"
"github.com/spf13/viper"
"github.com/satisfactorymodding/ficsit-cli/cli/cache"
"github.com/satisfactorymodding/ficsit-cli/cli/provider"
"github.com/satisfactorymodding/ficsit-cli/ficsit" "github.com/satisfactorymodding/ficsit-cli/ficsit"
) )
@ -11,6 +14,7 @@ type GlobalContext struct {
Installations *Installations Installations *Installations
Profiles *Profiles Profiles *Profiles
APIClient graphql.Client APIClient graphql.Client
Provider *provider.MixedProvider
} }
var globalContext *GlobalContext var globalContext *GlobalContext
@ -20,6 +24,14 @@ func InitCLI(apiOnly bool) (*GlobalContext, error) {
return globalContext, nil return globalContext, nil
} }
apiClient := ficsit.InitAPI()
mixedProvider := provider.InitMixedProvider(apiClient)
if viper.GetBool("offline") {
mixedProvider.Offline = true
}
if !apiOnly { if !apiOnly {
profiles, err := InitProfiles() profiles, err := InitProfiles()
if err != nil { if err != nil {
@ -31,14 +43,21 @@ func InitCLI(apiOnly bool) (*GlobalContext, error) {
return nil, errors.Wrap(err, "failed to initialize installations") return nil, errors.Wrap(err, "failed to initialize installations")
} }
_, err = cache.LoadCache()
if err != nil {
return nil, errors.Wrap(err, "failed to load cache")
}
globalContext = &GlobalContext{ globalContext = &GlobalContext{
Installations: installations, Installations: installations,
Profiles: profiles, Profiles: profiles,
APIClient: ficsit.InitAPI(), APIClient: apiClient,
Provider: mixedProvider,
} }
} else { } else {
globalContext = &GlobalContext{ globalContext = &GlobalContext{
APIClient: ficsit.InitAPI(), APIClient: apiClient,
Provider: mixedProvider,
} }
} }

View file

@ -5,24 +5,24 @@ import (
"fmt" "fmt"
"slices" "slices"
"github.com/Khan/genqlient/graphql"
"github.com/mircearoata/pubgrub-go/pubgrub" "github.com/mircearoata/pubgrub-go/pubgrub"
"github.com/mircearoata/pubgrub-go/pubgrub/helpers" "github.com/mircearoata/pubgrub-go/pubgrub/helpers"
"github.com/mircearoata/pubgrub-go/pubgrub/semver" "github.com/mircearoata/pubgrub-go/pubgrub/semver"
"github.com/pkg/errors" "github.com/pkg/errors"
"github.com/spf13/viper" "github.com/spf13/viper"
"github.com/satisfactorymodding/ficsit-cli/cli/provider"
"github.com/satisfactorymodding/ficsit-cli/ficsit" "github.com/satisfactorymodding/ficsit-cli/ficsit"
) )
const smlDownloadTemplate = `https://github.com/satisfactorymodding/SatisfactoryModLoader/releases/download/v%s/SML.zip` const smlDownloadTemplate = `https://github.com/satisfactorymodding/SatisfactoryModLoader/releases/download/v%s/SML.zip`
type DependencyResolver struct { type DependencyResolver struct {
apiClient graphql.Client provider provider.Provider
} }
func NewDependencyResolver(apiClient graphql.Client) DependencyResolver { func NewDependencyResolver(provider provider.Provider) DependencyResolver {
return DependencyResolver{apiClient: apiClient} return DependencyResolver{provider}
} }
var ( var (
@ -32,7 +32,7 @@ var (
) )
type ficsitAPISource struct { type ficsitAPISource struct {
apiClient graphql.Client provider provider.Provider
lockfile *LockFile lockfile *LockFile
toInstall map[string]semver.Constraint toInstall map[string]semver.Constraint
modVersionInfo map[string]ficsit.ModVersionsWithDependenciesResponse modVersionInfo map[string]ficsit.ModVersionsWithDependenciesResponse
@ -67,7 +67,7 @@ func (f *ficsitAPISource) GetPackageVersions(pkg string) ([]pubgrub.PackageVersi
} }
return versions, nil return versions, nil
} }
response, err := ficsit.ModVersionsWithDependencies(context.TODO(), f.apiClient, pkg) response, err := f.provider.ModVersionsWithDependencies(context.TODO(), pkg)
if err != nil { if err != nil {
return nil, errors.Wrapf(err, "failed to fetch mod %s", pkg) return nil, errors.Wrapf(err, "failed to fetch mod %s", pkg)
} }
@ -120,7 +120,7 @@ func (f *ficsitAPISource) PickVersion(pkg string, versions []semver.Version) sem
} }
func (d DependencyResolver) ResolveModDependencies(constraints map[string]string, lockFile *LockFile, gameVersion int) (LockFile, error) { func (d DependencyResolver) ResolveModDependencies(constraints map[string]string, lockFile *LockFile, gameVersion int) (LockFile, error) {
smlVersionsDB, err := ficsit.SMLVersions(context.TODO(), d.apiClient) smlVersionsDB, err := d.provider.SMLVersions(context.TODO())
if err != nil { if err != nil {
return nil, errors.Wrap(err, "failed fetching SML versions") return nil, errors.Wrap(err, "failed fetching SML versions")
} }
@ -140,7 +140,7 @@ func (d DependencyResolver) ResolveModDependencies(constraints map[string]string
} }
ficsitSource := &ficsitAPISource{ ficsitSource := &ficsitAPISource{
apiClient: d.apiClient, provider: d.provider,
smlVersions: smlVersionsDB.SmlVersions.Sml_versions, smlVersions: smlVersionsDB.SmlVersions.Sml_versions,
gameVersion: gameVersionSemver, gameVersion: gameVersionSemver,
lockfile: lockFile, lockfile: lockFile,
@ -153,7 +153,7 @@ func (d DependencyResolver) ResolveModDependencies(constraints map[string]string
finalError := err finalError := err
var solverErr pubgrub.SolvingError var solverErr pubgrub.SolvingError
if errors.As(err, &solverErr) { if errors.As(err, &solverErr) {
finalError = DependencyResolverError{SolvingError: solverErr, apiClient: d.apiClient, smlVersions: smlVersionsDB.SmlVersions.Sml_versions, gameVersion: gameVersion} finalError = DependencyResolverError{SolvingError: solverErr, provider: d.provider, smlVersions: smlVersionsDB.SmlVersions.Sml_versions, gameVersion: gameVersion}
} }
return nil, errors.Wrap(finalError, "failed to solve dependencies") return nil, errors.Wrap(finalError, "failed to solve dependencies")
} }

View file

@ -5,16 +5,16 @@ import (
"fmt" "fmt"
"strings" "strings"
"github.com/Khan/genqlient/graphql"
"github.com/mircearoata/pubgrub-go/pubgrub" "github.com/mircearoata/pubgrub-go/pubgrub"
"github.com/mircearoata/pubgrub-go/pubgrub/semver" "github.com/mircearoata/pubgrub-go/pubgrub/semver"
"github.com/satisfactorymodding/ficsit-cli/cli/provider"
"github.com/satisfactorymodding/ficsit-cli/ficsit" "github.com/satisfactorymodding/ficsit-cli/ficsit"
) )
type DependencyResolverError struct { type DependencyResolverError struct {
pubgrub.SolvingError pubgrub.SolvingError
apiClient graphql.Client provider provider.Provider
smlVersions []ficsit.SMLVersionsSmlVersionsGetSMLVersionsSml_versionsSMLVersion smlVersions []ficsit.SMLVersionsSmlVersionsGetSMLVersionsSml_versionsSMLVersion
gameVersion int gameVersion int
} }
@ -23,7 +23,7 @@ func (e DependencyResolverError) Error() string {
rootPkg := e.Cause().Terms()[0].Dependency() rootPkg := e.Cause().Terms()[0].Dependency()
writer := pubgrub.NewStandardErrorWriter(rootPkg). writer := pubgrub.NewStandardErrorWriter(rootPkg).
WithIncompatibilityStringer( WithIncompatibilityStringer(
MakeDependencyResolverErrorStringer(e.apiClient, e.smlVersions, e.gameVersion), MakeDependencyResolverErrorStringer(e.provider, e.smlVersions, e.gameVersion),
) )
e.WriteTo(writer) e.WriteTo(writer)
return writer.String() return writer.String()
@ -31,15 +31,15 @@ func (e DependencyResolverError) Error() string {
type DependencyResolverErrorStringer struct { type DependencyResolverErrorStringer struct {
pubgrub.StandardIncompatibilityStringer pubgrub.StandardIncompatibilityStringer
apiClient graphql.Client provider provider.Provider
packageNames map[string]string packageNames map[string]string
smlVersions []ficsit.SMLVersionsSmlVersionsGetSMLVersionsSml_versionsSMLVersion smlVersions []ficsit.SMLVersionsSmlVersionsGetSMLVersionsSml_versionsSMLVersion
gameVersion int gameVersion int
} }
func MakeDependencyResolverErrorStringer(apiClient graphql.Client, smlVersions []ficsit.SMLVersionsSmlVersionsGetSMLVersionsSml_versionsSMLVersion, gameVersion int) *DependencyResolverErrorStringer { func MakeDependencyResolverErrorStringer(provider provider.Provider, smlVersions []ficsit.SMLVersionsSmlVersionsGetSMLVersionsSml_versionsSMLVersion, gameVersion int) *DependencyResolverErrorStringer {
s := &DependencyResolverErrorStringer{ s := &DependencyResolverErrorStringer{
apiClient: apiClient, provider: provider,
smlVersions: smlVersions, smlVersions: smlVersions,
gameVersion: gameVersion, gameVersion: gameVersion,
packageNames: map[string]string{}, packageNames: map[string]string{},
@ -58,7 +58,7 @@ func (w *DependencyResolverErrorStringer) getPackageName(pkg string) string {
if name, ok := w.packageNames[pkg]; ok { if name, ok := w.packageNames[pkg]; ok {
return name return name
} }
result, err := ficsit.GetModName(context.Background(), w.apiClient, pkg) result, err := w.provider.GetModName(context.TODO(), pkg)
if err != nil { if err != nil {
return pkg return pkg
} }
@ -98,7 +98,7 @@ func (w *DependencyResolverErrorStringer) Term(t pubgrub.Term, includeVersion bo
} }
return fmt.Sprintf("%s \"%s\"", fullName, t.Constraint()) return fmt.Sprintf("%s \"%s\"", fullName, t.Constraint())
default: default:
res, err := ficsit.ModVersions(context.Background(), w.apiClient, t.Dependency(), ficsit.VersionFilter{ res, err := w.provider.ModVersions(context.TODO(), t.Dependency(), ficsit.VersionFilter{
Limit: 100, Limit: 100,
}) })
if err != nil { if err != nil {

View file

@ -14,6 +14,7 @@ import (
"github.com/rs/zerolog/log" "github.com/rs/zerolog/log"
"github.com/spf13/viper" "github.com/spf13/viper"
"github.com/satisfactorymodding/ficsit-cli/cli/cache"
"github.com/satisfactorymodding/ficsit-cli/cli/disk" "github.com/satisfactorymodding/ficsit-cli/cli/disk"
"github.com/satisfactorymodding/ficsit-cli/utils" "github.com/satisfactorymodding/ficsit-cli/utils"
) )
@ -317,7 +318,7 @@ func (i *Installation) ResolveProfile(ctx *GlobalContext) (LockFile, error) {
return nil, err return nil, err
} }
resolver := NewDependencyResolver(ctx.APIClient) resolver := NewDependencyResolver(ctx.Provider)
gameVersion, err := i.GetGameVersion(ctx) gameVersion, err := i.GetGameVersion(ctx)
if err != nil { if err != nil {
@ -446,7 +447,7 @@ func (i *Installation) Install(ctx *GlobalContext, updates chan InstallUpdate) e
} }
log.Info().Str("mod_reference", modReference).Str("version", version.Version).Str("link", version.Link).Msg("downloading mod") log.Info().Str("mod_reference", modReference).Str("version", version.Version).Str("link", version.Link).Msg("downloading mod")
reader, size, err := utils.DownloadOrCache(modReference+"_"+version.Version+".zip", version.Hash, version.Link, genericUpdates) reader, size, err := cache.DownloadOrCache(modReference+"_"+version.Version+".zip", version.Hash, version.Link, genericUpdates)
if err != nil { if err != nil {
return errors.Wrap(err, "failed to download "+modReference+" from: "+version.Link) return errors.Wrap(err, "failed to download "+modReference+" from: "+version.Link)
} }

47
cli/provider/ficsit.go Normal file
View file

@ -0,0 +1,47 @@
package provider
import (
"context"
"github.com/Khan/genqlient/graphql"
"github.com/satisfactorymodding/ficsit-cli/ficsit"
)
type ficsitProvider struct {
client graphql.Client
}
func initFicsitProvider(client graphql.Client) ficsitProvider {
return ficsitProvider{
client,
}
}
func (p ficsitProvider) Mods(context context.Context, filter ficsit.ModFilter) (*ficsit.ModsResponse, error) {
return ficsit.Mods(context, p.client, filter)
}
func (p ficsitProvider) GetMod(context context.Context, modReference string) (*ficsit.GetModResponse, error) {
return ficsit.GetMod(context, p.client, modReference)
}
func (p ficsitProvider) ModVersions(context context.Context, modReference string, filter ficsit.VersionFilter) (*ficsit.ModVersionsResponse, error) {
return ficsit.ModVersions(context, p.client, modReference, filter)
}
func (p ficsitProvider) SMLVersions(context context.Context) (*ficsit.SMLVersionsResponse, error) {
return ficsit.SMLVersions(context, p.client)
}
func (p ficsitProvider) ResolveModDependencies(context context.Context, filter []ficsit.ModVersionConstraint) (*ficsit.ResolveModDependenciesResponse, error) {
return ficsit.ResolveModDependencies(context, p.client, filter)
}
func (p ficsitProvider) ModVersionsWithDependencies(context context.Context, modID string) (*ficsit.ModVersionsWithDependenciesResponse, error) {
return ficsit.ModVersionsWithDependencies(context, p.client, modID)
}
func (p ficsitProvider) GetModName(context context.Context, modReference string) (*ficsit.GetModNameResponse, error) {
return ficsit.GetModName(context, p.client, modReference)
}

289
cli/provider/local.go Normal file
View file

@ -0,0 +1,289 @@
package provider
import (
"context"
"strings"
"time"
"github.com/Masterminds/semver/v3"
"github.com/pkg/errors"
"github.com/satisfactorymodding/ficsit-cli/cli/cache"
"github.com/satisfactorymodding/ficsit-cli/ficsit"
)
type localProvider struct{}
func initLocalProvider() localProvider {
return localProvider{}
}
func (p localProvider) Mods(_ context.Context, filter ficsit.ModFilter) (*ficsit.ModsResponse, error) {
cachedMods, err := cache.GetCache()
if err != nil {
return nil, errors.Wrap(err, "failed to get cache")
}
mods := make([]ficsit.ModsModsGetModsModsMod, 0)
for modReference, files := range cachedMods {
if modReference == "SML" {
continue
}
if len(filter.References) > 0 {
skip := true
for _, a := range filter.References {
if a == modReference {
skip = false
break
}
}
if skip {
continue
}
}
mods = append(mods, ficsit.ModsModsGetModsModsMod{
Id: modReference,
Name: files[0].Plugin.FriendlyName,
Mod_reference: modReference,
Last_version_date: time.Now(),
Created_at: time.Now(),
Downloads: 0,
Popularity: 0,
Hotness: 0,
})
}
if filter.Limit == 0 {
filter.Limit = 25
}
low := filter.Offset
high := filter.Offset + filter.Limit
if low > len(mods) {
return &ficsit.ModsResponse{
Mods: ficsit.ModsModsGetMods{
Count: 0,
Mods: []ficsit.ModsModsGetModsModsMod{},
},
}, nil
}
if high > len(mods) {
high = len(mods)
}
mods = mods[low:high]
return &ficsit.ModsResponse{
Mods: ficsit.ModsModsGetMods{
Count: len(mods),
Mods: mods,
},
}, nil
}
func (p localProvider) GetMod(_ context.Context, modReference string) (*ficsit.GetModResponse, error) {
cachedModFiles, err := cache.GetCacheMod(modReference)
if err != nil {
return nil, errors.Wrap(err, "failed to get cache")
}
if len(cachedModFiles) == 0 {
return nil, errors.New("mod not found")
}
authors := make([]ficsit.GetModModAuthorsUserMod, 0)
for _, author := range strings.Split(cachedModFiles[0].Plugin.CreatedBy, ",") {
authors = append(authors, ficsit.GetModModAuthorsUserMod{
Role: "Unknown",
User: ficsit.GetModModAuthorsUserModUser{
Username: author,
},
})
}
return &ficsit.GetModResponse{
Mod: ficsit.GetModMod{
Id: modReference,
Name: cachedModFiles[0].Plugin.FriendlyName,
Mod_reference: modReference,
Created_at: time.Now(),
Views: 0,
Downloads: 0,
Authors: authors,
Full_description: "",
Source_url: "",
},
}, nil
}
func (p localProvider) ModVersions(_ context.Context, modReference string, filter ficsit.VersionFilter) (*ficsit.ModVersionsResponse, error) {
cachedModFiles, err := cache.GetCacheMod(modReference)
if err != nil {
return nil, errors.Wrap(err, "failed to get cache")
}
if filter.Limit == 0 {
filter.Limit = 25
}
versions := make([]ficsit.ModVersionsModVersionsVersion, 0)
for _, modFile := range cachedModFiles[filter.Offset : filter.Offset+filter.Limit] {
versions = append(versions, ficsit.ModVersionsModVersionsVersion{
Id: modReference + ":" + modFile.Plugin.SemVersion,
Version: modFile.Plugin.SemVersion,
})
}
return &ficsit.ModVersionsResponse{
Mod: ficsit.ModVersionsMod{
Id: modReference,
Versions: versions,
},
}, nil
}
func (p localProvider) SMLVersions(_ context.Context) (*ficsit.SMLVersionsResponse, error) {
cachedSMLFiles, err := cache.GetCacheMod("SML")
if err != nil {
return nil, errors.Wrap(err, "failed to get cache")
}
smlVersions := make([]ficsit.SMLVersionsSmlVersionsGetSMLVersionsSml_versionsSMLVersion, 0)
for _, smlFile := range cachedSMLFiles {
smlVersions = append(smlVersions, ficsit.SMLVersionsSmlVersionsGetSMLVersionsSml_versionsSMLVersion{
Id: "SML:" + smlFile.Plugin.SemVersion,
Version: smlFile.Plugin.SemVersion,
Satisfactory_version: 0, // TODO: where can this be obtained from?
})
}
return &ficsit.SMLVersionsResponse{
SmlVersions: ficsit.SMLVersionsSmlVersionsGetSMLVersions{
Count: len(smlVersions),
Sml_versions: smlVersions,
},
}, nil
}
func (p localProvider) ResolveModDependencies(_ context.Context, filter []ficsit.ModVersionConstraint) (*ficsit.ResolveModDependenciesResponse, error) {
cachedMods, err := cache.GetCache()
if err != nil {
return nil, errors.Wrap(err, "failed to get cache")
}
mods := make([]ficsit.ResolveModDependenciesModsModVersion, 0)
constraintMap := make(map[string]string)
for _, constraint := range filter {
constraintMap[constraint.ModIdOrReference] = constraint.Version
}
for modReference, modFiles := range cachedMods {
constraint, ok := constraintMap[modReference]
if !ok {
continue
}
semverConstraint, err := semver.NewConstraint(constraint)
if err != nil {
return nil, errors.Wrapf(err, "failed to parse constraint for %s", modReference)
}
versions := make([]ficsit.ResolveModDependenciesModsModVersionVersionsVersion, 0)
for _, modFile := range modFiles {
semverVersion, err := semver.NewVersion(modFile.Plugin.SemVersion)
if err != nil {
return nil, errors.Wrapf(err, "failed to parse version for %s", modReference)
}
if !semverConstraint.Check(semverVersion) {
continue
}
dependencies := make([]ficsit.ResolveModDependenciesModsModVersionVersionsVersionDependenciesVersionDependency, 0)
for _, dependency := range modFile.Plugin.Plugins {
if dependency.BasePlugin {
continue
}
dependencies = append(dependencies, ficsit.ResolveModDependenciesModsModVersionVersionsVersionDependenciesVersionDependency{
Mod_id: dependency.Name,
Condition: dependency.SemVersion,
Optional: dependency.Optional,
})
}
versions = append(versions, ficsit.ResolveModDependenciesModsModVersionVersionsVersion{
Id: modReference + ":" + modFile.Plugin.SemVersion,
Version: modFile.Plugin.SemVersion,
Link: "",
Hash: modFile.Hash,
Dependencies: dependencies,
})
}
mods = append(mods, ficsit.ResolveModDependenciesModsModVersion{
Id: modReference,
Mod_reference: modReference,
Versions: versions,
})
}
return &ficsit.ResolveModDependenciesResponse{
Mods: mods,
}, nil
}
func (p localProvider) ModVersionsWithDependencies(_ context.Context, modID string) (*ficsit.ModVersionsWithDependenciesResponse, error) {
cachedModFiles, err := cache.GetCacheMod(modID)
if err != nil {
return nil, errors.Wrap(err, "failed to get cache")
}
versions := make([]ficsit.ModVersionsWithDependenciesModVersionsVersion, 0)
for _, modFile := range cachedModFiles {
versions = append(versions, ficsit.ModVersionsWithDependenciesModVersionsVersion{
Id: modID + ":" + modFile.Plugin.SemVersion,
Version: modFile.Plugin.SemVersion,
})
}
return &ficsit.ModVersionsWithDependenciesResponse{
Mod: ficsit.ModVersionsWithDependenciesMod{
Id: modID,
Versions: versions,
},
}, nil
}
func (p localProvider) GetModName(_ context.Context, modReference string) (*ficsit.GetModNameResponse, error) {
cachedModFiles, err := cache.GetCacheMod(modReference)
if err != nil {
return nil, errors.Wrap(err, "failed to get cache")
}
if len(cachedModFiles) == 0 {
return nil, errors.New("mod not found")
}
return &ficsit.GetModNameResponse{
Mod: ficsit.GetModNameMod{
Id: modReference,
Name: cachedModFiles[0].Plugin.FriendlyName,
Mod_reference: modReference,
},
}, nil
}

72
cli/provider/mixed.go Normal file
View file

@ -0,0 +1,72 @@
package provider
import (
"context"
"github.com/Khan/genqlient/graphql"
"github.com/satisfactorymodding/ficsit-cli/ficsit"
)
type MixedProvider struct {
ficsitProvider ficsitProvider
localProvider localProvider
Offline bool
}
func InitMixedProvider(client graphql.Client) *MixedProvider {
return &MixedProvider{
ficsitProvider: initFicsitProvider(client),
localProvider: initLocalProvider(),
Offline: false,
}
}
func (p MixedProvider) Mods(context context.Context, filter ficsit.ModFilter) (*ficsit.ModsResponse, error) {
if p.Offline {
return p.localProvider.Mods(context, filter)
}
return p.ficsitProvider.Mods(context, filter)
}
func (p MixedProvider) GetMod(context context.Context, modReference string) (*ficsit.GetModResponse, error) {
if p.Offline {
return p.localProvider.GetMod(context, modReference)
}
return p.ficsitProvider.GetMod(context, modReference)
}
func (p MixedProvider) ModVersions(context context.Context, modReference string, filter ficsit.VersionFilter) (*ficsit.ModVersionsResponse, error) {
if p.Offline {
return p.localProvider.ModVersions(context, modReference, filter)
}
return p.ficsitProvider.ModVersions(context, modReference, filter)
}
func (p MixedProvider) SMLVersions(context context.Context) (*ficsit.SMLVersionsResponse, error) {
if p.Offline {
return p.localProvider.SMLVersions(context)
}
return p.ficsitProvider.SMLVersions(context)
}
func (p MixedProvider) ResolveModDependencies(context context.Context, filter []ficsit.ModVersionConstraint) (*ficsit.ResolveModDependenciesResponse, error) {
if p.Offline {
return p.localProvider.ResolveModDependencies(context, filter)
}
return p.ficsitProvider.ResolveModDependencies(context, filter)
}
func (p MixedProvider) ModVersionsWithDependencies(context context.Context, modID string) (*ficsit.ModVersionsWithDependenciesResponse, error) {
if p.Offline {
return p.localProvider.ModVersionsWithDependencies(context, modID)
}
return p.ficsitProvider.ModVersionsWithDependencies(context, modID)
}
func (p MixedProvider) GetModName(context context.Context, modReference string) (*ficsit.GetModNameResponse, error) {
if p.Offline {
return p.localProvider.GetModName(context, modReference)
}
return p.ficsitProvider.GetModName(context, modReference)
}

18
cli/provider/provider.go Normal file
View file

@ -0,0 +1,18 @@
package provider
import (
"context"
"github.com/satisfactorymodding/ficsit-cli/ficsit"
)
type Provider interface {
Mods(context context.Context, filter ficsit.ModFilter) (*ficsit.ModsResponse, error)
GetMod(context context.Context, modReference string) (*ficsit.GetModResponse, error)
ModVersions(context context.Context, modReference string, filter ficsit.VersionFilter) (*ficsit.ModVersionsResponse, error)
SMLVersions(context context.Context) (*ficsit.SMLVersionsResponse, error)
ResolveModDependencies(context context.Context, filter []ficsit.ModVersionConstraint) (*ficsit.ResolveModDependenciesResponse, error)
ModVersionsWithDependencies(context context.Context, modID string) (*ficsit.ModVersionsWithDependenciesResponse, error)
GetModName(context context.Context, modReference string) (*ficsit.GetModNameResponse, error)
}

View file

@ -13,7 +13,7 @@ func profilesGetResolver() DependencyResolver {
panic(err) panic(err)
} }
return NewDependencyResolver(ctx.APIClient) return NewDependencyResolver(ctx.Provider)
} }
func TestProfileResolution(t *testing.T) { func TestProfileResolution(t *testing.T) {

View file

@ -138,6 +138,8 @@ func init() {
RootCmd.PersistentFlags().String("graphql-api", "/v2/query", "Path for GraphQL API") RootCmd.PersistentFlags().String("graphql-api", "/v2/query", "Path for GraphQL API")
RootCmd.PersistentFlags().String("api-key", "", "API key to use when sending requests") RootCmd.PersistentFlags().String("api-key", "", "API key to use when sending requests")
RootCmd.PersistentFlags().Bool("offline", false, "Whether to only use local data")
_ = viper.BindPFlag("log", RootCmd.PersistentFlags().Lookup("log")) _ = viper.BindPFlag("log", RootCmd.PersistentFlags().Lookup("log"))
_ = viper.BindPFlag("log-file", RootCmd.PersistentFlags().Lookup("log-file")) _ = viper.BindPFlag("log-file", RootCmd.PersistentFlags().Lookup("log-file"))
_ = viper.BindPFlag("quiet", RootCmd.PersistentFlags().Lookup("quiet")) _ = viper.BindPFlag("quiet", RootCmd.PersistentFlags().Lookup("quiet"))
@ -153,4 +155,6 @@ func init() {
_ = viper.BindPFlag("api-base", RootCmd.PersistentFlags().Lookup("api-base")) _ = viper.BindPFlag("api-base", RootCmd.PersistentFlags().Lookup("api-base"))
_ = viper.BindPFlag("graphql-api", RootCmd.PersistentFlags().Lookup("graphql-api")) _ = viper.BindPFlag("graphql-api", RootCmd.PersistentFlags().Lookup("graphql-api"))
_ = viper.BindPFlag("api-key", RootCmd.PersistentFlags().Lookup("api-key")) _ = viper.BindPFlag("api-key", RootCmd.PersistentFlags().Lookup("api-key"))
_ = viper.BindPFlag("offline", RootCmd.PersistentFlags().Lookup("offline"))
} }

1
go.mod
View file

@ -6,6 +6,7 @@ require (
github.com/JohannesKaufmann/html-to-markdown v1.3.6 github.com/JohannesKaufmann/html-to-markdown v1.3.6
github.com/Khan/genqlient v0.5.0 github.com/Khan/genqlient v0.5.0
github.com/MarvinJWendt/testza v0.5.2 github.com/MarvinJWendt/testza v0.5.2
github.com/Masterminds/semver/v3 v3.2.1
github.com/PuerkitoBio/goquery v1.8.0 github.com/PuerkitoBio/goquery v1.8.0
github.com/charmbracelet/bubbles v0.14.0 github.com/charmbracelet/bubbles v0.14.0
github.com/charmbracelet/bubbletea v0.22.1 github.com/charmbracelet/bubbletea v0.22.1

2
go.sum
View file

@ -60,6 +60,8 @@ github.com/MarvinJWendt/testza v0.3.0/go.mod h1:eFcL4I0idjtIx8P9C6KkAuLgATNKpX4/
github.com/MarvinJWendt/testza v0.4.2/go.mod h1:mSdhXiKH8sg/gQehJ63bINcCKp7RtYewEjXsvsVUPbE= github.com/MarvinJWendt/testza v0.4.2/go.mod h1:mSdhXiKH8sg/gQehJ63bINcCKp7RtYewEjXsvsVUPbE=
github.com/MarvinJWendt/testza v0.5.2 h1:53KDo64C1z/h/d/stCYCPY69bt/OSwjq5KpFNwi+zB4= github.com/MarvinJWendt/testza v0.5.2 h1:53KDo64C1z/h/d/stCYCPY69bt/OSwjq5KpFNwi+zB4=
github.com/MarvinJWendt/testza v0.5.2/go.mod h1:xu53QFE5sCdjtMCKk8YMQ2MnymimEctc4n3EjyIYvEY= github.com/MarvinJWendt/testza v0.5.2/go.mod h1:xu53QFE5sCdjtMCKk8YMQ2MnymimEctc4n3EjyIYvEY=
github.com/Masterminds/semver/v3 v3.2.1 h1:RN9w6+7QoMeJVGyfmbcgs28Br8cvmnucEXnY0rYXWg0=
github.com/Masterminds/semver/v3 v3.2.1/go.mod h1:qvl/7zhW3nngYb5+80sSMF+FG2BjYrf8m9wsX0PNOMQ=
github.com/PuerkitoBio/goquery v1.8.0 h1:PJTF7AmFCFKk1N6V6jmKfrNH9tV5pNE6lZMkG0gta/U= github.com/PuerkitoBio/goquery v1.8.0 h1:PJTF7AmFCFKk1N6V6jmKfrNH9tV5pNE6lZMkG0gta/U=
github.com/PuerkitoBio/goquery v1.8.0/go.mod h1:ypIiRMtY7COPGk+I/YbZLbxsxn9g5ejnI2HSMtkjZvI= github.com/PuerkitoBio/goquery v1.8.0/go.mod h1:ypIiRMtY7COPGk+I/YbZLbxsxn9g5ejnI2HSMtkjZvI=
github.com/agnivade/levenshtein v1.0.1/go.mod h1:CURSv5d9Uaml+FovSIICkLbAUZ9S4RqaHDIsdSBg7lM= github.com/agnivade/levenshtein v1.0.1/go.mod h1:CURSv5d9Uaml+FovSIICkLbAUZ9S4RqaHDIsdSBg7lM=

View file

@ -57,5 +57,10 @@ func (h headerComponent) View() string {
out += "N/A" out += "N/A"
} }
if h.root.GetProvider().Offline {
out += "\n"
out += h.labelStyle.Render("Offline")
}
return lipgloss.NewStyle().Margin(1, 0).Render(out) return lipgloss.NewStyle().Margin(1, 0).Render(out)
} }

View file

@ -5,6 +5,7 @@ import (
tea "github.com/charmbracelet/bubbletea" tea "github.com/charmbracelet/bubbletea"
"github.com/satisfactorymodding/ficsit-cli/cli" "github.com/satisfactorymodding/ficsit-cli/cli"
"github.com/satisfactorymodding/ficsit-cli/cli/provider"
) )
type RootModel interface { type RootModel interface {
@ -17,6 +18,7 @@ type RootModel interface {
SetCurrentInstallation(installation *cli.Installation) error SetCurrentInstallation(installation *cli.Installation) error
GetAPIClient() graphql.Client GetAPIClient() graphql.Client
GetProvider() *provider.MixedProvider
Size() tea.WindowSizeMsg Size() tea.WindowSizeMsg
SetSize(size tea.WindowSizeMsg) SetSize(size tea.WindowSizeMsg)

View file

@ -7,6 +7,7 @@ import (
"github.com/pkg/errors" "github.com/pkg/errors"
"github.com/satisfactorymodding/ficsit-cli/cli" "github.com/satisfactorymodding/ficsit-cli/cli"
"github.com/satisfactorymodding/ficsit-cli/cli/provider"
"github.com/satisfactorymodding/ficsit-cli/tea/components" "github.com/satisfactorymodding/ficsit-cli/tea/components"
"github.com/satisfactorymodding/ficsit-cli/tea/scenes" "github.com/satisfactorymodding/ficsit-cli/tea/scenes"
) )
@ -25,7 +26,7 @@ func newModel(global *cli.GlobalContext) *rootModel {
Width: 20, Width: 20,
Height: 14, Height: 14,
}, },
dependencyResolver: cli.NewDependencyResolver(global.APIClient), dependencyResolver: cli.NewDependencyResolver(global.Provider),
} }
m.headerComponent = components.NewHeaderComponent(m) m.headerComponent = components.NewHeaderComponent(m)
@ -63,6 +64,10 @@ func (m *rootModel) GetAPIClient() graphql.Client {
return m.global.APIClient return m.global.APIClient
} }
func (m *rootModel) GetProvider() *provider.MixedProvider {
return m.global.Provider
}
func (m *rootModel) Size() tea.WindowSizeMsg { func (m *rootModel) Size() tea.WindowSizeMsg {
return m.currentSize return m.currentSize
} }

View file

@ -111,7 +111,7 @@ func (m installedModsList) LoadModData() {
i++ i++
} }
mods, err := ficsit.Mods(context.TODO(), m.root.GetAPIClient(), ficsit.ModFilter{ mods, err := m.root.GetProvider().Mods(context.TODO(), ficsit.ModFilter{
References: references, References: references,
}) })
if err != nil { if err != nil {

View file

@ -87,7 +87,7 @@ func NewModInfo(root components.RootModel, parent tea.Model, mod utils.Mod) tea.
model.help.Width = root.Size().Width model.help.Width = root.Size().Width
go func() { go func() {
fullMod, err := ficsit.GetMod(context.TODO(), root.GetAPIClient(), mod.Reference) fullMod, err := root.GetProvider().GetMod(context.TODO(), mod.Reference)
if err != nil { if err != nil {
model.modError <- err.Error() model.modError <- err.Error()
return return

View file

@ -221,7 +221,7 @@ func NewMods(root components.RootModel, parent tea.Model) tea.Model {
allMods := make([]ficsit.ModsModsGetModsModsMod, 0) allMods := make([]ficsit.ModsModsGetModsModsMod, 0)
offset := 0 offset := 0
for { for {
mods, err := ficsit.Mods(context.TODO(), root.GetAPIClient(), ficsit.ModFilter{ mods, err := root.GetProvider().Mods(context.TODO(), ficsit.ModFilter{
Limit: 100, Limit: 100,
Offset: offset, Offset: offset,
Order_by: ficsit.ModFieldsLastVersionDate, Order_by: ficsit.ModFieldsLastVersionDate,

View file

@ -56,7 +56,7 @@ func NewModVersionList(root components.RootModel, parent tea.Model, mod utils.Mo
allVersions := make([]ficsit.ModVersionsModVersionsVersion, 0) allVersions := make([]ficsit.ModVersionsModVersionsVersion, 0)
offset := 0 offset := 0
for { for {
versions, err := ficsit.ModVersions(context.TODO(), root.GetAPIClient(), mod.Reference, ficsit.VersionFilter{ versions, err := root.GetProvider().ModVersions(context.TODO(), mod.Reference, ficsit.VersionFilter{
Limit: 100, Limit: 100,
Offset: offset, Offset: offset,
Order: ficsit.OrderDesc, Order: ficsit.OrderDesc,

View file

@ -4,134 +4,20 @@ import (
"archive/zip" "archive/zip"
"crypto/sha256" "crypto/sha256"
"encoding/hex" "encoding/hex"
"fmt"
"io" "io"
"net/http"
"os" "os"
"path/filepath" "path/filepath"
"github.com/pkg/errors" "github.com/pkg/errors"
"github.com/spf13/viper"
"github.com/satisfactorymodding/ficsit-cli/cli/disk" "github.com/satisfactorymodding/ficsit-cli/cli/disk"
) )
type Progresser struct {
io.Reader
updates chan GenericUpdate
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 <- GenericUpdate{Progress: float64(pt.running) / float64(pt.total)}:
default:
}
}
}
if err == io.EOF {
return n, io.EOF
}
return n, errors.Wrap(err, "failed to read")
}
type GenericUpdate struct { type GenericUpdate struct {
ModReference *string ModReference *string
Progress float64 Progress float64
} }
func DownloadOrCache(cacheKey string, hash string, url string, updates chan GenericUpdate) (io.ReaderAt, int64, error) {
downloadCache := filepath.Join(viper.GetString("cache-dir"), "downloadCache")
if err := os.MkdirAll(downloadCache, 0o777); err != nil {
if !os.IsExist(err) {
return nil, 0, errors.Wrap(err, "failed creating download cache")
}
}
location := filepath.Join(downloadCache, cacheKey)
stat, err := os.Stat(location)
if err == nil {
existingHash := ""
if hash != "" {
f, err := os.Open(location)
if err != nil {
return nil, 0, errors.Wrap(err, "failed to open file: "+location)
}
existingHash, err = SHA256Data(f)
if err != nil {
return nil, 0, errors.Wrap(err, "could not compute hash for file: "+location)
}
}
if hash == existingHash {
f, err := os.Open(location)
if err != nil {
return nil, 0, errors.Wrap(err, "failed to open file: "+location)
}
return f, stat.Size(), nil
}
if err := os.Remove(location); err != nil {
return nil, 0, errors.Wrap(err, "failed to delete file: "+location)
}
} else if !os.IsNotExist(err) {
return nil, 0, errors.Wrap(err, "failed to stat file: "+location)
}
out, err := os.Create(location)
if err != nil {
return nil, 0, errors.Wrap(err, "failed creating file at: "+location)
}
defer out.Close()
resp, err := http.Get(url)
if err != nil {
return nil, 0, errors.Wrap(err, "failed to fetch: "+url)
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
return nil, 0, fmt.Errorf("bad status: %s on url: %s", resp.Status, url)
}
progresser := &Progresser{
Reader: resp.Body,
total: resp.ContentLength,
updates: updates,
}
_, err = io.Copy(out, progresser)
if err != nil {
return nil, 0, errors.Wrap(err, "failed writing file to disk")
}
f, err := os.Open(location)
if err != nil {
return nil, 0, errors.Wrap(err, "failed to open file: "+location)
}
if updates != nil {
select {
case updates <- GenericUpdate{Progress: 1}:
default:
}
}
return f, resp.ContentLength, nil
}
func SHA256Data(f io.Reader) (string, error) { func SHA256Data(f io.Reader) (string, error) {
h := sha256.New() h := sha256.New()
if _, err := io.Copy(h, f); err != nil { if _, err := io.Copy(h, f); err != nil {