├── .dockerignore ├── .editorconfig ├── .gitattributes ├── .github ├── FUNDING.yml ├── ISSUE_TEMPLATE │ ├── bug_report.md │ ├── config.yml │ └── feature_request.md └── workflows │ ├── lints.yml │ ├── release.yml │ └── tests.yml ├── .gitignore ├── .golangci.yml ├── .pre-commit-config.yaml ├── CODE_OF_CONDUCT.md ├── CONTRIBUTING.md ├── Dockerfile ├── LICENSE ├── Makefile ├── README.md ├── auth ├── interfaces.go ├── service_integration_test.go ├── service_test.go └── services.go ├── client ├── catalogue.go ├── data.go ├── data_test.go ├── download.go ├── download_test.go ├── games.go ├── games_test.go ├── login.go ├── login_test.go └── utils_test.go ├── cmd ├── catalogue.go ├── catalogue_test.go ├── cli.go ├── cli_test.go ├── download.go ├── file.go ├── gui.go ├── login.go └── version.go ├── codecov.yml ├── db ├── db.go ├── db_test.go ├── game.go ├── game_test.go ├── token.go └── token_test.go ├── docs ├── README.md ├── examples │ ├── calculate_storage_for_all_games.ps1 │ ├── download_all_games.ps1 │ ├── download_all_games.sh │ └── simple_example.sh ├── figures │ ├── make_figures.sh │ ├── package_dependency_graph.dot │ └── package_dependency_graph.svg └── 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 ├── go.mod ├── go.sum ├── gui ├── about.go ├── actions.go ├── assets.go ├── assets │ ├── JetBrainsMono-2.304 │ │ ├── AUTHORS.txt │ │ ├── OFL.txt │ │ └── fonts │ │ │ └── ttf │ │ │ ├── JetBrainsMono-Bold.ttf │ │ │ └── JetBrainsMono-Regular.ttf │ ├── ding-small-bell-sfx-233008.mp3 │ └── game-card-svgrepo-com.svg ├── desktop.go ├── download.go ├── events.go ├── file.go ├── library.go ├── manager.go ├── settings.go ├── shared.go ├── sound.go ├── theme.go ├── widgets.go └── window.go ├── logo.jpeg ├── main.go ├── main_test.go ├── pkg ├── hasher │ ├── hasher.go │ └── hasher_test.go ├── operations │ ├── hashing.go │ ├── hashing_test.go │ ├── storage.go │ └── storage_test.go └── pool │ ├── pool.go │ └── pool_test.go ├── pyproject.toml └── scripts ├── docker_entrypoint.sh ├── test_gogg_cli.sh └── test_makefile.sh /.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 | -------------------------------------------------------------------------------- /.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 | -------------------------------------------------------------------------------- /.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 | -------------------------------------------------------------------------------- /.github/FUNDING.yml: -------------------------------------------------------------------------------- 1 | github: [ habedi ] 2 | -------------------------------------------------------------------------------- /.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 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/config.yml: -------------------------------------------------------------------------------- 1 | blank_issues_enabled: false 2 | -------------------------------------------------------------------------------- /.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 | -------------------------------------------------------------------------------- /.github/workflows/lints.yml: -------------------------------------------------------------------------------- 1 | name: Run Linters 2 | 3 | on: 4 | workflow_dispatch: 5 | pull_request: 6 | branches: 7 | - "main" 8 | push: 9 | tags: 10 | - 'v*' 11 | 12 | permissions: 13 | contents: read 14 | 15 | jobs: 16 | lints: 17 | runs-on: ubuntu-latest 18 | 19 | steps: 20 | - name: Checkout Repository 21 | uses: actions/checkout@v4 22 | 23 | - name: Set up Go Environment 24 | uses: actions/setup-go@v5 25 | with: 26 | go-version: stable 27 | 28 | - name: Cache Go Modules 29 | uses: actions/cache@v4 30 | with: 31 | path: | 32 | ~/.cache/go-build 33 | ~/go/pkg/mod 34 | key: ${{ runner.os }}-go-${{ hashFiles('**/go.sum') }} 35 | restore-keys: | 36 | ${{ runner.os }}-go- 37 | 38 | - name: Install Dependencies 39 | run: | 40 | make install-deps 41 | 42 | - name: Run Linters 43 | run: | 44 | make lint 45 | -------------------------------------------------------------------------------- /.github/workflows/release.yml: -------------------------------------------------------------------------------- 1 | name: Make a Release 2 | 3 | on: 4 | workflow_dispatch: 5 | push: 6 | tags: 7 | - 'v*' 8 | 9 | permissions: 10 | contents: read 11 | 12 | jobs: 13 | 14 | build-windows: 15 | name: Build Windows Binary 16 | runs-on: windows-latest 17 | steps: 18 | - name: Checkout Repository 19 | uses: actions/checkout@v4 20 | with: 21 | lfs: true 22 | 23 | - name: Setup MSYS2 Environment 24 | uses: msys2/setup-msys2@v2 25 | with: 26 | msystem: MINGW64 27 | update: true 28 | install: >- 29 | make 30 | mingw-w64-x86_64-go 31 | mingw-w64-x86_64-gcc 32 | mingw-w64-x86_64-pkg-config 33 | mingw-w64-x86_64-freeglut 34 | 35 | - name: Run Tests 36 | shell: msys2 {0} 37 | run: | 38 | make test 39 | 40 | - name: Build Windows Binary 41 | shell: msys2 {0} 42 | run: | 43 | make release GOGG_BINARY=gogg.exe 44 | 45 | - name: Upload Windows Artifact 46 | uses: actions/upload-artifact@v4 47 | with: 48 | name: gogg-windows-amd64 49 | path: bin/gogg.exe 50 | 51 | build-linux: 52 | name: Build Linux Binary 53 | runs-on: ubuntu-latest 54 | env: 55 | LANG: en_US.UTF-8 56 | LC_ALL: en_US.UTF-8 57 | steps: 58 | - name: Checkout Repository 59 | uses: actions/checkout@v4 60 | with: 61 | lfs: true 62 | 63 | - name: Set up Go 64 | uses: actions/setup-go@v5 65 | with: 66 | go-version: stable 67 | 68 | - name: Install Dependencies 69 | run: | 70 | sudo apt-get update 71 | make install-deps 72 | 73 | - name: Run Tests 74 | run: | 75 | make test 76 | 77 | - name: Build Linux Binary 78 | run: | 79 | make release 80 | 81 | - name: Upload Linux Artifact 82 | uses: actions/upload-artifact@v4 83 | with: 84 | name: gogg-linux-amd64 85 | path: bin/gogg 86 | 87 | build-macos: 88 | name: Build macOS Binary 89 | runs-on: macos-latest 90 | steps: 91 | - name: Checkout Repository 92 | uses: actions/checkout@v4 93 | with: 94 | lfs: true 95 | 96 | - name: Set up Go 97 | uses: actions/setup-go@v5 98 | with: 99 | go-version: stable 100 | 101 | - name: Install Dependencies 102 | run: | 103 | brew install make pkg-config glfw 104 | 105 | - name: Run Tests 106 | run: | 107 | make test 108 | 109 | - name: Build macOS Binary 110 | run: | 111 | make release-macos 112 | 113 | - name: Upload macOS Artifact 114 | uses: actions/upload-artifact@v4 115 | with: 116 | name: gogg-macos-arm64 117 | path: bin/gogg 118 | 119 | build-and-push-docker: 120 | runs-on: ubuntu-latest 121 | needs: [ build-linux ] 122 | permissions: 123 | contents: read 124 | packages: write 125 | 126 | steps: 127 | - name: Checkout Repository 128 | uses: actions/checkout@v4 129 | 130 | - name: Log in to the GitHub Container Registry 131 | uses: docker/login-action@v3 132 | with: 133 | registry: ghcr.io 134 | username: ${{ github.repository_owner }} 135 | password: ${{ secrets.GITHUB_TOKEN }} 136 | 137 | - name: Extract Metadata (Tags and Labels) for Docker 138 | id: meta 139 | uses: docker/metadata-action@v5 140 | with: 141 | images: ghcr.io/${{ github.repository }} 142 | tags: | 143 | type=raw,value=latest 144 | type=ref,event=branch 145 | type=ref,event=tag 146 | 147 | - name: Build and Push Docker Image 148 | uses: docker/build-push-action@v5 149 | with: 150 | context: . 151 | push: true 152 | tags: ${{ steps.meta.outputs.tags }} 153 | labels: ${{ steps.meta.outputs.labels }} 154 | 155 | release: 156 | runs-on: ubuntu-latest 157 | needs: [ build-windows, build-linux, build-macos ] 158 | permissions: 159 | contents: write 160 | steps: 161 | - name: Download Windows Artifact 162 | uses: actions/download-artifact@v4 163 | with: 164 | name: gogg-windows-amd64 165 | path: ./dist/windows 166 | 167 | - name: Download Linux Artifact 168 | uses: actions/download-artifact@v4 169 | with: 170 | name: gogg-linux-amd64 171 | path: ./dist/linux 172 | 173 | - name: Download macOS Artifact 174 | uses: actions/download-artifact@v4 175 | with: 176 | name: gogg-macos-arm64 177 | path: ./dist/macos 178 | 179 | - name: List Downloaded Files (for debugging) 180 | run: ls -R ./dist 181 | 182 | - name: Create Archives for Each Platform 183 | run: | 184 | cd dist/windows && zip -r9 ../gogg-windows-amd64.zip gogg.exe && cd ../.. 185 | cd dist/linux && zip -r9 ../gogg-linux-amd64.zip gogg && cd ../.. 186 | cd dist/macos && zip -r9 ../gogg-macos-arm64.zip gogg && cd ../.. 187 | 188 | - name: List Archives (for debugging) 189 | run: ls -l dist/gogg-*.zip 190 | 191 | - name: Create GitHub Release 192 | uses: ncipollo/release-action@v1 193 | if: github.ref_type == 'tag' && startsWith(github.ref_name, 'v') 194 | with: 195 | token: ${{ secrets.GITHUB_TOKEN }} 196 | name: ${{ github.ref_name }} 197 | tag: ${{ github.ref_name }} 198 | body: | 199 | Release version ${{ github.ref_name }} 200 | artifacts: | 201 | dist/gogg-windows-amd64.zip 202 | dist/gogg-linux-amd64.zip 203 | dist/gogg-macos-arm64.zip 204 | draft: true 205 | prerelease: false 206 | -------------------------------------------------------------------------------- /.github/workflows/tests.yml: -------------------------------------------------------------------------------- 1 | name: Run Tests 2 | 3 | on: 4 | workflow_dispatch: 5 | pull_request: 6 | branches: 7 | - "main" 8 | push: 9 | tags: 10 | - 'v*' 11 | 12 | permissions: 13 | contents: read 14 | 15 | jobs: 16 | tests: 17 | runs-on: ubuntu-latest 18 | 19 | strategy: 20 | matrix: 21 | go-version: [ "1.21", "1.22", "1.23", "1.24" ] 22 | 23 | steps: 24 | - name: Checkout Repository 25 | uses: actions/checkout@v4 26 | 27 | - name: Set up Go ${{ matrix.go-version }} 28 | uses: actions/setup-go@v5 29 | with: 30 | go-version: ${{ matrix.go-version }} 31 | 32 | - name: Cache Go Modules 33 | uses: actions/cache@v4 34 | with: 35 | path: | 36 | ~/.cache/go-build 37 | ~/go/pkg/mod 38 | key: ${{ runner.os }}-go-${{ hashFiles('**/go.sum') }} 39 | restore-keys: | 40 | ${{ runner.os }}-go- 41 | 42 | - name: Install Dependencies 43 | run: | 44 | sudo apt-get update 45 | sudo apt-get install -y make 46 | make install-deps 47 | 48 | - name: Run Tests and Generate Coverage Report 49 | run: | 50 | make test 51 | 52 | - name: Upload Coverage Reports to Codecov 53 | uses: codecov/codecov-action@v5 54 | with: 55 | token: ${{ secrets.CODECOV_TOKEN }} 56 | continue-on-error: false 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 | -------------------------------------------------------------------------------- /.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 | -------------------------------------------------------------------------------- /.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 | - id: end-of-file-fixer 8 | - id: check-yaml 9 | - id: check-merge-conflict 10 | - id: check-added-large-files 11 | args: [ '--maxkb=600' ] 12 | 13 | - repo: local 14 | hooks: 15 | - id: format 16 | name: Format the code 17 | entry: make format 18 | language: system 19 | pass_filenames: false 20 | types: [ 'go' ] 21 | 22 | - id: lint 23 | name: Check code style 24 | entry: make lint 25 | language: system 26 | pass_filenames: false 27 | types: [ 'go' ] 28 | 29 | - id: test 30 | name: Run tests 31 | entry: make test 32 | language: system 33 | pass_filenames: false 34 | -------------------------------------------------------------------------------- /CODE_OF_CONDUCT.md: -------------------------------------------------------------------------------- 1 | # Code of Conduct 2 | 3 | We adhere to the [Go Community Code of Conduct](https://go.dev/conduct). 4 | -------------------------------------------------------------------------------- /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 [Go Community Code of Conduct](https://go.dev/conduct). 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 | -------------------------------------------------------------------------------- /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 | -------------------------------------------------------------------------------- /Makefile: -------------------------------------------------------------------------------- 1 | # Variables 2 | REPO := github.com/habedi/gogg 3 | BINARY_NAME := $(or $(GOGG_BINARY), $(notdir $(REPO))) 4 | BINARY := bin/$(BINARY_NAME) 5 | COVER_PROFILE := coverage.txt 6 | GO_FILES := $(shell find . -type f -name '*.go') 7 | EXTRA_TMP_FILES := $(shell find . -type f -name 'gogg_*.csv' -o -name 'gogg_*.json' -o -name '*_output.txt') 8 | GO ?= go 9 | MAIN ?= ./main.go 10 | ECHO := @echo 11 | RELEASE_FLAGS := -ldflags="-s -w" -trimpath 12 | FYNE_TAGS := -tags desktop,gl 13 | 14 | # Adjust PATH if necessary (append /snap/bin if not present) 15 | PATH := $(if $(findstring /snap/bin,$(PATH)),$(PATH),/snap/bin:$(PATH)) 16 | 17 | #################################################################################################### 18 | ## Shell Settings 19 | #################################################################################################### 20 | SHELL := /bin/bash 21 | .SHELLFLAGS := -e -o pipefail -c 22 | 23 | #################################################################################################### 24 | ## Go Targets 25 | #################################################################################################### 26 | 27 | # Default target 28 | .DEFAULT_GOAL := help 29 | 30 | help: ## Show this help message 31 | @echo "Usage: make " 32 | @echo "" 33 | @echo "Targets:" 34 | @grep -E '^[a-zA-Z_-]+:.*## .*$$' Makefile | \ 35 | awk 'BEGIN {FS = ":.*## "}; {printf " \033[36m%-15s\033[0m %s\n", $$1, $$2}' 36 | 37 | .PHONY: format 38 | format: ## Format Go files 39 | $(ECHO) "Formatting Go files..." 40 | @$(GO) fmt ./... 41 | 42 | .PHONY: test 43 | test: format ## Run tests with coverage 44 | $(ECHO) "Running all tests with coverage" 45 | @$(GO) test -v ./... --cover --coverprofile=$(COVER_PROFILE) --race 46 | 47 | .PHONY: showcov 48 | showcov: test ## Display test coverage report 49 | $(ECHO) "Displaying test coverage report..." 50 | @$(GO) tool cover -func=$(COVER_PROFILE) 51 | 52 | .PHONY: build 53 | build: format ## Build the binary for the current platform 54 | $(ECHO) "Tidying dependencies..." 55 | @$(GO) mod tidy 56 | $(ECHO) "Building the binary..." 57 | @$(GO) build $(FYNE_TAGS) -o $(BINARY) $(MAIN) 58 | @$(ECHO) "Build complete: $(BINARY)" 59 | 60 | .PHONY: build-macos 61 | build-macos: format ## Build binary for macOS (v14 and newer; arm64) 62 | $(ECHO) "Tidying dependencies..." 63 | @$(GO) mod tidy 64 | $(ECHO) "Building arm64 binary for macOS..." 65 | @mkdir -p bin 66 | export CGO_ENABLED=1 ;\ 67 | export CGO_CFLAGS="$$(pkg-config --cflags glfw3)" ;\ 68 | export CGO_LDFLAGS="$$(pkg-config --libs glfw3)" ;\ 69 | GOARCH=arm64 $(GO) build -v $(FYNE_TAGS) -ldflags="-s -w" -o $(BINARY) $(MAIN) ;\ 70 | echo "Build complete: $(BINARY)" 71 | 72 | .PHONY: run 73 | run: build ## Build and run the binary 74 | $(ECHO) "Running the $(BINARY) binary..." 75 | @./$(BINARY) 76 | 77 | .PHONY: clean 78 | clean: ## Remove artifacts and temporary files 79 | $(ECHO) "Cleaning up..." 80 | @$(GO) clean #-cache -testcache -modcache 81 | @find . -type f -name '*.got.*' -delete 82 | @find . -type f -name '*.out' -delete 83 | @find . -type f -name '*.snap' -delete 84 | @rm -f $(COVER_PROFILE) 85 | @rm -rf bin/ 86 | @rm -f $(EXTRA_TMP_FILES) 87 | 88 | #################################################################################################### 89 | ## Dependency & Lint Targets 90 | #################################################################################################### 91 | 92 | .PHONY: install-snap 93 | install-snap: ## Install Snap (for Debian-based systems) 94 | $(ECHO) "Installing Snap..." 95 | @sudo apt-get update 96 | @sudo apt-get install -y snapd 97 | @sudo snap refresh 98 | 99 | .PHONY: install-deps 100 | install-deps: ## Install development dependencies (for Debian-based systems) 101 | $(ECHO) "Installing dependencies..." 102 | @sudo apt-get install -y make libgl1-mesa-dev libx11-dev xorg-dev \ 103 | libxcursor-dev libxrandr-dev libxinerama-dev libxi-dev pkg-config libasound2-dev 104 | @$(MAKE) install-snap 105 | @sudo snap install go --classic 106 | @sudo snap install golangci-lint --classic 107 | @$(GO) install mvdan.cc/gofumpt@latest 108 | @$(GO) install github.com/google/pprof@latest 109 | @$(GO) mod download 110 | 111 | .PHONY: lint 112 | lint: format ## Run the linters 113 | $(ECHO) "Linting Go files..." 114 | @golangci-lint run ./... 115 | 116 | .PHONY: gofumpt 117 | gofumpt: ## Run gofumpt to format Go files 118 | $(ECHO) "Running gofumpt for formatting..." 119 | @gofumpt -l -w . 120 | 121 | .PHONY: release 122 | release: ## Build the release binary for current platform (Linux and Windows) 123 | $(ECHO) "Tidying dependencies..." 124 | @$(GO) mod tidy 125 | $(ECHO) "Building the release binary..." 126 | @$(GO) build $(RELEASE_FLAGS) $(FYNE_TAGS) -o $(BINARY) $(MAIN) 127 | @$(ECHO) "Build complete: $(BINARY)" 128 | 129 | .PHONY: release-macos 130 | release-macos: ## Build release binary for macOS (v14 and newer; arm64) 131 | $(ECHO) "Tidying dependencies..." 132 | @$(GO) mod tidy 133 | $(ECHO) "Building arm64 release binary for macOS..." 134 | @mkdir -p bin 135 | export CGO_ENABLED=1 ;\ 136 | export CGO_CFLAGS="$$(pkg-config --cflags glfw3)" ;\ 137 | export CGO_LDFLAGS="$$(pkg-config --libs glfw3)" ;\ 138 | GOARCH=arm64 $(GO) build $(RELEASE_FLAGS) $(FYNE_TAGS) -o $(BINARY) $(MAIN) ;\ 139 | echo "Build complete: $(BINARY)" 140 | 141 | .PHONY: setup-hooks 142 | setup-hooks: ## Install Git hooks (pre-commit and pre-push) 143 | @echo "Setting up Git hooks..." 144 | @if ! command -v pre-commit &> /dev/null; then \ 145 | echo "pre-commit not found. Please install it using 'pip install pre-commit'"; \ 146 | exit 1; \ 147 | fi 148 | @pre-commit install --hook-type pre-commit 149 | @pre-commit install --hook-type pre-push 150 | @pre-commit install-hooks 151 | 152 | .PHONY: test-hooks 153 | test-hooks: ## Test Git hooks on all files 154 | @echo "Testing Git hooks..." 155 | @pre-commit run --all-files --show-diff-on-failure 156 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 |
2 | 3 | Gogg Logo 4 | 5 |
6 |
7 | 8 |
9 | 10 | Tests 11 | 12 | 13 | Linux Build 14 | 15 | 16 | Windows Build 17 | 18 | 19 | MacOS Build 20 | 21 |
22 | 23 | Docs 24 | 25 | 26 | License 27 | 28 | 29 | Code Coverage 30 | 31 | 32 | CodeFactor 33 | 34 | 35 | Docker Image 36 | 37 | 38 | Release 39 | 40 | 41 | Total Downloads 42 | 43 |
44 | 45 | --- 46 | 47 | Gogg is a minimalistic tool for downloading game files from [GOG.com](https://www.gog.com/). 48 | It is written in [Go](https://golang.org/) and uses the 49 | official [GOG API](https://gogapidocs.readthedocs.io/en/latest/index.html). 50 | 51 | The main goal of Gogg is to provide a simple and easy-to-use interface for people who want to download their GOG games 52 | for offline use or archival purposes. 53 | 54 | ### Features 55 | 56 | Main features of Gogg: 57 | 58 | - It can be used to fully automate the download process with a few simple commands. 59 | - It can run anywhere (Windows, macOS, or Linux) that a Go compiler is available. 60 | - It has a graphical user interface (GUI) that lets users search and download games they own on GOG. 61 | 62 | Additionally, it allows users to perform the following actions: 63 | 64 | - List owned games 65 | - Export the list of owned games to a file 66 | - Search in the owned games 67 | - Download game files (like installers, patches, and bonus content) 68 | - Filter files to be downloaded by platform, language, and other attributes like content type 69 | - Download files using multiple threads to speed up the process 70 | - Resume interrupted downloads and only download missing or newer files 71 | - Verify the integrity of downloaded files by calculating their hashes 72 | - Calculate the total size of the files to be downloaded (for storage planning) 73 | 74 | --- 75 | 76 | ### Getting Started 77 | 78 | See the [documentation](docs/README.md) for how to install and use Gogg. 79 | 80 | Run `gogg -h` to see the available commands and options. 81 | 82 | > [!NOTE] 83 | > * Since version `0.4.1`, Gogg has a GUI besides its command line interface (CLI). 84 | > The GUI is still in the early stages of development and does not support all the features of the CLI and may have 85 | > bugs. 86 | > To start the GUI, run `gogg gui`. 87 | > * Since version `0.4.2`, there are Docker images available for Gogg. 88 | > See the [documentation](docs/README.md#containerization) for more information. 89 | 90 | #### Examples 91 | 92 | | File | Description | 93 | |------------------------------------------------------------------------------------------|---------------------------------------------------------------------| 94 | | [calculate_storage_for_all_games.ps1](docs/examples/calculate_storage_for_all_games.ps1) | PowerShell script to calculate storage size for all games user owns | 95 | | [download_all_games.ps1](docs/examples/download_all_games.ps1) | PowerShell script to download all games user owns | 96 | | [download_all_games.sh](docs/examples/download_all_games.sh) | Bash script to download all games user owns | 97 | | [simple_example.sh](docs/examples/simple_example.sh) | Simple examples of how to use Gogg from the command line | 98 | 99 | ##### Login to GOG 100 | 101 | ```bash 102 | # First-time using Gogg, you need to log in to GOG to authenticate 103 | gogg login 104 | ``` 105 | 106 | > [!IMPORTANT] 107 | > You might need to have [Google Chrome](https://www.google.com/chrome/), [Chromium](https://www.chromium.org/), or 108 | > [Microsoft Edge](https://www.microsoft.com/edge) browsers installed on your machine for the first-time authentication. 109 | > So, make sure you have one of them installed and available in your system's PATH. 110 | 111 | ##### Syncing the Game Catalogue 112 | 113 | ```bash 114 | # Will fetch the up-to-date information about the games you own on GOG 115 | gogg catalogue refresh 116 | ``` 117 | 118 | ##### Searching for Games 119 | 120 | ```bash 121 | # Will show the game ID and title of the games that contain "Witcher" in their title 122 | gogg catalogue search "Witcher" 123 | ``` 124 | 125 | ##### Downloading a Game 126 | 127 | ```bash 128 | # Will download the files for `The Witcher: Enhanced Edition` to `./games` directory (without extra content) 129 | gogg download 1207658924 ./games --platform=windows --lang=en --dlcs=true --extras=false \ 130 | --resume=true --threads 5 --flatten=true 131 | ``` 132 | 133 | ##### File Hashes (For Verification) 134 | 135 | ```bash 136 | # Will show the SHA1 hash of the downloaded files for `The Witcher: Enhanced Edition` 137 | gogg file hash ./games/the-witcher-enhanced-edition --algo=sha1 138 | ``` 139 | 140 | ##### Storage Size Calculation 141 | 142 | ```bash 143 | # Will show the total size of the files to be downloaded for `The Witcher: Enhanced Edition` 144 | DEBUG_GOGG=false gogg file size 1207658924 --platform=windows --lang=en --dlcs=true \ 145 | --extras=false --unit=GB 146 | ``` 147 | 148 | ### CLI Demo 149 | 150 | [![asciicast](https://asciinema.org/a/kXMGRUUV149R37IEmZKtTH7nI.svg)](https://asciinema.org/a/kXMGRUUV149R37IEmZKtTH7nI) 151 | 152 | ### GUI Screenshots 153 | 154 |
155 | Game Library 156 |
157 | 158 |
159 | Show more screenshots 160 | 161 |
162 | File Operations 163 | Download Games 164 | About 165 | Download Progress 166 |
167 | 168 |
169 | 170 | --- 171 | 172 | ### Contributing 173 | 174 | Please see the [CONTRIBUTING.md](CONTRIBUTING.md) file for information on how to contribute to Gogg. 175 | 176 | ### License 177 | 178 | Gogg is licensed under the [MIT License](LICENSE). 179 | -------------------------------------------------------------------------------- /auth/interfaces.go: -------------------------------------------------------------------------------- 1 | package auth 2 | 3 | import "github.com/habedi/gogg/db" 4 | 5 | // TokenStorer defines the contract for any component that can store and retrieve a token. 6 | type TokenStorer interface { 7 | GetTokenRecord() (*db.Token, error) 8 | UpsertTokenRecord(token *db.Token) error 9 | } 10 | 11 | // TokenRefresher defines the contract for any component that can perform a token refresh action. 12 | type TokenRefresher interface { 13 | PerformTokenRefresh(refreshToken string) (accessToken string, newRefreshToken string, expiresIn int64, err error) 14 | } 15 | -------------------------------------------------------------------------------- /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 | r.ParseForm() 34 | assert.Equal(t, "expired-refresh-token", r.FormValue("refresh_token")) 35 | 36 | w.Header().Set("Content-Type", "application/json") 37 | w.WriteHeader(http.StatusOK) 38 | json.NewEncoder(w).Encode(map[string]interface{}{ 39 | "access_token": "new-shiny-access-token", 40 | "refresh_token": "new-shiny-refresh-token", 41 | "expires_in": 3600, 42 | }) 43 | })) 44 | defer server.Close() 45 | 46 | expiredToken := &db.Token{ 47 | AccessToken: "expired-access-token", 48 | RefreshToken: "expired-refresh-token", 49 | ExpiresAt: time.Now().Add(-1 * time.Hour).Format(time.RFC3339), 50 | } 51 | require.NoError(t, db.UpsertTokenRecord(expiredToken)) 52 | 53 | storer := &db.TokenStore{} 54 | refresher := &client.GogClient{TokenURL: server.URL + "/token"} 55 | authService := auth.NewService(storer, refresher) 56 | 57 | refreshedToken, err := authService.RefreshToken() 58 | 59 | require.NoError(t, err) 60 | assert.Equal(t, "new-shiny-access-token", refreshedToken.AccessToken) 61 | assert.Equal(t, "new-shiny-refresh-token", refreshedToken.RefreshToken) 62 | 63 | dbToken, err := db.GetTokenRecord() 64 | require.NoError(t, err) 65 | assert.Equal(t, "new-shiny-access-token", dbToken.AccessToken) 66 | } 67 | 68 | func TestRefreshToken_Integration_ApiFailure(t *testing.T) { 69 | setupTestDB(t) 70 | 71 | server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { 72 | w.Header().Set("Content-Type", "application/json") 73 | w.WriteHeader(http.StatusUnauthorized) 74 | json.NewEncoder(w).Encode(map[string]string{ 75 | "error_description": "Invalid refresh token", 76 | }) 77 | })) 78 | defer server.Close() 79 | 80 | expiredToken := &db.Token{ 81 | AccessToken: "old-token", 82 | RefreshToken: "invalid-refresh", 83 | ExpiresAt: time.Now().Add(-2 * time.Hour).Format(time.RFC3339), 84 | } 85 | require.NoError(t, db.UpsertTokenRecord(expiredToken)) 86 | 87 | storer := &db.TokenStore{} 88 | refresher := &client.GogClient{TokenURL: server.URL + "/token"} 89 | authService := auth.NewService(storer, refresher) 90 | 91 | _, err := authService.RefreshToken() 92 | 93 | require.Error(t, err) 94 | assert.Contains(t, err.Error(), "Invalid refresh token") 95 | 96 | dbToken, err := db.GetTokenRecord() 97 | require.NoError(t, err) 98 | assert.Equal(t, "old-token", dbToken.AccessToken, "Token in DB should not have been updated on failure") 99 | } 100 | -------------------------------------------------------------------------------- /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 | -------------------------------------------------------------------------------- /auth/services.go: -------------------------------------------------------------------------------- 1 | package auth 2 | 3 | import ( 4 | "fmt" 5 | "time" 6 | 7 | "github.com/habedi/gogg/db" 8 | "github.com/rs/zerolog/log" 9 | ) 10 | 11 | // Service orchestrates the token refresh process using its dependencies. 12 | type Service struct { 13 | Storer TokenStorer 14 | Refresher TokenRefresher 15 | } 16 | 17 | // NewService is the constructor for our auth service. 18 | func NewService(storer TokenStorer, refresher TokenRefresher) *Service { 19 | return &Service{ 20 | Storer: storer, 21 | Refresher: refresher, 22 | } 23 | } 24 | 25 | // RefreshToken is a method that handles the full token refresh logic. 26 | func (s *Service) RefreshToken() (*db.Token, error) { 27 | token, err := s.Storer.GetTokenRecord() 28 | if err != nil { 29 | return nil, fmt.Errorf("failed to retrieve token record: %w", err) 30 | } 31 | 32 | valid, err := isTokenValid(token) 33 | if err != nil { 34 | return nil, fmt.Errorf("failed to check token validity: %w", err) 35 | } 36 | 37 | if !valid { 38 | log.Info().Msg("Access token expired or invalid, refreshing...") 39 | newAccessToken, newRefreshToken, expiresIn, err := s.Refresher.PerformTokenRefresh(token.RefreshToken) 40 | if err != nil { 41 | return nil, fmt.Errorf("failed to perform token refresh via client: %w", err) 42 | } 43 | 44 | token.AccessToken = newAccessToken 45 | token.RefreshToken = newRefreshToken 46 | token.ExpiresAt = time.Now().Add(time.Duration(expiresIn) * time.Second).Format(time.RFC3339) 47 | 48 | if err := s.Storer.UpsertTokenRecord(token); err != nil { 49 | return nil, fmt.Errorf("failed to save refreshed token: %w", err) 50 | } 51 | log.Info().Msg("Token refreshed and saved successfully.") 52 | } 53 | 54 | return token, nil 55 | } 56 | 57 | // isTokenValid checks if the access token is still valid. 58 | func isTokenValid(token *db.Token) (bool, error) { 59 | if token == nil { 60 | return false, fmt.Errorf("token record does not exist in the database; please login first") 61 | } 62 | if token.AccessToken == "" || token.RefreshToken == "" || token.ExpiresAt == "" { 63 | return false, nil 64 | } 65 | expiresAt, err := time.Parse(time.RFC3339, token.ExpiresAt) 66 | if err != nil { 67 | log.Error().Err(err).Msgf("Failed to parse expiration time: %s", token.ExpiresAt) 68 | return false, err 69 | } 70 | return time.Now().Add(5 * time.Minute).Before(expiresAt), nil 71 | } 72 | -------------------------------------------------------------------------------- /client/catalogue.go: -------------------------------------------------------------------------------- 1 | package client 2 | 3 | import ( 4 | "context" 5 | "fmt" 6 | "sync/atomic" 7 | 8 | "github.com/habedi/gogg/auth" 9 | "github.com/habedi/gogg/db" 10 | "github.com/habedi/gogg/pkg/pool" 11 | "github.com/rs/zerolog/log" 12 | ) 13 | 14 | // RefreshCatalogue fetches all owned game details from GOG and updates the local database. 15 | // It reports progress via the progressCb callback, which receives a value from 0.0 to 1.0. 16 | func RefreshCatalogue( 17 | ctx context.Context, 18 | authService *auth.Service, 19 | numWorkers int, 20 | progressCb func(float64), 21 | ) error { 22 | token, err := authService.RefreshToken() 23 | if err != nil { 24 | return fmt.Errorf("failed to refresh token: %w", err) 25 | } 26 | 27 | gameIDs, err := FetchIdOfOwnedGames(token.AccessToken, "https://embed.gog.com/user/data/games") 28 | if err != nil { 29 | return fmt.Errorf("failed to fetch owned game IDs: %w", err) 30 | } 31 | if len(gameIDs) == 0 { 32 | log.Info().Msg("No games found in the GOG account.") 33 | if progressCb != nil { 34 | progressCb(1.0) // Signal completion 35 | } 36 | return nil 37 | } 38 | 39 | if err := db.EmptyCatalogue(); err != nil { 40 | return fmt.Errorf("failed to empty catalogue: %w", err) 41 | } 42 | 43 | var processedCount atomic.Int64 44 | totalGames := float64(len(gameIDs)) 45 | 46 | workerFunc := func(ctx context.Context, id int) error { 47 | // Defer the counter increment to guarantee it runs even if a fetch fails. 48 | defer func() { 49 | count := processedCount.Add(1) 50 | if progressCb != nil { 51 | progress := float64(count) / totalGames 52 | progressCb(progress) 53 | } 54 | }() 55 | 56 | url := fmt.Sprintf("https://embed.gog.com/account/gameDetails/%d.json", id) 57 | details, raw, fetchErr := FetchGameData(token.AccessToken, url) 58 | if fetchErr != nil { 59 | log.Warn().Err(fetchErr).Int("gameID", id).Msg("Failed to fetch game details") 60 | return nil // Don't treat as a fatal error for the pool 61 | } 62 | if details.Title != "" { 63 | if err := db.PutInGame(id, details.Title, raw); err != nil { 64 | log.Error().Err(err).Int("gameID", id).Msg("Failed to save game to DB") 65 | } 66 | } 67 | 68 | return nil 69 | } 70 | 71 | _ = pool.Run(ctx, gameIDs, numWorkers, workerFunc) 72 | 73 | return ctx.Err() 74 | } 75 | -------------------------------------------------------------------------------- /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 | aux := &struct { 71 | RawDownloads [][]interface{} `json:"downloads"` 72 | *Alias 73 | }{ 74 | Alias: (*Alias)(gd), 75 | } 76 | 77 | if err := json.Unmarshal(data, &aux); err != nil { 78 | return err 79 | } 80 | 81 | // Process RawDownloads for Game. 82 | gd.Downloads = parseRawDownloads(aux.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 | -------------------------------------------------------------------------------- /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 | -------------------------------------------------------------------------------- /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 | -------------------------------------------------------------------------------- /client/games.go: -------------------------------------------------------------------------------- 1 | package client 2 | 3 | import ( 4 | "encoding/json" 5 | "fmt" 6 | "io" 7 | "net/http" 8 | "strconv" 9 | "strings" 10 | "time" 11 | 12 | "github.com/rs/zerolog/log" 13 | ) 14 | 15 | func FetchGameData(accessToken string, url string) (Game, string, error) { 16 | req, err := createRequest("GET", url, accessToken) 17 | if err != nil { 18 | return Game{}, "", err 19 | } 20 | 21 | resp, err := sendRequest(req) 22 | if err != nil { 23 | return Game{}, "", err 24 | } 25 | defer resp.Body.Close() 26 | 27 | body, err := readResponseBody(resp) 28 | if err != nil { 29 | return Game{}, "", err 30 | } 31 | 32 | var game Game 33 | if err := parseGameData(body, &game); err != nil { 34 | return Game{}, "", err 35 | } 36 | 37 | return game, string(body), nil 38 | } 39 | 40 | func FetchIdOfOwnedGames(accessToken string, apiURL string) ([]int, error) { 41 | req, err := createRequest("GET", apiURL, accessToken) 42 | if err != nil { 43 | return nil, err 44 | } 45 | 46 | resp, err := sendRequest(req) 47 | if err != nil { 48 | return nil, err 49 | } 50 | defer resp.Body.Close() 51 | 52 | body, err := readResponseBody(resp) 53 | if err != nil { 54 | return nil, err 55 | } 56 | 57 | return parseOwnedGames(body) 58 | } 59 | 60 | func createRequest(method, url, accessToken string) (*http.Request, error) { 61 | req, err := http.NewRequest(method, url, nil) 62 | if err != nil { 63 | log.Error().Err(err).Msg("Failed to create request") 64 | return nil, err 65 | } 66 | req.Header.Set("Authorization", fmt.Sprintf("Bearer %s", accessToken)) 67 | return req, nil 68 | } 69 | 70 | func sendRequest(req *http.Request) (*http.Response, error) { 71 | client := &http.Client{Timeout: 30 * time.Second} 72 | var resp *http.Response 73 | var err error 74 | 75 | const maxRetries = 3 76 | backoff := 1 * time.Second 77 | 78 | for i := 0; i < maxRetries; i++ { 79 | resp, err = client.Do(req) 80 | if err != nil { 81 | log.Warn().Err(err).Int("attempt", i+1).Int("max_attempts", maxRetries).Msg("Request failed, retrying...") 82 | time.Sleep(backoff) 83 | backoff *= 2 84 | continue 85 | } 86 | 87 | if resp.StatusCode >= 500 { 88 | log.Warn().Int("status", resp.StatusCode).Int("attempt", i+1).Int("max_attempts", maxRetries).Msg("Server error, retrying...") 89 | time.Sleep(backoff) 90 | backoff *= 2 91 | continue 92 | } 93 | 94 | break 95 | } 96 | 97 | if err != nil { 98 | log.Error().Err(err).Msg("Failed to send request after multiple retries") 99 | return nil, err 100 | } 101 | 102 | if resp.StatusCode < 200 || resp.StatusCode >= 300 { 103 | log.Error().Int("status", resp.StatusCode).Msg("HTTP request failed with non-successful status") 104 | return nil, fmt.Errorf("HTTP request failed with status %d", resp.StatusCode) 105 | } 106 | return resp, nil 107 | } 108 | 109 | func readResponseBody(resp *http.Response) ([]byte, error) { 110 | body, err := io.ReadAll(resp.Body) 111 | if err != nil { 112 | log.Error().Err(err).Msg("Failed to read response body") 113 | return nil, err 114 | } 115 | return body, nil 116 | } 117 | 118 | func parseGameData(body []byte, game *Game) error { 119 | if err := json.Unmarshal(body, game); err != nil { 120 | log.Error().Err(err).Msg("Failed to parse game data") 121 | return err 122 | } 123 | return nil 124 | } 125 | 126 | func parseOwnedGames(body []byte) ([]int, error) { 127 | var response struct { 128 | Owned []int `json:"owned"` 129 | } 130 | if err := json.Unmarshal(body, &response); err != nil { 131 | log.Error().Err(err).Msg("Failed to parse response") 132 | return nil, err 133 | } 134 | return response.Owned, nil 135 | } 136 | 137 | func (g *Game) EstimateStorageSize(language, platformName string, extrasFlag, dlcFlag bool) (int64, error) { 138 | var totalSizeBytes int64 139 | 140 | parseSize := func(sizeStr string) (int64, error) { 141 | s := strings.TrimSpace(strings.ToLower(sizeStr)) 142 | var val float64 143 | var err error 144 | switch { 145 | case strings.HasSuffix(s, " gb"): 146 | _, err = fmt.Sscanf(s, "%f gb", &val) 147 | if err != nil { 148 | return 0, err 149 | } 150 | return int64(val * 1024 * 1024 * 1024), nil 151 | case strings.HasSuffix(s, " mb"): 152 | _, err = fmt.Sscanf(s, "%f mb", &val) 153 | if err != nil { 154 | return 0, err 155 | } 156 | return int64(val * 1024 * 1024), nil 157 | case strings.HasSuffix(s, " kb"): 158 | _, err = fmt.Sscanf(s, "%f kb", &val) 159 | if err != nil { 160 | return 0, err 161 | } 162 | return int64(val * 1024), nil 163 | default: 164 | bytesVal, err := strconv.ParseInt(s, 10, 64) 165 | if err == nil { 166 | return bytesVal, nil 167 | } 168 | return 0, fmt.Errorf("unknown or missing size unit in '%s'", sizeStr) 169 | } 170 | } 171 | 172 | processFiles := func(files []PlatformFile) { 173 | for _, file := range files { 174 | if size, err := parseSize(file.Size); err == nil { 175 | totalSizeBytes += size 176 | } 177 | } 178 | } 179 | 180 | for _, download := range g.Downloads { 181 | if !strings.EqualFold(download.Language, language) { 182 | continue 183 | } 184 | platforms := map[string][]PlatformFile{ 185 | "windows": download.Platforms.Windows, 186 | "mac": download.Platforms.Mac, 187 | "linux": download.Platforms.Linux, 188 | } 189 | for name, files := range platforms { 190 | if platformName == "all" || strings.EqualFold(platformName, name) { 191 | processFiles(files) 192 | } 193 | } 194 | } 195 | 196 | if extrasFlag { 197 | for _, extra := range g.Extras { 198 | if size, err := parseSize(extra.Size); err == nil { 199 | totalSizeBytes += size 200 | } 201 | } 202 | } 203 | 204 | if dlcFlag { 205 | for _, dlc := range g.DLCs { 206 | for _, download := range dlc.ParsedDownloads { 207 | if !strings.EqualFold(download.Language, language) { 208 | continue 209 | } 210 | platforms := map[string][]PlatformFile{ 211 | "windows": download.Platforms.Windows, 212 | "mac": download.Platforms.Mac, 213 | "linux": download.Platforms.Linux, 214 | } 215 | for name, files := range platforms { 216 | if platformName == "all" || strings.EqualFold(platformName, name) { 217 | processFiles(files) 218 | } 219 | } 220 | } 221 | if extrasFlag { 222 | for _, extra := range dlc.Extras { 223 | if size, err := parseSize(extra.Size); err == nil { 224 | totalSizeBytes += size 225 | } 226 | } 227 | } 228 | } 229 | } 230 | 231 | return totalSizeBytes, nil 232 | } 233 | -------------------------------------------------------------------------------- /client/games_test.go: -------------------------------------------------------------------------------- 1 | package client_test 2 | 3 | import ( 4 | "net/http" 5 | "net/http/httptest" 6 | "testing" 7 | 8 | "github.com/habedi/gogg/client" 9 | "github.com/stretchr/testify/assert" 10 | "github.com/stretchr/testify/require" 11 | ) 12 | 13 | // TestFetchGameData_ReturnsGameData tests that FetchGameData returns the correct game data 14 | // when provided with a valid token and URL. 15 | func TestFetchGameData_ReturnsGameData(t *testing.T) { 16 | server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { 17 | w.WriteHeader(http.StatusOK) 18 | w.Write([]byte(`{"title": "Test Game"}`)) 19 | })) 20 | defer server.Close() 21 | 22 | game, body, err := client.FetchGameData("valid_token", server.URL) 23 | require.NoError(t, err) 24 | assert.Equal(t, "Test Game", game.Title) 25 | assert.Equal(t, `{"title": "Test Game"}`, body) 26 | } 27 | 28 | // TestFetchGameData_ReturnsErrorOnInvalidToken tests that FetchGameData returns an error 29 | // when provided with an invalid token. 30 | func TestFetchGameData_ReturnsErrorOnInvalidToken(t *testing.T) { 31 | server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { 32 | w.WriteHeader(http.StatusUnauthorized) 33 | })) 34 | defer server.Close() 35 | 36 | _, _, err := client.FetchGameData("invalid_token", server.URL) 37 | assert.Error(t, err) 38 | } 39 | 40 | // TestFetchIdOfOwnedGames_ReturnsOwnedGames tests that FetchIdOfOwnedGames returns the correct 41 | // list of owned game IDs when provided with a valid token and URL. 42 | func TestFetchIdOfOwnedGames_ReturnsOwnedGames(t *testing.T) { 43 | server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { 44 | w.WriteHeader(http.StatusOK) 45 | w.Write([]byte(`{"owned": [1, 2, 3]}`)) 46 | })) 47 | defer server.Close() 48 | 49 | ids, err := client.FetchIdOfOwnedGames("valid_token", server.URL) 50 | require.NoError(t, err) 51 | assert.Equal(t, []int{1, 2, 3}, ids) 52 | } 53 | 54 | // TestFetchIdOfOwnedGames_ReturnsErrorOnInvalidToken tests that FetchIdOfOwnedGames returns an error 55 | // when provided with an invalid token. 56 | func TestFetchIdOfOwnedGames_ReturnsErrorOnInvalidToken(t *testing.T) { 57 | server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { 58 | w.WriteHeader(http.StatusUnauthorized) 59 | })) 60 | defer server.Close() 61 | 62 | _, err := client.FetchIdOfOwnedGames("invalid_token", server.URL) 63 | assert.Error(t, err) 64 | } 65 | 66 | // TestFetchIdOfOwnedGames_ReturnsErrorOnInvalidResponse tests that FetchIdOfOwnedGames returns an error 67 | // when the response from the server is invalid. 68 | func TestFetchIdOfOwnedGames_ReturnsErrorOnInvalidResponse(t *testing.T) { 69 | server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { 70 | w.WriteHeader(http.StatusUnauthorized) // Should raise error 71 | w.Write([]byte(`{"invalid": "response"}`)) 72 | })) 73 | defer server.Close() 74 | 75 | _, err := client.FetchIdOfOwnedGames("valid_token", server.URL) 76 | assert.Error(t, err) 77 | } 78 | -------------------------------------------------------------------------------- /client/login.go: -------------------------------------------------------------------------------- 1 | package client 2 | 3 | import ( 4 | "context" 5 | "encoding/json" 6 | "errors" 7 | "fmt" 8 | "io" 9 | "net/http" 10 | "net/url" 11 | "os/exec" 12 | "strings" 13 | "time" 14 | 15 | "github.com/chromedp/chromedp" 16 | "github.com/habedi/gogg/db" 17 | "github.com/rs/zerolog/log" 18 | ) 19 | 20 | var GOGLoginURL = "https://auth.gog.com/auth?client_id=46899977096215655" + 21 | "&redirect_uri=https%3A%2F%2Fembed.gog.com%2Fon_login_success%3Forigin%3Dclient" + 22 | "&response_type=code&layout=client2" 23 | 24 | type GogClient struct { 25 | TokenURL string 26 | } 27 | 28 | func (c *GogClient) PerformTokenRefresh(refreshToken string) (accessToken string, newRefreshToken string, expiresIn int64, err error) { 29 | query := url.Values{ 30 | "client_id": {"46899977096215655"}, 31 | "client_secret": {"9d85c43b1482497dbbce61f6e4aa173a433796eeae2ca8c5f6129f2dc4de46d9"}, 32 | "grant_type": {"refresh_token"}, 33 | "refresh_token": {refreshToken}, 34 | } 35 | 36 | resp, err := http.PostForm(c.TokenURL, query) 37 | if err != nil { 38 | return "", "", 0, fmt.Errorf("failed to post form for token refresh: %w", err) 39 | } 40 | defer resp.Body.Close() 41 | 42 | body, err := io.ReadAll(resp.Body) 43 | if err != nil { 44 | return "", "", 0, fmt.Errorf("failed to read token refresh response: %w", err) 45 | } 46 | 47 | if resp.StatusCode >= 400 { 48 | return "", "", 0, fmt.Errorf("token refresh failed with status %d: %s", resp.StatusCode, string(body)) 49 | } 50 | 51 | var result struct { 52 | AccessToken string `json:"access_token"` 53 | ExpiresIn int64 `json:"expires_in"` 54 | RefreshToken string `json:"refresh_token"` 55 | Error string `json:"error_description"` 56 | } 57 | 58 | if err := json.Unmarshal(body, &result); err != nil { 59 | return "", "", 0, fmt.Errorf("failed to parse token refresh response: %w", err) 60 | } 61 | 62 | if result.Error != "" { 63 | return "", "", 0, fmt.Errorf("token refresh API error: %s", result.Error) 64 | } 65 | 66 | return result.AccessToken, result.RefreshToken, result.ExpiresIn, nil 67 | } 68 | 69 | func (c *GogClient) Login(loginURL string, username string, password string, headless bool) error { 70 | if username == "" || password == "" { 71 | return fmt.Errorf("username and password cannot be empty") 72 | } 73 | 74 | ctx, cancel, err := createChromeContext(headless) 75 | if err != nil { 76 | return err 77 | } 78 | defer cancel() 79 | 80 | log.Info().Msg("Trying to login to GOG.com.") 81 | 82 | finalURL, err := performLogin(ctx, loginURL, username, password, headless) 83 | if err != nil { 84 | if headless { 85 | log.Warn().Err(err).Msg("Headless login failed, retrying with window mode.") 86 | fmt.Println("Headless login failed, retrying with window mode.") 87 | 88 | // Cancel the first headless context before creating a new one. 89 | cancel() 90 | 91 | var headedCtx context.Context 92 | var headedCancel context.CancelFunc 93 | headedCtx, headedCancel, err = createChromeContext(false) 94 | if err != nil { 95 | return fmt.Errorf("failed to create Chrome context: %w", err) 96 | } 97 | defer headedCancel() // Defer cancellation of the new headed context. 98 | 99 | finalURL, err = performLogin(headedCtx, loginURL, username, password, false) 100 | if err != nil { 101 | return fmt.Errorf("failed to login: %w", err) 102 | } 103 | } else { 104 | return fmt.Errorf("failed to login: %w", err) 105 | } 106 | } 107 | 108 | code, err := extractAuthCode(finalURL) 109 | if err != nil { 110 | return err 111 | } 112 | 113 | token, refreshToken, expiresAt, err := c.exchangeCodeForToken(code) 114 | if err != nil { 115 | return fmt.Errorf("failed to exchange authorization code for token: %w", err) 116 | } 117 | 118 | log.Info().Msgf("Access token: %s", token[:10]) 119 | log.Info().Msgf("Refresh token: %s", refreshToken[:10]) 120 | log.Info().Msgf("Expires at: %s", expiresAt) 121 | 122 | return db.UpsertTokenRecord(&db.Token{AccessToken: token, RefreshToken: refreshToken, ExpiresAt: expiresAt}) 123 | } 124 | 125 | func createChromeContext(headless bool) (context.Context, context.CancelFunc, error) { 126 | var execPath string 127 | // Search for browsers in order of preference 128 | browserExecutables := []string{"google-chrome", "Google Chrome", "chromium", "Chromium", "chrome", "msedge", "Microsoft Edge"} 129 | for _, browser := range browserExecutables { 130 | if p, err := exec.LookPath(browser); err == nil { 131 | execPath = p 132 | break 133 | } 134 | } 135 | 136 | if execPath == "" { 137 | return nil, nil, fmt.Errorf("no Chrome, Chromium, or Edge executable found in PATH") 138 | } 139 | 140 | opts := append(chromedp.DefaultExecAllocatorOptions[:], 141 | chromedp.ExecPath(execPath), 142 | chromedp.Flag("headless", headless), 143 | ) 144 | 145 | if headless { 146 | opts = append(opts, chromedp.Flag("disable-gpu", true)) 147 | } 148 | 149 | allocatorCtx, cancelAllocator := chromedp.NewExecAllocator(context.Background(), opts...) 150 | ctx, cancelContext := chromedp.NewContext(allocatorCtx, chromedp.WithLogf(log.Info().Msgf)) 151 | 152 | return ctx, func() { 153 | cancelContext() 154 | cancelAllocator() 155 | }, nil 156 | } 157 | 158 | func performLogin(ctx context.Context, loginURL string, username string, password string, 159 | headlessMode bool, 160 | ) (string, error) { 161 | var timeoutCtx context.Context 162 | var cancel context.CancelFunc 163 | var finalURL string 164 | 165 | if headlessMode { 166 | timeoutCtx, cancel = context.WithTimeout(ctx, 30*time.Second) 167 | } else { 168 | timeoutCtx, cancel = context.WithTimeout(ctx, 4*time.Minute) 169 | } 170 | defer cancel() 171 | 172 | err := chromedp.Run(timeoutCtx, 173 | chromedp.Navigate(loginURL), 174 | chromedp.WaitVisible(`#login_username`, chromedp.ByID), 175 | chromedp.SendKeys(`#login_username`, username, chromedp.ByID), 176 | chromedp.SendKeys(`#login_password`, password, chromedp.ByID), 177 | chromedp.Click(`#login_login`, chromedp.ByID), 178 | chromedp.ActionFunc(func(ctx context.Context) error { 179 | for { 180 | var currentURL string 181 | if err := chromedp.Location(¤tURL).Do(ctx); err != nil { 182 | return err 183 | } 184 | if strings.Contains(currentURL, "on_login_success") && strings.Contains(currentURL, "code=") { 185 | finalURL = currentURL 186 | return nil 187 | } 188 | time.Sleep(500 * time.Millisecond) 189 | } 190 | }), 191 | ) 192 | return finalURL, err 193 | } 194 | 195 | func extractAuthCode(authURL string) (string, error) { 196 | parsedURL, err := url.Parse(authURL) 197 | if err != nil { 198 | return "", fmt.Errorf("failed to parse URL: %w", err) 199 | } 200 | 201 | code := parsedURL.Query().Get("code") 202 | if code == "" { 203 | return "", errors.New("authorization code not found in the URL") 204 | } 205 | 206 | return code, nil 207 | } 208 | 209 | func (c *GogClient) exchangeCodeForToken(code string) (string, string, string, error) { 210 | query := url.Values{ 211 | "client_id": {"46899977096215655"}, 212 | "client_secret": {"9d85c43b1482497dbbce61f6e4aa173a433796eeae2ca8c5f6129f2dc4de46d9"}, 213 | "grant_type": {"authorization_code"}, 214 | "code": {code}, 215 | "redirect_uri": {"https://embed.gog.com/on_login_success?origin=client"}, 216 | } 217 | 218 | resp, err := http.PostForm(c.TokenURL, query) 219 | if err != nil { 220 | return "", "", "", fmt.Errorf("failed to exchange code for token: %w", err) 221 | } 222 | defer resp.Body.Close() 223 | 224 | body, err := io.ReadAll(resp.Body) 225 | if err != nil { 226 | return "", "", "", fmt.Errorf("failed to read token response: %w", err) 227 | } 228 | 229 | var result struct { 230 | AccessToken string `json:"access_token"` 231 | ExpiresIn int64 `json:"expires_in"` 232 | RefreshToken string `json:"refresh_token"` 233 | } 234 | 235 | if err := json.Unmarshal(body, &result); err != nil { 236 | return "", "", "", fmt.Errorf("failed to parse token response: %w", err) 237 | } 238 | 239 | expiresAt := time.Now().Add(time.Duration(result.ExpiresIn) * time.Second).Format(time.RFC3339) 240 | return result.AccessToken, result.RefreshToken, expiresAt, nil 241 | } 242 | -------------------------------------------------------------------------------- /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 | -------------------------------------------------------------------------------- /client/utils_test.go: -------------------------------------------------------------------------------- 1 | package client 2 | 3 | import ( 4 | "testing" 5 | 6 | "github.com/stretchr/testify/assert" 7 | ) 8 | 9 | func TestCreateRequest(t *testing.T) { 10 | req, err := createRequest("GET", "http://example.com/path", "mytoken") 11 | if err != nil { 12 | t.Fatalf("Unexpected error: %v", err) 13 | } 14 | auth := req.Header.Get("Authorization") 15 | expected := "Bearer mytoken" 16 | if auth != expected { 17 | t.Errorf("Expected Authorization header %q, got %q", expected, auth) 18 | } 19 | } 20 | 21 | func TestParseRawDownloads(t *testing.T) { 22 | raw := [][]interface{}{ 23 | {"en", map[string]interface{}{"windows": []interface{}{map[string]interface{}{"name": "setup.exe", "size": "1GB"}}}}, 24 | {123, "invalid"}, 25 | {"fr"}, 26 | } 27 | downloads := parseRawDownloads(raw) 28 | if len(downloads) != 1 { 29 | t.Fatalf("Expected 1 valid download entry, got %d", len(downloads)) 30 | } 31 | dl := downloads[0] 32 | assert.Equal(t, "en", dl.Language) 33 | assert.Len(t, dl.Platforms.Windows, 1) 34 | file := dl.Platforms.Windows[0] 35 | assert.Equal(t, "setup.exe", file.Name) 36 | assert.Equal(t, "1GB", file.Size) 37 | } 38 | -------------------------------------------------------------------------------- /cmd/catalogue_test.go: -------------------------------------------------------------------------------- 1 | package cmd 2 | 3 | import ( 4 | "bytes" 5 | "encoding/json" 6 | "errors" 7 | "github.com/stretchr/testify/assert" 8 | "os" 9 | "path/filepath" 10 | "testing" 11 | 12 | "github.com/habedi/gogg/auth" 13 | "github.com/habedi/gogg/db" 14 | "github.com/rs/zerolog/log" 15 | "github.com/spf13/cobra" 16 | "github.com/stretchr/testify/require" 17 | ) 18 | 19 | // TestMain sets up the database once for all tests in this package. 20 | func TestMain(m *testing.M) { 21 | // Setup: Initialize the database once. 22 | tmpDir, err := os.MkdirTemp("", "gogg-cmd-test-") 23 | if err != nil { 24 | log.Fatal().Err(err).Msg("Failed to create temp dir for testing") 25 | } 26 | db.Path = filepath.Join(tmpDir, "games.db") 27 | if err := db.InitDB(); err != nil { 28 | log.Fatal().Err(err).Msg("Failed to init db for testing") 29 | } 30 | 31 | // Run all tests in the package. 32 | exitCode := m.Run() 33 | 34 | // Teardown: Clean up resources after all tests are done. 35 | if err := db.CloseDB(); err != nil { 36 | log.Error().Err(err).Msg("Failed to close db after testing") 37 | } 38 | os.RemoveAll(tmpDir) 39 | 40 | os.Exit(exitCode) 41 | } 42 | 43 | // cleanDBTables ensures test isolation by clearing tables before each test. 44 | func cleanDBTables(t *testing.T) { 45 | t.Helper() 46 | err := db.Db.Exec("DELETE FROM games").Error 47 | require.NoError(t, err) 48 | err = db.Db.Exec("DELETE FROM tokens").Error 49 | require.NoError(t, err) 50 | } 51 | 52 | type mockTokenStorer struct { 53 | getTokenErr error 54 | } 55 | 56 | func (m *mockTokenStorer) GetTokenRecord() (*db.Token, error) { 57 | if m.getTokenErr != nil { 58 | return nil, m.getTokenErr 59 | } 60 | return &db.Token{RefreshToken: "valid-refresh-token"}, nil 61 | } 62 | func (m *mockTokenStorer) UpsertTokenRecord(token *db.Token) error { return nil } 63 | 64 | type mockTokenRefresher struct{} 65 | 66 | func (m *mockTokenRefresher) PerformTokenRefresh(refreshToken string) (string, string, int64, error) { 67 | return "new-access-token", "new-refresh-token", 3600, nil 68 | } 69 | 70 | func addTestGame(t *testing.T, id int, title, data string) { 71 | t.Helper() 72 | if err := db.PutInGame(id, title, data); err != nil { 73 | t.Fatalf("failed to add game: %v", err) 74 | } 75 | } 76 | 77 | func captureCombinedOutput(cmd *cobra.Command, args ...string) (string, error) { 78 | buf := new(bytes.Buffer) 79 | cmd.SetOut(buf) 80 | cmd.SetErr(buf) 81 | cmd.SetArgs(args) 82 | 83 | err := cmd.Execute() 84 | 85 | return buf.String(), err 86 | } 87 | 88 | func TestListCmd(t *testing.T) { 89 | cleanDBTables(t) 90 | dummyData := `{"dummy": "data"}` 91 | addTestGame(t, 1, "Test Game 1", dummyData) 92 | addTestGame(t, 2, "Test Game 2", dummyData) 93 | listCommand := listCmd() 94 | output, err := captureCombinedOutput(listCommand) 95 | require.NoError(t, err) 96 | assert.Contains(t, output, "Test Game 1") 97 | assert.Contains(t, output, "Test Game 2") 98 | } 99 | 100 | func TestInfoCmd(t *testing.T) { 101 | cleanDBTables(t) 102 | nested := map[string]interface{}{ 103 | "description": "A cool game", 104 | "rating": 5, 105 | } 106 | nestedBytes, err := json.Marshal(nested) 107 | require.NoError(t, err) 108 | addTestGame(t, 10, "Info Test Game", string(nestedBytes)) 109 | infoCommand := infoCmd() 110 | output, err := captureCombinedOutput(infoCommand, "10") 111 | require.NoError(t, err) 112 | assert.Contains(t, output, "cool game") 113 | } 114 | 115 | func TestSearchCmd(t *testing.T) { 116 | cleanDBTables(t) 117 | dummyData := `{"dummy": "data"}` 118 | addTestGame(t, 20, "Awesome Game", dummyData) 119 | addTestGame(t, 21, "Not So Awesome", dummyData) 120 | searchCommand := searchCmd() 121 | output, err := captureCombinedOutput(searchCommand, "Awesome") 122 | require.NoError(t, err) 123 | assert.Contains(t, output, "Awesome Game") 124 | assert.Contains(t, output, "Not So Awesome") 125 | 126 | addTestGame(t, 30, "ID Game", dummyData) 127 | searchCommand = searchCmd() 128 | output, err = captureCombinedOutput(searchCommand, "30", "--id") 129 | require.NoError(t, err) 130 | assert.Contains(t, output, "ID Game") 131 | } 132 | 133 | func TestExportCmd(t *testing.T) { 134 | cleanDBTables(t) 135 | dummyData := `{"dummy": "data"}` 136 | addTestGame(t, 40, "Export Test Game", dummyData) 137 | tmpExportDir := t.TempDir() 138 | exportCommand := exportCmd() 139 | exportCommand.Flags().Set("format", "json") 140 | output, err := captureCombinedOutput(exportCommand, tmpExportDir) 141 | require.NoError(t, err) 142 | assert.Contains(t, output, tmpExportDir) 143 | // ... rest of assertions 144 | } 145 | 146 | func TestRefreshCmd(t *testing.T) { 147 | cleanDBTables(t) 148 | storer := &mockTokenStorer{getTokenErr: errors.New("mock db error")} 149 | refresher := &mockTokenRefresher{} 150 | authService := auth.NewService(storer, refresher) 151 | 152 | refreshCommand := refreshCmd(authService) 153 | output, err := captureCombinedOutput(refreshCommand) 154 | require.NoError(t, err) // The command itself should not error, just print an error message 155 | 156 | expectedErrorMsg := "Error: Failed to refresh catalogue. Please check the logs for details." 157 | assert.Contains(t, output, expectedErrorMsg) 158 | } 159 | -------------------------------------------------------------------------------- /cmd/cli.go: -------------------------------------------------------------------------------- 1 | package cmd 2 | 3 | import ( 4 | "os" 5 | 6 | "github.com/habedi/gogg/auth" 7 | "github.com/habedi/gogg/client" 8 | "github.com/habedi/gogg/db" 9 | "github.com/rs/zerolog/log" 10 | "github.com/spf13/cobra" 11 | ) 12 | 13 | func Execute() { 14 | initializeDatabase() 15 | defer closeDatabase() 16 | 17 | tokenStore := &db.TokenStore{} 18 | gogClient := &client.GogClient{ 19 | TokenURL: "https://auth.gog.com/token", 20 | } 21 | 22 | authService := auth.NewService(tokenStore, gogClient) 23 | 24 | rootCmd := createRootCmd(authService, gogClient) 25 | rootCmd.PersistentFlags().BoolP("help", "h", false, "Show help for a command") 26 | 27 | if err := rootCmd.Execute(); err != nil { 28 | log.Error().Err(err).Msg("Command execution failed.") 29 | os.Exit(1) 30 | } 31 | } 32 | 33 | func createRootCmd(authService *auth.Service, gogClient *client.GogClient) *cobra.Command { 34 | rootCmd := &cobra.Command{ 35 | Use: "gogg", 36 | Short: "A Downloader for GOG", 37 | } 38 | 39 | rootCmd.AddCommand( 40 | catalogueCmd(authService), 41 | downloadCmd(authService), 42 | versionCmd(), 43 | loginCmd(gogClient), 44 | fileCmd(), 45 | guiCmd(authService), 46 | ) 47 | 48 | rootCmd.CompletionOptions.HiddenDefaultCmd = true 49 | rootCmd.SetHelpCommand(&cobra.Command{ 50 | Use: "no-help", 51 | Hidden: true, 52 | }) 53 | 54 | return rootCmd 55 | } 56 | 57 | func initializeDatabase() { 58 | if err := db.InitDB(); err != nil { 59 | log.Error().Err(err).Msg("Failed to initialize database") 60 | os.Exit(1) 61 | } 62 | } 63 | 64 | func closeDatabase() { 65 | if err := db.CloseDB(); err != nil { 66 | log.Error().Err(err).Msg("Failed to close the database.") 67 | os.Exit(1) 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 | rootCmd := createRootCmd(authService, gogClient) 31 | 32 | if rootCmd.Use != "gogg" { 33 | t.Errorf("expected root command use to be 'gogg', got: %s", rootCmd.Use) 34 | } 35 | 36 | subCommands := rootCmd.Commands() 37 | if len(subCommands) == 0 { 38 | t.Error("expected root command to have subcommands, got none") 39 | } 40 | 41 | for _, cmd := range subCommands { 42 | if cmd.Use == "help" { 43 | t.Error("expected help command to be replaced, but found a subcommand with use 'help'") 44 | } 45 | } 46 | } 47 | 48 | func TestInitializeAndCloseDatabase(t *testing.T) { 49 | tmpDir := t.TempDir() 50 | db.Path = filepath.Join(tmpDir, "games.db") 51 | initializeDatabase() 52 | closeDatabase() 53 | } 54 | 55 | func TestExecuteFailure(t *testing.T) { 56 | if os.Getenv("TEST_EXECUTE_FAILURE") == "1" { 57 | authService := auth.NewService(&mockAuthStorer{}, &mockAuthRefresher{}) 58 | gogClient := &client.GogClient{} 59 | rootCmd := createRootCmd(authService, gogClient) 60 | rootCmd.RunE = func(cmd *cobra.Command, args []string) error { 61 | return errors.New("dummy failure") 62 | } 63 | if err := rootCmd.Execute(); err != nil { 64 | os.Exit(1) 65 | } 66 | return 67 | } 68 | 69 | cmd := exec.Command(os.Args[0], "-test.run=TestExecuteFailure") 70 | cmd.Env = append(os.Environ(), "TEST_EXECUTE_FAILURE=1") 71 | err := cmd.Run() 72 | var exitError *exec.ExitError 73 | if errors.As(err, &exitError) { 74 | if exitError.ExitCode() != 1 { 75 | t.Fatalf("expected exit code 1, got %d", exitError.ExitCode()) 76 | } 77 | } 78 | } 79 | -------------------------------------------------------------------------------- /cmd/download.go: -------------------------------------------------------------------------------- 1 | package cmd 2 | 3 | import ( 4 | "bufio" 5 | "context" 6 | "encoding/json" 7 | "fmt" 8 | "os" 9 | "path/filepath" 10 | "sort" 11 | "strconv" 12 | "strings" 13 | "sync" 14 | "time" 15 | 16 | "github.com/habedi/gogg/auth" 17 | "github.com/habedi/gogg/client" 18 | "github.com/habedi/gogg/db" 19 | "github.com/rs/zerolog/log" 20 | "github.com/schollz/progressbar/v3" 21 | "github.com/spf13/cobra" 22 | ) 23 | 24 | // formatBytes converts a byte count into a human-readable string (KB, MB, GB). 25 | func formatBytes(b int64) string { 26 | const unit = 1024 27 | if b < unit { 28 | return fmt.Sprintf("%d B", b) 29 | } 30 | div, exp := int64(unit), 0 31 | for n := b / unit; n >= unit; n /= unit { 32 | div *= unit 33 | exp++ 34 | } 35 | return fmt.Sprintf("%.1f%ciB", float64(b)/float64(div), "KMGTPE"[exp]) 36 | } 37 | 38 | // cliProgressWriter handles progress updates for the CLI. 39 | type cliProgressWriter struct { 40 | bar *progressbar.ProgressBar 41 | fileProgress map[string]struct{ current, total int64 } 42 | fileBytes map[string]int64 43 | downloadedBytes int64 44 | mu sync.RWMutex 45 | } 46 | 47 | func (cw *cliProgressWriter) Write(p []byte) (n int, err error) { 48 | scanner := bufio.NewScanner(strings.NewReader(string(p))) 49 | for scanner.Scan() { 50 | var update client.ProgressUpdate 51 | if err := json.Unmarshal(scanner.Bytes(), &update); err == nil { 52 | cw.mu.Lock() 53 | switch update.Type { 54 | case "start": 55 | cw.bar = progressbar.NewOptions64( 56 | update.OverallTotalBytes, 57 | progressbar.OptionSetDescription("Downloading..."), 58 | progressbar.OptionSetWriter(os.Stderr), 59 | progressbar.OptionShowBytes(true), 60 | progressbar.OptionThrottle(200*time.Millisecond), 61 | progressbar.OptionClearOnFinish(), 62 | progressbar.OptionSpinnerType(14), 63 | ) 64 | cw.fileProgress = make(map[string]struct{ current, total int64 }) 65 | cw.fileBytes = make(map[string]int64) 66 | case "file_progress": 67 | if cw.bar != nil { 68 | diff := update.CurrentBytes - cw.fileBytes[update.FileName] 69 | cw.fileBytes[update.FileName] = update.CurrentBytes 70 | cw.downloadedBytes += diff 71 | _ = cw.bar.Set64(cw.downloadedBytes) 72 | 73 | cw.fileProgress[update.FileName] = struct{ current, total int64 }{update.CurrentBytes, update.TotalBytes} 74 | if update.CurrentBytes >= update.TotalBytes && update.TotalBytes > 0 { 75 | delete(cw.fileProgress, update.FileName) 76 | } 77 | cw.bar.Describe(cw.getFileStatusString()) 78 | } 79 | } 80 | cw.mu.Unlock() 81 | } 82 | } 83 | return len(p), nil 84 | } 85 | 86 | // getFileStatusString builds a compact string of current file progresses. 87 | func (cw *cliProgressWriter) getFileStatusString() string { 88 | if len(cw.fileProgress) == 0 { 89 | return "Finalizing..." 90 | } 91 | 92 | files := make([]string, 0, len(cw.fileProgress)) 93 | for f := range cw.fileProgress { 94 | files = append(files, f) 95 | } 96 | sort.Strings(files) 97 | 98 | var sb strings.Builder 99 | sb.WriteString(fmt.Sprintf("Downloading %d files: ", len(files))) 100 | for i, file := range files { 101 | shortName := file 102 | if len(shortName) > 25 { 103 | shortName = "..." + shortName[len(shortName)-22:] 104 | } 105 | progress := cw.fileProgress[file] 106 | sizeStr := fmt.Sprintf("%s/%s", formatBytes(progress.current), formatBytes(progress.total)) 107 | sb.WriteString(fmt.Sprintf("%s %s", shortName, sizeStr)) 108 | if i < len(files)-1 { 109 | sb.WriteString(" | ") 110 | } 111 | } 112 | return sb.String() 113 | } 114 | 115 | func downloadCmd(authService *auth.Service) *cobra.Command { 116 | var language, platformName string 117 | var extrasFlag, dlcFlag, resumeFlag, flattenFlag, skipPatchesFlag bool 118 | var numThreads int 119 | 120 | cmd := &cobra.Command{ 121 | Use: "download [gameID] [downloadDir]", 122 | Short: "Download game files from GOG", 123 | Long: "Download game files from GOG for the specified game ID to the specified directory", 124 | Args: cobra.ExactArgs(2), 125 | Run: func(cmd *cobra.Command, args []string) { 126 | gameID, err := strconv.Atoi(args[0]) 127 | if err != nil { 128 | cmd.PrintErrln("Error: Invalid game ID. It must be a positive integer.") 129 | return 130 | } 131 | downloadDir := args[1] 132 | executeDownload(authService, gameID, downloadDir, strings.ToLower(language), platformName, extrasFlag, dlcFlag, resumeFlag, flattenFlag, skipPatchesFlag, numThreads) 133 | }, 134 | } 135 | 136 | cmd.Flags().StringVarP(&language, "lang", "l", "en", "Game language [en, fr, de, es, it, ru, pl, pt-BR, zh-Hans, ja, ko]") 137 | cmd.Flags().StringVarP(&platformName, "platform", "p", "windows", "Platform name [all, windows, mac, linux]; all means all platforms") 138 | cmd.Flags().BoolVarP(&extrasFlag, "extras", "e", true, "Include extra content files? [true, false]") 139 | cmd.Flags().BoolVarP(&dlcFlag, "dlcs", "d", true, "Include DLC files? [true, false]") 140 | cmd.Flags().BoolVarP(&resumeFlag, "resume", "r", true, "Resume downloading? [true, false]") 141 | cmd.Flags().IntVarP(&numThreads, "threads", "t", 5, "Number of worker threads to use for downloading [1-20]") 142 | cmd.Flags().BoolVarP(&flattenFlag, "flatten", "f", true, "Flatten the directory structure when downloading? [true, false]") 143 | cmd.Flags().BoolVarP(&skipPatchesFlag, "skip-patches", "s", false, "Skip patches when downloading? [true, false]") 144 | 145 | return cmd 146 | } 147 | 148 | func executeDownload(authService *auth.Service, gameID int, downloadPath, language, platformName string, extrasFlag, dlcFlag, resumeFlag, flattenFlag, skipPatchesFlag bool, numThreads int) { 149 | log.Info().Msgf("Downloading games to %s...", downloadPath) 150 | log.Info().Msgf("Language: %s, Platform: %s, Extras: %v, DLC: %v", language, platformName, extrasFlag, dlcFlag) 151 | 152 | if numThreads < 1 || numThreads > 20 { 153 | fmt.Println("Number of threads must be between 1 and 20.") 154 | return 155 | } 156 | 157 | languageFullName, ok := client.GameLanguages[language] 158 | if !ok { 159 | fmt.Println("Invalid language code. Supported languages are:") 160 | for langCode, langName := range client.GameLanguages { 161 | fmt.Printf("'%s' for %s\n", langCode, langName) 162 | } 163 | return 164 | } 165 | 166 | user, err := authService.RefreshToken() 167 | if err != nil { 168 | fmt.Println("Failed to find or refresh the access token. Did you login?") 169 | return 170 | } 171 | 172 | if _, err := os.Stat(downloadPath); os.IsNotExist(err) { 173 | log.Info().Msgf("Creating download path %s", downloadPath) 174 | if err := os.MkdirAll(downloadPath, os.ModePerm); err != nil { 175 | log.Error().Err(err).Msgf("Failed to create download path %s", downloadPath) 176 | return 177 | } 178 | } 179 | 180 | game, err := db.GetGameByID(gameID) 181 | if err != nil { 182 | log.Error().Err(err).Msg("Failed to get game by ID.") 183 | fmt.Println("Error retrieving game from local catalogue.") 184 | return 185 | } 186 | if game == nil { 187 | log.Error().Msg("Game not found in the catalogue.") 188 | fmt.Printf("Game with ID %d not found in the local catalogue.\n", gameID) 189 | return 190 | } 191 | parsedGameData, err := client.ParseGameData(game.Data) 192 | if err != nil { 193 | log.Error().Err(err).Msg("Failed to parse game details.") 194 | fmt.Println("Error parsing game data from local catalogue.") 195 | return 196 | } 197 | 198 | logDownloadParameters(parsedGameData, gameID, downloadPath, languageFullName, platformName, extrasFlag, dlcFlag, resumeFlag, flattenFlag, skipPatchesFlag, numThreads) 199 | 200 | ctx := context.Background() 201 | progressWriter := &cliProgressWriter{} 202 | 203 | err = client.DownloadGameFiles(ctx, user.AccessToken, parsedGameData, downloadPath, languageFullName, platformName, extrasFlag, dlcFlag, resumeFlag, flattenFlag, skipPatchesFlag, numThreads, progressWriter) 204 | if err != nil { 205 | if err == context.Canceled || err == context.DeadlineExceeded { 206 | log.Warn().Err(err).Msg("Download operation cancelled or timed out.") 207 | fmt.Println("\nDownload cancelled or timed out.") 208 | } else { 209 | log.Error().Err(err).Msg("Failed to download game files.") 210 | fmt.Printf("\nError downloading game files: %v\n", err) 211 | } 212 | return 213 | } 214 | 215 | fmt.Printf("\rGame files downloaded successfully to: \"%s\" \n", filepath.Join(downloadPath, client.SanitizePath(parsedGameData.Title))) 216 | } 217 | 218 | func logDownloadParameters(game client.Game, gameID int, downloadPath, language, platformName string, extrasFlag, dlcFlag, resumeFlag, flattenFlag, skipPatchesFlag bool, numThreads int) { 219 | fmt.Println("================================= Download Parameters =====================================") 220 | fmt.Printf("Downloading \"%v\" (with game ID=\"%d\") to \"%v\"\n", game.Title, gameID, downloadPath) 221 | fmt.Printf("Platform: \"%v\", Language: '%v'\n", platformName, language) 222 | fmt.Printf("Include Extras: %v, Include DLCs: %v, Resume enabled: %v\n", extrasFlag, dlcFlag, resumeFlag) 223 | fmt.Printf("Number of worker threads for download: %d\n", numThreads) 224 | fmt.Printf("Flatten directory structure: %v\n", flattenFlag) 225 | fmt.Printf("Skip patches: %v\n", skipPatchesFlag) 226 | fmt.Println("============================================================================================") 227 | } 228 | -------------------------------------------------------------------------------- /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/hasher" 11 | "github.com/habedi/gogg/pkg/operations" 12 | "github.com/rs/zerolog/log" 13 | "github.com/spf13/cobra" 14 | ) 15 | 16 | func fileCmd() *cobra.Command { 17 | cmd := &cobra.Command{ 18 | Use: "file", 19 | Short: "Perform various file operations", 20 | } 21 | cmd.AddCommand(hashCmd(), sizeCmd()) 22 | return cmd 23 | } 24 | 25 | func hashCmd() *cobra.Command { 26 | var saveToFileFlag, cleanFlag, recursiveFlag bool 27 | var algo string 28 | var numThreads int 29 | 30 | cmd := &cobra.Command{ 31 | Use: "hash [fileDir]", 32 | Short: "Generate hash values for game files in a directory", 33 | Args: cobra.ExactArgs(1), 34 | Run: func(cmd *cobra.Command, args []string) { 35 | dir := args[0] 36 | if !hasher.IsValidHashAlgo(algo) { 37 | log.Error().Msgf("Unsupported hash algorithm: %s", algo) 38 | return 39 | } 40 | 41 | if cleanFlag { 42 | log.Info().Msgf("Cleaning old hash files from %s...", dir) 43 | if err := operations.CleanHashes(dir, recursiveFlag); err != nil { 44 | log.Error().Err(err).Msg("Error cleaning old hash files") 45 | } else { 46 | log.Info().Msg("Finished cleaning old hash files.") 47 | } 48 | } 49 | 50 | files, err := operations.FindFilesToHash(dir, recursiveFlag, operations.DefaultHashExclusions) 51 | if err != nil { 52 | log.Error().Err(err).Msg("Error finding files to hash") 53 | return 54 | } 55 | 56 | if len(files) == 0 { 57 | cmd.Println("No files found to hash.") 58 | return 59 | } 60 | 61 | cmd.Printf("Found %d files. Generating %s hashes...\n", len(files), algo) 62 | resultsChan := operations.GenerateHashes(context.Background(), files, algo, numThreads) 63 | 64 | var savedFiles []string 65 | for res := range resultsChan { 66 | if res.Err != nil { 67 | log.Error().Err(res.Err).Str("file", res.File).Msg("Error generating hash") 68 | continue 69 | } 70 | if saveToFileFlag { 71 | hashFilePath := res.File + "." + algo 72 | err := os.WriteFile(hashFilePath, []byte(res.Hash), 0644) 73 | if err != nil { 74 | log.Error().Err(err).Str("file", hashFilePath).Msg("Error writing hash to file") 75 | } else { 76 | savedFiles = append(savedFiles, hashFilePath) 77 | } 78 | } else { 79 | fmt.Printf("%s hash for \"%s\": %s\n", algo, res.File, res.Hash) 80 | } 81 | } 82 | 83 | if saveToFileFlag { 84 | fmt.Println("Generated hash files:") 85 | for _, file := range savedFiles { 86 | fmt.Println(file) 87 | } 88 | } 89 | }, 90 | } 91 | cmd.Flags().StringVarP(&algo, "algo", "a", "md5", fmt.Sprintf("Hash algorithm to use %v", hasher.HashAlgorithms)) 92 | cmd.Flags().BoolVarP(&recursiveFlag, "recursive", "r", true, "Process files in subdirectories? [true, false]") 93 | cmd.Flags().BoolVarP(&saveToFileFlag, "save", "s", false, "Save hash to files? [true, false]") 94 | cmd.Flags().BoolVarP(&cleanFlag, "clean", "c", false, "Remove old hash files before generating new ones? [true, false]") 95 | cmd.Flags().IntVarP(&numThreads, "threads", "t", 4, "Number of worker threads to use for hashing [1-16]") 96 | 97 | return cmd 98 | } 99 | 100 | func sizeCmd() *cobra.Command { 101 | var language, platformName, sizeUnit string 102 | var extrasFlag, dlcFlag bool 103 | 104 | cmd := &cobra.Command{ 105 | Use: "size [gameID]", 106 | Short: "Show the total storage size needed to download game files", 107 | Args: cobra.ExactArgs(1), 108 | Run: func(cmd *cobra.Command, args []string) { 109 | gameID, err := strconv.Atoi(args[0]) 110 | if err != nil { 111 | log.Fatal().Err(err).Msgf("Invalid game ID: %s", args[0]) 112 | return 113 | } 114 | 115 | params := operations.EstimationParams{ 116 | LanguageCode: strings.ToLower(language), 117 | PlatformName: platformName, 118 | IncludeExtras: extrasFlag, 119 | IncludeDLCs: dlcFlag, 120 | } 121 | 122 | totalSizeBytes, gameData, err := operations.EstimateGameSize(gameID, params) 123 | if err != nil { 124 | log.Fatal().Err(err).Msg("Error estimating storage size") 125 | return 126 | } 127 | 128 | log.Info().Msgf("Game title: \"%s\"\n", gameData.Title) 129 | log.Info().Msgf("Download parameters: Language=%s; Platform=%s; Extras=%t; DLCs=%t\n", params.LanguageCode, params.PlatformName, params.IncludeExtras, params.IncludeDLCs) 130 | 131 | sizeUnit = strings.ToLower(sizeUnit) 132 | switch sizeUnit { 133 | case "gb": 134 | fmt.Printf("Total download size: %.2f GB\n", float64(totalSizeBytes)/(1024*1024*1024)) 135 | case "mb": 136 | fmt.Printf("Total download size: %.2f MB\n", float64(totalSizeBytes)/(1024*1024)) 137 | case "kb": 138 | fmt.Printf("Total download size: %.2f KB\n", float64(totalSizeBytes)/1024) 139 | case "b": 140 | fmt.Printf("Total download size: %d B\n", totalSizeBytes) 141 | default: 142 | log.Fatal().Msgf("invalid size unit: \"%s\". Unit must be one of [gb, mb, kb, b]", sizeUnit) 143 | } 144 | }, 145 | } 146 | cmd.Flags().StringVarP(&language, "lang", "l", "en", "Game language [en, fr, de, es, it, ru, pl, pt-BR, zh-Hans, ja, ko]") 147 | cmd.Flags().StringVarP(&platformName, "platform", "p", "windows", "Platform name [all, windows, mac, linux]; all means all platforms") 148 | cmd.Flags().BoolVarP(&extrasFlag, "extras", "e", true, "Include extra content files? [true, false]") 149 | cmd.Flags().BoolVarP(&dlcFlag, "dlcs", "d", true, "Include DLC files? [true, false]") 150 | cmd.Flags().StringVarP(&sizeUnit, "unit", "u", "gb", "Size unit to display [gb, mb, kb, b]") 151 | return cmd 152 | } 153 | -------------------------------------------------------------------------------- /cmd/gui.go: -------------------------------------------------------------------------------- 1 | package cmd 2 | 3 | import ( 4 | "github.com/habedi/gogg/auth" 5 | "github.com/habedi/gogg/gui" 6 | "github.com/spf13/cobra" 7 | ) 8 | 9 | func guiCmd(authService *auth.Service) *cobra.Command { 10 | cmd := &cobra.Command{ 11 | Use: "gui", 12 | Short: "Start the Gogg GUI", 13 | Run: func(cmd *cobra.Command, args []string) { 14 | gui.Run(version, authService) 15 | }, 16 | } 17 | return cmd 18 | } 19 | -------------------------------------------------------------------------------- /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/spf13/cobra" 11 | "golang.org/x/term" 12 | ) 13 | 14 | func loginCmd(gogClient *client.GogClient) *cobra.Command { 15 | var gogUsername, gogPassword string 16 | var headless bool 17 | 18 | cmd := &cobra.Command{ 19 | Use: "login", 20 | Short: "Login to GOG.com", 21 | Long: "Login to GOG.com using your username and password", 22 | Run: func(cmd *cobra.Command, args []string) { 23 | cmd.Println("Please enter your GOG username and password.") 24 | gogUsername = promptForInput("GOG username: ") 25 | gogPassword = promptForPassword("GOG password: ") 26 | 27 | if validateCredentials(gogUsername, gogPassword) { 28 | if err := gogClient.Login(client.GOGLoginURL, gogUsername, gogPassword, headless); err != nil { 29 | cmd.PrintErrln("Error: Failed to login to GOG.com.") 30 | if strings.Contains(err.Error(), "executable found in PATH") { 31 | cmd.PrintErrln("Hint: Make sure Google Chrome or Chromium is installed and accessible in your system's PATH.") 32 | } 33 | } else { 34 | cmd.Println("Login was successful.") 35 | } 36 | } else { 37 | cmd.PrintErrln("Error: Username and password cannot be empty.") 38 | } 39 | }, 40 | } 41 | 42 | cmd.Flags().BoolVarP(&headless, "headless", "n", true, "Login in headless mode without showing the browser window? [true, false]") 43 | 44 | return cmd 45 | } 46 | 47 | func promptForInput(prompt string) string { 48 | reader := bufio.NewReader(os.Stdin) 49 | fmt.Print(prompt) 50 | input, err := reader.ReadString('\n') 51 | if err != nil { 52 | fmt.Println("Error: Failed to read input.") 53 | os.Exit(1) 54 | } 55 | return strings.TrimSpace(input) 56 | } 57 | 58 | func promptForPassword(prompt string) string { 59 | fmt.Print(prompt) 60 | password, err := term.ReadPassword(int(os.Stdin.Fd())) 61 | if err != nil { 62 | fmt.Println("Error: Failed to read password.") 63 | os.Exit(1) 64 | } 65 | fmt.Println() 66 | return strings.TrimSpace(string(password)) 67 | } 68 | 69 | func validateCredentials(username, password string) bool { 70 | return username != "" && password != "" 71 | } 72 | -------------------------------------------------------------------------------- /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.2-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 | -------------------------------------------------------------------------------- /codecov.yml: -------------------------------------------------------------------------------- 1 | ignore: 2 | - "gui/*.go" 3 | -------------------------------------------------------------------------------- /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 | ) 12 | 13 | // Database variables 14 | var ( 15 | Db *gorm.DB 16 | Path string 17 | ) 18 | 19 | func init() { 20 | ConfigurePath() 21 | } 22 | 23 | // ConfigurePath determines and sets the database path based on environment variables. 24 | // It is public to allow for re-evaluation during testing. 25 | func ConfigurePath() { 26 | var baseDir string 27 | 28 | // 1. Check for explicit GOGG_HOME override 29 | if goggHome := os.Getenv("GOGG_HOME"); goggHome != "" { 30 | baseDir = goggHome 31 | } else if xdgDataHome := os.Getenv("XDG_DATA_HOME"); xdgDataHome != "" { 32 | // 2. Check for XDG_DATA_HOME convention (e.g., ~/.local/share) 33 | baseDir = filepath.Join(xdgDataHome, "gogg") 34 | } else { 35 | // 3. Fallback to the default in the user's home directory 36 | homeDir, err := os.UserHomeDir() 37 | if err != nil { 38 | log.Fatal().Err(err).Msg("Could not determine user home directory") 39 | } 40 | baseDir = filepath.Join(homeDir, ".gogg") 41 | } 42 | 43 | Path = filepath.Join(baseDir, "games.db") 44 | } 45 | 46 | // InitDB initializes the database and creates the tables if they don't exist. 47 | // It returns an error if any step in the initialization process fails. 48 | func InitDB() error { 49 | if err := createDBDirectory(); err != nil { 50 | return err 51 | } 52 | 53 | if err := openDatabase(); err != nil { 54 | return err 55 | } 56 | 57 | if err := migrateTables(); err != nil { 58 | return err 59 | } 60 | 61 | configureLogger() 62 | log.Info().Str("path", Path).Msg("Database initialized successfully") 63 | return nil 64 | } 65 | 66 | // createDBDirectory checks if the database path exists and creates it if it doesn't. 67 | // It returns an error if the directory creation fails. 68 | func createDBDirectory() error { 69 | dbDir := filepath.Dir(Path) 70 | if _, err := os.Stat(dbDir); os.IsNotExist(err) { 71 | if err := os.MkdirAll(dbDir, 0o750); err != nil { 72 | log.Error().Err(err).Msgf("Failed to create database directory: %s", dbDir) 73 | return err 74 | } 75 | } 76 | return nil 77 | } 78 | 79 | // openDatabase opens the database connection. 80 | // It returns an error if the database connection fails to open. 81 | func openDatabase() error { 82 | var err error 83 | Db, err = gorm.Open(sqlite.Open(Path), &gorm.Config{}) 84 | if err != nil { 85 | log.Error().Err(err).Msg("Failed to initialize database") 86 | return err 87 | } 88 | return nil 89 | } 90 | 91 | // migrateTables creates the tables if they don't exist. 92 | // It returns an error if the table migration fails. 93 | func migrateTables() error { 94 | if err := Db.AutoMigrate(&Game{}); err != nil { 95 | log.Error().Err(err).Msg("Failed to auto-migrate database") 96 | return err 97 | } 98 | 99 | if err := Db.AutoMigrate(&Token{}); err != nil { 100 | log.Error().Err(err).Msg("Failed to auto-migrate database") 101 | return err 102 | } 103 | return nil 104 | } 105 | 106 | // configureLogger configures the GORM logger based on the environment variable. 107 | func configureLogger() { 108 | if zerolog.GlobalLevel() == zerolog.Disabled { 109 | Db.Logger = Db.Logger.LogMode(0) // Silent mode 110 | } else { 111 | Db.Logger = Db.Logger.LogMode(4) // Debug mode 112 | } 113 | } 114 | 115 | // CloseDB closes the database connection. 116 | // It returns an error if the database connection fails to close. 117 | func CloseDB() error { 118 | if Db == nil { 119 | return nil // Nothing to close 120 | } 121 | sqlDB, err := Db.DB() 122 | if err != nil { 123 | log.Error().Err(err).Msg("Failed to get raw database connection") 124 | return err 125 | } 126 | return sqlDB.Close() 127 | } 128 | -------------------------------------------------------------------------------- /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 | -------------------------------------------------------------------------------- /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 | func PutInGame(id int, title, data string) error { 22 | game := Game{ 23 | ID: id, 24 | Title: title, 25 | Data: data, 26 | } 27 | 28 | return upsertGame(game) 29 | } 30 | 31 | // upsertGame performs an upsert operation on the game record. 32 | // It takes a Game object as a parameter and returns an error if the operation fails. 33 | func upsertGame(game Game) error { 34 | if err := Db.Clauses( 35 | clause.OnConflict{ 36 | UpdateAll: true, // Updates all fields if there's a conflict on the primary key (ID). 37 | }, 38 | ).Create(&game).Error; err != nil { 39 | log.Error().Err(err).Msgf("Failed to upsert game with ID %d", game.ID) 40 | return err 41 | } 42 | 43 | log.Info().Msgf("Game upserted successfully: ID=%d, Title=%s", game.ID, game.Title) 44 | return nil 45 | } 46 | 47 | // EmptyCatalogue removes all records from the game catalogue. 48 | // It returns an error if the operation fails. 49 | func EmptyCatalogue() error { 50 | if err := Db.Unscoped().Where("1 = 1").Delete(&Game{}).Error; err != nil { 51 | log.Error().Err(err).Msg("Failed to empty game catalogue") 52 | return err 53 | } 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 | func GetCatalogue() ([]Game, error) { 62 | var games []Game 63 | if err := Db.Find(&games).Error; err != nil { 64 | log.Error().Err(err).Msg("Failed to fetch games from the database") 65 | return nil, err 66 | } 67 | 68 | log.Info().Msgf("Retrieved %d games from the catalogue", len(games)) 69 | return games, nil 70 | } 71 | 72 | // GetGameByID retrieves a game from the catalogue by its ID. 73 | // It takes the game ID as a parameter and returns a pointer to the Game object and an error if the operation fails. 74 | func GetGameByID(id int) (*Game, error) { 75 | if Db == nil { 76 | return nil, fmt.Errorf("database connection is not initialized") 77 | } 78 | 79 | var game Game 80 | if err := Db.First(&game, "id = ?", id).Error; err != nil { 81 | if errors.Is(err, gorm.ErrRecordNotFound) { 82 | return nil, nil // Game not found 83 | } 84 | return nil, fmt.Errorf("failed to retrieve game with ID %d: %w", id, err) 85 | } 86 | 87 | return &game, nil 88 | } 89 | 90 | // SearchGamesByName searches for games in the catalogue by name. 91 | // It takes the game name as a parameter and returns a slice of Game objects and an error if the operation fails. 92 | func SearchGamesByName(name string) ([]Game, error) { 93 | if Db == nil { 94 | return nil, fmt.Errorf("database connection is not initialized") 95 | } 96 | 97 | var games []Game 98 | if err := Db.Where("title LIKE ?", "%"+name+"%").Find(&games).Error; err != nil { 99 | log.Error().Err(err).Msgf("Failed to search games by name: %s", name) 100 | return nil, err 101 | } 102 | 103 | return games, nil 104 | } 105 | -------------------------------------------------------------------------------- /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 | -------------------------------------------------------------------------------- /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 | type Token struct { 13 | ID uint `gorm:"primaryKey"` 14 | AccessToken string `json:"access_token,omitempty"` 15 | RefreshToken string `json:"refresh_token,omitempty"` 16 | ExpiresAt string `json:"expires_at,omitempty"` 17 | } 18 | 19 | // TokenStore is a concrete implementation of the auth.TokenStorer interface using GORM. 20 | type TokenStore struct{} 21 | 22 | func (ts *TokenStore) GetTokenRecord() (*Token, error) { 23 | return GetTokenRecord() 24 | } 25 | 26 | func (ts *TokenStore) UpsertTokenRecord(token *Token) error { 27 | return UpsertTokenRecord(token) 28 | } 29 | 30 | func GetTokenRecord() (*Token, error) { 31 | if Db == nil { 32 | return nil, fmt.Errorf("database connection is not initialized") 33 | } 34 | 35 | var token Token 36 | if err := Db.First(&token).Error; err != nil { 37 | if errors.Is(err, gorm.ErrRecordNotFound) { 38 | return nil, nil 39 | } 40 | log.Error().Err(err).Msg("Failed to retrieve token data") 41 | return nil, err 42 | } 43 | 44 | return &token, nil 45 | } 46 | 47 | func UpsertTokenRecord(token *Token) error { 48 | if Db == nil { 49 | return fmt.Errorf("database connection is not initialized") 50 | } 51 | 52 | token.ID = 1 53 | 54 | if err := Db.Clauses(clause.OnConflict{ 55 | Columns: []clause.Column{{Name: "id"}}, 56 | DoUpdates: clause.AssignmentColumns([]string{"access_token", "refresh_token", "expires_at"}), 57 | }).Create(token).Error; err != nil { 58 | log.Error().Err(err).Msgf("Failed to upsert token") 59 | return err 60 | } 61 | 62 | log.Info().Msgf("Token upserted successfully") 63 | return nil 64 | } 65 | -------------------------------------------------------------------------------- /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 | -------------------------------------------------------------------------------- /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 | -------------------------------------------------------------------------------- /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 = 1 # Include DLCs 21 | $INCLUDE_EXTRA_CONTENT = 1 # Include extra content 22 | $RESUME_DOWNLOAD = 1 # Resume download 23 | $NUM_THREADS = 4 # Number of worker threads for downloading 24 | $FLATTEN = 1 # Flatten directory structure 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 65 | Start-Sleep -Seconds 1 66 | } 67 | 68 | # Clean up 69 | Cleanup 70 | -------------------------------------------------------------------------------- /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=1 # Include DLCs 20 | INCLUDE_EXTRA_CONTENT=1 # Include extra content 21 | RESUME_DOWNLOAD=1 # Resume download 22 | NUM_THREADS=4 # Number of worker threads for downloading 23 | FLATTEN=1 # 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 60 | sleep 1 61 | #break # Comment out this line to download all games 62 | done 63 | 64 | # Clean up 65 | cleanup 66 | -------------------------------------------------------------------------------- /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 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 | -------------------------------------------------------------------------------- /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 | -------------------------------------------------------------------------------- /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 | -------------------------------------------------------------------------------- /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 | -------------------------------------------------------------------------------- /go.mod: -------------------------------------------------------------------------------- 1 | module github.com/habedi/gogg 2 | 3 | go 1.24 4 | 5 | toolchain go1.24.1 6 | 7 | require ( 8 | fyne.io/fyne/v2 v2.6.1 9 | github.com/chromedp/chromedp v0.13.7 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.9.1 15 | github.com/stretchr/testify v1.10.0 16 | golang.org/x/term v0.33.0 17 | gorm.io/driver/sqlite v1.6.0 18 | gorm.io/gorm v1.30.0 19 | ) 20 | 21 | require ( 22 | fyne.io/systray v1.11.0 // indirect 23 | github.com/BurntSushi/toml v1.5.0 // indirect 24 | github.com/chromedp/cdproto v0.0.0-20250715215929-4738bcb231c7 // 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.0 // 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.1.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-20250714165856-be8212f5270d // 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.7 // 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.34.0 // indirect 74 | golang.org/x/text v0.24.0 // indirect 75 | gopkg.in/yaml.v3 v3.0.1 // indirect 76 | ) 77 | -------------------------------------------------------------------------------- /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 | -------------------------------------------------------------------------------- /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 | err := client.RefreshCatalogue(ctx, authService, 10, progressCb) 38 | 39 | runOnMain(func() { 40 | dlg.Hide() 41 | 42 | if errors.Is(err, context.Canceled) { 43 | games, dbErr := db.GetCatalogue() 44 | var msg string 45 | if dbErr != nil { 46 | msg = "Refresh was cancelled. Could not retrieve partial game count." 47 | } else { 48 | msg = fmt.Sprintf("Refresh was cancelled.\n%d games were loaded before stopping.", len(games)) 49 | } 50 | dialog.ShowInformation("Refresh Cancelled", msg, win) 51 | SignalCatalogueUpdated() // Signal that a partial update occurred 52 | } else if err != nil { 53 | showErrorDialog(win, "Failed to refresh catalogue", err) 54 | } else { 55 | games, dbErr := db.GetCatalogue() 56 | if dbErr != nil { 57 | dialog.ShowInformation("Success", "Successfully refreshed catalogue.", win) 58 | } else { 59 | successMsg := fmt.Sprintf("Successfully refreshed catalogue.\nYour library now contains %d games.", len(games)) 60 | dialog.ShowInformation("Success", successMsg, win) 61 | } 62 | SignalCatalogueUpdated() // Signal that the update is complete 63 | } 64 | 65 | onFinish() 66 | }) 67 | }() 68 | } 69 | 70 | func ExportCatalogueAction(win fyne.Window, format string) { 71 | defaultName := fmt.Sprintf("gogg_catalogue.%s", format) 72 | fileDialog := dialog.NewFileSave(func(uc fyne.URIWriteCloser, err error) { 73 | if err != nil { 74 | showErrorDialog(win, "File save error", err) 75 | return 76 | } 77 | if uc == nil { 78 | return 79 | } 80 | defer uc.Close() 81 | 82 | games, err := db.GetCatalogue() 83 | if err != nil { 84 | showErrorDialog(win, "Failed to read catalogue from database", err) 85 | return 86 | } 87 | if len(games) == 0 { 88 | dialog.ShowInformation("Info", "Catalogue is empty. Nothing to export.", win) 89 | return 90 | } 91 | 92 | var exportErr error 93 | if format == "json" { 94 | enc := json.NewEncoder(uc) 95 | enc.SetIndent("", " ") 96 | exportErr = enc.Encode(games) 97 | } else { // csv 98 | if _, err := fmt.Fprintln(uc, "ID,Title"); err != nil { 99 | exportErr = err 100 | } else { 101 | for _, g := range games { 102 | title := strings.ReplaceAll(g.Title, "\"", "\"\"") 103 | if _, err := fmt.Fprintf(uc, "%d,\"%s\"\n", g.ID, title); err != nil { 104 | exportErr = err 105 | break 106 | } 107 | } 108 | } 109 | } 110 | 111 | if exportErr != nil { 112 | showErrorDialog(win, "Failed to write export file", exportErr) 113 | } else { 114 | dialog.ShowInformation("Success", "Data exported successfully.", win) 115 | } 116 | }, win) 117 | fileDialog.SetFileName(defaultName) 118 | fileDialog.SetFilter(storage.NewExtensionFileFilter([]string{"." + format})) 119 | fileDialog.Resize(fyne.NewSize(800, 600)) 120 | fileDialog.Show() 121 | } 122 | 123 | func showErrorDialog(win fyne.Window, msg string, err error) { 124 | detail := msg 125 | if err != nil { 126 | detail = fmt.Sprintf("%s\nError: %v", msg, err) 127 | } 128 | d := dialog.NewError(errors.New(detail), win) 129 | d.Show() 130 | } 131 | -------------------------------------------------------------------------------- /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 | -------------------------------------------------------------------------------- /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 | -------------------------------------------------------------------------------- /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 | -------------------------------------------------------------------------------- /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 | -------------------------------------------------------------------------------- /gui/assets/ding-small-bell-sfx-233008.mp3: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/habedi/gogg/0b697978dc25006fb2e1edbb500b016ca736641b/gui/assets/ding-small-bell-sfx-233008.mp3 -------------------------------------------------------------------------------- /gui/assets/game-card-svgrepo-com.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 8 | 11 | 12 | -------------------------------------------------------------------------------- /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 | -------------------------------------------------------------------------------- /gui/download.go: -------------------------------------------------------------------------------- 1 | package gui 2 | 3 | import ( 4 | "bytes" 5 | "context" 6 | "encoding/json" 7 | "errors" 8 | "fmt" 9 | "math" 10 | "path/filepath" 11 | "sort" 12 | "strings" 13 | "sync" 14 | "time" 15 | 16 | "fyne.io/fyne/v2" 17 | "fyne.io/fyne/v2/data/binding" 18 | "github.com/habedi/gogg/auth" 19 | "github.com/habedi/gogg/client" 20 | "github.com/habedi/gogg/db" 21 | "github.com/rs/zerolog/log" 22 | ) 23 | 24 | var ( 25 | ErrDownloadInProgress = errors.New("download already in progress") 26 | activeDownloads = make(map[int]struct{}) 27 | activeDownloadsMutex = &sync.Mutex{} 28 | ) 29 | 30 | func formatBytes(b int64) string { 31 | const unit = 1024 32 | if b < unit { 33 | return fmt.Sprintf("%d B", b) 34 | } 35 | div, exp := int64(unit), 0 36 | for n := b / unit; n >= unit; n /= unit { 37 | div *= unit 38 | exp++ 39 | } 40 | return fmt.Sprintf("%.1f %ciB", float64(b)/float64(div), "KMGTPE"[exp]) 41 | } 42 | 43 | type progressUpdater struct { 44 | task *DownloadTask 45 | totalBytes int64 46 | downloadedBytes int64 47 | fileBytes map[string]int64 48 | fileProgress map[string]struct{ current, total int64 } 49 | mu sync.Mutex 50 | incompleteMessage []byte 51 | lastUpdateTime time.Time 52 | lastBytes int64 53 | speeds []float64 54 | speedAvgSize int 55 | } 56 | 57 | func (pu *progressUpdater) Write(p []byte) (n int, err error) { 58 | pu.mu.Lock() 59 | defer pu.mu.Unlock() 60 | 61 | data := append(pu.incompleteMessage, p...) 62 | pu.incompleteMessage = nil 63 | 64 | dec := json.NewDecoder(bytes.NewReader(data)) 65 | for dec.More() { 66 | var update client.ProgressUpdate 67 | if err := dec.Decode(&update); err != nil { 68 | offset := int(dec.InputOffset()) 69 | pu.incompleteMessage = data[offset:] 70 | break 71 | } 72 | 73 | switch update.Type { 74 | case "start": 75 | pu.totalBytes = update.OverallTotalBytes 76 | pu.lastUpdateTime = time.Now() 77 | case "file_progress": 78 | diff := update.CurrentBytes - pu.fileBytes[update.FileName] 79 | pu.downloadedBytes += diff 80 | pu.fileBytes[update.FileName] = update.CurrentBytes 81 | 82 | if pu.totalBytes > 0 { 83 | progress := float64(pu.downloadedBytes) / float64(pu.totalBytes) 84 | _ = pu.task.Progress.Set(progress) 85 | } 86 | if pu.task.State == StatePreparing { 87 | pu.task.State = StateDownloading 88 | _ = pu.task.Status.Set("Downloading files...") 89 | } 90 | pu.updateSpeedAndETA() 91 | 92 | pu.fileProgress[update.FileName] = struct{ current, total int64 }{update.CurrentBytes, update.TotalBytes} 93 | if update.CurrentBytes >= update.TotalBytes && update.TotalBytes > 0 { 94 | delete(pu.fileProgress, update.FileName) 95 | } 96 | pu.updateFileStatusText() 97 | } 98 | } 99 | 100 | return len(p), nil 101 | } 102 | 103 | func (pu *progressUpdater) updateSpeedAndETA() { 104 | now := time.Now() 105 | elapsed := now.Sub(pu.lastUpdateTime).Seconds() 106 | 107 | if elapsed < 1.0 { 108 | return 109 | } 110 | 111 | bytesSinceLast := pu.downloadedBytes - pu.lastBytes 112 | currentSpeed := float64(bytesSinceLast) / elapsed 113 | 114 | if pu.speedAvgSize == 0 { 115 | pu.speedAvgSize = 5 116 | } 117 | pu.speeds = append(pu.speeds, currentSpeed) 118 | if len(pu.speeds) > pu.speedAvgSize { 119 | pu.speeds = pu.speeds[1:] 120 | } 121 | 122 | var totalSpeed float64 123 | for _, s := range pu.speeds { 124 | totalSpeed += s 125 | } 126 | avgSpeed := totalSpeed / float64(len(pu.speeds)) 127 | 128 | pu.lastUpdateTime = now 129 | pu.lastBytes = pu.downloadedBytes 130 | 131 | detailsStr := fmt.Sprintf("Speed: %s/s", formatBytes(int64(avgSpeed))) 132 | remainingBytes := pu.totalBytes - pu.downloadedBytes 133 | if avgSpeed > 0 && remainingBytes > 0 { 134 | etaSeconds := float64(remainingBytes) / avgSpeed 135 | duration, _ := time.ParseDuration(fmt.Sprintf("%fs", math.Round(etaSeconds))) 136 | detailsStr += fmt.Sprintf(" | ETA: %s", duration.Truncate(time.Second).String()) 137 | } 138 | 139 | _ = pu.task.Details.Set(detailsStr) 140 | } 141 | 142 | func (pu *progressUpdater) updateFileStatusText() { 143 | if len(pu.fileProgress) == 0 { 144 | _ = pu.task.FileStatus.Set("") 145 | return 146 | } 147 | 148 | files := make([]string, 0, len(pu.fileProgress)) 149 | for f := range pu.fileProgress { 150 | files = append(files, f) 151 | } 152 | sort.Strings(files) 153 | 154 | var sb strings.Builder 155 | const maxLines = 3 156 | for i, file := range files { 157 | if i >= maxLines { 158 | sb.WriteString(fmt.Sprintf("...and %d more files.", len(files)-maxLines)) 159 | break 160 | } 161 | progress := pu.fileProgress[file] 162 | percentage := 0 163 | if progress.total > 0 { 164 | percentage = int((float64(progress.current) / float64(progress.total)) * 100) 165 | } 166 | sizeStr := fmt.Sprintf("%s/%s", formatBytes(progress.current), formatBytes(progress.total)) 167 | sb.WriteString(fmt.Sprintf("%s: %s (%d%%)\n", file, sizeStr, percentage)) 168 | } 169 | 170 | _ = pu.task.FileStatus.Set(strings.TrimSpace(sb.String())) 171 | } 172 | 173 | func executeDownload(authService *auth.Service, dm *DownloadManager, game db.Game, 174 | downloadPath, language, platformName string, extrasFlag, dlcFlag, resumeFlag, 175 | flattenFlag, skipPatchesFlag bool, numThreads int) error { 176 | 177 | activeDownloadsMutex.Lock() 178 | if _, exists := activeDownloads[game.ID]; exists { 179 | log.Warn().Int("gameID", game.ID).Msg("Download is already in progress. Ignoring new request.") 180 | activeDownloadsMutex.Unlock() 181 | return ErrDownloadInProgress 182 | } 183 | activeDownloads[game.ID] = struct{}{} 184 | activeDownloadsMutex.Unlock() 185 | 186 | go func() { 187 | defer func() { 188 | activeDownloadsMutex.Lock() 189 | delete(activeDownloads, game.ID) 190 | activeDownloadsMutex.Unlock() 191 | dm.PersistHistory() 192 | }() 193 | 194 | ctx, cancel := context.WithCancel(context.Background()) 195 | 196 | parsedGameData, err := client.ParseGameData(game.Data) 197 | if err != nil { 198 | fmt.Printf("Error parsing game data for %s: %v\n", game.Title, err) 199 | cancel() 200 | return 201 | } 202 | targetDir := filepath.Join(downloadPath, client.SanitizePath(parsedGameData.Title)) 203 | 204 | task := &DownloadTask{ 205 | ID: game.ID, 206 | InstanceID: time.Now(), 207 | Title: game.Title, 208 | State: StatePreparing, 209 | Status: binding.NewString(), 210 | Details: binding.NewString(), 211 | Progress: binding.NewFloat(), 212 | CancelFunc: cancel, 213 | FileStatus: binding.NewString(), 214 | DownloadPath: targetDir, 215 | } 216 | _ = task.Status.Set("Preparing...") 217 | _ = task.Details.Set("Speed: N/A | ETA: N/A") 218 | _ = dm.AddTask(task) 219 | 220 | fyne.CurrentApp().Preferences().SetString("lastUsedDownloadPath", downloadPath) 221 | 222 | token, err := authService.RefreshToken() 223 | if err != nil { 224 | task.State = StateError 225 | _ = task.Status.Set(fmt.Sprintf("Error: %v", err)) 226 | return 227 | } 228 | 229 | updater := &progressUpdater{ 230 | task: task, 231 | fileBytes: make(map[string]int64), 232 | fileProgress: make(map[string]struct{ current, total int64 }), 233 | } 234 | 235 | err = client.DownloadGameFiles( 236 | ctx, token.AccessToken, parsedGameData, downloadPath, language, platformName, 237 | extrasFlag, dlcFlag, resumeFlag, flattenFlag, skipPatchesFlag, numThreads, 238 | updater, 239 | ) 240 | 241 | if err != nil { 242 | if errors.Is(err, context.Canceled) { 243 | task.State = StateCancelled 244 | _ = task.Status.Set("Cancelled") 245 | } else { 246 | task.State = StateError 247 | _ = task.Status.Set(fmt.Sprintf("Error: %v", err)) 248 | } 249 | _ = task.FileStatus.Set("") 250 | _ = task.Details.Set("") 251 | return 252 | } 253 | 254 | task.State = StateCompleted 255 | _ = task.Status.Set(fmt.Sprintf("Download completed. Files are stored in: %s", targetDir)) 256 | _ = task.Details.Set("") 257 | _ = task.Progress.Set(1.0) 258 | _ = task.FileStatus.Set("") 259 | go PlayNotificationSound() 260 | }() 261 | 262 | return nil 263 | } 264 | -------------------------------------------------------------------------------- /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 | -------------------------------------------------------------------------------- /gui/manager.go: -------------------------------------------------------------------------------- 1 | package gui 2 | 3 | import ( 4 | "context" 5 | "encoding/json" 6 | "io" 7 | "sync" 8 | "time" 9 | 10 | "fyne.io/fyne/v2" 11 | "fyne.io/fyne/v2/container" 12 | "fyne.io/fyne/v2/data/binding" 13 | "fyne.io/fyne/v2/layout" 14 | "fyne.io/fyne/v2/storage" 15 | "fyne.io/fyne/v2/theme" 16 | "fyne.io/fyne/v2/widget" 17 | "github.com/rs/zerolog/log" 18 | ) 19 | 20 | const ( 21 | StatePreparing = iota 22 | StateDownloading 23 | StateCompleted 24 | StateCancelled 25 | StateError 26 | ) 27 | 28 | type DownloadTask struct { 29 | ID int 30 | InstanceID time.Time // Unique identifier for this specific download 31 | State int 32 | Title string 33 | Status binding.String 34 | Details binding.String 35 | Progress binding.Float 36 | CancelFunc context.CancelFunc 37 | FileStatus binding.String 38 | DownloadPath string 39 | } 40 | 41 | // PersistentDownloadTask is a serializable representation of a finished task. 42 | type PersistentDownloadTask struct { 43 | ID int `json:"id"` 44 | InstanceID time.Time `json:"instance_id"` 45 | State int `json:"state"` 46 | Title string `json:"title"` 47 | StatusText string `json:"status_text"` 48 | DownloadPath string `json:"download_path"` 49 | } 50 | 51 | type DownloadManager struct { 52 | mu sync.RWMutex 53 | Tasks binding.UntypedList 54 | historyPath fyne.URI 55 | } 56 | 57 | func NewDownloadManager() *DownloadManager { 58 | a := fyne.CurrentApp() 59 | historyURI, err := storage.Child(a.Storage().RootURI(), "download_history.json") 60 | if err != nil { 61 | log.Fatal().Err(err).Msg("Failed to create history file path") 62 | } 63 | 64 | dm := &DownloadManager{ 65 | Tasks: binding.NewUntypedList(), 66 | historyPath: historyURI, 67 | } 68 | 69 | dm.loadHistory() 70 | return dm 71 | } 72 | 73 | func (dm *DownloadManager) AddTask(task *DownloadTask) error { 74 | dm.mu.Lock() 75 | defer dm.mu.Unlock() 76 | return dm.Tasks.Append(task) 77 | } 78 | 79 | func (dm *DownloadManager) loadHistory() { 80 | dm.mu.Lock() 81 | defer dm.mu.Unlock() 82 | 83 | reader, err := storage.Reader(dm.historyPath) 84 | if err != nil { 85 | log.Info().Msg("No download history found or file is not readable.") 86 | return 87 | } 88 | defer reader.Close() 89 | 90 | bytes, err := io.ReadAll(reader) 91 | if err != nil || len(bytes) == 0 { 92 | log.Error().Err(err).Msg("Failed to read history file or file is empty.") 93 | return 94 | } 95 | 96 | var persistentTasks []PersistentDownloadTask 97 | if err := json.Unmarshal(bytes, &persistentTasks); err != nil { 98 | log.Error().Err(err).Msg("Failed to unmarshal download history.") 99 | return 100 | } 101 | 102 | uiTasks := make([]interface{}, 0, len(persistentTasks)) 103 | for _, pTask := range persistentTasks { 104 | status := binding.NewString() 105 | _ = status.Set(pTask.StatusText) 106 | progress := binding.NewFloat() 107 | if pTask.State == StateCompleted { 108 | _ = progress.Set(1.0) 109 | } 110 | 111 | uiTasks = append(uiTasks, &DownloadTask{ 112 | ID: pTask.ID, 113 | InstanceID: pTask.InstanceID, 114 | State: pTask.State, 115 | Title: pTask.Title, 116 | Status: status, 117 | Progress: progress, 118 | DownloadPath: pTask.DownloadPath, 119 | Details: binding.NewString(), 120 | FileStatus: binding.NewString(), 121 | CancelFunc: nil, 122 | }) 123 | } 124 | _ = dm.Tasks.Set(uiTasks) 125 | log.Info().Int("count", len(uiTasks)).Msg("Download history loaded.") 126 | } 127 | 128 | func (dm *DownloadManager) PersistHistory() { 129 | dm.mu.Lock() 130 | defer dm.mu.Unlock() 131 | 132 | allTasks, _ := dm.Tasks.Get() 133 | persistentTasks := make([]PersistentDownloadTask, 0) 134 | 135 | for _, taskRaw := range allTasks { 136 | task := taskRaw.(*DownloadTask) 137 | if task.State == StateCompleted || task.State == StateCancelled || task.State == StateError { 138 | status, _ := task.Status.Get() 139 | persistentTasks = append(persistentTasks, PersistentDownloadTask{ 140 | ID: task.ID, 141 | InstanceID: task.InstanceID, 142 | State: task.State, 143 | Title: task.Title, 144 | StatusText: status, 145 | DownloadPath: task.DownloadPath, 146 | }) 147 | } 148 | } 149 | 150 | writer, err := storage.Writer(dm.historyPath) 151 | if err != nil { 152 | log.Error().Err(err).Msg("Failed to open history file for writing.") 153 | return 154 | } 155 | defer writer.Close() 156 | 157 | encoder := json.NewEncoder(writer) 158 | encoder.SetIndent("", " ") 159 | if err := encoder.Encode(persistentTasks); err != nil { 160 | log.Error().Err(err).Msg("Failed to encode and save download history.") 161 | } 162 | } 163 | 164 | func DownloadsTabUI(dm *DownloadManager) fyne.CanvasObject { 165 | list := widget.NewListWithData( 166 | dm.Tasks, 167 | func() fyne.CanvasObject { 168 | title := widget.NewLabel("Game Title") 169 | title.TextStyle = fyne.TextStyle{Bold: true} 170 | 171 | actionBtn := widget.NewButtonWithIcon("Action", theme.CancelIcon(), nil) 172 | clearBtn := widget.NewButtonWithIcon("", theme.DeleteIcon(), nil) 173 | clearBtn.Importance = widget.LowImportance 174 | 175 | actionBox := container.NewHBox(actionBtn, clearBtn) 176 | topRow := container.NewBorder(nil, nil, nil, actionBox, title) 177 | 178 | status := widget.NewLabel("Status") 179 | status.Wrapping = fyne.TextWrapWord 180 | details := widget.NewLabel("Details") 181 | details.TextStyle = fyne.TextStyle{Italic: true} 182 | progress := widget.NewProgressBar() 183 | fileStatus := widget.NewLabel("") 184 | fileStatus.TextStyle = fyne.TextStyle{Monospace: true} 185 | fileStatus.Wrapping = fyne.TextWrapWord 186 | 187 | progressBox := container.NewVBox(details, progress) 188 | content := container.NewVBox(topRow, status, progressBox, fileStatus) 189 | 190 | return widget.NewCard("", "", content) 191 | }, 192 | func(item binding.DataItem, obj fyne.CanvasObject) { 193 | taskRaw, err := item.(binding.Untyped).Get() 194 | if err != nil { 195 | return 196 | } 197 | task := taskRaw.(*DownloadTask) 198 | 199 | card := obj.(*widget.Card) 200 | contentVBox := card.Content.(*fyne.Container) 201 | topRow := contentVBox.Objects[0].(*fyne.Container) 202 | actionBox := topRow.Objects[1].(*fyne.Container) 203 | 204 | title := topRow.Objects[0].(*widget.Label) 205 | actionBtn := actionBox.Objects[0].(*widget.Button) 206 | clearBtn := actionBox.Objects[1].(*widget.Button) 207 | 208 | status := contentVBox.Objects[1].(*widget.Label) 209 | progressBox := contentVBox.Objects[2].(*fyne.Container) 210 | details := progressBox.Objects[0].(*widget.Label) 211 | progress := progressBox.Objects[1].(*widget.ProgressBar) 212 | fileStatus := contentVBox.Objects[3].(*widget.Label) 213 | 214 | title.SetText(task.Title) 215 | status.Bind(task.Status) 216 | details.Bind(task.Details) 217 | progress.Bind(task.Progress) 218 | fileStatus.Bind(task.FileStatus) 219 | 220 | clearBtn.OnTapped = func() { 221 | dm.mu.Lock() 222 | currentTasks, _ := dm.Tasks.Get() 223 | keptTasks := make([]interface{}, 0) 224 | for _, tRaw := range currentTasks { 225 | if tRaw.(*DownloadTask).InstanceID != task.InstanceID { 226 | keptTasks = append(keptTasks, tRaw) 227 | } 228 | } 229 | _ = dm.Tasks.Set(keptTasks) 230 | dm.mu.Unlock() 231 | dm.PersistHistory() 232 | } 233 | 234 | switch task.State { 235 | case StateCompleted: 236 | actionBtn.SetIcon(theme.FolderOpenIcon()) 237 | actionBtn.SetText("Open Folder") 238 | actionBtn.OnTapped = func() { openFolder(task.DownloadPath) } 239 | actionBtn.Enable() 240 | clearBtn.Show() 241 | case StateCancelled, StateError: 242 | actionBtn.SetIcon(theme.ErrorIcon()) 243 | actionBtn.SetText("Error") 244 | if task.State == StateCancelled { 245 | actionBtn.SetIcon(theme.CancelIcon()) 246 | actionBtn.SetText("Cancelled") 247 | } 248 | actionBtn.OnTapped = nil 249 | actionBtn.Disable() 250 | clearBtn.Show() 251 | default: // Preparing, Downloading 252 | actionBtn.SetIcon(theme.CancelIcon()) 253 | actionBtn.SetText("Cancel") 254 | actionBtn.OnTapped = func() { 255 | if task.CancelFunc != nil { 256 | task.CancelFunc() 257 | } 258 | } 259 | actionBtn.Enable() 260 | clearBtn.Hide() 261 | } 262 | }, 263 | ) 264 | 265 | clearAllBtn := widget.NewButton("Clear All Finished", func() { 266 | dm.mu.Lock() 267 | currentTasks, _ := dm.Tasks.Get() 268 | keptTasks := make([]interface{}, 0) 269 | for _, taskRaw := range currentTasks { 270 | task := taskRaw.(*DownloadTask) 271 | if task.State != StateCompleted && task.State != StateCancelled && task.State != StateError { 272 | keptTasks = append(keptTasks, task) 273 | } 274 | } 275 | _ = dm.Tasks.Set(keptTasks) 276 | dm.mu.Unlock() 277 | dm.PersistHistory() 278 | }) 279 | bottomBar := container.NewHBox(layout.NewSpacer(), clearAllBtn) 280 | 281 | return container.NewBorder(nil, bottomBar, nil, nil, list) 282 | } 283 | -------------------------------------------------------------------------------- /gui/settings.go: -------------------------------------------------------------------------------- 1 | package gui 2 | 3 | import ( 4 | "os" 5 | 6 | "fyne.io/fyne/v2" 7 | "fyne.io/fyne/v2/container" 8 | "fyne.io/fyne/v2/dialog" 9 | "fyne.io/fyne/v2/storage" 10 | "fyne.io/fyne/v2/widget" 11 | ) 12 | 13 | func SettingsTabUI(win fyne.Window) fyne.CanvasObject { 14 | prefs := fyne.CurrentApp().Preferences() 15 | a := fyne.CurrentApp() 16 | 17 | // --- Theme Settings --- 18 | themeRadio := widget.NewRadioGroup([]string{"System Default", "Light", "Dark"}, func(selected string) { 19 | prefs.SetString("theme", selected) 20 | a.Settings().SetTheme(CreateThemeFromPreferences()) 21 | }) 22 | themeRadio.SetSelected(prefs.StringWithFallback("theme", "System Default")) 23 | 24 | themeBox := container.NewVBox(widget.NewLabel("UI Theme"), themeRadio) 25 | 26 | // --- Font Settings --- 27 | fontOptions := []string{ 28 | "System Default", 29 | "JetBrains Mono", 30 | "JetBrains Mono Bold", 31 | } 32 | fontSelect := widget.NewSelect(fontOptions, func(selected string) { 33 | prefs.SetString("fontName", selected) 34 | a.Settings().SetTheme(CreateThemeFromPreferences()) 35 | }) 36 | fontSelect.SetSelected(prefs.StringWithFallback("fontName", "System Default")) 37 | 38 | fontSizeSelect := widget.NewSelect([]string{"Small", "Normal", "Large", "Extra Large"}, func(s string) { 39 | prefs.SetString("fontSize", s) 40 | a.Settings().SetTheme(CreateThemeFromPreferences()) 41 | }) 42 | fontSizeSelect.SetSelected(prefs.StringWithFallback("fontSize", "Normal")) 43 | 44 | fontBox := container.NewVBox( 45 | widget.NewLabel("Font Family"), fontSelect, 46 | widget.NewLabel("Font Size"), fontSizeSelect, 47 | ) 48 | 49 | // --- Sound Settings --- 50 | soundCheck := widget.NewCheck("Play sound on download completion", func(checked bool) { 51 | prefs.SetBool("soundEnabled", checked) 52 | }) 53 | soundCheck.SetChecked(prefs.BoolWithFallback("soundEnabled", true)) 54 | 55 | soundPathLabel := widget.NewLabel("") 56 | soundStatusLabel := widget.NewLabelWithStyle("", fyne.TextAlignLeading, fyne.TextStyle{Italic: true}) 57 | 58 | validateSoundPath := func(path string) { 59 | if path == "" { 60 | soundPathLabel.SetText("Default sound file") 61 | soundStatusLabel.SetText("") 62 | soundStatusLabel.Hide() 63 | return 64 | } 65 | 66 | soundPathLabel.SetText(path) 67 | if _, err := os.Stat(path); err != nil { 68 | soundStatusLabel.SetText("File not found. Using default sound.") 69 | soundStatusLabel.Show() 70 | } else { 71 | soundStatusLabel.SetText("") 72 | soundStatusLabel.Hide() 73 | } 74 | } 75 | validateSoundPath(prefs.String("soundFilePath")) 76 | 77 | selectSoundBtn := widget.NewButton("Select Custom Sound...", func() { 78 | fd := dialog.NewFileOpen(func(reader fyne.URIReadCloser, err error) { 79 | if err != nil { 80 | dialog.ShowError(err, win) 81 | return 82 | } 83 | if reader == nil { 84 | return 85 | } 86 | path := reader.URI().Path() 87 | prefs.SetString("soundFilePath", path) 88 | validateSoundPath(path) 89 | }, win) 90 | fd.SetFilter(storage.NewExtensionFileFilter([]string{".mp3", ".wav", ".ogg"})) 91 | fd.Resize(fyne.NewSize(800, 600)) 92 | fd.Show() 93 | }) 94 | 95 | resetSoundBtn := widget.NewButton("Reset", func() { 96 | prefs.RemoveValue("soundFilePath") 97 | validateSoundPath("") 98 | }) 99 | 100 | testSoundBtn := widget.NewButton("Test", func() { 101 | validateSoundPath(prefs.String("soundFilePath")) 102 | go PlayNotificationSound() 103 | }) 104 | 105 | soundConfigBox := container.NewVBox( 106 | widget.NewLabel("Current sound file:"), 107 | soundPathLabel, 108 | soundStatusLabel, 109 | container.NewHBox(selectSoundBtn, resetSoundBtn, testSoundBtn), 110 | ) 111 | 112 | // --- Layout --- 113 | mainCard := widget.NewCard("Settings", "", container.NewVBox( 114 | themeBox, 115 | widget.NewSeparator(), 116 | fontBox, 117 | widget.NewSeparator(), 118 | soundCheck, 119 | soundConfigBox, 120 | )) 121 | 122 | return container.NewCenter(mainCard) 123 | } 124 | -------------------------------------------------------------------------------- /gui/shared.go: -------------------------------------------------------------------------------- 1 | package gui 2 | 3 | import ( 4 | "fyne.io/fyne/v2" 5 | ) 6 | 7 | // runOnMain schedules fn to run on the main Fyne thread 8 | func runOnMain(fn func()) { 9 | fyne.Do(fn) 10 | } 11 | -------------------------------------------------------------------------------- /gui/sound.go: -------------------------------------------------------------------------------- 1 | package gui 2 | 3 | import ( 4 | "bytes" 5 | _ "embed" 6 | "fmt" 7 | "io" 8 | "os" 9 | "path/filepath" 10 | "strings" 11 | "sync" 12 | "time" 13 | 14 | "fyne.io/fyne/v2" 15 | "github.com/faiface/beep" 16 | "github.com/faiface/beep/mp3" 17 | "github.com/faiface/beep/speaker" 18 | "github.com/faiface/beep/vorbis" 19 | "github.com/faiface/beep/wav" 20 | "github.com/rs/zerolog/log" 21 | ) 22 | 23 | //go:embed assets/ding-small-bell-sfx-233008.mp3 24 | var defaultDingSound []byte 25 | 26 | var ( 27 | speakerOnce sync.Once 28 | mixer *beep.Mixer 29 | sampleRate beep.SampleRate 30 | ) 31 | 32 | func initSpeaker(sr beep.SampleRate) { 33 | speakerOnce.Do(func() { 34 | sampleRate = sr 35 | // The buffer size should be large enough to avoid under-runs. 36 | bufferSize := sr.N(time.Second / 10) 37 | if err := speaker.Init(sampleRate, bufferSize); err != nil { 38 | log.Error().Err(err).Msg("Failed to initialize speaker") 39 | return 40 | } 41 | mixer = &beep.Mixer{} 42 | speaker.Play(mixer) 43 | }) 44 | } 45 | 46 | func PlayNotificationSound() { 47 | a := fyne.CurrentApp() 48 | if !a.Preferences().BoolWithFallback("soundEnabled", true) { 49 | return 50 | } 51 | 52 | filePath := a.Preferences().String("soundFilePath") 53 | var reader io.ReadCloser 54 | isDefault := false 55 | 56 | if filePath != "" { 57 | f, err := os.Open(filePath) 58 | if err != nil { 59 | log.Error().Err(err).Str("path", filePath).Msg("Failed to open custom sound file, falling back to default") 60 | isDefault = true 61 | } else { 62 | reader = f 63 | } 64 | } else { 65 | isDefault = true 66 | } 67 | 68 | if isDefault { 69 | if len(defaultDingSound) == 0 { 70 | log.Warn().Msg("No custom sound set and default sound asset is missing.") 71 | return 72 | } 73 | reader = io.NopCloser(bytes.NewReader(defaultDingSound)) 74 | filePath = ".mp3" // Pretend it's an mp3 for the decoder switch 75 | } 76 | defer reader.Close() 77 | 78 | var streamer beep.StreamSeekCloser 79 | var format beep.Format 80 | var err error 81 | 82 | switch strings.ToLower(filepath.Ext(filePath)) { 83 | case ".mp3": 84 | streamer, format, err = mp3.Decode(reader) 85 | case ".wav": 86 | streamer, format, err = wav.Decode(reader) 87 | case ".ogg": 88 | streamer, format, err = vorbis.Decode(reader) 89 | default: 90 | err = fmt.Errorf("unsupported sound format for file: %s", filePath) 91 | } 92 | 93 | if err != nil { 94 | log.Error().Err(err).Msg("Failed to decode audio stream") 95 | return 96 | } 97 | 98 | // Initialize the speaker with the format of the first sound played. 99 | initSpeaker(format.SampleRate) 100 | 101 | // Create a new streamer that is resampled to the mixer's sample rate. 102 | resampled := beep.Resample(4, format.SampleRate, sampleRate, streamer) 103 | 104 | // Add the resampled audio to the mixer. The mixer handles playing it. 105 | done := make(chan bool) 106 | mixer.Add(beep.Seq(resampled, beep.Callback(func() { 107 | done <- true 108 | }))) 109 | 110 | // Wait for this specific sound to finish. 111 | <-done 112 | } 113 | -------------------------------------------------------------------------------- /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 | -------------------------------------------------------------------------------- /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 | -------------------------------------------------------------------------------- /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.InfoIcon(), 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 | -------------------------------------------------------------------------------- /logo.jpeg: -------------------------------------------------------------------------------- 1 | version https://git-lfs.github.com/spec/v1 2 | oid sha256:494b498aac7634d722c3252efb072016043012a47e666f0a369bca8f08e7f96e 3 | size 34487 4 | -------------------------------------------------------------------------------- /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/rs/zerolog" 10 | "github.com/rs/zerolog/log" 11 | ) 12 | 13 | func main() { 14 | configureLogLevelFromEnv() 15 | 16 | stopChan := setupInterruptListener() 17 | go handleInterrupt(stopChan, 18 | func(msg string) { log.Fatal().Msg(msg) }, 19 | os.Exit, 20 | ) 21 | execute() 22 | } 23 | 24 | func configureLogLevelFromEnv() { 25 | debugMode := strings.TrimSpace(strings.ToLower(os.Getenv("DEBUG_GOGG"))) 26 | switch debugMode { 27 | case "false", "0", "": 28 | zerolog.SetGlobalLevel(zerolog.Disabled) 29 | default: 30 | zerolog.SetGlobalLevel(zerolog.DebugLevel) 31 | } 32 | } 33 | 34 | func setupInterruptListener() chan os.Signal { 35 | stopChan := make(chan os.Signal, 1) 36 | signal.Notify(stopChan, os.Interrupt) 37 | return stopChan 38 | } 39 | 40 | func handleInterrupt(stopChan chan os.Signal, fatalLog func(string), exitFunc func(int)) { 41 | <-stopChan 42 | fatalLog("Interrupt signal received. Exiting...") 43 | } 44 | 45 | func execute() { 46 | cmd.Execute() 47 | } 48 | -------------------------------------------------------------------------------- /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 | testCases := []struct { 16 | envVal string 17 | expectedLvl zerolog.Level 18 | }{ 19 | {"false", zerolog.Disabled}, 20 | {"0", zerolog.Disabled}, 21 | {"", zerolog.Disabled}, 22 | } 23 | 24 | for _, tc := range testCases { 25 | os.Setenv("DEBUG_GOGG", tc.envVal) 26 | configureLogLevelFromEnv() 27 | if zerolog.GlobalLevel() != tc.expectedLvl { 28 | t.Errorf("DEBUG_GOGG=%q: expected log level %v, got %v", 29 | tc.envVal, tc.expectedLvl, zerolog.GlobalLevel()) 30 | } 31 | } 32 | } 33 | 34 | func TestConfigureLogLevelFromEnv_Debug(t *testing.T) { 35 | originalLevel := zerolog.GlobalLevel() 36 | t.Cleanup(func() { zerolog.SetGlobalLevel(originalLevel) }) 37 | 38 | testCases := []struct { 39 | envVal string 40 | expectedLvl zerolog.Level 41 | }{ 42 | {"true", zerolog.DebugLevel}, 43 | {"1", zerolog.DebugLevel}, 44 | {"random", zerolog.DebugLevel}, 45 | } 46 | 47 | for _, tc := range testCases { 48 | os.Setenv("DEBUG_GOGG", tc.envVal) 49 | configureLogLevelFromEnv() 50 | if zerolog.GlobalLevel() != tc.expectedLvl { 51 | t.Errorf("DEBUG_GOGG=%q: expected log level %v, got %v", 52 | tc.envVal, tc.expectedLvl, zerolog.GlobalLevel()) 53 | } 54 | } 55 | } 56 | 57 | func TestSetupInterruptListener(t *testing.T) { 58 | stopChan := setupInterruptListener() 59 | if stopChan == nil { 60 | t.Error("expected non-nil channel from setupInterruptListener") 61 | } 62 | 63 | go func() { 64 | time.Sleep(10 * time.Millisecond) 65 | stopChan <- os.Interrupt 66 | }() 67 | 68 | select { 69 | case sig := <-stopChan: 70 | if sig != os.Interrupt { 71 | t.Errorf("expected os.Interrupt, got %v", sig) 72 | } 73 | case <-time.After(100 * time.Millisecond): 74 | t.Error("did not receive signal on channel") 75 | } 76 | } 77 | 78 | func TestHandleInterrupt(t *testing.T) { 79 | stopChan := make(chan os.Signal, 1) 80 | exitCalled := make(chan int, 1) 81 | var loggedMessage string 82 | 83 | fakeFatalLog := func(msg string) { 84 | loggedMessage = msg 85 | // In a real scenario log.Fatal would os.Exit, so we simulate the exit part 86 | exitCalled <- 1 87 | } 88 | // We don't need a fake exit anymore since log.Fatal now handles it. 89 | fakeExit := func(code int) { 90 | // This function is no longer called by the modified handleInterrupt 91 | } 92 | 93 | go handleInterrupt(stopChan, fakeFatalLog, fakeExit) 94 | 95 | stopChan <- os.Interrupt 96 | 97 | select { 98 | case code := <-exitCalled: 99 | if code != 1 { 100 | t.Errorf("expected exit code 1, got %d", code) 101 | } 102 | expectedMsg := "Interrupt signal received. Exiting..." 103 | if loggedMessage != expectedMsg { 104 | t.Errorf("expected log message %q, got %q", expectedMsg, loggedMessage) 105 | } 106 | case <-time.After(100 * time.Millisecond): 107 | t.Error("fatal log function was not called on interrupt") 108 | } 109 | } 110 | -------------------------------------------------------------------------------- /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 | -------------------------------------------------------------------------------- /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 | -------------------------------------------------------------------------------- /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 | file, err := os.Open(filePath) 71 | if err != nil { 72 | results <- HashResult{File: filePath, Err: err} 73 | continue 74 | } 75 | 76 | hash, err := hasher.GenerateHashFromReader(file, algo) 77 | file.Close() // Close the file handle 78 | results <- HashResult{File: filePath, Hash: hash, Err: err} 79 | } 80 | }() 81 | } 82 | 83 | for _, f := range files { 84 | tasks <- f 85 | } 86 | close(tasks) 87 | 88 | go func() { 89 | wg.Wait() 90 | close(results) 91 | }() 92 | 93 | return results 94 | } 95 | 96 | // CleanHashes walks a directory and removes files with extensions matching known hash algorithms. 97 | func CleanHashes(dir string, recursive bool) error { 98 | return filepath.Walk(dir, func(path string, info os.FileInfo, err error) error { 99 | if err != nil { 100 | return err 101 | } 102 | if info.IsDir() && !recursive && path != dir { 103 | return filepath.SkipDir 104 | } 105 | for _, algo := range hasher.HashAlgorithms { 106 | if strings.HasSuffix(info.Name(), "."+algo) { 107 | if err := os.Remove(path); err != nil { 108 | log.Warn().Err(err).Str("path", path).Msg("Failed to remove old hash file") 109 | } 110 | break 111 | } 112 | } 113 | return nil 114 | }) 115 | } 116 | -------------------------------------------------------------------------------- /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 | -------------------------------------------------------------------------------- /pkg/operations/storage.go: -------------------------------------------------------------------------------- 1 | package operations 2 | 3 | import ( 4 | "encoding/json" 5 | "fmt" 6 | "github.com/habedi/gogg/client" 7 | "github.com/habedi/gogg/db" 8 | ) 9 | 10 | // EstimationParams contains all parameters for calculating storage size. 11 | type EstimationParams struct { 12 | LanguageCode string 13 | PlatformName string 14 | IncludeExtras bool 15 | IncludeDLCs bool 16 | } 17 | 18 | // EstimateGameSize retrieves a game by ID and calculates its estimated download size. 19 | func EstimateGameSize(gameID int, params EstimationParams) (int64, *client.Game, error) { 20 | game, err := db.GetGameByID(gameID) 21 | if err != nil { 22 | return 0, nil, fmt.Errorf("failed to retrieve game data for ID %d: %w", gameID, err) 23 | } 24 | if game == nil { 25 | return 0, nil, fmt.Errorf("game with ID %d not found in the catalogue", gameID) 26 | } 27 | 28 | var nestedData client.Game 29 | if err := json.Unmarshal([]byte(game.Data), &nestedData); err != nil { 30 | return 0, nil, fmt.Errorf("failed to unmarshal game data for ID %d: %w", gameID, err) 31 | } 32 | 33 | langFullName, ok := client.GameLanguages[params.LanguageCode] 34 | if !ok { 35 | return 0, &nestedData, fmt.Errorf("invalid language code: %s", params.LanguageCode) 36 | } 37 | 38 | totalSizeBytes, err := nestedData.EstimateStorageSize(langFullName, params.PlatformName, params.IncludeExtras, params.IncludeDLCs) 39 | if err != nil { 40 | return 0, &nestedData, fmt.Errorf("failed to calculate storage size: %w", err) 41 | } 42 | 43 | return totalSizeBytes, &nestedData, nil 44 | } 45 | -------------------------------------------------------------------------------- /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 | -------------------------------------------------------------------------------- /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 | for _, item := range items { 36 | select { 37 | case taskChan <- item: 38 | case <-ctx.Done(): 39 | // Stop feeding tasks if the context is cancelled. 40 | break 41 | } 42 | } 43 | close(taskChan) 44 | 45 | wg.Wait() 46 | close(errChan) 47 | 48 | var allErrors []error 49 | for err := range errChan { 50 | allErrors = append(allErrors, err) 51 | } 52 | return allErrors 53 | } 54 | -------------------------------------------------------------------------------- /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 | -------------------------------------------------------------------------------- /pyproject.toml: -------------------------------------------------------------------------------- 1 | [project] 2 | name = "gogg" 3 | version = "0.1.0" 4 | description = "Python environment for Gogg" 5 | readme = "README.md" 6 | license = { text = "MIT" } 7 | authors = [ 8 | { name = "Hassan Abedi", email = "hassan.abedi.t@gmail.com" } 9 | ] 10 | 11 | requires-python = ">=3.10,<4.0" 12 | dependencies = [ 13 | "python-dotenv (>=1.1.0,<2.0.0)", 14 | "pre-commit (>=4.2.0,<5.0.0)" 15 | ] 16 | 17 | [project.optional-dependencies] 18 | dev = [ 19 | "pytest>=8.0.1", 20 | "pytest-cov>=6.0.0", 21 | "pytest-mock>=3.14.0", 22 | "pytest-asyncio (>=0.26.0,<0.27.0)", 23 | "mypy>=1.11.1", 24 | "ruff>=0.9.3", 25 | "icecream (>=2.1.4,<3.0.0)" 26 | ] 27 | -------------------------------------------------------------------------------- /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 | -------------------------------------------------------------------------------- /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 | -------------------------------------------------------------------------------- /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 | 28 | # --- Final Success Message --- 29 | echo -e "\n${GREEN}===== Makefile Test Suite Completed Successfully! =====${NC}" 30 | --------------------------------------------------------------------------------