├── .github ├── FUNDING.yml ├── ISSUE_TEMPLATE │ ├── config.yml │ ├── bug_report.md │ └── feature_request.md └── workflows │ ├── lints.yml │ └── tests.yml ├── codecov.yml ├── CODE_OF_CONDUCT.md ├── gui ├── assets │ ├── ding-small-bell-sfx-233008.mp3 │ ├── JetBrainsMono-2.304 │ │ ├── fonts │ │ │ └── ttf │ │ │ │ ├── JetBrainsMono-Bold.ttf │ │ │ │ └── JetBrainsMono-Regular.ttf │ │ ├── AUTHORS.txt │ │ └── OFL.txt │ └── game-card-svgrepo-com.svg ├── shared.go ├── events.go ├── desktop.go ├── widgets.go ├── assets.go ├── about.go ├── window.go ├── actions.go ├── theme.go ├── sound.go ├── settings.go └── sound_test.go ├── logo.jpeg ├── docs ├── screenshots │ └── v0.4.2 │ │ ├── 1.png │ │ ├── 10.png │ │ ├── 11.png │ │ ├── 12.png │ │ ├── 13.png │ │ ├── 14.png │ │ ├── 2.png │ │ ├── 3.png │ │ ├── 4.png │ │ ├── 5.png │ │ ├── 6.png │ │ ├── 7.png │ │ ├── 8.png │ │ └── 9.png ├── figures │ ├── make_figures.sh │ └── package_dependency_graph.dot └── examples │ ├── simple_example.sh │ ├── download_all_games.sh │ ├── download_all_games.ps1 │ └── calculate_storage_for_all_games.ps1 ├── cmd ├── clierr_tracker.go ├── version_format_test.go ├── gui.go ├── version_test.go ├── version.go ├── gui_headless.go ├── hash_clean_test.go ├── download_clierr_test.go ├── download_invalid_test.go ├── login.go ├── cli_test.go ├── cli.go ├── file_test.go ├── file.go └── catalogue_test.go ├── scripts ├── docker_entrypoint.sh ├── test_makefile.sh └── test_gogg_cli.sh ├── client ├── read_body_error_test.go ├── fuzz_test.go ├── pagination_loop_test.go ├── games_pagination_test.go ├── utils_test.go ├── games_unit_extra_test.go ├── progress_reader_test.go ├── property_test.go ├── send_request_retry_test.go ├── helpers_test.go ├── download_integration_test.go ├── games_test.go ├── rate_limiter.go ├── catalogue.go ├── login_test.go ├── catalogue_integration_test.go ├── data_test.go ├── data.go └── download_test.go ├── .golangci.yml ├── pyproject.toml ├── .dockerignore ├── pkg ├── clierr │ └── clierr.go ├── pool │ ├── pool_cancel_test.go │ ├── pool.go │ └── pool_test.go ├── hasher │ ├── hasher.go │ └── hasher_test.go ├── validation │ ├── validation.go │ └── validation_test.go └── operations │ ├── storage.go │ ├── storage_test.go │ ├── hashing_test.go │ └── hashing.go ├── auth ├── interfaces.go ├── service_test.go ├── services.go └── service_integration_test.go ├── .editorconfig ├── LICENSE ├── .pre-commit-config.yaml ├── main.go ├── .gitignore ├── .gitattributes ├── db ├── repository_test.go ├── token.go ├── db_test.go ├── repository.go ├── game.go ├── token_test.go ├── db.go └── game_test.go ├── CONTRIBUTING.md ├── Dockerfile ├── main_integration_test.go ├── main_test.go └── go.mod /.github/FUNDING.yml: -------------------------------------------------------------------------------- 1 | github: [ habedi ] 2 | -------------------------------------------------------------------------------- /codecov.yml: -------------------------------------------------------------------------------- 1 | #ignore: 2 | #- "gui/*.go" 3 | -------------------------------------------------------------------------------- /CODE_OF_CONDUCT.md: -------------------------------------------------------------------------------- 1 | ## Code of Conduct 2 | 3 | We adhere to the [Go Community Code of Conduct](https://go.dev/conduct). 4 | -------------------------------------------------------------------------------- /gui/assets/ding-small-bell-sfx-233008.mp3: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/habedi/gogg/HEAD/gui/assets/ding-small-bell-sfx-233008.mp3 -------------------------------------------------------------------------------- /logo.jpeg: -------------------------------------------------------------------------------- 1 | version https://git-lfs.github.com/spec/v1 2 | oid sha256:494b498aac7634d722c3252efb072016043012a47e666f0a369bca8f08e7f96e 3 | size 34487 4 | -------------------------------------------------------------------------------- /docs/screenshots/v0.4.2/1.png: -------------------------------------------------------------------------------- 1 | version https://git-lfs.github.com/spec/v1 2 | oid sha256:dee251dba93fce718ccf2ceee24b01d586b1b5f22f9d2b3c99769f1d6c694be5 3 | size 27128 4 | -------------------------------------------------------------------------------- /docs/screenshots/v0.4.2/10.png: -------------------------------------------------------------------------------- 1 | version https://git-lfs.github.com/spec/v1 2 | oid sha256:2ca2344651687aba67b281309aeebef3a79b3506922d8ed1ce63de344579a393 3 | size 121876 4 | -------------------------------------------------------------------------------- /docs/screenshots/v0.4.2/11.png: -------------------------------------------------------------------------------- 1 | version https://git-lfs.github.com/spec/v1 2 | oid sha256:cfe94d4ecf434b7a407e53593f5d0f73e902945f5249dbaf35bda6cb014f584c 3 | size 243757 4 | -------------------------------------------------------------------------------- /docs/screenshots/v0.4.2/12.png: -------------------------------------------------------------------------------- 1 | version https://git-lfs.github.com/spec/v1 2 | oid sha256:dc3f5921d90a67f120d893271324b3530bf36aca55904f867eb6c52dd8808678 3 | size 62617 4 | -------------------------------------------------------------------------------- /docs/screenshots/v0.4.2/13.png: -------------------------------------------------------------------------------- 1 | version https://git-lfs.github.com/spec/v1 2 | oid sha256:240516c6548b9c49c1f3d6247cf9b201c50475e2ae998e15966b83d185373a48 3 | size 56772 4 | -------------------------------------------------------------------------------- /docs/screenshots/v0.4.2/14.png: -------------------------------------------------------------------------------- 1 | version https://git-lfs.github.com/spec/v1 2 | oid sha256:0ee9b703bf8180216ca99b18518e4f85d9e06d48bafd1c9ced28c231803b4088 3 | size 47677 4 | -------------------------------------------------------------------------------- /docs/screenshots/v0.4.2/2.png: -------------------------------------------------------------------------------- 1 | version https://git-lfs.github.com/spec/v1 2 | oid sha256:b421fee5f035617be9b9d895a65e076f68915190783e26fd405fb98fe65ce86b 3 | size 46223 4 | -------------------------------------------------------------------------------- /docs/screenshots/v0.4.2/3.png: -------------------------------------------------------------------------------- 1 | version https://git-lfs.github.com/spec/v1 2 | oid sha256:6272849861235331621add3afa808839f602046f0bb0a112ff42d65a0ee4cf02 3 | size 55849 4 | -------------------------------------------------------------------------------- /docs/screenshots/v0.4.2/4.png: -------------------------------------------------------------------------------- 1 | version https://git-lfs.github.com/spec/v1 2 | oid sha256:2a74d0b94e8dc13c8a37ec9ea3bec9ab6e482fa06c1c6884c3f830a565ab672f 3 | size 50988 4 | -------------------------------------------------------------------------------- /docs/screenshots/v0.4.2/5.png: -------------------------------------------------------------------------------- 1 | version https://git-lfs.github.com/spec/v1 2 | oid sha256:570c229ba1c41c2dcaf571fa3b023a616999bfd3d0ef0b5f6c9f0de040715477 3 | size 48920 4 | -------------------------------------------------------------------------------- /docs/screenshots/v0.4.2/6.png: -------------------------------------------------------------------------------- 1 | version https://git-lfs.github.com/spec/v1 2 | oid sha256:27a9df839acfc87f8d80bb09c08f812206a1d4b0af8a345d876b4f5f5e86d3e4 3 | size 54583 4 | -------------------------------------------------------------------------------- /docs/screenshots/v0.4.2/7.png: -------------------------------------------------------------------------------- 1 | version https://git-lfs.github.com/spec/v1 2 | oid sha256:5c6b254ba6d61a4f3c3f5b250b51dd5a5e6bff56ca3c531c81b02ed8d1cd1882 3 | size 63228 4 | -------------------------------------------------------------------------------- /docs/screenshots/v0.4.2/8.png: -------------------------------------------------------------------------------- 1 | version https://git-lfs.github.com/spec/v1 2 | oid sha256:55101f646c83666e835ca9a3cfced247883db13b8826ed4cd8aeccb6498b9753 3 | size 102448 4 | -------------------------------------------------------------------------------- /docs/screenshots/v0.4.2/9.png: -------------------------------------------------------------------------------- 1 | version https://git-lfs.github.com/spec/v1 2 | oid sha256:4ab6de7f2718d366ed68e8c4a656b0a2101347dcd0f04749f194f5c6c89762cb 3 | size 74434 4 | -------------------------------------------------------------------------------- /gui/assets/JetBrainsMono-2.304/fonts/ttf/JetBrainsMono-Bold.ttf: -------------------------------------------------------------------------------- 1 | version https://git-lfs.github.com/spec/v1 2 | oid sha256:5590990c82e097397517f275f430af4546e1c45cff408bde4255dad142479dcb 3 | size 277828 4 | -------------------------------------------------------------------------------- /gui/assets/JetBrainsMono-2.304/fonts/ttf/JetBrainsMono-Regular.ttf: -------------------------------------------------------------------------------- 1 | version https://git-lfs.github.com/spec/v1 2 | oid sha256:a0bf60ef0f83c5ed4d7a75d45838548b1f6873372dfac88f71804491898d138f 3 | size 273900 4 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/config.yml: -------------------------------------------------------------------------------- 1 | blank_issues_enabled: false 2 | contact_links: 3 | - name: Discussions 4 | url: https://github.com/habedi/gogg/discussions 5 | about: Please ask and answer general questions here 6 | -------------------------------------------------------------------------------- /cmd/clierr_tracker.go: -------------------------------------------------------------------------------- 1 | package cmd 2 | 3 | import "github.com/habedi/gogg/pkg/clierr" 4 | 5 | var lastCliErr *clierr.Error 6 | 7 | func setLastCliErr(e *clierr.Error) { lastCliErr = e } 8 | func getLastCliErr() *clierr.Error { return lastCliErr } 9 | -------------------------------------------------------------------------------- /scripts/docker_entrypoint.sh: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | set -e 3 | 4 | if [[ "$1" == "gui" ]]; then 5 | echo "Starting GUI with Xvfb..." 6 | exec xvfb-run --auto-servernum --server-args="-screen 0 1024x768x24" gogg gui 7 | else 8 | exec gogg "$@" 9 | fi 10 | -------------------------------------------------------------------------------- /docs/figures/make_figures.sh: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | 3 | # You need to have Graphviz installed to run this script 4 | # On Debian-based OSes, you can install it using: sudo apt-get install graphviz 5 | 6 | # Directory containing .dot files (with default value) 7 | ASSET_DIR=${1:-"."} 8 | 9 | # Make figures from .dot files 10 | for f in "${ASSET_DIR}"/*.dot; do 11 | dot -Tsvg "$f" -o "${f%.dot}.svg" 12 | done 13 | -------------------------------------------------------------------------------- /gui/shared.go: -------------------------------------------------------------------------------- 1 | package gui 2 | 3 | import ( 4 | "net/url" 5 | 6 | "fyne.io/fyne/v2" 7 | ) 8 | 9 | // runOnMain schedules fn to run on the main Fyne thread 10 | func runOnMain(fn func()) { 11 | fyne.Do(fn) 12 | } 13 | 14 | // parseURL safely parses a URL string 15 | func parseURL(urlStr string) *url.URL { 16 | u, err := url.Parse(urlStr) 17 | if err != nil { 18 | return nil 19 | } 20 | return u 21 | } 22 | -------------------------------------------------------------------------------- /gui/assets/JetBrainsMono-2.304/AUTHORS.txt: -------------------------------------------------------------------------------- 1 | # This is the official list of project authors for copyright purposes. 2 | # This file is distinct from the CONTRIBUTORS.txt file. 3 | # See the latter for an explanation. 4 | # 5 | # Names should be added to this file as: 6 | # Name or Organization 7 | 8 | JetBrains <> 9 | Philipp Nurullin 10 | Konstantin Bulenkov 11 | -------------------------------------------------------------------------------- /cmd/version_format_test.go: -------------------------------------------------------------------------------- 1 | package cmd 2 | 3 | import "testing" 4 | 5 | func TestFormatBytes(t *testing.T) { 6 | cases := []struct { 7 | in int64 8 | want string 9 | }{ 10 | {999, "999 B"}, 11 | {1024, "1.0KiB"}, 12 | {1024*1024 + 512*1024, "1.5MiB"}, 13 | } 14 | for _, c := range cases { 15 | got := formatBytes(c.in) 16 | if got != c.want { 17 | t.Fatalf("formatBytes(%d)=%q, want %q", c.in, got, c.want) 18 | } 19 | } 20 | } 21 | -------------------------------------------------------------------------------- /cmd/gui.go: -------------------------------------------------------------------------------- 1 | //go:build !headless 2 | 3 | package cmd 4 | 5 | import ( 6 | "github.com/habedi/gogg/auth" 7 | "github.com/habedi/gogg/gui" 8 | "github.com/spf13/cobra" 9 | ) 10 | 11 | func guiCmd(authService *auth.Service) *cobra.Command { 12 | cmd := &cobra.Command{ 13 | Use: "gui", 14 | Short: "Start the Gogg GUI", 15 | Run: func(cmd *cobra.Command, args []string) { 16 | gui.Run(version, authService) 17 | }, 18 | } 19 | return cmd 20 | } 21 | -------------------------------------------------------------------------------- /client/read_body_error_test.go: -------------------------------------------------------------------------------- 1 | package client 2 | 3 | import ( 4 | "errors" 5 | "io" 6 | "net/http" 7 | "testing" 8 | ) 9 | 10 | type errReader struct{} 11 | 12 | func (errReader) Read(p []byte) (int, error) { return 0, errors.New("read err") } 13 | 14 | func TestReadResponseBody_Error(t *testing.T) { 15 | resp := &http.Response{Body: io.NopCloser(errReader{})} 16 | if _, err := readResponseBody(resp); err == nil { 17 | t.Fatalf("expected error") 18 | } 19 | } 20 | -------------------------------------------------------------------------------- /.golangci.yml: -------------------------------------------------------------------------------- 1 | version: "2" 2 | linters: 3 | exclusions: 4 | generated: lax 5 | presets: 6 | - comments 7 | - common-false-positives 8 | - legacy 9 | - std-error-handling 10 | rules: 11 | - linters: 12 | - errcheck 13 | path: _test\.go 14 | paths: 15 | - third_party$ 16 | - builtin$ 17 | - examples$ 18 | formatters: 19 | exclusions: 20 | generated: lax 21 | paths: 22 | - third_party$ 23 | - builtin$ 24 | - examples$ 25 | -------------------------------------------------------------------------------- /cmd/version_test.go: -------------------------------------------------------------------------------- 1 | package cmd 2 | 3 | import ( 4 | "bytes" 5 | "strings" 6 | "testing" 7 | ) 8 | 9 | func TestVersionCmd_PrintsInfo(t *testing.T) { 10 | cmd := versionCmd() 11 | buf := new(bytes.Buffer) 12 | cmd.SetOut(buf) 13 | cmd.SetErr(buf) 14 | if err := cmd.Execute(); err != nil { 15 | t.Fatalf("execute: %v", err) 16 | } 17 | out := buf.String() 18 | if !strings.Contains(out, "Gogg version:") || !strings.Contains(out, "Go version:") || !strings.Contains(out, "Platform:") { 19 | t.Fatalf("unexpected output: %s", out) 20 | } 21 | } 22 | -------------------------------------------------------------------------------- /gui/events.go: -------------------------------------------------------------------------------- 1 | package gui 2 | 3 | import "fyne.io/fyne/v2/data/binding" 4 | 5 | // catalogueUpdated is a simple data binding that acts as a signal bus. 6 | // Any part of the UI can listen for changes to know when the catalogue is refreshed. 7 | var catalogueUpdated = binding.NewBool() 8 | 9 | // SignalCatalogueUpdated sends a notification that the catalogue has been updated. 10 | func SignalCatalogueUpdated() { 11 | err := catalogueUpdated.Set(true) 12 | if err != nil { 13 | return 14 | } // The value doesn't matter, only the change event. 15 | } 16 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/bug_report.md: -------------------------------------------------------------------------------- 1 | --- 2 | name: Bug report 3 | about: Create a report to help us improve 4 | title: '' 5 | labels: bug 6 | assignees: '' 7 | 8 | --- 9 | 10 | **Describe the bug** 11 | A clear and concise description of what the bug is. 12 | 13 | **To Reproduce** 14 | Steps to reproduce the behavior: 15 | 1. 16 | 17 | **Expected behavior** 18 | A clear and concise description of what you expected to happen. 19 | 20 | **Logs** 21 | If applicable, add logs to help explain your problem. 22 | 23 | **Additional context** 24 | Add any other context about the problem here. 25 | -------------------------------------------------------------------------------- /pyproject.toml: -------------------------------------------------------------------------------- 1 | [project] 2 | name = "gogg" 3 | version = "0.1.0" 4 | description = "The Python environment for the Gogg project" 5 | 6 | requires-python = ">=3.10,<4.0" 7 | dependencies = [ 8 | "python-dotenv (>=1.1.0,<2.0.0)", 9 | "pre-commit (>=4.2.0,<5.0.0)" 10 | ] 11 | 12 | [project.optional-dependencies] 13 | dev = [ 14 | "pytest (>=8.0.1,<9.0.0)", 15 | "pytest-cov (>=6.0.0,<7.0.0)", 16 | "pytest-mock (>=3.14.0,<4.0.0)", 17 | "pytest-asyncio (>=0.26.0,<0.27.0)", 18 | "mypy (>=1.11.1,<2.0.0)", 19 | "ruff (>=0.9.3,<1.0.0)", 20 | "icecream (>=2.1.4,<3.0.0)" 21 | ] 22 | -------------------------------------------------------------------------------- /cmd/version.go: -------------------------------------------------------------------------------- 1 | package cmd 2 | 3 | import ( 4 | "runtime" 5 | 6 | "github.com/spf13/cobra" 7 | ) 8 | 9 | var ( 10 | version = "0.4.3-beta" 11 | goVersion = runtime.Version() 12 | platform = runtime.GOOS + "/" + runtime.GOARCH 13 | ) 14 | 15 | func versionCmd() *cobra.Command { 16 | cmd := &cobra.Command{ 17 | Use: "version", 18 | Short: "Show version information", 19 | Run: func(cmd *cobra.Command, args []string) { 20 | cmd.Println("Gogg version:", version) 21 | cmd.Println("Go version:", goVersion) 22 | cmd.Println("Platform:", platform) 23 | }, 24 | } 25 | return cmd 26 | } 27 | -------------------------------------------------------------------------------- /.dockerignore: -------------------------------------------------------------------------------- 1 | # Version control 2 | .git 3 | .gitignore 4 | 5 | # Binaries and build artifacts 6 | bin/ 7 | *.exe 8 | *.out 9 | *.test 10 | 11 | # IDE/editor junk 12 | *.swp 13 | *.swo 14 | *.bak 15 | *.tmp 16 | *.DS_Store 17 | .vscode/ 18 | .idea/ 19 | 20 | # Dependency directories 21 | vendor/ 22 | 23 | # Go test cache 24 | *.coverprofile 25 | *.cov 26 | coverage.out 27 | 28 | # Logs 29 | *.log 30 | 31 | # OS junk 32 | Thumbs.db 33 | 34 | # Other unneeded files and directories 35 | *.csv 36 | *.json 37 | *.zip 38 | pyproject.toml 39 | requirements.txt 40 | Dockerfile 41 | .venv/ 42 | games/ 43 | tmp/ 44 | temp/ 45 | -------------------------------------------------------------------------------- /gui/desktop.go: -------------------------------------------------------------------------------- 1 | package gui 2 | 3 | import ( 4 | "os/exec" 5 | "runtime" 6 | 7 | "github.com/rs/zerolog/log" 8 | ) 9 | 10 | // openFolder opens the specified path in the system's default file explorer. 11 | func openFolder(path string) { 12 | var cmd *exec.Cmd 13 | switch runtime.GOOS { 14 | case "windows": 15 | cmd = exec.Command("explorer", path) 16 | case "darwin": 17 | cmd = exec.Command("open", path) 18 | default: // "linux", "freebsd", "openbsd", "netbsd" 19 | cmd = exec.Command("xdg-open", path) 20 | } 21 | if err := cmd.Run(); err != nil { 22 | log.Error().Err(err).Str("path", path).Msg("Failed to open folder") 23 | } 24 | } 25 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/feature_request.md: -------------------------------------------------------------------------------- 1 | --- 2 | name: Feature request 3 | about: Suggest an idea for this project 4 | title: '' 5 | labels: enhancement 6 | assignees: '' 7 | 8 | --- 9 | 10 | **Is your feature request related to a problem? Please describe.** 11 | A clear and concise description of what the problem is. Ex. I'm always frustrated when [...] 12 | 13 | **Describe the solution you'd like** 14 | A clear and concise description of what you want to happen. 15 | 16 | **Describe alternatives you've considered** 17 | A clear and concise description of any alternative solutions or features you've considered. 18 | 19 | **Additional context** 20 | Add any other context or screenshots about the feature request here. 21 | -------------------------------------------------------------------------------- /client/fuzz_test.go: -------------------------------------------------------------------------------- 1 | //go:build go1.18 2 | 3 | package client 4 | 5 | import "testing" 6 | 7 | func FuzzParseSizeString(f *testing.F) { 8 | seed := []string{"1", "1024", "1KB", "1 KB", "1.5MB", "2 mb", "10GB", "0.1 gb", "3TB", "1GiB", "invalid"} 9 | for _, s := range seed { 10 | f.Add(s) 11 | } 12 | f.Fuzz(func(t *testing.T, s string) { 13 | _, _ = parseSizeString(s) 14 | }) 15 | } 16 | 17 | func FuzzParseGameData(f *testing.F) { 18 | seed := []string{ 19 | `{"title":"Test","downloads":[],"extras":[],"dlcs":[]}`, 20 | `{"title":"","downloads":[],"extras":[],"dlcs":[]}`, 21 | } 22 | for _, s := range seed { 23 | f.Add(s) 24 | } 25 | f.Fuzz(func(t *testing.T, s string) { 26 | _, _ = ParseGameData(s) 27 | }) 28 | } 29 | -------------------------------------------------------------------------------- /cmd/gui_headless.go: -------------------------------------------------------------------------------- 1 | //go:build headless 2 | 3 | package cmd 4 | 5 | import ( 6 | "fmt" 7 | 8 | "github.com/habedi/gogg/auth" 9 | "github.com/spf13/cobra" 10 | ) 11 | 12 | func guiCmd(authService *auth.Service) *cobra.Command { 13 | cmd := &cobra.Command{ 14 | Use: "gui", 15 | Short: "Start the Gogg GUI (not available in headless build)", 16 | Run: func(cmd *cobra.Command, args []string) { 17 | fmt.Println("Error: GUI is not available in this build.") 18 | fmt.Println("This is a headless (CLI-only) version of Gogg.") 19 | fmt.Println("To use the GUI, please download the full version for your platform") 20 | fmt.Println("or build from source without the 'headless' tag.") 21 | }, 22 | } 23 | return cmd 24 | } 25 | -------------------------------------------------------------------------------- /pkg/clierr/clierr.go: -------------------------------------------------------------------------------- 1 | package clierr 2 | 3 | // Type categorizes a CLI-facing error for consistent messaging & potential exit codes. 4 | type Type string 5 | 6 | const ( 7 | Validation Type = "validation" 8 | NotFound Type = "not_found" 9 | Download Type = "download" 10 | Internal Type = "internal" 11 | ) 12 | 13 | // Error is a structured user-facing error. 14 | type Error struct { 15 | Type Type 16 | Message string 17 | Err error // optional underlying error 18 | } 19 | 20 | func (e *Error) Error() string { return e.Message } 21 | func (e *Error) Unwrap() error { return e.Err } 22 | 23 | // New constructs a new CLI Error. 24 | func New(t Type, msg string, err error) *Error { return &Error{Type: t, Message: msg, Err: err} } 25 | -------------------------------------------------------------------------------- /client/pagination_loop_test.go: -------------------------------------------------------------------------------- 1 | package client 2 | 3 | import ( 4 | "context" 5 | "net/http" 6 | "net/http/httptest" 7 | "testing" 8 | ) 9 | 10 | func TestFetchAllOwnedGameIDs_LoopGuard(t *testing.T) { 11 | // Return next pointing to the same URL to simulate loop; we expect it to stop after first iteration 12 | server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { 13 | w.Write([]byte(`{"owned":[1],"next":"` + r.URL.String() + `"}`)) 14 | })) 15 | defer server.Close() 16 | 17 | ctx := context.Background() 18 | ids, err := FetchAllOwnedGameIDs(ctx, "tok", server.URL) 19 | if err != nil { 20 | t.Fatalf("err: %v", err) 21 | } 22 | if len(ids) != 1 || ids[0] != 1 { 23 | t.Fatalf("ids: %#v", ids) 24 | } 25 | } 26 | -------------------------------------------------------------------------------- /gui/widgets.go: -------------------------------------------------------------------------------- 1 | package gui 2 | 3 | import ( 4 | "fmt" 5 | 6 | "fyne.io/fyne/v2" 7 | "fyne.io/fyne/v2/widget" 8 | ) 9 | 10 | // CopyableLabel is a label that copies its content to the clipboard when tapped. 11 | type CopyableLabel struct { 12 | widget.Label 13 | } 14 | 15 | // NewCopyableLabel creates a new instance of the copyable label with the given text. 16 | func NewCopyableLabel(text string) *CopyableLabel { 17 | cl := &CopyableLabel{} 18 | cl.ExtendBaseWidget(cl) 19 | cl.SetText(text) 20 | return cl 21 | } 22 | 23 | // Tapped is called when a pointer taps this widget. 24 | func (cl *CopyableLabel) Tapped(_ *fyne.PointEvent) { 25 | fyne.CurrentApp().Clipboard().SetContent(cl.Text) 26 | fyne.CurrentApp().SendNotification( 27 | fyne.NewNotification("Copied to Clipboard", fmt.Sprintf("'%s' was copied.", cl.Text)), 28 | ) 29 | } 30 | -------------------------------------------------------------------------------- /client/games_pagination_test.go: -------------------------------------------------------------------------------- 1 | package client 2 | 3 | import ( 4 | "context" 5 | "net/http" 6 | "net/http/httptest" 7 | "testing" 8 | ) 9 | 10 | func TestFetchAllOwnedGameIDs_Pagination(t *testing.T) { 11 | server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { 12 | if r.URL.Path == "/first" { 13 | w.Write([]byte(`{"owned":[1,2],"next":"/second"}`)) 14 | return 15 | } 16 | if r.URL.Path == "/second" { 17 | w.Write([]byte(`{"owned":[3]}`)) 18 | return 19 | } 20 | w.WriteHeader(404) 21 | })) 22 | defer server.Close() 23 | 24 | ctx := context.Background() 25 | ids, err := FetchAllOwnedGameIDs(ctx, "tok", server.URL+"/first") 26 | if err != nil { 27 | t.Fatalf("err: %v", err) 28 | } 29 | if len(ids) != 3 || ids[0] != 1 || ids[2] != 3 { 30 | t.Fatalf("ids: %#v", ids) 31 | } 32 | } 33 | -------------------------------------------------------------------------------- /auth/interfaces.go: -------------------------------------------------------------------------------- 1 | package auth 2 | 3 | import ( 4 | "context" 5 | 6 | "github.com/habedi/gogg/db" 7 | ) 8 | 9 | // TokenStorer defines the contract for any component that can store and retrieve a token. 10 | type TokenStorer interface { 11 | GetTokenRecord() (*db.Token, error) 12 | UpsertTokenRecord(token *db.Token) error 13 | } 14 | 15 | // TokenRefresher defines the contract for any component that can perform a token refresh action. 16 | type TokenRefresher interface { 17 | PerformTokenRefresh(refreshToken string) (accessToken string, newRefreshToken string, expiresIn int64, err error) 18 | } 19 | 20 | // TokenRefresherWithCtx optionally supports context-aware refresh. 21 | type TokenRefresherWithCtx interface { 22 | PerformTokenRefreshCtx(ctx context.Context, refreshToken string) (accessToken, newRefreshToken string, expiresIn int64, err error) 23 | } 24 | -------------------------------------------------------------------------------- /gui/assets.go: -------------------------------------------------------------------------------- 1 | package gui 2 | 3 | import ( 4 | _ "embed" 5 | 6 | "fyne.io/fyne/v2" 7 | ) 8 | 9 | //go:embed assets/game-card-svgrepo-com.svg 10 | var logoSVG []byte 11 | 12 | // AppLogo is the resource for the embedded logo.svg file. 13 | var AppLogo = fyne.NewStaticResource("game-card-svgrepo-com.svg", logoSVG) 14 | 15 | // --- Embedded JetBrains Mono Fonts --- 16 | 17 | //go:embed assets/JetBrainsMono-2.304/fonts/ttf/JetBrainsMono-Regular.ttf 18 | var jetbrainsMonoRegularFont []byte 19 | 20 | //go:embed assets/JetBrainsMono-2.304/fonts/ttf/JetBrainsMono-Bold.ttf 21 | var jetbrainsMonoBoldFont []byte 22 | 23 | var ( 24 | JetBrainsMonoRegular = &fyne.StaticResource{StaticName: "JetBrainsMono-Regular.ttf", StaticContent: jetbrainsMonoRegularFont} 25 | JetBrainsMonoBold = &fyne.StaticResource{StaticName: "JetBrainsMono-Bold.ttf", StaticContent: jetbrainsMonoBoldFont} 26 | ) 27 | -------------------------------------------------------------------------------- /client/utils_test.go: -------------------------------------------------------------------------------- 1 | package client 2 | 3 | import "testing" 4 | 5 | func Test_parseSizeString(t *testing.T) { 6 | cases := []struct { 7 | in string 8 | ok bool 9 | min int64 10 | }{ 11 | {"1024", true, 1024}, 12 | {"1KB", true, 1024}, 13 | {"1 KB", true, 1024}, 14 | {"1.5 MB", true, 1_500_000}, 15 | {"2mb", true, 2 * 1024 * 1024}, 16 | {"0.5GB", true, 512 * 1024 * 1024}, 17 | {"1GiB", true, 1024 * 1024 * 1024}, 18 | {"10 tb", true, 10 * 1024 * 1024 * 1024 * 1024}, 19 | {"unknown", false, 0}, 20 | } 21 | for _, c := range cases { 22 | v, err := parseSizeString(c.in) 23 | if c.ok { 24 | if err != nil { 25 | t.Fatalf("expected ok for %q: %v", c.in, err) 26 | } 27 | if v < c.min { 28 | t.Fatalf("expected %q >= %d, got %d", c.in, c.min, v) 29 | } 30 | } else if err == nil { 31 | t.Fatalf("expected error for %q", c.in) 32 | } 33 | } 34 | } 35 | -------------------------------------------------------------------------------- /pkg/pool/pool_cancel_test.go: -------------------------------------------------------------------------------- 1 | package pool_test 2 | 3 | import ( 4 | "context" 5 | "sync/atomic" 6 | "testing" 7 | "time" 8 | 9 | "github.com/habedi/gogg/pkg/pool" 10 | ) 11 | 12 | func TestPool_CancelStopsEnqueue(t *testing.T) { 13 | items := make([]int, 1000) 14 | for i := range items { 15 | items[i] = i 16 | } 17 | ctx, cancel := context.WithCancel(context.Background()) 18 | var processed int64 19 | 20 | worker := func(ctx context.Context, i int) error { 21 | atomic.AddInt64(&processed, 1) 22 | if i == 0 { 23 | cancel() 24 | } 25 | select { 26 | case <-ctx.Done(): 27 | return ctx.Err() 28 | case <-time.After(1 * time.Millisecond): 29 | } 30 | return nil 31 | } 32 | 33 | _ = pool.Run(ctx, items, 8, worker) 34 | if atomic.LoadInt64(&processed) >= int64(len(items)) { 35 | t.Fatalf("expected fewer items processed after cancel, got %d", processed) 36 | } 37 | } 38 | -------------------------------------------------------------------------------- /.editorconfig: -------------------------------------------------------------------------------- 1 | # EditorConfig is awesome: https://EditorConfig.org 2 | 3 | # Top-most EditorConfig file 4 | root = true 5 | 6 | # Global settings (applicable to all files unless overridden) 7 | [*] 8 | # Default character encoding 9 | charset = utf-8 10 | # Use LF for line endings (Unix-style) 11 | end_of_line = lf 12 | # Use spaces for indentation 13 | indent_style = space 14 | # Default indentation size 15 | indent_size = 4 16 | # Make sure files end with a newline 17 | insert_final_newline = true 18 | # Remove trailing whitespace 19 | trim_trailing_whitespace = true 20 | 21 | # Go files 22 | [*.go] 23 | max_line_length = 100 24 | 25 | # Markdown files 26 | [*.md] 27 | max_line_length = 120 28 | trim_trailing_whitespace = false 29 | 30 | # Bash scripts 31 | [*.sh] 32 | indent_size = 2 33 | 34 | # PowerShell scripts 35 | [*.ps1] 36 | indent_size = 2 37 | 38 | # YAML files 39 | [*.{yaml,yml}] 40 | indent_size = 2 41 | -------------------------------------------------------------------------------- /client/games_unit_extra_test.go: -------------------------------------------------------------------------------- 1 | package client 2 | 3 | import ( 4 | "context" 5 | "net/http" 6 | "net/http/httptest" 7 | "testing" 8 | ) 9 | 10 | func TestFetchIdOfOwnedGames_ParsesArray(t *testing.T) { 11 | server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { 12 | w.WriteHeader(http.StatusOK) 13 | w.Write([]byte(`{"owned":[10,20,30]}`)) 14 | })) 15 | defer server.Close() 16 | 17 | ids, err := FetchIdOfOwnedGames(context.Background(), "token", server.URL) 18 | if err != nil { 19 | t.Fatalf("err: %v", err) 20 | } 21 | if len(ids) != 3 || ids[0] != 10 || ids[2] != 30 { 22 | t.Fatalf("unexpected ids: %#v", ids) 23 | } 24 | } 25 | 26 | func TestCreateRequest_AddsAuthHeader(t *testing.T) { 27 | req, err := createRequest(context.Background(), "GET", "http://example.com", "abc") 28 | if err != nil { 29 | t.Fatal(err) 30 | } 31 | if got := req.Header.Get("Authorization"); got == "" { 32 | t.Fatalf("missing auth header") 33 | } 34 | } 35 | -------------------------------------------------------------------------------- /cmd/hash_clean_test.go: -------------------------------------------------------------------------------- 1 | package cmd 2 | 3 | import ( 4 | "bytes" 5 | "os" 6 | "path/filepath" 7 | "testing" 8 | ) 9 | 10 | func TestHashCmd_CleanRecursiveRemovesOldHashes(t *testing.T) { 11 | dir := t.TempDir() 12 | sub := filepath.Join(dir, "sub") 13 | if err := os.MkdirAll(sub, 0o755); err != nil { 14 | t.Fatal(err) 15 | } 16 | f := filepath.Join(sub, "file.bin") 17 | if err := os.WriteFile(f, []byte("data"), 0644); err != nil { 18 | t.Fatal(err) 19 | } 20 | // Pre-create a .md5 file to be cleaned 21 | if err := os.WriteFile(f+".md5", []byte("hash"), 0644); err != nil { 22 | t.Fatal(err) 23 | } 24 | 25 | cmd := hashCmd() 26 | cmd.SetArgs([]string{dir, "-a", "md5", "-c", "-r", "-s"}) 27 | buf := new(bytes.Buffer) 28 | cmd.SetOut(buf) 29 | cmd.SetErr(buf) 30 | cmd.Execute() 31 | 32 | // After clean+rehash, a new .md5 should exist and only under included files 33 | if _, err := os.Stat(f + ".md5"); err != nil { 34 | t.Fatalf("expected %s to exist after rehash: %v", f+".md5", err) 35 | } 36 | } 37 | -------------------------------------------------------------------------------- /client/progress_reader_test.go: -------------------------------------------------------------------------------- 1 | package client 2 | 3 | import ( 4 | "bufio" 5 | "bytes" 6 | "encoding/json" 7 | "io" 8 | "testing" 9 | ) 10 | 11 | func TestProgressReader_EmitsJSONLines(t *testing.T) { 12 | src := bytes.NewBuffer(make([]byte, 2048)) 13 | buf := new(bytes.Buffer) 14 | pr := &progressReader{reader: src, writer: buf, fileName: "file.bin", totalSize: 2048} 15 | 16 | r := make([]byte, 512) 17 | for { 18 | _, err := pr.Read(r) 19 | if err == io.EOF { 20 | break 21 | } 22 | if err != nil { 23 | t.Fatalf("read: %v", err) 24 | } 25 | } 26 | 27 | scanner := bufio.NewScanner(bytes.NewReader(buf.Bytes())) 28 | count := 0 29 | for scanner.Scan() { 30 | count++ 31 | var u ProgressUpdate 32 | if err := json.Unmarshal(scanner.Bytes(), &u); err != nil { 33 | t.Fatalf("bad json: %v", err) 34 | } 35 | if u.FileName != "file.bin" || u.Type != "file_progress" { 36 | t.Fatalf("unexpected update: %+v", u) 37 | } 38 | } 39 | if count == 0 { 40 | t.Fatalf("expected progress updates") 41 | } 42 | } 43 | -------------------------------------------------------------------------------- /scripts/test_makefile.sh: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | 3 | # Exit immediately if a command exits with a non-zero status. 4 | set -e 5 | 6 | # --- Colors for logging --- 7 | GREEN='\033[0;32m' 8 | YELLOW='\033[1;33m' 9 | NC='\033[0m' # No Color 10 | 11 | # --- Helper function to run and log a test --- 12 | run_test() { 13 | local target="$1" 14 | local description="$2" 15 | echo -e "\n${YELLOW}---> Testing 'make ${target}': ${description}...${NC}" 16 | make "${target}" 17 | echo -e "${GREEN}---> 'make ${target}' successful.${NC}" 18 | } 19 | 20 | # --- Main test execution --- 21 | echo -e "${YELLOW}===== Starting Makefile Test Suite =====${NC}" 22 | 23 | run_test "clean" "Removing old build artifacts" 24 | run_test "format" "Formatting Go files" 25 | run_test "lint" "Running linter checks" 26 | run_test "test" "Running unit tests" 27 | run_test "test-integration" "Running integration tests" 28 | run_test "test-fuzz" "Running fuzz tests" 29 | 30 | # --- Final Success Message --- 31 | echo -e "\n${GREEN}===== Makefile Test Suite Completed Successfully! =====${NC}" 32 | -------------------------------------------------------------------------------- /.github/workflows/lints.yml: -------------------------------------------------------------------------------- 1 | name: Run Linter Checks 2 | 3 | on: 4 | workflow_dispatch: 5 | pull_request: 6 | branches: 7 | - "main" 8 | paths-ignore: 9 | - '**.md' 10 | - 'docs/**' 11 | push: 12 | tags: 13 | - 'v*' 14 | 15 | permissions: 16 | contents: read 17 | 18 | jobs: 19 | lints: 20 | runs-on: ubuntu-latest 21 | 22 | steps: 23 | - name: Checkout Repository 24 | uses: actions/checkout@v4 25 | 26 | - name: Set up Go Environment 27 | uses: actions/setup-go@v5 28 | with: 29 | go-version: stable 30 | 31 | - name: Cache Go Modules 32 | uses: actions/cache@v4 33 | with: 34 | path: | 35 | ~/.cache/go-build 36 | ~/go/pkg/mod 37 | key: ${{ runner.os }}-go-${{ hashFiles('**/go.sum') }} 38 | restore-keys: | 39 | ${{ runner.os }}-go- 40 | 41 | - name: Install Dependencies 42 | run: | 43 | make install-deps 44 | 45 | - name: Run Linters 46 | run: | 47 | make lint 48 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2025 Hassan Abedi 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /client/property_test.go: -------------------------------------------------------------------------------- 1 | package client 2 | 3 | import ( 4 | "math/rand" 5 | "testing" 6 | ) 7 | 8 | func TestFuzzSanitizePath(t *testing.T) { 9 | r := rand.New(rand.NewSource(42)) 10 | chars := []rune("abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789 -_()[]{}!@#$%^&*+=|\\/<>?\"'™®:") 11 | for i := 0; i < 1000; i++ { 12 | l := r.Intn(64) 13 | runes := make([]rune, l) 14 | for j := 0; j < l; j++ { 15 | runes[j] = chars[r.Intn(len(chars))] 16 | } 17 | in := string(runes) 18 | out := SanitizePath(in) 19 | if out != "" { 20 | if out[0] == '-' || out[len(out)-1] == '-' { 21 | t.Fatalf("sanitize produced leading/trailing hyphen for %q -> %q", in, out) 22 | } 23 | for k := 0; k < len(out); k++ { 24 | c := out[k] 25 | if c == '/' || c == '\\' { 26 | t.Fatalf("sanitize produced path separator for %q -> %q", in, out) 27 | } 28 | } 29 | } 30 | } 31 | } 32 | 33 | func TestFuzzParseSizeString(t *testing.T) { 34 | cases := []string{"1", "1024", "1KB", "1 kb", "1.5MB", "2 mb", "10GB", "0.1 gb", "3TB", "1GiB", "invalid"} 35 | for _, c := range cases { 36 | _, _ = parseSizeString(c) 37 | } 38 | } 39 | -------------------------------------------------------------------------------- /cmd/download_clierr_test.go: -------------------------------------------------------------------------------- 1 | package cmd 2 | 3 | import ( 4 | "context" 5 | "errors" 6 | "testing" 7 | 8 | "github.com/habedi/gogg/auth" 9 | "github.com/habedi/gogg/db" 10 | "github.com/habedi/gogg/pkg/clierr" 11 | ) 12 | 13 | type testStorer struct{} 14 | 15 | func (testStorer) GetTokenRecord() (*db.Token, error) { return nil, nil } 16 | func (testStorer) UpsertTokenRecord(token *db.Token) error { return nil } 17 | 18 | func TestCliErrTypes(t *testing.T) { 19 | // Validation example 20 | e := clierr.New(clierr.Validation, "Invalid platform", errors.New("bad")) 21 | if e.Type != clierr.Validation { 22 | t.Fatalf("type mismatch: %v", e.Type) 23 | } 24 | if e.Error() == "" { 25 | t.Fatal("empty message") 26 | } 27 | if !errors.Is(e, e.Err) { 28 | t.Fatal("unwrap failed") 29 | } 30 | } 31 | 32 | // Simple cancellation smoke test: executeDownload should handle cancelled context gracefully. 33 | func TestExecuteDownload_Cancel(t *testing.T) { 34 | ctx, cancel := context.WithCancel(context.Background()) 35 | cancel() 36 | svc := &auth.Service{Storer: testStorer{}} 37 | executeDownload(ctx, svc, 1, "/tmp", "en", "windows", false, false, true, true, false, false, false, 1) 38 | } 39 | -------------------------------------------------------------------------------- /client/send_request_retry_test.go: -------------------------------------------------------------------------------- 1 | package client 2 | 3 | import ( 4 | "net/http" 5 | "net/http/httptest" 6 | "testing" 7 | "time" 8 | ) 9 | 10 | func TestSendRequest_RetriesOn500ThenSucceeds(t *testing.T) { 11 | attempts := 0 12 | server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { 13 | attempts++ 14 | if attempts == 1 { 15 | w.WriteHeader(http.StatusInternalServerError) 16 | w.Write([]byte("boom")) 17 | return 18 | } 19 | w.WriteHeader(http.StatusOK) 20 | w.Write([]byte("{}")) 21 | })) 22 | defer server.Close() 23 | 24 | req, err := http.NewRequest("GET", server.URL, nil) 25 | if err != nil { 26 | t.Fatal(err) 27 | } 28 | start := time.Now() 29 | resp, err := sendRequest(req) 30 | if err != nil { 31 | t.Fatalf("unexpected error: %v", err) 32 | } 33 | if resp.StatusCode != 200 { 34 | t.Fatalf("status: %d", resp.StatusCode) 35 | } 36 | if attempts != 2 { 37 | t.Fatalf("expected 2 attempts, got %d", attempts) 38 | } 39 | if time.Since(start) < time.Second { 40 | // There is a 1s backoff; if it didn't wait at all, retry didn't happen 41 | t.Fatalf("expected at least ~1s backoff, got quick return") 42 | } 43 | _ = resp.Body.Close() 44 | } 45 | -------------------------------------------------------------------------------- /pkg/hasher/hasher.go: -------------------------------------------------------------------------------- 1 | package hasher 2 | 3 | import ( 4 | "crypto/md5" 5 | "crypto/sha1" 6 | "crypto/sha256" 7 | "crypto/sha512" 8 | "encoding/hex" 9 | "fmt" 10 | "hash" 11 | "io" 12 | "strings" 13 | ) 14 | 15 | // HashAlgorithms is a list of supported hashing algorithms. 16 | var HashAlgorithms = []string{"md5", "sha1", "sha256", "sha512"} 17 | 18 | // IsValidHashAlgo checks if the provided algorithm string is supported. 19 | func IsValidHashAlgo(algo string) bool { 20 | for _, validAlgo := range HashAlgorithms { 21 | if strings.ToLower(algo) == validAlgo { 22 | return true 23 | } 24 | } 25 | return false 26 | } 27 | 28 | // GenerateHashFromReader calculates the hash of content from an io.Reader. 29 | func GenerateHashFromReader(reader io.Reader, algo string) (string, error) { 30 | var h hash.Hash 31 | switch strings.ToLower(algo) { 32 | case "md5": 33 | h = md5.New() 34 | case "sha1": 35 | h = sha1.New() 36 | case "sha256": 37 | h = sha256.New() 38 | case "sha512": 39 | h = sha512.New() 40 | default: 41 | return "", fmt.Errorf("unsupported hash algorithm: %s", algo) 42 | } 43 | 44 | if _, err := io.Copy(h, reader); err != nil { 45 | return "", err 46 | } 47 | 48 | return hex.EncodeToString(h.Sum(nil)), nil 49 | } 50 | -------------------------------------------------------------------------------- /docs/examples/simple_example.sh: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | 3 | echo "Sample script to demonstrate Gogg's basic functionalities" 4 | sleep 1 5 | 6 | # Find the Gogg executable 7 | GOGG=$(command -v bin/gogg || command -v gogg || command -v ./gogg) 8 | 9 | echo "Show Gogg's top-level commands" 10 | $GOGG --help 11 | sleep 1 12 | 13 | echo "Show the version" 14 | $GOGG version 15 | sleep 1 16 | 17 | #echo "Login to GOG.com" 18 | #$GOGG login 19 | #sleep 1 20 | 21 | echo "Update game catalogue with the data from GOG.com" 22 | $GOGG catalogue refresh 23 | sleep 1 24 | 25 | echo "Search for games with specific terms in their titles" 26 | $GOGG catalogue search "Witcher" 27 | $GOGG catalogue search "mess" 28 | 29 | echo "Download a specific game (\"The Messenger\") with the given options" 30 | $GOGG download 1433116924 ./games --platform=all --lang=en --threads=4 \ 31 | --dlcs=true --extras=false --resume=true --flatten=true --keep-latest=true 32 | 33 | echo "Show the downloaded game files" 34 | tree ./games 35 | 36 | echo "Display hash values of the downloaded game files" 37 | $GOGG file hash ./games --algo=md5 38 | 39 | echo "Calculate the total size of (\"The Messenger\") game files in MB" 40 | $GOGG file size 1433116924 --platform=windows --lang=en --dlcs=true --extras=false 41 | -------------------------------------------------------------------------------- /gui/assets/game-card-svgrepo-com.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 8 | 11 | 12 | -------------------------------------------------------------------------------- /pkg/validation/validation.go: -------------------------------------------------------------------------------- 1 | package validation 2 | 3 | import ( 4 | "fmt" 5 | ) 6 | 7 | const ( 8 | MinThreads = 1 9 | MaxThreads = 20 10 | ) 11 | 12 | func ValidateThreadCount(threads int) error { 13 | if threads < MinThreads || threads > MaxThreads { 14 | return fmt.Errorf("thread count must be between %d and %d, got %d", MinThreads, MaxThreads, threads) 15 | } 16 | return nil 17 | } 18 | 19 | func ValidateGameID(id int) error { 20 | if id <= 0 { 21 | return fmt.Errorf("game ID must be a positive integer, got %d", id) 22 | } 23 | return nil 24 | } 25 | 26 | func ValidateNonEmptyString(fieldName, value string) error { 27 | if value == "" { 28 | return fmt.Errorf("%s cannot be empty", fieldName) 29 | } 30 | return nil 31 | } 32 | 33 | func ValidateLanguageCode(code string, validCodes map[string]string) error { 34 | if _, ok := validCodes[code]; !ok { 35 | return fmt.Errorf("invalid language code: %s", code) 36 | } 37 | return nil 38 | } 39 | 40 | func ValidatePlatform(platform string) error { 41 | validPlatforms := map[string]bool{ 42 | "all": true, 43 | "windows": true, 44 | "mac": true, 45 | "linux": true, 46 | } 47 | if !validPlatforms[platform] { 48 | return fmt.Errorf("invalid platform: %s (must be one of: all, windows, mac, linux)", platform) 49 | } 50 | return nil 51 | } 52 | -------------------------------------------------------------------------------- /.pre-commit-config.yaml: -------------------------------------------------------------------------------- 1 | default_stages: [ pre-push ] 2 | repos: 3 | - repo: https://github.com/pre-commit/pre-commit-hooks 4 | rev: v5.0.0 5 | hooks: 6 | - id: trailing-whitespace 7 | args: [ --markdown-linebreak-ext=md ] 8 | - id: end-of-file-fixer 9 | - id: mixed-line-ending 10 | - id: check-merge-conflict 11 | - id: detect-private-key 12 | - id: check-yaml 13 | - id: check-toml 14 | - id: check-json 15 | - id: check-docstring-first 16 | - id: pretty-format-json 17 | args: [ --autofix, --no-sort-keys ] 18 | - id: check-added-large-files 19 | args: [ '--maxkb=600' ] 20 | 21 | - repo: local 22 | hooks: 23 | - id: format 24 | name: Format the Code 25 | entry: make format 26 | language: system 27 | pass_filenames: false 28 | stages: [ pre-commit ] 29 | 30 | - id: lint 31 | name: Check Code Style 32 | entry: make lint 33 | language: system 34 | pass_filenames: false 35 | 36 | - id: test 37 | name: Run Tests 38 | entry: make test 39 | language: system 40 | pass_filenames: false 41 | 42 | - id: test-integration 43 | name: Run Integration Tests 44 | entry: make test-integration 45 | language: system 46 | pass_filenames: false 47 | -------------------------------------------------------------------------------- /pkg/pool/pool.go: -------------------------------------------------------------------------------- 1 | package pool 2 | 3 | import ( 4 | "context" 5 | "sync" 6 | ) 7 | 8 | // WorkerFunc defines the function signature for a worker that processes an item and may return an error. 9 | type WorkerFunc[T any] func(ctx context.Context, item T) error 10 | 11 | // Run executes a worker pool. It processes a slice of items concurrently. 12 | // It returns a slice containing any errors that occurred during processing. 13 | func Run[T any](ctx context.Context, items []T, numWorkers int, workerFunc WorkerFunc[T]) []error { 14 | var wg sync.WaitGroup 15 | taskChan := make(chan T, numWorkers) 16 | errChan := make(chan error, len(items)) 17 | 18 | for i := 0; i < numWorkers; i++ { 19 | wg.Add(1) 20 | go func() { 21 | defer wg.Done() 22 | for item := range taskChan { 23 | select { 24 | case <-ctx.Done(): 25 | return 26 | default: 27 | if err := workerFunc(ctx, item); err != nil { 28 | errChan <- err 29 | } 30 | } 31 | } 32 | }() 33 | } 34 | 35 | OUT: 36 | for _, item := range items { 37 | select { 38 | case taskChan <- item: 39 | case <-ctx.Done(): 40 | // Stop feeding tasks if the context is cancelled 41 | break OUT 42 | } 43 | } 44 | close(taskChan) 45 | 46 | wg.Wait() 47 | close(errChan) 48 | 49 | var allErrors []error 50 | for err := range errChan { 51 | allErrors = append(allErrors, err) 52 | } 53 | return allErrors 54 | } 55 | -------------------------------------------------------------------------------- /main.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "os" 5 | "os/signal" 6 | "strings" 7 | 8 | "github.com/habedi/gogg/cmd" 9 | "github.com/habedi/gogg/db" 10 | "github.com/rs/zerolog" 11 | "github.com/rs/zerolog/log" 12 | ) 13 | 14 | func main() { 15 | log.Info().Msg("Gogg starting up") 16 | configureLogLevelFromEnv() 17 | 18 | stopChan := setupInterruptListener() 19 | go handleInterrupt(stopChan, 20 | func(msg string) { log.Warn().Msg(msg) }, // avoid log.Fatal to keep control flow explicit 21 | func(code int) { 22 | log.Info().Msg("Shutdown initiated") 23 | // graceful cleanup 24 | db.Shutdown() 25 | log.Info().Msg("Shutdown complete") 26 | os.Exit(code) 27 | }, 28 | ) 29 | execute() 30 | } 31 | 32 | func configureLogLevelFromEnv() { 33 | debugMode := strings.TrimSpace(strings.ToLower(os.Getenv("DEBUG_GOGG"))) 34 | switch debugMode { 35 | case "false", "0", "": 36 | zerolog.SetGlobalLevel(zerolog.Disabled) 37 | default: 38 | zerolog.SetGlobalLevel(zerolog.DebugLevel) 39 | } 40 | } 41 | 42 | func setupInterruptListener() chan os.Signal { 43 | stopChan := make(chan os.Signal, 1) 44 | signal.Notify(stopChan, os.Interrupt) 45 | return stopChan 46 | } 47 | 48 | func handleInterrupt(stopChan chan os.Signal, logFunc func(string), exitFunc func(int)) { 49 | <-stopChan 50 | logFunc("Interrupt signal received. Exiting...") 51 | exitFunc(1) 52 | } 53 | 54 | func execute() { 55 | cmd.Execute() 56 | } 57 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # Python specific 2 | __pycache__/ 3 | *.py[cod] 4 | *$py.class 5 | 6 | # Virtual environments 7 | .env/ 8 | env/ 9 | .venv/ 10 | venv/ 11 | 12 | # Packaging and distribution files 13 | .Python 14 | build/ 15 | dist/ 16 | *.egg-info/ 17 | *.egg 18 | MANIFEST 19 | 20 | # Dependency directories 21 | develop-eggs/ 22 | downloads/ 23 | eggs/ 24 | .eggs/ 25 | lib/ 26 | lib64/ 27 | parts/ 28 | sdist/ 29 | var/ 30 | wheels/ 31 | .installed.cfg 32 | 33 | # Test and coverage reports 34 | htmlcov/ 35 | .tox/ 36 | .coverage 37 | .coverage.* 38 | .cache 39 | nosetests.xml 40 | coverage.xml 41 | *.cover 42 | .hypothesis/ 43 | .pytest_cache/ 44 | 45 | # IDE specific files and directories 46 | .idea/ 47 | *.iml 48 | .vscode/ 49 | 50 | # Jupyter Notebook files 51 | .ipynb_checkpoints 52 | 53 | # Temporary files created by editors and the system and folders to ignore 54 | *.swp 55 | *~ 56 | *.bak 57 | *.tmp 58 | temp/ 59 | tmp/ 60 | 61 | # Database files (SQLite, DuckDB, etc.) 62 | *.duckdb 63 | *.db 64 | *.wal 65 | *.sqlite 66 | 67 | # Dependency lock files (uncomment to ignore) 68 | poetry.lock 69 | uv.lock 70 | 71 | # Golang specific 72 | *.out 73 | *.test 74 | *.prof 75 | *.exe 76 | 77 | # Miscellaneous files and directories to ignore 78 | # Add any additional file patterns a directory names that should be ignored down here 79 | *.log 80 | bin/ 81 | download/ 82 | catalogue.csv 83 | *.snap 84 | gogg/ 85 | games/ 86 | gogg_catalogue* 87 | gogg_full_catalogue* 88 | coverage.txt 89 | .env 90 | *_output.txt 91 | testdata/ 92 | -------------------------------------------------------------------------------- /.gitattributes: -------------------------------------------------------------------------------- 1 | # Normalize all text files to LF line endings 2 | * text=auto eol=lf 3 | 4 | # Go source files 5 | *.go text 6 | *.mod text 7 | *.sum text 8 | 9 | # Markdown and documentation files 10 | *.md text 11 | *.rst text 12 | 13 | # JSON, YAML, and configuration files 14 | *.json text 15 | *.yaml text 16 | *.yml text 17 | *.toml text 18 | 19 | # Shell scripts 20 | *.sh text eol=lf 21 | 22 | # Static files 23 | *.html text 24 | *.css text 25 | *.js text 26 | *.svg text 27 | *.xml text 28 | 29 | # Large assets (use Git LFS) 30 | *.png filter=lfs diff=lfs merge=lfs -text 31 | *.jpg filter=lfs diff=lfs merge=lfs -text 32 | *.jpeg filter=lfs diff=lfs merge=lfs -text 33 | *.gif filter=lfs diff=lfs merge=lfs -text 34 | *.ico filter=lfs diff=lfs merge=lfs -text 35 | *.mp4 filter=lfs diff=lfs merge=lfs -text 36 | *.mov filter=lfs diff=lfs merge=lfs -text 37 | *.zip filter=lfs diff=lfs merge=lfs -text 38 | *.tar filter=lfs diff=lfs merge=lfs -text 39 | *.gz filter=lfs diff=lfs merge=lfs -text 40 | *.tgz filter=lfs diff=lfs merge=lfs -text 41 | 42 | # Font files (binary, tracked via LFS) 43 | *.ttf filter=lfs diff=lfs merge=lfs -text 44 | *.woff filter=lfs diff=lfs merge=lfs -text 45 | *.woff2 filter=lfs diff=lfs merge=lfs -text 46 | 47 | # Build artifacts (binary, optional LFS tracking) 48 | *.exe filter=lfs diff=lfs merge=lfs -text 49 | *.dll filter=lfs diff=lfs merge=lfs -text 50 | *.so filter=lfs diff=lfs merge=lfs -text 51 | *.out filter=lfs diff=lfs merge=lfs -text 52 | *.a filter=lfs diff=lfs merge=lfs -text 53 | *.o filter=lfs diff=lfs merge=lfs -text 54 | -------------------------------------------------------------------------------- /pkg/operations/storage.go: -------------------------------------------------------------------------------- 1 | package operations 2 | 3 | import ( 4 | "encoding/json" 5 | "fmt" 6 | 7 | "github.com/habedi/gogg/client" 8 | "github.com/habedi/gogg/db" 9 | ) 10 | 11 | // EstimationParams contains all parameters for calculating storage size. 12 | type EstimationParams struct { 13 | LanguageCode string 14 | PlatformName string 15 | IncludeExtras bool 16 | IncludeDLCs bool 17 | } 18 | 19 | // EstimateGameSize retrieves a game by ID and calculates its estimated download size. 20 | func EstimateGameSize(gameID int, params EstimationParams) (int64, *client.Game, error) { 21 | game, err := db.GetGameByID(gameID) 22 | if err != nil { 23 | return 0, nil, fmt.Errorf("failed to retrieve game data for ID %d: %w", gameID, err) 24 | } 25 | if game == nil { 26 | return 0, nil, fmt.Errorf("game with ID %d not found in the catalogue", gameID) 27 | } 28 | 29 | var nestedData client.Game 30 | if err := json.Unmarshal([]byte(game.Data), &nestedData); err != nil { 31 | return 0, nil, fmt.Errorf("failed to unmarshal game data for ID %d: %w", gameID, err) 32 | } 33 | 34 | langFullName, ok := client.GameLanguages[params.LanguageCode] 35 | if !ok { 36 | return 0, &nestedData, fmt.Errorf("invalid language code: %s", params.LanguageCode) 37 | } 38 | 39 | totalSizeBytes, err := nestedData.EstimateStorageSize(langFullName, params.PlatformName, params.IncludeExtras, params.IncludeDLCs) 40 | if err != nil { 41 | return 0, &nestedData, fmt.Errorf("failed to calculate storage size: %w", err) 42 | } 43 | 44 | return totalSizeBytes, &nestedData, nil 45 | } 46 | -------------------------------------------------------------------------------- /pkg/hasher/hasher_test.go: -------------------------------------------------------------------------------- 1 | package hasher_test 2 | 3 | import ( 4 | "strings" 5 | "testing" 6 | 7 | "github.com/habedi/gogg/pkg/hasher" 8 | "github.com/stretchr/testify/assert" 9 | ) 10 | 11 | func TestIsValidHashAlgo(t *testing.T) { 12 | assert.True(t, hasher.IsValidHashAlgo("md5")) 13 | assert.True(t, hasher.IsValidHashAlgo("sha1")) 14 | assert.True(t, hasher.IsValidHashAlgo("sha256")) 15 | assert.True(t, hasher.IsValidHashAlgo("sha512")) 16 | assert.True(t, hasher.IsValidHashAlgo("SHA1")) 17 | assert.False(t, hasher.IsValidHashAlgo("md4")) 18 | assert.False(t, hasher.IsValidHashAlgo("")) 19 | } 20 | 21 | func TestGenerateHashFromReader(t *testing.T) { 22 | content := "hello world" 23 | 24 | testCases := []struct { 25 | algo string 26 | expected string 27 | wantErr bool 28 | }{ 29 | {"md5", "5eb63bbbe01eeed093cb22bb8f5acdc3", false}, 30 | {"sha1", "2aae6c35c94fcfb415dbe95f408b9ce91ee846ed", false}, 31 | {"sha256", "b94d27b9934d3e08a52e52d7da7dabfac484efe37a5380ee9088f7ace2efcde9", false}, 32 | {"sha512", "309ecc489c12d6eb4cc40f50c902f2b4d0ed77ee511a7c7a9bcd3ca86d4cd86f989dd35bc5ff499670da34255b45b0cfd830e81f605dcf7dc5542e93ae9cd76f", false}, 33 | {"invalid", "", true}, 34 | } 35 | 36 | for _, tc := range testCases { 37 | t.Run(tc.algo, func(t *testing.T) { 38 | reader := strings.NewReader(content) 39 | hash, err := hasher.GenerateHashFromReader(reader, tc.algo) 40 | if tc.wantErr { 41 | assert.Error(t, err) 42 | } else { 43 | assert.NoError(t, err) 44 | assert.Equal(t, tc.expected, hash) 45 | } 46 | }) 47 | } 48 | } 49 | -------------------------------------------------------------------------------- /.github/workflows/tests.yml: -------------------------------------------------------------------------------- 1 | name: Run Tests 2 | 3 | on: 4 | workflow_dispatch: 5 | pull_request: 6 | branches: 7 | - "main" 8 | paths-ignore: 9 | - '**.md' 10 | - 'docs/**' 11 | push: 12 | tags: 13 | - 'v*' 14 | 15 | permissions: 16 | contents: read 17 | 18 | jobs: 19 | tests: 20 | runs-on: ubuntu-latest 21 | 22 | strategy: 23 | matrix: 24 | go-version: [ "1.21", "1.22", "1.23", "1.24", "1.25" ] 25 | 26 | steps: 27 | - name: Checkout Repository 28 | uses: actions/checkout@v4 29 | 30 | - name: Set up Go ${{ matrix.go-version }} 31 | uses: actions/setup-go@v5 32 | with: 33 | go-version: ${{ matrix.go-version }} 34 | 35 | - name: Cache Go Modules 36 | uses: actions/cache@v4 37 | with: 38 | path: | 39 | ~/.cache/go-build 40 | ~/go/pkg/mod 41 | key: ${{ runner.os }}-go-${{ hashFiles('**/go.sum') }} 42 | restore-keys: | 43 | ${{ runner.os }}-go- 44 | 45 | - name: Install Dependencies 46 | run: | 47 | sudo apt-get update 48 | sudo apt-get install -y make 49 | make install-deps 50 | 51 | - name: Run Tests and Generate Coverage Report 52 | env: 53 | LANG: en_US.UTF-8 54 | LC_ALL: en_US.UTF-8 55 | run: | 56 | make test 57 | make test-integration 58 | 59 | - name: Upload Coverage Reports to Codecov 60 | uses: codecov/codecov-action@v5 61 | with: 62 | token: ${{ secrets.CODECOV_TOKEN }} 63 | continue-on-error: false 64 | -------------------------------------------------------------------------------- /client/helpers_test.go: -------------------------------------------------------------------------------- 1 | package client 2 | 3 | import "testing" 4 | 5 | func TestSanitizePath_EdgeCases(t *testing.T) { 6 | cases := []struct{ in, wantPrefix string }{ 7 | {"", ""}, 8 | {"!!!***???", ""}, 9 | {"This Is A Very Long Name With Spaces And ™® ??? ", "this-is-a-very-long-name"}, 10 | {"/path/to\\file:name|*?\"'", "path-to-file"}, 11 | } 12 | for _, c := range cases { 13 | out := SanitizePath(c.in) 14 | if c.wantPrefix == "" && out != "" { 15 | t.Fatalf("expected empty, got %q for %q", out, c.in) 16 | } 17 | if c.wantPrefix != "" && (len(out) == 0 || out[:len(c.wantPrefix)] != c.wantPrefix) { 18 | t.Fatalf("expected prefix %q, got %q for %q", c.wantPrefix, out, c.in) 19 | } 20 | } 21 | } 22 | 23 | func TestURLHelpers(t *testing.T) { 24 | if !isAbsoluteURL("https://example.com/file") { 25 | t.Fatal("expected absolute") 26 | } 27 | if isAbsoluteURL("/relative/path") { 28 | t.Fatal("expected relative") 29 | } 30 | 31 | abs := buildManualURL("https://e/x") 32 | if abs != "https://e/x" { 33 | t.Fatalf("abs passthrough: %s", abs) 34 | } 35 | rel := buildManualURL("/file") 36 | if rel == "/file" || rel == "" { 37 | t.Fatalf("rel should be prefixed, got %q", rel) 38 | } 39 | } 40 | 41 | func TestResolveNext(t *testing.T) { 42 | base := "https://example.com/user/data/games" 43 | if got := resolveNext(base, ""); got != "" { 44 | t.Fatalf("expected empty, got %q", got) 45 | } 46 | if got := resolveNext(base, "https://a/b"); got != "https://a/b" { 47 | t.Fatalf("abs: %s", got) 48 | } 49 | if got := resolveNext(base, "/next?p=2"); got != "https://example.com/next?p=2" { 50 | t.Fatalf("rel: %s", got) 51 | } 52 | } 53 | -------------------------------------------------------------------------------- /gui/about.go: -------------------------------------------------------------------------------- 1 | package gui 2 | 3 | import ( 4 | "fmt" 5 | "net/url" 6 | "runtime" 7 | 8 | "fyne.io/fyne/v2" 9 | "fyne.io/fyne/v2/canvas" 10 | "fyne.io/fyne/v2/container" 11 | "fyne.io/fyne/v2/widget" 12 | ) 13 | 14 | var goggRepo = "https://github.com/habedi/gogg" 15 | 16 | func ShowAboutUI(version string) fyne.CanvasObject { 17 | platform := fmt.Sprintf("%s/%s", runtime.GOOS, runtime.GOARCH) 18 | goVersion := runtime.Version() 19 | 20 | logoImage := canvas.NewImageFromResource(AppLogo) 21 | logoImage.SetMinSize(fyne.NewSize(128, 128)) 22 | logoImage.FillMode = canvas.ImageFillContain 23 | 24 | // Use the new CopyableLabel for the version 25 | versionLbl := NewCopyableLabel(fmt.Sprintf("Version: %s", version)) 26 | goLbl := widget.NewLabel(fmt.Sprintf("Go version: %s", goVersion)) 27 | platformLbl := widget.NewLabel(fmt.Sprintf("Platform: %s", platform)) 28 | 29 | repoURL, _ := url.Parse(goggRepo) 30 | repoLink := widget.NewHyperlink("Project's Home Page", repoURL) 31 | 32 | info := container.NewVBox( 33 | versionLbl, 34 | goLbl, 35 | platformLbl, 36 | repoLink, 37 | ) 38 | 39 | author := widget.NewLabel("© 2025 Hassan Abedi") 40 | 41 | titleLbl := widget.NewLabelWithStyle("Gogg", fyne.TextAlignCenter, fyne.TextStyle{Bold: true}) 42 | subtitleLbl := widget.NewLabelWithStyle("A Game File Downloader for GOG", 43 | fyne.TextAlignCenter, fyne.TextStyle{Bold: true}) 44 | 45 | card := widget.NewCard( 46 | "", 47 | "", 48 | container.NewVBox( 49 | container.NewCenter(logoImage), 50 | widget.NewSeparator(), 51 | container.NewVBox( 52 | titleLbl, 53 | subtitleLbl, 54 | ), 55 | info, 56 | author, 57 | ), 58 | ) 59 | 60 | return container.NewCenter(card) 61 | } 62 | -------------------------------------------------------------------------------- /cmd/download_invalid_test.go: -------------------------------------------------------------------------------- 1 | package cmd 2 | 3 | import ( 4 | "bytes" 5 | "context" 6 | "io" 7 | "os" 8 | "path/filepath" 9 | "testing" 10 | 11 | "github.com/habedi/gogg/auth" 12 | ) 13 | 14 | func TestDownloadCmd_InvalidID(t *testing.T) { 15 | authService := auth.NewService(nil, nil) 16 | cmd := downloadCmd(authService) 17 | dir := t.TempDir() 18 | cmd.SetArgs([]string{"abc", dir}) 19 | buf := new(bytes.Buffer) 20 | cmd.SetOut(buf) 21 | cmd.SetErr(buf) 22 | // Should not panic or exit; prints error 23 | cmd.Execute() 24 | if got := buf.String(); got == "" { 25 | t.Fatalf("expected error output, got empty") 26 | } 27 | } 28 | 29 | // captureStdout runs f while capturing os.Stdout and returns captured output. 30 | func captureStdout2(f func()) string { 31 | old := os.Stdout 32 | r, w, _ := os.Pipe() 33 | os.Stdout = w 34 | f() 35 | _ = w.Close() 36 | os.Stdout = old 37 | out, _ := io.ReadAll(r) 38 | _ = r.Close() 39 | return string(out) 40 | } 41 | 42 | func TestExecuteDownload_InvalidLanguagePrintsList(t *testing.T) { 43 | // Use invalid language code to trigger early return and listing of supported languages 44 | out := captureStdout2(func() { 45 | executeDownload(context.Background(), nil, 1, filepath.Join(t.TempDir(), "dl"), "xx", "windows", true, true, true, true, false, false, false, 2) 46 | }) 47 | if out == "" { 48 | t.Fatalf("expected output for invalid language") 49 | } 50 | if !containsAll(out, []string{"Invalid language code", "'en'"}) { 51 | t.Fatalf("unexpected output: %s", out) 52 | } 53 | } 54 | 55 | func containsAll(s string, subs []string) bool { 56 | for _, sub := range subs { 57 | if !bytes.Contains([]byte(s), []byte(sub)) { 58 | return false 59 | } 60 | } 61 | return true 62 | } 63 | -------------------------------------------------------------------------------- /db/repository_test.go: -------------------------------------------------------------------------------- 1 | package db_test 2 | 3 | import ( 4 | "context" 5 | "path/filepath" 6 | "testing" 7 | 8 | "github.com/habedi/gogg/db" 9 | "github.com/stretchr/testify/require" 10 | ) 11 | 12 | func TestGameRepositoryBasicCRUD(t *testing.T) { 13 | temp := t.TempDir() 14 | db.Path = filepath.Join(temp, "games.db") 15 | require.NoError(t, db.InitDB()) 16 | t.Cleanup(func() { _ = db.CloseDB() }) 17 | 18 | repo := db.NewGameRepository(db.GetDB()) 19 | ctx := context.Background() 20 | 21 | // Put 22 | require.NoError(t, repo.Put(ctx, db.Game{ID: 1, Title: "Test Game", Data: "{}"})) 23 | 24 | // GetByID 25 | g, err := repo.GetByID(ctx, 1) 26 | require.NoError(t, err) 27 | require.NotNil(t, g) 28 | 29 | // List 30 | all, err := repo.List(ctx) 31 | require.NoError(t, err) 32 | require.Len(t, all, 1) 33 | 34 | // Search 35 | res, err := repo.SearchByTitle(ctx, "Test") 36 | require.NoError(t, err) 37 | require.Len(t, res, 1) 38 | 39 | // Clear 40 | require.NoError(t, repo.Clear(ctx)) 41 | all, err = repo.List(ctx) 42 | require.NoError(t, err) 43 | require.Len(t, all, 0) 44 | } 45 | 46 | func TestTokenRepositoryUpsertAndGet(t *testing.T) { 47 | temp := t.TempDir() 48 | db.Path = filepath.Join(temp, "games.db") 49 | require.NoError(t, db.InitDB()) 50 | t.Cleanup(func() { _ = db.CloseDB() }) 51 | 52 | repo := db.NewTokenRepository(db.GetDB()) 53 | ctx := context.Background() 54 | 55 | // Initially empty 56 | tok, err := repo.Get(ctx) 57 | require.NoError(t, err) 58 | require.Nil(t, tok) 59 | 60 | // Upsert 61 | require.NoError(t, repo.Upsert(ctx, &db.Token{AccessToken: "a", RefreshToken: "r", ExpiresAt: "soon"})) 62 | 63 | // Retrieve 64 | tok, err = repo.Get(ctx) 65 | require.NoError(t, err) 66 | require.NotNil(t, tok) 67 | require.Equal(t, "a", tok.AccessToken) 68 | } 69 | -------------------------------------------------------------------------------- /client/download_integration_test.go: -------------------------------------------------------------------------------- 1 | package client 2 | 3 | import ( 4 | "context" 5 | "net/http" 6 | "net/http/httptest" 7 | "os" 8 | "path/filepath" 9 | "testing" 10 | "time" 11 | ) 12 | 13 | // TestPartialCleanupOnCancel make sure partial files are removed when not resuming on ctx cancel. 14 | func TestPartialCleanupOnCancel(t *testing.T) { 15 | // Fake server that serves a large content slowly 16 | srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { 17 | w.Header().Set("Content-Length", "1048576") // 1MB 18 | // Send bytes slowly 19 | buf := make([]byte, 1024) 20 | for i := 0; i < 1024; i++ { 21 | select { 22 | case <-r.Context().Done(): 23 | return 24 | default: 25 | } 26 | if _, err := w.Write(buf); err != nil { 27 | return 28 | } 29 | time.Sleep(2 * time.Millisecond) 30 | } 31 | })) 32 | defer srv.Close() 33 | 34 | tmp := t.TempDir() 35 | g := Game{Title: "Test Game", Downloads: []Downloadable{{Language: "English", Platforms: Platform{Windows: []PlatformFile{{Name: "file.bin", Size: "1 MB", ManualURL: strPtr(srv.URL)}}}}}} 36 | ctx, cancel := context.WithCancel(context.Background()) 37 | // Cancel shortly after starting 38 | go func() { time.Sleep(10 * time.Millisecond); cancel() }() 39 | err := DownloadGameFiles(ctx, "tok", g, tmp, "English", "windows", false, false, true, true, false, false, 1, os.Stdout) 40 | if err == nil { 41 | // Should cancel 42 | t.Fatal("expected cancellation error") 43 | } 44 | // Partial file should not exist due to resume=false & cleanup logic 45 | p := filepath.Join(tmp, SanitizePath(g.Title), "windows", "file.bin") 46 | if _, statErr := os.Stat(p); statErr == nil { 47 | t.Fatalf("expected partial file to be removed, found: %s", p) 48 | } 49 | } 50 | 51 | func strPtr(s string) *string { return &s } 52 | -------------------------------------------------------------------------------- /CONTRIBUTING.md: -------------------------------------------------------------------------------- 1 | # Contributing to Gogg 2 | 3 | Thank you for considering contributing to developing Gogg! 4 | Your contributions help improve the project and make it more useful for everyone. 5 | 6 | ## How to Contribute 7 | 8 | ### Reporting Bugs 9 | 10 | 1. Open an issue on the [issue tracker](https://github.com/habedi/gogg/issues). 11 | 2. Include information like steps to reproduce, expected/actual behaviour, and relevant logs or screenshots. 12 | 13 | ### Suggesting Features 14 | 15 | 1. Open an issue on the [issue tracker](https://github.com/habedi/gogg/issues). 16 | 2. Write a little about the feature, its purpose, and potential implementation ideas. 17 | 18 | ## Submitting Pull Requests 19 | 20 | - Ensure all tests pass before submitting a pull request. 21 | - Write a clear description of the changes you made and the reasons behind them. 22 | 23 | > [!IMPORTANT] 24 | > It's assumed that by submitting a pull request, you agree to license your contributions under the project's license. 25 | 26 | ## Development Workflow 27 | 28 | ### Prerequisites 29 | 30 | Install system dependencies (Go and GNU Make). 31 | 32 | ```shell 33 | sudo apt-get install -y golang-go make 34 | ``` 35 | 36 | - Use the `make install-deps` command to install the development dependencies. 37 | - Use the `make setup-hooks` command to install the project's Git hooks. 38 | 39 | ### Code Style 40 | 41 | - Use the `make format` command to format the code. 42 | 43 | ### Running Tests 44 | 45 | - Use the `make test` command to run the tests. 46 | 47 | ### Running Linters 48 | 49 | - Use the `make lint` command to run the linters. 50 | 51 | ### See Available Commands 52 | 53 | - Run `make help` to see all available commands for managing different tasks. 54 | 55 | ## Code of Conduct 56 | 57 | We adhere to the project's [Code of Conduct](CODE_OF_CONDUCT.md). 58 | -------------------------------------------------------------------------------- /Dockerfile: -------------------------------------------------------------------------------- 1 | # ---- Builder Stage ---- 2 | FROM golang:1.24-bookworm as builder 3 | 4 | # Install build dependencies 5 | RUN apt-get update && \ 6 | apt-get install -y --no-install-recommends \ 7 | build-essential \ 8 | pkg-config \ 9 | libgl1-mesa-dev \ 10 | libx11-dev \ 11 | libxcursor-dev \ 12 | libxrandr-dev \ 13 | libxinerama-dev \ 14 | libxi-dev \ 15 | libxxf86vm-dev \ 16 | libasound2-dev \ 17 | ca-certificates \ 18 | && rm -rf /var/lib/apt/lists/* 19 | 20 | WORKDIR /app 21 | 22 | # Go module download 23 | COPY go.mod go.sum ./ 24 | RUN go mod download 25 | 26 | # Copy source 27 | COPY . . 28 | 29 | # Build Gogg 30 | RUN go build -o bin/gogg . 31 | 32 | # ---- Final Stage ---- 33 | FROM debian:bookworm-slim 34 | 35 | RUN apt-get update && \ 36 | apt-get install -y --no-install-recommends \ 37 | libgl1 \ 38 | libx11-6 \ 39 | libxcursor1 \ 40 | libxrandr2 \ 41 | libxinerama1 \ 42 | libxi6 \ 43 | libasound2 \ 44 | libxxf86vm1 \ 45 | xvfb \ 46 | ca-certificates \ 47 | htop nano duf ncdu \ 48 | && rm -rf /var/lib/apt/lists/* \ 49 | && apt-get autoremove -y \ 50 | && apt-get clean 51 | 52 | # Set up directories 53 | COPY --from=builder /app/bin/gogg /usr/local/bin/gogg 54 | COPY scripts/docker_entrypoint.sh /usr/local/bin/docker_entrypoint.sh 55 | RUN chmod +x /usr/local/bin/docker_entrypoint.sh 56 | 57 | # Create a non-root user and group 58 | RUN addgroup --system gogg && adduser --system --ingroup gogg gogg 59 | RUN mkdir -p /config /downloads && chown -R gogg:gogg /config /downloads 60 | 61 | # Set user and volume 62 | USER gogg 63 | VOLUME /config 64 | VOLUME /downloads 65 | ENV GOGG_HOME=/config 66 | 67 | ENTRYPOINT ["/usr/local/bin/docker_entrypoint.sh"] 68 | -------------------------------------------------------------------------------- /db/token.go: -------------------------------------------------------------------------------- 1 | package db 2 | 3 | import ( 4 | "errors" 5 | "fmt" 6 | 7 | "github.com/rs/zerolog/log" 8 | "gorm.io/gorm" 9 | "gorm.io/gorm/clause" 10 | ) 11 | 12 | // Token represents the persisted auth tokens. Currently we enforce a single-row table 13 | // by always upserting record with ID=1. A unique index on ID is implicit via primary key. 14 | // Future multi-user support could drop this constraint and add a user scoping column. 15 | type Token struct { 16 | ID uint `gorm:"primaryKey;uniqueIndex"` 17 | AccessToken string `json:"access_token,omitempty"` 18 | RefreshToken string `json:"refresh_token,omitempty"` 19 | ExpiresAt string `json:"expires_at,omitempty"` 20 | } 21 | 22 | // GetTokenRecord retrieves the token record from the database. 23 | // Deprecated: Use TokenRepository.Get with context for better cancellation support. 24 | func GetTokenRecord() (*Token, error) { 25 | if Db == nil { 26 | return nil, fmt.Errorf("database connection is not initialized") 27 | } 28 | var token Token 29 | if err := Db.First(&token).Error; err != nil { 30 | if errors.Is(err, gorm.ErrRecordNotFound) { 31 | return nil, nil 32 | } 33 | log.Error().Err(err).Msg("Failed to retrieve token data") 34 | return nil, err 35 | } 36 | return &token, nil 37 | } 38 | 39 | // UpsertTokenRecord inserts or updates the token record in the database. 40 | // Deprecated: Use TokenRepository.Upsert with context for better cancellation support. 41 | func UpsertTokenRecord(token *Token) error { 42 | if Db == nil { 43 | return fmt.Errorf("database connection is not initialized") 44 | } 45 | token.ID = 1 46 | if err := Db.Clauses(clause.OnConflict{ 47 | Columns: []clause.Column{{Name: "id"}}, 48 | DoUpdates: clause.AssignmentColumns([]string{"access_token", "refresh_token", "expires_at"}), 49 | }).Create(token).Error; err != nil { 50 | log.Error().Err(err).Msgf("Failed to upsert token") 51 | return err 52 | } 53 | 54 | log.Info().Msgf("Token upserted successfully") 55 | return nil 56 | } 57 | -------------------------------------------------------------------------------- /pkg/pool/pool_test.go: -------------------------------------------------------------------------------- 1 | package pool_test 2 | 3 | import ( 4 | "context" 5 | "errors" 6 | "runtime" 7 | "sync/atomic" 8 | "testing" 9 | "time" 10 | 11 | "github.com/habedi/gogg/pkg/pool" 12 | "github.com/stretchr/testify/assert" 13 | "github.com/stretchr/testify/require" 14 | ) 15 | 16 | func TestPool_Run(t *testing.T) { 17 | items := []int{1, 2, 3, 4, 5, 6, 7, 8, 9, 10} 18 | var count atomic.Int64 19 | 20 | workerFunc := func(ctx context.Context, item int) error { 21 | count.Add(1) 22 | time.Sleep(10 * time.Millisecond) // Simulate work 23 | return nil 24 | } 25 | 26 | errors := pool.Run(context.Background(), items, 3, workerFunc) 27 | 28 | assert.Empty(t, errors) 29 | assert.Equal(t, int64(len(items)), count.Load()) 30 | } 31 | 32 | func TestPool_CollectsErrors(t *testing.T) { 33 | items := []int{1, 2, 3, 4} 34 | expectedErr := errors.New("worker failed") 35 | 36 | workerFunc := func(ctx context.Context, item int) error { 37 | if item%2 == 0 { 38 | return expectedErr 39 | } 40 | return nil 41 | } 42 | 43 | errs := pool.Run(context.Background(), items, 2, workerFunc) 44 | require.Len(t, errs, 2) 45 | assert.ErrorIs(t, errs[0], expectedErr) 46 | assert.ErrorIs(t, errs[1], expectedErr) 47 | } 48 | 49 | func TestPool_ContextCancellation(t *testing.T) { 50 | items := make([]int, 100) 51 | for i := range items { 52 | items[i] = i 53 | } 54 | var processedCount atomic.Int64 55 | 56 | ctx, cancel := context.WithCancel(context.Background()) 57 | 58 | workerFunc := func(ctx context.Context, item int) error { 59 | processedCount.Add(1) 60 | // Cancel the context after the first item is processed 61 | if item == 0 { 62 | cancel() 63 | } 64 | // A realistic worker would check the context 65 | select { 66 | case <-ctx.Done(): 67 | return ctx.Err() 68 | case <-time.After(5 * time.Millisecond): 69 | } 70 | return nil 71 | } 72 | 73 | pool.Run(ctx, items, runtime.NumCPU(), workerFunc) 74 | 75 | // Due to the nature of concurrency, we can't assert an exact number. 76 | // But it should be much less than the total number of items. 77 | assert.Less(t, processedCount.Load(), int64(len(items)), "Pool should stop processing after context is cancelled") 78 | } 79 | -------------------------------------------------------------------------------- /docs/figures/package_dependency_graph.dot: -------------------------------------------------------------------------------- 1 | digraph package_dependency_graph { 2 | // Global layout settings for a less packed appearance 3 | rankdir = LR; // Left to Right layout 4 | splines = curved; 5 | ranksep = 1.8; // Increased distance between columns 6 | nodesep = 1.0; // Increased distance between nodes 7 | compound = true; // Improves layout with clusters 8 | 9 | graph [label = "Gogg Architecture", labelloc = t, fontsize = 16, fontname = "Arial"]; 10 | node [shape = box, style = "rounded,filled", fontname = "Arial", margin = "0.2,0.1"]; 11 | 12 | subgraph cluster_drivers { 13 | label = "UI Layer"; 14 | style = "rounded,filled"; 15 | color = "#e6f2fa"; 16 | main [label = "main", fillcolor = "#a7c7e7"]; 17 | cmd [label = "cmd", fillcolor = "#a7c7e7"]; 18 | gui [label = "gui", fillcolor = "#a7c7e7"]; 19 | } 20 | 21 | subgraph cluster_core { 22 | label = "Service Layer"; 23 | style = "rounded,filled"; 24 | color = "#eaf7ec"; 25 | auth [label = "auth", fillcolor = "#d4edda"]; 26 | client [label = "client", fillcolor = "#d4edda"]; 27 | pkg_operations [label = "pkg/operations", fillcolor = "#d4edda"]; 28 | } 29 | 30 | subgraph cluster_infra { 31 | label = "Adapter/Utility Layer"; 32 | style = "rounded,filled"; 33 | color = "#fef8e4"; 34 | db [label = "db", fillcolor = "#fff3cd"]; 35 | pkg_hasher [label = "pkg/hasher", fillcolor = "#fff3cd"]; 36 | pkg_pool [label = "pkg/pool", fillcolor = "#fff3cd"]; 37 | } 38 | 39 | subgraph cluster_external { 40 | label = "External Systems"; 41 | style = "rounded,filled"; 42 | color = "#eeeeee"; 43 | gog_api [label = "GOG API", shape = cylinder, fillcolor = whitesmoke]; 44 | db_file [label = "Database File", shape = cylinder, fillcolor = whitesmoke]; 45 | user_fs [label = "User Filesystem", shape = cylinder, fillcolor = whitesmoke]; 46 | } 47 | 48 | // -- Dependencies -- 49 | 50 | // Drivers initiate actions 51 | main -> cmd; 52 | cmd -> {gui; pkg_operations; client; auth}; 53 | gui -> {pkg_operations; client; auth}; 54 | 55 | // Core orchestrates logic 56 | pkg_operations -> {client; db; pkg_hasher; user_fs}; 57 | client -> {db; pkg_pool; gog_api; user_fs}; 58 | auth -> {client; db}; 59 | 60 | // Infrastructure provides low-level services 61 | db -> db_file; 62 | } 63 | -------------------------------------------------------------------------------- /main_integration_test.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "os" 5 | "os/exec" 6 | "path/filepath" 7 | "runtime" 8 | "testing" 9 | "time" 10 | ) 11 | 12 | func buildTestBinary(t *testing.T) string { 13 | binName := "gogg_it_bin" 14 | if runtime.GOOS == "windows" { 15 | binName += ".exe" 16 | } 17 | bin := filepath.Join(t.TempDir(), binName) 18 | cmd := exec.Command("go", "build", "-o", bin, ".") 19 | cmd.Env = os.Environ() 20 | out, err := cmd.CombinedOutput() 21 | if err != nil { 22 | t.Fatalf("failed to build binary: %v\n%s", err, string(out)) 23 | } 24 | return bin 25 | } 26 | 27 | // TestTimeoutContext make sure a short timeout cancels a long-running catalogue refresh (simulated by sleep via env flag). 28 | func TestTimeoutContext(t *testing.T) { 29 | bin := buildTestBinary(t) 30 | start := time.Now() 31 | cmd := exec.Command(bin, "catalogue", "list", "-T", "500ms") 32 | cmd.Env = os.Environ() 33 | err := cmd.Run() 34 | elapsed := time.Since(start) 35 | if err != nil { 36 | if _, ok := err.(*exec.ExitError); !ok { 37 | t.Fatalf("unexpected error type: %v", err) 38 | } else if elapsed > time.Second*2 { 39 | t.Fatalf("timeout test exceeded expected duration: %v", elapsed) 40 | } 41 | } 42 | if elapsed > time.Second*2 { 43 | t.Fatalf("list command took too long with timeout flag: %v", elapsed) 44 | } 45 | } 46 | 47 | // TestGracefulInterrupt runs the binary and sends SIGINT, expecting it to exit promptly. 48 | func TestGracefulInterrupt(t *testing.T) { 49 | if runtime.GOOS == "windows" { 50 | t.Skip("Skipping interrupt test on Windows - os.Interrupt signal not supported") 51 | } 52 | 53 | bin := buildTestBinary(t) 54 | cmd := exec.Command(bin, "catalogue", "list") 55 | if err := cmd.Start(); err != nil { 56 | t.Fatalf("failed to start binary: %v", err) 57 | } 58 | // Allow startup 59 | time.Sleep(200 * time.Millisecond) 60 | if err := cmd.Process.Signal(os.Interrupt); err != nil { 61 | t.Fatalf("failed to send interrupt: %v", err) 62 | } 63 | done := make(chan error, 1) 64 | go func() { done <- cmd.Wait() }() 65 | select { 66 | case err := <-done: 67 | // Accept any exit code; main uses exit code 1 on interrupt. 68 | _ = err 69 | case <-time.After(3 * time.Second): 70 | t.Fatal("process did not exit within 3s after SIGINT") 71 | } 72 | } 73 | -------------------------------------------------------------------------------- /db/db_test.go: -------------------------------------------------------------------------------- 1 | package db_test 2 | 3 | import ( 4 | "os" 5 | "path/filepath" 6 | "testing" 7 | 8 | "github.com/habedi/gogg/db" 9 | "github.com/stretchr/testify/assert" 10 | "github.com/stretchr/testify/require" 11 | ) 12 | 13 | func TestDBPathSelection(t *testing.T) { 14 | originalPath := db.Path 15 | t.Cleanup(func() { 16 | db.Path = originalPath 17 | }) 18 | 19 | t.Run("uses GOGG_HOME when set", func(t *testing.T) { 20 | tempDir := t.TempDir() 21 | t.Setenv("GOGG_HOME", tempDir) 22 | t.Setenv("XDG_DATA_HOME", "should_be_ignored") 23 | 24 | db.ConfigurePath() 25 | 26 | expected := filepath.Join(tempDir, "games.db") 27 | assert.Equal(t, expected, db.Path) 28 | }) 29 | 30 | t.Run("uses XDG_DATA_HOME when GOGG_HOME is not set", func(t *testing.T) { 31 | tempDir := t.TempDir() 32 | t.Setenv("GOGG_HOME", "") 33 | t.Setenv("XDG_DATA_HOME", tempDir) 34 | 35 | db.ConfigurePath() 36 | 37 | expected := filepath.Join(tempDir, "gogg", "games.db") 38 | assert.Equal(t, expected, db.Path) 39 | }) 40 | 41 | t.Run("uses default home directory as a fallback", func(t *testing.T) { 42 | t.Setenv("GOGG_HOME", "") 43 | t.Setenv("XDG_DATA_HOME", "") 44 | 45 | db.ConfigurePath() 46 | 47 | homeDir, err := os.UserHomeDir() 48 | require.NoError(t, err) 49 | expected := filepath.Join(homeDir, ".gogg", "games.db") 50 | assert.Equal(t, expected, db.Path) 51 | }) 52 | } 53 | 54 | func TestInitDB(t *testing.T) { 55 | tempDir := t.TempDir() 56 | db.Path = filepath.Join(tempDir, ".gogg", "games.db") 57 | 58 | err := db.InitDB() 59 | require.NoError(t, err, "InitDB should not return an error") 60 | 61 | _, statErr := os.Stat(db.Path) 62 | assert.NoError(t, statErr, "Database file should exist") 63 | 64 | err = db.CloseDB() 65 | assert.NoError(t, err, "CloseDB should not return an error") 66 | } 67 | 68 | func TestCloseDB(t *testing.T) { 69 | t.Run("it does nothing if DB is not initialized", func(t *testing.T) { 70 | db.Db = nil // Ensure DB is nil 71 | err := db.CloseDB() 72 | assert.NoError(t, err) 73 | }) 74 | 75 | t.Run("it closes an open DB connection", func(t *testing.T) { 76 | db.Path = filepath.Join(t.TempDir(), "test.db") 77 | require.NoError(t, db.InitDB()) 78 | 79 | err := db.CloseDB() 80 | assert.NoError(t, err, "CloseDB should not return an error") 81 | }) 82 | } 83 | -------------------------------------------------------------------------------- /cmd/login.go: -------------------------------------------------------------------------------- 1 | package cmd 2 | 3 | import ( 4 | "bufio" 5 | "fmt" 6 | "os" 7 | "strings" 8 | 9 | "github.com/habedi/gogg/client" 10 | "github.com/habedi/gogg/pkg/clierr" 11 | "github.com/spf13/cobra" 12 | "golang.org/x/term" 13 | ) 14 | 15 | func loginCmd(gogClient *client.GogClient) *cobra.Command { 16 | var gogUsername, gogPassword string 17 | var headless bool 18 | 19 | cmd := &cobra.Command{ 20 | Use: "login", 21 | Short: "Login to GOG.com", 22 | Long: "Login to GOG.com using your username and password", 23 | Run: func(cmd *cobra.Command, args []string) { 24 | cmd.Println("Please enter your GOG username and password.") 25 | gogUsername = promptForInput("GOG username: ") 26 | gogPassword = promptForPassword("GOG password: ") 27 | 28 | if validateCredentials(gogUsername, gogPassword) { 29 | if err := gogClient.Login(client.GOGLoginURL, gogUsername, gogPassword, headless); err != nil { 30 | cmd.PrintErrln(clierr.New(clierr.Internal, "Failed to login to GOG.com", err).Message) 31 | if strings.Contains(err.Error(), "executable found in PATH") { 32 | cmd.PrintErrln("Hint: Make sure Google Chrome or Chromium is installed and accessible in your system's PATH.") 33 | } 34 | } else { 35 | cmd.Println("Login was successful.") 36 | } 37 | } else { 38 | cmd.PrintErrln(clierr.New(clierr.Validation, "Username and password cannot be empty", nil).Message) 39 | } 40 | }, 41 | } 42 | 43 | cmd.Flags().BoolVarP(&headless, "headless", "n", true, "Login in headless mode without showing the browser window? [true, false]") 44 | 45 | return cmd 46 | } 47 | 48 | func promptForInput(prompt string) string { 49 | reader := bufio.NewReader(os.Stdin) 50 | fmt.Print(prompt) 51 | input, err := reader.ReadString('\n') 52 | if err != nil { 53 | fmt.Println("Error: Failed to read input.") 54 | os.Exit(1) 55 | } 56 | return strings.TrimSpace(input) 57 | } 58 | 59 | func promptForPassword(prompt string) string { 60 | fmt.Print(prompt) 61 | password, err := term.ReadPassword(int(os.Stdin.Fd())) 62 | if err != nil { 63 | fmt.Println("Error: Failed to read password.") 64 | os.Exit(1) 65 | } 66 | fmt.Println() 67 | return strings.TrimSpace(string(password)) 68 | } 69 | 70 | func validateCredentials(username, password string) bool { 71 | return username != "" && password != "" 72 | } 73 | -------------------------------------------------------------------------------- /docs/examples/download_all_games.sh: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | 3 | # Colors 4 | RED='\033[0;31m' 5 | GREEN='\033[0;32m' 6 | YELLOW='\033[1;33m' 7 | NC='\033[0m' # No Color 8 | 9 | echo -e "${GREEN}=========================== Download All Games (Linux Version) =============================${NC}" 10 | echo -e "${GREEN}The code in this script downloads all games owned by the user on GOG.com with given options.${NC}" 11 | echo -e "${GREEN}============================================================================================${NC}" 12 | 13 | DEBUG_MODE=1 # Debug mode enabled 14 | GOGG=$(command -v bin/gogg || command -v gogg) 15 | 16 | # Download options 17 | LANG=en # Language English 18 | PLATFORM=windows # Platform Windows 19 | INCLUDE_DLC=true 20 | INCLUDE_EXTRA_CONTENT=true 21 | RESUME_DOWNLOAD=true 22 | NUM_THREADS=4 # Number of worker threads for downloading 23 | FLATTEN=true # Flatten the directory structure 24 | OUTPUT_DIR=./games # Output directory 25 | 26 | # Function to clean up the CSV file 27 | cleanup() { 28 | if [ -n "$latest_csv" ]; then 29 | rm -f "$latest_csv" 30 | if [ $? -eq 0 ]; then 31 | echo -e "${RED}Cleanup: removed $latest_csv${NC}" 32 | fi 33 | fi 34 | } 35 | 36 | # Trap SIGINT (Ctrl+C) and call cleanup 37 | trap cleanup SIGINT 38 | 39 | # Update game catalogue and export it to a CSV file 40 | $GOGG catalogue refresh 41 | $GOGG catalogue export ./ --format=csv 42 | 43 | # Find the newest catalogue file 44 | latest_csv=$(ls -t gogg_catalogue_*.csv 2>/dev/null | head -n 1) 45 | 46 | # Check if the catalogue file exists 47 | if [ -z "$latest_csv" ]; then 48 | echo -e "${RED}No CSV file found.${NC}" 49 | exit 1 50 | fi 51 | 52 | echo -e "${GREEN}Using catalogue file: $latest_csv${NC}" 53 | 54 | # Download each game listed in catalogue file, skipping the first line 55 | tail -n +2 "$latest_csv" | while IFS=, read -r game_id game_title; do 56 | echo -e "${YELLOW}Game ID: $game_id, Title: $game_title${NC}" 57 | DEBUG_GOGG=$DEBUG_MODE $GOGG download "$game_id" $OUTPUT_DIR --platform=$PLATFORM --lang=$LANG \ 58 | --dlcs=$INCLUDE_DLC --extras=$INCLUDE_EXTRA_CONTENT --resume=$RESUME_DOWNLOAD --threads=$NUM_THREADS \ 59 | --flatten=$FLATTEN --keep-latest=true 60 | sleep 1 61 | #break # Comment out this line to download all games 62 | done 63 | 64 | # Clean up 65 | cleanup 66 | -------------------------------------------------------------------------------- /client/games_test.go: -------------------------------------------------------------------------------- 1 | package client_test 2 | 3 | import ( 4 | "context" 5 | "net/http" 6 | "net/http/httptest" 7 | "testing" 8 | 9 | "github.com/habedi/gogg/client" 10 | "github.com/stretchr/testify/assert" 11 | "github.com/stretchr/testify/require" 12 | ) 13 | 14 | func TestFetchGameData_ReturnsGameData(t *testing.T) { 15 | server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { 16 | w.WriteHeader(http.StatusOK) 17 | w.Write([]byte(`{"title": "Test Game"}`)) 18 | })) 19 | defer server.Close() 20 | 21 | game, body, err := client.FetchGameData(context.Background(), "valid_token", server.URL) 22 | require.NoError(t, err) 23 | assert.Equal(t, "Test Game", game.Title) 24 | assert.Equal(t, `{"title": "Test Game"}`, body) 25 | } 26 | 27 | func TestFetchGameData_ReturnsErrorOnInvalidToken(t *testing.T) { 28 | server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { 29 | w.WriteHeader(http.StatusUnauthorized) 30 | })) 31 | defer server.Close() 32 | 33 | _, _, err := client.FetchGameData(context.Background(), "invalid_token", server.URL) 34 | assert.Error(t, err) 35 | } 36 | 37 | func TestFetchIdOfOwnedGames_ReturnsOwnedGames(t *testing.T) { 38 | server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { 39 | w.WriteHeader(http.StatusOK) 40 | w.Write([]byte(`{"owned": [1, 2, 3]}`)) 41 | })) 42 | defer server.Close() 43 | 44 | ids, err := client.FetchIdOfOwnedGames(context.Background(), "valid_token", server.URL) 45 | require.NoError(t, err) 46 | assert.Equal(t, []int{1, 2, 3}, ids) 47 | } 48 | 49 | func TestFetchIdOfOwnedGames_ReturnsErrorOnInvalidToken(t *testing.T) { 50 | server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { 51 | w.WriteHeader(http.StatusUnauthorized) 52 | })) 53 | defer server.Close() 54 | 55 | _, err := client.FetchIdOfOwnedGames(context.Background(), "invalid_token", server.URL) 56 | assert.Error(t, err) 57 | } 58 | 59 | func TestFetchIdOfOwnedGames_ReturnsErrorOnInvalidResponse(t *testing.T) { 60 | server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { 61 | w.WriteHeader(http.StatusUnauthorized) 62 | w.Write([]byte(`{"invalid": "response"}`)) 63 | })) 64 | defer server.Close() 65 | 66 | _, err := client.FetchIdOfOwnedGames(context.Background(), "valid_token", server.URL) 67 | assert.Error(t, err) 68 | } 69 | -------------------------------------------------------------------------------- /gui/window.go: -------------------------------------------------------------------------------- 1 | package gui 2 | 3 | import ( 4 | "fyne.io/fyne/v2" 5 | "fyne.io/fyne/v2/app" 6 | "fyne.io/fyne/v2/container" 7 | "fyne.io/fyne/v2/theme" 8 | "fyne.io/fyne/v2/widget" 9 | "github.com/habedi/gogg/auth" 10 | ) 11 | 12 | func Run(version string, authService *auth.Service) { 13 | myApp := app.NewWithID("com.github.habedi.gogg") 14 | myApp.SetIcon(AppLogo) 15 | 16 | myApp.Settings().SetTheme(CreateThemeFromPreferences()) 17 | 18 | myWindow := myApp.NewWindow("GOGG GUI") 19 | dm := NewDownloadManager() 20 | prefs := myApp.Preferences() 21 | 22 | width := prefs.FloatWithFallback("windowWidth", 960) 23 | height := prefs.FloatWithFallback("windowHeight", 640) 24 | myWindow.Resize(fyne.NewSize(float32(width), float32(height))) 25 | 26 | myWindow.SetOnClosed(func() { 27 | size := myWindow.Canvas().Size() 28 | prefs.SetFloat("windowWidth", float64(size.Width)) 29 | prefs.SetFloat("windowHeight", float64(size.Height)) 30 | }) 31 | 32 | library := LibraryTabUI(myWindow, authService, dm) 33 | 34 | mainTabs := container.NewAppTabs( 35 | container.NewTabItemWithIcon("Catalogue", theme.ListIcon(), library.content), 36 | container.NewTabItemWithIcon("Downloads", theme.DownloadIcon(), DownloadsTabUI(dm)), 37 | container.NewTabItemWithIcon("File Ops", theme.DocumentIcon(), FileTabUI(myWindow)), 38 | container.NewTabItemWithIcon("Settings", theme.SettingsIcon(), SettingsTabUI(myWindow)), 39 | container.NewTabItemWithIcon("About", theme.HelpIcon(), ShowAboutUI(version)), 40 | ) 41 | 42 | mainTabs.OnSelected = func(tab *container.TabItem) { 43 | if tab.Text == "Catalogue" { 44 | myWindow.Canvas().Focus(library.searchEntry) 45 | } 46 | } 47 | 48 | mainTabs.SetTabLocation(container.TabLocationTop) 49 | 50 | myWindow.SetContent(mainTabs) 51 | mainTabs.SelectIndex(0) // Programmatically select the first tab to trigger OnSelected. 52 | 53 | myWindow.ShowAndRun() 54 | } 55 | 56 | func FileTabUI(win fyne.Window) fyne.CanvasObject { 57 | head := widget.NewLabelWithStyle("File Operations", fyne.TextAlignCenter, fyne.TextStyle{Bold: true}) 58 | hashTab := HashUI(win) 59 | sizeTab := SizeUI(win) 60 | fileTabs := container.NewAppTabs( 61 | container.NewTabItemWithIcon("File Hashes", theme.ContentAddIcon(), hashTab), 62 | container.NewTabItemWithIcon("Storage Size", theme.ViewFullScreenIcon(), sizeTab), 63 | ) 64 | fileTabs.SetTabLocation(container.TabLocationTop) 65 | return container.NewBorder( 66 | head, nil, nil, nil, 67 | fileTabs, 68 | ) 69 | } 70 | -------------------------------------------------------------------------------- /cmd/cli_test.go: -------------------------------------------------------------------------------- 1 | package cmd 2 | 3 | import ( 4 | "errors" 5 | "os" 6 | "os/exec" 7 | "path/filepath" 8 | "testing" 9 | 10 | "github.com/habedi/gogg/auth" 11 | "github.com/habedi/gogg/client" 12 | "github.com/habedi/gogg/db" 13 | "github.com/spf13/cobra" 14 | ) 15 | 16 | type mockAuthStorer struct{} 17 | 18 | func (m *mockAuthStorer) GetTokenRecord() (*db.Token, error) { return nil, nil } 19 | func (m *mockAuthStorer) UpsertTokenRecord(token *db.Token) error { return nil } 20 | 21 | type mockAuthRefresher struct{} 22 | 23 | func (m *mockAuthRefresher) PerformTokenRefresh(refreshToken string) (string, string, int64, error) { 24 | return "", "", 0, nil 25 | } 26 | 27 | func TestCreateRootCmd(t *testing.T) { 28 | authService := auth.NewService(&mockAuthStorer{}, &mockAuthRefresher{}) 29 | gogClient := &client.GogClient{} 30 | gameRepo := db.NewGameRepository(db.GetDB()) 31 | rootCmd := createRootCmd(authService, gogClient, gameRepo) 32 | 33 | if rootCmd.Use != "gogg" { 34 | t.Errorf("expected root command use to be 'gogg', got: %s", rootCmd.Use) 35 | } 36 | 37 | subCommands := rootCmd.Commands() 38 | if len(subCommands) == 0 { 39 | t.Error("expected root command to have subcommands, got none") 40 | } 41 | 42 | for _, cmd := range subCommands { 43 | if cmd.Use == "help" { 44 | t.Error("expected help command to be replaced, but found a subcommand with use 'help'") 45 | } 46 | } 47 | } 48 | 49 | func TestInitializeAndCloseDatabase(t *testing.T) { 50 | tmpDir := t.TempDir() 51 | db.Path = filepath.Join(tmpDir, "games.db") 52 | initializeDatabase() 53 | closeDatabase() 54 | } 55 | 56 | func TestExecuteFailure(t *testing.T) { 57 | if os.Getenv("TEST_EXECUTE_FAILURE") == "1" { 58 | authService := auth.NewService(&mockAuthStorer{}, &mockAuthRefresher{}) 59 | gogClient := &client.GogClient{} 60 | gameRepo := db.NewGameRepository(db.GetDB()) 61 | rootCmd := createRootCmd(authService, gogClient, gameRepo) 62 | rootCmd.RunE = func(cmd *cobra.Command, args []string) error { 63 | return errors.New("dummy failure") 64 | } 65 | if err := rootCmd.Execute(); err != nil { 66 | os.Exit(1) 67 | } 68 | return 69 | } 70 | 71 | cmd := exec.Command(os.Args[0], "-test.run=TestExecuteFailure") 72 | cmd.Env = append(os.Environ(), "TEST_EXECUTE_FAILURE=1") 73 | err := cmd.Run() 74 | var exitError *exec.ExitError 75 | if errors.As(err, &exitError) { 76 | if exitError.ExitCode() != 1 { 77 | t.Fatalf("expected exit code 1, got %d", exitError.ExitCode()) 78 | } 79 | } 80 | } 81 | -------------------------------------------------------------------------------- /client/rate_limiter.go: -------------------------------------------------------------------------------- 1 | package client 2 | 3 | import ( 4 | "io" 5 | "sync" 6 | "time" 7 | ) 8 | 9 | type RateLimiter struct { 10 | mu sync.Mutex 11 | rate int64 // bytes per second 12 | tokens float64 // current available tokens 13 | last time.Time 14 | } 15 | 16 | var ( 17 | GlobalDownloadRateLimiter *RateLimiter 18 | rateLimiterMu sync.RWMutex 19 | ) 20 | 21 | func SetGlobalDownloadRateLimit(bytesPerSecond int64) { 22 | rateLimiterMu.Lock() 23 | lim := GlobalDownloadRateLimiter 24 | if bytesPerSecond <= 0 { 25 | GlobalDownloadRateLimiter = nil 26 | rateLimiterMu.Unlock() 27 | return 28 | } 29 | if lim == nil { 30 | GlobalDownloadRateLimiter = &RateLimiter{rate: bytesPerSecond, tokens: float64(bytesPerSecond), last: time.Now()} 31 | rateLimiterMu.Unlock() 32 | return 33 | } 34 | // Update existing limiter outside of rateLimiterMu to avoid lock ordering issues 35 | rateLimiterMu.Unlock() 36 | lim.mu.Lock() 37 | lim.rate = bytesPerSecond 38 | if lim.tokens > float64(bytesPerSecond) { 39 | lim.tokens = float64(bytesPerSecond) 40 | } 41 | lim.last = time.Now() 42 | lim.mu.Unlock() 43 | } 44 | 45 | type limitedReader struct { 46 | under io.Reader 47 | lim *RateLimiter 48 | } 49 | 50 | func (lr *limitedReader) Read(p []byte) (int, error) { 51 | if lr.lim == nil || lr.lim.rate <= 0 { 52 | return lr.under.Read(p) 53 | } 54 | lr.lim.mu.Lock() 55 | // Refill tokens 56 | now := time.Now() 57 | elapsed := now.Sub(lr.lim.last).Seconds() 58 | if elapsed > 0 { 59 | lr.lim.tokens += elapsed * float64(lr.lim.rate) 60 | maxTokens := float64(lr.lim.rate) 61 | if lr.lim.tokens > maxTokens { 62 | lr.lim.tokens = maxTokens 63 | } 64 | lr.lim.last = now 65 | } 66 | // Decide max bytes we can read now 67 | allowed := int(lr.lim.tokens) 68 | if allowed <= 0 { 69 | // Need to wait for next refill cycle 70 | lr.lim.mu.Unlock() 71 | sleepDur := time.Duration(float64(time.Second) * (1.0 / float64(lr.lim.rate))) 72 | time.Sleep(sleepDur) 73 | return lr.Read(p) 74 | } 75 | if len(p) > allowed { 76 | p = p[:allowed] 77 | } 78 | lr.lim.mu.Unlock() 79 | n, err := lr.under.Read(p) 80 | if n > 0 { 81 | lr.lim.mu.Lock() 82 | lr.lim.tokens -= float64(n) 83 | lr.lim.mu.Unlock() 84 | } 85 | return n, err 86 | } 87 | 88 | func wrapWithGlobalRateLimiter(r io.Reader) io.Reader { 89 | rateLimiterMu.RLock() 90 | lim := GlobalDownloadRateLimiter 91 | rateLimiterMu.RUnlock() 92 | 93 | if lim == nil { 94 | return r 95 | } 96 | return &limitedReader{under: r, lim: lim} 97 | } 98 | -------------------------------------------------------------------------------- /pkg/validation/validation_test.go: -------------------------------------------------------------------------------- 1 | package validation 2 | 3 | import ( 4 | "testing" 5 | ) 6 | 7 | func TestValidateThreadCount(t *testing.T) { 8 | tests := []struct { 9 | name string 10 | threads int 11 | wantErr bool 12 | }{ 13 | {"valid minimum", 1, false}, 14 | {"valid middle", 10, false}, 15 | {"valid maximum", 20, false}, 16 | {"too low", 0, true}, 17 | {"negative", -1, true}, 18 | {"too high", 21, true}, 19 | } 20 | 21 | for _, tt := range tests { 22 | t.Run(tt.name, func(t *testing.T) { 23 | err := ValidateThreadCount(tt.threads) 24 | if (err != nil) != tt.wantErr { 25 | t.Errorf("ValidateThreadCount(%d) error = %v, wantErr %v", tt.threads, err, tt.wantErr) 26 | } 27 | }) 28 | } 29 | } 30 | 31 | func TestValidateGameID(t *testing.T) { 32 | tests := []struct { 33 | name string 34 | id int 35 | wantErr bool 36 | }{ 37 | {"valid positive", 123, false}, 38 | {"valid large", 999999, false}, 39 | {"zero", 0, true}, 40 | {"negative", -1, true}, 41 | } 42 | 43 | for _, tt := range tests { 44 | t.Run(tt.name, func(t *testing.T) { 45 | err := ValidateGameID(tt.id) 46 | if (err != nil) != tt.wantErr { 47 | t.Errorf("ValidateGameID(%d) error = %v, wantErr %v", tt.id, err, tt.wantErr) 48 | } 49 | }) 50 | } 51 | } 52 | 53 | func TestValidateNonEmptyString(t *testing.T) { 54 | tests := []struct { 55 | name string 56 | fieldName string 57 | value string 58 | wantErr bool 59 | }{ 60 | {"valid string", "username", "john", false}, 61 | {"empty string", "username", "", true}, 62 | } 63 | 64 | for _, tt := range tests { 65 | t.Run(tt.name, func(t *testing.T) { 66 | err := ValidateNonEmptyString(tt.fieldName, tt.value) 67 | if (err != nil) != tt.wantErr { 68 | t.Errorf("ValidateNonEmptyString(%q, %q) error = %v, wantErr %v", tt.fieldName, tt.value, err, tt.wantErr) 69 | } 70 | }) 71 | } 72 | } 73 | 74 | func TestValidatePlatform(t *testing.T) { 75 | tests := []struct { 76 | name string 77 | platform string 78 | wantErr bool 79 | }{ 80 | {"all platforms", "all", false}, 81 | {"windows", "windows", false}, 82 | {"mac", "mac", false}, 83 | {"linux", "linux", false}, 84 | {"invalid", "ios", true}, 85 | {"empty", "", true}, 86 | } 87 | 88 | for _, tt := range tests { 89 | t.Run(tt.name, func(t *testing.T) { 90 | err := ValidatePlatform(tt.platform) 91 | if (err != nil) != tt.wantErr { 92 | t.Errorf("ValidatePlatform(%q) error = %v, wantErr %v", tt.platform, err, tt.wantErr) 93 | } 94 | }) 95 | } 96 | } 97 | -------------------------------------------------------------------------------- /client/catalogue.go: -------------------------------------------------------------------------------- 1 | package client 2 | 3 | import ( 4 | "context" 5 | "fmt" 6 | "os" 7 | "strings" 8 | "sync/atomic" 9 | 10 | "github.com/habedi/gogg/auth" 11 | "github.com/habedi/gogg/db" 12 | "github.com/habedi/gogg/pkg/pool" 13 | "github.com/rs/zerolog/log" 14 | ) 15 | 16 | func embedBase() string { 17 | if v := strings.TrimSpace(os.Getenv("GOGG_EMBED_BASE")); v != "" { 18 | return v 19 | } 20 | return "https://embed.gog.com" 21 | } 22 | 23 | // RefreshCatalogue fetches all owned game details from GOG and updates the local database via the provided repo. 24 | // It reports progress via the progressCb callback, which receives a value from 0.0 to 1.0. 25 | func RefreshCatalogue( 26 | ctx context.Context, 27 | authService *auth.Service, 28 | repo db.GameRepository, 29 | numWorkers int, 30 | progressCb func(float64), 31 | ) error { 32 | // Prefer context-aware token refresh to honor cancellations/timeouts 33 | token, err := authService.RefreshTokenCtx(ctx) 34 | if err != nil { 35 | return fmt.Errorf("failed to refresh token: %w", err) 36 | } 37 | 38 | ownedURL := fmt.Sprintf("%s/user/data/games", embedBase()) 39 | gameIDs, err := FetchAllOwnedGameIDs(ctx, token.AccessToken, ownedURL) 40 | if err != nil { 41 | return fmt.Errorf("failed to fetch owned game IDs: %w", err) 42 | } 43 | if len(gameIDs) == 0 { 44 | log.Info().Msg("No games found in the GOG account.") 45 | if progressCb != nil { 46 | progressCb(1.0) // Signal completion 47 | } 48 | return nil 49 | } 50 | 51 | if err := repo.Clear(ctx); err != nil { 52 | return fmt.Errorf("failed to empty catalogue: %w", err) 53 | } 54 | 55 | var processedCount atomic.Int64 56 | totalGames := float64(len(gameIDs)) 57 | 58 | workerFunc := func(ctx context.Context, id int) error { 59 | // Defer the counter-increment to guarantee it runs even if a fetch fails. 60 | defer func() { 61 | count := processedCount.Add(1) 62 | if progressCb != nil { 63 | progress := float64(count) / totalGames 64 | progressCb(progress) 65 | } 66 | }() 67 | 68 | url := fmt.Sprintf("%s/account/gameDetails/%d.json", embedBase(), id) 69 | details, raw, fetchErr := FetchGameData(ctx, token.AccessToken, url) 70 | if fetchErr != nil { 71 | log.Warn().Err(fetchErr).Int("gameID", id).Msg("Failed to fetch game details") 72 | return nil 73 | } 74 | if details.Title != "" { 75 | _ = repo.Put(ctx, db.Game{ID: id, Title: details.Title, Data: raw}) 76 | } 77 | 78 | return nil 79 | } 80 | 81 | _ = pool.Run(ctx, gameIDs, numWorkers, workerFunc) 82 | 83 | return ctx.Err() 84 | } 85 | -------------------------------------------------------------------------------- /docs/examples/download_all_games.ps1: -------------------------------------------------------------------------------- 1 | # To run the script, open PowerShell and execute the following command: 2 | # Set-ExecutionPolicy -Scope Process -ExecutionPolicy Bypass; .\download_all_games.ps1 3 | 4 | # Colors 5 | $RED = "`e[31m" 6 | $GREEN = "`e[32m" 7 | $YELLOW = "`e[33m" 8 | $NC = "`e[0m" # No Color 9 | 10 | Write-Host "${GREEN}===================== Download All Games (Windows Powershell Version) ======================${NC}" 11 | Write-Host "${GREEN}The code in this script downloads all games owned by the user on GOG.com with given options.${NC}" 12 | Write-Host "${GREEN}============================================================================================${NC}" 13 | 14 | $env:DEBUG_GOGG = 1 # Debug mode enabled 15 | $GOGG = ".\bin/gogg" # Path to Gogg's executable file (for example, ".\bin\gogg") 16 | 17 | # Download options 18 | $LANG = "en" # Language English 19 | $PLATFORM = "windows" # Platform Windows 20 | $INCLUDE_DLC = $true 21 | $INCLUDE_EXTRA_CONTENT = $true 22 | $RESUME_DOWNLOAD = $true 23 | $NUM_THREADS = 4 # Number of worker threads for downloading 24 | $FLATTEN = $true 25 | $OUTPUT_DIR = "./games" # Output directory 26 | 27 | # Function to clean up the CSV file 28 | function Cleanup 29 | { 30 | if ($latest_csv) 31 | { 32 | Remove-Item -Force $latest_csv 33 | if ($?) 34 | { 35 | Write-Host "${RED}Cleanup: removed $latest_csv${NC}" 36 | } 37 | } 38 | } 39 | 40 | # Update game catalogue and export it to a CSV file 41 | & $GOGG catalogue refresh 42 | & $GOGG catalogue export ./ --format=csv 43 | 44 | # Find the newest catalogue file 45 | $latest_csv = Get-ChildItem -Path . -Filter "gogg_catalogue_*.csv" | Sort-Object LastWriteTime -Descending | Select-Object -First 1 46 | 47 | # Check if the catalogue file exists 48 | if (-not $latest_csv) 49 | { 50 | Write-Host "${RED}No CSV file found.${NC}" 51 | exit 1 52 | } 53 | 54 | Write-Host "${GREEN}Using catalogue file: $( $latest_csv.Name )${NC}" 55 | 56 | # Download each game listed in catalogue file, skipping the first line 57 | Get-Content $latest_csv.FullName | Select-Object -Skip 1 | ForEach-Object { 58 | $fields = $_ -split "," 59 | $game_id = $fields[0] 60 | $game_title = $fields[1] 61 | Write-Host "${YELLOW}Game ID: $game_id, Title: $game_title${NC}" 62 | & $GOGG download $game_id $OUTPUT_DIR --platform=$PLATFORM --lang=$LANG ` 63 | --dlcs=$INCLUDE_DLC --extras=$INCLUDE_EXTRA_CONTENT --resume=$RESUME_DOWNLOAD --threads=$NUM_THREADS ` 64 | --flatten=$FLATTEN --keep-latest=true 65 | Start-Sleep -Seconds 1 66 | } 67 | 68 | # Clean up 69 | Cleanup 70 | -------------------------------------------------------------------------------- /pkg/operations/storage_test.go: -------------------------------------------------------------------------------- 1 | package operations_test 2 | 3 | import ( 4 | "testing" 5 | 6 | "github.com/habedi/gogg/db" 7 | "github.com/habedi/gogg/pkg/operations" 8 | "github.com/stretchr/testify/assert" 9 | "github.com/stretchr/testify/require" 10 | "gorm.io/driver/sqlite" 11 | "gorm.io/gorm" 12 | "gorm.io/gorm/logger" 13 | ) 14 | 15 | func setupTestDB(t *testing.T) { 16 | t.Helper() 17 | gormDB, err := gorm.Open(sqlite.Open("file::memory:?cache=shared"), &gorm.Config{ 18 | // Disable logger for cleaner test output 19 | Logger: logger.Default.LogMode(logger.Silent), 20 | }) 21 | require.NoError(t, err) 22 | 23 | db.Db = gormDB 24 | require.NoError(t, db.Db.AutoMigrate(&db.Game{})) 25 | } 26 | 27 | func TestEstimateGameSize(t *testing.T) { 28 | setupTestDB(t) 29 | 30 | // This JSON structure mimics the GOG API format that your custom unmarshaller expects. 31 | rawJSONData := ` 32 | { 33 | "title": "Test Sizer Game", 34 | "downloads": [ 35 | ["English", { "windows": [{"name": "setup.exe", "size": "1.5 GB"}], "linux": [{"name": "game.sh", "size": "1.4 GB"}] }], 36 | ["German", { "windows": [{"name": "setup_de.exe", "size": "1.5 GB"}] }] 37 | ], 38 | "dlcs": [ 39 | { 40 | "title": "Test DLC", 41 | "downloads": [ 42 | ["English", { "windows": [{"name": "dlc.exe", "size": "512 MB"}] }] 43 | ] 44 | } 45 | ] 46 | }` 47 | 48 | require.NoError(t, db.PutInGame(123, "Test Sizer Game", rawJSONData)) 49 | 50 | t.Run("Windows with DLC", func(t *testing.T) { 51 | params := operations.EstimationParams{ 52 | LanguageCode: "en", 53 | PlatformName: "windows", 54 | IncludeExtras: false, 55 | IncludeDLCs: true, 56 | } 57 | size, _, err := operations.EstimateGameSize(123, params) 58 | require.NoError(t, err) 59 | // 1.5GB (1.5 * 1024^3) + 512MB (512 * 1024^2) = 2147483648 bytes 60 | assert.Equal(t, int64(2147483648), size) 61 | }) 62 | 63 | t.Run("Windows without DLC", func(t *testing.T) { 64 | params := operations.EstimationParams{ 65 | LanguageCode: "en", 66 | PlatformName: "windows", 67 | IncludeExtras: false, 68 | IncludeDLCs: false, 69 | } 70 | size, _, err := operations.EstimateGameSize(123, params) 71 | require.NoError(t, err) 72 | // 1.5GB (1.5 * 1024^3) = 1610612736 bytes 73 | assert.Equal(t, int64(1610612736), size) 74 | }) 75 | 76 | t.Run("Game not found", func(t *testing.T) { 77 | _, _, err := operations.EstimateGameSize(999, operations.EstimationParams{}) 78 | assert.Error(t, err) 79 | assert.Contains(t, err.Error(), "game with ID 999 not found") 80 | }) 81 | } 82 | -------------------------------------------------------------------------------- /cmd/cli.go: -------------------------------------------------------------------------------- 1 | package cmd 2 | 3 | import ( 4 | "context" 5 | "os" 6 | 7 | "github.com/habedi/gogg/auth" 8 | "github.com/habedi/gogg/client" 9 | "github.com/habedi/gogg/db" 10 | "github.com/habedi/gogg/pkg/clierr" 11 | "github.com/rs/zerolog/log" 12 | "github.com/spf13/cobra" 13 | ) 14 | 15 | var exitCodeByType = map[clierr.Type]int{ 16 | clierr.Validation: 2, 17 | clierr.NotFound: 3, 18 | clierr.Download: 4, 19 | clierr.Internal: 1, 20 | } 21 | 22 | func Execute() { 23 | initializeDatabase() 24 | defer closeDatabase() 25 | 26 | gameRepo := db.NewGameRepository(db.GetDB()) 27 | tokenRepo := db.NewTokenRepository(db.GetDB()) 28 | gogClient := &client.GogClient{TokenURL: "https://auth.gog.com/token"} 29 | authService := auth.NewServiceWithRepo(tokenRepo, gogClient) 30 | 31 | rootCmd := createRootCmd(authService, gogClient, gameRepo) 32 | rootCmd.PersistentFlags().DurationP("timeout", "T", 0, "Global timeout for command execution (like 30s or 2m). 0 means no timeout") 33 | var cancel context.CancelFunc 34 | rootCmd.PersistentPreRunE = func(cmd *cobra.Command, args []string) error { 35 | to, err := cmd.Flags().GetDuration("timeout") 36 | if err != nil { 37 | return err 38 | } 39 | ctx := context.Background() 40 | if to > 0 { 41 | ctx, cancel = context.WithTimeout(ctx, to) 42 | } 43 | cmd.SetContext(ctx) 44 | return nil 45 | } 46 | rootCmd.PersistentPostRun = func(cmd *cobra.Command, args []string) { 47 | if cancel != nil { 48 | cancel() 49 | } 50 | } 51 | 52 | if err := rootCmd.Execute(); err != nil { 53 | log.Error().Err(err).Msg("Command execution failed.") 54 | os.Exit(1) 55 | } 56 | if e := getLastCliErr(); e != nil { // mapped exit code 57 | if code, ok := exitCodeByType[e.Type]; ok { 58 | os.Exit(code) 59 | } 60 | os.Exit(1) 61 | } 62 | } 63 | 64 | func createRootCmd(authService *auth.Service, gogClient *client.GogClient, gameRepo db.GameRepository) *cobra.Command { 65 | rootCmd := &cobra.Command{ 66 | Use: "gogg", 67 | Short: "A Downloader for GOG", 68 | } 69 | 70 | rootCmd.AddCommand( 71 | catalogueCmd(authService, gameRepo), 72 | downloadCmd(authService), 73 | versionCmd(), 74 | loginCmd(gogClient), 75 | fileCmd(), 76 | guiCmd(authService), 77 | ) 78 | 79 | rootCmd.CompletionOptions.HiddenDefaultCmd = true 80 | rootCmd.SetHelpCommand(&cobra.Command{ 81 | Use: "no-help", 82 | Hidden: true, 83 | }) 84 | 85 | return rootCmd 86 | } 87 | 88 | func initializeDatabase() { 89 | if err := db.InitDB(); err != nil { 90 | log.Error().Err(err).Msg("Failed to initialize database") 91 | os.Exit(1) 92 | } 93 | } 94 | 95 | func closeDatabase() { 96 | if err := db.CloseDB(); err != nil { 97 | log.Error().Err(err).Msg("Failed to close the database.") 98 | os.Exit(1) 99 | } 100 | } 101 | -------------------------------------------------------------------------------- /pkg/operations/hashing_test.go: -------------------------------------------------------------------------------- 1 | package operations_test 2 | 3 | import ( 4 | "context" 5 | "os" 6 | "path/filepath" 7 | "sort" 8 | "testing" 9 | 10 | "github.com/habedi/gogg/pkg/operations" 11 | "github.com/stretchr/testify/assert" 12 | "github.com/stretchr/testify/require" 13 | ) 14 | 15 | func createTestDir(t *testing.T) string { 16 | t.Helper() 17 | dir := t.TempDir() 18 | 19 | // Create root files 20 | require.NoError(t, os.WriteFile(filepath.Join(dir, "root.txt"), []byte("root"), 0600)) 21 | require.NoError(t, os.WriteFile(filepath.Join(dir, "data.csv"), []byte("ignore"), 0600)) 22 | 23 | // Create subdir 24 | subdir := filepath.Join(dir, "subdir") 25 | require.NoError(t, os.Mkdir(subdir, 0755)) 26 | require.NoError(t, os.WriteFile(filepath.Join(subdir, "sub.txt"), []byte("sub"), 0600)) 27 | require.NoError(t, os.WriteFile(filepath.Join(subdir, "sub.txt.md5"), []byte("hash"), 0600)) 28 | 29 | return dir 30 | } 31 | 32 | func TestFindFilesToHash(t *testing.T) { 33 | dir := createTestDir(t) 34 | testExclusions := []string{"*.csv", "*.md5"} 35 | 36 | t.Run("Recursive", func(t *testing.T) { 37 | files, err := operations.FindFilesToHash(dir, true, testExclusions) 38 | require.NoError(t, err) 39 | 40 | expected := []string{filepath.Join(dir, "root.txt"), filepath.Join(dir, "subdir", "sub.txt")} 41 | sort.Strings(files) 42 | sort.Strings(expected) 43 | 44 | assert.Equal(t, expected, files) 45 | }) 46 | 47 | t.Run("Non-Recursive", func(t *testing.T) { 48 | files, err := operations.FindFilesToHash(dir, false, testExclusions) 49 | require.NoError(t, err) 50 | assert.Equal(t, []string{filepath.Join(dir, "root.txt")}, files) 51 | }) 52 | 53 | t.Run("Non-existent dir", func(t *testing.T) { 54 | _, err := operations.FindFilesToHash("nonexistent-dir", true, nil) 55 | assert.Error(t, err) 56 | }) 57 | } 58 | 59 | func TestGenerateHashes(t *testing.T) { 60 | dir := t.TempDir() 61 | filePath := filepath.Join(dir, "test.txt") 62 | require.NoError(t, os.WriteFile(filePath, []byte("gogg-test"), 0600)) 63 | 64 | files := []string{filePath} 65 | resultsChan := operations.GenerateHashes(context.Background(), files, "md5", 1) 66 | 67 | result := <-resultsChan 68 | assert.NoError(t, result.Err) 69 | assert.Equal(t, filePath, result.File) 70 | assert.Equal(t, "0a8cfa7d700dfe898c6cb702e13ed466", result.Hash) 71 | 72 | _, ok := <-resultsChan 73 | assert.False(t, ok, "Channel should be closed") 74 | } 75 | 76 | func TestCleanHashes(t *testing.T) { 77 | dir := createTestDir(t) 78 | 79 | err := operations.CleanHashes(dir, true) 80 | require.NoError(t, err) 81 | 82 | _, err = os.Stat(filepath.Join(dir, "subdir", "sub.txt.md5")) 83 | assert.True(t, os.IsNotExist(err), "Hash file should have been deleted") 84 | 85 | _, err = os.Stat(filepath.Join(dir, "subdir", "sub.txt")) 86 | assert.NoError(t, err, "Regular file should still exist") 87 | } 88 | -------------------------------------------------------------------------------- /main_test.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "os" 5 | "testing" 6 | "time" 7 | 8 | "github.com/rs/zerolog" 9 | ) 10 | 11 | func TestConfigureLogLevelFromEnv_Disabled(t *testing.T) { 12 | originalLevel := zerolog.GlobalLevel() 13 | t.Cleanup(func() { zerolog.SetGlobalLevel(originalLevel) }) 14 | 15 | for _, tc := range []struct { 16 | envVal string 17 | expectedLvl zerolog.Level 18 | }{ 19 | {"false", zerolog.Disabled}, 20 | {"0", zerolog.Disabled}, 21 | {"", zerolog.Disabled}, 22 | } { 23 | if err := os.Setenv("DEBUG_GOGG", tc.envVal); err != nil { 24 | t.Fatalf("failed to set env: %v", err) 25 | } 26 | configureLogLevelFromEnv() 27 | if zerolog.GlobalLevel() != tc.expectedLvl { 28 | t.Errorf("DEBUG_GOGG=%q: expected log level %v, got %v", tc.envVal, tc.expectedLvl, zerolog.GlobalLevel()) 29 | } 30 | } 31 | } 32 | 33 | func TestConfigureLogLevelFromEnv_Debug(t *testing.T) { 34 | originalLevel := zerolog.GlobalLevel() 35 | t.Cleanup(func() { zerolog.SetGlobalLevel(originalLevel) }) 36 | 37 | for _, tc := range []struct { 38 | envVal string 39 | expectedLvl zerolog.Level 40 | }{ 41 | {"true", zerolog.DebugLevel}, 42 | {"1", zerolog.DebugLevel}, 43 | {"random", zerolog.DebugLevel}, 44 | } { 45 | if err := os.Setenv("DEBUG_GOGG", tc.envVal); err != nil { 46 | t.Fatalf("failed to set env: %v", err) 47 | } 48 | configureLogLevelFromEnv() 49 | if zerolog.GlobalLevel() != tc.expectedLvl { 50 | t.Errorf("DEBUG_GOGG=%q: expected log level %v, got %v", tc.envVal, tc.expectedLvl, zerolog.GlobalLevel()) 51 | } 52 | } 53 | } 54 | 55 | func TestSetupInterruptListener(t *testing.T) { 56 | stopChan := setupInterruptListener() 57 | if stopChan == nil { 58 | t.Error("expected non-nil channel from setupInterruptListener") 59 | } 60 | 61 | go func() { 62 | time.Sleep(10 * time.Millisecond) 63 | stopChan <- os.Interrupt 64 | }() 65 | 66 | select { 67 | case sig := <-stopChan: 68 | if sig != os.Interrupt { 69 | t.Errorf("expected os.Interrupt, got %v", sig) 70 | } 71 | case <-time.After(100 * time.Millisecond): 72 | t.Error("did not receive signal on channel") 73 | } 74 | } 75 | 76 | func TestHandleInterrupt(t *testing.T) { 77 | stopChan := make(chan os.Signal, 1) 78 | exitCalled := make(chan int, 1) 79 | var loggedMessage string 80 | 81 | fakeLog := func(msg string) { loggedMessage = msg } 82 | fakeExit := func(code int) { exitCalled <- code } 83 | 84 | go handleInterrupt(stopChan, fakeLog, fakeExit) 85 | 86 | stopChan <- os.Interrupt 87 | 88 | select { 89 | case code := <-exitCalled: 90 | if code != 1 { 91 | t.Errorf("expected exit code 1, got %d", code) 92 | } 93 | expectedMsg := "Interrupt signal received. Exiting..." 94 | if loggedMessage != expectedMsg { 95 | t.Errorf("expected log message %q, got %q", expectedMsg, loggedMessage) 96 | } 97 | case <-time.After(100 * time.Millisecond): 98 | t.Error("exit function was not called on interrupt") 99 | } 100 | } 101 | -------------------------------------------------------------------------------- /client/login_test.go: -------------------------------------------------------------------------------- 1 | package client 2 | 3 | import ( 4 | "encoding/json" 5 | "net/http" 6 | "net/http/httptest" 7 | "testing" 8 | "time" 9 | 10 | "github.com/stretchr/testify/assert" 11 | "github.com/stretchr/testify/require" 12 | ) 13 | 14 | func TestPerformTokenRefresh_Success(t *testing.T) { 15 | server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { 16 | assert.Equal(t, "/token", r.URL.Path) 17 | assert.Equal(t, "POST", r.Method) 18 | r.ParseForm() 19 | assert.Equal(t, "my-refresh-token", r.FormValue("refresh_token")) 20 | assert.Equal(t, "refresh_token", r.FormValue("grant_type")) 21 | 22 | w.Header().Set("Content-Type", "application/json") 23 | json.NewEncoder(w).Encode(map[string]interface{}{ 24 | "access_token": "new-access-token", 25 | "refresh_token": "new-refresh-token", 26 | "expires_in": 7200, 27 | }) 28 | })) 29 | defer server.Close() 30 | 31 | client := &GogClient{TokenURL: server.URL + "/token"} 32 | accessToken, refreshToken, expiresIn, err := client.PerformTokenRefresh("my-refresh-token") 33 | 34 | require.NoError(t, err) 35 | assert.Equal(t, "new-access-token", accessToken) 36 | assert.Equal(t, "new-refresh-token", refreshToken) 37 | assert.Equal(t, int64(7200), expiresIn) 38 | } 39 | 40 | func TestPerformTokenRefresh_ApiError(t *testing.T) { 41 | server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { 42 | w.Header().Set("Content-Type", "application/json") 43 | w.WriteHeader(http.StatusBadRequest) 44 | json.NewEncoder(w).Encode(map[string]string{ 45 | "error_description": "The provided authorization code is invalid or expired", 46 | }) 47 | })) 48 | defer server.Close() 49 | 50 | client := &GogClient{TokenURL: server.URL + "/token"} 51 | _, _, _, err := client.PerformTokenRefresh("bad-token") 52 | 53 | require.Error(t, err) 54 | assert.Contains(t, err.Error(), "The provided authorization code is invalid or expired") 55 | } 56 | 57 | func TestExchangeCodeForToken_Success(t *testing.T) { 58 | server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { 59 | assert.Equal(t, "/token", r.URL.Path) 60 | r.ParseForm() 61 | assert.Equal(t, "my-auth-code", r.FormValue("code")) 62 | assert.Equal(t, "authorization_code", r.FormValue("grant_type")) 63 | 64 | w.Header().Set("Content-Type", "application/json") 65 | json.NewEncoder(w).Encode(map[string]interface{}{ 66 | "access_token": "access-from-code", 67 | "refresh_token": "refresh-from-code", 68 | "expires_in": 3600, 69 | }) 70 | })) 71 | defer server.Close() 72 | 73 | gogClient := &GogClient{TokenURL: server.URL + "/token"} 74 | 75 | accessToken, refreshToken, expiresAt, err := gogClient.exchangeCodeForToken("my-auth-code") 76 | 77 | require.NoError(t, err) 78 | assert.Equal(t, "access-from-code", accessToken) 79 | assert.Equal(t, "refresh-from-code", refreshToken) 80 | 81 | expectedExpiry := time.Now().Add(time.Hour) 82 | actualExpiry, parseErr := time.Parse(time.RFC3339, expiresAt) 83 | require.NoError(t, parseErr) 84 | assert.WithinDuration(t, expectedExpiry, actualExpiry, 5*time.Second) 85 | } 86 | -------------------------------------------------------------------------------- /docs/examples/calculate_storage_for_all_games.ps1: -------------------------------------------------------------------------------- 1 | # To run the script, open PowerShell and execute the following command: 2 | # Set-ExecutionPolicy -Scope Process -ExecutionPolicy Bypass; .\calculate_storage_all_games.ps1 3 | 4 | # Install PowerShell on Linux 5 | # sudo apt-get install -y powershell # Debian-based distros 6 | # sudo yum install -y powershell # Fedora 7 | # sudo zypper install -y powershell # openSUSE 8 | # sudo pacman -S powershell # Arch Linux 9 | # sudo snap install powershell --classic # Ubuntu 10 | 11 | # Colors 12 | $RED = "`e[31m" 13 | $GREEN = "`e[32m" 14 | $YELLOW = "`e[33m" 15 | $NC = "`e[0m" # No Color 16 | 17 | Write-Host "${GREEN}========================== Download All Games (Powershell Script) ===================================${NC}" 18 | Write-Host "${GREEN}Calculate the storage size for downloading all games owned by the user on GOG.com with given options.${NC}" 19 | Write-Host "${GREEN}=====================================================================================================${NC}" 20 | 21 | $env:DEBUG_GOGG = 0 # Debug mode disabled 22 | $GOGG = ".\bin/gogg" # Path to Gogg's executable file (for example, ".\bin\gogg") 23 | 24 | # Download options 25 | $LANG = "en" # Language English 26 | $PLATFORM = "windows" # Platform Windows 27 | $INCLUDE_DLC = 1 # Include DLCs 28 | $INCLUDE_EXTRA_CONTENT = 1 # Include extra content 29 | $STORAGE_UNIT = "GB" # Storage unit (MB or GB) 30 | 31 | # Function to clean up the CSV file 32 | function Cleanup 33 | { 34 | if ($latest_csv) 35 | { 36 | Remove-Item -Force $latest_csv 37 | if ($?) 38 | { 39 | Write-Host "${RED}Cleanup: removed $latest_csv${NC}" 40 | } 41 | } 42 | } 43 | 44 | # Update game catalogue and export it to a CSV file 45 | & $GOGG catalogue refresh 46 | & $GOGG catalogue export ./ --format=csv 47 | 48 | # Find the newest catalogue file 49 | $latest_csv = Get-ChildItem -Path . -Filter "gogg_catalogue_*.csv" | Sort-Object LastWriteTime -Descending | Select-Object -First 1 50 | 51 | # Check if the catalogue file exists 52 | if (-not $latest_csv) 53 | { 54 | Write-Host "${RED}No CSV file found.${NC}" 55 | exit 1 56 | } 57 | 58 | Write-Host "${GREEN}Using catalogue file: $( $latest_csv.Name )${NC}" 59 | 60 | # Initialize counter and total size 61 | $counter = 0 62 | $totalSize = 0.0 63 | 64 | # Download each game listed in catalogue file, skipping the first line 65 | Get-Content $latest_csv.FullName | Select-Object -Skip 1 | ForEach-Object { 66 | $fields = $_ -split "," 67 | $game_id = $fields[0] 68 | $game_title = $fields[1] 69 | $counter++ 70 | Write-Host "${YELLOW}${counter}: Game ID: $game_id, Title: $game_title${NC}" 71 | $sizeOutput = & $GOGG file size $game_id --platform=$PLATFORM --lang=$LANG --dlcs=$INCLUDE_DLC ` 72 | --extras=$INCLUDE_EXTRA_CONTENT --unit=$STORAGE_UNIT 73 | $size = [double]($sizeOutput -replace '[^\d.]', '') 74 | $totalSize += $size 75 | Write-Host "${YELLOW}Total download size: $size $STORAGE_UNIT${NC}" 76 | Start-Sleep -Seconds 0.0 77 | } 78 | 79 | # Print total download size 80 | Write-Host "${GREEN}Total download size for all games: $totalSize $STORAGE_UNIT${NC}" 81 | 82 | # Clean up 83 | Cleanup 84 | -------------------------------------------------------------------------------- /auth/service_test.go: -------------------------------------------------------------------------------- 1 | package auth_test 2 | 3 | import ( 4 | "errors" 5 | "testing" 6 | "time" 7 | 8 | "github.com/habedi/gogg/auth" 9 | "github.com/habedi/gogg/db" 10 | "github.com/stretchr/testify/assert" 11 | "github.com/stretchr/testify/require" 12 | ) 13 | 14 | type mockStorer struct { 15 | tokenToReturn *db.Token 16 | errToReturn error 17 | upsertCalled bool 18 | } 19 | 20 | func (m *mockStorer) GetTokenRecord() (*db.Token, error) { 21 | return m.tokenToReturn, m.errToReturn 22 | } 23 | 24 | func (m *mockStorer) UpsertTokenRecord(token *db.Token) error { 25 | m.upsertCalled = true 26 | m.tokenToReturn = token 27 | return nil 28 | } 29 | 30 | type mockRefresher struct { 31 | errToReturn error 32 | } 33 | 34 | func (m *mockRefresher) PerformTokenRefresh(refreshToken string) (string, string, int64, error) { 35 | if m.errToReturn != nil { 36 | return "", "", 0, m.errToReturn 37 | } 38 | return "new-access-token", "new-refresh-token", 3600, nil 39 | } 40 | 41 | func TestRefreshToken_WhenTokenIsValid(t *testing.T) { 42 | storer := &mockStorer{ 43 | tokenToReturn: &db.Token{ 44 | AccessToken: "valid-access", 45 | RefreshToken: "valid-refresh", 46 | ExpiresAt: time.Now().Add(1 * time.Hour).Format(time.RFC3339), 47 | }, 48 | } 49 | service := auth.NewService(storer, &mockRefresher{}) 50 | 51 | token, err := service.RefreshToken() 52 | 53 | require.NoError(t, err) 54 | assert.Equal(t, "valid-access", token.AccessToken) 55 | assert.False(t, storer.upsertCalled, "Upsert should not be called for a valid token") 56 | } 57 | 58 | func TestRefreshToken_WhenTokenIsExpired(t *testing.T) { 59 | storer := &mockStorer{ 60 | tokenToReturn: &db.Token{ 61 | AccessToken: "expired-access", 62 | RefreshToken: "expired-refresh", 63 | ExpiresAt: time.Now().Add(-1 * time.Hour).Format(time.RFC3339), 64 | }, 65 | } 66 | service := auth.NewService(storer, &mockRefresher{}) 67 | 68 | token, err := service.RefreshToken() 69 | 70 | require.NoError(t, err) 71 | assert.Equal(t, "new-access-token", token.AccessToken) 72 | assert.Equal(t, "new-refresh-token", token.RefreshToken) 73 | assert.True(t, storer.upsertCalled, "Upsert should be called for an expired token") 74 | } 75 | 76 | func TestRefreshToken_WhenRefreshFails(t *testing.T) { 77 | storer := &mockStorer{ 78 | tokenToReturn: &db.Token{ 79 | AccessToken: "expired-access", 80 | RefreshToken: "expired-refresh", 81 | ExpiresAt: time.Now().Add(-1 * time.Hour).Format(time.RFC3339), 82 | }, 83 | } 84 | refresher := &mockRefresher{errToReturn: errors.New("network error")} 85 | service := auth.NewService(storer, refresher) 86 | 87 | _, err := service.RefreshToken() 88 | 89 | require.Error(t, err) 90 | assert.Contains(t, err.Error(), "network error") 91 | assert.False(t, storer.upsertCalled, "Upsert should not be called if refresh fails") 92 | } 93 | 94 | func TestRefreshToken_WhenNoTokenInDB(t *testing.T) { 95 | storer := &mockStorer{tokenToReturn: nil} 96 | service := auth.NewService(storer, &mockRefresher{}) 97 | 98 | _, err := service.RefreshToken() 99 | 100 | require.Error(t, err) 101 | assert.Contains(t, err.Error(), "token record does not exist") 102 | } 103 | -------------------------------------------------------------------------------- /db/repository.go: -------------------------------------------------------------------------------- 1 | package db 2 | 3 | import ( 4 | "context" 5 | "errors" 6 | 7 | "gorm.io/gorm" 8 | "gorm.io/gorm/clause" 9 | ) 10 | 11 | // GameRepository defines decoupled operations for game persistence. 12 | type GameRepository interface { 13 | Put(ctx context.Context, g Game) error 14 | GetByID(ctx context.Context, id int) (*Game, error) 15 | List(ctx context.Context) ([]Game, error) 16 | SearchByTitle(ctx context.Context, titleSubstr string) ([]Game, error) 17 | Clear(ctx context.Context) error 18 | } 19 | 20 | // TokenRepository defines decoupled operations for token persistence. 21 | type TokenRepository interface { 22 | Get(ctx context.Context) (*Token, error) 23 | Upsert(ctx context.Context, token *Token) error 24 | } 25 | 26 | // gormGameRepo is a GORM-backed implementation of GameRepository. 27 | // Use constructor NewGameRepository to obtain an instance. 28 | type gormGameRepo struct{ db *gorm.DB } 29 | 30 | // gormTokenRepo is a GORM-backed implementation of TokenRepository. 31 | // Use constructor NewTokenRepository to obtain an instance. 32 | type gormTokenRepo struct{ db *gorm.DB } 33 | 34 | // NewGameRepository creates a GameRepository. Accepts *gorm.DB to avoid global access. 35 | func NewGameRepository(db *gorm.DB) GameRepository { return &gormGameRepo{db: db} } 36 | 37 | // NewTokenRepository creates a TokenRepository. Accepts *gorm.DB to avoid global access. 38 | func NewTokenRepository(db *gorm.DB) TokenRepository { return &gormTokenRepo{db: db} } 39 | 40 | func (r *gormGameRepo) Put(ctx context.Context, g Game) error { 41 | return r.db.WithContext(ctx).Clauses(clause.OnConflict{UpdateAll: true}).Create(&g).Error 42 | } 43 | 44 | func (r *gormGameRepo) GetByID(ctx context.Context, id int) (*Game, error) { 45 | var game Game 46 | err := r.db.WithContext(ctx).First(&game, "id = ?", id).Error 47 | if errors.Is(err, gorm.ErrRecordNotFound) { 48 | return nil, nil 49 | } 50 | if err != nil { 51 | return nil, err 52 | } 53 | return &game, nil 54 | } 55 | 56 | func (r *gormGameRepo) List(ctx context.Context) ([]Game, error) { 57 | var games []Game 58 | if err := r.db.WithContext(ctx).Find(&games).Error; err != nil { 59 | return nil, err 60 | } 61 | return games, nil 62 | } 63 | 64 | func (r *gormGameRepo) SearchByTitle(ctx context.Context, titleSubstr string) ([]Game, error) { 65 | var games []Game 66 | if err := r.db.WithContext(ctx).Where("title LIKE ?", "%"+titleSubstr+"%").Find(&games).Error; err != nil { 67 | return nil, err 68 | } 69 | return games, nil 70 | } 71 | 72 | func (r *gormGameRepo) Clear(ctx context.Context) error { 73 | return r.db.WithContext(ctx).Session(&gorm.Session{AllowGlobalUpdate: true}).Unscoped().Delete(&Game{}).Error 74 | } 75 | 76 | func (r *gormTokenRepo) Get(ctx context.Context) (*Token, error) { 77 | var token Token 78 | err := r.db.WithContext(ctx).First(&token).Error 79 | if errors.Is(err, gorm.ErrRecordNotFound) { 80 | return nil, nil 81 | } 82 | if err != nil { 83 | return nil, err 84 | } 85 | return &token, nil 86 | } 87 | 88 | func (r *gormTokenRepo) Upsert(ctx context.Context, token *Token) error { 89 | token.ID = 1 90 | return r.db.WithContext(ctx).Clauses(clause.OnConflict{ 91 | Columns: []clause.Column{{Name: "id"}}, 92 | DoUpdates: clause.AssignmentColumns([]string{"access_token", "refresh_token", "expires_at"}), 93 | }).Create(token).Error 94 | } 95 | -------------------------------------------------------------------------------- /pkg/operations/hashing.go: -------------------------------------------------------------------------------- 1 | package operations 2 | 3 | import ( 4 | "context" 5 | "os" 6 | "path/filepath" 7 | "strings" 8 | "sync" 9 | 10 | "github.com/habedi/gogg/pkg/hasher" 11 | "github.com/rs/zerolog/log" 12 | ) 13 | 14 | // HashResult represents the result of a single file hashing operation. 15 | type HashResult struct { 16 | File string 17 | Hash string 18 | Err error 19 | } 20 | 21 | // DefaultHashExclusions is the list of patterns to exclude from hashing. 22 | var DefaultHashExclusions = []string{ 23 | ".git", ".gitignore", ".DS_Store", "Thumbs.db", "desktop.ini", 24 | "*.json", "*.xml", "*.csv", "*.log", "*.txt", "*.md", "*.html", "*.htm", 25 | "*.md5", "*.sha1", "*.sha256", "*.sha512", "*.cksum", "*.sum", "*.sig", "*.asc", "*.gpg", 26 | } 27 | 28 | // FindFilesToHash walks a directory and returns a slice of file paths to be processed. 29 | func FindFilesToHash(dir string, recursive bool, exclusions []string) ([]string, error) { 30 | var filesToProcess []string 31 | walkErr := filepath.Walk(dir, func(path string, info os.FileInfo, err error) error { 32 | if err != nil { 33 | return err 34 | } 35 | if info.IsDir() { 36 | if !recursive && path != dir { 37 | return filepath.SkipDir 38 | } 39 | return nil 40 | } 41 | for _, pattern := range exclusions { 42 | if matched, _ := filepath.Match(pattern, info.Name()); matched { 43 | return nil 44 | } 45 | } 46 | filesToProcess = append(filesToProcess, path) 47 | return nil 48 | }) 49 | return filesToProcess, walkErr 50 | } 51 | 52 | // GenerateHashes concurrently generates hashes for a list of files. 53 | func GenerateHashes(ctx context.Context, files []string, algo string, numThreads int) <-chan HashResult { 54 | tasks := make(chan string, len(files)) 55 | results := make(chan HashResult, len(files)) 56 | 57 | var wg sync.WaitGroup 58 | 59 | for i := 0; i < numThreads; i++ { 60 | wg.Add(1) 61 | go func() { 62 | defer wg.Done() 63 | for filePath := range tasks { 64 | select { 65 | case <-ctx.Done(): 66 | return 67 | default: 68 | } 69 | 70 | func() { 71 | file, err := os.Open(filePath) 72 | if err != nil { 73 | results <- HashResult{File: filePath, Err: err} 74 | return 75 | } 76 | defer file.Close() 77 | 78 | hash, err := hasher.GenerateHashFromReader(file, algo) 79 | results <- HashResult{File: filePath, Hash: hash, Err: err} 80 | }() 81 | } 82 | }() 83 | } 84 | 85 | for _, f := range files { 86 | tasks <- f 87 | } 88 | close(tasks) 89 | 90 | go func() { 91 | wg.Wait() 92 | close(results) 93 | }() 94 | 95 | return results 96 | } 97 | 98 | // CleanHashes walks a directory and removes files with extensions matching known hash algorithms. 99 | func CleanHashes(dir string, recursive bool) error { 100 | return filepath.Walk(dir, func(path string, info os.FileInfo, err error) error { 101 | if err != nil { 102 | return err 103 | } 104 | if info.IsDir() && !recursive && path != dir { 105 | return filepath.SkipDir 106 | } 107 | for _, algo := range hasher.HashAlgorithms { 108 | if strings.HasSuffix(info.Name(), "."+algo) { 109 | if err := os.Remove(path); err != nil { 110 | log.Warn().Err(err).Str("path", path).Msg("Failed to remove old hash file") 111 | } 112 | break 113 | } 114 | } 115 | return nil 116 | }) 117 | } 118 | -------------------------------------------------------------------------------- /auth/services.go: -------------------------------------------------------------------------------- 1 | package auth 2 | 3 | import ( 4 | "context" 5 | "fmt" 6 | "time" 7 | 8 | "github.com/habedi/gogg/db" 9 | "github.com/rs/zerolog/log" 10 | ) 11 | 12 | // Service orchestrates the token refresh process using its dependencies. 13 | type Service struct { 14 | Storer TokenStorer 15 | Refresher TokenRefresher 16 | } 17 | 18 | // NewService is the constructor for our auth service. 19 | func NewService(storer TokenStorer, refresher TokenRefresher) *Service { 20 | return &Service{ 21 | Storer: storer, 22 | Refresher: refresher, 23 | } 24 | } 25 | 26 | // NewServiceWithRepo constructs Service using a TokenRepository directly. 27 | func NewServiceWithRepo(tokenRepo db.TokenRepository, refresher TokenRefresher) *Service { 28 | adapter := &struct{ TokenStorer }{TokenStorer: &tokenRepoStorer{repo: tokenRepo}} 29 | return NewService(adapter.TokenStorer, refresher) 30 | } 31 | 32 | // RefreshToken is a method that handles the full token refresh logic. 33 | // Deprecated: Use RefreshTokenCtx for context-aware cancellation support. 34 | func (s *Service) RefreshToken() (*db.Token, error) { 35 | return s.RefreshTokenCtx(context.Background()) 36 | } 37 | 38 | // RefreshTokenCtx performs refresh honoring cancellation if the refresher supports it. 39 | func (s *Service) RefreshTokenCtx(ctx context.Context) (*db.Token, error) { 40 | token, err := s.Storer.GetTokenRecord() 41 | if err != nil { 42 | return nil, fmt.Errorf("failed to retrieve token record: %w", err) 43 | } 44 | 45 | valid, err := isTokenValid(token) 46 | if err != nil { 47 | return nil, fmt.Errorf("failed to check token validity: %w", err) 48 | } 49 | if valid { 50 | return token, nil 51 | } 52 | 53 | var access, refresh string 54 | var expiresIn int64 55 | if rf, ok := s.Refresher.(TokenRefresherWithCtx); ok { 56 | access, refresh, expiresIn, err = rf.PerformTokenRefreshCtx(ctx, token.RefreshToken) 57 | } else { 58 | access, refresh, expiresIn, err = s.Refresher.PerformTokenRefresh(token.RefreshToken) 59 | } 60 | if err != nil { 61 | return nil, fmt.Errorf("failed to perform token refresh via client: %w", err) 62 | } 63 | token.AccessToken = access 64 | token.RefreshToken = refresh 65 | token.ExpiresAt = time.Now().Add(time.Duration(expiresIn) * time.Second).Format(time.RFC3339) 66 | if err := s.Storer.UpsertTokenRecord(token); err != nil { 67 | return nil, fmt.Errorf("failed to save refreshed token: %w", err) 68 | } 69 | log.Info().Msg("Token refreshed and saved successfully.") 70 | return token, nil 71 | } 72 | 73 | // isTokenValid checks if the access token is still valid. 74 | func isTokenValid(token *db.Token) (bool, error) { 75 | if token == nil { 76 | return false, fmt.Errorf("token record does not exist in the database; please login first") 77 | } 78 | if token.AccessToken == "" || token.RefreshToken == "" || token.ExpiresAt == "" { 79 | return false, nil 80 | } 81 | expiresAt, err := time.Parse(time.RFC3339, token.ExpiresAt) 82 | if err != nil { 83 | log.Error().Err(err).Msgf("Failed to parse expiration time: %s", token.ExpiresAt) 84 | return false, err 85 | } 86 | return time.Now().Add(5 * time.Minute).Before(expiresAt), nil 87 | } 88 | 89 | // tokenRepoStorer adapts db.TokenRepository to TokenStorer. 90 | type tokenRepoStorer struct{ repo db.TokenRepository } 91 | 92 | func (s *tokenRepoStorer) GetTokenRecord() (*db.Token, error) { 93 | return s.repo.Get(context.Background()) 94 | } 95 | func (s *tokenRepoStorer) UpsertTokenRecord(token *db.Token) error { 96 | return s.repo.Upsert(context.Background(), token) 97 | } 98 | -------------------------------------------------------------------------------- /go.mod: -------------------------------------------------------------------------------- 1 | module github.com/habedi/gogg 2 | 3 | go 1.24.0 4 | 5 | toolchain go1.24.6 6 | 7 | require ( 8 | fyne.io/fyne/v2 v2.7.1 9 | github.com/chromedp/chromedp v0.14.2 10 | github.com/faiface/beep v1.1.0 11 | github.com/olekukonko/tablewriter v0.0.5 12 | github.com/rs/zerolog v1.34.0 13 | github.com/schollz/progressbar/v3 v3.18.0 14 | github.com/spf13/cobra v1.10.1 15 | github.com/stretchr/testify v1.11.1 16 | golang.org/x/term v0.37.0 17 | gorm.io/driver/sqlite v1.6.0 18 | gorm.io/gorm v1.31.1 19 | ) 20 | 21 | require ( 22 | fyne.io/systray v1.11.1-0.20250603113521-ca66a66d8b58 // indirect 23 | github.com/BurntSushi/toml v1.5.0 // indirect 24 | github.com/chromedp/cdproto v0.0.0-20250724212937-08a3db8b4327 // indirect 25 | github.com/chromedp/sysutil v1.1.0 // indirect 26 | github.com/davecgh/go-spew v1.1.1 // indirect 27 | github.com/fredbi/uri v1.1.1 // indirect 28 | github.com/fsnotify/fsnotify v1.9.0 // indirect 29 | github.com/fyne-io/gl-js v0.2.0 // indirect 30 | github.com/fyne-io/glfw-js v0.3.0 // indirect 31 | github.com/fyne-io/image v0.1.1 // indirect 32 | github.com/fyne-io/oksvg v0.2.0 // indirect 33 | github.com/go-gl/gl v0.0.0-20231021071112-07e5d0ea2e71 // indirect 34 | github.com/go-gl/glfw/v3.3/glfw v0.0.0-20250301202403-da16c1255728 // indirect 35 | github.com/go-json-experiment/json v0.0.0-20250725192818-e39067aee2d2 // indirect 36 | github.com/go-text/render v0.2.0 // indirect 37 | github.com/go-text/typesetting v0.3.0 // indirect 38 | github.com/gobwas/httphead v0.1.0 // indirect 39 | github.com/gobwas/pool v0.2.1 // indirect 40 | github.com/gobwas/ws v1.4.0 // indirect 41 | github.com/godbus/dbus/v5 v5.1.0 // indirect 42 | github.com/hack-pad/go-indexeddb v0.3.2 // indirect 43 | github.com/hack-pad/safejs v0.1.1 // indirect 44 | github.com/hajimehoshi/go-mp3 v0.3.0 // indirect 45 | github.com/hajimehoshi/oto v0.7.1 // indirect 46 | github.com/inconshreveable/mousetrap v1.1.0 // indirect 47 | github.com/jeandeaual/go-locale v0.0.0-20250612000132-0ef82f21eade // indirect 48 | github.com/jfreymuth/oggvorbis v1.0.1 // indirect 49 | github.com/jfreymuth/vorbis v1.0.0 // indirect 50 | github.com/jinzhu/inflection v1.0.0 // indirect 51 | github.com/jinzhu/now v1.1.5 // indirect 52 | github.com/jsummers/gobmp v0.0.0-20230614200233-a9de23ed2e25 // indirect 53 | github.com/kr/text v0.2.0 // indirect 54 | github.com/mattn/go-colorable v0.1.14 // indirect 55 | github.com/mattn/go-isatty v0.0.20 // indirect 56 | github.com/mattn/go-runewidth v0.0.16 // indirect 57 | github.com/mattn/go-sqlite3 v1.14.28 // indirect 58 | github.com/mitchellh/colorstring v0.0.0-20190213212951-d06e56a500db // indirect 59 | github.com/nfnt/resize v0.0.0-20180221191011-83c6a9932646 // indirect 60 | github.com/nicksnyder/go-i18n/v2 v2.6.0 // indirect 61 | github.com/pkg/errors v0.9.1 // indirect 62 | github.com/pmezard/go-difflib v1.0.0 // indirect 63 | github.com/rivo/uniseg v0.4.7 // indirect 64 | github.com/rymdport/portal v0.4.2 // indirect 65 | github.com/spf13/pflag v1.0.9 // indirect 66 | github.com/srwiley/oksvg v0.0.0-20221011165216-be6e8873101c // indirect 67 | github.com/srwiley/rasterx v0.0.0-20220730225603-2ab79fcdd4ef // indirect 68 | github.com/yuin/goldmark v1.7.12 // indirect 69 | golang.org/x/exp v0.0.0-20190306152737-a1d7652674e8 // indirect 70 | golang.org/x/image v0.26.0 // indirect 71 | golang.org/x/mobile v0.0.0-20231127183840-76ac6878050a // indirect 72 | golang.org/x/net v0.39.0 // indirect 73 | golang.org/x/sys v0.38.0 // indirect 74 | golang.org/x/text v0.24.0 // indirect 75 | gopkg.in/yaml.v3 v3.0.1 // indirect 76 | ) 77 | -------------------------------------------------------------------------------- /auth/service_integration_test.go: -------------------------------------------------------------------------------- 1 | package auth_test 2 | 3 | import ( 4 | "encoding/json" 5 | "net/http" 6 | "net/http/httptest" 7 | "testing" 8 | "time" 9 | 10 | "github.com/habedi/gogg/auth" 11 | "github.com/habedi/gogg/client" 12 | "github.com/habedi/gogg/db" 13 | "github.com/stretchr/testify/assert" 14 | "github.com/stretchr/testify/require" 15 | "gorm.io/driver/sqlite" 16 | "gorm.io/gorm" 17 | ) 18 | 19 | func setupTestDB(t *testing.T) { 20 | t.Helper() 21 | gormDB, err := gorm.Open(sqlite.Open("file::memory:?cache=shared"), &gorm.Config{}) 22 | require.NoError(t, err) 23 | 24 | db.Db = gormDB 25 | require.NoError(t, db.Db.AutoMigrate(&db.Token{}, &db.Game{})) 26 | } 27 | 28 | func TestRefreshToken_Integration_Success(t *testing.T) { 29 | setupTestDB(t) 30 | 31 | server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { 32 | require.Equal(t, "/token", r.URL.Path) 33 | if err := r.ParseForm(); err != nil { 34 | w.WriteHeader(http.StatusBadRequest) 35 | return 36 | } 37 | assert.Equal(t, "expired-refresh-token", r.FormValue("refresh_token")) 38 | 39 | w.Header().Set("Content-Type", "application/json") 40 | w.WriteHeader(http.StatusOK) 41 | _ = json.NewEncoder(w).Encode(map[string]interface{}{ 42 | "access_token": "new-shiny-access-token", 43 | "refresh_token": "new-shiny-refresh-token", 44 | "expires_in": 3600, 45 | }) 46 | })) 47 | defer server.Close() 48 | 49 | expiredToken := &db.Token{ 50 | AccessToken: "expired-access-token", 51 | RefreshToken: "expired-refresh-token", 52 | ExpiresAt: time.Now().Add(-1 * time.Hour).Format(time.RFC3339), 53 | } 54 | require.NoError(t, db.UpsertTokenRecord(expiredToken)) 55 | 56 | tokenRepo := db.NewTokenRepository(db.GetDB()) 57 | refresher := &client.GogClient{TokenURL: server.URL + "/token"} 58 | authService := auth.NewServiceWithRepo(tokenRepo, refresher) 59 | 60 | refreshedToken, err := authService.RefreshToken() 61 | 62 | require.NoError(t, err) 63 | assert.Equal(t, "new-shiny-access-token", refreshedToken.AccessToken) 64 | assert.Equal(t, "new-shiny-refresh-token", refreshedToken.RefreshToken) 65 | 66 | dbToken, err := db.GetTokenRecord() 67 | require.NoError(t, err) 68 | assert.Equal(t, "new-shiny-access-token", dbToken.AccessToken) 69 | } 70 | 71 | func TestRefreshToken_Integration_ApiFailure(t *testing.T) { 72 | setupTestDB(t) 73 | 74 | server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { 75 | w.Header().Set("Content-Type", "application/json") 76 | w.WriteHeader(http.StatusUnauthorized) 77 | _ = json.NewEncoder(w).Encode(map[string]string{ 78 | "error_description": "Invalid refresh token", 79 | }) 80 | })) 81 | defer server.Close() 82 | 83 | expiredToken := &db.Token{ 84 | AccessToken: "old-token", 85 | RefreshToken: "invalid-refresh", 86 | ExpiresAt: time.Now().Add(-2 * time.Hour).Format(time.RFC3339), 87 | } 88 | require.NoError(t, db.UpsertTokenRecord(expiredToken)) 89 | 90 | tokenRepo := db.NewTokenRepository(db.GetDB()) 91 | refresher := &client.GogClient{TokenURL: server.URL + "/token"} 92 | authService := auth.NewServiceWithRepo(tokenRepo, refresher) 93 | 94 | _, err := authService.RefreshToken() 95 | 96 | require.Error(t, err) 97 | assert.Contains(t, err.Error(), "Invalid refresh token") 98 | 99 | dbToken, err := db.GetTokenRecord() 100 | require.NoError(t, err) 101 | assert.Equal(t, "old-token", dbToken.AccessToken, "Token in DB should not have been updated on failure") 102 | } 103 | -------------------------------------------------------------------------------- /client/catalogue_integration_test.go: -------------------------------------------------------------------------------- 1 | //go:build integration 2 | 3 | package client 4 | 5 | import ( 6 | "context" 7 | "encoding/json" 8 | "net/http" 9 | "net/http/httptest" 10 | "os" 11 | "testing" 12 | "time" 13 | 14 | "github.com/habedi/gogg/auth" 15 | "github.com/habedi/gogg/db" 16 | "gorm.io/driver/sqlite" 17 | "gorm.io/gorm" 18 | ) 19 | 20 | type memTokenStore struct{} 21 | 22 | func (memTokenStore) GetTokenRecord() (*db.Token, error) { 23 | return &db.Token{AccessToken: "tok", RefreshToken: "ref", ExpiresAt: time.Now().Add(time.Hour).Format(time.RFC3339)}, nil 24 | } 25 | func (memTokenStore) UpsertTokenRecord(token *db.Token) error { return nil } 26 | 27 | type staticRefresher struct{} 28 | 29 | func (staticRefresher) PerformTokenRefresh(refreshToken string) (string, string, int64, error) { 30 | return "tok", "ref", 3600, nil 31 | } 32 | 33 | func setupMemDB(t *testing.T) { 34 | gormDB, err := gorm.Open(sqlite.Open("file::memory:?cache=shared"), &gorm.Config{}) 35 | if err != nil { 36 | t.Fatalf("open mem db: %v", err) 37 | } 38 | db.Db = gormDB 39 | if err := db.Db.AutoMigrate(&db.Token{}, &db.Game{}); err != nil { 40 | t.Fatalf("migrate: %v", err) 41 | } 42 | } 43 | 44 | func TestIntegration_RefreshCatalogue_WithPaginationAndEdgeCases(t *testing.T) { 45 | setupMemDB(t) 46 | 47 | ownedPage1 := []int{1, 2} 48 | ownedPage2 := []int{3} 49 | 50 | server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { 51 | switch r.URL.Path { 52 | case "/user/data/games": 53 | if r.URL.Query().Get("page") == "2" { 54 | json.NewEncoder(w).Encode(map[string]interface{}{"owned": ownedPage2}) 55 | return 56 | } 57 | json.NewEncoder(w).Encode(map[string]interface{}{ 58 | "owned": ownedPage1, 59 | "next": "/user/data/games?page=2", 60 | }) 61 | case "/account/gameDetails/1.json": 62 | json.NewEncoder(w).Encode(map[string]interface{}{"title": "Game One", "downloads": [][]interface{}{}}) 63 | case "/account/gameDetails/2.json": 64 | json.NewEncoder(w).Encode(map[string]interface{}{"downloads": [][]interface{}{}}) 65 | case "/account/gameDetails/3.json": 66 | list := make([][]interface{}, 3) 67 | for i := 0; i < 3; i++ { 68 | list[i] = []interface{}{"English", map[string]interface{}{"windows": []map[string]interface{}{{"name": "setup.exe", "size": "1GB"}}}} 69 | } 70 | json.NewEncoder(w).Encode(map[string]interface{}{"title": "Game Three", "downloads": list}) 71 | default: 72 | http.NotFound(w, r) 73 | } 74 | })) 75 | defer server.Close() 76 | 77 | os.Setenv("GOGG_EMBED_BASE", server.URL) 78 | defer os.Unsetenv("GOGG_EMBED_BASE") 79 | 80 | st := memTokenStore{} 81 | rf := staticRefresher{} 82 | svc := auth.NewService(st, rf) 83 | 84 | repo := db.NewGameRepository(db.GetDB()) 85 | ctx := context.Background() 86 | err := RefreshCatalogue(ctx, svc, repo, 3, nil) 87 | if err != nil && err != context.Canceled { 88 | t.Fatalf("refresh failed: %v", err) 89 | } 90 | 91 | g1, err := db.GetGameByID(1) 92 | if err != nil || g1 == nil || g1.Title != "Game One" { 93 | t.Fatalf("game 1 not stored correctly: %+v err=%v", g1, err) 94 | } 95 | g3, err := db.GetGameByID(3) 96 | if err != nil || g3 == nil || g3.Title != "Game Three" { 97 | t.Fatalf("game 3 not stored correctly: %+v err=%v", g3, err) 98 | } 99 | g2, err := db.GetGameByID(2) 100 | if err != nil { 101 | t.Fatalf("get 2: %v", err) 102 | } 103 | if g2 != nil && g2.Title != "" { 104 | t.Fatalf("game 2 should have empty or missing title, got: %+v", g2) 105 | } 106 | } 107 | -------------------------------------------------------------------------------- /scripts/test_gogg_cli.sh: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | 3 | # test_gogg_cli.sh - A script to test the CLI API of gogg 4 | 5 | # Set colors for better readability 6 | GREEN='\033[0;32m' 7 | YELLOW='\033[1;33m' 8 | BLUE='\033[0;34m' 9 | NC='\033[0m' # No Color 10 | 11 | # Find the gogg executable 12 | GOGG=$(command -v bin/gogg || command -v gogg || command -v ./gogg) 13 | 14 | if [ -z "$GOGG" ]; then 15 | echo "Error: gogg executable not found. Make sure it's in your PATH or in the current directory." 16 | exit 1 17 | fi 18 | 19 | echo -e "${GREEN}=== Testing gogg CLI API ===${NC}" 20 | 21 | # Function to run a test and report result 22 | run_test() { 23 | local test_name="$1" 24 | local command="$2" 25 | 26 | echo -e "\n${YELLOW}Testing: ${test_name}${NC}" 27 | echo -e "${BLUE}Command: ${command}${NC}" 28 | 29 | # Run the command and capture exit code 30 | eval "$command" 31 | local exit_code=$? 32 | 33 | if [ $exit_code -eq 0 ]; then 34 | echo -e "${GREEN}✓ Test passed (exit code: $exit_code)${NC}" 35 | else 36 | echo -e "\033[0;31m✗ Test failed (exit code: $exit_code)\033[0m" 37 | fi 38 | 39 | # Add a small delay between tests 40 | sleep 1 41 | } 42 | 43 | # Test 1: Help command 44 | run_test "Help command" "$GOGG --help" 45 | 46 | # Test 2: Version command 47 | run_test "Version command" "$GOGG version" 48 | 49 | # Test 3: Catalogue commands 50 | run_test "Catalogue help" "$GOGG catalogue --help" 51 | run_test "Catalogue refresh" "$GOGG catalogue refresh" 52 | run_test "Catalogue search" "$GOGG catalogue search 'Witcher'" 53 | run_test "Catalogue export" "$GOGG catalogue export ./ --format=csv" 54 | 55 | # Test 4: File commands 56 | run_test "File help" "$GOGG file --help" 57 | if [ -d "./games" ]; then 58 | run_test "File hash" "$GOGG file hash ./games --algo=md5" 59 | # Get a game ID from the catalogue if available 60 | GAME_ID=$(tail -n +2 gogg_catalogue_*.csv 2>/dev/null | head -n 1 | cut -d, -f1) 61 | if [ -n "$GAME_ID" ]; then 62 | run_test "File size" "$GOGG file size $GAME_ID --platform=windows --lang=en" 63 | fi 64 | else 65 | echo -e "\033[0;33mSkipping file hash test as ./games directory doesn't exist${NC}" 66 | fi 67 | 68 | # Test 5: Download command (with --dry-run if available to avoid actual downloads) 69 | run_test "Download help" "$GOGG download --help" 70 | # Get a game ID from the catalogue if available 71 | GAME_ID=$(tail -n +2 gogg_catalogue_*.csv 2>/dev/null | head -n 1 | cut -d, -f1) 72 | if [ -n "$GAME_ID" ]; then 73 | # Check if --dry-run is supported 74 | if $GOGG download --help | grep -q "dry-run"; then 75 | run_test "Download dry run" "$GOGG download $GAME_ID ./games --platform=windows --lang=en --dry-run=true" 76 | else 77 | echo -e "\033[0;33mSkipping download test with actual game ID as --dry-run is not supported${NC}" 78 | fi 79 | else 80 | # Use a sample ID for testing 81 | run_test "Download command syntax" "$GOGG download 1234567890 ./games --platform=windows --lang=en --threads=4 --dlcs=true --extras=false --resume=true --flatten=true" 82 | fi 83 | 84 | # Test 6: GUI command (just check if it exists, don't actually launch it) 85 | if $GOGG --help | grep -q "gui"; then 86 | run_test "GUI help" "$GOGG gui --help" 87 | else 88 | echo -e "\033[0;33mSkipping GUI test as the command doesn't exist${NC}" 89 | fi 90 | 91 | # Test 7: Login command (just check help, don't actually login) 92 | if $GOGG --help | grep -q "login"; then 93 | run_test "Login help" "$GOGG login --help" 94 | else 95 | echo -e "\033[0;33mSkipping login test as the command doesn't exist${NC}" 96 | fi 97 | 98 | echo -e "\n${GREEN}=== All tests completed ===${NC}" 99 | -------------------------------------------------------------------------------- /cmd/file_test.go: -------------------------------------------------------------------------------- 1 | package cmd 2 | 3 | import ( 4 | "bytes" 5 | "io" 6 | "os" 7 | "path/filepath" 8 | "strings" 9 | "testing" 10 | 11 | "github.com/habedi/gogg/db" 12 | "gorm.io/driver/sqlite" 13 | "gorm.io/gorm" 14 | "gorm.io/gorm/logger" 15 | ) 16 | 17 | func TestFileHashCmd_PrintsHashesAndSavesToFiles(t *testing.T) { 18 | dir := t.TempDir() 19 | fExcluded := filepath.Join(dir, "a.txt") // excluded by DefaultHashExclusions 20 | fIncluded := filepath.Join(dir, "b.bin") // included 21 | if err := os.WriteFile(fExcluded, []byte("hello"), 0644); err != nil { 22 | t.Fatal(err) 23 | } 24 | if err := os.WriteFile(fIncluded, []byte("world"), 0644); err != nil { 25 | t.Fatal(err) 26 | } 27 | 28 | cmd := hashCmd() 29 | cmd.SetArgs([]string{dir, "-a", "md5", "-s", "-r"}) 30 | buf := new(bytes.Buffer) 31 | cmd.SetOut(buf) 32 | cmd.SetErr(buf) 33 | cmd.Execute() 34 | 35 | // Included file should have a hash file 36 | if _, err := os.Stat(fIncluded + ".md5"); err != nil { 37 | t.Fatalf("expected %s to exist: %v", fIncluded+".md5", err) 38 | } 39 | // Excluded file should not have a hash file 40 | if _, err := os.Stat(fExcluded + ".md5"); err == nil { 41 | t.Fatalf("did not expect %s to exist", fExcluded+".md5") 42 | } 43 | } 44 | 45 | func TestFileHashCmd_InvalidAlgo(t *testing.T) { 46 | cmd := hashCmd() 47 | cmd.SetArgs([]string{"/does/not/matter", "-a", "bad"}) 48 | buf := new(bytes.Buffer) 49 | cmd.SetOut(buf) 50 | cmd.SetErr(buf) 51 | cmd.Execute() 52 | } 53 | 54 | func TestFileHashCmd_InvalidAlgo_ShowsError(t *testing.T) { 55 | cmd := hashCmd() 56 | cmd.SetArgs([]string{"/irrelevant", "-a", "notanalgo"}) 57 | buf := new(bytes.Buffer) 58 | cmd.SetOut(buf) 59 | cmd.SetErr(buf) 60 | cmd.Execute() 61 | if !strings.Contains(buf.String(), "Unsupported hash algorithm") { 62 | t.Fatalf("expected unsupported algo message, got: %s", buf.String()) 63 | } 64 | } 65 | 66 | func TestFileHashCmd_InvalidThreads(t *testing.T) { 67 | cmd := hashCmd() 68 | cmd.SetArgs([]string{"/irrelevant", "-t", "99"}) 69 | buf := new(bytes.Buffer) 70 | cmd.SetOut(buf) 71 | cmd.SetErr(buf) 72 | cmd.Execute() 73 | if !strings.Contains(buf.String(), "Invalid thread count") { 74 | t.Fatalf("expected invalid thread count message, got: %s", buf.String()) 75 | } 76 | } 77 | 78 | func setupMemDB(t *testing.T) { 79 | t.Helper() 80 | gormDB, err := gorm.Open(sqlite.Open("file::memory:?cache=shared"), &gorm.Config{Logger: logger.Default.LogMode(logger.Silent)}) 81 | if err != nil { 82 | t.Fatalf("open db: %v", err) 83 | } 84 | db.Db = gormDB 85 | if err := db.Db.AutoMigrate(&db.Game{}, &db.Token{}); err != nil { 86 | t.Fatalf("migrate: %v", err) 87 | } 88 | } 89 | 90 | // captureStdout runs f while capturing os.Stdout and returns captured output. 91 | func captureStdout(f func()) string { 92 | old := os.Stdout 93 | r, w, _ := os.Pipe() 94 | os.Stdout = w 95 | f() 96 | _ = w.Close() 97 | os.Stdout = old 98 | out, _ := io.ReadAll(r) 99 | _ = r.Close() 100 | return string(out) 101 | } 102 | 103 | func TestSizeCmd_HappyPathAndUnits(t *testing.T) { 104 | setupMemDB(t) 105 | raw := `{"title":"CLI Size Game","downloads":[["English", {"windows":[{"name":"setup.exe","size":"1 MB"}]}]],"extras":[],"dlcs":[]}` 106 | if err := db.PutInGame(991, "CLI Size Game", raw); err != nil { 107 | t.Skipf("skipping: %v", err) 108 | } 109 | 110 | for _, unit := range []string{"gb", "mb", "kb", "b"} { 111 | cmd := sizeCmd() 112 | cmd.SetArgs([]string{"991", "--lang", "en", "--platform", "windows", "--unit", unit}) 113 | buf := new(bytes.Buffer) 114 | cmd.SetOut(buf) 115 | cmd.SetErr(buf) 116 | out := captureStdout(func() { cmd.Execute() }) 117 | if !strings.Contains(out, "Total download size:") { 118 | t.Fatalf("expected size output for unit %s, got: %s", unit, out) 119 | } 120 | } 121 | } 122 | -------------------------------------------------------------------------------- /db/game.go: -------------------------------------------------------------------------------- 1 | package db 2 | 3 | import ( 4 | "errors" 5 | "fmt" 6 | 7 | "github.com/rs/zerolog/log" 8 | "gorm.io/gorm" 9 | "gorm.io/gorm/clause" 10 | ) 11 | 12 | // Game represents a game record in the catalogue. 13 | type Game struct { 14 | ID int `gorm:"primaryKey" json:"id"` 15 | Title string `gorm:"index" json:"title"` // Indexed for faster queries 16 | Data string `json:"data"` 17 | } 18 | 19 | // PutInGame inserts or updates a game record in the catalogue. 20 | // It takes the game ID, title, and data as parameters and returns an error if the operation fails. 21 | // Deprecated: Use GameRepository.Put with context for better cancellation support. 22 | func PutInGame(id int, title, data string) error { 23 | game := Game{ 24 | ID: id, 25 | Title: title, 26 | Data: data, 27 | } 28 | if Db == nil { 29 | return fmt.Errorf("database connection is not initialized") 30 | } 31 | if err := Db.Clauses( 32 | clause.OnConflict{ 33 | UpdateAll: true, 34 | }, 35 | ).Create(&game).Error; err != nil { 36 | log.Error().Err(err).Msgf("Failed to upsert game with ID %d", game.ID) 37 | return err 38 | } 39 | 40 | log.Info().Msgf("Game upserted successfully: ID=%d, Title=%s", game.ID, game.Title) 41 | return nil 42 | } 43 | 44 | // EmptyCatalogue removes all records from the game catalogue. 45 | // It returns an error if the operation fails. 46 | // Deprecated: Use GameRepository.Clear with context for better cancellation support. 47 | func EmptyCatalogue() error { 48 | if Db == nil { 49 | return fmt.Errorf("database connection is not initialized") 50 | } 51 | if err := Db.Session(&gorm.Session{AllowGlobalUpdate: true}).Unscoped().Delete(&Game{}).Error; err != nil { 52 | log.Error().Err(err).Msg("Failed to empty game catalogue") 53 | return err 54 | } 55 | log.Info().Msg("Game catalogue emptied successfully") 56 | return nil 57 | } 58 | 59 | // GetCatalogue retrieves all games in the catalogue. 60 | // It returns a slice of Game objects and an error if the operation fails. 61 | // Deprecated: Use GameRepository.List with context for better cancellation support. 62 | func GetCatalogue() ([]Game, error) { 63 | if Db == nil { 64 | return nil, fmt.Errorf("database connection is not initialized") 65 | } 66 | var games []Game 67 | if err := Db.Find(&games).Error; err != nil { 68 | log.Error().Err(err).Msg("Failed to fetch games from the database") 69 | return nil, err 70 | } 71 | log.Info().Msgf("Retrieved %d games from the catalogue", len(games)) 72 | return games, nil 73 | } 74 | 75 | // GetGameByID retrieves a game from the catalogue by its ID. 76 | // It takes the game ID as a parameter and returns a pointer to the Game object and an error if the operation fails. 77 | // Deprecated: Use GameRepository.GetByID with context for better cancellation support. 78 | func GetGameByID(id int) (*Game, error) { 79 | if Db == nil { 80 | return nil, fmt.Errorf("database connection is not initialized") 81 | } 82 | var game Game 83 | if err := Db.First(&game, "id = ?", id).Error; err != nil { 84 | if errors.Is(err, gorm.ErrRecordNotFound) { 85 | return nil, nil 86 | } 87 | return nil, fmt.Errorf("failed to retrieve game with ID %d: %w", id, err) 88 | } 89 | return &game, nil 90 | } 91 | 92 | // SearchGamesByName searches for games in the catalogue by name. 93 | // It takes the game name as a parameter and returns a slice of Game objects and an error if the operation fails. 94 | // Deprecated: Use GameRepository.SearchByTitle with context for better cancellation support. 95 | func SearchGamesByName(name string) ([]Game, error) { 96 | if Db == nil { 97 | return nil, fmt.Errorf("database connection is not initialized") 98 | } 99 | var games []Game 100 | if err := Db.Where("title LIKE ?", "%"+name+"%").Find(&games).Error; err != nil { 101 | log.Error().Err(err).Msgf("Failed to search games by name: %s", name) 102 | return nil, err 103 | } 104 | 105 | return games, nil 106 | } 107 | -------------------------------------------------------------------------------- /client/data_test.go: -------------------------------------------------------------------------------- 1 | package client_test 2 | 3 | import ( 4 | "encoding/json" 5 | "testing" 6 | 7 | "github.com/habedi/gogg/client" 8 | "github.com/stretchr/testify/assert" 9 | "github.com/stretchr/testify/require" 10 | ) 11 | 12 | // UnmarshalGameData unmarshals the provided JSON string into a Game object. 13 | // It takes a testing.T object and a JSON string as parameters and returns a pointer to the Game object. 14 | func UnmarshalGameData(t *testing.T, jsonData string) *client.Game { 15 | var game client.Game 16 | err := json.Unmarshal([]byte(jsonData), &game) 17 | require.NoError(t, err) 18 | return &game 19 | } 20 | 21 | // TestParsesDownloadsCorrectly tests the parsing of downloads from the JSON data. 22 | func TestParsesDownloadsCorrectly(t *testing.T) { 23 | jsonData := `{ 24 | "title": "Test Game", 25 | "backgroundImage": "https://example.com/background.jpg", 26 | "downloads": [ 27 | ["English", {"windows": [{"name": "setup.exe", "size": "1GB"}]}] 28 | ], 29 | "extras": [], 30 | "dlcs": [] 31 | }` 32 | game := UnmarshalGameData(t, jsonData) 33 | 34 | assert.Equal(t, "Test Game", game.Title) 35 | assert.Len(t, game.Downloads, 1) 36 | assert.Equal(t, "English", game.Downloads[0].Language) 37 | assert.Len(t, game.Downloads[0].Platforms.Windows, 1) 38 | assert.Equal(t, "setup.exe", game.Downloads[0].Platforms.Windows[0].Name) 39 | assert.Equal(t, "1GB", game.Downloads[0].Platforms.Windows[0].Size) 40 | } 41 | 42 | // TestParsesDLCsCorrectly tests the parsing of DLCs from the JSON data. 43 | func TestParsesDLCsCorrectly(t *testing.T) { 44 | jsonData := `{ 45 | "title": "Test Game", 46 | "downloads": [], 47 | "extras": [], 48 | "dlcs": [ 49 | { 50 | "title": "Test DLC", 51 | "downloads": [ 52 | ["English", {"windows": [{"name": "dlc_setup.exe", "size": "500MB"}]}] 53 | ] 54 | } 55 | ] 56 | }` 57 | game := UnmarshalGameData(t, jsonData) 58 | 59 | assert.Len(t, game.DLCs, 1) 60 | assert.Equal(t, "Test DLC", game.DLCs[0].Title) 61 | assert.Len(t, game.DLCs[0].ParsedDownloads, 1) 62 | assert.Equal(t, "English", game.DLCs[0].ParsedDownloads[0].Language) 63 | assert.Len(t, game.DLCs[0].ParsedDownloads[0].Platforms.Windows, 1) 64 | assert.Equal(t, "dlc_setup.exe", game.DLCs[0].ParsedDownloads[0].Platforms.Windows[0].Name) 65 | assert.Equal(t, "500MB", game.DLCs[0].ParsedDownloads[0].Platforms.Windows[0].Size) 66 | } 67 | 68 | // TestIgnoresInvalidDownloads tests that invalid downloads are ignored during parsing. 69 | func TestIgnoresInvalidDownloads(t *testing.T) { 70 | jsonData := `{ 71 | "title": "Test Game", 72 | "downloads": [ 73 | ["English", {"windows": [{"name": "setup.exe", "size": "1GB"}]}], 74 | ["Invalid"] 75 | ], 76 | "extras": [], 77 | "dlcs": [] 78 | }` 79 | game := UnmarshalGameData(t, jsonData) 80 | 81 | assert.Len(t, game.Downloads, 1) 82 | assert.Equal(t, "English", game.Downloads[0].Language) 83 | assert.Len(t, game.Downloads[0].Platforms.Windows, 1) 84 | assert.Equal(t, "setup.exe", game.Downloads[0].Platforms.Windows[0].Name) 85 | assert.Equal(t, "1GB", game.Downloads[0].Platforms.Windows[0].Size) 86 | } 87 | 88 | // TestParsesExtrasCorrectly tests the parsing of extras from the JSON data. 89 | func TestParsesExtrasCorrectly(t *testing.T) { 90 | jsonData := `{ 91 | "title": "Test Game", 92 | "downloads": [], 93 | "extras": [ 94 | {"name": "Soundtrack", "size": "200MB", "manualUrl": "http://example.com/soundtrack"} 95 | ], 96 | "dlcs": [] 97 | }` 98 | game := UnmarshalGameData(t, jsonData) 99 | 100 | assert.Len(t, game.Extras, 1) 101 | assert.Equal(t, "Soundtrack", game.Extras[0].Name) 102 | assert.Equal(t, "200MB", game.Extras[0].Size) 103 | assert.Equal(t, "http://example.com/soundtrack", game.Extras[0].ManualURL) 104 | } 105 | 106 | // TestHandlesEmptyDownloads tests that the Game object handles empty downloads correctly. 107 | func TestHandlesEmptyDownloads(t *testing.T) { 108 | jsonData := `{ 109 | "title": "Test Game", 110 | "downloads": [], 111 | "extras": [], 112 | "dlcs": [] 113 | }` 114 | game := UnmarshalGameData(t, jsonData) 115 | 116 | assert.Equal(t, "Test Game", game.Title) 117 | assert.Empty(t, game.Downloads) 118 | assert.Empty(t, game.Extras) 119 | assert.Empty(t, game.DLCs) 120 | } 121 | -------------------------------------------------------------------------------- /gui/actions.go: -------------------------------------------------------------------------------- 1 | package gui 2 | 3 | import ( 4 | "context" 5 | "encoding/json" 6 | "errors" 7 | "fmt" 8 | "strings" 9 | 10 | "fyne.io/fyne/v2" 11 | "fyne.io/fyne/v2/container" 12 | "fyne.io/fyne/v2/dialog" 13 | "fyne.io/fyne/v2/storage" 14 | "fyne.io/fyne/v2/widget" 15 | "github.com/habedi/gogg/auth" 16 | "github.com/habedi/gogg/client" 17 | "github.com/habedi/gogg/db" 18 | ) 19 | 20 | func RefreshCatalogueAction(win fyne.Window, authService *auth.Service, onFinish func()) { 21 | progress := widget.NewProgressBar() 22 | statusLabel := widget.NewLabel("Preparing to refresh...") 23 | ctx, cancel := context.WithCancel(context.Background()) 24 | content := container.NewVBox(statusLabel, progress, widget.NewButton("Cancel", cancel)) 25 | dlg := dialog.NewCustom("Refreshing Catalogue", "Hide", content, win) 26 | dlg.Resize(fyne.NewSize(400, 200)) 27 | dlg.Show() 28 | 29 | go func() { 30 | progressCb := func(p float64) { 31 | runOnMain(func() { 32 | statusLabel.SetText("Populating with new data...") 33 | progress.SetValue(p) 34 | }) 35 | } 36 | 37 | repo := db.NewGameRepository(db.GetDB()) 38 | err := client.RefreshCatalogue(ctx, authService, repo, 10, progressCb) 39 | 40 | runOnMain(func() { 41 | dlg.Hide() 42 | 43 | if errors.Is(err, context.Canceled) { 44 | games, dbErr := repo.List(context.Background()) 45 | var msg string 46 | if dbErr != nil { 47 | msg = "Refresh was cancelled. Could not retrieve partial game count." 48 | } else { 49 | msg = fmt.Sprintf("Refresh was cancelled.\n%d games were loaded before stopping.", len(games)) 50 | } 51 | dialog.ShowInformation("Refresh Cancelled", msg, win) 52 | SignalCatalogueUpdated() // Signal that a partial update occurred 53 | } else if err != nil { 54 | showErrorDialog(win, "Failed to refresh catalogue", err) 55 | } else { 56 | games, dbErr := repo.List(context.Background()) 57 | if dbErr != nil { 58 | dialog.ShowInformation("Success", "Successfully refreshed catalogue.", win) 59 | } else { 60 | successMsg := fmt.Sprintf("Successfully refreshed catalogue.\nYour library now contains %d games.", len(games)) 61 | dialog.ShowInformation("Success", successMsg, win) 62 | } 63 | SignalCatalogueUpdated() // Signal that the update is complete 64 | } 65 | 66 | onFinish() 67 | }) 68 | }() 69 | } 70 | 71 | func ExportCatalogueAction(win fyne.Window, format string) { 72 | defaultName := fmt.Sprintf("gogg_catalogue.%s", format) 73 | fileDialog := dialog.NewFileSave(func(uc fyne.URIWriteCloser, err error) { 74 | if err != nil { 75 | showErrorDialog(win, "File save error", err) 76 | return 77 | } 78 | if uc == nil { 79 | return 80 | } 81 | defer uc.Close() 82 | 83 | games, err := db.GetCatalogue() 84 | if err != nil { 85 | showErrorDialog(win, "Failed to read catalogue from database", err) 86 | return 87 | } 88 | if len(games) == 0 { 89 | dialog.ShowInformation("Info", "Catalogue is empty. Nothing to export.", win) 90 | return 91 | } 92 | 93 | var exportErr error 94 | if format == "json" { 95 | enc := json.NewEncoder(uc) 96 | enc.SetIndent("", " ") 97 | exportErr = enc.Encode(games) 98 | } else { // csv 99 | if _, err := fmt.Fprintln(uc, "ID,Title"); err != nil { 100 | exportErr = err 101 | } else { 102 | for _, g := range games { 103 | title := strings.ReplaceAll(g.Title, "\"", "\"\"") 104 | if _, err := fmt.Fprintf(uc, "%d,\"%s\"\n", g.ID, title); err != nil { 105 | exportErr = err 106 | break 107 | } 108 | } 109 | } 110 | } 111 | 112 | if exportErr != nil { 113 | showErrorDialog(win, "Failed to write export file", exportErr) 114 | } else { 115 | dialog.ShowInformation("Success", "Data exported successfully.", win) 116 | } 117 | }, win) 118 | fileDialog.SetFileName(defaultName) 119 | fileDialog.SetFilter(storage.NewExtensionFileFilter([]string{"." + format})) 120 | fileDialog.Resize(fyne.NewSize(800, 600)) 121 | fileDialog.Show() 122 | } 123 | 124 | func showErrorDialog(win fyne.Window, msg string, err error) { 125 | detail := msg 126 | if err != nil { 127 | detail = fmt.Sprintf("%s\nError: %v", msg, err) 128 | } 129 | d := dialog.NewError(errors.New(detail), win) 130 | d.Show() 131 | } 132 | -------------------------------------------------------------------------------- /db/token_test.go: -------------------------------------------------------------------------------- 1 | package db_test 2 | 3 | import ( 4 | "testing" 5 | 6 | "github.com/habedi/gogg/db" 7 | "github.com/stretchr/testify/assert" 8 | "github.com/stretchr/testify/require" 9 | "gorm.io/driver/sqlite" 10 | "gorm.io/gorm" 11 | "gorm.io/gorm/logger" 12 | ) 13 | 14 | // setupTestDBForToken sets up an in-memory SQLite database for testing purposes. 15 | // It returns a pointer to the gorm.DB instance. 16 | func setupTestDBForToken(t *testing.T) *gorm.DB { 17 | dBOject, err := gorm.Open(sqlite.Open(":memory:"), &gorm.Config{ 18 | Logger: logger.Default.LogMode(logger.Silent), 19 | }) 20 | require.NoError(t, err) 21 | require.NoError(t, dBOject.AutoMigrate(&db.Token{})) 22 | return dBOject 23 | } 24 | 25 | // TestGetTokenRecord_ReturnsToken tests the retrieval of a token record from the database. 26 | func TestGetTokenRecord_ReturnsToken(t *testing.T) { 27 | testDB := setupTestDBForToken(t) 28 | db.Db = testDB 29 | 30 | token := &db.Token{AccessToken: "access_token", RefreshToken: "refresh_token", ExpiresAt: "expires_at"} 31 | err := db.UpsertTokenRecord(token) 32 | require.NoError(t, err) 33 | 34 | retrievedToken, err := db.GetTokenRecord() 35 | require.NoError(t, err) 36 | assert.NotNil(t, retrievedToken) 37 | assert.Equal(t, "access_token", retrievedToken.AccessToken) 38 | assert.Equal(t, "refresh_token", retrievedToken.RefreshToken) 39 | assert.Equal(t, "expires_at", retrievedToken.ExpiresAt) 40 | } 41 | 42 | // TestGetTokenRecord_ReturnsNilForNoToken tests that GetTokenRecord returns nil when no token is found. 43 | func TestGetTokenRecord_ReturnsNilForNoToken(t *testing.T) { 44 | testDB := setupTestDBForToken(t) 45 | db.Db = testDB 46 | 47 | retrievedToken, err := db.GetTokenRecord() 48 | require.NoError(t, err) 49 | assert.Nil(t, retrievedToken) 50 | } 51 | 52 | // TestGetTokenRecord_ReturnsErrorForUninitializedDB tests that GetTokenRecord returns an error when the database is not initialized. 53 | func TestGetTokenRecord_ReturnsErrorForUninitializedDB(t *testing.T) { 54 | db.Db = nil 55 | 56 | retrievedToken, err := db.GetTokenRecord() 57 | assert.Error(t, err) 58 | assert.Nil(t, retrievedToken) 59 | } 60 | 61 | // TestUpsertTokenRecord_InsertsNewToken tests the insertion of a new token record into the database. 62 | func TestUpsertTokenRecord_InsertsNewToken(t *testing.T) { 63 | testDB := setupTestDBForToken(t) 64 | db.Db = testDB 65 | 66 | token := &db.Token{AccessToken: "access_token", RefreshToken: "refresh_token", ExpiresAt: "expires_at"} 67 | err := db.UpsertTokenRecord(token) 68 | require.NoError(t, err) 69 | 70 | var retrievedToken db.Token 71 | err = testDB.First(&retrievedToken, "1 = 1").Error 72 | require.NoError(t, err) 73 | assert.Equal(t, "access_token", retrievedToken.AccessToken) 74 | assert.Equal(t, "refresh_token", retrievedToken.RefreshToken) 75 | assert.Equal(t, "expires_at", retrievedToken.ExpiresAt) 76 | } 77 | 78 | // TestUpsertTokenRecord_UpdatesExistingToken tests the update of an existing token record in the database. 79 | func TestUpsertTokenRecord_UpdatesExistingToken(t *testing.T) { 80 | testDB := setupTestDBForToken(t) 81 | db.Db = testDB 82 | 83 | token := &db.Token{AccessToken: "access_token", RefreshToken: "refresh_token", ExpiresAt: "expires_at"} 84 | err := db.UpsertTokenRecord(token) 85 | require.NoError(t, err) 86 | 87 | updatedToken := &db.Token{AccessToken: "new_access_token", RefreshToken: "new_refresh_token", ExpiresAt: "new_expires_at"} 88 | err = db.UpsertTokenRecord(updatedToken) 89 | require.NoError(t, err) 90 | 91 | var retrievedToken db.Token 92 | err = testDB.First(&retrievedToken, "1 = 1").Error 93 | require.NoError(t, err) 94 | assert.Equal(t, "new_access_token", retrievedToken.AccessToken) 95 | assert.Equal(t, "new_refresh_token", retrievedToken.RefreshToken) 96 | assert.Equal(t, "new_expires_at", retrievedToken.ExpiresAt) 97 | } 98 | 99 | // TestUpsertTokenRecord_ReturnsErrorForUninitializedDB tests that UpsertTokenRecord returns an error when the database is not initialized. 100 | func TestUpsertTokenRecord_ReturnsErrorForUninitializedDB(t *testing.T) { 101 | db.Db = nil 102 | 103 | token := &db.Token{AccessToken: "access_token", RefreshToken: "refresh_token", ExpiresAt: "expires_at"} 104 | err := db.UpsertTokenRecord(token) 105 | assert.Error(t, err) 106 | } 107 | -------------------------------------------------------------------------------- /gui/theme.go: -------------------------------------------------------------------------------- 1 | package gui 2 | 3 | import ( 4 | "image/color" 5 | 6 | "fyne.io/fyne/v2" 7 | "fyne.io/fyne/v2/theme" 8 | ) 9 | 10 | var colorGoggBlue = &color.NRGBA{R: 0x0, G: 0x78, B: 0xD4, A: 0xff} 11 | 12 | // GoggTheme defines a custom theme that supports color variants, custom fonts, and sizes. 13 | type GoggTheme struct { 14 | fyne.Theme 15 | variant *fyne.ThemeVariant // Pointer to allow for nil (system default) 16 | 17 | regular, bold, italic, boldItalic, monospace fyne.Resource 18 | textSize float32 19 | } 20 | 21 | // Color overrides the default to use our forced variant and custom colors. 22 | func (t *GoggTheme) Color(name fyne.ThemeColorName, v fyne.ThemeVariant) color.Color { 23 | // Determine the final variant to use (forced or system) 24 | finalVariant := v 25 | if t.variant != nil { 26 | finalVariant = *t.variant 27 | } 28 | 29 | // Custom color overrides 30 | switch name { 31 | case theme.ColorNamePrimary: 32 | return colorGoggBlue 33 | case theme.ColorNameFocus: 34 | return colorGoggBlue 35 | case theme.ColorNameSeparator: 36 | if finalVariant == theme.VariantDark { 37 | return &color.NRGBA{R: 0x4A, B: 0x4A, G: 0x4A, A: 0xff} // Darker gray for dark mode 38 | } 39 | return &color.NRGBA{R: 0xD0, B: 0xD0, G: 0xD0, A: 0xff} // Lighter gray for light mode 40 | } 41 | 42 | // Fallback to the default theme for all other colors, using the correct variant. 43 | return t.Theme.Color(name, finalVariant) 44 | } 45 | 46 | // Icon demonstrates how to override a default Fyne icon. 47 | func (t *GoggTheme) Icon(name fyne.ThemeIconName) fyne.Resource { 48 | // Example: Override the settings icon. 49 | // To make this work, you would need to: 50 | // 1. Add a "custom_settings.svg" file to your assets. 51 | // 2. Embed it in `gui/assets.go` like the other assets. 52 | // 3. Uncomment the following lines. 53 | // 54 | // if name == theme.IconNameSettings { 55 | // return YourCustomSettingsIconResource 56 | // } 57 | 58 | // Fallback to the default theme for all other icons 59 | return t.Theme.Icon(name) 60 | } 61 | 62 | func (t *GoggTheme) Font(style fyne.TextStyle) fyne.Resource { 63 | if t.regular == nil { 64 | return t.Theme.Font(style) // Fallback to default theme's font 65 | } 66 | 67 | if style.Monospace && t.monospace != nil { 68 | return t.monospace 69 | } 70 | if style.Bold && style.Italic && t.boldItalic != nil { 71 | return t.boldItalic 72 | } 73 | if style.Bold && t.bold != nil { 74 | return t.bold 75 | } 76 | if style.Italic && t.italic != nil { 77 | return t.italic 78 | } 79 | return t.regular 80 | } 81 | 82 | func (t *GoggTheme) Size(name fyne.ThemeSizeName) float32 { 83 | if t.textSize > 0 { 84 | if name == theme.SizeNameText { 85 | return t.textSize 86 | } 87 | } 88 | return t.Theme.Size(name) 89 | } 90 | 91 | // CreateThemeFromPreferences reads all UI preferences and constructs the appropriate theme. 92 | func CreateThemeFromPreferences() fyne.Theme { 93 | prefs := fyne.CurrentApp().Preferences() 94 | variantName := prefs.StringWithFallback("theme", "System Default") 95 | fontName := prefs.StringWithFallback("fontName", "System Default") 96 | sizeName := prefs.StringWithFallback("fontSize", "Normal") 97 | 98 | customTheme := &GoggTheme{Theme: theme.DefaultTheme()} 99 | 100 | // Set color variant 101 | switch variantName { 102 | case "Light": 103 | lightVariant := theme.VariantLight 104 | customTheme.variant = &lightVariant 105 | case "Dark": 106 | darkVariant := theme.VariantDark 107 | customTheme.variant = &darkVariant 108 | } 109 | 110 | // Set font family and weight 111 | switch fontName { 112 | case "JetBrains Mono": 113 | customTheme.regular = JetBrainsMonoRegular 114 | customTheme.bold = JetBrainsMonoBold 115 | customTheme.monospace = JetBrainsMonoRegular 116 | case "JetBrains Mono Bold": 117 | customTheme.regular = JetBrainsMonoBold 118 | customTheme.bold = JetBrainsMonoBold // It's already bold, so bold style is the same 119 | customTheme.monospace = JetBrainsMonoBold 120 | } 121 | 122 | // Set size 123 | switch sizeName { 124 | case "Small": 125 | customTheme.textSize = 12 126 | case "Normal": 127 | customTheme.textSize = 14 128 | case "Large": 129 | customTheme.textSize = 16 130 | case "Extra Large": 131 | customTheme.textSize = 18 132 | } 133 | 134 | return customTheme 135 | } 136 | -------------------------------------------------------------------------------- /client/data.go: -------------------------------------------------------------------------------- 1 | package client 2 | 3 | import "encoding/json" 4 | 5 | // GameLanguages is a map of language codes to their full names. 6 | var GameLanguages = map[string]string{ 7 | "en": "English", 8 | "fr": "Français", 9 | "de": "Deutsch", 10 | "es": "Español", 11 | "it": "Italiano", 12 | "ru": "Русский", 13 | "pl": "Polski", 14 | "pt-BR": "Português do Brasil", 15 | "zh-Hans": "简体中文", 16 | "ja": "日本語", 17 | "ko": "한국어", 18 | } 19 | 20 | // Game contains information about a game and its downloadable content like extras and DLCs. 21 | type Game struct { 22 | Title string `json:"title"` 23 | BackgroundImage *string `json:"backgroundImage,omitempty"` 24 | Downloads []Downloadable `json:"downloads"` 25 | Extras []Extra `json:"extras"` 26 | DLCs []DLC `json:"dlcs"` 27 | } 28 | 29 | // PlatformFile contains information about a platform-specific installation file. 30 | type PlatformFile struct { 31 | ManualURL *string `json:"manualUrl,omitempty"` 32 | Name string `json:"name"` 33 | Version *string `json:"version,omitempty"` 34 | Date *string `json:"date,omitempty"` 35 | Size string `json:"size"` 36 | } 37 | 38 | // Extra contains information about an extra file like game manual and soundtracks. 39 | type Extra struct { 40 | Name string `json:"name"` 41 | Size string `json:"size"` 42 | ManualURL string `json:"manualUrl"` 43 | } 44 | 45 | // DLC contains information about a downloadable content like expansions and updates. 46 | type DLC struct { 47 | Title string `json:"title"` 48 | BackgroundImage *string `json:"backgroundImage,omitempty"` 49 | Downloads [][]interface{} `json:"downloads"` 50 | Extras []Extra `json:"extras"` 51 | ParsedDownloads []Downloadable `json:"-"` 52 | } 53 | 54 | // Platform contains information about platform-specific installation files. 55 | type Platform struct { 56 | Windows []PlatformFile `json:"windows,omitempty"` 57 | Mac []PlatformFile `json:"mac,omitempty"` 58 | Linux []PlatformFile `json:"linux,omitempty"` 59 | } 60 | 61 | // Downloadable contains information about a downloadable file for a specific language and platform. 62 | type Downloadable struct { 63 | Language string `json:"language"` 64 | Platforms Platform `json:"platforms"` 65 | } 66 | 67 | // UnmarshalJSON is a custom unmarshal function for Game to process downloads and DLCs correctly. 68 | func (gd *Game) UnmarshalJSON(data []byte) error { 69 | type Alias Game 70 | // Unmarshal into a temporary value to avoid aliasing into a possibly nil receiver 71 | var tmp struct { 72 | RawDownloads [][]interface{} `json:"downloads"` 73 | Alias 74 | } 75 | if err := json.Unmarshal(data, &tmp); err != nil { 76 | return err 77 | } 78 | // Copy basic fields 79 | *gd = Game(tmp.Alias) 80 | 81 | // Process RawDownloads for Game. 82 | gd.Downloads = parseRawDownloads(tmp.RawDownloads) 83 | 84 | // Process DLC downloads. 85 | for i, dlc := range gd.DLCs { 86 | parsedDLCDownloads := parseRawDownloads(dlc.Downloads) 87 | gd.DLCs[i].ParsedDownloads = parsedDLCDownloads 88 | } 89 | 90 | return nil 91 | } 92 | 93 | // parseRawDownloads parses the raw downloads data into a slice of Downloadable. 94 | func parseRawDownloads(rawDownloads [][]interface{}) []Downloadable { 95 | var downloads []Downloadable 96 | 97 | for _, raw := range rawDownloads { 98 | if len(raw) != 2 { 99 | continue 100 | } 101 | 102 | // First element is the language. 103 | language, ok := raw[0].(string) 104 | if !ok { 105 | continue 106 | } 107 | 108 | // Second element is the platforms object. 109 | platforms, err := parsePlatforms(raw[1]) 110 | if err != nil { 111 | continue 112 | } 113 | 114 | downloads = append(downloads, Downloadable{ 115 | Language: language, 116 | Platforms: platforms, 117 | }) 118 | } 119 | 120 | return downloads 121 | } 122 | 123 | // parsePlatforms parses the platforms data from an interface{}. 124 | func parsePlatforms(data interface{}) (Platform, error) { 125 | platformsData, err := json.Marshal(data) 126 | if err != nil { 127 | return Platform{}, err 128 | } 129 | 130 | var platforms Platform 131 | if err := json.Unmarshal(platformsData, &platforms); err != nil { 132 | return Platform{}, err 133 | } 134 | 135 | return platforms, nil 136 | } 137 | -------------------------------------------------------------------------------- /db/db.go: -------------------------------------------------------------------------------- 1 | package db 2 | 3 | import ( 4 | "os" 5 | "path/filepath" 6 | 7 | "github.com/rs/zerolog" 8 | "github.com/rs/zerolog/log" 9 | "gorm.io/driver/sqlite" 10 | "gorm.io/gorm" 11 | "gorm.io/gorm/logger" 12 | ) 13 | 14 | // Database variables 15 | var ( 16 | Db *gorm.DB 17 | Path string 18 | ) 19 | 20 | func init() { 21 | ConfigurePath() 22 | } 23 | 24 | // ConfigurePath determines and sets the database path based on environment variables. 25 | // It is public to allow for re-evaluation during testing. 26 | func ConfigurePath() { _ = ConfigurePathErr() } 27 | 28 | // ConfigurePathErr is like ConfigurePath but returns an error instead of calling log.Fatal. 29 | func ConfigurePathErr() error { 30 | var baseDir string 31 | 32 | // 1. Check for explicit GOGG_HOME override 33 | if goggHome := os.Getenv("GOGG_HOME"); goggHome != "" { 34 | baseDir = goggHome 35 | } else if xdgDataHome := os.Getenv("XDG_DATA_HOME"); xdgDataHome != "" { 36 | // 2. Check for XDG_DATA_HOME convention (like `~/.local/share`) 37 | baseDir = filepath.Join(xdgDataHome, "gogg") 38 | } else { 39 | // 3. Fallback to the default in the user's home directory 40 | homeDir, err := os.UserHomeDir() 41 | if err != nil { 42 | log.Error().Err(err).Msg("Could not determine user home directory; using working directory as fallback") 43 | cwd, cwdErr := os.Getwd() 44 | if cwdErr != nil { 45 | return cwdErr 46 | } 47 | baseDir = filepath.Join(cwd, ".gogg") 48 | } else { 49 | baseDir = filepath.Join(homeDir, ".gogg") 50 | } 51 | } 52 | 53 | Path = filepath.Join(baseDir, "games.db") 54 | return nil 55 | } 56 | 57 | // InitDB initializes the database and creates the tables if they don't exist. 58 | // It returns an error if any step in the initialization process fails. 59 | func InitDB() error { 60 | if err := createDBDirectory(); err != nil { 61 | return err 62 | } 63 | 64 | if err := openDatabase(); err != nil { 65 | return err 66 | } 67 | 68 | if err := migrateTables(); err != nil { 69 | return err 70 | } 71 | 72 | configureLogger() 73 | log.Info().Str("path", Path).Msg("Database initialized successfully") 74 | return nil 75 | } 76 | 77 | // createDBDirectory checks if the database path exists and creates it if it doesn't. 78 | // It returns an error if the directory creation fails. 79 | func createDBDirectory() error { 80 | dbDir := filepath.Dir(Path) 81 | if _, err := os.Stat(dbDir); os.IsNotExist(err) { 82 | if err := os.MkdirAll(dbDir, 0o750); err != nil { 83 | log.Error().Err(err).Msgf("Failed to create database directory: %s", dbDir) 84 | return err 85 | } 86 | } 87 | return nil 88 | } 89 | 90 | // openDatabase opens the database connection. 91 | // It returns an error if the database connection fails to open. 92 | func openDatabase() error { 93 | var err error 94 | Db, err = gorm.Open(sqlite.Open(Path), &gorm.Config{}) 95 | if err != nil { 96 | log.Error().Err(err).Msg("Failed to initialize database") 97 | return err 98 | } 99 | return nil 100 | } 101 | 102 | // migrateTables creates the tables if they don't exist. 103 | // It returns an error if the table migration fails. 104 | func migrateTables() error { 105 | if err := Db.AutoMigrate(&Game{}); err != nil { 106 | log.Error().Err(err).Msg("Failed to auto-migrate database") 107 | return err 108 | } 109 | 110 | if err := Db.AutoMigrate(&Token{}); err != nil { 111 | log.Error().Err(err).Msg("Failed to auto-migrate database") 112 | return err 113 | } 114 | return nil 115 | } 116 | 117 | // configureLogger configures the GORM logger based on the environment variable. 118 | func configureLogger() { 119 | if Db == nil { 120 | return 121 | } 122 | if zerolog.GlobalLevel() == zerolog.Disabled { 123 | Db.Logger = Db.Logger.LogMode(logger.Silent) 124 | } else { 125 | Db.Logger = Db.Logger.LogMode(logger.Info) 126 | } 127 | } 128 | 129 | // GetDB provides read-only access to the underlying *gorm.DB reference. 130 | func GetDB() *gorm.DB { return Db } 131 | 132 | // Shutdown closes the database ignoring the error (for interrupt handling). 133 | func Shutdown() { _ = CloseDB() } 134 | 135 | // CloseDB closes the database connection. 136 | // It returns an error if the database connection fails to close. 137 | func CloseDB() error { 138 | if Db == nil { 139 | return nil // Nothing to close 140 | } 141 | sqlDB, err := Db.DB() 142 | if err != nil { 143 | log.Error().Err(err).Msg("Failed to get raw database connection") 144 | return err 145 | } 146 | return sqlDB.Close() 147 | } 148 | -------------------------------------------------------------------------------- /gui/assets/JetBrainsMono-2.304/OFL.txt: -------------------------------------------------------------------------------- 1 | Copyright 2020 The JetBrains Mono Project Authors (https://github.com/JetBrains/JetBrainsMono) 2 | 3 | This Font Software is licensed under the SIL Open Font License, Version 1.1. 4 | This license is copied below, and is also available with a FAQ at: 5 | https://scripts.sil.org/OFL 6 | 7 | 8 | ----------------------------------------------------------- 9 | SIL OPEN FONT LICENSE Version 1.1 - 26 February 2007 10 | ----------------------------------------------------------- 11 | 12 | PREAMBLE 13 | The goals of the Open Font License (OFL) are to stimulate worldwide 14 | development of collaborative font projects, to support the font creation 15 | efforts of academic and linguistic communities, and to provide a free and 16 | open framework in which fonts may be shared and improved in partnership 17 | with others. 18 | 19 | The OFL allows the licensed fonts to be used, studied, modified and 20 | redistributed freely as long as they are not sold by themselves. The 21 | fonts, including any derivative works, can be bundled, embedded, 22 | redistributed and/or sold with any software provided that any reserved 23 | names are not used by derivative works. The fonts and derivatives, 24 | however, cannot be released under any other type of license. The 25 | requirement for fonts to remain under this license does not apply 26 | to any document created using the fonts or their derivatives. 27 | 28 | DEFINITIONS 29 | "Font Software" refers to the set of files released by the Copyright 30 | Holder(s) under this license and clearly marked as such. This may 31 | include source files, build scripts and documentation. 32 | 33 | "Reserved Font Name" refers to any names specified as such after the 34 | copyright statement(s). 35 | 36 | "Original Version" refers to the collection of Font Software components as 37 | distributed by the Copyright Holder(s). 38 | 39 | "Modified Version" refers to any derivative made by adding to, deleting, 40 | or substituting -- in part or in whole -- any of the components of the 41 | Original Version, by changing formats or by porting the Font Software to a 42 | new environment. 43 | 44 | "Author" refers to any designer, engineer, programmer, technical 45 | writer or other person who contributed to the Font Software. 46 | 47 | PERMISSION & CONDITIONS 48 | Permission is hereby granted, free of charge, to any person obtaining 49 | a copy of the Font Software, to use, study, copy, merge, embed, modify, 50 | redistribute, and sell modified and unmodified copies of the Font 51 | Software, subject to the following conditions: 52 | 53 | 1) Neither the Font Software nor any of its individual components, 54 | in Original or Modified Versions, may be sold by itself. 55 | 56 | 2) Original or Modified Versions of the Font Software may be bundled, 57 | redistributed and/or sold with any software, provided that each copy 58 | contains the above copyright notice and this license. These can be 59 | included either as stand-alone text files, human-readable headers or 60 | in the appropriate machine-readable metadata fields within text or 61 | binary files as long as those fields can be easily viewed by the user. 62 | 63 | 3) No Modified Version of the Font Software may use the Reserved Font 64 | Name(s) unless explicit written permission is granted by the corresponding 65 | Copyright Holder. This restriction only applies to the primary font name as 66 | presented to the users. 67 | 68 | 4) The name(s) of the Copyright Holder(s) or the Author(s) of the Font 69 | Software shall not be used to promote, endorse or advertise any 70 | Modified Version, except to acknowledge the contribution(s) of the 71 | Copyright Holder(s) and the Author(s) or with their explicit written 72 | permission. 73 | 74 | 5) The Font Software, modified or unmodified, in part or in whole, 75 | must be distributed entirely under this license, and must not be 76 | distributed under any other license. The requirement for fonts to 77 | remain under this license does not apply to any document created 78 | using the Font Software. 79 | 80 | TERMINATION 81 | This license becomes null and void if any of the above conditions are 82 | not met. 83 | 84 | DISCLAIMER 85 | THE FONT SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, 86 | EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO ANY WARRANTIES OF 87 | MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT 88 | OF COPYRIGHT, PATENT, TRADEMARK, OR OTHER RIGHT. IN NO EVENT SHALL THE 89 | COPYRIGHT HOLDER BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, 90 | INCLUDING ANY GENERAL, SPECIAL, INDIRECT, INCIDENTAL, OR CONSEQUENTIAL 91 | DAMAGES, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING 92 | FROM, OUT OF THE USE OR INABILITY TO USE THE FONT SOFTWARE OR FROM 93 | OTHER DEALINGS IN THE FONT SOFTWARE. 94 | -------------------------------------------------------------------------------- /db/game_test.go: -------------------------------------------------------------------------------- 1 | package db_test 2 | 3 | import ( 4 | "testing" 5 | 6 | "github.com/habedi/gogg/db" 7 | "github.com/stretchr/testify/assert" 8 | "github.com/stretchr/testify/require" 9 | "gorm.io/driver/sqlite" 10 | "gorm.io/gorm" 11 | "gorm.io/gorm/logger" 12 | ) 13 | 14 | // setupTestDBForGames sets up an in-memory SQLite database for testing purposes. 15 | // It returns a pointer to the gorm.DB instance. 16 | func setupTestDBForGames(t *testing.T) *gorm.DB { 17 | dBOject, err := gorm.Open(sqlite.Open(":memory:"), &gorm.Config{ 18 | Logger: logger.Default.LogMode(logger.Silent), 19 | }) 20 | require.NoError(t, err) 21 | require.NoError(t, dBOject.AutoMigrate(&db.Game{})) 22 | return dBOject 23 | } 24 | 25 | // TestPutInGame_InsertsNewGame tests the insertion of a new game into the database. 26 | func TestPutInGame_InsertsNewGame(t *testing.T) { 27 | testDB := setupTestDBForGames(t) 28 | db.Db = testDB 29 | 30 | err := db.PutInGame(1, "Test Game", "Test Data") 31 | require.NoError(t, err) 32 | 33 | var game db.Game 34 | err = testDB.First(&game, 1).Error 35 | require.NoError(t, err) 36 | assert.Equal(t, "Test Game", game.Title) 37 | assert.Equal(t, "Test Data", game.Data) 38 | } 39 | 40 | // TestPutInGame_UpdatesExistingGame tests the update of an existing game in the database. 41 | func TestPutInGame_UpdatesExistingGame(t *testing.T) { 42 | testDB := setupTestDBForGames(t) 43 | db.Db = testDB 44 | 45 | err := db.PutInGame(1, "Test Game", "Test Data") 46 | require.NoError(t, err) 47 | 48 | err = db.PutInGame(1, "Updated Game", "Updated Data") 49 | require.NoError(t, err) 50 | 51 | var game db.Game 52 | err = testDB.First(&game, 1).Error 53 | require.NoError(t, err) 54 | assert.Equal(t, "Updated Game", game.Title) 55 | assert.Equal(t, "Updated Data", game.Data) 56 | } 57 | 58 | // TestEmptyCatalogue_RemovesAllGames tests the removal of all games from the database. 59 | func TestEmptyCatalogue_RemovesAllGames(t *testing.T) { 60 | testDB := setupTestDBForGames(t) 61 | db.Db = testDB 62 | 63 | err := db.PutInGame(1, "Test Game", "Test Data") 64 | require.NoError(t, err) 65 | 66 | err = db.EmptyCatalogue() 67 | require.NoError(t, err) 68 | 69 | var games []db.Game 70 | err = testDB.Find(&games).Error 71 | require.NoError(t, err) 72 | assert.Empty(t, games) 73 | } 74 | 75 | // TestGetCatalogue_ReturnsAllGames tests the retrieval of all games from the database. 76 | func TestGetCatalogue_ReturnsAllGames(t *testing.T) { 77 | testDB := setupTestDBForGames(t) 78 | db.Db = testDB 79 | 80 | err := db.PutInGame(1, "Test Game 1", "Test Data 1") 81 | require.NoError(t, err) 82 | err = db.PutInGame(2, "Test Game 2", "Test Data 2") 83 | require.NoError(t, err) 84 | 85 | games, err := db.GetCatalogue() 86 | require.NoError(t, err) 87 | assert.Len(t, games, 2) 88 | } 89 | 90 | // TestGetGameByID_ReturnsGame tests the retrieval of a game by its ID from the database. 91 | func TestGetGameByID_ReturnsGame(t *testing.T) { 92 | testDB := setupTestDBForGames(t) 93 | db.Db = testDB 94 | 95 | err := db.PutInGame(1, "Test Game", "Test Data") 96 | require.NoError(t, err) 97 | 98 | game, err := db.GetGameByID(1) 99 | require.NoError(t, err) 100 | assert.NotNil(t, game) 101 | assert.Equal(t, "Test Game", game.Title) 102 | assert.Equal(t, "Test Data", game.Data) 103 | } 104 | 105 | // TestGetGameByID_ReturnsNilForNonExistentGame tests that a non-existent game returns nil. 106 | func TestGetGameByID_ReturnsNilForNonExistentGame(t *testing.T) { 107 | testDB := setupTestDBForGames(t) 108 | db.Db = testDB 109 | 110 | game, err := db.GetGameByID(1) 111 | require.NoError(t, err) 112 | assert.Nil(t, game) 113 | } 114 | 115 | // TestSearchGamesByName_ReturnsMatchingGames tests the search functionality for games by name. 116 | func TestSearchGamesByName_ReturnsMatchingGames(t *testing.T) { 117 | testDB := setupTestDBForGames(t) 118 | db.Db = testDB 119 | 120 | err := db.PutInGame(1, "Test Game 1", "Test Data 1") 121 | require.NoError(t, err) 122 | err = db.PutInGame(2, "Another Game", "Test Data 2") 123 | require.NoError(t, err) 124 | 125 | games, err := db.SearchGamesByName("Test") 126 | require.NoError(t, err) 127 | assert.Len(t, games, 1) 128 | assert.Equal(t, "Test Game 1", games[0].Title) 129 | } 130 | 131 | // TestSearchGamesByName_ReturnsEmptyForNoMatches tests that no matches return an empty result. 132 | func TestSearchGamesByName_ReturnsEmptyForNoMatches(t *testing.T) { 133 | testDB := setupTestDBForGames(t) 134 | db.Db = testDB 135 | 136 | err := db.PutInGame(1, "Test Game 1", "Test Data 1") 137 | require.NoError(t, err) 138 | 139 | games, err := db.SearchGamesByName("Nonexistent") 140 | require.NoError(t, err) 141 | assert.Empty(t, games) 142 | } 143 | -------------------------------------------------------------------------------- /gui/sound.go: -------------------------------------------------------------------------------- 1 | package gui 2 | 3 | import ( 4 | "bytes" 5 | "context" 6 | _ "embed" 7 | "fmt" 8 | "io" 9 | "os" 10 | "path/filepath" 11 | "strings" 12 | "sync" 13 | "time" 14 | 15 | "fyne.io/fyne/v2" 16 | "github.com/faiface/beep" 17 | "github.com/faiface/beep/mp3" 18 | "github.com/faiface/beep/speaker" 19 | "github.com/faiface/beep/vorbis" 20 | "github.com/faiface/beep/wav" 21 | "github.com/rs/zerolog/log" 22 | ) 23 | 24 | //go:embed assets/ding-small-bell-sfx-233008.mp3 25 | var defaultDingSound []byte 26 | 27 | var ( 28 | speakerOnce sync.Once 29 | mixer *beep.Mixer 30 | sampleRate beep.SampleRate 31 | currentSound context.CancelFunc 32 | currentSoundMux sync.Mutex 33 | soundPlaying bool 34 | ) 35 | 36 | func initSpeaker(sr beep.SampleRate) { 37 | speakerOnce.Do(func() { 38 | sampleRate = sr 39 | bufferSize := sr.N(time.Second / 10) 40 | if err := speaker.Init(sampleRate, bufferSize); err != nil { 41 | log.Error().Err(err).Msg("Failed to initialize speaker") 42 | return 43 | } 44 | mixer = &beep.Mixer{} 45 | speaker.Play(mixer) 46 | }) 47 | } 48 | 49 | func validateAudioFile(filePath string) error { 50 | if filePath == "" { 51 | return fmt.Errorf("empty file path") 52 | } 53 | 54 | info, err := os.Stat(filePath) 55 | if err != nil { 56 | return fmt.Errorf("cannot access file: %w", err) 57 | } 58 | 59 | if info.IsDir() { 60 | return fmt.Errorf("path is a directory, not a file") 61 | } 62 | 63 | if info.Size() == 0 { 64 | return fmt.Errorf("file is empty") 65 | } 66 | 67 | if info.Size() > 50*1024*1024 { 68 | return fmt.Errorf("file is too large (>50MB), please use a shorter audio clip") 69 | } 70 | 71 | ext := strings.ToLower(filepath.Ext(filePath)) 72 | if ext != ".mp3" && ext != ".wav" && ext != ".ogg" { 73 | return fmt.Errorf("unsupported file format: %s (supported: .mp3, .wav, .ogg)", ext) 74 | } 75 | 76 | return nil 77 | } 78 | 79 | func PlayNotificationSound() { 80 | defer func() { 81 | if r := recover(); r != nil { 82 | log.Error().Interface("panic", r).Msg("Recovered from panic in PlayNotificationSound") 83 | } 84 | }() 85 | 86 | a := fyne.CurrentApp() 87 | if !a.Preferences().BoolWithFallback("soundEnabled", true) { 88 | return 89 | } 90 | 91 | currentSoundMux.Lock() 92 | if currentSound != nil && soundPlaying { 93 | currentSound() 94 | } 95 | ctx, cancel := context.WithCancel(context.Background()) 96 | currentSound = cancel 97 | soundPlaying = true 98 | currentSoundMux.Unlock() 99 | 100 | defer func() { 101 | currentSoundMux.Lock() 102 | soundPlaying = false 103 | currentSound = nil 104 | currentSoundMux.Unlock() 105 | }() 106 | 107 | filePath := a.Preferences().String("soundFilePath") 108 | var reader io.ReadCloser 109 | isDefault := false 110 | 111 | if filePath != "" { 112 | if err := validateAudioFile(filePath); err != nil { 113 | log.Error().Err(err).Str("path", filePath).Msg("Invalid custom sound file, falling back to default") 114 | isDefault = true 115 | } else { 116 | f, err := os.Open(filePath) 117 | if err != nil { 118 | log.Error().Err(err).Str("path", filePath).Msg("Failed to open custom sound file, falling back to default") 119 | isDefault = true 120 | } else { 121 | reader = f 122 | } 123 | } 124 | } else { 125 | isDefault = true 126 | } 127 | 128 | if isDefault { 129 | if len(defaultDingSound) == 0 { 130 | log.Warn().Msg("No custom sound set and default sound asset is missing.") 131 | return 132 | } 133 | reader = io.NopCloser(bytes.NewReader(defaultDingSound)) 134 | filePath = ".mp3" 135 | } 136 | defer func() { 137 | if err := reader.Close(); err != nil { 138 | log.Debug().Err(err).Msg("Failed to close audio reader") 139 | } 140 | }() 141 | 142 | var streamer beep.StreamSeekCloser 143 | var format beep.Format 144 | var err error 145 | 146 | switch strings.ToLower(filepath.Ext(filePath)) { 147 | case ".mp3": 148 | streamer, format, err = mp3.Decode(reader) 149 | case ".wav": 150 | streamer, format, err = wav.Decode(reader) 151 | case ".ogg": 152 | streamer, format, err = vorbis.Decode(reader) 153 | default: 154 | err = fmt.Errorf("unsupported sound format for file: %s", filePath) 155 | } 156 | 157 | if err != nil { 158 | log.Error().Err(err).Str("path", filePath).Msg("Failed to decode audio stream - file may be corrupted or invalid") 159 | return 160 | } 161 | defer func() { 162 | if err := streamer.Close(); err != nil { 163 | log.Debug().Err(err).Msg("Failed to close audio streamer") 164 | } 165 | }() 166 | 167 | initSpeaker(format.SampleRate) 168 | 169 | resampled := beep.Resample(4, format.SampleRate, sampleRate, streamer) 170 | 171 | done := make(chan bool, 1) 172 | mixer.Add(beep.Seq(resampled, beep.Callback(func() { 173 | select { 174 | case done <- true: 175 | default: 176 | } 177 | }))) 178 | 179 | select { 180 | case <-done: 181 | case <-ctx.Done(): 182 | log.Debug().Msg("Sound playback cancelled") 183 | case <-time.After(30 * time.Second): 184 | log.Warn().Msg("Sound playback timeout - audio file may be too long") 185 | } 186 | } 187 | -------------------------------------------------------------------------------- /client/download_test.go: -------------------------------------------------------------------------------- 1 | package client 2 | 3 | import ( 4 | "os" 5 | "path/filepath" 6 | "testing" 7 | 8 | "github.com/stretchr/testify/assert" 9 | "github.com/stretchr/testify/require" 10 | ) 11 | 12 | func TestParseGameDataFromDownload(t *testing.T) { 13 | validJSON := `{"title":"Test","downloads":[],"extras":[],"dlcs":[]}` 14 | g, err := ParseGameData(validJSON) 15 | require.NoError(t, err) 16 | assert.Equal(t, "Test", g.Title) 17 | 18 | _, err = ParseGameData("invalid json") 19 | assert.Error(t, err) 20 | } 21 | 22 | func TestDownloadSanitizePath(t *testing.T) { 23 | cases := map[string]string{ 24 | "My Game® (Test)™": "my-game-test", 25 | "Spaces And:Colons": "spaces-andcolons", 26 | "UPPER_case": "upper_case", 27 | } 28 | for input, expected := range cases { 29 | got := SanitizePath(input) 30 | if got != expected { 31 | t.Errorf("SanitizePath(%q) = %q; want %q", input, got, expected) 32 | } 33 | } 34 | } 35 | 36 | func TestDownloadEnsureDirExists(t *testing.T) { 37 | tmp := t.TempDir() 38 | path := filepath.Join(tmp, "subdir") 39 | err := ensureDirExists(path) 40 | assert.NoError(t, err) 41 | info, err := os.Stat(path) 42 | assert.NoError(t, err) 43 | assert.True(t, info.IsDir()) 44 | 45 | filePath := filepath.Join(tmp, "file.txt") 46 | os.WriteFile(filePath, []byte("data"), 0o644) 47 | err = ensureDirExists(filePath) 48 | if err == nil { 49 | t.Error("Expected error when path exists and is not a directory, got nil") 50 | } 51 | } 52 | 53 | func TestFilenameExtractionFromRedirectURL(t *testing.T) { 54 | tests := []struct { 55 | name string 56 | redirectURL string 57 | expectedBase string 58 | description string 59 | }{ 60 | { 61 | name: "exe_file", 62 | redirectURL: "https://cdn.gog.com/content-system/v2/setup_nox_2.0.0.20.exe", 63 | expectedBase: "setup_nox_2.0.0.20.exe", 64 | description: "Should extract .exe extension from redirect URL", 65 | }, 66 | { 67 | name: "zip_file", 68 | redirectURL: "https://cdn.gog.com/content-system/v2/Nox_QRC.zip", 69 | expectedBase: "Nox_QRC.zip", 70 | description: "Should extract .zip extension from redirect URL", 71 | }, 72 | { 73 | name: "bin_file", 74 | redirectURL: "https://cdn.gog.com/secure/setup_prey_12742273_(64bit)_(65935)-1.bin", 75 | expectedBase: "setup_prey_12742273_(64bit)_(65935)-1.bin", 76 | description: "Should extract .bin extension from redirect URL", 77 | }, 78 | { 79 | name: "multipart_installer", 80 | redirectURL: "https://cdn.gog.com/secure/setup_prey_12742273_(64bit)_(65935).exe", 81 | expectedBase: "setup_prey_12742273_(64bit)_(65935).exe", 82 | description: "Should extract main .exe for multipart installers", 83 | }, 84 | { 85 | name: "url_encoded_filename", 86 | redirectURL: "https://cdn.gog.com/secure/Game%20File%20v1.2.3.zip", 87 | expectedBase: "Game%20File%20v1.2.3.zip", 88 | description: "Should handle URL-encoded filenames (decoding happens later)", 89 | }, 90 | } 91 | 92 | for _, tt := range tests { 93 | t.Run(tt.name, func(t *testing.T) { 94 | base := filepath.Base(tt.redirectURL) 95 | assert.Equal(t, tt.expectedBase, base, tt.description) 96 | 97 | // Verify the extension is present 98 | ext := filepath.Ext(base) 99 | assert.NotEmpty(t, ext, "Filename should have an extension: %s", tt.description) 100 | }) 101 | } 102 | } 103 | 104 | func TestFilenameWithoutExtensionShouldBeReplaced(t *testing.T) { 105 | // This test documents the bug: when API returns filename without extension, 106 | // and redirect URL has the proper filename with extension, we should use the redirect URL's filename 107 | 108 | tests := []struct { 109 | name string 110 | apiFileName string 111 | redirectURL string 112 | expectedFinalName string 113 | }{ 114 | { 115 | name: "bastion_installer", 116 | apiFileName: "Bastion", // API returns name without extension 117 | redirectURL: "https://cdn.gog.com/secure/bastion_installer_v1.0.exe", 118 | expectedFinalName: "bastion_installer_v1.0.exe", // Should use redirect URL's name 119 | }, 120 | { 121 | name: "wallpaper_file", 122 | apiFileName: "wallpaper", 123 | redirectURL: "https://cdn.gog.com/extras/wallpaper_4k.zip", 124 | expectedFinalName: "wallpaper_4k.zip", 125 | }, 126 | } 127 | 128 | for _, tt := range tests { 129 | t.Run(tt.name, func(t *testing.T) { 130 | // Simulate the logic in downloadFile function 131 | fileName := tt.apiFileName 132 | 133 | // After getting redirect, extract filename from URL 134 | base := filepath.Base(tt.redirectURL) 135 | if base != "." && base != "/" { 136 | // BUG FIX: Always use redirect URL's filename, don't check if fileName is empty 137 | fileName = base 138 | } 139 | 140 | assert.Equal(t, tt.expectedFinalName, fileName, 141 | "Should replace API filename with redirect URL filename to get proper extension") 142 | 143 | // Verify the final filename has an extension 144 | ext := filepath.Ext(fileName) 145 | assert.NotEmpty(t, ext, "Final filename must have an extension") 146 | }) 147 | } 148 | } 149 | 150 | func TestBuildManualURL(t *testing.T) { 151 | tests := []struct { 152 | input string 153 | expected string 154 | }{ 155 | { 156 | input: "https://api.gog.com/secure/file.exe", 157 | expected: "https://api.gog.com/secure/file.exe", 158 | }, 159 | { 160 | input: "/account/gameDetails/123.json", 161 | expected: "https://embed.gog.com/account/gameDetails/123.json", 162 | }, 163 | } 164 | 165 | for _, tt := range tests { 166 | result := buildManualURL(tt.input) 167 | assert.Equal(t, tt.expected, result) 168 | } 169 | } 170 | -------------------------------------------------------------------------------- /gui/settings.go: -------------------------------------------------------------------------------- 1 | package gui 2 | 3 | import ( 4 | "fmt" 5 | "strings" 6 | 7 | "github.com/habedi/gogg/client" 8 | 9 | "fyne.io/fyne/v2" 10 | "fyne.io/fyne/v2/container" 11 | "fyne.io/fyne/v2/dialog" 12 | "fyne.io/fyne/v2/storage" 13 | "fyne.io/fyne/v2/widget" 14 | ) 15 | 16 | func SettingsTabUI(win fyne.Window) fyne.CanvasObject { 17 | prefs := fyne.CurrentApp().Preferences() 18 | a := fyne.CurrentApp() 19 | 20 | // --- Theme Settings --- 21 | themeRadio := widget.NewRadioGroup([]string{"System Default", "Light", "Dark"}, func(selected string) { 22 | prefs.SetString("theme", selected) 23 | a.Settings().SetTheme(CreateThemeFromPreferences()) 24 | }) 25 | themeRadio.SetSelected(prefs.StringWithFallback("theme", "System Default")) 26 | 27 | themeBox := container.NewVBox(widget.NewLabel("UI Theme"), themeRadio) 28 | 29 | // --- Font Settings --- 30 | fontOptions := []string{ 31 | "System Default", 32 | "JetBrains Mono", 33 | "JetBrains Mono Bold", 34 | } 35 | fontSelect := widget.NewSelect(fontOptions, func(selected string) { 36 | prefs.SetString("fontName", selected) 37 | a.Settings().SetTheme(CreateThemeFromPreferences()) 38 | }) 39 | fontSelect.SetSelected(prefs.StringWithFallback("fontName", "System Default")) 40 | 41 | fontSizeSelect := widget.NewSelect([]string{"Small", "Normal", "Large", "Extra Large"}, func(s string) { 42 | prefs.SetString("fontSize", s) 43 | a.Settings().SetTheme(CreateThemeFromPreferences()) 44 | }) 45 | fontSizeSelect.SetSelected(prefs.StringWithFallback("fontSize", "Normal")) 46 | 47 | fontBox := container.NewVBox( 48 | widget.NewLabel("Font Family"), fontSelect, 49 | widget.NewLabel("Font Size"), fontSizeSelect, 50 | ) 51 | 52 | // --- Sound Settings --- 53 | soundCheck := widget.NewCheck("Play sound on download completion", func(checked bool) { 54 | prefs.SetBool("soundEnabled", checked) 55 | }) 56 | soundCheck.SetChecked(prefs.BoolWithFallback("soundEnabled", true)) 57 | 58 | soundPathLabel := widget.NewLabel("") 59 | soundStatusLabel := widget.NewLabelWithStyle("", fyne.TextAlignLeading, fyne.TextStyle{Italic: true}) 60 | 61 | validateSoundPath := func(path string) { 62 | if path == "" { 63 | soundPathLabel.SetText("Default sound file") 64 | soundStatusLabel.SetText("") 65 | soundStatusLabel.Hide() 66 | return 67 | } 68 | 69 | soundPathLabel.SetText(path) 70 | if err := validateAudioFile(path); err != nil { 71 | soundStatusLabel.SetText(fmt.Sprintf("⚠ %s. Using default.", err.Error())) 72 | soundStatusLabel.Show() 73 | } else { 74 | soundStatusLabel.SetText("✓ Valid audio file") 75 | soundStatusLabel.Show() 76 | } 77 | } 78 | validateSoundPath(prefs.String("soundFilePath")) 79 | 80 | selectSoundBtn := widget.NewButton("Select Custom Sound...", func() { 81 | fd := dialog.NewFileOpen(func(reader fyne.URIReadCloser, err error) { 82 | if err != nil { 83 | dialog.ShowError(err, win) 84 | return 85 | } 86 | if reader == nil { 87 | return 88 | } 89 | path := reader.URI().Path() 90 | 91 | if err := validateAudioFile(path); err != nil { 92 | errDialog := dialog.NewError(fmt.Errorf("invalid audio file: %w\n\nSupported formats: .mp3, .wav, .ogg\nMax size: 50MB", err), win) 93 | errDialog.SetDismissText("OK") 94 | errDialog.Show() 95 | return 96 | } 97 | 98 | prefs.SetString("soundFilePath", path) 99 | validateSoundPath(path) 100 | }, win) 101 | fd.SetFilter(storage.NewExtensionFileFilter([]string{".mp3", ".wav", ".ogg"})) 102 | fd.Resize(fyne.NewSize(800, 600)) 103 | fd.Show() 104 | }) 105 | 106 | resetSoundBtn := widget.NewButton("Reset", func() { 107 | prefs.RemoveValue("soundFilePath") 108 | validateSoundPath("") 109 | }) 110 | 111 | testSoundBtn := widget.NewButton("Test", func() { 112 | path := prefs.String("soundFilePath") 113 | if path != "" { 114 | if err := validateAudioFile(path); err != nil { 115 | dialog.ShowError(fmt.Errorf("can't play sound: %w", err), win) 116 | return 117 | } 118 | } 119 | go PlayNotificationSound() 120 | }) 121 | 122 | soundConfigBox := container.NewVBox( 123 | widget.NewLabel("Current sound file:"), 124 | soundPathLabel, 125 | soundStatusLabel, 126 | widget.NewLabelWithStyle("Tip: Use short audio clips (2-5 seconds) for best results", fyne.TextAlignLeading, fyne.TextStyle{Italic: true}), 127 | container.NewHBox(selectSoundBtn, resetSoundBtn, testSoundBtn), 128 | ) 129 | 130 | // --- Download Limits --- 131 | maxConcSelect := widget.NewSelect([]string{"1", "2", "3", "4", "5", "6", "7", "8", "9", "10"}, func(s string) { 132 | prefs.SetString("download.maxConcurrent", s) 133 | }) 134 | maxConcSelect.SetSelected(fmt.Sprintf("%d", prefs.IntWithFallback("download.maxConcurrent", 2))) 135 | 136 | speedEntry := widget.NewEntry() 137 | speedEntry.SetPlaceHolder("Speed limit KB/s (0=unlimited)") 138 | if v := prefs.IntWithFallback("download.maxSpeedKBps", 0); v > 0 { 139 | speedEntry.SetText(fmt.Sprintf("%d", v)) 140 | } 141 | speedEntry.OnChanged = func(s string) { 142 | if s == "" { 143 | prefs.SetInt("download.maxSpeedKBps", 0) 144 | client.SetGlobalDownloadRateLimit(0) 145 | return 146 | } 147 | var val int 148 | _, err := fmt.Sscanf(strings.TrimSpace(s), "%d", &val) 149 | if err != nil { 150 | return 151 | } 152 | prefs.SetInt("download.maxSpeedKBps", val) 153 | if val <= 0 { 154 | client.SetGlobalDownloadRateLimit(0) 155 | } else { 156 | client.SetGlobalDownloadRateLimit(int64(val) * 1024) 157 | } 158 | } 159 | limitsBox := container.NewVBox(widget.NewLabel("Download Limits"), widget.NewForm( 160 | widget.NewFormItem("Max Concurrent", maxConcSelect), 161 | widget.NewFormItem("Speed Limit", speedEntry), 162 | )) 163 | 164 | // --- Layout --- 165 | mainCard := widget.NewCard("Settings", "", container.NewVBox( 166 | themeBox, 167 | widget.NewSeparator(), 168 | fontBox, 169 | widget.NewSeparator(), 170 | soundCheck, 171 | soundConfigBox, 172 | widget.NewSeparator(), 173 | limitsBox, 174 | )) 175 | 176 | return container.NewCenter(mainCard) 177 | } 178 | -------------------------------------------------------------------------------- /gui/sound_test.go: -------------------------------------------------------------------------------- 1 | package gui 2 | 3 | import ( 4 | "os" 5 | "path/filepath" 6 | "testing" 7 | ) 8 | 9 | func TestValidateAudioFile(t *testing.T) { 10 | tests := []struct { 11 | name string 12 | setup func(t *testing.T) string 13 | wantErr bool 14 | errMsg string 15 | }{ 16 | { 17 | name: "empty path", 18 | setup: func(t *testing.T) string { 19 | return "" 20 | }, 21 | wantErr: true, 22 | errMsg: "empty file path", 23 | }, 24 | { 25 | name: "non-existent file", 26 | setup: func(t *testing.T) string { 27 | return "/nonexistent/path/to/file.mp3" 28 | }, 29 | wantErr: true, 30 | errMsg: "cannot access file", 31 | }, 32 | { 33 | name: "directory instead of file", 34 | setup: func(t *testing.T) string { 35 | dir, err := os.MkdirTemp("", "audio-test-*") 36 | if err != nil { 37 | t.Fatal(err) 38 | } 39 | t.Cleanup(func() { os.RemoveAll(dir) }) 40 | return dir 41 | }, 42 | wantErr: true, 43 | errMsg: "path is a directory", 44 | }, 45 | { 46 | name: "empty file", 47 | setup: func(t *testing.T) string { 48 | f, err := os.CreateTemp("", "audio-test-*.mp3") 49 | if err != nil { 50 | t.Fatal(err) 51 | } 52 | path := f.Name() 53 | f.Close() 54 | t.Cleanup(func() { os.Remove(path) }) 55 | return path 56 | }, 57 | wantErr: true, 58 | errMsg: "file is empty", 59 | }, 60 | { 61 | name: "file too large", 62 | setup: func(t *testing.T) string { 63 | f, err := os.CreateTemp("", "audio-test-*.mp3") 64 | if err != nil { 65 | t.Fatal(err) 66 | } 67 | path := f.Name() 68 | 69 | data := make([]byte, 51*1024*1024) 70 | if _, err := f.Write(data); err != nil { 71 | f.Close() 72 | os.Remove(path) 73 | t.Fatal(err) 74 | } 75 | f.Close() 76 | t.Cleanup(func() { os.Remove(path) }) 77 | return path 78 | }, 79 | wantErr: true, 80 | errMsg: "file is too large", 81 | }, 82 | { 83 | name: "unsupported format", 84 | setup: func(t *testing.T) string { 85 | f, err := os.CreateTemp("", "audio-test-*.mp4") 86 | if err != nil { 87 | t.Fatal(err) 88 | } 89 | path := f.Name() 90 | f.Write([]byte("fake content")) 91 | f.Close() 92 | t.Cleanup(func() { os.Remove(path) }) 93 | return path 94 | }, 95 | wantErr: true, 96 | errMsg: "unsupported file format", 97 | }, 98 | { 99 | name: "valid mp3 file", 100 | setup: func(t *testing.T) string { 101 | f, err := os.CreateTemp("", "audio-test-*.mp3") 102 | if err != nil { 103 | t.Fatal(err) 104 | } 105 | path := f.Name() 106 | f.Write([]byte("fake mp3 content that is not empty")) 107 | f.Close() 108 | t.Cleanup(func() { os.Remove(path) }) 109 | return path 110 | }, 111 | wantErr: false, 112 | }, 113 | { 114 | name: "valid wav file", 115 | setup: func(t *testing.T) string { 116 | f, err := os.CreateTemp("", "audio-test-*.wav") 117 | if err != nil { 118 | t.Fatal(err) 119 | } 120 | path := f.Name() 121 | f.Write([]byte("fake wav content that is not empty")) 122 | f.Close() 123 | t.Cleanup(func() { os.Remove(path) }) 124 | return path 125 | }, 126 | wantErr: false, 127 | }, 128 | { 129 | name: "valid ogg file", 130 | setup: func(t *testing.T) string { 131 | f, err := os.CreateTemp("", "audio-test-*.ogg") 132 | if err != nil { 133 | t.Fatal(err) 134 | } 135 | path := f.Name() 136 | f.Write([]byte("fake ogg content that is not empty")) 137 | f.Close() 138 | t.Cleanup(func() { os.Remove(path) }) 139 | return path 140 | }, 141 | wantErr: false, 142 | }, 143 | { 144 | name: "uppercase extension", 145 | setup: func(t *testing.T) string { 146 | f, err := os.CreateTemp("", "audio-test-*.MP3") 147 | if err != nil { 148 | t.Fatal(err) 149 | } 150 | path := f.Name() 151 | f.Write([]byte("fake mp3 content")) 152 | f.Close() 153 | t.Cleanup(func() { os.Remove(path) }) 154 | return path 155 | }, 156 | wantErr: false, 157 | }, 158 | } 159 | 160 | for _, tt := range tests { 161 | t.Run(tt.name, func(t *testing.T) { 162 | path := tt.setup(t) 163 | err := validateAudioFile(path) 164 | 165 | if tt.wantErr { 166 | if err == nil { 167 | t.Errorf("validateAudioFile() expected error but got none") 168 | } else if tt.errMsg != "" && !contains(err.Error(), tt.errMsg) { 169 | t.Errorf("validateAudioFile() error = %v, want error containing %q", err, tt.errMsg) 170 | } 171 | } else { 172 | if err != nil { 173 | t.Errorf("validateAudioFile() unexpected error = %v", err) 174 | } 175 | } 176 | }) 177 | } 178 | } 179 | 180 | func TestValidateAudioFile_RealFiles(t *testing.T) { 181 | if testing.Short() { 182 | t.Skip("Skipping test that requires real audio files") 183 | } 184 | 185 | testdataDir := filepath.Join("testdata", "audio") 186 | if _, err := os.Stat(testdataDir); os.IsNotExist(err) { 187 | t.Skip("testdata/audio directory not found") 188 | } 189 | 190 | tests := []struct { 191 | filename string 192 | wantErr bool 193 | }{ 194 | {"valid.mp3", false}, 195 | {"valid.wav", false}, 196 | {"valid.ogg", false}, 197 | {"corrupted.ogg", false}, 198 | } 199 | 200 | for _, tt := range tests { 201 | t.Run(tt.filename, func(t *testing.T) { 202 | path := filepath.Join(testdataDir, tt.filename) 203 | if _, err := os.Stat(path); os.IsNotExist(err) { 204 | t.Skipf("Test file %s not found", path) 205 | } 206 | 207 | err := validateAudioFile(path) 208 | if (err != nil) != tt.wantErr { 209 | t.Errorf("validateAudioFile(%s) error = %v, wantErr %v", tt.filename, err, tt.wantErr) 210 | } 211 | }) 212 | } 213 | } 214 | 215 | func contains(s, substr string) bool { 216 | return len(s) >= len(substr) && (s == substr || len(s) > len(substr) && containsAny(s, substr)) 217 | } 218 | 219 | func containsAny(s, substr string) bool { 220 | for i := 0; i <= len(s)-len(substr); i++ { 221 | if s[i:i+len(substr)] == substr { 222 | return true 223 | } 224 | } 225 | return false 226 | } 227 | -------------------------------------------------------------------------------- /cmd/file.go: -------------------------------------------------------------------------------- 1 | package cmd 2 | 3 | import ( 4 | "context" 5 | "fmt" 6 | "os" 7 | "strconv" 8 | "strings" 9 | 10 | "github.com/habedi/gogg/pkg/clierr" 11 | "github.com/habedi/gogg/pkg/hasher" 12 | "github.com/habedi/gogg/pkg/operations" 13 | "github.com/habedi/gogg/pkg/validation" 14 | "github.com/rs/zerolog/log" 15 | "github.com/spf13/cobra" 16 | ) 17 | 18 | func fileCmd() *cobra.Command { 19 | cmd := &cobra.Command{ 20 | Use: "file", 21 | Short: "Perform various file operations", 22 | } 23 | cmd.AddCommand(hashCmd(), sizeCmd()) 24 | return cmd 25 | } 26 | 27 | func hashCmd() *cobra.Command { 28 | var saveToFileFlag, cleanFlag, recursiveFlag bool 29 | var algo string 30 | var numThreads int 31 | 32 | cmd := &cobra.Command{ 33 | Use: "hash [fileDir]", 34 | Short: "Generate hash values for game files in a directory", 35 | Args: cobra.ExactArgs(1), 36 | Run: func(cmd *cobra.Command, args []string) { 37 | dir := args[0] 38 | if !hasher.IsValidHashAlgo(algo) { 39 | e := clierr.New(clierr.Validation, "Unsupported hash algorithm", nil) 40 | cmd.PrintErrln(e.Message) 41 | setLastCliErr(e) 42 | return 43 | } 44 | if err := validation.ValidateThreadCount(numThreads); err != nil { 45 | e := clierr.New(clierr.Validation, "Invalid thread count", err) 46 | cmd.PrintErrln(e.Message) 47 | setLastCliErr(e) 48 | return 49 | } 50 | 51 | if cleanFlag { 52 | log.Info().Msgf("Cleaning old hash files from %s...", dir) 53 | if err := operations.CleanHashes(dir, recursiveFlag); err != nil { 54 | log.Error().Err(err).Msg("Error cleaning old hash files") 55 | } else { 56 | log.Info().Msg("Finished cleaning old hash files.") 57 | } 58 | } 59 | 60 | files, err := operations.FindFilesToHash(dir, recursiveFlag, operations.DefaultHashExclusions) 61 | if err != nil { 62 | log.Error().Err(err).Msg("Error finding files to hash") 63 | return 64 | } 65 | 66 | if len(files) == 0 { 67 | cmd.Println("No files found to hash.") 68 | return 69 | } 70 | 71 | cmd.Printf("Found %d files. Generating %s hashes...\n", len(files), algo) 72 | resultsChan := operations.GenerateHashes(context.Background(), files, algo, numThreads) 73 | 74 | var savedFiles []string 75 | for res := range resultsChan { 76 | if res.Err != nil { 77 | log.Error().Err(res.Err).Str("file", res.File).Msg("Error generating hash") 78 | continue 79 | } 80 | if saveToFileFlag { 81 | hashFilePath := res.File + "." + algo 82 | err := os.WriteFile(hashFilePath, []byte(res.Hash), 0644) 83 | if err != nil { 84 | log.Error().Err(err).Str("file", hashFilePath).Msg("Error writing hash to file") 85 | } else { 86 | savedFiles = append(savedFiles, hashFilePath) 87 | } 88 | } else { 89 | fmt.Printf("%s hash for \"%s\": %s\n", algo, res.File, res.Hash) 90 | } 91 | } 92 | 93 | if saveToFileFlag { 94 | fmt.Println("Generated hash files:") 95 | for _, file := range savedFiles { 96 | fmt.Println(file) 97 | } 98 | } 99 | }, 100 | } 101 | cmd.Flags().StringVarP(&algo, "algo", "a", "md5", fmt.Sprintf("Hash algorithm to use %v", hasher.HashAlgorithms)) 102 | cmd.Flags().BoolVarP(&recursiveFlag, "recursive", "r", true, "Process files in subdirectories? [true, false]") 103 | cmd.Flags().BoolVarP(&saveToFileFlag, "save", "s", false, "Save hash to files? [true, false]") 104 | cmd.Flags().BoolVarP(&cleanFlag, "clean", "c", false, "Remove old hash files before generating new ones? [true, false]") 105 | cmd.Flags().IntVarP(&numThreads, "threads", "t", 4, "Number of worker threads to use for hashing [1-16]") 106 | 107 | return cmd 108 | } 109 | 110 | func sizeCmd() *cobra.Command { 111 | var language, platformName, sizeUnit string 112 | var extrasFlag, dlcFlag bool 113 | 114 | cmd := &cobra.Command{ 115 | Use: "size [gameID]", 116 | Short: "Show the total storage size needed to download game files", 117 | Args: cobra.ExactArgs(1), 118 | Run: func(cmd *cobra.Command, args []string) { 119 | gameID, err := strconv.Atoi(args[0]) 120 | if err != nil { 121 | cmd.PrintErrln("Error: Invalid game ID. It must be a positive integer.") 122 | return 123 | } 124 | 125 | if err := validation.ValidateGameID(gameID); err != nil { 126 | e := clierr.New(clierr.Validation, "Invalid game ID", err) 127 | cmd.PrintErrln(e.Message) 128 | setLastCliErr(e) 129 | return 130 | } 131 | 132 | params := operations.EstimationParams{ 133 | LanguageCode: strings.ToLower(language), 134 | PlatformName: platformName, 135 | IncludeExtras: extrasFlag, 136 | IncludeDLCs: dlcFlag, 137 | } 138 | 139 | totalSizeBytes, gameData, err := operations.EstimateGameSize(gameID, params) 140 | if err != nil { 141 | e := clierr.New(clierr.Internal, "Error estimating storage size", err) 142 | cmd.PrintErrln(e.Message) 143 | setLastCliErr(e) 144 | return 145 | } 146 | 147 | log.Info().Msgf("Game title: \"%s\"\n", gameData.Title) 148 | log.Info().Msgf("Download parameters: Language=%s; Platform=%s; Extras=%t; DLCs=%t\n", params.LanguageCode, params.PlatformName, params.IncludeExtras, params.IncludeDLCs) 149 | 150 | sizeUnit = strings.ToLower(sizeUnit) 151 | switch sizeUnit { 152 | case "gb": 153 | fmt.Printf("Total download size: %.2f GB\n", float64(totalSizeBytes)/(1024*1024*1024)) 154 | case "mb": 155 | fmt.Printf("Total download size: %.2f MB\n", float64(totalSizeBytes)/(1024*1024)) 156 | case "kb": 157 | fmt.Printf("Total download size: %.2f KB\n", float64(totalSizeBytes)/1024) 158 | case "b": 159 | fmt.Printf("Total download size: %d B\n", totalSizeBytes) 160 | default: 161 | cmd.PrintErrf("invalid size unit: %q. Unit must be one of [gb, mb, kb, b]\n", sizeUnit) 162 | return 163 | } 164 | }, 165 | } 166 | cmd.Flags().StringVarP(&language, "lang", "l", "en", "Game language [en, fr, de, es, it, ru, pl, pt-BR, zh-Hans, ja, ko]") 167 | cmd.Flags().StringVarP(&platformName, "platform", "p", "windows", "Platform name [all, windows, mac, linux]; all means all platforms") 168 | cmd.Flags().BoolVarP(&extrasFlag, "extras", "e", true, "Include extra content files? [true, false]") 169 | cmd.Flags().BoolVarP(&dlcFlag, "dlcs", "d", true, "Include DLC files? [true, false]") 170 | cmd.Flags().StringVarP(&sizeUnit, "unit", "u", "gb", "Size unit to display [gb, mb, kb, b]") 171 | return cmd 172 | } 173 | -------------------------------------------------------------------------------- /cmd/catalogue_test.go: -------------------------------------------------------------------------------- 1 | package cmd 2 | 3 | import ( 4 | "bytes" 5 | "context" 6 | "encoding/json" 7 | "errors" 8 | "fmt" 9 | "os" 10 | "path/filepath" 11 | "testing" 12 | 13 | "github.com/stretchr/testify/assert" 14 | 15 | "github.com/habedi/gogg/auth" 16 | "github.com/habedi/gogg/db" 17 | "github.com/rs/zerolog/log" 18 | "github.com/spf13/cobra" 19 | "github.com/stretchr/testify/require" 20 | ) 21 | 22 | // TestMain sets up the database once for all tests in this package. 23 | func TestMain(m *testing.M) { 24 | // Setup: Initialize the database at once. 25 | tmpDir, err := os.MkdirTemp("", "gogg-cmd-test-") 26 | if err != nil { 27 | fmt.Fprintf(os.Stderr, "Failed to create temp dir for testing: %v\n", err) 28 | os.Exit(1) 29 | } 30 | db.Path = filepath.Join(tmpDir, "games.db") 31 | if err := db.InitDB(); err != nil { 32 | fmt.Fprintf(os.Stderr, "Failed to init db for testing: %v\n", err) 33 | os.Exit(1) 34 | } 35 | 36 | // Run all tests in the package. 37 | exitCode := m.Run() 38 | 39 | // Teardown: Clean up resources after all tests are done. 40 | if err := db.CloseDB(); err != nil { 41 | log.Error().Err(err).Msg("Failed to close db after testing") 42 | } 43 | os.RemoveAll(tmpDir) 44 | 45 | os.Exit(exitCode) 46 | } 47 | 48 | // cleanDBTables makes sure test isolation by clearing tables before each test. 49 | func cleanDBTables(t *testing.T) { 50 | t.Helper() 51 | err := db.Db.Exec("DELETE FROM games").Error 52 | require.NoError(t, err) 53 | err = db.Db.Exec("DELETE FROM tokens").Error 54 | require.NoError(t, err) 55 | } 56 | 57 | type mockTokenStorer struct { 58 | getTokenErr error 59 | } 60 | 61 | func (m *mockTokenStorer) GetTokenRecord() (*db.Token, error) { 62 | if m.getTokenErr != nil { 63 | return nil, m.getTokenErr 64 | } 65 | return &db.Token{RefreshToken: "valid-refresh-token"}, nil 66 | } 67 | func (m *mockTokenStorer) UpsertTokenRecord(token *db.Token) error { return nil } 68 | 69 | type mockTokenRefresher struct{} 70 | 71 | func (m *mockTokenRefresher) PerformTokenRefresh(refreshToken string) (string, string, int64, error) { 72 | return "new-access-token", "new-refresh-token", 3600, nil 73 | } 74 | 75 | func addTestGame(t *testing.T, repo db.GameRepository, id int, title, data string) { 76 | t.Helper() 77 | require.NoError(t, repo.Put(context.Background(), db.Game{ID: id, Title: title, Data: data})) 78 | } 79 | 80 | func captureCombinedOutput(cmd *cobra.Command, args ...string) (string, error) { 81 | buf := new(bytes.Buffer) 82 | cmd.SetOut(buf) 83 | cmd.SetErr(buf) 84 | cmd.SetArgs(args) 85 | 86 | err := cmd.Execute() 87 | 88 | return buf.String(), err 89 | } 90 | 91 | func TestListCmd(t *testing.T) { 92 | cleanDBTables(t) 93 | repo := db.NewGameRepository(db.GetDB()) 94 | dummyData := `{"dummy": "data"}` 95 | addTestGame(t, repo, 1, "Test Game 1", dummyData) 96 | addTestGame(t, repo, 2, "Test Game 2", dummyData) 97 | listCommand := listCmd(repo) 98 | output, err := captureCombinedOutput(listCommand) 99 | require.NoError(t, err) 100 | assert.Contains(t, output, "Test Game 1") 101 | assert.Contains(t, output, "Test Game 2") 102 | } 103 | 104 | func TestInfoCmd(t *testing.T) { 105 | cleanDBTables(t) 106 | repo := db.NewGameRepository(db.GetDB()) 107 | nested := map[string]interface{}{ 108 | "description": "A cool game", 109 | "rating": 5, 110 | } 111 | nestedBytes, err := json.Marshal(nested) 112 | require.NoError(t, err) 113 | addTestGame(t, repo, 10, "Info Test Game", string(nestedBytes)) 114 | infoCommand := infoCmd(repo) 115 | output, err := captureCombinedOutput(infoCommand, "10") 116 | require.NoError(t, err) 117 | assert.Contains(t, output, "cool game") 118 | } 119 | 120 | func TestSearchCmd(t *testing.T) { 121 | cleanDBTables(t) 122 | repo := db.NewGameRepository(db.GetDB()) 123 | dummyData := `{"dummy": "data"}` 124 | addTestGame(t, repo, 20, "Awesome Game", dummyData) 125 | addTestGame(t, repo, 21, "Not So Awesome", dummyData) 126 | searchCommand := searchCmd(repo) 127 | output, err := captureCombinedOutput(searchCommand, "Awesome") 128 | require.NoError(t, err) 129 | assert.Contains(t, output, "Awesome Game") 130 | assert.Contains(t, output, "Not So Awesome") 131 | 132 | addTestGame(t, repo, 30, "ID Game", dummyData) 133 | searchCommand = searchCmd(repo) 134 | output, err = captureCombinedOutput(searchCommand, "30", "--id") 135 | require.NoError(t, err) 136 | assert.Contains(t, output, "ID Game") 137 | } 138 | 139 | func TestExportCmd(t *testing.T) { 140 | cleanDBTables(t) 141 | repo := db.NewGameRepository(db.GetDB()) 142 | dummyData := `{"dummy": "data"}` 143 | addTestGame(t, repo, 40, "Export Test Game", dummyData) 144 | tmpExportDir := t.TempDir() 145 | exportCommand := exportCmd(repo) 146 | exportCommand.Flags().Set("format", "json") 147 | output, err := captureCombinedOutput(exportCommand, tmpExportDir) 148 | require.NoError(t, err) 149 | assert.Contains(t, output, tmpExportDir) 150 | 151 | exportedFiles, err := os.ReadDir(tmpExportDir) 152 | require.NoError(t, err) 153 | assert.NotEmpty(t, exportedFiles, "Export directory should contain files") 154 | 155 | var foundJSONFile bool 156 | for _, file := range exportedFiles { 157 | if filepath.Ext(file.Name()) == ".json" { 158 | foundJSONFile = true 159 | exportedFilePath := filepath.Join(tmpExportDir, file.Name()) 160 | content, err := os.ReadFile(exportedFilePath) 161 | require.NoError(t, err) 162 | assert.Contains(t, string(content), "Export Test Game", "Exported JSON should contain game title") 163 | break 164 | } 165 | } 166 | assert.True(t, foundJSONFile, "Should export at least one JSON file") 167 | } 168 | 169 | func TestRefreshCmd(t *testing.T) { 170 | cleanDBTables(t) 171 | storer := &mockTokenStorer{getTokenErr: errors.New("mock db error")} 172 | refresher := &mockTokenRefresher{} 173 | authService := auth.NewService(storer, refresher) 174 | 175 | refreshCommand := refreshCmd(authService) 176 | output, err := captureCombinedOutput(refreshCommand) 177 | require.NoError(t, err) 178 | 179 | expectedErrorMsg := "Error: Failed to refresh catalogue. Please check the logs for details." 180 | assert.Contains(t, output, expectedErrorMsg) 181 | } 182 | 183 | func TestCatalogueCliErr_NotFound(t *testing.T) { 184 | cleanDBTables(t) 185 | repo := db.NewGameRepository(db.GetDB()) 186 | infoCommand := infoCmd(repo) 187 | output, err := captureCombinedOutput(infoCommand, "99999") 188 | require.NoError(t, err) 189 | assert.Contains(t, output, "Game not found") 190 | } 191 | 192 | func TestCatalogueCliErr_InvalidExportFormat(t *testing.T) { 193 | cleanDBTables(t) 194 | repo := db.NewGameRepository(db.GetDB()) 195 | addTestGame(t, repo, 50, "Export Err Game", "{}") 196 | cmd := exportCmd(repo) 197 | cmd.Flags().Set("format", "xml") 198 | output, err := captureCombinedOutput(cmd, t.TempDir()) 199 | require.NoError(t, err) 200 | assert.Contains(t, output, "Invalid export format") 201 | } 202 | --------------------------------------------------------------------------------