From 92c1e03feb0c5739a640b08bdf7800e1658eedc2 Mon Sep 17 00:00:00 2001 From: Haryo Bagas Assyafah Date: Sat, 7 Dec 2024 18:18:53 +0700 Subject: [PATCH] feat: add initial feature implementation (#1) - Main feature implementation - Test github workflow --- .../workflows/build-and-upload-binaries.yml | 69 ++++++ .gitignore | 4 + go.mod | 13 ++ go.sum | 12 ++ internal/cli/cli.go | 36 ++++ internal/cli/library/library.go | 40 ++++ .../set_auto_update/set_auto_update.go | 35 +++ .../set_background_downloads.go | 46 ++++ internal/config/config.go | 24 +++ internal/model/error.go | 16 ++ internal/pkg/prettier.go | 7 + .../set_auto_update/set_auto_update.go | 125 +++++++++++ .../set_background_downloads.go | 101 +++++++++ internal/usecase/type.go | 5 + main.go | 28 +++ pkg/steam_acf/error.go | 10 + pkg/steam_acf/steam_acf.go | 199 ++++++++++++++++++ pkg/steam_path/error.go | 10 + pkg/steam_path/steam_path.go | 100 +++++++++ 19 files changed, 880 insertions(+) create mode 100644 .github/workflows/build-and-upload-binaries.yml create mode 100644 go.mod create mode 100644 go.sum create mode 100644 internal/cli/cli.go create mode 100644 internal/cli/library/library.go create mode 100644 internal/cli/library/set_auto_update/set_auto_update.go create mode 100644 internal/cli/library/set_background_downloads/set_background_downloads.go create mode 100644 internal/config/config.go create mode 100644 internal/model/error.go create mode 100644 internal/pkg/prettier.go create mode 100644 internal/usecase/library/set_auto_update/set_auto_update.go create mode 100644 internal/usecase/library/set_background_downloads/set_background_downloads.go create mode 100644 internal/usecase/type.go create mode 100644 main.go create mode 100644 pkg/steam_acf/error.go create mode 100644 pkg/steam_acf/steam_acf.go create mode 100644 pkg/steam_path/error.go create mode 100644 pkg/steam_path/steam_path.go diff --git a/.github/workflows/build-and-upload-binaries.yml b/.github/workflows/build-and-upload-binaries.yml new file mode 100644 index 0000000..6212a20 --- /dev/null +++ b/.github/workflows/build-and-upload-binaries.yml @@ -0,0 +1,69 @@ +name: Build and Upload Binaries on Tag + +on: + push: + tags: + - 'v*' # Trigger the workflow for tags like v1.0, v2.1, etc. + +jobs: + build: + runs-on: ubuntu-latest + + steps: + # Checkout the code + - name: Checkout code + uses: actions/checkout@v2 + + # Set up Go environment + - name: Set up Go + uses: actions/setup-go@v3 + with: + go-version: '1.21' # Set the Go version you're using + + # Get the version from the tag + - name: Get version from tag + id: get_version + run: | + echo "VERSION=$(echo ${GITHUB_REF} | sed 's/refs\/tags\///')" >> $GITHUB_ENV + + # Get the repository name from the GitHub context (e.g., steam-utils from bearaujus/steam-utils) + - name: Get repo name + id: get_repo_name + run: | + REPO_NAME=$(echo "${GITHUB_REPOSITORY}" | cut -d'/' -f2) + echo "REPO_NAME=${REPO_NAME}" >> $GITHUB_ENV + + # Build binaries for all GOOS and GOARCH combinations + - name: Build binaries + run: | + # List of GOOS and GOARCH combinations + GOOS_ARCH_LIST=$(go tool dist list) + VERSION=${{ env.VERSION }} + REPO_NAME=${{ env.REPO_NAME }} + + # Create a directory for the build outputs + mkdir -p binaries + + # Loop through the GOOS and GOARCH combinations and build binaries + for GOOS_ARCH in $GOOS_ARCH_LIST; do + GOOS=$(echo $GOOS_ARCH | cut -d'/' -f1) + GOARCH=$(echo $GOOS_ARCH | cut -d'/' -f2) + + # Set environment variables + export GOOS + export GOARCH + + # Build the binary with the required format and ldflags for version, name, arch, and goos + FILENAME="${REPO_NAME}-${VERSION}-${GOOS}-${GOARCH}" + go build -ldflags "-X main.name=${REPO_NAME} -X main.version=${VERSION} -X main.arch=${GOARCH} -X main.goos=${GOOS}" -o "binaries/${FILENAME}" + + echo "Built binary: ${FILENAME}" + done + + # Upload binaries to GitHub Releases + - name: Upload binaries to release + uses: softprops/action-gh-release@v1 + with: + files: binaries/* + env: + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} diff --git a/.gitignore b/.gitignore index 6f72f89..04029ae 100644 --- a/.gitignore +++ b/.gitignore @@ -7,6 +7,10 @@ *.dll *.so *.dylib +**/.idea +**/.DS_Store +dev +bin # Test binary, built with `go test -c` *.test diff --git a/go.mod b/go.mod new file mode 100644 index 0000000..89465d3 --- /dev/null +++ b/go.mod @@ -0,0 +1,13 @@ +module github.com/bearaujus/steam-utils + +go 1.23.3 + +require ( + github.com/bearaujus/berror v0.0.1 + github.com/spf13/cobra v1.8.1 +) + +require ( + github.com/inconshreveable/mousetrap v1.1.0 // indirect + github.com/spf13/pflag v1.0.5 // indirect +) diff --git a/go.sum b/go.sum new file mode 100644 index 0000000..8c61c33 --- /dev/null +++ b/go.sum @@ -0,0 +1,12 @@ +github.com/bearaujus/berror v0.0.1 h1:O7/b0SvDQMNHjUmERiGKzvGmpudvhJFd5nuYtMCtz1g= +github.com/bearaujus/berror v0.0.1/go.mod h1:gqwSzaF6jOE3pCU8x2JXt8+j5ns72G6OWunartoQJcw= +github.com/cpuguy83/go-md2man/v2 v2.0.4/go.mod h1:tgQtvFlXSQOSOSIRvRPT7W67SCa46tRHOmNcaadrF8o= +github.com/inconshreveable/mousetrap v1.1.0 h1:wN+x4NVGpMsO7ErUn/mUI3vEoE6Jt13X2s0bqwp9tc8= +github.com/inconshreveable/mousetrap v1.1.0/go.mod h1:vpF70FUmC8bwa3OWnCshd2FqLfsEA9PFc4w1p2J65bw= +github.com/russross/blackfriday/v2 v2.1.0/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM= +github.com/spf13/cobra v1.8.1 h1:e5/vxKd/rZsfSJMUX1agtjeTDf+qv1/JdBF8gg5k9ZM= +github.com/spf13/cobra v1.8.1/go.mod h1:wHxEcudfqmLYa8iTfL+OuZPbBZkmvliBWKIezN3kD9Y= +github.com/spf13/pflag v1.0.5 h1:iy+VFUOCP1a+8yFto/drg2CJ5u0yRoB7fZw3DKv/JXA= +github.com/spf13/pflag v1.0.5/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg= +gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= +gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= diff --git a/internal/cli/cli.go b/internal/cli/cli.go new file mode 100644 index 0000000..cae6fcf --- /dev/null +++ b/internal/cli/cli.go @@ -0,0 +1,36 @@ +package cli + +import ( + "context" + "github.com/bearaujus/steam-utils/internal/cli/library" + "github.com/bearaujus/steam-utils/internal/config" + "github.com/spf13/cobra" +) + +func NewRoot(ctx context.Context, cfg *config.Config) *cobra.Command { + var rootCmd = &cobra.Command{ + Use: "steam-utils", + Short: "Sets of utilities for managing your Steam", + Args: cobra.NoArgs, + CompletionOptions: cobra.CompletionOptions{ + DisableDefaultCmd: true, + DisableNoDescFlag: true, + DisableDescriptions: true, + HiddenDefaultCmd: true, + }, + } + rootCmd.AddCommand(newLibraryCmd(ctx, cfg)) + return rootCmd +} + +func newLibraryCmd(ctx context.Context, cfg *config.Config) *cobra.Command { + var cmd = &cobra.Command{ + Use: "library", + Short: "Steam library utilities", + Args: cobra.NoArgs, + } + for _, childCmd := range library.New(ctx, cfg) { + cmd.AddCommand(childCmd) + } + return cmd +} diff --git a/internal/cli/library/library.go b/internal/cli/library/library.go new file mode 100644 index 0000000..bedabdb --- /dev/null +++ b/internal/cli/library/library.go @@ -0,0 +1,40 @@ +package library + +import ( + "context" + "github.com/bearaujus/steam-utils/internal/cli/library/set_auto_update" + "github.com/bearaujus/steam-utils/internal/cli/library/set_background_downloads" + "github.com/bearaujus/steam-utils/internal/config" + "github.com/spf13/cobra" +) + +func New(ctx context.Context, cfg *config.Config) []*cobra.Command { + return []*cobra.Command{ + newSetAutoUpdateCmd(ctx, cfg), + newSetBackgroundDownloadsCmd(ctx, cfg), + } +} + +func newSetAutoUpdateCmd(ctx context.Context, cfg *config.Config) *cobra.Command { + var cmd = &cobra.Command{ + Use: "set-auto-update", + Short: "Set auto update behavior on all collections on your Steam library", + Args: cobra.NoArgs, + } + for _, childCmd := range set_auto_update.New(ctx, cfg) { + cmd.AddCommand(childCmd) + } + return cmd +} + +func newSetBackgroundDownloadsCmd(ctx context.Context, cfg *config.Config) *cobra.Command { + var cmd = &cobra.Command{ + Use: "set-background-downloads", + Short: "Set background downloads behavior on all collections on your Steam library", + Args: cobra.NoArgs, + } + for _, childCmd := range set_background_downloads.New(ctx, cfg) { + cmd.AddCommand(childCmd) + } + return cmd +} diff --git a/internal/cli/library/set_auto_update/set_auto_update.go b/internal/cli/library/set_auto_update/set_auto_update.go new file mode 100644 index 0000000..82f76d6 --- /dev/null +++ b/internal/cli/library/set_auto_update/set_auto_update.go @@ -0,0 +1,35 @@ +package set_auto_update + +import ( + "context" + "github.com/bearaujus/steam-utils/internal/config" + "github.com/bearaujus/steam-utils/internal/usecase/library/set_auto_update" + "github.com/spf13/cobra" +) + +func New(ctx context.Context, cfg *config.Config) []*cobra.Command { + return []*cobra.Command{ + new0Cmd(ctx, cfg), + new1Cmd(ctx, cfg), + } +} + +func new0Cmd(ctx context.Context, cfg *config.Config) *cobra.Command { + var cmd = &cobra.Command{ + Use: "0", + Short: "Always keep all games updated", + Args: cobra.NoArgs, + RunE: set_auto_update.NewCmdRunner(ctx, cfg), + } + return cmd +} + +func new1Cmd(ctx context.Context, cfg *config.Config) *cobra.Command { + var cmd = &cobra.Command{ + Use: "1", + Short: "Only update a game when you launch it", + Args: cobra.NoArgs, + RunE: set_auto_update.NewCmdRunner(ctx, cfg), + } + return cmd +} diff --git a/internal/cli/library/set_background_downloads/set_background_downloads.go b/internal/cli/library/set_background_downloads/set_background_downloads.go new file mode 100644 index 0000000..9799a81 --- /dev/null +++ b/internal/cli/library/set_background_downloads/set_background_downloads.go @@ -0,0 +1,46 @@ +package set_background_downloads + +import ( + "context" + "github.com/bearaujus/steam-utils/internal/config" + "github.com/bearaujus/steam-utils/internal/usecase/library/set_background_downloads" + "github.com/spf13/cobra" +) + +func New(ctx context.Context, cfg *config.Config) []*cobra.Command { + return []*cobra.Command{ + new0Cmd(ctx, cfg), + new1Cmd(ctx, cfg), + new2Cmd(ctx, cfg), + } +} + +func new0Cmd(ctx context.Context, cfg *config.Config) *cobra.Command { + var cmd = &cobra.Command{ + Use: "0", + Short: "Follow your global steam settings", + Args: cobra.NoArgs, + RunE: set_background_downloads.NewCmdRunner(ctx, cfg), + } + return cmd +} + +func new1Cmd(ctx context.Context, cfg *config.Config) *cobra.Command { + var cmd = &cobra.Command{ + Use: "1", + Short: "Always allow background downloads", + Args: cobra.NoArgs, + RunE: set_background_downloads.NewCmdRunner(ctx, cfg), + } + return cmd +} + +func new2Cmd(ctx context.Context, cfg *config.Config) *cobra.Command { + var cmd = &cobra.Command{ + Use: "2", + Short: "Never allow background downloads", + Args: cobra.NoArgs, + RunE: set_background_downloads.NewCmdRunner(ctx, cfg), + } + return cmd +} diff --git a/internal/config/config.go b/internal/config/config.go new file mode 100644 index 0000000..b3c9bdb --- /dev/null +++ b/internal/config/config.go @@ -0,0 +1,24 @@ +package config + +import ( + "github.com/bearaujus/steam-utils/pkg/steam_path" + "github.com/spf13/cobra" +) + +const ( + PersistentFlagSteamPath = "steam-path" +) + +type Config struct { + SteamPath string +} + +func LoadConfig(cmd *cobra.Command, config *Config) error { + var defaultSteamPath string + sp, err := steam_path.LoadDefaultSteamPath() + if err == nil { + defaultSteamPath = sp.Base() + } + cmd.PersistentFlags().StringVar(&config.SteamPath, PersistentFlagSteamPath, defaultSteamPath, "Path to steam installation directory") + return nil +} diff --git a/internal/model/error.go b/internal/model/error.go new file mode 100644 index 0000000..244c194 --- /dev/null +++ b/internal/model/error.go @@ -0,0 +1,16 @@ +package model + +import ( + "github.com/bearaujus/berror" +) + +var ( + ErrSteamPathIsNotSet = berror.NewErrDefinition("default steam installation path is not detected. please include flag '--%v'", berror.OptionErrDefinitionWithDisabledStackTrace()) + ErrSteamPathIsInvalid = berror.NewErrDefinition("%v. please update argument on flag '--%v'", berror.OptionErrDefinitionWithDisabledStackTrace()) + ErrReadDirectory = berror.NewErrDefinition("fail to read directory: %v", berror.OptionErrDefinitionWithDisabledStackTrace()) + ErrReadFile = berror.NewErrDefinition("fail to read file: %v", berror.OptionErrDefinitionWithDisabledStackTrace()) + ErrWriteFile = berror.NewErrDefinition("fail to write file: %v", berror.OptionErrDefinitionWithDisabledStackTrace()) + ErrParseSteamACFFile = berror.NewErrDefinition("fail to parse steam acf: %v", berror.OptionErrDefinitionWithDisabledStackTrace()) + ErrGetValueFromSteamACFFile = berror.NewErrDefinition("fail to get value from steam acf: %v", berror.OptionErrDefinitionWithDisabledStackTrace()) + ErrUpdateValueFromSteamACFFile = berror.NewErrDefinition("fail to update value to steam acf: %v", berror.OptionErrDefinitionWithDisabledStackTrace()) +) diff --git a/internal/pkg/prettier.go b/internal/pkg/prettier.go new file mode 100644 index 0000000..8094e1e --- /dev/null +++ b/internal/pkg/prettier.go @@ -0,0 +1,7 @@ +package pkg + +import "fmt" + +func PrintSep() { + fmt.Println("-------------------------------------------------------------------------------------------------") +} diff --git a/internal/usecase/library/set_auto_update/set_auto_update.go b/internal/usecase/library/set_auto_update/set_auto_update.go new file mode 100644 index 0000000..27950aa --- /dev/null +++ b/internal/usecase/library/set_auto_update/set_auto_update.go @@ -0,0 +1,125 @@ +package set_auto_update + +import ( + "context" + "errors" + "fmt" + "github.com/bearaujus/steam-utils/internal/config" + "github.com/bearaujus/steam-utils/internal/model" + "github.com/bearaujus/steam-utils/internal/pkg" + "github.com/bearaujus/steam-utils/internal/usecase" + "github.com/bearaujus/steam-utils/pkg/steam_acf" + "github.com/bearaujus/steam-utils/pkg/steam_path" + "github.com/spf13/cobra" + "os" + "path/filepath" + "strings" +) + +func NewCmdRunner(_ context.Context, cfg *config.Config) usecase.CmdRunner { + return func(cmd *cobra.Command, args []string) error { + se, err := steam_path.NewSteamPath(cfg.SteamPath) + if err != nil { + if errors.Is(err, steam_path.ErrEmptyPath) { + return model.ErrSteamPathIsNotSet.New(config.PersistentFlagSteamPath) + } + return model.ErrSteamPathIsInvalid.New(err, config.PersistentFlagSteamPath) + } + + files, err := os.ReadDir(se.SteamApps()) + if err != nil { + return model.ErrReadDirectory.New(err) + } + + var ( + aubUpdate, sauUpdate, totalUpdate int + aubTargets = []string{"AppState", "AutoUpdateBehavior"} + aubTargetsName = strings.Join(aubTargets, ".") + sauTargets = []string{"AppState", "ScheduledAutoUpdate"} + sauTargetsName = strings.Join(sauTargets, ".") + ) + pkg.PrintSep() + for _, file := range files { + if file.IsDir() { + continue + } + if strings.HasSuffix(strings.ToLower(file.Name()), ".acf") { + totalUpdate++ + fileName := filepath.Join(se.SteamApps(), file.Name()) + + data, err := os.ReadFile(fileName) + if err != nil { + return model.ErrReadFile.New(err) + } + + sa, err := steam_acf.Parse(data) + if err != nil { + return model.ErrParseSteamACFFile.New(err) + } + + appName, err := sa.Get([]string{"AppState", "name"}) + if err != nil { + return model.ErrGetValueFromSteamACFFile.New(err) + } + + fmt.Printf("Index\t: %v\nName\t: %v\nFile\t: %v\n", totalUpdate, appName, fileName) + var aubPrevious, sauPrevious string + aubPrevious, err = sa.Update(aubTargets, cmd.Use) + if err != nil { + return model.ErrUpdateValueFromSteamACFFile.New(err) + } + + if cmd.Use == "1" { + sauPrevious, err = sa.Update(sauTargets, "0") + if err != nil { + return model.ErrUpdateValueFromSteamACFFile.New(err) + } + } + + if aubPrevious != cmd.Use || (cmd.Use == "1" && sauPrevious != "0") { + err = os.WriteFile(fileName, sa.Serialize(), os.ModePerm) + if err != nil { + return model.ErrWriteFile.New(err) + } + } + if aubPrevious == cmd.Use { + fmt.Printf("Action\t: No changes made. %v is already configured and up-to-date\n", aubTargetsName) + } else { + fmt.Printf("Action\t: Updated %v from %v -> %v\n", aubTargetsName, aubPrevious, cmd.Use) + aubUpdate++ + } + + if cmd.Use == "1" && sauPrevious == "0" { + fmt.Printf("Action\t: No changes made. %v is already configured and up-to-date\n", sauTargetsName) + } else if cmd.Use == "1" { + fmt.Printf("Action\t: Updated %v from %v -> %v\n", sauTargetsName, sauPrevious, cmd.Use) + sauUpdate++ + } + + pkg.PrintSep() + } + } + + msg := fmt.Sprintf("Successfully updated %v: %d out of %d", aubTargetsName, aubUpdate, totalUpdate) + if aubUpdate == 0 { + msg = fmt.Sprintf("No files were updated for %v", aubTargetsName) + } + fmt.Println(msg) + + if cmd.Use == "1" { + msg = fmt.Sprintf("Successfully updated %v: %d out of %d", sauTargetsName, sauUpdate, totalUpdate) + if sauUpdate == 0 { + msg = fmt.Sprintf("No files were updated for %v", sauTargetsName) + } + fmt.Println(msg) + } + + pkg.PrintSep() + fmt.Printf("Applied\t: %v - %v\n", cmd.Use, cmd.Short) + if aubUpdate != 0 || sauUpdate != 0 { + fmt.Println("To see the changes, please restart your Steam!") + } + + return nil + } +} diff --git a/internal/usecase/library/set_background_downloads/set_background_downloads.go b/internal/usecase/library/set_background_downloads/set_background_downloads.go new file mode 100644 index 0000000..7c8ba08 --- /dev/null +++ b/internal/usecase/library/set_background_downloads/set_background_downloads.go @@ -0,0 +1,101 @@ +package set_background_downloads + +import ( + "context" + "errors" + "fmt" + "github.com/bearaujus/steam-utils/internal/config" + "github.com/bearaujus/steam-utils/internal/model" + "github.com/bearaujus/steam-utils/internal/pkg" + "github.com/bearaujus/steam-utils/internal/usecase" + "github.com/bearaujus/steam-utils/pkg/steam_acf" + "github.com/bearaujus/steam-utils/pkg/steam_path" + "github.com/spf13/cobra" + "os" + "path/filepath" + "strings" +) + +func NewCmdRunner(_ context.Context, cfg *config.Config) usecase.CmdRunner { + return func(cmd *cobra.Command, args []string) error { + se, err := steam_path.NewSteamPath(cfg.SteamPath) + if err != nil { + if errors.Is(err, steam_path.ErrEmptyPath) { + return model.ErrSteamPathIsNotSet.New(config.PersistentFlagSteamPath) + } + return model.ErrSteamPathIsInvalid.New(err, config.PersistentFlagSteamPath) + } + + files, err := os.ReadDir(se.SteamApps()) + if err != nil { + return model.ErrReadDirectory.New(err) + } + + var ( + aodwrUpdate, totalUpdate int + aodwrTargets = []string{"AppState", "AllowOtherDownloadsWhileRunning"} + aodwrTargetsName = strings.Join(aodwrTargets, ".") + ) + pkg.PrintSep() + for _, file := range files { + if file.IsDir() { + continue + } + if strings.HasSuffix(strings.ToLower(file.Name()), ".acf") { + totalUpdate++ + fileName := filepath.Join(se.SteamApps(), file.Name()) + + data, err := os.ReadFile(fileName) + if err != nil { + return model.ErrReadFile.New(err) + } + + sa, err := steam_acf.Parse(data) + if err != nil { + return model.ErrParseSteamACFFile.New(err) + } + + appName, err := sa.Get([]string{"AppState", "name"}) + if err != nil { + return model.ErrGetValueFromSteamACFFile.New(err) + } + + fmt.Printf("Index\t: %v\nName\t: %v\nFile\t: %v\n", totalUpdate, appName, fileName) + var aodwrPrevious string + aodwrPrevious, err = sa.Update(aodwrTargets, cmd.Use) + if err != nil { + return model.ErrUpdateValueFromSteamACFFile.New(err) + } + + if aodwrPrevious != cmd.Use { + err = os.WriteFile(fileName, sa.Serialize(), os.ModePerm) + if err != nil { + return model.ErrWriteFile.New(err) + } + } + if aodwrPrevious == cmd.Use { + fmt.Printf("Action\t: No changes made. %v is already configured and up-to-date\n", aodwrTargetsName) + } else { + fmt.Printf("Action\t: Updated %v from %v -> %v\n", aodwrTargetsName, aodwrPrevious, cmd.Use) + aodwrUpdate++ + } + + pkg.PrintSep() + } + } + + msg := fmt.Sprintf("Successfully updated %v: %d out of %d", aodwrTargetsName, aodwrUpdate, totalUpdate) + if aodwrUpdate == 0 { + msg = fmt.Sprintf("No files were updated for %v", aodwrTargetsName) + } + fmt.Println(msg) + + pkg.PrintSep() + fmt.Printf("Applied\t: %v - %v\n", cmd.Use, cmd.Short) + if aodwrUpdate != 0 { + fmt.Println("To see the changes, please restart your Steam!") + } + + return nil + } +} diff --git a/internal/usecase/type.go b/internal/usecase/type.go new file mode 100644 index 0000000..27582d7 --- /dev/null +++ b/internal/usecase/type.go @@ -0,0 +1,5 @@ +package usecase + +import "github.com/spf13/cobra" + +type CmdRunner func(cmd *cobra.Command, args []string) error diff --git a/main.go b/main.go new file mode 100644 index 0000000..2e5b65e --- /dev/null +++ b/main.go @@ -0,0 +1,28 @@ +package main + +import ( + "context" + "fmt" + "github.com/bearaujus/steam-utils/internal/cli" + "github.com/bearaujus/steam-utils/internal/config" +) + +// these variable will be retrieved from -ldflags +var ( + name string // main.name + version string // main.version + arch string // main.arch + goos string // main.goos +) + +func main() { + fmt.Println(name, version, arch, goos) + cfg := &config.Config{} + var rootCLI = cli.NewRoot(context.TODO(), cfg) + err := config.LoadConfig(rootCLI, cfg) + if err != nil { + fmt.Println(err) + return + } + _ = rootCLI.Execute() +} diff --git a/pkg/steam_acf/error.go b/pkg/steam_acf/error.go new file mode 100644 index 0000000..a73fd1e --- /dev/null +++ b/pkg/steam_acf/error.go @@ -0,0 +1,10 @@ +package steam_acf + +import "errors" + +var ( + ErrEmptyData = errors.New("empty data") + ErrParseData = errors.New("error when parsing data") + ErrGetData = errors.New("error when get data") + ErrUpdateData = errors.New("error when update data") +) diff --git a/pkg/steam_acf/steam_acf.go b/pkg/steam_acf/steam_acf.go new file mode 100644 index 0000000..41f4fa8 --- /dev/null +++ b/pkg/steam_acf/steam_acf.go @@ -0,0 +1,199 @@ +package steam_acf + +import ( + "bufio" + "bytes" + "errors" + "fmt" + "regexp" + "sort" + "strings" +) + +const ( + serializeRecursiveIndent = 0 + serializeRecursiveKeyValIndent = 2 +) + +var ( + writeRegex = regexp.MustCompile(`^[^\n{}"]*$`) +) + +type SteamACF interface { + Get(target []string) (string, error) + Update(target []string, value string) (string, error) + Serialize() []byte + String() string +} + +type steamACF struct { + data map[string]interface{} +} + +func Parse(data []byte) (SteamACF, error) { + if strings.TrimSpace(string(data)) == "" { + return nil, ErrEmptyData + } + parsedSteamACFData, err := parseRecursive(bufio.NewScanner(bytes.NewReader(data))) + if err != nil { + return nil, errors.Join(ErrParseData, err) + } + return &steamACF{data: parsedSteamACFData}, nil +} + +func (sa *steamACF) Get(target []string) (string, error) { + ret, err := getRecursive(sa.data, target, nil) + if err != nil { + return "", errors.Join(ErrGetData, err) + } + return string(ret), nil +} + +func (sa *steamACF) Update(target []string, replacement string) (string, error) { + if !writeRegex.MatchString(replacement) { + return "", fmt.Errorf("invalid update value") + } + previousValue, updatedData, err := updateRecursive(sa.data, target, replacement, nil) + if err != nil { + return "", errors.Join(ErrUpdateData, err) + } + sa.data = updatedData + return fmt.Sprint(previousValue), nil +} + +func (sa *steamACF) Serialize() []byte { + return serializeRecursive(sa.data, serializeRecursiveIndent, serializeRecursiveKeyValIndent) +} + +func (sa *steamACF) String() string { + return string(sa.Serialize()) +} + +func parseRecursive(scanner *bufio.Scanner) (map[string]interface{}, error) { + var ( + data = make(map[string]interface{}) + currKey, currVal string + valStart bool + err error + ) + for scanner.Scan() { + line := strings.TrimSpace(scanner.Text()) + for _, v := range line { + switch string(v) { + case `{`: + data[currKey], err = parseRecursive(scanner) + if err != nil { + return nil, err + } + currKey = "" + case `}`: + return data, nil + case `"`: + if !valStart { + valStart = true + continue + } + if currKey == "" { + currKey = currVal + } else { + data[currKey] = currVal + currKey = "" + } + currVal = "" + valStart = false + default: + if valStart { + currVal += string(v) + } + } + } + } + err = scanner.Err() + if err != nil { + return nil, err + } + return data, nil +} + +func getRecursive(data map[string]interface{}, tk []string, ptk []string) ([]byte, error) { + if len(tk) == 0 { + return serializeRecursive(data, serializeRecursiveIndent, serializeRecursiveKeyValIndent), nil + } + targetKey := tk[0] + ptk = append(ptk, targetKey) + dataValue, ok := data[targetKey] + if !ok { + return nil, fmt.Errorf("target key '%v' is not found", strings.Join(ptk, ".")) + } + _, isMap := dataValue.(map[string]interface{}) + if len(tk) != 1 { + if !isMap { + return nil, fmt.Errorf("target key '%v' is value", strings.Join(append(ptk, tk[1]), ".")) + } + var err error + ret, err := getRecursive(dataValue.(map[string]interface{}), tk[1:], ptk) + if err != nil { + return nil, err + } + return ret, nil + } + if isMap { + return serializeRecursive(dataValue.(map[string]interface{}), serializeRecursiveIndent, serializeRecursiveKeyValIndent), nil + } + return []byte(fmt.Sprint(dataValue)), nil +} + +func updateRecursive(data map[string]interface{}, tk []string, replacement string, ptk []string) (interface{}, map[string]interface{}, error) { + if len(tk) == 0 { + return nil, nil, errors.New("target keys is empty") + } + targetKey := tk[0] + ptk = append(ptk, targetKey) + dataValue, hasChild := data[targetKey] + if !hasChild { + return nil, nil, fmt.Errorf("target key '%v' is not found", strings.Join(ptk, ".")) + } + _, isMap := dataValue.(map[string]interface{}) + if len(tk) != 1 { + if !isMap { + return nil, nil, fmt.Errorf("target key '%v' is not found", strings.Join(append(ptk, tk[1]), ".")) + } + var err error + dataValue, data[targetKey], err = updateRecursive(dataValue.(map[string]interface{}), tk[1:], replacement, ptk) + if err != nil { + return nil, nil, err + } + return dataValue, data, nil + } + if isMap { + return nil, nil, fmt.Errorf("target key '%v' is not a value", strings.Join(ptk, ".")) + } + data[targetKey] = replacement + return dataValue, data, nil +} + +func serializeRecursive(data map[string]interface{}, indent, keyValIndent int) []byte { + var ( + keys = make([]string, len(data)) + indentStr = strings.Repeat("\t", indent) + keyValIndentStr = strings.Repeat("\t", keyValIndent) + ) + var i int + for key := range data { + keys[i] = key + i++ + } + sort.Strings(keys) + var buf bytes.Buffer + for _, key := range keys { + switch value := data[key].(type) { + case map[string]interface{}: + buf.WriteString(fmt.Sprintf("%v\"%v\"\n%v{\n", indentStr, key, indentStr)) + buf.Write(serializeRecursive(value, indent+1, keyValIndent)) + buf.WriteString(fmt.Sprintf("%v}\n", indentStr)) + default: + buf.WriteString(fmt.Sprintf("%v\"%v\"%v\"%v\"\n", indentStr, key, keyValIndentStr, value)) + } + } + return buf.Bytes() +} diff --git a/pkg/steam_path/error.go b/pkg/steam_path/error.go new file mode 100644 index 0000000..af7715f --- /dev/null +++ b/pkg/steam_path/error.go @@ -0,0 +1,10 @@ +package steam_path + +import "errors" + +var ( + ErrEmptyPath = errors.New("empty steam installation path") + ErrInvalidPath = errors.New("invalid steam installation path") + ErrUnsupportedOS = errors.New("unsupported operating system") + ErrDefaultPathNotFound = errors.New("default steam installation not found") +) diff --git a/pkg/steam_path/steam_path.go b/pkg/steam_path/steam_path.go new file mode 100644 index 0000000..ed84f5f --- /dev/null +++ b/pkg/steam_path/steam_path.go @@ -0,0 +1,100 @@ +package steam_path + +import ( + "fmt" + "os" + "path" + "runtime" +) + +type SteamPath interface { + Base() string + SteamApps() string +} + +type steamPath struct { + basePath string +} + +func NewSteamPath(location string) (SteamPath, error) { + if location == "" { + return nil, ErrEmptyPath + } + if _, err := os.Stat(location); err != nil { + return nil, ErrInvalidPath + } + sp := steamPath{basePath: location} + if err := sp.validate(); err != nil { + return nil, err + } + return &sp, nil +} + +func (sp *steamPath) Base() string { + return sp.basePath +} + +func (sp *steamPath) SteamApps() string { + return path.Join(sp.basePath, "steamapps") +} + +func (sp *steamPath) validate() error { + paths := []string{ + sp.SteamApps(), + } + for _, v := range paths { + if _, err := os.Stat(v); err != nil { + return ErrInvalidPath + } + } + return nil +} + +func LoadDefaultSteamPath() (SteamPath, error) { + var paths []string + + switch runtime.GOOS { + case "windows": + targetPath := []string{ + `Program Files (x86)/Steam`, + `Program Files/Steam`, + } + for _, drive := range windowsListAvailableDrives() { + for _, dir := range targetPath { + paths = append(paths, path.Join(drive, dir)) + } + } + case "linux": + paths = []string{ + os.ExpandEnv("$HOME/.steam/steam"), + os.ExpandEnv("$HOME/.local/share/Steam"), + } + case "darwin": // macOS + paths = []string{ + os.ExpandEnv("$HOME/Library/Application Support/Steam"), + } + default: + return nil, ErrUnsupportedOS + } + + for _, v := range paths { + sp, err := NewSteamPath(v) + if err != nil { + continue + } + return sp, nil + } + + return nil, ErrDefaultPathNotFound +} + +func windowsListAvailableDrives() []string { + var drives []string + for letter := 'A'; letter <= 'Z'; letter++ { + drive := fmt.Sprintf("%c:/", letter) + if _, err := os.Stat(drive); err == nil { + drives = append(drives, drive) + } + } + return drives +}